Update secure login flow and login view
@@ -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": [
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
@@ -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 === '')
|
||||
@@ -196,13 +256,25 @@ export const App: FC<{}> = props =>
|
||||
}
|
||||
|
||||
if(loginScreenEnabled)
|
||||
{
|
||||
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 + '/');
|
||||
@@ -216,6 +288,7 @@ export const App: FC<{}> = props =>
|
||||
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 /> }
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
@@ -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,
|
||||
|
||||
@@ -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,9 +30,18 @@ export const LoadingView: FC<LoadingViewProps> = props => {
|
||||
</a>
|
||||
}
|
||||
</Column>
|
||||
:
|
||||
<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>
|
||||
</Column>
|
||||
);
|
||||
|
||||
@@ -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,11 +180,32 @@ 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', ''));
|
||||
const sun = interpolate(loginImages['sun'] || GetConfigurationValue<string>('login_sun', ''));
|
||||
@@ -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' &&
|
||||
|
||||
@@ -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();
|
||||
}, []);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -18,8 +18,14 @@ export default defineConfig({
|
||||
},
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.AUTH_PROXY_TARGET || 'http://localhost:2096',
|
||||
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,
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||