/** * 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(null); const [mfaEnrollment, setMfaEnrollment] = useState(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> = [ { key: 'name', header: 'Weg', render: (channel) => (

{channel.name}

{channel.channel_type === 'email' ? 'E-Mail' : 'Webhook'}

), }, { key: 'severity', header: 'Ab Schweregrad', render: (channel) => , }, { key: 'actions', header: '', className: 'w-px', render: (channel) => mayWriteSettings ? ( ) : null, }, ]; const webhookIsSecure = draftType !== 'webhook' || draftTarget.trim().toLowerCase().startsWith('https://'); return ( <> {/* --- Eigener zweiter Faktor --- */} ) : ( ) } /> {mfaIsActive ? (

Ihr Konto verlangt bei der Anmeldung einen zeitbasierten Code. Ein abgefangener Code gilt im selben 30-Sekunden-Fenster kein zweites Mal.

) : mfaEnrollment ? (
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.

Geheimnis (Base32)

{mfaEnrollment.secret}
setMfaCode(changeEvent.target.value)} placeholder="123456" />
) : (

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.

)}
{/* --- Benachrichtigungswege --- */} setIsChannelDialogOpen(true)}> Weg anlegen ) : null } /> {channelsResource.loadState === 'failed' && channelsResource.loadError ? ( ) : ( 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 ? ( ) : null } /> )} {/* --- Weg anlegen --- */} setDraftName(changeEvent.target.value)} placeholder="Bereitschaft" /> setDraftType(changeEvent.target.value)} > 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 } /> setDraftSeverity(changeEvent.target.value)} hint="Nur neue Meldungen werden zugestellt, aktualisierte nicht." > 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. {draftType === 'webhook' ? ( Interne Adressen werden abgelehnt — der Metadatendienst einer Cloud (169.254.169.254) und die eigene Datenbank sind sonst mit einem Eintrag erreichbar. Geprüft wird beim Anlegen und vor jedem Verbindungsaufbau: Ein Name kann zwischenzeitlich auf eine andere Adresse zeigen. ) : null} !isOpen && setChannelPendingDeletion(null)} title="Benachrichtigungsweg entfernen" description={channelPendingDeletion?.name} confirmLabel="Entfernen" isDestructive isLoading={deleteChannelMutation.isRunning} onConfirm={() => { if (channelPendingDeletion) { void deleteChannelMutation.run(channelPendingDeletion.id); } }} > Ü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“. ); }