mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
feat: create managed profiles from shared access
- add a managed-profile card to Settings -> Shared access, between the invitation card and the standing-state cards - collect exactly the four fields the create route accepts, with the date of birth genuinely optional and never derived from a year - refresh the account payload on success, so the new record reaches the switcher and the banner without a reload - assert the section is reachable for an owner and refused for a Guardian inside a profile and for an adult manager
This commit is contained in:
1 parent
466c9c906f
commit
110511ff31
14 files changed
+1188
-3
No files matched your search
@@ -3,6 +3,7 @@
|
||||
import { GrantInviteCard } from "@/components/settings/access/grant-invite-card";
|
||||
import { GrantsGivenCard } from "@/components/settings/access/grants-given-card";
|
||||
import { GrantsReceivedCard } from "@/components/settings/access/grants-received-card";
|
||||
import { ManagedProfileCard } from "@/components/settings/access/managed-profile-card";
|
||||
import { RecordActivityCard } from "@/components/settings/access/record-activity-card";
|
||||
|
||||
/**
|
||||
@@ -15,9 +16,18 @@ import { RecordActivityCard } from "@/components/settings/access/record-activity
|
||||
* somebody reads them:
|
||||
*
|
||||
* 1. Invite — the act.
|
||||
* 2. Who has access — the standing state, and the way to end it.
|
||||
* 3. What I can open — the same, from the other side.
|
||||
* 4. When it was opened — what actually happened.
|
||||
* 2. Records you look after — the other act, and the one that CREATES a
|
||||
* record rather than opening one that already exists.
|
||||
* 3. Who has access — the standing state, and the way to end it.
|
||||
* 4. What I can open — the same, from the other side.
|
||||
* 5. When it was opened — what actually happened.
|
||||
*
|
||||
* v1.37.0 put the managed-profile card second rather than in a settings area
|
||||
* of its own. A managed profile is a record with no login, reachable only
|
||||
* through the people who look after it — which is the same question this
|
||||
* section already answers twice, from a third side. Splitting it out would
|
||||
* have made "who may open which record" two half-answers on two pages, which
|
||||
* is the settings-IA rule below in miniature.
|
||||
*
|
||||
* Distinct from Settings → "Health record" → sharing links, which mints a
|
||||
* time-boxed read-only link to a REPORT for a clinician. This section is about
|
||||
@@ -35,6 +45,7 @@ export function AccessSection() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<GrantInviteCard />
|
||||
<ManagedProfileCard />
|
||||
<GrantsGivenCard />
|
||||
<GrantsReceivedCard />
|
||||
<RecordActivityCard />
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* The managed-profile surface, from a static render.
|
||||
*
|
||||
* ## What an SSR render can prove here, and what it cannot
|
||||
*
|
||||
* It can prove that a control exists, that the sentence which qualifies it
|
||||
* sits beside it in document order, that a list renders one row per profile,
|
||||
* and — because the section is composed rather than routed — that the whole
|
||||
* card is present for an owner and absent for a delegate. It CANNOT prove that
|
||||
* what the form posts reaches the server: there is no click here, so the
|
||||
* submit path never runs and every assertion below stays green with the form
|
||||
* wired to a constant. That half belongs to `tests/integration/
|
||||
* managed-profile-surface.test.ts` (the real routes) and to the browser
|
||||
* journey in `e2e/v137-sharing-managed-profiles.spec.ts`.
|
||||
*
|
||||
* The unit environment is `node`: no jsdom, no testing-library. The pure
|
||||
* exports (`displayNameIssue`, `createManagedProfileErrorKey`,
|
||||
* `managedProfilesOf`) are where the decisions live, and they are pinned
|
||||
* directly for that reason.
|
||||
*
|
||||
* ## The reachability legs are legs, not reasoning
|
||||
*
|
||||
* `/settings/access` is an ACTOR surface — grant management is the one thing a
|
||||
* delegate must have no reach into — and `access` classifies `personal`, so
|
||||
* the prediction is that a Guardian inside a profile and an adult MANAGE
|
||||
* delegate both get the refusal panel instead. That is asserted rather than
|
||||
* argued: `auth-shell.tsx` and `settings-shell.tsx` decide reachability above
|
||||
* every component in this file, and Plan 13 shipped a destination that was
|
||||
* admitted and unreachable because the prediction was not checked.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import type { AccountAccessEntry } from "@/lib/sharing/account-access-view";
|
||||
import type { GrantList } from "@/lib/queries/use-account-grants";
|
||||
|
||||
/** The account payload, as the cards under test read it. */
|
||||
const authRef: {
|
||||
value: {
|
||||
accounts: AccountAccessEntry[];
|
||||
active: AccountAccessEntry | null;
|
||||
};
|
||||
} = { value: { accounts: [], active: null } };
|
||||
|
||||
vi.mock("@/hooks/use-auth", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/hooks/use-auth")>();
|
||||
return {
|
||||
...actual,
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
id: "guardian",
|
||||
username: "guardian",
|
||||
timezone: "Europe/Berlin",
|
||||
accountAccess: {
|
||||
accounts: authRef.value.accounts,
|
||||
active: authRef.value.active,
|
||||
canSwitch: authRef.value.accounts.length > 0,
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
isAuthUnknown: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
function grantList(partial: Partial<GrantList> = {}): GrantList {
|
||||
return { given: [], received: [], retentionDays: 365, ...partial };
|
||||
}
|
||||
|
||||
vi.mock("@/lib/queries/use-account-grants", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/queries/use-account-grants")>();
|
||||
return {
|
||||
...actual,
|
||||
useAccountGrants: () => ({
|
||||
data: grantList(),
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useRecordActivity: () => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useInviteGrant: () => idle(),
|
||||
useAcceptGrant: () => idle(),
|
||||
useRevokeGrant: () => idle(),
|
||||
useRenounceGrant: () => idle(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/queries/use-managed-profiles", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/queries/use-managed-profiles")>();
|
||||
return { ...actual, useCreateManagedProfile: () => idle() };
|
||||
});
|
||||
|
||||
vi.mock("@/hooks/use-account-switch", () => ({
|
||||
useAccountSwitch: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
function idle() {
|
||||
return {
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
variables: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
import { AccessSection } from "@/components/settings/access-section";
|
||||
import { RecordSettingsSectionGate } from "@/components/settings/record-settings-section-gate";
|
||||
import {
|
||||
createManagedProfileErrorKey,
|
||||
displayNameIssue,
|
||||
} from "../managed-profile-create-form";
|
||||
import { managedProfilesOf } from "../managed-profile-card";
|
||||
|
||||
function render(node: React.ReactNode): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** The section as the settings page actually mounts it: behind the gate. */
|
||||
function renderGatedSection(): string {
|
||||
return render(
|
||||
<RecordSettingsSectionGate section="access">
|
||||
<AccessSection />
|
||||
</RecordSettingsSectionGate>,
|
||||
);
|
||||
}
|
||||
|
||||
function entry(partial: Partial<AccountAccessEntry> = {}): AccountAccessEntry {
|
||||
return {
|
||||
accountId: "p1",
|
||||
username: "managed-abc",
|
||||
displayName: "Managed record",
|
||||
access: "write",
|
||||
level: "manage",
|
||||
recordKind: "managed",
|
||||
sections: null,
|
||||
canWrite: true,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
authRef.value = { accounts: [], active: null };
|
||||
});
|
||||
|
||||
describe("the managed-profile card, in the shared-access section", () => {
|
||||
it("renders inside the section, after the invitation card", () => {
|
||||
const html = renderGatedSection();
|
||||
expect(html).toContain('data-slot="managed-profile-card"');
|
||||
// Reading order: the invitation is the first act, this is the second, and
|
||||
// the standing state of both follows. A card that drifted below the
|
||||
// activity feed would still contain every string this file asserts.
|
||||
expect(html.indexOf('data-slot="grant-invite-card"')).toBeLessThan(
|
||||
html.indexOf('data-slot="managed-profile-card"'),
|
||||
);
|
||||
expect(html.indexOf('data-slot="managed-profile-card"')).toBeLessThan(
|
||||
html.indexOf('data-slot="grants-given-card"'),
|
||||
);
|
||||
});
|
||||
|
||||
it("says the record has no login and that the creator looks after it", () => {
|
||||
const html = renderGatedSection();
|
||||
// The two facts somebody is consenting to. A rewrite that keeps the form
|
||||
// and drops either sentence should fail here.
|
||||
expect(html).toContain("It has no login and no e-mail address");
|
||||
expect(html).toContain("you become its first guardian");
|
||||
});
|
||||
|
||||
it("collects the four fields the route accepts and nothing else", () => {
|
||||
const html = renderGatedSection();
|
||||
for (const slot of [
|
||||
"managed-profile-name",
|
||||
"managed-profile-dob",
|
||||
"managed-profile-locale",
|
||||
"managed-profile-create-submit",
|
||||
]) {
|
||||
expect(html, slot).toContain(`data-slot="${slot}"`);
|
||||
}
|
||||
// The timezone control is the shared picker, which labels itself.
|
||||
expect(html).toContain('id="managed-profile-timezone"');
|
||||
// Six languages offered, which is the route's enum exactly.
|
||||
const options =
|
||||
/<select[^>]*data-slot="managed-profile-locale"[\s\S]*?<\/select>/
|
||||
.exec(html)?.[0]
|
||||
.match(/<option/g) ?? [];
|
||||
expect(options).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("lists the profiles this account looks after, and only those", () => {
|
||||
authRef.value = {
|
||||
accounts: [
|
||||
entry(),
|
||||
entry({
|
||||
accountId: "a1",
|
||||
username: "housemate",
|
||||
displayName: "Jo",
|
||||
recordKind: "shared",
|
||||
}),
|
||||
],
|
||||
active: null,
|
||||
};
|
||||
const html = renderGatedSection();
|
||||
expect(html).toContain('data-managed-profile-id="p1"');
|
||||
// An adult who shared their own record is not a profile anybody looks
|
||||
// after, and offering a delete control for one would be the same mistake
|
||||
// one level up.
|
||||
expect(html).not.toContain('data-managed-profile-id="a1"');
|
||||
expect(html.match(/data-slot="managed-profile-row"/g)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("who can reach the section at all", () => {
|
||||
it("renders for an unswitched owner", () => {
|
||||
const html = renderGatedSection();
|
||||
expect(html).toContain('data-slot="managed-profile-card"');
|
||||
expect(html).not.toContain("shared-record-settings-unavailable-title");
|
||||
});
|
||||
|
||||
it("does not render for a Guardian inside the profile they look after", () => {
|
||||
// Grant management is the one surface a delegate must have no reach into,
|
||||
// and a Guardian acting AS the profile is a delegate for this purpose.
|
||||
authRef.value = { accounts: [entry()], active: entry() };
|
||||
const html = renderGatedSection();
|
||||
expect(html).toContain("shared-record-settings-unavailable-title");
|
||||
expect(html).not.toContain('data-slot="managed-profile-card"');
|
||||
expect(html).not.toContain('data-slot="grant-invite-card"');
|
||||
});
|
||||
|
||||
it("does not render for an adult delegate holding MANAGE", () => {
|
||||
const adult = entry({
|
||||
accountId: "a1",
|
||||
username: "housemate",
|
||||
displayName: "Jo",
|
||||
recordKind: "shared",
|
||||
});
|
||||
authRef.value = { accounts: [adult], active: adult };
|
||||
const html = renderGatedSection();
|
||||
expect(html).toContain("shared-record-settings-unavailable-title");
|
||||
expect(html).not.toContain('data-slot="managed-profile-card"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the form decides before it sends", () => {
|
||||
it("treats a whitespace-only name as empty, because the route does", () => {
|
||||
// The route trims and then requires 1..80. A client that did not trim
|
||||
// would offer to send a name the server reads as absent.
|
||||
expect(displayNameIssue(" ")).toBe("recordSharing.managed.nameRequired");
|
||||
expect(displayNameIssue("")).toBe("recordSharing.managed.nameRequired");
|
||||
expect(displayNameIssue(" Managed record ")).toBeNull();
|
||||
});
|
||||
|
||||
it("holds the route's own 80-character bound", () => {
|
||||
expect(displayNameIssue("x".repeat(80))).toBeNull();
|
||||
expect(displayNameIssue("x".repeat(81))).toBe(
|
||||
"recordSharing.managed.nameTooLong",
|
||||
);
|
||||
// And trailing space does not push a valid name over the edge.
|
||||
expect(displayNameIssue(`${"x".repeat(80)} `)).toBeNull();
|
||||
});
|
||||
|
||||
it("routes the step-up gate to its own sentence, not to a failure", () => {
|
||||
// Every route in this family resolves `requireFreshMfa`, so for anybody
|
||||
// with a second factor this is the FIRST answer, not an error. Told as a
|
||||
// failure it sends somebody looking for a problem with what they typed.
|
||||
expect(
|
||||
createManagedProfileErrorKey(
|
||||
new ApiError("refused", 401, { errorCode: "auth.stepup.required" }),
|
||||
),
|
||||
).toBe("recordSharing.managed.errorStepUp");
|
||||
expect(createManagedProfileErrorKey(new ApiError("refused", 422))).toBe(
|
||||
"recordSharing.managed.errorInvalid",
|
||||
);
|
||||
expect(createManagedProfileErrorKey(new ApiError("refused", 500))).toBe(
|
||||
"recordSharing.managed.errorFailed",
|
||||
);
|
||||
// Not an `ApiError` means no response came back at all.
|
||||
expect(createManagedProfileErrorKey(new TypeError("Failed to fetch"))).toBe(
|
||||
"recordSharing.managed.errorOffline",
|
||||
);
|
||||
// The four are distinct, so a resolver returning one key for everything
|
||||
// fails here rather than passing each row above by accident.
|
||||
expect(
|
||||
new Set(
|
||||
[401, 422, 500].map((s) =>
|
||||
createManagedProfileErrorKey(new ApiError("refused", s)),
|
||||
),
|
||||
).size,
|
||||
).toBe(3);
|
||||
});
|
||||
|
||||
it("keeps a refused creation's fields, and clears them only on success", () => {
|
||||
// A static render cannot press the button, so the claim is made about the
|
||||
// code path: every reset lives inside `onSuccess`. A reset that migrated
|
||||
// into `onError` would wipe a typed name on a failure the person is being
|
||||
// asked to retry.
|
||||
const source = readForm();
|
||||
const start = source.indexOf("onSuccess:");
|
||||
const split = source.indexOf("onError:", start);
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
expect(split).toBeGreaterThan(start);
|
||||
const onSuccess = source.slice(start, split);
|
||||
const onError = source.slice(split);
|
||||
expect(onSuccess).toContain("setCreated");
|
||||
expect(onError).toContain("createManagedProfileErrorKey");
|
||||
for (const reset of [
|
||||
'setDisplayName("")',
|
||||
'setDateOfBirth("")',
|
||||
"setLocale(actorLocale)",
|
||||
"setTimezone(browserTimezone())",
|
||||
]) {
|
||||
expect(onSuccess, reset).toContain(reset);
|
||||
expect(onError, reset).not.toContain(reset);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("which accounts count as profiles looked after", () => {
|
||||
it("keeps the managed entries and drops the rest", () => {
|
||||
const managed = entry();
|
||||
const shared = entry({ accountId: "a1", recordKind: "shared" });
|
||||
expect(managedProfilesOf([managed, shared])).toEqual([managed]);
|
||||
expect(managedProfilesOf(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
function readForm(): string {
|
||||
return readFileSync(
|
||||
join(
|
||||
process.cwd(),
|
||||
"src/components/settings/access/managed-profile-create-form.tsx",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { UserRoundCog } from "lucide-react";
|
||||
|
||||
import { ManagedProfileCreateForm } from "@/components/settings/access/managed-profile-create-form";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
accountLabel,
|
||||
type AccountAccessEntry,
|
||||
} from "@/lib/sharing/account-access-view";
|
||||
|
||||
/**
|
||||
* v1.37.0 — the records this account looks after.
|
||||
*
|
||||
* ## Why the list is not a read of its own
|
||||
*
|
||||
* There is no `GET /api/managed-profiles`, and there does not need to be. A
|
||||
* Guardian's relationship to a managed profile IS a MANAGE grant with the
|
||||
* profile as grantor, so the profiles somebody looks after are already on
|
||||
* `GET /api/auth/me` as the `managed` entries of `accountAccess.accounts` —
|
||||
* the same list that paints the switcher. Reading them from anywhere else
|
||||
* would be a second answer to a question the account payload already answers,
|
||||
* and the two would disagree the first time a grant lapsed.
|
||||
*
|
||||
* It is also what makes the creation contract simple to state: the new profile
|
||||
* appears here, in the switcher and in the banner from ONE invalidation, and
|
||||
* if the account payload is not refreshed it appears in none of them.
|
||||
*
|
||||
* ## Why this card, in this section
|
||||
*
|
||||
* The section's docblock states the rule — one concept, one section — and this
|
||||
* is the same concept from the third side: who can open a record. Two of the
|
||||
* cards here are about another adult opening yours or you opening theirs; this
|
||||
* one is about a record that exists only because somebody looks after it. A
|
||||
* settings area of its own would split "who may open which record" across two
|
||||
* pages, which is the split that rule exists to prevent.
|
||||
*
|
||||
* It sits directly after the invitation card because both are acts, and this
|
||||
* one creates the record the other two then talk about.
|
||||
*/
|
||||
export function ManagedProfileCard() {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const profiles = managedProfilesOf(user?.accountAccess?.accounts);
|
||||
|
||||
return (
|
||||
<SettingsCard data-slot="managed-profile-card">
|
||||
<SettingsCardHeader
|
||||
icon={UserRoundCog}
|
||||
title={t("recordSharing.managed.title")}
|
||||
description={t("recordSharing.managed.description")}
|
||||
/>
|
||||
<div className="mt-4 space-y-4">
|
||||
{/* Content, not meta: this is what somebody is consenting to take on,
|
||||
and UI-STANDARDS §3 reserves muted for the incidental. */}
|
||||
<p data-slot="managed-profile-explainer" className="text-sm">
|
||||
{t("recordSharing.managed.explainer")}
|
||||
</p>
|
||||
|
||||
{profiles.length > 0 && (
|
||||
<ul data-slot="managed-profile-list" className="divide-y">
|
||||
{profiles.map((profile) => (
|
||||
<li
|
||||
key={profile.accountId}
|
||||
data-slot="managed-profile-row"
|
||||
data-managed-profile-id={profile.accountId}
|
||||
className="py-3 first:pt-0 last:pb-0"
|
||||
>
|
||||
{/* UI-STANDARDS §11 inline action row. */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* A person's name is user data and stays content. */}
|
||||
<p className="truncate text-sm font-medium">
|
||||
{accountLabel(profile)}
|
||||
</p>
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{t("recordSharing.lookingAfter.kindManaged")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<ManagedProfileCreateForm />
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The managed entries of the account payload, in the order the server sent
|
||||
* them.
|
||||
*
|
||||
* Exported and pure so the filter can be pinned: `recordKind` is server-
|
||||
* resolved presentation metadata, and a card that showed every shared record
|
||||
* here would offer a delete control for an adult's own account.
|
||||
*/
|
||||
export function managedProfilesOf(
|
||||
accounts: AccountAccessEntry[] | undefined,
|
||||
): AccountAccessEntry[] {
|
||||
return (accounts ?? []).filter((entry) => entry.recordKind === "managed");
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
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 { TimezonePicker } from "@/components/settings/timezone-picker";
|
||||
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 {
|
||||
DEFAULT_TIMEZONE,
|
||||
detectBrowserTimezone,
|
||||
isValidTimezone,
|
||||
} from "@/lib/tz/format";
|
||||
|
||||
/**
|
||||
* v1.37.0 — creating a record that has no login of its own.
|
||||
*
|
||||
* ## Why the form is exactly four 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.
|
||||
*
|
||||
* ## The date of birth is genuinely optional
|
||||
*
|
||||
* Not "optional but we will guess". A managed profile is frequently a child,
|
||||
* and a child's age changes what the product says about a reading — so the
|
||||
* temptation to synthesise a date from a birth year, or from an age, is real
|
||||
* and is refused here as it is in the route. An absent date of birth stays
|
||||
* absent, and everything downstream reads it as absent rather than as
|
||||
* "1 January of some year".
|
||||
*
|
||||
* ## Whose locale, and whose timezone
|
||||
*
|
||||
* The profile's locale is a property OF THE RECORD: it decides the language of
|
||||
* content generated for it. The chrome around this form stays in the actor's
|
||||
* language, which is a decision made elsewhere and is not re-litigated in the
|
||||
* copy here. The default is the actor's own language, because a record created
|
||||
* by a German speaker for a person in their household is overwhelmingly likely
|
||||
* to want German, and the control is right there for the case where it is not.
|
||||
*
|
||||
* The timezone defaults to the browser's, for the same reason and with less
|
||||
* ambiguity: the profile lives where the person creating it lives, until told
|
||||
* otherwise.
|
||||
*
|
||||
* ## The step-up is the expected first answer
|
||||
*
|
||||
* Every route in this family resolves `requireFreshMfa`. For anybody with a
|
||||
* second factor who has not proved it in the last few minutes, the FIRST
|
||||
* response to this form is a 401 carrying `auth.stepup.required` — not an
|
||||
* error, a gate. It gets its own sentence naming the next step, exactly as the
|
||||
* invitation form does, rather than the generic "could not be created", which
|
||||
* would send somebody looking for a problem with what they typed.
|
||||
*/
|
||||
export function ManagedProfileCreateForm() {
|
||||
const { t, locale: actorLocale } = useTranslations();
|
||||
const create = useCreateManagedProfile();
|
||||
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [dateOfBirth, setDateOfBirth] = useState("");
|
||||
const [locale, setLocale] = useState<Locale>(actorLocale);
|
||||
const [timezone, setTimezone] = useState(() => browserTimezone());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [created, setCreated] = useState<string | null>(null);
|
||||
|
||||
const trimmedName = displayName.trim();
|
||||
const nameIssue = displayNameIssue(displayName);
|
||||
const timezoneIssue = isValidTimezone(timezone)
|
||||
? null
|
||||
: "recordSharing.managed.timezoneInvalid";
|
||||
const blocked = nameIssue !== null || timezoneIssue !== null;
|
||||
|
||||
const submit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (blocked || create.isPending) return;
|
||||
setError(null);
|
||||
setCreated(null);
|
||||
create.mutate(
|
||||
{
|
||||
displayName: trimmedName,
|
||||
// Empty means absent. `null` is sent rather than the key being
|
||||
// omitted, so "this record has no date of birth" is something the
|
||||
// client says out loud.
|
||||
dateOfBirth: dateOfBirth.length > 0 ? dateOfBirth : null,
|
||||
locale,
|
||||
timezone,
|
||||
},
|
||||
{
|
||||
onSuccess: (profile) => {
|
||||
setDisplayName("");
|
||||
setDateOfBirth("");
|
||||
setLocale(actorLocale);
|
||||
setTimezone(browserTimezone());
|
||||
setCreated(profile.displayName);
|
||||
},
|
||||
// Nothing is cleared here, on purpose: a refused creation is one the
|
||||
// person is being asked to retry, and a form that emptied itself
|
||||
// would make them type it all again to find out whether the second
|
||||
// attempt fails the same way.
|
||||
onError: (err) => setError(t(createManagedProfileErrorKey(err))),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={submit}
|
||||
data-slot="managed-profile-create-form"
|
||||
className="space-y-3"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="managed-profile-name">
|
||||
{t("recordSharing.managed.nameLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="managed-profile-name"
|
||||
data-slot="managed-profile-name"
|
||||
value={displayName}
|
||||
onChange={(e) => {
|
||||
setDisplayName(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder={t("recordSharing.managed.namePlaceholder")}
|
||||
autoComplete="off"
|
||||
maxLength={80}
|
||||
aria-invalid={
|
||||
displayName.length > 0 && nameIssue !== null ? "true" : undefined
|
||||
}
|
||||
aria-describedby="managed-profile-name-hint"
|
||||
/>
|
||||
<p
|
||||
id="managed-profile-name-hint"
|
||||
data-slot="managed-profile-name-hint"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{displayName.length > 0 && nameIssue !== null
|
||||
? t(nameIssue)
|
||||
: t("recordSharing.managed.nameHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="managed-profile-dob">
|
||||
{t("recordSharing.managed.dobLabel")}
|
||||
</Label>
|
||||
<DateField
|
||||
id="managed-profile-dob"
|
||||
data-slot="managed-profile-dob"
|
||||
data-testid="managed-profile-dob-input"
|
||||
value={dateOfBirth}
|
||||
onChange={setDateOfBirth}
|
||||
aria-describedby="managed-profile-dob-hint"
|
||||
/>
|
||||
<p
|
||||
id="managed-profile-dob-hint"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("recordSharing.managed.dobHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="managed-profile-locale">
|
||||
{t("recordSharing.managed.localeLabel")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="managed-profile-locale"
|
||||
data-slot="managed-profile-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>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("recordSharing.managed.localeHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<TimezonePicker
|
||||
id="managed-profile-timezone"
|
||||
value={timezone}
|
||||
onChange={(next) => {
|
||||
setTimezone(next);
|
||||
setError(null);
|
||||
}}
|
||||
// The picker's own list comes from the engine, so the select branch
|
||||
// cannot produce an unknown zone. Its free-text fallback (older
|
||||
// engines without `Intl.supportedValuesOf`) can, and the submit button
|
||||
// is inert for one — so the hint slot says why rather than leaving a
|
||||
// dead button with no explanation beside it.
|
||||
hint={t(timezoneIssue ?? "recordSharing.managed.timezoneHint")}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
data-slot="managed-profile-create-submit"
|
||||
disabled={create.isPending || blocked}
|
||||
>
|
||||
{create.isPending
|
||||
? t("recordSharing.managed.creating")
|
||||
: t("recordSharing.managed.create")}
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
data-slot="managed-profile-create-error"
|
||||
className="text-destructive text-sm"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{created && (
|
||||
<p
|
||||
role="status"
|
||||
data-slot="managed-profile-created"
|
||||
className="text-sm"
|
||||
>
|
||||
{t("recordSharing.managed.created", { name: created })}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/** The browser's zone, or the app default when the engine will not say. */
|
||||
function browserTimezone(): string {
|
||||
const detected = detectBrowserTimezone();
|
||||
return isValidTimezone(detected) ? detected : DEFAULT_TIMEZONE;
|
||||
}
|
||||
|
||||
/**
|
||||
* The route's own bound on the display name, checked before sending.
|
||||
*
|
||||
* Exported and pure so the bound can be pinned without a click. The route
|
||||
* trims and then requires 1..80, so the client trims first too: a name of
|
||||
* three spaces is empty to the server and must read as empty here, rather
|
||||
* than as a valid entry that comes back refused.
|
||||
*/
|
||||
export function displayNameIssue(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) return "recordSharing.managed.nameRequired";
|
||||
if (trimmed.length > 80) return "recordSharing.managed.nameTooLong";
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The message for a refused creation.
|
||||
*
|
||||
* Four arms and each names a different next step. The step-up arm is the one
|
||||
* that matters: it is not a failure, it is the gate every route in this family
|
||||
* carries, and it is the answer most people will get the first time they use
|
||||
* this form in a session.
|
||||
*
|
||||
* A 422 gets its own sentence rather than the field-level list the server
|
||||
* actually sends. The multi-issue envelope rides `details.issues`, and the
|
||||
* client's fetch wrapper keeps only `meta` — so the issues do not reach the
|
||||
* browser at all. The form validates the same bounds the route does before it
|
||||
* sends anything, which makes a 422 a disagreement between the two rather than
|
||||
* something the person typed; saying so is more honest than inventing a
|
||||
* field-level message the server did not send. Recorded as a finding in
|
||||
* `02-13.1-CONTRACT-FINDINGS.md`.
|
||||
*/
|
||||
export function createManagedProfileErrorKey(err: unknown): string {
|
||||
if (!(err instanceof ApiError)) return "recordSharing.managed.errorOffline";
|
||||
// The step-up gate. `requireFreshMfa` throws `StepUpRequiredError`, which is
|
||||
// a 401 carrying a stable code, and the request was authenticated enough to
|
||||
// reach it — so this is never a lapsed session.
|
||||
if (err.status === 401) return "recordSharing.managed.errorStepUp";
|
||||
if (err.status === 422) return "recordSharing.managed.errorInvalid";
|
||||
return "recordSharing.managed.errorFailed";
|
||||
}
|
||||
Reference in new issue
Block a user