Files
HealthLog/src/lib/queries/use-managed-profiles.ts
T
Marc-André Bombeck 99f0607a69 fix(auth): let every fresh-factor refusal open the re-proof dialog
A Guardian signed in through single sign-on with a second factor enrolled
could not hand a managed profile over once five minutes had passed since
sign-in: the handover answered 401 auth.stepup.required, and the panel
printed "Confirm your second factor, then try again" with no way to do so.

The same held for every action behind requireFreshMfa: creating, editing
and ending a managed profile, adding and removing a Guardian, disabling
the second factor, rotating recovery codes, removing a security key or a
passkey, changing the password, the data wipe, account deletion and the
key rotation. The recent-proof routes had the dialog; these had text.

- requireFreshMfa names the proofs that clear a stale stamp in
  meta.methods (the possession proofs the account holds, never a
  password, which stamps reproofAt only).
- The re-proof flow treats that refusal like auth.reproof.required:
  open the dialog, post the proof to /api/auth/reproof, send the request
  once more. The flow moves into a React-free module so it can be driven
  in tests; the hook binds it to the dialog.
- The managed-profile mutation hooks take a required proof guard, and
  every caller passes recentProof.run. The other surfaces wrap their
  requests in recentProof.run; disabling the second factor asks before
  its confirmation opens, so the code typed there is not spent twice.
- An account with nothing to re-prove in place (single sign-on only, no
  factor) is told to sign in again when adding a factor, instead of being
  offered a password it does not have.
- step-up-web-caller-guard freezes the gated route methods and fails when
  a web request to one of them runs outside the flow.

An account whose only second factor is the identity provider's is still
asked for an app factor on the managed-profile acts: the app cannot see
the provider's factor. Enrolling right after a sign-in needs no further
proof, and the confirmation stamps the session, so the act follows.

Refs #959
2026-10-17 09:17:03 +02:00

484 lines
18 KiB
TypeScript

