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, } }