feat(require-mfa): #155 enforce MFA via admin policy toggle across app access

Add an admin-controlled `require_mfa` policy in App Settings and expose it via `/auth/app-config` so the client can enforce it globally. Users without MFA are redirected to Settings after login and blocked from protected API/WebSocket access until setup is completed, while preserving MFA setup endpoints and admin recovery paths. Also prevent enabling the policy unless the acting admin already has MFA enabled, and block MFA disable while the policy is active. Includes UI toggle in Admin > Settings, required-policy notice in Settings, client-side 403 `MFA_REQUIRED` handling, and i18n updates for all supported locales.
This commit is contained in:
fgbona
2026-03-30 17:42:40 -03:00
parent 8412f303dd
commit 66f5ea50c5
22 changed files with 260 additions and 10 deletions

View File

@@ -85,6 +85,7 @@ export default function AdminPage(): React.ReactElement {
// Registration toggle
const [allowRegistration, setAllowRegistration] = useState<boolean>(true)
const [requireMfa, setRequireMfa] = useState<boolean>(false)
// Invite links
const [invites, setInvites] = useState<any[]>([])
@@ -119,7 +120,7 @@ export default function AdminPage(): React.ReactElement {
const [updating, setUpdating] = useState<boolean>(false)
const [updateResult, setUpdateResult] = useState<'success' | 'error' | null>(null)
const { user: currentUser, updateApiKeys } = useAuthStore()
const { user: currentUser, updateApiKeys, setAppRequireMfa } = useAuthStore()
const navigate = useNavigate()
const toast = useToast()
@@ -155,6 +156,7 @@ export default function AdminPage(): React.ReactElement {
try {
const config = await authApi.getAppConfig()
setAllowRegistration(config.allow_registration)
if (config.require_mfa !== undefined) setRequireMfa(!!config.require_mfa)
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
} catch (err: unknown) {
// ignore
@@ -201,6 +203,18 @@ export default function AdminPage(): React.ReactElement {
}
}
const handleToggleRequireMfa = async (value: boolean) => {
setRequireMfa(value)
try {
await authApi.updateAppSettings({ require_mfa: value })
setAppRequireMfa(value)
toast.success(t('common.saved'))
} catch (err: unknown) {
setRequireMfa(!value)
toast.error(getApiErrorMessage(err, t('common.error')))
}
}
const toggleKey = (key) => {
setShowKeys(prev => ({ ...prev, [key]: !prev[key] }))
}
@@ -706,6 +720,34 @@ export default function AdminPage(): React.ReactElement {
</div>
</div>
{/* Require 2FA for all users */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-100">
<h2 className="font-semibold text-slate-900">{t('admin.requireMfa')}</h2>
</div>
<div className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-700">{t('admin.requireMfa')}</p>
<p className="text-xs text-slate-400 mt-0.5">{t('admin.requireMfaHint')}</p>
</div>
<button
type="button"
onClick={() => handleToggleRequireMfa(!requireMfa)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
requireMfa ? 'bg-slate-900' : 'bg-slate-300'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
requireMfa ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
</div>
</div>
{/* Allowed File Types */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-100">

View File

@@ -1,12 +1,12 @@
import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useAuthStore } from '../store/authStore'
import { useSettingsStore } from '../store/settingsStore'
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'
import Navbar from '../components/Layout/Navbar'
import CustomSelect from '../components/shared/CustomSelect'
import { useToast } from '../components/shared/Toast'
import { Save, Map, Palette, User, Moon, Sun, Monitor, Shield, Camera, Trash2, Lock, KeyRound } from 'lucide-react'
import { Save, Map, Palette, User, Moon, Sun, Monitor, Shield, Camera, Trash2, Lock, KeyRound, AlertTriangle } from 'lucide-react'
import { authApi, adminApi, notificationsApi } from '../api/client'
import apiClient from '../api/client'
import type { LucideIcon } from 'lucide-react'
@@ -101,7 +101,8 @@ function NotificationPreferences({ t, memoriesEnabled }: { t: any; memoriesEnabl
}
export default function SettingsPage(): React.ReactElement {
const { user, updateProfile, uploadAvatar, deleteAvatar, logout, loadUser, demoMode } = useAuthStore()
const { user, updateProfile, uploadAvatar, deleteAvatar, logout, loadUser, demoMode, appRequireMfa } = useAuthStore()
const [searchParams] = useSearchParams()
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean | 'blocked'>(false)
const avatarInputRef = React.useRef<HTMLInputElement>(null)
const { settings, updateSetting, updateSettings } = useSettingsStore()
@@ -193,6 +194,10 @@ export default function SettingsPage(): React.ReactElement {
const [mfaDisablePwd, setMfaDisablePwd] = useState('')
const [mfaDisableCode, setMfaDisableCode] = useState('')
const [mfaLoading, setMfaLoading] = useState(false)
const mfaRequiredByPolicy =
!demoMode &&
!user?.mfa_enabled &&
(searchParams.get('mfa') === 'required' || appRequireMfa)
useEffect(() => {
setMapTileUrl(settings.map_tile_url || '')
@@ -652,6 +657,19 @@ export default function SettingsPage(): React.ReactElement {
<h3 className="font-semibold text-base m-0" style={{ color: 'var(--text-primary)' }}>{t('settings.mfa.title')}</h3>
</div>
<div className="space-y-3">
{mfaRequiredByPolicy && (
<div
className="flex gap-3 p-3 rounded-lg border text-sm"
style={{
background: 'var(--bg-secondary)',
borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
}}
>
<AlertTriangle className="w-5 h-5 flex-shrink-0 text-amber-600" />
<p className="m-0 leading-relaxed">{t('settings.mfa.requiredByPolicy')}</p>
</div>
)}
<p className="text-sm m-0" style={{ color: 'var(--text-muted)', lineHeight: 1.5 }}>{t('settings.mfa.description')}</p>
{demoMode ? (
<p className="text-sm text-amber-700 m-0">{t('settings.mfa.demoBlocked')}</p>