feat(web): step-up modal and MFA settings controls

Adds the global re-authentication modal for guarded routes and the
owner/admin MFA controls on the settings page.

request() in lib/api.ts now intercepts a 403 step_up_required response,
awaits re-authentication through a callback registered by StepUpModal
(lib/stepup.ts), and retries the original request exactly once. The
modal offers TOTP, recovery code and password, since the webauthn
step-up routes (/me/step-up/webauthn/begin and /finish) are not
registered server-side yet; it omits the passkey option rather than
calling a route that does not exist.

me.stepUp posts one factor to /api/me/step-up. The settings page gains
an owner-only "Require MFA" toggle and the members table gains an MFA
column and a "Reset MFA" action, both routed through the existing
PUT /api/settings and DELETE /api/org/users/:id/mfa.
This commit is contained in:
2026-09-16 09:33:50 +00:00
parent 32fd11cde7
commit 3e341b17ec
6 changed files with 330 additions and 14 deletions
+43 -3
View File
@@ -1,3 +1,5 @@
import { requestStepUp } from "./stepup";
export type ServerStatus = "pending" | "active" | "offline";
export type KeySource = "uploaded" | "generated";
@@ -297,6 +299,8 @@ export interface Settings {
workflow_log_retention_days?: number | null;
local_login_enabled?: boolean;
api_token_max_days?: number | null;
/** Owner-only: members without a factor must enrol at their next sign-in. */
require_mfa?: boolean;
}
export type ApiToken = {
@@ -564,6 +568,7 @@ export interface InstanceUser {
hq_user_id?: string;
created_at: string;
last_login?: string;
mfa_enabled: boolean;
}
export interface OrgUserInput {
@@ -641,7 +646,10 @@ export class ApiError extends Error {
}
}
async function request<T>(path: string, options?: RequestInit): Promise<T> {
/** RequestInit plus the internal flag that stops a step-up retry from looping. */
type ApiRequestInit = RequestInit & { __retried?: boolean };
async function request<T>(path: string, options?: ApiRequestInit): Promise<T> {
const res = await fetch(`/api${path}`, {
credentials: "include",
headers: {
@@ -655,11 +663,28 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
const text = await res.text().catch(() => "");
let message = text || res.statusText || `HTTP ${res.status}`;
let code: string | undefined;
let methods: string[] | undefined;
try {
const body = JSON.parse(text);
const body = JSON.parse(text || "{}");
if (body?.error) message = body.error;
if (body?.code) code = body.code;
if (Array.isArray(body?.methods)) methods = body.methods;
} catch {}
throw new ApiError(res.status, message);
// A guarded route answers 403 with the factors that would satisfy it.
// Re-authenticate once through the shared modal, then retry the
// original request exactly once - a loop here would prompt forever
// against a server that keeps refusing. This is outside the parse
// try/catch above: a cancelled step-up rejects, and that rejection
// must propagate, not be swallowed as a JSON parse failure.
if (res.status === 403 && code === "step_up_required" && !options?.__retried) {
await requestStepUp(methods ?? ["password"]);
return request<T>(path, { ...options, __retried: true });
}
const retryAfter = res.status === 429 ? Number(res.headers.get("Retry-After")) : undefined;
throw new ApiError(res.status, message, code, Number.isFinite(retryAfter) ? retryAfter : undefined);
}
if (res.status === 204) {
@@ -852,6 +877,15 @@ export const me = {
deletePasskey(id: string): Promise<void> {
return request<void>(`/me/passkeys/${id}`, { method: "DELETE" });
},
/**
* Re-authenticates the current session against exactly one factor. Called
* by the StepUpModal, and by `request()` internally never - the modal is
* the only caller, so the retry-once guarantee lives entirely in `request`.
*/
stepUp(factor: { totp: string } | { recovery: string } | { password: string }): Promise<{ ok: true }> {
return request("/me/step-up", { method: "POST", body: JSON.stringify(factor) });
},
};
export const api = {
@@ -874,6 +908,11 @@ export const api = {
return request<void>(`/instance/users/${userId}`, { method: "DELETE" });
},
/** Owner or admin; an admin cannot reset an owner's MFA (403). Step-up guarded. */
resetMemberMFA(userId: string): Promise<void> {
return request<void>(`/org/users/${userId}/mfa`, { method: "DELETE" });
},
listAuthPresets(): Promise<AuthPreset[]> {
return request<AuthPreset[]>("/auth/presets");
},
@@ -1152,6 +1191,7 @@ export const api = {
workflow_log_retention_days?: number | null;
local_login_enabled?: boolean;
api_token_max_days?: number | null;
require_mfa?: boolean;
}): Promise<{ saved: boolean }> {
return request<{ saved: boolean }>("/settings", {
method: "PUT",