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
224 lines
7.3 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|