fix(privacy): de-duplicate trusted devices and handle the summary loading state

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 22:23:31 +02:00
1 parent 4a1f7f93df
commit 361505bd31
9 files changed
+85 -33

No files matched your search

+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
@@ -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(
<I18nProvider initialLocale={locale}>
<QueryClientProvider client={client}>{node}</QueryClientProvider>
@@ -56,5 +66,31 @@ describe("<PrivacySection> — 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(
<I18nProvider initialLocale="en">
<QueryClientProvider client={client}>
<PrivacySection />
</QueryClientProvider>
</I18nProvider>,
);
// Skeleton rows render while the summary is loading; no retention day-count.
expect(html).toContain('data-slot="skeleton"');
expect(html).not.toContain("365");
});
});
+36 -23
View File
@@ -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<PrivacySummary>("/api/settings/privacy-summary"),
enabled: isAuthenticated,
@@ -99,7 +104,7 @@ export function PrivacySection() {
description={t("settings.privacy.stored.description")}
className="mb-4"
/>
<ul className="text-muted-foreground list-disc space-y-1 pl-12 text-sm">
<ul className="text-muted-foreground list-disc space-y-1 pl-7 text-sm">
<li>{t("settings.privacy.stored.measurements")}</li>
<li>{t("settings.privacy.stored.medications")}</li>
<li>{t("settings.privacy.stored.moodLabs")}</li>
@@ -117,27 +122,35 @@ export function PrivacySection() {
description={t("settings.privacy.retention.description")}
className="mb-4"
/>
<ul className="text-muted-foreground space-y-1 pl-7 text-sm">
{summary && (
<>
<li>
{t("settings.privacy.retention.coach", {
days: summary.retention.coachMessagesDays,
})}
</li>
<li>
{t("settings.privacy.retention.audit", {
days: summary.retention.auditLogDays,
})}
</li>
<li>
{t("settings.privacy.retention.delivery", {
days: summary.retention.deliveryLogDays,
})}
</li>
</>
)}
</ul>
{summaryLoading ? (
<div className="space-y-2 pl-7">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-4 w-1/2" />
</div>
) : summaryError || !summary ? (
<p className="text-muted-foreground pl-7 text-sm">
{t("settings.privacy.retention.unavailable")}
</p>
) : (
<ul className="text-muted-foreground space-y-1 pl-7 text-sm">
<li>
{t("settings.privacy.retention.coach", {
days: summary.retention.coachMessagesDays,
})}
</li>
<li>
{t("settings.privacy.retention.audit", {
days: summary.retention.auditLogDays,
})}
</li>
<li>
{t("settings.privacy.retention.delivery", {
days: summary.retention.deliveryLogDays,
})}
</li>
</ul>
)}
</SettingsCard>
{/* Export */}
@@ -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() {
<SecurityKeysCard keys={status.webauthn} />
<PasskeyListSection isAuthenticated={isAuthenticated} />
<TrustedDevicesCard isAuthenticated={isAuthenticated} />
</div>
);
}