**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>
415 lines
14 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|