Merge managed-profile editing, sex at creation and module switches per record

This commit is contained in:
Marc-André Bombeck committed 2026-09-10 04:19:35 +02:00
commit 06d2104808
36 files changed
+2795 -147

No files matched your search

@@ -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
+14 -5
View File
@@ -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>
);