1 Commits
Author SHA1 Message Date
remco 711e1bb5dc chore(deps): update all non-major dependencies
CI / check (push) Skipped
renovate/stability-days Updates have not met minimum release age requirement
CI / tests-unit (push) Skipped
CI / tests-integration (push) Skipped
CI / tests-ui (push) Skipped
CI / preflight (push) Skipped
CI / check (pull_request) Failing after 18s
CI / tests-unit (pull_request) Skipped
CI / tests-integration (pull_request) Skipped
CI / tests-ui (pull_request) Skipped
CI / tests-imaging-browsers (pull_request) Skipped
CI / tests-quality (pull_request) Skipped
CI / preflight (pull_request) Skipped
CI / deploy (pull_request) Skipped
2026-10-18 12:01:29 +00:00
118 changed files with 1191 additions and 1294 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,
},
+14 -14
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",
@@ -56,9 +56,9 @@
"@dnd-kit/sortable": "10.0.0",
"@dnd-kit/utilities": "3.2.2",
"@formatjs/icu-messageformat-parser": "3.5.21",
"@hookform/resolvers": "5.9.1",
"@tanstack/react-query": "5.104.3",
"@tanstack/react-virtual": "3.14.19",
"@hookform/resolvers": "5.9.2",
"@tanstack/react-query": "5.104.4",
"@tanstack/react-virtual": "3.14.21",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "1.1.1",
@@ -70,12 +70,12 @@
"jpeg-js": "0.4.4",
"jsonc-parser": "3.3.1",
"jszip": "3.10.2",
"lucide-react": "1.57.0",
"lucide-react": "1.59.0",
"lzma-wasm": "1.0.7",
"motion": "14.2.0",
"music-metadata": "12.2.1",
"motion": "14.3.0",
"music-metadata": "12.2.2",
"mysql2": "3.24.6",
"next": "16.4.1",
"next": "16.4.2",
"next-auth": "5.0.0-beta.32",
"next-intl": "4.14.9",
"otplib": "13.5.0",
@@ -83,7 +83,7 @@
"react": "19.3.1",
"react-dom": "19.3.1",
"react-hook-form": "7.90.0",
"resend": "6.33.0",
"resend": "6.34.1",
"server-only": "0.0.1",
"sharp": "^0.35.5",
"sonner": "2.0.8",
@@ -92,7 +92,7 @@
"zod": "4.6.7"
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
"@axe-core/playwright": "4.14.0",
"@babel/parser": "8.0.7",
"@biomejs/biome": "2.5.15",
"@playwright/test": "1.64.1",
@@ -102,7 +102,7 @@
"@types/node": "26.6.5",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitest/coverage-v8": "5.0.4",
"@vitest/coverage-v8": "5.0.5",
"drizzle-kit": "0.31.11",
"esbuild": "0.28.2",
"msw": "^3.0.3",
@@ -113,6 +113,6 @@
"tsx": "4.23.15",
"typescript": "7.0.2",
"vite": "8.3.4",
"vitest": "5.0.4"
"vitest": "5.0.5"
}
}
+151 -136
View File
@@ -189,14 +189,14 @@ importers:
specifier: 3.5.21
version: 3.5.21
'@hookform/resolvers':
specifier: 5.9.1
version: 5.9.1([email protected](@types/[email protected])([email protected]))([email protected])
specifier: 5.9.2
version: 5.9.2([email protected](@types/[email protected])([email protected]))([email protected])
'@tanstack/react-query':
specifier: 5.104.3
version: 5.104.3([email protected])
specifier: 5.104.4
version: 5.104.4(@types/[email protected])([email protected])
'@tanstack/react-virtual':
specifier: 3.14.19
version: 3.14.19([email protected]([email protected]))([email protected])
specifier: 3.14.21
version: 3.14.21([email protected]([email protected]))([email protected])
class-variance-authority:
specifier: 0.7.1
version: 0.7.1
@@ -231,29 +231,29 @@ importers:
specifier: 3.10.2
version: 3.10.2
lucide-react:
specifier: 1.57.0
version: 1.57.0(@types/[email protected])([email protected])
specifier: 1.59.0
version: 1.59.0(@types/[email protected])([email protected])
lzma-wasm:
specifier: 1.0.7
version: 1.0.7
motion:
specifier: 14.2.0
version: 14.2.0([email protected]([email protected]))([email protected])
specifier: 14.3.0
version: 14.3.0([email protected]([email protected]))([email protected])
music-metadata:
specifier: 12.2.1
version: 12.2.1
specifier: 12.2.2
version: 12.2.2
mysql2:
specifier: 3.24.6
version: 3.24.6(@types/[email protected])
next:
specifier: 16.4.1
version: 16.4.1(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
specifier: 16.4.2
version: 16.4.2(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
next-auth:
specifier: 5.0.0-beta.32
version: 5.0.0-beta.32([email protected].1(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected])
version: 5.0.0-beta.32([email protected].2(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected])
next-intl:
specifier: 4.14.9
version: 4.14.9(@swc/[email protected])(@types/[email protected])([email protected].1(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected])
version: 4.14.9(@swc/[email protected])(@types/[email protected])([email protected].2(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected])
otplib:
specifier: 13.5.0
version: 13.5.0
@@ -270,8 +270,8 @@ importers:
specifier: 7.90.0
version: 7.90.0(@types/[email protected])([email protected])
resend:
specifier: 6.33.0
version: 6.33.0
specifier: 6.34.1
version: 6.34.1
server-only:
specifier: 0.0.1
version: 0.0.1
@@ -292,8 +292,8 @@ importers:
version: 4.6.7
devDependencies:
'@axe-core/playwright':
specifier: 4.13.0
version: 4.13.0([email protected])
specifier: 4.14.0
version: 4.14.0([email protected])
'@babel/parser':
specifier: 8.0.7
version: 8.0.7
@@ -322,8 +322,8 @@ importers:
specifier: 19.3.0
version: 19.3.0(@types/[email protected])
'@vitest/coverage-v8':
specifier: 5.0.4
version: 5.0.4([email protected].4)
specifier: 5.0.5
version: 5.0.5([email protected].5)
drizzle-kit:
specifier: 0.31.11
version: 0.31.11
@@ -355,8 +355,8 @@ importers:
specifier: 8.3.4
version: 8.3.4(@types/[email protected])([email protected])([email protected])([email protected])([email protected])
vitest:
specifier: 5.0.4
version: 5.0.4(@types/[email protected])(@vitest/[email protected].4)([email protected](@noble/[email protected]))([email protected](@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
specifier: 5.0.5
version: 5.0.5(@types/[email protected])(@vitest/[email protected].5)([email protected](@noble/[email protected]))([email protected](@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
packages:
@@ -386,8 +386,9 @@ packages:
nodemailer:
optional: true
'@axe-core/[email protected]3.0':
resolution: {integrity: sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==}
'@axe-core/[email protected]4.0':
resolution: {integrity: sha512-SGUhG5PIHd2eMdpgBF2PyQLOhX+qyDR2PT4cmoS4Qk2ak+jX/rrSvst9l0PGv5WTHolKpUMvVmoHpr4cZgf1RQ==}
engines: {node: '>=22.12.0'}
peerDependencies:
playwright-core: '>= 1.0.0'
@@ -967,8 +968,8 @@ packages:
engines: {node: '>=6'}
hasBin: true
'@hookform/[email protected].1':
resolution: {integrity: sha512-7b7vsbraJxKgjVSA1Nur9tLwj539WGJUBLA7QNvXnFoT2pM5Z7G+6rlukk4B2/QrTZy6huRtH6wKeESPKuIr6w==}
'@hookform/[email protected].2':
resolution: {integrity: sha512-RtjTnZqsC38s4NKp2XO3fD8IA5uyN4tB7McQ5C6uiMPcV+WWO8F+wVa3e+BXUpH2ScgxghAlwFRMVqjT9WChAQ==}
peerDependencies:
'@sinclair/typebox': '>=0.25.24'
'@standard-schema/spec': ^1.0.0
@@ -978,7 +979,7 @@ packages:
ajv-errors: ^3.0.0
ajv-formats: ^2.1.1
arktype: ^2.0.0
ata-validator: ^1.2.0
ata-validator: ^1.23.0
class-transformer: '>=0.4.0'
class-validator: '>=0.12.0'
computed-types: ^1.0.0
@@ -1278,57 +1279,57 @@ packages:
resolution: {integrity: sha512-lZBylf8kTt096RGnnVfHq+5w4DTRgmFjf59/IVMPBBwKrViumV1l95LZ/LrWt1LZlsJymHOSnoP8mX/kPXZPWw==}
engines: {node: '>=22'}
'@next/[email protected].1':
resolution: {integrity: sha512-5sUUrrSvs/2/2LnLoesybMGx12EtPB46fdZ6RhAw0XDGSPxU2LFyQBB7yuEiOFFJPKmBY6+q2jTr1/AMbnp0/g==}
'@next/[email protected].2':
resolution: {integrity: sha512-nTUTFH7G5/J/fmpkjVLegOkqyKOP0CEsJcMsyO4UCYeN1su+YMW/MfaSErnJskL7XM+UDK6aTvslxsRCyya/sA==}
'@next/[email protected].1':
resolution: {integrity: sha512-U+hQ5Gj3fpzf4fveTIF3dpUFYhaSW8vHhdAnbiIomzXzg7boreYFgBqprepXFtW4sw+aTttiNmg0gew/iFNhbg==}
'@next/[email protected].2':
resolution: {integrity: sha512-ONkhFvZNFWlsEHOM/81AtxdyuJP2KRYKYR0JxPwvcu1AuPMMMgcL+wW2luldJvMtZwKtUxoSkCychW5wIeoYSw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@next/[email protected].1':
resolution: {integrity: sha512-/hqC3V1XLUjhMASf/WridKdO9FXoEgN3HBWmtKbj00MPgnltutfraWJ3bYTpCRIgEIpbjJyOVLpYgAhh/BkdcQ==}
'@next/[email protected].2':
resolution: {integrity: sha512-luPCMbOsCoaF+kXJEUjmjmOZg1lC3BflhmUu+ZmW4tBu2vW/4AXak8JBFvePizGkxVmNiAw/r9ra+6UviCWtWQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@next/[email protected].1':
resolution: {integrity: sha512-GHzncHbcaS9g95kcvTNtKQW3aCLoR+xc3Jm7qrkpk1R+D2BjMHIPyvQGRPKCdMX95BsH/421jBUK06q9tEwzjw==}
'@next/[email protected].2':
resolution: {integrity: sha512-xhwFTAncJyaCNy5KqKKuKaIIdCy1/1ZV1s5S1G0hhT7wGMkDTlfJ6aQbfRFnIG/s3/kDrxQ8aycVe+c5QnP8ow==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/[email protected].1':
resolution: {integrity: sha512-1SuJyDxW/PR9dHJat/ZRgcYLhuSZ+1psP4DlKb2tKIuXbjygqpLrJWRSq+onZQBv8LcmU67EIrmb6c3XTaJLjw==}
'@next/[email protected].2':
resolution: {integrity: sha512-T1PNmfbPcxr5ndtWUL/3wQtLXsz808lq6sc2GrNT5k2oquF7UyqZddHSuAewHVsWsGXe6nsl9HoN1YNriVjuSg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/[email protected].1':
resolution: {integrity: sha512-Istng1hMGp9TGO4lUqeKLd70MPN/OOkucVTP2Nmj+gLkwBgmPqUw8CJYBCC7nbl/B/23bBCcjCI5Hsh6hazC1A==}
'@next/[email protected].2':
resolution: {integrity: sha512-N9cDUOCGtkJG5PNKDFZUF3xXndrJJcpLp121uAGjIGvO/U2p9tfdNOM9htj7+5f72l0m8rMw/cvYacUJBQoudg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/[email protected].1':
resolution: {integrity: sha512-aSC9wMNiZRRFVpPkPWBzRwLQxIGm/CiQcXyFF1XNcgodb+uMxdk7aSSGGpDXCbnvZdf4itjHxpOd5h7i7zaiYw==}
'@next/[email protected].2':
resolution: {integrity: sha512-yY2uh1YhX6ufuYy2FCbw1dqblbhI57GLYAJfCkqD9auTv6pLrH67Uccjc3QMKBeCYAG6lkgu08ExmX1bbjejjQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/[email protected].1':
resolution: {integrity: sha512-t/myvwFDlypMUjRvbBf8qLPyuaB0iXYYdBtwMt6DM6f+60wtGr4bSYholdPqNeV5VUXZhrpMwfza829kmhS3iA==}
'@next/[email protected].2':
resolution: {integrity: sha512-j/ArWRIHpMMB6wYLMhr2puTguBt70lNU/Cmjl9p6dTDE8DGknYJMP9PdwPk2dqpCNL/KIX9yfngDvIesxJPmsg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@next/[email protected].1':
resolution: {integrity: sha512-6ICqPo9awCkrtYNPhwkx1uU7rlFWrZRrIboDHS2LU+jv088UBYQtmJ25h6pCp27/+FZjF6BQ4Te4ErugyMeuBg==}
'@next/[email protected].2':
resolution: {integrity: sha512-AA4edyWHDko3NCSo0x6GomsdgFb0oR1J3MXU1b5xt6FRFg4AucrMPW3S0hCe/9onU0O1BbpGQBsqo1r0Gneu1g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -1952,22 +1953,26 @@ packages:
peerDependencies:
tailwindcss: '>=3.0.0 || >=4.0.0 || insiders'
'@tanstack/[email protected].3':
resolution: {integrity: sha512-DZY9TuJAOgU3UyHUhXb4CcTYEHNZszlwkPg4VViIwTxOKzLprPUU+rbaPZQd3srqkGOtpaDdKpW972UV/LKlcg==}
'@tanstack/[email protected].4':
resolution: {integrity: sha512-nU4Yrb8Sjn0DeVb7PKG9uiepNavys3yoxy1FX0hViY4/P6Ng/mBje2SMl7i9812rtyHKtHyqiaC/td6GkI8StA==}
'@tanstack/[email protected].3':
resolution: {integrity: sha512-5Ts1ImwJKqoJgPkLxl9WrPQy1JEoEmti3MaaODCfBamHUGFk57IFrc+AIksPq+BAm10EsroX56qm398Z42LG7w==}
'@tanstack/[email protected].4':
resolution: {integrity: sha512-YD5QkvUNy8Ey3LbOy2vwL97JjoYycwDv++cWAibTe+9BLYvl3c09XpYp5dZ2VcxcguwoDhGF+utYJYb79RB36w==}
peerDependencies:
'@types/react': ^18 || ^19
react: ^18 || ^19
peerDependenciesMeta:
'@types/react':
optional: true
'@tanstack/[email protected]9':
resolution: {integrity: sha512-zmVdJO8DDqHphZ2H6SnTmgpVXcUy5DprB+clZF0ik679/d0MQ1UPEb2jtaX6F5VTAX4o3ozSLQaRCnybS2KYLA==}
'@tanstack/[email protected].21':
resolution: {integrity: sha512-wWE/ZJDNNDp18JHWy7n5PQooqerHePVkEEu6Op0twKQ2InF9DPhYMQptQY0syDEviMd3/r8D0OUvKJibDGm9HQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/[email protected].4':
resolution: {integrity: sha512-ygZ3O99Il18dl0gG0q7sH78iSkb96F07G+xK9V83eCgV8NQbcNd7R+cgcJJMFO7llBoebd0A82sCCstQq2Vp6Q==}
'@tanstack/[email protected].6':
resolution: {integrity: sha512-CsTW892z23gy7IyxYTTorVu1uZWB+0N349rSMgKz4rFLPpLza7F7TpcCKeI5iUAwYQCt8QUlOU67J+KXCxtpGw==}
'@tokenizer/[email protected]':
resolution: {integrity: sha512-56U2O6fDLFtqokwWh597IdeUgtM7j+TT9tb1soybhgA6cy163gAcVmoQr8H4Ud/CkgCkViwcuLyqtn6wYqw59Q==}
@@ -2146,11 +2151,11 @@ packages:
cpu: [x64]
os: [win32]
'@vitest/[email protected].4':
resolution: {integrity: sha512-P5YcQY8Y6RmoE9xRXI38rT8pssbcubEzqtYTM2XQoxOXMDn04nRaTI1SM8qCl9/NfW3ag9GlzdSXu7IwO5Ft8A==}
'@vitest/[email protected].5':
resolution: {integrity: sha512-4KpkGdIrSqBihx/JyP/e+S+IKB98etg1XDQiLAxksLfcShnwi1VReGeOT9NI43hLHbknosKp/1CCLn9Zgcs8mg==}
peerDependencies:
'@vitest/browser': 5.0.4
vitest: 5.0.4
'@vitest/browser': 5.0.5
vitest: 5.0.5
peerDependenciesMeta:
'@vitest/browser':
optional: true
@@ -2163,8 +2168,8 @@ packages:
resolution: {integrity: sha512-gUsfXZJbzPamoIY5TvHFiMMoXESBrUMo+xqaj+rYrWI69+EnvRlYBlP96ZnHPY4vX8kyUpgAnFUCg5wZG/HkDQ==}
engines: {node: '>=22'}
'@vitest/[email protected].4':
resolution: {integrity: sha512-tqAlrYWce4iUxhr3ncNohejtCTOcHeE8XWrSNZY3OhPoDn01yeGv+gnaBy64ATp7o6pb03eIbBAjzmibpc9vIQ==}
'@vitest/[email protected].5':
resolution: {integrity: sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==}
peerDependencies:
msw: ^2.4.9 || ^3.0.0
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
@@ -2174,8 +2179,8 @@ packages:
vite:
optional: true
'@vitest/[email protected].4':
resolution: {integrity: sha512-MZKk/gSkm2q+ICobMxCpH1mA+cCAWm8WV7UhxlqGjq/sJ3AGc1BqFOs5CRVXxH78wQtme9q/NAAgBquj4AcyMw==}
'@vitest/[email protected].5':
resolution: {integrity: sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==}
[email protected]:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
@@ -2233,8 +2238,8 @@ packages:
resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==}
engines: {node: '>= 6.0.0'}
[email protected]3.0:
resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==}
[email protected]4.0:
resolution: {integrity: sha512-9WTZxEjsZ7b13TH8JPmbV2z8CHbl80/2hm3XPEG4JgNdQLK81IBRXmSxHfMAOkSqQeRxT/0dwNDz2GOm3zzpcQ==}
engines: {node: '>=4'}
[email protected]:
@@ -2669,8 +2674,8 @@ packages:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
framer-motion@14.2.0:
resolution: {integrity: sha512-cD2gfFujHt4r/+dZpcn5yziu7VRwQAQDakwIm/bICYXbafnpHCYokV+PnEXqErnSdv6Eh28a+SRF+f3ndGcMsQ==}
framer-motion@14.3.0:
resolution: {integrity: sha512-d8WCrU0ZQn7ukKMykCsDyIOwb/taZeDqqrDBK0du5GO0AO/ynz8fdR/LIIz9Pyy3n3tGwrneR+n0egyrrLa3fw==}
peerDependencies:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
@@ -2995,8 +3000,8 @@ packages:
resolution: {integrity: sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==}
engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'}
[email protected]7.0:
resolution: {integrity: sha512-bXFZEucYhAzLD62Jse76AbTR6LiSLTn3Tqir2f1n8rZrWAdLodYy7jnRZSPIT89dRLk/IseMOUWG82wJtTyTTw==}
[email protected]9.0:
resolution: {integrity: sha512-5qgMyyCPU6OvnKpLS2t693+ZtiqA8oaSweWoUows0BLFKFf6V75m4+CptpmohZHpD5IuOVIBXHBBiv2vpLJmtw==}
peerDependencies:
'@types/react': '*'
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
@@ -3051,14 +3056,14 @@ packages:
engines: {node: '>=10'}
hasBin: true
motion-dom@14.2.0:
resolution: {integrity: sha512-TDdxnjAavh2Qm4lQCQI3uoTx9IR/Id2PI8JNEXW3AcwwV2yvhoimAX3/TnE+x82gDHfPq2yx/cJ58TTw5mMr0A==}
motion-dom@14.3.0:
resolution: {integrity: sha512-rcKO4aEJzAqDEsrH6qFzhLZxs7bNqZpiUEuEATz9LaRwt6dG7hZEbcFUSHNR8NECaKYM0Iuw6Iv6NpWEGXDI+w==}
[email protected]:
resolution: {integrity: sha512-3VvN5Uvf8B6qG+OfOui6Nht5EWp+vLuU3IqcAj7ItZGKqtmTjCebl5sGZF7/08gEHRnEl1AP/fIwbcEyVrcQGQ==}
motion@14.2.0:
resolution: {integrity: sha512-45cDwjZh0yOMZtnX3hIFPIPmzCWz/RC2bD/niWVIssGBjJlMmosbDILG9a+ZyoP635Z5xrbKvuo10spv9dBdnw==}
motion@14.3.0:
resolution: {integrity: sha512-yh/nHYjikl68+FTxAMywFYzzj21/oGY7RGCfkqN5zpuT1rk3rp0lKfVU8EUq0A64qQeGnxuKUJWP85MipqdLBw==}
peerDependencies:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
@@ -3087,8 +3092,8 @@ packages:
vite:
optional: true
[email protected].1:
resolution: {integrity: sha512-JB/bnmQAa8HiKHuIg9dupGFjCKpdsocm9nJrTQe31f5zTaKfJmTwJwOvH1yVL91/jwFNKTOAOIPrHo3L5XwGfQ==}
[email protected].2:
resolution: {integrity: sha512-m0fMlp+9snT8Gte3t99fMXpAeS0oRWVLzbj5OZd8zrPuP4RTQ/+VTaxgiz1igrJ/8NwhzdRNk/38aDVK/5N0Ow==}
engines: {node: '>=22'}
[email protected]:
@@ -3146,8 +3151,8 @@ packages:
'@types/react':
optional: true
[email protected].1:
resolution: {integrity: sha512-uc657V62E34fOI6W5NtG9IzwsPcms0Z60grz2MJGtglgD+pTFnNt4Yw2PzaU622tfX4vhrDgLZzicE2YlhI20Q==}
[email protected].2:
resolution: {integrity: sha512-r+oBJojfefUjOiWkBhXgLS+igxX/7BWyK59uHnyOyplMSLtBZ+QLP3okrdYTbrfrbrmNTVWLBPaHDX9KRaIt3w==}
engines: {node: '>=20.9.0'}
hasBin: true
peerDependencies:
@@ -3380,8 +3385,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==}
[email protected]3.0:
resolution: {integrity: sha512-sbpWLrwlaIDZuGHRYNX6101CWtXLqRNcT1S9c8BB357Ab2+IUJ7dNpYffVmGh2C11/4AfcFvM4ltURH8NJlPGw==}
[email protected]4.1:
resolution: {integrity: sha512-3jo/j1WAW7CBkE6pzK4qLjZCfHRLIhY3ph3x/wzU+MN51cSw3EtReGce8S1cA1R0ipNDOdFYU5Lmt5r563lvYw==}
engines: {node: '>=20'}
peerDependencies:
'@react-email/render': '*'
@@ -3546,6 +3551,10 @@ packages:
resolution: {integrity: sha512-PgAsgIuizbZN/r4+g4UNvbVCTpJWdoIdI71kIWwKmdIH0vJ0q7ylMVGU8PWj2s9O98y4MtFWiDTIrtNukZMMgQ==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-p8ynRS8lXnvA3tsdcUYFjbzwh2MsdBZgxrFT2G+Yb7vNvUfPF+GgHCt/0jkayfsRXA1TwtZstf+JA0V+iQXdHA==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
engines: {node: '>= 12.0.0'}
@@ -3759,20 +3768,20 @@ packages:
yaml:
optional: true
[email protected].4:
resolution: {integrity: sha512-z0WxZRV2Sg48wF0FSgMq3TFG8JS38/Qr96vZVZdX6+Em9raQTvnXQvdTFJe2m3rfyJnEHeHQ/snceIdi8D1pVg==}
[email protected].5:
resolution: {integrity: sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==}
engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0
'@types/node': ^22.0.0 || >=24.0.0
'@vitest/browser-playwright': 5.0.4
'@vitest/browser-preview': 5.0.4
'@vitest/browser-playwright': 5.0.5
'@vitest/browser-preview': 5.0.5
'@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0
'@vitest/coverage-istanbul': 5.0.4
'@vitest/coverage-v8': 5.0.4
'@vitest/ui': 5.0.4
'@vitest/coverage-istanbul': 5.0.5
'@vitest/coverage-v8': 5.0.5
'@vitest/ui': 5.0.5
happy-dom: '*'
jsdom: '*'
vite: ^6.4.0 || ^7.0.0 || ^8.0.0
@@ -3906,9 +3915,9 @@ snapshots:
preact: 10.24.3
preact-render-to-string: 6.5.11([email protected])
'@axe-core/[email protected]3.0([email protected])':
'@axe-core/[email protected]4.0([email protected])':
dependencies:
axe-core: 4.13.0
axe-core: 4.14.0
playwright-core: 1.64.1
'@babel/[email protected]': {}
@@ -4288,7 +4297,7 @@ snapshots:
protobufjs: 7.6.6
yargs: 17.7.3
'@hookform/[email protected].1([email protected](@types/[email protected])([email protected]))([email protected])':
'@hookform/[email protected].2([email protected](@types/[email protected])([email protected]))([email protected])':
dependencies:
'@standard-schema/utils': 0.3.0
react-hook-form: 7.90.0(@types/[email protected])([email protected])
@@ -4472,30 +4481,30 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@next/[email protected].1': {}
'@next/[email protected].2': {}
'@next/[email protected].1':
'@next/[email protected].2':
optional: true
'@next/[email protected].1':
'@next/[email protected].2':
optional: true
'@next/[email protected].1':
'@next/[email protected].2':
optional: true
'@next/[email protected].1':
'@next/[email protected].2':
optional: true
'@next/[email protected].1':
'@next/[email protected].2':
optional: true
'@next/[email protected].1':
'@next/[email protected].2':
optional: true
'@next/[email protected].1':
'@next/[email protected].2':
optional: true
'@next/[email protected].1':
'@next/[email protected].2':
optional: true
'@noble/[email protected]': {}
@@ -4952,20 +4961,22 @@ snapshots:
postcss-selector-parser: 7.1.6
tailwindcss: 4.3.3
'@tanstack/[email protected].3': {}
'@tanstack/[email protected].4': {}
'@tanstack/[email protected].3([email protected])':
'@tanstack/[email protected].4(@types/[email protected])([email protected])':
dependencies:
'@tanstack/query-core': 5.104.3
'@tanstack/query-core': 5.104.4
react: 19.3.1
optionalDependencies:
'@types/react': 19.3.0
'@tanstack/[email protected]9([email protected]([email protected]))([email protected])':
'@tanstack/[email protected].21([email protected]([email protected]))([email protected])':
dependencies:
'@tanstack/virtual-core': 3.19.4
'@tanstack/virtual-core': 3.19.6
react: 19.3.1
react-dom: 19.3.1([email protected])
'@tanstack/[email protected].4': {}
'@tanstack/[email protected].6': {}
'@tokenizer/[email protected]':
dependencies:
@@ -5098,7 +5109,7 @@ snapshots:
'@typescript/[email protected]':
optional: true
'@vitest/[email protected].4([email protected].4)':
'@vitest/[email protected].5([email protected].5)':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/istanbul-lib-coverage': 1.0.2
@@ -5108,7 +5119,7 @@ snapshots:
obug: 3.0.0
std-env: 4.3.0
tinyrainbow: 3.2.0
vitest: 5.0.4(@types/[email protected])(@vitest/[email protected].4)([email protected](@noble/[email protected]))([email protected](@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
vitest: 5.0.5(@types/[email protected])(@vitest/[email protected].5)([email protected](@noble/[email protected]))([email protected](@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
'@vitest/[email protected]': {}
@@ -5116,17 +5127,17 @@ snapshots:
dependencies:
'@vitest/istanbul-lib-coverage': 1.0.2
'@vitest/[email protected].4([email protected](@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))':
'@vitest/[email protected].5([email protected](@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))':
dependencies:
'@jridgewell/trace-mapping': 0.3.31
'@vitest/spy': 5.0.4
'@vitest/spy': 5.0.5
estree-walker: 3.0.3
magic-string: 1.4.3
optionalDependencies:
msw: 3.0.3(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
vite: 8.3.4(@types/[email protected])([email protected])([email protected])([email protected])([email protected])
'@vitest/[email protected].4': {}
'@vitest/[email protected].5': {}
[email protected]:
dependencies:
@@ -5190,7 +5201,7 @@ snapshots:
[email protected]: {}
[email protected]3.0: {}
[email protected]4.0: {}
[email protected]: {}
@@ -5555,9 +5566,9 @@ snapshots:
cross-spawn: 7.0.6
signal-exit: 4.1.0
framer-motion@14.2.0([email protected]([email protected]))([email protected]):
framer-motion@14.3.0([email protected]([email protected]))([email protected]):
dependencies:
motion-dom: 14.2.0
motion-dom: 14.3.0
motion-utils: 14.1.1
tslib: 2.8.1
optionalDependencies:
@@ -5829,7 +5840,7 @@ snapshots:
[email protected]: {}
[email protected]7.0(@types/[email protected])([email protected]):
[email protected]9.0(@types/[email protected])([email protected]):
dependencies:
react: 19.3.1
optionalDependencies:
@@ -5873,15 +5884,15 @@ snapshots:
[email protected]: {}
motion-dom@14.2.0:
motion-dom@14.3.0:
dependencies:
motion-utils: 14.1.1
[email protected]: {}
motion@14.2.0([email protected]([email protected]))([email protected]):
motion@14.3.0([email protected]([email protected]))([email protected]):
dependencies:
framer-motion: 14.2.0([email protected]([email protected]))([email protected])
framer-motion: 14.3.0([email protected]([email protected]))([email protected])
tslib: 2.8.1
optionalDependencies:
react: 19.3.1
@@ -5910,7 +5921,7 @@ snapshots:
- '@types/node'
- supports-color
[email protected].1:
[email protected].2:
dependencies:
'@borewit/text-codec': 0.2.2
'@tokenizer/token': 0.3.0
@@ -5918,7 +5929,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
@@ -5951,15 +5962,15 @@ snapshots:
dependencies:
content-type: 2.1.0
[email protected]([email protected].1(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected]):
[email protected]([email protected].2(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected]):
dependencies:
'@auth/core': 0.41.3
next: 16.4.1(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
next: 16.4.2(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
react: 19.3.1
[email protected]: {}
[email protected](@swc/[email protected])(@types/[email protected])([email protected].1(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected]):
[email protected](@swc/[email protected])(@types/[email protected])([email protected].2(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected]):
dependencies:
'@eloqnt/config': 0.1.1
'@eloqnt/format-json': 0.1.0
@@ -5969,7 +5980,7 @@ snapshots:
'@swc/core': 1.16.18(@swc/[email protected])
icu-minify: 4.14.9
negotiator: 1.1.0
next: 16.4.1(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
next: 16.4.2(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
next-intl-swc-plugin-extractor: 4.14.9
react: 19.3.1
use-intl: 4.14.9(@types/[email protected])([email protected])
@@ -5978,9 +5989,9 @@ snapshots:
transitivePeerDependencies:
- '@swc/helpers'
[email protected].1(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]):
[email protected].2(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]):
dependencies:
'@next/env': 16.4.1
'@next/env': 16.4.2
'@swc/helpers': 0.5.23
baseline-browser-mapping: 2.11.28
caniuse-lite: 1.0.30001816
@@ -5989,14 +6000,14 @@ snapshots:
react-dom: 19.3.1([email protected])
styled-jsx: 5.1.6([email protected])
optionalDependencies:
'@next/swc-darwin-arm64': 16.4.1
'@next/swc-darwin-x64': 16.4.1
'@next/swc-linux-arm64-gnu': 16.4.1
'@next/swc-linux-arm64-musl': 16.4.1
'@next/swc-linux-x64-gnu': 16.4.1
'@next/swc-linux-x64-musl': 16.4.1
'@next/swc-win32-arm64-msvc': 16.4.1
'@next/swc-win32-x64-msvc': 16.4.1
'@next/swc-darwin-arm64': 16.4.2
'@next/swc-darwin-x64': 16.4.2
'@next/swc-linux-arm64-gnu': 16.4.2
'@next/swc-linux-arm64-musl': 16.4.2
'@next/swc-linux-x64-gnu': 16.4.2
'@next/swc-linux-x64-musl': 16.4.2
'@next/swc-win32-arm64-msvc': 16.4.2
'@next/swc-win32-x64-msvc': 16.4.2
'@playwright/test': 1.64.1
sharp: 0.35.5(@types/[email protected])
transitivePeerDependencies:
@@ -6237,7 +6248,7 @@ snapshots:
[email protected]: {}
[email protected]3.0:
[email protected]4.1:
dependencies:
postal-mime: 2.7.6
standardwebhooks: 1.1.1
@@ -6427,6 +6438,10 @@ snapshots:
dependencies:
'@tokenizer/token': 0.3.0
[email protected]:
dependencies:
'@tokenizer/token': 0.3.0
[email protected]([email protected]):
dependencies:
client-only: 0.0.1
@@ -6646,10 +6661,10 @@ snapshots:
tsx: 4.23.15
yaml: 2.9.1
[email protected].4(@types/[email protected])(@vitest/[email protected].4)([email protected](@noble/[email protected]))([email protected](@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])):
[email protected].5(@types/[email protected])(@vitest/[email protected].5)([email protected](@noble/[email protected]))([email protected](@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])):
dependencies:
'@types/chai': 5.2.3
'@vitest/mocker': 5.0.4([email protected](@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
'@vitest/mocker': 5.0.5([email protected](@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
chai: 6.3.0
es-module-lexer: 2.3.2
expect-type: 1.4.0
@@ -6664,7 +6679,7 @@ snapshots:
why-is-node-running: 3.2.1
optionalDependencies:
'@types/node': 26.6.5
'@vitest/coverage-v8': 5.0.4([email protected].4)
'@vitest/coverage-v8': 5.0.5([email protected].5)
jsdom: 30.1.3(@noble/[email protected])
transitivePeerDependencies:
- msw
-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