Update secure login flow and login view

This commit is contained in:
Lorenzune
2026-04-23 16:26:32 +02:00
parent 237c523f9a
commit 541d3045f1
24 changed files with 801 additions and 106 deletions
+4 -3
View File
@@ -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": [
+32 -3
View File
@@ -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"
],
+96 -23
View File
@@ -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<Promise<void>>(null);
const rendererPromiseRef = useRef<Promise<any>>(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<string> =>
{
const remembered = GetRememberLogin();
if(!remembered) return '';
if(!remembered.token?.length && remembered.ssoTicket?.length) return remembered.ssoTicket;
let allowSsoFallback = true;
try
{
const rawEndpoint = GetConfiguration().getValue<string>('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<string, unknown> = {};
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 (
<Base fit overflow="hidden" className={ !(window.devicePixelRatio % 1) && 'image-rendering-pixelated' }>
{ !isReady && !showLogin && errorMessage.length > 0 &&
{ !isReady && !showLogin &&
<LoadingView isError={ errorMessage.length > 0 } message={ errorMessage } homeUrl={ homeUrl } /> }
{ !isReady && showLogin && <LoginView onAuthenticated={ handleAuthenticated } isEntering={ isEnteringHotel } /> }
{ isReady && <MainView /> }
+1 -1
View File
@@ -2,7 +2,7 @@ export const GetLocalStorage = <T>(key: string) =>
{
try
{
JSON.parse(window.localStorage.getItem(key)) as T ?? null;
return JSON.parse(window.localStorage.getItem(key)) as T ?? null;
}
catch (e)
{
+59
View File
@@ -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<string, unknown>, 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 });
};
+1
View File
@@ -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';
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

+3 -3
View File
@@ -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,
+11 -1
View File
@@ -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<LoadingViewProps> = props => {
</a>
}
</Column>
: null
:
<Column alignItems="center" justifyContent="center" gap={ 3 } className="z-[3]">
<img src={ loadingGif } alt="" draggable={ false } className="block w-auto h-auto select-none pointer-events-none" />
{ message && message.length ?
<Text fontSizeCustom={ 20 } variant="white" className="text-center [text-shadow:0px_4px_4px_rgba(0,0,0,0.25)]">
{ message }
</Text>
: null
}
</Column>
}
</Column>
</Base>
+272 -24
View File
@@ -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<string>(key, '');
if(configValue) return configValue;
}
catch {}
try
{
const configValue = GetConfigurationValue<string>(key, '');
if(configValue) return configValue;
}
catch {}
return '';
});
};
const LOCK_KEY = 'nitro.login.lock';
const CHAT_TRANSLATION_SETTINGS_KEY = 'chatTranslationSettings';
const MAX_ATTEMPTS = 5;
const LOCK_WINDOW_MS = 60_000;
const LOCK_DURATION_MS = 2 * 60_000;
@@ -23,6 +67,17 @@ const DEFAULT_LOGIN_IMAGES: Record<string, string> = {
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<LoginViewProps> = ({ onAuthenticated, isEntering = fa
const [ loginTurnstileResetSignal, setLoginTurnstileResetSignal ] = useState(0);
const [ loginServerReachable, setLoginServerReachable ] = useState<boolean | null>(null);
const [ loginPingingServer, setLoginPingingServer ] = useState(false);
const [ rememberMe, setRememberMe ] = useState(() => !!GetRememberLogin());
const [ selectedLocale, setSelectedLocale ] = useState<LoginLocale>(() => readCachedLocale());
const [ localeApplying, setLocaleApplying ] = useState(false);
const [ localeError, setLocaleError ] = useState('');
const [ loginViewConfig, setLoginViewConfig ] = useState<Record<string, unknown>>(() => GetConfigurationValue<Record<string, unknown>>('loginview', {}));
const submitTimeRef = useRef(0);
const configuredLoginImages: Record<string, string> = ((GetConfigurationValue<Record<string, unknown>>('loginview', {})?.['images']) as Record<string, string>) ?? {};
const configuredLoginImages: Record<string, string> = (loginViewConfig?.['images'] as Record<string, string>) ?? {};
const loginImages: Record<string, string> = { ...DEFAULT_LOGIN_IMAGES, ...configuredLoginImages };
const configuredLoginWidgets: Record<string, unknown> = (loginViewConfig?.['widgets'] as Record<string, unknown>) ?? {};
const loginWidgetSlots = useMemo(() =>
{
return Object.entries(configuredLoginWidgets)
.filter(([ key, value ]) => key.startsWith('slot.') && key.endsWith('.widget') && typeof value === 'string' && value.length > 0)
.map(([ key, value ]) =>
{
const slotNum = key.match(/\d+/)?.[0] ?? '';
const conf = configuredLoginWidgets[`slot.${ slotNum }.conf`] as Record<string, unknown> ?? {};
return { key, slotNum: Number(slotNum), type: value as string, conf };
})
.filter(slot => slot.slotNum > 0)
.sort((a, b) => a.slotNum - b.slotNum);
}, [ configuredLoginWidgets ]);
const backgroundColor = (loginImages['background.colour'] || GetConfigurationValue<string>('login_background.colour', '#6eadc8'));
const background = interpolate(loginImages['background'] || GetConfigurationValue<string>('login_background', ''));
@@ -73,7 +213,10 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
const left = interpolate(loginImages['left'] || GetConfigurationValue<string>('login_left', ''));
const rightRepeat = interpolate(loginImages['right.repeat'] || GetConfigurationValue<string>('login_right.repeat', ''));
const right = interpolate(loginImages['right'] || GetConfigurationValue<string>('login_right', ''));
const 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<string>('login.endpoint', '/api/auth/login');
const registerUrl = GetConfigurationValue<string>('login.register.endpoint', '/api/auth/register');
@@ -97,6 +240,62 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
if(mode === 'login') resetLoginTurnstile();
}, [ mode, resetLoginTurnstile ]);
useEffect(() =>
{
let cancelled = false;
const refreshLoginViewConfig = () =>
{
if(cancelled) return;
const nextConfig = GetConfigurationValue<Record<string, unknown>>('loginview', {});
setLoginViewConfig(previousConfig =>
{
try
{
return JSON.stringify(previousConfig) === JSON.stringify(nextConfig) ? previousConfig : nextConfig;
}
catch
{
return nextConfig;
}
});
};
refreshLoginViewConfig();
const timers = [ 50, 150, 300, 600, 1000, 2000 ].map(delay => window.setTimeout(refreshLoginViewConfig, delay));
return () =>
{
cancelled = true;
timers.forEach(timer => window.clearTimeout(timer));
};
}, []);
const confirmLocaleSelection = useCallback(async () =>
{
if(localeApplying) return;
setLocaleApplying(true);
setLocaleError('');
try
{
applyLocaleSelection(selectedLocale);
await applyTextTranslationLocale(selectedLocale.code);
}
catch
{
setLocaleError('Unable to load this language pack.');
}
finally
{
setLocaleApplying(false);
}
}, [ localeApplying, selectedLocale ]);
useEffect(() =>
{
if(!loginImageUrls.length) return;
@@ -216,17 +415,6 @@ export const LoginView: FC<LoginViewProps> = ({ 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<HTMLFormElement>) =>
{
event.preventDefault();
@@ -262,15 +450,10 @@ export const LoginView: FC<LoginViewProps> = ({ 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<LoginViewProps> = ({ 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<LoginViewProps> = ({ 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<string>('login.check-email.endpoint', '/api/auth/check-email');
const checkUsernameUrl = GetConfigurationValue<string>('login.check-username.endpoint', '/api/auth/check-username');
@@ -454,7 +639,61 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
{ loginImageUrls.map(url => <img key={ url } src={ url } decoding="async" loading="eager" alt="" />) }
</div>
{ loginWidgetSlots.length > 0 &&
<div className="login-widgets">
{ loginWidgetSlots.map(slot =>
{
const image = typeof slot.conf.image === 'string' ? interpolate(slot.conf.image) : '';
const texts = typeof slot.conf.texts === 'string' ? slot.conf.texts : '';
const btnText = typeof slot.conf.btnText === 'string' ? slot.conf.btnText : '';
const btnLink = typeof slot.conf.btnLink === 'string' ? interpolate(slot.conf.btnLink) : '';
const title = typeof slot.conf.title === 'string' ? slot.conf.title : (texts || slot.type);
const description = typeof slot.conf.description === 'string' ? slot.conf.description : '';
return (
<div key={ slot.key } className="login-widget-slot" data-widget-type={ slot.type }>
{ image && <img className="login-widget-image" src={ image } alt="" draggable={ false } /> }
<div className="login-widget-content">
<div className="login-widget-title">{ title }</div>
{ description && <div className="login-widget-description">{ description }</div> }
{ btnText &&
<button
type="button"
className="login-widget-button"
onClick={ () => { if(btnLink) window.location.href = btnLink; } }
>
{ btnText }
</button> }
</div>
</div>
);
}) }
</div> }
<div className="login-stack">
<div className="nitro-login-card login-language-card">
<div className="card-title">Choose your language</div>
<div className="login-language-grid" role="list" aria-label="Language selection">
{ LOGIN_LOCALES.map(locale =>
<button
key={ locale.code }
type="button"
className={ `login-language-option ${ selectedLocale.code === locale.code ? 'selected' : '' }` }
onClick={ () => setSelectedLocale(locale) }
title={ locale.label }
aria-label={ locale.label }
style={ selectedLocale.code === locale.code ? { backgroundImage: `url(${ flagSelected })` } : undefined }
>
<img src={ locale.flag } alt="" draggable={ false } />
<span>{ locale.label }</span>
</button>) }
</div>
{ localeError.length > 0 && <div className="language-error">{ localeError }</div> }
<button type="button" className="ok-button login-language-confirm" disabled={ localeApplying } onClick={ confirmLocaleSelection }>
{ localeApplying ? 'Loading...' : 'OK' }
</button>
</div>
<div className="nitro-login-card">
<div className="card-title">First time here?</div>
<div className="card-body register-card-body">
@@ -490,6 +729,14 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
onChange={ e => setPassword(e.target.value) }
/>
</div>
<label className="remember-row">
<input
type="checkbox"
checked={ rememberMe }
onChange={ e => setRememberMe(e.target.checked) }
/>
<span>Ricordami</span>
</label>
{ turnstileEnabled && mode === 'login' &&
<TurnstileWidget
siteKey={ turnstileSiteKey }
@@ -513,12 +760,13 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = fa
<button
type="submit"
className="ok-button"
disabled={ submitting || isEntering || isLocked || loginServerReachable === false || loginPingingServer }
disabled={ submitting || isEntering || isLocked }
>{ isEntering ? 'Entrando' : loginPingingServer ? 'Checking' : 'OK' }</button>
</div>
<a className="forgot" onClick={ () => setMode('forgot') }>Forgotten your password?</a>
</form>
</div>
</div>
{ mode === 'register' &&
+4 -2
View File
@@ -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<string>('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();
}, []);
+185 -4
View File
@@ -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;
}
}
+42 -31
View File
@@ -150,6 +150,44 @@ const dispatchLocalizationUpdated = () =>
window.dispatchEvent(new CustomEvent('nitro-localization-updated'));
};
export const applyTextTranslationLocale = async (languageCode: string): Promise<void> =>
{
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<string, string>();
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<string, string>();
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
+80 -6
View File
@@ -65,6 +65,13 @@ const textDecoder = new TextDecoder();
let secureSessionPromise: Promise<SecureSession> = null;
let installed = false;
const secureResponseCache = new Map<string, Promise<Response>>();
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<SecureSession> =>
@@ -178,11 +193,26 @@ const createSecureSession = async (): Promise<SecureSession> =>
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<SecureSession> =>
{
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;
}
+11 -5
View File
@@ -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,