refactor(admin): the console speaks the Settings vocabulary without an accent

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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-07 19:55:47 +02:00
1 parent b2e1aa0cde
commit 330f60115a
27 files changed
+472 -569

No files matched your search

-6
View File
@@ -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})",
-6
View File
@@ -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})",
-6
View File
@@ -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})",
-6
View File
@@ -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})",
-6
View File
@@ -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})",
-6
View File
@@ -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})",
+24 -78
View File
@@ -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 `<AuthShell>`,
// 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 (
<SectionFrame
title={t("admin.section.system-status.title")}
subtitle={t("admin.section.system-status.subtitle")}
>
<SectionFrame>
<SystemStatusSection />
</SectionFrame>
);
case "general":
return (
<SectionFrame
title={t("admin.section.general.title")}
subtitle={t("admin.section.general.subtitle")}
>
<SectionFrame>
<GeneralSettingsSection />
</SectionFrame>
);
case "services":
return (
<SectionFrame
title={t("admin.section.services.title")}
subtitle={t("admin.section.services.subtitle")}
>
<SectionFrame>
<ServicesSection />
</SectionFrame>
);
case "integrations":
return (
<SectionFrame
title={t("admin.section.integrations.title")}
subtitle={t("admin.section.integrations.subtitle")}
>
<SectionFrame>
<IntegrationsGroupSection />
</SectionFrame>
);
@@ -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 (
<SectionFrame
title={t("admin.section.coach.title")}
subtitle={t("admin.section.coach.subtitle")}
>
<SectionFrame>
<AssistantSection />
<AiServerKeySection />
<CentralCodexSection />
@@ -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 (
<SectionFrame
title={t("admin.section.module-availability.title")}
subtitle={t("admin.section.module-availability.subtitle")}
>
<SectionFrame>
<ModuleAvailabilitySection />
</SectionFrame>
);
case "reminders":
return (
<SectionFrame
title={t("admin.section.reminders.title")}
subtitle={t("admin.section.reminders.subtitle")}
>
<SectionFrame>
<RemindersSection />
</SectionFrame>
);
case "users":
return (
<SectionFrame
title={t("admin.section.users.title")}
subtitle={t("admin.section.users.subtitle")}
>
<SectionFrame>
<UserManagementSection />
</SectionFrame>
);
@@ -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 (
<SectionFrame
title={t("admin.section.invites.title")}
subtitle={t("admin.section.invites.subtitle")}
>
<SectionFrame>
<InviteTokensSection />
</SectionFrame>
);
case "api-tokens":
return (
<SectionFrame
title={t("admin.section.api-tokens.title")}
subtitle={t("admin.section.api-tokens.subtitle")}
>
<SectionFrame>
<ApiTokenOverviewSection />
</SectionFrame>
);
case "login-overview":
return (
<SectionFrame
title={t("admin.section.login-overview.title")}
subtitle={t("admin.section.login-overview.subtitle")}
>
<SectionFrame>
<LoginOverviewSection />
</SectionFrame>
);
case "app-logs":
return (
<SectionFrame
title={t("admin.section.app-logs.title")}
subtitle={t("admin.section.app-logs.subtitle")}
>
<SectionFrame>
<AppLogPreviewSection />
</SectionFrame>
);
case "backups":
return (
<SectionFrame
title={t("admin.section.backups.title")}
subtitle={t("admin.section.backups.subtitle")}
>
<SectionFrame>
<BackupsSection />
</SectionFrame>
);
case "encryption":
return (
<SectionFrame
title={t("admin.section.encryption.title")}
subtitle={t("admin.section.encryption.subtitle")}
>
<SectionFrame>
<EncryptionSection />
</SectionFrame>
);
case "danger-zone":
return (
<SectionFrame
title={t("admin.section.danger-zone.title")}
subtitle={t("admin.section.danger-zone.subtitle")}
>
<SectionFrame>
<DangerZoneSection />
</SectionFrame>
);
@@ -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 (
<SectionFrame
title={t("admin.section.about.title")}
subtitle={t("admin.section.about.subtitle")}
>
<SectionFrame>
<AboutSection />
</SectionFrame>
);
@@ -225,17 +175,13 @@ export function AdminSectionRenderer({
}
}
interface SectionFrameProps {
// v1.18.6.1 — the visible heading + subtitle moved to `<AdminShell>`, 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 `<AdminShell>` 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 <div className="space-y-6">{children}</div>;
}
-16
View File
@@ -41,22 +41,6 @@ export default function AdminOverviewPage() {
</div>
)}
{/* Welcome card — greeting with admin context indicator. */}
<section
aria-labelledby="admin-overview-welcome-heading"
className="bg-card border-border rounded-xl border p-6"
>
<h2
id="admin-overview-welcome-heading"
className="text-lg font-semibold"
>
{t("admin.overview.welcomeTitle", { name: user.username })}
</h2>
<p className="text-muted-foreground mt-1 text-sm">
{t("admin.overview.welcomeSubtitle")}
</p>
</section>
<VersionTileSection />
<SystemStatusSummary />
@@ -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(<AdminOverviewPage />);
// `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", () => {
+14 -8
View File
@@ -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 (
<div className="bg-muted/50 rounded-lg p-3">
<div className="text-muted-foreground flex items-center gap-1.5 text-xs">
<Icon className="h-3.5 w-3.5" />
{label}
</div>
<p className={`mt-1 text-sm font-semibold ${className ?? ""}`}>{value}</p>
</div>
<StatTile
icon={icon}
label={label}
value={value}
valueClassName={className}
/>
);
}
+24 -21
View File
@@ -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() {
<SettingsCardHeader
icon={KeyRound}
title={t("admin.aiServerKey.title")}
description={
<>
<p>{t("admin.aiServerKey.description")}</p>
<p>{t("admin.aiServerKey.byokHint")}</p>
<p>{t("admin.aiServerKey.consentHint")}</p>
</>
}
description={t("admin.aiServerKey.description")}
status={
<Badge variant={configured ? "default" : "outline"}>
{configured
@@ -115,6 +110,11 @@ export function AiServerKeySection() {
/>
<div className="space-y-4">
<div className="space-y-2 text-sm">
<p>{t("admin.aiServerKey.byokHint")}</p>
<p>{t("admin.aiServerKey.consentHint")}</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="admin-ai-server-key-input">
{t("admin.aiServerKey.keyLabel")}
@@ -159,8 +159,23 @@ export function AiServerKeySection() {
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<SettingsCardActions>
{configured && (
<ConfirmButton
slot="admin-ai-server-key-remove"
variant="outline"
size="sm"
label={t("admin.aiServerKey.remove")}
title={t("admin.aiServerKey.removeTitle")}
body={t("admin.aiServerKey.removeBody")}
confirmLabel={t("admin.aiServerKey.removeConfirm")}
pending={save.isPending}
onConfirm={handleRemove}
/>
)}
<Button
size="sm"
className="min-h-11 sm:min-h-9"
onClick={handleSave}
disabled={save.isPending}
data-slot="admin-ai-server-key-save"
@@ -170,19 +185,7 @@ export function AiServerKeySection() {
)}
{t("admin.aiServerKey.save")}
</Button>
{configured && (
<ConfirmButton
slot="admin-ai-server-key-remove"
variant="outline"
label={t("admin.aiServerKey.remove")}
title={t("admin.aiServerKey.removeTitle")}
body={t("admin.aiServerKey.removeBody")}
confirmLabel={t("admin.aiServerKey.removeConfirm")}
pending={save.isPending}
onConfirm={handleRemove}
/>
)}
</div>
</SettingsCardActions>
</div>
</SettingsCard>
);
@@ -123,8 +123,9 @@ export function AppLogPreviewSection() {
description={t("admin.section.app-logs.processNote")}
status={
<Button
variant="ghost"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => refetch()}
disabled={isFetching}
aria-label={t("admin.section.app-logs.refresh")}
+17 -15
View File
@@ -36,6 +36,7 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
@@ -505,21 +506,6 @@ export function BackupsSection() {
</Badge>
) : null
}
status={
<Button
size="sm"
disabled={runBackup.isPending}
onClick={() => runBackup.mutate()}
className="min-h-11"
>
{runBackup.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<PlayCircle className="h-3.5 w-3.5" />
)}
{t("admin.section.backups.runNow")}
</Button>
}
description={
<p>
{t("admin.section.backups.description")}{" "}
@@ -776,6 +762,22 @@ export function BackupsSection() {
</p>
</div>
)}
<SettingsCardActions>
<Button
size="sm"
disabled={runBackup.isPending}
onClick={() => runBackup.mutate()}
className="min-h-11"
>
{runBackup.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<PlayCircle className="h-3.5 w-3.5" />
)}
{t("admin.section.backups.runNow")}
</Button>
</SettingsCardActions>
</SettingsCard>
);
}
+37 -32
View File
@@ -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() {
/>
<div className="space-y-4">
<div className="text-muted-foreground flex items-start gap-2 text-xs">
<ShieldAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
<p className="min-w-0">{t("admin.centralCodex.honesty")}</p>
</div>
<SettingsInfoTile icon={ShieldAlert} tone="warning">
<p className="text-foreground">{t("admin.centralCodex.honesty")}</p>
</SettingsInfoTile>
{data?.connectedAt && isConnected && (
<p className="text-muted-foreground text-xs">
@@ -181,25 +182,27 @@ export function CentralCodexSection() {
)}
{isConnected ? (
<ConfirmButton
slot="admin-central-codex-disconnect"
variant="outline"
size="sm"
className="text-destructive min-h-11 shrink-0 sm:min-h-9"
icon={<Trash2 className="h-4 w-4" />}
label={t("admin.centralCodex.disconnectButton")}
title={t("admin.centralCodex.disconnectTitle")}
body={t("admin.centralCodex.disconnectBody")}
confirmLabel={t("admin.centralCodex.disconnectConfirm")}
pending={disconnecting}
onConfirm={handleDisconnect}
/>
<SettingsCardActions>
<ConfirmButton
slot="admin-central-codex-disconnect"
variant="destructive"
size="sm"
className="min-h-11 shrink-0 sm:min-h-9"
icon={<Trash2 className="h-4 w-4" />}
label={t("admin.centralCodex.disconnectButton")}
title={t("admin.centralCodex.disconnectTitle")}
body={t("admin.centralCodex.disconnectBody")}
confirmLabel={t("admin.centralCodex.disconnectConfirm")}
pending={disconnecting}
onConfirm={handleDisconnect}
/>
</SettingsCardActions>
) : deviceCode ? (
<div className="border-primary bg-primary/5 space-y-3 rounded-lg border-l-4 p-4">
<p className="text-sm font-medium">
{t("settings.ai.deviceCodeHeading")}
</p>
<ol className="text-muted-foreground list-decimal space-y-2 pl-5 text-sm">
<ol className="text-muted-foreground list-decimal space-y-2 pl-6 text-sm">
<li>
{t("settings.ai.deviceCodeStep1")}{" "}
<a
@@ -248,20 +251,22 @@ export function CentralCodexSection() {
</div>
</div>
) : (
<Button
variant="outline"
onClick={handleConnect}
disabled={devicePolling}
className="min-h-11 w-full sm:min-h-9 sm:w-auto"
data-slot="admin-central-codex-connect"
>
{devicePolling ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Sparkles className="h-4 w-4" />
)}
{t("admin.centralCodex.connectButton")}
</Button>
<SettingsCardActions>
<Button
size="sm"
onClick={handleConnect}
disabled={devicePolling}
className="min-h-11 w-full sm:min-h-9 sm:w-auto"
data-slot="admin-central-codex-connect"
>
{devicePolling ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Sparkles className="h-4 w-4" />
)}
{t("admin.centralCodex.connectButton")}
</Button>
</SettingsCardActions>
)}
</div>
</SettingsCard>
+71 -72
View File
@@ -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={
<AlertDialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) setTyped("");
}}
>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
disabled={wipeAllData.isPending}
className="min-h-11"
>
{wipeAllData.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
{t("admin.deleteButton")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("admin.deleteAllConfirm")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("admin.deleteAllConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-2">
<Label htmlFor="wipe-confirm-prompt">
{t("admin.deleteAllPromptLabel", {
token: WIPE_CONFIRM_TOKEN,
})}
</Label>
<Input
id="wipe-confirm-prompt"
value={typed}
onChange={(e) => setTyped(e.target.value)}
autoComplete="off"
spellCheck={false}
placeholder={WIPE_CONFIRM_TOKEN}
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={!matched || wipeAllData.isPending}
aria-busy={wipeAllData.isPending || undefined}
onClick={(e) => {
if (!matched) {
e.preventDefault();
return;
}
setOpen(false);
setTyped("");
wipeAllData.mutate();
}}
>
{wipeAllData.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("admin.finalDelete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
}
/>
{wipeMsg && (
<p
@@ -177,6 +106,76 @@ export function DangerZoneSection() {
{wipeMsg}
</p>
)}
<SettingsCardActions>
<AlertDialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) setTyped("");
}}
>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
disabled={wipeAllData.isPending}
className="min-h-11"
>
{wipeAllData.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
{t("admin.deleteButton")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("admin.deleteAllConfirm")}</AlertDialogTitle>
<AlertDialogDescription>
{t("admin.deleteAllConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-2">
<Label htmlFor="wipe-confirm-prompt">
{t("admin.deleteAllPromptLabel", {
token: WIPE_CONFIRM_TOKEN,
})}
</Label>
<Input
id="wipe-confirm-prompt"
value={typed}
onChange={(e) => setTyped(e.target.value)}
autoComplete="off"
spellCheck={false}
placeholder={WIPE_CONFIRM_TOKEN}
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={!matched || wipeAllData.isPending}
aria-busy={wipeAllData.isPending || undefined}
onClick={(e) => {
if (!matched) {
e.preventDefault();
return;
}
setOpen(false);
setTyped("");
wipeAllData.mutate();
}}
>
{wipeAllData.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("admin.finalDelete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</SettingsCardActions>
</SettingsCard>
);
}
+66 -56
View File
@@ -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 (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
{t("admin.section.encryption.loading")}
</div>
);
}
if (statusQuery.isError || !statusQuery.data) {
return (
<p role="alert" className="text-destructive text-sm">
{t("admin.section.encryption.loadError")}
</p>
<SettingsCard>
<SettingsCardHeader
icon={ShieldCheck}
title={t("admin.section.encryption.coverageTitle")}
description={t("admin.section.encryption.coverageDescription")}
/>
{statusQuery.isLoading ? (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
{t("admin.section.encryption.loading")}
</div>
) : (
<p role="alert" className="text-destructive text-sm">
{t("admin.section.encryption.loadError")}
</p>
)}
</SettingsCard>
);
}
@@ -187,46 +200,48 @@ export function EncryptionSection() {
)}
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={running}
data-testid="admin-encryption-rotate"
>
{running ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RotateCw className="h-3.5 w-3.5" />
)}
{t("admin.section.encryption.rotateNow")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("admin.section.encryption.rotateConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("admin.section.encryption.rotateConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("admin.section.encryption.cancel")}
</AlertDialogCancel>
<AlertDialogAction onClick={() => rotate.mutate()}>
{t("admin.section.encryption.rotateConfirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<p className="text-muted-foreground text-xs">
{t("admin.section.encryption.cliNote")}
</p>
<SettingsCardActions>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={running}
data-testid="admin-encryption-rotate"
>
{running ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RotateCw className="h-3.5 w-3.5" />
)}
{t("admin.section.encryption.rotateNow")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("admin.section.encryption.rotateConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("admin.section.encryption.rotateConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("admin.section.encryption.cancel")}
</AlertDialogCancel>
<AlertDialogAction onClick={() => rotate.mutate()}>
{t("admin.section.encryption.rotateConfirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</SettingsCardActions>
</SettingsCard>
{/* ── Per-column coverage table ──────────────────────────────── */}
@@ -286,10 +301,5 @@ export function EncryptionSection() {
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div className="bg-muted/40 rounded-lg px-3 py-2">
<p className="text-muted-foreground text-xs">{label}</p>
<p className="text-foreground font-mono text-sm font-medium">{value}</p>
</div>
);
return <StatTile label={label} value={value} valueClassName="font-mono" />;
}
@@ -103,7 +103,7 @@ export function GeneralSettingsSection() {
{(settings?.registrationEnabled ?? true) && (
<p
data-slot="registration-open-hint"
className="text-muted-foreground -mt-2 text-xs"
className="text-muted-foreground text-xs"
>
{t("admin.registrationOpenHint")}
</p>
+43 -27
View File
@@ -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 (
<SettingsCard as="section" aria-labelledby="admin-host-metrics-heading">
<div className="mb-3 flex items-center justify-between">
<h2 id="admin-host-metrics-heading" className="text-sm font-semibold">
{t("admin.hostMetrics.title")}
</h2>
<span className="text-muted-foreground text-xs">
{t("admin.hostMetrics.last2hours")}
</span>
</div>
<SettingsCardHeader
icon={Activity}
titleId="admin-host-metrics-heading"
title={t("admin.hostMetrics.title")}
status={
<span className="text-muted-foreground text-xs">
{t("admin.hostMetrics.last2hours")}
</span>
}
/>
<Skeleton className="bg-muted/40 h-[180px] w-full" />
</SettingsCard>
);
@@ -180,9 +185,16 @@ export function HostMetricsChart() {
if (isError) {
return (
<SettingsCard as="section" aria-labelledby="admin-host-metrics-heading">
<h2 id="admin-host-metrics-heading" className="text-sm font-semibold">
{t("admin.hostMetrics.title")}
</h2>
<SettingsCardHeader
icon={Activity}
titleId="admin-host-metrics-heading"
title={t("admin.hostMetrics.title")}
status={
<span className="text-muted-foreground text-xs">
{t("admin.hostMetrics.last2hours")}
</span>
}
/>
<p
role="alert"
className="text-destructive bg-destructive/10 border-destructive/30 rounded-md border px-3 py-2 text-xs"
@@ -199,14 +211,16 @@ export function HostMetricsChart() {
if (rows.length === 0) {
return (
<SettingsCard as="section" aria-labelledby="admin-host-metrics-heading">
<div className="mb-3 flex items-center justify-between">
<h2 id="admin-host-metrics-heading" className="text-sm font-semibold">
{t("admin.hostMetrics.title")}
</h2>
<span className="text-muted-foreground text-xs">
{t("admin.hostMetrics.last2hours")}
</span>
</div>
<SettingsCardHeader
icon={Activity}
titleId="admin-host-metrics-heading"
title={t("admin.hostMetrics.title")}
status={
<span className="text-muted-foreground text-xs">
{t("admin.hostMetrics.last2hours")}
</span>
}
/>
<p className="text-muted-foreground text-xs">
{t("admin.hostMetrics.empty")}
</p>
@@ -216,14 +230,16 @@ export function HostMetricsChart() {
return (
<SettingsCard as="section" aria-labelledby="admin-host-metrics-heading">
<div className="mb-3 flex items-center justify-between">
<h2 id="admin-host-metrics-heading" className="text-sm font-semibold">
{t("admin.hostMetrics.title")}
</h2>
<span className="text-muted-foreground text-xs">
{t("admin.hostMetrics.last2hours")}
</span>
</div>
<SettingsCardHeader
icon={Activity}
titleId="admin-host-metrics-heading"
title={t("admin.hostMetrics.title")}
status={
<span className="text-muted-foreground text-xs">
{t("admin.hostMetrics.last2hours")}
</span>
}
/>
<div className="h-[200px] w-full touch-pan-y">
<ResponsiveContainer width="100%" height="100%">
@@ -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 (
<div className="space-y-6">
<>
<UmamiSection />
<GlitchtipSection />
<WebPushVapidSection />
</div>
</>
);
}
+30 -57
View File
@@ -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 (
<section
<SettingsCard
as="section"
aria-labelledby="admin-invites-title"
className="bg-card border-border rounded-xl border"
data-testid="admin-invites-card"
>
{/* Header — copy left, the one action right. */}
<div className="flex flex-col gap-4 p-4 sm:flex-row sm:items-start sm:justify-between sm:p-6">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Ticket
className="text-muted-foreground h-5 w-5"
aria-hidden="true"
/>
<h2 id="admin-invites-title" className="text-lg font-semibold">
{t("admin.invites.title")}
</h2>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("admin.invites.description")}
</p>
<p className="text-muted-foreground mt-1 flex items-center gap-1.5 text-xs">
<ShieldCheck className="size-3.5 shrink-0" aria-hidden="true" />
{t("admin.invites.adminOnlyHint")}
</p>
</div>
<Button
type="button"
onClick={openCreateDialog}
data-testid="admin-invites-open-create"
className="shrink-0"
>
<Plus className="size-4" aria-hidden="true" />
{t("admin.invites.create")}
</Button>
</div>
<SettingsCardHeader
icon={Ticket}
titleId="admin-invites-title"
title={t("admin.invites.title")}
description={t("admin.invites.description")}
/>
{isError && (
<p className="text-destructive px-4 pb-4 text-sm sm:px-6">
<p className="text-destructive text-sm">
{t("admin.invites.loadError")}
</p>
)}
{invites && invites.length === 0 && (
<div className="px-4 pb-4 sm:px-6 sm:pb-6">
<div>
<EmptyState
icon={<Ticket className="size-6" aria-hidden="true" />}
title={t("admin.invites.emptyTitle")}
description={t("admin.invites.emptyDescription")}
action={
<Button
type="button"
variant="outline"
onClick={openCreateDialog}
>
<Plus className="size-4" aria-hidden="true" />
{t("admin.invites.create")}
</Button>
}
/>
</div>
)}
@@ -370,7 +330,7 @@ export function InviteTokensSection() {
{invites && invites.length > 0 && (
<>
{/* Desktop table (md+) */}
<div className="hidden px-2 pb-2 md:block">
<div className="hidden md:block">
<Table data-testid="admin-invites-table">
<TableHeader>
<TableRow className="hover:bg-transparent">
@@ -470,7 +430,7 @@ export function InviteTokensSection() {
</div>
{/* Mobile cards (<md) */}
<ul className="space-y-2 px-4 pb-4 md:hidden">
<ul className="space-y-2 md:hidden">
{invites.map((invite) => {
const status = deriveInviteStatus(invite, now);
const relative = expiryRelative(invite, status);
@@ -718,7 +678,20 @@ export function InviteTokensSection() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</section>
<SettingsCardActions>
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={openCreateDialog}
data-testid="admin-invites-open-create"
>
<Plus className="size-4" aria-hidden="true" />
{t("admin.invites.create")}
</Button>
</SettingsCardActions>
</SettingsCard>
);
}
@@ -62,7 +62,7 @@ export function RecentAuditPreview() {
status={
<Link
href="/admin/login-overview"
className="text-primary hover:text-primary/80 inline-flex items-center gap-1 text-sm"
className="text-primary inline-flex items-center gap-1 text-sm underline-offset-2 hover:underline"
>
{t("admin.overview.auditViewAll")}
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
+58 -58
View File
@@ -55,7 +55,7 @@ function NotificationHealthPanel() {
});
return (
<SettingsCard className="mt-6">
<SettingsCard>
<SettingsCardHeader
icon={HeartPulse}
title={t("admin.notificationHealth.title")}
@@ -261,63 +261,6 @@ export function RemindersSection() {
</div>
</div>
<SettingsCardActions>
<Button
size="sm"
variant="outline"
onClick={() => testNotification.mutate()}
disabled={testNotification.isPending}
>
{testNotification.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
<Bell className="h-3.5 w-3.5" />
{t("admin.notificationTestSend")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => reminderCheck.mutate()}
disabled={reminderCheck.isPending}
>
{reminderCheck.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
<Activity className="h-3.5 w-3.5" />
{t("admin.reminderCheckRun")}
</Button>
<Button
size="sm"
onClick={() => {
updateSettings.mutate(
{
...(reminderLateDraft != null && {
reminderLateMinutes: reminderLateDraft,
}),
...(reminderMissedDraft != null && {
reminderMissedMinutes: reminderMissedDraft,
}),
},
{
onSuccess: () => {
setReminderLateDraft(null);
setReminderMissedDraft(null);
},
},
);
}}
disabled={
updateSettings.isPending ||
(reminderLateDraft == null && reminderMissedDraft == null)
}
>
{updateSettings.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
</SettingsCardActions>
{testNotification.data?.results &&
testNotification.data.results.length > 0 && (
<div className="space-y-1">
@@ -406,6 +349,63 @@ export function RemindersSection() {
</div>
</div>
)}
<SettingsCardActions>
<Button
size="sm"
variant="outline"
onClick={() => testNotification.mutate()}
disabled={testNotification.isPending}
>
{testNotification.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
<Bell className="h-3.5 w-3.5" />
{t("admin.notificationTestSend")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => reminderCheck.mutate()}
disabled={reminderCheck.isPending}
>
{reminderCheck.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
<Activity className="h-3.5 w-3.5" />
{t("admin.reminderCheckRun")}
</Button>
<Button
size="sm"
onClick={() => {
updateSettings.mutate(
{
...(reminderLateDraft != null && {
reminderLateMinutes: reminderLateDraft,
}),
...(reminderMissedDraft != null && {
reminderMissedMinutes: reminderMissedDraft,
}),
},
{
onSuccess: () => {
setReminderLateDraft(null);
setReminderMissedDraft(null);
},
},
);
}}
disabled={
updateSettings.isPending ||
(reminderLateDraft == null && reminderMissedDraft == null)
}
>
{updateSettings.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
</SettingsCardActions>
</SettingsCard>
<NotificationHealthPanel />
</>
+1 -29
View File
@@ -12,16 +12,7 @@
* don't re-fetch.
*/
import {
Clock,
Cog,
Database,
GitCommit,
Globe,
Loader2,
Server,
Tag,
} from "lucide-react";
import { Clock, Cog, Database, Globe, Loader2, Server } from "lucide-react";
import { formatDateTime } from "@/lib/format";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -46,11 +37,6 @@ export function SystemStatusSummary() {
{status ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<StatusItem
icon={Tag}
label={t("admin.overview.snapshotVersion")}
value={version?.version ?? status.version}
/>
<StatusItem
icon={Database}
label={t("admin.overview.snapshotDatabase")}
@@ -82,20 +68,6 @@ export function SystemStatusSummary() {
label={t("admin.overview.snapshotStarted")}
value={formatDateTime(status.startTime)}
/>
{(version?.buildSha ?? status.gitCommit) && (
<StatusItem
icon={GitCommit}
label={t("admin.overview.snapshotBuildSha")}
value={version?.buildSha ?? status.gitCommit}
/>
)}
{version?.builtAt && (
<StatusItem
icon={Clock}
label={t("admin.overview.snapshotBuiltAt")}
value={formatDateTime(version.builtAt)}
/>
)}
{/* v1.4.27 R5 — surface the offline-geo state so the maintainer
spots the missing MAXMIND_LICENSE_KEY without crawling logs.
The field is undefined on legacy responses; the row only
@@ -13,6 +13,7 @@ import {
} 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 { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
@@ -282,24 +283,29 @@ export function UserManagementSection() {
</Badge>
) : null
}
status={
<div className="flex flex-wrap items-center gap-1.5">
{(["all", "admin", "user"] as const).map((value) => (
<Button
key={value}
variant={filter === value ? "default" : "ghost"}
size="sm"
className="min-h-11 min-w-11 px-3 text-xs"
onClick={() => setFilter(value)}
aria-pressed={filter === value}
>
{t(`admin.section.users.filter.${value}`)}
</Button>
))}
</div>
}
/>
{/* Filter toolbar. It used to sit in the header's status slot, which is
where the neighbouring sections put a primary, a destructive, and a
badge — three different meanings for one position. */}
<div
className="flex flex-wrap items-center gap-1.5"
data-slot="admin-users-filter"
>
{(["all", "admin", "user"] as const).map((value) => (
<Button
key={value}
variant={filter === value ? "default" : "outline"}
size="sm"
className="min-h-11 min-w-11 px-3 text-xs sm:min-h-9"
onClick={() => setFilter(value)}
aria-pressed={filter === value}
>
{t(`admin.section.users.filter.${value}`)}
</Button>
))}
</div>
{filteredUsers ? (
filteredUsers.length === 0 ? (
// v1.4.15 phase-C5: dedicated empty state. The previous build
@@ -454,8 +460,8 @@ export function UserManagementSection() {
{/* Edit Dialog */}
{editingUser && (
<div className="bg-muted/80 rounded-lg p-4">
<h3 className="mb-3 text-sm font-semibold">
<div className="bg-muted/50 space-y-3 rounded-lg p-3">
<h3 className="text-sm font-semibold">
{t("admin.editUserTitle", { name: editingUser.username })}
</h3>
{/* v1.16.4 — a real form so Enter in the username / email
@@ -535,10 +541,25 @@ export function UserManagementSection() {
{t("admin.userDocumentQuotaHint")}
</p>
</div>
<div className="flex gap-2">
{updateUser.isError && (
<p className="text-destructive text-sm">
{(updateUser.error as Error).message}
</p>
)}
<SettingsCardActions>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => setEditingUser(null)}
>
{t("common.cancel")}
</Button>
<Button
type="submit"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={updateUser.isPending}
aria-busy={updateUser.isPending || undefined}
>
@@ -547,28 +568,15 @@ export function UserManagementSection() {
)}
{t("common.save")}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setEditingUser(null)}
>
{t("common.cancel")}
</Button>
{updateUser.isError && (
<span className="text-destructive self-center text-sm">
{(updateUser.error as Error).message}
</span>
)}
</div>
</SettingsCardActions>
</form>
</div>
)}
{/* Password Reset Dialog */}
{resetUser && (
<div className="bg-muted/80 rounded-lg p-4">
<h3 className="mb-3 text-sm font-semibold">
<div className="bg-muted/50 space-y-3 rounded-lg p-3">
<h3 className="text-sm font-semibold">
{t("admin.resetPasswordTitle", { name: resetUser.username })}
</h3>
<div className="space-y-3">
@@ -582,9 +590,25 @@ export function UserManagementSection() {
/>
<PasswordStrength password={resetPassword} />
</div>
<div className="flex gap-2">
{resetMsg && (
<p
className={`text-sm ${resetMsg === t("admin.passwordReset") ? "text-success" : "text-destructive"}`}
>
{resetMsg}
</p>
)}
<SettingsCardActions>
<Button
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => setResetUser(null)}
>
{t("common.cancel")}
</Button>
<Button
size="sm"
className="min-h-11 sm:min-h-9"
disabled={resetPw.isPending || !resetPassword}
onClick={() =>
resetPw.mutate({
@@ -598,21 +622,7 @@ export function UserManagementSection() {
)}
{t("admin.reset")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setResetUser(null)}
>
{t("common.cancel")}
</Button>
</div>
{resetMsg && (
<p
className={`text-sm ${resetMsg === t("admin.passwordReset") ? "text-success" : "text-destructive"}`}
>
{resetMsg}
</p>
)}
</SettingsCardActions>
</div>
</div>
)}
@@ -97,7 +97,7 @@ export function VersionTileSection() {
}
target="_blank"
rel="noopener noreferrer"
className="bg-warning/15 text-warning border-warning/30 inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-opacity hover:opacity-80"
className="bg-warning/15 text-warning border-warning/30 inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium underline-offset-2 hover:underline"
>
<ArrowUpCircle className="h-3.5 w-3.5" aria-hidden="true" />
{t("admin.overview.versionTileUpdateAvailable", {
@@ -121,9 +121,16 @@ export function NotificationStatusCard() {
// post-hydration client update, not a hydration-time branch.
if (!hydrated || !isAuthenticated) return null;
// The header paints in every state, so resolving the channel list does not
// move the page under the cursor.
if (isLoading) {
return (
<SettingsCard>
<SettingsCardHeader
icon={Bell}
title={t("settings.notificationStatus.title")}
description={t("settings.notificationStatus.description")}
/>
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
</SettingsCard>
);
+1 -1
View File
@@ -107,7 +107,7 @@ export function ConfirmButton({
label: string;
disabled?: boolean;
size?: "sm" | "icon";
variant?: "outline" | "ghost";
variant?: "outline" | "ghost" | "destructive";
className?: string;
/** For an icon-only control, where `label` renders as an icon. */
ariaLabel?: string;