Merge v1.25.1: settings deep-round IA (sign-in consolidation, sub-tab relabels, account-delete relocation, a11y id, ES labels)

This commit is contained in:
Marc-André Bombeck committed 2026-06-29 00:38:38 +02:00
commit 2a66e81031
18 files changed
+516 -253

No files matched your search

+10 -5
View File
@@ -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": {
+10 -5
View File
@@ -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": {
+11 -6
View File
@@ -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": {
+10 -5
View File
@@ -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": {
+10 -5
View File
@@ -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": {
+10 -5
View File
@@ -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": {
+10
View File
@@ -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
@@ -1,16 +1,20 @@
/**
* v1.4.43 QoL (M3 + L5) — `<AdvancedSection>` 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 `<AdvancedSection>` (Data &
* Privacy group) into the Account group as a standalone `<AccountDeleteCard>`.
* The L5 data-reset assertions still target `<AdvancedSection>`; 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("<AdvancedSection> 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(
<I18nProvider initialLocale={locale}>
<AccountDeleteCard />
</I18nProvider>,
);
}
describe("<AdvancedSection> 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("<AdvancedSection> danger-zone shaping (v1.4.43 M3 + L5)", () => {
expect(cardMatch).not.toBeNull();
expect(cardMatch![0]).not.toMatch(/lucide-alert-triangle/);
});
});
describe("<AccountDeleteCard> (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("<AdvancedSection> 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");
});
@@ -52,7 +52,7 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") {
}
describe("<PrivacySection> — 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(<PrivacySection />);
// Encryption-at-rest block driven from the seeded summary.
expect(html).toContain("AES-256-GCM");
@@ -64,11 +64,13 @@ describe("<PrivacySection> — 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", () => {
@@ -126,12 +126,16 @@ function renderFramed(
}
describe("settings sections — SSR smoke", () => {
it("<AccountSection> renders the Account heading and Profile card", () => {
it("<AccountSection> renders the Profile heading and Profile card", () => {
const html = renderFramed("account", <AccountSection />);
// 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 &amp; 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("<IntegrationsSection> renders Withings card title", () => {
const html = renderFramed("integrations", <IntegrationsSection />);
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");
});
@@ -348,6 +348,18 @@ describe("<SettingsShell>", () => {
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(/<nav\b[^>]*md:hidden[^>]*>/);
@@ -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<string | null>(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 (
<SettingsCard data-slot="settings-account-delete-card">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
<div className="space-y-1">
<h2 className="text-foreground text-lg font-semibold">
{t("settings.deleteAccountCardTitle")}
</h2>
<p className="text-muted-foreground text-xs">
{t("settings.deleteAccountCardDescription")}
</p>
</div>
<AlertDialog
open={confirmOpen}
onOpenChange={(open) => {
// 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);
}}
>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
disabled={deleting}
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
data-slot="settings-account-delete-trigger"
>
{deleting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
{t("settings.deleteAccountCta")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.deleteAccountConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.deleteAccountConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={(e) => {
e.preventDefault();
void handleDeleteAccount();
}}
disabled={deleting}
aria-busy={deleting || undefined}
data-slot="settings-account-delete-confirm"
>
{deleting && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.deleteAccountFinal")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{msg && (
<p
role="alert"
className={`mt-3 text-sm ${msgType === "success" ? "text-success" : "text-destructive"}`}
>
{msg}
</p>
)}
</SettingsCard>
);
}
@@ -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() {
</div>
</SettingsCard>
{/* v1.23 — active-session management (issue #64) + the security-activity
feed. Both sit in the account/security block under the credential
controls above. */}
<SecuritySessionsCard isAuthenticated={isAuthenticated} />
<SecurityActivityCard isAuthenticated={isAuthenticated} />
{/* 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. */}
<AccountDeleteCard />
{/* v1.18.1 (D1) — the "Tour neu starten" card moved to Settings →
Erweitert. It is a maintenance / reset action, not a profile or
+4 -142
View File
@@ -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
// (`<AccountDeleteCard>` 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 (
<div className="space-y-6">
<ResearchModeCard />
<DataResetCard />
<AccountDeleteCard />
</div>
);
}
@@ -336,144 +339,3 @@ function DataResetCard() {
</SettingsCard>
);
}
/**
* 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<string | null>(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 (
<SettingsCard data-slot="settings-account-delete-card">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
<div className="space-y-1">
<h2 className="text-foreground text-lg font-semibold">
{t("settings.deleteAccountCardTitle")}
</h2>
<p className="text-muted-foreground text-xs">
{t("settings.deleteAccountCardDescription")}
</p>
</div>
<AlertDialog
open={confirmOpen}
onOpenChange={(open) => {
// 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);
}}
>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
disabled={deleting}
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
data-slot="settings-account-delete-trigger"
>
{deleting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
{t("settings.deleteAccountCta")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.deleteAccountConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.deleteAccountConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={(e) => {
e.preventDefault();
void handleDeleteAccount();
}}
disabled={deleting}
aria-busy={deleting || undefined}
data-slot="settings-account-delete-confirm"
>
{deleting && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.deleteAccountFinal")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{msg && (
<p
role="alert"
className={`mt-3 text-sm ${msgType === "success" ? "text-success" : "text-destructive"}`}
>
{msg}
</p>
)}
</SettingsCard>
);
}
+19 -7
View File
@@ -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() {
</div>
</SettingsCard>
{/* Active sessions + trusted devices + security activity (embedded) */}
<SecuritySessionsCard isAuthenticated={isAuthenticated} />
<TrustedDevicesCard isAuthenticated={isAuthenticated} />
<SecurityActivityCard isAuthenticated={isAuthenticated} />
{/* 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. */}
<SettingsCard>
<SettingsCardHeader
icon={ShieldCheck}
title={t("settings.privacy.signIn.title")}
description={t("settings.privacy.signIn.description")}
className="mb-4"
/>
<div className="pl-7">
<Button asChild variant="outline" size="sm">
<Link href="/settings/security">
{t("settings.privacy.signIn.openSecurity")}
</Link>
</Button>
</div>
</SettingsCard>
</div>
);
}
@@ -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 `<SecuritySection>`.
*/
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(
<I18nProvider initialLocale="en">
<QueryClientProvider client={client}>
<SecuritySection />
</QueryClientProvider>
</I18nProvider>,
);
}
describe("<SecuritySection> — 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"');
});
});
@@ -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 (
<div className="space-y-4">
// 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 ? (
<>
<Skeleton className="h-40 w-full rounded-xl" />
<Skeleton className="h-40 w-full rounded-xl" />
<Skeleton className="h-40 w-full rounded-xl" />
</div>
</>
) : (
<>
{showNudge && <PasskeyUpgradeNudge />}
<TotpCard
enabled={status.totp.enabled}
recoveryCodesRemaining={status.recoveryCodesRemaining}
/>
<SecurityKeysCard keys={status.webauthn} />
<PasskeyListSection isAuthenticated={isAuthenticated} />
</>
);
}
return (
<div className="space-y-6">
{showNudge && <PasskeyUpgradeNudge />}
{mfaCards}
<TotpCard
enabled={status.totp.enabled}
recoveryCodesRemaining={status.recoveryCodesRemaining}
/>
<SecurityKeysCard keys={status.webauthn} />
<PasskeyListSection isAuthenticated={isAuthenticated} />
{/* v1.25.1 (H1) — sign-in management consolidated here. */}
<SecuritySessionsCard isAuthenticated={isAuthenticated} />
<TrustedDevicesCard isAuthenticated={isAuthenticated} />
<SecurityActivityCard isAuthenticated={isAuthenticated} />
</div>
);
}
+73 -32
View File
@@ -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<HTMLElement | null>(null);
React.useEffect(() => {
if (typeof window === "undefined") return;
const strip = subTabStripRef.current;
if (!strip) return;
const active = strip.querySelector<HTMLElement>('[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 ? (
<div className="space-y-6">
{resolvedHeading.topSlot ? <div>{resolvedHeading.topSlot}</div> : null}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h1
id={resolvedHeading.headingId}
data-settings-heading={resolvedHeading.headingId}
// v1.25.1 (A11Y-L6) — programmatically focusable so an in-shell
// section switch can move focus here (see the `activeSlug` effect).
// `tabIndex={-1}` keeps it out of the sequential tab order but lets
// `.focus()` land, so keyboard / screen-reader users are placed in
// the new section's content instead of being left on the nav link.
tabIndex={-1}
className="text-2xl font-bold tracking-tight outline-none"
>
{resolvedHeading.title}
</h1>
{resolvedHeading.subtitle ? (
<p className="text-muted-foreground text-sm">
{resolvedHeading.subtitle}
</p>
// 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 ? (
<div className="space-y-6">
{resolvedHeading.topSlot ? <div>{resolvedHeading.topSlot}</div> : null}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h1
{...(withId ? { id: resolvedHeading.headingId } : {})}
data-settings-heading={resolvedHeading.headingId}
// v1.25.1 (A11Y-L6) — programmatically focusable so an in-shell
// section switch can move focus here (see the `activeSlug` effect).
// `tabIndex={-1}` keeps it out of the sequential tab order but lets
// `.focus()` land, so keyboard / screen-reader users are placed in
// the new section's content instead of being left on the nav link.
tabIndex={-1}
className="text-2xl font-bold tracking-tight outline-none"
>
{resolvedHeading.title}
</h1>
{resolvedHeading.subtitle ? (
<p className="text-muted-foreground text-sm">
{resolvedHeading.subtitle}
</p>
) : null}
</div>
{resolvedHeading.headingAccessory ? (
<div className="shrink-0">{resolvedHeading.headingAccessory}</div>
) : null}
</div>
{resolvedHeading.headingAccessory ? (
<div className="shrink-0">{resolvedHeading.headingAccessory}</div>
) : null}
</div>
</div>
) : 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 ? (
<div className="mb-4 md:hidden">{headingBlock}</div>
{mobileHeadingBlock ? (
<div className="mb-4 md:hidden">{mobileHeadingBlock}</div>
) : 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. */}
<div className="grid gap-6 md:grid-cols-[220px_1fr] md:grid-rows-[auto_1fr]">
{/* Heading — desktop only here (mobile renders it above the strip). */}
{headingBlock ? (
{desktopHeadingBlock ? (
<div className="hidden md:col-start-2 md:row-start-1 md:block">
{headingBlock}
{desktopHeadingBlock}
</div>
) : 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 ? (
<nav
ref={subTabStripRef}
aria-label={t("settings.shell.subSectionsNav")}
data-slot="settings-subtabs"
className="no-scrollbar bg-muted/50 -mx-1 mb-6 flex gap-1 overflow-x-auto rounded-lg p-1"