mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
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:
commit
920bd19617
59 files changed
+7147
-694
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