feat: add passkeys

This commit is contained in:
Alexander Medvedev 2026-08-29 14:23:23 +02:00
parent 632ba89301
commit 31c198bafd
16 changed files with 2271 additions and 243 deletions

View File

@ -9,7 +9,8 @@
<meta property="og:title" content="Pumpkin Market" /> <meta property="og:title" content="Pumpkin Market" />
<meta property="og:description" content="Discover, download, and publish high-performance WebAssembly plugins for PumpkinMC Minecraft servers." /> <meta property="og:description" content="Discover, download, and publish high-performance WebAssembly plugins for PumpkinMC Minecraft servers." />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:site_name" content="Pumpkin Market" /> <meta name="referrer" content="strict-origin-when-cross-origin" />
<meta http-equiv="X-Content-Type-Options" content="nosniff" />
<link rel="search" type="application/opensearchdescription+xml" title="Pumpkin Market" href="/opensearch.xml" /> <link rel="search" type="application/opensearchdescription+xml" title="Pumpkin Market" href="/opensearch.xml" />
<title>Pumpkin Market</title> <title>Pumpkin Market</title>
<!-- Privacy-friendly Google Consent Mode v2 Default --> <!-- Privacy-friendly Google Consent Mode v2 Default -->

39
package-lock.json generated
View File

@ -13,6 +13,7 @@
"@fontsource/jetbrains-mono": "^5.3.0", "@fontsource/jetbrains-mono": "^5.3.0",
"@fontsource/nunito": "^5.3.0", "@fontsource/nunito": "^5.3.0",
"@marsidev/react-turnstile": "^1.6.1", "@marsidev/react-turnstile": "^1.6.1",
"@simplewebauthn/browser": "^13.3.0",
"@vnedyalk0v/react19-simple-maps": "^2.0.10", "@vnedyalk0v/react19-simple-maps": "^2.0.10",
"axios": "^1.20.0", "axios": "^1.20.0",
"country-list": "^2.4.1", "country-list": "^2.4.1",
@ -20,7 +21,7 @@
"d3-scale": "^4.0.2", "d3-scale": "^4.0.2",
"i18next": "^26.4.0", "i18next": "^26.4.0",
"i18next-browser-languagedetector": "^8.2.1", "i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.34.0", "lucide-react": "^1.37.0",
"or": "^0.2.0", "or": "^0.2.0",
"qrcode.react": "^4.2.0", "qrcode.react": "^4.2.0",
"react": "^19.2.8", "react": "^19.2.8",
@ -28,7 +29,7 @@
"react-i18next": "^17.0.12", "react-i18next": "^17.0.12",
"react-is": "^19.2.8", "react-is": "^19.2.8",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "^7.18.2", "react-router-dom": "^7.18.3",
"recharts": "^3.10.1", "recharts": "^3.10.1",
"remark-gfm": "^4.0.1" "remark-gfm": "^4.0.1"
}, },
@ -38,7 +39,7 @@
"@types/node": "^26.4.0", "@types/node": "^26.4.0",
"@types/react": "^19.2.18", "@types/react": "^19.2.18",
"@types/react-dom": "^19.2.5", "@types/react-dom": "^19.2.5",
"@vitejs/plugin-react": "^6.1.0", "@vitejs/plugin-react": "^6.1.1",
"eslint": "^10.9.1", "eslint": "^10.9.1",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.5", "eslint-plugin-react-refresh": "^0.5.5",
@ -885,6 +886,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@simplewebauthn/browser": {
"version": "13.3.0",
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz",
"integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": { "node_modules/@standard-schema/spec": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@ -1661,9 +1668,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/@vitejs/plugin-react": { "node_modules/@vitejs/plugin-react": {
"version": "6.1.0", "version": "6.1.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.0.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz",
"integrity": "sha512-qd2BzUBehkov86WFhg0JkEFEYyCLG9uPCe6qWTY/kRlss9OvJrOF2UbIWT7p+8IzZHkEu0DNGHc4HSv+JdDLsw==", "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -3527,9 +3534,9 @@
} }
}, },
"node_modules/lucide-react": { "node_modules/lucide-react": {
"version": "1.34.0", "version": "1.37.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.34.0.tgz", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.37.0.tgz",
"integrity": "sha512-vnjGJNI7Htk5+oWW8gXGuaLgwgAb0T6/iZbBrp9JCfRFwdNWZ0YTm3eyxjOLgwN6r8iyAf3UA70zNmBRBNv7yg==", "integrity": "sha512-LPsB4rD1TD6wZu1djKOf9vUnS1jTNaHbolXebXDgiTdb6jeA1agIJhJsIybCmjKmQClcOaal1o1OaiYahEftyQ==",
"license": "ISC", "license": "ISC",
"peerDependencies": { "peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@ -4783,9 +4790,9 @@
} }
}, },
"node_modules/react-router": { "node_modules/react-router": {
"version": "7.18.2", "version": "7.18.3",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz",
"integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", "integrity": "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cookie": "^1.0.1", "cookie": "^1.0.1",
@ -4805,12 +4812,12 @@
} }
}, },
"node_modules/react-router-dom": { "node_modules/react-router-dom": {
"version": "7.18.2", "version": "7.18.3",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.3.tgz",
"integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", "integrity": "sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"react-router": "7.18.2" "react-router": "7.18.3"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"

View File

@ -19,6 +19,7 @@
"@fontsource/jetbrains-mono": "^5.3.0", "@fontsource/jetbrains-mono": "^5.3.0",
"@fontsource/nunito": "^5.3.0", "@fontsource/nunito": "^5.3.0",
"@marsidev/react-turnstile": "^1.6.1", "@marsidev/react-turnstile": "^1.6.1",
"@simplewebauthn/browser": "^13.3.0",
"@vnedyalk0v/react19-simple-maps": "^2.0.10", "@vnedyalk0v/react19-simple-maps": "^2.0.10",
"axios": "^1.20.0", "axios": "^1.20.0",
"country-list": "^2.4.1", "country-list": "^2.4.1",
@ -26,7 +27,7 @@
"d3-scale": "^4.0.2", "d3-scale": "^4.0.2",
"i18next": "^26.4.0", "i18next": "^26.4.0",
"i18next-browser-languagedetector": "^8.2.1", "i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.34.0", "lucide-react": "^1.37.0",
"or": "^0.2.0", "or": "^0.2.0",
"qrcode.react": "^4.2.0", "qrcode.react": "^4.2.0",
"react": "^19.2.8", "react": "^19.2.8",
@ -34,7 +35,7 @@
"react-i18next": "^17.0.12", "react-i18next": "^17.0.12",
"react-is": "^19.2.8", "react-is": "^19.2.8",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "^7.18.2", "react-router-dom": "^7.18.3",
"recharts": "^3.10.1", "recharts": "^3.10.1",
"remark-gfm": "^4.0.1" "remark-gfm": "^4.0.1"
}, },
@ -44,7 +45,7 @@
"@types/node": "^26.4.0", "@types/node": "^26.4.0",
"@types/react": "^19.2.18", "@types/react": "^19.2.18",
"@types/react-dom": "^19.2.5", "@types/react-dom": "^19.2.5",
"@vitejs/plugin-react": "^6.1.0", "@vitejs/plugin-react": "^6.1.1",
"eslint": "^10.9.1", "eslint": "^10.9.1",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.5", "eslint-plugin-react-refresh": "^0.5.5",

View File

