1 Commits
Author SHA1 Message Date
remco 3fae6436eb chore(deps): lock file maintenance
CI / check (push) Skipped
CI / tests-unit (push) Skipped
CI / tests-integration (push) Skipped
CI / tests-ui (push) Skipped
CI / preflight (push) Skipped
CI / check (pull_request) Successful in 35s
CI / tests-imaging-browsers (pull_request) Skipped
CI / tests-quality (pull_request) Skipped
CI / tests-integration (pull_request) Successful in 1m43s
CI / tests-unit (pull_request) Successful in 1m48s
CI / tests-ui (pull_request) Successful in 4m35s
CI / deploy (pull_request) Skipped
CI / preflight (pull_request) Successful in 2m52s
2026-10-18 12:01:42 +00:00
118 changed files with 1063 additions and 1181 deletions

No files matched your search

+2 -11
View File
@@ -3,13 +3,8 @@
# ==============================================================================
# --- DATABASE (High Performance Pooling & Strict Timeouts) ---
# The pool size is governed by DATABASE_POOL_SIZE (src/lib/db.ts); the
# `connection_limit` in the URL is only honoured by tools that read the URL
# directly (drizzle-kit, scripts). Keep both in step and keep them well below
# MariaDB's `max_connections` (151 by default) or the emulator and any second
# CMS instance get "Too many connections". README §performance: pool size 25.
DATABASE_URL="mysql://user:password@localhost:3306/dbname?charset=utf8mb4&connection_limit=25&connect_timeout=5"
DATABASE_POOL_SIZE=25
DATABASE_URL="mysql://user:password@localhost:3306/dbname?charset=utf8mb4&connection_limit=150&connect_timeout=5"
DATABASE_POOL_SIZE=150
DATABASE_IDLE_TIMEOUT_MS=60000
DATABASE_CONNECT_TIMEOUT_MS=5000
@@ -64,10 +59,6 @@ AUTH_SECRET=your-super-secret-auth-key-change-this-min-32-chars
APP_KEY=base64:your-app-key-here=
# Bcrypt cost factor for new password hashes.
BCRYPT_COST=12
# Password-length policy for every new password (register, password reset and
# staff-created accounts). Minimum: 5-30. Maximum: 30-128.
PASSWORD_MIN_LENGTH=12
PASSWORD_MAX_LENGTH=128
# --- ANTI-DDOS (app-layer gate, production only) ---
# On by default in production. Set to "false" to disable (not recommended).
+1 -9
View File
@@ -158,15 +158,7 @@ const nextConfig: NextConfig = {
},
experimental: {
// Barrel-heavy packages: without this every import pulls the whole
// module graph into the chunk that first touches it.
optimizePackageImports: [
"lucide-react",
"motion",
"@dnd-kit/core",
"@dnd-kit/sortable",
"@tanstack/react-virtual",
],
optimizePackageImports: ["lucide-react", "date-fns"],
useTypeScriptCli: true,
hideLogsAfterAbort: true,
},
+3 -3
View File
@@ -18,8 +18,8 @@
"format": "biome format --write .",
"diag:permissions": "tsx scripts/diagnose-permission-page.ts",
"jobs:worker": "node --conditions=react-server --import tsx scripts/jobs-worker.ts",
"test": "bash scripts/with-memory-cap.sh 8g vitest run",
"test:coverage": "bash scripts/with-memory-cap.sh 8g vitest run --coverage.enabled=true",
"test": "bash scripts/with-memory-cap.sh 8g vitest run --coverage.enabled=false",
"test:coverage": "bash scripts/with-memory-cap.sh 8g vitest run",
"typecheck": "bash scripts/with-memory-cap.sh 6g tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:introspect": "drizzle-kit introspect",
@@ -34,7 +34,7 @@
"db:studio": "drizzle-kit studio",
"gamedata:compress": "node scripts/compress-gamedata.mjs",
"hk:matrix:check": "tsx scripts/verify-housekeeping-matrix.ts",
"test:housekeeping": "bash scripts/with-memory-cap.sh 8g vitest run src/features/housekeeping src/lib/admin-theme-source-audit.test.ts src/lib/admin/authorization-contract.test.ts",
"test:housekeeping": "bash scripts/with-memory-cap.sh 8g vitest run --coverage.enabled=false src/features/housekeeping src/lib/admin-theme-source-audit.test.ts src/lib/admin/authorization-contract.test.ts",
"assets:editor": "node scripts/copy-editor-assets.mjs",
"deps:audit": "pnpm audit --audit-level=high",
"analyze": "pnpm assets:editor && bash scripts/with-memory-cap.sh 10g next build --webpack && node scripts/performance-report.mjs --output-dir build-reports",
+34 -34
View File
@@ -220,7 +220,7 @@ importers:
version: 6.0.0
isomorphic-dompurify:
specifier: ^4.5.0
version: 4.5.0(@noble/[email protected])
version: 4.6.0(@noble/[email protected])
jpeg-js:
specifier: 0.4.4
version: 0.4.4
@@ -364,12 +364,12 @@ packages:
resolution: {integrity: sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==}
engines: {node: '>=10'}
'@asamuzakjp/[email protected].5':
resolution: {integrity: sha512-jCpAvj8apa7SKHZYogk0rbXB3Kb0hLFrpkWF+Ap+vBA5rirItnpv9NKyAyEuDuFizvl0qwnj6JWR70RGJ0rOlw==}
'@asamuzakjp/[email protected].7':
resolution: {integrity: sha512-Q7ioeear07HwixdIitIn9KS1LZi97CEDFCSjPNm9v+y8KNbsu9q4Lrd4zUQW4faED+E7qxvfeVnbNRW46wFKuw==}
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
'@asamuzakjp/[email protected].3':
resolution: {integrity: sha512-3+777rEPsOOjoy5/sRmIzHnoRDaLpm3XWY2dmWn/N7f/XwDG0bcpnFaKL3X9FYJUbNwLmpcJC1bUYexJRxsFlw==}
'@asamuzakjp/[email protected].4':
resolution: {integrity: sha512-A6f1nKy99I6tyoK82U6kBHvQQZmdHmTmPzJYPY/0eaOW0r8/qPmEOwjGHs+hL/vCgOwQTupu4LT1XtXNUuDruA==}
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
'@auth/[email protected]':
@@ -1274,8 +1274,8 @@ packages:
'@msw/[email protected]':
resolution: {integrity: sha512-DdeBLQxQ+ZrtmwEmaj7GQ7lbzPCgbbbyAqOz/I4ulo68hwBpfdNvQJ5bmihJUkAAKOeCmqCIG3OFZGosqFN4mA==}
'@mswjs/[email protected].8':
resolution: {integrity: sha512-lZBylf8kTt096RGnnVfHq+5w4DTRgmFjf59/IVMPBBwKrViumV1l95LZ/LrWt1LZlsJymHOSnoP8mX/kPXZPWw==}
'@mswjs/[email protected].9':
resolution: {integrity: sha512-NmEzpWbbiWpudgqKVZtGdSAvQGIYyj7Rf8ea+1hJEo+Dn0YvPHtiz577US7kGg0sCaZtPelrdqV4SfwwGJq1cA==}
engines: {node: '>=22'}
'@next/[email protected]':
@@ -2292,8 +2292,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
[email protected]8:
resolution: {integrity: sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==}
[email protected]9:
resolution: {integrity: sha512-YeesxhUmR4h5y5V8KF8iBCqJb8YFrBJ++XBTeGcWIaQOYXGN19JM0FoiOjAhwuDoln8owMgXcWBmgHtYSkky2w==}
engines: {node: '>=6.0.0'}
hasBin: true
@@ -2783,8 +2783,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
isomorphic-dompurify@4.5.0:
resolution: {integrity: sha512-5bUPydvGp1pJx27Cff7ScHsgJkETjliiSqpQjPZHMBO6hgqZem38qsXF4TkUYu83yBlbb3rYhypgoo6mMvz48w==}
isomorphic-dompurify@4.6.0:
resolution: {integrity: sha512-ZLYxxq2efNUsaUMViQXML/HMBkaJSd8WAFcNJQfWOGi+KLGQyZA8Ewpcj7yWur6KzMHlrZNXTXr8trbx/bkZRw==}
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
[email protected]:
@@ -3432,8 +3432,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
[email protected].3:
resolution: {integrity: sha512-xletSdAI5efyVJjsatBbzDkXl3M/Zmad3xfnQe7ZXuBB0yNwW7C2WFsNGjdRNfZunmNHQuX79xs/ACMTLCdcew==}
[email protected].4:
resolution: {integrity: sha512-iEmdEew+Tn1BwMGMP8LzGBEPC7Mf1sySjglCeEPb/Vchm9bnGezePvacYcId+fGprQzIng+TBkzqfJ/qS7AsBg==}
[email protected]:
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
@@ -3542,8 +3542,8 @@ packages:
resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==}
engines: {node: '>=14.16'}
strtok3@10.5.0:
resolution: {integrity: sha512-PgAsgIuizbZN/r4+g4UNvbVCTpJWdoIdI71kIWwKmdIH0vJ0q7ylMVGU8PWj2s9O98y4MtFWiDTIrtNukZMMgQ==}
strtok3@10.6.1:
resolution: {integrity: sha512-p8ynRS8lXnvA3tsdcUYFjbzwh2MsdBZgxrFT2G+Yb7vNvUfPF+GgHCt/0jkayfsRXA1TwtZstf+JA0V+iQXdHA==}
engines: {node: '>=18'}
[email protected]:
@@ -3672,8 +3672,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==}
[email protected].1:
resolution: {integrity: sha512-usz3OVQBeh1LxNn0DzU0voLXMgn5CU/WUdpBsY6Ti5afNao+vTkmn4H/NxoXolc10cOqe2+YIG0Hspq2rsl64w==}
[email protected].2:
resolution: {integrity: sha512-XtuDWVdqTLr3NAxm2ly9/hU6kUsJOgGh1RASzMBbsw6aWCOaQzHdW+QQDfdERjfJ2V5vL5pRPa4RetO9UfivDg==}
engines: {node: '>=22.19.0'}
[email protected]:
@@ -3883,7 +3883,7 @@ snapshots:
'@alloc/[email protected]': {}
'@asamuzakjp/[email protected].5':
'@asamuzakjp/[email protected].7':
dependencies:
'@csstools/css-calc': 3.4.3(@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected])
'@csstools/css-color-parser': 4.2.6(@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected])
@@ -3891,7 +3891,7 @@ snapshots:
'@csstools/css-tokenizer': 4.0.2
lru-cache: 11.5.3
'@asamuzakjp/[email protected].3':
'@asamuzakjp/[email protected].4':
dependencies:
bidi-js: 1.1.0
css-tree: 3.2.1
@@ -4461,7 +4461,7 @@ snapshots:
'@msw/[email protected]': {}
'@mswjs/[email protected].8':
'@mswjs/[email protected].9':
dependencies:
'@open-draft/until': 3.0.1
'@types/debug': 4.1.13
@@ -5229,7 +5229,7 @@ snapshots:
[email protected]: {}
[email protected]8: {}
[email protected]9: {}
[email protected]:
dependencies:
@@ -5544,7 +5544,7 @@ snapshots:
[email protected]:
dependencies:
'@tokenizer/inflate': 0.5.1
strtok3: 10.5.0
strtok3: 10.6.1
token-types: 6.1.2
uint8array-extras: 1.6.0
transitivePeerDependencies:
@@ -5597,7 +5597,7 @@ snapshots:
[email protected]:
dependencies:
'@types/set-cookie-parser': 2.4.10
set-cookie-parser: 3.1.3
set-cookie-parser: 3.1.4
[email protected]: {}
@@ -5657,7 +5657,7 @@ snapshots:
[email protected]: {}
isomorphic-dompurify@4.5.0(@noble/[email protected]):
isomorphic-dompurify@4.6.0(@noble/[email protected]):
dependencies:
dompurify: 3.4.17
jsdom: 30.1.3(@noble/[email protected])
@@ -5681,8 +5681,8 @@ snapshots:
[email protected](@noble/[email protected]):
dependencies:
'@asamuzakjp/css-color': 7.1.5
'@asamuzakjp/dom-selector': 10.0.3
'@asamuzakjp/css-color': 7.1.7
'@asamuzakjp/dom-selector': 10.0.4
'@bramus/specificity': 2.4.3
'@csstools/css-syntax-patches-for-csstree': 1.1.15([email protected])
'@exodus/bytes': 1.16.0(@noble/[email protected])
@@ -5695,7 +5695,7 @@ snapshots:
parse5: 8.0.1
saxes: 6.0.0
tough-cookie: 6.0.2
undici: 8.12.1
undici: 8.12.2
w3c-xmlserializer: 6.0.0
webidl-conversions: 8.0.1
whatwg-mimetype: 5.0.0
@@ -5893,7 +5893,7 @@ snapshots:
dependencies:
'@inquirer/confirm': 6.3.3(@types/[email protected])
'@msw/url': 0.1.2
'@mswjs/interceptors': 0.45.8
'@mswjs/interceptors': 0.45.9
cookie: 2.0.1
headers-polyfill: 5.0.1
is-node-process: 1.2.0
@@ -5918,7 +5918,7 @@ snapshots:
debug: 4.4.3
file-type: 22.2.0
media-typer: 2.0.0
strtok3: 10.5.0
strtok3: 10.6.1
token-types: 6.1.2
uint8array-extras: 1.6.0
win-guid: 0.2.1
@@ -5982,7 +5982,7 @@ snapshots:
dependencies:
'@next/env': 16.4.1
'@swc/helpers': 0.5.23
baseline-browser-mapping: 2.11.28
baseline-browser-mapping: 2.11.29
caniuse-lite: 1.0.30001816
postcss: 8.5.23
react: 19.3.1
@@ -6287,7 +6287,7 @@ snapshots:
[email protected]: {}
[email protected].3: {}
[email protected].4: {}
[email protected]: {}
@@ -6423,7 +6423,7 @@ snapshots:
[email protected]: {}
strtok3@10.5.0:
strtok3@10.6.1:
dependencies:
'@tokenizer/token': 0.3.0
@@ -6501,7 +6501,7 @@ snapshots:
ssh-remote-port-forward: 1.0.4
tar-fs: 3.1.3
tmp: 0.2.7
undici: 8.12.1
undici: 8.12.2
transitivePeerDependencies:
- bare-abort-controller
- bare-buffer
@@ -6596,7 +6596,7 @@ snapshots:
[email protected]: {}
[email protected].1: {}
[email protected].2: {}
[email protected]: {}
-5
View File
@@ -2,11 +2,6 @@
"schemaVersion": 1,
"mode": "enforce",
"routes": [
{
"route": "/",
"maxInitialRawBytes": 716800,
"maxInitialGzipBytes": 225280
},
{
"route": "/me",
"maxInitialRawBytes": 716800,
+1
View File
@@ -60,6 +60,7 @@ export async function uploadBadge(formData: FormData): Promise<void> {
try {
const buffer = Buffer.from(await file.arrayBuffer());
const gif = await toBadgeGif(buffer);
// eslint-disable-next-line security/detect-non-literal-fs-filename
await writeFile(target, gif);
} catch {
back("error", "Could not process or write the badge file");
+1 -1
View File
@@ -32,7 +32,7 @@ async function upsertSetting(key: string, value: string): Promise<void> {
.values({
key,
value,
// key is one of 3 known const values
// eslint-disable-next-line security/detect-object-injection -- key is one of 3 known const values
comment: COMMENTS[key] ?? null,
})
.onDuplicateKeyUpdate({ set: { value } });
+2 -2
View File
@@ -71,10 +71,10 @@ export async function savePoints(formData: FormData): Promise<void> {
POINTS_KEYS.map((key) =>
db
.insert(WebsiteSetting)
// key from POINTS_KEYS const
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
.values({ key, value: values[key], comment: "Radio points" })
.onDuplicateKeyUpdate({
// key from POINTS_KEYS const
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
set: { value: values[key] },
}),
),
+1 -1
View File
@@ -112,7 +112,7 @@ export async function saveTheme(formData: FormData): Promise<void> {
export async function applyPreset(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
const name = String(formData.get("preset") ?? "").normalize("NFC");
// guarded by null check below
// eslint-disable-next-line security/detect-object-injection -- guarded by null check below
const preset = PRESETS[name];
if (!preset) redirect("/admin/theme");
+1 -6
View File
@@ -6,7 +6,6 @@ import { redirect } from "next/navigation";
import { env } from "@/env";
import { invalidateLoginCache } from "@/lib/auth/login-core";
import { hashPassword } from "@/lib/auth/password";
import { passwordPolicy } from "@/lib/auth/password-policy";
import { revokeUserCredentials } from "@/lib/auth/session-revocation";
import { db, PasswordReset, User } from "@/lib/db";
import { logger } from "@/lib/logger";
@@ -110,11 +109,7 @@ export async function resetPassword(formData: FormData): Promise<void> {
}
let error: "password" | "invalid" | "failed" | null = null;
if (
password.length < passwordPolicy.minLength ||
password.length > passwordPolicy.maxLength
)
error = "password";
if (password.length < 12) error = "password";
if (!error) {
try {
+4 -10
View File
@@ -5,7 +5,6 @@ import { after } from "next/server";
import { z } from "zod";
import { sendVerification } from "@/lib/auth/email-verification";
import { hashPassword } from "@/lib/auth/password";
import { passwordPolicy } from "@/lib/auth/password-policy";
import { DEFAULT_AVATAR_FIGURE } from "@/lib/avatar-figure";
import { invalidateKey } from "@/lib/cached-db";
import { db, User } from "@/lib/db";
@@ -84,11 +83,6 @@ function hasDisposableEmailDomain(email: string): boolean {
return domain ? DISPOSABLE_EMAIL_DOMAINS.has(domain) : false;
}
// Rendered into the schema below and mapped back to a locale-independent code
// via `ZOD_MESSAGE_CODES`, so the length always reflects the configured policy.
const PASSWORD_MIN_MESSAGE = `Password must be at least ${passwordPolicy.minLength} characters`;
const PASSWORD_MAX_MESSAGE = "Password is too long";
const registerSchema = z
.object({
username: z
@@ -111,8 +105,8 @@ const registerSchema = z
),
password: z
.string()
.min(passwordPolicy.minLength, PASSWORD_MIN_MESSAGE)
.max(passwordPolicy.maxLength, PASSWORD_MAX_MESSAGE)
.min(12, "Password must be at least 12 characters") // Increased min length
.max(128, "Password is too long") // Added max length
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
.regex(/[0-9]/, "Password must contain at least one digit")
@@ -166,8 +160,8 @@ const ZOD_MESSAGE_CODES: Record<string, RegisterErrorCode> = {
"This username is reserved": "usernameReserved",
"Enter a valid email address": "emailValid",
"Temporary email domains are not allowed": "emailDisposable",
[PASSWORD_MIN_MESSAGE]: "passwordMinLength",
[PASSWORD_MAX_MESSAGE]: "passwordMaxLength",
"Password must be at least 12 characters": "passwordMinLength",
"Password is too long": "passwordMaxLength",
"Password must contain at least one uppercase letter": "passwordUpper",
"Password must contain at least one lowercase letter": "passwordLower",
"Password must contain at least one digit": "passwordDigit",
+4
View File
@@ -31,7 +31,9 @@ export async function saveFavicon(
}
const buffer = upload.bytes;
// eslint-disable-next-line security/detect-non-literal-fs-filename
await mkdir(baseDir, { recursive: true });
// eslint-disable-next-line security/detect-non-literal-fs-filename
await writeFile(filePath, buffer);
const url = `/api/media/favicon/${filename}`;
@@ -44,6 +46,7 @@ export async function saveFavicon(
const oldPath = path.resolve(baseDir, oldName);
if (oldPath.startsWith(baseDir + path.sep)) {
try {
// eslint-disable-next-line security/detect-non-literal-fs-filename
await unlink(oldPath);
} catch {
/* ignore if file doesn't exist */
@@ -82,6 +85,7 @@ export async function deleteFavicon(): Promise<{
const oldPath = path.resolve(baseDir, oldName);
if (oldPath.startsWith(baseDir + path.sep)) {
try {
// eslint-disable-next-line security/detect-non-literal-fs-filename
await unlink(oldPath);
} catch {
/* ignore */
+2
View File
@@ -29,7 +29,9 @@ export async function saveLogo(
}
const buffer = upload.bytes;
// eslint-disable-next-line security/detect-non-literal-fs-filename
await mkdir(baseDir, { recursive: true });
// eslint-disable-next-line security/detect-non-literal-fs-filename
await writeFile(filePath, buffer);
const url = `/api/media/logo/${filename}`;
+3 -1
View File
@@ -278,12 +278,14 @@ export async function uploadBackground(formData: FormData): Promise<void> {
let uploaded = false;
try {
// eslint-disable-next-line security/detect-non-literal-fs-filename
await mkdir(MEDIA_ROOT, { recursive: true });
const ext = file.name.split(".").pop() ?? "png";
const name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
const bytes = await file.arrayBuffer();
const filePath = resolveMediaPath(name);
if (filePath.startsWith(MEDIA_ROOT + path.sep)) {
// eslint-disable-next-line security/detect-non-literal-fs-filename
await writeFile(filePath, Buffer.from(bytes));
await writeSetting("background_image", `/api/media/${name}`);
await writeSetting(
@@ -317,7 +319,7 @@ export async function applyPresetManager(formData: FormData): Promise<void> {
const name = String(formData.get("preset") ?? "")
.normalize("NFC")
.trim();
// guarded by null check below
// eslint-disable-next-line security/detect-object-injection -- guarded by null check below
const preset = PRESETS[name];
if (!preset) redirect(MANAGER_PATH);
+1
View File
@@ -142,6 +142,7 @@ export default async function ApplyStaffPage({
<article key={String(position.id)} className="content-card">
<div className="content-card-head">
{team?.badge ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={`${BADGE_IMG_BASE}=${encodeURIComponent(team.badge)}`}
alt={rankName}
+1
View File
@@ -120,6 +120,7 @@ export default async function ApplyTeamPage({
<article key={String(team.id)} className="content-card">
<div className="content-card-head">
{team.badge ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={`${BADGE_IMG_BASE}=${encodeURIComponent(team.badge)}`}
alt={team.rankName}
+1
View File
@@ -48,6 +48,7 @@ export default async function BadgesPage() {
alignItems: "flex-start",
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`${BADGE_IMG_BASE}=${encodeURIComponent(badge.badgeKey)}`}
alt={badge.badgeName}
+1
View File
@@ -190,6 +190,7 @@ export default async function DrawBadgePage({
{b.badgeUrl ? (
// Badge images are arbitrary external URLs stored by the
// draw-badge tool, so a plain <img> is correct here.
// eslint-disable-next-line @next/next/no-img-element
<img
src={b.badgeUrl}
alt=""
+1
View File
@@ -149,6 +149,7 @@ export default async function GuildPage({
style={{ display: "flex", gap: "1.25rem", alignItems: "flex-start" }}
>
{guild.badge ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${guild.badge}.gif`}
alt={`${guild.name} badge`}
+1
View File
@@ -74,6 +74,7 @@ export default async function GuildsPage() {
}}
>
{g.badge ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${g.badge}.gif`}
alt={`${g.name} badge`}
+1
View File
@@ -110,6 +110,7 @@ export default async function HelpCategoryPage({
<ContentCard icon="❓" title={title} subtitle={t("topicSubtitle")}>
{imageSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={imageSrc}
alt=""
+1
View File
@@ -159,6 +159,7 @@ export default async function HelpCenterPage() {
}}
>
{c.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={c.imageUrl}
alt=""
+11 -27
View File
@@ -1,5 +1,4 @@
import dynamic from "next/dynamic";
import { headers } from "next/headers";
import { NextIntlClientProvider } from "next-intl";
import { getLocale, getMessages } from "next-intl/server";
import type { ReactNode } from "react";
@@ -35,43 +34,28 @@ export default async function SiteLayout({
}: {
children: ReactNode;
}) {
const [session, locale, messages, h] = await Promise.all([
const [session, locale, messages] = await Promise.all([
auth(),
getLocale(),
getMessages(),
headers(),
]);
const clientMessages = publicClientMessages(messages);
// The landing pages render their own AuthTopBar (logo + live online), so the
// global site banner / navigation is hidden there to keep them clean and
// avoid showing the same chrome twice. Everything else keeps the chrome.
const pathname = h.get("x-pathname");
const isLandingPage =
pathname === "/" || pathname === "/login" || pathname === "/register";
return (
<NextIntlClientProvider locale={locale} messages={clientMessages}>
<CloudsField />
{/* Site chrome is public: hiding it all for anonymous visitors used to
strand them — from /news, /leaderboard or /shop there was no way to
reach any other page at all. Only the standalone landing pages opt
out, because they carry their own header. */}
{!isLandingPage && (
<div data-theme-block="top_header">
<TopHeader session={session} />
</div>
)}
{!isLandingPage && (
<div data-theme-block="site_header">
<SiteHeader />
</div>
)}
{!isLandingPage && (
<div data-theme-block="navigation">
<Navigation session={session} />
</div>
)}
reach any other page at all. */}
<div data-theme-block="top_header">
<TopHeader session={session} />
</div>
<div data-theme-block="site_header">
<SiteHeader />
</div>
<div data-theme-block="navigation">
<Navigation session={session} />
</div>
<main>
<div
data-theme-block="content_grid"
+1 -1
View File
@@ -171,7 +171,7 @@ export default async function LeaderboardPage({
const rows = await (active === "credits"
? loadCreditsRows()
: active === "diamonds" || active === "duckets"
? // active validated as "diamonds"|"duckets" in this branch
? // eslint-disable-next-line security/detect-object-injection -- active validated as "diamonds"|"duckets" in this branch
loadCurrencyRows(CURRENCY_TYPE[active])
: loadSettingsRows(
SETTINGS_FIELD[active as keyof typeof SETTINGS_FIELD],
+164 -26
View File
@@ -1,14 +1,24 @@
import { desc, eq } from "drizzle-orm";
import type { Metadata } from "next";
import { headers } from "next/headers";
import Image from "next/image";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { AuthAlert } from "@/components/auth/auth-alert";
import {
AuthLandingView,
loadAuthLanding,
} from "@/components/auth/auth-landing";
AuthPageFrame,
AuthUsersCards,
} from "@/components/auth/auth-page-frame";
import { LoginForm } from "@/components/auth/login-form";
import { SurfaceCard } from "@/components/surface-card";
import { auth } from "@/lib/auth";
import { safeRedirectPath } from "@/lib/auth/safe-redirect";
import { cached } from "@/lib/cache";
import { db, User } from "@/lib/db";
import { resolveHotelName } from "@/lib/hotel-name";
import { captchaConfig } from "@/lib/services/captcha";
import { cachedOnlineCount } from "@/lib/services/public-counters";
import { siteSettings } from "@/lib/services/site-settings";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.login");
@@ -33,6 +43,13 @@ export default async function LoginPage({
}>;
}) {
const t = await getTranslations("pages.login");
const [hotelName, cfg, logo] = await Promise.all([
resolveHotelName(),
captchaConfig(),
siteSettings.get("cms_logo", ""),
]);
const nonce = (await headers()).get("x-nonce") ?? undefined;
const sp = await searchParams;
const redirectTo = safeRedirectPath(sp.from);
@@ -41,44 +58,165 @@ export default async function LoginPage({
const session = await auth();
if (session?.user?.id) redirect(redirectTo);
const data = await loadAuthLanding();
const [online, recentUsers, latestUsers] = await Promise.all([
cachedOnlineCount().catch(() => 0),
cached(
"auth_online_users",
10_000,
() =>
db
.select({ id: User.id, username: User.username, look: User.look })
.from(User)
.where(eq(User.online, "1"))
.limit(8),
{ staleMs: 30000 },
).catch(() => []),
cached(
"auth_latest_users",
30_000,
() =>
db
.select({ id: User.id, username: User.username, look: User.look })
.from(User)
.orderBy(desc(User.accountCreated))
.limit(8),
{ staleMs: 60000 },
).catch(() => []),
]);
// `/login?registered=1` is where the sign-up form lands when it could not
// auto sign-in (e-mail verification still pending). Without this notice the
// visitor would only see an empty login form and no sign their account
// exists. `?reset=1` comes from a successful password reset; saying so
// matters because a silent form reads like the reset failed.
// exists.
const notice =
sp.registered === "1" ? (
<AuthAlert variant="success" className="mb-4">
<p
role="status"
className="auth-alert auth-alert--success animate-fade-in-up m-0 mb-4"
>
{t("registeredSuccess")}
</AuthAlert>
</p>
) : sp.reset === "1" ? (
<AuthAlert variant="success" className="mb-4">
// `?reset=1` comes from a successful password reset; saying so matters
// because the visitor just changed their password and a silent form
// reads like the reset failed.
<p
role="status"
className="auth-alert auth-alert--success animate-fade-in-up m-0 mb-4"
>
{t("passwordChanged")}
</AuthAlert>
</p>
) : undefined;
return (
<AuthLandingView
data={data}
copy={{
title: t("title"),
subtitle: t("subtitle"),
notice,
promoTitle: t("welcomeBack"),
promoSubtitle: t("welcomeBackSub", { hotelName: data.hotelName }),
usersOnlineLabel: t("usersOnline", { count: data.online }),
whoIsOnlineLabel: t("whoIsOnline"),
newestCitizensLabel: t("newestCitizens"),
}}
<AuthPageFrame
hotelName={hotelName}
logo={logo}
title={t("title")}
subtitle={t("subtitle")}
notice={notice}
form={
<LoginForm
redirectTo={redirectTo}
captcha={data.captcha}
nonce={data.nonce}
captcha={{
provider: cfg.provider,
siteKey: cfg.siteKey || undefined,
field: cfg.field || undefined,
}}
nonce={nonce}
/>
}
/>
>
<SurfaceCard
className="card-glow relative overflow-hidden p-6 text-center"
style={{
borderColor:
"color-mix(in srgb, var(--color-primary) 20%, transparent)",
}}
>
<div
className="absolute inset-0"
style={{
background:
"linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, transparent), color-mix(in srgb, var(--color-accent) 10%, transparent))",
}}
/>
<div aria-hidden="true" className="absolute inset-0 overflow-hidden">
<div className="brand-halo left-1/2 top-0 h-64 w-[460px] max-w-full -translate-x-1/2 -translate-y-1/2" />
<div
className="aurora-blob -left-16 top-10 h-56 w-56"
style={{
background:
"color-mix(in srgb, var(--color-primary) 45%, transparent)",
}}
/>
<div
className="aurora-blob -right-16 bottom-0 h-56 w-56"
style={{
background:
"color-mix(in srgb, var(--color-accent) 40%, transparent)",
animationDelay: "-8s",
}}
/>
</div>
<div className="relative">
<div className="brand-halo left-1/2 top-8 h-52 w-52 -translate-x-1/2" />
<Image
src="/assets/images/FrankwithBag.gif"
alt="Frank"
width={130}
height={170}
className="relative object-contain mx-auto drop-shadow-xl animate-float"
unoptimized
/>
<div
className="inline-flex items-center gap-2 px-3.5 py-1 rounded-full text-xs font-bold uppercase tracking-wider mt-4 mb-3 border"
style={{
borderColor:
"color-mix(in srgb, var(--color-primary) 30%, transparent)",
background:
"color-mix(in srgb, var(--color-surface) 72%, transparent)",
color: "var(--color-primary-readable, var(--color-primary))",
boxShadow:
"0 8px 20px -12px color-mix(in srgb, var(--color-primary) 70%, transparent)",
}}
>
<span className="relative flex h-2 w-2">
<span
className="animate-ping absolute inline-flex h-full w-full rounded-full opacity-75"
style={{ background: "var(--color-primary)" }}
/>
<span
className="relative inline-flex rounded-full h-2 w-2"
style={{ background: "var(--color-primary)" }}
/>
</span>
{t("usersOnline", { count: online })}
</div>
<h1
className="text-xl font-black"
style={{
color: "var(--color-text-readable)",
fontFamily: "var(--font-nunito)",
}}
>
{t("welcomeBack")}
</h1>
<p
className="text-xs mt-1.5 mx-auto max-w-[220px] leading-relaxed"
style={{ color: "var(--color-text-muted)" }}
>
{t("welcomeBackSub", { hotelName })}
</p>
</div>
</SurfaceCard>
<AuthUsersCards
recentUsers={recentUsers}
latestUsers={latestUsers}
recentTitle={t("whoIsOnline")}
latestTitle={t("newestCitizens")}
/>
</AuthPageFrame>
);
}
+1
View File
@@ -191,6 +191,7 @@ async function ArticlePage({
}
>
{article.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={article.image}
alt=""
+163 -10
View File
@@ -7,20 +7,22 @@ import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import type { CSSProperties, ReactNode } from "react";
import { AnimatedCounter } from "@/components/animated-counter";
import { AuthTopBar } from "@/components/auth/auth-top-bar";
import { LoginForm } from "@/components/auth/login-form";
import { Clock } from "@/components/clock";
import { LanguageSwitcher } from "@/components/language-switcher";
import Link from "@/components/link";
import { LiveOnlineCounter } from "@/components/live-online-counter";
import { Reveal } from "@/components/motion-reveal";
import { PublicLoadError } from "@/components/public/load-error";
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
import { SpotlightCard } from "@/components/spotlight-card";
import { ThemeSwitcher } from "@/components/theme-switcher";
import { TypewriterText } from "@/components/typewriter-text";
import { auth } from "@/lib/auth";
import { cached } from "@/lib/cache";
import { CameraWeb, db, User } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { resolveHotelName } from "@/lib/hotel-name";
import { withPublicPagePerformance } from "@/lib/public-page-performance";
import { captchaConfig } from "@/lib/services/captcha";
import { getNewsList } from "@/lib/services/news-list";
import { loadProfilePrivacyMap } from "@/lib/services/profile-privacy";
@@ -38,6 +40,7 @@ import {
ICON_CAMERA,
ICON_CATALOG,
ICON_FRIENDS,
ICON_LIGHTHOUSE,
ICON_NAV_GOODY,
ICON_NAV_ME,
} from "@/lib/site-icons";
@@ -235,7 +238,7 @@ async function getHotelData() {
};
}
async function Home() {
export default async function Home() {
const session = await auth();
if (session?.user?.id) redirect("/me");
@@ -401,10 +404,88 @@ async function Home() {
return (
<div className="flex w-full flex-col gap-14 pb-16 pt-2 md:gap-20 md:pb-24">
{/* ── Top bar: logo + live online + language/clock/theme ── */}
<div className="mx-auto w-full max-w-7xl">
<AuthTopBar hotelName={hotelName} logo={logo} online={online} />
</div>
{/* ── Top bar ── */}
<Reveal className="mx-auto w-full max-w-7xl">
<div
className="flex items-center justify-between rounded-full border px-3 py-2.5 sm:px-5"
style={{
background:
"linear-gradient(90deg, color-mix(in srgb, var(--color-surface) 62%, transparent), color-mix(in srgb, var(--color-primary) 7%, transparent))",
backdropFilter: "blur(14px)",
WebkitBackdropFilter: "blur(14px)",
borderColor:
"color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
boxShadow:
"0 1px 3px rgba(0,0,0,0.04), 0 0 0 0.5px color-mix(in srgb, var(--color-text-muted) 6%, transparent)",
}}
>
<div className="flex items-center gap-2 sm:gap-3">
{logo ? (
<Image
src={logo}
alt={hotelName}
width={100}
height={28}
className="shrink-0 object-contain"
unoptimized
style={{ maxHeight: 26, width: "auto", height: "auto" }}
/>
) : (
<div
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
style={{
background:
"linear-gradient(135deg, var(--color-primary), color-mix(in srgb, var(--color-accent) 60%, var(--color-primary)))",
boxShadow:
"0 4px 16px -4px color-mix(in srgb, var(--color-primary) 60%, transparent)",
}}
>
<Image
src={ICON_LIGHTHOUSE}
alt=""
width={18}
height={18}
className="shrink-0"
unoptimized
style={{ filter: "brightness(0) invert(1)" }}
/>
</div>
)}
<span
className="hidden text-sm font-extrabold uppercase tracking-wide sm:inline"
style={{ color: "var(--color-text-readable)" }}
>
{hotelName}
</span>
</div>
<div
className="flex items-center gap-1 sm:gap-1.5 rounded-full px-1.5 py-1"
style={{
background:
"color-mix(in srgb, var(--color-text-muted) 5%, transparent)",
}}
>
<LanguageSwitcher />
<div
className="hidden w-px h-5 sm:block"
style={{
background:
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
}}
/>
<Clock />
<div
className="w-px h-5"
style={{
background:
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
}}
/>
<ThemeSwitcher />
</div>
</div>
</Reveal>
{/* ── Cinematic hero ── */}
<Reveal className="-mx-3 sm:-mx-4 md:-mx-6">
<div className="relative overflow-hidden">
@@ -453,7 +534,50 @@ async function Home() {
style={{ opacity: 0.5, zIndex: 1 }}
/>
<div className="relative z-10 mx-auto flex min-h-[540px] w-full max-w-6xl flex-col items-center justify-center px-4 py-24 text-center sm:min-h-[580px] lg:min-h-[640px]">
<h1 style={{ fontFamily: "var(--font-nunito)" }}>
<span
className="glass-chip gap-2.5 px-4 py-2"
style={{
fontWeight: 800,
letterSpacing: "0.14em",
textTransform: "uppercase",
}}
>
<span className="relative flex h-2 w-2">
<span
className="absolute inline-flex h-full w-full animate-ping rounded-full opacity-70"
style={{ background: "var(--color-primary)" }}
/>
<span
className="relative inline-flex h-2 w-2 rounded-full"
style={{ background: "var(--color-primary)" }}
/>
</span>
<span
className="text-[10px] font-black sm:text-xs"
style={{ color: "var(--color-text-readable)" }}
>
{tp("onlineNow")}
</span>
{online === null ? (
<span className="text-xs">{te("unavailable")}</span>
) : (
<span
className="text-sm sm:text-base"
style={{
color:
"var(--color-primary-readable, var(--color-primary))",
fontWeight: 900,
}}
>
<LiveOnlineCounter initial={online} />
</span>
)}
</span>
<h1
className="mt-7 sm:mt-8"
style={{ fontFamily: "var(--font-nunito)" }}
>
<span
className="mb-3 block text-xs font-extrabold uppercase tracking-[0.32em] sm:text-sm"
style={{ color: "rgba(255,255,255,0.72)" }}
@@ -472,6 +596,37 @@ async function Home() {
{tp("welcomeBody")}
</p>
{recentUsers !== null && recentUsers.length > 0 && (
<div
className="animate-fade-in-up mt-7 inline-flex items-center gap-3 rounded-full border px-3 py-1.5"
style={{
background: "rgba(255,255,255,0.08)",
borderColor: "rgba(255,255,255,0.22)",
backdropFilter: "blur(12px)",
WebkitBackdropFilter: "blur(12px)",
animationDelay: "0.08s",
}}
>
<div className="flex -space-x-2.5">
{recentUsers.slice(0, 4).map((u) => (
<UserAvatarThumbnail
key={u.username}
figure={u.look}
alt={u.username}
options={{ direction: 2 }}
className="h-9 w-9 rounded-full ring-2 ring-white/70 shadow-lg"
/>
))}
</div>
<span
className="pr-1 text-[10px] font-extrabold uppercase tracking-[0.22em]"
style={{ color: "rgba(255,255,255,0.75)" }}
>
{tp("recentUsers")}
</span>
</div>
)}
<div
className="animate-fade-in-up mt-9 flex flex-wrap items-center justify-center gap-3"
style={{ animationDelay: "0.2s" }}
@@ -993,5 +1148,3 @@ async function Home() {
</div>
);
}
export default withPublicPagePerformance("/", Home);
+2
View File
@@ -108,6 +108,7 @@ export default async function RareCategoryPage({
}}
>
{badgeSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={badgeSrc}
alt={`${cat.name} badge`}
@@ -149,6 +150,7 @@ export default async function RareCategoryPage({
<tr key={String(r.id)}>
<td>
{iconSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={iconSrc}
alt=""
+1
View File
@@ -88,6 +88,7 @@ export default async function RareValuesPage() {
>
<div className="content-card-head">
{badgeSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={badgeSrc}
alt={`${c.name} badge`}
+156 -28
View File
@@ -1,13 +1,23 @@
import { desc, eq } from "drizzle-orm";
import type { Metadata } from "next";
import { headers } from "next/headers";
import Image from "next/image";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import {
AuthLandingView,
loadAuthLanding,
} from "@/components/auth/auth-landing";
AuthPageFrame,
AuthUsersCards,
} from "@/components/auth/auth-page-frame";
import { RegisterForm } from "@/components/auth/register-form";
import { SurfaceCard } from "@/components/surface-card";
import { auth } from "@/lib/auth";
import { passwordPolicy } from "@/lib/auth/password-policy";
import { cached } from "@/lib/cache";
import { db, User } from "@/lib/db";
import { resolveHotelName } from "@/lib/hotel-name";
import { captchaConfig } from "@/lib/services/captcha";
import { cachedOnlineCount } from "@/lib/services/public-counters";
import { siteSettings } from "@/lib/services/site-settings";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.register");
@@ -21,40 +31,158 @@ export async function generateMetadata(): Promise<Metadata> {
}
export default async function RegisterPage() {
const t = await getTranslations("pages.register");
const tpr = await getTranslations("pages.register");
const [hotelName, cfg, logo] = await Promise.all([
resolveHotelName(),
captchaConfig(),
siteSettings.get("cms_logo", ""),
]);
const nonce = (await headers()).get("x-nonce") ?? undefined;
// An account already exists in this browser — there is nothing to sign up.
const session = await auth();
if (session?.user?.id) redirect("/me");
const data = await loadAuthLanding();
const [online, recentUsers, latestUsers] = await Promise.all([
cachedOnlineCount().catch(() => 0),
cached(
"auth_online_users",
10_000,
() =>
db
.select({ id: User.id, username: User.username, look: User.look })
.from(User)
.where(eq(User.online, "1"))
.limit(8),
{ staleMs: 30000 },
).catch(() => []),
cached(
"auth_latest_users",
30_000,
() =>
db
.select({ id: User.id, username: User.username, look: User.look })
.from(User)
.orderBy(desc(User.accountCreated))
.limit(8),
{ staleMs: 60000 },
).catch(() => []),
]);
return (
<AuthLandingView
data={data}
copy={{
title: t("title"),
subtitle: t("subtitle"),
promoTitle: (
<>
{t("title")} —{" "}
<span className="gradient-text">{data.hotelName}</span>
</>
),
promoSubtitle: t("subtitle"),
usersOnlineLabel: t("usersOnline", { count: data.online }),
whoIsOnlineLabel: t("whoIsOnline"),
newestCitizensLabel: t("newestCitizens"),
}}
<AuthPageFrame
hotelName={hotelName}
logo={logo}
title={tpr("title")}
subtitle={tpr("subtitle")}
form={
<RegisterForm
hotelName={data.hotelName}
captcha={data.captcha}
nonce={data.nonce}
passwordMinLength={passwordPolicy.minLength}
passwordMaxLength={passwordPolicy.maxLength}
hotelName={hotelName}
captcha={{
provider: cfg.provider,
siteKey: cfg.siteKey || undefined,
}}
nonce={nonce}
/>
}
/>
>
<SurfaceCard
className="card-glow relative overflow-hidden p-6"
style={{
borderColor:
"color-mix(in srgb, var(--color-primary) 20%, transparent)",
}}
>
<div
className="absolute inset-0"
style={{
background:
"linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, transparent), color-mix(in srgb, var(--color-accent) 10%, transparent))",
}}
/>
<div aria-hidden="true" className="absolute inset-0 overflow-hidden">
<div className="brand-halo left-1/2 top-0 h-64 w-[460px] max-w-full -translate-x-1/2 -translate-y-1/2" />
<div
className="aurora-blob -left-16 top-6 h-56 w-56"
style={{
background:
"color-mix(in srgb, var(--color-primary) 45%, transparent)",
}}
/>
<div
className="aurora-blob -right-16 bottom-0 h-56 w-56"
style={{
background:
"color-mix(in srgb, var(--color-accent) 40%, transparent)",
animationDelay: "-8s",
}}
/>
</div>
<div className="relative">
<div className="flex items-center gap-4">
<div className="shrink-0 animate-float">
<div className="brand-halo left-1/2 top-1/2 h-52 w-52 -translate-x-1/2 -translate-y-1/2" />
<Image
src="/assets/images/FrankwithBag.gif"
alt="Frank"
width={110}
height={145}
className="relative object-contain drop-shadow-xl"
unoptimized
/>
</div>
<div className="flex-1">
<div
className="inline-flex items-center gap-2 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider mb-2 border"
style={{
borderColor:
"color-mix(in srgb, var(--color-primary) 30%, transparent)",
background:
"color-mix(in srgb, var(--color-surface) 72%, transparent)",
color: "var(--color-primary-readable, var(--color-primary))",
boxShadow:
"0 8px 20px -12px color-mix(in srgb, var(--color-primary) 70%, transparent)",
}}
>
<span className="relative flex h-2 w-2">
<span
className="animate-ping absolute inline-flex h-full w-full rounded-full opacity-75"
style={{ background: "var(--color-primary)" }}
/>
<span
className="relative inline-flex rounded-full h-2 w-2"
style={{ background: "var(--color-primary)" }}
/>
</span>
{tpr("usersOnline", { count: online })}
</div>
<h1
className="text-lg font-black leading-tight"
style={{
color: "var(--color-text-readable)",
fontFamily: "var(--font-nunito)",
}}
>
{tpr("title")} —{" "}
<span className="gradient-text">{hotelName}</span>
</h1>
<p
className="text-xs mt-1.5 leading-relaxed"
style={{ color: "var(--color-text-muted)" }}
>
{tpr("subtitle")}
</p>
</div>
</div>
</div>
</SurfaceCard>
<AuthUsersCards
recentUsers={recentUsers}
latestUsers={latestUsers}
recentTitle={tpr("whoIsOnline")}
latestTitle={tpr("newestCitizens")}
/>
</AuthPageFrame>
);
}
+2 -7
View File
@@ -3,7 +3,6 @@ import { getTranslations } from "next-intl/server";
import { resetPassword } from "@/actions/password-reset";
import Link from "@/components/link";
import { ContentCard } from "@/components/public/ui";
import { passwordPolicy } from "@/lib/auth/password-policy";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.reset");
@@ -42,12 +41,8 @@ export default async function ResetPage({
<input
name="password"
type="password"
placeholder={t("passwordPlaceholder", {
min: passwordPolicy.minLength,
})}
placeholder={t("passwordPlaceholder")}
autoComplete="new-password"
minLength={passwordPolicy.minLength}
maxLength={passwordPolicy.maxLength}
required
/>
<button type="submit" className="btn btn-primary">
@@ -63,7 +58,7 @@ export default async function ResetPage({
marginBottom: 0,
}}
>
{t(errorKey, { min: passwordPolicy.minLength })}
{t(errorKey)}
</p>
) : null}
<p
+1 -1
View File
@@ -16,7 +16,7 @@ const STATE_LABELS: Record<string, { label: string; icon: string }> = {
};
function describeState(state: string): { label: string; icon: string } {
// STATE_LABELS has known keys, fallback provided
// eslint-disable-next-line security/detect-object-injection -- STATE_LABELS has known keys, fallback provided
return STATE_LABELS[state] ?? { label: state || "Unknown", icon: "🚪" };
}
+1
View File
@@ -249,6 +249,7 @@ export default async function ShopPage({
}}
>
{a.iconUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={a.iconUrl}
alt=""
+1
View File
@@ -366,6 +366,7 @@ async function ProfilePage({
flexWrap: "wrap",
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<ProfileImage
src={avatar}
alt={user.username}
+1 -11
View File
@@ -1,6 +1,4 @@
import { notFound } from "next/navigation";
import { NextIntlClientProvider } from "next-intl";
import { getLocale, getMessages } from "next-intl/server";
import type { ReactNode } from "react";
import { env } from "@/env";
import { isHousekeepingPreviewEnabled } from "@/features/housekeeping/foundation/preview-gate";
@@ -29,13 +27,5 @@ export default async function AdminNextLayout({
notFound();
}
// Staff tree: re-install the full catalogue the root layout scopes down to
// the public subset (nested providers replace the parent set).
const [locale, messages] = await Promise.all([getLocale(), getMessages()]);
return (
<NextIntlClientProvider locale={locale} messages={messages}>
{children}
</NextIntlClientProvider>
);
return children;
}
+1
View File
@@ -49,6 +49,7 @@ export function AdsTable({ ads }: { ads: AdRow[] }) {
{ads.map((ad) => (
<tr key={ad.id}>
<td>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={ad.image}
alt=""
+1
View File
@@ -103,6 +103,7 @@ export default async function AdminCalendarCampaign({
<div className="admin-card flex items-center gap-5 mb-6">
{campaign.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img
className="w-24 h-24 object-cover"
src={campaign.image}
+1
View File
@@ -98,6 +98,7 @@ export default async function AdminCalendar() {
className="admin-card hover article"
>
{c.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img className="article-img" src={c.image} alt={c.name} />
) : (
<div className="article-img" />
+1
View File
@@ -96,6 +96,7 @@ export default async function AdminGuildDetailPage({
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex items-center gap-4">
{guild.badge ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${guild.badge}.gif`}
alt=""
+1
View File
@@ -155,6 +155,7 @@ export default async function AdminGuildsPage({
<td className="muted">{g.id}</td>
<td>
{g.badge ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${g.badge}.gif`}
alt=""
+3 -9
View File
@@ -1,8 +1,7 @@
import { eq } from "drizzle-orm";
import { LogOut } from "lucide-react";
import { redirect } from "next/navigation";
import { NextIntlClientProvider } from "next-intl";
import { getLocale, getMessages, getTranslations } from "next-intl/server";
import { getTranslations } from "next-intl/server";
import type { ReactNode } from "react";
import { AdminHubChrome } from "@/components/admin/admin-hub-chrome";
import { AdminMobileWrapper } from "@/components/admin/admin-mobile-wrapper";
@@ -41,13 +40,8 @@ export default async function AdminLayout({
if (!u?.twoFactorConfirmedAt) redirect("/settings/2fa?error=staffrequired");
}
// The root layout ships the reduced catalogue so public pages do not carry
// the ~195 KB of staff-tool strings. Nested providers replace the parent
// set rather than merging, so staff route groups re-install the full one.
const [locale, messages] = await Promise.all([getLocale(), getMessages()]);
return (
<NextIntlClientProvider locale={locale} messages={messages}>
<>
{csrfToken ? <meta name="csrf-token" content={csrfToken} /> : null}
<AdminMobileWrapper sidebar={<Sidebar staff={staff} />}>
<div
@@ -62,7 +56,7 @@ export default async function AdminLayout({
</div>
</AdminMobileWrapper>
<AdminToaster />
</NextIntlClientProvider>
</>
);
}
+1
View File
@@ -94,6 +94,7 @@ export default async function AdminPhotos({
className="admin-card"
style={{ padding: 0, overflow: "hidden" }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={p.url}
alt={t("photoAlt", { id: String(p.id) })}
+2 -2
View File
@@ -62,7 +62,7 @@ function isRecord(v: unknown): v is Record<string, unknown> {
}
function pickString(obj: Record<string, unknown>, key: string): string | null {
// only called with hardcoded keys
// eslint-disable-next-line security/detect-object-injection -- only called with hardcoded keys
const v = obj[key];
return typeof v === "string" && v.trim() !== "" ? v.trim() : null;
}
@@ -112,7 +112,7 @@ function findNumberDeep(
if (typeof value === "number" && Number.isFinite(value)) return value;
if (!isRecord(value)) return null;
for (const key of keys) {
// keys from hardcoded array
// eslint-disable-next-line security/detect-object-injection -- keys from hardcoded array
const v = value[key];
if (typeof v === "number" && Number.isFinite(v)) return v;
if (
@@ -577,6 +577,7 @@ export function FurniClient({ room, items = [], canEdit }: Props) {
)}
<TableCell>
{icon ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={icon}
alt={item.name}
@@ -732,6 +733,7 @@ export function FurniClient({ room, items = [], canEdit }: Props) {
>
<TableCell>
{icon ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={icon}
alt={g.base.name}
+1 -1
View File
@@ -493,7 +493,7 @@ export default async function AdminTheme({
width: 20,
height: 20,
borderRadius: 4,
// k from fixed theme color key list
// eslint-disable-next-line security/detect-object-injection -- k from fixed theme color key list
background: theme.settings[k] ?? "#64748b",
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.2)",
}}
+3 -3
View File
@@ -356,7 +356,7 @@ export async function GET(req: Request) {
]);
const rarity = Object.fromEntries(
// rk from rarityKeys const, i is array index
// eslint-disable-next-line security/detect-object-injection -- rk from rarityKeys const, i is array index
rarityKeys.map((rk, i) => [rk, rarityBoards[i]]),
) as Record<BadgeRarityKey, BoardCore>;
@@ -398,7 +398,7 @@ export async function GET(req: Request) {
totalBadgesViewer = tb;
achievementViewer = ach;
rarityViewer = Object.fromEntries(
// rk from rarityKeys const, i is array index
// eslint-disable-next-line security/detect-object-injection -- rk from rarityKeys const, i is array index
rarityKeys.map((rk, i) => [rk, rvs[i]]),
) as Record<BadgeRarityKey, Partial<BadgeLeaderboardEntry> | undefined>;
}
@@ -414,7 +414,7 @@ export async function GET(req: Request) {
viewerEntry: achievementViewer,
},
rarity: Object.fromEntries(
// rk from rarityKeys const
// eslint-disable-next-line security/detect-object-injection -- rk from rarityKeys const
rarityKeys.map((rk) => [
rk,
{ ...global.rarity[rk], viewerEntry: rarityViewer[rk] },
+1 -1
View File
@@ -110,7 +110,7 @@ export async function GET(req: Request) {
async () =>
type === "credits"
? await loadCreditsRows()
: // type validated to "diamonds"|"duckets"
: // eslint-disable-next-line security/detect-object-injection -- type validated to "diamonds"|"duckets"
await loadCurrencyRows(CURRENCY_TYPE[type]),
// Rankings are re-sorted on every read; serving the previous order for a
// minute keeps a busy leaderboard off the database.
+3 -1
View File
@@ -51,6 +51,7 @@ export async function GET(
headers: SECURITY_HEADERS,
});
}
// eslint-disable-next-line security/detect-non-literal-fs-filename
if (!existsSync(filePath)) {
return new NextResponse("Not found", {
status: 404,
@@ -58,6 +59,7 @@ export async function GET(
});
}
// eslint-disable-next-line security/detect-non-literal-fs-filename
const bytes = await readFile(filePath);
const mime: Record<string, string> = {
".png": "image/png",
@@ -74,7 +76,7 @@ export async function GET(
headers: {
...SECURITY_HEADERS,
...(ext === ".svg" ? { "Content-Disposition": "attachment" } : {}),
// ext validated against ALLOWED_EXT
// eslint-disable-next-line security/detect-object-injection -- ext validated against ALLOWED_EXT
"Content-Type": mime[ext] ?? "application/octet-stream",
"Cache-Control": "public, max-age=3600, must-revalidate",
},
+2
View File
@@ -12,9 +12,11 @@ import { PERMS } from "@/lib/permissions";
*/
export const GET = withAdmin({ permission: PERMS.PAGES_EDIT }, async () => {
const dir = MEDIA_ROOT;
// eslint-disable-next-line security/detect-non-literal-fs-filename
if (!existsSync(dir)) {
return apiOk({ files: [] });
}
// eslint-disable-next-line security/detect-non-literal-fs-filename
const files = readdirSync(dir)
.filter((f) => /\.(png|jpg|jpeg|gif|webp|svg|bmp)$/i.test(f))
.map((f) => {
+1 -1
View File
@@ -35,7 +35,7 @@ function findCount(value: unknown, depth = 0): number | null {
"count",
];
for (const key of keys) {
// keys from hardcoded array
// eslint-disable-next-line security/detect-object-injection -- keys from hardcoded array
const v = value[key];
if (typeof v === "number" && Number.isFinite(v)) return v;
if (
-14
View File
@@ -2893,20 +2893,6 @@ html {
0 0 0 3px color-mix(in srgb, var(--color-primary) 16%, transparent),
0 0 24px -8px color-mix(in srgb, var(--color-primary) 60%, transparent);
}
.auth-input[aria-invalid="true"]:not(:focus) {
border-color: color-mix(in srgb, var(--color-danger) 60%, transparent);
background: color-mix(
in srgb,
var(--color-danger) 6%,
color-mix(in srgb, var(--color-background) 88%, var(--color-surface))
);
}
.auth-input[aria-invalid="true"]:focus {
border-color: var(--color-danger);
box-shadow:
0 0 0 3px color-mix(in srgb, var(--color-danger) 18%, transparent),
0 0 24px -8px color-mix(in srgb, var(--color-danger) 55%, transparent);
}
.auth-input:disabled {
opacity: 0.55;
cursor: not-allowed;
+1 -6
View File
@@ -13,7 +13,6 @@ import { ThemeVars } from "@/components/theme-vars";
import { enforceSiteAccess } from "@/lib/access-guard";
import { getDefaultFaviconDataUri } from "@/lib/default-favicon";
import { resolveHotelName } from "@/lib/hotel-name";
import { publicClientMessages } from "@/lib/i18n-client-messages";
import { siteSettings } from "@/lib/services/site-settings";
import "./globals.css";
@@ -69,11 +68,7 @@ export default async function RootLayout({
}) {
await enforceSiteAccess();
const locale = await getLocale();
// Only the namespaces non-staff routes can reach: the full catalogue is
// ~254 KB and would otherwise be serialised into every public page's RSC
// payload. `/admin`, `/admin-next` and `/mod` install a nested provider
// seeded with the full set (see i18n-client-messages).
const messages = publicClientMessages(await getMessages());
const messages = await getMessages();
const defaultDark = await siteSettings.getBool("default_dark", false);
const nitroUrl = await siteSettings.get("nitro_client_url", "");
const nonce = (await headers()).get("x-nonce") ?? undefined;
+3 -9
View File
@@ -10,8 +10,7 @@ import {
Users,
} from "lucide-react";
import { redirect } from "next/navigation";
import { NextIntlClientProvider } from "next-intl";
import { getLocale, getMessages, getTranslations } from "next-intl/server";
import { getTranslations } from "next-intl/server";
import type { ReactNode } from "react";
import { LanguageSwitcher } from "@/components/language-switcher";
import Link from "@/components/link";
@@ -82,13 +81,8 @@ export default async function ModLayout({ children }: { children: ReactNode }) {
{ href: "/mod/team", label: t("nav.team"), icon: Users, show: showTeam },
].filter((item) => item.show);
// The root layout ships the reduced catalogue so public pages do not carry
// the ~195 KB of staff-tool strings. Nested providers replace the parent
// set rather than merging, so staff route groups re-install the full one.
const [locale, messages] = await Promise.all([getLocale(), getMessages()]);
return (
<NextIntlClientProvider locale={locale} messages={messages}>
<>
{csrfToken ? <meta name="csrf-token" content={csrfToken} /> : null}
<div
data-admin
@@ -146,6 +140,6 @@ export default async function ModLayout({ children }: { children: ReactNode }) {
</header>
<main className="mx-auto max-w-6xl p-4 sm:p-6">{children}</main>
</div>
</NextIntlClientProvider>
</>
);
}
+1
View File
@@ -45,6 +45,7 @@ export function ArticleCard({
<div className="admin-card overflow-hidden flex flex-col">
{confirmDialog}
<div className="relative w-full aspect-[16/9] bg-[var(--admin-canvas)]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={image || ""}
alt={title}
+1
View File
@@ -296,6 +296,7 @@ export function ArticleForm({
/>
</div>
{image && !imageError ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={image}
alt={t("imagePreview")}
@@ -715,6 +715,7 @@ function FurniThumb({
className="flex items-center justify-center rounded bg-muted/50"
style={{ width: size, height: size }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={primary}
alt={item.itemName}
@@ -893,7 +893,7 @@ export function SortableTree({
handleToggleEnabled,
handleAddSubpage,
],
);
); // eslint-disable-line react-hooks/exhaustive-deps
return (
<div
@@ -34,6 +34,7 @@ export function FurniIcon({
return (
<div className="w-10 h-10 rounded bg-muted/50 flex items-center justify-center overflow-hidden">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={localUrl}
alt={classname}
@@ -231,6 +231,7 @@ function FurniThumb({
return (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded bg-muted/50">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={primary}
alt={classname}
@@ -192,6 +192,7 @@ export function ShopFurniIcon({
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={primary}
alt={classname}
+1
View File
@@ -226,6 +226,7 @@ export function AdminMediaGrid() {
}}
>
<div className="relative">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={validImageUrl(f.url)}
alt={f.name}
+1
View File
@@ -156,6 +156,7 @@ export function MediaPicker({
e.currentTarget.style.borderColor = "transparent";
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={validImageUrl(f.url)}
alt={f.name}
@@ -51,6 +51,7 @@ export function FurniThumb({
className="flex items-center justify-center rounded bg-muted/50"
style={{ width: size, height: size }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={primary}
alt={item.itemName}
@@ -405,7 +405,7 @@ export function ThemeManagerPanel({
width: 14,
height: 14,
borderRadius: 3,
// k from fixed key list
// eslint-disable-next-line security/detect-object-injection -- k from fixed key list
background: palette.light[k],
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.15)",
marginLeft: -3,
-38
View File
@@ -1,38 +0,0 @@
"use client";
import type { ReactNode } from "react";
interface AuthAlertProps {
children: ReactNode;
/** Tint of the box: red for errors, green for confirmations. */
variant?: "error" | "success";
/** Extra classes, e.g. spacing (`mb-4`) or entrance animation. */
className?: string;
/** Optional id so inputs can point at the message via `aria-describedby`. */
id?: string;
}
/**
* The single status/error banner for the auth forms. Uses `role="alert"` for
* failures (screen readers announce them immediately) and `role="status"` for
* confirmations, so the two are wired correctly instead of every caller
* repeating the markup and risking an unannounced message.
*/
export function AuthAlert({
children,
variant = "error",
className = "",
id,
}: AuthAlertProps) {
return (
<p
id={id}
role={variant === "error" ? "alert" : "status"}
className={`auth-alert animate-fade-in-up m-0 ${
variant === "success" ? "auth-alert--success" : ""
} ${className}`}
>
{children}
</p>
);
}
-112
View File
@@ -1,112 +0,0 @@
"use client";
import type { LucideIcon } from "lucide-react";
import type { CSSProperties, ReactNode, Ref } from "react";
const ICON_CLASS =
"pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2";
export interface AuthFieldProps {
id: string;
name: string;
label: string;
icon: LucideIcon;
type?: string;
/** Controlled value; omit both `value` and `onChange` for an uncontrolled field. */
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
autoComplete?: string;
inputMode?:
| "none"
| "text"
| "tel"
| "url"
| "email"
| "numeric"
| "decimal"
| "search";
required?: boolean;
disabled?: boolean;
minLength?: number;
maxLength?: number;
/** Marks the field with `aria-invalid` and a danger border. */
invalid?: boolean;
/** id of the element describing this field (e.g. an error banner). */
describedBy?: string;
iconColor?: string;
inputStyle?: CSSProperties;
/** Absolutely positioned control inside the input, e.g. `PasswordToggle`. */
trailing?: ReactNode;
/** Content rendered below the field: strength meter, policy hints, mismatch. */
children?: ReactNode;
/** Extra classes on the wrapping `space-y` container. */
className?: string;
inputRef?: Ref<HTMLInputElement>;
}
/**
* One labelled, icon-prefixed auth input. Login and register repeat this markup
* seven times; centralising it keeps the icon, focus and `aria-describedby`
* behaviour identical everywhere.
*/
export function AuthField({
id,
name,
label,
icon: Icon,
type = "text",
value,
onChange,
placeholder,
autoComplete,
inputMode,
required,
disabled,
minLength,
maxLength,
invalid,
describedBy,
iconColor = "var(--color-text-muted)",
inputStyle,
trailing,
children,
className = "",
inputRef,
}: AuthFieldProps) {
return (
<div className={`space-y-1.5 ${className}`}>
<label htmlFor={id} className="auth-label">
{label}
</label>
<div className="relative">
<Icon
aria-hidden="true"
className={ICON_CLASS}
style={{ color: iconColor }}
/>
<input
ref={inputRef}
id={id}
name={name}
type={type}
value={value}
onChange={onChange ? (e) => onChange(e.target.value) : undefined}
placeholder={placeholder}
autoComplete={autoComplete}
inputMode={inputMode}
required={required}
disabled={disabled}
minLength={minLength}
maxLength={maxLength}
aria-invalid={invalid ? true : undefined}
aria-describedby={describedBy}
className={`auth-input has-icon ${trailing ? "has-action" : ""}`}
style={inputStyle}
/>
{trailing}
</div>
{children}
</div>
);
}
-125
View File
@@ -1,125 +0,0 @@
import { desc, eq } from "drizzle-orm";
import { headers } from "next/headers";
import type { ReactNode } from "react";
import {
AuthPageFrame,
AuthUsersCards,
type PublicUser,
} from "@/components/auth/auth-page-frame";
import { AuthPromoCard } from "@/components/auth/auth-promo-card";
import type { CaptchaPublicConfig } from "@/components/auth/captcha-widget";
import { cached } from "@/lib/cache";
import { db, User } from "@/lib/db";
import { resolveHotelName } from "@/lib/hotel-name";
import { captchaConfig } from "@/lib/services/captcha";
import { cachedOnlineCount } from "@/lib/services/public-counters";
import { siteSettings } from "@/lib/services/site-settings";
export interface AuthLandingData {
hotelName: string;
logo: string | null;
nonce?: string;
/** Ready to spread onto a form's `captcha` prop (secrets never included). */
captcha: CaptchaPublicConfig;
online: number;
recentUsers: PublicUser[];
latestUsers: PublicUser[];
}
/**
* Everything `/login` and `/register` need around their form: hotel identity,
* captcha config, the CSP nonce and the "who is online" / "newest citizens"
* rails. The rosters are memoised, so both pages share one cache entry. Kept in
* one place because the two landing pages are otherwise identical.
*/
export async function loadAuthLanding(): Promise<AuthLandingData> {
const [hotelName, cfg, logo] = await Promise.all([
resolveHotelName(),
captchaConfig(),
siteSettings.get("cms_logo", ""),
]);
const captcha: CaptchaPublicConfig = {
provider: cfg.provider,
siteKey: cfg.siteKey || undefined,
field: cfg.field || undefined,
};
const nonce = (await headers()).get("x-nonce") ?? undefined;
const [online, recentUsers, latestUsers] = await Promise.all([
cachedOnlineCount().catch(() => 0),
cached(
"auth_online_users",
10_000,
() =>
db
.select({ id: User.id, username: User.username, look: User.look })
.from(User)
.where(eq(User.online, "1"))
.limit(8),
{ staleMs: 30000 },
).catch(() => []),
cached(
"auth_latest_users",
30_000,
() =>
db
.select({ id: User.id, username: User.username, look: User.look })
.from(User)
.orderBy(desc(User.accountCreated))
.limit(8),
{ staleMs: 60000 },
).catch(() => []),
]);
return { hotelName, captcha, logo, nonce, online, recentUsers, latestUsers };
}
/** Locale-resolved strings the landing frame needs, keyed to the page. */
export interface AuthLandingCopy {
title: string;
subtitle: ReactNode;
/** Optional success notice rendered above the form (e.g. "registered"). */
notice?: ReactNode;
promoTitle: ReactNode;
promoSubtitle: ReactNode;
usersOnlineLabel: string;
whoIsOnlineLabel: string;
newestCitizensLabel: string;
}
interface AuthLandingViewProps {
data: AuthLandingData;
copy: AuthLandingCopy;
form: ReactNode;
}
/**
* Presentational shell shared by the auth landing pages: top bar, promo panel
* and user rails around a caller-supplied form. All data and copy come from the
* page so this component stays free of queries and translations.
*/
export function AuthLandingView({ data, copy, form }: AuthLandingViewProps) {
return (
<AuthPageFrame
hotelName={data.hotelName}
logo={data.logo}
online={data.online}
title={copy.title}
subtitle={copy.subtitle}
notice={copy.notice}
form={form}
>
<AuthPromoCard
onlineLabel={copy.usersOnlineLabel}
title={copy.promoTitle}
subtitle={copy.promoSubtitle}
/>
<AuthUsersCards
recentUsers={data.recentUsers}
latestUsers={data.latestUsers}
recentTitle={copy.whoIsOnlineLabel}
latestTitle={copy.newestCitizensLabel}
/>
</AuthPageFrame>
);
}
+1 -4
View File
@@ -128,8 +128,6 @@ function AvatarGrid({
interface AuthPageFrameProps {
hotelName: string;
logo?: string | null;
/** Live online count shown in the top bar; omit to hide it. */
online?: number;
/** Title of the form card on the right. */
title: string;
/** One-line intro above the form. */
@@ -150,7 +148,6 @@ interface AuthPageFrameProps {
export function AuthPageFrame({
hotelName,
logo,
online,
title,
subtitle,
notice,
@@ -161,7 +158,7 @@ export function AuthPageFrame({
<div className="mx-auto w-full max-w-6xl flex flex-col gap-8 pb-16">
<div className="relative">
<AuthAurora />
<AuthTopBar hotelName={hotelName} logo={logo} online={online} />
<AuthTopBar hotelName={hotelName} logo={logo} />
</div>
<Reveal>
-107
View File
@@ -1,107 +0,0 @@
import Image from "next/image";
import type { ReactNode } from "react";
import { SurfaceCard } from "@/components/surface-card";
interface AuthPromoCardProps {
/** Fully formatted "N users online" label. */
onlineLabel: string;
title: ReactNode;
subtitle: ReactNode;
}
/**
* The single promo panel both `/login` and `/register` show above the form:
* Frank, the online badge and a short welcome. Keeping it in one place means
* the two auth pages can never drift apart visually again.
*/
export function AuthPromoCard({
onlineLabel,
title,
subtitle,
}: AuthPromoCardProps) {
return (
<SurfaceCard
className="card-glow relative overflow-hidden p-6 text-center"
style={{
borderColor:
"color-mix(in srgb, var(--color-primary) 20%, transparent)",
}}
>
<div
className="absolute inset-0"
style={{
background:
"linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, transparent), color-mix(in srgb, var(--color-accent) 10%, transparent))",
}}
/>
<div aria-hidden="true" className="absolute inset-0 overflow-hidden">
<div className="brand-halo left-1/2 top-0 h-64 w-[460px] max-w-full -translate-x-1/2 -translate-y-1/2" />
<div
className="aurora-blob -left-16 top-10 h-56 w-56"
style={{
background:
"color-mix(in srgb, var(--color-primary) 45%, transparent)",
}}
/>
<div
className="aurora-blob -right-16 bottom-0 h-56 w-56"
style={{
background:
"color-mix(in srgb, var(--color-accent) 40%, transparent)",
animationDelay: "-8s",
}}
/>
</div>
<div className="relative">
<div className="brand-halo left-1/2 top-8 h-52 w-52 -translate-x-1/2" />
<Image
src="/assets/images/FrankwithBag.gif"
alt="Frank"
width={130}
height={170}
className="relative object-contain mx-auto drop-shadow-xl animate-float"
unoptimized
/>
<div
className="inline-flex items-center gap-2 px-3.5 py-1 rounded-full text-xs font-bold uppercase tracking-wider mt-4 mb-3 border"
style={{
borderColor:
"color-mix(in srgb, var(--color-primary) 30%, transparent)",
background:
"color-mix(in srgb, var(--color-surface) 72%, transparent)",
color: "var(--color-primary-readable, var(--color-primary))",
boxShadow:
"0 8px 20px -12px color-mix(in srgb, var(--color-primary) 70%, transparent)",
}}
>
<span className="relative flex h-2 w-2">
<span
className="animate-ping absolute inline-flex h-full w-full rounded-full opacity-75"
style={{ background: "var(--color-primary)" }}
/>
<span
className="relative inline-flex rounded-full h-2 w-2"
style={{ background: "var(--color-primary)" }}
/>
</span>
{onlineLabel}
</div>
<h1
className="text-xl font-black"
style={{
color: "var(--color-text-readable)",
fontFamily: "var(--font-nunito)",
}}
>
{title}
</h1>
<p
className="text-xs mt-1.5 mx-auto max-w-[240px] leading-relaxed"
style={{ color: "var(--color-text-muted)" }}
>
{subtitle}
</p>
</div>
</SurfaceCard>
);
}
+16 -36
View File
@@ -1,32 +1,29 @@
import Image from "next/image";
import { Clock } from "@/components/clock";
import { LanguageSwitcher } from "@/components/language-switcher";
import { LiveOnlineCount } from "@/components/live-online-count";
import { Reveal } from "@/components/motion-reveal";
import { ThemeSwitcher } from "@/components/theme-switcher";
import { ICON_LIGHTHOUSE } from "@/lib/site-icons";
/**
* Shared frosted-glass top bar for the public pages (index, login, register).
* Shows the hotel logo on the left and the live "online" counter (which carries
* the hotel name), language / clock / theme controls on the right. One
* component so every public page keeps the exact same chrome instead of
* duplicating it.
* Shared frosted-glass top bar for the public auth pages (login, register,
* etc.). Shows the hotel logo + name on the left and language / clock / theme
* controls on the right. Defaults mirror the homepage top bar so auth pages
* share one consistent look.
*/
export function AuthTopBar({
hotelName,
logo,
online,
showHotelName = true,
}: {
hotelName: string;
logo?: string | null;
/** Live online count; omit (or pass null) to hide the indicator. */
online?: number | null;
showHotelName?: boolean;
}) {
return (
<Reveal>
<div
className="relative flex items-center justify-between gap-3 rounded-full border px-3 py-2.5 sm:px-5"
className="relative flex items-center justify-between rounded-full border px-3 py-2.5 sm:px-5"
style={{
background:
"linear-gradient(90deg, color-mix(in srgb, var(--color-surface) 62%, transparent), color-mix(in srgb, var(--color-primary) 7%, transparent))",
@@ -38,7 +35,7 @@ export function AuthTopBar({
"0 1px 3px rgba(0,0,0,0.04), 0 0 0 0.5px color-mix(in srgb, var(--color-text-muted) 6%, transparent)",
}}
>
<div className="flex min-w-0 items-center gap-2 sm:gap-3">
<div className="flex items-center gap-2 sm:gap-3">
{logo ? (
<Image
src={logo}
@@ -70,6 +67,14 @@ export function AuthTopBar({
/>
</div>
)}
{showHotelName ? (
<span
className="hidden text-sm font-extrabold uppercase tracking-wide sm:inline"
style={{ color: "var(--color-text-readable)" }}
>
{hotelName}
</span>
) : null}
</div>
<div
className="flex items-center gap-1 sm:gap-1.5 rounded-full px-1.5 py-1 sm:px-2"
@@ -78,31 +83,6 @@ export function AuthTopBar({
"color-mix(in srgb, var(--color-text-muted) 5%, transparent)",
}}
>
{typeof online === "number" && (
<span
className="flex items-center gap-1.5 px-1"
style={{ color: "var(--color-text-readable)" }}
>
<span className="relative flex h-2 w-2 shrink-0">
<span
className="absolute inline-flex h-full w-full animate-ping rounded-full opacity-70"
style={{ background: "var(--color-primary)" }}
/>
<span
className="relative inline-flex h-2 w-2 rounded-full"
style={{ background: "var(--color-primary)" }}
/>
</span>
<span
className="text-xs font-black tabular-nums"
style={{
color: "var(--color-primary-readable, var(--color-primary))",
}}
>
<LiveOnlineCount initial={online} hotelName={hotelName} />
</span>
</span>
)}
<LanguageSwitcher />
<div
className="hidden sm:block w-px h-5"
+88 -74
View File
@@ -3,10 +3,8 @@
import { KeyRound, Lock, UserRound } from "lucide-react";
import { signIn } from "next-auth/react";
import { useTranslations } from "next-intl";
import { type FormEvent, useEffect, useId, useRef, useState } from "react";
import { type FormEvent, useId, useState } from "react";
import { precheckLogin } from "@/actions/auth-precheck";
import { AuthAlert } from "@/components/auth/auth-alert";
import { AuthField } from "@/components/auth/auth-field";
import {
type CaptchaPublicConfig,
CaptchaWidget,
@@ -53,17 +51,8 @@ export function LoginForm({
const [unverified, setUnverified] = useState(false);
const [pending, setPending] = useState(false);
const [locked, setLocked] = useState(false);
const codeRef = useRef<HTMLInputElement>(null);
// The 2FA field only exists after the pre-check asks for a code; move focus
// there automatically so the visitor can keep typing without reaching for
// the mouse (the lint rule rules out a bare `autoFocus`).
useEffect(() => {
if (needs2fa) codeRef.current?.focus();
}, [needs2fa]);
const showFooter = variant === "page";
const compact = variant === "compact";
const lockCredentials =
needs2fa || locked ? "opacity-50 pointer-events-none" : "";
@@ -126,9 +115,7 @@ export function LoginForm({
<>
{needs2fa && (
<p
className={`animate-fade-in-up rounded-xl border px-3 py-2.5 text-sm font-semibold ${
compact ? "mb-3" : "mb-4"
}`}
className="animate-fade-in-up mb-4 rounded-xl border px-3 py-2.5 text-sm font-semibold"
style={{
color: "var(--color-text-readable)",
background:
@@ -144,76 +131,105 @@ export function LoginForm({
<form
onSubmit={onSubmit}
aria-label={t("signIn")}
className={`relative flex flex-col ${compact ? "gap-3" : "gap-4"}`}
className="relative flex flex-col gap-4"
>
<AuthField
id={`${fieldId}-username`}
name="username"
label={t("username")}
icon={UserRound}
value={username}
onChange={setUsername}
placeholder={t("usernamePlaceholder")}
autoComplete="username"
disabled={needs2fa || pending}
required
invalid={!!error}
describedBy={error ? `${fieldId}-error` : undefined}
className={lockCredentials}
/>
<div className={`space-y-1.5 ${lockCredentials}`}>
<label htmlFor={`${fieldId}-username`} className="auth-label">
{t("username")}
</label>
<div className="relative">
<UserRound
aria-hidden="true"
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
style={{ color: "var(--color-text-muted)" }}
/>
<input
id={`${fieldId}-username`}
name="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t("usernamePlaceholder")}
autoComplete="username"
disabled={needs2fa}
required
className="auth-input has-icon"
/>
</div>
</div>
<AuthField
id={`${fieldId}-password`}
name="password"
label={t("password")}
icon={Lock}
type={showPassword ? "text" : "password"}
value={password}
onChange={setPassword}
placeholder={t("passwordPlaceholder")}
autoComplete="current-password"
disabled={needs2fa || pending}
required
invalid={!!error}
describedBy={error ? `${fieldId}-error` : undefined}
className={lockCredentials}
trailing={
<div className={`space-y-1.5 ${lockCredentials}`}>
<label htmlFor={`${fieldId}-password`} className="auth-label">
{t("password")}
</label>
<div className="relative">
<Lock
aria-hidden="true"
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
style={{ color: "var(--color-text-muted)" }}
/>
<input
id={`${fieldId}-password`}
name="password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("passwordPlaceholder")}
autoComplete="current-password"
disabled={needs2fa}
required
className="auth-input has-icon has-action"
/>
<PasswordToggle
show={showPassword}
onToggle={() => setShowPassword((v) => !v)}
labelShow={t("showPassword")}
labelHide={t("hidePassword")}
/>
}
/>
</div>
</div>
{needs2fa ? (
<AuthField
id={`${fieldId}-2fa`}
name="code"
label={t("codePlaceholder")}
icon={KeyRound}
iconColor="var(--color-primary)"
value={code}
onChange={setCode}
placeholder={t("codePlaceholder")}
inputMode="numeric"
autoComplete="one-time-code"
disabled={pending}
invalid={!!error}
describedBy={error ? `${fieldId}-error` : undefined}
className="animate-scale-in"
inputRef={codeRef}
inputStyle={{
borderColor:
"color-mix(in srgb, var(--color-primary) 45%, transparent)",
}}
/>
<div className="animate-scale-in space-y-1.5">
<label htmlFor={`${fieldId}-2fa`} className="auth-label">
{t("codePlaceholder")}
</label>
<div className="relative">
<KeyRound
aria-hidden="true"
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
style={{ color: "var(--color-primary)" }}
/>
<input
id={`${fieldId}-2fa`}
name="code"
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t("codePlaceholder")}
inputMode="numeric"
autoComplete="one-time-code"
className="auth-input has-icon"
style={{
borderColor:
"color-mix(in srgb, var(--color-primary) 45%, transparent)",
}}
/>
</div>
</div>
) : (
<CaptchaWidget captcha={captcha} nonce={nonce} />
)}
{error && <AuthAlert id={`${fieldId}-error`}>{error}</AuthAlert>}
{error && (
<p
role="alert"
aria-live="polite"
className="auth-alert animate-fade-in-up m-0"
>
{error}
</p>
)}
{unverified && (
<p
@@ -233,9 +249,7 @@ export function LoginForm({
<button
type="submit"
disabled={pending || locked}
className={`btn-brand btn-shine w-full cursor-pointer text-sm font-extrabold uppercase tracking-wider disabled:opacity-60 ${
compact ? "py-3" : "py-3.5"
}`}
className="btn-brand btn-shine w-full cursor-pointer py-3.5 text-sm font-extrabold uppercase tracking-wider disabled:opacity-60"
>
{pending ? (
<span className="inline-flex items-center justify-center gap-2">
+174 -124
View File
@@ -4,7 +4,6 @@ import {
Check,
KeyRound,
Lock,
type LucideIcon,
Mail,
ShieldCheck,
UserRound,
@@ -19,8 +18,6 @@ import {
type RegisterState,
register,
} from "@/actions/register";
import { AuthAlert } from "@/components/auth/auth-alert";
import { AuthField } from "@/components/auth/auth-field";
import { PasswordToggle } from "@/components/auth/password-toggle";
import Link from "@/components/link";
import { signInWithTransientRetry } from "@/lib/auth/sign-in-retry";
@@ -30,23 +27,19 @@ interface RegisterFormProps {
captcha: { provider: string; siteKey?: string };
error?: string;
nonce?: string;
/** Effective policy from `PASSWORD_MIN_LENGTH` / `PASSWORD_MAX_LENGTH`. */
passwordMinLength: number;
passwordMaxLength: number;
}
function passwordStrength(
pw: string,
t: (key: string) => string,
minLength: number,
): {
score: number;
label: string;
color: string;
} {
let score = 0;
if (pw.length >= minLength) score += 1;
if (pw.length >= minLength + 4) score += 1;
if (pw.length >= 8) score += 1;
if (pw.length >= 12) score += 1;
if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) score += 1;
if (/\d/.test(pw)) score += 1;
if (/[^a-zA-Z0-9]/.test(pw)) score += 1;
@@ -57,58 +50,11 @@ function passwordStrength(
return { score: 5, label: t("strengthStrong"), color: "#16a34a" };
}
/** The numbered section rule above each group of fields ("01 Account", ...). */
function AuthSectionHeader({
icon: Icon,
label,
step,
}: {
icon: LucideIcon;
label: string;
step: string;
}) {
const accent = "var(--color-primary-readable, var(--color-primary))";
return (
<div className="flex items-center gap-2.5">
<span
aria-hidden="true"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg"
style={{
background:
"color-mix(in srgb, var(--color-primary) 12%, transparent)",
boxShadow:
"inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 22%, transparent)",
}}
>
<Icon className="h-3.5 w-3.5" style={{ color: accent }} />
</span>
<span
className="text-[11px] font-black uppercase tracking-widest"
style={{ color: accent }}
>
{label}
</span>
<span
className="h-px flex-1"
style={{
background:
"linear-gradient(90deg, color-mix(in srgb, var(--color-text-muted) 18%, transparent), transparent)",
}}
/>
<span aria-hidden="true" className="number-mark text-lg">
{step}
</span>
</div>
);
}
export function RegisterForm({
hotelName,
captcha,
error,
nonce,
passwordMinLength,
passwordMaxLength,
}: RegisterFormProps) {
const t = useTranslations("pages.register");
const showCaptcha = captcha.provider !== "none" && !!captcha.siteKey;
@@ -144,7 +90,7 @@ export function RegisterForm({
);
const autoLoginStartedRef = useRef(false);
const [referralCode, setReferralCode] = useState("");
const strength = passwordStrength(password, t, passwordMinLength);
const strength = passwordStrength(password, t);
/**
* The password policy the server enforces, evaluated live so the visitor can
@@ -152,10 +98,7 @@ export function RegisterForm({
* single error back.
*/
const requirements = [
{
label: t("passwordMinLength", { min: passwordMinLength }),
met: password.length >= passwordMinLength,
},
{ label: t("passwordMinLength"), met: password.length >= 12 },
{ label: t("passwordUpper"), met: /[A-Z]/.test(password) },
{ label: t("passwordLower"), met: /[a-z]/.test(password) },
{ label: t("passwordDigit"), met: /\d/.test(password) },
@@ -240,7 +183,15 @@ export function RegisterForm({
}}
className="flex flex-col gap-6"
>
{errorMessage && <AuthAlert>{errorMessage}</AuthAlert>}
{errorMessage && (
<p
role="alert"
aria-live="polite"
className="auth-alert animate-fade-in-up m-0"
>
{errorMessage}
</p>
)}
{referralCode && (
<div
@@ -261,65 +212,156 @@ export function RegisterForm({
{/* Username & Email */}
<div className="space-y-2">
<AuthSectionHeader
icon={UserRound}
label={t("accountDetails")}
step="01"
/>
<div className="flex items-center gap-2.5">
<span
aria-hidden="true"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg"
style={{
background:
"color-mix(in srgb, var(--color-primary) 12%, transparent)",
boxShadow:
"inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 22%, transparent)",
}}
>
<UserRound
className="h-3.5 w-3.5"
style={{
color: "var(--color-primary-readable, var(--color-primary))",
}}
/>
</span>
<span
className="text-[11px] font-black uppercase tracking-widest"
style={{
color: "var(--color-primary-readable, var(--color-primary))",
}}
>
{t("accountDetails")}
</span>
<span
className="h-px flex-1"
style={{
background:
"linear-gradient(90deg, color-mix(in srgb, var(--color-text-muted) 18%, transparent), transparent)",
}}
/>
<span aria-hidden="true" className="number-mark text-lg">
01
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 stagger-children">
<AuthField
id="username"
name="username"
label={t("username")}
icon={UserRound}
placeholder={t("usernamePlaceholder")}
autoComplete="username"
required
/>
<AuthField
id="mail"
name="mail"
label={t("email")}
icon={Mail}
type="email"
placeholder={t("emailPlaceholder")}
autoComplete="email"
/>
<div className="space-y-1.5">
<label htmlFor="username" className="auth-label">
{t("username")}
</label>
<div className="relative">
<UserRound
aria-hidden="true"
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
style={{ color: "var(--color-text-muted)" }}
/>
<input
id="username"
name="username"
type="text"
placeholder={t("usernamePlaceholder")}
autoComplete="username"
className="auth-input has-icon"
required
/>
</div>
</div>
<div className="space-y-1.5">
<label htmlFor="mail" className="auth-label">
{t("email")}
</label>
<div className="relative">
<Mail
aria-hidden="true"
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
style={{ color: "var(--color-text-muted)" }}
/>
<input
id="mail"
name="mail"
type="email"
placeholder={t("emailPlaceholder")}
autoComplete="email"
className="auth-input has-icon"
/>
</div>
</div>
</div>
</div>
{/* Password & Confirm */}
<div className="space-y-2">
<AuthSectionHeader
icon={KeyRound}
label={t("credentials")}
step="02"
/>
<div className="flex items-center gap-2.5">
<span
aria-hidden="true"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg"
style={{
background:
"color-mix(in srgb, var(--color-primary) 12%, transparent)",
boxShadow:
"inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 22%, transparent)",
}}
>
<KeyRound
className="h-3.5 w-3.5"
style={{
color: "var(--color-primary-readable, var(--color-primary))",
}}
/>
</span>
<span
className="text-[11px] font-black uppercase tracking-widest"
style={{
color: "var(--color-primary-readable, var(--color-primary))",
}}
>
{t("credentials")}
</span>
<span
className="h-px flex-1"
style={{
background:
"linear-gradient(90deg, color-mix(in srgb, var(--color-text-muted) 18%, transparent), transparent)",
}}
/>
<span aria-hidden="true" className="number-mark text-lg">
02
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 stagger-children">
<AuthField
id="password"
name="password"
label={t("password")}
icon={Lock}
type={showPassword ? "text" : "password"}
value={password}
onChange={setPassword}
placeholder={t("passwordPlaceholder", {
min: passwordMinLength,
})}
autoComplete="new-password"
required
minLength={passwordMinLength}
maxLength={passwordMaxLength}
trailing={
<div className="space-y-1.5">
<label htmlFor="password" className="auth-label">
{t("password")}
</label>
<div className="relative">
<Lock
aria-hidden="true"
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
style={{ color: "var(--color-text-muted)" }}
/>
<input
id="password"
name="password"
type={showPassword ? "text" : "password"}
placeholder={t("passwordPlaceholder")}
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="auth-input has-icon has-action"
required
/>
<PasswordToggle
show={showPassword}
onToggle={() => setShowPassword((v) => !v)}
labelShow={t("showPassword")}
labelHide={t("hidePassword")}
/>
}
>
</div>
{password.length > 0 && (
<div className="animate-fade-in-up space-y-1.5 pt-0.5">
<div className="flex gap-1.5">
@@ -375,27 +417,35 @@ export function RegisterForm({
</li>
))}
</ul>
</AuthField>
<AuthField
id="password_confirmation"
name="password_confirmation"
label={t("confirmPassword")}
icon={Lock}
type={showConfirm ? "text" : "password"}
value={confirmPassword}
onChange={setConfirmPassword}
placeholder={t("confirmPasswordPlaceholder")}
autoComplete="new-password"
required
trailing={
</div>
<div className="space-y-1.5">
<label htmlFor="password_confirmation" className="auth-label">
{t("confirmPassword")}
</label>
<div className="relative">
<Lock
aria-hidden="true"
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
style={{ color: "var(--color-text-muted)" }}
/>
<input
id="password_confirmation"
name="password_confirmation"
type={showConfirm ? "text" : "password"}
placeholder={t("confirmPasswordPlaceholder")}
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="auth-input has-icon has-action"
required
/>
<PasswordToggle
show={showConfirm}
onToggle={() => setShowConfirm((v) => !v)}
labelShow={t("showPassword")}
labelHide={t("hidePassword")}
/>
}
>
</div>
{mismatch && (
<p
className="animate-fade-in-up text-xs font-bold"
@@ -404,7 +454,7 @@ export function RegisterForm({
{t("passwordsDontMatch")}
</p>
)}
</AuthField>
</div>
</div>
</div>
+1 -1
View File
@@ -44,7 +44,7 @@ export function NavbarColorPicker() {
const [mounted, setMounted] = useState(false);
function apply(index: number) {
// index validated by findIndex + Math.max, guard below
// eslint-disable-next-line security/detect-object-injection -- index validated by findIndex + Math.max, guard below
const c = COLORS[index];
if (!c) return;
document.documentElement.style.setProperty("--color-navbar", c.value);
+3 -1
View File
@@ -29,7 +29,7 @@ export function PhotoLightbox({ photos }: { photos: LightboxPhoto[] }) {
// has to hold the overlay itself.
const [closing, setClosing] = useState(false);
const exitTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// openIndex is numeric array index, guarded by null check
// eslint-disable-next-line security/detect-object-injection -- openIndex is numeric array index, guarded by null check
const active = openIndex !== null ? photos[openIndex] : null;
const close = useCallback(() => {
@@ -97,6 +97,7 @@ export function PhotoLightbox({ photos }: { photos: LightboxPhoto[] }) {
}}
aria-label={p.alt}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={p.url}
alt={p.alt}
@@ -179,6 +180,7 @@ export function PhotoLightbox({ photos }: { photos: LightboxPhoto[] }) {
cursor: "default",
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={active.url}
alt={active.alt}
+1 -1
View File
@@ -59,7 +59,7 @@ function scanCharWidths(
const w = end - start;
if (w > 0) {
// ci increments within CHAR_ORDER bounds
// eslint-disable-next-line security/detect-object-injection -- ci increments within CHAR_ORDER bounds
chars.push({ char: CHAR_ORDER[ci], x: start, w });
ci++;
}
+1
View File
@@ -31,6 +31,7 @@ export function AvatarImage({
(options?.headOnly ? "l" : max <= 40 ? "s" : max <= 80 ? "m" : "l");
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={getAvatarUrl(figure, { ...options, size: autoSize })}
alt={alt}
@@ -11,7 +11,7 @@ const compactCallSites = new Map<string, number>([
["src/app/(site)/leaderboard/page.tsx", 1],
["src/app/(site)/me/page.tsx", 1],
["src/app/(site)/messages/page.tsx", 2],
["src/app/(site)/page.tsx", 1],
["src/app/(site)/page.tsx", 2],
["src/app/(site)/radio/leaderboard/page.tsx", 1],
["src/app/(site)/radio/shouts/page.tsx", 1],
["src/app/(site)/rankings/page.tsx", 1],
-5
View File
@@ -71,11 +71,6 @@ const schema = z
// bcrypt cost factor used for new password hashes.
BCRYPT_COST: z.coerce.number().int().min(4).max(31).default(12),
// Password-length policy for every new password (register, password reset
// and staff-created accounts). The minimum may be set between 5 and 30
// characters, the maximum between 30 and 128, so min always fits under max.
PASSWORD_MIN_LENGTH: z.coerce.number().int().min(5).max(30).default(12),
PASSWORD_MAX_LENGTH: z.coerce.number().int().min(30).max(128).default(128),
// Filesystem dir the badge uploader writes <code>.gif into (the emulator's
// badge image folder, e.g. .../assets/c_images/album1584). Upload is disabled
// when unset.
@@ -41,8 +41,6 @@ const routeMocks = vi.hoisted(() => {
}
return translate;
}),
getLocale: vi.fn(async () => "en"),
getMessages: vi.fn(async () => ({})),
notFound: vi.fn((): never => {
throw new Error("NEXT_NOT_FOUND");
}),
@@ -60,8 +58,6 @@ vi.mock("next/navigation", () => ({
}));
vi.mock("next-intl/server", () => ({
getTranslations: routeMocks.getTranslations,
getLocale: routeMocks.getLocale,
getMessages: routeMocks.getMessages,
}));
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
getHousekeepingCapabilityContext: routeMocks.getHousekeepingCapabilityContext,
+1 -14
View File
@@ -1,7 +1,6 @@
import { eq } from "drizzle-orm";
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { cache } from "react";
import { env } from "@/env";
import { getCachedJwtVersion } from "@/lib/auth/jwt-version-cache";
import {
@@ -26,7 +25,7 @@ import { db, User } from "@/lib/db";
import { logger } from "@/lib/logger";
import { clientIp, rateLimit } from "@/lib/rate-limit";
const nextAuth = NextAuth({
export const { handlers, signOut, auth } = NextAuth({
trustHost: true,
secret: env.AUTH_SECRET,
session: { strategy: "jwt", maxAge: 24 * 60 * 60 },
@@ -162,15 +161,3 @@ const nextAuth = NextAuth({
},
},
});
export const { handlers, signOut } = nextAuth;
/**
* `auth()` re-parses the session cookie, decodes and HMAC-verifies the JWT and
* runs the `jwt`/`session` callbacks on every call. A single authenticated
* render calls it from the access guard, the site layout, both permission
* loaders and the page itself — 4-5 times on the critical path. React's
* `cache` scopes the memo to the current request, so those all share one
* verification. Outside a render (scripts, worker) it is a pass-through.
*/
export const auth = cache(nextAuth.auth);
-27
View File
@@ -1,27 +0,0 @@
import { env } from "@/env";
/** Hard floor/ceiling so a bad env value can never lock accounts out. */
const ABSOLUTE_MIN_LENGTH = 5;
const ABSOLUTE_MAX_LENGTH = 128;
function toLength(value: unknown, fallback: number): number {
const parsed = typeof value === "number" ? value : Number(value);
return Number.isFinite(parsed) ? Math.trunc(parsed) : fallback;
}
/**
* Effective password-length policy. `PASSWORD_MIN_LENGTH` / `PASSWORD_MAX_LENGTH`
* drive the bounds; the values are clamped to a sane window so a typo in the env
* can neither accept empty passwords nor reject every possible one.
*/
export const passwordPolicy = (() => {
const minLength = Math.min(
ABSOLUTE_MAX_LENGTH,
Math.max(ABSOLUTE_MIN_LENGTH, toLength(env.PASSWORD_MIN_LENGTH, 12)),
);
const maxLength = Math.max(
minLength,
Math.min(ABSOLUTE_MAX_LENGTH, toLength(env.PASSWORD_MAX_LENGTH, 128)),
);
return { minLength, maxLength };
})();
+1 -62
View File
@@ -85,12 +85,7 @@ describe("publicClientMessages", () => {
if (key === "admin") continue;
expect(result[key]).toBeDefined();
}
// `pages.admin` survives only in the shape the shared server-action hook
// needs: the generic `actions` copy, nothing staff-specific.
expect(Object.keys(result.pages.admin)).toEqual(["actions"]);
expect(result.pages.admin.actions.saved).toBe(en.pages.admin.actions.saved);
expect(result.pages.admin.nav).toBeUndefined();
expect(result.pages.admin.catalog).toBeUndefined();
expect(result.pages.admin).toBeUndefined();
});
it("actually shrinks the catalogue", async () => {
@@ -116,59 +111,3 @@ describe("publicClientMessages", () => {
expect(en.pages.admin).toBeDefined();
});
});
/**
* Scoping only works if the reduced catalogue goes to the client *and* every
* staff route group re-installs the full one. These two halves drift silently:
* narrowing the root without adding a staff provider renders raw keys in the
* admin panel, and the reverse just costs bytes. Both are asserted here.
*/
describe("client catalogue scoping is wired up", () => {
const STAFF_LAYOUTS = [
"src/app/admin/layout.tsx",
"src/app/admin-next/layout.tsx",
"src/app/mod/layout.tsx",
];
it("the root layout feeds the reduced catalogue to its provider", () => {
const src = readFileSync(join(process.cwd(), "src/app/layout.tsx"), "utf8");
expect(src).toContain("publicClientMessages(await getMessages())");
});
for (const layout of STAFF_LAYOUTS) {
it(`${layout} re-installs the full catalogue`, () => {
const src = readFileSync(join(process.cwd(), layout), "utf8");
expect(src).toContain("NextIntlClientProvider");
expect(src).toContain("getMessages()");
});
}
});
/**
* A client component that reads `admin.*` / `pages.admin.*` outside a staff
* route group would fall back to raw keys once the root catalogue is scoped,
* because the provider it inherits no longer carries them. Staff trees are the
* ones that install the full catalogue; the allowlist holds shared code that
* deliberately runs on public pages too.
*/
describe("staff namespace consumers sit in a staff tree", () => {
const STAFF_TREE =
/^src[\\/](app[\\/](admin|admin-next|mod)[\\/]|components[\\/]admin[\\/]|features[\\/](catalog|operations|history|housekeeping)[\\/])/;
const ALLOWLIST = new Set(["src/hooks/use-server-action.ts"]);
const CLIENT_STAFF_NS = /useTranslations\((["`])((admin)|(pages\.admin))/;
it("there is source to scan", () => {
expect(sourceFiles(join(process.cwd(), "src")).length).toBeGreaterThan(500);
});
it("no stray consumer of a staff namespace", () => {
const offenders: string[] = [];
for (const file of sourceFiles(join(process.cwd(), "src"))) {
const rel = file.slice(process.cwd().length + 1).replaceAll("\\", "/");
if (rel.includes(".test.")) continue;
if (STAFF_TREE.test(rel) || ALLOWLIST.has(rel)) continue;
if (CLIENT_STAFF_NS.test(readFileSync(file, "utf8"))) offenders.push(rel);
}
expect(offenders).toEqual([]);
});
});
+9 -35
View File
@@ -3,35 +3,22 @@ import type { AbstractIntlMessages } from "next-intl";
/**
* Client-side message scoping.
*
* `getMessages()` hands back the whole catalogue, and the root layout feeds it
* to `NextIntlClientProvider`, which serialises every message into the RSC
* payload of every page. The catalogue is ~254 KB of which `pages.admin` and
* `admin` together are ~195 KB — and no public page ever reads either.
* `getMessages()` hands back the whole catalogue, and the root layout feeds all
* of it to `NextIntlClientProvider`, which serialises every message into the RSC
* payload of every page. The catalogue is ~235 KB of which `pages.admin` and
* `admin` together are ~177 KB — and no public page ever reads either.
*
* Nested `NextIntlClientProvider`s REPLACE the parent's messages rather than
* merging them (see use-intl's IntlProvider: `messages ?? parent.messages`), so
* a segment cannot ask for "just my part". The root layout therefore installs
* the reduced catalogue, and the staff route groups (`/admin`, `/admin-next`,
* `/mod`) each install a nested provider seeded with the full one, which keeps
* staff tools complete while public pages ship ~59 KB instead of ~254 KB.
* a segment cannot ask for "just my part". Instead the public layout installs a
* provider seeded with the public subset, which shrinks the payload for every
* public page while leaving /admin, /mod, /client and /admin-next on the full set.
*/
/** Top-level namespaces plus `pages.*` sub-namespaces reserved for staff tools. */
const ADMIN_ONLY_NAMESPACES = new Set(["admin"]);
const ADMIN_ONLY_PAGE_NAMESPACES = new Set(["admin"]);
/**
* `pages.admin` sub-namespaces that are NOT staff-only because a shared
* client component reads them on public pages too.
*
* `useServerAction` (src/hooks/use-server-action.ts) resolves its default
* success/error toast copy from `pages.admin.actions` and is used by
* `/events/[slugOrId]` and `/polls/[id]`, so that one sub-namespace has to
* survive the filter — the strings in it are generic ("Saved successfully."),
* not staff-tool copy.
*/
const PUBLIC_PAGE_NAMESPACES = new Set(["actions"]);
/**
* Drop the staff-tool namespaces from a full catalogue.
*
@@ -56,21 +43,8 @@ export function publicClientMessages(
for (const [pageKey, pageValue] of Object.entries(
value as AbstractIntlMessages,
)) {
if (!ADMIN_ONLY_PAGE_NAMESPACES.has(pageKey)) {
pages[pageKey] = pageValue;
continue;
}
// Staff-only page namespace: keep only the sub-namespaces that a
// shared client component reads on public pages as well.
const shared: AbstractIntlMessages = {};
for (const [sharedKey, sharedValue] of Object.entries(
pageValue as AbstractIntlMessages,
)) {
if (PUBLIC_PAGE_NAMESPACES.has(sharedKey)) {
shared[sharedKey] = sharedValue;
}
}
if (Object.keys(shared).length > 0) pages[pageKey] = shared;
if (ADMIN_ONLY_PAGE_NAMESPACES.has(pageKey)) continue;
pages[pageKey] = pageValue;
}
result.pages = pages;
}
-1
View File
@@ -4,7 +4,6 @@ import { collectPerformance } from "./performance-diagnostics";
import { type PerformanceSample, recordPerformance } from "./performance-store";
type PublicRoute =
| "/"
| "/me"
| "/news"
| "/news/[slug]"
+1 -1
View File
@@ -14,6 +14,6 @@ const LABELS: Record<CurrencyType, string> = {
};
export function currencyLabel(type: CurrencyType): string {
// type is CurrencyType enum
// eslint-disable-next-line security/detect-object-injection -- type is CurrencyType enum
return LABELS[type];
}
+2
View File
@@ -55,10 +55,12 @@ async function writeToFile(
): Promise<boolean> {
try {
const logDir = resolve(process.cwd(), "storage", "logs");
// eslint-disable-next-line security/detect-non-literal-fs-filename
await mkdir(logDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const filename = `email-${timestamp}.html`;
const content = `<!-- To: ${to} | From: ${from} | Subject: ${subject} -->\n${html}`;
// eslint-disable-next-line security/detect-non-literal-fs-filename
await writeFile(resolve(logDir, filename), content, "utf-8");
logger.info("Email written to file", { module: "email", filename });
return true;
+1 -1
View File
@@ -59,7 +59,7 @@ export async function checkVpn(ip: string): Promise<IpVerdict> {
string,
{ proxy?: string; type?: string }
>;
// ip is the API response key from proxycheck
// eslint-disable-next-line security/detect-object-injection -- ip is the API response key from proxycheck
const entry = d?.[ip];
if (entry?.proxy === "yes")
return { blocked: true, reason: `${entry.type ?? "Proxy"} detected` };
+1 -1
View File
@@ -84,7 +84,7 @@ export async function sendCurrency(
data: { credits: { increment: amount } },
});
} else {
// type is CurrencyName union, not "credits"
// eslint-disable-next-line security/detect-object-injection -- type is CurrencyName union, not "credits"
const t = TYPE_VALUE[type];
await deps.db.usersCurrency.upsert({
where: { userId_type: { userId, type: t } },
+1 -1
View File
@@ -128,7 +128,7 @@ class SiteSettings {
): Promise<string | null> {
const map = await this.load();
if (map.has(key)) return map.get(key) as string;
// guarded by `key in DEFAULTS`
// eslint-disable-next-line security/detect-object-injection -- guarded by `key in DEFAULTS`
if (key in DEFAULTS) return DEFAULTS[key] as string;
return fallback;
}
+4 -4
View File
@@ -42,7 +42,7 @@ function fallbackFor(key: string): string {
const preset = PRESETS["Atom (golden)"];
if (key.endsWith("_dark")) {
const base = key.slice(0, -"_dark".length) as ThemeColorKey;
return preset.dark[base] ?? ""; // key derived from internal THEME_COLOR_KEYS
return preset.dark[base] ?? ""; // eslint-disable-line security/detect-object-injection -- key derived from internal THEME_COLOR_KEYS
}
if (key in preset.light) {
return preset.light[key as ThemeColorKey] ?? "";
@@ -63,7 +63,7 @@ function fallbackFor(key: string): string {
theme_preset: "Atom (golden)",
...BACKGROUND_DEFAULTS,
};
return extras[key] ?? ""; // key derived from internal EXTRA_KEYS
return extras[key] ?? ""; // eslint-disable-line security/detect-object-injection -- key derived from internal EXTRA_KEYS
}
export async function listCustomThemes(): Promise<CustomTheme[]> {
@@ -106,9 +106,9 @@ export async function upsertCustomTheme(
if (id) {
const idx = themes.findIndex((t) => t.id === id);
if (idx >= 0) {
themes[idx] = { ...themes[idx], name: trimmed, settings }; // idx from findIndex over internal array
themes[idx] = { ...themes[idx], name: trimmed, settings }; // eslint-disable-line security/detect-object-injection -- idx from findIndex over internal array
await persist(themes);
return themes[idx]; // idx from findIndex over internal array
return themes[idx]; // eslint-disable-line security/detect-object-injection -- idx from findIndex over internal array
}
}
const theme: CustomTheme = {
+5 -5
View File
@@ -12,7 +12,7 @@ describe("createUserSchema", () => {
const result = createUserSchema.safeParse({
username: "TestUser",
mail: "[email protected]",
password: "secret123456",
password: "secret123",
});
expect(result.success).toBe(true);
if (result.success) {
@@ -26,7 +26,7 @@ describe("createUserSchema", () => {
createUserSchema.safeParse({
username: "ab",
mail: "[email protected]",
password: "secret123456",
password: "secret123",
}).success,
).toBe(false);
});
@@ -36,7 +36,7 @@ describe("createUserSchema", () => {
createUserSchema.safeParse({
username: "a".repeat(21),
mail: "[email protected]",
password: "secret123456",
password: "secret123",
}).success,
).toBe(false);
});
@@ -46,7 +46,7 @@ describe("createUserSchema", () => {
createUserSchema.safeParse({
username: "user@name",
mail: "[email protected]",
password: "secret123456",
password: "secret123",
}).success,
).toBe(false);
});
@@ -56,7 +56,7 @@ describe("createUserSchema", () => {
createUserSchema.safeParse({
username: "TestUser",
mail: "not-an-email",
password: "secret123456",
password: "secret123",
}).success,
).toBe(false);
});
+1 -11
View File
@@ -1,5 +1,4 @@
import { z } from "zod";
import { passwordPolicy } from "@/lib/auth/password-policy";
export const createUserSchema = z.object({
username: z
@@ -11,16 +10,7 @@ export const createUserSchema = z.object({
"Username can only contain letters, numbers, _ and -",
),
mail: z.string().email("Invalid email address"),
password: z
.string()
.min(
passwordPolicy.minLength,
`Password must be at least ${passwordPolicy.minLength} characters`,
)
.max(
passwordPolicy.maxLength,
`Password must be at most ${passwordPolicy.maxLength} characters`,
),
password: z.string().min(6, "Password must be at least 6 characters"),
rank: z.coerce.number().int().min(1).max(7).default(1),
motto: z.string().max(127).default(""),
});
+5 -5
View File
@@ -6132,7 +6132,7 @@
"confirmPassword": "أعد كلمة المرور",
"usernamePlaceholder": "اسم المستخدم",
"emailPlaceholder": "البريد الإلكتروني",
"passwordPlaceholder": "كلمة المرور ({min} حرفًا على الأقل)",
"passwordPlaceholder": "كلمة المرور (12 حرفًا على الأقل)",
"confirmPasswordPlaceholder": "أعد كلمة المرور",
"selectOutfit": "اختر ملابسك",
"termsAccept": "أنا أبلغ 18 عامًا وأوافق على شروط {hotel}.",
@@ -6153,7 +6153,7 @@
"termsError": "يجب الموافقة على الشروط للتسجيل.",
"usernameError": "اسم المستخدم مطلوب.",
"emailError": "البريد الإلكتروني الصالح مطلوب.",
"passwordError": "يجب ألا تقل كلمة المرور عن {min} حرفًا",
"passwordError": "يجب ألا تقل كلمة المرور عن 12 حرفًا",
"confirmPasswordError": "كلمتا المرور غير متطابقتين.",
"termsRequired": "يجب الموافقة على الشروط.",
"usernameRequired": "اسم المستخدم مطلوب.",
@@ -6167,7 +6167,7 @@
"strengthFair": "مقبول",
"strengthGood": "جيد",
"strengthStrong": "قوي",
"passwordMinLength": "يجب ألا تقل كلمة المرور عن {min} حرفًا",
"passwordMinLength": "يجب ألا تقل كلمة المرور عن 12 حرفًا",
"passwordUpper": "يجب أن تحتوي كلمة المرور على حرف كبير واحد على الأقل",
"passwordLower": "يجب أن تحتوي كلمة المرور على حرف صغير واحد على الأقل",
"passwordDigit": "يجب أن تحتوي كلمة المرور على رقم واحد على الأقل",
@@ -6202,10 +6202,10 @@
"reset": {
"title": "Set a new password",
"subtitle": "Choose a strong password you don't use elsewhere.",
"passwordPlaceholder": "New password (min {min} chars)",
"passwordPlaceholder": "New password (min 12 chars)",
"resetPassword": "Reset password",
"backToLogin": "Back to login",
"passwordMinLength": "يجب ألا تقل كلمة المرور عن {min} حرفًا",
"passwordMinLength": "يجب ألا تقل كلمة المرور عن 12 حرفًا",
"tooManyAttempts": "محاولات كثيرة جدًا — حاول لاحقًا",
"invalidLink": "رابط إعادة التعيين غير صالح أو منتهي الصلاحية. اطلب رابطًا جديدًا.",
"failed": "تعذّرت إعادة تعيين كلمة المرور. حاول مرة أخرى."
+5 -5
View File
@@ -885,7 +885,7 @@
"confirmPassword": "Повторете паролата",
"usernamePlaceholder": "Потребителско име",
"emailPlaceholder": "Имейл",
"passwordPlaceholder": "Парола (мин. {min} знака)",
"passwordPlaceholder": "Парола (мин. 12 знака)",
"confirmPasswordPlaceholder": "Повторете паролата",
"selectOutfit": "Изберете вашето облекло",
"termsAccept": "Аз съм на 18+ и приемам условията и правилата на {hotel}.",
@@ -897,7 +897,7 @@
"termsError": "Трябва да приемете условията и правилата, за да се регистрирате.",
"usernameError": "Изисква се потребителско име.",
"emailError": "Изисква се валиден имейл.",
"passwordError": "Паролата трябва да е поне {min} знака",
"passwordError": "Паролата трябва да е поне 12 знака",
"confirmPasswordError": "Паролите не съвпадат.",
"termsRequired": "Трябва да приемете условията и правилата.",
"usernameRequired": "Изисква се потребителско име.",
@@ -920,7 +920,7 @@
"strengthFair": "Средна",
"strengthGood": "Добра",
"strengthStrong": "Силна",
"passwordMinLength": "Паролата трябва да е поне {min} знака",
"passwordMinLength": "Паролата трябва да е поне 12 знака",
"passwordUpper": "Паролата трябва да съдържа поне една главна буква",
"passwordLower": "Паролата трябва да съдържа поне една малка буква",
"passwordDigit": "Паролата трябва да съдържа поне една цифра",
@@ -955,10 +955,10 @@
"reset": {
"title": "Задайте нова парола",
"subtitle": "Изберете силна парола, която не използвате другаде.",
"passwordPlaceholder": "Нова парола (мин. {min} знака)",
"passwordPlaceholder": "Нова парола (мин. 12 знака)",
"resetPassword": "Нулирайте паролата",
"backToLogin": "Назад към входа",
"passwordMinLength": "Паролата трябва да е поне {min} знака",
"passwordMinLength": "Паролата трябва да е поне 12 знака",
"tooManyAttempts": "Твърде много опити — опитайте по-късно",
"invalidLink": "Този линк за нулиране е невалиден или е изтекъл. Поискайте нов.",
"failed": "Нулирането на паролата не бе успешно. Моля, опитайте отново."
+5 -5
View File
@@ -885,7 +885,7 @@
"confirmPassword": "Opakujte heslo",
"usernamePlaceholder": "Uživatelské jméno",
"emailPlaceholder": "Email",
"passwordPlaceholder": "Heslo (min. {min} znaků)",
"passwordPlaceholder": "Heslo (min. 12 znaků)",
"confirmPasswordPlaceholder": "Opakujte heslo",
"selectOutfit": "Vyberte si oblečení",
"termsAccept": "Je mi 18+ a souhlasím s podmínkami a pravidly {hotel}.",
@@ -897,7 +897,7 @@
"termsError": "Chcete-li se zaregistrovat, musíte přijmout podmínky a pravidla.",
"usernameError": "Uživatelské jméno je povinné.",
"emailError": "Je vyžadován platný e-mail.",
"passwordError": "Heslo musí mít alespoň {min} znaků",
"passwordError": "Heslo musí mít alespoň 12 znaků",
"confirmPasswordError": "Hesla se neshodují.",
"termsRequired": "Musíte přijmout podmínky a pravidla.",
"usernameRequired": "Uživatelské jméno je povinné.",
@@ -920,7 +920,7 @@
"strengthFair": "Ucházející",
"strengthGood": "Dobré",
"strengthStrong": "Silné",
"passwordMinLength": "Heslo musí mít alespoň {min} znaků",
"passwordMinLength": "Heslo musí mít alespoň 12 znaků",
"passwordUpper": "Heslo musí obsahovat alespoň jedno velké písmeno",
"passwordLower": "Heslo musí obsahovat alespoň jedno malé písmeno",
"passwordDigit": "Heslo musí obsahovat alespoň jednu číslici",
@@ -955,10 +955,10 @@
"reset": {
"title": "Nastavte nové heslo",
"subtitle": "Vyberte si silné heslo, které jinde nepoužíváte.",
"passwordPlaceholder": "Nové heslo (min. {min} znaků)",
"passwordPlaceholder": "Nové heslo (min. 12 znaků)",
"resetPassword": "Obnovit heslo",
"backToLogin": "Zpět k přihlášení",
"passwordMinLength": "Heslo musí mít alespoň {min} znaků",
"passwordMinLength": "Heslo musí mít alespoň 12 znaků",
"tooManyAttempts": "Příliš mnoho pokusů — zkuste to později",
"invalidLink": "Tento odkaz pro obnovu je neplatný nebo vypršel. Vyžádejte si nový.",
"failed": "Heslo nebylo možné obnovit. Zkuste to prosím znovu."
+5 -5
View File
@@ -885,7 +885,7 @@
"confirmPassword": "Gentag adgangskoden",
"usernamePlaceholder": "Brugernavn",
"emailPlaceholder": "E-mail",
"passwordPlaceholder": "Adgangskode (min. {min} tegn)",
"passwordPlaceholder": "Adgangskode (min. 12 tegn)",
"confirmPasswordPlaceholder": "Gentag adgangskoden",
"selectOutfit": "Vælg dit outfit",
"termsAccept": "Jeg er 18+ og accepterer vilkårene og reglerne for {hotel}.",
@@ -897,7 +897,7 @@
"termsError": "Du skal acceptere vilkårene og reglerne for at registrere dig.",
"usernameError": "Brugernavn er påkrævet.",
"emailError": "Gyldig e-mail er påkrævet.",
"passwordError": "Adgangskoden skal være på mindst {min} tegn",
"passwordError": "Adgangskoden skal være på mindst 12 tegn",
"confirmPasswordError": "Adgangskoder stemmer ikke overens.",
"termsRequired": "Du skal acceptere vilkårene og reglerne.",
"usernameRequired": "Brugernavn er påkrævet.",
@@ -920,7 +920,7 @@
"strengthFair": "Middelmådig",
"strengthGood": "God",
"strengthStrong": "Stærk",
"passwordMinLength": "Adgangskoden skal være på mindst {min} tegn",
"passwordMinLength": "Adgangskoden skal være på mindst 12 tegn",
"passwordUpper": "Adgangskoden skal indeholde mindst ét stort bogstav",
"passwordLower": "Adgangskoden skal indeholde mindst ét lille bogstav",
"passwordDigit": "Adgangskoden skal indeholde mindst ét tal",
@@ -955,10 +955,10 @@
"reset": {
"title": "Indstil en ny adgangskode",
"subtitle": "Vælg en stærk adgangskode, du ikke bruger andre steder.",
"passwordPlaceholder": "Ny adgangskode (min. {min} tegn)",
"passwordPlaceholder": "Ny adgangskode (min. 12 tegn)",
"resetPassword": "Nulstil adgangskode",
"backToLogin": "Tilbage til login",
"passwordMinLength": "Adgangskoden skal være på mindst {min} tegn",
"passwordMinLength": "Adgangskoden skal være på mindst 12 tegn",
"tooManyAttempts": "For mange forsøg — prøv igen senere",
"invalidLink": "Dette nulstillingslink er ugyldigt eller udløbet. Anmod om et nyt.",
"failed": "Adgangskoden kunne ikke nulstilles. Prøv igen."
+5 -5
View File
@@ -855,7 +855,7 @@
"confirmPassword": "Passwort wiederholen",
"usernamePlaceholder": "Benutzername",
"emailPlaceholder": "E-Mail",
"passwordPlaceholder": "Passwort (min. {min} Zeichen)",
"passwordPlaceholder": "Passwort (min. 12 Zeichen)",
"confirmPasswordPlaceholder": "Passwort wiederholen",
"selectOutfit": "Wähle dein Outfit",
"termsAccept": "Ich bin 18+ und akzeptiere die {hotel} AGB & Regeln.",
@@ -867,7 +867,7 @@
"termsError": "Du musst die AGB & Regeln akzeptieren, um dich zu registrieren.",
"usernameError": "Benutzername ist erforderlich.",
"emailError": "Eine gültige E-Mail ist erforderlich.",
"passwordError": "Das Passwort muss mindestens {min} Zeichen lang sein",
"passwordError": "Das Passwort muss mindestens 12 Zeichen lang sein",
"confirmPasswordError": "Passwörter stimmen nicht überein.",
"termsRequired": "Du musst die AGB & Regeln akzeptieren.",
"usernameRequired": "Benutzername ist erforderlich.",
@@ -890,7 +890,7 @@
"strengthFair": "Mittel",
"strengthGood": "Gut",
"strengthStrong": "Stark",
"passwordMinLength": "Das Passwort muss mindestens {min} Zeichen lang sein",
"passwordMinLength": "Das Passwort muss mindestens 12 Zeichen lang sein",
"passwordUpper": "Das Passwort muss mindestens einen Großbuchstaben enthalten",
"passwordLower": "Das Passwort muss mindestens einen Kleinbuchstaben enthalten",
"passwordDigit": "Das Passwort muss mindestens eine Ziffer enthalten",
@@ -925,10 +925,10 @@
"reset": {
"title": "Neues Passwort festlegen",
"subtitle": "Wähle ein sicheres Passwort, das du sonst nirgendwo verwendest.",
"passwordPlaceholder": "Neues Passwort (min. {min} Zeichen)",
"passwordPlaceholder": "Neues Passwort (min. 12 Zeichen)",
"resetPassword": "Passwort zurücksetzen",
"backToLogin": "Zurück zur Anmeldung",
"passwordMinLength": "Das Passwort muss mindestens {min} Zeichen lang sein",
"passwordMinLength": "Das Passwort muss mindestens 12 Zeichen lang sein",
"tooManyAttempts": "Zu viele Versuche — bitte später erneut probieren",
"invalidLink": "Dieser Zurücksetzungslink ist ungültig oder abgelaufen. Fordere einen neuen an.",
"failed": "Das Passwort konnte nicht zurückgesetzt werden. Bitte versuche es erneut."
+5 -5
View File
@@ -885,7 +885,7 @@
"confirmPassword": "Επαναλάβετε τον κωδικό πρόσβασης",
"usernamePlaceholder": "Όνομα χρήστη",
"emailPlaceholder": "Email",
"passwordPlaceholder": "Κωδικός πρόσβασης (ελάχ. {min} χαρακτήρες)",
"passwordPlaceholder": "Κωδικός πρόσβασης (ελάχ. 12 χαρακτήρες)",
"confirmPasswordPlaceholder": "Επαναλάβετε τον κωδικό πρόσβασης",
"selectOutfit": "Επιλέξτε το ντύσιμό σας",
"termsAccept": "Είμαι 18+ και αποδέχομαι τους όρους και τους κανόνες του {hotel}.",
@@ -897,7 +897,7 @@
"termsError": "Πρέπει να αποδεχτείτε τους όρους και τους κανόνες για να εγγραφείτε.",
"usernameError": "Απαιτείται όνομα χρήστη.",
"emailError": "Απαιτείται έγκυρο email.",
"passwordError": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον {min} χαρακτήρες",
"passwordError": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον 12 χαρακτήρες",
"confirmPasswordError": "Οι κωδικοί πρόσβασης δεν ταιριάζουν.",
"termsRequired": "Πρέπει να αποδεχτείτε τους όρους και τους κανόνες.",
"usernameRequired": "Απαιτείται όνομα χρήστη.",
@@ -920,7 +920,7 @@
"strengthFair": "Μέτριο",
"strengthGood": "Καλό",
"strengthStrong": "Δυνατό",
"passwordMinLength": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον {min} χαρακτήρες",
"passwordMinLength": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον 12 χαρακτήρες",
"passwordUpper": "Ο κωδικός πρόσβασης πρέπει να περιέχει τουλάχιστον ένα κεφαλαίο γράμμα",
"passwordLower": "Ο κωδικός πρόσβασης πρέπει να περιέχει τουλάχιστον ένα πεζό γράμμα",
"passwordDigit": "Ο κωδικός πρόσβασης πρέπει να περιέχει τουλάχιστον ένα ψηφίο",
@@ -955,10 +955,10 @@
"reset": {
"title": "Ορίστε νέο κωδικό πρόσβασης",
"subtitle": "Επιλέξτε έναν ισχυρό κωδικό πρόσβασης που δεν χρησιμοποιείτε αλλού.",
"passwordPlaceholder": "Νέος κωδικός πρόσβασης (ελάχ. {min} χαρακτήρες)",
"passwordPlaceholder": "Νέος κωδικός πρόσβασης (ελάχ. 12 χαρακτήρες)",
"resetPassword": "Επαναφορά κωδικού πρόσβασης",
"backToLogin": "Επιστροφή στην είσοδο",
"passwordMinLength": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον {min} χαρακτήρες",
"passwordMinLength": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον 12 χαρακτήρες",
"tooManyAttempts": "Πάρα πολλές προσπάθειες — δοκιμάστε αργότερα",
"invalidLink": "Αυτός ο σύνδεσμος επαναφοράς δεν είναι έγκυρος ή έχει λήξει. Ζητήστε νέο.",
"failed": "Δεν ήταν δυνατή η επαναφορά του κωδικού. Δοκιμάστε ξανά."
+5 -5
View File
@@ -1058,7 +1058,7 @@
"confirmPassword": "Repeat password",
"usernamePlaceholder": "Username",
"emailPlaceholder": "Email",
"passwordPlaceholder": "Password (min {min} chars)",
"passwordPlaceholder": "Password (min 12 chars)",
"confirmPasswordPlaceholder": "Repeat password",
"selectOutfit": "Select your outfit",
"termsAccept": "I am 18+ and accept the {hotel} terms & rules.",
@@ -1081,7 +1081,7 @@
"termsError": "You must accept the terms & rules to register.",
"usernameError": "Username is required.",
"emailError": "Valid email is required.",
"passwordError": "Password must be at least {min} characters",
"passwordError": "Password must be at least 12 characters",
"confirmPasswordError": "Passwords do not match.",
"termsRequired": "You must accept the terms & rules.",
"usernameRequired": "Username is required.",
@@ -1093,7 +1093,7 @@
"strengthFair": "Fair",
"strengthGood": "Good",
"strengthStrong": "Strong",
"passwordMinLength": "Password must be at least {min} characters",
"passwordMinLength": "Password must be at least 12 characters",
"passwordUpper": "Password must contain at least one uppercase letter",
"passwordLower": "Password must contain at least one lowercase letter",
"passwordDigit": "Password must contain at least one digit",
@@ -1128,10 +1128,10 @@
"reset": {
"title": "Set a new password",
"subtitle": "Choose a strong password you don't use elsewhere.",
"passwordPlaceholder": "New password (min {min} chars)",
"passwordPlaceholder": "New password (min 12 chars)",
"resetPassword": "Reset password",
"backToLogin": "Back to login",
"passwordMinLength": "Password must be at least {min} characters",
"passwordMinLength": "Password must be at least 12 characters",
"tooManyAttempts": "Too many attempts — try again later",
"invalidLink": "This reset link is invalid or has expired. Request a new one.",
"failed": "The password could not be reset. Please try again."
+5 -5
View File
@@ -855,7 +855,7 @@
"confirmPassword": "Repetir contraseña",
"usernamePlaceholder": "Usuario",
"emailPlaceholder": "Correo electrónico",
"passwordPlaceholder": "Contraseña (mín. {min} caracteres)",
"passwordPlaceholder": "Contraseña (mín. 12 caracteres)",
"confirmPasswordPlaceholder": "Repetir contraseña",
"selectOutfit": "Selecciona tu atuendo",
"termsAccept": "Soy mayor de 18 años y acepto los términos y reglas de {hotel}.",
@@ -867,7 +867,7 @@
"termsError": "Debes aceptar los términos y reglas para registrarte.",
"usernameError": "El usuario es obligatorio.",
"emailError": "Se requiere un correo electrónico válido.",
"passwordError": "La contraseña debe tener al menos {min} caracteres",
"passwordError": "La contraseña debe tener al menos 12 caracteres",
"confirmPasswordError": "Las contraseñas no coinciden.",
"termsRequired": "Debes aceptar los términos y reglas.",
"usernameRequired": "El usuario es obligatorio.",
@@ -890,7 +890,7 @@
"strengthFair": "Aceptable",
"strengthGood": "Buena",
"strengthStrong": "Fuerte",
"passwordMinLength": "La contraseña debe tener al menos {min} caracteres",
"passwordMinLength": "La contraseña debe tener al menos 12 caracteres",
"passwordUpper": "La contraseña debe contener al menos una mayúscula",
"passwordLower": "La contraseña debe contener al menos una minúscula",
"passwordDigit": "La contraseña debe contener al menos un dígito",
@@ -925,10 +925,10 @@
"reset": {
"title": "Establecer una nueva contraseña",
"subtitle": "Elige una contraseña segura que no uses en otros sitios.",
"passwordPlaceholder": "Nueva contraseña (mín. {min} caracteres)",
"passwordPlaceholder": "Nueva contraseña (mín. 12 caracteres)",
"resetPassword": "Restablecer contraseña",
"backToLogin": "Volver al inicio de sesión",
"passwordMinLength": "La contraseña debe tener al menos {min} caracteres",
"passwordMinLength": "La contraseña debe tener al menos 12 caracteres",
"tooManyAttempts": "Demasiados intentos — inténtalo más tarde",
"invalidLink": "Este enlace de restablecimiento no es válido o ha caducado. Solicita uno nuevo.",
"failed": "No se pudo restablecer la contraseña. Vuelve a intentarlo."
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff. Show more