mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +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
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user