diff --git a/src/components/login/LoginView.tsx b/src/components/login/LoginView.tsx index adb8dbb..1177dc9 100644 --- a/src/components/login/LoginView.tsx +++ b/src/components/login/LoginView.tsx @@ -1,5 +1,6 @@ import { AvatarScaleType, AvatarSetType, GetAvatarRenderManager, GetConfiguration, IAvatarImage } from '@nitrots/nitro-renderer'; -import { FC, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { FC, useActionState, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useFormStatus } from 'react-dom'; import { ClearRememberLogin, GetConfigurationValue, GetRememberLogin, StoreRememberLoginFromPayload } from '../../api'; import { configFileUrl } from '../../secure-assets'; import flagBr from '../../assets/images/flag_icon/flag_icon_br.png'; @@ -172,6 +173,20 @@ const applyLocaleSelection = (locale: LoginLocale): void => catch {} }; +const LoginSubmitButton: FC<{ isEntering: boolean; isLocked: boolean; loginPingingServer: boolean }> = ({ isEntering, isLocked, loginPingingServer }) => +{ + const { pending } = useFormStatus(); + + return ( + + { isEntering ? t('nitro.login.entering', 'Entering…') : (pending || loginPingingServer) ? t('nitro.login.server.checking', 'Checking…') : t('login.title', 'Log in') } + + ); +}; + export interface LoginViewProps { onAuthenticated: (ssoTicket: string) => void; @@ -357,7 +372,7 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa return () => window.clearTimeout(timeout); }, [ info ]); - const lockState = useMemo(() => readLock(), [ submitting ]); + const lockState = readLock(); const now = Date.now(); const isLocked = lockState.lockedUntil > now; @@ -445,45 +460,46 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa } }, [ checkServerReachable ]); - const handleLoginSubmit = useCallback(async (event: FormEvent) => + const loginAction = useCallback(async (_prev: null, formData: FormData): Promise => { - event.preventDefault(); - - if(submitting || isEntering) return; + if(isEntering) return null; const nowTs = Date.now(); - if(nowTs - submitTimeRef.current < 1000) return; + if(nowTs - submitTimeRef.current < 1000) return null; submitTimeRef.current = nowTs; - const state = readLock(); - if(state.lockedUntil > nowTs) + const usernameInput = String(formData.get('username') || '').trim(); + const passwordInput = String(formData.get('password') || ''); + const rememberFlag = formData.get('remember') === 'on'; + + const lockSnapshot = readLock(); + if(lockSnapshot.lockedUntil > nowTs) { - const remaining = Math.ceil((state.lockedUntil - nowTs) / 1000); + const remaining = Math.ceil((lockSnapshot.lockedUntil - nowTs) / 1000); setError(t('nitro.login.error.too_many_attempts', 'Too many attempts. Try again in %seconds%s.', [ 'seconds' ], [ String(remaining) ])); - return; + return null; } - if(!username.trim() || !password) + if(!usernameInput || !passwordInput) { setError(t('nitro.login.error.missing_credentials', 'Please enter both your Habbo name and password.')); - return; + return null; } if(turnstileEnabled && !loginTurnstileToken) { setError(t('nitro.login.error.turnstile', 'Please complete the security check.')); - return; + return null; } setError(null); - setSubmitting(true); try { const { ok, payload } = await postJson(loginUrl, { - username: username.trim(), - password, - remember: rememberMe, + username: usernameInput, + password: passwordInput, + remember: rememberFlag, turnstileToken: turnstileEnabled ? loginTurnstileToken : undefined }); @@ -492,10 +508,10 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa if(ok && ssoTicket) { clearLock(); - if(rememberMe) StoreRememberLoginFromPayload(payload, typeof payload.username === 'string' ? payload.username : username.trim(), ssoTicket); + if(rememberFlag) StoreRememberLoginFromPayload(payload, typeof payload.username === 'string' ? payload.username : usernameInput, ssoTicket); else ClearRememberLogin(); onAuthenticated(ssoTicket); - return; + return null; } recordFailure(); @@ -503,17 +519,17 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa setError(message); resetLoginTurnstile(); } - catch(err) + catch { recordFailure(); setError(t('nitro.login.error.login_unreachable', 'Unable to reach the login service. Please try again.')); resetLoginTurnstile(); } - finally - { - setSubmitting(false); - } - }, [ submitting, isEntering, username, password, rememberMe, turnstileEnabled, loginTurnstileToken, loginUrl, postJson, clearLock, recordFailure, onAuthenticated, resetLoginTurnstile, pingLoginServer ]); + + return null; + }, [ isEntering, turnstileEnabled, loginTurnstileToken, loginUrl, postJson, clearLock, recordFailure, onAuthenticated, resetLoginTurnstile ]); + + const [ , submitLoginAction, isLoginPending ] = useActionState(loginAction, null); const checkEmailUrl = GetConfigurationValue('login.check-email.endpoint', '/api/auth/check-email'); const checkUsernameUrl = GetConfigurationValue('login.check-username.endpoint', '/api/auth/check-username'); @@ -735,7 +751,7 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa { t('nitro.login.card.title', 'What\'s your Habbo called?') } - + { t('login.username', 'Name of your Habbo') } = ({ onAuthenticated, isEntering = fa setRememberMe(e.target.checked) } /> @@ -788,11 +805,7 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa { error && { error } } { info && { info } } - { isEntering ? t('nitro.login.entering', 'Entering…') : loginPingingServer ? t('nitro.login.server.checking', 'Checking…') : t('login.title', 'Log in') } + setMode('forgot') }>{ t('login.forgot_password', 'Forgotten your password?') } @@ -840,7 +853,7 @@ interface DialogSharedProps interface RegisterDialogProps extends DialogSharedProps { - onSubmit: (body: { username: string; email: string; password: string; figure: string; gender: string; turnstileToken: string; }, onDialogReset: () => void) => void; + onSubmit: (body: { username: string; email: string; password: string; figure: string; gender: string; turnstileToken: string; }, onDialogReset: () => void) => Promise | void; onCheckEmail: (email: string) => Promise<{ available: boolean; error?: string }>; onCheckUsername: (username: string) => Promise<{ available: boolean; error?: string }>; onCheckServer: () => Promise; @@ -1067,7 +1080,6 @@ const RegisterDialog: FC = props => const [ gender, setGender ] = useState('F'); const [ selection, setSelection ] = useState(() => ({ ...FALLBACK_DEFAULTS.F })); const [ localError, setLocalError ] = useState(null); - const [ checking, setChecking ] = useState(false); const [ turnstileToken, setTurnstileToken ] = useState(''); const [ resetSignal, setResetSignal ] = useState(0); const [ serverReachable, setServerReachable ] = useState(null); @@ -1236,54 +1248,50 @@ const RegisterDialog: FC = props => password.length >= 8 && password === confirm; - const handleCredentialsNext = async (event: FormEvent) => + const credentialsAction = useCallback(async (_prev: null, _formData: FormData): Promise => { - event.preventDefault(); setLocalError(null); if(!email.trim() || !password || !confirm) { setLocalError(t('nitro.login.register.error.missing_fields', 'Please fill in every field.')); - return; + return null; } if(!EMAIL_REGEX.test(email.trim())) { setLocalError(t('nitro.login.register.error.invalid_email', 'Please enter a valid email address.')); - return; + return null; } if(password.length < 8) { setLocalError(t('nitro.login.register.error.password_too_short', 'Your password must be at least 8 characters.')); - return; + return null; } if(password !== confirm) { setLocalError(t('nitro.login.register.error.password_mismatch', 'Passwords do not match.')); - return; + return null; } - setChecking(true); - try + const serverOk = await pingServer(); + if(!serverOk) { - const serverOk = await pingServer(); - if(!serverOk) - { - setLocalError(t('nitro.login.error.server_offline', 'The gameserver is not running. Please try again later.')); - return; - } - const result = await onCheckEmail(email.trim()); - if(!result.available) - { - setLocalError(result.error || t('nitro.login.error.email_taken', 'This email is already in use.')); - return; - } - setStep('avatar'); + setLocalError(t('nitro.login.error.server_offline', 'The gameserver is not running. Please try again later.')); + return null; } - finally + + const result = await onCheckEmail(email.trim()); + if(!result.available) { - setChecking(false); + setLocalError(result.error || t('nitro.login.error.email_taken', 'This email is already in use.')); + return null; } - }; + + setStep('avatar'); + return null; + }, [ email, password, confirm, pingServer, onCheckEmail ]); + + const [ , submitCredentialsAction, isCredentialsPending ] = useActionState(credentialsAction, null); const applyGender = (newGender: GenderKey) => { @@ -1345,61 +1353,57 @@ const RegisterDialog: FC = props => const figure = buildFigureString(selection); const previewSrc = useAvatarPreview(figure, gender, AvatarSetType.FULL); - const handleAvatarSubmit = async (event: FormEvent) => + const avatarAction = useCallback(async (_prev: null, _formData: FormData): Promise => { - event.preventDefault(); setLocalError(null); const trimmed = username.trim(); if(!trimmed) { setLocalError(t('nitro.login.register.error.username_required', 'Please choose a Habbo name.')); - return; + return null; } if(trimmed.length < 3 || trimmed.length > 16) { setLocalError(t('nitro.login.register.error.username_length', 'Habbo name must be 3–16 characters.')); - return; + return null; } if(turnstileEnabled && !turnstileToken) { setLocalError(t('nitro.login.error.turnstile', 'Please complete the security check.')); - return; + return null; } - setChecking(true); - try + const serverOk = await pingServer(); + if(!serverOk) { - const serverOk = await pingServer(); - if(!serverOk) - { - setLocalError(t('nitro.login.error.server_offline', 'The gameserver is not running. Please try again later.')); - return; - } - const result = await onCheckUsername(trimmed); - if(!result.available) - { - setLocalError(result.error || t('nitro.login.error.username_taken', 'This Habbo name is already taken.')); - return; - } - } - finally - { - setChecking(false); + setLocalError(t('nitro.login.error.server_offline', 'The gameserver is not running. Please try again later.')); + return null; } - onSubmit({ + const result = await onCheckUsername(trimmed); + if(!result.available) + { + setLocalError(result.error || t('nitro.login.error.username_taken', 'This Habbo name is already taken.')); + return null; + } + + await onSubmit({ username: trimmed, email: email.trim(), password, - figure, + figure: buildFigureString(selection), gender, turnstileToken }, resetWidget); - }; - const busy = submitting || checking || pingingServer; + return null; + }, [ username, turnstileEnabled, turnstileToken, pingServer, onCheckUsername, onSubmit, email, password, selection, gender, resetWidget ]); + + const [ , submitAvatarAction, isAvatarPending ] = useActionState(avatarAction, null); + + const busy = submitting || isCredentialsPending || isAvatarPending || pingingServer; const serverOffline = serverReachable === false; return ( @@ -1412,7 +1416,7 @@ const RegisterDialog: FC = props => { step === 'credentials' && - + { t('nitro.login.register.intro.credentials', 'Let\'s create your account. Enter your email and pick a password — we\'ll check that email isn\'t already in use.') } @@ -1426,17 +1430,17 @@ const RegisterDialog: FC = props => } { t('nitro.login.register.email', 'Email') } - setEmail(e.target.value) } /> { t('generic.password', 'Password') } - setPassword(e.target.value) } /> { t('nitro.login.register.confirm_password', 'Confirm password') } - setConfirm(e.target.value) } /> { (localError || error) && { localError || error } } @@ -1444,14 +1448,14 @@ const RegisterDialog: FC = props => 1/2 - { checking || pingingServer ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.register.next', 'Next') } + { isCredentialsPending || pingingServer ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.register.next', 'Next') } } { step === 'avatar' && - + { t('nitro.login.register.intro.avatar', 'Now it\'s time to make your own Habbo character! To make your own Habbo, please start by choosing your Habbo Name.') } @@ -1542,7 +1546,7 @@ const RegisterDialog: FC = props => setStep('credentials') } disabled={ busy }>{ t('nitro.login.register.back', 'Back') } 2/2 - { submitting ? t('nitro.login.register.creating', 'Creating…') : (checking || pingingServer) ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.register.next', 'Next') } + { (submitting || isAvatarPending) ? t('nitro.login.register.creating', 'Creating…') : pingingServer ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.register.next', 'Next') } @@ -1556,12 +1560,19 @@ const RegisterDialog: FC = props => interface ForgotDialogProps extends DialogSharedProps { - onSubmit: (body: { email: string; turnstileToken: string; }, onDialogReset: () => void) => void; + onSubmit: (body: { email: string; turnstileToken: string; }, onDialogReset: () => void) => Promise | void; } +const ForgotSubmitButton: FC = () => +{ + const { pending } = useFormStatus(); + + return { t('nitro.login.forgot.send_email', 'Send email') }; +}; + const ForgotDialog: FC = props => { - const { onCancel, onSubmit, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props; + const { onCancel, onSubmit, error, info, turnstileEnabled, turnstileSiteKey } = props; const [ email, setEmail ] = useState(''); const [ localError, setLocalError ] = useState(null); const [ turnstileToken, setTurnstileToken ] = useState(''); @@ -1573,19 +1584,23 @@ const ForgotDialog: FC = props => setResetSignal(prev => prev + 1); }, []); - const handle = (event: FormEvent) => + const forgotAction = useCallback(async (_prev: null, formData: FormData): Promise => { - event.preventDefault(); setLocalError(null); - if(!email.trim()) + const emailInput = String(formData.get('email') || '').trim(); + + if(!emailInput) { setLocalError(t('nitro.login.forgot.error.email_required', 'Please enter your email address.')); - return; + return null; } - onSubmit({ email: email.trim(), turnstileToken }, resetWidget); - }; + await onSubmit({ email: emailInput, turnstileToken }, resetWidget); + return null; + }, [ onSubmit, turnstileToken, resetWidget ]); + + const [ , submitForgotAction ] = useActionState(forgotAction, null); return ( @@ -1595,10 +1610,10 @@ const ForgotDialog: FC = props => { t('nitro.login.forgot.title', 'Reset password') } - + { t('nitro.login.forgot.email_label', 'Email address') } - setEmail(e.target.value) } /> { turnstileEnabled && @@ -1613,7 +1628,7 @@ const ForgotDialog: FC = props => { (localError || error) && { localError || error } } { info && { info } } - { t('nitro.login.forgot.send_email', 'Send email') } +