Author SHA1 Message Date
openhands 5c428c4526 feat(home): label the top-bar online counter with the hotel name
CI / check (push) Successful in 33s
CI / tests-integration (push) Successful in 1m58s
CI / tests-unit (push) Successful in 2m1s
CI / tests-imaging-browsers (push) Successful in 1m24s
CI / tests-quality (push) Successful in 50s
CI / tests-ui (push) Successful in 4m34s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 3m12s
Use the shared LiveOnlineCount so the landing top bar reads "0 <hotel> online"
instead of a bare number, and drop the hotel name from the left (the logo
stays) so it is not shown twice.
2026-10-18 20:16:23 +02:00
openhands e7a6b2b602 style(home): tidy hero spacing and top-bar online badge
CI / check (push) Successful in 28s
CI / tests-imaging-browsers (push) Successful in 1m32s
CI / tests-integration (push) Successful in 1m49s
CI / tests-unit (push) Successful in 1m55s
CI / tests-quality (push) Successful in 50s
CI / tests-ui (push) Successful in 5m1s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m24s
Drop the orphaned h1 margin left over from the removed hero online chip, and
the stray title attribute on the top-bar online indicator.
2026-10-18 20:09:54 +02:00
openhands 4b25292ecc feat(site): hide global chrome on landing pages
CI / check (push) Successful in 28s
CI / tests-imaging-browsers (push) Successful in 2m7s
CI / tests-unit (push) Successful in 2m23s
CI / tests-integration (push) Successful in 2m22s
CI / tests-quality (push) Successful in 52s
CI / tests-ui (push) Successful in 5m14s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 3m43s
The index, login and register render their own AuthTopBar, so the shared
TopHeader / SiteHeader banner / Navigation duplicated the same chrome. Hide the
global chrome on those three standalone landing pages only.
2026-10-18 19:58:53 +02:00
openhands b40293bd06 feat(home): clean shared top bar with live online counter
CI / check (push) Successful in 28s
CI / tests-imaging-browsers (push) Successful in 1m34s
CI / tests-integration (push) Successful in 1m57s
CI / tests-unit (push) Successful in 2m1s
CI / tests-quality (push) Successful in 50s
CI / tests-ui (push) Successful in 4m39s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 3m29s
The homepage duplicated the online count and recent-user avatars (hero plus
community sidebar), and the public pages each kept their own chrome. Put one
AuthTopBar at the top of index, login and register — logo + hotel name plus a
live online indicator and the language/clock/theme controls — and drop the
duplicated hero rows.
2026-10-18 19:38:32 +02:00
openhands 9cf1eb615c feat(auth): make password length configurable via env (min 5–30, max 30–128)
CI / check (push) Successful in 29s
CI / tests-imaging-browsers (push) Successful in 1m46s
CI / tests-integration (push) Successful in 1m47s
CI / tests-unit (push) Successful in 1m49s
CI / tests-quality (push) Successful in 44s
CI / tests-ui (push) Successful in 4m38s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 3m38s
Add PASSWORD_MIN_LENGTH / PASSWORD_MAX_LENGTH (validated in src/env.ts) and a
shared passwordPolicy helper. The policy drives the zod schemas in register,
password reset and the admin create-user dialog, plus the live requirement
checks and strength meter in the register form.

Locale messages now interpolate the configured minimum via {min} instead of
hard-coding 12.
2026-10-18 19:20:38 +02:00
openhands aef0939ee9 refactor(auth): share the auth landing and extract AuthField
CI / check (push) Successful in 36s
CI / tests-imaging-browsers (push) Successful in 1m36s
CI / tests-integration (push) Successful in 1m52s
CI / tests-unit (push) Successful in 1m57s
CI / tests-quality (push) Successful in 52s
CI / tests-ui (push) Successful in 4m46s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 3m40s
The login and register pages were near-identical after the previous cleanup,
and both forms repeated the same labelled, icon-prefixed input seven times.
Move the data loading and page shell into auth-landing.tsx (loadAuthLanding +
AuthLandingView) and the input markup into a single AuthField, so the pages
become thin compositions and every field shares one focus, icon and aria
implementation. The numbered section headers collapse into AuthSectionHeader.

No behaviour change.
2026-10-18 19:01:40 +02:00
openhands c6e5e0b1a9 feat(auth): unify and polish login, register and homepage auth UI
CI / check (push) Successful in 35s
CI / tests-imaging-browsers (push) Successful in 1m34s
CI / tests-integration (push) Successful in 2m1s
CI / tests-unit (push) Successful in 2m11s
CI / tests-quality (push) Successful in 52s
CI / tests-ui (push) Successful in 5m7s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 4m11s
Login and register each carried their own copy of the promo panel and the
error banner, so the two pages drifted apart and every caller had to wire
role="alert" by hand. Extract a shared AuthPromoCard and AuthAlert, use them
in both forms and for the login notices, and link errors to their fields via
aria-invalid / aria-describedby.

Focus now lands on the 2FA code field the moment it appears, the credential
fields lock while a request is in flight, and invalid inputs get a danger
tint. The homepage top bar was a byte-for-byte duplicate of AuthTopBar; it
now renders the shared component, and the compact homepage login uses the
tighter spacing its comment already promised.
2026-10-18 18:49:18 +02:00
openhands 2249303dbd chore(lint): remove dead eslint-disable directives
CI / check (push) Successful in 30s
CI / tests-imaging-browsers (push) Successful in 1m31s
CI / tests-unit (push) Successful in 1m45s
CI / tests-integration (push) Successful in 1m49s
CI / tests-quality (push) Successful in 47s
CI / tests-ui (push) Successful in 4m24s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 3m39s
The project lints with Biome only, so none of these 78 directives across 57
files ever suppressed anything. Directives that carried the rationale for a
flagged dynamic index access keep that rationale as a plain comment.
2026-10-18 14:50:51 +02:00
openhands 07c5904879 test: make coverage opt-in and ratchet thresholds to the measured floor
Coverage was enabled by default, which forced v8 instrumentation on every run
and made a focused `vitest run <file>` fail the global thresholds that a
single file can never satisfy. `pnpm test:coverage` now opts in explicitly
while `pnpm test` stays fast.

Thresholds ratcheted from 36/29/30/37 to the measured full-suite floor of
37/30/31/38, so a drop in coverage fails again. Also drops a stale comment
about a parent Laravel repo and a dead .next.prev exclude from the config.
2026-10-18 14:50:50 +02:00
openhands 4776a41c01 fix(env): align suggested DB pool size with MariaDB max_connections
The example .env suggested DATABASE_POOL_SIZE=150 and connection_limit=150.
MariaDB defaults max_connections to 151, so a single CMS instance could claim
the server's entire connection budget, starving the emulator and any second
instance. The README already documents a pool size of 25; the example now
matches and notes that DATABASE_POOL_SIZE is the value that actually applies.
2026-10-18 14:50:33 +02:00
openhands a32cf6119b perf(build): measure the homepage and fix optimizePackageImports
/ is the highest-traffic route but was missing from both the public-route
instrumentation and the JS size budgets, so a payload regression there could
never fail the build. It is now wrapped in withPublicPagePerformance and
budgeted alongside the other public routes.

optimizePackageImports listed date-fns, which is not a dependency (a no-op),
while omitting the barrel-heavy packages that are actually imported: motion,
@dnd-kit/core, @dnd-kit/sortable and @tanstack/react-virtual.
2026-10-18 14:50:32 +02:00
openhands bb3799f705 perf(i18n): stop serializing the full catalogue on public pages
The root layout fed the entire ~254 KB message catalogue to
NextIntlClientProvider, which serializes it into the RSC payload of every
page. A public page only reads ~59 KB of it; the ~195 KB difference is staff
tool copy that only /admin, /admin-next and /mod ever render.

The root provider now installs the reduced catalogue and each staff route
group re-installs the full one, since a nested NextIntlClientProvider replaces
the parent message set rather than merging into it. pages.admin.actions stays
public because the shared useServerAction hook runs on public pages too.

Measured: 253 KB -> 59 KB of messages per public page (raw JSON).

Guard tests cover all three halves: the root keeps using the reduced
catalogue, every staff layout installs its own full provider, and no public
tree reads a staff-only namespace.
2026-10-18 14:50:26 +02:00
openhands d28dde459a perf(auth): memoize auth() per request with React cache
A single authenticated render called auth() 4-5 times (access guard, site
layout, both permission loaders, the page itself), and every call re-parsed
the session cookie, decoded and HMAC-verified the JWT and ran the jwt/session
callbacks. Wrapping NextAuth's handler in React's cache scopes that work to
one verification per request.

Outside a render (scripts, jobs worker) React's cache is a pass-through, so
those call sites behave exactly as before.
2026-10-18 14:49:34 +02:00
118 changed files with 1294 additions and 1191 deletions

No files matched your search

