From 361505bd3133b45c54deb808840c6ffb27dc0708 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 22:23:31 +0200 Subject: [PATCH] fix(privacy): de-duplicate trusted devices and handle the summary loading state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trusted devices was mounted on both the Security section and the Privacy dashboard. Keep it only on the Privacy dashboard, which already gathers sessions and security activity, and drop it from the Security section. While here: the Retention card painted an empty list while the privacy summary was loading and on error — give it a skeleton placeholder and a one-line fallback, and align the "What's stored" indentation with the other privacy cards. --- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/pl.json | 3 +- .../__tests__/privacy-section.test.tsx | 38 +++++++++++- src/components/settings/privacy-section.tsx | 59 +++++++++++-------- .../settings/security-section/index.tsx | 3 - 9 files changed, 85 insertions(+), 33 deletions(-) diff --git a/messages/de.json b/messages/de.json index 628c3fd9f..62913d6c2 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5127,7 +5127,8 @@ "description": "Wie lange zeitgebundene Datensätze vor der automatischen Bereinigung gespeichert bleiben.", "coach": "Coach-Unterhaltungen: {days} Tage", "audit": "Sicherheitsprotokoll: {days} Tage", - "delivery": "Benachrichtigungsprotokoll: {days} Tage" + "delivery": "Benachrichtigungsprotokoll: {days} Tage", + "unavailable": "Aufbewahrungsdetails sind derzeit nicht verfügbar." }, "export": { "title": "Meine Daten herunterladen", diff --git a/messages/en.json b/messages/en.json index 79eddff16..761a1d876 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5127,7 +5127,8 @@ "description": "How long time-bound records are kept before automatic cleanup.", "coach": "Coach conversations: kept {days} days", "audit": "Security activity log: kept {days} days", - "delivery": "Notification delivery log: kept {days} days" + "delivery": "Notification delivery log: kept {days} days", + "unavailable": "Retention details are unavailable right now." }, "export": { "title": "Download my data", diff --git a/messages/es.json b/messages/es.json index 50f8d9dd4..0b5b87658 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5127,7 +5127,8 @@ "description": "Cuánto tiempo se conservan los registros con límite temporal antes de su limpieza automática.", "coach": "Conversaciones con el asistente: {days} días", "audit": "Registro de seguridad: {days} días", - "delivery": "Registro de envío de notificaciones: {days} días" + "delivery": "Registro de envío de notificaciones: {days} días", + "unavailable": "Los detalles de retención no están disponibles en este momento." }, "export": { "title": "Descargar mis datos", diff --git a/messages/fr.json b/messages/fr.json index 122199d74..c26d0256e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5127,7 +5127,8 @@ "description": "Durée de conservation des enregistrements limités dans le temps avant le nettoyage automatique.", "coach": "Conversations avec le coach : {days} jours", "audit": "Journal de sécurité : {days} jours", - "delivery": "Journal d'envoi des notifications : {days} jours" + "delivery": "Journal d'envoi des notifications : {days} jours", + "unavailable": "Les détails de conservation sont indisponibles pour le moment." }, "export": { "title": "Télécharger mes données", diff --git a/messages/it.json b/messages/it.json index 743cb5e34..7ab45c824 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5127,7 +5127,8 @@ "description": "Per quanto tempo i record a tempo vengono conservati prima della pulizia automatica.", "coach": "Conversazioni con il coach: {days} giorni", "audit": "Registro di sicurezza: {days} giorni", - "delivery": "Registro di invio delle notifiche: {days} giorni" + "delivery": "Registro di invio delle notifiche: {days} giorni", + "unavailable": "I dettagli di conservazione non sono disponibili al momento." }, "export": { "title": "Scarica i miei dati", diff --git a/messages/pl.json b/messages/pl.json index 7d344e71d..39e58d11e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5127,7 +5127,8 @@ "description": "Jak długo rekordy ograniczone czasowo są przechowywane przed automatycznym czyszczeniem.", "coach": "Rozmowy z asystentem: {days} dni", "audit": "Dziennik zabezpieczeń: {days} dni", - "delivery": "Dziennik wysyłki powiadomień: {days} dni" + "delivery": "Dziennik wysyłki powiadomień: {days} dni", + "unavailable": "Szczegóły przechowywania są obecnie niedostępne." }, "export": { "title": "Pobierz moje dane", diff --git a/src/components/settings/__tests__/privacy-section.test.tsx b/src/components/settings/__tests__/privacy-section.test.tsx index 56aebc8cb..288a064d4 100644 --- a/src/components/settings/__tests__/privacy-section.test.tsx +++ b/src/components/settings/__tests__/privacy-section.test.tsx @@ -33,7 +33,17 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") { // assembled cards paint without firing network. client.setQueryData(queryKeys.sessions(), { sessions: [] }); client.setQueryData(queryKeys.securityActivity(), { events: [] }); - client.setQueryData(queryKeys.trustedDevices(), { devices: [] }); + // Seed a trusted device so the card paints (it hides itself when empty). + client.setQueryData(queryKeys.trustedDevices(), { + devices: [ + { + id: "td-1", + label: "Firefox on macOS", + isCurrent: true, + expiresAt: "2099-01-01T00:00:00.000Z", + }, + ], + }); return renderToStaticMarkup( {node} @@ -56,5 +66,31 @@ describe(" — assembled Data & Privacy dashboard", () => { expect(html).toContain('href="/settings/advanced"'); // Embedded active-session + security-activity cards. expect(html).toContain("settings-security-sessions-card"); + // Trusted devices now lives here (and only here) — its single home is the + // Privacy dashboard, not the Security section. + expect(html).toContain("settings-trusted-devices-card"); + }); + + it("shows a loading placeholder for the retention block before the summary resolves", () => { + // Render WITHOUT seeding the privacy summary: the query is pending, so the + // retention block must paint a skeleton rather than an empty list. + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false, refetchOnMount: false }, + }, + }); + client.setQueryData(queryKeys.sessions(), { sessions: [] }); + client.setQueryData(queryKeys.securityActivity(), { events: [] }); + client.setQueryData(queryKeys.trustedDevices(), { devices: [] }); + const html = renderToStaticMarkup( + + + + + , + ); + // Skeleton rows render while the summary is loading; no retention day-count. + expect(html).toContain('data-slot="skeleton"'); + expect(html).not.toContain("365"); }); }); diff --git a/src/components/settings/privacy-section.tsx b/src/components/settings/privacy-section.tsx index 511877d30..6aa6534f4 100644 --- a/src/components/settings/privacy-section.tsx +++ b/src/components/settings/privacy-section.tsx @@ -25,6 +25,7 @@ import { } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { SettingsInfoTile } from "@/components/settings/_info-tile"; @@ -53,7 +54,11 @@ export function PrivacySection() { const { t } = useTranslations(); const { isAuthenticated } = useAuth(); - const { data: summary } = useQuery({ + const { + data: summary, + isLoading: summaryLoading, + isError: summaryError, + } = useQuery({ queryKey: queryKeys.privacySummary(), queryFn: () => apiGet("/api/settings/privacy-summary"), enabled: isAuthenticated, @@ -99,7 +104,7 @@ export function PrivacySection() { description={t("settings.privacy.stored.description")} className="mb-4" /> -
    +
    • {t("settings.privacy.stored.measurements")}
    • {t("settings.privacy.stored.medications")}
    • {t("settings.privacy.stored.moodLabs")}
    • @@ -117,27 +122,35 @@ export function PrivacySection() { description={t("settings.privacy.retention.description")} className="mb-4" /> -
        - {summary && ( - <> -
      • - {t("settings.privacy.retention.coach", { - days: summary.retention.coachMessagesDays, - })} -
      • -
      • - {t("settings.privacy.retention.audit", { - days: summary.retention.auditLogDays, - })} -
      • -
      • - {t("settings.privacy.retention.delivery", { - days: summary.retention.deliveryLogDays, - })} -
      • - - )} -
      + {summaryLoading ? ( +
      + + + +
      + ) : summaryError || !summary ? ( +

      + {t("settings.privacy.retention.unavailable")} +

      + ) : ( +
        +
      • + {t("settings.privacy.retention.coach", { + days: summary.retention.coachMessagesDays, + })} +
      • +
      • + {t("settings.privacy.retention.audit", { + days: summary.retention.auditLogDays, + })} +
      • +
      • + {t("settings.privacy.retention.delivery", { + days: summary.retention.deliveryLogDays, + })} +
      • +
      + )} {/* Export */} diff --git a/src/components/settings/security-section/index.tsx b/src/components/settings/security-section/index.tsx index 53c8b34f9..f2e9a5ac5 100644 --- a/src/components/settings/security-section/index.tsx +++ b/src/components/settings/security-section/index.tsx @@ -10,7 +10,6 @@ import { TotpCard } from "./totp-card"; import { SecurityKeysCard, type WebauthnKeyInfo } from "./security-keys-card"; import { PasskeyListSection } from "./passkey-list-section"; import { PasskeyUpgradeNudge } from "./passkey-upgrade-nudge"; -import { TrustedDevicesCard } from "@/components/settings/trusted-devices-card"; interface MfaStatus { totp: { enabled: boolean }; @@ -68,8 +67,6 @@ export function SecuritySection() { - - ); }