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

No files matched your search

+2 -11
View File
@@ -3,13 +3,8 @@
# ============================================================================== # ==============================================================================
# --- DATABASE (High Performance Pooling & Strict Timeouts) --- # --- DATABASE (High Performance Pooling & Strict Timeouts) ---
# The pool size is governed by DATABASE_POOL_SIZE (src/lib/db.ts); the DATABASE_URL="mysql://user:password@localhost:3306/dbname?charset=utf8mb4&connection_limit=150&connect_timeout=5"
# `connection_limit` in the URL is only honoured by tools that read the URL DATABASE_POOL_SIZE=150
# 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_IDLE_TIMEOUT_MS=60000 DATABASE_IDLE_TIMEOUT_MS=60000
DATABASE_CONNECT_TIMEOUT_MS=5000 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= APP_KEY=base64:your-app-key-here=
# Bcrypt cost factor for new password hashes. # Bcrypt cost factor for new password hashes.
BCRYPT_COST=12 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) --- # --- ANTI-DDOS (app-layer gate, production only) ---
# On by default in production. Set to "false" to disable (not recommended). # On by default in production. Set to "false" to disable (not recommended).
+1 -9
View File
@@ -158,15 +158,7 @@ const nextConfig: NextConfig = {
}, },
experimental: { experimental: {
// Barrel-heavy packages: without this every import pulls the whole optimizePackageImports: ["lucide-react", "date-fns"],
// module graph into the chunk that first touches it.
optimizePackageImports: [
"lucide-react",
"motion",
"@dnd-kit/core",
"@dnd-kit/sortable",
"@tanstack/react-virtual",
],
useTypeScriptCli: true, useTypeScriptCli: true,
hideLogsAfterAbort: true, hideLogsAfterAbort: true,
}, },
+9 -9
View File
@@ -18,8 +18,8 @@
"format": "biome format --write .", "format": "biome format --write .",
"diag:permissions": "tsx scripts/diagnose-permission-page.ts", "diag:permissions": "tsx scripts/diagnose-permission-page.ts",
"jobs:worker": "node --conditions=react-server --import tsx scripts/jobs-worker.ts", "jobs:worker": "node --conditions=react-server --import tsx scripts/jobs-worker.ts",
"test": "bash scripts/with-memory-cap.sh 8g vitest run", "test": "bash scripts/with-memory-cap.sh 8g vitest run --coverage.enabled=false",
"test:coverage": "bash scripts/with-memory-cap.sh 8g vitest run --coverage.enabled=true", "test:coverage": "bash scripts/with-memory-cap.sh 8g vitest run",
"typecheck": "bash scripts/with-memory-cap.sh 6g tsc --noEmit", "typecheck": "bash scripts/with-memory-cap.sh 6g tsc --noEmit",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:introspect": "drizzle-kit introspect", "db:introspect": "drizzle-kit introspect",
@@ -34,7 +34,7 @@
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"gamedata:compress": "node scripts/compress-gamedata.mjs", "gamedata:compress": "node scripts/compress-gamedata.mjs",
"hk:matrix:check": "tsx scripts/verify-housekeeping-matrix.ts", "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", "assets:editor": "node scripts/copy-editor-assets.mjs",
"deps:audit": "pnpm audit --audit-level=high", "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", "analyze": "pnpm assets:editor && bash scripts/with-memory-cap.sh 10g next build --webpack && node scripts/performance-report.mjs --output-dir build-reports",
@@ -58,7 +58,7 @@
"@formatjs/icu-messageformat-parser": "3.5.21", "@formatjs/icu-messageformat-parser": "3.5.21",
"@hookform/resolvers": "5.9.1", "@hookform/resolvers": "5.9.1",
"@tanstack/react-query": "5.104.3", "@tanstack/react-query": "5.104.3",
"@tanstack/react-virtual": "3.14.20", "@tanstack/react-virtual": "3.14.19",
"class-variance-authority": "0.7.1", "class-variance-authority": "0.7.1",
"clsx": "2.1.1", "clsx": "2.1.1",
"cmdk": "1.1.1", "cmdk": "1.1.1",
@@ -72,10 +72,10 @@
"jszip": "3.10.2", "jszip": "3.10.2",
"lucide-react": "1.57.0", "lucide-react": "1.57.0",
"lzma-wasm": "1.0.7", "lzma-wasm": "1.0.7",
"motion": "14.3.0", "motion": "14.2.0",
"music-metadata": "12.2.1", "music-metadata": "12.2.1",
"mysql2": "3.24.6", "mysql2": "3.24.6",
"next": "16.4.2", "next": "16.4.1",
"next-auth": "5.0.0-beta.32", "next-auth": "5.0.0-beta.32",
"next-intl": "4.14.9", "next-intl": "4.14.9",
"otplib": "13.5.0", "otplib": "13.5.0",
@@ -92,7 +92,7 @@
"zod": "4.6.7" "zod": "4.6.7"
}, },
"devDependencies": { "devDependencies": {
"@axe-core/playwright": "4.14.0", "@axe-core/playwright": "4.13.0",
"@babel/parser": "8.0.7", "@babel/parser": "8.0.7",
"@biomejs/biome": "2.5.15", "@biomejs/biome": "2.5.15",
"@playwright/test": "1.64.1", "@playwright/test": "1.64.1",
@@ -102,7 +102,7 @@
"@types/node": "26.6.5", "@types/node": "26.6.5",
"@types/react": "19.3.0", "@types/react": "19.3.0",
"@types/react-dom": "19.3.0", "@types/react-dom": "19.3.0",
"@vitest/coverage-v8": "5.0.5", "@vitest/coverage-v8": "5.0.4",
"drizzle-kit": "0.31.11", "drizzle-kit": "0.31.11",
"esbuild": "0.28.2", "esbuild": "0.28.2",
"msw": "^3.0.3", "msw": "^3.0.3",
@@ -113,6 +113,6 @@
"tsx": "4.23.15", "tsx": "4.23.15",
"typescript": "7.0.2", "typescript": "7.0.2",
"vite": "8.3.4", "vite": "8.3.4",
"vitest": "5.0.5" "vitest": "5.0.4"
} }
} }
+139 -140
View File
@@ -195,8 +195,8 @@ importers:
specifier: 5.104.3 specifier: 5.104.3
version: 5.104.3([email protected]) version: 5.104.3([email protected])
'@tanstack/react-virtual': '@tanstack/react-virtual':
specifier: 3.14.20 specifier: 3.14.19
version: 3.14.20([email protected]([email protected]))([email protected]) version: 3.14.19([email protected]([email protected]))([email protected])
class-variance-authority: class-variance-authority:
specifier: 0.7.1 specifier: 0.7.1
version: 0.7.1 version: 0.7.1
@@ -220,7 +220,7 @@ importers:
version: 6.0.0 version: 6.0.0
isomorphic-dompurify: isomorphic-dompurify:
specifier: ^4.5.0 specifier: ^4.5.0
version: 4.5.0(@noble/[email protected]) version: 4.6.0(@noble/[email protected])
jpeg-js: jpeg-js:
specifier: 0.4.4 specifier: 0.4.4
version: 0.4.4 version: 0.4.4
@@ -237,8 +237,8 @@ importers:
specifier: 1.0.7 specifier: 1.0.7
version: 1.0.7 version: 1.0.7
motion: motion:
specifier: 14.3.0 specifier: 14.2.0
version: 14.3.0([email protected]([email protected]))([email protected]) version: 14.2.0([email protected]([email protected]))([email protected])
music-metadata: music-metadata:
specifier: 12.2.1 specifier: 12.2.1
version: 12.2.1 version: 12.2.1
@@ -246,14 +246,14 @@ importers:
specifier: 3.24.6 specifier: 3.24.6
version: 3.24.6(@types/[email protected]) version: 3.24.6(@types/[email protected])
next: next:
specifier: 16.4.2 specifier: 16.4.1
version: 16.4.2(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]) version: 16.4.1(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
next-auth: next-auth:
specifier: 5.0.0-beta.32 specifier: 5.0.0-beta.32
version: 5.0.0-beta.32([email protected].2(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected]) version: 5.0.0-beta.32([email protected].1(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected])
next-intl: next-intl:
specifier: 4.14.9 specifier: 4.14.9
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]) 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])
otplib: otplib:
specifier: 13.5.0 specifier: 13.5.0
version: 13.5.0 version: 13.5.0
@@ -292,8 +292,8 @@ importers:
version: 4.6.7 version: 4.6.7
devDependencies: devDependencies:
'@axe-core/playwright': '@axe-core/playwright':
specifier: 4.14.0 specifier: 4.13.0
version: 4.14.0([email protected]) version: 4.13.0([email protected])
'@babel/parser': '@babel/parser':
specifier: 8.0.7 specifier: 8.0.7
version: 8.0.7 version: 8.0.7
@@ -322,8 +322,8 @@ importers:
specifier: 19.3.0 specifier: 19.3.0
version: 19.3.0(@types/[email protected]) version: 19.3.0(@types/[email protected])
'@vitest/coverage-v8': '@vitest/coverage-v8':
specifier: 5.0.5 specifier: 5.0.4
version: 5.0.5([email protected].5) version: 5.0.4([email protected].4)
drizzle-kit: drizzle-kit:
specifier: 0.31.11 specifier: 0.31.11
version: 0.31.11 version: 0.31.11
@@ -355,8 +355,8 @@ importers:
specifier: 8.3.4 specifier: 8.3.4
version: 8.3.4(@types/[email protected])([email protected])([email protected])([email protected])([email protected]) version: 8.3.4(@types/[email protected])([email protected])([email protected])([email protected])([email protected])
vitest: vitest:
specifier: 5.0.5 specifier: 5.0.4
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])) 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]))
packages: packages:
@@ -364,12 +364,12 @@ packages:
resolution: {integrity: sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==} resolution: {integrity: sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==}
engines: {node: '>=10'} engines: {node: '>=10'}
'@asamuzakjp/[email protected].5': '@asamuzakjp/[email protected].7':
resolution: {integrity: sha512-jCpAvj8apa7SKHZYogk0rbXB3Kb0hLFrpkWF+Ap+vBA5rirItnpv9NKyAyEuDuFizvl0qwnj6JWR70RGJ0rOlw==} resolution: {integrity: sha512-Q7ioeear07HwixdIitIn9KS1LZi97CEDFCSjPNm9v+y8KNbsu9q4Lrd4zUQW4faED+E7qxvfeVnbNRW46wFKuw==}
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
'@asamuzakjp/[email protected].3': '@asamuzakjp/[email protected].4':
resolution: {integrity: sha512-3+777rEPsOOjoy5/sRmIzHnoRDaLpm3XWY2dmWn/N7f/XwDG0bcpnFaKL3X9FYJUbNwLmpcJC1bUYexJRxsFlw==} resolution: {integrity: sha512-A6f1nKy99I6tyoK82U6kBHvQQZmdHmTmPzJYPY/0eaOW0r8/qPmEOwjGHs+hL/vCgOwQTupu4LT1XtXNUuDruA==}
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
'@auth/[email protected]': '@auth/[email protected]':
@@ -386,9 +386,8 @@ packages:
nodemailer: nodemailer:
optional: true optional: true
'@axe-core/[email protected]4.0': '@axe-core/[email protected]3.0':
resolution: {integrity: sha512-SGUhG5PIHd2eMdpgBF2PyQLOhX+qyDR2PT4cmoS4Qk2ak+jX/rrSvst9l0PGv5WTHolKpUMvVmoHpr4cZgf1RQ==} resolution: {integrity: sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==}
engines: {node: '>=22.12.0'}
peerDependencies: peerDependencies:
playwright-core: '>= 1.0.0' playwright-core: '>= 1.0.0'
@@ -1275,61 +1274,61 @@ packages:
'@msw/[email protected]': '@msw/[email protected]':
resolution: {integrity: sha512-DdeBLQxQ+ZrtmwEmaj7GQ7lbzPCgbbbyAqOz/I4ulo68hwBpfdNvQJ5bmihJUkAAKOeCmqCIG3OFZGosqFN4mA==} resolution: {integrity: sha512-DdeBLQxQ+ZrtmwEmaj7GQ7lbzPCgbbbyAqOz/I4ulo68hwBpfdNvQJ5bmihJUkAAKOeCmqCIG3OFZGosqFN4mA==}
'@mswjs/[email protected].8': '@mswjs/[email protected].9':
resolution: {integrity: sha512-lZBylf8kTt096RGnnVfHq+5w4DTRgmFjf59/IVMPBBwKrViumV1l95LZ/LrWt1LZlsJymHOSnoP8mX/kPXZPWw==} resolution: {integrity: sha512-NmEzpWbbiWpudgqKVZtGdSAvQGIYyj7Rf8ea+1hJEo+Dn0YvPHtiz577US7kGg0sCaZtPelrdqV4SfwwGJq1cA==}
engines: {node: '>=22'} engines: {node: '>=22'}
'@next/[email protected].2': '@next/[email protected].1':
resolution: {integrity: sha512-nTUTFH7G5/J/fmpkjVLegOkqyKOP0CEsJcMsyO4UCYeN1su+YMW/MfaSErnJskL7XM+UDK6aTvslxsRCyya/sA==} resolution: {integrity: sha512-5sUUrrSvs/2/2LnLoesybMGx12EtPB46fdZ6RhAw0XDGSPxU2LFyQBB7yuEiOFFJPKmBY6+q2jTr1/AMbnp0/g==}
'@next/[email protected].2': '@next/[email protected].1':
resolution: {integrity: sha512-ONkhFvZNFWlsEHOM/81AtxdyuJP2KRYKYR0JxPwvcu1AuPMMMgcL+wW2luldJvMtZwKtUxoSkCychW5wIeoYSw==} resolution: {integrity: sha512-U+hQ5Gj3fpzf4fveTIF3dpUFYhaSW8vHhdAnbiIomzXzg7boreYFgBqprepXFtW4sw+aTttiNmg0gew/iFNhbg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@next/[email protected].2': '@next/[email protected].1':
resolution: {integrity: sha512-luPCMbOsCoaF+kXJEUjmjmOZg1lC3BflhmUu+ZmW4tBu2vW/4AXak8JBFvePizGkxVmNiAw/r9ra+6UviCWtWQ==} resolution: {integrity: sha512-/hqC3V1XLUjhMASf/WridKdO9FXoEgN3HBWmtKbj00MPgnltutfraWJ3bYTpCRIgEIpbjJyOVLpYgAhh/BkdcQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@next/[email protected].2': '@next/[email protected].1':
resolution: {integrity: sha512-xhwFTAncJyaCNy5KqKKuKaIIdCy1/1ZV1s5S1G0hhT7wGMkDTlfJ6aQbfRFnIG/s3/kDrxQ8aycVe+c5QnP8ow==} resolution: {integrity: sha512-GHzncHbcaS9g95kcvTNtKQW3aCLoR+xc3Jm7qrkpk1R+D2BjMHIPyvQGRPKCdMX95BsH/421jBUK06q9tEwzjw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@next/[email protected].2': '@next/[email protected].1':
resolution: {integrity: sha512-T1PNmfbPcxr5ndtWUL/3wQtLXsz808lq6sc2GrNT5k2oquF7UyqZddHSuAewHVsWsGXe6nsl9HoN1YNriVjuSg==} resolution: {integrity: sha512-1SuJyDxW/PR9dHJat/ZRgcYLhuSZ+1psP4DlKb2tKIuXbjygqpLrJWRSq+onZQBv8LcmU67EIrmb6c3XTaJLjw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@next/[email protected].2': '@next/[email protected].1':
resolution: {integrity: sha512-N9cDUOCGtkJG5PNKDFZUF3xXndrJJcpLp121uAGjIGvO/U2p9tfdNOM9htj7+5f72l0m8rMw/cvYacUJBQoudg==} resolution: {integrity: sha512-Istng1hMGp9TGO4lUqeKLd70MPN/OOkucVTP2Nmj+gLkwBgmPqUw8CJYBCC7nbl/B/23bBCcjCI5Hsh6hazC1A==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@next/[email protected].2': '@next/[email protected].1':
resolution: {integrity: sha512-yY2uh1YhX6ufuYy2FCbw1dqblbhI57GLYAJfCkqD9auTv6pLrH67Uccjc3QMKBeCYAG6lkgu08ExmX1bbjejjQ==} resolution: {integrity: sha512-aSC9wMNiZRRFVpPkPWBzRwLQxIGm/CiQcXyFF1XNcgodb+uMxdk7aSSGGpDXCbnvZdf4itjHxpOd5h7i7zaiYw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@next/[email protected].2': '@next/[email protected].1':
resolution: {integrity: sha512-j/ArWRIHpMMB6wYLMhr2puTguBt70lNU/Cmjl9p6dTDE8DGknYJMP9PdwPk2dqpCNL/KIX9yfngDvIesxJPmsg==} resolution: {integrity: sha512-t/myvwFDlypMUjRvbBf8qLPyuaB0iXYYdBtwMt6DM6f+60wtGr4bSYholdPqNeV5VUXZhrpMwfza829kmhS3iA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@next/[email protected].2': '@next/[email protected].1':
resolution: {integrity: sha512-AA4edyWHDko3NCSo0x6GomsdgFb0oR1J3MXU1b5xt6FRFg4AucrMPW3S0hCe/9onU0O1BbpGQBsqo1r0Gneu1g==} resolution: {integrity: sha512-6ICqPo9awCkrtYNPhwkx1uU7rlFWrZRrIboDHS2LU+jv088UBYQtmJ25h6pCp27/+FZjF6BQ4Te4ErugyMeuBg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
@@ -1961,14 +1960,14 @@ packages:
peerDependencies: peerDependencies:
react: ^18 || ^19 react: ^18 || ^19
'@tanstack/[email protected].20': '@tanstack/[email protected].19':
resolution: {integrity: sha512-9hbhFlc0lznjXmWYDB/KEl7PJxEPz0Qnj2FzJFse98miGvXtx7jzjE5jlasmtczmZ6TxXsyI4v7PuyGQm2q1nQ==} resolution: {integrity: sha512-zmVdJO8DDqHphZ2H6SnTmgpVXcUy5DprB+clZF0ik679/d0MQ1UPEb2jtaX6F5VTAX4o3ozSLQaRCnybS2KYLA==}
peerDependencies: peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 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 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/[email protected].5': '@tanstack/[email protected].4':
resolution: {integrity: sha512-X/wq6DtSK/XClznMM+I4JE63yyIwmyPk4Ts83bD0VKaIbwHNdRsO5QipCPX+3OovqYyr0puQNpbmp8+SPhspNg==} resolution: {integrity: sha512-ygZ3O99Il18dl0gG0q7sH78iSkb96F07G+xK9V83eCgV8NQbcNd7R+cgcJJMFO7llBoebd0A82sCCstQq2Vp6Q==}
'@tokenizer/[email protected]': '@tokenizer/[email protected]':
resolution: {integrity: sha512-56U2O6fDLFtqokwWh597IdeUgtM7j+TT9tb1soybhgA6cy163gAcVmoQr8H4Ud/CkgCkViwcuLyqtn6wYqw59Q==} resolution: {integrity: sha512-56U2O6fDLFtqokwWh597IdeUgtM7j+TT9tb1soybhgA6cy163gAcVmoQr8H4Ud/CkgCkViwcuLyqtn6wYqw59Q==}
@@ -2147,11 +2146,11 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@vitest/[email protected].5': '@vitest/[email protected].4':
resolution: {integrity: sha512-4KpkGdIrSqBihx/JyP/e+S+IKB98etg1XDQiLAxksLfcShnwi1VReGeOT9NI43hLHbknosKp/1CCLn9Zgcs8mg==} resolution: {integrity: sha512-P5YcQY8Y6RmoE9xRXI38rT8pssbcubEzqtYTM2XQoxOXMDn04nRaTI1SM8qCl9/NfW3ag9GlzdSXu7IwO5Ft8A==}
peerDependencies: peerDependencies:
'@vitest/browser': 5.0.5 '@vitest/browser': 5.0.4
vitest: 5.0.5 vitest: 5.0.4
peerDependenciesMeta: peerDependenciesMeta:
'@vitest/browser': '@vitest/browser':
optional: true optional: true
@@ -2164,8 +2163,8 @@ packages:
resolution: {integrity: sha512-gUsfXZJbzPamoIY5TvHFiMMoXESBrUMo+xqaj+rYrWI69+EnvRlYBlP96ZnHPY4vX8kyUpgAnFUCg5wZG/HkDQ==} resolution: {integrity: sha512-gUsfXZJbzPamoIY5TvHFiMMoXESBrUMo+xqaj+rYrWI69+EnvRlYBlP96ZnHPY4vX8kyUpgAnFUCg5wZG/HkDQ==}
engines: {node: '>=22'} engines: {node: '>=22'}
'@vitest/[email protected].5': '@vitest/[email protected].4':
resolution: {integrity: sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==} resolution: {integrity: sha512-tqAlrYWce4iUxhr3ncNohejtCTOcHeE8XWrSNZY3OhPoDn01yeGv+gnaBy64ATp7o6pb03eIbBAjzmibpc9vIQ==}
peerDependencies: peerDependencies:
msw: ^2.4.9 || ^3.0.0 msw: ^2.4.9 || ^3.0.0
vite: ^6.0.0 || ^7.0.0 || ^8.0.0 vite: ^6.0.0 || ^7.0.0 || ^8.0.0
@@ -2175,8 +2174,8 @@ packages:
vite: vite:
optional: true optional: true
'@vitest/[email protected].5': '@vitest/[email protected].4':
resolution: {integrity: sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==} resolution: {integrity: sha512-MZKk/gSkm2q+ICobMxCpH1mA+cCAWm8WV7UhxlqGjq/sJ3AGc1BqFOs5CRVXxH78wQtme9q/NAAgBquj4AcyMw==}
[email protected]: [email protected]:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
@@ -2234,8 +2233,8 @@ packages:
resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==}
engines: {node: '>= 6.0.0'} engines: {node: '>= 6.0.0'}
[email protected]4.0: [email protected]3.0:
resolution: {integrity: sha512-9WTZxEjsZ7b13TH8JPmbV2z8CHbl80/2hm3XPEG4JgNdQLK81IBRXmSxHfMAOkSqQeRxT/0dwNDz2GOm3zzpcQ==} resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==}
engines: {node: '>=4'} engines: {node: '>=4'}
[email protected]: [email protected]:
@@ -2293,8 +2292,8 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
[email protected]8: [email protected]9:
resolution: {integrity: sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==} resolution: {integrity: sha512-YeesxhUmR4h5y5V8KF8iBCqJb8YFrBJ++XBTeGcWIaQOYXGN19JM0FoiOjAhwuDoln8owMgXcWBmgHtYSkky2w==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
hasBin: true hasBin: true
@@ -2670,8 +2669,8 @@ packages:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'} engines: {node: '>=14'}
framer-motion@14.3.0: framer-motion@14.2.0:
resolution: {integrity: sha512-d8WCrU0ZQn7ukKMykCsDyIOwb/taZeDqqrDBK0du5GO0AO/ynz8fdR/LIIz9Pyy3n3tGwrneR+n0egyrrLa3fw==} resolution: {integrity: sha512-cD2gfFujHt4r/+dZpcn5yziu7VRwQAQDakwIm/bICYXbafnpHCYokV+PnEXqErnSdv6Eh28a+SRF+f3ndGcMsQ==}
peerDependencies: peerDependencies:
react: ^18.0.0 || ^19.0.0 react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0
@@ -2784,8 +2783,8 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
isomorphic-dompurify@4.5.0: isomorphic-dompurify@4.6.0:
resolution: {integrity: sha512-5bUPydvGp1pJx27Cff7ScHsgJkETjliiSqpQjPZHMBO6hgqZem38qsXF4TkUYu83yBlbb3rYhypgoo6mMvz48w==} resolution: {integrity: sha512-ZLYxxq2efNUsaUMViQXML/HMBkaJSd8WAFcNJQfWOGi+KLGQyZA8Ewpcj7yWur6KzMHlrZNXTXr8trbx/bkZRw==}
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
[email protected]: [email protected]:
@@ -3052,14 +3051,14 @@ packages:
engines: {node: '>=10'} engines: {node: '>=10'}
hasBin: true hasBin: true
motion-dom@14.3.0: motion-dom@14.2.0:
resolution: {integrity: sha512-rcKO4aEJzAqDEsrH6qFzhLZxs7bNqZpiUEuEATz9LaRwt6dG7hZEbcFUSHNR8NECaKYM0Iuw6Iv6NpWEGXDI+w==} resolution: {integrity: sha512-TDdxnjAavh2Qm4lQCQI3uoTx9IR/Id2PI8JNEXW3AcwwV2yvhoimAX3/TnE+x82gDHfPq2yx/cJ58TTw5mMr0A==}
[email protected]: [email protected]:
resolution: {integrity: sha512-3VvN5Uvf8B6qG+OfOui6Nht5EWp+vLuU3IqcAj7ItZGKqtmTjCebl5sGZF7/08gEHRnEl1AP/fIwbcEyVrcQGQ==} resolution: {integrity: sha512-3VvN5Uvf8B6qG+OfOui6Nht5EWp+vLuU3IqcAj7ItZGKqtmTjCebl5sGZF7/08gEHRnEl1AP/fIwbcEyVrcQGQ==}
motion@14.3.0: motion@14.2.0:
resolution: {integrity: sha512-yh/nHYjikl68+FTxAMywFYzzj21/oGY7RGCfkqN5zpuT1rk3rp0lKfVU8EUq0A64qQeGnxuKUJWP85MipqdLBw==} resolution: {integrity: sha512-45cDwjZh0yOMZtnX3hIFPIPmzCWz/RC2bD/niWVIssGBjJlMmosbDILG9a+ZyoP635Z5xrbKvuo10spv9dBdnw==}
peerDependencies: peerDependencies:
react: ^18.0.0 || ^19.0.0 react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0
@@ -3147,8 +3146,8 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
[email protected].2: [email protected].1:
resolution: {integrity: sha512-r+oBJojfefUjOiWkBhXgLS+igxX/7BWyK59uHnyOyplMSLtBZ+QLP3okrdYTbrfrbrmNTVWLBPaHDX9KRaIt3w==} resolution: {integrity: sha512-uc657V62E34fOI6W5NtG9IzwsPcms0Z60grz2MJGtglgD+pTFnNt4Yw2PzaU622tfX4vhrDgLZzicE2YlhI20Q==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
@@ -3433,8 +3432,8 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
[email protected].3: [email protected].4:
resolution: {integrity: sha512-xletSdAI5efyVJjsatBbzDkXl3M/Zmad3xfnQe7ZXuBB0yNwW7C2WFsNGjdRNfZunmNHQuX79xs/ACMTLCdcew==} resolution: {integrity: sha512-iEmdEew+Tn1BwMGMP8LzGBEPC7Mf1sySjglCeEPb/Vchm9bnGezePvacYcId+fGprQzIng+TBkzqfJ/qS7AsBg==}
[email protected]: [email protected]:
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
@@ -3543,8 +3542,8 @@ packages:
resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==}
engines: {node: '>=14.16'} engines: {node: '>=14.16'}
strtok3@10.5.0: strtok3@10.6.1:
resolution: {integrity: sha512-PgAsgIuizbZN/r4+g4UNvbVCTpJWdoIdI71kIWwKmdIH0vJ0q7ylMVGU8PWj2s9O98y4MtFWiDTIrtNukZMMgQ==} resolution: {integrity: sha512-p8ynRS8lXnvA3tsdcUYFjbzwh2MsdBZgxrFT2G+Yb7vNvUfPF+GgHCt/0jkayfsRXA1TwtZstf+JA0V+iQXdHA==}
engines: {node: '>=18'} engines: {node: '>=18'}
[email protected]: [email protected]:
@@ -3673,8 +3672,8 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==}
[email protected].1: [email protected].2:
resolution: {integrity: sha512-usz3OVQBeh1LxNn0DzU0voLXMgn5CU/WUdpBsY6Ti5afNao+vTkmn4H/NxoXolc10cOqe2+YIG0Hspq2rsl64w==} resolution: {integrity: sha512-XtuDWVdqTLr3NAxm2ly9/hU6kUsJOgGh1RASzMBbsw6aWCOaQzHdW+QQDfdERjfJ2V5vL5pRPa4RetO9UfivDg==}
engines: {node: '>=22.19.0'} engines: {node: '>=22.19.0'}
[email protected]: [email protected]:
@@ -3760,20 +3759,20 @@ packages:
yaml: yaml:
optional: true optional: true
[email protected].5: [email protected].4:
resolution: {integrity: sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==} resolution: {integrity: sha512-z0WxZRV2Sg48wF0FSgMq3TFG8JS38/Qr96vZVZdX6+Em9raQTvnXQvdTFJe2m3rfyJnEHeHQ/snceIdi8D1pVg==}
engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
'@edge-runtime/vm': '*' '@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0 '@opentelemetry/api': ^1.9.0
'@types/node': ^22.0.0 || >=24.0.0 '@types/node': ^22.0.0 || >=24.0.0
'@vitest/browser-playwright': 5.0.5 '@vitest/browser-playwright': 5.0.4
'@vitest/browser-preview': 5.0.5 '@vitest/browser-preview': 5.0.4
'@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0
'@vitest/coverage-istanbul': 5.0.5 '@vitest/coverage-istanbul': 5.0.4
'@vitest/coverage-v8': 5.0.5 '@vitest/coverage-v8': 5.0.4
'@vitest/ui': 5.0.5 '@vitest/ui': 5.0.4
happy-dom: '*' happy-dom: '*'
jsdom: '*' jsdom: '*'
vite: ^6.4.0 || ^7.0.0 || ^8.0.0 vite: ^6.4.0 || ^7.0.0 || ^8.0.0
@@ -3884,7 +3883,7 @@ snapshots:
'@alloc/[email protected]': {} '@alloc/[email protected]': {}
'@asamuzakjp/[email protected].5': '@asamuzakjp/[email protected].7':
dependencies: dependencies:
'@csstools/css-calc': 3.4.3(@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected]) '@csstools/css-calc': 3.4.3(@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected])
'@csstools/css-color-parser': 4.2.6(@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected]) '@csstools/css-color-parser': 4.2.6(@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected])
@@ -3892,7 +3891,7 @@ snapshots:
'@csstools/css-tokenizer': 4.0.2 '@csstools/css-tokenizer': 4.0.2
lru-cache: 11.5.3 lru-cache: 11.5.3
'@asamuzakjp/[email protected].3': '@asamuzakjp/[email protected].4':
dependencies: dependencies:
bidi-js: 1.1.0 bidi-js: 1.1.0
css-tree: 3.2.1 css-tree: 3.2.1
@@ -3907,9 +3906,9 @@ snapshots:
preact: 10.24.3 preact: 10.24.3
preact-render-to-string: 6.5.11([email protected]) preact-render-to-string: 6.5.11([email protected])
'@axe-core/[email protected]4.0([email protected])': '@axe-core/[email protected]3.0([email protected])':
dependencies: dependencies:
axe-core: 4.14.0 axe-core: 4.13.0
playwright-core: 1.64.1 playwright-core: 1.64.1
'@babel/[email protected]': {} '@babel/[email protected]': {}
@@ -4462,7 +4461,7 @@ snapshots:
'@msw/[email protected]': {} '@msw/[email protected]': {}
'@mswjs/[email protected].8': '@mswjs/[email protected].9':
dependencies: dependencies:
'@open-draft/until': 3.0.1 '@open-draft/until': 3.0.1
'@types/debug': 4.1.13 '@types/debug': 4.1.13
@@ -4473,30 +4472,30 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@next/[email protected].2': {} '@next/[email protected].1': {}
'@next/[email protected].2': '@next/[email protected].1':
optional: true optional: true
'@next/[email protected].2': '@next/[email protected].1':
optional: true optional: true
'@next/[email protected].2': '@next/[email protected].1':
optional: true optional: true
'@next/[email protected].2': '@next/[email protected].1':
optional: true optional: true
'@next/[email protected].2': '@next/[email protected].1':
optional: true optional: true
'@next/[email protected].2': '@next/[email protected].1':
optional: true optional: true
'@next/[email protected].2': '@next/[email protected].1':
optional: true optional: true
'@next/[email protected].2': '@next/[email protected].1':
optional: true optional: true
'@noble/[email protected]': {} '@noble/[email protected]': {}
@@ -4960,13 +4959,13 @@ snapshots:
'@tanstack/query-core': 5.104.3 '@tanstack/query-core': 5.104.3
react: 19.3.1 react: 19.3.1
'@tanstack/[email protected].20([email protected]([email protected]))([email protected])': '@tanstack/[email protected].19([email protected]([email protected]))([email protected])':
dependencies: dependencies:
'@tanstack/virtual-core': 3.19.5 '@tanstack/virtual-core': 3.19.4
react: 19.3.1 react: 19.3.1
react-dom: 19.3.1([email protected]) react-dom: 19.3.1([email protected])
'@tanstack/[email protected].5': {} '@tanstack/[email protected].4': {}
'@tokenizer/[email protected]': '@tokenizer/[email protected]':
dependencies: dependencies:
@@ -5099,7 +5098,7 @@ snapshots:
'@typescript/[email protected]': '@typescript/[email protected]':
optional: true optional: true
'@vitest/[email protected].5([email protected].5)': '@vitest/[email protected].4([email protected].4)':
dependencies: dependencies:
'@bcoe/v8-coverage': 1.0.2 '@bcoe/v8-coverage': 1.0.2
'@vitest/istanbul-lib-coverage': 1.0.2 '@vitest/istanbul-lib-coverage': 1.0.2
@@ -5109,7 +5108,7 @@ snapshots:
obug: 3.0.0 obug: 3.0.0
std-env: 4.3.0 std-env: 4.3.0
tinyrainbow: 3.2.0 tinyrainbow: 3.2.0
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: 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/[email protected]': {} '@vitest/[email protected]': {}
@@ -5117,17 +5116,17 @@ snapshots:
dependencies: dependencies:
'@vitest/istanbul-lib-coverage': 1.0.2 '@vitest/istanbul-lib-coverage': 1.0.2
'@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]))': '@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]))':
dependencies: dependencies:
'@jridgewell/trace-mapping': 0.3.31 '@jridgewell/trace-mapping': 0.3.31
'@vitest/spy': 5.0.5 '@vitest/spy': 5.0.4
estree-walker: 3.0.3 estree-walker: 3.0.3
magic-string: 1.4.3 magic-string: 1.4.3
optionalDependencies: optionalDependencies:
msw: 3.0.3(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])) 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]) vite: 8.3.4(@types/[email protected])([email protected])([email protected])([email protected])([email protected])
'@vitest/[email protected].5': {} '@vitest/[email protected].4': {}
[email protected]: [email protected]:
dependencies: dependencies:
@@ -5191,7 +5190,7 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]4.0: {} [email protected]3.0: {}
[email protected]: {} [email protected]: {}
@@ -5230,7 +5229,7 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]8: {} [email protected]9: {}
[email protected]: [email protected]:
dependencies: dependencies:
@@ -5545,7 +5544,7 @@ snapshots:
[email protected]: [email protected]:
dependencies: dependencies:
'@tokenizer/inflate': 0.5.1 '@tokenizer/inflate': 0.5.1
strtok3: 10.5.0 strtok3: 10.6.1
token-types: 6.1.2 token-types: 6.1.2
uint8array-extras: 1.6.0 uint8array-extras: 1.6.0
transitivePeerDependencies: transitivePeerDependencies:
@@ -5556,9 +5555,9 @@ snapshots:
cross-spawn: 7.0.6 cross-spawn: 7.0.6
signal-exit: 4.1.0 signal-exit: 4.1.0
framer-motion@14.3.0([email protected]([email protected]))([email protected]): framer-motion@14.2.0([email protected]([email protected]))([email protected]):
dependencies: dependencies:
motion-dom: 14.3.0 motion-dom: 14.2.0
motion-utils: 14.1.1 motion-utils: 14.1.1
tslib: 2.8.1 tslib: 2.8.1
optionalDependencies: optionalDependencies:
@@ -5598,7 +5597,7 @@ snapshots:
[email protected]: [email protected]:
dependencies: dependencies:
'@types/set-cookie-parser': 2.4.10 '@types/set-cookie-parser': 2.4.10
set-cookie-parser: 3.1.3 set-cookie-parser: 3.1.4
[email protected]: {} [email protected]: {}
@@ -5658,7 +5657,7 @@ snapshots:
[email protected]: {} [email protected]: {}
isomorphic-dompurify@4.5.0(@noble/[email protected]): isomorphic-dompurify@4.6.0(@noble/[email protected]):
dependencies: dependencies:
dompurify: 3.4.17 dompurify: 3.4.17
jsdom: 30.1.3(@noble/[email protected]) jsdom: 30.1.3(@noble/[email protected])
@@ -5682,8 +5681,8 @@ snapshots:
[email protected](@noble/[email protected]): [email protected](@noble/[email protected]):
dependencies: dependencies:
'@asamuzakjp/css-color': 7.1.5 '@asamuzakjp/css-color': 7.1.7
'@asamuzakjp/dom-selector': 10.0.3 '@asamuzakjp/dom-selector': 10.0.4
'@bramus/specificity': 2.4.3 '@bramus/specificity': 2.4.3
'@csstools/css-syntax-patches-for-csstree': 1.1.15([email protected]) '@csstools/css-syntax-patches-for-csstree': 1.1.15([email protected])
'@exodus/bytes': 1.16.0(@noble/[email protected]) '@exodus/bytes': 1.16.0(@noble/[email protected])
@@ -5696,7 +5695,7 @@ snapshots:
parse5: 8.0.1 parse5: 8.0.1
saxes: 6.0.0 saxes: 6.0.0
tough-cookie: 6.0.2 tough-cookie: 6.0.2
undici: 8.12.1 undici: 8.12.2
w3c-xmlserializer: 6.0.0 w3c-xmlserializer: 6.0.0
webidl-conversions: 8.0.1 webidl-conversions: 8.0.1
whatwg-mimetype: 5.0.0 whatwg-mimetype: 5.0.0
@@ -5874,15 +5873,15 @@ snapshots:
[email protected]: {} [email protected]: {}
motion-dom@14.3.0: motion-dom@14.2.0:
dependencies: dependencies:
motion-utils: 14.1.1 motion-utils: 14.1.1
[email protected]: {} [email protected]: {}
motion@14.3.0([email protected]([email protected]))([email protected]): motion@14.2.0([email protected]([email protected]))([email protected]):
dependencies: dependencies:
framer-motion: 14.3.0([email protected]([email protected]))([email protected]) framer-motion: 14.2.0([email protected]([email protected]))([email protected])
tslib: 2.8.1 tslib: 2.8.1
optionalDependencies: optionalDependencies:
react: 19.3.1 react: 19.3.1
@@ -5894,7 +5893,7 @@ snapshots:
dependencies: dependencies:
'@inquirer/confirm': 6.3.3(@types/[email protected]) '@inquirer/confirm': 6.3.3(@types/[email protected])
'@msw/url': 0.1.2 '@msw/url': 0.1.2
'@mswjs/interceptors': 0.45.8 '@mswjs/interceptors': 0.45.9
cookie: 2.0.1 cookie: 2.0.1
headers-polyfill: 5.0.1 headers-polyfill: 5.0.1
is-node-process: 1.2.0 is-node-process: 1.2.0
@@ -5919,7 +5918,7 @@ snapshots:
debug: 4.4.3 debug: 4.4.3
file-type: 22.2.0 file-type: 22.2.0
media-typer: 2.0.0 media-typer: 2.0.0
strtok3: 10.5.0 strtok3: 10.6.1
token-types: 6.1.2 token-types: 6.1.2
uint8array-extras: 1.6.0 uint8array-extras: 1.6.0
win-guid: 0.2.1 win-guid: 0.2.1
@@ -5952,15 +5951,15 @@ snapshots:
dependencies: dependencies:
content-type: 2.1.0 content-type: 2.1.0
[email protected]([email protected].2(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected]): [email protected]([email protected].1(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected]):
dependencies: dependencies:
'@auth/core': 0.41.3 '@auth/core': 0.41.3
next: 16.4.2(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]) next: 16.4.1(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
react: 19.3.1 react: 19.3.1
[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]): [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]):
dependencies: dependencies:
'@eloqnt/config': 0.1.1 '@eloqnt/config': 0.1.1
'@eloqnt/format-json': 0.1.0 '@eloqnt/format-json': 0.1.0
@@ -5970,7 +5969,7 @@ snapshots:
'@swc/core': 1.16.18(@swc/[email protected]) '@swc/core': 1.16.18(@swc/[email protected])
icu-minify: 4.14.9 icu-minify: 4.14.9
negotiator: 1.1.0 negotiator: 1.1.0
next: 16.4.2(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]) next: 16.4.1(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
next-intl-swc-plugin-extractor: 4.14.9 next-intl-swc-plugin-extractor: 4.14.9
react: 19.3.1 react: 19.3.1
use-intl: 4.14.9(@types/[email protected])([email protected]) use-intl: 4.14.9(@types/[email protected])([email protected])
@@ -5979,25 +5978,25 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- '@swc/helpers' - '@swc/helpers'
[email protected].2(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]): [email protected].1(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]):
dependencies: dependencies:
'@next/env': 16.4.2 '@next/env': 16.4.1
'@swc/helpers': 0.5.23 '@swc/helpers': 0.5.23
baseline-browser-mapping: 2.11.28 baseline-browser-mapping: 2.11.29
caniuse-lite: 1.0.30001816 caniuse-lite: 1.0.30001816
postcss: 8.5.23 postcss: 8.5.23
react: 19.3.1 react: 19.3.1
react-dom: 19.3.1([email protected]) react-dom: 19.3.1([email protected])
styled-jsx: 5.1.6([email protected]) styled-jsx: 5.1.6([email protected])
optionalDependencies: optionalDependencies:
'@next/swc-darwin-arm64': 16.4.2 '@next/swc-darwin-arm64': 16.4.1
'@next/swc-darwin-x64': 16.4.2 '@next/swc-darwin-x64': 16.4.1
'@next/swc-linux-arm64-gnu': 16.4.2 '@next/swc-linux-arm64-gnu': 16.4.1
'@next/swc-linux-arm64-musl': 16.4.2 '@next/swc-linux-arm64-musl': 16.4.1
'@next/swc-linux-x64-gnu': 16.4.2 '@next/swc-linux-x64-gnu': 16.4.1
'@next/swc-linux-x64-musl': 16.4.2 '@next/swc-linux-x64-musl': 16.4.1
'@next/swc-win32-arm64-msvc': 16.4.2 '@next/swc-win32-arm64-msvc': 16.4.1
'@next/swc-win32-x64-msvc': 16.4.2 '@next/swc-win32-x64-msvc': 16.4.1
'@playwright/test': 1.64.1 '@playwright/test': 1.64.1
sharp: 0.35.5(@types/[email protected]) sharp: 0.35.5(@types/[email protected])
transitivePeerDependencies: transitivePeerDependencies:
@@ -6288,7 +6287,7 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected].3: {} [email protected].4: {}
[email protected]: {} [email protected]: {}
@@ -6424,7 +6423,7 @@ snapshots:
[email protected]: {} [email protected]: {}
strtok3@10.5.0: strtok3@10.6.1:
dependencies: dependencies:
'@tokenizer/token': 0.3.0 '@tokenizer/token': 0.3.0
@@ -6502,7 +6501,7 @@ snapshots:
ssh-remote-port-forward: 1.0.4 ssh-remote-port-forward: 1.0.4
tar-fs: 3.1.3 tar-fs: 3.1.3
tmp: 0.2.7 tmp: 0.2.7
undici: 8.12.1 undici: 8.12.2
transitivePeerDependencies: transitivePeerDependencies:
- bare-abort-controller - bare-abort-controller
- bare-buffer - bare-buffer
@@ -6597,7 +6596,7 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected].1: {} [email protected].2: {}
[email protected]: {} [email protected]: {}
@@ -6647,10 +6646,10 @@ snapshots:
tsx: 4.23.15 tsx: 4.23.15
yaml: 2.9.1 yaml: 2.9.1
[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])): [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])):
dependencies: dependencies:
'@types/chai': 5.2.3 '@types/chai': 5.2.3
'@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])) '@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]))
chai: 6.3.0 chai: 6.3.0
es-module-lexer: 2.3.2 es-module-lexer: 2.3.2
expect-type: 1.4.0 expect-type: 1.4.0
@@ -6665,7 +6664,7 @@ snapshots:
why-is-node-running: 3.2.1 why-is-node-running: 3.2.1
optionalDependencies: optionalDependencies:
'@types/node': 26.6.5 '@types/node': 26.6.5
'@vitest/coverage-v8': 5.0.5([email protected].5) '@vitest/coverage-v8': 5.0.4([email protected].4)
jsdom: 30.1.3(@noble/[email protected]) jsdom: 30.1.3(@noble/[email protected])
transitivePeerDependencies: transitivePeerDependencies:
- msw - msw
-5
View File
@@ -2,11 +2,6 @@
"schemaVersion": 1, "schemaVersion": 1,
"mode": "enforce", "mode": "enforce",
"routes": [ "routes": [
{
"route": "/",
"maxInitialRawBytes": 716800,
"maxInitialGzipBytes": 225280
},
{ {
"route": "/me", "route": "/me",
"maxInitialRawBytes": 716800, "maxInitialRawBytes": 716800,
+1
View File
@@ -60,6 +60,7 @@ export async function uploadBadge(formData: FormData): Promise<void> {
try { try {
const buffer = Buffer.from(await file.arrayBuffer()); const buffer = Buffer.from(await file.arrayBuffer());
const gif = await toBadgeGif(buffer); const gif = await toBadgeGif(buffer);
// eslint-disable-next-line security/detect-non-literal-fs-filename
await writeFile(target, gif); await writeFile(target, gif);
} catch { } catch {
back("error", "Could not process or write the badge file"); 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({ .values({
key, key,
value, 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, comment: COMMENTS[key] ?? null,
}) })
.onDuplicateKeyUpdate({ set: { value } }); .onDuplicateKeyUpdate({ set: { value } });
+2 -2
View File
@@ -71,10 +71,10 @@ export async function savePoints(formData: FormData): Promise<void> {
POINTS_KEYS.map((key) => POINTS_KEYS.map((key) =>
db db
.insert(WebsiteSetting) .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" }) .values({ key, value: values[key], comment: "Radio points" })
.onDuplicateKeyUpdate({ .onDuplicateKeyUpdate({
// key from POINTS_KEYS const // eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
set: { value: values[key] }, 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> { export async function applyPreset(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.SETTINGS_EDIT); const staff = await requirePermission(PERMS.SETTINGS_EDIT);
const name = String(formData.get("preset") ?? "").normalize("NFC"); 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]; const preset = PRESETS[name];
if (!preset) redirect("/admin/theme"); if (!preset) redirect("/admin/theme");
+1 -6
View File
@@ -6,7 +6,6 @@ import { redirect } from "next/navigation";
import { env } from "@/env"; import { env } from "@/env";
import { invalidateLoginCache } from "@/lib/auth/login-core"; import { invalidateLoginCache } from "@/lib/auth/login-core";
import { hashPassword } from "@/lib/auth/password"; import { hashPassword } from "@/lib/auth/password";
import { passwordPolicy } from "@/lib/auth/password-policy";
import { revokeUserCredentials } from "@/lib/auth/session-revocation"; import { revokeUserCredentials } from "@/lib/auth/session-revocation";
import { db, PasswordReset, User } from "@/lib/db"; import { db, PasswordReset, User } from "@/lib/db";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
@@ -110,11 +109,7 @@ export async function resetPassword(formData: FormData): Promise<void> {
} }
let error: "password" | "invalid" | "failed" | null = null; let error: "password" | "invalid" | "failed" | null = null;
if ( if (password.length < 12) error = "password";
password.length < passwordPolicy.minLength ||
password.length > passwordPolicy.maxLength
)
error = "password";
if (!error) { if (!error) {
try { try {
+4 -10
View File
@@ -5,7 +5,6 @@ import { after } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { sendVerification } from "@/lib/auth/email-verification"; import { sendVerification } from "@/lib/auth/email-verification";
import { hashPassword } from "@/lib/auth/password"; import { hashPassword } from "@/lib/auth/password";
import { passwordPolicy } from "@/lib/auth/password-policy";
import { DEFAULT_AVATAR_FIGURE } from "@/lib/avatar-figure"; import { DEFAULT_AVATAR_FIGURE } from "@/lib/avatar-figure";
import { invalidateKey } from "@/lib/cached-db"; import { invalidateKey } from "@/lib/cached-db";
import { db, User } from "@/lib/db"; import { db, User } from "@/lib/db";
@@ -84,11 +83,6 @@ function hasDisposableEmailDomain(email: string): boolean {
return domain ? DISPOSABLE_EMAIL_DOMAINS.has(domain) : false; 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 const registerSchema = z
.object({ .object({
username: z username: z
@@ -111,8 +105,8 @@ const registerSchema = z
), ),
password: z password: z
.string() .string()
.min(passwordPolicy.minLength, PASSWORD_MIN_MESSAGE) .min(12, "Password must be at least 12 characters") // Increased min length
.max(passwordPolicy.maxLength, PASSWORD_MAX_MESSAGE) .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 uppercase letter")
.regex(/[a-z]/, "Password must contain at least one lowercase letter") .regex(/[a-z]/, "Password must contain at least one lowercase letter")
.regex(/[0-9]/, "Password must contain at least one digit") .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", "This username is reserved": "usernameReserved",
"Enter a valid email address": "emailValid", "Enter a valid email address": "emailValid",
"Temporary email domains are not allowed": "emailDisposable", "Temporary email domains are not allowed": "emailDisposable",
[PASSWORD_MIN_MESSAGE]: "passwordMinLength", "Password must be at least 12 characters": "passwordMinLength",
[PASSWORD_MAX_MESSAGE]: "passwordMaxLength", "Password is too long": "passwordMaxLength",
"Password must contain at least one uppercase letter": "passwordUpper", "Password must contain at least one uppercase letter": "passwordUpper",
"Password must contain at least one lowercase letter": "passwordLower", "Password must contain at least one lowercase letter": "passwordLower",
"Password must contain at least one digit": "passwordDigit", "Password must contain at least one digit": "passwordDigit",
+4
View File
@@ -31,7 +31,9 @@ export async function saveFavicon(
} }
const buffer = upload.bytes; const buffer = upload.bytes;
// eslint-disable-next-line security/detect-non-literal-fs-filename
await mkdir(baseDir, { recursive: true }); await mkdir(baseDir, { recursive: true });
// eslint-disable-next-line security/detect-non-literal-fs-filename
await writeFile(filePath, buffer); await writeFile(filePath, buffer);
const url = `/api/media/favicon/${filename}`; const url = `/api/media/favicon/${filename}`;
@@ -44,6 +46,7 @@ export async function saveFavicon(
const oldPath = path.resolve(baseDir, oldName); const oldPath = path.resolve(baseDir, oldName);
if (oldPath.startsWith(baseDir + path.sep)) { if (oldPath.startsWith(baseDir + path.sep)) {
try { try {
// eslint-disable-next-line security/detect-non-literal-fs-filename
await unlink(oldPath); await unlink(oldPath);
} catch { } catch {
/* ignore if file doesn't exist */ /* ignore if file doesn't exist */
@@ -82,6 +85,7 @@ export async function deleteFavicon(): Promise<{
const oldPath = path.resolve(baseDir, oldName); const oldPath = path.resolve(baseDir, oldName);
if (oldPath.startsWith(baseDir + path.sep)) { if (oldPath.startsWith(baseDir + path.sep)) {
try { try {
// eslint-disable-next-line security/detect-non-literal-fs-filename
await unlink(oldPath); await unlink(oldPath);
} catch { } catch {
/* ignore */ /* ignore */
+2
View File
@@ -29,7 +29,9 @@ export async function saveLogo(
} }
const buffer = upload.bytes; const buffer = upload.bytes;
// eslint-disable-next-line security/detect-non-literal-fs-filename
await mkdir(baseDir, { recursive: true }); await mkdir(baseDir, { recursive: true });
// eslint-disable-next-line security/detect-non-literal-fs-filename
await writeFile(filePath, buffer); await writeFile(filePath, buffer);
const url = `/api/media/logo/${filename}`; 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; let uploaded = false;
try { try {
// eslint-disable-next-line security/detect-non-literal-fs-filename
await mkdir(MEDIA_ROOT, { recursive: true }); await mkdir(MEDIA_ROOT, { recursive: true });
const ext = file.name.split(".").pop() ?? "png"; const ext = file.name.split(".").pop() ?? "png";
const name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`; const name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
const bytes = await file.arrayBuffer(); const bytes = await file.arrayBuffer();
const filePath = resolveMediaPath(name); const filePath = resolveMediaPath(name);
if (filePath.startsWith(MEDIA_ROOT + path.sep)) { if (filePath.startsWith(MEDIA_ROOT + path.sep)) {
// eslint-disable-next-line security/detect-non-literal-fs-filename
await writeFile(filePath, Buffer.from(bytes)); await writeFile(filePath, Buffer.from(bytes));
await writeSetting("background_image", `/api/media/${name}`); await writeSetting("background_image", `/api/media/${name}`);
await writeSetting( await writeSetting(
@@ -317,7 +319,7 @@ export async function applyPresetManager(formData: FormData): Promise<void> {
const name = String(formData.get("preset") ?? "") const name = String(formData.get("preset") ?? "")
.normalize("NFC") .normalize("NFC")
.trim(); .trim();
// guarded by null check below // eslint-disable-next-line security/detect-object-injection -- guarded by null check below
const preset = PRESETS[name]; const preset = PRESETS[name];
if (!preset) redirect(MANAGER_PATH); 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"> <article key={String(position.id)} className="content-card">
<div className="content-card-head"> <div className="content-card-head">
{team?.badge ? ( {team?.badge ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img <img
src={`${BADGE_IMG_BASE}=${encodeURIComponent(team.badge)}`} src={`${BADGE_IMG_BASE}=${encodeURIComponent(team.badge)}`}
alt={rankName} alt={rankName}
+1
View File
@@ -120,6 +120,7 @@ export default async function ApplyTeamPage({
<article key={String(team.id)} className="content-card"> <article key={String(team.id)} className="content-card">
<div className="content-card-head"> <div className="content-card-head">
{team.badge ? ( {team.badge ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img <img
src={`${BADGE_IMG_BASE}=${encodeURIComponent(team.badge)}`} src={`${BADGE_IMG_BASE}=${encodeURIComponent(team.badge)}`}
alt={team.rankName} alt={team.rankName}
+1
View File
@@ -48,6 +48,7 @@ export default async function BadgesPage() {
alignItems: "flex-start", alignItems: "flex-start",
}} }}
> >
{/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={`${BADGE_IMG_BASE}=${encodeURIComponent(badge.badgeKey)}`} src={`${BADGE_IMG_BASE}=${encodeURIComponent(badge.badgeKey)}`}
alt={badge.badgeName} alt={badge.badgeName}
+1
View File
@@ -190,6 +190,7 @@ export default async function DrawBadgePage({
{b.badgeUrl ? ( {b.badgeUrl ? (
// Badge images are arbitrary external URLs stored by the // Badge images are arbitrary external URLs stored by the
// draw-badge tool, so a plain <img> is correct here. // draw-badge tool, so a plain <img> is correct here.
// eslint-disable-next-line @next/next/no-img-element
<img <img
src={b.badgeUrl} src={b.badgeUrl}
alt="" alt=""
+1
View File
@@ -149,6 +149,7 @@ export default async function GuildPage({
style={{ display: "flex", gap: "1.25rem", alignItems: "flex-start" }} style={{ display: "flex", gap: "1.25rem", alignItems: "flex-start" }}
> >
{guild.badge ? ( {guild.badge ? (
// eslint-disable-next-line @next/next/no-img-element
<img <img
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${guild.badge}.gif`} src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${guild.badge}.gif`}
alt={`${guild.name} badge`} alt={`${guild.name} badge`}
+1
View File
@@ -74,6 +74,7 @@ export default async function GuildsPage() {
}} }}
> >
{g.badge ? ( {g.badge ? (
// eslint-disable-next-line @next/next/no-img-element
<img <img
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${g.badge}.gif`} src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${g.badge}.gif`}
alt={`${g.name} badge`} alt={`${g.name} badge`}
+1
View File
@@ -110,6 +110,7 @@ export default async function HelpCategoryPage({
<ContentCard icon="❓" title={title} subtitle={t("topicSubtitle")}> <ContentCard icon="❓" title={title} subtitle={t("topicSubtitle")}>
{imageSrc ? ( {imageSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img <img
src={imageSrc} src={imageSrc}
alt="" alt=""
+1
View File
@@ -159,6 +159,7 @@ export default async function HelpCenterPage() {
}} }}
> >
{c.imageUrl ? ( {c.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img <img
src={c.imageUrl} src={c.imageUrl}
alt="" alt=""
+2 -18
View File
@@ -1,5 +1,4 @@
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { headers } from "next/headers";
import { NextIntlClientProvider } from "next-intl"; import { NextIntlClientProvider } from "next-intl";
import { getLocale, getMessages } from "next-intl/server"; import { getLocale, getMessages } from "next-intl/server";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
@@ -35,43 +34,28 @@ export default async function SiteLayout({
}: { }: {
children: ReactNode; children: ReactNode;
}) { }) {
const [session, locale, messages, h] = await Promise.all([ const [session, locale, messages] = await Promise.all([
auth(), auth(),
getLocale(), getLocale(),
getMessages(), getMessages(),
headers(),
]); ]);
const clientMessages = publicClientMessages(messages); 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 ( return (
<NextIntlClientProvider locale={locale} messages={clientMessages}> <NextIntlClientProvider locale={locale} messages={clientMessages}>
<CloudsField /> <CloudsField />
{/* Site chrome is public: hiding it all for anonymous visitors used to {/* Site chrome is public: hiding it all for anonymous visitors used to
strand them — from /news, /leaderboard or /shop there was no way 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 reach any other page at all. */}
out, because they carry their own header. */}
{!isLandingPage && (
<div data-theme-block="top_header"> <div data-theme-block="top_header">
<TopHeader session={session} /> <TopHeader session={session} />
</div> </div>
)}
{!isLandingPage && (
<div data-theme-block="site_header"> <div data-theme-block="site_header">
<SiteHeader /> <SiteHeader />
</div> </div>
)}
{!isLandingPage && (
<div data-theme-block="navigation"> <div data-theme-block="navigation">
<Navigation session={session} /> <Navigation session={session} />
</div> </div>
)}
<main> <main>
<div <div
data-theme-block="content_grid" data-theme-block="content_grid"
+1 -1
View File
@@ -171,7 +171,7 @@ export default async function LeaderboardPage({
const rows = await (active === "credits" const rows = await (active === "credits"
? loadCreditsRows() ? loadCreditsRows()
: active === "diamonds" || active === "duckets" : 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]) loadCurrencyRows(CURRENCY_TYPE[active])
: loadSettingsRows( : loadSettingsRows(
SETTINGS_FIELD[active as keyof typeof SETTINGS_FIELD], SETTINGS_FIELD[active as keyof typeof SETTINGS_FIELD],
+163 -25
View File
@@ -1,14 +1,24 @@
import { desc, eq } from "drizzle-orm";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { headers } from "next/headers";
import Image from "next/image";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { AuthAlert } from "@/components/auth/auth-alert";
import { import {
AuthLandingView, AuthPageFrame,
loadAuthLanding, AuthUsersCards,
} from "@/components/auth/auth-landing"; } from "@/components/auth/auth-page-frame";
import { LoginForm } from "@/components/auth/login-form"; import { LoginForm } from "@/components/auth/login-form";
import { SurfaceCard } from "@/components/surface-card";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { safeRedirectPath } from "@/lib/auth/safe-redirect"; 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> { export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.login"); const t = await getTranslations("pages.login");
@@ -33,6 +43,13 @@ export default async function LoginPage({
}>; }>;
}) { }) {
const t = await getTranslations("pages.login"); 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 sp = await searchParams;
const redirectTo = safeRedirectPath(sp.from); const redirectTo = safeRedirectPath(sp.from);
@@ -41,44 +58,165 @@ export default async function LoginPage({
const session = await auth(); const session = await auth();
if (session?.user?.id) redirect(redirectTo); 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 // `/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 // 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 // visitor would only see an empty login form and no sign their account
// exists. `?reset=1` comes from a successful password reset; saying so // exists.
// matters because a silent form reads like the reset failed.
const notice = const notice =
sp.registered === "1" ? ( 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")} {t("registeredSuccess")}
</AuthAlert> </p>
) : sp.reset === "1" ? ( ) : 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")} {t("passwordChanged")}
</AuthAlert> </p>
) : undefined; ) : undefined;
return ( return (
<AuthLandingView <AuthPageFrame
data={data} hotelName={hotelName}
copy={{ logo={logo}
title: t("title"), title={t("title")}
subtitle: t("subtitle"), subtitle={t("subtitle")}
notice, notice={notice}
promoTitle: t("welcomeBack"),
promoSubtitle: t("welcomeBackSub", { hotelName: data.hotelName }),
usersOnlineLabel: t("usersOnline", { count: data.online }),
whoIsOnlineLabel: t("whoIsOnline"),
newestCitizensLabel: t("newestCitizens"),
}}
form={ form={
<LoginForm <LoginForm
redirectTo={redirectTo} redirectTo={redirectTo}
captcha={data.captcha} captcha={{
nonce={data.nonce} 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 ? ( {article.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img <img
src={article.image} src={article.image}
alt="" alt=""
+162 -9
View File
@@ -7,20 +7,22 @@ import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import type { CSSProperties, ReactNode } from "react"; import type { CSSProperties, ReactNode } from "react";
import { AnimatedCounter } from "@/components/animated-counter"; import { AnimatedCounter } from "@/components/animated-counter";
import { AuthTopBar } from "@/components/auth/auth-top-bar";
import { LoginForm } from "@/components/auth/login-form"; import { LoginForm } from "@/components/auth/login-form";
import { Clock } from "@/components/clock";
import { LanguageSwitcher } from "@/components/language-switcher";
import Link from "@/components/link"; import Link from "@/components/link";
import { LiveOnlineCounter } from "@/components/live-online-counter";
import { Reveal } from "@/components/motion-reveal"; import { Reveal } from "@/components/motion-reveal";
import { PublicLoadError } from "@/components/public/load-error"; import { PublicLoadError } from "@/components/public/load-error";
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail"; import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
import { SpotlightCard } from "@/components/spotlight-card"; import { SpotlightCard } from "@/components/spotlight-card";
import { ThemeSwitcher } from "@/components/theme-switcher";
import { TypewriterText } from "@/components/typewriter-text"; import { TypewriterText } from "@/components/typewriter-text";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { cached } from "@/lib/cache"; import { cached } from "@/lib/cache";
import { CameraWeb, db, User } from "@/lib/db"; import { CameraWeb, db, User } from "@/lib/db";
import { formatDate } from "@/lib/format-date"; import { formatDate } from "@/lib/format-date";
import { resolveHotelName } from "@/lib/hotel-name"; import { resolveHotelName } from "@/lib/hotel-name";
import { withPublicPagePerformance } from "@/lib/public-page-performance";
import { captchaConfig } from "@/lib/services/captcha"; import { captchaConfig } from "@/lib/services/captcha";
import { getNewsList } from "@/lib/services/news-list"; import { getNewsList } from "@/lib/services/news-list";
import { loadProfilePrivacyMap } from "@/lib/services/profile-privacy"; import { loadProfilePrivacyMap } from "@/lib/services/profile-privacy";
@@ -38,6 +40,7 @@ import {
ICON_CAMERA, ICON_CAMERA,
ICON_CATALOG, ICON_CATALOG,
ICON_FRIENDS, ICON_FRIENDS,
ICON_LIGHTHOUSE,
ICON_NAV_GOODY, ICON_NAV_GOODY,
ICON_NAV_ME, ICON_NAV_ME,
} from "@/lib/site-icons"; } from "@/lib/site-icons";
@@ -235,7 +238,7 @@ async function getHotelData() {
}; };
} }
async function Home() { export default async function Home() {
const session = await auth(); const session = await auth();
if (session?.user?.id) redirect("/me"); if (session?.user?.id) redirect("/me");
@@ -401,10 +404,88 @@ async function Home() {
return ( return (
<div className="flex w-full flex-col gap-14 pb-16 pt-2 md:gap-20 md:pb-24"> <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 ── */} {/* ── Top bar ── */}
<div className="mx-auto w-full max-w-7xl"> <Reveal className="mx-auto w-full max-w-7xl">
<AuthTopBar hotelName={hotelName} logo={logo} online={online} /> <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> </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 ── */} {/* ── Cinematic hero ── */}
<Reveal className="-mx-3 sm:-mx-4 md:-mx-6"> <Reveal className="-mx-3 sm:-mx-4 md:-mx-6">
<div className="relative overflow-hidden"> <div className="relative overflow-hidden">
@@ -453,7 +534,50 @@ async function Home() {
style={{ opacity: 0.5, zIndex: 1 }} 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]"> <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 <span
className="mb-3 block text-xs font-extrabold uppercase tracking-[0.32em] sm:text-sm" className="mb-3 block text-xs font-extrabold uppercase tracking-[0.32em] sm:text-sm"
style={{ color: "rgba(255,255,255,0.72)" }} style={{ color: "rgba(255,255,255,0.72)" }}
@@ -472,6 +596,37 @@ async function Home() {
{tp("welcomeBody")} {tp("welcomeBody")}
</p> </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 <div
className="animate-fade-in-up mt-9 flex flex-wrap items-center justify-center gap-3" className="animate-fade-in-up mt-9 flex flex-wrap items-center justify-center gap-3"
style={{ animationDelay: "0.2s" }} style={{ animationDelay: "0.2s" }}
@@ -993,5 +1148,3 @@ async function Home() {
</div> </div>
); );
} }
export default withPublicPagePerformance("/", Home);
+2
View File
@@ -108,6 +108,7 @@ export default async function RareCategoryPage({
}} }}
> >
{badgeSrc ? ( {badgeSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img <img
src={badgeSrc} src={badgeSrc}
alt={`${cat.name} badge`} alt={`${cat.name} badge`}
@@ -149,6 +150,7 @@ export default async function RareCategoryPage({
<tr key={String(r.id)}> <tr key={String(r.id)}>
<td> <td>
{iconSrc ? ( {iconSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img <img
src={iconSrc} src={iconSrc}
alt="" alt=""
+1
View File
@@ -88,6 +88,7 @@ export default async function RareValuesPage() {
> >
<div className="content-card-head"> <div className="content-card-head">
{badgeSrc ? ( {badgeSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img <img
src={badgeSrc} src={badgeSrc}
alt={`${c.name} badge`} alt={`${c.name} badge`}
+155 -27
View File
@@ -1,13 +1,23 @@
import { desc, eq } from "drizzle-orm";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { headers } from "next/headers";
import Image from "next/image";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { import {
AuthLandingView, AuthPageFrame,
loadAuthLanding, AuthUsersCards,
} from "@/components/auth/auth-landing"; } from "@/components/auth/auth-page-frame";
import { RegisterForm } from "@/components/auth/register-form"; import { RegisterForm } from "@/components/auth/register-form";
import { SurfaceCard } from "@/components/surface-card";
import { auth } from "@/lib/auth"; 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> { export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.register"); const t = await getTranslations("pages.register");
@@ -21,40 +31,158 @@ export async function generateMetadata(): Promise<Metadata> {
} }
export default async function RegisterPage() { 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. // An account already exists in this browser — there is nothing to sign up.
const session = await auth(); const session = await auth();
if (session?.user?.id) redirect("/me"); 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 ( return (
<AuthLandingView <AuthPageFrame
data={data} hotelName={hotelName}
copy={{ logo={logo}
title: t("title"), title={tpr("title")}
subtitle: t("subtitle"), subtitle={tpr("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"),
}}
form={ form={
<RegisterForm <RegisterForm
hotelName={data.hotelName} hotelName={hotelName}
captcha={data.captcha} captcha={{
nonce={data.nonce} provider: cfg.provider,
passwordMinLength={passwordPolicy.minLength} siteKey: cfg.siteKey || undefined,
passwordMaxLength={passwordPolicy.maxLength} }}
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 { resetPassword } from "@/actions/password-reset";
import Link from "@/components/link"; import Link from "@/components/link";
import { ContentCard } from "@/components/public/ui"; import { ContentCard } from "@/components/public/ui";
import { passwordPolicy } from "@/lib/auth/password-policy";
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.reset"); const t = await getTranslations("pages.reset");
@@ -42,12 +41,8 @@ export default async function ResetPage({
<input <input
name="password" name="password"
type="password" type="password"
placeholder={t("passwordPlaceholder", { placeholder={t("passwordPlaceholder")}
min: passwordPolicy.minLength,
})}
autoComplete="new-password" autoComplete="new-password"
minLength={passwordPolicy.minLength}
maxLength={passwordPolicy.maxLength}
required required
/> />
<button type="submit" className="btn btn-primary"> <button type="submit" className="btn btn-primary">
@@ -63,7 +58,7 @@ export default async function ResetPage({
marginBottom: 0, marginBottom: 0,
}} }}
> >
{t(errorKey, { min: passwordPolicy.minLength })} {t(errorKey)}
</p> </p>
) : null} ) : null}
<p <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 } { 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: "🚪" }; return STATE_LABELS[state] ?? { label: state || "Unknown", icon: "🚪" };
} }
+1
View File
@@ -249,6 +249,7 @@ export default async function ShopPage({
}} }}
> >
{a.iconUrl ? ( {a.iconUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img <img
src={a.iconUrl} src={a.iconUrl}
alt="" alt=""
+1
View File
@@ -366,6 +366,7 @@ async function ProfilePage({
flexWrap: "wrap", flexWrap: "wrap",
}} }}
> >
{/* eslint-disable-next-line @next/next/no-img-element */}
<ProfileImage <ProfileImage
src={avatar} src={avatar}
alt={user.username} alt={user.username}
+1 -11
View File
@@ -1,6 +1,4 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { NextIntlClientProvider } from "next-intl";
import { getLocale, getMessages } from "next-intl/server";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { env } from "@/env"; import { env } from "@/env";
import { isHousekeepingPreviewEnabled } from "@/features/housekeeping/foundation/preview-gate"; import { isHousekeepingPreviewEnabled } from "@/features/housekeeping/foundation/preview-gate";
@@ -29,13 +27,5 @@ export default async function AdminNextLayout({
notFound(); notFound();
} }
// Staff tree: re-install the full catalogue the root layout scopes down to return children;
// 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>
);
} }
+1
View File
@@ -49,6 +49,7 @@ export function AdsTable({ ads }: { ads: AdRow[] }) {
{ads.map((ad) => ( {ads.map((ad) => (
<tr key={ad.id}> <tr key={ad.id}>
<td> <td>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={ad.image} src={ad.image}
alt="" alt=""
+1
View File
@@ -103,6 +103,7 @@ export default async function AdminCalendarCampaign({
<div className="admin-card flex items-center gap-5 mb-6"> <div className="admin-card flex items-center gap-5 mb-6">
{campaign.image ? ( {campaign.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img <img
className="w-24 h-24 object-cover" className="w-24 h-24 object-cover"
src={campaign.image} src={campaign.image}
+1
View File
@@ -98,6 +98,7 @@ export default async function AdminCalendar() {
className="admin-card hover article" className="admin-card hover article"
> >
{c.image ? ( {c.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img className="article-img" src={c.image} alt={c.name} /> <img className="article-img" src={c.image} alt={c.name} />
) : ( ) : (
<div className="article-img" /> <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 flex-wrap items-start justify-between gap-4">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{guild.badge ? ( {guild.badge ? (
// eslint-disable-next-line @next/next/no-img-element
<img <img
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${guild.badge}.gif`} src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${guild.badge}.gif`}
alt="" alt=""
+1
View File
@@ -155,6 +155,7 @@ export default async function AdminGuildsPage({
<td className="muted">{g.id}</td> <td className="muted">{g.id}</td>
<td> <td>
{g.badge ? ( {g.badge ? (
// eslint-disable-next-line @next/next/no-img-element
<img <img
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${g.badge}.gif`} src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${g.badge}.gif`}
alt="" alt=""
+3 -9
View File
@@ -1,8 +1,7 @@
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { LogOut } from "lucide-react"; import { LogOut } from "lucide-react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { NextIntlClientProvider } from "next-intl"; import { getTranslations } from "next-intl/server";
import { getLocale, getMessages, getTranslations } from "next-intl/server";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { AdminHubChrome } from "@/components/admin/admin-hub-chrome"; import { AdminHubChrome } from "@/components/admin/admin-hub-chrome";
import { AdminMobileWrapper } from "@/components/admin/admin-mobile-wrapper"; 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"); 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 ( return (
<NextIntlClientProvider locale={locale} messages={messages}> <>
{csrfToken ? <meta name="csrf-token" content={csrfToken} /> : null} {csrfToken ? <meta name="csrf-token" content={csrfToken} /> : null}
<AdminMobileWrapper sidebar={<Sidebar staff={staff} />}> <AdminMobileWrapper sidebar={<Sidebar staff={staff} />}>
<div <div
@@ -62,7 +56,7 @@ export default async function AdminLayout({
</div> </div>
</AdminMobileWrapper> </AdminMobileWrapper>
<AdminToaster /> <AdminToaster />
</NextIntlClientProvider> </>
); );
} }
+1
View File
@@ -94,6 +94,7 @@ export default async function AdminPhotos({
className="admin-card" className="admin-card"
style={{ padding: 0, overflow: "hidden" }} style={{ padding: 0, overflow: "hidden" }}
> >
{/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={p.url} src={p.url}
alt={t("photoAlt", { id: String(p.id) })} 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 { 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]; const v = obj[key];
return typeof v === "string" && v.trim() !== "" ? v.trim() : null; 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 (typeof value === "number" && Number.isFinite(value)) return value;
if (!isRecord(value)) return null; if (!isRecord(value)) return null;
for (const key of keys) { 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]; const v = value[key];
if (typeof v === "number" && Number.isFinite(v)) return v; if (typeof v === "number" && Number.isFinite(v)) return v;
if ( if (
@@ -577,6 +577,7 @@ export function FurniClient({ room, items = [], canEdit }: Props) {
)} )}
<TableCell> <TableCell>
{icon ? ( {icon ? (
// eslint-disable-next-line @next/next/no-img-element
<img <img
src={icon} src={icon}
alt={item.name} alt={item.name}
@@ -732,6 +733,7 @@ export function FurniClient({ room, items = [], canEdit }: Props) {
> >
<TableCell> <TableCell>
{icon ? ( {icon ? (
// eslint-disable-next-line @next/next/no-img-element
<img <img
src={icon} src={icon}
alt={g.base.name} alt={g.base.name}
+1 -1
View File
@@ -493,7 +493,7 @@ export default async function AdminTheme({
width: 20, width: 20,
height: 20, height: 20,
borderRadius: 4, 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", background: theme.settings[k] ?? "#64748b",
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.2)", 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( 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]]), rarityKeys.map((rk, i) => [rk, rarityBoards[i]]),
) as Record<BadgeRarityKey, BoardCore>; ) as Record<BadgeRarityKey, BoardCore>;
@@ -398,7 +398,7 @@ export async function GET(req: Request) {
totalBadgesViewer = tb; totalBadgesViewer = tb;
achievementViewer = ach; achievementViewer = ach;
rarityViewer = Object.fromEntries( 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]]), rarityKeys.map((rk, i) => [rk, rvs[i]]),
) as Record<BadgeRarityKey, Partial<BadgeLeaderboardEntry> | undefined>; ) as Record<BadgeRarityKey, Partial<BadgeLeaderboardEntry> | undefined>;
} }
@@ -414,7 +414,7 @@ export async function GET(req: Request) {
viewerEntry: achievementViewer, viewerEntry: achievementViewer,
}, },
rarity: Object.fromEntries( rarity: Object.fromEntries(
// rk from rarityKeys const // eslint-disable-next-line security/detect-object-injection -- rk from rarityKeys const
rarityKeys.map((rk) => [ rarityKeys.map((rk) => [
rk, rk,
{ ...global.rarity[rk], viewerEntry: rarityViewer[rk] }, { ...global.rarity[rk], viewerEntry: rarityViewer[rk] },
+1 -1
View File
@@ -110,7 +110,7 @@ export async function GET(req: Request) {
async () => async () =>
type === "credits" type === "credits"
? await loadCreditsRows() ? 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]), await loadCurrencyRows(CURRENCY_TYPE[type]),
// Rankings are re-sorted on every read; serving the previous order for a // Rankings are re-sorted on every read; serving the previous order for a
// minute keeps a busy leaderboard off the database. // minute keeps a busy leaderboard off the database.
+3 -1
View File
@@ -51,6 +51,7 @@ export async function GET(
headers: SECURITY_HEADERS, headers: SECURITY_HEADERS,
}); });
} }
// eslint-disable-next-line security/detect-non-literal-fs-filename
if (!existsSync(filePath)) { if (!existsSync(filePath)) {
return new NextResponse("Not found", { return new NextResponse("Not found", {
status: 404, 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 bytes = await readFile(filePath);
const mime: Record<string, string> = { const mime: Record<string, string> = {
".png": "image/png", ".png": "image/png",
@@ -74,7 +76,7 @@ export async function GET(
headers: { headers: {
...SECURITY_HEADERS, ...SECURITY_HEADERS,
...(ext === ".svg" ? { "Content-Disposition": "attachment" } : {}), ...(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", "Content-Type": mime[ext] ?? "application/octet-stream",
"Cache-Control": "public, max-age=3600, must-revalidate", "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 () => { export const GET = withAdmin({ permission: PERMS.PAGES_EDIT }, async () => {
const dir = MEDIA_ROOT; const dir = MEDIA_ROOT;
// eslint-disable-next-line security/detect-non-literal-fs-filename
if (!existsSync(dir)) { if (!existsSync(dir)) {
return apiOk({ files: [] }); return apiOk({ files: [] });
} }
// eslint-disable-next-line security/detect-non-literal-fs-filename
const files = readdirSync(dir) const files = readdirSync(dir)
.filter((f) => /\.(png|jpg|jpeg|gif|webp|svg|bmp)$/i.test(f)) .filter((f) => /\.(png|jpg|jpeg|gif|webp|svg|bmp)$/i.test(f))
.map((f) => { .map((f) => {
+1 -1
View File
@@ -35,7 +35,7 @@ function findCount(value: unknown, depth = 0): number | null {
"count", "count",
]; ];
for (const key of keys) { 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]; const v = value[key];
if (typeof v === "number" && Number.isFinite(v)) return v; if (typeof v === "number" && Number.isFinite(v)) return v;
if ( if (
-14
View File
@@ -2893,20 +2893,6 @@ html {
0 0 0 3px color-mix(in srgb, var(--color-primary) 16%, transparent), 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); 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 { .auth-input:disabled {
opacity: 0.55; opacity: 0.55;
cursor: not-allowed; cursor: not-allowed;
+1 -6
View File
@@ -13,7 +13,6 @@ import { ThemeVars } from "@/components/theme-vars";
import { enforceSiteAccess } from "@/lib/access-guard"; import { enforceSiteAccess } from "@/lib/access-guard";
import { getDefaultFaviconDataUri } from "@/lib/default-favicon"; import { getDefaultFaviconDataUri } from "@/lib/default-favicon";
import { resolveHotelName } from "@/lib/hotel-name"; import { resolveHotelName } from "@/lib/hotel-name";
import { publicClientMessages } from "@/lib/i18n-client-messages";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
import "./globals.css"; import "./globals.css";
@@ -69,11 +68,7 @@ export default async function RootLayout({
}) { }) {
await enforceSiteAccess(); await enforceSiteAccess();
const locale = await getLocale(); const locale = await getLocale();
// Only the namespaces non-staff routes can reach: the full catalogue is const messages = await getMessages();
// ~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 defaultDark = await siteSettings.getBool("default_dark", false); const defaultDark = await siteSettings.getBool("default_dark", false);
const nitroUrl = await siteSettings.get("nitro_client_url", ""); const nitroUrl = await siteSettings.get("nitro_client_url", "");
const nonce = (await headers()).get("x-nonce") ?? undefined; const nonce = (await headers()).get("x-nonce") ?? undefined;
+3 -9
View File
@@ -10,8 +10,7 @@ import {
Users, Users,
} from "lucide-react"; } from "lucide-react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { NextIntlClientProvider } from "next-intl"; import { getTranslations } from "next-intl/server";
import { getLocale, getMessages, getTranslations } from "next-intl/server";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { LanguageSwitcher } from "@/components/language-switcher"; import { LanguageSwitcher } from "@/components/language-switcher";
import Link from "@/components/link"; 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 }, { href: "/mod/team", label: t("nav.team"), icon: Users, show: showTeam },
].filter((item) => item.show); ].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 ( return (
<NextIntlClientProvider locale={locale} messages={messages}> <>
{csrfToken ? <meta name="csrf-token" content={csrfToken} /> : null} {csrfToken ? <meta name="csrf-token" content={csrfToken} /> : null}
<div <div
data-admin data-admin
@@ -146,6 +140,6 @@ export default async function ModLayout({ children }: { children: ReactNode }) {
</header> </header>
<main className="mx-auto max-w-6xl p-4 sm:p-6">{children}</main> <main className="mx-auto max-w-6xl p-4 sm:p-6">{children}</main>
</div> </div>
</NextIntlClientProvider> </>
); );
} }
+1
View File
@@ -45,6 +45,7 @@ export function ArticleCard({
<div className="admin-card overflow-hidden flex flex-col"> <div className="admin-card overflow-hidden flex flex-col">
{confirmDialog} {confirmDialog}
<div className="relative w-full aspect-[16/9] bg-[var(--admin-canvas)]"> <div className="relative w-full aspect-[16/9] bg-[var(--admin-canvas)]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={image || ""} src={image || ""}
alt={title} alt={title}
+1
View File
@@ -296,6 +296,7 @@ export function ArticleForm({
/> />
</div> </div>
{image && !imageError ? ( {image && !imageError ? (
// eslint-disable-next-line @next/next/no-img-element
<img <img
src={image} src={image}
alt={t("imagePreview")} alt={t("imagePreview")}
@@ -715,6 +715,7 @@ function FurniThumb({
className="flex items-center justify-center rounded bg-muted/50" className="flex items-center justify-center rounded bg-muted/50"
style={{ width: size, height: size }} style={{ width: size, height: size }}
> >
{/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={primary} src={primary}
alt={item.itemName} alt={item.itemName}
@@ -893,7 +893,7 @@ export function SortableTree({
handleToggleEnabled, handleToggleEnabled,
handleAddSubpage, handleAddSubpage,
], ],
); ); // eslint-disable-line react-hooks/exhaustive-deps
return ( return (
<div <div
@@ -34,6 +34,7 @@ export function FurniIcon({
return ( return (
<div className="w-10 h-10 rounded bg-muted/50 flex items-center justify-center overflow-hidden"> <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 <img
src={localUrl} src={localUrl}
alt={classname} alt={classname}
@@ -231,6 +231,7 @@ function FurniThumb({
return ( return (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded bg-muted/50"> <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 <img
src={primary} src={primary}
alt={classname} alt={classname}
@@ -192,6 +192,7 @@ export function ShopFurniIcon({
} }
return ( return (
// eslint-disable-next-line @next/next/no-img-element
<img <img
src={primary} src={primary}
alt={classname} alt={classname}
+1
View File
@@ -226,6 +226,7 @@ export function AdminMediaGrid() {
}} }}
> >
<div className="relative"> <div className="relative">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={validImageUrl(f.url)} src={validImageUrl(f.url)}
alt={f.name} alt={f.name}
+1
View File
@@ -156,6 +156,7 @@ export function MediaPicker({
e.currentTarget.style.borderColor = "transparent"; e.currentTarget.style.borderColor = "transparent";
}} }}
> >
{/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={validImageUrl(f.url)} src={validImageUrl(f.url)}
alt={f.name} alt={f.name}
@@ -51,6 +51,7 @@ export function FurniThumb({
className="flex items-center justify-center rounded bg-muted/50" className="flex items-center justify-center rounded bg-muted/50"
style={{ width: size, height: size }} style={{ width: size, height: size }}
> >
{/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={primary} src={primary}
alt={item.itemName} alt={item.itemName}
@@ -405,7 +405,7 @@ export function ThemeManagerPanel({
width: 14, width: 14,
height: 14, height: 14,
borderRadius: 3, borderRadius: 3,
// k from fixed key list // eslint-disable-next-line security/detect-object-injection -- k from fixed key list
background: palette.light[k], background: palette.light[k],
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.15)", boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.15)",
marginLeft: -3, 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 { interface AuthPageFrameProps {
hotelName: string; hotelName: string;
logo?: string | null; 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 of the form card on the right. */
title: string; title: string;
/** One-line intro above the form. */ /** One-line intro above the form. */
@@ -150,7 +148,6 @@ interface AuthPageFrameProps {
export function AuthPageFrame({ export function AuthPageFrame({
hotelName, hotelName,
logo, logo,
online,
title, title,
subtitle, subtitle,
notice, 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="mx-auto w-full max-w-6xl flex flex-col gap-8 pb-16">
<div className="relative"> <div className="relative">
<AuthAurora /> <AuthAurora />
<AuthTopBar hotelName={hotelName} logo={logo} online={online} /> <AuthTopBar hotelName={hotelName} logo={logo} />
</div> </div>
<Reveal> <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 Image from "next/image";
import { Clock } from "@/components/clock"; import { Clock } from "@/components/clock";
import { LanguageSwitcher } from "@/components/language-switcher"; import { LanguageSwitcher } from "@/components/language-switcher";
import { LiveOnlineCount } from "@/components/live-online-count";
import { Reveal } from "@/components/motion-reveal"; import { Reveal } from "@/components/motion-reveal";
import { ThemeSwitcher } from "@/components/theme-switcher"; import { ThemeSwitcher } from "@/components/theme-switcher";
import { ICON_LIGHTHOUSE } from "@/lib/site-icons"; import { ICON_LIGHTHOUSE } from "@/lib/site-icons";
/** /**
* Shared frosted-glass top bar for the public pages (index, login, register). * Shared frosted-glass top bar for the public auth pages (login, register,
* Shows the hotel logo on the left and the live "online" counter (which carries * etc.). Shows the hotel logo + name on the left and language / clock / theme
* the hotel name), language / clock / theme controls on the right. One * controls on the right. Defaults mirror the homepage top bar so auth pages
* component so every public page keeps the exact same chrome instead of * share one consistent look.
* duplicating it.
*/ */
export function AuthTopBar({ export function AuthTopBar({
hotelName, hotelName,
logo, logo,
online, showHotelName = true,
}: { }: {
hotelName: string; hotelName: string;
logo?: string | null; logo?: string | null;
/** Live online count; omit (or pass null) to hide the indicator. */ showHotelName?: boolean;
online?: number | null;
}) { }) {
return ( return (
<Reveal> <Reveal>
<div <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={{ style={{
background: background:
"linear-gradient(90deg, color-mix(in srgb, var(--color-surface) 62%, transparent), color-mix(in srgb, var(--color-primary) 7%, transparent))", "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)", "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 ? ( {logo ? (
<Image <Image
src={logo} src={logo}
@@ -70,6 +67,14 @@ export function AuthTopBar({
/> />
</div> </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>
<div <div
className="flex items-center gap-1 sm:gap-1.5 rounded-full px-1.5 py-1 sm:px-2" 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)", "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 /> <LanguageSwitcher />
<div <div
className="hidden sm:block w-px h-5" className="hidden sm:block w-px h-5"
+64 -50
View File
@@ -3,10 +3,8 @@
import { KeyRound, Lock, UserRound } from "lucide-react"; import { KeyRound, Lock, UserRound } from "lucide-react";
import { signIn } from "next-auth/react"; import { signIn } from "next-auth/react";
import { useTranslations } from "next-intl"; 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 { precheckLogin } from "@/actions/auth-precheck";
import { AuthAlert } from "@/components/auth/auth-alert";
import { AuthField } from "@/components/auth/auth-field";
import { import {
type CaptchaPublicConfig, type CaptchaPublicConfig,
CaptchaWidget, CaptchaWidget,
@@ -53,17 +51,8 @@ export function LoginForm({
const [unverified, setUnverified] = useState(false); const [unverified, setUnverified] = useState(false);
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
const [locked, setLocked] = 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 showFooter = variant === "page";
const compact = variant === "compact";
const lockCredentials = const lockCredentials =
needs2fa || locked ? "opacity-50 pointer-events-none" : ""; needs2fa || locked ? "opacity-50 pointer-events-none" : "";
@@ -126,9 +115,7 @@ export function LoginForm({
<> <>
{needs2fa && ( {needs2fa && (
<p <p
className={`animate-fade-in-up rounded-xl border px-3 py-2.5 text-sm font-semibold ${ className="animate-fade-in-up mb-4 rounded-xl border px-3 py-2.5 text-sm font-semibold"
compact ? "mb-3" : "mb-4"
}`}
style={{ style={{
color: "var(--color-text-readable)", color: "var(--color-text-readable)",
background: background:
@@ -144,76 +131,105 @@ export function LoginForm({
<form <form
onSubmit={onSubmit} onSubmit={onSubmit}
aria-label={t("signIn")} aria-label={t("signIn")}
className={`relative flex flex-col ${compact ? "gap-3" : "gap-4"}`} className="relative flex flex-col gap-4"
> >
<AuthField <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`} id={`${fieldId}-username`}
name="username" name="username"
label={t("username")} type="text"
icon={UserRound}
value={username} value={username}
onChange={setUsername} onChange={(e) => setUsername(e.target.value)}
placeholder={t("usernamePlaceholder")} placeholder={t("usernamePlaceholder")}
autoComplete="username" autoComplete="username"
disabled={needs2fa || pending} disabled={needs2fa}
required required
invalid={!!error} className="auth-input has-icon"
describedBy={error ? `${fieldId}-error` : undefined}
className={lockCredentials}
/> />
</div>
</div>
<AuthField <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`} id={`${fieldId}-password`}
name="password" name="password"
label={t("password")}
icon={Lock}
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
value={password} value={password}
onChange={setPassword} onChange={(e) => setPassword(e.target.value)}
placeholder={t("passwordPlaceholder")} placeholder={t("passwordPlaceholder")}
autoComplete="current-password" autoComplete="current-password"
disabled={needs2fa || pending} disabled={needs2fa}
required required
invalid={!!error} className="auth-input has-icon has-action"
describedBy={error ? `${fieldId}-error` : undefined} />
className={lockCredentials}
trailing={
<PasswordToggle <PasswordToggle
show={showPassword} show={showPassword}
onToggle={() => setShowPassword((v) => !v)} onToggle={() => setShowPassword((v) => !v)}
labelShow={t("showPassword")} labelShow={t("showPassword")}
labelHide={t("hidePassword")} labelHide={t("hidePassword")}
/> />
} </div>
/> </div>
{needs2fa ? ( {needs2fa ? (
<AuthField <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`} id={`${fieldId}-2fa`}
name="code" name="code"
label={t("codePlaceholder")} type="text"
icon={KeyRound}
iconColor="var(--color-primary)"
value={code} value={code}
onChange={setCode} onChange={(e) => setCode(e.target.value)}
placeholder={t("codePlaceholder")} placeholder={t("codePlaceholder")}
inputMode="numeric" inputMode="numeric"
autoComplete="one-time-code" autoComplete="one-time-code"
disabled={pending} className="auth-input has-icon"
invalid={!!error} style={{
describedBy={error ? `${fieldId}-error` : undefined}
className="animate-scale-in"
inputRef={codeRef}
inputStyle={{
borderColor: borderColor:
"color-mix(in srgb, var(--color-primary) 45%, transparent)", "color-mix(in srgb, var(--color-primary) 45%, transparent)",
}} }}
/> />
</div>
</div>
) : ( ) : (
<CaptchaWidget captcha={captcha} nonce={nonce} /> <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 && ( {unverified && (
<p <p
@@ -233,9 +249,7 @@ export function LoginForm({
<button <button
type="submit" type="submit"
disabled={pending || locked} disabled={pending || locked}
className={`btn-brand btn-shine w-full cursor-pointer text-sm font-extrabold uppercase tracking-wider disabled:opacity-60 ${ className="btn-brand btn-shine w-full cursor-pointer py-3.5 text-sm font-extrabold uppercase tracking-wider disabled:opacity-60"
compact ? "py-3" : "py-3.5"
}`}
> >
{pending ? ( {pending ? (
<span className="inline-flex items-center justify-center gap-2"> <span className="inline-flex items-center justify-center gap-2">
+149 -99
View File
@@ -4,7 +4,6 @@ import {
Check, Check,
KeyRound, KeyRound,
Lock, Lock,
type LucideIcon,
Mail, Mail,
ShieldCheck, ShieldCheck,
UserRound, UserRound,
@@ -19,8 +18,6 @@ import {
type RegisterState, type RegisterState,
register, register,
} from "@/actions/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 { PasswordToggle } from "@/components/auth/password-toggle";
import Link from "@/components/link"; import Link from "@/components/link";
import { signInWithTransientRetry } from "@/lib/auth/sign-in-retry"; import { signInWithTransientRetry } from "@/lib/auth/sign-in-retry";
@@ -30,23 +27,19 @@ interface RegisterFormProps {
captcha: { provider: string; siteKey?: string }; captcha: { provider: string; siteKey?: string };
error?: string; error?: string;
nonce?: string; nonce?: string;
/** Effective policy from `PASSWORD_MIN_LENGTH` / `PASSWORD_MAX_LENGTH`. */
passwordMinLength: number;
passwordMaxLength: number;
} }
function passwordStrength( function passwordStrength(
pw: string, pw: string,
t: (key: string) => string, t: (key: string) => string,
minLength: number,
): { ): {
score: number; score: number;
label: string; label: string;
color: string; color: string;
} { } {
let score = 0; let score = 0;
if (pw.length >= minLength) score += 1; if (pw.length >= 8) score += 1;
if (pw.length >= minLength + 4) score += 1; if (pw.length >= 12) score += 1;
if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) score += 1; if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) score += 1;
if (/\d/.test(pw)) score += 1; if (/\d/.test(pw)) score += 1;
if (/[^a-zA-Z0-9]/.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" }; 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({ export function RegisterForm({
hotelName, hotelName,
captcha, captcha,
error, error,
nonce, nonce,
passwordMinLength,
passwordMaxLength,
}: RegisterFormProps) { }: RegisterFormProps) {
const t = useTranslations("pages.register"); const t = useTranslations("pages.register");
const showCaptcha = captcha.provider !== "none" && !!captcha.siteKey; const showCaptcha = captcha.provider !== "none" && !!captcha.siteKey;
@@ -144,7 +90,7 @@ export function RegisterForm({
); );
const autoLoginStartedRef = useRef(false); const autoLoginStartedRef = useRef(false);
const [referralCode, setReferralCode] = useState(""); 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 * The password policy the server enforces, evaluated live so the visitor can
@@ -152,10 +98,7 @@ export function RegisterForm({
* single error back. * single error back.
*/ */
const requirements = [ const requirements = [
{ { label: t("passwordMinLength"), met: password.length >= 12 },
label: t("passwordMinLength", { min: passwordMinLength }),
met: password.length >= passwordMinLength,
},
{ label: t("passwordUpper"), met: /[A-Z]/.test(password) }, { label: t("passwordUpper"), met: /[A-Z]/.test(password) },
{ label: t("passwordLower"), met: /[a-z]/.test(password) }, { label: t("passwordLower"), met: /[a-z]/.test(password) },
{ label: t("passwordDigit"), met: /\d/.test(password) }, { label: t("passwordDigit"), met: /\d/.test(password) },
@@ -240,7 +183,15 @@ export function RegisterForm({
}} }}
className="flex flex-col gap-6" 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 && ( {referralCode && (
<div <div
@@ -261,65 +212,156 @@ export function RegisterForm({
{/* Username & Email */} {/* Username & Email */}
<div className="space-y-2"> <div className="space-y-2">
<AuthSectionHeader <div className="flex items-center gap-2.5">
icon={UserRound} <span
label={t("accountDetails")} aria-hidden="true"
step="01" 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"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4 stagger-children">
<AuthField <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" id="username"
name="username" name="username"
label={t("username")} type="text"
icon={UserRound}
placeholder={t("usernamePlaceholder")} placeholder={t("usernamePlaceholder")}
autoComplete="username" autoComplete="username"
className="auth-input has-icon"
required required
/> />
<AuthField </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" id="mail"
name="mail" name="mail"
label={t("email")}
icon={Mail}
type="email" type="email"
placeholder={t("emailPlaceholder")} placeholder={t("emailPlaceholder")}
autoComplete="email" autoComplete="email"
className="auth-input has-icon"
/> />
</div> </div>
</div> </div>
</div>
</div>
{/* Password & Confirm */} {/* Password & Confirm */}
<div className="space-y-2"> <div className="space-y-2">
<AuthSectionHeader <div className="flex items-center gap-2.5">
icon={KeyRound} <span
label={t("credentials")} aria-hidden="true"
step="02" 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"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4 stagger-children">
<AuthField <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" id="password"
name="password" name="password"
label={t("password")}
icon={Lock}
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
value={password} placeholder={t("passwordPlaceholder")}
onChange={setPassword}
placeholder={t("passwordPlaceholder", {
min: passwordMinLength,
})}
autoComplete="new-password" autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="auth-input has-icon has-action"
required required
minLength={passwordMinLength} />
maxLength={passwordMaxLength}
trailing={
<PasswordToggle <PasswordToggle
show={showPassword} show={showPassword}
onToggle={() => setShowPassword((v) => !v)} onToggle={() => setShowPassword((v) => !v)}
labelShow={t("showPassword")} labelShow={t("showPassword")}
labelHide={t("hidePassword")} labelHide={t("hidePassword")}
/> />
} </div>
>
{password.length > 0 && ( {password.length > 0 && (
<div className="animate-fade-in-up space-y-1.5 pt-0.5"> <div className="animate-fade-in-up space-y-1.5 pt-0.5">
<div className="flex gap-1.5"> <div className="flex gap-1.5">
@@ -375,27 +417,35 @@ export function RegisterForm({
</li> </li>
))} ))}
</ul> </ul>
</AuthField> </div>
<AuthField <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" id="password_confirmation"
name="password_confirmation" name="password_confirmation"
label={t("confirmPassword")}
icon={Lock}
type={showConfirm ? "text" : "password"} type={showConfirm ? "text" : "password"}
value={confirmPassword}
onChange={setConfirmPassword}
placeholder={t("confirmPasswordPlaceholder")} placeholder={t("confirmPasswordPlaceholder")}
autoComplete="new-password" autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="auth-input has-icon has-action"
required required
trailing={ />
<PasswordToggle <PasswordToggle
show={showConfirm} show={showConfirm}
onToggle={() => setShowConfirm((v) => !v)} onToggle={() => setShowConfirm((v) => !v)}
labelShow={t("showPassword")} labelShow={t("showPassword")}
labelHide={t("hidePassword")} labelHide={t("hidePassword")}
/> />
} </div>
>
{mismatch && ( {mismatch && (
<p <p
className="animate-fade-in-up text-xs font-bold" className="animate-fade-in-up text-xs font-bold"
@@ -404,7 +454,7 @@ export function RegisterForm({
{t("passwordsDontMatch")} {t("passwordsDontMatch")}
</p> </p>
)} )}
</AuthField> </div>
</div> </div>
</div> </div>
+1 -1
View File
@@ -44,7 +44,7 @@ export function NavbarColorPicker() {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
function apply(index: number) { 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]; const c = COLORS[index];
if (!c) return; if (!c) return;
document.documentElement.style.setProperty("--color-navbar", c.value); 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. // has to hold the overlay itself.
const [closing, setClosing] = useState(false); const [closing, setClosing] = useState(false);
const exitTimer = useRef<ReturnType<typeof setTimeout> | null>(null); 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 active = openIndex !== null ? photos[openIndex] : null;
const close = useCallback(() => { const close = useCallback(() => {
@@ -97,6 +97,7 @@ export function PhotoLightbox({ photos }: { photos: LightboxPhoto[] }) {
}} }}
aria-label={p.alt} aria-label={p.alt}
> >
{/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={p.url} src={p.url}
alt={p.alt} alt={p.alt}
@@ -179,6 +180,7 @@ export function PhotoLightbox({ photos }: { photos: LightboxPhoto[] }) {
cursor: "default", cursor: "default",
}} }}
> >
{/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={active.url} src={active.url}
alt={active.alt} alt={active.alt}
+1 -1
View File
@@ -59,7 +59,7 @@ function scanCharWidths(
const w = end - start; const w = end - start;
if (w > 0) { 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 }); chars.push({ char: CHAR_ORDER[ci], x: start, w });
ci++; ci++;
} }
+1
View File
@@ -31,6 +31,7 @@ export function AvatarImage({
(options?.headOnly ? "l" : max <= 40 ? "s" : max <= 80 ? "m" : "l"); (options?.headOnly ? "l" : max <= 40 ? "s" : max <= 80 ? "m" : "l");
return ( return (
// eslint-disable-next-line @next/next/no-img-element
<img <img
src={getAvatarUrl(figure, { ...options, size: autoSize })} src={getAvatarUrl(figure, { ...options, size: autoSize })}
alt={alt} alt={alt}
@@ -11,7 +11,7 @@ const compactCallSites = new Map<string, number>([
["src/app/(site)/leaderboard/page.tsx", 1], ["src/app/(site)/leaderboard/page.tsx", 1],
["src/app/(site)/me/page.tsx", 1], ["src/app/(site)/me/page.tsx", 1],
["src/app/(site)/messages/page.tsx", 2], ["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/leaderboard/page.tsx", 1],
["src/app/(site)/radio/shouts/page.tsx", 1], ["src/app/(site)/radio/shouts/page.tsx", 1],
["src/app/(site)/rankings/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 factor used for new password hashes.
BCRYPT_COST: z.coerce.number().int().min(4).max(31).default(12), 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 // Filesystem dir the badge uploader writes <code>.gif into (the emulator's
// badge image folder, e.g. .../assets/c_images/album1584). Upload is disabled // badge image folder, e.g. .../assets/c_images/album1584). Upload is disabled
// when unset. // when unset.
@@ -41,8 +41,6 @@ const routeMocks = vi.hoisted(() => {
} }
return translate; return translate;
}), }),
getLocale: vi.fn(async () => "en"),
getMessages: vi.fn(async () => ({})),
notFound: vi.fn((): never => { notFound: vi.fn((): never => {
throw new Error("NEXT_NOT_FOUND"); throw new Error("NEXT_NOT_FOUND");
}), }),
@@ -60,8 +58,6 @@ vi.mock("next/navigation", () => ({
})); }));
vi.mock("next-intl/server", () => ({ vi.mock("next-intl/server", () => ({
getTranslations: routeMocks.getTranslations, getTranslations: routeMocks.getTranslations,
getLocale: routeMocks.getLocale,
getMessages: routeMocks.getMessages,
})); }));
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({ vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
getHousekeepingCapabilityContext: routeMocks.getHousekeepingCapabilityContext, getHousekeepingCapabilityContext: routeMocks.getHousekeepingCapabilityContext,
+1 -14
View File
@@ -1,7 +1,6 @@
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import NextAuth from "next-auth"; import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials"; import Credentials from "next-auth/providers/credentials";
import { cache } from "react";
import { env } from "@/env"; import { env } from "@/env";
import { getCachedJwtVersion } from "@/lib/auth/jwt-version-cache"; import { getCachedJwtVersion } from "@/lib/auth/jwt-version-cache";
import { import {
@@ -26,7 +25,7 @@ import { db, User } from "@/lib/db";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { clientIp, rateLimit } from "@/lib/rate-limit"; import { clientIp, rateLimit } from "@/lib/rate-limit";
const nextAuth = NextAuth({ export const { handlers, signOut, auth } = NextAuth({
trustHost: true, trustHost: true,
secret: env.AUTH_SECRET, secret: env.AUTH_SECRET,
session: { strategy: "jwt", maxAge: 24 * 60 * 60 }, 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; if (key === "admin") continue;
expect(result[key]).toBeDefined(); expect(result[key]).toBeDefined();
} }
// `pages.admin` survives only in the shape the shared server-action hook expect(result.pages.admin).toBeUndefined();
// 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();
}); });
it("actually shrinks the catalogue", async () => { it("actually shrinks the catalogue", async () => {
@@ -116,59 +111,3 @@ describe("publicClientMessages", () => {
expect(en.pages.admin).toBeDefined(); 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([]);
});
});
+8 -34
View File
@@ -3,35 +3,22 @@ import type { AbstractIntlMessages } from "next-intl";
/** /**
* Client-side message scoping. * Client-side message scoping.
* *
* `getMessages()` hands back the whole catalogue, and the root layout feeds it * `getMessages()` hands back the whole catalogue, and the root layout feeds all
* to `NextIntlClientProvider`, which serialises every message into the RSC * of it to `NextIntlClientProvider`, which serialises every message into the RSC
* payload of every page. The catalogue is ~254 KB of which `pages.admin` and * payload of every page. The catalogue is ~235 KB of which `pages.admin` and
* `admin` together are ~195 KB — and no public page ever reads either. * `admin` together are ~177 KB — and no public page ever reads either.
* *
* Nested `NextIntlClientProvider`s REPLACE the parent's messages rather than * Nested `NextIntlClientProvider`s REPLACE the parent's messages rather than
* merging them (see use-intl's IntlProvider: `messages ?? parent.messages`), so * merging them (see use-intl's IntlProvider: `messages ?? parent.messages`), so
* a segment cannot ask for "just my part". The root layout therefore installs * a segment cannot ask for "just my part". Instead the public layout installs a
* the reduced catalogue, and the staff route groups (`/admin`, `/admin-next`, * provider seeded with the public subset, which shrinks the payload for every
* `/mod`) each install a nested provider seeded with the full one, which keeps * public page while leaving /admin, /mod, /client and /admin-next on the full set.
* staff tools complete while public pages ship ~59 KB instead of ~254 KB.
*/ */
/** Top-level namespaces plus `pages.*` sub-namespaces reserved for staff tools. */ /** Top-level namespaces plus `pages.*` sub-namespaces reserved for staff tools. */
const ADMIN_ONLY_NAMESPACES = new Set(["admin"]); const ADMIN_ONLY_NAMESPACES = new Set(["admin"]);
const ADMIN_ONLY_PAGE_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. * Drop the staff-tool namespaces from a full catalogue.
* *
@@ -56,21 +43,8 @@ export function publicClientMessages(
for (const [pageKey, pageValue] of Object.entries( for (const [pageKey, pageValue] of Object.entries(
value as AbstractIntlMessages, value as AbstractIntlMessages,
)) { )) {
if (!ADMIN_ONLY_PAGE_NAMESPACES.has(pageKey)) { if (ADMIN_ONLY_PAGE_NAMESPACES.has(pageKey)) continue;
pages[pageKey] = pageValue; 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;
} }
result.pages = pages; result.pages = pages;
} }
-1
View File
@@ -4,7 +4,6 @@ import { collectPerformance } from "./performance-diagnostics";
import { type PerformanceSample, recordPerformance } from "./performance-store"; import { type PerformanceSample, recordPerformance } from "./performance-store";
type PublicRoute = type PublicRoute =
| "/"
| "/me" | "/me"
| "/news" | "/news"
| "/news/[slug]" | "/news/[slug]"
+1 -1
View File
@@ -14,6 +14,6 @@ const LABELS: Record<CurrencyType, string> = {
}; };
export function currencyLabel(type: 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]; return LABELS[type];
} }
+2
View File
@@ -55,10 +55,12 @@ async function writeToFile(
): Promise<boolean> { ): Promise<boolean> {
try { try {
const logDir = resolve(process.cwd(), "storage", "logs"); const logDir = resolve(process.cwd(), "storage", "logs");
// eslint-disable-next-line security/detect-non-literal-fs-filename
await mkdir(logDir, { recursive: true }); await mkdir(logDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const filename = `email-${timestamp}.html`; const filename = `email-${timestamp}.html`;
const content = `<!-- To: ${to} | From: ${from} | Subject: ${subject} -->\n${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"); await writeFile(resolve(logDir, filename), content, "utf-8");
logger.info("Email written to file", { module: "email", filename }); logger.info("Email written to file", { module: "email", filename });
return true; return true;
+1 -1
View File
@@ -59,7 +59,7 @@ export async function checkVpn(ip: string): Promise<IpVerdict> {
string, string,
{ proxy?: string; type?: 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]; const entry = d?.[ip];
if (entry?.proxy === "yes") if (entry?.proxy === "yes")
return { blocked: true, reason: `${entry.type ?? "Proxy"} detected` }; return { blocked: true, reason: `${entry.type ?? "Proxy"} detected` };
+1 -1
View File
@@ -84,7 +84,7 @@ export async function sendCurrency(
data: { credits: { increment: amount } }, data: { credits: { increment: amount } },
}); });
} else { } 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]; const t = TYPE_VALUE[type];
await deps.db.usersCurrency.upsert({ await deps.db.usersCurrency.upsert({
where: { userId_type: { userId, type: t } }, where: { userId_type: { userId, type: t } },
+1 -1
View File
@@ -128,7 +128,7 @@ class SiteSettings {
): Promise<string | null> { ): Promise<string | null> {
const map = await this.load(); const map = await this.load();
if (map.has(key)) return map.get(key) as string; 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; if (key in DEFAULTS) return DEFAULTS[key] as string;
return fallback; return fallback;
} }
+4 -4
View File
@@ -42,7 +42,7 @@ function fallbackFor(key: string): string {
const preset = PRESETS["Atom (golden)"]; const preset = PRESETS["Atom (golden)"];
if (key.endsWith("_dark")) { if (key.endsWith("_dark")) {
const base = key.slice(0, -"_dark".length) as ThemeColorKey; 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) { if (key in preset.light) {
return preset.light[key as ThemeColorKey] ?? ""; return preset.light[key as ThemeColorKey] ?? "";
@@ -63,7 +63,7 @@ function fallbackFor(key: string): string {
theme_preset: "Atom (golden)", theme_preset: "Atom (golden)",
...BACKGROUND_DEFAULTS, ...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[]> { export async function listCustomThemes(): Promise<CustomTheme[]> {
@@ -106,9 +106,9 @@ export async function upsertCustomTheme(
if (id) { if (id) {
const idx = themes.findIndex((t) => t.id === id); const idx = themes.findIndex((t) => t.id === id);
if (idx >= 0) { 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); 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 = { const theme: CustomTheme = {
+5 -5
View File
@@ -12,7 +12,7 @@ describe("createUserSchema", () => {
const result = createUserSchema.safeParse({ const result = createUserSchema.safeParse({
username: "TestUser", username: "TestUser",
mail: "[email protected]", mail: "[email protected]",
password: "secret123456", password: "secret123",
}); });
expect(result.success).toBe(true); expect(result.success).toBe(true);
if (result.success) { if (result.success) {
@@ -26,7 +26,7 @@ describe("createUserSchema", () => {
createUserSchema.safeParse({ createUserSchema.safeParse({
username: "ab", username: "ab",
mail: "[email protected]", mail: "[email protected]",
password: "secret123456", password: "secret123",
}).success, }).success,
).toBe(false); ).toBe(false);
}); });
@@ -36,7 +36,7 @@ describe("createUserSchema", () => {
createUserSchema.safeParse({ createUserSchema.safeParse({
username: "a".repeat(21), username: "a".repeat(21),
mail: "[email protected]", mail: "[email protected]",
password: "secret123456", password: "secret123",
}).success, }).success,
).toBe(false); ).toBe(false);
}); });
@@ -46,7 +46,7 @@ describe("createUserSchema", () => {
createUserSchema.safeParse({ createUserSchema.safeParse({
username: "user@name", username: "user@name",
mail: "[email protected]", mail: "[email protected]",
password: "secret123456", password: "secret123",
}).success, }).success,
).toBe(false); ).toBe(false);
}); });
@@ -56,7 +56,7 @@ describe("createUserSchema", () => {
createUserSchema.safeParse({ createUserSchema.safeParse({
username: "TestUser", username: "TestUser",
mail: "not-an-email", mail: "not-an-email",
password: "secret123456", password: "secret123",
}).success, }).success,
).toBe(false); ).toBe(false);
}); });
+1 -11
View File
@@ -1,5 +1,4 @@
import { z } from "zod"; import { z } from "zod";
import { passwordPolicy } from "@/lib/auth/password-policy";
export const createUserSchema = z.object({ export const createUserSchema = z.object({
username: z username: z
@@ -11,16 +10,7 @@ export const createUserSchema = z.object({
"Username can only contain letters, numbers, _ and -", "Username can only contain letters, numbers, _ and -",
), ),
mail: z.string().email("Invalid email address"), mail: z.string().email("Invalid email address"),
password: z password: z.string().min(6, "Password must be at least 6 characters"),
.string()
.min(
passwordPolicy.minLength,
`Password must be at least ${passwordPolicy.minLength} characters`,
)
.max(
passwordPolicy.maxLength,
`Password must be at most ${passwordPolicy.maxLength} characters`,
),
rank: z.coerce.number().int().min(1).max(7).default(1), rank: z.coerce.number().int().min(1).max(7).default(1),
motto: z.string().max(127).default(""), motto: z.string().max(127).default(""),
}); });
+5 -5
View File
@@ -6132,7 +6132,7 @@
"confirmPassword": "أعد كلمة المرور", "confirmPassword": "أعد كلمة المرور",
"usernamePlaceholder": "اسم المستخدم", "usernamePlaceholder": "اسم المستخدم",
"emailPlaceholder": "البريد الإلكتروني", "emailPlaceholder": "البريد الإلكتروني",
"passwordPlaceholder": "كلمة المرور ({min} حرفًا على الأقل)", "passwordPlaceholder": "كلمة المرور (12 حرفًا على الأقل)",
"confirmPasswordPlaceholder": "أعد كلمة المرور", "confirmPasswordPlaceholder": "أعد كلمة المرور",
"selectOutfit": "اختر ملابسك", "selectOutfit": "اختر ملابسك",
"termsAccept": "أنا أبلغ 18 عامًا وأوافق على شروط {hotel}.", "termsAccept": "أنا أبلغ 18 عامًا وأوافق على شروط {hotel}.",
@@ -6153,7 +6153,7 @@
"termsError": "يجب الموافقة على الشروط للتسجيل.", "termsError": "يجب الموافقة على الشروط للتسجيل.",
"usernameError": "اسم المستخدم مطلوب.", "usernameError": "اسم المستخدم مطلوب.",
"emailError": "البريد الإلكتروني الصالح مطلوب.", "emailError": "البريد الإلكتروني الصالح مطلوب.",
"passwordError": "يجب ألا تقل كلمة المرور عن {min} حرفًا", "passwordError": "يجب ألا تقل كلمة المرور عن 12 حرفًا",
"confirmPasswordError": "كلمتا المرور غير متطابقتين.", "confirmPasswordError": "كلمتا المرور غير متطابقتين.",
"termsRequired": "يجب الموافقة على الشروط.", "termsRequired": "يجب الموافقة على الشروط.",
"usernameRequired": "اسم المستخدم مطلوب.", "usernameRequired": "اسم المستخدم مطلوب.",
@@ -6167,7 +6167,7 @@
"strengthFair": "مقبول", "strengthFair": "مقبول",
"strengthGood": "جيد", "strengthGood": "جيد",
"strengthStrong": "قوي", "strengthStrong": "قوي",
"passwordMinLength": "يجب ألا تقل كلمة المرور عن {min} حرفًا", "passwordMinLength": "يجب ألا تقل كلمة المرور عن 12 حرفًا",
"passwordUpper": "يجب أن تحتوي كلمة المرور على حرف كبير واحد على الأقل", "passwordUpper": "يجب أن تحتوي كلمة المرور على حرف كبير واحد على الأقل",
"passwordLower": "يجب أن تحتوي كلمة المرور على حرف صغير واحد على الأقل", "passwordLower": "يجب أن تحتوي كلمة المرور على حرف صغير واحد على الأقل",
"passwordDigit": "يجب أن تحتوي كلمة المرور على رقم واحد على الأقل", "passwordDigit": "يجب أن تحتوي كلمة المرور على رقم واحد على الأقل",
@@ -6202,10 +6202,10 @@
"reset": { "reset": {
"title": "Set a new password", "title": "Set a new password",
"subtitle": "Choose a strong password you don't use elsewhere.", "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", "resetPassword": "Reset password",
"backToLogin": "Back to login", "backToLogin": "Back to login",
"passwordMinLength": "يجب ألا تقل كلمة المرور عن {min} حرفًا", "passwordMinLength": "يجب ألا تقل كلمة المرور عن 12 حرفًا",
"tooManyAttempts": "محاولات كثيرة جدًا — حاول لاحقًا", "tooManyAttempts": "محاولات كثيرة جدًا — حاول لاحقًا",
"invalidLink": "رابط إعادة التعيين غير صالح أو منتهي الصلاحية. اطلب رابطًا جديدًا.", "invalidLink": "رابط إعادة التعيين غير صالح أو منتهي الصلاحية. اطلب رابطًا جديدًا.",
"failed": "تعذّرت إعادة تعيين كلمة المرور. حاول مرة أخرى." "failed": "تعذّرت إعادة تعيين كلمة المرور. حاول مرة أخرى."
+5 -5
View File
@@ -885,7 +885,7 @@
"confirmPassword": "Повторете паролата", "confirmPassword": "Повторете паролата",
"usernamePlaceholder": "Потребителско име", "usernamePlaceholder": "Потребителско име",
"emailPlaceholder": "Имейл", "emailPlaceholder": "Имейл",
"passwordPlaceholder": "Парола (мин. {min} знака)", "passwordPlaceholder": "Парола (мин. 12 знака)",
"confirmPasswordPlaceholder": "Повторете паролата", "confirmPasswordPlaceholder": "Повторете паролата",
"selectOutfit": "Изберете вашето облекло", "selectOutfit": "Изберете вашето облекло",
"termsAccept": "Аз съм на 18+ и приемам условията и правилата на {hotel}.", "termsAccept": "Аз съм на 18+ и приемам условията и правилата на {hotel}.",
@@ -897,7 +897,7 @@
"termsError": "Трябва да приемете условията и правилата, за да се регистрирате.", "termsError": "Трябва да приемете условията и правилата, за да се регистрирате.",
"usernameError": "Изисква се потребителско име.", "usernameError": "Изисква се потребителско име.",
"emailError": "Изисква се валиден имейл.", "emailError": "Изисква се валиден имейл.",
"passwordError": "Паролата трябва да е поне {min} знака", "passwordError": "Паролата трябва да е поне 12 знака",
"confirmPasswordError": "Паролите не съвпадат.", "confirmPasswordError": "Паролите не съвпадат.",
"termsRequired": "Трябва да приемете условията и правилата.", "termsRequired": "Трябва да приемете условията и правилата.",
"usernameRequired": "Изисква се потребителско име.", "usernameRequired": "Изисква се потребителско име.",
@@ -920,7 +920,7 @@
"strengthFair": "Средна", "strengthFair": "Средна",
"strengthGood": "Добра", "strengthGood": "Добра",
"strengthStrong": "Силна", "strengthStrong": "Силна",
"passwordMinLength": "Паролата трябва да е поне {min} знака", "passwordMinLength": "Паролата трябва да е поне 12 знака",
"passwordUpper": "Паролата трябва да съдържа поне една главна буква", "passwordUpper": "Паролата трябва да съдържа поне една главна буква",
"passwordLower": "Паролата трябва да съдържа поне една малка буква", "passwordLower": "Паролата трябва да съдържа поне една малка буква",
"passwordDigit": "Паролата трябва да съдържа поне една цифра", "passwordDigit": "Паролата трябва да съдържа поне една цифра",
@@ -955,10 +955,10 @@
"reset": { "reset": {
"title": "Задайте нова парола", "title": "Задайте нова парола",
"subtitle": "Изберете силна парола, която не използвате другаде.", "subtitle": "Изберете силна парола, която не използвате другаде.",
"passwordPlaceholder": "Нова парола (мин. {min} знака)", "passwordPlaceholder": "Нова парола (мин. 12 знака)",
"resetPassword": "Нулирайте паролата", "resetPassword": "Нулирайте паролата",
"backToLogin": "Назад към входа", "backToLogin": "Назад към входа",
"passwordMinLength": "Паролата трябва да е поне {min} знака", "passwordMinLength": "Паролата трябва да е поне 12 знака",
"tooManyAttempts": "Твърде много опити — опитайте по-късно", "tooManyAttempts": "Твърде много опити — опитайте по-късно",
"invalidLink": "Този линк за нулиране е невалиден или е изтекъл. Поискайте нов.", "invalidLink": "Този линк за нулиране е невалиден или е изтекъл. Поискайте нов.",
"failed": "Нулирането на паролата не бе успешно. Моля, опитайте отново." "failed": "Нулирането на паролата не бе успешно. Моля, опитайте отново."
+5 -5
View File
@@ -885,7 +885,7 @@
"confirmPassword": "Opakujte heslo", "confirmPassword": "Opakujte heslo",
"usernamePlaceholder": "Uživatelské jméno", "usernamePlaceholder": "Uživatelské jméno",
"emailPlaceholder": "Email", "emailPlaceholder": "Email",
"passwordPlaceholder": "Heslo (min. {min} znaků)", "passwordPlaceholder": "Heslo (min. 12 znaků)",
"confirmPasswordPlaceholder": "Opakujte heslo", "confirmPasswordPlaceholder": "Opakujte heslo",
"selectOutfit": "Vyberte si oblečení", "selectOutfit": "Vyberte si oblečení",
"termsAccept": "Je mi 18+ a souhlasím s podmínkami a pravidly {hotel}.", "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.", "termsError": "Chcete-li se zaregistrovat, musíte přijmout podmínky a pravidla.",
"usernameError": "Uživatelské jméno je povinné.", "usernameError": "Uživatelské jméno je povinné.",
"emailError": "Je vyžadován platný e-mail.", "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í.", "confirmPasswordError": "Hesla se neshodují.",
"termsRequired": "Musíte přijmout podmínky a pravidla.", "termsRequired": "Musíte přijmout podmínky a pravidla.",
"usernameRequired": "Uživatelské jméno je povinné.", "usernameRequired": "Uživatelské jméno je povinné.",
@@ -920,7 +920,7 @@
"strengthFair": "Ucházející", "strengthFair": "Ucházející",
"strengthGood": "Dobré", "strengthGood": "Dobré",
"strengthStrong": "Silné", "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", "passwordUpper": "Heslo musí obsahovat alespoň jedno velké písmeno",
"passwordLower": "Heslo musí obsahovat alespoň jedno malé písmeno", "passwordLower": "Heslo musí obsahovat alespoň jedno malé písmeno",
"passwordDigit": "Heslo musí obsahovat alespoň jednu číslici", "passwordDigit": "Heslo musí obsahovat alespoň jednu číslici",
@@ -955,10 +955,10 @@
"reset": { "reset": {
"title": "Nastavte nové heslo", "title": "Nastavte nové heslo",
"subtitle": "Vyberte si silné heslo, které jinde nepoužíváte.", "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", "resetPassword": "Obnovit heslo",
"backToLogin": "Zpět k přihlášení", "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", "tooManyAttempts": "Příliš mnoho pokusů — zkuste to později",
"invalidLink": "Tento odkaz pro obnovu je neplatný nebo vypršel. Vyžádejte si nový.", "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." "failed": "Heslo nebylo možné obnovit. Zkuste to prosím znovu."
+5 -5
View File
@@ -885,7 +885,7 @@
"confirmPassword": "Gentag adgangskoden", "confirmPassword": "Gentag adgangskoden",
"usernamePlaceholder": "Brugernavn", "usernamePlaceholder": "Brugernavn",
"emailPlaceholder": "E-mail", "emailPlaceholder": "E-mail",
"passwordPlaceholder": "Adgangskode (min. {min} tegn)", "passwordPlaceholder": "Adgangskode (min. 12 tegn)",
"confirmPasswordPlaceholder": "Gentag adgangskoden", "confirmPasswordPlaceholder": "Gentag adgangskoden",
"selectOutfit": "Vælg dit outfit", "selectOutfit": "Vælg dit outfit",
"termsAccept": "Jeg er 18+ og accepterer vilkårene og reglerne for {hotel}.", "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.", "termsError": "Du skal acceptere vilkårene og reglerne for at registrere dig.",
"usernameError": "Brugernavn er påkrævet.", "usernameError": "Brugernavn er påkrævet.",
"emailError": "Gyldig e-mail 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.", "confirmPasswordError": "Adgangskoder stemmer ikke overens.",
"termsRequired": "Du skal acceptere vilkårene og reglerne.", "termsRequired": "Du skal acceptere vilkårene og reglerne.",
"usernameRequired": "Brugernavn er påkrævet.", "usernameRequired": "Brugernavn er påkrævet.",
@@ -920,7 +920,7 @@
"strengthFair": "Middelmådig", "strengthFair": "Middelmådig",
"strengthGood": "God", "strengthGood": "God",
"strengthStrong": "Stærk", "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", "passwordUpper": "Adgangskoden skal indeholde mindst ét stort bogstav",
"passwordLower": "Adgangskoden skal indeholde mindst ét lille bogstav", "passwordLower": "Adgangskoden skal indeholde mindst ét lille bogstav",
"passwordDigit": "Adgangskoden skal indeholde mindst ét tal", "passwordDigit": "Adgangskoden skal indeholde mindst ét tal",
@@ -955,10 +955,10 @@
"reset": { "reset": {
"title": "Indstil en ny adgangskode", "title": "Indstil en ny adgangskode",
"subtitle": "Vælg en stærk adgangskode, du ikke bruger andre steder.", "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", "resetPassword": "Nulstil adgangskode",
"backToLogin": "Tilbage til login", "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", "tooManyAttempts": "For mange forsøg — prøv igen senere",
"invalidLink": "Dette nulstillingslink er ugyldigt eller udløbet. Anmod om et nyt.", "invalidLink": "Dette nulstillingslink er ugyldigt eller udløbet. Anmod om et nyt.",
"failed": "Adgangskoden kunne ikke nulstilles. Prøv igen." "failed": "Adgangskoden kunne ikke nulstilles. Prøv igen."
+5 -5
View File
@@ -855,7 +855,7 @@
"confirmPassword": "Passwort wiederholen", "confirmPassword": "Passwort wiederholen",
"usernamePlaceholder": "Benutzername", "usernamePlaceholder": "Benutzername",
"emailPlaceholder": "E-Mail", "emailPlaceholder": "E-Mail",
"passwordPlaceholder": "Passwort (min. {min} Zeichen)", "passwordPlaceholder": "Passwort (min. 12 Zeichen)",
"confirmPasswordPlaceholder": "Passwort wiederholen", "confirmPasswordPlaceholder": "Passwort wiederholen",
"selectOutfit": "Wähle dein Outfit", "selectOutfit": "Wähle dein Outfit",
"termsAccept": "Ich bin 18+ und akzeptiere die {hotel} AGB & Regeln.", "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.", "termsError": "Du musst die AGB & Regeln akzeptieren, um dich zu registrieren.",
"usernameError": "Benutzername ist erforderlich.", "usernameError": "Benutzername ist erforderlich.",
"emailError": "Eine gültige E-Mail 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.", "confirmPasswordError": "Passwörter stimmen nicht überein.",
"termsRequired": "Du musst die AGB & Regeln akzeptieren.", "termsRequired": "Du musst die AGB & Regeln akzeptieren.",
"usernameRequired": "Benutzername ist erforderlich.", "usernameRequired": "Benutzername ist erforderlich.",
@@ -890,7 +890,7 @@
"strengthFair": "Mittel", "strengthFair": "Mittel",
"strengthGood": "Gut", "strengthGood": "Gut",
"strengthStrong": "Stark", "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", "passwordUpper": "Das Passwort muss mindestens einen Großbuchstaben enthalten",
"passwordLower": "Das Passwort muss mindestens einen Kleinbuchstaben enthalten", "passwordLower": "Das Passwort muss mindestens einen Kleinbuchstaben enthalten",
"passwordDigit": "Das Passwort muss mindestens eine Ziffer enthalten", "passwordDigit": "Das Passwort muss mindestens eine Ziffer enthalten",
@@ -925,10 +925,10 @@
"reset": { "reset": {
"title": "Neues Passwort festlegen", "title": "Neues Passwort festlegen",
"subtitle": "Wähle ein sicheres Passwort, das du sonst nirgendwo verwendest.", "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", "resetPassword": "Passwort zurücksetzen",
"backToLogin": "Zurück zur Anmeldung", "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", "tooManyAttempts": "Zu viele Versuche — bitte später erneut probieren",
"invalidLink": "Dieser Zurücksetzungslink ist ungültig oder abgelaufen. Fordere einen neuen an.", "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." "failed": "Das Passwort konnte nicht zurückgesetzt werden. Bitte versuche es erneut."
+5 -5
View File
@@ -885,7 +885,7 @@
"confirmPassword": "Επαναλάβετε τον κωδικό πρόσβασης", "confirmPassword": "Επαναλάβετε τον κωδικό πρόσβασης",
"usernamePlaceholder": "Όνομα χρήστη", "usernamePlaceholder": "Όνομα χρήστη",
"emailPlaceholder": "Email", "emailPlaceholder": "Email",
"passwordPlaceholder": "Κωδικός πρόσβασης (ελάχ. {min} χαρακτήρες)", "passwordPlaceholder": "Κωδικός πρόσβασης (ελάχ. 12 χαρακτήρες)",
"confirmPasswordPlaceholder": "Επαναλάβετε τον κωδικό πρόσβασης", "confirmPasswordPlaceholder": "Επαναλάβετε τον κωδικό πρόσβασης",
"selectOutfit": "Επιλέξτε το ντύσιμό σας", "selectOutfit": "Επιλέξτε το ντύσιμό σας",
"termsAccept": "Είμαι 18+ και αποδέχομαι τους όρους και τους κανόνες του {hotel}.", "termsAccept": "Είμαι 18+ και αποδέχομαι τους όρους και τους κανόνες του {hotel}.",
@@ -897,7 +897,7 @@
"termsError": "Πρέπει να αποδεχτείτε τους όρους και τους κανόνες για να εγγραφείτε.", "termsError": "Πρέπει να αποδεχτείτε τους όρους και τους κανόνες για να εγγραφείτε.",
"usernameError": "Απαιτείται όνομα χρήστη.", "usernameError": "Απαιτείται όνομα χρήστη.",
"emailError": "Απαιτείται έγκυρο email.", "emailError": "Απαιτείται έγκυρο email.",
"passwordError": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον {min} χαρακτήρες", "passwordError": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον 12 χαρακτήρες",
"confirmPasswordError": "Οι κωδικοί πρόσβασης δεν ταιριάζουν.", "confirmPasswordError": "Οι κωδικοί πρόσβασης δεν ταιριάζουν.",
"termsRequired": "Πρέπει να αποδεχτείτε τους όρους και τους κανόνες.", "termsRequired": "Πρέπει να αποδεχτείτε τους όρους και τους κανόνες.",
"usernameRequired": "Απαιτείται όνομα χρήστη.", "usernameRequired": "Απαιτείται όνομα χρήστη.",
@@ -920,7 +920,7 @@
"strengthFair": "Μέτριο", "strengthFair": "Μέτριο",
"strengthGood": "Καλό", "strengthGood": "Καλό",
"strengthStrong": "Δυνατό", "strengthStrong": "Δυνατό",
"passwordMinLength": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον {min} χαρακτήρες", "passwordMinLength": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον 12 χαρακτήρες",
"passwordUpper": "Ο κωδικός πρόσβασης πρέπει να περιέχει τουλάχιστον ένα κεφαλαίο γράμμα", "passwordUpper": "Ο κωδικός πρόσβασης πρέπει να περιέχει τουλάχιστον ένα κεφαλαίο γράμμα",
"passwordLower": "Ο κωδικός πρόσβασης πρέπει να περιέχει τουλάχιστον ένα πεζό γράμμα", "passwordLower": "Ο κωδικός πρόσβασης πρέπει να περιέχει τουλάχιστον ένα πεζό γράμμα",
"passwordDigit": "Ο κωδικός πρόσβασης πρέπει να περιέχει τουλάχιστον ένα ψηφίο", "passwordDigit": "Ο κωδικός πρόσβασης πρέπει να περιέχει τουλάχιστον ένα ψηφίο",
@@ -955,10 +955,10 @@
"reset": { "reset": {
"title": "Ορίστε νέο κωδικό πρόσβασης", "title": "Ορίστε νέο κωδικό πρόσβασης",
"subtitle": "Επιλέξτε έναν ισχυρό κωδικό πρόσβασης που δεν χρησιμοποιείτε αλλού.", "subtitle": "Επιλέξτε έναν ισχυρό κωδικό πρόσβασης που δεν χρησιμοποιείτε αλλού.",
"passwordPlaceholder": "Νέος κωδικός πρόσβασης (ελάχ. {min} χαρακτήρες)", "passwordPlaceholder": "Νέος κωδικός πρόσβασης (ελάχ. 12 χαρακτήρες)",
"resetPassword": "Επαναφορά κωδικού πρόσβασης", "resetPassword": "Επαναφορά κωδικού πρόσβασης",
"backToLogin": "Επιστροφή στην είσοδο", "backToLogin": "Επιστροφή στην είσοδο",
"passwordMinLength": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον {min} χαρακτήρες", "passwordMinLength": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον 12 χαρακτήρες",
"tooManyAttempts": "Πάρα πολλές προσπάθειες — δοκιμάστε αργότερα", "tooManyAttempts": "Πάρα πολλές προσπάθειες — δοκιμάστε αργότερα",
"invalidLink": "Αυτός ο σύνδεσμος επαναφοράς δεν είναι έγκυρος ή έχει λήξει. Ζητήστε νέο.", "invalidLink": "Αυτός ο σύνδεσμος επαναφοράς δεν είναι έγκυρος ή έχει λήξει. Ζητήστε νέο.",
"failed": "Δεν ήταν δυνατή η επαναφορά του κωδικού. Δοκιμάστε ξανά." "failed": "Δεν ήταν δυνατή η επαναφορά του κωδικού. Δοκιμάστε ξανά."
+5 -5
View File
@@ -1058,7 +1058,7 @@
"confirmPassword": "Repeat password", "confirmPassword": "Repeat password",
"usernamePlaceholder": "Username", "usernamePlaceholder": "Username",
"emailPlaceholder": "Email", "emailPlaceholder": "Email",
"passwordPlaceholder": "Password (min {min} chars)", "passwordPlaceholder": "Password (min 12 chars)",
"confirmPasswordPlaceholder": "Repeat password", "confirmPasswordPlaceholder": "Repeat password",
"selectOutfit": "Select your outfit", "selectOutfit": "Select your outfit",
"termsAccept": "I am 18+ and accept the {hotel} terms & rules.", "termsAccept": "I am 18+ and accept the {hotel} terms & rules.",
@@ -1081,7 +1081,7 @@
"termsError": "You must accept the terms & rules to register.", "termsError": "You must accept the terms & rules to register.",
"usernameError": "Username is required.", "usernameError": "Username is required.",
"emailError": "Valid email 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.", "confirmPasswordError": "Passwords do not match.",
"termsRequired": "You must accept the terms & rules.", "termsRequired": "You must accept the terms & rules.",
"usernameRequired": "Username is required.", "usernameRequired": "Username is required.",
@@ -1093,7 +1093,7 @@
"strengthFair": "Fair", "strengthFair": "Fair",
"strengthGood": "Good", "strengthGood": "Good",
"strengthStrong": "Strong", "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", "passwordUpper": "Password must contain at least one uppercase letter",
"passwordLower": "Password must contain at least one lowercase letter", "passwordLower": "Password must contain at least one lowercase letter",
"passwordDigit": "Password must contain at least one digit", "passwordDigit": "Password must contain at least one digit",
@@ -1128,10 +1128,10 @@
"reset": { "reset": {
"title": "Set a new password", "title": "Set a new password",
"subtitle": "Choose a strong password you don't use elsewhere.", "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", "resetPassword": "Reset password",
"backToLogin": "Back to login", "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", "tooManyAttempts": "Too many attempts — try again later",
"invalidLink": "This reset link is invalid or has expired. Request a new one.", "invalidLink": "This reset link is invalid or has expired. Request a new one.",
"failed": "The password could not be reset. Please try again." "failed": "The password could not be reset. Please try again."
+5 -5
View File
@@ -855,7 +855,7 @@
"confirmPassword": "Repetir contraseña", "confirmPassword": "Repetir contraseña",
"usernamePlaceholder": "Usuario", "usernamePlaceholder": "Usuario",
"emailPlaceholder": "Correo electrónico", "emailPlaceholder": "Correo electrónico",
"passwordPlaceholder": "Contraseña (mín. {min} caracteres)", "passwordPlaceholder": "Contraseña (mín. 12 caracteres)",
"confirmPasswordPlaceholder": "Repetir contraseña", "confirmPasswordPlaceholder": "Repetir contraseña",
"selectOutfit": "Selecciona tu atuendo", "selectOutfit": "Selecciona tu atuendo",
"termsAccept": "Soy mayor de 18 años y acepto los términos y reglas de {hotel}.", "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.", "termsError": "Debes aceptar los términos y reglas para registrarte.",
"usernameError": "El usuario es obligatorio.", "usernameError": "El usuario es obligatorio.",
"emailError": "Se requiere un correo electrónico válido.", "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.", "confirmPasswordError": "Las contraseñas no coinciden.",
"termsRequired": "Debes aceptar los términos y reglas.", "termsRequired": "Debes aceptar los términos y reglas.",
"usernameRequired": "El usuario es obligatorio.", "usernameRequired": "El usuario es obligatorio.",
@@ -890,7 +890,7 @@
"strengthFair": "Aceptable", "strengthFair": "Aceptable",
"strengthGood": "Buena", "strengthGood": "Buena",
"strengthStrong": "Fuerte", "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", "passwordUpper": "La contraseña debe contener al menos una mayúscula",
"passwordLower": "La contraseña debe contener al menos una minúscula", "passwordLower": "La contraseña debe contener al menos una minúscula",
"passwordDigit": "La contraseña debe contener al menos un dígito", "passwordDigit": "La contraseña debe contener al menos un dígito",
@@ -925,10 +925,10 @@
"reset": { "reset": {
"title": "Establecer una nueva contraseña", "title": "Establecer una nueva contraseña",
"subtitle": "Elige una contraseña segura que no uses en otros sitios.", "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", "resetPassword": "Restablecer contraseña",
"backToLogin": "Volver al inicio de sesión", "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", "tooManyAttempts": "Demasiados intentos — inténtalo más tarde",
"invalidLink": "Este enlace de restablecimiento no es válido o ha caducado. Solicita uno nuevo.", "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." "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