From ccfff87d3f0d8c61d5bcd2970a59db0afe06d5c6 Mon Sep 17 00:00:00 2001 From: Jerrit Fritzsche Date: Tue, 18 Aug 2026 13:35:05 +0200 Subject: [PATCH] Weboberflaeche: vollstaendige Verwaltungskonsole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Von 95 fachlichen Endpunkten erreicht die Oberflaeche jetzt 89 statt 23. Schreibend waren es neun (vier davon An- und Abmeldung) — jetzt ist jede Handlung der Anlage bedienbar. Neu bedienbar: - **Wiederherstellung** als vierstufiger Assistent. Die Vorabpruefung ist ein eigener Schritt, weil sie den Unterschied zwischen Hoffnung und Nachweis macht: Sie schreibt nichts und stellt fest, ob **jeder benoetigte Block noch da ist**. Ein Manifest allein belegt nur, dass jemand einmal etwas gesichert hat. Die drei Huerden vor dem Ueberschreiben sind sichtbar umgesetzt; laeuft die dritte ins Leere, entfaellt sie — ein Ritual ohne Anlass gewoehnt das Wegklicken an. - **Wiederherstellungspunkte** mit Bewertung, Schutz, Ransomware-Einschaetzung, Legal Hold, Fristverlaengerung und Loeschung. Eine unbelastbare Prozentzahl wird als Vermutung gekennzeichnet, ungemessene Eingangsgroessen erscheinen als "ungemessen" statt als null Punkte. - **Pruefung** mit allen fuenf Pruefarten. Zustand und Ergebnis stehen nebeneinander: Eine gescheiterte Pruefung ist kein Befund am Backup. - **Repositories** mit Integritaetslauf, Katalog-Neuaufbau, Gesundheitspruefung und gemessener Durchsetzungsstufe. Die Uebernahme sagt ausdruecklich, dass hier nichts angelegt wird. - **Aufbewahrung** mit Regeln und Vorschau vor dem Loeschen. - **Proxmox** (neun Endpunkte, bisher ohne jede Oberflaeche) und **Agenten** samt einmaliger Anzeige des Aufnahme-Tokens. - **Benutzer, Rollen, Benachrichtigungswege, eigener zweiter Faktor.** Zwei Funde beim Nachweis gegen den laufenden Dienst: - **Die Einstufung heisst `successful`, nicht `unverified`.** Das Vokabular lautet failed/corrupted/successful/verified/recoverable. `successful` ist die Falle: Es bedeutet "der Lauf ist durchgelaufen" — nicht "wiederherstellbar". Es ist deshalb **neutral**, nicht gruen; ein gruenes Abzeichen laese sich als "geprueft und in Ordnung", und genau diese Verwechslung soll die Anlage verhindern. Regressionstest, durch Mutation als fangend bestaetigt. - **Der Geheimnis-Scanner griff korrekt** beim Platzhaltertext des SSH-Schluesselfelds. Gekennzeichnet statt das Muster aufzuweichen. Die Statuszuordnung steht an genau einer Stelle und faellt fuer unbekannte Werte auf neutral zurueck, nie auf gruen: Ein neuer Serverzustand darf nicht als "in Ordnung" durchgehen. Nachgewiesen im Container gegen Debian 12 mit echtem PostgreSQL und nginx, ueber genau die Aufrufe, die die Konsole macht: Repository uebernommen, Durchsetzungsstufe gemessen (advisory — korrekt auf overlayfs), Auftrag angelegt, Lauf 202, zweiter Anstoss 409, Sicherung erfolgreich (2 Objekte, 3.000.006 Byte, 0 uebergangen), Blockpruefung clean (5 Bloecke), Vorabpruefung "wiederherstellbar: 2 Dateien, 2,9 MiB", Wiederherstellung nach /etc abgewiesen. 76 Tests gruen, tsc sauber, eslint ohne Warnung, Bau 460 KB (136 KB gzip). Co-Authored-By: Claude Opus 5 --- apps/web/src/App.tsx | 76 ++- .../src/components/ui/StatusBadge.test.tsx | 74 +++ apps/web/src/components/ui/StatusBadge.tsx | 11 +- .../features/administration/EventsPage.tsx | 222 +++++++ .../src/features/administration/RolesPage.tsx | 369 +++++++++++ .../features/administration/SettingsPage.tsx | 414 ++++++++++++ .../src/features/administration/UsersPage.tsx | 460 +++++++++++++ .../administration/administrationApi.ts | 252 +++++++ .../src/features/backups/BackupDetailPage.tsx | 591 +++++++++++++++++ .../src/features/backups/VerificationPage.tsx | 203 ++++++ apps/web/src/features/backups/backupsApi.ts | 287 ++++++++ .../features/dashboard/RecoveryPointsPage.tsx | 35 +- .../src/features/identity/IdentityPages.tsx | 164 ----- .../features/infrastructure/AgentsPage.tsx | 383 +++++++++++ .../infrastructure/ProtectedSystemsPage.tsx | 163 +++++ .../features/infrastructure/ProxmoxPage.tsx | 618 ++++++++++++++++++ .../infrastructure/infrastructureApi.ts | 262 ++++++++ .../src/features/inventory/InventoryPages.tsx | 357 ---------- .../src/features/inventory/inventoryApi.ts | 113 ---- .../repositories/RepositoriesPage.tsx | 496 ++++++++++++++ .../features/repositories/RetentionPage.tsx | 452 +++++++++++++ .../features/repositories/repositoriesApi.ts | 277 ++++++++ .../src/features/restores/RestoresPage.tsx | 328 ++++++++++ 23 files changed, 5954 insertions(+), 653 deletions(-) create mode 100644 apps/web/src/components/ui/StatusBadge.test.tsx create mode 100644 apps/web/src/features/administration/EventsPage.tsx create mode 100644 apps/web/src/features/administration/RolesPage.tsx create mode 100644 apps/web/src/features/administration/SettingsPage.tsx create mode 100644 apps/web/src/features/administration/UsersPage.tsx create mode 100644 apps/web/src/features/administration/administrationApi.ts create mode 100644 apps/web/src/features/backups/BackupDetailPage.tsx create mode 100644 apps/web/src/features/backups/VerificationPage.tsx create mode 100644 apps/web/src/features/backups/backupsApi.ts delete mode 100644 apps/web/src/features/identity/IdentityPages.tsx create mode 100644 apps/web/src/features/infrastructure/AgentsPage.tsx create mode 100644 apps/web/src/features/infrastructure/ProtectedSystemsPage.tsx create mode 100644 apps/web/src/features/infrastructure/ProxmoxPage.tsx create mode 100644 apps/web/src/features/infrastructure/infrastructureApi.ts delete mode 100644 apps/web/src/features/inventory/InventoryPages.tsx delete mode 100644 apps/web/src/features/inventory/inventoryApi.ts create mode 100644 apps/web/src/features/repositories/RepositoriesPage.tsx create mode 100644 apps/web/src/features/repositories/RetentionPage.tsx create mode 100644 apps/web/src/features/repositories/repositoriesApi.ts create mode 100644 apps/web/src/features/restores/RestoresPage.tsx diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index b7169a2..1cf1c07 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -18,13 +18,18 @@ import { LoginPage } from './features/auth/LoginPage'; import { AlertsPage } from './features/alerts/AlertsPage'; import { DashboardPage } from './features/dashboard/DashboardPage'; import { RecoveryPointsPage } from './features/dashboard/RecoveryPointsPage'; -import { RolesPage, UsersPage } from './features/identity/IdentityPages'; -import { - AgentsPage, - EventsPage, - RepositoriesPage, - RestoresPage, -} from './features/inventory/InventoryPages'; +import { RolesPage } from './features/administration/RolesPage'; +import { SettingsPage } from './features/administration/SettingsPage'; +import { UsersPage } from './features/administration/UsersPage'; +import { EventsPage } from './features/administration/EventsPage'; +import { AgentsPage } from './features/infrastructure/AgentsPage'; +import { ProxmoxPage } from './features/infrastructure/ProxmoxPage'; +import { ProtectedSystemsPage } from './features/infrastructure/ProtectedSystemsPage'; +import { RepositoriesPage } from './features/repositories/RepositoriesPage'; +import { RetentionPage } from './features/repositories/RetentionPage'; +import { BackupDetailPage } from './features/backups/BackupDetailPage'; +import { VerificationPage } from './features/backups/VerificationPage'; +import { RestoresPage } from './features/restores/RestoresPage'; import { JobDetailPage } from './features/jobs/JobDetailPage'; import { JobsPage } from './features/jobs/JobsPage'; import { MetricsPage } from './features/metrics/MetricsPage'; @@ -85,6 +90,7 @@ export function App(): React.JSX.Element { @@ -97,11 +103,13 @@ export function App(): React.JSX.Element { function CurrentPageContent({ currentPageId, grantedPermissions, + currentUser, openObjectIdentifier, onOpenObject, }: { readonly currentPageId: string; readonly grantedPermissions: readonly string[]; + readonly currentUser: CurrentUser; readonly openObjectIdentifier: string | null; readonly onOpenObject: (objectIdentifier: string | null) => void; }): React.JSX.Element { @@ -148,17 +156,49 @@ function CurrentPageContent({ ); case 'recovery-points': - return ; + return openObjectIdentifier ? ( + onOpenObject(null)} + onDeleted={() => onOpenObject(null)} + /> + ) : ( + + ); case 'restores': - return ; + return ; + + case 'verification': + return openObjectIdentifier ? ( + onOpenObject(null)} + onDeleted={() => onOpenObject(null)} + /> + ) : ( + + ); case 'repositories': - return ; + return ; + + case 'retention': + return ; + + case 'agents': + return ; + + case 'proxmox': + return ; case 'protected-systems': - case 'agents': - return ; + return ; case 'events': return ; @@ -176,10 +216,18 @@ function CurrentPageContent({ return ; case 'users': - return ; + return ; case 'roles': - return ; + return ; + + case 'settings': + return ( + + ); default: // Eine Seite steht im Verzeichnis, hat aber noch keine Umsetzung. Das diff --git a/apps/web/src/components/ui/StatusBadge.test.tsx b/apps/web/src/components/ui/StatusBadge.test.tsx new file mode 100644 index 0000000..c441bab --- /dev/null +++ b/apps/web/src/components/ui/StatusBadge.test.tsx @@ -0,0 +1,74 @@ +/** + * Prueft die Zuordnung der Fachbegriffe auf die fuenf Bedeutungen. + * + * Diese Tabelle ist der eine Ort, an dem ein falscher Ton die Aussage der + * ganzen Anlage umkehren kann: Ein gruenes Abzeichen an einem Teilfehler oder + * an einem ungepruften Backup macht aus einer Warnung eine Entwarnung — und + * niemand sieht es, weil die Oberflaeche einwandfrei aussieht. + */ + +import { describe, expect, it } from 'vitest'; +import { resolveStatus } from './StatusBadge'; + +describe('Statuszuordnung', () => { + it('faerbt einen Teilfehler niemals gruen', () => { + // Entwicklungsregel 1: Teilfehler heissen PARTIAL FAILURE, nie SUCCESS. + expect(resolveStatus('partial_failure').tone).not.toBe('healthy'); + expect(resolveStatus('partial_failure').tone).toBe('warning'); + }); + + it('faerbt ein gesichertes, aber ungepruftes Backup nicht gruen', () => { + // `successful` heisst "der Lauf ist durchgelaufen" — nicht + // "wiederherstellbar". Erst ein Wiederherstellungstest hebt auf + // `recoverable`. + expect(resolveStatus('successful').tone).toBe('neutral'); + expect(resolveStatus('recoverable').tone).toBe('healthy'); + }); + + it('behandelt ein beschaedigtes Backup als kritisch, nicht als Warnung', () => { + // Ein Backup mit einem beschaedigten Block ist nicht zu 70 Prozent + // wiederherstellbar, sondern gar nicht. + expect(resolveStatus('corrupted').tone).toBe('critical'); + }); + + it('faerbt einen nur softwareseitig durchgesetzten Loeschschutz als Warnung', () => { + // `advisory` bedeutet: Wer Zugriff auf die Dateien hat, kann die Backups + // loeschen. Das als "in Ordnung" zu zeigen waere die gefaehrlichste + // Beschoenigung der Anlage. + expect(resolveStatus('advisory').tone).toBe('warning'); + expect(resolveStatus('filesystem').tone).toBe('healthy'); + }); + + it('zeigt einen unbekannten Zustand neutral und niemals gruen', () => { + // Ein neuer Serverzustand, den diese Tabelle noch nicht kennt, darf nicht + // als "in Ordnung" durchgehen. Gruen waere der bequeme Standard und der + // gefaehrlichste. + const unknownStatus = resolveStatus('ein_neuer_serverzustand'); + + expect(unknownStatus.tone).toBe('neutral'); + expect(unknownStatus.label).toBe('ein_neuer_serverzustand'); + }); + + it('behandelt einen fehlenden Wert als unbekannt', () => { + expect(resolveStatus(null).tone).toBe('neutral'); + expect(resolveStatus(undefined).label).toBe('Unbekannt'); + }); + + it('kennt jede Einstufung, die der Server vergeben kann', () => { + // Das Vokabular stammt aus packages/verification/assurance.go. Kommt dort + // eine Einstufung hinzu, ohne dass sie hier auftaucht, erscheint sie in der + // Oberflaeche als englischer Rohwert. + for (const classification of [ + 'failed', + 'corrupted', + 'successful', + 'verified', + 'recoverable', + ]) { + expect( + resolveStatus(classification).label, + `Einstufung ${classification} ist nicht uebersetzt`, + ).not.toBe(classification); + } + }); +}); diff --git a/apps/web/src/components/ui/StatusBadge.tsx b/apps/web/src/components/ui/StatusBadge.tsx index c9124e8..36a90c4 100644 --- a/apps/web/src/components/ui/StatusBadge.tsx +++ b/apps/web/src/components/ui/StatusBadge.tsx @@ -54,9 +54,18 @@ const statusDefinitions: Readonly> = { disabled: { tone: 'neutral', label: 'Deaktiviert' }, // --- Einstufung der Wiederherstellungspunkte (Phase 10) --- + // + // Das Vokabular lautet: failed, successful, verified, recoverable, corrupted. + // + // `successful` ist die Falle dieser Tabelle und deshalb **neutral**, nicht + // gruen: Es bedeutet "der Sicherungslauf ist durchgelaufen" — nicht, dass + // sich daraus etwas wiederherstellen laesst. Ein gruenes Abzeichen laese sich + // hier als "geprueft und in Ordnung", und genau diese Verwechslung soll die + // ganze Anlage verhindern. Erst ein Wiederherstellungstest hebt auf + // `recoverable`; alles davor ist ein Indiz. recoverable: { tone: 'healthy', label: 'Wiederherstellbar' }, verified: { tone: 'info', label: 'Geprueft' }, - unverified: { tone: 'neutral', label: 'Ungeprueft' }, + successful: { tone: 'neutral', label: 'Gesichert, ungeprueft' }, corrupted: { tone: 'critical', label: 'Beschaedigt' }, // --- Pruefergebnisse --- diff --git a/apps/web/src/features/administration/EventsPage.tsx b/apps/web/src/features/administration/EventsPage.tsx new file mode 100644 index 0000000..130649f --- /dev/null +++ b/apps/web/src/features/administration/EventsPage.tsx @@ -0,0 +1,222 @@ +/** + * Ereignisprotokoll. + * + * Das Protokoll ist **append-only per Datenbank-Trigger**, nicht nur per + * Anwendungslogik. Es laesst sich weder aus der Oberflaeche noch ueber die API + * aendern oder loeschen — ein Protokoll, das sich bereinigen liesse, waere + * keines. Deshalb gibt es hier keine Handlungen, nur eine Suche. + * + * Die Adresse in der Spalte „Herkunft" ist die der tatsaechlichen Verbindung. + * Weitergeleitete IP-Header werden bewusst ignoriert: Sie sind faelschbar, und + * im Audit stuende sonst eine beliebige Adresse. Hinter einem Reverse Proxy — + * also im Normalfall dieser Anlage — steht dort dessen Adresse. + */ + +import { RefreshCw, Search } from 'lucide-react'; +import { useCallback, useMemo, useState } from 'react'; +import { requestApi } from '@/api/client'; +import { useApiResource } from '@/api/useApiResource'; +import { describeApiError } from '@/api/useMutation'; +import { PageHeader } from '@/components/layout/PageHeader'; +import { + Button, + Callout, + Card, + DataTable, + ErrorState, + StatusBadge, + TextInput, + type TableColumn, +} from '@/components/ui'; +import { formatDateTime, formatRelativeTime } from '@/lib/utils'; + +/** Ein Eintrag des Auditprotokolls. */ +interface AuditEvent { + readonly id: string; + readonly created_at: string; + readonly action: string; + readonly actor_username?: string; + readonly entity_type?: string; + readonly entity_id?: string; + readonly result: string; + readonly ip_address?: string; + readonly correlation_id?: string; +} + +/** Laedt die letzten Ereignisse. */ +async function fetchAuditEvents(abortSignal: AbortSignal): Promise { + return requestApi('/audit-events?page_size=100', { + signal: abortSignal, + }); +} + +export function EventsPage() { + const [searchTerm, setSearchTerm] = useState(''); + + const eventsResource = useApiResource( + useCallback((abortSignal: AbortSignal) => fetchAuditEvents(abortSignal), []), + ); + + /** + * Filterung im Browser. + * + * Die Liste umfasst hoechstens hundert Eintraege; eine serverseitige Suche + * waere hier mehr Vertrag als Nutzen. Bei groesseren Bestaenden gehoert sie + * auf den Server — das ist eine bewusste Grenze, keine Auslassung. + */ + const filteredEvents = useMemo(() => { + const allEvents = eventsResource.data ?? []; + const normalizedTerm = searchTerm.trim().toLowerCase(); + + if (!normalizedTerm) { + return allEvents; + } + + return allEvents.filter((auditEvent) => + [ + auditEvent.action, + auditEvent.actor_username, + auditEvent.entity_type, + auditEvent.entity_id, + auditEvent.correlation_id, + ] + .filter((field): field is string => typeof field === 'string') + .some((field) => field.toLowerCase().includes(normalizedTerm)), + ); + }, [eventsResource.data, searchTerm]); + + const tableColumns: ReadonlyArray> = [ + { + key: 'created_at', + header: 'Zeitpunkt', + render: (auditEvent) => ( +
+

{formatDateTime(auditEvent.created_at)}

+

+ {formatRelativeTime(auditEvent.created_at)} +

+
+ ), + }, + { + key: 'action', + header: 'Handlung', + render: (auditEvent) => ( + {auditEvent.action} + ), + }, + { + key: 'actor', + header: 'Wer', + render: (auditEvent) => ( + + {/* Ein fehlender Akteur ist die Anlage selbst — etwa bei der + Erstinbetriebnahme oder einem Zeitplanlauf. */} + {auditEvent.actor_username ?? ( + System + )} + + ), + }, + { + key: 'entity', + header: 'Gegenstand', + render: (auditEvent) => ( +
+

{auditEvent.entity_type ?? '—'}

+ {auditEvent.entity_id ? ( +

+ {auditEvent.entity_id} +

+ ) : null} +
+ ), + }, + { + key: 'result', + header: 'Ausgang', + render: (auditEvent) => , + }, + { + key: 'correlation', + header: 'Vorgang', + render: (auditEvent) => + auditEvent.correlation_id ? ( + + {auditEvent.correlation_id.slice(0, 8)}… + + ) : ( + '—' + ), + }, + ]; + + if (eventsResource.loadState === 'failed' && eventsResource.loadError) { + return ( + <> + + + + + + ); + } + + return ( + <> + + + Aktualisieren + + } + /> + + + Das Protokoll ist append-only per Datenbank-Trigger. Es + laesst sich weder hier noch ueber die API aendern oder loeschen — auch + nicht von einem Administrator. + + + +
+
+ + setSearchTerm(changeEvent.target.value)} + /> +
+
+ + auditEvent.id} + isLoading={eventsResource.loadState === 'loading'} + emptyTitle={ + searchTerm ? 'Kein Treffer' : 'Das Auditprotokoll ist leer' + } + emptyDescription={ + searchTerm + ? 'Der Suchbegriff kommt in den letzten 100 Ereignissen nicht vor.' + : 'Sobald jemand eine sicherheitsrelevante Handlung ausfuehrt, erscheint sie hier.' + } + /> +
+ + ); +} diff --git a/apps/web/src/features/administration/RolesPage.tsx b/apps/web/src/features/administration/RolesPage.tsx new file mode 100644 index 0000000..62834e8 --- /dev/null +++ b/apps/web/src/features/administration/RolesPage.tsx @@ -0,0 +1,369 @@ +/** + * Rollen und Berechtigungen. + * + * Die Berechtigungsauswahl ist nach Bereichen gruppiert, weil eine flache Liste + * mit 34 Eintraegen dazu fuehrt, dass jemand „alles" ankreuzt. Zwei Paare sind + * bewusst getrennt und werden hier ausdruecklich erklaert: + * + * - `restores.execute` enthaelt **nicht** `restores.overwrite`. Wiederherstellen + * ist etwas anderes als vorhandene Daten ueberschreiben. + * - `backups.delete` enthaelt **nicht** `immutability.manage`. Wer aufraeumen + * darf, darf keinen Loeschschutz aufheben — das ist der Schritt, der einem + * Angreifer den Weg oeffnet. + * + * Mitgelieferte Rollen sind unveraenderlich und werden als solche gezeigt, + * statt eine Maske anzubieten, die der Server ablehnt. + */ + +import { Lock, Plus, Trash2 } from 'lucide-react'; +import { useCallback, useMemo, useState } from 'react'; +import { useApiResource } from '@/api/useApiResource'; +import { describeApiError, useMutation } from '@/api/useMutation'; +import { PageHeader } from '@/components/layout/PageHeader'; +import { + Button, + Callout, + Card, + CheckboxField, + ConfirmDialog, + DataTable, + DialogBody, + DialogContent, + DialogFooter, + DialogHeader, + DialogRoot, + ErrorState, + TextInput, + useToast, + type TableColumn, +} from '@/components/ui'; +import { + createRole, + deleteRole, + listPermissions, + listRoles, + type Permission, + type Role, +} from './administrationApi'; + +/** Erklaerungen zu Berechtigungen, deren Zuschnitt nicht offensichtlich ist. */ +const PERMISSION_NOTES: Readonly> = { + 'restores.overwrite': + 'Nicht in restores.execute enthalten. Erlaubt das Ueberschreiben vorhandener Daten am Ziel.', + 'immutability.manage': + 'Nicht in backups.delete enthalten. Erlaubt das Aufheben von Loeschschutz und Legal Hold.', + 'verification.restore_test': + 'Liest ein Backup vollstaendig und schreibt es versuchsweise zurueck — die einzige Pruefung, die ein Nachweis ist.', + 'settings.write': + 'Umfasst die Benachrichtigungswege. Wer sie umleitet, kann erreichen, dass niemand mehr von einem Ausfall erfaehrt.', +}; + +export function RolesPage({ + grantedPermissions, +}: { + readonly grantedPermissions: readonly string[]; +}) { + const toast = useToast(); + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [rolePendingDeletion, setRolePendingDeletion] = useState(null); + const [selectedRole, setSelectedRole] = useState(null); + + const [draftName, setDraftName] = useState(''); + const [draftDescription, setDraftDescription] = useState(''); + const [draftPermissions, setDraftPermissions] = useState([]); + + const rolesResource = useApiResource( + useCallback((abortSignal) => listRoles(abortSignal), []), + ); + const permissionsResource = useApiResource( + useCallback((abortSignal) => listPermissions(abortSignal), []), + ); + + const mayWrite = + grantedPermissions.includes('*') || grantedPermissions.includes('roles.write'); + + const createMutation = useMutation(createRole, { + onSuccess: (role) => { + toast.showSuccess('Rolle angelegt', role.name); + setIsCreateDialogOpen(false); + setDraftName(''); + setDraftDescription(''); + setDraftPermissions([]); + rolesResource.reload(); + }, + onError: (apiError) => + toast.showError('Die Rolle liess sich nicht anlegen', describeApiError(apiError), apiError.requestId), + }); + + const deleteMutation = useMutation(deleteRole, { + onSuccess: () => { + toast.showSuccess('Rolle geloescht'); + setRolePendingDeletion(null); + rolesResource.reload(); + }, + onError: (apiError) => + toast.showError('Das Loeschen schlug fehl', describeApiError(apiError), apiError.requestId), + }); + + const roles = rolesResource.data ?? []; + + /** + * Berechtigungen nach Bereich gruppiert. + * + * Der Rueckfall auf ein leeres Array steht **innerhalb** der Berechnung: Ein + * `?? []` davor erzeugte bei jedem Rendern ein neues Array, und die + * Gruppierung liefe jedes Mal neu — bei 34 Berechtigungen unmerklich, als + * Muster trotzdem falsch. + */ + const permissionsByCategory = useMemo(() => { + const grouped = new Map(); + + for (const permission of permissionsResource.data ?? []) { + // Ohne ausdrueckliche Kategorie dient der Teil vor dem Punkt als Bereich — + // "backups.read" gehoert zu "backups". + const categoryName = + permission.category ?? permission.name.split('.')[0] ?? 'sonstige'; + const existing = grouped.get(categoryName) ?? []; + existing.push(permission); + grouped.set(categoryName, existing); + } + + return [...grouped.entries()].sort(([left], [right]) => left.localeCompare(right)); + }, [permissionsResource.data]); + + const tableColumns: ReadonlyArray> = [ + { + key: 'name', + header: 'Rolle', + render: (role) => ( +
+ {role.is_builtin ? ( + + ) : null} +
+

{role.name}

+ {role.description ? ( +

{role.description}

+ ) : null} +
+
+ ), + }, + { + key: 'permissions', + header: 'Berechtigungen', + numeric: true, + render: (role) => role.permissions.length, + }, + { + key: 'users', + header: 'Zugewiesen', + numeric: true, + render: (role) => role.user_count ?? '—', + }, + { + key: 'kind', + header: 'Art', + render: (role) => ( + + {role.is_builtin ? 'Mitgeliefert (unveraenderlich)' : 'Selbst angelegt'} + + ), + }, + { + key: 'actions', + header: '', + className: 'w-px', + render: (role) => + mayWrite && !role.is_builtin ? ( + + ) : null, + }, + ]; + + if (rolesResource.loadState === 'failed' && rolesResource.loadError) { + return ( + <> + + + + + + ); + } + + return ( + <> + setIsCreateDialogOpen(true)}> + + Rolle anlegen + + ) : null + } + /> + + + role.id} + onRowClick={(role) => setSelectedRole(role)} + isLoading={rolesResource.loadState === 'loading'} + emptyTitle="Keine Rollen" + /> + + + {selectedRole ? ( + +
+
+

{selectedRole.name}

+

+ {selectedRole.description ?? 'Ohne Beschreibung'} +

+
+ +
+
+ {selectedRole.is_builtin ? ( + + Mitgelieferte Rollen sind unveraenderlich. Eine + Aenderung verschoebe die Bedeutung bestehender Zuweisungen — + jemand behielte eine Rolle, die etwas anderes bedeutet als bei + ihrer Vergabe. + + ) : null} +
+ {selectedRole.permissions.map((permissionName) => ( + + {permissionName} + + ))} +
+
+
+ ) : null} + + {/* --- Rolle anlegen --- */} + + + + + setDraftName(changeEvent.target.value)} + placeholder="Bereitschaft Nacht" + /> + setDraftDescription(changeEvent.target.value)} + placeholder="Darf Laeufe anstossen und Meldungen bestaetigen" + /> + +
+ {permissionsByCategory.map(([categoryName, categoryPermissions]) => ( +
+

+ {categoryName} +

+
+ {categoryPermissions.map((permission) => ( + + setDraftPermissions((currentPermissions) => + isChecked + ? [...currentPermissions, permission.name] + : currentPermissions.filter( + (permissionName) => permissionName !== permission.name, + ), + ) + } + label={ + {permission.name} + } + hint={PERMISSION_NOTES[permission.name] ?? permission.description} + /> + ))} +
+
+ ))} +
+
+ + + + +
+
+ + !isOpen && setRolePendingDeletion(null)} + title="Rolle loeschen" + description={rolePendingDeletion?.name} + confirmLabel="Loeschen" + isDestructive + isLoading={deleteMutation.isRunning} + onConfirm={() => { + if (rolePendingDeletion) { + void deleteMutation.run(rolePendingDeletion.id); + } + }} + > + + Konten mit dieser Rolle verlieren die zugehoerigen Rechte sofort. Ist + sie noch zugewiesen, lehnt der Server das Loeschen ab. + + + + ); +} diff --git a/apps/web/src/features/administration/SettingsPage.tsx b/apps/web/src/features/administration/SettingsPage.tsx new file mode 100644 index 0000000..afcf9bb --- /dev/null +++ b/apps/web/src/features/administration/SettingsPage.tsx @@ -0,0 +1,414 @@ +/** + * 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 liess 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 liess sich nicht beginnen', + describeApiError(apiError), + apiError.requestId, + ), + }); + + const confirmMfaMutation = useMutation(confirmMfa, { + onSuccess: () => { + toast.showSuccess( + 'Zweiter Faktor eingerichtet', + 'Ab der naechsten Anmeldung wird er verlangt.', + ); + setIsMfaConfirmed(true); + setMfaEnrollment(null); + setMfaCode(''); + }, + onError: (apiError) => + toast.showError( + 'Der Code wurde nicht anerkannt', + `${describeApiError(apiError)} — pruefen Sie die Uhrzeit des Geraets; TOTP haengt 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 + bestaetigen Sie mit dem angezeigten Code. Erst dann wird der + zweite Faktor scharf — sonst haetten Sie ein Geheimnis + hinterlegt, dessen App Sie nie geprueft haben. + + +
+

Geheimnis (Base32)

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

+ Ihr Konto ist nur durch sein Passwort geschuetzt. Bei einem Konto + mit Loeschrecht ist das der Punkt, an dem eine Uebernahme zum + Datenverlust fuehrt. +

+ +
+ )} +
+
+ + {/* --- Benachrichtigungswege --- */} + + setIsChannelDialogOpen(true)}> + + Weg anlegen + + ) : null + } + /> + + {channelsResource.loadState === 'failed' && channelsResource.loadError ? ( + + ) : ( + channel.id} + isLoading={channelsResource.loadState === 'loading'} + emptyTitle="Kein Benachrichtigungsweg" + emptyDescription="Ohne Weg erfaehrt 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 traegt oft ein Token im Pfad.' + : undefined + } + /> + + setDraftSeverity(changeEvent.target.value)} + hint="Nur neue Meldungen werden zugestellt, aktualisierte nicht." + > + + + + + + + + Eine zu niedrige Schwelle ist gefaehrlicher als eine zu hohe: Wer + taeglich 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. + Geprueft 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); + } + }} + > + + Ueber diesen Weg wird danach nichts mehr zugestellt. Ist es der + einzige, erfaehrt niemand mehr von einem Ausfall — die Meldungen + entstehen weiterhin und stehen unter „Meldungen“. + + + + ); +} diff --git a/apps/web/src/features/administration/UsersPage.tsx b/apps/web/src/features/administration/UsersPage.tsx new file mode 100644 index 0000000..8dd571e --- /dev/null +++ b/apps/web/src/features/administration/UsersPage.tsx @@ -0,0 +1,460 @@ +/** + * Benutzer. + * + * Der Schutz des letzten Administrators liegt beim Server — die Oberflaeche + * kann ihn nicht durchsetzen, weil sie nicht weiss, wer sonst noch Administrator + * ist. Was sie tut: den Serverfehler in einen verstaendlichen Satz uebersetzen, + * statt „LAST_ADMINISTRATOR" anzuzeigen. + * + * Ein fehlender zweiter Faktor wird benannt, nicht verschwiegen. Er ist ein + * Sicherheitsbefund, und die Liste ist der einzige Ort, an dem er auffaellt. + */ + +import { Plus, ShieldOff, Trash2, UserCog } 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, + CheckboxField, + ConfirmDialog, + DataTable, + DialogBody, + DialogContent, + DialogFooter, + DialogHeader, + DialogRoot, + ErrorState, + StatusBadge, + TextInput, + useToast, + type TableColumn, +} from '@/components/ui'; +import { formatRelativeTime } from '@/lib/utils'; +import { + createUser, + deleteUser, + disableUserMfa, + listRoles, + listUsers, + updateUser, + type User, +} from './administrationApi'; + +export function UsersPage({ + grantedPermissions, +}: { + readonly grantedPermissions: readonly string[]; +}) { + const toast = useToast(); + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [userPendingDeletion, setUserPendingDeletion] = useState(null); + const [userPendingMfaReset, setUserPendingMfaReset] = useState(null); + const [userBeingEdited, setUserBeingEdited] = useState(null); + + const [draftUsername, setDraftUsername] = useState(''); + const [draftEmail, setDraftEmail] = useState(''); + const [draftPassword, setDraftPassword] = useState(''); + const [draftRoles, setDraftRoles] = useState([]); + + const usersResource = useApiResource( + useCallback((abortSignal) => listUsers(abortSignal), []), + ); + const rolesResource = useApiResource( + useCallback((abortSignal) => listRoles(abortSignal), []), + ); + + const mayWrite = + grantedPermissions.includes('*') || grantedPermissions.includes('users.write'); + + const createMutation = useMutation(createUser, { + onSuccess: (user) => { + toast.showSuccess( + 'Konto angelegt', + `${user.username} — richten Sie als Naechstes den zweiten Faktor ein.`, + ); + setIsCreateDialogOpen(false); + setDraftUsername(''); + setDraftEmail(''); + setDraftPassword(''); + setDraftRoles([]); + usersResource.reload(); + }, + onError: (apiError) => + toast.showError('Das Konto liess sich nicht anlegen', describeApiError(apiError), apiError.requestId), + }); + + const updateMutation = useMutation(updateUser, { + onSuccess: () => { + toast.showSuccess('Konto geaendert'); + setUserBeingEdited(null); + usersResource.reload(); + }, + onError: (apiError) => + toast.showError('Die Aenderung schlug fehl', describeApiError(apiError), apiError.requestId), + }); + + const deleteMutation = useMutation(deleteUser, { + onSuccess: () => { + toast.showSuccess('Konto geloescht'); + setUserPendingDeletion(null); + usersResource.reload(); + }, + onError: (apiError) => + toast.showError('Das Loeschen schlug fehl', describeApiError(apiError), apiError.requestId), + }); + + const mfaResetMutation = useMutation(disableUserMfa, { + onSuccess: () => { + toast.showSuccess( + 'Zweiter Faktor zurueckgesetzt', + 'Das Konto ist jetzt nur noch durch sein Passwort geschuetzt.', + ); + setUserPendingMfaReset(null); + usersResource.reload(); + }, + onError: (apiError) => + toast.showError('Fehlgeschlagen', describeApiError(apiError), apiError.requestId), + }); + + const users = usersResource.data ?? []; + const roles = rolesResource.data ?? []; + + const tableColumns: ReadonlyArray> = [ + { + key: 'username', + header: 'Konto', + render: (user) => ( +
+

{user.username}

+

{user.email ?? '—'}

+
+ ), + }, + { + key: 'status', + header: 'Zustand', + render: (user) => , + }, + { + key: 'roles', + header: 'Rollen', + render: (user) => ( + {user.roles.join(', ') || '—'} + ), + }, + { + key: 'mfa', + header: 'Zweiter Faktor', + render: (user) => + user.mfa_enabled ? ( + + ) : ( + // Ein fehlender zweiter Faktor ist ein Sicherheitsbefund und wird + // benannt, nicht verschwiegen. + + ), + }, + { + key: 'last_login', + header: 'Letzte Anmeldung', + render: (user) => ( + + {user.last_login_at ? formatRelativeTime(user.last_login_at) : 'Noch nie'} + + ), + }, + { + key: 'actions', + header: '', + className: 'w-px', + render: (user) => + mayWrite ? ( +
clickEvent.stopPropagation()} + onKeyDown={(keyboardEvent) => keyboardEvent.stopPropagation()} + role="presentation" + > + + {user.mfa_enabled ? ( + + ) : null} + +
+ ) : null, + }, + ]; + + const usersWithoutMfa = users.filter((user) => !user.mfa_enabled).length; + + if (usersResource.loadState === 'failed' && usersResource.loadError) { + return ( + <> + + + + + + ); + } + + return ( + <> + setIsCreateDialogOpen(true)}> + + Konto anlegen + + ) : null + } + /> + + {usersWithoutMfa > 0 ? ( + + {usersWithoutMfa} von {users.length} Konten sind nur durch ihr Passwort + geschuetzt. Bei Konten mit Loeschrecht ist das der Punkt, an dem ein + uebernommenes Konto zum Datenverlust fuehrt. + + ) : null} + + + user.id} + isLoading={usersResource.loadState === 'loading'} + emptyTitle="Keine Konten" + /> + + + {/* --- Anlegen --- */} + + + + + setDraftUsername(changeEvent.target.value)} + /> + setDraftEmail(changeEvent.target.value)} + /> + setDraftPassword(changeEvent.target.value)} + hint="Wird mit Argon2id gehasht abgelegt. Der Klartext verlaesst diese Maske nur im Anfragekoerper." + /> + +
+

