From a81303e55c0b86369cf3c3abcb5aefd86d6f3b02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 21:50:30 +0200 Subject: [PATCH] feat(privacy): add a Data & Privacy dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- messages/de.json | 52 +++++ messages/en.json | 52 +++++ messages/es.json | 52 +++++ messages/fr.json | 52 +++++ messages/it.json | 52 +++++ messages/pl.json | 52 +++++ src/app/api/settings/privacy-summary/route.ts | 51 +++++ src/app/settings/[section]/page.tsx | 2 + .../__tests__/privacy-section.test.tsx | 60 +++++ .../__tests__/settings-shell.test.tsx | 3 + src/components/settings/privacy-section.tsx | 213 ++++++++++++++++++ .../settings/section-placeholder.tsx | 2 + src/components/settings/section-slugs.ts | 5 + src/components/settings/settings-shell.tsx | 6 + src/lib/query-keys/settings.ts | 6 + 15 files changed, 660 insertions(+) create mode 100644 src/app/api/settings/privacy-summary/route.ts create mode 100644 src/components/settings/__tests__/privacy-section.test.tsx create mode 100644 src/components/settings/privacy-section.tsx diff --git a/messages/de.json b/messages/de.json index 233f0c8ab..7c4a91435 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4226,6 +4226,11 @@ "security": { "title": "Sicherheit", "subtitle": "Zwei-Faktor-Authentifizierung, Wiederherstellungscodes und Passkeys." + }, + "privacy": { + "title": "Daten & Datenschutz", + "subtitle": "Deine Daten, Aufbewahrung, Verschlüsselung und Kontosicherheit an einem Ort.", + "description": "Export, Löschung, Aufbewahrung, Verschlüsselungsstatus, Sitzungen und Sicherheitsaktivität." } }, "profile": "Profil", @@ -5093,6 +5098,53 @@ "current": "Dieses Gerät", "expires": "Vertraut bis {date}" } + }, + "privacy": { + "intro": { + "title": "Deine Daten bleiben deine", + "body": "HealthLog wird selbst gehostet. Diese Seite fasst zusammen, wie deine Daten geschützt werden, wie lange sie gespeichert bleiben, wie du sie exportierst und wie du sie löschst." + }, + "encryption": { + "title": "Verschlüsselt gespeichert", + "description": "Sensible Felder werden in der Datenbank verschlüsselt.", + "summary": "Deine sensiblen Daten werden mit {algorithm} verschlüsselt gespeichert – über {columns} Felder in {models} Datensatztypen.", + "statement": "Deine sensiblen Daten werden mit AES-256-GCM verschlüsselt gespeichert." + }, + "stored": { + "title": "Was gespeichert wird", + "description": "Die Datenkategorien, die dieses Konto enthält.", + "measurements": "Messwerte (Gewicht, Blutdruck, Puls, Glukose, Schlaf, Körperzusammensetzung)", + "medications": "Medikamente und Einnahmeverlauf", + "moodLabs": "Stimmungseinträge und Laborwerte", + "coach": "Coach-Unterhaltungen", + "integrations": "Integrations-Tokens (verschlüsselt)", + "security": "Konto- und Sicherheitsaktivität (Sitzungen, Anmeldeprotokoll)" + }, + "retention": { + "title": "Aufbewahrung", + "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" + }, + "export": { + "title": "Meine Daten herunterladen", + "description": "Exportiere deine Datensätze als CSV oder JSON, optional passphrasen-verschlüsselt, oder erstelle eine vollständige Gesundheitsakte.", + "openExport": "Export & Backup", + "openHealthRecord": "Gesundheitsakte" + }, + "delete": { + "title": "Löschen oder zurücksetzen", + "description": "Lösche dein Konto vollständig oder lösche deine Gesundheitsdaten und behalte das Konto.", + "backupLag": "Die Löschung entfernt deine Daten sofort. Verschlüsselte Off-Host-Backups enthalten sie weiterhin, bis sie aus dem Aufbewahrungsfenster des Betreibers herausfallen.", + "openAdvanced": "Löschen oder zurücksetzen" + }, + "posture": { + "title": "Datenschutz-Einstellung", + "description": "Steuere, wie viele deiner Daten an KI-Funktionen und die freiwillige Forschung weitergegeben werden.", + "openAi": "KI-Datenschutzmodus", + "openResearch": "Forschungsmodus" + } } }, "admin": { diff --git a/messages/en.json b/messages/en.json index 81ce03e05..b93195156 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4226,6 +4226,11 @@ "security": { "title": "Security", "subtitle": "Two-factor authentication, recovery codes, and passkeys." + }, + "privacy": { + "title": "Data & Privacy", + "subtitle": "Your data, retention, encryption, and account security in one place.", + "description": "Export, deletion, retention, encryption status, sessions, and security activity." } }, "profile": "Profile", @@ -5093,6 +5098,53 @@ "current": "This device", "expires": "Trusted until {date}" } + }, + "privacy": { + "intro": { + "title": "Your data stays yours", + "body": "HealthLog is self-hosted. This page brings together how your data is protected, how long it is kept, how to get it out, and how to delete it." + }, + "encryption": { + "title": "Encrypted at rest", + "description": "Sensitive fields are encrypted in the database.", + "summary": "Your sensitive data is encrypted at rest with {algorithm} across {columns} fields in {models} record types.", + "statement": "Your sensitive data is encrypted at rest with AES-256-GCM." + }, + "stored": { + "title": "What's stored", + "description": "The categories of data this account holds.", + "measurements": "Measurements (weight, blood pressure, pulse, glucose, sleep, body composition)", + "medications": "Medications and intake history", + "moodLabs": "Mood entries and lab results", + "coach": "Coach conversations", + "integrations": "Integration tokens (encrypted)", + "security": "Account and security activity (sessions, sign-in log)" + }, + "retention": { + "title": "Retention", + "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" + }, + "export": { + "title": "Download my data", + "description": "Export your records as CSV or JSON, optionally passphrase-encrypted, or generate a full health record.", + "openExport": "Export & backup", + "openHealthRecord": "Health record" + }, + "delete": { + "title": "Delete or reset", + "description": "Remove your account entirely, or wipe your health records while keeping the account.", + "backupLag": "Deletion removes your data immediately. Encrypted off-host backups still contain it until they roll off the operator's backup retention window.", + "openAdvanced": "Delete or reset" + }, + "posture": { + "title": "Privacy posture", + "description": "Control how much your data is shared with AI features and opt-in research.", + "openAi": "AI privacy mode", + "openResearch": "Research mode" + } } }, "admin": { diff --git a/messages/es.json b/messages/es.json index 109649d9d..482776912 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4226,6 +4226,11 @@ "security": { "title": "Seguridad", "subtitle": "Autenticación de dos factores, códigos de recuperación y claves de acceso." + }, + "privacy": { + "title": "Datos y privacidad", + "subtitle": "Tus datos, su retención, el cifrado y la seguridad de la cuenta en un solo lugar.", + "description": "Exportación, eliminación, retención, estado de cifrado, sesiones y actividad de seguridad." } }, "profile": "Perfil", @@ -5093,6 +5098,53 @@ "current": "Este dispositivo", "expires": "De confianza hasta el {date}" } + }, + "privacy": { + "intro": { + "title": "Tus datos siguen siendo tuyos", + "body": "HealthLog es autoalojado. Esta página reúne cómo se protegen tus datos, cuánto tiempo se conservan, cómo exportarlos y cómo eliminarlos." + }, + "encryption": { + "title": "Cifrado en reposo", + "description": "Los campos sensibles se cifran en la base de datos.", + "summary": "Tus datos sensibles se cifran en reposo con {algorithm} en {columns} campos de {models} tipos de registro.", + "statement": "Tus datos sensibles se cifran en reposo con AES-256-GCM." + }, + "stored": { + "title": "Qué se almacena", + "description": "Las categorías de datos que contiene esta cuenta.", + "measurements": "Mediciones (peso, presión arterial, pulso, glucosa, sueño, composición corporal)", + "medications": "Medicamentos e historial de tomas", + "moodLabs": "Entradas de estado de ánimo y resultados de laboratorio", + "coach": "Conversaciones con el asistente", + "integrations": "Tokens de integración (cifrados)", + "security": "Actividad de la cuenta y de seguridad (sesiones, registro de inicio de sesión)" + }, + "retention": { + "title": "Retención", + "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" + }, + "export": { + "title": "Descargar mis datos", + "description": "Exporta tus registros como CSV o JSON, opcionalmente cifrados con frase de contraseña, o genera un historial médico completo.", + "openExport": "Exportar y copia de seguridad", + "openHealthRecord": "Historial médico" + }, + "delete": { + "title": "Eliminar o restablecer", + "description": "Elimina tu cuenta por completo o borra tus registros de salud conservando la cuenta.", + "backupLag": "La eliminación borra tus datos de inmediato. Las copias de seguridad cifradas externas aún los contienen hasta que caduquen según la ventana de retención del operador.", + "openAdvanced": "Eliminar o restablecer" + }, + "posture": { + "title": "Configuración de privacidad", + "description": "Controla cuántos de tus datos se comparten con las funciones de IA y la investigación opcional.", + "openAi": "Modo de privacidad de IA", + "openResearch": "Modo investigación" + } } }, "admin": { diff --git a/messages/fr.json b/messages/fr.json index 846e7898b..2cd706a63 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4226,6 +4226,11 @@ "security": { "title": "Sécurité", "subtitle": "Authentification à deux facteurs, codes de récupération et clés d'accès." + }, + "privacy": { + "title": "Données et confidentialité", + "subtitle": "Vos données, leur conservation, le chiffrement et la sécurité du compte au même endroit.", + "description": "Export, suppression, conservation, état du chiffrement, sessions et activité de sécurité." } }, "profile": "Profil", @@ -5093,6 +5098,53 @@ "current": "Cet appareil", "expires": "De confiance jusqu'au {date}" } + }, + "privacy": { + "intro": { + "title": "Vos données vous appartiennent", + "body": "HealthLog est auto-hébergé. Cette page réunit la façon dont vos données sont protégées, leur durée de conservation, comment les exporter et comment les supprimer." + }, + "encryption": { + "title": "Chiffré au repos", + "description": "Les champs sensibles sont chiffrés dans la base de données.", + "summary": "Vos données sensibles sont chiffrées au repos avec {algorithm} sur {columns} champs dans {models} types d'enregistrement.", + "statement": "Vos données sensibles sont chiffrées au repos avec AES-256-GCM." + }, + "stored": { + "title": "Ce qui est stocké", + "description": "Les catégories de données que contient ce compte.", + "measurements": "Mesures (poids, tension, pouls, glycémie, sommeil, composition corporelle)", + "medications": "Médicaments et historique des prises", + "moodLabs": "Entrées d'humeur et résultats de laboratoire", + "coach": "Conversations avec le coach", + "integrations": "Jetons d'intégration (chiffrés)", + "security": "Activité du compte et de sécurité (sessions, journal de connexion)" + }, + "retention": { + "title": "Conservation", + "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" + }, + "export": { + "title": "Télécharger mes données", + "description": "Exportez vos enregistrements en CSV ou JSON, éventuellement chiffrés par phrase secrète, ou générez un dossier médical complet.", + "openExport": "Export et sauvegarde", + "openHealthRecord": "Dossier médical" + }, + "delete": { + "title": "Supprimer ou réinitialiser", + "description": "Supprimez complètement votre compte, ou effacez vos données de santé tout en conservant le compte.", + "backupLag": "La suppression efface vos données immédiatement. Les sauvegardes chiffrées hors site les contiennent encore jusqu'à leur expiration selon la fenêtre de conservation de l'opérateur.", + "openAdvanced": "Supprimer ou réinitialiser" + }, + "posture": { + "title": "Posture de confidentialité", + "description": "Contrôlez la quantité de données partagées avec les fonctions d'IA et la recherche optionnelle.", + "openAi": "Mode confidentialité IA", + "openResearch": "Mode recherche" + } } }, "admin": { diff --git a/messages/it.json b/messages/it.json index 5cd11eb56..028f384d3 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4226,6 +4226,11 @@ "security": { "title": "Sicurezza", "subtitle": "Autenticazione a due fattori, codici di recupero e passkey." + }, + "privacy": { + "title": "Dati e privacy", + "subtitle": "I tuoi dati, la conservazione, la cifratura e la sicurezza dell'account in un unico posto.", + "description": "Esportazione, eliminazione, conservazione, stato della cifratura, sessioni e attività di sicurezza." } }, "profile": "Profilo", @@ -5093,6 +5098,53 @@ "current": "Questo dispositivo", "expires": "Attendibile fino al {date}" } + }, + "privacy": { + "intro": { + "title": "I tuoi dati restano tuoi", + "body": "HealthLog è self-hosted. Questa pagina riunisce come sono protetti i tuoi dati, per quanto tempo vengono conservati, come esportarli e come eliminarli." + }, + "encryption": { + "title": "Cifrati a riposo", + "description": "I campi sensibili sono cifrati nel database.", + "summary": "I tuoi dati sensibili sono cifrati a riposo con {algorithm} su {columns} campi in {models} tipi di record.", + "statement": "I tuoi dati sensibili sono cifrati a riposo con AES-256-GCM." + }, + "stored": { + "title": "Cosa viene memorizzato", + "description": "Le categorie di dati contenute in questo account.", + "measurements": "Misurazioni (peso, pressione, polso, glicemia, sonno, composizione corporea)", + "medications": "Farmaci e cronologia delle assunzioni", + "moodLabs": "Voci dell'umore e referti di laboratorio", + "coach": "Conversazioni con il coach", + "integrations": "Token di integrazione (cifrati)", + "security": "Attività dell'account e di sicurezza (sessioni, registro degli accessi)" + }, + "retention": { + "title": "Conservazione", + "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" + }, + "export": { + "title": "Scarica i miei dati", + "description": "Esporta i tuoi record come CSV o JSON, eventualmente cifrati con passphrase, oppure genera un fascicolo sanitario completo.", + "openExport": "Esportazione e backup", + "openHealthRecord": "Fascicolo sanitario" + }, + "delete": { + "title": "Elimina o reimposta", + "description": "Elimina completamente il tuo account oppure cancella i tuoi dati sanitari mantenendo l'account.", + "backupLag": "L'eliminazione rimuove subito i tuoi dati. I backup cifrati esterni li contengono ancora finché non scadono secondo la finestra di conservazione dell'operatore.", + "openAdvanced": "Elimina o reimposta" + }, + "posture": { + "title": "Impostazione privacy", + "description": "Controlla quanti dei tuoi dati vengono condivisi con le funzioni di IA e la ricerca facoltativa.", + "openAi": "Modalità privacy IA", + "openResearch": "Modalità ricerca" + } } }, "admin": { diff --git a/messages/pl.json b/messages/pl.json index 586d1281b..b0f909a44 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4226,6 +4226,11 @@ "security": { "title": "Bezpieczeństwo", "subtitle": "Uwierzytelnianie dwuskładnikowe, kody odzyskiwania i klucze dostępu." + }, + "privacy": { + "title": "Dane i prywatność", + "subtitle": "Twoje dane, retencja, szyfrowanie i bezpieczeństwo konta w jednym miejscu.", + "description": "Eksport, usuwanie, retencja, stan szyfrowania, sesje i aktywność zabezpieczeń." } }, "profile": "Profil", @@ -5093,6 +5098,53 @@ "current": "To urządzenie", "expires": "Zaufane do {date}" } + }, + "privacy": { + "intro": { + "title": "Twoje dane pozostają Twoje", + "body": "HealthLog jest hostowany samodzielnie. Ta strona zbiera w jednym miejscu informacje o tym, jak chronione są Twoje dane, jak długo są przechowywane, jak je wyeksportować i jak je usunąć." + }, + "encryption": { + "title": "Szyfrowane w spoczynku", + "description": "Wrażliwe pola są szyfrowane w bazie danych.", + "summary": "Twoje wrażliwe dane są szyfrowane w spoczynku algorytmem {algorithm} w {columns} polach w {models} typach rekordów.", + "statement": "Twoje wrażliwe dane są szyfrowane w spoczynku algorytmem AES-256-GCM." + }, + "stored": { + "title": "Co jest przechowywane", + "description": "Kategorie danych przechowywanych na tym koncie.", + "measurements": "Pomiary (waga, ciśnienie, tętno, glukoza, sen, skład ciała)", + "medications": "Leki i historia przyjmowania", + "moodLabs": "Wpisy nastroju i wyniki badań laboratoryjnych", + "coach": "Rozmowy z asystentem", + "integrations": "Tokeny integracji (szyfrowane)", + "security": "Aktywność konta i zabezpieczeń (sesje, dziennik logowań)" + }, + "retention": { + "title": "Retencja", + "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" + }, + "export": { + "title": "Pobierz moje dane", + "description": "Wyeksportuj swoje rekordy jako CSV lub JSON, opcjonalnie zaszyfrowane hasłem, albo wygeneruj pełną dokumentację medyczną.", + "openExport": "Eksport i kopia zapasowa", + "openHealthRecord": "Dokumentacja medyczna" + }, + "delete": { + "title": "Usuń lub zresetuj", + "description": "Usuń całe konto albo wyczyść dane zdrowotne, zachowując konto.", + "backupLag": "Usunięcie natychmiast usuwa Twoje dane. Zaszyfrowane kopie zapasowe poza serwerem nadal je zawierają, dopóki nie wygasną zgodnie z oknem retencji operatora.", + "openAdvanced": "Usuń lub zresetuj" + }, + "posture": { + "title": "Ustawienia prywatności", + "description": "Kontroluj, ile Twoich danych jest udostępnianych funkcjom AI i opcjonalnym badaniom.", + "openAi": "Tryb prywatności AI", + "openResearch": "Tryb badawczy" + } } }, "admin": { diff --git a/src/app/api/settings/privacy-summary/route.ts b/src/app/api/settings/privacy-summary/route.ts new file mode 100644 index 000000000..8bc58c600 --- /dev/null +++ b/src/app/api/settings/privacy-summary/route.ts @@ -0,0 +1,51 @@ +/** + * GET /api/settings/privacy-summary + * + * Server-authoritative facts for the user-facing "Data & Privacy" dashboard: + * the configured retention windows and a truthful encryption-at-rest summary + * driven from the canonical `ENCRYPTED_COLUMNS` registry. No secrets, no key + * material, no per-row data — just the numbers the dashboard renders read-only. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { ENCRYPTED_COLUMNS } from "@/lib/crypto/encrypted-columns"; + +export const dynamic = "force-dynamic"; + +/** + * Retention windows. These mirror the defaults + env names the cleanup jobs + * read (`coach-message-cleanup.ts`, `audit-log-cleanup.ts`, the reminder + * cleanup handlers); read here directly so the dashboard never imports a + * pg-boss job module. Off-host backup retention is operator infrastructure + * (not an app setting), so it is disclosed as prose, not a number. + */ +function resolveRetention() { + const intEnv = (name: string, fallback: number, min: number): number => { + const raw = process.env[name]; + const parsed = raw ? Number.parseInt(raw, 10) : NaN; + return Number.isFinite(parsed) && parsed >= min ? parsed : fallback; + }; + return { + coachMessagesDays: intEnv("COACH_MESSAGE_RETENTION_DAYS", 365, 30), + auditLogDays: intEnv("AUDIT_LOG_RETENTION_DAYS", 365, 7), + // The push-attempt + mood-dispatch ledgers are a fixed 90-day window. + deliveryLogDays: 90, + }; +} + +export const GET = apiHandler(async () => { + await requireAuth(); + annotate({ action: { name: "settings.privacy.summary" } }); + + const encryptedModels = new Set(ENCRYPTED_COLUMNS.map((c) => c.model)); + + return apiSuccess({ + retention: resolveRetention(), + encryption: { + algorithm: "AES-256-GCM", + columnCount: ENCRYPTED_COLUMNS.length, + modelCount: encryptedModels.size, + }, + }); +}); diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx index be04898a5..9ce6a1129 100644 --- a/src/app/settings/[section]/page.tsx +++ b/src/app/settings/[section]/page.tsx @@ -20,6 +20,7 @@ import { MedicationsSection } from "@/components/settings/medications-section"; import { ModulesSection } from "@/components/settings/modules-section"; import { MoodSection } from "@/components/settings/mood-section"; import { NotificationsSection } from "@/components/settings/notifications-section"; +import { PrivacySection } from "@/components/settings/privacy-section"; import { SectionPlaceholder } from "@/components/settings/section-placeholder"; import { VorsorgeSection } from "@/components/settings/vorsorge-section"; // v1.18.1 (D4) — `channels` and `sources` are standalone left-side entries @@ -79,6 +80,7 @@ const SECTION_COMPONENTS: Record< sharing: SharingSection, export: ExportSection, advanced: AdvancedSection, + privacy: PrivacySection, }; interface PageProps { diff --git a/src/components/settings/__tests__/privacy-section.test.tsx b/src/components/settings/__tests__/privacy-section.test.tsx new file mode 100644 index 000000000..56aebc8cb --- /dev/null +++ b/src/components/settings/__tests__/privacy-section.test.tsx @@ -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( + + {node} + , + ); +} + +describe(" — assembled Data & Privacy dashboard", () => { + it("renders the assembled sections (encryption, retention, export, delete, sessions, activity)", () => { + const html = render(); + // 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"); + }); +}); diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx index 012b2e7e3..259e9ba46 100644 --- a/src/components/settings/__tests__/settings-shell.test.tsx +++ b/src/components/settings/__tests__/settings-shell.test.tsx @@ -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", ]); }); diff --git a/src/components/settings/privacy-section.tsx b/src/components/settings/privacy-section.tsx new file mode 100644 index 000000000..511877d30 --- /dev/null +++ b/src/components/settings/privacy-section.tsx @@ -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("/api/settings/privacy-summary"), + enabled: isAuthenticated, + }); + + return ( +
+ {/* Intro */} + + {t("settings.privacy.intro.body")} + + + {/* Encryption at rest */} + + +
+

+ {summary + ? t("settings.privacy.encryption.summary", { + algorithm: summary.encryption.algorithm, + columns: summary.encryption.columnCount, + models: summary.encryption.modelCount, + }) + : t("settings.privacy.encryption.statement")} +

+
+
+ + {/* What's stored */} + + +
    +
  • {t("settings.privacy.stored.measurements")}
  • +
  • {t("settings.privacy.stored.medications")}
  • +
  • {t("settings.privacy.stored.moodLabs")}
  • +
  • {t("settings.privacy.stored.coach")}
  • +
  • {t("settings.privacy.stored.integrations")}
  • +
  • {t("settings.privacy.stored.security")}
  • +
+
+ + {/* Retention */} + + +
    + {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, + })} +
  • + + )} +
