mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
Merge remote-tracking branch 'origin/main' into fix/v1434-illness-symptoms
This commit is contained in:
commit
e8e3cf4cd3
47 files changed
+2646
-553
No files matched your search
@@ -8,6 +8,8 @@ vi.mock("@/lib/db", () => ({
|
||||
apiToken: { findUnique: vi.fn(), update: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
webauthnMfaCredential: { count: vi.fn() },
|
||||
// The stale refusal names the proofs the account holds, passkeys among them.
|
||||
passkey: { count: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ vi.mock("@/lib/db", () => ({
|
||||
apiToken: { findUnique: vi.fn(), update: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
webauthnMfaCredential: { count: vi.fn() },
|
||||
// The stale refusal names the proofs the account holds, passkeys among them.
|
||||
passkey: { count: vi.fn() },
|
||||
stepUpElevation: { findUnique: vi.fn() },
|
||||
$queryRaw: vi.fn(),
|
||||
},
|
||||
|
||||
@@ -82,6 +82,46 @@ describe("requireFreshMfa", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The stale refusal names what clears it, so the browser can open the
|
||||
* re-proof dialog instead of printing a sentence (#959): the possession
|
||||
* proofs the account holds, never a password, which stamps `reproofAt` only
|
||||
* and would leave this gate shut.
|
||||
*/
|
||||
it("names the possession proofs on a stale refusal", async () => {
|
||||
mockSession({ ...MFA_USER, passwordHash: "argon2-hash" });
|
||||
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(1 as never);
|
||||
vi.mocked(prisma.passkey.count).mockResolvedValue(1 as never);
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
||||
mfaVerifiedAt: new Date(Date.now() - 10 * 60_000),
|
||||
} as never);
|
||||
|
||||
await expect(requireFreshMfa(5 * 60)).rejects.toMatchObject({
|
||||
errorCode: "auth.stepup.required",
|
||||
extraMeta: { methods: ["totp", "webauthn", "passkey"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("offers a passkey-only account its passkey, not its password", async () => {
|
||||
mockSession({
|
||||
...MFA_USER,
|
||||
totpConfirmedAt: null,
|
||||
passwordHash: "argon2-hash",
|
||||
});
|
||||
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
|
||||
vi.mocked(prisma.passkey.count).mockResolvedValue(1 as never);
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
||||
mfaVerifiedAt: null,
|
||||
} as never);
|
||||
|
||||
await expect(
|
||||
requireFreshMfa(5 * 60, "any-possession"),
|
||||
).rejects.toMatchObject({
|
||||
errorCode: "auth.stepup.required",
|
||||
extraMeta: { methods: ["passkey"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an absent verification stamp", async () => {
|
||||
mockSession(MFA_USER);
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
||||
@@ -93,9 +133,10 @@ describe("requireFreshMfa", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a user with neither TOTP nor a security key", async () => {
|
||||
it("rejects a user with neither TOTP nor a security key nor a passkey", async () => {
|
||||
mockSession({ ...MFA_USER, totpConfirmedAt: null });
|
||||
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
|
||||
vi.mocked(prisma.passkey.count).mockResolvedValue(0 as never);
|
||||
await expect(requireFreshMfa(5 * 60)).rejects.toMatchObject({
|
||||
errorCode: "auth.stepup.mfa_not_enrolled",
|
||||
});
|
||||
@@ -123,21 +164,50 @@ describe("requireFreshMfa", () => {
|
||||
* parameter, and it changes nothing else: the freshness read below is the same
|
||||
* read either way.
|
||||
*
|
||||
* A primary passkey is deliberately outside the default set and inside
|
||||
* `"any-possession"`. It DOES stamp `mfaVerifiedAt` on login, so it can clear
|
||||
* the gate — but counting it by default would pull every passkey-holding
|
||||
* Since v1.43.3 a primary passkey counts by default: `requireFreshMfa`
|
||||
* defaults to `"any-possession"`, so a passkey-only account clears the
|
||||
* managed-profile acts with a passkey re-proof. It DOES stamp `mfaVerifiedAt`
|
||||
* on login and on re-proof, so it can clear the gate. What stays narrow is
|
||||
* `hasSecondFactorEnrolled`: widening THAT would pull every passkey-holding
|
||||
* account into `requireFreshMfaIfEnrolled`'s gate on account deletion, the
|
||||
* data reset, the password change, the encrypted export and key rotation,
|
||||
* none of which have ever applied to them. The sibling suite
|
||||
* `require-fresh-mfa-if-enrolled.test.ts` is what catches that if the two
|
||||
* predicates are ever collapsed into one.
|
||||
* data reset, the password change, the encrypted export and key rotation.
|
||||
* The sibling suite `require-fresh-mfa-if-enrolled.test.ts` is what catches
|
||||
* that if the two predicates are ever collapsed into one.
|
||||
*/
|
||||
it("admits a passkey-only account by default, with a fresh stamp", async () => {
|
||||
mockSession({ ...MFA_USER, totpConfirmedAt: null });
|
||||
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
|
||||
vi.mocked(prisma.passkey.count).mockResolvedValue(1 as never);
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
||||
mfaVerifiedAt: new Date(Date.now() - 60_000),
|
||||
} as never);
|
||||
|
||||
const ctx = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
|
||||
expect(ctx.user.id).toBe("user-1");
|
||||
});
|
||||
|
||||
it("asks a passkey-only account for its passkey by default when the stamp is stale", async () => {
|
||||
mockSession({ ...MFA_USER, totpConfirmedAt: null });
|
||||
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
|
||||
vi.mocked(prisma.passkey.count).mockResolvedValue(1 as never);
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
||||
mfaVerifiedAt: null,
|
||||
} as never);
|
||||
|
||||
await expect(requireFreshMfa(5 * 60)).rejects.toMatchObject({
|
||||
errorCode: "auth.stepup.required",
|
||||
extraMeta: { methods: ["passkey"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a passkey-only account when only a second factor counts", async () => {
|
||||
mockSession({ ...MFA_USER, totpConfirmedAt: null });
|
||||
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
|
||||
vi.mocked(prisma.passkey.count).mockResolvedValue(1 as never);
|
||||
|
||||
await expect(requireFreshMfa(5 * 60)).rejects.toMatchObject({
|
||||
await expect(
|
||||
requireFreshMfa(5 * 60, "second-factor"),
|
||||
).rejects.toMatchObject({
|
||||
errorCode: "auth.stepup.mfa_not_enrolled",
|
||||
});
|
||||
});
|
||||
|
||||
+55
-8
@@ -1253,11 +1253,13 @@ export type FreshMfaContext = AuthContext & { mfaVerifiedAt: Date };
|
||||
* the route is protecting.
|
||||
*
|
||||
* `second-factor` — a confirmed TOTP secret or a registered second-factor
|
||||
* security key. The default, and the right question for the routes that MANAGE
|
||||
* a second factor: there is nothing there to protect if nothing is enrolled.
|
||||
* security key. The default of `requireMfaManagementAuth`, and the right
|
||||
* question for the routes that MANAGE a second factor: there is nothing there
|
||||
* to protect if nothing is enrolled.
|
||||
*
|
||||
* `any-possession` — the above, OR a registered PRIMARY passkey. The right
|
||||
* question for a route protecting the passkey itself, because a passkey is a
|
||||
* `any-possession` — the above, OR a registered PRIMARY passkey. The default
|
||||
* of `requireFreshMfa` since v1.43.3, and the right question for a route
|
||||
* protecting the passkey itself, because a passkey is a
|
||||
* possession factor in its own right and this codebase already treats it as
|
||||
* one in three places: `/api/auth/passkey/login-verify` stamps `mfaVerifiedAt`
|
||||
* on a passkey login (v1.23, M-review M1), `passkey` is in
|
||||
@@ -1270,6 +1272,37 @@ export type FreshMfaContext = AuthContext & { mfaVerifiedAt: Date };
|
||||
*/
|
||||
export type FreshFactorProofSource = "second-factor" | "any-possession";
|
||||
|
||||
/*
|
||||
* A PASSKEY COUNTS AS A SECOND FACTOR FOR THE UNCONDITIONAL STEP-UP GATE.
|
||||
*
|
||||
* Maintainer decision, v1.43.3. `requireFreshMfa` defaults to
|
||||
* `any-possession`, so an account whose only strong credential is a passkey
|
||||
* is no longer refused `mfa_not_enrolled` on the routes that call it directly
|
||||
* (creating, editing, deleting and handing over a managed profile, inviting
|
||||
* and removing a guardian, the admin key rotation). It clears the gate the
|
||||
* way every other account does: a passkey sign-in or a passkey re-proof at
|
||||
* `POST /api/auth/reproof` stamps `Session.mfaVerifiedAt`, and the gate reads
|
||||
* that stamp inside the same five minutes.
|
||||
*
|
||||
* The limits, each deliberate:
|
||||
*
|
||||
* - `hasSecondFactorEnrolled` keeps its narrow meaning. It decides whether
|
||||
* the CONDITIONAL gates (`requireFreshMfaIfEnrolled`,
|
||||
* `requireFreshMfaOrElevationIfEnrolled`, `requireRecentProof`'s branches,
|
||||
* the enrollment proofs) apply at all. Widening it would newly gate
|
||||
* account deletion, the data reset, the password change and the export
|
||||
* for every passkey holder, take the password re-proof away from them, and
|
||||
* change what the native app's Bearer requests need. None of that is part
|
||||
* of the decision.
|
||||
* - Password login is untouched. It asks for a second factor from
|
||||
* `totpConfirmedAt` and the security-key count only; a passkey-only
|
||||
* account signing in with its password is not challenged, as before.
|
||||
* - The MFA-management routes (`requireMfaManagementAuth`) still default to
|
||||
* `second-factor`: disabling TOTP or rotating recovery codes on an account
|
||||
* with no second factor has nothing to protect.
|
||||
* - Cookie only, as ever. The Bearer path is not reached from here.
|
||||
*/
|
||||
|
||||
/**
|
||||
* v1.23 — step-up gate. Passes only for a COOKIE session whose
|
||||
* `Session.mfaVerifiedAt` is within `maxAgeSeconds` AND whose user holds a
|
||||
@@ -1285,12 +1318,13 @@ export type FreshFactorProofSource = "second-factor" | "any-possession";
|
||||
* waves gate account deletion, key rotation, and passphrase export on it.
|
||||
*
|
||||
* @param proofSource which credentials count as "can prove a factor". Defaults
|
||||
* to the second-factor set every existing caller means; see
|
||||
* {@link FreshFactorProofSource}.
|
||||
* to `any-possession`, so a primary passkey counts (see the decision note
|
||||
* below {@link FreshFactorProofSource}); `requireMfaManagementAuth` passes
|
||||
* `second-factor` explicitly.
|
||||
*/
|
||||
export async function requireFreshMfa(
|
||||
maxAgeSeconds: number,
|
||||
proofSource: FreshFactorProofSource = "second-factor",
|
||||
proofSource: FreshFactorProofSource = "any-possession",
|
||||
): Promise<FreshMfaContext> {
|
||||
const sessionData = await getSession();
|
||||
if (!sessionData)
|
||||
@@ -1321,7 +1355,20 @@ export async function requireFreshMfa(
|
||||
});
|
||||
const verifiedAt = row?.mfaVerifiedAt ?? null;
|
||||
if (!verifiedAt || verifiedAt.getTime() < Date.now() - maxAgeSeconds * 1000) {
|
||||
throw new StepUpRequiredError();
|
||||
// Name the proofs that clear this gate, the way the recent-proof gate
|
||||
// does, so the browser can open the re-proof dialog instead of printing a
|
||||
// sentence with nowhere to act on it. Only the possession proofs: a
|
||||
// `POST /api/auth/reproof` with one of them stamps `mfaVerifiedAt`, a
|
||||
// password stamps `reproofAt` only and would leave this gate shut.
|
||||
const methods = (await recentProofMethods(sessionData.user)).filter(
|
||||
(method) => method !== "password",
|
||||
);
|
||||
annotate({ meta: { step_up_methods: methods.join(",") } });
|
||||
throw new StepUpRequiredError(
|
||||
"auth.stepup.required",
|
||||
"Recent second-factor verification required",
|
||||
{ methods },
|
||||
);
|
||||
}
|
||||
|
||||
return { ...sessionData, authMethod: "cookie", mfaVerifiedAt: verifiedAt };
|
||||
|
||||
@@ -21,6 +21,13 @@ import { prisma } from "@/lib/db";
|
||||
* a behaviour change to five destructive actions, made in passing, for the
|
||||
* benefit of a sixth. `canProveFreshFactor` in `api-handler.ts` is where the wider question
|
||||
* lives.
|
||||
*
|
||||
* Since v1.43.3 a passkey DOES count as a second factor where the gate is
|
||||
* unconditional: `requireFreshMfa` defaults to `any-possession`, so a
|
||||
* passkey-only account clears the managed-profile acts with a passkey
|
||||
* re-proof instead of being told to enrol something. That decision was made
|
||||
* there and not here, on purpose; the note beside `FreshFactorProofSource`
|
||||
* lists what it does and does not change.
|
||||
*/
|
||||
export async function hasSecondFactorEnrolled(user: {
|
||||
id: string;
|
||||
|
||||
@@ -75,7 +75,7 @@ const accountGrantInvite = inviteGrantSchema.meta({
|
||||
const createManagedProfileRequest = createManagedProfileSchema.meta({
|
||||
id: "CreateManagedProfileRequest",
|
||||
description:
|
||||
"Create a health record for somebody who has no login of their own — a child, a dependent adult, an animal. The caller becomes its first Guardian in the same transaction, through an ordinary MANAGE grant with the new record as grantor, so the profile appears in `accountAccess.accounts` on the next `GET /api/auth/me` and is switched into like any other shared record. `displayName` is the only required text; `dateOfBirth` is an optional `YYYY-MM-DD` and is never synthesised from a year. `gender` is optional and takes the same three values `PATCH /api/auth/me` takes for an ordinary account (`MALE`, `FEMALE`, `OTHER`) or null; absent and null both mean not recorded, and the value is what the cycle module derives its default from, so a record created for a child who does not need it starts with it off. `locale` and `timezone` belong to the RECORD and not to the Guardian reading it: they decide how the profile's own reminders are worded and when its day starts. Strict — an unexpected field is a 422 rather than an ignored key. Cookie transport only and step-up gated (`requireFreshMfa`, unconditional): a Bearer caller cannot mint a credential-less person and a permanent management relationship, and an account with no second factor enrolled is refused rather than waved through. Rate-limited to ten an hour per caller.",
|
||||
"Create a health record for somebody who has no login of their own — a child, a dependent adult, an animal. The caller becomes its first Guardian in the same transaction, through an ordinary MANAGE grant with the new record as grantor, so the profile appears in `accountAccess.accounts` on the next `GET /api/auth/me` and is switched into like any other shared record. `displayName` is the only required text; `dateOfBirth` is an optional `YYYY-MM-DD` and is never synthesised from a year. `gender` is optional and takes the same three values `PATCH /api/auth/me` takes for an ordinary account (`MALE`, `FEMALE`, `OTHER`) or null; absent and null both mean not recorded, and the value is what the cycle module derives its default from, so a record created for a child who does not need it starts with it off. `locale` and `timezone` belong to the RECORD and not to the Guardian reading it: they decide how the profile's own reminders are worded and when its day starts. Strict — an unexpected field is a 422 rather than an ignored key. Cookie transport only and step-up gated (`requireFreshMfa`, unconditional): a Bearer caller cannot mint a credential-less person and a permanent management relationship, and an account with no second factor enrolled is refused rather than waved through. A primary passkey counts as that factor here: a passkey sign-in or a passkey re-proof at POST /api/auth/reproof stamps the session. Rate-limited to ten an hour per caller.",
|
||||
});
|
||||
|
||||
const updateManagedProfileRequest = updateManagedProfileSchema.meta({
|
||||
@@ -1089,7 +1089,7 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
"401": {
|
||||
description:
|
||||
"No fresh second-factor proof (`meta.errorCode: auth.stepup.required`), or none enrolled at all (`meta.errorCode: auth.stepup.mfa_not_enrolled`) — this gate is unconditional, so an account without a second factor cannot create a managed profile until it enrols one. The ordinary unauthenticated 401 arrives here too, including for a Bearer caller, whose transport this gate does not read.",
|
||||
"No fresh second-factor proof (`meta.errorCode: auth.stepup.required`, with `meta.methods` naming the proofs that clear it at POST /api/auth/reproof), or none enrolled at all (`meta.errorCode: auth.stepup.mfa_not_enrolled`) — this gate is unconditional, so an account with neither a second factor nor a passkey cannot create a managed profile until it enrols one. A passkey counts: an account whose only strong credential is a passkey is offered `passkey` in `meta.methods`. The ordinary unauthenticated 401 arrives here too, including for a Bearer caller, whose transport this gate does not read.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"429": {
|
||||
|
||||
@@ -1099,7 +1099,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Auth"],
|
||||
summary: "Re-prove a credential on a browser session (cookie only)",
|
||||
description:
|
||||
"Stamps the calling browser session as recently re-proved, so for the next five minutes it may run the actions that ask for a fresh proof: GET /api/export/full-backup, POST /api/export/encrypted, POST /api/share-links, POST /api/mcp/tokens, POST /api/tokens/measurements, POST /api/tokens/workouts, POST /api/tokens/documents, and the admin backup download, upload and restore, the admin data wipe and the admin password reset. Those answer 401 `auth.reproof.required` with `meta.methods` (the proofs this account can give) when the session signed in or re-proved more than five minutes ago.\n\n" +
|
||||
"Stamps the calling browser session as recently re-proved, so for the next five minutes it may run the actions that ask for a fresh proof: GET /api/export/full-backup, POST /api/export/encrypted, POST /api/share-links, POST /api/mcp/tokens, POST /api/tokens/measurements, POST /api/tokens/workouts, POST /api/tokens/documents, and the admin backup download, upload and restore, the admin data wipe and the admin password reset. Those answer 401 `auth.reproof.required` with `meta.methods` (the proofs this account can give) when the session signed in or re-proved more than five minutes ago. A `totp`, `webauthn` or `passkey` proof here also clears the routes that ask for a fresh second factor (the managed-profile acts, the second-factor and passkey changes, the password change, account deletion, the data wipe and key rotation): on a browser session those answer 401 `auth.stepup.required` with `meta.methods` naming the possession proofs the account holds, and the web re-proves here and retries.\n\n" +
|
||||
'The body takes the same shapes as POST /api/auth/step-up. On an account with a second factor only `totp`, `webauthn` (a security key) or `passkey` are accepted, because a password has never stood in for the second factor; on one without, `password` or `passkey`. A method the account cannot use here is refused with 422 `auth.reproof.too_weak` and `meta.methods`, before any guess is spent. A second-factor or passkey proof also refreshes the session\'s second-factor stamp, exactly as signing in again would. Assertions begin at POST /api/auth/passkey/register-options with `{ method: "passkey" | "webauthn" }`.\n\n' +
|
||||
"Proofs draw on the account's shared re-proof budget (five per fifteen minutes, 429 once spent). A proof that does not verify is 401 `auth.reproof.failed` and is audited. Bearer callers use POST /api/auth/step-up instead.",
|
||||
requestBody: {
|
||||
|
||||
@@ -116,7 +116,9 @@ describe("what a managed-profile change refreshes", () => {
|
||||
|
||||
describe("the creation hook, driven for real", () => {
|
||||
it("posts the four fields the strict schema accepts, and no others", async () => {
|
||||
const options = useCreateManagedProfile() as unknown as {
|
||||
const options = useCreateManagedProfile((action) =>
|
||||
action(),
|
||||
) as unknown as {
|
||||
mutationFn: (input: unknown) => Promise<unknown>;
|
||||
onSuccess: () => void;
|
||||
};
|
||||
@@ -147,7 +149,9 @@ describe("the creation hook, driven for real", () => {
|
||||
});
|
||||
|
||||
it("puts the new profile in the switcher by refreshing the payload", () => {
|
||||
const options = useCreateManagedProfile() as unknown as {
|
||||
const options = useCreateManagedProfile((action) =>
|
||||
action(),
|
||||
) as unknown as {
|
||||
onSuccess: () => void;
|
||||
};
|
||||
options.onSuccess();
|
||||
|
||||
@@ -108,21 +108,34 @@ function useInvalidateManagedProfiles() {
|
||||
return () => invalidateManagedProfileReads(queryClient);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a request that the server may hold back for a fresh proof — in a
|
||||
* component, `useRecentProof().run`.
|
||||
*
|
||||
* Every write in this family resolves `requireFreshMfa`, whose 401
|
||||
* `auth.stepup.required` names the proofs that clear it in `meta.methods`.
|
||||
* The guard opens the re-proof dialog on that answer, posts the proof and
|
||||
* sends the request once more, so the mutation settles with the real outcome
|
||||
* instead of a sentence telling somebody to confirm a factor with nowhere to
|
||||
* do it. The argument is required rather than defaulted: a caller that forgot
|
||||
* it would compile, render the refusal as text, and be the defect again.
|
||||
*/
|
||||
export type ProofGuard = <T>(action: () => Promise<T>) => Promise<T>;
|
||||
|
||||
/**
|
||||
* Create a record somebody looks after.
|
||||
*
|
||||
* `requireFreshMfa` guards the route, so a 401 carrying
|
||||
* `meta.errorCode: auth.stepup.required` is the EXPECTED first answer for
|
||||
* anybody who has a second factor and has not proved it recently. The form
|
||||
* routes that into the app's re-verification path rather than into a generic
|
||||
* failure — see `managed-profile-create-form.tsx`.
|
||||
* anybody who has a second factor and has not proved it recently. `guard`
|
||||
* turns it into the re-proof dialog and a second attempt.
|
||||
*/
|
||||
export function useCreateManagedProfile() {
|
||||
export function useCreateManagedProfile(guard: ProofGuard) {
|
||||
const invalidate = useInvalidateManagedProfiles();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.managedProfileCreate(),
|
||||
mutationFn: (input: CreateManagedProfileInput) =>
|
||||
apiPost<ManagedProfileView>("/api/managed-profiles", input),
|
||||
guard(() => apiPost<ManagedProfileView>("/api/managed-profiles", input)),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
@@ -151,21 +164,23 @@ export function useManagedProfile(profileId: string, enabled = true) {
|
||||
*
|
||||
* Guarded by `requireFreshMfa` like every other act in this family, so a 401
|
||||
* carrying `meta.errorCode: auth.stepup.required` is the expected first answer
|
||||
* rather than a failure — the form routes it into the re-verification path.
|
||||
* rather than a failure — `guard` routes it into the re-proof dialog.
|
||||
*
|
||||
* The whole family is invalidated on success because the display name is what
|
||||
* the switcher, the record banner and this card all identify the record by, and
|
||||
* the record's own identity read is dropped alongside so a second edit opens
|
||||
* from the server's version rather than from the one that was just replaced.
|
||||
*/
|
||||
export function useUpdateManagedProfile() {
|
||||
export function useUpdateManagedProfile(guard: ProofGuard) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.managedProfileUpdate(),
|
||||
mutationFn: (input: UpdateManagedProfileInput) =>
|
||||
apiPatch<ManagedProfileView>(
|
||||
`/api/managed-profiles/${input.profileId}`,
|
||||
managedProfileEditBody(input),
|
||||
guard(() =>
|
||||
apiPatch<ManagedProfileView>(
|
||||
`/api/managed-profiles/${input.profileId}`,
|
||||
managedProfileEditBody(input),
|
||||
),
|
||||
),
|
||||
onSuccess: (_result, input) => {
|
||||
invalidateManagedProfileReads(queryClient);
|
||||
@@ -250,12 +265,14 @@ export function useManagedProfileGuardians(profileId: string, enabled = true) {
|
||||
* floor. A Guardian may not strand a record with nobody looking after it, but
|
||||
* they may end the record.
|
||||
*/
|
||||
export function useDeleteManagedProfile() {
|
||||
export function useDeleteManagedProfile(guard: ProofGuard) {
|
||||
const invalidate = useInvalidateManagedProfiles();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.managedProfileDelete(),
|
||||
mutationFn: (profileId: string) =>
|
||||
apiDelete<{ deleted: true }>(`/api/managed-profiles/${profileId}`),
|
||||
guard(() =>
|
||||
apiDelete<{ deleted: true }>(`/api/managed-profiles/${profileId}`),
|
||||
),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
@@ -285,14 +302,16 @@ export interface InviteManagedProfileGuardianInput {
|
||||
* here accepts on their behalf, and until they accept they do not count toward
|
||||
* the last-Guardian floor.
|
||||
*/
|
||||
export function useInviteManagedProfileGuardian() {
|
||||
export function useInviteManagedProfileGuardian(guard: ProofGuard) {
|
||||
const invalidate = useInvalidateManagedProfiles();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.managedProfileGuardianInvite(),
|
||||
mutationFn: (input: InviteManagedProfileGuardianInput) =>
|
||||
apiPost(
|
||||
`/api/managed-profiles/${input.profileId}/guardians`,
|
||||
guardianInviteBody(input),
|
||||
guard(() =>
|
||||
apiPost(
|
||||
`/api/managed-profiles/${input.profileId}/guardians`,
|
||||
guardianInviteBody(input),
|
||||
),
|
||||
),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
@@ -328,7 +347,7 @@ export function guardianInviteBody(input: InviteManagedProfileGuardianInput): {
|
||||
* `variables` carries both ids so a refusal can be rendered on the guardian row
|
||||
* it belongs to rather than on the panel.
|
||||
*/
|
||||
export function useRemoveManagedProfileGuardian() {
|
||||
export function useRemoveManagedProfileGuardian(guard: ProofGuard) {
|
||||
const invalidate = useInvalidateManagedProfiles();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.managedProfileGuardianRemove(),
|
||||
@@ -338,7 +357,10 @@ export function useRemoveManagedProfileGuardian() {
|
||||
}: {
|
||||
profileId: string;
|
||||
grantId: string;
|
||||
}) => apiDelete(`/api/managed-profiles/${profileId}/guardians/${grantId}`),
|
||||
}) =>
|
||||
guard(() =>
|
||||
apiDelete(`/api/managed-profiles/${profileId}/guardians/${grantId}`),
|
||||
),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
@@ -409,19 +431,22 @@ export function useManagedProfileHandover(profileId: string, enabled = true) {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 401 carrying `auth.stepup.required` is the expected first answer, and
|
||||
* `guard` turns it into the re-proof dialog and a second attempt.
|
||||
*
|
||||
* 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() {
|
||||
export function useCreateManagedProfileHandover(guard: ProofGuard) {
|
||||
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),
|
||||
const link = await guard(() =>
|
||||
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.
|
||||
|
||||
Reference in new issue
Block a user