feat: add passkeys
This commit is contained in:
parent
632ba89301
commit
31c198bafd
@ -9,7 +9,8 @@
|
||||
<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: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" />
|
||||
<title>Pumpkin Market</title>
|
||||
<!-- Privacy-friendly Google Consent Mode v2 Default -->
|
||||
|
||||
39
package-lock.json
generated
39
package-lock.json
generated
@ -13,6 +13,7 @@
|
||||
"@fontsource/jetbrains-mono": "^5.3.0",
|
||||
"@fontsource/nunito": "^5.3.0",
|
||||
"@marsidev/react-turnstile": "^1.6.1",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@vnedyalk0v/react19-simple-maps": "^2.0.10",
|
||||
"axios": "^1.20.0",
|
||||
"country-list": "^2.4.1",
|
||||
@ -20,7 +21,7 @@
|
||||
"d3-scale": "^4.0.2",
|
||||
"i18next": "^26.4.0",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^1.34.0",
|
||||
"lucide-react": "^1.37.0",
|
||||
"or": "^0.2.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.2.8",
|
||||
@ -28,7 +29,7 @@
|
||||
"react-i18next": "^17.0.12",
|
||||
"react-is": "^19.2.8",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.18.2",
|
||||
"react-router-dom": "^7.18.3",
|
||||
"recharts": "^3.10.1",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
@ -38,7 +39,7 @@
|
||||
"@types/node": "^26.4.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.5",
|
||||
"@vitejs/plugin-react": "^6.1.0",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"eslint": "^10.9.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.5",
|
||||
@ -885,6 +886,12 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
@ -1661,9 +1668,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.0.tgz",
|
||||
"integrity": "sha512-qd2BzUBehkov86WFhg0JkEFEYyCLG9uPCe6qWTY/kRlss9OvJrOF2UbIWT7p+8IzZHkEu0DNGHc4HSv+JdDLsw==",
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz",
|
||||
"integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -3527,9 +3534,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.34.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.34.0.tgz",
|
||||
"integrity": "sha512-vnjGJNI7Htk5+oWW8gXGuaLgwgAb0T6/iZbBrp9JCfRFwdNWZ0YTm3eyxjOLgwN6r8iyAf3UA70zNmBRBNv7yg==",
|
||||
"version": "1.37.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.37.0.tgz",
|
||||
"integrity": "sha512-LPsB4rD1TD6wZu1djKOf9vUnS1jTNaHbolXebXDgiTdb6jeA1agIJhJsIybCmjKmQClcOaal1o1OaiYahEftyQ==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
@ -4783,9 +4790,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
|
||||
"integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==",
|
||||
"version": "7.18.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz",
|
||||
"integrity": "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
@ -4805,12 +4812,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz",
|
||||
"integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==",
|
||||
"version": "7.18.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.3.tgz",
|
||||
"integrity": "sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.18.2"
|
||||
"react-router": "7.18.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
|
||||
@ -19,6 +19,7 @@
|
||||
"@fontsource/jetbrains-mono": "^5.3.0",
|
||||
"@fontsource/nunito": "^5.3.0",
|
||||
"@marsidev/react-turnstile": "^1.6.1",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@vnedyalk0v/react19-simple-maps": "^2.0.10",
|
||||
"axios": "^1.20.0",
|
||||
"country-list": "^2.4.1",
|
||||
@ -26,7 +27,7 @@
|
||||
"d3-scale": "^4.0.2",
|
||||
"i18next": "^26.4.0",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^1.34.0",
|
||||
"lucide-react": "^1.37.0",
|
||||
"or": "^0.2.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.2.8",
|
||||
@ -34,7 +35,7 @@
|
||||
"react-i18next": "^17.0.12",
|
||||
"react-is": "^19.2.8",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.18.2",
|
||||
"react-router-dom": "^7.18.3",
|
||||
"recharts": "^3.10.1",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
@ -44,7 +45,7 @@
|
||||
"@types/node": "^26.4.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.5",
|
||||
"@vitejs/plugin-react": "^6.1.0",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"eslint": "^10.9.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.5",
|
||||
|
||||
105
src/App.tsx
105
src/App.tsx
@ -63,34 +63,56 @@ const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
const payload = decodeToken(token);
|
||||
const initAuth = async () => {
|
||||
try {
|
||||
const res = await api.get('/user/me');
|
||||
if (res.data?.token) {
|
||||
const payload = decodeToken(res.data.token);
|
||||
if (payload && payload.exp * 1000 > Date.now()) {
|
||||
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');
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
}
|
||||
};
|
||||
initAuth();
|
||||
}, [i18n]);
|
||||
|
||||
const login = (token: string) => {
|
||||
localStorage.setItem('token', token);
|
||||
// Decode token for client-side state without persisting in localStorage
|
||||
const payload = decodeToken(token);
|
||||
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');
|
||||
setUser(null);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshUser = async () => {
|
||||
try {
|
||||
const res = await api.get('/user/me');
|
||||
if (res.data.token) {
|
||||
if (res.data?.token) {
|
||||
login(res.data.token);
|
||||
}
|
||||
} 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 = () => (
|
||||
<footer style={{ padding: '16px 24px' }}>
|
||||
<div style={{
|
||||
@ -392,6 +471,8 @@ const Footer = () => (
|
||||
Legal Notice
|
||||
</Link>
|
||||
|
||||
<span style={{ opacity: 0.2, fontSize: '11px' }}>·</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCookiePreferencesModal}
|
||||
@ -411,6 +492,10 @@ const Footer = () => (
|
||||
>
|
||||
Cookie Preferences
|
||||
</button>
|
||||
|
||||
<span style={{ opacity: 0.2, fontSize: '11px' }}>·</span>
|
||||
|
||||
<LanguageSelector />
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
|
||||
10
src/api.ts
10
src/api.ts
@ -4,12 +4,16 @@ const API_URL = '/api';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_URL,
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
const lang = localStorage.getItem('i18nextLng') || (typeof navigator !== 'undefined' ? navigator.language : 'en');
|
||||
if (lang) {
|
||||
config.headers['Accept-Language'] = lang;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
382
src/components/ChangePasswordModal.tsx
Normal file
382
src/components/ChangePasswordModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -8,6 +8,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import api from '../api';
|
||||
import { useAuth } from '../App';
|
||||
import { SecuritySetupCards } from './SecuritySetupCards';
|
||||
import './DeveloperOnboardingModal.css';
|
||||
|
||||
interface DeveloperOnboardingModalProps {
|
||||
@ -45,6 +46,8 @@ export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> =
|
||||
const [loading, setLoading] = useState(false);
|
||||
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;
|
||||
|
||||
// Max steps: 3 for Free, 4 for Paid
|
||||
@ -425,6 +428,18 @@ export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> =
|
||||
</ul>
|
||||
</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 ── */}
|
||||
<div style={{
|
||||
marginTop: '1.25rem',
|
||||
@ -449,7 +464,7 @@ export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> =
|
||||
<div className="dev-btn-row-stacked" style={{ marginTop: '1.25rem' }}>
|
||||
<button
|
||||
className="dev-btn dev-btn-primary"
|
||||
disabled={loading || !acceptedTerms}
|
||||
disabled={loading || !acceptedTerms || !hasSecureAuth}
|
||||
onClick={() => handleCompleteOnboarding(false)}
|
||||
>
|
||||
{loading ? 'Completing registration...' : t('developer.onboarding.btn_complete')}
|
||||
@ -473,6 +488,18 @@ export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> =
|
||||
</ul>
|
||||
</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 ── */}
|
||||
<div style={{
|
||||
marginTop: '1.25rem',
|
||||
@ -497,14 +524,14 @@ export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> =
|
||||
<div className="dev-btn-row-stacked" style={{ marginTop: '1.25rem' }}>
|
||||
<button
|
||||
className="dev-btn dev-btn-stripe"
|
||||
disabled={loading || !acceptedTerms}
|
||||
disabled={loading || !acceptedTerms || !hasSecureAuth}
|
||||
onClick={() => handleCompleteOnboarding(true)}
|
||||
>
|
||||
<CreditCard size={16} /> {loading ? 'Connecting Stripe...' : t('developer.onboarding.btn_connect_stripe')}
|
||||
</button>
|
||||
<button
|
||||
className="dev-btn dev-btn-secondary"
|
||||
disabled={loading || !acceptedTerms}
|
||||
disabled={loading || !acceptedTerms || !hasSecureAuth}
|
||||
onClick={() => handleCompleteOnboarding(false)}
|
||||
>
|
||||
{t('developer.onboarding.btn_complete')}
|
||||
|
||||
224
src/components/RecoveryCodesModal.tsx
Normal file
224
src/components/RecoveryCodesModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
689
src/components/SecuritySetupCards.tsx
Normal file
689
src/components/SecuritySetupCards.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -47,8 +47,9 @@ i18n
|
||||
escapeValue: false, // React already escapes values
|
||||
},
|
||||
detection: {
|
||||
order: ['navigator', 'htmlTag'],
|
||||
caches: [], // Always auto-detect based on the user's browser/system region
|
||||
order: ['localStorage', 'navigator', 'htmlTag'],
|
||||
lookupLocalStorage: 'i18nextLng',
|
||||
caches: ['localStorage'],
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -52,6 +52,26 @@ const REVIEW_REPORT_REASONS = [
|
||||
];
|
||||
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 }) => (
|
||||
<div className="star-rating-container" style={{ display: 'flex', gap: 2 }}>
|
||||
{[...Array(max)].map((_, i) => (
|
||||
@ -818,7 +838,7 @@ const PluginDetail = () => {
|
||||
</div>
|
||||
<div className={`plugin-recent-update-body markdown-content ${isLong ? 'is-clamped' : ''}`}>
|
||||
{latest.release_notes ? (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={safeMarkdownComponents}>
|
||||
{latest.release_notes}
|
||||
</ReactMarkdown>
|
||||
) : (
|
||||
@ -843,7 +863,7 @@ const PluginDetail = () => {
|
||||
|
||||
{/* DESCRIPTION */}
|
||||
<div className="markdown-content">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{currentDescription}</ReactMarkdown>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={safeMarkdownComponents}>{currentDescription}</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
<hr className="divider" />
|
||||
@ -1432,7 +1452,7 @@ const PluginDetail = () => {
|
||||
</div>
|
||||
<div className="markdown-content" style={{ color: 'var(--text)', fontSize: '0.95rem', lineHeight: 1.6 }}>
|
||||
{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>
|
||||
)}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, useLocation, Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import api from '../api';
|
||||
import { useAuth } from '../App';
|
||||
@ -7,12 +8,16 @@ import { getCodeList } from 'country-list';
|
||||
import DeveloperOnboardingModal from '../components/DeveloperOnboardingModal';
|
||||
import { getPluginUrl } from '../utils/url';
|
||||
import { openCookiePreferencesModal } from '../utils/consent';
|
||||
import { startRegistration } from '@simplewebauthn/browser';
|
||||
import { RecoveryCodesModal } from '../components/RecoveryCodesModal';
|
||||
import { ChangePasswordModal } from '../components/ChangePasswordModal';
|
||||
import {
|
||||
User, Mail, Lock, Shield, CreditCard,
|
||||
BookOpen, AlertTriangle, LogOut, CheckCircle,
|
||||
AlertCircle, Eye, EyeOff, ChevronRight, Bell,
|
||||
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';
|
||||
|
||||
interface LibraryEntry {
|
||||
@ -144,6 +149,7 @@ const PwInput: React.FC<PwInputProps> = ({ placeholder, value, onChange, show, o
|
||||
|
||||
const ProfilePage = () => {
|
||||
const { user, login, logout, refreshUser } = useAuth();
|
||||
const { i18n } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
@ -157,13 +163,9 @@ const ProfilePage = () => {
|
||||
}, [location]);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
username: '', email: '', country: '',
|
||||
password: '', currentPassword: '', disable2faPassword: '',
|
||||
newPassword: '', confirmPassword: '',
|
||||
username: '', email: '', country: '', language: '',
|
||||
password: '', disable2faPassword: '',
|
||||
});
|
||||
const [showCurrentPw, setShowCurrentPw] = useState(false);
|
||||
const [showNewPw, setShowNewPw] = useState(false);
|
||||
const [showConfirmPw, setShowConfirmPw] = useState(false);
|
||||
const [show2faPw, setShow2faPw] = useState(false);
|
||||
|
||||
const [toast, setToast] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
|
||||
@ -197,10 +199,11 @@ const ProfilePage = () => {
|
||||
username: user.username || '',
|
||||
email: user.email || '',
|
||||
country: user.country || '',
|
||||
language: user.preferred_language || prev.language || (i18n.language ? i18n.language.split('-')[0] : 'en'),
|
||||
}));
|
||||
setIs2faEnabled(user.totp_enabled || false);
|
||||
}
|
||||
}, [user]);
|
||||
}, [user, i18n.language]);
|
||||
|
||||
// Developer Profile State
|
||||
const [isDevModalOpen, setIsDevModalOpen] = useState(false);
|
||||
@ -251,12 +254,111 @@ const ProfilePage = () => {
|
||||
const [revokingSessionId, setRevokingSessionId] = useState<string | null>(null);
|
||||
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(() => {
|
||||
if (activeTab === 'security') {
|
||||
fetchSessions();
|
||||
fetchPasskeys();
|
||||
fetchRecoveryStatus();
|
||||
}
|
||||
}, [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 () => {
|
||||
setSessionsLoading(true);
|
||||
try {
|
||||
@ -316,19 +418,6 @@ const ProfilePage = () => {
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
await withSaving('accountInfo', async () => {
|
||||
@ -357,7 +446,8 @@ const ProfilePage = () => {
|
||||
|
||||
if (formData.country && formData.country !== user?.country) {
|
||||
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;
|
||||
} catch (err: any) {
|
||||
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) {
|
||||
showToast('Account details updated successfully!');
|
||||
refreshUser?.();
|
||||
@ -445,22 +547,29 @@ const ProfilePage = () => {
|
||||
|
||||
const confirm2faSetup = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setVerify2faError('');
|
||||
setVerify2faLoading(true);
|
||||
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);
|
||||
setSetup2faData(null);
|
||||
setVerify2faCode('');
|
||||
setVerify2faError('');
|
||||
showToast('2FA enabled successfully!');
|
||||
refreshUser?.();
|
||||
} 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) => {
|
||||
e.preventDefault();
|
||||
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);
|
||||
setFormData({ ...formData, disable2faPassword: '' });
|
||||
showToast('2FA has been disabled.');
|
||||
@ -564,6 +673,7 @@ const ProfilePage = () => {
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: '1.25rem' }}>
|
||||
<Field label="Country">
|
||||
<select
|
||||
id="accountCountry"
|
||||
@ -583,6 +693,31 @@ const ProfilePage = () => {
|
||||
</select>
|
||||
</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' }}>
|
||||
<SaveBtn isSaving={saving['accountInfo']} label="Save Changes" />
|
||||
</div>
|
||||
@ -602,59 +737,149 @@ const ProfilePage = () => {
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<SettingsCard title="Change Password" description="Use a strong, unique password you don't use elsewhere." icon={Key}>
|
||||
<form onSubmit={e => {
|
||||
if (formData.newPassword !== formData.confirmPassword) {
|
||||
e.preventDefault();
|
||||
showToast('Passwords do not match.', 'error');
|
||||
return;
|
||||
}
|
||||
handleUpdate(e, '/user/settings', {
|
||||
currentPassword: formData.currentPassword,
|
||||
newPassword: formData.newPassword,
|
||||
}, 'password');
|
||||
<SettingsCard title="Password" description="Use a strong, unique password you don't use elsewhere." icon={Key}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '0.75rem' }}>
|
||||
<div style={{ fontSize: '0.9rem', color: 'var(--mp-muted, #94a3b8)', flex: 1 }}>
|
||||
Protect your account with a strong password. You can also choose to log out of other active devices when updating your password.
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="settings-btn settings-btn-primary"
|
||||
onClick={() => setShowChangePasswordModal(true)}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: '0.4rem', padding: '0.5rem 0.9rem', fontSize: '0.85rem' }}
|
||||
>
|
||||
<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">
|
||||
<PwInput
|
||||
id="currentPassword"
|
||||
name="currentPassword"
|
||||
placeholder="Current password"
|
||||
value={formData.currentPassword}
|
||||
onChange={v => setFormData({ ...formData, currentPassword: v })}
|
||||
show={showCurrentPw}
|
||||
onToggle={() => setShowCurrentPw(s => !s)}
|
||||
autoComplete="current-password"
|
||||
<p style={{ margin: '0 0 0.75rem 0', fontWeight: 600, fontSize: '0.95rem', color: 'var(--mp-text, #fff)' }}>
|
||||
Register a New Passkey
|
||||
</p>
|
||||
<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.">
|
||||
<input
|
||||
type="text"
|
||||
className="settings-input"
|
||||
placeholder="e.g. MacBook Pro Touch ID"
|
||||
value={newPasskeyName}
|
||||
onChange={e => setNewPasskeyName(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
<Field label="New Password">
|
||||
<PwInput
|
||||
id="newPassword"
|
||||
name="newPassword"
|
||||
placeholder="New password"
|
||||
value={formData.newPassword}
|
||||
onChange={v => setFormData({ ...formData, newPassword: v })}
|
||||
show={showNewPw}
|
||||
onToggle={() => setShowNewPw(s => !s)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Confirm New Password">
|
||||
<PwInput
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
placeholder="Repeat new password"
|
||||
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" />
|
||||
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '1rem' }}>
|
||||
<button
|
||||
type="submit"
|
||||
className="settings-btn settings-btn-primary"
|
||||
disabled={passkeyRegistering}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: '0.5rem' }}
|
||||
>
|
||||
<Fingerprint size={16} /> {passkeyRegistering ? 'Prompting Device...' : 'Continue with Biometrics'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="settings-btn settings-btn-danger-outline"
|
||||
onClick={() => { setShowAddPasskeyModal(false); setNewPasskeyName(''); }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
{/* 2FA */}
|
||||
@ -666,6 +891,14 @@ const ProfilePage = () => {
|
||||
<CheckCircle size={16} />
|
||||
<span>2FA is currently <strong>enabled</strong> on your account.</span>
|
||||
</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' }}>
|
||||
<Field label="Confirm with your password to disable">
|
||||
<PwInput
|
||||
@ -683,6 +916,7 @@ const ProfilePage = () => {
|
||||
Disable 2FA
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
) : !setup2faData ? (
|
||||
<div>
|
||||
@ -705,7 +939,7 @@ const ProfilePage = () => {
|
||||
<code className="settings-secret">{setup2faData.secret}</code>
|
||||
</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
|
||||
type="text"
|
||||
id="verify2faCode"
|
||||
@ -714,19 +948,125 @@ const ProfilePage = () => {
|
||||
placeholder="000 000"
|
||||
maxLength={8}
|
||||
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"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
required
|
||||
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>
|
||||
</div>
|
||||
)}
|
||||
</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 */}
|
||||
<SettingsCard
|
||||
title="Active Devices & Sessions"
|
||||
@ -1341,6 +1681,21 @@ const ProfilePage = () => {
|
||||
setActiveTab('developer');
|
||||
}}
|
||||
/>
|
||||
|
||||
<RecoveryCodesModal
|
||||
isOpen={showCodesModal}
|
||||
codes={generatedCodes}
|
||||
onClose={() => setShowCodesModal(false)}
|
||||
/>
|
||||
|
||||
<ChangePasswordModal
|
||||
isOpen={showChangePasswordModal}
|
||||
onClose={() => setShowChangePasswordModal(false)}
|
||||
onSuccess={() => {
|
||||
showToast('Password changed successfully.', 'success');
|
||||
fetchSessions();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -2,6 +2,8 @@ import React, { useState, useRef } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Turnstile } from '@marsidev/react-turnstile';
|
||||
import { startAuthentication } from '@simplewebauthn/browser';
|
||||
import { Fingerprint, AlertCircle } from 'lucide-react';
|
||||
import api from '../../api';
|
||||
import { useAuth } from '../../App';
|
||||
|
||||
@ -19,6 +21,9 @@ const LoginPage = () => {
|
||||
const [requires2fa, setRequires2fa] = useState(false);
|
||||
const [tempToken, setTempToken] = useState<string | null>(null);
|
||||
const [totpCode, setTotpCode] = useState('');
|
||||
const [useRecoveryCode, setUseRecoveryCode] = useState(false);
|
||||
const [recoveryCode, setRecoveryCode] = useState('');
|
||||
const [codeError, setCodeError] = useState('');
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { login } = useAuth();
|
||||
@ -46,15 +51,25 @@ const LoginPage = () => {
|
||||
if (requires2fa) {
|
||||
setIsSubmitting(true);
|
||||
setError('');
|
||||
setCodeError('');
|
||||
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', {
|
||||
temp_token: tempToken,
|
||||
code: totpCode,
|
||||
});
|
||||
login(res.data.token);
|
||||
navigate('/dashboard');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Invalid 2FA code.');
|
||||
setCodeError(err.response?.data?.error || (useRecoveryCode ? 'Invalid recovery code' : 'Invalid 2FA code'));
|
||||
} finally {
|
||||
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 emailIsValid = emailTouched && emailValid === true;
|
||||
|
||||
@ -341,7 +386,7 @@ const LoginPage = () => {
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
{error && (
|
||||
{!requires2fa && error && (
|
||||
<div className="error-banner">
|
||||
<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"/>
|
||||
@ -356,8 +401,12 @@ const LoginPage = () => {
|
||||
|
||||
{requires2fa ? (
|
||||
<div className="form-field">
|
||||
{!useRecoveryCode ? (
|
||||
<>
|
||||
<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">
|
||||
<input
|
||||
id="totpCode"
|
||||
@ -367,19 +416,131 @@ const LoginPage = () => {
|
||||
placeholder="000000"
|
||||
maxLength={8}
|
||||
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"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
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 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>
|
||||
) : (
|
||||
<>
|
||||
<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">
|
||||
<label className="field-label" htmlFor="email">{t('auth.email')}</label>
|
||||
<div className="field-input-wrapper">
|
||||
@ -456,11 +617,34 @@ const LoginPage = () => {
|
||||
</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 ? (
|
||||
<><span className="spinner" />{t('common.loading')}</>
|
||||
) : codeError ? (
|
||||
<>
|
||||
<AlertCircle size={18} />
|
||||
<span>{codeError} — Try Again</span>
|
||||
</>
|
||||
) : requires2fa ? (
|
||||
'Verify'
|
||||
useRecoveryCode ? 'Verify Recovery Code' : 'Verify & Sign In'
|
||||
) : (
|
||||
t('auth.sign_in')
|
||||
)}
|
||||
|
||||
@ -28,7 +28,7 @@ function getStrength(pw: string): { score: number; label: string; color: string
|
||||
}
|
||||
|
||||
const RegisterPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const [form, setForm] = useState({ username: '', email: '', password: '', website: '' });
|
||||
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
|
||||
const turnstileRef = useRef<any>(null);
|
||||
@ -98,7 +98,8 @@ const RegisterPage = () => {
|
||||
}
|
||||
|
||||
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 } });
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Registration failed. Please try again.');
|
||||
|
||||
@ -1,12 +1,16 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../App';
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { login } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
const verify = async () => {
|
||||
@ -22,17 +26,11 @@ const VerifyEmailPage = () => {
|
||||
try {
|
||||
const res = await api.get(`/auth/verify-email?token=${token}`);
|
||||
|
||||
// Assuming the API returns a token and user object on success
|
||||
localStorage.setItem('token', res.data.token);
|
||||
localStorage.setItem('user', JSON.stringify(res.data.user));
|
||||
if (res.data.token) {
|
||||
login(res.data.token);
|
||||
}
|
||||
|
||||
setStatus('success');
|
||||
|
||||
// Redirect to home page after a short delay
|
||||
setTimeout(() => {
|
||||
navigate('/');
|
||||
}, 3000);
|
||||
|
||||
} catch (err: any) {
|
||||
setStatus('error');
|
||||
setError(err.response?.data?.error || 'Verification failed. The link may be invalid or expired.');
|
||||
@ -40,22 +38,85 @@ const VerifyEmailPage = () => {
|
||||
};
|
||||
|
||||
verify();
|
||||
}, [location, navigate]);
|
||||
}, [location]);
|
||||
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '50px' }}>
|
||||
{status === 'verifying' && <h1>Verifying your email...</h1>}
|
||||
{status === 'success' && (
|
||||
<div>
|
||||
<h1>Email Verified Successfully!</h1>
|
||||
<p>You are now logged in. Redirecting to the homepage...</p>
|
||||
<div style={{
|
||||
minHeight: '80vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
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>
|
||||
)}
|
||||
|
||||
{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' && (
|
||||
<div>
|
||||
<h1>Verification Failed</h1>
|
||||
<p>{error}</p>
|
||||
<p>Please try registering again or contact support.</p>
|
||||
<div style={{
|
||||
maxWidth: 480,
|
||||
width: '100%',
|
||||
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>
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
import api from '../../../api';
|
||||
import { useAuth } from '../../../App';
|
||||
import { DeveloperOnboardingModal } from '../../../components/DeveloperOnboardingModal';
|
||||
import { SecuritySetupCards } from '../../../components/SecuritySetupCards';
|
||||
import { validateWasmFile, validateAndSanitizeImage } from '../../../utils/fileValidation';
|
||||
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 (
|
||||
<div className="ap-root" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '60vh', padding: '2rem 1rem' }}>
|
||||
<div style={{
|
||||
maxWidth: 520, width: '100%', padding: '2.5rem', background: 'var(--mp-surface, #14171c)',
|
||||
borderRadius: 16, border: '1px solid var(--mp-border, rgba(255,255,255,0.1))', textAlign: 'center',
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.3)'
|
||||
}}>
|
||||
<div style={{
|
||||
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>
|
||||
<SecuritySetupCards
|
||||
title="Developer Security Setup"
|
||||
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."
|
||||
isRequired={true}
|
||||
canSkip={false}
|
||||
onComplete={() => refreshUser?.()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user