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:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user