Merge pull request #753 from MBombeck/fix/honest-errors-and-security-copy

Honest read-failure states and MCP scope-note correction
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-09 06:30:12 +02:00
commit 18e9a80ea9
50 files changed
+1371 -706

No files matched your search

+10
View File
@@ -1,5 +1,15 @@
# Changelog
## [Unreleased]
### Fixed
- The connector-token note in Settings no longer claims a token is read-only in every case, which the write toggle right below it disproved. It now states the real model: read-only by default, write access an opt-in you turn on per token, and no token ever reaches admin functions.
- A failed load of your share links now says so, with a way to retry, instead of quietly showing "no active share links" as if you had never shared anything. The same held for the API and connector token lists on the security screens.
- A read that fails now shows a clear, recoverable error across the app instead of an empty list or a silent gap. The sharing and token screens, the ECG detail and list, the cycle and mood insights, the dashboard preventive-care tile, the checkups list, and the admin console all say when a read failed and offer a retry, rather than reading as "nothing here".
- The AI consent and connector notes in Settings render as normal text now, not faint fine print, so the copy you are meant to read before turning a feature on is legible.
- The respiratory-rate tile on the dashboard shows its unit in your own language instead of a fixed English label, and the personalized greeting carries its comma.
## [1.37.3] — 2026-08-09
### Added
+2 -2
View File
@@ -1,11 +1,11 @@
{
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged.",
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling.",
"routesKbGz": {
"/page": 460,
"/insights/page": 445,
"/measurements/page": 445,
"/insights/mood/page": 460
},
"totalClientKbGz": 3352,
"totalClientKbGz": 3428,
"maxRechartsChunks": 1
}
+7 -5
View File
@@ -427,7 +427,7 @@
"evening": "Guten Abend"
},
"welcomeBack": "{greeting}, willkommen zurück.",
"welcomeBackWithName": "{greeting} {name}, willkommen zurück.",
"welcomeBackWithName": "{greeting}, {name}, willkommen zurück.",
"greetingSalutation": "Hallo, {name}",
"streakLabel": "Tage in Folge",
"weight": "Gewicht",
@@ -4615,6 +4615,8 @@
},
"sharedRecord": {
"managedSettings": {
"saveError": "Speichern fehlgeschlagen. Bitte erneut versuchen.",
"validationError": "Bitte prüfe deine Eingaben.",
"profile": {
"title": "Profil",
"displayName": "Anzeigename",
@@ -6024,7 +6026,7 @@
"scopeTitle": "Was dieser Link enthält",
"scopeInsuranceExcluded": "Versichertendaten werden über einen Link nie geteilt.",
"statusNeedsReselect": "Inhalt neu wählen",
"reselectReason": "Dieser Link lieferte einen Umfang, den niemand gewählt hatte, und wurde deshalb geschlossen. Legen Sie einen neuen an und wählen Sie den Inhalt.",
"reselectReason": "Dieser Link lieferte einen Umfang, den niemand gewählt hatte, und wurde deshalb geschlossen. Lege einen neuen an und wähle den Inhalt.",
"reselectAction": "Ersatz anlegen",
"createDetail": "Der Token wird nur einmal bei der Erstellung angezeigt — sichere ihn dann; er lässt sich nicht wiederherstellen."
},
@@ -6283,7 +6285,7 @@
"connectDescription": "Füge diese URL in die Einrichtung des eigenen Connectors deines Assistenten ein und autorisiere dich mit dem Token unten oder melde dich bei Aufforderung an.",
"tokensTitle": "Connector-Tokens",
"tokensDescription": "Erzeuge ein schreibgeschütztes Token, um einen Assistenten zu verbinden.",
"scopeNote": "Tokens sind auf health:read beschränkt — nur Lesezugriff auf deine eigenen Daten. Sie können niemals Admin-Funktionen erreichen.",
"scopeNote": "Tokens haben standardmäßig nur Lesezugriff auf deine eigenen Daten. Schreibzugriff ist optional und wird pro Token über den Schalter unten aktiviert. Die Tokens erreichen niemals Admin-Funktionen.",
"writeScopeToggleLabel": "Eintragen erlauben (health:write)",
"writeScopeNote": "Fügt den Scope health:write hinzu, damit der Assistent neue Messwerte und Stimmungseinträge in dein Konto eintragen kann. Jeder Schreibvorgang wird zuvor bestätigt. Das Token kann weiterhin keine vorhandenen Einträge löschen oder ändern, keine Medikamente bearbeiten und keine REST- oder Admin-Funktionen erreichen. Für reinen Lesezugriff ausgeschaltet lassen.",
"connectionsTitle": "Verbundene Assistenten",
@@ -9836,8 +9838,8 @@
"standardHint": "Ein Klick füllt aus, was ein normaler Arztbesuch meist braucht. Jeder Eintrag bleibt änderbar.",
"groupCount": "{on}/{total}",
"groupDisclosure": "Einträge in {group} anzeigen",
"sensitiveHint": "Hier ist nie etwas vorausgewählt. Wählen Sie jeden Eintrag selbst.",
"scopeNone": "Noch nichts ausgewählt. Wählen Sie aus, was enthalten sein soll, oder übernehmen Sie den Standardbericht.",
"sensitiveHint": "Hier ist nie etwas vorausgewählt. Wähle jeden Eintrag selbst.",
"scopeNone": "Noch nichts ausgewählt. Wähle aus, was enthalten sein soll, oder übernimm den Standardbericht.",
"scopeCount": "{count} Einträge aus {groups} Bereichen",
"scopeSensitive": "inkl. {names}",
"leafVisits": "Arztbesuche",
+4 -2
View File
@@ -427,7 +427,7 @@
"evening": "Good evening"
},
"welcomeBack": "{greeting}, welcome back.",
"welcomeBackWithName": "{greeting} {name}, welcome back.",
"welcomeBackWithName": "{greeting}, {name}, welcome back.",
"greetingSalutation": "Hi, {name}",
"streakLabel": "days in a row",
"weight": "Weight",
@@ -4615,6 +4615,8 @@
},
"sharedRecord": {
"managedSettings": {
"saveError": "Couldn't save. Please try again.",
"validationError": "Please check your entries.",
"profile": {
"title": "Profile",
"displayName": "Display name",
@@ -6283,7 +6285,7 @@
"connectDescription": "Paste this URL into your assistant's custom-connector setup, then authorize with the token below or sign in when prompted.",
"tokensTitle": "Connector tokens",
"tokensDescription": "Mint a read-only token to connect an assistant.",
"scopeNote": "Tokens are scoped to health:read — read-only access to your own records. They can never reach admin functions.",
"scopeNote": "Tokens grant read-only access to your own records by default. Write access is optional and enabled per token with the toggle below. The tokens can never reach admin functions.",
"writeScopeToggleLabel": "Allow logging (health:write)",
"writeScopeNote": "Adds the health:write scope so the assistant can log new measurements and mood entries to your account. Every write is confirmed first. The token still can't delete or edit existing entries, change medications, or reach REST or admin functions. Leave off for read-only access.",
"connectionsTitle": "Connected assistants",
+7 -5
View File
@@ -427,7 +427,7 @@
"evening": "Buenas tardes"
},
"welcomeBack": "{greeting}, te damos la bienvenida de nuevo.",
"welcomeBackWithName": "{greeting} {name}, te damos la bienvenida de nuevo.",
"welcomeBackWithName": "{greeting}, {name}, te damos la bienvenida de nuevo.",
"greetingSalutation": "Hola, {name}",
"streakLabel": "días seguidos",
"weight": "Peso",
@@ -4615,6 +4615,8 @@
},
"sharedRecord": {
"managedSettings": {
"saveError": "No se pudo guardar. Inténtalo de nuevo.",
"validationError": "Revisa tus datos.",
"profile": {
"title": "Perfil",
"displayName": "Nombre visible",
@@ -6024,7 +6026,7 @@
"scopeTitle": "Qué contiene este enlace",
"scopeInsuranceExcluded": "Los datos del seguro nunca se comparten por enlace.",
"statusNeedsReselect": "Elegir contenido de nuevo",
"reselectReason": "Este enlace mostraba un alcance que nadie eligió, por eso se cerró. Cree uno nuevo y elija su contenido.",
"reselectReason": "Este enlace mostraba un alcance que nadie eligió, por eso se cerró. Crea uno nuevo y elige su contenido.",
"reselectAction": "Crear un reemplazo",
"createDetail": "El token se muestra una sola vez al crearlo — guárdalo entonces; no se puede recuperar."
},
@@ -6283,7 +6285,7 @@
"connectDescription": "Pega esta URL en la configuración del conector personalizado de tu asistente y autoriza con el token de abajo o inicia sesión cuando se te solicite.",
"tokensTitle": "Tokens del conector",
"tokensDescription": "Genera un token de solo lectura para conectar un asistente.",
"scopeNote": "Los tokens se limitan a health:read: acceso de solo lectura a tus propios registros. Nunca pueden acceder a funciones de administración.",
"scopeNote": "De forma predeterminada, los tokens solo tienen acceso de lectura a tus propios registros. El acceso de escritura es opcional y se activa por token con el interruptor de abajo. Los tokens nunca pueden acceder a funciones de administración.",
"writeScopeToggleLabel": "Permitir registro (health:write)",
"writeScopeNote": "Añade el ámbito health:write para que el asistente pueda registrar nuevas mediciones y entradas de estado de ánimo en tu cuenta. Cada escritura se confirma antes. El token sigue sin poder eliminar ni editar entradas existentes, cambiar medicamentos ni acceder a funciones REST o de administración. Déjalo desactivado para acceso de solo lectura.",
"connectionsTitle": "Asistentes conectados",
@@ -9836,8 +9838,8 @@
"standardHint": "Un clic rellena lo que suele necesitar una consulta general. Cada entrada sigue siendo editable.",
"groupCount": "{on}/{total}",
"groupDisclosure": "Mostrar las entradas de {group}",
"sensitiveHint": "Aquí nunca hay nada preseleccionado. Elija cada entrada usted mismo.",
"scopeNone": "Nada seleccionado todavía. Elija qué incluir o aplique el informe estándar.",
"sensitiveHint": "Aquí nunca hay nada preseleccionado. Elige cada entrada tú mismo.",
"scopeNone": "Nada seleccionado todavía. Elige qué incluir o aplica el informe estándar.",
"scopeCount": "{count} entradas de {groups} áreas",
"scopeSensitive": "incl. {names}",
"leafVisits": "Visitas médicas",
+4 -2
View File
@@ -427,7 +427,7 @@
"evening": "Bonsoir"
},
"welcomeBack": "{greeting}, content de te revoir.",
"welcomeBackWithName": "{greeting} {name}, content de te revoir.",
"welcomeBackWithName": "{greeting}, {name}, content de te revoir.",
"greetingSalutation": "Bonjour, {name}",
"streakLabel": "jours d’affilée",
"weight": "Poids",
@@ -4615,6 +4615,8 @@
},
"sharedRecord": {
"managedSettings": {
"saveError": "Échec de l'enregistrement. Réessayez.",
"validationError": "Vérifiez vos saisies.",
"profile": {
"title": "Profil",
"displayName": "Nom affiché",
@@ -6283,7 +6285,7 @@
"connectDescription": "Collez cette URL dans la configuration du connecteur personnalisé de votre assistant, puis autorisez avec le jeton ci-dessous ou connectez-vous lorsque vous y êtes invité.",
"tokensTitle": "Jetons du connecteur",
"tokensDescription": "Générez un jeton en lecture seule pour connecter un assistant.",
"scopeNote": "Les jetons sont limités à health:read : accès en lecture seule à vos propres données. Ils ne peuvent jamais atteindre les fonctions d'administration.",
"scopeNote": "Par défaut, les jetons n'ont qu'un accès en lecture à vos propres données. L'accès en écriture est optionnel et s'active par jeton avec le commutateur ci-dessous. Les jetons ne peuvent jamais atteindre les fonctions d'administration.",
"writeScopeToggleLabel": "Autoriser l'enregistrement (health:write)",
"writeScopeNote": "Ajoute la portée health:write pour que l'assistant puisse enregistrer de nouvelles mesures et entrées d'humeur dans votre compte. Chaque écriture est d'abord confirmée. Le jeton ne peut toujours pas supprimer ni modifier des entrées existantes, changer les médicaments, ni atteindre les fonctions REST ou d'administration. Laissez désactivé pour un accès en lecture seule.",
"connectionsTitle": "Assistants connectés",
+4 -2
View File
@@ -427,7 +427,7 @@
"evening": "Buonasera"
},
"welcomeBack": "{greeting}, bentornato.",
"welcomeBackWithName": "{greeting} {name}, bentornato.",
"welcomeBackWithName": "{greeting}, {name}, bentornato.",
"greetingSalutation": "Ciao, {name}",
"streakLabel": "giorni di fila",
"weight": "Peso",
@@ -4615,6 +4615,8 @@
},
"sharedRecord": {
"managedSettings": {
"saveError": "Salvataggio non riuscito. Riprova.",
"validationError": "Controlla i tuoi dati.",
"profile": {
"title": "Profilo",
"displayName": "Nome visualizzato",
@@ -6283,7 +6285,7 @@
"connectDescription": "Incolla questo URL nella configurazione del connettore personalizzato del tuo assistente e autorizza con il token qui sotto o accedi quando richiesto.",
"tokensTitle": "Token del connettore",
"tokensDescription": "Genera un token in sola lettura per collegare un assistente.",
"scopeNote": "I token sono limitati a health:read: accesso in sola lettura ai tuoi dati. Non possono mai raggiungere funzioni di amministrazione.",
"scopeNote": "Per impostazione predefinita, i token hanno accesso in sola lettura ai tuoi dati. L'accesso in scrittura è opzionale e si attiva per singolo token con l'interruttore qui sotto. I token non possono mai raggiungere funzioni di amministrazione.",
"writeScopeToggleLabel": "Consenti la registrazione (health:write)",
"writeScopeNote": "Aggiunge l'ambito health:write così l'assistente può registrare nuove misurazioni e voci dell'umore nel tuo account. Ogni scrittura viene prima confermata. Il token non può comunque eliminare o modificare voci esistenti, cambiare i farmaci o raggiungere funzioni REST o di amministrazione. Lascia disattivato per l'accesso in sola lettura.",
"connectionsTitle": "Assistenti collegati",
+4 -2
View File
@@ -427,7 +427,7 @@
"evening": "Dobry wieczór"
},
"welcomeBack": "{greeting}, witaj ponownie.",
"welcomeBackWithName": "{greeting} {name}, witaj ponownie.",
"welcomeBackWithName": "{greeting}, {name}, witaj ponownie.",
"greetingSalutation": "Cześć, {name}",
"streakLabel": "dni z rzędu",
"weight": "Waga",
@@ -4615,6 +4615,8 @@
},
"sharedRecord": {
"managedSettings": {
"saveError": "Nie udało się zapisać. Spróbuj ponownie.",
"validationError": "Sprawdź wprowadzone dane.",
"profile": {
"title": "Profil",
"displayName": "Wyświetlana nazwa",
@@ -6283,7 +6285,7 @@
"connectDescription": "Wklej ten adres URL w konfiguracji własnego łącznika asystenta, a następnie autoryzuj za pomocą poniższego tokenu lub zaloguj się po wyświetleniu monitu.",
"tokensTitle": "Tokeny łącznika",
"tokensDescription": "Wygeneruj token tylko do odczytu, aby połączyć asystenta.",
"scopeNote": "Tokeny są ograniczone do health:read — dostęp tylko do odczytu własnych danych. Nigdy nie mogą uzyskać dostępu do funkcji administracyjnych.",
"scopeNote": "Domyślnie tokeny mają dostęp tylko do odczytu własnych danych. Dostęp do zapisu jest opcjonalny i włączany dla każdego tokena przełącznikiem poniżej. Tokeny nigdy nie mogą uzyskać dostępu do funkcji administracyjnych.",
"writeScopeToggleLabel": "Zezwól na zapis (health:write)",
"writeScopeNote": "Dodaje zakres health:write, aby asystent mógł zapisywać nowe pomiary i wpisy nastroju na Twoim koncie. Każdy zapis jest najpierw potwierdzany. Token nadal nie może usuwać ani edytować istniejących wpisów, zmieniać leków ani uzyskiwać dostępu do funkcji REST lub administracyjnych. Pozostaw wyłączone, aby uzyskać dostęp tylko do odczytu.",
"connectionsTitle": "Połączeni asystenci",
+7 -7
View File
@@ -2,6 +2,7 @@
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { AdminShell } from "@/components/admin/admin-shell";
import { SystemStatusSummary } from "@/components/admin/system-status-summary";
import { VersionTileSection } from "@/components/admin/version-tile-section";
@@ -23,7 +24,8 @@ export default function AdminOverviewPage() {
// Many sub-sections share `useAdminSettings()` and render defaults
// silently on error — the banner makes the failure visible so the
// admin knows the toggles below aren't reflecting real state.
const { isError: settingsError } = useAdminSettings();
const { isError: settingsError, refetch: refetchSettings } =
useAdminSettings();
if (!user || user.role !== "ADMIN") return null;
@@ -33,12 +35,10 @@ export default function AdminOverviewPage() {
{/* v1.18.6.1 — the console title + subtitle render in `<AdminShell>`
now (its own grid row, so the nav lines up with the first card). */}
{settingsError && (
<div
role="alert"
className="text-destructive bg-destructive/10 border-destructive/30 rounded-md border px-3 py-2 text-sm"
>
{t("admin.adminSettingsLoadError")}
</div>
<QueryErrorRow
message={t("admin.adminSettingsLoadError")}
onRetry={() => void refetchSettings()}
/>
)}
<VersionTileSection />
@@ -0,0 +1,42 @@
/**
* A failed ECG-list read on the routed `/insights/ecg` page must not leave the
* shell heading over an empty body — indistinguishable from "still loading".
* The shared list query is forced into its error state; the page has to render
* a `query-error-card` with retry inside the shell.
*/
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ isAuthenticated: true, user: null }),
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: undefined,
isLoading: false,
isError: true,
refetch: vi.fn(),
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import InsightsEcgPage from "../page";
function render() {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<InsightsEcgPage />
</I18nProvider>,
);
}
describe("/insights/ecg page — a read failure is honest, not an empty shell", () => {
it("renders the query-error card with retry, not an empty section body", () => {
const html = render();
expect(html).toContain('data-slot="query-error-card"');
expect(html).toContain('data-slot="query-error-retry"');
// The heading shell is still there, but not over a silently empty body.
expect(html).toContain('id="insights-subpage-title"');
});
});
+17 -1
View File
@@ -9,6 +9,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { Button } from "@/components/ui/button";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { EcgSection } from "@/components/insights/ecg-section";
@@ -38,12 +39,27 @@ export default function InsightsEcgPage() {
// Reuse the SAME query cell `<EcgSection>` reads (`insightsEcgList`) so the
// page and the section share one cache entry. The page only needs the
// `hasRecordings` flag to choose between the empty state and the section.
const { data, isLoading } = useQuery({
const { data, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.insightsEcgList(),
queryFn: () => apiGet<{ hasRecordings: boolean }>("/api/insights/ecg"),
enabled: isAuthenticated,
});
// A read failure must be visible, not a heading over an empty body that
// reads the same as "still loading" (§6). The section teaser below unmounts
// silently on error, which is right for the overview but wrong for the page
// the user opened to see this list.
if (isError) {
return (
<SubPageShell
title={t("insights.ecg.sectionTitle")}
description={t("insights.subPage.ecgDescription")}
>
<QueryErrorCard onRetry={() => refetch()} />
</SubPageShell>
);
}
// Data-availability empty state — a direct URL hit (or deleted recordings)
// lands here without a section to show. Mirrors the sub-page convention:
// a calm one-line scaffold + a connect-a-device CTA, never a blank surface.
+1 -1
View File
@@ -1354,7 +1354,7 @@ export default function DashboardPageClient({
key="respiratoryRate"
label={t("measurements.typeRespiratoryRate")}
latest={respRateSummary?.latest ?? null}
unit="breaths/min"
unit={t("insights.units.respiratoryRate")}
avg7={respRateSummary?.avg7 ?? null}
avg30={respRateSummary?.avg30 ?? null}
slope30={respRateSummary?.slope30 ?? null}
@@ -0,0 +1,45 @@
/**
* The admin error-box family rendered a failed read as a hand-rolled
* destructive-tinted box (§13 drift). Each self-contained admin section is
* forced into its query error state and must now render the shared
* `query-error-row` primitive with retry.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: undefined,
isLoading: false,
isError: true,
refetch: vi.fn(),
}),
useMutation: () => ({ mutate: vi.fn(), isPending: false, isError: false }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { RecentAuditPreview } from "../recent-audit-preview";
import { CoachFeedbackSection } from "../coach-feedback-section";
import { AiQualitySection } from "../ai-quality-section";
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
const cases: Array<[string, () => React.ReactNode]> = [
["RecentAuditPreview", () => <RecentAuditPreview />],
["CoachFeedbackSection", () => <CoachFeedbackSection />],
["AiQualitySection", () => <AiQualitySection />],
];
describe("admin read-error family — a read failure is foreground and recoverable", () => {
it.each(cases)("%s renders a query-error row with retry", (_name, node) => {
const html = render(node());
expect(html).toContain('data-slot="query-error-row"');
expect(html).toContain('data-slot="query-error-row-retry"');
expect(html).not.toContain("bg-destructive/10");
});
});
+5 -6
View File
@@ -23,6 +23,7 @@ import { useQuery } from "@tanstack/react-query";
import { Sparkles, Loader2 } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
@@ -102,12 +103,10 @@ export function AiQualitySection() {
</span>
</div>
) : query.isError ? (
<div
role="alert"
className="text-destructive bg-destructive/10 border-destructive/30 rounded-md border p-3 text-sm"
>
{t("admin.aiQuality.loadError")}
</div>
<QueryErrorRow
message={t("admin.aiQuality.loadError")}
onRetry={() => void query.refetch()}
/>
) : !hasData ? (
<p className="text-muted-foreground text-sm">
{t("admin.aiQuality.empty")}
+6 -7
View File
@@ -36,6 +36,7 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
@@ -286,7 +287,7 @@ export function BackupsSection() {
const fmt = useFormatters();
const queryClient = useQueryClient();
const { data, isLoading, isError } = useQuery({
const { data, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.adminBackups(),
queryFn: async () => {
return apiGet<BackupsList>("/api/admin/backups");
@@ -596,12 +597,10 @@ export function BackupsSection() {
</span>
</div>
) : isError ? (
<div
role="alert"
className="text-destructive bg-destructive/10 border-destructive/30 rounded-md border px-3 py-2 text-sm"
>
{t("admin.section.backups.loadError")}
</div>
<QueryErrorRow
message={t("admin.section.backups.loadError")}
onRetry={() => void refetch()}
/>
) : rows.length === 0 ? (
// v1.4.15 phase-C5: replace bare text with the EmptyState
// primitive. The header already exposes "Backup now" but a
@@ -23,6 +23,7 @@ import { Loader2, Sparkles } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
@@ -97,12 +98,10 @@ export function CoachFeedbackSection() {
</span>
</div>
) : query.isError ? (
<div
role="alert"
className="text-destructive bg-destructive/10 border-destructive/30 rounded-md border p-3 text-sm"
>
{t("admin.coachFeedback.loadError")}
</div>
<QueryErrorRow
message={t("admin.coachFeedback.loadError")}
onRetry={() => void query.refetch()}
/>
) : !hasData ? (
<p className="text-muted-foreground text-sm">
{t("admin.coachFeedback.empty")}
+6 -7
View File
@@ -39,6 +39,7 @@ import { useMemo } from "react";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { Skeleton } from "@/components/ui/skeleton";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Activity } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
@@ -142,7 +143,7 @@ export function HostMetricsChart() {
const { t } = useTranslations();
const fmt = useFormatters();
const { data, isLoading, isError } = useQuery({
const { data, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.adminHostMetrics("2h"),
queryFn: async () => {
return apiGet<HostMetricsApiResponse>("/api/admin/host-metrics?since=2h");
@@ -195,12 +196,10 @@ export function HostMetricsChart() {
</span>
}
/>
<p
role="alert"
className="text-destructive bg-destructive/10 border-destructive/30 rounded-md border px-3 py-2 text-xs"
>
{t("admin.hostMetrics.loadError")}
</p>
<QueryErrorRow
message={t("admin.hostMetrics.loadError")}
onRetry={() => void refetch()}
/>
</SettingsCard>
);
}
@@ -19,6 +19,7 @@ import {
XCircle,
} from "lucide-react";
import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { formatDateTime } from "@/lib/format";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -37,7 +38,7 @@ interface AuditLogResponse {
export function RecentAuditPreview() {
const { t } = useTranslations();
const { data, isLoading, isError } = useQuery({
const { data, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.adminAuditOverview(),
queryFn: async () => {
return apiGet<AuditLogResponse>(
@@ -82,12 +83,10 @@ export function RecentAuditPreview() {
</span>
</div>
) : isError ? (
<div
role="alert"
className="text-destructive bg-destructive/10 border-destructive/30 rounded-md border px-3 py-2 text-sm"
>
{t("admin.overview.auditLoadError")}
</div>
<QueryErrorRow
message={t("admin.overview.auditLoadError")}
onRetry={() => void refetch()}
/>
) : entries.length === 0 ? (
// v1.4.15 phase-C5: compact + plain so it nests inside the
// overview card without doubling the dashed border. Reuses
+8 -25
View File
@@ -13,11 +13,10 @@ import {
Key,
Loader2,
Map,
RefreshCw,
Server,
Users,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Skeleton } from "@/components/ui/skeleton";
import { formatDateTime } from "@/lib/format";
import { SettingsCard } from "@/components/settings/settings-card";
@@ -128,7 +127,7 @@ export function FailingJobsCard({
export function SystemStatusSection() {
const { t } = useTranslations();
const fmt = useFormatters();
const { data: status, isError, refetch, isFetching } = useSystemStatus();
const { data: status, isError, refetch } = useSystemStatus();
const { data: version } = usePublicVersion();
return (
@@ -273,28 +272,12 @@ export function SystemStatusSection() {
// v1.4.16 Wave-C MED — pair the alert with a Retry button so a
// transient 500 (rolling deploy, DB blip) doesn't require a
// full page reload to recover.
<div
role="alert"
className="text-destructive bg-destructive/10 border-destructive/30 flex flex-col items-start gap-2 rounded-md border px-3 py-2 text-sm sm:flex-row sm:items-center sm:justify-between"
>
<span>{t("admin.systemStatusLoadError")}</span>
<Button
type="button"
size="sm"
variant="outline"
disabled={isFetching}
onClick={() => void refetch()}
className="min-h-9"
data-testid="system-status-retry"
>
{isFetching ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{t("admin.systemStatusRetry")}
</Button>
</div>
<QueryErrorRow
message={t("admin.systemStatusLoadError")}
onRetry={() => void refetch()}
retryLabel={t("admin.systemStatusRetry")}
retrySlot="system-status-retry"
/>
) : (
<div className="flex items-center gap-2">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
@@ -16,12 +16,13 @@ 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";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { useTranslations } from "@/lib/i18n/context";
import { StatusItem, usePublicVersion, useSystemStatus } from "./_shared";
export function SystemStatusSummary() {
const { t } = useTranslations();
const { data: status, isError } = useSystemStatus();
const { data: status, isError, refetch } = useSystemStatus();
const { data: version } = usePublicVersion();
return (
@@ -90,12 +91,10 @@ export function SystemStatusSummary() {
)}
</div>
) : isError ? (
<div
role="alert"
className="text-destructive bg-destructive/10 border-destructive/30 rounded-md border px-3 py-2 text-sm"
>
{t("admin.overview.snapshotLoadError")}
</div>
<QueryErrorRow
message={t("admin.overview.snapshotLoadError")}
onRetry={() => void refetch()}
/>
) : (
<div className="flex items-center gap-2">
<Loader2
@@ -0,0 +1,69 @@
/**
* A failed cycle read must surface as a recoverable, foreground error, not a
* hand-rolled muted line. The calendar read (which drives the default tab) is
* forced into its error state; the tab has to render the shared query-error
* primitive with retry, and no longer the muted `cycle-tab-error` row.
*/
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({ canManage: true, canAdd: true }),
}));
const errorQuery = {
data: undefined,
isError: true,
isLoading: false,
refetch: vi.fn(),
};
const idleQuery = {
data: undefined,
isError: false,
isLoading: false,
refetch: vi.fn(),
};
const mutationStub = { mutate: vi.fn(), isPending: false, isError: false };
vi.mock("../use-cycle", () => ({
localYmd: () => "2026-07-01",
useCycleCalendar: () => errorQuery,
useCycleHistory: () => idleQuery,
useCycleInsights: () => idleQuery,
useCycleProfile: () => idleQuery,
useCycleDayLog: () => idleQuery,
useCustomSymptoms: () => idleQuery,
useLogDay: () => mutationStub,
usePatchDayLog: () => mutationStub,
useStartPeriod: () => mutationStub,
useEndPeriod: () => mutationStub,
useDeleteDayLog: () => mutationStub,
useCreateCustomSymptom: () => mutationStub,
useDeleteCustomSymptom: () => mutationStub,
CUSTOM_SYMPTOM_LIMIT_ERROR_CODE: "cycle.symptom.custom.limit",
CustomSymptomError: class extends Error {},
}));
import { I18nProvider } from "@/lib/i18n/context";
import { CycleView } from "../cycle-view";
function render() {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<CycleView />
</I18nProvider>,
);
}
describe("<CycleView> — a read failure is honest, not a muted line", () => {
it("renders the shared query-error primitive with retry, not the muted row", () => {
const html = render();
expect(html).toContain('data-slot="query-error-row"');
expect(html).toContain('data-slot="query-error-row-retry"');
expect(html).not.toContain('data-slot="cycle-tab-error"');
});
});
+21 -38
View File
@@ -8,6 +8,8 @@ import { Loader2, Plus, RefreshCw, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
@@ -349,7 +351,13 @@ export function CycleView() {
{t("cycle.calendar.loading")}
</p>
) : calendarError ? (
<TabError onRetry={() => void calendar.refetch()} />
// Inside the calendar card already — a borderless row keeps
// it in line with the plain loading line above it (§3/§6).
<QueryErrorRow
message={t("cycle.loadError")}
onRetry={() => void calendar.refetch()}
className="border-0 bg-transparent p-0"
/>
) : (
<>
{/* Cold start: the grid carries no fertile/ovulation/phase
@@ -388,11 +396,10 @@ export function CycleView() {
<TabsContent value="predictions" className="mt-4 space-y-4">
{calendarError ? (
<Card>
<CardContent className="py-4">
<TabError onRetry={() => void calendar.refetch()} />
</CardContent>
</Card>
<QueryErrorCard
title={t("cycle.loadError")}
onRetry={() => void calendar.refetch()}
/>
) : (
<>
<PredictionsPanel
@@ -419,11 +426,10 @@ export function CycleView() {
<TabsContent value="insights" className="mt-4 space-y-4">
{insights.isError ? (
<Card>
<CardContent className="py-4">
<TabError onRetry={() => void insights.refetch()} />
</CardContent>
</Card>
<QueryErrorCard
title={t("cycle.loadError")}
onRetry={() => void insights.refetch()}
/>
) : (
<>
<CyclePhaseHeadline
@@ -439,11 +445,10 @@ export function CycleView() {
<TabsContent value="settings" className="mt-4">
{profileQuery.isError ? (
<Card>
<CardContent className="py-4">
<TabError onRetry={() => void profileQuery.refetch()} />
</CardContent>
</Card>
<QueryErrorCard
title={t("cycle.loadError")}
onRetry={() => void profileQuery.refetch()}
/>
) : profileQuery.isLoading ? (
<div className="flex h-32 items-center justify-center">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin motion-reduce:animate-none" />
@@ -480,25 +485,3 @@ export function CycleView() {
}
/** A compact in-place error + Retry for a failed tab read. */
function TabError({ onRetry }: { onRetry: () => void }) {
const { t } = useTranslations();
return (
<div
role="alert"
data-slot="cycle-tab-error"
className="text-muted-foreground flex flex-col items-start gap-3 text-sm sm:flex-row sm:items-center sm:justify-between"
>
<span>{t("cycle.loadError")}</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={onRetry}
className="gap-1.5"
>
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("common.retry")}</span>
</Button>
</div>
);
}
@@ -20,6 +20,7 @@ import { toast } from "sonner";
import { Input } from "@/components/ui/input";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
@@ -132,9 +133,10 @@ export function DocumentLinkPicker({
))}
</div>
) : list.isError ? (
<p role="alert" className="text-destructive text-sm">
{t("documents.list.loadError")}
</p>
<QueryErrorRow
message={t("documents.list.loadError")}
onRetry={() => list.refetch()}
/>
) : documents.length === 0 ? (
<p className="text-muted-foreground py-4 text-center text-sm">
{t("documents.linkPicker.empty")}
@@ -78,18 +78,21 @@ describe("<EcgDetail> — non-diagnostic framing", () => {
expect(html).toContain("as reported by the recording device");
});
it("says a failed read failed instead of painting an empty recording", () => {
it("shows a recoverable read failure instead of painting an empty recording", () => {
useQueryMock.mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
refetch: vi.fn(),
});
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<EcgDetail recordingId="ecg_1" />
</I18nProvider>,
);
expect(html).toContain('data-slot="ecg-detail-error"');
// The failure is a recoverable card, not a muted "no data" line.
expect(html).toContain('data-slot="query-error-card"');
expect(html).toContain('data-slot="query-error-retry"');
expect(html).not.toContain('data-slot="ecg-trace"');
});
});
+8 -9
View File
@@ -7,6 +7,7 @@ import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { EcgWaveform } from "@/components/insights/ecg-waveform";
import { InsightSectionCard } from "@/components/insights/insight-section-card";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import {
ecgResultLabel,
isNonNormalEcg,
@@ -41,7 +42,7 @@ export function EcgDetail({ recordingId }: { recordingId: string }) {
const { t } = useTranslations();
const fmt = useFormatters();
const { data, isLoading, isError } = useQuery({
const { data, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.insightsEcgDetail(recordingId, false),
queryFn: async () => {
try {
@@ -55,15 +56,13 @@ export function EcgDetail({ recordingId }: { recordingId: string }) {
});
if (isError) {
// A failed read must never read as "no data" (UI-STANDARDS §6).
// A failed read must never read as "no data" (UI-STANDARDS §6), and the
// alert is content, not muted meta — it also has to be recoverable.
return (
<p
data-slot="ecg-detail-error"
role="alert"
className="text-muted-foreground text-sm"
>
{t("insights.ecg.loadError")}
</p>
<QueryErrorCard
title={t("insights.ecg.loadError")}
onRetry={() => refetch()}
/>
);
}
@@ -0,0 +1,53 @@
/**
* The mood-insights regions used to render nothing on a failed read, by
* documented intent ("degrades gracefully to the line chart"). The maintainer
* decision is to enforce §6 here like everywhere else: a failed read is not an
* empty page. The shared query is forced into its error state; the main "rest"
* region has to surface a `query-error-row` with retry (mirroring the loading
* convention, which also only paints in the "rest" region).
*/
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1" },
isLoading: false,
isAuthenticated: true,
error: null,
refetch: () => {},
}),
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: undefined,
isLoading: false,
isError: true,
refetch: vi.fn(),
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { MoodInsightsSections } from "../mood-insights-sections";
function render(region: "heatmap" | "assessment" | "rest") {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MoodInsightsSections region={region} />
</I18nProvider>,
);
}
describe("<MoodInsightsSections> — a read failure is honest, not empty", () => {
it("surfaces a query-error row with retry in the rest region", () => {
const html = render("rest");
expect(html).toContain('data-slot="query-error-row"');
expect(html).toContain('data-slot="query-error-row-retry"');
});
it("stays quiet in the heatmap/assessment regions to avoid stacking rows", () => {
expect(render("heatmap")).toBe("");
expect(render("assessment")).toBe("");
});
});
@@ -7,6 +7,7 @@ import { CalendarDays, Sparkles } from "lucide-react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
@@ -100,7 +101,7 @@ export function MoodInsightsSections({
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const { data, isLoading } = useQuery({
const { data, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.moodInsights(),
queryFn: async () => {
return apiGet<MoodInsightsResponse>("/api/mood/insights");
@@ -118,6 +119,15 @@ export function MoodInsightsSections({
) : null;
}
if (isError) {
// §6 — a failed read is not an empty page. Surface it once, in the main
// "rest" region (mirroring the loading convention above), rather than
// stacking an identical row in all three regions down the page.
return region === "rest" ? (
<QueryErrorRow onRetry={() => refetch()} />
) : null;
}
if (!data || data.summary.totalEntries === 0) {
return null;
}
@@ -0,0 +1,57 @@
/**
* A reminder-read failure on the dashboard Vorsorge tile must not read as "no
* upcoming checkup reminders" — a missed preventive-care nudge is the harm
* behind that fall-through. `useMeasurementReminders` is forced into its error
* state; the tile has to render a `query-error-row` with retry and never the
* empty copy.
*
* SSR-static render; the query-backed hooks are mocked so the error branch is
* synchronous.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@/hooks/use-measurement-reminders", () => ({
useMeasurementReminders: () => ({
data: undefined,
isError: true,
refetch: vi.fn(),
}),
useMeasurementReminderMutations: () => ({
satisfy: { mutate: vi.fn(), isPending: false },
}),
}));
vi.mock("@/hooks/use-encounters", () => ({
useEncounters: () => ({ data: { upcoming: [] } }),
}));
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({ canManage: true, canAdd: true }),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/",
useSearchParams: () => new URLSearchParams(),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { VorsorgeDashboardCard } from "../vorsorge-dashboard-card";
function render() {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<VorsorgeDashboardCard />
</I18nProvider>,
);
}
describe("<VorsorgeDashboardCard> — a read failure is honest, not empty", () => {
it("renders the query-error row with retry and never the empty copy", () => {
const html = render();
expect(html).toContain('data-slot="query-error-row"');
expect(html).toContain('data-slot="query-error-row-retry"');
expect(html).not.toContain("No upcoming checkup reminders");
});
});
@@ -89,6 +89,21 @@ describe("<VorsorgeSection> loading + empty", () => {
expect(html).not.toContain('data-slot="empty-state"');
});
it("surfaces a read failure through the shared query-error primitive with retry", () => {
remindersMock.mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
refetch: vi.fn(),
});
const html = render(<VorsorgeSection />);
expect(html).toContain('data-slot="query-error-row"');
expect(html).toContain('data-slot="query-error-row-retry"');
// Not the empty state, and no longer the muted bespoke row.
expect(html).not.toContain('data-slot="empty-state"');
expect(html).not.toContain('data-slot="vorsorge-error"');
});
it("routes the no-data case through the shared EmptyState with an action", () => {
remindersMock.mockReturnValue({ data: [], isLoading: false });
const html = render(<VorsorgeSection />);
@@ -32,6 +32,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ListRow } from "@/components/ui/list-row";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { MeasurementForm } from "@/components/measurements/measurement-form";
@@ -78,7 +79,7 @@ export function VorsorgeDashboardCard() {
// the dashboard summary offering what the dedicated page withholds was the
// tell that this row had never been asked.
const { canManage } = useRecordCapabilities();
const { data: reminders, isError } = useMeasurementReminders();
const { data: reminders, isError, refetch } = useMeasurementReminders();
// A query's `isLoading` is not a hydration-safe branch: TanStack reports the
// optimistic mount fetch on the client's very first render and cannot on the
@@ -153,6 +154,10 @@ export function VorsorgeDashboardCard() {
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : isError ? (
// A read failure must not read as "no upcoming reminders" — a missed
// checkup is the harm behind that fall-through (§6).
<QueryErrorRow onRetry={() => refetch()} />
) : upcoming.length === 0 ? (
<div className="space-y-2">
<p className="text-muted-foreground text-sm">
@@ -38,7 +38,6 @@ import {
CalendarPlus,
CheckCircle2,
Plus,
RefreshCw,
Wrench,
} from "lucide-react";
@@ -76,6 +75,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { DateField } from "@/components/ui/date-field";
import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { LineChart, MoreVertical, Pencil, Trash2 } from "lucide-react";
@@ -703,23 +703,10 @@ export function VorsorgeSection({
)}
{!isLoading && isError && (
<div
role="alert"
data-slot="vorsorge-error"
className="text-muted-foreground flex flex-col items-start gap-3 py-8 text-sm sm:flex-row sm:items-center sm:justify-between"
>
<span>{t("measurementReminders.loadError")}</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => void refetch()}
className="gap-1.5"
>
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("common.retry")}</span>
</Button>
</div>
<QueryErrorRow
message={t("measurementReminders.loadError")}
onRetry={() => void refetch()}
/>
)}
{!isLoading && !isError && (reminders?.length ?? 0) === 0 && (
@@ -0,0 +1,63 @@
/**
* A read failure on the API-token list must not read as "no active tokens".
* `useQuery` is forced into its error state; the card has to render the
* `query-error-card` slot with retry and must never fall through to the empty
* state — the same §6 fall-through the share-link surface had, on another
* security surface.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/settings/api",
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: undefined,
isLoading: false,
isError: true,
refetch: vi.fn(),
}),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
useMutation: () => ({
mutate: vi.fn(),
isPending: false,
isError: false,
error: null,
}),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", username: "testuser", role: "USER" },
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { ApiSection } from "../api-section";
function render() {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<ApiSection />
</I18nProvider>,
);
}
describe("ApiSection — a token-read failure is honest, not empty", () => {
it("renders the query-error card with retry and never the empty state", () => {
const html = render();
expect(html).toContain('data-slot="query-error-card"');
expect(html).toContain('data-slot="query-error-retry"');
expect(html).not.toContain(
'data-testid="settings-api-tokens-active-empty"',
);
expect(html).not.toContain("No active tokens");
});
});
@@ -0,0 +1,51 @@
/**
* A read failure on the MCP connector cards (connected assistants, connector
* tokens) must not read as "nothing connected / no tokens". Both queries are
* forced into their error state; each card has to render the `query-error-card`
* slot with retry and never fall through to its empty copy — the §6
* fall-through on another security surface.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: undefined,
isLoading: false,
isError: true,
refetch: vi.fn(),
}),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", role: "USER", modules: { mcp: true } },
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { McpSection } from "../mcp-section";
function render() {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<McpSection />
</I18nProvider>,
);
}
describe("McpSection — a read failure is honest, not empty", () => {
it("renders query-error cards with retry and never the empty copy on error", () => {
const html = render();
expect(html).toContain('data-slot="query-error-card"');
expect(html).toContain('data-slot="query-error-retry"');
// Neither empty copy may appear when the reads failed.
expect(html).not.toContain("No connected assistants");
expect(html).not.toContain("No active tokens");
});
});
@@ -0,0 +1,65 @@
/**
* The bare-destructive read-failure family: several settings cards rendered a
* failed read as a bare `text-destructive` line with no way to recover. Each is
* forced into its query error state and must now render the shared
* `query-error-row` primitive with a retry control — an alert is foreground
* content, and a read failure has to be retryable.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: undefined,
isLoading: false,
isError: true,
refetch: vi.fn(),
}),
useMutation: () => ({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
isError: false,
reset: vi.fn(),
}),
useQueryClient: () => ({ invalidateQueries: vi.fn(), setQueryData: vi.fn() }),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/settings",
useSearchParams: () => new URLSearchParams(),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { SecuritySessionsCard } from "../security-sessions-card";
import { SecurityActivityCard } from "../security-activity-card";
import { TrustedDevicesCard } from "../trusted-devices-card";
import { CoachMemorySection } from "../coach-memory-section";
import { CoachRemindersSection } from "../coach-reminders-section";
import { AboutMeSection } from "../about-me-section";
function renderCard(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
const cases: Array<[string, () => React.ReactNode]> = [
["SecuritySessionsCard", () => <SecuritySessionsCard isAuthenticated />],
["SecurityActivityCard", () => <SecurityActivityCard isAuthenticated />],
["TrustedDevicesCard", () => <TrustedDevicesCard isAuthenticated />],
["CoachMemorySection", () => <CoachMemorySection isAuthenticated />],
["CoachRemindersSection", () => <CoachRemindersSection isAuthenticated />],
["AboutMeSection", () => <AboutMeSection isAuthenticated />],
];
describe("settings read-error family — a read failure is foreground and recoverable", () => {
it.each(cases)("%s renders a query-error row with retry", (_name, node) => {
const html = renderCard(node());
expect(html).toContain('data-slot="query-error-row"');
expect(html).toContain('data-slot="query-error-row-retry"');
// No bare red line: the alert is the primitive now.
expect(html).not.toContain('<p role="alert" class="text-destructive');
});
});
@@ -0,0 +1,54 @@
/**
* A read failure on the owner share-link surface must not read as "no active
* links". `useQuery` is forced into its error state; the card has to render the
* `query-error-card` slot with a retry affordance and must never fall through
* to the empty-state copy — that fall-through is the §6-forbidden case a
* clinical sharing surface cannot afford (a failed read is indistinguishable
* from "you have shared nothing").
*
* SSR-static render, matching the sibling sharing-section test.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", role: "USER" },
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
}),
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: undefined,
isError: true,
isLoading: false,
refetch: vi.fn(),
}),
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { SharingSection } from "../sharing-section";
function render() {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<SharingSection />
</I18nProvider>,
);
}
describe("<SharingSection> — a read failure is honest, not empty", () => {
it("renders the query-error card with retry and never the empty state", () => {
const html = render();
expect(html).toContain('data-slot="query-error-card"');
expect(html).toContain('data-slot="query-error-retry"');
// The empty-state must not appear: a failed read is not "no active links".
expect(html).not.toContain('data-testid="share-active-empty"');
expect(html).not.toContain("No active share links");
});
});
+5 -7
View File
@@ -49,6 +49,7 @@ import {
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { SettingsInfoTile } from "@/components/settings/_info-tile";
import { Label } from "@/components/ui/label";
import { ConfirmButton } from "@/components/ui/confirm-button";
@@ -325,13 +326,10 @@ export function AboutMeSection({
)}
{query.isError && (
<p
role="status"
aria-live="polite"
className="text-destructive text-sm"
>
{t("settings.ai.aboutMe.loadError")}
</p>
<QueryErrorRow
message={t("settings.ai.aboutMe.loadError")}
onRetry={() => query.refetch()}
/>
)}
<p className="text-muted-foreground border-border border-t pt-3 text-xs">
@@ -0,0 +1,46 @@
/**
* When the AI settings reads fail, the card must not render the provider form
* as if it were configured a certain way — a swallowed error there is a lie
* about the account's provider state. Every read is forced into its error
* state; the card has to render a `query-error-card` with retry and must not
* paint the active-provider select.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/settings/ai",
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: undefined,
isError: true,
isLoading: false,
refetch: vi.fn(),
}),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { AiInsightsCard } from "../ai-insights-card";
function render() {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<AiInsightsCard isAuthenticated={true} />
</I18nProvider>,
);
}
describe("<AiInsightsCard> — a settings-read failure is honest, not a default form", () => {
it("renders the query-error card with retry and never the provider form", () => {
const html = render();
expect(html).toContain('data-slot="query-error-card"');
expect(html).toContain('data-slot="query-error-retry"');
expect(html).not.toContain('data-testid="ai-active-provider-select"');
});
});
+81 -51
View File
@@ -22,6 +22,7 @@ import { Badge } from "@/components/ui/badge";
import { MedicalDisclaimer } from "@/components/common/medical-disclaimer";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { apiFetchRaw } from "@/lib/api/api-fetch";
@@ -61,39 +62,62 @@ export function AiInsightsCard({
const pathname = usePathname();
const searchParams = useSearchParams();
const { data: insightsSettings } = useQuery({
// A read failure must surface, not be swallowed into a null that renders the
// default provider form as if the account were configured that way (§6). The
// queryFns throw on a non-OK response so the query reports `isError`, and the
// card paints an honest error instead of a misleading form.
const {
data: insightsSettings,
isError: settingsError,
refetch: refetchSettings,
} = useQuery({
queryKey: queryKeys.insightsSettings(),
queryFn: async () => {
const res = await apiFetchRaw("/api/insights/settings");
if (!res.ok) return null;
if (!res.ok) throw new Error("insights settings read failed");
const json = await res.json();
return json.data as InsightsSettings;
},
enabled: isAuthenticated,
});
const { data: userProvider } = useQuery({
const {
data: userProvider,
isError: providerError,
refetch: refetchProvider,
} = useQuery({
queryKey: queryKeys.userAiProvider(),
queryFn: async () => {
const res = await apiFetchRaw("/api/user/ai-provider");
if (!res.ok) return null;
if (!res.ok) throw new Error("user AI provider read failed");
const json = await res.json();
return json.data as UserAIProvider;
},
enabled: isAuthenticated,
});
const { data: chainData } = useQuery({
const {
data: chainData,
isError: chainError,
refetch: refetchChain,
} = useQuery({
queryKey: queryKeys.insightsProviderChain(),
queryFn: async () => {
const res = await apiFetchRaw("/api/insights/provider-chain");
if (!res.ok) return null;
if (!res.ok) throw new Error("insights provider chain read failed");
const json = await res.json();
return json.data as ProviderChainData;
},
enabled: isAuthenticated,
});
const hasReadError = settingsError || providerError || chainError;
function retryReads() {
void refetchSettings();
void refetchProvider();
void refetchChain();
}
// v1.16.13 — heal the web AI-consent receipt on mount. The server-side
// consent gate (admin-openai egress) requires an active `ai_full`
// receipt; the web client never minted one, so existing web users on a
@@ -160,65 +184,71 @@ export function AiInsightsCard({
/>
<div className="space-y-4">
<ActiveProviderSelect
value={selectedProvider}
onChange={pickProvider}
/>
{hasReadError ? (
<QueryErrorCard onRetry={retryReads} />
) : (
<>
<ActiveProviderSelect
value={selectedProvider}
onChange={pickProvider}
/>
<ProviderConfigCard
provider={selectedProvider}
insightsSettings={insightsSettings}
userProvider={userProvider}
/>
<ProviderConfigCard
provider={selectedProvider}
insightsSettings={insightsSettings}
userProvider={userProvider}
/>
<FallbackChainCard
chain={chainData?.configuredChain ?? []}
selected={selectedProvider}
onSelect={pickProvider}
/>
<FallbackChainCard
chain={chainData?.configuredChain ?? []}
selected={selectedProvider}
onSelect={pickProvider}
/>
{/* v1.22 (#89) — per-user response timeout (mainly for slow
{/* v1.22 (#89) — per-user response timeout (mainly for slow
local/self-hosted backends). */}
<ResponseTimeoutCard userProvider={userProvider} />
<ResponseTimeoutCard userProvider={userProvider} />
{/* Read uploaded vault documents automatically with AI (opt-in). */}
<AutoReadCard />
{/* Read uploaded vault documents automatically with AI (opt-in). */}
<AutoReadCard />
{/* Use the operator's shared central Codex connection (opt-in; only
{/* Use the operator's shared central Codex connection (opt-in; only
renders when the operator has connected it). */}
<CentralCodexSwitch settings={insightsSettings} />
<CentralCodexSwitch settings={insightsSettings} />
{/* The standing consent and the one control that withdraws it. It
{/* The standing consent and the one control that withdraws it. It
sits last because it governs everything above rather than
configuring any single provider. */}
<AiConsentCard isAuthenticated={isAuthenticated} />
<AiConsentCard isAuthenticated={isAuthenticated} />
<RuntimeActionsRow
provider={selectedProvider}
userProvider={userProvider}
canRegenerate={
insightsSettings?.codexStatus === "connected" ||
insightsSettings?.hasAdminKey ||
Boolean(userProvider?.provider)
}
privacyMode={insightsSettings?.privacyMode ?? "aggregated"}
lastInsightAt={insightsSettings?.lastInsightAt ?? null}
onRegenerated={() =>
queryClient.invalidateQueries({
queryKey: queryKeys.insightsRoot(),
})
}
onPrivacyChanged={() =>
queryClient.invalidateQueries({
queryKey: queryKeys.insightsRoot(),
})
}
/>
<RuntimeActionsRow
provider={selectedProvider}
userProvider={userProvider}
canRegenerate={
insightsSettings?.codexStatus === "connected" ||
insightsSettings?.hasAdminKey ||
Boolean(userProvider?.provider)
}
privacyMode={insightsSettings?.privacyMode ?? "aggregated"}
lastInsightAt={insightsSettings?.lastInsightAt ?? null}
onRegenerated={() =>
queryClient.invalidateQueries({
queryKey: queryKeys.insightsRoot(),
})
}
onPrivacyChanged={() =>
queryClient.invalidateQueries({
queryKey: queryKeys.insightsRoot(),
})
}
/>
{/* v1.25 — the data-posture statement lives where the provider key is
{/* v1.25 — the data-posture statement lives where the provider key is
configured: self-hosted, data stays on this instance, BYOK / local
model, no third-party analytics. One calm line, not a banner. */}
<MedicalDisclaimer variant="dataPosture" />
<MedicalDisclaimer variant="dataPosture" />
</>
)}
</div>
</SettingsCard>
);
@@ -105,8 +105,11 @@ export function AutoReadCard() {
role="note"
className="border-border space-y-3 rounded-lg border border-dashed px-3 py-2.5"
>
<div className="text-muted-foreground flex items-start gap-2 text-xs">
<ShieldAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
<div className="flex items-start gap-2 text-sm">
<ShieldAlert
className="text-muted-foreground mt-0.5 size-3.5 shrink-0"
aria-hidden
/>
<p className="min-w-0">{t("settings.ai.autoRead.honesty")}</p>
</div>
<div className="flex flex-wrap gap-2">
@@ -104,8 +104,11 @@ export function CentralCodexSwitch({
role="note"
className="border-border space-y-3 rounded-lg border border-dashed px-3 py-2.5"
>
<div className="text-muted-foreground flex items-start gap-2 text-xs">
<ShieldAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
<div className="flex items-start gap-2 text-sm">
<ShieldAlert
className="text-muted-foreground mt-0.5 size-3.5 shrink-0"
aria-hidden
/>
<p className="min-w-0">{t("settings.ai.centralCodex.honesty")}</p>
</div>
<div className="flex flex-wrap gap-2">
+292 -271
View File
@@ -19,6 +19,8 @@ import {
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useAuth } from "@/hooks/use-auth";
@@ -150,7 +152,12 @@ function ApiTokensCard() {
const queryClient = useQueryClient();
const [showRevokedTokens, setShowRevokedTokens] = useState(false);
const { data: tokens } = useQuery({
const {
data: tokens,
isError,
isLoading,
refetch,
} = useQuery({
queryKey: queryKeys.tokens(),
queryFn: async () => {
return apiGet<ApiTokenInfo[]>("/api/tokens");
@@ -215,87 +222,196 @@ function ApiTokensCard() {
medication. This card lists and revokes. */}
<p className="text-sm">{t("settings.tokenMintMovedDescription")}</p>
<div>
<p className="mb-2 text-sm font-medium">
{t("settings.activeTokensTitle")}
</p>
{/* Desktop table — verbatim layout for md+, hidden on phones. */}
<div className="border-border hidden overflow-x-auto rounded-lg border md:block">
<table className="w-full min-w-[860px] text-sm md:min-w-0">
<thead>
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTableName")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTablePermissions")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTableStatus")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTableCreated")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTableLastUsed")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("settings.tokenTableActions")}
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
{activeTokens.length === 0 && (
<tr>
<td
colSpan={6}
className="text-muted-foreground px-3 py-4 text-center text-sm"
>
{t("settings.noActiveTokens")}
</td>
</tr>
)}
{activeTokens.map((tok, index) => {
const isExpired =
tok.expiresAt && new Date(tok.expiresAt) < new Date();
return (
<tr
key={tok.id}
className={index % 2 === 0 ? "bg-muted/20" : ""}
>
<td className="px-3 py-2 font-medium">{tok.name}</td>
<td className="text-muted-foreground px-3 py-2 text-xs">
{tok.permissions.join(", ")}
</td>
<td className="px-3 py-2">
{isExpired ? (
<Badge variant="destructive" className="text-xs">
{t("settings.tokenExpired")}
</Badge>
) : (
<Badge className="bg-success/15 text-success text-xs">
{t("settings.tokenActive")}
</Badge>
)}
</td>
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
{formatDate(tok.createdAt)}
</td>
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
{tok.lastUsedAt
? formatDateTime(tok.lastUsedAt)
: t("settings.tokenNeverUsed")}
</td>
<td className="px-3 py-2 text-right">
{isError ? (
// A read failure must not read as "no active tokens" on this
// security surface — the §6 fall-through the token list must avoid.
<QueryErrorCard onRetry={() => refetch()} />
) : isLoading ? (
<div data-testid="settings-api-tokens-loading">
<Skeleton className="h-40 w-full rounded-lg" />
</div>
) : (
<>
<div>
<p className="mb-2 text-sm font-medium">
{t("settings.activeTokensTitle")}
</p>
{/* Desktop table — verbatim layout for md+, hidden on phones. */}
<div className="border-border hidden overflow-x-auto rounded-lg border md:block">
<table className="w-full min-w-[860px] text-sm md:min-w-0">
<thead>
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTableName")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTablePermissions")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTableStatus")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTableCreated")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTableLastUsed")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("settings.tokenTableActions")}
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
{activeTokens.length === 0 && (
<tr>
<td
colSpan={6}
className="text-muted-foreground px-3 py-4 text-center text-sm"
>
{t("settings.noActiveTokens")}
</td>
</tr>
)}
{activeTokens.map((tok, index) => {
const isExpired =
tok.expiresAt && new Date(tok.expiresAt) < new Date();
return (
<tr
key={tok.id}
className={index % 2 === 0 ? "bg-muted/20" : ""}
>
<td className="px-3 py-2 font-medium">{tok.name}</td>
<td className="text-muted-foreground px-3 py-2 text-xs">
{tok.permissions.join(", ")}
</td>
<td className="px-3 py-2">
{isExpired ? (
<Badge variant="destructive" className="text-xs">
{t("settings.tokenExpired")}
</Badge>
) : (
<Badge className="bg-success/15 text-success text-xs">
{t("settings.tokenActive")}
</Badge>
)}
</td>
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
{formatDate(tok.createdAt)}
</td>
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
{tok.lastUsedAt
? formatDateTime(tok.lastUsedAt)
: t("settings.tokenNeverUsed")}
</td>
<td className="px-3 py-2 text-right">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-destructive h-9 w-9"
aria-label={t("settings.tokenRevokeAction")}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.tokenRevoke")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.tokenRevokeDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => handleRevoke(tok.id)}
>
{t("settings.tokenRevoked")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Mobile card list — each row stacks its columns vertically
with explicit labels so the data is readable inside a
narrow viewport. The revoke action stays full-width to
clear the 44pt tap-target floor. */}
{activeTokens.length === 0 ? (
<EmptyState
className="md:hidden"
size="compact"
data-testid="settings-api-tokens-active-empty"
title={t("settings.noActiveTokens")}
/>
) : (
<ul
className="space-y-2 md:hidden"
data-testid="settings-api-tokens-mobile-list"
>
{activeTokens.map((tok) => {
const isExpired =
tok.expiresAt && new Date(tok.expiresAt) < new Date();
return (
<li
key={tok.id}
className="bg-muted/30 border-border space-y-2 rounded-lg border p-3"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<p className="min-w-0 flex-1 text-sm font-medium break-all">
{tok.name}
</p>
{isExpired ? (
<Badge variant="destructive" className="text-xs">
{t("settings.tokenExpired")}
</Badge>
) : (
<Badge className="bg-success/15 text-success text-xs">
{t("settings.tokenActive")}
</Badge>
)}
</div>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.tokenTablePermissions")}:
</span>{" "}
{tok.permissions.join(", ")}
</p>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.tokenTableCreated")}:
</span>{" "}
{formatDate(tok.createdAt)}
</p>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.tokenTableLastUsed")}:
</span>{" "}
{tok.lastUsedAt
? formatDateTime(tok.lastUsedAt)
: t("settings.tokenNeverUsed")}
</p>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-destructive h-9 w-9"
aria-label={t("settings.tokenRevokeAction")}
variant="outline"
size="sm"
className="text-destructive border-destructive/30 min-h-11 w-full"
>
<Trash2 className="h-3.5 w-3.5" />
{t("settings.tokenRevoke")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
@@ -320,212 +436,117 @@ function ApiTokensCard() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</li>
);
})}
</ul>
)}
</div>
{/* Mobile card list — each row stacks its columns vertically
with explicit labels so the data is readable inside a
narrow viewport. The revoke action stays full-width to
clear the 44pt tap-target floor. */}
{activeTokens.length === 0 ? (
<EmptyState
className="md:hidden"
size="compact"
data-testid="settings-api-tokens-active-empty"
title={t("settings.noActiveTokens")}
/>
) : (
<ul
className="space-y-2 md:hidden"
data-testid="settings-api-tokens-mobile-list"
>
{activeTokens.map((tok) => {
const isExpired =
tok.expiresAt && new Date(tok.expiresAt) < new Date();
return (
<li
key={tok.id}
className="bg-muted/30 border-border space-y-2 rounded-lg border p-3"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<p className="min-w-0 flex-1 text-sm font-medium break-all">
{tok.name}
</p>
{isExpired ? (
<Badge variant="destructive" className="text-xs">
{t("settings.tokenExpired")}
</Badge>
) : (
<Badge className="bg-success/15 text-success text-xs">
{t("settings.tokenActive")}
</Badge>
)}
{revokedTokens.length > 0 && (
<div className="space-y-2">
<button
type="button"
onClick={() => setShowRevokedTokens((prev) => !prev)}
className="text-foreground hover:text-primary flex items-center gap-1 text-sm font-medium transition-colors"
>
<ChevronDown
className={`h-4 w-4 transition-transform ${showRevokedTokens ? "rotate-180" : ""}`}
/>
{t("settings.revokedTokensTitle", {
count: revokedTokens.length,
})}
</button>
{showRevokedTokens && (
<>
{/* Desktop table — verbatim layout for md+. */}
<div className="border-border hidden overflow-x-auto rounded-lg border md:block">
<table className="w-full min-w-[760px] text-sm md:min-w-0">
<thead>
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTableName")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTablePermissions")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTableCreated")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTableLastUsed")}
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
{revokedTokens.map((tok, index) => (
<tr
key={tok.id}
className={index % 2 === 0 ? "bg-muted/20" : ""}
>
<td className="px-3 py-2 font-medium">
{tok.name}
</td>
<td className="text-muted-foreground px-3 py-2 text-xs">
{tok.permissions.join(", ")}
</td>
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
{formatDate(tok.createdAt)}
</td>
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
{tok.lastUsedAt
? formatDateTime(tok.lastUsedAt)
: t("settings.tokenNeverUsed")}
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.tokenTablePermissions")}:
</span>{" "}
{tok.permissions.join(", ")}
</p>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.tokenTableCreated")}:
</span>{" "}
{formatDate(tok.createdAt)}
</p>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.tokenTableLastUsed")}:
</span>{" "}
{tok.lastUsedAt
? formatDateTime(tok.lastUsedAt)
: t("settings.tokenNeverUsed")}
</p>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="text-destructive border-destructive/30 min-h-11 w-full"
>
<Trash2 className="h-3.5 w-3.5" />
{t("settings.tokenRevoke")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.tokenRevoke")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.tokenRevokeDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => handleRevoke(tok.id)}
>
{t("settings.tokenRevoked")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</li>
);
})}
</ul>
)}
</div>
{revokedTokens.length > 0 && (
<div className="space-y-2">
<button
type="button"
onClick={() => setShowRevokedTokens((prev) => !prev)}
className="text-foreground hover:text-primary flex items-center gap-1 text-sm font-medium transition-colors"
>
<ChevronDown
className={`h-4 w-4 transition-transform ${showRevokedTokens ? "rotate-180" : ""}`}
/>
{t("settings.revokedTokensTitle", {
count: revokedTokens.length,
})}
</button>
{showRevokedTokens && (
<>
{/* Desktop table — verbatim layout for md+. */}
<div className="border-border hidden overflow-x-auto rounded-lg border md:block">
<table className="w-full min-w-[760px] text-sm md:min-w-0">
<thead>
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTableName")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTablePermissions")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTableCreated")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTableLastUsed")}
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
{revokedTokens.map((tok, index) => (
<tr
{/* Mobile card list — stacked meta layout, no
horizontal scroll. Revoked tokens are read-only so
no action footer is needed. */}
<ul
className="space-y-2 md:hidden"
data-testid="settings-api-tokens-revoked-mobile-list"
>
{revokedTokens.map((tok) => (
<li
key={tok.id}
className={index % 2 === 0 ? "bg-muted/20" : ""}
className="bg-muted/30 border-border space-y-1.5 rounded-lg border p-3"
>
<td className="px-3 py-2 font-medium">{tok.name}</td>
<td className="text-muted-foreground px-3 py-2 text-xs">
<p className="text-sm font-medium break-all">
{tok.name}
</p>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.tokenTablePermissions")}:
</span>{" "}
{tok.permissions.join(", ")}
</td>
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
</p>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.tokenTableCreated")}:
</span>{" "}
{formatDate(tok.createdAt)}
</td>
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
</p>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.tokenTableLastUsed")}:
</span>{" "}
{tok.lastUsedAt
? formatDateTime(tok.lastUsedAt)
: t("settings.tokenNeverUsed")}
</td>
</tr>
</p>
</li>
))}
</tbody>
</table>
</div>
{/* Mobile card list — stacked meta layout, no
horizontal scroll. Revoked tokens are read-only so
no action footer is needed. */}
<ul
className="space-y-2 md:hidden"
data-testid="settings-api-tokens-revoked-mobile-list"
>
{revokedTokens.map((tok) => (
<li
key={tok.id}
className="bg-muted/30 border-border space-y-1.5 rounded-lg border p-3"
>
<p className="text-sm font-medium break-all">
{tok.name}
</p>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.tokenTablePermissions")}:
</span>{" "}
{tok.permissions.join(", ")}
</p>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.tokenTableCreated")}:
</span>{" "}
{formatDate(tok.createdAt)}
</p>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.tokenTableLastUsed")}:
</span>{" "}
{tok.lastUsedAt
? formatDateTime(tok.lastUsedAt)
: t("settings.tokenNeverUsed")}
</p>
</li>
))}
</ul>
</>
</ul>
</>
)}
</div>
)}
</div>
</>
)}
</div>
</SettingsCard>
@@ -34,6 +34,7 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { EmptyState } from "@/components/ui/empty-state";
@@ -152,13 +153,10 @@ export function CoachMemorySection({
<p className="text-sm">{t("settings.ai.coachMemory.detail")}</p>
{query.isError && (
<p
role="status"
aria-live="polite"
className="text-destructive text-sm"
>
{t("settings.ai.coachMemory.loadError")}
</p>
<QueryErrorRow
message={t("settings.ai.coachMemory.loadError")}
onRetry={() => query.refetch()}
/>
)}
{!query.isError && facts.length === 0 ? (
@@ -22,6 +22,7 @@ import { BellRing, Check, Trash2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { formatDateOrRelative } from "@/lib/format";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -165,13 +166,10 @@ export function CoachRemindersSection({
<p className="text-sm">{t("settings.ai.coachReminders.detail")}</p>
{query.isError && (
<p
role="status"
aria-live="polite"
className="text-destructive text-sm"
>
{t("settings.ai.coachReminders.loadError")}
</p>
<QueryErrorRow
message={t("settings.ai.coachReminders.loadError")}
onRetry={() => query.refetch()}
/>
)}
{!query.isError && reminders.length === 0 ? (
@@ -1010,9 +1010,13 @@ export function ManagedRecordSettingsSection({
saveLabel={t("common.save")}
settings={query.data.settings}
/>
{save.isError || validationFailed ? (
{validationFailed ? (
<p className="text-destructive text-sm" role="alert">
{t("common.networkError")}
{t("settings.sharedRecord.managedSettings.validationError")}
</p>
) : save.isError ? (
<p className="text-destructive text-sm" role="alert">
{t("settings.sharedRecord.managedSettings.saveError")}
</p>
) : null}
</>
+26 -8
View File
@@ -35,6 +35,8 @@ import {
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -77,7 +79,12 @@ function McpConnectionsCard() {
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const { data: connections } = useQuery({
const {
data: connections,
isError,
isLoading,
refetch,
} = useQuery({
queryKey: queryKeys.mcpConnections(),
queryFn: async () => apiGet<McpConnectionInfo[]>("/api/mcp/connections"),
enabled: isAuthenticated,
@@ -110,7 +117,11 @@ function McpConnectionsCard() {
<p className="text-sm">{t("settings.mcp.connectionsDetail")}</p>
<div className="space-y-4">
{list.length === 0 ? (
{isError ? (
<QueryErrorCard onRetry={() => refetch()} />
) : isLoading ? (
<Skeleton className="h-24 w-full rounded-lg" />
) : list.length === 0 ? (
<p className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-center text-sm">
{t("settings.mcp.noConnections")}
</p>
@@ -264,7 +275,12 @@ function McpTokensCard() {
const [tokenMsg, setTokenMsg] = useState<string | null>(null);
const [tokenCopied, setTokenCopied] = useState(false);
const { data: tokens } = useQuery({
const {
data: tokens,
isError,
isLoading,
refetch,
} = useQuery({
queryKey: queryKeys.mcpTokens(),
queryFn: async () => apiGet<McpTokenInfo[]>("/api/mcp/tokens"),
enabled: isAuthenticated,
@@ -340,9 +356,7 @@ function McpTokensCard() {
<p className="text-sm">{t("settings.mcp.tokensDetail")}</p>
<div className="space-y-4">
<p className="text-muted-foreground text-xs leading-relaxed">
{t("settings.mcp.scopeNote")}
</p>
<p className="text-sm leading-relaxed">{t("settings.mcp.scopeNote")}</p>
<div className="border-border bg-muted/30 space-y-2 rounded-lg border p-3">
<div className="flex items-center justify-between gap-4">
@@ -355,7 +369,7 @@ function McpTokensCard() {
aria-label={t("settings.mcp.writeScopeToggleLabel")}
/>
</div>
<p className="text-muted-foreground text-xs leading-relaxed">
<p className="text-sm leading-relaxed">
{t("settings.mcp.writeScopeNote")}
</p>
</div>
@@ -421,7 +435,11 @@ function McpTokensCard() {
<p className="mb-2 text-sm font-medium">
{t("settings.activeTokensTitle")}
</p>
{activeTokens.length === 0 ? (
{isError ? (
<QueryErrorCard onRetry={() => refetch()} />
) : isLoading ? (
<Skeleton className="h-24 w-full rounded-lg" />
) : activeTokens.length === 0 ? (
<p className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-center text-sm">
{t("settings.noActiveTokens")}
</p>
@@ -15,6 +15,7 @@ import { ChevronDown, Loader2, ShieldCheck } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
@@ -62,7 +63,7 @@ export function SecurityActivityCard({
const [open, setOpen] = useState(false);
const regionId = useId();
const { data, isLoading, isError } = useQuery({
const { data, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.securityActivity(),
queryFn: () =>
apiGet<{ events: ActivityRow[] }>("/api/auth/me/security-activity"),
@@ -102,9 +103,10 @@ export function SecurityActivityCard({
)}
{isError && (
<p role="alert" className="text-destructive text-sm">
{t("settings.security.activityLoadError")}
</p>
<QueryErrorRow
message={t("settings.security.activityLoadError")}
onRetry={() => refetch()}
/>
)}
{!isLoading && !isError && events.length === 0 && (
@@ -18,6 +18,7 @@ import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet, apiDelete } from "@/lib/api/api-fetch";
@@ -48,7 +49,7 @@ export function SecuritySessionsCard({
const [open, setOpen] = useState(false);
const regionId = useId();
const { data, isLoading, isError } = useQuery({
const { data, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.sessions(),
queryFn: () => apiGet<{ sessions: SessionRow[] }>("/api/auth/me/sessions"),
enabled: isAuthenticated,
@@ -117,9 +118,10 @@ export function SecuritySessionsCard({
)}
{isError && (
<p role="alert" className="text-destructive text-sm">
{t("settings.security.sessionsLoadError")}
</p>
<QueryErrorRow
message={t("settings.security.sessionsLoadError")}
onRetry={() => refetch()}
/>
)}
{!isLoading && !isError && sessions.length === 0 && (
@@ -19,6 +19,7 @@ import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta";
@@ -152,9 +153,10 @@ export function ShareDocumentPicker({
))}
</div>
) : list.isError ? (
<p role="alert" className="text-destructive text-sm">
{t("documents.list.loadError")}
</p>
<QueryErrorRow
message={t("documents.list.loadError")}
onRetry={() => list.refetch()}
/>
) : documents.length === 0 ? (
<p className="text-muted-foreground py-4 text-center text-sm">
{t("settings.sharing.pickerEmpty")}
+188 -168
View File
@@ -39,6 +39,8 @@ import {
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import {
@@ -106,7 +108,12 @@ function ShareLinksCard() {
expiryDays: number;
} | null>(null);
const { data: links } = useQuery({
const {
data: links,
isError,
isLoading,
refetch,
} = useQuery({
queryKey: queryKeys.shareLinks(),
queryFn: () =>
apiGet<{ shareLinks: ShareLinkSummary[] }>("/api/share-links").then(
@@ -147,180 +154,193 @@ function ShareLinksCard() {
initialExpiryDays={prefill?.expiryDays}
/>
<div className="space-y-2">
<h3 className="text-sm font-medium">
{t("settings.sharing.activeTitle")}
</h3>
{activeLinks.length === 0 ? (
<EmptyState
size="compact"
data-testid="share-active-empty"
title={t("settings.sharing.noActive")}
/>
) : (
<ul className="space-y-2" data-testid="share-active-list">
{activeLinks.map((link) => (
<li
key={link.id}
className="bg-muted/30 border-border space-y-2 rounded-lg border p-3"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<p className="min-w-0 flex-1 text-sm font-medium break-words">
{link.label}
</p>
<div className="flex flex-wrap items-center gap-1.5">
<Badge className="bg-success/15 text-success text-xs">
{t("settings.sharing.statusActive")}
</Badge>
{link.protected && (
<Badge
variant="outline"
className="gap-1 text-xs"
data-testid="share-protected-badge"
>
<KeyRound className="h-2.5 w-2.5" />
{t("settings.sharing.protected")}
</Badge>
)}
{link.documentCount > 0 && (
<Badge
variant="outline"
className="gap-1 text-xs"
data-testid="share-doc-count-badge"
aria-label={t("settings.sharing.documentCount", {
count: link.documentCount,
})}
>
<FileText className="h-2.5 w-2.5" />
{link.documentCount}
</Badge>
)}
</div>
</div>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.sharing.created")}:
</span>{" "}
{formatDate(link.createdAt)}
</p>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.sharing.expires")}:
</span>{" "}
{formatDateTime(link.expiresAt)}
</p>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.sharing.accessCount")}:
</span>{" "}
{link.accessCount}
{link.lastAccessAt
? ` · ${formatDateTime(link.lastAccessAt)}`
: ""}
</p>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
className="min-h-11 w-full sm:min-h-9"
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
{t("settings.sharing.revoke")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.sharing.revoke")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.sharing.revokeDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => revokeMutation.mutate(link.id)}
>
{t("settings.sharing.revoke")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</li>
))}
</ul>
)}
</div>
{inactiveLinks.length > 0 && (
<div className="space-y-2">
<button
type="button"
onClick={() => setShowRevoked((prev) => !prev)}
className="text-foreground hover:text-primary text-sm font-medium transition-colors"
>
{t("settings.sharing.inactiveTitle", {
count: inactiveLinks.length,
})}
</button>
{showRevoked && (
<ul className="space-y-2" data-testid="share-inactive-list">
{inactiveLinks.map((link) => (
<li
key={link.id}
className="bg-muted/20 border-border space-y-1.5 rounded-lg border p-3"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<p className="min-w-0 flex-1 text-sm font-medium break-words">
{link.label}
{isError ? (
// A read failure must not read as "no active links" on a clinical
// sharing surface — that is the §6 fall-through this card must avoid.
<QueryErrorCard onRetry={() => refetch()} />
) : isLoading ? (
<div className="space-y-2" data-testid="share-links-loading">
<Skeleton className="h-24 w-full rounded-lg" />
<Skeleton className="h-24 w-full rounded-lg" />
</div>
) : (
<>
<div className="space-y-2">
<h3 className="text-sm font-medium">
{t("settings.sharing.activeTitle")}
</h3>
{activeLinks.length === 0 ? (
<EmptyState
size="compact"
data-testid="share-active-empty"
title={t("settings.sharing.noActive")}
/>
) : (
<ul className="space-y-2" data-testid="share-active-list">
{activeLinks.map((link) => (
<li
key={link.id}
className="bg-muted/30 border-border space-y-2 rounded-lg border p-3"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<p className="min-w-0 flex-1 text-sm font-medium break-words">
{link.label}
</p>
<div className="flex flex-wrap items-center gap-1.5">
<Badge className="bg-success/15 text-success text-xs">
{t("settings.sharing.statusActive")}
</Badge>
{link.protected && (
<Badge
variant="outline"
className="gap-1 text-xs"
data-testid="share-protected-badge"
>
<KeyRound className="h-2.5 w-2.5" />
{t("settings.sharing.protected")}
</Badge>
)}
{link.documentCount > 0 && (
<Badge
variant="outline"
className="gap-1 text-xs"
data-testid="share-doc-count-badge"
aria-label={t("settings.sharing.documentCount", {
count: link.documentCount,
})}
>
<FileText className="h-2.5 w-2.5" />
{link.documentCount}
</Badge>
)}
</div>
</div>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.sharing.created")}:
</span>{" "}
{formatDate(link.createdAt)}
</p>
<Badge variant="secondary" className="text-xs">
{link.needsReselection
? t("settings.sharing.statusNeedsReselect")
: link.revokedAt
? t("settings.sharing.statusRevoked")
: t("settings.sharing.statusExpired")}
</Badge>
</div>
{/* A link whose frozen scope predates the selection model.
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.sharing.expires")}:
</span>{" "}
{formatDateTime(link.expiresAt)}
</p>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.sharing.accessCount")}:
</span>{" "}
{link.accessCount}
{link.lastAccessAt
? ` · ${formatDateTime(link.lastAccessAt)}`
: ""}
</p>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
className="min-h-11 w-full sm:min-h-9"
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
{t("settings.sharing.revoke")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.sharing.revoke")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.sharing.revokeDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => revokeMutation.mutate(link.id)}
>
{t("settings.sharing.revoke")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</li>
))}
</ul>
)}
</div>
{inactiveLinks.length > 0 && (
<div className="space-y-2">
<button
type="button"
onClick={() => setShowRevoked((prev) => !prev)}
className="text-foreground hover:text-primary text-sm font-medium transition-colors"
>
{t("settings.sharing.inactiveTitle", {
count: inactiveLinks.length,
})}
</button>
{showRevoked && (
<ul className="space-y-2" data-testid="share-inactive-list">
{inactiveLinks.map((link) => (
<li
key={link.id}
className="bg-muted/20 border-border space-y-1.5 rounded-lg border p-3"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<p className="min-w-0 flex-1 text-sm font-medium break-words">
{link.label}
</p>
<Badge variant="secondary" className="text-xs">
{link.needsReselection
? t("settings.sharing.statusNeedsReselect")
: link.revokedAt
? t("settings.sharing.statusRevoked")
: t("settings.sharing.statusExpired")}
</Badge>
</div>
{/* A link whose frozen scope predates the selection model.
It served a scope nobody chose, so it was closed; the
owner re-mints above with the label, window and expiry
still in front of them. */}
{link.needsReselection ? (
<div
className="space-y-1.5"
data-testid={`share-needs-reselect-${link.id}`}
>
{link.needsReselection ? (
<div
className="space-y-1.5"
data-testid={`share-needs-reselect-${link.id}`}
>
<p className="text-muted-foreground text-xs">
{t("settings.sharing.reselectReason")}
</p>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
data-testid={`share-reselect-${link.id}`}
onClick={() => setPrefill(prefillFrom(link))}
>
{t("settings.sharing.reselectAction")}
</Button>
</div>
) : null}
<p className="text-muted-foreground text-xs">
{t("settings.sharing.reselectReason")}
<span className="font-medium">
{t("settings.sharing.accessCount")}:
</span>{" "}
{link.accessCount}
</p>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
data-testid={`share-reselect-${link.id}`}
onClick={() => setPrefill(prefillFrom(link))}
>
{t("settings.sharing.reselectAction")}
</Button>
</div>
) : null}
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.sharing.accessCount")}:
</span>{" "}
{link.accessCount}
</p>
</li>
))}
</ul>
</li>
))}
</ul>
)}
</div>
)}
</div>
</>
)}
</SettingsCard>
);
@@ -15,6 +15,7 @@ import { Loader2, ShieldCheck } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -39,7 +40,7 @@ export function TrustedDevicesCard({
const queryClient = useQueryClient();
const [status, setStatus] = useState<string | null>(null);
const { data, isLoading, isError } = useQuery({
const { data, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.trustedDevices(),
queryFn: () =>
apiGet<{ devices: TrustedDeviceRow[] }>("/api/auth/me/trusted-devices"),
@@ -102,9 +103,10 @@ export function TrustedDevicesCard({
)}
{isError && (
<p role="alert" className="text-destructive text-sm">
{t("settings.security.trustedDevices.loadError")}
</p>
<QueryErrorRow
message={t("settings.security.trustedDevices.loadError")}
onRetry={() => refetch()}
/>
)}
{devices.length > 0 && (