+
+ + {/* Export */} + + +
+ + +
+
+ + {/* Delete / reset — with the honest backup lag disclosure */} + + +
+ + {t("settings.privacy.delete.backupLag")} + + +
+
+ + {/* Privacy posture (AI privacy mode + research mode live on their pages) */} + + +
+ + +
+
+ + {/* Active sessions + trusted devices + security activity (embedded) */} + + + +
+ ); +} diff --git a/src/components/settings/section-placeholder.tsx b/src/components/settings/section-placeholder.tsx index 09f3ec64a..3b79c3d26 100644 --- a/src/components/settings/section-placeholder.tsx +++ b/src/components/settings/section-placeholder.tsx @@ -24,6 +24,7 @@ import { Layers, LayoutDashboard, Link2, + Lock, Pill, Radio, Settings2, @@ -66,6 +67,7 @@ const SLUG_ICON: Record = { sharing: Share2, export: Download, advanced: Settings2, + privacy: Lock, about: Info, }; diff --git a/src/components/settings/section-slugs.ts b/src/components/settings/section-slugs.ts index 002d44801..96d4e325e 100644 --- a/src/components/settings/section-slugs.ts +++ b/src/components/settings/section-slugs.ts @@ -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; diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index 2412f53d2..e79f0370e 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -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", diff --git a/src/lib/query-keys/settings.ts b/src/lib/query-keys/settings.ts index 69b4ea195..4261a02a2 100644 --- a/src/lib/query-keys/settings.ts +++ b/src/lib/query-keys/settings.ts @@ -37,6 +37,12 @@ export const settingsKeys = { versionUpdateCheck: () => ["version", "update-check"] as const, researchMode: () => ["research-mode"] as const, + /** + * v1.23 — the Data & Privacy dashboard's read-only retention + encryption + * summary behind `GET /api/settings/privacy-summary`. + */ + privacySummary: () => ["settings", "privacy-summary"] as const, + /** v1.11.0 — owner's clinician share links (Settings → Sharing). */ shareLinks: () => ["share-links"] as const,