From 91d01d154ad1ea48afbe9fe4565cec81f66cff58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 00:51:16 +0200 Subject: [PATCH] fix(settings): make delegated module rows read-only managed deep-links The Module hub rendered live switches for the delegated keys cycle and coach, but the gate resolves those from their own sources (cycle from CycleProfile, coach from the operator flag + disableCoach), so a toggle wrote an inert entry to modulePreferencesJson, snapped back, and still fired a success toast. Render delegated modules as read-only rows that deep-link to their real control, strip delegated keys from the modules PATCH schema so no inert value is stored, drop the duplicate cycle control, and surface an operator-disabled state with the previously orphaned hint string. --- messages/de.json | 4 +- messages/en.json | 4 +- messages/es.json | 4 +- messages/fr.json | 4 +- messages/it.json | 4 +- messages/pl.json | 4 +- src/app/api/auth/me/modules/route.ts | 13 +-- src/app/api/auth/me/route.ts | 19 ++++- .../__tests__/modules-section.test.tsx | 24 ++++-- src/components/settings/module-toggle-row.tsx | 83 +++++++++++++++---- src/components/settings/modules-section.tsx | 30 +++++++ src/hooks/use-auth.ts | 18 ++++ src/lib/modules/registry.ts | 17 ++++ src/lib/validations/modules.ts | 24 +++++- 14 files changed, 216 insertions(+), 36 deletions(-) diff --git a/messages/de.json b/messages/de.json index a423c6f78..43caea3e7 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4120,7 +4120,9 @@ "description": "Die Grundlagen von HealthLog. Diese Bereiche lassen sich nicht abschalten.", "note": "Kernbereich — immer aktiv" }, - "description": "Lege fest, was HealthLog für dich erfasst. Schalte einzelne Bereiche ein oder aus — die Navigation, Kacheln und Auswertungen passen sich sofort an." + "description": "Lege fest, was HealthLog für dich erfasst. Schalte einzelne Bereiche ein oder aus — die Navigation, Kacheln und Auswertungen passen sich sofort an.", + "manageIn": "In {section} verwalten", + "operatorDisabled": "Serverweit deaktiviert" }, "gesundheitsakte": { "title": "Gesundheitsakte", diff --git a/messages/en.json b/messages/en.json index 4efb384dc..8283bcb7e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4120,7 +4120,9 @@ "description": "HealthLog's foundations. These areas can't be turned off.", "note": "Core area — always on" }, - "description": "Choose what HealthLog tracks for you. Turn individual areas on or off — the navigation, tiles, and insights adapt right away." + "description": "Choose what HealthLog tracks for you. Turn individual areas on or off — the navigation, tiles, and insights adapt right away.", + "manageIn": "Manage in {section}", + "operatorDisabled": "Disabled server-wide" }, "gesundheitsakte": { "title": "Health record", diff --git a/messages/es.json b/messages/es.json index e78370478..c1a856c45 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4120,7 +4120,9 @@ "description": "Los pilares de HealthLog. Estas áreas no se pueden desactivar.", "note": "Área esencial — siempre activa" }, - "description": "Elige qué registra HealthLog por ti. Activa o desactiva áreas concretas: la navegación, los paneles y los análisis se adaptan al instante." + "description": "Elige qué registra HealthLog por ti. Activa o desactiva áreas concretas: la navegación, los paneles y los análisis se adaptan al instante.", + "manageIn": "Gestionar en {section}", + "operatorDisabled": "Desactivado en todo el servidor" }, "gesundheitsakte": { "title": "Historial médico", diff --git a/messages/fr.json b/messages/fr.json index 32f5c2b32..fe2b34b3a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4120,7 +4120,9 @@ "description": "Les fondations de HealthLog. Ces domaines ne peuvent pas être désactivés.", "note": "Domaine essentiel — toujours actif" }, - "description": "Choisissez ce que HealthLog suit pour vous. Activez ou désactivez chaque domaine : la navigation, les tuiles et les analyses s'adaptent aussitôt." + "description": "Choisissez ce que HealthLog suit pour vous. Activez ou désactivez chaque domaine : la navigation, les tuiles et les analyses s'adaptent aussitôt.", + "manageIn": "Gérer dans {section}", + "operatorDisabled": "Désactivé sur tout le serveur" }, "gesundheitsakte": { "title": "Dossier médical", diff --git a/messages/it.json b/messages/it.json index 162c586dd..5dd3fad48 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4120,7 +4120,9 @@ "description": "Le fondamenta di HealthLog. Questi ambiti non possono essere disattivati.", "note": "Ambito di base — sempre attivo" }, - "description": "Scegli cosa registra HealthLog per te. Attiva o disattiva i singoli ambiti: navigazione, riquadri e analisi si adattano subito." + "description": "Scegli cosa registra HealthLog per te. Attiva o disattiva i singoli ambiti: navigazione, riquadri e analisi si adattano subito.", + "manageIn": "Gestisci in {section}", + "operatorDisabled": "Disattivato a livello di server" }, "gesundheitsakte": { "title": "Cartella clinica", diff --git a/messages/pl.json b/messages/pl.json index c16c94efd..71db46251 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4120,7 +4120,9 @@ "description": "Podstawy HealthLog. Tych obszarów nie można wyłączyć.", "note": "Obszar podstawowy — zawsze aktywny" }, - "description": "Wybierz, co HealthLog ma dla Ciebie śledzić. Włączaj i wyłączaj poszczególne obszary — nawigacja, kafelki i analizy dostosują się od razu." + "description": "Wybierz, co HealthLog ma dla Ciebie śledzić. Włączaj i wyłączaj poszczególne obszary — nawigacja, kafelki i analizy dostosują się od razu.", + "manageIn": "Zarządzaj w {section}", + "operatorDisabled": "Wyłączone na całym serwerze" }, "gesundheitsakte": { "title": "Dokumentacja zdrowotna", diff --git a/src/app/api/auth/me/modules/route.ts b/src/app/api/auth/me/modules/route.ts index 2be6c5b5d..c846f4368 100644 --- a/src/app/api/auth/me/modules/route.ts +++ b/src/app/api/auth/me/modules/route.ts @@ -11,15 +11,18 @@ * returns the freshly-resolved module map. * * The body schema (`modulePrefsPatchSchema`) is `strict()` over the - * canonical toggleable key set, so a core-domain key (`weight`, - * `bloodPressure`, `pulse`, `medications`) or any unknown key is a 422 — - * the core measurement engine + meds can never be disabled here. + * directly-owned toggleable key set, so a core-domain key (`weight`, + * `bloodPressure`, `pulse`, `medications`), a delegated key (`cycle`, + * `coach`), or any unknown key is a 422 — the core measurement engine + + * meds can never be disabled here, and a value for a delegated module can + * never land inert in `modulePreferencesJson`. The delegated modules are + * managed at their real control (cycle in Account, coach in Settings → + * Coach); the Modules hub deep-links there rather than offering a dead + * toggle. * * `userId` is always narrowed from `requireAuth()`; the body never * carries it. The semantics are a DISABLED allowlist: a key set to * `false` disables that module; `true` (or absence) leaves it enabled. - * cycle/coach keys are accepted for forward-compat but the gate ignores - * the blob for them (they delegate to their existing source of truth). */ import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; import { diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 6da40db69..f7fec1a52 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -6,7 +6,10 @@ import { buildAvatarUrl } from "@/lib/avatar"; import { decrypt } from "@/lib/crypto"; import { prisma } from "@/lib/db"; import { isCycleEnabled } from "@/lib/cycle/gate"; -import { resolveModuleMap } from "@/lib/modules/gate"; +import { + resolveModuleMap, + getOperatorModuleAvailability, +} from "@/lib/modules/gate"; export const dynamic = "force-dynamic"; @@ -22,7 +25,7 @@ export const GET = apiHandler(async () => { // cycle-tracking gate; no row is forced, a NULL toggle derives from // gender) is a Postgres round-trip. Running them sequentially added // the cookie hop to every /me — and /me sits on every app boot path. - const [, cycleProfile, modules] = await Promise.all([ + const [, cycleProfile, modules, moduleAvailability] = await Promise.all([ setOnboardingPendingCookie(user.onboardingCompletedAt == null), prisma.cycleProfile.findUnique({ where: { userId: user.id }, @@ -34,6 +37,12 @@ export const GET = apiHandler(async () => { // disabled-allowlist `modulePreferencesJson`. Default-on. Clients // hide a whole module surface end-to-end when its key is `false`. resolveModuleMap(user.id), + // v1.18.0 — operator-layer availability map (server-wide kill-switch). + // The resolved `modules` map above already AND-s this in, so it cannot + // distinguish operator-off from user-off. The Modules hub needs that + // distinction to render an operator-disabled module as a read-only + // "disabled server-wide" row instead of a live toggle that no-ops. + getOperatorModuleAvailability(), ]); const cycleTrackingEnabled = isCycleEnabled(user.gender, cycleProfile); @@ -99,5 +108,11 @@ export const GET = apiHandler(async () => { // resolved `disableCoach` + operator master flag, so this map is the // single thing a client needs to gate every secondary domain. modules, + // v1.18.0 — operator-layer availability per toggleable module. `false` + // ⇒ the operator turned the module off server-wide (off for every + // account regardless of personal preference). The Modules hub reads + // this to show a "disabled server-wide" read-only row; everywhere else + // the already-AND-ed `modules` map is the single gate to read. + moduleAvailability, }); }); diff --git a/src/components/settings/__tests__/modules-section.test.tsx b/src/components/settings/__tests__/modules-section.test.tsx index 02cc42844..af1183e3b 100644 --- a/src/components/settings/__tests__/modules-section.test.tsx +++ b/src/components/settings/__tests__/modules-section.test.tsx @@ -20,7 +20,11 @@ import { renderToStaticMarkup } from "react-dom/server"; import { I18nProvider } from "@/lib/i18n/context"; import type { AuthUser } from "@/hooks/use-auth"; import { queryKeys } from "@/lib/query-keys"; -import { MODULE_KEYS, CORE_DOMAIN_KEYS } from "@/lib/modules/registry"; +import { + MODULE_KEYS, + CORE_DOMAIN_KEYS, + moduleDelegatesTo, +} from "@/lib/modules/registry"; // Capture the latest useMutation config + a shared mutate spy so we can // invoke `mutationFn` / `onSuccess` by hand (the SSR pass can't fire a @@ -104,12 +108,22 @@ afterEach(() => { }); describe("", () => { - it("renders a Switch for every toggleable + core module", () => { + it("renders a Switch for every non-delegated toggleable module, a managed deep-link for delegated ones, and locked switches for core", () => { const html = render(); for (const key of MODULE_KEYS) { - expect(html, `toggleable switch ${key}`).toContain( - `id="module-toggle-${key}"`, - ); + if (moduleDelegatesTo(key) !== undefined) { + // Delegated modules (cycle/coach) are owned by their real control + // elsewhere — they render as a read-only "manage in X" deep-link, + // never a live Switch that would write an inert disabled-allowlist + // entry the gate ignores. + expect(html, `delegated has no switch ${key}`).not.toContain( + `id="module-toggle-${key}"`, + ); + } else { + expect(html, `toggleable switch ${key}`).toContain( + `id="module-toggle-${key}"`, + ); + } } for (const key of CORE_DOMAIN_KEYS) { expect(html, `core switch ${key}`).toContain(`id="module-toggle-${key}"`); diff --git a/src/components/settings/module-toggle-row.tsx b/src/components/settings/module-toggle-row.tsx index 47a8cbb23..efa4d0a46 100644 --- a/src/components/settings/module-toggle-row.tsx +++ b/src/components/settings/module-toggle-row.tsx @@ -9,14 +9,25 @@ * row is presentation-only — the parent `` owns the mutation * and cache invalidation, so this component stays a pure, easily-tested leaf. * - * Two modes: + * Four modes: * - toggleable: the Switch is live; flipping it calls `onToggle(next)`. * - locked (core domains): the Switch is checked + disabled with a short * "always on" note, so the always-on measurement engine + meds read as - * deliberately fixed rather than broken. Palette stays neutral — no - * alarming green-when-on / red-when-off tint. + * deliberately fixed rather than broken. + * - managed elsewhere (delegated modules cycle/coach): no Switch at all — + * the real on/off lives in another section, so the row renders a + * read-only deep-link ("Manage in X") rather than a dead toggle that + * no-ops and snaps back behind a misleading "saved" toast. + * - operator-disabled: the operator turned the module off server-wide, so + * the Switch is replaced by a read-only "disabled server-wide" note — + * a per-user toggle could not re-enable it anyway. + * + * Palette stays neutral throughout — no alarming green-when-on / + * red-when-off tint. */ +import Link from "next/link"; +import { ChevronRight } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { Label } from "@/components/ui/label"; @@ -37,6 +48,21 @@ export interface ModuleToggleRowProps { locked?: boolean; /** One-line "core / always on" note, only shown when `locked`. */ lockedNote?: string; + /** + * Delegated modules (cycle/coach): the real control lives elsewhere. When + * set, the row renders a read-only deep-link instead of a Switch. + * `label` is the destination name; `href` the in-app link. + */ + managedAt?: { href: string; label: string }; + /** Localised "Manage in {section}" link text (managed-elsewhere rows). */ + manageLinkLabel?: string; + /** + * Operator turned this module off server-wide. Replaces the Switch with a + * read-only note; a per-user toggle cannot re-enable it. `operatorNote` + * is the localised "disabled server-wide" copy. + */ + operatorDisabled?: boolean; + operatorNote?: string; /** Disable the Switch while a mutation is in flight (toggleable rows). */ pending?: boolean; /** Fired with the next desired state on a user toggle (toggleable rows). */ @@ -51,10 +77,18 @@ export function ModuleToggleRow({ enabled, locked = false, lockedNote, + managedAt, + manageLinkLabel, + operatorDisabled = false, + operatorNote, pending = false, onToggle, }: ModuleToggleRowProps) { const inputId = `module-toggle-${moduleKey}`; + // A delegated module whose real control lives elsewhere, or an + // operator-disabled module: either way no live Switch — the per-user + // toggle would be a dead no-op. + const managed = managedAt != null; return (
@@ -63,23 +97,44 @@ export function ModuleToggleRow({ aria-hidden="true" />
- + {managed ? ( +

{label}

+ ) : ( + + )}

{description}

{locked && lockedNote ? (

{lockedNote}

) : null}
- onToggle?.(v)} - className="mt-0.5 shrink-0" - aria-label={label} - /> + {operatorDisabled ? ( + // Operator kill-switch: read-only note, no toggle. + + {operatorNote} + + ) : managed ? ( + // Delegated module: deep-link to the real control instead of a + // dead toggle. + + {manageLinkLabel} +
); } diff --git a/src/components/settings/modules-section.tsx b/src/components/settings/modules-section.tsx index 7c81829d3..92259a449 100644 --- a/src/components/settings/modules-section.tsx +++ b/src/components/settings/modules-section.tsx @@ -52,6 +52,7 @@ import { queryKeys } from "@/lib/query-keys"; import { CORE_DOMAIN_KEYS, MODULE_REGISTRY, + moduleDelegatesTo, type CoreDomainKey, type ModuleKey, } from "@/lib/modules/registry"; @@ -85,12 +86,16 @@ export function ModulesSection() { const queryClient = useQueryClient(); const modules = user?.modules ?? {}; + const moduleAvailability = user?.moduleAvailability ?? {}; const toggle = useMutation({ // Factory-routed; the in-repo eslint rule forbids a bare literal here. mutationKey: queryKeys.modulesPrefs(), mutationFn: async (vars: { key: ModuleKey; enabled: boolean }) => { // DISABLED allowlist: send only the single key the user flipped. + // Delegated keys (cycle/coach) never reach here — they render as + // read-only deep-link rows, so the PATCH only ever carries a + // directly-owned key. return apiPatch("/api/auth/me/modules", { [vars.key]: vars.enabled }); }, onSuccess: () => { @@ -131,6 +136,14 @@ export function ModulesSection() { const def = MODULE_REGISTRY[key]; // Default-on: a module is enabled unless explicitly `false`. const enabled = modules[key] !== false; + // Delegated modules (cycle/coach) are owned elsewhere — never a + // live toggle here; deep-link to their real control instead. + const delegated = moduleDelegatesTo(key) !== undefined; + // Operator turned this module off server-wide: no per-user + // toggle can re-enable it, so show a read-only note. Delegated + // rows already deep-link, so this only matters for owned keys. + const operatorDisabled = + !delegated && moduleAvailability[key] === false; return ( toggle.mutate({ key, enabled: next })} /> ); diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index 8d423d522..fefbbc258 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -95,6 +95,17 @@ export interface AuthUser { * absent), so live code reads a real map. */ modules?: Partial>; + /** + * v1.18.0 — operator-layer availability per toggleable module. `false` + * ⇒ the operator disabled the module server-wide (off for every account, + * regardless of personal preference). Distinct from `modules`, which is + * the already-AND-ed effective state and cannot tell operator-off from + * user-off apart. Only the Modules hub needs this distinction (to render + * an operator-disabled module as a read-only "disabled server-wide" row); + * every other gate reads `modules`. Coerced to `{}` against a stale /me + * payload so a missing map reads as all-available. + */ + moduleAvailability?: Partial>; } async function fetchMe(): Promise { @@ -137,6 +148,13 @@ async function fetchMe(): Promise { // open: a missing key reads as enabled and the nav stays intact. modules: data.modules && typeof data.modules === "object" ? data.modules : {}, + // v1.18.0 — operator availability map, coerced against a stale payload + // to an empty map (all-available) so the Modules hub never shows a + // spurious "disabled server-wide" row on an older server image. + moduleAvailability: + data.moduleAvailability && typeof data.moduleAvailability === "object" + ? data.moduleAvailability + : {}, }; } diff --git a/src/lib/modules/registry.ts b/src/lib/modules/registry.ts index 45f2781c1..85e5b6cb7 100644 --- a/src/lib/modules/registry.ts +++ b/src/lib/modules/registry.ts @@ -65,6 +65,13 @@ export interface ModuleDefinition { * there is exactly one source of truth. See `ModuleDelegation`. */ delegatesTo?: ModuleDelegation; + /** + * For delegated modules only: where the real on/off control lives, so the + * Modules hub can render a read-only "managed in X" row that deep-links to + * the canonical control instead of a dead toggle. `href` is the in-app + * link; `labelKey` names the destination ("Account", "Coach settings"). + */ + managedAt?: { href: string; labelKey: string }; } /** @@ -138,6 +145,11 @@ export const MODULE_REGISTRY: Readonly> = descriptionKey: "modules.cycle.description", category: "tracking", delegatesTo: "cycle", + // The real on/off lives in the Account section's cycle-tracking card. + managedAt: { + href: "/settings/account#cycle-tracking", + labelKey: "settings.sections.account.title", + }, }, mood: { key: "mood", @@ -187,6 +199,11 @@ export const MODULE_REGISTRY: Readonly> = descriptionKey: "modules.coach.description", category: "intelligence", delegatesTo: "coach", + // The real on/off lives in the dedicated Coach settings section. + managedAt: { + href: "/settings/coach", + labelKey: "settings.sections.coach.title", + }, }, insights: { key: "insights", diff --git a/src/lib/validations/modules.ts b/src/lib/validations/modules.ts index 44c29ebf6..d33ae6870 100644 --- a/src/lib/validations/modules.ts +++ b/src/lib/validations/modules.ts @@ -11,17 +11,33 @@ * `strict()` rejects unknown keys outright rather than silently dropping * them, so a client gets a clear error instead of a no-op. * + * Delegated keys (`cycle`, `coach`) are NOT accepted here: their + * enabled-state is owned elsewhere (the cycle gate / `disableCoach` + the + * operator assistant flag), so a value for them would land inert in + * `modulePreferencesJson` and mislead — the Modules hub renders them as + * read-only "managed in X" rows that deep-link to the real control. Omitting + * them from the schema means a stale client sending `{ coach: false }` gets + * a clear 422 instead of a silent no-op behind a green "saved" toast. + * * The shared shape is reused by the OpenAPI registry so the wire * contract stays single-source. */ import { z } from "zod/v4"; -import { MODULE_KEYS } from "@/lib/modules/registry"; +import { MODULE_KEYS, moduleDelegatesTo } from "@/lib/modules/registry"; -/** Build `{ ?: boolean }` from the canonical key list. */ +/** + * The directly-owned (non-delegated) toggleable keys — the only keys this + * PATCH surface may write into `modulePreferencesJson`. + */ +const WRITABLE_MODULE_KEYS = MODULE_KEYS.filter( + (k) => moduleDelegatesTo(k) === undefined, +); + +/** Build `{ ?: boolean }` from the directly-owned key list. */ const moduleShape = Object.fromEntries( - MODULE_KEYS.map((k) => [k, z.boolean().optional()]), -) as Record<(typeof MODULE_KEYS)[number], z.ZodOptional>; + WRITABLE_MODULE_KEYS.map((k) => [k, z.boolean().optional()]), +) as Record<(typeof WRITABLE_MODULE_KEYS)[number], z.ZodOptional>; /** * Partial module-preferences PATCH body. Every key is an optional