diff --git a/frontend/src/__tests__/pages/ApiTokens.test.tsx b/frontend/src/__tests__/pages/ApiTokens.test.tsx new file mode 100644 index 0000000..0bc43d1 --- /dev/null +++ b/frontend/src/__tests__/pages/ApiTokens.test.tsx @@ -0,0 +1,157 @@ +/** + * ApiTokens page tests — Bearer token management (module 3/16). + * + * Covers: rendering, token cards with scopes/expiry, create flow with + * ONE-TIME plaintext reveal, copy button, revoke with confirmation, + * permission gating (mcp:write), empty and error states. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { ApiTokensPage } from '@/pages/ApiTokens'; +import type { ApiTokenInfo } from '@/api/apiTokens'; + +const createMut = vi.fn().mockImplementation((_payload, opts) => { + opts?.onSuccess?.({ + id: 'tk-new', token: 'secret-plaintext-token', name: 'CI', scopes: [], + expires_at: null, last_used_at: null, created_at: '2026-09-13T09:00:00Z', + }); + return Promise.resolve({}); +}); +const revokeMut = vi.fn().mockResolvedValue({}); + +const makeToken = (overrides: Partial = {}): ApiTokenInfo => ({ + id: 'tk-1', + name: 'CI Integration', + scopes: ['contacts:read', 'mail:read'], + expires_at: null, + last_used_at: null, + created_at: '2026-09-01T10:00:00Z', + ...overrides, +}); + +let mockItems: ApiTokenInfo[] = []; +let mockCanWrite = true; +let mockError = false; + +vi.mock('@/api/apiTokens', () => ({ + useApiTokens: () => ({ + data: { items: mockItems, total: mockItems.length }, + isLoading: false, + isError: mockError, + isFetching: false, + refetch: vi.fn(), + }), + useCreateApiToken: () => ({ + mutate: createMut, + isPending: false, + }), + useRevokeApiToken: () => ({ + mutate: revokeMut, + isPending: false, + }), +})); + +vi.mock('@/hooks/usePermission', () => ({ + usePermission: () => ({ + hasPermission: (perm: string) => mockCanWrite || perm !== 'mcp:write', + }), +})); + +function renderPage() { + return render( + + + , + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockItems = []; + mockCanWrite = true; + mockError = false; +}); + +describe('ApiTokensPage', () => { + it('renders the page with title', () => { + renderPage(); + expect(screen.getByTestId('api-tokens-page')).toBeInTheDocument(); + }); + + it('shows empty state when no tokens exist', () => { + renderPage(); + expect(screen.getByTestId('api-tokens-empty')).toBeInTheDocument(); + }); + + it('shows error state on load failure', () => { + mockError = true; + renderPage(); + expect(screen.getByTestId('api-tokens-error')).toBeInTheDocument(); + }); + + it('renders token cards with scopes', () => { + mockItems = [makeToken()]; + renderPage(); + expect(screen.getByTestId('api-token-card-tk-1')).toBeInTheDocument(); + expect(screen.getByText('CI Integration')).toBeInTheDocument(); + expect(screen.getByText('contacts:read')).toBeInTheDocument(); + expect(screen.getByText('mail:read')).toBeInTheDocument(); + }); + + it('shows expired badge for expired tokens', () => { + mockItems = [makeToken({ expires_at: '2020-01-01T00:00:00Z' })]; + renderPage(); + expect(screen.getByTestId(`api-token-expired-tk-1`)).toBeInTheDocument(); + }); + + it('hides create + revoke without mcp:write permission', () => { + mockItems = [makeToken()]; + mockCanWrite = false; + const { container } = renderPage(); + expect(screen.queryByTestId('api-token-create-open')).not.toBeInTheDocument(); + expect(screen.queryByTestId('api-token-revoke-tk-1')).not.toBeInTheDocument(); + expect(container).toBeTruthy(); + }); + + it('creates a token and reveals the plaintext ONCE with copy button', async () => { + renderPage(); + fireEvent.click(screen.getByTestId('api-token-create-open')); + expect(screen.getByTestId('api-token-create-submit')).toBeInTheDocument(); + + const nameInput = screen.getByLabelText(/name/i, { selector: 'input' }) as HTMLInputElement; + fireEvent.change(nameInput, { target: { value: 'CI' } }); + fireEvent.change(screen.getByLabelText(/scopes/i, { selector: 'input' }), { + target: { value: 'contacts:read, mail:read' }, + }); + + fireEvent.click(screen.getByTestId('api-token-create-submit')); + + await waitFor(() => { + expect(createMut).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'CI', + scopes: ['contacts:read', 'mail:read'], + }), + expect.anything(), + ); + }); + + // ONE-TIME reveal dialog shows the plaintext token + expect(await screen.findByTestId('api-token-plaintext')).toHaveTextContent( + 'secret-plaintext-token', + ); + expect(screen.getByTestId('api-token-copy')).toBeInTheDocument(); + }); + + it('revokes a token after confirmation', async () => { + mockItems = [makeToken()]; + window.confirm = vi.fn(() => true); + renderPage(); + fireEvent.click(screen.getByTestId('api-token-revoke-tk-1')); + await waitFor(() => { + expect(revokeMut).toHaveBeenCalledWith('tk-1'); + }); + }); +}); diff --git a/frontend/src/api/apiTokens.ts b/frontend/src/api/apiTokens.ts new file mode 100644 index 0000000..f6d06e5 --- /dev/null +++ b/frontend/src/api/apiTokens.ts @@ -0,0 +1,66 @@ +/** + * API Tokens client — Bearer tokens for programmatic access. + * + * Backend: /api/v1/tokens (create, list, revoke). + * The plaintext token is returned ONCE at creation — never stored. + * Permissions: mcp:read (list) / mcp:write (create, revoke). + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { apiGet, apiPost, apiDelete } from '@/api/client'; + +export interface ApiTokenInfo { + id: string; + name: string; + scopes: string[]; + expires_at: string | null; + last_used_at: string | null; + created_at: string | null; +} + +export interface ApiTokenListResponse { + items: ApiTokenInfo[]; + total: number; +} + +/** Response of the create endpoint — includes the plaintext token ONCE. */ +export interface ApiTokenCreated extends ApiTokenInfo { + token: string; +} + +export interface ApiTokenCreatePayload { + name: string; + scopes?: string[]; + expires_in_days?: number | null; +} + +// ─── Query hooks ───────────────────────────────────────────── + +export function useApiTokens() { + return useQuery({ + queryKey: ['api-tokens'], + queryFn: () => apiGet('/tokens'), + }); +} + +// ─── Mutation hooks ────────────────────────────────────────── + +export function useCreateApiToken() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (data) => apiPost('/tokens', data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['api-tokens'] }); + }, + }); +} + +export function useRevokeApiToken() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (tokenId) => apiDelete(`/tokens/${tokenId}`), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['api-tokens'] }); + }, + }); +} diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 9f726a3..1f0d070 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1659,5 +1659,30 @@ "deleteConfirm": "Diese Delegation wirklich loeschen?", "empty": "Keine Delegationen in dieser Ansicht.", "loadError": "Delegationen konnten nicht geladen werden." + }, + "apiTokens": { + "title": "API-Tokens", + "create": "Token erstellen", + "createTitle": "API-Token erstellen", + "createSubmit": "Erstellen", + "name": "Name", + "namePlaceholder": "z.B. CI-Integration", + "scopes": "Scopes", + "scopesPlaceholder": "z.B. contacts:read, mail:read", + "scopesHelper": "Kommagetrennte Liste. Leer = alle Scopes.", + "expiresInDays": "Gueltigkeit (Tage, optional)", + "expiresPlaceholder": "z.B. 30", + "revealTitle": "Token erstellt", + "revealWarning": "Dies ist der einzige Moment, in dem das Token im Klartext sichtbar ist. Jetzt kopieren und sicher speichern — spaeter nicht mehr abrufbar.", + "copy": "Kopieren", + "done": "Fertig", + "created": "Erstellt:", + "expires": "Laeuft ab:", + "lastUsed": "Zuletzt genutzt:", + "expiredBadge": "Abgelaufen", + "revoke": "Widerrufen", + "revokeConfirm": "Token '{{name}}' wirklich widerrufen? Anwendungen damit verlieren sofort den Zugriff.", + "empty": "Keine API-Tokens vorhanden.", + "loadError": "API-Tokens konnten nicht geladen werden." } } diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 909ccc5..f57aa9c 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1659,5 +1659,30 @@ "deleteConfirm": "Really delete this delegation?", "empty": "No delegations in this view.", "loadError": "Failed to load delegations." + }, + "apiTokens": { + "title": "API Tokens", + "create": "Create token", + "createTitle": "Create API token", + "createSubmit": "Create", + "name": "Name", + "namePlaceholder": "e.g. CI integration", + "scopes": "Scopes", + "scopesPlaceholder": "e.g. contacts:read, mail:read", + "scopesHelper": "Comma-separated list. Empty = all scopes.", + "expiresInDays": "Validity (days, optional)", + "expiresPlaceholder": "e.g. 30", + "revealTitle": "Token created", + "revealWarning": "This is the only moment the plaintext token is visible. Copy and store it safely now — it cannot be retrieved later.", + "copy": "Copy", + "done": "Done", + "created": "Created:", + "expires": "Expires:", + "lastUsed": "Last used:", + "expiredBadge": "Expired", + "revoke": "Revoke", + "revokeConfirm": "Really revoke token '{{name}}'? Applications using it lose access immediately.", + "empty": "No API tokens yet.", + "loadError": "Failed to load API tokens." } } diff --git a/frontend/src/pages/ApiTokens.tsx b/frontend/src/pages/ApiTokens.tsx new file mode 100644 index 0000000..eb7c580 --- /dev/null +++ b/frontend/src/pages/ApiTokens.tsx @@ -0,0 +1,307 @@ +/** + * 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.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 && ( + + )} +
+
+ ); +} + +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')} + /> +
+ + +
+
+
+ ); +} + +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} + + +
+
+ +
+
+
+ ); +} + +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 && ( + + )} +
+ + {isLoading && ( +
+ + )} + + {isError && ( + + + )} + + {!isLoading && !isError && items.length === 0 && ( + + + )} + +
+ {items.map((token) => ( + + ))} +
+ + setShowCreate(false)} + onSubmit={handleSubmit} + isSubmitting={createMut.isPending} + /> + setRevealedToken(null)} + /> +
+ ); +} + +export default ApiTokensPage; diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index cc3767b..40ec6f6 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -48,6 +48,7 @@ export function SettingsPage() { { to: '/settings/webhooks', label: 'Webhooks', icon: '\ud83d\udd14' }, { to: '/settings/workspaces', label: 'Workspaces', icon: '\ud83d\udd58\ufe0f' }, { to: '/settings/backup', label: 'Backup & Restore', icon: '\ud83d\udcbe' }, + { to: '/settings/api-tokens', label: 'API-Tokens', icon: '\ud83d\udd11' }, ]; const existingPaths = new Set(hardcodedNavItems.map(item => item.to)); diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index cefefc4..40c5d90 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -45,6 +45,7 @@ const CustomFieldsPage = React.lazy(() => import('@/pages/CustomFields').then(m const ActivityTimelinePage = React.lazy(() => import('@/pages/ActivityTimeline').then(m => ({ default: m.ActivityTimelinePage }))); const ApprovalsPage = React.lazy(() => import('@/pages/Approvals').then(m => ({ default: m.ApprovalsPage }))); const DelegationsPage = React.lazy(() => import('@/pages/Delegations').then(m => ({ default: m.DelegationsPage }))); +const ApiTokensPage = React.lazy(() => import('@/pages/ApiTokens').then(m => ({ default: m.ApiTokensPage }))); const SettingsWebhooksPage = React.lazy(() => import('@/pages/SettingsWebhooks').then(m => ({ default: m.SettingsWebhooksPage }))); const SettingsBackupPage = React.lazy(() => import('@/pages/SettingsBackup').then(m => ({ default: m.SettingsBackupPage }))); const WorkspaceManagerPage = React.lazy(() => import('@/pages/SettingsWorkspaces').then(m => ({ default: m.WorkspaceManagerPage }))); @@ -204,6 +205,7 @@ const router = createBrowserRouter([ { path: 'webhooks', element: withSuspense() }, { path: 'workspaces', element: withSuspense() }, { path: 'backup', element: withSuspense() }, + { path: 'api-tokens', element: withSuspense() }, { path: 'rechte', element: {withSuspense()} }, // Phase Q2: plugin settings sub-pages render with bare sub-segments { path: '*', element: {} },