"use client";
/**
* v1.37.0 — the reads and writes behind the managed-profile surface.
*
* A managed profile is a record with no login of its own. It is created by the
* person who will look after it, and from that moment it exists only through
* its Guardians — which is why every call here refreshes the ACCOUNT payload
* and not merely the panel it was fired from.
*
* That is the one property worth stating up front. The list of profiles a
* Guardian looks after is not a read of its own: it is `accountAccess.accounts`
* on `GET /api/auth/me`, filtered to the managed entries, because the Guardian's
* relationship to the profile IS a MANAGE grant. So a creation that refreshed
* the sharing panel and not the payload would leave the new profile invisible —
* in the switcher, in the banner, and in this card — until the next boot. Every
* mutation below therefore goes through `invalidateGrantReads`, the one entry
* `use-account-grants.ts` already publishes for exactly this fan-out; the
* managed-profile family rides it as a key prefix rather than as a second
* invalidation path (`grantDependentKeys`).
*/
import type { HandoverMethod } from "@/lib/validations/claim-sso";
import {
useMutation,
useQuery,
useQueryClient,
type QueryClient,
} from "@tanstack/react-query";
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
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.
*
* The route's schema is `.strict()`, so an extra key is a 422 with nothing
* useful in it — a form that helpfully posted an empty `heightCm` would be
* refused for a field the person never saw. Naming the four fields as a type
* rather than passing the form state through is what stops that: a fifth field
* added to the form cannot reach the wire without somebody deciding it should.
*/
export interface CreateManagedProfileInput {
displayName: string;
/** ISO `yyyy-MM-dd`, or null. Never synthesised from a year. */
dateOfBirth: string | null;
locale: Locale;
/** IANA zone name. */
timezone: string;
/** `User.gender`. Null is a real answer — "not recorded" — not an omission. */
gender: ManagedProfileGender;
}
/** The three values `User.gender` takes, plus the honest absence. */
export type ManagedProfileGender = "MALE" | "FEMALE" | "OTHER" | null;
/** What the create, read and update routes all answer with. */
export interface ManagedProfileView {
id: string;
displayName: string | null;
dateOfBirth: string | null;
gender: ManagedProfileGender;
/** Nullable, as the column is. A record created here always carries one. */
locale: string | null;
timezone: string;
recordKind: "managed";
}
/**
* The body `PATCH /api/managed-profiles/{id}` accepts, plus the record it is
* addressed to.
*
* Every field optional, at least one required, and `.strict()` on the server —
* so the mutation names the fields one by one rather than spreading form state,
* exactly as the creation does. An absent key means "leave it"; an explicit
* null on the two nullable fields clears it. `profileId` addresses the request
* and is deliberately not in the body: the schema would refuse it.
*/
export interface UpdateManagedProfileInput {
profileId: string;
displayName?: string;
dateOfBirth?: string | null;
locale?: Locale;
timezone?: string;
gender?: ManagedProfileGender;
}
/**
* Everything a managed-profile lifecycle change makes stale.
*
* Exported as a plain function over a `QueryClient` for the same reason
* `invalidateGrantReads` is: a test can hand it a real client, seed the reads
* and check they came back invalidated. "The mutation calls invalidate" is a
* claim about the source; "the account payload comes back invalidated" is the
* claim that decides whether the new profile appears in the switcher.
*/
export function invalidateManagedProfileReads(queryClient: QueryClient): void {
invalidateGrantReads(queryClient);
}
/** The same refresh, bound to the mounted client. */
function useInvalidateManagedProfiles() {
const queryClient = useQueryClient();
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. `guard`
* turns it into the re-proof dialog and a second attempt.
*/
export function useCreateManagedProfile(guard: ProofGuard) {
const invalidate = useInvalidateManagedProfiles();
return useMutation({
mutationKey: queryKeys.managedProfileCreate(),
mutationFn: (input: CreateManagedProfileInput) =>
guard(() => apiPost<ManagedProfileView>("/api/managed-profiles", input)),
onSuccess: invalidate,
});
}
/**
* The record as it stands, for the form that is about to change it.
*
* The account payload names a managed record and nothing more, so a form
* opened from the Guardian's own panel cannot show the timezone, the language,
* the date of birth or the sex it is about to overwrite. This is that read.
* `enabled` is what keeps it off the wire until somebody opens the form: a
* Guardian with four records should not fetch four identities to paint a list
* that shows none of them.
*/
export function useManagedProfile(profileId: string, enabled = true) {
return useQuery({
queryKey: queryKeys.managedProfile(profileId),
queryFn: () =>
apiGet<ManagedProfileView>(`/api/managed-profiles/${profileId}`),
enabled,
});
}
/**
* Change a record somebody looks after.
*
* 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 — `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(guard: ProofGuard) {
const queryClient = useQueryClient();
return useMutation({
mutationKey: queryKeys.managedProfileUpdate(),
mutationFn: (input: UpdateManagedProfileInput) =>
guard(() =>
apiPatch<ManagedProfileView>(
`/api/managed-profiles/${input.profileId}`,
managedProfileEditBody(input),
),
),
onSuccess: (_result, input) => {
invalidateManagedProfileReads(queryClient);
void queryClient.invalidateQueries({
queryKey: queryKeys.managedProfile(input.profileId),
});
},
});
}
/**
* The wire body, composed once and exported.
*
* Pure and separate from the mutation for the same reason `guardianInviteBody`
* is: the integration suite posts the REAL body through the REAL route rather
* than a hand-written copy, which is the assembly a two-sided test misses.
*
* Each key is present only when the caller named it, because the route reads
* absence as "leave it" and an explicit `undefined` would serialise to nothing
* anyway — stating it is what stops a later spread from sending five keys for a
* one-field edit.
*/
export function managedProfileEditBody(
input: UpdateManagedProfileInput,
): Record<string, unknown> {
return {
...(input.displayName !== undefined
? { displayName: input.displayName }
: {}),
...(input.dateOfBirth !== undefined
? { dateOfBirth: input.dateOfBirth }
: {}),
...(input.locale !== undefined ? { locale: input.locale } : {}),
...(input.timezone !== undefined ? { timezone: input.timezone } : {}),
...(input.gender !== undefined ? { gender: input.gender } : {}),
};
}
/** One person who looks after a profile, as the roster publishes them. */
export interface ManagedProfileGuardian {
/** The grant row — what `DELETE .../guardians/{grantId}` takes. */
grantId: string;
account: GrantParty;
/**
* PENDING or ACTIVE. Only ACTIVE counts toward the last-Guardian floor, and
* the server counts the same way — a pending invitation does not let the
* last Guardian leave.
*/
state: "PENDING" | "ACTIVE";
invitedAt: string;
acceptedAt: string | null;
}
/**
* Who else looks after this profile.
*
* The read the removal control needs to exist at all: a Guardian grant has the
* PROFILE as grantor, so it never appears in the caller's own given-list and
* `GET /api/account/grants` cannot supply the `grantId`. It is also where the
* last-Guardian floor is counted from BEFORE the act rather than discovered as
* a 409 after it.
*/
export function useManagedProfileGuardians(profileId: string, enabled = true) {
return useQuery({
queryKey: queryKeys.managedProfileGuardians(profileId),
queryFn: () =>
apiGet<ManagedProfileGuardian[]>(
`/api/managed-profiles/${profileId}/guardians`,
),
enabled,
});
}
/**
* Delete a managed profile, and everything recorded in it.
*
* The variable is the profile id, which is what lets a refusal be rendered on
* the row it was refused for rather than somewhere near it.
*
* This route emits no last-Guardian refusal, and that is deliberate rather
* than an omission: deleting the profile is the documented way OUT of the
* floor. A Guardian may not strand a record with nobody looking after it, but
* they may end the record.
*/
export function useDeleteManagedProfile(guard: ProofGuard) {
const invalidate = useInvalidateManagedProfiles();
return useMutation({
mutationKey: queryKeys.managedProfileDelete(),
mutationFn: (profileId: string) =>
guard(() =>
apiDelete<{ deleted: true }>(`/api/managed-profiles/${profileId}`),
),
onSuccess: invalidate,
});
}
/**
* Exactly the body `POST /api/managed-profiles/{id}/guardians` accepts, plus
* the profile the request is addressed to.
*
* The route's schema is `.strict()` and names two fields. `identifier` is the
* invitee's username OR e-mail, matched case-insensitively — the same
* identifier the ordinary grant invitation takes. `expiresAt` is an ISO
* datetime WITH an offset: a bare `YYYY-MM-DD` is a 422, which is why the
* caller runs it through the shared end-of-day helper rather than sending the
* date field's raw value.
*/
export interface InviteManagedProfileGuardianInput {
profileId: string;
identifier: string;
expiresAt: string | null;
}
/**
* Offer somebody else a share in looking after this profile.
*
* The grant is created PENDING and reaches the invitee's ordinary "invitations
* to you" card, where they accept it exactly as they would any other. Nothing
* here accepts on their behalf, and until they accept they do not count toward
* the last-Guardian floor.
*/
export function useInviteManagedProfileGuardian(guard: ProofGuard) {
const invalidate = useInvalidateManagedProfiles();
return useMutation({
mutationKey: queryKeys.managedProfileGuardianInvite(),
mutationFn: (input: InviteManagedProfileGuardianInput) =>
guard(() =>
apiPost(
`/api/managed-profiles/${input.profileId}/guardians`,
guardianInviteBody(input),
),
),
onSuccess: invalidate,
});
}
/**
* The wire body, composed once and exported.
*
* Pure and separate from the mutation so the integration suite can post the
* REAL body through the REAL route rather than a hand-written copy of it. A
* hand-written copy is the shape of test that stays green while the client
* renames a field: both ends prove themselves and the assembly between them is
* what dropped it.
*
* `profileId` addresses the request and is deliberately not in the body: the
* route's schema is `.strict()` and would refuse it.
*/
export function guardianInviteBody(input: InviteManagedProfileGuardianInput): {
identifier: string;
expiresAt: string | null;
} {
return { identifier: input.identifier, expiresAt: input.expiresAt };
}
/**
* End another Guardian's access.
*
* Another Guardian's, never one's own: the service refuses a target grant whose
* grantee is the caller and the route turns that into the same 404 an unknown
* profile gets. A Guardian steps away through the renounce control on the
* shared-access panel instead.
*
* `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(guard: ProofGuard) {
const invalidate = useInvalidateManagedProfiles();
return useMutation({
mutationKey: queryKeys.managedProfileGuardianRemove(),
mutationFn: ({
profileId,
grantId,
}: {
profileId: string;
grantId: string;
}) =>
guard(() =>
apiDelete(`/api/managed-profiles/${profileId}/guardians/${grantId}`),
),
onSuccess: invalidate,
});
}
// ── Handover (v1.42, #959) ──────────────────────────────────────────────────
/** What `GET /api/managed-profiles/{id}/handover` answers. */
export interface ManagedProfileHandoverStatus {
/** False only when no way to redeem a link exists on this instance. */
available: boolean;
/**
* v1.43 — how the person can redeem the link here. Absent from an older
* server, which only offered the password.
*/
methods?: HandoverMethod[];
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, 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(guard: ProofGuard) {
const queryClient = useQueryClient();
return useMutation({
mutationKey: queryKeys.managedProfileHandoverCreate(),
mutationFn: async (input: CreateManagedProfileHandoverInput) => {
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.
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),
});
},
});
}