From b50ad2b9bcea0bd6a098c194c1de4c363c15daad Mon Sep 17 00:00:00 2001 From: Jerrit Fritzsche Date: Tue, 18 Aug 2026 14:42:37 +0200 Subject: [PATCH] Sitzung ueberlebt Neuladen, Umlaute, Fehlergrenze MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Sitzung.** Die Tokens lagen nur im Arbeitsspeicher — jedes Neuladen warf den Betreiber auf die Anmeldemaske. Das war die sicherste Variante und praktisch unbrauchbar; mitten in einer Stoerung ist es kein Sicherheitsgewinn, sondern ein Hindernis. Jetzt `sessionStorage` (nicht `localStorage`: stirbt mit dem Tab), begrenzt durch zwei Uhren: - **Harte Obergrenze** von 30 Minuten ab Anmeldung, durch keine Interaktion verschiebbar. Sonst waere "30 Minuten" keine Zusage. - **Untaetigkeitsgrenze** von 30 Minuten. - Der sofortige serverseitige Widerruf bleibt die eigentliche Absicherung — die Tokens sind opak, kein JWT, und genau dafuer wurden sie gewaehlt. Dazu die Sitzungsuhr oben rechts neben "Abmelden", unter fuenf Minuten auffaellig. Umgesetzt mit `useSyncExternalStore`: Die Restzeit haengt an der Uhr und am Speicher, also an zwei Dingen ausserhalb von React. Sie beim Rendern auszurechnen waere ein unreiner Aufruf, sie in einem Effekt zu setzen eine zweite Renderrunde je Sekunde — beides hat der Linter gemeldet. Sechs Tests halten die Grenzen fest, zwei davon durch Mutation als fangend bestaetigt (Obergrenze mitverschieben schlaegt fehl). **Fehlergrenze.** Ein Fehler in einer Komponente riss bisher den gesamten Baum ab; uebrig blieb eine leere Seite — im dunklen Thema ein schwarzer Bildschirm ohne jeden Hinweis. Die Grenze sitzt **um den Inhalt**: Menue und Kopfzeile bleiben stehen, der Fehlertext ist lesbar und kopierbar. **Umlaute.** Die Oberflaeche schrieb durchgehend ae/oe/ue/ss. Jetzt aeoeuess. Dabei ein selbst verursachter Schaden, gefunden und behoben: Eine Regel "ue → ü" ist falsch, weil die Buchstabenfolge nicht immer ein Umlaut ist. Sie machte aus "Quelle" ein "Qülle", aus "neue" ein "neü", aus "aktuell" ein "aktüll", aus "Dauer" ein "Daür". Die Abbildung laeuft jetzt ueber eine gepruefte Wortliste mit Ausschluss englischer Bezeichner (`value`, `message`, `session`, `queued`, `true`); die 23 zerstoerten Woerter sind einzeln zurueckgesetzt. Ein alter Tippfehler ("geprueter") ist dabei mit aufgefallen. 82 Tests gruen, tsc sauber, eslint ohne Warnung. Co-Authored-By: Claude Opus 5 --- apps/web/src/App.tsx | 104 +++++++--- apps/web/src/api/client.test.ts | 16 +- apps/web/src/api/client.ts | 86 ++++----- apps/web/src/api/useApiResource.ts | 44 ++--- apps/web/src/api/useMutation.ts | 88 ++++----- apps/web/src/components/layout/AppShell.tsx | 49 +++-- .../src/components/layout/ErrorBoundary.tsx | 112 +++++++++++ apps/web/src/components/layout/PageHeader.tsx | 4 +- apps/web/src/components/ui/Button.tsx | 20 +- apps/web/src/components/ui/Card.tsx | 2 +- apps/web/src/components/ui/DataTable.tsx | 14 +- apps/web/src/components/ui/Dialog.tsx | 40 ++-- apps/web/src/components/ui/Feedback.tsx | 18 +- apps/web/src/components/ui/Form.tsx | 8 +- .../src/components/ui/StatusBadge.test.tsx | 34 ++-- apps/web/src/components/ui/StatusBadge.tsx | 64 +++---- apps/web/src/components/ui/Toast.tsx | 14 +- .../features/administration/EventsPage.tsx | 20 +- .../src/features/administration/RolesPage.tsx | 62 +++--- .../features/administration/SettingsPage.tsx | 38 ++-- .../src/features/administration/UsersPage.tsx | 60 +++--- .../administration/administrationApi.ts | 58 +++--- apps/web/src/features/alerts/AlertsPage.tsx | 30 +-- apps/web/src/features/alerts/alertsApi.ts | 34 ++-- apps/web/src/features/auth/LoginPage.test.tsx | 18 +- apps/web/src/features/auth/LoginPage.tsx | 14 +- apps/web/src/features/auth/authApi.ts | 43 ++++- .../src/features/auth/sessionStore.test.ts | 102 ++++++++++ apps/web/src/features/auth/sessionStore.ts | 181 ++++++++++++++++++ apps/web/src/features/auth/useSession.ts | 172 +++++++++++++++++ .../src/features/backups/BackupDetailPage.tsx | 90 ++++----- .../src/features/backups/VerificationPage.tsx | 34 ++-- apps/web/src/features/backups/backupsApi.ts | 78 ++++---- .../features/dashboard/DashboardPage.test.tsx | 22 +-- .../src/features/dashboard/DashboardPage.tsx | 26 +-- .../features/dashboard/RecoveryPointsPage.tsx | 26 +-- .../src/features/dashboard/dashboardApi.ts | 40 ++-- .../health/SystemHealthPanel.test.tsx | 10 +- .../src/features/health/SystemHealthPanel.tsx | 6 +- .../src/features/health/useSystemHealth.ts | 12 +- .../features/infrastructure/AgentsPage.tsx | 40 ++-- .../infrastructure/ProtectedSystemsPage.tsx | 16 +- .../features/infrastructure/ProxmoxPage.tsx | 84 ++++---- .../infrastructure/infrastructureApi.ts | 48 ++--- .../src/features/jobs/BackupWizard.test.tsx | 62 +++--- apps/web/src/features/jobs/BackupWizard.tsx | 80 ++++---- apps/web/src/features/jobs/JobDetailPage.tsx | 44 ++--- apps/web/src/features/jobs/JobsPage.tsx | 70 +++---- apps/web/src/features/jobs/jobsApi.ts | 78 ++++---- .../web/src/features/jobs/wizardModel.test.ts | 46 ++--- apps/web/src/features/jobs/wizardModel.ts | Bin 15953 -> 15953 bytes .../src/features/metrics/LineChart.test.tsx | 32 ++-- apps/web/src/features/metrics/LineChart.tsx | 48 ++--- apps/web/src/features/metrics/MetricsPage.tsx | 14 +- apps/web/src/features/metrics/metricsApi.ts | 20 +- .../src/features/reports/ReportsPage.test.tsx | 44 ++--- apps/web/src/features/reports/ReportsPage.tsx | 58 +++--- .../repositories/RepositoriesPage.tsx | 92 ++++----- .../features/repositories/RetentionPage.tsx | 74 +++---- .../features/repositories/repositoriesApi.ts | 66 +++---- .../src/features/restores/RestoreWizard.tsx | 96 +++++----- .../src/features/restores/RestoresPage.tsx | 36 ++-- apps/web/src/features/restores/restoresApi.ts | 50 ++--- .../src/features/security/SecurityPage.tsx | 62 +++--- apps/web/src/lib/utils.ts | 26 +-- apps/web/src/main.tsx | 4 +- apps/web/src/navigation/UnavailablePage.tsx | 8 +- apps/web/src/navigation/pages.test.ts | 16 +- apps/web/src/navigation/pages.ts | 42 ++-- apps/web/src/navigation/useCurrentPage.ts | 28 +-- apps/web/src/test/setup.ts | 2 +- apps/web/src/types/api.ts | 30 +-- apps/web/src/types/auth.ts | 8 +- 73 files changed, 1997 insertions(+), 1320 deletions(-) create mode 100644 apps/web/src/components/layout/ErrorBoundary.tsx create mode 100644 apps/web/src/features/auth/sessionStore.test.ts create mode 100644 apps/web/src/features/auth/sessionStore.ts create mode 100644 apps/web/src/features/auth/useSession.ts 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)} >