import { AddLinkEventTracker, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; import { FC, KeyboardEvent, useEffect, useMemo, useState } from 'react'; import { FaArrowLeft, FaCheckCircle, FaChevronRight, FaEnvelope, FaExclamationTriangle, FaEye, FaEyeSlash, FaIdBadge, FaInfoCircle, FaKey, FaShieldAlt, FaUserCog } from 'react-icons/fa'; import { GetConfigurationValue, getAccessToken } from '../../api'; import { Button, LayoutAvatarImageView, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common'; const MIN_PASSWORD_LENGTH = 8; const MAX_PASSWORD_LENGTH = 128; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const MAX_EMAIL_LENGTH = 254; const USERNAME_RE = /^[A-Za-z0-9._-]{3,25}$/; const MIN_USERNAME_LENGTH = 3; const MAX_USERNAME_LENGTH = 25; type FeedbackKind = 'error' | 'success'; type Section = 'menu' | 'password' | 'email' | 'username'; const passwordStrength = (value: string): { score: number; label: string; color: string } => { if(!value) return { score: 0, label: '', color: 'bg-black/10' }; let score = 0; if(value.length >= MIN_PASSWORD_LENGTH) score++; if(value.length >= 12) score++; if(/[A-Z]/.test(value) && /[a-z]/.test(value)) score++; if(/\d/.test(value)) score++; if(/[^A-Za-z0-9]/.test(value)) score++; if(score <= 1) return { score: 1, label: 'Weak', color: 'bg-[#a81a12]' }; if(score === 2) return { score: 2, label: 'Fair', color: 'bg-[#ffc107]' }; if(score === 3) return { score: 3, label: 'Good', color: 'bg-[#1e7295]' }; return { score: 4, label: 'Strong', color: 'bg-[#00800b]' }; }; export const UserAccountSettingsView: FC<{}> = () => { const [ isVisible, setIsVisible ] = useState(false); const [ section, setSection ] = useState
('menu'); const [ currentPassword, setCurrentPassword ] = useState(''); const [ newPassword, setNewPassword ] = useState(''); const [ confirmPassword, setConfirmPassword ] = useState(''); const [ showCurrent, setShowCurrent ] = useState(false); const [ showNew, setShowNew ] = useState(false); const [ emailCurrentPassword, setEmailCurrentPassword ] = useState(''); const [ newEmail, setNewEmail ] = useState(''); const [ showEmailPassword, setShowEmailPassword ] = useState(false); const [ usernameCurrentPassword, setUsernameCurrentPassword ] = useState(''); const [ newUsername, setNewUsername ] = useState(''); const [ showUsernamePassword, setShowUsernamePassword ] = useState(false); const [ submitting, setSubmitting ] = useState(false); const [ feedback, setFeedback ] = useState<{ kind: FeedbackKind; message: string } | null>(null); const session = useMemo(() => { try { const manager = GetSessionDataManager(); return { username: manager?.userName ?? '', figure: manager?.figure ?? '' }; } catch { return { username: '', figure: '' }; } }, [ isVisible ]); const strength = useMemo(() => passwordStrength(newPassword), [ newPassword ]); const resetForm = () => { setCurrentPassword(''); setNewPassword(''); setConfirmPassword(''); setShowCurrent(false); setShowNew(false); setEmailCurrentPassword(''); setNewEmail(''); setShowEmailPassword(false); setUsernameCurrentPassword(''); setNewUsername(''); setShowUsernamePassword(false); setFeedback(null); }; const close = () => { setIsVisible(false); setSection('menu'); resetForm(); setSubmitting(false); }; useEffect(() => { const linkTracker: ILinkEventTracker = { linkReceived: (url: string) => { const parts = url.split('/'); if(parts.length < 2) return; switch(parts[1]) { case 'show': setIsVisible(true); return; case 'hide': close(); return; case 'toggle': setIsVisible(prev => !prev); return; } }, eventUrlPrefix: 'user-account-settings/' }; AddLinkEventTracker(linkTracker); return () => RemoveLinkEventTracker(linkTracker); }, []); const submitPasswordChange = async () => { if(submitting) return; setFeedback(null); if(!currentPassword || !newPassword || !confirmPassword) { setFeedback({ kind: 'error', message: 'All fields are required.' }); return; } if(newPassword.length < MIN_PASSWORD_LENGTH) { setFeedback({ kind: 'error', message: `Password must be at least ${ MIN_PASSWORD_LENGTH } characters.` }); return; } if(newPassword.length > MAX_PASSWORD_LENGTH) { setFeedback({ kind: 'error', message: 'Password is too long.' }); return; } if(newPassword !== confirmPassword) { setFeedback({ kind: 'error', message: 'New passwords do not match.' }); return; } if(newPassword === currentPassword) { setFeedback({ kind: 'error', message: 'New password must be different from the current password.' }); return; } const token = getAccessToken(); if(!token) { setFeedback({ kind: 'error', message: 'You are not authenticated. Please log in again.' }); return; } const endpoint = GetConfigurationValue('account.change-password.endpoint', '/api/auth/change-password'); setSubmitting(true); try { const response = await fetch(endpoint, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'Authorization': `Bearer ${ token }`, 'X-Requested-With': 'NitroUserAccountSettings' }, body: JSON.stringify({ currentPassword, newPassword, confirmPassword }) }); let payload: Record = {}; try { payload = await response.json(); } catch {} if(!response.ok) { const message = typeof payload.error === 'string' && payload.error ? payload.error : `Request failed (${ response.status }).`; setFeedback({ kind: 'error', message }); return; } const message = typeof payload.message === 'string' && payload.message ? payload.message : 'Password updated successfully.'; setFeedback({ kind: 'success', message }); setCurrentPassword(''); setNewPassword(''); setConfirmPassword(''); setShowCurrent(false); setShowNew(false); } catch { setFeedback({ kind: 'error', message: 'Could not reach the server. Please try again.' }); } finally { setSubmitting(false); } }; const submitEmailChange = async () => { if(submitting) return; setFeedback(null); if(!emailCurrentPassword || !newEmail) { setFeedback({ kind: 'error', message: 'All fields are required.' }); return; } if(newEmail.length > MAX_EMAIL_LENGTH) { setFeedback({ kind: 'error', message: 'Email address is too long.' }); return; } if(!EMAIL_RE.test(newEmail)) { setFeedback({ kind: 'error', message: 'Please enter a valid email address.' }); return; } const token = getAccessToken(); if(!token) { setFeedback({ kind: 'error', message: 'You are not authenticated. Please log in again.' }); return; } const endpoint = GetConfigurationValue('account.change-email.endpoint', '/api/auth/change-email'); setSubmitting(true); try { const response = await fetch(endpoint, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'Authorization': `Bearer ${ token }`, 'X-Requested-With': 'NitroUserAccountSettings' }, body: JSON.stringify({ currentPassword: emailCurrentPassword, newEmail }) }); let payload: Record = {}; try { payload = await response.json(); } catch {} if(!response.ok) { const message = typeof payload.error === 'string' && payload.error ? payload.error : `Request failed (${ response.status }).`; setFeedback({ kind: 'error', message }); return; } const message = typeof payload.message === 'string' && payload.message ? payload.message : 'Email updated successfully.'; setFeedback({ kind: 'success', message }); setEmailCurrentPassword(''); setNewEmail(''); setShowEmailPassword(false); } catch { setFeedback({ kind: 'error', message: 'Could not reach the server. Please try again.' }); } finally { setSubmitting(false); } }; const submitUsernameChange = async () => { if(submitting) return; setFeedback(null); if(!usernameCurrentPassword || !newUsername) { setFeedback({ kind: 'error', message: 'All fields are required.' }); return; } if(newUsername.length < MIN_USERNAME_LENGTH || newUsername.length > MAX_USERNAME_LENGTH) { setFeedback({ kind: 'error', message: `Username must be between ${ MIN_USERNAME_LENGTH } and ${ MAX_USERNAME_LENGTH } characters.` }); return; } if(!USERNAME_RE.test(newUsername)) { setFeedback({ kind: 'error', message: 'Username may only contain letters, numbers, dot, underscore and dash.' }); return; } if(newUsername === session.username) { setFeedback({ kind: 'error', message: 'New username must be different from the current one.' }); return; } const token = getAccessToken(); if(!token) { setFeedback({ kind: 'error', message: 'You are not authenticated. Please log in again.' }); return; } const endpoint = GetConfigurationValue('account.change-username.endpoint', '/api/auth/change-username'); setSubmitting(true); try { const response = await fetch(endpoint, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'Authorization': `Bearer ${ token }`, 'X-Requested-With': 'NitroUserAccountSettings' }, body: JSON.stringify({ currentPassword: usernameCurrentPassword, newUsername }) }); let payload: Record = {}; try { payload = await response.json(); } catch {} if(!response.ok) { const message = typeof payload.error === 'string' && payload.error ? payload.error : `Request failed (${ response.status }).`; setFeedback({ kind: 'error', message }); return; } const message = typeof payload.message === 'string' && payload.message ? payload.message : 'Username updated. Please log in again with your new name.'; setFeedback({ kind: 'success', message }); setUsernameCurrentPassword(''); setNewUsername(''); setShowUsernamePassword(false); // The server has dropped our session — clear local credentials and bounce // the user back to the login screen so the whole client reloads cleanly. try { window.localStorage.removeItem('nitro.access.token'); } catch {} try { window.localStorage.removeItem('nitro.access.token.exp'); } catch {} window.setTimeout(() => { try { window.location.reload(); } catch {} }, 2500); } catch { setFeedback({ kind: 'error', message: 'Could not reach the server. Please try again.' }); } finally { setSubmitting(false); } }; if(!isVisible) return null; return (
{ session.figure && (
) }
My account { session.username || 'Guest' } Manage your account and security
{ section === 'menu' && (
Account
More coming soon Two-factor authentication and more.
) } { section === 'password' && (
) => { if(event.key === 'Enter') { event.preventDefault(); submitPasswordChange(); } } }>
Reset password
Use at least { MIN_PASSWORD_LENGTH } characters. Mix upper & lowercase, numbers and symbols for a stronger password.