diff --git a/webui/src/components/copy-to-clipboard.tsx b/webui/src/components/copy-to-clipboard.tsx deleted file mode 100644 index f44e89a91..000000000 --- a/webui/src/components/copy-to-clipboard.tsx +++ /dev/null @@ -1,50 +0,0 @@ -/******************************************************************************** - * Copyright (c) 2020 TypeFox and others - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License v. 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. - * - * SPDX-License-Identifier: EPL-2.0 - ********************************************************************************/ - -import { FunctionComponent, ReactElement, useState } from 'react'; -import copy from 'clipboard-copy'; -import { Tooltip, TooltipProps } from '@mui/material'; - -export const CopyToClipboard: FunctionComponent = props => { - const [showTooltip, setTooltip] = useState(false); - - const handleOnTooltipClose = () => { - setTooltip(false); - }; - - const onCopy = (content: any) => { - copy(content); - setTooltip(true); - }; - - return ( - - { - props.children({ copy: onCopy }) as ReactElement - } - - ); -}; - -interface ChildProps { - copy: (content: any) => void; -} - -export interface CopyToClipboardProps { - tooltipProps?: Partial; - children: (props: ChildProps) => ReactElement; -} \ No newline at end of file diff --git a/webui/src/components/generate-token-dialog.tsx b/webui/src/components/generate-token-dialog.tsx new file mode 100644 index 000000000..531096576 --- /dev/null +++ b/webui/src/components/generate-token-dialog.tsx @@ -0,0 +1,164 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import { ChangeEvent, FunctionComponent, useState } from 'react'; +import { + Button, Dialog, DialogTitle, DialogContent, DialogContentText, + Box, TextField, DialogActions, Typography, Paper, IconButton, + Tooltip, LinearProgress, styled +} from '@mui/material'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import copy from 'clipboard-copy'; + +const TOKEN_DESCRIPTION_SIZE = 255; + +const StyledDialog = styled(Dialog)({ + '& .MuiDialog-paper': { + width: 480, + } +}); + +const TokenDisplay = styled(Paper)(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + padding: theme.spacing(1.5), + wordBreak: 'break-all', + fontFamily: 'monospace', + fontSize: '0.85rem', + backgroundColor: theme.palette.mode === 'dark' + ? theme.palette.grey[900] + : theme.palette.grey[100], +})); + +export const GenerateTokenDialog: FunctionComponent = props => { + const [loading, setLoading] = useState(false); + const [description, setDescription] = useState(''); + const [descriptionError, setDescriptionError] = useState(); + const [tokenValue, setTokenValue] = useState(); + const [copied, setCopied] = useState(false); + + const { open, onClose, title = 'Generate new token' } = props; + + const resetState = () => { + setLoading(false); + setDescription(''); + setDescriptionError(undefined); + setTokenValue(undefined); + setCopied(false); + }; + + const handleClose = () => { + if (!loading) { + resetState(); + onClose(); + } + }; + + const handleDescriptionChange = (event: ChangeEvent) => { + const value = event.target.value; + setDescription(value); + setDescriptionError( + value.length > TOKEN_DESCRIPTION_SIZE + ? `Description must not exceed ${TOKEN_DESCRIPTION_SIZE} characters.` + : undefined + ); + }; + + const handleGenerate = async () => { + setLoading(true); + try { + const value = await props.onGenerate(description); + setTokenValue(value); + } catch (err) { + props.onError?.(err); + } finally { + setLoading(false); + } + }; + + const handleCopy = () => { + if (tokenValue) { + copy(tokenValue); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + const canGenerate = !descriptionError && !loading; + + return + {title} + {loading && } + + {tokenValue ? <> + + Copy this token now. It will not be shown again. + + + + {tokenValue} + + + + + + + + : <> + + You can add an optional description to help identify where this token is used. + + + { + if (e.key === 'Enter' && canGenerate) { + handleGenerate(); + } + }} + /> + + } + + + + {!tokenValue && + + } + + ; +}; + +export interface GenerateTokenDialogProps { + open: boolean; + onClose: () => void; + onGenerate: (description: string) => Promise; + onError?: (err: unknown) => void; + title?: string; +} diff --git a/webui/src/extension-registry-service.ts b/webui/src/extension-registry-service.ts index aaf8a6d55..c5526cfac 100644 --- a/webui/src/extension-registry-service.ts +++ b/webui/src/extension-registry-service.ts @@ -533,6 +533,9 @@ export interface AdminService { removeCustomerMember(abortController: AbortController, name: string, user: UserData): Promise>; getUsageStats(abortController: AbortController, customerName: string, date: Date): Promise>; getLogs(abortController: AbortController, page?: number, size?: number, period?: string): Promise>; + getCustomerTokens(abortController: AbortController, customerName: string): Promise>; + createCustomerToken(abortController: AbortController, customerName: string, description: string): Promise>; + deleteCustomerToken(abortController: AbortController, customerName: string, tokenId: number): Promise>; } export interface AdminServiceConstructor { @@ -1065,6 +1068,36 @@ export class AdminServiceImpl implements AdminService { credentials: true }, false); } + + // TODO: Replace with real endpoints when backend is ready + private static fakeTokens: CustomerAccessToken[] = [ + { id: 1, description: 'token 1', createdTimestamp: '2026-01-15T10:30:00Z' }, + { id: 2, description: 'token 2', createdTimestamp: '2026-02-20T14:00:00Z' }, + ]; + private static nextTokenId = 3; + + async getCustomerTokens(_abortController: AbortController, _customerName: string): Promise> { + await new Promise(r => setTimeout(r, 300)); + return AdminServiceImpl.fakeTokens; + } + + async createCustomerToken(_abortController: AbortController, _customerName: string, description: string): Promise> { + await new Promise(r => setTimeout(r, 500)); + const token: CustomerAccessToken = { + id: AdminServiceImpl.nextTokenId++, + value: 'cust_' + crypto.randomUUID().replace(/-/g, ''), + description: description || 'Unnamed token', + createdTimestamp: new Date().toISOString(), + }; + AdminServiceImpl.fakeTokens = [...AdminServiceImpl.fakeTokens, token]; + return token; + } + + async deleteCustomerToken(_abortController: AbortController, _customerName: string, tokenId: number): Promise> { + await new Promise(r => setTimeout(r, 300)); + AdminServiceImpl.fakeTokens = AdminServiceImpl.fakeTokens.filter(t => t.id !== tokenId); + return { success: 'Token deleted' }; + } } export interface ExtensionFilter { diff --git a/webui/src/extension-registry-types.ts b/webui/src/extension-registry-types.ts index be2c9ba28..94007aacf 100644 --- a/webui/src/extension-registry-types.ts +++ b/webui/src/extension-registry-types.ts @@ -467,6 +467,13 @@ export interface CustomerMembershipList { customerMemberships: CustomerMembership[]; } +export interface CustomerAccessToken { + id: number; + value?: string; + description: string; + createdTimestamp: TimestampString; +} + export interface UsageStats { windowStart: number; // epoch seconds in UTC duration: number; // in seconds diff --git a/webui/src/pages/admin-dashboard/customers/customer-details.tsx b/webui/src/pages/admin-dashboard/customers/customer-details.tsx index d563066c0..12cbccee5 100644 --- a/webui/src/pages/admin-dashboard/customers/customer-details.tsx +++ b/webui/src/pages/admin-dashboard/customers/customer-details.tsx @@ -28,6 +28,7 @@ import { useAdminUsageStats } from '../usage-stats/use-usage-stats'; import { GeneralDetails, UsageStats } from '../../../components/rate-limiting/customer'; import { CustomerFormDialog } from './customer-form-dialog'; import { CustomerMemberList } from './customer-member-list'; +import { CustomerTokenList } from './customer-token-list'; const CustomerDetailsLoading: FC = () => ( @@ -115,6 +116,9 @@ export const CustomerDetails: FC = () => { + diff --git a/webui/src/pages/admin-dashboard/customers/customer-token-list.tsx b/webui/src/pages/admin-dashboard/customers/customer-token-list.tsx new file mode 100644 index 000000000..492d48673 --- /dev/null +++ b/webui/src/pages/admin-dashboard/customers/customer-token-list.tsx @@ -0,0 +1,130 @@ +/****************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + *****************************************************************************/ + +import { FunctionComponent, useEffect, useState, useContext, useRef } from 'react'; +import { + Box, + Typography, + Divider, + List, + ListItem, + ListItemText, + IconButton, + Paper, + Button, + type PaperProps +} from '@mui/material'; +import DeleteIcon from '@mui/icons-material/Delete'; +import AddIcon from '@mui/icons-material/Add'; +import { MainContext } from '../../../context'; +import { Customer, CustomerAccessToken, isError } from '../../../extension-registry-types'; +import { GenerateTokenDialog } from '../../../components/generate-token-dialog'; + +const sectionPaperProps: PaperProps = { elevation: 1, sx: { p: 3, mb: 3 } }; + +export const CustomerTokenList: FunctionComponent = props => { + const { service, handleError } = useContext(MainContext); + const [tokens, setTokens] = useState([]); + const [dialogOpen, setDialogOpen] = useState(false); + const abortController = useRef(new AbortController()); + + useEffect(() => { + fetchTokens(); + }, [props.customer]); + + useEffect(() => { + return () => { + abortController.current.abort(); + }; + }, []); + + const fetchTokens = async () => { + try { + const result = await service.admin.getCustomerTokens(abortController.current, props.customer.name); + setTokens([...result]); + } catch (err) { + handleError(err); + } + }; + + const handleGenerate = async (description: string): Promise => { + const token = await service.admin.createCustomerToken(abortController.current, props.customer.name, description); + await fetchTokens(); + return token.value ?? ''; + }; + + const handleDelete = async (tokenId: number) => { + try { + const result = await service.admin.deleteCustomerToken(abortController.current, props.customer.name, tokenId); + if (isError(result)) { + throw result; + } + await fetchTokens(); + } catch (err) { + handleError(err); + } + }; + + return + + Tokens + + + + {tokens.length === 0 ? ( + + No rate limiting tokens for this customer. + + ) : ( + + {tokens.map(token => ( + handleDelete(token.id)} title='Delete token'> + + + } + > + + + ))} + + )} + + setDialogOpen(false)} + onGenerate={handleGenerate} + onError={handleError} + title='Generate token' + /> + ; +}; + +export interface CustomerTokenListProps { + customer: Customer; +} diff --git a/webui/src/pages/user/generate-token-dialog.tsx b/webui/src/pages/user/generate-token-dialog.tsx index 9a808864f..ca6d21e2a 100644 --- a/webui/src/pages/user/generate-token-dialog.tsx +++ b/webui/src/pages/user/generate-token-dialog.tsx @@ -8,147 +8,39 @@ * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ -import { ChangeEvent, FunctionComponent, useContext, useEffect, useState, useRef } from 'react'; -import { Button, Dialog, DialogTitle, DialogContent, DialogContentText, Box, TextField, DialogActions, Typography } from '@mui/material'; -import { ButtonWithProgress } from '../../components/button-with-progress'; -import { CopyToClipboard } from '../../components/copy-to-clipboard'; -import { PersonalAccessToken, isError } from '../../extension-registry-types'; +import { FunctionComponent, useContext, useRef, useState } from 'react'; +import { Button } from '@mui/material'; +import { GenerateTokenDialog as BaseGenerateTokenDialog } from '../../components/generate-token-dialog'; +import { isError } from '../../extension-registry-types'; import { MainContext } from '../../context'; -const TOKEN_DESCRIPTION_SIZE = 255; - export const GenerateTokenDialog: FunctionComponent = props => { - const [open, setOpen] = useState(false); - const [posted, setPosted] = useState(false); - const [description, setDescription] = useState(''); - const [descriptionError, setDescriptionError] = useState(); - const [token, setToken] = useState(); - const context = useContext(MainContext); const abortController = useRef(new AbortController()); + const [open, setOpen] = useState(false); - useEffect(() => { - document.addEventListener('keydown', handleEnter); - return () => { - abortController.current.abort(); - document.removeEventListener('keydown', handleEnter); - }; - }, []); - - const handleOpenDialog = () => { - setOpen(true); - setPosted(false); - setDescription(''); - setToken(undefined); - }; - - const handleClose = () => setOpen(false); - - const handleDescriptionChange = (event: ChangeEvent) => { - const description = event.target.value; - let descriptionError: string | undefined; - if (description.length > TOKEN_DESCRIPTION_SIZE) { - descriptionError = `The description must not be longer than ${TOKEN_DESCRIPTION_SIZE} characters.`; - } - - setDescription(description); - setDescriptionError(descriptionError); - }; - - const handleGenerate = async () => { + const handleGenerate = async (description: string): Promise => { if (!context.user) { - return; + throw new Error('Not logged in'); } - setPosted(true); - try { - const token = await context.service.createAccessToken(abortController.current, context.user, description); - if (isError(token)) { - throw token; - } - setToken(token); - props.handleTokenGenerated(); - } catch (err) { - context.handleError(err); + const token = await context.service.createAccessToken(abortController.current, context.user, description); + if (isError(token)) { + throw token; } - }; - const handleEnter = (e: KeyboardEvent) => { - if (e.code === 'Enter' && open && !token) { - handleGenerate(); - } + props.handleTokenGenerated(); + + return token.value!; }; return <> - - - Generate new token - - - Describe where you will use this token. - - - - - - { - token ? - - - Copy and paste this token to a safe place. It will not be displayed again. - - : null - } - - - { - token ? - - {({ copy }) => ( - - )} - : null - } - - { - !token ? - - Generate Token - : null - } - - + + setOpen(false)} + onGenerate={handleGenerate} + onError={context.handleError} + /> ; };