+11 -2
View File
@@ -3,8 +3,13 @@
# ==============================================================================
# --- DATABASE (High Performance Pooling & Strict Timeouts) ---
DATABASE_URL="mysql://user:password@localhost:3306/dbname?charset=utf8mb4&connection_limit=150&connect_timeout=5"
DATABASE_POOL_SIZE=150
# The pool size is governed by DATABASE_POOL_SIZE (src/lib/db.ts); the
# `connection_limit` in the URL is only honoured by tools that read the URL
# directly (drizzle-kit, scripts). Keep both in step and keep them well below
# MariaDB's `max_connections` (151 by default) or the emulator and any second
# CMS instance get "Too many connections". README §performance: pool size 25.
DATABASE_URL="mysql://user:password@localhost:3306/dbname?charset=utf8mb4&connection_limit=25&connect_timeout=5"
DATABASE_POOL_SIZE=25
DATABASE_IDLE_TIMEOUT_MS=60000
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=
# Bcrypt cost factor for new password hashes.
BCRYPT_COST=12
# Password-length policy for every new password (register, password reset and
# staff-created accounts). Minimum: 5-30. Maximum: 30-128.
PASSWORD_MIN_LENGTH=12
PASSWORD_MAX_LENGTH=128
# --- ANTI-DDOS (app-layer gate, production only) ---
# On by default in production. Set to "false" to disable (not recommended).
+9 -1
View File
@@ -158,7 +158,15 @@ const nextConfig: NextConfig = {
},
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,
hideLogsAfterAbort: true,
},
+14 -14
View File
@@ -18,8 +18,8 @@
"format": "biome format --write .",
"diag:permissions": "tsx scripts/diagnose-permission-page.ts",
"jobs:worker": "node --conditions=react-server --import tsx scripts/jobs-worker.ts",
"test": "bash scripts/with-memory-cap.sh 8g vitest run --coverage.enabled=false",
"test:coverage": "bash scripts/with-memory-cap.sh 8g vitest run",
"test": "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",
"db:generate": "drizzle-kit generate",
"db:introspect": "drizzle-kit introspect",
@@ -34,7 +34,7 @@
"db:studio": "drizzle-kit studio",
"gamedata:compress": "node scripts/compress-gamedata.mjs",
"hk:matrix:check": "tsx scripts/verify-housekeeping-matrix.ts",
"test:housekeeping": "bash scripts/with-memory-cap.sh 8g vitest run --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",
"deps:audit": "pnpm audit --audit-level=high",
"analyze": "pnpm assets:editor && bash scripts/with-memory-cap.sh 10g next build --webpack && node scripts/performance-report.mjs --output-dir build-reports",
@@ -56,9 +56,9 @@
"@dnd-kit/sortable": "10.0.0",
"@dnd-kit/utilities": "3.2.2",
"@formatjs/icu-messageformat-parser": "3.5.21",
"@hookform/resolvers": "5.9.2",
"@tanstack/react-query": "5.104.4",
"@tanstack/react-virtual": "3.14.21",
"@hookform/resolvers": "5.9.1",
"@tanstack/react-query": "5.104.3",
"@tanstack/react-virtual": "3.14.19",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "1.1.1",
@@ -70,12 +70,12 @@
"jpeg-js": "0.4.4",
"jsonc-parser": "3.3.1",
"jszip": "3.10.2",
"lucide-react": "1.59.0",
"lucide-react": "1.57.0",
"lzma-wasm": "1.0.7",
"motion": "14.3.0",
"music-metadata": "12.2.2",
"motion": "14.2.0",
"music-metadata": "12.2.1",
"mysql2": "3.24.6",
"next": "16.4.2",
"next": "16.4.1",
"next-auth": "5.0.0-beta.32",
"next-intl": "4.14.9",
"otplib": "13.5.0",
@@ -83,7 +83,7 @@
"react": "19.3.1",
"react-dom": "19.3.1",
"react-hook-form": "7.90.0",
"resend": "6.34.1",
"resend": "6.33.0",
"server-only": "0.0.1",
"sharp": "^0.35.5",
"sonner": "2.0.8",
@@ -92,7 +92,7 @@
"zod": "4.6.7"
},
"devDependencies": {
"@axe-core/playwright": "4.14.0",
"@axe-core/playwright": "4.13.0",
"@babel/parser": "8.0.7",
"@biomejs/biome": "2.5.15",
"@playwright/test": "1.64.1",
@@ -102,7 +102,7 @@
"@types/node": "26.6.5",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitest/coverage-v8": "5.0.5",
"@vitest/coverage-v8": "5.0.4",
"drizzle-kit": "0.31.11",
"esbuild": "0.28.2",
"msw": "^3.0.3",
@@ -113,6 +113,6 @@
"tsx": "4.23.15",
"typescript": "7.0.2",
"vite": "8.3.4",
"vitest": "5.0.5"
"vitest": "5.0.4"
}
}
+136 -151
View File
@@ -189,14 +189,14 @@ importers:
specifier: 3.5.21
version: 3.5.21
'@hookform/resolvers':
specifier: 5.9.2
version: 5.9.2([email protected](@types/[email protected])([email protected]))([email protected])
specifier: 5.9.1
version: 5.9.1([email protected](@types/[email protected])([email protected]))([email protected])
'@tanstack/react-query':
specifier: 5.104.4
version: 5.104.4(@types/[email protected])([email protected])
specifier: 5.104.3
version: 5.104.3([email protected])
'@tanstack/react-virtual':
specifier: 3.14.21
version: 3.14.21([email protected]([email protected]))([email protected])
specifier: 3.14.19
version: 3.14.19([email protected]([email protected]))([email protected])
class-variance-authority:
specifier: 0.7.1
version: 0.7.1
@@ -231,29 +231,29 @@ importers:
specifier: 3.10.2
version: 3.10.2
lucide-react:
specifier: 1.59.0
version: 1.59.0(@types/[email protected])([email protected])
specifier: 1.57.0
version: 1.57.0(@types/[email protected])([email protected])
lzma-wasm:
specifier: 1.0.7
version: 1.0.7
motion:
specifier: 14.3.0
version: 14.3.0([email protected]([email protected]))([email protected])
specifier: 14.2.0
version: 14.2.0([email protected]([email protected]))([email protected])
music-metadata:
specifier: 12.2.2
version: 12.2.2
specifier: 12.2.1
version: 12.2.1
mysql2:
specifier: 3.24.6
version: 3.24.6(@types/[email protected])
next:
specifier: 16.4.2
version: 16.4.2(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
specifier: 16.4.1
version: 16.4.1(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
next-auth:
specifier: 5.0.0-beta.32
version: 5.0.0-beta.32([email protected].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:
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:
specifier: 13.5.0
version: 13.5.0
@@ -270,8 +270,8 @@ importers:
specifier: 7.90.0
version: 7.90.0(@types/[email protected])([email protected])
resend:
specifier: 6.34.1
version: 6.34.1
specifier: 6.33.0
version: 6.33.0
server-only:
specifier: 0.0.1
version: 0.0.1
@@ -292,8 +292,8 @@ importers:
version: 4.6.7
devDependencies:
'@axe-core/playwright':
specifier: 4.14.0
version: 4.14.0([email protected])
specifier: 4.13.0
version: 4.13.0([email protected])
'@babel/parser':
specifier: 8.0.7
version: 8.0.7
@@ -322,8 +322,8 @@ importers:
specifier: 19.3.0
version: 19.3.0(@types/[email protected])
'@vitest/coverage-v8':
specifier: 5.0.5
version: 5.0.5([email protected].5)
specifier: 5.0.4
version: 5.0.4([email protected].4)
drizzle-kit:
specifier: 0.31.11
version: 0.31.11
@@ -355,8 +355,8 @@ importers:
specifier: 8.3.4
version: 8.3.4(@types/[email protected])([email protected])([email protected])([email protected])([email protected])
vitest:
specifier: 5.0.5
version: 5.0.5(@types/[email protected])(@vitest/[email protected].5)([email protected](@noble/[email protected]))([email protected](@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
specifier: 5.0.4
version: 5.0.4(@types/[email protected])(@vitest/[email protected].4)([email protected](@noble/[email protected]))([email protected](@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
packages:
@@ -386,9 +386,8 @@ packages:
nodemailer:
optional: true
'@axe-core/[email protected]4.0':
resolution: {integrity: sha512-SGUhG5PIHd2eMdpgBF2PyQLOhX+qyDR2PT4cmoS4Qk2ak+jX/rrSvst9l0PGv5WTHolKpUMvVmoHpr4cZgf1RQ==}
engines: {node: '>=22.12.0'}
'@axe-core/[email protected]3.0':
resolution: {integrity: sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==}
peerDependencies:
playwright-core: '>= 1.0.0'
@@ -968,8 +967,8 @@ packages:
engines: {node: '>=6'}
hasBin: true
'@hookform/[email protected].2':
resolution: {integrity: sha512-RtjTnZqsC38s4NKp2XO3fD8IA5uyN4tB7McQ5C6uiMPcV+WWO8F+wVa3e+BXUpH2ScgxghAlwFRMVqjT9WChAQ==}
'@hookform/[email protected].1':
resolution: {integrity: sha512-7b7vsbraJxKgjVSA1Nur9tLwj539WGJUBLA7QNvXnFoT2pM5Z7G+6rlukk4B2/QrTZy6huRtH6wKeESPKuIr6w==}
peerDependencies:
'@sinclair/typebox': '>=0.25.24'
'@standard-schema/spec': ^1.0.0
@@ -979,7 +978,7 @@ packages:
ajv-errors: ^3.0.0
ajv-formats: ^2.1.1
arktype: ^2.0.0
ata-validator: ^1.23.0
ata-validator: ^1.2.0
class-transformer: '>=0.4.0'
class-validator: '>=0.12.0'
computed-types: ^1.0.0
@@ -1279,57 +1278,57 @@ packages:
resolution: {integrity: sha512-lZBylf8kTt096RGnnVfHq+5w4DTRgmFjf59/IVMPBBwKrViumV1l95LZ/LrWt1LZlsJymHOSnoP8mX/kPXZPWw==}
engines: {node: '>=22'}
'@next/[email protected].2':
resolution: {integrity: sha512-nTUTFH7G5/J/fmpkjVLegOkqyKOP0CEsJcMsyO4UCYeN1su+YMW/MfaSErnJskL7XM+UDK6aTvslxsRCyya/sA==}
'@next/[email protected].1':
resolution: {integrity: sha512-5sUUrrSvs/2/2LnLoesybMGx12EtPB46fdZ6RhAw0XDGSPxU2LFyQBB7yuEiOFFJPKmBY6+q2jTr1/AMbnp0/g==}
'@next/[email protected].2':
resolution: {integrity: sha512-ONkhFvZNFWlsEHOM/81AtxdyuJP2KRYKYR0JxPwvcu1AuPMMMgcL+wW2luldJvMtZwKtUxoSkCychW5wIeoYSw==}
'@next/[email protected].1':
resolution: {integrity: sha512-U+hQ5Gj3fpzf4fveTIF3dpUFYhaSW8vHhdAnbiIomzXzg7boreYFgBqprepXFtW4sw+aTttiNmg0gew/iFNhbg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@next/[email protected].2':
resolution: {integrity: sha512-luPCMbOsCoaF+kXJEUjmjmOZg1lC3BflhmUu+ZmW4tBu2vW/4AXak8JBFvePizGkxVmNiAw/r9ra+6UviCWtWQ==}
'@next/[email protected].1':
resolution: {integrity: sha512-/hqC3V1XLUjhMASf/WridKdO9FXoEgN3HBWmtKbj00MPgnltutfraWJ3bYTpCRIgEIpbjJyOVLpYgAhh/BkdcQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@next/[email protected].2':
resolution: {integrity: sha512-xhwFTAncJyaCNy5KqKKuKaIIdCy1/1ZV1s5S1G0hhT7wGMkDTlfJ6aQbfRFnIG/s3/kDrxQ8aycVe+c5QnP8ow==}
'@next/[email protected].1':
resolution: {integrity: sha512-GHzncHbcaS9g95kcvTNtKQW3aCLoR+xc3Jm7qrkpk1R+D2BjMHIPyvQGRPKCdMX95BsH/421jBUK06q9tEwzjw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/[email protected].2':
resolution: {integrity: sha512-T1PNmfbPcxr5ndtWUL/3wQtLXsz808lq6sc2GrNT5k2oquF7UyqZddHSuAewHVsWsGXe6nsl9HoN1YNriVjuSg==}
'@next/[email protected].1':
resolution: {integrity: sha512-1SuJyDxW/PR9dHJat/ZRgcYLhuSZ+1psP4DlKb2tKIuXbjygqpLrJWRSq+onZQBv8LcmU67EIrmb6c3XTaJLjw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/[email protected].2':
resolution: {integrity: sha512-N9cDUOCGtkJG5PNKDFZUF3xXndrJJcpLp121uAGjIGvO/U2p9tfdNOM9htj7+5f72l0m8rMw/cvYacUJBQoudg==}
'@next/[email protected].1':
resolution: {integrity: sha512-Istng1hMGp9TGO4lUqeKLd70MPN/OOkucVTP2Nmj+gLkwBgmPqUw8CJYBCC7nbl/B/23bBCcjCI5Hsh6hazC1A==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/[email protected].2':
resolution: {integrity: sha512-yY2uh1YhX6ufuYy2FCbw1dqblbhI57GLYAJfCkqD9auTv6pLrH67Uccjc3QMKBeCYAG6lkgu08ExmX1bbjejjQ==}
'@next/[email protected].1':
resolution: {integrity: sha512-aSC9wMNiZRRFVpPkPWBzRwLQxIGm/CiQcXyFF1XNcgodb+uMxdk7aSSGGpDXCbnvZdf4itjHxpOd5h7i7zaiYw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/[email protected].2':
resolution: {integrity: sha512-j/ArWRIHpMMB6wYLMhr2puTguBt70lNU/Cmjl9p6dTDE8DGknYJMP9PdwPk2dqpCNL/KIX9yfngDvIesxJPmsg==}
'@next/[email protected].1':
resolution: {integrity: sha512-t/myvwFDlypMUjRvbBf8qLPyuaB0iXYYdBtwMt6DM6f+60wtGr4bSYholdPqNeV5VUXZhrpMwfza829kmhS3iA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@next/[email protected].2':
resolution: {integrity: sha512-AA4edyWHDko3NCSo0x6GomsdgFb0oR1J3MXU1b5xt6FRFg4AucrMPW3S0hCe/9onU0O1BbpGQBsqo1r0Gneu1g==}
'@next/[email protected].1':
resolution: {integrity: sha512-6ICqPo9awCkrtYNPhwkx1uU7rlFWrZRrIboDHS2LU+jv088UBYQtmJ25h6pCp27/+FZjF6BQ4Te4ErugyMeuBg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -1953,26 +1952,22 @@ packages:
peerDependencies:
tailwindcss: '>=3.0.0 || >=4.0.0 || insiders'
'@tanstack/[email protected].4':
resolution: {integrity: sha512-nU4Yrb8Sjn0DeVb7PKG9uiepNavys3yoxy1FX0hViY4/P6Ng/mBje2SMl7i9812rtyHKtHyqiaC/td6GkI8StA==}
'@tanstack/[email protected].3':
resolution: {integrity: sha512-DZY9TuJAOgU3UyHUhXb4CcTYEHNZszlwkPg4VViIwTxOKzLprPUU+rbaPZQd3srqkGOtpaDdKpW972UV/LKlcg==}
'@tanstack/[email protected].4':
resolution: {integrity: sha512-YD5QkvUNy8Ey3LbOy2vwL97JjoYycwDv++cWAibTe+9BLYvl3c09XpYp5dZ2VcxcguwoDhGF+utYJYb79RB36w==}
'@tanstack/[email protected].3':
resolution: {integrity: sha512-5Ts1ImwJKqoJgPkLxl9WrPQy1JEoEmti3MaaODCfBamHUGFk57IFrc+AIksPq+BAm10EsroX56qm398Z42LG7w==}
peerDependencies:
'@types/react': ^18 || ^19
react: ^18 || ^19
peerDependenciesMeta:
'@types/react':
optional: true
'@tanstack/[email protected].21':
resolution: {integrity: sha512-wWE/ZJDNNDp18JHWy7n5PQooqerHePVkEEu6Op0twKQ2InF9DPhYMQptQY0syDEviMd3/r8D0OUvKJibDGm9HQ==}
'@tanstack/[email protected]9':
resolution: {integrity: sha512-zmVdJO8DDqHphZ2H6SnTmgpVXcUy5DprB+clZF0ik679/d0MQ1UPEb2jtaX6F5VTAX4o3ozSLQaRCnybS2KYLA==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/[email protected].6':
resolution: {integrity: sha512-CsTW892z23gy7IyxYTTorVu1uZWB+0N349rSMgKz4rFLPpLza7F7TpcCKeI5iUAwYQCt8QUlOU67J+KXCxtpGw==}
'@tanstack/[email protected].4':
resolution: {integrity: sha512-ygZ3O99Il18dl0gG0q7sH78iSkb96F07G+xK9V83eCgV8NQbcNd7R+cgcJJMFO7llBoebd0A82sCCstQq2Vp6Q==}
'@tokenizer/[email protected]':
resolution: {integrity: sha512-56U2O6fDLFtqokwWh597IdeUgtM7j+TT9tb1soybhgA6cy163gAcVmoQr8H4Ud/CkgCkViwcuLyqtn6wYqw59Q==}
@@ -2151,11 +2146,11 @@ packages:
cpu: [x64]
os: [win32]
'@vitest/[email protected].5':
resolution: {integrity: sha512-4KpkGdIrSqBihx/JyP/e+S+IKB98etg1XDQiLAxksLfcShnwi1VReGeOT9NI43hLHbknosKp/1CCLn9Zgcs8mg==}
'@vitest/[email protected].4':
resolution: {integrity: sha512-P5YcQY8Y6RmoE9xRXI38rT8pssbcubEzqtYTM2XQoxOXMDn04nRaTI1SM8qCl9/NfW3ag9GlzdSXu7IwO5Ft8A==}
peerDependencies:
'@vitest/browser': 5.0.5
vitest: 5.0.5
'@vitest/browser': 5.0.4
vitest: 5.0.4
peerDependenciesMeta:
'@vitest/browser':
optional: true
@@ -2168,8 +2163,8 @@ packages:
resolution: {integrity: sha512-gUsfXZJbzPamoIY5TvHFiMMoXESBrUMo+xqaj+rYrWI69+EnvRlYBlP96ZnHPY4vX8kyUpgAnFUCg5wZG/HkDQ==}
engines: {node: '>=22'}
'@vitest/[email protected].5':
resolution: {integrity: sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==}
'@vitest/[email protected].4':
resolution: {integrity: sha512-tqAlrYWce4iUxhr3ncNohejtCTOcHeE8XWrSNZY3OhPoDn01yeGv+gnaBy64ATp7o6pb03eIbBAjzmibpc9vIQ==}
peerDependencies:
msw: ^2.4.9 || ^3.0.0
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
@@ -2179,8 +2174,8 @@ packages:
vite:
optional: true
'@vitest/[email protected].5':
resolution: {integrity: sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==}
'@vitest/[email protected].4':
resolution: {integrity: sha512-MZKk/gSkm2q+ICobMxCpH1mA+cCAWm8WV7UhxlqGjq/sJ3AGc1BqFOs5CRVXxH78wQtme9q/NAAgBquj4AcyMw==}
[email protected]:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
@@ -2238,8 +2233,8 @@ packages:
resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==}
engines: {node: '>= 6.0.0'}
[email protected]4.0:
resolution: {integrity: sha512-9WTZxEjsZ7b13TH8JPmbV2z8CHbl80/2hm3XPEG4JgNdQLK81IBRXmSxHfMAOkSqQeRxT/0dwNDz2GOm3zzpcQ==}
[email protected]3.0:
resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==}
engines: {node: '>=4'}
[email protected]:
@@ -2674,8 +2669,8 @@ packages:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
framer-motion@14.3.0:
resolution: {integrity: sha512-d8WCrU0ZQn7ukKMykCsDyIOwb/taZeDqqrDBK0du5GO0AO/ynz8fdR/LIIz9Pyy3n3tGwrneR+n0egyrrLa3fw==}
framer-motion@14.2.0:
resolution: {integrity: sha512-cD2gfFujHt4r/+dZpcn5yziu7VRwQAQDakwIm/bICYXbafnpHCYokV+PnEXqErnSdv6Eh28a+SRF+f3ndGcMsQ==}
peerDependencies:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
@@ -3000,8 +2995,8 @@ packages:
resolution: {integrity: sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==}
engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'}
[email protected]9.0:
resolution: {integrity: sha512-5qgMyyCPU6OvnKpLS2t693+ZtiqA8oaSweWoUows0BLFKFf6V75m4+CptpmohZHpD5IuOVIBXHBBiv2vpLJmtw==}
[email protected]7.0:
resolution: {integrity: sha512-bXFZEucYhAzLD62Jse76AbTR6LiSLTn3Tqir2f1n8rZrWAdLodYy7jnRZSPIT89dRLk/IseMOUWG82wJtTyTTw==}
peerDependencies:
'@types/react': '*'
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
@@ -3056,14 +3051,14 @@ packages:
engines: {node: '>=10'}
hasBin: true
motion-dom@14.3.0:
resolution: {integrity: sha512-rcKO4aEJzAqDEsrH6qFzhLZxs7bNqZpiUEuEATz9LaRwt6dG7hZEbcFUSHNR8NECaKYM0Iuw6Iv6NpWEGXDI+w==}
motion-dom@14.2.0:
resolution: {integrity: sha512-TDdxnjAavh2Qm4lQCQI3uoTx9IR/Id2PI8JNEXW3AcwwV2yvhoimAX3/TnE+x82gDHfPq2yx/cJ58TTw5mMr0A==}
[email protected]:
resolution: {integrity: sha512-3VvN5Uvf8B6qG+OfOui6Nht5EWp+vLuU3IqcAj7ItZGKqtmTjCebl5sGZF7/08gEHRnEl1AP/fIwbcEyVrcQGQ==}
motion@14.3.0:
resolution: {integrity: sha512-yh/nHYjikl68+FTxAMywFYzzj21/oGY7RGCfkqN5zpuT1rk3rp0lKfVU8EUq0A64qQeGnxuKUJWP85MipqdLBw==}
motion@14.2.0:
resolution: {integrity: sha512-45cDwjZh0yOMZtnX3hIFPIPmzCWz/RC2bD/niWVIssGBjJlMmosbDILG9a+ZyoP635Z5xrbKvuo10spv9dBdnw==}
peerDependencies:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
@@ -3092,8 +3087,8 @@ packages:
vite:
optional: true
[email protected].2:
resolution: {integrity: sha512-m0fMlp+9snT8Gte3t99fMXpAeS0oRWVLzbj5OZd8zrPuP4RTQ/+VTaxgiz1igrJ/8NwhzdRNk/38aDVK/5N0Ow==}
[email protected].1:
resolution: {integrity: sha512-JB/bnmQAa8HiKHuIg9dupGFjCKpdsocm9nJrTQe31f5zTaKfJmTwJwOvH1yVL91/jwFNKTOAOIPrHo3L5XwGfQ==}
engines: {node: '>=22'}
[email protected]:
@@ -3151,8 +3146,8 @@ packages:
'@types/react':
optional: true
[email protected].2:
resolution: {integrity: sha512-r+oBJojfefUjOiWkBhXgLS+igxX/7BWyK59uHnyOyplMSLtBZ+QLP3okrdYTbrfrbrmNTVWLBPaHDX9KRaIt3w==}
[email protected].1:
resolution: {integrity: sha512-uc657V62E34fOI6W5NtG9IzwsPcms0Z60grz2MJGtglgD+pTFnNt4Yw2PzaU622tfX4vhrDgLZzicE2YlhI20Q==}
engines: {node: '>=20.9.0'}
hasBin: true
peerDependencies:
@@ -3385,8 +3380,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==}
[email protected]4.1:
resolution: {integrity: sha512-3jo/j1WAW7CBkE6pzK4qLjZCfHRLIhY3ph3x/wzU+MN51cSw3EtReGce8S1cA1R0ipNDOdFYU5Lmt5r563lvYw==}
[email protected]3.0:
resolution: {integrity: sha512-sbpWLrwlaIDZuGHRYNX6101CWtXLqRNcT1S9c8BB357Ab2+IUJ7dNpYffVmGh2C11/4AfcFvM4ltURH8NJlPGw==}
engines: {node: '>=20'}
peerDependencies:
'@react-email/render': '*'
@@ -3551,10 +3546,6 @@ packages:
resolution: {integrity: sha512-PgAsgIuizbZN/r4+g4UNvbVCTpJWdoIdI71kIWwKmdIH0vJ0q7ylMVGU8PWj2s9O98y4MtFWiDTIrtNukZMMgQ==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-p8ynRS8lXnvA3tsdcUYFjbzwh2MsdBZgxrFT2G+Yb7vNvUfPF+GgHCt/0jkayfsRXA1TwtZstf+JA0V+iQXdHA==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
engines: {node: '>= 12.0.0'}
@@ -3768,20 +3759,20 @@ packages:
yaml:
optional: true
[email protected].5:
resolution: {integrity: sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==}
[email protected].4:
resolution: {integrity: sha512-z0WxZRV2Sg48wF0FSgMq3TFG8JS38/Qr96vZVZdX6+Em9raQTvnXQvdTFJe2m3rfyJnEHeHQ/snceIdi8D1pVg==}
engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0
'@types/node': ^22.0.0 || >=24.0.0
'@vitest/browser-playwright': 5.0.5
'@vitest/browser-preview': 5.0.5
'@vitest/browser-playwright': 5.0.4
'@vitest/browser-preview': 5.0.4
'@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0
'@vitest/coverage-istanbul': 5.0.5
'@vitest/coverage-v8': 5.0.5
'@vitest/ui': 5.0.5
'@vitest/coverage-istanbul': 5.0.4
'@vitest/coverage-v8': 5.0.4
'@vitest/ui': 5.0.4
happy-dom: '*'
jsdom: '*'
vite: ^6.4.0 || ^7.0.0 || ^8.0.0
@@ -3915,9 +3906,9 @@ snapshots:
preact: 10.24.3
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:
axe-core: 4.14.0
axe-core: 4.13.0
playwright-core: 1.64.1
'@babel/[email protected]': {}
@@ -4297,7 +4288,7 @@ snapshots:
protobufjs: 7.6.6
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:
'@standard-schema/utils': 0.3.0
react-hook-form: 7.90.0(@types/[email protected])([email protected])
@@ -4481,30 +4472,30 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@next/[email protected].2': {}
'@next/[email protected].1': {}
'@next/[email protected].2':
'@next/[email protected].1':
optional: true
'@next/[email protected].2':
'@next/[email protected].1':
optional: true
'@next/[email protected].2':
'@next/[email protected].1':
optional: true
'@next/[email protected].2':
'@next/[email protected].1':
optional: true
'@next/[email protected].2':
'@next/[email protected].1':
optional: true
'@next/[email protected].2':
'@next/[email protected].1':
optional: true
'@next/[email protected].2':
'@next/[email protected].1':
optional: true
'@next/[email protected].2':
'@next/[email protected].1':
optional: true
'@noble/[email protected]': {}
@@ -4961,22 +4952,20 @@ snapshots:
postcss-selector-parser: 7.1.6
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:
'@tanstack/query-core': 5.104.4
'@tanstack/query-core': 5.104.3
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:
'@tanstack/virtual-core': 3.19.6
'@tanstack/virtual-core': 3.19.4
react: 19.3.1
react-dom: 19.3.1([email protected])
'@tanstack/[email protected].6': {}
'@tanstack/[email protected].4': {}
'@tokenizer/[email protected]':
dependencies:
@@ -5109,7 +5098,7 @@ snapshots:
'@typescript/[email protected]':
optional: true
'@vitest/[email protected].5([email protected].5)':
'@vitest/[email protected].4([email protected].4)':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/istanbul-lib-coverage': 1.0.2
@@ -5119,7 +5108,7 @@ snapshots:
obug: 3.0.0
std-env: 4.3.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]': {}
@@ -5127,17 +5116,17 @@ snapshots:
dependencies:
'@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:
'@jridgewell/trace-mapping': 0.3.31
'@vitest/spy': 5.0.5
'@vitest/spy': 5.0.4
estree-walker: 3.0.3
magic-string: 1.4.3
optionalDependencies:
msw: 3.0.3(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
vite: 8.3.4(@types/[email protected])([email protected])([email protected])([email protected])([email protected])
'@vitest/[email protected].5': {}
'@vitest/[email protected].4': {}
[email protected]:
dependencies:
@@ -5201,7 +5190,7 @@ snapshots:
[email protected]: {}
[email protected]4.0: {}
[email protected]3.0: {}
[email protected]: {}
@@ -5566,9 +5555,9 @@ snapshots:
cross-spawn: 7.0.6
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:
motion-dom: 14.3.0
motion-dom: 14.2.0
motion-utils: 14.1.1
tslib: 2.8.1
optionalDependencies:
@@ -5840,7 +5829,7 @@ snapshots:
[email protected]: {}
[email protected]9.0(@types/[email protected])([email protected]):
[email protected]7.0(@types/[email protected])([email protected]):
dependencies:
react: 19.3.1
optionalDependencies:
@@ -5884,15 +5873,15 @@ snapshots:
[email protected]: {}
motion-dom@14.3.0:
motion-dom@14.2.0:
dependencies:
motion-utils: 14.1.1
[email protected]: {}
motion@14.3.0([email protected]([email protected]))([email protected]):
motion@14.2.0([email protected]([email protected]))([email protected]):
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
optionalDependencies:
react: 19.3.1
@@ -5921,7 +5910,7 @@ snapshots:
- '@types/node'
- supports-color
[email protected].2:
[email protected].1:
dependencies:
'@borewit/text-codec': 0.2.2
'@tokenizer/token': 0.3.0
@@ -5929,7 +5918,7 @@ snapshots:
debug: 4.4.3
file-type: 22.2.0
media-typer: 2.0.0
strtok3: 10.6.1
strtok3: 10.5.0
token-types: 6.1.2
uint8array-extras: 1.6.0
win-guid: 0.2.1
@@ -5962,15 +5951,15 @@ snapshots:
dependencies:
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:
'@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
[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:
'@eloqnt/config': 0.1.1
'@eloqnt/format-json': 0.1.0
@@ -5980,7 +5969,7 @@ snapshots:
'@swc/core': 1.16.18(@swc/[email protected])
icu-minify: 4.14.9
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
react: 19.3.1
use-intl: 4.14.9(@types/[email protected])([email protected])
@@ -5989,9 +5978,9 @@ snapshots:
transitivePeerDependencies:
- '@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:
'@next/env': 16.4.2
'@next/env': 16.4.1
'@swc/helpers': 0.5.23
baseline-browser-mapping: 2.11.28
caniuse-lite: 1.0.30001816
@@ -6000,14 +5989,14 @@ snapshots:
react-dom: 19.3.1([email protected])
styled-jsx: 5.1.6([email protected])
optionalDependencies:
'@next/swc-darwin-arm64': 16.4.2
'@next/swc-darwin-x64': 16.4.2
'@next/swc-linux-arm64-gnu': 16.4.2
'@next/swc-linux-arm64-musl': 16.4.2
'@next/swc-linux-x64-gnu': 16.4.2
'@next/swc-linux-x64-musl': 16.4.2
'@next/swc-win32-arm64-msvc': 16.4.2
'@next/swc-win32-x64-msvc': 16.4.2
'@next/swc-darwin-arm64': 16.4.1
'@next/swc-darwin-x64': 16.4.1
'@next/swc-linux-arm64-gnu': 16.4.1
'@next/swc-linux-arm64-musl': 16.4.1
'@next/swc-linux-x64-gnu': 16.4.1
'@next/swc-linux-x64-musl': 16.4.1
'@next/swc-win32-arm64-msvc': 16.4.1
'@next/swc-win32-x64-msvc': 16.4.1
'@playwright/test': 1.64.1
sharp: 0.35.5(@types/[email protected])
transitivePeerDependencies:
@@ -6248,7 +6237,7 @@ snapshots:
[email protected]: {}
[email protected]4.1:
[email protected]3.0:
dependencies:
postal-mime: 2.7.6
standardwebhooks: 1.1.1
@@ -6438,10 +6427,6 @@ snapshots:
dependencies:
'@tokenizer/token': 0.3.0
[email protected]:
dependencies:
'@tokenizer/token': 0.3.0
[email protected]([email protected]):
dependencies:
client-only: 0.0.1
@@ -6661,10 +6646,10 @@ snapshots:
tsx: 4.23.15
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:
'@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
es-module-lexer: 2.3.2
expect-type: 1.4.0
@@ -6679,7 +6664,7 @@ snapshots:
why-is-node-running: 3.2.1
optionalDependencies:
'@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])
transitivePeerDependencies:
- msw
+5
View File
@@ -2,6 +2,11 @@
"schemaVersion": 1,
"mode": "enforce",
"routes": [
{
"route": "/",
"maxInitialRawBytes": 716800,
"maxInitialGzipBytes": 225280
},
{
"route": "/me",
"maxInitialRawBytes": 716800,
-1
View File
@@ -60,7 +60,6 @@ export async function uploadBadge(formData: FormData): Promise<void> {
try {
const buffer = Buffer.from(await file.arrayBuffer());
const gif = await toBadgeGif(buffer);
// eslint-disable-next-line security/detect-non-literal-fs-filename
await writeFile(target, gif);
} catch {
back("error", "Could not process or write the badge file");
+1 -1
View File
@@ -32,7 +32,7 @@ async function upsertSetting(key: string, value: string): Promise<void> {
.values({
key,
value,
// 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,
})
.onDuplicateKeyUpdate({ set: { value } });
+2 -2
View File
@@ -71,10 +71,10 @@ export async function savePoints(formData: FormData): Promise<void> {
POINTS_KEYS.map((key) =>
db
.insert(WebsiteSetting)
// 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" })
.onDuplicateKeyUpdate({
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
// key from POINTS_KEYS const
set: { value: values[key] },
}),
),
+1 -1
View File
@@ -112,7 +112,7 @@ export async function saveTheme(formData: FormData): Promise<void> {
export async function applyPreset(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
const name = String(formData.get("preset") ?? "").normalize("NFC");
// eslint-disable-next-line security/detect-object-injection -- guarded by null check below
// guarded by null check below
const preset = PRESETS[name];
if (!preset) redirect("/admin/theme");
+6 -1
View File
@@ -6,6 +6,7 @@ import { redirect } from "next/navigation";
import { env } from "@/env";
import { invalidateLoginCache } from "@/lib/auth/login-core";
import { hashPassword } from "@/lib/auth/password";
import { passwordPolicy } from "@/lib/auth/password-policy";
import { revokeUserCredentials } from "@/lib/auth/session-revocation";
import { db, PasswordReset, User } from "@/lib/db";
import { logger } from "@/lib/logger";
@@ -109,7 +110,11 @@ export async function resetPassword(formData: FormData): Promise<void> {
}
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) {
try {
+10 -4
View File
@@ -5,6 +5,7 @@ import { after } from "next/server";
import { z } from "zod";
import { sendVerification } from "@/lib/auth/email-verification";
import { hashPassword } from "@/lib/auth/password";
import { passwordPolicy } from "@/lib/auth/password-policy";
import { DEFAULT_AVATAR_FIGURE } from "@/lib/avatar-figure";
import { invalidateKey } from "@/lib/cached-db";
import { db, User } from "@/lib/db";
@@ -83,6 +84,11 @@ function hasDisposableEmailDomain(email: string): boolean {
return domain ? DISPOSABLE_EMAIL_DOMAINS.has(domain) : false;
}
// Rendered into the schema below and mapped back to a locale-independent code
// via `ZOD_MESSAGE_CODES`, so the length always reflects the configured policy.
const PASSWORD_MIN_MESSAGE = `Password must be at least ${passwordPolicy.minLength} characters`;
const PASSWORD_MAX_MESSAGE = "Password is too long";
const registerSchema = z
.object({
username: z
@@ -105,8 +111,8 @@ const registerSchema = z
),
password: z
.string()
.min(12, "Password must be at least 12 characters") // Increased min length
.max(128, "Password is too long") // Added max length
.min(passwordPolicy.minLength, PASSWORD_MIN_MESSAGE)
.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 lowercase letter")
.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",
"Enter a valid email address": "emailValid",
"Temporary email domains are not allowed": "emailDisposable",
"Password must be at least 12 characters": "passwordMinLength",
"Password is too long": "passwordMaxLength",
[PASSWORD_MIN_MESSAGE]: "passwordMinLength",
[PASSWORD_MAX_MESSAGE]: "passwordMaxLength",
"Password must contain at least one uppercase letter": "passwordUpper",
"Password must contain at least one lowercase letter": "passwordLower",
"Password must contain at least one digit": "passwordDigit",
-4
View File
@@ -31,9 +31,7 @@ export async function saveFavicon(
}
const buffer = upload.bytes;
// eslint-disable-next-line security/detect-non-literal-fs-filename
await mkdir(baseDir, { recursive: true });
// eslint-disable-next-line security/detect-non-literal-fs-filename
await writeFile(filePath, buffer);
const url = `/api/media/favicon/${filename}`;
@@ -46,7 +44,6 @@ export async function saveFavicon(
const oldPath = path.resolve(baseDir, oldName);
if (oldPath.startsWith(baseDir + path.sep)) {
try {
// eslint-disable-next-line security/detect-non-literal-fs-filename
await unlink(oldPath);
} catch {
/* ignore if file doesn't exist */
@@ -85,7 +82,6 @@ export async function deleteFavicon(): Promise<{
const oldPath = path.resolve(baseDir, oldName);
if (oldPath.startsWith(baseDir + path.sep)) {
try {
// eslint-disable-next-line security/detect-non-literal-fs-filename
await unlink(oldPath);
} catch {
/* ignore */
-2
View File
@@ -29,9 +29,7 @@ export async function saveLogo(
}
const buffer = upload.bytes;
// eslint-disable-next-line security/detect-non-literal-fs-filename
await mkdir(baseDir, { recursive: true });
// eslint-disable-next-line security/detect-non-literal-fs-filename
await writeFile(filePath, buffer);
const url = `/api/media/logo/${filename}`;
+1 -3
View File
@@ -278,14 +278,12 @@ export async function uploadBackground(formData: FormData): Promise<void> {
let uploaded = false;
try {
// eslint-disable-next-line security/detect-non-literal-fs-filename
await mkdir(MEDIA_ROOT, { recursive: true });
const ext = file.name.split(".").pop() ?? "png";
const name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
const bytes = await file.arrayBuffer();
const filePath = resolveMediaPath(name);
if (filePath.startsWith(MEDIA_ROOT + path.sep)) {
// eslint-disable-next-line security/detect-non-literal-fs-filename
await writeFile(filePath, Buffer.from(bytes));
await writeSetting("background_image", `/api/media/${name}`);
await writeSetting(
@@ -319,7 +317,7 @@ export async function applyPresetManager(formData: FormData): Promise<void> {
const name = String(formData.get("preset") ?? "")
.normalize("NFC")
.trim();
// eslint-disable-next-line security/detect-object-injection -- guarded by null check below
// guarded by null check below
const preset = PRESETS[name];
if (!preset) redirect(MANAGER_PATH);
-1
View File
@@ -142,7 +142,6 @@ export default async function ApplyStaffPage({
<article key={String(position.id)} className="content-card">
<div className="content-card-head">
{team?.badge ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={`${BADGE_IMG_BASE}=${encodeURIComponent(team.badge)}`}
alt={rankName}
-1
View File
@@ -120,7 +120,6 @@ export default async function ApplyTeamPage({
<article key={String(team.id)} className="content-card">
<div className="content-card-head">
{team.badge ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={`${BADGE_IMG_BASE}=${encodeURIComponent(team.badge)}`}
alt={team.rankName}
-1
View File
@@ -48,7 +48,6 @@ export default async function BadgesPage() {
alignItems: "flex-start",
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`${BADGE_IMG_BASE}=${encodeURIComponent(badge.badgeKey)}`}
alt={badge.badgeName}
-1
View File
@@ -190,7 +190,6 @@ export default async function DrawBadgePage({
{b.badgeUrl ? (
// Badge images are arbitrary external URLs stored by the
// draw-badge tool, so a plain <img> is correct here.
// eslint-disable-next-line @next/next/no-img-element
<img
src={b.badgeUrl}
alt=""
-1
View File
@@ -149,7 +149,6 @@ export default async function GuildPage({
style={{ display: "flex", gap: "1.25rem", alignItems: "flex-start" }}
>
{guild.badge ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${guild.badge}.gif`}
alt={`${guild.name} badge`}
-1
View File
@@ -74,7 +74,6 @@ export default async function GuildsPage() {
}}
>
{g.badge ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${g.badge}.gif`}
alt={`${g.name} badge`}
-1
View File
@@ -110,7 +110,6 @@ export default async function HelpCategoryPage({
<ContentCard icon="❓" title={title} subtitle={t("topicSubtitle")}>
{imageSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={imageSrc}
alt=""
-1
View File
@@ -159,7 +159,6 @@ export default async function HelpCenterPage() {
}}
>
{c.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={c.imageUrl}
alt=""
+27 -11
View File
@@ -1,4 +1,5 @@
import dynamic from "next/dynamic";
import { headers } from "next/headers";
import { NextIntlClientProvider } from "next-intl";
import { getLocale, getMessages } from "next-intl/server";
import type { ReactNode } from "react";
@@ -34,28 +35,43 @@ export default async function SiteLayout({
}: {
children: ReactNode;
}) {
const [session, locale, messages] = await Promise.all([
const [session, locale, messages, h] = await Promise.all([
auth(),
getLocale(),
getMessages(),
headers(),
]);
const clientMessages = publicClientMessages(messages);
// The landing pages render their own AuthTopBar (logo + live online), so the
// global site banner / navigation is hidden there to keep them clean and
// avoid showing the same chrome twice. Everything else keeps the chrome.
const pathname = h.get("x-pathname");
const isLandingPage =
pathname === "/" || pathname === "/login" || pathname === "/register";
return (
<NextIntlClientProvider locale={locale} messages={clientMessages}>
<CloudsField />
{/* Site chrome is public: hiding it all for anonymous visitors used to
strand them — from /news, /leaderboard or /shop there was no way to
reach any other page at all. */}
<div data-theme-block="top_header">
<TopHeader session={session} />
</div>
<div data-theme-block="site_header">
<SiteHeader />
</div>
<div data-theme-block="navigation">
<Navigation session={session} />
</div>
reach any other page at all. Only the standalone landing pages opt
out, because they carry their own header. */}
{!isLandingPage && (
<div data-theme-block="top_header">
<TopHeader session={session} />
</div>
)}
{!isLandingPage && (
<div data-theme-block="site_header">
<SiteHeader />
</div>
)}
{!isLandingPage && (
<div data-theme-block="navigation">
<Navigation session={session} />
</div>
)}
<main>
<div
data-theme-block="content_grid"
+1 -1
View File
@@ -171,7 +171,7 @@ export default async function LeaderboardPage({
const rows = await (active === "credits"
? loadCreditsRows()
: active === "diamonds" || active === "duckets"
? // 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])
: loadSettingsRows(
SETTINGS_FIELD[active as keyof typeof SETTINGS_FIELD],
+26 -164
View File
@@ -1,24 +1,14 @@
import { desc, eq } from "drizzle-orm";
import type { Metadata } from "next";
import { headers } from "next/headers";
import Image from "next/image";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { AuthAlert } from "@/components/auth/auth-alert";
import {
AuthPageFrame,
AuthUsersCards,
} from "@/components/auth/auth-page-frame";
AuthLandingView,
loadAuthLanding,
} from "@/components/auth/auth-landing";
import { LoginForm } from "@/components/auth/login-form";
import { SurfaceCard } from "@/components/surface-card";
import { auth } from "@/lib/auth";
import { safeRedirectPath } from "@/lib/auth/safe-redirect";
import { cached } from "@/lib/cache";
import { db, User } from "@/lib/db";
import { resolveHotelName } from "@/lib/hotel-name";
import { captchaConfig } from "@/lib/services/captcha";
import { cachedOnlineCount } from "@/lib/services/public-counters";
import { siteSettings } from "@/lib/services/site-settings";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.login");
@@ -43,13 +33,6 @@ export default async function LoginPage({
}>;
}) {
const t = await getTranslations("pages.login");
const [hotelName, cfg, logo] = await Promise.all([
resolveHotelName(),
captchaConfig(),
siteSettings.get("cms_logo", ""),
]);
const nonce = (await headers()).get("x-nonce") ?? undefined;
const sp = await searchParams;
const redirectTo = safeRedirectPath(sp.from);
@@ -58,165 +41,44 @@ export default async function LoginPage({
const session = await auth();
if (session?.user?.id) redirect(redirectTo);
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(() => []),
]);
const data = await loadAuthLanding();
// `/login?registered=1` is where the sign-up form lands when it could not
// auto sign-in (e-mail verification still pending). Without this notice the
// visitor would only see an empty login form and no sign their account
// exists.
// exists. `?reset=1` comes from a successful password reset; saying so
// matters because a silent form reads like the reset failed.
const notice =
sp.registered === "1" ? (
<p
role="status"
className="auth-alert auth-alert--success animate-fade-in-up m-0 mb-4"
>
<AuthAlert variant="success" className="mb-4">
{t("registeredSuccess")}
</p>
</AuthAlert>
) : sp.reset === "1" ? (
// `?reset=1` comes from a successful password reset; saying so matters
// because the visitor just changed their password and a silent form
// reads like the reset failed.
<p
role="status"
className="auth-alert auth-alert--success animate-fade-in-up m-0 mb-4"
>
<AuthAlert variant="success" className="mb-4">
{t("passwordChanged")}
</p>
</AuthAlert>
) : undefined;
return (
<AuthPageFrame
hotelName={hotelName}
logo={logo}
title={t("title")}
subtitle={t("subtitle")}
notice={notice}
<AuthLandingView
data={data}
copy={{
title: t("title"),
subtitle: t("subtitle"),
notice,
promoTitle: t("welcomeBack"),
promoSubtitle: t("welcomeBackSub", { hotelName: data.hotelName }),
usersOnlineLabel: t("usersOnline", { count: data.online }),
whoIsOnlineLabel: t("whoIsOnline"),
newestCitizensLabel: t("newestCitizens"),
}}
form={
<LoginForm
redirectTo={redirectTo}
captcha={{
provider: cfg.provider,
siteKey: cfg.siteKey || undefined,
field: cfg.field || undefined,
}}
nonce={nonce}
captcha={data.captcha}
nonce={data.nonce}
/>
}
>
<SurfaceCard
className="card-glow relative overflow-hidden p-6 text-center"
style={{
borderColor:
"color-mix(in srgb, var(--color-primary) 20%, transparent)",
}}
>
<div
className="absolute inset-0"
style={{
background:
"linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 10%, transparent), color-mix(in srgb, var(--color-accent) 10%, transparent))",
}}
/>
<div aria-hidden="true" className="absolute inset-0 overflow-hidden">
<div className="brand-halo left-1/2 top-0 h-64 w-[460px] max-w-full -translate-x-1/2 -translate-y-1/2" />
<div
className="aurora-blob -left-16 top-10 h-56 w-56"
style={{
background:
"color-mix(in srgb, var(--color-primary) 45%, transparent)",
}}
/>
<div
className="aurora-blob -right-16 bottom-0 h-56 w-56"
style={{
background:
"color-mix(in srgb, var(--color-accent) 40%, transparent)",
animationDelay: "-8s",
}}
/>
</div>
<div className="relative">
<div className="brand-halo left-1/2 top-8 h-52 w-52 -translate-x-1/2" />
<Image
src="/assets/images/FrankwithBag.gif"
alt="Frank"
width={130}
height={170}
className="relative object-contain mx-auto drop-shadow-xl animate-float"
unoptimized
/>
<div
className="inline-flex items-center gap-2 px-3.5 py-1 rounded-full text-xs font-bold uppercase tracking-wider mt-4 mb-3 border"
style={{
borderColor:
"color-mix(in srgb, var(--color-primary) 30%, transparent)",
background:
"color-mix(in srgb, var(--color-surface) 72%, transparent)",
color: "var(--color-primary-readable, var(--color-primary))",
boxShadow:
"0 8px 20px -12px color-mix(in srgb, var(--color-primary) 70%, transparent)",
}}
>
<span className="relative flex h-2 w-2">
<span
className="animate-ping absolute inline-flex h-full w-full rounded-full opacity-75"
style={{ background: "var(--color-primary)" }}
/>
<span
className="relative inline-flex rounded-full h-2 w-2"
style={{ background: "var(--color-primary)" }}
/>
</span>
{t("usersOnline", { count: online })}
</div>
<h1
className="text-xl font-black"
style={{
color: "var(--color-text-readable)",
fontFamily: "var(--font-nunito)",
}}
>
{t("welcomeBack")}
</h1>
<p
className="text-xs mt-1.5 mx-auto max-w-[220px] leading-relaxed"
style={{ color: "var(--color-text-muted)" }}
>
{t("welcomeBackSub", { hotelName })}
</p>
</div>
</SurfaceCard>
<AuthUsersCards
recentUsers={recentUsers}
latestUsers={latestUsers}
recentTitle={t("whoIsOnline")}
latestTitle={t("newestCitizens")}
/>
</AuthPageFrame>
/>
);
}
-1
View File
@@ -191,7 +191,6 @@ async function ArticlePage({
}
>
{article.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={article.image}
alt=""
+10 -163
View File
@@ -7,22 +7,20 @@ import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import type { CSSProperties, ReactNode } from "react";
import { AnimatedCounter } from "@/components/animated-counter";
import { AuthTopBar } from "@/components/auth/auth-top-bar";
import { LoginForm } from "@/components/auth/login-form";
import { Clock } from "@/components/clock";
import { LanguageSwitcher } from "@/components/language-switcher";
import Link from "@/components/link";
import { LiveOnlineCounter } from "@/components/live-online-counter";
import { Reveal } from "@/components/motion-reveal";
import { PublicLoadError } from "@/components/public/load-error";
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
import { SpotlightCard } from "@/components/spotlight-card";
import { ThemeSwitcher } from "@/components/theme-switcher";
import { TypewriterText } from "@/components/typewriter-text";
import { auth } from "@/lib/auth";
import { cached } from "@/lib/cache";
import { CameraWeb, db, User } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { resolveHotelName } from "@/lib/hotel-name";
import { withPublicPagePerformance } from "@/lib/public-page-performance";
import { captchaConfig } from "@/lib/services/captcha";
import { getNewsList } from "@/lib/services/news-list";
import { loadProfilePrivacyMap } from "@/lib/services/profile-privacy";
@@ -40,7 +38,6 @@ import {
ICON_CAMERA,
ICON_CATALOG,
ICON_FRIENDS,
ICON_LIGHTHOUSE,
ICON_NAV_GOODY,
ICON_NAV_ME,
} from "@/lib/site-icons";
@@ -238,7 +235,7 @@ async function getHotelData() {
};
}
export default async function Home() {
async function Home() {
const session = await auth();
if (session?.user?.id) redirect("/me");
@@ -404,88 +401,10 @@ export default async function Home() {
return (
<div className="flex w-full flex-col gap-14 pb-16 pt-2 md:gap-20 md:pb-24">
{/* ── Top bar ── */}
<Reveal className="mx-auto w-full max-w-7xl">
<div
className="flex items-center justify-between rounded-full border px-3 py-2.5 sm:px-5"
style={{
background:
"linear-gradient(90deg, color-mix(in srgb, var(--color-surface) 62%, transparent), color-mix(in srgb, var(--color-primary) 7%, transparent))",
backdropFilter: "blur(14px)",
WebkitBackdropFilter: "blur(14px)",
borderColor:
"color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
boxShadow:
"0 1px 3px rgba(0,0,0,0.04), 0 0 0 0.5px color-mix(in srgb, var(--color-text-muted) 6%, transparent)",
}}
>
<div className="flex items-center gap-2 sm:gap-3">
{logo ? (
<Image
src={logo}
alt={hotelName}
width={100}
height={28}
className="shrink-0 object-contain"
unoptimized
style={{ maxHeight: 26, width: "auto", height: "auto" }}
/>
) : (
<div
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
style={{
background:
"linear-gradient(135deg, var(--color-primary), color-mix(in srgb, var(--color-accent) 60%, var(--color-primary)))",
boxShadow:
"0 4px 16px -4px color-mix(in srgb, var(--color-primary) 60%, transparent)",
}}
>
<Image
src={ICON_LIGHTHOUSE}
alt=""
width={18}
height={18}
className="shrink-0"
unoptimized
style={{ filter: "brightness(0) invert(1)" }}
/>
</div>
)}
<span
className="hidden text-sm font-extrabold uppercase tracking-wide sm:inline"
style={{ color: "var(--color-text-readable)" }}
>
{hotelName}
</span>
</div>
<div
className="flex items-center gap-1 sm:gap-1.5 rounded-full px-1.5 py-1"
style={{
background:
"color-mix(in srgb, var(--color-text-muted) 5%, transparent)",
}}
>
<LanguageSwitcher />
<div
className="hidden w-px h-5 sm:block"
style={{
background:
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
}}
/>
<Clock />
<div
className="w-px h-5"
style={{
background:
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
}}
/>
<ThemeSwitcher />
</div>
</div>
</Reveal>
{/* ── Top bar: logo + live online + language/clock/theme ── */}
<div className="mx-auto w-full max-w-7xl">
<AuthTopBar hotelName={hotelName} logo={logo} online={online} />
</div>
{/* ── Cinematic hero ── */}
<Reveal className="-mx-3 sm:-mx-4 md:-mx-6">
<div className="relative overflow-hidden">
@@ -534,50 +453,7 @@ export default async function Home() {
style={{ opacity: 0.5, zIndex: 1 }}
/>
<div className="relative z-10 mx-auto flex min-h-[540px] w-full max-w-6xl flex-col items-center justify-center px-4 py-24 text-center sm:min-h-[580px] lg:min-h-[640px]">
<span
className="glass-chip gap-2.5 px-4 py-2"
style={{
fontWeight: 800,
letterSpacing: "0.14em",
textTransform: "uppercase",
}}
>
<span className="relative flex h-2 w-2">
<span
className="absolute inline-flex h-full w-full animate-ping rounded-full opacity-70"
style={{ background: "var(--color-primary)" }}
/>
<span
className="relative inline-flex h-2 w-2 rounded-full"
style={{ background: "var(--color-primary)" }}
/>
</span>
<span
className="text-[10px] font-black sm:text-xs"
style={{ color: "var(--color-text-readable)" }}
>
{tp("onlineNow")}
</span>
{online === null ? (
<span className="text-xs">{te("unavailable")}</span>
) : (
<span
className="text-sm sm:text-base"
style={{
color:
"var(--color-primary-readable, var(--color-primary))",
fontWeight: 900,
}}
>
<LiveOnlineCounter initial={online} />
</span>
)}
</span>
<h1
className="mt-7 sm:mt-8"
style={{ fontFamily: "var(--font-nunito)" }}
>
<h1 style={{ fontFamily: "var(--font-nunito)" }}>
<span
className="mb-3 block text-xs font-extrabold uppercase tracking-[0.32em] sm:text-sm"
style={{ color: "rgba(255,255,255,0.72)" }}
@@ -596,37 +472,6 @@ export default async function Home() {
{tp("welcomeBody")}
</p>
{recentUsers !== null && recentUsers.length > 0 && (
<div
className="animate-fade-in-up mt-7 inline-flex items-center gap-3 rounded-full border px-3 py-1.5"
style={{
background: "rgba(255,255,255,0.08)",
borderColor: "rgba(255,255,255,0.22)",
backdropFilter: "blur(12px)",
WebkitBackdropFilter: "blur(12px)",
animationDelay: "0.08s",
}}
>
<div className="flex -space-x-2.5">
{recentUsers.slice(0, 4).map((u) => (
<UserAvatarThumbnail
key={u.username}
figure={u.look}
alt={u.username}
options={{ direction: 2 }}
className="h-9 w-9 rounded-full ring-2 ring-white/70 shadow-lg"
/>
))}
</div>
<span
className="pr-1 text-[10px] font-extrabold uppercase tracking-[0.22em]"
style={{ color: "rgba(255,255,255,0.75)" }}
>
{tp("recentUsers")}
</span>
</div>
)}
<div
className="animate-fade-in-up mt-9 flex flex-wrap items-center justify-center gap-3"
style={{ animationDelay: "0.2s" }}
@@ -1148,3 +993,5 @@ export default async function Home() {
</div>
);
}
export default withPublicPagePerformance("/", Home);
-2
View File
@@ -108,7 +108,6 @@ export default async function RareCategoryPage({
}}
>
{badgeSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={badgeSrc}
alt={`${cat.name} badge`}
@@ -150,7 +149,6 @@ export default async function RareCategoryPage({
<tr key={String(r.id)}>
<td>
{iconSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={iconSrc}
alt=""
-1
View File
@@ -88,7 +88,6 @@ export default async function RareValuesPage() {
>
<div className="content-card-head">
{badgeSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={badgeSrc}
alt={`${c.name} badge`}
+28 -156
View File
@@ -1,23 +1,13 @@
import { desc, eq } from "drizzle-orm";
import type { Metadata } from "next";
import { headers } from "next/headers";
import Image from "next/image";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import {
AuthPageFrame,
AuthUsersCards,
} from "@/components/auth/auth-page-frame";
AuthLandingView,
loadAuthLanding,
} from "@/components/auth/auth-landing";
import { RegisterForm } from "@/components/auth/register-form";
import { SurfaceCard } from "@/components/surface-card";
import { auth } from "@/lib/auth";
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";
import { passwordPolicy } from "@/lib/auth/password-policy";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.register");
@@ -31,158 +21,40 @@ export async function generateMetadata(): Promise<Metadata> {
}
export default async function RegisterPage() {
const tpr = await getTranslations("pages.register");
const [hotelName, cfg, logo] = await Promise.all([
resolveHotelName(),
captchaConfig(),
siteSettings.get("cms_logo", ""),
]);
const nonce = (await headers()).get("x-nonce") ?? undefined;
const t = await getTranslations("pages.register");
// An account already exists in this browser — there is nothing to sign up.
const session = await auth();
if (session?.user?.id) redirect("/me");
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(() => []),
]);
const data = await loadAuthLanding();
return (
<AuthPageFrame
hotelName={hotelName}
logo={logo}
title={tpr("title")}
subtitle={tpr("subtitle")}
<AuthLandingView
data={data}
copy={{
title: t("title"),
subtitle: t("subtitle"),
promoTitle: (
<>
{t("title")} —{" "}
<span className="gradient-text">{data.hotelName}</span>
</>
),
promoSubtitle: t("subtitle"),
usersOnlineLabel: t("usersOnline", { count: data.online }),
whoIsOnlineLabel: t("whoIsOnline"),
newestCitizensLabel: t("newestCitizens"),
}}
form={
<RegisterForm
hotelName={hotelName}
captcha={{
provider: cfg.provider,
siteKey: cfg.siteKey || undefined,
}}
nonce={nonce}
hotelName={data.hotelName}
captcha={data.captcha}
nonce={data.nonce}
passwordMinLength={passwordPolicy.minLength}
passwordMaxLength={passwordPolicy.maxLength}
/>
}
>
<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>
/>
);
}
+7 -2
View File
@@ -3,6 +3,7 @@ import { getTranslations } from "next-intl/server";
import { resetPassword } from "@/actions/password-reset";
import Link from "@/components/link";
import { ContentCard } from "@/components/public/ui";
import { passwordPolicy } from "@/lib/auth/password-policy";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("pages.reset");
@@ -41,8 +42,12 @@ export default async function ResetPage({
<input
name="password"
type="password"
placeholder={t("passwordPlaceholder")}
placeholder={t("passwordPlaceholder", {
min: passwordPolicy.minLength,
})}
autoComplete="new-password"
minLength={passwordPolicy.minLength}
maxLength={passwordPolicy.maxLength}
required
/>
<button type="submit" className="btn btn-primary">
@@ -58,7 +63,7 @@ export default async function ResetPage({
marginBottom: 0,
}}
>
{t(errorKey)}
{t(errorKey, { min: passwordPolicy.minLength })}
</p>
) : null}
<p
+1 -1
View File
@@ -16,7 +16,7 @@ const STATE_LABELS: Record<string, { label: string; icon: string }> = {
};
function describeState(state: string): { label: string; icon: string } {
// 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: "🚪" };
}
-1
View File
@@ -249,7 +249,6 @@ export default async function ShopPage({
}}
>
{a.iconUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={a.iconUrl}
alt=""
-1
View File
@@ -366,7 +366,6 @@ async function ProfilePage({
flexWrap: "wrap",
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<ProfileImage
src={avatar}
alt={user.username}
+11 -1
View File
@@ -1,4 +1,6 @@
import { notFound } from "next/navigation";
import { NextIntlClientProvider } from "next-intl";
import { getLocale, getMessages } from "next-intl/server";
import type { ReactNode } from "react";
import { env } from "@/env";
import { isHousekeepingPreviewEnabled } from "@/features/housekeeping/foundation/preview-gate";
@@ -27,5 +29,13 @@ export default async function AdminNextLayout({
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>
);
}
-1
View File
@@ -49,7 +49,6 @@ export function AdsTable({ ads }: { ads: AdRow[] }) {
{ads.map((ad) => (
<tr key={ad.id}>
<td>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={ad.image}
alt=""
-1
View File
@@ -103,7 +103,6 @@ export default async function AdminCalendarCampaign({
<div className="admin-card flex items-center gap-5 mb-6">
{campaign.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img
className="w-24 h-24 object-cover"
src={campaign.image}
-1
View File
@@ -98,7 +98,6 @@ export default async function AdminCalendar() {
className="admin-card hover article"
>
{c.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img className="article-img" src={c.image} alt={c.name} />
) : (
<div className="article-img" />
-1
View File
@@ -96,7 +96,6 @@ export default async function AdminGuildDetailPage({
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex items-center gap-4">
{guild.badge ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${guild.badge}.gif`}
alt=""
-1
View File
@@ -155,7 +155,6 @@ export default async function AdminGuildsPage({
<td className="muted">{g.id}</td>
<td>
{g.badge ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${g.badge}.gif`}
alt=""
+9 -3
View File
@@ -1,7 +1,8 @@
import { eq } from "drizzle-orm";
import { LogOut } from "lucide-react";
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 { AdminHubChrome } from "@/components/admin/admin-hub-chrome";
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");
}
// The root layout ships the reduced catalogue so public pages do not carry
// the ~195 KB of staff-tool strings. Nested providers replace the parent
// set rather than merging, so staff route groups re-install the full one.
const [locale, messages] = await Promise.all([getLocale(), getMessages()]);
return (
<>
<NextIntlClientProvider locale={locale} messages={messages}>
{csrfToken ? <meta name="csrf-token" content={csrfToken} /> : null}
<AdminMobileWrapper sidebar={<Sidebar staff={staff} />}>
<div
@@ -56,7 +62,7 @@ export default async function AdminLayout({
</div>
</AdminMobileWrapper>
<AdminToaster />
</>
</NextIntlClientProvider>
);
}
-1
View File
@@ -94,7 +94,6 @@ export default async function AdminPhotos({
className="admin-card"
style={{ padding: 0, overflow: "hidden" }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={p.url}
alt={t("photoAlt", { id: String(p.id) })}
+2 -2
View File
@@ -62,7 +62,7 @@ function isRecord(v: unknown): v is Record<string, unknown> {
}
function pickString(obj: Record<string, unknown>, key: string): string | null {
// eslint-disable-next-line security/detect-object-injection -- only called with hardcoded keys
// only called with hardcoded keys
const v = obj[key];
return typeof v === "string" && v.trim() !== "" ? v.trim() : null;
}
@@ -112,7 +112,7 @@ function findNumberDeep(
if (typeof value === "number" && Number.isFinite(value)) return value;
if (!isRecord(value)) return null;
for (const key of keys) {
// eslint-disable-next-line security/detect-object-injection -- keys from hardcoded array
// keys from hardcoded array
const v = value[key];
if (typeof v === "number" && Number.isFinite(v)) return v;
if (
@@ -577,7 +577,6 @@ export function FurniClient({ room, items = [], canEdit }: Props) {
)}
<TableCell>
{icon ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={icon}
alt={item.name}
@@ -733,7 +732,6 @@ export function FurniClient({ room, items = [], canEdit }: Props) {
>
<TableCell>
{icon ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={icon}
alt={g.base.name}
+1 -1
View File
@@ -493,7 +493,7 @@ export default async function AdminTheme({
width: 20,
height: 20,
borderRadius: 4,
// 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",
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.2)",
}}
+3 -3
View File
@@ -356,7 +356,7 @@ export async function GET(req: Request) {
]);
const rarity = Object.fromEntries(
// 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]]),
) as Record<BadgeRarityKey, BoardCore>;
@@ -398,7 +398,7 @@ export async function GET(req: Request) {
totalBadgesViewer = tb;
achievementViewer = ach;
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]]),
) as Record<BadgeRarityKey, Partial<BadgeLeaderboardEntry> | undefined>;
}
@@ -414,7 +414,7 @@ export async function GET(req: Request) {
viewerEntry: achievementViewer,
},
rarity: Object.fromEntries(
// eslint-disable-next-line security/detect-object-injection -- rk from rarityKeys const
// rk from rarityKeys const
rarityKeys.map((rk) => [
rk,
{ ...global.rarity[rk], viewerEntry: rarityViewer[rk] },
+1 -1
View File
@@ -110,7 +110,7 @@ export async function GET(req: Request) {
async () =>
type === "credits"
? await loadCreditsRows()
: // eslint-disable-next-line security/detect-object-injection -- type validated to "diamonds"|"duckets"
: // type validated to "diamonds"|"duckets"
await loadCurrencyRows(CURRENCY_TYPE[type]),
// Rankings are re-sorted on every read; serving the previous order for a
// minute keeps a busy leaderboard off the database.
+1 -3
View File
@@ -51,7 +51,6 @@ export async function GET(
headers: SECURITY_HEADERS,
});
}
// eslint-disable-next-line security/detect-non-literal-fs-filename
if (!existsSync(filePath)) {
return new NextResponse("Not found", {
status: 404,
@@ -59,7 +58,6 @@ export async function GET(
});
}
// eslint-disable-next-line security/detect-non-literal-fs-filename
const bytes = await readFile(filePath);
const mime: Record<string, string> = {
".png": "image/png",
@@ -76,7 +74,7 @@ export async function GET(
headers: {
...SECURITY_HEADERS,
...(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",
"Cache-Control": "public, max-age=3600, must-revalidate",
},
-2
View File
@@ -12,11 +12,9 @@ import { PERMS } from "@/lib/permissions";
*/
export const GET = withAdmin({ permission: PERMS.PAGES_EDIT }, async () => {
const dir = MEDIA_ROOT;
// eslint-disable-next-line security/detect-non-literal-fs-filename
if (!existsSync(dir)) {
return apiOk({ files: [] });
}
// eslint-disable-next-line security/detect-non-literal-fs-filename
const files = readdirSync(dir)
.filter((f) => /\.(png|jpg|jpeg|gif|webp|svg|bmp)$/i.test(f))
.map((f) => {
+1 -1
View File
@@ -35,7 +35,7 @@ function findCount(value: unknown, depth = 0): number | null {
"count",
];
for (const key of keys) {
// eslint-disable-next-line security/detect-object-injection -- keys from hardcoded array
// keys from hardcoded array
const v = value[key];
if (typeof v === "number" && Number.isFinite(v)) return v;
if (
+14
View File
@@ -2893,6 +2893,20 @@ html {
0 0 0 3px color-mix(in srgb, var(--color-primary) 16%, transparent),
0 0 24px -8px color-mix(in srgb, var(--color-primary) 60%, transparent);
}
.auth-input[aria-invalid="true"]:not(:focus) {
border-color: color-mix(in srgb, var(--color-danger) 60%, transparent);
background: color-mix(
in srgb,
var(--color-danger) 6%,
color-mix(in srgb, var(--color-background) 88%, var(--color-surface))
);
}
.auth-input[aria-invalid="true"]:focus {
border-color: var(--color-danger);
box-shadow:
0 0 0 3px color-mix(in srgb, var(--color-danger) 18%, transparent),
0 0 24px -8px color-mix(in srgb, var(--color-danger) 55%, transparent);
}
.auth-input:disabled {
opacity: 0.55;
cursor: not-allowed;
+6 -1
View File
@@ -13,6 +13,7 @@ import { ThemeVars } from "@/components/theme-vars";
import { enforceSiteAccess } from "@/lib/access-guard";
import { getDefaultFaviconDataUri } from "@/lib/default-favicon";
import { resolveHotelName } from "@/lib/hotel-name";
import { publicClientMessages } from "@/lib/i18n-client-messages";
import { siteSettings } from "@/lib/services/site-settings";
import "./globals.css";
@@ -68,7 +69,11 @@ export default async function RootLayout({
}) {
await enforceSiteAccess();
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 nitroUrl = await siteSettings.get("nitro_client_url", "");
const nonce = (await headers()).get("x-nonce") ?? undefined;
+9 -3
View File
@@ -10,7 +10,8 @@ import {
Users,
} from "lucide-react";
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 { LanguageSwitcher } from "@/components/language-switcher";
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 },
].filter((item) => item.show);
// The root layout ships the reduced catalogue so public pages do not carry
// the ~195 KB of staff-tool strings. Nested providers replace the parent
// set rather than merging, so staff route groups re-install the full one.
const [locale, messages] = await Promise.all([getLocale(), getMessages()]);
return (
<>
<NextIntlClientProvider locale={locale} messages={messages}>
{csrfToken ? <meta name="csrf-token" content={csrfToken} /> : null}
<div
data-admin
@@ -140,6 +146,6 @@ export default async function ModLayout({ children }: { children: ReactNode }) {
</header>
<main className="mx-auto max-w-6xl p-4 sm:p-6">{children}</main>
</div>
</>
</NextIntlClientProvider>
);
}
-1
View File
@@ -45,7 +45,6 @@ export function ArticleCard({
<div className="admin-card overflow-hidden flex flex-col">
{confirmDialog}
<div className="relative w-full aspect-[16/9] bg-[var(--admin-canvas)]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={image || ""}
alt={title}
-1
View File
@@ -296,7 +296,6 @@ export function ArticleForm({
/>
</div>
{image && !imageError ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={image}
alt={t("imagePreview")}
@@ -715,7 +715,6 @@ function FurniThumb({
className="flex items-center justify-center rounded bg-muted/50"
style={{ width: size, height: size }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={primary}
alt={item.itemName}
@@ -893,7 +893,7 @@ export function SortableTree({
handleToggleEnabled,
handleAddSubpage,
],
); // eslint-disable-line react-hooks/exhaustive-deps
);
return (
<div
@@ -34,7 +34,6 @@ export function FurniIcon({
return (
<div className="w-10 h-10 rounded bg-muted/50 flex items-center justify-center overflow-hidden">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={localUrl}
alt={classname}
@@ -231,7 +231,6 @@ function FurniThumb({
return (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded bg-muted/50">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={primary}
alt={classname}
@@ -192,7 +192,6 @@ export function ShopFurniIcon({
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={primary}
alt={classname}
-1
View File
@@ -226,7 +226,6 @@ export function AdminMediaGrid() {
}}
>
<div className="relative">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={validImageUrl(f.url)}
alt={f.name}
-1
View File
@@ -156,7 +156,6 @@ export function MediaPicker({
e.currentTarget.style.borderColor = "transparent";
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={validImageUrl(f.url)}
alt={f.name}
@@ -51,7 +51,6 @@ export function FurniThumb({
className="flex items-center justify-center rounded bg-muted/50"
style={{ width: size, height: size }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={primary}
alt={item.itemName}
@@ -405,7 +405,7 @@ export function ThemeManagerPanel({
width: 14,
height: 14,
borderRadius: 3,
// eslint-disable-next-line security/detect-object-injection -- k from fixed key list
// k from fixed key list
background: palette.light[k],
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.15)",
marginLeft: -3,
+38
View File
@@ -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>
);
}
+112
View File
@@ -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>
);
}
+125
View File
@@ -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>
);
}
+4 -1
View File
@@ -128,6 +128,8 @@ function AvatarGrid({
interface AuthPageFrameProps {
hotelName: string;
logo?: string | null;
/** Live online count shown in the top bar; omit to hide it. */
online?: number;
/** Title of the form card on the right. */
title: string;
/** One-line intro above the form. */
@@ -148,6 +150,7 @@ interface AuthPageFrameProps {
export function AuthPageFrame({
hotelName,
logo,
online,
title,
subtitle,
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="relative">
<AuthAurora />
<AuthTopBar hotelName={hotelName} logo={logo} />
<AuthTopBar hotelName={hotelName} logo={logo} online={online} />
</div>
<Reveal>
+107
View File
@@ -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>
);
}
+36 -16
View File
@@ -1,29 +1,32 @@
import Image from "next/image";
import { Clock } from "@/components/clock";
import { LanguageSwitcher } from "@/components/language-switcher";
import { LiveOnlineCount } from "@/components/live-online-count";
import { Reveal } from "@/components/motion-reveal";
import { ThemeSwitcher } from "@/components/theme-switcher";
import { ICON_LIGHTHOUSE } from "@/lib/site-icons";
/**
* Shared frosted-glass top bar for the public auth pages (login, register,
* etc.). Shows the hotel logo + name on the left and language / clock / theme
* controls on the right. Defaults mirror the homepage top bar so auth pages
* share one consistent look.
* Shared frosted-glass top bar for the public pages (index, login, register).
* Shows the hotel logo on the left and the live "online" counter (which carries
* the hotel name), language / clock / theme controls on the right. One
* component so every public page keeps the exact same chrome instead of
* duplicating it.
*/
export function AuthTopBar({
hotelName,
logo,
showHotelName = true,
online,
}: {
hotelName: string;
logo?: string | null;
showHotelName?: boolean;
/** Live online count; omit (or pass null) to hide the indicator. */
online?: number | null;
}) {
return (
<Reveal>
<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={{
background:
"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)",
}}
>
<div className="flex items-center gap-2 sm:gap-3">
<div className="flex min-w-0 items-center gap-2 sm:gap-3">
{logo ? (
<Image
src={logo}
@@ -67,14 +70,6 @@ export function AuthTopBar({
/>
</div>
)}
{showHotelName ? (
<span
className="hidden text-sm font-extrabold uppercase tracking-wide sm:inline"
style={{ color: "var(--color-text-readable)" }}
>
{hotelName}
</span>
) : null}
</div>
<div
className="flex items-center gap-1 sm:gap-1.5 rounded-full px-1.5 py-1 sm:px-2"
@@ -83,6 +78,31 @@ export function AuthTopBar({
"color-mix(in srgb, var(--color-text-muted) 5%, transparent)",
}}
>
{typeof online === "number" && (
<span
className="flex items-center gap-1.5 px-1"
style={{ color: "var(--color-text-readable)" }}
>
<span className="relative flex h-2 w-2 shrink-0">
<span
className="absolute inline-flex h-full w-full animate-ping rounded-full opacity-70"
style={{ background: "var(--color-primary)" }}
/>
<span
className="relative inline-flex h-2 w-2 rounded-full"
style={{ background: "var(--color-primary)" }}
/>
</span>
<span
className="text-xs font-black tabular-nums"
style={{
color: "var(--color-primary-readable, var(--color-primary))",
}}
>
<LiveOnlineCount initial={online} hotelName={hotelName} />
</span>
</span>
)}
<LanguageSwitcher />
<div
className="hidden sm:block w-px h-5"
+74 -88
View File
@@ -3,8 +3,10 @@
import { KeyRound, Lock, UserRound } from "lucide-react";
import { signIn } from "next-auth/react";
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 { AuthAlert } from "@/components/auth/auth-alert";
import { AuthField } from "@/components/auth/auth-field";
import {
type CaptchaPublicConfig,
CaptchaWidget,
@@ -51,8 +53,17 @@ export function LoginForm({
const [unverified, setUnverified] = useState(false);
const [pending, setPending] = useState(false);
const [locked, setLocked] = useState(false);
const codeRef = useRef<HTMLInputElement>(null);
// The 2FA field only exists after the pre-check asks for a code; move focus
// there automatically so the visitor can keep typing without reaching for
// the mouse (the lint rule rules out a bare `autoFocus`).
useEffect(() => {
if (needs2fa) codeRef.current?.focus();
}, [needs2fa]);
const showFooter = variant === "page";
const compact = variant === "compact";
const lockCredentials =
needs2fa || locked ? "opacity-50 pointer-events-none" : "";
@@ -115,7 +126,9 @@ export function LoginForm({
<>
{needs2fa && (
<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={{
color: "var(--color-text-readable)",
background:
@@ -131,105 +144,76 @@ export function LoginForm({
<form
onSubmit={onSubmit}
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}`}>
<label htmlFor={`${fieldId}-username`} className="auth-label">
{t("username")}
</label>
<div className="relative">
<UserRound
aria-hidden="true"
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
style={{ color: "var(--color-text-muted)" }}
/>
<input
id={`${fieldId}-username`}
name="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t("usernamePlaceholder")}
autoComplete="username"
disabled={needs2fa}
required
className="auth-input has-icon"
/>
</div>
</div>
<AuthField
id={`${fieldId}-username`}
name="username"
label={t("username")}
icon={UserRound}
value={username}
onChange={setUsername}
placeholder={t("usernamePlaceholder")}
autoComplete="username"
disabled={needs2fa || pending}
required
invalid={!!error}
describedBy={error ? `${fieldId}-error` : undefined}
className={lockCredentials}
/>
<div className={`space-y-1.5 ${lockCredentials}`}>
<label htmlFor={`${fieldId}-password`} className="auth-label">
{t("password")}
</label>
<div className="relative">
<Lock
aria-hidden="true"
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
style={{ color: "var(--color-text-muted)" }}
/>
<input
id={`${fieldId}-password`}
name="password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("passwordPlaceholder")}
autoComplete="current-password"
disabled={needs2fa}
required
className="auth-input has-icon has-action"
/>
<AuthField
id={`${fieldId}-password`}
name="password"
label={t("password")}
icon={Lock}
type={showPassword ? "text" : "password"}
value={password}
onChange={setPassword}
placeholder={t("passwordPlaceholder")}
autoComplete="current-password"
disabled={needs2fa || pending}
required
invalid={!!error}
describedBy={error ? `${fieldId}-error` : undefined}
className={lockCredentials}
trailing={
<PasswordToggle
show={showPassword}
onToggle={() => setShowPassword((v) => !v)}
labelShow={t("showPassword")}
labelHide={t("hidePassword")}
/>
</div>
</div>
}
/>
{needs2fa ? (
<div className="animate-scale-in space-y-1.5">
<label htmlFor={`${fieldId}-2fa`} className="auth-label">
{t("codePlaceholder")}
</label>
<div className="relative">
<KeyRound
aria-hidden="true"
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
style={{ color: "var(--color-primary)" }}
/>
<input
id={`${fieldId}-2fa`}
name="code"
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t("codePlaceholder")}
inputMode="numeric"
autoComplete="one-time-code"
className="auth-input has-icon"
style={{
borderColor:
"color-mix(in srgb, var(--color-primary) 45%, transparent)",
}}
/>
</div>
</div>
<AuthField
id={`${fieldId}-2fa`}
name="code"
label={t("codePlaceholder")}
icon={KeyRound}
iconColor="var(--color-primary)"
value={code}
onChange={setCode}
placeholder={t("codePlaceholder")}
inputMode="numeric"
autoComplete="one-time-code"
disabled={pending}
invalid={!!error}
describedBy={error ? `${fieldId}-error` : undefined}
className="animate-scale-in"
inputRef={codeRef}
inputStyle={{
borderColor:
"color-mix(in srgb, var(--color-primary) 45%, transparent)",
}}
/>
) : (
<CaptchaWidget captcha={captcha} nonce={nonce} />
)}
{error && (
<p
role="alert"
aria-live="polite"
className="auth-alert animate-fade-in-up m-0"
>
{error}
</p>
)}
{error && <AuthAlert id={`${fieldId}-error`}>{error}</AuthAlert>}
{unverified && (
<p
@@ -249,7 +233,9 @@ export function LoginForm({
<button
type="submit"
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 ? (
<span className="inline-flex items-center justify-center gap-2">
+124 -174
View File
@@ -4,6 +4,7 @@ import {
Check,
KeyRound,
Lock,
type LucideIcon,
Mail,
ShieldCheck,
UserRound,
@@ -18,6 +19,8 @@ import {
type RegisterState,
register,
} from "@/actions/register";
import { AuthAlert } from "@/components/auth/auth-alert";
import { AuthField } from "@/components/auth/auth-field";
import { PasswordToggle } from "@/components/auth/password-toggle";
import Link from "@/components/link";
import { signInWithTransientRetry } from "@/lib/auth/sign-in-retry";
@@ -27,19 +30,23 @@ interface RegisterFormProps {
captcha: { provider: string; siteKey?: string };
error?: string;
nonce?: string;
/** Effective policy from `PASSWORD_MIN_LENGTH` / `PASSWORD_MAX_LENGTH`. */
passwordMinLength: number;
passwordMaxLength: number;
}
function passwordStrength(
pw: string,
t: (key: string) => string,
minLength: number,
): {
score: number;
label: string;
color: string;
} {
let score = 0;
if (pw.length >= 8) score += 1;
if (pw.length >= 12) score += 1;
if (pw.length >= minLength) score += 1;
if (pw.length >= minLength + 4) score += 1;
if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) score += 1;
if (/\d/.test(pw)) score += 1;
if (/[^a-zA-Z0-9]/.test(pw)) score += 1;
@@ -50,11 +57,58 @@ function passwordStrength(
return { score: 5, label: t("strengthStrong"), color: "#16a34a" };
}
/** The numbered section rule above each group of fields ("01 Account", ...). */
function AuthSectionHeader({
icon: Icon,
label,
step,
}: {
icon: LucideIcon;
label: string;
step: string;
}) {
const accent = "var(--color-primary-readable, var(--color-primary))";
return (
<div className="flex items-center gap-2.5">
<span
aria-hidden="true"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg"
style={{
background:
"color-mix(in srgb, var(--color-primary) 12%, transparent)",
boxShadow:
"inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 22%, transparent)",
}}
>
<Icon className="h-3.5 w-3.5" style={{ color: accent }} />
</span>
<span
className="text-[11px] font-black uppercase tracking-widest"
style={{ color: accent }}
>
{label}
</span>
<span
className="h-px flex-1"
style={{
background:
"linear-gradient(90deg, color-mix(in srgb, var(--color-text-muted) 18%, transparent), transparent)",
}}
/>
<span aria-hidden="true" className="number-mark text-lg">
{step}
</span>
</div>
);
}
export function RegisterForm({
hotelName,
captcha,
error,
nonce,
passwordMinLength,
passwordMaxLength,
}: RegisterFormProps) {
const t = useTranslations("pages.register");
const showCaptcha = captcha.provider !== "none" && !!captcha.siteKey;
@@ -90,7 +144,7 @@ export function RegisterForm({
);
const autoLoginStartedRef = useRef(false);
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
@@ -98,7 +152,10 @@ export function RegisterForm({
* single error back.
*/
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("passwordLower"), met: /[a-z]/.test(password) },
{ label: t("passwordDigit"), met: /\d/.test(password) },
@@ -183,15 +240,7 @@ export function RegisterForm({
}}
className="flex flex-col gap-6"
>
{errorMessage && (
<p
role="alert"
aria-live="polite"
className="auth-alert animate-fade-in-up m-0"
>
{errorMessage}
</p>
)}
{errorMessage && <AuthAlert>{errorMessage}</AuthAlert>}
{referralCode && (
<div
@@ -212,156 +261,65 @@ export function RegisterForm({
{/* Username & Email */}
<div className="space-y-2">
<div className="flex items-center gap-2.5">
<span
aria-hidden="true"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg"
style={{
background:
"color-mix(in srgb, var(--color-primary) 12%, transparent)",
boxShadow:
"inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 22%, transparent)",
}}
>
<UserRound
className="h-3.5 w-3.5"
style={{
color: "var(--color-primary-readable, var(--color-primary))",
}}
/>
</span>
<span
className="text-[11px] font-black uppercase tracking-widest"
style={{
color: "var(--color-primary-readable, var(--color-primary))",
}}
>
{t("accountDetails")}
</span>
<span
className="h-px flex-1"
style={{
background:
"linear-gradient(90deg, color-mix(in srgb, var(--color-text-muted) 18%, transparent), transparent)",
}}
/>
<span aria-hidden="true" className="number-mark text-lg">
01
</span>
</div>
<AuthSectionHeader
icon={UserRound}
label={t("accountDetails")}
step="01"
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 stagger-children">
<div className="space-y-1.5">
<label htmlFor="username" className="auth-label">
{t("username")}
</label>
<div className="relative">
<UserRound
aria-hidden="true"
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
style={{ color: "var(--color-text-muted)" }}
/>
<input
id="username"
name="username"
type="text"
placeholder={t("usernamePlaceholder")}
autoComplete="username"
className="auth-input has-icon"
required
/>
</div>
</div>
<div className="space-y-1.5">
<label htmlFor="mail" className="auth-label">
{t("email")}
</label>
<div className="relative">
<Mail
aria-hidden="true"
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
style={{ color: "var(--color-text-muted)" }}
/>
<input
id="mail"
name="mail"
type="email"
placeholder={t("emailPlaceholder")}
autoComplete="email"
className="auth-input has-icon"
/>
</div>
</div>
<AuthField
id="username"
name="username"
label={t("username")}
icon={UserRound}
placeholder={t("usernamePlaceholder")}
autoComplete="username"
required
/>
<AuthField
id="mail"
name="mail"
label={t("email")}
icon={Mail}
type="email"
placeholder={t("emailPlaceholder")}
autoComplete="email"
/>
</div>
</div>
{/* Password & Confirm */}
<div className="space-y-2">
<div className="flex items-center gap-2.5">
<span
aria-hidden="true"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg"
style={{
background:
"color-mix(in srgb, var(--color-primary) 12%, transparent)",
boxShadow:
"inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 22%, transparent)",
}}
>
<KeyRound
className="h-3.5 w-3.5"
style={{
color: "var(--color-primary-readable, var(--color-primary))",
}}
/>
</span>
<span
className="text-[11px] font-black uppercase tracking-widest"
style={{
color: "var(--color-primary-readable, var(--color-primary))",
}}
>
{t("credentials")}
</span>
<span
className="h-px flex-1"
style={{
background:
"linear-gradient(90deg, color-mix(in srgb, var(--color-text-muted) 18%, transparent), transparent)",
}}
/>
<span aria-hidden="true" className="number-mark text-lg">
02
</span>
</div>
<AuthSectionHeader
icon={KeyRound}
label={t("credentials")}
step="02"
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 stagger-children">
<div className="space-y-1.5">
<label htmlFor="password" className="auth-label">
{t("password")}
</label>
<div className="relative">
<Lock
aria-hidden="true"
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
style={{ color: "var(--color-text-muted)" }}
/>
<input
id="password"
name="password"
type={showPassword ? "text" : "password"}
placeholder={t("passwordPlaceholder")}
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="auth-input has-icon has-action"
required
/>
<AuthField
id="password"
name="password"
label={t("password")}
icon={Lock}
type={showPassword ? "text" : "password"}
value={password}
onChange={setPassword}
placeholder={t("passwordPlaceholder", {
min: passwordMinLength,
})}
autoComplete="new-password"
required
minLength={passwordMinLength}
maxLength={passwordMaxLength}
trailing={
<PasswordToggle
show={showPassword}
onToggle={() => setShowPassword((v) => !v)}
labelShow={t("showPassword")}
labelHide={t("hidePassword")}
/>
</div>
}
>
{password.length > 0 && (
<div className="animate-fade-in-up space-y-1.5 pt-0.5">
<div className="flex gap-1.5">
@@ -417,35 +375,27 @@ export function RegisterForm({
</li>
))}
</ul>
</div>
<div className="space-y-1.5">
<label htmlFor="password_confirmation" className="auth-label">
{t("confirmPassword")}
</label>
<div className="relative">
<Lock
aria-hidden="true"
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
style={{ color: "var(--color-text-muted)" }}
/>
<input
id="password_confirmation"
name="password_confirmation"
type={showConfirm ? "text" : "password"}
placeholder={t("confirmPasswordPlaceholder")}
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="auth-input has-icon has-action"
required
/>
</AuthField>
<AuthField
id="password_confirmation"
name="password_confirmation"
label={t("confirmPassword")}
icon={Lock}
type={showConfirm ? "text" : "password"}
value={confirmPassword}
onChange={setConfirmPassword}
placeholder={t("confirmPasswordPlaceholder")}
autoComplete="new-password"
required
trailing={
<PasswordToggle
show={showConfirm}
onToggle={() => setShowConfirm((v) => !v)}
labelShow={t("showPassword")}
labelHide={t("hidePassword")}
/>
</div>
}
>
{mismatch && (
<p
className="animate-fade-in-up text-xs font-bold"
@@ -454,7 +404,7 @@ export function RegisterForm({
{t("passwordsDontMatch")}
</p>
)}
</div>
</AuthField>
</div>
</div>
+1 -1
View File
@@ -44,7 +44,7 @@ export function NavbarColorPicker() {
const [mounted, setMounted] = useState(false);
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];
if (!c) return;
document.documentElement.style.setProperty("--color-navbar", c.value);
+1 -3
View File
@@ -29,7 +29,7 @@ export function PhotoLightbox({ photos }: { photos: LightboxPhoto[] }) {
// has to hold the overlay itself.
const [closing, setClosing] = useState(false);
const exitTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// 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 close = useCallback(() => {
@@ -97,7 +97,6 @@ export function PhotoLightbox({ photos }: { photos: LightboxPhoto[] }) {
}}
aria-label={p.alt}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={p.url}
alt={p.alt}
@@ -180,7 +179,6 @@ export function PhotoLightbox({ photos }: { photos: LightboxPhoto[] }) {
cursor: "default",
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={active.url}
alt={active.alt}
+1 -1
View File
@@ -59,7 +59,7 @@ function scanCharWidths(
const w = end - start;
if (w > 0) {
// 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 });
ci++;
}
-1
View File
@@ -31,7 +31,6 @@ export function AvatarImage({
(options?.headOnly ? "l" : max <= 40 ? "s" : max <= 80 ? "m" : "l");
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={getAvatarUrl(figure, { ...options, size: autoSize })}
alt={alt}
@@ -11,7 +11,7 @@ const compactCallSites = new Map<string, number>([
["src/app/(site)/leaderboard/page.tsx", 1],
["src/app/(site)/me/page.tsx", 1],
["src/app/(site)/messages/page.tsx", 2],
["src/app/(site)/page.tsx", 2],
["src/app/(site)/page.tsx", 1],
["src/app/(site)/radio/leaderboard/page.tsx", 1],
["src/app/(site)/radio/shouts/page.tsx", 1],
["src/app/(site)/rankings/page.tsx", 1],
+5
View File
@@ -71,6 +71,11 @@ const schema = z
// bcrypt cost factor used for new password hashes.
BCRYPT_COST: z.coerce.number().int().min(4).max(31).default(12),
// Password-length policy for every new password (register, password reset
// and staff-created accounts). The minimum may be set between 5 and 30
// characters, the maximum between 30 and 128, so min always fits under max.
PASSWORD_MIN_LENGTH: z.coerce.number().int().min(5).max(30).default(12),
PASSWORD_MAX_LENGTH: z.coerce.number().int().min(30).max(128).default(128),
// Filesystem dir the badge uploader writes <code>.gif into (the emulator's
// badge image folder, e.g. .../assets/c_images/album1584). Upload is disabled
// when unset.
@@ -41,6 +41,8 @@ const routeMocks = vi.hoisted(() => {
}
return translate;
}),
getLocale: vi.fn(async () => "en"),
getMessages: vi.fn(async () => ({})),
notFound: vi.fn((): never => {
throw new Error("NEXT_NOT_FOUND");
}),
@@ -58,6 +60,8 @@ vi.mock("next/navigation", () => ({
}));
vi.mock("next-intl/server", () => ({
getTranslations: routeMocks.getTranslations,
getLocale: routeMocks.getLocale,
getMessages: routeMocks.getMessages,
}));
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
getHousekeepingCapabilityContext: routeMocks.getHousekeepingCapabilityContext,
+14 -1
View File
@@ -1,6 +1,7 @@
import { eq } from "drizzle-orm";
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { cache } from "react";
import { env } from "@/env";
import { getCachedJwtVersion } from "@/lib/auth/jwt-version-cache";
import {
@@ -25,7 +26,7 @@ import { db, User } from "@/lib/db";
import { logger } from "@/lib/logger";
import { clientIp, rateLimit } from "@/lib/rate-limit";
export const { handlers, signOut, auth } = NextAuth({
const nextAuth = NextAuth({
trustHost: true,
secret: env.AUTH_SECRET,
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);
+27
View File
@@ -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 };
})();
+62 -1
View File
@@ -85,7 +85,12 @@ describe("publicClientMessages", () => {
if (key === "admin") continue;
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 () => {
@@ -111,3 +116,59 @@ describe("publicClientMessages", () => {
expect(en.pages.admin).toBeDefined();
});
});
/**
* Scoping only works if the reduced catalogue goes to the client *and* every
* staff route group re-installs the full one. These two halves drift silently:
* narrowing the root without adding a staff provider renders raw keys in the
* admin panel, and the reverse just costs bytes. Both are asserted here.
*/
describe("client catalogue scoping is wired up", () => {
const STAFF_LAYOUTS = [
"src/app/admin/layout.tsx",
"src/app/admin-next/layout.tsx",
"src/app/mod/layout.tsx",
];
it("the root layout feeds the reduced catalogue to its provider", () => {
const src = readFileSync(join(process.cwd(), "src/app/layout.tsx"), "utf8");
expect(src).toContain("publicClientMessages(await getMessages())");
});
for (const layout of STAFF_LAYOUTS) {
it(`${layout} re-installs the full catalogue`, () => {
const src = readFileSync(join(process.cwd(), layout), "utf8");
expect(src).toContain("NextIntlClientProvider");
expect(src).toContain("getMessages()");
});
}
});
/**
* A client component that reads `admin.*` / `pages.admin.*` outside a staff
* route group would fall back to raw keys once the root catalogue is scoped,
* because the provider it inherits no longer carries them. Staff trees are the
* ones that install the full catalogue; the allowlist holds shared code that
* deliberately runs on public pages too.
*/
describe("staff namespace consumers sit in a staff tree", () => {
const STAFF_TREE =
/^src[\\/](app[\\/](admin|admin-next|mod)[\\/]|components[\\/]admin[\\/]|features[\\/](catalog|operations|history|housekeeping)[\\/])/;
const ALLOWLIST = new Set(["src/hooks/use-server-action.ts"]);
const CLIENT_STAFF_NS = /useTranslations\((["`])((admin)|(pages\.admin))/;
it("there is source to scan", () => {
expect(sourceFiles(join(process.cwd(), "src")).length).toBeGreaterThan(500);
});
it("no stray consumer of a staff namespace", () => {
const offenders: string[] = [];
for (const file of sourceFiles(join(process.cwd(), "src"))) {
const rel = file.slice(process.cwd().length + 1).replaceAll("\\", "/");
if (rel.includes(".test.")) continue;
if (STAFF_TREE.test(rel) || ALLOWLIST.has(rel)) continue;
if (CLIENT_STAFF_NS.test(readFileSync(file, "utf8"))) offenders.push(rel);
}
expect(offenders).toEqual([]);
});
});
+35 -9
View File
@@ -3,22 +3,35 @@ import type { AbstractIntlMessages } from "next-intl";
/**
* Client-side message scoping.
*
* `getMessages()` hands back the whole catalogue, and the root layout feeds all
* of it to `NextIntlClientProvider`, which serialises every message into the RSC
* payload of every page. The catalogue is ~235 KB of which `pages.admin` and
* `admin` together are ~177 KB — and no public page ever reads either.
* `getMessages()` hands back the whole catalogue, and the root layout feeds it
* to `NextIntlClientProvider`, which serialises every message into the RSC
* payload of every page. The catalogue is ~254 KB of which `pages.admin` and
* `admin` together are ~195 KB — and no public page ever reads either.
*
* Nested `NextIntlClientProvider`s REPLACE the parent's messages rather than
* merging them (see use-intl's IntlProvider: `messages ?? parent.messages`), so
* a segment cannot ask for "just my part". Instead the public layout installs a
* provider seeded with the public subset, which shrinks the payload for every
* public page while leaving /admin, /mod, /client and /admin-next on the full set.
* a segment cannot ask for "just my part". The root layout therefore installs
* the reduced catalogue, and the staff route groups (`/admin`, `/admin-next`,
* `/mod`) each install a nested provider seeded with the full one, which keeps
* staff tools complete while public pages ship ~59 KB instead of ~254 KB.
*/
/** Top-level namespaces plus `pages.*` sub-namespaces reserved for staff tools. */
const ADMIN_ONLY_NAMESPACES = new Set(["admin"]);
const ADMIN_ONLY_PAGE_NAMESPACES = new Set(["admin"]);
/**
* `pages.admin` sub-namespaces that are NOT staff-only because a shared
* client component reads them on public pages too.
*
* `useServerAction` (src/hooks/use-server-action.ts) resolves its default
* success/error toast copy from `pages.admin.actions` and is used by
* `/events/[slugOrId]` and `/polls/[id]`, so that one sub-namespace has to
* survive the filter — the strings in it are generic ("Saved successfully."),
* not staff-tool copy.
*/
const PUBLIC_PAGE_NAMESPACES = new Set(["actions"]);
/**
* Drop the staff-tool namespaces from a full catalogue.
*
@@ -43,8 +56,21 @@ export function publicClientMessages(
for (const [pageKey, pageValue] of Object.entries(
value as AbstractIntlMessages,
)) {
if (ADMIN_ONLY_PAGE_NAMESPACES.has(pageKey)) continue;
pages[pageKey] = pageValue;
if (!ADMIN_ONLY_PAGE_NAMESPACES.has(pageKey)) {
pages[pageKey] = pageValue;
continue;
}
// Staff-only page namespace: keep only the sub-namespaces that a
// shared client component reads on public pages as well.
const shared: AbstractIntlMessages = {};
for (const [sharedKey, sharedValue] of Object.entries(
pageValue as AbstractIntlMessages,
)) {
if (PUBLIC_PAGE_NAMESPACES.has(sharedKey)) {
shared[sharedKey] = sharedValue;
}
}
if (Object.keys(shared).length > 0) pages[pageKey] = shared;
}
result.pages = pages;
}
+1
View File
@@ -4,6 +4,7 @@ import { collectPerformance } from "./performance-diagnostics";
import { type PerformanceSample, recordPerformance } from "./performance-store";
type PublicRoute =
| "/"
| "/me"
| "/news"
| "/news/[slug]"
+1 -1
View File
@@ -14,6 +14,6 @@ const LABELS: Record<CurrencyType, string> = {
};
export function currencyLabel(type: CurrencyType): string {
// eslint-disable-next-line security/detect-object-injection -- type is CurrencyType enum
// type is CurrencyType enum
return LABELS[type];
}
-2
View File
@@ -55,12 +55,10 @@ async function writeToFile(
): Promise<boolean> {
try {
const logDir = resolve(process.cwd(), "storage", "logs");
// eslint-disable-next-line security/detect-non-literal-fs-filename
await mkdir(logDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const filename = `email-${timestamp}.html`;
const content = `<!-- To: ${to} | From: ${from} | Subject: ${subject} -->\n${html}`;
// eslint-disable-next-line security/detect-non-literal-fs-filename
await writeFile(resolve(logDir, filename), content, "utf-8");
logger.info("Email written to file", { module: "email", filename });
return true;
+1 -1
View File
@@ -59,7 +59,7 @@ export async function checkVpn(ip: string): Promise<IpVerdict> {
string,
{ proxy?: string; type?: string }
>;
// 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];
if (entry?.proxy === "yes")
return { blocked: true, reason: `${entry.type ?? "Proxy"} detected` };
+1 -1
View File
@@ -84,7 +84,7 @@ export async function sendCurrency(
data: { credits: { increment: amount } },
});
} else {
// 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];
await deps.db.usersCurrency.upsert({
where: { userId_type: { userId, type: t } },
+1 -1
View File
@@ -128,7 +128,7 @@ class SiteSettings {
): Promise<string | null> {
const map = await this.load();
if (map.has(key)) return map.get(key) as string;
// 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;
return fallback;
}
+4 -4
View File
@@ -42,7 +42,7 @@ function fallbackFor(key: string): string {
const preset = PRESETS["Atom (golden)"];
if (key.endsWith("_dark")) {
const base = key.slice(0, -"_dark".length) as ThemeColorKey;
return preset.dark[base] ?? ""; // 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) {
return preset.light[key as ThemeColorKey] ?? "";
@@ -63,7 +63,7 @@ function fallbackFor(key: string): string {
theme_preset: "Atom (golden)",
...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[]> {
@@ -106,9 +106,9 @@ export async function upsertCustomTheme(
if (id) {
const idx = themes.findIndex((t) => t.id === id);
if (idx >= 0) {
themes[idx] = { ...themes[idx], name: trimmed, settings }; // 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);
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 = {
+5 -5
View File
@@ -12,7 +12,7 @@ describe("createUserSchema", () => {
const result = createUserSchema.safeParse({
username: "TestUser",
mail: "[email protected]",
password: "secret123",
password: "secret123456",
});
expect(result.success).toBe(true);
if (result.success) {
@@ -26,7 +26,7 @@ describe("createUserSchema", () => {
createUserSchema.safeParse({
username: "ab",
mail: "[email protected]",
password: "secret123",
password: "secret123456",
}).success,
).toBe(false);
});
@@ -36,7 +36,7 @@ describe("createUserSchema", () => {
createUserSchema.safeParse({
username: "a".repeat(21),
mail: "[email protected]",
password: "secret123",
password: "secret123456",
}).success,
).toBe(false);
});
@@ -46,7 +46,7 @@ describe("createUserSchema", () => {
createUserSchema.safeParse({
username: "user@name",
mail: "[email protected]",
password: "secret123",
password: "secret123456",
}).success,
).toBe(false);
});
@@ -56,7 +56,7 @@ describe("createUserSchema", () => {
createUserSchema.safeParse({
username: "TestUser",
mail: "not-an-email",
password: "secret123",
password: "secret123456",
}).success,
).toBe(false);
});
+11 -1
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import { passwordPolicy } from "@/lib/auth/password-policy";
export const createUserSchema = z.object({
username: z
@@ -10,7 +11,16 @@ export const createUserSchema = z.object({
"Username can only contain letters, numbers, _ and -",
),
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),
motto: z.string().max(127).default(""),
});
+5 -5
View File
@@ -6132,7 +6132,7 @@
"confirmPassword": "أعد كلمة المرور",
"usernamePlaceholder": "اسم المستخدم",
"emailPlaceholder": "البريد الإلكتروني",
"passwordPlaceholder": "كلمة المرور (12 حرفًا على الأقل)",
"passwordPlaceholder": "كلمة المرور ({min} حرفًا على الأقل)",
"confirmPasswordPlaceholder": "أعد كلمة المرور",
"selectOutfit": "اختر ملابسك",
"termsAccept": "أنا أبلغ 18 عامًا وأوافق على شروط {hotel}.",
@@ -6153,7 +6153,7 @@
"termsError": "يجب الموافقة على الشروط للتسجيل.",
"usernameError": "اسم المستخدم مطلوب.",
"emailError": "البريد الإلكتروني الصالح مطلوب.",
"passwordError": "يجب ألا تقل كلمة المرور عن 12 حرفًا",
"passwordError": "يجب ألا تقل كلمة المرور عن {min} حرفًا",
"confirmPasswordError": "كلمتا المرور غير متطابقتين.",
"termsRequired": "يجب الموافقة على الشروط.",
"usernameRequired": "اسم المستخدم مطلوب.",
@@ -6167,7 +6167,7 @@
"strengthFair": "مقبول",
"strengthGood": "جيد",
"strengthStrong": "قوي",
"passwordMinLength": "يجب ألا تقل كلمة المرور عن 12 حرفًا",
"passwordMinLength": "يجب ألا تقل كلمة المرور عن {min} حرفًا",
"passwordUpper": "يجب أن تحتوي كلمة المرور على حرف كبير واحد على الأقل",
"passwordLower": "يجب أن تحتوي كلمة المرور على حرف صغير واحد على الأقل",
"passwordDigit": "يجب أن تحتوي كلمة المرور على رقم واحد على الأقل",
@@ -6202,10 +6202,10 @@
"reset": {
"title": "Set a new password",
"subtitle": "Choose a strong password you don't use elsewhere.",
"passwordPlaceholder": "New password (min 12 chars)",
"passwordPlaceholder": "New password (min {min} chars)",
"resetPassword": "Reset password",
"backToLogin": "Back to login",
"passwordMinLength": "يجب ألا تقل كلمة المرور عن 12 حرفًا",
"passwordMinLength": "يجب ألا تقل كلمة المرور عن {min} حرفًا",
"tooManyAttempts": "محاولات كثيرة جدًا — حاول لاحقًا",
"invalidLink": "رابط إعادة التعيين غير صالح أو منتهي الصلاحية. اطلب رابطًا جديدًا.",
"failed": "تعذّرت إعادة تعيين كلمة المرور. حاول مرة أخرى."
+5 -5
View File
@@ -885,7 +885,7 @@
"confirmPassword": "Повторете паролата",
"usernamePlaceholder": "Потребителско име",
"emailPlaceholder": "Имейл",
"passwordPlaceholder": "Парола (мин. 12 знака)",
"passwordPlaceholder": "Парола (мин. {min} знака)",
"confirmPasswordPlaceholder": "Повторете паролата",
"selectOutfit": "Изберете вашето облекло",
"termsAccept": "Аз съм на 18+ и приемам условията и правилата на {hotel}.",
@@ -897,7 +897,7 @@
"termsError": "Трябва да приемете условията и правилата, за да се регистрирате.",
"usernameError": "Изисква се потребителско име.",
"emailError": "Изисква се валиден имейл.",
"passwordError": "Паролата трябва да е поне 12 знака",
"passwordError": "Паролата трябва да е поне {min} знака",
"confirmPasswordError": "Паролите не съвпадат.",
"termsRequired": "Трябва да приемете условията и правилата.",
"usernameRequired": "Изисква се потребителско име.",
@@ -920,7 +920,7 @@
"strengthFair": "Средна",
"strengthGood": "Добра",
"strengthStrong": "Силна",
"passwordMinLength": "Паролата трябва да е поне 12 знака",
"passwordMinLength": "Паролата трябва да е поне {min} знака",
"passwordUpper": "Паролата трябва да съдържа поне една главна буква",
"passwordLower": "Паролата трябва да съдържа поне една малка буква",
"passwordDigit": "Паролата трябва да съдържа поне една цифра",
@@ -955,10 +955,10 @@
"reset": {
"title": "Задайте нова парола",
"subtitle": "Изберете силна парола, която не използвате другаде.",
"passwordPlaceholder": "Нова парола (мин. 12 знака)",
"passwordPlaceholder": "Нова парола (мин. {min} знака)",
"resetPassword": "Нулирайте паролата",
"backToLogin": "Назад към входа",
"passwordMinLength": "Паролата трябва да е поне 12 знака",
"passwordMinLength": "Паролата трябва да е поне {min} знака",
"tooManyAttempts": "Твърде много опити — опитайте по-късно",
"invalidLink": "Този линк за нулиране е невалиден или е изтекъл. Поискайте нов.",
"failed": "Нулирането на паролата не бе успешно. Моля, опитайте отново."
+5 -5
View File
@@ -885,7 +885,7 @@
"confirmPassword": "Opakujte heslo",
"usernamePlaceholder": "Uživatelské jméno",
"emailPlaceholder": "Email",
"passwordPlaceholder": "Heslo (min. 12 znaků)",
"passwordPlaceholder": "Heslo (min. {min} znaků)",
"confirmPasswordPlaceholder": "Opakujte heslo",
"selectOutfit": "Vyberte si oblečení",
"termsAccept": "Je mi 18+ a souhlasím s podmínkami a pravidly {hotel}.",
@@ -897,7 +897,7 @@
"termsError": "Chcete-li se zaregistrovat, musíte přijmout podmínky a pravidla.",
"usernameError": "Uživatelské jméno je povinné.",
"emailError": "Je vyžadován platný e-mail.",
"passwordError": "Heslo musí mít alespoň 12 znaků",
"passwordError": "Heslo musí mít alespoň {min} znaků",
"confirmPasswordError": "Hesla se neshodují.",
"termsRequired": "Musíte přijmout podmínky a pravidla.",
"usernameRequired": "Uživatelské jméno je povinné.",
@@ -920,7 +920,7 @@
"strengthFair": "Ucházející",
"strengthGood": "Dobré",
"strengthStrong": "Silné",
"passwordMinLength": "Heslo musí mít alespoň 12 znaků",
"passwordMinLength": "Heslo musí mít alespoň {min} znaků",
"passwordUpper": "Heslo musí obsahovat alespoň jedno velké písmeno",
"passwordLower": "Heslo musí obsahovat alespoň jedno malé písmeno",
"passwordDigit": "Heslo musí obsahovat alespoň jednu číslici",
@@ -955,10 +955,10 @@
"reset": {
"title": "Nastavte nové heslo",
"subtitle": "Vyberte si silné heslo, které jinde nepoužíváte.",
"passwordPlaceholder": "Nové heslo (min. 12 znaků)",
"passwordPlaceholder": "Nové heslo (min. {min} znaků)",
"resetPassword": "Obnovit heslo",
"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",
"invalidLink": "Tento odkaz pro obnovu je neplatný nebo vypršel. Vyžádejte si nový.",
"failed": "Heslo nebylo možné obnovit. Zkuste to prosím znovu."
+5 -5
View File
@@ -885,7 +885,7 @@
"confirmPassword": "Gentag adgangskoden",
"usernamePlaceholder": "Brugernavn",
"emailPlaceholder": "E-mail",
"passwordPlaceholder": "Adgangskode (min. 12 tegn)",
"passwordPlaceholder": "Adgangskode (min. {min} tegn)",
"confirmPasswordPlaceholder": "Gentag adgangskoden",
"selectOutfit": "Vælg dit outfit",
"termsAccept": "Jeg er 18+ og accepterer vilkårene og reglerne for {hotel}.",
@@ -897,7 +897,7 @@
"termsError": "Du skal acceptere vilkårene og reglerne for at registrere dig.",
"usernameError": "Brugernavn er påkrævet.",
"emailError": "Gyldig e-mail er påkrævet.",
"passwordError": "Adgangskoden skal være på mindst 12 tegn",
"passwordError": "Adgangskoden skal være på mindst {min} tegn",
"confirmPasswordError": "Adgangskoder stemmer ikke overens.",
"termsRequired": "Du skal acceptere vilkårene og reglerne.",
"usernameRequired": "Brugernavn er påkrævet.",
@@ -920,7 +920,7 @@
"strengthFair": "Middelmådig",
"strengthGood": "God",
"strengthStrong": "Stærk",
"passwordMinLength": "Adgangskoden skal være på mindst 12 tegn",
"passwordMinLength": "Adgangskoden skal være på mindst {min} tegn",
"passwordUpper": "Adgangskoden skal indeholde mindst ét stort bogstav",
"passwordLower": "Adgangskoden skal indeholde mindst ét lille bogstav",
"passwordDigit": "Adgangskoden skal indeholde mindst ét tal",
@@ -955,10 +955,10 @@
"reset": {
"title": "Indstil en ny adgangskode",
"subtitle": "Vælg en stærk adgangskode, du ikke bruger andre steder.",
"passwordPlaceholder": "Ny adgangskode (min. 12 tegn)",
"passwordPlaceholder": "Ny adgangskode (min. {min} tegn)",
"resetPassword": "Nulstil adgangskode",
"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",
"invalidLink": "Dette nulstillingslink er ugyldigt eller udløbet. Anmod om et nyt.",
"failed": "Adgangskoden kunne ikke nulstilles. Prøv igen."
+5 -5
View File
@@ -855,7 +855,7 @@
"confirmPassword": "Passwort wiederholen",
"usernamePlaceholder": "Benutzername",
"emailPlaceholder": "E-Mail",
"passwordPlaceholder": "Passwort (min. 12 Zeichen)",
"passwordPlaceholder": "Passwort (min. {min} Zeichen)",
"confirmPasswordPlaceholder": "Passwort wiederholen",
"selectOutfit": "Wähle dein Outfit",
"termsAccept": "Ich bin 18+ und akzeptiere die {hotel} AGB & Regeln.",
@@ -867,7 +867,7 @@
"termsError": "Du musst die AGB & Regeln akzeptieren, um dich zu registrieren.",
"usernameError": "Benutzername ist erforderlich.",
"emailError": "Eine gültige E-Mail ist erforderlich.",
"passwordError": "Das Passwort muss mindestens 12 Zeichen lang sein",
"passwordError": "Das Passwort muss mindestens {min} Zeichen lang sein",
"confirmPasswordError": "Passwörter stimmen nicht überein.",
"termsRequired": "Du musst die AGB & Regeln akzeptieren.",
"usernameRequired": "Benutzername ist erforderlich.",
@@ -890,7 +890,7 @@
"strengthFair": "Mittel",
"strengthGood": "Gut",
"strengthStrong": "Stark",
"passwordMinLength": "Das Passwort muss mindestens 12 Zeichen lang sein",
"passwordMinLength": "Das Passwort muss mindestens {min} Zeichen lang sein",
"passwordUpper": "Das Passwort muss mindestens einen Großbuchstaben enthalten",
"passwordLower": "Das Passwort muss mindestens einen Kleinbuchstaben enthalten",
"passwordDigit": "Das Passwort muss mindestens eine Ziffer enthalten",
@@ -925,10 +925,10 @@
"reset": {
"title": "Neues Passwort festlegen",
"subtitle": "Wähle ein sicheres Passwort, das du sonst nirgendwo verwendest.",
"passwordPlaceholder": "Neues Passwort (min. 12 Zeichen)",
"passwordPlaceholder": "Neues Passwort (min. {min} Zeichen)",
"resetPassword": "Passwort zurücksetzen",
"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",
"invalidLink": "Dieser Zurücksetzungslink ist ungültig oder abgelaufen. Fordere einen neuen an.",
"failed": "Das Passwort konnte nicht zurückgesetzt werden. Bitte versuche es erneut."
+5 -5
View File
@@ -885,7 +885,7 @@
"confirmPassword": "Επαναλάβετε τον κωδικό πρόσβασης",
"usernamePlaceholder": "Όνομα χρήστη",
"emailPlaceholder": "Email",
"passwordPlaceholder": "Κωδικός πρόσβασης (ελάχ. 12 χαρακτήρες)",
"passwordPlaceholder": "Κωδικός πρόσβασης (ελάχ. {min} χαρακτήρες)",
"confirmPasswordPlaceholder": "Επαναλάβετε τον κωδικό πρόσβασης",
"selectOutfit": "Επιλέξτε το ντύσιμό σας",
"termsAccept": "Είμαι 18+ και αποδέχομαι τους όρους και τους κανόνες του {hotel}.",
@@ -897,7 +897,7 @@
"termsError": "Πρέπει να αποδεχτείτε τους όρους και τους κανόνες για να εγγραφείτε.",
"usernameError": "Απαιτείται όνομα χρήστη.",
"emailError": "Απαιτείται έγκυρο email.",
"passwordError": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον 12 χαρακτήρες",
"passwordError": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον {min} χαρακτήρες",
"confirmPasswordError": "Οι κωδικοί πρόσβασης δεν ταιριάζουν.",
"termsRequired": "Πρέπει να αποδεχτείτε τους όρους και τους κανόνες.",
"usernameRequired": "Απαιτείται όνομα χρήστη.",
@@ -920,7 +920,7 @@
"strengthFair": "Μέτριο",
"strengthGood": "Καλό",
"strengthStrong": "Δυνατό",
"passwordMinLength": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον 12 χαρακτήρες",
"passwordMinLength": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον {min} χαρακτήρες",
"passwordUpper": "Ο κωδικός πρόσβασης πρέπει να περιέχει τουλάχιστον ένα κεφαλαίο γράμμα",
"passwordLower": "Ο κωδικός πρόσβασης πρέπει να περιέχει τουλάχιστον ένα πεζό γράμμα",
"passwordDigit": "Ο κωδικός πρόσβασης πρέπει να περιέχει τουλάχιστον ένα ψηφίο",
@@ -955,10 +955,10 @@
"reset": {
"title": "Ορίστε νέο κωδικό πρόσβασης",
"subtitle": "Επιλέξτε έναν ισχυρό κωδικό πρόσβασης που δεν χρησιμοποιείτε αλλού.",
"passwordPlaceholder": "Νέος κωδικός πρόσβασης (ελάχ. 12 χαρακτήρες)",
"passwordPlaceholder": "Νέος κωδικός πρόσβασης (ελάχ. {min} χαρακτήρες)",
"resetPassword": "Επαναφορά κωδικού πρόσβασης",
"backToLogin": "Επιστροφή στην είσοδο",
"passwordMinLength": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον 12 χαρακτήρες",
"passwordMinLength": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον {min} χαρακτήρες",
"tooManyAttempts": "Πάρα πολλές προσπάθειες — δοκιμάστε αργότερα",
"invalidLink": "Αυτός ο σύνδεσμος επαναφοράς δεν είναι έγκυρος ή έχει λήξει. Ζητήστε νέο.",
"failed": "Δεν ήταν δυνατή η επαναφορά του κωδικού. Δοκιμάστε ξανά."
+5 -5
View File
@@ -1058,7 +1058,7 @@
"confirmPassword": "Repeat password",
"usernamePlaceholder": "Username",
"emailPlaceholder": "Email",
"passwordPlaceholder": "Password (min 12 chars)",
"passwordPlaceholder": "Password (min {min} chars)",
"confirmPasswordPlaceholder": "Repeat password",
"selectOutfit": "Select your outfit",
"termsAccept": "I am 18+ and accept the {hotel} terms & rules.",
@@ -1081,7 +1081,7 @@
"termsError": "You must accept the terms & rules to register.",
"usernameError": "Username is required.",
"emailError": "Valid email is required.",
"passwordError": "Password must be at least 12 characters",
"passwordError": "Password must be at least {min} characters",
"confirmPasswordError": "Passwords do not match.",
"termsRequired": "You must accept the terms & rules.",
"usernameRequired": "Username is required.",
@@ -1093,7 +1093,7 @@
"strengthFair": "Fair",
"strengthGood": "Good",
"strengthStrong": "Strong",
"passwordMinLength": "Password must be at least 12 characters",
"passwordMinLength": "Password must be at least {min} characters",
"passwordUpper": "Password must contain at least one uppercase letter",
"passwordLower": "Password must contain at least one lowercase letter",
"passwordDigit": "Password must contain at least one digit",
@@ -1128,10 +1128,10 @@
"reset": {
"title": "Set a new password",
"subtitle": "Choose a strong password you don't use elsewhere.",
"passwordPlaceholder": "New password (min 12 chars)",
"passwordPlaceholder": "New password (min {min} chars)",
"resetPassword": "Reset password",
"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",
"invalidLink": "This reset link is invalid or has expired. Request a new one.",
"failed": "The password could not be reset. Please try again."
+5 -5
View File
@@ -855,7 +855,7 @@
"confirmPassword": "Repetir contraseña",
"usernamePlaceholder": "Usuario",
"emailPlaceholder": "Correo electrónico",
"passwordPlaceholder": "Contraseña (mín. 12 caracteres)",
"passwordPlaceholder": "Contraseña (mín. {min} caracteres)",
"confirmPasswordPlaceholder": "Repetir contraseña",
"selectOutfit": "Selecciona tu atuendo",
"termsAccept": "Soy mayor de 18 años y acepto los términos y reglas de {hotel}.",
@@ -867,7 +867,7 @@
"termsError": "Debes aceptar los términos y reglas para registrarte.",
"usernameError": "El usuario es obligatorio.",
"emailError": "Se requiere un correo electrónico válido.",
"passwordError": "La contraseña debe tener al menos 12 caracteres",
"passwordError": "La contraseña debe tener al menos {min} caracteres",
"confirmPasswordError": "Las contraseñas no coinciden.",
"termsRequired": "Debes aceptar los términos y reglas.",
"usernameRequired": "El usuario es obligatorio.",
@@ -890,7 +890,7 @@
"strengthFair": "Aceptable",
"strengthGood": "Buena",
"strengthStrong": "Fuerte",
"passwordMinLength": "La contraseña debe tener al menos 12 caracteres",
"passwordMinLength": "La contraseña debe tener al menos {min} caracteres",
"passwordUpper": "La contraseña debe contener al menos una mayúscula",
"passwordLower": "La contraseña debe contener al menos una minúscula",
"passwordDigit": "La contraseña debe contener al menos un dígito",
@@ -925,10 +925,10 @@
"reset": {
"title": "Establecer una nueva contraseña",
"subtitle": "Elige una contraseña segura que no uses en otros sitios.",
"passwordPlaceholder": "Nueva contraseña (mín. 12 caracteres)",
"passwordPlaceholder": "Nueva contraseña (mín. {min} caracteres)",
"resetPassword": "Restablecer contraseña",
"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",
"invalidLink": "Este enlace de restablecimiento no es válido o ha caducado. Solicita uno nuevo.",
"failed": "No se pudo restablecer la contraseña. Vuelve a intentarlo."
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff. Show more