dorfteich/apps/web/src/api-tokens/ApiTokensSection.tsx
Claude Fable 5 8719b0ee1e #168: Formulare — Fehler-Verdrahtung und Namenslücken
Der Field-Baustein verdrahtet Hinweis/Fehler jetzt per aria-describedby
und aria-invalid mit dem Eingabefeld (cloneElement auf das einzelne
Kind; Fragmente bleiben unangetastet) — Screenreader nennen den Fehler
damit auch beim Feld-Fokus. Quota-Typ-Select mit Namen; die leeren
Aktions-/Erledigt-Spaltenköpfe in API-Tokens, Feed-Tokens, Sitzungen
und der Aufgabenübersicht (NodeView UND Server-Renderpfad) tragen
visually-hidden-Beschriftungen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGM8jo3hwoV9wsCVGfy8iq
2026-07-21 14:26:54 +02:00

224 lines
7.3 KiB
TypeScript

import type { ApiTokenCreatedView, ApiTokenView, PondView } from '@dorfteich/shared';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FormError } from '../components/forms';
import { apiDelete, apiGet, apiPost } from '../lib/api';
const TOKENS_QUERY_KEY = ['users', 'me', 'api-tokens'] as const;
/**
* Personal-access-token management in the user settings (issue #104):
* create (scope, optional expiry, optional pond restriction), the one-time
* secret reveal, and the list with revoke.
*/
export function ApiTokensSection(): React.JSX.Element {
const { t } = useTranslation('apiTokens');
const tokens = useQuery({
queryKey: TOKENS_QUERY_KEY,
queryFn: () => apiGet<ApiTokenView[]>('/users/me/api-tokens'),
});
return (
<section className="settings-section api-tokens">
<h2>{t('section.title')}</h2>
<p className="api-tokens__intro">{t('section.intro')}</p>
<CreateTokenForm />
{tokens.data && tokens.data.length === 0 && <p>{t('section.empty')}</p>}
{tokens.data && tokens.data.length > 0 && <TokenList tokens={tokens.data} />}
</section>
);
}
function CreateTokenForm(): React.JSX.Element {
const { t } = useTranslation('apiTokens');
const queryClient = useQueryClient();
const [name, setName] = useState('');
const [scope, setScope] = useState<'read' | 'write'>('read');
const [expiresAt, setExpiresAt] = useState('');
const [pondIds, setPondIds] = useState<string[]>([]);
const [created, setCreated] = useState<ApiTokenCreatedView | null>(null);
const [copied, setCopied] = useState(false);
const [error, setError] = useState<unknown>(null);
const [busy, setBusy] = useState(false);
const ponds = useQuery({
queryKey: ['ponds'],
queryFn: () => apiGet<PondView[]>('/ponds'),
});
const submit = async (event: React.FormEvent): Promise<void> => {
event.preventDefault();
setError(null);
setCreated(null);
setCopied(false);
setBusy(true);
try {
const view = await apiPost<ApiTokenCreatedView>('/users/me/api-tokens', {
name,
scope,
expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null,
pondIds,
});
setCreated(view);
setName('');
setExpiresAt('');
setPondIds([]);
await queryClient.invalidateQueries({ queryKey: TOKENS_QUERY_KEY });
} catch (err) {
setError(err);
} finally {
setBusy(false);
}
};
return (
<form className="api-tokens__create" onSubmit={(event) => void submit(event)}>
<FormError error={error} />
<label>
{t('fields.name')}
<input
type="text"
value={name}
required
maxLength={80}
onChange={(event) => setName(event.target.value)}
/>
</label>
<label>
{t('fields.scope')}
<select
value={scope}
onChange={(event) => setScope(event.target.value as 'read' | 'write')}
>
<option value="read">{t('fields.scopeRead')}</option>
<option value="write">{t('fields.scopeWrite')}</option>
</select>
</label>
<label>
{t('fields.expiresAt')}
<input
type="date"
value={expiresAt}
onChange={(event) => setExpiresAt(event.target.value)}
/>
</label>
<fieldset className="api-tokens__ponds">
<legend>{t('fields.ponds')}</legend>
<p className="api-tokens__hint">{t('fields.pondsHint')}</p>
{(ponds.data ?? []).map((pond) => (
<label key={pond.id} className="api-tokens__pond">
<input
type="checkbox"
checked={pondIds.includes(pond.id)}
onChange={(event) =>
setPondIds((current) =>
event.target.checked
? [...current, pond.id]
: current.filter((id) => id !== pond.id),
)
}
/>
{pond.name}
</label>
))}
</fieldset>
<button type="submit" className="button" disabled={busy || !name.trim()}>
{busy ? t('create.pending') : t('create.button')}
</button>
{created && (
<div className="api-tokens__created" role="status">
<h3>{t('create.createdTitle')}</h3>
<p>{t('create.createdHint')}</p>
<code className="api-tokens__secret">{created.token}</code>
<button
type="button"
className="button"
onClick={() => {
void navigator.clipboard.writeText(created.token).then(() => setCopied(true));
}}
>
{copied ? t('create.copied') : t('create.copy')}
</button>
</div>
)}
</form>
);
}
function TokenList({ tokens }: { tokens: ApiTokenView[] }): React.JSX.Element {
const { t } = useTranslation('apiTokens');
const queryClient = useQueryClient();
const [error, setError] = useState<unknown>(null);
const revoke = async (id: string): Promise<void> => {
if (!window.confirm(t('list.revokeConfirm'))) return;
setError(null);
try {
await apiDelete(`/users/me/api-tokens/${id}`);
await queryClient.invalidateQueries({ queryKey: TOKENS_QUERY_KEY });
} catch (err) {
setError(err);
}
};
const statusOf = (token: ApiTokenView): 'active' | 'revoked' | 'expired' => {
if (token.revokedAt) return 'revoked';
if (token.expiresAt && new Date(token.expiresAt).getTime() <= Date.now()) return 'expired';
return 'active';
};
return (
<>
<FormError error={error} />
<table className="table api-tokens__table">
<thead>
<tr>
<th>{t('fields.name')}</th>
<th>{t('fields.scope')}</th>
<th>{t('fields.ponds')}</th>
<th>{t('list.created')}</th>
<th>{t('list.lastUsed')}</th>
<th>{t('list.expires')}</th>
<th>{t('list.status')}</th>
<th>
<span className="visually-hidden">{t('common:tableActions')}</span>
</th>
</tr>
</thead>
<tbody>
{tokens.map((token) => (
<tr key={token.id}>
<td>{token.name}</td>
<td>{token.scope === 'write' ? t('fields.scopeWrite') : t('fields.scopeRead')}</td>
<td>
{token.ponds.length === 0
? t('list.allPonds')
: token.ponds.map((pond) => pond.name).join(', ')}
</td>
<td>{new Date(token.createdAt).toLocaleDateString()}</td>
<td>
{token.lastUsedAt ? new Date(token.lastUsedAt).toLocaleString() : t('list.never')}
</td>
<td>{token.expiresAt ? new Date(token.expiresAt).toLocaleDateString() : '—'}</td>
<td>{t(`list.${statusOf(token)}`)}</td>
<td>
{!token.revokedAt && (
<button
type="button"
className="button api-tokens__revoke"
onClick={() => void revoke(token.id)}
>
{t('list.revoke')}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</>
);
}