From aa432244cedf267b956210a12202e40ea63846a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 29 Jun 2026 00:34:24 +0200 Subject: [PATCH] fix(settings): consolidate sign-in management and tidy the settings IA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups on the v1.25.1 settings group consolidation. - Sign-in security is no longer split across two top-level groups. Active sessions, trusted devices, and the login-activity feed move into Account > Security alongside the second-factor and passkey controls, so every "who can sign in as me" control sits in one place. The Data & Privacy pane keeps the data / retention / encryption story and cross-links to the security home. Sessions and activity are no longer duplicated on the Profile tab. - First-child sub-tabs no longer repeat their group name: the section titles become "Profile & preferences", "Connections", and "Privacy & retention" across all six locales. - Account deletion moves out of Advanced into the Account group as a standalone card; research mode and the data reset stay in Advanced. - Disambiguate the Spanish labels for the health record vs medical history (they both read "Historial médico"); medical history becomes "Antecedentes médicos". - The section heading carries its id on a single breakpoint instance to clear a duplicate-id-aria violation; the focus hook keeps targeting both instances and picks the visible one. - The in-page sub-tab strip scrolls its active tab into view on a narrow viewport, matching the group strip. - Redirect /settings/layout to the Display landing now that nothing links to the old personalisation hub. - Note in code that delivery channels deliberately stay under Integrations (a channel is a delivery provider). --- messages/de.json | 15 +- messages/en.json | 15 +- messages/es.json | 17 +- messages/fr.json | 15 +- messages/it.json | 15 +- messages/pl.json | 15 +- next.config.ts | 10 ++ .../advanced-account-delete.test.tsx | 46 +++-- .../__tests__/privacy-section.test.tsx | 14 +- .../settings/__tests__/sections.test.tsx | 14 +- .../__tests__/settings-shell.test.tsx | 12 ++ .../settings/account-delete-card.tsx | 167 ++++++++++++++++++ .../settings/account-section/index.tsx | 19 +- src/components/settings/advanced-section.tsx | 146 +-------------- src/components/settings/privacy-section.tsx | 26 ++- .../security-section-signin.test.tsx | 70 ++++++++ .../settings/security-section/index.tsx | 48 +++-- src/components/settings/settings-shell.tsx | 105 +++++++---- 18 files changed, 516 insertions(+), 253 deletions(-) create mode 100644 src/components/settings/account-delete-card.tsx create mode 100644 src/components/settings/security-section/__tests__/security-section-signin.test.tsx diff --git a/messages/de.json b/messages/de.json index b5a71a557..44c75d707 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4052,12 +4052,12 @@ }, "sections": { "account": { - "title": "Konto", + "title": "Profil & Einstellungen", "description": "Profil, Passwort, Passkeys und deine Onboarding-Tour.", "subtitle": "Dein Profil, deine Identität und deine Anmeldedaten." }, "integrations": { - "title": "Integrationen", + "title": "Verbindungen", "description": "Verbundene Dienste, Zustellkanäle und die Quellen-Priorität an einem Ort.", "subtitle": "Datenquellen und Geräte verbinden und verwalten." }, @@ -4349,9 +4349,9 @@ "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." + "title": "Datenschutz & Aufbewahrung", + "subtitle": "Deine Daten, Aufbewahrung und Verschlüsselung an einem Ort.", + "description": "Export, Löschung, Aufbewahrung und Verschlüsselungsstatus." }, "mcp": { "title": "MCP-Connector", @@ -5318,6 +5318,11 @@ "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 52b44e938..f56b31db5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4052,12 +4052,12 @@ }, "sections": { "account": { - "title": "Account", + "title": "Profile & preferences", "description": "Profile, password, passkeys, and your onboarding tour.", "subtitle": "Your profile, identity, and sign-in details." }, "integrations": { - "title": "Integrations", + "title": "Connections", "description": "Connected services, delivery channels, and source priority in one place.", "subtitle": "Connect and manage your data sources and devices." }, @@ -4349,9 +4349,9 @@ "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." + "title": "Privacy & retention", + "subtitle": "Your data, retention, and encryption in one place.", + "description": "Export, deletion, retention, and encryption status." }, "mcp": { "title": "MCP Connector", @@ -5318,6 +5318,11 @@ "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 aef6b734b..4e4310ca7 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4052,12 +4052,12 @@ }, "sections": { "account": { - "title": "Cuenta", + "title": "Perfil y preferencias", "description": "Perfil, contraseña, claves de acceso y tu recorrido de incorporación.", "subtitle": "Tu perfil, identidad y datos de acceso." }, "integrations": { - "title": "Integraciones", + "title": "Conexiones", "description": "Servicios conectados, canales de entrega y prioridad de fuentes en un solo lugar.", "subtitle": "Conecta y gestiona tus fuentes de datos y dispositivos." }, @@ -4349,16 +4349,16 @@ "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." + "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." }, "mcp": { "title": "Conector MCP", "subtitle": "Conecta un asistente externo a tus propios datos de salud." }, "anamnesis": { - "title": "Historial médico", + "title": "Antecedentes médicos", "subtitle": "Gestiona tus alergias y tus antecedentes familiares.", "description": "Registros estructurados de alergias y antecedentes familiares." }, @@ -5318,6 +5318,11 @@ "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 61c28e78e..71affa25e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4052,12 +4052,12 @@ }, "sections": { "account": { - "title": "Compte", + "title": "Profil et préférences", "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": "Intégrations", + "title": "Connexions", "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." }, @@ -4349,9 +4349,9 @@ "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é." + "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." }, "mcp": { "title": "Connecteur MCP", @@ -5318,6 +5318,11 @@ "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 60f4161b3..791d1b676 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4052,12 +4052,12 @@ }, "sections": { "account": { - "title": "Account", + "title": "Profilo e preferenze", "description": "Profilo, password, passkey e il tour di benvenuto.", "subtitle": "Il tuo profilo, la tua identità e i dati di accesso." }, "integrations": { - "title": "Integrazioni", + "title": "Connessioni", "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." }, @@ -4349,9 +4349,9 @@ "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." + "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." }, "mcp": { "title": "Connettore MCP", @@ -5318,6 +5318,11 @@ "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 0978c5675..be90d11ac 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4052,12 +4052,12 @@ }, "sections": { "account": { - "title": "Konto", + "title": "Profil i preferencje", "description": "Profil, hasło, klucze dostępu i przewodnik wprowadzający.", "subtitle": "Twój profil, tożsamość i dane logowania." }, "integrations": { - "title": "Integracje", + "title": "Połączenia", "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." }, @@ -4349,9 +4349,9 @@ "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ń." + "title": "Prywatność i przechowywanie", + "subtitle": "Twoje dane, retencja i szyfrowanie w jednym miejscu.", + "description": "Eksport, usuwanie, retencja i stan szyfrowania." }, "mcp": { "title": "Łącznik MCP", @@ -5318,6 +5318,11 @@ "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 2c6e0c4ed..3ba5f19c5 100644 --- a/next.config.ts +++ b/next.config.ts @@ -134,6 +134,16 @@ 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 fd1c4e711..19e6dccbd 100644 --- a/src/components/settings/__tests__/advanced-account-delete.test.tsx +++ b/src/components/settings/__tests__/advanced-account-delete.test.tsx @@ -1,16 +1,20 @@ /** - * 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`) 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. + * (`AccountDeleteCard`) gives the GDPR Article 17 full-erasure CTA its + * own surface alongside the half-erasure data reset. * * 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. @@ -34,6 +38,7 @@ 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( @@ -43,11 +48,25 @@ function render(locale: "en" | "de" = "en") { ); } -describe(" danger-zone shaping (v1.4.43 M3 + L5)", () => { - it("mounts both the data-reset card and the account-delete card", () => { +function renderDeleteCard(locale: "en" | "de" = "en") { + return renderToStaticMarkup( + + + , + ); +} + +describe(" danger-zone shaping (v1.4.43 L5)", () => { + it("mounts the data-reset card", () => { const html = render(); expect(html).toContain('data-slot="settings-data-reset-card"'); - expect(html).toContain('data-slot="settings-account-delete-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"'); }); it("L5: the data-reset card title is neutral foreground (no destructive red)", () => { @@ -75,9 +94,16 @@ describe(" danger-zone shaping (v1.4.43 M3 + 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 = render(); + const html = renderDeleteCard(); 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. @@ -91,14 +117,14 @@ describe(" danger-zone shaping (v1.4.43 M3 + L5)", () => { }); it("M3: the account-delete card surfaces tight DE copy under the 'de' locale", () => { - const html = render("de"); + const html = renderDeleteCard("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 = render("en"); + const html = renderDeleteCard("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 288a064d4..7fcc39a6f 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 sections (encryption, retention, export, delete, sessions, activity)", () => { + it("renders the assembled data/retention sections and links out (no embedded sign-in cards)", () => { const html = render(); // Encryption-at-rest block driven from the seeded summary. expect(html).toContain("AES-256-GCM"); @@ -64,11 +64,13 @@ describe(" — assembled Data & Privacy dashboard", () => { 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"); - // 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"); + // 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"'); }); 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 5744b4ef7..9823a60dd 100644 --- a/src/components/settings/__tests__/sections.test.tsx +++ b/src/components/settings/__tests__/sections.test.tsx @@ -126,12 +126,16 @@ function renderFramed( } describe("settings sections — SSR smoke", () => { - it(" renders the Account heading and Profile card", () => { + it(" renders the Profile heading and Profile card", () => { const html = renderFramed("account", ); - // Section title resolves via the frame + i18n provider. - expect(html).toContain("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"); // 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."); }); @@ -184,7 +188,9 @@ describe("settings sections — SSR smoke", () => { it(" renders Withings card title", () => { const html = renderFramed("integrations", ); - expect(html).toContain("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("Withings"); }); diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx index 1f84fdfef..e36a6d369 100644 --- a/src/components/settings/__tests__/settings-shell.test.tsx +++ b/src/components/settings/__tests__/settings-shell.test.tsx @@ -348,6 +348,18 @@ describe("", () => { 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", () => { const html = renderShell({ active: "account" }); const nav = html.match(/]*md:hidden[^>]*>/); diff --git a/src/components/settings/account-delete-card.tsx b/src/components/settings/account-delete-card.tsx new file mode 100644 index 000000000..f26233280 --- /dev/null +++ b/src/components/settings/account-delete-card.tsx @@ -0,0 +1,167 @@ +"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 16d30e392..29980c8f3 100644 --- a/src/components/settings/account-section/index.tsx +++ b/src/components/settings/account-section/index.tsx @@ -37,8 +37,7 @@ import { type StatusMessage, } from "./account-section-utils"; import { AvatarSection } from "./avatar-section"; -import { SecuritySessionsCard } from "@/components/settings/security-sessions-card"; -import { SecurityActivityCard } from "@/components/settings/security-activity-card"; +import { AccountDeleteCard } from "@/components/settings/account-delete-card"; export { resolveInitialTimezone } from "./account-section-utils"; @@ -498,11 +497,17 @@ export function AccountSection() { - {/* v1.23 — active-session management (issue #64) + the security-activity - feed. Both sit in the account/security block under the credential - controls above. */} - - + {/* 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.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 39ef7f925..2db8a4bbf 100644 --- a/src/components/settings/advanced-section.tsx +++ b/src/components/settings/advanced-section.tsx @@ -55,11 +55,14 @@ 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 (
-
); } @@ -336,144 +339,3 @@ 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 6aa6534f4..119e4831b 100644 --- a/src/components/settings/privacy-section.tsx +++ b/src/components/settings/privacy-section.tsx @@ -29,9 +29,6 @@ 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"; @@ -217,10 +214,25 @@ export function PrivacySection() { - {/* Active sessions + trusted devices + security activity (embedded) */} - - - + {/* 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. */} + + +
+ +
+
); } 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 new file mode 100644 index 000000000..628f27947 --- /dev/null +++ b/src/components/settings/security-section/__tests__/security-section-signin.test.tsx @@ -0,0 +1,70 @@ +/** + * 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 f2e9a5ac5..1ac88487b 100644 --- a/src/components/settings/security-section/index.tsx +++ b/src/components/settings/security-section/index.tsx @@ -10,6 +10,14 @@ 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 }; @@ -45,28 +53,40 @@ export function SecuritySection() { passkeys != null && passkeys.length === 0; - if (isLoading || !status) { - return ( -
+ // 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 ? ( + <> -
+ + ) : ( + <> + {showNudge && } + + + + + + + ); - } return (
- {showNudge && } + {mfaCards} - - - - - + {/* v1.25.1 (H1) — sign-in management consolidated here. */} + + +
); } diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index e4036657e..0559772b6 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -366,6 +366,11 @@ export const SETTINGS_GROUPS: readonly SettingsGroup[] = [ id: "integrations", titleKey: "settings.groups.integrations.title", icon: Link2, + // `channels` (delivery channels) stays under Integrations by deliberate + // choice (Q2-M1): a channel is a delivery PROVIDER, which the standing + // "providers → Integrations" principle places here, not under Notifications + // (which owns reminder TYPES). Reconsidered and kept as status quo in + // v1.25.1 — do not move it to the Notifications group. children: ["integrations", "channels", "sources"], }, { @@ -567,6 +572,25 @@ export function SettingsShell({ strip.scrollTo({ left: target, behavior: "auto" }); }, [activeSlug]); + // v1.25.1 (Q3-M3) — the in-page SUB-TAB strip needs the same + // scroll-active-into-view treatment as the group strip above. The Tracking + // group renders up to nine sub-tabs in an `overflow-x-auto` strip; deep-linking + // (or navigating) to a late tab — thresholds / environment / anamnesis — left + // the active `[aria-current="page"]` chip off-screen to the right, reading as + // "nothing selected" on a narrow viewport. Pin it to the strip's left edge, + // clamped to the scroll range, with the same instant (non-animated) jump. + const subTabStripRef = React.useRef(null); + React.useEffect(() => { + if (typeof window === "undefined") return; + const strip = subTabStripRef.current; + if (!strip) return; + const active = strip.querySelector('[aria-current="page"]'); + if (!active) return; + const maxScroll = strip.scrollWidth - strip.clientWidth; + const target = Math.max(0, Math.min(active.offsetLeft, maxScroll)); + strip.scrollTo({ left: target, behavior: "auto" }); + }, [activeSlug]); + // v1.18.6.1 — resolve the heading once. Pages pass an explicit `heading` // (the canonical path); the slug fallback keeps the shell renderable on // its own (and in tests that mount it without a heading) by deriving the @@ -618,36 +642,51 @@ export function SettingsShell({ } }, [activeSlug, headingId]); - const headingBlock = resolvedHeading ? ( -
- {resolvedHeading.topSlot ?
{resolvedHeading.topSlot}
: null} -
-
-

- {resolvedHeading.title} -

- {resolvedHeading.subtitle ? ( -

- {resolvedHeading.subtitle} -

+ // v1.25.1 (A11Y M1) — the heading node is painted at two breakpoints (mobile + // above the strip, desktop inside the grid). Both stay in the DOM (CSS + // `display:none`, not unmounted), so emitting the `id` on both produced a real + // `duplicate-id-aria` violation (the id is the `aria-labelledby` target of the + // section body, and any `getElementById` consumer would resolve the hidden + // instance). The `id` now lands on ONE instance only (`withId`). The focus + // effect targets `data-settings-heading` (kept on both) and picks the visible + // one, and `aria-labelledby` still resolves because referenced-element text + // counts even when the element is `display:none`. + const makeHeadingBlock = (withId: boolean) => + resolvedHeading ? ( +
+ {resolvedHeading.topSlot ?
{resolvedHeading.topSlot}
: null} +
+
+

+ {resolvedHeading.title} +

+ {resolvedHeading.subtitle ? ( +

+ {resolvedHeading.subtitle} +

+ ) : null} +
+ {resolvedHeading.headingAccessory ? ( +
{resolvedHeading.headingAccessory}
) : null}
- {resolvedHeading.headingAccessory ? ( -
{resolvedHeading.headingAccessory}
- ) : null}
-
- ) : null; + ) : null; + + // The desktop instance carries the canonical `id`; the mobile instance is + // id-less (it keeps `data-settings-heading` for the focus effect). + const mobileHeadingBlock = makeHeadingBlock(false); + const desktopHeadingBlock = makeHeadingBlock(true); // v1.4.25 W8 — AuthShell wraps the page in `px-4 py-6 md:px-6` // already, so this inner shell only carries the wider max-width. @@ -659,8 +698,8 @@ export function SettingsShell({ {/* v1.18.6.1 — on mobile the heading sits above the chip strip; on desktop it is rendered inside the grid (row 1 / content column) so it does not paint twice. */} - {headingBlock ? ( -
{headingBlock}
+ {mobileHeadingBlock ? ( +
{mobileHeadingBlock}
) : null} {/* Mobile section strip — horizontal scroll, hidden on md+. @@ -722,9 +761,9 @@ export function SettingsShell({ the `space-y-6` the heading-to-first-card gap used to carry. */}
{/* Heading — desktop only here (mobile renders it above the strip). */} - {headingBlock ? ( + {desktopHeadingBlock ? (
- {headingBlock} + {desktopHeadingBlock}
) : null} @@ -791,11 +830,13 @@ export function SettingsShell({ {/* v1.25.1 — in-page sub-tabs for the active group. Painted only when the group has 2+ visible children (single-child groups — Notifications, About — render no strip). Horizontal scroll keeps - the 8-tab Tracking group from overflowing a narrow viewport. A + the nine-tab Tracking group from overflowing a narrow viewport, + and the `subTabStripRef` effect scrolls the active tab into view. A segmented control (shadcn-style) on every breakpoint. Each tab is a real route Link so deep links + the back/forward stack work. */} {subTabs.length > 1 ? (