syncova-backup/apps/web/src/App.tsx
Jerrit Fritzsche 6e23696fcb
Some checks failed
CI / Backend (Go) (push) Failing after 30s
CI / Frontend (React/TypeScript) (push) Successful in 43s
CI / Sicherheitsprüfungen (push) Successful in 27s
Weboberflaeche: letzte Seiten auf das Design-System gezogen
Damit gibt es nur noch ein Stylesheet. Uebersicht, Meldungen, Kennzahlen,
Berichte, Security Center, Wiederherstellungspunkte, Anmeldung, Backup-Assistent
und Gesundheitsanzeige nutzten noch das alte — sie funktionierten, sahen aber
anders aus als der Rest.

Der Backup-Assistent wurde als **Klassenabbildung** umgestellt, nicht neu
geschrieben: Seine Logik ist geprueft und richtig; eine Neufassung haette 500
Zeilen Verhalten ohne Not angefasst. Die elf Tests des Assistenten pruefen
Verhalten und blieben unveraendert gueltig.

Entfernt, weil ersetzt und nirgends mehr verwendet: JobsPanel, StatusIndicator,
PageState, App.css, tokens.css und vier weitere Stylesheets. Das ausgelieferte
CSS faellt von 54 auf 30 KB.

Drei Funde beim Umbau, alle von Tests aufgedeckt:

- **`describeApiError` warf die genauere Servermeldung weg.** Sie ersetzte sie
  durch den allgemeinen Satz aus der Codetabelle. Ein `SERVICE_UNAVAILABLE` mit
  der Meldung "Fuer diesen Bericht ist keine Sicherheitspruefung eingerichtet."
  wurde zu "Der Dienst ist derzeit nicht vollstaendig verfuegbar" — der
  Betreiber haette den Fehler bei seiner Anlage gesucht statt bei der
  Einrichtung dieses einen Berichts. Jetzt hat die Servermeldung Vorrang; die
  Tabelle springt nur ein, wenn keine mitkommt.
- **Einem Fehler nach einer Handlung fehlte `role="alert"`.** Ein Screenreader
  haette ihn nicht angesagt. `Callout` nimmt jetzt eine Rolle entgegen; Standard
  bleibt `note`, weil die meisten Hinweise schon beim Oeffnen dastehen.
- **Zwei Statusbeschriftungen wichen von den etablierten ab** (`Gesund` statt
  `Fehlerfrei`, `Nicht verbunden` statt `Nicht erreichbar`). Die etablierten
  gewinnen — sie stehen in Tests fest und sind treffender.

Dazu zwei kleinere Korrekturen: Die Gesundheitsanzeige hing kurzzeitig in der
Uebersicht und verband damit zwei Ladewege, die nichts miteinander zu tun haben;
sie steht jetzt wieder daneben. Und ein frueherer Regex hatte
`(row) => void | undefined` erzeugt — gemeint war eine optionale Eigenschaft,
geschrieben stand "gibt void oder undefined zurueck".

Nachgewiesen gegen Debian 12 mit nginx: alle 18 Seiten liefern 200, das
Design-System steckt im ausgelieferten CSS samt Dark-Mode-Regeln, und vom alten
Stylesheet ist kein Klassenname mehr darin.

76 Tests gruen, tsc sauber, eslint ohne Warnung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 13:50:15 +02:00

246 lines
8.1 KiB
TypeScript

