diff --git a/src/App.tsx b/src/App.tsx index 0a77cbf..27f6ec9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import { GetAssetManager, GetAvatarRenderManager, GetCommunication, GetConfiguration, GetLocalizationManager, GetRoomEngine, GetRoomSessionManager, GetSessionDataManager, GetSoundManager, GetStage, GetTexturePool, GetTicker, HabboWebTools, LegacyExternalInterface, LoadGameUrlEvent, NitroEventType, NitroLogger, NitroVersion, PrepareRenderer } from '@nitrots/nitro-renderer'; import { FC, useCallback, useEffect, useEffectEvent, useRef, useState } from 'react'; -import { ClearRememberLogin, GetRememberLogin, GetUIVersion, StoreRememberLoginFromPayload } from './api'; +import { ClearRememberLogin, GetRememberLogin, GetUIVersion, StoreRememberLoginFromPayload, persistAccessTokenFromPayload } from './api'; import { Base } from './common'; import { LoadingView } from './components/loading/LoadingView'; import { LoginView } from './components/login/LoginView'; @@ -202,6 +202,7 @@ export const App: FC<{}> = props => if(response.ok && ssoTicket) { + persistAccessTokenFromPayload(payload); StoreRememberLoginFromPayload(payload, typeof payload.username === 'string' ? payload.username : remembered.username, ssoTicket); return ssoTicket; } @@ -251,6 +252,7 @@ export const App: FC<{}> = props => if(response.ok) { + persistAccessTokenFromPayload(payload); StoreRememberLoginFromPayload(payload, remembered.username, remembered.ssoTicket); return; } diff --git a/src/components/MainView.tsx b/src/components/MainView.tsx index cc5baf4..8df9234 100644 --- a/src/components/MainView.tsx +++ b/src/components/MainView.tsx @@ -34,6 +34,7 @@ import { ToolbarView } from './toolbar/ToolbarView'; import { TranslationBootstrap } from './translation/TranslationBootstrap'; import { TranslationSettingsView } from './translation/TranslationSettingsView'; import { UserProfileView } from './user-profile/UserProfileView'; +import { UserAccountSettingsView } from './user-settings/UserAccountSettingsView'; import { UserSettingsView } from './user-settings/UserSettingsView'; import { WiredView } from './wired/WiredView'; import { WiredCreatorToolsView } from './wired-tools/WiredCreatorToolsView'; @@ -133,6 +134,7 @@ export const MainView: FC<{}> = props => + diff --git a/src/components/login/LoginView.tsx b/src/components/login/LoginView.tsx index ce7a1b5..37e36df 100644 --- a/src/components/login/LoginView.tsx +++ b/src/components/login/LoginView.tsx @@ -1,7 +1,7 @@ import { AvatarScaleType, AvatarSetType, GetAvatarRenderManager, GetConfiguration, IAvatarImage } from '@nitrots/nitro-renderer'; import { FC, useActionState, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useFormStatus } from 'react-dom'; -import { ClearRememberLogin, GetConfigurationValue, GetRememberLogin, StoreRememberLoginFromPayload } from '../../api'; +import { ClearRememberLogin, GetConfigurationValue, GetRememberLogin, StoreRememberLoginFromPayload, persistAccessTokenFromPayload } from '../../api'; import { configFileUrl } from '../../secure-assets'; import flagBr from '../../assets/images/flag_icon/flag_icon_br.png'; import flagDe from '../../assets/images/flag_icon/flag_icon_de.png'; @@ -527,6 +527,7 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa if(ok && ssoTicket) { clearLock(); + persistAccessTokenFromPayload(payload); if(rememberFlag) StoreRememberLoginFromPayload(payload, typeof payload.username === 'string' ? payload.username : usernameInput, ssoTicket); else ClearRememberLogin(); onAuthenticated(ssoTicket); diff --git a/src/components/user-settings/UserAccountSettingsView.tsx b/src/components/user-settings/UserAccountSettingsView.tsx new file mode 100644 index 0000000..c834ba2 --- /dev/null +++ b/src/components/user-settings/UserAccountSettingsView.tsx @@ -0,0 +1,758 @@ +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. +
+ + + +
+
+ +
);