Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c428c4526 | ||
|
|
e7a6b2b602 | ||
|
|
4b25292ecc | ||
|
|
b40293bd06 | ||
|
|
9cf1eb615c | ||
|
|
aef0939ee9 | ||
|
|
c6e5e0b1a9 | ||
|
|
2249303dbd | ||
|
|
07c5904879 | ||
|
|
4776a41c01 | ||
|
|
a32cf6119b | ||
|
|
bb3799f705 | ||
|
|
d28dde459a |
No files matched your search
+11
-2
@@ -3,8 +3,13 @@
|
|||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
|
|
||||||
# --- DATABASE (High Performance Pooling & Strict Timeouts) ---
|
# --- DATABASE (High Performance Pooling & Strict Timeouts) ---
|
||||||
DATABASE_URL="mysql://user:password@localhost:3306/dbname?charset=utf8mb4&connection_limit=150&connect_timeout=5"
|
# The pool size is governed by DATABASE_POOL_SIZE (src/lib/db.ts); the
|
||||||
DATABASE_POOL_SIZE=150
|
# `connection_limit` in the URL is only honoured by tools that read the URL
|
||||||
|
# directly (drizzle-kit, scripts). Keep both in step and keep them well below
|
||||||
|
# MariaDB's `max_connections` (151 by default) or the emulator and any second
|
||||||
|
# CMS instance get "Too many connections". README §performance: pool size 25.
|
||||||
|
DATABASE_URL="mysql://user:password@localhost:3306/dbname?charset=utf8mb4&connection_limit=25&connect_timeout=5"
|
||||||
|
DATABASE_POOL_SIZE=25
|
||||||
DATABASE_IDLE_TIMEOUT_MS=60000
|
DATABASE_IDLE_TIMEOUT_MS=60000
|
||||||
DATABASE_CONNECT_TIMEOUT_MS=5000
|
DATABASE_CONNECT_TIMEOUT_MS=5000
|
||||||
|
|
||||||
@@ -59,6 +64,10 @@ 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).
|
||||||
|
|||||||
+9
-1
@@ -158,7 +158,15 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
experimental: {
|
experimental: {
|
||||||
optimizePackageImports: ["lucide-react", "date-fns"],
|
// Barrel-heavy packages: without this every import pulls the whole
|
||||||
|
// module graph into the chunk that first touches it.
|
||||||
|
optimizePackageImports: [
|
||||||
|
"lucide-react",
|
||||||
|
"motion",
|
||||||
|
"@dnd-kit/core",
|
||||||
|
"@dnd-kit/sortable",
|
||||||
|
"@tanstack/react-virtual",
|
||||||
|
],
|
||||||
useTypeScriptCli: true,
|
useTypeScriptCli: true,
|
||||||
hideLogsAfterAbort: true,
|
hideLogsAfterAbort: true,
|
||||||
},
|
},
|
||||||
|
|||||||
+14
-14
@@ -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 --coverage.enabled=false",
|
"test": "bash scripts/with-memory-cap.sh 8g vitest run",
|
||||||
"test:coverage": "bash scripts/with-memory-cap.sh 8g vitest run",
|
"test:coverage": "bash scripts/with-memory-cap.sh 8g vitest run --coverage.enabled=true",
|
||||||
"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 --coverage.enabled=false 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 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",
|
||||||
@@ -56,9 +56,9 @@
|
|||||||
"@dnd-kit/sortable": "10.0.0",
|
"@dnd-kit/sortable": "10.0.0",
|
||||||
"@dnd-kit/utilities": "3.2.2",
|
"@dnd-kit/utilities": "3.2.2",
|
||||||
"@formatjs/icu-messageformat-parser": "3.5.21",
|
"@formatjs/icu-messageformat-parser": "3.5.21",
|
||||||
"@hookform/resolvers": "5.9.2",
|
"@hookform/resolvers": "5.9.1",
|
||||||
"@tanstack/react-query": "5.104.4",
|
"@tanstack/react-query": "5.104.3",
|
||||||
"@tanstack/react-virtual": "3.14.21",
|
"@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",
|
||||||
@@ -70,12 +70,12 @@
|
|||||||
"jpeg-js": "0.4.4",
|
"jpeg-js": "0.4.4",
|
||||||
"jsonc-parser": "3.3.1",
|
"jsonc-parser": "3.3.1",
|
||||||
"jszip": "3.10.2",
|
"jszip": "3.10.2",
|
||||||
"lucide-react": "1.59.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.2",
|
"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",
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
"react": "19.3.1",
|
"react": "19.3.1",
|
||||||
"react-dom": "19.3.1",
|
"react-dom": "19.3.1",
|
||||||
"react-hook-form": "7.90.0",
|
"react-hook-form": "7.90.0",
|
||||||
"resend": "6.34.1",
|
"resend": "6.33.0",
|
||||||
"server-only": "0.0.1",
|
"server-only": "0.0.1",
|
||||||
"sharp": "^0.35.5",
|
"sharp": "^0.35.5",
|
||||||
"sonner": "2.0.8",
|
"sonner": "2.0.8",
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Generated
+136
-151
@@ -189,14 +189,14 @@ importers:
|
|||||||
specifier: 3.5.21
|
specifier: 3.5.21
|
||||||
version: 3.5.21
|
version: 3.5.21
|
||||||
'@hookform/resolvers':
|
'@hookform/resolvers':
|
||||||
specifier: 5.9.2
|
specifier: 5.9.1
|
||||||
version: 5.9.2([email protected](@types/[email protected])([email protected]))([email protected])
|
version: 5.9.1([email protected](@types/[email protected])([email protected]))([email protected])
|
||||||
'@tanstack/react-query':
|
'@tanstack/react-query':
|
||||||
specifier: 5.104.4
|
specifier: 5.104.3
|
||||||
version: 5.104.4(@types/[email protected])([email protected])
|
version: 5.104.3([email protected])
|
||||||
'@tanstack/react-virtual':
|
'@tanstack/react-virtual':
|
||||||
specifier: 3.14.21
|
specifier: 3.14.19
|
||||||
version: 3.14.21([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
|
||||||
@@ -231,29 +231,29 @@ importers:
|
|||||||
specifier: 3.10.2
|
specifier: 3.10.2
|
||||||
version: 3.10.2
|
version: 3.10.2
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: 1.59.0
|
specifier: 1.57.0
|
||||||
version: 1.59.0(@types/[email protected])([email protected])
|
version: 1.57.0(@types/[email protected])([email protected])
|
||||||
lzma-wasm:
|
lzma-wasm:
|
||||||
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.2
|
specifier: 12.2.1
|
||||||
version: 12.2.2
|
version: 12.2.1
|
||||||
mysql2:
|
mysql2:
|
||||||
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
|
||||||
@@ -270,8 +270,8 @@ importers:
|
|||||||
specifier: 7.90.0
|
specifier: 7.90.0
|
||||||
version: 7.90.0(@types/[email protected])([email protected])
|
version: 7.90.0(@types/[email protected])([email protected])
|
||||||
resend:
|
resend:
|
||||||
specifier: 6.34.1
|
specifier: 6.33.0
|
||||||
version: 6.34.1
|
version: 6.33.0
|
||||||
server-only:
|
server-only:
|
||||||
specifier: 0.0.1
|
specifier: 0.0.1
|
||||||
version: 0.0.1
|
version: 0.0.1
|
||||||
@@ -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:
|
||||||
|
|
||||||
@@ -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'
|
||||||
|
|
||||||
@@ -968,8 +967,8 @@ packages:
|
|||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
'@hookform/[email protected].2':
|
'@hookform/[email protected].1':
|
||||||
resolution: {integrity: sha512-RtjTnZqsC38s4NKp2XO3fD8IA5uyN4tB7McQ5C6uiMPcV+WWO8F+wVa3e+BXUpH2ScgxghAlwFRMVqjT9WChAQ==}
|
resolution: {integrity: sha512-7b7vsbraJxKgjVSA1Nur9tLwj539WGJUBLA7QNvXnFoT2pM5Z7G+6rlukk4B2/QrTZy6huRtH6wKeESPKuIr6w==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@sinclair/typebox': '>=0.25.24'
|
'@sinclair/typebox': '>=0.25.24'
|
||||||
'@standard-schema/spec': ^1.0.0
|
'@standard-schema/spec': ^1.0.0
|
||||||
@@ -979,7 +978,7 @@ packages:
|
|||||||
ajv-errors: ^3.0.0
|
ajv-errors: ^3.0.0
|
||||||
ajv-formats: ^2.1.1
|
ajv-formats: ^2.1.1
|
||||||
arktype: ^2.0.0
|
arktype: ^2.0.0
|
||||||
ata-validator: ^1.23.0
|
ata-validator: ^1.2.0
|
||||||
class-transformer: '>=0.4.0'
|
class-transformer: '>=0.4.0'
|
||||||
class-validator: '>=0.12.0'
|
class-validator: '>=0.12.0'
|
||||||
computed-types: ^1.0.0
|
computed-types: ^1.0.0
|
||||||
@@ -1279,57 +1278,57 @@ packages:
|
|||||||
resolution: {integrity: sha512-lZBylf8kTt096RGnnVfHq+5w4DTRgmFjf59/IVMPBBwKrViumV1l95LZ/LrWt1LZlsJymHOSnoP8mX/kPXZPWw==}
|
resolution: {integrity: sha512-lZBylf8kTt096RGnnVfHq+5w4DTRgmFjf59/IVMPBBwKrViumV1l95LZ/LrWt1LZlsJymHOSnoP8mX/kPXZPWw==}
|
||||||
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]
|
||||||
@@ -1953,26 +1952,22 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
tailwindcss: '>=3.0.0 || >=4.0.0 || insiders'
|
tailwindcss: '>=3.0.0 || >=4.0.0 || insiders'
|
||||||
|
|
||||||
'@tanstack/[email protected].4':
|
'@tanstack/[email protected].3':
|
||||||
resolution: {integrity: sha512-nU4Yrb8Sjn0DeVb7PKG9uiepNavys3yoxy1FX0hViY4/P6Ng/mBje2SMl7i9812rtyHKtHyqiaC/td6GkI8StA==}
|
resolution: {integrity: sha512-DZY9TuJAOgU3UyHUhXb4CcTYEHNZszlwkPg4VViIwTxOKzLprPUU+rbaPZQd3srqkGOtpaDdKpW972UV/LKlcg==}
|
||||||
|
|
||||||
'@tanstack/[email protected].4':
|
'@tanstack/[email protected].3':
|
||||||
resolution: {integrity: sha512-YD5QkvUNy8Ey3LbOy2vwL97JjoYycwDv++cWAibTe+9BLYvl3c09XpYp5dZ2VcxcguwoDhGF+utYJYb79RB36w==}
|
resolution: {integrity: sha512-5Ts1ImwJKqoJgPkLxl9WrPQy1JEoEmti3MaaODCfBamHUGFk57IFrc+AIksPq+BAm10EsroX56qm398Z42LG7w==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@types/react': ^18 || ^19
|
|
||||||
react: ^18 || ^19
|
react: ^18 || ^19
|
||||||
peerDependenciesMeta:
|
|
||||||
'@types/react':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@tanstack/[email protected].21':
|
'@tanstack/[email protected]9':
|
||||||
resolution: {integrity: sha512-wWE/ZJDNNDp18JHWy7n5PQooqerHePVkEEu6Op0twKQ2InF9DPhYMQptQY0syDEviMd3/r8D0OUvKJibDGm9HQ==}
|
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].6':
|
'@tanstack/[email protected].4':
|
||||||
resolution: {integrity: sha512-CsTW892z23gy7IyxYTTorVu1uZWB+0N349rSMgKz4rFLPpLza7F7TpcCKeI5iUAwYQCt8QUlOU67J+KXCxtpGw==}
|
resolution: {integrity: sha512-ygZ3O99Il18dl0gG0q7sH78iSkb96F07G+xK9V83eCgV8NQbcNd7R+cgcJJMFO7llBoebd0A82sCCstQq2Vp6Q==}
|
||||||
|
|
||||||
'@tokenizer/[email protected]':
|
'@tokenizer/[email protected]':
|
||||||
resolution: {integrity: sha512-56U2O6fDLFtqokwWh597IdeUgtM7j+TT9tb1soybhgA6cy163gAcVmoQr8H4Ud/CkgCkViwcuLyqtn6wYqw59Q==}
|
resolution: {integrity: sha512-56U2O6fDLFtqokwWh597IdeUgtM7j+TT9tb1soybhgA6cy163gAcVmoQr8H4Ud/CkgCkViwcuLyqtn6wYqw59Q==}
|
||||||
@@ -2151,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
|
||||||
@@ -2168,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
|
||||||
@@ -2179,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==}
|
||||||
@@ -2238,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]:
|
||||||
@@ -2674,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
|
||||||
@@ -3000,8 +2995,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==}
|
resolution: {integrity: sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==}
|
||||||
engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'}
|
engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'}
|
||||||
|
|
||||||
[email protected]9.0:
|
[email protected]7.0:
|
||||||
resolution: {integrity: sha512-5qgMyyCPU6OvnKpLS2t693+ZtiqA8oaSweWoUows0BLFKFf6V75m4+CptpmohZHpD5IuOVIBXHBBiv2vpLJmtw==}
|
resolution: {integrity: sha512-bXFZEucYhAzLD62Jse76AbTR6LiSLTn3Tqir2f1n8rZrWAdLodYy7jnRZSPIT89dRLk/IseMOUWG82wJtTyTTw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@types/react': '*'
|
'@types/react': '*'
|
||||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
@@ -3056,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
|
||||||
@@ -3092,8 +3087,8 @@ packages:
|
|||||||
vite:
|
vite:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
[email protected].2:
|
[email protected].1:
|
||||||
resolution: {integrity: sha512-m0fMlp+9snT8Gte3t99fMXpAeS0oRWVLzbj5OZd8zrPuP4RTQ/+VTaxgiz1igrJ/8NwhzdRNk/38aDVK/5N0Ow==}
|
resolution: {integrity: sha512-JB/bnmQAa8HiKHuIg9dupGFjCKpdsocm9nJrTQe31f5zTaKfJmTwJwOvH1yVL91/jwFNKTOAOIPrHo3L5XwGfQ==}
|
||||||
engines: {node: '>=22'}
|
engines: {node: '>=22'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
@@ -3151,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:
|
||||||
@@ -3385,8 +3380,8 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==}
|
resolution: {integrity: sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==}
|
||||||
|
|
||||||
[email protected]4.1:
|
[email protected]3.0:
|
||||||
resolution: {integrity: sha512-3jo/j1WAW7CBkE6pzK4qLjZCfHRLIhY3ph3x/wzU+MN51cSw3EtReGce8S1cA1R0ipNDOdFYU5Lmt5r563lvYw==}
|
resolution: {integrity: sha512-sbpWLrwlaIDZuGHRYNX6101CWtXLqRNcT1S9c8BB357Ab2+IUJ7dNpYffVmGh2C11/4AfcFvM4ltURH8NJlPGw==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@react-email/render': '*'
|
'@react-email/render': '*'
|
||||||
@@ -3551,10 +3546,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-PgAsgIuizbZN/r4+g4UNvbVCTpJWdoIdI71kIWwKmdIH0vJ0q7ylMVGU8PWj2s9O98y4MtFWiDTIrtNukZMMgQ==}
|
resolution: {integrity: sha512-PgAsgIuizbZN/r4+g4UNvbVCTpJWdoIdI71kIWwKmdIH0vJ0q7ylMVGU8PWj2s9O98y4MtFWiDTIrtNukZMMgQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
[email protected]:
|
|
||||||
resolution: {integrity: sha512-p8ynRS8lXnvA3tsdcUYFjbzwh2MsdBZgxrFT2G+Yb7vNvUfPF+GgHCt/0jkayfsRXA1TwtZstf+JA0V+iQXdHA==}
|
|
||||||
engines: {node: '>=18'}
|
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
|
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
@@ -3768,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
|
||||||
@@ -3915,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]': {}
|
||||||
@@ -4297,7 +4288,7 @@ snapshots:
|
|||||||
protobufjs: 7.6.6
|
protobufjs: 7.6.6
|
||||||
yargs: 17.7.3
|
yargs: 17.7.3
|
||||||
|
|
||||||
'@hookform/[email protected].2([email protected](@types/[email protected])([email protected]))([email protected])':
|
'@hookform/[email protected].1([email protected](@types/[email protected])([email protected]))([email protected])':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@standard-schema/utils': 0.3.0
|
'@standard-schema/utils': 0.3.0
|
||||||
react-hook-form: 7.90.0(@types/[email protected])([email protected])
|
react-hook-form: 7.90.0(@types/[email protected])([email protected])
|
||||||
@@ -4481,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]': {}
|
||||||
@@ -4961,22 +4952,20 @@ snapshots:
|
|||||||
postcss-selector-parser: 7.1.6
|
postcss-selector-parser: 7.1.6
|
||||||
tailwindcss: 4.3.3
|
tailwindcss: 4.3.3
|
||||||
|
|
||||||
'@tanstack/[email protected].4': {}
|
'@tanstack/[email protected].3': {}
|
||||||
|
|
||||||
'@tanstack/[email protected].4(@types/[email protected])([email protected])':
|
'@tanstack/[email protected].3([email protected])':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tanstack/query-core': 5.104.4
|
'@tanstack/query-core': 5.104.3
|
||||||
react: 19.3.1
|
react: 19.3.1
|
||||||
optionalDependencies:
|
|
||||||
'@types/react': 19.3.0
|
|
||||||
|
|
||||||
'@tanstack/[email protected].21([email protected]([email protected]))([email protected])':
|
'@tanstack/[email protected]9([email protected]([email protected]))([email protected])':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tanstack/virtual-core': 3.19.6
|
'@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].6': {}
|
'@tanstack/[email protected].4': {}
|
||||||
|
|
||||||
'@tokenizer/[email protected]':
|
'@tokenizer/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -5109,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
|
||||||
@@ -5119,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]': {}
|
||||||
|
|
||||||
@@ -5127,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:
|
||||||
@@ -5201,7 +5190,7 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]4.0: {}
|
[email protected]3.0: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
@@ -5566,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:
|
||||||
@@ -5840,7 +5829,7 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]9.0(@types/[email protected])([email protected]):
|
[email protected]7.0(@types/[email protected])([email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.3.1
|
react: 19.3.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
@@ -5884,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
|
||||||
@@ -5921,7 +5910,7 @@ snapshots:
|
|||||||
- '@types/node'
|
- '@types/node'
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
[email protected].2:
|
[email protected].1:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@borewit/text-codec': 0.2.2
|
'@borewit/text-codec': 0.2.2
|
||||||
'@tokenizer/token': 0.3.0
|
'@tokenizer/token': 0.3.0
|
||||||
@@ -5929,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.6.1
|
strtok3: 10.5.0
|
||||||
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
|
||||||
@@ -5962,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
|
||||||
@@ -5980,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])
|
||||||
@@ -5989,9 +5978,9 @@ 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.28
|
||||||
caniuse-lite: 1.0.30001816
|
caniuse-lite: 1.0.30001816
|
||||||
@@ -6000,14 +5989,14 @@ snapshots:
|
|||||||
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:
|
||||||
@@ -6248,7 +6237,7 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]4.1:
|
[email protected]3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
postal-mime: 2.7.6
|
postal-mime: 2.7.6
|
||||||
standardwebhooks: 1.1.1
|
standardwebhooks: 1.1.1
|
||||||
@@ -6438,10 +6427,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@tokenizer/token': 0.3.0
|
'@tokenizer/token': 0.3.0
|
||||||
|
|
||||||
[email protected]:
|
|
||||||
dependencies:
|
|
||||||
'@tokenizer/token': 0.3.0
|
|
||||||
|
|
||||||
[email protected]([email protected]):
|
[email protected]([email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
client-only: 0.0.1
|
client-only: 0.0.1
|
||||||
@@ -6661,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
|
||||||
@@ -6679,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
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"mode": "enforce",
|
"mode": "enforce",
|
||||||
"routes": [
|
"routes": [
|
||||||
|
{
|
||||||
|
"route": "/",
|
||||||
|
"maxInitialRawBytes": 716800,
|
||||||
|
"maxInitialGzipBytes": 225280
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"route": "/me",
|
"route": "/me",
|
||||||
"maxInitialRawBytes": 716800,
|
"maxInitialRawBytes": 716800,
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ 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");
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ async function upsertSetting(key: string, value: string): Promise<void> {
|
|||||||
.values({
|
.values({
|
||||||
key,
|
key,
|
||||||
value,
|
value,
|
||||||
// eslint-disable-next-line security/detect-object-injection -- key is one of 3 known const values
|
// key is one of 3 known const values
|
||||||
comment: COMMENTS[key] ?? null,
|
comment: COMMENTS[key] ?? null,
|
||||||
})
|
})
|
||||||
.onDuplicateKeyUpdate({ set: { value } });
|
.onDuplicateKeyUpdate({ set: { value } });
|
||||||
|
|||||||
@@ -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)
|
||||||
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
|
// key from POINTS_KEYS const
|
||||||
.values({ key, value: values[key], comment: "Radio points" })
|
.values({ key, value: values[key], comment: "Radio points" })
|
||||||
.onDuplicateKeyUpdate({
|
.onDuplicateKeyUpdate({
|
||||||
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
|
// key from POINTS_KEYS const
|
||||||
set: { value: values[key] },
|
set: { value: values[key] },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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");
|
||||||
// eslint-disable-next-line security/detect-object-injection -- guarded by null check below
|
// guarded by null check below
|
||||||
const preset = PRESETS[name];
|
const preset = PRESETS[name];
|
||||||
if (!preset) redirect("/admin/theme");
|
if (!preset) redirect("/admin/theme");
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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";
|
||||||
@@ -109,7 +110,11 @@ export async function resetPassword(formData: FormData): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let error: "password" | "invalid" | "failed" | null = null;
|
let error: "password" | "invalid" | "failed" | null = null;
|
||||||
if (password.length < 12) error = "password";
|
if (
|
||||||
|
password.length < passwordPolicy.minLength ||
|
||||||
|
password.length > passwordPolicy.maxLength
|
||||||
|
)
|
||||||
|
error = "password";
|
||||||
|
|
||||||
if (!error) {
|
if (!error) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+10
-4
@@ -5,6 +5,7 @@ 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";
|
||||||
@@ -83,6 +84,11 @@ 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
|
||||||
@@ -105,8 +111,8 @@ const registerSchema = z
|
|||||||
),
|
),
|
||||||
password: z
|
password: z
|
||||||
.string()
|
.string()
|
||||||
.min(12, "Password must be at least 12 characters") // Increased min length
|
.min(passwordPolicy.minLength, PASSWORD_MIN_MESSAGE)
|
||||||
.max(128, "Password is too long") // Added max length
|
.max(passwordPolicy.maxLength, PASSWORD_MAX_MESSAGE)
|
||||||
.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")
|
||||||
@@ -160,8 +166,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 must be at least 12 characters": "passwordMinLength",
|
[PASSWORD_MIN_MESSAGE]: "passwordMinLength",
|
||||||
"Password is too long": "passwordMaxLength",
|
[PASSWORD_MAX_MESSAGE]: "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",
|
||||||
|
|||||||
@@ -31,9 +31,7 @@ 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}`;
|
||||||
@@ -46,7 +44,6 @@ 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 */
|
||||||
@@ -85,7 +82,6 @@ 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 */
|
||||||
|
|||||||
@@ -29,9 +29,7 @@ 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}`;
|
||||||
|
|||||||
@@ -278,14 +278,12 @@ 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(
|
||||||
@@ -319,7 +317,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();
|
||||||
// eslint-disable-next-line security/detect-object-injection -- guarded by null check below
|
// guarded by null check below
|
||||||
const preset = PRESETS[name];
|
const preset = PRESETS[name];
|
||||||
if (!preset) redirect(MANAGER_PATH);
|
if (!preset) redirect(MANAGER_PATH);
|
||||||
|
|
||||||
|
|||||||
@@ -142,7 +142,6 @@ 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}
|
||||||
|
|||||||
@@ -120,7 +120,6 @@ 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}
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ 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}
|
||||||
|
|||||||
@@ -190,7 +190,6 @@ 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=""
|
||||||
|
|||||||
@@ -149,7 +149,6 @@ 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`}
|
||||||
|
|||||||
@@ -74,7 +74,6 @@ 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`}
|
||||||
|
|||||||
@@ -110,7 +110,6 @@ 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=""
|
||||||
|
|||||||
@@ -159,7 +159,6 @@ 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=""
|
||||||
|
|||||||
+27
-11
@@ -1,4 +1,5 @@
|
|||||||
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";
|
||||||
@@ -34,28 +35,43 @@ export default async function SiteLayout({
|
|||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [session, locale, messages] = await Promise.all([
|
const [session, locale, messages, h] = 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. */}
|
reach any other page at all. Only the standalone landing pages opt
|
||||||
<div data-theme-block="top_header">
|
out, because they carry their own header. */}
|
||||||
<TopHeader session={session} />
|
{!isLandingPage && (
|
||||||
</div>
|
<div data-theme-block="top_header">
|
||||||
<div data-theme-block="site_header">
|
<TopHeader session={session} />
|
||||||
<SiteHeader />
|
</div>
|
||||||
</div>
|
)}
|
||||||
<div data-theme-block="navigation">
|
{!isLandingPage && (
|
||||||
<Navigation session={session} />
|
<div data-theme-block="site_header">
|
||||||
</div>
|
<SiteHeader />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isLandingPage && (
|
||||||
|
<div data-theme-block="navigation">
|
||||||
|
<Navigation session={session} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<main>
|
<main>
|
||||||
<div
|
<div
|
||||||
data-theme-block="content_grid"
|
data-theme-block="content_grid"
|
||||||
|
|||||||
@@ -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"
|
||||||
? // eslint-disable-next-line security/detect-object-injection -- active validated as "diamonds"|"duckets" in this branch
|
? // 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],
|
||||||
|
|||||||
+26
-164
@@ -1,24 +1,14 @@
|
|||||||
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 {
|
||||||
AuthPageFrame,
|
AuthLandingView,
|
||||||
AuthUsersCards,
|
loadAuthLanding,
|
||||||
} from "@/components/auth/auth-page-frame";
|
} from "@/components/auth/auth-landing";
|
||||||
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");
|
||||||
@@ -43,13 +33,6 @@ 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);
|
||||||
|
|
||||||
@@ -58,165 +41,44 @@ 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 [online, recentUsers, latestUsers] = await Promise.all([
|
const data = await loadAuthLanding();
|
||||||
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.
|
// exists. `?reset=1` comes from a successful password reset; saying so
|
||||||
|
// matters because a silent form reads like the reset failed.
|
||||||
const notice =
|
const notice =
|
||||||
sp.registered === "1" ? (
|
sp.registered === "1" ? (
|
||||||
<p
|
<AuthAlert variant="success" className="mb-4">
|
||||||
role="status"
|
|
||||||
className="auth-alert auth-alert--success animate-fade-in-up m-0 mb-4"
|
|
||||||
>
|
|
||||||
{t("registeredSuccess")}
|
{t("registeredSuccess")}
|
||||||
</p>
|
</AuthAlert>
|
||||||
) : sp.reset === "1" ? (
|
) : sp.reset === "1" ? (
|
||||||
// `?reset=1` comes from a successful password reset; saying so matters
|
<AuthAlert variant="success" className="mb-4">
|
||||||
// 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")}
|
||||||
</p>
|
</AuthAlert>
|
||||||
) : undefined;
|
) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthPageFrame
|
<AuthLandingView
|
||||||
hotelName={hotelName}
|
data={data}
|
||||||
logo={logo}
|
copy={{
|
||||||
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={{
|
captcha={data.captcha}
|
||||||
provider: cfg.provider,
|
nonce={data.nonce}
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -191,7 +191,6 @@ 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=""
|
||||||
|
|||||||
+10
-163
@@ -7,22 +7,20 @@ 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";
|
||||||
@@ -40,7 +38,6 @@ 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";
|
||||||
@@ -238,7 +235,7 @@ async function getHotelData() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function Home() {
|
async function Home() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (session?.user?.id) redirect("/me");
|
if (session?.user?.id) redirect("/me");
|
||||||
|
|
||||||
@@ -404,88 +401,10 @@ export default 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 ── */}
|
{/* ── Top bar: logo + live online + language/clock/theme ── */}
|
||||||
<Reveal className="mx-auto w-full max-w-7xl">
|
<div className="mx-auto w-full max-w-7xl">
|
||||||
<div
|
<AuthTopBar hotelName={hotelName} logo={logo} online={online} />
|
||||||
className="flex items-center justify-between rounded-full border px-3 py-2.5 sm:px-5"
|
</div>
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
"linear-gradient(90deg, color-mix(in srgb, var(--color-surface) 62%, transparent), color-mix(in srgb, var(--color-primary) 7%, transparent))",
|
|
||||||
backdropFilter: "blur(14px)",
|
|
||||||
WebkitBackdropFilter: "blur(14px)",
|
|
||||||
borderColor:
|
|
||||||
"color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
|
|
||||||
boxShadow:
|
|
||||||
"0 1px 3px rgba(0,0,0,0.04), 0 0 0 0.5px color-mix(in srgb, var(--color-text-muted) 6%, transparent)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 sm:gap-3">
|
|
||||||
{logo ? (
|
|
||||||
<Image
|
|
||||||
src={logo}
|
|
||||||
alt={hotelName}
|
|
||||||
width={100}
|
|
||||||
height={28}
|
|
||||||
className="shrink-0 object-contain"
|
|
||||||
unoptimized
|
|
||||||
style={{ maxHeight: 26, width: "auto", height: "auto" }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
"linear-gradient(135deg, var(--color-primary), color-mix(in srgb, var(--color-accent) 60%, var(--color-primary)))",
|
|
||||||
boxShadow:
|
|
||||||
"0 4px 16px -4px color-mix(in srgb, var(--color-primary) 60%, transparent)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src={ICON_LIGHTHOUSE}
|
|
||||||
alt=""
|
|
||||||
width={18}
|
|
||||||
height={18}
|
|
||||||
className="shrink-0"
|
|
||||||
unoptimized
|
|
||||||
style={{ filter: "brightness(0) invert(1)" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className="hidden text-sm font-extrabold uppercase tracking-wide sm:inline"
|
|
||||||
style={{ color: "var(--color-text-readable)" }}
|
|
||||||
>
|
|
||||||
{hotelName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-1 sm:gap-1.5 rounded-full px-1.5 py-1"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
"color-mix(in srgb, var(--color-text-muted) 5%, transparent)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LanguageSwitcher />
|
|
||||||
<div
|
|
||||||
className="hidden w-px h-5 sm:block"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Clock />
|
|
||||||
<div
|
|
||||||
className="w-px h-5"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ThemeSwitcher />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Reveal>
|
|
||||||
|
|
||||||
{/* ── Cinematic hero ── */}
|
{/* ── 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">
|
||||||
@@ -534,50 +453,7 @@ export default 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]">
|
||||||
<span
|
<h1 style={{ fontFamily: "var(--font-nunito)" }}>
|
||||||
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)" }}
|
||||||
@@ -596,37 +472,6 @@ export default 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" }}
|
||||||
@@ -1148,3 +993,5 @@ export default async function Home() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default withPublicPagePerformance("/", Home);
|
||||||
@@ -108,7 +108,6 @@ 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`}
|
||||||
@@ -150,7 +149,6 @@ 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=""
|
||||||
|
|||||||
@@ -88,7 +88,6 @@ 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`}
|
||||||
|
|||||||
@@ -1,23 +1,13 @@
|
|||||||
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 {
|
||||||
AuthPageFrame,
|
AuthLandingView,
|
||||||
AuthUsersCards,
|
loadAuthLanding,
|
||||||
} from "@/components/auth/auth-page-frame";
|
} from "@/components/auth/auth-landing";
|
||||||
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 { cached } from "@/lib/cache";
|
import { passwordPolicy } from "@/lib/auth/password-policy";
|
||||||
|
|
||||||
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");
|
||||||
@@ -31,158 +21,40 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function RegisterPage() {
|
export default async function RegisterPage() {
|
||||||
const tpr = await getTranslations("pages.register");
|
const t = 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 [online, recentUsers, latestUsers] = await Promise.all([
|
const data = await loadAuthLanding();
|
||||||
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 (
|
||||||
<AuthPageFrame
|
<AuthLandingView
|
||||||
hotelName={hotelName}
|
data={data}
|
||||||
logo={logo}
|
copy={{
|
||||||
title={tpr("title")}
|
title: t("title"),
|
||||||
subtitle={tpr("subtitle")}
|
subtitle: t("subtitle"),
|
||||||
|
promoTitle: (
|
||||||
|
<>
|
||||||
|
{t("title")} —{" "}
|
||||||
|
<span className="gradient-text">{data.hotelName}</span>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
promoSubtitle: t("subtitle"),
|
||||||
|
usersOnlineLabel: t("usersOnline", { count: data.online }),
|
||||||
|
whoIsOnlineLabel: t("whoIsOnline"),
|
||||||
|
newestCitizensLabel: t("newestCitizens"),
|
||||||
|
}}
|
||||||
form={
|
form={
|
||||||
<RegisterForm
|
<RegisterForm
|
||||||
hotelName={hotelName}
|
hotelName={data.hotelName}
|
||||||
captcha={{
|
captcha={data.captcha}
|
||||||
provider: cfg.provider,
|
nonce={data.nonce}
|
||||||
siteKey: cfg.siteKey || undefined,
|
passwordMinLength={passwordPolicy.minLength}
|
||||||
}}
|
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ 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");
|
||||||
@@ -41,8 +42,12 @@ 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">
|
||||||
@@ -58,7 +63,7 @@ export default async function ResetPage({
|
|||||||
marginBottom: 0,
|
marginBottom: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t(errorKey)}
|
{t(errorKey, { min: passwordPolicy.minLength })}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<p
|
<p
|
||||||
|
|||||||
@@ -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 } {
|
||||||
// eslint-disable-next-line security/detect-object-injection -- STATE_LABELS has known keys, fallback provided
|
// STATE_LABELS has known keys, fallback provided
|
||||||
return STATE_LABELS[state] ?? { label: state || "Unknown", icon: "🚪" };
|
return STATE_LABELS[state] ?? { label: state || "Unknown", icon: "🚪" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -249,7 +249,6 @@ 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=""
|
||||||
|
|||||||
@@ -366,7 +366,6 @@ 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,4 +1,6 @@
|
|||||||
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";
|
||||||
@@ -27,5 +29,13 @@ export default async function AdminNextLayout({
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
return children;
|
// Staff tree: re-install the full catalogue the root layout scopes down to
|
||||||
|
// the public subset (nested providers replace the parent set).
|
||||||
|
const [locale, messages] = await Promise.all([getLocale(), getMessages()]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||||
|
{children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -49,7 +49,6 @@ 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=""
|
||||||
|
|||||||
@@ -103,7 +103,6 @@ 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}
|
||||||
|
|||||||
@@ -98,7 +98,6 @@ 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" />
|
||||||
|
|||||||
@@ -96,7 +96,6 @@ 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=""
|
||||||
|
|||||||
@@ -155,7 +155,6 @@ 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=""
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
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 { getTranslations } from "next-intl/server";
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
|
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";
|
||||||
@@ -40,8 +41,13 @@ 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
|
||||||
@@ -56,7 +62,7 @@ export default async function AdminLayout({
|
|||||||
</div>
|
</div>
|
||||||
</AdminMobileWrapper>
|
</AdminMobileWrapper>
|
||||||
<AdminToaster />
|
<AdminToaster />
|
||||||
</>
|
</NextIntlClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,7 +94,6 @@ 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) })}
|
||||||
|
|||||||
@@ -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 {
|
||||||
// eslint-disable-next-line security/detect-object-injection -- only called with hardcoded keys
|
// 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) {
|
||||||
// eslint-disable-next-line security/detect-object-injection -- keys from hardcoded array
|
// 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,7 +577,6 @@ 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}
|
||||||
@@ -733,7 +732,6 @@ 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}
|
||||||
|
|||||||
@@ -493,7 +493,7 @@ export default async function AdminTheme({
|
|||||||
width: 20,
|
width: 20,
|
||||||
height: 20,
|
height: 20,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
// eslint-disable-next-line security/detect-object-injection -- k from fixed theme color key list
|
// 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)",
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -356,7 +356,7 @@ export async function GET(req: Request) {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const rarity = Object.fromEntries(
|
const rarity = Object.fromEntries(
|
||||||
// eslint-disable-next-line security/detect-object-injection -- rk from rarityKeys const, i is array index
|
// 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(
|
||||||
// eslint-disable-next-line security/detect-object-injection -- rk from rarityKeys const, i is array index
|
// 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(
|
||||||
// eslint-disable-next-line security/detect-object-injection -- rk from rarityKeys const
|
// rk from rarityKeys const
|
||||||
rarityKeys.map((rk) => [
|
rarityKeys.map((rk) => [
|
||||||
rk,
|
rk,
|
||||||
{ ...global.rarity[rk], viewerEntry: rarityViewer[rk] },
|
{ ...global.rarity[rk], viewerEntry: rarityViewer[rk] },
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export async function GET(req: Request) {
|
|||||||
async () =>
|
async () =>
|
||||||
type === "credits"
|
type === "credits"
|
||||||
? await loadCreditsRows()
|
? await loadCreditsRows()
|
||||||
: // eslint-disable-next-line security/detect-object-injection -- type validated to "diamonds"|"duckets"
|
: // 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.
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ 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,
|
||||||
@@ -59,7 +58,6 @@ 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",
|
||||||
@@ -76,7 +74,7 @@ export async function GET(
|
|||||||
headers: {
|
headers: {
|
||||||
...SECURITY_HEADERS,
|
...SECURITY_HEADERS,
|
||||||
...(ext === ".svg" ? { "Content-Disposition": "attachment" } : {}),
|
...(ext === ".svg" ? { "Content-Disposition": "attachment" } : {}),
|
||||||
// eslint-disable-next-line security/detect-object-injection -- ext validated against ALLOWED_EXT
|
// 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",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,11 +12,9 @@ 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) => {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ function findCount(value: unknown, depth = 0): number | null {
|
|||||||
"count",
|
"count",
|
||||||
];
|
];
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
// eslint-disable-next-line security/detect-object-injection -- keys from hardcoded array
|
// 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 (
|
||||||
|
|||||||
@@ -2893,6 +2893,20 @@ 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;
|
||||||
|
|||||||
+6
-1
@@ -13,6 +13,7 @@ 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";
|
||||||
|
|
||||||
@@ -68,7 +69,11 @@ export default async function RootLayout({
|
|||||||
}) {
|
}) {
|
||||||
await enforceSiteAccess();
|
await enforceSiteAccess();
|
||||||
const locale = await getLocale();
|
const locale = await getLocale();
|
||||||
const messages = await getMessages();
|
// Only the namespaces non-staff routes can reach: the full catalogue is
|
||||||
|
// ~254 KB and would otherwise be serialised into every public page's RSC
|
||||||
|
// payload. `/admin`, `/admin-next` and `/mod` install a nested provider
|
||||||
|
// seeded with the full set (see i18n-client-messages).
|
||||||
|
const messages = publicClientMessages(await getMessages());
|
||||||
const 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;
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
|
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";
|
||||||
@@ -81,8 +82,13 @@ 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
|
||||||
@@ -140,6 +146,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -45,7 +45,6 @@ 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}
|
||||||
|
|||||||
@@ -296,7 +296,6 @@ 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,7 +715,6 @@ 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,7 +34,6 @@ 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,7 +231,6 @@ 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,7 +192,6 @@ export function ShopFurniIcon({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
<img
|
||||||
src={primary}
|
src={primary}
|
||||||
alt={classname}
|
alt={classname}
|
||||||
|
|||||||
@@ -226,7 +226,6 @@ 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}
|
||||||
|
|||||||
@@ -156,7 +156,6 @@ 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,7 +51,6 @@ 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,
|
||||||
// eslint-disable-next-line security/detect-object-injection -- k from fixed key list
|
// 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,
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -128,6 +128,8 @@ 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. */
|
||||||
@@ -148,6 +150,7 @@ interface AuthPageFrameProps {
|
|||||||
export function AuthPageFrame({
|
export function AuthPageFrame({
|
||||||
hotelName,
|
hotelName,
|
||||||
logo,
|
logo,
|
||||||
|
online,
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
notice,
|
notice,
|
||||||
@@ -158,7 +161,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} />
|
<AuthTopBar hotelName={hotelName} logo={logo} online={online} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Reveal>
|
<Reveal>
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,29 +1,32 @@
|
|||||||
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 auth pages (login, register,
|
* Shared frosted-glass top bar for the public pages (index, login, register).
|
||||||
* etc.). Shows the hotel logo + name on the left and language / clock / theme
|
* Shows the hotel logo on the left and the live "online" counter (which carries
|
||||||
* controls on the right. Defaults mirror the homepage top bar so auth pages
|
* the hotel name), language / clock / theme controls on the right. One
|
||||||
* share one consistent look.
|
* component so every public page keeps the exact same chrome instead of
|
||||||
|
* duplicating it.
|
||||||
*/
|
*/
|
||||||
export function AuthTopBar({
|
export function AuthTopBar({
|
||||||
hotelName,
|
hotelName,
|
||||||
logo,
|
logo,
|
||||||
showHotelName = true,
|
online,
|
||||||
}: {
|
}: {
|
||||||
hotelName: string;
|
hotelName: string;
|
||||||
logo?: string | null;
|
logo?: string | null;
|
||||||
showHotelName?: boolean;
|
/** Live online count; omit (or pass null) to hide the indicator. */
|
||||||
|
online?: number | null;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<div
|
<div
|
||||||
className="relative flex items-center justify-between rounded-full border px-3 py-2.5 sm:px-5"
|
className="relative flex items-center justify-between gap-3 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))",
|
||||||
@@ -35,7 +38,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 items-center gap-2 sm:gap-3">
|
<div className="flex min-w-0 items-center gap-2 sm:gap-3">
|
||||||
{logo ? (
|
{logo ? (
|
||||||
<Image
|
<Image
|
||||||
src={logo}
|
src={logo}
|
||||||
@@ -67,14 +70,6 @@ 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"
|
||||||
@@ -83,6 +78,31 @@ 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"
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
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, useId, useState } from "react";
|
import { type FormEvent, useEffect, useId, useRef, 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,
|
||||||
@@ -51,8 +53,17 @@ 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" : "";
|
||||||
|
|
||||||
@@ -115,7 +126,9 @@ export function LoginForm({
|
|||||||
<>
|
<>
|
||||||
{needs2fa && (
|
{needs2fa && (
|
||||||
<p
|
<p
|
||||||
className="animate-fade-in-up mb-4 rounded-xl border px-3 py-2.5 text-sm font-semibold"
|
className={`animate-fade-in-up 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:
|
||||||
@@ -131,105 +144,76 @@ export function LoginForm({
|
|||||||
<form
|
<form
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
aria-label={t("signIn")}
|
aria-label={t("signIn")}
|
||||||
className="relative flex flex-col gap-4"
|
className={`relative flex flex-col ${compact ? "gap-3" : "gap-4"}`}
|
||||||
>
|
>
|
||||||
<div className={`space-y-1.5 ${lockCredentials}`}>
|
<AuthField
|
||||||
<label htmlFor={`${fieldId}-username`} className="auth-label">
|
id={`${fieldId}-username`}
|
||||||
{t("username")}
|
name="username"
|
||||||
</label>
|
label={t("username")}
|
||||||
<div className="relative">
|
icon={UserRound}
|
||||||
<UserRound
|
value={username}
|
||||||
aria-hidden="true"
|
onChange={setUsername}
|
||||||
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
|
placeholder={t("usernamePlaceholder")}
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
autoComplete="username"
|
||||||
/>
|
disabled={needs2fa || pending}
|
||||||
<input
|
required
|
||||||
id={`${fieldId}-username`}
|
invalid={!!error}
|
||||||
name="username"
|
describedBy={error ? `${fieldId}-error` : undefined}
|
||||||
type="text"
|
className={lockCredentials}
|
||||||
value={username}
|
/>
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
|
||||||
placeholder={t("usernamePlaceholder")}
|
|
||||||
autoComplete="username"
|
|
||||||
disabled={needs2fa}
|
|
||||||
required
|
|
||||||
className="auth-input has-icon"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={`space-y-1.5 ${lockCredentials}`}>
|
<AuthField
|
||||||
<label htmlFor={`${fieldId}-password`} className="auth-label">
|
id={`${fieldId}-password`}
|
||||||
{t("password")}
|
name="password"
|
||||||
</label>
|
label={t("password")}
|
||||||
<div className="relative">
|
icon={Lock}
|
||||||
<Lock
|
type={showPassword ? "text" : "password"}
|
||||||
aria-hidden="true"
|
value={password}
|
||||||
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
|
onChange={setPassword}
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
placeholder={t("passwordPlaceholder")}
|
||||||
/>
|
autoComplete="current-password"
|
||||||
<input
|
disabled={needs2fa || pending}
|
||||||
id={`${fieldId}-password`}
|
required
|
||||||
name="password"
|
invalid={!!error}
|
||||||
type={showPassword ? "text" : "password"}
|
describedBy={error ? `${fieldId}-error` : undefined}
|
||||||
value={password}
|
className={lockCredentials}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
trailing={
|
||||||
placeholder={t("passwordPlaceholder")}
|
|
||||||
autoComplete="current-password"
|
|
||||||
disabled={needs2fa}
|
|
||||||
required
|
|
||||||
className="auth-input has-icon has-action"
|
|
||||||
/>
|
|
||||||
<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 ? (
|
||||||
<div className="animate-scale-in space-y-1.5">
|
<AuthField
|
||||||
<label htmlFor={`${fieldId}-2fa`} className="auth-label">
|
id={`${fieldId}-2fa`}
|
||||||
{t("codePlaceholder")}
|
name="code"
|
||||||
</label>
|
label={t("codePlaceholder")}
|
||||||
<div className="relative">
|
icon={KeyRound}
|
||||||
<KeyRound
|
iconColor="var(--color-primary)"
|
||||||
aria-hidden="true"
|
value={code}
|
||||||
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
|
onChange={setCode}
|
||||||
style={{ color: "var(--color-primary)" }}
|
placeholder={t("codePlaceholder")}
|
||||||
/>
|
inputMode="numeric"
|
||||||
<input
|
autoComplete="one-time-code"
|
||||||
id={`${fieldId}-2fa`}
|
disabled={pending}
|
||||||
name="code"
|
invalid={!!error}
|
||||||
type="text"
|
describedBy={error ? `${fieldId}-error` : undefined}
|
||||||
value={code}
|
className="animate-scale-in"
|
||||||
onChange={(e) => setCode(e.target.value)}
|
inputRef={codeRef}
|
||||||
placeholder={t("codePlaceholder")}
|
inputStyle={{
|
||||||
inputMode="numeric"
|
borderColor:
|
||||||
autoComplete="one-time-code"
|
"color-mix(in srgb, var(--color-primary) 45%, transparent)",
|
||||||
className="auth-input has-icon"
|
}}
|
||||||
style={{
|
/>
|
||||||
borderColor:
|
|
||||||
"color-mix(in srgb, var(--color-primary) 45%, transparent)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<CaptchaWidget captcha={captcha} nonce={nonce} />
|
<CaptchaWidget captcha={captcha} nonce={nonce} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && <AuthAlert id={`${fieldId}-error`}>{error}</AuthAlert>}
|
||||||
<p
|
|
||||||
role="alert"
|
|
||||||
aria-live="polite"
|
|
||||||
className="auth-alert animate-fade-in-up m-0"
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{unverified && (
|
{unverified && (
|
||||||
<p
|
<p
|
||||||
@@ -249,7 +233,9 @@ export function LoginForm({
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={pending || locked}
|
disabled={pending || locked}
|
||||||
className="btn-brand btn-shine w-full cursor-pointer py-3.5 text-sm font-extrabold uppercase tracking-wider disabled:opacity-60"
|
className={`btn-brand btn-shine w-full cursor-pointer text-sm font-extrabold uppercase tracking-wider disabled:opacity-60 ${
|
||||||
|
compact ? "py-3" : "py-3.5"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{pending ? (
|
{pending ? (
|
||||||
<span className="inline-flex items-center justify-center gap-2">
|
<span className="inline-flex items-center justify-center gap-2">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
Lock,
|
Lock,
|
||||||
|
type LucideIcon,
|
||||||
Mail,
|
Mail,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
UserRound,
|
UserRound,
|
||||||
@@ -18,6 +19,8 @@ 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";
|
||||||
@@ -27,19 +30,23 @@ 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 >= 8) score += 1;
|
if (pw.length >= minLength) score += 1;
|
||||||
if (pw.length >= 12) score += 1;
|
if (pw.length >= minLength + 4) 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;
|
||||||
@@ -50,11 +57,58 @@ 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;
|
||||||
@@ -90,7 +144,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);
|
const strength = passwordStrength(password, t, passwordMinLength);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The password policy the server enforces, evaluated live so the visitor can
|
* The password policy the server enforces, evaluated live so the visitor can
|
||||||
@@ -98,7 +152,10 @@ 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) },
|
||||||
@@ -183,15 +240,7 @@ export function RegisterForm({
|
|||||||
}}
|
}}
|
||||||
className="flex flex-col gap-6"
|
className="flex flex-col gap-6"
|
||||||
>
|
>
|
||||||
{errorMessage && (
|
{errorMessage && <AuthAlert>{errorMessage}</AuthAlert>}
|
||||||
<p
|
|
||||||
role="alert"
|
|
||||||
aria-live="polite"
|
|
||||||
className="auth-alert animate-fade-in-up m-0"
|
|
||||||
>
|
|
||||||
{errorMessage}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{referralCode && (
|
{referralCode && (
|
||||||
<div
|
<div
|
||||||
@@ -212,156 +261,65 @@ export function RegisterForm({
|
|||||||
|
|
||||||
{/* Username & Email */}
|
{/* Username & Email */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2.5">
|
<AuthSectionHeader
|
||||||
<span
|
icon={UserRound}
|
||||||
aria-hidden="true"
|
label={t("accountDetails")}
|
||||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg"
|
step="01"
|
||||||
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">
|
||||||
<div className="space-y-1.5">
|
<AuthField
|
||||||
<label htmlFor="username" className="auth-label">
|
id="username"
|
||||||
{t("username")}
|
name="username"
|
||||||
</label>
|
label={t("username")}
|
||||||
<div className="relative">
|
icon={UserRound}
|
||||||
<UserRound
|
placeholder={t("usernamePlaceholder")}
|
||||||
aria-hidden="true"
|
autoComplete="username"
|
||||||
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
|
required
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
/>
|
||||||
/>
|
<AuthField
|
||||||
<input
|
id="mail"
|
||||||
id="username"
|
name="mail"
|
||||||
name="username"
|
label={t("email")}
|
||||||
type="text"
|
icon={Mail}
|
||||||
placeholder={t("usernamePlaceholder")}
|
type="email"
|
||||||
autoComplete="username"
|
placeholder={t("emailPlaceholder")}
|
||||||
className="auth-input has-icon"
|
autoComplete="email"
|
||||||
required
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label htmlFor="mail" className="auth-label">
|
|
||||||
{t("email")}
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Mail
|
|
||||||
aria-hidden="true"
|
|
||||||
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
|
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
id="mail"
|
|
||||||
name="mail"
|
|
||||||
type="email"
|
|
||||||
placeholder={t("emailPlaceholder")}
|
|
||||||
autoComplete="email"
|
|
||||||
className="auth-input has-icon"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Password & Confirm */}
|
{/* Password & Confirm */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2.5">
|
<AuthSectionHeader
|
||||||
<span
|
icon={KeyRound}
|
||||||
aria-hidden="true"
|
label={t("credentials")}
|
||||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg"
|
step="02"
|
||||||
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">
|
||||||
<div className="space-y-1.5">
|
<AuthField
|
||||||
<label htmlFor="password" className="auth-label">
|
id="password"
|
||||||
{t("password")}
|
name="password"
|
||||||
</label>
|
label={t("password")}
|
||||||
<div className="relative">
|
icon={Lock}
|
||||||
<Lock
|
type={showPassword ? "text" : "password"}
|
||||||
aria-hidden="true"
|
value={password}
|
||||||
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
|
onChange={setPassword}
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
placeholder={t("passwordPlaceholder", {
|
||||||
/>
|
min: passwordMinLength,
|
||||||
<input
|
})}
|
||||||
id="password"
|
autoComplete="new-password"
|
||||||
name="password"
|
required
|
||||||
type={showPassword ? "text" : "password"}
|
minLength={passwordMinLength}
|
||||||
placeholder={t("passwordPlaceholder")}
|
maxLength={passwordMaxLength}
|
||||||
autoComplete="new-password"
|
trailing={
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
className="auth-input has-icon has-action"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<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">
|
||||||
@@ -417,35 +375,27 @@ export function RegisterForm({
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</AuthField>
|
||||||
<div className="space-y-1.5">
|
<AuthField
|
||||||
<label htmlFor="password_confirmation" className="auth-label">
|
id="password_confirmation"
|
||||||
{t("confirmPassword")}
|
name="password_confirmation"
|
||||||
</label>
|
label={t("confirmPassword")}
|
||||||
<div className="relative">
|
icon={Lock}
|
||||||
<Lock
|
type={showConfirm ? "text" : "password"}
|
||||||
aria-hidden="true"
|
value={confirmPassword}
|
||||||
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
|
onChange={setConfirmPassword}
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
placeholder={t("confirmPasswordPlaceholder")}
|
||||||
/>
|
autoComplete="new-password"
|
||||||
<input
|
required
|
||||||
id="password_confirmation"
|
trailing={
|
||||||
name="password_confirmation"
|
|
||||||
type={showConfirm ? "text" : "password"}
|
|
||||||
placeholder={t("confirmPasswordPlaceholder")}
|
|
||||||
autoComplete="new-password"
|
|
||||||
value={confirmPassword}
|
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
||||||
className="auth-input has-icon has-action"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<PasswordToggle
|
<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"
|
||||||
@@ -454,7 +404,7 @@ export function RegisterForm({
|
|||||||
{t("passwordsDontMatch")}
|
{t("passwordsDontMatch")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</AuthField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
// eslint-disable-next-line security/detect-object-injection -- index validated by findIndex + Math.max, guard below
|
// 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);
|
||||||
|
|||||||
@@ -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);
|
||||||
// eslint-disable-next-line security/detect-object-injection -- openIndex is numeric array index, guarded by null check
|
// 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,7 +97,6 @@ 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}
|
||||||
@@ -180,7 +179,6 @@ 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}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ function scanCharWidths(
|
|||||||
|
|
||||||
const w = end - start;
|
const w = end - start;
|
||||||
if (w > 0) {
|
if (w > 0) {
|
||||||
// eslint-disable-next-line security/detect-object-injection -- ci increments within CHAR_ORDER bounds
|
// 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++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ 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", 2],
|
["src/app/(site)/page.tsx", 1],
|
||||||
["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],
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ 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,6 +41,8 @@ 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");
|
||||||
}),
|
}),
|
||||||
@@ -58,6 +60,8 @@ 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,
|
||||||
|
|||||||
+14
-1
@@ -1,6 +1,7 @@
|
|||||||
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 {
|
||||||
@@ -25,7 +26,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";
|
||||||
|
|
||||||
export const { handlers, signOut, auth } = NextAuth({
|
const nextAuth = 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 },
|
||||||
@@ -161,3 +162,15 @@ export const { handlers, signOut, auth } = 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);
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
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 };
|
||||||
|
})();
|
||||||
@@ -85,7 +85,12 @@ describe("publicClientMessages", () => {
|
|||||||
if (key === "admin") continue;
|
if (key === "admin") continue;
|
||||||
expect(result[key]).toBeDefined();
|
expect(result[key]).toBeDefined();
|
||||||
}
|
}
|
||||||
expect(result.pages.admin).toBeUndefined();
|
// `pages.admin` survives only in the shape the shared server-action hook
|
||||||
|
// needs: the generic `actions` copy, nothing staff-specific.
|
||||||
|
expect(Object.keys(result.pages.admin)).toEqual(["actions"]);
|
||||||
|
expect(result.pages.admin.actions.saved).toBe(en.pages.admin.actions.saved);
|
||||||
|
expect(result.pages.admin.nav).toBeUndefined();
|
||||||
|
expect(result.pages.admin.catalog).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("actually shrinks the catalogue", async () => {
|
it("actually shrinks the catalogue", async () => {
|
||||||
@@ -111,3 +116,59 @@ 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,22 +3,35 @@ 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 all
|
* `getMessages()` hands back the whole catalogue, and the root layout feeds it
|
||||||
* of it to `NextIntlClientProvider`, which serialises every message into the RSC
|
* to `NextIntlClientProvider`, which serialises every message into the RSC
|
||||||
* payload of every page. The catalogue is ~235 KB of which `pages.admin` and
|
* payload of every page. The catalogue is ~254 KB of which `pages.admin` and
|
||||||
* `admin` together are ~177 KB — and no public page ever reads either.
|
* `admin` together are ~195 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". Instead the public layout installs a
|
* a segment cannot ask for "just my part". The root layout therefore installs
|
||||||
* provider seeded with the public subset, which shrinks the payload for every
|
* the reduced catalogue, and the staff route groups (`/admin`, `/admin-next`,
|
||||||
* public page while leaving /admin, /mod, /client and /admin-next on the full set.
|
* `/mod`) each install a nested provider seeded with the full one, which keeps
|
||||||
|
* staff tools complete while public pages ship ~59 KB instead of ~254 KB.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** 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.
|
||||||
*
|
*
|
||||||
@@ -43,8 +56,21 @@ 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)) continue;
|
if (!ADMIN_ONLY_PAGE_NAMESPACES.has(pageKey)) {
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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]"
|
||||||
|
|||||||
@@ -14,6 +14,6 @@ const LABELS: Record<CurrencyType, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function currencyLabel(type: CurrencyType): string {
|
export function currencyLabel(type: CurrencyType): string {
|
||||||
// eslint-disable-next-line security/detect-object-injection -- type is CurrencyType enum
|
// type is CurrencyType enum
|
||||||
return LABELS[type];
|
return LABELS[type];
|
||||||
}
|
}
|
||||||
@@ -55,12 +55,10 @@ 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;
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export async function checkVpn(ip: string): Promise<IpVerdict> {
|
|||||||
string,
|
string,
|
||||||
{ proxy?: string; type?: string }
|
{ proxy?: string; type?: string }
|
||||||
>;
|
>;
|
||||||
// eslint-disable-next-line security/detect-object-injection -- ip is the API response key from proxycheck
|
// 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` };
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export async function sendCurrency(
|
|||||||
data: { credits: { increment: amount } },
|
data: { credits: { increment: amount } },
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// eslint-disable-next-line security/detect-object-injection -- type is CurrencyName union, not "credits"
|
// 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 } },
|
||||||
|
|||||||
@@ -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;
|
||||||
// eslint-disable-next-line security/detect-object-injection -- guarded by `key in DEFAULTS`
|
// 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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] ?? ""; // eslint-disable-line security/detect-object-injection -- key derived from internal THEME_COLOR_KEYS
|
return preset.dark[base] ?? ""; // 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] ?? ""; // eslint-disable-line security/detect-object-injection -- key derived from internal EXTRA_KEYS
|
return extras[key] ?? ""; // 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 }; // eslint-disable-line security/detect-object-injection -- idx from findIndex over internal array
|
themes[idx] = { ...themes[idx], name: trimmed, settings }; // idx from findIndex over internal array
|
||||||
await persist(themes);
|
await persist(themes);
|
||||||
return themes[idx]; // eslint-disable-line security/detect-object-injection -- idx from findIndex over internal array
|
return themes[idx]; // idx from findIndex over internal array
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const theme: CustomTheme = {
|
const theme: CustomTheme = {
|
||||||
|
|||||||
@@ -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: "secret123",
|
password: "secret123456",
|
||||||
});
|
});
|
||||||
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: "secret123",
|
password: "secret123456",
|
||||||
}).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: "secret123",
|
password: "secret123456",
|
||||||
}).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: "secret123",
|
password: "secret123456",
|
||||||
}).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: "secret123",
|
password: "secret123456",
|
||||||
}).success,
|
}).success,
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
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
|
||||||
@@ -10,7 +11,16 @@ 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.string().min(6, "Password must be at least 6 characters"),
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(
|
||||||
|
passwordPolicy.minLength,
|
||||||
|
`Password must be at least ${passwordPolicy.minLength} characters`,
|
||||||
|
)
|
||||||
|
.max(
|
||||||
|
passwordPolicy.maxLength,
|
||||||
|
`Password must be at most ${passwordPolicy.maxLength} characters`,
|
||||||
|
),
|
||||||
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(""),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6132,7 +6132,7 @@
|
|||||||
"confirmPassword": "أعد كلمة المرور",
|
"confirmPassword": "أعد كلمة المرور",
|
||||||
"usernamePlaceholder": "اسم المستخدم",
|
"usernamePlaceholder": "اسم المستخدم",
|
||||||
"emailPlaceholder": "البريد الإلكتروني",
|
"emailPlaceholder": "البريد الإلكتروني",
|
||||||
"passwordPlaceholder": "كلمة المرور (12 حرفًا على الأقل)",
|
"passwordPlaceholder": "كلمة المرور ({min} حرفًا على الأقل)",
|
||||||
"confirmPasswordPlaceholder": "أعد كلمة المرور",
|
"confirmPasswordPlaceholder": "أعد كلمة المرور",
|
||||||
"selectOutfit": "اختر ملابسك",
|
"selectOutfit": "اختر ملابسك",
|
||||||
"termsAccept": "أنا أبلغ 18 عامًا وأوافق على شروط {hotel}.",
|
"termsAccept": "أنا أبلغ 18 عامًا وأوافق على شروط {hotel}.",
|
||||||
@@ -6153,7 +6153,7 @@
|
|||||||
"termsError": "يجب الموافقة على الشروط للتسجيل.",
|
"termsError": "يجب الموافقة على الشروط للتسجيل.",
|
||||||
"usernameError": "اسم المستخدم مطلوب.",
|
"usernameError": "اسم المستخدم مطلوب.",
|
||||||
"emailError": "البريد الإلكتروني الصالح مطلوب.",
|
"emailError": "البريد الإلكتروني الصالح مطلوب.",
|
||||||
"passwordError": "يجب ألا تقل كلمة المرور عن 12 حرفًا",
|
"passwordError": "يجب ألا تقل كلمة المرور عن {min} حرفًا",
|
||||||
"confirmPasswordError": "كلمتا المرور غير متطابقتين.",
|
"confirmPasswordError": "كلمتا المرور غير متطابقتين.",
|
||||||
"termsRequired": "يجب الموافقة على الشروط.",
|
"termsRequired": "يجب الموافقة على الشروط.",
|
||||||
"usernameRequired": "اسم المستخدم مطلوب.",
|
"usernameRequired": "اسم المستخدم مطلوب.",
|
||||||
@@ -6167,7 +6167,7 @@
|
|||||||
"strengthFair": "مقبول",
|
"strengthFair": "مقبول",
|
||||||
"strengthGood": "جيد",
|
"strengthGood": "جيد",
|
||||||
"strengthStrong": "قوي",
|
"strengthStrong": "قوي",
|
||||||
"passwordMinLength": "يجب ألا تقل كلمة المرور عن 12 حرفًا",
|
"passwordMinLength": "يجب ألا تقل كلمة المرور عن {min} حرفًا",
|
||||||
"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 12 chars)",
|
"passwordPlaceholder": "New password (min {min} chars)",
|
||||||
"resetPassword": "Reset password",
|
"resetPassword": "Reset password",
|
||||||
"backToLogin": "Back to login",
|
"backToLogin": "Back to login",
|
||||||
"passwordMinLength": "يجب ألا تقل كلمة المرور عن 12 حرفًا",
|
"passwordMinLength": "يجب ألا تقل كلمة المرور عن {min} حرفًا",
|
||||||
"tooManyAttempts": "محاولات كثيرة جدًا — حاول لاحقًا",
|
"tooManyAttempts": "محاولات كثيرة جدًا — حاول لاحقًا",
|
||||||
"invalidLink": "رابط إعادة التعيين غير صالح أو منتهي الصلاحية. اطلب رابطًا جديدًا.",
|
"invalidLink": "رابط إعادة التعيين غير صالح أو منتهي الصلاحية. اطلب رابطًا جديدًا.",
|
||||||
"failed": "تعذّرت إعادة تعيين كلمة المرور. حاول مرة أخرى."
|
"failed": "تعذّرت إعادة تعيين كلمة المرور. حاول مرة أخرى."
|
||||||
|
|||||||
@@ -885,7 +885,7 @@
|
|||||||
"confirmPassword": "Повторете паролата",
|
"confirmPassword": "Повторете паролата",
|
||||||
"usernamePlaceholder": "Потребителско име",
|
"usernamePlaceholder": "Потребителско име",
|
||||||
"emailPlaceholder": "Имейл",
|
"emailPlaceholder": "Имейл",
|
||||||
"passwordPlaceholder": "Парола (мин. 12 знака)",
|
"passwordPlaceholder": "Парола (мин. {min} знака)",
|
||||||
"confirmPasswordPlaceholder": "Повторете паролата",
|
"confirmPasswordPlaceholder": "Повторете паролата",
|
||||||
"selectOutfit": "Изберете вашето облекло",
|
"selectOutfit": "Изберете вашето облекло",
|
||||||
"termsAccept": "Аз съм на 18+ и приемам условията и правилата на {hotel}.",
|
"termsAccept": "Аз съм на 18+ и приемам условията и правилата на {hotel}.",
|
||||||
@@ -897,7 +897,7 @@
|
|||||||
"termsError": "Трябва да приемете условията и правилата, за да се регистрирате.",
|
"termsError": "Трябва да приемете условията и правилата, за да се регистрирате.",
|
||||||
"usernameError": "Изисква се потребителско име.",
|
"usernameError": "Изисква се потребителско име.",
|
||||||
"emailError": "Изисква се валиден имейл.",
|
"emailError": "Изисква се валиден имейл.",
|
||||||
"passwordError": "Паролата трябва да е поне 12 знака",
|
"passwordError": "Паролата трябва да е поне {min} знака",
|
||||||
"confirmPasswordError": "Паролите не съвпадат.",
|
"confirmPasswordError": "Паролите не съвпадат.",
|
||||||
"termsRequired": "Трябва да приемете условията и правилата.",
|
"termsRequired": "Трябва да приемете условията и правилата.",
|
||||||
"usernameRequired": "Изисква се потребителско име.",
|
"usernameRequired": "Изисква се потребителско име.",
|
||||||
@@ -920,7 +920,7 @@
|
|||||||
"strengthFair": "Средна",
|
"strengthFair": "Средна",
|
||||||
"strengthGood": "Добра",
|
"strengthGood": "Добра",
|
||||||
"strengthStrong": "Силна",
|
"strengthStrong": "Силна",
|
||||||
"passwordMinLength": "Паролата трябва да е поне 12 знака",
|
"passwordMinLength": "Паролата трябва да е поне {min} знака",
|
||||||
"passwordUpper": "Паролата трябва да съдържа поне една главна буква",
|
"passwordUpper": "Паролата трябва да съдържа поне една главна буква",
|
||||||
"passwordLower": "Паролата трябва да съдържа поне една малка буква",
|
"passwordLower": "Паролата трябва да съдържа поне една малка буква",
|
||||||
"passwordDigit": "Паролата трябва да съдържа поне една цифра",
|
"passwordDigit": "Паролата трябва да съдържа поне една цифра",
|
||||||
@@ -955,10 +955,10 @@
|
|||||||
"reset": {
|
"reset": {
|
||||||
"title": "Задайте нова парола",
|
"title": "Задайте нова парола",
|
||||||
"subtitle": "Изберете силна парола, която не използвате другаде.",
|
"subtitle": "Изберете силна парола, която не използвате другаде.",
|
||||||
"passwordPlaceholder": "Нова парола (мин. 12 знака)",
|
"passwordPlaceholder": "Нова парола (мин. {min} знака)",
|
||||||
"resetPassword": "Нулирайте паролата",
|
"resetPassword": "Нулирайте паролата",
|
||||||
"backToLogin": "Назад към входа",
|
"backToLogin": "Назад към входа",
|
||||||
"passwordMinLength": "Паролата трябва да е поне 12 знака",
|
"passwordMinLength": "Паролата трябва да е поне {min} знака",
|
||||||
"tooManyAttempts": "Твърде много опити — опитайте по-късно",
|
"tooManyAttempts": "Твърде много опити — опитайте по-късно",
|
||||||
"invalidLink": "Този линк за нулиране е невалиден или е изтекъл. Поискайте нов.",
|
"invalidLink": "Този линк за нулиране е невалиден или е изтекъл. Поискайте нов.",
|
||||||
"failed": "Нулирането на паролата не бе успешно. Моля, опитайте отново."
|
"failed": "Нулирането на паролата не бе успешно. Моля, опитайте отново."
|
||||||
|
|||||||
@@ -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. 12 znaků)",
|
"passwordPlaceholder": "Heslo (min. {min} 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ň 12 znaků",
|
"passwordError": "Heslo musí mít alespoň {min} 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ň 12 znaků",
|
"passwordMinLength": "Heslo musí mít alespoň {min} 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. 12 znaků)",
|
"passwordPlaceholder": "Nové heslo (min. {min} 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ň 12 znaků",
|
"passwordMinLength": "Heslo musí mít alespoň {min} 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."
|
||||||
|
|||||||
@@ -885,7 +885,7 @@
|
|||||||
"confirmPassword": "Gentag adgangskoden",
|
"confirmPassword": "Gentag adgangskoden",
|
||||||
"usernamePlaceholder": "Brugernavn",
|
"usernamePlaceholder": "Brugernavn",
|
||||||
"emailPlaceholder": "E-mail",
|
"emailPlaceholder": "E-mail",
|
||||||
"passwordPlaceholder": "Adgangskode (min. 12 tegn)",
|
"passwordPlaceholder": "Adgangskode (min. {min} 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 12 tegn",
|
"passwordError": "Adgangskoden skal være på mindst {min} 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 12 tegn",
|
"passwordMinLength": "Adgangskoden skal være på mindst {min} 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. 12 tegn)",
|
"passwordPlaceholder": "Ny adgangskode (min. {min} tegn)",
|
||||||
"resetPassword": "Nulstil adgangskode",
|
"resetPassword": "Nulstil adgangskode",
|
||||||
"backToLogin": "Tilbage til login",
|
"backToLogin": "Tilbage til login",
|
||||||
"passwordMinLength": "Adgangskoden skal være på mindst 12 tegn",
|
"passwordMinLength": "Adgangskoden skal være på mindst {min} 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."
|
||||||
|
|||||||
@@ -855,7 +855,7 @@
|
|||||||
"confirmPassword": "Passwort wiederholen",
|
"confirmPassword": "Passwort wiederholen",
|
||||||
"usernamePlaceholder": "Benutzername",
|
"usernamePlaceholder": "Benutzername",
|
||||||
"emailPlaceholder": "E-Mail",
|
"emailPlaceholder": "E-Mail",
|
||||||
"passwordPlaceholder": "Passwort (min. 12 Zeichen)",
|
"passwordPlaceholder": "Passwort (min. {min} 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 12 Zeichen lang sein",
|
"passwordError": "Das Passwort muss mindestens {min} 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 12 Zeichen lang sein",
|
"passwordMinLength": "Das Passwort muss mindestens {min} 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. 12 Zeichen)",
|
"passwordPlaceholder": "Neues Passwort (min. {min} Zeichen)",
|
||||||
"resetPassword": "Passwort zurücksetzen",
|
"resetPassword": "Passwort zurücksetzen",
|
||||||
"backToLogin": "Zurück zur Anmeldung",
|
"backToLogin": "Zurück zur Anmeldung",
|
||||||
"passwordMinLength": "Das Passwort muss mindestens 12 Zeichen lang sein",
|
"passwordMinLength": "Das Passwort muss mindestens {min} 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."
|
||||||
|
|||||||
@@ -885,7 +885,7 @@
|
|||||||
"confirmPassword": "Επαναλάβετε τον κωδικό πρόσβασης",
|
"confirmPassword": "Επαναλάβετε τον κωδικό πρόσβασης",
|
||||||
"usernamePlaceholder": "Όνομα χρήστη",
|
"usernamePlaceholder": "Όνομα χρήστη",
|
||||||
"emailPlaceholder": "Email",
|
"emailPlaceholder": "Email",
|
||||||
"passwordPlaceholder": "Κωδικός πρόσβασης (ελάχ. 12 χαρακτήρες)",
|
"passwordPlaceholder": "Κωδικός πρόσβασης (ελάχ. {min} χαρακτήρες)",
|
||||||
"confirmPasswordPlaceholder": "Επαναλάβετε τον κωδικό πρόσβασης",
|
"confirmPasswordPlaceholder": "Επαναλάβετε τον κωδικό πρόσβασης",
|
||||||
"selectOutfit": "Επιλέξτε το ντύσιμό σας",
|
"selectOutfit": "Επιλέξτε το ντύσιμό σας",
|
||||||
"termsAccept": "Είμαι 18+ και αποδέχομαι τους όρους και τους κανόνες του {hotel}.",
|
"termsAccept": "Είμαι 18+ και αποδέχομαι τους όρους και τους κανόνες του {hotel}.",
|
||||||
@@ -897,7 +897,7 @@
|
|||||||
"termsError": "Πρέπει να αποδεχτείτε τους όρους και τους κανόνες για να εγγραφείτε.",
|
"termsError": "Πρέπει να αποδεχτείτε τους όρους και τους κανόνες για να εγγραφείτε.",
|
||||||
"usernameError": "Απαιτείται όνομα χρήστη.",
|
"usernameError": "Απαιτείται όνομα χρήστη.",
|
||||||
"emailError": "Απαιτείται έγκυρο email.",
|
"emailError": "Απαιτείται έγκυρο email.",
|
||||||
"passwordError": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον 12 χαρακτήρες",
|
"passwordError": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον {min} χαρακτήρες",
|
||||||
"confirmPasswordError": "Οι κωδικοί πρόσβασης δεν ταιριάζουν.",
|
"confirmPasswordError": "Οι κωδικοί πρόσβασης δεν ταιριάζουν.",
|
||||||
"termsRequired": "Πρέπει να αποδεχτείτε τους όρους και τους κανόνες.",
|
"termsRequired": "Πρέπει να αποδεχτείτε τους όρους και τους κανόνες.",
|
||||||
"usernameRequired": "Απαιτείται όνομα χρήστη.",
|
"usernameRequired": "Απαιτείται όνομα χρήστη.",
|
||||||
@@ -920,7 +920,7 @@
|
|||||||
"strengthFair": "Μέτριο",
|
"strengthFair": "Μέτριο",
|
||||||
"strengthGood": "Καλό",
|
"strengthGood": "Καλό",
|
||||||
"strengthStrong": "Δυνατό",
|
"strengthStrong": "Δυνατό",
|
||||||
"passwordMinLength": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον 12 χαρακτήρες",
|
"passwordMinLength": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον {min} χαρακτήρες",
|
||||||
"passwordUpper": "Ο κωδικός πρόσβασης πρέπει να περιέχει τουλάχιστον ένα κεφαλαίο γράμμα",
|
"passwordUpper": "Ο κωδικός πρόσβασης πρέπει να περιέχει τουλάχιστον ένα κεφαλαίο γράμμα",
|
||||||
"passwordLower": "Ο κωδικός πρόσβασης πρέπει να περιέχει τουλάχιστον ένα πεζό γράμμα",
|
"passwordLower": "Ο κωδικός πρόσβασης πρέπει να περιέχει τουλάχιστον ένα πεζό γράμμα",
|
||||||
"passwordDigit": "Ο κωδικός πρόσβασης πρέπει να περιέχει τουλάχιστον ένα ψηφίο",
|
"passwordDigit": "Ο κωδικός πρόσβασης πρέπει να περιέχει τουλάχιστον ένα ψηφίο",
|
||||||
@@ -955,10 +955,10 @@
|
|||||||
"reset": {
|
"reset": {
|
||||||
"title": "Ορίστε νέο κωδικό πρόσβασης",
|
"title": "Ορίστε νέο κωδικό πρόσβασης",
|
||||||
"subtitle": "Επιλέξτε έναν ισχυρό κωδικό πρόσβασης που δεν χρησιμοποιείτε αλλού.",
|
"subtitle": "Επιλέξτε έναν ισχυρό κωδικό πρόσβασης που δεν χρησιμοποιείτε αλλού.",
|
||||||
"passwordPlaceholder": "Νέος κωδικός πρόσβασης (ελάχ. 12 χαρακτήρες)",
|
"passwordPlaceholder": "Νέος κωδικός πρόσβασης (ελάχ. {min} χαρακτήρες)",
|
||||||
"resetPassword": "Επαναφορά κωδικού πρόσβασης",
|
"resetPassword": "Επαναφορά κωδικού πρόσβασης",
|
||||||
"backToLogin": "Επιστροφή στην είσοδο",
|
"backToLogin": "Επιστροφή στην είσοδο",
|
||||||
"passwordMinLength": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον 12 χαρακτήρες",
|
"passwordMinLength": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον {min} χαρακτήρες",
|
||||||
"tooManyAttempts": "Πάρα πολλές προσπάθειες — δοκιμάστε αργότερα",
|
"tooManyAttempts": "Πάρα πολλές προσπάθειες — δοκιμάστε αργότερα",
|
||||||
"invalidLink": "Αυτός ο σύνδεσμος επαναφοράς δεν είναι έγκυρος ή έχει λήξει. Ζητήστε νέο.",
|
"invalidLink": "Αυτός ο σύνδεσμος επαναφοράς δεν είναι έγκυρος ή έχει λήξει. Ζητήστε νέο.",
|
||||||
"failed": "Δεν ήταν δυνατή η επαναφορά του κωδικού. Δοκιμάστε ξανά."
|
"failed": "Δεν ήταν δυνατή η επαναφορά του κωδικού. Δοκιμάστε ξανά."
|
||||||
|
|||||||
@@ -1058,7 +1058,7 @@
|
|||||||
"confirmPassword": "Repeat password",
|
"confirmPassword": "Repeat password",
|
||||||
"usernamePlaceholder": "Username",
|
"usernamePlaceholder": "Username",
|
||||||
"emailPlaceholder": "Email",
|
"emailPlaceholder": "Email",
|
||||||
"passwordPlaceholder": "Password (min 12 chars)",
|
"passwordPlaceholder": "Password (min {min} 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 12 characters",
|
"passwordError": "Password must be at least {min} 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 12 characters",
|
"passwordMinLength": "Password must be at least {min} 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 12 chars)",
|
"passwordPlaceholder": "New password (min {min} chars)",
|
||||||
"resetPassword": "Reset password",
|
"resetPassword": "Reset password",
|
||||||
"backToLogin": "Back to login",
|
"backToLogin": "Back to login",
|
||||||
"passwordMinLength": "Password must be at least 12 characters",
|
"passwordMinLength": "Password must be at least {min} 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."
|
||||||
|
|||||||
@@ -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. 12 caracteres)",
|
"passwordPlaceholder": "Contraseña (mín. {min} 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 12 caracteres",
|
"passwordError": "La contraseña debe tener al menos {min} 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 12 caracteres",
|
"passwordMinLength": "La contraseña debe tener al menos {min} 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. 12 caracteres)",
|
"passwordPlaceholder": "Nueva contraseña (mín. {min} 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 12 caracteres",
|
"passwordMinLength": "La contraseña debe tener al menos {min} 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
Reference in new issue
Block a user