syncova-backup/apps/web/src/features/administration/SettingsPage.tsx
Jerrit Fritzsche b50ad2b9bc Sitzung ueberlebt Neuladen, Umlaute, Fehlergrenze
**Sitzung.** Die Tokens lagen nur im Arbeitsspeicher — jedes Neuladen warf den
Betreiber auf die Anmeldemaske. Das war die sicherste Variante und praktisch
unbrauchbar; mitten in einer Stoerung ist es kein Sicherheitsgewinn, sondern ein
Hindernis. Jetzt `sessionStorage` (nicht `localStorage`: stirbt mit dem Tab),
begrenzt durch zwei Uhren:

- **Harte Obergrenze** von 30 Minuten ab Anmeldung, durch keine Interaktion
  verschiebbar. Sonst waere "30 Minuten" keine Zusage.
- **Untaetigkeitsgrenze** von 30 Minuten.
- Der sofortige serverseitige Widerruf bleibt die eigentliche Absicherung — die
  Tokens sind opak, kein JWT, und genau dafuer wurden sie gewaehlt.

Dazu die Sitzungsuhr oben rechts neben "Abmelden", unter fuenf Minuten
auffaellig. Umgesetzt mit `useSyncExternalStore`: Die Restzeit haengt an der Uhr
und am Speicher, also an zwei Dingen ausserhalb von React. Sie beim Rendern
auszurechnen waere ein unreiner Aufruf, sie in einem Effekt zu setzen eine
zweite Renderrunde je Sekunde — beides hat der Linter gemeldet.

Sechs Tests halten die Grenzen fest, zwei davon durch Mutation als fangend
bestaetigt (Obergrenze mitverschieben schlaegt fehl).

**Fehlergrenze.** Ein Fehler in einer Komponente riss bisher den gesamten Baum
ab; uebrig blieb eine leere Seite — im dunklen Thema ein schwarzer Bildschirm
ohne jeden Hinweis. Die Grenze sitzt **um den Inhalt**: Menue und Kopfzeile
bleiben stehen, der Fehlertext ist lesbar und kopierbar.

**Umlaute.** Die Oberflaeche schrieb durchgehend ae/oe/ue/ss. Jetzt aeoeuess.

Dabei ein selbst verursachter Schaden, gefunden und behoben: Eine Regel
"ue → ü" ist falsch, weil die Buchstabenfolge nicht immer ein Umlaut ist. Sie
machte aus "Quelle" ein "Qülle", aus "neue" ein "neü", aus "aktuell" ein
"aktüll", aus "Dauer" ein "Daür". Die Abbildung laeuft jetzt ueber eine
gepruefte Wortliste mit Ausschluss englischer Bezeichner (`value`, `message`,
`session`, `queued`, `true`); die 23 zerstoerten Woerter sind einzeln
zurueckgesetzt. Ein alter Tippfehler ("geprueter") ist dabei mit aufgefallen.

82 Tests gruen, tsc sauber, eslint ohne Warnung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 14:42:37 +02:00

415 lines
14 KiB
TypeScript

