feat: Implement Two-Factor Authentication (2FA) for enhanced user account security.
This commit is contained in:
@@ -23,6 +23,16 @@ export function AuthProvider({ children }) {
|
||||
|
||||
const login = useCallback(async (email, password) => {
|
||||
const res = await api.post('/auth/login', { email, password });
|
||||
if (res.data.requires2FA) {
|
||||
return { requires2FA: true, tempToken: res.data.tempToken };
|
||||
}
|
||||
localStorage.setItem('token', res.data.token);
|
||||
setUser(res.data.user);
|
||||
return res.data.user;
|
||||
}, []);
|
||||
|
||||
const verify2FA = useCallback(async (tempToken, code) => {
|
||||
const res = await api.post('/auth/login/2fa', { tempToken, code });
|
||||
localStorage.setItem('token', res.data.token);
|
||||
setUser(res.data.user);
|
||||
return res.data.user;
|
||||
@@ -75,7 +85,7 @@ export function AuthProvider({ children }) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, register, logout, loginWithOAuth, updateUser }}>
|
||||
<AuthContext.Provider value={{ user, loading, login, verify2FA, register, logout, loginWithOAuth, updateUser }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
{
|
||||
"common": {
|
||||
"appName": "Redlight",
|
||||
"loading": "Laden...",
|
||||
@@ -99,7 +99,15 @@
|
||||
"oauthError": "Anmeldung fehlgeschlagen",
|
||||
"oauthNoToken": "Kein Authentifizierungstoken erhalten.",
|
||||
"oauthLoginFailed": "Anmeldung konnte nicht abgeschlossen werden. Bitte versuche es erneut.",
|
||||
"oauthRedirecting": "Du wirst angemeldet..."
|
||||
"oauthRedirecting": "Du wirst angemeldet...",
|
||||
"2fa": {
|
||||
"title": "Zwei-Faktor-Authentifizierung",
|
||||
"prompt": "Gib den 6-stelligen Code aus deiner Authenticator-App ein.",
|
||||
"codeLabel": "Bestätigungscode",
|
||||
"verify": "Bestätigen",
|
||||
"verifyFailed": "Überprüfung fehlgeschlagen",
|
||||
"backToLogin": "← Zurück zum Login"
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"madeFor": "Made for BigBlueButton",
|
||||
@@ -327,6 +335,26 @@
|
||||
"passwordChangeFailed": "Fehler beim Ändern",
|
||||
"passwordMismatch": "Passwörter stimmen nicht überein",
|
||||
"selectLanguage": "Sprache auswählen",
|
||||
"security": {
|
||||
"title": "Sicherheit",
|
||||
"subtitle": "Schütze dein Konto mit Zwei-Faktor-Authentifizierung (2FA). Nach der Aktivierung benötigst du sowohl dein Passwort als auch einen Code aus deiner Authenticator-App zum Anmelden.",
|
||||
"statusEnabled": "2FA ist aktiviert",
|
||||
"statusEnabledDesc": "Dein Konto ist durch Zwei-Faktor-Authentifizierung geschützt.",
|
||||
"statusDisabled": "2FA ist nicht aktiviert",
|
||||
"statusDisabledDesc": "Aktiviere die Zwei-Faktor-Authentifizierung für zusätzliche Sicherheit.",
|
||||
"enable": "2FA aktivieren",
|
||||
"disable": "2FA deaktivieren",
|
||||
"enabled": "Zwei-Faktor-Authentifizierung aktiviert!",
|
||||
"disabled": "Zwei-Faktor-Authentifizierung deaktiviert.",
|
||||
"enableFailed": "2FA konnte nicht aktiviert werden",
|
||||
"disableFailed": "2FA konnte nicht deaktiviert werden",
|
||||
"setupFailed": "2FA-Einrichtung konnte nicht gestartet werden",
|
||||
"scanQR": "Scanne diesen QR-Code mit deiner Authenticator-App (Google Authenticator, Authy, etc.).",
|
||||
"manualKey": "Oder gib diesen Schlüssel manuell ein:",
|
||||
"verifyCode": "Gib den Code aus deiner App zur Überprüfung ein",
|
||||
"codeLabel": "6-stelliger Code",
|
||||
"disableConfirm": "Gib dein Passwort und einen aktuellen 2FA-Code ein, um die Zwei-Faktor-Authentifizierung zu deaktivieren."
|
||||
},
|
||||
"caldav": {
|
||||
"title": "CalDAV",
|
||||
"subtitle": "Verbinde deine Kalender-App (z. B. Apple Kalender, Thunderbird, DAVx⁵) über das CalDAV-Protokoll. Verwende deine E-Mail-Adresse und ein App-Token als Passwort.",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
{
|
||||
"common": {
|
||||
"appName": "Redlight",
|
||||
"loading": "Loading...",
|
||||
@@ -99,7 +99,15 @@
|
||||
"oauthError": "Authentication failed",
|
||||
"oauthNoToken": "No authentication token received.",
|
||||
"oauthLoginFailed": "Could not complete sign in. Please try again.",
|
||||
"oauthRedirecting": "Signing you in..."
|
||||
"oauthRedirecting": "Signing you in...",
|
||||
"2fa": {
|
||||
"title": "Two-Factor Authentication",
|
||||
"prompt": "Enter the 6-digit code from your authenticator app.",
|
||||
"codeLabel": "Verification code",
|
||||
"verify": "Verify",
|
||||
"verifyFailed": "Verification failed",
|
||||
"backToLogin": "← Back to login"
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"madeFor": "Made for BigBlueButton",
|
||||
@@ -327,6 +335,26 @@
|
||||
"passwordChangeFailed": "Error changing password",
|
||||
"passwordMismatch": "Passwords do not match",
|
||||
"selectLanguage": "Select language",
|
||||
"security": {
|
||||
"title": "Security",
|
||||
"subtitle": "Protect your account with two-factor authentication (2FA). After enabling, you will need both your password and a code from your authenticator app to sign in.",
|
||||
"statusEnabled": "2FA is enabled",
|
||||
"statusEnabledDesc": "Your account is protected with two-factor authentication.",
|
||||
"statusDisabled": "2FA is not enabled",
|
||||
"statusDisabledDesc": "Enable two-factor authentication for an extra layer of security.",
|
||||
"enable": "Enable 2FA",
|
||||
"disable": "Disable 2FA",
|
||||
"enabled": "Two-factor authentication enabled!",
|
||||
"disabled": "Two-factor authentication disabled.",
|
||||
"enableFailed": "Could not enable 2FA",
|
||||
"disableFailed": "Could not disable 2FA",
|
||||
"setupFailed": "Could not start 2FA setup",
|
||||
"scanQR": "Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.).",
|
||||
"manualKey": "Or enter this key manually:",
|
||||
"verifyCode": "Enter the code from your app to verify",
|
||||
"codeLabel": "6-digit code",
|
||||
"disableConfirm": "Enter your password and a current 2FA code to disable two-factor authentication."
|
||||
},
|
||||
"caldav": {
|
||||
"title": "CalDAV",
|
||||
"subtitle": "Connect your calendar app (e.g. Apple Calendar, Thunderbird, DAVx⁵) using the CalDAV protocol. Use your email address and an app token as password.",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useBranding } from '../contexts/BrandingContext';
|
||||
import { Mail, Lock, ArrowRight, Loader2, AlertTriangle, RefreshCw, LogIn } from 'lucide-react';
|
||||
import { Mail, Lock, ArrowRight, Loader2, AlertTriangle, RefreshCw, LogIn, ShieldCheck } from 'lucide-react';
|
||||
import BrandLogo from '../components/BrandLogo';
|
||||
import api from '../services/api';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -15,7 +15,14 @@ export default function Login() {
|
||||
const [needsVerification, setNeedsVerification] = useState(false);
|
||||
const [resendCooldown, setResendCooldown] = useState(0);
|
||||
const [resending, setResending] = useState(false);
|
||||
const { login } = useAuth();
|
||||
// 2FA state
|
||||
const [needs2FA, setNeeds2FA] = useState(false);
|
||||
const [tempToken, setTempToken] = useState('');
|
||||
const [totpCode, setTotpCode] = useState('');
|
||||
const [verifying2FA, setVerifying2FA] = useState(false);
|
||||
const totpInputRef = useRef(null);
|
||||
|
||||
const { login, verify2FA } = useAuth();
|
||||
const { t } = useLanguage();
|
||||
const { registrationMode, oauthEnabled, oauthDisplayName } = useBranding();
|
||||
const navigate = useNavigate();
|
||||
@@ -26,6 +33,13 @@ export default function Login() {
|
||||
return () => clearTimeout(timer);
|
||||
}, [resendCooldown]);
|
||||
|
||||
// Auto-focus TOTP input when 2FA screen appears
|
||||
useEffect(() => {
|
||||
if (needs2FA && totpInputRef.current) {
|
||||
totpInputRef.current.focus();
|
||||
}
|
||||
}, [needs2FA]);
|
||||
|
||||
const handleResend = async () => {
|
||||
if (resendCooldown > 0 || resending) return;
|
||||
setResending(true);
|
||||
@@ -48,7 +62,13 @@ export default function Login() {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(email, password);
|
||||
const result = await login(email, password);
|
||||
if (result?.requires2FA) {
|
||||
setTempToken(result.tempToken);
|
||||
setNeeds2FA(true);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
toast.success(t('auth.loginSuccess'));
|
||||
navigate('/dashboard');
|
||||
} catch (err) {
|
||||
@@ -62,6 +82,27 @@ export default function Login() {
|
||||
}
|
||||
};
|
||||
|
||||
const handle2FASubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setVerifying2FA(true);
|
||||
try {
|
||||
await verify2FA(tempToken, totpCode);
|
||||
toast.success(t('auth.loginSuccess'));
|
||||
navigate('/dashboard');
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('auth.2fa.verifyFailed'));
|
||||
setTotpCode('');
|
||||
} finally {
|
||||
setVerifying2FA(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setNeeds2FA(false);
|
||||
setTempToken('');
|
||||
setTotpCode('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-6 relative overflow-hidden">
|
||||
{/* Animated background */}
|
||||
@@ -81,111 +122,171 @@ export default function Login() {
|
||||
<BrandLogo size="lg" />
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-th-text mb-2">{t('auth.welcomeBack')}</h2>
|
||||
<p className="text-th-text-s">
|
||||
{t('auth.loginSubtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>
|
||||
<div className="relative">
|
||||
<Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
className="input-field pl-11"
|
||||
placeholder={t('auth.emailPlaceholder')}
|
||||
required
|
||||
/>
|
||||
{needs2FA ? (
|
||||
<>
|
||||
{/* 2FA verification step */}
|
||||
<div className="mb-8 text-center">
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-th-accent/10 mb-4">
|
||||
<ShieldCheck size={28} className="text-th-accent" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-th-text mb-2">{t('auth.2fa.title')}</h2>
|
||||
<p className="text-th-text-s text-sm">
|
||||
{t('auth.2fa.prompt')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.password')}</label>
|
||||
<div className="relative">
|
||||
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
className="input-field pl-11"
|
||||
placeholder={t('auth.passwordPlaceholder')}
|
||||
required
|
||||
/>
|
||||
<form onSubmit={handle2FASubmit} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.2fa.codeLabel')}</label>
|
||||
<div className="relative">
|
||||
<ShieldCheck size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
ref={totpInputRef}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
value={totpCode}
|
||||
onChange={e => setTotpCode(e.target.value.replace(/[^0-9\s]/g, '').slice(0, 7))}
|
||||
className="input-field pl-11 text-center text-lg tracking-[0.3em] font-mono"
|
||||
placeholder="000 000"
|
||||
required
|
||||
maxLength={7}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={verifying2FA || totpCode.replace(/\s/g, '').length < 6}
|
||||
className="btn-primary w-full py-3"
|
||||
>
|
||||
{verifying2FA ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
{t('auth.2fa.verify')}
|
||||
<ArrowRight size={18} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="block mt-4 w-full text-center text-sm text-th-text-s hover:text-th-text transition-colors"
|
||||
>
|
||||
{t('auth.2fa.backToLogin')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-th-text mb-2">{t('auth.welcomeBack')}</h2>
|
||||
<p className="text-th-text-s">
|
||||
{t('auth.loginSubtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full py-3"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>
|
||||
<div className="relative">
|
||||
<Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
className="input-field pl-11"
|
||||
placeholder={t('auth.emailPlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.password')}</label>
|
||||
<div className="relative">
|
||||
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
className="input-field pl-11"
|
||||
placeholder={t('auth.passwordPlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full py-3"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
{t('auth.login')}
|
||||
<ArrowRight size={18} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{oauthEnabled && (
|
||||
<>
|
||||
{t('auth.login')}
|
||||
<ArrowRight size={18} />
|
||||
<div className="relative my-6">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-th-border" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs">
|
||||
<span className="px-3 bg-th-card/80 text-th-text-s">{t('auth.orContinueWith')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="/api/oauth/authorize"
|
||||
className="btn-secondary w-full py-3 flex items-center justify-center gap-2"
|
||||
>
|
||||
<LogIn size={18} />
|
||||
{t('auth.loginWithOAuth').replace('{provider}', oauthDisplayName || 'SSO')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{oauthEnabled && (
|
||||
<>
|
||||
<div className="relative my-6">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-th-border" />
|
||||
{needsVerification && (
|
||||
<div className="mt-4 p-4 rounded-xl bg-amber-500/10 border border-amber-500/30 space-y-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle size={16} className="text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-amber-200">{t('auth.emailVerificationBanner')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleResend}
|
||||
disabled={resendCooldown > 0 || resending}
|
||||
className="flex items-center gap-1.5 text-sm text-amber-400 hover:text-amber-300 underline underline-offset-2 transition-colors disabled:opacity-60 disabled:no-underline disabled:cursor-not-allowed"
|
||||
>
|
||||
<RefreshCw size={13} className={resending ? 'animate-spin' : ''} />
|
||||
{resendCooldown > 0
|
||||
? t('auth.emailVerificationResendCooldown').replace('{seconds}', resendCooldown)
|
||||
: t('auth.emailVerificationResend')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs">
|
||||
<span className="px-3 bg-th-card/80 text-th-text-s">{t('auth.orContinueWith')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="/api/oauth/authorize"
|
||||
className="btn-secondary w-full py-3 flex items-center justify-center gap-2"
|
||||
>
|
||||
<LogIn size={18} />
|
||||
{t('auth.loginWithOAuth').replace('{provider}', oauthDisplayName || 'SSO')}
|
||||
</a>
|
||||
)}
|
||||
|
||||
{registrationMode !== 'invite' && (
|
||||
<p className="mt-6 text-center text-sm text-th-text-s">
|
||||
{t('auth.noAccount')}{' '}
|
||||
<Link to="/register" className="text-th-accent hover:underline font-medium">
|
||||
{t('auth.signUpNow')}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Link to="/" className="block mt-4 text-center text-sm text-th-text-s hover:text-th-text transition-colors">
|
||||
{t('auth.backToHome')}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
{needsVerification && (
|
||||
<div className="mt-4 p-4 rounded-xl bg-amber-500/10 border border-amber-500/30 space-y-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle size={16} className="text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-amber-200">{t('auth.emailVerificationBanner')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleResend}
|
||||
disabled={resendCooldown > 0 || resending}
|
||||
className="flex items-center gap-1.5 text-sm text-amber-400 hover:text-amber-300 underline underline-offset-2 transition-colors disabled:opacity-60 disabled:no-underline disabled:cursor-not-allowed"
|
||||
>
|
||||
<RefreshCw size={13} className={resending ? 'animate-spin' : ''} />
|
||||
{resendCooldown > 0
|
||||
? t('auth.emailVerificationResendCooldown').replace('{seconds}', resendCooldown)
|
||||
: t('auth.emailVerificationResend')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{registrationMode !== 'invite' && (
|
||||
<p className="mt-6 text-center text-sm text-th-text-s">
|
||||
{t('auth.noAccount')}{' '}
|
||||
<Link to="/register" className="text-th-accent hover:underline font-medium">
|
||||
{t('auth.signUpNow')}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Link to="/" className="block mt-4 text-center text-sm text-th-text-s hover:text-th-text transition-colors">
|
||||
{t('auth.backToHome')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { User, Mail, Lock, Palette, Save, Loader2, Globe, Camera, X, Calendar, Plus, Trash2, Copy, Eye, EyeOff } from 'lucide-react';
|
||||
import { User, Mail, Lock, Palette, Save, Loader2, Globe, Camera, X, Calendar, Plus, Trash2, Copy, Eye, EyeOff, Shield, ShieldCheck, ShieldOff } from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
@@ -46,6 +46,17 @@ export default function Settings() {
|
||||
const [newlyCreatedToken, setNewlyCreatedToken] = useState(null);
|
||||
const [tokenVisible, setTokenVisible] = useState(false);
|
||||
|
||||
// 2FA state
|
||||
const [twoFaEnabled, setTwoFaEnabled] = useState(!!user?.totp_enabled);
|
||||
const [twoFaLoading, setTwoFaLoading] = useState(false);
|
||||
const [twoFaSetupData, setTwoFaSetupData] = useState(null); // { secret, uri, qrDataUrl }
|
||||
const [twoFaCode, setTwoFaCode] = useState('');
|
||||
const [twoFaEnabling, setTwoFaEnabling] = useState(false);
|
||||
const [twoFaDisablePassword, setTwoFaDisablePassword] = useState('');
|
||||
const [twoFaDisableCode, setTwoFaDisableCode] = useState('');
|
||||
const [twoFaDisabling, setTwoFaDisabling] = useState(false);
|
||||
const [showDisableForm, setShowDisableForm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSection === 'caldav') {
|
||||
setCaldavLoading(true);
|
||||
@@ -54,6 +65,13 @@ export default function Settings() {
|
||||
.catch(() => {})
|
||||
.finally(() => setCaldavLoading(false));
|
||||
}
|
||||
if (activeSection === 'security') {
|
||||
setTwoFaLoading(true);
|
||||
api.get('/auth/2fa/status')
|
||||
.then(r => setTwoFaEnabled(r.data.enabled))
|
||||
.catch(() => {})
|
||||
.finally(() => setTwoFaLoading(false));
|
||||
}
|
||||
}, [activeSection]);
|
||||
|
||||
const handleCreateToken = async (e) => {
|
||||
@@ -85,6 +103,56 @@ export default function Settings() {
|
||||
}
|
||||
};
|
||||
|
||||
// 2FA handlers
|
||||
const handleSetup2FA = async () => {
|
||||
setTwoFaLoading(true);
|
||||
try {
|
||||
const res = await api.post('/auth/2fa/setup');
|
||||
// Generate QR code data URL client-side
|
||||
const QRCode = (await import('qrcode')).default;
|
||||
const qrDataUrl = await QRCode.toDataURL(res.data.uri, { width: 200, margin: 2, color: { dark: '#000000', light: '#ffffff' } });
|
||||
setTwoFaSetupData({ secret: res.data.secret, uri: res.data.uri, qrDataUrl });
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('settings.security.setupFailed'));
|
||||
} finally {
|
||||
setTwoFaLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnable2FA = async (e) => {
|
||||
e.preventDefault();
|
||||
setTwoFaEnabling(true);
|
||||
try {
|
||||
await api.post('/auth/2fa/enable', { code: twoFaCode });
|
||||
setTwoFaEnabled(true);
|
||||
setTwoFaSetupData(null);
|
||||
setTwoFaCode('');
|
||||
toast.success(t('settings.security.enabled'));
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('settings.security.enableFailed'));
|
||||
setTwoFaCode('');
|
||||
} finally {
|
||||
setTwoFaEnabling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisable2FA = async (e) => {
|
||||
e.preventDefault();
|
||||
setTwoFaDisabling(true);
|
||||
try {
|
||||
await api.post('/auth/2fa/disable', { password: twoFaDisablePassword, code: twoFaDisableCode });
|
||||
setTwoFaEnabled(false);
|
||||
setShowDisableForm(false);
|
||||
setTwoFaDisablePassword('');
|
||||
setTwoFaDisableCode('');
|
||||
toast.success(t('settings.security.disabled'));
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('settings.security.disableFailed'));
|
||||
} finally {
|
||||
setTwoFaDisabling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const groups = getThemeGroups();
|
||||
|
||||
const avatarColors = [
|
||||
@@ -184,6 +252,7 @@ export default function Settings() {
|
||||
const sections = [
|
||||
{ id: 'profile', label: t('settings.profile'), icon: User },
|
||||
{ id: 'password', label: t('settings.password'), icon: Lock },
|
||||
{ id: 'security', label: t('settings.security.title'), icon: Shield },
|
||||
{ id: 'language', label: t('settings.language'), icon: Globe },
|
||||
{ id: 'themes', label: t('settings.themes'), icon: Palette },
|
||||
{ id: 'caldav', label: t('settings.caldav.title'), icon: Calendar },
|
||||
@@ -411,6 +480,147 @@ export default function Settings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Security / 2FA section */}
|
||||
{activeSection === 'security' && (
|
||||
<div className="space-y-5">
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold text-th-text mb-1">{t('settings.security.title')}</h2>
|
||||
<p className="text-sm text-th-text-s mb-6">{t('settings.security.subtitle')}</p>
|
||||
|
||||
{twoFaLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 size={24} className="animate-spin text-th-text-s" />
|
||||
</div>
|
||||
) : twoFaEnabled ? (
|
||||
/* 2FA is enabled */
|
||||
<div>
|
||||
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/30 mb-5">
|
||||
<ShieldCheck size={22} className="text-emerald-400 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-emerald-300">{t('settings.security.statusEnabled')}</p>
|
||||
<p className="text-xs text-emerald-400/70">{t('settings.security.statusEnabledDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!showDisableForm ? (
|
||||
<button
|
||||
onClick={() => setShowDisableForm(true)}
|
||||
className="btn-ghost text-th-error hover:text-th-error text-sm"
|
||||
>
|
||||
<ShieldOff size={16} />
|
||||
{t('settings.security.disable')}
|
||||
</button>
|
||||
) : (
|
||||
<form onSubmit={handleDisable2FA} className="space-y-4 p-4 rounded-xl bg-th-bg-t border border-th-border">
|
||||
<p className="text-sm text-th-text-s">{t('settings.security.disableConfirm')}</p>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('settings.currentPassword')}</label>
|
||||
<div className="relative">
|
||||
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="password"
|
||||
value={twoFaDisablePassword}
|
||||
onChange={e => setTwoFaDisablePassword(e.target.value)}
|
||||
className="input-field pl-11"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('settings.security.codeLabel')}</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
value={twoFaDisableCode}
|
||||
onChange={e => setTwoFaDisableCode(e.target.value.replace(/[^0-9\s]/g, '').slice(0, 7))}
|
||||
className="input-field text-center text-lg tracking-[0.3em] font-mono"
|
||||
placeholder="000 000"
|
||||
required
|
||||
maxLength={7}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" disabled={twoFaDisabling} className="btn-primary bg-red-600 hover:bg-red-700 border-red-600">
|
||||
{twoFaDisabling ? <Loader2 size={14} className="animate-spin" /> : <ShieldOff size={14} />}
|
||||
{t('settings.security.disable')}
|
||||
</button>
|
||||
<button type="button" onClick={() => { setShowDisableForm(false); setTwoFaDisablePassword(''); setTwoFaDisableCode(''); }} className="btn-ghost text-sm">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
) : twoFaSetupData ? (
|
||||
/* Setup flow: show QR code + verification */
|
||||
<div className="space-y-5">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-th-text mb-4">{t('settings.security.scanQR')}</p>
|
||||
<div className="inline-block p-3 bg-white rounded-xl">
|
||||
<img src={twoFaSetupData.qrDataUrl} alt="TOTP QR Code" className="w-[200px] h-[200px]" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-th-text-s mb-1">{t('settings.security.manualKey')}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 text-xs bg-th-bg-t px-3 py-2 rounded-lg text-th-accent font-mono break-all">
|
||||
{twoFaSetupData.secret}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => { navigator.clipboard.writeText(twoFaSetupData.secret); toast.success(t('room.linkCopied')); }}
|
||||
className="btn-ghost py-1.5 px-2 flex-shrink-0"
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handleEnable2FA} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('settings.security.verifyCode')}</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
value={twoFaCode}
|
||||
onChange={e => setTwoFaCode(e.target.value.replace(/[^0-9\s]/g, '').slice(0, 7))}
|
||||
className="input-field text-center text-lg tracking-[0.3em] font-mono"
|
||||
placeholder="000 000"
|
||||
required
|
||||
maxLength={7}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" disabled={twoFaEnabling || twoFaCode.replace(/\s/g, '').length < 6} className="btn-primary">
|
||||
{twoFaEnabling ? <Loader2 size={14} className="animate-spin" /> : <ShieldCheck size={14} />}
|
||||
{t('settings.security.enable')}
|
||||
</button>
|
||||
<button type="button" onClick={() => { setTwoFaSetupData(null); setTwoFaCode(''); }} className="btn-ghost text-sm">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
/* 2FA is disabled — show enable button */
|
||||
<div>
|
||||
<div className="flex items-center gap-3 p-4 rounded-xl bg-th-bg-t border border-th-border mb-5">
|
||||
<ShieldOff size={22} className="text-th-text-s flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-th-text">{t('settings.security.statusDisabled')}</p>
|
||||
<p className="text-xs text-th-text-s">{t('settings.security.statusDisabledDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={handleSetup2FA} disabled={twoFaLoading} className="btn-primary">
|
||||
{twoFaLoading ? <Loader2 size={16} className="animate-spin" /> : <Shield size={16} />}
|
||||
{t('settings.security.enable')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Language section */}
|
||||
{activeSection === 'language' && (
|
||||
<div className="card p-6">
|
||||
|
||||
Reference in New Issue
Block a user