mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06: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
@@ -229,6 +229,9 @@ describe("grant management is not delegable by construction", () => {
|
||||
"app/api/account/grants/[id]/route.ts",
|
||||
"app/api/account/grants/[id]/accept/route.ts",
|
||||
"app/api/account/grants/[id]/renounce/route.ts",
|
||||
// v1.42 (#959) — the new owner's decision about former Guardians ends and
|
||||
// writes grant rows, so it is a grant route like the four above.
|
||||
"app/api/account/handover-decision/route.ts",
|
||||
];
|
||||
|
||||
it("every grant route exists and resolves through bare requireAuth", () => {
|
||||
@@ -300,6 +303,20 @@ describe("ending a grant and clearing the switch are one act", () => {
|
||||
expect(body).toContain("clearActingSessions");
|
||||
});
|
||||
|
||||
it("ends a handover Guardian's row and clears their switch in the caller's transaction", () => {
|
||||
// v1.42 (#959) — the one other place a grant ends: settling a Guardian's
|
||||
// access when a managed profile is claimed or decided. It runs inside the
|
||||
// claim's own transaction (it takes `tx`), so the guard here is that the
|
||||
// cleanup is part of the same function as the ending.
|
||||
const body = functionBody(
|
||||
read("lib/sharing/grants.ts"),
|
||||
"settleHandoverGuardianGrant",
|
||||
);
|
||||
expect(body.length).toBeGreaterThan(0);
|
||||
expect(body).toContain("revokedAt: input.now");
|
||||
expect(body).toContain("clearActingSessions(pair, tx)");
|
||||
});
|
||||
|
||||
it("routes an ending through the cleanup, never through the bare transition", () => {
|
||||
for (const rel of [
|
||||
"app/api/account/grants/[id]/route.ts",
|
||||
|
||||
@@ -78,9 +78,6 @@ const DYNAMIC_ALLOWLIST_PREFIXES = [
|
||||
* before the release is tagged. It is NOT a place for keys nobody will wire.
|
||||
*/
|
||||
const RELEASE_PENDING_PREFIXES: Record<string, string> = {
|
||||
"auth.claim": "v1.42 managed-profile claim page (#959)",
|
||||
"recordSharing.managed.handover":
|
||||
"v1.42 managed-profile handover dialog (#959)",
|
||||
"settings.sections.environment.airQuality":
|
||||
"v1.42 environment settings, air quality (#615)",
|
||||
"environment.fields.apparentMax": "v1.42 air-quality field label (#615)",
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* v1.42 (#959) — `/claim/<hlp_token>` is the managed-profile handover link.
|
||||
*
|
||||
* The twin of `proxy-invite-public.test.ts`: the link is a shape-validated
|
||||
* edge redirect onto `/auth/claim?token=…`, it carries no session, and the
|
||||
* hop is uncacheable, unindexable and never leaked as a `Referer`. The two API
|
||||
* routes behind the page authenticate by the token in the request body, so
|
||||
* they pass the page gate; on a demo instance they are refused like every
|
||||
* other mutation off the allowlist.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/process-type", () => ({
|
||||
shouldRunWeb: () => true,
|
||||
}));
|
||||
|
||||
import { proxy, PUBLIC_PATHS } from "../proxy";
|
||||
|
||||
const VALID_TOKEN = `hlp_${"a".repeat(64)}`;
|
||||
|
||||
function makeRequest(
|
||||
pathname: string,
|
||||
init: { cookies?: Record<string, string>; method?: string } = {},
|
||||
): NextRequest {
|
||||
const cookieHeader = Object.entries(init.cookies ?? {})
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join("; ");
|
||||
return new NextRequest(`http://localhost${pathname}`, {
|
||||
method: init.method ?? "GET",
|
||||
headers: cookieHeader ? { cookie: cookieHeader } : {},
|
||||
});
|
||||
}
|
||||
|
||||
describe("proxy.ts /claim public allowlist", () => {
|
||||
it("lists the landing and the anonymous claim routes as public", () => {
|
||||
expect(PUBLIC_PATHS).toContain("/claim/");
|
||||
expect(PUBLIC_PATHS).toContain("/api/auth/claim");
|
||||
});
|
||||
|
||||
it("does not bounce an unauthenticated /claim/<token> to /auth/login", () => {
|
||||
const res = proxy(makeRequest(`/claim/${VALID_TOKEN}`));
|
||||
expect(res.headers.get("location") ?? "").not.toMatch(/\/auth\/login/);
|
||||
});
|
||||
|
||||
it("does not bounce a pending-onboarding session off the link", () => {
|
||||
const res = proxy(
|
||||
makeRequest(`/claim/${VALID_TOKEN}`, {
|
||||
cookies: { healthlog_session: "sess-1", hl_onboarding: "pending" },
|
||||
}),
|
||||
);
|
||||
expect(res.headers.get("location") ?? "").not.toMatch(/\/onboarding/);
|
||||
});
|
||||
|
||||
it("lets the anonymous preview and claim reach their handlers", () => {
|
||||
for (const path of ["/api/auth/claim", "/api/auth/claim/preview"]) {
|
||||
const res = proxy(makeRequest(path, { method: "POST" }));
|
||||
expect(res.status, path).not.toBe(401);
|
||||
expect(res.headers.get("location"), path).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("proxy.ts /claim edge redirect", () => {
|
||||
it("307s a shape-valid token onto the claim page", () => {
|
||||
const res = proxy(makeRequest(`/claim/${VALID_TOKEN}`));
|
||||
expect(res.status).toBe(307);
|
||||
expect(res.headers.get("location")).toBe(
|
||||
`http://localhost/auth/claim?token=${VALID_TOKEN}`,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an uppercase hex body", `hlp_${"A".repeat(64)}`],
|
||||
["a short body", `hlp_${"a".repeat(63)}`],
|
||||
["a long body", `hlp_${"a".repeat(65)}`],
|
||||
["an invite token", `hlv_${"a".repeat(64)}`],
|
||||
["markup", "%3Cscript%3E"],
|
||||
])("drops %s rather than echoing it into the target", (_label, segment) => {
|
||||
const res = proxy(makeRequest(`/claim/${segment}`));
|
||||
expect(res.headers.get("location")).toBe("http://localhost/auth/claim");
|
||||
});
|
||||
|
||||
it("hardens the token-in-path hop against cache, index and referrer leaks", () => {
|
||||
const res = proxy(makeRequest(`/claim/${VALID_TOKEN}`));
|
||||
expect(res.headers.get("Cache-Control")).toBe(
|
||||
"no-store, no-cache, must-revalidate",
|
||||
);
|
||||
expect(res.headers.get("X-Robots-Tag")).toBe("noindex, nofollow");
|
||||
expect(res.headers.get("Referrer-Policy")).toBe("no-referrer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("proxy.ts DEMO_MODE refuses the handover", () => {
|
||||
const original = process.env.DEMO_MODE;
|
||||
afterEach(() => {
|
||||
if (original === undefined) delete process.env.DEMO_MODE;
|
||||
else process.env.DEMO_MODE = original;
|
||||
});
|
||||
|
||||
it.each([
|
||||
["POST", "/api/auth/claim"],
|
||||
["POST", "/api/auth/claim/preview"],
|
||||
["POST", "/api/managed-profiles/p1/handover"],
|
||||
["DELETE", "/api/managed-profiles/p1/handover"],
|
||||
["POST", "/api/account/handover-decision"],
|
||||
])("403s %s %s on a demo instance", async (method, path) => {
|
||||
process.env.DEMO_MODE = "true";
|
||||
const res = proxy(
|
||||
makeRequest(path, {
|
||||
method,
|
||||
cookies: { healthlog_session: "sess-1" },
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("leaves the same claim alone off demo mode", () => {
|
||||
delete process.env.DEMO_MODE;
|
||||
const res = proxy(makeRequest("/api/auth/claim", { method: "POST" }));
|
||||
expect(res.status).not.toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -85,6 +85,17 @@ describe("S1 — the direct session-resolution set is frozen", () => {
|
||||
// here twice over: it would throw 401 at the anonymous caller the route
|
||||
// exists for, and this route wants the ABSENCE of a session.
|
||||
"app/api/auth/register/route.ts": "refuses to register over a live session",
|
||||
// v1.42 (#959) — the anonymous managed-profile claim and its preview. Same
|
||||
// argument as registration: claiming signs the new owner in, so a Guardian
|
||||
// opening their own handover link must be refused rather than signed out
|
||||
// of their account and into the person's. The probe only asks whether a
|
||||
// session EXISTS; the credential these routes act on is the one-time
|
||||
// `hlp_` token in the body, and the probe is resolved in the route file
|
||||
// itself (not inside `claimGate`) so both stay visible on this list.
|
||||
"app/api/auth/claim/route.ts":
|
||||
"refuses to claim a profile over a live session",
|
||||
"app/api/auth/claim/preview/route.ts":
|
||||
"refuses to preview a handover link over a live session",
|
||||
// Anonymous IdP callback. Probes for an already-valid session so a
|
||||
// replayed callback redirects instead of minting a second identity.
|
||||
"app/api/auth/oidc/callback/route.ts":
|
||||
@@ -244,6 +255,17 @@ describe("S2 — the URL-token authentication set is frozen", () => {
|
||||
// it authenticates nothing.
|
||||
"app/invite/[token]/page.tsx":
|
||||
"validates the invite token's shape and redirects; no lookup",
|
||||
// v1.42 (#959) — the managed-profile handover link. The landing is the
|
||||
// invite landing's twin: a shape check on the `hlp_` segment and a
|
||||
// redirect, no lookup (the proxy normally answers it at the edge).
|
||||
"app/claim/[token]/page.tsx":
|
||||
"validates the handover token's shape and redirects; no lookup",
|
||||
// The claim page reads the token off `?token=` and sends it in the BODY of
|
||||
// the anonymous preview and claim requests, which are the only places a
|
||||
// handover token is resolved. The page itself authenticates nothing and
|
||||
// renders nothing about the profile before the preview answers.
|
||||
"app/auth/claim/page.tsx":
|
||||
"carries the handover token from the query string into the claim requests' body",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -283,6 +305,7 @@ describe("S2 — the URL-token authentication set is frozen", () => {
|
||||
"app/c/[token]/fhir/route.ts",
|
||||
"app/c/[token]/page.tsx",
|
||||
"app/c/[token]/report.pdf/route.ts",
|
||||
"app/claim/[token]/page.tsx",
|
||||
"app/invite/[token]/page.tsx",
|
||||
]);
|
||||
});
|
||||
@@ -304,7 +327,10 @@ describe("S2 — the URL-token authentication set is frozen", () => {
|
||||
const byQuery = appFilesMatching(QUERY_CREDENTIAL);
|
||||
|
||||
expect(byQuery.length).toBeGreaterThan(0);
|
||||
expect(byQuery).toEqual(["app/api/withings/webhook/route.ts"]);
|
||||
expect(byQuery).toEqual([
|
||||
"app/api/withings/webhook/route.ts",
|
||||
"app/auth/claim/page.tsx",
|
||||
]);
|
||||
});
|
||||
|
||||
it("the three legs together equal the frozen allowlist", () => {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* `/api/account/handover-decision` — the new owner's final word on each former
|
||||
* Guardian after claiming a managed profile (v1.42, #959).
|
||||
*
|
||||
* The claim applies the access the issuing Guardian proposed, so the record
|
||||
* is never without a decision. This is where the owner sees it and decides:
|
||||
*
|
||||
* - `GET` answers the decision that is waiting, or `pending: null`.
|
||||
* - `POST` records it. A Guardian the body leaves out keeps what the claim
|
||||
* gave them; after this, the ordinary Settings → Shared access panel is
|
||||
* where access changes.
|
||||
*
|
||||
* A grant-management surface, resolved like the others (`/api/account/
|
||||
* grants/**`): bare `requireAuth()`, which refuses under an acting-account
|
||||
* switch and refuses a narrow Bearer scope. The owner is the session user; no
|
||||
* party to any grant comes from the body, only the claim-time grant id that
|
||||
* names which former Guardian a decision is about.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
decideHandover,
|
||||
HandoverError,
|
||||
readPendingHandoverDecision,
|
||||
} from "@/lib/managed-profiles/handover";
|
||||
import { notifyGuardiansOfHandover } from "@/lib/managed-profiles/handover-notify";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { handoverDecisionSchema } from "@/lib/validations/managed-profile-handover";
|
||||
|
||||
export const GET = apiHandler(async (_request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const pending = await readPendingHandoverDecision(user.id);
|
||||
annotate({
|
||||
action: { name: "managed_profile.handover.decision_read" },
|
||||
meta: { pending: pending !== null },
|
||||
});
|
||||
return apiSuccess({
|
||||
pending: pending
|
||||
? {
|
||||
claimedAt: pending.claimedAt.toISOString(),
|
||||
guardians: pending.guardians,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = handoverDecisionSchema.safeParse(body);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await decideHandover({
|
||||
userId: user.id,
|
||||
decisions: parsed.data.decisions.map((d) => ({
|
||||
grantId: d.grantId,
|
||||
access: d.access,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof HandoverError) {
|
||||
if (error.code === "unknown_guardian") {
|
||||
return apiError(
|
||||
"A decision names somebody who was not a Guardian",
|
||||
422,
|
||||
{
|
||||
errorCode: "managed_profile.handover.unknown_guardian",
|
||||
},
|
||||
);
|
||||
}
|
||||
return apiError("There is no handover decision waiting", 409, {
|
||||
errorCode: "managed_profile.handover.no_pending",
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (result.changed.length > 0) {
|
||||
const self = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { displayName: true, username: true },
|
||||
});
|
||||
notifyGuardiansOfHandover(
|
||||
"changed",
|
||||
self?.displayName?.trim() || self?.username || user.username,
|
||||
result.changed,
|
||||
);
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "managed_profile.handover.decided" },
|
||||
meta: { changed: result.changed.length },
|
||||
});
|
||||
return apiSuccess({ decided: true, changed: result.changed.length });
|
||||
});
|
||||
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* v1.42 (#959) — the anonymous claim and its preview, at the route seam.
|
||||
*
|
||||
* The transaction itself is integration-tested
|
||||
* (`tests/integration/managed-profile-handover.test.ts`); here the order of
|
||||
* the refusals is pinned, because each one is a security property:
|
||||
*
|
||||
* - single sign-on only refuses before anything else;
|
||||
* - a live session is refused BEFORE the rate limit (a state, not an
|
||||
* attempt), and before any token is read;
|
||||
* - the rate limit runs before the body is parsed;
|
||||
* - an unusable token answers the same 404 on both routes, and the claim
|
||||
* checks the link BEFORE probing whether a username or email is taken, so
|
||||
* the route is not a name oracle for somebody without a live link;
|
||||
* - nothing is hashed for a token that is refused.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { user: { findUnique: vi.fn() } },
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
createSession: vi.fn(),
|
||||
getSession: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/auth/oidc", () => ({ isOidcOnly: vi.fn() }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
checkAuthSurfaceRateLimit: vi.fn(),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("@/lib/auth/password", () => ({
|
||||
hashPassword: vi.fn(),
|
||||
checkPasswordStrength: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/password-breach-check", () => ({
|
||||
checkPasswordBreachIfEnabled: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
vi.mock("@/lib/auth/login-alert", () => ({ recordSignInDevice: vi.fn() }));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn().mockResolvedValue("en"),
|
||||
}));
|
||||
vi.mock("@/lib/managed-profiles/handover", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/lib/managed-profiles/handover")
|
||||
>("@/lib/managed-profiles/handover");
|
||||
return {
|
||||
HandoverError: actual.HandoverError,
|
||||
previewHandover: vi.fn(),
|
||||
claimManagedProfile: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/managed-profiles/handover-notify", () => ({
|
||||
notifyGuardiansOfHandover: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST as claim } from "../route";
|
||||
import { POST as preview } from "../preview/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { createSession, getSession } from "@/lib/auth/session";
|
||||
import { isOidcOnly } from "@/lib/auth/oidc";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import { checkPasswordStrength, hashPassword } from "@/lib/auth/password";
|
||||
import { checkPasswordBreachIfEnabled } from "@/lib/password-breach-check";
|
||||
import {
|
||||
claimManagedProfile,
|
||||
HandoverError,
|
||||
previewHandover,
|
||||
} from "@/lib/managed-profiles/handover";
|
||||
import { notifyGuardiansOfHandover } from "@/lib/managed-profiles/handover-notify";
|
||||
|
||||
const TOKEN = `hlp_${"a".repeat(64)}`;
|
||||
const BODY = {
|
||||
token: TOKEN,
|
||||
username: "alex",
|
||||
email: "alex@example.test",
|
||||
password: "a long enough passphrase",
|
||||
};
|
||||
|
||||
function post(path: string, body: unknown): NextRequest {
|
||||
return new NextRequest(`http://localhost${path}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
const PREVIEW = {
|
||||
displayName: "Alex",
|
||||
expiresAt: new Date("2030-01-01T00:00:00Z"),
|
||||
guardians: [{ grantId: "g1", displayName: "Sam", proposal: "read" as const }],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(isOidcOnly).mockReturnValue(false);
|
||||
vi.mocked(getSession).mockResolvedValue(null as never);
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
ip: "203.0.113.7",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
|
||||
vi.mocked(checkPasswordStrength).mockReturnValue({
|
||||
isAcceptable: true,
|
||||
feedback: [],
|
||||
} as never);
|
||||
vi.mocked(checkPasswordBreachIfEnabled).mockResolvedValue(null as never);
|
||||
vi.mocked(hashPassword).mockResolvedValue("argon2-hash");
|
||||
vi.mocked(previewHandover).mockResolvedValue(PREVIEW);
|
||||
vi.mocked(claimManagedProfile).mockResolvedValue({
|
||||
profileId: "p1",
|
||||
username: "alex",
|
||||
displayName: "Alex",
|
||||
guardians: [{ guardianId: "u-sam", access: "read" }],
|
||||
});
|
||||
});
|
||||
|
||||
async function errorCode(res: Response): Promise<unknown> {
|
||||
const body = (await res.json()) as { meta?: { errorCode?: string } };
|
||||
return body.meta?.errorCode;
|
||||
}
|
||||
|
||||
describe.each([
|
||||
["claim", claim, "/api/auth/claim", BODY],
|
||||
["preview", preview, "/api/auth/claim/preview", { token: TOKEN }],
|
||||
] as const)(
|
||||
"POST %s — refusals that read no token",
|
||||
(_n, route, path, body) => {
|
||||
it("refuses on a single-sign-on-only instance before anything else", async () => {
|
||||
vi.mocked(isOidcOnly).mockReturnValue(true);
|
||||
vi.mocked(getSession).mockResolvedValue({ user: { id: "u1" } } as never);
|
||||
const res = await route(post(path, body));
|
||||
expect(res.status).toBe(403);
|
||||
expect(await errorCode(res)).toBe("profile_claim.oidc_only_unsupported");
|
||||
expect(previewHandover).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses over a live session before spending the rate limit", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue({ user: { id: "u1" } } as never);
|
||||
const res = await route(post(path, body));
|
||||
expect(res.status).toBe(409);
|
||||
expect(await errorCode(res)).toBe("auth.already_authenticated");
|
||||
expect(checkAuthSurfaceRateLimit).not.toHaveBeenCalled();
|
||||
expect(previewHandover).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("answers 429 from the per-address bucket before parsing the body", async () => {
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
ip: "203.0.113.7",
|
||||
} as never);
|
||||
const res = await route(post(path, body));
|
||||
expect(res.status).toBe(429);
|
||||
expect(previewHandover).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("answers the same 404 for an unusable link", async () => {
|
||||
vi.mocked(previewHandover).mockResolvedValue(null);
|
||||
const res = await route(post(path, body));
|
||||
expect(res.status).toBe(404);
|
||||
expect(await errorCode(res)).toBe("profile_claim.invalid");
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe("POST /api/auth/claim", () => {
|
||||
it("checks the link before the username and email probe", async () => {
|
||||
vi.mocked(previewHandover).mockResolvedValue(null);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({ id: "x" } as never);
|
||||
const res = await claim(post("/api/auth/claim", BODY));
|
||||
// A taken name would be a 409; the link is checked first, so 404.
|
||||
expect(res.status).toBe(404);
|
||||
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||
expect(hashPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("answers 409 profile_claim.taken for a name in use, before hashing", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
id: "x",
|
||||
} as never);
|
||||
const res = await claim(post("/api/auth/claim", BODY));
|
||||
expect(res.status).toBe(409);
|
||||
expect(await errorCode(res)).toBe("profile_claim.taken");
|
||||
expect(hashPassword).not.toHaveBeenCalled();
|
||||
expect(claimManagedProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a username with the managed-profile prefix as 422", async () => {
|
||||
const res = await claim(
|
||||
post("/api/auth/claim", { ...BODY, username: "managed-alex" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(previewHandover).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses an unknown field (strict body)", async () => {
|
||||
const res = await claim(
|
||||
post("/api/auth/claim", { ...BODY, userId: "someone-else" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("maps a race lost inside the transaction to the same 404", async () => {
|
||||
vi.mocked(claimManagedProfile).mockRejectedValue(
|
||||
new HandoverError("invalid"),
|
||||
);
|
||||
const res = await claim(post("/api/auth/claim", BODY));
|
||||
expect(res.status).toBe(404);
|
||||
expect(createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps a unique-constraint race to 409 taken", async () => {
|
||||
vi.mocked(claimManagedProfile).mockRejectedValue(
|
||||
new HandoverError("taken"),
|
||||
);
|
||||
const res = await claim(post("/api/auth/claim", BODY));
|
||||
expect(res.status).toBe(409);
|
||||
expect(await errorCode(res)).toBe("profile_claim.taken");
|
||||
});
|
||||
|
||||
it("signs the new owner in with onboarding owed and tells the guardians", async () => {
|
||||
const res = await claim(post("/api/auth/claim", BODY));
|
||||
expect(res.status).toBe(201);
|
||||
expect(claimManagedProfile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
rawToken: TOKEN,
|
||||
username: "alex",
|
||||
email: "alex@example.test",
|
||||
passwordHash: "argon2-hash",
|
||||
}),
|
||||
);
|
||||
expect(createSession).toHaveBeenCalledWith("p1", true, "203.0.113.7", null);
|
||||
expect(notifyGuardiansOfHandover).toHaveBeenCalledWith("claimed", "Alex", [
|
||||
{ guardianId: "u-sam", access: "read" },
|
||||
]);
|
||||
const body = (await res.json()) as { data: unknown };
|
||||
expect(body.data).toEqual({ userId: "p1", username: "alex" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/auth/claim/preview", () => {
|
||||
it("returns the minimal preview and nothing else", async () => {
|
||||
const res = await preview(
|
||||
post("/api/auth/claim/preview", { token: TOKEN }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
expect(Object.keys(body.data).sort()).toEqual([
|
||||
"displayName",
|
||||
"expiresAt",
|
||||
"guardians",
|
||||
]);
|
||||
expect(body.data.expiresAt).toBe("2030-01-01T00:00:00.000Z");
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,50 @@
|
||||
/**
|
||||
* `POST /api/auth/claim/preview` — what a handover link would hand over: the
|
||||
* profile's display name and each guardian's proposal (v1.42, #959).
|
||||
* Anonymous, token in the body, the same uniform 404 as the claim itself.
|
||||
* profile's display name, the link's expiry and each Guardian's proposed
|
||||
* access (v1.42, #959). No health data.
|
||||
*
|
||||
* Contract stub: answers 501.
|
||||
* Anonymous by design: the person opening the link has no credentials yet.
|
||||
* The one-time `hlp_` token travels in the BODY, never in this request's URL,
|
||||
* so it never reaches an access log or a wide event's `http.path`. Every
|
||||
* unusable link — malformed, unknown, expired, used, withdrawn, a profile no
|
||||
* longer managed, a creator who lost access — answers the same 404.
|
||||
*/
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export const POST = apiHandler(async () => notImplemented());
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { claimGate, claimInvalid } from "@/lib/managed-profiles/claim-gate";
|
||||
import { previewHandover } from "@/lib/managed-profiles/handover";
|
||||
import { claimPreviewSchema } from "@/lib/validations/managed-profile-handover";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// Refuses to preview over a live session — see `claimGate`.
|
||||
const gate = await claimGate(
|
||||
request,
|
||||
"preview",
|
||||
(await getSession()) !== null,
|
||||
);
|
||||
if (gate.refusal) return gate.refusal;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 4 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = claimPreviewSchema.safeParse(body);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
|
||||
const preview = await previewHandover(parsed.data.token);
|
||||
annotate({
|
||||
action: { name: "profile_claim.preview" },
|
||||
meta: { found: preview !== null },
|
||||
});
|
||||
if (!preview) return claimInvalid();
|
||||
|
||||
return apiSuccess({
|
||||
displayName: preview.displayName,
|
||||
expiresAt: preview.expiresAt.toISOString(),
|
||||
guardians: preview.guardians,
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,155 @@
|
||||
/**
|
||||
* `POST /api/auth/claim` — claim a managed profile through its handover link
|
||||
* (v1.42, #959). Anonymous by design: the person claiming has no credentials
|
||||
* yet. The one-time `hlp_` token travels in the body, never in the URL of
|
||||
* this request, and every failure answers the same 404.
|
||||
* (v1.42, #959). The person the profile describes sets a username, an email
|
||||
* and a password, and the profile becomes their own account. No data moves.
|
||||
*
|
||||
* Contract stub: answers 501.
|
||||
* Anonymous by design: the claimant has no credentials yet, and the one-time
|
||||
* `hlp_` token in the BODY is what authorises the request. The order of the
|
||||
* work is the registration route's, for the same reasons:
|
||||
*
|
||||
* 1. refusals that read no token (`claimGate`: SSO-only, live session, rate
|
||||
* limit);
|
||||
* 2. the body, strictly parsed, and the link checked;
|
||||
* 3. the username and email collision probe, before any expensive work;
|
||||
* 4. password strength, the breach corpus, the Argon2id hash — outside the
|
||||
* lock, because none of it depends on the profile;
|
||||
* 5. the claim itself, one transaction under the managed-profile lock
|
||||
* (`claimManagedProfile`), where the token is consumed;
|
||||
* 6. after the commit: the session, the device record, and a notification
|
||||
* to each former Guardian on their own channels.
|
||||
*
|
||||
* A closed registration does not block a claim: no account is created, the
|
||||
* link is the door.
|
||||
*/
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export const POST = apiHandler(async () => notImplemented());
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { recordSignInDevice } from "@/lib/auth/login-alert";
|
||||
import { checkPasswordStrength, hashPassword } from "@/lib/auth/password";
|
||||
import { createSession, getSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { claimGate, claimInvalid } from "@/lib/managed-profiles/claim-gate";
|
||||
import {
|
||||
claimManagedProfile,
|
||||
HandoverError,
|
||||
previewHandover,
|
||||
} from "@/lib/managed-profiles/handover";
|
||||
import { notifyGuardiansOfHandover } from "@/lib/managed-profiles/handover-notify";
|
||||
import { checkPasswordBreachIfEnabled } from "@/lib/password-breach-check";
|
||||
import { claimSchema } from "@/lib/validations/managed-profile-handover";
|
||||
|
||||
const taken = () =>
|
||||
apiError("Username or email already taken", 409, {
|
||||
errorCode: "profile_claim.taken",
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// Refuses to claim over a live session — see `claimGate`.
|
||||
const gate = await claimGate(request, "claim", (await getSession()) !== null);
|
||||
if (gate.refusal) return gate.refusal;
|
||||
const ip = gate.ip ?? "unknown";
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = claimSchema.safeParse(body);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
const { token, username, email, password } = parsed.data;
|
||||
|
||||
// The link first. Without this the collision probe below would answer 409
|
||||
// to anybody, token or not, and the claim route would be a username and
|
||||
// email oracle that registration being closed does not close. With it, only
|
||||
// the holder of a live link learns that a name is taken. The claim re-checks
|
||||
// everything under the lock; this read decides nothing on its own.
|
||||
if (!(await previewHandover(token))) return claimInvalid();
|
||||
|
||||
// Before the strength check, the breach lookup and the hash, exactly as the
|
||||
// registration route orders it (its comment carries the timing argument).
|
||||
// The profile's own row never collides here: its username is the generated
|
||||
// `managed-…` one, which no chosen name may start with, and it has no email.
|
||||
const [existingEmail, existingUsername] = await Promise.all([
|
||||
prisma.user.findUnique({ where: { email }, select: { id: true } }),
|
||||
prisma.user.findUnique({ where: { username }, select: { id: true } }),
|
||||
]);
|
||||
if (existingEmail || existingUsername) return taken();
|
||||
|
||||
const locale = await resolveServerLocale({ request });
|
||||
const strength = checkPasswordStrength(password, [username, email], locale);
|
||||
if (!strength.isAcceptable) {
|
||||
return apiError(
|
||||
strength.feedback[0] || "Password too weak (score < 3)",
|
||||
422,
|
||||
);
|
||||
}
|
||||
const breach = await checkPasswordBreachIfEnabled(password);
|
||||
if (breach?.breached) {
|
||||
return apiError(
|
||||
getServerTranslator(locale).t("auth.passwordBreached"),
|
||||
422,
|
||||
);
|
||||
}
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
let claimed;
|
||||
try {
|
||||
claimed = await claimManagedProfile({
|
||||
rawToken: token,
|
||||
username,
|
||||
email,
|
||||
passwordHash,
|
||||
ipAddress: ip,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof HandoverError) {
|
||||
annotate({
|
||||
action: { name: "profile_claim.refused" },
|
||||
meta: { reason: error.code },
|
||||
});
|
||||
return error.code === "taken" ? taken() : claimInvalid();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// The new owner starts with onboarding owed: the claim reset it, because
|
||||
// the disclaimer and the setup were a Guardian's answers, not theirs.
|
||||
const ua = request.headers.get("user-agent");
|
||||
await createSession(claimed.profileId, true, ip, ua);
|
||||
void recordSignInDevice({
|
||||
userId: claimed.profileId,
|
||||
ip,
|
||||
userAgent: ua,
|
||||
alertOnNew: false,
|
||||
});
|
||||
await auditLog("auth.claim", {
|
||||
userId: claimed.profileId,
|
||||
actorUserId: null,
|
||||
ipAddress: ip,
|
||||
details: { method: "password" },
|
||||
});
|
||||
|
||||
notifyGuardiansOfHandover(
|
||||
"claimed",
|
||||
claimed.displayName ?? claimed.username,
|
||||
claimed.guardians,
|
||||
);
|
||||
|
||||
annotate({
|
||||
action: { name: "profile_claim.complete" },
|
||||
meta: { guardians: claimed.guardians.length },
|
||||
});
|
||||
return apiSuccess(
|
||||
{ userId: claimed.profileId, username: claimed.username },
|
||||
201,
|
||||
);
|
||||
});
|
||||
@@ -1,21 +1,186 @@
|
||||
/**
|
||||
* `POST /api/managed-profiles/[id]/handover` — mint the one-time link that
|
||||
* hands a managed profile over to the person it describes, with a proposal
|
||||
* per guardian; `DELETE` withdraws the open link (v1.42, #959). Cookie-only
|
||||
* like every act on a managed profile; minting is step-up gated, withdrawing
|
||||
* is not.
|
||||
* `/api/managed-profiles/[id]/handover` — the Guardian's side of handing a
|
||||
* managed profile over to the person it describes (v1.42, #959).
|
||||
*
|
||||
* Contract stub: authenticates as the finished route will, then answers 501.
|
||||
* - `GET` reads whether a link is open, never the link itself (only its hash
|
||||
* exists). Cookie-only, no step-up: it discloses to a Guardian what they
|
||||
* already administer.
|
||||
* - `POST` mints the one-time link with a proposal per Guardian. Step-up
|
||||
* gated like every act that changes what a managed profile is
|
||||
* (`requireFreshMfa`), and rate-limited like its siblings.
|
||||
* - `DELETE` withdraws the open link. Cookie-only and NOT step-up gated:
|
||||
* taking a link back must never be harder than handing it out.
|
||||
*
|
||||
* All three answer the same 404 for "no such profile", "not managed" and "not
|
||||
* a Guardian of it", so the route is not an enumeration oracle. Bearer tokens
|
||||
* cannot reach any of them: every gate here resolves the cookie session.
|
||||
*/
|
||||
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
|
||||
import { notImplemented } from "@/lib/http/not-implemented";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
await requireCookieAuth();
|
||||
return notImplemented();
|
||||
});
|
||||
import {
|
||||
apiHandler,
|
||||
MFA_STEP_UP_MAX_AGE_SECONDS,
|
||||
requireCookieAuth,
|
||||
requireFreshMfa,
|
||||
} from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { buildHandoverUrl } from "@/lib/auth/handover-token";
|
||||
import { isOidcOnly } from "@/lib/auth/oidc";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
createHandover,
|
||||
HandoverError,
|
||||
readHandoverStatus,
|
||||
revokeHandover,
|
||||
} from "@/lib/managed-profiles/handover";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { createHandoverSchema } from "@/lib/validations/managed-profile-handover";
|
||||
|
||||
export const DELETE = apiHandler(async () => {
|
||||
await requireCookieAuth();
|
||||
return notImplemented();
|
||||
});
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
const notFound = () =>
|
||||
apiError("Managed profile not found", 404, {
|
||||
errorCode: "managed_profile.not_found",
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.42 refuses the handover on a single-sign-on-only instance: the claim
|
||||
* sets a password, and such an instance admits none. The SSO arm of the claim
|
||||
* is a separate piece of work.
|
||||
*/
|
||||
const oidcOnlyRefusal = () =>
|
||||
apiError(
|
||||
"Handing over a profile is not available while this server allows single sign-on only",
|
||||
403,
|
||||
{ errorCode: "profile_claim.oidc_only_unsupported" },
|
||||
);
|
||||
|
||||
/** Same ceiling as creating, editing and inviting: ten an hour per caller. */
|
||||
const CREATE_LIMIT = 10;
|
||||
const CREATE_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireCookieAuth();
|
||||
const { id } = await params;
|
||||
|
||||
const status = await readHandoverStatus({
|
||||
profileId: id,
|
||||
guardianId: user.id,
|
||||
});
|
||||
if (!status) return notFound();
|
||||
|
||||
annotate({
|
||||
action: { name: "managed_profile.handover.read" },
|
||||
meta: { profile_id: id, open: status.open !== null },
|
||||
});
|
||||
return apiSuccess({
|
||||
available: !isOidcOnly(),
|
||||
open: status.open
|
||||
? {
|
||||
id: status.open.id,
|
||||
createdAt: status.open.createdAt.toISOString(),
|
||||
expiresAt: status.open.expiresAt.toISOString(),
|
||||
createdByYou: status.open.createdByCaller,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
|
||||
// Before the id is read and before any lock is taken, as the siblings do:
|
||||
// the service locks on the id it is handed before deciding the caller is
|
||||
// not a Guardian of it.
|
||||
const rateLimit = await checkRateLimit(
|
||||
`managed-profile:handover:${user.id}`,
|
||||
CREATE_LIMIT,
|
||||
CREATE_WINDOW_MS,
|
||||
);
|
||||
if (!rateLimit.allowed) {
|
||||
return apiError("Too many handover links, try again later", 429);
|
||||
}
|
||||
if (isOidcOnly()) return oidcOnlyRefusal();
|
||||
const { id } = await params;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = createHandoverSchema.safeParse(body);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
|
||||
let created;
|
||||
try {
|
||||
created = await createHandover({
|
||||
profileId: id,
|
||||
guardianId: user.id,
|
||||
expiresInDays: parsed.data.expiresInDays,
|
||||
proposals: parsed.data.proposals.map((p) => ({
|
||||
grantId: p.grantId,
|
||||
proposal: p.proposal,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof HandoverError) {
|
||||
if (error.code === "unknown_guardian") {
|
||||
return apiError(
|
||||
"A proposal names somebody who is not a Guardian",
|
||||
422,
|
||||
{
|
||||
errorCode: "managed_profile.handover.unknown_guardian",
|
||||
},
|
||||
);
|
||||
}
|
||||
return notFound();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "managed_profile.handover.create" },
|
||||
meta: {
|
||||
profile_id: id,
|
||||
expires_in_days: parsed.data.expiresInDays,
|
||||
proposals: parsed.data.proposals.length,
|
||||
},
|
||||
});
|
||||
return apiSuccess(
|
||||
{
|
||||
id: created.id,
|
||||
token: created.token,
|
||||
url: buildHandoverUrl(created.token, request.url),
|
||||
expiresAt: created.expiresAt.toISOString(),
|
||||
},
|
||||
201,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireCookieAuth();
|
||||
const { id } = await params;
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await revokeHandover({ profileId: id, guardianId: user.id });
|
||||
} catch (error) {
|
||||
if (error instanceof HandoverError) return notFound();
|
||||
throw error;
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "managed_profile.handover.revoke" },
|
||||
meta: { profile_id: id, revoked: result.revoked },
|
||||
});
|
||||
return apiSuccess(result);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
import { ClaimProfile } from "@/components/auth/claim-profile";
|
||||
|
||||
/**
|
||||
* v1.42 (#959) — `/auth/claim?token=hlp_…`, where the link a Guardian handed
|
||||
* over (`/claim/<token>`) lands after the edge redirect.
|
||||
*
|
||||
* A URL-credential surface by shape (`session-surface-guard.test.ts`, S2): the
|
||||
* page reads the one-time token off the query string and passes it to the
|
||||
* form, which sends it only in the BODY of the anonymous preview and claim
|
||||
* requests. Nothing here resolves the token; those two routes do, behind one
|
||||
* uniform 404 and a per-address rate limit.
|
||||
*/
|
||||
export default function ClaimProfilePage() {
|
||||
const searchParams = useSearchParams();
|
||||
return <ClaimProfile token={searchParams.get("token")} />;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { looksLikeHandoverToken } from "@/lib/auth/handover-token";
|
||||
|
||||
/**
|
||||
* v1.42 (#959) — the managed-profile handover link landing.
|
||||
*
|
||||
* `https://<host>/claim/<hlp_token>` is the link a Guardian hands over. The
|
||||
* proxy answers it at the edge with a 307 onto `/auth/claim?token=…`
|
||||
* (`src/proxy.ts`), so in practice this page never renders; it stays as the
|
||||
* fallback for any path that bypasses the proxy matcher, exactly like
|
||||
* `src/app/invite/[token]/page.tsx`.
|
||||
*
|
||||
* Security, the invite landing's posture:
|
||||
* - the `hlp_` shape is validated before the token is reflected into the
|
||||
* redirect target, so a malformed segment lands on the bare claim page
|
||||
* and the page never echoes attacker-controlled text;
|
||||
* - no database read: a well-formed unknown token and a valid one redirect
|
||||
* identically. Whether a token is real is only decided by the anonymous
|
||||
* preview and claim routes, behind their uniform 404 and rate limit;
|
||||
* - the token is never logged here (and `/claim/` is a registered secret
|
||||
* path in `src/lib/logging/redact.ts`).
|
||||
*/
|
||||
export default async function ClaimLandingPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ token: string }>;
|
||||
}) {
|
||||
const { token } = await params;
|
||||
if (looksLikeHandoverToken(token)) {
|
||||
redirect(`/auth/claim?token=${encodeURIComponent(token)}`);
|
||||
}
|
||||
redirect("/auth/claim");
|
||||
}
|
||||
@@ -39,12 +39,19 @@ vi.mock("@/components/onboarding/onboarding-shell", () => ({
|
||||
OnboardingShell: ({ children }: { children: ReactElement }) => children,
|
||||
}));
|
||||
|
||||
const pendingHandoverMock = vi.fn(async () => false);
|
||||
vi.mock("@/lib/managed-profiles/handover", () => ({
|
||||
hasPendingHandoverDecision: () => pendingHandoverMock(),
|
||||
}));
|
||||
|
||||
import OnboardingRootPage from "../page";
|
||||
import { WelcomeScreen } from "@/components/onboarding/welcome-screen";
|
||||
|
||||
beforeEach(() => {
|
||||
redirectMock.mockClear();
|
||||
loadMock.mockReset();
|
||||
pendingHandoverMock.mockReset();
|
||||
pendingHandoverMock.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
function state(
|
||||
@@ -67,11 +74,15 @@ type ShellElement = ReactElement<{
|
||||
children: ReactElement<{ variant: string }>;
|
||||
}>;
|
||||
|
||||
async function run(): Promise<
|
||||
{ redirect: string } | { element: ShellElement }
|
||||
> {
|
||||
async function run(
|
||||
searchParams?: Record<string, string>,
|
||||
): Promise<{ redirect: string } | { element: ShellElement }> {
|
||||
try {
|
||||
const element = (await OnboardingRootPage()) as ShellElement;
|
||||
const element = (await OnboardingRootPage(
|
||||
searchParams
|
||||
? { searchParams: Promise.resolve(searchParams) }
|
||||
: undefined,
|
||||
)) as ShellElement;
|
||||
return { element };
|
||||
} catch (e) {
|
||||
const tagged = e as Error & { __redirect__?: string };
|
||||
@@ -87,6 +98,27 @@ function welcomeVariant(element: ShellElement): string {
|
||||
}
|
||||
|
||||
describe("<OnboardingRootPage>", () => {
|
||||
it("sends an account with a handover decision waiting to that screen first", async () => {
|
||||
pendingHandoverMock.mockResolvedValue(true);
|
||||
loadMock.mockResolvedValueOnce({
|
||||
userId: "u1",
|
||||
userLocale: "en",
|
||||
state: state(),
|
||||
});
|
||||
expect(await run()).toEqual({ redirect: "/onboarding/handover" });
|
||||
});
|
||||
|
||||
it("lets the handover screen's own 'later' through to the welcome", async () => {
|
||||
pendingHandoverMock.mockResolvedValue(true);
|
||||
loadMock.mockResolvedValueOnce({
|
||||
userId: "u1",
|
||||
userLocale: "en",
|
||||
state: state(),
|
||||
});
|
||||
const result = await run({ handover: "later" });
|
||||
expect("element" in result && welcomeVariant(result.element)).toBe("fresh");
|
||||
});
|
||||
|
||||
it("redirects to /auth/login when there is no session", async () => {
|
||||
loadMock.mockResolvedValueOnce(null);
|
||||
expect(await run()).toEqual({ redirect: "/auth/login" });
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { StepHeading } from "@/components/onboarding/step-heading";
|
||||
import {
|
||||
HandoverDecisionForm,
|
||||
HandoverNotificationHint,
|
||||
} from "@/components/settings/access/handover-decision";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Logo } from "@/components/ui/logo";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useHandoverDecision } from "@/lib/queries/use-handover-decision";
|
||||
|
||||
/**
|
||||
* v1.42 (#959) — the first screen after claiming a managed profile.
|
||||
*
|
||||
* The claim applied the access each Guardian was proposed; here the new owner
|
||||
* sees it and decides. It lives under `/onboarding/` because a claimed
|
||||
* account starts with its setup owed and the proxy walls everything else off
|
||||
* until that is done; the setup's front door sends a pending decision here
|
||||
* first (`src/app/onboarding/page.tsx`).
|
||||
*
|
||||
* "Later" is honest: the proposal keeps holding, and the same decision waits
|
||||
* at the top of Settings → Shared access until it is made.
|
||||
*/
|
||||
export default function HandoverDecisionPage() {
|
||||
const { t } = useTranslations();
|
||||
const router = useRouter();
|
||||
const decision = useHandoverDecision();
|
||||
const pending = decision.data?.pending;
|
||||
const nothingWaiting = decision.data !== undefined && pending === null;
|
||||
|
||||
useEffect(() => {
|
||||
if (nothingWaiting) router.replace("/onboarding");
|
||||
}, [nothingWaiting, router]);
|
||||
|
||||
const continueSetup = (later: boolean) =>
|
||||
router.push(later ? "/onboarding?handover=later" : "/onboarding");
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mx-auto flex w-full max-w-xl flex-col gap-6 px-4 pt-6 pb-[max(env(safe-area-inset-bottom),1rem)]"
|
||||
data-slot="handover-decision-screen"
|
||||
>
|
||||
<header className="flex items-center">
|
||||
<Logo size={32} />
|
||||
</header>
|
||||
|
||||
<StepHeading
|
||||
id="handover-decision-heading"
|
||||
title={t("recordSharing.handoverDecision.title")}
|
||||
description={t("recordSharing.handoverDecision.description")}
|
||||
/>
|
||||
|
||||
{decision.isError ? (
|
||||
<QueryErrorCard
|
||||
title={t("recordSharing.handoverDecision.loadError")}
|
||||
onRetry={() => void decision.refetch()}
|
||||
/>
|
||||
) : !pending ? (
|
||||
<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>
|
||||
) : (
|
||||
<>
|
||||
<HandoverNotificationHint />
|
||||
<HandoverDecisionForm
|
||||
pending={pending}
|
||||
onDecided={() => continueSetup(false)}
|
||||
secondary={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="handover-decision-later"
|
||||
onClick={() => continueSetup(true)}
|
||||
>
|
||||
{t("recordSharing.handoverDecision.decideLater")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { redirect } from "next/navigation";
|
||||
|
||||
import { OnboardingShell } from "@/components/onboarding/onboarding-shell";
|
||||
import { WelcomeScreen } from "@/components/onboarding/welcome-screen";
|
||||
import { hasPendingHandoverDecision } from "@/lib/managed-profiles/handover";
|
||||
import { loadOnboardingFlowState } from "@/lib/onboarding/load-flow-state";
|
||||
import { isOnboardingSettled } from "@/lib/onboarding/needs";
|
||||
import { resumeScreen } from "@/lib/onboarding/wizard-steps";
|
||||
@@ -21,13 +22,24 @@ import { resumeScreen } from "@/lib/onboarding/wizard-steps";
|
||||
* No session mirrors the proxy's own gate (`/onboarding` is a public path,
|
||||
* so the page has to say it too).
|
||||
*/
|
||||
export default async function OnboardingRootPage() {
|
||||
export default async function OnboardingRootPage(props?: {
|
||||
searchParams?: Promise<{ handover?: string | string[] }>;
|
||||
}) {
|
||||
const flow = await loadOnboardingFlowState();
|
||||
if (!flow) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
const { state, userLocale } = flow;
|
||||
|
||||
// v1.42 (#959) — an account that just claimed a managed profile decides its
|
||||
// former Guardians' access first. `?handover=later` is that screen's own
|
||||
// "later", which must not bounce straight back to it; the decision then
|
||||
// waits in Settings → Shared access.
|
||||
const handover = (await props?.searchParams)?.handover;
|
||||
if (handover !== "later" && (await hasPendingHandoverDecision(flow.userId))) {
|
||||
redirect("/onboarding/handover");
|
||||
}
|
||||
|
||||
const resume = resumeScreen(state);
|
||||
if (resume !== "welcome") {
|
||||
const finished = state.completedAt !== null && isOnboardingSettled(state);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -8,6 +8,7 @@ describe("looksSecretShaped", () => {
|
||||
["a refresh token", "hlr_xyz789"],
|
||||
["a share-link token", "see hls_def456"],
|
||||
["an invite token", "hlv_0a1b2c3d"],
|
||||
["a managed-profile handover token", "hlp_0a1b2c3d"],
|
||||
["an elevation token", "hle_0a1b2c3d"],
|
||||
["a native sign-in handoff code", "hlh_Ab9-xYz_12"],
|
||||
["an MCP authorization code", "hlac_eyJ0eXAi"],
|
||||
|
||||
@@ -470,6 +470,15 @@ describe("deriveUniqueUsername", () => {
|
||||
expect(username.length).toBeLessThanOrEqual(30);
|
||||
});
|
||||
|
||||
it("never derives a name with the managed-profile prefix", async () => {
|
||||
const exists = vi.fn().mockResolvedValue(false);
|
||||
const username = await deriveUniqueUsername(
|
||||
"Managed-managed-alex@example.com",
|
||||
exists,
|
||||
);
|
||||
expect(username).toBe("alex");
|
||||
});
|
||||
|
||||
it("appends a numeric suffix on collision", async () => {
|
||||
const exists = vi
|
||||
.fn()
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* v1.42 (#959) — managed-profile handover tokens.
|
||||
*
|
||||
* A Guardian mints one of these to hand a managed profile over to the person
|
||||
* it describes. The raw token (`hlp_<64 hex>`) is shown exactly once, in the
|
||||
* response that minted it; only its HMAC-SHA256 hash under
|
||||
* `API_TOKEN_HMAC_KEY` is stored, the same scheme as `ApiToken`,
|
||||
* `ClinicianShareLink` and the registration invite (`invite-token.ts`). A
|
||||
* database leak therefore never yields a usable link, and the keyed hash makes
|
||||
* the unique-index lookup the timing-safe comparison: without the key nobody
|
||||
* can construct a preimage to probe the index byte by byte.
|
||||
*
|
||||
* The shape gate below is restated in `src/proxy.ts` (the `/claim/` edge
|
||||
* redirect), because that module must not pull in the Prisma client this one
|
||||
* does not need either. Keep the two in step.
|
||||
*/
|
||||
import { randomBytes, timingSafeEqual } from "node:crypto";
|
||||
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
|
||||
/** Raw-token prefix — distinct from every other `hl*_` credential. */
|
||||
export const HANDOVER_TOKEN_PREFIX = "hlp_";
|
||||
|
||||
const HANDOVER_TOKEN_SHAPE = /^hlp_[0-9a-f]{64}$/;
|
||||
|
||||
/** Generate a fresh raw handover token. 32 random bytes → 64 hex chars. */
|
||||
export function generateHandoverToken(): string {
|
||||
return `${HANDOVER_TOKEN_PREFIX}${randomBytes(32).toString("hex")}`;
|
||||
}
|
||||
|
||||
/** Cheap shape gate before paying the HMAC and the database lookup. */
|
||||
export function looksLikeHandoverToken(value: string): boolean {
|
||||
return HANDOVER_TOKEN_SHAPE.test(value);
|
||||
}
|
||||
|
||||
/** The stored form of a raw token. */
|
||||
export function hashHandoverToken(rawToken: string): string {
|
||||
return hashToken(rawToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare a stored hash with the hash of a presented token in constant time.
|
||||
*
|
||||
* The row was found through the unique index on exactly this hash, so the two
|
||||
* are equal whenever this runs. It is kept anyway: it costs nothing, and it is
|
||||
* what keeps a later change to the lookup (a prefix scan, a cache) from
|
||||
* turning into a byte-by-byte comparison without anybody deciding that.
|
||||
*/
|
||||
export function handoverHashMatches(
|
||||
stored: string,
|
||||
presented: string,
|
||||
): boolean {
|
||||
const a = Buffer.from(stored, "utf8");
|
||||
const b = Buffer.from(presented, "utf8");
|
||||
return a.length === b.length && timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
/**
|
||||
* The link a Guardian hands over: `<origin>/claim/<hlp_token>`.
|
||||
*
|
||||
* Origin precedence matches `buildInviteUrl`: the operator-configured
|
||||
* `APP_URL` / `NEXT_PUBLIC_APP_URL` win over the request origin, which behind
|
||||
* a reverse proxy may be an internal hostname.
|
||||
*/
|
||||
export function buildHandoverUrl(rawToken: string, requestUrl: string): string {
|
||||
const candidates = [
|
||||
process.env.APP_URL,
|
||||
process.env.NEXT_PUBLIC_APP_URL,
|
||||
requestUrl,
|
||||
]
|
||||
.map((value) => value?.trim())
|
||||
.filter((value): value is string => Boolean(value));
|
||||
|
||||
let origin = "http://localhost:3000";
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
origin = new URL(candidate).origin;
|
||||
break;
|
||||
} catch {
|
||||
// try the next candidate
|
||||
}
|
||||
}
|
||||
return `${origin}/claim/${encodeURIComponent(rawToken)}`;
|
||||
}
|
||||
@@ -393,6 +393,11 @@ export async function deriveUniqueUsername(
|
||||
): Promise<string> {
|
||||
const localPart = email.split("@")[0] ?? "user";
|
||||
let base = localPart.replace(/[^a-zA-Z0-9_-]/g, "");
|
||||
// v1.42 (#959) — `managed-` is the generated name of a profile nobody signs
|
||||
// into, and no self-named account may carry it (`accountUsernameSchema`).
|
||||
while (base.toLowerCase().startsWith("managed-")) {
|
||||
base = base.slice("managed-".length);
|
||||
}
|
||||
if (base.length < 3) base = `${base}user`.slice(0, 3).padEnd(3, "0");
|
||||
base = base.slice(0, 26); // leave room for a numeric suffix up to 30 chars
|
||||
|
||||
|
||||
@@ -112,38 +112,57 @@ export async function withdrawConsent(
|
||||
now: Date = new Date(),
|
||||
purge: typeof purgeRegenerableAiText = purgeRegenerableAiText,
|
||||
): Promise<ConsentWithdrawal> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const revoked: ConsentWithdrawal["revoked"] = [];
|
||||
for (const kind of kinds) {
|
||||
// The partial unique index keeps at most one active row per (user,
|
||||
// kind), so one conditional update revokes "the" active receipt; a
|
||||
// concurrent revoke finds nothing left to match and writes nothing.
|
||||
const updated = await tx.consentReceipt.updateMany({
|
||||
where: { userId, kind, revokedAt: null },
|
||||
data: { revokedAt: now },
|
||||
});
|
||||
if (updated.count === 0) continue;
|
||||
const receipt = await tx.consentReceipt.findFirst({
|
||||
where: { userId, kind, revokedAt: now },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
if (receipt) revoked.push({ kind, receipt });
|
||||
}
|
||||
return prisma.$transaction((tx) =>
|
||||
withdrawConsentInTransaction(tx, userId, kinds, now, purge),
|
||||
);
|
||||
}
|
||||
|
||||
const touchedAnalysis = revoked.some(({ kind }) =>
|
||||
INSIGHTS_CONSENT_KINDS.includes(kind),
|
||||
);
|
||||
if (!touchedAnalysis) return { revoked, purged: null };
|
||||
/**
|
||||
* The same withdrawal inside a caller's transaction.
|
||||
*
|
||||
* v1.42 (#959) — a managed profile's AI consent was given by its Guardian on
|
||||
* the person's behalf. When the person claims the profile, every active
|
||||
* receipt is withdrawn in the claim's own transaction, so the new owner starts
|
||||
* from no consent and gives it themselves, and the text written under the
|
||||
* Guardian's consent goes with it exactly as on any other withdrawal.
|
||||
*/
|
||||
export async function withdrawConsentInTransaction(
|
||||
tx: Prisma.TransactionClient,
|
||||
userId: string,
|
||||
kinds: readonly ConsentKind[],
|
||||
now: Date = new Date(),
|
||||
purge: typeof purgeRegenerableAiText = purgeRegenerableAiText,
|
||||
): Promise<ConsentWithdrawal> {
|
||||
const revoked: ConsentWithdrawal["revoked"] = [];
|
||||
for (const kind of kinds) {
|
||||
// The partial unique index keeps at most one active row per (user,
|
||||
// kind), so one conditional update revokes "the" active receipt; a
|
||||
// concurrent revoke finds nothing left to match and writes nothing.
|
||||
const updated = await tx.consentReceipt.updateMany({
|
||||
where: { userId, kind, revokedAt: null },
|
||||
data: { revokedAt: now },
|
||||
});
|
||||
if (updated.count === 0) continue;
|
||||
const receipt = await tx.consentReceipt.findFirst({
|
||||
where: { userId, kind, revokedAt: now },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
if (receipt) revoked.push({ kind, receipt });
|
||||
}
|
||||
|
||||
const stillCovered = await tx.consentReceipt.count({
|
||||
where: {
|
||||
userId,
|
||||
revokedAt: null,
|
||||
kind: { in: [...INSIGHTS_CONSENT_KINDS] },
|
||||
},
|
||||
});
|
||||
if (stillCovered > 0) return { revoked, purged: null };
|
||||
const touchedAnalysis = revoked.some(({ kind }) =>
|
||||
INSIGHTS_CONSENT_KINDS.includes(kind),
|
||||
);
|
||||
if (!touchedAnalysis) return { revoked, purged: null };
|
||||
|
||||
return { revoked, purged: await purge(tx, userId) };
|
||||
const stillCovered = await tx.consentReceipt.count({
|
||||
where: {
|
||||
userId,
|
||||
revokedAt: null,
|
||||
kind: { in: [...INSIGHTS_CONSENT_KINDS] },
|
||||
},
|
||||
});
|
||||
if (stillCovered > 0) return { revoked, purged: null };
|
||||
|
||||
return { revoked, purged: await purge(tx, userId) };
|
||||
}
|
||||
@@ -119,6 +119,7 @@ describe("redactSecrets", () => {
|
||||
|
||||
it.each([
|
||||
["an invite token", `hlv_${"0a".repeat(32)}`],
|
||||
["a managed-profile handover token", `hlp_${"0a".repeat(32)}`],
|
||||
["a session secret", `hls_${"ab".repeat(32)}`],
|
||||
["an elevation", `hle_${"cd".repeat(32)}`],
|
||||
["a native sign-in handoff code", `hlh_${"Ab9_-".repeat(9)}Ab`],
|
||||
@@ -216,6 +217,40 @@ describe("redactSecrets", () => {
|
||||
// v1.11.0 (Epic C, C6) — the raw `hls_` clinician share token rides as
|
||||
// the trailing path segment of `/c/<token>`. It must be scrubbed from
|
||||
// `http.path` / `http.route` before the Wide Event leaves the process.
|
||||
// v1.42 (#959) — the handover link `/claim/<hlp_token>`.
|
||||
it("registers the handover-link prefix in PATH_SECRET_PATHS", () => {
|
||||
expect(PATH_SECRET_PATHS.map((entry) => entry.prefix)).toContain(
|
||||
"/claim/",
|
||||
);
|
||||
});
|
||||
|
||||
it("redacts any segment on the handover-link path, token-shaped or not", () => {
|
||||
expect(redactSecrets(`/claim/hlp_${"ab".repeat(32)}`)).toBe(
|
||||
"/claim/[REDACTED]",
|
||||
);
|
||||
expect(redactSecrets("/claim/not-yet-a-token")).toBe("/claim/[REDACTED]");
|
||||
});
|
||||
|
||||
it("redacts the handover token in the claim page's query string", () => {
|
||||
expect(redactSecrets(`/auth/claim?token=hlp_${"ab".repeat(32)}`)).toBe(
|
||||
"/auth/claim?token=[REDACTED]",
|
||||
);
|
||||
});
|
||||
|
||||
it("redacts the handover token in an absolute URL by its shape", () => {
|
||||
const token = `hlp_${"cd".repeat(32)}`;
|
||||
expect(
|
||||
redactSecrets(`https://app.healthlog.dev/claim/${token}`),
|
||||
).not.toContain(token);
|
||||
});
|
||||
|
||||
it("keeps the claim API routes' own path segments readable", () => {
|
||||
expect(redactSecrets("/api/auth/claim/preview")).toBe(
|
||||
"/api/auth/claim/preview",
|
||||
);
|
||||
expect(redactSecrets("/api/auth/claim")).toBe("/api/auth/claim");
|
||||
});
|
||||
|
||||
it("registers the clinician share-view prefix in PATH_SECRET_PATHS", () => {
|
||||
expect(PATH_SECRET_PATHS.map((entry) => entry.prefix)).toContain("/c/");
|
||||
});
|
||||
|
||||
@@ -37,7 +37,16 @@
|
||||
* Add a new entry whenever a route exposes a secret in its URL path
|
||||
* (rather than headers, body, or query). Tested in `redact.test.ts`.
|
||||
*/
|
||||
export const PATH_SECRET_PATHS: ReadonlyArray<{ prefix: string }> = [
|
||||
export const PATH_SECRET_PATHS: ReadonlyArray<{
|
||||
prefix: string;
|
||||
/**
|
||||
* Match the prefix only where a path STARTS with it, not inside a longer
|
||||
* path. Needed when the prefix is also the tail of an unrelated route:
|
||||
* `/claim/` is both the handover link and the end of `/api/auth/claim/`,
|
||||
* whose `preview` segment is a route name, not a secret.
|
||||
*/
|
||||
pathStart?: true;
|
||||
}> = [
|
||||
// Withings webhook entrypoint: `WITHINGS_WEBHOOK_SECRET` travels as
|
||||
// the trailing path segment (v1.4.25 W17a). Without this rule the
|
||||
// secret lands in every Wide Event's `http.path` / `http.route`.
|
||||
@@ -51,17 +60,26 @@ export const PATH_SECRET_PATHS: ReadonlyArray<{ prefix: string }> = [
|
||||
// the trailing path segment (mirrors Withings). Without this rule the
|
||||
// secret lands in every Wide Event's `http.path` / `http.route`.
|
||||
{ prefix: "/api/whoop/webhook/" },
|
||||
// v1.42 (#959) — managed-profile handover link. The raw `hlp_` token rides
|
||||
// as the trailing path segment of `/claim/<token>` until the proxy turns it
|
||||
// into `/auth/claim?token=…` (scrubbed by the `token=` query rule below);
|
||||
// it is a one-time credential that turns a profile into an account.
|
||||
// `pathStart`, because `/api/auth/claim/preview` ends in the same prefix
|
||||
// and its last segment is a route name. A full URL carrying the token is
|
||||
// still scrubbed, by the `hlp_` rule in `redactSecrets`.
|
||||
{ prefix: "/claim/", pathStart: true },
|
||||
];
|
||||
|
||||
function redactPathSegments(input: string): string {
|
||||
let out = input;
|
||||
for (const { prefix } of PATH_SECRET_PATHS) {
|
||||
for (const { prefix, pathStart } of PATH_SECRET_PATHS) {
|
||||
// Match `<prefix><segment>` where `<segment>` is one or more
|
||||
// non-`/` non-`?` chars, then preserve anything after (next path
|
||||
// segment, query string, hash). Capture group 1 is the optional
|
||||
// trailing context starting with `/`, `?`, or `#`.
|
||||
const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const re = new RegExp(`${escaped}[^/?#\\s]+([/?#][^\\s]*)?`, "g");
|
||||
const start = pathStart ? "(?<![A-Za-z0-9_.-])" : "";
|
||||
const re = new RegExp(`${start}${escaped}[^/?#\\s]+([/?#][^\\s]*)?`, "g");
|
||||
out = out.replace(re, (_match, tail: string | undefined) => {
|
||||
return `${prefix}[REDACTED]${tail ?? ""}`;
|
||||
});
|
||||
@@ -85,12 +103,13 @@ export function redactSecrets(input: string): string {
|
||||
.replace(/(^|[^A-Za-z0-9])sk-(?:ant-)?[A-Za-z0-9_-]{8,}/g, "$1[REDACTED]")
|
||||
// HealthLog hex-bodied credentials: `hlk_` access token, `hlr_` refresh
|
||||
// token, `hls_` session cookie secret (v1.30.32) and clinician share
|
||||
// token, `hle_` step-up elevation (v1.30.34), `hlv_` registration invite.
|
||||
// token, `hle_` step-up elevation (v1.30.34), `hlv_` registration invite,
|
||||
// `hlp_` managed-profile handover link (v1.42).
|
||||
// Same boundary-aware shape as the `sk-` rule. The
|
||||
// idempotency replay-cache already rejects bodies containing
|
||||
// these prefixes (CLAUDE.md headless-client-API patterns); this
|
||||
// is the matching egress guard for log/error surfaces.
|
||||
.replace(/(^|[^A-Za-z0-9])hl[kresv]_[A-Fa-f0-9]{32,}/g, "$1[REDACTED]")
|
||||
.replace(/(^|[^A-Za-z0-9])hl[kpresv]_[A-Fa-f0-9]{32,}/g, "$1[REDACTED]")
|
||||
// v1.22.0 — the MCP OAuth bridge artifacts: the authorization code
|
||||
// (`hlac_`), refresh token (`hlrt_`), and DCR client id (`hlc_`). All
|
||||
// are signed, base64url-bodied, and credential-bearing, so they are
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* v1.42 (#959) — the pure parts of the managed-profile handover: the token
|
||||
* shape, the access policy, the request schemas and the stored-state reader.
|
||||
*/
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildHandoverUrl,
|
||||
generateHandoverToken,
|
||||
handoverHashMatches,
|
||||
hashHandoverToken,
|
||||
looksLikeHandoverToken,
|
||||
} from "@/lib/auth/handover-token";
|
||||
import {
|
||||
DEFAULT_HANDOVER_ACCESS,
|
||||
grantAccessFor,
|
||||
handoverAccessOf,
|
||||
isHandoverMinor,
|
||||
} from "@/lib/managed-profiles/handover-access";
|
||||
import { parseHandoverState } from "@/lib/managed-profiles/handover";
|
||||
import {
|
||||
claimSchema,
|
||||
createHandoverSchema,
|
||||
handoverDecisionSchema,
|
||||
} from "@/lib/validations/managed-profile-handover";
|
||||
import { registerSchema } from "@/lib/validations/auth";
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.API_TOKEN_HMAC_KEY ??= "x".repeat(48);
|
||||
});
|
||||
|
||||
describe("handover token", () => {
|
||||
it("mints hlp_ plus 64 lowercase hex characters, fresh every time", () => {
|
||||
const a = generateHandoverToken();
|
||||
const b = generateHandoverToken();
|
||||
expect(a).toMatch(/^hlp_[0-9a-f]{64}$/);
|
||||
expect(looksLikeHandoverToken(a)).toBe(true);
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it.each([
|
||||
`hlv_${"a".repeat(64)}`,
|
||||
`hlp_${"A".repeat(64)}`,
|
||||
`hlp_${"a".repeat(63)}`,
|
||||
`hlp_${"a".repeat(65)}`,
|
||||
"",
|
||||
])("refuses %s at the shape gate", (value) => {
|
||||
expect(looksLikeHandoverToken(value)).toBe(false);
|
||||
});
|
||||
|
||||
it("stores a keyed hash, never the token, and compares it in constant time", () => {
|
||||
const token = generateHandoverToken();
|
||||
const hash = hashHandoverToken(token);
|
||||
expect(hash).not.toContain(token.slice(4));
|
||||
expect(handoverHashMatches(hash, hashHandoverToken(token))).toBe(true);
|
||||
expect(
|
||||
handoverHashMatches(hash, hashHandoverToken(generateHandoverToken())),
|
||||
).toBe(false);
|
||||
expect(handoverHashMatches(hash, "short")).toBe(false);
|
||||
});
|
||||
|
||||
it("builds the link on the configured origin, not the request's", () => {
|
||||
const previous = process.env.APP_URL;
|
||||
process.env.APP_URL = "https://health.example.org/some/path";
|
||||
try {
|
||||
expect(buildHandoverUrl("hlp_x", "http://internal:3000/api/x")).toBe(
|
||||
"https://health.example.org/claim/hlp_x",
|
||||
);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.APP_URL;
|
||||
else process.env.APP_URL = previous;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("handover access policy", () => {
|
||||
it("proposes view-only when nobody says otherwise", () => {
|
||||
expect(DEFAULT_HANDOVER_ACCESS).toBe("read");
|
||||
});
|
||||
|
||||
it("maps each level to a grant level, and end to none", () => {
|
||||
expect(grantAccessFor("end")).toBeNull();
|
||||
expect(grantAccessFor("read")).toBe("READ");
|
||||
expect(grantAccessFor("manage")).toBe("MANAGE");
|
||||
expect(handoverAccessOf(null)).toBe("end");
|
||||
expect(handoverAccessOf({ access: "WRITE" })).toBe("read");
|
||||
expect(handoverAccessOf({ access: "MANAGE" })).toBe("manage");
|
||||
});
|
||||
|
||||
it("flags a minor by the birthday, not the birth year", () => {
|
||||
const today = new Date("2026-10-08T12:00:00Z");
|
||||
expect(isHandoverMinor("2010-10-09", today)).toBe(true);
|
||||
expect(isHandoverMinor("2010-10-08", today)).toBe(false);
|
||||
expect(isHandoverMinor(null, today)).toBe(false);
|
||||
expect(isHandoverMinor("not-a-date", today)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handover request schemas", () => {
|
||||
it("defaults the link to seven days and refuses other lifetimes", () => {
|
||||
expect(createHandoverSchema.parse({}).expiresInDays).toBe(7);
|
||||
expect(createHandoverSchema.safeParse({ expiresInDays: 30 }).success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a guardian named twice and unknown fields", () => {
|
||||
const twice = createHandoverSchema.safeParse({
|
||||
proposals: [
|
||||
{ grantId: "g1", proposal: "read" },
|
||||
{ grantId: "g1", proposal: "end" },
|
||||
],
|
||||
});
|
||||
expect(twice.success).toBe(false);
|
||||
expect(
|
||||
createHandoverSchema.safeParse({ proposals: [], profileId: "p" }).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
handoverDecisionSchema.safeParse({
|
||||
decisions: [
|
||||
{ grantId: "g1", access: "read" },
|
||||
{ grantId: "g1", access: "manage" },
|
||||
],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("requires an email and refuses the managed- prefix on a claim", () => {
|
||||
const base = {
|
||||
token: "hlp_x",
|
||||
username: "alex",
|
||||
email: "alex@example.test",
|
||||
password: "a long enough passphrase",
|
||||
};
|
||||
expect(claimSchema.safeParse(base).success).toBe(true);
|
||||
expect(claimSchema.safeParse({ ...base, email: undefined }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
claimSchema.safeParse({ ...base, username: "Managed-alex" }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses the managed- prefix on registration too", () => {
|
||||
expect(
|
||||
registerSchema.safeParse({
|
||||
email: "a@example.test",
|
||||
username: "managed-0123",
|
||||
password: "a long enough passphrase",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseHandoverState", () => {
|
||||
const proposals = [{ grantId: "g1", guardianId: "u1", proposal: "read" }];
|
||||
|
||||
it("reads the shape it writes", () => {
|
||||
const state = parseHandoverState({
|
||||
version: 1,
|
||||
proposals,
|
||||
claim: {
|
||||
claimedAt: "2026-10-08T00:00:00.000Z",
|
||||
decidedAt: null,
|
||||
guardians: [
|
||||
{
|
||||
grantId: "g1",
|
||||
guardianId: "u1",
|
||||
proposal: "read",
|
||||
applied: "read",
|
||||
currentGrantId: "g2",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(state?.claim?.guardians[0].currentGrantId).toBe("g2");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a later version", { version: 2, proposals }],
|
||||
[
|
||||
"an unknown level",
|
||||
{ version: 1, proposals: [{ ...proposals[0], proposal: "write" }] },
|
||||
],
|
||||
[
|
||||
"a missing guardian",
|
||||
{ version: 1, proposals: [{ grantId: "g1", proposal: "read" }] },
|
||||
],
|
||||
["an array", [proposals]],
|
||||
["null", null],
|
||||
])("refuses %s rather than guessing", (_label, value) => {
|
||||
expect(parseHandoverState(value)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* v1.42 (#959) — the refusals both anonymous claim routes give before they
|
||||
* look at a token, in one place so the preview and the claim cannot disagree.
|
||||
*
|
||||
* Order matters and is the register route's order:
|
||||
*
|
||||
* 1. A single-sign-on-only instance admits no password, and the claim sets
|
||||
* one, so the handover is refused outright in v1.42.
|
||||
* 2. A signed-in caller is refused with 409. Claiming creates a session for
|
||||
* the new owner, and a Guardian testing their own link would otherwise be
|
||||
* signed out of their account and into the person's. Refused before the
|
||||
* rate limit: it is a state, not an attempt, and spending the bucket on it
|
||||
* would lock out the person the link is for, who may share the address.
|
||||
* 3. The per-address rate limit, the anonymous auth-surface pattern: a
|
||||
* misconfigured proxy trust chain collapses every caller into one tight
|
||||
* bucket rather than one free-for-all.
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { isOidcOnly } from "@/lib/auth/oidc";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
|
||||
/** The one answer for every unusable link. Not an oracle. */
|
||||
export function claimInvalid() {
|
||||
return apiError("This link is invalid, expired or already used", 404, {
|
||||
errorCode: "profile_claim.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
export async function claimGate(
|
||||
request: Request,
|
||||
surface: "preview" | "claim",
|
||||
/**
|
||||
* Whether the request carries a live session. Resolved by the ROUTE with
|
||||
* `getSession()` and handed in, rather than resolved here: a session read
|
||||
* inside a `lib/` wrapper would take both routes off the direct
|
||||
* session-resolution list in `session-surface-guard.test.ts`, which is
|
||||
* exactly the disappearance that guard exists to catch.
|
||||
*/
|
||||
signedIn: boolean,
|
||||
): Promise<{ refusal: Response } | { refusal: null; ip: string | null }> {
|
||||
if (isOidcOnly()) {
|
||||
return {
|
||||
refusal: apiError(
|
||||
"Taking over a profile is not available while this server allows single sign-on only",
|
||||
403,
|
||||
{ errorCode: "profile_claim.oidc_only_unsupported" },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (signedIn) {
|
||||
annotate({
|
||||
action: { name: `profile_claim.${surface}.refused_authenticated` },
|
||||
});
|
||||
return {
|
||||
refusal: apiError("Already signed in — sign out first", 409, {
|
||||
errorCode: "auth.already_authenticated",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const rl =
|
||||
surface === "preview"
|
||||
? await checkAuthSurfaceRateLimit(
|
||||
request,
|
||||
"auth:claim-preview",
|
||||
20,
|
||||
15 * 60 * 1000,
|
||||
)
|
||||
: await checkAuthSurfaceRateLimit(
|
||||
request,
|
||||
"auth:claim",
|
||||
5,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return {
|
||||
refusal: NextResponse.json(
|
||||
{ data: null, error: "Too many attempts. Please try again later." },
|
||||
{ status: 429, headers: rateLimitHeaders(rl) },
|
||||
),
|
||||
};
|
||||
}
|
||||
return { refusal: null, ip: rl.ip };
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* v1.42 (#959) — what happens to each Guardian's access when a managed profile
|
||||
* is handed over. Every rule about that choice lives in this file, and only
|
||||
* here, so that changing the policy is one edit:
|
||||
*
|
||||
* - the three levels a Guardian can end up with;
|
||||
* - the level a Guardian is proposed when nobody says otherwise;
|
||||
* - which levels the issuing Guardian may propose, and which the new owner
|
||||
* may then pick;
|
||||
* - what each level means as an `AccountGrant` row.
|
||||
*
|
||||
* The flow it serves (option C): the Guardian who mints the link proposes a
|
||||
* level per Guardian. Claiming applies the proposals at once, so the record is
|
||||
* never left without a decision. The new owner then sees each Guardian's access
|
||||
* and decides finally; until they do, the proposed level is what holds.
|
||||
*
|
||||
* No Prisma and no server imports: the settings dialog, the claim page and the
|
||||
* decision screen read the same constants the server enforces.
|
||||
*/
|
||||
|
||||
/** `end` removes the Guardian; `read` leaves view-only; `manage` keeps all. */
|
||||
export const HANDOVER_ACCESS_LEVELS = ["end", "read", "manage"] as const;
|
||||
|
||||
export type HandoverAccess = (typeof HANDOVER_ACCESS_LEVELS)[number];
|
||||
|
||||
/**
|
||||
* The proposal a Guardian gets when the issuing Guardian named none for them,
|
||||
* including somebody who became a Guardian after the link was minted.
|
||||
*
|
||||
* View-only rather than either extreme: it keeps the people who looked after
|
||||
* the record able to see it (continuity of care), and it takes away every
|
||||
* act on it, so nothing is done in the new owner's name until they decide.
|
||||
*/
|
||||
export const DEFAULT_HANDOVER_ACCESS: HandoverAccess = "read";
|
||||
|
||||
/** The levels the issuing Guardian may propose. */
|
||||
export const PROPOSABLE_HANDOVER_ACCESS: readonly HandoverAccess[] = [
|
||||
...HANDOVER_ACCESS_LEVELS,
|
||||
];
|
||||
|
||||
/**
|
||||
* The levels the new owner may choose for a Guardian on the decision screen.
|
||||
*
|
||||
* Every level, independent of the proposal: a proposal can never bind the
|
||||
* person whose record it is. Raising a Guardian back to `manage` re-creates
|
||||
* access that Guardian already held and accepted, so it needs no new
|
||||
* acceptance from them — and it can only reach the level they held, because
|
||||
* every Guardian held `manage`.
|
||||
*/
|
||||
export const OWNER_CHOOSABLE_HANDOVER_ACCESS: readonly HandoverAccess[] = [
|
||||
...HANDOVER_ACCESS_LEVELS,
|
||||
];
|
||||
|
||||
/** The grant level a handover access maps to, or null for `end`. */
|
||||
export function grantAccessFor(
|
||||
access: HandoverAccess,
|
||||
): "READ" | "MANAGE" | null {
|
||||
switch (access) {
|
||||
case "end":
|
||||
return null;
|
||||
case "read":
|
||||
return "READ";
|
||||
case "manage":
|
||||
return "MANAGE";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The handover level a live grant reads as.
|
||||
*
|
||||
* WRITE cannot arise from a handover, but a grant the owner re-shaped through
|
||||
* the ordinary sharing panel can be anything; it reads as `read` here because
|
||||
* this vocabulary has nothing between view and manage, and the decision screen
|
||||
* shows such a row as already settled rather than offering to change it.
|
||||
*/
|
||||
export function handoverAccessOf(
|
||||
grant: { access: "READ" | "WRITE" | "MANAGE" } | null,
|
||||
): HandoverAccess {
|
||||
if (grant === null) return "end";
|
||||
return grant.access === "MANAGE" ? "manage" : "read";
|
||||
}
|
||||
|
||||
/** The age below which the Guardian dialog shows the minor hint. */
|
||||
export const HANDOVER_MINOR_AGE = 16;
|
||||
|
||||
/**
|
||||
* Whether a date of birth (`yyyy-MM-dd`) is under {@link HANDOVER_MINOR_AGE}
|
||||
* on `today`. A hint only: there is no age gate, because the age of majority
|
||||
* differs between countries and the Guardian knows the person.
|
||||
*/
|
||||
export function isHandoverMinor(
|
||||
dateOfBirth: string | null,
|
||||
today: Date = new Date(),
|
||||
): boolean {
|
||||
if (!dateOfBirth) return false;
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateOfBirth);
|
||||
if (!match) return false;
|
||||
const [year, month, day] = [
|
||||
Number(match[1]),
|
||||
Number(match[2]),
|
||||
Number(match[3]),
|
||||
];
|
||||
let age = today.getUTCFullYear() - year;
|
||||
const beforeBirthday =
|
||||
today.getUTCMonth() + 1 < month ||
|
||||
(today.getUTCMonth() + 1 === month && today.getUTCDate() < day);
|
||||
if (beforeBirthday) age -= 1;
|
||||
return age < HANDOVER_MINOR_AGE;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* v1.42 (#959) — telling each former Guardian what a handover did to their
|
||||
* access.
|
||||
*
|
||||
* Addressed to the Guardian's OWN account and channels. The managed-record
|
||||
* fan-out (`delivery-identity.ts`) is closed to this event on purpose, and it
|
||||
* no longer applies anyway: the record stopped being managed in the claim.
|
||||
*
|
||||
* Fire-and-forget after the commit. A notification that cannot be delivered
|
||||
* must never undo a handover that already happened, and nothing here waits on
|
||||
* a provider.
|
||||
*/
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import type { HandoverAccess } from "@/lib/managed-profiles/handover-access";
|
||||
import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised";
|
||||
|
||||
/** The sentence for an access level, as literal keys the i18n guards can see. */
|
||||
function accessMessageKey(
|
||||
kind: "claimed" | "changed",
|
||||
access: HandoverAccess,
|
||||
): string {
|
||||
if (kind === "claimed") {
|
||||
switch (access) {
|
||||
case "end":
|
||||
return "notifications.handover.claimedEnd";
|
||||
case "read":
|
||||
return "notifications.handover.claimedRead";
|
||||
case "manage":
|
||||
return "notifications.handover.claimedManage";
|
||||
}
|
||||
}
|
||||
switch (access) {
|
||||
case "end":
|
||||
return "notifications.handover.changedEnd";
|
||||
case "read":
|
||||
return "notifications.handover.changedRead";
|
||||
case "manage":
|
||||
return "notifications.handover.changedManage";
|
||||
}
|
||||
}
|
||||
|
||||
export function notifyGuardiansOfHandover(
|
||||
kind: "claimed" | "changed",
|
||||
recordName: string,
|
||||
guardians: { guardianId: string; access: HandoverAccess }[],
|
||||
): void {
|
||||
for (const guardian of guardians) {
|
||||
void dispatchLocalisedNotification({
|
||||
userId: guardian.guardianId,
|
||||
titleKey:
|
||||
kind === "claimed"
|
||||
? "notifications.handover.claimedTitle"
|
||||
: "notifications.handover.changedTitle",
|
||||
messageKey: accessMessageKey(kind, guardian.access),
|
||||
params: { name: recordName },
|
||||
}).catch((err: unknown) => {
|
||||
getEvent()?.addWarning(
|
||||
`handover notification failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,877 @@
|
||||
/**
|
||||
* v1.42 (#959) — handing a managed profile over to the person it describes.
|
||||
*
|
||||
* A managed profile is a `User` row nobody signs into, kept by its Guardians.
|
||||
* The handover turns it into that person's own account. No data moves: every
|
||||
* reading already hangs off the profile's own id. What changes is who can sign
|
||||
* in to it and who else keeps access.
|
||||
*
|
||||
* The flow, in the order it happens:
|
||||
*
|
||||
* 1. A Guardian, with a fresh second factor, mints a one-time link and
|
||||
* proposes a level per Guardian (`handover-access.ts`). Only the HMAC of
|
||||
* the token is stored; at most one link per profile is open, and minting
|
||||
* a new one withdraws the old.
|
||||
* 2. The person opens the link, sees a minimal preview (the profile's name,
|
||||
* the link's expiry, each Guardian's proposed access — no health data),
|
||||
* and chooses a username, an email and a password.
|
||||
* 3. The claim runs as ONE transaction under the managed-profile lock: the
|
||||
* token is consumed, the credentials are set, the marker is cleared,
|
||||
* every pending invitation the record has offered is withdrawn, each
|
||||
* Guardian's access is set to its proposal, the disclaimer, onboarding
|
||||
* and AI consent are reset so the new owner gives them personally, and
|
||||
* every step is audited. A failure anywhere leaves the profile managed
|
||||
* and the link unused.
|
||||
* 4. On first sign-in the new owner sees each Guardian's access and decides
|
||||
* finally. Until they do, the proposal is what holds.
|
||||
*
|
||||
* Every refusal on the anonymous side is the same `invalid`, so the preview
|
||||
* and the claim cannot be used to learn whether a token, a profile or a
|
||||
* Guardian exists.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
generateHandoverToken,
|
||||
handoverHashMatches,
|
||||
hashHandoverToken,
|
||||
looksLikeHandoverToken,
|
||||
} from "@/lib/auth/handover-token";
|
||||
import { withdrawConsentInTransaction } from "@/lib/consent/withdrawal";
|
||||
import { consentKindEnum } from "@/lib/validations/consent";
|
||||
import {
|
||||
activeGuardianWhere,
|
||||
clearManagedProfileMarker,
|
||||
withManagedProfileLock,
|
||||
} from "@/lib/managed-profiles/lifecycle";
|
||||
import {
|
||||
DEFAULT_HANDOVER_ACCESS,
|
||||
HANDOVER_ACCESS_LEVELS,
|
||||
handoverAccessOf,
|
||||
type HandoverAccess,
|
||||
} from "@/lib/managed-profiles/handover-access";
|
||||
import {
|
||||
isGrantActive,
|
||||
settleHandoverGuardianGrant,
|
||||
withdrawPendingGrantsOfRecord,
|
||||
} from "@/lib/sharing/grants";
|
||||
import { isP2002 } from "@/lib/prisma-errors";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
type Transaction = Prisma.TransactionClient;
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export type HandoverErrorCode =
|
||||
/** No such managed profile, or the caller is not one of its Guardians. */
|
||||
| "not_found"
|
||||
/** A proposal names a grant that is not an active Guardian of the profile. */
|
||||
| "unknown_guardian"
|
||||
/** Anonymous side: no usable link behind the token. Every cause. */
|
||||
| "invalid"
|
||||
/** The username or email is already taken. */
|
||||
| "taken"
|
||||
/** The new owner has no handover decision waiting. */
|
||||
| "no_pending";
|
||||
|
||||
export class HandoverError extends Error {
|
||||
constructor(readonly code: HandoverErrorCode) {
|
||||
super(`managed profile handover refused: ${code}`);
|
||||
this.name = "HandoverError";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stored state ────────────────────────────────────────────────────────────
|
||||
|
||||
/** One Guardian's proposal, as the issuing Guardian recorded it. */
|
||||
interface StoredProposal {
|
||||
/** The Guardian's MANAGE grant when the link was minted. */
|
||||
grantId: string;
|
||||
guardianId: string;
|
||||
proposal: HandoverAccess;
|
||||
}
|
||||
|
||||
/** One Guardian's access across the claim and the owner's decision. */
|
||||
interface StoredClaimEntry {
|
||||
/** The Guardian's MANAGE grant at the moment of the claim. */
|
||||
grantId: string;
|
||||
guardianId: string;
|
||||
proposal: HandoverAccess;
|
||||
/** What the claim set. */
|
||||
applied: HandoverAccess;
|
||||
/** The live grant the handover last wrote or kept, or null for `end`. */
|
||||
currentGrantId: string | null;
|
||||
/** The new owner's final choice, once made. */
|
||||
decided?: HandoverAccess;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything `proposals_json` holds. Versioned, because the column outlives
|
||||
* the code that wrote it: a row from a later shape that this code cannot read
|
||||
* is treated as no handover at all, never guessed at.
|
||||
*/
|
||||
interface HandoverState {
|
||||
version: 1;
|
||||
proposals: StoredProposal[];
|
||||
claim?: {
|
||||
claimedAt: string;
|
||||
guardians: StoredClaimEntry[];
|
||||
decidedAt: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
function isAccess(value: unknown): value is HandoverAccess {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
(HANDOVER_ACCESS_LEVELS as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
function isString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.length > 0;
|
||||
}
|
||||
|
||||
/** Read the column, refusing anything it does not fully understand. */
|
||||
export function parseHandoverState(value: unknown): HandoverState | null {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const raw = value as Record<string, unknown>;
|
||||
if (raw.version !== 1 || !Array.isArray(raw.proposals)) return null;
|
||||
|
||||
const proposals: StoredProposal[] = [];
|
||||
for (const entry of raw.proposals) {
|
||||
const e = entry as Record<string, unknown> | null;
|
||||
if (!e || !isString(e.grantId) || !isString(e.guardianId)) return null;
|
||||
if (!isAccess(e.proposal)) return null;
|
||||
proposals.push({
|
||||
grantId: e.grantId,
|
||||
guardianId: e.guardianId,
|
||||
proposal: e.proposal,
|
||||
});
|
||||
}
|
||||
|
||||
if (raw.claim === undefined) return { version: 1, proposals };
|
||||
const claim = raw.claim as Record<string, unknown> | null;
|
||||
if (!claim || !isString(claim.claimedAt) || !Array.isArray(claim.guardians)) {
|
||||
return null;
|
||||
}
|
||||
if (claim.decidedAt !== null && !isString(claim.decidedAt)) return null;
|
||||
const guardians: StoredClaimEntry[] = [];
|
||||
for (const entry of claim.guardians) {
|
||||
const e = entry as Record<string, unknown> | null;
|
||||
if (!e || !isString(e.grantId) || !isString(e.guardianId)) return null;
|
||||
if (!isAccess(e.proposal) || !isAccess(e.applied)) return null;
|
||||
if (e.currentGrantId !== null && !isString(e.currentGrantId)) return null;
|
||||
if (e.decided !== undefined && !isAccess(e.decided)) return null;
|
||||
guardians.push({
|
||||
grantId: e.grantId,
|
||||
guardianId: e.guardianId,
|
||||
proposal: e.proposal,
|
||||
applied: e.applied,
|
||||
currentGrantId: e.currentGrantId,
|
||||
...(e.decided !== undefined ? { decided: e.decided } : {}),
|
||||
});
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
proposals,
|
||||
claim: {
|
||||
claimedAt: claim.claimedAt,
|
||||
guardians,
|
||||
decidedAt: claim.decidedAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toJson(state: HandoverState): Prisma.InputJsonValue {
|
||||
return state as unknown as Prisma.InputJsonValue;
|
||||
}
|
||||
|
||||
/** The proposal for a Guardian grant, or the default when none was named. */
|
||||
function proposalFor(state: HandoverState, grantId: string): HandoverAccess {
|
||||
return (
|
||||
state.proposals.find((entry) => entry.grantId === grantId)?.proposal ??
|
||||
DEFAULT_HANDOVER_ACCESS
|
||||
);
|
||||
}
|
||||
|
||||
async function isActiveGuardian(
|
||||
db: Pick<Transaction, "accountGrant">,
|
||||
profileId: string,
|
||||
guardianId: string,
|
||||
now: Date,
|
||||
): Promise<boolean> {
|
||||
const grant = await db.accountGrant.findFirst({
|
||||
where: {
|
||||
grantorId: profileId,
|
||||
granteeId: guardianId,
|
||||
...activeGuardianWhere(now),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
return grant !== null;
|
||||
}
|
||||
|
||||
function nameOf(user: { displayName: string | null; username: string }) {
|
||||
return user.displayName?.trim() || user.username;
|
||||
}
|
||||
|
||||
// ── Guardian side ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface CreatedHandover {
|
||||
id: string;
|
||||
/** The raw token. Returned once, here, and never stored. */
|
||||
token: string;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint the one-time link. Any active Guardian may, not only the profile's
|
||||
* creator: Guardians of one record hold the same standing.
|
||||
*
|
||||
* One proposal per active Guardian is recorded. A Guardian the caller left
|
||||
* out is proposed {@link DEFAULT_HANDOVER_ACCESS}; a proposal naming a grant
|
||||
* that is not an active Guardian of this profile is refused rather than
|
||||
* dropped, because it means the form and the roster disagree.
|
||||
*/
|
||||
export async function createHandover(input: {
|
||||
profileId: string;
|
||||
guardianId: string;
|
||||
expiresInDays: number;
|
||||
proposals: { grantId: string; proposal: HandoverAccess }[];
|
||||
now?: Date;
|
||||
}): Promise<CreatedHandover> {
|
||||
const now = input.now ?? new Date();
|
||||
return prisma.$transaction((tx) =>
|
||||
withManagedProfileLock(tx, input.profileId, async (profile) => {
|
||||
if (!profile?.managedProfileAt) throw new HandoverError("not_found");
|
||||
|
||||
const guardians = await tx.accountGrant.findMany({
|
||||
where: { grantorId: profile.id, ...activeGuardianWhere(now) },
|
||||
select: { id: true, granteeId: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
if (!guardians.some((g) => g.granteeId === input.guardianId)) {
|
||||
throw new HandoverError("not_found");
|
||||
}
|
||||
const known = new Set(guardians.map((g) => g.id));
|
||||
if (input.proposals.some((p) => !known.has(p.grantId))) {
|
||||
throw new HandoverError("unknown_guardian");
|
||||
}
|
||||
|
||||
const proposals: StoredProposal[] = guardians.map((g) => ({
|
||||
grantId: g.id,
|
||||
guardianId: g.granteeId,
|
||||
proposal:
|
||||
input.proposals.find((p) => p.grantId === g.id)?.proposal ??
|
||||
DEFAULT_HANDOVER_ACCESS,
|
||||
}));
|
||||
|
||||
// At most one open link per profile: the partial unique index enforces
|
||||
// it, and this is what keeps a second mint from colliding with it.
|
||||
const replaced = await tx.managedProfileHandover.updateMany({
|
||||
where: { profileId: profile.id, usedAt: null, revokedAt: null },
|
||||
data: { revokedAt: now },
|
||||
});
|
||||
|
||||
const token = generateHandoverToken();
|
||||
const expiresAt = new Date(now.getTime() + input.expiresInDays * DAY_MS);
|
||||
const row = await tx.managedProfileHandover.create({
|
||||
data: {
|
||||
profileId: profile.id,
|
||||
createdById: input.guardianId,
|
||||
tokenHash: hashHandoverToken(token),
|
||||
proposalsJson: toJson({ version: 1, proposals }),
|
||||
expiresAt,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
// Filed under the RECORD with the Guardian as the actor, like every
|
||||
// other act on a managed profile. No token, no names.
|
||||
await auditLog("managed_profile.handover.created", {
|
||||
userId: profile.id,
|
||||
actorUserId: input.guardianId,
|
||||
details: {
|
||||
handoverId: row.id,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
replaced: replaced.count,
|
||||
proposals: proposals.map((p) => ({
|
||||
grantId: p.grantId,
|
||||
proposal: p.proposal,
|
||||
})),
|
||||
},
|
||||
client: tx,
|
||||
});
|
||||
|
||||
return { id: row.id, token, expiresAt };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export interface HandoverStatus {
|
||||
open: {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
expiresAt: Date;
|
||||
createdByCaller: boolean;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The profile's open link, for a Guardian's panel. Never the link itself:
|
||||
* only its hash exists. `null` when the caller may not see the profile.
|
||||
*/
|
||||
export async function readHandoverStatus(input: {
|
||||
profileId: string;
|
||||
guardianId: string;
|
||||
now?: Date;
|
||||
}): Promise<HandoverStatus | null> {
|
||||
const now = input.now ?? new Date();
|
||||
const profile = await prisma.user.findUnique({
|
||||
where: { id: input.profileId },
|
||||
select: { managedProfileAt: true },
|
||||
});
|
||||
if (!profile?.managedProfileAt) return null;
|
||||
if (
|
||||
!(await isActiveGuardian(prisma, input.profileId, input.guardianId, now))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const open = await prisma.managedProfileHandover.findFirst({
|
||||
where: {
|
||||
profileId: input.profileId,
|
||||
usedAt: null,
|
||||
revokedAt: null,
|
||||
expiresAt: { gt: now },
|
||||
},
|
||||
select: { id: true, createdAt: true, expiresAt: true, createdById: true },
|
||||
});
|
||||
// A link whose creator has since lost access cannot be claimed; showing it
|
||||
// as open would promise something the claim refuses.
|
||||
if (
|
||||
!open ||
|
||||
!(await isActiveGuardian(prisma, input.profileId, open.createdById, now))
|
||||
) {
|
||||
return { open: null };
|
||||
}
|
||||
return {
|
||||
open: {
|
||||
id: open.id,
|
||||
createdAt: open.createdAt,
|
||||
expiresAt: open.expiresAt,
|
||||
createdByCaller: open.createdById === input.guardianId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Withdraw the open link. Any active Guardian may, with no step-up: taking a
|
||||
* link back must never be harder than handing it out.
|
||||
*/
|
||||
export async function revokeHandover(input: {
|
||||
profileId: string;
|
||||
guardianId: string;
|
||||
now?: Date;
|
||||
}): Promise<{ revoked: boolean }> {
|
||||
const now = input.now ?? new Date();
|
||||
return prisma.$transaction((tx) =>
|
||||
withManagedProfileLock(tx, input.profileId, async (profile) => {
|
||||
if (!profile?.managedProfileAt) throw new HandoverError("not_found");
|
||||
if (!(await isActiveGuardian(tx, profile.id, input.guardianId, now))) {
|
||||
throw new HandoverError("not_found");
|
||||
}
|
||||
const { count } = await tx.managedProfileHandover.updateMany({
|
||||
where: { profileId: profile.id, usedAt: null, revokedAt: null },
|
||||
data: { revokedAt: now },
|
||||
});
|
||||
if (count > 0) {
|
||||
await auditLog("managed_profile.handover.revoked", {
|
||||
userId: profile.id,
|
||||
actorUserId: input.guardianId,
|
||||
details: { count },
|
||||
client: tx,
|
||||
});
|
||||
}
|
||||
return { revoked: count > 0 };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Anonymous side ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The stored row behind a raw token, by keyed-hash lookup, or null. A token
|
||||
* that fails the shape gate costs no HMAC and no query.
|
||||
*/
|
||||
async function findHandoverByToken(
|
||||
db: Pick<Transaction, "managedProfileHandover">,
|
||||
rawToken: string,
|
||||
) {
|
||||
if (!looksLikeHandoverToken(rawToken)) return null;
|
||||
const tokenHash = hashHandoverToken(rawToken);
|
||||
const row = await db.managedProfileHandover.findUnique({
|
||||
where: { tokenHash },
|
||||
});
|
||||
if (!row || !handoverHashMatches(row.tokenHash, tokenHash)) return null;
|
||||
return row;
|
||||
}
|
||||
|
||||
function isOpen(
|
||||
row: { usedAt: Date | null; revokedAt: Date | null; expiresAt: Date },
|
||||
now: Date,
|
||||
): boolean {
|
||||
return row.usedAt === null && row.revokedAt === null && row.expiresAt > now;
|
||||
}
|
||||
|
||||
export interface HandoverPreview {
|
||||
displayName: string | null;
|
||||
expiresAt: Date;
|
||||
guardians: {
|
||||
grantId: string;
|
||||
displayName: string;
|
||||
proposal: HandoverAccess;
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
* What a link would hand over, or null for every kind of unusable link.
|
||||
*
|
||||
* Deliberately minimal: the profile's name, the expiry, and who keeps which
|
||||
* access. No health data, no date of birth, no usernames — the person holding
|
||||
* the link has not proved anything yet beyond holding it.
|
||||
*/
|
||||
export async function previewHandover(
|
||||
rawToken: string,
|
||||
now: Date = new Date(),
|
||||
): Promise<HandoverPreview | null> {
|
||||
const row = await findHandoverByToken(prisma, rawToken);
|
||||
if (!row || !isOpen(row, now)) return null;
|
||||
const state = parseHandoverState(row.proposalsJson);
|
||||
if (!state) return null;
|
||||
|
||||
const profile = await prisma.user.findUnique({
|
||||
where: { id: row.profileId },
|
||||
select: { displayName: true, managedProfileAt: true },
|
||||
});
|
||||
if (!profile?.managedProfileAt) return null;
|
||||
if (!(await isActiveGuardian(prisma, row.profileId, row.createdById, now))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const guardians = await prisma.accountGrant.findMany({
|
||||
where: { grantorId: row.profileId, ...activeGuardianWhere(now) },
|
||||
select: {
|
||||
id: true,
|
||||
grantee: { select: { displayName: true, username: true } },
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
return {
|
||||
displayName: profile.displayName,
|
||||
expiresAt: row.expiresAt,
|
||||
guardians: guardians.map((g) => ({
|
||||
grantId: g.id,
|
||||
displayName: nameOf(g.grantee),
|
||||
proposal: proposalFor(state, g.id),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export interface ClaimedProfile {
|
||||
profileId: string;
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
guardians: { guardianId: string; access: HandoverAccess }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim the profile behind a link. One transaction; see the module header for
|
||||
* everything it does. Throws `HandoverError("invalid")` for every unusable
|
||||
* link and `HandoverError("taken")` for a username or email in use.
|
||||
*
|
||||
* The caller has already hashed the password (Argon2id) and checked its
|
||||
* strength: none of that work belongs inside the lock.
|
||||
*/
|
||||
export async function claimManagedProfile(input: {
|
||||
rawToken: string;
|
||||
username: string;
|
||||
email: string;
|
||||
passwordHash: string;
|
||||
ipAddress?: string | null;
|
||||
now?: Date;
|
||||
}): Promise<ClaimedProfile> {
|
||||
const now = input.now ?? new Date();
|
||||
const found = await findHandoverByToken(prisma, input.rawToken);
|
||||
if (!found) throw new HandoverError("invalid");
|
||||
|
||||
try {
|
||||
return await prisma.$transaction((tx) =>
|
||||
withManagedProfileLock(tx, found.profileId, async (profile) => {
|
||||
if (!profile?.managedProfileAt) throw new HandoverError("invalid");
|
||||
|
||||
// Re-read under the lock: a new link, a withdrawal or a second claim
|
||||
// that committed while this one waited is seen here.
|
||||
const row = await tx.managedProfileHandover.findUnique({
|
||||
where: { id: found.id },
|
||||
});
|
||||
if (!row || !isOpen(row, now)) throw new HandoverError("invalid");
|
||||
const state = parseHandoverState(row.proposalsJson);
|
||||
if (!state) throw new HandoverError("invalid");
|
||||
if (!(await isActiveGuardian(tx, profile.id, row.createdById, now))) {
|
||||
throw new HandoverError("invalid");
|
||||
}
|
||||
|
||||
// Single use: the conditional update is the claim on the token.
|
||||
const consumed = await tx.managedProfileHandover.updateMany({
|
||||
where: {
|
||||
id: row.id,
|
||||
usedAt: null,
|
||||
revokedAt: null,
|
||||
expiresAt: { gt: now },
|
||||
},
|
||||
data: { usedAt: now },
|
||||
});
|
||||
if (consumed.count !== 1) throw new HandoverError("invalid");
|
||||
|
||||
// The new owner's credentials, field by field. The disclaimer and the
|
||||
// onboarding were given by a Guardian on the person's behalf; an adult
|
||||
// gives both themselves.
|
||||
const updated = await tx.user.update({
|
||||
where: { id: profile.id },
|
||||
data: {
|
||||
username: input.username,
|
||||
email: input.email,
|
||||
passwordHash: input.passwordHash,
|
||||
disclaimerAcknowledgedAt: null,
|
||||
disclaimerAcknowledgedVersion: null,
|
||||
onboardingCompletedAt: null,
|
||||
},
|
||||
select: { username: true, displayName: true },
|
||||
});
|
||||
await clearManagedProfileMarker({ profileId: profile.id }, tx);
|
||||
|
||||
const withdrawnInvitations = await withdrawPendingGrantsOfRecord(
|
||||
tx,
|
||||
profile.id,
|
||||
now,
|
||||
);
|
||||
|
||||
const guardians = await tx.accountGrant.findMany({
|
||||
where: { grantorId: profile.id, ...activeGuardianWhere(now) },
|
||||
select: { id: true, granteeId: true, access: true, scopeJson: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
const entries: StoredClaimEntry[] = [];
|
||||
for (const grant of guardians) {
|
||||
const proposal = proposalFor(state, grant.id);
|
||||
const settled = await settleHandoverGuardianGrant(tx, {
|
||||
recordId: profile.id,
|
||||
guardianId: grant.granteeId,
|
||||
liveGrant: grant,
|
||||
target: proposal,
|
||||
now,
|
||||
});
|
||||
entries.push({
|
||||
grantId: grant.id,
|
||||
guardianId: grant.granteeId,
|
||||
proposal,
|
||||
applied: settled.access,
|
||||
currentGrantId: settled.grantId,
|
||||
});
|
||||
// Under the Guardian's OWN account, so their history shows that a
|
||||
// record they looked after has gone. No actor: it is not somebody
|
||||
// else acting on the Guardian's record.
|
||||
await auditLog("managed_profile.handed_over", {
|
||||
userId: grant.granteeId,
|
||||
actorUserId: null,
|
||||
details: {
|
||||
profileId: profile.id,
|
||||
access: settled.access,
|
||||
grantId: settled.grantId,
|
||||
},
|
||||
client: tx,
|
||||
});
|
||||
}
|
||||
|
||||
const consent = await withdrawConsentInTransaction(
|
||||
tx,
|
||||
profile.id,
|
||||
consentKindEnum.options,
|
||||
now,
|
||||
);
|
||||
|
||||
await tx.managedProfileHandover.update({
|
||||
where: { id: row.id },
|
||||
data: {
|
||||
proposalsJson: toJson({
|
||||
...state,
|
||||
claim: {
|
||||
claimedAt: now.toISOString(),
|
||||
guardians: entries,
|
||||
decidedAt: null,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("managed_profile.claimed", {
|
||||
userId: profile.id,
|
||||
actorUserId: null,
|
||||
ipAddress: input.ipAddress ?? null,
|
||||
details: {
|
||||
handoverId: row.id,
|
||||
guardians: entries.map((e) => ({
|
||||
grantId: e.grantId,
|
||||
access: e.applied,
|
||||
newGrantId: e.currentGrantId,
|
||||
})),
|
||||
withdrawnInvitations,
|
||||
consentsWithdrawn: consent.revoked.length,
|
||||
},
|
||||
client: tx,
|
||||
});
|
||||
|
||||
return {
|
||||
profileId: profile.id,
|
||||
username: updated.username,
|
||||
displayName: updated.displayName,
|
||||
guardians: entries.map((e) => ({
|
||||
guardianId: e.guardianId,
|
||||
access: e.applied,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
if (isP2002(err)) throw new HandoverError("taken");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ── The new owner's decision ────────────────────────────────────────────────
|
||||
|
||||
export interface PendingHandoverGuardian {
|
||||
/** The Guardian's grant at the claim; what a decision names. */
|
||||
grantId: string;
|
||||
displayName: string;
|
||||
proposal: HandoverAccess;
|
||||
/** The access the Guardian holds now. */
|
||||
current: HandoverAccess;
|
||||
/**
|
||||
* False when the access moved since the claim — the Guardian stepped away,
|
||||
* or the owner already changed it under Settings → Shared access — so the
|
||||
* decision screen shows it and leaves it alone.
|
||||
*/
|
||||
decidable: boolean;
|
||||
}
|
||||
|
||||
export interface PendingHandoverDecision {
|
||||
handoverId: string;
|
||||
claimedAt: Date;
|
||||
guardians: PendingHandoverGuardian[];
|
||||
}
|
||||
|
||||
async function latestClaimedHandover(
|
||||
db: Pick<Transaction, "managedProfileHandover">,
|
||||
userId: string,
|
||||
) {
|
||||
return db.managedProfileHandover.findFirst({
|
||||
where: { profileId: userId, usedAt: { not: null } },
|
||||
orderBy: { usedAt: "desc" },
|
||||
select: { id: true, proposalsJson: true },
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether the pair's live row is still the one the handover left there. */
|
||||
function isUntouched(
|
||||
entry: StoredClaimEntry,
|
||||
live: { id: string } | null,
|
||||
): boolean {
|
||||
return entry.currentGrantId === null
|
||||
? live === null
|
||||
: live?.id === entry.currentGrantId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a decision is waiting, for the setup flow's front door. The cheap
|
||||
* question; {@link readPendingHandoverDecision} is the full answer.
|
||||
*/
|
||||
export async function hasPendingHandoverDecision(
|
||||
userId: string,
|
||||
): Promise<boolean> {
|
||||
const row = await latestClaimedHandover(prisma, userId);
|
||||
const state = row ? parseHandoverState(row.proposalsJson) : null;
|
||||
return Boolean(state?.claim && state.claim.decidedAt === null);
|
||||
}
|
||||
|
||||
/**
|
||||
* The decision waiting for the new owner, or null when there is none — the
|
||||
* account was never a managed profile, or the decision was made.
|
||||
*/
|
||||
export async function readPendingHandoverDecision(
|
||||
userId: string,
|
||||
now: Date = new Date(),
|
||||
): Promise<PendingHandoverDecision | null> {
|
||||
const row = await latestClaimedHandover(prisma, userId);
|
||||
if (!row) return null;
|
||||
const state = parseHandoverState(row.proposalsJson);
|
||||
if (!state?.claim || state.claim.decidedAt !== null) return null;
|
||||
|
||||
const ids = state.claim.guardians.map((e) => e.guardianId);
|
||||
const [people, liveGrants] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: { id: true, displayName: true, username: true },
|
||||
}),
|
||||
prisma.accountGrant.findMany({
|
||||
where: { grantorId: userId, granteeId: { in: ids }, revokedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
granteeId: true,
|
||||
access: true,
|
||||
acceptedAt: true,
|
||||
revokedAt: true,
|
||||
expiresAt: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const guardians: PendingHandoverGuardian[] = [];
|
||||
for (const entry of state.claim.guardians) {
|
||||
const person = people.find((p) => p.id === entry.guardianId);
|
||||
// A Guardian whose account is gone has nothing left to decide.
|
||||
if (!person) continue;
|
||||
const live =
|
||||
liveGrants.find((g) => g.granteeId === entry.guardianId) ?? null;
|
||||
const decidable = isUntouched(entry, live);
|
||||
guardians.push({
|
||||
grantId: entry.grantId,
|
||||
displayName: nameOf(person),
|
||||
proposal: entry.proposal,
|
||||
current: decidable
|
||||
? entry.applied
|
||||
: handoverAccessOf(live && isGrantActive(live, now) ? live : null),
|
||||
decidable,
|
||||
});
|
||||
}
|
||||
return {
|
||||
handoverId: row.id,
|
||||
claimedAt: new Date(state.claim.claimedAt),
|
||||
guardians,
|
||||
};
|
||||
}
|
||||
|
||||
export interface HandoverDecisionResult {
|
||||
/** Guardians whose access this decision changed, for their notification. */
|
||||
changed: { guardianId: string; access: HandoverAccess }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the new owner's final word on each former Guardian.
|
||||
*
|
||||
* A Guardian the body leaves out keeps the access the claim gave them. One the
|
||||
* owner already changed elsewhere, or who stepped away, is left alone. Every
|
||||
* change ends a row and writes a new one, as any change of level does.
|
||||
*/
|
||||
export async function decideHandover(input: {
|
||||
userId: string;
|
||||
decisions: { grantId: string; access: HandoverAccess }[];
|
||||
now?: Date;
|
||||
}): Promise<HandoverDecisionResult> {
|
||||
const now = input.now ?? new Date();
|
||||
return prisma.$transaction((tx) =>
|
||||
// The same key every lifecycle act on this record takes. The marker is
|
||||
// gone, so nothing here depends on it; the lock orders this against a
|
||||
// second decision from another tab.
|
||||
withManagedProfileLock(tx, input.userId, async () => {
|
||||
const row = await latestClaimedHandover(tx, input.userId);
|
||||
const state = row ? parseHandoverState(row.proposalsJson) : null;
|
||||
if (!row || !state?.claim || state.claim.decidedAt !== null) {
|
||||
throw new HandoverError("no_pending");
|
||||
}
|
||||
const claim = state.claim;
|
||||
const known = new Set(claim.guardians.map((e) => e.grantId));
|
||||
if (input.decisions.some((d) => !known.has(d.grantId))) {
|
||||
throw new HandoverError("unknown_guardian");
|
||||
}
|
||||
|
||||
const changed: HandoverDecisionResult["changed"] = [];
|
||||
const outcome: {
|
||||
grantId: string;
|
||||
access: HandoverAccess;
|
||||
changed: boolean;
|
||||
skipped: boolean;
|
||||
}[] = [];
|
||||
const guardians: StoredClaimEntry[] = [];
|
||||
for (const entry of claim.guardians) {
|
||||
const target =
|
||||
input.decisions.find((d) => d.grantId === entry.grantId)?.access ??
|
||||
entry.applied;
|
||||
const live = await tx.accountGrant.findFirst({
|
||||
where: {
|
||||
grantorId: input.userId,
|
||||
granteeId: entry.guardianId,
|
||||
revokedAt: null,
|
||||
},
|
||||
select: { id: true, access: true, scopeJson: true },
|
||||
});
|
||||
if (!isUntouched(entry, live)) {
|
||||
guardians.push(entry);
|
||||
outcome.push({
|
||||
grantId: entry.grantId,
|
||||
access: target,
|
||||
changed: false,
|
||||
skipped: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const settled = await settleHandoverGuardianGrant(tx, {
|
||||
recordId: input.userId,
|
||||
guardianId: entry.guardianId,
|
||||
liveGrant: live,
|
||||
target,
|
||||
now,
|
||||
});
|
||||
guardians.push({
|
||||
...entry,
|
||||
decided: target,
|
||||
currentGrantId: settled.grantId,
|
||||
});
|
||||
outcome.push({
|
||||
grantId: entry.grantId,
|
||||
access: target,
|
||||
changed: settled.changed,
|
||||
skipped: false,
|
||||
});
|
||||
if (settled.changed) {
|
||||
changed.push({ guardianId: entry.guardianId, access: target });
|
||||
await auditLog("managed_profile.handover.access_changed", {
|
||||
userId: entry.guardianId,
|
||||
actorUserId: null,
|
||||
details: { profileId: input.userId, access: target },
|
||||
client: tx,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await tx.managedProfileHandover.update({
|
||||
where: { id: row.id },
|
||||
data: {
|
||||
proposalsJson: toJson({
|
||||
...state,
|
||||
claim: { ...claim, guardians, decidedAt: now.toISOString() },
|
||||
}),
|
||||
},
|
||||
});
|
||||
await auditLog("managed_profile.handover.decided", {
|
||||
userId: input.userId,
|
||||
details: { handoverId: row.id, decisions: outcome },
|
||||
client: tx,
|
||||
});
|
||||
return { changed };
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -407,21 +407,36 @@ export async function updateManagedProfile(input: {
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal-only future compatibility hook. There is intentionally no route for
|
||||
* this state change in the current product; holding the same lock ensures any
|
||||
* later handover cannot race a Guardian reduction.
|
||||
* Clear the managed-profile marker: the record becomes an ordinary account.
|
||||
*
|
||||
* v1.42 (#959) — the handover's claim (`handover.ts`) is the one product path
|
||||
* that calls this, inside its own transaction and in the same commit that sets
|
||||
* the new owner's credentials and settles every Guardian's access. Passing
|
||||
* `tx` runs it there; the advisory lock is re-entrant inside one transaction,
|
||||
* so the claim, which already holds it, takes it again here at no cost.
|
||||
* Without `tx` it opens a transaction of its own, which is what the lifecycle
|
||||
* race tests drive it through.
|
||||
*
|
||||
* Holding the same lock is the point either way: an acceptance, a Guardian
|
||||
* removal or a deletion that races the clearing is ordered entirely before or
|
||||
* after it.
|
||||
*/
|
||||
export async function clearManagedProfileMarker(input: {
|
||||
profileId: string;
|
||||
}): Promise<void> {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await withManagedProfileLock(tx, input.profileId, async (profile) => {
|
||||
export async function clearManagedProfileMarker(
|
||||
input: { profileId: string },
|
||||
tx?: Transaction,
|
||||
): Promise<void> {
|
||||
const clear = (client: Transaction) =>
|
||||
withManagedProfileLock(client, input.profileId, async (profile) => {
|
||||
if (!profile) throw new ManagedProfileLifecycleError("not_found");
|
||||
if (!profile.managedProfileAt) return;
|
||||
await tx.user.update({
|
||||
await client.user.update({
|
||||
where: { id: profile.id },
|
||||
data: { managedProfileAt: null },
|
||||
});
|
||||
});
|
||||
});
|
||||
if (tx) {
|
||||
await clear(tx);
|
||||
return;
|
||||
}
|
||||
await prisma.$transaction(clear);
|
||||
}
|
||||
@@ -234,6 +234,8 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"managed_profile.guardian.duplicate",
|
||||
"managed_profile.guardian.managed_invitee",
|
||||
"managed_profile.guardian.required",
|
||||
"managed_profile.handover.no_pending",
|
||||
"managed_profile.handover.unknown_guardian",
|
||||
"managed_profile.not_found",
|
||||
],
|
||||
measurement: [
|
||||
@@ -315,6 +317,11 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"profile.update.invalidBody",
|
||||
"profile.update.nothingSaved",
|
||||
],
|
||||
profile_claim: [
|
||||
"profile_claim.invalid",
|
||||
"profile_claim.oidc_only_unsupported",
|
||||
"profile_claim.taken",
|
||||
],
|
||||
record_write: ["record_write.rate_limited"],
|
||||
"report-selection": [
|
||||
"report-selection.body.invalid_json",
|
||||
|
||||
@@ -36,14 +36,14 @@ import {
|
||||
inviteManagedProfileGuardianSchema,
|
||||
updateManagedProfileSchema,
|
||||
} from "@/lib/validations/managed-profiles";
|
||||
import {
|
||||
createHandoverSchema,
|
||||
handoverAccessSchema,
|
||||
handoverDecisionSchema,
|
||||
} from "@/lib/validations/managed-profile-handover";
|
||||
import { onboardingStateResource } from "./onboarding";
|
||||
import { aiAccountBlock, moduleAccessMap } from "./profile";
|
||||
import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
notImplementedResponse,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
|
||||
|
||||
/**
|
||||
* The four request bodies this table publishes, and the one thing to know about
|
||||
@@ -551,32 +551,17 @@ const managedProfileEnvelope = dataEnvelope(
|
||||
);
|
||||
|
||||
// v1.42 (#959) — handing a managed profile over to the person it describes.
|
||||
const handoverProposal = z
|
||||
.enum(["end", "read", "manage"])
|
||||
const handoverAccess = handoverAccessSchema
|
||||
.describe(
|
||||
"What a guardian's access becomes after the handover: ended, view only, or still managing. The new owner confirms or changes it when claiming.",
|
||||
"What a guardian's access becomes after the handover: ended (`end`), view only (`read`), or still managing (`manage`).",
|
||||
)
|
||||
.meta({ id: "ManagedProfileHandoverProposal" });
|
||||
.meta({ id: "ManagedProfileHandoverAccess" });
|
||||
|
||||
const createHandoverRequest = z
|
||||
.object({
|
||||
expiresInDays: z
|
||||
.union([z.literal(1), z.literal(7), z.literal(14)])
|
||||
.default(7)
|
||||
.describe("How long the link stays valid."),
|
||||
proposals: z
|
||||
.array(
|
||||
z.object({
|
||||
grantId: z.string().describe("The guardian's MANAGE grant."),
|
||||
proposal: handoverProposal,
|
||||
}),
|
||||
)
|
||||
.describe(
|
||||
"One proposal per active guardian. A guardian left out is proposed `read`.",
|
||||
),
|
||||
})
|
||||
.strict()
|
||||
.meta({ id: "CreateManagedProfileHandoverRequest" });
|
||||
const createHandoverRequest = createHandoverSchema.meta({
|
||||
id: "CreateManagedProfileHandoverRequest",
|
||||
description:
|
||||
"`expiresInDays` is 1, 7 (default) or 14. `proposals` names at most one entry per active guardian grant; a guardian left out is proposed `read`, and a grant that is not an active guardian of this profile is refused with 422.",
|
||||
});
|
||||
|
||||
const managedProfileHandover = z
|
||||
.object({
|
||||
@@ -591,13 +576,93 @@ const managedProfileHandover = z
|
||||
})
|
||||
.meta({ id: "ManagedProfileHandover" });
|
||||
|
||||
const managedProfileHandoverStatus = z
|
||||
.object({
|
||||
available: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"False on an instance that allows single sign-on only, where a handover cannot be created.",
|
||||
),
|
||||
open: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
createdAt: z.iso.datetime({ offset: true }),
|
||||
expiresAt: z.iso.datetime({ offset: true }),
|
||||
createdByYou: z.boolean(),
|
||||
})
|
||||
.nullable()
|
||||
.describe(
|
||||
"The profile's open link, or null. Never the link itself: only its hash is stored.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "ManagedProfileHandoverStatus" });
|
||||
|
||||
const handoverDecisionRequest = handoverDecisionSchema.meta({
|
||||
id: "HandoverDecisionRequest",
|
||||
description:
|
||||
"The new owner's decision per former guardian, named by the guardian's grant id at the claim. A guardian left out keeps the access the claim gave them.",
|
||||
});
|
||||
|
||||
const pendingHandoverDecision = z
|
||||
.object({
|
||||
pending: z
|
||||
.object({
|
||||
claimedAt: z.iso.datetime({ offset: true }),
|
||||
guardians: z.array(
|
||||
z.object({
|
||||
grantId: z
|
||||
.string()
|
||||
.describe(
|
||||
"The guardian's grant at the claim; what a decision names.",
|
||||
),
|
||||
displayName: z.string(),
|
||||
proposal: handoverAccess,
|
||||
current: handoverAccess,
|
||||
decidable: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"False when the access moved since the claim (the guardian stepped away, or it was changed under shared access); such a row is shown and left alone.",
|
||||
),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.nullable(),
|
||||
})
|
||||
.meta({ id: "PendingHandoverDecision" });
|
||||
|
||||
export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/managed-profiles/{id}/handover": {
|
||||
get: {
|
||||
tags: ["Account sharing"],
|
||||
summary: "Whether a handover link is open",
|
||||
description:
|
||||
"The profile's open handover link, if any, and whether this instance allows creating one. Never returns the link: only its hash is stored. Cookie-only, no step-up.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
...stdResponses,
|
||||
"200": {
|
||||
description: "The status.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
managedProfileHandoverStatus,
|
||||
"ManagedProfileHandoverStatusEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: managed_profile.not_found`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["Account sharing"],
|
||||
summary: "Create a handover link for a managed profile",
|
||||
description:
|
||||
"Mints the one-time link that lets the person a managed profile describes take it over as their own account. No data moves: claiming sets credentials on the profile's own record. Each guardian's access afterwards follows the proposal recorded here unless the new owner changes it. Creating a new link withdraws an open one. Pending MANAGE invitations on the profile are withdrawn when it is claimed. Cookie-only and step-up gated, like every act on a managed profile. Refused on an instance that allows single sign-on only.",
|
||||
"Mints the one-time link that lets the person a managed profile describes take it over as their own account. No data moves: claiming sets credentials on the profile's own record. Claiming applies each guardian's proposal at once; the new owner then confirms or changes it on first sign-in, and until they do the proposal holds. Creating a new link withdraws an open one. Pending invitations on the profile are withdrawn when it is claimed. Cookie-only and step-up gated, like every act on a managed profile; ten an hour per caller. Refused on an instance that allows single sign-on only.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
requestBody: {
|
||||
required: true,
|
||||
@@ -626,7 +691,11 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: managed_profile.not_found`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...notImplementedResponse,
|
||||
"422": {
|
||||
description:
|
||||
"The body is invalid, or a proposal names a grant that is not an active guardian of this profile (`meta.errorCode: managed_profile.handover.unknown_guardian`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
@@ -653,7 +722,68 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: managed_profile.not_found`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...notImplementedResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/account/handover-decision": {
|
||||
get: {
|
||||
tags: ["Account sharing"],
|
||||
summary: "The handover decision waiting for a new owner",
|
||||
description:
|
||||
"After claiming a managed profile, the new owner decides each former guardian's access. This answers that decision, or `pending: null` when none is waiting. Refused while acting on another account.",
|
||||
responses: {
|
||||
...stdResponses,
|
||||
"200": {
|
||||
description: "The waiting decision, or null.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
pendingHandoverDecision,
|
||||
"PendingHandoverDecisionEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["Account sharing"],
|
||||
summary: "Decide each former guardian's access",
|
||||
description:
|
||||
"Records the new owner's final decision per former guardian. A changed level ends the guardian's grant and writes a new one; `manage` can restore access a guardian held before the handover. After this, access changes through the ordinary grant endpoints. Refused while acting on another account.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: handoverDecisionRequest } },
|
||||
},
|
||||
responses: {
|
||||
...stdResponses,
|
||||
"200": {
|
||||
description: "Decided.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({
|
||||
decided: z.literal(true),
|
||||
changed: z
|
||||
.number()
|
||||
.int()
|
||||
.describe("How many guardians' access this changed."),
|
||||
}),
|
||||
"HandoverDecisionEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"409": {
|
||||
description:
|
||||
"No decision is waiting (`meta.errorCode: managed_profile.handover.no_pending`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"422": {
|
||||
description:
|
||||
"The body is invalid, or a decision names somebody who was not a guardian at the claim (`meta.errorCode: managed_profile.handover.unknown_guardian`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -46,9 +46,13 @@ import {
|
||||
stdResponses,
|
||||
errorEnvelope,
|
||||
loginPasswordSchema,
|
||||
notImplementedResponse,
|
||||
} from "./shared";
|
||||
import { passkeyLoginOptionsSchema } from "@/lib/validations/auth";
|
||||
import {
|
||||
claimPreviewSchema,
|
||||
claimSchema,
|
||||
} from "@/lib/validations/managed-profile-handover";
|
||||
import { HANDOVER_ACCESS_LEVELS } from "@/lib/managed-profiles/handover-access";
|
||||
|
||||
// ── Sub-schemas owned here (route-specific shapes) ───────────────────
|
||||
|
||||
@@ -699,48 +703,42 @@ const codexDevicePollResponse = z
|
||||
.meta({ id: "CodexDevicePollResponse" });
|
||||
|
||||
// v1.42 (#959) — claiming a managed profile through its handover link.
|
||||
const claimTokenField = z
|
||||
.string()
|
||||
.describe(
|
||||
"The `hlp_` token from the handover link. Sent in the body, never in a URL.",
|
||||
);
|
||||
const claimPreviewRequest = claimPreviewSchema.meta({
|
||||
id: "ProfileClaimPreviewRequest",
|
||||
description:
|
||||
"`token` is the `hlp_` token from the handover link. Sent in the body, never in a URL.",
|
||||
});
|
||||
|
||||
const claimPreviewRequest = z
|
||||
.object({ token: claimTokenField })
|
||||
.strict()
|
||||
.meta({ id: "ProfileClaimPreviewRequest" });
|
||||
const claimAccess = z
|
||||
.enum(HANDOVER_ACCESS_LEVELS)
|
||||
.meta({ id: "ProfileClaimGuardianAccess" });
|
||||
|
||||
const claimPreview = z
|
||||
.object({
|
||||
displayName: z.string(),
|
||||
displayName: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("The profile's name, as its guardians set it."),
|
||||
expiresAt: z.iso.datetime({ offset: true }),
|
||||
guardians: z.array(
|
||||
z.object({
|
||||
grantId: z.string(),
|
||||
displayName: z.string(),
|
||||
proposal: z.enum(["end", "read", "manage"]),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.meta({ id: "ProfileClaimPreview" });
|
||||
|
||||
const claimRequest = z
|
||||
.object({
|
||||
token: claimTokenField,
|
||||
username: z.string(),
|
||||
email: z.string().describe("Required: the account needs a way back in."),
|
||||
password: z.string(),
|
||||
guardians: z
|
||||
.array(
|
||||
z.object({
|
||||
grantId: z.string(),
|
||||
decision: z.enum(["end", "read", "manage"]),
|
||||
displayName: z.string(),
|
||||
proposal: claimAccess,
|
||||
}),
|
||||
)
|
||||
.describe("The new owner's decision per guardian."),
|
||||
.describe(
|
||||
"Each guardian and the access they keep after the claim, as proposed. The new owner confirms or changes it after signing in.",
|
||||
),
|
||||
})
|
||||
.strict()
|
||||
.meta({ id: "ProfileClaimRequest" });
|
||||
.meta({ id: "ProfileClaimPreview" });
|
||||
|
||||
const claimRequest = claimSchema.meta({
|
||||
id: "ProfileClaimRequest",
|
||||
description:
|
||||
"`token` is the `hlp_` token from the handover link, in the body. `username` follows the registration rules and may not start with `managed-`; `email` is required, because the account needs a way back in; `password` follows the registration password policy.",
|
||||
});
|
||||
|
||||
export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/auth/claim/preview": {
|
||||
@@ -781,7 +779,6 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
...notImplementedResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -792,7 +789,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Auth"],
|
||||
summary: "Claim a managed profile",
|
||||
description:
|
||||
"Turns the managed profile behind a handover link into the claimant's own account: sets the username, email and password on the profile's record, clears the managed marker, settles each guardian's access by the claimant's decision, withdraws pending MANAGE invitations, and resets the disclaimer, onboarding and AI consent so the new owner gives them personally. No data moves. Signs the new owner in. Anonymous; the token travels in the body, and every token failure answers the same 404.",
|
||||
"Turns the managed profile behind a handover link into the claimant's own account, in one transaction: sets the username, email and password on the profile's record, clears the managed marker, withdraws pending invitations, applies each guardian's proposed access, and resets the disclaimer, onboarding and AI consent so the new owner gives them personally. The new owner then decides each guardian's access on first sign-in (`/api/account/handover-decision`). No data moves. Signs the new owner in. Anonymous; the token travels in the body, every token failure answers the same 404, and five attempts per fifteen minutes per source address are allowed.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: claimRequest } },
|
||||
@@ -821,11 +818,10 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
"409": {
|
||||
description:
|
||||
"The request carries a signed-in session (`meta.errorCode: auth.already_authenticated`), or the username or email is taken.",
|
||||
"The request carries a signed-in session (`meta.errorCode: auth.already_authenticated`), or the username or email is taken (`meta.errorCode: profile_claim.taken`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
...notImplementedResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.42 (#959) — the decision a new owner owes about their former Guardians,
|
||||
* after claiming a managed profile.
|
||||
*
|
||||
* Read by the decision screen the claim lands on (`/onboarding/handover`) and
|
||||
* by the card at the top of Settings → Shared access, which keeps offering it
|
||||
* until it is made. Deciding refreshes the grant reads as well, because every
|
||||
* change it makes is an ordinary grant row the sharing panel lists.
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import type { HandoverAccess } from "@/lib/managed-profiles/handover-access";
|
||||
import { invalidateGrantReads } from "@/lib/queries/use-account-grants";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
export interface PendingHandoverGuardian {
|
||||
grantId: string;
|
||||
displayName: string;
|
||||
proposal: HandoverAccess;
|
||||
current: HandoverAccess;
|
||||
decidable: boolean;
|
||||
}
|
||||
|
||||
export interface HandoverDecisionState {
|
||||
pending: {
|
||||
claimedAt: string;
|
||||
guardians: PendingHandoverGuardian[];
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function useHandoverDecision(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.handoverDecision(),
|
||||
queryFn: () =>
|
||||
apiGet<HandoverDecisionState>("/api/account/handover-decision"),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
/** The wire body, composed once and exported for the integration suite. */
|
||||
export function handoverDecisionBody(
|
||||
decisions: { grantId: string; access: HandoverAccess }[],
|
||||
): { decisions: { grantId: string; access: HandoverAccess }[] } {
|
||||
return {
|
||||
decisions: decisions.map((d) => ({ grantId: d.grantId, access: d.access })),
|
||||
};
|
||||
}
|
||||
|
||||
export function useDecideHandover() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.handoverDecide(),
|
||||
mutationFn: (decisions: { grantId: string; access: HandoverAccess }[]) =>
|
||||
apiPost<{ decided: true; changed: number }>(
|
||||
"/api/account/handover-decision",
|
||||
handoverDecisionBody(decisions),
|
||||
),
|
||||
onSuccess: () => {
|
||||
invalidateGrantReads(queryClient);
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.handoverDecision(),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
import { invalidateGrantReads } from "@/lib/queries/use-account-grants";
|
||||
import type { GrantParty } from "@/lib/queries/use-account-grants";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import type { HandoverAccess } from "@/lib/managed-profiles/handover-access";
|
||||
|
||||
/**
|
||||
* Exactly the body `POST /api/managed-profiles` accepts.
|
||||
@@ -340,3 +341,112 @@ export function useRemoveManagedProfileGuardian() {
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Handover (v1.42, #959) ──────────────────────────────────────────────────
|
||||
|
||||
/** What `GET /api/managed-profiles/{id}/handover` answers. */
|
||||
export interface ManagedProfileHandoverStatus {
|
||||
/** False on a single-sign-on-only instance, where no link can be made. */
|
||||
available: boolean;
|
||||
open: {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
createdByYou: boolean;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/** The minted link. `token` and `url` exist in this response only. */
|
||||
export interface ManagedProfileHandoverLink {
|
||||
id: string;
|
||||
token: string;
|
||||
url: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exactly the body `POST /api/managed-profiles/{id}/handover` accepts, plus
|
||||
* the profile it is addressed to. The route is `.strict()`, so the wire body
|
||||
* is composed by {@link handoverCreateBody} rather than by spreading this.
|
||||
*/
|
||||
export interface CreateManagedProfileHandoverInput {
|
||||
profileId: string;
|
||||
expiresInDays: 1 | 7 | 14;
|
||||
proposals: { grantId: string; proposal: HandoverAccess }[];
|
||||
}
|
||||
|
||||
/** The wire body, composed once and exported for the integration suite. */
|
||||
export function handoverCreateBody(input: CreateManagedProfileHandoverInput): {
|
||||
expiresInDays: 1 | 7 | 14;
|
||||
proposals: { grantId: string; proposal: HandoverAccess }[];
|
||||
} {
|
||||
return {
|
||||
expiresInDays: input.expiresInDays,
|
||||
proposals: input.proposals.map((p) => ({
|
||||
grantId: p.grantId,
|
||||
proposal: p.proposal,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether this profile has an open handover link. Never the link itself. */
|
||||
export function useManagedProfileHandover(profileId: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.managedProfileHandover(profileId),
|
||||
queryFn: () =>
|
||||
apiGet<ManagedProfileHandoverStatus>(
|
||||
`/api/managed-profiles/${profileId}/handover`,
|
||||
),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint the handover link. Step-up gated like every act in this family, so a
|
||||
* 401 carrying `auth.stepup.required` is the expected first answer.
|
||||
*
|
||||
* The link lives in the mutation's `data` and nowhere else: it is shown once
|
||||
* and never cached under a query key.
|
||||
*/
|
||||
export function useCreateManagedProfileHandover() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.managedProfileHandoverCreate(),
|
||||
mutationFn: async (input: CreateManagedProfileHandoverInput) => {
|
||||
const link = await apiPost<ManagedProfileHandoverLink>(
|
||||
`/api/managed-profiles/${input.profileId}/handover`,
|
||||
handoverCreateBody(input),
|
||||
);
|
||||
// Lazy chunk, as the admin invite and the share link do: the QR encoder
|
||||
// is needed here, at mint time, and nowhere else.
|
||||
const QRCode = (await import("qrcode")).default;
|
||||
const qrDataUrl = await QRCode.toDataURL(link.url, {
|
||||
width: 240,
|
||||
margin: 1,
|
||||
});
|
||||
return { ...link, qrDataUrl };
|
||||
},
|
||||
onSuccess: (_link, input) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.managedProfileHandover(input.profileId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Withdraw the open link. No step-up: taking it back is never harder. */
|
||||
export function useRevokeManagedProfileHandover() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.managedProfileHandoverRevoke(),
|
||||
mutationFn: (profileId: string) =>
|
||||
apiDelete<{ revoked: boolean }>(
|
||||
`/api/managed-profiles/${profileId}/handover`,
|
||||
),
|
||||
onSuccess: (_result, profileId) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.managedProfileHandover(profileId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.42 (#959) — the anonymous side of a managed-profile handover: the
|
||||
* preview of a link and the claim itself.
|
||||
*
|
||||
* The token travels in request BODIES only, never in a query key: a cache key
|
||||
* is somewhere a credential must not be written, and the preview key is one
|
||||
* fixed value because one claim page reads one link.
|
||||
*/
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import type { HandoverAccess } from "@/lib/managed-profiles/handover-access";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
export interface ProfileClaimPreview {
|
||||
displayName: string | null;
|
||||
expiresAt: string;
|
||||
guardians: {
|
||||
grantId: string;
|
||||
displayName: string;
|
||||
proposal: HandoverAccess;
|
||||
}[];
|
||||
}
|
||||
|
||||
export function useProfileClaimPreview(token: string | null, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.profileClaimPreview(),
|
||||
queryFn: () =>
|
||||
apiPost<ProfileClaimPreview>("/api/auth/claim/preview", { token }),
|
||||
enabled: enabled && token !== null,
|
||||
// A 404 is an answer, not a blip, and a retry would only spend the
|
||||
// per-address budget the claim itself needs.
|
||||
retry: false,
|
||||
staleTime: Infinity,
|
||||
gcTime: 0,
|
||||
});
|
||||
}
|
||||
|
||||
/** Exactly the body `POST /api/auth/claim` accepts. */
|
||||
export interface ProfileClaimInput {
|
||||
token: string;
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export function claimBody(input: ProfileClaimInput): ProfileClaimInput {
|
||||
return {
|
||||
token: input.token,
|
||||
username: input.username,
|
||||
email: input.email,
|
||||
password: input.password,
|
||||
};
|
||||
}
|
||||
|
||||
export function useClaimProfile() {
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.profileClaim(),
|
||||
mutationFn: (input: ProfileClaimInput) =>
|
||||
apiPost<{ userId: string; username: string }>(
|
||||
"/api/auth/claim",
|
||||
claimBody(input),
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -58,6 +58,28 @@ export const sharingKeys = {
|
||||
managedProfile: (profileId: string) =>
|
||||
["managed-profiles", profileId, "profile"] as const,
|
||||
|
||||
/**
|
||||
* v1.42 (#959) — whether a managed profile has an open handover link (`GET
|
||||
* /api/managed-profiles/{id}/handover`). Under the family prefix so a grant
|
||||
* transition refreshes it with the rest.
|
||||
*/
|
||||
managedProfileHandover: (profileId: string) =>
|
||||
["managed-profiles", profileId, "handover"] as const,
|
||||
|
||||
/**
|
||||
* v1.42 (#959) — the decision a new owner owes about their former
|
||||
* Guardians (`GET /api/account/handover-decision`). Under `account` like
|
||||
* the grant list it changes.
|
||||
*/
|
||||
handoverDecision: () => ["account", "handover-decision"] as const,
|
||||
|
||||
/**
|
||||
* v1.42 (#959) — the anonymous preview of a handover link. The token is
|
||||
* deliberately NOT part of the key: a cache key is a place a credential
|
||||
* should never be written to, and one claim page reads one link.
|
||||
*/
|
||||
profileClaimPreview: () => ["profile-claim", "preview"] as const,
|
||||
|
||||
/** Mutation keys — kept in the factory so no bare array reaches a call site. */
|
||||
accountGrantInvite: () => ["account", "grants", "invite"] as const,
|
||||
accountGrantAccept: () => ["account", "grants", "accept"] as const,
|
||||
@@ -71,4 +93,10 @@ export const sharingKeys = {
|
||||
["managed-profiles", "guardian", "invite"] as const,
|
||||
managedProfileGuardianRemove: () =>
|
||||
["managed-profiles", "guardian", "remove"] as const,
|
||||
managedProfileHandoverCreate: () =>
|
||||
["managed-profiles", "handover", "create"] as const,
|
||||
managedProfileHandoverRevoke: () =>
|
||||
["managed-profiles", "handover", "revoke"] as const,
|
||||
handoverDecide: () => ["account", "handover-decision", "decide"] as const,
|
||||
profileClaim: () => ["profile-claim", "submit"] as const,
|
||||
};
|
||||
@@ -4,7 +4,8 @@
|
||||
* such text.
|
||||
*
|
||||
* `hlk_` access tokens, `hlr_` refresh tokens, `hls_` clinician share-link
|
||||
* tokens and session secrets, `hlv_` registration invite tokens, `hle_`
|
||||
* tokens and session secrets, `hlv_` registration invite tokens, `hlp_`
|
||||
* managed-profile handover tokens (v1.42), `hle_`
|
||||
* elevation tokens, `hlh_` native sign-in handoff codes, and the MCP OAuth
|
||||
* artifacts `hlac_` (authorization code), `hlrt_` (refresh token) and `hlc_`
|
||||
* (client id);
|
||||
@@ -17,7 +18,7 @@
|
||||
* that knows the secrets in play must also refuse text containing them.
|
||||
*/
|
||||
const SECRET_SHAPED =
|
||||
/(?:\b(?:hlk_|hlr_|hls_|hlv_|hle_|hlh_|hlac_|hlrt_|hlc_)[A-Za-z0-9_-]+|\bsk-(?:ant-)?[A-Za-z0-9_-]{8,})/;
|
||||
/(?:\b(?:hlk_|hlr_|hls_|hlv_|hlp_|hle_|hlh_|hlac_|hlrt_|hlc_)[A-Za-z0-9_-]+|\bsk-(?:ant-)?[A-Za-z0-9_-]{8,})/;
|
||||
|
||||
export function looksSecretShaped(text: string): boolean {
|
||||
return SECRET_SHAPED.test(text);
|
||||
|
||||
@@ -62,6 +62,10 @@ import {
|
||||
reduceManagedProfileGuardian,
|
||||
withManagedProfileLock,
|
||||
} from "@/lib/managed-profiles/lifecycle";
|
||||
import {
|
||||
grantAccessFor,
|
||||
type HandoverAccess,
|
||||
} from "@/lib/managed-profiles/handover-access";
|
||||
import { ENTIRE_RECORD, isShareDomain } from "@/lib/sharing/scope";
|
||||
import type { ShareDomain, ShareScope } from "@/lib/sharing/scope";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
@@ -613,6 +617,19 @@ export async function revokeManagedProfileGuardian(input: {
|
||||
{ grantorId: profile.id, granteeId: target.granteeId },
|
||||
tx,
|
||||
);
|
||||
// v1.42 (#959) — a handover link the removed Guardian minted dies with
|
||||
// their access. The claim refuses it anyway (its creator must still be
|
||||
// an active Guardian); withdrawing it here keeps the other Guardians'
|
||||
// panel from showing a link that can no longer be used.
|
||||
await tx.managedProfileHandover.updateMany({
|
||||
where: {
|
||||
profileId: profile.id,
|
||||
createdById: target.granteeId,
|
||||
usedAt: null,
|
||||
revokedAt: null,
|
||||
},
|
||||
data: { revokedAt: now },
|
||||
});
|
||||
await auditLog("managed_profile.guardian.revoked", {
|
||||
userId: profile.id,
|
||||
actorUserId: input.guardianId,
|
||||
@@ -624,6 +641,139 @@ export async function revokeManagedProfileGuardian(input: {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Handover (v1.42, #959) ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Withdraw every invitation the record has offered and nobody has accepted.
|
||||
*
|
||||
* Called when a managed profile is claimed. A pending MANAGE invitation on a
|
||||
* managed profile is a Guardian invitation; once the record belongs to an
|
||||
* adult, `acceptGrant` would still accept it, and the invitee would become a
|
||||
* MANAGE delegate of a person who never agreed to them. Withdrawing every
|
||||
* pending row the record has offered closes that, and nothing else is lost:
|
||||
* the new owner invites whoever they want themselves.
|
||||
*
|
||||
* `revokedBy = GRANTOR` because the record's owner is the party ending them.
|
||||
* Must run inside the caller's transaction and under the managed-profile lock,
|
||||
* which is what orders it against a concurrent acceptance.
|
||||
*/
|
||||
export async function withdrawPendingGrantsOfRecord(
|
||||
tx: Prisma.TransactionClient,
|
||||
recordId: string,
|
||||
now: Date,
|
||||
): Promise<number> {
|
||||
const { count } = await tx.accountGrant.updateMany({
|
||||
where: { grantorId: recordId, acceptedAt: null, revokedAt: null },
|
||||
data: { revokedAt: now, revokedBy: "GRANTOR" },
|
||||
});
|
||||
return count;
|
||||
}
|
||||
|
||||
/** What settling one Guardian's access did. */
|
||||
export interface HandoverGrantSettlement {
|
||||
/** The level the Guardian now holds. */
|
||||
access: HandoverAccess;
|
||||
/** The live grant afterwards, or null when access ended. */
|
||||
grantId: string | null;
|
||||
/** Whether a row was ended or written. */
|
||||
changed: boolean;
|
||||
/** How many of the Guardian's browser sessions were inside the record. */
|
||||
sessionsCleared: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set one former Guardian's access to a managed profile that is being, or has
|
||||
* just been, handed over. The one place a handover moves a grant.
|
||||
*
|
||||
* The rules of WHICH level to set live in `handover-access.ts`; this is the
|
||||
* mechanics, and it follows the state machine above:
|
||||
*
|
||||
* - the level a row carries never moves. Changing it ends the row
|
||||
* (`revokedBy = GRANTOR`, the record's owner deciding) and writes a new
|
||||
* one, so the history keeps "who had what, from when to when";
|
||||
* - ending a row and clearing the sessions inside it are one act, in the
|
||||
* caller's transaction, as {@link endAndClear} has it for the panel;
|
||||
* - a row that already says what the target says is left alone.
|
||||
*
|
||||
* The new row is written ACCEPTED, which is the one place this file creates a
|
||||
* grant without an acceptance, and it is argued rather than assumed. The
|
||||
* Guardian accepted MANAGE over this whole record when they became a Guardian;
|
||||
* a handover only ever sets a level at or below that, and only for somebody
|
||||
* who held it a moment earlier. A READ row cut from their MANAGE is a
|
||||
* narrowing they need no new consent for, and a MANAGE row the new owner
|
||||
* restores is the consent they already gave. What it cannot do is reach
|
||||
* anybody who was not a Guardian of this record, or reach above MANAGE.
|
||||
*
|
||||
* Callers hold the managed-profile lock (`withManagedProfileLock`) for the
|
||||
* transaction, so the live row read beside this call is the row it settles.
|
||||
*/
|
||||
export async function settleHandoverGuardianGrant(
|
||||
tx: Prisma.TransactionClient,
|
||||
input: {
|
||||
recordId: string;
|
||||
guardianId: string;
|
||||
/** The pair's live row (`revokedAt IS NULL`), or null. */
|
||||
liveGrant: Pick<AccountGrant, "id" | "access" | "scopeJson"> | null;
|
||||
target: HandoverAccess;
|
||||
now: Date;
|
||||
},
|
||||
): Promise<HandoverGrantSettlement> {
|
||||
const pair = { grantorId: input.recordId, granteeId: input.guardianId };
|
||||
const targetAccess = grantAccessFor(input.target);
|
||||
const live = input.liveGrant;
|
||||
|
||||
const alreadyThere =
|
||||
live !== null &&
|
||||
targetAccess !== null &&
|
||||
live.access === targetAccess &&
|
||||
(live.access === "MANAGE" || live.scopeJson === null);
|
||||
if (alreadyThere) {
|
||||
return {
|
||||
access: input.target,
|
||||
grantId: live.id,
|
||||
changed: false,
|
||||
sessionsCleared: 0,
|
||||
};
|
||||
}
|
||||
if (live === null && targetAccess === null) {
|
||||
return {
|
||||
access: "end",
|
||||
grantId: null,
|
||||
changed: false,
|
||||
sessionsCleared: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (live !== null) {
|
||||
await tx.accountGrant.updateMany({
|
||||
where: { id: live.id, revokedAt: null },
|
||||
data: { revokedAt: input.now, revokedBy: "GRANTOR" },
|
||||
});
|
||||
}
|
||||
|
||||
let grantId: string | null = null;
|
||||
if (targetAccess !== null) {
|
||||
const created = await tx.accountGrant.create({
|
||||
data: {
|
||||
grantorId: input.recordId,
|
||||
granteeId: input.guardianId,
|
||||
access: targetAccess,
|
||||
// `DbNull`, never `null`: see `inviteGrant` for why the two nulls
|
||||
// mean opposite things on this column.
|
||||
scopeJson: Prisma.DbNull,
|
||||
invitedAt: input.now,
|
||||
acceptedAt: input.now,
|
||||
expiresAt: null,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
grantId = created.id;
|
||||
}
|
||||
|
||||
const sessionsCleared = await clearActingSessions(pair, tx);
|
||||
return { access: input.target, grantId, changed: true, sessionsCleared };
|
||||
}
|
||||
|
||||
export interface RevokeGrantInput {
|
||||
grantId: string;
|
||||
/** The owner ending their own grant. Taken from the session. */
|
||||
|
||||
+31
-11
@@ -6,19 +6,39 @@ import { clientTransportSchema } from "@/lib/validations/client-transport";
|
||||
/** Minimum password length — must match checkPasswordStrength() in @/lib/auth/password.ts */
|
||||
const PASSWORD_MIN_LENGTH = 12;
|
||||
|
||||
/**
|
||||
* v1.42 (#959) — the prefix a managed profile's generated username carries
|
||||
* (`managed-<hex>`, `src/lib/managed-profiles/create.ts`). An account a person
|
||||
* names themselves may not start with it: a self-chosen `managed-…` would read
|
||||
* as a profile nobody signs into, in every list that shows usernames, and a
|
||||
* profile being claimed has to leave the generated name behind rather than
|
||||
* keep it.
|
||||
*/
|
||||
export const MANAGED_USERNAME_PREFIX = "managed-";
|
||||
|
||||
/** A username a person chooses: registration and claiming a profile. */
|
||||
export const accountUsernameSchema = z
|
||||
.string()
|
||||
.min(3, "At least 3 characters")
|
||||
.max(30, "Maximum 30 characters")
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, "Only letters, numbers, _ and -")
|
||||
.refine(
|
||||
(value) => !value.toLowerCase().startsWith(MANAGED_USERNAME_PREFIX),
|
||||
`A username cannot start with "${MANAGED_USERNAME_PREFIX}"`,
|
||||
);
|
||||
|
||||
/** A first password: registration and claiming a profile. */
|
||||
export const newAccountPasswordSchema = z
|
||||
.string()
|
||||
.min(
|
||||
PASSWORD_MIN_LENGTH,
|
||||
`Password must be at least ${PASSWORD_MIN_LENGTH} characters`,
|
||||
);
|
||||
|
||||
export const registerSchema = z.object({
|
||||
email: z.email("Invalid email address"),
|
||||
username: z
|
||||
.string()
|
||||
.min(3, "At least 3 characters")
|
||||
.max(30, "Maximum 30 characters")
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, "Only letters, numbers, _ and -"),
|
||||
password: z
|
||||
.string()
|
||||
.min(
|
||||
PASSWORD_MIN_LENGTH,
|
||||
`Password must be at least ${PASSWORD_MIN_LENGTH} characters`,
|
||||
),
|
||||
username: accountUsernameSchema,
|
||||
password: newAccountPasswordSchema,
|
||||
/**
|
||||
* v1.4.25 W7 — browser-detected timezone. The signup form pulls
|
||||
* `Intl.DateTimeFormat().resolvedOptions().timeZone` and sends it
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* v1.42 (#959) — the bodies the managed-profile handover accepts.
|
||||
*
|
||||
* Four requests, all `.strict()`: a Guardian minting the link, the anonymous
|
||||
* preview and claim on the far side of it, and the new owner's decision about
|
||||
* each former Guardian afterwards. The OpenAPI table publishes these exact
|
||||
* schemas, so they live here once rather than beside each route.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { HANDOVER_ACCESS_LEVELS } from "@/lib/managed-profiles/handover-access";
|
||||
import {
|
||||
accountUsernameSchema,
|
||||
newAccountPasswordSchema,
|
||||
} from "@/lib/validations/auth";
|
||||
|
||||
/** What a Guardian's access becomes after the handover. */
|
||||
export const handoverAccessSchema = z.enum(HANDOVER_ACCESS_LEVELS);
|
||||
|
||||
/**
|
||||
* A grant id as the server minted it (cuid). Bounded so a body cannot carry a
|
||||
* megabyte of identifiers into a lookup.
|
||||
*/
|
||||
const grantIdSchema = z.string().min(1).max(64);
|
||||
|
||||
/**
|
||||
* At most one entry per Guardian, and a profile has a handful. The cap is a
|
||||
* body-size floor rather than a product rule.
|
||||
*/
|
||||
const MAX_GUARDIAN_ENTRIES = 50;
|
||||
|
||||
/** `POST /api/managed-profiles/{id}/handover`. */
|
||||
export const createHandoverSchema = z
|
||||
.object({
|
||||
// How long the link stays valid, in days: seven unless the Guardian
|
||||
// chooses one or fourteen.
|
||||
expiresInDays: z
|
||||
.union([z.literal(1), z.literal(7), z.literal(14)])
|
||||
.default(7),
|
||||
proposals: z
|
||||
.array(
|
||||
z
|
||||
.object({ grantId: grantIdSchema, proposal: handoverAccessSchema })
|
||||
.strict(),
|
||||
)
|
||||
.max(MAX_GUARDIAN_ENTRIES)
|
||||
.default([])
|
||||
.refine(
|
||||
(entries) =>
|
||||
new Set(entries.map((entry) => entry.grantId)).size ===
|
||||
entries.length,
|
||||
{ message: "Name each guardian once" },
|
||||
),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* The token field of the two anonymous requests. Shape-bounded here only to
|
||||
* keep the body small; the `hlp_` shape itself is checked by the service so
|
||||
* that a malformed token and an unknown one answer identically.
|
||||
*/
|
||||
const claimTokenSchema = z.string().min(1).max(80);
|
||||
|
||||
/** `POST /api/auth/claim/preview`. */
|
||||
export const claimPreviewSchema = z
|
||||
.object({ token: claimTokenSchema })
|
||||
.strict();
|
||||
|
||||
/** `POST /api/auth/claim`. */
|
||||
export const claimSchema = z
|
||||
.object({
|
||||
token: claimTokenSchema,
|
||||
username: accountUsernameSchema,
|
||||
email: z.email("Invalid email address").max(254),
|
||||
password: newAccountPasswordSchema.max(1024),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** `POST /api/account/handover-decision`. */
|
||||
export const handoverDecisionSchema = z
|
||||
.object({
|
||||
decisions: z
|
||||
.array(
|
||||
z
|
||||
.object({ grantId: grantIdSchema, access: handoverAccessSchema })
|
||||
.strict(),
|
||||
)
|
||||
.max(MAX_GUARDIAN_ENTRIES)
|
||||
.refine(
|
||||
(entries) =>
|
||||
new Set(entries.map((entry) => entry.grantId)).size ===
|
||||
entries.length,
|
||||
{ message: "Name each guardian once" },
|
||||
),
|
||||
})
|
||||
.strict();
|
||||
Reference in new issue
Block a user