diff --git a/web/app/(app)/layout.tsx b/web/app/(app)/layout.tsx index be7a8f8..a783f1f 100644 --- a/web/app/(app)/layout.tsx +++ b/web/app/(app)/layout.tsx @@ -1,5 +1,6 @@ import { AuthProvider } from "@/components/AuthProvider"; import { AppShell } from "@/components/AppShell"; +import { StepUpModal } from "@/components/mfa/StepUpModal"; export default function AppLayout({ children, @@ -9,6 +10,7 @@ export default function AppLayout({ return ( {children} + ); } diff --git a/web/app/(app)/settings/page.tsx b/web/app/(app)/settings/page.tsx index 24ac2d1..7ce16c4 100644 --- a/web/app/(app)/settings/page.tsx +++ b/web/app/(app)/settings/page.tsx @@ -51,6 +51,18 @@ function DocumentIcon() { ); } +function ShieldIcon() { + return ( + + + + ); +} + function KeyIcon() { return ( @@ -127,7 +139,7 @@ function SecretsTokenCard({ tokenSet, rotatedAt }: { tokenSet: boolean; rotatedA export default function SettingsPage() { const queryClient = useQueryClient(); - const { instance, isAdmin } = useAuth(); + const { instance, isAdmin, user } = useAuth(); const { data: settings, isLoading } = useQuery({ queryKey: ["settings"], @@ -145,6 +157,7 @@ export default function SettingsPage() { const [logRetentionDays, setLogRetentionDays] = useState(30); const [offlineChannelIds, setOfflineChannelIds] = useState([]); const [apiTokenMaxDays, setApiTokenMaxDays] = useState(0); + const [requireMfa, setRequireMfa] = useState(false); const toast = useToast(); useEffect(() => { @@ -153,6 +166,7 @@ export default function SettingsPage() { setLogRetentionDays(settings.workflow_log_retention_days ?? 30); setOfflineChannelIds(settings.alerts.offline_channel_ids ?? []); setApiTokenMaxDays(settings.api_token_max_days ?? 0); + setRequireMfa(settings.require_mfa ?? false); }, [settings]); // The one place the in-progress form is turned into a payload. Both the @@ -235,6 +249,36 @@ export default function SettingsPage() { save({ ...currentPayload(), local_login_enabled: v }); }} /> + {user?.role === "owner" && ( + } + > + +

+ Members without a second factor already set up must enrol one at their next sign-in. Members who sign in through + a provider or a passkey are unaffected. +

+ + )} diff --git a/web/components/mfa/StepUpModal.tsx b/web/components/mfa/StepUpModal.tsx new file mode 100644 index 0000000..d27c7e3 --- /dev/null +++ b/web/components/mfa/StepUpModal.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { me } from "@/lib/api"; +import { registerStepUpPrompt } from "@/lib/stepup"; +import { Button, Modal, friendlyMessage } from "@/components/ui"; + +// The server may also offer "webauthn" (a passkey), but /me/step-up/webauthn +// isn't wired up server-side yet, so this modal only ever offers the three +// methods it can actually complete. +const SUPPORTED = ["totp", "recovery", "password"] as const; +type SupportedMethod = (typeof SUPPORTED)[number]; + +const LABELS: Record = { + totp: "Authenticator app", + recovery: "Recovery code", + password: "Password", +}; + +const INPUT_LABELS: Record = { + totp: "6-digit code", + recovery: "Recovery code", + password: "Password", +}; + +/** + * Mounted once in the (app) layout. Registers itself as the step-up prompt on + * mount, so `request()` in lib/api.ts can reach it without importing React. + */ +export function StepUpModal() { + const [open, setOpen] = useState(false); + const [methods, setMethods] = useState([]); + const [method, setMethod] = useState(null); + const [value, setValue] = useState(""); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + + const resolveRef = useRef<(() => void) | null>(null); + const rejectRef = useRef<((e: Error) => void) | null>(null); + + useEffect(() => { + registerStepUpPrompt((requested) => { + const supported = requested.filter((m): m is SupportedMethod => (SUPPORTED as readonly string[]).includes(m)); + return new Promise((resolve, reject) => { + setMethods(supported); + setMethod(supported[0] ?? null); + setValue(""); + setError(""); + resolveRef.current = resolve; + rejectRef.current = reject; + setOpen(true); + }); + }); + // Unregistering on unmount matches "no prompt registered fails rather + // than hangs": outside the app shell there is nothing to reach. + return () => registerStepUpPrompt(null); + }, []); + + function cancel(reason: string) { + setOpen(false); + rejectRef.current?.(new Error(reason)); + resolveRef.current = null; + rejectRef.current = null; + } + + async function submit(e: React.FormEvent) { + e.preventDefault(); + if (!method) return; + setBusy(true); + setError(""); + try { + const factor = method === "totp" ? { totp: value } : method === "recovery" ? { recovery: value } : { password: value }; + await me.stepUp(factor); + setOpen(false); + resolveRef.current?.(); + resolveRef.current = null; + rejectRef.current = null; + } catch (err) { + setError(friendlyMessage(err)); + } finally { + setBusy(false); + } + } + + return ( + cancel("re-authentication was cancelled")}> +
+

This action reveals a credential, so please confirm it is you.

+ + {methods.length === 0 ? ( + <> +

No supported re-authentication method is available for this account.

+
+ +
+ + ) : ( + <> + {methods.length > 1 && ( +
+ {methods.map((m) => ( + + ))} +
+ )} + +
+
+ + setValue(e.target.value)} + className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30" + /> +
+ + {error &&
{error}
} + +
+ + +
+
+ + )} +
+
+ ); +} diff --git a/web/components/settings/MembersCard.tsx b/web/components/settings/MembersCard.tsx index 270b026..0aeb616 100644 --- a/web/components/settings/MembersCard.tsx +++ b/web/components/settings/MembersCard.tsx @@ -38,6 +38,7 @@ export function MembersCard() { const toast = useToast(); const [addOpen, setAddOpen] = useState(false); const [removing, setRemoving] = useState(null); + const [resettingMfa, setResettingMfa] = useState(null); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [role, setRole] = useState("member"); @@ -84,6 +85,20 @@ export function MembersCard() { }, }); + const { + mutate: resetMfa, + isPending: isResettingMfa, + error: resetMfaError, + reset: resetResetMfa, + } = useMutation({ + mutationFn: (member: Member) => api.resetMemberMFA(member.id), + onSuccess: (_data, member) => { + invalidate(); + toast.success(`Cleared MFA for ${member.email}.`); + setResettingMfa(null); + }, + }); + // Removal failures are shown inside the confirm dialog that raised them, so // only the inline role change lands here - otherwise the same sentence // appears twice on screen. @@ -121,6 +136,7 @@ export function MembersCard() { Email Role Sign-in + MFA Last login Actions @@ -158,6 +174,13 @@ export function MembersCard() { {u.auth_source === "oidc" ? "SSO" : u.auth_source === "hq" ? "Vantage HQ" : "Password"} + + {managedByHQ ? ( + - + ) : ( + {u.mfa_enabled ? "Enabled" : "Not set up"} + )} + {u.last_login ? new Date(u.last_login).toLocaleString() : "Never"} {managedByHQ ? ( @@ -169,16 +192,29 @@ export function MembersCard() { Managed in Vantage HQ ) ) : ( - !locked && ( - - ) +
+ {/* Only an owner may reset an owner's MFA - the same rule the + server enforces, so admins never see a button that would 403. */} + {u.mfa_enabled && (isOwner || u.role !== "owner") && ( + + )} + {!locked && ( + + )} +
)} @@ -212,6 +248,25 @@ export function MembersCard() { } /> + { + resetResetMfa(); + setResettingMfa(null); + }} + onConfirm={() => resettingMfa && resetMfa(resettingMfa)} + body={ +

+ {resettingMfa?.email} loses every enrolled factor and recovery code. If this + instance requires MFA for password sign-in, they must set up a new factor the next time they sign in. +

+ } + /> + setAddOpen(false)}>
{ diff --git a/web/lib/api.ts b/web/lib/api.ts index d460ae9..f2bff68 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -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(path: string, options?: RequestInit): Promise { +/** RequestInit plus the internal flag that stops a step-up retry from looping. */ +type ApiRequestInit = RequestInit & { __retried?: boolean }; + +async function request(path: string, options?: ApiRequestInit): Promise { const res = await fetch(`/api${path}`, { credentials: "include", headers: { @@ -655,11 +663,28 @@ async function request(path: string, options?: RequestInit): Promise { 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(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 { return request(`/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(`/instance/users/${userId}`, { method: "DELETE" }); }, + /** Owner or admin; an admin cannot reset an owner's MFA (403). Step-up guarded. */ + resetMemberMFA(userId: string): Promise { + return request(`/org/users/${userId}/mfa`, { method: "DELETE" }); + }, + listAuthPresets(): Promise { return request("/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", diff --git a/web/lib/stepup.ts b/web/lib/stepup.ts new file mode 100644 index 0000000..bd2bd1f --- /dev/null +++ b/web/lib/stepup.ts @@ -0,0 +1,18 @@ +// The step-up broker. `request()` in api.ts lives outside React, so it cannot +// render a dialog itself; instead it calls back into whatever prompt the +// StepUpModal registered when it mounted inside the (app) layout. + +type Prompt = (methods: string[]) => Promise; + +let prompt: Prompt | null = null; + +// The provider registers the real prompt at mount. Before that, or outside the +// app shell, step-up simply fails rather than hanging forever. +export function registerStepUpPrompt(fn: Prompt | null) { + prompt = fn; +} + +export async function requestStepUp(methods: string[]): Promise { + if (!prompt) throw new Error("re-authentication is required"); + return prompt(methods); +}