mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-20 15:36:18 +00:00
React 19 Phase 3: login/forgot/register forms → useActionState + useFormStatus
Migrate all three inline forms in LoginView.tsx to React 19 Actions:
- Login form: handleLoginSubmit → loginAction(prevState, FormData) wrapped in
useActionState. Submit button extracted as <LoginSubmitButton/> reading
pending via useFormStatus, dropping the local `submitting` flag for the
login flow. Reads username/password/remember from FormData; rememberMe
checkbox now carries name="remember".
- Forgot form (inline): forgotAction wrapped in useActionState; awaits
parent's onSubmit so pending stays true through the parent fetch.
ForgotSubmitButton uses useFormStatus.
- Register credentials step: credentialsAction with useActionState; the
step transition (setStep('avatar')) happens inside the action after
pingServer + onCheckEmail.
- Register avatar step: avatarAction validates username, pings server,
checks availability, then awaits onSubmit. The button label uses
isAvatarPending to show "Creating…" without prop drilling submitting.
- DialogSharedProps onSubmit signatures updated to return Promise<void>
so dialog actions can await the parent's fetch.
- lockState memo replaced with a direct readLock() call in render: the
previous useMemo depended on `submitting` to refresh after a failed
attempt; now any re-render (triggered by the action's pending toggle)
recomputes it.
- Remove unused FormEvent import; remove unused checking state in
RegisterDialog (replaced by isCredentialsPending / isAvatarPending).
https://claude.ai/code/session_01GrR87LAqnAEyKG2ZbmQt5Q
This commit is contained in:
+118
-103
@@ -1,5 +1,6 @@
|
|||||||
import { AvatarScaleType, AvatarSetType, GetAvatarRenderManager, GetConfiguration, IAvatarImage } from '@nitrots/nitro-renderer';
|
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 { ClearRememberLogin, GetConfigurationValue, GetRememberLogin, StoreRememberLoginFromPayload } from '../../api';
|
||||||
import { configFileUrl } from '../../secure-assets';
|
import { configFileUrl } from '../../secure-assets';
|
||||||
import flagBr from '../../assets/images/flag_icon/flag_icon_br.png';
|
import flagBr from '../../assets/images/flag_icon/flag_icon_br.png';
|
||||||
@@ -172,6 +173,20 @@ const applyLocaleSelection = (locale: LoginLocale): void =>
|
|||||||
catch {}
|
catch {}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const LoginSubmitButton: FC<{ isEntering: boolean; isLocked: boolean; loginPingingServer: boolean }> = ({ isEntering, isLocked, loginPingingServer }) =>
|
||||||
|
{
|
||||||
|
const { pending } = useFormStatus();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="ok-button"
|
||||||
|
disabled={ pending || isEntering || isLocked }>
|
||||||
|
{ isEntering ? t('nitro.login.entering', 'Entering…') : (pending || loginPingingServer) ? t('nitro.login.server.checking', 'Checking…') : t('login.title', 'Log in') }
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export interface LoginViewProps
|
export interface LoginViewProps
|
||||||
{
|
{
|
||||||
onAuthenticated: (ssoTicket: string) => void;
|
onAuthenticated: (ssoTicket: string) => void;
|
||||||
@@ -357,7 +372,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
|
|||||||
return () => window.clearTimeout(timeout);
|
return () => window.clearTimeout(timeout);
|
||||||
}, [ info ]);
|
}, [ info ]);
|
||||||
|
|
||||||
const lockState = useMemo(() => readLock(), [ submitting ]);
|
const lockState = readLock();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const isLocked = lockState.lockedUntil > now;
|
const isLocked = lockState.lockedUntil > now;
|
||||||
|
|
||||||
@@ -445,45 +460,46 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
|
|||||||
}
|
}
|
||||||
}, [ checkServerReachable ]);
|
}, [ checkServerReachable ]);
|
||||||
|
|
||||||
const handleLoginSubmit = useCallback(async (event: FormEvent<HTMLFormElement>) =>
|
const loginAction = useCallback(async (_prev: null, formData: FormData): Promise<null> =>
|
||||||
{
|
{
|
||||||
event.preventDefault();
|
if(isEntering) return null;
|
||||||
|
|
||||||
if(submitting || isEntering) return;
|
|
||||||
|
|
||||||
const nowTs = Date.now();
|
const nowTs = Date.now();
|
||||||
if(nowTs - submitTimeRef.current < 1000) return;
|
if(nowTs - submitTimeRef.current < 1000) return null;
|
||||||
submitTimeRef.current = nowTs;
|
submitTimeRef.current = nowTs;
|
||||||
|
|
||||||
const state = readLock();
|
const usernameInput = String(formData.get('username') || '').trim();
|
||||||
if(state.lockedUntil > nowTs)
|
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) ]));
|
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.'));
|
setError(t('nitro.login.error.missing_credentials', 'Please enter both your Habbo name and password.'));
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if(turnstileEnabled && !loginTurnstileToken)
|
if(turnstileEnabled && !loginTurnstileToken)
|
||||||
{
|
{
|
||||||
setError(t('nitro.login.error.turnstile', 'Please complete the security check.'));
|
setError(t('nitro.login.error.turnstile', 'Please complete the security check.'));
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
setError(null);
|
setError(null);
|
||||||
setSubmitting(true);
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const { ok, payload } = await postJson(loginUrl, {
|
const { ok, payload } = await postJson(loginUrl, {
|
||||||
username: username.trim(),
|
username: usernameInput,
|
||||||
password,
|
password: passwordInput,
|
||||||
remember: rememberMe,
|
remember: rememberFlag,
|
||||||
turnstileToken: turnstileEnabled ? loginTurnstileToken : undefined
|
turnstileToken: turnstileEnabled ? loginTurnstileToken : undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -492,10 +508,10 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
|
|||||||
if(ok && ssoTicket)
|
if(ok && ssoTicket)
|
||||||
{
|
{
|
||||||
clearLock();
|
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();
|
else ClearRememberLogin();
|
||||||
onAuthenticated(ssoTicket);
|
onAuthenticated(ssoTicket);
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
recordFailure();
|
recordFailure();
|
||||||
@@ -503,17 +519,17 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
|
|||||||
setError(message);
|
setError(message);
|
||||||
resetLoginTurnstile();
|
resetLoginTurnstile();
|
||||||
}
|
}
|
||||||
catch(err)
|
catch
|
||||||
{
|
{
|
||||||
recordFailure();
|
recordFailure();
|
||||||
setError(t('nitro.login.error.login_unreachable', 'Unable to reach the login service. Please try again.'));
|
setError(t('nitro.login.error.login_unreachable', 'Unable to reach the login service. Please try again.'));
|
||||||
resetLoginTurnstile();
|
resetLoginTurnstile();
|
||||||
}
|
}
|
||||||
finally
|
|
||||||
{
|
return null;
|
||||||
setSubmitting(false);
|
}, [ isEntering, turnstileEnabled, loginTurnstileToken, loginUrl, postJson, clearLock, recordFailure, onAuthenticated, resetLoginTurnstile ]);
|
||||||
}
|
|
||||||
}, [ submitting, isEntering, username, password, rememberMe, turnstileEnabled, loginTurnstileToken, loginUrl, postJson, clearLock, recordFailure, onAuthenticated, resetLoginTurnstile, pingLoginServer ]);
|
const [ , submitLoginAction, isLoginPending ] = useActionState<null, FormData>(loginAction, null);
|
||||||
|
|
||||||
const checkEmailUrl = GetConfigurationValue<string>('login.check-email.endpoint', '/api/auth/check-email');
|
const checkEmailUrl = GetConfigurationValue<string>('login.check-email.endpoint', '/api/auth/check-email');
|
||||||
const checkUsernameUrl = GetConfigurationValue<string>('login.check-username.endpoint', '/api/auth/check-username');
|
const checkUsernameUrl = GetConfigurationValue<string>('login.check-username.endpoint', '/api/auth/check-username');
|
||||||
@@ -735,7 +751,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
|
|||||||
|
|
||||||
<div className="nitro-login-card">
|
<div className="nitro-login-card">
|
||||||
<div className="card-title">{ t('nitro.login.card.title', 'What\'s your Habbo called?') }</div>
|
<div className="card-title">{ t('nitro.login.card.title', 'What\'s your Habbo called?') }</div>
|
||||||
<form className="card-body" onSubmit={ handleLoginSubmit } autoComplete="on">
|
<form className="card-body" action={ submitLoginAction } autoComplete="on">
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label htmlFor="login-username">{ t('login.username', 'Name of your Habbo') }</label>
|
<label htmlFor="login-username">{ t('login.username', 'Name of your Habbo') }</label>
|
||||||
<input
|
<input
|
||||||
@@ -763,6 +779,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
|
|||||||
<label className="remember-row">
|
<label className="remember-row">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
name="remember"
|
||||||
checked={ rememberMe }
|
checked={ rememberMe }
|
||||||
onChange={ e => setRememberMe(e.target.checked) }
|
onChange={ e => setRememberMe(e.target.checked) }
|
||||||
/>
|
/>
|
||||||
@@ -788,11 +805,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
|
|||||||
{ error && <div className="error-line">{ error }</div> }
|
{ error && <div className="error-line">{ error }</div> }
|
||||||
{ info && <div className="info-line">{ info }</div> }
|
{ info && <div className="info-line">{ info }</div> }
|
||||||
<div className="submit-row">
|
<div className="submit-row">
|
||||||
<button
|
<LoginSubmitButton isEntering={ isEntering } isLocked={ isLocked } loginPingingServer={ loginPingingServer } />
|
||||||
type="submit"
|
|
||||||
className="ok-button"
|
|
||||||
disabled={ submitting || isEntering || isLocked }
|
|
||||||
>{ isEntering ? t('nitro.login.entering', 'Entering…') : loginPingingServer ? t('nitro.login.server.checking', 'Checking…') : t('login.title', 'Log in') }</button>
|
|
||||||
</div>
|
</div>
|
||||||
<a className="forgot" onClick={ () => setMode('forgot') }>{ t('login.forgot_password', 'Forgotten your password?') }</a>
|
<a className="forgot" onClick={ () => setMode('forgot') }>{ t('login.forgot_password', 'Forgotten your password?') }</a>
|
||||||
</form>
|
</form>
|
||||||
@@ -840,7 +853,7 @@ interface DialogSharedProps
|
|||||||
|
|
||||||
interface RegisterDialogProps extends 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> | void;
|
||||||
onCheckEmail: (email: string) => Promise<{ available: boolean; error?: string }>;
|
onCheckEmail: (email: string) => Promise<{ available: boolean; error?: string }>;
|
||||||
onCheckUsername: (username: string) => Promise<{ available: boolean; error?: string }>;
|
onCheckUsername: (username: string) => Promise<{ available: boolean; error?: string }>;
|
||||||
onCheckServer: () => Promise<boolean>;
|
onCheckServer: () => Promise<boolean>;
|
||||||
@@ -1067,7 +1080,6 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
|
|||||||
const [ gender, setGender ] = useState<GenderKey>('F');
|
const [ gender, setGender ] = useState<GenderKey>('F');
|
||||||
const [ selection, setSelection ] = useState<FigureSelection>(() => ({ ...FALLBACK_DEFAULTS.F }));
|
const [ selection, setSelection ] = useState<FigureSelection>(() => ({ ...FALLBACK_DEFAULTS.F }));
|
||||||
const [ localError, setLocalError ] = useState<string | null>(null);
|
const [ localError, setLocalError ] = useState<string | null>(null);
|
||||||
const [ checking, setChecking ] = useState(false);
|
|
||||||
const [ turnstileToken, setTurnstileToken ] = useState('');
|
const [ turnstileToken, setTurnstileToken ] = useState('');
|
||||||
const [ resetSignal, setResetSignal ] = useState(0);
|
const [ resetSignal, setResetSignal ] = useState(0);
|
||||||
const [ serverReachable, setServerReachable ] = useState<boolean | null>(null);
|
const [ serverReachable, setServerReachable ] = useState<boolean | null>(null);
|
||||||
@@ -1236,54 +1248,50 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
|
|||||||
password.length >= 8 &&
|
password.length >= 8 &&
|
||||||
password === confirm;
|
password === confirm;
|
||||||
|
|
||||||
const handleCredentialsNext = async (event: FormEvent<HTMLFormElement>) =>
|
const credentialsAction = useCallback(async (_prev: null, _formData: FormData): Promise<null> =>
|
||||||
{
|
{
|
||||||
event.preventDefault();
|
|
||||||
setLocalError(null);
|
setLocalError(null);
|
||||||
|
|
||||||
if(!email.trim() || !password || !confirm)
|
if(!email.trim() || !password || !confirm)
|
||||||
{
|
{
|
||||||
setLocalError(t('nitro.login.register.error.missing_fields', 'Please fill in every field.'));
|
setLocalError(t('nitro.login.register.error.missing_fields', 'Please fill in every field.'));
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
if(!EMAIL_REGEX.test(email.trim()))
|
if(!EMAIL_REGEX.test(email.trim()))
|
||||||
{
|
{
|
||||||
setLocalError(t('nitro.login.register.error.invalid_email', 'Please enter a valid email address.'));
|
setLocalError(t('nitro.login.register.error.invalid_email', 'Please enter a valid email address.'));
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
if(password.length < 8)
|
if(password.length < 8)
|
||||||
{
|
{
|
||||||
setLocalError(t('nitro.login.register.error.password_too_short', 'Your password must be at least 8 characters.'));
|
setLocalError(t('nitro.login.register.error.password_too_short', 'Your password must be at least 8 characters.'));
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
if(password !== confirm)
|
if(password !== confirm)
|
||||||
{
|
{
|
||||||
setLocalError(t('nitro.login.register.error.password_mismatch', 'Passwords do not match.'));
|
setLocalError(t('nitro.login.register.error.password_mismatch', 'Passwords do not match.'));
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
setChecking(true);
|
const serverOk = await pingServer();
|
||||||
try
|
if(!serverOk)
|
||||||
{
|
{
|
||||||
const serverOk = await pingServer();
|
setLocalError(t('nitro.login.error.server_offline', 'The gameserver is not running. Please try again later.'));
|
||||||
if(!serverOk)
|
return null;
|
||||||
{
|
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
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<null, FormData>(credentialsAction, null);
|
||||||
|
|
||||||
const applyGender = (newGender: GenderKey) =>
|
const applyGender = (newGender: GenderKey) =>
|
||||||
{
|
{
|
||||||
@@ -1345,61 +1353,57 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
|
|||||||
const figure = buildFigureString(selection);
|
const figure = buildFigureString(selection);
|
||||||
const previewSrc = useAvatarPreview(figure, gender, AvatarSetType.FULL);
|
const previewSrc = useAvatarPreview(figure, gender, AvatarSetType.FULL);
|
||||||
|
|
||||||
const handleAvatarSubmit = async (event: FormEvent<HTMLFormElement>) =>
|
const avatarAction = useCallback(async (_prev: null, _formData: FormData): Promise<null> =>
|
||||||
{
|
{
|
||||||
event.preventDefault();
|
|
||||||
setLocalError(null);
|
setLocalError(null);
|
||||||
|
|
||||||
const trimmed = username.trim();
|
const trimmed = username.trim();
|
||||||
if(!trimmed)
|
if(!trimmed)
|
||||||
{
|
{
|
||||||
setLocalError(t('nitro.login.register.error.username_required', 'Please choose a Habbo name.'));
|
setLocalError(t('nitro.login.register.error.username_required', 'Please choose a Habbo name.'));
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
if(trimmed.length < 3 || trimmed.length > 16)
|
if(trimmed.length < 3 || trimmed.length > 16)
|
||||||
{
|
{
|
||||||
setLocalError(t('nitro.login.register.error.username_length', 'Habbo name must be 3–16 characters.'));
|
setLocalError(t('nitro.login.register.error.username_length', 'Habbo name must be 3–16 characters.'));
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if(turnstileEnabled && !turnstileToken)
|
if(turnstileEnabled && !turnstileToken)
|
||||||
{
|
{
|
||||||
setLocalError(t('nitro.login.error.turnstile', 'Please complete the security check.'));
|
setLocalError(t('nitro.login.error.turnstile', 'Please complete the security check.'));
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
setChecking(true);
|
const serverOk = await pingServer();
|
||||||
try
|
if(!serverOk)
|
||||||
{
|
{
|
||||||
const serverOk = await pingServer();
|
setLocalError(t('nitro.login.error.server_offline', 'The gameserver is not running. Please try again later.'));
|
||||||
if(!serverOk)
|
return null;
|
||||||
{
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
username: trimmed,
|
||||||
email: email.trim(),
|
email: email.trim(),
|
||||||
password,
|
password,
|
||||||
figure,
|
figure: buildFigureString(selection),
|
||||||
gender,
|
gender,
|
||||||
turnstileToken
|
turnstileToken
|
||||||
}, resetWidget);
|
}, resetWidget);
|
||||||
};
|
|
||||||
|
|
||||||
const busy = submitting || checking || pingingServer;
|
return null;
|
||||||
|
}, [ username, turnstileEnabled, turnstileToken, pingServer, onCheckUsername, onSubmit, email, password, selection, gender, resetWidget ]);
|
||||||
|
|
||||||
|
const [ , submitAvatarAction, isAvatarPending ] = useActionState<null, FormData>(avatarAction, null);
|
||||||
|
|
||||||
|
const busy = submitting || isCredentialsPending || isAvatarPending || pingingServer;
|
||||||
const serverOffline = serverReachable === false;
|
const serverOffline = serverReachable === false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1412,7 +1416,7 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{ step === 'credentials' &&
|
{ step === 'credentials' &&
|
||||||
<form className="card-body" onSubmit={ handleCredentialsNext } autoComplete="on">
|
<form className="card-body" action={ submitCredentialsAction } autoComplete="on">
|
||||||
<div className="register-intro">
|
<div className="register-intro">
|
||||||
{ 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.') }
|
{ 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.') }
|
||||||
</div>
|
</div>
|
||||||
@@ -1426,17 +1430,17 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
|
|||||||
}
|
}
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label htmlFor="register-email">{ t('nitro.login.register.email', 'Email') }</label>
|
<label htmlFor="register-email">{ t('nitro.login.register.email', 'Email') }</label>
|
||||||
<input id="register-email" type="email" maxLength={ 120 } autoComplete="email"
|
<input id="register-email" name="email" type="email" maxLength={ 120 } autoComplete="email"
|
||||||
value={ email } onChange={ e => setEmail(e.target.value) } />
|
value={ email } onChange={ e => setEmail(e.target.value) } />
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label htmlFor="register-password">{ t('generic.password', 'Password') }</label>
|
<label htmlFor="register-password">{ t('generic.password', 'Password') }</label>
|
||||||
<input id="register-password" type="password" maxLength={ 128 } autoComplete="new-password"
|
<input id="register-password" name="password" type="password" maxLength={ 128 } autoComplete="new-password"
|
||||||
value={ password } onChange={ e => setPassword(e.target.value) } />
|
value={ password } onChange={ e => setPassword(e.target.value) } />
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label htmlFor="register-confirm">{ t('nitro.login.register.confirm_password', 'Confirm password') }</label>
|
<label htmlFor="register-confirm">{ t('nitro.login.register.confirm_password', 'Confirm password') }</label>
|
||||||
<input id="register-confirm" type="password" maxLength={ 128 } autoComplete="new-password"
|
<input id="register-confirm" name="confirm" type="password" maxLength={ 128 } autoComplete="new-password"
|
||||||
value={ confirm } onChange={ e => setConfirm(e.target.value) } />
|
value={ confirm } onChange={ e => setConfirm(e.target.value) } />
|
||||||
</div>
|
</div>
|
||||||
{ (localError || error) && <div className="error-line">{ localError || error }</div> }
|
{ (localError || error) && <div className="error-line">{ localError || error }</div> }
|
||||||
@@ -1444,14 +1448,14 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
|
|||||||
<div className="step-footer">
|
<div className="step-footer">
|
||||||
<span className="step-indicator">1/2</span>
|
<span className="step-indicator">1/2</span>
|
||||||
<button type="submit" className="ok-button" disabled={ !credentialsValid || busy || serverOffline }>
|
<button type="submit" className="ok-button" disabled={ !credentialsValid || busy || serverOffline }>
|
||||||
{ 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') }
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
|
|
||||||
{ step === 'avatar' &&
|
{ step === 'avatar' &&
|
||||||
<form className="card-body" onSubmit={ handleAvatarSubmit } autoComplete="on">
|
<form className="card-body" action={ submitAvatarAction } autoComplete="on">
|
||||||
<div className="register-intro">
|
<div className="register-intro">
|
||||||
{ 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.') }
|
{ 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.') }
|
||||||
</div>
|
</div>
|
||||||
@@ -1542,7 +1546,7 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
|
|||||||
<button type="button" className="ok-button back-button" onClick={ () => setStep('credentials') } disabled={ busy }>{ t('nitro.login.register.back', 'Back') }</button>
|
<button type="button" className="ok-button back-button" onClick={ () => setStep('credentials') } disabled={ busy }>{ t('nitro.login.register.back', 'Back') }</button>
|
||||||
<span className="step-indicator">2/2</span>
|
<span className="step-indicator">2/2</span>
|
||||||
<button type="submit" className="ok-button" disabled={ !username.trim() || busy || serverOffline }>
|
<button type="submit" className="ok-button" disabled={ !username.trim() || busy || serverOffline }>
|
||||||
{ 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') }
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -1556,12 +1560,19 @@ const RegisterDialog: FC<RegisterDialogProps> = props =>
|
|||||||
|
|
||||||
interface ForgotDialogProps extends DialogSharedProps
|
interface ForgotDialogProps extends DialogSharedProps
|
||||||
{
|
{
|
||||||
onSubmit: (body: { email: string; turnstileToken: string; }, onDialogReset: () => void) => void;
|
onSubmit: (body: { email: string; turnstileToken: string; }, onDialogReset: () => void) => Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ForgotSubmitButton: FC = () =>
|
||||||
|
{
|
||||||
|
const { pending } = useFormStatus();
|
||||||
|
|
||||||
|
return <button type="submit" className="ok-button" disabled={ pending }>{ t('nitro.login.forgot.send_email', 'Send email') }</button>;
|
||||||
|
};
|
||||||
|
|
||||||
const ForgotDialog: FC<ForgotDialogProps> = props =>
|
const ForgotDialog: FC<ForgotDialogProps> = props =>
|
||||||
{
|
{
|
||||||
const { onCancel, onSubmit, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props;
|
const { onCancel, onSubmit, error, info, turnstileEnabled, turnstileSiteKey } = props;
|
||||||
const [ email, setEmail ] = useState('');
|
const [ email, setEmail ] = useState('');
|
||||||
const [ localError, setLocalError ] = useState<string | null>(null);
|
const [ localError, setLocalError ] = useState<string | null>(null);
|
||||||
const [ turnstileToken, setTurnstileToken ] = useState('');
|
const [ turnstileToken, setTurnstileToken ] = useState('');
|
||||||
@@ -1573,19 +1584,23 @@ const ForgotDialog: FC<ForgotDialogProps> = props =>
|
|||||||
setResetSignal(prev => prev + 1);
|
setResetSignal(prev => prev + 1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handle = (event: FormEvent<HTMLFormElement>) =>
|
const forgotAction = useCallback(async (_prev: null, formData: FormData): Promise<null> =>
|
||||||
{
|
{
|
||||||
event.preventDefault();
|
|
||||||
setLocalError(null);
|
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.'));
|
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<null, FormData>(forgotAction, null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="nitro-login-modal">
|
<div className="nitro-login-modal">
|
||||||
@@ -1595,10 +1610,10 @@ const ForgotDialog: FC<ForgotDialogProps> = props =>
|
|||||||
<span>{ t('nitro.login.forgot.title', 'Reset password') }</span>
|
<span>{ t('nitro.login.forgot.title', 'Reset password') }</span>
|
||||||
<span className="nitro-card-close-button" role="button" aria-label={ t('generic.close', 'Close') } onClick={ onCancel } />
|
<span className="nitro-card-close-button" role="button" aria-label={ t('generic.close', 'Close') } onClick={ onCancel } />
|
||||||
</div>
|
</div>
|
||||||
<form className="card-body" onSubmit={ handle } autoComplete="on">
|
<form className="card-body" action={ submitForgotAction } autoComplete="on">
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label htmlFor="forgot-email">{ t('nitro.login.forgot.email_label', 'Email address') }</label>
|
<label htmlFor="forgot-email">{ t('nitro.login.forgot.email_label', 'Email address') }</label>
|
||||||
<input id="forgot-email" type="email" maxLength={ 120 } autoComplete="email"
|
<input id="forgot-email" name="email" type="email" maxLength={ 120 } autoComplete="email"
|
||||||
value={ email } onChange={ e => setEmail(e.target.value) } />
|
value={ email } onChange={ e => setEmail(e.target.value) } />
|
||||||
</div>
|
</div>
|
||||||
{ turnstileEnabled &&
|
{ turnstileEnabled &&
|
||||||
@@ -1613,7 +1628,7 @@ const ForgotDialog: FC<ForgotDialogProps> = props =>
|
|||||||
{ (localError || error) && <div className="error-line">{ localError || error }</div> }
|
{ (localError || error) && <div className="error-line">{ localError || error }</div> }
|
||||||
{ info && <div className="info-line">{ info }</div> }
|
{ info && <div className="info-line">{ info }</div> }
|
||||||
<div className="submit-row">
|
<div className="submit-row">
|
||||||
<button type="submit" className="ok-button" disabled={ submitting }>{ t('nitro.login.forgot.send_email', 'Send email') }</button>
|
<ForgotSubmitButton />
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user