diff --git a/client/src/App.tsx b/client/src/App.tsx index 80e8338..14ae098 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -23,8 +23,9 @@ interface ProtectedRouteProps { } function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps) { - const { isAuthenticated, user, isLoading } = useAuthStore() + const { isAuthenticated, user, isLoading, appRequireMfa } = useAuthStore() const { t } = useTranslation() + const location = useLocation() if (isLoading) { return ( @@ -41,6 +42,15 @@ function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps return } + if ( + appRequireMfa && + user && + !user.mfa_enabled && + location.pathname !== '/settings' + ) { + return + } + if (adminRequired && user && user.role !== 'admin') { return } @@ -63,17 +73,18 @@ function RootRedirect() { } 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() useEffect(() => { if (token) { 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?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key) if (config?.timezone) setServerTimezone(config.timezone) + if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa) if (config?.version) { const storedVersion = localStorage.getItem('trek_app_version') diff --git a/client/src/api/client.ts b/client/src/api/client.ts index fbabe99..98b97ef 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -34,6 +34,13 @@ apiClient.interceptors.response.use( 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) } ) diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index 08820dd..ebeb8bf 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -226,6 +226,7 @@ const ar: Record = { 'settings.avatarError': 'فشل الرفع', 'settings.mfa.title': 'المصادقة الثنائية (2FA)', 'settings.mfa.description': 'تضيف خطوة ثانية عند تسجيل الدخول. استخدم تطبيق مصادقة (Google Authenticator، Authy، إلخ).', + 'settings.mfa.requiredByPolicy': 'المسؤول يتطلب المصادقة الثنائية. اضبط تطبيق المصادقة أدناه قبل المتابعة.', 'settings.mfa.enabled': 'المصادقة الثنائية مفعّلة على حسابك.', 'settings.mfa.disabled': 'المصادقة الثنائية غير مفعّلة.', 'settings.mfa.setup': 'إعداد المصادقة', @@ -374,6 +375,8 @@ const ar: Record = { 'admin.invite.deleteError': 'فشل حذف رابط الدعوة', 'admin.allowRegistration': 'السماح بالتسجيل', 'admin.allowRegistrationHint': 'يمكن للمستخدمين الجدد التسجيل بأنفسهم', + 'admin.requireMfa': 'فرض المصادقة الثنائية (2FA)', + 'admin.requireMfaHint': 'يجب على المستخدمين الذين لا يملكون 2FA إكمال الإعداد في الإعدادات قبل استخدام التطبيق.', 'admin.apiKeys': 'مفاتيح API', 'admin.apiKeysHint': 'اختياري. يُفعّل بيانات الأماكن الموسعة مثل الصور والطقس.', 'admin.mapsKey': 'مفتاح Google Maps API', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index dab6913..746b8e8 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -221,6 +221,7 @@ const br: Record = { 'settings.avatarError': 'Falha no envio', '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.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.disabled': 'O 2FA não está ativado.', 'settings.mfa.setup': 'Configurar autenticador', @@ -364,6 +365,8 @@ const br: Record = { 'admin.tabs.settings': 'Configurações', 'admin.allowRegistration': 'Permitir cadastro', '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.apiKeysHint': 'Opcional. Habilita dados estendidos de lugares, como fotos e clima.', 'admin.mapsKey': 'Chave da API Google Maps', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index daefce0..db955ac 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -188,6 +188,7 @@ const cs: Record = { 'settings.avatarError': 'Nahrávání se nezdařilo', '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.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.disabled': '2FA není aktivní.', 'settings.mfa.setup': 'Nastavit autentizační aplikaci', @@ -365,6 +366,8 @@ const cs: Record = { 'admin.tabs.settings': 'Nastavení', 'admin.allowRegistration': 'Povolit registraci', '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.apiKeysHint': 'Volitelné. Povoluje rozšířená data o místech (fotky, počasí).', 'admin.mapsKey': 'Google Maps API klíč', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 1e5422d..929eefa 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -221,6 +221,7 @@ const de: Record = { 'settings.avatarError': 'Fehler beim Hochladen', '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.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.disabled': '2FA ist nicht aktiviert.', 'settings.mfa.setup': 'Authenticator einrichten', @@ -365,6 +366,8 @@ const de: Record = { 'admin.tabs.settings': 'Einstellungen', 'admin.allowRegistration': 'Registrierung erlauben', '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.apiKeysHint': 'Optional. Aktiviert erweiterte Ortsdaten wie Fotos und Wetter.', 'admin.mapsKey': 'Google Maps API Key', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index b4f5a05..09b90fe 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -221,6 +221,7 @@ const en: Record = { 'settings.avatarError': 'Upload failed', '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.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.disabled': '2FA is not enabled.', 'settings.mfa.setup': 'Set up authenticator', @@ -365,6 +366,8 @@ const en: Record = { 'admin.tabs.settings': 'Settings', 'admin.allowRegistration': 'Allow Registration', '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.apiKeysHint': 'Optional. Enables extended place data like photos and weather.', 'admin.mapsKey': 'Google Maps API Key', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index 778a98c..9001789 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -211,6 +211,7 @@ const es: Record = { 'settings.saveProfile': 'Guardar perfil', '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.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.disabled': '2FA no está activado.', 'settings.mfa.setup': 'Configurar autenticador', @@ -363,6 +364,8 @@ const es: Record = { 'admin.tabs.settings': 'Ajustes', 'admin.allowRegistration': 'Permitir el registro', '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.apiKeysHint': 'Opcional. Activa datos ampliados de lugares, como fotos y previsión del tiempo.', 'admin.mapsKey': 'Clave API de Google Maps', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index 079f0c1..84130d6 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -212,6 +212,7 @@ const fr: Record = { 'settings.saveProfile': 'Enregistrer le profil', '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.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.disabled': '2FA n\'est pas activé.', 'settings.mfa.setup': 'Configurer l\'authentificateur', @@ -364,6 +365,8 @@ const fr: Record = { 'admin.tabs.settings': 'Paramètres', 'admin.allowRegistration': 'Autoriser les inscriptions', '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.apiKeysHint': 'Facultatif. Active les données de lieu étendues comme les photos et la météo.', 'admin.mapsKey': 'Clé API Google Maps', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index 212287c..f46532b 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -187,6 +187,7 @@ const hu: Record = { 'settings.avatarError': 'Feltöltés sikertelen', '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.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.disabled': '2FA nincs engedélyezve.', 'settings.mfa.setup': 'Hitelesítő beállítása', @@ -364,6 +365,8 @@ const hu: Record = { 'admin.tabs.settings': 'Beállítások', 'admin.allowRegistration': 'Regisztráció engedélyezése', '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.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', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index 8727080..6508727 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -187,6 +187,7 @@ const it: Record = { 'settings.avatarError': 'Impossibile caricare', '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.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.disabled': 'La 2FA non è abilitata.', 'settings.mfa.setup': 'Configura authenticator', @@ -364,6 +365,8 @@ const it: Record = { 'admin.tabs.settings': 'Impostazioni', 'admin.allowRegistration': 'Consenti Registrazione', '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.apiKeysHint': 'Opzionale. Abilita dati estesi per i luoghi come foto e meteo.', 'admin.mapsKey': 'Chiave API Google Maps', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index cfe0255..78fc8d8 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -212,6 +212,7 @@ const nl: Record = { 'settings.saveProfile': 'Profiel opslaan', '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.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.disabled': '2FA is niet ingeschakeld.', 'settings.mfa.setup': 'Authenticator instellen', @@ -365,6 +366,8 @@ const nl: Record = { 'admin.tabs.settings': 'Instellingen', 'admin.allowRegistration': 'Registratie toestaan', '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.apiKeysHint': 'Optioneel. Schakelt uitgebreide plaatsgegevens in zoals foto\'s en weer.', 'admin.mapsKey': 'Google Maps API-sleutel', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 5698ebe..a59c99d 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -212,6 +212,7 @@ const ru: Record = { 'settings.saveProfile': 'Сохранить профиль', 'settings.mfa.title': 'Двухфакторная аутентификация (2FA)', 'settings.mfa.description': 'Добавляет второй шаг при входе. Используйте приложение-аутентификатор (Google Authenticator, Authy и др.).', + 'settings.mfa.requiredByPolicy': 'Администратор требует двухфакторную аутентификацию. Настройте приложение-аутентификатор ниже, прежде чем продолжить.', 'settings.mfa.enabled': '2FA включена для вашего аккаунта.', 'settings.mfa.disabled': '2FA не включена.', 'settings.mfa.setup': 'Настроить аутентификатор', @@ -365,6 +366,8 @@ const ru: Record = { 'admin.tabs.settings': 'Настройки', 'admin.allowRegistration': 'Разрешить регистрацию', 'admin.allowRegistrationHint': 'Новые пользователи могут регистрироваться самостоятельно', + 'admin.requireMfa': 'Требовать двухфакторную аутентификацию (2FA)', + 'admin.requireMfaHint': 'Пользователи без 2FA должны завершить настройку в разделе «Настройки» перед использованием приложения.', 'admin.apiKeys': 'API-ключи', 'admin.apiKeysHint': 'Необязательно. Включает расширенные данные о местах, такие как фото и погода.', 'admin.mapsKey': 'API-ключ Google Maps', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 02cd7d1..7bfc2ff 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -212,6 +212,7 @@ const zh: Record = { 'settings.saveProfile': '保存资料', 'settings.mfa.title': '双因素认证 (2FA)', 'settings.mfa.description': '登录时添加第二步验证。使用身份验证器应用(Google Authenticator、Authy 等)。', + 'settings.mfa.requiredByPolicy': '管理员要求双因素身份验证。请先完成下方的身份验证器设置后再继续。', 'settings.mfa.enabled': '您的账户已启用 2FA。', 'settings.mfa.disabled': '2FA 未启用。', 'settings.mfa.setup': '设置身份验证器', @@ -365,6 +366,8 @@ const zh: Record = { 'admin.tabs.settings': '设置', 'admin.allowRegistration': '允许注册', 'admin.allowRegistrationHint': '新用户可以自行注册', + 'admin.requireMfa': '要求双因素身份验证(2FA)', + 'admin.requireMfaHint': '未启用 2FA 的用户必须先完成设置中的配置才能使用应用。', 'admin.apiKeys': 'API 密钥', 'admin.apiKeysHint': '可选。启用地点的扩展数据,如照片和天气。', 'admin.mapsKey': 'Google Maps API 密钥', diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx index 2fe5c2d..38fd59b 100644 --- a/client/src/pages/AdminPage.tsx +++ b/client/src/pages/AdminPage.tsx @@ -85,6 +85,7 @@ export default function AdminPage(): React.ReactElement { // Registration toggle const [allowRegistration, setAllowRegistration] = useState(true) + const [requireMfa, setRequireMfa] = useState(false) // Invite links const [invites, setInvites] = useState([]) @@ -119,7 +120,7 @@ export default function AdminPage(): React.ReactElement { const [updating, setUpdating] = useState(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 { + {/* Require 2FA for all users */} +
+
+

{t('admin.requireMfa')}

+
+
+
+
+

{t('admin.requireMfa')}

+

{t('admin.requireMfaHint')}

+
+ +
+
+
+ {/* Allowed File Types */}
diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx index 7ae6bd2..98a44cc 100644 --- a/client/src/pages/SettingsPage.tsx +++ b/client/src/pages/SettingsPage.tsx @@ -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(false) const avatarInputRef = React.useRef(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 {

{t('settings.mfa.title')}

+ {mfaRequiredByPolicy && ( +
+ +

{t('settings.mfa.requiredByPolicy')}

+
+ )}

{t('settings.mfa.description')}

{demoMode ? (

{t('settings.mfa.demoBlocked')}

diff --git a/client/src/store/authStore.ts b/client/src/store/authStore.ts index 8387c4e..b26eb14 100644 --- a/client/src/store/authStore.ts +++ b/client/src/store/authStore.ts @@ -24,6 +24,8 @@ interface AuthState { demoMode: boolean hasMapsKey: boolean serverTimezone: string + /** Server policy: all users must enable MFA */ + appRequireMfa: boolean login: (email: string, password: string) => Promise completeMfaLogin: (mfaToken: string, code: string) => Promise @@ -38,6 +40,7 @@ interface AuthState { setDemoMode: (val: boolean) => void setHasMapsKey: (val: boolean) => void setServerTimezone: (tz: string) => void + setAppRequireMfa: (val: boolean) => void demoLogin: () => Promise } @@ -50,6 +53,7 @@ export const useAuthStore = create((set, get) => ({ demoMode: localStorage.getItem('demo_mode') === 'true', hasMapsKey: false, serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + appRequireMfa: false, login: async (email: string, password: string) => { set({ isLoading: true, error: null }) @@ -205,6 +209,7 @@ export const useAuthStore = create((set, get) => ({ setHasMapsKey: (val: boolean) => set({ hasMapsKey: val }), setServerTimezone: (tz: string) => set({ serverTimezone: tz }), + setAppRequireMfa: (val: boolean) => set({ appRequireMfa: val }), demoLogin: async () => { set({ isLoading: true, error: null }) diff --git a/client/src/types.ts b/client/src/types.ts index ac232ee..b216b63 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -281,6 +281,8 @@ export interface AppConfig { has_maps_key?: boolean allowed_file_types?: string timezone?: string + /** When true, users without MFA cannot use the app until they enable it */ + require_mfa?: boolean } // Translation function type diff --git a/server/src/index.ts b/server/src/index.ts index 4cdbe4e..a8b1b82 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,5 +1,7 @@ import 'dotenv/config'; +import './config'; import express, { Request, Response, NextFunction } from 'express'; +import { enforceGlobalMfaPolicy } from './middleware/mfaPolicy'; import cors from 'cors'; import helmet from 'helmet'; import path from 'path'; @@ -80,6 +82,8 @@ if (shouldForceHttps) { app.use(express.json({ limit: '100kb' })); app.use(express.urlencoded({ extended: true })); +app.use(enforceGlobalMfaPolicy); + if (DEBUG) { app.use((req: Request, res: Response, next: NextFunction) => { const startedAt = Date.now(); diff --git a/server/src/middleware/mfaPolicy.ts b/server/src/middleware/mfaPolicy.ts new file mode 100644 index 0000000..2912faa --- /dev/null +++ b/server/src/middleware/mfaPolicy.ts @@ -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', + }); +} diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 5229b08..683f42d 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -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 oidcOnlyMode = oidcConfigured && oidcOnlySetting === 'true'; + const requireMfaRow = db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined; res.json({ allow_registration: isDemo ? false : allowRegistration, has_users: userCount > 0, @@ -151,6 +152,7 @@ router.get('/app-config', (_req: Request, res: Response) => { oidc_configured: oidcConfigured, oidc_display_name: oidcConfigured ? (oidcDisplayName || 'SSO') : undefined, 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', demo_mode: isDemo, demo_email: isDemo ? 'demo@trek.app' : undefined, @@ -516,7 +518,7 @@ router.get('/validate-keys', authenticate, async (req: Request, res: Response) = 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) => { 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; 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; + + 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) { 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 if (key === 'smtp_pass' && val === '••••••••') continue; 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: { allow_registration: allow_registration !== undefined ? Boolean(allow_registration) : 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 }); @@ -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') { 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 }; if (!password || !code) { return res.status(400).json({ error: 'Password and authenticator code are required' }); diff --git a/server/src/websocket.ts b/server/src/websocket.ts index 1e28ddb..2f6e44c 100644 --- a/server/src/websocket.ts +++ b/server/src/websocket.ts @@ -55,12 +55,18 @@ function setupWebSocket(server: http.Server): void { try { const decoded = jwt.verify(token, JWT_SECRET) as { id: number }; 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; if (!user) { nws.close(4001, 'User not found'); 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) { nws.close(4001, 'Invalid or expired token'); return;