Files
Nitro-V3/src/components/login/LoginView.tsx
T

1625 lines
69 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { AvatarScaleType, AvatarSetType, GetAvatarRenderManager, GetConfiguration, IAvatarImage } from '@nitrots/nitro-renderer';
import { FC, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
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';
import flagEn from '../../assets/images/flag_icon/flag_icon_en.png';
import flagEs from '../../assets/images/flag_icon/flag_icon_es.png';
import flagFi from '../../assets/images/flag_icon/flag_icon_fi.png';
import flagFr from '../../assets/images/flag_icon/flag_icon_fr.png';
import flagIt from '../../assets/images/flag_icon/flag_icon_it.png';
import flagNl from '../../assets/images/flag_icon/flag_icon_nl.png';
import flagSelected from '../../assets/images/flag_icon/flag_icon_selected.png';
import flagTr from '../../assets/images/flag_icon/flag_icon_tr.png';
import { applyTextTranslationLocale } from '../../hooks/translation/useTranslation';
import { NewsWindow } from './components/NewsWindow';
import { TurnstileWidget } from './TurnstileWidget';
import { t } from './utils/i18n';
type DialogMode = 'login' | 'register' | 'forgot';
type LoginLocale = { code: string; file: string; label: string; flag: string };
const interpolate = (value: string | null | undefined): string =>
{
if(!value) return '';
let output = value;
try { output = GetConfiguration().interpolate(value) || value; }
catch {}
return output.replace(/\$\{([^}]+)\}/g, (_, key: string) =>
{
if(key === 'api.url' && typeof (window as any).NitroSecureApiUrl === 'string')
{
const secureApiUrl = (window as any).NitroSecureApiUrl.replace(/\/$/, '');
if(secureApiUrl) return secureApiUrl;
}
try
{
const configValue = GetConfiguration().getValue<string>(key, '');
if(configValue) return configValue;
}
catch {}
try
{
const configValue = GetConfigurationValue<string>(key, '');
if(configValue) return configValue;
}
catch {}
return '';
});
};
const LOCK_KEY = 'nitro.login.lock';
const CHAT_TRANSLATION_SETTINGS_KEY = 'chatTranslationSettings';
const MAX_ATTEMPTS = 5;
const LOCK_WINDOW_MS = 60_000;
const LOCK_DURATION_MS = 2 * 60_000;
const getDefaultLoginImages = (): Record<string, string> =>
{
const imagesBase = (GetConfigurationValue<string>('images.url', '') || '').replace(/\/$/, '');
if(!imagesBase.length) return { 'background.colour': '#6eadc8' };
return {
background: `${ imagesBase }/reception/background_gradient_apr25.png`,
'background.colour': '#6eadc8',
drape: `${ imagesBase }/reception/drape.png`,
left: `${ imagesBase }/reception/mute_reception_backdrop_left.png`,
right: `${ imagesBase }/reception/background_right.png`
};
};
const LOGIN_LOCALES: LoginLocale[] = [
{ code: 'it', file: 'it', label: 'Italiano', flag: flagIt },
{ code: 'en', file: 'com', label: 'English', flag: flagEn },
{ code: 'es', file: 'es', label: 'Español', flag: flagEs },
{ code: 'fr', file: 'fr', label: 'Français', flag: flagFr },
{ code: 'de', file: 'de', label: 'Deutsch', flag: flagDe },
{ code: 'pt-BR', file: 'br', label: 'Português', flag: flagBr },
{ code: 'nl', file: 'nl', label: 'Nederlands', flag: flagNl },
{ code: 'fi', file: 'fi', label: 'Suomi', flag: flagFi },
{ code: 'tr', file: 'tr', label: 'Türkçe', flag: flagTr }
];
type AttemptState = { attempts: number; firstAt: number; lockedUntil: number };
const readLock = (): AttemptState =>
{
try
{
const raw = sessionStorage.getItem(LOCK_KEY);
if(!raw) return { attempts: 0, firstAt: 0, lockedUntil: 0 };
return JSON.parse(raw);
}
catch { return { attempts: 0, firstAt: 0, lockedUntil: 0 }; }
};
const writeLock = (state: AttemptState) =>
{
try { sessionStorage.setItem(LOCK_KEY, JSON.stringify(state)); }
catch { }
};
const normalizeLanguageCode = (value: string): string =>
{
if(!value) return '';
const normalized = value.trim().replace('_', '-');
const parts = normalized.split('-');
if(parts.length === 1) return parts[0].toLowerCase();
return `${ parts[0].toLowerCase() }-${ parts[1].toUpperCase() }`;
};
const resolveLoginLocale = (value: string): LoginLocale =>
{
const normalized = normalizeLanguageCode(value);
const exactMatch = LOGIN_LOCALES.find(locale => normalizeLanguageCode(locale.code) === normalized);
if(exactMatch) return exactMatch;
const base = normalized.split('-')[0];
if(base === 'pt') return LOGIN_LOCALES.find(locale => locale.file === 'br') || LOGIN_LOCALES[0];
return LOGIN_LOCALES.find(locale => normalizeLanguageCode(locale.code).split('-')[0] === base) || LOGIN_LOCALES[0];
};
const getBrowserLocale = (): LoginLocale =>
{
if(typeof navigator === 'undefined') return LOGIN_LOCALES[0];
return resolveLoginLocale(navigator.language || navigator.languages?.[0] || 'it');
};
const readCachedLocale = (): LoginLocale =>
{
try
{
const settings = JSON.parse(localStorage.getItem(CHAT_TRANSLATION_SETTINGS_KEY) || '{}');
if(typeof settings.uiTextLanguage === 'string' && settings.uiTextLanguage.length) return resolveLoginLocale(settings.uiTextLanguage);
}
catch {}
return getBrowserLocale();
};
const applyLocaleSelection = (locale: LoginLocale): void =>
{
try
{
const previousSettings = JSON.parse(localStorage.getItem(CHAT_TRANSLATION_SETTINGS_KEY) || '{}');
const nextSettings = {
enabled: previousSettings.enabled ?? false,
incomingTargetLanguage: previousSettings.incomingTargetLanguage || locale.code,
outgoingTargetLanguage: previousSettings.outgoingTargetLanguage || locale.code,
...previousSettings,
uiTextLanguage: locale.code
};
localStorage.setItem(CHAT_TRANSLATION_SETTINGS_KEY, JSON.stringify(nextSettings));
}
catch {}
};
export interface LoginViewProps
{
onAuthenticated: (ssoTicket: string) => void;
isEntering?: boolean;
}
export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = false }) =>
{
const [ mode, setMode ] = useState<DialogMode>('login');
const [ username, setUsername ] = useState('');
const [ password, setPassword ] = useState('');
const [ error, setError ] = useState<string | null>(null);
const [ info, setInfo ] = useState<string | null>(null);
const [ submitting, setSubmitting ] = useState(false);
const [ loginTurnstileToken, setLoginTurnstileToken ] = useState('');
const [ loginTurnstileResetSignal, setLoginTurnstileResetSignal ] = useState(0);
const [ loginServerReachable, setLoginServerReachable ] = useState<boolean | null>(null);
const [ loginPingingServer, setLoginPingingServer ] = useState(false);
const [ rememberMe, setRememberMe ] = useState(() => !!GetRememberLogin());
const [ selectedLocale, setSelectedLocale ] = useState<LoginLocale>(() => readCachedLocale());
const [ localeApplying, setLocaleApplying ] = useState(false);
const [ localeError, setLocaleError ] = useState('');
const [ loginViewConfig, setLoginViewConfig ] = useState<Record<string, unknown>>(() => GetConfigurationValue<Record<string, unknown>>('loginview', {}));
const submitTimeRef = useRef(0);
const preloadedLoginImagesRef = useRef<Set<string>>(new Set());
const configuredLoginImages = useMemo<Record<string, string>>(() =>
(loginViewConfig?.['images'] as Record<string, string>) ?? {}, [ loginViewConfig ]);
const loginImages = useMemo<Record<string, string>>(() =>
({ ...getDefaultLoginImages(), ...configuredLoginImages }), [ configuredLoginImages ]);
const configuredLoginWidgets = useMemo<Record<string, unknown>>(() =>
(loginViewConfig?.['widgets'] as Record<string, unknown>) ?? {}, [ loginViewConfig ]);
const loginWidgetSlots = useMemo(() =>
{
return Object.entries(configuredLoginWidgets)
.filter(([ key, value ]) => key.startsWith('slot.') && key.endsWith('.widget') && typeof value === 'string' && value.length > 0)
.map(([ key, value ]) =>
{
const slotNum = key.match(/\d+/)?.[0] ?? '';
const conf = configuredLoginWidgets[`slot.${ slotNum }.conf`] as Record<string, unknown> ?? {};
return { key, slotNum: Number(slotNum), type: value as string, conf };
})
.filter(slot => slot.slotNum > 0)
.sort((a, b) => a.slotNum - b.slotNum);
}, [ configuredLoginWidgets ]);
const backgroundColor = (loginImages['background.colour'] || GetConfigurationValue<string>('login_background.colour', '#6eadc8'));
const background = interpolate(loginImages['background'] || GetConfigurationValue<string>('login_background', ''));
const sun = interpolate(loginImages['sun'] || GetConfigurationValue<string>('login_sun', ''));
const drape = interpolate(loginImages['drape'] || GetConfigurationValue<string>('login_drape', ''));
const left = interpolate(loginImages['left'] || GetConfigurationValue<string>('login_left', ''));
const rightRepeat = interpolate(loginImages['right.repeat'] || GetConfigurationValue<string>('login_right.repeat', ''));
const right = interpolate(loginImages['right'] || GetConfigurationValue<string>('login_right', ''));
const widgetImageUrls = useMemo(() => loginWidgetSlots
.map(slot => typeof slot.conf.image === 'string' ? interpolate(slot.conf.image) : '')
.filter(Boolean), [ loginWidgetSlots ]);
const loginImageUrls = useMemo(() => [ background, sun, drape, left, rightRepeat, right, ...widgetImageUrls ].filter(Boolean), [ background, sun, drape, left, rightRepeat, right, widgetImageUrls ]);
const [ loginImagesVersion, setLoginImagesVersion ] = useState(0);
const loginUrl = GetConfigurationValue<string>('login.endpoint', '/api/auth/login');
const registerUrl = GetConfigurationValue<string>('login.register.endpoint', '/api/auth/register');
const forgotUrl = GetConfigurationValue<string>('login.forgot.endpoint', '/api/auth/forgot-password');
const newsUrl = interpolate(GetConfigurationValue<string>('login.news.url', ''));
const turnstileSiteKey = GetConfigurationValue<string>('login.turnstile.sitekey', '');
const rawTurnstileEnabled = GetConfigurationValue<unknown>('login.turnstile.enabled', false);
const turnstileEnabled = (rawTurnstileEnabled === true
|| rawTurnstileEnabled === 'true'
|| rawTurnstileEnabled === 1
|| rawTurnstileEnabled === '1') && !!turnstileSiteKey;
const resetLoginTurnstile = useCallback(() =>
{
setLoginTurnstileToken('');
setLoginTurnstileResetSignal(prev => prev + 1);
}, []);
useEffect(() =>
{
setError(null);
if(mode === 'login') resetLoginTurnstile();
}, [ mode, resetLoginTurnstile ]);
useEffect(() =>
{
let cancelled = false;
const refreshLoginViewConfig = () =>
{
if(cancelled) return;
const nextConfig = GetConfigurationValue<Record<string, unknown>>('loginview', {});
setLoginViewConfig(previousConfig =>
{
try
{
return JSON.stringify(previousConfig) === JSON.stringify(nextConfig) ? previousConfig : nextConfig;
}
catch
{
return nextConfig;
}
});
};
refreshLoginViewConfig();
const timers = [ 50, 150, 300, 600, 1000, 2000 ].map(delay => window.setTimeout(refreshLoginViewConfig, delay));
return () =>
{
cancelled = true;
timers.forEach(timer => window.clearTimeout(timer));
};
}, []);
const confirmLocaleSelection = useCallback(async () =>
{
if(localeApplying) return;
setLocaleApplying(true);
setLocaleError('');
try
{
applyLocaleSelection(selectedLocale);
await applyTextTranslationLocale(selectedLocale.code);
}
catch
{
setLocaleError('Unable to load this language pack.');
}
finally
{
setLocaleApplying(false);
}
}, [ localeApplying, selectedLocale ]);
useEffect(() =>
{
if(!loginImageUrls.length) return;
let cancelled = false;
let remaining = 0;
loginImageUrls
.filter(url =>
{
if(preloadedLoginImagesRef.current.has(url)) return false;
preloadedLoginImagesRef.current.add(url);
return true;
})
.forEach(url =>
{
remaining++;
const image = new Image();
image.onload = image.onerror = () =>
{
remaining--;
if(!cancelled && remaining <= 0) setLoginImagesVersion(version => version + 1);
};
image.src = url;
});
return () =>
{
cancelled = true;
};
}, [ loginImageUrls ]);
useEffect(() =>
{
if(!info) return;
const timeout = window.setTimeout(() => setInfo(null), 8000);
return () => window.clearTimeout(timeout);
}, [ info ]);
const lockState = useMemo(() => readLock(), [ submitting ]);
const now = Date.now();
const isLocked = lockState.lockedUntil > now;
const recordFailure = useCallback(() =>
{
const state = readLock();
const currentNow = Date.now();
if(currentNow - state.firstAt > LOCK_WINDOW_MS)
{
writeLock({ attempts: 1, firstAt: currentNow, lockedUntil: 0 });
return;
}
const attempts = state.attempts + 1;
const lockedUntil = attempts >= MAX_ATTEMPTS ? currentNow + LOCK_DURATION_MS : 0;
writeLock({ attempts, firstAt: state.firstAt || currentNow, lockedUntil });
}, []);
const clearLock = useCallback(() =>
{
writeLock({ attempts: 0, firstAt: 0, lockedUntil: 0 });
}, []);
const postJson = useCallback(async (url: string, body: Record<string, unknown>) =>
{
const response = await fetch(url, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Requested-With': 'NitroLoginView'
},
body: JSON.stringify(body)
});
let payload: Record<string, unknown> = {};
try { payload = await response.json(); }
catch { }
return { ok: response.ok, status: response.status, payload };
}, []);
const healthUrl = GetConfigurationValue<string>('login.health.endpoint', '');
const healthMethodRaw = GetConfigurationValue<string>('login.health.method', 'GET');
const healthMethod = (healthMethodRaw || 'GET').toUpperCase();
const checkServerReachable = useCallback(async (): Promise<boolean> =>
{
if(!healthUrl) return true;
try
{
const controller = new AbortController();
const timer = window.setTimeout(() => controller.abort(), 5000);
try
{
const response = await fetch(healthUrl, { method: healthMethod, credentials: 'omit', signal: controller.signal });
if(response.status === 403) return false;
if(response.status >= 500) return false;
return true;
}
finally
{
window.clearTimeout(timer);
}
}
catch
{
return false;
}
}, [ healthUrl, healthMethod ]);
const pingLoginServer = useCallback(async () =>
{
setLoginPingingServer(true);
try
{
const ok = await checkServerReachable();
setLoginServerReachable(ok);
return ok;
}
finally
{
setLoginPingingServer(false);
}
}, [ checkServerReachable ]);
const handleLoginSubmit = useCallback(async (event: FormEvent<HTMLFormElement>) =>
{
event.preventDefault();
if(submitting || isEntering) return;
const nowTs = Date.now();
if(nowTs - submitTimeRef.current < 1000) return;
submitTimeRef.current = nowTs;
const state = readLock();
if(state.lockedUntil > nowTs)
{
const remaining = Math.ceil((state.lockedUntil - nowTs) / 1000);
setError(t('nitro.login.error.too_many_attempts', 'Too many attempts. Try again in %seconds%s.', [ 'seconds' ], [ String(remaining) ]));
return;
}
if(!username.trim() || !password)
{
setError(t('nitro.login.error.missing_credentials', 'Please enter both your Habbo name and password.'));
return;
}
if(turnstileEnabled && !loginTurnstileToken)
{
setError(t('nitro.login.error.turnstile', 'Please complete the security check.'));
return;
}
setError(null);
setSubmitting(true);
try
{
const { ok, payload } = await postJson(loginUrl, {
username: username.trim(),
password,
remember: rememberMe,
turnstileToken: turnstileEnabled ? loginTurnstileToken : undefined
});
const ssoTicket = typeof payload.ssoTicket === 'string' ? payload.ssoTicket : (typeof payload.sso === 'string' ? payload.sso : '');
if(ok && ssoTicket)
{
clearLock();
persistAccessTokenFromPayload(payload);
if(rememberMe) StoreRememberLoginFromPayload(payload, typeof payload.username === 'string' ? payload.username : username.trim(), ssoTicket);
else ClearRememberLogin();
onAuthenticated(ssoTicket);
return;
}
recordFailure();
const message = typeof payload.error === 'string' ? payload.error : t('nitro.login.error.invalid_credentials', 'Invalid Habbo name or password.');
setError(message);
resetLoginTurnstile();
}
catch(err)
{
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 ]);
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 interpretAvailability = (ok: boolean, status: number, payload: Record<string, unknown>): { available: boolean; error?: string } =>
{
const isTrue = (v: unknown) => v === true || v === 'true' || v === 1 || v === '1';
const isFalse = (v: unknown) => v === false || v === 'false' || v === 0 || v === '0';
if(ok)
{
if(isTrue(payload.available) || isFalse(payload.exists) || isFalse(payload.taken) || isFalse(payload.inUse) || isFalse(payload.in_use)) return { available: true };
if(isFalse(payload.available) || isTrue(payload.exists) || isTrue(payload.taken) || isTrue(payload.inUse) || isTrue(payload.in_use)) return { available: false, error: typeof payload.error === 'string' ? payload.error : undefined };
return { available: true };
}
if(status === 404 || status === 405 || status === 501) return { available: true };
if(status === 409) return { available: false, error: typeof payload.error === 'string' ? payload.error : undefined };
return { available: true };
};
const checkEmailAvailable = useCallback(async (email: string): Promise<{ available: boolean; error?: string }> =>
{
try
{
const { ok, status, payload } = await postJson(checkEmailUrl, { email });
const result = interpretAvailability(ok, status, payload);
if(result.available) return { available: true };
return { available: false, error: result.error || t('nitro.login.error.email_taken', 'This email is already in use.') };
}
catch
{
return { available: true };
}
}, [ checkEmailUrl, postJson ]);
const checkUsernameAvailable = useCallback(async (username: string): Promise<{ available: boolean; error?: string }> =>
{
try
{
const { ok, status, payload } = await postJson(checkUsernameUrl, { username });
const result = interpretAvailability(ok, status, payload);
if(result.available) return { available: true };
return { available: false, error: result.error || t('nitro.login.error.username_taken', 'This Habbo name is already taken.') };
}
catch
{
return { available: true };
}
}, [ checkUsernameUrl, postJson ]);
const handleRegisterSubmit = useCallback(async (body: { username: string; email: string; password: string; figure: string; gender: string; turnstileToken: string; }, onDialogReset: () => void) =>
{
if(turnstileEnabled && !body.turnstileToken)
{
setError(t('nitro.login.error.turnstile', 'Please complete the security check.'));
return;
}
setError(null);
setInfo(null);
setSubmitting(true);
try
{
const { ok, payload } = await postJson(registerUrl, {
username: body.username,
email: body.email,
password: body.password,
figure: body.figure,
gender: body.gender,
turnstileToken: turnstileEnabled ? body.turnstileToken : undefined
});
if(ok)
{
const friendly = t('nitro.login.register.success', 'Welcome aboard, %username%! Your account is ready — log in below with the password you just chose.', [ 'username' ], [ body.username ]);
setInfo(typeof payload.message === 'string' ? payload.message : friendly);
setMode('login');
setUsername(body.username);
setPassword('');
return;
}
setError(typeof payload.error === 'string' ? payload.error : t('nitro.login.error.register_failed', 'Unable to create your account.'));
onDialogReset();
}
catch
{
setError(t('nitro.login.error.register_unreachable', 'Unable to reach the registration service.'));
onDialogReset();
}
finally
{
setSubmitting(false);
}
}, [ turnstileEnabled, registerUrl, postJson ]);
const handleForgotSubmit = useCallback(async (body: { email: string; turnstileToken: string; }, onDialogReset: () => void) =>
{
if(turnstileEnabled && !body.turnstileToken)
{
setError(t('nitro.login.error.turnstile', 'Please complete the security check.'));
return;
}
setError(null);
setInfo(null);
setSubmitting(true);
try
{
const { ok, payload } = await postJson(forgotUrl, {
email: body.email,
turnstileToken: turnstileEnabled ? body.turnstileToken : undefined
});
if(ok)
{
const friendly = t('nitro.login.forgot.success', 'Email sent! If an account matches that address you\'ll find a reset link in your inbox shortly (check spam if it doesn\'t show up within a minute).');
setInfo(typeof payload.message === 'string' ? payload.message : friendly);
setMode('login');
return;
}
setError(typeof payload.error === 'string' ? payload.error : t('nitro.login.error.forgot_failed', 'Unable to send a reset email right now.'));
onDialogReset();
}
catch
{
setError(t('nitro.login.error.forgot_unreachable', 'Unable to reach the password reset service.'));
onDialogReset();
}
finally
{
setSubmitting(false);
}
}, [ turnstileEnabled, forgotUrl, postJson ]);
return (
<div
className="nitro-login-view"
style={ backgroundColor ? { background: backgroundColor } : undefined }
>
{ background ? <img className="login-background login-layer login-layer-img" src={ background } alt="" draggable={ false } /> : null }
{ sun ? <img className="login-sun login-layer login-layer-img" src={ sun } alt="" draggable={ false } /> : null }
{ drape ? <img className="login-drape login-layer login-layer-img" src={ drape } alt="" draggable={ false } /> : null }
{ left ? <img className="login-left login-layer login-layer-img" src={ left } alt="" draggable={ false } /> : null }
{ rightRepeat ? <div className="login-right-repeat login-layer" style={ { backgroundImage: `url(${ rightRepeat })` } } /> : null }
{ right ? <img className="login-right login-layer login-layer-img" src={ right } alt="" draggable={ false } /> : null }
<div className="login-image-preloader" aria-hidden="true" data-version={ loginImagesVersion }>
{ loginImageUrls.map(url => <img key={ url } src={ url } decoding="async" loading="eager" alt="" />) }
</div>
{ loginWidgetSlots.length > 0 &&
<div className="login-widgets">
{ loginWidgetSlots.map(slot =>
{
const image = typeof slot.conf.image === 'string' ? interpolate(slot.conf.image) : '';
const texts = typeof slot.conf.texts === 'string' ? slot.conf.texts : '';
const btnText = typeof slot.conf.btnText === 'string' ? slot.conf.btnText : '';
const btnLink = typeof slot.conf.btnLink === 'string' ? interpolate(slot.conf.btnLink) : '';
const title = typeof slot.conf.title === 'string' ? slot.conf.title : (texts || slot.type);
const description = typeof slot.conf.description === 'string' ? slot.conf.description : '';
return (
<div key={ slot.key } className="login-widget-slot" data-widget-type={ slot.type }>
{ image && <img className="login-widget-image" src={ image } alt="" draggable={ false } /> }
<div className="login-widget-content">
<div className="login-widget-title">{ title }</div>
{ description && <div className="login-widget-description">{ description }</div> }
{ btnText &&
<button
type="button"
className="login-widget-button"
onClick={ () => { if(btnLink) window.location.href = btnLink; } }
>
{ btnText }
</button> }
</div>
</div>
);
}) }
</div> }
{ newsUrl && <NewsWindow newsUrl={ newsUrl } /> }
<div className="login-stack">
<div className="nitro-login-card login-language-card">
<div className="card-title">{ t('nitro.login.language.title', 'Choose your language') }</div>
<div className="login-language-grid" role="list" aria-label={ t('nitro.login.language.aria', 'Language selection') }>
{ LOGIN_LOCALES.map(locale =>
<button
key={ locale.code }
type="button"
className={ `login-language-option ${ selectedLocale.code === locale.code ? 'selected' : '' }` }
onClick={ () => setSelectedLocale(locale) }
title={ locale.label }
aria-label={ locale.label }
style={ selectedLocale.code === locale.code ? { backgroundImage: `url(${ flagSelected })` } : undefined }
>
<img src={ locale.flag } alt="" draggable={ false } />
<span>{ locale.label }</span>
</button>) }
</div>
{ localeError.length > 0 && <div className="language-error">{ localeError }</div> }
<button type="button" className="ok-button login-language-confirm" disabled={ localeApplying } onClick={ confirmLocaleSelection }>
{ localeApplying ? t('nitro.login.language.loading', 'Loading...') : t('nitro.login.language.ok', 'OK') }
</button>
</div>
<div className="nitro-login-card">
<div className="card-title">{ t('nitro.login.firsttime.title', 'First time here?') }</div>
<div className="card-body register-card-body">
<span>{ t('nitro.login.firsttime.text', 'Don\'t have a Habbo yet?') }</span>
<a onClick={ () => setMode('register') }>{ t('nitro.login.firsttime.link', 'You can create one here') }</a>
</div>
</div>
<div className="nitro-login-card">
<div className="card-title">{ t('nitro.login.card.title', 'What\'s your Habbo called?') }</div>
<form className="card-body" onSubmit={ handleLoginSubmit } autoComplete="on">
<div className="field">
<label htmlFor="login-username">{ t('login.username', 'Name of your Habbo') }</label>
<input
id="login-username"
name="username"
autoComplete="username"
type="text"
maxLength={ 32 }
value={ username }
onChange={ e => setUsername(e.target.value) }
/>
</div>
<div className="field">
<label htmlFor="login-password">{ t('generic.password', 'Password') }</label>
<input
id="login-password"
name="password"
autoComplete="current-password"
type="password"
maxLength={ 128 }
value={ password }
onChange={ e => setPassword(e.target.value) }
/>
</div>
<label className="remember-row">
<input
type="checkbox"
checked={ rememberMe }
onChange={ e => setRememberMe(e.target.checked) }
/>
<span>{ t('login.remember_me', 'Remember me') }</span>
</label>
{ turnstileEnabled && mode === 'login' &&
<TurnstileWidget
siteKey={ turnstileSiteKey }
size="compact"
onToken={ setLoginTurnstileToken }
onExpire={ () => setLoginTurnstileToken('') }
onError={ () => setLoginTurnstileToken('') }
resetSignal={ loginTurnstileResetSignal }
/> }
{ loginServerReachable === false &&
<div className="error-line server-offline">
{ t('nitro.login.server.offline.short', 'The gameserver isn\'t running right now. Please try again in a moment.') }
<button type="button" className="retry-link" onClick={ pingLoginServer } disabled={ loginPingingServer }>
{ loginPingingServer ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.server.retry', 'Retry') }
</button>
</div>
}
{ error && <div className="error-line">{ error }</div> }
{ info && <div className="info-line">{ info }</div> }
<div className="submit-row">
<button
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>
<a className="forgot" onClick={ () => setMode('forgot') }>{ t('login.forgot_password', 'Forgotten your password?') }</a>
</form>
</div>
</div>
{ mode === 'register' &&
<RegisterDialog
onCancel={ () => setMode('login') }
onSubmit={ handleRegisterSubmit }
onCheckEmail={ checkEmailAvailable }
onCheckUsername={ checkUsernameAvailable }
onCheckServer={ checkServerReachable }
submitting={ submitting }
error={ error }
info={ info }
turnstileEnabled={ turnstileEnabled }
turnstileSiteKey={ turnstileSiteKey }
/> }
{ mode === 'forgot' &&
<ForgotDialog
onCancel={ () => setMode('login') }
onSubmit={ handleForgotSubmit }
submitting={ submitting }
error={ error }
info={ info }
turnstileEnabled={ turnstileEnabled }
turnstileSiteKey={ turnstileSiteKey }
/> }
</div>
);
};
interface DialogSharedProps
{
onCancel: () => void;
submitting: boolean;
error: string | null;
info: string | null;
turnstileEnabled: boolean;
turnstileSiteKey: string;
}
interface RegisterDialogProps extends DialogSharedProps
{
onSubmit: (body: { username: string; email: string; password: string; figure: string; gender: string; turnstileToken: string; }, onDialogReset: () => void) => void;
onCheckEmail: (email: string) => Promise<{ available: boolean; error?: string }>;
onCheckUsername: (username: string) => Promise<{ available: boolean; error?: string }>;
onCheckServer: () => Promise<boolean>;
}
type RegisterStep = 'credentials' | 'avatar';
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
type GenderKey = 'M' | 'F';
const PART_ROWS: string[] = [ 'hr', 'hd', 'ch', 'lg', 'sh' ];
const FALLBACK_DEFAULTS: Record<GenderKey, Record<string, { partId: number; colors: number[] }>> = {
M: {
hr: { partId: 180, colors: [ 45 ] },
hd: { partId: 180, colors: [ 1 ] },
ch: { partId: 215, colors: [ 66 ] },
lg: { partId: 270, colors: [ 82 ] },
sh: { partId: 290, colors: [ 80 ] }
},
F: {
hr: { partId: 515, colors: [ 45 ] },
hd: { partId: 600, colors: [ 1 ] },
ch: { partId: 660, colors: [ 100 ] },
lg: { partId: 716, colors: [ 82 ] },
sh: { partId: 725, colors: [ 61 ] }
}
};
const FALLBACK_HEX: Record<number, string> = {
1: '#ffcb98', 8: '#f4ac54', 14: '#f5da88', 19: '#b87560', 20: '#9c543f',
45: '#e8c498', 61: '#f1ece3', 66: '#96743d', 80: '#4f4d4d', 82: '#7f4f30',
92: '#ececec', 100: '#c7ddff', 106: '#c6e6bd', 110: '#91a7c8', 143: '#ffffff'
};
interface FigureColor { id: number; hexCode: string; club: number; selectable: boolean; }
interface FigurePalette { id: number; colors: FigureColor[]; }
interface FigureSet { id: number; gender: 'M' | 'F' | 'U'; club: number; selectable: boolean; }
interface FigureSetType { type: string; paletteId: number; sets: FigureSet[]; }
interface FigureData { palettes: FigurePalette[]; setTypes: FigureSetType[]; }
interface PartSelection { partId: number; colors: number[]; }
type FigureSelection = Record<string, PartSelection>;
const buildFigureString = (selection: FigureSelection): string =>
{
const seen = new Set<string>();
const parts: string[] = [];
const push = (setType: string) =>
{
if(seen.has(setType)) return;
seen.add(setType);
const sel = selection[setType];
if(!sel || sel.partId < 0) return;
const tail = (sel.colors && sel.colors.length) ? `-${ sel.colors.join('-') }` : '';
parts.push(`${ setType }-${ sel.partId }${ tail }`);
};
for(const setType of PART_ROWS) push(setType);
for(const setType of Object.keys(selection)) push(setType);
return parts.join('.');
};
const HEAD_ONLY_PARTS = new Set([ 'hr', 'hd' ]);
const buildPartPreviewFigure = (setType: string, selection: FigureSelection, gender: GenderKey): string =>
{
const defaults = FALLBACK_DEFAULTS[gender];
const partSel = selection[setType] ?? defaults[setType];
const tail = (partSel.colors && partSel.colors.length) ? `-${ partSel.colors.join('-') }` : '';
const hd = defaults.hd;
const head = `hd-${ hd.partId }-${ hd.colors.join('-') }`;
const part = `${ setType }-${ partSel.partId }${ tail }`;
return setType === 'hd' ? part : `${ head }.${ part }`;
};
const AVATAR_PREVIEW_CACHE = new Map<string, string>();
const AVATAR_PREVIEW_CACHE_MAX = 200;
const AVATAR_PREVIEW_MAX_ATTEMPTS = 4;
const AVATAR_PREVIEW_TIMEOUT_MS = 8000;
const renderAvatarPreview = (figure: string, gender: GenderKey, setType: string): Promise<string> =>
{
if(!figure) return Promise.resolve('');
const cacheKey = `${ gender }|${ setType }|${ figure }`;
const cached = AVATAR_PREVIEW_CACHE.get(cacheKey);
if(cached) return Promise.resolve(cached);
return new Promise<string>(resolve =>
{
let avatarImage: IAvatarImage | null = null;
let resolved = false;
let attempts = 0;
let timer: number | null = null;
const finish = (url: string) =>
{
if(resolved) return;
resolved = true;
if(timer !== null) window.clearTimeout(timer);
try { avatarImage?.dispose(); } catch {}
avatarImage = null;
if(url)
{
AVATAR_PREVIEW_CACHE.set(cacheKey, url);
if(AVATAR_PREVIEW_CACHE.size > AVATAR_PREVIEW_CACHE_MAX)
{
const firstKey = AVATAR_PREVIEW_CACHE.keys().next().value;
if(firstKey) AVATAR_PREVIEW_CACHE.delete(firstKey);
}
}
resolve(url);
};
timer = window.setTimeout(() => finish(''), AVATAR_PREVIEW_TIMEOUT_MS);
const attempt = () =>
{
if(resolved) return;
if(attempts >= AVATAR_PREVIEW_MAX_ATTEMPTS) { finish(''); return; }
attempts++;
try { avatarImage?.dispose(); } catch {}
avatarImage = null;
try
{
avatarImage = GetAvatarRenderManager().createAvatarImage(figure, AvatarScaleType.LARGE, gender, {
resetFigure: () => attempt(),
dispose: () => {},
disposed: false
});
}
catch
{
finish('');
return;
}
if(!avatarImage) { finish(''); return; }
if(avatarImage.isPlaceholder()) return;
try
{
const url = avatarImage.processAsImageUrl(setType);
if(url) finish(url);
}
catch
{
finish('');
}
};
attempt();
});
};
const useAvatarPreview = (figure: string, gender: GenderKey, setType: string): string =>
{
const [ url, setUrl ] = useState<string>(() =>
AVATAR_PREVIEW_CACHE.get(`${ gender }|${ setType }|${ figure }`) ?? '');
useEffect(() =>
{
const cacheKey = `${ gender }|${ setType }|${ figure }`;
const cached = AVATAR_PREVIEW_CACHE.get(cacheKey);
if(cached)
{
setUrl(cached);
return;
}
let cancelled = false;
setUrl('');
renderAvatarPreview(figure, gender, setType).then(result =>
{
if(!cancelled) setUrl(result);
});
return () => { cancelled = true; };
}, [ figure, gender, setType ]);
return url;
};
interface AvatarPartRowProps
{
setType: string;
selection: FigureSelection;
gender: GenderKey;
onPrev: () => void;
onNext: () => void;
}
const AvatarPartRow: FC<AvatarPartRowProps> = ({ setType, selection, gender, onPrev, onNext }) =>
{
const figure = useMemo(() => buildPartPreviewFigure(setType, selection, gender), [ setType, selection, gender ]);
const previewSetType = HEAD_ONLY_PARTS.has(setType) ? AvatarSetType.HEAD : AvatarSetType.FULL;
const url = useAvatarPreview(figure, gender, previewSetType);
return (
<div className="avatar-part-row">
<button type="button" className="arrow-btn" aria-label={ `Previous ${ setType }` } onClick={ onPrev }>&lsaquo;</button>
<div className={ `part-preview part-preview-${ setType }` }>
{ url && <img src={ url } alt={ `${ setType } preview` } onError={ e => { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } /> }
</div>
<button type="button" className="arrow-btn" aria-label={ `Next ${ setType }` } onClick={ onNext }>&rsaquo;</button>
</div>
);
};
const RegisterDialog: FC<RegisterDialogProps> = props =>
{
const { onCancel, onSubmit, onCheckEmail, onCheckUsername, onCheckServer, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props;
const [ step, setStep ] = useState<RegisterStep>('credentials');
const [ email, setEmail ] = useState('');
const [ password, setPassword ] = useState('');
const [ confirm, setConfirm ] = useState('');
const [ username, setUsername ] = useState('');
const [ gender, setGender ] = useState<GenderKey>('F');
const [ selection, setSelection ] = useState<FigureSelection>(() => ({ ...FALLBACK_DEFAULTS.F }));
const [ localError, setLocalError ] = useState<string | null>(null);
const [ checking, setChecking ] = useState(false);
const [ turnstileToken, setTurnstileToken ] = useState('');
const [ resetSignal, setResetSignal ] = useState(0);
const [ serverReachable, setServerReachable ] = useState<boolean | null>(null);
const [ pingingServer, setPingingServer ] = useState(false);
const pingServer = useCallback(async () =>
{
setPingingServer(true);
try
{
const ok = await onCheckServer();
setServerReachable(ok);
return ok;
}
finally
{
setPingingServer(false);
}
}, [ onCheckServer ]);
useEffect(() =>
{
let cancelled = false;
(async () =>
{
const ok = await onCheckServer();
if(!cancelled) setServerReachable(ok);
})();
return () => { cancelled = true; };
}, [ onCheckServer ]);
const resetWidget = useCallback(() =>
{
setTurnstileToken('');
setResetSignal(prev => prev + 1);
}, []);
useEffect(() => { setLocalError(null); }, [ step ]);
const [ figureData, setFigureData ] = useState<FigureData | null>(null);
const figureDataUrlRaw = GetConfigurationValue<string>('avatar.figuredata.url', '');
const figureDataUrl = useMemo(() =>
{
if(!figureDataUrlRaw) return '';
try { return GetConfiguration().interpolate(figureDataUrlRaw); }
catch { return figureDataUrlRaw; }
}, [ figureDataUrlRaw ]);
useEffect(() =>
{
if(step !== 'avatar' || figureData || !figureDataUrl) return;
let cancelled = false;
fetch(figureDataUrl, { credentials: 'omit' })
.then(r => r.ok ? r.json() : null)
.then(json => { if(!cancelled && json) setFigureData(json as FigureData); })
.catch(() => { });
return () => { cancelled = true; };
}, [ step, figureData, figureDataUrl ]);
const partOptions = useMemo(() =>
{
const result: Record<string, Record<GenderKey, number[]>> = {};
if(!figureData) return result;
for(const st of figureData.setTypes)
{
if(!PART_ROWS.includes(st.type)) continue;
const forGender = (g: GenderKey) => st.sets
.filter(s => s.selectable && s.club === 0 && (s.gender === g || s.gender === 'U'))
.map(s => s.id);
result[st.type] = { M: forGender('M'), F: forGender('F') };
}
return result;
}, [ figureData ]);
const paletteOptions = useMemo(() =>
{
const result: Record<string, { id: number; hex: string }[]> = {};
if(!figureData) return result;
for(const st of figureData.setTypes)
{
if(!PART_ROWS.includes(st.type)) continue;
const palette = figureData.palettes.find(p => p.id === st.paletteId);
if(!palette) { result[st.type] = []; continue; }
result[st.type] = palette.colors
.filter(c => c.selectable && c.club === 0)
.map(c => ({ id: c.id, hex: '#' + c.hexCode.toUpperCase() }));
}
return result;
}, [ figureData ]);
const hexFor = useCallback((setType: string, colorId: number): string =>
{
const list = paletteOptions[setType];
if(list)
{
const found = list.find(c => c.id === colorId);
if(found) return found.hex;
}
return FALLBACK_HEX[colorId] || '#c9c9c9';
}, [ paletteOptions ]);
const [ hotLooks, setHotLooks ] = useState<{ gender: GenderKey; figure: string }[]>([]);
const [ hotLookIndex, setHotLookIndex ] = useState(-1);
useEffect(() =>
{
if(step !== 'avatar' || hotLooks.length) return;
let cancelled = false;
fetch(configFileUrl('hotlooks.json', true), { credentials: 'omit' })
.then(r => r.ok ? r.json() : null)
.then((json: unknown) =>
{
if(cancelled || !Array.isArray(json)) return;
const parsed: { gender: GenderKey; figure: string }[] = [];
for(const entry of json as Record<string, unknown>[])
{
const rawGender = typeof entry._gender === 'string' ? entry._gender.toUpperCase() : '';
const figure = typeof entry._figure === 'string' ? entry._figure : '';
if((rawGender !== 'M' && rawGender !== 'F') || !figure) continue;
parsed.push({ gender: rawGender as GenderKey, figure });
}
if(parsed.length) setHotLooks(parsed);
})
.catch(() => { });
return () => { cancelled = true; };
}, [ step, hotLooks.length ]);
const applyLook = useCallback((figure: string, lookGender: GenderKey) =>
{
const next: FigureSelection = {};
for(const setPart of figure.split('.'))
{
const bits = setPart.split('-');
if(bits.length < 2) continue;
const setType = bits[0];
const partId = parseInt(bits[1], 10);
if(!setType || Number.isNaN(partId)) continue;
const colors: number[] = [];
for(let i = 2; i < bits.length; i++)
{
const c = parseInt(bits[i], 10);
if(!Number.isNaN(c)) colors.push(c);
}
next[setType] = { partId, colors };
}
for(const setType of PART_ROWS)
{
if(!next[setType]) next[setType] = { ...FALLBACK_DEFAULTS[lookGender][setType] };
}
setGender(lookGender);
setSelection(next);
}, []);
const cycleHotLook = useCallback(() =>
{
if(!hotLooks.length) return;
const nextIdx = (hotLookIndex + 1) % hotLooks.length;
setHotLookIndex(nextIdx);
const look = hotLooks[nextIdx];
applyLook(look.figure, look.gender);
}, [ hotLooks, hotLookIndex, applyLook ]);
const credentialsValid =
EMAIL_REGEX.test(email.trim()) &&
password.length >= 8 &&
password === confirm;
const handleCredentialsNext = async (event: FormEvent<HTMLFormElement>) =>
{
event.preventDefault();
setLocalError(null);
if(!email.trim() || !password || !confirm)
{
setLocalError(t('nitro.login.register.error.missing_fields', 'Please fill in every field.'));
return;
}
if(!EMAIL_REGEX.test(email.trim()))
{
setLocalError(t('nitro.login.register.error.invalid_email', 'Please enter a valid email address.'));
return;
}
if(password.length < 8)
{
setLocalError(t('nitro.login.register.error.password_too_short', 'Your password must be at least 8 characters.'));
return;
}
if(password !== confirm)
{
setLocalError(t('nitro.login.register.error.password_mismatch', 'Passwords do not match.'));
return;
}
setChecking(true);
try
{
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');
}
finally
{
setChecking(false);
}
};
const applyGender = (newGender: GenderKey) =>
{
setGender(newGender);
setSelection({ ...FALLBACK_DEFAULTS[newGender] });
setHotLookIndex(-1);
};
const getPartList = useCallback((setType: string): number[] =>
{
const loaded = partOptions[setType]?.[gender];
if(loaded && loaded.length) return loaded;
const fallback = FALLBACK_DEFAULTS[gender][setType]?.partId;
return fallback !== undefined ? [ fallback ] : [];
}, [ partOptions, gender ]);
const getColorList = useCallback((setType: string): number[] =>
{
const loaded = paletteOptions[setType];
if(loaded && loaded.length) return loaded.map(c => c.id);
const fallback = FALLBACK_DEFAULTS[gender][setType]?.colors?.[0];
return fallback !== undefined ? [ fallback ] : [];
}, [ paletteOptions, gender ]);
const cyclePart = (setType: string, direction: 1 | -1) =>
{
const options = getPartList(setType);
if(!options.length) return;
const current = selection[setType]?.partId ?? options[0];
const idx = options.indexOf(current);
const nextIdx = ((idx === -1 ? 0 : idx) + direction + options.length) % options.length;
const colors = getColorList(setType);
setSelection(prev => ({
...prev,
[setType]: {
partId: options[nextIdx],
colors: prev[setType]?.colors ?? [ colors[0] ?? 0 ]
}
}));
};
const cycleColor = (setType: string, direction: 1 | -1) =>
{
const colors = getColorList(setType);
if(!colors.length) return;
const currentColor = selection[setType]?.colors?.[0] ?? colors[0];
const idx = colors.indexOf(currentColor);
const nextIdx = ((idx === -1 ? 0 : idx) + direction + colors.length) % colors.length;
const parts = getPartList(setType);
setSelection(prev => ({
...prev,
[setType]: {
partId: prev[setType]?.partId ?? parts[0],
colors: [ colors[nextIdx] ]
}
}));
};
const figure = buildFigureString(selection);
const previewSrc = useAvatarPreview(figure, gender, AvatarSetType.FULL);
const handleAvatarSubmit = async (event: FormEvent<HTMLFormElement>) =>
{
event.preventDefault();
setLocalError(null);
const trimmed = username.trim();
if(!trimmed)
{
setLocalError(t('nitro.login.register.error.username_required', 'Please choose a Habbo name.'));
return;
}
if(trimmed.length < 3 || trimmed.length > 16)
{
setLocalError(t('nitro.login.register.error.username_length', 'Habbo name must be 316 characters.'));
return;
}
if(turnstileEnabled && !turnstileToken)
{
setLocalError(t('nitro.login.error.turnstile', 'Please complete the security check.'));
return;
}
setChecking(true);
try
{
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);
}
onSubmit({
username: trimmed,
email: email.trim(),
password,
figure,
gender,
turnstileToken
}, resetWidget);
};
const busy = submitting || checking || pingingServer;
const serverOffline = serverReachable === false;
return (
<div className="nitro-login-modal">
<div className={ `dialog ${ step === 'avatar' ? 'dialog-avatar' : '' }` }>
<div className="nitro-login-card">
<div className="card-title">
<span>{ t('nitro.login.register.title', 'Habbo Details') }</span>
<span className="nitro-card-close-button" role="button" aria-label={ t('generic.close', 'Close') } onClick={ onCancel } />
</div>
{ step === 'credentials' &&
<form className="card-body" onSubmit={ handleCredentialsNext } autoComplete="on">
<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.') }
</div>
{ serverOffline &&
<div className="error-line server-offline">
{ t('nitro.login.register.server.offline', 'The gameserver isn\'t running right now, so new accounts can\'t be created. Please try again in a moment.') }
<button type="button" className="retry-link" onClick={ pingServer } disabled={ pingingServer }>
{ pingingServer ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.server.retry', 'Retry') }
</button>
</div>
}
<div className="field">
<label htmlFor="register-email">{ t('nitro.login.register.email', 'Email') }</label>
<input id="register-email" type="email" maxLength={ 120 } autoComplete="email"
value={ email } onChange={ e => setEmail(e.target.value) } />
</div>
<div className="field">
<label htmlFor="register-password">{ t('generic.password', 'Password') }</label>
<input id="register-password" type="password" maxLength={ 128 } autoComplete="new-password"
value={ password } onChange={ e => setPassword(e.target.value) } />
</div>
<div className="field">
<label htmlFor="register-confirm">{ t('nitro.login.register.confirm_password', 'Confirm password') }</label>
<input id="register-confirm" type="password" maxLength={ 128 } autoComplete="new-password"
value={ confirm } onChange={ e => setConfirm(e.target.value) } />
</div>
{ (localError || error) && <div className="error-line">{ localError || error }</div> }
{ info && <div className="info-line">{ info }</div> }
<div className="step-footer">
<span className="step-indicator">1/2</span>
<button type="submit" className="ok-button" disabled={ !credentialsValid || busy || serverOffline }>
{ checking || pingingServer ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.register.next', 'Next') }
</button>
</div>
</form>
}
{ step === 'avatar' &&
<form className="card-body" onSubmit={ handleAvatarSubmit } autoComplete="on">
<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.') }
</div>
{ serverOffline &&
<div className="error-line server-offline">
{ t('nitro.login.register.server.offline', 'The gameserver isn\'t running right now, so new accounts can\'t be created. Please try again in a moment.') }
<button type="button" className="retry-link" onClick={ pingServer } disabled={ pingingServer }>
{ pingingServer ? t('nitro.login.server.checking', 'Checking…') : t('nitro.login.server.retry', 'Retry') }
</button>
</div>
}
<div className="field">
<input id="register-username" type="text" maxLength={ 16 } autoComplete="username" placeholder={ t('nitro.login.register.username_placeholder', 'HabboName') }
value={ username } onChange={ e => setUsername(e.target.value) } />
</div>
<div className="gender-row">
<label>
<input type="radio" name="register-gender" checked={ gender === 'F' } onChange={ () => applyGender('F') } />
<span>{ t('nitro.login.register.gender.girl', 'Girl') }</span>
</label>
<label>
<input type="radio" name="register-gender" checked={ gender === 'M' } onChange={ () => applyGender('M') } />
<span>{ t('nitro.login.register.gender.boy', 'Boy') }</span>
</label>
</div>
<div className="avatar-builder">
<div className="avatar-part-col">
{ PART_ROWS.map(setType => (
<AvatarPartRow
key={ `part-${ setType }` }
setType={ setType }
selection={ selection }
gender={ gender }
onPrev={ () => cyclePart(setType, -1) }
onNext={ () => cyclePart(setType, 1) }
/>
)) }
</div>
<div className="avatar-preview">
{ previewSrc && <img src={ previewSrc } alt="Habbo preview" onError={ e => { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } /> }
</div>
<div className="avatar-color-col">
{ PART_ROWS.map(setType => {
const fallbackColor = FALLBACK_DEFAULTS[gender][setType]?.colors?.[0] ?? 0;
const currentColor = selection[setType]?.colors?.[0] ?? fallbackColor;
const swatchHex = hexFor(setType, currentColor);
return (
<div className="avatar-color-row" key={ `color-${ setType }` }>
<button type="button" className="arrow-btn" aria-label={ `Previous color ${ setType }` }
onClick={ () => cycleColor(setType, -1) }>&lsaquo;</button>
<div className="color-swatch" style={ { background: swatchHex } } />
<button type="button" className="arrow-btn" aria-label={ `Next color ${ setType }` }
onClick={ () => cycleColor(setType, 1) }>&rsaquo;</button>
</div>
);
}) }
</div>
</div>
<div className="hot-looks-row">
<button type="button" className="ok-button hot-looks-button"
onClick={ cycleHotLook }
disabled={ !hotLooks.length || busy }
title={ hotLooks.length
? t('nitro.login.register.hotlooks.available', '%count% looks available', [ 'count' ], [ String(hotLooks.length) ])
: t('nitro.login.register.hotlooks.none', 'No hot looks loaded') }>
{ t('nitro.login.register.hotlooks', 'Hot Looks') }{ hotLookIndex >= 0 && hotLooks.length ? ` (${ hotLookIndex + 1 }/${ hotLooks.length })` : '' }
</button>
</div>
{ turnstileEnabled &&
<TurnstileWidget
siteKey={ turnstileSiteKey }
size="compact"
onToken={ setTurnstileToken }
onExpire={ () => setTurnstileToken('') }
onError={ () => setTurnstileToken('') }
resetSignal={ resetSignal }
/> }
{ (localError || error) && <div className="error-line">{ localError || error }</div> }
{ info && <div className="info-line">{ info }</div> }
<div className="step-footer step-footer-split">
<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>
<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') }
</button>
</div>
</form>
}
</div>
</div>
</div>
);
};
interface ForgotDialogProps extends DialogSharedProps
{
onSubmit: (body: { email: string; turnstileToken: string; }, onDialogReset: () => void) => void;
}
const ForgotDialog: FC<ForgotDialogProps> = props =>
{
const { onCancel, onSubmit, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props;
const [ email, setEmail ] = useState('');
const [ localError, setLocalError ] = useState<string | null>(null);
const [ turnstileToken, setTurnstileToken ] = useState('');
const [ resetSignal, setResetSignal ] = useState(0);
const resetWidget = useCallback(() =>
{
setTurnstileToken('');
setResetSignal(prev => prev + 1);
}, []);
const handle = (event: FormEvent<HTMLFormElement>) =>
{
event.preventDefault();
setLocalError(null);
if(!email.trim())
{
setLocalError(t('nitro.login.forgot.error.email_required', 'Please enter your email address.'));
return;
}
onSubmit({ email: email.trim(), turnstileToken }, resetWidget);
};
return (
<div className="nitro-login-modal">
<div className="dialog">
<div className="nitro-login-card">
<div className="card-title">
<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 } />
</div>
<form className="card-body" onSubmit={ handle } autoComplete="on">
<div className="field">
<label htmlFor="forgot-email">{ t('nitro.login.forgot.email_label', 'Email address') }</label>
<input id="forgot-email" type="email" maxLength={ 120 } autoComplete="email"
value={ email } onChange={ e => setEmail(e.target.value) } />
</div>
{ turnstileEnabled &&
<TurnstileWidget
siteKey={ turnstileSiteKey }
size="compact"
onToken={ setTurnstileToken }
onExpire={ () => setTurnstileToken('') }
onError={ () => setTurnstileToken('') }
resetSignal={ resetSignal }
/> }
{ (localError || error) && <div className="error-line">{ localError || error }</div> }
{ info && <div className="info-line">{ info }</div> }
<div className="submit-row">
<button type="submit" className="ok-button" disabled={ submitting }>{ t('nitro.login.forgot.send_email', 'Send email') }</button>
</div>
</form>
</div>
</div>
</div>
);
};