From 330f60115accdbc186dc84a3fdee4e2bdc030035 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 19:55:47 +0200 Subject: [PATCH] refactor(admin): the console speaks the Settings vocabulary without an accent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twenty-six of twenty-eight admin files already used the Settings card and header. What made the console read as its own system was everything on top of that: three hand-rolled cards, a header slot that meant five different things, and a heading the frame took but never painted. - `SectionFrame` drops `title` and `subtitle`. Seventeen call sites passed both into a component that has rendered neither since v1.18.6.1 — the shell reads the same keys itself. A prop contract nothing honours is a promise the next reader believes. - The invite card, the four host-metric headers, and the `/admin` welcome card stop hand-rolling `bg-card border rounded-xl` shells with their own padding steps and their own heading weights. `text-sm` as a card title on the first card of a page, directly above a correct one, was the first thing the eye landed on. - The header status slot carries status. The backups run, the invite create, the danger-zone wipe, and the user filters come out of it; the app-log refresh stays but reads as the secondary it is. - Loading states paint their card and header. Encryption returned a bare spinner row and then a card, so the section jumped when the status landed — the fix `coach-feedback-section.tsx` already carried, applied where it was missing. Same for the notification-status card. - One label/value tile. `StatusItem`, the encryption section's private `Stat`, the user-management inline panels and the backups upload box were four fills and three paddings for one role. - The overview stopped printing the running version, build SHA and build time twice, thirty pixels apart. The version tile owns build identity — it is the one that checks for a newer release; the snapshot owns what is running. - `ai-server-key` had three paragraphs stacked in a muted `text-xs` slot; one sentence stays there and the two that carry information move into the body as foreground. The central-codex consent copy moves the same way, and its disconnect is `variant="destructive"` — which `ConfirmButton` refused to accept, so every destructive confirm in the tree had been hand-tinting an outline instead. - Leftovers: the `-mt-2` correcting the card's own rhythm, two alpha hovers on text, `text-[10px]` badges, the redundant `space-y-6` wrapper, the empty state repeating the header's CTA, and the `mt-6` a card added inside a frame that already spaces its children. --- messages/de.json | 6 - messages/en.json | 6 - messages/es.json | 6 - messages/fr.json | 6 - messages/it.json | 6 - messages/pl.json | 6 - src/app/admin/[section]/renderer.tsx | 102 +++---------- src/app/admin/page.tsx | 16 -- .../admin/__tests__/status-card-grid.test.tsx | 13 +- src/components/admin/_shared.tsx | 22 ++- .../admin/ai-server-key-section.tsx | 45 +++--- .../admin/app-log-preview-section.tsx | 3 +- src/components/admin/backups-section.tsx | 32 ++-- .../admin/central-codex-section.tsx | 69 +++++---- src/components/admin/danger-zone-section.tsx | 143 +++++++++--------- src/components/admin/encryption-section.tsx | 122 ++++++++------- .../admin/general-settings-section.tsx | 2 +- src/components/admin/host-metrics-chart.tsx | 70 +++++---- .../admin/integrations-group-section.tsx | 8 +- .../admin/invite-tokens-section.tsx | 87 ++++------- src/components/admin/recent-audit-preview.tsx | 2 +- src/components/admin/reminders-section.tsx | 116 +++++++------- .../admin/system-status-summary.tsx | 30 +--- .../admin/user-management-section.tsx | 112 +++++++------- src/components/admin/version-tile-section.tsx | 2 +- .../settings/notification-status-card.tsx | 7 + src/components/ui/confirm-button.tsx | 2 +- 27 files changed, 472 insertions(+), 569 deletions(-) diff --git a/messages/de.json b/messages/de.json index 59742c675..f43f491d6 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6361,7 +6361,6 @@ "invites": { "title": "Einladungen", "description": "Einladungslinks für die Registrierung — funktionieren auch bei geschlossener Registrierung. Der Link wird nur einmal angezeigt.", - "adminOnlyHint": "Nur Admins können Einladungen erstellen und widerrufen.", "create": "Einladung erstellen", "createTitle": "Einladung erstellen", "createDescription": "Der Link erlaubt eine Registrierung auch bei geschlossener Registrierung.", @@ -6516,15 +6515,10 @@ "sectionsNav": "Admin-Bereiche" }, "overview": { - "welcomeTitle": "Willkommen, {name}", - "welcomeSubtitle": "Du bist als Administrator angemeldet. Wähle einen Bereich, um HealthLog zu verwalten.", "snapshotTitle": "System-Snapshot", - "snapshotVersion": "Version", "snapshotDatabase": "Datenbank", "snapshotWorker": "Worker", "snapshotStarted": "Gestartet", - "snapshotBuildSha": "Build-SHA", - "snapshotBuiltAt": "Gebaut am", "snapshotOfflineGeo": "Geodaten", "snapshotOfflineGeoOn": "Offline (GeoLite2)", "snapshotOfflineGeoOff": "Online ({host})", diff --git a/messages/en.json b/messages/en.json index 49e5a9425..acdce065e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6361,7 +6361,6 @@ "invites": { "title": "Invites", "description": "Registration invite links — they work even while registration is closed. The link is shown only once.", - "adminOnlyHint": "Only admins can create and revoke invites.", "create": "Create invite", "createTitle": "Create invite", "createDescription": "The link admits a signup even while registration is closed.", @@ -6516,15 +6515,10 @@ "sectionsNav": "Admin sections" }, "overview": { - "welcomeTitle": "Welcome, {name}", - "welcomeSubtitle": "You are signed in as administrator. Pick a section to manage HealthLog.", "snapshotTitle": "System snapshot", - "snapshotVersion": "Version", "snapshotDatabase": "Database", "snapshotWorker": "Worker", "snapshotStarted": "Started", - "snapshotBuildSha": "Build SHA", - "snapshotBuiltAt": "Built at", "snapshotOfflineGeo": "Geo data", "snapshotOfflineGeoOn": "Offline (GeoLite2)", "snapshotOfflineGeoOff": "Online ({host})", diff --git a/messages/es.json b/messages/es.json index 01568c5c7..3876b6160 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6361,7 +6361,6 @@ "invites": { "title": "Invitaciones", "description": "Enlaces de invitación para el registro: funcionan incluso con el registro cerrado. El enlace se muestra solo una vez.", - "adminOnlyHint": "Solo los administradores pueden crear y revocar invitaciones.", "create": "Crear invitación", "createTitle": "Crear invitación", "createDescription": "El enlace permite registrarse incluso con el registro cerrado.", @@ -6516,15 +6515,10 @@ "sectionsNav": "Secciones de administración" }, "overview": { - "welcomeTitle": "Bienvenido,{name}", - "welcomeSubtitle": "Has iniciado sesión como administrador. Elige una sección para gestionar HealthLog.", "snapshotTitle": "Instantánea del sistema", - "snapshotVersion": "Versión", "snapshotDatabase": "Base de datos", "snapshotWorker": "Worker", "snapshotStarted": "Iniciado", - "snapshotBuildSha": "Build SHA", - "snapshotBuiltAt": "Compilado el", "snapshotOfflineGeo": "Datos geográficos", "snapshotOfflineGeoOn": "Sin conexión (GeoLite2)", "snapshotOfflineGeoOff": "En línea ({host})", diff --git a/messages/fr.json b/messages/fr.json index a1f9c84e2..df8d23b66 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6361,7 +6361,6 @@ "invites": { "title": "Invitations", "description": "Liens d'invitation pour l'inscription — ils fonctionnent même lorsque les inscriptions sont fermées. Le lien n'est affiché qu'une seule fois.", - "adminOnlyHint": "Seuls les administrateurs peuvent créer et révoquer des invitations.", "create": "Créer une invitation", "createTitle": "Créer une invitation", "createDescription": "Le lien permet une inscription même lorsque les inscriptions sont fermées.", @@ -6516,15 +6515,10 @@ "sectionsNav": "Sections d'administration" }, "overview": { - "welcomeTitle": "Bienvenue,{name}", - "welcomeSubtitle": "Vous êtes connecté en tant qu'administrateur. Choisissez une section pour gérer HealthLog.", "snapshotTitle": "Instantané du système", - "snapshotVersion": "Version", "snapshotDatabase": "Base de données", "snapshotWorker": "Worker", "snapshotStarted": "Démarré", - "snapshotBuildSha": "Build SHA", - "snapshotBuiltAt": "Compilé le", "snapshotOfflineGeo": "Données géographiques", "snapshotOfflineGeoOn": "Hors ligne (GeoLite2)", "snapshotOfflineGeoOff": "En ligne ({host})", diff --git a/messages/it.json b/messages/it.json index d19756fef..4c6797efa 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6361,7 +6361,6 @@ "invites": { "title": "Inviti", "description": "Link di invito per la registrazione: funzionano anche quando la registrazione è chiusa. Il link viene mostrato una sola volta.", - "adminOnlyHint": "Solo gli amministratori possono creare e revocare gli inviti.", "create": "Crea invito", "createTitle": "Crea invito", "createDescription": "Il link consente una registrazione anche quando la registrazione è chiusa.", @@ -6516,15 +6515,10 @@ "sectionsNav": "Sezioni di amministrazione" }, "overview": { - "welcomeTitle": "Benvenuto,{name}", - "welcomeSubtitle": "Hai effettuato l'accesso come amministratore. Scegli una sezione per gestire HealthLog.", "snapshotTitle": "Istantanea del sistema", - "snapshotVersion": "Versione", "snapshotDatabase": "Database", "snapshotWorker": "Worker", "snapshotStarted": "Avviato", - "snapshotBuildSha": "Build SHA", - "snapshotBuiltAt": "Compilato il", "snapshotOfflineGeo": "Dati geografici", "snapshotOfflineGeoOn": "Offline (GeoLite2)", "snapshotOfflineGeoOff": "Online ({host})", diff --git a/messages/pl.json b/messages/pl.json index 5edbe577e..93c5d411f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6361,7 +6361,6 @@ "invites": { "title": "Zaproszenia", "description": "Linki z zaproszeniem do rejestracji — działają nawet przy zamkniętej rejestracji. Link jest wyświetlany tylko raz.", - "adminOnlyHint": "Tylko administratorzy mogą tworzyć i unieważniać zaproszenia.", "create": "Utwórz zaproszenie", "createTitle": "Utwórz zaproszenie", "createDescription": "Link umożliwia rejestrację nawet przy zamkniętej rejestracji.", @@ -6516,15 +6515,10 @@ "sectionsNav": "Sekcje administracyjne" }, "overview": { - "welcomeTitle": "Witamy,{name}", - "welcomeSubtitle": "Zalogowano jako administrator. Wybierz sekcję, aby zarządzać HealthLog.", "snapshotTitle": "Migawka systemu", - "snapshotVersion": "Wersja", "snapshotDatabase": "Baza danych", "snapshotWorker": "Worker", "snapshotStarted": "Uruchomiono", - "snapshotBuildSha": "Build SHA", - "snapshotBuiltAt": "Skompilowano", "snapshotOfflineGeo": "Dane geograficzne", "snapshotOfflineGeoOn": "Offline (GeoLite2)", "snapshotOfflineGeoOff": "Online ({host})", diff --git a/src/app/admin/[section]/renderer.tsx b/src/app/admin/[section]/renderer.tsx index 9829379ff..f1af619a4 100644 --- a/src/app/admin/[section]/renderer.tsx +++ b/src/app/admin/[section]/renderer.tsx @@ -15,7 +15,6 @@ import type { JSX } from "react"; import { useAuth } from "@/hooks/use-auth"; -import { useTranslations } from "@/lib/i18n/context"; import { AboutSection } from "@/components/settings/about-section"; import { AiQualitySection } from "@/components/admin/ai-quality-section"; import { AiServerKeySection } from "@/components/admin/ai-server-key-section"; @@ -46,7 +45,6 @@ export function AdminSectionRenderer({ slug, }: RendererProps): JSX.Element | null { const { user } = useAuth(); - const { t } = useTranslations(); // The admin shell layout already gates non-admins via ``, // but the auth hook is async — return nothing on the first render so @@ -57,37 +55,25 @@ export function AdminSectionRenderer({ switch (slug) { case "system-status": return ( - + ); case "general": return ( - + ); case "services": return ( - + ); case "integrations": return ( - + ); @@ -99,10 +85,7 @@ export function AdminSectionRenderer({ // `module-availability` section: it gates EVERY module, not just the // coach, so it no longer belongs stacked under Coach. return ( - + @@ -115,28 +98,19 @@ export function AdminSectionRenderer({ // admin section (was stacked under Coach). Reconciled name: the // user-facing "Module" settings pick from what is available here. return ( - + ); case "reminders": return ( - + ); case "users": return ( - + ); @@ -145,64 +119,43 @@ export function AdminSectionRenderer({ // the full table (status, redemptions, revocation) is a workflow // of its own and was drowning under the account list. return ( - + ); case "api-tokens": return ( - + ); case "login-overview": return ( - + ); case "app-logs": return ( - + ); case "backups": return ( - + ); case "encryption": return ( - + ); case "danger-zone": return ( - + ); @@ -212,10 +165,7 @@ export function AdminSectionRenderer({ // section so it gets the same visible heading + subtitle (the one admin // page that was bypassing the frame and showing no heading). return ( - + ); @@ -225,17 +175,13 @@ export function AdminSectionRenderer({ } } -interface SectionFrameProps { - // v1.18.6.1 — the visible heading + subtitle moved to ``, which - // places them in their own grid row so the left nav lines up with the first - // card. `title` / `subtitle` are still passed by every call site (and remain - // the i18n source of truth the shell reads), but the frame no longer paints - // them — it is now just the `space-y-6` card-stack wrapper. - title?: string; - subtitle?: string; - children: React.ReactNode; -} - -function SectionFrame({ children }: SectionFrameProps) { +/** + * The card-stack wrapper for a section. It took a `title` and a `subtitle` + * from all seventeen call sites and rendered neither — the visible heading + * moved to `` in v1.18.6.1, which reads the same i18n keys + * itself. A prop contract nothing honours is a promise a later reader + * believes, so it is gone. + */ +function SectionFrame({ children }: { children: React.ReactNode }) { return
{children}
; } diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 3cc1eb594..9d956cc88 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -41,22 +41,6 @@ export default function AdminOverviewPage() { )} - {/* Welcome card — greeting with admin context indicator. */} -
-

