Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 0 additions & 50 deletions webui/src/components/copy-to-clipboard.tsx

This file was deleted.

164 changes: 164 additions & 0 deletions webui/src/components/generate-token-dialog.tsx
Original file line number Diff line number Diff line change
@@ -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<GenerateTokenDialogProps> = props => {
const [loading, setLoading] = useState(false);
const [description, setDescription] = useState('');
const [descriptionError, setDescriptionError] = useState<string>();
const [tokenValue, setTokenValue] = useState<string>();
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<HTMLInputElement>) => {
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 <StyledDialog open={open} onClose={handleClose}>
<DialogTitle>{title}</DialogTitle>
{loading && <LinearProgress color='secondary' />}
<DialogContent>
{tokenValue ? <>
<DialogContentText sx={{ mb: 2 }}>
Copy this token now. It will not be shown again.
</DialogContentText>
<TokenDisplay variant='outlined'>
<Typography component='span' sx={{ flex: 1, fontFamily: 'inherit', fontSize: 'inherit' }}>
{tokenValue}
</Typography>
<Tooltip title={copied ? 'Copied!' : 'Copy'} placement='top'>
<IconButton size='small' onClick={handleCopy}>
<ContentCopyIcon fontSize='small' />
</IconButton>
</Tooltip>
</TokenDisplay>
</> : <>
<DialogContentText>
You can add an optional description to help identify where this token is used.
</DialogContentText>
<Box mt={2}>
<TextField
autoFocus
fullWidth
label='Description (optional)'
error={Boolean(descriptionError)}
helperText={descriptionError}
value={description}
onChange={handleDescriptionChange}
onKeyDown={e => {
if (e.key === 'Enter' && canGenerate) {
handleGenerate();
}
}}
/>
</Box>
</>}
</DialogContent>
<DialogActions>
<Button onClick={handleClose} color='secondary'>
{tokenValue ? 'Close' : 'Cancel'}
</Button>
{!tokenValue &&
<Button
variant='contained'
color='secondary'
disabled={!canGenerate}
onClick={handleGenerate}
>
Generate Token
</Button>
}
</DialogActions>
</StyledDialog>;
};

export interface GenerateTokenDialogProps {
open: boolean;
onClose: () => void;
onGenerate: (description: string) => Promise<string>;
onError?: (err: unknown) => void;
title?: string;
}
33 changes: 33 additions & 0 deletions webui/src/extension-registry-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,9 @@ export interface AdminService {
removeCustomerMember(abortController: AbortController, name: string, user: UserData): Promise<Readonly<SuccessResult | ErrorResult>>;
getUsageStats(abortController: AbortController, customerName: string, date: Date): Promise<Readonly<UsageStatsList>>;
getLogs(abortController: AbortController, page?: number, size?: number, period?: string): Promise<Readonly<LogPageableList>>;
getCustomerTokens(abortController: AbortController, customerName: string): Promise<Readonly<CustomerAccessToken[]>>;
createCustomerToken(abortController: AbortController, customerName: string, description: string): Promise<Readonly<CustomerAccessToken>>;
deleteCustomerToken(abortController: AbortController, customerName: string, tokenId: number): Promise<Readonly<SuccessResult | ErrorResult>>;
}

export interface AdminServiceConstructor {
Expand Down Expand Up @@ -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<Readonly<CustomerAccessToken[]>> {
await new Promise(r => setTimeout(r, 300));
return AdminServiceImpl.fakeTokens;
}

async createCustomerToken(_abortController: AbortController, _customerName: string, description: string): Promise<Readonly<CustomerAccessToken>> {
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<Readonly<SuccessResult | ErrorResult>> {
await new Promise(r => setTimeout(r, 300));
AdminServiceImpl.fakeTokens = AdminServiceImpl.fakeTokens.filter(t => t.id !== tokenId);
return { success: 'Token deleted' };
}
}

export interface ExtensionFilter {
Expand Down
7 changes: 7 additions & 0 deletions webui/src/extension-registry-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => (
<Box sx={{ p: 3 }}>
Expand Down Expand Up @@ -115,6 +116,9 @@ export const CustomerDetails: FC = () => {
<CustomerMemberList
customer={customer}
/>
<CustomerTokenList
customer={customer}
/>
<UsageStats usageStats={usageStats} customer={customer} startDate={startDate} onStartDateChange={setStartDate} />
</Box>

Expand Down
Loading