mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
revert(settings): restore the flat settings navigation
Revert the nine-group consolidation and the card relocations back to the flat sidebar: sessions, trusted devices, and login activity return to Privacy, and account deletion returns inline under Advanced. Drop the /settings/layout redirect and the now-unused group and sign-in cross-link i18n keys across all six locales. Keep the collapse-by-default sessions and activity cards, the medical-history cross-link from "About me", and the chronic-conditions consolidation.
This commit is contained in:
1 parent
7bd26ff2bd
commit
f564cdeef7
19 files changed
+518
-1133
No files matched your search
+7
-42
@@ -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": {
|
||||
|
||||
+7
-42
@@ -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": {
|
||||
|
||||
+7
-42
@@ -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": {
|
||||
|
||||
+7
-42
@@ -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": {
|
||||
|
||||
+7
-42
@@ -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": {
|
||||
|
||||
+7
-42
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
/**
|
||||
* v1.4.43 QoL (M3 + L5) — danger-zone shaping.
|
||||
* v1.4.43 QoL (M3 + L5) — `<AdvancedSection>` 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 `<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.
|
||||
@@ -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(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<AccountDeleteCard />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<AdvancedSection> danger-zone shaping (v1.4.43 L5)", () => {
|
||||
it("mounts the data-reset card", () => {
|
||||
describe("<AdvancedSection> 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("<AdvancedSection> danger-zone shaping (v1.4.43 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 = 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("<AccountDeleteCard> (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");
|
||||
});
|
||||
|
||||
@@ -52,7 +52,7 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
}
|
||||
|
||||
describe("<PrivacySection> — 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(<PrivacySection />);
|
||||
// Encryption-at-rest block driven from the seeded summary.
|
||||
expect(html).toContain("AES-256-GCM");
|
||||
@@ -64,13 +64,11 @@ describe("<PrivacySection> — 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", () => {
|
||||
|
||||
@@ -126,16 +126,12 @@ function renderFramed(
|
||||
}
|
||||
|
||||
describe("settings sections — SSR smoke", () => {
|
||||
it("<AccountSection> renders the Profile heading and Profile card", () => {
|
||||
it("<AccountSection> renders the Account heading and Profile card", () => {
|
||||
const html = renderFramed("account", <AccountSection />);
|
||||
// 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("<IntegrationsSection> renders Withings card title", () => {
|
||||
const html = renderFramed("integrations", <IntegrationsSection />);
|
||||
// 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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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("<SettingsShell>", () => {
|
||||
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/<slug>` 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 =
|
||||
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/notifications"|<a\b[^>]*\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 =
|
||||
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/api"|<a\b[^>]*\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 =
|
||||
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/layout"|<a\b[^>]*\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 =
|
||||
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/layout"|<a\b[^>]*\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 =
|
||||
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/mood"|<a\b[^>]*\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<Record<string, boolean> | 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 =
|
||||
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/account"|<a\b[^>]*\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</a>");
|
||||
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</a>");
|
||||
// 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</a>");
|
||||
// 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(/<nav\b[^>]*md:hidden[^>]*>/);
|
||||
expect(nav).not.toBeNull();
|
||||
|
||||
@@ -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<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,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() {
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
{/* 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.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.18.1 (D1) — the "Tour neu starten" card moved to Settings →
|
||||
Erweitert. It is a maintenance / reset action, not a profile or
|
||||
|
||||
@@ -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
|
||||
// (`<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>
|
||||
);
|
||||
}
|
||||
@@ -339,3 +336,144 @@ 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>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
{/* 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>
|
||||
{/* Active sessions + trusted devices + security activity (embedded) */}
|
||||
<SecuritySessionsCard isAuthenticated={isAuthenticated} />
|
||||
<TrustedDevicesCard isAuthenticated={isAuthenticated} />
|
||||
<SecurityActivityCard isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 `<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,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 (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{showNudge && <PasskeyUpgradeNudge />}
|
||||
|
||||
<TotpCard
|
||||
enabled={status.totp.enabled}
|
||||
recoveryCodesRemaining={status.recoveryCodesRemaining}
|
||||
/>
|
||||
|
||||
<SecurityKeysCard keys={status.webauthn} />
|
||||
|
||||
<PasskeyListSection isAuthenticated={isAuthenticated} />
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{mfaCards}
|
||||
{showNudge && <PasskeyUpgradeNudge />}
|
||||
|
||||
{/* v1.25.1 (H1) — sign-in management consolidated here. */}
|
||||
<SecuritySessionsCard isAuthenticated={isAuthenticated} />
|
||||
<TrustedDevicesCard isAuthenticated={isAuthenticated} />
|
||||
<SecurityActivityCard isAuthenticated={isAuthenticated} />
|
||||
<TotpCard
|
||||
enabled={status.totp.enabled}
|
||||
recoveryCodesRemaining={status.recoveryCodesRemaining}
|
||||
/>
|
||||
|
||||
<SecurityKeysCard keys={status.webauthn} />
|
||||
|
||||
<PasskeyListSection isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground -mx-1 inline-flex w-fit items-center gap-1 rounded-md px-1 py-0.5 text-xs font-medium transition-colors",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
{t(labelKey)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
isSettingsSectionSlug,
|
||||
type SettingsSectionSlug,
|
||||
} from "./section-slugs";
|
||||
import { SettingsHubBackLink } from "./settings-hub-back-link";
|
||||
|
||||
// Re-export so existing call-sites importing from this module keep working.
|
||||
// New server-side call-sites (e.g. `generateStaticParams()`) should import
|
||||
@@ -288,142 +289,6 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* v1.25.1 — Settings information-architecture consolidation.
|
||||
*
|
||||
* The left rail used to render one entry per section (26 visible). It now
|
||||
* renders NINE top-level GROUPS. Every section route stays live at its own
|
||||
* URL — `generateStaticParams()` still emits every slug, deep links and the
|
||||
* per-page settings cogs still resolve — but the sidebar is driven by this
|
||||
* group model and each group page surfaces its children as in-page sub-tabs.
|
||||
*
|
||||
* The mechanism generalises the old `LAYOUT_CHILD_SLUGS` + `navHighlightSlug`
|
||||
* trick (a live route, hidden from the rail, highlighting a parent): every
|
||||
* child slug now maps to its group for rail highlighting, and the group page
|
||||
* paints a sub-tab strip across its visible children.
|
||||
*
|
||||
* Two levels everywhere — the rail is one level, the sub-tabs are the second,
|
||||
* no child of a child.
|
||||
*/
|
||||
export type SettingsGroupId =
|
||||
| "account"
|
||||
| "tracking"
|
||||
| "display"
|
||||
| "integrations"
|
||||
| "notifications"
|
||||
| "ai"
|
||||
| "access"
|
||||
| "data"
|
||||
| "about";
|
||||
|
||||
export interface SettingsGroup {
|
||||
id: SettingsGroupId;
|
||||
/** i18n key under `settings.groups.<id>.title`. */
|
||||
titleKey: string;
|
||||
icon: LucideIcon;
|
||||
/**
|
||||
* Ordered child section slugs. Rendered as in-page sub-tabs (filtered by
|
||||
* module gate) when more than one is visible. The first VISIBLE child is
|
||||
* the group's landing route — the rail entry links there so a click never
|
||||
* lands on a module-gated-off page.
|
||||
*/
|
||||
children: readonly SettingsSectionSlug[];
|
||||
}
|
||||
|
||||
export const SETTINGS_GROUPS: readonly SettingsGroup[] = [
|
||||
{
|
||||
id: "account",
|
||||
titleKey: "settings.groups.account.title",
|
||||
icon: User,
|
||||
children: ["account", "security"],
|
||||
},
|
||||
{
|
||||
id: "tracking",
|
||||
titleKey: "settings.groups.tracking.title",
|
||||
icon: ClipboardList,
|
||||
children: [
|
||||
"modules",
|
||||
"medications",
|
||||
"mood",
|
||||
"labs",
|
||||
"illness",
|
||||
"vorsorge",
|
||||
"anamnesis",
|
||||
"environment",
|
||||
"thresholds",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "display",
|
||||
titleKey: "settings.groups.display.title",
|
||||
icon: LayoutDashboard,
|
||||
// `layout` (the personalisation hub) stays a live route and highlights
|
||||
// this group, but it is not a sub-tab — Dashboard + Insights are the
|
||||
// tabs. See `SLUG_TO_GROUP` below for the `layout` alias.
|
||||
children: ["dashboard", "insights"],
|
||||
},
|
||||
{
|
||||
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"],
|
||||
},
|
||||
{
|
||||
id: "notifications",
|
||||
titleKey: "settings.groups.notifications.title",
|
||||
icon: Bell,
|
||||
children: ["notifications"],
|
||||
},
|
||||
{
|
||||
id: "ai",
|
||||
titleKey: "settings.groups.ai.title",
|
||||
icon: Sparkles,
|
||||
children: ["ai", "coach"],
|
||||
},
|
||||
{
|
||||
id: "access",
|
||||
titleKey: "settings.groups.access.title",
|
||||
icon: KeyRound,
|
||||
children: ["api", "mcp"],
|
||||
},
|
||||
{
|
||||
id: "data",
|
||||
titleKey: "settings.groups.data.title",
|
||||
icon: Lock,
|
||||
children: ["export", "gesundheitsakte", "sharing", "privacy", "advanced"],
|
||||
},
|
||||
{
|
||||
id: "about",
|
||||
titleKey: "settings.groups.about.title",
|
||||
icon: Info,
|
||||
children: ["about"],
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Reverse index: every section slug → its owning group id. Built from
|
||||
* `SETTINGS_GROUPS.children`, plus the `layout` alias (the personalisation
|
||||
* hub route lives under Display but is not one of its sub-tabs).
|
||||
*/
|
||||
const SLUG_TO_GROUP: Record<string, SettingsGroupId> = (() => {
|
||||
const map: Record<string, SettingsGroupId> = {};
|
||||
for (const group of SETTINGS_GROUPS) {
|
||||
for (const child of group.children) map[child] = group.id;
|
||||
}
|
||||
map["layout"] = "display";
|
||||
return map;
|
||||
})();
|
||||
|
||||
/** Per-slug metadata lookup (icon / titleKey / moduleGate) for sub-tabs. */
|
||||
const SECTION_BY_SLUG: ReadonlyMap<string, SettingsSection> = new Map(
|
||||
SETTINGS_SECTIONS.map((section) => [section.slug, section]),
|
||||
);
|
||||
|
||||
export interface SettingsShellProps {
|
||||
/**
|
||||
* Optional override for the active section. When omitted the shell reads
|
||||
@@ -456,40 +321,38 @@ export interface SettingsShellProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active section slug from the route (or an explicit override the
|
||||
* page passes). Unlike the pre-v1.25.1 shell this no longer remaps a child
|
||||
* onto a parent — the real slug is kept so the heading + active sub-tab are
|
||||
* correct. Group highlighting is derived separately via `SLUG_TO_GROUP`.
|
||||
*/
|
||||
// v1.17.1 (F-2) — the personalization editors live under the Layout
|
||||
// hub. They keep their own routes but are NOT standalone nav entries, so
|
||||
// when the user is on one of them the Layout nav entry is the one that
|
||||
// reads active.
|
||||
//
|
||||
// v1.18.0 (S5) — `mood` (Stimmung) and `medications` (Medikamente)
|
||||
// graduated to their own nav entries, so they are no longer Layout-hub
|
||||
// children for highlighting purposes.
|
||||
const LAYOUT_CHILD_SLUGS: ReadonlySet<string> = new Set([
|
||||
"dashboard",
|
||||
"insights",
|
||||
]);
|
||||
|
||||
/** Map a Layout child editor onto the Layout hub for nav highlighting. */
|
||||
function navHighlightSlug(slug: SettingsSectionSlug): SettingsSectionSlug {
|
||||
return LAYOUT_CHILD_SLUGS.has(slug) ? "layout" : slug;
|
||||
}
|
||||
|
||||
function deriveActiveSlug(
|
||||
pathname: string | null,
|
||||
override?: SettingsSectionSlug,
|
||||
): SettingsSectionSlug {
|
||||
if (override) return override;
|
||||
if (override) return navHighlightSlug(override);
|
||||
if (!pathname) return "account";
|
||||
// Match `/settings/<slug>` and ignore any trailing segments (none today,
|
||||
// but cheap insurance for future nested routes).
|
||||
const match = pathname.match(/^\/settings\/([^/]+)/);
|
||||
const candidate = match?.[1] ?? "";
|
||||
if (LAYOUT_CHILD_SLUGS.has(candidate)) return "layout";
|
||||
return isSettingsSectionSlug(candidate) ? candidate : "account";
|
||||
}
|
||||
|
||||
/** Map any section slug onto its owning group (defaults to Account). */
|
||||
function deriveActiveGroup(slug: SettingsSectionSlug): SettingsGroupId {
|
||||
return SLUG_TO_GROUP[slug] ?? "account";
|
||||
}
|
||||
|
||||
/** Is a slug visible given the user's module map? Fails OPEN. */
|
||||
function isSlugVisible(
|
||||
slug: SettingsSectionSlug,
|
||||
modules: Record<string, boolean> | undefined,
|
||||
): boolean {
|
||||
const section = SECTION_BY_SLUG.get(slug);
|
||||
if (!section?.moduleGate) return true;
|
||||
return modules?.[section.moduleGate] !== false;
|
||||
}
|
||||
|
||||
export function SettingsShell({
|
||||
active,
|
||||
heading,
|
||||
@@ -507,36 +370,12 @@ export function SettingsShell({
|
||||
// reads as enabled so an entry never silently disappears. Entries with
|
||||
// no `moduleGate` (global / CORE) are always shown.
|
||||
const modules = user?.modules;
|
||||
const activeGroupId = deriveActiveGroup(activeSlug);
|
||||
|
||||
// v1.25.1 — the rail renders one entry per GROUP. Each group surfaces its
|
||||
// first VISIBLE child as the landing route, so a tap never lands on a
|
||||
// module-gated-off page. A group shows when it has at least one visible
|
||||
// child; every group carries an always-on child, so all nine always render
|
||||
// (the proposal's "no group fully disappears" guarantee).
|
||||
const groupNav = SETTINGS_GROUPS.map((group) => {
|
||||
const visibleChildren = group.children.filter((slug) =>
|
||||
isSlugVisible(slug, modules),
|
||||
);
|
||||
return { group, visibleChildren, landingSlug: visibleChildren[0] };
|
||||
}).filter(
|
||||
(
|
||||
entry,
|
||||
): entry is {
|
||||
group: SettingsGroup;
|
||||
visibleChildren: SettingsSectionSlug[];
|
||||
landingSlug: SettingsSectionSlug;
|
||||
} => entry.landingSlug !== undefined,
|
||||
const visibleSections = SETTINGS_SECTIONS.filter(
|
||||
(section) => !section.moduleGate || modules?.[section.moduleGate] !== false,
|
||||
);
|
||||
|
||||
// Sub-tabs for the active group — painted only when it has 2+ visible
|
||||
// children. `layout` (the Display hub) is not one of Display's tabs, so on
|
||||
// that route no tab reads active and the hub body renders below the strip.
|
||||
const activeGroupEntry = groupNav.find(
|
||||
(entry) => entry.group.id === activeGroupId,
|
||||
const activeSection = visibleSections.find(
|
||||
(section) => section.slug === activeSlug,
|
||||
);
|
||||
const subTabs: SettingsSectionSlug[] =
|
||||
activeGroupEntry?.visibleChildren ?? [];
|
||||
|
||||
// v1.4.33 IW4 — keep the active chip in view inside the horizontal
|
||||
// mobile strip. On a 393 CSS px viewport the strip is wider than the
|
||||
@@ -572,25 +411,6 @@ 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
|
||||
@@ -599,94 +419,44 @@ export function SettingsShell({
|
||||
// below.
|
||||
const resolvedHeading =
|
||||
heading ??
|
||||
(isSettingsSectionSlug(activeSlug)
|
||||
(activeSection
|
||||
? {
|
||||
title: t(`settings.sections.${activeSlug}.title`),
|
||||
subtitle: t(`settings.sections.${activeSlug}.subtitle`),
|
||||
headingId: `settings-section-${activeSlug}-title`,
|
||||
topSlot: undefined,
|
||||
title: t(activeSection.titleKey),
|
||||
subtitle: t(`settings.sections.${activeSection.slug}.subtitle`),
|
||||
headingId: `settings-section-${activeSection.slug}-title`,
|
||||
topSlot: LAYOUT_CHILD_SLUGS.has(activeSection.slug) ? (
|
||||
<SettingsHubBackLink
|
||||
href="/settings/layout"
|
||||
labelKey="settings.sections.layout.backToHub"
|
||||
/>
|
||||
) : undefined,
|
||||
headingAccessory: undefined,
|
||||
}
|
||||
: null);
|
||||
|
||||
// v1.25.1 (A11Y-L6) — move focus to the section heading when the active
|
||||
// section changes WITHIN the mounted shell (a sub-tab tap or a rail click;
|
||||
// the shell instance is reused across `/settings/[section]` param changes,
|
||||
// which is also why the mobile-strip effect above keys on `activeSlug`).
|
||||
// Without this, keyboard / screen-reader users stay focused on the nav link
|
||||
// they activated and the new content is never announced. The very first
|
||||
// mount (a deep link or a fresh entry into Settings) is skipped on purpose:
|
||||
// auto-focusing on initial page load is itself a jarring, unexpected focus
|
||||
// move. The heading id is shared by two instances (mobile above the strip,
|
||||
// desktop in the grid) — only one is visible per breakpoint — so we focus
|
||||
// the visible one. `preventScroll` leaves the global scroll-reset in charge.
|
||||
const headingId = resolvedHeading?.headingId;
|
||||
const firstHeadingFocusRef = React.useRef(true);
|
||||
React.useEffect(() => {
|
||||
if (firstHeadingFocusRef.current) {
|
||||
firstHeadingFocusRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!headingId || typeof document === "undefined") return;
|
||||
const candidates = document.querySelectorAll<HTMLElement>(
|
||||
`[data-settings-heading="${headingId}"]`,
|
||||
);
|
||||
for (const el of candidates) {
|
||||
// `offsetParent === null` ⇒ the element (or an ancestor) is
|
||||
// `display:none` — i.e. the hidden-for-this-breakpoint instance. Skip it
|
||||
// and focus the visible heading.
|
||||
if (el.offsetParent !== null) {
|
||||
el.focus({ preventScroll: true });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [activeSlug, headingId]);
|
||||
|
||||
// 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>
|
||||
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}
|
||||
className="text-2xl font-bold tracking-tight"
|
||||
>
|
||||
{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>
|
||||
) : 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);
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
// 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.
|
||||
@@ -698,8 +468,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. */}
|
||||
{mobileHeadingBlock ? (
|
||||
<div className="mb-4 md:hidden">{mobileHeadingBlock}</div>
|
||||
{headingBlock ? (
|
||||
<div className="mb-4 md:hidden">{headingBlock}</div>
|
||||
) : null}
|
||||
|
||||
{/* Mobile section strip — horizontal scroll, hidden on md+.
|
||||
@@ -722,15 +492,13 @@ export function SettingsShell({
|
||||
className="no-scrollbar relative -mx-4 mb-4 snap-x snap-mandatory overflow-x-auto px-4 md:hidden"
|
||||
>
|
||||
<ul className="flex min-w-max gap-2">
|
||||
{groupNav.map(({ group, landingSlug }) => {
|
||||
const isActive = group.id === activeGroupId;
|
||||
const Icon = group.icon;
|
||||
{visibleSections.map((section) => {
|
||||
const isActive = section.slug === activeSlug;
|
||||
const Icon = section.icon;
|
||||
return (
|
||||
<li key={group.id} className="snap-start">
|
||||
<li key={section.slug} className="snap-start">
|
||||
<Link
|
||||
href={`/settings/${landingSlug}`}
|
||||
data-slot="settings-group-nav-item"
|
||||
data-group={group.id}
|
||||
href={`/settings/${section.slug}`}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
// v1.4.25 W8 — chip strip is the primary mobile-settings
|
||||
@@ -743,7 +511,7 @@ export function SettingsShell({
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||
{t(group.titleKey)}
|
||||
{t(section.titleKey)}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
@@ -761,9 +529,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). */}
|
||||
{desktopHeadingBlock ? (
|
||||
{headingBlock ? (
|
||||
<div className="hidden md:col-start-2 md:row-start-1 md:block">
|
||||
{desktopHeadingBlock}
|
||||
{headingBlock}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -784,15 +552,13 @@ export function SettingsShell({
|
||||
className="no-scrollbar hidden max-h-[calc(100dvh-5.5rem)] overflow-y-auto md:sticky md:top-6 md:col-start-1 md:row-start-2 md:block md:self-start"
|
||||
>
|
||||
<ul className="space-y-1">
|
||||
{groupNav.map(({ group, landingSlug }) => {
|
||||
const isActive = group.id === activeGroupId;
|
||||
const Icon = group.icon;
|
||||
{visibleSections.map((section) => {
|
||||
const isActive = section.slug === activeSlug;
|
||||
const Icon = section.icon;
|
||||
return (
|
||||
<li key={group.id}>
|
||||
<li key={section.slug}>
|
||||
<Link
|
||||
href={`/settings/${landingSlug}`}
|
||||
data-slot="settings-group-nav-item"
|
||||
data-group={group.id}
|
||||
href={`/settings/${section.slug}`}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
||||
@@ -802,7 +568,7 @@ export function SettingsShell({
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||
{t(group.titleKey)}
|
||||
{t(section.titleKey)}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
@@ -827,48 +593,6 @@ export function SettingsShell({
|
||||
already provides the page's single `<main>` landmark and a
|
||||
nested second one is an a11y violation. */}
|
||||
<div className="min-h-[calc(100dvh-12rem)] min-w-0 pb-24 md:col-start-2 md:row-start-2 md:pb-0">
|
||||
{/* 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 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"
|
||||
>
|
||||
{subTabs.map((slug) => {
|
||||
const isActive = slug === activeSlug;
|
||||
return (
|
||||
<Link
|
||||
key={slug}
|
||||
href={`/settings/${slug}`}
|
||||
data-slot="settings-subtab"
|
||||
data-subtab-slug={slug}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex min-h-9 items-center rounded-md px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-colors",
|
||||
isActive
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: // v1.25.1 (A11Y-L3) — inactive tab text over the
|
||||
// `bg-muted/50` strip. `text-muted-foreground` is tuned
|
||||
// for ~AA on the page background; over the tinted strip
|
||||
// the effective ratio drops below 4.5:1 in light mode.
|
||||
// `text-foreground/70` keeps the de-emphasised look but
|
||||
// clears the WCAG AA floor on the strip.
|
||||
"text-foreground/70 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t(`settings.sections.${slug}.title`)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
) : null}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user