feat(privacy): add a Data & Privacy dashboard

Bring the scattered data-sovereignty controls into one /settings/privacy pane.
It assembles already-shipped pieces rather than rebuilding them: the encryption-
at-rest summary and configured retention windows (read from the server so the
copy stays truthful), links to the export surfaces (including the passphrase-
encrypted option) and the account-deletion / data-reset surface, the active-
session list, trusted devices, and the security-activity log. The backup↔
deletion lag is disclosed honestly — deletion is immediate, but encrypted
off-host backups still hold the data until they roll off retention.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 21:50:30 +02:00
1 parent c44eed365d
commit a81303e55c
15 files changed
+660

No files matched your search

@@ -0,0 +1,60 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ isAuthenticated: true, isLoading: false }),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { PrivacySection } from "../privacy-section";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: Number.POSITIVE_INFINITY,
refetchOnMount: false,
},
},
});
// Seed the privacy summary so the encryption + retention blocks paint inline.
client.setQueryData(queryKeys.privacySummary(), {
retention: {
coachMessagesDays: 365,
auditLogDays: 365,
deliveryLogDays: 90,
},
encryption: { algorithm: "AES-256-GCM", columnCount: 74, modelCount: 21 },
});
// Seed the embedded session + trusted-device + activity reads so the
// assembled cards paint without firing network.
client.setQueryData(queryKeys.sessions(), { sessions: [] });
client.setQueryData(queryKeys.securityActivity(), { events: [] });
client.setQueryData(queryKeys.trustedDevices(), { devices: [] });
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<QueryClientProvider client={client}>{node}</QueryClientProvider>
</I18nProvider>,
);
}
describe("<PrivacySection> — assembled Data & Privacy dashboard", () => {
it("renders the assembled sections (encryption, retention, export, delete, sessions, activity)", () => {
const html = render(<PrivacySection />);
// Encryption-at-rest block driven from the seeded summary.
expect(html).toContain("AES-256-GCM");
// Retention numbers surfaced from the summary.
expect(html).toContain("365");
// The honest backup↔deletion lag disclosure is present.
expect(html.toLowerCase()).toContain("backup");
// Links to the existing export + destructive surfaces (assembly, not rebuild).
expect(html).toContain('href="/settings/export"');
expect(html).toContain('href="/settings/gesundheitsakte"');
expect(html).toContain('href="/settings/advanced"');
// Embedded active-session + security-activity cards.
expect(html).toContain("settings-security-sessions-card");
});
});
@@ -121,6 +121,9 @@ describe("SETTINGS_SECTION_SLUGS", () => {
"sharing",
"export",
"advanced",
// v1.23 — "Data & Privacy" assembles the export / deletion / retention /
// encryption / session / activity pieces into one pane, after `advanced`.
"privacy",
"about",
]);
});
+213
View File
@@ -0,0 +1,213 @@
"use client";
/**
* v1.23 — "Data & Privacy" dashboard (P7).
*
* One coherent pane that ASSEMBLES already-shipped machinery — it does not
* rebuild any of it. Each block either embeds an existing card (active
* sessions, security activity, trusted devices) or links to the existing
* surface that owns the action (export incl. the passphrase option, account
* deletion / data reset, AI privacy mode, research mode). The retention +
* encryption facts are read from the server so the copy stays truthful, and
* the backup↔deletion lag is disclosed honestly.
*/
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import {
Database,
Download,
Lock,
ShieldCheck,
Trash2,
Clock,
Sparkles,
Info,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsInfoTile } from "@/components/settings/_info-tile";
import { SecuritySessionsCard } from "@/components/settings/security-sessions-card";
import { SecurityActivityCard } from "@/components/settings/security-activity-card";
import { TrustedDevicesCard } from "@/components/settings/trusted-devices-card";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
interface PrivacySummary {
retention: {
coachMessagesDays: number;
auditLogDays: number;
deliveryLogDays: number;
};
encryption: {
algorithm: string;
columnCount: number;
modelCount: number;
};
}
export function PrivacySection() {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const { data: summary } = useQuery({
queryKey: queryKeys.privacySummary(),
queryFn: () => apiGet<PrivacySummary>("/api/settings/privacy-summary"),
enabled: isAuthenticated,
});
return (
<div className="space-y-6">
{/* Intro */}
<SettingsInfoTile
icon={Lock}
tone="info"
title={t("settings.privacy.intro.title")}
>
{t("settings.privacy.intro.body")}
</SettingsInfoTile>
{/* Encryption at rest */}
<SettingsCard>
<SettingsCardHeader
icon={ShieldCheck}
title={t("settings.privacy.encryption.title")}
description={t("settings.privacy.encryption.description")}
className="mb-4"
/>
<div className="space-y-3 pl-7">
<p className="text-sm">
{summary
? t("settings.privacy.encryption.summary", {
algorithm: summary.encryption.algorithm,
columns: summary.encryption.columnCount,
models: summary.encryption.modelCount,
})
: t("settings.privacy.encryption.statement")}
</p>
</div>
</SettingsCard>
{/* What's stored */}
<SettingsCard>
<SettingsCardHeader
icon={Database}
title={t("settings.privacy.stored.title")}
description={t("settings.privacy.stored.description")}
className="mb-4"
/>
<ul className="text-muted-foreground list-disc space-y-1 pl-12 text-sm">
<li>{t("settings.privacy.stored.measurements")}</li>
<li>{t("settings.privacy.stored.medications")}</li>
<li>{t("settings.privacy.stored.moodLabs")}</li>
<li>{t("settings.privacy.stored.coach")}</li>
<li>{t("settings.privacy.stored.integrations")}</li>
<li>{t("settings.privacy.stored.security")}</li>
</ul>
</SettingsCard>
{/* Retention */}
<SettingsCard>
<SettingsCardHeader
icon={Clock}
title={t("settings.privacy.retention.title")}
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>
</SettingsCard>
{/* Export */}
<SettingsCard>
<SettingsCardHeader
icon={Download}
title={t("settings.privacy.export.title")}
description={t("settings.privacy.export.description")}
className="mb-4"
/>
<div className="flex flex-wrap gap-2 pl-7">
<Button asChild variant="outline" size="sm">
<Link href="/settings/export">
{t("settings.privacy.export.openExport")}
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href="/settings/gesundheitsakte">
{t("settings.privacy.export.openHealthRecord")}
</Link>
</Button>
</div>
</SettingsCard>
{/* Delete / reset — with the honest backup lag disclosure */}
<SettingsCard>
<SettingsCardHeader
icon={Trash2}
title={t("settings.privacy.delete.title")}
description={t("settings.privacy.delete.description")}
className="mb-4"
/>
<div className="space-y-3 pl-7">
<SettingsInfoTile icon={Info} tone="warning">
{t("settings.privacy.delete.backupLag")}
</SettingsInfoTile>
<Button asChild variant="outline" size="sm">
<Link href="/settings/advanced">
{t("settings.privacy.delete.openAdvanced")}
</Link>
</Button>
</div>
</SettingsCard>
{/* Privacy posture (AI privacy mode + research mode live on their pages) */}
<SettingsCard>
<SettingsCardHeader
icon={Sparkles}
title={t("settings.privacy.posture.title")}
description={t("settings.privacy.posture.description")}
className="mb-4"
/>
<div className="flex flex-wrap gap-2 pl-7">
<Button asChild variant="outline" size="sm">
<Link href="/settings/ai">
{t("settings.privacy.posture.openAi")}
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href="/settings/advanced">
{t("settings.privacy.posture.openResearch")}
</Link>
</Button>
</div>
</SettingsCard>
{/* Active sessions + trusted devices + security activity (embedded) */}
<SecuritySessionsCard isAuthenticated={isAuthenticated} />
<TrustedDevicesCard isAuthenticated={isAuthenticated} />
<SecurityActivityCard isAuthenticated={isAuthenticated} />
</div>
);
}
@@ -24,6 +24,7 @@ import {
Layers,
LayoutDashboard,
Link2,
Lock,
Pill,
Radio,
Settings2,
@@ -66,6 +67,7 @@ const SLUG_ICON: Record<SettingsSectionSlug, LucideIcon> = {
sharing: Share2,
export: Download,
advanced: Settings2,
privacy: Lock,
about: Info,
};
+5
View File
@@ -87,6 +87,11 @@ export const SETTINGS_SECTION_SLUGS = [
"sharing",
"export",
"advanced",
// v1.23 — "Data & Privacy": a single surface that assembles the already-
// shipped export / deletion / retention / encryption / session / activity
// pieces into one coherent privacy pane. Sits next to `advanced` (the
// destructive-action home) and before `about`.
"privacy",
"about",
] as const;
@@ -30,6 +30,7 @@ import {
LayoutDashboard,
Layers,
Link2,
Lock,
Pill,
Radio,
Settings2,
@@ -251,6 +252,11 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
titleKey: "settings.sections.advanced.title",
icon: Settings2,
},
{
slug: "privacy",
titleKey: "settings.sections.privacy.title",
icon: Lock,
},
{
slug: "about",
titleKey: "settings.sections.about.title",