dorfteich/apps/web/src/api-tokens/FeedTokensSection.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

118 lines
4.0 KiB
TypeScript

import type { FeedTokenCreatedView, FeedTokenView } 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 FEED_TOKENS_QUERY_KEY = ['users', 'me', 'feed-tokens'] as const;
/**
* Feed-token management in the user settings (issue #149): create, the
* one-time secret reveal with a ready-to-paste feed URL, and the list with
* delete. Feed tokens are read-only and only authenticate the Atom feeds.
*/
export function FeedTokensSection(): React.JSX.Element {
const { t, i18n } = useTranslation('apiTokens');
const queryClient = useQueryClient();
const [name, setName] = useState('');
const [created, setCreated] = useState<FeedTokenCreatedView | null>(null);
const [error, setError] = useState<unknown>(null);
const [busy, setBusy] = useState(false);
const tokens = useQuery({
queryKey: FEED_TOKENS_QUERY_KEY,
queryFn: () => apiGet<FeedTokenView[]>('/users/me/feed-tokens'),
});
const invalidate = () => queryClient.invalidateQueries({ queryKey: FEED_TOKENS_QUERY_KEY });
const submit = async (event: React.FormEvent): Promise<void> => {
event.preventDefault();
setError(null);
setCreated(null);
setBusy(true);
try {
const view = await apiPost<FeedTokenCreatedView>('/users/me/feed-tokens', { name });
setCreated(view);
setName('');
await invalidate();
} catch (err) {
setError(err);
} finally {
setBusy(false);
}
};
const remove = async (id: string): Promise<void> => {
await apiDelete(`/users/me/feed-tokens/${id}`);
await invalidate();
};
const formatTime = (iso: string): string =>
new Intl.DateTimeFormat(i18n.language, { dateStyle: 'medium' }).format(new Date(iso));
return (
<section className="settings-section api-tokens">
<h2>{t('feed.title')}</h2>
<p className="api-tokens__intro">{t('feed.intro')}</p>
<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>
<button type="submit" className="button" disabled={busy}>
{t('feed.create')}
</button>
</form>
{created && (
<div className="api-tokens__reveal" role="status">
<p>{t('feed.revealHint')}</p>
<code className="api-tokens__secret">{created.token}</code>
<p className="api-tokens__hint">
{t('feed.urlHint', {
url: `${window.location.origin}/api/v1/public/<${t('feed.pondPlaceholder')}>/feed.xml?token=${created.token}`,
})}
</p>
</div>
)}
{tokens.data && tokens.data.length === 0 && <p>{t('feed.empty')}</p>}
{tokens.data && tokens.data.length > 0 && (
<table className="table">
<thead>
<tr>
<th>{t('fields.name')}</th>
<th>{t('list.created')}</th>
<th>{t('list.lastUsed')}</th>
<th>
<span className="visually-hidden">{t('common:tableActions')}</span>
</th>
</tr>
</thead>
<tbody>
{tokens.data.map((token) => (
<tr key={token.id}>
<td>{token.name}</td>
<td>{formatTime(token.createdAt)}</td>
<td>{token.lastUsedAt ? formatTime(token.lastUsedAt) : '—'}</td>
<td>
<button type="button" className="linklike" onClick={() => void remove(token.id)}>
{t('feed.delete')}
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
);
}