Skip to content

Commit 1de42e6

Browse files
committed
feat: add 2fa
1 parent f0700bd commit 1de42e6

9 files changed

Lines changed: 585 additions & 291 deletions

File tree

package-lock.json

Lines changed: 247 additions & 211 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,39 +11,40 @@
1111
"preview": "vite preview"
1212
},
1313
"dependencies": {
14-
"@marsidev/react-turnstile": "^1.5.1",
14+
"@marsidev/react-turnstile": "^1.5.2",
1515
"@vnedyalk0v/react19-simple-maps": "^2.0.7",
16-
"axios": "^1.15.2",
16+
"axios": "^1.16.1",
1717
"d3-geo": "^3.1.1",
1818
"d3-scale": "^4.0.2",
19-
"i18next": "^26.0.8",
19+
"i18next": "^26.1.0",
2020
"i18next-browser-languagedetector": "^8.2.1",
21-
"lucide-react": "^1.14.0",
21+
"lucide-react": "^1.16.0",
2222
"or": "^0.2.0",
23-
"react": "^19.2.5",
24-
"react-dom": "^19.2.5",
25-
"react-i18next": "^17.0.6",
26-
"react-is": "^19.2.5",
23+
"qrcode.react": "^4.2.0",
24+
"react": "^19.2.6",
25+
"react-dom": "^19.2.6",
26+
"react-i18next": "^17.0.7",
27+
"react-is": "^19.2.6",
2728
"react-markdown": "^10.1.0",
28-
"react-router-dom": "^7.14.2",
29+
"react-router-dom": "^7.15.1",
2930
"recharts": "^3.8.1",
3031
"remark-gfm": "^4.0.1"
3132
},
3233
"devDependencies": {
3334
"@eslint/js": "^10.0.1",
34-
"@types/node": "^25.6.0",
35+
"@types/node": "^25.8.0",
3536
"@types/react": "^19.2.14",
3637
"@types/react-dom": "^19.2.3",
3738
"@vitejs/plugin-react": "^6.0.1",
38-
"eslint": "^10.2.1",
39+
"eslint": "^10.3.0",
3940
"eslint-plugin-react-hooks": "^7.1.1",
4041
"eslint-plugin-react-refresh": "^0.5.2",
41-
"globals": "^17.5.0",
42+
"globals": "^17.6.0",
4243
"typescript": "^6.0.3",
43-
"typescript-eslint": "^8.59.1",
44-
"vite": "^8.0.10"
44+
"typescript-eslint": "^8.59.3",
45+
"vite": "^8.0.13"
4546
},
4647
"overrides": {
47-
"vite": "^8.0.10"
48+
"vite": "^8.0.13"
4849
}
4950
}

src/App.css

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,6 @@ h3 {
381381
gap: 1.5rem;
382382
width: 100%;
383383
max-width: 100%;
384-
overflow: hidden;
385384
}
386385

