Files
HealthLog/src/components/layout/module-disabled-notice.tsx
T
Marc-André Bombeck 47a5079cfa feat(api): say why a module's surfaces are not there, not only that they are not
`GET /api/auth/me` publishes a boolean per module, and three different
situations collapse into the same `false`: the record has the module switched
off, the active grant's sections do not open it, or the operator switched it
off for the whole instance. A client holding the boolean has to guess between
them, so the empty state either says nothing or says something that may be
untrue -- offering a delegate a switch that is not theirs to reach, or telling
an account holder to turn on a module the operator removed.

`moduleAccess` carries the reason beside the booleans, which keep their exact
meaning: `enabled`, `disabled`, `not_granted`, `unavailable`, resolved
outside-in so the reason published is the one nothing further in can change.
`modules[key]` stays exactly `moduleAccess[key] === "enabled"` for every key,
and the two halves are built independently -- the boolean still runs through
`maskModulesToSections` -- so the test of that pairing is an assertion rather
than a restatement of a derivation. It is checked on the pure builder across a
matrix of grants, records and operator blobs, and again on the real route for a
scoped delegate, an unscoped one, and an operator-disabled module.

Both ends in one change, because a payload field with no reader is the failure
this repository keeps rediscovering. The reader is `ModuleDisabledNotice`, the
shared module-off empty state the empty-states audit asks for: it names the
reason in the reader's own language and offers the enable control only when the
record's own switch is what is off, since neither of the other two is fixable
from that page. The nutrients page adopts it and drops its bespoke block, which
had been handing every delegate a button that would refuse them.

Registered in the OpenAPI registry as `ModuleAccessMap` and carried on the
account payload; the native contract gains a field and loses nothing, since a
Bearer request carries no switch and never reads `not_granted`.
2026-09-10 04:41:38 +02:00

93 lines
3.3 KiB
TypeScript

"use client";
import type { ReactNode } from "react";
import { EmptyState } from "@/components/ui/empty-state";
import { useAuth } from "@/hooks/use-auth";
import { MODULE_REGISTRY, type ModuleKey } from "@/lib/modules/registry";
import type { ModuleAccessState } from "@/lib/sharing/module-disclosure";
import { useTranslations } from "@/lib/i18n/context";
/**
* The page a person reached for a module that is not there, saying which of
* the three reasons it is.
*
* Modelled on `shared-record-unavailable.tsx`, which solves the same problem
* one layer over. The difference this file exists for is that "module off"
* has never been one situation: the record has it switched off, the grant this
* session is inside does not cover it, or the operator switched it off for the
* whole instance. The boolean map collapses all three into `false`, so an
* empty state built on it either says nothing or says something that may be
* untrue — telling a delegate to go and turn on a switch that is not theirs,
* or telling an account holder to turn on a module the operator removed.
*
* `moduleAccess` carries the reason, so this says it. The action is offered
* ONLY for the record's own switch: nobody else's state is fixable from here,
* and an affordance that cannot work is worse than none.
*
* Paint, not enforcement. Every route behind a module refuses on its own
* through the server gate; deleting this file would restore a wall of 403s,
* not open a door.
*/
const NOTICE_COPY: Record<
Exclude<ModuleAccessState, "enabled">,
{ title: string; description: string }
> = {
disabled: {
title: "moduleOff.disabled.title",
description: "moduleOff.disabled.description",
},
not_granted: {
title: "moduleOff.notGranted.title",
description: "moduleOff.notGranted.description",
},
unavailable: {
title: "moduleOff.unavailable.title",
description: "moduleOff.unavailable.description",
},
};
export interface ModuleDisabledNoticeProps {
moduleKey: ModuleKey;
icon?: ReactNode;
/**
* Rendered only when the record's own switch is what is off. A delegate
* outside the grant and an instance-wide switch both have nothing here the
* reader can act on.
*/
action?: ReactNode;
}
export function ModuleDisabledNotice({
moduleKey,
icon,
action,
}: ModuleDisabledNoticeProps) {
const { t } = useTranslations();
const { user } = useAuth();
// Fall back to the boolean rather than to a guess: a server image that
// predates the field publishes no reason, and "the record has it off" is the
// only one of the three that is true for every account on such a server.
const state: Exclude<ModuleAccessState, "enabled"> =
user?.moduleAccess?.[moduleKey] &&
user.moduleAccess[moduleKey] !== "enabled"
? (user.moduleAccess[moduleKey] as Exclude<ModuleAccessState, "enabled">)
: "disabled";
const copy = NOTICE_COPY[state];
const moduleName = t(MODULE_REGISTRY[moduleKey].labelKey);
return (
<div data-slot="module-disabled-notice" data-module-access={state}>
<EmptyState
icon={icon}
title={t(copy.title, { module: moduleName })}
description={t(copy.description, { module: moduleName })}
ctaSize="lg"
action={state === "disabled" ? action : undefined}
/>
</div>
);
}