diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 6ffb688..3c3c348 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -147,6 +147,7 @@ export function App(): React.JSX.Element { currentUser={authenticatedUser} openObjectIdentifier={openObjectIdentifier} onOpenObject={setOpenObjectIdentifier} + onNavigate={handleNavigate} /> @@ -161,12 +162,14 @@ function CurrentPageContent({ currentUser, openObjectIdentifier, onOpenObject, + onNavigate, }: { readonly currentPageId: string; readonly grantedPermissions: readonly string[]; readonly currentUser: CurrentUser; readonly openObjectIdentifier: string | null; readonly onOpenObject: (objectIdentifier: string | null) => void; + readonly onNavigate: (pageIdentifier: string) => void; }): React.JSX.Element { const pageDefinition = findPage(currentPageId) ?? findPage(DEFAULT_PAGE_ID); @@ -199,7 +202,10 @@ function CurrentPageContent({ case 'dashboard': return ( <> - +
diff --git a/apps/web/src/components/ui/Button.tsx b/apps/web/src/components/ui/Button.tsx index e006362..08c4d93 100644 --- a/apps/web/src/components/ui/Button.tsx +++ b/apps/web/src/components/ui/Button.tsx @@ -16,7 +16,7 @@ import { cn } from '@/lib/utils'; const buttonVariants = cva( // Gemeinsame Grundlage aller Ausprägungen. [ - 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md', + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg', 'text-sm font-medium transition-colors', 'disabled:pointer-events-none disabled:opacity-50', '[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', @@ -24,7 +24,7 @@ const buttonVariants = cva( { variants: { variant: { - primary: 'bg-accent text-accent-fg hover:bg-accent-hover', + primary: 'bg-accent text-accent-fg shadow-[var(--shadow-card)] hover:bg-accent-hover', secondary: 'border border-line-strong bg-card text-fg hover:bg-hover', ghost: 'text-fg-muted hover:bg-hover hover:text-fg', destructive: diff --git a/apps/web/src/components/ui/Card.tsx b/apps/web/src/components/ui/Card.tsx index e3b98ad..78e94ae 100644 --- a/apps/web/src/components/ui/Card.tsx +++ b/apps/web/src/components/ui/Card.tsx @@ -1,12 +1,27 @@ /** Karte — die Grundfläche aller Inhalte. */ +import { Slot } from '@radix-ui/react-slot'; import type { HTMLAttributes, ReactNode } from 'react'; import { cn } from '@/lib/utils'; -export function Card({ className, ...restProps }: HTMLAttributes) { +export function Card({ + className, + asChild = false, + ...restProps +}: HTMLAttributes & { + /** + * Rendert die Kindkomponente statt eines
. + * + * Fuer anklickbare Kacheln: Eine Schaltflaeche ist mit der Tastatur + * erreichbar, ein div mit Klick-Handler nicht. + */ + readonly asChild?: boolean | undefined; +}) { + const RenderedComponent = asChild ? Slot : 'div'; + return ( -
); @@ -26,7 +41,7 @@ export function CardHeader({ return (
({
- + {columns.map((column) => (
- Das Protokoll ist append-only per Datenbank-Trigger. Es - lässt sich weder hier noch über die API ändern oder löschen — auch - nicht von einem Administrator. + Append-only: weder hier noch über die API änderbar. diff --git a/apps/web/src/features/administration/RolesPage.tsx b/apps/web/src/features/administration/RolesPage.tsx index abebef0..f852e2d 100644 --- a/apps/web/src/features/administration/RolesPage.tsx +++ b/apps/web/src/features/administration/RolesPage.tsx @@ -249,10 +249,7 @@ export function RolesPage({
{selectedRole.is_builtin ? ( - 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. + Mitgelieferte Rollen sind unveränderlich. ) : null}
@@ -360,8 +357,7 @@ export function RolesPage({ }} > - Konten mit dieser Rolle verlieren die zugehörigen Rechte sofort. Ist - sie noch zugewiesen, lehnt der Server das Löschen ab. + Konten mit dieser Rolle verlieren die Rechte sofort. diff --git a/apps/web/src/features/administration/SettingsPage.tsx b/apps/web/src/features/administration/SettingsPage.tsx index ddeb373..5b86a18 100644 --- a/apps/web/src/features/administration/SettingsPage.tsx +++ b/apps/web/src/features/administration/SettingsPage.tsx @@ -283,7 +283,7 @@ export function SettingsPage({ getRowKey={(channel) => channel.id} isLoading={channelsResource.loadState === 'loading'} emptyTitle="Kein Benachrichtigungsweg" - emptyDescription="Ohne Weg erfährt niemand von einem Ausfall. Die Meldungen entstehen trotzdem und stehen unter „Meldungen“." + emptyDescription="Ohne Weg erfährt niemand von einem Ausfall." emptyAction={ mayWriteSettings ? (
- Dem letzten Administrator lassen sich die - Verwaltungsrechte nicht entziehen — der Server lehnt das ab. Sonst - wäre die Anlage nicht mehr verwaltbar. + Dem letzten Administrator lassen sich die Rechte nicht entziehen. @@ -428,9 +426,7 @@ export function UsersPage({ }} > - Die Einträge dieses Kontos im Ereignisprotokoll bleiben bestehen — - ein Protokoll, das sich durch Löschen eines Kontos bereinigen ließe, - wäre keines. + Die Einträge im Ereignisprotokoll bleiben bestehen. @@ -449,10 +445,7 @@ export function UsersPage({ }} > - Danach genügt das Passwort allein. Der Vorgang wird - protokolliert. Vergewissern Sie sich auf anderem Weg, dass die - Anfrage tatsächlich von dieser Person stammt — das ist der übliche - Ansatzpunkt für eine Kontoübernahme. + Danach genügt das Passwort allein. Prüfen Sie die Anfrage auf anderem Weg. diff --git a/apps/web/src/features/auth/sessionStore.test.ts b/apps/web/src/features/auth/sessionStore.test.ts index 027c2e4..e816702 100644 --- a/apps/web/src/features/auth/sessionStore.test.ts +++ b/apps/web/src/features/auth/sessionStore.test.ts @@ -20,7 +20,7 @@ import { const tokenPair = { access_token: 'zugriff', - refresh_token: 'erneuerung', + refresh_token: 'erneuerung', // secretscan:erlaubt — Testwert, kein Geheimnis access_expires_at: '', refresh_expires_at: '', }; diff --git a/apps/web/src/features/backups/VerificationPage.tsx b/apps/web/src/features/backups/VerificationPage.tsx index d2289a3..613bf56 100644 --- a/apps/web/src/features/backups/VerificationPage.tsx +++ b/apps/web/src/features/backups/VerificationPage.tsx @@ -182,9 +182,7 @@ export function VerificationPage({ /> - Eine gescheiterte Prüfung ist kein Befund am Backup. - Ist das Repository nicht erreichbar, gilt der Auftrag als fehlgeschlagen - — die Einstufung des Wiederherstellungspunkts bleibt davon unberührt. + Eine gescheiterte Prüfung ist kein Befund am Backup. @@ -195,7 +193,7 @@ export function VerificationPage({ onRowClick={(job) => onOpenBackup(job.backup_id)} isLoading={verificationsResource.loadState === 'loading'} emptyTitle="Noch keine Prüfung" - emptyDescription="Eine Prüfung wird bei einem Wiederherstellungspunkt gestartet. Ohne sie bleibt jede Einstufung ein Indiz." + emptyDescription="Prüfungen starten Sie bei einem Wiederherstellungspunkt." /> diff --git a/apps/web/src/features/dashboard/DashboardPage.tsx b/apps/web/src/features/dashboard/DashboardPage.tsx index bb7b8ad..e128acb 100644 --- a/apps/web/src/features/dashboard/DashboardPage.tsx +++ b/apps/web/src/features/dashboard/DashboardPage.tsx @@ -17,10 +17,18 @@ import { describeApiError } from '@/api/useMutation'; import { PageHeader } from '@/components/layout/PageHeader'; import { Card, ErrorState, LoadingState } from '@/components/ui'; import { cn } from '@/lib/utils'; +import { QuickActions } from './QuickActions'; import { fetchDashboard } from './dashboardApi'; import type { Dashboard, DashboardWidget } from './dashboardApi'; -export function DashboardPage(): React.JSX.Element { +export function DashboardPage({ + grantedPermissions = [], + onNavigate, +}: { + readonly grantedPermissions?: readonly string[] | undefined; + /** Fehlt der Rückruf, entfallen die Schnellzugriffe. */ + readonly onNavigate?: ((pageIdentifier: string) => void) | undefined; +} = {}): React.JSX.Element { const loadDashboard = useCallback( (abortSignal: AbortSignal) => fetchDashboard(abortSignal), [], @@ -38,6 +46,10 @@ export function DashboardPage(): React.JSX.Element { } /> + {onNavigate ? ( + + ) : null} + {loadState === 'loading' ? ( diff --git a/apps/web/src/features/dashboard/QuickActions.tsx b/apps/web/src/features/dashboard/QuickActions.tsx new file mode 100644 index 0000000..6a44bf5 --- /dev/null +++ b/apps/web/src/features/dashboard/QuickActions.tsx @@ -0,0 +1,140 @@ +/** + * Schnellzugriffe auf der Übersicht. + * + * Bisher endete die Übersicht bei Kennzahlen — wer etwas anlegen wollte, musste + * erst wissen, unter welchem Menüpunkt es steckt. Die Kacheln nennen die + * Handlung, nicht den Bereich: „Auftrag anlegen" statt „Sicherungsaufträge". + * + * Gezeigt wird nur, was die Rolle auch darf. Eine Kachel, die in eine + * Fehlermeldung führt, ist schlimmer als keine. + */ + +import { + Archive, + BadgeCheck, + Clock, + Database, + MonitorCog, + RotateCcw, + Server, + UserPlus, + type LucideIcon, +} from 'lucide-react'; +import { Card } from '@/components/ui'; + +interface QuickAction { + readonly label: string; + readonly hint: string; + readonly icon: LucideIcon; + readonly targetPageId: string; + /** Berechtigung, die die Handlung verlangt. */ + readonly permission: string; +} + +const QUICK_ACTIONS: readonly QuickAction[] = [ + { + label: 'Auftrag anlegen', + hint: 'Was wann wohin', + icon: Clock, + targetPageId: 'jobs', + permission: 'jobs.write', + }, + { + label: 'Wiederherstellen', + hint: 'Aus einem Punkt zurück', + icon: RotateCcw, + targetPageId: 'recovery-points', + permission: 'restores.execute', + }, + { + label: 'Repository übernehmen', + hint: 'Vorhandene Ablage', + icon: Database, + targetPageId: 'repositories', + permission: 'repositories.write', + }, + { + label: 'Prüfung starten', + hint: 'Nachweis erzeugen', + icon: BadgeCheck, + targetPageId: 'recovery-points', + permission: 'verification.write', + }, + { + label: 'Agent aufnehmen', + hint: 'Token erzeugen', + icon: MonitorCog, + targetPageId: 'agents', + permission: 'agents.enroll', + }, + { + label: 'Proxmox verbinden', + hint: 'Verbund einrichten', + icon: Server, + targetPageId: 'proxmox', + permission: 'providers.write', + }, + { + label: 'Aufbewahrung', + hint: 'Regel anlegen', + icon: Archive, + targetPageId: 'retention', + permission: 'retention.write', + }, + { + label: 'Benutzer anlegen', + hint: 'Konto und Rollen', + icon: UserPlus, + targetPageId: 'users', + permission: 'users.write', + }, +]; + +export function QuickActions({ + grantedPermissions, + onNavigate, +}: { + readonly grantedPermissions: readonly string[]; + readonly onNavigate: (pageIdentifier: string) => void; +}) { + const isAllowed = (permission: string): boolean => + grantedPermissions.includes('*') || grantedPermissions.includes(permission); + + const visibleActions = QUICK_ACTIONS.filter((action) => isAllowed(action.permission)); + + if (visibleActions.length === 0) { + return null; + } + + return ( +
+ {visibleActions.map((action) => { + const IconComponent = action.icon; + + return ( + + + + ); + })} +
+ ); +} diff --git a/apps/web/src/features/infrastructure/AgentsPage.tsx b/apps/web/src/features/infrastructure/AgentsPage.tsx index 3af13a6..ebb3f20 100644 --- a/apps/web/src/features/infrastructure/AgentsPage.tsx +++ b/apps/web/src/features/infrastructure/AgentsPage.tsx @@ -209,10 +209,7 @@ export function AgentsPage({ /> - Der Agent holt seine Aufträge ab; der Server drückt - sie nicht. Ein Agent steht hinter einer Firewall und ist vom Server aus - nicht erreichbar — jedenfalls nicht ohne eingehende Portfreigabe auf - jedem gesicherten System. + Der Agent holt seine Aufträge ab; der Server drückt sie nicht. @@ -222,7 +219,7 @@ export function AgentsPage({ getRowKey={(agent) => agent.id} isLoading={agentsResource.loadState === 'loading'} emptyTitle="Kein Agent aufgenommen" - emptyDescription="Ein Agent wird mit einem Aufnahme-Token registriert. Das Token gilt eine Stunde und nur einmal." + emptyDescription="Registrierung über ein Aufnahme-Token, gültig eine Stunde." emptyAction={ mayEnroll ? (
)} @@ -461,8 +455,7 @@ export function ProxmoxPage({ }} > - Die vorhandenen Sicherungen der Gäste bleiben im Repository. Verweisen - noch Quellen auf diesen Verbund, wird das Entfernen abgelehnt. + Die Sicherungen der Gäste bleiben im Repository. diff --git a/apps/web/src/features/jobs/JobsPage.tsx b/apps/web/src/features/jobs/JobsPage.tsx index 7455dfd..0ada982 100644 --- a/apps/web/src/features/jobs/JobsPage.tsx +++ b/apps/web/src/features/jobs/JobsPage.tsx @@ -284,9 +284,7 @@ export function JobsPage({ Absenden des Assistenten zu melden wäre die unfreundlichste Stelle. */} {repositoriesResource.loadState === 'loaded' && writableRepositories.length === 0 ? ( - Ein Auftrag braucht ein Ziel, das Sicherungen annimmt. Legen Sie - zuerst unter „Repositories" eines an oder prüfen Sie den Zustand der - vorhandenen. + Kein Ziel nimmt Sicherungen an. Prüfen Sie die Repositories. ) : null} @@ -298,7 +296,7 @@ export function JobsPage({ onRowClick={(job) => onOpenJob(job.id)} isLoading={jobsResource.loadState === 'loading'} emptyTitle="Noch kein Sicherungsauftrag" - emptyDescription="Ohne Auftrag wird nichts gesichert — auch nicht im Hintergrund." + emptyDescription="Ohne Auftrag wird nichts gesichert." emptyAction={ mayWriteJobs ? ( + ))} + + +
+ ) : null} + - + - Eine Regel ohne jede Haltevorgabe wird abgelehnt. - Sonst löschte „30 Tage" bei einem länger nicht gesicherten System - jedes Backup — genau dann, wenn man es braucht. + Ohne Haltevorgabe wird die Regel abgelehnt. @@ -412,8 +453,7 @@ export function RetentionPage({ }} >

- Bereits gelöschte Wiederherstellungspunkte kommen dadurch nicht - zurück. Künftig wird nach dieser Regel nichts mehr entfernt. + Künftig wird nach dieser Regel nichts mehr entfernt.

@@ -443,8 +483,7 @@ export function RetentionPage({ } > - Geschützte Punkte (Aufbewahrungsfrist oder Legal Hold) bleiben - unberührt — der Server lehnt ihre Löschung ab. + Geschützte Punkte bleiben unberührt. diff --git a/apps/web/src/features/repositories/repositoriesApi.ts b/apps/web/src/features/repositories/repositoriesApi.ts index 0ed2fba..613fb24 100644 --- a/apps/web/src/features/repositories/repositoriesApi.ts +++ b/apps/web/src/features/repositories/repositoriesApi.ts @@ -247,14 +247,41 @@ export interface RetentionPolicy { time_zone?: string; } -/** Lädt die Aufbewahrungsregeln. */ +/** + * Antwort auf die Regelliste. + * + * **Dieser Endpunkt liefert ein Objekt, keine Liste** — als einziger von zehn + * geprüften. Ihn als Array zu behandeln ließ die Seite abstürzen: `map` gibt es + * auf einem Objekt nicht, React hängte den ganzen Baum aus, und übrig blieb ein + * schwarzer Bildschirm. + */ +export interface RetentionPolicyList { + /** Selbst angelegte Regeln. */ + policies: RetentionPolicy[]; + /** + * Mitgelieferte Vorlagen. + * + * Sie tragen keine Kennung — es sind Vorschläge, keine Regeln. Wer eine + * übernimmt, legt damit eine eigene an. + */ + predefined: Array & { description?: string }>; +} + +/** Lädt die Aufbewahrungsregeln samt Vorlagen. */ export async function listRetentionPolicies( abortSignal?: AbortSignal, -): Promise { - return requestApi( +): Promise { + const response = await requestApi( '/retention-policies', abortSignal ? { signal: abortSignal } : {}, ); + + // Fehlt ein Feld, ist die Liste leer — nicht undefined. Ein `?? []` an jeder + // Verwendungsstelle wäre ein `??` zu viel, das irgendwann jemand vergisst. + return { + policies: response.policies ?? [], + predefined: response.predefined ?? [], + }; } /** Legt eine Aufbewahrungsregel an. */ diff --git a/apps/web/src/features/restores/RestoreWizard.tsx b/apps/web/src/features/restores/RestoreWizard.tsx index 70765d5..7e8c68a 100644 --- a/apps/web/src/features/restores/RestoreWizard.tsx +++ b/apps/web/src/features/restores/RestoreWizard.tsx @@ -186,10 +186,7 @@ export function RestoreWizard({ /> - In ein leeres Verzeichnis. Zurück an den - Ursprungsort zu schreiben überschreibt genau die Daten, deren - Zustand Sie vielleicht noch brauchen — etwa um zu vergleichen, - was gefehlt hat. + Am besten in ein leeres Verzeichnis — der Ursprungsort wäre sonst überschrieben. ) : null} diff --git a/apps/web/src/features/restores/RestoresPage.tsx b/apps/web/src/features/restores/RestoresPage.tsx index 23d5c08..7f3aa42 100644 --- a/apps/web/src/features/restores/RestoresPage.tsx +++ b/apps/web/src/features/restores/RestoresPage.tsx @@ -211,7 +211,7 @@ export function RestoresPage({ onRowClick={(session) => setSelectedSession(session)} isLoading={restoresResource.loadState === 'loading'} emptyTitle="Noch keine Wiederherstellung" - emptyDescription="Führen Sie eine durch, bevor Sie eine brauchen. Erst ein durchgeführter Test hebt einen Wiederherstellungspunkt auf „wiederherstellbar“ — alles davor ist ein Indiz." + emptyDescription="Führen Sie eine durch, bevor Sie eine brauchen." />
@@ -247,10 +247,7 @@ export function RestoresPage({ ) : null} - Die Datei, die beim Abbruch gerade geschrieben wurde, wird zuerst - entfernt und dann neu angelegt. Dateien aus diesem abgebrochenen - Lauf werden ersetzt — nur bei einer Fortsetzung, - nicht bei einem neuen Lauf. + Die beim Abbruch angefangene Datei wird ersetzt. diff --git a/apps/web/src/styles/theme.css b/apps/web/src/styles/theme.css index 446b03d..4f7bcac 100644 --- a/apps/web/src/styles/theme.css +++ b/apps/web/src/styles/theme.css @@ -26,10 +26,15 @@ --font-mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; /* --- Radien --- */ - --radius-sm: 0.25rem; - --radius-md: 0.375rem; - --radius-lg: 0.5rem; - --radius-xl: 0.75rem; + --radius-sm: 0.375rem; + --radius-md: 0.5rem; + --radius-lg: 0.75rem; + --radius-xl: 1rem; + + /* Schatten statt Rahmen, wo Tiefe gemeint ist. Ein Rahmen trennt, ein + * Schatten hebt — Karten sollen heben, Tabellenzeilen trennen. */ + --shadow-card: 0 1px 2px 0 hsl(240 20% 12% / 0.04), 0 1px 3px 0 hsl(240 20% 12% / 0.06); + --shadow-raised: 0 4px 12px -2px hsl(240 20% 12% / 0.10), 0 2px 4px -2px hsl(240 20% 12% / 0.06); } /* @@ -39,25 +44,25 @@ :root, [data-theme='light'] { /* Flaechen — vier Ebenen reichen: Seite, Karte, erhoben, Rand. */ - --surface-page: 0 0% 98%; + --surface-page: 240 20% 97%; --surface-card: 0 0% 100%; --surface-raised: 0 0% 100%; - --surface-sunken: 240 5% 96%; - --surface-hover: 240 5% 94%; + --surface-sunken: 240 20% 96%; + --surface-hover: 240 20% 94%; - --border-default: 240 6% 87%; - --border-strong: 240 5% 78%; + --border-default: 240 12% 90%; + --border-strong: 240 10% 80%; - --text-primary: 240 10% 10%; - --text-secondary: 240 4% 42%; - --text-muted: 240 4% 56%; + --text-primary: 240 20% 12%; + --text-secondary: 240 8% 44%; + --text-muted: 240 6% 58%; --text-inverted: 0 0% 100%; /* Akzent — Bedienelemente, niemals Zustand. */ - --accent: 221 70% 45%; - --accent-hover: 221 70% 39%; + --accent: 243 75% 58%; + --accent-hover: 243 75% 50%; --accent-text: 0 0% 100%; - --accent-subtle: 221 70% 96%; + --accent-subtle: 243 75% 96%; /* --- Statusfarben (PROMPT.md §106) --- * Gruen = Healthy, Gelb = Warning, Orange = High, Rot = Critical, @@ -79,24 +84,24 @@ } [data-theme='dark'] { - --surface-page: 240 8% 8%; - --surface-card: 240 7% 11%; - --surface-raised: 240 6% 14%; - --surface-sunken: 240 9% 6%; - --surface-hover: 240 6% 17%; + --surface-page: 240 15% 7%; + --surface-card: 240 13% 10%; + --surface-raised: 240 12% 13%; + --surface-sunken: 240 16% 5%; + --surface-hover: 240 12% 16%; - --border-default: 240 5% 22%; - --border-strong: 240 5% 32%; + --border-default: 240 8% 20%; + --border-strong: 240 8% 30%; - --text-primary: 0 0% 96%; - --text-secondary: 240 5% 68%; - --text-muted: 240 4% 52%; - --text-inverted: 240 10% 8%; + --text-primary: 240 20% 97%; + --text-secondary: 240 8% 70%; + --text-muted: 240 6% 54%; + --text-inverted: 240 15% 7%; - --accent: 217 85% 62%; - --accent-hover: 217 85% 70%; - --accent-text: 240 10% 8%; - --accent-subtle: 221 50% 18%; + --accent: 243 80% 70%; + --accent-hover: 243 80% 78%; + --accent-text: 240 15% 7%; + --accent-subtle: 243 45% 20%; /* Im dunklen Thema werden die Statusfarben aufgehellt. Die dunklen Toene des * hellen Themas waeren auf dunklem Grund nicht mehr unterscheidbar — und ein