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:
Marc-André Bombeck committed 2026-08-07 01:53:26 +02:00
1 parent 466c9c906f
commit 110511ff31
14 files changed
+1188 -3

No files matched your search

+14 -3
View File
@@ -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";
}