From 541d3045f1b2d03d31e6ae2b40fd4b4dece5f80d Mon Sep 17 00:00:00 2001 From: Lorenzune Date: Thu, 23 Apr 2026 16:26:32 +0200 Subject: [PATCH] Update secure login flow and login view --- public/renderer-config.json | 7 +- public/ui-config.json | 35 ++- src/App.tsx | 119 +++++-- src/api/utils/GetLocalStorage.ts | 2 +- src/api/utils/RememberLogin.ts | 59 ++++ src/api/utils/index.ts | 1 + src/assets/images/flag_icon/flag_icon_br.png | Bin 0 -> 2096 bytes src/assets/images/flag_icon/flag_icon_de.png | Bin 0 -> 1643 bytes src/assets/images/flag_icon/flag_icon_en.png | Bin 0 -> 2584 bytes src/assets/images/flag_icon/flag_icon_es.png | Bin 0 -> 1658 bytes src/assets/images/flag_icon/flag_icon_fi.png | Bin 0 -> 1783 bytes src/assets/images/flag_icon/flag_icon_fr.png | Bin 0 -> 1743 bytes src/assets/images/flag_icon/flag_icon_it.png | Bin 0 -> 1745 bytes src/assets/images/flag_icon/flag_icon_nl.png | Bin 0 -> 1679 bytes .../images/flag_icon/flag_icon_selected.png | Bin 0 -> 9336 bytes src/assets/images/flag_icon/flag_icon_tr.png | Bin 0 -> 1868 bytes src/bootstrap.ts | 6 +- src/components/loading/LoadingView.tsx | 12 +- src/components/login/LoginView.tsx | 296 ++++++++++++++++-- src/components/purse/PurseView.tsx | 6 +- src/css/login/LoginView.css | 189 ++++++++++- src/hooks/translation/useTranslation.ts | 73 +++-- src/secure-assets.ts | 86 ++++- vite.config.mjs | 16 +- 24 files changed, 801 insertions(+), 106 deletions(-) create mode 100644 src/api/utils/RememberLogin.ts create mode 100644 src/assets/images/flag_icon/flag_icon_br.png create mode 100644 src/assets/images/flag_icon/flag_icon_de.png create mode 100644 src/assets/images/flag_icon/flag_icon_en.png create mode 100644 src/assets/images/flag_icon/flag_icon_es.png create mode 100644 src/assets/images/flag_icon/flag_icon_fi.png create mode 100644 src/assets/images/flag_icon/flag_icon_fr.png create mode 100644 src/assets/images/flag_icon/flag_icon_it.png create mode 100644 src/assets/images/flag_icon/flag_icon_nl.png create mode 100644 src/assets/images/flag_icon/flag_icon_selected.png create mode 100644 src/assets/images/flag_icon/flag_icon_tr.png 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 0000000000000000000000000000000000000000..9d62a464e1639ae5aa6540d75fa22f48b71eff61 GIT binary patch literal 2096 zcmaJ?Yg7|w8jiOtpit2Qi-Jr8F2$NF1ajjhfdCTX2DFHV5R(B?NhVDOG6bYnw!458 zFNhp(p+^ul&_Z2z3k)pdrMHM=?Oe)CCoGLV@_6gUaWfYl-;h>4UyM?fG> zCdvf)ph%MUVIRoCVAlCblT+XnZURFL$p|7lhJeTvC>w)eg(C`)_$UYiN5D*}JQO#j z@5BL8Nht1pDwo7nM1fh-Sd9`)(j+E}HAlsC2`)SgU?B`tKnB7h0Fh9E~83+z0kb|hf!Q|Zlg+!(hN$4GnCkHX8 z!3;_Wuyo_Xv=g6TWKlaZRauPhi1&Mk>XzfMo<7}Pl)BmP*t*|u!j^QW&tWeCsUzxrJffR}*R?+f zeG|Xs!p7FtAia0ShR42GAB$JvvGf2qF~@bs!D%gwXh z5RmGVP?WK`5(?sCd9t^(kw4tKDVZL8X7Z%Tc0?4_jbn*frdpq{=bPcXW4)cZ&ZYs= zG1H-jdY6*scn)nX!Rv5BPf~|Dx^i+}F#5Xm=g|2wMU*4bSkRLsFke46 zd6IYADv*CE{AgjP4qba-K3i5m{>fKcn(P1K@a`vHXBDz|KV9}&s2F;c$Zo$FV%B|% zbaQSDEPQ)sxRJv$1Q=WOSFw1-5K56uqoa4Dys)EpBJbGWk?|7{Ovw9?}!Jk_1>Vy0{tU`-x(Y?+;NuXr7E z_ipuIQx)s2xt2^QjQh%VCGYKm=l>bRnZAeC+n8sf+qZnx|7LcqLEy9S;Nj50^_wc1 zTI^CzP9A&P_-iLCi2B9Qp)$4Y{@Sn45nJsc#VN#Nj`85b@Q?C4cdZfM@eo9qb)Wq0 zYD2$mYv=c61fPXld%MaFe>lTT)2ShF)O($}Yh+}9+YCNbdqW*=*2%1z3PQ#4>N^~k?Z!hed^hohu|&1u zQ~c;_+jv;pDR1jwEmCdCy}Tj8PdK69VU_gy3iCO7G)Z=txda&|2%B7aveJ-F?L%98 z&~wAB3Htl*G1L0SRTuto!D*g(r9Q3h$cMrk4$-C>3}=|e*6;k7>7&dm-aPTi%Dr92e@l9dmJeD9SZmM) zM7JSZa64tdps@s+rP)@#_4fE(}PPrYkQ zTXXt)-%itCzjEdEG#u>e*ix2zJ{|?tRhtekH2o2$Egm{?{7=5xVorn|GQo*(U9!`P znU?M3U*zw}@?`ay`b=>i?}UHM5_a#Lb_##`dG&j9n|b@2HTat&+hf{lFV49`mTeh5 zN%y>=OSRuETps-A*mVIOTiB`FP!Gx#tS{MBN z{2D!=t#>EZ7i`P^>_CmLw%+Y@fzyRaO=6>be*OgV0jGWTcjue@Un<8&9b;RzJ~6hU zP5um-N|=81FN-7XjAHMAE3bctLC$z;APFq9>1N$Qt3xc!tUERDmX3+h^I9Un8y)t4 OcwBTMyKztYiT?ulg?-Eb literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6090d2c90aad5b46d6a1914565c08496d945b773 GIT binary patch literal 1643 zcmeAS@N?(olHy`uVBq!ia0vp^^MH5>2OE$~OPRg_NJ*BsMwA5Srr5%(GQ`zk9!uLS~AsQn;zFfp39xYDT62(s|s5su(?)1Hb_`sNdc^+B->UA;;0DU00rm#qErP_J!9Qu14BavGc!Fy z6H_xYLmdSp14AQy10XWfH8im@HM24@SAYT~plwAdX;wilZcw{`JX@uVl9B=|ef{$C za=mh6z5JqdeM3u2OOP2xM!G;1y2X`wC5aWfdBw^w6I@b@lZ!G7N;32F6hI~>Cgqow z*eU^C3h_d20o>TUVrVb{15Cdnu|VHY&j92lm_lD){7Q3k;i`*Ef>IIg#cFVINM%8) zeo$(0erZuMFyhjbK~@!5ITxiSmgEr(2xdaO%|uIz}H9wMbD769T3m5EGtofgE_!Pt60S_ab1z7LxP-%fP^N*VDx@ zq~g|_+lGF(0~n4!w3(%Atn96>GDXwY!$K@N|B}p-nMx|}4g`gsYFMSQLuj3c@aLT4 z$KD^kuO6zTYOrJfze+xVZ@>R8NuFEpG)chfZ_1(M2_F`T9z85FOJMbUk){tPtZfzs z?pA#`F}I>=abd$D<=r2mSk333|0^8Ra5k+ttReJa?9MGJrQUZ+Uh2$YU9)2+hgg4! z`1;pNXP-@5SJbxccJBJq=^syIe_S=~+@aoV#@BlDk1DS{`a1jNw9`vh&slgi>+0)I zkv5m&Y`oKd?uxMMKE9#*`nR0)mpb=XK3!dXZF2=r(yecLVs@`ox7CRCn~1M}7pwC) zovTeB1mrmQ zA1c}i$Q@!vrZ_wP+1xw){(1B9&(q`ANx7Tp*Xzjby8=>=su0X;VFy~JV1rC?@VB&o zaHv40z*aRvY=lyn7Q+luF28(3cwgOpS(|-3i!WMitGyFxQ?rs4=tg8)pmrkL;!v?9 z=hheIdH3q-KYe@Gz54imIUTu3j^9peH`T|;+ia_t6Se2|@APi>L+N`24sG9`FLL<% z<;ebh=gU&_#rhM^RUDn~I{E1D|7)I~Nq;$O&RZS1X8pL6pDzBsBzjG(|BskHFr+_h zdLQ*#Z|+g$n@3-l{@lUyTrc+IqlwoZO`WATpH=4jp}7&eI*%&9Jo>uir$l7H#HE`n zRCZTP>%Zn5Ub3m~_iv!w{G;0S4m&l<13%VrXFTki_U`Uy9XUz8d%K&&Cq9opAgCL2 z^-`qGroF3kORk^JSabeoLbv<2?sP`oIY*WG*E8`joYVh)(r-&l8>smAboFyt=akR{ E05j@@ZvX%Q literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..5d1daa6e5bc15cadc2528fd68d683b46a4f53098 GIT binary patch literal 2584 zcmaJ@X;>3k7LA4m8^t(|3n;{N>~XY}Y>)sUVUe&CML>ZT!H~@mlVDOn0U`n;TL%y~ z2p}%4=qO8L5S3L#6BI-N1=(>jiV*=LvNer#g%-Pij8k7#y?Xb%=iGDOJMa4{#dC+V zf&Myu7z}3M>O%8^#(e0xv_uzrb68owK!XwB=m&UnIZv(q0Pa7f=~zzgHde3PzICF22jClE>B2Bk5<;AQ9KqE z?MtNN=%Ehm13Z@~G21(8hYvG~&$MEpZEa8#kPHzB*Z>0s3JwY-WRQxU=Oshm+AtQ4 znoj}vRP-OB{OF!22a%YKB4W%;nK(QiMIvGF=0p++zYRse;R#qAbdyZ+=42v?Ot3(G zzR*xLF^fa?qB(u81RU{M%AMMFyeXh9JAg;pr}yiCx7VL?VH7LUPcTUtn@)Bk^} zK=3770(i0i)cZe)B|ed%Y^)bsA_@~Tp^f8g)Rqb*JBZl~KqU4Ni4HETqUQk-Ad(yq zg`yn1NvO?q29qb$2DZ#2=ybBHPy#T7OtveHiiS8aJRXZoGb7N51S_J06UhpXcO+U_ z5}jyxGZN0yo@noAv3&tc6EVXCY$33KW&MdIeHE)UgCG>DOk<0A;cS+ZSR_ErH%;b! zU5nXQ`95P=U)N&(RV)?~gVk>BU$%N71o=n%`Ng=<p1BcnVkoHOH-zdK@&EXgh9RGAcSjpYOqW?olE%ExA^C#M5Q^3Istv%gKq z`)}1+SUdWkS(=$*gGxT1Og22Gm#`{dp4xNF^}?>?#}+^6>mz`*$YZ)i{FHm}o5!5% z;vyD#f#-|g$Lk9Tvb@OI%V`hxOx9L@`u>goljz@`9xB5jGptwd_kLM@cj+n`Jtc`C}RgJerpf7xl!J~Q*w`7o++nimOz2AHtJ+}0t+tUq}>!`(o+KE>{UUkV( z2ZC4=Gw1W7uP=XkPRO_M-dR44LpI<7hbBidMs{=Lgr}_^fSCzEf<5s!X-XbvCAo%_ z-v94)%ii#92*`$<=jD&X6~L9N9$a}%l#SYCYB(t9AJbLHnAq!|+NZ`uX7ZrDW3s{| zja|70w=Ym(MhUEJ4{4t{Jp8uh)vFl<4!I=%dE!JcQ#O*jbSG!ks7=`Erd5=?V znwo)vpG!Ssin&n}mw(QT)7T|x0t&isPZpjTm%473A8(|^WSp~(t(}=T*d}{7dS|h* zDh{I}vMc;vj}dqEtC$sX`rtLVRPW;Eu41>+j0;POfapCvWv#w%r%17%J`|+iA~pKd z$vX;WCqu87`}g@j0MA>hWd4nPX_km4{Hl~}%nGUA4YNMHd*eIeH%6k7f{iMBFC#<) zfUg6OCu*7sOG|uH%QW5Vob(-Xm#Y>TAwJ+u6ba^1ZiG%S7eB0GlBIf!-;V^m>jgJH zf?HJI`}OVf31ZE&zR@1K^WfqW=K1R9i8Za=HTTM-wk3f@Q|UU2giC3I{B*yqsh89G zZ)wUt2I7%dQBi{zM?3Q;G$+mWp$rkg`kgcQRbJ^WF1-LWdlDky_BkEoS41*nms0vO z^dg%=zQeX?l7SWv_!d?F5E%2y`Vd6jk8pbOvo>BJ#5>VMLIHQl6PC(03PosY-gtyU#BD@H?%P*cfn5u z?};bNG}?}hrQE2p0`B_t!%EMo@Rec<69wGFC}S)DfJ3P(V9EfU*7m$R+3A6%mriS7 zT4^z=*(v4eu89X(flU(Umdv}LouVFqv@L>$qZ*isLB0#3UVwu&mAh-}51blbUJv!! z0A?@8A@@@}^b8@N7Sw~Vm1&cQheLXG+&WZR5}2Axf!c(%TIBC(?s?I<`#N-}p+2%Qw1?X~uy7nKwO)BA2eW#NL2)rtF)BQoCcjlAEN3vKD=*sk(;{HMzLKU||)j6aIzuvfOpaP1YnGVlKdc5{dQO%#lj6yzEK7cgL%taZTBHlhjh@IB7;<)L P|J7U_chHKr?~nf-ldOpD literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..623c8c43b04cdb25cf8ae920c3bc699ec54ec2af GIT binary patch literal 1658 zcmaJ?4@?tx6s{vWA~QB_6m=P%hi)^rJ+9D}o^7?X6dNrgg~T!~TCPXwYVU5>!xrp7 z3Fu6t3^p;_B2I^Ki9!BF-4w=T>(u>`&5Xh9bW(+n1h}x?uj&`8*zPfnmMQ1Z*{UY9i->cdqSFJupky6f>Ja<+2_tL+~^R;FgKdL`*8fBywo- zfOirQCTG4QVrE(5pr(?jX6q zs&RzTh#5#XYb9~Qpi{wsL+o(au!Ka7UYn?wYxS}u6ityTlB9Yanjni$(nvKa^5iM3 zj>7X@1j$ZeZI7`@PsMV};G#ig9lwSxuEf;F~=-gGZzuRcM7eP_U8-;(YY@p#Yl8+4jX@3sCQnifpw zUF=py=4|V4GcC_gPTttJstfbnjxKyJ`>3|=i&d9O%@tkd#Y^DQlFc2at6#l!2JaaT z-F-LR;9T5RS6rR3wCda58vJl^awQ)dwYA~z3?7RPeEGr${FBIhjraGLs(<+8dgb6H znexJqN$a{REZ*9Z^XfGZR(3^fT3Ehjt~vU~?h{otcMU&s`U17h2T*)NKEz)2x8p5@q&`2xA?h?5!JyyTkH!W>0m|Xn#HzL#mHA`7FXC z0lX{V+ub3fE_ihGj|Xe~djn0rsw*M%N?>PQtK!p>bGw%-`LS(UFQhDe_DQpdKU&cI+S=^S z*y85FnuflSfsRo3W>MtA2|&aQ^Itfqyv`fBaHi?rJ;;v*3{RN0nM9m7se_U4x6osw z^qry0LlyS@-=_(} z**gsZJQ%X5l&>onse8)!>an5|i$8zSu6n2r9&E`{?osqaQ%x`Xhwo;!zu$gUstAHP zZ*8vZ{doBJ>hDCcBckl>Bf^_Szn%Wsg85S${hi@^4a;^|&Q%8DY6GjK7Ijq8Kv}Sn zecqpX)!*574{iJi-e4bEzhg+atoG2U+qQp(hhGtt`vi4=&*?q(YgXXM=5SB+k#VX^a*2sxzQmz7bX&Ko%huT;(~#uU4kp2ioNnV^LX|@A8aLB Szd6AD4Gbx1x>L#9O8y0gP=*iy literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c547fb1a06dd053ca1c87fa4ac2b2ce97338e6bf GIT binary patch literal 1783 zcmeAS@N?(olHy`uVBq!ia0vp^^MH5>2OE$~OPRg_NJ*BsMwA5Srr5%(GQ`zk9!uLS~AsQn;zFfp39xYDT62(s|s5su(?)1Hb_`sNdc^+B->UA;;0DU00rm#qErP_J!9Qu14BavGc!Fy z6H_xYLmdSp14AQy10XWfH8im@HM24@SAYT~plwAdX;wilZcw{`JX@uVl9B=|ef{$C za=mh6z5JqdeM3u2OOP2xM!G;1y2X`wC5aWfdBw^w6I@b@lZ!G7N;32F6hI~>Cgqow z*eU^C3h_d20o>TUVrVb{15Cdnu|VHY&j92lm_lD){7Q3k;i`*Ef>IIg#cFVINM%8) zeo$(0erZuMFyhjbK~@!5ITxiSmgE|*u>vYuTOWoutw+f;=a;km7f{;{L%V_DR$n>p7`%G&I=^8CdVAFnfc z-~Y)cpD2K2vp=3Xu-s%b^c*z3(27_?5A{My$V1Z2g^&^05(Cr@vpBx!&{Y z?4!!(jORU1;Jo;44bXMJ1K)1jEPHs%>&WwuE^f`(6bma7tKdMbWdHD3roM%~uSGI|FyU*#Ko_h1Q{rV}Xm9N(vUwFPO zG+(TLhx}EuxnFhWFXgtpZTa}(>YTS<3r|F@e;Jr}{q*ziF?)KJu0B`x{h3(*kMk7^ z)Au>+Diqpe@!DRtd@OiZGJpBzd)tif?>o2NZ_>U4U+=R{-*sW>>iKVh31Qwxr}-CG z{P+EE=beH0JA3cvJ??kX_w;U#Q~h%0xI=T*o|58jccJd|pEoLBzO$bBzjnW|9H;KR z+MS^nAGg_WZxDNT@zUD6=N?Ub@Mx{5&Ap;(Zi@x2*-=*A?wk6vn&rM4NqnxgyYXn^ ln@5oj-02@Z4(BtnF?^o#`xnQvH%CELk*BMl%Q~loCIBkg+6Mpt literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2f438778db3a59d9792ec6d4dc4bcceba0209bc6 GIT binary patch literal 1743 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 XEad{{ZC{oyP!-|n>gTe~DWM4f#QK{< literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3aac0676d6b480f80cd7322c9c644a3a6a19ef00 GIT binary patch literal 9336 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℘cW-$jNY|nr$W3qY_7Y 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9cd1c27a8541d55527104a3b6bf663e7ede28436 GIT binary patch literal 1868 zcmaJ?X;2eq7!KG2C~8q^ixyY{3^+(aI1)$`93TV&hG2pc6|pf{AV9J)S#>XyvH!s0ShlQB**IK!pN={epn~QM$AHeY@{I@AE$Iv9syn zVL?mHR-55)xTUOM1{dsVa21<;0@fH&{spjEp@Dpqhs2;tfgHy9iIANz#F7YNVJ<8X zCGTp6X*k>x2XO=+<+C?Xg@}Y8FklFnLrGk5dnTUsG zA!q^}|6UZI9S#K`av1U=c(@APNF<0%CXhV6$Yjzw$lZ+oQnmX==eAkl~IXArBX>ydJqtKERp2n<741(cXtH{S4FZE6=1GXh5bAO z16By-Vi_t%q>zD8uoFo_>3E>@eF_rU0Qbx;tZp&AQ__Rmc%&_rZ<(4uJ)Akd-b2grPx-$4 zIAwX|b<;ogH9T4OSXU8l7r$yRoTk~7MyWp4BUpXHZmc2v?pNv&8+DQRuRL>3`!a2} zXYzqG&8sv@f~+bsf5yb@<@1@Ivqob}hjME-7ZK7mAwk~8V^L9_O}r?5Uf$rO?r~kA zK$*p1_1ddPR#b4PSMND?^CwgqzOLlx;G3!CgT3ZcV|_v{Fg?^|XQqVS2%?>L>3Upg z-FHJ7T>ncfT_?`H`8@hs$h(f#BPmV14f^ZR*UsInp_@;45cHP##$!FrR>w z%jx>xhg43w>@2j2cT#t@;jxrXxhXudKZ)l_Lb zCxFmWn`RKg!mhKx=h1Wk?VfA&pw@Yno9CS z9gVGvdX_PgJ#EN`HhSK*~Sc7GdtR_bQu*9SpskU1$3h<2f;b zf!S%Oudnx34)tkmRGM}1V-jKX@L2yy#n4L54^6d~_d{yqpY$irB#(}+aZTBA@=eZU z;)BKy4$Zy%H}keBy?DCCekSmwnnY>@zyTZddtmM zibqNhW#*P=U7I%EkDY~{T!gmw-tWlpzb3vEQsVqrQ{yH`Nbd7GU zt-i*|xvI5u>@Cg6bUX{yMrUFz;a6fm>soe&X5_M>oI};E?I?T1imW?#rjyig9BbK3 gGhP8er)`bRan-W3p{0r^hJPiF6&S`S_y0Ea9|s#17XSbN literal 0 HcmV?d00001 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,