/** * API Tokens settings page — Bearer tokens for programmatic access * (UI-Backlog module 3/16). * * Backend: /api/v1/tokens (create, list, revoke). The plaintext token is * returned ONCE at creation — shown once with copy button, never again. * Permissions: mcp:read (list) / mcp:write (create, revoke). */ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { KeyRound, Plus, Trash2, Copy, Check, Inbox, AlertTriangle, } from 'lucide-react'; import { useApiTokens, useCreateApiToken, useRevokeApiToken, type ApiTokenInfo, } from '@/api/apiTokens'; import { usePermission } from '@/hooks/usePermission'; import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; import { Modal } from '@/components/ui/Modal'; import { Input } from '@/components/ui/Input'; import { Badge } from '@/components/ui/Badge'; function formatDateTime(iso: string | null): string { if (!iso) return '—'; try { return new Date(iso).toLocaleString(); } catch { return iso; } } function TokenCard({ token, canWrite, onRevoke, isMutating, }: { token: ApiTokenInfo; canWrite: boolean; onRevoke: (token: ApiTokenInfo) => void; isMutating: boolean; }) { const { t } = useTranslation(); const expired = token.expires_at !== null && new Date(token.expires_at) < new Date(); return ( {token.name} {expired && ( {t('apiTokens.expiredBadge')} )} {token.scopes.map((scope) => ( {scope} ))} {t('apiTokens.created')} {formatDateTime(token.created_at)} ·{' '} {t('apiTokens.expires')} {formatDateTime(token.expires_at)} ·{' '} {t('apiTokens.lastUsed')} {formatDateTime(token.last_used_at)} {canWrite && ( onRevoke(token)} disabled={isMutating} aria-label={t('apiTokens.revoke')} data-testid={`api-token-revoke-${token.id}`} className="text-danger-600 hover:text-danger-700" > )} ); } function CreateTokenDialog({ open, onClose, onSubmit, isSubmitting, }: { open: boolean; onClose: () => void; onSubmit: (payload: { name: string; scopes?: string[]; expires_in_days?: number | null }) => void; isSubmitting: boolean; }) { const { t } = useTranslation(); const [name, setName] = useState(''); const [scopes, setScopes] = useState(''); const [expiresInDays, setExpiresInDays] = useState(''); const parsedScopes = scopes .split(',') .map((s) => s.trim()) .filter(Boolean); const parsedDays = expiresInDays ? parseInt(expiresInDays, 10) : null; const valid = name.trim().length > 0 && (parsedDays === null || (!isNaN(parsedDays) && parsedDays > 0)); const submit = () => { if (!valid) return; onSubmit({ name: name.trim(), scopes: parsedScopes, expires_in_days: parsedDays, }); }; return ( setName(e.target.value)} required placeholder={t('apiTokens.namePlaceholder')} /> setScopes(e.target.value)} placeholder={t('apiTokens.scopesPlaceholder')} helperText={t('apiTokens.scopesHelper')} /> setExpiresInDays(e.target.value)} placeholder={t('apiTokens.expiresPlaceholder')} /> {t('common.cancel')} {t('apiTokens.createSubmit')} ); } function TokenRevealDialog({ token, onClose, }: { token: string | null; onClose: () => void; }) { const { t } = useTranslation(); const [copied, setCopied] = useState(false); const copy = async () => { if (!token) return; try { await navigator.clipboard.writeText(token); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch { // clipboard API unavailable — user can select manually } }; return ( {t('apiTokens.revealWarning')} {token} {copied ? : } {t('apiTokens.done')} ); } export function ApiTokensPage() { const { t } = useTranslation(); const [showCreate, setShowCreate] = useState(false); const [revealedToken, setRevealedToken] = useState(null); const { data, isLoading, isError } = useApiTokens(); const createMut = useCreateApiToken(); const revokeMut = useRevokeApiToken(); const { hasPermission } = usePermission(); const canWrite = hasPermission('mcp:write'); const isMutating = createMut.isPending || revokeMut.isPending; const handleSubmit = (payload: { name: string; scopes?: string[]; expires_in_days?: number | null }) => { createMut.mutate(payload, { onSuccess: (result) => { setShowCreate(false); setRevealedToken(result.token); }, }); }; const handleRevoke = (token: ApiTokenInfo) => { if (window.confirm(t('apiTokens.revokeConfirm', { name: token.name }))) { revokeMut.mutate(token.id); } }; const items = data?.items ?? []; return ( {t('apiTokens.title')} {canWrite && ( setShowCreate(true)} data-testid="api-token-create-open"> {t('apiTokens.create')} )} {isLoading && ( )} {isError && ( {t('apiTokens.loadError')} )} {!isLoading && !isError && items.length === 0 && ( {t('apiTokens.empty')} )} {items.map((token) => ( ))} setShowCreate(false)} onSubmit={handleSubmit} isSubmitting={createMut.isPending} /> setRevealedToken(null)} /> ); } export default ApiTokensPage;
{t('apiTokens.revealWarning')}
{token}
{t('apiTokens.empty')}