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:
simoleo89
2026-05-11 16:31:50 +00:00
parent a1bee1d825
commit 1b1e0c18bf
+103 -88
View File
@@ -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);
try
{
const serverOk = await pingServer(); const serverOk = await pingServer();
if(!serverOk) if(!serverOk)
{ {
setLocalError(t('nitro.login.error.server_offline', 'The gameserver is not running. Please try again later.')); setLocalError(t('nitro.login.error.server_offline', 'The gameserver is not running. Please try again later.'));
return; return null;
} }
const result = await onCheckEmail(email.trim()); const result = await onCheckEmail(email.trim());
if(!result.available) if(!result.available)
{ {
setLocalError(result.error || t('nitro.login.error.email_taken', 'This email is already in use.')); setLocalError(result.error || t('nitro.login.error.email_taken', 'This email is already in use.'));
return; return null;
} }
setStep('avatar'); setStep('avatar');
} return null;
finally }, [ email, password, confirm, pingServer, onCheckEmail ]);
{
setChecking(false); 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 316 characters.')); setLocalError(t('nitro.login.register.error.username_length', 'Habbo name must be 316 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);
try
{
const serverOk = await pingServer(); const serverOk = await pingServer();
if(!serverOk) if(!serverOk)
{ {
setLocalError(t('nitro.login.error.server_offline', 'The gameserver is not running. Please try again later.')); setLocalError(t('nitro.login.error.server_offline', 'The gameserver is not running. Please try again later.'));
return; return null;
} }
const result = await onCheckUsername(trimmed); const result = await onCheckUsername(trimmed);
if(!result.available) if(!result.available)
{ {
setLocalError(result.error || t('nitro.login.error.username_taken', 'This Habbo name is already taken.')); setLocalError(result.error || t('nitro.login.error.username_taken', 'This Habbo name is already taken.'));
return; return null;
}
}
finally
{
setChecking(false);
} }
onSubmit({ 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>