- {t("admin.overview.welcomeTitle", { name: user.username })} -

-

- {t("admin.overview.welcomeSubtitle")} -

-
- diff --git a/src/components/admin/__tests__/status-card-grid.test.tsx b/src/components/admin/__tests__/status-card-grid.test.tsx index 1a8029bd9..1b7cc0ad3 100644 --- a/src/components/admin/__tests__/status-card-grid.test.tsx +++ b/src/components/admin/__tests__/status-card-grid.test.tsx @@ -6,7 +6,10 @@ import { renderToStaticMarkup } from "react-dom/server"; * `StatusCardGrid` (the section-card grid the maintainer described as redundant * with the sidebar nav). The status-card component itself was removed * from the codebase. This test guards the *new* overview composition: - * a welcome card, the system snapshot, and the recent-audit preview. + * the version tile, the system snapshot, and the recent-audit preview. The + * welcome card went with the grid: it greeted the reader and told them they + * were signed in as an administrator, on a route only an administrator can + * reach. * * The file kept its old name so git history follows it. Nothing else * imports `status-card-grid`. @@ -71,12 +74,10 @@ describe("/admin overview composition", () => { expect(html).not.toContain("Open backups"); }); - it("renders the welcome card with the admin's username", () => { + it("renders no welcome card", () => { const html = render(); - // `welcomeTitle` interpolates the username; the mocked auth hook - // returns "testuser". - expect(html).toContain("Welcome, testuser"); - expect(html).toContain("admin-overview-welcome-heading"); + expect(html).not.toContain("Welcome, testuser"); + expect(html).not.toContain("admin-overview-welcome-heading"); }); it("renders the system snapshot section heading", () => { diff --git a/src/components/admin/_shared.tsx b/src/components/admin/_shared.tsx index faa6ebd04..82b5dbe4a 100644 --- a/src/components/admin/_shared.tsx +++ b/src/components/admin/_shared.tsx @@ -1,6 +1,7 @@ "use client"; import { useMemo } from "react"; +import { StatTile } from "@/components/admin/_stat-tile"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { Cpu, Fingerprint, Globe, KeyRound } from "lucide-react"; @@ -149,8 +150,14 @@ export function ConfiguredBadge() { ); } +/** + * The console's label/value tile. Four hand-rolled variants of this box used + * to exist side by side; `StatTile` is the one, and this is the thin adapter + * the status grids call (the `className` they pass tints the VALUE, which is + * why it cannot just be `StatTile` under a different name). + */ export function StatusItem({ - icon: Icon, + icon, label, value, className, @@ -161,13 +168,12 @@ export function StatusItem({ className?: string; }) { return ( -
-
- - {label} -
-

{value}

-
+ ); } diff --git a/src/components/admin/ai-server-key-section.tsx b/src/components/admin/ai-server-key-section.tsx index 1c0e27a02..9fa09b63e 100644 --- a/src/components/admin/ai-server-key-section.tsx +++ b/src/components/admin/ai-server-key-section.tsx @@ -6,6 +6,7 @@ import { KeyRound, Loader2 } from "lucide-react"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; import { Button } from "@/components/ui/button"; import { ConfirmButton } from "@/components/ui/confirm-button"; import { Input } from "@/components/ui/input"; @@ -98,13 +99,7 @@ export function AiServerKeySection() { -

{t("admin.aiServerKey.description")}

-

{t("admin.aiServerKey.byokHint")}

-

{t("admin.aiServerKey.consentHint")}

- - } + description={t("admin.aiServerKey.description")} status={ {configured @@ -115,6 +110,11 @@ export function AiServerKeySection() { />
+
+

{t("admin.aiServerKey.byokHint")}

+

{t("admin.aiServerKey.consentHint")}

+
+
-
+ + {configured && ( + + )}
+ ); diff --git a/src/components/admin/app-log-preview-section.tsx b/src/components/admin/app-log-preview-section.tsx index ab7835630..3e396cfd6 100644 --- a/src/components/admin/app-log-preview-section.tsx +++ b/src/components/admin/app-log-preview-section.tsx @@ -123,8 +123,9 @@ export function AppLogPreviewSection() { description={t("admin.section.app-logs.processNote")} status={ - } description={

{t("admin.section.backups.description")}{" "} @@ -776,6 +762,22 @@ export function BackupsSection() {

)} + + + + ); } diff --git a/src/components/admin/central-codex-section.tsx b/src/components/admin/central-codex-section.tsx index 0ab2a8738..68b582074 100644 --- a/src/components/admin/central-codex-section.tsx +++ b/src/components/admin/central-codex-section.tsx @@ -21,6 +21,8 @@ import { Loader2, ShieldAlert, Sparkles, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; +import { SettingsInfoTile } from "@/components/settings/_info-tile"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; import { Button } from "@/components/ui/button"; import { ConfirmButton } from "@/components/ui/confirm-button"; import { SettingsCard } from "@/components/settings/settings-card"; @@ -167,10 +169,9 @@ export function CentralCodexSection() { />
-
- -

{t("admin.centralCodex.honesty")}

-
+ +

{t("admin.centralCodex.honesty")}

+
{data?.connectedAt && isConnected && (

@@ -181,25 +182,27 @@ export function CentralCodexSection() { )} {isConnected ? ( - } - label={t("admin.centralCodex.disconnectButton")} - title={t("admin.centralCodex.disconnectTitle")} - body={t("admin.centralCodex.disconnectBody")} - confirmLabel={t("admin.centralCodex.disconnectConfirm")} - pending={disconnecting} - onConfirm={handleDisconnect} - /> + + } + label={t("admin.centralCodex.disconnectButton")} + title={t("admin.centralCodex.disconnectTitle")} + body={t("admin.centralCodex.disconnectBody")} + confirmLabel={t("admin.centralCodex.disconnectConfirm")} + pending={disconnecting} + onConfirm={handleDisconnect} + /> + ) : deviceCode ? (

{t("settings.ai.deviceCodeHeading")}

-
    +
    1. {t("settings.ai.deviceCodeStep1")}{" "}
) : ( - + + + )}
diff --git a/src/components/admin/danger-zone-section.tsx b/src/components/admin/danger-zone-section.tsx index 04f2fdb58..5936bd5f8 100644 --- a/src/components/admin/danger-zone-section.tsx +++ b/src/components/admin/danger-zone-section.tsx @@ -15,6 +15,7 @@ import { AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { SettingsCard } from "@/components/settings/settings-card"; @@ -95,78 +96,6 @@ export function DangerZoneSection() { icon={AlertTriangle} title={t("admin.deleteAllData")} description={t("admin.deleteAllDescription")} - status={ - { - setOpen(next); - if (!next) setTyped(""); - }} - > - - - - - - - {t("admin.deleteAllConfirm")} - - - {t("admin.deleteAllConfirmDescription")} - - -
- - setTyped(e.target.value)} - autoComplete="off" - spellCheck={false} - placeholder={WIPE_CONFIRM_TOKEN} - /> -
- - {t("common.cancel")} - { - if (!matched) { - e.preventDefault(); - return; - } - setOpen(false); - setTyped(""); - wipeAllData.mutate(); - }} - > - {wipeAllData.isPending && ( - - )} - {t("admin.finalDelete")} - - -
-
- } /> {wipeMsg && (

)} + + { + setOpen(next); + if (!next) setTyped(""); + }} + > + + + + + + {t("admin.deleteAllConfirm")} + + {t("admin.deleteAllConfirmDescription")} + + +

+ + setTyped(e.target.value)} + autoComplete="off" + spellCheck={false} + placeholder={WIPE_CONFIRM_TOKEN} + /> +
+ + {t("common.cancel")} + { + if (!matched) { + e.preventDefault(); + return; + } + setOpen(false); + setTyped(""); + wipeAllData.mutate(); + }} + > + {wipeAllData.isPending && ( + + )} + {t("admin.finalDelete")} + + + + + ); } diff --git a/src/components/admin/encryption-section.tsx b/src/components/admin/encryption-section.tsx index 69d672849..a5f5b459b 100644 --- a/src/components/admin/encryption-section.tsx +++ b/src/components/admin/encryption-section.tsx @@ -27,6 +27,8 @@ import { AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { StatTile } from "@/components/admin/_stat-tile"; import { Button } from "@/components/ui/button"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; @@ -99,20 +101,31 @@ export function EncryptionSection() { }, }); - if (statusQuery.isLoading) { + // The card + header stay rendered across every query state so the heading + // keeps a constant Y-offset — the loading branch used to return a bare + // spinner row and the error branch a bare paragraph, so the section jumped + // downward the moment the status resolved. Same shape as + // `coach-feedback-section.tsx`, which fixed this for itself and never had + // the fix carried across. + if (statusQuery.isLoading || statusQuery.isError || !statusQuery.data) { return ( -
- - {t("admin.section.encryption.loading")} -
- ); - } - - if (statusQuery.isError || !statusQuery.data) { - return ( -

- {t("admin.section.encryption.loadError")} -

+ + + {statusQuery.isLoading ? ( +
+ + {t("admin.section.encryption.loading")} +
+ ) : ( +

+ {t("admin.section.encryption.loadError")} +

+ )} +
); } @@ -187,46 +200,48 @@ export function EncryptionSection() { )} - - - - - - - - {t("admin.section.encryption.rotateConfirmTitle")} - - - {t("admin.section.encryption.rotateConfirmDescription")} - - - - - {t("admin.section.encryption.cancel")} - - rotate.mutate()}> - {t("admin.section.encryption.rotateConfirm")} - - - - -