387386
.plugin-sidebar {
@@ -406,11 +405,13 @@ h3 {
406405

407406
.plugin-title {
408407
font-size: 2.25rem;
408+
word-break: break-word;
409+
overflow-wrap: break-word;
409410
}
410411

411412
.plugin-detail-header {
412413
flex-direction: row;
413-
align-items: center;
414+
align-items: flex-start;
414415
gap: 1.25rem;
415416
margin-bottom: 2rem;
416417
}
@@ -1458,6 +1459,11 @@ h3 {
14581459
margin-bottom: 3rem;
14591460
}
14601461

1462+
.plugin-header-info {
1463+
flex: 1;
1464+
min-width: 0;
1465+
}
1466+
14611467
.plugin-detail-layout {
14621468
display: grid;
14631469
grid-template-columns: 1fr 400px;
@@ -1468,6 +1474,8 @@ h3 {
14681474
.plugin-title {
14691475
font-size: 3.5rem;
14701476
text-transform: uppercase;
1477+
overflow-wrap: break-word;
1478+
word-break: break-word;
14711479
}
14721480

14731481
.plugin-description {
@@ -1521,6 +1529,26 @@ h3 {
15211529
border: 1px solid var(--border);
15221530
}
15231531

1532+
.markdown-content table {
1533+
width: 100%;
1534+
border-collapse: collapse;
1535+
margin-bottom: 1.5rem;
1536+
display: block;
1537+
overflow-x: auto;
1538+
}
1539+
1540+
.markdown-content th,
1541+
.markdown-content td {
1542+
padding: 0.75rem 1rem;
1543+
border: 1px solid var(--border);
1544+
}
1545+
1546+
.markdown-content th {
1547+
background: rgba(255, 255, 255, 0.05);
1548+
font-weight: 700;
1549+
text-align: left;
1550+
}
1551+
15241552
.markdown-content pre code {
15251553
background: none;
15261554
padding: 0;

src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import RegisterPage from './pages/auth/RegisterPage';
88
import ForgotPasswordPage from './pages/auth/ForgotPasswordPage';
99
import ResetPasswordPage from './pages/auth/ResetPasswordPage';
1010
import VerifyEmailPage from './pages/auth/VerifyEmailPage';
11+
import ConfirmEmailChangePage from './pages/auth/ConfirmEmailChangePage';
1112
import CheckEmailPage from './pages/auth/CheckEmailPage';
1213
import ProfilePage from './pages/ProfilePage';
1314
import AuthorProfilePage from './pages/AuthorProfilePage';
@@ -125,6 +126,7 @@ const App = () => (
125126
<Route path="forgot-password" element={<ForgotPasswordPage />} />
126127
<Route path="reset-password" element={<ResetPasswordPage />} />
127128
<Route path="verify-email" element={<VerifyEmailPage />} />
129+
<Route path="confirm-email" element={<ConfirmEmailChangePage />} />
128130
<Route path="check-email" element={<CheckEmailPage />} />
129131
<Route path="dashboard" element={<ProtectedRoute><DashboardLayout /></ProtectedRoute>}>
130132
<Route index element={<DashboardOverview />} />

src/pages/PluginDetail.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -494,7 +494,7 @@ const PluginDetail = () => {
494494
>
495495
{!plugin.preview_path && plugin.name.charAt(0)}
496496
</div>
497-
<div>
497+
<div className="plugin-header-info">
498498
<h1 className="plugin-title">{plugin.name}</h1>
499499
<p className="dev-name">by <Link to={`/profile/${plugin.dev_name}`} style={{ color: 'inherit', textDecoration: 'none' }} onMouseEnter={e => (e.currentTarget.style.textDecoration = 'underline')} onMouseLeave={e => (e.currentTarget.style.textDecoration = 'none')}>{plugin.dev_name}</Link></p>
500500
</div>

src/pages/ProfilePage.tsx

Lines changed: 93 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React, { useState, useEffect } from 'react';
22
import { useNavigate, useLocation, Link } from 'react-router-dom';
3+
import { QRCodeSVG } from 'qrcode.react';
34
import api from '../api';
45
import { useAuth } from '../App';
56

@@ -30,7 +31,7 @@ const ProfilePage = () => {
3031
else setActiveTab('accountDetails');
3132
}, [location]);
3233

33-
const [formData, setFormData] = useState({ username: '', email: '', password: '' });
34+
const [formData, setFormData] = useState({ username: '', email: '', password: '', currentPassword: '', disable2faPassword: '' });
3435
const [message, setMessage] = useState({ text: '', type: '' });
3536
const [isStripeLoading, setIsStripeLoading] = useState(false);
3637

@@ -39,11 +40,17 @@ const ProfilePage = () => {
3940
const [libraryLoading, setLibraryLoading] = useState(false);
4041
const [libraryError, setLibraryError] = useState<string | null>(null);
4142

43+
// 2FA state
44+
const [is2faEnabled, setIs2faEnabled] = useState(false);
45+
const [setup2faData, setSetup2faData] = useState<{ uri: string, secret: string } | null>(null);
46+
const [verify2faCode, setVerify2faCode] = useState('');
47+
4248
useEffect(() => { refreshUser?.(); }, []);
4349

4450
useEffect(() => {
4551
if (user) {
4652
setFormData(prev => ({ ...prev, username: user.username || '', email: user.email || '' }));
53+
setIs2faEnabled(user.totp_enabled || false);
4754
}
4855
}, [user]);
4956

@@ -109,6 +116,46 @@ const ProfilePage = () => {
109116
}
110117
};
111118

119+
const start2faSetup = async () => {
120+
try {
121+
const res = await api.post('/user/2fa/setup');
122+
setSetup2faData(res.data);
123+
} catch (err: any) {
124+
showMessage(err.response?.data?.error || 'Failed to start 2FA setup.', true);
125+
if (err.response?.status === 400 && err.response?.data?.error === '2FA is already enabled') {
126+
setIs2faEnabled(true);
127+
refreshUser?.();
128+
}
129+
}
130+
};
131+
132+
const confirm2faSetup = async (e: React.FormEvent) => {
133+
e.preventDefault();
134+
try {
135+
await api.post('/user/2fa/verify', { code: verify2faCode });
136+
setIs2faEnabled(true);
137+
setSetup2faData(null);
138+
setVerify2faCode('');
139+
showMessage('2FA enabled successfully!');
140+
refreshUser?.();
141+
} catch (err: any) {
142+
showMessage(err.response?.data?.error || 'Invalid 2FA code.', true);
143+
}
144+
};
145+
146+
const disable2fa = async (e: React.FormEvent) => {
147+
e.preventDefault();
148+
try {
149+
await api.post('/user/2fa/disable', { password: formData.disable2faPassword });
150+
setIs2faEnabled(false);
151+
setFormData({ ...formData, disable2faPassword: '' });
152+
showMessage('2FA has been disabled.');
153+
refreshUser?.();
154+
} catch (err: any) {
155+
showMessage(err.response?.data?.error || 'Failed to disable 2FA.', true);
156+
}
157+
};
158+
112159
return (
113160
<div className="container profile-page">
114161
<h1 className="page-title">User <span>Profile</span></h1>
@@ -328,11 +375,54 @@ const ProfilePage = () => {
328375
{/* ── Security ── */}
329376
{activeTab === 'security' && (
330377
<div className="profile-section">
378+
<h2 className="section-title"><span>Two-Factor</span> Authentication</h2>
379+
<div style={{ background: 'var(--surface-light)', padding: '1.5rem', borderRadius: '12px', border: '1px solid var(--border)', marginBottom: '3rem' }}>
380+
{is2faEnabled ? (
381+
<div>
382+
<p style={{ color: 'var(--success)', fontWeight: 600, marginBottom: '1rem' }}>✓ Two-Factor Authentication is currently enabled.</p>
383+
<form onSubmit={disable2fa} style={{ marginTop: '1rem' }}>
384+
<p style={{ fontSize: '0.85rem', color: 'var(--text-muted)', marginBottom: '1rem' }}>Enter your password to disable 2FA.</p>
385+
<div className="form-group">
386+
<input type="password" placeholder="Current Password" value={formData.disable2faPassword} onChange={e => setFormData({ ...formData, disable2faPassword: e.target.value })} required />
387+
</div>
388+
<button type="submit" className="btn btn-secondary" style={{ color: '#ff4d4d', borderColor: '#ff4d4d' }}>Disable 2FA</button>
389+
</form>
390+
</div>
391+
) : (
392+
<div>
393+
<p style={{ marginBottom: '1rem', color: 'var(--text-muted)' }}>Protect your account with Two-Factor Authentication using an app like Google Authenticator or Authy.</p>
394+
{!setup2faData ? (
395+
<button className="btn" onClick={start2faSetup}>Set Up 2FA</button>
396+
) : (
397+
<div style={{ marginTop: '1rem' }}>
398+
<p style={{ fontSize: '0.9rem', marginBottom: '1rem' }}>1. Scan this QR code with your authenticator app:</p>
399+
<div style={{ background: 'white', padding: '1rem', display: 'inline-block', borderRadius: '8px', marginBottom: '1rem' }}>
400+
<QRCodeSVG value={setup2faData.uri} size={150} />
401+
</div>
402+
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)', marginBottom: '1.5rem' }}>Or enter this code manually: <strong style={{ fontFamily: 'var(--font-mono)' }}>{setup2faData.secret}</strong></p>
403+
404+
<form onSubmit={confirm2faSetup}>
405+
<p style={{ fontSize: '0.9rem', marginBottom: '0.5rem' }}>2. Enter the 6-digit code from your app:</p>
406+
<div className="form-group" style={{ display: 'flex', gap: '0.5rem', maxWidth: '300px' }}>
407+
<input type="text" placeholder="000000" maxLength={6} value={verify2faCode} onChange={e => setVerify2faCode(e.target.value.replace(/\D/g, ''))} required style={{ fontFamily: 'var(--font-mono)', fontSize: '1.2rem', letterSpacing: '0.2em', textAlign: 'center' }} />
408+
<button type="submit" className="btn">Verify</button>
409+
</div>
410+
</form>
411+
</div>
412+
)}
413+
</div>
414+
)}
415+
</div>
416+
331417
<h2 className="section-title"><span>Change</span> Password</h2>
332-
<form onSubmit={(e) => handleUpdate(e, '/user/settings', { newPassword: formData.password })}>
418+
<form onSubmit={(e) => handleUpdate(e, '/user/settings', { currentPassword: formData.currentPassword, newPassword: formData.password })}>
419+
<div className="form-group">
420+
<label>CURRENT PASSWORD</label>
421+
<input type="password" value={formData.currentPassword} onChange={e => setFormData({ ...formData, currentPassword: e.target.value })} required />
422+
</div>
333423
<div className="form-group">
334424
<label>NEW PASSWORD</label>
335-
<input type="password" value={formData.password} onChange={e => setFormData({ ...formData, password: e.target.value })} />
425+
<input type="password" value={formData.password} onChange={e => setFormData({ ...formData, password: e.target.value })} required />
336426
</div>
337427
<button type="submit" className="btn">Update Password</button>
338428
</form>
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { useEffect, useState } from 'react';
2+
import { useLocation, useNavigate } from 'react-router-dom';
3+
import api from '../../api';
4+
import { useAuth } from '../../App';
5+
6+
const ConfirmEmailChangePage = () => {
7+
const [status, setStatus] = useState<'verifying' | 'success' | 'error'>('verifying');
8+
const [error, setError] = useState<string | null>(null);
9+
const location = useLocation();
10+
const navigate = useNavigate();
11+
const { login } = useAuth();
12+
13+
useEffect(() => {
14+
const verify = async () => {
15+
const params = new URLSearchParams(location.search);
16+
const token = params.get('token');
17+
18+
if (!token) {
19+
setStatus('error');
20+
setError('No verification token found.');
21+
return;
22+
}
23+
24+
try {
25+
const res = await api.post('/user/confirm-email-change', { token });
26+
27+
if (res.data.token) {
28+
login(res.data.token);
29+
}
30+
31+
setStatus('success');
32+
33+
setTimeout(() => {
34+
navigate('/profile');
35+
}, 3000);
36+
37+
} catch (err: any) {
38+
setStatus('error');
39+
setError(err.response?.data?.error || 'Verification failed. The link may be invalid or expired.');
40+
}
41+
};
42+
43+
verify();
44+
}, [location, navigate, login]);
45+
46+
return (
47+
<div style={{ textAlign: 'center', padding: '50px' }}>
48+
{status === 'verifying' && <h1>Verifying your email change...</h1>}
49+
{status === 'success' && (
50+
<div>
51+
<h1>Email Updated Successfully!</h1>
52+
<p>Your new email is now active. Redirecting to your profile...</p>
53+
</div>
54+
)}
55+
{status === 'error' && (
56+
<div>
57+
<h1>Update Failed</h1>
58+
<p>{error}</p>
59+
<p>Please try updating your email again from your profile page.</p>
60+
</div>
61+
)}
62+
</div>
63+
);
64+
};
65+
66+
export default ConfirmEmailChangePage;

0 commit comments

Comments
 (0)