mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
feat(sharing): hand a managed profile over to the person it describes
A guardian with a fresh second factor mints a one-time handover link (hlp_ token, HMAC hash stored only, 1/7/14 days, at most one open per profile, withdrawable) with a proposed access level per guardian: end, view only or manage, view only by default. The link is shown once with a copy button and a QR code; nothing is mailed. The panel sits at the end of each managed-profile row and hints when the person is under 16. The person opens /claim/<token>, sees a minimal preview (name, expiry, who keeps which access, no health data) and chooses a username, a required email and a password under the registration policy. The claim is one transaction under the managed-profile lock: the token is consumed, the credentials are set, the marker is cleared, every pending invitation the record offered is withdrawn, each guardian's access is set to its proposal, the disclaimer, onboarding and AI consent are reset, and every step is audited on the record and on each guardian's own account. A failure anywhere leaves the profile managed and the link unused. Each guardian is notified on their own channels. The new owner then sees each guardian's access on the first screen after signing in and decides finally; until then the proposal holds, and the decision waits at the top of Shared access. A guardian whose access moved since the claim is left alone. The same screen points the owner at their own notification channels, since reminders no longer reach the guardians. Both anonymous routes refuse a single-sign-on-only instance, refuse a live session before spending the per-address rate limit, check the link before probing names, and answer one 404 for every unusable token. The token travels in request bodies only; hlp_ joins the redaction and secret-shape rules and /claim/ the path-secret registry. Usernames starting with managed- are reserved. The session-surface and acting- account guards list the new surfaces with their reasons.
This commit is contained in:
1 parent
321ed1241c
commit
8617f809f0
59 files changed
+7148
-695
No files matched your search
@@ -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"),
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user