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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user