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:
@@ -23,8 +23,9 @@ interface ProtectedRouteProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps) {
|
function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps) {
|
||||||
const { isAuthenticated, user, isLoading } = useAuthStore()
|
const { isAuthenticated, user, isLoading, appRequireMfa } = useAuthStore()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -41,6 +42,15 @@ function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps
|
|||||||
return <Navigate to="/login" replace />
|
return <Navigate to="/login" replace />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
appRequireMfa &&
|
||||||
|
user &&
|
||||||
|
!user.mfa_enabled &&
|
||||||
|
location.pathname !== '/settings'
|
||||||
|
) {
|
||||||
|
return <Navigate to="/settings?mfa=required" replace />
|
||||||
|
}
|
||||||
|
|
||||||
if (adminRequired && user && user.role !== 'admin') {
|
if (adminRequired && user && user.role !== 'admin') {
|
||||||
return <Navigate to="/dashboard" replace />
|
return <Navigate to="/dashboard" replace />
|
||||||
}
|
}
|
||||||
@@ -63,17 +73,18 @@ function RootRedirect() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { loadUser, token, isAuthenticated, demoMode, setDemoMode, setHasMapsKey, setServerTimezone } = useAuthStore()
|
const { loadUser, token, isAuthenticated, demoMode, setDemoMode, setHasMapsKey, setServerTimezone, setAppRequireMfa } = useAuthStore()
|
||||||
const { loadSettings } = useSettingsStore()
|
const { loadSettings } = useSettingsStore()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (token) {
|
if (token) {
|
||||||
loadUser()
|
loadUser()
|
||||||
}
|
}
|
||||||
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string }) => {
|
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean }) => {
|
||||||
if (config?.demo_mode) setDemoMode(true)
|
if (config?.demo_mode) setDemoMode(true)
|
||||||
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
|
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
|
||||||
if (config?.timezone) setServerTimezone(config.timezone)
|
if (config?.timezone) setServerTimezone(config.timezone)
|
||||||
|
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa)
|
||||||
|
|
||||||
if (config?.version) {
|
if (config?.version) {
|
||||||
const storedVersion = localStorage.getItem('trek_app_version')
|
const storedVersion = localStorage.getItem('trek_app_version')
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ apiClient.interceptors.response.use(
|
|||||||
window.location.href = '/login'
|
window.location.href = '/login'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
error.response?.status === 403 &&
|
||||||
|
(error.response?.data as { code?: string } | undefined)?.code === 'MFA_REQUIRED' &&
|
||||||
|
!window.location.pathname.startsWith('/settings')
|
||||||
|
) {
|
||||||
|
window.location.href = '/settings?mfa=required'
|
||||||
|
}
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -226,6 +226,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.avatarError': 'فشل الرفع',
|
'settings.avatarError': 'فشل الرفع',
|
||||||
'settings.mfa.title': 'المصادقة الثنائية (2FA)',
|
'settings.mfa.title': 'المصادقة الثنائية (2FA)',
|
||||||
'settings.mfa.description': 'تضيف خطوة ثانية عند تسجيل الدخول. استخدم تطبيق مصادقة (Google Authenticator، Authy، إلخ).',
|
'settings.mfa.description': 'تضيف خطوة ثانية عند تسجيل الدخول. استخدم تطبيق مصادقة (Google Authenticator، Authy، إلخ).',
|
||||||
|
'settings.mfa.requiredByPolicy': 'المسؤول يتطلب المصادقة الثنائية. اضبط تطبيق المصادقة أدناه قبل المتابعة.',
|
||||||
'settings.mfa.enabled': 'المصادقة الثنائية مفعّلة على حسابك.',
|
'settings.mfa.enabled': 'المصادقة الثنائية مفعّلة على حسابك.',
|
||||||
'settings.mfa.disabled': 'المصادقة الثنائية غير مفعّلة.',
|
'settings.mfa.disabled': 'المصادقة الثنائية غير مفعّلة.',
|
||||||
'settings.mfa.setup': 'إعداد المصادقة',
|
'settings.mfa.setup': 'إعداد المصادقة',
|
||||||
@@ -374,6 +375,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.invite.deleteError': 'فشل حذف رابط الدعوة',
|
'admin.invite.deleteError': 'فشل حذف رابط الدعوة',
|
||||||
'admin.allowRegistration': 'السماح بالتسجيل',
|
'admin.allowRegistration': 'السماح بالتسجيل',
|
||||||
'admin.allowRegistrationHint': 'يمكن للمستخدمين الجدد التسجيل بأنفسهم',
|
'admin.allowRegistrationHint': 'يمكن للمستخدمين الجدد التسجيل بأنفسهم',
|
||||||
|
'admin.requireMfa': 'فرض المصادقة الثنائية (2FA)',
|
||||||
|
'admin.requireMfaHint': 'يجب على المستخدمين الذين لا يملكون 2FA إكمال الإعداد في الإعدادات قبل استخدام التطبيق.',
|
||||||
'admin.apiKeys': 'مفاتيح API',
|
'admin.apiKeys': 'مفاتيح API',
|
||||||
'admin.apiKeysHint': 'اختياري. يُفعّل بيانات الأماكن الموسعة مثل الصور والطقس.',
|
'admin.apiKeysHint': 'اختياري. يُفعّل بيانات الأماكن الموسعة مثل الصور والطقس.',
|
||||||
'admin.mapsKey': 'مفتاح Google Maps API',
|
'admin.mapsKey': 'مفتاح Google Maps API',
|
||||||
|
|||||||
@@ -221,6 +221,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.avatarError': 'Falha no envio',
|
'settings.avatarError': 'Falha no envio',
|
||||||
'settings.mfa.title': 'Autenticação em duas etapas (2FA)',
|
'settings.mfa.title': 'Autenticação em duas etapas (2FA)',
|
||||||
'settings.mfa.description': 'Adiciona uma segunda etapa ao entrar com e-mail e senha. Use um app autenticador (Google Authenticator, Authy, etc.).',
|
'settings.mfa.description': 'Adiciona uma segunda etapa ao entrar com e-mail e senha. Use um app autenticador (Google Authenticator, Authy, etc.).',
|
||||||
|
'settings.mfa.requiredByPolicy': 'O administrador exige autenticação em dois fatores. Configure um app autenticador abaixo antes de continuar.',
|
||||||
'settings.mfa.enabled': 'O 2FA está ativado na sua conta.',
|
'settings.mfa.enabled': 'O 2FA está ativado na sua conta.',
|
||||||
'settings.mfa.disabled': 'O 2FA não está ativado.',
|
'settings.mfa.disabled': 'O 2FA não está ativado.',
|
||||||
'settings.mfa.setup': 'Configurar autenticador',
|
'settings.mfa.setup': 'Configurar autenticador',
|
||||||
@@ -364,6 +365,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.tabs.settings': 'Configurações',
|
'admin.tabs.settings': 'Configurações',
|
||||||
'admin.allowRegistration': 'Permitir cadastro',
|
'admin.allowRegistration': 'Permitir cadastro',
|
||||||
'admin.allowRegistrationHint': 'Novos usuários podem se cadastrar sozinhos',
|
'admin.allowRegistrationHint': 'Novos usuários podem se cadastrar sozinhos',
|
||||||
|
'admin.requireMfa': 'Exigir autenticação em dois fatores (2FA)',
|
||||||
|
'admin.requireMfaHint': 'Usuários sem 2FA precisam concluir a configuração em Configurações antes de usar o app.',
|
||||||
'admin.apiKeys': 'Chaves de API',
|
'admin.apiKeys': 'Chaves de API',
|
||||||
'admin.apiKeysHint': 'Opcional. Habilita dados estendidos de lugares, como fotos e clima.',
|
'admin.apiKeysHint': 'Opcional. Habilita dados estendidos de lugares, como fotos e clima.',
|
||||||
'admin.mapsKey': 'Chave da API Google Maps',
|
'admin.mapsKey': 'Chave da API Google Maps',
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.avatarError': 'Nahrávání se nezdařilo',
|
'settings.avatarError': 'Nahrávání se nezdařilo',
|
||||||
'settings.mfa.title': 'Dvoufaktorové ověření (2FA)',
|
'settings.mfa.title': 'Dvoufaktorové ověření (2FA)',
|
||||||
'settings.mfa.description': 'Přidá druhý stupeň zabezpečení při přihlašování e-mailem a heslem. Použijte aplikaci (Google Authenticator, Authy apod.).',
|
'settings.mfa.description': 'Přidá druhý stupeň zabezpečení při přihlašování e-mailem a heslem. Použijte aplikaci (Google Authenticator, Authy apod.).',
|
||||||
|
'settings.mfa.requiredByPolicy': 'Správce vyžaduje dvoufázové ověření. Nejdřív níže nastavte aplikaci autentikátoru.',
|
||||||
'settings.mfa.enabled': '2FA je pro váš účet aktivní.',
|
'settings.mfa.enabled': '2FA je pro váš účet aktivní.',
|
||||||
'settings.mfa.disabled': '2FA není aktivní.',
|
'settings.mfa.disabled': '2FA není aktivní.',
|
||||||
'settings.mfa.setup': 'Nastavit autentizační aplikaci',
|
'settings.mfa.setup': 'Nastavit autentizační aplikaci',
|
||||||
@@ -365,6 +366,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.tabs.settings': 'Nastavení',
|
'admin.tabs.settings': 'Nastavení',
|
||||||
'admin.allowRegistration': 'Povolit registraci',
|
'admin.allowRegistration': 'Povolit registraci',
|
||||||
'admin.allowRegistrationHint': 'Noví uživatelé se mohou sami registrovat',
|
'admin.allowRegistrationHint': 'Noví uživatelé se mohou sami registrovat',
|
||||||
|
'admin.requireMfa': 'Vyžadovat dvoufázové ověření (2FA)',
|
||||||
|
'admin.requireMfaHint': 'Uživatelé bez 2FA musí dokončit nastavení v Nastavení před použitím aplikace.',
|
||||||
'admin.apiKeys': 'API klíče',
|
'admin.apiKeys': 'API klíče',
|
||||||
'admin.apiKeysHint': 'Volitelné. Povoluje rozšířená data o místech (fotky, počasí).',
|
'admin.apiKeysHint': 'Volitelné. Povoluje rozšířená data o místech (fotky, počasí).',
|
||||||
'admin.mapsKey': 'Google Maps API klíč',
|
'admin.mapsKey': 'Google Maps API klíč',
|
||||||
|
|||||||
@@ -221,6 +221,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.avatarError': 'Fehler beim Hochladen',
|
'settings.avatarError': 'Fehler beim Hochladen',
|
||||||
'settings.mfa.title': 'Zwei-Faktor-Authentifizierung (2FA)',
|
'settings.mfa.title': 'Zwei-Faktor-Authentifizierung (2FA)',
|
||||||
'settings.mfa.description': 'Zusätzlicher Schritt bei der Anmeldung mit E-Mail und Passwort. Nutze eine Authenticator-App (Google Authenticator, Authy, …).',
|
'settings.mfa.description': 'Zusätzlicher Schritt bei der Anmeldung mit E-Mail und Passwort. Nutze eine Authenticator-App (Google Authenticator, Authy, …).',
|
||||||
|
'settings.mfa.requiredByPolicy': 'Dein Administrator verlangt Zwei-Faktor-Authentifizierung. Richte unten eine Authenticator-App ein, bevor du fortfährst.',
|
||||||
'settings.mfa.enabled': '2FA ist für dein Konto aktiv.',
|
'settings.mfa.enabled': '2FA ist für dein Konto aktiv.',
|
||||||
'settings.mfa.disabled': '2FA ist nicht aktiviert.',
|
'settings.mfa.disabled': '2FA ist nicht aktiviert.',
|
||||||
'settings.mfa.setup': 'Authenticator einrichten',
|
'settings.mfa.setup': 'Authenticator einrichten',
|
||||||
@@ -365,6 +366,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.tabs.settings': 'Einstellungen',
|
'admin.tabs.settings': 'Einstellungen',
|
||||||
'admin.allowRegistration': 'Registrierung erlauben',
|
'admin.allowRegistration': 'Registrierung erlauben',
|
||||||
'admin.allowRegistrationHint': 'Neue Benutzer können sich selbst registrieren',
|
'admin.allowRegistrationHint': 'Neue Benutzer können sich selbst registrieren',
|
||||||
|
'admin.requireMfa': 'Zwei-Faktor-Authentifizierung (2FA) für alle verlangen',
|
||||||
|
'admin.requireMfaHint': 'Benutzer ohne 2FA müssen die Einrichtung unter Einstellungen abschließen, bevor sie die App nutzen können.',
|
||||||
'admin.apiKeys': 'API-Schlüssel',
|
'admin.apiKeys': 'API-Schlüssel',
|
||||||
'admin.apiKeysHint': 'Optional. Aktiviert erweiterte Ortsdaten wie Fotos und Wetter.',
|
'admin.apiKeysHint': 'Optional. Aktiviert erweiterte Ortsdaten wie Fotos und Wetter.',
|
||||||
'admin.mapsKey': 'Google Maps API Key',
|
'admin.mapsKey': 'Google Maps API Key',
|
||||||
|
|||||||
@@ -221,6 +221,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.avatarError': 'Upload failed',
|
'settings.avatarError': 'Upload failed',
|
||||||
'settings.mfa.title': 'Two-factor authentication (2FA)',
|
'settings.mfa.title': 'Two-factor authentication (2FA)',
|
||||||
'settings.mfa.description': 'Adds a second step when you sign in with email and password. Use an authenticator app (Google Authenticator, Authy, etc.).',
|
'settings.mfa.description': 'Adds a second step when you sign in with email and password. Use an authenticator app (Google Authenticator, Authy, etc.).',
|
||||||
|
'settings.mfa.requiredByPolicy': 'Your administrator requires two-factor authentication. Set up an authenticator app below before continuing.',
|
||||||
'settings.mfa.enabled': '2FA is enabled on your account.',
|
'settings.mfa.enabled': '2FA is enabled on your account.',
|
||||||
'settings.mfa.disabled': '2FA is not enabled.',
|
'settings.mfa.disabled': '2FA is not enabled.',
|
||||||
'settings.mfa.setup': 'Set up authenticator',
|
'settings.mfa.setup': 'Set up authenticator',
|
||||||
@@ -365,6 +366,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.tabs.settings': 'Settings',
|
'admin.tabs.settings': 'Settings',
|
||||||
'admin.allowRegistration': 'Allow Registration',
|
'admin.allowRegistration': 'Allow Registration',
|
||||||
'admin.allowRegistrationHint': 'New users can register themselves',
|
'admin.allowRegistrationHint': 'New users can register themselves',
|
||||||
|
'admin.requireMfa': 'Require two-factor authentication (2FA)',
|
||||||
|
'admin.requireMfaHint': 'Users without 2FA must complete setup in Settings before using the app.',
|
||||||
'admin.apiKeys': 'API Keys',
|
'admin.apiKeys': 'API Keys',
|
||||||
'admin.apiKeysHint': 'Optional. Enables extended place data like photos and weather.',
|
'admin.apiKeysHint': 'Optional. Enables extended place data like photos and weather.',
|
||||||
'admin.mapsKey': 'Google Maps API Key',
|
'admin.mapsKey': 'Google Maps API Key',
|
||||||
|
|||||||
@@ -211,6 +211,7 @@ const es: Record<string, string> = {
|
|||||||
'settings.saveProfile': 'Guardar perfil',
|
'settings.saveProfile': 'Guardar perfil',
|
||||||
'settings.mfa.title': 'Autenticación de dos factores (2FA)',
|
'settings.mfa.title': 'Autenticación de dos factores (2FA)',
|
||||||
'settings.mfa.description': 'Añade un segundo paso al iniciar sesión. Usa una app de autenticación (Google Authenticator, Authy, etc.).',
|
'settings.mfa.description': 'Añade un segundo paso al iniciar sesión. Usa una app de autenticación (Google Authenticator, Authy, etc.).',
|
||||||
|
'settings.mfa.requiredByPolicy': 'Tu administrador exige autenticación en dos factores. Configura una app de autenticación abajo antes de continuar.',
|
||||||
'settings.mfa.enabled': '2FA está activado en tu cuenta.',
|
'settings.mfa.enabled': '2FA está activado en tu cuenta.',
|
||||||
'settings.mfa.disabled': '2FA no está activado.',
|
'settings.mfa.disabled': '2FA no está activado.',
|
||||||
'settings.mfa.setup': 'Configurar autenticador',
|
'settings.mfa.setup': 'Configurar autenticador',
|
||||||
@@ -363,6 +364,8 @@ const es: Record<string, string> = {
|
|||||||
'admin.tabs.settings': 'Ajustes',
|
'admin.tabs.settings': 'Ajustes',
|
||||||
'admin.allowRegistration': 'Permitir el registro',
|
'admin.allowRegistration': 'Permitir el registro',
|
||||||
'admin.allowRegistrationHint': 'Los nuevos usuarios pueden registrarse por sí mismos',
|
'admin.allowRegistrationHint': 'Los nuevos usuarios pueden registrarse por sí mismos',
|
||||||
|
'admin.requireMfa': 'Exigir autenticación en dos factores (2FA)',
|
||||||
|
'admin.requireMfaHint': 'Los usuarios sin 2FA deben completar la configuración en Ajustes antes de usar la aplicación.',
|
||||||
'admin.apiKeys': 'Claves API',
|
'admin.apiKeys': 'Claves API',
|
||||||
'admin.apiKeysHint': 'Opcional. Activa datos ampliados de lugares, como fotos y previsión del tiempo.',
|
'admin.apiKeysHint': 'Opcional. Activa datos ampliados de lugares, como fotos y previsión del tiempo.',
|
||||||
'admin.mapsKey': 'Clave API de Google Maps',
|
'admin.mapsKey': 'Clave API de Google Maps',
|
||||||
|
|||||||
@@ -212,6 +212,7 @@ const fr: Record<string, string> = {
|
|||||||
'settings.saveProfile': 'Enregistrer le profil',
|
'settings.saveProfile': 'Enregistrer le profil',
|
||||||
'settings.mfa.title': 'Authentification à deux facteurs (2FA)',
|
'settings.mfa.title': 'Authentification à deux facteurs (2FA)',
|
||||||
'settings.mfa.description': 'Ajoute une étape supplémentaire lors de la connexion. Utilisez une application d\'authentification (Google Authenticator, Authy, etc.).',
|
'settings.mfa.description': 'Ajoute une étape supplémentaire lors de la connexion. Utilisez une application d\'authentification (Google Authenticator, Authy, etc.).',
|
||||||
|
'settings.mfa.requiredByPolicy': 'Votre administrateur exige l\'authentification à deux facteurs. Configurez une application d\'authentification ci-dessous avant de continuer.',
|
||||||
'settings.mfa.enabled': '2FA est activé sur votre compte.',
|
'settings.mfa.enabled': '2FA est activé sur votre compte.',
|
||||||
'settings.mfa.disabled': '2FA n\'est pas activé.',
|
'settings.mfa.disabled': '2FA n\'est pas activé.',
|
||||||
'settings.mfa.setup': 'Configurer l\'authentificateur',
|
'settings.mfa.setup': 'Configurer l\'authentificateur',
|
||||||
@@ -364,6 +365,8 @@ const fr: Record<string, string> = {
|
|||||||
'admin.tabs.settings': 'Paramètres',
|
'admin.tabs.settings': 'Paramètres',
|
||||||
'admin.allowRegistration': 'Autoriser les inscriptions',
|
'admin.allowRegistration': 'Autoriser les inscriptions',
|
||||||
'admin.allowRegistrationHint': 'Les nouveaux utilisateurs peuvent s\'inscrire eux-mêmes',
|
'admin.allowRegistrationHint': 'Les nouveaux utilisateurs peuvent s\'inscrire eux-mêmes',
|
||||||
|
'admin.requireMfa': 'Exiger l\'authentification à deux facteurs (2FA)',
|
||||||
|
'admin.requireMfaHint': 'Les utilisateurs sans 2FA doivent terminer la configuration dans Paramètres avant d\'utiliser l\'application.',
|
||||||
'admin.apiKeys': 'Clés API',
|
'admin.apiKeys': 'Clés API',
|
||||||
'admin.apiKeysHint': 'Facultatif. Active les données de lieu étendues comme les photos et la météo.',
|
'admin.apiKeysHint': 'Facultatif. Active les données de lieu étendues comme les photos et la météo.',
|
||||||
'admin.mapsKey': 'Clé API Google Maps',
|
'admin.mapsKey': 'Clé API Google Maps',
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.avatarError': 'Feltöltés sikertelen',
|
'settings.avatarError': 'Feltöltés sikertelen',
|
||||||
'settings.mfa.title': 'Kétfaktoros hitelesítés (2FA)',
|
'settings.mfa.title': 'Kétfaktoros hitelesítés (2FA)',
|
||||||
'settings.mfa.description': 'Egy második lépést ad a bejelentkezéshez e-mail és jelszó használatakor. Használj hitelesítő alkalmazást (Google Authenticator, Authy stb.).',
|
'settings.mfa.description': 'Egy második lépést ad a bejelentkezéshez e-mail és jelszó használatakor. Használj hitelesítő alkalmazást (Google Authenticator, Authy stb.).',
|
||||||
|
'settings.mfa.requiredByPolicy': 'A rendszergazda kétlépcsős hitelesítést ír elő. Állíts be hitelesítő alkalmazást lent, mielőtt továbblépnél.',
|
||||||
'settings.mfa.enabled': '2FA engedélyezve van a fiókodban.',
|
'settings.mfa.enabled': '2FA engedélyezve van a fiókodban.',
|
||||||
'settings.mfa.disabled': '2FA nincs engedélyezve.',
|
'settings.mfa.disabled': '2FA nincs engedélyezve.',
|
||||||
'settings.mfa.setup': 'Hitelesítő beállítása',
|
'settings.mfa.setup': 'Hitelesítő beállítása',
|
||||||
@@ -364,6 +365,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.tabs.settings': 'Beállítások',
|
'admin.tabs.settings': 'Beállítások',
|
||||||
'admin.allowRegistration': 'Regisztráció engedélyezése',
|
'admin.allowRegistration': 'Regisztráció engedélyezése',
|
||||||
'admin.allowRegistrationHint': 'Új felhasználók regisztrálhatják magukat',
|
'admin.allowRegistrationHint': 'Új felhasználók regisztrálhatják magukat',
|
||||||
|
'admin.requireMfa': 'Kétlépcsős hitelesítés (2FA) kötelezővé tétele',
|
||||||
|
'admin.requireMfaHint': 'A 2FA nélküli felhasználóknak a Beállításokban kell befejezniük a beállítást az alkalmazás használata előtt.',
|
||||||
'admin.apiKeys': 'API kulcsok',
|
'admin.apiKeys': 'API kulcsok',
|
||||||
'admin.apiKeysHint': 'Opcionális. Bővített helyadatokat tesz lehetővé, például fotókat és időjárást.',
|
'admin.apiKeysHint': 'Opcionális. Bővített helyadatokat tesz lehetővé, például fotókat és időjárást.',
|
||||||
'admin.mapsKey': 'Google Maps API kulcs',
|
'admin.mapsKey': 'Google Maps API kulcs',
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.avatarError': 'Impossibile caricare',
|
'settings.avatarError': 'Impossibile caricare',
|
||||||
'settings.mfa.title': 'Autenticazione a due fattori (2FA)',
|
'settings.mfa.title': 'Autenticazione a due fattori (2FA)',
|
||||||
'settings.mfa.description': 'Aggiunge un secondo passaggio quando accedi con email e password. Usa un\'app authenticator (Google Authenticator, Authy, ecc.).',
|
'settings.mfa.description': 'Aggiunge un secondo passaggio quando accedi con email e password. Usa un\'app authenticator (Google Authenticator, Authy, ecc.).',
|
||||||
|
'settings.mfa.requiredByPolicy': 'L\'amministratore richiede l\'autenticazione a due fattori. Configura un\'app authenticator qui sotto prima di continuare.',
|
||||||
'settings.mfa.enabled': 'La 2FA è abilitata sul tuo account.',
|
'settings.mfa.enabled': 'La 2FA è abilitata sul tuo account.',
|
||||||
'settings.mfa.disabled': 'La 2FA non è abilitata.',
|
'settings.mfa.disabled': 'La 2FA non è abilitata.',
|
||||||
'settings.mfa.setup': 'Configura authenticator',
|
'settings.mfa.setup': 'Configura authenticator',
|
||||||
@@ -364,6 +365,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.tabs.settings': 'Impostazioni',
|
'admin.tabs.settings': 'Impostazioni',
|
||||||
'admin.allowRegistration': 'Consenti Registrazione',
|
'admin.allowRegistration': 'Consenti Registrazione',
|
||||||
'admin.allowRegistrationHint': 'I nuovi utenti possono registrarsi autonomamente',
|
'admin.allowRegistrationHint': 'I nuovi utenti possono registrarsi autonomamente',
|
||||||
|
'admin.requireMfa': 'Richiedi autenticazione a due fattori (2FA)',
|
||||||
|
'admin.requireMfaHint': 'Gli utenti senza 2FA devono completare la configurazione in Impostazioni prima di usare l\'app.',
|
||||||
'admin.apiKeys': 'Chiavi API',
|
'admin.apiKeys': 'Chiavi API',
|
||||||
'admin.apiKeysHint': 'Opzionale. Abilita dati estesi per i luoghi come foto e meteo.',
|
'admin.apiKeysHint': 'Opzionale. Abilita dati estesi per i luoghi come foto e meteo.',
|
||||||
'admin.mapsKey': 'Chiave API Google Maps',
|
'admin.mapsKey': 'Chiave API Google Maps',
|
||||||
|
|||||||
@@ -212,6 +212,7 @@ const nl: Record<string, string> = {
|
|||||||
'settings.saveProfile': 'Profiel opslaan',
|
'settings.saveProfile': 'Profiel opslaan',
|
||||||
'settings.mfa.title': 'Tweefactorauthenticatie (2FA)',
|
'settings.mfa.title': 'Tweefactorauthenticatie (2FA)',
|
||||||
'settings.mfa.description': 'Voegt een tweede stap toe bij het inloggen. Gebruik een authenticator-app (Google Authenticator, Authy, etc.).',
|
'settings.mfa.description': 'Voegt een tweede stap toe bij het inloggen. Gebruik een authenticator-app (Google Authenticator, Authy, etc.).',
|
||||||
|
'settings.mfa.requiredByPolicy': 'Je beheerder vereist tweestapsverificatie. Stel hieronder een authenticator-app in voordat je verdergaat.',
|
||||||
'settings.mfa.enabled': '2FA is ingeschakeld op je account.',
|
'settings.mfa.enabled': '2FA is ingeschakeld op je account.',
|
||||||
'settings.mfa.disabled': '2FA is niet ingeschakeld.',
|
'settings.mfa.disabled': '2FA is niet ingeschakeld.',
|
||||||
'settings.mfa.setup': 'Authenticator instellen',
|
'settings.mfa.setup': 'Authenticator instellen',
|
||||||
@@ -365,6 +366,8 @@ const nl: Record<string, string> = {
|
|||||||
'admin.tabs.settings': 'Instellingen',
|
'admin.tabs.settings': 'Instellingen',
|
||||||
'admin.allowRegistration': 'Registratie toestaan',
|
'admin.allowRegistration': 'Registratie toestaan',
|
||||||
'admin.allowRegistrationHint': 'Nieuwe gebruikers kunnen zichzelf registreren',
|
'admin.allowRegistrationHint': 'Nieuwe gebruikers kunnen zichzelf registreren',
|
||||||
|
'admin.requireMfa': 'Tweestapsverificatie (2FA) verplicht stellen',
|
||||||
|
'admin.requireMfaHint': 'Gebruikers zonder 2FA moeten de installatie in Instellingen voltooien voordat ze de app kunnen gebruiken.',
|
||||||
'admin.apiKeys': 'API-sleutels',
|
'admin.apiKeys': 'API-sleutels',
|
||||||
'admin.apiKeysHint': 'Optioneel. Schakelt uitgebreide plaatsgegevens in zoals foto\'s en weer.',
|
'admin.apiKeysHint': 'Optioneel. Schakelt uitgebreide plaatsgegevens in zoals foto\'s en weer.',
|
||||||
'admin.mapsKey': 'Google Maps API-sleutel',
|
'admin.mapsKey': 'Google Maps API-sleutel',
|
||||||
|
|||||||
@@ -212,6 +212,7 @@ const ru: Record<string, string> = {
|
|||||||
'settings.saveProfile': 'Сохранить профиль',
|
'settings.saveProfile': 'Сохранить профиль',
|
||||||
'settings.mfa.title': 'Двухфакторная аутентификация (2FA)',
|
'settings.mfa.title': 'Двухфакторная аутентификация (2FA)',
|
||||||
'settings.mfa.description': 'Добавляет второй шаг при входе. Используйте приложение-аутентификатор (Google Authenticator, Authy и др.).',
|
'settings.mfa.description': 'Добавляет второй шаг при входе. Используйте приложение-аутентификатор (Google Authenticator, Authy и др.).',
|
||||||
|
'settings.mfa.requiredByPolicy': 'Администратор требует двухфакторную аутентификацию. Настройте приложение-аутентификатор ниже, прежде чем продолжить.',
|
||||||
'settings.mfa.enabled': '2FA включена для вашего аккаунта.',
|
'settings.mfa.enabled': '2FA включена для вашего аккаунта.',
|
||||||
'settings.mfa.disabled': '2FA не включена.',
|
'settings.mfa.disabled': '2FA не включена.',
|
||||||
'settings.mfa.setup': 'Настроить аутентификатор',
|
'settings.mfa.setup': 'Настроить аутентификатор',
|
||||||
@@ -365,6 +366,8 @@ const ru: Record<string, string> = {
|
|||||||
'admin.tabs.settings': 'Настройки',
|
'admin.tabs.settings': 'Настройки',
|
||||||
'admin.allowRegistration': 'Разрешить регистрацию',
|
'admin.allowRegistration': 'Разрешить регистрацию',
|
||||||
'admin.allowRegistrationHint': 'Новые пользователи могут регистрироваться самостоятельно',
|
'admin.allowRegistrationHint': 'Новые пользователи могут регистрироваться самостоятельно',
|
||||||
|
'admin.requireMfa': 'Требовать двухфакторную аутентификацию (2FA)',
|
||||||
|
'admin.requireMfaHint': 'Пользователи без 2FA должны завершить настройку в разделе «Настройки» перед использованием приложения.',
|
||||||
'admin.apiKeys': 'API-ключи',
|
'admin.apiKeys': 'API-ключи',
|
||||||
'admin.apiKeysHint': 'Необязательно. Включает расширенные данные о местах, такие как фото и погода.',
|
'admin.apiKeysHint': 'Необязательно. Включает расширенные данные о местах, такие как фото и погода.',
|
||||||
'admin.mapsKey': 'API-ключ Google Maps',
|
'admin.mapsKey': 'API-ключ Google Maps',
|
||||||
|
|||||||
@@ -212,6 +212,7 @@ const zh: Record<string, string> = {
|
|||||||
'settings.saveProfile': '保存资料',
|
'settings.saveProfile': '保存资料',
|
||||||
'settings.mfa.title': '双因素认证 (2FA)',
|
'settings.mfa.title': '双因素认证 (2FA)',
|
||||||
'settings.mfa.description': '登录时添加第二步验证。使用身份验证器应用(Google Authenticator、Authy 等)。',
|
'settings.mfa.description': '登录时添加第二步验证。使用身份验证器应用(Google Authenticator、Authy 等)。',
|
||||||
|
'settings.mfa.requiredByPolicy': '管理员要求双因素身份验证。请先完成下方的身份验证器设置后再继续。',
|
||||||
'settings.mfa.enabled': '您的账户已启用 2FA。',
|
'settings.mfa.enabled': '您的账户已启用 2FA。',
|
||||||
'settings.mfa.disabled': '2FA 未启用。',
|
'settings.mfa.disabled': '2FA 未启用。',
|
||||||
'settings.mfa.setup': '设置身份验证器',
|
'settings.mfa.setup': '设置身份验证器',
|
||||||
@@ -365,6 +366,8 @@ const zh: Record<string, string> = {
|
|||||||
'admin.tabs.settings': '设置',
|
'admin.tabs.settings': '设置',
|
||||||
'admin.allowRegistration': '允许注册',
|
'admin.allowRegistration': '允许注册',
|
||||||
'admin.allowRegistrationHint': '新用户可以自行注册',
|
'admin.allowRegistrationHint': '新用户可以自行注册',
|
||||||
|
'admin.requireMfa': '要求双因素身份验证(2FA)',
|
||||||
|
'admin.requireMfaHint': '未启用 2FA 的用户必须先完成设置中的配置才能使用应用。',
|
||||||
'admin.apiKeys': 'API 密钥',
|
'admin.apiKeys': 'API 密钥',
|
||||||
'admin.apiKeysHint': '可选。启用地点的扩展数据,如照片和天气。',
|
'admin.apiKeysHint': '可选。启用地点的扩展数据,如照片和天气。',
|
||||||
'admin.mapsKey': 'Google Maps API 密钥',
|
'admin.mapsKey': 'Google Maps API 密钥',
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
|
|
||||||
// Registration toggle
|
// Registration toggle
|
||||||
const [allowRegistration, setAllowRegistration] = useState<boolean>(true)
|
const [allowRegistration, setAllowRegistration] = useState<boolean>(true)
|
||||||
|
const [requireMfa, setRequireMfa] = useState<boolean>(false)
|
||||||
|
|
||||||
// Invite links
|
// Invite links
|
||||||
const [invites, setInvites] = useState<any[]>([])
|
const [invites, setInvites] = useState<any[]>([])
|
||||||
@@ -119,7 +120,7 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
const [updating, setUpdating] = useState<boolean>(false)
|
const [updating, setUpdating] = useState<boolean>(false)
|
||||||
const [updateResult, setUpdateResult] = useState<'success' | 'error' | null>(null)
|
const [updateResult, setUpdateResult] = useState<'success' | 'error' | null>(null)
|
||||||
|
|
||||||
const { user: currentUser, updateApiKeys } = useAuthStore()
|
const { user: currentUser, updateApiKeys, setAppRequireMfa } = useAuthStore()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
@@ -155,6 +156,7 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
try {
|
try {
|
||||||
const config = await authApi.getAppConfig()
|
const config = await authApi.getAppConfig()
|
||||||
setAllowRegistration(config.allow_registration)
|
setAllowRegistration(config.allow_registration)
|
||||||
|
if (config.require_mfa !== undefined) setRequireMfa(!!config.require_mfa)
|
||||||
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
|
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
// ignore
|
// 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) => {
|
const toggleKey = (key) => {
|
||||||
setShowKeys(prev => ({ ...prev, [key]: !prev[key] }))
|
setShowKeys(prev => ({ ...prev, [key]: !prev[key] }))
|
||||||
}
|
}
|
||||||
@@ -706,6 +720,34 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Allowed File Types */}
|
||||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||||
<div className="px-6 py-4 border-b border-slate-100">
|
<div className="px-6 py-4 border-b border-slate-100">
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
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 { useAuthStore } from '../store/authStore'
|
||||||
import { useSettingsStore } from '../store/settingsStore'
|
import { useSettingsStore } from '../store/settingsStore'
|
||||||
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'
|
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'
|
||||||
import Navbar from '../components/Layout/Navbar'
|
import Navbar from '../components/Layout/Navbar'
|
||||||
import CustomSelect from '../components/shared/CustomSelect'
|
import CustomSelect from '../components/shared/CustomSelect'
|
||||||
import { useToast } from '../components/shared/Toast'
|
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 { authApi, adminApi, notificationsApi } from '../api/client'
|
||||||
import apiClient from '../api/client'
|
import apiClient from '../api/client'
|
||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
@@ -101,7 +101,8 @@ function NotificationPreferences({ t, memoriesEnabled }: { t: any; memoriesEnabl
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsPage(): React.ReactElement {
|
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 [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean | 'blocked'>(false)
|
||||||
const avatarInputRef = React.useRef<HTMLInputElement>(null)
|
const avatarInputRef = React.useRef<HTMLInputElement>(null)
|
||||||
const { settings, updateSetting, updateSettings } = useSettingsStore()
|
const { settings, updateSetting, updateSettings } = useSettingsStore()
|
||||||
@@ -193,6 +194,10 @@ export default function SettingsPage(): React.ReactElement {
|
|||||||
const [mfaDisablePwd, setMfaDisablePwd] = useState('')
|
const [mfaDisablePwd, setMfaDisablePwd] = useState('')
|
||||||
const [mfaDisableCode, setMfaDisableCode] = useState('')
|
const [mfaDisableCode, setMfaDisableCode] = useState('')
|
||||||
const [mfaLoading, setMfaLoading] = useState(false)
|
const [mfaLoading, setMfaLoading] = useState(false)
|
||||||
|
const mfaRequiredByPolicy =
|
||||||
|
!demoMode &&
|
||||||
|
!user?.mfa_enabled &&
|
||||||
|
(searchParams.get('mfa') === 'required' || appRequireMfa)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMapTileUrl(settings.map_tile_url || '')
|
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>
|
<h3 className="font-semibold text-base m-0" style={{ color: 'var(--text-primary)' }}>{t('settings.mfa.title')}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<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>
|
<p className="text-sm m-0" style={{ color: 'var(--text-muted)', lineHeight: 1.5 }}>{t('settings.mfa.description')}</p>
|
||||||
{demoMode ? (
|
{demoMode ? (
|
||||||
<p className="text-sm text-amber-700 m-0">{t('settings.mfa.demoBlocked')}</p>
|
<p className="text-sm text-amber-700 m-0">{t('settings.mfa.demoBlocked')}</p>
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ interface AuthState {
|
|||||||
demoMode: boolean
|
demoMode: boolean
|
||||||
hasMapsKey: boolean
|
hasMapsKey: boolean
|
||||||
serverTimezone: string
|
serverTimezone: string
|
||||||
|
/** Server policy: all users must enable MFA */
|
||||||
|
appRequireMfa: boolean
|
||||||
|
|
||||||
login: (email: string, password: string) => Promise<LoginResult>
|
login: (email: string, password: string) => Promise<LoginResult>
|
||||||
completeMfaLogin: (mfaToken: string, code: string) => Promise<AuthResponse>
|
completeMfaLogin: (mfaToken: string, code: string) => Promise<AuthResponse>
|
||||||
@@ -38,6 +40,7 @@ interface AuthState {
|
|||||||
setDemoMode: (val: boolean) => void
|
setDemoMode: (val: boolean) => void
|
||||||
setHasMapsKey: (val: boolean) => void
|
setHasMapsKey: (val: boolean) => void
|
||||||
setServerTimezone: (tz: string) => void
|
setServerTimezone: (tz: string) => void
|
||||||
|
setAppRequireMfa: (val: boolean) => void
|
||||||
demoLogin: () => Promise<AuthResponse>
|
demoLogin: () => Promise<AuthResponse>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +53,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
demoMode: localStorage.getItem('demo_mode') === 'true',
|
demoMode: localStorage.getItem('demo_mode') === 'true',
|
||||||
hasMapsKey: false,
|
hasMapsKey: false,
|
||||||
serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
appRequireMfa: false,
|
||||||
|
|
||||||
login: async (email: string, password: string) => {
|
login: async (email: string, password: string) => {
|
||||||
set({ isLoading: true, error: null })
|
set({ isLoading: true, error: null })
|
||||||
@@ -205,6 +209,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
|
|
||||||
setHasMapsKey: (val: boolean) => set({ hasMapsKey: val }),
|
setHasMapsKey: (val: boolean) => set({ hasMapsKey: val }),
|
||||||
setServerTimezone: (tz: string) => set({ serverTimezone: tz }),
|
setServerTimezone: (tz: string) => set({ serverTimezone: tz }),
|
||||||
|
setAppRequireMfa: (val: boolean) => set({ appRequireMfa: val }),
|
||||||
|
|
||||||
demoLogin: async () => {
|
demoLogin: async () => {
|
||||||
set({ isLoading: true, error: null })
|
set({ isLoading: true, error: null })
|
||||||
|
|||||||
@@ -281,6 +281,8 @@ export interface AppConfig {
|
|||||||
has_maps_key?: boolean
|
has_maps_key?: boolean
|
||||||
allowed_file_types?: string
|
allowed_file_types?: string
|
||||||
timezone?: string
|
timezone?: string
|
||||||
|
/** When true, users without MFA cannot use the app until they enable it */
|
||||||
|
require_mfa?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// Translation function type
|
// Translation function type
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
|
import './config';
|
||||||
import express, { Request, Response, NextFunction } from 'express';
|
import express, { Request, Response, NextFunction } from 'express';
|
||||||
|
import { enforceGlobalMfaPolicy } from './middleware/mfaPolicy';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@@ -80,6 +82,8 @@ if (shouldForceHttps) {
|
|||||||
app.use(express.json({ limit: '100kb' }));
|
app.use(express.json({ limit: '100kb' }));
|
||||||
app.use(express.urlencoded({ extended: true }));
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
|
app.use(enforceGlobalMfaPolicy);
|
||||||
|
|
||||||
if (DEBUG) {
|
if (DEBUG) {
|
||||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
|
|||||||
98
server/src/middleware/mfaPolicy.ts
Normal file
98
server/src/middleware/mfaPolicy.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { db } from '../db/database';
|
||||||
|
import { JWT_SECRET } from '../config';
|
||||||
|
|
||||||
|
/** Paths that never require MFA (public or pre-auth). */
|
||||||
|
function isPublicApiPath(method: string, pathNoQuery: string): boolean {
|
||||||
|
if (method === 'GET' && pathNoQuery === '/api/health') return true;
|
||||||
|
if (method === 'GET' && pathNoQuery === '/api/auth/app-config') return true;
|
||||||
|
if (method === 'POST' && pathNoQuery === '/api/auth/login') return true;
|
||||||
|
if (method === 'POST' && pathNoQuery === '/api/auth/register') return true;
|
||||||
|
if (method === 'POST' && pathNoQuery === '/api/auth/demo-login') return true;
|
||||||
|
if (method === 'GET' && pathNoQuery.startsWith('/api/auth/invite/')) return true;
|
||||||
|
if (method === 'POST' && pathNoQuery === '/api/auth/mfa/verify-login') return true;
|
||||||
|
if (pathNoQuery.startsWith('/api/auth/oidc/')) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Authenticated paths allowed while MFA is not yet enabled (setup + lockout recovery). */
|
||||||
|
function isMfaSetupExemptPath(method: string, pathNoQuery: string): boolean {
|
||||||
|
if (method === 'GET' && pathNoQuery === '/api/auth/me') return true;
|
||||||
|
if (method === 'POST' && pathNoQuery === '/api/auth/mfa/setup') return true;
|
||||||
|
if (method === 'POST' && pathNoQuery === '/api/auth/mfa/enable') return true;
|
||||||
|
if ((method === 'GET' || method === 'PUT') && pathNoQuery === '/api/auth/app-settings') return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When app_settings.require_mfa is true, block API access for users without MFA enabled,
|
||||||
|
* except for public routes and MFA setup endpoints.
|
||||||
|
*/
|
||||||
|
export function enforceGlobalMfaPolicy(req: Request, res: Response, next: NextFunction): void {
|
||||||
|
const pathNoQuery = (req.originalUrl || req.url || '').split('?')[0];
|
||||||
|
|
||||||
|
if (!pathNoQuery.startsWith('/api')) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPublicApiPath(req.method, pathNoQuery)) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
const token = authHeader && authHeader.split(' ')[1];
|
||||||
|
if (!token) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let userId: number;
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, JWT_SECRET) as { id: number };
|
||||||
|
userId = decoded.id;
|
||||||
|
} catch {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requireRow = db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined;
|
||||||
|
if (requireRow?.value !== 'true') {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.DEMO_MODE === 'true') {
|
||||||
|
const demo = db.prepare('SELECT email FROM users WHERE id = ?').get(userId) as { email: string } | undefined;
|
||||||
|
if (demo?.email === 'demo@trek.app' || demo?.email === 'demo@nomad.app') {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = db.prepare('SELECT mfa_enabled, role FROM users WHERE id = ?').get(userId) as
|
||||||
|
| { mfa_enabled: number | boolean; role: string }
|
||||||
|
| undefined;
|
||||||
|
if (!row) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mfaOk = row.mfa_enabled === 1 || row.mfa_enabled === true;
|
||||||
|
if (mfaOk) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMfaSetupExemptPath(req.method, pathNoQuery)) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(403).json({
|
||||||
|
error: 'Two-factor authentication is required. Complete setup in Settings.',
|
||||||
|
code: 'MFA_REQUIRED',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -143,6 +143,7 @@ router.get('/app-config', (_req: Request, res: Response) => {
|
|||||||
);
|
);
|
||||||
const oidcOnlySetting = process.env.OIDC_ONLY || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_only'").get() as { value: string } | undefined)?.value;
|
const oidcOnlySetting = process.env.OIDC_ONLY || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_only'").get() as { value: string } | undefined)?.value;
|
||||||
const oidcOnlyMode = oidcConfigured && oidcOnlySetting === 'true';
|
const oidcOnlyMode = oidcConfigured && oidcOnlySetting === 'true';
|
||||||
|
const requireMfaRow = db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined;
|
||||||
res.json({
|
res.json({
|
||||||
allow_registration: isDemo ? false : allowRegistration,
|
allow_registration: isDemo ? false : allowRegistration,
|
||||||
has_users: userCount > 0,
|
has_users: userCount > 0,
|
||||||
@@ -151,6 +152,7 @@ router.get('/app-config', (_req: Request, res: Response) => {
|
|||||||
oidc_configured: oidcConfigured,
|
oidc_configured: oidcConfigured,
|
||||||
oidc_display_name: oidcConfigured ? (oidcDisplayName || 'SSO') : undefined,
|
oidc_display_name: oidcConfigured ? (oidcDisplayName || 'SSO') : undefined,
|
||||||
oidc_only_mode: oidcOnlyMode,
|
oidc_only_mode: oidcOnlyMode,
|
||||||
|
require_mfa: requireMfaRow?.value === 'true',
|
||||||
allowed_file_types: (db.prepare("SELECT value FROM app_settings WHERE key = 'allowed_file_types'").get() as { value: string } | undefined)?.value || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv',
|
allowed_file_types: (db.prepare("SELECT value FROM app_settings WHERE key = 'allowed_file_types'").get() as { value: string } | undefined)?.value || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv',
|
||||||
demo_mode: isDemo,
|
demo_mode: isDemo,
|
||||||
demo_email: isDemo ? 'demo@trek.app' : undefined,
|
demo_email: isDemo ? 'demo@trek.app' : undefined,
|
||||||
@@ -516,7 +518,7 @@ router.get('/validate-keys', authenticate, async (req: Request, res: Response) =
|
|||||||
res.json(result);
|
res.json(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
const ADMIN_SETTINGS_KEYS = ['allow_registration', 'allowed_file_types', 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'notification_webhook_url', 'app_url'];
|
const ADMIN_SETTINGS_KEYS = ['allow_registration', 'allowed_file_types', 'require_mfa', 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'notification_webhook_url', 'app_url'];
|
||||||
|
|
||||||
router.get('/app-settings', authenticate, (req: Request, res: Response) => {
|
router.get('/app-settings', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
@@ -536,9 +538,23 @@ router.put('/app-settings', authenticate, (req: Request, res: Response) => {
|
|||||||
const user = db.prepare('SELECT role FROM users WHERE id = ?').get(authReq.user.id) as { role: string } | undefined;
|
const user = db.prepare('SELECT role FROM users WHERE id = ?').get(authReq.user.id) as { role: string } | undefined;
|
||||||
if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
|
if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
|
||||||
|
|
||||||
|
const { allow_registration, allowed_file_types, require_mfa } = req.body as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (require_mfa === true || require_mfa === 'true') {
|
||||||
|
const adminMfa = db.prepare('SELECT mfa_enabled FROM users WHERE id = ?').get(authReq.user.id) as { mfa_enabled: number } | undefined;
|
||||||
|
if (!(adminMfa?.mfa_enabled === 1)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Enable two-factor authentication on your own account before requiring it for all users.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const key of ADMIN_SETTINGS_KEYS) {
|
for (const key of ADMIN_SETTINGS_KEYS) {
|
||||||
if (req.body[key] !== undefined) {
|
if (req.body[key] !== undefined) {
|
||||||
const val = String(req.body[key]);
|
let val = String(req.body[key]);
|
||||||
|
if (key === 'require_mfa') {
|
||||||
|
val = req.body[key] === true || val === 'true' ? 'true' : 'false';
|
||||||
|
}
|
||||||
// Don't save masked password
|
// Don't save masked password
|
||||||
if (key === 'smtp_pass' && val === '••••••••') continue;
|
if (key === 'smtp_pass' && val === '••••••••') continue;
|
||||||
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val);
|
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val);
|
||||||
@@ -551,6 +567,7 @@ router.put('/app-settings', authenticate, (req: Request, res: Response) => {
|
|||||||
details: {
|
details: {
|
||||||
allow_registration: allow_registration !== undefined ? Boolean(allow_registration) : undefined,
|
allow_registration: allow_registration !== undefined ? Boolean(allow_registration) : undefined,
|
||||||
allowed_file_types_changed: allowed_file_types !== undefined,
|
allowed_file_types_changed: allowed_file_types !== undefined,
|
||||||
|
require_mfa: require_mfa !== undefined ? (require_mfa === true || require_mfa === 'true') : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
@@ -717,6 +734,10 @@ router.post('/mfa/disable', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (re
|
|||||||
if (process.env.DEMO_MODE === 'true' && authReq.user.email === 'demo@nomad.app') {
|
if (process.env.DEMO_MODE === 'true' && authReq.user.email === 'demo@nomad.app') {
|
||||||
return res.status(403).json({ error: 'MFA cannot be changed in demo mode.' });
|
return res.status(403).json({ error: 'MFA cannot be changed in demo mode.' });
|
||||||
}
|
}
|
||||||
|
const policy = db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined;
|
||||||
|
if (policy?.value === 'true') {
|
||||||
|
return res.status(403).json({ error: 'Two-factor authentication cannot be disabled while it is required for all users.' });
|
||||||
|
}
|
||||||
const { password, code } = req.body as { password?: string; code?: string };
|
const { password, code } = req.body as { password?: string; code?: string };
|
||||||
if (!password || !code) {
|
if (!password || !code) {
|
||||||
return res.status(400).json({ error: 'Password and authenticator code are required' });
|
return res.status(400).json({ error: 'Password and authenticator code are required' });
|
||||||
|
|||||||
@@ -55,12 +55,18 @@ function setupWebSocket(server: http.Server): void {
|
|||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, JWT_SECRET) as { id: number };
|
const decoded = jwt.verify(token, JWT_SECRET) as { id: number };
|
||||||
user = db.prepare(
|
user = db.prepare(
|
||||||
'SELECT id, username, email, role FROM users WHERE id = ?'
|
'SELECT id, username, email, role, mfa_enabled FROM users WHERE id = ?'
|
||||||
).get(decoded.id) as User | undefined;
|
).get(decoded.id) as User | undefined;
|
||||||
if (!user) {
|
if (!user) {
|
||||||
nws.close(4001, 'User not found');
|
nws.close(4001, 'User not found');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const requireMfa = (db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined)?.value === 'true';
|
||||||
|
const mfaOk = user.mfa_enabled === 1 || user.mfa_enabled === true;
|
||||||
|
if (requireMfa && !mfaOk) {
|
||||||
|
nws.close(4403, 'MFA required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
nws.close(4001, 'Invalid or expired token');
|
nws.close(4001, 'Invalid or expired token');
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user