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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 00:51:16 +02:00
1 parent 7c8d82f565
commit 91d01d154a
14 files changed
+216 -36

No files matched your search

+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+8 -5
View File
@@ -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 {
+17 -2
View File
@@ -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,
});
});
@@ -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("<ModulesSection>", () => {
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}"`);
+69 -14
View File
@@ -9,14 +9,25 @@
* row is presentation-only — the parent `<ModulesSection>` 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 (
<div className="flex items-start justify-between gap-4 py-3">
<div className="flex min-w-0 items-start gap-3">
@@ -63,23 +97,44 @@ export function ModuleToggleRow({
aria-hidden="true"
/>
<div className="min-w-0 space-y-0.5">
<Label htmlFor={inputId} className="text-sm font-medium">
{label}
</Label>
{managed ? (
<p className="text-sm font-medium">{label}</p>
) : (
<Label htmlFor={inputId} className="text-sm font-medium">
{label}
</Label>
)}
<p className="text-muted-foreground text-xs">{description}</p>
{locked && lockedNote ? (
<p className="text-muted-foreground/80 text-xs">{lockedNote}</p>
) : null}
</div>
</div>
<Switch
id={inputId}
checked={locked ? true : enabled}
disabled={locked || pending}
onCheckedChange={locked ? undefined : (v) => onToggle?.(v)}
className="mt-0.5 shrink-0"
aria-label={label}
/>
{operatorDisabled ? (
// Operator kill-switch: read-only note, no toggle.
<span className="text-muted-foreground/80 mt-0.5 shrink-0 text-xs">
{operatorNote}
</span>
) : managed ? (
// Delegated module: deep-link to the real control instead of a
// dead toggle.
<Link
href={managedAt.href}
className="text-muted-foreground hover:text-foreground mt-0.5 inline-flex shrink-0 items-center gap-1 text-xs underline-offset-2 hover:underline"
>
{manageLinkLabel}
<ChevronRight className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
) : (
<Switch
id={inputId}
checked={locked ? true : enabled}
disabled={locked || pending}
onCheckedChange={locked ? undefined : (v) => onToggle?.(v)}
className="mt-0.5 shrink-0"
aria-label={label}
/>
)}
</div>
);
}
@@ -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 (
<ModuleToggleRow
key={key}
@@ -140,6 +153,23 @@ export function ModulesSection() {
description={t(def.descriptionKey)}
enabled={enabled}
pending={toggle.isPending}
managedAt={
delegated && def.managedAt
? {
href: def.managedAt.href,
label: t(def.managedAt.labelKey),
}
: undefined
}
manageLinkLabel={
delegated && def.managedAt
? t("settings.sections.modules.manageIn", {
section: t(def.managedAt.labelKey),
})
: undefined
}
operatorDisabled={operatorDisabled}
operatorNote={t("settings.sections.modules.operatorDisabled")}
onToggle={(next) => toggle.mutate({ key, enabled: next })}
/>
);
+18
View File
@@ -95,6 +95,17 @@ export interface AuthUser {
* absent), so live code reads a real map.
*/
modules?: Partial<Record<ModuleKey, boolean>>;
/**
* 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<Record<ModuleKey, boolean>>;
}
async function fetchMe(): Promise<AuthUser> {
@@ -137,6 +148,13 @@ async function fetchMe(): Promise<AuthUser> {
// 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
: {},
};
}
+17
View File
@@ -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<Record<ModuleKey, ModuleDefinition>> =
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<Record<ModuleKey, ModuleDefinition>> =
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",
+20 -4
View File
@@ -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 `{ <toggleableKey>?: 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 `{ <writableKey>?: 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<z.ZodBoolean>>;
WRITABLE_MODULE_KEYS.map((k) => [k, z.boolean().optional()]),
) as Record<(typeof WRITABLE_MODULE_KEYS)[number], z.ZodOptional<z.ZodBoolean>>;
/**
* Partial module-preferences PATCH body. Every key is an optional