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
+65 -10
View File
@@ -38,6 +38,7 @@ export function MembersCard() {
const toast = useToast();
const [addOpen, setAddOpen] = useState(false);
const [removing, setRemoving] = useState<Member | null>(null);
const [resettingMfa, setResettingMfa] = useState<Member | null>(null);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState<Role>("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() {
<Th>Email</Th>
<Th>Role</Th>
<Th>Sign-in</Th>
<Th>MFA</Th>
<Th>Last login</Th>
<Th className="text-right">Actions</Th>
</Tr>
@@ -158,6 +174,13 @@ export function MembersCard() {
<Td label="Sign-in">
<Badge variant="neutral">{u.auth_source === "oidc" ? "SSO" : u.auth_source === "hq" ? "Vantage HQ" : "Password"}</Badge>
</Td>
<Td label="MFA">
{managedByHQ ? (
<span className="text-xs text-text-tertiary">-</span>
) : (
<Badge variant={u.mfa_enabled ? "success" : "neutral"}>{u.mfa_enabled ? "Enabled" : "Not set up"}</Badge>
)}
</Td>
<Td label="Last login" className="text-text-secondary">{u.last_login ? new Date(u.last_login).toLocaleString() : "Never"}</Td>
<Td label="Actions" className="text-right">
{managedByHQ ? (
@@ -169,16 +192,29 @@ export function MembersCard() {
<span className="text-xs text-text-tertiary">Managed in Vantage HQ</span>
)
) : (
!locked && (
<Button
variant="ghost"
size="sm"
className="text-danger hover:text-danger"
onClick={() => setRemoving({ id: u.user_id, email: u.email })}
>
Remove<span className="sr-only"> {u.email}</span>
</Button>
)
<div className="flex justify-end gap-2">
{/* 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") && (
<Button
variant="ghost"
size="sm"
onClick={() => setResettingMfa({ id: u.user_id, email: u.email })}
>
Reset MFA<span className="sr-only"> for {u.email}</span>
</Button>
)}
{!locked && (
<Button
variant="ghost"
size="sm"
className="text-danger hover:text-danger"
onClick={() => setRemoving({ id: u.user_id, email: u.email })}
>
Remove<span className="sr-only"> {u.email}</span>
</Button>
)}
</div>
)}
</Td>
</Tr>
@@ -212,6 +248,25 @@ export function MembersCard() {
}
/>
<ConfirmDialog
open={resettingMfa !== null}
title="Reset MFA"
confirmLabel="Reset MFA"
loading={isResettingMfa}
error={resetMfaError ? friendlyMessage(resetMfaError) : null}
onClose={() => {
resetResetMfa();
setResettingMfa(null);
}}
onConfirm={() => resettingMfa && resetMfa(resettingMfa)}
body={
<p>
<span className="text-text-primary">{resettingMfa?.email}</span> 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.
</p>
}
/>
<Modal open={addOpen} title="Add member" onClose={() => setAddOpen(false)}>
<form
onSubmit={(e) => {