From 13b4a0be0b26eb00970a62586aa29b867133b20f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 14:29:24 +0200 Subject: [PATCH] refactor(settings): thin the Account tree down to the profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two cards on Settings → Account were asking questions that already have a home somewhere else. - cycle tracking is a module, and the Modules hub carries the switch that drives the same `cycleTrackingEnabled` column. The Account card was a second door to one decision, so it goes, and the "manage in Account" deep-link beside the module switch goes with it. That link pointed at a `#cycle-tracking` anchor no element ever rendered, so it never landed anywhere in the first place - changing a password belongs with the second factors and the passkeys. The card moves to Settings → Security and leads that page. It renders before the factor status resolves: it is now the only way to change a password and must not depend on a read it does not need Account keeps the profile, the photo, the identity fields and the AI context. The `settings.cycleTracking.*` strings leave all six bundles. --- messages/de.json | 8 - messages/en.json | 8 - messages/es.json | 8 - messages/fr.json | 8 - messages/it.json | 8 - messages/pl.json | 8 - .../__tests__/modules-section.test.tsx | 15 +- .../password-card-placement.test.tsx | 108 ++++++++++ .../settings/account-section/index.tsx | 171 +--------------- src/components/settings/password-card.tsx | 192 ++++++++++++++++++ .../settings/security-section/index.tsx | 40 ++-- src/components/settings/settings-card.tsx | 6 +- src/lib/modules/registry.ts | 9 +- 13 files changed, 349 insertions(+), 240 deletions(-) create mode 100644 src/components/settings/__tests__/password-card-placement.test.tsx create mode 100644 src/components/settings/password-card.tsx diff --git a/messages/de.json b/messages/de.json index 22e0b9015..d749c64b4 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6037,14 +6037,6 @@ "createDetail": "Der Token wird nur einmal bei der Erstellung angezeigt — sichere ihn dann; er lässt sich nicht wiederherstellen." }, "genderOther": "Divers", - "cycleTracking": { - "title": "Zyklus-Tracking", - "description": "Verfolge deinen Menstruationszyklus, Vorhersagen, Symptome und Phasen-Insights.", - "enable": "Zyklus-Tracking aktivieren", - "manage": "Zyklus-Tracking öffnen", - "error": "Zyklus-Tracking konnte nicht aktualisiert werden.", - "detail": "Standardmäßig an für als weiblich eingestellte Konten; für alle verfügbar." - }, "nightscout": "Nightscout", "nightscoutTag": "CGM", "nightscoutDescription": "Verbinde deine eigene Nightscout-Seite, um kontinuierliche Glukosewerte ins Glukose-Panel zu synchronisieren.", diff --git a/messages/en.json b/messages/en.json index afacc42d2..3ab28641f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6037,14 +6037,6 @@ "createDetail": "The token is shown once on creation — store it then; it cannot be recovered." }, "genderOther": "Other", - "cycleTracking": { - "title": "Cycle tracking", - "description": "Track your menstrual cycle, predictions, symptoms, and phase insights.", - "enable": "Enable cycle tracking", - "manage": "Open cycle tracking", - "error": "Couldn't update cycle tracking.", - "detail": "On by default for accounts set to female; available to everyone." - }, "nightscout": "Nightscout", "nightscoutTag": "CGM", "nightscoutDescription": "Connect your own Nightscout site to sync continuous glucose readings into the glucose panel.", diff --git a/messages/es.json b/messages/es.json index 7cec1ba45..ff86aae20 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6037,14 +6037,6 @@ "createDetail": "El token se muestra una sola vez al crearlo — guárdalo entonces; no se puede recuperar." }, "genderOther": "Otro", - "cycleTracking": { - "title": "Seguimiento del ciclo", - "description": "Registra tu ciclo menstrual, predicciones, síntomas e información de fases.", - "enable": "Activar el seguimiento del ciclo", - "manage": "Abrir seguimiento del ciclo", - "error": "No se pudo actualizar el seguimiento del ciclo.", - "detail": "Activado por defecto en cuentas configuradas como femeninas; disponible para todos." - }, "nightscout": "Nightscout", "nightscoutTag": "CGM", "nightscoutDescription": "Conecta tu propio sitio Nightscout para sincronizar lecturas continuas de glucosa en el panel de glucosa.", diff --git a/messages/fr.json b/messages/fr.json index d91f2cf3d..0731663e4 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6037,14 +6037,6 @@ "createDetail": "Le jeton n'est affiché qu'une fois à la création — conservez-le alors ; il ne peut pas être récupéré." }, "genderOther": "Autre", - "cycleTracking": { - "title": "Suivi du cycle", - "description": "Suivez votre cycle menstruel, les prédictions, les symptômes et les analyses de phases.", - "enable": "Activer le suivi du cycle", - "manage": "Ouvrir le suivi du cycle", - "error": "Impossible de mettre à jour le suivi du cycle.", - "detail": "Activé par défaut pour les comptes définis comme féminins ; disponible pour tous." - }, "nightscout": "Nightscout", "nightscoutTag": "CGM", "nightscoutDescription": "Connecte ton propre site Nightscout pour synchroniser les mesures de glucose en continu dans le panneau glucose.", diff --git a/messages/it.json b/messages/it.json index 60bfd0bf4..e4528699f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6037,14 +6037,6 @@ "createDetail": "Il token viene mostrato una sola volta alla creazione — salvalo subito; non può essere recuperato." }, "genderOther": "Altro", - "cycleTracking": { - "title": "Monitoraggio del ciclo", - "description": "Monitora il ciclo mestruale, le previsioni, i sintomi e gli approfondimenti sulle fasi.", - "enable": "Attiva il monitoraggio del ciclo", - "manage": "Apri il monitoraggio del ciclo", - "error": "Impossibile aggiornare il monitoraggio del ciclo.", - "detail": "Attivo per impostazione predefinita per gli account impostati come femminili; disponibile per tutti." - }, "nightscout": "Nightscout", "nightscoutTag": "CGM", "nightscoutDescription": "Collega il tuo sito Nightscout per sincronizzare le letture continue del glucosio nel pannello glucosio.", diff --git a/messages/pl.json b/messages/pl.json index cd1f4c71e..1e4bc806c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6037,14 +6037,6 @@ "createDetail": "Token jest pokazywany tylko raz przy tworzeniu — zapisz go wtedy; nie można go odzyskać." }, "genderOther": "Inna", - "cycleTracking": { - "title": "Śledzenie cyklu", - "description": "Śledź swój cykl menstruacyjny, prognozy, objawy i analizy faz.", - "enable": "Włącz śledzenie cyklu", - "manage": "Otwórz śledzenie cyklu", - "error": "Nie udało się zaktualizować śledzenia cyklu.", - "detail": "Domyślnie włączone dla kont ustawionych jako kobiece; dostępne dla wszystkich." - }, "nightscout": "Nightscout", "nightscoutTag": "CGM", "nightscoutDescription": "Połącz własną stronę Nightscout, aby synchronizować ciągłe odczyty glukozy w panelu glukozy.", diff --git a/src/components/settings/__tests__/modules-section.test.tsx b/src/components/settings/__tests__/modules-section.test.tsx index 4003760d1..8a6ea9e80 100644 --- a/src/components/settings/__tests__/modules-section.test.tsx +++ b/src/components/settings/__tests__/modules-section.test.tsx @@ -163,11 +163,20 @@ describe("", () => { } }); - it("keeps a manage deep-link beside the delegated coach + cycle switches", () => { + it("keeps a manage deep-link beside the delegated coach switch", () => { const html = render(); - // Coach → Coach settings; cycle → the Account cycle-tracking card. + // Coach → Coach settings. Coach is the only delegated module with more + // to configure than on/off (cadence, memory) at a settings address. expect(html).toContain('href="/settings/coach"'); - expect(html).toContain('href="/settings/account#cycle-tracking"'); + }); + + it("offers no Account deep-link beside the cycle switch", () => { + // Cycle used to carry a second enable surface in Settings → Account, + // and this link pointed at it. The switch here drives the same column, + // so the Account card went and the link with it — a deep-link to an + // anchor nothing renders is a door into a wall. + const html = render(); + expect(html).not.toContain("/settings/account#cycle-tracking"); }); it("no longer renders the 'Always on' core-domains card", () => { diff --git a/src/components/settings/__tests__/password-card-placement.test.tsx b/src/components/settings/__tests__/password-card-placement.test.tsx new file mode 100644 index 000000000..ec428f6f3 --- /dev/null +++ b/src/components/settings/__tests__/password-card-placement.test.tsx @@ -0,0 +1,108 @@ +/** + * Where the change-password card lives. + * + * It used to sit at the bottom of Settings → Account, under the profile form. + * Changing a password is the same kind of decision as enrolling a second + * factor, so it moved to Settings → Security. Two things have to hold for + * that move to be real rather than a duplicate: + * + * 1. Security mounts it — and mounts it BEFORE the factor status resolves, + * because it is now the only way to change a password and must not + * depend on a query it does not need. + * 2. Account does not, and no second copy of the dialog exists anywhere. + * + * The second claim is checked against the source tree rather than a render, + * because a render of `` proves the card is absent from the + * one branch the test happened to reach. Sweeping the files proves there is + * exactly one place in the app that can paint it. + */ + +import { describe, it, expect, vi } from "vitest"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: undefined, isLoading: true }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useMutation: () => ({ mutate: vi.fn(), isPending: false }), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: null, + isAuthenticated: true, + isLoading: false, + refetch: vi.fn(), + }), +})); + +import { PasswordCard } from "../password-card"; +import { SecuritySection } from "../security-section"; + +const SLOT = 'data-slot="settings-password-card"'; + +function render(node: React.ReactNode): string { + return renderToStaticMarkup( + {node}, + ); +} + +const ROOT = process.cwd(); + +function walk(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + if (entry === "__tests__") continue; + walk(full, out); + } else if (entry.endsWith(".tsx")) { + out.push(full); + } + } + return out; +} + +describe("change-password card placement", () => { + it("paints its slot", () => { + // Positive control for the two assertions below — without it, a renamed + // slot would make every "is it here / is it gone" check read green. + expect(render()).toContain(SLOT); + }); + + it("is mounted by Settings → Security before the factor status lands", () => { + // The mocked query never resolves, which is the case that matters: the + // MFA read is still in flight (or failing) and the password card is + // reachable regardless. + const html = render(); + expect(html).toContain(SLOT); + }); + + it("has exactly one home in the tree", () => { + const owners = walk(join(ROOT, "src")) + .filter((file) => readFileSync(file, "utf8").includes("PasswordCard")) + .map((file) => file.slice(ROOT.length + 1)) + .sort(); + expect(owners).toEqual([ + "src/components/settings/password-card.tsx", + "src/components/settings/security-section/index.tsx", + ]); + }); + + it("left no password form behind in the Account section", () => { + const account = readFileSync( + join(ROOT, "src/components/settings/account-section/index.tsx"), + "utf8", + ); + for (const marker of [ + "settings.passwordReset", + "settings.changePassword", + "/api/auth/password", + "current-password", + ]) { + expect(account, `Account still carries ${marker}`).not.toContain(marker); + } + }); +}); diff --git a/src/components/settings/account-section/index.tsx b/src/components/settings/account-section/index.tsx index 280bc5669..5d223b5fd 100644 --- a/src/components/settings/account-section/index.tsx +++ b/src/components/settings/account-section/index.tsx @@ -2,11 +2,10 @@ import { useCallback, useEffect, useState } from "react"; import { useRouter } from "next/navigation"; -import { Loader2, Save, Shield, User } from "lucide-react"; +import { Loader2, Save, User } from "lucide-react"; import { Button } from "@/components/ui/button"; import { SettingsCardActions } from "@/components/settings/_card-actions"; -import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { SeededFormDiscardDialog, useSeededFormDismissal, @@ -15,7 +14,6 @@ import { Input } from "@/components/ui/input"; import { DateField } from "@/components/ui/date-field"; import { Label } from "@/components/ui/label"; import { NativeSelect } from "@/components/ui/native-select"; -import { PasswordInput } from "@/components/ui/password-input"; import { HeightFieldControl } from "@/components/profile/height-field-control"; import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; @@ -35,7 +33,6 @@ import { storeTimezone } from "@/lib/timezone-mirror"; import { TimeFormatSelect } from "@/components/settings/time-format-select"; import { DateFormatSelect } from "@/components/settings/date-format-select"; import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select"; -import { CycleTrackingCard } from "@/components/settings/cycle-tracking-card"; import { detectBrowserTimezone } from "@/lib/tz/format"; import { apiFetchRaw } from "@/lib/api/api-fetch"; import { @@ -94,17 +91,6 @@ export function AccountSection() { "success" | "warning" | "error" | null >(null); - // Password change dialog state. - const [currentPassword, setCurrentPassword] = useState(""); - const [newPassword, setNewPassword] = useState(""); - const [confirmPassword, setConfirmPassword] = useState(""); - const [passwordSaving, setPasswordSaving] = useState(false); - const [passwordMsg, setPasswordMsg] = useState(null); - const [passwordMsgType, setPasswordMsgType] = useState< - "success" | "error" | null - >(null); - const [passwordDialogOpen, setPasswordDialogOpen] = useState(false); - // v1.23 — passkey + second-factor management moved to the dedicated // /settings/security hub so "how I secure my account" reads as one place. @@ -298,52 +284,6 @@ export function AccountSection() { setSaving(false); } - async function handleChangePassword(e: React.FormEvent) { - e.preventDefault(); - setPasswordSaving(true); - setPasswordMsg(null); - setPasswordMsgType(null); - - if (newPassword !== confirmPassword) { - setPasswordMsg({ key: "settings.passwordMismatch" }); - setPasswordMsgType("error"); - setPasswordSaving(false); - return; - } - - try { - const res = await apiFetchRaw("/api/auth/password", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - currentPassword, - newPassword, - confirmPassword, - }), - }); - const json = await res.json(); - - if (!res.ok) { - setPasswordMsg( - json.error ? { text: json.error } : { key: "settings.savingError" }, - ); - setPasswordMsgType("error"); - return; - } - - setPasswordMsg({ key: "settings.passwordUpdated" }); - setPasswordMsgType("success"); - setCurrentPassword(""); - setNewPassword(""); - setConfirmPassword(""); - } catch { - setPasswordMsg({ key: "common.networkError" }); - setPasswordMsgType("error"); - } finally { - setPasswordSaving(false); - } - } - if (!mounted || isLoading) { return (
@@ -584,32 +524,12 @@ export function AccountSection() { provider-configuration screen. */} - {/* Cycle-tracking enable on-ramp — auto-on for female accounts, but this - lets any account opt in (or opt out) before the gated /cycle page is - reachable. */} - + {/* Cycle tracking is a module, and the Modules hub carries its switch. + The second enable surface that used to sit here asked the same + question in a second place. */} - {/* Password card. The action is this card's only one, so it sits in - the card's action row like every other primary. It stays full-width - under `sm` — the German "Passwort ändern" is long enough that a - right-hugging button used to break through the card's right border - on a narrow phone. */} - - - - - - + {/* Changing the password is a security control, so the card lives in + Settings → Security beside the second factors and the passkeys. */} {/* v1.25.7 — active-session management + the security-activity feed live only under Settings → Data & Privacy now; the duplicate cards @@ -619,85 +539,6 @@ export function AccountSection() { Erweitert. It is a maintenance / reset action, not a profile or security control, so it sits beside Research Mode + the danger zone. */} - { - setPasswordDialogOpen(open); - if (!open) { - setCurrentPassword(""); - setNewPassword(""); - setConfirmPassword(""); - setPasswordMsg(null); - setPasswordMsgType(null); - } - }} - title={t("settings.passwordReset")} - description={t("settings.changePasswordDescription")} - className="sm:max-w-xl" - > -
-
-
- - setCurrentPassword(e.target.value)} - /> -
-
- - setNewPassword(e.target.value)} - /> -
-
- - setConfirmPassword(e.target.value)} - /> -
-
- - {passwordMsg && ( -

- {statusText(passwordMsg, t)} -

- )} - - -
-
(null); + const [msgType, setMsgType] = useState<"success" | "error" | null>(null); + const [dialogOpen, setDialogOpen] = useState(false); + + async function handleChangePassword(e: React.FormEvent) { + e.preventDefault(); + setSaving(true); + setMsg(null); + setMsgType(null); + + if (newPassword !== confirmPassword) { + setMsg({ key: "settings.passwordMismatch" }); + setMsgType("error"); + setSaving(false); + return; + } + + try { + const res = await apiFetchRaw("/api/auth/password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + currentPassword, + newPassword, + confirmPassword, + }), + }); + const json = await res.json(); + + if (!res.ok) { + setMsg( + json.error ? { text: json.error } : { key: "settings.savingError" }, + ); + setMsgType("error"); + return; + } + + setMsg({ key: "settings.passwordUpdated" }); + setMsgType("success"); + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + } catch { + setMsg({ key: "common.networkError" }); + setMsgType("error"); + } finally { + setSaving(false); + } + } + + return ( + <> + {/* The action is this card's only one, so it sits in the card's action + row like every other primary. It stays full-width under `sm` — the + German "Passwort ändern" is long enough that a right-hugging button + used to break through the card's right border on a narrow phone. */} + + + + + + + + { + setDialogOpen(open); + if (!open) { + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + setMsg(null); + setMsgType(null); + } + }} + title={t("settings.passwordReset")} + description={t("settings.changePasswordDescription")} + className="sm:max-w-xl" + > +
+
+
+ + setCurrentPassword(e.target.value)} + /> +
+
+ + setNewPassword(e.target.value)} + /> +
+
+ + setConfirmPassword(e.target.value)} + /> +
+
+ + {msg && ( +

+ {statusText(msg, t)} +

+ )} + + +
+
+ + ); +} diff --git a/src/components/settings/security-section/index.tsx b/src/components/settings/security-section/index.tsx index f2e9a5ac5..4b439c67b 100644 --- a/src/components/settings/security-section/index.tsx +++ b/src/components/settings/security-section/index.tsx @@ -6,6 +6,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { useAuth } from "@/hooks/use-auth"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; +import { PasswordCard } from "@/components/settings/password-card"; import { TotpCard } from "./totp-card"; import { SecurityKeysCard, type WebauthnKeyInfo } from "./security-keys-card"; import { PasskeyListSection } from "./passkey-list-section"; @@ -45,28 +46,35 @@ export function SecuritySection() { passkeys != null && passkeys.length === 0; - if (isLoading || !status) { - return ( -
- - - -
- ); - } - return (
{showNudge && } - + {/* The password leads, and it renders before the factor status has + landed — it depends on nothing this page fetches. It is also the + only way to change a password now that the Account card is gone, so + a `/api/auth/me/mfa` that never answers must not take it down with + the cards that do need it. */} + - + {isLoading || !status ? ( + <> + + + + + ) : ( + <> + - + + + + + )}
); } diff --git a/src/components/settings/settings-card.tsx b/src/components/settings/settings-card.tsx index 56a69636b..178a215d5 100644 --- a/src/components/settings/settings-card.tsx +++ b/src/components/settings/settings-card.tsx @@ -63,8 +63,10 @@ export function SettingsCard({ // Mirror the ui Card shell so a semantic landmark card paints // identically to the default -backed one. className={cn( - "bg-card text-card-foreground flex flex-col rounded-xl border shadow-sm", - flush ? SETTINGS_CARD_FLUSH_SHELL : "gap-4 p-4 md:gap-6 md:p-6", + "bg-card text-card-foreground rounded-xl border shadow-sm", + flush + ? SETTINGS_CARD_FLUSH_SHELL + : "flex flex-col gap-4 p-4 md:gap-6 md:p-6", className, )} {...props} diff --git a/src/lib/modules/registry.ts b/src/lib/modules/registry.ts index 16546d4c3..329254702 100644 --- a/src/lib/modules/registry.ts +++ b/src/lib/modules/registry.ts @@ -203,12 +203,9 @@ export const MODULE_REGISTRY: Readonly> = descriptionKey: "modules.cycle.description", category: "tracking", delegatesTo: "cycle", - // The hub switch drives `cycleTrackingEnabled`; the fuller cycle-tracking - // card (goal, predictions, cycle lengths) lives in the Account section. - managedAt: { - href: "/settings/account#cycle-tracking", - labelKey: "settings.sections.account.title", - }, + // No `managedAt`. The switch here drives `cycleTrackingEnabled`, and + // that is the whole of the decision; the goal, predictions and cycle + // lengths live on the /cycle page itself, not in Settings. }, mood: { key: "mood",