@ -63,34 +63,56 @@ const AuthProvider = ({ children }: { children: React.ReactNode }) => {
} }
}; };
const { i18n } = useTranslation();
useEffect(() => { useEffect(() => {
const token = localStorage.getItem('token'); const initAuth = async () => {
if (token) { try {
const payload = decodeToken(token); const res = await api.get('/user/me');
if (res.data?.token) {
const payload = decodeToken(res.data.token);
if (payload && payload.exp * 1000 > Date.now()) { if (payload && payload.exp * 1000 > Date.now()) {
setUser(payload); setUser(payload);
} else { if (payload.preferred_language && i18n.language !== payload.preferred_language) {
i18n.changeLanguage(payload.preferred_language);
}
}
}
} catch (e) {
setUser(null);
} finally {
// Clean up legacy localStorage token if present
localStorage.removeItem('token'); localStorage.removeItem('token');
}
}
setLoading(false); setLoading(false);
}, []); }
};
initAuth();
}, [i18n]);
const login = (token: string) => { const login = (token: string) => {
localStorage.setItem('token', token); // Decode token for client-side state without persisting in localStorage
const payload = decodeToken(token); const payload = decodeToken(token);
setUser(payload); setUser(payload);
if (payload?.preferred_language && i18n.language !== payload.preferred_language) {
i18n.changeLanguage(payload.preferred_language);
}
}; };
const logout = () => { const logout = async () => {
try {
await api.post('/auth/logout');
} catch (e) {
console.error('Failed to logout cleanly on server', e);
} finally {
localStorage.removeItem('token'); localStorage.removeItem('token');
setUser(null); setUser(null);
}
}; };
const refreshUser = async () => { const refreshUser = async () => {
try { try {
const res = await api.get('/user/me'); const res = await api.get('/user/me');
if (res.data.token) { if (res.data?.token) {
login(res.data.token); login(res.data.token);
} }
} catch (e) { } catch (e) {
@ -325,6 +347,63 @@ const Navbar = ({ user }: any) => {
); );
}; };
const LanguageSelector = () => {
const { i18n } = useTranslation();
const { user, login } = useAuth();
const handleLanguageChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
const newLang = e.target.value;
i18n.changeLanguage(newLang);
if (user) {
try {
const res = await api.post('/user/change-language', { newLanguage: newLang });
if (res.data.token) {
login(res.data.token);
}
} catch (err) {
console.error('Failed to update language on server', err);
}
}
};
const currentLang = i18n.language ? i18n.language.split('-')[0] : 'en';
return (
<select
aria-label="Language selection"
value={currentLang}
onChange={handleLanguageChange}
style={{
background: 'rgba(255, 255, 255, 0.05)',
border: '1px solid rgba(255, 255, 255, 0.12)',
color: 'inherit',
fontSize: '12px',
padding: '2px 8px',
borderRadius: '6px',
opacity: 0.6,
cursor: 'pointer',
outline: 'none',
}}
onMouseEnter={e => (e.currentTarget.style.opacity = '1')}
onMouseLeave={e => (e.currentTarget.style.opacity = '0.6')}
>
<option value="en" style={{ background: '#18181b', color: '#fff' }}>English</option>
<option value="de" style={{ background: '#18181b', color: '#fff' }}>Deutsch</option>
<option value="fr" style={{ background: '#18181b', color: '#fff' }}>Français</option>
<option value="es" style={{ background: '#18181b', color: '#fff' }}>Español</option>
<option value="it" style={{ background: '#18181b', color: '#fff' }}>Italiano</option>
<option value="nl" style={{ background: '#18181b', color: '#fff' }}>Nederlands</option>
<option value="pt" style={{ background: '#18181b', color: '#fff' }}>Português</option>
<option value="pl" style={{ background: '#18181b', color: '#fff' }}>Polski</option>
<option value="ru" style={{ background: '#18181b', color: '#fff' }}>Русский</option>
<option value="tr" style={{ background: '#18181b', color: '#fff' }}>Türkçe</option>
<option value="ja" style={{ background: '#18181b', color: '#fff' }}>日本語</option>
<option value="ko" style={{ background: '#18181b', color: '#fff' }}>한국어</option>
<option value="zh" style={{ background: '#18181b', color: '#fff' }}>中文</option>
</select>
);
};
const Footer = () => ( const Footer = () => (
<footer style={{ padding: '16px 24px' }}> <footer style={{ padding: '16px 24px' }}>
<div style={{ <div style={{
@ -392,6 +471,8 @@ const Footer = () => (
Legal Notice Legal Notice
</Link> </Link>
<span style={{ opacity: 0.2, fontSize: '11px' }}>·</span>
<button <button
type="button" type="button"
onClick={openCookiePreferencesModal} onClick={openCookiePreferencesModal}
@ -411,6 +492,10 @@ const Footer = () => (
> >
Cookie Preferences Cookie Preferences
</button> </button>
<span style={{ opacity: 0.2, fontSize: '11px' }}>·</span>
<LanguageSelector />
</div> </div>
</footer> </footer>
); );

View File

@ -4,12 +4,16 @@ const API_URL = '/api';
const api = axios.create({ const api = axios.create({
baseURL: API_URL, baseURL: API_URL,
withCredentials: true,
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
}); });
api.interceptors.request.use((config) => { api.interceptors.request.use((config) => {
const token = localStorage.getItem('token'); const lang = localStorage.getItem('i18nextLng') || (typeof navigator !== 'undefined' ? navigator.language : 'en');
if (token) { if (lang) {
config.headers.Authorization = `Bearer ${token}`; config.headers['Accept-Language'] = lang;
} }
return config; return config;
}); });

View File

@ -0,0 +1,382 @@
import React, { useState } from 'react';
import { Key, Eye, EyeOff, X, AlertCircle, CheckCircle2, ShieldCheck } from 'lucide-react';
import api from '../api';
interface ChangePasswordModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess?: () => void;
}
export const ChangePasswordModal: React.FC<ChangePasswordModalProps> = ({
isOpen,
onClose,
onSuccess,
}) => {
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [logoutOtherDevices, setLogoutOtherDevices] = useState(true);
const [showCurrentPw, setShowCurrentPw] = useState(false);
const [showNewPw, setShowNewPw] = useState(false);
const [showConfirmPw, setShowConfirmPw] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
if (!isOpen) return null;
const handleResetForm = () => {
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
setLogoutOtherDevices(true);
setError('');
setSuccess(false);
setIsSubmitting(false);
};
const handleClose = () => {
handleResetForm();
onClose();
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!currentPassword) {
setError('Please enter your current password.');
return;
}
if (newPassword.length < 8) {
setError('New password must be at least 8 characters long.');
return;
}
if (newPassword !== confirmPassword) {
setError('New passwords do not match.');
return;
}
setIsSubmitting(true);
try {
await api.post('/user/settings', {
currentPassword,
newPassword,
logoutOtherDevices,
});
setSuccess(true);
setTimeout(() => {
handleClose();
if (onSuccess) onSuccess();
}, 1500);
} catch (err: any) {
setError(
err.response?.data?.error ||
err.response?.data?.message ||
'Failed to change password. Please verify your current password.'
);
} finally {
setIsSubmitting(false);
}
};
return (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 9999,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.82)',
backdropFilter: 'blur(8px)',
padding: '1rem',
}}
onClick={(e) => {
if (e.target === e.currentTarget) handleClose();
}}
>
<div
style={{
maxWidth: 480,
width: '100%',
backgroundColor: 'var(--mp-surface, #14171c)',
borderRadius: 16,
border: '1px solid var(--mp-border, rgba(255, 255, 255, 0.12))',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.85)',
padding: '2rem',
color: 'var(--mp-text, #f8fafc)',
position: 'relative',
}}
>
{/* Close Icon */}
<button
type="button"
onClick={handleClose}
style={{
position: 'absolute',
top: '1.25rem',
right: '1.25rem',
background: 'none',
border: 'none',
color: 'var(--mp-muted, #94a3b8)',
cursor: 'pointer',
padding: '0.25rem',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
aria-label="Close"
>
<X size={18} />
</button>
{/* Modal Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '1.25rem' }}>
<div
style={{
width: 44,
height: 44,
borderRadius: 12,
backgroundColor: 'rgba(249, 115, 22, 0.12)',
color: 'var(--mp-accent, #f97316)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Key size={22} />
</div>
<div>
<h2 style={{ fontSize: '1.25rem', fontWeight: 700, margin: 0 }}>Change Password</h2>
<p style={{ fontSize: '0.84rem', color: 'var(--mp-muted, #94a3b8)', margin: '0.2rem 0 0 0' }}>
Choose a strong, unique password to secure your account.
</p>
</div>
</div>
{/* Error Banner */}
{error && (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.75rem 1rem',
backgroundColor: 'rgba(239, 68, 68, 0.1)',
border: '1px solid rgba(239, 68, 68, 0.25)',
borderRadius: 8,
fontSize: '0.85rem',
color: '#fca5a5',
marginBottom: '1.25rem',
}}
>
<AlertCircle size={16} style={{ flexShrink: 0 }} />
<span>{error}</span>
</div>
)}
{/* Success Banner */}
{success && (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.75rem 1rem',
backgroundColor: 'rgba(34, 197, 94, 0.1)',
border: '1px solid rgba(34, 197, 94, 0.25)',
borderRadius: 8,
fontSize: '0.85rem',
color: '#86efac',
marginBottom: '1.25rem',
}}
>
<CheckCircle2 size={16} style={{ flexShrink: 0 }} />
<span>Password successfully updated! Closing...</span>
</div>
)}
<form onSubmit={handleSubmit}>
{/* Current Password */}
<div style={{ marginBottom: '1rem' }}>
<label
htmlFor="modalCurrentPassword"
style={{ display: 'block', fontSize: '0.84rem', fontWeight: 600, color: 'var(--mp-muted, #94a3b8)', marginBottom: '0.4rem' }}
>
Current Password
</label>
<div style={{ position: 'relative' }}>
<input
id="modalCurrentPassword"
type={showCurrentPw ? 'text' : 'password'}
className="settings-input"
placeholder="Enter current password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
autoComplete="current-password"
required
style={{ width: '100%', paddingRight: '2.5rem' }}
/>
<button
type="button"
className="settings-pw-eye"
onClick={() => setShowCurrentPw((s) => !s)}
tabIndex={-1}
aria-label={showCurrentPw ? 'Hide password' : 'Show password'}
>
{showCurrentPw ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
{/* New Password */}
<div style={{ marginBottom: '1rem' }}>
<label
htmlFor="modalNewPassword"
style={{ display: 'block', fontSize: '0.84rem', fontWeight: 600, color: 'var(--mp-muted, #94a3b8)', marginBottom: '0.4rem' }}
>
New Password
</label>
<div style={{ position: 'relative' }}>
<input
id="modalNewPassword"
type={showNewPw ? 'text' : 'password'}
className="settings-input"
placeholder="At least 8 characters"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
required
style={{ width: '100%', paddingRight: '2.5rem' }}
/>
<button
type="button"
className="settings-pw-eye"
onClick={() => setShowNewPw((s) => !s)}
tabIndex={-1}
aria-label={showNewPw ? 'Hide password' : 'Show password'}
>
{showNewPw ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
{/* Confirm New Password */}
<div style={{ marginBottom: '1.25rem' }}>
<label
htmlFor="modalConfirmPassword"
style={{ display: 'block', fontSize: '0.84rem', fontWeight: 600, color: 'var(--mp-muted, #94a3b8)', marginBottom: '0.4rem' }}
>
Confirm New Password
</label>
<div style={{ position: 'relative' }}>
<input
id="modalConfirmPassword"
type={showConfirmPw ? 'text' : 'password'}
className="settings-input"
placeholder="Repeat new password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
required
style={{ width: '100%', paddingRight: '2.5rem' }}
/>
<button
type="button"
className="settings-pw-eye"
onClick={() => setShowConfirmPw((s) => !s)}
tabIndex={-1}
aria-label={showConfirmPw ? 'Hide password' : 'Show password'}
>
{showConfirmPw ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
{newPassword && confirmPassword && newPassword !== confirmPassword && (
<p className="settings-inline-error" style={{ marginTop: '0.35rem' }}>
<AlertCircle size={13} /> Passwords do not match
</p>
)}
</div>
{/* Log Out Other Devices Checkbox (Checked by default) */}
<div
style={{
display: 'flex',
alignItems: 'flex-start',
gap: '0.65rem',
padding: '0.85rem 1rem',
backgroundColor: 'rgba(255, 255, 255, 0.03)',
borderRadius: 10,
border: '1px solid var(--mp-border, rgba(255, 255, 255, 0.08))',
marginBottom: '1.5rem',
cursor: 'pointer',
}}
onClick={() => setLogoutOtherDevices((v) => !v)}
>
<input
type="checkbox"
id="modalLogoutOtherDevices"
checked={logoutOtherDevices}
onChange={(e) => setLogoutOtherDevices(e.target.checked)}
onClick={(e) => e.stopPropagation()}
style={{
width: 17,
height: 17,
marginTop: '0.15rem',
accentColor: 'var(--mp-accent, #f97316)',
cursor: 'pointer',
}}
/>
<label
htmlFor="modalLogoutOtherDevices"
style={{ cursor: 'pointer', userSelect: 'none', margin: 0 }}
onClick={(e) => e.stopPropagation()}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', fontWeight: 600, fontSize: '0.88rem', color: '#fff' }}>
<ShieldCheck size={14} style={{ color: 'var(--mp-accent, #f97316)' }} />
Log out other devices
</div>
<div style={{ fontSize: '0.78rem', color: 'var(--mp-muted, #94a3b8)', marginTop: '0.15rem', lineHeight: 1.35 }}>
Sign out of all other browsers, phones, and devices. You will remain logged in on this device.
</div>
</label>
</div>
{/* Modal Actions */}
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'flex-end' }}>
<button
type="button"
className="settings-btn settings-btn-danger-outline"
onClick={handleClose}
disabled={isSubmitting}
>
Cancel
</button>
<button
type="submit"
className="settings-btn settings-btn-primary"
disabled={isSubmitting || success}
style={{ display: 'inline-flex', alignItems: 'center', gap: '0.4rem', minWidth: 140, justifyContent: 'center' }}
>
{isSubmitting ? (
<span>Updating...</span>
) : (
<>
<Key size={15} /> Update Password
</>
)}
</button>
</div>
</form>
</div>
</div>
);
};

View File