/**
* Wurzelkomponente der Syncova-Oberflaeche.
*
* 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/<id>`). Das reicht fuer diese Konsole und kostet rund fuenfzig Zeilen —
* eine Router-Bibliothek waere hier mehr Abhaengigkeit als Nutzen.
*/
import { 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 { LoginPage } from './features/auth/LoginPage';
import { AlertsPage } from './features/alerts/AlertsPage';
import { DashboardPage } from './features/dashboard/DashboardPage';
import { SystemHealthPanel } from './features/health/SystemHealthPanel';
import { RecoveryPointsPage } from './features/dashboard/RecoveryPointsPage';
import { RolesPage } from './features/administration/RolesPage';
import { SettingsPage } from './features/administration/SettingsPage';
import { UsersPage } from './features/administration/UsersPage';
import { EventsPage } from './features/administration/EventsPage';
import { AgentsPage } from './features/infrastructure/AgentsPage';
import { ProxmoxPage } from './features/infrastructure/ProxmoxPage';
import { ProtectedSystemsPage } from './features/infrastructure/ProtectedSystemsPage';
import { RepositoriesPage } from './features/repositories/RepositoriesPage';
import { RetentionPage } from './features/repositories/RetentionPage';
import { BackupDetailPage } from './features/backups/BackupDetailPage';
import { VerificationPage } from './features/backups/VerificationPage';
import { RestoresPage } from './features/restores/RestoresPage';
import { JobDetailPage } from './features/jobs/JobDetailPage';
import { JobsPage } from './features/jobs/JobsPage';
import { MetricsPage } from './features/metrics/MetricsPage';
import { ReportsPage } from './features/reports/ReportsPage';
import { SecurityPage } from './features/security/SecurityPage';
import { UnavailablePage } from './navigation/UnavailablePage';
import { DEFAULT_PAGE_ID, findPage, mayViewPage } from './navigation/pages';
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.
setAccessTokenProvider(getAccessToken);
export function App(): React.JSX.Element {
const [authenticatedUser, setAuthenticatedUser] = useState<CurrentUser | null>(null);
const { currentPageId, navigateToPage } = useCurrentPage();
// Das gerade geoeffnete Objekt innerhalb einer Seite, etwa ein Auftrag.
const [openObjectIdentifier, setOpenObjectIdentifier] = useState<string | null>(null);
if (authenticatedUser === null) {
return (
<ToastProvider>
<LoginPage onAuthenticated={setAuthenticatedUser} />
</ToastProvider>
);
}
async function handleLogout(): Promise<void> {
await logout();
setAuthenticatedUser(null);
}
const grantedPermissions = authenticatedUser.permissions ?? [];
/** Wechselt die Seite und schliesst ein offenes Objekt. */
function handleNavigate(pageIdentifier: string): void {
setOpenObjectIdentifier(null);
navigateToPage(pageIdentifier);
}
return (
<ToastProvider>
<AppShell
currentPageId={currentPageId}
onNavigate={handleNavigate}
grantedPermissions={grantedPermissions}
userName={authenticatedUser.username}
onLogout={() => void handleLogout()}
>
<CurrentPageContent
currentPageId={currentPageId}
grantedPermissions={grantedPermissions}
currentUser={authenticatedUser}
openObjectIdentifier={openObjectIdentifier}
onOpenObject={setOpenObjectIdentifier}
/>
</AppShell>
</ToastProvider>
);
}
/** Zeigt den Inhalt der gewaehlten Seite. */
function CurrentPageContent({
currentPageId,
grantedPermissions,
currentUser,
openObjectIdentifier,
onOpenObject,
}: {
readonly currentPageId: string;
readonly grantedPermissions: readonly string[];
readonly currentUser: CurrentUser;
readonly openObjectIdentifier: string | null;
readonly onOpenObject: (objectIdentifier: string | null) => void;
}): React.JSX.Element {
const pageDefinition = findPage(currentPageId) ?? findPage(DEFAULT_PAGE_ID);
if (pageDefinition === undefined) {
return <DashboardPage />;
}
// Die Anzeigepruefung ersetzt die serverseitige nicht, sie ergaenzt sie: Ohne
// sie liefe der Aufruf in eine Fehlermeldung statt in eine verstaendliche
// Auskunft (PROMPT.md §42).
if (!mayViewPage(pageDefinition, grantedPermissions)) {
return (
<UnavailablePage
title={pageDefinition.label}
reason="Ihrer Rolle fehlt die Berechtigung fuer diesen Bereich. Die Pruefung erfolgt auf dem Server; diese Anzeige verhindert nur den Umweg ueber eine Fehlermeldung."
/>
);
}
if (!pageDefinition.available) {
return (
<UnavailablePage
title={pageDefinition.label}
reason={pageDefinition.unavailableReason ?? 'Dieser Bereich ist noch nicht verfuegbar.'}
/>
);
}
switch (pageDefinition.id) {
case 'dashboard':
return (
<>
<DashboardPage />
<div className="mt-4">
<SystemHealthPanel />
</div>
</>
);
case 'jobs':
return openObjectIdentifier ? (
<JobDetailPage
jobIdentifier={openObjectIdentifier}
grantedPermissions={grantedPermissions}
onBack={() => onOpenObject(null)}
/>
) : (
<JobsPage grantedPermissions={grantedPermissions} onOpenJob={onOpenObject} />
);
case 'recovery-points':
return openObjectIdentifier ? (
<BackupDetailPage
backupIdentifier={openObjectIdentifier}
grantedPermissions={grantedPermissions}
onBack={() => onOpenObject(null)}
onDeleted={() => onOpenObject(null)}
/>
) : (
<RecoveryPointsPage onOpenBackup={onOpenObject} />
);
case 'restores':
return <RestoresPage grantedPermissions={grantedPermissions} />;
case 'verification':
return openObjectIdentifier ? (
<BackupDetailPage
backupIdentifier={openObjectIdentifier}
grantedPermissions={grantedPermissions}
onBack={() => onOpenObject(null)}
onDeleted={() => onOpenObject(null)}
/>
) : (
<VerificationPage
grantedPermissions={grantedPermissions}
onOpenBackup={onOpenObject}
/>
);
case 'repositories':
return <RepositoriesPage grantedPermissions={grantedPermissions} />;
case 'retention':
return <RetentionPage grantedPermissions={grantedPermissions} />;
case 'agents':
return <AgentsPage grantedPermissions={grantedPermissions} />;
case 'proxmox':
return <ProxmoxPage grantedPermissions={grantedPermissions} />;
case 'protected-systems':
return <ProtectedSystemsPage />;
case 'events':
return <EventsPage />;
case 'alerts':
return <AlertsPage />;
case 'metrics':
return <MetricsPage />;
case 'reports':
return <ReportsPage />;
case 'security':
return <SecurityPage />;
case 'users':
return <UsersPage grantedPermissions={grantedPermissions} />;
case 'roles':
return <RolesPage grantedPermissions={grantedPermissions} />;
case 'settings':
return (
<SettingsPage
grantedPermissions={grantedPermissions}
currentUser={currentUser}
/>
);
default:
// Eine Seite steht im Verzeichnis, hat aber noch keine Umsetzung. Das
// ist kein Fehler, sondern der Ausbaustand — und wird als solcher
// benannt, statt eine leere Maske zu zeigen.
return (
<UnavailablePage
title={pageDefinition.label}
reason="Dieser Bereich wird gerade gebaut. Die zugehoerigen API-Endpunkte sind vorhanden; es fehlt die Bedienoberflaeche."
/>
);
}
}