diff --git a/public/UITexts.example b/public/UITexts.example index bb8779b..c7480c7 100644 --- a/public/UITexts.example +++ b/public/UITexts.example @@ -109,5 +109,65 @@ "groupforum.message.hide": "Hide message", "group.forum.enable.caption": "Enable / Disable group forum", "group.forum.enable.help": "If you disable the group forum, all posts will also be deleted!", - "groupforum.view.no_threads": "There are currently no active threads" -} \ No newline at end of file + "groupforum.view.no_threads": "There are currently no active threads", + "login.username": "Name of your Habbo", + "login.forgot_password": "Forgotten your password?", + + "nitro.login.firsttime.title": "First time here?", + "nitro.login.firsttime.text": "Don't have a Habbo yet?", + "nitro.login.firsttime.link": "You can create one here", + "nitro.login.card.title": "What's your Habbo called?", + + "nitro.login.server.offline.short": "The gameserver isn't running right now. Please try again in a moment.", + "nitro.login.server.offline.long": "The gameserver isn't running right now, so new accounts can't be created. Please try again in a moment.", + "nitro.login.server.checking": "Checking…", + "nitro.login.server.retry": "Retry", + + "nitro.login.register.title": "Habbo Details", + "nitro.login.register.next": "Next", + "nitro.login.register.finish": "Finish", + "nitro.login.register.creating": "Creating…", + + "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.", + "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.", + "nitro.login.register.intro.room": "Last step — pick a starter room, or skip and create your own later.", + + "nitro.login.register.confirm.label": "Confirm password", + "nitro.login.register.username.placeholder": "HabboName", + + "nitro.login.register.hotlooks.count": "%count% looks available", + "nitro.login.register.hotlooks.none": "No hot looks loaded", + + "nitro.login.register.room.skip.title": "I'm okay — I'll create my own rooms", + "nitro.login.register.room.skip.description": "Skip for now and start with an empty hotel inventory.", + "nitro.login.register.room.loading": "Loading rooms…", + "nitro.login.register.room.error": "Could not load room options. You can still skip this step.", + + "nitro.login.register.success": "Welcome aboard, %username%! Your account is ready — log in below with the password you just chose.", + + "nitro.login.forgot.title": "Reset password", + "nitro.login.forgot.email.label": "Email address", + "nitro.login.forgot.send": "Send email", + "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).", + + "nitro.login.error.missing_credentials": "Please enter both your Habbo name and password.", + "nitro.login.error.invalid_credentials": "Invalid Habbo name or password.", + "nitro.login.error.too_many_attempts": "Too many attempts. Try again in %seconds%s.", + "nitro.login.error.turnstile": "Please complete the security check.", + "nitro.login.error.server_offline": "The gameserver is not running. Please try again later.", + "nitro.login.error.login_unreachable": "Unable to reach the login service. Please try again.", + "nitro.login.error.register_failed": "Unable to create your account.", + "nitro.login.error.register_unreachable": "Unable to reach the registration service.", + "nitro.login.error.forgot_failed": "Unable to send a reset email right now.", + "nitro.login.error.forgot_unreachable": "Unable to reach the password reset service.", + "nitro.login.error.missing_fields": "Please fill in every field.", + "nitro.login.error.invalid_email": "Please enter a valid email address.", + "nitro.login.error.password_too_short": "Your password must be at least 8 characters.", + "nitro.login.error.password_mismatch": "Passwords do not match.", + "nitro.login.error.email_taken": "This email is already in use.", + "nitro.login.error.missing_username": "Please choose a Habbo name.", + "nitro.login.error.username_length": "Habbo name must be 3–16 characters.", + "nitro.login.error.username_taken": "This Habbo name is already taken.", + "nitro.login.error.missing_email": "Please enter your email address." +} +} diff --git a/public/hotlooks.json b/public/hotlooks.json new file mode 100644 index 0000000..cda5717 --- /dev/null +++ b/public/hotlooks.json @@ -0,0 +1,52 @@ +[ + { + "_gender": "m", + "_figure": "hr-155-40.hd-180-10.ch-255-1408.lg-280-64.sh-290-64.ha-1003-64", + "_hash": "b5d1a24d16c9d516b3d793c66d152b77" + }, + { + "_gender": "f", + "_figure": "hr-515-34.hd-629-8.ch-665-1408.lg-715-1320.sh-740-1408.he-1608", + "_hash": "694573ec86cf5346f1c88b1017f069f8" + }, + { + "_gender": "f", + "_figure": "hr-890-36.hd-629-8.ch-685-71.lg-715-71.sh-3068-71-73.ha-1018.fa-1202-71.ca-1802", + "_hash": "10b9a935209e6c213e54108474186dc8" + }, + { + "_gender": "m", + "_figure": "hr-115-42.hd-209-1.ch-255-73.lg-3078-82.sh-300-64", + "_hash": "1457ce2369b982bcce30e8307c005d98" + }, + { + "_gender": "m", + "_figure": "hr-115-40.hd-190-14.ch-235-1408.lg-280-1408.sh-908-1408.he-1608", + "_hash": "d35b7492386963d7612341b222f7f5d9" + }, + { + "_gender": "m", + "_figure": "hr-115-31.hd-180-14.ch-210-64.lg-3023-91.sh-300-91", + "_hash": "b49e529b7604fbd3596951bc69d6551b" + }, + { + "_gender": "m", + "_figure": "hr-100.hd-180-1.ch-210-1408.lg-270-64.sh-300-64.ha-1002-64.cc-260-64", + "_hash": "f052b0ccc54cfa933d473b433b154ef5" + }, + { + "_gender": "m", + "_figure": "hr-125-34.hd-205-14.ch-235-1408.lg-285-81.sh-300-64.wa-3211-64-64", + "_hash": "08c77292a4462c36f0393820d5753de3" + }, + { + "_gender": "f", + "_figure": "hr-890-31.hd-600-1.ch-822-71.lg-715-74.he-1602-71", + "_hash": "4102a76da4bca25d5125b75d9ea1ca14" + }, + { + "_gender": "f", + "_figure": "hr-515-35.hd-628-14.ch-667.lg-696-73.he-1606-82.ca-1810.cp-3124-81", + "_hash": "4987ff565ec8e6ecedb31b08c2b017a6" + } +] \ No newline at end of file diff --git a/public/renderer-config.json b/public/renderer-config.example similarity index 97% rename from public/renderer-config.json rename to public/renderer-config.example index 9eda4a8..a959ce2 100644 --- a/public/renderer-config.json +++ b/public/renderer-config.example @@ -45,6 +45,11 @@ "login.register.endpoint": "${api.url}/api/auth/register", "login.forgot.endpoint": "${api.url}/api/auth/forgot-password", "login.logout.endpoint": "${api.url}/api/auth/logout", + "login.health.endpoint": "${api.url}/api/health", + "login.check-email.endpoint": "${api.url}/api/auth/check-email", + "login.check-username.endpoint": "${api.url}/api/auth/check-username", + "login.room_templates.endpoint": "${api.url}/api/auth/room-templates", + "login.remember.endpoint": "${api.url}/api/auth/remember", "login.turnstile.enabled": false, "login.turnstile.sitekey": "", "avatar.mandatory.libraries": [ diff --git a/src/App.tsx b/src/App.tsx index 94128b3..241209b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -35,7 +35,6 @@ export const App: FC<{}> = props => setPrepareTrigger(prev => prev + 1); }, []); - // Listen for socket closed events (code 1000 "Bye" - server rejected SSO) useNitroEvent(NitroEventType.SOCKET_CLOSED, showSessionExpired); useMessageEvent(LoadGameUrlEvent, event => @@ -57,26 +56,77 @@ export const App: FC<{}> = props => { if(!window.NitroConfig) throw new Error('NitroConfig is not defined!'); - const ssoTicket = window.NitroConfig['sso.ticket']; + let ssoTicket = window.NitroConfig['sso.ticket']; + let configInitError: unknown = null; if(!ssoTicket || ssoTicket === '') { - // Configuration is loaded lazily — fetch it up-front so the login - // screen toggle and Turnstile keys are available before we decide. - let configInitError: unknown = null; try { await GetConfiguration().init(); } catch(e) { configInitError = e; } - const rawLoginEnabled = GetConfiguration().getValue('login.screen.enabled', false); - const loginScreenEnabled = rawLoginEnabled === true || rawLoginEnabled === 'true' || rawLoginEnabled === 1; - if(configInitError) { NitroLogger.error('[LoginScreen] Failed to load renderer-config.json — cannot resolve login.screen.enabled', configInitError); } + if(!configInitError) + { + let storedRemember: string | null = null; + try { storedRemember = window.localStorage.getItem('nitro.remember.token'); } + catch {} + + if(storedRemember) + { + const rememberUrlTemplate = GetConfiguration().getValue('login.remember.endpoint', '/api/auth/remember'); + const rememberUrl = GetConfiguration().interpolate(rememberUrlTemplate); + try + { + const response = await fetch(rememberUrl, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-Requested-With': 'NitroRememberMe' + }, + body: JSON.stringify({ rememberToken: storedRemember }) + }); + if(response.ok) + { + const payload = await response.json(); + const ticket = typeof payload.ssoTicket === 'string' ? payload.ssoTicket : ''; + if(ticket) + { + window.NitroConfig['sso.ticket'] = ticket; + ssoTicket = ticket; + try + { + if(typeof payload.rememberToken === 'string' && payload.rememberToken.length) + window.localStorage.setItem('nitro.remember.token', payload.rememberToken); + } + catch {} + } + } + else if(response.status === 401) + { + try { window.localStorage.removeItem('nitro.remember.token'); } catch {} + } + } + catch {} + } + } + } + + if(!ssoTicket || ssoTicket === '') + { + const rawLoginEnabled = GetConfiguration().getValue('login.screen.enabled', false); + const loginScreenEnabled = rawLoginEnabled === true || rawLoginEnabled === 'true' || rawLoginEnabled === 1; + if(loginScreenEnabled) { + try { await GetLocalizationManager().init(); } + catch(localizationErr) { NitroLogger.error('[LoginScreen] Localization init failed', localizationErr); } + setIsReady(false); setShowLogin(true); return; @@ -110,7 +160,7 @@ export const App: FC<{}> = props => eventMode: 'none', failIfMajorPerformanceCaveat: false, roundPixels: true, - useBackBuffer // Keep disabled by default unless explicitly enabled in NitroConfig + useBackBuffer }); await GetConfiguration().init(); diff --git a/src/components/login/LoginView.tsx b/src/components/login/LoginView.tsx index 8116373..352014b 100644 --- a/src/components/login/LoginView.tsx +++ b/src/components/login/LoginView.tsx @@ -1,8 +1,26 @@ import { GetConfiguration } from '@nitrots/nitro-renderer'; import { FC, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { GetConfigurationValue } from '../../api'; +import { GetConfigurationValue, LocalizeText } from '../../api'; import { TurnstileWidget } from './TurnstileWidget'; +const t = (key: string, fallback: string, params?: string[], replacements?: string[]): string => +{ + try + { + const value = LocalizeText(key, params ?? null, replacements ?? null); + if(value && value !== key) return value; + } + catch {} + + if(!params || !replacements) return fallback; + let out = fallback; + for(let i = 0; i < params.length; i++) + { + if(replacements[i] !== undefined) out = out.replace('%' + params[i] + '%', replacements[i]); + } + return out; +}; + type DialogMode = 'login' | 'register' | 'forgot'; const interpolate = (value: string | null | undefined): string => @@ -14,8 +32,8 @@ const interpolate = (value: string | null | undefined): string => const LOCK_KEY = 'nitro.login.lock'; const MAX_ATTEMPTS = 5; -const LOCK_WINDOW_MS = 60_000; // rolling 60s window -const LOCK_DURATION_MS = 2 * 60_000; // 2 minute lockout +const LOCK_WINDOW_MS = 60_000; +const LOCK_DURATION_MS = 2 * 60_000; type AttemptState = { attempts: number; firstAt: number; lockedUntil: number }; @@ -33,7 +51,7 @@ const readLock = (): AttemptState => const writeLock = (state: AttemptState) => { try { sessionStorage.setItem(LOCK_KEY, JSON.stringify(state)); } - catch { /* ignore */ } + catch { } }; export interface LoginViewProps @@ -46,11 +64,14 @@ export const LoginView: FC = ({ onAuthenticated }) => const [ mode, setMode ] = useState('login'); const [ username, setUsername ] = useState(''); const [ password, setPassword ] = useState(''); + const [ rememberMe, setRememberMe ] = useState(false); const [ error, setError ] = useState(null); const [ info, setInfo ] = useState(null); const [ submitting, setSubmitting ] = useState(false); const [ loginTurnstileToken, setLoginTurnstileToken ] = useState(''); const [ loginTurnstileResetSignal, setLoginTurnstileResetSignal ] = useState(0); + const [ loginServerReachable, setLoginServerReachable ] = useState(null); + const [ loginPingingServer, setLoginPingingServer ] = useState(false); const submitTimeRef = useRef(0); const loginImages: Record = ((GetConfigurationValue>('loginview', {})?.['images']) as Record) ?? {}; @@ -62,24 +83,9 @@ export const LoginView: FC = ({ onAuthenticated }) => const left = interpolate(loginImages['left'] || GetConfigurationValue('login_left', '')); const rightRepeat = interpolate(loginImages['right.repeat'] || GetConfigurationValue('login_right.repeat', '')); const right = interpolate(loginImages['right'] || GetConfigurationValue('login_right', '')); - - useEffect(() => - { - // eslint-disable-next-line no-console - console.info('[LoginView] resolved background assets', { - 'asset.url': GetConfigurationValue('asset.url', ''), - login_background: background, - 'login_background.colour': backgroundColor, - login_sun: sun, - login_drape: drape, - login_left: left, - login_right: right, - 'login_right.repeat': rightRepeat - }); - }, [ background, backgroundColor, sun, drape, left, right, rightRepeat ]); - const loginUrl = GetConfigurationValue('login.endpoint', '/api/auth/login'); const registerUrl = GetConfigurationValue('login.register.endpoint', '/api/auth/register'); + const roomTemplatesUrl = GetConfigurationValue('login.room_templates.endpoint', '/api/auth/room-templates'); const forgotUrl = GetConfigurationValue('login.forgot.endpoint', '/api/auth/forgot-password'); const turnstileSiteKey = GetConfigurationValue('login.turnstile.sitekey', ''); const rawTurnstileEnabled = GetConfigurationValue('login.turnstile.enabled', false); @@ -88,33 +94,18 @@ export const LoginView: FC = ({ onAuthenticated }) => || rawTurnstileEnabled === 1 || rawTurnstileEnabled === '1') && !!turnstileSiteKey; - useEffect(() => - { - // eslint-disable-next-line no-console - console.info('[LoginView] turnstile config', { - rawTurnstileEnabled, - turnstileEnabled, - turnstileSiteKey: turnstileSiteKey ? (turnstileSiteKey.slice(0, 6) + '…') : '(empty)' - }); - }, [ rawTurnstileEnabled, turnstileEnabled, turnstileSiteKey ]); - const resetLoginTurnstile = useCallback(() => { setLoginTurnstileToken(''); setLoginTurnstileResetSignal(prev => prev + 1); }, []); - // Clear error on mode change but keep the success notification so users - // returning to the login form can read it (e.g. "Account created"). - // Reset the login captcha only when we're actually on the login form. useEffect(() => { setError(null); if(mode === 'login') resetLoginTurnstile(); }, [ mode, resetLoginTurnstile ]); - // Auto-dismiss the info notification after a few seconds so it doesn't - // hang around forever once the user has seen it. useEffect(() => { if(!info) return; @@ -162,11 +153,65 @@ export const LoginView: FC = ({ onAuthenticated }) => let payload: Record = {}; try { payload = await response.json(); } - catch { /* ignore non-json responses */ } + catch { } return { ok: response.ok, status: response.status, payload }; }, []); + const healthUrl = GetConfigurationValue('login.health.endpoint', '/api/health'); + const healthMethodRaw = GetConfigurationValue('login.health.method', 'GET'); + const healthMethod = (healthMethodRaw || 'GET').toUpperCase(); + const checkServerReachable = useCallback(async (): Promise => + { + 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 ]); + + useEffect(() => + { + let cancelled = false; + (async () => + { + const ok = await checkServerReachable(); + if(!cancelled) setLoginServerReachable(ok); + })(); + return () => { cancelled = true; }; + }, [ checkServerReachable ]); + const handleLoginSubmit = useCallback(async (event: FormEvent) => { event.preventDefault(); @@ -181,19 +226,19 @@ export const LoginView: FC = ({ onAuthenticated }) => if(state.lockedUntil > nowTs) { const remaining = Math.ceil((state.lockedUntil - nowTs) / 1000); - setError(`Too many attempts. Try again in ${ remaining }s.`); + 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('Please enter both your Habbo name and password.'); + setError(t('nitro.login.error.missing_credentials', 'Please enter both your Habbo name and password.')); return; } if(turnstileEnabled && !loginTurnstileToken) { - setError('Please complete the security check.'); + setError(t('nitro.login.error.turnstile', 'Please complete the security check.')); return; } @@ -202,9 +247,16 @@ export const LoginView: FC = ({ onAuthenticated }) => try { + const serverOk = await pingLoginServer(); + if(!serverOk) + { + setError(t('nitro.login.error.server_offline', 'The gameserver is not running. Please try again later.')); + return; + } const { ok, payload } = await postJson(loginUrl, { username: username.trim(), password, + remember: rememberMe, turnstileToken: turnstileEnabled ? loginTurnstileToken : undefined }); @@ -212,38 +264,92 @@ export const LoginView: FC = ({ onAuthenticated }) => if(ok && ssoTicket) { + try + { + const rememberToken = typeof payload.rememberToken === 'string' ? payload.rememberToken : ''; + if(rememberMe && rememberToken) window.localStorage.setItem('nitro.remember.token', rememberToken); + else window.localStorage.removeItem('nitro.remember.token'); + } + catch { /* localStorage may be disabled in private mode */ } + clearLock(); onAuthenticated(ssoTicket); return; } recordFailure(); - const message = typeof payload.error === 'string' ? payload.error : 'Invalid Habbo name or password.'; + 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('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(); } finally { setSubmitting(false); } - }, [ submitting, username, password, turnstileEnabled, loginTurnstileToken, loginUrl, postJson, clearLock, recordFailure, onAuthenticated, resetLoginTurnstile ]); + }, [ submitting, username, password, rememberMe, turnstileEnabled, loginTurnstileToken, loginUrl, postJson, clearLock, recordFailure, onAuthenticated, resetLoginTurnstile, pingLoginServer ]); - // Register + forgot-password submit handlers receive the Turnstile token - // from the dialog (the dialog owns its own widget lifecycle), so the - // login widget underneath can't reset or overwrite it while the user - // is working on the modal. + const checkEmailUrl = GetConfigurationValue('login.check-email.endpoint', '/api/auth/check-email'); + const checkUsernameUrl = GetConfigurationValue('login.check-username.endpoint', '/api/auth/check-username'); + const imagingUrl = GetConfigurationValue('login.register.imaging.url', 'https://www.habbo.com/habbo-imaging/avatarimage?figure={figure}&gender={gender}&direction=2&head_direction=2&size=l'); + const interpretAvailability = (ok: boolean, status: number, payload: Record): { 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'; - const handleRegisterSubmit = useCallback(async (body: { username: string; email: string; password: string; turnstileToken: string; }, onDialogReset: () => void) => + 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; templateId: number | null; }, onDialogReset: () => void) => { if(turnstileEnabled && !body.turnstileToken) { - setError('Please complete the security check.'); + setError(t('nitro.login.error.turnstile', 'Please complete the security check.')); return; } @@ -257,12 +363,15 @@ export const LoginView: FC = ({ onAuthenticated }) => username: body.username, email: body.email, password: body.password, + figure: body.figure, + gender: body.gender, + templateId: body.templateId ?? undefined, turnstileToken: turnstileEnabled ? body.turnstileToken : undefined }); if(ok) { - const friendly = `Welcome aboard, ${ body.username }! Your account is ready — log in below with the password you just chose.`; + 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); @@ -270,12 +379,12 @@ export const LoginView: FC = ({ onAuthenticated }) => return; } - setError(typeof payload.error === 'string' ? payload.error : 'Unable to create your account.'); + setError(typeof payload.error === 'string' ? payload.error : t('nitro.login.error.register_failed', 'Unable to create your account.')); onDialogReset(); } catch { - setError('Unable to reach the registration service.'); + setError(t('nitro.login.error.register_unreachable', 'Unable to reach the registration service.')); onDialogReset(); } finally @@ -288,7 +397,7 @@ export const LoginView: FC = ({ onAuthenticated }) => { if(turnstileEnabled && !body.turnstileToken) { - setError('Please complete the security check.'); + setError(t('nitro.login.error.turnstile', 'Please complete the security check.')); return; } @@ -305,18 +414,18 @@ export const LoginView: FC = ({ onAuthenticated }) => if(ok) { - const friendly = '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).'; + 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 : 'Unable to send a reset email right now.'); + setError(typeof payload.error === 'string' ? payload.error : t('nitro.login.error.forgot_failed', 'Unable to send a reset email right now.')); onDialogReset(); } catch { - setError('Unable to reach the password reset service.'); + setError(t('nitro.login.error.forgot_unreachable', 'Unable to reach the password reset service.')); onDialogReset(); } finally @@ -339,18 +448,18 @@ export const LoginView: FC = ({ onAuthenticated }) =>
-
First time here?
+
{ t('nitro.login.firsttime.title', 'First time here?') }
- Don't have a Habbo yet? - setMode('register') }>You can create one here + { t('nitro.login.firsttime.text', 'Don\'t have a Habbo yet?') } + setMode('register') }>{ t('nitro.login.firsttime.link', 'You can create one here') }
-
What's your Habbo called?
+
{ t('nitro.login.card.title', 'What\'s your Habbo called?') }
- + = ({ onAuthenticated }) => />
- + = ({ onAuthenticated }) => onChange={ e => setPassword(e.target.value) } />
+ { turnstileEnabled && mode === 'login' && = ({ onAuthenticated }) => onError={ () => setLoginTurnstileToken('') } resetSignal={ loginTurnstileResetSignal } /> } + { loginServerReachable === false && +
+ { t('nitro.login.server.offline.short', 'The gameserver isn\'t running right now. Please try again in a moment.') } + +
+ } { error &&
{ error }
} { info &&
{ info }
}
+ disabled={ submitting || isLocked || loginServerReachable === false || loginPingingServer } + >{ loginPingingServer ? t('nitro.login.server.checking', 'Checking…') : t('login.title', 'Log in') }
- setMode('forgot') }>Forgotten your password? + setMode('forgot') }>{ t('login.forgot_password', 'Forgotten your password?') }
@@ -400,6 +521,11 @@ export const LoginView: FC = ({ onAuthenticated }) => setMode('login') } onSubmit={ handleRegisterSubmit } + onCheckEmail={ checkEmailAvailable } + onCheckUsername={ checkUsernameAvailable } + onCheckServer={ checkServerReachable } + imagingUrl={ imagingUrl } + roomTemplatesUrl={ roomTemplatesUrl } submitting={ submitting } error={ error } info={ info } @@ -433,19 +559,168 @@ interface DialogSharedProps interface RegisterDialogProps extends DialogSharedProps { - onSubmit: (body: { username: string; email: string; password: string; turnstileToken: string; }, onDialogReset: () => void) => void; + onSubmit: (body: { username: string; email: string; password: string; figure: string; gender: string; turnstileToken: string; templateId: number | null; }, onDialogReset: () => void) => void; + onCheckEmail: (email: string) => Promise<{ available: boolean; error?: string }>; + onCheckUsername: (username: string) => Promise<{ available: boolean; error?: string }>; + onCheckServer: () => Promise; + imagingUrl: string; + roomTemplatesUrl: string; } +type RegisterStep = 'credentials' | 'avatar' | 'room'; + +interface RoomTemplate { templateId: number; title: string; description: string; thumbnail: string; } + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +type GenderKey = 'M' | 'F'; + +const PART_ROWS: string[] = [ 'hr', 'hd', 'ch', 'lg', 'sh' ]; + +const FALLBACK_DEFAULTS: Record> = { + 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 = { + 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; + +const buildFigureString = (selection: FigureSelection): string => +{ + const seen = new Set(); + 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 buildImagingUrl = (template: string, figure: string, gender: GenderKey): string => + template + .replace(/\{figure\}/g, encodeURIComponent(figure)) + .replace(/\{gender\}/g, gender) + .replace(/\{direction\}/g, '2'); + +const HEAD_ONLY_PARTS = new Set([ 'hr', 'hd' ]); + +const buildPartPreviewUrl = ( + template: string, + 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 isHeadOnly = HEAD_ONLY_PARTS.has(setType); + + let parts: string[]; + if(isHeadOnly) + { + const hd = defaults.hd; + const pieces = new Map(); + pieces.set('hd', `hd-${ hd.partId }-${ hd.colors.join('-') }`); + pieces.set(setType, `${ setType }-${ partSel.partId }${ tail }`); + parts = Array.from(pieces.values()); + } + else + { + const hd = defaults.hd; + parts = [ + `hd-${ hd.partId }-${ hd.colors.join('-') }`, + `${ setType }-${ partSel.partId }${ tail }` + ]; + } + + const figure = parts.join('.'); + let url = template + .replace(/\{figure\}/g, encodeURIComponent(figure)) + .replace(/\{gender\}/g, gender) + .replace(/\{direction\}/g, '2'); + + url = url.replace(/size=l/, 'size=s').replace(/size=m/, 'size=s'); + if(!/size=/.test(url)) url += (url.includes('?') ? '&' : '?') + 'size=s'; + if(isHeadOnly && !/headonly=/.test(url)) url += '&headonly=1'; + + return url; +}; + const RegisterDialog: FC = props => { - const { onCancel, onSubmit, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props; - const [ username, setUsername ] = useState(''); + const { onCancel, onSubmit, onCheckEmail, onCheckUsername, onCheckServer, imagingUrl, roomTemplatesUrl, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props; + + const [ step, setStep ] = useState('credentials'); const [ email, setEmail ] = useState(''); const [ password, setPassword ] = useState(''); const [ confirm, setConfirm ] = useState(''); + const [ username, setUsername ] = useState(''); + const [ gender, setGender ] = useState('F'); + const [ selection, setSelection ] = useState(() => ({ ...FALLBACK_DEFAULTS.F })); const [ localError, setLocalError ] = useState(null); + const [ checking, setChecking ] = useState(false); const [ turnstileToken, setTurnstileToken ] = useState(''); const [ resetSignal, setResetSignal ] = useState(0); + const [ serverReachable, setServerReachable ] = useState(null); + 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(() => { @@ -453,82 +728,558 @@ const RegisterDialog: FC = props => setResetSignal(prev => prev + 1); }, []); - const handle = (event: FormEvent) => + useEffect(() => { setLocalError(null); }, [ step ]); + + const [ roomTemplates, setRoomTemplates ] = useState(null); + const [ roomTemplatesError, setRoomTemplatesError ] = useState(null); + const [ selectedTemplateId, setSelectedTemplateId ] = useState(null); + + const [ figureData, setFigureData ] = useState(null); + const figureDataUrlRaw = GetConfigurationValue('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 ]); + + useEffect(() => + { + if(step !== 'room' || roomTemplates !== null || !roomTemplatesUrl) return; + let cancelled = false; + setRoomTemplatesError(null); + fetch(roomTemplatesUrl, { credentials: 'include' }) + .then(async r => { + if(!r.ok) throw new Error(`status ${ r.status }`); + return r.json(); + }) + .then(json => { + if(cancelled) return; + const list = Array.isArray((json as { templates?: unknown })?.templates) + ? (json as { templates: RoomTemplate[] }).templates + : []; + setRoomTemplates(list); + }) + .catch(() => { + if(cancelled) return; + setRoomTemplates([]); + setRoomTemplatesError(t('nitro.login.register.room.error', 'Could not load room options. You can still skip this step.')); + }); + return () => { cancelled = true; }; + }, [ step, roomTemplates, roomTemplatesUrl ]); + + const partOptions = useMemo(() => + { + const result: Record> = {}; + 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 = {}; + 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('hotlooks.json', { 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[]) + { + 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) => { event.preventDefault(); setLocalError(null); - if(!username.trim() || !email.trim() || !password) + if(!email.trim() || !password || !confirm) { - setLocalError('Please fill in every field.'); + setLocalError(t('nitro.login.error.missing_fields', 'Please fill in every field.')); + return; + } + if(!EMAIL_REGEX.test(email.trim())) + { + setLocalError(t('nitro.login.error.invalid_email', 'Please enter a valid email address.')); return; } - if(password.length < 8) { - setLocalError('Your password must be at least 8 characters.'); + setLocalError(t('nitro.login.error.password_too_short', 'Your password must be at least 8 characters.')); return; } - if(password !== confirm) { - setLocalError('Passwords do not match.'); + setLocalError(t('nitro.login.error.password_mismatch', 'Passwords do not match.')); return; } - onSubmit({ username: username.trim(), email: email.trim(), password, turnstileToken }, resetWidget); + 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 = buildImagingUrl(imagingUrl, figure, gender); + + const handleAvatarSubmit = async (event: FormEvent) => + { + event.preventDefault(); + setLocalError(null); + + const trimmed = username.trim(); + if(!trimmed) + { + setLocalError(t('nitro.login.error.missing_username', 'Please choose a Habbo name.')); + return; + } + if(trimmed.length < 3 || trimmed.length > 16) + { + setLocalError(t('nitro.login.error.username_length', 'Habbo name must be 3–16 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); + } + + setStep('room'); + }; + + const submitRegistration = (templateId: number | null) => + { + onSubmit({ + username: username.trim(), + email: email.trim(), + password, + figure, + gender, + turnstileToken, + templateId + }, resetWidget); + }; + + const handleRoomSubmit = (event: FormEvent) => + { + event.preventDefault(); + setLocalError(null); + submitRegistration(selectedTemplateId); + }; + + const busy = submitting || checking || pingingServer; + const serverOffline = serverReachable === false; + return (
-
+
- Create a Habbo - + { t('nitro.login.register.title', 'Habbo Details') } +
-
-
- - setUsername(e.target.value) } /> -
-
- - setEmail(e.target.value) } /> -
-
- - setPassword(e.target.value) } /> -
-
- - setConfirm(e.target.value) } /> -
- { turnstileEnabled && - setTurnstileToken('') } - onError={ () => setTurnstileToken('') } - resetSignal={ resetSignal } - /> } - { (localError || error) &&
{ localError || error }
} - { info &&
{ info }
} -
- -
- + + { step === 'credentials' && +
+
+ { t('nitro.login.register.intro.credentials', 'Let\'s create your account. Enter your email and pick a password — we\'ll check that email isn\'t already in use.') } +
+ { serverOffline && +
+ { t('nitro.login.server.offline.long', 'The gameserver isn\'t running right now, so new accounts can\'t be created. Please try again in a moment.') } + +
+ } +
+ + setEmail(e.target.value) } /> +
+
+ + setPassword(e.target.value) } /> +
+
+ + setConfirm(e.target.value) } /> +
+ { (localError || error) &&
{ localError || error }
} + { info &&
{ info }
} +
+ 1/3 + +
+
+ } + + { step === 'avatar' && +
+
+ { t('nitro.login.register.intro.avatar', 'Now it\'s time to make your own Habbo character! To make your own Habbo, please start by choosing your Habbo Name.') } +
+ { serverOffline && +
+ { t('nitro.login.server.offline.long', 'The gameserver isn\'t running right now, so new accounts can\'t be created. Please try again in a moment.') } + +
+ } +
+ setUsername(e.target.value) } /> +
+ +
+ + +
+ +
+
+ { PART_ROWS.map(setType => { + const partPreviewSrc = buildPartPreviewUrl(imagingUrl, setType, selection, gender); + return ( +
+ +
+ { { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } /> +
+ +
+ ); + }) } +
+ +
+ Habbo preview { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } /> +
+ +
+ { 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 ( +
+ +
+ +
+ ); + }) } +
+
+ +
+ +
+ + { turnstileEnabled && + setTurnstileToken('') } + onError={ () => setTurnstileToken('') } + resetSignal={ resetSignal } + /> } + { (localError || error) &&
{ localError || error }
} + { info &&
{ info }
} + +
+ + 2/3 + +
+ + } + + { step === 'room' && +
+
+ { t('nitro.login.register.intro.room', 'Last step — pick a starter room, or skip and create your own later.') } +
+ { serverOffline && +
+ { t('nitro.login.server.offline.long', 'The gameserver isn\'t running right now, so new accounts can\'t be created. Please try again in a moment.') } + +
+ } + +
+ + + { roomTemplates === null &&
{ t('nitro.login.register.room.loading', 'Loading rooms…') }
} + + { roomTemplates !== null && roomTemplates.map(template => ( + + )) } +
+ + { roomTemplatesError &&
{ roomTemplatesError }
} + { (localError || error) &&
{ localError || error }
} + { info &&
{ info }
} + +
+ + 3/3 + +
+
+ }
); }; + interface ForgotDialogProps extends DialogSharedProps { onSubmit: (body: { email: string; turnstileToken: string; }, onDialogReset: () => void) => void; @@ -555,7 +1306,7 @@ const ForgotDialog: FC = props => if(!email.trim()) { - setLocalError('Please enter your email address.'); + setLocalError(t('nitro.login.error.missing_email', 'Please enter your email address.')); return; } @@ -567,12 +1318,12 @@ const ForgotDialog: FC = props =>
- Reset password - + { t('nitro.login.forgot.title', 'Reset password') } +
- + setEmail(e.target.value) } />
@@ -588,7 +1339,7 @@ const ForgotDialog: FC = props => { (localError || error) &&
{ localError || error }
} { info &&
{ info }
}
- +
diff --git a/src/components/purse/PurseView.tsx b/src/components/purse/PurseView.tsx index 2b7bc27..00a797c 100644 --- a/src/components/purse/PurseView.tsx +++ b/src/components/purse/PurseView.tsx @@ -64,6 +64,9 @@ export const PurseView: FC<{}> = props => { const logoutUrl = GetConfigurationValue('login.logout.endpoint', '/api/auth/logout'); const ssoTicket = (window.NitroConfig?.['sso.ticket'] as string) ?? ''; + let rememberToken = ''; + try { rememberToken = window.localStorage.getItem('nitro.remember.token') ?? ''; } + catch { /* localStorage may be disabled */ } try { @@ -76,11 +79,12 @@ export const PurseView: FC<{}> = props => { 'Accept': 'application/json', 'X-Requested-With': 'NitroPurseLogout' }, - body: JSON.stringify({ ssoTicket }) + body: JSON.stringify({ ssoTicket, rememberToken }) }); } catch { /* best-effort — proceed with local logout regardless */ } + try { window.localStorage.removeItem('nitro.remember.token'); } catch { /* noop */ } if(window.NitroConfig) window.NitroConfig['sso.ticket'] = ''; window.location.reload(); }, []); diff --git a/src/css/login/LoginView.css b/src/css/login/LoginView.css index c2f15d8..c88081c 100644 --- a/src/css/login/LoginView.css +++ b/src/css/login/LoginView.css @@ -1,18 +1,3 @@ -/* ─── Classic Login View ───────────────────────────────────────────────── - Port of the old Nitro HotelView background layering, used exclusively by - the login screen. Assets are driven by ui-config.json: - loginview.images.background → .login-background - loginview.images.background.colour → .nitro-login-view base colour - loginview.images.sun → .login-sun - loginview.images.drape → .login-drape - loginview.images.left → .login-left - loginview.images.right → .login-right - loginview.images.right.repeat → .login-right-repeat - - Class names are deliberately prefixed so HotelView.css rules - (.left { left: 18vw !important } etc.) cannot clobber us. - --------------------------------------------------------------------- */ - .nitro-login-view { position: fixed; inset: 0; @@ -82,7 +67,7 @@ background-position: right bottom; } -/* ─── Foreground Login Card Stack ───────────────────────────────────── */ +/* ─── Foreground Login Card Stack ─── */ .nitro-login-view .login-stack { position: absolute; @@ -167,6 +152,22 @@ box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1), 0 0 0 2px rgba(63, 106, 133, 0.3); } +.nitro-login-card .remember-me { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: #0a2e45; + user-select: none; + cursor: pointer; + margin: -2px 0 2px 0; +} + +.nitro-login-card .remember-me input[type="checkbox"] { + margin: 0; + cursor: pointer; +} + .nitro-login-card .submit-row { display: flex; justify-content: center; @@ -242,8 +243,6 @@ max-width: 100%; } -/* Modal overlay used for register + forgot password dialogs */ - .nitro-login-modal { position: fixed; inset: 0; @@ -259,3 +258,282 @@ max-width: calc(100% - 40px); } +.nitro-login-modal .dialog.dialog-avatar { + width: 400px; +} + +/* ─── Multi-step register dialog ─── */ + +.nitro-login-card .register-intro { + background: #eef4f8; + border: 1px solid #b6cfdd; + border-radius: 4px; + padding: 6px 8px; + font-size: 11px; + line-height: 1.4; + color: #0a2e45; +} + +.nitro-login-card .step-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + margin-top: 4px; +} + +.nitro-login-card .step-footer-split { + justify-content: space-between; +} + +.nitro-login-card .step-indicator { + font-size: 11px; + color: #134b6e; + font-weight: 600; +} + +.nitro-login-card .back-button { + background: #d5e2eb; +} + +/* ─── Avatar builder (pre-login) ─── */ + +.nitro-login-card .gender-row { + display: flex; + justify-content: center; + gap: 22px; + font-size: 11px; + font-weight: 600; +} + +.nitro-login-card .gender-row label { + display: inline-flex; + align-items: center; + gap: 4px; + cursor: pointer; +} + +.nitro-login-card .avatar-builder { + display: grid; + grid-template-columns: 74px 1fr 74px; + gap: 6px; + align-items: stretch; + background: repeating-linear-gradient( + 0deg, + #ffffff 0, + #ffffff 8px, + #e5ecf1 8px, + #e5ecf1 16px + ); + border: 1px solid #7595ac; + border-radius: 6px; + padding: 6px; +} + +.nitro-login-card .avatar-part-col, +.nitro-login-card .avatar-color-col { + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 4px; +} + +.nitro-login-card .avatar-part-row, +.nitro-login-card .avatar-color-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 3px; + min-height: 44px; +} + +.nitro-login-card .arrow-btn { + width: 16px; + height: 20px; + line-height: 1; + padding: 0; + border: 1px solid #7595ac; + border-radius: 3px; + background: #ffffff; + color: #0a2e45; + font-size: 14px; + font-weight: 700; + cursor: pointer; + box-shadow: inset 0 1px rgba(255, 255, 255, 0.8), 0 1px rgba(0, 0, 0, 0.15); + flex-shrink: 0; +} + +.nitro-login-card .arrow-btn:hover { + background: #e9f1f7; +} + +.nitro-login-card .part-preview { + flex: 1; + height: 44px; + border: 1px solid #7595ac; + border-radius: 3px; + background: #ffffff; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + box-shadow: inset 0 1px rgba(255, 255, 255, 0.8); +} + +.nitro-login-card .part-preview img { + image-rendering: pixelated; + image-rendering: -moz-crisp-edges; + pointer-events: none; + user-select: none; + max-width: none; + height: auto; +} + +.nitro-login-card .part-preview-hr img, +.nitro-login-card .part-preview-hd img { + width: 40px; + height: auto; +} + +.nitro-login-card .part-preview-ch img { + width: 50px; + margin-top: 8px; +} + +.nitro-login-card .part-preview-lg img { + width: 50px; + margin-top: -8px; +} + +.nitro-login-card .part-preview-sh img { + width: 50px; + margin-top: -22px; +} + +.nitro-login-card .color-swatch { + flex: 1; + height: 18px; + border: 1px solid #7595ac; + border-radius: 3px; + box-shadow: inset 0 1px rgba(255, 255, 255, 0.4); +} + +.nitro-login-card .avatar-preview { + display: flex; + align-items: flex-end; + justify-content: center; + min-height: 130px; + overflow: hidden; +} + +.nitro-login-card .avatar-preview img { + max-width: 100%; + max-height: 140px; + image-rendering: pixelated; + image-rendering: -moz-crisp-edges; +} + +.nitro-login-card .hot-looks-row { + display: flex; + justify-content: center; + margin-top: 2px; +} + +.nitro-login-card .server-offline { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 6px; + text-align: left; +} + +.nitro-login-card .server-offline .retry-link { + background: #ffffff; + border: 1px solid #3f6a85; + border-radius: 4px; + padding: 2px 10px; + font-size: 11px; + font-weight: 700; + color: #0a2e45; + cursor: pointer; + box-shadow: inset 0 1px rgba(255, 255, 255, 0.8), 0 1px rgba(0, 0, 0, 0.15); +} + +.nitro-login-card .server-offline .retry-link:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.nitro-login-card .hot-looks-button { + padding: 4px 14px; + font-size: 11px; +} + +/* ─── Room template picker (step 3) ─── */ + +.nitro-login-card .room-templates-list { + display: flex; + flex-direction: column; + gap: 6px; + max-height: 260px; + overflow-y: auto; + padding-right: 4px; +} + +.nitro-login-card .room-template-option { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 8px 10px; + border: 1px solid #b6cfdd; + border-radius: 4px; + background: #eef4f8; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; +} + +.nitro-login-card .room-template-option:hover { + border-color: #7fa9c3; +} + +.nitro-login-card .room-template-option.selected { + border-color: #2e6b92; + background: #d9e8f2; +} + +.nitro-login-card .room-template-option input[type="radio"] { + margin: 2px 0 0 0; + flex-shrink: 0; + cursor: pointer; +} + +.nitro-login-card .room-template-thumb { + width: 48px; + height: 48px; + object-fit: cover; + border-radius: 3px; + flex-shrink: 0; +} + +.nitro-login-card .room-template-body { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; +} + +.nitro-login-card .room-template-title { + font-weight: 700; + font-size: 12px; + color: #0a2e45; + line-height: 1.2; +} + +.nitro-login-card .room-template-description { + font-size: 11px; + color: #2a4a5c; + line-height: 1.3; +} +