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:
+43
-3
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user