Merge managed profile handover into release/v1.42.0

# Conflicts:
#	docs/api/openapi.yaml
#	src/__tests__/i18n-reverse-coverage.test.ts
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 11:21:25 +02:00
commit 920bd19617
59 files changed
+7147 -694

No files matched your search

+2
View File
@@ -329,6 +329,8 @@ export function useAuthActionLabels(): Record<string, string> {
return useMemo(
() => ({
"auth.register": t("admin.authRegister"),
// v1.42 (#959) — a managed profile taken over by its person.
"auth.claim": t("admin.authClaim"),
"auth.login": t("admin.authLogin"),
"auth.login.passkey": t("admin.authLoginPasskey"),
"auth.login.password": t("admin.authLoginPassword"),
+382
View File
@@ -0,0 +1,382 @@
"use client";
import { useId, useState } from "react";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { KeyRound, Loader2, LogOut } from "lucide-react";
import { accessLabel } from "@/components/settings/access/managed-profile-handover";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Logo } from "@/components/ui/logo";
import { PasswordStrength } from "@/components/ui/password-strength";
import { clearCachesForSessionEnd, useAuth } from "@/hooks/use-auth";
import { ApiError, apiFetchRaw } from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import {
useClaimProfile,
useProfileClaimPreview,
} from "@/lib/queries/use-profile-claim";
import { queryKeys } from "@/lib/query-keys";
/**
* v1.42 (#959) — the person a managed profile describes takes it over as
* their own account (`/auth/claim?token=hlp_…`).
*
* The page hands the token in from `?token=`; this sends it only in the BODY
* of the two anonymous requests behind it — the preview, then the claim —
* never in another URL, a query key or storage.
*
* What the person sees before choosing a sign-in is deliberately small: their
* record's name, who keeps which access for now, and until when the link
* holds. No health data. The decision about each Guardian comes right after
* the first sign-in, on the screen the claim lands on.
*/
export function ClaimProfile({ token }: { token: string | null }) {
const router = useRouter();
const queryClient = useQueryClient();
const { t } = useTranslations();
const fmt = useFormatters();
// A resolved account payload is the only positive evidence of a session;
// see the registration page for why `isAuthenticated` is not used here.
const { user } = useAuth();
const preview = useProfileClaimPreview(token, !user);
const claim = useClaimProfile();
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [signingOut, setSigningOut] = useState(false);
const errorId = useId();
const previewStatus =
preview.error instanceof ApiError ? preview.error.status : null;
const signedIn = Boolean(user) || previewStatus === 409;
const usernameReserved = username.toLowerCase().startsWith("managed-");
async function signOut() {
setSigningOut(true);
try {
await apiFetchRaw("/api/auth/logout", { method: "POST" });
clearCachesForSessionEnd(queryClient);
// Back to this page with the link intact: the token only rides the URL.
router.replace(
token
? `/auth/claim?token=${encodeURIComponent(token)}`
: "/auth/claim",
);
router.refresh();
} finally {
setSigningOut(false);
}
}
function submit(event: React.FormEvent) {
event.preventDefault();
if (!token || claim.isPending || usernameReserved) return;
claim.mutate(
{ token, username, email, password },
{
onSuccess: async () => {
queryClient.removeQueries({
queryKey: queryKeys.profileClaimPreview(),
});
await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
router.push("/onboarding/handover");
},
},
);
}
if (signedIn) {
return (
<ClaimCard testId="claim-signed-in">
<Header
title={t("auth.claim.signedInTitle")}
description={t("auth.claim.signedInBody", {
username: user?.username ?? "",
})}
/>
<Button
type="button"
size="lg"
className="mt-8 min-h-11 w-full"
onClick={() => void signOut()}
disabled={signingOut}
data-slot="claim-sign-out"
>
{signingOut ? (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
) : (
<LogOut className="size-4" />
)}
{t("auth.claim.signOut")}
</Button>
</ClaimCard>
);
}
if (!token || previewStatus === 404 || previewStatus === 422) {
return (
<ClaimCard testId="claim-invalid">
<Header
title={t("auth.claim.invalid")}
description={t("auth.claim.invalidHint")}
/>
</ClaimCard>
);
}
if (previewStatus === 403) {
return (
<ClaimCard testId="claim-unavailable">
<Header
title={t("auth.claim.title")}
description={t("auth.claim.oidcOnly")}
/>
</ClaimCard>
);
}
if (preview.isError) {
return (
<ClaimCard testId="claim-error">
<Header
title={t("auth.claim.title")}
description={t(
previewStatus === 429
? "auth.claim.errorRateLimit"
: "auth.claim.errorFailed",
)}
/>
<Button
type="button"
variant="outline"
size="lg"
className="mt-8 min-h-11 w-full"
onClick={() => void preview.refetch()}
>
{t("common.retry")}
</Button>
</ClaimCard>
);
}
if (!preview.data) {
return (
<ClaimCard testId="claim-loading">
<p
role="status"
className="text-muted-foreground flex items-center justify-center gap-2 text-sm"
>
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
{t("auth.claim.loading")}
</p>
</ClaimCard>
);
}
const data = preview.data;
const errorKey = claim.isError ? claimErrorKey(claim.error) : null;
const errorText =
claim.error instanceof ApiError &&
claim.error.status === 422 &&
claim.error.message
? claim.error.message
: errorKey
? t(errorKey)
: null;
return (
<ClaimCard testId="claim-form-card">
<Header
title={
data.displayName
? t("auth.claim.titleNamed", { name: data.displayName })
: t("auth.claim.title")
}
description={t("auth.claim.description")}
/>
<section className="mt-6 space-y-2" data-slot="claim-guardians">
<h2 className="text-sm font-medium">
{t("auth.claim.guardiansTitle")}
</h2>
{data.guardians.length > 0 ? (
<ul className="divide-y rounded-lg border">
{data.guardians.map((guardian) => (
<li
key={guardian.grantId}
data-slot="claim-guardian"
data-proposal={guardian.proposal}
className="flex items-center justify-between gap-3 px-3 py-2"
>
<span className="min-w-0 truncate text-sm">
{guardian.displayName}
</span>
<span className="text-muted-foreground shrink-0 text-xs">
{accessLabel(t, guardian.proposal)}
</span>
</li>
))}
</ul>
) : null}
<p className="text-muted-foreground text-xs">
{t("auth.claim.guardiansHint")}
</p>
</section>
<form onSubmit={submit} className="mt-6 space-y-4" data-slot="claim-form">
<div className="space-y-2">
<Label htmlFor="claim-username">{t("auth.username")}</Label>
<Input
id="claim-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
minLength={3}
maxLength={30}
autoComplete="username"
autoCapitalize="none"
spellCheck={false}
aria-invalid={usernameReserved || undefined}
aria-describedby={`${errorId}-username`}
/>
<p
id={`${errorId}-username`}
className={
usernameReserved
? "text-destructive text-xs"
: "text-muted-foreground text-xs"
}
>
{usernameReserved
? t("auth.claim.usernameReserved")
: t("auth.claim.usernameHint")}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="claim-email">{t("auth.email")}</Label>
<Input
id="claim-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
autoCapitalize="none"
spellCheck={false}
aria-describedby={`${errorId}-email`}
/>
<p id={`${errorId}-email`} className="text-muted-foreground text-xs">
{t("auth.claim.emailHint")}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="claim-password">{t("auth.password")}</Label>
<Input
id="claim-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="new-password"
placeholder={t("auth.passwordMinLength")}
/>
<PasswordStrength password={password} />
</div>
<p className="text-sm">{t("auth.claim.consentNote")}</p>
<p className="text-muted-foreground text-xs">
{t("auth.claim.expires", {
date: fmt.dateTime(new Date(data.expiresAt)),
})}
</p>
{errorText && (
<div
id={errorId}
role="alert"
aria-live="polite"
data-slot="claim-error"
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
>
{errorText}
</div>
)}
<Button
type="submit"
size="lg"
className="min-h-11 w-full"
disabled={claim.isPending || usernameReserved}
data-slot="claim-submit"
>
{claim.isPending ? (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
) : (
<KeyRound className="size-4" />
)}
{claim.isPending
? t("auth.claim.submitting")
: t("auth.claim.submit")}
</Button>
</form>
</ClaimCard>
);
}
function ClaimCard({
testId,
children,
}: {
testId: string;
children: React.ReactNode;
}) {
return (
<div className="w-full max-w-md py-8">
<div
className="border-border bg-card rounded-xl border p-4 shadow-lg shadow-black/20 md:p-6"
data-testid={testId}
>
{children}
</div>
</div>
);
}
function Header({
title,
description,
}: {
title: string;
description: string;
}) {
return (
<div className="flex flex-col items-center gap-3 text-center">
<div className="bg-primary/10 flex size-12 items-center justify-center rounded-lg">
<Logo className="text-primary" size={28} />
</div>
<h1 className="text-xl font-bold tracking-tight">{title}</h1>
<p className="text-muted-foreground text-sm">{description}</p>
</div>
);
}
/** The message for a refused claim. Exported and pure so it can be pinned. */
export function claimErrorKey(err: unknown): string {
if (!(err instanceof ApiError)) return "auth.claim.errorOffline";
if (err.status === 404) return "auth.claim.invalid";
if (err.status === 409) {
return err.meta?.errorCode === "auth.already_authenticated"
? "auth.claim.signedInTitle"
: "auth.claim.errorTaken";
}
if (err.status === 403) return "auth.claim.oidcOnly";
if (err.status === 429) return "auth.claim.errorRateLimit";
return "auth.claim.errorFailed";
}
@@ -136,6 +136,7 @@ describe("AuthShell public paths agree with proxy.ts", () => {
it("has page prefixes to check at all", () => {
expect(PAGE_PREFIXES.length).toBeGreaterThan(0);
expect(PAGE_PREFIXES).toContain("/invite/");
expect(PAGE_PREFIXES).toContain("/claim/");
});
it.each(PAGE_PREFIXES)(
@@ -152,6 +153,14 @@ describe("AuthShell public paths agree with proxy.ts", () => {
expect(markup).toContain('data-slot="sentinel-page"');
});
it("renders the handover landing and the claim page for a real token", () => {
// v1.42 (#959) — the managed-profile handover link and the page the edge
// redirects it onto.
const token = `hlp_${"b".repeat(64)}`;
expect(render(`/claim/${token}`)).toContain('data-slot="sentinel-page"');
expect(render("/auth/claim")).toContain('data-slot="sentinel-page"');
});
it("still gates a protected route", () => {
// Negative check — confirms the fixture would catch a real regression.
const markup = render("/insights");
@@ -1,6 +1,7 @@
"use client";
import { GrantInviteCard } from "@/components/settings/access/grant-invite-card";
import { HandoverDecisionCard } from "@/components/settings/access/handover-decision";
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";
@@ -44,6 +45,10 @@ import { RecordActivityCard } from "@/components/settings/access/record-activity
export function AccessSection() {
return (
<div className="space-y-4">
{/* v1.42 (#959) — only for an account that claimed a managed profile
and has not decided its former Guardians' access yet; it comes first
because it is the one open question on this page. */}
<HandoverDecisionCard />
<GrantInviteCard />
<ManagedProfileCard />
<GrantsGivenCard />
@@ -141,6 +141,13 @@ vi.mock("@/lib/queries/use-managed-profiles", async (importOriginal) => {
};
});
// v1.42 (#959) — the handover decision card at the top of the section. It
// renders nothing without a pending decision, which is every case here.
vi.mock("@/lib/queries/use-handover-decision", () => ({
useHandoverDecision: () => ({ data: { pending: null } }),
useDecideHandover: () => idle(),
}));
vi.mock("@/hooks/use-account-switch", () => ({
useAccountSwitch: () => ({ mutate: vi.fn(), isPending: false }),
}));
@@ -520,6 +527,24 @@ describe("ending a managed profile", () => {
);
});
// v1.42 (#959) — the handover, offered beside edit and delete.
it("offers the handover on the profile's own row once the roster answered", () => {
authRef.value = { accounts: [entry()], active: null };
const html = renderGatedSection();
expect(html).toMatch(
/data-slot="managed-profile-handover-toggle"[^>]*class="[^"]*min-h-11/,
);
// Closed until somebody opens it: the panel reads its own status then.
expect(html).not.toContain('data-slot="managed-profile-handover"');
});
it("withholds the handover until the roster has answered", () => {
authRef.value = { accounts: [entry()], active: null };
rosterRef.value = "loading";
const html = renderGatedSection();
expect(html).not.toContain('data-slot="managed-profile-handover-toggle"');
});
it("withholds the control until the roster has answered", () => {
// The confirm copy states how many people lose the record, and that number
// comes from the roster. A control that existed before the read would put
@@ -0,0 +1,61 @@
/**
* v1.42 (#959) — the sentences a refused handover mint and a refused claim
* map to. Pure, so each arm is pinned without driving a browser.
*/
import { describe, expect, it } from "vitest";
import { handoverCreateErrorKey } from "@/components/settings/access/managed-profile-handover";
import { claimErrorKey } from "@/components/auth/claim-profile";
import { ApiError } from "@/lib/api/api-fetch";
describe("handoverCreateErrorKey", () => {
it.each([
[
new ApiError("x", 401, { errorCode: "auth.stepup.required" }),
"recordSharing.managed.errorStepUp",
],
[
new ApiError("x", 401, { errorCode: "auth.stepup.mfa_not_enrolled" }),
"recordSharing.managed.errorMfaRequired",
],
[
new ApiError("x", 403, {
errorCode: "profile_claim.oidc_only_unsupported",
}),
"recordSharing.managed.handover.oidcOnly",
],
[new ApiError("x", 404, {}), "recordSharing.actionError.profileNotFound"],
[
new ApiError("x", 422, {
errorCode: "managed_profile.handover.unknown_guardian",
}),
"recordSharing.managed.handover.errorRosterChanged",
],
[new ApiError("x", 422, {}), "recordSharing.managed.errorInvalid"],
[new ApiError("x", 429, {}), "recordSharing.managed.errorRateLimit"],
[new ApiError("x", 500, {}), "recordSharing.managed.handover.errorFailed"],
[new Error("offline"), "recordSharing.managed.errorOffline"],
])("maps %o", (err, key) => {
expect(handoverCreateErrorKey(err)).toBe(key);
});
});
describe("claimErrorKey", () => {
it.each([
[new ApiError("x", 404, {}), "auth.claim.invalid"],
[
new ApiError("x", 409, { errorCode: "profile_claim.taken" }),
"auth.claim.errorTaken",
],
[
new ApiError("x", 409, { errorCode: "auth.already_authenticated" }),
"auth.claim.signedInTitle",
],
[new ApiError("x", 403, {}), "auth.claim.oidcOnly"],
[new ApiError("x", 429, {}), "auth.claim.errorRateLimit"],
[new ApiError("x", 500, {}), "auth.claim.errorFailed"],
[new Error("offline"), "auth.claim.errorOffline"],
])("maps %o", (err, key) => {
expect(claimErrorKey(err)).toBe(key);
});
});
@@ -0,0 +1,227 @@
"use client";
import { useState } from "react";
import { BellRing, UserRoundCheck } from "lucide-react";
import Link from "next/link";
import { accessLabel } from "@/components/settings/access/managed-profile-handover";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { ApiError } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import {
OWNER_CHOOSABLE_HANDOVER_ACCESS,
type HandoverAccess,
} from "@/lib/managed-profiles/handover-access";
import {
useDecideHandover,
useHandoverDecision,
type HandoverDecisionState,
} from "@/lib/queries/use-handover-decision";
type PendingDecision = NonNullable<HandoverDecisionState["pending"]>;
/**
* v1.42 (#959) — the new owner's decision about each former Guardian.
*
* The claim already applied what the issuing Guardian proposed; this is where
* the person whose record it is sees that and has the final word. Each row is
* a Guardian, preselected at the access they hold now. A row whose access
* moved since the claim (the Guardian stepped away, or the owner already
* changed it under Shared access) is shown and left alone.
*
* Rendered in two places from one form: the screen the claim lands on, and a
* card at the top of Settings → Shared access that stays until the decision
* is made.
*/
export function HandoverDecisionForm({
pending,
onDecided,
secondary,
}: {
pending: PendingDecision;
onDecided?: () => void;
/** An extra action before the primary one (the setup screen's "later"). */
secondary?: React.ReactNode;
}) {
const { t } = useTranslations();
const decide = useDecideHandover();
const [choices, setChoices] = useState<Record<string, HandoverAccess>>({});
const submit = (event: React.FormEvent) => {
event.preventDefault();
if (decide.isPending) return;
decide.mutate(
pending.guardians
.filter((g) => g.decidable)
.map((g) => ({
grantId: g.grantId,
access: choices[g.grantId] ?? g.current,
})),
{ onSuccess: () => onDecided?.() },
);
};
return (
<form
onSubmit={submit}
data-slot="handover-decision-form"
className="space-y-4"
>
{pending.guardians.length === 0 ? (
<p className="text-sm">{t("recordSharing.handoverDecision.empty")}</p>
) : (
<ul className="divide-y" data-slot="handover-decision-list">
{pending.guardians.map((guardian) => {
const id = `handover-decision-${guardian.grantId}`;
return (
<li
key={guardian.grantId}
data-slot="handover-decision-guardian"
data-guardian-grant-id={guardian.grantId}
data-decidable={guardian.decidable ? "true" : "false"}
className="flex items-center justify-between gap-3 py-3 first:pt-0 last:pb-0"
>
<div className="min-w-0 flex-1">
<Label
noColon
htmlFor={guardian.decidable ? id : undefined}
className="block truncate pl-0 text-sm font-medium"
>
{guardian.displayName}
</Label>
<p className="text-muted-foreground truncate text-xs">
{guardian.decidable
? t("recordSharing.handoverDecision.proposed", {
access: accessLabel(t, guardian.proposal),
})
: t("recordSharing.handoverDecision.settled")}
</p>
</div>
{guardian.decidable ? (
<NativeSelect
id={id}
data-slot="handover-decision-choice"
className="w-40 shrink-0"
value={choices[guardian.grantId] ?? guardian.current}
onChange={(e) =>
setChoices((current) => ({
...current,
[guardian.grantId]: e.target.value as HandoverAccess,
}))
}
>
{OWNER_CHOOSABLE_HANDOVER_ACCESS.map((level) => (
<option key={level} value={level}>
{accessLabel(t, level)}
</option>
))}
</NativeSelect>
) : (
<p className="shrink-0 text-sm">
{accessLabel(t, guardian.current)}
</p>
)}
</li>
);
})}
</ul>
)}
<p className="text-sm">{t("recordSharing.handoverDecision.later")}</p>
{decide.isError && (
<p
role="alert"
data-slot="handover-decision-error"
className="text-destructive text-sm"
>
{t(
decide.error instanceof ApiError
? "recordSharing.handoverDecision.errorFailed"
: "recordSharing.managed.errorOffline",
)}
</p>
)}
<SettingsCardActions>
{secondary}
<Button
type="submit"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="handover-decision-submit"
disabled={decide.isPending}
>
{decide.isPending
? t("recordSharing.handoverDecision.saving")
: t("recordSharing.handoverDecision.save")}
</Button>
</SettingsCardActions>
</form>
);
}
/**
* The one-time pointer to the new owner's own notification channels.
*
* Reminders for a managed profile went to its Guardians. The claim ends that
* fan-out, and the record has no channel of its own yet, so without this a
* medication reminder would quietly stop reaching anybody.
*/
export function HandoverNotificationHint() {
const { t } = useTranslations();
return (
<div
data-slot="handover-notification-hint"
className="flex items-start gap-3 rounded-lg border p-3"
>
<BellRing
className="text-muted-foreground mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<div className="min-w-0 flex-1 space-y-2">
<p className="text-sm font-medium">
{t("recordSharing.handoverDecision.notificationsTitle")}
</p>
<p className="text-sm">
{t("recordSharing.handoverDecision.notificationsBody")}
</p>
<Link
href="/settings/notifications"
className="text-primary text-sm underline"
data-slot="handover-notification-link"
>
{t("recordSharing.handoverDecision.notificationsAction")}
</Link>
</div>
</div>
);
}
/**
* Settings → Shared access: the decision, until it is made. Renders nothing
* for every account that has none waiting, which is nearly all of them.
*/
export function HandoverDecisionCard() {
const { t } = useTranslations();
const decision = useHandoverDecision();
const pending = decision.data?.pending;
if (!pending) return null;
return (
<SettingsCard data-slot="handover-decision-card">
<SettingsCardHeader
icon={UserRoundCheck}
title={t("recordSharing.handoverDecision.title")}
description={t("recordSharing.handoverDecision.description")}
/>
<HandoverNotificationHint />
<HandoverDecisionForm pending={pending} />
</SettingsCard>
);
}
@@ -10,6 +10,7 @@ import {
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 { ManagedProfileHandover } from "@/components/settings/access/managed-profile-handover";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Button } from "@/components/ui/button";
@@ -134,6 +135,9 @@ function ManagedProfileRow({
// 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);
// v1.42 (#959) — the handover panel, per row for the same reason: it reads
// the row's own handover status when it opens, and only then.
const [handingOver, setHandingOver] = useState(false);
return (
<li
@@ -222,6 +226,48 @@ function ManagedProfileRow({
roster={guardians.data}
/>
)}
{/* v1.42 (#959) — the handover, last in the row and on a line of its
own: it is the weightiest and rarest act on a profile, and the row's
action strip beside the name has no room for a third control at
390 px. Withheld until the roster answers, like the deletion: the
panel proposes an access level per Guardian and has nothing to
propose for before the roster is in hand. */}
{guardians.data && (
<div
data-slot="managed-profile-handover-entry"
className="space-y-3 border-t pt-3"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<h3 className="text-sm font-medium">
{t("recordSharing.managed.handover.title", { name })}
</h3>
<p className="text-muted-foreground text-xs">
{t("recordSharing.managed.handover.entryHint", { name })}
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 shrink-0 sm:min-h-9"
data-slot="managed-profile-handover-toggle"
aria-expanded={handingOver}
onClick={() => setHandingOver((open) => !open)}
>
{t("recordSharing.managed.handover.action")}
</Button>
</div>
{handingOver && (
<ManagedProfileHandover
profileId={profile.accountId}
profileName={name}
roster={guardians.data}
onClose={() => setHandingOver(false)}
/>
)}
</div>
)}
</li>
);
}
@@ -0,0 +1,513 @@
"use client";
import { useState } from "react";
import { Check, Copy, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { stepUpErrorKey } from "@/components/settings/access/grant-action-error";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Button } from "@/components/ui/button";
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 { useAuth } from "@/hooks/use-auth";
import { ApiError } from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import {
DEFAULT_HANDOVER_ACCESS,
PROPOSABLE_HANDOVER_ACCESS,
isHandoverMinor,
type HandoverAccess,
} from "@/lib/managed-profiles/handover-access";
import {
useCreateManagedProfileHandover,
useManagedProfile,
useManagedProfileHandover,
useRevokeManagedProfileHandover,
type ManagedProfileGuardian,
} from "@/lib/queries/use-managed-profiles";
import { accountLabel } from "@/lib/sharing/account-access-view";
/**
* v1.42 (#959) — handing a managed profile over to the person it describes,
* from the Guardian's side.
*
* Three states, one panel:
*
* 1. **Propose.** How long the link lives and, per active Guardian, what
* their access becomes. The irreversibility is said here, before the
* link exists, because it is the last moment the Guardian decides
* anything: once the person claims the profile, it is theirs.
* 2. **The link, once.** Shown in the response that minted it and never
* again (only its hash is stored): the URL, a copy button and a QR code
* to scan from the Guardian's screen. Nothing is mailed; the Guardian
* hands it over in person or through a channel they trust.
* 3. **Open.** Afterwards the panel shows that a link is open and until
* when, with the way to withdraw it. Making a new link withdraws the old.
*
* The rules of which levels exist and which one is the default live in
* `handover-access.ts`; this panel only renders them.
*/
export function ManagedProfileHandover({
profileId,
profileName,
roster,
onClose,
}: {
profileId: string;
profileName: string;
/** The resolved roster; the row withholds this panel until it arrives. */
roster: ManagedProfileGuardian[];
onClose: () => void;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const status = useManagedProfileHandover(profileId);
const create = useCreateManagedProfileHandover();
const revoke = useRevokeManagedProfileHandover();
const [replacing, setReplacing] = useState(false);
const minted =
create.data && create.variables?.profileId === profileId
? create.data
: null;
if (status.isError) {
return (
<QueryErrorCard
title={t("recordSharing.managed.handover.loadError")}
onRetry={() => void status.refetch()}
/>
);
}
if (!status.data) {
return (
<p
role="status"
className="text-muted-foreground flex items-center gap-2 text-sm"
>
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
{t("nav.loadingScreen")}
</p>
);
}
return (
<section
data-slot="managed-profile-handover"
aria-label={t("recordSharing.managed.handover.title", {
name: profileName,
})}
className="space-y-4 rounded-lg border p-3"
>
<p className="text-sm">
{t("recordSharing.managed.handover.description", {
name: profileName,
})}
</p>
{!status.data.available ? (
<>
<p
data-slot="managed-profile-handover-unavailable"
className="text-sm"
>
{t("recordSharing.managed.handover.oidcOnly")}
</p>
<SettingsCardActions>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={onClose}
>
{t("common.close")}
</Button>
</SettingsCardActions>
</>
) : minted ? (
<HandoverLink
link={minted}
profileName={profileName}
expiresLabel={fmt.dateTime(new Date(minted.expiresAt))}
onDone={() => {
create.reset();
setReplacing(false);
onClose();
}}
/>
) : status.data.open && !replacing ? (
<div data-slot="managed-profile-handover-open" className="space-y-3">
<p className="text-sm">
{t("recordSharing.managed.handover.pending", {
date: fmt.dateTime(new Date(status.data.open.expiresAt)),
})}
</p>
{revoke.isError && revoke.variables === profileId && (
<p role="alert" className="text-destructive text-sm">
{t("recordSharing.managed.handover.revokeFailed")}
</p>
)}
<SettingsCardActions>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="managed-profile-handover-revoke"
disabled={revoke.isPending}
onClick={() =>
revoke.mutate(profileId, {
onSuccess: () =>
toastWrittenOutcome(
"success",
t("recordSharing.managed.handover.revoked"),
),
})
}
>
{t("recordSharing.managed.handover.revoke")}
</Button>
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="managed-profile-handover-replace"
onClick={() => setReplacing(true)}
>
{t("recordSharing.managed.handover.replace")}
</Button>
</SettingsCardActions>
</div>
) : (
<HandoverProposalForm
profileId={profileId}
profileName={profileName}
roster={roster}
replacing={status.data.open !== null}
create={create}
onCancel={() => {
setReplacing(false);
if (!status.data?.open) onClose();
}}
/>
)}
</section>
);
}
function HandoverProposalForm({
profileId,
profileName,
roster,
replacing,
create,
onCancel,
}: {
profileId: string;
profileName: string;
roster: ManagedProfileGuardian[];
replacing: boolean;
create: ReturnType<typeof useCreateManagedProfileHandover>;
onCancel: () => void;
}) {
const { t } = useTranslations();
const { user } = useAuth();
const profile = useManagedProfile(profileId);
const [expiresInDays, setExpiresInDays] = useState<1 | 7 | 14>(7);
const [proposals, setProposals] = useState<Record<string, HandoverAccess>>(
{},
);
const active = roster.filter((g) => g.state === "ACTIVE");
const minor = isHandoverMinor(profile.data?.dateOfBirth ?? null);
const submit = (event: React.FormEvent) => {
event.preventDefault();
if (create.isPending) return;
create.mutate({
profileId,
expiresInDays,
proposals: active.map((g) => ({
grantId: g.grantId,
proposal: proposals[g.grantId] ?? DEFAULT_HANDOVER_ACCESS,
})),
});
};
return (
<form
onSubmit={submit}
data-slot="managed-profile-handover-form"
className="space-y-4"
>
<fieldset className="space-y-2">
<legend className="text-sm font-medium">
{t("recordSharing.managed.handover.guardiansLabel")}
</legend>
<p className="text-muted-foreground text-xs">
{t("recordSharing.managed.handover.guardiansHint", {
name: profileName,
})}
</p>
<ul className="divide-y">
{active.map((guardian) => {
const id = `handover-proposal-${profileId}-${guardian.grantId}`;
const isSelf = guardian.account.id === user?.id;
return (
<li
key={guardian.grantId}
data-slot="managed-profile-handover-guardian"
data-guardian-grant-id={guardian.grantId}
className="flex items-center justify-between gap-3 py-2 first:pt-0 last:pb-0"
>
<Label
noColon
htmlFor={id}
className="block min-w-0 flex-1 truncate pl-0 text-sm font-normal"
>
{accountLabel(guardian.account)}
{isSelf ? ` ${t("recordSharing.managed.guardianYou")}` : ""}
</Label>
<NativeSelect
id={id}
data-slot="managed-profile-handover-proposal"
className="w-40 shrink-0"
value={proposals[guardian.grantId] ?? DEFAULT_HANDOVER_ACCESS}
onChange={(e) =>
setProposals((current) => ({
...current,
[guardian.grantId]: e.target.value as HandoverAccess,
}))
}
>
{PROPOSABLE_HANDOVER_ACCESS.map((level) => (
<option key={level} value={level}>
{accessLabel(t, level)}
</option>
))}
</NativeSelect>
</li>
);
})}
</ul>
</fieldset>
<div className="space-y-1.5">
<Label htmlFor={`handover-expiry-${profileId}`}>
{t("recordSharing.managed.handover.expiryLabel")}
</Label>
<NativeSelect
id={`handover-expiry-${profileId}`}
data-slot="managed-profile-handover-expiry"
className="w-full sm:w-48"
value={String(expiresInDays)}
onChange={(e) =>
setExpiresInDays(Number(e.target.value) as 1 | 7 | 14)
}
>
<option value="1">
{t("recordSharing.managed.handover.expiry1")}
</option>
<option value="7">
{t("recordSharing.managed.handover.expiry7")}
</option>
<option value="14">
{t("recordSharing.managed.handover.expiry14")}
</option>
</NativeSelect>
</div>
{minor && (
<p
data-slot="managed-profile-handover-minor"
className="bg-info/10 text-info rounded-md p-3 text-sm"
>
{t("recordSharing.managed.handover.minorHint", { name: profileName })}
</p>
)}
<div className="space-y-2 text-sm">
<p data-slot="managed-profile-handover-irreversible">
{t("recordSharing.managed.handover.irreversible", {
name: profileName,
})}
</p>
<p>
{t("recordSharing.managed.handover.afterwards", {
name: profileName,
})}
</p>
{replacing && <p>{t("recordSharing.managed.handover.replaceNote")}</p>}
</div>
{create.isError && (
<p
role="alert"
data-slot="managed-profile-handover-error"
className="text-destructive text-sm"
>
{t(handoverCreateErrorKey(create.error))}
</p>
)}
<SettingsCardActions>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={onCancel}
>
{t("common.cancel")}
</Button>
<Button
type="submit"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="managed-profile-handover-create"
disabled={create.isPending}
>
{create.isPending
? t("recordSharing.managed.handover.creating")
: t("recordSharing.managed.handover.create")}
</Button>
</SettingsCardActions>
</form>
);
}
function HandoverLink({
link,
profileName,
expiresLabel,
onDone,
}: {
link: { url: string; qrDataUrl: string };
profileName: string;
expiresLabel: string;
onDone: () => void;
}) {
const { t } = useTranslations();
const [copied, setCopied] = useState(false);
async function copy() {
try {
await navigator.clipboard.writeText(link.url);
setCopied(true);
toastWrittenOutcome(
"success",
t("recordSharing.managed.handover.copied"),
);
window.setTimeout(() => setCopied(false), 2_000);
} catch {
toast.error(t("recordSharing.managed.handover.copyError"));
}
}
return (
<div
data-slot="managed-profile-handover-link"
data-handover-url={link.url}
className="space-y-4"
>
<p className="text-sm">
{t("recordSharing.managed.handover.linkOnce", { name: profileName })}
</p>
<div className="flex flex-col items-center gap-4 sm:flex-row sm:items-start">
{/* The QR quiet zone stays white in both themes: a scanner needs the
contrast, and UI-STANDARDS §4 names this exemption. */}
{/* eslint-disable-next-line @next/next/no-img-element -- a data: URL minted client-side; nothing for the image optimiser to do */}
<img
src={link.qrDataUrl}
alt={t("recordSharing.managed.handover.qrAlt", { name: profileName })}
width={160}
height={160}
className="size-40 shrink-0 rounded-md bg-white p-1"
data-slot="managed-profile-handover-qr"
/>
<div className="w-full min-w-0 flex-1 space-y-2">
<Label htmlFor="managed-profile-handover-url">
{t("recordSharing.managed.handover.urlLabel")}
</Label>
<div className="flex gap-2">
<Input
id="managed-profile-handover-url"
readOnly
value={link.url}
className="min-w-0 flex-1 font-mono text-xs"
onFocus={(e) => e.currentTarget.select()}
/>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 shrink-0 sm:min-h-9"
data-slot="managed-profile-handover-copy"
onClick={() => void copy()}
>
{copied ? (
<Check className="size-4" aria-hidden="true" />
) : (
<Copy className="size-4" aria-hidden="true" />
)}
{t("recordSharing.managed.handover.copy")}
</Button>
</div>
<p className="text-muted-foreground text-xs">
{t("recordSharing.managed.handover.linkExpires", {
date: expiresLabel,
})}
</p>
</div>
</div>
<SettingsCardActions>
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="managed-profile-handover-done"
onClick={onDone}
>
{t("recordSharing.managed.handover.done")}
</Button>
</SettingsCardActions>
</div>
);
}
/** The label of a handover level, as literal keys the i18n guards can see. */
export function accessLabel(
t: (key: string, params?: Record<string, string | number>) => string,
access: HandoverAccess,
): string {
switch (access) {
case "end":
return t("recordSharing.managed.handover.access.end");
case "read":
return t("recordSharing.managed.handover.access.read");
case "manage":
return t("recordSharing.managed.handover.access.manage");
}
}
/** The message for a refused mint. Exported and pure so it can be pinned. */
export function handoverCreateErrorKey(err: unknown): string {
if (!(err instanceof ApiError)) return "recordSharing.managed.errorOffline";
if (err.status === 401) return stepUpErrorKey(err);
if (err.status === 403) return "recordSharing.managed.handover.oidcOnly";
if (err.status === 404) return "recordSharing.actionError.profileNotFound";
if (err.status === 422) {
return err.meta?.errorCode === "managed_profile.handover.unknown_guardian"
? "recordSharing.managed.handover.errorRosterChanged"
: "recordSharing.managed.errorInvalid";
}
if (err.status === 429) return "recordSharing.managed.errorRateLimit";
return "recordSharing.managed.handover.errorFailed";
}
@@ -35,7 +35,12 @@ function actionLabelKey(action: string): string {
return "settings.security.action.newDevice";
if (action === "auth.login.failed")
return "settings.security.action.loginFailed";
if (action.startsWith("auth.login") || action === "auth.register")
// v1.42 (#959) — claiming a managed profile is the account's first sign-in.
if (
action.startsWith("auth.login") ||
action === "auth.register" ||
action === "auth.claim"
)
return "settings.security.action.login";
if (action.startsWith("auth.password"))
return "settings.security.action.passwordChange";