mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
🆕 UI Login page / no CMS required anymore
This commit is contained in:
@@ -39,6 +39,13 @@
|
|||||||
"room.color.skip.transition": true,
|
"room.color.skip.transition": true,
|
||||||
"room.landscapes.enabled": true,
|
"room.landscapes.enabled": true,
|
||||||
"room.zoom.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": [
|
"avatar.mandatory.libraries": [
|
||||||
"bd:1",
|
"bd:1",
|
||||||
"li:0"
|
"li:0"
|
||||||
|
|||||||
@@ -27,6 +27,17 @@
|
|||||||
"guides.enabled": true,
|
"guides.enabled": true,
|
||||||
"toolbar.hide.quests": true,
|
"toolbar.hide.quests": true,
|
||||||
"catalog.style.new": 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": [
|
"navigator.room.models": [
|
||||||
{
|
{
|
||||||
"clubLevel": 0,
|
"clubLevel": 0,
|
||||||
+46
-2
@@ -3,6 +3,7 @@ import { FC, useCallback, useEffect, useState } from 'react';
|
|||||||
import { GetUIVersion } from './api';
|
import { GetUIVersion } from './api';
|
||||||
import { Base } from './common';
|
import { Base } from './common';
|
||||||
import { LoadingView } from './components/loading/LoadingView';
|
import { LoadingView } from './components/loading/LoadingView';
|
||||||
|
import { LoginView } from './components/login/LoginView';
|
||||||
import { MainView } from './components/MainView';
|
import { MainView } from './components/MainView';
|
||||||
import { ReconnectView } from './components/reconnect/ReconnectView';
|
import { ReconnectView } from './components/reconnect/ReconnectView';
|
||||||
import { useMessageEvent, useNitroEvent } from './hooks';
|
import { useMessageEvent, useNitroEvent } from './hooks';
|
||||||
@@ -14,12 +15,24 @@ export const App: FC<{}> = props =>
|
|||||||
const [ isReady, setIsReady ] = useState(false);
|
const [ isReady, setIsReady ] = useState(false);
|
||||||
const [ errorMessage, setErrorMessage ] = useState('');
|
const [ errorMessage, setErrorMessage ] = useState('');
|
||||||
const [ homeUrl, setHomeUrl ] = useState('');
|
const [ homeUrl, setHomeUrl ] = useState('');
|
||||||
|
const [ showLogin, setShowLogin ] = useState(false);
|
||||||
|
const [ prepareTrigger, setPrepareTrigger ] = useState(0);
|
||||||
const showSessionExpired = useCallback(() =>
|
const showSessionExpired = useCallback(() =>
|
||||||
{
|
{
|
||||||
const baseUrl = window.location.origin + '/';
|
const baseUrl = window.location.origin + '/';
|
||||||
setHomeUrl(baseUrl);
|
setHomeUrl(baseUrl);
|
||||||
setErrorMessage('Your session has expired.\nPlease log in again to enter the hotel.');
|
setErrorMessage('Your session has expired.\nPlease log in again to enter the hotel.');
|
||||||
setIsReady(false);
|
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)
|
// Listen for socket closed events (code 1000 "Bye" - server rejected SSO)
|
||||||
@@ -48,6 +61,36 @@ export const App: FC<{}> = props =>
|
|||||||
|
|
||||||
if(!ssoTicket || ssoTicket === '')
|
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<unknown>('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();
|
showSessionExpired();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -120,12 +163,13 @@ export const App: FC<{}> = props =>
|
|||||||
{
|
{
|
||||||
if(heartbeatInterval !== null) window.clearInterval(heartbeatInterval);
|
if(heartbeatInterval !== null) window.clearInterval(heartbeatInterval);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [ prepareTrigger ]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Base fit overflow="hidden" className={ !(window.devicePixelRatio % 1) && 'image-rendering-pixelated' }>
|
<Base fit overflow="hidden" className={ !(window.devicePixelRatio % 1) && 'image-rendering-pixelated' }>
|
||||||
{ !isReady &&
|
{ !isReady && !showLogin &&
|
||||||
<LoadingView isError={ errorMessage.length > 0 } message={ errorMessage } homeUrl={ homeUrl } /> }
|
<LoadingView isError={ errorMessage.length > 0 } message={ errorMessage } homeUrl={ homeUrl } /> }
|
||||||
|
{ !isReady && showLogin && <LoginView onAuthenticated={ handleAuthenticated } /> }
|
||||||
{ isReady && <MainView /> }
|
{ isReady && <MainView /> }
|
||||||
<ReconnectView />
|
<ReconnectView />
|
||||||
<Base id="draggable-windows-container" />
|
<Base id="draggable-windows-container" />
|
||||||
|
|||||||
@@ -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<LoginViewProps> = ({ onAuthenticated }) =>
|
||||||
|
{
|
||||||
|
const [ mode, setMode ] = useState<DialogMode>('login');
|
||||||
|
const [ username, setUsername ] = useState('');
|
||||||
|
const [ password, setPassword ] = useState('');
|
||||||
|
const [ error, setError ] = useState<string | null>(null);
|
||||||
|
const [ info, setInfo ] = useState<string | null>(null);
|
||||||
|
const [ submitting, setSubmitting ] = useState(false);
|
||||||
|
const [ loginTurnstileToken, setLoginTurnstileToken ] = useState('');
|
||||||
|
const [ loginTurnstileResetSignal, setLoginTurnstileResetSignal ] = useState(0);
|
||||||
|
const submitTimeRef = useRef(0);
|
||||||
|
|
||||||
|
const loginImages: Record<string, string> = ((GetConfigurationValue<Record<string, unknown>>('loginview', {})?.['images']) as Record<string, string>) ?? {};
|
||||||
|
|
||||||
|
const backgroundColor = (loginImages['background.colour'] || GetConfigurationValue<string>('login_background.colour', '#6eadc8'));
|
||||||
|
const background = interpolate(loginImages['background'] || GetConfigurationValue<string>('login_background', ''));
|
||||||
|
const sun = interpolate(loginImages['sun'] || GetConfigurationValue<string>('login_sun', ''));
|
||||||
|
const drape = interpolate(loginImages['drape'] || GetConfigurationValue<string>('login_drape', ''));
|
||||||
|
const left = interpolate(loginImages['left'] || GetConfigurationValue<string>('login_left', ''));
|
||||||
|
const rightRepeat = interpolate(loginImages['right.repeat'] || GetConfigurationValue<string>('login_right.repeat', ''));
|
||||||
|
const right = interpolate(loginImages['right'] || GetConfigurationValue<string>('login_right', ''));
|
||||||
|
|
||||||
|
const loginUrl = GetConfigurationValue<string>('login.endpoint', '/api/auth/login');
|
||||||
|
const registerUrl = GetConfigurationValue<string>('login.register.endpoint', '/api/auth/register');
|
||||||
|
const forgotUrl = GetConfigurationValue<string>('login.forgot.endpoint', '/api/auth/forgot-password');
|
||||||
|
const turnstileSiteKey = GetConfigurationValue<string>('login.turnstile.sitekey', '');
|
||||||
|
const rawTurnstileEnabled = GetConfigurationValue<unknown>('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<string, unknown>) =>
|
||||||
|
{
|
||||||
|
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<string, unknown> = {};
|
||||||
|
try { payload = await response.json(); }
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
return { ok: response.ok, status: response.status, payload };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLoginSubmit = useCallback(async (event: FormEvent<HTMLFormElement>) =>
|
||||||
|
{
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className="nitro-login-view"
|
||||||
|
style={ backgroundColor ? { background: backgroundColor } : undefined }
|
||||||
|
>
|
||||||
|
{ background ? <div className="login-background login-layer" style={ { backgroundImage: `url(${ background })` } } /> : null }
|
||||||
|
{ sun ? <div className="login-sun login-layer" style={ { backgroundImage: `url(${ sun })` } } /> : null }
|
||||||
|
{ drape ? <div className="login-drape login-layer" style={ { backgroundImage: `url(${ drape })` } } /> : null }
|
||||||
|
{ left ? <div className="login-left login-layer" style={ { backgroundImage: `url(${ left })` } } /> : null }
|
||||||
|
{ rightRepeat ? <div className="login-right-repeat login-layer" style={ { backgroundImage: `url(${ rightRepeat })` } } /> : null }
|
||||||
|
{ right ? <div className="login-right login-layer" style={ { backgroundImage: `url(${ right })` } } /> : null }
|
||||||
|
|
||||||
|
<div className="login-stack">
|
||||||
|
<div className="nitro-login-card">
|
||||||
|
<div className="card-title">First time here?</div>
|
||||||
|
<div className="card-body register-card-body">
|
||||||
|
<span>Don't have a Habbo yet?</span>
|
||||||
|
<a onClick={ () => setMode('register') }>You can create one here</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="nitro-login-card">
|
||||||
|
<div className="card-title">What's your Habbo called?</div>
|
||||||
|
<form className="card-body" onSubmit={ handleLoginSubmit } autoComplete="on">
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="login-username">Name of your Habbo</label>
|
||||||
|
<input
|
||||||
|
id="login-username"
|
||||||
|
name="username"
|
||||||
|
autoComplete="username"
|
||||||
|
type="text"
|
||||||
|
maxLength={ 32 }
|
||||||
|
value={ username }
|
||||||
|
onChange={ e => setUsername(e.target.value) }
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="login-password">Password</label>
|
||||||
|
<input
|
||||||
|
id="login-password"
|
||||||
|
name="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
type="password"
|
||||||
|
maxLength={ 128 }
|
||||||
|
value={ password }
|
||||||
|
onChange={ e => setPassword(e.target.value) }
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{ turnstileEnabled && mode === 'login' &&
|
||||||
|
<TurnstileWidget
|
||||||
|
siteKey={ turnstileSiteKey }
|
||||||
|
size="compact"
|
||||||
|
onToken={ setLoginTurnstileToken }
|
||||||
|
onExpire={ () => setLoginTurnstileToken('') }
|
||||||
|
onError={ () => setLoginTurnstileToken('') }
|
||||||
|
resetSignal={ loginTurnstileResetSignal }
|
||||||
|
/> }
|
||||||
|
{ error && <div className="error-line">{ error }</div> }
|
||||||
|
{ info && <div className="info-line">{ info }</div> }
|
||||||
|
<div className="submit-row">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="ok-button"
|
||||||
|
disabled={ submitting || isLocked }
|
||||||
|
>OK</button>
|
||||||
|
</div>
|
||||||
|
<a className="forgot" onClick={ () => setMode('forgot') }>Forgotten your password?</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ mode === 'register' &&
|
||||||
|
<RegisterDialog
|
||||||
|
onCancel={ () => setMode('login') }
|
||||||
|
onSubmit={ handleRegisterSubmit }
|
||||||
|
submitting={ submitting }
|
||||||
|
error={ error }
|
||||||
|
info={ info }
|
||||||
|
turnstileEnabled={ turnstileEnabled }
|
||||||
|
turnstileSiteKey={ turnstileSiteKey }
|
||||||
|
/> }
|
||||||
|
|
||||||
|
{ mode === 'forgot' &&
|
||||||
|
<ForgotDialog
|
||||||
|
onCancel={ () => setMode('login') }
|
||||||
|
onSubmit={ handleForgotSubmit }
|
||||||
|
submitting={ submitting }
|
||||||
|
error={ error }
|
||||||
|
info={ info }
|
||||||
|
turnstileEnabled={ turnstileEnabled }
|
||||||
|
turnstileSiteKey={ turnstileSiteKey }
|
||||||
|
/> }
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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<RegisterDialogProps> = 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<string | null>(null);
|
||||||
|
const [ turnstileToken, setTurnstileToken ] = useState('');
|
||||||
|
const [ resetSignal, setResetSignal ] = useState(0);
|
||||||
|
|
||||||
|
const resetWidget = useCallback(() =>
|
||||||
|
{
|
||||||
|
setTurnstileToken('');
|
||||||
|
setResetSignal(prev => prev + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handle = (event: FormEvent<HTMLFormElement>) =>
|
||||||
|
{
|
||||||
|
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 (
|
||||||
|
<div className="nitro-login-modal">
|
||||||
|
<div className="dialog">
|
||||||
|
<div className="nitro-login-card">
|
||||||
|
<div className="card-title">
|
||||||
|
<span>Create a Habbo</span>
|
||||||
|
<span className="nitro-card-close-button" role="button" aria-label="Close" onClick={ onCancel } />
|
||||||
|
</div>
|
||||||
|
<form className="card-body" onSubmit={ handle } autoComplete="on">
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="register-username">Habbo name</label>
|
||||||
|
<input id="register-username" type="text" maxLength={ 32 } autoComplete="username"
|
||||||
|
value={ username } onChange={ e => setUsername(e.target.value) } />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="register-email">Email</label>
|
||||||
|
<input id="register-email" type="email" maxLength={ 120 } autoComplete="email"
|
||||||
|
value={ email } onChange={ e => setEmail(e.target.value) } />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="register-password">Password</label>
|
||||||
|
<input id="register-password" type="password" maxLength={ 128 } autoComplete="new-password"
|
||||||
|
value={ password } onChange={ e => setPassword(e.target.value) } />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="register-confirm">Confirm password</label>
|
||||||
|
<input id="register-confirm" type="password" maxLength={ 128 } autoComplete="new-password"
|
||||||
|
value={ confirm } onChange={ e => setConfirm(e.target.value) } />
|
||||||
|
</div>
|
||||||
|
{ turnstileEnabled &&
|
||||||
|
<TurnstileWidget
|
||||||
|
siteKey={ turnstileSiteKey }
|
||||||
|
size="compact"
|
||||||
|
onToken={ setTurnstileToken }
|
||||||
|
onExpire={ () => setTurnstileToken('') }
|
||||||
|
onError={ () => setTurnstileToken('') }
|
||||||
|
resetSignal={ resetSignal }
|
||||||
|
/> }
|
||||||
|
{ (localError || error) && <div className="error-line">{ localError || error }</div> }
|
||||||
|
{ info && <div className="info-line">{ info }</div> }
|
||||||
|
<div className="submit-row">
|
||||||
|
<button type="submit" className="ok-button" disabled={ submitting }>Create account</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ForgotDialogProps extends DialogSharedProps
|
||||||
|
{
|
||||||
|
onSubmit: (body: { email: string; turnstileToken: string; }, onDialogReset: () => void) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ForgotDialog: FC<ForgotDialogProps> = props =>
|
||||||
|
{
|
||||||
|
const { onCancel, onSubmit, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props;
|
||||||
|
const [ email, setEmail ] = useState('');
|
||||||
|
const [ localError, setLocalError ] = useState<string | null>(null);
|
||||||
|
const [ turnstileToken, setTurnstileToken ] = useState('');
|
||||||
|
const [ resetSignal, setResetSignal ] = useState(0);
|
||||||
|
|
||||||
|
const resetWidget = useCallback(() =>
|
||||||
|
{
|
||||||
|
setTurnstileToken('');
|
||||||
|
setResetSignal(prev => prev + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handle = (event: FormEvent<HTMLFormElement>) =>
|
||||||
|
{
|
||||||
|
event.preventDefault();
|
||||||
|
setLocalError(null);
|
||||||
|
|
||||||
|
if(!email.trim())
|
||||||
|
{
|
||||||
|
setLocalError('Please enter your email address.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit({ email: email.trim(), turnstileToken }, resetWidget);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="nitro-login-modal">
|
||||||
|
<div className="dialog">
|
||||||
|
<div className="nitro-login-card">
|
||||||
|
<div className="card-title">
|
||||||
|
<span>Reset password</span>
|
||||||
|
<span className="nitro-card-close-button" role="button" aria-label="Close" onClick={ onCancel } />
|
||||||
|
</div>
|
||||||
|
<form className="card-body" onSubmit={ handle } autoComplete="on">
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="forgot-email">Email address</label>
|
||||||
|
<input id="forgot-email" type="email" maxLength={ 120 } autoComplete="email"
|
||||||
|
value={ email } onChange={ e => setEmail(e.target.value) } />
|
||||||
|
</div>
|
||||||
|
{ turnstileEnabled &&
|
||||||
|
<TurnstileWidget
|
||||||
|
siteKey={ turnstileSiteKey }
|
||||||
|
size="compact"
|
||||||
|
onToken={ setTurnstileToken }
|
||||||
|
onExpire={ () => setTurnstileToken('') }
|
||||||
|
onError={ () => setTurnstileToken('') }
|
||||||
|
resetSignal={ resetSignal }
|
||||||
|
/> }
|
||||||
|
{ (localError || error) && <div className="error-line">{ localError || error }</div> }
|
||||||
|
{ info && <div className="info-line">{ info }</div> }
|
||||||
|
<div className="submit-row">
|
||||||
|
<button type="submit" className="ok-button" disabled={ submitting }>Send email</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { FC, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
declare global
|
||||||
|
{
|
||||||
|
interface Window
|
||||||
|
{
|
||||||
|
turnstile?: {
|
||||||
|
render: (container: string | HTMLElement, options: Record<string, unknown>) => 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<void> | null = null;
|
||||||
|
|
||||||
|
const loadTurnstileScript = (): Promise<void> =>
|
||||||
|
{
|
||||||
|
if(typeof window === 'undefined') return Promise.resolve();
|
||||||
|
if(window.turnstile) return Promise.resolve();
|
||||||
|
if(scriptPromise) return scriptPromise;
|
||||||
|
|
||||||
|
scriptPromise = new Promise<void>((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<TurnstileWidgetProps> = props =>
|
||||||
|
{
|
||||||
|
const { siteKey, theme = 'light', size = 'normal', onToken, onExpire, onError, resetSignal = 0 } = props;
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const widgetIdRef = useRef<string | null>(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 <div ref={ containerRef } className="turnstile-slot" />;
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { CreateLinkEvent, HabboClubLevelEnum } from '@nitrots/nitro-renderer';
|
import { CreateLinkEvent, HabboClubLevelEnum } from '@nitrots/nitro-renderer';
|
||||||
import { FC, useEffect, useMemo, useState } from 'react';
|
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { FaChevronDown, FaQuestionCircle } from 'react-icons/fa';
|
import { FaChevronDown, FaQuestionCircle, FaSignOutAlt } from 'react-icons/fa';
|
||||||
import { FriendlyTime, GetConfigurationValue, LocalizeText } from '../../api';
|
import { FriendlyTime, GetConfigurationValue, LocalizeText } from '../../api';
|
||||||
import { Column, Flex, LayoutCurrencyIcon, Text } from '../../common';
|
import { Column, Flex, LayoutCurrencyIcon, Text } from '../../common';
|
||||||
import { usePurse } from '../../hooks';
|
import { usePurse } from '../../hooks';
|
||||||
@@ -58,6 +58,33 @@ export const PurseView: FC<{}> = props => {
|
|||||||
return () => window.clearTimeout(timeout);
|
return () => window.clearTimeout(timeout);
|
||||||
}, [ isOpen ]);
|
}, [ isOpen ]);
|
||||||
|
|
||||||
|
const handleLogout = useCallback(async (event: React.MouseEvent) =>
|
||||||
|
{
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
const logoutUrl = GetConfigurationValue<string>('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;
|
if (!purse) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -97,6 +124,9 @@ export const PurseView: FC<{}> = props => {
|
|||||||
<button type="button" className="nitro-purse__action-button nitro-purse__action-button--settings" onClick={ event => { event.stopPropagation(); CreateLinkEvent('user-settings/toggle'); } } title={ LocalizeText('widget.memenu.settings.title') }>
|
<button type="button" className="nitro-purse__action-button nitro-purse__action-button--settings" onClick={ event => { event.stopPropagation(); CreateLinkEvent('user-settings/toggle'); } } title={ LocalizeText('widget.memenu.settings.title') }>
|
||||||
<i className="nitro-icon icon-cog" />
|
<i className="nitro-icon icon-cog" />
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" className="nitro-purse__action-button nitro-purse__action-button--logout" onClick={ handleLogout } title="Log out">
|
||||||
|
<FaSignOutAlt />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{ seasonalCurrencies.length > 0 &&
|
{ seasonalCurrencies.length > 0 &&
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -14,6 +14,8 @@ import './css/forms/form_select.css';
|
|||||||
|
|
||||||
import './css/hotelview/HotelView.css';
|
import './css/hotelview/HotelView.css';
|
||||||
|
|
||||||
|
import './css/login/LoginView.css';
|
||||||
|
|
||||||
import './css/icons/icons.css';
|
import './css/icons/icons.css';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:3000',
|
target: process.env.AUTH_PROXY_TARGET || 'http://localhost:2096',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user