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