@ -8,6 +8,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import api from '../api'; import api from '../api';
import { useAuth } from '../App'; import { useAuth } from '../App';
import { SecuritySetupCards } from './SecuritySetupCards';
import './DeveloperOnboardingModal.css'; import './DeveloperOnboardingModal.css';
interface DeveloperOnboardingModalProps { interface DeveloperOnboardingModalProps {
@ -45,6 +46,8 @@ export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> =
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const hasSecureAuth = Boolean(user?.totp_enabled || (user?.passkey_count && user.passkey_count > 0) || user?.has_passkey);
if (!isOpen) return null; if (!isOpen) return null;
// Max steps: 3 for Free, 4 for Paid // Max steps: 3 for Free, 4 for Paid
@ -425,6 +428,18 @@ export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> =
</ul> </ul>
</div> </div>
{/* ── Security Requirement for Developers ── */}
{!hasSecureAuth && (
<div style={{ marginTop: '1.25rem' }}>
<SecuritySetupCards
title="Developer Security Requirement"
description="To publish plugins and protect developer payouts, you must set up a Passkey or Two-Factor Authentication before completing onboarding."
isRequired={true}
onComplete={() => {}}
/>
</div>
)}
{/* ── Mandatory Legal Consent ── */} {/* ── Mandatory Legal Consent ── */}
<div style={{ <div style={{
marginTop: '1.25rem', marginTop: '1.25rem',
@ -449,7 +464,7 @@ export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> =
<div className="dev-btn-row-stacked" style={{ marginTop: '1.25rem' }}> <div className="dev-btn-row-stacked" style={{ marginTop: '1.25rem' }}>
<button <button
className="dev-btn dev-btn-primary" className="dev-btn dev-btn-primary"
disabled={loading || !acceptedTerms} disabled={loading || !acceptedTerms || !hasSecureAuth}
onClick={() => handleCompleteOnboarding(false)} onClick={() => handleCompleteOnboarding(false)}
> >
{loading ? 'Completing registration...' : t('developer.onboarding.btn_complete')} {loading ? 'Completing registration...' : t('developer.onboarding.btn_complete')}
@ -473,6 +488,18 @@ export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> =
</ul> </ul>
</div> </div>
{/* ── Security Requirement for Developers ── */}
{!hasSecureAuth && (
<div style={{ marginTop: '1.25rem' }}>
<SecuritySetupCards
title="Developer Security Requirement"
description="To publish plugins and protect developer payouts, you must set up a Passkey or Two-Factor Authentication before completing onboarding."
isRequired={true}
onComplete={() => {}}
/>
</div>
)}
{/* ── Mandatory Legal Consent ── */} {/* ── Mandatory Legal Consent ── */}
<div style={{ <div style={{
marginTop: '1.25rem', marginTop: '1.25rem',
@ -497,14 +524,14 @@ export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> =
<div className="dev-btn-row-stacked" style={{ marginTop: '1.25rem' }}> <div className="dev-btn-row-stacked" style={{ marginTop: '1.25rem' }}>
<button <button
className="dev-btn dev-btn-stripe" className="dev-btn dev-btn-stripe"
disabled={loading || !acceptedTerms} disabled={loading || !acceptedTerms || !hasSecureAuth}
onClick={() => handleCompleteOnboarding(true)} onClick={() => handleCompleteOnboarding(true)}
> >
<CreditCard size={16} /> {loading ? 'Connecting Stripe...' : t('developer.onboarding.btn_connect_stripe')} <CreditCard size={16} /> {loading ? 'Connecting Stripe...' : t('developer.onboarding.btn_connect_stripe')}
</button> </button>
<button <button
className="dev-btn dev-btn-secondary" className="dev-btn dev-btn-secondary"
disabled={loading || !acceptedTerms} disabled={loading || !acceptedTerms || !hasSecureAuth}
onClick={() => handleCompleteOnboarding(false)} onClick={() => handleCompleteOnboarding(false)}
> >
{t('developer.onboarding.btn_complete')} {t('developer.onboarding.btn_complete')}

View File

@ -0,0 +1,224 @@
import React, { useState } from 'react';
import { ShieldAlert, Download, Copy, Check, Printer } from 'lucide-react';
interface RecoveryCodesModalProps {
isOpen: boolean;
codes: string[];
onClose: () => void;
}
export const RecoveryCodesModal: React.FC<RecoveryCodesModalProps> = ({
isOpen,
codes,
onClose,
}) => {
const [copied, setCopied] = useState(false);
if (!isOpen || codes.length === 0) return null;
const handleCopyAll = () => {
const text = `PUMPKIN MARKET - EMERGENCY RECOVERY CODES\nGenerated on: ${new Date().toLocaleString()}\n\nEach code can be used once if you lose access to your Passkey or 2FA Authenticator:\n\n` +
codes.map((c, i) => `${i + 1}. ${c}`).join('\n') +
`\n\nKeep these codes in a secure location (e.g., password manager).`;
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2500);
};
const handleDownload = () => {
const text = `PUMPKIN MARKET - EMERGENCY RECOVERY CODES\nGenerated on: ${new Date().toLocaleString()}\n\nEach code can be used once if you lose access to your Passkey or 2FA Authenticator:\n\n` +
codes.map((c, i) => `${i + 1}. ${c}`).join('\n') +
`\n\nKeep these codes in a secure location (e.g., password manager).`;
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `pumpkin-market-recovery-codes-${new Date().toISOString().slice(0, 10)}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const handlePrint = () => {
window.print();
};
return (
<div style={{
position: 'fixed',
inset: 0,
zIndex: 9999,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.8)',
backdropFilter: 'blur(6px)',
padding: '1rem',
}}>
<div style={{
maxWidth: 540,
width: '100%',
backgroundColor: 'var(--mp-surface, #14171c)',
borderRadius: 16,
border: '1px solid var(--mp-border, rgba(255,255,255,0.12))',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.7)',
padding: '2rem',
color: 'var(--mp-text, #f8fafc)',
}}>
{/* Header */}
<div style={{ textAlign: 'center', marginBottom: '1.5rem' }}>
<div style={{
width: 52,
height: 52,
borderRadius: '50%',
backgroundColor: 'rgba(249, 115, 22, 0.15)',
color: 'var(--mp-accent, #f97316)',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '0.75rem',
}}>
<ShieldAlert size={26} />
</div>
<h2 style={{ fontSize: '1.4rem', fontWeight: 700, margin: '0 0 0.4rem 0' }}>
Save Your Emergency Recovery Codes
</h2>
<p style={{ fontSize: '0.88rem', color: 'var(--mp-muted, #94a3b8)', lineHeight: 1.5, margin: 0 }}>
If you lose access to your device or authenticator app, these <strong>one-time backup codes</strong> are the only way to regain access to your account.
</p>
</div>
{/* Warning Alert */}
<div style={{
padding: '0.75rem 1rem',
backgroundColor: 'rgba(239, 68, 68, 0.1)',
border: '1px solid rgba(239, 68, 68, 0.25)',
borderRadius: 8,
fontSize: '0.82rem',
color: '#fca5a5',
marginBottom: '1.25rem',
lineHeight: 1.4,
}}>
⚠️ <strong>Important:</strong> We do not store these codes in plaintext and cannot recover them for you. Save them in a password manager now.
</div>
{/* Codes Grid */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, 1fr)',
gap: '0.5rem',
backgroundColor: 'rgba(0, 0, 0, 0.35)',
padding: '1rem',
borderRadius: 10,
border: '1px solid rgba(255, 255, 255, 0.06)',
marginBottom: '1.25rem',
}}>
{codes.map((code, idx) => (
<div
key={idx}
style={{
fontFamily: 'var(--font-mono, monospace)',
fontSize: '0.92rem',
color: '#4ade80',
padding: '0.4rem 0.6rem',
backgroundColor: 'rgba(255, 255, 255, 0.03)',
borderRadius: 6,
textAlign: 'center',
letterSpacing: '0.05em',
}}
>
{code}
</div>
))}
</div>
{/* Action Buttons */}
<div style={{ display: 'flex', gap: '0.6rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
<button
type="button"
onClick={handleCopyAll}
style={{
flex: 1,
minWidth: 120,
padding: '0.6rem 0.8rem',
backgroundColor: 'rgba(255, 255, 255, 0.06)',
border: '1px solid rgba(255, 255, 255, 0.12)',
borderRadius: 8,
color: copied ? '#4ade80' : '#fff',
fontSize: '0.85rem',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.4rem',
cursor: 'pointer',
}}
>
{copied ? <Check size={15} /> : <Copy size={15} />}
{copied ? 'Copied!' : 'Copy All'}
</button>
<button
type="button"
onClick={handleDownload}
style={{
flex: 1,
minWidth: 120,
padding: '0.6rem 0.8rem',
backgroundColor: 'rgba(255, 255, 255, 0.06)',
border: '1px solid rgba(255, 255, 255, 0.12)',
borderRadius: 8,
color: '#fff',
fontSize: '0.85rem',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.4rem',
cursor: 'pointer',
}}
>
<Download size={15} /> Download (.txt)
</button>
<button
type="button"
onClick={handlePrint}
style={{
padding: '0.6rem 0.8rem',
backgroundColor: 'rgba(255, 255, 255, 0.06)',
border: '1px solid rgba(255, 255, 255, 0.12)',
borderRadius: 8,
color: '#fff',
fontSize: '0.85rem',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.4rem',
cursor: 'pointer',
}}
title="Print Codes"
>
<Printer size={15} />
</button>
</div>
{/* Confirmation Button */}
<button
type="button"
className="mp-btn mp-btn-primary"
onClick={onClose}
style={{
width: '100%',
padding: '0.8rem',
fontSize: '0.92rem',
fontWeight: 600,
cursor: 'pointer',
}}
>
I have safely stored my recovery codes →
</button>
</div>
</div>
);
};

View File

@ -0,0 +1,689 @@
import React, { useState } from 'react';
import { useAuth } from '../App';
import api from '../api';
import { startRegistration } from '@simplewebauthn/browser';
import { RecoveryCodesModal } from './RecoveryCodesModal';
import {
Fingerprint,
Smartphone,
ShieldCheck,
ChevronDown,
ChevronUp,
Copy,
Check,
AlertCircle,
Loader2,
Sparkles,
Shield
} from 'lucide-react';
interface SecuritySetupCardsProps {
title?: string;
description?: string;
onComplete?: () => void;
onSkip?: () => void;
canSkip?: boolean;
isRequired?: boolean;
}
export const SecuritySetupCards: React.FC<SecuritySetupCardsProps> = ({
title = 'Secure Your Account',
description = 'Protect your account with modern security. Choose between biometric passkeys or standard 2FA authenticator apps.',
onComplete,
onSkip,
canSkip = false,
isRequired = false,
}) => {
const { login, user, refreshUser } = useAuth();
const [expandedBox, setExpandedBox] = useState<'passkey' | 'totp' | null>(null);
// Recovery Codes Modal State
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([]);
const [showRecoveryModal, setShowRecoveryModal] = useState(false);
// Passkey State
const [passkeyName, setPasskeyName] = useState('');
const [passkeyLoading, setPasskeyLoading] = useState(false);
const [passkeyError, setPasskeyError] = useState<string | null>(null);
const [passkeySuccess, setPasskeySuccess] = useState(false);
// TOTP State
const [totpLoading, setTotpLoading] = useState(false);
const [totpQr, setTotpQr] = useState<string | null>(null);
const [totpSecret, setTotpSecret] = useState<string | null>(null);
const [totpCode, setTotpCode] = useState('');
const [totpVerifying, setTotpVerifying] = useState(false);
const [totpError, setTotpError] = useState<string | null>(null);
const [totpSuccess, setTotpSuccess] = useState(false);
const [copiedSecret, setCopiedSecret] = useState(false);
const toggleBox = (box: 'passkey' | 'totp') => {
if (expandedBox === box) {
setExpandedBox(null);
} else {
setExpandedBox(box);
if (box === 'totp' && !totpQr && !totpSuccess) {
loadTotpSetup();
}
}
};
const loadTotpSetup = async () => {
try {
setTotpLoading(true);
setTotpError(null);
const res = await api.post('/user/2fa/setup');
setTotpQr(res.data.qrCode);
setTotpSecret(res.data.secret);
} catch (err: any) {
setTotpError(err.response?.data?.error || 'Failed to initialize 2FA setup.');
} finally {
setTotpLoading(false);
}
};
const handleCopySecret = () => {
if (!totpSecret) return;
navigator.clipboard.writeText(totpSecret);
setCopiedSecret(true);
setTimeout(() => setCopiedSecret(false), 2500);
};
const handleVerifyTotp = async (e: React.FormEvent) => {
e.preventDefault();
const clean = totpCode.trim();
if (clean.length !== 6) {
setTotpError('Please enter a valid 6-digit authentication code.');
return;
}
try {
setTotpVerifying(true);
setTotpError(null);
const res = await api.post('/user/2fa/verify', { code: clean });
if (res.data.token) {
login(res.data.token);
} else if (refreshUser) {
await refreshUser();
}
setTotpSuccess(true);
if (res.data.recoveryCodes && res.data.recoveryCodes.length > 0) {
setRecoveryCodes(res.data.recoveryCodes);
setShowRecoveryModal(true);
} else {
setTimeout(() => {
onComplete?.();
}, 1200);
}
} catch (err: any) {
setTotpError(err.response?.data?.error || 'Invalid 2FA code. Please try again.');
} finally {
setTotpVerifying(false);
}
};
const handleRegisterPasskey = async () => {
try {
setPasskeyLoading(true);
setPasskeyError(null);
const startRes = await api.post('/user/passkey/register/start');
const options = startRes.data;
const credential = await startRegistration({ optionsJSON: options });
const finishRes = await api.post('/user/passkey/register/finish', {
name: passkeyName.trim() || 'My Passkey',
credential,
});
if (finishRes.data.token) {
login(finishRes.data.token);
} else if (refreshUser) {
await refreshUser();
}
setPasskeySuccess(true);
if (finishRes.data.recoveryCodes && finishRes.data.recoveryCodes.length > 0) {
setRecoveryCodes(finishRes.data.recoveryCodes);
setShowRecoveryModal(true);
} else {
setTimeout(() => {
onComplete?.();
}, 1200);
}
} catch (err: any) {
console.error('Passkey registration error:', err);
if (err.name === 'NotAllowedError') {
setPasskeyError('Registration was canceled or timed out.');
} else {
setPasskeyError(
err.response?.data?.error ||
err.message ||
'Failed to register passkey. Ensure your device supports biometrics/WebAuthn.'
);
}
} finally {
setPasskeyLoading(false);
}
};
const hasSecureAuth = Boolean(user?.totp_enabled || (user?.passkey_count && user.passkey_count > 0) || user?.has_passkey);
return (
<div style={{
maxWidth: 680,
width: '100%',
margin: '0 auto',
padding: '2rem 1.5rem',
background: 'var(--mp-surface, #14171c)',
borderRadius: 20,
border: '1px solid var(--mp-border, rgba(255,255,255,0.08))',
boxShadow: '0 20px 40px -15px rgba(0,0,0,0.5)',
}}>
{/* Header */}
<div style={{ textAlign: 'center', marginBottom: '2rem' }}>
<div style={{
width: 56,
height: 56,
borderRadius: '50%',
background: 'linear-gradient(135deg, rgba(249, 115, 22, 0.2), rgba(234, 88, 12, 0.05))',
border: '1px solid rgba(249, 115, 22, 0.3)',
color: 'var(--mp-accent, #f97316)',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '1rem',
}}>
<Shield size={28} />
</div>
<h2 style={{
fontSize: '1.6rem',
fontWeight: 700,
margin: '0 0 0.5rem 0',
color: 'var(--mp-text, #f8fafc)',
letterSpacing: '-0.02em',
}}>
{title}
</h2>
<p style={{
color: 'var(--mp-muted, #94a3b8)',
fontSize: '0.95rem',
lineHeight: 1.5,
maxWidth: 540,
margin: '0 auto',
}}>
{description}
</p>
{isRequired && (
<div style={{
marginTop: '1rem',
display: 'inline-flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.4rem 0.85rem',
background: 'rgba(239, 68, 68, 0.12)',
border: '1px solid rgba(239, 68, 68, 0.25)',
borderRadius: 9999,
color: '#f87171',
fontSize: '0.82rem',
fontWeight: 600,
}}>
<AlertCircle size={14} /> Required for Plugin Publishing & Developer Verification
</div>
)}
</div>
{/* Boxes Grid */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{/* ── Box 1: Passkey / Biometrics (Recommended) ── */}
<div style={{
border: expandedBox === 'passkey' ? '1px solid var(--mp-accent, #f97316)' : '1px solid var(--mp-border, rgba(255,255,255,0.08))',
borderRadius: 14,
background: expandedBox === 'passkey' ? 'rgba(249, 115, 22, 0.03)' : 'rgba(255,255,255,0.02)',
transition: 'all 0.2s ease',
overflow: 'hidden',
}}>
<button
type="button"
onClick={() => toggleBox('passkey')}
style={{
width: '100%',
padding: '1.25rem 1.5rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: 'none',
border: 'none',
cursor: 'pointer',
textAlign: 'left',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<div style={{
width: 44,
height: 44,
borderRadius: 12,
background: 'linear-gradient(135deg, rgba(34, 197, 94, 0.15), rgba(34, 197, 94, 0.05))',
border: '1px solid rgba(34, 197, 94, 0.3)',
color: '#4ade80',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}>
<Fingerprint size={24} />
</div>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', marginBottom: '0.2rem' }}>
<span style={{ fontWeight: 700, fontSize: '1.05rem', color: 'var(--mp-text, #f8fafc)' }}>
Passkey & Biometrics
</span>
<span style={{
fontSize: '0.72rem',
fontWeight: 700,
padding: '0.15rem 0.55rem',
borderRadius: 999,
background: 'rgba(34, 197, 94, 0.15)',
color: '#4ade80',
border: '1px solid rgba(34, 197, 94, 0.3)',
display: 'inline-flex',
alignItems: 'center',
gap: '0.25rem',
}}>
<Sparkles size={11} /> Recommended
</span>
</div>
<p style={{ margin: 0, fontSize: '0.85rem', color: 'var(--mp-muted, #94a3b8)' }}>
Fastest & most secure. Touch ID, Face ID, Windows Hello, or Security Key.
</p>
</div>
</div>
<div style={{ color: 'var(--mp-muted, #94a3b8)', marginLeft: '1rem' }}>
{expandedBox === 'passkey' ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
</div>
</button>
{/* Passkey Expanded Content */}
{expandedBox === 'passkey' && (
<div style={{
padding: '0 1.5rem 1.5rem 1.5rem',
borderTop: '1px solid rgba(255,255,255,0.06)',
paddingTop: '1.25rem',
}}>
<div style={{
padding: '0.9rem 1rem',
background: 'rgba(255,255,255,0.03)',
borderRadius: 10,
marginBottom: '1.25rem',
fontSize: '0.85rem',
color: 'var(--mp-muted, #94a3b8)',
lineHeight: 1.5,
}}>
💡 <strong>How it works:</strong> Passkeys replace passwords and SMS codes with cryptographic keys stored securely in your device’s keychain (Apple Keychain, Google Password Manager, Windows Hello, 1Password, or YubiKey). They are 100% phishing-proof.
</div>
{passkeyError && (
<div style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.75rem 1rem',
background: 'rgba(239, 68, 68, 0.1)',
border: '1px solid rgba(239, 68, 68, 0.25)',
borderRadius: 8,
color: '#f87171',
fontSize: '0.85rem',
marginBottom: '1rem',
}}>
<AlertCircle size={16} />
<span>{passkeyError}</span>
</div>
)}
{passkeySuccess ? (
<div style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.85rem 1rem',
background: 'rgba(34, 197, 94, 0.12)',
border: '1px solid rgba(34, 197, 94, 0.3)',
borderRadius: 8,
color: '#4ade80',
fontSize: '0.9rem',
fontWeight: 600,
}}>
<Check size={18} />
<span>Passkey created successfully! Redirecting...</span>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.85rem' }}>
<div>
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: 600, color: 'var(--mp-text, #f8fafc)', marginBottom: '0.4rem' }}>
Device / Passkey Name (Optional)
</label>
<input
type="text"
value={passkeyName}
onChange={(e) => setPasskeyName(e.target.value)}
placeholder="e.g. MacBook Pro Touch ID, Windows PC, or YubiKey"
style={{
width: '100%',
padding: '0.75rem 1rem',
background: 'rgba(255,255,255,0.05)',
border: '1px solid var(--mp-border, rgba(255,255,255,0.12))',
borderRadius: 8,
color: '#fff',
fontSize: '0.9rem',
boxSizing: 'border-box',
outline: 'none',
}}
/>
</div>
<button
type="button"
className="mp-btn mp-btn-primary"
disabled={passkeyLoading}
onClick={handleRegisterPasskey}
style={{
padding: '0.85rem',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.5rem',
cursor: passkeyLoading ? 'not-allowed' : 'pointer',
borderRadius: 8,
}}
>
{passkeyLoading ? (
<>
<Loader2 size={18} className="animate-spin" /> Setting up Passkey...
</>
) : (
<>
<Fingerprint size={18} /> Create & Register Passkey
</>
)}
</button>
</div>
)}
</div>
)}
</div>
{/* ── Box 2: Authenticator App (TOTP 2FA) ── */}
<div style={{
border: expandedBox === 'totp' ? '1px solid var(--mp-accent, #f97316)' : '1px solid var(--mp-border, rgba(255,255,255,0.08))',
borderRadius: 14,
background: expandedBox === 'totp' ? 'rgba(249, 115, 22, 0.03)' : 'rgba(255,255,255,0.02)',
transition: 'all 0.2s ease',
overflow: 'hidden',
}}>
<button
type="button"
onClick={() => toggleBox('totp')}
style={{
width: '100%',
padding: '1.25rem 1.5rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: 'none',
border: 'none',
cursor: 'pointer',
textAlign: 'left',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<div style={{
width: 44,
height: 44,
borderRadius: 12,
background: 'linear-gradient(135deg, rgba(59, 130, 246, 0.15), rgba(59, 130, 246, 0.05))',
border: '1px solid rgba(59, 130, 246, 0.3)',
color: '#60a5fa',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}>
<Smartphone size={24} />
</div>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', marginBottom: '0.2rem' }}>
<span style={{ fontWeight: 700, fontSize: '1.05rem', color: 'var(--mp-text, #f8fafc)' }}>
Authenticator App (TOTP 2FA)
</span>
</div>
<p style={{ margin: 0, fontSize: '0.85rem', color: 'var(--mp-muted, #94a3b8)' }}>
Use Google Authenticator, Microsoft Authenticator, Authy, or 1Password.
</p>
</div>
</div>
<div style={{ color: 'var(--mp-muted, #94a3b8)', marginLeft: '1rem' }}>
{expandedBox === 'totp' ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
</div>
</button>
{/* TOTP Expanded Content */}
{expandedBox === 'totp' && (
<div style={{
padding: '0 1.5rem 1.5rem 1.5rem',
borderTop: '1px solid rgba(255,255,255,0.06)',
paddingTop: '1.25rem',
}}>
{totpLoading ? (
<div style={{ textAlign: 'center', padding: '2rem', color: 'var(--mp-muted, #94a3b8)' }}>
<Loader2 size={24} className="animate-spin" style={{ margin: '0 auto 0.5rem auto' }} />
<p style={{ margin: 0 }}>Generating 2FA Secret & QR Code...</p>
</div>
) : totpSuccess ? (
<div style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.85rem 1rem',
background: 'rgba(34, 197, 94, 0.12)',
border: '1px solid rgba(34, 197, 94, 0.3)',
borderRadius: 8,
color: '#4ade80',
fontSize: '0.9rem',
fontWeight: 600,
}}>
<Check size={18} />
<span>Two-Factor Authentication enabled successfully! Redirecting...</span>
</div>
) : (
<div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '1.5rem', alignItems: 'center', marginBottom: '1.5rem' }}>
{totpQr && (
<div style={{
padding: '0.5rem',
background: '#fff',
borderRadius: 12,
display: 'inline-flex',
}}>
<img src={totpQr} alt="2FA QR Code" style={{ width: 140, height: 140, display: 'block' }} />
</div>
)}
<div style={{ flex: 1, minWidth: 200 }}>
<p style={{ margin: '0 0 0.5rem 0', fontSize: '0.85rem', color: 'var(--mp-text, #f8fafc)', fontWeight: 600 }}>
1. Scan this QR Code with your Authenticator App
</p>
<p style={{ margin: '0 0 0.8rem 0', fontSize: '0.8rem', color: 'var(--mp-muted, #94a3b8)' }}>
Or manually copy the setup key below if you cannot scan the code:
</p>
{totpSecret && (
<div style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
background: 'rgba(255,255,255,0.05)',
padding: '0.45rem 0.75rem',
borderRadius: 8,
border: '1px solid rgba(255,255,255,0.1)',
}}>
<code style={{ fontSize: '0.85rem', color: '#f97316', flex: 1, letterSpacing: '0.05em' }}>
{totpSecret}
</code>
<button
type="button"
onClick={handleCopySecret}
style={{
background: 'none',
border: 'none',
color: copiedSecret ? '#4ade80' : 'var(--mp-muted, #94a3b8)',
cursor: 'pointer',
padding: '0.2rem',
display: 'flex',
alignItems: 'center',
}}
title="Copy Secret"
>
{copiedSecret ? <Check size={16} /> : <Copy size={16} />}
</button>
</div>
)}
</div>
</div>
<form onSubmit={handleVerifyTotp} style={{ display: 'flex', flexDirection: 'column', gap: '0.85rem' }}>
<div>
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: 600, color: 'var(--mp-text, #f8fafc)', marginBottom: '0.4rem' }}>
2. Enter the 6-Digit Code from the App
</label>
<input
type="text"
maxLength={6}
value={totpCode}
onChange={(e) => {
setTotpError(null);
setTotpCode(e.target.value.replace(/\D/g, ''));
}}
placeholder="123456"
style={{
width: '100%',
padding: '0.75rem 1rem',
background: 'rgba(255,255,255,0.05)',
border: totpError ? '1px solid #ef4444' : '1px solid var(--mp-border, rgba(255,255,255,0.12))',
boxShadow: totpError ? '0 0 0 3px rgba(239, 68, 68, 0.2)' : undefined,
borderRadius: 8,
color: '#fff',
fontSize: '1.1rem',
letterSpacing: '0.2em',
textAlign: 'center',
boxSizing: 'border-box',
outline: 'none',
}}
/>
</div>
<button
type="submit"
className="mp-btn mp-btn-primary"
disabled={totpVerifying || (totpCode.trim().length !== 6 && !totpError)}
style={{
padding: '0.85rem',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.5rem',
cursor: totpVerifying ? 'not-allowed' : 'pointer',
borderRadius: 8,
backgroundColor: totpError ? '#ef4444' : undefined,
borderColor: totpError ? '#ef4444' : undefined,
color: '#fff',
opacity: (totpCode.trim().length !== 6 && !totpError) ? 0.6 : 1,
}}
>
{totpVerifying ? (
<>
<Loader2 size={18} className="animate-spin" /> Verifying Code...
</>
) : totpError ? (
<>
<AlertCircle size={18} /> {totpError} — Try Again
</>
) : (
<>
<ShieldCheck size={18} /> Verify & Enable 2FA
</>
)}
</button>
</form>
</div>
)}
</div>
)}
</div>
</div>
{/* Footer / Skip Actions */}
<div style={{
marginTop: '1.75rem',
display: 'flex',
alignItems: 'center',
justifyContent: canSkip ? 'space-between' : 'center',
paddingTop: '1rem',
borderTop: '1px solid rgba(255,255,255,0.06)',
}}>
{canSkip ? (
<>
<button
type="button"
onClick={onSkip}
style={{
background: 'none',
border: 'none',
color: 'var(--mp-muted, #94a3b8)',
fontSize: '0.9rem',
cursor: 'pointer',
padding: '0.5rem 0.75rem',
borderRadius: 6,
transition: 'color 0.2s',
}}
onMouseEnter={(e) => (e.currentTarget.style.color = '#fff')}
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--mp-muted, #94a3b8)')}
>
Skip for now →
</button>
{hasSecureAuth && (
<button
type="button"
className="mp-btn mp-btn-primary"
onClick={onComplete}
style={{ padding: '0.6rem 1.25rem', fontSize: '0.9rem', fontWeight: 600 }}
>
Continue →
</button>
)}
</>
) : (
hasSecureAuth && (
<button
type="button"
className="mp-btn mp-btn-primary"
onClick={onComplete}
style={{ width: '100%', padding: '0.85rem', fontSize: '0.95rem', fontWeight: 600 }}
>
Continue to Upload →
</button>
)
)}
</div>
<RecoveryCodesModal
isOpen={showRecoveryModal}
codes={recoveryCodes}
onClose={() => {
setShowRecoveryModal(false);
onComplete?.();
}}
/>
</div>
);
};

View File

@ -47,8 +47,9 @@ i18n
escapeValue: false, // React already escapes values escapeValue: false, // React already escapes values
}, },
detection: { detection: {
order: ['navigator', 'htmlTag'], order: ['localStorage', 'navigator', 'htmlTag'],
caches: [], // Always auto-detect based on the user's browser/system region lookupLocalStorage: 'i18nextLng',
caches: ['localStorage'],
}, },
}); });

View File

@ -52,6 +52,26 @@ const REVIEW_REPORT_REASONS = [
]; ];
const MAX_REVIEW_LENGTH = 2000; const MAX_REVIEW_LENGTH = 2000;
const safeMarkdownComponents = {
a: ({ href, children, ...props }: any) => {
const isSafe = typeof href === 'string' && (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('/'));
if (!isSafe) {
return <span>{children}</span>;
}
const isExternal = href.startsWith('http://') || href.startsWith('https://');
return (
<a
href={href}
target={isExternal ? '_blank' : undefined}
rel={isExternal ? 'noopener noreferrer nofollow' : undefined}
{...props}
>
{children}
</a>
);
},
};
const StarRating = ({ rating, max = 5, onRate }: { rating: number; max?: number; onRate?: (n: number) => void }) => ( const StarRating = ({ rating, max = 5, onRate }: { rating: number; max?: number; onRate?: (n: number) => void }) => (
<div className="star-rating-container" style={{ display: 'flex', gap: 2 }}> <div className="star-rating-container" style={{ display: 'flex', gap: 2 }}>
{[...Array(max)].map((_, i) => ( {[...Array(max)].map((_, i) => (
@ -818,7 +838,7 @@ const PluginDetail = () => {
</div> </div>
<div className={`plugin-recent-update-body markdown-content ${isLong ? 'is-clamped' : ''}`}> <div className={`plugin-recent-update-body markdown-content ${isLong ? 'is-clamped' : ''}`}>
{latest.release_notes ? ( {latest.release_notes ? (
<ReactMarkdown remarkPlugins={[remarkGfm]}> <ReactMarkdown remarkPlugins={[remarkGfm]} components={safeMarkdownComponents}>
{latest.release_notes} {latest.release_notes}
</ReactMarkdown> </ReactMarkdown>
) : ( ) : (
@ -843,7 +863,7 @@ const PluginDetail = () => {
{/* DESCRIPTION */} {/* DESCRIPTION */}
<div className="markdown-content"> <div className="markdown-content">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{currentDescription}</ReactMarkdown> <ReactMarkdown remarkPlugins={[remarkGfm]} components={safeMarkdownComponents}>{currentDescription}</ReactMarkdown>
</div> </div>
<hr className="divider" /> <hr className="divider" />
@ -1432,7 +1452,7 @@ const PluginDetail = () => {
</div> </div>
<div className="markdown-content" style={{ color: 'var(--text)', fontSize: '0.95rem', lineHeight: 1.6 }}> <div className="markdown-content" style={{ color: 'var(--text)', fontSize: '0.95rem', lineHeight: 1.6 }}>
{v.release_notes ? ( {v.release_notes ? (
<ReactMarkdown remarkPlugins={[remarkGfm]}>{v.release_notes}</ReactMarkdown> <ReactMarkdown remarkPlugins={[remarkGfm]} components={safeMarkdownComponents}>{v.release_notes}</ReactMarkdown>
) : ( ) : (
<p style={{ fontStyle: 'italic', color: 'var(--text-muted)', opacity: 0.5 }}>No specific release notes for this update.</p> <p style={{ fontStyle: 'italic', color: 'var(--text-muted)', opacity: 0.5 }}>No specific release notes for this update.</p>
)} )}

View File

@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useNavigate, useLocation, Link } from 'react-router-dom'; import { useNavigate, useLocation, Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { QRCodeSVG } from 'qrcode.react'; import { QRCodeSVG } from 'qrcode.react';
import api from '../api'; import api from '../api';
import { useAuth } from '../App'; import { useAuth } from '../App';
@ -7,12 +8,16 @@ import { getCodeList } from 'country-list';
import DeveloperOnboardingModal from '../components/DeveloperOnboardingModal'; import DeveloperOnboardingModal from '../components/DeveloperOnboardingModal';
import { getPluginUrl } from '../utils/url'; import { getPluginUrl } from '../utils/url';
import { openCookiePreferencesModal } from '../utils/consent'; import { openCookiePreferencesModal } from '../utils/consent';
import { startRegistration } from '@simplewebauthn/browser';
import { RecoveryCodesModal } from '../components/RecoveryCodesModal';
import { ChangePasswordModal } from '../components/ChangePasswordModal';
import { import {
User, Mail, Lock, Shield, CreditCard, User, Mail, Lock, Shield, CreditCard,
BookOpen, AlertTriangle, LogOut, CheckCircle, BookOpen, AlertTriangle, LogOut, CheckCircle,
AlertCircle, Eye, EyeOff, ChevronRight, Bell, AlertCircle, Eye, EyeOff, ChevronRight, Bell,
Smartphone, Key, Trash2, Code, Sparkles, Building2, Smartphone, Key, Trash2, Code, Sparkles, Building2,
ShieldCheck, Download, Sliders, Package, Laptop, Tablet ShieldCheck, Download, Sliders, Package, Laptop, Tablet,
Fingerprint, Plus, ShieldAlert, RefreshCw
} from 'lucide-react'; } from 'lucide-react';
interface LibraryEntry { interface LibraryEntry {
@ -144,6 +149,7 @@ const PwInput: React.FC<PwInputProps> = ({ placeholder, value, onChange, show, o
const ProfilePage = () => { const ProfilePage = () => {
const { user, login, logout, refreshUser } = useAuth(); const { user, login, logout, refreshUser } = useAuth();
const { i18n } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
@ -157,13 +163,9 @@ const ProfilePage = () => {
}, [location]); }, [location]);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
username: '', email: '', country: '', username: '', email: '', country: '', language: '',
password: '', currentPassword: '', disable2faPassword: '', password: '', disable2faPassword: '',
newPassword: '', confirmPassword: '',
}); });
const [showCurrentPw, setShowCurrentPw] = useState(false);
const [showNewPw, setShowNewPw] = useState(false);
const [showConfirmPw, setShowConfirmPw] = useState(false);
const [show2faPw, setShow2faPw] = useState(false); const [show2faPw, setShow2faPw] = useState(false);
const [toast, setToast] = useState<{ text: string; type: 'success' | 'error' } | null>(null); const [toast, setToast] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
@ -197,10 +199,11 @@ const ProfilePage = () => {
username: user.username || '', username: user.username || '',
email: user.email || '', email: user.email || '',
country: user.country || '', country: user.country || '',
language: user.preferred_language || prev.language || (i18n.language ? i18n.language.split('-')[0] : 'en'),
})); }));
setIs2faEnabled(user.totp_enabled || false); setIs2faEnabled(user.totp_enabled || false);
} }
}, [user]); }, [user, i18n.language]);
// Developer Profile State // Developer Profile State
const [isDevModalOpen, setIsDevModalOpen] = useState(false); const [isDevModalOpen, setIsDevModalOpen] = useState(false);
@ -251,12 +254,111 @@ const ProfilePage = () => {
const [revokingSessionId, setRevokingSessionId] = useState<string | null>(null); const [revokingSessionId, setRevokingSessionId] = useState<string | null>(null);
const [revokingAllSessions, setRevokingAllSessions] = useState(false); const [revokingAllSessions, setRevokingAllSessions] = useState(false);
const [passkeys, setPasskeys] = useState<any[]>([]);
const [passkeysLoading, setPasskeysLoading] = useState(false);
const [showAddPasskeyModal, setShowAddPasskeyModal] = useState(false);
const [newPasskeyName, setNewPasskeyName] = useState('');
const [passkeyRegistering, setPasskeyRegistering] = useState(false);
const [recoveryStatus, setRecoveryStatus] = useState<{ remaining: number; total: number; hasCodes: boolean } | null>(null);
const [recoveryLoading, setRecoveryLoading] = useState(false);
const [showRegenModal, setShowRegenModal] = useState(false);
const [regenPassword, setRegenPassword] = useState('');
const [regenLoading, setRegenLoading] = useState(false);
const [generatedCodes, setGeneratedCodes] = useState<string[]>([]);
const [showCodesModal, setShowCodesModal] = useState(false);
const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
const [verify2faError, setVerify2faError] = useState('');
const [verify2faLoading, setVerify2faLoading] = useState(false);
useEffect(() => { useEffect(() => {
if (activeTab === 'security') { if (activeTab === 'security') {
fetchSessions(); fetchSessions();
fetchPasskeys();
fetchRecoveryStatus();
} }
}, [activeTab]); }, [activeTab]);
const fetchRecoveryStatus = async () => {
setRecoveryLoading(true);
try {
const res = await api.get('/user/recovery-codes');
setRecoveryStatus(res.data);
} catch {
setRecoveryStatus(null);
} finally {
setRecoveryLoading(false);
}
};
const handleGenerateRecoveryCodes = async (e: React.FormEvent) => {
e.preventDefault();
setRegenLoading(true);
try {
const res = await api.post('/user/recovery-codes/generate', { password: regenPassword });
setGeneratedCodes(res.data.codes || []);
setShowRegenModal(false);
setRegenPassword('');
setShowCodesModal(true);
fetchRecoveryStatus();
showToast('New recovery codes generated.');
} catch (err: any) {
showToast(err.response?.data?.error || 'Failed to generate recovery codes.', 'error');
} finally {
setRegenLoading(false);
}
};
const fetchPasskeys = async () => {
setPasskeysLoading(true);
try {
const res = await api.get('/user/passkeys');
setPasskeys(Array.isArray(res.data) ? res.data : []);
} catch {
setPasskeys([]);
} finally {
setPasskeysLoading(false);
}
};
const handleRegisterPasskey = async (e: React.FormEvent) => {
e.preventDefault();
setPasskeyRegistering(true);
try {
const startRes = await api.post('/user/passkey/register/start');
const credential = await startRegistration({ optionsJSON: startRes.data });
const finishRes = await api.post('/user/passkey/register/finish', {
name: newPasskeyName.trim() || 'My Passkey',
credential,
});
if (finishRes.data.token) login(finishRes.data.token);
showToast('Passkey registered successfully!');
setNewPasskeyName('');
setShowAddPasskeyModal(false);
fetchPasskeys();
refreshUser?.();
} catch (err: any) {
if (err.name !== 'NotAllowedError') {
showToast(err.response?.data?.error || err.message || 'Failed to register passkey.', 'error');
}
} finally {
setPasskeyRegistering(false);
}
};
const handleDeletePasskey = async (id: number) => {
if (!window.confirm('Are you sure you want to remove this passkey?')) return;
try {
const res = await api.delete(`/user/passkeys/${id}`);
if (res.data.token) login(res.data.token);
showToast('Passkey removed.');
fetchPasskeys();
refreshUser?.();
} catch (err: any) {
showToast(err.response?.data?.error || 'Failed to remove passkey.', 'error');
}
};
const fetchSessions = async () => { const fetchSessions = async () => {
setSessionsLoading(true); setSessionsLoading(true);
try { try {
@ -316,19 +418,6 @@ const ProfilePage = () => {
try { await fn(); } finally { setSaving(s => ({ ...s, [key]: false })); } try { await fn(); } finally { setSaving(s => ({ ...s, [key]: false })); }
}; };
const handleUpdate = async (e: React.FormEvent, endpoint: string, payload: object, key: string) => {
e.preventDefault();
await withSaving(key, async () => {
try {
const res = await api.post(endpoint, payload);
if (res.data.token) login(res.data.token);
showToast(res.data.message || 'Updated successfully!');
} catch (err: any) {
showToast(err.response?.data?.error || 'Update failed.', 'error');
}
});
};
const handleSaveAccountInfo = async (e: React.FormEvent) => { const handleSaveAccountInfo = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
await withSaving('accountInfo', async () => { await withSaving('accountInfo', async () => {
@ -357,7 +446,8 @@ const ProfilePage = () => {
if (formData.country && formData.country !== user?.country) { if (formData.country && formData.country !== user?.country) {
try { try {
await api.post('/user/change-country', { newCountry: formData.country }); const res = await api.post('/user/change-country', { newCountry: formData.country });
if (res.data.token) login(res.data.token);
updatedAny = true; updatedAny = true;
} catch (err: any) { } catch (err: any) {
showToast(err.response?.data?.error || 'Failed to update country.', 'error'); showToast(err.response?.data?.error || 'Failed to update country.', 'error');
@ -365,6 +455,18 @@ const ProfilePage = () => {
} }
} }
if (formData.language && formData.language !== user?.preferred_language) {
try {
const res = await api.post('/user/change-language', { newLanguage: formData.language });
if (res.data.token) login(res.data.token);
i18n.changeLanguage(formData.language);
updatedAny = true;
} catch (err: any) {
showToast(err.response?.data?.error || 'Failed to update preferred language.', 'error');
return;
}
}
if (updatedAny) { if (updatedAny) {
showToast('Account details updated successfully!'); showToast('Account details updated successfully!');
refreshUser?.(); refreshUser?.();
@ -445,22 +547,29 @@ const ProfilePage = () => {
const confirm2faSetup = async (e: React.FormEvent) => { const confirm2faSetup = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setVerify2faError('');
setVerify2faLoading(true);
try { try {
await api.post('/user/2fa/verify', { code: verify2faCode }); const res = await api.post('/user/2fa/verify', { code: verify2faCode });
if (res.data.token) login(res.data.token);
setIs2faEnabled(true); setIs2faEnabled(true);
setSetup2faData(null); setSetup2faData(null);
setVerify2faCode(''); setVerify2faCode('');
setVerify2faError('');
showToast('2FA enabled successfully!'); showToast('2FA enabled successfully!');
refreshUser?.(); refreshUser?.();
} catch (err: any) { } catch (err: any) {
showToast(err.response?.data?.error || 'Invalid 2FA code.', 'error'); setVerify2faError(err.response?.data?.error || 'Invalid 2FA code');
} finally {
setVerify2faLoading(false);
} }
}; };
const disable2fa = async (e: React.FormEvent) => { const disable2fa = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
try { try {
await api.post('/user/2fa/disable', { password: formData.disable2faPassword }); const res = await api.post('/user/2fa/disable', { password: formData.disable2faPassword });
if (res.data.token) login(res.data.token);
setIs2faEnabled(false); setIs2faEnabled(false);
setFormData({ ...formData, disable2faPassword: '' }); setFormData({ ...formData, disable2faPassword: '' });
showToast('2FA has been disabled.'); showToast('2FA has been disabled.');
@ -564,6 +673,7 @@ const ProfilePage = () => {
</Field> </Field>
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: '1.25rem' }}>
<Field label="Country"> <Field label="Country">
<select <select
id="accountCountry" id="accountCountry"
@ -583,6 +693,31 @@ const ProfilePage = () => {
</select> </select>
</Field> </Field>
<Field label="Preferred Language">
<select
id="accountLanguage"
name="language"
className="settings-input settings-select"
value={formData.language ? formData.language.split('-')[0] : 'en'}
onChange={e => setFormData({ ...formData, language: e.target.value })}
>
<option value="en">English</option>
<option value="de">Deutsch (German)</option>
<option value="fr">Français (French)</option>
<option value="es">Español (Spanish)</option>
<option value="it">Italiano (Italian)</option>
<option value="nl">Nederlands (Dutch)</option>
<option value="pt">Português (Portuguese)</option>
<option value="pl">Polski (Polish)</option>
<option value="ru">Русский (Russian)</option>
<option value="tr">Türkçe (Turkish)</option>
<option value="ja">日本語 (Japanese)</option>
<option value="ko">한국어 (Korean)</option>
<option value="zh">中文 (Chinese)</option>
</select>
</Field>
</div>
<div style={{ marginTop: '0.5rem' }}> <div style={{ marginTop: '0.5rem' }}>
<SaveBtn isSaving={saving['accountInfo']} label="Save Changes" /> <SaveBtn isSaving={saving['accountInfo']} label="Save Changes" />
</div> </div>
@ -602,59 +737,149 @@ const ProfilePage = () => {
</div> </div>
{/* Password */} {/* Password */}
<SettingsCard title="Change Password" description="Use a strong, unique password you don't use elsewhere." icon={Key}> <SettingsCard title="Password" description="Use a strong, unique password you don't use elsewhere." icon={Key}>
<form onSubmit={e => { <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '0.75rem' }}>
if (formData.newPassword !== formData.confirmPassword) { <div style={{ fontSize: '0.9rem', color: 'var(--mp-muted, #94a3b8)', flex: 1 }}>
e.preventDefault(); Protect your account with a strong password. You can also choose to log out of other active devices when updating your password.
showToast('Passwords do not match.', 'error'); </div>
return; <button
} type="button"
handleUpdate(e, '/user/settings', { className="settings-btn settings-btn-primary"
currentPassword: formData.currentPassword, onClick={() => setShowChangePasswordModal(true)}
newPassword: formData.newPassword, style={{ display: 'inline-flex', alignItems: 'center', gap: '0.4rem', padding: '0.5rem 0.9rem', fontSize: '0.85rem' }}
}, 'password'); >
<Key size={15} /> Change Password
</button>
</div>
</SettingsCard>
{/* Passkeys */}
<SettingsCard
title="Passkeys & Biometrics"
icon={Fingerprint}
description="Sign in securely and instantly with Touch ID, Face ID, Windows Hello, or physical security keys."
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.25rem', flexWrap: 'wrap', gap: '0.75rem' }}>
<div style={{ fontSize: '0.9rem', color: 'var(--mp-muted, #94a3b8)', flex: 1 }}>
Passkeys provide phishing-resistant security backed by hardware cryptographic keys on your device.
</div>
<button
type="button"
className="settings-btn settings-btn-primary"
onClick={() => setShowAddPasskeyModal(true)}
style={{ display: 'inline-flex', alignItems: 'center', gap: '0.4rem', padding: '0.5rem 0.9rem', fontSize: '0.85rem' }}
>
<Plus size={15} /> Add Passkey
</button>
</div>
{showAddPasskeyModal && (
<form onSubmit={handleRegisterPasskey} style={{
padding: '1.25rem',
background: 'rgba(255,255,255,0.03)',
borderRadius: 12,
border: '1px solid var(--mp-border, rgba(255,255,255,0.1))',
marginBottom: '1.25rem'
}}> }}>
<Field label="Current Password"> <p style={{ margin: '0 0 0.75rem 0', fontWeight: 600, fontSize: '0.95rem', color: 'var(--mp-text, #fff)' }}>
<PwInput Register a New Passkey
id="currentPassword" </p>
name="currentPassword" <Field label="Passkey Name (e.g. MacBook Pro Touch ID, Windows Hello)" hint="Give your passkey a descriptive nickname so you recognize the device later.">
placeholder="Current password" <input
value={formData.currentPassword} type="text"
onChange={v => setFormData({ ...formData, currentPassword: v })} className="settings-input"
show={showCurrentPw} placeholder="e.g. MacBook Pro Touch ID"
onToggle={() => setShowCurrentPw(s => !s)} value={newPasskeyName}
autoComplete="current-password" onChange={e => setNewPasskeyName(e.target.value)}
autoFocus
/> />
</Field> </Field>
<Field label="New Password"> <div style={{ display: 'flex', gap: '0.75rem', marginTop: '1rem' }}>
<PwInput <button
id="newPassword" type="submit"
name="newPassword" className="settings-btn settings-btn-primary"
placeholder="New password" disabled={passkeyRegistering}
value={formData.newPassword} style={{ display: 'inline-flex', alignItems: 'center', gap: '0.5rem' }}
onChange={v => setFormData({ ...formData, newPassword: v })} >
show={showNewPw} <Fingerprint size={16} /> {passkeyRegistering ? 'Prompting Device...' : 'Continue with Biometrics'}
onToggle={() => setShowNewPw(s => !s)} </button>
autoComplete="new-password" <button
/> type="button"
</Field> className="settings-btn settings-btn-danger-outline"
<Field label="Confirm New Password"> onClick={() => { setShowAddPasskeyModal(false); setNewPasskeyName(''); }}
<PwInput >
id="confirmPassword" Cancel
name="confirmPassword" </button>
placeholder="Repeat new password" </div>
value={formData.confirmPassword}
onChange={v => setFormData({ ...formData, confirmPassword: v })}
show={showConfirmPw}
onToggle={() => setShowConfirmPw(s => !s)}
autoComplete="new-password"
/>
</Field>
{formData.newPassword && formData.confirmPassword && formData.newPassword !== formData.confirmPassword && (
<p className="settings-inline-error"><AlertCircle size={13} /> Passwords do not match</p>
)}
<SaveBtn isSaving={saving['password']} label="Update Password" />
</form> </form>
)}
{passkeysLoading ? (
<p style={{ color: 'var(--mp-muted, #94a3b8)', fontSize: '0.88rem' }}>Loading registered passkeys...</p>
) : passkeys.length === 0 ? (
<div className="settings-status-row warn">
<AlertCircle size={16} />
<span>No passkeys registered yet. Add one to enable passwordless biometrics.</span>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{passkeys.map(pk => (
<div
key={pk.id}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0.85rem 1.1rem',
background: 'rgba(255,255,255,0.02)',
border: '1px solid var(--mp-border, rgba(255,255,255,0.08))',
borderRadius: 10,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.85rem' }}>
<div style={{
width: 36,
height: 36,
borderRadius: 8,
background: 'rgba(34, 197, 94, 0.12)',
color: '#4ade80',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<Fingerprint size={20} />
</div>
<div>
<div style={{ fontWeight: 600, fontSize: '0.92rem', color: 'var(--mp-text, #fff)' }}>
{pk.name}
</div>
<div style={{ fontSize: '0.78rem', color: 'var(--mp-muted, #94a3b8)' }}>
Added {pk.createdAt ? new Date(pk.createdAt).toLocaleDateString() : 'recently'}
{pk.lastUsedAt && ` • Last used ${new Date(pk.lastUsedAt).toLocaleDateString()}`}
</div>
</div>
</div>
<button
type="button"
onClick={() => handleDeletePasskey(pk.id)}
style={{
background: 'none',
border: 'none',
color: '#f87171',
cursor: 'pointer',
padding: '0.4rem',
borderRadius: 6,
display: 'flex',
alignItems: 'center',
}}
title="Delete Passkey"
>
<Trash2 size={16} />
</button>
</div>
))}
</div>
)}
</SettingsCard> </SettingsCard>
{/* 2FA */} {/* 2FA */}
@ -666,6 +891,14 @@ const ProfilePage = () => {
<CheckCircle size={16} /> <CheckCircle size={16} />
<span>2FA is currently <strong>enabled</strong> on your account.</span> <span>2FA is currently <strong>enabled</strong> on your account.</span>
</div> </div>
{user?.role === 'admin' || user?.role === 'moderator' ? (
<div className="settings-status-row warn" style={{ marginTop: '1.25rem' }}>
<Shield size={16} />
<span>
Staff members ({user.role === 'admin' ? 'Administrator' : 'Moderator'}) are required to keep 2FA enabled for platform security.
</span>
</div>
) : (
<form onSubmit={disable2fa} style={{ marginTop: '1.5rem' }}> <form onSubmit={disable2fa} style={{ marginTop: '1.5rem' }}>
<Field label="Confirm with your password to disable"> <Field label="Confirm with your password to disable">
<PwInput <PwInput
@ -683,6 +916,7 @@ const ProfilePage = () => {
Disable 2FA Disable 2FA
</button> </button>
</form> </form>
)}
</div> </div>
) : !setup2faData ? ( ) : !setup2faData ? (
<div> <div>
@ -705,7 +939,7 @@ const ProfilePage = () => {
<code className="settings-secret">{setup2faData.secret}</code> <code className="settings-secret">{setup2faData.secret}</code>
</p> </p>
<p className="settings-2fa-step" style={{ marginTop: '1.5rem' }}><span>2</span> Enter the 6-digit code from your app:</p> <p className="settings-2fa-step" style={{ marginTop: '1.5rem' }}><span>2</span> Enter the 6-digit code from your app:</p>
<form onSubmit={confirm2faSetup} style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', marginTop: '0.75rem' }}> <form onSubmit={confirm2faSetup} style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', marginTop: '0.75rem', flexWrap: 'wrap' }}>
<input <input
type="text" type="text"
id="verify2faCode" id="verify2faCode"
@ -714,19 +948,125 @@ const ProfilePage = () => {
placeholder="000 000" placeholder="000 000"
maxLength={8} maxLength={8}
value={verify2faCode} value={verify2faCode}
onChange={e => setVerify2faCode(e.target.value.replace(/\D/g, '').slice(0, 6))} onChange={e => {
setVerify2faError('');
setVerify2faCode(e.target.value.replace(/\D/g, '').slice(0, 6));
}}
autoComplete="one-time-code" autoComplete="one-time-code"
inputMode="numeric" inputMode="numeric"
pattern="[0-9]*" pattern="[0-9]*"
required required
autoFocus autoFocus
style={{
borderColor: verify2faError ? '#ef4444' : undefined,
boxShadow: verify2faError ? '0 0 0 3px rgba(239, 68, 68, 0.2)' : undefined,
}}
/> />
<button type="submit" className="settings-btn settings-btn-primary">Verify & Enable</button> <button
type="submit"
className={`settings-btn ${verify2faError ? 'settings-btn-danger' : 'settings-btn-primary'}`}
disabled={verify2faLoading}
style={
verify2faError
? {
backgroundColor: '#ef4444',
borderColor: '#ef4444',
color: '#fff',
display: 'inline-flex',
alignItems: 'center',
gap: '0.4rem',
}
: undefined
}
>
{verify2faLoading ? (
'Verifying...'
) : verify2faError ? (
<>
<AlertCircle size={15} /> {verify2faError} — Try Again
</>
) : (
'Verify & Enable'
)}
</button>
</form> </form>
</div> </div>
)} )}
</SettingsCard> </SettingsCard>
{/* Recovery Codes */}
<SettingsCard
title="Emergency Recovery Codes"
icon={ShieldAlert}
description="One-time use backup codes to regain access to your account if you lose your phone or security key."
>
{recoveryLoading ? (
<p className="settings-hint" style={{ padding: '0.75rem 0' }}>Loading recovery codes status...</p>
) : (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem', flexWrap: 'wrap', gap: '0.75rem' }}>
<div style={{ fontSize: '0.9rem', color: 'var(--mp-muted, #94a3b8)', flex: 1 }}>
{recoveryStatus?.hasCodes ? (
<span>You have <strong>{recoveryStatus.remaining}</strong> of <strong>{recoveryStatus.total}</strong> recovery codes remaining.</span>
) : (
<span>No active recovery codes. Generate a set of backup codes to prevent getting locked out.</span>
)}
</div>
<button
type="button"
className="settings-btn settings-btn-primary"
onClick={() => setShowRegenModal(true)}
style={{ display: 'inline-flex', alignItems: 'center', gap: '0.4rem', padding: '0.5rem 0.9rem', fontSize: '0.85rem' }}
>
<RefreshCw size={14} /> {recoveryStatus?.hasCodes ? 'Generate New Codes' : 'Generate Codes'}
</button>
</div>
)}
{showRegenModal && (
<form onSubmit={handleGenerateRecoveryCodes} style={{
padding: '1.25rem',
background: 'rgba(255,255,255,0.03)',
borderRadius: 12,
border: '1px solid var(--mp-border, rgba(255,255,255,0.1))',
marginBottom: '1rem'
}}>
<p style={{ margin: '0 0 0.5rem 0', fontWeight: 600, fontSize: '0.95rem', color: 'var(--mp-text, #fff)' }}>
Confirm Password to Generate Recovery Codes
</p>
<p style={{ margin: '0 0 0.75rem 0', fontSize: '0.82rem', color: 'var(--mp-muted, #94a3b8)' }}>
Generating new recovery codes will automatically invalidate all previously generated codes.
</p>
<Field label="Your Current Password">
<input
type="password"
className="settings-input"
placeholder="••••••••"
value={regenPassword}
onChange={e => setRegenPassword(e.target.value)}
autoFocus
required
/>
</Field>
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '1rem' }}>
<button
type="submit"
className="settings-btn settings-btn-primary"
disabled={regenLoading}
>
{regenLoading ? 'Generating...' : 'Generate 10 Codes'}
</button>
<button
type="button"
className="settings-btn settings-btn-danger-outline"
onClick={() => { setShowRegenModal(false); setRegenPassword(''); }}
>
Cancel
</button>
</div>
</form>
)}
</SettingsCard>
{/* Active Devices & Sessions */} {/* Active Devices & Sessions */}
<SettingsCard <SettingsCard
title="Active Devices & Sessions" title="Active Devices & Sessions"
@ -1341,6 +1681,21 @@ const ProfilePage = () => {
setActiveTab('developer'); setActiveTab('developer');
}} }}
/> />
<RecoveryCodesModal
isOpen={showCodesModal}
codes={generatedCodes}
onClose={() => setShowCodesModal(false)}
/>
<ChangePasswordModal
isOpen={showChangePasswordModal}
onClose={() => setShowChangePasswordModal(false)}
onSuccess={() => {
showToast('Password changed successfully.', 'success');
fetchSessions();
}}
/>
</div> </div>
); );
}; };

View File

@ -2,6 +2,8 @@ import React, { useState, useRef } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Turnstile } from '@marsidev/react-turnstile'; import { Turnstile } from '@marsidev/react-turnstile';
import { startAuthentication } from '@simplewebauthn/browser';
import { Fingerprint, AlertCircle } from 'lucide-react';
import api from '../../api'; import api from '../../api';
import { useAuth } from '../../App'; import { useAuth } from '../../App';
@ -19,6 +21,9 @@ const LoginPage = () => {
const [requires2fa, setRequires2fa] = useState(false); const [requires2fa, setRequires2fa] = useState(false);
const [tempToken, setTempToken] = useState<string | null>(null); const [tempToken, setTempToken] = useState<string | null>(null);
const [totpCode, setTotpCode] = useState(''); const [totpCode, setTotpCode] = useState('');
const [useRecoveryCode, setUseRecoveryCode] = useState(false);
const [recoveryCode, setRecoveryCode] = useState('');
const [codeError, setCodeError] = useState('');
const navigate = useNavigate(); const navigate = useNavigate();
const { login } = useAuth(); const { login } = useAuth();
@ -46,15 +51,25 @@ const LoginPage = () => {
if (requires2fa) { if (requires2fa) {
setIsSubmitting(true); setIsSubmitting(true);
setError(''); setError('');
setCodeError('');
try { try {
if (useRecoveryCode) {
const res = await api.post('/auth/login/recovery-code', {
tempToken,
code: recoveryCode.trim(),
});
login(res.data.token);
navigate('/dashboard');
} else {
const res = await api.post('/auth/login/2fa', { const res = await api.post('/auth/login/2fa', {
temp_token: tempToken, temp_token: tempToken,
code: totpCode, code: totpCode,
}); });
login(res.data.token); login(res.data.token);
navigate('/dashboard'); navigate('/dashboard');
}
} catch (err: any) { } catch (err: any) {
setError(err.response?.data?.error || 'Invalid 2FA code.'); setCodeError(err.response?.data?.error || (useRecoveryCode ? 'Invalid recovery code' : 'Invalid 2FA code'));
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@ -114,6 +129,36 @@ const LoginPage = () => {
} }
}; };
const handlePasskeyLogin = async () => {
setError('');
setIsSubmitting(true);
try {
const startRes = await api.post('/auth/passkey/login/start', {
email: form.email.trim() || undefined,
});
const credential = await startAuthentication({
optionsJSON: startRes.data.options,
});
const finishRes = await api.post('/auth/passkey/login/finish', {
challengeId: startRes.data.challengeId,
credential,
});
if (finishRes.data.token) {
login(finishRes.data.token);
navigate('/dashboard');
}
} catch (err: any) {
if (err.name !== 'NotAllowedError') {
setError(err.response?.data?.error || err.message || 'Passkey authentication failed.');
}
} finally {
setIsSubmitting(false);
}
};
const emailIsInvalid = emailTouched && emailValid === false; const emailIsInvalid = emailTouched && emailValid === false;
const emailIsValid = emailTouched && emailValid === true; const emailIsValid = emailTouched && emailValid === true;
@ -341,7 +386,7 @@ const LoginPage = () => {
</div> </div>
<form onSubmit={handleSubmit} noValidate> <form onSubmit={handleSubmit} noValidate>
{error && ( {!requires2fa && error && (
<div className="error-banner"> <div className="error-banner">
<svg className="error-icon" viewBox="0 0 16 16" fill="currentColor"> <svg className="error-icon" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 0a8 8 0 100 16A8 8 0 008 0zm.75 4.75a.75.75 0 00-1.5 0v3.5a.75.75 0 001.5 0v-3.5zm-.75 6a1 1 0 110 2 1 1 0 010-2z"/> <path d="M8 0a8 8 0 100 16A8 8 0 008 0zm.75 4.75a.75.75 0 00-1.5 0v3.5a.75.75 0 001.5 0v-3.5zm-.75 6a1 1 0 110 2 1 1 0 010-2z"/>
@ -356,8 +401,12 @@ const LoginPage = () => {
{requires2fa ? ( {requires2fa ? (
<div className="form-field"> <div className="form-field">
{!useRecoveryCode ? (
<>
<label className="field-label" htmlFor="totpCode">Authentication Code</label> <label className="field-label" htmlFor="totpCode">Authentication Code</label>
<p style={{ fontSize: '13px', color: 'var(--text-muted, #888)', marginBottom: '12px' }}>Enter the 6-digit code from your authenticator app.</p> <p style={{ fontSize: '13px', color: 'var(--text-muted, #888)', marginBottom: '12px' }}>
Enter the 6-digit code from your authenticator app.
</p>
<div className="field-input-wrapper"> <div className="field-input-wrapper">
<input <input
id="totpCode" id="totpCode"
@ -367,19 +416,131 @@ const LoginPage = () => {
placeholder="000000" placeholder="000000"
maxLength={8} maxLength={8}
value={totpCode} value={totpCode}
onChange={e => setTotpCode(e.target.value.replace(/\D/g, '').slice(0, 6))} onChange={e => {
setCodeError('');
setTotpCode(e.target.value.replace(/\D/g, '').slice(0, 6));
}}
autoComplete="one-time-code" autoComplete="one-time-code"
inputMode="numeric" inputMode="numeric"
pattern="[0-9]*" pattern="[0-9]*"
required required
disabled={isSubmitting} disabled={isSubmitting}
autoFocus autoFocus
style={{ letterSpacing: '0.2em', textAlign: 'center', fontSize: '1.2rem', fontFamily: 'var(--font-mono, monospace)' }} style={{
letterSpacing: '0.2em',
textAlign: 'center',
fontSize: '1.2rem',
fontFamily: 'var(--font-mono, monospace)',
borderColor: codeError ? '#ef4444' : undefined,
boxShadow: codeError ? '0 0 0 3px rgba(239, 68, 68, 0.2)' : undefined,
}}
/> />
</div> </div>
<div style={{ marginTop: '14px', textAlign: 'center' }}>
<button
type="button"
onClick={() => { setUseRecoveryCode(true); setCodeError(''); setError(''); }}
style={{
background: 'none',
border: 'none',
color: 'var(--primary, #FF7518)',
fontSize: '13px',
cursor: 'pointer',
textDecoration: 'none',
fontWeight: 500,
}}
>
Lost access to your phone? Use a recovery code →
</button>
</div>
</>
) : (
<>
<label className="field-label" htmlFor="recoveryCode">Emergency Recovery Code</label>
<p style={{ fontSize: '13px', color: 'var(--text-muted, #888)', marginBottom: '12px' }}>
Enter one of your 12-character backup recovery codes.
</p>
<div className="field-input-wrapper">
<input
id="recoveryCode"
name="recoveryCode"
className="login-input no-icon"
type="text"
placeholder="xxxx-xxxx-xxxx"
value={recoveryCode}
onChange={e => {
setCodeError('');
setRecoveryCode(e.target.value);
}}
autoComplete="off"
required
disabled={isSubmitting}
autoFocus
style={{
letterSpacing: '0.08em',
textAlign: 'center',
fontSize: '1.05rem',
fontFamily: 'var(--font-mono, monospace)',
borderColor: codeError ? '#ef4444' : undefined,
boxShadow: codeError ? '0 0 0 3px rgba(239, 68, 68, 0.2)' : undefined,
}}
/>
</div>
<div style={{ marginTop: '14px', textAlign: 'center' }}>
<button
type="button"
onClick={() => { setUseRecoveryCode(false); setCodeError(''); setError(''); }}
style={{
background: 'none',
border: 'none',
color: 'var(--text-muted, #888)',
fontSize: '13px',
cursor: 'pointer',
textDecoration: 'none',
fontWeight: 500,
}}
>
← Back to authenticator app code
</button>
</div>
</>
)}
</div> </div>
) : ( ) : (
<> <>
<button
type="button"
onClick={handlePasskeyLogin}
disabled={isSubmitting}
style={{
width: '100%',
padding: '0.75rem 1rem',
marginBottom: '1.25rem',
background: 'rgba(255, 255, 255, 0.05)',
border: '1px solid var(--border, #2A2A2A)',
borderRadius: '7px',
color: 'var(--text, #EAEAEA)',
fontWeight: 600,
fontSize: '14px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.6rem',
cursor: isSubmitting ? 'not-allowed' : 'pointer',
transition: 'all 0.15s ease',
}}
onMouseEnter={e => (e.currentTarget.style.background = 'rgba(255, 255, 255, 0.08)')}
onMouseLeave={e => (e.currentTarget.style.background = 'rgba(255, 255, 255, 0.05)')}
>
<Fingerprint size={18} color="#4ade80" /> Sign in with a Passkey
</button>
<div style={{ display: 'flex', alignItems: 'center', margin: '0 0 1.25rem 0', gap: '0.75rem' }}>
<div style={{ flex: 1, height: 1, background: 'var(--border, #2A2A2A)' }} />
<span style={{ fontSize: '11px', color: 'var(--text-muted, #888)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>or email & password</span>
<div style={{ flex: 1, height: 1, background: 'var(--border, #2A2A2A)' }} />
</div>
<div className="form-field"> <div className="form-field">
<label className="field-label" htmlFor="email">{t('auth.email')}</label> <label className="field-label" htmlFor="email">{t('auth.email')}</label>
<div className="field-input-wrapper"> <div className="field-input-wrapper">
@ -456,11 +617,34 @@ const LoginPage = () => {
</div> </div>
)} )}
<button className="submit-btn" type="submit" disabled={isSubmitting}> <button
className="submit-btn"
type="submit"
disabled={isSubmitting}
style={
codeError
? {
backgroundColor: '#ef4444',
borderColor: '#ef4444',
color: '#fff',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.45rem',
fontWeight: 600,
}
: undefined
}
>
{isSubmitting ? ( {isSubmitting ? (
<><span className="spinner" />{t('common.loading')}</> <><span className="spinner" />{t('common.loading')}</>
) : codeError ? (
<>
<AlertCircle size={18} />
<span>{codeError} — Try Again</span>
</>
) : requires2fa ? ( ) : requires2fa ? (
'Verify' useRecoveryCode ? 'Verify Recovery Code' : 'Verify & Sign In'
) : ( ) : (
t('auth.sign_in') t('auth.sign_in')
)} )}

View File

@ -28,7 +28,7 @@ function getStrength(pw: string): { score: number; label: string; color: string
} }
const RegisterPage = () => { const RegisterPage = () => {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const [form, setForm] = useState({ username: '', email: '', password: '', website: '' }); const [form, setForm] = useState({ username: '', email: '', password: '', website: '' });
const [captchaToken, setCaptchaToken] = useState<string | null>(null); const [captchaToken, setCaptchaToken] = useState<string | null>(null);
const turnstileRef = useRef<any>(null); const turnstileRef = useRef<any>(null);
@ -98,7 +98,8 @@ const RegisterPage = () => {
} }
try { try {
await api.post('/auth/register', { ...form, captchaToken: currentToken }); const language = i18n.language ? i18n.language.split('-')[0] : 'en';
await api.post('/auth/register', { ...form, captchaToken: currentToken, language });
navigate('/check-email', { state: { email: form.email } }); navigate('/check-email', { state: { email: form.email } });
} catch (err: any) { } catch (err: any) {
setError(err.response?.data?.error || 'Registration failed. Please try again.'); setError(err.response?.data?.error || 'Registration failed. Please try again.');

View File

@ -1,12 +1,16 @@
import { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../../App';
import api from '../../api'; import api from '../../api';
import { SecuritySetupCards } from '../../components/SecuritySetupCards';
import { CheckCircle2, AlertCircle, Loader2 } from 'lucide-react';
const VerifyEmailPage = () => { const VerifyEmailPage: React.FC = () => {
const [status, setStatus] = useState<'verifying' | 'success' | 'error'>('verifying'); const [status, setStatus] = useState<'verifying' | 'success' | 'error'>('verifying');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const { login } = useAuth();
useEffect(() => { useEffect(() => {
const verify = async () => { const verify = async () => {
@ -22,17 +26,11 @@ const VerifyEmailPage = () => {
try { try {
const res = await api.get(`/auth/verify-email?token=${token}`); const res = await api.get(`/auth/verify-email?token=${token}`);
// Assuming the API returns a token and user object on success if (res.data.token) {
localStorage.setItem('token', res.data.token); login(res.data.token);
localStorage.setItem('user', JSON.stringify(res.data.user)); }
setStatus('success'); setStatus('success');
// Redirect to home page after a short delay
setTimeout(() => {
navigate('/');
}, 3000);
} catch (err: any) { } catch (err: any) {
setStatus('error'); setStatus('error');
setError(err.response?.data?.error || 'Verification failed. The link may be invalid or expired.'); setError(err.response?.data?.error || 'Verification failed. The link may be invalid or expired.');
@ -40,22 +38,85 @@ const VerifyEmailPage = () => {
}; };
verify(); verify();
}, [location, navigate]); }, [location]);
return ( return (
<div style={{ textAlign: 'center', padding: '50px' }}> <div style={{
{status === 'verifying' && <h1>Verifying your email...</h1>} minHeight: '80vh',
{status === 'success' && ( display: 'flex',
<div> alignItems: 'center',
<h1>Email Verified Successfully!</h1> justifyContent: 'center',
<p>You are now logged in. Redirecting to the homepage...</p> padding: '2rem 1rem',
}}>
{status === 'verifying' && (
<div style={{ textAlign: 'center', color: 'var(--mp-muted, #94a3b8)' }}>
<Loader2 size={40} className="animate-spin" style={{ margin: '0 auto 1rem auto', color: 'var(--mp-accent, #f97316)' }} />
<h2 style={{ fontSize: '1.4rem', fontWeight: 600, color: 'var(--mp-text, #fff)' }}>Verifying your email address...</h2>
<p>Please wait while we confirm your credentials.</p>
</div> </div>
)} )}
{status === 'success' && (
<div style={{ width: '100%', maxWidth: 680 }}>
<div style={{
textAlign: 'center',
marginBottom: '1.5rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.5rem',
color: '#4ade80',
}}>
<CheckCircle2 size={24} />
<span style={{ fontSize: '1.1rem', fontWeight: 600 }}>Email verified successfully! Welcome to Pumpkin Market.</span>
</div>
<SecuritySetupCards
title="Protect Your Account (Recommended)"
description="Add an extra layer of security before you get started. Choose passkeys (biometrics) or an authenticator app. You can also do this later."
canSkip={true}
onSkip={() => navigate('/')}
onComplete={() => navigate('/')}
/>
</div>
)}
{status === 'error' && ( {status === 'error' && (
<div> <div style={{
<h1>Verification Failed</h1> maxWidth: 480,
<p>{error}</p> width: '100%',
<p>Please try registering again or contact support.</p> padding: '2.5rem',
background: 'var(--mp-surface, #14171c)',
borderRadius: 16,
border: '1px solid rgba(239, 68, 68, 0.2)',
textAlign: 'center',
}}>
<div style={{
width: 56,
height: 56,
borderRadius: '50%',
background: 'rgba(239, 68, 68, 0.15)',
color: '#f87171',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '1rem',
}}>
<AlertCircle size={28} />
</div>
<h2 style={{ fontSize: '1.4rem', fontWeight: 700, margin: '0 0 0.5rem 0', color: '#fff' }}>
Verification Failed
</h2>
<p style={{ color: 'var(--mp-muted, #94a3b8)', fontSize: '0.9rem', lineHeight: 1.5, marginBottom: '1.5rem' }}>
{error}
</p>
<button
className="mp-btn mp-btn-primary"
onClick={() => navigate('/register')}
style={{ width: '100%', padding: '0.75rem', fontWeight: 600 }}
>
Return to Registration
</button>
</div> </div>
)} )}
</div> </div>

View File

@ -9,6 +9,7 @@ import {
import api from '../../../api'; import api from '../../../api';
import { useAuth } from '../../../App'; import { useAuth } from '../../../App';
import { DeveloperOnboardingModal } from '../../../components/DeveloperOnboardingModal'; import { DeveloperOnboardingModal } from '../../../components/DeveloperOnboardingModal';
import { SecuritySetupCards } from '../../../components/SecuritySetupCards';
import { validateWasmFile, validateAndSanitizeImage } from '../../../utils/fileValidation'; import { validateWasmFile, validateAndSanitizeImage } from '../../../utils/fileValidation';
import './AddPlugin.css'; import './AddPlugin.css';
@ -219,35 +220,20 @@ const AddPlugin = () => {
); );
} }
if (user && user.is_developer && !user.totp_enabled) { const hasSecureAuth = Boolean(
user?.totp_enabled || (user?.passkey_count && user.passkey_count > 0) || user?.has_passkey
);
if (user && user.is_developer && !hasSecureAuth) {
return ( return (
<div className="ap-root" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '60vh', padding: '2rem 1rem' }}> <div className="ap-root" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '60vh', padding: '2rem 1rem' }}>
<div style={{ <SecuritySetupCards
maxWidth: 520, width: '100%', padding: '2.5rem', background: 'var(--mp-surface, #14171c)', title="Developer Security Setup"
borderRadius: 16, border: '1px solid var(--mp-border, rgba(255,255,255,0.1))', textAlign: 'center', description="To protect Minecraft server owners from unauthorized uploads and supply-chain attacks, developers are required to configure either a Passkey (Biometrics) or Two-Factor Authentication (2FA) before publishing plugins."
boxShadow: '0 8px 32px rgba(0,0,0,0.3)' isRequired={true}
}}> canSkip={false}
<div style={{ onComplete={() => refreshUser?.()}
width: 60, height: 60, borderRadius: '50%', background: 'rgba(249, 115, 22, 0.15)', />
color: 'var(--mp-accent, #f97316)', display: 'inline-flex', alignItems: 'center',
justifyContent: 'center', marginBottom: '1.25rem'
}}>
<Lock size={28} />
</div>
<h2 style={{ fontSize: '1.5rem', fontWeight: 700, margin: '0 0 0.5rem 0', color: 'var(--mp-text, #fff)' }}>
Two-Factor Authentication Required
</h2>
<p style={{ color: 'var(--mp-muted, #94a3b8)', fontSize: '0.9rem', lineHeight: 1.5, marginBottom: '1.75rem' }}>
To protect Minecraft servers and prevent unauthorized plugin uploads from compromised accounts, all developers must enable <strong>Two-Factor Authentication (2FA)</strong> before publishing plugins.
</p>
<button
className="mp-btn mp-btn-primary"
style={{ width: '100%', padding: '0.85rem', fontSize: '0.95rem', fontWeight: 600, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem', cursor: 'pointer' }}
onClick={() => navigate('/settings?tab=security')}
>
<ShieldCheck size={16} /> Enable 2FA in Security Settings →
</button>
</div>
</div> </div>
); );
} }