fix(settings): bring the API keys section and its dialog in line with the rest of TREK

The section stacked a lonely button, a centred line and a small print hint
on top of each other, and the create dialog used bare browser checkboxes
that ignore the accent. The dialog now runs on the shared Modal with
selectable scope tiles, keys render as cards with their scopes as chips,
deletion goes through ConfirmDialog, and the endpoint sits in its own row
with a copy button.

Pressing Enter in the name field with every scope switched off used to
create a key: the empty list was dropped from the request and the server
stored it as a key that reads everything. Creation is now refused until at
least one scope is picked.

A failed list load no longer reads as "no keys yet", the create button
stops at the server's limit of ten keys, a refused clipboard says so, and
SQLite timestamps are parsed so Safari does not show Invalid Date.
This commit is contained in:
Maurice
2026-09-14 21:59:27 +02:00
parent da679e6266
commit 25282ea1c5
25 changed files with 648 additions and 220 deletions
@@ -1,5 +1,5 @@
// FE-COMP-APIKEYS-001 to FE-COMP-APIKEYS-010
import { render, screen, waitFor } from '../../../tests/helpers/render';
// FE-COMP-APIKEYS-001 to FE-COMP-APIKEYS-020
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
@@ -21,7 +21,8 @@ beforeAll(() => {
});
beforeEach(() => {
clipboardWriteText.mockClear();
clipboardWriteText.mockReset();
clipboardWriteText.mockResolvedValue(undefined);
resetAllStores();
vi.clearAllMocks();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
@@ -38,6 +39,39 @@ function renderSection() {
);
}
/** userEvent.setup() installs its own clipboard stub, so the spy goes back on afterwards. */
function restoreClipboardSpy() {
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: clipboardWriteText },
configurable: true,
writable: true,
});
}
function createdResponse(extra: Record<string, unknown> = {}) {
return HttpResponse.json({
token: {
id: 1,
name: 'Dawarich',
raw_token: 'trek_thefullsecretvalue',
token_prefix: 'trek_thefull',
created_at: '2026-08-27T10:00:00Z',
...extra,
},
});
}
function keyRow(overrides: Record<string, unknown> = {}) {
return {
id: 7,
name: 'Dawarich',
token_prefix: 'trek_abcdefg',
created_at: '2026-08-01T10:00:00Z',
last_used_at: null,
...overrides,
};
}
describe('ApiKeysSection', () => {
it('FE-COMP-APIKEYS-001: renders without the MCP addon, because an API key does not need it', async () => {
renderSection();
@@ -62,24 +96,10 @@ describe('ApiKeysSection', () => {
});
it('FE-COMP-APIKEYS-003: lists existing keys by prefix, never the key itself', async () => {
server.use(
http.get('/api/auth/api-tokens', () =>
HttpResponse.json({
tokens: [
{
id: 7,
name: 'Dawarich',
token_prefix: 'trek_abcdefg',
created_at: '2026-08-01T10:00:00Z',
last_used_at: null,
},
],
}),
),
);
server.use(http.get('/api/auth/api-tokens', () => HttpResponse.json({ tokens: [keyRow()] })));
renderSection();
expect(await screen.findByText('Dawarich')).toBeInTheDocument();
expect(screen.getByText(/trek_abcdefg\.\.\./)).toBeInTheDocument();
expect(screen.getByText('trek_abcdefg…')).toBeInTheDocument();
});
it('FE-COMP-APIKEYS-004: shows the empty state when there is nothing to list', async () => {
@@ -88,19 +108,7 @@ describe('ApiKeysSection', () => {
});
it('FE-COMP-APIKEYS-005: creates a key and shows it once, with the warning', async () => {
server.use(
http.post('/api/auth/api-tokens', () =>
HttpResponse.json({
token: {
id: 1,
name: 'Dawarich',
raw_token: 'trek_thefullsecretvalue',
token_prefix: 'trek_thefull',
created_at: '2026-08-27T10:00:00Z',
},
}),
),
);
server.use(http.post('/api/auth/api-tokens', () => createdResponse()));
const user = userEvent.setup();
renderSection();
@@ -113,19 +121,7 @@ describe('ApiKeysSection', () => {
});
it('FE-COMP-APIKEYS-006: drops the raw key from the DOM once the modal is closed', async () => {
server.use(
http.post('/api/auth/api-tokens', () =>
HttpResponse.json({
token: {
id: 1,
name: 'Dawarich',
raw_token: 'trek_thefullsecretvalue',
token_prefix: 'trek_thefull',
created_at: '2026-08-27T10:00:00Z',
},
}),
),
);
server.use(http.post('/api/auth/api-tokens', () => createdResponse()));
const user = userEvent.setup();
renderSection();
@@ -137,7 +133,7 @@ describe('ApiKeysSection', () => {
await waitFor(() => expect(screen.queryByText('trek_thefullsecretvalue')).not.toBeInTheDocument());
// The list keeps only the prefix, which is what the server stores alongside the hash.
expect(screen.getByText(/trek_thefull\.\.\./)).toBeInTheDocument();
expect(screen.getByText('trek_thefull…')).toBeInTheDocument();
});
it('FE-COMP-APIKEYS-007: refuses to create a key without a name', async () => {
@@ -150,13 +146,7 @@ describe('ApiKeysSection', () => {
it('FE-COMP-APIKEYS-008: asks before deleting, and only deletes on confirm', async () => {
let deleted = 0;
server.use(
http.get('/api/auth/api-tokens', () =>
HttpResponse.json({
tokens: [
{ id: 7, name: 'Dawarich', token_prefix: 'trek_abc', created_at: '2026-08-01T10:00:00Z', last_used_at: null },
],
}),
),
http.get('/api/auth/api-tokens', () => HttpResponse.json({ tokens: [keyRow({ token_prefix: 'trek_abc' })] })),
http.delete('/api/auth/api-tokens/7', () => {
deleted += 1;
return HttpResponse.json({ success: true });
@@ -192,37 +182,175 @@ describe('ApiKeysSection', () => {
});
it('FE-COMP-APIKEYS-010: copies the new key to the clipboard on request', async () => {
server.use(http.post('/api/auth/api-tokens', () => createdResponse()));
const user = userEvent.setup();
restoreClipboardSpy();
renderSection();
await user.click(await screen.findByRole('button', { name: /Create key/ }));
await user.type(screen.getByPlaceholderText('e.g. Dawarich'), 'Dawarich');
await user.click(screen.getByRole('button', { name: /^Create$/ }));
const secret = await screen.findByText('trek_thefullsecretvalue');
// The section's endpoint row has a copy button of its own; this one sits beside the key.
await user.click(within(secret.parentElement!).getByTitle('Copy'));
expect(clipboardWriteText).toHaveBeenCalledWith('trek_thefullsecretvalue');
// And the button confirms it to the reader, which is the only feedback there is.
expect(await within(secret.parentElement!).findByText('Copied')).toBeInTheDocument();
});
it('FE-COMP-APIKEYS-011: Enter with nothing selected does not mint a key that reads everything', async () => {
const posts: unknown[] = [];
server.use(
http.post('/api/auth/api-tokens', () =>
HttpResponse.json({
token: {
id: 1,
name: 'Dawarich',
raw_token: 'trek_thefullsecretvalue',
token_prefix: 'trek_thefull',
created_at: '2026-08-27T10:00:00Z',
},
}),
),
http.post('/api/auth/api-tokens', async ({ request }) => {
posts.push(await request.json());
return createdResponse();
}),
);
const user = userEvent.setup();
// userEvent.setup() installs its own clipboard stub, so the spy goes back on
// afterwards — otherwise this asserts against a stub the component never reaches.
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: clipboardWriteText },
configurable: true,
writable: true,
});
renderSection();
await user.click(await screen.findByRole('button', { name: /Create key/ }));
await user.click(screen.getByRole('button', { name: 'Deselect all' }));
expect(screen.getByText(/Pick at least one area/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^Create$/ })).toBeDisabled();
await user.type(screen.getByPlaceholderText('e.g. Dawarich'), 'Dawarich{Enter}');
// An empty list would be dropped from the request, and the server stores
// "no list" as a key with full access, so nothing may go out at all.
await new Promise((resolve) => setTimeout(resolve, 50));
expect(posts).toEqual([]);
});
it('FE-COMP-APIKEYS-012: sends only the areas left switched on', async () => {
const posts: Array<{ name?: string; scopes?: string[] }> = [];
server.use(
http.post('/api/auth/api-tokens', async ({ request }) => {
posts.push((await request.json()) as { name?: string; scopes?: string[] });
return createdResponse({ scope_mode: 'limited', scopes: ['trips', 'days'] });
}),
);
const user = userEvent.setup();
renderSection();
await user.click(await screen.findByRole('button', { name: /Create key/ }));
await user.click(screen.getByRole('button', { name: 'Deselect all' }));
const trips = screen.getByRole('checkbox', { name: 'Trips' });
expect(trips).toHaveAttribute('aria-checked', 'false');
await user.click(trips);
await user.click(screen.getByRole('checkbox', { name: 'Days' }));
expect(trips).toHaveAttribute('aria-checked', 'true');
await user.type(screen.getByPlaceholderText('e.g. Dawarich'), 'Dawarich');
await user.click(screen.getByRole('button', { name: /^Create$/ }));
await waitFor(() => expect(posts).toHaveLength(1));
expect(posts[0]).toEqual({ name: 'Dawarich', scopes: ['trips', 'days'] });
});
it('FE-COMP-APIKEYS-013: sends no list at all when every area stays on', async () => {
const posts: Array<Record<string, unknown>> = [];
server.use(
http.post('/api/auth/api-tokens', async ({ request }) => {
posts.push((await request.json()) as Record<string, unknown>);
return createdResponse();
}),
);
const user = userEvent.setup();
renderSection();
await user.click(await screen.findByRole('button', { name: /Create key/ }));
// Off and back on again: the result is "everything", not a limited key that lists it all.
await user.click(screen.getByRole('button', { name: 'Deselect all' }));
await user.click(screen.getByRole('button', { name: 'Select all' }));
await user.type(screen.getByPlaceholderText('e.g. Dawarich'), 'Dawarich');
await user.click(screen.getByRole('button', { name: /^Create$/ }));
await waitFor(() => expect(posts).toHaveLength(1));
expect(posts[0]).toEqual({ name: 'Dawarich' });
});
it('FE-COMP-APIKEYS-014: a failed load says so instead of claiming there are no keys', async () => {
server.use(http.get('/api/auth/api-tokens', () => HttpResponse.json({ error: 'down' }, { status: 500 })));
renderSection();
expect(await screen.findByRole('alert')).toHaveTextContent(/Could not load your keys/);
expect(screen.queryByText(/No keys yet/)).not.toBeInTheDocument();
});
it('FE-COMP-APIKEYS-015: shows what a narrowed key may read, and that it was never used', async () => {
server.use(
http.get('/api/auth/api-tokens', () =>
HttpResponse.json({ tokens: [keyRow({ scope_mode: 'limited', scopes: ['places', 'notes'] })] }),
),
);
renderSection();
expect(await screen.findByText('Places')).toBeInTheDocument();
expect(screen.getByText('Day notes')).toBeInTheDocument();
expect(screen.queryByText('Everything')).not.toBeInTheDocument();
expect(screen.getByText(/never used/)).toBeInTheDocument();
});
it('FE-COMP-APIKEYS-016: labels a key minted before scopes existed as reading everything', async () => {
server.use(
http.get('/api/auth/api-tokens', () =>
HttpResponse.json({ tokens: [keyRow({ last_used_at: '2026-09-01 08:30:00' })] }),
),
);
renderSection();
expect(await screen.findByText('Everything')).toBeInTheDocument();
// SQLite's "YYYY-MM-DD HH:MM:SS" has to come out as a date, not "Invalid Date".
expect(screen.getByText(/last used/)).not.toHaveTextContent(/Invalid Date/);
expect(screen.getByText(/last used/)).toHaveTextContent(new Date('2026-09-01T08:30:00Z').toLocaleDateString('en'));
});
it('FE-COMP-APIKEYS-017: stops offering a new key once the account holds the most it can', async () => {
const tokens = Array.from({ length: 10 }, (_, i) => keyRow({ id: i + 1, name: `Key ${i + 1}` }));
server.use(http.get('/api/auth/api-tokens', () => HttpResponse.json({ tokens })));
renderSection();
expect(await screen.findByText('Key 10')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Create key/ })).toBeDisabled();
expect(screen.getByText(/You have 10 keys/)).toBeInTheDocument();
});
it('FE-COMP-APIKEYS-018: copies the endpoint the key is meant for', async () => {
const user = userEvent.setup();
restoreClipboardSpy();
renderSection();
const endpoint = await screen.findByText(`${window.location.origin}/api/v1`);
await user.click(within(endpoint.parentElement!).getByRole('button', { name: 'Copy' }));
expect(clipboardWriteText).toHaveBeenCalledWith(`${window.location.origin}/api/v1`);
});
it('FE-COMP-APIKEYS-019: tells the reader when the clipboard refuses, instead of staying silent', async () => {
clipboardWriteText.mockRejectedValue(new Error('denied'));
const user = userEvent.setup();
restoreClipboardSpy();
renderSection();
const endpoint = await screen.findByText(`${window.location.origin}/api/v1`);
await user.click(within(endpoint.parentElement!).getByRole('button', { name: 'Copy' }));
expect(await screen.findByText(/Could not copy/)).toBeInTheDocument();
});
it('FE-COMP-APIKEYS-020: keeps the new key on screen through Escape, so it cannot be lost by accident', async () => {
server.use(http.post('/api/auth/api-tokens', () => createdResponse()));
const user = userEvent.setup();
renderSection();
await user.click(await screen.findByRole('button', { name: /Create key/ }));
await user.type(screen.getByPlaceholderText('e.g. Dawarich'), 'Dawarich');
await user.click(screen.getByRole('button', { name: /^Create$/ }));
await screen.findByText('trek_thefullsecretvalue');
await user.click(screen.getByTitle('Copy'));
expect(clipboardWriteText).toHaveBeenCalledWith('trek_thefullsecretvalue');
// And the button confirms it to the reader, which is the only feedback there is.
expect(await screen.findByTitle('Copy')).toBeInTheDocument();
await user.keyboard('{Escape}');
expect(screen.getByText('trek_thefullsecretvalue')).toBeInTheDocument();
});
});
+307 -145
View File
@@ -1,7 +1,12 @@
import React, { useEffect, useState } from 'react'
import { KeyRound, Plus, Trash2, Copy, Check } from 'lucide-react'
import React, { useEffect, useRef, useState } from 'react'
import {
KeyRound, Plus, Trash2, Copy, Check, AlertTriangle, Briefcase, CalendarDays, MapPin,
StickyNote, Ticket, Hotel, Users, Star, BarChart3, type LucideIcon,
} from 'lucide-react'
import { PUBLIC_API_SCOPES, type PublicApiScope } from '@trek/shared'
import Section from './Section'
import Modal from '../shared/Modal'
import ConfirmDialog from '../shared/ConfirmDialog'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { authApi } from '../../api/client'
@@ -30,17 +35,61 @@ interface ApiKey {
scopes?: PublicApiScope[]
}
/** The server refuses an eleventh key (`TokenService.createToken`). */
const MAX_KEYS = 10
/** The glyph each area already wears elsewhere in the app, so the list reads like the planner. */
const SCOPE_ICONS: Record<PublicApiScope, LucideIcon> = {
trips: Briefcase,
days: CalendarDays,
places: MapPin,
notes: StickyNote,
reservations: Ticket,
accommodations: Hotel,
travellers: Users,
'bucket-list': Star,
stats: BarChart3,
}
/** SQLite hands out "YYYY-MM-DD HH:MM:SS" in UTC, which Safari will not parse as it stands. */
function formatStamp(ts: string, locale: string): string {
const iso = ts.includes('T') ? ts : ts.replace(' ', 'T')
const d = new Date(/(Z|[+-]\d{2}:?\d{2})$/.test(iso) ? iso : `${iso}Z`)
return Number.isNaN(d.getTime()) ? ts : d.toLocaleDateString(locale)
}
/**
* The usage hint with its header names and path set as code.
*
* Every locale quotes the two headers the same way, so they can be picked out
* of the sentence instead of cutting it into fragments a translator would have
* to reassemble. A translation that drops the quotes simply stays plain text.
*/
function withCode(text: string): React.ReactNode[] {
return text.split(/("[^"]+"|\/api\/v1)/).map((part, i) => {
if (i % 2 === 0) return part
return (
<code key={i} className="rounded px-1 py-px font-mono bg-surface-hover text-content-secondary">
{part.replace(/^"|"$/g, '')}
</code>
)
})
}
export default function ApiKeysSection(): React.ReactElement {
const { t, locale } = useTranslation()
const toast = useToast()
const [keys, setKeys] = useState<ApiKey[]>([])
/** A failed load must not read as "you have no keys": that sends people off minting duplicates. */
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'failed'>('loading')
const [modalOpen, setModalOpen] = useState(false)
const [newName, setNewName] = useState('')
const [creating, setCreating] = useState(false)
/** The raw key, held only until the modal closes — the server keeps a hash. */
const [created, setCreated] = useState<string | null>(null)
const [deleteId, setDeleteId] = useState<number | null>(null)
const [copied, setCopied] = useState(false)
const [copied, setCopied] = useState<'key' | 'endpoint' | null>(null)
const copiedTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
/**
* What the new key may read. Starts as everything, because that is what a key
* did before this existed and what most integrations want — narrowing is a
@@ -48,19 +97,38 @@ export default function ApiKeysSection(): React.ReactElement {
*/
const [newScopes, setNewScopes] = useState<Set<PublicApiScope>>(new Set(PUBLIC_API_SCOPES))
const endpoint = `${window.location.origin}/api/v1`
const atLimit = keys.length >= MAX_KEYS
const allSelected = newScopes.size === PUBLIC_API_SCOPES.length
const canCreate = !!newName.trim() && !creating && newScopes.size > 0
useEffect(() => {
authApi.apiKeys.list().then(d => setKeys(d.tokens || [])).catch(() => {})
let cancelled = false
authApi.apiKeys.list()
.then(d => {
if (cancelled) return
setKeys(d.tokens || [])
setLoadState('ready')
})
.catch(() => { if (!cancelled) setLoadState('failed') })
return () => {
cancelled = true
clearTimeout(copiedTimer.current)
}
}, [])
const handleCreate = async () => {
if (!newName.trim() || creating) return
// Enter in the name field arrives here past the button's disabled state, and
// an empty selection must not fall through to the "no narrowing" branch
// below, which would mint a key that reads everything.
if (!canCreate) return
setCreating(true)
try {
// All of them selected means "no narrowing", which is what the server
// stores as `all` — sending the full list would record it as a limited key
// that happens to allow everything, and a section added in a later version
// would then be refused for a key nobody meant to restrict.
const narrowed = newScopes.size < PUBLIC_API_SCOPES.length ? [...newScopes] : undefined
const narrowed = allSelected ? undefined : [...newScopes]
const d = await authApi.apiKeys.create(newName.trim(), narrowed)
setCreated(d.token.raw_token)
setKeys(prev => [
@@ -90,23 +158,38 @@ export default function ApiKeysSection(): React.ReactElement {
toast.success(t('settings.apiKeys.deleted'))
} catch {
toast.error(t('settings.apiKeys.deleteFailed'))
} finally {
setDeleteId(null)
}
}
const handleCopy = () => {
if (!created) return
navigator.clipboard.writeText(created)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
const handleCopy = async (text: string, what: 'key' | 'endpoint') => {
try {
// Absent on a plain-http origin, which is exactly where a self-run
// instance on the LAN tends to live.
if (!navigator.clipboard) throw new Error('clipboard unavailable')
await navigator.clipboard.writeText(text)
setCopied(what)
clearTimeout(copiedTimer.current)
copiedTimer.current = setTimeout(() => setCopied(null), 2000)
} catch {
toast.error(t('settings.apiKeys.copyFailed'))
}
}
const resetForm = () => {
setCreated(null)
setCopied(null)
setNewName('')
setNewScopes(new Set(PUBLIC_API_SCOPES))
}
const openModal = () => {
resetForm()
setModalOpen(true)
}
const closeModal = () => {
setModalOpen(false)
setCreated(null)
setNewName('')
setNewScopes(new Set(PUBLIC_API_SCOPES))
resetForm()
}
const toggleScope = (scope: PublicApiScope) => {
@@ -121,148 +204,227 @@ export default function ApiKeysSection(): React.ReactElement {
return (
<>
<Section title={t('settings.apiKeys.title')} icon={KeyRound}>
<p className="text-sm text-content-secondary">{t('settings.apiKeys.description')}</p>
<div className="flex justify-end">
<button type="button" onClick={() => { setModalOpen(true); setCreated(null); setNewName('') }}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors text-white bg-slate-900 hover:bg-slate-700">
<Plus className="w-3.5 h-3.5" /> {t('settings.apiKeys.create')}
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-6">
<p className="text-body text-content-secondary">{t('settings.apiKeys.description')}</p>
<button type="button" onClick={openModal} disabled={atLimit}
className="inline-flex flex-shrink-0 items-center gap-1.5 self-start rounded-lg px-3.5 py-2 text-body font-medium transition-colors bg-accent text-accent-text hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50">
<Plus className="w-4 h-4" /> {t('settings.apiKeys.create')}
</button>
</div>
{keys.length === 0 ? (
<p className="text-sm py-2 text-center" style={{ color: 'var(--text-tertiary)' }}>
{t('settings.apiKeys.empty')}
{atLimit && (
<p className="text-caption text-content-muted">{t('settings.apiKeys.limitReached', { max: MAX_KEYS })}</p>
)}
{loadState === 'failed' && (
<p role="alert" className="rounded-lg px-3 py-2.5 text-caption bg-danger-soft text-danger">
{t('settings.apiKeys.loadFailed')}
</p>
) : (
<div className="rounded-lg border overflow-hidden border-edge">
{keys.map((key, i) => (
<div key={key.id} className={`flex items-center gap-3 px-4 py-3 ${i < keys.length - 1 ? 'border-b border-edge' : ''}`}>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate text-content">{key.name}</p>
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
{key.token_prefix}...
<span className="ml-3 font-sans">{t('settings.apiKeys.createdAt')} {new Date(key.created_at).toLocaleDateString(locale)}</span>
{key.last_used_at && (
<span className="ml-2">· {t('settings.apiKeys.usedAt')} {new Date(key.last_used_at).toLocaleDateString(locale)}</span>
)}
</p>
{/* What the key may read. Shown on the row rather than behind a
detail view: the whole reason to narrow a key is to be able
to see later that you did. */}
<p className="text-xs mt-1 text-content-muted">
{key.scope_mode === 'limited' && key.scopes
? key.scopes.map(scope => t(`settings.apiScopes.${scope}`)).join(', ')
: t('settings.apiScopes.all')}
</p>
</div>
<button type="button" onClick={() => setDeleteId(key.id)}
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
style={{ color: 'var(--text-tertiary)' }} title={t('settings.apiKeys.deleteTitle')}>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
)}
{loadState === 'ready' && keys.length === 0 && (
<div className="flex flex-col items-center gap-2.5 rounded-lg border border-dashed px-4 py-6 text-center border-edge">
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-surface-hover">
<KeyRound className="w-5 h-5 text-content-muted" />
</span>
<p className="text-body text-content-muted">{t('settings.apiKeys.empty')}</p>
</div>
)}
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.apiKeys.docsHint')}</p>
{keys.length > 0 && (
<ul className="m-0 list-none space-y-2 p-0">
{keys.map(key => (
<ApiKeyRow key={key.id} apiKey={key} locale={locale} onDelete={() => setDeleteId(key.id)} />
))}
</ul>
)}
<div className="space-y-2 border-t pt-4 border-edge-secondary">
<span className="block text-caption font-medium text-content-secondary">{t('settings.apiKeys.endpoint')}</span>
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded-lg border px-3 py-2 font-mono text-caption border-edge bg-surface-secondary text-content">
{endpoint}
</code>
<button type="button" onClick={() => handleCopy(endpoint, 'endpoint')}
className="flex-shrink-0 rounded-lg border p-2 transition-colors border-edge text-content-secondary hover:bg-surface-hover"
title={t('common.copy')} aria-label={t('common.copy')}>
{copied === 'endpoint' ? <Check className="w-4 h-4 text-success" /> : <Copy className="w-4 h-4" />}
</button>
</div>
<p className="text-caption text-content-muted">{withCode(t('settings.apiKeys.docsHint'))}</p>
</div>
</Section>
{modalOpen && (
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
onClick={e => { if (e.target === e.currentTarget && !created) closeModal() }}>
<div className="rounded-xl shadow-xl w-full max-w-md p-6 space-y-4 bg-surface-card">
{!created ? (
<>
<h3 className="text-lg font-semibold text-content">{t('settings.apiKeys.modal.createTitle')}</h3>
<div>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">{t('settings.apiKeys.modal.name')}</label>
<input type="text" value={newName} onChange={e => setNewName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleCreate()}
placeholder={t('settings.apiKeys.modal.namePlaceholder')}
className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 border-edge bg-surface-secondary text-content"
autoFocus />
<p className="mt-1.5 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.apiKeys.modal.nameHint')}</p>
</div>
<div>
<span className="block text-sm font-medium mb-1 text-content-secondary">
{t('settings.apiScopes.title')}
</span>
<p className="text-xs mb-2 text-content-muted">{t('settings.apiScopes.hint')}</p>
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5">
{PUBLIC_API_SCOPES.map(scope => (
<label key={scope} className="flex items-center gap-2 text-sm text-content-secondary cursor-pointer">
<input
type="checkbox"
checked={newScopes.has(scope)}
onChange={() => toggleScope(scope)}
/>
{t(`settings.apiScopes.${scope}`)}
</label>
))}
</div>
</div>
<div className="flex gap-2 justify-end pt-1">
<button type="button" onClick={closeModal}
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary">
{t('common.cancel')}
</button>
<button type="button" onClick={handleCreate} disabled={!newName.trim() || creating || newScopes.size === 0}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700 disabled:opacity-50">
{creating ? t('settings.apiKeys.modal.creating') : t('settings.apiKeys.modal.create')}
</button>
</div>
</>
) : (
<>
<h3 className="text-lg font-semibold text-content">{t('settings.apiKeys.modal.createdTitle')}</h3>
<div className="flex items-start gap-2 p-3 rounded-lg border border-amber-200 bg-[rgba(251,191,36,0.1)]">
<span className="text-amber-500 mt-0.5">⚠</span>
<p className="text-sm text-content-secondary">{t('settings.apiKeys.modal.createdWarning')}</p>
</div>
<div className="relative">
<pre className="p-3 pr-10 rounded-lg text-xs font-mono break-all border whitespace-pre-wrap bg-surface-secondary border-edge text-content">
{created}
</pre>
<button type="button" onClick={handleCopy}
className="absolute top-2 right-2 p-1.5 rounded transition-colors hover:bg-slate-200 dark:hover:bg-slate-600 text-content-secondary"
title={t('settings.apiKeys.copy')}>
{copied ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
</button>
</div>
<div className="flex justify-end">
<button type="button" onClick={closeModal}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700">
{t('settings.apiKeys.modal.done')}
</button>
</div>
</>
)}
<Modal
isOpen={modalOpen}
// Once the key is on screen, Done is the only way out: a stray Escape or
// backdrop click would throw away the one copy there will ever be.
onClose={created ? () => {} : closeModal}
hideCloseButton={!!created}
size="lg"
title={created ? t('settings.apiKeys.modal.createdTitle') : t('settings.apiKeys.modal.createTitle')}
footer={created ? (
<div className="flex justify-end">
<button type="button" onClick={closeModal}
className="rounded-lg px-4 py-2 text-body font-medium transition-colors bg-accent text-accent-text hover:bg-accent-hover">
{t('settings.apiKeys.modal.done')}
</button>
</div>
</div>
)}
{deleteId !== null && (
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
onClick={e => { if (e.target === e.currentTarget) setDeleteId(null) }}>
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4 bg-surface-card">
<h3 className="text-base font-semibold text-content">{t('settings.apiKeys.deleteTitle')}</h3>
<p className="text-sm text-content-secondary">{t('settings.apiKeys.deleteMessage')}</p>
<div className="flex gap-2 justify-end">
<button type="button" onClick={() => setDeleteId(null)}
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary">
{t('common.cancel')}
</button>
<button type="button" onClick={() => handleDelete(deleteId)}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
{t('settings.apiKeys.deleteTitle')}
) : (
<div className="flex justify-end gap-2">
<button type="button" onClick={closeModal}
className="rounded-lg border px-4 py-2 text-body font-medium transition-colors border-edge text-content-secondary hover:bg-surface-hover">
{t('common.cancel')}
</button>
<button type="button" onClick={handleCreate} disabled={!canCreate}
className="inline-flex items-center gap-2 rounded-lg px-4 py-2 text-body font-medium transition-colors bg-accent text-accent-text hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50">
{creating && <span aria-hidden className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />}
{creating ? t('settings.apiKeys.modal.creating') : t('settings.apiKeys.modal.create')}
</button>
</div>
)}
>
{created ? (
<div className="space-y-4">
<div className="flex items-start gap-2.5 rounded-lg p-3 bg-warning-soft">
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0 text-warning" />
<p className="text-caption text-content">{t('settings.apiKeys.modal.createdWarning')}</p>
</div>
<div className="flex items-stretch overflow-hidden rounded-lg border border-edge bg-surface-secondary">
<code className="min-w-0 flex-1 select-all break-all px-3 py-2.5 font-mono text-caption text-content">{created}</code>
<button type="button" onClick={() => handleCopy(created, 'key')} title={t('settings.apiKeys.copy')}
className="flex flex-shrink-0 items-center gap-1.5 border-l px-3 text-caption font-medium transition-colors border-edge text-content-secondary hover:bg-surface-hover">
{copied === 'key' ? <Check className="w-3.5 h-3.5 text-success" /> : <Copy className="w-3.5 h-3.5" />}
{copied === 'key' ? t('common.copied') : t('settings.apiKeys.copy')}
</button>
</div>
</div>
</div>
)}
) : (
<div className="space-y-6">
<div>
<label htmlFor="api-key-name" className="mb-1.5 block text-caption font-medium text-content-secondary">
{t('settings.apiKeys.modal.name')}
</label>
<input id="api-key-name" type="text" value={newName} onChange={e => setNewName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleCreate() }}
placeholder={t('settings.apiKeys.modal.namePlaceholder')}
maxLength={100}
className="w-full rounded-lg border px-3 py-2.5 text-body focus:outline-none focus:ring-2 ring-accent border-edge bg-surface-input text-content"
autoFocus />
<p className="mt-1.5 text-caption text-content-muted">{t('settings.apiKeys.modal.nameHint')}</p>
</div>
<div role="group" aria-labelledby="api-key-scopes-title">
<div className="mb-1 flex items-baseline justify-between gap-3">
<span id="api-key-scopes-title" className="text-caption font-medium text-content-secondary">
{t('settings.apiScopes.title')}
</span>
<button type="button"
onClick={() => setNewScopes(allSelected ? new Set() : new Set(PUBLIC_API_SCOPES))}
className="flex-shrink-0 text-caption font-medium transition-colors text-content-muted hover:text-content">
{allSelected ? t('common.deselectAll') : t('common.selectAll')}
</button>
</div>
<p className="mb-3 text-caption text-content-muted">{t('settings.apiScopes.hint')}</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{PUBLIC_API_SCOPES.map(scope => (
<ScopeOption key={scope} icon={SCOPE_ICONS[scope]} label={t(`settings.apiScopes.${scope}`)}
checked={newScopes.has(scope)} onToggle={() => toggleScope(scope)} />
))}
</div>
{newScopes.size === 0 && (
<p className="mt-2 text-caption text-danger">{t('settings.apiScopes.noneSelected')}</p>
)}
</div>
</div>
)}
</Modal>
<ConfirmDialog
isOpen={deleteId !== null}
onClose={() => setDeleteId(null)}
onConfirm={() => { if (deleteId !== null) handleDelete(deleteId) }}
title={t('settings.apiKeys.deleteTitle')}
message={t('settings.apiKeys.deleteMessage')}
confirmLabel={t('settings.apiKeys.deleteTitle')}
/>
</>
)
}
function ApiKeyRow({ apiKey, locale, onDelete }: { apiKey: ApiKey; locale: string; onDelete: () => void }): React.ReactElement {
const { t } = useTranslation()
const scopes = apiKey.scope_mode === 'limited' ? apiKey.scopes : undefined
return (
<li className="flex items-start gap-3 rounded-lg border p-3 border-edge">
<span className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-surface-hover">
<KeyRound className="w-4 h-4 text-content-secondary" />
</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<span className="truncate text-body font-medium text-content">{apiKey.name}</span>
<code className="rounded px-1.5 py-0.5 font-mono text-caption bg-surface-hover text-content-muted">
{apiKey.token_prefix}…
</code>
</div>
<p className="mt-0.5 text-caption text-content-faint first-letter:uppercase">
{t('settings.apiKeys.createdAt')} {formatStamp(apiKey.created_at, locale)}
{' · '}
{apiKey.last_used_at
? `${t('settings.apiKeys.usedAt')} ${formatStamp(apiKey.last_used_at, locale)}`
: t('settings.apiKeys.neverUsed')}
</p>
{/* What the key may read. Shown on the row rather than behind a detail
view: the whole reason to narrow a key is to be able to see later
that you did. */}
<div className="mt-2 flex flex-wrap gap-1">
{scopes ? scopes.map(scope => {
const Icon = SCOPE_ICONS[scope]
return (
<span key={scope} className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-caption bg-surface-hover text-content-secondary">
{Icon && <Icon className="w-3 h-3" />}
{t(`settings.apiScopes.${scope}`)}
</span>
)
}) : (
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-caption bg-surface-hover text-content-secondary">
{t('settings.apiScopes.all')}
</span>
)}
</div>
</div>
<button type="button" onClick={onDelete}
className="flex-shrink-0 rounded-lg p-1.5 transition-colors text-content-faint hover:bg-danger-soft hover:text-danger"
title={t('settings.apiKeys.deleteTitle')}>
<Trash2 className="w-4 h-4" />
</button>
</li>
)
}
/**
* One area the key may read. The whole tile is the control: a bare browser
* checkbox is a small target and ignores the user's accent.
*/
function ScopeOption({ icon: Icon, label, checked, onToggle }: {
icon: LucideIcon
label: string
checked: boolean
onToggle: () => void
}): React.ReactElement {
return (
<button type="button" role="checkbox" aria-checked={checked} onClick={onToggle}
className={`flex w-full items-center gap-2.5 rounded-lg border px-3 py-2.5 text-left transition-colors border-edge ${
checked ? 'bg-accent-subtle' : 'hover:bg-surface-hover'
}`}>
<span aria-hidden
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded transition-colors ${
checked ? 'bg-accent' : 'border-[1.5px] border-edge'
}`}>
{checked && <Check className="w-2.5 h-2.5 text-accent-text" strokeWidth={3} />}
</span>
<Icon aria-hidden className={`w-4 h-4 flex-shrink-0 ${checked ? 'text-content-secondary' : 'text-content-faint'}`} />
<span className={`min-w-0 truncate text-body ${checked ? 'text-content' : 'text-content-muted'}`}>{label}</span>
</button>
)
}