diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index d6ae84c..6ffb688 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,19 +1,26 @@ /** - * Wurzelkomponente der Syncova-Oberflaeche. + * Wurzelkomponente der Syncova-Oberfläche. * * Sie entscheidet zwischen Anmeldemaske und angemeldeter Ansicht und verteilt * letztere auf die Bereiche aus PROMPT.md §28. * * Die Navigation kennt zwei Ebenen: eine Seite und optional ein Objekt darauf - * (`jobs/`). Das reicht fuer diese Konsole und kostet rund fuenfzig Zeilen — - * eine Router-Bibliothek waere hier mehr Abhaengigkeit als Nutzen. + * (`jobs/`). Das reicht für diese Konsole und kostet rund fünfzig Zeilen — + * eine Router-Bibliothek wäre hier mehr Abhängigkeit als Nutzen. */ -import { useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { setAccessTokenProvider } from './api/client'; import { AppShell } from './components/layout/AppShell'; -import { ToastProvider } from './components/ui'; -import { getAccessToken, logout } from './features/auth/authApi'; +import { ErrorBoundary } from './components/layout/ErrorBoundary'; +import { LoadingState, ToastProvider } from './components/ui'; +import { + fetchCurrentUser, + getAccessToken, + hasResumableSession, + logout, +} from './features/auth/authApi'; +import { useSession } from './features/auth/useSession'; import { LoginPage } from './features/auth/LoginPage'; import { AlertsPage } from './features/alerts/AlertsPage'; import { DashboardPage } from './features/dashboard/DashboardPage'; @@ -42,16 +49,64 @@ import { useCurrentPage } from './navigation/useCurrentPage'; import type { CurrentUser } from './types/auth'; import './styles/theme.css'; -// Der API-Client erhaelt seinen Tokenzugriff einmalig beim Laden des Moduls. +// Der API-Client erhält seinen Tokenzugriff einmalig beim Laden des Moduls. setAccessTokenProvider(getAccessToken); export function App(): React.JSX.Element { const [authenticatedUser, setAuthenticatedUser] = useState(null); const { currentPageId, navigateToPage } = useCurrentPage(); - // Das gerade geoeffnete Objekt innerhalb einer Seite, etwa ein Auftrag. + // Das gerade geöffnete Objekt innerhalb einer Seite, etwa ein Auftrag. const [openObjectIdentifier, setOpenObjectIdentifier] = useState(null); + // Beim Start wird geprüft, ob eine Sitzung fortgesetzt werden kann. Solange + // das läuft, erscheint weder Anmeldemaske noch Inhalt — sonst blitzte nach + // jedem Neuladen kurz die Anmeldung auf. + const [isResuming, setIsResuming] = useState(() => hasResumableSession()); + + useEffect(() => { + if (!isResuming) { + return; + } + + let isCancelled = false; + + void fetchCurrentUser() + .then((resumedUser) => { + if (!isCancelled) { + setAuthenticatedUser(resumedUser); + } + }) + .catch(() => { + // Der Server hat das Token abgelehnt — etwa nach einer Kontosperre. + // Dann gilt die Sitzung als beendet, unabhängig von der lokalen Uhr. + }) + .finally(() => { + if (!isCancelled) { + setIsResuming(false); + } + }); + + return () => { + isCancelled = true; + }; + }, [isResuming]); + + const handleSessionExpired = useCallback(() => { + setAuthenticatedUser(null); + void logout(); + }, []); + + const sessionClock = useSession(authenticatedUser !== null, handleSessionExpired); + + if (isResuming) { + return ( +
+ +
+ ); + } + if (authenticatedUser === null) { return ( @@ -67,7 +122,7 @@ export function App(): React.JSX.Element { const grantedPermissions = authenticatedUser.permissions ?? []; - /** Wechselt die Seite und schliesst ein offenes Objekt. */ + /** Wechselt die Seite und schließt ein offenes Objekt. */ function handleNavigate(pageIdentifier: string): void { setOpenObjectIdentifier(null); navigateToPage(pageIdentifier); @@ -80,21 +135,26 @@ export function App(): React.JSX.Element { onNavigate={handleNavigate} grantedPermissions={grantedPermissions} userName={authenticatedUser.username} + sessionClock={sessionClock} onLogout={() => void handleLogout()} > - + {/* Die Grenze sitzt um den Inhalt: Ein Fehler auf einer Seite lässt + Menü und Kopfzeile stehen, statt die Konsole zu schwärzen. */} + + + ); } -/** Zeigt den Inhalt der gewaehlten Seite. */ +/** Zeigt den Inhalt der gewählten Seite. */ function CurrentPageContent({ currentPageId, grantedPermissions, @@ -114,14 +174,14 @@ function CurrentPageContent({ return ; } - // Die Anzeigepruefung ersetzt die serverseitige nicht, sie ergaenzt sie: Ohne - // sie liefe der Aufruf in eine Fehlermeldung statt in eine verstaendliche + // Die Anzeigeprüfung ersetzt die serverseitige nicht, sie ergänzt sie: Ohne + // sie liefe der Aufruf in eine Fehlermeldung statt in eine verständliche // Auskunft (PROMPT.md §42). if (!mayViewPage(pageDefinition, grantedPermissions)) { return ( ); } @@ -130,7 +190,7 @@ function CurrentPageContent({ return ( ); } @@ -238,7 +298,7 @@ function CurrentPageContent({ return ( ); } diff --git a/apps/web/src/api/client.test.ts b/apps/web/src/api/client.test.ts index 7a71f39..ec26ef2 100644 --- a/apps/web/src/api/client.test.ts +++ b/apps/web/src/api/client.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { ApiError, MALFORMED_RESPONSE_CODE, NETWORK_ERROR_CODE, requestApi } from './client'; -/** Baut eine Antwort, wie sie das Backend liefern wuerde. */ +/** Baut eine Antwort, wie sie das Backend liefern würde. */ function buildJsonResponse(responseBody: unknown, statusCode: number): Response { return new Response(JSON.stringify(responseBody), { status: statusCode, @@ -14,7 +14,7 @@ afterEach(() => { }); describe('requestApi', () => { - it('gibt die Nutzlast aus der Standard-Huelle zurueck', async () => { + it('gibt die Nutzlast aus der Standard-Hülle zurück', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue( @@ -35,7 +35,7 @@ describe('requestApi', () => { await requestApi('/health'); - // Ohne Correlation ID liesse sich eine Operation nicht Ende-zu-Ende verfolgen. + // Ohne Correlation ID ließe sich eine Operation nicht Ende-zu-Ende verfolgen. const firstCall = fetchMock.mock.calls[0]; expect(firstCall).toBeDefined(); @@ -69,7 +69,7 @@ describe('requestApi', () => { }); }); - it('meldet eine nicht erreichbare API verstaendlich', async () => { + it('meldet eine nicht erreichbare API verständlich', async () => { vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch'))); // Ein Netzwerkfehler darf nicht als leere Antwort durchgehen. @@ -79,24 +79,24 @@ describe('requestApi', () => { it('lehnt eine Erfolgsantwort ohne data-Feld ab', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(buildJsonResponse({ meta: {} }, 200))); - // Eine Antwort ausserhalb des Vertrags darf nicht stillschweigend + // Eine Antwort außerhalb des Vertrags darf nicht stillschweigend // als leeres Ergebnis interpretiert werden (PROMPT.md §140). await expect(requestApi('/health')).rejects.toMatchObject({ code: MALFORMED_RESPONSE_CODE }); }); - it('lehnt eine Fehlerantwort ohne Fehlerkoerper ab', async () => { + it('lehnt eine Fehlerantwort ohne Fehlerkörper ab', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(buildJsonResponse({ unerwartet: true }, 500))); await expect(requestApi('/health')).rejects.toMatchObject({ code: MALFORMED_RESPONSE_CODE }); }); - it('reicht einen Abbruch unveraendert durch', async () => { + it('reicht einen Abbruch unverändert durch', async () => { vi.stubGlobal( 'fetch', vi.fn().mockRejectedValue(new DOMException('Aborted', 'AbortError')), ); - // Ein Abbruch ist Folge des Aufraeumens und kein Fehlerfall. + // Ein Abbruch ist Folge des Aufräumens und kein Fehlerfall. await expect(requestApi('/health')).rejects.toSatisfy( (thrownError: unknown) => thrownError instanceof DOMException && !(thrownError instanceof ApiError), ); diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 8ac1370..5a5d565 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -1,8 +1,8 @@ /** - * HTTP-Client fuer die Syncova-API. + * HTTP-Client für die Syncova-API. * - * Der Client kapselt die Antworthuelle des Backends und liefert Fehler stets als - * ApiError. Aufrufer muessen sich damit nicht mit HTTP-Details befassen und es + * Der Client kapselt die Antworthülle des Backends und liefert Fehler stets als + * ApiError. Aufrufer müssen sich damit nicht mit HTTP-Details befassen und es * kann keine Fehlerantwort versehentlich als Nutzlast interpretiert werden * (PROMPT.md §140: keine stillen Fehler). */ @@ -12,15 +12,15 @@ import type { ErrorResponse, SuccessResponse } from '../types/api'; /** Basis-Pfad aller fachlichen Endpunkte (SYNCOVA_API.md). */ const API_BASE_PATH = '/api/v1'; -/** Header, ueber den eine Operation Ende-zu-Ende verfolgt wird (PROMPT.md §50). */ +/** Header, über den eine Operation Ende-zu-Ende verfolgt wird (PROMPT.md §50). */ const CORRELATION_ID_HEADER = 'X-Correlation-ID'; /** - * Header, der eine Anfrage genau einmal wirken laesst (SYNCOVA_API.md §5). + * Header, der eine Anfrage genau einmal wirken lässt (SYNCOVA_API.md §5). * - * Er gehoert an alle anlegenden und zerstoerenden Aufrufe. Ohne ihn erzeugt ein - * Doppelklick oder ein wiederholter Versuch nach einer Zeitueberschreitung zwei - * Auftraege — und bei einer Wiederherstellung zwei gleichzeitige Laeufe in + * Er gehört an alle anlegenden und zerstörenden Aufrufe. Ohne ihn erzeugt ein + * Doppelklick oder ein wiederholter Versuch nach einer Zeitüberschreitung zwei + * Aufträge — und bei einer Wiederherstellung zwei gleichzeitige Läufe in * dasselbe Ziel. */ const IDEMPOTENCY_KEY_HEADER = 'Idempotency-Key'; @@ -28,8 +28,8 @@ const IDEMPOTENCY_KEY_HEADER = 'Idempotency-Key'; /** * Fehler einer API-Anfrage. * - * Er traegt den maschinenlesbaren Code und die Request-ID, damit ein Anwender - * einen Vorfall gegenueber dem Betreiber eindeutig benennen kann. + * Er trägt den maschinenlesbaren Code und die Request-ID, damit ein Anwender + * einen Vorfall gegenüber dem Betreiber eindeutig benennen kann. */ export class ApiError extends Error { /** Stabiler maschinenlesbarer Fehlercode. */ @@ -60,8 +60,8 @@ export class ApiError extends Error { /** * Liefert das aktuelle Zugriffstoken, sofern eine Sitzung besteht. * - * Der Client kennt die Anmeldelogik bewusst nicht, sondern erhaelt sie ueber - * diese Funktion. Andernfalls entstuende ein Zirkelbezug zwischen dem Client und + * Der Client kennt die Anmeldelogik bewusst nicht, sondern erhält sie über + * diese Funktion. Andernfalls entstünde ein Zirkelbezug zwischen dem Client und * dem Anmeldemodul, das seinerseits den Client verwendet. */ let accessTokenProvider: () => string | null = () => null; @@ -71,24 +71,24 @@ export function setAccessTokenProvider(tokenProvider: () => string | null): void accessTokenProvider = tokenProvider; } -/** Fehlercode fuer eine nicht erreichbare API. */ +/** Fehlercode für eine nicht erreichbare API. */ export const NETWORK_ERROR_CODE = 'NETWORK_UNREACHABLE'; -/** Fehlercode fuer eine unverstaendliche Antwort. */ +/** Fehlercode für eine unverständliche Antwort. */ export const MALFORMED_RESPONSE_CODE = 'MALFORMED_RESPONSE'; /** - * Erzeugt eine Correlation ID fuer einen Request. + * Erzeugt eine Correlation ID für einen Request. * - * crypto.randomUUID ist in allen unterstuetzten Browsern verfuegbar; der - * Rueckfall deckt aeltere Testumgebungen ab. + * crypto.randomUUID ist in allen unterstützten Browsern verfügbar; der + * Rückfall deckt ältere Testumgebungen ab. */ function createCorrelationId(): string { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); } - // Rueckfall ohne kryptografische Garantie - die Correlation ID dient allein + // Rückfall ohne kryptografische Garantie - die Correlation ID dient allein // der Nachvollziehbarkeit, nicht der Sicherheit. return `00000000-0000-4000-8000-${Date.now().toString(16).padStart(12, '0').slice(-12)}`; } @@ -97,22 +97,22 @@ function createCorrelationId(): string { export interface RequestOptions { /** HTTP-Methode; Standard ist GET. */ method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'; - /** Optionaler Anfragekoerper, der als JSON gesendet wird. */ + /** Optionaler Anfragekörper, der als JSON gesendet wird. */ body?: unknown; /** Signal zum Abbrechen der Anfrage. */ signal?: AbortSignal; /** - * Schluessel fuer Idempotenz. + * Schlüssel für Idempotenz. * - * `true` erzeugt einen zufaelligen Schluessel; eine Zeichenkette wird - * unveraendert verwendet, damit ein Wiederholungsversuch derselben Handlung - * denselben Schluessel traegt. + * `true` erzeugt einen zufälligen Schlüssel; eine Zeichenkette wird + * unverändert verwendet, damit ein Wiederholungsversuch derselben Handlung + * denselben Schlüssel trägt. */ idempotencyKey?: string | true; } /** - * Fuehrt eine Anfrage gegen die Syncova-API aus. + * Führt eine Anfrage gegen die Syncova-API aus. * * @param endpointPath Pfad unterhalb von /api/v1, z. B. "/health". * @returns Die Nutzlast der Antwort. @@ -142,7 +142,7 @@ export async function requestApi( } // Besteht eine Sitzung, wird sie mitgesendet. Ohne Token laufen die Anfragen - // unauthentifiziert - der Server entscheidet dann ueber den Zugriff. + // unauthentifiziert - der Server entscheidet dann über den Zugriff. const accessToken = accessTokenProvider(); if (accessToken !== null) { requestHeaders['Authorization'] = `Bearer ${accessToken}`; @@ -154,7 +154,7 @@ export async function requestApi( method: requestMethod, headers: requestHeaders, body: requestOptions.body === undefined ? null : JSON.stringify(requestOptions.body), - // Die Sitzung laeuft ueber ein Cookie bzw. einen Token desselben Ursprungs. + // Die Sitzung läuft über ein Cookie bzw. einen Token desselben Ursprungs. credentials: 'same-origin', ...(requestOptions.signal ? { signal: requestOptions.signal } : {}), }); @@ -166,13 +166,13 @@ export async function requestApi( throw new ApiError({ code: NETWORK_ERROR_CODE, - message: 'Syncova ist derzeit nicht erreichbar. Bitte Netzwerkverbindung und Dienststatus pruefen.', + message: 'Syncova ist derzeit nicht erreichbar. Bitte Netzwerkverbindung und Dienststatus prüfen.', statusCode: 0, requestId: correlationId, }); } - // 204 traegt per Definition keinen Koerper. + // 204 trägt per Definition keinen Körper. if (httpResponse.status === 204) { return undefined as TPayload; } @@ -183,19 +183,19 @@ export async function requestApi( } catch { throw new ApiError({ code: MALFORMED_RESPONSE_CODE, - message: 'Die Antwort des Servers war unverstaendlich.', + message: 'Die Antwort des Servers war unverständlich.', statusCode: httpResponse.status, requestId: httpResponse.headers.get('X-Request-ID') ?? correlationId, }); } - // Massgeblich ist die Antworthuelle, nicht allein der HTTP-Status. + // Maßgeblich ist die Antworthülle, nicht allein der HTTP-Status. // // Beide Angaben tragen unterschiedliche Aussagen: der Status beschreibt den - // Betriebszustand, die Huelle den Inhalt. GET /api/v1/health nutzt genau diese + // Betriebszustand, die Hülle den Inhalt. GET /api/v1/health nutzt genau diese // Trennung und meldet einen kritischen Systemzustand mit 503, liefert dabei - // aber einen vollstaendigen Bericht als Nutzlast. Wuerde der Client jeden - // Status ausserhalb von 2xx als inhaltsleeren Fehler behandeln, ginge + // aber einen vollständigen Bericht als Nutzlast. Würde der Client jeden + // Status außerhalb von 2xx als inhaltsleeren Fehler behandeln, ginge // ausgerechnet die Diagnose verloren, die der Anwender jetzt braucht. const errorResponse = parsedBody as Partial; if (errorResponse.error) { @@ -224,17 +224,17 @@ export async function requestApi( } /** - * Laedt eine Datei von der API herunter. + * Lädt eine Datei von der API herunter. * - * Sie steht neben requestApi und nicht darin: Eine Datei traegt **keine** - * Antworthuelle, sondern ist der Inhalt selbst. Wuerde man sie durch requestApi + * Sie steht neben requestApi und nicht darin: Eine Datei trägt **keine** + * Antworthülle, sondern ist der Inhalt selbst. Würde man sie durch requestApi * schicken, versuchte dieser, ein PDF als JSON zu lesen, und meldete eine - * unverstaendliche Antwort — obwohl alles in Ordnung ist. + * unverständliche Antwort — obwohl alles in Ordnung ist. * - * Der Fehlerfall geht dagegen sehr wohl durch die Huelle: Scheitert die Anfrage, - * antwortet der Server mit JSON. Deshalb wird der Inhaltstyp geprueft, bevor die + * Der Fehlerfall geht dagegen sehr wohl durch die Hülle: Scheitert die Anfrage, + * antwortet der Server mit JSON. Deshalb wird der Inhaltstyp geprüft, bevor die * Antwort als Datei behandelt wird — sonst landete eine Fehlermeldung als - * „bericht.pdf" im Download-Ordner, und der Anwender saehe statt einer Meldung + * „bericht.pdf" im Download-Ordner, und der Anwender sähe statt einer Meldung * eine kaputte Datei. */ export async function downloadApiFile( @@ -272,7 +272,7 @@ export async function downloadApiFile( throw new ApiError({ code: NETWORK_ERROR_CODE, - message: 'Syncova ist derzeit nicht erreichbar. Bitte Netzwerkverbindung und Dienststatus pruefen.', + message: 'Syncova ist derzeit nicht erreichbar. Bitte Netzwerkverbindung und Dienststatus prüfen.', statusCode: 0, requestId: correlationId, }); @@ -310,8 +310,8 @@ export async function downloadApiFile( /** * Liest den Dateinamen aus dem Content-Disposition-Kopf. * - * Ohne verwertbaren Kopf bleibt der Name leer und der Aufrufer waehlt einen — - * ein erfundener Name aus dem Kopf zu lesen waere schlimmer als keiner. + * Ohne verwertbaren Kopf bleibt der Name leer und der Aufrufer wählt einen — + * ein erfundener Name aus dem Kopf zu lesen wäre schlimmer als keiner. */ function parseFileNameFromDisposition(dispositionHeader: string | null): string { if (dispositionHeader === null) { diff --git a/apps/web/src/api/useApiResource.ts b/apps/web/src/api/useApiResource.ts index 4e6af21..448b2bf 100644 --- a/apps/web/src/api/useApiResource.ts +++ b/apps/web/src/api/useApiResource.ts @@ -1,14 +1,14 @@ /** - * Allgemeiner Lade-Hook fuer API-Ressourcen. + * Allgemeiner Lade-Hook für API-Ressourcen. * * Er folgt demselben Muster wie useSystemHealth: Solange kein echtes Ergebnis - * vorliegt, bleibt der Zustand ausdruecklich „laedt" oder „Fehler" — niemals ein - * leeres Ergebnis, das sich von einem echten leeren nicht unterscheiden liesse + * vorliegt, bleibt der Zustand ausdrücklich „lädt" oder „Fehler" — niemals ein + * leeres Ergebnis, das sich von einem echten leeren nicht unterscheiden ließe * (PROMPT.md §139). * - * Der Hook ersetzt die Wiederholung derselben dreissig Zeilen in jeder Seite. + * Der Hook ersetzt die Wiederholung derselben dreißig Zeilen in jeder Seite. * Genau deshalb steht er hier und nicht in einer der Seiten: Ein zweiter Ort mit - * eigener Fehlerbehandlung waere ein zweiter Ort, an dem sie fehlen kann. + * eigener Fehlerbehandlung wäre ein zweiter Ort, an dem sie fehlen kann. */ import { useCallback, useEffect, useState } from 'react'; @@ -25,17 +25,17 @@ export interface UseApiResourceResult { readonly data: TPayload | null; /** Aufgetretener Fehler; null, wenn keiner vorliegt. */ readonly loadError: ApiError | null; - /** Laedt die Ressource erneut. */ + /** Lädt die Ressource erneut. */ readonly reload: () => void; } /** - * Laedt eine Ressource und haelt ihren Zustand. + * Lädt eine Ressource und hält ihren Zustand. * - * @param loadResource Ladefunktion; sie erhaelt ein Abbruchsignal. - * @param dependencyKey Aendert sich dieser Wert, wird neu geladen. Ein einzelner - * Schluessel statt eines Abhaengigkeitsarrays: Ein Array mit wechselnder Laenge - * ist in React ein Fehler, und ein Objekt als Abhaengigkeit laedt bei jedem + * @param loadResource Ladefunktion; sie erhält ein Abbruchsignal. + * @param dependencyKey Ändert sich dieser Wert, wird neu geladen. Ein einzelner + * Schlüssel statt eines Abhängigkeitsarrays: Ein Array mit wechselnder Länge + * ist in React ein Fehler, und ein Objekt als Abhängigkeit lädt bei jedem * Rendern neu. */ export function useApiResource( @@ -45,11 +45,11 @@ export function useApiResource( // reloadCounter erzwingt einen erneuten Lauf des Effekts bei manuellem Neuladen. const [reloadCounter, setReloadCounter] = useState(0); - // Das Ergebnis traegt den Schluessel, unter dem es entstanden ist. Daraus - // laesst sich der Ladezustand **ableiten**, statt ihn im Effekt zu setzen: - // Passt der Schluessel nicht zum aktuellen, laeuft die Anfrage noch. Ein - // setState im Effektkoerper loeste dagegen eine zweite Renderrunde aus, - // bevor ueberhaupt etwas geladen wurde. + // Das Ergebnis trägt den Schlüssel, unter dem es entstanden ist. Daraus + // lässt sich der Ladezustand **ableiten**, statt ihn im Effekt zu setzen: + // Passt der Schlüssel nicht zum aktuellen, läuft die Anfrage noch. Ein + // setState im Effektkörper löste dagegen eine zweite Renderrunde aus, + // bevor überhaupt etwas geladen wurde. const [loadResult, setLoadResult] = useState<{ key: string; data: TPayload | null; @@ -71,7 +71,7 @@ export function useApiResource( setLoadResult({ key: effectiveKey, data: loadedPayload, error: null }); } catch (caughtError) { - // Ein Abbruch ist kein Fehler, sondern Folge des Aufraeumens. + // Ein Abbruch ist kein Fehler, sondern Folge des Aufräumens. if (caughtError instanceof DOMException && caughtError.name === 'AbortError') { return; } @@ -95,14 +95,14 @@ export function useApiResource( void loadFromApi(); return () => abortController.abort(); - // loadResource bewusst nicht in den Abhaengigkeiten: Eine bei jedem Rendern - // neu gebildete Funktion loeste sonst eine Endlosschleife aus. Der - // effectiveKey steuert das Neuladen ausdruecklich. + // loadResource bewusst nicht in den Abhängigkeiten: Eine bei jedem Rendern + // neu gebildete Funktion löste sonst eine Endlosschleife aus. Der + // effectiveKey steuert das Neuladen ausdrücklich. // eslint-disable-next-line react-hooks/exhaustive-deps }, [effectiveKey]); - // Solange kein Ergebnis zum aktuellen Schluessel vorliegt, wird geladen. Die - // vorherigen Daten bleiben dabei sichtbar — ein Filterwechsel laesst die + // Solange kein Ergebnis zum aktuellen Schlüssel vorliegt, wird geladen. Die + // vorherigen Daten bleiben dabei sichtbar — ein Filterwechsel lässt die // Tabelle also nicht aufblitzen. if (loadResult === null || loadResult.key !== effectiveKey) { return { diff --git a/apps/web/src/api/useMutation.ts b/apps/web/src/api/useMutation.ts index e4dd69a..79d02cb 100644 --- a/apps/web/src/api/useMutation.ts +++ b/apps/web/src/api/useMutation.ts @@ -1,24 +1,24 @@ /** - * Haken fuer schreibende Aufrufe. + * Haken für schreibende Aufrufe. * * Bisher gab es nur `useApiResource` zum Lesen — was daran lag, dass die - * Oberflaeche kaum etwas schreiben konnte. Dieser Haken traegt die drei Dinge, - * die eine veraendernde Handlung in einer Betriebskonsole braucht: + * Oberfläche kaum etwas schreiben konnte. Dieser Haken trägt die drei Dinge, + * die eine verändernde Handlung in einer Betriebskonsole braucht: * - * 1. **Doppelklickschutz.** Solange die Anfrage laeuft, wird keine zweite - * ausgeloest. Bei einem Sicherungslauf waere die zweite ein 409, bei einer - * Loeschung schlimmstenfalls mehr als beabsichtigt. + * 1. **Doppelklickschutz.** Solange die Anfrage läuft, wird keine zweite + * ausgelöst. Bei einem Sicherungslauf wäre die zweite ein 409, bei einer + * Löschung schlimmstenfalls mehr als beabsichtigt. * 2. **Die Vorgangsnummer bis in die Meldung.** Sie ist die einzige Angabe, mit - * der sich ein Vorfall spaeter im Serverprotokoll wiederfinden laesst. - * 3. **Kein `setState` nach dem Aushaengen.** Ein Dialog, der sich nach dem - * Absenden schliesst, ist weg, bevor die Antwort da ist. + * der sich ein Vorfall später im Serverprotokoll wiederfinden lässt. + * 3. **Kein `setState` nach dem Aushängen.** Ein Dialog, der sich nach dem + * Absenden schließt, ist weg, bevor die Antwort da ist. */ import { useCallback, useEffect, useRef, useState } from 'react'; import { ApiError } from './client'; export interface MutationState { - /** Laeuft die Anfrage gerade? */ + /** Läuft die Anfrage gerade? */ readonly isRunning: boolean; /** Fehler des letzten Versuchs, sonst null. */ readonly error: ApiError | null; @@ -27,17 +27,17 @@ export interface MutationState { export interface MutationResult { readonly isRunning: boolean; readonly error: ApiError | null; - /** Fuehrt die Handlung aus. Liefert das Ergebnis oder null bei Fehler. */ + /** Führt die Handlung aus. Liefert das Ergebnis oder null bei Fehler. */ readonly run: (...callArguments: TArguments) => Promise; /** Verwirft eine stehende Fehlermeldung. */ readonly reset: () => void; } /** - * Baut einen Aufrufer fuer eine veraendernde API-Handlung. + * Baut einen Aufrufer für eine verändernde API-Handlung. * * @param performRequest Die eigentliche Anfrage. - * @param callbacks Rueckmeldungen bei Erfolg und Fehler. + * @param callbacks Rückmeldungen bei Erfolg und Fehler. */ export function useMutation( performRequest: (...callArguments: TArguments) => Promise, @@ -51,14 +51,14 @@ export function useMutation( error: null, }); - // Die Rueckmeldungen liegen in einer Referenz, damit `run` seine Identitaet - // behaelt. Sonst loeste jede Renderrunde einen neuen Effekt aus, der `run` - // in seiner Abhaengigkeitsliste hat. + // Die Rückmeldungen liegen in einer Referenz, damit `run` seine Identität + // behält. Sonst löste jede Renderrunde einen neuen Effekt aus, der `run` + // in seiner Abhängigkeitsliste hat. const callbacksReference = useRef(callbacks); - // Die Zuweisung gehoert in einen Effekt, nicht in den Renderkoerper: Waehrend + // Die Zuweisung gehört in einen Effekt, nicht in den Renderkörper: Während // des Renderns eine Referenz zu beschreiben ist ein Nebeneffekt, und React - // darf ein Rendern verwerfen oder zweimal ausfuehren. + // darf ein Rendern verwerfen oder zweimal ausführen. useEffect(() => { callbacksReference.current = callbacks; }); @@ -84,8 +84,8 @@ export function useMutation( setMutationState({ isRunning: false, error: null }); } - // Die Rueckmeldung laeuft auch dann, wenn die Komponente weg ist: Ein - // Dialog schliesst sich nach dem Absenden, und die Liste dahinter soll + // Die Rückmeldung läuft auch dann, wenn die Komponente weg ist: Ein + // Dialog schließt sich nach dem Absenden, und die Liste dahinter soll // sich trotzdem aktualisieren. callbacksReference.current.onSuccess?.(result); @@ -139,33 +139,33 @@ export function useMutation( /** Bekannte Lagen samt Abhilfe. */ const errorCodeExplanations: Readonly> = { - // Diese Lage entsteht bei einem zweiten Anstoss und ist kein Fehler des - // Anwenders — der Auftrag laeuft bereits. + // Diese Lage entsteht bei einem zweiten Anstoß und ist kein Fehler des + // Anwenders — der Auftrag läuft bereits. JOB_ALREADY_RUNNING: - 'Dieser Auftrag laeuft bereits. Ein zweiter Lauf wuerde in dieselbe Kette schreiben.', + 'Dieser Auftrag läuft bereits. Ein zweiter Lauf würde in dieselbe Kette schreiben.', RESTORE_ALREADY_RUNNING: - 'In dieses Ziel laeuft bereits eine Wiederherstellung. Zwei gleichzeitige wuerden sich gegenseitig ueberschreiben.', - VERIFICATION_ALREADY_RUNNING: 'Dieses Backup wird bereits geprueft.', + 'In dieses Ziel läuft bereits eine Wiederherstellung. Zwei gleichzeitige würden sich gegenseitig überschreiben.', + VERIFICATION_ALREADY_RUNNING: 'Dieses Backup wird bereits geprüft.', TARGET_NOT_EMPTY: - 'Das Zielverzeichnis ist nicht leer. Ueberschreiben verlangt die ausdrueckliche Bestaetigung.', + 'Das Zielverzeichnis ist nicht leer. Überschreiben verlangt die ausdrückliche Bestätigung.', RESTORE_TARGET_FORBIDDEN: - 'Der Zielpfad liegt in einem Systemverzeichnis. Dorthin wird nicht zurueckgeschrieben.', + 'Der Zielpfad liegt in einem Systemverzeichnis. Dorthin wird nicht zurückgeschrieben.', LAST_ADMINISTRATOR: - 'Das ist der letzte Administrator. Er laesst sich weder loeschen noch deaktivieren — sonst waere die Anlage nicht mehr verwaltbar.', + 'Das ist der letzte Administrator. Er lässt sich weder löschen noch deaktivieren — sonst wäre die Anlage nicht mehr verwaltbar.', BUILTIN_ROLE_IMMUTABLE: - 'Mitgelieferte Rollen sind unveraenderlich. Eine Aenderung verschoebe die Bedeutung bestehender Zuweisungen.', - REPOSITORY_IN_USE: 'Auf dieses Repository verweisen noch Auftraege oder Sicherungen.', + 'Mitgelieferte Rollen sind unveränderlich. Eine Änderung verschöbe die Bedeutung bestehender Zuweisungen.', + REPOSITORY_IN_USE: 'Auf dieses Repository verweisen noch Aufträge oder Sicherungen.', REPOSITORY_LOCKED: - 'Das Repository ist gesperrt — vermutlich laeuft eine Sicherung. Haelt der Zustand an, hilft "syncova-repo break-lock".', + 'Das Repository ist gesperrt — vermutlich läuft eine Sicherung. Hält der Zustand an, hilft "syncova-repo break-lock".', REPOSITORY_FULL: - 'Auf dem Ziel ist kein Platz mehr. Ein Wiederholungslauf wuerde die Lage verschaerfen.', + 'Auf dem Ziel ist kein Platz mehr. Ein Wiederholungslauf würde die Lage verschärfen.', IMMUTABLE_UNTIL: - 'Dieser Wiederherstellungspunkt steht unter Aufbewahrungsschutz und laesst sich bis zum Fristende nicht loeschen.', + 'Dieser Wiederherstellungspunkt steht unter Aufbewahrungsschutz und lässt sich bis zum Fristende nicht löschen.', LEGAL_HOLD: 'Auf diesem Wiederherstellungspunkt liegt ein Legal Hold. Er muss zuerst aufgehoben werden.', SERVICE_UNAVAILABLE: - 'Der Dienst ist derzeit nicht vollstaendig verfuegbar. Das ist kein Problem Ihrer Sitzung.', - FORBIDDEN: 'Ihrer Rolle fehlt die Berechtigung fuer diese Handlung.', + 'Der Dienst ist derzeit nicht vollständig verfügbar. Das ist kein Problem Ihrer Sitzung.', + FORBIDDEN: 'Ihrer Rolle fehlt die Berechtigung für diese Handlung.', }; /** @@ -173,14 +173,14 @@ const errorCodeExplanations: Readonly> = { * * **Die Servermeldung hat Vorrang.** Sie kennt den Einzelfall — welcher Bericht, * welches Repository, welche Datei —, und diese Genauigkeit ist mehr wert als - * jede allgemeine Erklaerung. Die Tabelle springt nur ein, wenn der Server + * jede allgemeine Erklärung. Die Tabelle springt nur ein, wenn der Server * keine Meldung mitschickt. * - * Das war zunaechst umgekehrt gebaut, und ein Test hat es aufgedeckt: Ein - * `SERVICE_UNAVAILABLE` mit der Meldung „Fuer diesen Bericht ist keine - * Sicherheitspruefung eingerichtet." wurde durch den allgemeinen Satz „Der - * Dienst ist derzeit nicht vollstaendig verfuegbar" ersetzt. Der Betreiber - * haette den Fehler bei seiner Anlage gesucht statt bei der Einrichtung dieses + * Das war zunächst umgekehrt gebaut, und ein Test hat es aufgedeckt: Ein + * `SERVICE_UNAVAILABLE` mit der Meldung „Für diesen Bericht ist keine + * Sicherheitsprüfung eingerichtet." wurde durch den allgemeinen Satz „Der + * Dienst ist derzeit nicht vollständig verfügbar" ersetzt. Der Betreiber + * hätte den Fehler bei seiner Anlage gesucht statt bei der Einrichtung dieses * einen Berichts. */ export function describeApiError(apiError: ApiError): string { @@ -194,10 +194,10 @@ export function describeApiError(apiError: ApiError): string { } /** - * Liefert die allgemeine Erklaerung zu einem Fehlercode, sofern es eine gibt. + * Liefert die allgemeine Erklärung zu einem Fehlercode, sofern es eine gibt. * - * Gedacht als **Ergaenzung** zur Servermeldung, nicht als Ersatz: Wo eine Lage - * eine bekannte Abhilfe hat, laesst sie sich zusaetzlich anzeigen. + * Gedacht als **Ergänzung** zur Servermeldung, nicht als Ersatz: Wo eine Lage + * eine bekannte Abhilfe hat, lässt sie sich zusätzlich anzeigen. */ export function explainApiErrorCode(errorCode: string): string | undefined { return errorCodeExplanations[errorCode]; diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx index 2a3c122..9093420 100644 --- a/apps/web/src/components/layout/AppShell.tsx +++ b/apps/web/src/components/layout/AppShell.tsx @@ -1,19 +1,20 @@ /** * Rahmen der angemeldeten Ansicht: Seitenleiste, Kopfzeile, Inhalt. * - * Die Seitenleiste laesst sich einklappen und merkt sich das. Der Grund ist - * nicht Aesthetik: Wer eine Stoerung bearbeitet, hat oft eine breite Tabelle mit - * Laufzeiten und Fehlercodes vor sich, und 240 Pixel Menue sind dann im Weg. + * Die Seitenleiste lässt sich einklappen und merkt sich das. Der Grund ist + * nicht Ästhetik: Wer eine Störung bearbeitet, hat oft eine breite Tabelle mit + * Laufzeiten und Fehlercodes vor sich, und 240 Pixel Menü sind dann im Weg. * - * Auf schmalen Geraeten wird sie zur Schublade. Der Plan verlangt Bedienbarkeit + * Auf schmalen Geräten wird sie zur Schublade. Der Plan verlangt Bedienbarkeit * auf Tablets (PROMPT.md §68) — und ein Bereitschaftsdienst sieht nachts als * Erstes auf ein Telefon. */ -import { Menu, Moon, PanelLeftClose, PanelLeft, Sun, X } from 'lucide-react'; +import { Menu, Moon, PanelLeftClose, PanelLeft, Sun, Timer, X } from 'lucide-react'; import { useEffect, useState, type ReactNode } from 'react'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui'; +import { formatSessionClock, type SessionClock } from '@/features/auth/useSession'; import { ALL_PAGES, SECTION_LABELS, @@ -43,6 +44,7 @@ export function AppShell({ onNavigate, grantedPermissions, userName, + sessionClock, onLogout, children, }: { @@ -50,6 +52,8 @@ export function AppShell({ readonly onNavigate: (pageIdentifier: string) => void; readonly grantedPermissions: readonly string[]; readonly userName: string; + /** Verbleibende Sitzungszeit für die Anzeige. */ + readonly sessionClock: SessionClock; readonly onLogout: () => void; readonly children: ReactNode; }) { @@ -60,7 +64,7 @@ export function AppShell({ const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); // Das Thema wird am Wurzelelement gesetzt, weil die Farbvariablen dort - // haengen — und weil ein Dialog im Portal sonst im falschen Thema erschiene. + // hängen — und weil ein Dialog im Portal sonst im falschen Thema erschiene. useEffect(() => { document.documentElement.setAttribute('data-theme', themePreference); window.localStorage.setItem(THEME_STORAGE_KEY, themePreference); @@ -71,13 +75,13 @@ export function AppShell({ return (
- {/* Abdunklung hinter der Schublade auf schmalen Geraeten. */} + {/* Abdunklung hinter der Schublade auf schmalen Geräten. */} {isMobileMenuOpen ? ( @@ -128,7 +132,7 @@ export function AppShell({ isActive={page.id === currentPageId} isCollapsed={isSidebarCollapsed} onSelect={() => { - // Die Schublade schliesst sich beim Auswaehlen, nicht + // Die Schublade schließt sich beim Auswählen, nicht // in einem Effekt auf den Seitenwechsel: Der Anlass // ist die Handlung, nicht der neue Zustand. setIsMobileMenuOpen(false); @@ -155,7 +159,7 @@ export function AppShell({ }) } className="hidden w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-sm text-fg-muted hover:bg-hover hover:text-fg lg:flex" - title={isSidebarCollapsed ? 'Menue ausklappen' : 'Menue einklappen'} + title={isSidebarCollapsed ? 'Menü ausklappen' : 'Menü einklappen'} > {isSidebarCollapsed ? ( @@ -180,7 +184,7 @@ export function AppShell({ type="button" className="rounded p-1.5 text-fg-muted hover:bg-hover hover:text-fg lg:hidden" onClick={() => setIsMobileMenuOpen(true)} - aria-label="Menue oeffnen" + aria-label="Menü öffnen" > @@ -208,6 +212,23 @@ export function AppShell({ {userName} + {/* Die Uhr steht neben „Abmelden", weil dort auch die Handlung + sitzt, die sie ankündigt. Unter fünf Minuten wird sie auffällig — + früher zu warnen erzeugt Gewöhnung, später bleibt keine Zeit. */} + + + Verbleibende Sitzungszeit: + {formatSessionClock(sessionClock.remainingSeconds)} + + @@ -244,8 +265,8 @@ function NavigationItem({ isActive ? 'bg-accent-subtle font-medium text-accent' : 'text-fg-muted hover:bg-hover hover:text-fg', - // Ein noch nicht verfuegbarer Bereich bleibt anklickbar — die Zielseite - // erklaert, was fehlt. Ihn zu sperren verschwiege den Ausbaustand. + // Ein noch nicht verfügbarer Bereich bleibt anklickbar — die Zielseite + // erklärt, was fehlt. Ihn zu sperren verschwiege den Ausbaustand. !page.available && 'opacity-60', )} > diff --git a/apps/web/src/components/layout/ErrorBoundary.tsx b/apps/web/src/components/layout/ErrorBoundary.tsx new file mode 100644 index 0000000..f07a7c6 --- /dev/null +++ b/apps/web/src/components/layout/ErrorBoundary.tsx @@ -0,0 +1,112 @@ +/** + * Fehlergrenze um den Seiteninhalt. + * + * Ohne sie reißt ein einziger Fehler in einer Komponente den gesamten Baum ab — + * React hängt alles aus, und übrig bleibt eine leere Seite. Im dunklen Thema ist + * das ein schwarzer Bildschirm ohne jeden Hinweis, und ein Betreiber hat keine + * Möglichkeit zu melden, was schiefging. + * + * Die Grenze sitzt **um den Inhalt**, nicht um die ganze Anwendung: Seitenleiste + * und Kopfzeile bleiben stehen, und man kann auf eine andere Seite wechseln, + * statt neu laden zu müssen. + * + * Klassenkomponente, weil React für Fehlergrenzen keine Entsprechung als Haken + * anbietet. + */ + +import { AlertTriangle } from 'lucide-react'; +import { Component, type ErrorInfo, type ReactNode } from 'react'; +import { Button } from '@/components/ui'; + +interface ErrorBoundaryProps { + /** Wechselt der Wert, wird der Fehler verworfen — etwa beim Seitenwechsel. */ + readonly resetKey: string; + readonly children: ReactNode; +} + +interface ErrorBoundaryState { + readonly caughtError: Error | null; + readonly componentStack: string; + readonly resetKey: string; +} + +export class ErrorBoundary extends Component { + public constructor(properties: ErrorBoundaryProps) { + super(properties); + + this.state = { caughtError: null, componentStack: '', resetKey: properties.resetKey }; + } + + public static getDerivedStateFromError(caughtError: Error): Partial { + return { caughtError }; + } + + /** + * Verwirft den Fehler beim Seitenwechsel. + * + * Abgeleitet statt in einem Effekt gesetzt: Sonst zeigte die neue Seite noch + * einen Wimpernschlag lang den Fehler der alten. + */ + public static getDerivedStateFromProps( + nextProperties: ErrorBoundaryProps, + currentState: ErrorBoundaryState, + ): Partial | null { + if (nextProperties.resetKey !== currentState.resetKey) { + return { caughtError: null, componentStack: '', resetKey: nextProperties.resetKey }; + } + + return null; + } + + public override componentDidCatch(caughtError: Error, errorInfo: ErrorInfo): void { + this.setState({ componentStack: errorInfo.componentStack ?? '' }); + + // Die Meldung gehört in die Entwicklerkonsole, damit sie sich kopieren + // lässt. Sie hier zu verschlucken wäre der stille Fehler, den die + // Entwicklungsregeln ausdrücklich verbieten. + console.error('Unbehandelter Fehler in der Oberfläche:', caughtError, errorInfo); + } + + public override render(): ReactNode { + const { caughtError, componentStack } = this.state; + + if (caughtError === null) { + return this.props.children; + } + + return ( +
+
+ +
+

+ Diese Seite konnte nicht angezeigt werden +

+

+ Die übrige Konsole arbeitet weiter. Wechseln Sie den Bereich oder + laden Sie neu. +

+ +
+              {caughtError.message || caughtError.name}
+              {componentStack ? `\n${componentStack.trim().split('\n').slice(0, 6).join('\n')}` : ''}
+            
+ +
+ + +
+
+
+
+ ); + } +} diff --git a/apps/web/src/components/layout/PageHeader.tsx b/apps/web/src/components/layout/PageHeader.tsx index c58bfe7..599a803 100644 --- a/apps/web/src/components/layout/PageHeader.tsx +++ b/apps/web/src/components/layout/PageHeader.tsx @@ -1,4 +1,4 @@ -/** Kopfbereich einer Seite: Titel, Erlaeuterung, Handlungen. */ +/** Kopfbereich einer Seite: Titel, Erläuterung, Handlungen. */ import { ChevronLeft } from 'lucide-react'; import type { ReactNode } from 'react'; @@ -10,7 +10,7 @@ export function PageHeader({ description, actions, onBack, - backLabel = 'Zurueck', + backLabel = 'Zurück', className, }: { readonly title: ReactNode; diff --git a/apps/web/src/components/ui/Button.tsx b/apps/web/src/components/ui/Button.tsx index 3ad3a1e..e006362 100644 --- a/apps/web/src/components/ui/Button.tsx +++ b/apps/web/src/components/ui/Button.tsx @@ -1,10 +1,10 @@ /** - * Schaltflaeche. + * Schaltfläche. * - * Die Auspraegung "destructive" ist bewusst eigenstaendig und nicht nur eine - * rote Farbe: Loeschende Handlungen sollen sich auch dann unterscheiden, wenn - * jemand die Oberflaeche in Graustufen betrachtet oder farbfehlsichtig ist. - * Deshalb traegt sie zusaetzlich einen kraeftigen Rand. + * Die Ausprägung "destructive" ist bewusst eigenständig und nicht nur eine + * rote Farbe: Löschende Handlungen sollen sich auch dann unterscheiden, wenn + * jemand die Oberfläche in Graustufen betrachtet oder farbfehlsichtig ist. + * Deshalb trägt sie zusätzlich einen kräftigen Rand. */ import { Slot } from '@radix-ui/react-slot'; @@ -14,7 +14,7 @@ import { forwardRef, type ButtonHTMLAttributes } from 'react'; import { cn } from '@/lib/utils'; const buttonVariants = cva( - // Gemeinsame Grundlage aller Auspraegungen. + // Gemeinsame Grundlage aller Ausprägungen. [ 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md', 'text-sm font-medium transition-colors', @@ -50,7 +50,7 @@ export interface ButtonProps VariantProps { /** Rendert die Kindkomponente statt eines diff --git a/apps/web/src/features/administration/EventsPage.tsx b/apps/web/src/features/administration/EventsPage.tsx index 130649f..2392d4f 100644 --- a/apps/web/src/features/administration/EventsPage.tsx +++ b/apps/web/src/features/administration/EventsPage.tsx @@ -2,13 +2,13 @@ * 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 + * Anwendungslogik. Es lässt sich weder aus der Oberfläche noch über die API + * ändern oder löschen — ein Protokoll, das sich bereinigen ließe, wäre * 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 — + * Die Adresse in der Spalte „Herkunft" ist die der tatsächlichen Verbindung. + * Weitergeleitete IP-Header werden bewusst ignoriert: Sie sind fälschbar, und + * im Audit stünde sonst eine beliebige Adresse. Hinter einem Reverse Proxy — * also im Normalfall dieser Anlage — steht dort dessen Adresse. */ @@ -43,7 +43,7 @@ interface AuditEvent { readonly correlation_id?: string; } -/** Laedt die letzten Ereignisse. */ +/** Lädt die letzten Ereignisse. */ async function fetchAuditEvents(abortSignal: AbortSignal): Promise { return requestApi('/audit-events?page_size=100', { signal: abortSignal, @@ -60,8 +60,8 @@ export function EventsPage() { /** * Filterung im Browser. * - * Die Liste umfasst hoechstens hundert Eintraege; eine serverseitige Suche - * waere hier mehr Vertrag als Nutzen. Bei groesseren Bestaenden gehoert sie + * Die Liste umfasst höchstens hundert Einträge; eine serverseitige Suche + * wäre hier mehr Vertrag als Nutzen. Bei größeren Beständen gehört sie * auf den Server — das ist eine bewusste Grenze, keine Auslassung. */ const filteredEvents = useMemo(() => { @@ -181,7 +181,7 @@ export function EventsPage() { Das Protokoll ist append-only per Datenbank-Trigger. Es - laesst sich weder hier noch ueber die API aendern oder loeschen — auch + lässt sich weder hier noch über die API ändern oder löschen — auch nicht von einem Administrator. @@ -213,7 +213,7 @@ export function EventsPage() { emptyDescription={ searchTerm ? 'Der Suchbegriff kommt in den letzten 100 Ereignissen nicht vor.' - : 'Sobald jemand eine sicherheitsrelevante Handlung ausfuehrt, erscheint sie hier.' + : 'Sobald jemand eine sicherheitsrelevante Handlung ausführt, erscheint sie hier.' } /> diff --git a/apps/web/src/features/administration/RolesPage.tsx b/apps/web/src/features/administration/RolesPage.tsx index 62834e8..abebef0 100644 --- a/apps/web/src/features/administration/RolesPage.tsx +++ b/apps/web/src/features/administration/RolesPage.tsx @@ -2,16 +2,16 @@ * 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: + * mit 34 Einträgen dazu führt, dass jemand „alles" ankreuzt. Zwei Paare sind + * bewusst getrennt und werden hier ausdrücklich erklärt: * - * - `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. + * - `restores.execute` enthält **nicht** `restores.overwrite`. Wiederherstellen + * ist etwas anderes als vorhandene Daten überschreiben. + * - `backups.delete` enthält **nicht** `immutability.manage`. Wer aufräumen + * darf, darf keinen Löschschutz aufheben — das ist der Schritt, der einem + * Angreifer den Weg öffnet. * - * Mitgelieferte Rollen sind unveraenderlich und werden als solche gezeigt, + * Mitgelieferte Rollen sind unveränderlich und werden als solche gezeigt, * statt eine Maske anzubieten, die der Server ablehnt. */ @@ -46,16 +46,16 @@ import { type Role, } from './administrationApi'; -/** Erklaerungen zu Berechtigungen, deren Zuschnitt nicht offensichtlich ist. */ +/** Erklärungen 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.', + 'Nicht in restores.execute enthalten. Erlaubt das Überschreiben vorhandener Daten am Ziel.', 'immutability.manage': - 'Nicht in backups.delete enthalten. Erlaubt das Aufheben von Loeschschutz und Legal Hold.', + 'Nicht in backups.delete enthalten. Erlaubt das Aufheben von Löschschutz und Legal Hold.', 'verification.restore_test': - 'Liest ein Backup vollstaendig und schreibt es versuchsweise zurueck — die einzige Pruefung, die ein Nachweis ist.', + 'Liest ein Backup vollständig und schreibt es versuchsweise zurück — die einzige Prüfung, die ein Nachweis ist.', 'settings.write': - 'Umfasst die Benachrichtigungswege. Wer sie umleitet, kann erreichen, dass niemand mehr von einem Ausfall erfaehrt.', + 'Umfasst die Benachrichtigungswege. Wer sie umleitet, kann erreichen, dass niemand mehr von einem Ausfall erfährt.', }; export function RolesPage({ @@ -92,17 +92,17 @@ export function RolesPage({ rolesResource.reload(); }, onError: (apiError) => - toast.showError('Die Rolle liess sich nicht anlegen', describeApiError(apiError), apiError.requestId), + toast.showError('Die Rolle ließ sich nicht anlegen', describeApiError(apiError), apiError.requestId), }); const deleteMutation = useMutation(deleteRole, { onSuccess: () => { - toast.showSuccess('Rolle geloescht'); + toast.showSuccess('Rolle gelöscht'); setRolePendingDeletion(null); rolesResource.reload(); }, onError: (apiError) => - toast.showError('Das Loeschen schlug fehl', describeApiError(apiError), apiError.requestId), + toast.showError('Das Löschen schlug fehl', describeApiError(apiError), apiError.requestId), }); const roles = rolesResource.data ?? []; @@ -110,7 +110,7 @@ export function RolesPage({ /** * Berechtigungen nach Bereich gruppiert. * - * Der Rueckfall auf ein leeres Array steht **innerhalb** der Berechnung: Ein + * Der Rückfall 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. @@ -119,8 +119,8 @@ export function RolesPage({ 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". + // Ohne ausdrückliche Kategorie dient der Teil vor dem Punkt als Bereich — + // "backups.read" gehört zu "backups". const categoryName = permission.category ?? permission.name.split('.')[0] ?? 'sonstige'; const existing = grouped.get(categoryName) ?? []; @@ -166,7 +166,7 @@ export function RolesPage({ header: 'Art', render: (role) => ( - {role.is_builtin ? 'Mitgeliefert (unveraenderlich)' : 'Selbst angelegt'} + {role.is_builtin ? 'Mitgeliefert (unveränderlich)' : 'Selbst angelegt'} ), }, @@ -179,8 +179,8 @@ export function RolesPage({
{selectedRole.is_builtin ? ( - Mitgelieferte Rollen sind unveraenderlich. Eine - Aenderung verschoebe die Bedeutung bestehender Zuweisungen — + Mitgelieferte Rollen sind unveränderlich. Eine + Änderung verschöbe die Bedeutung bestehender Zuweisungen — jemand behielte eine Rolle, die etwas anderes bedeutet als bei ihrer Vergabe. @@ -289,7 +289,7 @@ export function RolesPage({ label="Beschreibung" value={draftDescription} onChange={(changeEvent) => setDraftDescription(changeEvent.target.value)} - placeholder="Darf Laeufe anstossen und Meldungen bestaetigen" + placeholder="Darf Läufe anstoßen und Meldungen bestätigen" />
@@ -348,9 +348,9 @@ export function RolesPage({ !isOpen && setRolePendingDeletion(null)} - title="Rolle loeschen" + title="Rolle löschen" description={rolePendingDeletion?.name} - confirmLabel="Loeschen" + confirmLabel="Löschen" isDestructive isLoading={deleteMutation.isRunning} onConfirm={() => { @@ -360,8 +360,8 @@ export function RolesPage({ }} > - Konten mit dieser Rolle verlieren die zugehoerigen Rechte sofort. Ist - sie noch zugewiesen, lehnt der Server das Loeschen ab. + Konten mit dieser Rolle verlieren die zugehörigen Rechte sofort. Ist + sie noch zugewiesen, lehnt der Server das Löschen ab. diff --git a/apps/web/src/features/administration/SettingsPage.tsx b/apps/web/src/features/administration/SettingsPage.tsx index afcf9bb..ddeb373 100644 --- a/apps/web/src/features/administration/SettingsPage.tsx +++ b/apps/web/src/features/administration/SettingsPage.tsx @@ -84,7 +84,7 @@ export function SettingsPage({ }, onError: (apiError) => toast.showError( - 'Der Weg liess sich nicht anlegen', + 'Der Weg ließ sich nicht anlegen', describeApiError(apiError), apiError.requestId, ), @@ -104,7 +104,7 @@ export function SettingsPage({ onSuccess: (enrollment) => setMfaEnrollment(enrollment), onError: (apiError) => toast.showError( - 'Die Einrichtung liess sich nicht beginnen', + 'Die Einrichtung ließ sich nicht beginnen', describeApiError(apiError), apiError.requestId, ), @@ -114,7 +114,7 @@ export function SettingsPage({ onSuccess: () => { toast.showSuccess( 'Zweiter Faktor eingerichtet', - 'Ab der naechsten Anmeldung wird er verlangt.', + 'Ab der nächsten Anmeldung wird er verlangt.', ); setIsMfaConfirmed(true); setMfaEnrollment(null); @@ -123,7 +123,7 @@ export function SettingsPage({ onError: (apiError) => toast.showError( 'Der Code wurde nicht anerkannt', - `${describeApiError(apiError)} — pruefen Sie die Uhrzeit des Geraets; TOTP haengt an der Zeit.`, + `${describeApiError(apiError)} — prüfen Sie die Uhrzeit des Geräts; TOTP hängt an der Zeit.`, apiError.requestId, ), }); @@ -182,7 +182,7 @@ export function SettingsPage({ @@ -202,9 +202,9 @@ export function SettingsPage({
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. + 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.
@@ -231,16 +231,16 @@ export function SettingsPage({ disabled={mfaCode.trim().length !== 6} onClick={() => void confirmMfaMutation.run(mfaCode.trim())} > - Bestaetigen + Bestätigen
) : (

- Ihr Konto ist nur durch sein Passwort geschuetzt. Bei einem Konto - mit Loeschrecht ist das der Punkt, an dem eine Uebernahme zum - Datenverlust fuehrt. + 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.

) : null}
) : null} @@ -259,9 +259,9 @@ function AlertRow({ /** * Zeigt, worauf geachtet wird. * - * Eine Regel, die dauerhaft schweigt, ist gefaehrlicher als keine — sie erweckt - * den Eindruck, es werde geprueft. Deshalb steht bei jeder nicht ausgewerteten - * Regel ausdruecklich, dass sie nicht greift, und warum. + * Eine Regel, die dauerhaft schweigt, ist gefährlicher als keine — sie erweckt + * den Eindruck, es werde geprüft. Deshalb steht bei jeder nicht ausgewerteten + * Regel ausdrücklich, dass sie nicht greift, und warum. */ function RuleList({ overview }: { readonly overview: AlertOverview }): React.JSX.Element { return ( @@ -285,7 +285,7 @@ function RuleList({ overview }: { readonly overview: AlertOverview }): React.JSX {rule.available ? ( ) : ( - + )}

diff --git a/apps/web/src/features/alerts/alertsApi.ts b/apps/web/src/features/alerts/alertsApi.ts index a2b5f64..8a6c936 100644 --- a/apps/web/src/features/alerts/alertsApi.ts +++ b/apps/web/src/features/alerts/alertsApi.ts @@ -1,5 +1,5 @@ /** - * Zugriff auf Meldungen und Benachrichtigungskanaele. + * Zugriff auf Meldungen und Benachrichtigungskanäle. */ import { requestApi } from '../../api/client'; @@ -12,17 +12,17 @@ export type AlertStatus = 'open' | 'acknowledged' | 'resolved'; /** Eine Meldung. */ export interface Alert { - /** Oeffentlicher Bezeichner. */ + /** Öffentlicher Bezeichner. */ readonly id: string; - /** Ausloesende Regel. */ + /** Auslösende Regel. */ readonly rule_name: string; /** Schweregrad. */ readonly severity: AlertSeverity; /** Bearbeitungszustand. */ readonly status: AlertStatus; - /** Ueberschrift. */ + /** Überschrift. */ readonly title: string; - /** Befund und naechste Handlung. */ + /** Befund und nächste Handlung. */ readonly message: string; /** Art des betroffenen Gegenstands. */ readonly entity_type?: string; @@ -41,11 +41,11 @@ export interface Alert { readonly last_seen_at: string; /** Zeitpunkt der Kenntnisnahme in UTC. */ readonly acknowledged_at?: string; - /** Bemerkung des Bestaetigenden. */ + /** Bemerkung des Bestätigenden. */ readonly acknowledgement_note?: string; - /** Zeitpunkt der Aufloesung in UTC. */ + /** Zeitpunkt der Auflösung in UTC. */ readonly resolved_at?: string; - /** Begruendung der Aufloesung. */ + /** Begründung der Auflösung. */ readonly resolution_note?: string; } @@ -55,13 +55,13 @@ export interface AlertRule { readonly name: string; /** Bezeichnung. */ readonly title: string; - /** Erklaerung. */ + /** Erklärung. */ readonly description: string; - /** Schweregrad ausgeloester Meldungen. */ + /** Schweregrad ausgelöster Meldungen. */ readonly severity: AlertSeverity; - /** Meldet, ob die Regel ausloesen kann. */ + /** Meldet, ob die Regel auslösen kann. */ readonly available: boolean; - /** Erklaert eine Regel ohne Datengrundlage. */ + /** Erklärt eine Regel ohne Datengrundlage. */ readonly unavailable_reason?: string; } @@ -79,17 +79,17 @@ export interface AlertSummary { readonly resolved_last_day: number; } -/** Antwort der Meldungsuebersicht. */ +/** Antwort der Meldungsübersicht. */ export interface AlertOverview { /** Die Meldungslage. */ readonly summary: AlertSummary; /** Alle Regeln. */ readonly rules: readonly AlertRule[]; - /** Zahl der ausloesbaren Regeln. */ + /** Zahl der auslösbaren Regeln. */ readonly available_rule_count: number; } -/** Laedt die Meldungen. */ +/** Lädt die Meldungen. */ export async function fetchAlerts( onlyActive: boolean, abortSignal?: AbortSignal, @@ -106,7 +106,7 @@ export async function fetchAlerts( ); } -/** Laedt die Meldungslage samt Regelwerk. */ +/** Lädt die Meldungslage samt Regelwerk. */ export async function fetchAlertOverview(abortSignal?: AbortSignal): Promise { return requestApi('/alerts/summary', abortSignal ? { signal: abortSignal } : {}); } @@ -119,7 +119,7 @@ export async function acknowledgeAlert(alertIdentifier: string, note: string): P }); } -/** Schliesst eine Meldung von Hand. */ +/** Schließt eine Meldung von Hand. */ export async function resolveAlert(alertIdentifier: string, note: string): Promise { await requestApi(`/alerts/${encodeURIComponent(alertIdentifier)}/resolve`, { method: 'POST', diff --git a/apps/web/src/features/auth/LoginPage.test.tsx b/apps/web/src/features/auth/LoginPage.test.tsx index b01b713..abb4b47 100644 --- a/apps/web/src/features/auth/LoginPage.test.tsx +++ b/apps/web/src/features/auth/LoginPage.test.tsx @@ -12,7 +12,7 @@ function buildResponse(responseBody: unknown, statusCode = 200): Response { }); } -/** Ein vollstaendiger Benutzer, wie ihn die API zurueckgibt. */ +/** Ein vollständiger Benutzer, wie ihn die API zurückgibt. */ const testUser = { id: '11111111-1111-4111-8111-111111111111', username: 'admin', @@ -22,7 +22,7 @@ const testUser = { permissions: ['users.read'], }; -/** Ein Tokenpaar, wie es die API zurueckgibt. */ +/** Ein Tokenpaar, wie es die API zurückgibt. */ const testTokens = { access_token: 'zugriffstoken', // secretscan:erlaubt: erfundener Testwert refresh_token: 'erneuerungstoken', // secretscan:erlaubt: erfundener Testwert @@ -85,7 +85,7 @@ describe('LoginPage', () => { expect(handleAuthenticated).not.toHaveBeenCalled(); }); - it('schliesst die Anmeldung nach gueltigem Code ab', async () => { + it('schließt die Anmeldung nach gültigem Code ab', async () => { const fetchMock = vi .fn() .mockResolvedValueOnce( @@ -112,14 +112,14 @@ describe('LoginPage', () => { await waitFor(() => expect(screen.getByLabelText('Code')).toBeInTheDocument()); await userEvent.type(screen.getByLabelText('Code'), '123456'); - await userEvent.click(screen.getByRole('button', { name: 'Bestaetigen' })); + await userEvent.click(screen.getByRole('button', { name: 'Bestätigen' })); await waitFor(() => { expect(handleAuthenticated).toHaveBeenCalled(); }); }); - it('zeigt die Fehlermeldung des Servers verstaendlich an', async () => { + it('zeigt die Fehlermeldung des Servers verständlich an', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue( @@ -166,14 +166,14 @@ describe('LoginPage', () => { await userEvent.type(passwordField, 'falsch'); await userEvent.click(screen.getByRole('button', { name: 'Anmelden' })); - // Ein stehengebliebenes Passwort waere auf einem gemeinsam genutzten + // Ein stehengebliebenes Passwort wäre auf einem gemeinsam genutzten // Bildschirm sichtbar. await waitFor(() => { expect(passwordField.value).toBe(''); }); }); - it('kehrt bei abgelaufener Herausforderung zur Anmeldung zurueck', async () => { + it('kehrt bei abgelaufener Herausforderung zur Anmeldung zurück', async () => { const fetchMock = vi .fn() .mockResolvedValueOnce( @@ -205,9 +205,9 @@ describe('LoginPage', () => { await waitFor(() => expect(screen.getByLabelText('Code')).toBeInTheDocument()); await userEvent.type(screen.getByLabelText('Code'), '123456'); - await userEvent.click(screen.getByRole('button', { name: 'Bestaetigen' })); + await userEvent.click(screen.getByRole('button', { name: 'Bestätigen' })); - // Ohne Rueckkehr sässe der Benutzer in einem Schritt fest, der nicht mehr gilt. + // Ohne Rückkehr sässe der Benutzer in einem Schritt fest, der nicht mehr gilt. await waitFor(() => { expect(screen.getByLabelText('Benutzername')).toBeInTheDocument(); }); diff --git a/apps/web/src/features/auth/LoginPage.tsx b/apps/web/src/features/auth/LoginPage.tsx index 006e050..44c8f8f 100644 --- a/apps/web/src/features/auth/LoginPage.tsx +++ b/apps/web/src/features/auth/LoginPage.tsx @@ -29,7 +29,7 @@ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Elemen const [errorMessage, setErrorMessage] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); - /** Wandelt einen Fehler in eine verstaendliche Meldung (PROMPT.md §124). */ + /** Wandelt einen Fehler in eine verständliche Meldung (PROMPT.md §124). */ function describeError(caughtError: unknown): string { if (caughtError instanceof ApiError) { return caughtError.message; @@ -59,7 +59,7 @@ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Elemen } // Weder Tokens noch Herausforderung: die Antwort passt nicht zum Vertrag. - setErrorMessage('Die Antwort des Servers war unvollstaendig. Bitte erneut versuchen.'); + setErrorMessage('Die Antwort des Servers war unvollständig. Bitte erneut versuchen.'); } catch (caughtError) { setErrorMessage(describeError(caughtError)); // Das Passwort wird nach einem Fehlversuch geleert. @@ -88,7 +88,7 @@ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Elemen return; } - setErrorMessage('Die Antwort des Servers war unvollstaendig. Bitte erneut versuchen.'); + setErrorMessage('Die Antwort des Servers war unvollständig. Bitte erneut versuchen.'); } catch (caughtError) { setErrorMessage(describeError(caughtError)); setMfaCode(''); @@ -147,7 +147,7 @@ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Elemen )} ) : ( @@ -155,7 +155,7 @@ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Elemen

Zweiter Faktor

Bitte den Code aus der Authenticator-App eingeben. Alternativ ist ein - Wiederherstellungscode moeglich. + Wiederherstellungscode möglich.

{/* Eine Zahl, die auf zu vielen Vermutungen beruht, wird als - Vermutung gekennzeichnet — sonst wird sie zitiert, als waere + Vermutung gekennzeichnet — sonst wird sie zitiert, als wäre sie eine Messung. */} {!assurance.is_trustworthy ? ( - {assurance.unknown_input_count} Eingangsgroessen wurden nicht + {assurance.unknown_input_count} Eingangsgrößen wurden nicht gemessen und gehen weder positiv noch negativ ein. Solange das so ist, sagt der Prozentwert wenig. @@ -307,7 +307,7 @@ export function BackupDetailPage({
- + {assurance.last_verified_at ? formatDateTime(assurance.last_verified_at) : 'Nie'} @@ -328,7 +328,7 @@ export function BackupDetailPage({ {/* --- Schutz --- */}
- + {protection ? ( <> @@ -339,7 +339,7 @@ export function BackupDetailPage({ )} - {protection.is_protected ? 'Geschuetzt' : 'Nicht geschuetzt'} + {protection.is_protected ? 'Geschützt' : 'Nicht geschützt'}
@@ -353,7 +353,7 @@ export function BackupDetailPage({ {protection.legal_hold ? ( - {protection.legal_hold_reason || 'Ohne Begruendung gesetzt.'} + {protection.legal_hold_reason || 'Ohne Begründung gesetzt.'} ) : null} @@ -383,7 +383,7 @@ export function BackupDetailPage({ )} ) : null} @@ -397,7 +397,7 @@ export function BackupDetailPage({ {/* --- Ransomware --- */} {ransomware ? ( - + {ransomware.summary ? ( @@ -405,15 +405,15 @@ export function BackupDetailPage({ ) : null} {ransomware.severity === 'unknown' ? (

- Unter fuenf Vergleichslaeufen gibt es keinen belastbaren + Unter fünf Vergleichsläufen gibt es keinen belastbaren Basiswert. Wer nicht messen kann, hat nichts gemessen — eine - geratene Schwelle waere schlechter als keine. + geratene Schwelle wäre schlechter als keine.

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

- {ransomware.triggered_signal_count} von 6 Signalen auffaellig. - Diese Einschaetzung meldet nur und handelt nie. + {ransomware.triggered_signal_count} von 6 Signalen auffällig. + Diese Einschätzung meldet nur und handelt nie.

) : null}
@@ -428,12 +428,12 @@ export function BackupDetailPage({ disabled={protection?.is_protected} title={ protection?.is_protected - ? 'Dieser Punkt steht unter Schutz und laesst sich nicht loeschen.' + ? 'Dieser Punkt steht unter Schutz und lässt sich nicht löschen.' : undefined } > - Loeschen + Löschen ) : null} @@ -453,14 +453,14 @@ export function BackupDetailPage({ void verifyMutation.run(backupIdentifier, verificationType)} >
setVerificationType(changeEvent.target.value)} > @@ -482,7 +482,7 @@ export function BackupDetailPage({ {verificationType === 'restore_test' ? ( - Nur diese Pruefung ist ein Nachweis. Sie hebt die + Nur diese Prüfung ist ein Nachweis. Sie hebt die Einstufung auf „wiederherstellbar“ — alles andere sind Indizien. ) : null} @@ -493,17 +493,17 @@ export function BackupDetailPage({ open={isLegalHoldDialogOpen} onOpenChange={setIsLegalHoldDialogOpen} title="Legal Hold setzen" - description="Der Punkt laesst sich danach nicht mehr loeschen — unbefristet." + description="Der Punkt lässt sich danach nicht mehr löschen — unbefristet." confirmLabel="Legal Hold setzen" isLoading={legalHoldMutation.isRunning} onConfirm={() => void legalHoldMutation.run(backupIdentifier, holdReason)} >