mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
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:
1 parent
b2e1aa0cde
commit
330f60115a
27 files changed
+472
-569
No files matched your search
@@ -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})",
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user