From 46eb7b45fcaeb550e5f295640307810e1b4a39cf Mon Sep 17 00:00:00 2001 From: duckietm Date: Fri, 1 May 2026 07:47:12 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=86=99=20Code=20cleanup=20News=20UI=20Log?= =?UTF-8?q?in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/renderer-config.example | 1 + src/components/login/LoginView.tsx | 1049 +---------------- .../login/components/ForgotDialog.tsx | 72 ++ .../login/components/NewsWindow.tsx | 122 ++ .../login/components/RegisterDialog.tsx | 633 ++++++++++ src/components/login/components/shared.ts | 9 + src/components/login/utils/ban.ts | 32 + src/components/login/utils/figure.ts | 106 ++ src/components/login/utils/i18n.ts | 27 + src/components/login/utils/lockState.ts | 23 + src/components/login/utils/news.ts | 46 + 11 files changed, 1079 insertions(+), 1041 deletions(-) create mode 100644 src/components/login/components/ForgotDialog.tsx create mode 100644 src/components/login/components/NewsWindow.tsx create mode 100644 src/components/login/components/RegisterDialog.tsx create mode 100644 src/components/login/components/shared.ts create mode 100644 src/components/login/utils/ban.ts create mode 100644 src/components/login/utils/figure.ts create mode 100644 src/components/login/utils/i18n.ts create mode 100644 src/components/login/utils/lockState.ts create mode 100644 src/components/login/utils/news.ts diff --git a/public/renderer-config.example b/public/renderer-config.example index 5fb8068..b45973e 100644 --- a/public/renderer-config.example +++ b/public/renderer-config.example @@ -54,6 +54,7 @@ "login.room_templates.endpoint": "${api.url}/api/auth/room-templates", "login.remember.endpoint": "${api.url}/api/auth/remember", "login.server_key.endpoint": "${api.url}/api/auth/server-key", + "login.news.endpoint": "${api.url}/api/auth/news", "login.turnstile.enabled": true, "login.turnstile.sitekey": "", "avatar.mandatory.libraries": [ diff --git a/src/components/login/LoginView.tsx b/src/components/login/LoginView.tsx index db5c9ce..e4a8f61 100644 --- a/src/components/login/LoginView.tsx +++ b/src/components/login/LoginView.tsx @@ -1,92 +1,15 @@ -import { GetConfiguration } from '@nitrots/nitro-renderer'; import { FC, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { GetConfigurationValue, LocalizeText } from '../../api'; +import { GetConfigurationValue } from '../../api'; +import { ForgotDialog } from './components/ForgotDialog'; +import { NewsWindow } from './components/NewsWindow'; +import { RegisterDialog } from './components/RegisterDialog'; import { TurnstileWidget } from './TurnstileWidget'; - -const t = (key: string, fallback: string, params?: string[], replacements?: string[]): string => -{ - try - { - const value = LocalizeText(key, params ?? null, replacements ?? null); - if(value && value !== key) return value; - } - catch {} - - if(!params || !replacements) return fallback; - let out = fallback; - for(let i = 0; i < params.length; i++) - { - if(replacements[i] !== undefined) out = out.replace('%' + params[i] + '%', replacements[i]); - } - return out; -}; +import { BanInfo, formatRemaining, parseBan } from './utils/ban'; +import { interpolate, t } from './utils/i18n'; +import { LOCK_DURATION_MS, LOCK_WINDOW_MS, MAX_ATTEMPTS, readLock, writeLock } from './utils/lockState'; 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 ''; - 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; @@ -359,6 +282,7 @@ export const LoginView: FC = ({ onAuthenticated }) => const checkEmailUrl = GetConfigurationValue('login.check-email.endpoint', '/api/auth/check-email'); const checkUsernameUrl = GetConfigurationValue('login.check-username.endpoint', '/api/auth/check-username'); const imagingUrl = GetConfigurationValue('login.register.imaging.url', 'https://www.habbo.com/habbo-imaging/avatarimage?figure={figure}&gender={gender}&direction=2&head_direction=2&size=l'); + const interpretAvailability = (ok: boolean, status: number, payload: Record): { available: boolean; error?: string } => { const isTrue = (v: unknown) => v === true || v === 'true' || v === 1 || v === '1'; @@ -626,960 +550,3 @@ export const LoginView: FC = ({ onAuthenticated }) => ); }; - -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; figure: string; gender: string; turnstileToken: string; templateId: number | null; }, onDialogReset: () => void) => void; - onCheckEmail: (email: string) => Promise<{ available: boolean; error?: string }>; - onCheckUsername: (username: string) => Promise<{ available: boolean; error?: string }>; - onCheckServer: () => Promise; - imagingUrl: string; - roomTemplatesUrl: string; -} - -type RegisterStep = 'credentials' | 'avatar' | 'room'; - -interface RoomTemplate { templateId: number; title: string; description: string; thumbnail: string; } - -const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - -type GenderKey = 'M' | 'F'; - -const PART_ROWS: string[] = [ 'hr', 'hd', 'ch', 'lg', 'sh' ]; - -const FALLBACK_DEFAULTS: Record> = { - M: { - hr: { partId: 180, colors: [ 45 ] }, - hd: { partId: 180, colors: [ 1 ] }, - ch: { partId: 215, colors: [ 66 ] }, - lg: { partId: 270, colors: [ 82 ] }, - sh: { partId: 290, colors: [ 80 ] } - }, - F: { - hr: { partId: 515, colors: [ 45 ] }, - hd: { partId: 600, colors: [ 1 ] }, - ch: { partId: 660, colors: [ 100 ] }, - lg: { partId: 716, colors: [ 82 ] }, - sh: { partId: 725, colors: [ 61 ] } - } -}; - -const FALLBACK_HEX: Record = { - 1: '#ffcb98', 8: '#f4ac54', 14: '#f5da88', 19: '#b87560', 20: '#9c543f', - 45: '#e8c498', 61: '#f1ece3', 66: '#96743d', 80: '#4f4d4d', 82: '#7f4f30', - 92: '#ececec', 100: '#c7ddff', 106: '#c6e6bd', 110: '#91a7c8', 143: '#ffffff' -}; - -interface FigureColor { id: number; hexCode: string; club: number; selectable: boolean; } -interface FigurePalette { id: number; colors: FigureColor[]; } -interface FigureSet { id: number; gender: 'M' | 'F' | 'U'; club: number; selectable: boolean; } -interface FigureSetType { type: string; paletteId: number; sets: FigureSet[]; } -interface FigureData { palettes: FigurePalette[]; setTypes: FigureSetType[]; } - -interface PartSelection { partId: number; colors: number[]; } -type FigureSelection = Record; - -const buildFigureString = (selection: FigureSelection): string => -{ - const seen = new Set(); - const parts: string[] = []; - const push = (setType: string) => - { - if(seen.has(setType)) return; - seen.add(setType); - const sel = selection[setType]; - if(!sel || sel.partId < 0) return; - const tail = (sel.colors && sel.colors.length) ? `-${ sel.colors.join('-') }` : ''; - parts.push(`${ setType }-${ sel.partId }${ tail }`); - }; - for(const setType of PART_ROWS) push(setType); - for(const setType of Object.keys(selection)) push(setType); - return parts.join('.'); -}; - -const buildImagingUrl = (template: string, figure: string, gender: GenderKey): string => - template - .replace(/\{figure\}/g, encodeURIComponent(figure)) - .replace(/\{gender\}/g, gender) - .replace(/\{direction\}/g, '2'); - -const HEAD_ONLY_PARTS = new Set([ 'hr', 'hd' ]); - -const buildPartPreviewUrl = ( - template: string, - setType: string, - selection: FigureSelection, - gender: GenderKey -): string => -{ - const defaults = FALLBACK_DEFAULTS[gender]; - const partSel = selection[setType] ?? defaults[setType]; - const tail = (partSel.colors && partSel.colors.length) ? `-${ partSel.colors.join('-') }` : ''; - const isHeadOnly = HEAD_ONLY_PARTS.has(setType); - - let parts: string[]; - if(isHeadOnly) - { - const hd = defaults.hd; - const pieces = new Map(); - pieces.set('hd', `hd-${ hd.partId }-${ hd.colors.join('-') }`); - pieces.set(setType, `${ setType }-${ partSel.partId }${ tail }`); - parts = Array.from(pieces.values()); - } - else - { - const hd = defaults.hd; - parts = [ - `hd-${ hd.partId }-${ hd.colors.join('-') }`, - `${ setType }-${ partSel.partId }${ tail }` - ]; - } - - const figure = parts.join('.'); - let url = template - .replace(/\{figure\}/g, encodeURIComponent(figure)) - .replace(/\{gender\}/g, gender) - .replace(/\{direction\}/g, '2'); - - url = url.replace(/size=l/, 'size=s').replace(/size=m/, 'size=s'); - if(!/size=/.test(url)) url += (url.includes('?') ? '&' : '?') + 'size=s'; - if(isHeadOnly && !/headonly=/.test(url)) url += '&headonly=1'; - - return url; -}; - -const RegisterDialog: FC = props => -{ - const { onCancel, onSubmit, onCheckEmail, onCheckUsername, onCheckServer, imagingUrl, roomTemplatesUrl, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props; - - const [ step, setStep ] = useState('credentials'); - const [ email, setEmail ] = useState(''); - const [ password, setPassword ] = useState(''); - const [ confirm, setConfirm ] = useState(''); - const [ username, setUsername ] = useState(''); - const [ gender, setGender ] = useState('F'); - const [ selection, setSelection ] = useState(() => ({ ...FALLBACK_DEFAULTS.F })); - const [ localError, setLocalError ] = useState(null); - const [ checking, setChecking ] = useState(false); - const [ turnstileToken, setTurnstileToken ] = useState(''); - const [ resetSignal, setResetSignal ] = useState(0); - const [ serverReachable, setServerReachable ] = useState(null); - const [ pingingServer, setPingingServer ] = useState(false); - - const pingServer = useCallback(async () => - { - setPingingServer(true); - try - { - const ok = await onCheckServer(); - setServerReachable(ok); - return ok; - } - finally - { - setPingingServer(false); - } - }, [ onCheckServer ]); - - useEffect(() => - { - let cancelled = false; - (async () => - { - const ok = await onCheckServer(); - if(!cancelled) setServerReachable(ok); - })(); - return () => { cancelled = true; }; - }, [ onCheckServer ]); - - const resetWidget = useCallback(() => - { - setTurnstileToken(''); - setResetSignal(prev => prev + 1); - }, []); - - useEffect(() => { setLocalError(null); }, [ step ]); - - const [ roomTemplates, setRoomTemplates ] = useState(null); - const [ roomTemplatesError, setRoomTemplatesError ] = useState(null); - const [ selectedTemplateId, setSelectedTemplateId ] = useState(null); - - const [ figureData, setFigureData ] = useState(null); - const figureDataUrlRaw = GetConfigurationValue('avatar.figuredata.url', ''); - const figureDataUrl = useMemo(() => - { - if(!figureDataUrlRaw) return ''; - try { return GetConfiguration().interpolate(figureDataUrlRaw); } - catch { return figureDataUrlRaw; } - }, [ figureDataUrlRaw ]); - - useEffect(() => - { - if(step !== 'avatar' || figureData || !figureDataUrl) return; - let cancelled = false; - fetch(figureDataUrl, { credentials: 'omit' }) - .then(r => r.ok ? r.json() : null) - .then(json => { if(!cancelled && json) setFigureData(json as FigureData); }) - .catch(() => { }); - return () => { cancelled = true; }; - }, [ step, figureData, figureDataUrl ]); - - useEffect(() => - { - if(step !== 'room' || roomTemplates !== null || !roomTemplatesUrl) return; - let cancelled = false; - setRoomTemplatesError(null); - fetch(roomTemplatesUrl, { credentials: 'include' }) - .then(async r => { - if(!r.ok) throw new Error(`status ${ r.status }`); - return r.json(); - }) - .then(json => { - if(cancelled) return; - const list = Array.isArray((json as { templates?: unknown })?.templates) - ? (json as { templates: RoomTemplate[] }).templates - : []; - setRoomTemplates(list); - }) - .catch(() => { - if(cancelled) return; - setRoomTemplates([]); - setRoomTemplatesError(t('nitro.login.register.room.error', 'Could not load room options. You can still skip this step.')); - }); - return () => { cancelled = true; }; - }, [ step, roomTemplates, roomTemplatesUrl ]); - - const partOptions = useMemo(() => - { - const result: Record> = {}; - if(!figureData) return result; - for(const st of figureData.setTypes) - { - if(!PART_ROWS.includes(st.type)) continue; - const forGender = (g: GenderKey) => st.sets - .filter(s => s.selectable && s.club === 0 && (s.gender === g || s.gender === 'U')) - .map(s => s.id); - result[st.type] = { M: forGender('M'), F: forGender('F') }; - } - return result; - }, [ figureData ]); - - const paletteOptions = useMemo(() => - { - const result: Record = {}; - if(!figureData) return result; - for(const st of figureData.setTypes) - { - if(!PART_ROWS.includes(st.type)) continue; - const palette = figureData.palettes.find(p => p.id === st.paletteId); - if(!palette) { result[st.type] = []; continue; } - result[st.type] = palette.colors - .filter(c => c.selectable && c.club === 0) - .map(c => ({ id: c.id, hex: '#' + c.hexCode.toUpperCase() })); - } - return result; - }, [ figureData ]); - - const hexFor = useCallback((setType: string, colorId: number): string => - { - const list = paletteOptions[setType]; - if(list) - { - const found = list.find(c => c.id === colorId); - if(found) return found.hex; - } - return FALLBACK_HEX[colorId] || '#c9c9c9'; - }, [ paletteOptions ]); - - const [ hotLooks, setHotLooks ] = useState<{ gender: GenderKey; figure: string }[]>([]); - const [ hotLookIndex, setHotLookIndex ] = useState(-1); - - useEffect(() => - { - if(step !== 'avatar' || hotLooks.length) return; - let cancelled = false; - fetch('hotlooks.json', { credentials: 'omit' }) - .then(r => r.ok ? r.json() : null) - .then((json: unknown) => - { - if(cancelled || !Array.isArray(json)) return; - const parsed: { gender: GenderKey; figure: string }[] = []; - for(const entry of json as Record[]) - { - const rawGender = typeof entry._gender === 'string' ? entry._gender.toUpperCase() : ''; - const figure = typeof entry._figure === 'string' ? entry._figure : ''; - if((rawGender !== 'M' && rawGender !== 'F') || !figure) continue; - parsed.push({ gender: rawGender as GenderKey, figure }); - } - if(parsed.length) setHotLooks(parsed); - }) - .catch(() => { }); - return () => { cancelled = true; }; - }, [ step, hotLooks.length ]); - - const applyLook = useCallback((figure: string, lookGender: GenderKey) => - { - const next: FigureSelection = {}; - for(const setPart of figure.split('.')) - { - const bits = setPart.split('-'); - if(bits.length < 2) continue; - const setType = bits[0]; - const partId = parseInt(bits[1], 10); - if(!setType || Number.isNaN(partId)) continue; - const colors: number[] = []; - for(let i = 2; i < bits.length; i++) - { - const c = parseInt(bits[i], 10); - if(!Number.isNaN(c)) colors.push(c); - } - next[setType] = { partId, colors }; - } - - for(const setType of PART_ROWS) - { - if(!next[setType]) next[setType] = { ...FALLBACK_DEFAULTS[lookGender][setType] }; - } - setGender(lookGender); - setSelection(next); - }, []); - - const cycleHotLook = useCallback(() => - { - if(!hotLooks.length) return; - const nextIdx = (hotLookIndex + 1) % hotLooks.length; - setHotLookIndex(nextIdx); - const look = hotLooks[nextIdx]; - applyLook(look.figure, look.gender); - }, [ hotLooks, hotLookIndex, applyLook ]); - - const credentialsValid = - EMAIL_REGEX.test(email.trim()) && - password.length >= 8 && - password === confirm; - - const handleCredentialsNext = async (event: FormEvent) => - { - event.preventDefault(); - setLocalError(null); - - if(!email.trim() || !password || !confirm) - { - setLocalError(t('nitro.login.error.missing_fields', 'Please fill in every field.')); - return; - } - if(!EMAIL_REGEX.test(email.trim())) - { - setLocalError(t('nitro.login.error.invalid_email', 'Please enter a valid email address.')); - return; - } - if(password.length < 8) - { - setLocalError(t('nitro.login.error.password_too_short', 'Your password must be at least 8 characters.')); - return; - } - if(password !== confirm) - { - setLocalError(t('nitro.login.error.password_mismatch', 'Passwords do not match.')); - return; - } - - setChecking(true); - try - { - const serverOk = await pingServer(); - if(!serverOk) - { - setLocalError(t('nitro.login.error.server_offline', 'The gameserver is not running. Please try again later.')); - return; - } - const result = await onCheckEmail(email.trim()); - if(!result.available) - { - setLocalError(result.error || t('nitro.login.error.email_taken', 'This email is already in use.')); - return; - } - setStep('avatar'); - } - finally - { - setChecking(false); - } - }; - - const applyGender = (newGender: GenderKey) => - { - setGender(newGender); - setSelection({ ...FALLBACK_DEFAULTS[newGender] }); - setHotLookIndex(-1); - }; - - const getPartList = useCallback((setType: string): number[] => - { - const loaded = partOptions[setType]?.[gender]; - if(loaded && loaded.length) return loaded; - const fallback = FALLBACK_DEFAULTS[gender][setType]?.partId; - return fallback !== undefined ? [ fallback ] : []; - }, [ partOptions, gender ]); - - const getColorList = useCallback((setType: string): number[] => - { - const loaded = paletteOptions[setType]; - if(loaded && loaded.length) return loaded.map(c => c.id); - const fallback = FALLBACK_DEFAULTS[gender][setType]?.colors?.[0]; - return fallback !== undefined ? [ fallback ] : []; - }, [ paletteOptions, gender ]); - - const cyclePart = (setType: string, direction: 1 | -1) => - { - const options = getPartList(setType); - if(!options.length) return; - const current = selection[setType]?.partId ?? options[0]; - const idx = options.indexOf(current); - const nextIdx = ((idx === -1 ? 0 : idx) + direction + options.length) % options.length; - const colors = getColorList(setType); - setSelection(prev => ({ - ...prev, - [setType]: { - partId: options[nextIdx], - colors: prev[setType]?.colors ?? [ colors[0] ?? 0 ] - } - })); - }; - - const cycleColor = (setType: string, direction: 1 | -1) => - { - const colors = getColorList(setType); - if(!colors.length) return; - const currentColor = selection[setType]?.colors?.[0] ?? colors[0]; - const idx = colors.indexOf(currentColor); - const nextIdx = ((idx === -1 ? 0 : idx) + direction + colors.length) % colors.length; - const parts = getPartList(setType); - setSelection(prev => ({ - ...prev, - [setType]: { - partId: prev[setType]?.partId ?? parts[0], - colors: [ colors[nextIdx] ] - } - })); - }; - - const figure = buildFigureString(selection); - const previewSrc = buildImagingUrl(imagingUrl, figure, gender); - - const handleAvatarSubmit = async (event: FormEvent) => - { - event.preventDefault(); - setLocalError(null); - - const trimmed = username.trim(); - if(!trimmed) - { - setLocalError(t('nitro.login.error.missing_username', 'Please choose a Habbo name.')); - return; - } - if(trimmed.length < 3 || trimmed.length > 16) - { - setLocalError(t('nitro.login.error.username_length', 'Habbo name must be 3–16 characters.')); - return; - } - - if(turnstileEnabled && !turnstileToken) - { - setLocalError(t('nitro.login.error.turnstile', 'Please complete the security check.')); - return; - } - - setChecking(true); - try - { - const serverOk = await pingServer(); - if(!serverOk) - { - setLocalError(t('nitro.login.error.server_offline', 'The gameserver is not running. Please try again later.')); - return; - } - const result = await onCheckUsername(trimmed); - if(!result.available) - { - setLocalError(result.error || t('nitro.login.error.username_taken', 'This Habbo name is already taken.')); - return; - } - } - finally - { - setChecking(false); - } - - setStep('room'); - }; - - const submitRegistration = (templateId: number | null) => - { - onSubmit({ - username: username.trim(), - email: email.trim(), - password, - figure, - gender, - turnstileToken, - templateId - }, resetWidget); - }; - - const handleRoomSubmit = (event: FormEvent) => - { - event.preventDefault(); - setLocalError(null); - submitRegistration(selectedTemplateId); - }; - - const busy = submitting || checking || pingingServer; - const serverOffline = serverReachable === false; - - return ( -
-
-
-
- { t('nitro.login.register.title', 'Habbo Details') } - -
- - { step === 'credentials' && -
-
- { t('nitro.login.register.intro.credentials', 'Let\'s create your account. Enter your email and pick a password — we\'ll check that email isn\'t already in use.') } -
- { serverOffline && -
- { t('nitro.login.server.offline.long', 'The gameserver isn\'t running right now, so new accounts can\'t be created. Please try again in a moment.') } - -
- } -
- - setEmail(e.target.value) } /> -
-
- - setPassword(e.target.value) } /> -
-
- - setConfirm(e.target.value) } /> -
- { (localError || error) &&
{ localError || error }
} - { info &&
{ info }
} -
- 1/3 - -
-
- } - - { step === 'avatar' && -
-
- { t('nitro.login.register.intro.avatar', 'Now it\'s time to make your own Habbo character! To make your own Habbo, please start by choosing your Habbo Name.') } -
- { serverOffline && -
- { t('nitro.login.server.offline.long', 'The gameserver isn\'t running right now, so new accounts can\'t be created. Please try again in a moment.') } - -
- } -
- setUsername(e.target.value) } /> -
- -
- - -
- -
-
- { PART_ROWS.map(setType => { - const partPreviewSrc = buildPartPreviewUrl(imagingUrl, setType, selection, gender); - return ( -
- -
- { { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } /> -
- -
- ); - }) } -
- -
- Habbo preview { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } /> -
- -
- { PART_ROWS.map(setType => { - const fallbackColor = FALLBACK_DEFAULTS[gender][setType]?.colors?.[0] ?? 0; - const currentColor = selection[setType]?.colors?.[0] ?? fallbackColor; - const swatchHex = hexFor(setType, currentColor); - return ( -
- -
- -
- ); - }) } -
-
- -
- -
- - { turnstileEnabled && - setTurnstileToken('') } - onError={ () => setTurnstileToken('') } - resetSignal={ resetSignal } - /> } - { (localError || error) &&
{ localError || error }
} - { info &&
{ info }
} - -
- - 2/3 - -
- - } - - { step === 'room' && -
-
- { t('nitro.login.register.intro.room', 'Last step — pick a starter room, or skip and create your own later.') } -
- { serverOffline && -
- { t('nitro.login.server.offline.long', 'The gameserver isn\'t running right now, so new accounts can\'t be created. Please try again in a moment.') } - -
- } - -
- - - { roomTemplates === null &&
{ t('nitro.login.register.room.loading', 'Loading rooms…') }
} - - { roomTemplates !== null && roomTemplates.map(template => ( - - )) } -
- - { roomTemplatesError &&
{ roomTemplatesError }
} - { (localError || error) &&
{ localError || error }
} - { info &&
{ info }
} - -
- - 3/3 - -
-
- } -
-
-
- ); -}; - - -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(t('nitro.login.error.missing_email', 'Please enter your email address.')); - return; - } - - onSubmit({ email: email.trim(), turnstileToken }, resetWidget); - }; - - return ( -
-
-
-
- { t('nitro.login.forgot.title', 'Reset password') } - -
-
-
- - setEmail(e.target.value) } /> -
- { turnstileEnabled && - setTurnstileToken('') } - onError={ () => setTurnstileToken('') } - resetSignal={ resetSignal } - /> } - { (localError || error) &&
{ localError || error }
} - { info &&
{ info }
} -
- -
- -
-
-
- ); -}; - -interface NewsItem -{ - id: number; - title: string; - body: string; - image: string | null; - linkText: string; - linkUrl: string; -} - -interface NewsWindowProps { newsUrl: string; } - -const NEWS_AUTO_ADVANCE_MS = 10000; - -const resolveNewsImage = (raw: string | null | undefined): string => -{ - const value = (raw ?? '').trim(); - if(!value) return ''; - if(/^https?:\/\//i.test(value)) return value; - if(value.startsWith('//')) return value; - if(value.startsWith('/') && !value.startsWith('//')) return value; - if(value.startsWith('data:')) - { - return /^data:image\/[a-z0-9.+-]+[,;]/i.test(value) ? value : ''; - } - - const stripped = value.replace(/\s+/g, ''); - if(!/^[A-Za-z0-9+/=]+$/.test(stripped)) return ''; - let mime = 'image/png'; - if(stripped.startsWith('/9j/')) mime = 'image/jpeg'; - else if(stripped.startsWith('R0lGOD')) mime = 'image/gif'; - else if(stripped.startsWith('UklGR')) mime = 'image/webp'; - else if(stripped.startsWith('PHN2Zy') || stripped.startsWith('PD94bWw')) mime = 'image/svg+xml'; - else if(stripped.startsWith('iVBORw0KGgo')) mime = 'image/png'; - return `data:${ mime };base64,${ stripped }`; -}; - -const resolveNewsLink = (raw: string | null | undefined): string => -{ - const value = (raw ?? '').trim(); - if(!value) return ''; - try - { - const url = new URL(value, window.location.href); - const proto = url.protocol.toLowerCase(); - if(proto !== 'http:' && proto !== 'https:') return ''; - return url.href; - } - catch { return ''; } -}; - -const NewsWindow: FC = ({ newsUrl }) => -{ - const [ items, setItems ] = useState(null); - const [ failed, setFailed ] = useState(false); - const [ index, setIndex ] = useState(0); - const [ autoTick, setAutoTick ] = useState(0); - - useEffect(() => - { - if(!newsUrl) { setFailed(true); return; } - let cancelled = false; - fetch(newsUrl, { credentials: 'omit' }) - .then(async r => - { - if(!r.ok) throw new Error('status ' + r.status); - return r.json(); - }) - .then((json: unknown) => - { - if(cancelled) return; - const list = Array.isArray((json as { news?: unknown })?.news) - ? (json as { news: NewsItem[] }).news - : []; - setItems(list); - }) - .catch(() => { if(!cancelled) setFailed(true); }); - return () => { cancelled = true; }; - }, [ newsUrl ]); - - useEffect(() => - { - if(!items || items.length < 2) return; - const id = window.setTimeout(() => - { - setIndex(i => (i + 1) % items.length); - }, NEWS_AUTO_ADVANCE_MS); - return () => window.clearTimeout(id); - }, [ items, index, autoTick ]); - - if(failed) return null; - if(!items || !items.length) return null; - - const current = items[Math.min(index, items.length - 1)]; - const hasMany = items.length > 1; - const bumpAuto = () => setAutoTick(t => t + 1); - const prev = () => { setIndex(i => (i - 1 + items.length) % items.length); bumpAuto(); }; - const next = () => { setIndex(i => (i + 1) % items.length); bumpAuto(); }; - - const safeLinkUrl = resolveNewsLink(current.linkUrl); - const safeImageSrc = resolveNewsImage(current.image); - const openLink = () => - { - if(!safeLinkUrl) return; - window.open(safeLinkUrl, '_blank', 'noopener,noreferrer'); - }; - - return ( -
-
- - - - - - -
-
- { t('nitro.login.news.title', 'Hotel News') } -
-
- { safeImageSrc && -
- { { (e.currentTarget as HTMLImageElement).style.display = 'none'; } } - /> -
- } -
{ current.title }
- { current.body && -
{ current.body }
} - -
- { current.linkText && safeLinkUrl - ? - : } - - { hasMany && -
- - { index + 1 }/{ items.length } - -
- } -
-
-
-
-
- ); -}; diff --git a/src/components/login/components/ForgotDialog.tsx b/src/components/login/components/ForgotDialog.tsx new file mode 100644 index 0000000..65cffa8 --- /dev/null +++ b/src/components/login/components/ForgotDialog.tsx @@ -0,0 +1,72 @@ +import { FC, FormEvent, useCallback, useState } from 'react'; +import { TurnstileWidget } from '../TurnstileWidget'; +import { t } from '../utils/i18n'; +import { DialogSharedProps } from './shared'; + +export interface ForgotDialogProps extends DialogSharedProps +{ + onSubmit: (body: { email: string; turnstileToken: string; }, onDialogReset: () => void) => void; +} + +export 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(t('nitro.login.error.missing_email', 'Please enter your email address.')); + return; + } + + onSubmit({ email: email.trim(), turnstileToken }, resetWidget); + }; + + return ( +
+
+
+
+ { t('nitro.login.forgot.title', 'Reset password') } + +
+
+
+ + setEmail(e.target.value) } /> +
+ { turnstileEnabled && + setTurnstileToken('') } + onError={ () => setTurnstileToken('') } + resetSignal={ resetSignal } + /> } + { (localError || error) &&
{ localError || error }
} + { info &&
{ info }
} +
+ +
+ +
+
+
+ ); +}; diff --git a/src/components/login/components/NewsWindow.tsx b/src/components/login/components/NewsWindow.tsx new file mode 100644 index 0000000..a7c05d3 --- /dev/null +++ b/src/components/login/components/NewsWindow.tsx @@ -0,0 +1,122 @@ +import { FC, useEffect, useState } from 'react'; +import { t } from '../utils/i18n'; +import { resolveNewsImage, resolveNewsLink } from '../utils/news'; + +interface NewsItem +{ + id: number; + title: string; + body: string; + image: string | null; + linkText: string; + linkUrl: string; +} + +interface NewsWindowProps { newsUrl: string; } + +const NEWS_AUTO_ADVANCE_MS = 10000; + +export const NewsWindow: FC = ({ newsUrl }) => +{ + const [ items, setItems ] = useState(null); + const [ failed, setFailed ] = useState(false); + const [ index, setIndex ] = useState(0); + const [ autoTick, setAutoTick ] = useState(0); + + useEffect(() => + { + if(!newsUrl) { setFailed(true); return; } + let cancelled = false; + fetch(newsUrl, { credentials: 'omit' }) + .then(async r => + { + if(!r.ok) throw new Error('status ' + r.status); + return r.json(); + }) + .then((json: unknown) => + { + if(cancelled) return; + const list = Array.isArray((json as { news?: unknown })?.news) + ? (json as { news: NewsItem[] }).news + : []; + setItems(list); + }) + .catch(() => { if(!cancelled) setFailed(true); }); + return () => { cancelled = true; }; + }, [ newsUrl ]); + + useEffect(() => + { + if(!items || items.length < 2) return; + const id = window.setTimeout(() => + { + setIndex(i => (i + 1) % items.length); + }, NEWS_AUTO_ADVANCE_MS); + return () => window.clearTimeout(id); + }, [ items, index, autoTick ]); + + if(failed) return null; + if(!items || !items.length) return null; + + const current = items[Math.min(index, items.length - 1)]; + const hasMany = items.length > 1; + const bumpAuto = () => setAutoTick(t => t + 1); + const prev = () => { setIndex(i => (i - 1 + items.length) % items.length); bumpAuto(); }; + const next = () => { setIndex(i => (i + 1) % items.length); bumpAuto(); }; + + const safeLinkUrl = resolveNewsLink(current.linkUrl); + const safeImageSrc = resolveNewsImage(current.image); + const openLink = () => + { + if(!safeLinkUrl) return; + window.open(safeLinkUrl, '_blank', 'noopener,noreferrer'); + }; + + return ( +
+
+ + + + + + +
+
+ { t('nitro.login.news.title', 'Hotel News') } +
+
+ { safeImageSrc && +
+ { { (e.currentTarget as HTMLImageElement).style.display = 'none'; } } + /> +
+ } +
{ current.title }
+ { current.body && +
{ current.body }
} + +
+ { current.linkText && safeLinkUrl + ? + : } + + { hasMany && +
+ + { index + 1 }/{ items.length } + +
+ } +
+
+
+
+
+ ); +}; diff --git a/src/components/login/components/RegisterDialog.tsx b/src/components/login/components/RegisterDialog.tsx new file mode 100644 index 0000000..b952c56 --- /dev/null +++ b/src/components/login/components/RegisterDialog.tsx @@ -0,0 +1,633 @@ +import { FC, FormEvent, useCallback, useEffect, useMemo, useState } from 'react'; +import { GetConfiguration } from '@nitrots/nitro-renderer'; +import { GetConfigurationValue } from '../../../api'; +import { TurnstileWidget } from '../TurnstileWidget'; +import { t } from '../utils/i18n'; +import { + buildFigureString, + buildImagingUrl, + buildPartPreviewUrl, + EMAIL_REGEX, + FALLBACK_DEFAULTS, + FALLBACK_HEX, + FigureData, + FigureSelection, + GenderKey, + PART_ROWS +} from '../utils/figure'; +import { DialogSharedProps } from './shared'; + +export interface RegisterDialogProps extends DialogSharedProps +{ + onSubmit: (body: { username: string; email: string; password: string; figure: string; gender: string; turnstileToken: string; templateId: number | null; }, onDialogReset: () => void) => void; + onCheckEmail: (email: string) => Promise<{ available: boolean; error?: string }>; + onCheckUsername: (username: string) => Promise<{ available: boolean; error?: string }>; + onCheckServer: () => Promise; + imagingUrl: string; + roomTemplatesUrl: string; +} + +type RegisterStep = 'credentials' | 'avatar' | 'room'; + +interface RoomTemplate { templateId: number; title: string; description: string; thumbnail: string; } + +export const RegisterDialog: FC = props => +{ + const { onCancel, onSubmit, onCheckEmail, onCheckUsername, onCheckServer, imagingUrl, roomTemplatesUrl, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props; + + const [ step, setStep ] = useState('credentials'); + const [ email, setEmail ] = useState(''); + const [ password, setPassword ] = useState(''); + const [ confirm, setConfirm ] = useState(''); + const [ username, setUsername ] = useState(''); + const [ gender, setGender ] = useState('F'); + const [ selection, setSelection ] = useState(() => ({ ...FALLBACK_DEFAULTS.F })); + const [ localError, setLocalError ] = useState(null); + const [ checking, setChecking ] = useState(false); + const [ turnstileToken, setTurnstileToken ] = useState(''); + const [ resetSignal, setResetSignal ] = useState(0); + const [ serverReachable, setServerReachable ] = useState(null); + const [ pingingServer, setPingingServer ] = useState(false); + + const pingServer = useCallback(async () => + { + setPingingServer(true); + try + { + const ok = await onCheckServer(); + setServerReachable(ok); + return ok; + } + finally + { + setPingingServer(false); + } + }, [ onCheckServer ]); + + useEffect(() => + { + let cancelled = false; + (async () => + { + const ok = await onCheckServer(); + if(!cancelled) setServerReachable(ok); + })(); + return () => { cancelled = true; }; + }, [ onCheckServer ]); + + const resetWidget = useCallback(() => + { + setTurnstileToken(''); + setResetSignal(prev => prev + 1); + }, []); + + useEffect(() => { setLocalError(null); }, [ step ]); + + const [ roomTemplates, setRoomTemplates ] = useState(null); + const [ roomTemplatesError, setRoomTemplatesError ] = useState(null); + const [ selectedTemplateId, setSelectedTemplateId ] = useState(null); + + const [ figureData, setFigureData ] = useState(null); + const figureDataUrlRaw = GetConfigurationValue('avatar.figuredata.url', ''); + const figureDataUrl = useMemo(() => + { + if(!figureDataUrlRaw) return ''; + try { return GetConfiguration().interpolate(figureDataUrlRaw); } + catch { return figureDataUrlRaw; } + }, [ figureDataUrlRaw ]); + + useEffect(() => + { + if(step !== 'avatar' || figureData || !figureDataUrl) return; + let cancelled = false; + fetch(figureDataUrl, { credentials: 'omit' }) + .then(r => r.ok ? r.json() : null) + .then(json => { if(!cancelled && json) setFigureData(json as FigureData); }) + .catch(() => { }); + return () => { cancelled = true; }; + }, [ step, figureData, figureDataUrl ]); + + useEffect(() => + { + if(step !== 'room' || roomTemplates !== null || !roomTemplatesUrl) return; + let cancelled = false; + setRoomTemplatesError(null); + fetch(roomTemplatesUrl, { credentials: 'include' }) + .then(async r => { + if(!r.ok) throw new Error(`status ${ r.status }`); + return r.json(); + }) + .then(json => { + if(cancelled) return; + const list = Array.isArray((json as { templates?: unknown })?.templates) + ? (json as { templates: RoomTemplate[] }).templates + : []; + setRoomTemplates(list); + }) + .catch(() => { + if(cancelled) return; + setRoomTemplates([]); + setRoomTemplatesError(t('nitro.login.register.room.error', 'Could not load room options. You can still skip this step.')); + }); + return () => { cancelled = true; }; + }, [ step, roomTemplates, roomTemplatesUrl ]); + + const partOptions = useMemo(() => + { + const result: Record> = {}; + if(!figureData) return result; + for(const st of figureData.setTypes) + { + if(!PART_ROWS.includes(st.type)) continue; + const forGender = (g: GenderKey) => st.sets + .filter(s => s.selectable && s.club === 0 && (s.gender === g || s.gender === 'U')) + .map(s => s.id); + result[st.type] = { M: forGender('M'), F: forGender('F') }; + } + return result; + }, [ figureData ]); + + const paletteOptions = useMemo(() => + { + const result: Record = {}; + if(!figureData) return result; + for(const st of figureData.setTypes) + { + if(!PART_ROWS.includes(st.type)) continue; + const palette = figureData.palettes.find(p => p.id === st.paletteId); + if(!palette) { result[st.type] = []; continue; } + result[st.type] = palette.colors + .filter(c => c.selectable && c.club === 0) + .map(c => ({ id: c.id, hex: '#' + c.hexCode.toUpperCase() })); + } + return result; + }, [ figureData ]); + + const hexFor = useCallback((setType: string, colorId: number): string => + { + const list = paletteOptions[setType]; + if(list) + { + const found = list.find(c => c.id === colorId); + if(found) return found.hex; + } + return FALLBACK_HEX[colorId] || '#c9c9c9'; + }, [ paletteOptions ]); + + const [ hotLooks, setHotLooks ] = useState<{ gender: GenderKey; figure: string }[]>([]); + const [ hotLookIndex, setHotLookIndex ] = useState(-1); + + useEffect(() => + { + if(step !== 'avatar' || hotLooks.length) return; + let cancelled = false; + fetch('hotlooks.json', { credentials: 'omit' }) + .then(r => r.ok ? r.json() : null) + .then((json: unknown) => + { + if(cancelled || !Array.isArray(json)) return; + const parsed: { gender: GenderKey; figure: string }[] = []; + for(const entry of json as Record[]) + { + const rawGender = typeof entry._gender === 'string' ? entry._gender.toUpperCase() : ''; + const figure = typeof entry._figure === 'string' ? entry._figure : ''; + if((rawGender !== 'M' && rawGender !== 'F') || !figure) continue; + parsed.push({ gender: rawGender as GenderKey, figure }); + } + if(parsed.length) setHotLooks(parsed); + }) + .catch(() => { }); + return () => { cancelled = true; }; + }, [ step, hotLooks.length ]); + + const applyLook = useCallback((figure: string, lookGender: GenderKey) => + { + const next: FigureSelection = {}; + for(const setPart of figure.split('.')) + { + const bits = setPart.split('-'); + if(bits.length < 2) continue; + const setType = bits[0]; + const partId = parseInt(bits[1], 10); + if(!setType || Number.isNaN(partId)) continue; + const colors: number[] = []; + for(let i = 2; i < bits.length; i++) + { + const c = parseInt(bits[i], 10); + if(!Number.isNaN(c)) colors.push(c); + } + next[setType] = { partId, colors }; + } + + for(const setType of PART_ROWS) + { + if(!next[setType]) next[setType] = { ...FALLBACK_DEFAULTS[lookGender][setType] }; + } + setGender(lookGender); + setSelection(next); + }, []); + + const cycleHotLook = useCallback(() => + { + if(!hotLooks.length) return; + const nextIdx = (hotLookIndex + 1) % hotLooks.length; + setHotLookIndex(nextIdx); + const look = hotLooks[nextIdx]; + applyLook(look.figure, look.gender); + }, [ hotLooks, hotLookIndex, applyLook ]); + + const credentialsValid = + EMAIL_REGEX.test(email.trim()) && + password.length >= 8 && + password === confirm; + + const handleCredentialsNext = async (event: FormEvent) => + { + event.preventDefault(); + setLocalError(null); + + if(!email.trim() || !password || !confirm) + { + setLocalError(t('nitro.login.error.missing_fields', 'Please fill in every field.')); + return; + } + if(!EMAIL_REGEX.test(email.trim())) + { + setLocalError(t('nitro.login.error.invalid_email', 'Please enter a valid email address.')); + return; + } + if(password.length < 8) + { + setLocalError(t('nitro.login.error.password_too_short', 'Your password must be at least 8 characters.')); + return; + } + if(password !== confirm) + { + setLocalError(t('nitro.login.error.password_mismatch', 'Passwords do not match.')); + return; + } + + setChecking(true); + try + { + const serverOk = await pingServer(); + if(!serverOk) + { + setLocalError(t('nitro.login.error.server_offline', 'The gameserver is not running. Please try again later.')); + return; + } + const result = await onCheckEmail(email.trim()); + if(!result.available) + { + setLocalError(result.error || t('nitro.login.error.email_taken', 'This email is already in use.')); + return; + } + setStep('avatar'); + } + finally + { + setChecking(false); + } + }; + + const applyGender = (newGender: GenderKey) => + { + setGender(newGender); + setSelection({ ...FALLBACK_DEFAULTS[newGender] }); + setHotLookIndex(-1); + }; + + const getPartList = useCallback((setType: string): number[] => + { + const loaded = partOptions[setType]?.[gender]; + if(loaded && loaded.length) return loaded; + const fallback = FALLBACK_DEFAULTS[gender][setType]?.partId; + return fallback !== undefined ? [ fallback ] : []; + }, [ partOptions, gender ]); + + const getColorList = useCallback((setType: string): number[] => + { + const loaded = paletteOptions[setType]; + if(loaded && loaded.length) return loaded.map(c => c.id); + const fallback = FALLBACK_DEFAULTS[gender][setType]?.colors?.[0]; + return fallback !== undefined ? [ fallback ] : []; + }, [ paletteOptions, gender ]); + + const cyclePart = (setType: string, direction: 1 | -1) => + { + const options = getPartList(setType); + if(!options.length) return; + const current = selection[setType]?.partId ?? options[0]; + const idx = options.indexOf(current); + const nextIdx = ((idx === -1 ? 0 : idx) + direction + options.length) % options.length; + const colors = getColorList(setType); + setSelection(prev => ({ + ...prev, + [setType]: { + partId: options[nextIdx], + colors: prev[setType]?.colors ?? [ colors[0] ?? 0 ] + } + })); + }; + + const cycleColor = (setType: string, direction: 1 | -1) => + { + const colors = getColorList(setType); + if(!colors.length) return; + const currentColor = selection[setType]?.colors?.[0] ?? colors[0]; + const idx = colors.indexOf(currentColor); + const nextIdx = ((idx === -1 ? 0 : idx) + direction + colors.length) % colors.length; + const parts = getPartList(setType); + setSelection(prev => ({ + ...prev, + [setType]: { + partId: prev[setType]?.partId ?? parts[0], + colors: [ colors[nextIdx] ] + } + })); + }; + + const figure = buildFigureString(selection); + const previewSrc = buildImagingUrl(imagingUrl, figure, gender); + + const handleAvatarSubmit = async (event: FormEvent) => + { + event.preventDefault(); + setLocalError(null); + + const trimmed = username.trim(); + if(!trimmed) + { + setLocalError(t('nitro.login.error.missing_username', 'Please choose a Habbo name.')); + return; + } + if(trimmed.length < 3 || trimmed.length > 16) + { + setLocalError(t('nitro.login.error.username_length', 'Habbo name must be 3–16 characters.')); + return; + } + + if(turnstileEnabled && !turnstileToken) + { + setLocalError(t('nitro.login.error.turnstile', 'Please complete the security check.')); + return; + } + + setChecking(true); + try + { + const serverOk = await pingServer(); + if(!serverOk) + { + setLocalError(t('nitro.login.error.server_offline', 'The gameserver is not running. Please try again later.')); + return; + } + const result = await onCheckUsername(trimmed); + if(!result.available) + { + setLocalError(result.error || t('nitro.login.error.username_taken', 'This Habbo name is already taken.')); + return; + } + } + finally + { + setChecking(false); + } + + setStep('room'); + }; + + const submitRegistration = (templateId: number | null) => + { + onSubmit({ + username: username.trim(), + email: email.trim(), + password, + figure, + gender, + turnstileToken, + templateId + }, resetWidget); + }; + + const handleRoomSubmit = (event: FormEvent) => + { + event.preventDefault(); + setLocalError(null); + submitRegistration(selectedTemplateId); + }; + + const busy = submitting || checking || pingingServer; + const serverOffline = serverReachable === false; + + return ( +
+
+
+
+ { t('nitro.login.register.title', 'Habbo Details') } + +
+ + { step === 'credentials' && +
+
+ { t('nitro.login.register.intro.credentials', 'Let\'s create your account. Enter your email and pick a password — we\'ll check that email isn\'t already in use.') } +
+ { serverOffline && +
+ { t('nitro.login.server.offline.long', 'The gameserver isn\'t running right now, so new accounts can\'t be created. Please try again in a moment.') } + +
+ } +
+ + setEmail(e.target.value) } /> +
+
+ + setPassword(e.target.value) } /> +
+
+ + setConfirm(e.target.value) } /> +
+ { (localError || error) &&
{ localError || error }
} + { info &&
{ info }
} +
+ 1/3 + +
+
+ } + + { step === 'avatar' && +
+
+ { t('nitro.login.register.intro.avatar', 'Now it\'s time to make your own Habbo character! To make your own Habbo, please start by choosing your Habbo Name.') } +
+ { serverOffline && +
+ { t('nitro.login.server.offline.long', 'The gameserver isn\'t running right now, so new accounts can\'t be created. Please try again in a moment.') } + +
+ } +
+ setUsername(e.target.value) } /> +
+ +
+ + +
+ +
+
+ { PART_ROWS.map(setType => { + const partPreviewSrc = buildPartPreviewUrl(imagingUrl, setType, selection, gender); + return ( +
+ +
+ { { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } /> +
+ +
+ ); + }) } +
+ +
+ Habbo preview { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } /> +
+ +
+ { PART_ROWS.map(setType => { + const fallbackColor = FALLBACK_DEFAULTS[gender][setType]?.colors?.[0] ?? 0; + const currentColor = selection[setType]?.colors?.[0] ?? fallbackColor; + const swatchHex = hexFor(setType, currentColor); + return ( +
+ +
+ +
+ ); + }) } +
+
+ +
+ +
+ + { turnstileEnabled && + setTurnstileToken('') } + onError={ () => setTurnstileToken('') } + resetSignal={ resetSignal } + /> } + { (localError || error) &&
{ localError || error }
} + { info &&
{ info }
} + +
+ + 2/3 + +
+ + } + + { step === 'room' && +
+
+ { t('nitro.login.register.intro.room', 'Last step — pick a starter room, or skip and create your own later.') } +
+ { serverOffline && +
+ { t('nitro.login.server.offline.long', 'The gameserver isn\'t running right now, so new accounts can\'t be created. Please try again in a moment.') } + +
+ } + +
+ + + { roomTemplates === null &&
{ t('nitro.login.register.room.loading', 'Loading rooms…') }
} + + { roomTemplates !== null && roomTemplates.map(template => ( + + )) } +
+ + { roomTemplatesError &&
{ roomTemplatesError }
} + { (localError || error) &&
{ localError || error }
} + { info &&
{ info }
} + +
+ + 3/3 + +
+
+ } +
+
+
+ ); +}; diff --git a/src/components/login/components/shared.ts b/src/components/login/components/shared.ts new file mode 100644 index 0000000..53e30ed --- /dev/null +++ b/src/components/login/components/shared.ts @@ -0,0 +1,9 @@ +export interface DialogSharedProps +{ + onCancel: () => void; + submitting: boolean; + error: string | null; + info: string | null; + turnstileEnabled: boolean; + turnstileSiteKey: string; +} diff --git a/src/components/login/utils/ban.ts b/src/components/login/utils/ban.ts new file mode 100644 index 0000000..7c9b904 --- /dev/null +++ b/src/components/login/utils/ban.ts @@ -0,0 +1,32 @@ +export interface BanInfo +{ + type: 'account' | 'ip' | 'machine' | 'super' | string; + reason: string; + permanent: boolean; + expiresAt?: number; +} + +export 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 }; +}; + +export 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`; +}; diff --git a/src/components/login/utils/figure.ts b/src/components/login/utils/figure.ts new file mode 100644 index 0000000..2743662 --- /dev/null +++ b/src/components/login/utils/figure.ts @@ -0,0 +1,106 @@ +export type GenderKey = 'M' | 'F'; + +export const PART_ROWS: string[] = [ 'hr', 'hd', 'ch', 'lg', 'sh' ]; + +export const FALLBACK_DEFAULTS: Record> = { + M: { + hr: { partId: 180, colors: [ 45 ] }, + hd: { partId: 180, colors: [ 1 ] }, + ch: { partId: 215, colors: [ 66 ] }, + lg: { partId: 270, colors: [ 82 ] }, + sh: { partId: 290, colors: [ 80 ] } + }, + F: { + hr: { partId: 515, colors: [ 45 ] }, + hd: { partId: 600, colors: [ 1 ] }, + ch: { partId: 660, colors: [ 100 ] }, + lg: { partId: 716, colors: [ 82 ] }, + sh: { partId: 725, colors: [ 61 ] } + } +}; + +export const FALLBACK_HEX: Record = { + 1: '#ffcb98', 8: '#f4ac54', 14: '#f5da88', 19: '#b87560', 20: '#9c543f', + 45: '#e8c498', 61: '#f1ece3', 66: '#96743d', 80: '#4f4d4d', 82: '#7f4f30', + 92: '#ececec', 100: '#c7ddff', 106: '#c6e6bd', 110: '#91a7c8', 143: '#ffffff' +}; + +export interface FigureColor { id: number; hexCode: string; club: number; selectable: boolean; } +export interface FigurePalette { id: number; colors: FigureColor[]; } +export interface FigureSet { id: number; gender: 'M' | 'F' | 'U'; club: number; selectable: boolean; } +export interface FigureSetType { type: string; paletteId: number; sets: FigureSet[]; } +export interface FigureData { palettes: FigurePalette[]; setTypes: FigureSetType[]; } + +export interface PartSelection { partId: number; colors: number[]; } +export type FigureSelection = Record; + +export const buildFigureString = (selection: FigureSelection): string => +{ + const seen = new Set(); + const parts: string[] = []; + const push = (setType: string) => + { + if(seen.has(setType)) return; + seen.add(setType); + const sel = selection[setType]; + if(!sel || sel.partId < 0) return; + const tail = (sel.colors && sel.colors.length) ? `-${ sel.colors.join('-') }` : ''; + parts.push(`${ setType }-${ sel.partId }${ tail }`); + }; + for(const setType of PART_ROWS) push(setType); + for(const setType of Object.keys(selection)) push(setType); + return parts.join('.'); +}; + +export const buildImagingUrl = (template: string, figure: string, gender: GenderKey): string => + template + .replace(/\{figure\}/g, encodeURIComponent(figure)) + .replace(/\{gender\}/g, gender) + .replace(/\{direction\}/g, '2'); + +const HEAD_ONLY_PARTS = new Set([ 'hr', 'hd' ]); + +export const buildPartPreviewUrl = ( + template: string, + setType: string, + selection: FigureSelection, + gender: GenderKey +): string => +{ + const defaults = FALLBACK_DEFAULTS[gender]; + const partSel = selection[setType] ?? defaults[setType]; + const tail = (partSel.colors && partSel.colors.length) ? `-${ partSel.colors.join('-') }` : ''; + const isHeadOnly = HEAD_ONLY_PARTS.has(setType); + + let parts: string[]; + if(isHeadOnly) + { + const hd = defaults.hd; + const pieces = new Map(); + pieces.set('hd', `hd-${ hd.partId }-${ hd.colors.join('-') }`); + pieces.set(setType, `${ setType }-${ partSel.partId }${ tail }`); + parts = Array.from(pieces.values()); + } + else + { + const hd = defaults.hd; + parts = [ + `hd-${ hd.partId }-${ hd.colors.join('-') }`, + `${ setType }-${ partSel.partId }${ tail }` + ]; + } + + const figure = parts.join('.'); + let url = template + .replace(/\{figure\}/g, encodeURIComponent(figure)) + .replace(/\{gender\}/g, gender) + .replace(/\{direction\}/g, '2'); + + url = url.replace(/size=l/, 'size=s').replace(/size=m/, 'size=s'); + if(!/size=/.test(url)) url += (url.includes('?') ? '&' : '?') + 'size=s'; + if(isHeadOnly && !/headonly=/.test(url)) url += '&headonly=1'; + + return url; +}; + +export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; diff --git a/src/components/login/utils/i18n.ts b/src/components/login/utils/i18n.ts new file mode 100644 index 0000000..58f88f8 --- /dev/null +++ b/src/components/login/utils/i18n.ts @@ -0,0 +1,27 @@ +import { GetConfiguration } from '@nitrots/nitro-renderer'; +import { LocalizeText } from '../../../api'; + +export const t = (key: string, fallback: string, params?: string[], replacements?: string[]): string => +{ + try + { + const value = LocalizeText(key, params ?? null, replacements ?? null); + if(value && value !== key) return value; + } + catch {} + + if(!params || !replacements) return fallback; + let out = fallback; + for(let i = 0; i < params.length; i++) + { + if(replacements[i] !== undefined) out = out.replace('%' + params[i] + '%', replacements[i]); + } + return out; +}; + +export const interpolate = (value: string | null | undefined): string => +{ + if(!value) return ''; + try { return GetConfiguration().interpolate(value); } + catch { return value; } +}; diff --git a/src/components/login/utils/lockState.ts b/src/components/login/utils/lockState.ts new file mode 100644 index 0000000..ef5a946 --- /dev/null +++ b/src/components/login/utils/lockState.ts @@ -0,0 +1,23 @@ +export const LOCK_KEY = 'nitro.login.lock'; +export const MAX_ATTEMPTS = 5; +export const LOCK_WINDOW_MS = 60_000; +export const LOCK_DURATION_MS = 2 * 60_000; + +export type AttemptState = { attempts: number; firstAt: number; lockedUntil: number }; + +export 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 }; } +}; + +export const writeLock = (state: AttemptState) => +{ + try { sessionStorage.setItem(LOCK_KEY, JSON.stringify(state)); } + catch { } +}; diff --git a/src/components/login/utils/news.ts b/src/components/login/utils/news.ts new file mode 100644 index 0000000..510655c --- /dev/null +++ b/src/components/login/utils/news.ts @@ -0,0 +1,46 @@ +/** + * Accepts a URL (http/https, protocol-relative, or site-relative), + * a data URL with an image mime type, or a raw base64 image payload. + * Anything else (including data:text/html, javascript:, etc.) is rejected + * to keep an admin-set DB value from becoming an XSS / phishing vector. + */ +export const resolveNewsImage = (raw: string | null | undefined): string => +{ + const value = (raw ?? '').trim(); + if(!value) return ''; + if(/^https?:\/\//i.test(value)) return value; + if(value.startsWith('//')) return value; + if(value.startsWith('/') && !value.startsWith('//')) return value; + if(value.startsWith('data:')) + { + return /^data:image\/[a-z0-9.+-]+[,;]/i.test(value) ? value : ''; + } + + const stripped = value.replace(/\s+/g, ''); + if(!/^[A-Za-z0-9+/=]+$/.test(stripped)) return ''; + let mime = 'image/png'; + if(stripped.startsWith('/9j/')) mime = 'image/jpeg'; + else if(stripped.startsWith('R0lGOD')) mime = 'image/gif'; + else if(stripped.startsWith('UklGR')) mime = 'image/webp'; + else if(stripped.startsWith('PHN2Zy') || stripped.startsWith('PD94bWw')) mime = 'image/svg+xml'; + else if(stripped.startsWith('iVBORw0KGgo')) mime = 'image/png'; + return `data:${ mime };base64,${ stripped }`; +}; + +/** + * Rejects anything that isn't an http(s) URL or a same-origin path so a + * malicious DB value can't be a `javascript:` / `data:` / `file:` link. + */ +export const resolveNewsLink = (raw: string | null | undefined): string => +{ + const value = (raw ?? '').trim(); + if(!value) return ''; + try + { + const url = new URL(value, window.location.href); + const proto = url.protocol.toLowerCase(); + if(proto !== 'http:' && proto !== 'https:') return ''; + return url.href; + } + catch { return ''; } +};