{t("admin.section.encryption.cliNote")}

+ + + + + + + + + + {t("admin.section.encryption.rotateConfirmTitle")} + + + {t("admin.section.encryption.rotateConfirmDescription")} + + + + + {t("admin.section.encryption.cancel")} + + rotate.mutate()}> + {t("admin.section.encryption.rotateConfirm")} + + + + + {/* ── Per-column coverage table ──────────────────────────────── */} @@ -286,10 +301,5 @@ export function EncryptionSection() { } function Stat({ label, value }: { label: string; value: string }) { - return ( -
-

{label}

-

{value}

-
- ); + return ; } diff --git a/src/components/admin/general-settings-section.tsx b/src/components/admin/general-settings-section.tsx index 7799373c5..28ad68a1d 100644 --- a/src/components/admin/general-settings-section.tsx +++ b/src/components/admin/general-settings-section.tsx @@ -103,7 +103,7 @@ export function GeneralSettingsSection() { {(settings?.registrationEnabled ?? true) && (

{t("admin.registrationOpenHint")}

diff --git a/src/components/admin/host-metrics-chart.tsx b/src/components/admin/host-metrics-chart.tsx index c4d8c4042..3422a8372 100644 --- a/src/components/admin/host-metrics-chart.tsx +++ b/src/components/admin/host-metrics-chart.tsx @@ -39,7 +39,10 @@ import { useMemo } from "react"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { Skeleton } from "@/components/ui/skeleton"; +import { Activity } from "lucide-react"; + import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; import { apiGet } from "@/lib/api/api-fetch"; import { readStoredTimezone } from "@/lib/timezone-mirror"; @@ -164,14 +167,16 @@ export function HostMetricsChart() { if (isLoading) { return ( -
-

- {t("admin.hostMetrics.title")} -

- - {t("admin.hostMetrics.last2hours")} - -
+ + {t("admin.hostMetrics.last2hours")} + + } + />
); @@ -180,9 +185,16 @@ export function HostMetricsChart() { if (isError) { return ( -

- {t("admin.hostMetrics.title")} -

+ + {t("admin.hostMetrics.last2hours")} + + } + />

-

-

- {t("admin.hostMetrics.title")} -

- - {t("admin.hostMetrics.last2hours")} - -
+ + {t("admin.hostMetrics.last2hours")} + + } + />

{t("admin.hostMetrics.empty")}

@@ -216,14 +230,16 @@ export function HostMetricsChart() { return ( -
-

- {t("admin.hostMetrics.title")} -

- - {t("admin.hostMetrics.last2hours")} - -
+ + {t("admin.hostMetrics.last2hours")} + + } + />
diff --git a/src/components/admin/integrations-group-section.tsx b/src/components/admin/integrations-group-section.tsx index 215ec06db..438176327 100644 --- a/src/components/admin/integrations-group-section.tsx +++ b/src/components/admin/integrations-group-section.tsx @@ -6,6 +6,10 @@ * Wraps the standalone integration sub-sections (Umami, GlitchTip, Web Push) * under one route so each existing component stays the source of truth for its * own settings while admins still get a single landing page. + * + * No wrapper element: `SectionFrame` already stacks its children at + * `space-y-6`, so a second identical stack here was one nesting level with + * no effect. */ import { GlitchtipSection } from "./glitchtip-section"; @@ -14,10 +18,10 @@ import { WebPushVapidSection } from "./web-push-vapid-section"; export function IntegrationsGroupSection() { return ( -
+ <> -
+ ); } diff --git a/src/components/admin/invite-tokens-section.tsx b/src/components/admin/invite-tokens-section.tsx index 0b7733593..0e8a741ed 100644 --- a/src/components/admin/invite-tokens-section.tsx +++ b/src/components/admin/invite-tokens-section.tsx @@ -28,16 +28,7 @@ */ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { - Ban, - Check, - Copy, - Loader2, - Plus, - ShieldCheck, - Ticket, - Users, -} from "lucide-react"; +import { Ban, Check, Copy, Loader2, Plus, Ticket, Users } from "lucide-react"; import { toast } from "sonner"; import { @@ -51,6 +42,9 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { SettingsCard } from "@/components/settings/settings-card"; import { Dialog, DialogContent, @@ -305,64 +299,30 @@ export function InviteTokensSection() { } return ( -
- {/* Header — copy left, the one action right. */} -
-
-
-
-

- {t("admin.invites.description")} -

-

-

-
- -
+ {isError && ( -

+

{t("admin.invites.loadError")}

)} {invites && invites.length === 0 && ( -
+
)} @@ -370,7 +330,7 @@ export function InviteTokensSection() { {invites && invites.length > 0 && ( <> {/* Desktop table (md+) */} -
+
@@ -470,7 +430,7 @@ export function InviteTokensSection() { {/* Mobile cards ( +
    {invites.map((invite) => { const status = deriveInviteStatus(invite, now); const relative = expiryRelative(invite, status); @@ -718,7 +678,20 @@ export function InviteTokensSection() { - + + + + + ); } diff --git a/src/components/admin/recent-audit-preview.tsx b/src/components/admin/recent-audit-preview.tsx index 63235bcef..7a07b4a9a 100644 --- a/src/components/admin/recent-audit-preview.tsx +++ b/src/components/admin/recent-audit-preview.tsx @@ -62,7 +62,7 @@ export function RecentAuditPreview() { status={ {t("admin.overview.auditViewAll")}