mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
Merge managed-profile editing, sex at creation and module switches per record
This commit is contained in:
commit
06d2104808
36 files changed
+2795
-147
No files matched your search
@@ -23,7 +23,7 @@ describe("managed record settings configuration contract", () => {
|
||||
"timeFormat",
|
||||
"dateFormat",
|
||||
],
|
||||
modules: ["modulePreferences"],
|
||||
modules: ["modulePreferences", "cycleTrackingEnabled"],
|
||||
notifications: ["moodReminderEnabled", "notificationPreferences"],
|
||||
thresholds: ["overrides"],
|
||||
coach: ["disableCoach", "preferences"],
|
||||
@@ -34,6 +34,13 @@ describe("managed record settings configuration contract", () => {
|
||||
it.each([
|
||||
["profile", { email: "not-allowed@example.test" }],
|
||||
["modules", { role: "ADMIN" }],
|
||||
// The delegated key by its module name, which is the mistake a stale
|
||||
// client makes. It has to stay a refusal: accepting it would persist an
|
||||
// inert `false` behind a green "saved" and change nothing.
|
||||
["modules", { modulePreferences: { cycle: false } }],
|
||||
// An empty patch. It parses as a strict object and would audit a change
|
||||
// nobody made.
|
||||
["modules", {}],
|
||||
[
|
||||
"notifications",
|
||||
{
|
||||
@@ -63,6 +70,11 @@ describe("managed record settings configuration contract", () => {
|
||||
},
|
||||
],
|
||||
["modules", { modulePreferences: { mood: false } }],
|
||||
// v1.38.14 (#939) — the delegated key, sent on its own. `cycle` is not a
|
||||
// writable module preference and never will be: its user-layer state is
|
||||
// the record's own cycle profile, so the family names the real column
|
||||
// rather than a blob entry the gate ignores.
|
||||
["modules", { cycleTrackingEnabled: false }],
|
||||
[
|
||||
"notifications",
|
||||
{
|
||||
|
||||
@@ -88,19 +88,43 @@ describe("shared-record navigation", () => {
|
||||
).toEqual(["/checkups"]);
|
||||
});
|
||||
|
||||
it("uses the active record scope instead of the actor's module flags", () => {
|
||||
const actorModules = {
|
||||
medications: false,
|
||||
labs: false,
|
||||
mood: false,
|
||||
mentalHealth: false,
|
||||
cycle: false,
|
||||
illness: false,
|
||||
inboundDocuments: false,
|
||||
};
|
||||
/**
|
||||
* Scope and modules are two filters and they answer different questions.
|
||||
*
|
||||
* This case used to assert that the module map was IGNORED inside a shared
|
||||
* record, and the reason was sound at the time: the map `GET /api/auth/me`
|
||||
* published was the ACTOR's, which describes their own dashboard, so letting
|
||||
* it hide a domain the record had granted would have been the actor's
|
||||
* preference deciding somebody else's navigation.
|
||||
*
|
||||
* v1.38.14 (#939) removed the premise rather than the filter. The payload
|
||||
* now resolves the map for the RECORD the session is inside, so the map is
|
||||
* the record's own answer to "what does this record track" — and honouring
|
||||
* it is what makes a guardian's Modules toggle visible inside the profile it
|
||||
* was set for. The scope still decides which doors the grant opens; the map
|
||||
* decides which of them the record uses at all.
|
||||
*/
|
||||
it("applies the record's own module map on top of its scope", () => {
|
||||
const scope = ["medications", "labs"] as const;
|
||||
|
||||
expect(
|
||||
visibleNavDestinations(actorModules, true, true, ["medications"]).map(
|
||||
visibleNavDestinations({}, true, true, [...scope]).map(
|
||||
(destination) => destination.href,
|
||||
),
|
||||
).toEqual(["/medications", "/labs"]);
|
||||
|
||||
// The record tracks medications and not labs. Both are in scope; one door
|
||||
// is offered.
|
||||
expect(
|
||||
visibleNavDestinations({ labs: false }, true, true, [...scope]).map(
|
||||
(destination) => destination.href,
|
||||
),
|
||||
).toEqual(["/medications"]);
|
||||
|
||||
// And the scope still binds: a module the record tracks is not a door the
|
||||
// grant opens.
|
||||
expect(
|
||||
visibleNavDestinations({}, true, true, ["medications"]).map(
|
||||
(destination) => destination.href,
|
||||
),
|
||||
).toEqual(["/medications"]);
|
||||
|
||||
+108
-31
@@ -9,12 +9,37 @@
|
||||
* shell would 403 the moment a switch was on.
|
||||
*
|
||||
* So every field below is still the CALLER's own — their preferences, their
|
||||
* modules, their identity — and the one field about the switch,
|
||||
* `accountAccess`, says so explicitly. Nothing here ever renders the owner's
|
||||
* data; a delegate reading their own display preferences while inside
|
||||
* somebody else's record is the correct behaviour and the reason the mode
|
||||
* exists (design §4: locale, theme and layout belong to the person, not the
|
||||
* record).
|
||||
* identity — and the one field about the switch, `accountAccess`, says so
|
||||
* explicitly. Nothing here ever renders the owner's health data; a delegate
|
||||
* reading their own display preferences while inside somebody else's record is
|
||||
* the correct behaviour and the reason the mode exists (design §4: locale,
|
||||
* theme and layout belong to the person, not the record).
|
||||
*
|
||||
* TWO fields are the exception, and the exception is the rule applied rather
|
||||
* than a hole in it: `modules` and `cycleTrackingEnabled` resolve for the
|
||||
* RECORD the session is inside, not for the actor (#939). They are not display
|
||||
* preferences. They say what this record tracks — and every consumer of them
|
||||
* gates a surface that shows the RECORD's data: the navigation, the dashboard
|
||||
* tiles, the insights sections, quick-add. Publishing the actor's map while
|
||||
* switched meant a Guardian who turned Cycle off for the child still saw a
|
||||
* Cycle destination in the child's record, and a Guardian who tracks it saw
|
||||
* one there whether or not the child did — the toggle in the record's own
|
||||
* settings wrote a column nothing on screen was reading. Design §4 is about
|
||||
* whose eyes are on the screen; this is about whose record is on it.
|
||||
*
|
||||
* Those two fields are then MASKED to the sections the active grant names.
|
||||
* `accountAccess.active` is set for any live grant, level and scope included,
|
||||
* so the record scoping on its own would hand a delegate scoped to
|
||||
* `measurements` the owner's cycle, screener, illness and nutrient
|
||||
* configuration — and the cycle flag is a derivation of the owner's recorded
|
||||
* sex. A section the grant does not open answers `false`, which is the answer
|
||||
* a module that is off already gives and the answer the browser's own route
|
||||
* inventory has always acted on. An unscoped grant names every section and is
|
||||
* unaffected.
|
||||
*
|
||||
* The own-record case is byte-identical: with no switch the active record IS
|
||||
* the caller, there is no grant to narrow to, and every value below is
|
||||
* resolved from the same row it always was.
|
||||
*/
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { apiHandler, requireActorAuth } from "@/lib/api-handler";
|
||||
@@ -31,6 +56,10 @@ import {
|
||||
} from "@/lib/modules/gate";
|
||||
import { parseTourProgress } from "@/lib/onboarding/tour-progress";
|
||||
import { resolveAccountAccess } from "@/lib/sharing/account-access";
|
||||
import {
|
||||
maskModulesToSections,
|
||||
sectionsOpen,
|
||||
} from "@/lib/sharing/module-disclosure";
|
||||
import { recordSessionForPayload } from "@/lib/sharing/record-session-fence";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -41,26 +70,54 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
annotate({ action: { name: "auth.me" } });
|
||||
|
||||
// Two independent awaits, overlapped: the onboarding-cookie resync
|
||||
// (v1.4.22 W5 Sr-H1 — fall-back for legacy sessions that predate the
|
||||
// cookie; new sessions anchor it inside `createSession`) touches only
|
||||
// the cookie store, while the cycle-profile read (v1.15.0 — resolved
|
||||
// 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, moduleAvailability, accountAccess] =
|
||||
// Overlapped, not sequential: the two cookie resyncs (v1.4.22 W5 Sr-H1 —
|
||||
// the onboarding fall-back for sessions that predate the cookie; new ones
|
||||
// anchor it inside `createSession`) touch only the cookie store, while the
|
||||
// access resolution is a Postgres round-trip. Running them one after the
|
||||
// other added the cookie hop to every /me, and /me sits on every app boot.
|
||||
const [, , accountAccess] = await Promise.all([
|
||||
setOnboardingPendingCookie(user.onboardingCompletedAt == null),
|
||||
// v1.23 — keep the admin-enforced-MFA hint cookie honest on every app
|
||||
// boot, mirroring the onboarding-cookie resync. A locally edited cookie is
|
||||
// corrected here; an account that enrols (or has the policy lifted) loses
|
||||
// the redirect on the next /me read.
|
||||
syncMfaEnrollCookie(user.id, {
|
||||
totpConfirmedAt: user.totpConfirmedAt,
|
||||
mfaEnforced: user.mfaEnforced,
|
||||
}),
|
||||
// v1.36.0 — which records this caller may open, which one they are
|
||||
// inside, and what they may do there. Every value resolved server-side;
|
||||
// see the module docblock for why none of it is left to the client.
|
||||
resolveAccountAccess(auth),
|
||||
]);
|
||||
|
||||
// The record the two module fields answer for, and the reason this read is
|
||||
// sequenced after the block above rather than inside it: the active record
|
||||
// has to be the GRANT-VALIDATED one. The session row's own selector is not
|
||||
// that — it is a stamp, and a stamp whose grant was revoked a minute ago
|
||||
// still names an account. `resolveAccountAccess` is what checks the live
|
||||
// grant, so its answer is what may pick whose configuration is published.
|
||||
// The cost is one round-trip on a hot path, paid on every /me; publishing a
|
||||
// revoked record's configuration would be paid once, by somebody else.
|
||||
const recordId = accountAccess.active?.accountId ?? user.id;
|
||||
|
||||
// What the grant opens, and therefore what the two record fields below may
|
||||
// say. `null` is the whole record — the answer for an unscoped grant and for
|
||||
// no switch at all, and the reason the own-record payload is untouched.
|
||||
const sections = accountAccess.active?.sections ?? null;
|
||||
|
||||
const [record, cycleProfile, resolvedModules, moduleAvailability] =
|
||||
await Promise.all([
|
||||
setOnboardingPendingCookie(user.onboardingCompletedAt == null),
|
||||
// v1.23 — keep the admin-enforced-MFA hint cookie honest on every app
|
||||
// boot, mirroring the onboarding-cookie resync. A locally edited cookie is
|
||||
// corrected here; an account that enrols (or has the policy lifted) loses
|
||||
// the redirect on the next /me read.
|
||||
syncMfaEnrollCookie(user.id, {
|
||||
totpConfirmedAt: user.totpConfirmedAt,
|
||||
mfaEnforced: user.mfaEnforced,
|
||||
}),
|
||||
// The actor's own row is already in hand; a switched session needs the
|
||||
// record's `gender`, which is the column the cycle gate derives from.
|
||||
recordId === user.id
|
||||
? Promise.resolve({ gender: user.gender })
|
||||
: prisma.user.findUnique({
|
||||
where: { id: recordId },
|
||||
select: { gender: true },
|
||||
}),
|
||||
prisma.cycleProfile.findUnique({
|
||||
where: { userId: user.id },
|
||||
where: { userId: recordId },
|
||||
select: { cycleTrackingEnabled: true },
|
||||
}),
|
||||
// v1.18.0 — resolved module enable/disable map for every toggleable
|
||||
@@ -68,19 +125,33 @@ export const GET = apiHandler(async () => {
|
||||
// gate / disableCoach + operator assistant flag); the rest read the
|
||||
// disabled-allowlist `modulePreferencesJson`. Default-on. Clients
|
||||
// hide a whole module surface end-to-end when its key is `false`.
|
||||
resolveModuleMap(user.id),
|
||||
resolveModuleMap(recordId),
|
||||
// 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.
|
||||
// Server-WIDE, so it is the same map for every record and is not
|
||||
// re-scoped here.
|
||||
getOperatorModuleAvailability(),
|
||||
// v1.36.0 — which records this caller may open, which one they are
|
||||
// inside, and what they may do there. Every value resolved server-side;
|
||||
// see the module docblock for why none of it is left to the client.
|
||||
resolveAccountAccess(auth),
|
||||
]);
|
||||
const cycleTrackingEnabled = isCycleEnabled(user.gender, cycleProfile);
|
||||
|
||||
// The masking step, and the one place the record scoping is narrowed rather
|
||||
// than resolved. `active` is set for ANY live grant — a READ grant scoped to
|
||||
// `measurements` included — so publishing the record's whole map would tell
|
||||
// a delegate whether the owner tracks their cycle, their screeners, their
|
||||
// illness episodes or their supplement intake, none of which their grant
|
||||
// opens. A closed section reads `false`, the same answer a module that is
|
||||
// off already gives, and the navigation the client builds from it drops
|
||||
// exactly the doors `isSharedRecordPathPresentable` drops anyway.
|
||||
const modules = maskModulesToSections(resolvedModules, sections);
|
||||
// The cycle flag is masked on its own rather than read off `modules.cycle`:
|
||||
// that key also carries the operator's server-wide availability, and this
|
||||
// field never has. Same section, same answer, without borrowing a second
|
||||
// layer's decision.
|
||||
const cycleTrackingEnabled =
|
||||
sectionsOpen(sections, "cycle") &&
|
||||
isCycleEnabled(record?.gender ?? null, cycleProfile);
|
||||
|
||||
// v1.7.0 — patient-identity fields for the health-record export. The
|
||||
// KVNR is stored encrypted; decrypt fail-soft so a key-rotation gap on
|
||||
@@ -157,7 +228,11 @@ export const GET = apiHandler(async () => {
|
||||
insurerIkNumber: user.insurerIkNumber ?? null,
|
||||
insuranceNumber,
|
||||
// v1.15.0 — cycle-tracking feature gate, resolved server-side. iOS
|
||||
// hides the whole cycle tab when this is false.
|
||||
// hides the whole cycle tab when this is false. v1.38.14 — resolved for
|
||||
// the ACTIVE RECORD, so a switched browser hides the tab the record does
|
||||
// not track rather than the one the actor does not (#939), and `false`
|
||||
// whenever the active grant does not name the `cycle` section. Unchanged
|
||||
// on the native transport, which carries no switch.
|
||||
cycleTrackingEnabled,
|
||||
// v1.18.0 — module enable/disable map. `{ <moduleKey>: boolean }`
|
||||
// for every toggleable module; `false` means the module is OFF and
|
||||
@@ -165,6 +240,8 @@ export const GET = apiHandler(async () => {
|
||||
// …). `cycle` mirrors `cycleTrackingEnabled` and `coach` mirrors the
|
||||
// resolved `disableCoach` + operator master flag, so this map is the
|
||||
// single thing a client needs to gate every secondary domain.
|
||||
// v1.38.14 — resolved for the ACTIVE RECORD and masked to the sections
|
||||
// the active grant opens; see the module docblock.
|
||||
modules,
|
||||
// v1.18.0 — operator-layer availability per toggleable module. `false`
|
||||
// ⇒ the operator turned the module off server-wide (off for every
|
||||
|
||||
@@ -3,17 +3,166 @@ import { NextRequest } from "next/server";
|
||||
import {
|
||||
apiHandler,
|
||||
MFA_STEP_UP_MAX_AGE_SECONDS,
|
||||
requireCookieAuth,
|
||||
requireFreshMfa,
|
||||
} from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import {
|
||||
deleteManagedProfile,
|
||||
ManagedProfileLifecycleError,
|
||||
readManagedProfileForGuardian,
|
||||
updateManagedProfile,
|
||||
} from "@/lib/managed-profiles/lifecycle";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { updateManagedProfileSchema } from "@/lib/validations/managed-profiles";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
/** The one refusal this file answers with for every "you may not have it". */
|
||||
const notFound = () =>
|
||||
apiError("Managed profile not found", 404, {
|
||||
errorCode: "managed_profile.not_found",
|
||||
});
|
||||
|
||||
/**
|
||||
* The record as it stands.
|
||||
*
|
||||
* The read the edit form fills itself from, and the reason it exists at all:
|
||||
* `accountAccess.accounts` on the account payload carries a managed record's
|
||||
* NAME and nothing else, so a form offered from the Guardian's own panel had
|
||||
* no way to show the timezone, the language or the date of birth it was about
|
||||
* to overwrite. A form that opens with blank fields and PUTs them is how a
|
||||
* record loses its timezone to an edit of its name.
|
||||
*
|
||||
* Cookie-only and NOT step-up gated, exactly like the guardian roster beside
|
||||
* it and for the same reason: this is a read, it discloses to a Guardian what
|
||||
* they already administer, and the gate belongs to the acts. (Named
|
||||
* `requireCookieAuth` rather than described by the session helper it calls —
|
||||
* see the roster route's docblock for why that distinction is load-bearing.)
|
||||
*
|
||||
* An ACTOR surface: the profile is named in the path and the caller acts as
|
||||
* themselves, so it answers from the Guardian's own account rather than only
|
||||
* while switched into the record.
|
||||
*/
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireCookieAuth();
|
||||
const { id } = await params;
|
||||
|
||||
const profile = await readManagedProfileForGuardian({
|
||||
profileId: id,
|
||||
guardianId: user.id,
|
||||
});
|
||||
// One refusal for "no such account", "not a managed profile" and "not a
|
||||
// Guardian of it", so the route is not an enumeration oracle.
|
||||
if (!profile) return notFound();
|
||||
|
||||
annotate({
|
||||
action: { name: "managed_profile.read" },
|
||||
meta: { profile_id: id },
|
||||
});
|
||||
return apiSuccess(profile);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* The ceiling the create sibling carries, on the act that changes what it
|
||||
* created. Ten an hour per caller: a person cannot mistype their way into ten
|
||||
* edits of a record's identity, and the number matching creation and guardian
|
||||
* invitation keeps one family answering one way.
|
||||
*
|
||||
* The GET beside it stays unlimited, matching the guardian roster read next to
|
||||
* it: both are cheap identity reads a Guardian may already make, and neither
|
||||
* family sibling has ever carried a read bucket.
|
||||
*/
|
||||
const UPDATE_LIMIT = 10;
|
||||
const UPDATE_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Change a managed record's identity.
|
||||
*
|
||||
* ## Why the same gate as deletion
|
||||
*
|
||||
* `requireFreshMfa`, unconditionally, which is what creation and deletion
|
||||
* already carry. The three acts are one family: they mint, change and end an
|
||||
* account that can never prove anything about itself, so the proof has to come
|
||||
* from the person doing it. Softening the gate here — "it is only a rename" —
|
||||
* would leave the display name of every record somebody looks after writable
|
||||
* from an ambient cookie, and the display name is what every other Guardian,
|
||||
* the switcher and the record banner identify the record by.
|
||||
*
|
||||
* A Guardian who is already switched INTO the record edits the same five
|
||||
* columns through `PATCH /api/record-settings/profile` without a step-up, and
|
||||
* that is not a contradiction: reaching that surface at all requires the
|
||||
* switch, which is itself an act on the record. This route is the one reachable
|
||||
* from the Guardian's own panel, where nothing else has been proved.
|
||||
*
|
||||
* ## Which fields, and why not more
|
||||
*
|
||||
* The five the creation takes. Everything else a `User` row carries —
|
||||
* credentials, provider connections, notification routing, the health profile
|
||||
* — is deliberately out of reach: this is the record's identity, not its
|
||||
* account. The wider configuration surface is `PATCH
|
||||
* /api/record-settings/{family}`, which is guardian-fenced and switched-in.
|
||||
*/
|
||||
export const PATCH = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
|
||||
// Before the id is read, exactly as the create sibling does it. The cheap
|
||||
// refusal has to come first here for a second reason: `updateManagedProfile`
|
||||
// opens a transaction and takes an advisory lock on the id it was HANDED
|
||||
// before it decides the caller is not a Guardian of it, so an unthrottled
|
||||
// caller can name any id they like and make the database hold a lock about
|
||||
// it. Same ceiling as creating a record and inviting a Guardian — the three
|
||||
// acts are one family and answering the same abuse differently is the
|
||||
// asymmetry nobody notices until it is used.
|
||||
const rateLimit = await checkRateLimit(
|
||||
`managed-profile:update:${user.id}`,
|
||||
UPDATE_LIMIT,
|
||||
UPDATE_WINDOW_MS,
|
||||
);
|
||||
if (!rateLimit.allowed) {
|
||||
return apiError("Too many profile edits, try again later", 429);
|
||||
}
|
||||
const { id } = await params;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = updateManagedProfileSchema.safeParse(body);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
|
||||
// The parsed patch goes to the service, which assembles the Prisma payload
|
||||
// field by field. One assembly rather than one here and one there: two
|
||||
// statements of which fields are writable is one statement too many.
|
||||
let profile;
|
||||
try {
|
||||
profile = await updateManagedProfile({
|
||||
profileId: id,
|
||||
guardianId: user.id,
|
||||
patch: parsed.data,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ManagedProfileLifecycleError) return notFound();
|
||||
throw error;
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "managed_profile.update" },
|
||||
meta: { profile_id: id, changed: Object.keys(parsed.data) },
|
||||
});
|
||||
return apiSuccess(profile);
|
||||
},
|
||||
);
|
||||
|
||||
/** Delete a managed record only after its cookie-backed Guardian proves MFA. */
|
||||
export const DELETE = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
@@ -23,11 +172,7 @@ export const DELETE = apiHandler(
|
||||
try {
|
||||
await deleteManagedProfile({ profileId: id, guardianId: user.id });
|
||||
} catch (error) {
|
||||
if (error instanceof ManagedProfileLifecycleError) {
|
||||
return apiError("Managed profile not found", 404, {
|
||||
errorCode: "managed_profile.not_found",
|
||||
});
|
||||
}
|
||||
if (error instanceof ManagedProfileLifecycleError) return notFound();
|
||||
throw error;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { createManagedProfile } from "@/lib/managed-profiles/create";
|
||||
import { toManagedProfileView } from "@/lib/managed-profiles/lifecycle";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { createManagedProfileSchema } from "@/lib/validations/managed-profiles";
|
||||
@@ -62,6 +63,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
: null,
|
||||
locale: parsed.data.locale,
|
||||
timezone: parsed.data.timezone,
|
||||
// Absent and null both mean "not recorded". The record keeps the same
|
||||
// NULL an account that never answered carries, and the cycle module
|
||||
// derives its default from it rather than from a guess about the person.
|
||||
gender: parsed.data.gender ?? null,
|
||||
});
|
||||
|
||||
await auditLog("managed_profile.created", {
|
||||
@@ -73,15 +78,5 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
meta: { profile_id: profile.id },
|
||||
});
|
||||
|
||||
return apiSuccess(
|
||||
{
|
||||
id: profile.id,
|
||||
displayName: profile.displayName,
|
||||
dateOfBirth: profile.dateOfBirth,
|
||||
locale: profile.locale,
|
||||
timezone: profile.timezone,
|
||||
recordKind: "managed" as const,
|
||||
},
|
||||
201,
|
||||
);
|
||||
return apiSuccess(toManagedProfileView(profile), 201);
|
||||
});
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
parseManagedRecordSettingsPatch,
|
||||
resolveGuardianRecordSettingsAccess,
|
||||
} from "@/lib/record-settings";
|
||||
import { isCycleEnabled } from "@/lib/cycle/gate";
|
||||
import { getOrCreateCycleProfile } from "@/lib/cycle/profile";
|
||||
import {
|
||||
resolveInsightsLayout,
|
||||
serializeInsightsLayout,
|
||||
@@ -101,13 +103,23 @@ export const GET = apiHandler(
|
||||
dateFormat: record.dateFormat,
|
||||
};
|
||||
break;
|
||||
case "modules":
|
||||
case "modules": {
|
||||
// The delegated key needs its real source, which is not the module
|
||||
// blob. `isCycleEnabled` resolves the record's own opt-in against its
|
||||
// sex exactly as `/api/auth/me` does for the account itself, so the
|
||||
// switch a guardian sees is the state the gate will answer with.
|
||||
const cycleProfile = await prisma.cycleProfile.findUnique({
|
||||
where: { userId: access.recordId },
|
||||
select: { cycleTrackingEnabled: true },
|
||||
});
|
||||
settings = {
|
||||
modulePreferences: managedModulePreferencesFrom(
|
||||
record.modulePreferencesJson,
|
||||
),
|
||||
cycleTrackingEnabled: isCycleEnabled(record.gender, cycleProfile),
|
||||
};
|
||||
break;
|
||||
}
|
||||
case "notifications": {
|
||||
const preferences = parseNotificationPrefs(record.notificationPrefs);
|
||||
settings = {
|
||||
@@ -233,19 +245,50 @@ export const PATCH = apiHandler(
|
||||
const patch = parseManagedRecordSettingsPatch("modules", body);
|
||||
const current = await prisma.user.findUnique({
|
||||
where: { id: access.recordId },
|
||||
select: { modulePreferencesJson: true },
|
||||
select: { modulePreferencesJson: true, gender: true },
|
||||
});
|
||||
const modulePreferences = {
|
||||
...managedModulePreferencesFrom(current?.modulePreferencesJson),
|
||||
...patch.modulePreferences,
|
||||
};
|
||||
await prisma.user.update({
|
||||
where: { id: access.recordId },
|
||||
data: { modulePreferencesJson: toJson(modulePreferences) },
|
||||
if (patch.modulePreferences !== undefined) {
|
||||
await prisma.user.update({
|
||||
where: { id: access.recordId },
|
||||
data: { modulePreferencesJson: toJson(modulePreferences) },
|
||||
});
|
||||
}
|
||||
// The delegated key writes its own column. An explicit boolean is
|
||||
// what the cycle gate honours over the sex-derived default, so a
|
||||
// guardian turning Cycle off for a record whose sex says otherwise
|
||||
// is recorded as the deliberate answer it is.
|
||||
if (patch.cycleTrackingEnabled !== undefined) {
|
||||
await getOrCreateCycleProfile(access.recordId);
|
||||
await prisma.cycleProfile.update({
|
||||
where: { userId: access.recordId },
|
||||
data: { cycleTrackingEnabled: patch.cycleTrackingEnabled },
|
||||
});
|
||||
}
|
||||
const cycleProfile = await prisma.cycleProfile.findUnique({
|
||||
where: { userId: access.recordId },
|
||||
select: { cycleTrackingEnabled: true },
|
||||
});
|
||||
invalidateUserHealthScore(access.recordId);
|
||||
settings = { modulePreferences };
|
||||
changed = Object.keys(patch.modulePreferences);
|
||||
settings = {
|
||||
modulePreferences,
|
||||
cycleTrackingEnabled: isCycleEnabled(
|
||||
current?.gender ?? null,
|
||||
cycleProfile,
|
||||
),
|
||||
};
|
||||
// The module keys themselves, not the wrapper field: the record's
|
||||
// trail should say which module moved. `cycleTrackingEnabled` is
|
||||
// named as itself because that is the column it writes.
|
||||
changed = [
|
||||
...Object.keys(patch.modulePreferences ?? {}),
|
||||
...(patch.cycleTrackingEnabled !== undefined
|
||||
? ["cycleTrackingEnabled"]
|
||||
: []),
|
||||
];
|
||||
break;
|
||||
}
|
||||
case "notifications": {
|
||||
|
||||
@@ -89,6 +89,32 @@ describe("the destination list under a switch", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("a module the RECORD does not track", () => {
|
||||
/**
|
||||
* #939 — the map `GET /api/auth/me` publishes is the RECORD's while a
|
||||
* session is switched into one, so this filter is what makes a guardian's
|
||||
* toggle visible. Before it, turning Cycle off for a profile left the Cycle
|
||||
* door standing in that profile's own navigation, and the toggle wrote a
|
||||
* column nothing on screen was reading.
|
||||
*/
|
||||
it("drops its destination inside the record", () => {
|
||||
const withCycle = visibleNavDestinations(ALL_MODULES, true, true, null).map(
|
||||
(d) => d.href,
|
||||
);
|
||||
expect(withCycle).toContain("/cycle");
|
||||
|
||||
const withoutCycle = visibleNavDestinations(
|
||||
{ ...ALL_MODULES, cycle: false },
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
).map((d) => d.href);
|
||||
expect(withoutCycle).not.toContain("/cycle");
|
||||
// Only that one door: a module toggle is not a scope change.
|
||||
expect(withoutCycle).toContain("/measurements");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the utility tail under a switch", () => {
|
||||
it("disappears entirely", () => {
|
||||
// Settings holds credentials, integrations, notification channels and
|
||||
|
||||
@@ -342,13 +342,22 @@ function isNavDestinationVisible(
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// A shared record's server-resolved scope decides which health-domain doors
|
||||
// exist. The actor's module preferences describe their own dashboard and
|
||||
// must not hide a domain the target record explicitly granted.
|
||||
if (sharedRecord) return true;
|
||||
if (d.sharedRecordOnly) return false;
|
||||
// The inverse marker: a destination that exists ONLY inside somebody else's
|
||||
// record (the read-only profile summary). Guarded on `!sharedRecord` rather
|
||||
// than reached only through the shared arm, which is what it relied on
|
||||
// before the module gate below started applying to a shared record too.
|
||||
if (!sharedRecord && d.sharedRecordOnly) return false;
|
||||
if (!d.requiresModule) return true;
|
||||
if (!mounted) return false;
|
||||
// The module map, and whose it is, is the whole point. A shared record used
|
||||
// to skip this line: the grant's scope decided which doors existed, and the
|
||||
// module map was the ACTOR's, which describes their own dashboard and must
|
||||
// not hide a domain the record granted. `GET /api/auth/me` now resolves the
|
||||
// map for the RECORD the session is inside (#939), so the objection is gone
|
||||
// and the line is the one that makes a guardian's toggle visible — without
|
||||
// it, turning Cycle off for a profile left the Cycle door standing in that
|
||||
// profile's own navigation. Scope decides which doors the grant opens;
|
||||
// this decides which of them the record tracks at all.
|
||||
return modules?.[d.requiresModule] !== false;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,10 @@ import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS } from "@/lib/record-settings";
|
||||
import { ALL_METRICS } from "@/lib/validations/thresholds";
|
||||
|
||||
import { ManagedRecordSettingsForm } from "../managed-record-settings-section";
|
||||
import {
|
||||
managedModulesPatch,
|
||||
ManagedRecordSettingsForm,
|
||||
} from "../managed-record-settings-section";
|
||||
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
|
||||
|
||||
@@ -201,3 +204,60 @@ describe("managed record settings forms", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("what a managed Modules save sends", () => {
|
||||
const moduleKeys = Object.keys(MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS);
|
||||
|
||||
/** The form as it stands after somebody flips one switch. */
|
||||
const formWith = (checked: Record<string, boolean>) => {
|
||||
const form = new FormData();
|
||||
for (const [name, on] of Object.entries(checked)) {
|
||||
if (on) form.set(name, "on");
|
||||
}
|
||||
return form;
|
||||
};
|
||||
|
||||
it("omits the cycle flag when only a direct module moved", () => {
|
||||
// The record tracks cycles and the guardian is turning Labs off. Sending
|
||||
// the cycle switch's position here would write the sex-derived default as
|
||||
// an explicit answer and freeze it against a later change of sex.
|
||||
const settings = {
|
||||
modulePreferences: {},
|
||||
cycleTrackingEnabled: true,
|
||||
};
|
||||
const checked = Object.fromEntries(
|
||||
moduleKeys.map((key) => [key, key !== "labs"]),
|
||||
);
|
||||
|
||||
const patch = managedModulesPatch(
|
||||
settings,
|
||||
formWith({ ...checked, cycleTrackingEnabled: true }),
|
||||
);
|
||||
|
||||
expect(patch.modulePreferences.labs).toBe(false);
|
||||
expect("cycleTrackingEnabled" in patch).toBe(false);
|
||||
});
|
||||
|
||||
it("sends the cycle flag when the cycle switch itself moved", () => {
|
||||
const patch = managedModulesPatch(
|
||||
{ modulePreferences: {}, cycleTrackingEnabled: true },
|
||||
formWith(Object.fromEntries(moduleKeys.map((key) => [key, true]))),
|
||||
);
|
||||
|
||||
expect(patch.cycleTrackingEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("sends the cycle flag when a record with none turns it on", () => {
|
||||
// `cycleTrackingEnabled` absent is the record that never answered; the
|
||||
// switch opens off, so turning it on is a change and has to ride.
|
||||
const patch = managedModulesPatch(
|
||||
{ modulePreferences: {} },
|
||||
formWith({
|
||||
...Object.fromEntries(moduleKeys.map((key) => [key, true])),
|
||||
cycleTrackingEnabled: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(patch.cycleTrackingEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -20,9 +20,27 @@ vi.mock("next/navigation", () => ({
|
||||
const mockModulesRef: { value: Record<string, boolean> | undefined } = {
|
||||
value: undefined,
|
||||
};
|
||||
// v1.37.0 — the shell also filters by which record the browser is inside.
|
||||
// Unlike the module gate above, that filter is NOT hydration-deferred: the
|
||||
// server has already resolved which record is active, so it applies on the SSR
|
||||
// pass and is assertable from a static render.
|
||||
const mockActiveRecordRef: {
|
||||
value: { recordKind: "managed" | "shared"; level: string } | null;
|
||||
} = { value: null };
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "u1", role: "USER", modules: mockModulesRef.value },
|
||||
user: {
|
||||
id: "u1",
|
||||
role: "USER",
|
||||
modules: mockModulesRef.value,
|
||||
accountAccess: mockActiveRecordRef.value
|
||||
? {
|
||||
accounts: [mockActiveRecordRef.value],
|
||||
active: mockActiveRecordRef.value,
|
||||
canSwitch: true,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
@@ -42,9 +60,11 @@ function renderShell(props: {
|
||||
pathname?: string;
|
||||
locale?: "en" | "de";
|
||||
modules?: Record<string, boolean>;
|
||||
activeRecord?: { recordKind: "managed" | "shared"; level: string } | null;
|
||||
}) {
|
||||
mockPathnameRef.value = props.pathname ?? "/settings/account";
|
||||
mockModulesRef.value = props.modules;
|
||||
mockActiveRecordRef.value = props.activeRecord ?? null;
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={props.locale ?? "en"}>
|
||||
<SettingsShell active={props.active}>
|
||||
@@ -327,6 +347,39 @@ describe("<SettingsShell>", () => {
|
||||
expect(optedOut).toContain('href="/settings/gesundheitsakte"');
|
||||
});
|
||||
|
||||
describe("inside somebody else's record", () => {
|
||||
/**
|
||||
* #939 — a guardian could turn a module off for the profile they look
|
||||
* after and had to find the page by URL to do it. The section is
|
||||
* classified `managed-guardian`, which is what the shell offers a managed
|
||||
* record; asserting it here is what stops a future narrowing of the filter
|
||||
* from silently taking the destination away again.
|
||||
*/
|
||||
it("offers the Modules destination to a guardian on a managed record", () => {
|
||||
const html = renderShell({
|
||||
active: "account",
|
||||
activeRecord: { recordKind: "managed", level: "manage" },
|
||||
});
|
||||
expect(html).toContain('href="/settings/modules"');
|
||||
// Its own configuration is not the record's, and stays away.
|
||||
expect(html).not.toContain('href="/settings/security"');
|
||||
});
|
||||
|
||||
it("does not offer it to an adult delegate holding MANAGE", () => {
|
||||
// A delegate manages somebody's health record, not their account:
|
||||
// modules, thresholds and notification routing stay with the owner.
|
||||
// Integrations remains status-only on a managed record, and unreachable
|
||||
// here — both are `managed-guardian`, which an adult record never opens.
|
||||
const html = renderShell({
|
||||
active: "account",
|
||||
activeRecord: { recordKind: "shared", level: "manage" },
|
||||
});
|
||||
expect(html).not.toContain('href="/settings/modules"');
|
||||
expect(html).not.toContain('href="/settings/integrations"');
|
||||
expect(html).toContain('href="/settings/anamnesis"');
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to `account` when the pathname doesn't match a known slug", () => {
|
||||
const html = renderShell({ pathname: "/settings/totally-bogus" });
|
||||
const activeLinkRegex =
|
||||
|
||||
@@ -262,12 +262,16 @@ describe("the managed-profile card, in the shared-access section", () => {
|
||||
expect(html).toContain("you become its first guardian");
|
||||
});
|
||||
|
||||
it("collects the four fields the route accepts and nothing else", () => {
|
||||
it("collects the five fields the route accepts and nothing else", () => {
|
||||
const html = renderGatedSection();
|
||||
for (const slot of [
|
||||
"managed-profile-name",
|
||||
"managed-profile-dob",
|
||||
"managed-profile-locale",
|
||||
// v1.38.14 (#939) — the fifth. The route's schema is `.strict()`, so a
|
||||
// control the schema does not name is a 422 about a field nobody filled
|
||||
// in; this list and the schema are the same list.
|
||||
"managed-profile-gender",
|
||||
"managed-profile-create-submit",
|
||||
]) {
|
||||
expect(html, slot).toContain(`data-slot="${slot}"`);
|
||||
@@ -280,6 +284,27 @@ describe("the managed-profile card, in the shared-access section", () => {
|
||||
.exec(html)?.[0]
|
||||
.match(/<option/g) ?? [];
|
||||
expect(options).toHaveLength(7);
|
||||
// Four sex options, and the first is the empty one: null is a real answer
|
||||
// ("not recorded") rather than an absent field, and a form offering only
|
||||
// the three enum members would make it unsayable.
|
||||
const genderOptions =
|
||||
/<select[^>]*data-slot="managed-profile-gender"[\s\S]*?<\/select>/
|
||||
.exec(html)?.[0]
|
||||
.match(/<option/g) ?? [];
|
||||
expect(genderOptions).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("offers an edit control on every row it lists", () => {
|
||||
// Issue #939: a managed record could be created and deleted and never
|
||||
// changed, so a name typed as a placeholder was permanent. The control is
|
||||
// offered while the guardian roster is still in flight, unlike the
|
||||
// deletion beside it, whose confirm copy states how many people lose the
|
||||
// record and therefore cannot exist before that read answers.
|
||||
authRef.value = { accounts: [entry()], active: null };
|
||||
rosterRef.value = "loading";
|
||||
const html = renderGatedSection();
|
||||
expect(html).toContain('data-slot="managed-profile-edit"');
|
||||
expect(html).not.toContain('data-slot="managed-profile-delete"');
|
||||
});
|
||||
|
||||
it("lists the profiles this account looks after, and only those", () => {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* What the managed-record edit form sends, and what it must not.
|
||||
*
|
||||
* The route reads an absent key as "leave it", so the diff this function
|
||||
* computes is the whole of the promise that a save touches only what somebody
|
||||
* moved. The case worth pinning is the language: a record with none opens its
|
||||
* control on the ACTOR's, so comparing the control against the record's column
|
||||
* made every save of such a record carry `locale` — and a guardian who only
|
||||
* corrected the timezone wrote their own language into somebody else's record.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { managedProfileEditPatch } from "@/components/settings/access/managed-profile-edit-form";
|
||||
import type { ManagedProfileView } from "@/lib/queries/use-managed-profiles";
|
||||
|
||||
const record: ManagedProfileView = {
|
||||
id: "rec_1",
|
||||
displayName: "Managed record",
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
locale: null,
|
||||
timezone: "Europe/Berlin",
|
||||
recordKind: "managed",
|
||||
};
|
||||
|
||||
const draft = {
|
||||
displayName: "Managed record",
|
||||
dateOfBirth: "",
|
||||
locale: "en" as const,
|
||||
gender: null,
|
||||
timezone: "Europe/Berlin",
|
||||
};
|
||||
|
||||
describe("managedProfileEditPatch", () => {
|
||||
it("leaves the language out when a record with none only moves its timezone", () => {
|
||||
const patch = managedProfileEditPatch(
|
||||
record,
|
||||
{ ...draft, timezone: "Europe/Madrid" },
|
||||
"en",
|
||||
);
|
||||
|
||||
expect(patch).toEqual({ timezone: "Europe/Madrid" });
|
||||
});
|
||||
|
||||
it("answers null when a record with no language is not touched at all", () => {
|
||||
expect(managedProfileEditPatch(record, draft, "en")).toBeNull();
|
||||
});
|
||||
|
||||
it("sends the language once somebody picks a different one", () => {
|
||||
const patch = managedProfileEditPatch(
|
||||
record,
|
||||
{ ...draft, locale: "de" },
|
||||
"en",
|
||||
);
|
||||
|
||||
expect(patch).toEqual({ locale: "de" });
|
||||
});
|
||||
|
||||
it("sends the language when a record that has one is moved off it", () => {
|
||||
const patch = managedProfileEditPatch(
|
||||
{ ...record, locale: "de" },
|
||||
{ ...draft, locale: "en" },
|
||||
"de",
|
||||
);
|
||||
|
||||
expect(patch).toEqual({ locale: "en" });
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { UserRoundCog } from "lucide-react";
|
||||
|
||||
import {
|
||||
@@ -7,9 +8,11 @@ import {
|
||||
grantActionErrorKey,
|
||||
} from "@/components/settings/access/grant-action-error";
|
||||
import { ManagedProfileCreateForm } from "@/components/settings/access/managed-profile-create-form";
|
||||
import { ManagedProfileEditForm } from "@/components/settings/access/managed-profile-edit-form";
|
||||
import { ManagedProfileGuardians } from "@/components/settings/access/managed-profile-guardians";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
@@ -127,6 +130,10 @@ function ManagedProfileRow({
|
||||
const guardians = useManagedProfileGuardians(profile.accountId);
|
||||
const name = accountLabel(profile);
|
||||
const failed = remove.isError && remove.variables === profile.accountId;
|
||||
// Per row, not per card. The edit form reads the record's identity when it
|
||||
// opens, so one shared flag would put every row's form on the wire the
|
||||
// moment any of them was opened.
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
return (
|
||||
<li
|
||||
@@ -143,8 +150,24 @@ function ManagedProfileRow({
|
||||
{t("recordSharing.lookingAfter.kindManaged")}
|
||||
</p>
|
||||
</div>
|
||||
{guardians.data && (
|
||||
<div className="flex shrink-0 items-center">
|
||||
{/* UI-STANDARDS §11: the actions stay beside the name, never below it.
|
||||
Edit is offered without waiting for the roster — it changes what the
|
||||
record IS and does not depend on how many people look after it,
|
||||
unlike the deletion beside it, whose confirm copy states that
|
||||
number and therefore cannot exist before the roster answers. */}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="managed-profile-edit"
|
||||
aria-expanded={editing}
|
||||
onClick={() => setEditing((open) => !open)}
|
||||
>
|
||||
{t("recordSharing.managed.edit")}
|
||||
</Button>
|
||||
{guardians.data && (
|
||||
<ConfirmButton
|
||||
slot="managed-profile-delete"
|
||||
size="sm"
|
||||
@@ -161,9 +184,15 @@ function ManagedProfileRow({
|
||||
pending={remove.isPending}
|
||||
onConfirm={() => remove.mutate(profile.accountId)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{editing && (
|
||||
<ManagedProfileEditForm
|
||||
profileId={profile.accountId}
|
||||
onDone={() => setEditing(false)}
|
||||
/>
|
||||
)}
|
||||
{failed && (
|
||||
<GrantActionAlert
|
||||
grantId={profile.accountId}
|
||||
|
||||
@@ -12,7 +12,10 @@ import { stepUpErrorKey } from "@/components/settings/access/grant-action-error"
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import { locales, localeLabels, type Locale } from "@/lib/i18n/config";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useCreateManagedProfile } from "@/lib/queries/use-managed-profiles";
|
||||
import {
|
||||
useCreateManagedProfile,
|
||||
type ManagedProfileGender,
|
||||
} from "@/lib/queries/use-managed-profiles";
|
||||
import {
|
||||
DEFAULT_TIMEZONE,
|
||||
detectBrowserTimezone,
|
||||
@@ -22,15 +25,22 @@ import {
|
||||
/**
|
||||
* v1.37.0 — creating a record that has no login of its own.
|
||||
*
|
||||
* ## Why the form is exactly four fields
|
||||
* ## Why the form is exactly five fields
|
||||
*
|
||||
* `POST /api/managed-profiles` parses with a `.strict()` schema. A strict
|
||||
* schema refuses an extra key outright, so a form that helpfully posted an
|
||||
* empty `heightCm` — or a `gender` somebody thought would be useful — is
|
||||
* refused with a validation error about a field the person never filled in.
|
||||
* The four fields below are the schema, and the submit handler names them one
|
||||
* by one rather than spreading the form state, so a fifth control cannot reach
|
||||
* the wire by accident.
|
||||
* empty `heightCm` is refused with a validation error about a field the person
|
||||
* never filled in. The five fields below are the schema, and the submit
|
||||
* handler names them one by one rather than spreading the form state, so a
|
||||
* sixth control cannot reach the wire by accident.
|
||||
*
|
||||
* The fifth is `gender`, and it arrived late for a stated reason (#939). It
|
||||
* was left out of v1.37.0 on the argument that a record's identity should ask
|
||||
* for as little as possible — and the consequence was that every managed
|
||||
* record started with `gender` NULL, which is what the cycle module derives
|
||||
* its default from. A guardian creating a record for a child could neither
|
||||
* record the answer nor turn the module off from here. Asking once, optionally,
|
||||
* is less intrusive than the module surface that had to exist to undo it.
|
||||
*
|
||||
* ## The date of birth is genuinely optional
|
||||
*
|
||||
@@ -70,6 +80,7 @@ export function ManagedProfileCreateForm() {
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [dateOfBirth, setDateOfBirth] = useState("");
|
||||
const [locale, setLocale] = useState<Locale>(actorLocale);
|
||||
const [gender, setGender] = useState<ManagedProfileGender>(null);
|
||||
const [timezone, setTimezone] = useState(() => browserTimezone());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [created, setCreated] = useState<string | null>(null);
|
||||
@@ -95,14 +106,16 @@ export function ManagedProfileCreateForm() {
|
||||
dateOfBirth: dateOfBirth.length > 0 ? dateOfBirth : null,
|
||||
locale,
|
||||
timezone,
|
||||
gender,
|
||||
},
|
||||
{
|
||||
onSuccess: (profile) => {
|
||||
setDisplayName("");
|
||||
setDateOfBirth("");
|
||||
setLocale(actorLocale);
|
||||
setGender(null);
|
||||
setTimezone(browserTimezone());
|
||||
setCreated(profile.displayName);
|
||||
setCreated(profile.displayName ?? trimmedName);
|
||||
},
|
||||
// Nothing is cleared here, on purpose: a refused creation is one the
|
||||
// person is being asked to retry, and a form that emptied itself
|
||||
@@ -193,6 +206,28 @@ export function ManagedProfileCreateForm() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="managed-profile-gender">
|
||||
{t("recordSharing.managed.genderLabel")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="managed-profile-gender"
|
||||
data-slot="managed-profile-gender"
|
||||
className="w-full"
|
||||
value={gender ?? ""}
|
||||
onChange={(e) => setGender(genderFromSelect(e.target.value))}
|
||||
>
|
||||
{MANAGED_PROFILE_GENDER_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{t(option.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("recordSharing.managed.genderHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<TimezonePicker
|
||||
id="managed-profile-timezone"
|
||||
@@ -307,6 +342,36 @@ export function FieldGuidance({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The sex control's options, in the order the record's own settings offer them.
|
||||
*
|
||||
* Shared with the edit form rather than written twice: the empty value is the
|
||||
* NULL the column really stores, and a second copy of this list is how one
|
||||
* surface ends up offering four options and the other three.
|
||||
*/
|
||||
export const MANAGED_PROFILE_GENDER_OPTIONS: ReadonlyArray<{
|
||||
value: string;
|
||||
labelKey: string;
|
||||
}> = [
|
||||
{ value: "", labelKey: "recordSharing.managed.genderNone" },
|
||||
{ value: "MALE", labelKey: "recordSharing.managed.genderMale" },
|
||||
{ value: "FEMALE", labelKey: "recordSharing.managed.genderFemale" },
|
||||
{ value: "OTHER", labelKey: "recordSharing.managed.genderOther" },
|
||||
];
|
||||
|
||||
/**
|
||||
* The select's string, as the route's enum.
|
||||
*
|
||||
* Exported and pure because the empty option is the one that matters: it means
|
||||
* NULL — "not recorded" — and a form that sent `""` would be refused by the
|
||||
* enum for a choice the person deliberately made.
|
||||
*/
|
||||
export function genderFromSelect(value: string): ManagedProfileGender {
|
||||
return value === "MALE" || value === "FEMALE" || value === "OTHER"
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
/** The browser's zone, or the app default when the engine will not say. */
|
||||
function browserTimezone(): string {
|
||||
const detected = detectBrowserTimezone();
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
FieldGuidance,
|
||||
MANAGED_PROFILE_GENDER_OPTIONS,
|
||||
displayNameIssue,
|
||||
genderFromSelect,
|
||||
} from "@/components/settings/access/managed-profile-create-form";
|
||||
import { stepUpErrorKey } from "@/components/settings/access/grant-action-error";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { TimezonePicker } from "@/components/settings/timezone-picker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DateField } from "@/components/ui/date-field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import { locales, localeLabels, type Locale } from "@/lib/i18n/config";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
useManagedProfile,
|
||||
useUpdateManagedProfile,
|
||||
type ManagedProfileGender,
|
||||
type ManagedProfileView,
|
||||
type UpdateManagedProfileInput,
|
||||
} from "@/lib/queries/use-managed-profiles";
|
||||
import { isValidTimezone } from "@/lib/tz/format";
|
||||
|
||||
/**
|
||||
* v1.38.14 (#939) — changing a record after it exists.
|
||||
*
|
||||
* ## Why the form loads before it renders
|
||||
*
|
||||
* The account payload names a managed record and nothing else, so this form
|
||||
* cannot be filled from what the card already holds. It reads
|
||||
* `GET /api/managed-profiles/{id}` first and paints nothing editable until
|
||||
* that answers. The alternative — open the fields blank and send whatever is
|
||||
* in them — is how a record loses its timezone to an edit of its name, and it
|
||||
* would be invisible: the write would succeed.
|
||||
*
|
||||
* The read is `enabled` only while the form is open, so a Guardian who looks
|
||||
* after four records does not fetch four identities to paint a list that shows
|
||||
* none of them.
|
||||
*
|
||||
* ## Why it sends only what moved
|
||||
*
|
||||
* `PATCH` reads an absent key as "leave it" and an explicit null on the two
|
||||
* nullable fields as "clear it". The diff below is what keeps those two
|
||||
* different: a form that always sent five fields would overwrite a timezone
|
||||
* somebody changed in the record's own settings while this form sat open, with
|
||||
* the value it read minutes earlier. It also makes the audit row honest —
|
||||
* `changed` is derived from the body, so "the guardian renamed the record" is
|
||||
* what the record's trail says when that is what happened.
|
||||
*
|
||||
* ## The step-up is the expected first answer
|
||||
*
|
||||
* Same family, same gate as creating and deleting: `requireFreshMfa`, so the
|
||||
* first save in a session is a 401 carrying `auth.stepup.required`. It gets
|
||||
* its own sentence naming the next step rather than the generic failure, which
|
||||
* would send somebody looking for a problem with what they typed.
|
||||
*/
|
||||
export function ManagedProfileEditForm({
|
||||
profileId,
|
||||
onDone,
|
||||
}: {
|
||||
profileId: string;
|
||||
/** Called after a save lands, so the row can close the form. */
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const profile = useManagedProfile(profileId);
|
||||
|
||||
if (profile.isError) {
|
||||
return (
|
||||
<QueryErrorCard
|
||||
title={t("recordSharing.managed.editLoadError")}
|
||||
onRetry={() => void profile.refetch()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!profile.data) {
|
||||
return (
|
||||
<p
|
||||
role="status"
|
||||
data-slot="managed-profile-edit-loading"
|
||||
className="text-muted-foreground flex items-center gap-2 text-sm"
|
||||
>
|
||||
<Loader2
|
||||
className="h-4 w-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t("nav.loadingScreen")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
// Keyed on WHICH record is open, so the field state below is seeded once per
|
||||
// record rather than through an effect that re-seeds on every refetch. The
|
||||
// key was the query's `dataUpdatedAt`, which did the opposite of what it
|
||||
// claimed: any invalidation of the `managed-profiles` prefix — removing a
|
||||
// guardian from another row, say — refetches this read, moves the timestamp
|
||||
// and remounts the fields, so a half-typed name disappears while somebody is
|
||||
// looking at it. The id is stable for as long as the form is about the same
|
||||
// record, which is exactly as long as the seeding should hold.
|
||||
return (
|
||||
<ManagedProfileEditFields
|
||||
key={profile.data.id}
|
||||
profile={profile.data}
|
||||
onDone={onDone}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ManagedProfileEditFields({
|
||||
profile,
|
||||
onDone,
|
||||
}: {
|
||||
profile: ManagedProfileView;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const { t, locale: actorLocale } = useTranslations();
|
||||
const update = useUpdateManagedProfile();
|
||||
|
||||
const [displayName, setDisplayName] = useState(profile.displayName ?? "");
|
||||
const [dateOfBirth, setDateOfBirth] = useState(profile.dateOfBirth ?? "");
|
||||
// A record with no recorded language opens on the actor's, which is the same
|
||||
// default the creation form uses. It is a starting point for a control the
|
||||
// person is looking at, not an inference written behind their back — nothing
|
||||
// is sent unless they move it, which is why the seed is frozen at mount and
|
||||
// handed to the patch builder as what "moved" is measured against.
|
||||
const [seededLocale] = useState<Locale>(() =>
|
||||
isLocale(profile.locale) ? profile.locale : actorLocale,
|
||||
);
|
||||
const [locale, setLocale] = useState<Locale>(seededLocale);
|
||||
const [gender, setGender] = useState<ManagedProfileGender>(profile.gender);
|
||||
const [timezone, setTimezone] = useState(profile.timezone);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const nameIssue = displayNameIssue(displayName);
|
||||
const timezoneIssue = isValidTimezone(timezone)
|
||||
? null
|
||||
: "recordSharing.managed.timezoneInvalid";
|
||||
const patch = managedProfileEditPatch(
|
||||
profile,
|
||||
{
|
||||
displayName,
|
||||
dateOfBirth,
|
||||
locale,
|
||||
gender,
|
||||
timezone,
|
||||
},
|
||||
seededLocale,
|
||||
);
|
||||
const blocked =
|
||||
nameIssue !== null || timezoneIssue !== null || patch === null;
|
||||
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
// `blocked` already covers it; narrowed again so the spread below is typed
|
||||
// rather than asserted.
|
||||
if (blocked || patch === null || update.isPending) return;
|
||||
setError(null);
|
||||
update.mutate(
|
||||
{ profileId: profile.id, ...patch },
|
||||
{
|
||||
onSuccess: onDone,
|
||||
// Nothing is reset: a refused save is one somebody is being asked to
|
||||
// retry, and a form that restored the server's values would throw the
|
||||
// edit away to explain that it did not land.
|
||||
onError: (err) => setError(t(editManagedProfileErrorKey(err))),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={submit}
|
||||
data-slot="managed-profile-edit-form"
|
||||
className="space-y-3"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`managed-profile-edit-name-${profile.id}`}>
|
||||
{t("recordSharing.managed.nameLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id={`managed-profile-edit-name-${profile.id}`}
|
||||
data-slot="managed-profile-edit-name"
|
||||
value={displayName}
|
||||
onChange={(e) => {
|
||||
setDisplayName(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
autoComplete="off"
|
||||
maxLength={80}
|
||||
aria-invalid={nameIssue !== null ? "true" : undefined}
|
||||
/>
|
||||
<FieldGuidance
|
||||
slot="managed-profile-edit-name"
|
||||
error={nameIssue === null ? null : t(nameIssue)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`managed-profile-edit-dob-${profile.id}`}>
|
||||
{t("recordSharing.managed.dobLabel")}
|
||||
</Label>
|
||||
<DateField
|
||||
id={`managed-profile-edit-dob-${profile.id}`}
|
||||
data-slot="managed-profile-edit-dob"
|
||||
value={dateOfBirth}
|
||||
onChange={setDateOfBirth}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`managed-profile-edit-locale-${profile.id}`}>
|
||||
{t("recordSharing.managed.localeLabel")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id={`managed-profile-edit-locale-${profile.id}`}
|
||||
data-slot="managed-profile-edit-locale"
|
||||
className="w-full"
|
||||
value={locale}
|
||||
onChange={(e) => setLocale(e.target.value as Locale)}
|
||||
>
|
||||
{locales.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{localeLabels[option]}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`managed-profile-edit-gender-${profile.id}`}>
|
||||
{t("recordSharing.managed.genderLabel")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id={`managed-profile-edit-gender-${profile.id}`}
|
||||
data-slot="managed-profile-edit-gender"
|
||||
className="w-full"
|
||||
value={gender ?? ""}
|
||||
onChange={(e) => setGender(genderFromSelect(e.target.value))}
|
||||
>
|
||||
{MANAGED_PROFILE_GENDER_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{t(option.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("recordSharing.managed.genderHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<TimezonePicker
|
||||
id={`managed-profile-edit-timezone-${profile.id}`}
|
||||
value={timezone}
|
||||
onChange={(next) => {
|
||||
setTimezone(next);
|
||||
setError(null);
|
||||
}}
|
||||
hint={t("recordSharing.managed.timezoneHint")}
|
||||
/>
|
||||
<FieldGuidance
|
||||
slot="managed-profile-edit-timezone"
|
||||
error={timezoneIssue === null ? null : t(timezoneIssue)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Above the action row, per UI-STANDARDS §12: nothing follows it. */}
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
data-slot="managed-profile-edit-error"
|
||||
className="text-destructive text-sm"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="managed-profile-edit-cancel"
|
||||
onClick={onDone}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="managed-profile-edit-submit"
|
||||
disabled={blocked || update.isPending}
|
||||
>
|
||||
{update.isPending
|
||||
? t("recordSharing.managed.editSaving")
|
||||
: t("recordSharing.managed.editSave")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/** Is this the record's language, as the app knows languages. */
|
||||
function isLocale(value: string | null): value is Locale {
|
||||
return value !== null && (locales as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/** The form's five controls, as strings, before they become a patch. */
|
||||
interface ManagedProfileEditDraft {
|
||||
displayName: string;
|
||||
dateOfBirth: string;
|
||||
locale: Locale;
|
||||
gender: ManagedProfileGender;
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What actually changed, or `null` when nothing did.
|
||||
*
|
||||
* Exported and pure because it is the decision this form makes: the route
|
||||
* refuses an empty body, so "nothing changed" has to be answered here rather
|
||||
* than discovered as a 422, and a field the person did not touch must not ride
|
||||
* along and overwrite a concurrent edit made in the record's own settings.
|
||||
* The empty date field is `null` — "clear it" — and not the empty string,
|
||||
* which the route's date format would refuse.
|
||||
*/
|
||||
export function managedProfileEditPatch(
|
||||
profile: ManagedProfileView,
|
||||
draft: ManagedProfileEditDraft,
|
||||
/**
|
||||
* The language the control opened on, which is the record's own or — for a
|
||||
* record that has none — the actor's. Compared against instead of
|
||||
* `profile.locale` because those two differ exactly when the record has no
|
||||
* language: comparing against the column made every save of a record like
|
||||
* that carry `locale`, so moving only the timezone wrote the actor's
|
||||
* language into somebody else's record.
|
||||
*/
|
||||
seededLocale: Locale,
|
||||
): Omit<UpdateManagedProfileInput, "profileId"> | null {
|
||||
const displayName = draft.displayName.trim();
|
||||
const dateOfBirth = draft.dateOfBirth.length > 0 ? draft.dateOfBirth : null;
|
||||
const patch: Omit<UpdateManagedProfileInput, "profileId"> = {
|
||||
...(displayName !== (profile.displayName ?? "") ? { displayName } : {}),
|
||||
...(dateOfBirth !== profile.dateOfBirth ? { dateOfBirth } : {}),
|
||||
...(draft.locale !== seededLocale ? { locale: draft.locale } : {}),
|
||||
...(draft.gender !== profile.gender ? { gender: draft.gender } : {}),
|
||||
...(draft.timezone !== profile.timezone
|
||||
? { timezone: draft.timezone }
|
||||
: {}),
|
||||
};
|
||||
return Object.keys(patch).length > 0 ? patch : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The message for a refused edit.
|
||||
*
|
||||
* The arms creation carries plus the one it does not need, and with the same
|
||||
* reasoning: the step-up arm is a gate rather than a failure and must not read
|
||||
* as one, a 429 is a wait rather than a failure and reuses creation's own line,
|
||||
* and a 422 is a disagreement between this form's bounds and the route's rather
|
||||
* than something the person typed — the multi-issue envelope's `details.issues`
|
||||
* never reach the browser. The 404 is this route's alone: the record can be
|
||||
* ended by another Guardian while the form sits open.
|
||||
*/
|
||||
export function editManagedProfileErrorKey(err: unknown): string {
|
||||
if (!(err instanceof ApiError)) return "recordSharing.managed.errorOffline";
|
||||
if (err.status === 401) return stepUpErrorKey(err);
|
||||
if (err.status === 422) return "recordSharing.managed.errorInvalid";
|
||||
if (err.status === 429) return "recordSharing.managed.errorRateLimit";
|
||||
if (err.status === 404) return "recordSharing.managed.editErrorGone";
|
||||
return "recordSharing.managed.editErrorFailed";
|
||||
}
|
||||
@@ -484,6 +484,49 @@ function ProfileSettingsForm({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What a Modules save actually sends.
|
||||
*
|
||||
* The direct modules ride along whether or not they moved — they are one blob
|
||||
* the route merges, and a key that did not change writes the value it already
|
||||
* had. `cycleTrackingEnabled` is not one of them: it is a DELEGATED module
|
||||
* whose column the cycle gate honours OVER the sex-derived default, so sending
|
||||
* the switch's current position on every save freezes the derivation. A record
|
||||
* created with no recorded sex derives cycle OFF; a guardian who turns Labs off
|
||||
* and saves would write that OFF as an explicit answer, and the record's sex
|
||||
* being set to FEMALE afterwards would then leave cycle off with nothing on
|
||||
* screen to say why. It also put `cycleTrackingEnabled` in the record's audit
|
||||
* trail on every save of any module.
|
||||
*
|
||||
* So it is sent only when it differs from the value the form opened on, which
|
||||
* is what the managed-record edit form's `managedProfileEditPatch` already does
|
||||
* for its own five fields.
|
||||
*
|
||||
* Exported and pure because it is the decision this form makes, and a decision
|
||||
* about what NOT to send is invisible in the rendered markup.
|
||||
*/
|
||||
export function managedModulesPatch(
|
||||
settings: Record<string, unknown>,
|
||||
form: FormData,
|
||||
): {
|
||||
modulePreferences: Record<string, boolean>;
|
||||
cycleTrackingEnabled?: boolean;
|
||||
} {
|
||||
const cycleTrackingEnabled = form.get("cycleTrackingEnabled") === "on";
|
||||
// The same expression the switch is seeded from, so "differs from what the
|
||||
// form opened on" is one statement rather than two that can drift.
|
||||
const loaded = settings.cycleTrackingEnabled === true;
|
||||
return {
|
||||
modulePreferences: Object.fromEntries(
|
||||
Object.keys(MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS).map((key) => [
|
||||
key,
|
||||
form.get(key) === "on",
|
||||
]),
|
||||
),
|
||||
...(cycleTrackingEnabled !== loaded ? { cycleTrackingEnabled } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function ModulesSettingsForm({
|
||||
settings,
|
||||
disabled,
|
||||
@@ -500,12 +543,7 @@ function ModulesSettingsForm({
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
onSave({
|
||||
modulePreferences: Object.fromEntries(
|
||||
entries.map(([key]) => [key, form.get(key) === "on"]),
|
||||
),
|
||||
});
|
||||
onSave(managedModulesPatch(settings, new FormData(event.currentTarget)));
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -523,6 +561,17 @@ function ModulesSettingsForm({
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
{/* v1.38.14 (#939) — Cycle sits below the rest because it is not one of
|
||||
them: it is a DELEGATED module, so its state lives in the record's own
|
||||
cycle profile rather than in the module blob, and it had no row here
|
||||
at all. A guardian could not turn it off for a record whose sex says
|
||||
it should be on, which is the case the issue was opened about. */}
|
||||
<ManagedSwitchRow
|
||||
name="cycleTrackingEnabled"
|
||||
label={t(MODULE_REGISTRY.cycle.labelKey)}
|
||||
defaultChecked={settings.cycleTrackingEnabled === true}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<FormFooter disabled={disabled} label={saveLabel} error={error} />
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,12 @@ export interface CreateManagedProfileInput {
|
||||
dateOfBirth: Date | null;
|
||||
locale: string;
|
||||
timezone: string;
|
||||
/**
|
||||
* `User.gender` — "MALE" | "FEMALE" | "OTHER". Omitted or null both mean not
|
||||
* recorded, and the column keeps the NULL an account that never answered
|
||||
* carries; nothing is inferred from the name or from the date of birth.
|
||||
*/
|
||||
gender?: string | null;
|
||||
}
|
||||
|
||||
export interface CreatedManagedProfile {
|
||||
@@ -58,6 +64,7 @@ export async function createManagedProfile(
|
||||
passwordHash: null,
|
||||
displayName: input.displayName,
|
||||
dateOfBirth: input.dateOfBirth,
|
||||
gender: input.gender ?? null,
|
||||
locale: input.locale,
|
||||
timezone: input.timezone,
|
||||
managedProfileAt: now,
|
||||
|
||||
@@ -214,6 +214,191 @@ export async function deleteManagedProfile(input: {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One managed record, as its own family publishes it.
|
||||
*
|
||||
* The same five fields the creation answers with, plus `gender`. It is a view
|
||||
* and not the row: a `User` carries credentials, provider connections and the
|
||||
* whole health profile, and a Guardian panel needs the record's identity, not
|
||||
* its account.
|
||||
*/
|
||||
export interface ManagedProfileView {
|
||||
id: string;
|
||||
displayName: string | null;
|
||||
/** ISO `yyyy-MM-dd`, or null. Never synthesised from a year. */
|
||||
dateOfBirth: string | null;
|
||||
gender: string | null;
|
||||
/**
|
||||
* The column is nullable and this view says so. Every record this family
|
||||
* creates carries one — creation requires it — so null here means a row that
|
||||
* predates the requirement or was written by something else, and a client
|
||||
* renders its own language for it rather than the string "null".
|
||||
*/
|
||||
locale: string | null;
|
||||
timezone: string;
|
||||
recordKind: "managed";
|
||||
}
|
||||
|
||||
/** The columns the view is built from, kept in one place for both readers. */
|
||||
const MANAGED_PROFILE_VIEW_SELECT = {
|
||||
id: true,
|
||||
displayName: true,
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
locale: true,
|
||||
timezone: true,
|
||||
} as const;
|
||||
|
||||
export function toManagedProfileView(row: {
|
||||
id: string;
|
||||
displayName: string | null;
|
||||
dateOfBirth: Date | null;
|
||||
gender: string | null;
|
||||
locale: string | null;
|
||||
timezone: string;
|
||||
}): ManagedProfileView {
|
||||
return {
|
||||
id: row.id,
|
||||
displayName: row.displayName,
|
||||
dateOfBirth: row.dateOfBirth?.toISOString().slice(0, 10) ?? null,
|
||||
gender: row.gender,
|
||||
locale: row.locale,
|
||||
timezone: row.timezone,
|
||||
recordKind: "managed",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The record as it stands, or `null` when the caller may not have it.
|
||||
*
|
||||
* One `null` for "no such account", "not a managed profile" and "you are not
|
||||
* an active Guardian of it", exactly as the roster read answers, so the route
|
||||
* above it is not an enumeration oracle.
|
||||
*
|
||||
* No lock and no transaction, for the same reason the roster takes neither:
|
||||
* this guards no invariant. It is the read an edit form fills itself from, and
|
||||
* a form filled from a snapshot that moved under it is refused by the write,
|
||||
* not by the read.
|
||||
*/
|
||||
export async function readManagedProfileForGuardian(input: {
|
||||
profileId: string;
|
||||
guardianId: string;
|
||||
now?: Date;
|
||||
}): Promise<ManagedProfileView | null> {
|
||||
const profile = await prisma.user.findUnique({
|
||||
where: { id: input.profileId },
|
||||
select: { ...MANAGED_PROFILE_VIEW_SELECT, managedProfileAt: true },
|
||||
});
|
||||
if (!profile?.managedProfileAt) return null;
|
||||
|
||||
const guardian = await prisma.accountGrant.findFirst({
|
||||
where: {
|
||||
grantorId: profile.id,
|
||||
granteeId: input.guardianId,
|
||||
...activeGuardianWhere(input.now ?? new Date()),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (!guardian) return null;
|
||||
|
||||
return toManagedProfileView(profile);
|
||||
}
|
||||
|
||||
/**
|
||||
* The fields a Guardian may change, in the shape the route's schema parses
|
||||
* them into — `dateOfBirth` an ISO `yyyy-MM-dd` string rather than a `Date`.
|
||||
*
|
||||
* The parsed body arrives here and the column assembly happens below, in one
|
||||
* place. Doing it at the route and again here would be two statements of which
|
||||
* fields are writable, and the second one to be forgotten is the one that
|
||||
* matters.
|
||||
*/
|
||||
export interface ManagedProfileEdit {
|
||||
displayName?: string;
|
||||
dateOfBirth?: string | null;
|
||||
locale?: string;
|
||||
timezone?: string;
|
||||
gender?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change a managed record's identity.
|
||||
*
|
||||
* Guarded exactly as the deletion beside it is: the same advisory lock, the
|
||||
* same managed-profile marker, and the same active-Guardian grant lookup, so
|
||||
* the two acts on one record cannot disagree about who may perform them. The
|
||||
* lock is not strictly needed to write five columns — it is taken because a
|
||||
* concurrent deletion holds it, and an edit that landed between that
|
||||
* transaction's guardian check and its `user.delete` would be a write to a row
|
||||
* on its way out.
|
||||
*
|
||||
* The Prisma payload is assembled field by field from the parsed patch — never
|
||||
* by spreading it — and the `changed` list the audit row carries is derived
|
||||
* from the same object, so the trail names what actually moved rather than
|
||||
* what the form rendered.
|
||||
*/
|
||||
export async function updateManagedProfile(input: {
|
||||
profileId: string;
|
||||
guardianId: string;
|
||||
patch: ManagedProfileEdit;
|
||||
}): Promise<ManagedProfileView> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
return withManagedProfileLock(tx, input.profileId, async (profile) => {
|
||||
if (!profile) throw new ManagedProfileLifecycleError("not_found");
|
||||
if (!profile.managedProfileAt) {
|
||||
throw new ManagedProfileLifecycleError("not_managed");
|
||||
}
|
||||
|
||||
const guardian = await tx.accountGrant.findFirst({
|
||||
where: {
|
||||
grantorId: profile.id,
|
||||
granteeId: input.guardianId,
|
||||
...activeGuardianWhere(new Date()),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (!guardian) throw new ManagedProfileLifecycleError("not_guardian");
|
||||
|
||||
const patch = input.patch;
|
||||
const data = {
|
||||
...(patch.displayName !== undefined
|
||||
? { displayName: patch.displayName }
|
||||
: {}),
|
||||
...(patch.dateOfBirth !== undefined
|
||||
? {
|
||||
dateOfBirth: patch.dateOfBirth
|
||||
? new Date(`${patch.dateOfBirth}T00:00:00.000Z`)
|
||||
: null,
|
||||
}
|
||||
: {}),
|
||||
...(patch.locale !== undefined ? { locale: patch.locale } : {}),
|
||||
...(patch.timezone !== undefined ? { timezone: patch.timezone } : {}),
|
||||
...(patch.gender !== undefined ? { gender: patch.gender } : {}),
|
||||
};
|
||||
|
||||
const updated = await tx.user.update({
|
||||
where: { id: profile.id },
|
||||
data,
|
||||
select: MANAGED_PROFILE_VIEW_SELECT,
|
||||
});
|
||||
|
||||
// Filed under the RECORD with the Guardian named as the actor, which is
|
||||
// how every other record-scoped change is filed. The record's activity
|
||||
// trail is the one an incoming Guardian reads to learn what happened to
|
||||
// it, and a row filed under whoever happened to be at the keyboard would
|
||||
// leave that trail with a hole in it.
|
||||
await auditLog("managed_profile.updated", {
|
||||
userId: profile.id,
|
||||
actorUserId: input.guardianId,
|
||||
details: { changed: Object.keys(data) },
|
||||
client: tx,
|
||||
});
|
||||
|
||||
return toManagedProfileView(updated);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal-only future compatibility hook. There is intentionally no route for
|
||||
* this state change in the current product; holding the same lock ensures any
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import {
|
||||
createManagedProfileSchema,
|
||||
inviteManagedProfileGuardianSchema,
|
||||
updateManagedProfileSchema,
|
||||
} from "@/lib/validations/managed-profiles";
|
||||
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
|
||||
|
||||
@@ -63,7 +64,13 @@ const accountGrantInvite = inviteGrantSchema.meta({
|
||||
const createManagedProfileRequest = createManagedProfileSchema.meta({
|
||||
id: "CreateManagedProfileRequest",
|
||||
description:
|
||||
"Create a health record for somebody who has no login of their own — a child, a dependent adult, an animal. The caller becomes its first Guardian in the same transaction, through an ordinary MANAGE grant with the new record as grantor, so the profile appears in `accountAccess.accounts` on the next `GET /api/auth/me` and is switched into like any other shared record. `displayName` is the only required text; `dateOfBirth` is an optional `YYYY-MM-DD` and is never synthesised from a year. `locale` and `timezone` belong to the RECORD and not to the Guardian reading it: they decide how the profile's own reminders are worded and when its day starts. Strict — an unexpected field is a 422 rather than an ignored key. Cookie transport only and step-up gated (`requireFreshMfa`, unconditional): a Bearer caller cannot mint a credential-less person and a permanent management relationship, and an account with no second factor enrolled is refused rather than waved through. Rate-limited to ten an hour per caller.",
|
||||
"Create a health record for somebody who has no login of their own — a child, a dependent adult, an animal. The caller becomes its first Guardian in the same transaction, through an ordinary MANAGE grant with the new record as grantor, so the profile appears in `accountAccess.accounts` on the next `GET /api/auth/me` and is switched into like any other shared record. `displayName` is the only required text; `dateOfBirth` is an optional `YYYY-MM-DD` and is never synthesised from a year. `gender` is optional and takes the same three values `PATCH /api/auth/me` takes for an ordinary account (`MALE`, `FEMALE`, `OTHER`) or null; absent and null both mean not recorded, and the value is what the cycle module derives its default from, so a record created for a child who does not need it starts with it off. `locale` and `timezone` belong to the RECORD and not to the Guardian reading it: they decide how the profile's own reminders are worded and when its day starts. Strict — an unexpected field is a 422 rather than an ignored key. Cookie transport only and step-up gated (`requireFreshMfa`, unconditional): a Bearer caller cannot mint a credential-less person and a permanent management relationship, and an account with no second factor enrolled is refused rather than waved through. Rate-limited to ten an hour per caller.",
|
||||
});
|
||||
|
||||
const updateManagedProfileRequest = updateManagedProfileSchema.meta({
|
||||
id: "UpdateManagedProfileRequest",
|
||||
description:
|
||||
"Change what a managed record is, from the Guardian's own panel. The same five fields creation takes, every one of them optional, and at least one required — an empty body is a 422 rather than a no-op that audits a change nobody made. Absence means leave the field alone; an explicit null on `dateOfBirth` or `gender` clears it, and the two are different answers. Nothing else on the record is reachable here: credentials, provider connections, notification routing and the health profile are not identity, and the wider configuration surface is `PATCH /api/record-settings/{family}`, which is guardian-fenced and answers only while switched into the record. Strict, cookie-only and step-up gated, like every act in this family.",
|
||||
});
|
||||
|
||||
const inviteManagedProfileGuardianRequest =
|
||||
@@ -183,9 +190,25 @@ const managedProfileCreated = z
|
||||
.describe(
|
||||
"The profile's account id — the value `POST /api/account/switch` and the `X-HealthLog-Account` selector take, and the `{id}` in this family's paths.",
|
||||
),
|
||||
displayName: z.string(),
|
||||
dateOfBirth: z.string().nullable(),
|
||||
locale: z.string(),
|
||||
displayName: z.string().nullable(),
|
||||
dateOfBirth: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
"`YYYY-MM-DD`, or null. The same date-only shape the request takes — this is a calendar date, not an instant, and publishing it with a time would invite a client to apply a zone to it.",
|
||||
),
|
||||
gender: z
|
||||
.enum(["MALE", "FEMALE", "OTHER"])
|
||||
.nullable()
|
||||
.describe(
|
||||
"`User.gender` for this record. Null means not recorded, which is a real answer and not a missing one.",
|
||||
),
|
||||
locale: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
"The record's own language. Every profile this family creates carries one, because creation requires it; the column is nullable, so null means a row written before that and a client renders its own language rather than the word.",
|
||||
),
|
||||
timezone: z.string(),
|
||||
recordKind: z
|
||||
.literal("managed")
|
||||
@@ -196,7 +219,7 @@ const managedProfileCreated = z
|
||||
.meta({
|
||||
id: "ManagedProfile",
|
||||
description:
|
||||
"A record somebody looks after, as its creation answers with it. There is deliberately no list endpoint: the profiles a Guardian looks after are the `managed` entries of `accountAccess.accounts` on GET /api/auth/me, because a Guardian's relationship to a profile IS a MANAGE grant. Re-read the account payload after any change here; a client that refreshed only its own panel would leave the new record out of the switcher and the banner until the next boot.",
|
||||
"A record somebody looks after, as its creation, its read and its edit all answer with it. There is deliberately no list endpoint: the profiles a Guardian looks after are the `managed` entries of `accountAccess.accounts` on GET /api/auth/me, because a Guardian's relationship to a profile IS a MANAGE grant. Re-read the account payload after any change here; a client that refreshed only its own panel would leave the new record out of the switcher and the banner until the next boot.",
|
||||
});
|
||||
|
||||
const endedGuardianGrant = z
|
||||
@@ -430,16 +453,29 @@ const accountPayload = z
|
||||
.meta({
|
||||
id: "AccountPayload",
|
||||
description:
|
||||
"The signed-in account: its identity, its preferences, and (since v1.36.0) what account sharing lets it do. Additional properties are the preference fields this spec does not yet enumerate. Under an active switch this payload still describes the CALLER — their preferences, their modules, their identity — because display preferences belong to the person at the keyboard rather than to the record they are reading.",
|
||||
"The signed-in account: its identity, its preferences, and (since v1.36.0) what account sharing lets it do. Additional properties are the preference fields this spec does not yet enumerate. Under an active switch the identity and preference fields still describe the CALLER, because display preferences belong to the person at the keyboard rather than to the record they are reading. Two fields are the exception (v1.38.14): `modules` and `cycleTrackingEnabled` describe the ACTIVE RECORD, since every surface they gate shows the record's data — and they are masked to the sections the active grant opens, so a scoped grant reads `false` for a module outside it rather than the record's true state. With no switch — which is every native request, since the Bearer transport carries none — the two are the same account and nothing is masked.",
|
||||
});
|
||||
|
||||
/**
|
||||
* One envelope, three routes.
|
||||
*
|
||||
* `dataEnvelope` mints a new schema on every call, and two schemas cannot share
|
||||
* a component id — so creating, reading and editing a managed profile have to
|
||||
* reference the SAME const rather than call the helper three times. They do
|
||||
* describe the same thing, which is why they may.
|
||||
*/
|
||||
const managedProfileEnvelope = dataEnvelope(
|
||||
managedProfileCreated,
|
||||
"ManagedProfileEnvelope",
|
||||
);
|
||||
|
||||
export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/auth/me": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
summary: "The signed-in account, and what sharing lets it do",
|
||||
description:
|
||||
"Keeps answering while the browser is acting on another record — the switcher, the banner naming whose record is open, and the way back out all read it, so a refusal here would strand a switched session. It is therefore an ACTOR surface: it always serves the caller's own rows, and a request that attaches `X-HealthLog-Account` is refused with 403 `sharing.not_permitted` rather than quietly answered. A client that wants a record's data sends the selector on the read that needs it, never on this call.",
|
||||
"Keeps answering while the browser is acting on another record — the switcher, the banner naming whose record is open, and the way back out all read it, so a refusal here would strand a switched session. It is therefore an ACTOR surface: it serves the caller's own rows — apart from `modules` and `cycleTrackingEnabled`, which answer for the record the session is inside, masked to what the grant opens — and a request that attaches `X-HealthLog-Account` is refused with 403 `sharing.not_permitted` rather than quietly answered. A client that wants a record's data sends the selector on the read that needs it, never on this call.",
|
||||
responses: {
|
||||
...stdResponses,
|
||||
"200": {
|
||||
@@ -676,10 +712,7 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
description: "The new record.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
managedProfileCreated,
|
||||
"ManagedProfileEnvelope",
|
||||
),
|
||||
schema: managedProfileEnvelope,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -697,6 +730,72 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
"/api/managed-profiles/{id}": {
|
||||
get: {
|
||||
tags: ["Account sharing"],
|
||||
summary: "Read a managed record's identity",
|
||||
description:
|
||||
"What the record is right now — the five fields its creation took. The read an edit form fills itself from, and the reason it exists: `accountAccess.accounts` on the account payload carries the record's name and nothing else, so a form offered from the Guardian's own panel had no way to show the timezone, the language or the date of birth it was about to overwrite. Cookie transport only, an ACTOR surface (the profile is named in the path and the caller acts as themselves, so it answers without switching into the record), and NOT step-up gated: this is a read, it discloses to a Guardian only what they already administer, and the gate belongs to the acts.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string() }),
|
||||
},
|
||||
responses: {
|
||||
...stdResponses,
|
||||
"200": {
|
||||
description: "The record as it stands.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: managedProfileEnvelope,
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: managed_profile.not_found`). The two are byte-identical, so the refusal is not an enumeration oracle.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
},
|
||||
patch: {
|
||||
tags: ["Account sharing"],
|
||||
summary: "Change a managed record's identity",
|
||||
description:
|
||||
"Edit the record after it exists — the name that was a placeholder, the timezone that moved with the household, the date of birth somebody finally has, the sex that decides whether the cycle module is on. Any active Guardian may do it, from a cookie session with a fresh second factor: the same gate creation and deletion carry, because the three acts mint, change and end an account that can never prove anything about itself. Ten an hour per caller, the ceiling creation carries. Answers with the whole record, so a client re-renders from the server's version rather than from what it sent.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string() }),
|
||||
},
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: updateManagedProfileRequest },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
...stdResponses,
|
||||
"200": {
|
||||
description: "The record as it now stands.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: managedProfileEnvelope,
|
||||
},
|
||||
},
|
||||
},
|
||||
"401": {
|
||||
description:
|
||||
"No fresh second-factor proof, or none enrolled. Same unconditional gate as creation and deletion.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: managed_profile.not_found`). The two are byte-identical.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"429": {
|
||||
description:
|
||||
"More than ten edits from one caller within the hour — the ceiling creation carries. No error code; branch on the status. Nothing was changed, and the refusal lands before the record named in the path is looked at.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Account sharing"],
|
||||
summary: "Delete a managed record",
|
||||
|
||||
@@ -84,7 +84,7 @@ const moduleMapResolved = z
|
||||
.meta({
|
||||
id: "ModuleMap",
|
||||
description:
|
||||
"Fully-resolved per-user module enable/disable map. Every toggleable module key is present. `false` means the surface should disappear end-to-end (nav, dashboard, insights, …). `cycle` mirrors `cycleTrackingEnabled`; `coach` mirrors the resolved per-user opt-out + operator master flag.",
|
||||
"Fully-resolved module enable/disable map. Every toggleable module key is present. `false` means the surface should disappear end-to-end (nav, dashboard, insights, …). `cycle` mirrors `cycleTrackingEnabled`; `coach` mirrors the resolved per-user opt-out + operator master flag. On `GET /api/auth/me` it answers for the RECORD the session is inside rather than for the caller, because every surface it gates shows the record's data, and it is masked to the sections the active grant opens — a module outside them reads `false` rather than the record's true state. With no switch — which is every native request, since the Bearer transport carries none — the two are the same account, nothing is masked and the payload is unchanged.",
|
||||
});
|
||||
|
||||
const moduleMapEnvelopeInner = z
|
||||
|
||||
@@ -276,7 +276,7 @@ const recordSettingsPatchRequest = z
|
||||
MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS.modules.meta({
|
||||
id: "RecordModuleSettingsPatch",
|
||||
description:
|
||||
"Partial module toggle. Merged onto the record's current preferences; omitted keys keep their value.",
|
||||
"Partial module toggle. `modulePreferences` is merged onto the record's current preferences; omitted keys keep their value. `cycleTrackingEnabled` is separate because `cycle` is a DELEGATED module: its state lives in the record's own cycle profile and `modulePreferences.cycle` is refused, so this is the field that turns cycle tracking on or off for the record — an explicit boolean overrides the default the record's sex would derive. Both fields are optional and at least one is required; an empty body is a 422 rather than a save that changed nothing.",
|
||||
}),
|
||||
MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS.notifications.meta({
|
||||
id: "RecordNotificationSettingsPatch",
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
type QueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { apiDelete, apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { invalidateGrantReads } from "@/lib/queries/use-account-grants";
|
||||
import type { GrantParty } from "@/lib/queries/use-account-grants";
|
||||
@@ -49,18 +49,44 @@ export interface CreateManagedProfileInput {
|
||||
locale: Locale;
|
||||
/** IANA zone name. */
|
||||
timezone: string;
|
||||
/** `User.gender`. Null is a real answer — "not recorded" — not an omission. */
|
||||
gender: ManagedProfileGender;
|
||||
}
|
||||
|
||||
/** What the create route answers with. */
|
||||
export interface ManagedProfileCreated {
|
||||
/** The three values `User.gender` takes, plus the honest absence. */
|
||||
export type ManagedProfileGender = "MALE" | "FEMALE" | "OTHER" | null;
|
||||
|
||||
/** What the create, read and update routes all answer with. */
|
||||
export interface ManagedProfileView {
|
||||
id: string;
|
||||
displayName: string;
|
||||
displayName: string | null;
|
||||
dateOfBirth: string | null;
|
||||
locale: string;
|
||||
gender: ManagedProfileGender;
|
||||
/** Nullable, as the column is. A record created here always carries one. */
|
||||
locale: string | null;
|
||||
timezone: string;
|
||||
recordKind: "managed";
|
||||
}
|
||||
|
||||
/**
|
||||
* The body `PATCH /api/managed-profiles/{id}` accepts, plus the record it is
|
||||
* addressed to.
|
||||
*
|
||||
* Every field optional, at least one required, and `.strict()` on the server —
|
||||
* so the mutation names the fields one by one rather than spreading form state,
|
||||
* exactly as the creation does. An absent key means "leave it"; an explicit
|
||||
* null on the two nullable fields clears it. `profileId` addresses the request
|
||||
* and is deliberately not in the body: the schema would refuse it.
|
||||
*/
|
||||
export interface UpdateManagedProfileInput {
|
||||
profileId: string;
|
||||
displayName?: string;
|
||||
dateOfBirth?: string | null;
|
||||
locale?: Locale;
|
||||
timezone?: string;
|
||||
gender?: ManagedProfileGender;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything a managed-profile lifecycle change makes stale.
|
||||
*
|
||||
@@ -94,11 +120,88 @@ export function useCreateManagedProfile() {
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.managedProfileCreate(),
|
||||
mutationFn: (input: CreateManagedProfileInput) =>
|
||||
apiPost<ManagedProfileCreated>("/api/managed-profiles", input),
|
||||
apiPost<ManagedProfileView>("/api/managed-profiles", input),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The record as it stands, for the form that is about to change it.
|
||||
*
|
||||
* The account payload names a managed record and nothing more, so a form
|
||||
* opened from the Guardian's own panel cannot show the timezone, the language,
|
||||
* the date of birth or the sex it is about to overwrite. This is that read.
|
||||
* `enabled` is what keeps it off the wire until somebody opens the form: a
|
||||
* Guardian with four records should not fetch four identities to paint a list
|
||||
* that shows none of them.
|
||||
*/
|
||||
export function useManagedProfile(profileId: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.managedProfile(profileId),
|
||||
queryFn: () =>
|
||||
apiGet<ManagedProfileView>(`/api/managed-profiles/${profileId}`),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Change a record somebody looks after.
|
||||
*
|
||||
* Guarded by `requireFreshMfa` like every other act in this family, so a 401
|
||||
* carrying `meta.errorCode: auth.stepup.required` is the expected first answer
|
||||
* rather than a failure — the form routes it into the re-verification path.
|
||||
*
|
||||
* The whole family is invalidated on success because the display name is what
|
||||
* the switcher, the record banner and this card all identify the record by, and
|
||||
* the record's own identity read is dropped alongside so a second edit opens
|
||||
* from the server's version rather than from the one that was just replaced.
|
||||
*/
|
||||
export function useUpdateManagedProfile() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.managedProfileUpdate(),
|
||||
mutationFn: (input: UpdateManagedProfileInput) =>
|
||||
apiPatch<ManagedProfileView>(
|
||||
`/api/managed-profiles/${input.profileId}`,
|
||||
managedProfileEditBody(input),
|
||||
),
|
||||
onSuccess: (_result, input) => {
|
||||
invalidateManagedProfileReads(queryClient);
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.managedProfile(input.profileId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The wire body, composed once and exported.
|
||||
*
|
||||
* Pure and separate from the mutation for the same reason `guardianInviteBody`
|
||||
* is: the integration suite posts the REAL body through the REAL route rather
|
||||
* than a hand-written copy, which is the assembly a two-sided test misses.
|
||||
*
|
||||
* Each key is present only when the caller named it, because the route reads
|
||||
* absence as "leave it" and an explicit `undefined` would serialise to nothing
|
||||
* anyway — stating it is what stops a later spread from sending five keys for a
|
||||
* one-field edit.
|
||||
*/
|
||||
export function managedProfileEditBody(
|
||||
input: UpdateManagedProfileInput,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
...(input.displayName !== undefined
|
||||
? { displayName: input.displayName }
|
||||
: {}),
|
||||
...(input.dateOfBirth !== undefined
|
||||
? { dateOfBirth: input.dateOfBirth }
|
||||
: {}),
|
||||
...(input.locale !== undefined ? { locale: input.locale } : {}),
|
||||
...(input.timezone !== undefined ? { timezone: input.timezone } : {}),
|
||||
...(input.gender !== undefined ? { gender: input.gender } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** One person who looks after a profile, as the roster publishes them. */
|
||||
export interface ManagedProfileGuardian {
|
||||
/** The grant row — what `DELETE .../guardians/{grantId}` takes. */
|
||||
|
||||
@@ -50,6 +50,14 @@ export const sharingKeys = {
|
||||
managedProfileGuardians: (profileId: string) =>
|
||||
["managed-profiles", profileId, "guardians"] as const,
|
||||
|
||||
/**
|
||||
* One managed record's identity (`GET /api/managed-profiles/{id}`). Keyed by
|
||||
* profile for the same reason the roster is, and under the same prefix so a
|
||||
* grant transition invalidates it with the rest of the family.
|
||||
*/
|
||||
managedProfile: (profileId: string) =>
|
||||
["managed-profiles", profileId, "profile"] as const,
|
||||
|
||||
/** Mutation keys — kept in the factory so no bare array reaches a call site. */
|
||||
accountGrantInvite: () => ["account", "grants", "invite"] as const,
|
||||
accountGrantAccept: () => ["account", "grants", "accept"] as const,
|
||||
@@ -57,6 +65,7 @@ export const sharingKeys = {
|
||||
accountGrantRenounce: () => ["account", "grants", "renounce"] as const,
|
||||
accountSwitchMutation: () => ["account", "switch"] as const,
|
||||
managedProfileCreate: () => ["managed-profiles", "create"] as const,
|
||||
managedProfileUpdate: () => ["managed-profiles", "update"] as const,
|
||||
managedProfileDelete: () => ["managed-profiles", "delete"] as const,
|
||||
managedProfileGuardianInvite: () =>
|
||||
["managed-profiles", "guardian", "invite"] as const,
|
||||
|
||||
@@ -30,7 +30,7 @@ export const MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST = {
|
||||
"timeFormat",
|
||||
"dateFormat",
|
||||
],
|
||||
modules: ["modulePreferences"],
|
||||
modules: ["modulePreferences", "cycleTrackingEnabled"],
|
||||
notifications: ["moodReminderEnabled", "notificationPreferences"],
|
||||
thresholds: ["overrides"],
|
||||
coach: ["disableCoach", "preferences"],
|
||||
@@ -140,7 +140,31 @@ const managedCoachPreferencesSchema = coachPrefsSchema
|
||||
*/
|
||||
export const MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS = {
|
||||
profile: profilePatchSchema,
|
||||
modules: z.object({ modulePreferences: modulePrefsPatchSchema }).strict(),
|
||||
/**
|
||||
* Two fields because the module foundation has two kinds of key, and only
|
||||
* one of them lives in `modulePreferencesJson`.
|
||||
*
|
||||
* `cycle` is a DELEGATED module: its user-layer state is owned by
|
||||
* `CycleProfile.cycleTrackingEnabled` (NULL derives from `User.gender`), and
|
||||
* `modulePreferencesJson.cycle` is ignored on purpose — which is why
|
||||
* `modulePrefsPatchSchema` refuses the key. A guardian therefore had no way
|
||||
* to turn Cycle off for a record whose sex says it should be on, and no row
|
||||
* for it in the list either: it is simply not a writable key (#939). Naming
|
||||
* the real column here writes the real source of truth rather than minting a
|
||||
* second one that the gate would ignore.
|
||||
*
|
||||
* Both optional so a client may send either alone; at least one required, so
|
||||
* an empty body is a refusal rather than a save that changed nothing.
|
||||
*/
|
||||
modules: z
|
||||
.object({
|
||||
modulePreferences: modulePrefsPatchSchema.optional(),
|
||||
cycleTrackingEnabled: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine((patch) => Object.values(patch).some((v) => v !== undefined), {
|
||||
message: "Name at least one field to change",
|
||||
}),
|
||||
notifications: z
|
||||
.object({
|
||||
moodReminderEnabled: z.boolean().optional(),
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* What a scoped grant is allowed to learn about a record's configuration.
|
||||
*
|
||||
* v1.38.14 made `GET /api/auth/me` publish the ACTIVE RECORD's module map and
|
||||
* cycle flag rather than the actor's, because every surface those two fields
|
||||
* gate shows the record's data. That is right for a whole-record grant and too
|
||||
* generous for a narrowed one: `accountAccess.active` is set for any live
|
||||
* grant, level and scope included, so a delegate scoped to `measurements`
|
||||
* would read whether the owner tracks their cycle, their mental-health
|
||||
* screeners, their illness episodes and their supplement intake — configuration
|
||||
* the grant does not open, and in the cycle flag's case a derivation of the
|
||||
* owner's recorded sex.
|
||||
*
|
||||
* So the map is masked to the sections the grant names, and the mask is the
|
||||
* reason this table can exist beside `scope.ts`'s standing rule that the
|
||||
* sharing vocabulary never converts to the module vocabulary. The rule guards
|
||||
* a direction: a module the owner enabled must never imply a section the owner
|
||||
* shared. Nothing here runs that way. A closed section answers `false` — the
|
||||
* same answer the client already gets for a module that is off — and an open
|
||||
* one answers whatever the gate resolved, never more. No value in this file
|
||||
* can turn a module ON, admit a read, or widen a grant; deleting the table
|
||||
* would leak, not lock.
|
||||
*
|
||||
* `null` means "this module is not one section of a record". Those keys are
|
||||
* masked off for every scoped grant, which is the same answer the browser's
|
||||
* own route inventory gives them: `/`, `/achievements` and the narrative
|
||||
* surfaces read across sections, and a selected-domain grant never opens one.
|
||||
*/
|
||||
import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry";
|
||||
import type { ShareDomain } from "@/lib/sharing/scope";
|
||||
|
||||
/**
|
||||
* The section of a record each module's surfaces read, or `null` when they
|
||||
* read across sections.
|
||||
*
|
||||
* Typed as a total record over {@link MODULE_KEYS}, so a new module cannot
|
||||
* ship without an answer here — and the answer a hurried one gives is at worst
|
||||
* `null`, which discloses nothing.
|
||||
*
|
||||
* The section chosen is the one the browser's route inventory already puts the
|
||||
* module's page under (`src/lib/navigation/shared-record.ts`), so the chrome a
|
||||
* delegate is offered and the configuration they are told about agree.
|
||||
*/
|
||||
export const MODULE_SHARE_DOMAIN: Readonly<
|
||||
Record<ModuleKey, ShareDomain | null>
|
||||
> = Object.freeze({
|
||||
cycle: "cycle",
|
||||
mood: "mind",
|
||||
sleep: "measurements",
|
||||
glucose: "measurements",
|
||||
workouts: "measurements",
|
||||
recovery: "measurements",
|
||||
labs: "labs",
|
||||
illness: "illness",
|
||||
// Reads across the record: the achievement engine counts every domain.
|
||||
achievements: null,
|
||||
// The assistant surfaces narrate the whole record, which is why a scoped
|
||||
// grant is never offered them.
|
||||
coach: null,
|
||||
insights: null,
|
||||
medications: "medications",
|
||||
// The export assembles every section it is given.
|
||||
doctorReport: null,
|
||||
// Weather and air quality are context for the record as a whole, not one
|
||||
// section of it.
|
||||
environment: null,
|
||||
// The account's own remote endpoint. Not a section of anybody's record.
|
||||
mcp: null,
|
||||
inboundDocuments: "documents",
|
||||
mentalHealth: "mind",
|
||||
nutrients: "measurements",
|
||||
// An immunization history is health background, the section `/vaccinations`
|
||||
// is presented under.
|
||||
vaccinations: "profile",
|
||||
});
|
||||
|
||||
/** Does a grant carrying these sections open this one? `null` is the whole record. */
|
||||
export function sectionsOpen(
|
||||
sections: readonly ShareDomain[] | null,
|
||||
domain: ShareDomain,
|
||||
): boolean {
|
||||
return sections === null || sections.includes(domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* The module map as a grant carrying these sections may read it.
|
||||
*
|
||||
* `null` sections are the whole record and pass the map through untouched,
|
||||
* which keeps the own-record and whole-record payloads byte-identical to what
|
||||
* they were. Anything else answers `false` for every key outside the grant.
|
||||
*/
|
||||
export function maskModulesToSections(
|
||||
modules: Record<ModuleKey, boolean>,
|
||||
sections: readonly ShareDomain[] | null,
|
||||
): Record<ModuleKey, boolean> {
|
||||
if (sections === null) return modules;
|
||||
const masked = {} as Record<ModuleKey, boolean>;
|
||||
for (const key of MODULE_KEYS) {
|
||||
const domain = MODULE_SHARE_DOMAIN[key];
|
||||
masked[key] =
|
||||
domain !== null && sectionsOpen(sections, domain) ? modules[key] : false;
|
||||
}
|
||||
return masked;
|
||||
}
|
||||
@@ -1,33 +1,85 @@
|
||||
/**
|
||||
* v1.37.0 — the two bodies the managed-profile family accepts.
|
||||
* v1.37.0 — the bodies the managed-profile family accepts.
|
||||
*
|
||||
* Here rather than beside each route because the OpenAPI table publishes them,
|
||||
* and a schema written twice is a contract that drifts on the third change.
|
||||
* The route modules import these; nothing imports the route modules.
|
||||
*
|
||||
* Both are `.strict()`. A managed profile is a person's health record created
|
||||
* by somebody else, so a field the sender did not mean to send is refused
|
||||
* rather than ignored — and the client types name exactly these fields for the
|
||||
* same reason (`src/lib/queries/use-managed-profiles.ts`).
|
||||
* Every one is `.strict()`. A managed profile is a person's health record
|
||||
* created by somebody else, so a field the sender did not mean to send is
|
||||
* refused rather than ignored — and the client types name exactly these fields
|
||||
* for the same reason (`src/lib/queries/use-managed-profiles.ts`).
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { isValidTimezone } from "@/lib/tz/format";
|
||||
|
||||
/**
|
||||
* The record's sex, as `User.gender` already stores it.
|
||||
*
|
||||
* The column is the one every other surface writes — `PATCH /api/auth/me`, the
|
||||
* onboarding baseline, and the guardian's own `PATCH
|
||||
* /api/record-settings/profile` — so this family reuses its name and its three
|
||||
* values rather than minting a second word for one column. `null` is a real
|
||||
* answer ("not recorded"), not an absent field: a record created for a child
|
||||
* whose guardian would rather not record it says so, and the cycle module
|
||||
* derives OFF from it exactly as it does for an account that never answered.
|
||||
*/
|
||||
const managedProfileGenderSchema = z.enum(["MALE", "FEMALE", "OTHER"]);
|
||||
|
||||
/** The fields both bodies below share, with their bounds stated once. */
|
||||
const managedProfileFields = {
|
||||
displayName: z.string().trim().min(1).max(80),
|
||||
dateOfBirth: z.iso.date().nullable(),
|
||||
locale: z.enum(["de", "en", "es", "fr", "it", "pl", "ko"]),
|
||||
timezone: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.refine(isValidTimezone, "Invalid IANA timezone"),
|
||||
gender: managedProfileGenderSchema.nullable(),
|
||||
} as const;
|
||||
|
||||
/** The body `POST /api/managed-profiles` accepts. */
|
||||
export const createManagedProfileSchema = z
|
||||
.object({
|
||||
displayName: z.string().trim().min(1).max(80),
|
||||
dateOfBirth: z.iso.date().nullable().optional(),
|
||||
locale: z.enum(["de", "en", "es", "fr", "it", "pl", "ko"]),
|
||||
timezone: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.refine(isValidTimezone, "Invalid IANA timezone"),
|
||||
displayName: managedProfileFields.displayName,
|
||||
dateOfBirth: managedProfileFields.dateOfBirth.optional(),
|
||||
locale: managedProfileFields.locale,
|
||||
timezone: managedProfileFields.timezone,
|
||||
gender: managedProfileFields.gender.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* The body `PATCH /api/managed-profiles/{id}` accepts.
|
||||
*
|
||||
* Every field optional and at least one required. A managed record is edited
|
||||
* long after it was created — a name that was a placeholder, a timezone that
|
||||
* moved with the household, a date of birth somebody finally has — and a
|
||||
* PUT-shaped body would make changing the name mean re-sending the other four,
|
||||
* with a stale copy of each. So absence means "leave it", and `null` on the two
|
||||
* nullable fields means "clear it": the two are different answers and the
|
||||
* schema keeps them different.
|
||||
*
|
||||
* The refinement is not decoration. An empty strict object parses, and without
|
||||
* it an empty body would write nothing, audit a change, and answer 200 —
|
||||
* a no-op that looks like an edit in the record's own activity trail.
|
||||
*/
|
||||
export const updateManagedProfileSchema = z
|
||||
.object({
|
||||
displayName: managedProfileFields.displayName.optional(),
|
||||
dateOfBirth: managedProfileFields.dateOfBirth.optional(),
|
||||
locale: managedProfileFields.locale.optional(),
|
||||
timezone: managedProfileFields.timezone.optional(),
|
||||
gender: managedProfileFields.gender.optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(patch) => Object.values(patch).some((value) => value !== undefined),
|
||||
{ message: "Name at least one field to change" },
|
||||
);
|
||||
|
||||
/** The body `POST /api/managed-profiles/{id}/guardians` accepts. */
|
||||
export const inviteManagedProfileGuardianSchema = z
|
||||
.object({
|
||||
|
||||
Reference in new issue
Block a user