diff --git a/src/components/login/LoginView.tsx b/src/components/login/LoginView.tsx index 352014b..79a10f2 100644 --- a/src/components/login/LoginView.tsx +++ b/src/components/login/LoginView.tsx @@ -3,6 +3,12 @@ import { FC, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from import { GetConfigurationValue, LocalizeText } from '../../api'; import { TurnstileWidget } from './TurnstileWidget'; +/** + * Looks up a localized string. Falls back to `fallback` when the key is + * missing (LocalizeText returns the key itself) or when the localization + * manager isn't ready yet (login runs very early). Parameters are + * %name%-substituted into the fallback so the UI stays correct pre-init. + */ const t = (key: string, fallback: string, params?: string[], replacements?: string[]): string => { try @@ -10,7 +16,7 @@ const t = (key: string, fallback: string, params?: string[], replacements?: stri const value = LocalizeText(key, params ?? null, replacements ?? null); if(value && value !== key) return value; } - catch {} + catch { /* localization manager not initialised yet */ } if(!params || !replacements) return fallback; let out = fallback; @@ -23,6 +29,39 @@ const t = (key: string, fallback: string, params?: string[], replacements?: stri type DialogMode = 'login' | 'register' | 'forgot'; +interface BanInfo +{ + type: 'account' | 'ip' | 'machine' | 'super' | string; + reason: string; + permanent: boolean; + expiresAt?: number; +} + +const parseBan = (payload: Record): BanInfo | null => +{ + const raw = payload?.ban; + if(!raw || typeof raw !== 'object') return null; + const ban = raw as Record; + const type = typeof ban.type === 'string' ? ban.type : 'account'; + const reason = typeof ban.reason === 'string' ? ban.reason : ''; + const permanent = ban.permanent === true || ban.permanent === 'true'; + const expiresAt = typeof ban.expiresAt === 'number' ? ban.expiresAt : undefined; + return { type, reason, permanent, expiresAt }; +}; + +const formatRemaining = (epochSeconds: number): string => +{ + const totalSeconds = Math.max(0, epochSeconds - Math.floor(Date.now() / 1000)); + const days = Math.floor(totalSeconds / 86400); + const hours = Math.floor((totalSeconds % 86400) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + if(days > 0) return `${ days }d ${ hours }h ${ minutes }m`; + if(hours > 0) return `${ hours }h ${ minutes }m`; + if(minutes > 0) return `${ minutes }m ${ seconds }s`; + return `${ seconds }s`; +}; + const interpolate = (value: string | null | undefined): string => { if(!value) return ''; @@ -66,6 +105,8 @@ export const LoginView: FC = ({ onAuthenticated }) => const [ password, setPassword ] = useState(''); const [ rememberMe, setRememberMe ] = useState(false); const [ error, setError ] = useState(null); + const [ banInfo, setBanInfo ] = useState(null); + const [ , setBanTick ] = useState(0); const [ info, setInfo ] = useState(null); const [ submitting, setSubmitting ] = useState(false); const [ loginTurnstileToken, setLoginTurnstileToken ] = useState(''); @@ -103,9 +144,25 @@ export const LoginView: FC = ({ onAuthenticated }) => useEffect(() => { setError(null); + setBanInfo(null); if(mode === 'login') resetLoginTurnstile(); }, [ mode, resetLoginTurnstile ]); + useEffect(() => + { + if(!banInfo || banInfo.permanent || !banInfo.expiresAt) return; + const interval = window.setInterval(() => + { + if(banInfo.expiresAt && banInfo.expiresAt <= Math.floor(Date.now() / 1000)) + { + setBanInfo(null); + return; + } + setBanTick(t => t + 1); + }, 1000); + return () => window.clearInterval(interval); + }, [ banInfo ]); + useEffect(() => { if(!info) return; @@ -243,6 +300,7 @@ export const LoginView: FC = ({ onAuthenticated }) => } setError(null); + setBanInfo(null); setSubmitting(true); try @@ -277,6 +335,14 @@ export const LoginView: FC = ({ onAuthenticated }) => return; } + const ban = parseBan(payload); + if(ban) + { + setBanInfo(ban); + resetLoginTurnstile(); + return; + } + recordFailure(); const message = typeof payload.error === 'string' ? payload.error : t('nitro.login.error.invalid_credentials', 'Invalid Habbo name or password.'); setError(message); @@ -503,13 +569,29 @@ export const LoginView: FC = ({ onAuthenticated }) => } + { banInfo && +
+
+ { banInfo.type === 'ip' + ? t('nitro.login.error.banned.ip.title', 'This connection is banned') + : t('nitro.login.error.banned.account.title', 'Your account is banned') } +
+ { banInfo.permanent + ?
{ t('nitro.login.error.banned.permanent', 'This is a permanent ban.') }
+ : (banInfo.expiresAt + ?
{ t('nitro.login.error.banned.temporary', 'You can log in again in %time%.', [ 'time' ], [ formatRemaining(banInfo.expiresAt) ]) }
+ : null) } + { banInfo.reason && +
{ t('nitro.login.error.banned.reason', 'Reason: %reason%', [ 'reason' ], [ banInfo.reason ]) }
} +
+ } { error &&
{ error }
} { info &&
{ info }
}
setMode('forgot') }>{ t('login.forgot_password', 'Forgotten your password?') } diff --git a/src/css/login/LoginView.css b/src/css/login/LoginView.css index c88081c..e91cbad 100644 --- a/src/css/login/LoginView.css +++ b/src/css/login/LoginView.css @@ -225,6 +225,33 @@ text-align: center; } +.nitro-login-card .error-line.ban-message { + display: flex; + flex-direction: column; + gap: 3px; + padding: 8px 10px; + text-align: left; + line-height: 1.35; +} + +.nitro-login-card .error-line.ban-message .ban-title { + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.nitro-login-card .error-line.ban-message .ban-status { + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +.nitro-login-card .error-line.ban-message .ban-reason { + font-size: 11px; + font-style: italic; + word-break: break-word; +} + .nitro-login-card .register-card-body a { color: #134b6e; text-decoration: underline;