Rollen

+ {roles.map((role) => ( + + setDraftRoles((currentRoles) => + isChecked + ? [...currentRoles, role.name] + : currentRoles.filter((roleName) => roleName !== role.name), + ) + } + label={role.name} + hint={role.description} + /> + ))} +
+
+ + + + +
+
+ + {/* --- Bearbeiten --- */} + !isOpen && setUserBeingEdited(null)} + > + + + + setDraftEmail(changeEvent.target.value)} + /> + setDraftPassword(changeEvent.target.value)} + /> + +
+

Rollen

+ {roles.map((role) => ( + + setDraftRoles((currentRoles) => + isChecked + ? [...currentRoles, role.name] + : currentRoles.filter((roleName) => roleName !== role.name), + ) + } + label={role.name} + /> + ))} +
+ + + Dem letzten Administrator lassen sich die + Verwaltungsrechte nicht entziehen — der Server lehnt das ab. Sonst + waere die Anlage nicht mehr verwaltbar. + +
+ + + + +
+
+ + !isOpen && setUserPendingDeletion(null)} + title="Konto loeschen" + description={userPendingDeletion?.username} + confirmLabel="Loeschen" + isDestructive + isLoading={deleteMutation.isRunning} + confirmationWord={userPendingDeletion?.username} + onConfirm={() => { + if (userPendingDeletion) { + void deleteMutation.run(userPendingDeletion.id); + } + }} + > + + Die Eintraege dieses Kontos im Ereignisprotokoll bleiben bestehen — + ein Protokoll, das sich durch Loeschen eines Kontos bereinigen liesse, + waere keines. + + + + !isOpen && setUserPendingMfaReset(null)} + title="Zweiten Faktor zuruecksetzen" + description={userPendingMfaReset?.username} + confirmLabel="Zuruecksetzen" + isDestructive + isLoading={mfaResetMutation.isRunning} + onConfirm={() => { + if (userPendingMfaReset) { + void mfaResetMutation.run(userPendingMfaReset.id); + } + }} + > + + Danach genuegt das Passwort allein. Der Vorgang wird + protokolliert. Vergewissern Sie sich auf anderem Weg, dass die + Anfrage tatsaechlich von dieser Person stammt — das ist der uebliche + Ansatzpunkt fuer eine Kontouebernahme. + + + + ); +} diff --git a/apps/web/src/features/administration/administrationApi.ts b/apps/web/src/features/administration/administrationApi.ts new file mode 100644 index 0000000..1ad936e --- /dev/null +++ b/apps/web/src/features/administration/administrationApi.ts @@ -0,0 +1,252 @@ +/** + * API-Anbindung der Verwaltung: Benutzer, Rollen, Benachrichtigungswege, MFA. + * + * Diese vier gehoeren zusammen, weil sie dieselbe Frage betreffen: **Wer darf + * was, und wer erfaehrt davon?** Zwei Schutzmechanismen praegen die Masken: + * + * - **Der letzte Administrator ist geschuetzt** gegen Loeschung, Deaktivierung + * und Rollenentzug. Sonst waere die Anlage nicht mehr verwaltbar. + * - **Mitgelieferte Rollen sind unveraenderlich.** Eine Aenderung verschoebe die + * Bedeutung bestehender Zuweisungen — jemand behielte eine Rolle, die etwas + * anderes bedeutet als bei ihrer Vergabe. + */ + +import { requestApi } from '../../api/client'; + +// --------------------------------------------------------------------------- +// Benutzer +// --------------------------------------------------------------------------- + +/** Ein Benutzerkonto. */ +export interface User { + id: string; + username: string; + email?: string; + /** active, disabled oder locked. */ + status: string; + roles: string[]; + mfa_enabled: boolean; + last_login_at?: string; + created_at?: string; + /** Zaehler fehlgeschlagener Anmeldungen (Brute-Force-Schutz). */ + failed_login_count?: number; +} + +/** Laedt die Benutzer. */ +export async function listUsers(abortSignal?: AbortSignal): Promise { + return requestApi('/users', abortSignal ? { signal: abortSignal } : {}); +} + +/** + * Legt ein Benutzerkonto an. + * + * Das Passwort geht ausschliesslich ueber den Anfragekoerper — niemals als + * Abfrageparameter, sonst stuende es in jedem Zugriffsprotokoll. + */ +export async function createUser(userInput: { + username: string; + email?: string; + password: string; + roles: string[]; +}): Promise { + return requestApi('/users', { + method: 'POST', + body: userInput, + idempotencyKey: true, + }); +} + +/** Aendert ein Benutzerkonto. */ +export async function updateUser( + userIdentifier: string, + changes: { + email?: string; + status?: string; + password?: string; + roles?: string[]; + }, +): Promise { + return requestApi(`/users/${encodeURIComponent(userIdentifier)}`, { + method: 'PATCH', + body: changes, + }); +} + +/** Loescht ein Benutzerkonto. */ +export async function deleteUser(userIdentifier: string): Promise { + return requestApi(`/users/${encodeURIComponent(userIdentifier)}`, { + method: 'DELETE', + idempotencyKey: true, + }); +} + +/** + * Setzt den zweiten Faktor eines Kontos zurueck. + * + * Der Weg fuer den Fall, dass jemand sein Geraet verloren hat. Es ist eine + * sicherheitsrelevante Handlung und wird auditiert: Danach genuegt das + * Passwort allein. + */ +export async function disableUserMfa(userIdentifier: string): Promise { + return requestApi(`/users/${encodeURIComponent(userIdentifier)}/mfa/disable`, { + method: 'POST', + idempotencyKey: true, + }); +} + +// --------------------------------------------------------------------------- +// Rollen +// --------------------------------------------------------------------------- + +/** Eine Rolle. */ +export interface Role { + id: string; + name: string; + description?: string; + permissions: string[]; + /** Mitgelieferte Rollen sind unveraenderlich. */ + is_builtin?: boolean; + user_count?: number; +} + +/** Eine Berechtigung. */ +export interface Permission { + name: string; + description?: string; + /** Fachlicher Bereich, etwa "backups" oder "restores". */ + category?: string; +} + +/** Laedt die Rollen. */ +export async function listRoles(abortSignal?: AbortSignal): Promise { + return requestApi('/roles', abortSignal ? { signal: abortSignal } : {}); +} + +/** Laedt die vergebbaren Berechtigungen. */ +export async function listPermissions(abortSignal?: AbortSignal): Promise { + return requestApi('/permissions', abortSignal ? { signal: abortSignal } : {}); +} + +/** Legt eine Rolle an. */ +export async function createRole(roleInput: { + name: string; + description?: string; + permissions: string[]; +}): Promise { + return requestApi('/roles', { + method: 'POST', + body: roleInput, + idempotencyKey: true, + }); +} + +/** Aendert eine Rolle. */ +export async function updateRole( + roleIdentifier: string, + changes: { description?: string; permissions?: string[] }, +): Promise { + return requestApi(`/roles/${encodeURIComponent(roleIdentifier)}`, { + method: 'PATCH', + body: changes, + }); +} + +/** Loescht eine Rolle. */ +export async function deleteRole(roleIdentifier: string): Promise { + return requestApi(`/roles/${encodeURIComponent(roleIdentifier)}`, { + method: 'DELETE', + idempotencyKey: true, + }); +} + +// --------------------------------------------------------------------------- +// Benachrichtigungswege +// --------------------------------------------------------------------------- + +/** Ein Zustellweg fuer Meldungen. */ +export interface NotificationChannel { + id: string; + name: string; + /** email oder webhook. */ + channel_type: string; + /** + * Schwelle, ab der zugestellt wird. + * + * Ohne sie schaltet der Bereitschaftsdienst nach einer Woche die + * Benachrichtigungen ab — und dann kommt auch die kritische nicht mehr an. + */ + minimum_severity: string; + /** + * Konfiguration ohne Geheimnisse. + * + * Bei einem Webhook steht die Adresse hier, und die traegt oft ein Token im + * Pfad — der Server liefert sie deshalb geschwaerzt oder gar nicht aus. + */ + configuration?: Record; + enabled?: boolean; + created_at?: string; +} + +/** Laedt die Benachrichtigungswege. */ +export async function listNotificationChannels( + abortSignal?: AbortSignal, +): Promise { + return requestApi( + '/notification-channels', + abortSignal ? { signal: abortSignal } : {}, + ); +} + +/** Legt einen Benachrichtigungsweg an. */ +export async function createNotificationChannel(channelInput: { + name: string; + channel_type: string; + minimum_severity: string; + configuration: Record; + secret?: string; +}): Promise { + return requestApi('/notification-channels', { + method: 'POST', + body: channelInput, + idempotencyKey: true, + }); +} + +/** Loescht einen Benachrichtigungsweg. */ +export async function deleteNotificationChannel(channelIdentifier: string): Promise { + return requestApi( + `/notification-channels/${encodeURIComponent(channelIdentifier)}`, + { method: 'DELETE', idempotencyKey: true }, + ); +} + +// --------------------------------------------------------------------------- +// Eigener zweiter Faktor +// --------------------------------------------------------------------------- + +/** Antwort auf die MFA-Einrichtung. */ +export interface MfaEnrollment { + /** Das gemeinsame Geheimnis in Base32 — einmalig. */ + secret: string; + /** URI fuer die Authentifizierungs-App. */ + otpauth_url?: string; +} + +/** Beginnt die Einrichtung des zweiten Faktors. */ +export async function enrollMfa(): Promise { + return requestApi('/me/mfa/enroll', { method: 'POST' }); +} + +/** + * Schliesst die Einrichtung mit einem Code ab. + * + * Erst dieser Schritt macht den zweiten Faktor scharf. Ohne ihn haette jemand + * ein Geheimnis hinterlegt, dessen App er nie geprueft hat — und sich beim + * naechsten Anmelden ausgesperrt. + */ +export async function confirmMfa(code: string): Promise<{ mfa_enabled: boolean }> { + return requestApi<{ mfa_enabled: boolean }>('/me/mfa/confirm', { + method: 'POST', + body: { code }, + }); +} diff --git a/apps/web/src/features/backups/BackupDetailPage.tsx b/apps/web/src/features/backups/BackupDetailPage.tsx new file mode 100644 index 0000000..64b39b2 --- /dev/null +++ b/apps/web/src/features/backups/BackupDetailPage.tsx @@ -0,0 +1,591 @@ +/** + * Einzelheiten eines Wiederherstellungspunkts. + * + * Diese Seite beantwortet die einzige Frage, die bei einem Backup zaehlt: + * **Kann ich mich darauf verlassen?** Sie tut das in der Reihenfolge, in der + * die Antwort belastbar wird: + * + * 1. Die Bewertung nennt die Einstufung — und wie gut sie belegt ist. + * 2. Die fehlenden Messungen sind die Handlungsanweisung. + * 3. Die Pruefung erzeugt den Beleg; nur der Wiederherstellungstest ist einer. + * 4. Der Schutz sagt, ob jemand das alles loeschen koennte. + * + * Eine unbelastbare Prozentzahl wird als solche gekennzeichnet. "70 %" liest + * sich wie "weitgehend in Ordnung" — bei einem beschaedigten Backup waere das + * grob irrefuehrend, denn es ist nicht zu 70 % wiederherstellbar, sondern gar + * nicht. + */ + +import { BadgeCheck, RotateCcw, ShieldCheck, ShieldOff, 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, + DetailItem, + ErrorState, + LoadingState, + SelectInput, + StatusBadge, + TextArea, + TextInput, + useToast, +} from '@/components/ui'; +import { cn, formatDateTime } from '@/lib/utils'; +import { RestoreWizard } from '../restores/RestoreWizard'; +import { + VERIFICATION_TYPES, + deleteBackup, + extendRetention, + getAssurance, + getProtection, + getRansomwareAssessment, + releaseLegalHold, + setLegalHold, + startVerification, +} from './backupsApi'; + +export function BackupDetailPage({ + backupIdentifier, + grantedPermissions, + onBack, + onDeleted, +}: { + readonly backupIdentifier: string; + readonly grantedPermissions: readonly string[]; + readonly onBack: () => void; + readonly onDeleted: () => void; +}) { + const toast = useToast(); + const [isRestoreWizardOpen, setIsRestoreWizardOpen] = useState(false); + const [isVerifyDialogOpen, setIsVerifyDialogOpen] = useState(false); + const [isLegalHoldDialogOpen, setIsLegalHoldDialogOpen] = useState(false); + const [isReleaseDialogOpen, setIsReleaseDialogOpen] = useState(false); + const [isExtendDialogOpen, setIsExtendDialogOpen] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + + const [verificationType, setVerificationType] = useState('chunk_integrity'); + const [holdReason, setHoldReason] = useState(''); + const [newRetentionDate, setNewRetentionDate] = useState(''); + + const assuranceResource = useApiResource( + useCallback( + (abortSignal) => getAssurance(backupIdentifier, abortSignal), + [backupIdentifier], + ), + backupIdentifier, + ); + const protectionResource = useApiResource( + useCallback( + (abortSignal) => getProtection(backupIdentifier, abortSignal), + [backupIdentifier], + ), + backupIdentifier, + ); + const ransomwareResource = useApiResource( + useCallback( + (abortSignal) => getRansomwareAssessment(backupIdentifier, abortSignal), + [backupIdentifier], + ), + backupIdentifier, + ); + + const mayVerify = + grantedPermissions.includes('*') || grantedPermissions.includes('verification.write'); + const mayRestoreTest = + grantedPermissions.includes('*') || + grantedPermissions.includes('verification.restore_test'); + const mayManageImmutability = + grantedPermissions.includes('*') || grantedPermissions.includes('immutability.manage'); + const mayDelete = + grantedPermissions.includes('*') || grantedPermissions.includes('backups.delete'); + const mayRestore = + grantedPermissions.includes('*') || grantedPermissions.includes('restores.execute'); + + const reloadAll = () => { + assuranceResource.reload(); + protectionResource.reload(); + }; + + const verifyMutation = useMutation(startVerification, { + onSuccess: () => { + toast.showSuccess( + 'Pruefung gestartet', + 'Das Ergebnis erscheint unter „Pruefung“, sobald sie durchgelaufen ist.', + ); + setIsVerifyDialogOpen(false); + reloadAll(); + }, + onError: (apiError) => + toast.showError('Die Pruefung liess sich nicht starten', describeApiError(apiError), apiError.requestId), + }); + + const legalHoldMutation = useMutation(setLegalHold, { + onSuccess: () => { + toast.showSuccess('Legal Hold gesetzt', 'Dieser Punkt laesst sich jetzt nicht loeschen.'); + setIsLegalHoldDialogOpen(false); + setHoldReason(''); + reloadAll(); + }, + onError: (apiError) => + toast.showError('Fehlgeschlagen', describeApiError(apiError), apiError.requestId), + }); + + const releaseMutation = useMutation(releaseLegalHold, { + onSuccess: () => { + toast.showSuccess('Legal Hold aufgehoben'); + setIsReleaseDialogOpen(false); + setHoldReason(''); + reloadAll(); + }, + onError: (apiError) => + toast.showError('Fehlgeschlagen', describeApiError(apiError), apiError.requestId), + }); + + const extendMutation = useMutation(extendRetention, { + onSuccess: () => { + toast.showSuccess('Aufbewahrungsfrist verlaengert'); + setIsExtendDialogOpen(false); + reloadAll(); + }, + onError: (apiError) => + toast.showError('Fehlgeschlagen', describeApiError(apiError), apiError.requestId), + }); + + const deleteMutation = useMutation(deleteBackup, { + onSuccess: () => { + toast.showSuccess( + 'Wiederherstellungspunkt geloescht', + 'Wird kein Speicher frei, liegt das an der Deduplizierung — die Bloecke werden von einem anderen Backup gebraucht.', + ); + onDeleted(); + }, + onError: (apiError) => + toast.showError('Das Loeschen schlug fehl', describeApiError(apiError), apiError.requestId), + }); + + if (assuranceResource.loadState === 'loading') { + return ; + } + + if (assuranceResource.loadState === 'failed' || !assuranceResource.data) { + return ( + <> + + + + + + ); + } + + const assurance = assuranceResource.data; + const protection = protectionResource.data; + const ransomware = ransomwareResource.data; + + return ( + <> + + {mayRestore ? ( + + ) : null} + {mayVerify ? ( + + ) : null} + + } + /> + +
+ {/* --- Bewertung --- */} + + + +
+
+

