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
+2
View File
@@ -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 (
<AuthProvider>
<AppShell>{children}</AppShell>
<StepUpModal />
</AuthProvider>
);
}
+45 -1
View File
@@ -51,6 +51,18 @@ function DocumentIcon() {
);
}
function ShieldIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"
/>
</svg>
);
}
function KeyIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
@@ -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<string[]>([]);
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" && (
<SectionCard
title="Require MFA"
description="Require a second factor for every password sign-in on this instance."
icon={<ShieldIcon />}
>
<label className="flex items-center gap-2 text-sm text-text-secondary">
<input
type="checkbox"
checked={requireMfa}
onChange={(e) => {
if (!settings) return;
const v = e.target.checked;
setRequireMfa(v);
// Same in-progress form state the main Save button
// submits, not the stale loaded `settings` object -
// otherwise an unsaved edit elsewhere on this page is
// silently reverted the moment this toggle is flipped.
save({ ...currentPayload(), require_mfa: v });
}}
className="h-4 w-4 rounded-sm border-border bg-surface-2 accent-accent"
/>
Require MFA for password sign-in
</label>
<p className="mt-1.5 text-xs text-text-tertiary">
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.
</p>
</SectionCard>
)}
</Group>
<Group label="Monitoring">