/**
* Einstellungen: Benachrichtigungswege und der eigene zweite Faktor.
*
* Der wichtigste Satz dieser Seite steht bei der Schwelle: **Nur neue Meldungen
* werden zugestellt, und nur ab dem eingestellten Schweregrad.** Ohne Schwelle
* schaltet der Bereitschaftsdienst nach einer Woche die Benachrichtigungen ab —
* und dann kommt auch die kritische nicht mehr an.
*
* Webhooks verlangen HTTPS. Ein Schalter „Zertifikat egal" existiert nicht, und
* interne Adressen werden abgelehnt: `169.254.169.254` ist der Metadatendienst
* einer Cloud, `127.0.0.1:5432` die eigene Datenbank.
*/
import { Bell, Plus, ShieldCheck, Trash2 } from 'lucide-react';
import { useCallback, useState } from 'react';
import { useApiResource } from '@/api/useApiResource';
import { describeApiError, useMutation } from '@/api/useMutation';
import { PageHeader } from '@/components/layout/PageHeader';
import {
Button,
Callout,
Card,
CardContent,
CardHeader,
ConfirmDialog,
DataTable,
DialogBody,
DialogContent,
DialogFooter,
DialogHeader,
DialogRoot,
ErrorState,
SelectInput,
StatusBadge,
TextInput,
useToast,
type TableColumn,
} from '@/components/ui';
import type { CurrentUser } from '@/types/auth';
import {
confirmMfa,
createNotificationChannel,
deleteNotificationChannel,
enrollMfa,
listNotificationChannels,
type MfaEnrollment,
type NotificationChannel,
} from './administrationApi';
export function SettingsPage({
grantedPermissions,
currentUser,
}: {
readonly grantedPermissions: readonly string[];
readonly currentUser: CurrentUser;
}) {
const toast = useToast();
const [isChannelDialogOpen, setIsChannelDialogOpen] = useState(false);
const [channelPendingDeletion, setChannelPendingDeletion] =
useState<NotificationChannel | null>(null);
const [mfaEnrollment, setMfaEnrollment] = useState<MfaEnrollment | null>(null);
const [mfaCode, setMfaCode] = useState('');
const [isMfaConfirmed, setIsMfaConfirmed] = useState(false);
const [draftName, setDraftName] = useState('');
const [draftType, setDraftType] = useState('email');
const [draftSeverity, setDraftSeverity] = useState('high');
const [draftTarget, setDraftTarget] = useState('');
const channelsResource = useApiResource(
useCallback((abortSignal) => listNotificationChannels(abortSignal), []),
);
const mayWriteSettings =
grantedPermissions.includes('*') || grantedPermissions.includes('settings.write');
const createChannelMutation = useMutation(createNotificationChannel, {
onSuccess: () => {
toast.showSuccess('Benachrichtigungsweg angelegt');
setIsChannelDialogOpen(false);
setDraftName('');
setDraftTarget('');
channelsResource.reload();
},
onError: (apiError) =>
toast.showError(
'Der Weg ließ sich nicht anlegen',
describeApiError(apiError),
apiError.requestId,
),
});
const deleteChannelMutation = useMutation(deleteNotificationChannel, {
onSuccess: () => {
toast.showSuccess('Benachrichtigungsweg entfernt');
setChannelPendingDeletion(null);
channelsResource.reload();
},
onError: (apiError) =>
toast.showError('Fehlgeschlagen', describeApiError(apiError), apiError.requestId),
});
const enrollMfaMutation = useMutation(enrollMfa, {
onSuccess: (enrollment) => setMfaEnrollment(enrollment),
onError: (apiError) =>
toast.showError(
'Die Einrichtung ließ sich nicht beginnen',
describeApiError(apiError),
apiError.requestId,
),
});
const confirmMfaMutation = useMutation(confirmMfa, {
onSuccess: () => {
toast.showSuccess(
'Zweiter Faktor eingerichtet',
'Ab der nächsten Anmeldung wird er verlangt.',
);
setIsMfaConfirmed(true);
setMfaEnrollment(null);
setMfaCode('');
},
onError: (apiError) =>
toast.showError(
'Der Code wurde nicht anerkannt',
`${describeApiError(apiError)} — prüfen Sie die Uhrzeit des Geräts; TOTP hängt an der Zeit.`,
apiError.requestId,
),
});
const channels = channelsResource.data ?? [];
const mfaIsActive = isMfaConfirmed || currentUser.mfa_enabled;
const channelColumns: ReadonlyArray<TableColumn<NotificationChannel>> = [
{
key: 'name',
header: 'Weg',
render: (channel) => (
<div className="min-w-0">
<p className="truncate font-medium text-fg">{channel.name}</p>
<p className="text-xs text-fg-muted">
{channel.channel_type === 'email' ? 'E-Mail' : 'Webhook'}
</p>
</div>
),
},
{
key: 'severity',
header: 'Ab Schweregrad',
render: (channel) => <StatusBadge status={channel.minimum_severity} />,
},
{
key: 'actions',
header: '',
className: 'w-px',
render: (channel) =>
mayWriteSettings ? (
<Button
variant="ghost"
size="icon"
title="Entfernen"
aria-label={`${channel.name} entfernen`}
onClick={() => setChannelPendingDeletion(channel)}
>
<Trash2 />
</Button>
) : null,
},
];
const webhookIsSecure =
draftType !== 'webhook' || draftTarget.trim().toLowerCase().startsWith('https://');
return (
<>
<PageHeader
title="Einstellungen"
description="Benachrichtigungen und der eigene Zugang."
/>
{/* --- Eigener zweiter Faktor --- */}
<Card className="mb-4">
<CardHeader
title="Zweiter Faktor"
description="Für das eigene Konto."
action={
mfaIsActive ? (
<StatusBadge status="healthy" label="Eingerichtet" />
) : (
<StatusBadge status="warning" label="Fehlt" />
)
}
/>
<CardContent>
{mfaIsActive ? (
<p className="text-sm text-fg-muted">
Ihr Konto verlangt bei der Anmeldung einen zeitbasierten Code. Ein
abgefangener Code gilt im selben 30-Sekunden-Fenster kein zweites
Mal.
</p>
) : mfaEnrollment ? (
<div className="space-y-4">
<Callout tone="warning" title="Dieses Geheimnis erscheint nur jetzt">
Tragen Sie es in Ihre Authentifizierungs-App ein und
bestätigen Sie mit dem angezeigten Code. Erst dann wird der
zweite Faktor scharf — sonst hätten Sie ein Geheimnis
hinterlegt, dessen App Sie nie geprüft haben.
</Callout>
<div className="rounded-md border border-line bg-sunken p-3">
<p className="text-xs text-fg-subtle">Geheimnis (Base32)</p>
<code className="mt-1 block break-all font-mono text-sm text-fg">
{mfaEnrollment.secret}
</code>
</div>
<div className="flex flex-wrap items-end gap-2">
<TextInput
label="Code aus der App"
className="w-40 font-mono"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
value={mfaCode}
onChange={(changeEvent) => setMfaCode(changeEvent.target.value)}
placeholder="123456"
/>
<Button
variant="primary"
isLoading={confirmMfaMutation.isRunning}
disabled={mfaCode.trim().length !== 6}
onClick={() => void confirmMfaMutation.run(mfaCode.trim())}
>
Bestätigen
</Button>
</div>
</div>
) : (
<div className="space-y-3">
<p className="text-sm text-fg-muted">
Ihr Konto ist nur durch sein Passwort geschützt. Bei einem Konto
mit Löschrecht ist das der Punkt, an dem eine Übernahme zum
Datenverlust führt.
</p>
<Button
variant="primary"
isLoading={enrollMfaMutation.isRunning}
onClick={() => void enrollMfaMutation.run()}
>
<ShieldCheck />
Zweiten Faktor einrichten
</Button>
</div>
)}
</CardContent>
</Card>
{/* --- Benachrichtigungswege --- */}
<Card>
<CardHeader
title="Benachrichtigungswege"
description="Wohin Meldungen zugestellt werden."
action={
mayWriteSettings ? (
<Button variant="primary" size="sm" onClick={() => setIsChannelDialogOpen(true)}>
<Plus />
Weg anlegen
</Button>
) : null
}
/>
{channelsResource.loadState === 'failed' && channelsResource.loadError ? (
<ErrorState
message={describeApiError(channelsResource.loadError)}
requestId={channelsResource.loadError.requestId}
onRetry={channelsResource.reload}
/>
) : (
<DataTable
columns={channelColumns}
rows={channels}
getRowKey={(channel) => channel.id}
isLoading={channelsResource.loadState === 'loading'}
emptyTitle="Kein Benachrichtigungsweg"
emptyDescription="Ohne Weg erfährt niemand von einem Ausfall. Die Meldungen entstehen trotzdem und stehen unter „Meldungen“."
emptyAction={
mayWriteSettings ? (
<Button variant="primary" onClick={() => setIsChannelDialogOpen(true)}>
<Bell />
Ersten Weg anlegen
</Button>
) : null
}
/>
)}
</Card>
{/* --- Weg anlegen --- */}
<DialogRoot open={isChannelDialogOpen} onOpenChange={setIsChannelDialogOpen}>
<DialogContent>
<DialogHeader title="Benachrichtigungsweg anlegen" />
<DialogBody>
<TextInput
label="Bezeichnung"
required
value={draftName}
onChange={(changeEvent) => setDraftName(changeEvent.target.value)}
placeholder="Bereitschaft"
/>
<SelectInput
label="Art"
value={draftType}
onChange={(changeEvent) => setDraftType(changeEvent.target.value)}
>
<option value="email">E-Mail</option>
<option value="webhook">Webhook</option>
</SelectInput>
<TextInput
label={draftType === 'email' ? 'Empfaengeradresse' : 'Webhook-Adresse'}
required
className={draftType === 'webhook' ? 'font-mono' : undefined}
value={draftTarget}
onChange={(changeEvent) => setDraftTarget(changeEvent.target.value)}
placeholder={
draftType === 'email' ? 'bereitschaft@example.de' : 'https://hooks.example/…'
}
error={
draftType === 'webhook' && draftTarget.trim().length > 8 && !webhookIsSecure
? 'Nur https. Ein Webhook trägt oft ein Token im Pfad.'
: undefined
}
/>
<SelectInput
label="Ab Schweregrad zustellen"
value={draftSeverity}
onChange={(changeEvent) => setDraftSeverity(changeEvent.target.value)}
hint="Nur neue Meldungen werden zugestellt, aktualisierte nicht."
>
<option value="info">Information (alles)</option>
<option value="warning">Warnung</option>
<option value="high">Hoch (empfohlen)</option>
<option value="critical">Nur kritisch</option>
</SelectInput>
<Callout tone="info">
Eine zu niedrige Schwelle ist gefährlicher als eine zu hohe: Wer
täglich zwanzig Meldungen bekommt, schaltet sie nach einer Woche
ab — und dann kommt auch die kritische nicht mehr an.
</Callout>
{draftType === 'webhook' ? (
<Callout tone="warning">
Interne Adressen werden abgelehnt — der Metadatendienst einer
Cloud (<code className="font-mono text-xs">169.254.169.254</code>)
und die eigene Datenbank sind sonst mit einem Eintrag erreichbar.
Geprüft wird beim Anlegen <strong>und</strong> vor jedem
Verbindungsaufbau: Ein Name kann zwischenzeitlich auf eine andere
Adresse zeigen.
</Callout>
) : null}
</DialogBody>
<DialogFooter>
<Button variant="ghost" onClick={() => setIsChannelDialogOpen(false)}>
Abbrechen
</Button>
<Button
variant="primary"
isLoading={createChannelMutation.isRunning}
disabled={!draftName.trim() || !draftTarget.trim() || !webhookIsSecure}
onClick={() =>
void createChannelMutation.run({
name: draftName.trim(),
channel_type: draftType,
minimum_severity: draftSeverity,
configuration:
draftType === 'email'
? { recipient: draftTarget.trim() }
: { url: draftTarget.trim() },
})
}
>
Anlegen
</Button>
</DialogFooter>
</DialogContent>
</DialogRoot>
<ConfirmDialog
open={channelPendingDeletion !== null}
onOpenChange={(isOpen) => !isOpen && setChannelPendingDeletion(null)}
title="Benachrichtigungsweg entfernen"
description={channelPendingDeletion?.name}
confirmLabel="Entfernen"
isDestructive
isLoading={deleteChannelMutation.isRunning}
onConfirm={() => {
if (channelPendingDeletion) {
void deleteChannelMutation.run(channelPendingDeletion.id);
}
}}
>
<Callout tone="warning">
Über diesen Weg wird danach nichts mehr zugestellt. Ist es der
einzige, erfährt niemand mehr von einem Ausfall — die Meldungen
entstehen weiterhin und stehen unter „Meldungen“.
</Callout>
</ConfirmDialog>
</>
);
}