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
118 lines
4.0 KiB
TypeScript
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>
|
|
);
|
|
}
|