diff --git a/frontend/src/features/settings/components/MfaSettingsCard.tsx b/frontend/src/features/settings/components/MfaSettingsCard.tsx new file mode 100644 index 00000000..7bf0a58d --- /dev/null +++ b/frontend/src/features/settings/components/MfaSettingsCard.tsx @@ -0,0 +1,320 @@ +import React, { useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import { ShieldCheck, ShieldOff, Copy, Download, Check, KeyRound, AlertTriangle } from 'lucide-react'; + +import { Button, Card, Input, Loading, useConfirm } from '../../../components/common'; +import { mfaService } from '../../../services/mfa.service'; + +// Per-user admin TOTP MFA management (issue #738). Lives on the admin's own +// account surface (Settings → General → Admin Account). Self-service: acts on +// the currently authenticated admin only. + +interface RecoveryCodesPanelProps { + codes: string[]; + onConfirm: () => void; +} + +const RecoveryCodesPanel: React.FC = ({ codes, onConfirm }) => { + const { t } = useTranslation(); + const [copied, setCopied] = useState(false); + const [acknowledged, setAcknowledged] = useState(false); + + const asText = codes.join('\n'); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(asText); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + toast.error(t('settings.mfa.copyFailed')); + } + }; + + const handleDownload = () => { + const blob = new Blob([`${asText}\n`], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'picpeak-recovery-codes.txt'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + return ( +
+
+ +

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

+
+ +
+ {codes.map((code) => ( + {code} + ))} +
+ +
+ + +
+ + + + +
+ ); +}; + +export const MfaSettingsCard: React.FC = () => { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const confirm = useConfirm(); + + const { data: status, isLoading } = useQuery({ + queryKey: ['admin-mfa-status'], + queryFn: () => mfaService.getStatus(), + }); + + // Enrollment flow state + const [setupData, setSetupData] = useState> | null>(null); + const [enableCode, setEnableCode] = useState(''); + const [enableError, setEnableError] = useState(null); + + // Recovery codes to display once (after enable or regenerate) + const [recoveryCodes, setRecoveryCodes] = useState(null); + + // Regenerate flow state + const [showRegenerate, setShowRegenerate] = useState(false); + const [regenerateCode, setRegenerateCode] = useState(''); + const [regenerateError, setRegenerateError] = useState(null); + + const invalidateStatus = () => queryClient.invalidateQueries({ queryKey: ['admin-mfa-status'] }); + + const errorMessage = (error: any, fallbackKey: string): string => + error?.response?.data?.error || t(fallbackKey); + + const setupMutation = useMutation({ + mutationFn: () => mfaService.setup(), + onSuccess: (data) => { + setSetupData(data); + setEnableCode(''); + setEnableError(null); + }, + onError: (error) => toast.error(errorMessage(error, 'settings.mfa.setupFailed')), + }); + + const enableMutation = useMutation({ + mutationFn: (code: string) => mfaService.enable(code), + onSuccess: (data) => { + setRecoveryCodes(data.recoveryCodes); + setSetupData(null); + setEnableCode(''); + setEnableError(null); + invalidateStatus(); + }, + onError: (error) => setEnableError(errorMessage(error, 'settings.mfa.enableFailed')), + }); + + const disableMutation = useMutation({ + mutationFn: (code: string) => mfaService.disable(code), + onSuccess: () => { + toast.success(t('settings.mfa.disabledToast')); + invalidateStatus(); + }, + onError: (error) => toast.error(errorMessage(error, 'settings.mfa.disableFailed')), + }); + + const regenerateMutation = useMutation({ + mutationFn: (code: string) => mfaService.regenerateRecoveryCodes(code), + onSuccess: (data) => { + setRecoveryCodes(data.recoveryCodes); + setShowRegenerate(false); + setRegenerateCode(''); + setRegenerateError(null); + invalidateStatus(); + }, + onError: (error) => setRegenerateError(errorMessage(error, 'settings.mfa.regenerateFailed')), + }); + + const handleDisable = async () => { + const code = window.prompt(t('settings.mfa.disablePrompt')); + if (code === null) return; + const trimmed = code.trim(); + if (!trimmed) { + toast.error(t('settings.mfa.codeRequired')); + return; + } + const ok = await confirm({ + title: t('settings.mfa.disableConfirmTitle'), + message: t('settings.mfa.disableConfirmMessage'), + variant: 'danger', + confirmLabel: t('settings.mfa.disableConfirmButton'), + }); + if (ok) disableMutation.mutate(trimmed); + }; + + return ( + +
+ +

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

+
+

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

+ + {isLoading ? ( +
+ +
+ ) : recoveryCodes ? ( + setRecoveryCodes(null)} /> + ) : status?.enabled ? ( + /* ---------------- Enrolled ---------------- */ +
+
+ + {t('settings.mfa.enabledBadge')} +
+ +

+ {t('settings.mfa.recoveryCodesRemaining', { count: status.recoveryCodesRemaining })} +

+ + {showRegenerate ? ( +
+

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

+ { + setRegenerateCode(e.target.value); + if (regenerateError) setRegenerateError(null); + }} + placeholder={t('settings.mfa.codePlaceholder')} + leftIcon={} + error={regenerateError || undefined} + autoComplete="one-time-code" + /> +
+ + +
+
+ ) : ( +
+ + +
+ )} +
+ ) : setupData ? ( + /* ---------------- Setup in progress ---------------- */ +
+

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

+
+ {t('settings.mfa.qrAlt')} +
+

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

+ + {setupData.secret} + +
+
+ +
+ + { + setEnableCode(e.target.value); + if (enableError) setEnableError(null); + }} + placeholder={t('settings.mfa.codePlaceholder')} + leftIcon={} + error={enableError || undefined} + inputMode="numeric" + autoComplete="one-time-code" + /> +
+ +
+ + +
+
+ ) : ( + /* ---------------- Not enrolled ---------------- */ +
+

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

+ +
+ )} +
+ ); +}; diff --git a/frontend/src/features/settings/hooks/useSettingsState.ts b/frontend/src/features/settings/hooks/useSettingsState.ts index 7344907e..b8977d55 100644 --- a/frontend/src/features/settings/hooks/useSettingsState.ts +++ b/frontend/src/features/settings/hooks/useSettingsState.ts @@ -35,7 +35,6 @@ export interface GeneralSettings { export interface SecuritySettings { password_min_length: number; password_complexity: string; - enable_2fa: boolean; session_timeout_minutes: number; max_login_attempts: number; attempt_window_minutes: number; @@ -134,7 +133,6 @@ export function useSettingsState() { const [securitySettings, setSecuritySettings] = useState({ password_min_length: 8, password_complexity: 'moderate', - enable_2fa: false, session_timeout_minutes: 60, max_login_attempts: 5, attempt_window_minutes: 15, @@ -231,7 +229,6 @@ export function useSettingsState() { setSecuritySettings({ password_min_length: toNumber(settings.security_password_min_length, 8), password_complexity: settings.security_password_complexity ?? 'moderate', - enable_2fa: toBoolean(settings.security_enable_2fa, false), session_timeout_minutes: toNumber(settings.security_session_timeout_minutes, 60), max_login_attempts: toNumber(settings.security_max_login_attempts, 5), attempt_window_minutes: toNumber(settings.security_attempt_window_minutes, 15), diff --git a/frontend/src/features/settings/tabs/GeneralTab.tsx b/frontend/src/features/settings/tabs/GeneralTab.tsx index 85e63de3..d250570e 100644 --- a/frontend/src/features/settings/tabs/GeneralTab.tsx +++ b/frontend/src/features/settings/tabs/GeneralTab.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import type { GeneralSettings } from '../hooks/useSettingsState'; import { MAX_FILES_PER_UPLOAD_LIMIT } from '../hooks/useSettingsState'; import { SUPPORTED_LANGUAGES } from "../../../components/common/LanguageSelector.tsx"; +import { MfaSettingsCard } from '../components/MfaSettingsCard'; interface GeneralTabProps { generalSettings: GeneralSettings; @@ -94,6 +95,10 @@ export const GeneralTab: React.FC = ({ )} + {/* Per-user two-factor authentication (issue #738) — lives beside the + admin's own account details rather than the admin-wide Security tab. */} + +

{t('settings.general.siteConfiguration')}

diff --git a/frontend/src/features/settings/tabs/SecurityTab.tsx b/frontend/src/features/settings/tabs/SecurityTab.tsx index 79675bf0..cb338f13 100644 --- a/frontend/src/features/settings/tabs/SecurityTab.tsx +++ b/frontend/src/features/settings/tabs/SecurityTab.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Save, Key, AlertCircle } from 'lucide-react'; +import { Save, Key, AlertCircle, ShieldCheck } from 'lucide-react'; import { Button, Card, Input } from '../../../components/common'; import { useTranslation } from 'react-i18next'; import type { SecuritySettings } from '../hooks/useSettingsState'; @@ -124,15 +124,15 @@ export const SecurityTab: React.FC = ({ - +
+
+ +
+

{t('settings.security.twoFactorTitle')}

+

{t('settings.security.twoFactorNote')}

+
+
+
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 3b761a57..633af451 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1422,13 +1422,14 @@ "attemptWindowMinutesHelp": "Zeitraum, in dem fehlgeschlagene Anmeldeversuche gezählt werden", "lockoutDurationMinutes": "Sperrdauer (Minuten)", "lockoutDurationMinutesHelp": "Wie lange Galerie oder Konto nach zu vielen Fehlern gesperrt bleiben", - "enable2FA": "Zwei-Faktor-Authentifizierung für Admins aktivieren", "recaptchaSettings": "reCAPTCHA-Einstellungen", "enableRecaptcha": "reCAPTCHA für Anmeldeformulare aktivieren", "siteKey": "Site-Schlüssel", "secretKey": "Geheimer Schlüssel", "recaptchaHelp": "Holen Sie sich Ihre reCAPTCHA-Schlüssel von", - "saveSecuritySettings": "Sicherheitseinstellungen speichern" + "saveSecuritySettings": "Sicherheitseinstellungen speichern", + "twoFactorTitle": "Zwei-Faktor-Authentifizierung", + "twoFactorNote": "Die Zwei-Faktor-Authentifizierung wird jetzt pro Admin unter Einstellungen → Allgemein → Admin-Konto verwaltet. Jeder Admin aktiviert sie für seine eigene Anmeldung." }, "events": { "title": "Veranstaltungserstellung", @@ -2040,6 +2041,42 @@ "testSend": "Test senden", "testSending": "Senden…", "testSentToast": "Testnachricht gesendet (ID: {{id}})." + }, + "mfa": { + "title": "Zwei-Faktor-Authentifizierung", + "description": "Sichere deine Admin-Anmeldung mit einem zweiten Schritt über eine Authenticator-App (TOTP).", + "notEnrolled": "Die Zwei-Faktor-Authentifizierung ist für dein Konto nicht aktiviert.", + "setUp": "Einrichten", + "setupScanInstruction": "Scanne diesen QR-Code mit deiner Authenticator-App (z. B. Google Authenticator, 1Password, Authy).", + "manualEntry": "Oder gib diesen Schlüssel manuell ein:", + "qrAlt": "QR-Code zur Zwei-Faktor-Einrichtung", + "enterCodeLabel": "Gib den 6-stelligen Code aus deiner App ein", + "codePlaceholder": "123456", + "enable": "Aktivieren", + "enabledBadge": "Die Zwei-Faktor-Authentifizierung ist aktiviert.", + "recoveryCodesRemaining": "Noch {{count}} Wiederherstellungscode übrig.", + "recoveryCodesRemaining_other": "Noch {{count}} Wiederherstellungscodes übrig.", + "regenerate": "Wiederherstellungscodes neu erzeugen", + "regenerateHelp": "Gib einen aktuellen Authentifizierungscode ein, um neue Wiederherstellungscodes zu erzeugen. Deine alten Codes werden ungültig.", + "regenerateConfirm": "Neu erzeugen", + "disable": "Deaktivieren", + "disablePrompt": "Gib einen aktuellen Authentifizierungs- oder Wiederherstellungscode ein, um die Zwei-Faktor-Authentifizierung zu deaktivieren:", + "disableConfirmTitle": "Zwei-Faktor-Authentifizierung deaktivieren?", + "disableConfirmMessage": "Für dein Konto ist bei der Anmeldung dann kein zweiter Schritt mehr erforderlich. Du kannst sie jederzeit wieder aktivieren.", + "disableConfirmButton": "Deaktivieren", + "disabledToast": "Zwei-Faktor-Authentifizierung deaktiviert.", + "codeRequired": "Ein Code ist erforderlich.", + "recoveryCodesWarning": "Speichere diese Wiederherstellungscodes jetzt. Jeder kann einmal verwendet werden, falls du den Zugriff auf deine Authenticator-App verlierst. Sie werden nicht erneut angezeigt.", + "recoveryCodesAck": "Ich habe meine Wiederherstellungscodes an einem sicheren Ort gespeichert.", + "copy": "Kopieren", + "copied": "Kopiert", + "copyFailed": "Kopieren in die Zwischenablage fehlgeschlagen.", + "download": ".txt herunterladen", + "done": "Fertig", + "setupFailed": "Zwei-Faktor-Einrichtung konnte nicht gestartet werden. Bitte versuche es erneut.", + "enableFailed": "Zwei-Faktor-Authentifizierung konnte nicht aktiviert werden. Prüfe den Code und versuche es erneut.", + "disableFailed": "Zwei-Faktor-Authentifizierung konnte nicht deaktiviert werden. Prüfe den Code und versuche es erneut.", + "regenerateFailed": "Wiederherstellungscodes konnten nicht neu erzeugt werden. Prüfe den Code und versuche es erneut." } }, "branding": { @@ -3585,7 +3622,25 @@ "generalError": "Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.", "needHelp": "Hilfe benötigt? Kontakt", "poweredBy": "Bereitgestellt von PicPeak", - "devModeHint": "Entwicklungsmodus: E-Mail: admin@example.com, Passwort: admin123" + "devModeHint": "Entwicklungsmodus: E-Mail: admin@example.com, Passwort: admin123", + "mfa": { + "title": "Zwei-Faktor-Authentifizierung", + "subtitle": "Gib den 6-stelligen Code aus deiner Authenticator-App ein.", + "recoverySubtitle": "Gib einen deiner Wiederherstellungscodes ein.", + "codeLabel": "Authentifizierungscode", + "codePlaceholder": "123456", + "recoveryCodeLabel": "Wiederherstellungscode", + "recoveryCodePlaceholder": "awzq-jca3-va", + "verify": "Bestätigen", + "back": "Zurück", + "useRecoveryCode": "Stattdessen Wiederherstellungscode verwenden", + "useAuthenticator": "Stattdessen Authenticator-App verwenden", + "codeRequired": "Gib deinen Authentifizierungscode ein", + "invalidCode": "Ungültiger Code. Bitte versuche es erneut.", + "sessionExpired": "Deine Bestätigungssitzung ist abgelaufen. Bitte melde dich erneut an.", + "locked": "Konto wegen zu vieler Versuche vorübergehend gesperrt. Versuche es später erneut.", + "lockedRetry": "Konto vorübergehend gesperrt. Versuche es in {{seconds}} Sekunden erneut." + } }, "cssTemplates": { "title": "Benutzerdefinierte CSS-Vorlagen", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 3ba9cb3c..fbb30759 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -969,13 +969,14 @@ "attemptWindowMinutesHelp": "How long to look back when counting failed login attempts", "lockoutDurationMinutes": "Lockout Duration (minutes)", "lockoutDurationMinutesHelp": "How long the gallery or account stays locked after too many failures", - "enable2FA": "Enable two-factor authentication for admins", "recaptchaSettings": "reCAPTCHA Settings", "enableRecaptcha": "Enable reCAPTCHA for login forms", "siteKey": "Site Key", "secretKey": "Secret Key", "recaptchaHelp": "Get your reCAPTCHA keys from", - "saveSecuritySettings": "Save Security Settings" + "saveSecuritySettings": "Save Security Settings", + "twoFactorTitle": "Two-factor authentication", + "twoFactorNote": "Two-factor authentication is now managed per admin from Settings → General → Admin Account. Each admin enables it for their own login." }, "categories": { "title": "Categories", @@ -1587,6 +1588,42 @@ "testSend": "Send test", "testSending": "Sending…", "testSentToast": "Test message sent (id: {{id}})." + }, + "mfa": { + "title": "Two-factor authentication", + "description": "Add a second step to your admin sign-in using an authenticator app (TOTP).", + "notEnrolled": "Two-factor authentication is not enabled for your account.", + "setUp": "Set up", + "setupScanInstruction": "Scan this QR code with your authenticator app (e.g. Google Authenticator, 1Password, Authy).", + "manualEntry": "Or enter this secret manually:", + "qrAlt": "Two-factor setup QR code", + "enterCodeLabel": "Enter the 6-digit code from your app", + "codePlaceholder": "123456", + "enable": "Enable", + "enabledBadge": "Two-factor authentication is enabled.", + "recoveryCodesRemaining": "{{count}} recovery code remaining.", + "recoveryCodesRemaining_other": "{{count}} recovery codes remaining.", + "regenerate": "Regenerate recovery codes", + "regenerateHelp": "Enter a current authentication code to generate a new set of recovery codes. Your old codes will stop working.", + "regenerateConfirm": "Regenerate", + "disable": "Disable", + "disablePrompt": "Enter a current authentication or recovery code to disable two-factor authentication:", + "disableConfirmTitle": "Disable two-factor authentication?", + "disableConfirmMessage": "Your account will no longer require a second step at sign-in. You can re-enable it at any time.", + "disableConfirmButton": "Disable", + "disabledToast": "Two-factor authentication disabled.", + "codeRequired": "A code is required.", + "recoveryCodesWarning": "Save these recovery codes now. Each can be used once if you lose access to your authenticator app. They will not be shown again.", + "recoveryCodesAck": "I have saved my recovery codes in a safe place.", + "copy": "Copy", + "copied": "Copied", + "copyFailed": "Failed to copy to clipboard.", + "download": "Download .txt", + "done": "Done", + "setupFailed": "Could not start two-factor setup. Please try again.", + "enableFailed": "Could not enable two-factor authentication. Check the code and try again.", + "disableFailed": "Could not disable two-factor authentication. Check the code and try again.", + "regenerateFailed": "Could not regenerate recovery codes. Check the code and try again." } }, "analytics": { @@ -3481,7 +3518,25 @@ "generalError": "An error occurred. Please try again.", "needHelp": "Need help? Contact", "poweredBy": "Powered by PicPeak", - "devModeHint": "Development Mode: Use email: admin@example.com, password: admin123" + "devModeHint": "Development Mode: Use email: admin@example.com, password: admin123", + "mfa": { + "title": "Two-factor authentication", + "subtitle": "Enter the 6-digit code from your authenticator app.", + "recoverySubtitle": "Enter one of your recovery codes.", + "codeLabel": "Authentication code", + "codePlaceholder": "123456", + "recoveryCodeLabel": "Recovery code", + "recoveryCodePlaceholder": "awzq-jca3-va", + "verify": "Verify", + "back": "Back", + "useRecoveryCode": "Use a recovery code instead", + "useAuthenticator": "Use your authenticator app instead", + "codeRequired": "Enter your authentication code", + "invalidCode": "Invalid code. Please try again.", + "sessionExpired": "Your verification session expired. Please sign in again.", + "locked": "Account temporarily locked due to too many attempts. Try again later.", + "lockedRetry": "Account temporarily locked. Try again in {{seconds}} seconds." + } }, "slideshow": { "adminTitle": "Live Slideshow", diff --git a/frontend/src/pages/admin/AdminLoginPage.tsx b/frontend/src/pages/admin/AdminLoginPage.tsx index 5a482708..52173d69 100644 --- a/frontend/src/pages/admin/AdminLoginPage.tsx +++ b/frontend/src/pages/admin/AdminLoginPage.tsx @@ -1,13 +1,14 @@ import React, { useState, useEffect } from 'react'; import { Navigate, useSearchParams } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; -import { Lock, Mail, Eye, EyeOff, AlertCircle } from 'lucide-react'; +import { Lock, Mail, Eye, EyeOff, AlertCircle, ShieldCheck, KeyRound, ArrowLeft } from 'lucide-react'; import { toast } from 'react-toastify'; import { useTranslation } from 'react-i18next'; import { Button, Input, Card, ReCaptcha } from '../../components/common'; import { useAdminAuth } from '../../contexts'; import { authService } from '../../services/auth.service'; +import { isMfaChallenge } from '../../types'; import { setupService } from '../../services/setup.service'; import { usePublicSettings } from '../../hooks/usePublicSettings'; import { useAdminDarkMode } from '../../contexts/AdminDarkModeContext'; @@ -29,6 +30,14 @@ export const AdminLoginPage: React.FC = () => { const [loginSuccess, setLoginSuccess] = useState(false); const [recaptchaToken, setRecaptchaToken] = useState(null); + // Two-step MFA challenge state (issue #738). When the first step returns + // { mfaRequired, mfaToken } we swap the form to a code entry step. + const [step, setStep] = useState<'credentials' | 'mfa'>('credentials'); + const [mfaToken, setMfaToken] = useState(null); + const [mfaCode, setMfaCode] = useState(''); + const [useRecoveryCode, setUseRecoveryCode] = useState(false); + const [mfaError, setMfaError] = useState(null); + const { data: settingsData } = usePublicSettings(); const { isDark } = useAdminDarkMode(); @@ -103,6 +112,15 @@ export const AdminLoginPage: React.FC = () => { ...formData, recaptchaToken }); + // MFA enabled → move to the second step instead of logging in. + if (isMfaChallenge(response)) { + setMfaToken(response.mfaToken); + setMfaCode(''); + setUseRecoveryCode(false); + setMfaError(null); + setStep('mfa'); + return; + } login(response.token, response.user); toast.success(t('adminLogin.loginSuccess')); setLoginSuccess(true); @@ -142,6 +160,62 @@ export const AdminLoginPage: React.FC = () => { } }; + const backToCredentials = () => { + setStep('credentials'); + setMfaToken(null); + setMfaCode(''); + setMfaError(null); + setUseRecoveryCode(false); + }; + + const handleMfaSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + toast.dismiss(); + + const code = mfaCode.trim(); + if (!code) { + setMfaError(t('adminLogin.mfa.codeRequired')); + return; + } + if (!mfaToken) { + // Token lost somehow — restart the flow. + toast.info(t('adminLogin.mfa.sessionExpired')); + backToCredentials(); + return; + } + + setIsLoading(true); + setMfaError(null); + + try { + const response = await authService.adminLoginMfa({ mfaToken, code }); + login(response.token, response.user); + toast.success(t('adminLogin.loginSuccess')); + setLoginSuccess(true); + } catch (error: any) { + const data = error.response?.data; + const code = data?.code; + if (error.response?.status === 423) { + const retryAfter = data?.retryAfter; + toast.error( + retryAfter + ? t('adminLogin.mfa.lockedRetry', { seconds: retryAfter }) + : t('adminLogin.mfa.locked') + ); + backToCredentials(); + } else if (code === 'MFA_SESSION_EXPIRED') { + toast.info(t('adminLogin.mfa.sessionExpired')); + backToCredentials(); + } else if (code === 'MFA_INVALID') { + setMfaError(t('adminLogin.mfa.invalidCode')); + } else { + setMfaError(data?.error || t('adminLogin.generalError')); + } + } finally { + setIsLoading(false); + } + }; + return (
@@ -177,6 +251,7 @@ export const AdminLoginPage: React.FC = () => { {/* Login Form */} + {step === 'credentials' ? (
{/* Form Error */} {errors.form && ( @@ -263,6 +338,81 @@ export const AdminLoginPage: React.FC = () => { {t('adminLogin.signIn')}
+ ) : ( +
+
+
+ +
+

+ {t('adminLogin.mfa.title')} +

+

+ {useRecoveryCode ? t('adminLogin.mfa.recoverySubtitle') : t('adminLogin.mfa.subtitle')} +

+
+ + {mfaError && ( +
+ +

{mfaError}

+
+ )} + +
+ + { + setMfaCode(e.target.value); + if (mfaError) setMfaError(null); + }} + placeholder={useRecoveryCode ? t('adminLogin.mfa.recoveryCodePlaceholder') : t('adminLogin.mfa.codePlaceholder')} + leftIcon={} + inputMode={useRecoveryCode ? 'text' : 'numeric'} + autoComplete="one-time-code" + autoFocus + /> +
+ + + +
+ + +
+
+ )}
{/* Footer */} diff --git a/frontend/src/services/auth.service.ts b/frontend/src/services/auth.service.ts index 46d8add5..ff081149 100644 --- a/frontend/src/services/auth.service.ts +++ b/frontend/src/services/auth.service.ts @@ -1,5 +1,5 @@ import { api } from '../config/api'; -import type { LoginResponse, GalleryAuthResponse, AdminUser } from '../types'; +import type { LoginResponse, AdminLoginResponse, GalleryAuthResponse, AdminUser } from '../types'; import { normalizeRequirePassword } from '../utils/accessControl'; const normalizeGalleryResponse = (response: GalleryAuthResponse): GalleryAuthResponse => ({ @@ -14,9 +14,10 @@ const normalizeGalleryResponse = (response: GalleryAuthResponse): GalleryAuthRes export const authService = { // Admin authentication - async adminLogin(credentials: { email: string; password: string; recaptchaToken?: string | null }): Promise { - // Backend expects 'username' field, but we accept email - const response = await api.post('/auth/admin/login', { + async adminLogin(credentials: { email: string; password: string; recaptchaToken?: string | null }): Promise { + // Backend expects 'username' field, but we accept email. + // Returns either { user } (session set) or an MFA challenge { mfaRequired, mfaToken }. + const response = await api.post('/auth/admin/login', { username: credentials.email, password: credentials.password, recaptchaToken: credentials.recaptchaToken @@ -24,6 +25,14 @@ export const authService = { return response.data; }, + // Second step of the two-step admin login. `code` accepts a 6-digit TOTP + // or a recovery code (e.g. "awzq-jca3-va"). On success the session cookie + // is set server-side and the user object is returned. + async adminLoginMfa(payload: { mfaToken: string; code: string }): Promise { + const response = await api.post('/auth/admin/login/mfa', payload); + return response.data; + }, + async adminLogout() { try { await api.post('/auth/logout'); diff --git a/frontend/src/services/mfa.service.ts b/frontend/src/services/mfa.service.ts new file mode 100644 index 00000000..0324d619 --- /dev/null +++ b/frontend/src/services/mfa.service.ts @@ -0,0 +1,50 @@ +import { api } from '../config/api'; + +// Per-user admin TOTP MFA (issue #738). All endpoints operate on the +// currently authenticated admin's own account. + +export interface MfaStatus { + enabled: boolean; + enrolledAt: string | null; + recoveryCodesRemaining: number; +} + +export interface MfaSetupResponse { + secret: string; + otpauthUri: string; + qr: string; // PNG data URL + issuer: string; + account: string; +} + +export interface MfaRecoveryCodesResponse { + message: string; + recoveryCodes: string[]; +} + +export const mfaService = { + async getStatus(): Promise { + const response = await api.get('/admin/auth/mfa/status'); + return response.data; + }, + + async setup(): Promise { + const response = await api.post('/admin/auth/mfa/setup'); + return response.data; + }, + + async enable(code: string): Promise { + const response = await api.post('/admin/auth/mfa/enable', { code }); + return response.data; + }, + + async disable(code: string): Promise<{ message: string }> { + const response = await api.post<{ message: string }>('/admin/auth/mfa/disable', { code }); + return response.data; + }, + + async regenerateRecoveryCodes(code: string): Promise { + const response = await api.post('/admin/auth/mfa/recovery-codes', { code }); + return response.data; + }, +}; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index b84491bf..a33955a3 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -252,6 +252,20 @@ export interface LoginResponse { user: AdminUser; } +// Two-step admin login: when MFA is enabled, POST /auth/admin/login returns +// this challenge instead of a session (no cookie yet). The mfaToken is a +// short-lived (5 min) JWT exchanged at POST /auth/admin/login/mfa. +export interface MfaChallengeResponse { + mfaRequired: true; + mfaToken: string; +} + +export type AdminLoginResponse = LoginResponse | MfaChallengeResponse; + +export function isMfaChallenge(res: AdminLoginResponse): res is MfaChallengeResponse { + return (res as MfaChallengeResponse).mfaRequired === true; +} + export interface GalleryAuthResponse { token: string; event: {