From 2de52235eb717e551e84a346d47f516d4e9d401b Mon Sep 17 00:00:00 2001 From: duckietm Date: Mon, 20 Apr 2026 14:22:53 +0200 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=86=95=20UI=20Login=20page=20/=20no?= =?UTF-8?q?=20CMS=20required=20anymore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/renderer-config.json | 7 + public/{ui-config.json => ui-config.example} | 11 + src/App.tsx | 48 +- src/components/login/LoginView.tsx | 555 +++++++++++++++++++ src/components/login/TurnstileWidget.tsx | 118 ++++ src/components/purse/PurseView.tsx | 34 +- src/css/login/LoginView.css | 261 +++++++++ src/index.tsx | 2 + vite.config.mjs | 2 +- 9 files changed, 1033 insertions(+), 5 deletions(-) rename public/{ui-config.json => ui-config.example} (99%) create mode 100644 src/components/login/LoginView.tsx create mode 100644 src/components/login/TurnstileWidget.tsx create mode 100644 src/css/login/LoginView.css diff --git a/public/renderer-config.json b/public/renderer-config.json index b6657aa..766d547 100644 --- a/public/renderer-config.json +++ b/public/renderer-config.json @@ -39,6 +39,13 @@ "room.color.skip.transition": true, "room.landscapes.enabled": true, "room.zoom.enabled": true, + "login.screen.enabled": false, + "login.endpoint": "https://websocket.yourdomain.com/api/auth/login", + "login.register.endpoint": "https://websocket.yourdomain.com/api/auth/register", + "login.forgot.endpoint": "https://websocket.yourdomain.com/api/auth/forgot-password", + "login.logout.endpoint": "https://websocket.yourdomain.com/api/auth/logout", + "login.turnstile.enabled": false, + "login.turnstile.sitekey": "", "avatar.mandatory.libraries": [ "bd:1", "li:0" diff --git a/public/ui-config.json b/public/ui-config.example similarity index 99% rename from public/ui-config.json rename to public/ui-config.example index 6a8f1c0..d33db29 100644 --- a/public/ui-config.json +++ b/public/ui-config.example @@ -27,6 +27,17 @@ "guides.enabled": true, "toolbar.hide.quests": true, "catalog.style.new": true, + "loginview": { + "images": { + "background": "${asset.url}/c_images/reception/stretch_blue.png", + "background.colour": "#6eadc8", + "sun": "${asset.url}/c_images/reception/sun.png", + "drape": "${asset.url}/c_images/reception/drape.png", + "left": "${asset.url}/c_images/reception/ts.png", + "right": "${asset.url}/c_images/reception/US_right.png", + "right.repeat": "${asset.url}/c_images/reception/US_top_right.png" + } + }, "navigator.room.models": [ { "clubLevel": 0, diff --git a/src/App.tsx b/src/App.tsx index e5744e3..94128b3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import { FC, useCallback, useEffect, useState } from 'react'; import { GetUIVersion } from './api'; import { Base } from './common'; import { LoadingView } from './components/loading/LoadingView'; +import { LoginView } from './components/login/LoginView'; import { MainView } from './components/MainView'; import { ReconnectView } from './components/reconnect/ReconnectView'; import { useMessageEvent, useNitroEvent } from './hooks'; @@ -14,12 +15,24 @@ export const App: FC<{}> = props => const [ isReady, setIsReady ] = useState(false); const [ errorMessage, setErrorMessage ] = useState(''); const [ homeUrl, setHomeUrl ] = useState(''); + const [ showLogin, setShowLogin ] = useState(false); + const [ prepareTrigger, setPrepareTrigger ] = useState(0); const showSessionExpired = useCallback(() => { const baseUrl = window.location.origin + '/'; setHomeUrl(baseUrl); setErrorMessage('Your session has expired.\nPlease log in again to enter the hotel.'); setIsReady(false); + setShowLogin(false); + }, []); + + const handleAuthenticated = useCallback((ssoTicket: string) => + { + if(!ssoTicket) return; + window.NitroConfig['sso.ticket'] = ssoTicket; + setShowLogin(false); + setErrorMessage(''); + setPrepareTrigger(prev => prev + 1); }, []); // Listen for socket closed events (code 1000 "Bye" - server rejected SSO) @@ -48,6 +61,36 @@ export const App: FC<{}> = props => if(!ssoTicket || ssoTicket === '') { + // Configuration is loaded lazily — fetch it up-front so the login + // screen toggle and Turnstile keys are available before we decide. + let configInitError: unknown = null; + try { await GetConfiguration().init(); } + catch(e) { configInitError = e; } + + const rawLoginEnabled = GetConfiguration().getValue('login.screen.enabled', false); + const loginScreenEnabled = rawLoginEnabled === true || rawLoginEnabled === 'true' || rawLoginEnabled === 1; + + if(configInitError) + { + NitroLogger.error('[LoginScreen] Failed to load renderer-config.json — cannot resolve login.screen.enabled', configInitError); + } + + if(loginScreenEnabled) + { + setIsReady(false); + setShowLogin(true); + 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); + return; + } + showSessionExpired(); return; } @@ -120,12 +163,13 @@ export const App: FC<{}> = props => { if(heartbeatInterval !== null) window.clearInterval(heartbeatInterval); }; - }, []); + }, [ prepareTrigger ]); return ( - { !isReady && + { !isReady && !showLogin && 0 } message={ errorMessage } homeUrl={ homeUrl } /> } + { !isReady && showLogin && } { isReady && } diff --git a/src/components/login/LoginView.tsx b/src/components/login/LoginView.tsx new file mode 100644 index 0000000..f865f22 --- /dev/null +++ b/src/components/login/LoginView.tsx @@ -0,0 +1,555 @@ +import { GetConfiguration } from '@nitrots/nitro-renderer'; +import { FC, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { GetConfigurationValue } from '../../api'; +import { TurnstileWidget } from './TurnstileWidget'; + +type DialogMode = 'login' | 'register' | 'forgot'; + +const interpolate = (value: string | null | undefined): string => +{ + if(!value) return ''; + try { return GetConfiguration().interpolate(value); } + catch { return value; } +}; + +const LOCK_KEY = 'nitro.login.lock'; +const MAX_ATTEMPTS = 5; +const LOCK_WINDOW_MS = 60_000; +const LOCK_DURATION_MS = 2 * 60_000; + +type AttemptState = { attempts: number; firstAt: number; lockedUntil: number }; + +const readLock = (): AttemptState => +{ + try + { + const raw = sessionStorage.getItem(LOCK_KEY); + if(!raw) return { attempts: 0, firstAt: 0, lockedUntil: 0 }; + return JSON.parse(raw); + } + catch { return { attempts: 0, firstAt: 0, lockedUntil: 0 }; } +}; + +const writeLock = (state: AttemptState) => +{ + try { sessionStorage.setItem(LOCK_KEY, JSON.stringify(state)); } + catch { } +}; + +export interface LoginViewProps +{ + onAuthenticated: (ssoTicket: string) => void; +} + +export const LoginView: FC = ({ onAuthenticated }) => +{ + const [ mode, setMode ] = useState('login'); + const [ username, setUsername ] = useState(''); + const [ password, setPassword ] = useState(''); + const [ error, setError ] = useState(null); + const [ info, setInfo ] = useState(null); + const [ submitting, setSubmitting ] = useState(false); + const [ loginTurnstileToken, setLoginTurnstileToken ] = useState(''); + const [ loginTurnstileResetSignal, setLoginTurnstileResetSignal ] = useState(0); + const submitTimeRef = useRef(0); + + const loginImages: Record = ((GetConfigurationValue>('loginview', {})?.['images']) as Record) ?? {}; + + const backgroundColor = (loginImages['background.colour'] || GetConfigurationValue('login_background.colour', '#6eadc8')); + const background = interpolate(loginImages['background'] || GetConfigurationValue('login_background', '')); + const sun = interpolate(loginImages['sun'] || GetConfigurationValue('login_sun', '')); + const drape = interpolate(loginImages['drape'] || GetConfigurationValue('login_drape', '')); + 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 loginUrl = GetConfigurationValue('login.endpoint', '/api/auth/login'); + const registerUrl = GetConfigurationValue('login.register.endpoint', '/api/auth/register'); + const forgotUrl = GetConfigurationValue('login.forgot.endpoint', '/api/auth/forgot-password'); + const turnstileSiteKey = GetConfigurationValue('login.turnstile.sitekey', ''); + const rawTurnstileEnabled = GetConfigurationValue('login.turnstile.enabled', false); + const turnstileEnabled = (rawTurnstileEnabled === true + || rawTurnstileEnabled === 'true' + || rawTurnstileEnabled === 1 + || rawTurnstileEnabled === '1') && !!turnstileSiteKey; + + const resetLoginTurnstile = useCallback(() => + { + setLoginTurnstileToken(''); + setLoginTurnstileResetSignal(prev => prev + 1); + }, []); + + useEffect(() => + { + setError(null); + setInfo(null); + if(mode === 'login') resetLoginTurnstile(); + }, [ mode, resetLoginTurnstile ]); + + const lockState = useMemo(() => readLock(), [ submitting ]); + const now = Date.now(); + const isLocked = lockState.lockedUntil > now; + + const recordFailure = useCallback(() => + { + const state = readLock(); + const currentNow = Date.now(); + + if(currentNow - state.firstAt > LOCK_WINDOW_MS) + { + writeLock({ attempts: 1, firstAt: currentNow, lockedUntil: 0 }); + return; + } + + const attempts = state.attempts + 1; + const lockedUntil = attempts >= MAX_ATTEMPTS ? currentNow + LOCK_DURATION_MS : 0; + writeLock({ attempts, firstAt: state.firstAt || currentNow, lockedUntil }); + }, []); + + const clearLock = useCallback(() => + { + writeLock({ attempts: 0, firstAt: 0, lockedUntil: 0 }); + }, []); + + const postJson = useCallback(async (url: string, body: Record) => + { + const response = await fetch(url, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-Requested-With': 'NitroLoginView' + }, + body: JSON.stringify(body) + }); + + let payload: Record = {}; + try { payload = await response.json(); } + catch { } + + return { ok: response.ok, status: response.status, payload }; + }, []); + + const handleLoginSubmit = useCallback(async (event: FormEvent) => + { + event.preventDefault(); + + if(submitting) return; + + const nowTs = Date.now(); + if(nowTs - submitTimeRef.current < 1000) return; + submitTimeRef.current = nowTs; + + const state = readLock(); + if(state.lockedUntil > nowTs) + { + const remaining = Math.ceil((state.lockedUntil - nowTs) / 1000); + setError(`Too many attempts. Try again in ${ remaining }s.`); + return; + } + + if(!username.trim() || !password) + { + setError('Please enter both your Habbo name and password.'); + return; + } + + if(turnstileEnabled && !loginTurnstileToken) + { + setError('Please complete the security check.'); + return; + } + + setError(null); + setSubmitting(true); + + try + { + const { ok, payload } = await postJson(loginUrl, { + username: username.trim(), + password, + turnstileToken: turnstileEnabled ? loginTurnstileToken : undefined + }); + + const ssoTicket = typeof payload.ssoTicket === 'string' ? payload.ssoTicket : (typeof payload.sso === 'string' ? payload.sso : ''); + + if(ok && ssoTicket) + { + clearLock(); + onAuthenticated(ssoTicket); + return; + } + + recordFailure(); + const message = typeof payload.error === 'string' ? payload.error : 'Invalid Habbo name or password.'; + setError(message); + resetLoginTurnstile(); + } + catch(err) + { + recordFailure(); + setError('Unable to reach the login service. Please try again.'); + resetLoginTurnstile(); + } + finally + { + setSubmitting(false); + } + }, [ submitting, username, password, turnstileEnabled, loginTurnstileToken, loginUrl, postJson, clearLock, recordFailure, onAuthenticated, resetLoginTurnstile ]); + + const handleRegisterSubmit = useCallback(async (body: { username: string; email: string; password: string; turnstileToken: string; }, onDialogReset: () => void) => + { + if(turnstileEnabled && !body.turnstileToken) + { + setError('Please complete the security check.'); + return; + } + + setError(null); + setInfo(null); + setSubmitting(true); + + try + { + const { ok, payload } = await postJson(registerUrl, { + username: body.username, + email: body.email, + password: body.password, + turnstileToken: turnstileEnabled ? body.turnstileToken : undefined + }); + + if(ok) + { + setInfo(typeof payload.message === 'string' ? payload.message : 'Account created. You can now log in.'); + setMode('login'); + setUsername(body.username); + setPassword(''); + return; + } + + setError(typeof payload.error === 'string' ? payload.error : 'Unable to create your account.'); + onDialogReset(); + } + catch + { + setError('Unable to reach the registration service.'); + onDialogReset(); + } + finally + { + setSubmitting(false); + } + }, [ turnstileEnabled, registerUrl, postJson ]); + + const handleForgotSubmit = useCallback(async (body: { email: string; turnstileToken: string; }, onDialogReset: () => void) => + { + if(turnstileEnabled && !body.turnstileToken) + { + setError('Please complete the security check.'); + return; + } + + setError(null); + setInfo(null); + setSubmitting(true); + + try + { + const { ok, payload } = await postJson(forgotUrl, { + email: body.email, + turnstileToken: turnstileEnabled ? body.turnstileToken : undefined + }); + + if(ok) + { + setInfo(typeof payload.message === 'string' ? payload.message : 'If an account exists we just sent a reset link to your email.'); + setMode('login'); + return; + } + + setError(typeof payload.error === 'string' ? payload.error : 'Unable to send a reset email right now.'); + onDialogReset(); + } + catch + { + setError('Unable to reach the password reset service.'); + onDialogReset(); + } + finally + { + setSubmitting(false); + } + }, [ turnstileEnabled, forgotUrl, postJson ]); + + return ( +
+ { background ?
: null } + { sun ?
: null } + { drape ?
: null } + { left ?
: null } + { rightRepeat ?
: null } + { right ?
: null } + +
+
+
First time here?
+
+ Don't have a Habbo yet? + setMode('register') }>You can create one here +
+
+ +
+
What's your Habbo called?
+
+
+ + setUsername(e.target.value) } + /> +
+
+ + setPassword(e.target.value) } + /> +
+ { turnstileEnabled && mode === 'login' && + setLoginTurnstileToken('') } + onError={ () => setLoginTurnstileToken('') } + resetSignal={ loginTurnstileResetSignal } + /> } + { error &&
{ error }
} + { info &&
{ info }
} +
+ +
+ setMode('forgot') }>Forgotten your password? + +
+
+ + { mode === 'register' && + setMode('login') } + onSubmit={ handleRegisterSubmit } + submitting={ submitting } + error={ error } + info={ info } + turnstileEnabled={ turnstileEnabled } + turnstileSiteKey={ turnstileSiteKey } + /> } + + { mode === 'forgot' && + setMode('login') } + onSubmit={ handleForgotSubmit } + submitting={ submitting } + error={ error } + info={ info } + turnstileEnabled={ turnstileEnabled } + turnstileSiteKey={ turnstileSiteKey } + /> } +
+ ); +}; + +interface DialogSharedProps +{ + onCancel: () => void; + submitting: boolean; + error: string | null; + info: string | null; + turnstileEnabled: boolean; + turnstileSiteKey: string; +} + +interface RegisterDialogProps extends DialogSharedProps +{ + onSubmit: (body: { username: string; email: string; password: string; turnstileToken: string; }, onDialogReset: () => void) => void; +} + +const RegisterDialog: FC = props => +{ + const { onCancel, onSubmit, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props; + const [ username, setUsername ] = useState(''); + const [ email, setEmail ] = useState(''); + const [ password, setPassword ] = useState(''); + const [ confirm, setConfirm ] = useState(''); + const [ localError, setLocalError ] = useState(null); + const [ turnstileToken, setTurnstileToken ] = useState(''); + const [ resetSignal, setResetSignal ] = useState(0); + + const resetWidget = useCallback(() => + { + setTurnstileToken(''); + setResetSignal(prev => prev + 1); + }, []); + + const handle = (event: FormEvent) => + { + event.preventDefault(); + setLocalError(null); + + if(!username.trim() || !email.trim() || !password) + { + setLocalError('Please fill in every field.'); + return; + } + + if(password.length < 8) + { + setLocalError('Your password must be at least 8 characters.'); + return; + } + + if(password !== confirm) + { + setLocalError('Passwords do not match.'); + return; + } + + onSubmit({ username: username.trim(), email: email.trim(), password, turnstileToken }, resetWidget); + }; + + return ( +
+
+
+
+ Create a Habbo + +
+
+
+ + setUsername(e.target.value) } /> +
+
+ + setEmail(e.target.value) } /> +
+
+ + setPassword(e.target.value) } /> +
+
+ + setConfirm(e.target.value) } /> +
+ { turnstileEnabled && + setTurnstileToken('') } + onError={ () => setTurnstileToken('') } + resetSignal={ resetSignal } + /> } + { (localError || error) &&
{ localError || error }
} + { info &&
{ info }
} +
+ +
+ +
+
+
+ ); +}; + +interface ForgotDialogProps extends DialogSharedProps +{ + onSubmit: (body: { email: string; turnstileToken: string; }, onDialogReset: () => void) => void; +} + +const ForgotDialog: FC = props => +{ + const { onCancel, onSubmit, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props; + const [ email, setEmail ] = useState(''); + const [ localError, setLocalError ] = useState(null); + const [ turnstileToken, setTurnstileToken ] = useState(''); + const [ resetSignal, setResetSignal ] = useState(0); + + const resetWidget = useCallback(() => + { + setTurnstileToken(''); + setResetSignal(prev => prev + 1); + }, []); + + const handle = (event: FormEvent) => + { + event.preventDefault(); + setLocalError(null); + + if(!email.trim()) + { + setLocalError('Please enter your email address.'); + return; + } + + onSubmit({ email: email.trim(), turnstileToken }, resetWidget); + }; + + return ( +
+
+
+
+ Reset password + +
+
+
+ + setEmail(e.target.value) } /> +
+ { turnstileEnabled && + setTurnstileToken('') } + onError={ () => setTurnstileToken('') } + resetSignal={ resetSignal } + /> } + { (localError || error) &&
{ localError || error }
} + { info &&
{ info }
} +
+ +
+ +
+
+
+ ); +}; diff --git a/src/components/login/TurnstileWidget.tsx b/src/components/login/TurnstileWidget.tsx new file mode 100644 index 0000000..95d1ffa --- /dev/null +++ b/src/components/login/TurnstileWidget.tsx @@ -0,0 +1,118 @@ +import { FC, useEffect, useRef } from 'react'; + +declare global +{ + interface Window + { + turnstile?: { + render: (container: string | HTMLElement, options: Record) => string; + reset: (widgetId?: string) => void; + remove: (widgetId?: string) => void; + }; + onTurnstileLoad?: () => void; + } +} + +const SCRIPT_ID = 'cf-turnstile-script'; +const SCRIPT_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; + +let scriptPromise: Promise | null = null; + +const loadTurnstileScript = (): Promise => +{ + if(typeof window === 'undefined') return Promise.resolve(); + if(window.turnstile) return Promise.resolve(); + if(scriptPromise) return scriptPromise; + + scriptPromise = new Promise((resolve, reject) => + { + const existing = document.getElementById(SCRIPT_ID) as HTMLScriptElement | null; + + if(existing) + { + existing.addEventListener('load', () => resolve()); + existing.addEventListener('error', () => reject(new Error('Turnstile failed to load'))); + return; + } + + const script = document.createElement('script'); + script.id = SCRIPT_ID; + script.src = SCRIPT_SRC; + script.async = true; + script.defer = true; + script.onload = () => resolve(); + script.onerror = () => reject(new Error('Turnstile failed to load')); + document.head.appendChild(script); + }); + + return scriptPromise; +}; + +export interface TurnstileWidgetProps +{ + siteKey: string; + theme?: 'light' | 'dark' | 'auto'; + size?: 'normal' | 'compact'; + onToken: (token: string) => void; + onExpire?: () => void; + onError?: () => void; + resetSignal?: number; +} + +export const TurnstileWidget: FC = props => +{ + const { siteKey, theme = 'light', size = 'normal', onToken, onExpire, onError, resetSignal = 0 } = props; + const containerRef = useRef(null); + const widgetIdRef = useRef(null); + + useEffect(() => + { + if(!siteKey || !containerRef.current) return; + + let cancelled = false; + + loadTurnstileScript() + .then(() => + { + if(cancelled || !window.turnstile || !containerRef.current) return; + + widgetIdRef.current = window.turnstile.render(containerRef.current, { + sitekey: siteKey, + theme, + size, + callback: (token: string) => onToken(token), + 'expired-callback': () => onExpire?.(), + 'error-callback': () => onError?.() + }); + }) + .catch(err => + { + console.error('[Turnstile] script load failed', err); + onError?.(); + }); + + return () => + { + cancelled = true; + + if(widgetIdRef.current && window.turnstile) + { + try { window.turnstile.remove(widgetIdRef.current); } catch { } + widgetIdRef.current = null; + } + }; + }, [ siteKey, theme, size ]); + + useEffect(() => + { + if(resetSignal <= 0) return; + if(widgetIdRef.current && window.turnstile) + { + try { window.turnstile.reset(widgetIdRef.current); } catch { } + } + }, [ resetSignal ]); + + if(!siteKey) return null; + + return
; +}; diff --git a/src/components/purse/PurseView.tsx b/src/components/purse/PurseView.tsx index 4f6bb8b..2b7bc27 100644 --- a/src/components/purse/PurseView.tsx +++ b/src/components/purse/PurseView.tsx @@ -1,6 +1,6 @@ import { CreateLinkEvent, HabboClubLevelEnum } from '@nitrots/nitro-renderer'; -import { FC, useEffect, useMemo, useState } from 'react'; -import { FaChevronDown, FaQuestionCircle } from 'react-icons/fa'; +import { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import { FaChevronDown, FaQuestionCircle, FaSignOutAlt } from 'react-icons/fa'; import { FriendlyTime, GetConfigurationValue, LocalizeText } from '../../api'; import { Column, Flex, LayoutCurrencyIcon, Text } from '../../common'; import { usePurse } from '../../hooks'; @@ -58,6 +58,33 @@ export const PurseView: FC<{}> = props => { return () => window.clearTimeout(timeout); }, [ isOpen ]); + const handleLogout = useCallback(async (event: React.MouseEvent) => + { + event.stopPropagation(); + + const logoutUrl = GetConfigurationValue('login.logout.endpoint', '/api/auth/logout'); + const ssoTicket = (window.NitroConfig?.['sso.ticket'] as string) ?? ''; + + try + { + await fetch(logoutUrl, { + method: 'POST', + credentials: 'include', + keepalive: true, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-Requested-With': 'NitroPurseLogout' + }, + body: JSON.stringify({ ssoTicket }) + }); + } + catch { /* best-effort — proceed with local logout regardless */ } + + if(window.NitroConfig) window.NitroConfig['sso.ticket'] = ''; + window.location.reload(); + }, []); + if (!purse) return null; return ( @@ -97,6 +124,9 @@ export const PurseView: FC<{}> = props => { +
{ seasonalCurrencies.length > 0 && diff --git a/src/css/login/LoginView.css b/src/css/login/LoginView.css new file mode 100644 index 0000000..c2f15d8 --- /dev/null +++ b/src/css/login/LoginView.css @@ -0,0 +1,261 @@ +/* ─── Classic Login View ───────────────────────────────────────────────── + Port of the old Nitro HotelView background layering, used exclusively by + the login screen. Assets are driven by ui-config.json: + loginview.images.background → .login-background + loginview.images.background.colour → .nitro-login-view base colour + loginview.images.sun → .login-sun + loginview.images.drape → .login-drape + loginview.images.left → .login-left + loginview.images.right → .login-right + loginview.images.right.repeat → .login-right-repeat + + Class names are deliberately prefixed so HotelView.css rules + (.left { left: 18vw !important } etc.) cannot clobber us. + --------------------------------------------------------------------- */ + +.nitro-login-view { + position: fixed; + inset: 0; + width: 100%; + height: 100%; + overflow: hidden; + background: #6eadc8; + z-index: 100; +} + +.nitro-login-view .login-layer { + position: absolute; + background-repeat: no-repeat; + pointer-events: none; +} + +.nitro-login-view .login-background { + top: 0; + left: 0; + width: 100%; + height: 100%; + background-repeat: repeat-x; + background-position: center top; +} + +.nitro-login-view .login-sun { + left: 50%; + transform: translateX(-50%); + width: 600px; + height: 600px; + background-size: contain; + background-position: center top; +} + +.nitro-login-view .login-drape { + top: 0; + left: 0; + width: 190px; + height: 220px; + z-index: 3; +} + +.nitro-login-view .login-left { + bottom: 0; + left: 0; + width: 100%; + height: 100%; + background-position: left bottom; + background-size: auto; + background-repeat: no-repeat; +} + +.nitro-login-view .login-right-repeat { + top: 0; + right: 0; + width: 400px; + height: 100%; + background-repeat: repeat-y; + background-position: right top; +} + +.nitro-login-view .login-right { + bottom: 0; + right: 0; + width: 400px; + height: 100%; + background-position: right bottom; +} + +/* ─── Foreground Login Card Stack ───────────────────────────────────── */ + +.nitro-login-view .login-stack { + position: absolute; + top: 50%; + right: 8vw; + transform: translateY(-50%); + display: flex; + flex-direction: column; + gap: 18px; + width: 260px; + z-index: 50; + pointer-events: auto; +} + +.nitro-login-card { + background: #a2bfd1; + border: 2px solid #3f6a85; + border-radius: 8px; + padding: 12px 14px; + color: #0a2e45; + font-family: Ubuntu, 'Helvetica Neue', Arial, sans-serif; + box-shadow: inset 0 2px rgba(255, 255, 255, 0.35), 0 4px 6px rgba(0, 0, 0, 0.25); +} + +.nitro-login-card .card-title { + position: relative; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + color: #ffffff; + background: #3f6a85; + padding: 4px 26px; + margin: -12px -14px 10px -14px; + border-radius: 6px 6px 0 0; + font-size: 13px; + letter-spacing: 0.5px; + text-shadow: 0 1px rgba(0, 0, 0, 0.35); +} + +.nitro-login-card .card-title .nitro-card-close-button { + position: absolute; + top: 50%; + right: 6px; + transform: translateY(-50%); + cursor: pointer; +} + +.nitro-login-card .card-body { + display: flex; + flex-direction: column; + gap: 8px; + font-size: 12px; +} + +.nitro-login-card .field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.nitro-login-card .field label { + font-size: 11px; + color: #0a2e45; + font-weight: 600; +} + +.nitro-login-card .field input { + width: 100%; + padding: 6px 8px; + border-radius: 20px; + border: 1px solid #7595ac; + background: #ffffff; + color: #0a2e45; + font-size: 12px; + outline: none; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); +} + +.nitro-login-card .field input:focus { + border-color: #3f6a85; + 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 .submit-row { + display: flex; + justify-content: center; + margin-top: 2px; +} + +.nitro-login-card button.ok-button { + cursor: pointer; + background: #ffffff; + border: 1px solid #3f6a85; + border-radius: 4px; + padding: 3px 16px; + font-size: 12px; + font-weight: 700; + color: #0a2e45; + box-shadow: inset 0 1px rgba(255, 255, 255, 0.8), 0 1px rgba(0, 0, 0, 0.15); +} + +.nitro-login-card button.ok-button:hover { + background: #e9f1f7; +} + +.nitro-login-card button.ok-button:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.nitro-login-card .forgot { + display: block; + text-align: center; + margin-top: 6px; + font-size: 11px; + color: #134b6e; + text-decoration: underline; + cursor: pointer; +} + +.nitro-login-card .error-line { + color: #a81a12; + background: #fde6e4; + border: 1px solid #e0a7a2; + border-radius: 4px; + padding: 4px 6px; + font-size: 11px; + text-align: center; +} + +.nitro-login-card .info-line { + color: #0a4d2e; + background: #e5f5ec; + border: 1px solid #a4d4b8; + border-radius: 4px; + padding: 4px 6px; + font-size: 11px; + text-align: center; +} + +.nitro-login-card .register-card-body a { + color: #134b6e; + text-decoration: underline; + cursor: pointer; + font-weight: 600; +} + +.nitro-login-card .turnstile-slot { + display: flex; + justify-content: center; + margin-top: 4px; + min-height: 65px; +} + +.nitro-login-card .turnstile-slot iframe { + max-width: 100%; +} + +/* Modal overlay used for register + forgot password dialogs */ + +.nitro-login-modal { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + display: flex; + align-items: center; + justify-content: center; + z-index: 200; +} + +.nitro-login-modal .dialog { + width: 320px; + max-width: calc(100% - 40px); +} + diff --git a/src/index.tsx b/src/index.tsx index 066ba96..4442259 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -14,6 +14,8 @@ import './css/forms/form_select.css'; import './css/hotelview/HotelView.css'; +import './css/login/LoginView.css'; + import './css/icons/icons.css'; diff --git a/vite.config.mjs b/vite.config.mjs index 9549310..423663a 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -18,7 +18,7 @@ export default defineConfig({ }, proxy: { '/api': { - target: 'http://localhost:3000', + target: process.env.AUTH_PROXY_TARGET || 'http://localhost:2096', changeOrigin: true, } } From 675b864c5116024832f3bbe04790d9b0e92f35d2 Mon Sep 17 00:00:00 2001 From: duckietm Date: Mon, 20 Apr 2026 15:19:51 +0200 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=86=99=20Small=20fix=20Login?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/renderer-config.json | 20 +++++------ src/components/login/LoginView.tsx | 57 ++++++++++++++++++++++++++---- 2 files changed, 60 insertions(+), 17 deletions(-) diff --git a/public/renderer-config.json b/public/renderer-config.json index 766d547..78dc9cb 100644 --- a/public/renderer-config.json +++ b/public/renderer-config.json @@ -1,10 +1,10 @@ { - "socket.url": "ws://192.168.1.52:2096", - "asset.url": "https://client.slogga.it/nitro/bundled", - "image.library.url": "https://client.slogga.it/c_images/", - "hof.furni.url": "https://client.slogga.it/c_images/dcr/hof_furni", - "images.url": "https://client.slogga.it/nitro/images", - "gamedata.url": "https://client.slogga.it/nitro/gamedata", + "socket.url": "ws://localhost:2096", + "asset.url": "https://localhost/nitro/bundled", + "image.library.url": "https://localhost/c_images/", + "hof.furni.url": "https://localhost/c_images/dcr/hof_furni", + "images.url": "https://localhost/nitro/images", + "gamedata.url": "https://localhost/nitro/gamedata", "sounds.url": "${asset.url}/sounds/%sample%.mp3", "external.texts.url": [ "${gamedata.url}/ExternalTexts.json", @@ -40,10 +40,10 @@ "room.landscapes.enabled": true, "room.zoom.enabled": true, "login.screen.enabled": false, - "login.endpoint": "https://websocket.yourdomain.com/api/auth/login", - "login.register.endpoint": "https://websocket.yourdomain.com/api/auth/register", - "login.forgot.endpoint": "https://websocket.yourdomain.com/api/auth/forgot-password", - "login.logout.endpoint": "https://websocket.yourdomain.com/api/auth/logout", + "login.endpoint": "${socket.url}/api/auth/login", + "login.register.endpoint": "${socket.url}/api/auth/register", + "login.forgot.endpoint": "${socket.url}/api/auth/forgot-password", + "login.logout.endpoint": "${socket.url}/api/auth/logout", "login.turnstile.enabled": false, "login.turnstile.sitekey": "", "avatar.mandatory.libraries": [ diff --git a/src/components/login/LoginView.tsx b/src/components/login/LoginView.tsx index f865f22..8116373 100644 --- a/src/components/login/LoginView.tsx +++ b/src/components/login/LoginView.tsx @@ -14,8 +14,8 @@ const interpolate = (value: string | null | undefined): string => const LOCK_KEY = 'nitro.login.lock'; const MAX_ATTEMPTS = 5; -const LOCK_WINDOW_MS = 60_000; -const LOCK_DURATION_MS = 2 * 60_000; +const LOCK_WINDOW_MS = 60_000; // rolling 60s window +const LOCK_DURATION_MS = 2 * 60_000; // 2 minute lockout type AttemptState = { attempts: number; firstAt: number; lockedUntil: number }; @@ -33,7 +33,7 @@ const readLock = (): AttemptState => const writeLock = (state: AttemptState) => { try { sessionStorage.setItem(LOCK_KEY, JSON.stringify(state)); } - catch { } + catch { /* ignore */ } }; export interface LoginViewProps @@ -63,6 +63,21 @@ export const LoginView: FC = ({ onAuthenticated }) => const rightRepeat = interpolate(loginImages['right.repeat'] || GetConfigurationValue('login_right.repeat', '')); const right = interpolate(loginImages['right'] || GetConfigurationValue('login_right', '')); + useEffect(() => + { + // eslint-disable-next-line no-console + console.info('[LoginView] resolved background assets', { + 'asset.url': GetConfigurationValue('asset.url', ''), + login_background: background, + 'login_background.colour': backgroundColor, + login_sun: sun, + login_drape: drape, + login_left: left, + login_right: right, + 'login_right.repeat': rightRepeat + }); + }, [ background, backgroundColor, sun, drape, left, right, rightRepeat ]); + const loginUrl = GetConfigurationValue('login.endpoint', '/api/auth/login'); const registerUrl = GetConfigurationValue('login.register.endpoint', '/api/auth/register'); const forgotUrl = GetConfigurationValue('login.forgot.endpoint', '/api/auth/forgot-password'); @@ -73,19 +88,40 @@ export const LoginView: FC = ({ onAuthenticated }) => || rawTurnstileEnabled === 1 || rawTurnstileEnabled === '1') && !!turnstileSiteKey; + useEffect(() => + { + // eslint-disable-next-line no-console + console.info('[LoginView] turnstile config', { + rawTurnstileEnabled, + turnstileEnabled, + turnstileSiteKey: turnstileSiteKey ? (turnstileSiteKey.slice(0, 6) + '…') : '(empty)' + }); + }, [ rawTurnstileEnabled, turnstileEnabled, turnstileSiteKey ]); + const resetLoginTurnstile = useCallback(() => { setLoginTurnstileToken(''); setLoginTurnstileResetSignal(prev => prev + 1); }, []); + // Clear error on mode change but keep the success notification so users + // returning to the login form can read it (e.g. "Account created"). + // Reset the login captcha only when we're actually on the login form. useEffect(() => { setError(null); - setInfo(null); if(mode === 'login') resetLoginTurnstile(); }, [ mode, resetLoginTurnstile ]); + // Auto-dismiss the info notification after a few seconds so it doesn't + // hang around forever once the user has seen it. + useEffect(() => + { + if(!info) return; + const timeout = window.setTimeout(() => setInfo(null), 8000); + return () => window.clearTimeout(timeout); + }, [ info ]); + const lockState = useMemo(() => readLock(), [ submitting ]); const now = Date.now(); const isLocked = lockState.lockedUntil > now; @@ -126,7 +162,7 @@ export const LoginView: FC = ({ onAuthenticated }) => let payload: Record = {}; try { payload = await response.json(); } - catch { } + catch { /* ignore non-json responses */ } return { ok: response.ok, status: response.status, payload }; }, []); @@ -198,6 +234,11 @@ export const LoginView: FC = ({ onAuthenticated }) => } }, [ submitting, username, password, turnstileEnabled, loginTurnstileToken, loginUrl, postJson, clearLock, recordFailure, onAuthenticated, resetLoginTurnstile ]); + // Register + forgot-password submit handlers receive the Turnstile token + // from the dialog (the dialog owns its own widget lifecycle), so the + // login widget underneath can't reset or overwrite it while the user + // is working on the modal. + const handleRegisterSubmit = useCallback(async (body: { username: string; email: string; password: string; turnstileToken: string; }, onDialogReset: () => void) => { if(turnstileEnabled && !body.turnstileToken) @@ -221,7 +262,8 @@ export const LoginView: FC = ({ onAuthenticated }) => if(ok) { - setInfo(typeof payload.message === 'string' ? payload.message : 'Account created. You can now log in.'); + const friendly = `Welcome aboard, ${ body.username }! Your account is ready — log in below with the password you just chose.`; + setInfo(typeof payload.message === 'string' ? payload.message : friendly); setMode('login'); setUsername(body.username); setPassword(''); @@ -263,7 +305,8 @@ export const LoginView: FC = ({ onAuthenticated }) => if(ok) { - setInfo(typeof payload.message === 'string' ? payload.message : 'If an account exists we just sent a reset link to your email.'); + const friendly = 'Email sent! If an account matches that address you\'ll find a reset link in your inbox shortly (check spam if it doesn\'t show up within a minute).'; + setInfo(typeof payload.message === 'string' ? payload.message : friendly); setMode('login'); return; } From 80033667b8f5099c64ea74070de59c85b1c739cc Mon Sep 17 00:00:00 2001 From: DuckieTM Date: Mon, 20 Apr 2026 21:54:17 +0200 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=86=99=20Added=20google=20ADS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/ads.txt | 1 + public/adsense.json | 5 + public/ui-config.example | 1 + src/components/MainView.tsx | 2 + src/components/ads/GoogleAdsView.tsx | 164 ++++++++++++++++++ .../alert-layouts/NitroSystemAlertView.tsx | 11 +- .../notification/NotificationCenterView.css | 15 +- 7 files changed, 194 insertions(+), 5 deletions(-) create mode 100644 public/ads.txt create mode 100644 public/adsense.json create mode 100644 src/components/ads/GoogleAdsView.tsx diff --git a/public/ads.txt b/public/ads.txt new file mode 100644 index 0000000..3f282eb --- /dev/null +++ b/public/ads.txt @@ -0,0 +1 @@ +google.com, ## YOUR pub-XXXXXXXXX, DIRECT, XXXXXXX diff --git a/public/adsense.json b/public/adsense.json new file mode 100644 index 0000000..8dcba1b --- /dev/null +++ b/public/adsense.json @@ -0,0 +1,5 @@ +{ + "slot": "### SLOT ID FROM GOOGLE - data-ad-slot ###", + "format": "auto", + "fullWidthResponsive": true +} diff --git a/public/ui-config.example b/public/ui-config.example index d33db29..946e5e0 100644 --- a/public/ui-config.example +++ b/public/ui-config.example @@ -27,6 +27,7 @@ "guides.enabled": true, "toolbar.hide.quests": true, "catalog.style.new": true, + "show.google.ads": false, "loginview": { "images": { "background": "${asset.url}/c_images/reception/stretch_blue.png", diff --git a/src/components/MainView.tsx b/src/components/MainView.tsx index f757aa7..5309364 100644 --- a/src/components/MainView.tsx +++ b/src/components/MainView.tsx @@ -24,6 +24,7 @@ import { NavigatorView } from './navigator/NavigatorView'; import { NitrobubbleHiddenView } from './nitrobubblehidden/NitrobubbleHiddenView'; import { NitropediaView } from './nitropedia/NitropediaView'; import { ExternalPluginLoader } from './plugins/ExternalPluginLoader'; +import { GoogleAdsView } from './ads/GoogleAdsView'; import { RightSideView } from './right-side/RightSideView'; import { RoomView } from './room/RoomView'; import { ToolbarView } from './toolbar/ToolbarView'; @@ -97,6 +98,7 @@ export const MainView: FC<{}> = props => } + diff --git a/src/components/ads/GoogleAdsView.tsx b/src/components/ads/GoogleAdsView.tsx new file mode 100644 index 0000000..b31574e --- /dev/null +++ b/src/components/ads/GoogleAdsView.tsx @@ -0,0 +1,164 @@ +import { FC, useEffect, useRef, useState } from 'react'; +import { GetConfigurationValue } from '../../api'; +import { NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common'; + +interface AdsenseConfig { + slot: string; + format?: string; + fullWidthResponsive?: boolean; +} + +const ADSENSE_SCRIPT_ID = 'google-adsense-script'; + +const parsePublisherIdFromAdsTxt = (text: string): string | null => { + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.split('#')[0].trim(); + if (!line) continue; + const parts = line.split(',').map(part => part.trim()); + if (parts.length < 2) continue; + if (parts[0].toLowerCase() !== 'google.com') continue; + const pub = parts[1]; + if (/^pub-\d+$/.test(pub)) return pub; + } + return null; +}; + +const ensureAdsenseScript = (publisherId: string): void => { + if (typeof document === 'undefined') return; + if (document.getElementById(ADSENSE_SCRIPT_ID)) return; + + const script = document.createElement('script'); + script.id = ADSENSE_SCRIPT_ID; + script.async = true; + script.crossOrigin = 'anonymous'; + script.src = `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-${ publisherId }`; + document.head.appendChild(script); +}; + +export const GoogleAdsView: FC<{}> = () => { + const adsEnabled = GetConfigurationValue('show.google.ads', false); + const [ isOpen, setIsOpen ] = useState(false); + const [ publisherId, setPublisherId ] = useState(null); + const [ config, setConfig ] = useState(null); + const [ loadError, setLoadError ] = useState(null); + const insRef = useRef(null); + const pushedRef = useRef(false); + const autoOpenedRef = useRef(false); + + useEffect(() => { + if (!adsEnabled) return; + const handler = () => setIsOpen(prev => !prev); + window.addEventListener('ads:toggle', handler); + return () => window.removeEventListener('ads:toggle', handler); + }, [ adsEnabled ]); + + // Auto-open once on initial mount (the login / landing stage). + // Subsequent toggles are driven by the "ads:toggle" window event + // (e.g. the Show Ad button in NitroSystemAlertView). + useEffect(() => { + if (!adsEnabled) return; + if (autoOpenedRef.current) return; + autoOpenedRef.current = true; + const t = setTimeout(() => setIsOpen(true), 500); + return () => clearTimeout(t); + }, [ adsEnabled ]); + + useEffect(() => { + let cancelled = false; + + (async () => { + try { + const [ adsTxtRes, configRes ] = await Promise.all([ + fetch('/ads.txt', { cache: 'no-cache' }), + fetch('/adsense.json', { cache: 'no-cache' }) + ]); + + if (!adsTxtRes.ok) throw new Error(`ads.txt ${ adsTxtRes.status }`); + + const adsTxt = await adsTxtRes.text(); + const pubId = parsePublisherIdFromAdsTxt(adsTxt); + + if (!pubId) throw new Error('No google.com publisher id in ads.txt'); + + let cfg: AdsenseConfig = { slot: '', format: 'auto', fullWidthResponsive: true }; + if (configRes.ok) cfg = { ...cfg, ...(await configRes.json()) }; + + if (cancelled) return; + setPublisherId(pubId); + setConfig(cfg); + } catch (err) { + if (!cancelled) setLoadError((err as Error).message); + } + })(); + + return () => { cancelled = true; }; + }, []); + + useEffect(() => { + if (!isOpen || !publisherId || !config) return; + ensureAdsenseScript(publisherId); + }, [ isOpen, publisherId, config ]); + + useEffect(() => { + if (!isOpen) { + pushedRef.current = false; + return; + } + if (!insRef.current || pushedRef.current) return; + if (!publisherId || !config?.slot) return; + + const tryPush = () => { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const w = window as any; + w.adsbygoogle = w.adsbygoogle || []; + w.adsbygoogle.push({}); + pushedRef.current = true; + } catch { + // AdSense script may not be ready yet; retry once + setTimeout(() => { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const w = window as any; + w.adsbygoogle = w.adsbygoogle || []; + w.adsbygoogle.push({}); + pushedRef.current = true; + } catch { /* give up */ } + }, 500); + } + }; + + const t = setTimeout(tryPush, 50); + return () => clearTimeout(t); + }, [ isOpen, publisherId, config ]); + + if (!adsEnabled) return null; + if (!isOpen) return null; + + return ( + + setIsOpen(false) } /> + +
+ { loadError && +
Ads unavailable: { loadError }
} + { !loadError && (!publisherId || !config) && +
Loading…
} + { !loadError && publisherId && config?.slot && + } + { !loadError && publisherId && config && !config.slot && +
Ad slot not configured in adsense.json
} +
+
+
+ ); +}; diff --git a/src/components/notification-center/views/alert-layouts/NitroSystemAlertView.tsx b/src/components/notification-center/views/alert-layouts/NitroSystemAlertView.tsx index 58db879..dd47863 100644 --- a/src/components/notification-center/views/alert-layouts/NitroSystemAlertView.tsx +++ b/src/components/notification-center/views/alert-layouts/NitroSystemAlertView.tsx @@ -1,5 +1,5 @@ import { FC } from 'react'; -import { GetRendererVersion, GetUIVersion, NotificationAlertItem } from '../../../../api'; +import { GetConfigurationValue, GetRendererVersion, GetUIVersion, NotificationAlertItem } from '../../../../api'; import { Button, Column, Grid, LayoutNotificationAlertView, LayoutNotificationAlertViewProps, Text } from '../../../../common'; interface NotificationDefaultAlertViewProps extends LayoutNotificationAlertViewProps @@ -9,10 +9,11 @@ interface NotificationDefaultAlertViewProps extends LayoutNotificationAlertViewP export const NitroSystemAlertView: FC = props => { - const { title = 'Nitro', onClose = null, ...rest } = props; + const { title = 'Nitro', onClose = null, classNames = [], ...rest } = props; + const adsEnabled = GetConfigurationValue('show.google.ads', false); return ( - + @@ -23,6 +24,8 @@ export const NitroSystemAlertView: FC = props Renderer: v{ GetRendererVersion() } + { adsEnabled && + }
@@ -35,7 +38,7 @@ export const NitroSystemAlertView: FC = props
- +
); diff --git a/src/css/notification/NotificationCenterView.css b/src/css/notification/NotificationCenterView.css index 45e3e4b..ec2c5e5 100644 --- a/src/css/notification/NotificationCenterView.css +++ b/src/css/notification/NotificationCenterView.css @@ -19,7 +19,7 @@ min-width: auto; } } - + &.nitro-alert-credits { width: 370px; .notification-text { @@ -34,6 +34,19 @@ min-width: 225px; } } + + &.nitro-alert-system { + width: auto; + min-width: 260px; + max-width: 90vw; + min-height: auto; + max-height: none; + height: auto; + + .notification-text { + min-width: auto; + } + } } .nitro-notification-bubble {