mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
c44eed365d
commit
a81303e55c
15 files changed
+660
No files matched your search
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
Reference in new issue
Block a user