diff --git a/public/renderer-config.json b/public/renderer-config.json index 170ae55..634694b 100644 --- a/public/renderer-config.json +++ b/public/renderer-config.json @@ -1,11 +1,11 @@ { - "socket.url": "wss://nitro.slogga.it:2096", - "api.url": "https://nitro.slogga.it:2096", + "socket.url": "ws://192.168.1.52:2096", + "api.url": "http://192.168.1.52:2096", "asset.url": "https://hotel.slogga.it/client/nitro/bundled", "image.library.url": "https://hotel.slogga.it/client/c_images/", "hof.furni.url": "https://hotel.slogga.it/client/c_images/dcr/hof_furni", "images.url": "https://hotel.slogga.it/client/nitro/images", - "gamedata.url": "https://nitro.slogga.it:2096/nitro-sec/file?kind=gamedata&file=", + "gamedata.url": "http://192.168.1.52:2096/nitro-sec/file?kind=gamedata&file=", "sounds.url": "${asset.url}/sounds/%sample%.mp3", "external.texts.url": [ "${gamedata.url}/ExternalTexts.json", @@ -47,6 +47,7 @@ "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.remember.endpoint": "${api.url}/api/auth/remember", "login.turnstile.enabled": false, "login.turnstile.sitekey": "", "avatar.mandatory.libraries": [ diff --git a/public/ui-config.json b/public/ui-config.json index d065661..50cef22 100644 --- a/public/ui-config.json +++ b/public/ui-config.json @@ -30,9 +30,7 @@ "show.google.ads": false, "loginview": { "images": { - "background": "https://hotel.slogga.it/client/nitro/images/reception/background_gradient_apr25.png", - "background.colour": "#6eadc8", - "drape": "https://hotel.slogga.it/client/nitro/images/reception/drape.png", + "background": "https://hotel.slogga.it/client/nitro/images/reception/background_gradient_apr25.png", "drape": "https://hotel.slogga.it/client/nitro/images/reception/drape.png", "left": "https://hotel.slogga.it/client/nitro/images/reception/mute_reception_backdrop_left.png", "right": "https://hotel.slogga.it/client/nitro/images/reception/background_right.png" } @@ -1575,6 +1573,37 @@ "right.repeat": "" } }, + "loginview": { + "images": { + "background": "https://hotel.slogga.it/client/nitro/images/reception/background_gradient_apr25.png", + "background.colour": "#6eadc8", + "drape": "https://hotel.slogga.it/client/nitro/images/reception/drape.png", + "left": "https://hotel.slogga.it/client/nitro/images/reception/mute_reception_backdrop_left.png", + "right": "https://hotel.slogga.it/client/nitro/images/reception/background_right.png" + }, + "widgets": { + "slot.1.widget": "promoarticle", + "slot.1.conf": {}, + "slot.2.widget": "widgetcontainer", + "slot.2.conf": { + "image": "${image.library.url}web_promo_small/spromo_Canal_Bundle.png", + "texts": "2021NitroPromo", + "btnLink": "" + }, + "slot.3.widget": "", + "slot.3.conf": {}, + "slot.4.widget": "", + "slot.4.conf": {}, + "slot.5.widget": "", + "slot.5.conf": {}, + "slot.6.widget": "", + "slot.6.conf": { + "campaign": "" + }, + "slot.7.widget": "", + "slot.7.conf": {} + } + }, "achievements.unseen.ignored": [ "ACH_AllTimeHotelPresence" ], diff --git a/src/App.tsx b/src/App.tsx index ba9aff5..0728274 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import { GetAssetManager, GetAvatarRenderManager, GetCommunication, GetConfiguration, GetLocalizationManager, GetRoomEngine, GetRoomSessionManager, GetSessionDataManager, GetSoundManager, GetStage, GetTexturePool, GetTicker, HabboWebTools, LegacyExternalInterface, LoadGameUrlEvent, NitroEventType, NitroLogger, NitroVersion, PrepareRenderer } from '@nitrots/nitro-renderer'; import { FC, useCallback, useEffect, useRef, useState } from 'react'; -import { GetUIVersion } from './api'; +import { ClearRememberLogin, GetRememberLogin, GetUIVersion, StoreRememberLoginFromPayload } from './api'; import { Base } from './common'; import { LoadingView } from './components/loading/LoadingView'; import { LoginView } from './components/login/LoginView'; @@ -43,13 +43,15 @@ const asStringArray = (value: unknown): string[] => return []; }; +const hasRememberLogin = (): boolean => !!GetRememberLogin(); + export const App: FC<{}> = props => { const [ isReady, setIsReady ] = useState(false); const [ errorMessage, setErrorMessage ] = useState(''); const [ homeUrl, setHomeUrl ] = useState(''); - const [ showLogin, setShowLogin ] = useState(() => !window.NitroConfig?.['sso.ticket']); - const [ isEnteringHotel, setIsEnteringHotel ] = useState(false); + const [ showLogin, setShowLogin ] = useState(() => !window.NitroConfig?.['sso.ticket'] && !hasRememberLogin()); + const [ isEnteringHotel, setIsEnteringHotel ] = useState(() => !!window.NitroConfig?.['sso.ticket'] || hasRememberLogin()); const [ prepareTrigger, setPrepareTrigger ] = useState(0); const warmupPromiseRef = useRef>(null); const rendererPromiseRef = useRef>(null); @@ -65,14 +67,72 @@ export const App: FC<{}> = props => setIsEnteringHotel(false); }, []); - const handleAuthenticated = useCallback((ssoTicket: string) => + const applySsoTicket = useCallback((ssoTicket: string) => { if(!ssoTicket) return; window.NitroConfig['sso.ticket'] = ssoTicket; GetConfiguration().setValue('sso.ticket', ssoTicket); + }, []); + + const handleAuthenticated = useCallback((ssoTicket: string) => + { + if(!ssoTicket) return; + applySsoTicket(ssoTicket); setIsEnteringHotel(true); setErrorMessage(''); setPrepareTrigger(prev => prev + 1); + }, [ applySsoTicket ]); + + const tryRememberLogin = useCallback(async (): Promise => + { + const remembered = GetRememberLogin(); + + if(!remembered) return ''; + if(!remembered.token?.length && remembered.ssoTicket?.length) return remembered.ssoTicket; + + let allowSsoFallback = true; + + try + { + const rawEndpoint = GetConfiguration().getValue('login.remember.endpoint', '${api.url}/api/auth/remember'); + const endpoint = GetConfiguration().interpolate(rawEndpoint); + const response = await fetch(endpoint, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-Requested-With': 'NitroRememberLogin' + }, + body: JSON.stringify({ rememberToken: remembered.token }) + }); + + let payload: Record = {}; + try { payload = await response.json(); } + catch {} + + const ssoTicket = typeof payload.ssoTicket === 'string' ? payload.ssoTicket : (typeof payload.sso === 'string' ? payload.sso : ''); + + if(response.ok && ssoTicket) + { + StoreRememberLoginFromPayload(payload, typeof payload.username === 'string' ? payload.username : remembered.username, ssoTicket); + return ssoTicket; + } + + if(response.status === 400 || response.status === 401 || response.status === 403) + { + allowSsoFallback = false; + ClearRememberLogin(); + } + } + catch(error) + { + NitroLogger.error('[LoginScreen] Remember login failed', error); + } + + if(allowSsoFallback && remembered.ssoTicket?.length) return remembered.ssoTicket; + + return ''; }, []); // Listen for socket closed events (code 1000 "Bye" - server rejected SSO) @@ -176,7 +236,7 @@ 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']; if(ssoTicket) GetConfiguration().setValue('sso.ticket', ssoTicket); if(!ssoTicket || ssoTicket === '') @@ -197,24 +257,37 @@ export const App: FC<{}> = props => if(loginScreenEnabled) { - setIsReady(false); - setShowLogin(true); - startWarmup(width, height).catch(error => NitroLogger.error('[LoginScreen] Warmup failed', error)); + const rememberedSsoTicket = await tryRememberLogin(); + + if(rememberedSsoTicket) + { + ssoTicket = rememberedSsoTicket; + applySsoTicket(rememberedSsoTicket); + setShowLogin(false); + } + else + { + setIsReady(false); + setShowLogin(true); + startWarmup(width, height).catch(error => NitroLogger.error('[LoginScreen] Warmup failed', error)); + return; + } + } + else + { + if(configInitError) + { + setHomeUrl(window.location.origin + '/'); + setErrorMessage(`Unable to load renderer-config.json.\n${ String((configInitError as Error)?.message ?? configInitError) }`); + setIsReady(false); + setShowLogin(false); + setIsEnteringHotel(false); + return; + } + + showSessionExpired(); return; } - - if(configInitError) - { - setHomeUrl(window.location.origin + '/'); - setErrorMessage(`Unable to load renderer-config.json.\n${ String((configInitError as Error)?.message ?? configInitError) }`); - setIsReady(false); - setShowLogin(false); - setIsEnteringHotel(false); - return; - } - - showSessionExpired(); - return; } const renderer = await startRenderer(width, height); @@ -258,11 +331,11 @@ export const App: FC<{}> = props => { if(heartbeatIntervalRef.current !== null) window.clearInterval(heartbeatIntervalRef.current); }; - }, [ prepareTrigger, startWarmup, startRenderer ]); + }, [ prepareTrigger, startWarmup, startRenderer, tryRememberLogin, applySsoTicket ]); return ( - { !isReady && !showLogin && errorMessage.length > 0 && + { !isReady && !showLogin && 0 } message={ errorMessage } homeUrl={ homeUrl } /> } { !isReady && showLogin && } { isReady && } diff --git a/src/api/utils/GetLocalStorage.ts b/src/api/utils/GetLocalStorage.ts index a4270cf..e82d44b 100644 --- a/src/api/utils/GetLocalStorage.ts +++ b/src/api/utils/GetLocalStorage.ts @@ -2,7 +2,7 @@ export const GetLocalStorage = (key: string) => { try { - JSON.parse(window.localStorage.getItem(key)) as T ?? null; + return JSON.parse(window.localStorage.getItem(key)) as T ?? null; } catch (e) { diff --git a/src/api/utils/RememberLogin.ts b/src/api/utils/RememberLogin.ts new file mode 100644 index 0000000..e886126 --- /dev/null +++ b/src/api/utils/RememberLogin.ts @@ -0,0 +1,59 @@ +export interface RememberLoginData +{ + token?: string; + ssoTicket?: string; + expiresAt: number; + username?: string; +} + +const REMEMBER_LOGIN_KEY = 'nitro.auth.remember'; +const DEFAULT_REMEMBER_SECONDS = 30 * 24 * 60 * 60; + +export const GetRememberLogin = (): RememberLoginData | null => +{ + try + { + const data = JSON.parse(window.localStorage.getItem(REMEMBER_LOGIN_KEY) || 'null') as RememberLoginData | null; + + if(!data?.token?.length && !data?.ssoTicket?.length) return null; + if(data.expiresAt && ((data.expiresAt * 1000) <= Date.now())) + { + ClearRememberLogin(); + return null; + } + + return data; + } + catch + { + return null; + } +}; + +export const SetRememberLogin = (data: RememberLoginData): void => +{ + if(!data?.token?.length && !data?.ssoTicket?.length) return; + + try { window.localStorage.setItem(REMEMBER_LOGIN_KEY, JSON.stringify(data)); } + catch {} +}; + +export const ClearRememberLogin = (): void => +{ + try { window.localStorage.removeItem(REMEMBER_LOGIN_KEY); } + catch {} +}; + +export const StoreRememberLoginFromPayload = (payload: Record, username?: string, ssoTicket?: string): void => +{ + const token = typeof payload.rememberToken === 'string' ? payload.rememberToken : ''; + const rawExpiresAt = payload.rememberExpiresAt; + const parsedExpiresAt = typeof rawExpiresAt === 'number' ? rawExpiresAt : Number(rawExpiresAt || 0); + const expiresAt = (Number.isFinite(parsedExpiresAt) && parsedExpiresAt > 0) + ? parsedExpiresAt + : Math.floor(Date.now() / 1000) + DEFAULT_REMEMBER_SECONDS; + + if(!token.length && !ssoTicket?.length) return; + + SetRememberLogin({ token: token || undefined, ssoTicket: ssoTicket || undefined, expiresAt, username }); +}; diff --git a/src/api/utils/index.ts b/src/api/utils/index.ts index 1f22e7f..6e19efc 100644 --- a/src/api/utils/index.ts +++ b/src/api/utils/index.ts @@ -14,6 +14,7 @@ export * from './PlaySound'; export * from './PrefixUtils'; export * from './ProductImageUtility'; export * from './Randomizer'; +export * from './RememberLogin'; export * from './RoomChatFormatter'; export * from './SanitizeHtml'; export * from './SetLocalStorage'; diff --git a/src/assets/images/flag_icon/flag_icon_br.png b/src/assets/images/flag_icon/flag_icon_br.png new file mode 100644 index 0000000..9d62a46 Binary files /dev/null and b/src/assets/images/flag_icon/flag_icon_br.png differ diff --git a/src/assets/images/flag_icon/flag_icon_de.png b/src/assets/images/flag_icon/flag_icon_de.png new file mode 100644 index 0000000..6090d2c Binary files /dev/null and b/src/assets/images/flag_icon/flag_icon_de.png differ diff --git a/src/assets/images/flag_icon/flag_icon_en.png b/src/assets/images/flag_icon/flag_icon_en.png new file mode 100644 index 0000000..5d1daa6 Binary files /dev/null and b/src/assets/images/flag_icon/flag_icon_en.png differ diff --git a/src/assets/images/flag_icon/flag_icon_es.png b/src/assets/images/flag_icon/flag_icon_es.png new file mode 100644 index 0000000..623c8c4 Binary files /dev/null and b/src/assets/images/flag_icon/flag_icon_es.png differ diff --git a/src/assets/images/flag_icon/flag_icon_fi.png b/src/assets/images/flag_icon/flag_icon_fi.png new file mode 100644 index 0000000..c547fb1 Binary files /dev/null and b/src/assets/images/flag_icon/flag_icon_fi.png differ diff --git a/src/assets/images/flag_icon/flag_icon_fr.png b/src/assets/images/flag_icon/flag_icon_fr.png new file mode 100644 index 0000000..2f43877 Binary files /dev/null and b/src/assets/images/flag_icon/flag_icon_fr.png differ diff --git a/src/assets/images/flag_icon/flag_icon_it.png b/src/assets/images/flag_icon/flag_icon_it.png new file mode 100644 index 0000000..0daffbd Binary files /dev/null and b/src/assets/images/flag_icon/flag_icon_it.png differ diff --git a/src/assets/images/flag_icon/flag_icon_nl.png b/src/assets/images/flag_icon/flag_icon_nl.png new file mode 100644 index 0000000..548c212 Binary files /dev/null and b/src/assets/images/flag_icon/flag_icon_nl.png differ diff --git a/src/assets/images/flag_icon/flag_icon_selected.png b/src/assets/images/flag_icon/flag_icon_selected.png new file mode 100644 index 0000000..3aac067 Binary files /dev/null and b/src/assets/images/flag_icon/flag_icon_selected.png differ diff --git a/src/assets/images/flag_icon/flag_icon_tr.png b/src/assets/images/flag_icon/flag_icon_tr.png new file mode 100644 index 0000000..9cd1c27 Binary files /dev/null and b/src/assets/images/flag_icon/flag_icon_tr.png differ diff --git a/src/bootstrap.ts b/src/bootstrap.ts index c990925..052bf70 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -18,11 +18,11 @@ setBootDebug('boot: secure fetch installed'); const search = new URLSearchParams(window.location.search); -(window as any).NitroSecureApiUrl = 'https://nitro.slogga.it:2096'; +(window as any).NitroSecureApiUrl = 'http://192.168.1.52:2096/'; (window as any).NitroConfig = { 'config.urls': [ - secureUrl('config', 'renderer-config.json'), - secureUrl('config', 'ui-config.json') + secureUrl('config', 'renderer-config.json', true), + secureUrl('config', 'ui-config.json', true) ], 'sso.ticket': search.get('sso') || null, 'forward.type': search.get('room') ? 2 : -1, diff --git a/src/components/loading/LoadingView.tsx b/src/components/loading/LoadingView.tsx index a2e6a6d..c4447f0 100644 --- a/src/components/loading/LoadingView.tsx +++ b/src/components/loading/LoadingView.tsx @@ -1,4 +1,5 @@ import { FC } from 'react'; +import loadingGif from '@/assets/images/loading/loading.gif'; import { Base, Column, Text } from '../../common'; interface LoadingViewProps { @@ -29,7 +30,16 @@ export const LoadingView: FC = props => { } - : null + : + + + { message && message.length ? + + { message } + + : null + } + } diff --git a/src/components/login/LoginView.tsx b/src/components/login/LoginView.tsx index 218195a..e22daf5 100644 --- a/src/components/login/LoginView.tsx +++ b/src/components/login/LoginView.tsx @@ -1,18 +1,62 @@ import { GetConfiguration } from '@nitrots/nitro-renderer'; import { FC, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { GetConfigurationValue } from '../../api'; +import { ClearRememberLogin, GetConfigurationValue, GetRememberLogin, StoreRememberLoginFromPayload } from '../../api'; +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 { TurnstileWidget } from './TurnstileWidget'; 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 ''; - try { return GetConfiguration().interpolate(value); } - catch { return value; } + + 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(key, ''); + + if(configValue) return configValue; + } + catch {} + + try + { + const configValue = GetConfigurationValue(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; @@ -23,6 +67,17 @@ const DEFAULT_LOGIN_IMAGES: Record = { left: 'https://hotel.slogga.it/client/nitro/images/reception/mute_reception_backdrop_left.png', right: 'https://hotel.slogga.it/client/nitro/images/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 }; @@ -43,6 +98,70 @@ const writeLock = (state: AttemptState) => 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; @@ -61,10 +180,31 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa const [ loginTurnstileResetSignal, setLoginTurnstileResetSignal ] = useState(0); const [ loginServerReachable, setLoginServerReachable ] = useState(null); const [ loginPingingServer, setLoginPingingServer ] = useState(false); + const [ rememberMe, setRememberMe ] = useState(() => !!GetRememberLogin()); + const [ selectedLocale, setSelectedLocale ] = useState(() => readCachedLocale()); + const [ localeApplying, setLocaleApplying ] = useState(false); + const [ localeError, setLocaleError ] = useState(''); + const [ loginViewConfig, setLoginViewConfig ] = useState>(() => GetConfigurationValue>('loginview', {})); const submitTimeRef = useRef(0); - const configuredLoginImages: Record = ((GetConfigurationValue>('loginview', {})?.['images']) as Record) ?? {}; + const configuredLoginImages: Record = (loginViewConfig?.['images'] as Record) ?? {}; const loginImages: Record = { ...DEFAULT_LOGIN_IMAGES, ...configuredLoginImages }; + + const configuredLoginWidgets: Record = (loginViewConfig?.['widgets'] as Record) ?? {}; + 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 ?? {}; + + 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('login_background.colour', '#6eadc8')); const background = interpolate(loginImages['background'] || GetConfigurationValue('login_background', '')); @@ -73,7 +213,10 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa 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', '')); - const loginImageUrls = useMemo(() => [ background, sun, drape, left, rightRepeat, right ].filter(Boolean), [ background, sun, drape, left, rightRepeat, 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('login.endpoint', '/api/auth/login'); const registerUrl = GetConfigurationValue('login.register.endpoint', '/api/auth/register'); @@ -97,6 +240,62 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa if(mode === 'login') resetLoginTurnstile(); }, [ mode, resetLoginTurnstile ]); + useEffect(() => + { + let cancelled = false; + + const refreshLoginViewConfig = () => + { + if(cancelled) return; + + const nextConfig = GetConfigurationValue>('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; @@ -216,17 +415,6 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa } }, [ 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(); @@ -262,15 +450,10 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa try { - const serverOk = await pingLoginServer(); - if(!serverOk) - { - setError('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 }); @@ -279,6 +462,8 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa if(ok && ssoTicket) { clearLock(); + if(rememberMe) StoreRememberLoginFromPayload(payload, typeof payload.username === 'string' ? payload.username : username.trim(), ssoTicket); + else ClearRememberLogin(); onAuthenticated(ssoTicket); return; } @@ -298,7 +483,7 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa { setSubmitting(false); } - }, [ submitting, isEntering, username, password, turnstileEnabled, loginTurnstileToken, loginUrl, postJson, clearLock, recordFailure, onAuthenticated, resetLoginTurnstile, pingLoginServer ]); + }, [ submitting, isEntering, username, password, rememberMe, turnstileEnabled, loginTurnstileToken, loginUrl, postJson, clearLock, recordFailure, onAuthenticated, resetLoginTurnstile, pingLoginServer ]); const checkEmailUrl = GetConfigurationValue('login.check-email.endpoint', '/api/auth/check-email'); const checkUsernameUrl = GetConfigurationValue('login.check-username.endpoint', '/api/auth/check-username'); @@ -454,7 +639,61 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa { loginImageUrls.map(url => ) } + { loginWidgetSlots.length > 0 && +
+ { 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 ( +
+ { image && } +
+
{ title }
+ { description &&
{ description }
} + { btnText && + } +
+
+ ); + }) } +
} +
+
+
Choose your language
+
+ { LOGIN_LOCALES.map(locale => + ) } +
+ { localeError.length > 0 &&
{ localeError }
} + +
+
First time here?
@@ -490,6 +729,14 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa onChange={ e => setPassword(e.target.value) } />
+ { turnstileEnabled && mode === 'login' && = ({ onAuthenticated, isEntering = fa
setMode('forgot') }>Forgotten your password?
+ { mode === 'register' && diff --git a/src/components/purse/PurseView.tsx b/src/components/purse/PurseView.tsx index 9c81002..0a50441 100644 --- a/src/components/purse/PurseView.tsx +++ b/src/components/purse/PurseView.tsx @@ -1,7 +1,7 @@ import { CreateLinkEvent, HabboClubLevelEnum } from '@nitrots/nitro-renderer'; import { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { FaChevronDown, FaLanguage, FaQuestionCircle, FaSignOutAlt } from 'react-icons/fa'; -import { FriendlyTime, GetConfigurationValue, LocalizeText } from '../../api'; +import { ClearRememberLogin, FriendlyTime, GetConfigurationValue, GetRememberLogin, LocalizeText } from '../../api'; import { Column, Flex, LayoutCurrencyIcon, Text } from '../../common'; import { usePurse } from '../../hooks'; import purseIcon from '../../assets/images/rightside/purse.gif'; @@ -64,6 +64,7 @@ export const PurseView: FC<{}> = props => { const logoutUrl = GetConfigurationValue('login.logout.endpoint', '/api/auth/logout'); const ssoTicket = (window.NitroConfig?.['sso.ticket'] as string) ?? ''; + const rememberToken = GetRememberLogin()?.token || ''; try { @@ -76,11 +77,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 */ } + ClearRememberLogin(); 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 78c9d52..3c1badb 100644 --- a/src/css/login/LoginView.css +++ b/src/css/login/LoginView.css @@ -85,10 +85,11 @@ .nitro-login-view .login-right { bottom: 0; right: 0; - width: 400px; - height: 100%; - object-fit: none; - object-position: right bottom; + width: auto; + height: auto; + max-width: none; + object-fit: initial; + object-position: initial; } /* ─── Foreground Login Card Stack ─── */ @@ -106,6 +107,79 @@ pointer-events: auto; } +.nitro-login-view .login-widgets { + position: absolute; + top: 18px; + left: 240px; + right: 360px; + z-index: 25; + display: grid; + grid-template-columns: repeat(2, minmax(260px, 1fr)); + gap: 34px 58px; + pointer-events: auto; +} + +.nitro-login-view .login-widget-slot { + min-height: 110px; + display: grid; + grid-template-columns: 160px minmax(0, 1fr); + align-items: center; + gap: 22px; + color: #ffffff; + font-family: Ubuntu, 'Helvetica Neue', Arial, sans-serif; + text-shadow: 0 2px 2px rgba(0, 0, 0, 0.45); +} + +.nitro-login-view .login-widget-image { + max-width: 150px; + max-height: 150px; + width: auto; + height: auto; + justify-self: center; + image-rendering: auto; + user-select: none; + -webkit-user-drag: none; +} + +.nitro-login-view .login-widget-content { + min-width: 0; +} + +.nitro-login-view .login-widget-title { + font-size: 18px; + line-height: 20px; + font-weight: 700; + letter-spacing: 0.2px; + margin-bottom: 5px; +} + +.nitro-login-view .login-widget-description { + max-width: 285px; + font-size: 12px; + line-height: 14px; + font-weight: 600; + margin-bottom: 14px; +} + +.nitro-login-view .login-widget-button { + min-width: 178px; + height: 25px; + padding: 0 18px; + border: 1px solid #777777; + border-radius: 3px; + background: linear-gradient(#ffffff, #d4d4d4); + color: #111111; + font-size: 11px; + font-weight: 700; + cursor: pointer; + box-shadow: inset 0 1px rgba(255, 255, 255, 0.85), 0 1px 1px rgba(0, 0, 0, 0.35); + text-shadow: none; +} + +.nitro-login-view .login-widget-button:hover { + background: linear-gradient(#ffffff, #e9e9e9); +} + .nitro-login-card { background: #a2bfd1; border: 2px solid #3f6a85; @@ -176,6 +250,24 @@ 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-row { + display: flex; + align-items: center; + gap: 6px; + color: #0a2e45; + font-size: 11px; + font-weight: 600; + cursor: pointer; + user-select: none; +} + +.nitro-login-card .remember-row input { + width: 13px; + height: 13px; + margin: 0; + cursor: pointer; +} + .nitro-login-card .submit-row { display: flex; justify-content: center; @@ -240,6 +332,75 @@ font-weight: 600; } +.nitro-login-card.login-language-card { + padding-bottom: 10px; +} + +.nitro-login-card .login-language-grid { + display: grid; + grid-template-columns: repeat(5, 46px); + justify-content: center; + gap: 7px 3px; +} + +.nitro-login-card .login-language-option { + position: relative; + width: 46px; + height: 52px; + padding: 0; + border: 0; + background: transparent center 2px no-repeat; + background-size: 38px 32px; + cursor: pointer; + image-rendering: auto; + overflow: hidden; +} + +.nitro-login-card .login-language-option.selected { + background-size: 38px 32px; +} + +.nitro-login-card .login-language-option img { + position: absolute; + top: 18px; + left: 50%; + width: auto; + height: auto; + max-width: 28px; + max-height: 22px; + transform: translate(-50%, -50%); + pointer-events: none; + user-select: none; + -webkit-user-drag: none; +} + +.nitro-login-card .login-language-option span { + position: absolute; + left: 0; + right: 0; + bottom: 0; + color: #1b3444; + font-size: 9px; + line-height: 10px; + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.nitro-login-card .language-error { + margin-top: 6px; + color: #9f1b15; + font-size: 10px; + text-align: center; +} + +.nitro-login-card .login-language-confirm { + display: block; + min-width: 58px; + margin: 7px auto 0; +} + .nitro-login-card .turnstile-slot { display: flex; justify-content: center; @@ -478,3 +639,23 @@ font-size: 11px; } +@media (max-width: 1180px) { + .nitro-login-view .login-widgets { + left: 210px; + right: 315px; + grid-template-columns: 1fr; + gap: 16px; + } + + .nitro-login-view .login-widget-slot { + grid-template-columns: 120px minmax(0, 1fr); + min-height: 86px; + gap: 14px; + } + + .nitro-login-view .login-widget-image { + max-width: 110px; + max-height: 110px; + } +} + diff --git a/src/hooks/translation/useTranslation.ts b/src/hooks/translation/useTranslation.ts index 9a575f3..0d1f17e 100644 --- a/src/hooks/translation/useTranslation.ts +++ b/src/hooks/translation/useTranslation.ts @@ -150,6 +150,44 @@ const dispatchLocalizationUpdated = () => window.dispatchEvent(new CustomEvent('nitro-localization-updated')); }; +export const applyTextTranslationLocale = async (languageCode: string): Promise => +{ + const localizationManager = GetLocalizationManager(); + const sessionDataManager = GetSessionDataManager(); + const selectedLocale = resolveTextTranslationLocale(languageCode || ''); + + if(!selectedLocale) + { + localizationManager.clearOverrideValues(); + sessionDataManager.clearFurnitureDataOverrides(); + dispatchLocalizationUpdated(); + return; + } + + const textUrl = getTextTranslationUrl(selectedLocale.file); + const furnitureUrl = getFurnitureTranslationUrl(selectedLocale.file); + const response = await fetch(textUrl); + + if(response.status !== 200) throw new Error(`Unable to load ${ textUrl }`); + + const data = await response.json(); + const overrideValues = new Map(); + + Object.keys(data || {}).forEach(key => overrideValues.set(key, data[key])); + localizationManager.setOverrideValues(overrideValues); + + try + { + await sessionDataManager.applyFurnitureDataOverrides(furnitureUrl); + } + catch + { + sessionDataManager.clearFurnitureDataOverrides(); + } + + dispatchLocalizationUpdated(); +}; + const getBrowserLanguageCode = () => { if(typeof navigator === 'undefined') return 'en'; @@ -475,17 +513,13 @@ const useTranslationState = () => { let disposed = false; const requestId = ++localizationRequestRef.current; - const localizationManager = GetLocalizationManager(); - const sessionDataManager = GetSessionDataManager(); const selectedLocale = resolveTextTranslationLocale(settings.uiTextLanguage || ''); const applyLocalizationOverride = async () => { if(!selectedLocale) { - localizationManager.clearOverrideValues(); - sessionDataManager.clearFurnitureDataOverrides(); - dispatchLocalizationUpdated(); + await applyTextTranslationLocale(''); if((localizationRequestRef.current === requestId) && !disposed) { @@ -500,42 +534,19 @@ const useTranslationState = () => try { - const textUrl = getTextTranslationUrl(selectedLocale.file); - const furnitureUrl = getFurnitureTranslationUrl(selectedLocale.file); - const response = await fetch(textUrl); + if(disposed || (localizationRequestRef.current !== requestId)) return; - if(response.status !== 200) throw new Error(`Unable to load ${ textUrl }`); - - const data = await response.json(); - const overrideValues = new Map(); - - Object.keys(data || {}).forEach(key => overrideValues.set(key, data[key])); + await applyTextTranslationLocale(settings.uiTextLanguage || ''); if(disposed || (localizationRequestRef.current !== requestId)) return; - localizationManager.setOverrideValues(overrideValues); - - try - { - await sessionDataManager.applyFurnitureDataOverrides(furnitureUrl); - } - catch - { - if(disposed || (localizationRequestRef.current !== requestId)) return; - - sessionDataManager.clearFurnitureDataOverrides(); - } - - dispatchLocalizationUpdated(); setLastError(''); } catch(error) { if(disposed || (localizationRequestRef.current !== requestId)) return; - localizationManager.clearOverrideValues(); - sessionDataManager.clearFurnitureDataOverrides(); - dispatchLocalizationUpdated(); + await applyTextTranslationLocale(''); setLastError((error as Error)?.message || 'Unable to load translated UI texts.'); } finally diff --git a/src/secure-assets.ts b/src/secure-assets.ts index 6957316..05acb46 100644 --- a/src/secure-assets.ts +++ b/src/secure-assets.ts @@ -65,6 +65,13 @@ const textDecoder = new TextDecoder(); let secureSessionPromise: Promise = null; let installed = false; const secureResponseCache = new Map>(); +let secureSessionCreatedAt = 0; +const SECURE_SESSION_TTL_MS = 5 * 60 * 1000; +const REKEY_ENDPOINTS = new Set([ + '/api/auth/login', + '/api/auth/remember', + '/api/auth/logout' +]); const bytesToBase64 = (bytes: ArrayBuffer): string => { @@ -76,6 +83,13 @@ const bytesToBase64 = (bytes: ArrayBuffer): string => return btoa(binary); }; +const randomHex = (byteLength: number): string => +{ + const bytes = crypto.getRandomValues(new Uint8Array(byteLength)); + + return Array.from(bytes).map(value => value.toString(16).padStart(2, '0')).join(''); +}; + const hexValue = (code: number): number => { if(code >= 48 && code <= 57) return code - 48; @@ -139,14 +153,15 @@ const getApiBase = (): string => if(typeof configured === 'string' && configured.length) return configured.replace(/\/$/, ''); - return 'https://nitro.slogga.it:2096'; + return 'http://localhost:8443/'; }; -export const secureUrl = (kind: 'config' | 'gamedata', file: string): string => +export const secureUrl = (kind: 'config' | 'gamedata', file: string, cacheBust = false): string => { const base = getApiBase(); + const version = cacheBust ? `&v=${ encodeURIComponent(Date.now().toString(36)) }` : ''; - return `${ base }/nitro-sec/file?kind=${ encodeURIComponent(kind) }&file=${ encodeURIComponent(file) }`; + return `${ base }/nitro-sec/file?kind=${ encodeURIComponent(kind) }&file=${ encodeURIComponent(file) }${ version }`; }; const createSecureSession = async (): Promise => @@ -178,11 +193,26 @@ const createSecureSession = async (): Promise => const derived = await deriveAesKey(pair.privateKey, serverKey); + secureSessionCreatedAt = Date.now(); + return { publicKey: clientPublicKey, key: derived.key, fingerprint: derived.fingerprint }; }; +const clearSecureSession = (clearCache = false): void => +{ + secureSessionPromise = null; + secureSessionCreatedAt = 0; + if(clearCache) secureResponseCache.clear(); +}; + export const getSecureSession = (): Promise => { + if(secureSessionPromise && secureSessionCreatedAt && ((Date.now() - secureSessionCreatedAt) > SECURE_SESSION_TTL_MS)) + { + setDebugState('secure: session expired, rotating'); + clearSecureSession(); + } + if(!secureSessionPromise) secureSessionPromise = createSecureSession(); return secureSessionPromise; @@ -229,6 +259,8 @@ const normalizeSecureCacheKey = (requestUrl: string): string => if(!url.pathname.includes('/nitro-sec/file')) return requestUrl; const kind = url.searchParams.get('kind') || ''; + if(kind === 'config') return requestUrl; + const file = (url.searchParams.get('file') || '') .replace(/^[\\/]+/, '') .split('?')[0] @@ -291,6 +323,30 @@ const readRequestBody = async (input: RequestInfo | URL, init: RequestInit | und return null; }; +const buildSecureApiEnvelope = (requestUrl: string, method: string, clearBody: ArrayBuffer | null): ArrayBuffer | null => +{ + if(!clearBody) return null; + + const url = new URL(requestUrl, window.location.href); + const envelope = { + ts: Date.now(), + nonce: randomHex(16), + method, + path: `${ url.pathname }${ url.search }`, + body: bytesToBase64(clearBody) + }; + + return textEncoder.encode(JSON.stringify(envelope)).buffer; +}; + +const scheduleSecureRekey = (): void => +{ + queueMicrotask(() => + { + clearSecureSession(); + }); +}; + export const installSecureFetch = (): void => { if(installed) return; @@ -355,20 +411,38 @@ export const installSecureFetch = (): void => const session = await getSecureSession(); const headers = new Headers(init?.headers || (input instanceof Request ? input.headers : undefined)); const clearBody = await readRequestBody(input, init, method); + const secureBody = buildSecureApiEnvelope(requestUrl, method, clearBody); const encryptedInit: RequestInit = { ...init, method, headers }; headers.set('X-Nitro-Key', session.publicKey); headers.set('X-Nitro-Api', '1'); - if(clearBody) + if(secureBody) { - encryptedInit.body = await encryptBytes(session, clearBody); + encryptedInit.body = await encryptBytes(session, secureBody); headers.set('Content-Type', 'text/plain; charset=utf-8'); } const response = await nativeFetch(input, encryptedInit); - if(response.headers.get('X-Nitro-Sec') === '1') return decryptResponse(session, response); + if(response.headers.get('X-Nitro-Sec') === '1') + { + const decrypted = await decryptResponse(session, response); + + try + { + const pathname = new URL(requestUrl, window.location.href).pathname; + + if(response.ok && REKEY_ENDPOINTS.has(pathname)) + { + setDebugState(`secure: rekey after ${ pathname }`); + scheduleSecureRekey(); + } + } + catch {} + + return decrypted; + } return response; } diff --git a/vite.config.mjs b/vite.config.mjs index e8fa8ff..b8c6e4a 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -17,11 +17,17 @@ export default defineConfig({ ] }, proxy: { - '/api': { - target: process.env.AUTH_PROXY_TARGET || 'http://localhost:2096', - changeOrigin: true, - } - } + '/api': { + target: process.env.AUTH_PROXY_TARGET || 'http://192.168.1.52:2096/', + changeOrigin: true, + ws: true, + }, + '/nitro-sec': { + target: process.env.NITRO_PROXY_TARGET || 'http://192.168.1.52:2096/', + changeOrigin: true, + ws: true, + } + } }, resolve: { tsconfigPaths: true,