diff --git a/messages/de.json b/messages/de.json index b9e620b72..c97fb115e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4018,46 +4018,16 @@ "disableError": "Forschungsmodus konnte nicht deaktiviert werden. Bitte erneut versuchen." }, "shell": { - "sectionsNav": "Einstellungs-Bereiche", - "subSectionsNav": "Einstellungs-Unterbereiche" - }, - "groups": { - "account": { - "title": "Konto" - }, - "tracking": { - "title": "Tracking" - }, - "display": { - "title": "Darstellung" - }, - "integrations": { - "title": "Integrationen" - }, - "notifications": { - "title": "Benachrichtigungen" - }, - "ai": { - "title": "KI & Coach" - }, - "access": { - "title": "API & Zugriff" - }, - "data": { - "title": "Daten & Datenschutz" - }, - "about": { - "title": "Über" - } + "sectionsNav": "Einstellungs-Bereiche" }, "sections": { "account": { - "title": "Profil & Einstellungen", + "title": "Konto", "description": "Profil, Passwort, Passkeys und deine Onboarding-Tour.", "subtitle": "Dein Profil, deine Identität und deine Anmeldedaten." }, "integrations": { - "title": "Verbindungen", + "title": "Integrationen", "description": "Verbundene Dienste, Zustellkanäle und die Quellen-Priorität an einem Ort.", "subtitle": "Datenquellen und Geräte verbinden und verwalten." }, @@ -4069,7 +4039,7 @@ "subtitle": "Wähle, welche Erinnerungen du erhältst." }, "layout": { - "title": "Darstellung", + "title": "Dashboard", "description": "Lege fest, wie Dashboard und Insights aufgebaut und angeordnet sind.", "backToHub": "Zurück zu Layout & Personalisierung", "dashboard": { @@ -4348,9 +4318,9 @@ "subtitle": "Zwei-Faktor-Authentifizierung, Wiederherstellungscodes und Passkeys." }, "privacy": { - "title": "Datenschutz & Aufbewahrung", - "subtitle": "Deine Daten, Aufbewahrung und Verschlüsselung an einem Ort.", - "description": "Export, Löschung, Aufbewahrung und Verschlüsselungsstatus." + "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." }, "mcp": { "title": "MCP-Connector", @@ -5317,11 +5287,6 @@ "description": "Steuere, wie viele deiner Daten an KI-Funktionen und die freiwillige Forschung weitergegeben werden.", "openAi": "KI-Datenschutzmodus", "openResearch": "Forschungsmodus" - }, - "signIn": { - "title": "Anmeldung & Geräte", - "description": "Aktive Sitzungen, vertraute Geräte und Anmeldeaktivität findest du jetzt in den Sicherheitseinstellungen deines Kontos.", - "openSecurity": "Anmeldung & Sicherheit verwalten" } }, "mcp": { diff --git a/messages/en.json b/messages/en.json index 2d527bcfd..3f2844f28 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4018,46 +4018,16 @@ "disableError": "Could not disable Research Mode. Please try again." }, "shell": { - "sectionsNav": "Settings sections", - "subSectionsNav": "Settings sub-sections" - }, - "groups": { - "account": { - "title": "Account" - }, - "tracking": { - "title": "Tracking" - }, - "display": { - "title": "Display" - }, - "integrations": { - "title": "Integrations" - }, - "notifications": { - "title": "Notifications" - }, - "ai": { - "title": "AI & Coach" - }, - "access": { - "title": "API & Access" - }, - "data": { - "title": "Data & Privacy" - }, - "about": { - "title": "About" - } + "sectionsNav": "Settings sections" }, "sections": { "account": { - "title": "Profile & preferences", + "title": "Account", "description": "Profile, password, passkeys, and your onboarding tour.", "subtitle": "Your profile, identity, and sign-in details." }, "integrations": { - "title": "Connections", + "title": "Integrations", "description": "Connected services, delivery channels, and source priority in one place.", "subtitle": "Connect and manage your data sources and devices." }, @@ -4069,7 +4039,7 @@ "subtitle": "Choose which reminders you receive." }, "layout": { - "title": "Display", + "title": "Dashboard", "description": "Arrange how the dashboard and insights are laid out.", "backToHub": "Back to Layout & Personalization", "dashboard": { @@ -4348,9 +4318,9 @@ "subtitle": "Two-factor authentication, recovery codes, and passkeys." }, "privacy": { - "title": "Privacy & retention", - "subtitle": "Your data, retention, and encryption in one place.", - "description": "Export, deletion, retention, and encryption status." + "title": "Data & Privacy", + "subtitle": "Your data, retention, encryption, and account security in one place.", + "description": "Export, deletion, retention, encryption status, sessions, and security activity." }, "mcp": { "title": "MCP Connector", @@ -5317,11 +5287,6 @@ "description": "Control how much your data is shared with AI features and opt-in research.", "openAi": "AI privacy mode", "openResearch": "Research mode" - }, - "signIn": { - "title": "Sign-in & devices", - "description": "Active sessions, trusted devices, and login activity now live in your account security settings.", - "openSecurity": "Manage sign-in & security" } }, "mcp": { diff --git a/messages/es.json b/messages/es.json index ab0958f0a..98aee4b3d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4018,46 +4018,16 @@ "disableError": "No se pudo desactivar el modo Investigación. Inténtalo de nuevo." }, "shell": { - "sectionsNav": "Secciones de ajustes", - "subSectionsNav": "Subsecciones de ajustes" - }, - "groups": { - "account": { - "title": "Cuenta" - }, - "tracking": { - "title": "Seguimiento" - }, - "display": { - "title": "Visualización" - }, - "integrations": { - "title": "Integraciones" - }, - "notifications": { - "title": "Notificaciones" - }, - "ai": { - "title": "IA y Coach" - }, - "access": { - "title": "API y acceso" - }, - "data": { - "title": "Datos y privacidad" - }, - "about": { - "title": "Acerca de" - } + "sectionsNav": "Secciones de ajustes" }, "sections": { "account": { - "title": "Perfil y preferencias", + "title": "Cuenta", "description": "Perfil, contraseña, claves de acceso y tu recorrido de incorporación.", "subtitle": "Tu perfil, identidad y datos de acceso." }, "integrations": { - "title": "Conexiones", + "title": "Integraciones", "description": "Servicios conectados, canales de entrega y prioridad de fuentes en un solo lugar.", "subtitle": "Conecta y gestiona tus fuentes de datos y dispositivos." }, @@ -4069,7 +4039,7 @@ "subtitle": "Elige qué recordatorios recibes." }, "layout": { - "title": "Visualización", + "title": "Panel", "description": "Organiza cómo están dispuestos el panel y los análisis.", "backToHub": "Volver a Diseño y personalización", "dashboard": { @@ -4348,9 +4318,9 @@ "subtitle": "Autenticación de dos factores, códigos de recuperación y claves de acceso." }, "privacy": { - "title": "Privacidad y retención", - "subtitle": "Tus datos, su retención y el cifrado en un solo lugar.", - "description": "Exportación, eliminación, retención y estado de cifrado." + "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." }, "mcp": { "title": "Conector MCP", @@ -5317,11 +5287,6 @@ "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" - }, - "signIn": { - "title": "Inicio de sesión y dispositivos", - "description": "Las sesiones activas, los dispositivos de confianza y la actividad de inicio de sesión ahora están en la configuración de seguridad de tu cuenta.", - "openSecurity": "Gestionar inicio de sesión y seguridad" } }, "mcp": { diff --git a/messages/fr.json b/messages/fr.json index 32ae4643c..80c0be5fe 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4018,46 +4018,16 @@ "disableError": "Impossible de désactiver le mode Recherche. Veuillez réessayer." }, "shell": { - "sectionsNav": "Sections des réglages", - "subSectionsNav": "Sous-sections des réglages" - }, - "groups": { - "account": { - "title": "Compte" - }, - "tracking": { - "title": "Suivi" - }, - "display": { - "title": "Affichage" - }, - "integrations": { - "title": "Intégrations" - }, - "notifications": { - "title": "Notifications" - }, - "ai": { - "title": "IA et coach" - }, - "access": { - "title": "API et accès" - }, - "data": { - "title": "Données et confidentialité" - }, - "about": { - "title": "À propos" - } + "sectionsNav": "Sections des réglages" }, "sections": { "account": { - "title": "Profil et préférences", + "title": "Compte", "description": "Profil, mot de passe, clés d’accès et votre visite guidée.", "subtitle": "Votre profil, votre identité et vos identifiants." }, "integrations": { - "title": "Connexions", + "title": "Intégrations", "description": "Services connectés, canaux de diffusion et priorité des sources au même endroit.", "subtitle": "Connectez et gérez vos sources de données et appareils." }, @@ -4069,7 +4039,7 @@ "subtitle": "Choisissez les rappels que vous recevez." }, "layout": { - "title": "Affichage", + "title": "Tableau de bord", "description": "Organisez la disposition du tableau de bord et des analyses.", "backToHub": "Retour à Disposition et personnalisation", "dashboard": { @@ -4348,9 +4318,9 @@ "subtitle": "Authentification à deux facteurs, codes de récupération et clés d'accès." }, "privacy": { - "title": "Confidentialité et conservation", - "subtitle": "Vos données, leur conservation et le chiffrement au même endroit.", - "description": "Export, suppression, conservation et état du chiffrement." + "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é." }, "mcp": { "title": "Connecteur MCP", @@ -5317,11 +5287,6 @@ "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" - }, - "signIn": { - "title": "Connexion et appareils", - "description": "Les sessions actives, les appareils de confiance et l'activité de connexion se trouvent désormais dans les paramètres de sécurité de votre compte.", - "openSecurity": "Gérer la connexion et la sécurité" } }, "mcp": { diff --git a/messages/it.json b/messages/it.json index 331bb87e5..03a3fa8ed 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4018,46 +4018,16 @@ "disableError": "Impossibile disattivare la modalità Ricerca. Riprova." }, "shell": { - "sectionsNav": "Sezioni delle impostazioni", - "subSectionsNav": "Sottosezioni delle impostazioni" - }, - "groups": { - "account": { - "title": "Account" - }, - "tracking": { - "title": "Monitoraggio" - }, - "display": { - "title": "Visualizzazione" - }, - "integrations": { - "title": "Integrazioni" - }, - "notifications": { - "title": "Notifiche" - }, - "ai": { - "title": "IA e coach" - }, - "access": { - "title": "API e accesso" - }, - "data": { - "title": "Dati e privacy" - }, - "about": { - "title": "Informazioni" - } + "sectionsNav": "Sezioni delle impostazioni" }, "sections": { "account": { - "title": "Profilo e preferenze", + "title": "Account", "description": "Profilo, password, passkey e il tour di benvenuto.", "subtitle": "Il tuo profilo, la tua identità e i dati di accesso." }, "integrations": { - "title": "Connessioni", + "title": "Integrazioni", "description": "Servizi collegati, canali di consegna e priorità delle fonti in un unico posto.", "subtitle": "Collega e gestisci le tue fonti di dati e i dispositivi." }, @@ -4069,7 +4039,7 @@ "subtitle": "Scegli quali promemoria ricevere." }, "layout": { - "title": "Visualizzazione", + "title": "Cruscotto", "description": "Organizza la disposizione del cruscotto e degli approfondimenti.", "backToHub": "Torna a Layout e personalizzazione", "dashboard": { @@ -4348,9 +4318,9 @@ "subtitle": "Autenticazione a due fattori, codici di recupero e passkey." }, "privacy": { - "title": "Privacy e conservazione", - "subtitle": "I tuoi dati, la conservazione e la cifratura in un unico posto.", - "description": "Esportazione, eliminazione, conservazione e stato della cifratura." + "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." }, "mcp": { "title": "Connettore MCP", @@ -5317,11 +5287,6 @@ "description": "Controlla quanti dei tuoi dati vengono condivisi con le funzioni di IA e la ricerca facoltativa.", "openAi": "Modalità privacy IA", "openResearch": "Modalità ricerca" - }, - "signIn": { - "title": "Accesso e dispositivi", - "description": "Le sessioni attive, i dispositivi attendibili e l'attività di accesso si trovano ora nelle impostazioni di sicurezza del tuo account.", - "openSecurity": "Gestisci accesso e sicurezza" } }, "mcp": { diff --git a/messages/pl.json b/messages/pl.json index 15eb5497a..4b2ba446c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4018,46 +4018,16 @@ "disableError": "Nie udało się wyłączyć trybu Badawczego. Spróbuj ponownie." }, "shell": { - "sectionsNav": "Sekcje ustawień", - "subSectionsNav": "Podsekcje ustawień" - }, - "groups": { - "account": { - "title": "Konto" - }, - "tracking": { - "title": "Śledzenie" - }, - "display": { - "title": "Wyświetlanie" - }, - "integrations": { - "title": "Integracje" - }, - "notifications": { - "title": "Powiadomienia" - }, - "ai": { - "title": "AI i Coach" - }, - "access": { - "title": "API i dostęp" - }, - "data": { - "title": "Dane i prywatność" - }, - "about": { - "title": "O aplikacji" - } + "sectionsNav": "Sekcje ustawień" }, "sections": { "account": { - "title": "Profil i preferencje", + "title": "Konto", "description": "Profil, hasło, klucze dostępu i przewodnik wprowadzający.", "subtitle": "Twój profil, tożsamość i dane logowania." }, "integrations": { - "title": "Połączenia", + "title": "Integracje", "description": "Połączone usługi, kanały dostarczania i priorytet źródeł w jednym miejscu.", "subtitle": "Połącz i zarządzaj źródłami danych oraz urządzeniami." }, @@ -4069,7 +4039,7 @@ "subtitle": "Wybierz, które przypomnienia otrzymujesz." }, "layout": { - "title": "Wyświetlanie", + "title": "Pulpit", "description": "Ustal, jak rozłożone są pulpit i analizy.", "backToHub": "Powrót do Układ i personalizacja", "dashboard": { @@ -4348,9 +4318,9 @@ "subtitle": "Uwierzytelnianie dwuskładnikowe, kody odzyskiwania i klucze dostępu." }, "privacy": { - "title": "Prywatność i przechowywanie", - "subtitle": "Twoje dane, retencja i szyfrowanie w jednym miejscu.", - "description": "Eksport, usuwanie, retencja i stan szyfrowania." + "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ń." }, "mcp": { "title": "Łącznik MCP", @@ -5317,11 +5287,6 @@ "description": "Kontroluj, ile Twoich danych jest udostępnianych funkcjom AI i opcjonalnym badaniom.", "openAi": "Tryb prywatności AI", "openResearch": "Tryb badawczy" - }, - "signIn": { - "title": "Logowanie i urządzenia", - "description": "Aktywne sesje, zaufane urządzenia i aktywność logowania znajdują się teraz w ustawieniach zabezpieczeń konta.", - "openSecurity": "Zarządzaj logowaniem i zabezpieczeniami" } }, "mcp": { diff --git a/next.config.ts b/next.config.ts index 3ba5f19c5..2c6e0c4ed 100644 --- a/next.config.ts +++ b/next.config.ts @@ -134,16 +134,6 @@ const nextConfig: NextConfig = { destination: "/settings/notifications", permanent: true, }, - // v1.25.1 — the settings IA consolidation made the Display group land - // directly on `/settings/dashboard` (its first sub-tab), so the old - // "Layout & Personalization" hub at `/settings/layout` is no longer - // linked from anywhere. 301-redirect it to the Display landing so any old - // bookmark or cached navigation still resolves. - { - source: "/settings/layout", - destination: "/settings/dashboard", - permanent: true, - }, // v1.22.0 — the preventive-care surface moved from the German slug // `/vorsorge` to the English `/checkups` (the feature reads "Checkups" // in every non-German locale now). 301-redirect so bookmarks, the PWA's diff --git a/src/components/settings/__tests__/advanced-account-delete.test.tsx b/src/components/settings/__tests__/advanced-account-delete.test.tsx index 19e6dccbd..fd1c4e711 100644 --- a/src/components/settings/__tests__/advanced-account-delete.test.tsx +++ b/src/components/settings/__tests__/advanced-account-delete.test.tsx @@ -1,20 +1,16 @@ /** - * v1.4.43 QoL (M3 + L5) — danger-zone shaping. + * v1.4.43 QoL (M3 + L5) — `` danger-zone shaping. * * M3: a separate "Delete account entirely" destructive card - * (`AccountDeleteCard`) gives the GDPR Article 17 full-erasure CTA its - * own surface alongside the half-erasure data reset. + * (`AccountDeleteCard`) sits beside the existing `DataResetCard` + * so a user reading the GDPR Article 17 surface has the full + * erasure CTA right next to the half-erasure one. * * L5: the data-reset card no longer paints a red `AlertTriangle` * icon and the title sits in the neutral foreground colour * (mirrors GitHub's danger-zone shaping). The CTA button stays * red; the protective confirmation dialog stays unchanged. * - * v1.25.1 (Q2-M3): account deletion moved out of `` (Data & - * Privacy group) into the Account group as a standalone ``. - * The L5 data-reset assertions still target ``; the M3 - * account-delete assertions now render the relocated card directly. - * * SSR-only — we render the component statically and inspect the * markup. The mutation handlers don't fire in SSR, so this test only * pins the visible structure + copy. @@ -38,7 +34,6 @@ vi.mock("@/components/medications/research-mode-acknowledgment-dialog", () => ({ })); import { AdvancedSection } from "../advanced-section"; -import { AccountDeleteCard } from "../account-delete-card"; function render(locale: "en" | "de" = "en") { return renderToStaticMarkup( @@ -48,25 +43,11 @@ function render(locale: "en" | "de" = "en") { ); } -function renderDeleteCard(locale: "en" | "de" = "en") { - return renderToStaticMarkup( - - - , - ); -} - -describe(" danger-zone shaping (v1.4.43 L5)", () => { - it("mounts the data-reset card", () => { +describe(" danger-zone shaping (v1.4.43 M3 + L5)", () => { + it("mounts both the data-reset card and the account-delete card", () => { const html = render(); expect(html).toContain('data-slot="settings-data-reset-card"'); - }); - - it("v1.25.1 (Q2-M3): no longer mounts the account-delete card", () => { - // Account deletion graduated to the Account group; Advanced keeps research - // mode + the data reset only. - const html = render(); - expect(html).not.toContain('data-slot="settings-account-delete-card"'); + expect(html).toContain('data-slot="settings-account-delete-card"'); }); it("L5: the data-reset card title is neutral foreground (no destructive red)", () => { @@ -94,16 +75,9 @@ describe(" danger-zone shaping (v1.4.43 L5)", () => { expect(cardMatch).not.toBeNull(); expect(cardMatch![0]).not.toMatch(/lucide-alert-triangle/); }); -}); - -describe(" (v1.4.43 M3, relocated v1.25.1 Q2-M3)", () => { - it("renders the account-delete card", () => { - const html = renderDeleteCard(); - expect(html).toContain('data-slot="settings-account-delete-card"'); - }); it("M3: the account-delete card uses the destructive Button variant", () => { - const html = renderDeleteCard(); + const html = render(); expect(html).toContain('data-slot="settings-account-delete-trigger"'); // The trigger button still wears the destructive style — the CTA // remains red even though the surrounding card chrome is neutral. @@ -117,14 +91,14 @@ describe(" (v1.4.43 M3, relocated v1.25.1 Q2-M3)", () => { }); it("M3: the account-delete card surfaces tight DE copy under the 'de' locale", () => { - const html = renderDeleteCard("de"); + const html = render("de"); expect(html).toContain("Konto vollständig löschen"); // The descriptive sentence calls out the cascade scope plainly. expect(html).toContain("Passkeys"); }); it("M3: the account-delete card surfaces tight EN copy under the 'en' locale", () => { - const html = renderDeleteCard("en"); + const html = render("en"); expect(html).toContain("Delete account entirely"); expect(html).toContain("passkeys"); }); diff --git a/src/components/settings/__tests__/privacy-section.test.tsx b/src/components/settings/__tests__/privacy-section.test.tsx index 7fcc39a6f..288a064d4 100644 --- a/src/components/settings/__tests__/privacy-section.test.tsx +++ b/src/components/settings/__tests__/privacy-section.test.tsx @@ -52,7 +52,7 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") { } describe(" — assembled Data & Privacy dashboard", () => { - it("renders the assembled data/retention sections and links out (no embedded sign-in cards)", () => { + 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"); @@ -64,13 +64,11 @@ describe(" — assembled Data & Privacy dashboard", () => { expect(html).toContain('href="/settings/export"'); expect(html).toContain('href="/settings/gesundheitsakte"'); expect(html).toContain('href="/settings/advanced"'); - // v1.25.1 (H1) — active sessions, trusted devices, and the security-activity - // feed moved to Account → Security; they are NOT embedded here anymore. - expect(html).not.toContain("settings-security-sessions-card"); - expect(html).not.toContain("settings-trusted-devices-card"); - expect(html).not.toContain("settings-security-activity-card"); - // Instead this pane cross-links to the consolidated sign-in home. - expect(html).toContain('href="/settings/security"'); + // 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", () => { diff --git a/src/components/settings/__tests__/sections.test.tsx b/src/components/settings/__tests__/sections.test.tsx index 9823a60dd..5744b4ef7 100644 --- a/src/components/settings/__tests__/sections.test.tsx +++ b/src/components/settings/__tests__/sections.test.tsx @@ -126,16 +126,12 @@ function renderFramed( } describe("settings sections — SSR smoke", () => { - it(" renders the Profile heading and Profile card", () => { + it(" renders the Account heading and Profile card", () => { const html = renderFramed("account", ); - // v1.25.1 (Q2-M2) — the section title is now "Profile & preferences" (the - // first-child sub-tab no longer duplicates the "Account" group name). It - // resolves via the frame + i18n provider. - expect(html).toContain("Profile & preferences"); + // Section title resolves via the frame + i18n provider. + expect(html).toContain("Account"); // Profile card heading is also painted. expect(html).toContain("Profile"); - // v1.25.1 (Q2-M3) — account deletion moved here from Advanced. - expect(html).toContain('data-slot="settings-account-delete-card"'); // No raw key leaks past the i18n layer. expect(html).not.toContain("settings.sections."); }); @@ -188,9 +184,7 @@ describe("settings sections — SSR smoke", () => { it(" renders Withings card title", () => { const html = renderFramed("integrations", ); - // v1.25.1 (Q2-M2) — the section title is now "Connections" (the first-child - // sub-tab no longer duplicates the "Integrations" group name). - expect(html).toContain("Connections"); + expect(html).toContain("Integrations"); expect(html).toContain("Withings"); }); diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx index e36a6d369..004d13aed 100644 --- a/src/components/settings/__tests__/settings-shell.test.tsx +++ b/src/components/settings/__tests__/settings-shell.test.tsx @@ -29,17 +29,12 @@ vi.mock("@/hooks/use-auth", () => ({ import { I18nProvider } from "@/lib/i18n/context"; import { - SETTINGS_GROUPS, SETTINGS_SECTION_SLUGS, SETTINGS_SECTIONS, SettingsShell, isSettingsSectionSlug, } from "../settings-shell"; -function count(html: string, re: RegExp): number { - return html.match(re)?.length ?? 0; -} - function renderShell(props: { active?: (typeof SETTINGS_SECTION_SLUGS)[number]; pathname?: string; @@ -155,212 +150,229 @@ describe("SETTINGS_SECTION_SLUGS", () => { }); }); -// v1.25.1 — the left rail renders NINE groups (mobile strip + desktop sidebar -// = two copies each). Every section route stays live at its own URL, but the -// rail is driven by `SETTINGS_GROUPS`; each group page surfaces its children -// as in-page sub-tabs (`data-slot="settings-subtab"`). -const GROUP_LANDINGS: Record = { - account: "/settings/account", - tracking: "/settings/modules", - display: "/settings/dashboard", - integrations: "/settings/integrations", - notifications: "/settings/notifications", - ai: "/settings/ai", - access: "/settings/api", - data: "/settings/export", - about: "/settings/about", -}; - describe("", () => { - it("renders all nine group nav entries — once per layout (mobile + desktop)", () => { + it("renders every navigable section link — once for the mobile strip and once for the desktop sidebar", () => { const html = renderShell({ active: "account" }); - expect(count(html, /data-slot="settings-group-nav-item"/g)).toBe( - SETTINGS_GROUPS.length * 2, - ); - for (const group of SETTINGS_GROUPS) { - expect(count(html, new RegExp(`data-group="${group.id}"`, "g"))).toBe(2); + // Every slug — including `about`, which returned to the shell nav + // as the last entry after living dropdown-only since v1.4.33 IW7 — + // renders in both layouts. + const navigableSlugs = SETTINGS_SECTIONS.map((section) => section.slug); + for (const slug of navigableSlugs) { + const matches = html.match(new RegExp(`href="/settings/${slug}"`, "g")); + // Two renders — mobile strip + desktop sidebar — guarantee the link + // exists in both layouts. Tablet/desktop hide the strip with `md:`, + // but the markup is always present so it's keyboard-discoverable + // before media queries resolve. + expect(matches?.length ?? 0).toBe(2); } }); - it("each group entry links to its landing (first visible child) route", () => { + it("links use the correct `/settings/` href — regression guard against typos", () => { const html = renderShell({ active: "account" }); - for (const [id, landing] of Object.entries(GROUP_LANDINGS)) { - const re = new RegExp( - `data-slot="settings-group-nav-item"[^>]*data-group="${id}"[^>]*href="${landing}"|href="${landing}"[^>]*data-slot="settings-group-nav-item"[^>]*data-group="${id}"`, - "g", - ); - // React SSR attribute order is stable but we tolerate either ordering. - // Fall back to asserting the href is present on the group somewhere. - const present = - count(html, re) >= 2 || html.includes(`data-group="${id}"`) === true; - expect(present, `group ${id} → ${landing}`).toBe(true); - expect(html).toContain(`href="${landing}"`); - } + // Spot-check a couple of slugs that have historic anchor aliases (e.g. + // `/settings#withings` → `/settings/integrations`) — they must NOT + // sneak back in. + expect(html).toContain('href="/settings/integrations"'); + expect(html).toContain('href="/settings/ai"'); expect(html).not.toContain('href="/settings#'); + expect(html).not.toContain('href="/settings"'); }); - it('marks the active GROUP with aria-current="page" (both layouts)', () => { - // On `/settings/security` the Account GROUP reads active (security is an - // Account child). Its rail entry points at the group landing. - const html = renderShell({ pathname: "/settings/security" }); - const accountActive = - /data-group="account"[^>]*aria-current="page"|aria-current="page"[^>]*data-group="account"/g; - expect(count(html, accountActive)).toBe(2); + it('marks the active section with aria-current="page" (and only that one)', () => { + const html = renderShell({ active: "notifications" }); + // Both layouts emit the active link, so two `aria-current="page"`. + const occurrences = html.match(/aria-current="page"/g); + expect(occurrences?.length ?? 0).toBe(2); + + // And the active link is on the notifications href. Attribute order + // in React SSR is alphabetic, so `aria-current` precedes `href` — + // the regex deliberately doesn't pin order to stay robust against + // future React renderer changes. + const activeLinkRegex = + /]*\baria-current="page"[^>]*\bhref="\/settings\/notifications"|]*\bhref="\/settings\/notifications"[^>]*\baria-current="page"/g; + expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2); }); - it("derives the active group from the current pathname when `active` is omitted", () => { - const html = renderShell({ pathname: "/settings/mcp" }); - // mcp is a child of the API & Access group. - const accessActive = - /data-group="access"[^>]*aria-current="page"|aria-current="page"[^>]*data-group="access"/g; - expect(count(html, accessActive)).toBe(2); + it("derives the active slug from the current pathname when `active` is omitted", () => { + const html = renderShell({ pathname: "/settings/api" }); + const activeLinkRegex = + /]*\baria-current="page"[^>]*\bhref="\/settings\/api"|]*\bhref="\/settings\/api"[^>]*\baria-current="page"/g; + expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2); }); - it("highlights the Display group on its child editors and the legacy layout hub", () => { - // Dashboard + Insights are Display sub-tabs; `/settings/layout` (the - // personalisation hub) stays a live route and also maps to Display. - for (const child of [ - "/settings/dashboard", - "/settings/insights", - "/settings/layout", - ]) { + it("highlights the Layout hub when on a Layout child editor (v1.17.1 F-2)", () => { + // On `/settings/dashboard` (a Layout child reached through the hub) the + // Layout nav entry must read active even though Dashboard has no nav + // entry of its own. Both layouts emit the active link → two matches. + for (const child of ["/settings/dashboard", "/settings/insights"]) { const html = renderShell({ pathname: child }); - const displayActive = - /data-group="display"[^>]*aria-current="page"|aria-current="page"[^>]*data-group="display"/g; - expect(count(html, displayActive), child).toBe(2); + const layoutActive = + /]*\baria-current="page"[^>]*\bhref="\/settings\/layout"|]*\bhref="\/settings\/layout"[^>]*\baria-current="page"/g; + expect(html.match(layoutActive)?.length ?? 0).toBe(2); } }); - it("renders in-page sub-tabs for a multi-child group, marking the active child", () => { + it("highlights the Layout hub when the active prop is a Layout child", () => { + // The page passes `active={section}` explicitly; a child slug must + // still resolve onto the Layout hub for nav highlighting. const html = renderShell({ - active: "security", - pathname: "/settings/security", + active: "dashboard", + pathname: "/settings/dashboard", }); - // Account group → two sub-tabs (Profile + Security). - expect(html).toContain('data-subtab-slug="account"'); - expect(html).toContain('data-subtab-slug="security"'); - const securityTab = - /data-subtab-slug="security"[^>]*aria-current="page"|aria-current="page"[^>]*data-subtab-slug="security"/g; - expect(count(html, securityTab)).toBe(1); + const layoutActive = + /]*\baria-current="page"[^>]*\bhref="\/settings\/layout"|]*\bhref="\/settings\/layout"[^>]*\baria-current="page"/g; + expect(html.match(layoutActive)?.length ?? 0).toBe(2); }); - it("renders NO sub-tab strip for a single-child group", () => { - // Notifications + About are standalone groups (one child each). - const html = renderShell({ pathname: "/settings/notifications" }); - expect(html).not.toContain('data-slot="settings-subtabs"'); - expect(html).not.toContain('data-slot="settings-subtab"'); - }); - - it("module-gates a sub-tab off `user.modules` but keeps the group", () => { - // On a Tracking route with mood disabled: the Mood sub-tab is gone, the - // always-on sub-tabs remain, and the Tracking group entry still renders. + it("module-gates the Stimmung entry off `user.modules.mood` (v1.18.0 S5)", () => { + // Fail-open default: mood undefined → entry shown. + expect(renderShell({ active: "account" })).toContain( + 'href="/settings/mood"', + ); + // Explicitly disabled → entry hidden. const disabled = renderShell({ - active: "medications", - pathname: "/settings/medications", + active: "account", modules: { mood: false }, }); - expect(disabled).not.toContain('data-subtab-slug="mood"'); - expect(disabled).toContain('data-subtab-slug="modules"'); - expect(disabled).toContain('data-subtab-slug="thresholds"'); - expect(count(disabled, /data-group="tracking"/g)).toBe(2); - // Fail-open default → mood sub-tab shown. - const open = renderShell({ - active: "medications", - pathname: "/settings/medications", + expect(disabled).not.toContain('href="/settings/mood"'); + // Explicitly enabled → entry shown, marked active on its own route. + const enabled = renderShell({ + active: "mood", + pathname: "/settings/mood", + modules: { mood: true }, }); - expect(open).toContain('data-subtab-slug="mood"'); + const moodActive = + /]*\baria-current="page"[^>]*\bhref="\/settings\/mood"|]*\bhref="\/settings\/mood"[^>]*\baria-current="page"/g; + expect(enabled.match(moodActive)?.length ?? 0).toBe(2); }); - it("module-gates the Coach sub-tab off `user.modules.coach`", () => { - const open = renderShell({ pathname: "/settings/ai" }); - expect(open).toContain('data-subtab-slug="coach"'); + it("module-gates the Coach entry off `user.modules.coach` (v1.18.0 S5)", () => { + // Fail-open default: coach undefined → entry shown. + expect(renderShell({ active: "account" })).toContain( + 'href="/settings/coach"', + ); + // Explicitly disabled → entry hidden. const disabled = renderShell({ - pathname: "/settings/ai", + active: "account", modules: { coach: false }, }); - expect(disabled).not.toContain('data-subtab-slug="coach"'); - // AI single remaining child → no strip, but the group entry stays. - expect(count(disabled, /data-group="ai"/g)).toBe(2); + expect(disabled).not.toContain('href="/settings/coach"'); }); - it("always surfaces the Health-record sub-tab under Data & Privacy", () => { - // The health record is a flagship export and is not nav-gated. On a Data - // & Privacy route it is always one of the sub-tabs. - const cases: Array | undefined> = [ - undefined, - {}, - { doctorReport: false }, - ]; - for (const modules of cases) { - const html = renderShell({ pathname: "/settings/export", modules }); - expect(html).toContain('data-subtab-slug="gesundheitsakte"'); - } + it("always surfaces the Health-record entry, regardless of `doctorReport` (v1.18.6.1)", () => { + // The health-record (PDF / FHIR) entry is a flagship export capability + // and is no longer nav-gated: a default/unset modules map shows it, and + // even an explicit `doctorReport: false` keeps the Settings entry-point + // reachable. The server-side `/api/export/health-record` route remains + // the hard enforcement of the opt-out. + expect(renderShell({ active: "account" })).toContain( + 'href="/settings/gesundheitsakte"', + ); + expect(renderShell({ active: "account", modules: {} })).toContain( + 'href="/settings/gesundheitsakte"', + ); + const optedOut = renderShell({ + active: "account", + modules: { doctorReport: false }, + }); + expect(optedOut).toContain('href="/settings/gesundheitsakte"'); }); - it("falls back to the Account group when the pathname doesn't match a known slug", () => { + it("falls back to `account` when the pathname doesn't match a known slug", () => { const html = renderShell({ pathname: "/settings/totally-bogus" }); - const accountActive = - /data-group="account"[^>]*aria-current="page"|aria-current="page"[^>]*data-group="account"/g; - expect(count(html, accountActive)).toBe(2); + const activeLinkRegex = + /]*\baria-current="page"[^>]*\bhref="\/settings\/account"|]*\bhref="\/settings\/account"[^>]*\baria-current="page"/g; + expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2); }); - it("resolves the nine group titles via the i18n provider — English", () => { + it("resolves every section title via the i18n provider — English", () => { const html = renderShell({ active: "account", locale: "en" }); - for (const title of [ - "Account", - "Tracking", - "Display", - "Integrations", - "Notifications", - "AI & Coach", - "API & Access", - "Data & Privacy", - "About", - ]) { - expect(html, title).toContain(title); - } - // The fixed Display-group title resolves the v1.25.1 rename (it no longer - // collides with its own Dashboard child). - expect(html).toContain(">Display"); + expect(html).toContain("Account"); + expect(html).toContain("Integrations"); + // v1.9.0 — the section label is back to the shorter "Notifications" + // (single-line; the longer "Notification channels" wrapped). The + // `/notifications` inbox now shares the "Notifications" label. + expect(html).toContain("Notifications"); + // v1.17.1 (F-2) — the dashboard + insights arrangement editors are + // reached through one hub entry (route stays `/settings/layout`). + // v1.19.1 (S3) — the nav label now reads "Dashboard" to match where + // it leads; the route is unchanged. + expect(html).toContain('href="/settings/layout"'); + expect(html).toContain(">Dashboard"); + // v1.18.1 (D4) — Channels + Sources are their own left-side entries. + expect(html).toContain('href="/settings/channels"'); + expect(html).toContain('href="/settings/sources"'); + // v1.18.0 (S5) — Medications (Medikamente) and Mood (Stimmung, gated; + // fail-open mock shows both) are their own nav entries now. + expect(html).toContain('href="/settings/medications"'); + expect(html).toContain('href="/settings/mood"'); + // The ampersand is HTML-escaped by React SSR — assert on the encoded + // form so we don't accidentally match a parser that double-escapes. + expect(html).toContain("API & Tokens"); + // v1.18.0 (S5) — the health record is its own top-level entry. + expect(html).toContain('href="/settings/gesundheitsakte"'); + expect(html).toContain("Health record"); + // v1.4.16 phase B7: the consolidated Export section is a top-level + // entry in the sidebar; the link must be present in both locales. + expect(html).toContain('href="/settings/export"'); + expect(html).toContain("Advanced"); + // About is back in the settings nav as the last entry (it also + // stays linked from the sidebar user-card dropdown). + expect(html).toContain('href="/settings/about"'); + // v1.18.1 (D4) — Sources is a standalone nav entry again (split out of + // the Integrations sub-tabs). Targets keeps its own entry. + expect(html).toContain('href="/settings/thresholds"'); + expect(html).toContain('href="/settings/sources"'); + expect(html).toContain("Targets"); }); - it("resolves the nine group titles via the i18n provider — German", () => { + it("resolves every section title via the i18n provider — German", () => { const html = renderShell({ active: "account", locale: "de" }); - for (const title of [ - "Konto", - "Tracking", - "Darstellung", - "Integrationen", - "Benachrichtigungen", - "KI & Coach", - "API & Zugriff", - "Daten & Datenschutz", - "Über", - ]) { - expect(html, title).toContain(title); - } + expect(html).toContain("Konto"); + expect(html).toContain("Integrationen"); + // v1.9.0 — back to the shorter "Benachrichtigungen" (single-line; the + // compound "Benachrichtigungs-Kanäle" wrapped). The `/notifications` + // inbox now shares the "Benachrichtigungen" label. + expect(html).toContain("Benachrichtigungen"); + // v1.17.1 (F-2) — the arrangement editors are reached through one hub + // entry (route stays `/settings/layout`). v1.19.1 (S3) — the nav label + // reads "Dashboard" in every locale to match where it leads. + expect(html).toContain('href="/settings/layout"'); + expect(html).toContain(">Dashboard"); + // v1.18.1 (D4) — Channels ("Kanäle") + Sources ("Quellen-Priorität") + // are their own left-side entries. + expect(html).toContain('href="/settings/channels"'); + expect(html).toContain("Kanäle"); + // v1.18.0 (S5) — Medications + Mood are their own nav entries now. + expect(html).toContain('href="/settings/medications"'); + expect(html).toContain('href="/settings/mood"'); + // Targets keeps its German nav entry ("Zielwerte"). + expect(html).toContain("Zielwerte"); + // v1.18.6 (W9) — the AI section is named "KI-Anbieter" in German: the + // page is about the provider / BYOK, not an "Auswertung". + expect(html).toContain("KI-Anbieter"); + // API & Tokens is identical in both locales (proper noun + ampersand) + expect(html).toContain("API & Tokens"); + expect(html).toContain("Erweitert"); + // "Über diese App" (About) is back as the last in-shell nav entry + // (the sidebar user-card dropdown keeps its own link too). + expect(html).toContain('href="/settings/about"'); + // v1.18.1 (D4) — Sources is a standalone nav entry again. + expect(html).toContain('href="/settings/sources"'); }); - it("does NOT surface a raw i18n key — guards against missing JSON entries", () => { + it("does NOT surface the raw key when a translation resolves — guards against missing JSON entries", () => { const html = renderShell({ active: "account", locale: "en" }); - expect(html).not.toContain("settings.groups."); expect(html).not.toContain("settings.sections."); - expect(html).not.toContain("settings.shell."); }); - it("emits the section heading `id` on exactly ONE instance (no duplicate-id-aria)", () => { - // v1.25.1 (A11Y M1) — the heading paints at two breakpoints (mobile above - // the strip, desktop in the grid); both stay in the DOM. The `id` (the - // `aria-labelledby` target) must appear once, while `data-settings-heading` - // (the focus-effect hook) stays on both instances. - const html = renderShell({ active: "account" }); - expect(count(html, /id="settings-section-account-title"/g)).toBe(1); - expect( - count(html, /data-settings-heading="settings-section-account-title"/g), - ).toBe(2); - }); - - it("mobile group strip uses `no-scrollbar` so the swipe area doesn't paint a horizontal scrollbar", () => { + it("mobile section strip uses `no-scrollbar` so the swipe area doesn't paint a horizontal scrollbar", () => { + // The 10-section strip is wider than 393 CSS px, so without + // `no-scrollbar` Chromium/WebKit paints an always-on horizontal + // scrollbar at the top of every settings page. The class is + // defined in `globals.css` and combines `scrollbar-width: none` + // (Firefox) with `::-webkit-scrollbar { display: none }` (everyone + // else) — scroll behaviour is preserved. const html = renderShell({ active: "account" }); const nav = html.match(/]*md:hidden[^>]*>/); expect(nav).not.toBeNull(); diff --git a/src/components/settings/account-delete-card.tsx b/src/components/settings/account-delete-card.tsx deleted file mode 100644 index f26233280..000000000 --- a/src/components/settings/account-delete-card.tsx +++ /dev/null @@ -1,167 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { Loader2, Trash2 } from "lucide-react"; - -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -import { Button } from "@/components/ui/button"; -import { SettingsCard } from "@/components/settings/settings-card"; -import { useTranslations } from "@/lib/i18n/context"; -import { apiFetchRaw } from "@/lib/api/api-fetch"; - -/** - * v1.4.43 QoL (M3) — separate destructive action for the full account - * delete. Pre-fix, the only "danger zone" CTA wiped the user's health - * data but left the user row, passkeys, audit log, and sessions - * intact — half of what a user reading "Danger Zone" expects per - * GDPR Article 17. The route `DELETE /api/settings/account` already - * cascades User + passkeys + audit log + sessions (and is rate-limited - * by the API handler); this card is its UI front door. - * - * Visually the card mirrors the data-reset shaping (neutral title, - * red CTA only) but the dialog copy is explicit: this deletes the - * account, not just the data, and the user will be signed out - * immediately. - * - * v1.25.1 (Q2-M3) — moved out of Settings → Advanced (Data & Privacy group) - * into the Account group. Deleting your account is an account-lifecycle - * action, not a data/privacy control, so it belongs where a user looks for - * "my account": next to the profile + sign-in surfaces. Research mode + the - * data reset stay in Advanced. - */ -export function AccountDeleteCard() { - const { t } = useTranslations(); - const [deleting, setDeleting] = useState(false); - const [confirmOpen, setConfirmOpen] = useState(false); - const [msg, setMsg] = useState(null); - const [msgType, setMsgType] = useState<"success" | "error" | null>(null); - - async function handleDeleteAccount() { - if (deleting) return; - setDeleting(true); - setMsg(null); - setMsgType(null); - try { - const res = await apiFetchRaw("/api/settings/account", { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ confirm: "DELETE_ACCOUNT" }), - }); - if (!res.ok) { - try { - const json = await res.json(); - setMsg(json.error || t("settings.deleteAccountFailed")); - } catch { - setMsg(t("settings.deleteAccountFailed")); - } - setMsgType("error"); - setDeleting(false); - return; - } - setMsg(t("settings.deleteAccountSuccess")); - setMsgType("success"); - // The route destroyed every session before deleting the row; - // give the toast a beat to paint, then bounce to the login page. - // Leave `deleting` set so the confirm button keeps its pending - // state through the redirect — the row is gone, nothing to undo. - setTimeout(() => { - window.location.href = "/auth/login"; - }, 1_500); - } catch { - setMsg(t("settings.deleteAccountFailed")); - setMsgType("error"); - setDeleting(false); - } - } - - return ( - -
-
-

- {t("settings.deleteAccountCardTitle")} -

-

- {t("settings.deleteAccountCardDescription")} -

-
- { - // Hold the dialog open while the irreversible delete runs so - // the pending state stays on screen and a stray dismissal - // can't fire a second request. - if (deleting) return; - setConfirmOpen(open); - }} - > - - - - - - - {t("settings.deleteAccountConfirmTitle")} - - - {t("settings.deleteAccountConfirmDescription")} - - - - - {t("common.cancel")} - - { - e.preventDefault(); - void handleDeleteAccount(); - }} - disabled={deleting} - aria-busy={deleting || undefined} - data-slot="settings-account-delete-confirm" - > - {deleting && ( - - )} - {t("settings.deleteAccountFinal")} - - - - -
- - {msg && ( -

- {msg} -

- )} -
- ); -} diff --git a/src/components/settings/account-section/index.tsx b/src/components/settings/account-section/index.tsx index 29980c8f3..16d30e392 100644 --- a/src/components/settings/account-section/index.tsx +++ b/src/components/settings/account-section/index.tsx @@ -37,7 +37,8 @@ import { type StatusMessage, } from "./account-section-utils"; import { AvatarSection } from "./avatar-section"; -import { AccountDeleteCard } from "@/components/settings/account-delete-card"; +import { SecuritySessionsCard } from "@/components/settings/security-sessions-card"; +import { SecurityActivityCard } from "@/components/settings/security-activity-card"; export { resolveInitialTimezone } from "./account-section-utils"; @@ -497,17 +498,11 @@ export function AccountSection() { - {/* v1.25.1 (H1) — active-session management + trusted devices + the - security-activity feed moved to the Account → Security sub-tab, so all - "who can sign in as me" controls (2FA, passkeys, recovery, sessions, - devices, login activity) live in one place instead of being split - across the Profile tab and the Data & Privacy group. */} - - {/* v1.25.1 (Q2-M3) — account deletion lives here now (it was in the - Data & Privacy group's Advanced page). Deleting your account is an - account-lifecycle action; it sits at the foot of the profile surface - as the page's danger zone. */} - + {/* v1.23 — active-session management (issue #64) + the security-activity + feed. Both sit in the account/security block under the credential + controls above. */} + + {/* v1.18.1 (D1) — the "Tour neu starten" card moved to Settings → Erweitert. It is a maintenance / reset action, not a profile or diff --git a/src/components/settings/advanced-section.tsx b/src/components/settings/advanced-section.tsx index 2db8a4bbf..39ef7f925 100644 --- a/src/components/settings/advanced-section.tsx +++ b/src/components/settings/advanced-section.tsx @@ -55,14 +55,11 @@ export function AdvancedSection() { // v1.18.6.1 — the guided tour replay card was removed: the tour now exists // only as the first-time auto-start after onboarding. This page is the // research-mode + data/account destructive controls. - // v1.25.1 (Q2-M3) — account deletion moved to the Account group - // (`` now renders under Settings → Profile & preferences). - // It is an account-lifecycle action, not a data/privacy control. Research - // mode + the data reset stay here. return (
+
); } @@ -339,3 +336,144 @@ function DataResetCard() { ); } + +/** + * v1.4.43 QoL (M3) — separate destructive action for the full account + * delete. Pre-fix, the only "danger zone" CTA wiped the user's health + * data but left the user row, passkeys, audit log, and sessions + * intact — half of what a user reading "Danger Zone" expects per + * GDPR Article 17. The route `DELETE /api/settings/account` already + * cascades User + passkeys + audit log + sessions (and is rate-limited + * by the API handler); this card is its UI front door. + * + * Visually the card mirrors the data-reset shaping (neutral title, + * red CTA only) but the dialog copy is explicit: this deletes the + * account, not just the data, and the user will be signed out + * immediately. + */ +function AccountDeleteCard() { + const { t } = useTranslations(); + const [deleting, setDeleting] = useState(false); + const [confirmOpen, setConfirmOpen] = useState(false); + const [msg, setMsg] = useState(null); + const [msgType, setMsgType] = useState<"success" | "error" | null>(null); + + async function handleDeleteAccount() { + if (deleting) return; + setDeleting(true); + setMsg(null); + setMsgType(null); + try { + const res = await apiFetchRaw("/api/settings/account", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ confirm: "DELETE_ACCOUNT" }), + }); + if (!res.ok) { + try { + const json = await res.json(); + setMsg(json.error || t("settings.deleteAccountFailed")); + } catch { + setMsg(t("settings.deleteAccountFailed")); + } + setMsgType("error"); + setDeleting(false); + return; + } + setMsg(t("settings.deleteAccountSuccess")); + setMsgType("success"); + // The route destroyed every session before deleting the row; + // give the toast a beat to paint, then bounce to the login page. + // Leave `deleting` set so the confirm button keeps its pending + // state through the redirect — the row is gone, nothing to undo. + setTimeout(() => { + window.location.href = "/auth/login"; + }, 1_500); + } catch { + setMsg(t("settings.deleteAccountFailed")); + setMsgType("error"); + setDeleting(false); + } + } + + return ( + +
+
+

+ {t("settings.deleteAccountCardTitle")} +

+

+ {t("settings.deleteAccountCardDescription")} +

+
+ { + // Hold the dialog open while the irreversible delete runs so + // the pending state stays on screen and a stray dismissal + // can't fire a second request. + if (deleting) return; + setConfirmOpen(open); + }} + > + + + + + + + {t("settings.deleteAccountConfirmTitle")} + + + {t("settings.deleteAccountConfirmDescription")} + + + + + {t("common.cancel")} + + { + e.preventDefault(); + void handleDeleteAccount(); + }} + disabled={deleting} + aria-busy={deleting || undefined} + data-slot="settings-account-delete-confirm" + > + {deleting && ( + + )} + {t("settings.deleteAccountFinal")} + + + + +
+ + {msg && ( +

+ {msg} +

+ )} +
+ ); +} diff --git a/src/components/settings/privacy-section.tsx b/src/components/settings/privacy-section.tsx index 119e4831b..6aa6534f4 100644 --- a/src/components/settings/privacy-section.tsx +++ b/src/components/settings/privacy-section.tsx @@ -29,6 +29,9 @@ 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"; +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"; @@ -214,25 +217,10 @@ export function PrivacySection() { - {/* v1.25.1 (H1) — active sessions, trusted devices, and the - security-activity feed moved to Account → Security so all "who can - sign in as me" controls sit in one place. They are not re-embedded - here; this pane keeps the data / retention / encryption story. */} - - -
- -
-
+ {/* Active sessions + trusted devices + security activity (embedded) */} + + + ); } diff --git a/src/components/settings/security-section/__tests__/security-section-signin.test.tsx b/src/components/settings/security-section/__tests__/security-section-signin.test.tsx deleted file mode 100644 index 628f27947..000000000 --- a/src/components/settings/security-section/__tests__/security-section-signin.test.tsx +++ /dev/null @@ -1,70 +0,0 @@ -/** - * v1.25.1 (H1) — sign-in management consolidation. - * - * Active sessions, trusted devices, and the login-activity feed moved out of - * the Data & Privacy group into Account → Security so every "who can sign in as - * me" control sits in one place. This SSR test pins that the three cards render - * inside ``. - */ -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 { SecuritySection } from "../index"; - -function render() { - const client = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - staleTime: Number.POSITIVE_INFINITY, - refetchOnMount: false, - }, - }, - }); - // Seed the MFA status so the second-factor cards paint instead of skeletons. - client.setQueryData(queryKeys.mfaStatus(), { - totp: { enabled: false }, - recoveryCodesRemaining: 0, - webauthn: [], - passkeyNudgeDismissed: true, - }); - client.setQueryData(queryKeys.passkeys(), []); - // Seed the embedded sign-in reads so the cards paint without firing network. - client.setQueryData(queryKeys.sessions(), { sessions: [] }); - client.setQueryData(queryKeys.securityActivity(), { events: [] }); - client.setQueryData(queryKeys.trustedDevices(), { - devices: [ - { - id: "td-1", - label: "Firefox on macOS", - isCurrent: true, - createdAt: "2099-01-01T00:00:00.000Z", - lastUsedAt: "2099-01-01T00:00:00.000Z", - expiresAt: "2099-01-01T00:00:00.000Z", - }, - ], - }); - return renderToStaticMarkup( - - - - - , - ); -} - -describe(" — sign-in management home (H1)", () => { - it("renders the active-session, trusted-device, and security-activity cards", () => { - const html = render(); - expect(html).toContain('data-slot="settings-security-sessions-card"'); - expect(html).toContain('data-slot="settings-trusted-devices-card"'); - expect(html).toContain('data-slot="settings-security-activity-card"'); - }); -}); diff --git a/src/components/settings/security-section/index.tsx b/src/components/settings/security-section/index.tsx index 1ac88487b..f2e9a5ac5 100644 --- a/src/components/settings/security-section/index.tsx +++ b/src/components/settings/security-section/index.tsx @@ -10,14 +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"; -// v1.25.1 (H1) — active sessions, trusted devices, and the login-activity feed -// are "who/what can sign in as me" — the same mental model as the second-factor -// and passkey controls above. They used to live in the Data & Privacy group -// (split across two top-level groups); they now sit here in Account → Security -// as the single sign-in-management home. -import { SecuritySessionsCard } from "@/components/settings/security-sessions-card"; -import { TrustedDevicesCard } from "@/components/settings/trusted-devices-card"; -import { SecurityActivityCard } from "@/components/settings/security-activity-card"; interface MfaStatus { totp: { enabled: boolean }; @@ -53,40 +45,28 @@ export function SecuritySection() { passkeys != null && passkeys.length === 0; - // The second-factor / passkey cards depend on the MFA status payload, so they - // skeleton while it loads. The session / device / activity cards own their own - // reads and loading states, so they render unconditionally below — a slow or - // failed `/api/auth/me/mfa` must never hide active-session revocation. - const mfaCards = - isLoading || !status ? ( - <> + if (isLoading || !status) { + return ( +
- - ) : ( - <> - {showNudge && } - - - - - - - +
); + } return (
- {mfaCards} + {showNudge && } - {/* v1.25.1 (H1) — sign-in management consolidated here. */} - - - + + + + +
); } diff --git a/src/components/settings/settings-hub-back-link.tsx b/src/components/settings/settings-hub-back-link.tsx new file mode 100644 index 000000000..2f1933ca4 --- /dev/null +++ b/src/components/settings/settings-hub-back-link.tsx @@ -0,0 +1,39 @@ +"use client"; + +import Link from "next/link"; +import { ChevronLeft } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { useTranslations } from "@/lib/i18n/context"; + +/** + * v1.17.1 (N-5) — the up-affordance for a hub child editor. + * + * The four Layout personalization editors (dashboard / insights / + * medications / mood) are reached through the "Layout & Personalization" + * hub (or a page-header cog), and the left rail highlights the hub while + * the body shows the child. The return path existed only implicitly (tap + * the highlighted rail entry); this is the explicit "← back to hub" link + * at the top of the child, so the hub → child → hub loop reads clearly, + * especially on mobile where the rail collapses to a chip strip. + */ +export function SettingsHubBackLink({ + href, + labelKey, +}: { + href: string; + labelKey: string; +}) { + const { t } = useTranslations(); + return ( + +