diff --git a/index.html b/index.html index bd85810..e03e0c5 100644 --- a/index.html +++ b/index.html @@ -9,7 +9,8 @@ - + + Pumpkin Market diff --git a/package-lock.json b/package-lock.json index 664bd49..475eca6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index eb7670a..e172bd1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/App.tsx b/src/App.tsx index 7928cc3..485db1c 100644 --- a/src/App.tsx +++ b/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); - if (payload && payload.exp * 1000 > Date.now()) { - setUser(payload); - } else { + 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); + 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); } - } - 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 = () => { - localStorage.removeItem('token'); - setUser(null); + 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) => { + 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 ( + + ); +}; + const Footer = () => ( ); diff --git a/src/api.ts b/src/api.ts index 432c3a0..6e12ac7 100644 --- a/src/api.ts +++ b/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; }); diff --git a/src/components/ChangePasswordModal.tsx b/src/components/ChangePasswordModal.tsx new file mode 100644 index 0000000..2f564f2 --- /dev/null +++ b/src/components/ChangePasswordModal.tsx @@ -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 = ({ + 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 ( +
{ + if (e.target === e.currentTarget) handleClose(); + }} + > +
+ {/* Close Icon */} + + + {/* Modal Header */} +
+
+ +
+
+

Change Password

+

+ Choose a strong, unique password to secure your account. +

+
+
+ + {/* Error Banner */} + {error && ( +
+ + {error} +
+ )} + + {/* Success Banner */} + {success && ( +
+ + Password successfully updated! Closing... +
+ )} + +
+ {/* Current Password */} +
+ +
+ setCurrentPassword(e.target.value)} + autoComplete="current-password" + required + style={{ width: '100%', paddingRight: '2.5rem' }} + /> + +
+
+ + {/* New Password */} +
+ +
+ setNewPassword(e.target.value)} + autoComplete="new-password" + required + style={{ width: '100%', paddingRight: '2.5rem' }} + /> + +
+
+ + {/* Confirm New Password */} +
+ +
+ setConfirmPassword(e.target.value)} + autoComplete="new-password" + required + style={{ width: '100%', paddingRight: '2.5rem' }} + /> + +
+ {newPassword && confirmPassword && newPassword !== confirmPassword && ( +

+ Passwords do not match +

+ )} +
+ + {/* Log Out Other Devices Checkbox (Checked by default) */} +
setLogoutOtherDevices((v) => !v)} + > + setLogoutOtherDevices(e.target.checked)} + onClick={(e) => e.stopPropagation()} + style={{ + width: 17, + height: 17, + marginTop: '0.15rem', + accentColor: 'var(--mp-accent, #f97316)', + cursor: 'pointer', + }} + /> + +
+ + {/* Modal Actions */} +
+ + +
+
+
+
+ ); +}; diff --git a/src/components/DeveloperOnboardingModal.tsx b/src/components/DeveloperOnboardingModal.tsx index 30dc1a7..01ca1f7 100644 --- a/src/components/DeveloperOnboardingModal.tsx +++ b/src/components/DeveloperOnboardingModal.tsx @@ -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 = const [loading, setLoading] = useState(false); const [error, setError] = useState(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 = + {/* ── Security Requirement for Developers ── */} + {!hasSecureAuth && ( +
+ {}} + /> +
+ )} + {/* ── Mandatory Legal Consent ── */}
=
+ {/* ── Security Requirement for Developers ── */} + {!hasSecureAuth && ( +
+ {}} + /> +
+ )} + {/* ── Mandatory Legal Consent ── */}
=
+ + +
+ + {/* Confirmation Button */} + +
+
+ ); +}; diff --git a/src/components/SecuritySetupCards.tsx b/src/components/SecuritySetupCards.tsx new file mode 100644 index 0000000..6c6ef7d --- /dev/null +++ b/src/components/SecuritySetupCards.tsx @@ -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 = ({ + 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([]); + const [showRecoveryModal, setShowRecoveryModal] = useState(false); + + // Passkey State + const [passkeyName, setPasskeyName] = useState(''); + const [passkeyLoading, setPasskeyLoading] = useState(false); + const [passkeyError, setPasskeyError] = useState(null); + const [passkeySuccess, setPasskeySuccess] = useState(false); + + // TOTP State + const [totpLoading, setTotpLoading] = useState(false); + const [totpQr, setTotpQr] = useState(null); + const [totpSecret, setTotpSecret] = useState(null); + const [totpCode, setTotpCode] = useState(''); + const [totpVerifying, setTotpVerifying] = useState(false); + const [totpError, setTotpError] = useState(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 ( +
+ {/* Header */} +
+
+ +
+

+ {title} +

+

+ {description} +

+ + {isRequired && ( +
+ Required for Plugin Publishing & Developer Verification +
+ )} +
+ + {/* Boxes Grid */} +
+ {/* ── Box 1: Passkey / Biometrics (Recommended) ── */} +
+ + + {/* Passkey Expanded Content */} + {expandedBox === 'passkey' && ( +
+
+ 💡 How it works: 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. +
+ + {passkeyError && ( +
+ + {passkeyError} +
+ )} + + {passkeySuccess ? ( +
+ + Passkey created successfully! Redirecting... +
+ ) : ( +
+
+ + 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', + }} + /> +
+ +
+ )} +
+ )} +
+ + {/* ── Box 2: Authenticator App (TOTP 2FA) ── */} +
+ + + {/* TOTP Expanded Content */} + {expandedBox === 'totp' && ( +
+ {totpLoading ? ( +
+ +

Generating 2FA Secret & QR Code...

+
+ ) : totpSuccess ? ( +
+ + Two-Factor Authentication enabled successfully! Redirecting... +
+ ) : ( +
+
+ {totpQr && ( +
+ 2FA QR Code +
+ )} +
+

+ 1. Scan this QR Code with your Authenticator App +

+

+ Or manually copy the setup key below if you cannot scan the code: +

+ {totpSecret && ( +
+ + {totpSecret} + + +
+ )} +
+
+ +
+
+ + { + 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', + }} + /> +
+ +
+
+ )} +
+ )} +
+
+ + {/* Footer / Skip Actions */} +
+ {canSkip ? ( + <> + + {hasSecureAuth && ( + + )} + + ) : ( + hasSecureAuth && ( + + ) + )} +
+ + { + setShowRecoveryModal(false); + onComplete?.(); + }} + /> +
+ ); +}; diff --git a/src/i18n.ts b/src/i18n.ts index 09b4cf8..4cd11b3 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -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'], }, }); diff --git a/src/pages/PluginDetail.tsx b/src/pages/PluginDetail.tsx index 077cf86..3d568ac 100644 --- a/src/pages/PluginDetail.tsx +++ b/src/pages/PluginDetail.tsx @@ -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 {children}; + } + const isExternal = href.startsWith('http://') || href.startsWith('https://'); + return ( + + {children} + + ); + }, +}; + const StarRating = ({ rating, max = 5, onRate }: { rating: number; max?: number; onRate?: (n: number) => void }) => (
{[...Array(max)].map((_, i) => ( @@ -818,7 +838,7 @@ const PluginDetail = () => {
{latest.release_notes ? ( - + {latest.release_notes} ) : ( @@ -843,7 +863,7 @@ const PluginDetail = () => { {/* DESCRIPTION */}
- {currentDescription} + {currentDescription}

@@ -1432,7 +1452,7 @@ const PluginDetail = () => {
{v.release_notes ? ( - {v.release_notes} + {v.release_notes} ) : (

No specific release notes for this update.

)} diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index e358869..4f170d1 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from 'react'; import { useNavigate, useLocation, Link } from 'react-router-dom'; +import { 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 = ({ 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(null); const [revokingAllSessions, setRevokingAllSessions] = useState(false); + const [passkeys, setPasskeys] = useState([]); + 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([]); + 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,24 +673,50 @@ const ProfilePage = () => {
- - - +
+ + + + + + + +
@@ -602,59 +737,149 @@ const ProfilePage = () => {
{/* Password */} - -
{ - 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'); - }}> - - setFormData({ ...formData, currentPassword: v })} - show={showCurrentPw} - onToggle={() => setShowCurrentPw(s => !s)} - autoComplete="current-password" - /> - - - setFormData({ ...formData, newPassword: v })} - show={showNewPw} - onToggle={() => setShowNewPw(s => !s)} - autoComplete="new-password" - /> - - - setFormData({ ...formData, confirmPassword: v })} - show={showConfirmPw} - onToggle={() => setShowConfirmPw(s => !s)} - autoComplete="new-password" - /> - - {formData.newPassword && formData.confirmPassword && formData.newPassword !== formData.confirmPassword && ( -

Passwords do not match

- )} - - + +
+
+ Protect your account with a strong password. You can also choose to log out of other active devices when updating your password. +
+ +
+
+ + {/* Passkeys */} + +
+
+ Passkeys provide phishing-resistant security backed by hardware cryptographic keys on your device. +
+ +
+ + {showAddPasskeyModal && ( +
+

+ Register a New Passkey +

+ + setNewPasskeyName(e.target.value)} + autoFocus + /> + +
+ + +
+
+ )} + + {passkeysLoading ? ( +

Loading registered passkeys...

+ ) : passkeys.length === 0 ? ( +
+ + No passkeys registered yet. Add one to enable passwordless biometrics. +
+ ) : ( +
+ {passkeys.map(pk => ( +
+
+
+ +
+
+
+ {pk.name} +
+
+ Added {pk.createdAt ? new Date(pk.createdAt).toLocaleDateString() : 'recently'} + {pk.lastUsedAt && ` • Last used ${new Date(pk.lastUsedAt).toLocaleDateString()}`} +
+
+
+ +
+ ))} +
+ )}
{/* 2FA */} @@ -666,23 +891,32 @@ const ProfilePage = () => { 2FA is currently enabled on your account. -
- - setFormData({ ...formData, disable2faPassword: v })} - show={show2faPw} - onToggle={() => setShow2faPw(s => !s)} - autoComplete="current-password" - /> - - -
+ {user?.role === 'admin' || user?.role === 'moderator' ? ( +
+ + + Staff members ({user.role === 'admin' ? 'Administrator' : 'Moderator'}) are required to keep 2FA enabled for platform security. + +
+ ) : ( +
+ + setFormData({ ...formData, disable2faPassword: v })} + show={show2faPw} + onToggle={() => setShow2faPw(s => !s)} + autoComplete="current-password" + /> + + +
+ )} ) : !setup2faData ? (
@@ -705,7 +939,7 @@ const ProfilePage = () => { {setup2faData.secret}

2 Enter the 6-digit code from your app:

-
+ { 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, + }} /> - +
)}
+ {/* Recovery Codes */} + + {recoveryLoading ? ( +

Loading recovery codes status...

+ ) : ( +
+
+ {recoveryStatus?.hasCodes ? ( + You have {recoveryStatus.remaining} of {recoveryStatus.total} recovery codes remaining. + ) : ( + No active recovery codes. Generate a set of backup codes to prevent getting locked out. + )} +
+ +
+ )} + + {showRegenModal && ( +
+

+ Confirm Password to Generate Recovery Codes +

+

+ Generating new recovery codes will automatically invalidate all previously generated codes. +

+ + setRegenPassword(e.target.value)} + autoFocus + required + /> + +
+ + +
+
+ )} +
+ {/* Active Devices & Sessions */} { setActiveTab('developer'); }} /> + + setShowCodesModal(false)} + /> + + setShowChangePasswordModal(false)} + onSuccess={() => { + showToast('Password changed successfully.', 'success'); + fetchSessions(); + }} + /> ); }; diff --git a/src/pages/auth/LoginPage.tsx b/src/pages/auth/LoginPage.tsx index 6dde190..c12065f 100644 --- a/src/pages/auth/LoginPage.tsx +++ b/src/pages/auth/LoginPage.tsx @@ -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(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 { - const res = await api.post('/auth/login/2fa', { - temp_token: tempToken, - code: totpCode, - }); - login(res.data.token); - navigate('/dashboard'); + 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 = () => {
- {error && ( + {!requires2fa && error && (
@@ -356,30 +401,146 @@ const LoginPage = () => { {requires2fa ? (
- -

Enter the 6-digit code from your authenticator app.

-
- 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)' }} - /> -
+ {!useRecoveryCode ? ( + <> + +

+ Enter the 6-digit code from your authenticator app. +

+
+ { + 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)', + borderColor: codeError ? '#ef4444' : undefined, + boxShadow: codeError ? '0 0 0 3px rgba(239, 68, 68, 0.2)' : undefined, + }} + /> +
+
+ +
+ + ) : ( + <> + +

+ Enter one of your 12-character backup recovery codes. +

+
+ { + 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, + }} + /> +
+
+ +
+ + )}
) : ( <> + + +
+
+ or email & password +
+
+
@@ -456,11 +617,34 @@ const LoginPage = () => {
)} -
)}
diff --git a/src/pages/dashboard/plugin/AddPlugin.tsx b/src/pages/dashboard/plugin/AddPlugin.tsx index d559901..bb83d17 100644 --- a/src/pages/dashboard/plugin/AddPlugin.tsx +++ b/src/pages/dashboard/plugin/AddPlugin.tsx @@ -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 (
-
-
- -
-

- Two-Factor Authentication Required -

-

- To protect Minecraft servers and prevent unauthorized plugin uploads from compromised accounts, all developers must enable Two-Factor Authentication (2FA) before publishing plugins. -

- -
+ refreshUser?.()} + />
); }