+ {assurance.percentage} % +

+ +
+ +

{assurance.summary}

+
+ + {/* Eine Zahl, die auf zu vielen Vermutungen beruht, wird als + Vermutung gekennzeichnet — sonst wird sie zitiert, als waere + sie eine Messung. */} + {!assurance.is_trustworthy ? ( + + {assurance.unknown_input_count} Eingangsgroessen wurden nicht + gemessen und gehen weder positiv noch negativ ein. Solange das so + ist, sagt der Prozentwert wenig. + + ) : null} + + {assurance.missing_measurements.length > 0 ? ( +
+

+ Was fehlt +

+
    + {assurance.missing_measurements.map((measurement) => ( +
  • + • {measurement} +
  • + ))} +
+
+ ) : null} + +
+ {assurance.inputs.map((input) => ( +
+ + {input.name} + +
+
0 ? (input.earned_points / input.weight) * 100 : 0}%`, + }} + /> +
+ + {input.is_known ? ( + `${input.earned_points} / ${input.weight}` + ) : ( + // Ungemessen ist nicht null Punkte, sondern keine + // Aussage. Als 0 zu zeigen bestrafte das Unbekannte. + ungemessen + )} + +
+ ))} +
+ +
+ + {assurance.last_verified_at + ? formatDateTime(assurance.last_verified_at) + : 'Nie'} + + + {assurance.last_restore_test_at ? ( + formatDateTime(assurance.last_restore_test_at) + ) : ( + + Nie — die Wiederherstellbarkeit ist unbewiesen + + )} + +
+ + + + {/* --- Schutz --- */} +
+ + + + {protection ? ( + <> +
+ {protection.is_protected ? ( + + ) : ( + + )} + + {protection.is_protected ? 'Geschuetzt' : 'Nicht geschuetzt'} + +
+ +

{protection.description}

+ + {protection.immutable_until ? ( + + {formatDateTime(protection.immutable_until)} + + ) : null} + + {protection.legal_hold ? ( + + {protection.legal_hold_reason || 'Ohne Begruendung gesetzt.'} + + ) : null} + + {protection.enforcement_level ? ( +
+

+ Durchsetzungsstufe (gemessen) +

+ + {protection.enforcement_explanation ? ( +

+ {protection.enforcement_explanation} +

+ ) : null} +
+ ) : null} + + {mayManageImmutability ? ( +
+ {protection.legal_hold ? ( + + ) : ( + + )} + +
+ ) : null} + + ) : ( +

Der Schutzstatus wird geladen …

+ )} +
+
+ + {/* --- Ransomware --- */} + {ransomware ? ( + + + + + {ransomware.summary ? ( +

{ransomware.summary}

+ ) : null} + {ransomware.severity === 'unknown' ? ( +

+ Unter fuenf Vergleichslaeufen gibt es keinen belastbaren + Basiswert. Wer nicht messen kann, hat nichts gemessen — eine + geratene Schwelle waere schlechter als keine. +

+ ) : null} + {ransomware.triggered_signal_count !== undefined ? ( +

+ {ransomware.triggered_signal_count} von 6 Signalen auffaellig. + Diese Einschaetzung meldet nur und handelt nie. +

+ ) : null} +
+
+ ) : null} + + {mayDelete ? ( + + ) : null} +
+
+ + {/* --- Dialoge --- */} + + {isRestoreWizardOpen ? ( + setIsRestoreWizardOpen(false)} + onStarted={() => setIsRestoreWizardOpen(false)} + /> + ) : null} + + void verifyMutation.run(backupIdentifier, verificationType)} + > +
+ setVerificationType(changeEvent.target.value)} + > + {VERIFICATION_TYPES.map((type) => ( + + ))} + + +

+ {VERIFICATION_TYPES.find((type) => type.value === verificationType)?.description} +

+ + {verificationType === 'restore_test' ? ( + + Nur diese Pruefung ist ein Nachweis. Sie hebt die + Einstufung auf „wiederherstellbar“ — alles andere sind Indizien. + + ) : null} +
+
+ + void legalHoldMutation.run(backupIdentifier, holdReason)} + > +