+
{plugin.name}
by (e.currentTarget.style.textDecoration = 'underline')} onMouseLeave={e => (e.currentTarget.style.textDecoration = 'none')}>{plugin.dev_name}
diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx
index 711f92b..06eef91 100644
--- a/src/pages/ProfilePage.tsx
+++ b/src/pages/ProfilePage.tsx
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import { useNavigate, useLocation, Link } from 'react-router-dom';
+import { QRCodeSVG } from 'qrcode.react';
import api from '../api';
import { useAuth } from '../App';
@@ -30,7 +31,7 @@ const ProfilePage = () => {
else setActiveTab('accountDetails');
}, [location]);
- const [formData, setFormData] = useState({ username: '', email: '', password: '' });
+ const [formData, setFormData] = useState({ username: '', email: '', password: '', currentPassword: '', disable2faPassword: '' });
const [message, setMessage] = useState({ text: '', type: '' });
const [isStripeLoading, setIsStripeLoading] = useState(false);
@@ -39,11 +40,17 @@ const ProfilePage = () => {
const [libraryLoading, setLibraryLoading] = useState(false);
const [libraryError, setLibraryError] = useState
(null);
+ // 2FA state
+ const [is2faEnabled, setIs2faEnabled] = useState(false);
+ const [setup2faData, setSetup2faData] = useState<{ uri: string, secret: string } | null>(null);
+ const [verify2faCode, setVerify2faCode] = useState('');
+
useEffect(() => { refreshUser?.(); }, []);
useEffect(() => {
if (user) {
setFormData(prev => ({ ...prev, username: user.username || '', email: user.email || '' }));
+ setIs2faEnabled(user.totp_enabled || false);
}
}, [user]);
@@ -109,6 +116,46 @@ const ProfilePage = () => {
}
};
+ const start2faSetup = async () => {
+ try {
+ const res = await api.post('/user/2fa/setup');
+ setSetup2faData(res.data);
+ } catch (err: any) {
+ showMessage(err.response?.data?.error || 'Failed to start 2FA setup.', true);
+ if (err.response?.status === 400 && err.response?.data?.error === '2FA is already enabled') {
+ setIs2faEnabled(true);
+ refreshUser?.();
+ }
+ }
+ };
+
+ const confirm2faSetup = async (e: React.FormEvent) => {
+ e.preventDefault();
+ try {
+ await api.post('/user/2fa/verify', { code: verify2faCode });
+ setIs2faEnabled(true);
+ setSetup2faData(null);
+ setVerify2faCode('');
+ showMessage('2FA enabled successfully!');
+ refreshUser?.();
+ } catch (err: any) {
+ showMessage(err.response?.data?.error || 'Invalid 2FA code.', true);
+ }
+ };
+
+ const disable2fa = async (e: React.FormEvent) => {
+ e.preventDefault();
+ try {
+ await api.post('/user/2fa/disable', { password: formData.disable2faPassword });
+ setIs2faEnabled(false);
+ setFormData({ ...formData, disable2faPassword: '' });
+ showMessage('2FA has been disabled.');
+ refreshUser?.();
+ } catch (err: any) {
+ showMessage(err.response?.data?.error || 'Failed to disable 2FA.', true);
+ }
+ };
+
return (
User Profile
@@ -328,11 +375,54 @@ const ProfilePage = () => {
{/* ── Security ── */}
{activeTab === 'security' && (
+
Two-Factor Authentication
+
+ {is2faEnabled ? (
+
+
✓ Two-Factor Authentication is currently enabled.
+
+
+ ) : (
+
+
Protect your account with Two-Factor Authentication using an app like Google Authenticator or Authy.
+ {!setup2faData ? (
+
+ ) : (
+
+
1. Scan this QR code with your authenticator app:
+
+
+
+
Or enter this code manually: {setup2faData.secret}
+
+
+
+ )}
+
+ )}
+
+
Change Password
-
diff --git a/src/pages/auth/ConfirmEmailChangePage.tsx b/src/pages/auth/ConfirmEmailChangePage.tsx
new file mode 100644
index 0000000..874ed83
--- /dev/null
+++ b/src/pages/auth/ConfirmEmailChangePage.tsx
@@ -0,0 +1,66 @@
+import { useEffect, useState } from 'react';
+import { useLocation, useNavigate } from 'react-router-dom';
+import api from '../../api';
+import { useAuth } from '../../App';
+
+const ConfirmEmailChangePage = () => {
+ const [status, setStatus] = useState<'verifying' | 'success' | 'error'>('verifying');
+ const [error, setError] = useState
(null);
+ const location = useLocation();
+ const navigate = useNavigate();
+ const { login } = useAuth();
+
+ useEffect(() => {
+ const verify = async () => {
+ const params = new URLSearchParams(location.search);
+ const token = params.get('token');
+
+ if (!token) {
+ setStatus('error');
+ setError('No verification token found.');
+ return;
+ }
+
+ try {
+ const res = await api.post('/user/confirm-email-change', { token });
+
+ if (res.data.token) {
+ login(res.data.token);
+ }
+
+ setStatus('success');
+
+ setTimeout(() => {
+ navigate('/profile');
+ }, 3000);
+
+ } catch (err: any) {
+ setStatus('error');
+ setError(err.response?.data?.error || 'Verification failed. The link may be invalid or expired.');
+ }
+ };
+
+ verify();
+ }, [location, navigate, login]);
+
+ return (
+
+ {status === 'verifying' &&
Verifying your email change...
}
+ {status === 'success' && (
+
+
Email Updated Successfully!
+
Your new email is now active. Redirecting to your profile...
+
+ )}
+ {status === 'error' && (
+
+
Update Failed
+
{error}
+
Please try updating your email again from your profile page.
+
+ )}
+
+ );
+};
+
+export default ConfirmEmailChangePage;
diff --git a/src/pages/auth/LoginPage.tsx b/src/pages/auth/LoginPage.tsx
index 8b05527..ec6feeb 100644
--- a/src/pages/auth/LoginPage.tsx
+++ b/src/pages/auth/LoginPage.tsx
@@ -1,6 +1,7 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
+import { Turnstile } from '@marsidev/react-turnstile';
import api from '../../api';
import { useAuth } from '../../App';
@@ -11,6 +12,13 @@ const LoginPage = () => {
const [isSubmitting, setIsSubmitting] = useState(false);
const [emailTouched, setEmailTouched] = useState(false);
const [emailValid, setEmailValid] = useState(null);
+ const [captchaToken, setCaptchaToken] = useState(null);
+
+ // 2FA login state
+ const [requires2fa, setRequires2fa] = useState(false);
+ const [tempToken, setTempToken] = useState(null);
+ const [totpCode, setTotpCode] = useState('');
+
const navigate = useNavigate();
const { login } = useAuth();
@@ -34,6 +42,24 @@ const LoginPage = () => {
e.preventDefault();
if (form.website) return;
+ if (requires2fa) {
+ setIsSubmitting(true);
+ setError('');
+ try {
+ const res = await api.post('/auth/login/2fa', {
+ temp_token: tempToken,
+ code: totpCode,
+ });
+ login(res.data.token);
+ navigate('/dashboard');
+ } catch (err: any) {
+ setError(err.response?.data?.error || 'Invalid 2FA code.');
+ } finally {
+ setIsSubmitting(false);
+ }
+ return;
+ }
+
setEmailTouched(true);
const isEmailValid = validateEmail(form.email);
setEmailValid(isEmailValid);
@@ -43,15 +69,27 @@ const LoginPage = () => {
return;
}
+ if (!captchaToken) {
+ setError('Please complete the security check.');
+ return;
+ }
+
setIsSubmitting(true);
setError('');
try {
const res = await api.post('/auth/login', {
email: form.email,
password: form.password,
+ captchaToken,
});
- login(res.data.token);
- navigate('/dashboard');
+
+ if (res.data.requires_2fa) {
+ setRequires2fa(true);
+ setTempToken(res.data.temp_token);
+ } else {
+ login(res.data.token);
+ navigate('/dashboard');
+ }
} catch (err: any) {
setError(err.response?.data?.error || 'Invalid credentials.');
} finally {
@@ -313,68 +351,101 @@ const LoginPage = () => {
setForm({ ...form, website: e.target.value })} />
-
-
-
-
- {emailIsInvalid && (
-
- )}
- {emailIsValid && (
-
- )}
+ {requires2fa ? (
+
+
+
Enter the 6-digit code from your authenticator app.
+
+ setTotpCode(e.target.value.replace(/\D/g, ''))}
+ required
+ disabled={isSubmitting}
+ autoFocus
+ style={{ letterSpacing: '0.2em', textAlign: 'center', fontSize: '1.2rem', fontFamily: 'var(--font-mono, monospace)' }}
+ />
+
- {emailIsInvalid && (
-
-
- Please enter a valid email address
-
- )}
-
+ ) : (
+ <>
+
+
+
+
+ {emailIsInvalid && (
+
+ )}
+ {emailIsValid && (
+
+ )}
+
+ {emailIsInvalid && (
+
+
+ Please enter a valid email address
+
+ )}
+
-
-
-
-
- Forgot password?
-
+
+
+
+
+ Forgot password?
+
+
+
+ setForm({ ...form, password: e.target.value })}
+ required
+ disabled={isSubmitting}
+ autoComplete="current-password"
+ />
+
+
+ >
+ )}
+
+ {!requires2fa && (
+
+ setCaptchaToken(token)} options={{ theme: 'light', appearance: 'interaction-only' } as any} />
-
- setForm({ ...form, password: e.target.value })}
- required
- disabled={isSubmitting}
- autoComplete="current-password"
- />
-
-
+ )}