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

@@ -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 })}
/>
);