Compare commits
10
Commits
e617ed176a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a8c721111 | ||
|
|
75eada7a59 | ||
|
|
cea88eaa57 | ||
|
|
acbe54fcb7 | ||
|
|
3e69b72513 | ||
|
|
ad65d80d41 | ||
|
|
1ef405be0a | ||
|
|
89526af344 | ||
|
|
27447ce029 | ||
|
|
8dc187483c |
No files matched your search
@@ -36,3 +36,7 @@ db_backup_*.sql
|
||||
*.log
|
||||
.codex
|
||||
.agents
|
||||
|
||||
.docker-install
|
||||
.docker-install.tmp.*
|
||||
.env.install.*
|
||||
+3
-2
@@ -26,9 +26,10 @@ APP_URL=http://localhost:3002
|
||||
AUTH_URL=http://localhost:3002
|
||||
|
||||
# --- IMAGER ---
|
||||
IMAGING_UPSTREAM_URL=http://127.0.0.1:3030/imaging
|
||||
# Avatar imager: Polaris-imager (avatar-imaging-pixinode) serves /avatarimage on 8082.
|
||||
IMAGING_UPSTREAM_URL=http://127.0.0.1:8082/avatarimage
|
||||
# Runtime values: changing these only requires recreating the container.
|
||||
IMAGER_URL=http://127.0.0.1:3030/imaging
|
||||
IMAGER_URL=http://127.0.0.1:8082/avatarimage
|
||||
BADGE_URL=/swf/c_images/album1584
|
||||
# Legacy NEXT_PUBLIC_IMAGER_URL / NEXT_PUBLIC_BADGE_URL are still read at runtime.
|
||||
|
||||
|
||||
@@ -45,3 +45,8 @@ coverage/
|
||||
test-results/
|
||||
playwright-report/
|
||||
blob-report/
|
||||
|
||||
# Local Docker installation metadata
|
||||
.docker-install
|
||||
.docker-install.tmp.*
|
||||
.env.install.*
|
||||
@@ -16,7 +16,7 @@ Features a premium animated homepage (typewriter hero, floating orbs, scroll cou
|
||||
| yarn | >= 4.x | Supported alternative |
|
||||
| MySQL / MariaDB | 8.0+ / 10.6+ | Shared with the emulator |
|
||||
| Docker | 24+ | Optional — for containerized deployment |
|
||||
| DragonflyDB | 1.x+ | Optional — caching, rate limiting, SSE |
|
||||
| Valkey | 8.x+ | Optional — caching, rate limiting, SSE |
|
||||
|
||||
---
|
||||
|
||||
@@ -73,7 +73,7 @@ HOTEL_NAME=YourHotel
|
||||
APP_URL=http://localhost:3002
|
||||
```
|
||||
|
||||
See `.env.example` for all optional variables (RCON, email, DragonflyDB, OAuth, PayPal, etc.).
|
||||
See `.env.example` for all optional variables (RCON, email, Valkey, OAuth, PayPal, etc.).
|
||||
|
||||
### 4. Run CMS Migrations
|
||||
|
||||
@@ -217,6 +217,50 @@ local `/swf/c_images/album1584` fallback. Public URL resolution uses runtime `AP
|
||||
After changing `.env`, recreate the container; an image rebuild is not required.
|
||||
`NEXT_PUBLIC_CMS_RELEASE` deliberately remains compiled into the image.
|
||||
|
||||
### Guided installation from this repository
|
||||
|
||||
On a Linux host with Docker Engine, the Compose plugin, Git and `flock` available:
|
||||
|
||||
```bash
|
||||
git clone https://gitlab.epicnabbo.nl/remco/EpicNext-Cms.git
|
||||
cd EpicNext-Cms
|
||||
bash cms install
|
||||
```
|
||||
|
||||
The wizard asks for your public URL, delivery method, hotel name, existing database
|
||||
connection, Redis and avatar imager. It generates an authentication secret and
|
||||
creates `.env` with private permissions. An existing `.env` is preserved; review
|
||||
its `APP_URL`, `AUTH_URL`, RCON and optional original Laravel `APP_KEY` when moving
|
||||
an existing hotel. This installs the CMS, not the Habbo database, emulator, Redis
|
||||
or reverse proxy. Point HTTPS at port 3002 before running the final public check.
|
||||
The wizard may request sudo to prepare persistent directories for UID/GID 33;
|
||||
it does not recursively change ownership of existing assets.
|
||||
|
||||
Choose **prebuilt** to download the images specified by this repository in
|
||||
`docker-image.txt`, without entering a registry namespace or commit tag. Choose
|
||||
**source** when building your own fork on the installation host. Private packages
|
||||
still require `docker login` on that host. Maintainers must update `docker-image.txt`
|
||||
if the publication registry or namespace changes.
|
||||
|
||||
After installation, update with:
|
||||
|
||||
```bash
|
||||
bash cms update
|
||||
```
|
||||
|
||||
The updater pulls the configured Git upstream, loads `.docker-install`, uses the
|
||||
matching application/migration images and verifies the running release locally
|
||||
and at the saved public URL. If publication for the new commit is still running,
|
||||
it stops before replacing the current container; run the same command after CI
|
||||
succeeds. Existing application rollback remains available on a failed cutover;
|
||||
database migrations are not reversed.
|
||||
|
||||
`bash cms install --configure-only` saves configuration without preparing runtime
|
||||
directories or starting containers. Neither `.env` nor `.docker-install` is
|
||||
committed or sent in Docker build contexts. Existing users of
|
||||
`bash scripts/docker-update.sh` retain the previous behavior when no wizard
|
||||
profile exists; explicit `CMS_IMAGE_REPOSITORY`/`CMS_PUBLIC_URL` overrides still work.
|
||||
|
||||
To publish from Gitea:
|
||||
|
||||
Gitea packages belong to an account or organization, independently of repository
|
||||
@@ -248,7 +292,7 @@ Publication uses checksum-pinned regctl v0.11.6 with 8 MiB blob requests to
|
||||
avoid monolithic layer uploads exceeding reverse-proxy limits. Both images are
|
||||
exported and uploaded sequentially; temporary archives and credentials are removed
|
||||
on exit. The runner needs curl, sha256sum and temporary disk space for one Docker
|
||||
image archive. The remote image config digest is checked against the local image
|
||||
image archive plus its extracted OCI layout. The remote image config digest is checked against the locally normalized archive
|
||||
after each upload. A proxy must still allow the OCI registry PATCH/PUT endpoints.
|
||||
|
||||
For this repository the image base is
|
||||
@@ -349,7 +393,7 @@ docker compose build --build-arg RUN_USER=1000
|
||||
|
||||
### Networking
|
||||
|
||||
The CMS container runs with `network_mode: host`. This lets the existing `127.0.0.1` references in `.env` keep working against host services — MariaDB (`3306`), DragonflyDB/Redis (`6379`), the emulator RCON/API (`3003`/`3001`) and the avatar imager — without re-writing any environment variables. The container consequently listens **directly on the host's port 3002**, so stop any previous host-side CMS (e.g. `pm2 stop next`) that occupies that port before starting it.
|
||||
The CMS container runs with `network_mode: host`. This lets the existing `127.0.0.1` references in `.env` keep working against host services — MariaDB (`3306`), Valkey/Redis (`6379`), the emulator RCON/API (`3003`/`3001`) and the avatar imager — without re-writing any environment variables. The container consequently listens **directly on the host's port 3002**, so stop any previous host-side CMS (e.g. `pm2 stop next`) that occupies that port before starting it.
|
||||
|
||||
The **build** also runs on the host network (`build.network: host`) because this host disables Docker iptables (`/etc/docker/daemon.json`: `"iptables": false`), which would otherwise leave build containers without outbound NAT/DNS when fetching packages.
|
||||
|
||||
@@ -463,28 +507,35 @@ The script sets `FOREIGN_KEY_CHECKS=0` for the session and is safe to interrupt:
|
||||
|
||||
---
|
||||
|
||||
## DragonflyDB (Caching, Rate Limiting, SSE)
|
||||
## Valkey (Caching, Rate Limiting, SSE)
|
||||
|
||||
DragonflyDB is a drop-in, Redis-compatible in-memory datastore. The CMS connects to it via `REDIS_URL` using the `ioredis` client. It is optional: without it the CMS falls back to in-process memory.
|
||||
Valkey is a drop-in, Redis-compatible open-source in-memory datastore (successor to Redis OSS). The CMS connects to it via `REDIS_URL` using the `ioredis` client. It is optional: without it the CMS falls back to in-memory memory.
|
||||
|
||||
### Install (Ubuntu/Debian)
|
||||
|
||||
```bash
|
||||
curl -fsSL -o /tmp/dragonfly_amd64.deb \
|
||||
https://github.com/dragonflydb/dragonfly/releases/download/v1.40.1/dragonfly_amd64.deb
|
||||
apt-get install -y /tmp/dragonfly_amd64.deb
|
||||
systemctl enable --now dragonfly
|
||||
# Option 1: Package repository (recommended)
|
||||
curl -fsSL https://packages.valkey.io/valkey-pgp-key.gpg | sudo gpg --dearmor -o /usr/share/keyrings/valkey-archive-keyring.gpg
|
||||
echo "deb [signed-by=/usr/share/keyrings/valkey-archive-keyring.gpg] https://packages.valkey.io/apt $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/valkey.list
|
||||
apt-get update && apt-get install -y valkey
|
||||
|
||||
# Option 2: Direct .deb download
|
||||
curl -fsSL -o /tmp/valkey_amd64.deb \
|
||||
https://github.com/valkey-io/valkey/releases/download/8.1.1/valkey_8.1.1-1_amd64.deb
|
||||
apt-get install -y /tmp/valkey_amd64.deb
|
||||
|
||||
systemctl enable --now valkey
|
||||
```
|
||||
|
||||
### Configure
|
||||
|
||||
Edit `/etc/dragonfly/dragonfly.conf`:
|
||||
Edit `/etc/valkey/valkey.conf`:
|
||||
|
||||
```ini
|
||||
--bind=127.0.0.1
|
||||
--port=6379
|
||||
--maxmemory=2gb
|
||||
--version_check=false
|
||||
bind 127.0.0.1
|
||||
port 6379
|
||||
maxmemory 2gb
|
||||
maxmemory-policy allkeys-lru
|
||||
```
|
||||
|
||||
### Point the CMS at it
|
||||
@@ -690,7 +741,7 @@ For bulk-written tables (game-data JSON, imports) a containerized **`mariadb-tur
|
||||
|
||||
### 4. Cache layers (Redis + CDN)
|
||||
|
||||
The CMS caches through `cached()` / `cachedQuery()` (`src/lib/cache.ts`): an in-memory fast path with a Redis (DragonflyDB-compatible) fallback and single-flight protection against cache stampedes. Public API routes (home, leaderboard, online count, radio, photos, …) fill Redis keys per route; verify with `redis-cli DBSIZE`. With Cloudflare in front, static and `/imaging/*` responses are additionally cached edge-side (`cf-cache-status`), cutting origin load further.
|
||||
The CMS caches through `cached()` / `cachedQuery()` (`src/lib/cache.ts`): an in-memory fast path with a Redis (Valkey-compatible) fallback and single-flight protection against cache stampedes. Public API routes (home, leaderboard, online count, radio, photos, …) fill Redis keys per route; verify with `redis-cli DBSIZE`. With Cloudflare in front, static and `/imaging/*` responses are additionally cached edge-side (`cf-cache-status`), cutting origin load further.
|
||||
|
||||
---
|
||||
|
||||
@@ -747,7 +798,7 @@ pm2 restart next
|
||||
| **View Transitions API** | Native browser transitions between page navigations |
|
||||
| **Lenis Smooth Scroll** | Fluid, customizable scrolling |
|
||||
| **Server-Sent Events** | Real-time radio now-playing & listeners via SSE |
|
||||
| **DragonflyDB Caching** | Caches API responses up to 30s in DragonflyDB |
|
||||
| **Valkey Caching** | Caches API responses up to 30s in Valkey |
|
||||
| **RCON (TCP Socket)** | Live commands to the emulator (credits, badges, kick, ban) |
|
||||
| **Streaming & Suspense** | Next.js App Router streaming for fast page loads |
|
||||
| **Automatic Image Optimization** | `next/image` with Sharp for resizing and WebP/AVIF |
|
||||
@@ -782,7 +833,7 @@ pm2 restart next
|
||||
│ │ ├── auth/ # NextAuth, password hashing, 2FA, SSO tickets
|
||||
│ │ ├── services/ # RCON, email, currency, PayPal, alerts
|
||||
│ │ ├── db.ts # Drizzle connection singleton (runtime)
|
||||
│ │ ├── redis.ts # Cache client (ioredis → DragonflyDB)
|
||||
│ │ ├── redis.ts # Cache client (ioredis → Valkey)
|
||||
│ │ └── motion.ts # Framer Motion animation variants
|
||||
│ ├── messages/ # i18n translations (en, nl, de, fr, es, it, pt, da, no, sv)
|
||||
│ └── env.ts # Zod-validated environment schema
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
case "${1:-help}" in
|
||||
install) shift; exec bash "$DIR/scripts/docker-install.sh" "$@" ;;
|
||||
update) shift; [[ $# = 0 ]] || { echo "Usage: bash cms update" >&2; exit 1; }; exec bash "$DIR/scripts/docker-update.sh" ;;
|
||||
help|--help|-h) printf '%s\n' 'bash cms install Configure and install on a Linux Docker host' 'bash cms update Update using the saved installation settings' ;;
|
||||
*) echo "Unknown command. Use: bash cms install | update" >&2; exit 1 ;;
|
||||
esac
|
||||
+14
-84
@@ -6,16 +6,8 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
NEXT_DEPLOYMENT_ID: ${CMS_RELEASE:-unknown}
|
||||
# The host disables Docker iptables (daemon.json: "iptables": false), so
|
||||
# build containers on the bridge network have no outbound NAT/DNS. Build on
|
||||
# the host network instead so pnpm/npm/yarn can reach the registry.
|
||||
network: host
|
||||
container_name: epicnext-cms
|
||||
# Runs on the host network so existing 127.0.0.1 refs in .env keep working:
|
||||
# MariaDB (3306), DragonflyDB/Redis (6379), emulator RCON (3003) + API (3001),
|
||||
# and the imaging renderer (8082). The container then listens directly on the
|
||||
# host's 3002 (the same port the current host-side CMS uses).
|
||||
# NOTE: stop the host CMS (next-server on 3002) first, otherwise the port is taken.
|
||||
network_mode: host
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
@@ -23,32 +15,14 @@ services:
|
||||
environment:
|
||||
- HOSTNAME=0.0.0.0
|
||||
volumes:
|
||||
# ── Write targets (runtime imports/uploads, persistent on the host) ──
|
||||
# The CMS writes imported furni/figures/pets/effects here (see
|
||||
# src/lib/services/furni-asset-dirs.ts). These must be RW, and owned by
|
||||
# UID/GID 33 (www-data) on the host so the container user can write to them:
|
||||
# sudo chown -R 33:33 ./public/nitro-assets ./public/swf ./storage
|
||||
- ./public/nitro-assets:/app/public/nitro-assets
|
||||
- ./public/swf:/app/public/swf
|
||||
# Runtime uploaded media (persistent on the host).
|
||||
- ./storage:/app/storage
|
||||
|
||||
# ── Shared gamedata (absolute path the CMS hardcodes & writes to) ──
|
||||
# src/lib/services/furni-asset-dirs.ts: `DEFAULT_GAMEDATA_ROOT =
|
||||
# /var/www/Gamedata`. nginx on the host also serves /gamedata/ from this
|
||||
# same directory, so mount it into the container at the same absolute path.
|
||||
# Must be RW so furniture/badge imports can write mirrors to it.
|
||||
- /var/www/Gamedata:/var/www/Gamedata
|
||||
#
|
||||
# ── node_modules corruption (§ host-sync) ──
|
||||
# pnpm node_modules must NEVER be a host bind-mount: shared-filesystem
|
||||
# sync (Docker Desktop gRPC-FUSE/VirtioFS, Unison, Syncthing) corrupts
|
||||
# pnpm's hardlinked store and .bin shims. The production image already
|
||||
# bakes node_modules in at build time and is NOT bind-mounted here.
|
||||
# For local dev use a *named volume* instead of a host bind:
|
||||
# - node_modules:/app/node_modules
|
||||
# and never share `node_modules` / `pnpm store` via the Docker bind.
|
||||
# On macOS add `:cached`/`:delegated` consistency labels per mount.
|
||||
|
||||
# ── Resource limits aangepast voor 24 GB RAM ──
|
||||
# Verwijderd: mem_limit, memswap_limit en cpus. Next.js mag onbeperkt presteren.
|
||||
pids_limit: 1024 # Verhoogd van 512 zodat Node.js/Next.js oneindig veel async requests aankan
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "fetch('http://localhost:3002/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
|
||||
@@ -56,13 +30,8 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
mem_limit: 2g
|
||||
|
||||
# ── Byparr (Cloudflare bypass for clone sources) ──
|
||||
# Solves Cloudflare/TLS-fingerprint blocks via a headless Chrome browser.
|
||||
# Drop-in successor to FlareSolverr. The CMS calls this on
|
||||
# http://localhost:8191 for sources that block Node's TLS fingerprint
|
||||
# (e.g. Leet.city). Used by src/lib/services/byparr.ts.
|
||||
byparr:
|
||||
image: ghcr.io/thephaseless/byparr:latest
|
||||
container_name: byparr
|
||||
@@ -70,42 +39,19 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- LOG_LEVEL=INFO
|
||||
mem_limit: 2g
|
||||
|
||||
# Resource limits verwijderd: Headless Chrome heeft bij zware pagina-scrapes
|
||||
# soms tijdelijk meer dan 1 GB RAM nodig. Nu krijgt hij alle ruimte.
|
||||
pids_limit: 256
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:8191/health"]
|
||||
test: ["CMD", "curl", "http://localhost:8191/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
# ── Opt-in: Octane-Renderer (Habbo avatar imager) ──
|
||||
# Serves /imaging on port 3030 (the CMS proxies /imaging to it). Renders
|
||||
# avatars into /var/www/Gamedata/habbo-imaging, so it needs RW access.
|
||||
# Disabled by default — uncomment to run the renderer as a container.
|
||||
# imager:
|
||||
# build:
|
||||
# context: /var/www/Octane-Renderer
|
||||
# container_name: epicnext-octane-renderer
|
||||
# ports:
|
||||
# - "3030:3030"
|
||||
# restart: unless-stopped
|
||||
# volumes:
|
||||
# - /var/www/Gamedata:/var/www/Gamedata
|
||||
|
||||
# ── MariaDB "Turbo" (heavy JSON / bulk-loads) ──
|
||||
# Dedicated MariaDB tuned for >50 MB JSON imports. All tuning lives inline
|
||||
# in the service `command:` (bulk_insert_buffer_size,
|
||||
# innodb_flush_log_at_trx_commit=2, max_allowed_packet=512M, ...) so the
|
||||
# container configures itself — no external .cnf to mount or keep in sync.
|
||||
# Opt-in via profile so `docker compose up` keeps running the standalone
|
||||
# stack as today.
|
||||
#
|
||||
# Start: docker compose --profile db up -d (= pnpm db:up)
|
||||
# Note: host networking binds 127.0.0.1:3306 → STOP the host MariaDB
|
||||
# first (the app's .env DATABASE_URL points at localhost:3306).
|
||||
# Fixes the "Pulling schema from database..." hang: raise
|
||||
# net_read/net_write_timeout (done above) + stop imports while
|
||||
# running `pnpm db:generate` / drizzle-kit introspection.
|
||||
mariadb-turbo:
|
||||
image: mariadb:11
|
||||
container_name: mariadb-turbo
|
||||
@@ -113,31 +59,23 @@ services:
|
||||
network_mode: host
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# mariadb:11 uses MARIADB_*; MYSQL_* also works but is deprecated there.
|
||||
- MARIADB_ROOT_PASSWORD=${MARIADB_ROOT_PASSWORD:-root}
|
||||
- MARIADB_DATABASE=${MARIADB_DATABASE:-habbo}
|
||||
- MARIADB_USER=${MARIADB_USER:-cms}
|
||||
- MARIADB_PASSWORD=${MARIADB_PASSWORD:-cms}
|
||||
- MARIADB_AUTO_UPGRADE=1
|
||||
# MariaDB tunes itself — the Official image appends these flags to mysqld,
|
||||
# no external .cnf file needed.
|
||||
command: [
|
||||
# Charset
|
||||
"--character-set-server=utf8mb4",
|
||||
"--collation-server=utf8mb4_unicode_ci",
|
||||
# 50 MB JSON batches: raise the 16 MB default packet ceiling.
|
||||
"--max-allowed-packet=512M",
|
||||
"--net-buffer-length=1M",
|
||||
# Timeouts — fixes the "Pulling schema from database..." hang
|
||||
# (drizzle-kit introspection no longer starves behind big imports).
|
||||
"--connect-timeout=30",
|
||||
"--wait-timeout=3600",
|
||||
"--interactive-timeout=3600",
|
||||
"--net-read-timeout=600",
|
||||
"--net-write-timeout=600",
|
||||
# InnoDB: durability/performance trade-off for bulk writes.
|
||||
"--innodb-flush-log-at-trx-commit=2",
|
||||
"--innodb-buffer-pool-size=2G",
|
||||
"--innodb-buffer-pool-size=2G", # Perfect ingesteld voor een 24 GB server!
|
||||
"--innodb-buffer-pool-instances=4",
|
||||
"--innodb-log-file-size=1G",
|
||||
"--innodb-log-buffer-size=64M",
|
||||
@@ -145,35 +83,27 @@ services:
|
||||
"--innodb-autoextend-increment=512",
|
||||
"--innodb-max-dirty-pages-pct=90",
|
||||
"--bulk-insert-buffer-size=512M",
|
||||
# Raise so the engine nearly never double-writes during a 50 MB load.
|
||||
"--innodb-doublewrite=0",
|
||||
# libaio/native_aio misbehaves in some containers; io_uring path is fine.
|
||||
"--innodb-use-native-aio=0",
|
||||
# Temp tables used when MariaDB scans JSON blobs cannot be indexed.
|
||||
"--tmp-table-size=256M",
|
||||
"--max-heap-table-size=256M",
|
||||
"--read-buffer-size=4M",
|
||||
"--read-rnd-buffer-size=16M",
|
||||
# Save ~1-2 GB RAM; bulk loads don't need the instrumentation.
|
||||
"--performance-schema=OFF",
|
||||
"--skip-name-resolve",
|
||||
]
|
||||
volumes:
|
||||
# Named volume, NOT a host bind — shared-filesystem sync trashes InnoDB
|
||||
# files the same way it trashes pnpm's node_modules. Named volumes live
|
||||
# inside the container filesystem, so 50 MB of JSON writes never cross a
|
||||
# host-sync boundary.
|
||||
- mariadb-turbo-data:/var/lib/mysql
|
||||
healthcheck:
|
||||
# healthcheck.sh ships in the official mariadb image.
|
||||
test: ["CMD-SHELL", "healthcheck.sh --connect --innodb_initialized || mariadb-admin ping --silent"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
mem_limit: 4g
|
||||
|
||||
# Geoptimaliseerd: We verhogen de limiet naar 6 GB of halen hem volledig weg,
|
||||
# zodat de InnoDB buffer pool en zware JSON imports nooit Out Of Memory gaan.
|
||||
|
||||
# Named volumes declared once; used by the MariaDB service above.
|
||||
volumes:
|
||||
mariadb-turbo-data:
|
||||
driver: local
|
||||
@@ -0,0 +1 @@
|
||||
gitlab.epicnabbo.nl/simo/epicnext-cms
|
||||
@@ -81,3 +81,36 @@ Validation: 1,506 tests passed (six skipped), type checking, lint, translation
|
||||
contracts and a production build with fixture configuration. A browser fixture
|
||||
verified the real dialog at 1280 and 390 pixels; server actions were simulated.
|
||||
The new database migration and package publication have not run in production.
|
||||
|
||||
## Housekeeping search and user overview
|
||||
|
||||
The existing global search now includes furniture, normal/Club catalog categories
|
||||
and both ticket sources. Results respect module permissions, accept single-digit
|
||||
IDs, and remain usable when one source fails. Keyboard navigation and cancellation
|
||||
prevent stale search responses from replacing newer results.
|
||||
|
||||
User details open on an operational overview with up to five records from each
|
||||
authorized source: bans, active mute, support tickets, help tickets, reports,
|
||||
payments, catalog purchases and audit activity. Failed sources are distinguished
|
||||
from empty results. User detail and edit pages also apply log permissions before
|
||||
loading activity and exposing counters.
|
||||
|
||||
Italian and Dutch navigation, user management, news and support labels were
|
||||
reviewed. The new search and overview copy has English, Italian and Dutch text;
|
||||
other locales receive English fallback strings. This is a focused editorial pass,
|
||||
not a full translation of every CMS page. No database migration or dependency
|
||||
change is required. Browser checks use real components with simulated data at
|
||||
1280 and 390 pixels; production database behavior still needs deployment validation.
|
||||
|
||||
## Operational reliability and recovery
|
||||
|
||||
- Audit history now records category settings (normal/Club), individual/bulk offer prices, update-mode package publication and news edits inside the write transaction. The audit screen previews and restores individual changes after locking and comparing the current recorded fields. Deleted records, hierarchy changes, LTD counters, imports and historical entries without complete snapshots cannot be restored. Restores create their own history entry. Apply migration `0028_history_snapshots.sql` before running this version: it widens audit snapshots to MEDIUMTEXT without deleting existing data.
|
||||
- `/admin/operations` reuses durable import jobs, stable history pagination and owner-scoped failed-item retries. A deterministic child ID prevents duplicate retries. The shared Git export queue displays actual pending/running state and latest result; synchronous/SSE synchronization remains linked rather than represented as a durable job history.
|
||||
- Catalog maintenance includes a read-only integrity report for normal/Club categories, offers, furniture references and local icons. Known sentinel IDs are preserved. Only categories pointing to a missing positive parent have an automated repair: preview lists every affected category and apply compares the locked graph before reattaching those categories at the root. No records are deleted. Other issues require an explicit manual edit. Reports display up to 200 issues with complete counts; unavailable icon storage is distinguished from missing assets.
|
||||
- CMS errors support exact release and time filters plus frequency sorting. Counts refer to retained matching events, while group resolution remains current across releases.
|
||||
- User/settings forms now protect unsaved edits and preserve failed submissions. User/news validation errors appear at the affected fields; settings show returned validation errors inline. Existing submission locking is retained and tested in a browser.
|
||||
- `/admin/permissions/preview` shows one role's section access and known CMS grants using live ACL and the existing highest-rank policy. It never changes sessions. Additional user roles, navigation customization and record-specific authorization remain explicit limits of the preview.
|
||||
|
||||
New UI copy is supplied in English, Italian and Dutch; other locales receive English fallback strings. No new runtime dependencies. Browser fixtures use real UI components with simulated server responses; the database migration and production behavior have not been exercised on the live hotel.
|
||||
|
||||
Validation for this increment: 1,589 tests passed, six skipped; TypeScript, Biome, translation contracts and fixture production build passed. Browser checks covered user/settings/news forms, permission preview, CMS errors, integrity preview and history restore at 1280 and 390 pixels. Double submission, stale preview, blocked navigation, field focus and horizontal overflow were checked with simulated server actions. No live database writes or deployment were performed.
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "mysql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "5",
|
||||
"when": 1788791201804,
|
||||
"tag": "0000_premium_spirit",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Preserve complete before/after snapshots for long news articles.
|
||||
ALTER TABLE admin_audit_log MODIFY COLUMN `before` MEDIUMTEXT NULL, MODIFY COLUMN `after` MEDIUMTEXT NULL;
|
||||
@@ -1,49 +0,0 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:recommended", "helpers:disableTypesNodeMajor"],
|
||||
"labels": ["dependencies"],
|
||||
"rangeStrategy": "bump",
|
||||
"dependencyDashboard": true,
|
||||
"dependencyDashboardTitle": "Dependency Dashboard",
|
||||
"commitMessagePrefix": "chore(deps):",
|
||||
"commitMessageAction": "update",
|
||||
"schedule": ["at any time"],
|
||||
"timezone": "Europe/Amsterdam",
|
||||
"rebaseWhen": "conflicted",
|
||||
"prCreation": "immediate",
|
||||
"prConcurrentLimit": 5,
|
||||
"packageRules": [
|
||||
{
|
||||
"matchDepTypes": ["devDependencies"],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"groupName": "Development tools",
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"matchPackageNames": [
|
||||
"@base-ui/react",
|
||||
"lucide-react",
|
||||
"motion",
|
||||
"@dnd-kit/**"
|
||||
],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"groupName": "Interface libraries",
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"matchPackageNames": ["vitest", "@vitest/coverage-v8"],
|
||||
"groupName": "Vitest",
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"matchUpdateTypes": ["major"],
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"matchPackageNames": ["node", "pnpm"],
|
||||
"enabled": false
|
||||
}
|
||||
],
|
||||
"minimumReleaseAge": "3 days",
|
||||
"automerge": false
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import "./load-env";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
async function main() {
|
||||
const [dp] = (await db.execute(sql`
|
||||
SELECT CAST(parent_id AS CHAR) parentId, CAST(order_num AS CHAR) orderNum, COUNT(*) c, GROUP_CONCAT(id) ids
|
||||
FROM catalog_pages
|
||||
GROUP BY parent_id, order_num
|
||||
HAVING COUNT(*) > 1
|
||||
`)) as unknown as [Array<Record<string, unknown>>, unknown];
|
||||
console.log("=== catalog_pages duplicate (parent, order_num) groups ===");
|
||||
console.log(JSON.stringify(dp));
|
||||
|
||||
const [children] = (await db.execute(sql`
|
||||
SELECT CAST(parent_id AS CHAR) parentId, COUNT(*) c
|
||||
FROM catalog_pages
|
||||
WHERE parent_id <> 0
|
||||
GROUP BY parent_id ORDER BY c DESC LIMIT 8
|
||||
`)) as unknown as [Array<Record<string, unknown>>, unknown];
|
||||
console.log("=== most children per parent ===");
|
||||
console.log(JSON.stringify(children));
|
||||
|
||||
const [pages] = (await db.execute(sql`
|
||||
SELECT CAST(id AS CHAR) id, CAST(parent_id AS CHAR) parentId, CAST(order_num AS CHAR) orderNum, caption_save caption, enabled
|
||||
FROM catalog_pages WHERE id IN (353,354,355,356,357,358)
|
||||
`)) as unknown as [Array<Record<string, unknown>>, unknown];
|
||||
console.log("=== pages 353-358 ===");
|
||||
console.log(JSON.stringify(pages, null, 1));
|
||||
await db.$client?.end?.();
|
||||
}
|
||||
void main();
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
# Read installation metadata as data, never execute it as a shell script.
|
||||
load_docker_config() {
|
||||
local root="$1" key value mode="" public_url="" repository=""
|
||||
[[ -f "$root/.docker-install" ]] || return 0
|
||||
while IFS='=' read -r key value || [[ -n "$key" ]]; do
|
||||
value="${value%$'\r'}"
|
||||
case "$key" in
|
||||
''|'#'*) continue ;;
|
||||
MODE) [[ "$value" = prebuilt || "$value" = source ]] || return 1; mode="$value" ;;
|
||||
PUBLIC_URL) [[ "$value" =~ ^https?://[^[:space:]]+$ && "$value" != *'@'* && "$value" != *'?'* && "$value" != *'#'* ]] || return 1; public_url="$value" ;;
|
||||
*) echo "Unknown installation setting: $key" >&2; return 1 ;;
|
||||
esac
|
||||
done < "$root/.docker-install"
|
||||
[[ -n "$mode" && -n "$public_url" ]] || return 1
|
||||
if [[ "$mode" = prebuilt && ! ${CMS_IMAGE_REPOSITORY+x} ]]; then
|
||||
IFS= read -r repository < "$root/docker-image.txt"
|
||||
repository="${repository%$'\r'}"
|
||||
[[ "$repository" =~ ^[a-z0-9.-]+(:[0-9]+)?/[a-z0-9._/-]+$ ]] || return 1
|
||||
export CMS_IMAGE_REPOSITORY="$repository"
|
||||
fi
|
||||
CMS_INSTALL_MODE="$mode"
|
||||
export CMS_PUBLIC_URL="${CMS_PUBLIC_URL-$public_url}"
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env bash
|
||||
# Guided setup for the existing Linux/host-network Compose deployment.
|
||||
set -Eeuo pipefail
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$DIR"
|
||||
configure_only=0
|
||||
case "${1:-}" in '') ;; --configure-only) configure_only=1 ;; *) echo "Usage: bash cms install [--configure-only]" >&2; exit 1 ;; esac
|
||||
[[ $# -le 1 ]] || exit 1
|
||||
umask 077
|
||||
fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
|
||||
[[ "$(uname -s)" = Linux ]] || fail "Use a Linux Docker host. This Compose deployment uses host networking."
|
||||
for command in docker git flock od tr mktemp; do command -v "$command" >/dev/null || fail "Install required command: $command"; done
|
||||
docker info >/dev/null 2>&1 || fail "Docker is not reachable. Start Docker and check your account permissions."
|
||||
docker compose version >/dev/null 2>&1 || fail "Install the Docker Compose plugin."
|
||||
exec 9>"$DIR/.deploy.lock"
|
||||
flock -w 30 9 || fail "Another installation or update is running."
|
||||
[[ ! -L .env && ! -L .docker-install ]] || fail "Configuration files must not be symbolic links."
|
||||
[[ "$(docker inspect --format '{{.State.Running}}' epicnext-cms-app 2>/dev/null || true)" != true ]] || fail "This host is managed by CI. Do not create a second Compose installation."
|
||||
source "$DIR/scripts/docker-config.sh"
|
||||
load_docker_config "$DIR" || fail "Existing .docker-install is invalid; correct it before continuing."
|
||||
read_value() {
|
||||
local label="$1" default="${2:-}" secret="${3:-0}" value
|
||||
printf '%s' "$label" >&2
|
||||
[[ -z "$default" ]] || printf ' [%s]' "$default" >&2
|
||||
printf ': ' >&2
|
||||
if [[ "$secret" = 1 ]]; then
|
||||
IFS= read -r -s value || fail "Input cancelled."
|
||||
printf '\n' >&2
|
||||
else IFS= read -r value || fail "Input cancelled."; fi
|
||||
REPLY="${value:-$default}"
|
||||
}
|
||||
safe_value() { [[ -n "$1" && "$1" != *"'"* && "$1" != *'$'* && "$1" != *'\'* && ! "$1" =~ [[:cntrl:]] ]]; }
|
||||
http_url() { safe_value "$1" && [[ "$1" =~ ^https?://[^[:space:]]+$ && "$1" != *'@'* && "$1" != *'#'* ]]; }
|
||||
uri_encode() {
|
||||
local LC_ALL=C text="$1" i char
|
||||
for ((i=0;i<${#text};i++)); do
|
||||
char="${text:i:1}"
|
||||
case "$char" in [a-zA-Z0-9.~_-]) printf '%s' "$char" ;; *) printf '%%%02X' "'$char" ;; esac
|
||||
done
|
||||
}
|
||||
printf '%s\n' 'EpicNext-Cms installation' 'Requires an existing Habbo database and Redis. Configure HTTPS/reverse proxy to this host on port 3002.'
|
||||
read_value 'Public CMS URL (e.g. https://hotel.example)' "${CMS_PUBLIC_URL:-}"
|
||||
public_url="${REPLY%/}"
|
||||
http_url "$public_url" && [[ "$public_url" != *'?'* ]] || fail "Enter an HTTP(S) URL without credentials, query or fragment."
|
||||
read_value 'Delivery method: prebuilt or source' "${CMS_INSTALL_MODE:-prebuilt}"
|
||||
mode="$REPLY"
|
||||
[[ "$mode" = prebuilt || "$mode" = source ]] || fail "Choose prebuilt or source."
|
||||
if [[ "$mode" = prebuilt ]]; then
|
||||
IFS= read -r repository < docker-image.txt
|
||||
repository="${repository%$'\r'}"
|
||||
[[ "$repository" =~ ^[a-z0-9.-]+(:[0-9]+)?/[a-z0-9._/-]+$ ]] || fail "Invalid docker-image.txt."
|
||||
printf 'Image location is provided by the repository: %s\n' "$repository"
|
||||
fi
|
||||
env_tmp=""; profile_tmp=""
|
||||
cleanup() { [[ -z "$env_tmp" ]] || rm -f -- "$env_tmp"; [[ -z "$profile_tmp" ]] || rm -f -- "$profile_tmp"; }
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
if [[ -e .env ]]; then
|
||||
[[ -f .env ]] || fail ".env must be a regular file."
|
||||
printf '%s\n' 'Existing .env preserved. Check APP_URL/AUTH_URL and database settings there if needed.'
|
||||
else
|
||||
read_value 'Hotel name'; hotel="$REPLY"; safe_value "$hotel" || fail "Invalid hotel name (avoid quotes, dollar signs and backslashes)."
|
||||
read_value 'Database host' '127.0.0.1'; db_host="$REPLY"
|
||||
[[ "$db_host" =~ ^[a-zA-Z0-9.-]+$ ]] || fail "Invalid database hostname."
|
||||
read_value 'Database port' '3306'; db_port="$REPLY"
|
||||
[[ "$db_port" =~ ^[0-9]{1,5}$ ]] && ((10#$db_port>0 && 10#$db_port<=65535)) || fail "Invalid database port."
|
||||
read_value 'Existing database name'; db_name="$REPLY"; [[ -n "$db_name" ]] || fail "Database name is required."
|
||||
read_value 'Database user'; db_user="$REPLY"; [[ -n "$db_user" ]] || fail "Database user is required."
|
||||
read_value 'Database password (hidden)' '' 1; db_password="$REPLY"
|
||||
database_url="mysql://$(uri_encode "$db_user"):$(uri_encode "$db_password")@$db_host:$db_port/$(uri_encode "$db_name")?charset=utf8mb4"
|
||||
unset db_password
|
||||
read_value 'Redis URL (hidden; percent-encode special characters in credentials)' 'redis://127.0.0.1:6379' 1; redis_url="$REPLY"
|
||||
safe_value "$redis_url" && [[ "$redis_url" =~ ^rediss?://[^[:space:]]+$ ]] || fail "Invalid Redis URL."
|
||||
read_value 'Avatar imager URL'; imager_url="$REPLY"; http_url "$imager_url" || fail "Invalid imager URL."
|
||||
auth_secret="$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n')"
|
||||
[[ "$auth_secret" =~ ^[0-9a-f]{64}$ ]] || fail "Could not generate the authentication secret."
|
||||
env_tmp="$(mktemp "$DIR/.env.install.XXXXXX")"
|
||||
{
|
||||
printf '%s\n' 'NODE_ENV=production' 'PORT=3002' 'NEXT_TELEMETRY_DISABLED=1' 'AUTH_TRUST_HOST=true' 'CONVERT_PASSWORDS=true'
|
||||
printf "HOTEL_NAME='%s'\nAPP_URL='%s'\nAUTH_URL='%s'\n" "$hotel" "$public_url" "$public_url"
|
||||
printf "DATABASE_URL='%s'\nREDIS_URL='%s'\nAUTH_SECRET='%s'\n" "$database_url" "$redis_url" "$auth_secret"
|
||||
printf "IMAGER_URL='%s'\nIMAGING_UPSTREAM_URL='%s'\nBADGE_URL='/swf/c_images/album1584'\n" "$imager_url" "$imager_url"
|
||||
printf '%s\n' 'RCON_HOST=127.0.0.1' 'RCON_PORT=3003' '# Existing Laravel 2FA: add the original APP_KEY when migrating.'
|
||||
} > "$env_tmp"
|
||||
# Atomic creation fails rather than overwriting a concurrently created .env.
|
||||
ln "$env_tmp" "$DIR/.env" || fail ".env already exists; nothing was overwritten."
|
||||
unset auth_secret database_url redis_url
|
||||
fi
|
||||
profile_tmp="$(mktemp "$DIR/.docker-install.tmp.XXXXXX")"
|
||||
printf 'MODE=%s\nPUBLIC_URL=%s\n' "$mode" "$public_url" > "$profile_tmp"
|
||||
mv -f -- "$profile_tmp" "$DIR/.docker-install"; profile_tmp=""
|
||||
printf '%s\n' 'Configuration saved. Credentials remain in .env; installation settings are excluded from Git.'
|
||||
if [[ "$configure_only" = 1 ]]; then printf '%s\n' 'No container was started. Next: bash cms install'; exit 0; fi
|
||||
as_root() { if [[ "$EUID" = 0 ]]; then "$@"; else command -v sudo >/dev/null || fail "sudo is required to prepare runtime directories."; sudo "$@"; fi; }
|
||||
for path in "$DIR/public/nitro-assets" "$DIR/public/swf" "$DIR/storage" /var/www/Gamedata; do
|
||||
[[ ! -L "$path" ]] || fail "Runtime directory is a symlink: $path. Configure its permissions manually."
|
||||
if [[ ! -d "$path" ]]; then as_root install -d -o 33 -g 33 -m 0755 "$path"; fi
|
||||
# Do not recursively change ownership of existing assets.
|
||||
[[ "$(stat -c '%u:%g' "$path")" = 33:33 ]] || as_root chown 33:33 "$path"
|
||||
done
|
||||
cleanup
|
||||
trap - EXIT
|
||||
exec 9>&-
|
||||
# Avoid a stale value exported while reading previous installation settings.
|
||||
unset CMS_IMAGE_REPOSITORY CMS_PUBLIC_URL
|
||||
exec bash "$DIR/scripts/docker-update.sh"
|
||||
@@ -69,6 +69,9 @@ if [ "$script_before" != "$(git hash-object scripts/docker-update.sh)" ]; then
|
||||
exec 9>&-
|
||||
exec bash "$DIR/scripts/docker-update.sh"
|
||||
fi
|
||||
# Load after pulling so image location changes follow the repository.
|
||||
source "$DIR/scripts/docker-config.sh"
|
||||
load_docker_config "$DIR" || die "Invalid .docker-install: expected MODE=prebuilt or source and PUBLIC_URL=http(s)://your-host."
|
||||
export CMS_RELEASE="$(git rev-parse HEAD)"
|
||||
[[ "$CMS_RELEASE" =~ ^[0-9a-f]{40}$ ]] || die "Invalid Git commit."
|
||||
[[ -f .env ]] || die "Create .env before installing or updating."
|
||||
@@ -82,16 +85,16 @@ if [[ -n "$previous_container" ]]; then
|
||||
rollback_tag="rollback-$CMS_RELEASE-$$"
|
||||
docker image tag "$previous_image" "epicnext-cms:$rollback_tag"
|
||||
fi
|
||||
log "Building release $CMS_RELEASE from $DIR"
|
||||
log "Preparing release $CMS_RELEASE from $DIR"
|
||||
# The migrations stage contains matching source and locked dependencies.
|
||||
# No Node/package manager installation on the host is required.
|
||||
migration_image="epicnext-cms-migrations:$CMS_RELEASE"
|
||||
if [[ -n "${CMS_IMAGE_REPOSITORY:-}" ]]; then
|
||||
[[ "$CMS_IMAGE_REPOSITORY" =~ ^[a-z0-9.-]+(:[0-9]+)?/[a-z0-9._/-]+$ ]] || die "Invalid CMS_IMAGE_REPOSITORY; use registry/owner/image without a tag."
|
||||
log "Pulling prebuilt application and matching migrations for $CMS_RELEASE"
|
||||
docker pull "$CMS_IMAGE_REPOSITORY:$CMS_RELEASE" >>"$LOG_FILE" 2>&1
|
||||
docker pull "$CMS_IMAGE_REPOSITORY:$CMS_RELEASE" >>"$LOG_FILE" 2>&1 || die "Application image unavailable. Publication for this commit may still be running; retry bash cms update after CI succeeds. For private packages, log in to the registry first."
|
||||
remote_migration_image="$CMS_IMAGE_REPOSITORY:$CMS_RELEASE-migrations"
|
||||
docker pull "$remote_migration_image" >>"$LOG_FILE" 2>&1
|
||||
docker pull "$remote_migration_image" >>"$LOG_FILE" 2>&1 || die "Matching migrations image unavailable. Current CMS is unchanged; retry after publication succeeds."
|
||||
docker tag "$CMS_IMAGE_REPOSITORY:$CMS_RELEASE" "epicnext-cms:$CMS_RELEASE"
|
||||
docker tag "$CMS_IMAGE_REPOSITORY:$CMS_RELEASE-migrations" "$migration_image"
|
||||
else
|
||||
|
||||
@@ -58,12 +58,16 @@ regctl registry set "$registry" --blob-chunk 8388608 --blob-max 8388608
|
||||
for target in "$image-migrations" "$image"; do
|
||||
echo "Publishing $target with blob requests up to 8 MiB"
|
||||
docker image save --output "$context/image.tar" "$target"
|
||||
regctl image import "$target" "$context/image.tar"
|
||||
# Import may change compression/manifest representation, but the immutable
|
||||
# image config digest must still match the exact local image we verified.
|
||||
expected_config="$(docker image inspect --format '{{.Id}}' "$target")"
|
||||
# Normalize the saved archive locally before copying it unchanged to Gitea.
|
||||
# Docker engine IDs and OCI index IDs are not interchangeable with config IDs.
|
||||
local_ref="ocidir://$context/oci:verified"
|
||||
regctl image import "$local_ref" "$context/image.tar"
|
||||
expected_config="$(regctl manifest get "$local_ref" --platform "linux/$arch" --format '{{.GetConfig.Digest}}')"
|
||||
[[ "$expected_config" =~ ^sha256:[0-9a-f]{64}$ ]] || { echo "Invalid local image config digest" >&2; exit 1; }
|
||||
regctl image copy "$local_ref" "$target"
|
||||
remote_config="$(regctl manifest get "$target" --platform "linux/$arch" --format '{{.GetConfig.Digest}}')"
|
||||
[[ "$remote_config" = "$expected_config" ]] || { echo "Published image config does not match verified local image" >&2; exit 1; }
|
||||
rm -f -- "$context/image.tar"
|
||||
rm -rf -- "$context/oci"
|
||||
done
|
||||
echo "Published application and migrations: $image"
|
||||
@@ -8,6 +8,10 @@ const state = vi.hoisted(() => ({
|
||||
log: vi.fn(() => "news-error-1"),
|
||||
notify: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/features/history/server", () => ({
|
||||
historySnapshot: async () => ({}),
|
||||
recordHistory: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/admin/guard", () => ({
|
||||
requirePermission: async () => ({ id: 1, username: "staff" }),
|
||||
}));
|
||||
|
||||
@@ -5,11 +5,13 @@ import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ArticleDrafts, ArticleRevisions } from "@/db/article-editor";
|
||||
import { historySnapshot, recordHistory } from "@/features/history/server";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { articleEditToken } from "@/lib/article-edit-token";
|
||||
import {
|
||||
ArticleInputError,
|
||||
type ArticleSaveResult,
|
||||
articleInputField,
|
||||
readArticleInput,
|
||||
} from "@/lib/article-input";
|
||||
import {
|
||||
@@ -53,8 +55,14 @@ async function saveFailure(
|
||||
operation: string,
|
||||
): Promise<ArticleSaveResult> {
|
||||
const t = await getTranslations("pages.admin.articles.form");
|
||||
if (error instanceof ArticleInputError)
|
||||
return { ok: false, error: t(error.code) };
|
||||
if (error instanceof ArticleInputError) {
|
||||
const field = articleInputField(error.code);
|
||||
return {
|
||||
ok: false,
|
||||
error: t(error.code),
|
||||
...(field ? { fieldErrors: { [field]: [t(error.code)] } } : {}),
|
||||
};
|
||||
}
|
||||
const reference = logger.error("News save failed", {
|
||||
module: "news",
|
||||
operation,
|
||||
@@ -124,6 +132,8 @@ export async function updateArticle(
|
||||
if (!existing) throw new ArticleInputError("articleNotFound");
|
||||
if (formData.get("baseToken") !== articleEditToken(existing))
|
||||
throw new ArticleInputError("editConflict");
|
||||
const historyId = String(id);
|
||||
const historyBefore = await historySnapshot(tx, "news", historyId);
|
||||
await tx.insert(ArticleRevisions).values({
|
||||
articleId: id,
|
||||
userId: staff.id,
|
||||
@@ -158,6 +168,7 @@ export async function updateArticle(
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(WebsiteArticles.id, id));
|
||||
await recordHistory(tx, "news", historyId, staff.id, historyBefore);
|
||||
});
|
||||
} catch (error) {
|
||||
return saveFailure(error, "update");
|
||||
|
||||
@@ -75,7 +75,7 @@ export async function updateBcPage({
|
||||
return { ok: false as const, error: "No valid fields to update" };
|
||||
}
|
||||
try {
|
||||
await updatePageCommand("bc", id, data, expected);
|
||||
await updatePageCommand("bc", id, data, expected, staff.id);
|
||||
} catch (error) {
|
||||
return { ok: false as const, error: catalogFailure(error).message };
|
||||
}
|
||||
@@ -176,9 +176,9 @@ export async function toggleBcPage({
|
||||
id: number;
|
||||
field: "enabled" | "visible";
|
||||
}) {
|
||||
await requirePermission(PERMS.CATALOG_EDIT);
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
return await withCatalogExport(async () => {
|
||||
await togglePageCommand("bc", id, field);
|
||||
await togglePageCommand("bc", id, field, staff.id);
|
||||
await sendCatalogUpdate();
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
return { ok: true as const };
|
||||
|
||||
@@ -28,7 +28,7 @@ export async function applyBulkOffers(
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
try {
|
||||
return await withCatalogExport(async () => {
|
||||
const data = await applyBulkOffersCommand(input, fingerprint);
|
||||
const data = await applyBulkOffersCommand(input, fingerprint, staff.id);
|
||||
if (data.changedCount > 0) {
|
||||
await logAudit({
|
||||
userId: staff.id,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
guard: vi.fn(),
|
||||
inspect: vi.fn(),
|
||||
preview: vi.fn(),
|
||||
apply: vi.fn(),
|
||||
}));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
vi.mock("@/lib/admin/guard", () => ({
|
||||
requirePermissionRateLimited: mocks.guard,
|
||||
}));
|
||||
vi.mock("@/lib/permissions", () => import("@/lib/permission-slugs"));
|
||||
vi.mock("@/lib/safe-action-shared", () => ({
|
||||
actionOk: (data: unknown) => ({ ok: true, data }),
|
||||
actionError: (error: string) => ({ ok: false, error }),
|
||||
handleActionError: () => ({ ok: false, error: "denied" }),
|
||||
}));
|
||||
vi.mock("@/lib/services/catalog-audit", () => ({
|
||||
inspectLiveCatalogIntegrity: mocks.inspect,
|
||||
}));
|
||||
vi.mock("@/lib/services/catalog-repair", () => ({
|
||||
previewCatalogParentRepair: mocks.preview,
|
||||
applyCatalogParentRepair: mocks.apply,
|
||||
}));
|
||||
vi.mock("@/lib/services/catalog-git-queue", () => ({
|
||||
withCatalogExport: (fn: () => unknown) => fn(),
|
||||
}));
|
||||
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import {
|
||||
applyCatalogIntegrityRepairAction,
|
||||
inspectCatalogIntegrityAction,
|
||||
previewCatalogIntegrityRepairAction,
|
||||
} from "./catalog-integrity";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.guard.mockResolvedValue({ id: 7 });
|
||||
mocks.apply.mockResolvedValue({ applied: 1 });
|
||||
});
|
||||
describe("integrity action permissions", () => {
|
||||
it("allows inspection only after catalog view and preview only after catalog edit", async () => {
|
||||
await inspectCatalogIntegrityAction("normal");
|
||||
expect(mocks.guard).toHaveBeenLastCalledWith(PERMS.CATALOG_VIEW);
|
||||
await previewCatalogIntegrityRepairAction("bc");
|
||||
expect(mocks.guard).toHaveBeenLastCalledWith(PERMS.CATALOG_EDIT);
|
||||
expect(mocks.preview).toHaveBeenCalledWith("bc");
|
||||
});
|
||||
it("denies apply before reading or mutating data without edit permission", async () => {
|
||||
mocks.guard.mockRejectedValue(new Error("denied"));
|
||||
expect(await applyCatalogIntegrityRepairAction("normal", "abc")).toEqual({
|
||||
ok: false,
|
||||
error: "denied",
|
||||
});
|
||||
expect(mocks.guard).toHaveBeenCalledWith(PERMS.CATALOG_EDIT);
|
||||
expect(mocks.apply).not.toHaveBeenCalled();
|
||||
});
|
||||
it("forwards actor identity and translates changed state to a recoverable conflict", async () => {
|
||||
const error = new Error("changed");
|
||||
error.name = "CatalogPreviewConflict";
|
||||
mocks.apply.mockRejectedValue(error);
|
||||
expect(
|
||||
await applyCatalogIntegrityRepairAction("bc", "fingerprint"),
|
||||
).toEqual({ ok: false, error: "stalePreview" });
|
||||
expect(mocks.apply).toHaveBeenCalledWith("bc", "fingerprint", 7);
|
||||
});
|
||||
it("rejects unknown catalog names before executing queries", async () => {
|
||||
await inspectCatalogIntegrityAction("catalog_pages; DELETE");
|
||||
expect(mocks.inspect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import {
|
||||
actionError,
|
||||
actionOk,
|
||||
handleActionError,
|
||||
} from "@/lib/safe-action-shared";
|
||||
import { inspectLiveCatalogIntegrity } from "@/lib/services/catalog-audit";
|
||||
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
|
||||
import { integrityCatalog } from "@/lib/services/catalog-integrity";
|
||||
import {
|
||||
applyCatalogParentRepair,
|
||||
previewCatalogParentRepair,
|
||||
} from "@/lib/services/catalog-repair";
|
||||
|
||||
export async function inspectCatalogIntegrityAction(catalog: unknown) {
|
||||
try {
|
||||
await requirePermissionRateLimited(PERMS.CATALOG_VIEW);
|
||||
return actionOk(
|
||||
await inspectLiveCatalogIntegrity(integrityCatalog(catalog)),
|
||||
);
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function previewCatalogIntegrityRepairAction(catalog: unknown) {
|
||||
try {
|
||||
await requirePermissionRateLimited(PERMS.CATALOG_EDIT);
|
||||
return actionOk(
|
||||
await previewCatalogParentRepair(integrityCatalog(catalog)),
|
||||
);
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyCatalogIntegrityRepairAction(
|
||||
catalog: unknown,
|
||||
fingerprint: unknown,
|
||||
) {
|
||||
try {
|
||||
const staff = await requirePermissionRateLimited(PERMS.CATALOG_EDIT);
|
||||
const kind = integrityCatalog(catalog);
|
||||
if (typeof fingerprint !== "string") return actionError("Invalid preview");
|
||||
const result = await withCatalogExport(() =>
|
||||
applyCatalogParentRepair(kind, fingerprint, staff.id),
|
||||
);
|
||||
revalidatePath("/admin/catalog");
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
return actionOk(result);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "CatalogPreviewConflict")
|
||||
return { ok: false as const, error: "stalePreview" as const };
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
@@ -144,6 +144,12 @@ export async function bulkCreateCatalogItems({
|
||||
let created = 0;
|
||||
let failed = 0;
|
||||
|
||||
// Find the next order number on the target page to avoid collisions.
|
||||
const [maxOrder] = (await db.execute(sql`
|
||||
SELECT COALESCE(MAX(order_number), 0) AS maxOrder FROM catalog_items WHERE page_id = ${String(pageId)}
|
||||
`)) as unknown as [{ maxOrder: number }, unknown];
|
||||
let nextOrder = Number(maxOrder?.maxOrder ?? 0) + 1;
|
||||
|
||||
for (const row of rows) {
|
||||
const base = baseMap.get(row.baseId);
|
||||
if (!base) {
|
||||
@@ -161,7 +167,7 @@ export async function bulkCreateCatalogItems({
|
||||
amount: 1,
|
||||
limitedSells: 0,
|
||||
limitedStack: 0,
|
||||
orderNumber: 1,
|
||||
orderNumber: nextOrder++,
|
||||
offerId: -1,
|
||||
songId: 0,
|
||||
haveOffer: "1",
|
||||
@@ -251,7 +257,7 @@ export async function updateCatalogItem({
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
return await withCatalogExport(async () => {
|
||||
try {
|
||||
await updateOfferCommand({ id, catalogFields, baseItem });
|
||||
await updateOfferCommand({ id, catalogFields, baseItem }, staff.id);
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false as const,
|
||||
|
||||
@@ -65,7 +65,7 @@ export async function publishCatalogPackage(input: PublishCatalogPackageInput) {
|
||||
const warnings: string[] = [];
|
||||
try {
|
||||
await withCatalogExport(async () => {
|
||||
committed = await publishCatalogPackageCommand(input);
|
||||
committed = await publishCatalogPackageCommand(input, staff.id);
|
||||
return committed;
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -67,7 +67,7 @@ export async function updateCatalogPage({
|
||||
data.captionSave = data.caption.slice(0, 25);
|
||||
}
|
||||
try {
|
||||
await updatePageCommand("normal", id, data, expected);
|
||||
await updatePageCommand("normal", id, data, expected, staff.id);
|
||||
} catch (error) {
|
||||
return { ok: false as const, error: catalogFailure(error).message };
|
||||
}
|
||||
@@ -108,12 +108,13 @@ export async function toggleCatalogPage({
|
||||
id: number;
|
||||
action: "toggleEnabled" | "toggleVisible";
|
||||
}) {
|
||||
await requirePermission(PERMS.CATALOG_EDIT);
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
return await withCatalogExport(async () => {
|
||||
await togglePageCommand(
|
||||
"normal",
|
||||
id,
|
||||
action === "toggleEnabled" ? "enabled" : "visible",
|
||||
staff.id,
|
||||
);
|
||||
await sendCatalogUpdate();
|
||||
revalidatePath("/admin/catalog");
|
||||
|
||||
@@ -11,6 +11,7 @@ import { withCatalogExport } from "@/lib/services/catalog-git-queue";
|
||||
import type {
|
||||
AlignResult,
|
||||
DedupResult,
|
||||
FixEmulatorResult,
|
||||
FixOfferResult,
|
||||
FixSpriteResult,
|
||||
FurniHealth,
|
||||
@@ -60,6 +61,19 @@ export async function fixCatalogOffersAction(): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
export async function fixEmulatorIssuesAction(): Promise<
|
||||
ActionResult<FixEmulatorResult>
|
||||
> {
|
||||
try {
|
||||
await guard();
|
||||
return await withCatalogExport(async () => {
|
||||
return actionOk(await maintenance.fixEmulatorIssues());
|
||||
});
|
||||
} catch (e) {
|
||||
return handleActionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function reconcileIdsAction(): Promise<
|
||||
ActionResult<ReconcileResult>
|
||||
> {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
guard: vi.fn(),
|
||||
apply: vi.fn(),
|
||||
snapshot: vi.fn(),
|
||||
entry: {
|
||||
kind: "prices",
|
||||
targetId: 1,
|
||||
before: { costCredits: 1 },
|
||||
after: { costCredits: 2 },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
transaction: async (callback: (tx: object) => unknown) => callback({}),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: state.guard }));
|
||||
vi.mock("@/lib/permissions", () => ({
|
||||
PERMS: { LOGS_VIEW: "logs", CATALOG_EDIT: "catalog", NEWS_EDIT: "news" },
|
||||
}));
|
||||
vi.mock("@/features/history/server", () => ({
|
||||
readHistory: async () => state.entry,
|
||||
historySnapshot: state.snapshot,
|
||||
applyHistory: state.apply,
|
||||
}));
|
||||
vi.mock("@/lib/services/catalog-git-queue", () => ({
|
||||
withCatalogExport: async (fn: () => unknown) => fn(),
|
||||
}));
|
||||
vi.mock("@/lib/services/news-cache", () => ({ invalidateNewsCache: vi.fn() }));
|
||||
vi.mock("@/features/catalog/server/sync-status", () => ({
|
||||
sendCatalogUpdate: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
import { previewHistoryRestore, restoreHistory } from "./history";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
state.guard.mockResolvedValue({ id: 7 });
|
||||
state.snapshot.mockResolvedValue(state.entry.after);
|
||||
state.apply.mockResolvedValue(undefined);
|
||||
state.entry.kind = "prices";
|
||||
});
|
||||
it("requires logs access and catalog edit on preview and restore", async () => {
|
||||
await previewHistoryRestore(1);
|
||||
await restoreHistory(1);
|
||||
expect(state.guard.mock.calls.map((call) => call[0])).toEqual([
|
||||
"logs",
|
||||
"catalog",
|
||||
"logs",
|
||||
"catalog",
|
||||
]);
|
||||
});
|
||||
it("requires news edit for news history", async () => {
|
||||
state.entry.kind = "news";
|
||||
await restoreHistory(1);
|
||||
expect(state.guard).toHaveBeenCalledWith("news");
|
||||
});
|
||||
it("rejects forbidden restore before any mutation", async () => {
|
||||
state.guard.mockRejectedValue(Error("Forbidden"));
|
||||
await expect(restoreHistory(1)).rejects.toThrow("Forbidden");
|
||||
expect(state.apply).not.toHaveBeenCalled();
|
||||
});
|
||||
it("returns conflict when current data changed after preview", async () => {
|
||||
await previewHistoryRestore(1);
|
||||
state.apply.mockRejectedValue(Error("conflict"));
|
||||
await expect(restoreHistory(1)).resolves.toEqual({
|
||||
ok: false,
|
||||
code: "conflict",
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
"use server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { sendCatalogUpdate } from "@/features/catalog/server/sync-status";
|
||||
import { historyChanges, sameSnapshot } from "@/features/history/model";
|
||||
import {
|
||||
applyHistory,
|
||||
historySnapshot,
|
||||
readHistory,
|
||||
} from "@/features/history/server";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db } from "@/lib/db";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
|
||||
import { invalidateNewsCache } from "@/lib/services/news-cache";
|
||||
|
||||
async function authorized(id: number) {
|
||||
await requirePermission(PERMS.LOGS_VIEW);
|
||||
if (!Number.isSafeInteger(id) || id < 1) throw Error("unavailable");
|
||||
const entry = await db.transaction((tx) => readHistory(tx, id));
|
||||
const staff = await requirePermission(
|
||||
entry.kind === "news" ? PERMS.NEWS_EDIT : PERMS.CATALOG_EDIT,
|
||||
);
|
||||
return { entry, staff };
|
||||
}
|
||||
export async function previewHistoryRestore(id: number) {
|
||||
const { entry } = await authorized(id);
|
||||
return db.transaction(async (tx) => {
|
||||
const current = await historySnapshot(tx, entry.kind, entry.targetId);
|
||||
return {
|
||||
canRestore: sameSnapshot(current, entry.after),
|
||||
changes: historyChanges(current, entry.before),
|
||||
};
|
||||
});
|
||||
}
|
||||
export async function restoreHistory(id: number) {
|
||||
const { entry, staff } = await authorized(id);
|
||||
try {
|
||||
const restore = () =>
|
||||
db.transaction((tx) => applyHistory(tx, entry, staff.id));
|
||||
if (entry.kind === "news") await restore();
|
||||
else
|
||||
await withCatalogExport(async () => {
|
||||
await restore();
|
||||
return { ok: true as const, data: {} };
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "conflict")
|
||||
return { ok: false as const, code: "conflict" as const };
|
||||
logger.error("History restore failed", { module: "history", error });
|
||||
return { ok: false as const, code: "failed" as const };
|
||||
}
|
||||
try {
|
||||
if (entry.kind === "news") {
|
||||
await invalidateNewsCache();
|
||||
revalidatePath("/news", "layout");
|
||||
revalidatePath("/admin/articles", "layout");
|
||||
revalidatePath("/");
|
||||
revalidatePath("/me");
|
||||
} else {
|
||||
await sendCatalogUpdate();
|
||||
revalidatePath("/admin/catalog", "layout");
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("History refresh failed", { module: "history", error });
|
||||
}
|
||||
revalidatePath("/admin/logs/audit");
|
||||
return { ok: true as const };
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { CatalogIntegrityPanel } from "@/components/admin/catalog/catalog-integrity-panel";
|
||||
import { CatalogMaintenancePanel } from "@/components/admin/catalog/catalog-maintenance-panel";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
@@ -8,5 +10,26 @@ export default async function CatalogMaintenancePage() {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
return <CatalogMaintenancePanel />;
|
||||
const canRepair = canAccess(
|
||||
permissions,
|
||||
PERMS.CATALOG_EDIT,
|
||||
session.user.rank,
|
||||
);
|
||||
const t = await getTranslations("pages.admin.catalog.integrity");
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<CatalogIntegrityPanel canRepair={canRepair} />
|
||||
{canRepair && (
|
||||
<details className="rounded-xl border p-4">
|
||||
<summary className="cursor-pointer font-medium">
|
||||
{t("legacyTitle")}
|
||||
</summary>
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
{t("legacyHint")}
|
||||
</p>
|
||||
<CatalogMaintenancePanel />
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { getTranslations } from "next-intl/server";
|
||||
|
||||
import Link from "@/components/link";
|
||||
|
||||
import { queryErrorGroups } from "@/lib/error-group-query";
|
||||
import { summarizeErrorGroup } from "@/lib/error-groups";
|
||||
|
||||
import { ErrorStore } from "@/lib/error-monitor";
|
||||
@@ -26,8 +27,6 @@ export default async function CmsErrorsPage({
|
||||
|
||||
const params = await searchParams;
|
||||
|
||||
const query = (params.q ?? "").trim().slice(0, 200).toLowerCase();
|
||||
|
||||
let result: Awaited<ReturnType<ErrorStore["read"]>>;
|
||||
|
||||
try {
|
||||
@@ -40,30 +39,13 @@ export default async function CmsErrorsPage({
|
||||
);
|
||||
}
|
||||
|
||||
const allGroups = new Map<string, typeof result.records>();
|
||||
|
||||
for (const record of result.records) {
|
||||
const events = allGroups.get(record.fingerprint) ?? [];
|
||||
|
||||
events.push(record);
|
||||
allGroups.set(record.fingerprint, events);
|
||||
}
|
||||
|
||||
const releases = [...new Set(result.records.map((r) => r.release))].sort();
|
||||
const queried = queryErrorGroups(result.records, params);
|
||||
const groups = new Map(
|
||||
[...allGroups.entries()].filter(([, events]) => {
|
||||
const latest = summarizeErrorGroup(events).latest;
|
||||
|
||||
return (
|
||||
latest &&
|
||||
(!params.source || latest.source === params.source) &&
|
||||
(params.status !== "open" || !latest.resolved) &&
|
||||
(params.status !== "resolved" || latest.resolved) &&
|
||||
(!query ||
|
||||
events.some((record) =>
|
||||
JSON.stringify(record).toLowerCase().includes(query),
|
||||
))
|
||||
);
|
||||
}),
|
||||
queried.map((group) => [group.fingerprint, group.events]),
|
||||
);
|
||||
const latestByGroup = new Map(
|
||||
queried.map((group) => [group.fingerprint, group.latest]),
|
||||
);
|
||||
|
||||
const filtered = [...groups.values()].flat();
|
||||
@@ -72,14 +54,14 @@ export default async function CmsErrorsPage({
|
||||
1,
|
||||
|
||||
Math.min(
|
||||
Number(params.page) || 1,
|
||||
Math.floor(Number(params.page)) || 1,
|
||||
|
||||
Math.max(1, Math.ceil(groups.size / 25)),
|
||||
),
|
||||
);
|
||||
|
||||
const pageHref = (next: number) =>
|
||||
`?${new URLSearchParams({ q: params.q ?? "", source: params.source ?? "", status: params.status ?? "", page: String(next) })}`;
|
||||
`?${new URLSearchParams({ q: params.q ?? "", source: params.source ?? "", status: params.status ?? "", release: params.release ?? "", period: params.period ?? "", sort: params.sort ?? "", page: String(next) })}`;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -126,13 +108,46 @@ export default async function CmsErrorsPage({
|
||||
<option value="open">{t("open")}</option>
|
||||
<option value="resolved">{t("resolved")}</option>
|
||||
</select>
|
||||
<select
|
||||
name="release"
|
||||
aria-label={t("releaseFilter")}
|
||||
defaultValue={params.release ?? ""}
|
||||
className="input max-w-full"
|
||||
>
|
||||
<option value="">{t("allReleases")}</option>
|
||||
{releases.map((release) => (
|
||||
<option key={release} value={release}>
|
||||
{release}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
name="period"
|
||||
aria-label={t("period")}
|
||||
defaultValue={params.period ?? ""}
|
||||
className="input"
|
||||
>
|
||||
<option value="">{t("allRetained")}</option>
|
||||
<option value="24h">{t("last24h")}</option>
|
||||
<option value="7d">{t("last7d")}</option>
|
||||
</select>
|
||||
<select
|
||||
name="sort"
|
||||
aria-label={t("sort")}
|
||||
defaultValue={params.sort ?? ""}
|
||||
className="input"
|
||||
>
|
||||
<option value="">{t("recentFirst")}</option>
|
||||
<option value="frequency">{t("frequentFirst")}</option>
|
||||
</select>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("search")}
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("counts", { count: filtered.length, groups: groups.size })}{" "}
|
||||
{result.truncated ? t("truncated") : ""} {t("retention")}
|
||||
{result.truncated ? t("truncated") : ""} {t("retention")}{" "}
|
||||
{t("filterScope")}
|
||||
</p>
|
||||
{groups.size === 0 ? (
|
||||
<div className="admin-card">{t("empty")}</div>
|
||||
@@ -144,7 +159,7 @@ export default async function CmsErrorsPage({
|
||||
.map(([fingerprint, events]) => {
|
||||
const metrics = summarizeErrorGroup(events);
|
||||
|
||||
const r = metrics.latest;
|
||||
const r = latestByGroup.get(fingerprint);
|
||||
|
||||
if (!r) return null;
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ interface CloneSource {
|
||||
nitroBaseUrl: string;
|
||||
iconBaseUrl: string;
|
||||
hotel: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface FurniItem {
|
||||
@@ -81,6 +82,7 @@ function SourceForm({ initial, onSave, onCancel, saving }: SourceFormProps) {
|
||||
const [nitroBaseUrl, setNitroBaseUrl] = useState(initial?.nitroBaseUrl ?? "");
|
||||
const [iconBaseUrl, setIconBaseUrl] = useState(initial?.iconBaseUrl ?? "");
|
||||
const [hotel, setHotel] = useState(initial?.hotel ?? "");
|
||||
const [enabled, setEnabled] = useState(initial?.enabled ?? true);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -91,6 +93,7 @@ function SourceForm({ initial, onSave, onCancel, saving }: SourceFormProps) {
|
||||
nitroBaseUrl,
|
||||
iconBaseUrl,
|
||||
hotel,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -140,6 +143,15 @@ function SourceForm({ initial, onSave, onCancel, saving }: SourceFormProps) {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
Enabled (retired sources are excluded from counts and imports)
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" size="sm" disabled={saving}>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { DataTable } from "@/components/admin/data-table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { HistoryRestore } from "@/features/history/history-restore";
|
||||
import { formatAuditValue, readAuditChanges } from "@/lib/services/audit-diff";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
||||
import { AuditFilters } from "./audit-filters";
|
||||
@@ -102,7 +103,14 @@ export function AuditTable({ data }: AuditTableProps) {
|
||||
{
|
||||
key: "diff",
|
||||
label: t("audit.colDetails"),
|
||||
render: (_value, row) => <DiffCell row={row} />,
|
||||
render: (_value, row) => (
|
||||
<div className="space-y-2">
|
||||
<DiffCell row={row} />
|
||||
{["history_update", "history_restore"].includes(row.action) && (
|
||||
<HistoryRestore id={row.id} />
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "createdAt",
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { OperationsCenter } from "@/components/admin/operations-center";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
export default async function OperationsPage() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.ASSETS_IMPORT, session.user.rank))
|
||||
redirect("/admin");
|
||||
return <OperationsCenter />;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "@/components/link";
|
||||
import { db } from "@/lib/db";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { fetchEmulatorRankSummaries } from "@/lib/services/permission-ranks";
|
||||
@@ -16,6 +18,7 @@ export default async function PermissionsPage({
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const t = await getTranslations("pages.admin.permissionPreview");
|
||||
const sp = (await searchParams) ?? {};
|
||||
const fromHousekeeping = sp.from === "housekeeping";
|
||||
|
||||
@@ -36,9 +39,14 @@ export default async function PermissionsPage({
|
||||
}));
|
||||
|
||||
return (
|
||||
<PermissionsList
|
||||
ranks={ranksWithCounts}
|
||||
fromHousekeeping={fromHousekeeping}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<Link href="/admin/permissions/preview" className="btn btn-outline">
|
||||
{t("title")}
|
||||
</Link>
|
||||
<PermissionsList
|
||||
ranks={ranksWithCounts}
|
||||
fromHousekeeping={fromHousekeeping}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({ access: vi.fn(), execute: vi.fn() }));
|
||||
vi.mock("@/lib/db", () => ({ db: { execute: state.execute } }));
|
||||
vi.mock("@/lib/permissions", () => ({
|
||||
PERMS: { PERMISSIONS_MANAGE: "admin.permissions.manage" },
|
||||
getAdminContext: async () => ({
|
||||
session: { user: { rank: 7 } },
|
||||
permissions: {},
|
||||
}),
|
||||
canAccess: state.access,
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: () => {
|
||||
throw Error("redirect");
|
||||
},
|
||||
}));
|
||||
vi.mock("next-intl/server", () => ({
|
||||
getTranslations: async () => (key: string) => key,
|
||||
}));
|
||||
vi.mock("@/components/link", () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => (
|
||||
<span>{children}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
import Page from "./page";
|
||||
|
||||
beforeEach(() => {
|
||||
state.access.mockReset().mockReturnValue(true);
|
||||
state.execute.mockReset();
|
||||
});
|
||||
it("rejects before querying role details", async () => {
|
||||
state.access.mockReturnValue(false);
|
||||
await expect(Page({ searchParams: Promise.resolve({}) })).rejects.toThrow(
|
||||
"redirect",
|
||||
);
|
||||
expect(state.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does not silently substitute an invalid requested role", async () => {
|
||||
state.execute.mockResolvedValueOnce([
|
||||
[{ id: 1, slug: "rank_1", title: "User" }],
|
||||
]);
|
||||
const html = renderToStaticMarkup(
|
||||
await Page({ searchParams: Promise.resolve({ role: "99" }) }),
|
||||
);
|
||||
expect(html).toContain("missing");
|
||||
expect(state.execute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("simulates highest-rank override from live rank data", async () => {
|
||||
state.execute
|
||||
.mockResolvedValueOnce([[{ id: 10, slug: "rank_8", title: "Owner" }]])
|
||||
.mockResolvedValueOnce([[]])
|
||||
.mockResolvedValueOnce([[{ highest_rank: 8 }]])
|
||||
.mockResolvedValueOnce([[]]);
|
||||
const html = renderToStaticMarkup(
|
||||
await Page({ searchParams: Promise.resolve({ role: "10" }) }),
|
||||
);
|
||||
expect(html).toContain("superAdmin");
|
||||
expect(html).toContain("entryAllowed");
|
||||
});
|
||||
it("does not infer owner privileges from a title", async () => {
|
||||
state.execute
|
||||
.mockResolvedValueOnce([[{ id: 10, slug: "owner", title: "Owner" }]])
|
||||
.mockResolvedValueOnce([[]])
|
||||
.mockResolvedValueOnce([[{ highest_rank: 8 }]])
|
||||
.mockResolvedValueOnce([[]]);
|
||||
const html = renderToStaticMarkup(
|
||||
await Page({ searchParams: Promise.resolve({ role: "10" }) }),
|
||||
);
|
||||
expect(html).not.toContain("superAdmin");
|
||||
expect(html).toContain("entryDenied");
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "@/components/link";
|
||||
import { isDynamicSuperAdmin } from "@/lib/admin/authorization-policy";
|
||||
import { previewRoleAccess } from "@/lib/admin/permission-preview";
|
||||
import { db } from "@/lib/db";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
export default async function PermissionPreview({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ role?: string }>;
|
||||
}) {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.PERMISSIONS_MANAGE, session.user.rank))
|
||||
redirect("/admin");
|
||||
const t = await getTranslations("pages.admin.permissionPreview");
|
||||
const nav = await getTranslations("pages.admin.nav");
|
||||
const [roleRows] = await db.execute(
|
||||
sql`SELECT id, slug, title FROM acl_roles ORDER BY title, id`,
|
||||
);
|
||||
const roles = roleRows as unknown as {
|
||||
id: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
}[];
|
||||
const params = await searchParams;
|
||||
const selected =
|
||||
roles.find((r) => String(r.id) === params.role) ??
|
||||
(params.role ? undefined : roles[0]);
|
||||
let preview: ReturnType<typeof previewRoleAccess> | undefined;
|
||||
let superAdmin = false;
|
||||
let permissionTitles: Record<string, string> = {};
|
||||
if (selected) {
|
||||
const [grantRows] = await db.execute(
|
||||
sql`SELECT p.slug, p.title FROM acl_model_permissions mp JOIN acl_permissions p ON p.id = mp.permission_id WHERE mp.model_type = 'Role' AND mp.model_id = ${selected.id}`,
|
||||
);
|
||||
const grants = grantRows as unknown as { slug: string; title: string }[];
|
||||
const [highestRows] = await db.execute(
|
||||
sql`SELECT COALESCE((SELECT MAX(u.\`rank\`) FROM users u INNER JOIN permission_ranks pr ON pr.id = u.\`rank\`), (SELECT MAX(id) FROM permission_ranks)) AS highest_rank`,
|
||||
);
|
||||
const highest = (
|
||||
highestRows as unknown as { highest_rank: number | null }[]
|
||||
)[0]?.highest_rank;
|
||||
const rankMatch = /^rank_([1-9]\d*)$/.exec(selected.slug);
|
||||
superAdmin =
|
||||
!!rankMatch &&
|
||||
isDynamicSuperAdmin(
|
||||
Number(rankMatch[1]),
|
||||
highest == null ? null : Number(highest),
|
||||
);
|
||||
preview = previewRoleAccess(
|
||||
grants.map((g) => g.slug),
|
||||
superAdmin,
|
||||
);
|
||||
const [allRows] = await db.execute(
|
||||
sql`SELECT slug, title FROM acl_permissions ORDER BY slug`,
|
||||
);
|
||||
permissionTitles = Object.fromEntries(
|
||||
(allRows as unknown as { slug: string; title: string }[]).map((p) => [
|
||||
p.slug,
|
||||
p.title,
|
||||
]),
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">{t("title")}</h1>
|
||||
<p className="text-muted-foreground">{t("description")}</p>
|
||||
</div>
|
||||
<Link className="btn btn-outline" href="/admin/permissions">
|
||||
{t("back")}
|
||||
</Link>
|
||||
<form className="admin-card flex flex-wrap items-end gap-3">
|
||||
<label className="grid min-w-0 max-w-full flex-1 gap-1 text-sm">
|
||||
{t("role")}
|
||||
<select
|
||||
name="role"
|
||||
defaultValue={selected?.id ?? ""}
|
||||
className="input w-full min-w-0 max-w-full"
|
||||
>
|
||||
{roles.map((role) => (
|
||||
<option key={role.id} value={role.id}>
|
||||
{role.title} ({role.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
type="submit"
|
||||
disabled={!roles.length}
|
||||
>
|
||||
{t("show")}
|
||||
</button>
|
||||
</form>
|
||||
{!preview ? (
|
||||
<p role="status">{t("missing")}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="admin-card space-y-2">
|
||||
<p className="font-semibold">
|
||||
{t(preview.canEnter ? "entryAllowed" : "entryDenied")}
|
||||
</p>
|
||||
{superAdmin && <p>{t("superAdmin")}</p>}
|
||||
<p className="text-sm text-muted-foreground">{t("scope")}</p>
|
||||
</div>
|
||||
<section className="admin-card">
|
||||
<h2 className="mb-3 font-semibold">{t("sections")}</h2>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{preview.sections.map((item) => (
|
||||
<div key={item.href} className="min-w-0 rounded border p-3">
|
||||
<div className="flex justify-between gap-2">
|
||||
<span>{nav(item.labelKey)}</span>
|
||||
<span className="text-xs">
|
||||
{t(item.allowed ? "allowed" : "denied")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="break-all text-xs text-muted-foreground">
|
||||
{item.href}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<details className="admin-card">
|
||||
<summary className="cursor-pointer font-semibold">
|
||||
{t("actions")}
|
||||
</summary>
|
||||
<p className="my-3 text-sm text-muted-foreground">
|
||||
{t("actionScope")}
|
||||
</p>
|
||||
<ul className="divide-y">
|
||||
{preview.actions.map((item) => (
|
||||
<li
|
||||
key={item.slug}
|
||||
className="flex items-start justify-between gap-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="break-words">
|
||||
{permissionTitles[item.slug] || item.slug}
|
||||
</p>
|
||||
<code className="break-all text-xs text-muted-foreground">
|
||||
{item.slug}
|
||||
</code>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs">
|
||||
{t(item.granted ? "granted" : "notGranted")}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import { useUnsavedChanges } from "@/hooks/use-unsaved-changes";
|
||||
import {
|
||||
BOOLEAN_KEYS,
|
||||
type ManagedField,
|
||||
@@ -45,7 +46,13 @@ export function CmsSettingsForm({
|
||||
canEdit: boolean;
|
||||
}) {
|
||||
const [values, setValues] = useState(initialValues);
|
||||
const { isPending, run } = useServerAction();
|
||||
const [savedValues, setSavedValues] = useState(initialValues);
|
||||
const tAction = useTranslations("pages.admin.actions");
|
||||
const dirty = Object.keys({ ...savedValues, ...values }).some(
|
||||
(key) => savedValues[key] !== values[key],
|
||||
);
|
||||
useUnsavedChanges(canEdit && dirty, tAction("leaveUnsaved"));
|
||||
const { isPending, run, fieldErrors } = useServerAction();
|
||||
const t = useTranslations("pages.admin.settings");
|
||||
|
||||
function handleChange(key: string, value: string) {
|
||||
@@ -55,6 +62,7 @@ export function CmsSettingsForm({
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!canEdit) return;
|
||||
const submitted = { ...values };
|
||||
const normalized = { ...values };
|
||||
for (const key of BOOLEAN_KEYS) {
|
||||
normalized[key] = values[key] === "1" ? "1" : "0";
|
||||
@@ -62,24 +70,34 @@ export function CmsSettingsForm({
|
||||
run(() => saveManagedSettings({ settings: normalized }), {
|
||||
successMessage: t("saved"),
|
||||
errorMessage: t("saveFailed"),
|
||||
onSuccess: () => setSavedValues(submitted),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{Object.keys(fieldErrors).length > 0 && (
|
||||
<div role="alert" className="admin-card text-sm text-destructive">
|
||||
{Object.values(fieldErrors).flat().join(" · ")}
|
||||
</div>
|
||||
)}
|
||||
{SETTINGS_GROUPS.map((group) => (
|
||||
<GroupSection
|
||||
key={group.id}
|
||||
group={group}
|
||||
values={values}
|
||||
canEdit={canEdit}
|
||||
canEdit={canEdit && !isPending}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
))}
|
||||
|
||||
{canEdit ? (
|
||||
<div className="sticky bottom-3 z-10 flex justify-end">
|
||||
<Button type="submit" disabled={isPending} className="shadow-lg">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPending || !dirty}
|
||||
className="shadow-lg"
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useVisiblePolling } from "@/hooks/use-visible-polling";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
@@ -20,9 +21,13 @@ type ExportStatus = {
|
||||
|
||||
export function CatalogExportClient({
|
||||
canConfigure = false,
|
||||
monitorOnly = false,
|
||||
}: {
|
||||
canConfigure?: boolean;
|
||||
monitorOnly?: boolean;
|
||||
}) {
|
||||
const t = useTranslations("pages.admin.operationsExport");
|
||||
const submitting = useRef(false);
|
||||
const [status, setStatus] = useState<ExportStatus | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -34,37 +39,37 @@ export function CatalogExportClient({
|
||||
const response = await adminFetch("/api/admin/catalog-export", {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!response.ok) throw new Error("Unable to load export status");
|
||||
if (!response.ok) throw new Error(t("loadError"));
|
||||
setStatus(await response.json());
|
||||
setError("");
|
||||
} catch {
|
||||
setError("Unable to load export status");
|
||||
setError(t("loadError"));
|
||||
} finally {
|
||||
refreshing.current = false;
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
useVisiblePolling(refresh, 15000);
|
||||
async function retry() {
|
||||
if (submitting.current) return;
|
||||
submitting.current = true;
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await adminFetch("/api/admin/catalog-export", {
|
||||
method: "POST",
|
||||
});
|
||||
if (!response.ok) throw new Error("Unable to queue export");
|
||||
if (!response.ok) throw new Error(t("queueError"));
|
||||
await refresh();
|
||||
} catch {
|
||||
setError("Unable to queue export");
|
||||
setError(t("queueError"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
submitting.current = false;
|
||||
}
|
||||
}
|
||||
return (
|
||||
<section className="space-y-3 rounded-lg border border-[var(--admin-border)] p-4">
|
||||
<h2 className="text-lg font-semibold">Catalog repository export</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Automatically publishes bundles, icons, gamedata, translations and
|
||||
catalog SQL to your configured Gitea repository.
|
||||
</p>
|
||||
<h2 className="text-lg font-semibold">{t("title")}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t("hint")}</p>
|
||||
{canConfigure && (
|
||||
<CatalogGitConfiguration onSaved={() => void refresh()} />
|
||||
)}
|
||||
@@ -75,19 +80,19 @@ export function CatalogExportClient({
|
||||
)}
|
||||
<div role="status" className="text-sm" aria-live="polite">
|
||||
{!status
|
||||
? "Loading…"
|
||||
? t("loading")
|
||||
: !status.enabled
|
||||
? "Export disabled. Configure the Gitea connection below."
|
||||
? t("disabled")
|
||||
: status.active
|
||||
? `Waiting for ${status.active} operation(s) to finish.`
|
||||
? t("active", { count: status.active })
|
||||
: status.running
|
||||
? "Export in progress…"
|
||||
? t("running")
|
||||
: status.pending
|
||||
? `${status.pending} update(s) queued for export.`
|
||||
: "No pending updates."}
|
||||
? t("pending", { count: status.pending })
|
||||
: t("idle")}
|
||||
{status?.commit && (
|
||||
<p>
|
||||
Last published commit:{" "}
|
||||
{t("lastCommit")}{" "}
|
||||
<a
|
||||
className="underline"
|
||||
href={`${status.remote?.replace(/\.git$/, "")}/commit/${status.commit}`}
|
||||
@@ -98,21 +103,30 @@ export function CatalogExportClient({
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
{status?.finishedAt && <p>Last attempt: {status.finishedAt}</p>}
|
||||
{status?.finishedAt && (
|
||||
<p>{t("lastAttempt", { date: status.finishedAt })}</p>
|
||||
)}
|
||||
</div>
|
||||
{(error || status?.error) && (
|
||||
<p role="alert" className="text-sm">
|
||||
{error || status?.error}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!status?.enabled || busy}
|
||||
onClick={retry}
|
||||
className="rounded-md border border-[var(--admin-border)] px-3 py-2 text-sm disabled:opacity-50"
|
||||
>
|
||||
{busy ? "Queuing…" : "Export now / retry"}
|
||||
</button>
|
||||
{!monitorOnly && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
!status?.enabled ||
|
||||
busy ||
|
||||
Boolean(status.running) ||
|
||||
status.active > 0
|
||||
}
|
||||
onClick={retry}
|
||||
className="rounded-md border border-[var(--admin-border)] px-3 py-2 text-sm disabled:opacity-50"
|
||||
>
|
||||
{busy ? "Queuing…" : "Export now / retry"}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { updateUser } from "@/actions/users";
|
||||
import { useFormErrorFocus } from "@/components/admin/form-feedback";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -15,6 +19,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import { useUnsavedChanges } from "@/hooks/use-unsaved-changes";
|
||||
import { type UpdateUserInput, updateUserSchema } from "@/lib/validators/user";
|
||||
|
||||
interface UserEditFormProps {
|
||||
@@ -46,13 +51,18 @@ const FALLBACK_RANKS: Array<{ id: number; name: string }> = [
|
||||
|
||||
export function UserEditForm({ user, ranks }: UserEditFormProps) {
|
||||
const rankOptions = ranks && ranks.length > 0 ? ranks : FALLBACK_RANKS;
|
||||
const { isPending, run } = useServerAction();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const tAction = useTranslations("pages.admin.actions");
|
||||
const router = useRouter();
|
||||
const { isPending, run, fieldErrors } = useServerAction();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
setError,
|
||||
reset,
|
||||
formState: { errors, isDirty },
|
||||
} = useForm<UpdateUserInput>({
|
||||
resolver: zodResolver(updateUserSchema),
|
||||
defaultValues: {
|
||||
@@ -67,9 +77,36 @@ export function UserEditForm({ user, ranks }: UserEditFormProps) {
|
||||
},
|
||||
});
|
||||
|
||||
useUnsavedChanges(isDirty, tAction("leaveUnsaved"));
|
||||
useFormErrorFocus(formRef, fieldErrors, isPending);
|
||||
useEffect(() => {
|
||||
for (const [field, messages] of Object.entries(fieldErrors)) {
|
||||
if (
|
||||
[
|
||||
"username",
|
||||
"mail",
|
||||
"rank",
|
||||
"motto",
|
||||
"credits",
|
||||
"pixels",
|
||||
"diamonds",
|
||||
"duckets",
|
||||
].includes(field)
|
||||
)
|
||||
setError(
|
||||
field as keyof UpdateUserInput,
|
||||
{ type: "server", message: messages.join(" · ") },
|
||||
{ shouldFocus: true },
|
||||
);
|
||||
}
|
||||
}, [fieldErrors, setError]);
|
||||
function onSubmit(data: UpdateUserInput) {
|
||||
run(() => updateUser({ id: user.id, ...data }), {
|
||||
successMessage: "User updated successfully.",
|
||||
successMessage: tAction("saved"),
|
||||
onSuccess: () => {
|
||||
reset(data);
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -79,91 +116,127 @@ export function UserEditForm({ user, ranks }: UserEditFormProps) {
|
||||
<CardTitle>Edit User</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input id="username" {...register("username")} />
|
||||
{errors.username && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.username.message}
|
||||
</p>
|
||||
)}
|
||||
<form
|
||||
ref={formRef}
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<fieldset disabled={isPending} className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input id="username" {...register("username")} />
|
||||
{errors.username && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.username.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mail">Email</Label>
|
||||
<Input id="mail" type="email" {...register("mail")} />
|
||||
{errors.mail && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.mail.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mail">Email</Label>
|
||||
<Input id="mail" type="email" {...register("mail")} />
|
||||
{errors.mail && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.mail.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rank">Rank</Label>
|
||||
<Select
|
||||
defaultValue={String(user.rank)}
|
||||
onValueChange={(v) => setValue("rank", parseInt(v, 10))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select rank" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{rankOptions.map((r) => (
|
||||
<SelectItem key={r.id} value={String(r.id)}>
|
||||
{r.id} — {r.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rank">Rank</Label>
|
||||
<Select
|
||||
defaultValue={String(user.rank)}
|
||||
onValueChange={(v) =>
|
||||
setValue("rank", parseInt(v, 10), {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select rank" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{rankOptions.map((r) => (
|
||||
<SelectItem key={r.id} value={String(r.id)}>
|
||||
{r.id} — {r.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="motto">Motto</Label>
|
||||
<Input id="motto" {...register("motto")} />
|
||||
{errors.motto && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{errors.motto.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="motto">Motto</Label>
|
||||
<Input id="motto" {...register("motto")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="credits">Credits</Label>
|
||||
<Input
|
||||
id="credits"
|
||||
type="number"
|
||||
{...register("credits", { valueAsNumber: true })}
|
||||
/>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="credits">Credits</Label>
|
||||
<Input
|
||||
id="credits"
|
||||
type="number"
|
||||
{...register("credits", { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.credits && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{errors.credits.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pixels">Pixels</Label>
|
||||
<Input
|
||||
id="pixels"
|
||||
type="number"
|
||||
{...register("pixels", { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.pixels && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{errors.pixels.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="diamonds">Diamonds</Label>
|
||||
<Input
|
||||
id="diamonds"
|
||||
type="number"
|
||||
{...register("diamonds", { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.diamonds && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{errors.diamonds.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="duckets">Duckets</Label>
|
||||
<Input
|
||||
id="duckets"
|
||||
type="number"
|
||||
{...register("duckets", { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.duckets && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{errors.duckets.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pixels">Pixels</Label>
|
||||
<Input
|
||||
id="pixels"
|
||||
type="number"
|
||||
{...register("pixels", { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="diamonds">Diamonds</Label>
|
||||
<Input
|
||||
id="diamonds"
|
||||
type="number"
|
||||
{...register("diamonds", { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="duckets">Duckets</Label>
|
||||
<Input
|
||||
id="duckets"
|
||||
type="number"
|
||||
{...register("duckets", { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, UserPlus } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { createUser } from "@/actions/users";
|
||||
@@ -28,17 +29,10 @@ interface CreateUserDialogProps {
|
||||
sessionRank: number;
|
||||
}
|
||||
|
||||
const RANK_OPTIONS: { value: number; label: string }[] = [
|
||||
{ value: 1, label: "User (1)" },
|
||||
{ value: 2, label: "Bronze (2)" },
|
||||
{ value: 3, label: "Silver (3)" },
|
||||
{ value: 4, label: "Gold (4)" },
|
||||
{ value: 5, label: "Moderator (5)" },
|
||||
{ value: 6, label: "Admin (6)" },
|
||||
{ value: 7, label: "Super Admin (7)" },
|
||||
];
|
||||
const RANK_OPTIONS = [1, 2, 3, 4, 5, 6, 7];
|
||||
|
||||
export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) {
|
||||
const t = useTranslations("pages.admin.users.create");
|
||||
const { run, isPending } = useServerAction();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [username, setUsername] = useState("");
|
||||
@@ -48,7 +42,7 @@ export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) {
|
||||
const [motto, setMotto] = useState("");
|
||||
|
||||
const allowedRanks = RANK_OPTIONS.filter((r) =>
|
||||
sessionRank >= 7 ? true : r.value < sessionRank,
|
||||
sessionRank >= 7 ? true : r < sessionRank,
|
||||
);
|
||||
|
||||
function reset() {
|
||||
@@ -67,22 +61,22 @@ export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) {
|
||||
for (let i = 0; i < buf.length; i++)
|
||||
out += charset[buf[i] % charset.length];
|
||||
setPassword(out);
|
||||
toast.message("Password generated", { description: out });
|
||||
toast.message(t("passwordGenerated"));
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
const u = username.trim();
|
||||
const m = mail.trim();
|
||||
if (!u || !m || !password) {
|
||||
toast.error("Username, email and password are required");
|
||||
toast.error(t("required"));
|
||||
return;
|
||||
}
|
||||
if (u.length < 3 || u.length > 20 || !/^[a-zA-Z0-9_-]+$/.test(u)) {
|
||||
toast.error("Invalid username (3-20 chars, letters/digits/_/-)");
|
||||
toast.error(t("invalidUsername"));
|
||||
return;
|
||||
}
|
||||
if (password.length < 6) {
|
||||
toast.error("Password must be at least 6 characters");
|
||||
toast.error(t("passwordLength"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -96,7 +90,7 @@ export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) {
|
||||
motto: motto.trim(),
|
||||
}),
|
||||
{
|
||||
successMessage: `User "${u}" created.`,
|
||||
successMessage: t("created", { username: u }),
|
||||
onSuccess: () => {
|
||||
setOpen(false);
|
||||
reset();
|
||||
@@ -116,31 +110,28 @@ export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) {
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
New User
|
||||
{t("newUser")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create User</DialogTitle>
|
||||
<DialogDescription>
|
||||
Account is created immediately with default look and 5,000
|
||||
credits/duckets/diamonds.
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t("title")}</DialogTitle>
|
||||
<DialogDescription>{t("description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cu-username">Username</Label>
|
||||
<Label htmlFor="cu-username">{t("username")}</Label>
|
||||
<Input
|
||||
id="cu-username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="HabboName"
|
||||
placeholder={t("usernamePlaceholder")}
|
||||
autoComplete="off"
|
||||
maxLength={20}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cu-mail">Email</Label>
|
||||
<Label htmlFor="cu-mail">{t("email")}</Label>
|
||||
<Input
|
||||
id="cu-mail"
|
||||
type="email"
|
||||
@@ -151,14 +142,14 @@ export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cu-password">Password</Label>
|
||||
<Label htmlFor="cu-password">{t("password")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="cu-password"
|
||||
type="text"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Minimum 6 characters"
|
||||
placeholder={t("passwordPlaceholder")}
|
||||
autoComplete="new-password"
|
||||
className="font-mono"
|
||||
/>
|
||||
@@ -167,33 +158,33 @@ export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) {
|
||||
variant="outline"
|
||||
onClick={generatePassword}
|
||||
>
|
||||
Generate
|
||||
{t("generate")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Rank</Label>
|
||||
<Label>{t("rank")}</Label>
|
||||
<Select value={rank} onValueChange={setRank}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allowedRanks.map((r) => (
|
||||
<SelectItem key={r.value} value={String(r.value)}>
|
||||
{r.label}
|
||||
<SelectItem key={r} value={String(r)}>
|
||||
{t("rankOption", { rank: r })}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cu-motto">Motto (optional)</Label>
|
||||
<Label htmlFor="cu-motto">{t("motto")}</Label>
|
||||
<Input
|
||||
id="cu-motto"
|
||||
value={motto}
|
||||
onChange={(e) => setMotto(e.target.value)}
|
||||
maxLength={127}
|
||||
placeholder="I'm new here!"
|
||||
placeholder={t("mottoPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -207,7 +198,7 @@ export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) {
|
||||
) : (
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Create User
|
||||
{t("title")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
MonitorSmartphone,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "@/components/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -25,40 +26,41 @@ interface UserAccountInfoProps {
|
||||
}
|
||||
|
||||
export function UserAccountInfo({ user }: UserAccountInfoProps) {
|
||||
const t = useTranslations("pages.admin.users.presentation");
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Account Details</CardTitle>
|
||||
<CardTitle>{t("accountDetails")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-muted-foreground">Registered</span>
|
||||
<span className="text-muted-foreground">{t("registered")}</span>
|
||||
<span className="ml-auto font-medium">
|
||||
{formatDate(new Date(user.accountCreated * 1000), "date")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Clock className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-muted-foreground">Last Login</span>
|
||||
<span className="text-muted-foreground">{t("lastLogin")}</span>
|
||||
<span className="ml-auto font-medium">
|
||||
{user.lastLogin
|
||||
? formatDate(new Date(user.lastLogin * 1000))
|
||||
: "Never"}
|
||||
: t("never")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Globe className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-muted-foreground">IP Register</span>
|
||||
<span className="text-muted-foreground">{t("ipRegister")}</span>
|
||||
<span className="ml-auto flex items-center gap-1.5">
|
||||
<code className="text-xs font-mono bg-muted px-2 py-0.5 rounded">
|
||||
{user.ipRegister}
|
||||
</code>
|
||||
<Link
|
||||
href={`/admin/users?search=${user.ipRegister}`}
|
||||
title="Find other users with this IP"
|
||||
title={t("findIp")}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3 text-primary" />
|
||||
</Link>
|
||||
@@ -66,14 +68,14 @@ export function UserAccountInfo({ user }: UserAccountInfoProps) {
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Globe className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-muted-foreground">IP Current</span>
|
||||
<span className="text-muted-foreground">{t("ipCurrent")}</span>
|
||||
<span className="ml-auto flex items-center gap-1.5">
|
||||
<code className="text-xs font-mono bg-muted px-2 py-0.5 rounded">
|
||||
{user.ipCurrent}
|
||||
</code>
|
||||
<Link
|
||||
href={`/admin/users?search=${user.ipCurrent}`}
|
||||
title="Find other users with this IP"
|
||||
title={t("findIp")}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3 text-primary" />
|
||||
</Link>
|
||||
@@ -88,22 +90,22 @@ export function UserAccountInfo({ user }: UserAccountInfoProps) {
|
||||
variant={user.twoFactorConfirmed ? "default" : "outline"}
|
||||
className="ml-auto"
|
||||
>
|
||||
{user.twoFactorConfirmed ? "Enabled" : "Disabled"}
|
||||
{user.twoFactorConfirmed ? t("enabled") : t("disabled")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<MonitorSmartphone className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-muted-foreground">Machine ID</span>
|
||||
<span className="text-muted-foreground">{t("machineId")}</span>
|
||||
<code className="ml-auto text-xs font-mono bg-muted px-2 py-0.5 rounded truncate max-w-[180px]">
|
||||
{user.machine_id || "N/A"}
|
||||
{user.machine_id || t("unavailable")}
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="text-muted-foreground">Email</span>
|
||||
<span className="text-muted-foreground">{t("email")}</span>
|
||||
<span className="ml-auto font-medium">{user.mail}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="text-muted-foreground">Gender</span>
|
||||
<span className="text-muted-foreground">{t("gender")}</span>
|
||||
<Badge variant="outline" className="ml-auto">
|
||||
{user.gender}
|
||||
</Badge>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface Badge {
|
||||
id: number;
|
||||
@@ -6,8 +7,9 @@ interface Badge {
|
||||
}
|
||||
|
||||
export function UserBadgesReadonly({ badges }: { badges: Badge[] }) {
|
||||
const t = useTranslations("pages.admin.users.presentation");
|
||||
if (badges.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">No badges</p>;
|
||||
return <p className="text-sm text-muted-foreground">{t("noBadges")}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
WifiOff,
|
||||
} from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { AdminBreadcrumb } from "@/components/admin/admin-breadcrumb";
|
||||
import { UserWatchToggle } from "@/components/admin/user-watch-toggle";
|
||||
import Link from "@/components/link";
|
||||
@@ -42,6 +43,7 @@ interface UserHeroProps {
|
||||
activeBan: boolean;
|
||||
mode: "show" | "edit";
|
||||
canEdit: boolean;
|
||||
canViewLogs?: boolean;
|
||||
/** When non-null, render the watch toggle with this initial state. */
|
||||
watching?: boolean | null;
|
||||
}
|
||||
@@ -58,7 +60,9 @@ export function UserHero({
|
||||
mode,
|
||||
canEdit,
|
||||
watching,
|
||||
canViewLogs = true,
|
||||
}: UserHeroProps) {
|
||||
const t = useTranslations("pages.admin.users.presentation");
|
||||
const params = useSearchParams();
|
||||
const list = params.get("list") ?? "";
|
||||
const listHref = userListHref(list);
|
||||
@@ -66,9 +70,9 @@ export function UserHero({
|
||||
<>
|
||||
<AdminBreadcrumb
|
||||
items={[
|
||||
{ label: "Users", href: listHref },
|
||||
{ label: t("users"), href: listHref },
|
||||
{ label: user.username },
|
||||
{ label: mode === "edit" ? "Edit" : "View" },
|
||||
{ label: mode === "edit" ? t("edit") : t("view") },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -107,14 +111,14 @@ export function UserHero({
|
||||
{mode === "show" && canEdit && (
|
||||
<Button variant="outline" size="sm" asChild className="ml-2">
|
||||
<Link href={userDetailHref(user.id, "edit", list)}>
|
||||
<Pencil className="mr-1.5 h-3.5 w-3.5" /> Edit
|
||||
<Pencil className="mr-1.5 h-3.5 w-3.5" /> {t("edit")}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{mode === "edit" && (
|
||||
<Button variant="outline" size="sm" asChild className="ml-2">
|
||||
<Link href={userDetailHref(user.id, "show", list)}>
|
||||
<Eye className="mr-1.5 h-3.5 w-3.5" /> View
|
||||
<Eye className="mr-1.5 h-3.5 w-3.5" /> {t("view")}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
@@ -134,22 +138,24 @@ export function UserHero({
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge>{user.permission.rankName || `Rank ${user.rank}`}</Badge>
|
||||
<Badge>
|
||||
{user.permission.rankName || t("rank", { rank: user.rank })}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={isOnline ? "default" : "outline"}
|
||||
className={isOnline ? "bg-[var(--admin-success-subtle)]" : ""}
|
||||
>
|
||||
{isOnline ? (
|
||||
<>
|
||||
<Wifi className="mr-1 h-3 w-3" /> Online
|
||||
<Wifi className="mr-1 h-3 w-3" /> {t("online")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<WifiOff className="mr-1 h-3 w-3" /> Offline
|
||||
<WifiOff className="mr-1 h-3 w-3" /> {t("offline")}
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
{activeBan && <Badge variant="destructive">Banned</Badge>}
|
||||
{activeBan && <Badge variant="destructive">{t("banned")}</Badge>}
|
||||
{user.twoFactorConfirmed ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
@@ -167,24 +173,32 @@ export function UserHero({
|
||||
<span className="font-semibold">
|
||||
{formatNumber(user.credits)}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">credits</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("credits")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<CurrencyIcon kind="diamonds" size={18} />
|
||||
<span className="font-semibold">{formatNumber(diamonds)}</span>
|
||||
<span className="text-muted-foreground text-xs">diamonds</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("diamonds")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<CurrencyIcon kind="duckets" size={18} />
|
||||
<span className="font-semibold">{formatNumber(duckets)}</span>
|
||||
<span className="text-muted-foreground text-xs">duckets</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("duckets")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<CurrencyIcon kind="pixels" size={18} />
|
||||
<span className="font-semibold">
|
||||
{formatNumber(user.pixels)}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">pixels</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("pixels")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -197,18 +211,26 @@ export function UserHero({
|
||||
>
|
||||
<DoorOpen className="h-4 w-4 mx-auto text-muted-foreground" />
|
||||
<p className="text-lg font-bold">{roomCount}</p>
|
||||
<p className="text-[10px] text-muted-foreground">Rooms</p>
|
||||
<p className="text-[10px] text-muted-foreground">{t("rooms")}</p>
|
||||
</Link>
|
||||
<div className="rounded-lg border bg-card px-3 py-2 text-center">
|
||||
<MessageSquare className="h-4 w-4 mx-auto text-muted-foreground" />
|
||||
<p className="text-lg font-bold">{formatNumber(chatCount)}</p>
|
||||
<p className="text-[10px] text-muted-foreground">Messages</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card px-3 py-2 text-center">
|
||||
<ArrowLeftRight className="h-4 w-4 mx-auto text-muted-foreground" />
|
||||
<p className="text-lg font-bold">{tradeCount}</p>
|
||||
<p className="text-[10px] text-muted-foreground">Trades</p>
|
||||
</div>
|
||||
{canViewLogs && (
|
||||
<div className="rounded-lg border bg-card px-3 py-2 text-center">
|
||||
<MessageSquare className="h-4 w-4 mx-auto text-muted-foreground" />
|
||||
<p className="text-lg font-bold">{formatNumber(chatCount)}</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("messages")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{canViewLogs && (
|
||||
<div className="rounded-lg border bg-card px-3 py-2 text-center">
|
||||
<ArrowLeftRight className="h-4 w-4 mx-auto text-muted-foreground" />
|
||||
<p className="text-lg font-bold">{tradeCount}</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("trades")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "@/components/link";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import type { OverviewSection } from "../_lib/load-user-overview";
|
||||
|
||||
function eventDate(value: string | number | Date | null | undefined) {
|
||||
if (!value) return "—";
|
||||
const numeric =
|
||||
typeof value === "number" ||
|
||||
(typeof value === "string" && /^\d+$/.test(value));
|
||||
const date = numeric ? new Date(Number(value) * 1000) : new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? "—" : formatDate(date);
|
||||
}
|
||||
|
||||
export async function UserOperationalOverview({
|
||||
sections,
|
||||
}: {
|
||||
sections: OverviewSection[];
|
||||
}) {
|
||||
const t = await getTranslations("pages.admin.users.overview");
|
||||
return (
|
||||
<section className="space-y-4" aria-label={t("title")}>
|
||||
<p className="text-sm text-muted-foreground">{t("hint")}</p>
|
||||
{sections.length === 0 && (
|
||||
<p className="rounded-lg border bg-card p-4 text-sm text-muted-foreground">
|
||||
{t("restricted")}
|
||||
</p>
|
||||
)}
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
{sections.map((section) => (
|
||||
<section
|
||||
key={section.key}
|
||||
className="min-w-0 rounded-lg border bg-card p-4"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<h3 className="font-semibold text-sm">{t(section.key)}</h3>
|
||||
{section.href && (
|
||||
<Link
|
||||
href={section.href}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
{t("openModule")}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{section.key === "purchases" && (
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
{t("purchaseHint")}
|
||||
</p>
|
||||
)}
|
||||
{section.key === "activity" && (
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
{t("activityHint")}
|
||||
</p>
|
||||
)}
|
||||
{section.state === "error" ? (
|
||||
<p role="status" className="text-sm text-destructive">
|
||||
{t("error")}
|
||||
</p>
|
||||
) : section.rows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("empty")}</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{section.rows.map((row) => (
|
||||
<li key={String(row.id)} className="space-y-1 py-2 text-sm">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
{row.href ? (
|
||||
<Link
|
||||
href={row.href}
|
||||
className="min-w-0 break-words font-medium text-primary hover:underline"
|
||||
>
|
||||
#{String(row.id)} · {row.title || "—"}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="min-w-0 break-words font-medium">
|
||||
#{String(row.id)} · {row.title || "—"}
|
||||
</span>
|
||||
)}
|
||||
{row.status && (
|
||||
<span className="max-w-28 shrink-0 break-words rounded bg-muted px-1.5 py-0.5 text-xs">
|
||||
{[
|
||||
"active",
|
||||
"expired",
|
||||
"open",
|
||||
"closed",
|
||||
"reported",
|
||||
"reporter",
|
||||
].includes(row.status)
|
||||
? t(`status.${row.status}`)
|
||||
: row.status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{section.key === "purchases" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("purchaseDetail", {
|
||||
amount: row.amount ?? "—",
|
||||
credits: row.credits ?? "—",
|
||||
points: row.points ?? "—",
|
||||
type: row.pointsType ?? "—",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{row.detail && (
|
||||
<p className="break-words text-muted-foreground">
|
||||
{row.detail}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{section.key === "mute"
|
||||
? t("muteUntil", { date: eventDate(row.at) })
|
||||
: eventDate(row.at)}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { MySqlDialect } from "drizzle-orm/mysql-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { PermissionSet } from "@/lib/permissions";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
allowed: new Set<string>(),
|
||||
execute: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({ db: { execute: state.execute } }));
|
||||
vi.mock("@/lib/permissions", async () => ({
|
||||
...(await import("@/lib/permission-slugs")),
|
||||
canAccess: (_: unknown, permission: string) => state.allowed.has(permission),
|
||||
}));
|
||||
|
||||
import { loadUserOverview } from "./load-user-overview";
|
||||
|
||||
const permissions = {} as PermissionSet;
|
||||
const dialect = new MySqlDialect();
|
||||
beforeEach(() => {
|
||||
state.allowed.clear();
|
||||
state.execute
|
||||
.mockReset()
|
||||
.mockResolvedValue([[{ id: 12, title: "Existing record" }], []]);
|
||||
});
|
||||
describe("user operational overview", () => {
|
||||
it("does not query or expose any restricted source", async () => {
|
||||
expect(await loadUserOverview(42, permissions, 4)).toEqual([]);
|
||||
expect(state.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each([
|
||||
[PERMS.BANS_VIEW, ["sanctions"]],
|
||||
[PERMS.TICKETS_VIEW, ["tickets", "help"]],
|
||||
[PERMS.MODERATION_VIEW, ["mute", "reports"]],
|
||||
[PERMS.SHOP_VIEW, ["transactions"]],
|
||||
[PERMS.LOGS_VIEW, ["purchases", "activity"]],
|
||||
])("queries only sources allowed by %s", async (permission, keys) => {
|
||||
state.allowed.add(permission);
|
||||
const result = await loadUserOverview(42, permissions, 4);
|
||||
expect(result.map((section) => section.key)).toEqual(keys);
|
||||
expect(state.execute).toHaveBeenCalledTimes(keys.length);
|
||||
for (const [query] of state.execute.mock.calls) {
|
||||
const compiled = dialect.sqlToQuery(query);
|
||||
expect(compiled.sql).toMatch(/LIMIT 5$/);
|
||||
expect(compiled.params).toContain(42);
|
||||
}
|
||||
});
|
||||
it("keeps accessible sources available when another query fails", async () => {
|
||||
state.allowed.add(PERMS.TICKETS_VIEW);
|
||||
state.execute.mockRejectedValueOnce(new Error("database unavailable"));
|
||||
const result = await loadUserOverview(42, permissions, 4);
|
||||
expect(result[0]).toMatchObject({
|
||||
key: "tickets",
|
||||
state: "error",
|
||||
rows: [],
|
||||
});
|
||||
expect(result[1]).toMatchObject({
|
||||
key: "help",
|
||||
state: "ready",
|
||||
rows: [{ href: "/admin/help-tickets/12" }],
|
||||
});
|
||||
});
|
||||
it("distinguishes an empty source from a failed source", async () => {
|
||||
state.allowed.add(PERMS.SHOP_VIEW);
|
||||
state.execute.mockResolvedValueOnce([[], []]);
|
||||
expect(await loadUserOverview(42, permissions, 4)).toEqual([
|
||||
{
|
||||
key: "transactions",
|
||||
state: "ready",
|
||||
rows: [],
|
||||
href: "/admin/transactions",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
it("selects and preserves recorded purchase quantities and currency costs", async () => {
|
||||
state.allowed.add(PERMS.LOGS_VIEW);
|
||||
const purchase = {
|
||||
id: 5,
|
||||
title: "Chair",
|
||||
amount: 2,
|
||||
credits: 10,
|
||||
points: 3,
|
||||
pointsType: 5,
|
||||
};
|
||||
state.execute.mockResolvedValueOnce([[purchase], []]);
|
||||
const result = await loadUserOverview(42, permissions, 4);
|
||||
const query = dialect.sqlToQuery(state.execute.mock.calls[0][0]).sql;
|
||||
expect(query).toContain(
|
||||
"amount, cost_credits AS credits, cost_points AS points, points_type AS pointsType",
|
||||
);
|
||||
expect(
|
||||
result.find((section) => section.key === "purchases")?.rows[0],
|
||||
).toMatchObject(purchase);
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { type SQL, sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import { canAccess, PERMS, type PermissionSet } from "@/lib/permissions";
|
||||
|
||||
export type OverviewRow = {
|
||||
id: string | number;
|
||||
title: string;
|
||||
detail?: string | null;
|
||||
status?: string | null;
|
||||
at?: string | number | Date | null;
|
||||
href?: string;
|
||||
amount?: number | null;
|
||||
credits?: number | null;
|
||||
points?: number | null;
|
||||
pointsType?: number | null;
|
||||
};
|
||||
export type OverviewSection = {
|
||||
key: string;
|
||||
href?: string;
|
||||
state: "ready" | "error";
|
||||
rows: OverviewRow[];
|
||||
};
|
||||
|
||||
export async function loadUserOverview(
|
||||
userId: number,
|
||||
permissions: PermissionSet,
|
||||
rank: number,
|
||||
): Promise<OverviewSection[]> {
|
||||
const read = async (query: SQL): Promise<OverviewRow[]> => {
|
||||
const [rows] = await db.execute(query);
|
||||
return rows as unknown as OverviewRow[];
|
||||
};
|
||||
const sources: {
|
||||
key: string;
|
||||
permission: string;
|
||||
href?: string;
|
||||
query: () => Promise<OverviewRow[]>;
|
||||
rowHref?: (id: string | number) => string;
|
||||
}[] = [
|
||||
{
|
||||
key: "sanctions",
|
||||
permission: PERMS.BANS_VIEW,
|
||||
href: "/admin/bans",
|
||||
query: () =>
|
||||
read(
|
||||
sql`SELECT id, type AS title, LEFT(ban_reason, 180) AS detail, timestamp AS at, CASE WHEN ban_expire = 0 OR ban_expire > UNIX_TIMESTAMP() THEN 'active' ELSE 'expired' END AS status FROM bans WHERE user_id = ${userId} ORDER BY timestamp DESC, id DESC LIMIT 5`,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "mute",
|
||||
permission: PERMS.MODERATION_VIEW,
|
||||
query: () =>
|
||||
read(
|
||||
sql`SELECT user_id AS id, '' AS title, mute_end_timestamp AS at FROM users_settings WHERE user_id = ${userId} AND mute_end_timestamp > UNIX_TIMESTAMP() LIMIT 5`,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "tickets",
|
||||
permission: PERMS.TICKETS_VIEW,
|
||||
query: () =>
|
||||
read(
|
||||
sql`SELECT id, subject AS title, status, updated_at AS at FROM website_tickets WHERE creator_id = ${userId} ORDER BY updated_at DESC, id DESC LIMIT 5`,
|
||||
),
|
||||
rowHref: (id) => `/admin/tickets/${id}`,
|
||||
},
|
||||
{
|
||||
key: "help",
|
||||
permission: PERMS.TICKETS_VIEW,
|
||||
query: () =>
|
||||
read(
|
||||
sql`SELECT id, title, CASE WHEN open = 1 THEN 'open' ELSE 'closed' END AS status, created_at AS at FROM website_help_center_tickets WHERE user_id = ${userId} ORDER BY created_at DESC, id DESC LIMIT 5`,
|
||||
),
|
||||
rowHref: (id) => `/admin/help-tickets/${id}`,
|
||||
},
|
||||
{
|
||||
key: "reports",
|
||||
permission: PERMS.MODERATION_VIEW,
|
||||
query: () =>
|
||||
read(
|
||||
sql`SELECT id, LEFT(issue, 180) AS title, CASE WHEN reported_id = ${userId} THEN 'reported' ELSE 'reporter' END AS status, timestamp AS at FROM support_tickets WHERE reported_id = ${userId} OR sender_id = ${userId} ORDER BY timestamp DESC, id DESC LIMIT 5`,
|
||||
),
|
||||
rowHref: (id) => `/admin/moderation/cfh/${id}`,
|
||||
},
|
||||
{
|
||||
key: "transactions",
|
||||
permission: PERMS.SHOP_VIEW,
|
||||
href: "/admin/transactions",
|
||||
query: () =>
|
||||
read(
|
||||
sql`SELECT id, COALESCE(description, transaction_id) AS title, CONCAT(amount, ' ', currency) AS detail, status, created_at AS at FROM website_paypal_transactions WHERE user_id = ${userId} ORDER BY created_at DESC, id DESC LIMIT 5`,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "purchases",
|
||||
permission: PERMS.LOGS_VIEW,
|
||||
query: () =>
|
||||
read(
|
||||
sql`SELECT id, catalog_name AS title, amount, cost_credits AS credits, cost_points AS points, points_type AS pointsType, timestamp AS at FROM logs_shop_purchases WHERE user_id = ${userId} ORDER BY timestamp DESC, id DESC LIMIT 5`,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "activity",
|
||||
permission: PERMS.LOGS_VIEW,
|
||||
href: "/admin/logs/audit",
|
||||
query: () =>
|
||||
read(
|
||||
sql`SELECT id, action AS title, created_at AS at FROM admin_audit_log WHERE target = 'User' AND target_id = ${userId} ORDER BY id DESC LIMIT 5`,
|
||||
),
|
||||
},
|
||||
];
|
||||
// Filter before invoking the query; an inaccessible source must never touch its table.
|
||||
return Promise.all(
|
||||
sources
|
||||
.filter((source) => canAccess(permissions, source.permission, rank))
|
||||
.map(async (source) => {
|
||||
try {
|
||||
const rows = await source.query();
|
||||
return {
|
||||
key: source.key,
|
||||
href: source.href,
|
||||
state: "ready" as const,
|
||||
rows: rows.map((row) => ({
|
||||
...row,
|
||||
href: source.rowHref?.(row.id),
|
||||
})),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
key: source.key,
|
||||
href: source.href,
|
||||
state: "error" as const,
|
||||
rows: [],
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { MySqlDialect } from "drizzle-orm/mysql-core";
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
sql: [] as string[],
|
||||
tables: [] as string[],
|
||||
failLogs: false,
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({
|
||||
notFound: () => {
|
||||
throw new Error("not found");
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/db", () => {
|
||||
const tables = Object.fromEntries(
|
||||
["User", "Rooms", "RoomTradeLog", "UsersBadges", "UsersCurrency"].map(
|
||||
(name) => [name, { name }],
|
||||
),
|
||||
);
|
||||
return {
|
||||
...tables,
|
||||
db: {
|
||||
select() {
|
||||
let name = "";
|
||||
const rows = () =>
|
||||
name === "User"
|
||||
? [{ id: 42, username: "User", rank: 2, online: "0" }]
|
||||
: [];
|
||||
const query = {
|
||||
from(table: { name: string }) {
|
||||
name = table.name;
|
||||
state.tables.push(name);
|
||||
return query;
|
||||
},
|
||||
where() {
|
||||
return query;
|
||||
},
|
||||
orderBy() {
|
||||
return query;
|
||||
},
|
||||
limit() {
|
||||
return query;
|
||||
},
|
||||
// biome-ignore lint/suspicious/noThenProperty: Drizzle queries are intentionally awaitable.
|
||||
then(resolve: (value: unknown[]) => unknown) {
|
||||
return Promise.resolve(rows()).then(resolve);
|
||||
},
|
||||
catch() {
|
||||
return Promise.resolve(rows());
|
||||
},
|
||||
};
|
||||
return query;
|
||||
},
|
||||
async execute(query: Parameters<MySqlDialect["sqlToQuery"]>[0]) {
|
||||
const compiled = new MySqlDialect().sqlToQuery(query).sql;
|
||||
state.sql.push(compiled);
|
||||
if (state.failLogs && compiled.includes("chatlogs_room"))
|
||||
throw new Error("unavailable");
|
||||
return [[], []];
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { loadUserById } from "./load-user";
|
||||
|
||||
beforeEach(() => {
|
||||
state.sql = [];
|
||||
state.tables = [];
|
||||
state.failLogs = false;
|
||||
});
|
||||
it("skips chat, commands, trade and ban queries when excluded by profile ACL", async () => {
|
||||
const user = await loadUserById("42", { logs: false, bans: false });
|
||||
expect(state.sql.join(" ")).not.toMatch(
|
||||
/chatlogs_room|commandlogs|FROM bans/,
|
||||
);
|
||||
expect(state.tables).not.toContain("RoomTradeLog");
|
||||
expect(user.chatLogs).toEqual([]);
|
||||
expect(user.commandLogs).toEqual([]);
|
||||
expect(user.user.bansReceived).toEqual([]);
|
||||
});
|
||||
it("keeps the user profile available and identifies a failed activity source", async () => {
|
||||
state.failLogs = true;
|
||||
const user = await loadUserById("42", { logs: true, bans: false });
|
||||
expect(user.user.id).toBe(42);
|
||||
expect(user.activityUnavailable).toBe(true);
|
||||
});
|
||||
@@ -14,7 +14,10 @@ async function rawRows<T>(query: ReturnType<typeof sql>): Promise<T[]> {
|
||||
return rows ?? [];
|
||||
}
|
||||
|
||||
export async function loadUserById(id: string) {
|
||||
export async function loadUserById(
|
||||
id: string,
|
||||
access: { logs: boolean; bans: boolean },
|
||||
) {
|
||||
const numericId = Number(id);
|
||||
const isNumeric =
|
||||
Number.isSafeInteger(numericId) &&
|
||||
@@ -44,6 +47,7 @@ export async function loadUserById(id: string) {
|
||||
.limit(1);
|
||||
if (!user) notFound();
|
||||
|
||||
let activityUnavailable = false;
|
||||
const [
|
||||
currencies,
|
||||
badges,
|
||||
@@ -64,30 +68,40 @@ export async function loadUserById(id: string) {
|
||||
.from(UsersBadges)
|
||||
.where(eq(UsersBadges.userId, user.id))
|
||||
.orderBy(asc(UsersBadges.slotId)),
|
||||
rawRows<{
|
||||
id: number;
|
||||
banReason: string;
|
||||
type: string;
|
||||
timestamp: number;
|
||||
banExpire: number;
|
||||
staffName: string;
|
||||
}>(sql`
|
||||
access.bans
|
||||
? rawRows<{
|
||||
id: number;
|
||||
banReason: string;
|
||||
type: string;
|
||||
timestamp: number;
|
||||
banExpire: number;
|
||||
staffName: string;
|
||||
}>(sql`
|
||||
SELECT b.id, b.ban_reason AS banReason, b.type, b.timestamp, b.ban_expire AS banExpire,
|
||||
COALESCE(s.username, 'Unknown') AS staffName
|
||||
FROM bans b LEFT JOIN users s ON s.id = b.user_staff_id
|
||||
WHERE b.user_id = ${user.id} ORDER BY b.id DESC LIMIT 5`),
|
||||
WHERE b.user_id = ${user.id} ORDER BY b.id DESC LIMIT 5`).catch(
|
||||
() => [],
|
||||
)
|
||||
: [],
|
||||
rawRows<{ rankName: string }>(sql`
|
||||
SELECT COALESCE(rank_name, CONCAT('Rank ', id)) AS rankName FROM permissions WHERE id = ${user.rank} LIMIT 1`),
|
||||
db.select({ total: count() }).from(Rooms).where(eq(Rooms.ownerId, user.id)),
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(RoomTradeLog)
|
||||
.where(
|
||||
or(
|
||||
eq(RoomTradeLog.userOneId, user.id),
|
||||
eq(RoomTradeLog.userTwoId, user.id),
|
||||
),
|
||||
),
|
||||
access.logs
|
||||
? db
|
||||
.select({ total: count() })
|
||||
.from(RoomTradeLog)
|
||||
.where(
|
||||
or(
|
||||
eq(RoomTradeLog.userOneId, user.id),
|
||||
eq(RoomTradeLog.userTwoId, user.id),
|
||||
),
|
||||
)
|
||||
.catch(() => {
|
||||
activityUnavailable = true;
|
||||
return [];
|
||||
})
|
||||
: [],
|
||||
db
|
||||
.select({
|
||||
id: Rooms.id,
|
||||
@@ -100,23 +114,37 @@ export async function loadUserById(id: string) {
|
||||
.where(eq(Rooms.ownerId, user.id))
|
||||
.orderBy(desc(Rooms.users))
|
||||
.limit(20),
|
||||
rawRows<{
|
||||
id: number;
|
||||
roomId: number;
|
||||
timestamp: number;
|
||||
message: string;
|
||||
}>(sql`
|
||||
access.logs
|
||||
? rawRows<{
|
||||
id: number;
|
||||
roomId: number;
|
||||
timestamp: number;
|
||||
message: string;
|
||||
}>(sql`
|
||||
SELECT ROW_NUMBER() OVER (ORDER BY timestamp DESC) AS id, room_id AS roomId, timestamp, message
|
||||
FROM chatlogs_room WHERE user_from_id = ${user.id} ORDER BY timestamp DESC LIMIT 20`),
|
||||
rawRows<{
|
||||
id: number;
|
||||
timestamp: number;
|
||||
command: string;
|
||||
params: string;
|
||||
succes: string;
|
||||
}>(sql`
|
||||
FROM chatlogs_room WHERE user_from_id = ${user.id} ORDER BY timestamp DESC LIMIT 20`).catch(
|
||||
() => {
|
||||
activityUnavailable = true;
|
||||
return [];
|
||||
},
|
||||
)
|
||||
: [],
|
||||
access.logs
|
||||
? rawRows<{
|
||||
id: number;
|
||||
timestamp: number;
|
||||
command: string;
|
||||
params: string;
|
||||
succes: string;
|
||||
}>(sql`
|
||||
SELECT ROW_NUMBER() OVER (ORDER BY timestamp DESC) AS id, timestamp, command, params, succes
|
||||
FROM commandlogs WHERE user_id = ${user.id} ORDER BY timestamp DESC LIMIT 10`),
|
||||
FROM commandlogs WHERE user_id = ${user.id} ORDER BY timestamp DESC LIMIT 10`).catch(
|
||||
() => {
|
||||
activityUnavailable = true;
|
||||
return [];
|
||||
},
|
||||
)
|
||||
: [],
|
||||
]);
|
||||
|
||||
const roomCount = Number(roomCountRows[0]?.total ?? 0);
|
||||
@@ -157,6 +185,7 @@ export async function loadUserById(id: string) {
|
||||
userRooms,
|
||||
chatLogs,
|
||||
commandLogs,
|
||||
activityUnavailable,
|
||||
isOnline: user.online === "1",
|
||||
activeBan,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { db } from "@/lib/db";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
@@ -25,7 +26,18 @@ export default async function UserEditPage({
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const data = await loadUserById(id);
|
||||
const overviewT = await getTranslations("pages.admin.users.overview");
|
||||
const canViewLogs = canAccess(
|
||||
permissions,
|
||||
PERMS.LOGS_VIEW,
|
||||
session.user.rank,
|
||||
);
|
||||
const canViewBans = canAccess(
|
||||
permissions,
|
||||
PERMS.BANS_VIEW,
|
||||
session.user.rank,
|
||||
);
|
||||
const data = await loadUserById(id, { logs: canViewLogs, bans: canViewBans });
|
||||
const watched = await getWatchedUserIds(session.user.id);
|
||||
|
||||
// Fetch rank list from permissions table for the rank dropdown
|
||||
@@ -45,6 +57,7 @@ export default async function UserEditPage({
|
||||
duckets={data.duckets}
|
||||
roomCount={data.roomCount}
|
||||
chatCount={data.chatCount}
|
||||
canViewLogs={canViewLogs && !data.activityUnavailable}
|
||||
tradeCount={data.tradeCount}
|
||||
isOnline={data.isOnline}
|
||||
activeBan={!!data.activeBan}
|
||||
@@ -57,17 +70,21 @@ export default async function UserEditPage({
|
||||
<Tabs defaultValue="edit" className="space-y-4 min-w-0">
|
||||
<TabsList className="flex-wrap">
|
||||
<TabsTrigger value="edit">Edit Profile</TabsTrigger>
|
||||
<TabsTrigger value="bans">
|
||||
Bans
|
||||
{data.activeBan && (
|
||||
<span className="ml-1.5 flex h-2 w-2 rounded-full bg-destructive" />
|
||||
)}
|
||||
</TabsTrigger>
|
||||
{canViewBans && (
|
||||
<TabsTrigger value="bans">
|
||||
Bans
|
||||
{data.activeBan && (
|
||||
<span className="ml-1.5 flex h-2 w-2 rounded-full bg-destructive" />
|
||||
)}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="badges">
|
||||
Badges ({data.user.badges.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="rooms">Rooms ({data.roomCount})</TabsTrigger>
|
||||
<TabsTrigger value="activity">Activity</TabsTrigger>
|
||||
{canViewLogs && (
|
||||
<TabsTrigger value="activity">Activity</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="info">Account Info</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -88,20 +105,22 @@ export default async function UserEditPage({
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="bans">
|
||||
<UserBanSection
|
||||
userId={data.user.id}
|
||||
username={data.user.username}
|
||||
bans={data.user.bansReceived.map((b) => ({
|
||||
id: b.id,
|
||||
reason: b.banReason,
|
||||
type: b.type,
|
||||
staffName: b.staff.username,
|
||||
timestamp: b.timestamp,
|
||||
banExpire: b.banExpire,
|
||||
}))}
|
||||
/>
|
||||
</TabsContent>
|
||||
{canViewBans && (
|
||||
<TabsContent value="bans">
|
||||
<UserBanSection
|
||||
userId={data.user.id}
|
||||
username={data.user.username}
|
||||
bans={data.user.bansReceived.map((b) => ({
|
||||
id: b.id,
|
||||
reason: b.banReason,
|
||||
type: b.type,
|
||||
staffName: b.staff.username,
|
||||
timestamp: b.timestamp,
|
||||
banExpire: b.banExpire,
|
||||
}))}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
<TabsContent value="badges">
|
||||
<UserBadgesSection
|
||||
@@ -117,12 +136,23 @@ export default async function UserEditPage({
|
||||
<UserRooms rooms={data.userRooms} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="activity">
|
||||
<UserActivityLog
|
||||
chatLogs={data.chatLogs}
|
||||
commandLogs={data.commandLogs}
|
||||
/>
|
||||
</TabsContent>
|
||||
{canViewLogs && (
|
||||
<TabsContent value="activity">
|
||||
{data.activityUnavailable ? (
|
||||
<p
|
||||
role="status"
|
||||
className="rounded-lg border bg-card p-4 text-sm text-destructive"
|
||||
>
|
||||
{overviewT("error")}
|
||||
</p>
|
||||
) : (
|
||||
<UserActivityLog
|
||||
chatLogs={data.chatLogs}
|
||||
commandLogs={data.commandLogs}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
<TabsContent value="info">
|
||||
<UserAccountInfo user={data.user} />
|
||||
|
||||
@@ -6,9 +6,9 @@ import { getWatchedUserIds } from "@/lib/services/watch";
|
||||
import { UserAccountInfo } from "../../_components/user-account-info";
|
||||
import { UserBadgesReadonly } from "../../_components/user-badges-readonly";
|
||||
import { UserHero } from "../../_components/user-hero";
|
||||
import { UserSanctionTimeline } from "../../_components/user-sanction-timeline";
|
||||
import { UserOperationalOverview } from "../../_components/user-operational-overview";
|
||||
import { loadUserById } from "../../_lib/load-user";
|
||||
import { loadUserSanctions } from "../../_lib/load-user-sanctions";
|
||||
import { loadUserOverview } from "../../_lib/load-user-overview";
|
||||
import { UserActivityLog } from "../../[id]/user-activity-log";
|
||||
import { UserQuickActions } from "../../[id]/user-quick-actions";
|
||||
import { UserRooms } from "../../[id]/user-rooms";
|
||||
@@ -25,10 +25,19 @@ export default async function UserShowPage({
|
||||
}
|
||||
|
||||
const t = await getTranslations("pages.admin.users.detail");
|
||||
const overviewT = await getTranslations("pages.admin.users.overview");
|
||||
const canEdit = canAccess(permissions, PERMS.USERS_EDIT, session.user.rank);
|
||||
const data = await loadUserById(id);
|
||||
const [sanctions, watched] = await Promise.all([
|
||||
loadUserSanctions(data.user.id),
|
||||
const canViewLogs = canAccess(
|
||||
permissions,
|
||||
PERMS.LOGS_VIEW,
|
||||
session.user.rank,
|
||||
);
|
||||
const data = await loadUserById(id, {
|
||||
logs: canViewLogs,
|
||||
bans: canAccess(permissions, PERMS.BANS_VIEW, session.user.rank),
|
||||
});
|
||||
const [overview, watched] = await Promise.all([
|
||||
loadUserOverview(data.user.id, permissions, session.user.rank),
|
||||
getWatchedUserIds(session.user.id),
|
||||
]);
|
||||
|
||||
@@ -40,6 +49,7 @@ export default async function UserShowPage({
|
||||
duckets={data.duckets}
|
||||
roomCount={data.roomCount}
|
||||
chatCount={data.chatCount}
|
||||
canViewLogs={canViewLogs && !data.activityUnavailable}
|
||||
tradeCount={data.tradeCount}
|
||||
isOnline={data.isOnline}
|
||||
activeBan={!!data.activeBan}
|
||||
@@ -55,8 +65,9 @@ export default async function UserShowPage({
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Tabs defaultValue="info" className="space-y-4 min-w-0">
|
||||
<Tabs defaultValue="overview" className="space-y-4 min-w-0">
|
||||
<TabsList className="h-auto flex-wrap justify-start">
|
||||
<TabsTrigger value="overview">{overviewT("title")}</TabsTrigger>
|
||||
<TabsTrigger value="info">{t("tabInfo")}</TabsTrigger>
|
||||
<TabsTrigger value="badges">
|
||||
{t("tabBadges", { count: data.user.badges.length })}
|
||||
@@ -64,12 +75,15 @@ export default async function UserShowPage({
|
||||
<TabsTrigger value="rooms">
|
||||
{t("tabRooms", { count: data.roomCount })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="sanctions">
|
||||
{t("tabSanctions", { count: sanctions.events.length })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="activity">{t("tabActivity")}</TabsTrigger>
|
||||
{canViewLogs && (
|
||||
<TabsTrigger value="activity">{t("tabActivity")}</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview">
|
||||
<UserOperationalOverview sections={overview} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="info">
|
||||
<UserAccountInfo user={data.user} />
|
||||
</TabsContent>
|
||||
@@ -87,19 +101,23 @@ export default async function UserShowPage({
|
||||
<UserRooms rooms={data.userRooms} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="sanctions">
|
||||
<UserSanctionTimeline
|
||||
events={sanctions.events}
|
||||
muteUntil={sanctions.muteUntil}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="activity">
|
||||
<UserActivityLog
|
||||
chatLogs={data.chatLogs}
|
||||
commandLogs={data.commandLogs}
|
||||
/>
|
||||
</TabsContent>
|
||||
{canViewLogs && (
|
||||
<TabsContent value="activity">
|
||||
{data.activityUnavailable ? (
|
||||
<p
|
||||
role="status"
|
||||
className="rounded-lg border bg-card p-4 text-sm text-destructive"
|
||||
>
|
||||
{overviewT("error")}
|
||||
</p>
|
||||
) : (
|
||||
<UserActivityLog
|
||||
chatLogs={data.chatLogs}
|
||||
commandLogs={data.commandLogs}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
|
||||
{canEdit && (
|
||||
|
||||
@@ -85,6 +85,7 @@ export const POST = withAdmin(
|
||||
id,
|
||||
sourceSwfBaseUrl,
|
||||
} = body as Record<string, string>;
|
||||
const enabledRaw = (body as Record<string, unknown>).enabled;
|
||||
|
||||
const isHttps = (u: string) => {
|
||||
try {
|
||||
@@ -111,6 +112,7 @@ export const POST = withAdmin(
|
||||
iconBaseUrl: normalized(iconBaseUrl),
|
||||
hotel: hotel ?? "",
|
||||
sourceSwfBaseUrl: normalized(sourceSwfBaseUrl),
|
||||
enabled: enabledRaw !== "false" && enabledRaw !== false,
|
||||
});
|
||||
|
||||
logAudit({
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { MySqlDialect } from "drizzle-orm/mysql-core";
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
select: vi.fn(),
|
||||
guard: vi.fn(),
|
||||
permissions: new Set<string>(),
|
||||
rateLimit: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/db", async () => ({
|
||||
...(await import("@/db/schema")),
|
||||
db: { select: mocks.select },
|
||||
}));
|
||||
vi.mock("@/lib/permissions", async () => ({
|
||||
...(await import("@/lib/permission-slugs")),
|
||||
canAccess: (permissions: { has: (slug: string) => boolean }, slug: string) =>
|
||||
permissions.has(slug),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({ rateLimit: mocks.rateLimit }));
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
withAdmin: (
|
||||
options: unknown,
|
||||
handler: (request: NextRequest, context: unknown) => Promise<Response>,
|
||||
) => {
|
||||
mocks.guard(options);
|
||||
return (request: NextRequest) =>
|
||||
handler(request, {
|
||||
session: { user: { id: 1, rank: 5 } },
|
||||
permissions: {
|
||||
isSuperAdmin: false,
|
||||
has: (slug: string) => mocks.permissions.has(slug),
|
||||
},
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
CatalogPages,
|
||||
CatalogPagesBc,
|
||||
Guilds,
|
||||
ItemsBase,
|
||||
User,
|
||||
WebsiteArticles,
|
||||
WebsiteHelpCenterTickets,
|
||||
WebsiteTicket,
|
||||
} from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { GET } from "./route";
|
||||
|
||||
const calls: {
|
||||
table: unknown;
|
||||
fields: Record<string, unknown>;
|
||||
condition: unknown;
|
||||
limit: number;
|
||||
}[] = [];
|
||||
const dialect = new MySqlDialect();
|
||||
const rows = new Map<unknown, unknown[]>();
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.permissions.clear();
|
||||
mocks.rateLimit.mockResolvedValue({ ok: true });
|
||||
mocks.select.mockReset();
|
||||
rows.clear();
|
||||
calls.length = 0;
|
||||
mocks.select.mockImplementation((fields) => {
|
||||
const call = {
|
||||
table: undefined as unknown,
|
||||
fields,
|
||||
condition: undefined as unknown,
|
||||
limit: 0,
|
||||
};
|
||||
const builder = {
|
||||
from(table: unknown) {
|
||||
call.table = table;
|
||||
return builder;
|
||||
},
|
||||
where(condition: unknown) {
|
||||
call.condition = condition;
|
||||
return builder;
|
||||
},
|
||||
orderBy() {
|
||||
return builder;
|
||||
},
|
||||
limit(limit: number) {
|
||||
call.limit = limit;
|
||||
calls.push(call);
|
||||
return Promise.resolve(rows.get(call.table) ?? []);
|
||||
},
|
||||
};
|
||||
return builder;
|
||||
});
|
||||
});
|
||||
|
||||
const search = (q: string) =>
|
||||
GET(
|
||||
new NextRequest(
|
||||
`https://cms.test/api/admin/search?q=${encodeURIComponent(q)}`,
|
||||
),
|
||||
);
|
||||
|
||||
describe("unified admin search", () => {
|
||||
it("requires the staff dashboard gate and performs no entity reads without category permission", async () => {
|
||||
expect(mocks.guard).toHaveBeenCalledWith({
|
||||
permission: PERMS.ADMIN_DASHBOARD,
|
||||
});
|
||||
expect(await (await search("chair")).json()).toEqual({
|
||||
ok: true,
|
||||
results: [],
|
||||
partial: false,
|
||||
});
|
||||
expect(mocks.select).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each([
|
||||
[PERMS.USERS_VIEW, [User, Guilds]],
|
||||
[PERMS.NEWS_VIEW, [WebsiteArticles]],
|
||||
[PERMS.CATALOG_VIEW, [ItemsBase, CatalogPages, CatalogPagesBc]],
|
||||
[PERMS.TICKETS_VIEW, [WebsiteTicket, WebsiteHelpCenterTickets]],
|
||||
])("reads only categories granted by %s", async (permission, tables) => {
|
||||
mocks.permissions.add(permission);
|
||||
await search("chair");
|
||||
expect(calls.map((call) => call.table)).toEqual(tables);
|
||||
expect(calls.every((call) => call.limit === 5)).toBe(true);
|
||||
});
|
||||
it("returns exact detail destinations without private user or ticket fields", async () => {
|
||||
[PERMS.USERS_VIEW, PERMS.CATALOG_VIEW, PERMS.TICKETS_VIEW].forEach((p) => {
|
||||
mocks.permissions.add(p);
|
||||
});
|
||||
rows.set(User, [{ id: 12, title: "Alice" }]);
|
||||
rows.set(ItemsBase, [{ id: 12, title: "Chair", subtitle: "chair" }]);
|
||||
rows.set(CatalogPagesBc, [{ id: 12, title: "Chairs" }]);
|
||||
rows.set(WebsiteHelpCenterTickets, [{ id: 12n, title: "Help" }]);
|
||||
const payload = await (await search("12")).json();
|
||||
expect(payload.results.map((r: { url: string }) => r.url)).toEqual([
|
||||
"/admin/users/show/12",
|
||||
"/admin/items/12",
|
||||
"/admin/catalog/builder-club/12",
|
||||
"/admin/help-tickets/12",
|
||||
]);
|
||||
expect(calls.find((call) => call.table === User)?.fields).toEqual({
|
||||
id: User.id,
|
||||
title: User.username,
|
||||
});
|
||||
expect(
|
||||
calls.find((call) => call.table === WebsiteHelpCenterTickets)?.fields,
|
||||
).toEqual({
|
||||
id: WebsiteHelpCenterTickets.id,
|
||||
title: WebsiteHelpCenterTickets.title,
|
||||
});
|
||||
});
|
||||
it("ignores short queries and treats wildcard input literally", async () => {
|
||||
mocks.permissions.add(PERMS.USERS_VIEW);
|
||||
await search(" a ");
|
||||
expect(calls).toHaveLength(0);
|
||||
await search("_%");
|
||||
const query = dialect.sqlToQuery(
|
||||
calls[0].condition as Parameters<MySqlDialect["sqlToQuery"]>[0],
|
||||
);
|
||||
expect(query.params).toEqual(["%\\_\\%%"]);
|
||||
expect(query.sql).not.toContain("mail");
|
||||
});
|
||||
it("bounds oversized queries and rejects partial numeric identifiers", async () => {
|
||||
mocks.permissions.add(PERMS.USERS_VIEW);
|
||||
await search("a".repeat(200));
|
||||
expect(
|
||||
dialect.sqlToQuery(
|
||||
calls[0].condition as Parameters<MySqlDialect["sqlToQuery"]>[0],
|
||||
).params,
|
||||
).toEqual([`%${"a".repeat(64)}%`]);
|
||||
await search("12abc");
|
||||
expect(
|
||||
dialect.sqlToQuery(
|
||||
calls[2].condition as Parameters<MySqlDialect["sqlToQuery"]>[0],
|
||||
).params,
|
||||
).toEqual(["%12abc%"]);
|
||||
});
|
||||
it("supports one-digit identifiers", async () => {
|
||||
mocks.permissions.add(PERMS.USERS_VIEW);
|
||||
await search("1");
|
||||
expect(
|
||||
dialect.sqlToQuery(
|
||||
calls[0].condition as Parameters<MySqlDialect["sqlToQuery"]>[0],
|
||||
).params,
|
||||
).toEqual(["%1%", 1]);
|
||||
});
|
||||
it("reports a partial failure while retaining authorized results", async () => {
|
||||
mocks.permissions.add(PERMS.CATALOG_VIEW);
|
||||
rows.set(ItemsBase, [{ id: 12, title: "Chair", subtitle: "chair" }]);
|
||||
const implementation = mocks.select.getMockImplementation();
|
||||
mocks.select.mockImplementation((fields) => {
|
||||
const builder = implementation?.(fields);
|
||||
const originalFrom = builder.from;
|
||||
builder.from = (table: unknown) => {
|
||||
originalFrom(table);
|
||||
if (table === CatalogPagesBc)
|
||||
builder.limit = () => Promise.reject(new Error("missing table"));
|
||||
return builder;
|
||||
};
|
||||
return builder;
|
||||
});
|
||||
const payload = await (await search("chair")).json();
|
||||
expect(payload.partial).toBe(true);
|
||||
expect(payload.results).toHaveLength(1);
|
||||
expect(payload.results[0].url).toBe("/admin/items/12");
|
||||
});
|
||||
it("caps the combined results while retaining each requested category", async () => {
|
||||
[
|
||||
PERMS.USERS_VIEW,
|
||||
PERMS.NEWS_VIEW,
|
||||
PERMS.CATALOG_VIEW,
|
||||
PERMS.TICKETS_VIEW,
|
||||
].forEach((permission) => {
|
||||
mocks.permissions.add(permission);
|
||||
});
|
||||
[
|
||||
User,
|
||||
WebsiteArticles,
|
||||
ItemsBase,
|
||||
CatalogPages,
|
||||
CatalogPagesBc,
|
||||
WebsiteTicket,
|
||||
WebsiteHelpCenterTickets,
|
||||
].forEach((table) => {
|
||||
rows.set(
|
||||
table,
|
||||
Array.from({ length: 5 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
title: "Match",
|
||||
subtitle: "Name",
|
||||
})),
|
||||
);
|
||||
});
|
||||
const payload = await (await search("Match")).json();
|
||||
expect(payload.results).toHaveLength(20);
|
||||
expect(
|
||||
new Set(payload.results.map((row: { type: string }) => row.type)),
|
||||
).toEqual(
|
||||
new Set(["users", "articles", "furniture", "catalog", "tickets"]),
|
||||
);
|
||||
});
|
||||
it("rate limits without reading records", async () => {
|
||||
mocks.permissions.add(PERMS.USERS_VIEW);
|
||||
mocks.rateLimit.mockResolvedValue({ ok: false, retryAfter: 20 });
|
||||
expect((await search("Alice")).status).toBe(429);
|
||||
expect(mocks.select).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+314
-125
@@ -1,20 +1,24 @@
|
||||
import { eq, like, or } from "drizzle-orm";
|
||||
import { asc, eq, like, or } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { ADMIN_NAV_GROUPS, navItemIsAllowed } from "@/lib/admin-nav";
|
||||
import { apiOk } from "@/lib/api";
|
||||
import { withAdmin } from "@/lib/api-handler";
|
||||
import {
|
||||
CatalogPages,
|
||||
CatalogPagesBc,
|
||||
db,
|
||||
Guilds,
|
||||
ItemsBase,
|
||||
Rooms,
|
||||
User,
|
||||
WebsiteArticles,
|
||||
WebsiteHelpCenterTickets,
|
||||
WebsiteRareValues,
|
||||
WebsiteShopArticles,
|
||||
WebsiteTicket,
|
||||
} from "@/lib/db";
|
||||
import { canAccess, PERMS } from "@/lib/permissions";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
|
||||
export type AdminSearchResult = {
|
||||
type: string;
|
||||
id: number | string;
|
||||
@@ -22,16 +26,13 @@ export type AdminSearchResult = {
|
||||
subtitle: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
const PER_TYPE = 5;
|
||||
const MAX_TOTAL = 20;
|
||||
const MAX_QUERY_LENGTH = 64;
|
||||
|
||||
/** Escape LIKE wildcards so user input can't widen the match. */
|
||||
export function escapeLike(input: string): string {
|
||||
function escapeLike(input: string): string {
|
||||
return input.replace(/[\\%_]/g, (m) => `\\${m}`);
|
||||
}
|
||||
|
||||
export const GET = withAdmin(
|
||||
{ permission: PERMS.ADMIN_DASHBOARD },
|
||||
async (request, context) => {
|
||||
@@ -46,14 +47,12 @@ export const GET = withAdmin(
|
||||
{ status: 429, headers: { "retry-after": String(limited.retryAfter) } },
|
||||
);
|
||||
}
|
||||
|
||||
const q = (request.nextUrl.searchParams.get("q") || "")
|
||||
.trim()
|
||||
.slice(0, MAX_QUERY_LENGTH);
|
||||
if (q.length < 2) {
|
||||
if (q.length < 2 && !/^[1-9]$/.test(q)) {
|
||||
return apiOk({ results: [] });
|
||||
}
|
||||
|
||||
const canSearch = (href: string) => {
|
||||
const item = ADMIN_NAV_GROUPS.flatMap((group) => group.items).find(
|
||||
(item) => item.href === href,
|
||||
@@ -67,123 +66,272 @@ export const GET = withAdmin(
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const pattern = `%${escapeLike(q)}%`;
|
||||
const idExact = Number.parseInt(q, 10);
|
||||
const hasId = Number.isFinite(idExact) && String(idExact) === q;
|
||||
|
||||
const [users, articles, rooms, guilds, shopArticles, rareValues] =
|
||||
await Promise.all([
|
||||
canSearch("/admin/users")
|
||||
? db
|
||||
.select({
|
||||
id: User.id,
|
||||
title: User.username,
|
||||
subtitle: User.mail,
|
||||
})
|
||||
.from(User)
|
||||
.where(
|
||||
or(
|
||||
like(User.username, pattern),
|
||||
like(User.mail, pattern),
|
||||
...(hasId ? [eq(User.id, idExact)] : []),
|
||||
),
|
||||
)
|
||||
.limit(PER_TYPE)
|
||||
: Promise.resolve([]),
|
||||
|
||||
canSearch("/admin/articles")
|
||||
? db
|
||||
.select({
|
||||
id: WebsiteArticles.id,
|
||||
title: WebsiteArticles.title,
|
||||
subtitle: WebsiteArticles.slug,
|
||||
})
|
||||
.from(WebsiteArticles)
|
||||
.where(
|
||||
or(
|
||||
like(WebsiteArticles.title, pattern),
|
||||
like(WebsiteArticles.slug, pattern),
|
||||
),
|
||||
)
|
||||
.limit(PER_TYPE)
|
||||
: Promise.resolve([]),
|
||||
|
||||
canSearch("/admin/rooms")
|
||||
? db
|
||||
.select({
|
||||
id: Rooms.id,
|
||||
title: Rooms.name,
|
||||
subtitle: Rooms.ownerName,
|
||||
})
|
||||
.from(Rooms)
|
||||
.where(
|
||||
or(
|
||||
like(Rooms.name, pattern),
|
||||
...(hasId ? [eq(Rooms.id, idExact)] : []),
|
||||
),
|
||||
)
|
||||
.limit(PER_TYPE)
|
||||
: Promise.resolve([]),
|
||||
|
||||
canSearch("/admin/guilds")
|
||||
? db
|
||||
.select({
|
||||
id: Guilds.id,
|
||||
title: Guilds.name,
|
||||
subtitle: Guilds.description,
|
||||
})
|
||||
.from(Guilds)
|
||||
.where(
|
||||
or(
|
||||
like(Guilds.name, pattern),
|
||||
...(hasId ? [eq(Guilds.id, idExact)] : []),
|
||||
),
|
||||
)
|
||||
.limit(PER_TYPE)
|
||||
: Promise.resolve([]),
|
||||
|
||||
canSearch("/admin/shop")
|
||||
? db
|
||||
.select({
|
||||
id: WebsiteShopArticles.id,
|
||||
title: WebsiteShopArticles.name,
|
||||
subtitle: WebsiteShopArticles.info,
|
||||
})
|
||||
.from(WebsiteShopArticles)
|
||||
.where(
|
||||
or(
|
||||
like(WebsiteShopArticles.name, pattern),
|
||||
...(hasId
|
||||
? [eq(WebsiteShopArticles.id, BigInt(idExact))]
|
||||
: []),
|
||||
),
|
||||
)
|
||||
.limit(PER_TYPE)
|
||||
: Promise.resolve([]),
|
||||
|
||||
canSearch("/admin/rare-values")
|
||||
? db
|
||||
.select({
|
||||
id: WebsiteRareValues.id,
|
||||
title: WebsiteRareValues.name,
|
||||
subtitle: WebsiteRareValues.itemId,
|
||||
})
|
||||
.from(WebsiteRareValues)
|
||||
.where(
|
||||
or(
|
||||
like(WebsiteRareValues.name, pattern),
|
||||
...(hasId ? [eq(WebsiteRareValues.itemId, idExact)] : []),
|
||||
),
|
||||
)
|
||||
.limit(PER_TYPE)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const hasId =
|
||||
Number.isSafeInteger(idExact) && idExact > 0 && String(idExact) === q;
|
||||
let partial = false;
|
||||
const [
|
||||
users,
|
||||
articles,
|
||||
rooms,
|
||||
guilds,
|
||||
shopArticles,
|
||||
rareValues,
|
||||
furniture,
|
||||
catalog,
|
||||
catalogBc,
|
||||
tickets,
|
||||
helpTickets,
|
||||
] = await Promise.all([
|
||||
canSearch("/admin/users")
|
||||
? db
|
||||
.select({
|
||||
id: User.id,
|
||||
title: User.username,
|
||||
})
|
||||
.from(User)
|
||||
.where(
|
||||
or(
|
||||
like(User.username, pattern),
|
||||
...(hasId ? [eq(User.id, idExact)] : []),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(User.id))
|
||||
.limit(PER_TYPE)
|
||||
.catch(() => {
|
||||
partial = true;
|
||||
return [];
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
canSearch("/admin/articles")
|
||||
? db
|
||||
.select({
|
||||
id: WebsiteArticles.id,
|
||||
title: WebsiteArticles.title,
|
||||
subtitle: WebsiteArticles.slug,
|
||||
})
|
||||
.from(WebsiteArticles)
|
||||
.where(
|
||||
or(
|
||||
like(WebsiteArticles.title, pattern),
|
||||
like(WebsiteArticles.slug, pattern),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(WebsiteArticles.id))
|
||||
.limit(PER_TYPE)
|
||||
.catch(() => {
|
||||
partial = true;
|
||||
return [];
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
canSearch("/admin/rooms")
|
||||
? db
|
||||
.select({
|
||||
id: Rooms.id,
|
||||
title: Rooms.name,
|
||||
subtitle: Rooms.ownerName,
|
||||
})
|
||||
.from(Rooms)
|
||||
.where(
|
||||
or(
|
||||
like(Rooms.name, pattern),
|
||||
...(hasId ? [eq(Rooms.id, idExact)] : []),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(Rooms.id))
|
||||
.limit(PER_TYPE)
|
||||
.catch(() => {
|
||||
partial = true;
|
||||
return [];
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
canSearch("/admin/guilds")
|
||||
? db
|
||||
.select({
|
||||
id: Guilds.id,
|
||||
title: Guilds.name,
|
||||
subtitle: Guilds.description,
|
||||
})
|
||||
.from(Guilds)
|
||||
.where(
|
||||
or(
|
||||
like(Guilds.name, pattern),
|
||||
...(hasId ? [eq(Guilds.id, idExact)] : []),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(Guilds.id))
|
||||
.limit(PER_TYPE)
|
||||
.catch(() => {
|
||||
partial = true;
|
||||
return [];
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
canSearch("/admin/shop")
|
||||
? db
|
||||
.select({
|
||||
id: WebsiteShopArticles.id,
|
||||
title: WebsiteShopArticles.name,
|
||||
subtitle: WebsiteShopArticles.info,
|
||||
})
|
||||
.from(WebsiteShopArticles)
|
||||
.where(
|
||||
or(
|
||||
like(WebsiteShopArticles.name, pattern),
|
||||
...(hasId ? [eq(WebsiteShopArticles.id, BigInt(idExact))] : []),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(WebsiteShopArticles.id))
|
||||
.limit(PER_TYPE)
|
||||
.catch(() => {
|
||||
partial = true;
|
||||
return [];
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
canSearch("/admin/rare-values")
|
||||
? db
|
||||
.select({
|
||||
id: WebsiteRareValues.id,
|
||||
title: WebsiteRareValues.name,
|
||||
subtitle: WebsiteRareValues.itemId,
|
||||
})
|
||||
.from(WebsiteRareValues)
|
||||
.where(
|
||||
or(
|
||||
like(WebsiteRareValues.name, pattern),
|
||||
...(hasId ? [eq(WebsiteRareValues.itemId, idExact)] : []),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(WebsiteRareValues.id))
|
||||
.limit(PER_TYPE)
|
||||
.catch(() => {
|
||||
partial = true;
|
||||
return [];
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
canAccess(
|
||||
context.permissions,
|
||||
PERMS.CATALOG_VIEW,
|
||||
context.session.user.rank,
|
||||
)
|
||||
? db
|
||||
.select({
|
||||
id: ItemsBase.id,
|
||||
title: ItemsBase.publicName,
|
||||
subtitle: ItemsBase.itemName,
|
||||
})
|
||||
.from(ItemsBase)
|
||||
.where(
|
||||
or(
|
||||
like(ItemsBase.publicName, pattern),
|
||||
like(ItemsBase.itemName, pattern),
|
||||
...(hasId ? [eq(ItemsBase.id, idExact)] : []),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(ItemsBase.id))
|
||||
.limit(PER_TYPE)
|
||||
.catch(() => {
|
||||
partial = true;
|
||||
return [];
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
canAccess(
|
||||
context.permissions,
|
||||
PERMS.CATALOG_VIEW,
|
||||
context.session.user.rank,
|
||||
)
|
||||
? db
|
||||
.select({ id: CatalogPages.id, title: CatalogPages.caption })
|
||||
.from(CatalogPages)
|
||||
.where(
|
||||
or(
|
||||
like(CatalogPages.caption, pattern),
|
||||
...(hasId ? [eq(CatalogPages.id, idExact)] : []),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(CatalogPages.id))
|
||||
.limit(PER_TYPE)
|
||||
.catch(() => {
|
||||
partial = true;
|
||||
return [];
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
canAccess(
|
||||
context.permissions,
|
||||
PERMS.CATALOG_VIEW,
|
||||
context.session.user.rank,
|
||||
)
|
||||
? db
|
||||
.select({ id: CatalogPagesBc.id, title: CatalogPagesBc.caption })
|
||||
.from(CatalogPagesBc)
|
||||
.where(
|
||||
or(
|
||||
like(CatalogPagesBc.caption, pattern),
|
||||
...(hasId ? [eq(CatalogPagesBc.id, idExact)] : []),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(CatalogPagesBc.id))
|
||||
.limit(PER_TYPE)
|
||||
.catch(() => {
|
||||
partial = true;
|
||||
return [];
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
canAccess(
|
||||
context.permissions,
|
||||
PERMS.TICKETS_VIEW,
|
||||
context.session.user.rank,
|
||||
)
|
||||
? db
|
||||
.select({ id: WebsiteTicket.id, title: WebsiteTicket.subject })
|
||||
.from(WebsiteTicket)
|
||||
.where(
|
||||
or(
|
||||
like(WebsiteTicket.subject, pattern),
|
||||
...(hasId ? [eq(WebsiteTicket.id, idExact)] : []),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(WebsiteTicket.id))
|
||||
.limit(PER_TYPE)
|
||||
.catch(() => {
|
||||
partial = true;
|
||||
return [];
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
canAccess(
|
||||
context.permissions,
|
||||
PERMS.TICKETS_VIEW,
|
||||
context.session.user.rank,
|
||||
)
|
||||
? db
|
||||
.select({
|
||||
id: WebsiteHelpCenterTickets.id,
|
||||
title: WebsiteHelpCenterTickets.title,
|
||||
})
|
||||
.from(WebsiteHelpCenterTickets)
|
||||
.where(
|
||||
or(
|
||||
like(WebsiteHelpCenterTickets.title, pattern),
|
||||
...(hasId
|
||||
? [eq(WebsiteHelpCenterTickets.id, BigInt(idExact))]
|
||||
: []),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(WebsiteHelpCenterTickets.id))
|
||||
.limit(PER_TYPE)
|
||||
.catch(() => {
|
||||
partial = true;
|
||||
return [];
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
const groupMap: Record<
|
||||
string,
|
||||
{ type: string; items: Array<AdminSearchResult> }
|
||||
> = {
|
||||
furniture: { type: "furniture", items: [] },
|
||||
catalog: { type: "catalog", items: [] },
|
||||
tickets: { type: "tickets", items: [] },
|
||||
users: { type: "users", items: [] },
|
||||
articles: { type: "articles", items: [] },
|
||||
rooms: { type: "rooms", items: [] },
|
||||
@@ -191,13 +339,12 @@ export const GET = withAdmin(
|
||||
shop: { type: "shop", items: [] },
|
||||
rareValues: { type: "rareValues", items: [] },
|
||||
};
|
||||
|
||||
for (const r of users) {
|
||||
groupMap.users.items.push({
|
||||
type: "users",
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
subtitle: r.subtitle || "",
|
||||
subtitle: `#${r.id}`,
|
||||
url: `/admin/users/show/${r.id}`,
|
||||
});
|
||||
}
|
||||
@@ -246,10 +393,53 @@ export const GET = withAdmin(
|
||||
url: `/admin/rare-values/${r.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
for (const r of furniture) {
|
||||
groupMap.furniture.items.push({
|
||||
type: "furniture",
|
||||
id: r.id,
|
||||
title: r.title || r.subtitle || `#${r.id}`,
|
||||
subtitle: `${r.subtitle || ""} · #${r.id}`,
|
||||
url: `/admin/items/${r.id}`,
|
||||
});
|
||||
}
|
||||
for (const [rows, kind] of [
|
||||
[catalog, "normal"],
|
||||
[catalogBc, "bc"],
|
||||
] as const) {
|
||||
for (const r of rows)
|
||||
groupMap.catalog.items.push({
|
||||
type: "catalog",
|
||||
id: `${kind}-${r.id}`,
|
||||
title: r.title || `#${r.id}`,
|
||||
subtitle: kind === "bc" ? `BC · #${r.id}` : `#${r.id}`,
|
||||
url:
|
||||
kind === "bc"
|
||||
? `/admin/catalog/builder-club/${r.id}`
|
||||
: `/admin/catalog/${r.id}`,
|
||||
});
|
||||
}
|
||||
for (const r of tickets)
|
||||
groupMap.tickets.items.push({
|
||||
type: "tickets",
|
||||
id: `cms-${r.id}`,
|
||||
title: r.title,
|
||||
subtitle: `#${r.id}`,
|
||||
url: `/admin/tickets/${r.id}`,
|
||||
});
|
||||
for (const r of helpTickets)
|
||||
groupMap.tickets.items.push({
|
||||
type: "tickets",
|
||||
id: `help-${r.id}`,
|
||||
title: r.title,
|
||||
subtitle: `#${r.id}`,
|
||||
url: `/admin/help-tickets/${r.id}`,
|
||||
});
|
||||
const results: AdminSearchResult[] = [];
|
||||
const order = [
|
||||
"users",
|
||||
"furniture",
|
||||
"catalog",
|
||||
"tickets",
|
||||
"articles",
|
||||
"rooms",
|
||||
"guilds",
|
||||
@@ -268,7 +458,6 @@ export const GET = withAdmin(
|
||||
}
|
||||
if (!added) break;
|
||||
}
|
||||
|
||||
return apiOk({ results });
|
||||
return apiOk({ results, partial });
|
||||
},
|
||||
);
|
||||
@@ -7,6 +7,8 @@ const mocks = vi.hoisted(() => ({
|
||||
guard: vi.fn(),
|
||||
create: vi.fn(),
|
||||
list: vi.fn(),
|
||||
page: vi.fn(),
|
||||
retry: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
after: vi.fn(),
|
||||
ping: vi.fn(),
|
||||
@@ -31,6 +33,8 @@ vi.mock("@/lib/services/furni-job-store", async (original) => ({
|
||||
ImportJobStore: class {
|
||||
create = mocks.create;
|
||||
list = mocks.list;
|
||||
page = mocks.page;
|
||||
retry = mocks.retry;
|
||||
requestCancellation = mocks.cancel;
|
||||
},
|
||||
}));
|
||||
@@ -129,3 +133,49 @@ it("does not reveal other owners' jobs", async () => {
|
||||
it("rejects unsafe cancellation IDs", async () => {
|
||||
expect((await PATCH(request({ id: "../other" }), ctx)).status).toBe(400);
|
||||
});
|
||||
|
||||
it("delegates retry to owner-scoped server selection, ignoring client items", async () => {
|
||||
const id = randomUUID();
|
||||
mocks.retry.mockResolvedValue({ id });
|
||||
const response = await PATCH(
|
||||
request({ id, action: "retry", items: [item] }),
|
||||
ctx,
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
expect(mocks.retry).toHaveBeenCalledWith(id, 7);
|
||||
});
|
||||
it("rejects retries while the queue is offline", async () => {
|
||||
mocks.ping.mockResolvedValue(null);
|
||||
mocks.retry.mockClear();
|
||||
expect(
|
||||
(await PATCH(request({ id: randomUUID(), action: "retry" }), ctx)).status,
|
||||
).toBe(503);
|
||||
expect(mocks.retry).not.toHaveBeenCalled();
|
||||
});
|
||||
it("loads a page only for the authenticated operator", async () => {
|
||||
mocks.page.mockResolvedValue({ jobs: [], nextCursor: null });
|
||||
const response = await GET(
|
||||
new NextRequest(
|
||||
"http://localhost/api/admin/studio/import-jobs?page=1&userId=99",
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
expect(mocks.page).toHaveBeenCalledWith({
|
||||
userId: 7,
|
||||
limit: 20,
|
||||
before: undefined,
|
||||
});
|
||||
});
|
||||
it("rejects malformed history cursors", async () => {
|
||||
expect(
|
||||
(
|
||||
await GET(
|
||||
new NextRequest(
|
||||
"http://localhost/api/admin/studio/import-jobs?page=1&before=broken",
|
||||
),
|
||||
ctx,
|
||||
)
|
||||
).status,
|
||||
).toBe(400);
|
||||
});
|
||||
@@ -36,7 +36,26 @@ const schema = z.object({
|
||||
});
|
||||
export const GET = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (_request, ctx) => {
|
||||
async (request, ctx) => {
|
||||
const query = new URL(request.url).searchParams;
|
||||
if (query.has("page")) {
|
||||
const before = query.get("before") || undefined;
|
||||
if (
|
||||
before &&
|
||||
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\|[0-9a-f-]{36}$/i.test(
|
||||
before,
|
||||
)
|
||||
)
|
||||
return apiError("Invalid history cursor", 400);
|
||||
after(drainFurnitureImports);
|
||||
return apiOk(
|
||||
await new ImportJobStore().page({
|
||||
userId: ctx.session.user.id,
|
||||
limit: 20,
|
||||
before,
|
||||
}),
|
||||
);
|
||||
}
|
||||
after(drainFurnitureImports);
|
||||
const jobs = await new ImportJobStore().list({
|
||||
userId: ctx.session.user.id,
|
||||
@@ -88,6 +107,26 @@ export const PATCH = withAdmin(
|
||||
async (request, ctx) => {
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!validJobId(body?.id)) return apiError("Invalid import ID", 400);
|
||||
if (body.action === "retry") {
|
||||
if (!redis || (await redis.ping().catch(() => null)) !== "PONG")
|
||||
return apiError(
|
||||
"Background import queue is temporarily unavailable",
|
||||
503,
|
||||
);
|
||||
const job = await new ImportJobStore().retry(
|
||||
body.id,
|
||||
ctx.session.user.id,
|
||||
);
|
||||
if (!job)
|
||||
return apiError(
|
||||
"No safe remaining items to retry, or import job not found",
|
||||
404,
|
||||
);
|
||||
after(drainFurnitureImports);
|
||||
return apiOk({ job });
|
||||
}
|
||||
if (body.action && body.action !== "cancel")
|
||||
return apiError("Invalid job action", 400);
|
||||
const job = await new ImportJobStore().requestCancellation(
|
||||
body.id,
|
||||
ctx.session.user.id,
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { ArticleSaveResult } from "@/lib/article-input";
|
||||
import { slugify } from "@/lib/format";
|
||||
import { ArticlePreview, type ArticlePreviewData } from "./article-preview";
|
||||
import { useArticleRecovery } from "./article-recovery";
|
||||
import { FormFieldError, useFormErrorFocus } from "./form-feedback";
|
||||
import { MediaPicker } from "./media-picker";
|
||||
import { RichText } from "./rich-text";
|
||||
|
||||
@@ -36,6 +37,7 @@ export function ArticleForm({
|
||||
const tAction = useTranslations("pages.admin.actions");
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string[]>>({});
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
const saving = useRef(false);
|
||||
@@ -86,6 +88,7 @@ export function ArticleForm({
|
||||
dirty,
|
||||
restoreDraft,
|
||||
);
|
||||
useFormErrorFocus(formRef, fieldErrors, pending);
|
||||
const [preview, setPreview] = useState<ArticlePreviewData | null>(null);
|
||||
const suggestedSlug = useMemo(() => slugify(title), [title]);
|
||||
|
||||
@@ -105,12 +108,14 @@ export function ArticleForm({
|
||||
const submittedVersion = editVersion.current;
|
||||
saving.current = true;
|
||||
setSaveError(null);
|
||||
setFieldErrors({});
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await recovery.wait();
|
||||
const result = await action(data);
|
||||
if (result && !result.ok) {
|
||||
setSaveError(result.error);
|
||||
setFieldErrors(result.fieldErrors ?? {});
|
||||
return;
|
||||
}
|
||||
if (editVersion.current === submittedVersion) setDirty(false);
|
||||
@@ -151,6 +156,10 @@ export function ArticleForm({
|
||||
</span>
|
||||
<input
|
||||
name="title"
|
||||
aria-invalid={!!fieldErrors.title?.length}
|
||||
aria-describedby={
|
||||
fieldErrors.title?.length ? "error-title" : undefined
|
||||
}
|
||||
maxLength={255}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
@@ -158,6 +167,7 @@ export function ArticleForm({
|
||||
required
|
||||
className="input input-bordered mt-1 w-full"
|
||||
/>
|
||||
<FormFieldError name="title" errors={fieldErrors} />
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
@@ -186,6 +196,10 @@ export function ArticleForm({
|
||||
<input
|
||||
aria-label={t("image")}
|
||||
name="image"
|
||||
aria-invalid={!!fieldErrors.image?.length}
|
||||
aria-describedby={
|
||||
fieldErrors.image?.length ? "error-image" : undefined
|
||||
}
|
||||
maxLength={255}
|
||||
value={image}
|
||||
onChange={(e) => {
|
||||
@@ -195,6 +209,7 @@ export function ArticleForm({
|
||||
placeholder={t("imagePlaceholder")}
|
||||
className="input input-bordered min-w-0 flex-1"
|
||||
/>
|
||||
<FormFieldError name="image" errors={fieldErrors} />
|
||||
<MediaPicker
|
||||
onSelect={(url) => {
|
||||
setImage(url);
|
||||
@@ -227,6 +242,10 @@ export function ArticleForm({
|
||||
</span>
|
||||
<textarea
|
||||
name="shortStory"
|
||||
aria-invalid={!!fieldErrors.shortStory?.length}
|
||||
aria-describedby={
|
||||
fieldErrors.shortStory?.length ? "error-shortStory" : undefined
|
||||
}
|
||||
maxLength={255}
|
||||
value={summary}
|
||||
onChange={(event) => setSummary(event.target.value)}
|
||||
@@ -234,6 +253,7 @@ export function ArticleForm({
|
||||
rows={2}
|
||||
className="input input-bordered mt-1 w-full resize-y"
|
||||
/>
|
||||
<FormFieldError name="shortStory" errors={fieldErrors} />
|
||||
</label>
|
||||
|
||||
<div className="block">
|
||||
@@ -257,6 +277,10 @@ export function ArticleForm({
|
||||
</span>
|
||||
<select
|
||||
name="status"
|
||||
aria-invalid={!!fieldErrors.status?.length}
|
||||
aria-describedby={
|
||||
fieldErrors.status?.length ? "error-status" : undefined
|
||||
}
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
className="input input-bordered mt-1 w-full"
|
||||
@@ -265,6 +289,7 @@ export function ArticleForm({
|
||||
<option value="scheduled">{t("statusScheduled")}</option>
|
||||
<option value="published">{t("statusPublished")}</option>
|
||||
</select>
|
||||
<FormFieldError name="status" errors={fieldErrors} />
|
||||
</label>
|
||||
|
||||
{status === "scheduled" ? (
|
||||
@@ -275,11 +300,16 @@ export function ArticleForm({
|
||||
<input
|
||||
type="datetime-local"
|
||||
name="publishAt"
|
||||
aria-invalid={!!fieldErrors.publishAt?.length}
|
||||
aria-describedby={
|
||||
fieldErrors.publishAt?.length ? "error-publishAt" : undefined
|
||||
}
|
||||
value={publishAt}
|
||||
onChange={(e) => setPublishAt(e.target.value)}
|
||||
required
|
||||
className="input input-bordered mt-1 w-full"
|
||||
/>
|
||||
<FormFieldError name="publishAt" errors={fieldErrors} />
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
applyCatalogIntegrityRepairAction,
|
||||
inspectCatalogIntegrityAction,
|
||||
previewCatalogIntegrityRepairAction,
|
||||
} from "@/actions/catalog-integrity";
|
||||
import Link from "@/components/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import type {
|
||||
IntegrityCatalog,
|
||||
inspectCatalogIntegrity,
|
||||
} from "@/lib/services/catalog-integrity";
|
||||
import type { CatalogParentRepairPreview } from "@/lib/services/catalog-repair";
|
||||
|
||||
type Report = ReturnType<typeof inspectCatalogIntegrity>;
|
||||
export function CatalogIntegrityPanel({ canRepair }: { canRepair: boolean }) {
|
||||
const t = useTranslations("pages.admin.catalog.integrity");
|
||||
const [catalog, setCatalog] = useState<IntegrityCatalog>("normal");
|
||||
const [report, setReport] = useState<Report | null>(null);
|
||||
const [preview, setPreview] = useState<CatalogParentRepairPreview | null>(
|
||||
null,
|
||||
);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<"failed" | "stalePreview" | null>(null);
|
||||
const [applied, setApplied] = useState<number | null>(null);
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
const request = useRef(0);
|
||||
const applying = useRef(false);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: A requested refresh must start a new read-only scan.
|
||||
useEffect(() => {
|
||||
const version = ++request.current;
|
||||
setBusy(true);
|
||||
setReport(null);
|
||||
setPreview(null);
|
||||
setError(null);
|
||||
void inspectCatalogIntegrityAction(catalog)
|
||||
.then((result) => {
|
||||
if (version !== request.current) return;
|
||||
if (result.ok && result.data) setReport(result.data);
|
||||
else setError("failed");
|
||||
})
|
||||
.catch(() => {
|
||||
if (version === request.current) setError("failed");
|
||||
})
|
||||
.finally(() => {
|
||||
if (version === request.current) setBusy(false);
|
||||
});
|
||||
return () => {
|
||||
request.current++;
|
||||
};
|
||||
}, [catalog, refresh]);
|
||||
async function prepare() {
|
||||
setBusy(true);
|
||||
setPreview(null);
|
||||
setError(null);
|
||||
setApplied(null);
|
||||
try {
|
||||
const result = await previewCatalogIntegrityRepairAction(catalog);
|
||||
if (result.ok && result.data) setPreview(result.data);
|
||||
else setError("failed");
|
||||
} catch {
|
||||
setError("failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function apply() {
|
||||
if (!preview || applying.current) return;
|
||||
applying.current = true;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await applyCatalogIntegrityRepairAction(
|
||||
preview.catalog,
|
||||
preview.fingerprint,
|
||||
);
|
||||
setPreview(null);
|
||||
if (result.ok && result.data) {
|
||||
setApplied(result.data.applied);
|
||||
setRefresh((value) => value + 1);
|
||||
} else if (!result.ok)
|
||||
setError(result.error === "stalePreview" ? "stalePreview" : "failed");
|
||||
} catch {
|
||||
setError("failed");
|
||||
setPreview(null);
|
||||
} finally {
|
||||
applying.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("title")}</CardTitle>
|
||||
<CardDescription>{t("description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="text-sm" htmlFor="integrity-catalog">
|
||||
{t("catalog")}
|
||||
</label>
|
||||
<select
|
||||
id="integrity-catalog"
|
||||
value={catalog}
|
||||
disabled={busy}
|
||||
onChange={(event) => {
|
||||
setCatalog(event.target.value as IntegrityCatalog);
|
||||
setApplied(null);
|
||||
}}
|
||||
className="rounded-md border bg-[var(--admin-surface)] p-2 text-sm"
|
||||
>
|
||||
<option value="normal">{t("normal")}</option>
|
||||
<option value="bc">{t("bc")}</option>
|
||||
</select>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => setRefresh((value) => value + 1)}
|
||||
>
|
||||
{t("refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
{busy && <p role="status">{t("working")}</p>}
|
||||
{error && (
|
||||
<p role="alert" className="text-destructive">
|
||||
{t(error)}
|
||||
</p>
|
||||
)}
|
||||
{applied !== null && (
|
||||
<p role="status">{t("applied", { count: applied })}</p>
|
||||
)}
|
||||
{report && (
|
||||
<>
|
||||
{!report.iconsAvailable && (
|
||||
<p role="status" className="text-sm">
|
||||
{t("iconsUnavailable")}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm">
|
||||
{report.total === 0
|
||||
? t("healthy")
|
||||
: t("total", {
|
||||
count: report.total,
|
||||
shown: report.issues.length,
|
||||
})}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(report.counts).map(([code, count]) => (
|
||||
<span
|
||||
key={code}
|
||||
className="rounded-md border px-2 py-1 text-xs"
|
||||
>
|
||||
{t(`issues.${code}`)}: {count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{report.issues.length > 0 && (
|
||||
<ul className="m-0 max-h-80 list-none space-y-2 overflow-y-auto p-0">
|
||||
{report.issues.map((issue) => (
|
||||
<li
|
||||
key={`${issue.code}-${issue.id}`}
|
||||
className="rounded-md border p-3 text-sm"
|
||||
>
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
||||
<span className="min-w-0 break-all font-medium">
|
||||
{t(`issues.${issue.code}`)} · #{issue.id} {issue.name}
|
||||
</span>
|
||||
<Link href={issue.href}>{t("open")}</Link>
|
||||
</div>
|
||||
<p className="m-0 break-all text-xs text-muted-foreground">
|
||||
{t("reference", { value: issue.detail })}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{canRepair && (
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<p className="m-0 text-sm">{t("repairHint")}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy || !report}
|
||||
onClick={() => void prepare()}
|
||||
>
|
||||
{t("preview")}
|
||||
</Button>
|
||||
{preview && (
|
||||
<>
|
||||
<p className="m-0 text-sm">
|
||||
{t("planned", { count: preview.changes.length })}
|
||||
</p>
|
||||
<ul className="m-0 max-h-60 overflow-y-auto pl-5 text-sm">
|
||||
{preview.changes.map((change) => (
|
||||
<li key={change.id} className="break-all">
|
||||
#{change.id} {change.caption}:{" "}
|
||||
{t("parentChange", { from: change.from })}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button
|
||||
disabled={busy || preview.changes.length === 0}
|
||||
onClick={() => void apply()}
|
||||
>
|
||||
{t("apply")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { toast } from "sonner";
|
||||
import {
|
||||
applyAlignIdsAction,
|
||||
fixCatalogOffersAction,
|
||||
fixEmulatorIssuesAction,
|
||||
fixEverythingAction,
|
||||
fixSpriteIdsAction,
|
||||
getFurniHealthAction,
|
||||
@@ -33,6 +34,7 @@ interface Health {
|
||||
catalogDuplicates: number;
|
||||
catalogBcDuplicates: number;
|
||||
catalogPageDuplicates: number;
|
||||
emulatorIssues: number;
|
||||
}
|
||||
|
||||
export function CatalogMaintenancePanel() {
|
||||
@@ -175,6 +177,11 @@ export function CatalogMaintenancePanel() {
|
||||
value={health.catalogPageDuplicates}
|
||||
hint="same caption under the same parent (double links)"
|
||||
/>
|
||||
<Stat
|
||||
label="Emulator catalog issues"
|
||||
value={health.emulatorIssues}
|
||||
hint="points currency, prices, stack, orphans, order numbers"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -221,6 +228,19 @@ export function CatalogMaintenancePanel() {
|
||||
>
|
||||
Reconcile FurnitureData ↔ DB
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={busy !== null}
|
||||
onClick={() =>
|
||||
void run(
|
||||
"emulator",
|
||||
fixEmulatorIssuesAction,
|
||||
"Emulator catalog constraints fixed",
|
||||
)
|
||||
}
|
||||
>
|
||||
Fix emulator constraints
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
import { type RefObject, useEffect } from "react";
|
||||
export function useFormErrorFocus(
|
||||
form: RefObject<HTMLFormElement | null>,
|
||||
errors: Record<string, string[]>,
|
||||
pending: boolean,
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (pending) return;
|
||||
for (const field of Object.keys(errors)) {
|
||||
const control = form.current?.elements.namedItem(field);
|
||||
if (control instanceof HTMLElement && !control.hasAttribute("disabled")) {
|
||||
control.focus();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [form, errors, pending]);
|
||||
}
|
||||
export function FormFieldError({
|
||||
name,
|
||||
errors,
|
||||
}: {
|
||||
name: string;
|
||||
errors: Record<string, string[]>;
|
||||
}) {
|
||||
const messages = errors[name];
|
||||
return messages?.length ? (
|
||||
<p id={`error-${name}`} role="alert" className="text-sm text-destructive">
|
||||
{messages.join(" · ")}
|
||||
</p>
|
||||
) : null;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { CatalogExportClient } from "@/app/admin/studio/sync/catalog-export-client";
|
||||
import Link from "@/components/link";
|
||||
import { FurnitureJobHistory } from "./studio/furniture-jobs";
|
||||
import { useFurnitureJobs } from "./studio/use-furniture-jobs";
|
||||
export function OperationsCenter() {
|
||||
const t = useTranslations("pages.admin.operationsCenter");
|
||||
const jobs = useFurnitureJobs(() => {}, true);
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">{t("title")}</h1>
|
||||
<p className="text-[var(--admin-text-muted)]">{t("hint")}</p>
|
||||
<FurnitureJobHistory state={jobs} expanded />
|
||||
<CatalogExportClient monitorOnly />
|
||||
<section className="admin-card space-y-3">
|
||||
<h2 className="text-lg font-semibold">{t("syncTitle")}</h2>
|
||||
<p>{t("syncHint")}</p>
|
||||
<Link href="/admin/studio/sync" className="btn btn-outline">
|
||||
{t("openSync")}
|
||||
</Link>
|
||||
<Link href="/admin/studio/furni" className="btn btn-outline">
|
||||
{t("openImports")}
|
||||
</Link>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -58,7 +58,7 @@ export async function OperationsInbox() {
|
||||
source: "imports",
|
||||
count: total,
|
||||
severity: 2,
|
||||
href: "/admin/studio/furni",
|
||||
href: "/admin/operations",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
@@ -18,18 +18,21 @@ import {
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useDebounce } from "@/hooks/use-debounce";
|
||||
import {
|
||||
type AdminNavigationOptions,
|
||||
visibleAdminNavigation,
|
||||
} from "@/lib/admin-navigation";
|
||||
import { AdminSearchRequest } from "@/lib/admin-search-request";
|
||||
|
||||
type SearchResult = AdminSearchResult;
|
||||
|
||||
type SearchResponse = {
|
||||
ok: boolean;
|
||||
results: SearchResult[];
|
||||
partial?: boolean;
|
||||
};
|
||||
|
||||
export function SearchDialog({
|
||||
@@ -56,8 +59,9 @@ export function SearchDialog({
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [partial, setPartial] = useState(false);
|
||||
const debouncedQuery = useDebounce(query, 250);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const requestRef = useRef(new AdminSearchRequest());
|
||||
|
||||
const groupLabels: Record<string, string> = {
|
||||
users: t("users"),
|
||||
@@ -66,19 +70,22 @@ export function SearchDialog({
|
||||
guilds: t("guilds"),
|
||||
shop: t("shop"),
|
||||
rareValues: t("rareValues"),
|
||||
furniture: t("furniture"),
|
||||
catalog: t("catalog"),
|
||||
tickets: t("tickets"),
|
||||
};
|
||||
|
||||
const fetchResults = useCallback(async (q: string) => {
|
||||
abortRef.current?.abort();
|
||||
requestRef.current.cancel();
|
||||
setFailed(false);
|
||||
setPartial(false);
|
||||
setResults([]);
|
||||
if (q.length < 2) {
|
||||
if (!canSearchRecords(q)) {
|
||||
setResults([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
const controller = requestRef.current.start();
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/search?q=${encodeURIComponent(q)}`, {
|
||||
@@ -86,28 +93,30 @@ export function SearchDialog({
|
||||
});
|
||||
if (!res.ok) throw new Error("Search request failed");
|
||||
const data: SearchResponse = await res.json();
|
||||
if (abortRef.current !== controller || controller.signal.aborted) return;
|
||||
if (data.ok) setResults(data.results);
|
||||
else setFailed(true);
|
||||
if (!requestRef.current.accepts(controller)) return;
|
||||
if (data.ok) {
|
||||
setResults(data.results);
|
||||
setPartial(Boolean(data.partial));
|
||||
} else setFailed(true);
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") return;
|
||||
if (abortRef.current === controller) {
|
||||
if (requestRef.current.accepts(controller)) {
|
||||
setResults([]);
|
||||
setFailed(true);
|
||||
}
|
||||
} finally {
|
||||
if (abortRef.current === controller) setLoading(false);
|
||||
if (requestRef.current.accepts(controller)) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!open || query !== debouncedQuery) return;
|
||||
fetchResults(debouncedQuery);
|
||||
return () => abortRef.current?.abort();
|
||||
}, [debouncedQuery, fetchResults, open]);
|
||||
return () => requestRef.current.cancel();
|
||||
}, [debouncedQuery, fetchResults, open, query]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => abortRef.current?.abort();
|
||||
return () => requestRef.current.cancel();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -121,8 +130,29 @@ export function SearchDialog({
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
|
||||
function handleQueryChange(value: string) {
|
||||
requestRef.current.cancel();
|
||||
setQuery(value.slice(0, 64));
|
||||
setResults([]);
|
||||
setFailed(false);
|
||||
setPartial(false);
|
||||
setLoading(canSearchRecords(value));
|
||||
}
|
||||
|
||||
function handleOpenChange(next: boolean) {
|
||||
if (!next) {
|
||||
requestRef.current.cancel();
|
||||
setQuery("");
|
||||
setResults([]);
|
||||
setFailed(false);
|
||||
setPartial(false);
|
||||
setLoading(false);
|
||||
}
|
||||
setOpen(next);
|
||||
}
|
||||
|
||||
function handleSelect(url: string) {
|
||||
setOpen(false);
|
||||
handleOpenChange(false);
|
||||
setQuery("");
|
||||
setResults([]);
|
||||
router.push(url);
|
||||
@@ -132,19 +162,18 @@ export function SearchDialog({
|
||||
const pages = getPages(query.trim().toLocaleLowerCase()).slice(0, 8);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<button
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label={t("placeholder")}
|
||||
className="flex items-center gap-2 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-1.5 text-xs text-[var(--admin-text-muted)] hover:border-[var(--admin-accent)]/40 hover:text-[var(--admin-text)] transition-colors cursor-pointer"
|
||||
>
|
||||
<SearchIcon size={14} />
|
||||
<span className="hidden sm:inline">{t("placeholder")}</span>
|
||||
<kbd className="pointer-events-none hidden sm:inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium opacity-60">
|
||||
⌘K
|
||||
Ctrl/⌘ K
|
||||
</kbd>
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg p-0 gap-0 overflow-hidden">
|
||||
<DialogTitle className="sr-only">{t("placeholder")}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
@@ -157,21 +186,33 @@ export function SearchDialog({
|
||||
<CommandInput
|
||||
placeholder={t("placeholder")}
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
onValueChange={handleQueryChange}
|
||||
aria-label={t("placeholder")}
|
||||
maxLength={64}
|
||||
className="h-12"
|
||||
/>
|
||||
<CommandList className="max-h-[400px]">
|
||||
{loading && query.length >= 2 ? (
|
||||
{loading && canSearchRecords(query) ? (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
…
|
||||
</div>
|
||||
) : null}
|
||||
{!loading && !failed && (
|
||||
{!loading && !failed && !partial && canSearchRecords(query) && (
|
||||
<CommandEmpty>{t("noResults")}</CommandEmpty>
|
||||
)}
|
||||
{failed && (
|
||||
{!canSearchRecords(query) && (
|
||||
<p className="p-4 text-sm text-muted-foreground">{t("hint")}</p>
|
||||
)}
|
||||
{(failed || partial) && (
|
||||
<p role="alert" className="p-4 text-sm">
|
||||
{t("loadError")}
|
||||
{t(partial ? "partialError" : "loadError")}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchResults(query)}
|
||||
className="ml-2 underline"
|
||||
>
|
||||
{t("retry")}
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
{pages.length > 0 && (
|
||||
@@ -200,7 +241,9 @@ export function SearchDialog({
|
||||
onSelect={() => handleSelect(item.url)}
|
||||
className="flex flex-col items-start gap-0.5 py-2"
|
||||
>
|
||||
<span className="text-sm font-medium">{item.title}</span>
|
||||
<span className="w-full min-w-0 truncate text-sm font-medium">
|
||||
{item.title}
|
||||
</span>
|
||||
{item.subtitle ? (
|
||||
<span className="text-xs text-muted-foreground truncate w-full">
|
||||
{item.subtitle}
|
||||
@@ -217,6 +260,11 @@ export function SearchDialog({
|
||||
);
|
||||
}
|
||||
|
||||
function canSearchRecords(value: string) {
|
||||
const q = value.trim();
|
||||
return q.length >= 2 || /^[1-9]$/.test(q);
|
||||
}
|
||||
|
||||
function groupResults(results: SearchResult[]) {
|
||||
const grouped: Record<string, SearchResult[]> = {};
|
||||
for (const r of results) {
|
||||
|
||||
@@ -7,14 +7,19 @@ import { retryableJobItems } from "@/lib/furni/import-job-retry";
|
||||
import type { useFurnitureJobs } from "./use-furniture-jobs";
|
||||
export function FurnitureJobHistory({
|
||||
state,
|
||||
expanded = false,
|
||||
}: {
|
||||
state: ReturnType<typeof useFurnitureJobs>;
|
||||
expanded?: boolean;
|
||||
}) {
|
||||
const t = useTranslations("pages.admin.importHistory");
|
||||
const format = useFormatter();
|
||||
const [limits, setLimits] = useState<Record<string, number>>({});
|
||||
return (
|
||||
<details className="shrink-0 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-2 text-sm">
|
||||
<details
|
||||
open={expanded || undefined}
|
||||
className="shrink-0 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-2 text-sm"
|
||||
>
|
||||
<summary className="cursor-pointer">
|
||||
{t("summary", {
|
||||
count: state.jobs.filter(
|
||||
@@ -22,15 +27,40 @@ export function FurnitureJobHistory({
|
||||
).length,
|
||||
})}
|
||||
</summary>
|
||||
<div className="max-h-72 space-y-2 overflow-auto py-2">
|
||||
<div
|
||||
className={
|
||||
expanded ? "space-y-2 py-2" : "max-h-72 space-y-2 overflow-auto py-2"
|
||||
}
|
||||
>
|
||||
{state.error && <p role="alert">{state.error}</p>}
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">
|
||||
{t("historyLimit")}
|
||||
{state.paginated ? t("ownerHistory") : t("historyLimit")}
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => void state.refresh()}>
|
||||
{t("refreshHistory")}
|
||||
</Button>
|
||||
{!state.jobs.length && <p>{t("empty")}</p>}
|
||||
{state.paginated && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={state.loading || state.page === 1}
|
||||
onClick={() => void state.navigate("previous")}
|
||||
>
|
||||
{t("previousPage")}
|
||||
</Button>
|
||||
<span>{t("page", { page: state.page })}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={state.loading || !state.nextCursor}
|
||||
onClick={() => void state.navigate("next")}
|
||||
>
|
||||
{t("nextPage")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{!state.jobs.length && !state.loading && !state.error && (
|
||||
<p>{t("empty")}</p>
|
||||
)}
|
||||
{state.jobs.map((job) => {
|
||||
const retryItems = retryableJobItems(job);
|
||||
const done = job.items.filter((i) => i.state === "done"),
|
||||
@@ -123,14 +153,7 @@ export function FurnitureJobHistory({
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={state.busy}
|
||||
onClick={() =>
|
||||
void state.submit(retryItems, {
|
||||
sourceId: job.sourceId,
|
||||
mode: job.mode,
|
||||
translate: job.translate,
|
||||
langs: job.langs,
|
||||
})
|
||||
}
|
||||
onClick={() => void state.retry(job.id)}
|
||||
>
|
||||
{t(
|
||||
job.state === "interrupted" || job.state === "cancelled"
|
||||
|
||||
@@ -5,8 +5,13 @@ import { toast } from "sonner";
|
||||
import { useVisiblePolling } from "@/hooks/use-visible-polling";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
import type { ImportJob, ImportJobItem } from "@/lib/furni/import-job";
|
||||
export function useFurnitureJobs(onComplete: () => void) {
|
||||
export function useFurnitureJobs(onComplete: () => void, paginated = false) {
|
||||
const t = useTranslations("pages.admin.importHistory");
|
||||
const cursor = useRef<string | null>(null);
|
||||
const previous = useRef<Array<string | null>>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [cancelling, setCancelling] = useState<string | null>(null);
|
||||
const cancelInFlight = useRef(false);
|
||||
const [jobs, setJobs] = useState<ImportJob[]>([]),
|
||||
@@ -21,14 +26,18 @@ export function useFurnitureJobs(onComplete: () => void) {
|
||||
const refresh = useCallback(async () => {
|
||||
if (refreshing.current) return;
|
||||
refreshing.current = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await adminFetch("/api/admin/studio/import-jobs");
|
||||
const response = await adminFetch(
|
||||
`/api/admin/studio/import-jobs${paginated ? `?page=1${cursor.current ? `&before=${encodeURIComponent(cursor.current)}` : ""}` : ""}`,
|
||||
);
|
||||
const data = await response.json();
|
||||
if (!response.ok)
|
||||
throw Error(
|
||||
data.error || `Import history unavailable (${response.status})`,
|
||||
);
|
||||
setJobs(data.jobs);
|
||||
setNextCursor(data.nextCursor ?? null);
|
||||
setError("");
|
||||
let changed = false;
|
||||
for (const job of data.jobs as ImportJob[])
|
||||
@@ -48,8 +57,45 @@ export function useFurnitureJobs(onComplete: () => void) {
|
||||
);
|
||||
} finally {
|
||||
refreshing.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [paginated]);
|
||||
const navigate = async (direction: "next" | "previous") => {
|
||||
if (refreshing.current) return;
|
||||
if (direction === "next" && nextCursor) {
|
||||
previous.current.push(cursor.current);
|
||||
cursor.current = nextCursor;
|
||||
} else if (direction === "previous" && previous.current.length)
|
||||
cursor.current = previous.current.pop() ?? null;
|
||||
else return;
|
||||
setPage(previous.current.length + 1);
|
||||
setJobs([]);
|
||||
setNextCursor(null);
|
||||
await refresh();
|
||||
};
|
||||
const retry = async (id: string) => {
|
||||
if (submitting.current) return;
|
||||
submitting.current = true;
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await adminFetch("/api/admin/studio/import-jobs", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id, action: "retry" }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw Error(data.error || t("cancelFailed"));
|
||||
cursor.current = null;
|
||||
previous.current = [];
|
||||
setPage(1);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("cancelFailed"));
|
||||
} finally {
|
||||
submitting.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const active = jobs.some(
|
||||
(job) => job.state === "queued" || job.state === "running",
|
||||
);
|
||||
@@ -120,5 +166,19 @@ export function useFurnitureJobs(onComplete: () => void) {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return { jobs, busy, error, submit, refresh, cancel, cancelling };
|
||||
return {
|
||||
jobs,
|
||||
busy,
|
||||
error,
|
||||
submit,
|
||||
refresh,
|
||||
cancel,
|
||||
cancelling,
|
||||
retry,
|
||||
navigate,
|
||||
page,
|
||||
nextCursor,
|
||||
loading,
|
||||
paginated,
|
||||
};
|
||||
}
|
||||
+2
-2
@@ -1159,8 +1159,8 @@ export const AdminAuditLog = mysqlTable("admin_audit_log", {
|
||||
target: varchar("target", { length: 191 }).notNull().default(""),
|
||||
targetId: int("target_id"),
|
||||
details: text("details"),
|
||||
before: text("before"),
|
||||
after: text("after"),
|
||||
before: mediumtext("before"),
|
||||
after: mediumtext("after"),
|
||||
diff: text("diff"),
|
||||
ipAddress: varchar("ip_address", { length: 45 }),
|
||||
createdAt: varchar("created_at", { length: 64 }).notNull().default(""),
|
||||
|
||||
@@ -49,3 +49,22 @@ it("rejects fractional set/add prices, accepts percentage discounts", () => {
|
||||
.costPoints,
|
||||
).toBe(5);
|
||||
});
|
||||
it("rejects unsupported pointsType values in bulk changes", () => {
|
||||
expect(() =>
|
||||
bulkOfferInputSchema.parse({
|
||||
ids: [1],
|
||||
changes: { pointsType: 101 },
|
||||
}),
|
||||
).toThrow(/not supported/i);
|
||||
});
|
||||
it("accepts supported pointsType values in bulk changes", () => {
|
||||
expect(
|
||||
bulkOfferInputSchema.parse({
|
||||
ids: [1],
|
||||
changes: { pointsType: 5 },
|
||||
}),
|
||||
).toEqual({
|
||||
ids: [1],
|
||||
changes: { pointsType: 5 },
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
import { z } from "zod";
|
||||
import { CatalogInputError } from "./hierarchy";
|
||||
import { offerIdSchema, offerInteger } from "./offer-input";
|
||||
import {
|
||||
EMULATOR_POINTS_TYPES,
|
||||
offerIdSchema,
|
||||
offerInteger,
|
||||
} from "./offer-input";
|
||||
|
||||
const priceChange = z
|
||||
.object({
|
||||
@@ -23,6 +27,14 @@ export const bulkOfferChangesSchema = z
|
||||
.refine(
|
||||
(v) => Object.values(v).some((x) => x !== undefined),
|
||||
"Choose at least one field to change",
|
||||
)
|
||||
.refine(
|
||||
(v) =>
|
||||
v.pointsType === undefined ||
|
||||
EMULATOR_POINTS_TYPES.includes(
|
||||
v.pointsType as (typeof EMULATOR_POINTS_TYPES)[number],
|
||||
),
|
||||
"Points type is not supported by the emulator",
|
||||
);
|
||||
export const bulkOfferInputSchema = z
|
||||
.object({
|
||||
|
||||
@@ -22,7 +22,7 @@ describe("offer compatibility", () => {
|
||||
expect(parseFurnitureIds("12;23;12")).toEqual([12, 23, 12]);
|
||||
const values = {
|
||||
itemIds: "12;23;12",
|
||||
amount: 0,
|
||||
amount: 1,
|
||||
offerId: -1,
|
||||
orderNumber: 0,
|
||||
limitedSells: 4,
|
||||
@@ -45,9 +45,65 @@ describe("offer compatibility", () => {
|
||||
expect(offerPatchSchema.safeParse({ costCredits: Infinity }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(offerPatchSchema.parse({ offerId: 0, amount: 0 })).toEqual({
|
||||
expect(offerPatchSchema.parse({ offerId: 0, amount: 1 })).toEqual({
|
||||
offerId: 0,
|
||||
amount: 0,
|
||||
amount: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("emulator offer constraints", () => {
|
||||
it("rejects negative costCredits and costPoints", () => {
|
||||
expect(offerPatchSchema.safeParse({ costCredits: -1 }).success).toBe(false);
|
||||
expect(offerPatchSchema.safeParse({ costPoints: -5 }).success).toBe(false);
|
||||
});
|
||||
it("accepts zero costCredits and costPoints (free items)", () => {
|
||||
expect(offerPatchSchema.parse({ costCredits: 0, costPoints: 0 })).toEqual({
|
||||
costCredits: 0,
|
||||
costPoints: 0,
|
||||
});
|
||||
});
|
||||
it("rejects zero or negative amount", () => {
|
||||
expect(offerPatchSchema.safeParse({ amount: 0 }).success).toBe(false);
|
||||
expect(offerPatchSchema.safeParse({ amount: -1 }).success).toBe(false);
|
||||
});
|
||||
it("accepts positive amount", () => {
|
||||
expect(offerPatchSchema.parse({ amount: 1 })).toEqual({ amount: 1 });
|
||||
});
|
||||
it("rejects unsupported pointsType values", () => {
|
||||
expect(offerPatchSchema.safeParse({ pointsType: 101 }).success).toBe(false);
|
||||
expect(offerPatchSchema.safeParse({ pointsType: 103 }).success).toBe(false);
|
||||
expect(offerPatchSchema.safeParse({ pointsType: -1 }).success).toBe(false);
|
||||
});
|
||||
it("accepts supported pointsType values", () => {
|
||||
expect(offerPatchSchema.parse({ pointsType: 0 })).toEqual({
|
||||
pointsType: 0,
|
||||
});
|
||||
expect(offerPatchSchema.parse({ pointsType: 5 })).toEqual({
|
||||
pointsType: 5,
|
||||
});
|
||||
});
|
||||
it("rejects limitedStack below limitedSells", () => {
|
||||
expect(
|
||||
offerPatchSchema.safeParse({ limitedStack: 0, limitedSells: 1 }).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
offerPatchSchema.safeParse({ limitedStack: 5, limitedSells: 10 }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
it("accepts limitedStack >= limitedSells", () => {
|
||||
expect(
|
||||
offerPatchSchema.parse({ limitedStack: 10, limitedSells: 5 }),
|
||||
).toEqual({ limitedStack: 10, limitedSells: 5 });
|
||||
expect(
|
||||
offerPatchSchema.parse({ limitedStack: 0, limitedSells: 0 }),
|
||||
).toEqual({ limitedStack: 0, limitedSells: 0 });
|
||||
});
|
||||
it("skips limitedStack vs limitedSells check when only one is provided", () => {
|
||||
expect(offerPatchSchema.parse({ limitedStack: 0 })).toEqual({
|
||||
limitedStack: 0,
|
||||
});
|
||||
expect(offerPatchSchema.parse({ limitedSells: 5 })).toEqual({
|
||||
limitedSells: 5,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,9 @@ import { z } from "zod";
|
||||
export const offerInteger = z.number().int().min(-2147483648).max(2147483647);
|
||||
export const offerIdSchema = offerInteger.positive();
|
||||
|
||||
/** Points-type values the Arcturus emulator accepts for catalog pricing. */
|
||||
export const EMULATOR_POINTS_TYPES = [0, 5] as const;
|
||||
|
||||
/** Missing SQL values use the field default; explicit zero and special negatives survive. */
|
||||
export function numericValue(value: unknown, fallback: number): number {
|
||||
if (value == null || (typeof value === "string" && value.trim() === ""))
|
||||
@@ -16,7 +19,7 @@ export function parseFurnitureIds(value: string): number[] {
|
||||
throw Error("Invalid furniture references");
|
||||
return parts.map((part) => offerIdSchema.parse(Number(part)));
|
||||
}
|
||||
const itemIds = z
|
||||
export const itemIds = z
|
||||
.string()
|
||||
.max(666)
|
||||
.refine((value) => {
|
||||
@@ -28,23 +31,39 @@ const itemIds = z
|
||||
}
|
||||
}, "Invalid furniture references");
|
||||
// Keep signed/special values on existing offer fields: bundle/LTD meaning belongs to the emulator.
|
||||
export const offerPatchSchema = z.object({
|
||||
pageId: offerIdSchema.optional(),
|
||||
itemIds: itemIds.optional(),
|
||||
catalogName: z.string().max(100).optional(),
|
||||
costCredits: offerInteger.optional(),
|
||||
costPoints: offerInteger.optional(),
|
||||
pointsType: offerInteger.optional(),
|
||||
amount: offerInteger.optional(),
|
||||
orderNumber: offerInteger.optional(),
|
||||
offerId: offerInteger.optional(),
|
||||
songId: z.number().int().min(0).max(4294967295).optional(),
|
||||
limitedSells: offerInteger.optional(),
|
||||
limitedStack: offerInteger.optional(),
|
||||
extradata: z.string().max(500).optional(),
|
||||
haveOffer: z.enum(["0", "1"]).optional(),
|
||||
clubOnly: z.enum(["0", "1"]).optional(),
|
||||
});
|
||||
export const offerPatchSchema = z
|
||||
.object({
|
||||
pageId: offerIdSchema.optional(),
|
||||
itemIds: itemIds.optional(),
|
||||
catalogName: z.string().max(100).optional(),
|
||||
costCredits: offerInteger.nonnegative().optional(),
|
||||
costPoints: offerInteger.nonnegative().optional(),
|
||||
pointsType: offerInteger.optional(),
|
||||
amount: offerInteger.positive().optional(),
|
||||
orderNumber: offerInteger.optional(),
|
||||
offerId: offerInteger.optional(),
|
||||
songId: z.number().int().min(0).max(4294967295).optional(),
|
||||
limitedSells: offerInteger.nonnegative().optional(),
|
||||
limitedStack: offerInteger.nonnegative().optional(),
|
||||
extradata: z.string().max(500).optional(),
|
||||
haveOffer: z.enum(["0", "1"]).optional(),
|
||||
clubOnly: z.enum(["0", "1"]).optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) =>
|
||||
data.pointsType === undefined ||
|
||||
EMULATOR_POINTS_TYPES.includes(
|
||||
data.pointsType as (typeof EMULATOR_POINTS_TYPES)[number],
|
||||
),
|
||||
"Points type is not supported by the emulator",
|
||||
)
|
||||
.refine(
|
||||
(data) =>
|
||||
data.limitedStack === undefined ||
|
||||
data.limitedSells === undefined ||
|
||||
data.limitedStack >= data.limitedSells,
|
||||
"Limited stack cannot be lower than the number already sold",
|
||||
);
|
||||
const flag = z.union([
|
||||
z.literal(0),
|
||||
z.literal(1),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import "server-only";
|
||||
import { createHash } from "node:crypto";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { recordHistory } from "@/features/history/server";
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
type BulkOfferInput,
|
||||
@@ -108,6 +109,7 @@ export async function previewBulkOffersCommand(
|
||||
export async function applyBulkOffersCommand(
|
||||
value: BulkOfferInput,
|
||||
fingerprint: string,
|
||||
userId?: number,
|
||||
) {
|
||||
const input = bulkOfferInputSchema.parse(value);
|
||||
if (!/^[a-f0-9]{64}$/.test(fingerprint))
|
||||
@@ -144,6 +146,12 @@ export async function applyBulkOffersCommand(
|
||||
sql`, `,
|
||||
)} WHERE id=${row.id}`,
|
||||
);
|
||||
if (userId)
|
||||
await recordHistory(tx, "prices", row.id, userId, {
|
||||
costCredits: row.before.costCredits,
|
||||
costPoints: row.before.costPoints,
|
||||
pointsType: row.before.pointsType,
|
||||
});
|
||||
}
|
||||
return { changedCount: result.changedCount };
|
||||
});
|
||||
|
||||
@@ -33,6 +33,8 @@ vi.mock("@/lib/db", async () => ({
|
||||
state.bases.filter((row) => compiled.params.includes(row.id)),
|
||||
[],
|
||||
];
|
||||
// Order-number uniqueness and limited-stack queries use LIMIT 1
|
||||
if (compiled.sql.includes("LIMIT 1")) return [[], []];
|
||||
return [
|
||||
state.offers.filter((row) => compiled.params.includes(row.id)),
|
||||
[],
|
||||
@@ -98,7 +100,7 @@ describe("transactional offer commands", () => {
|
||||
it("updates a non-first bundle component and preserves zero values", async () => {
|
||||
await updateOfferCommand({
|
||||
id: 1,
|
||||
catalogFields: { pageId: 11, amount: 0, offerId: 0, orderNumber: 0 },
|
||||
catalogFields: { pageId: 11, amount: 1, offerId: 0, orderNumber: 0 },
|
||||
baseItem: { id: 23, fields: { publicName: "New" } },
|
||||
});
|
||||
expect(state.commits).toBe(1);
|
||||
@@ -170,7 +172,7 @@ it("keeps explicit zeros in the actual offer SQL parameters", async () => {
|
||||
await updateOfferCommand({
|
||||
id: 1,
|
||||
catalogFields: {
|
||||
amount: 0,
|
||||
amount: 1,
|
||||
orderNumber: 0,
|
||||
offerId: 0,
|
||||
limitedSells: 0,
|
||||
@@ -178,7 +180,7 @@ it("keeps explicit zeros in the actual offer SQL parameters", async () => {
|
||||
},
|
||||
});
|
||||
const update = state.queries.find((query) => query.sql.startsWith("UPDATE"));
|
||||
expect(update?.params).toEqual([0, 0, 0, 0, 0, 1]);
|
||||
expect(update?.params).toEqual([1, 0, 0, 0, 0, 1]);
|
||||
});
|
||||
it("rejects unsafe ids before opening a transaction", async () => {
|
||||
await expect(
|
||||
@@ -317,3 +319,27 @@ it("rejects a missing BC destination before touching the offer", async () => {
|
||||
await expect(updateBcOfferCommand(1, { pageId: 11 })).rejects.toThrow("page");
|
||||
expect(state.writes).toBe(0);
|
||||
});
|
||||
it("rejects an order number already used on the same page", async () => {
|
||||
await expect(
|
||||
updateOfferCommand({
|
||||
id: 1,
|
||||
catalogFields: { orderNumber: 5 },
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
// Verify the uniqueness SELECT was issued.
|
||||
const uniquenessQuery = state.queries.find(
|
||||
(q) => q.sql.includes("order_number") && q.sql.includes("LIMIT 1"),
|
||||
);
|
||||
expect(uniquenessQuery).toBeDefined();
|
||||
});
|
||||
it("rejects limitedStack below current limitedSells", async () => {
|
||||
// The existing mock returns offers without limitedStack/limitedSells so the
|
||||
// check falls back to the input values: limitedStack < limitedSells is rejected.
|
||||
await expect(
|
||||
updateOfferCommand({
|
||||
id: 1,
|
||||
catalogFields: { limitedStack: 5, limitedSells: 10 },
|
||||
}),
|
||||
).rejects.toThrow(/limited stack/i);
|
||||
expect(state.writes).toBe(0);
|
||||
});
|
||||
@@ -1,9 +1,13 @@
|
||||
import "server-only";
|
||||
import { getTableColumns, type SQL, sql } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { historySnapshot, recordHistory } from "@/features/history/server";
|
||||
import { CatalogItems, CatalogItemsBc, db, ItemsBase } from "@/lib/db";
|
||||
import { CatalogInputError } from "../domain/hierarchy";
|
||||
import {
|
||||
distinctOfferIds,
|
||||
furniturePatchSchema,
|
||||
itemIds,
|
||||
offerIdSchema,
|
||||
offerInteger,
|
||||
offerPatchSchema,
|
||||
@@ -79,7 +83,34 @@ function assignments(
|
||||
return sql`${sql.identifier(column.name)}=${key === "pageId" ? String(value) : value}`;
|
||||
});
|
||||
}
|
||||
export async function updateOfferCommand(input: UpdateOfferInput) {
|
||||
/** Emulator: "limited stack N is lower than the live slot count M". */
|
||||
function assertLimitedStackOk(limitedStack: number, limitedSells: number) {
|
||||
if (limitedStack < limitedSells)
|
||||
throw new CatalogInputError(
|
||||
`Limited stack (${limitedStack}) is lower than the number already sold (${limitedSells})`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Emulator: "sibling order N is used more than once". */
|
||||
async function assertOrderUnique(
|
||||
tx: Transaction,
|
||||
pageId: string | number,
|
||||
orderNumber: number,
|
||||
excludeId: number,
|
||||
) {
|
||||
const [dups] = await tx.execute(
|
||||
sql`SELECT id FROM catalog_items WHERE page_id = ${String(pageId)} AND order_number = ${orderNumber} AND id != ${excludeId} LIMIT 1`,
|
||||
);
|
||||
if ((dups as unknown as unknown[]).length > 0)
|
||||
throw new CatalogInputError(
|
||||
`Order number ${orderNumber} is already used by another offer on this page`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateOfferCommand(
|
||||
input: UpdateOfferInput,
|
||||
userId?: number,
|
||||
) {
|
||||
offerIdSchema.parse(input.id);
|
||||
const fields = offerPatchSchema.parse(input.catalogFields);
|
||||
const baseFields = input.baseItem
|
||||
@@ -95,6 +126,9 @@ export async function updateOfferCommand(input: UpdateOfferInput) {
|
||||
// Page locks precede offer locks, matching category deletion's lock order.
|
||||
if (fields.pageId !== undefined) await lockPage(tx, fields.pageId);
|
||||
const [offer] = await lockedOffers(tx, [input.id]);
|
||||
const before = userId
|
||||
? await historySnapshot(tx, "prices", input.id)
|
||||
: null;
|
||||
if (input.baseItem) {
|
||||
const currentIds = String(offer.itemIds).split(";").map(Number);
|
||||
const nextIds =
|
||||
@@ -111,6 +145,29 @@ export async function updateOfferCommand(input: UpdateOfferInput) {
|
||||
fields.itemIds === undefined ? [] : parseFurnitureIds(fields.itemIds);
|
||||
if (input.baseItem) references.push(input.baseItem.id);
|
||||
if (references.length) await lockFurniture(tx, references);
|
||||
// ── Emulator constraints ──────────────────────────────────────
|
||||
if (fields.orderNumber !== undefined || fields.pageId !== undefined) {
|
||||
const targetPageId = fields.pageId ?? offer.pageId;
|
||||
const targetOrder = fields.orderNumber ?? 0;
|
||||
if (fields.orderNumber !== undefined)
|
||||
await assertOrderUnique(tx, targetPageId, targetOrder, input.id);
|
||||
}
|
||||
if (
|
||||
fields.limitedStack !== undefined ||
|
||||
fields.limitedSells !== undefined
|
||||
) {
|
||||
const [current] = await tx.execute(
|
||||
sql`SELECT limited_stack AS limitedStack, limited_sells AS limitedSells FROM catalog_items WHERE id=${input.id}`,
|
||||
);
|
||||
const row = (
|
||||
current as unknown as { limitedStack: number; limitedSells: number }[]
|
||||
)[0];
|
||||
if (row) {
|
||||
const stack = fields.limitedStack ?? Number(row.limitedStack);
|
||||
const sells = fields.limitedSells ?? Number(row.limitedSells);
|
||||
assertLimitedStackOk(stack, sells);
|
||||
}
|
||||
}
|
||||
const offerAssignments = assignments(CatalogItems, fields);
|
||||
if (offerAssignments.length)
|
||||
await tx.execute(
|
||||
@@ -121,6 +178,8 @@ export async function updateOfferCommand(input: UpdateOfferInput) {
|
||||
await tx.execute(
|
||||
sql`UPDATE ${ItemsBase} SET ${sql.join(baseAssignments, sql`, `)} WHERE id=${input.baseItem.id}`,
|
||||
);
|
||||
if (before && userId)
|
||||
await recordHistory(tx, "prices", input.id, userId, before);
|
||||
});
|
||||
}
|
||||
export async function reorderOffersCommand(
|
||||
@@ -130,7 +189,16 @@ export async function reorderOffersCommand(
|
||||
for (const row of orders) offerInteger.parse(row.orderNumber);
|
||||
if (!ids.length) return;
|
||||
return db.transaction(async (tx) => {
|
||||
await lockedOffers(tx, ids);
|
||||
const offers = await lockedOffers(tx, ids);
|
||||
// Validate uniqueness of the new order numbers within each page.
|
||||
const pageId = offers[0]?.pageId;
|
||||
if (pageId !== undefined) {
|
||||
const nums = orders.map((r) => r.orderNumber);
|
||||
if (new Set(nums).size !== nums.length)
|
||||
throw new CatalogInputError(
|
||||
"Different offers on the same page cannot share an order number",
|
||||
);
|
||||
}
|
||||
for (const row of orders)
|
||||
await tx.execute(
|
||||
sql`UPDATE ${CatalogItems} SET order_number=${row.orderNumber} WHERE id=${row.id}`,
|
||||
@@ -166,12 +234,12 @@ export async function createOfferCommand<T>(
|
||||
});
|
||||
}
|
||||
// BC deliberately has no currencies, amount, LTD values or shared-furniture mutation.
|
||||
const bcOfferPatchSchema = offerPatchSchema.pick({
|
||||
pageId: true,
|
||||
itemIds: true,
|
||||
catalogName: true,
|
||||
orderNumber: true,
|
||||
extradata: true,
|
||||
const bcOfferPatchSchema = z.object({
|
||||
pageId: offerIdSchema.optional(),
|
||||
itemIds: itemIds.optional(),
|
||||
catalogName: z.string().max(100).optional(),
|
||||
orderNumber: offerInteger.optional(),
|
||||
extradata: z.string().max(500).optional(),
|
||||
});
|
||||
export async function updateBcOfferCommand(
|
||||
id: number,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import "server-only";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { historySnapshot, recordHistory } from "@/features/history/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { allocateCatalogItemId } from "@/lib/services/furni-import";
|
||||
import { remapIncludes } from "../domain/duplicate";
|
||||
@@ -261,6 +262,7 @@ async function updateRow(
|
||||
}
|
||||
async function publishCatalogPackageAttempt(
|
||||
raw: PublishCatalogPackageInput,
|
||||
userId?: number,
|
||||
): Promise<PackagePublication> {
|
||||
const input = publishPackageSchema.parse(raw);
|
||||
const initial = await getCatalogPackageCommand(input.id);
|
||||
@@ -292,21 +294,32 @@ async function publishCatalogPackageAttempt(
|
||||
const pageIds: number[] = [];
|
||||
if (p.mode === "update") {
|
||||
for (const page of p.draft.pages) {
|
||||
const historyBefore = userId
|
||||
? await historySnapshot(tx, "category", page.id)
|
||||
: null;
|
||||
await updateRow(
|
||||
tx,
|
||||
"catalog_pages",
|
||||
page.id,
|
||||
columns(page, pageColumns),
|
||||
);
|
||||
if (historyBefore && userId)
|
||||
await recordHistory(tx, "category", page.id, userId, historyBefore);
|
||||
pageIds.push(page.id);
|
||||
}
|
||||
for (const offer of p.draft.offers)
|
||||
for (const offer of p.draft.offers) {
|
||||
const historyBefore = userId
|
||||
? await historySnapshot(tx, "prices", offer.id)
|
||||
: null;
|
||||
await updateRow(
|
||||
tx,
|
||||
"catalog_items",
|
||||
offer.id,
|
||||
columns(offer, offerColumns),
|
||||
);
|
||||
if (historyBefore && userId)
|
||||
await recordHistory(tx, "prices", offer.id, userId, historyBefore);
|
||||
}
|
||||
} else {
|
||||
if (reserved.length !== source.offers.length)
|
||||
throw new CatalogConflict(
|
||||
@@ -399,10 +412,11 @@ async function publishCatalogPackageAttempt(
|
||||
/** An ID reserved by another process can race our process-local allocator. A collision rolls back the entire copy before retry. */
|
||||
export async function publishCatalogPackageCommand(
|
||||
input: PublishCatalogPackageInput,
|
||||
userId?: number,
|
||||
): Promise<PackagePublication> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
return await publishCatalogPackageAttempt(input);
|
||||
return await publishCatalogPackageAttempt(input, userId);
|
||||
} catch (error) {
|
||||
let cause: unknown = error,
|
||||
duplicate = false;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "server-only";
|
||||
import { getTableColumns, type SQL, sql } from "drizzle-orm";
|
||||
import { historySnapshot, recordHistory } from "@/features/history/server";
|
||||
import { CatalogPages, CatalogPagesBc, db } from "@/lib/db";
|
||||
import {
|
||||
assertParent,
|
||||
@@ -53,6 +54,7 @@ export async function updatePageCommand(
|
||||
id: number,
|
||||
fields: Record<string, unknown>,
|
||||
expected?: Record<string, unknown>,
|
||||
userId?: number,
|
||||
) {
|
||||
positiveId(id);
|
||||
const data = pagePatchSchema.parse(fields);
|
||||
@@ -76,6 +78,8 @@ export async function updatePageCommand(
|
||||
);
|
||||
}
|
||||
}
|
||||
const historyKind = kind === "bc" ? "category_bc" : "category";
|
||||
const before = userId ? await historySnapshot(tx, historyKind, id) : null;
|
||||
if (data.parentId !== undefined) assertParent(rows, id, data.parentId);
|
||||
const assignments = fieldsSql(kind, data);
|
||||
if (!assignments.length)
|
||||
@@ -83,6 +87,8 @@ export async function updatePageCommand(
|
||||
await tx.execute(
|
||||
sql`UPDATE ${tables(kind).pages} SET ${sql.join(assignments, sql`, `)} WHERE id=${id}`,
|
||||
);
|
||||
if (before && userId)
|
||||
await recordHistory(tx, historyKind, id, userId, before);
|
||||
});
|
||||
}
|
||||
export async function createPageCommand(
|
||||
@@ -153,6 +159,7 @@ export async function togglePageCommand(
|
||||
kind: CatalogKind,
|
||||
id: number,
|
||||
field: "enabled" | "visible",
|
||||
userId?: number,
|
||||
) {
|
||||
positiveId(id);
|
||||
if (field !== "enabled" && field !== "visible")
|
||||
@@ -161,9 +168,13 @@ export async function togglePageCommand(
|
||||
const rows = await lockedPages(tx, kind);
|
||||
if (!rows.some((row) => row.id === id))
|
||||
throw new CatalogNotFound("Catalog page not found");
|
||||
const historyKind = kind === "bc" ? "category_bc" : "category";
|
||||
const before = userId ? await historySnapshot(tx, historyKind, id) : null;
|
||||
const column = sql.identifier(field);
|
||||
await tx.execute(
|
||||
sql`UPDATE ${tables(kind).pages} SET ${column}=CASE WHEN ${column}='1' THEN '0' ELSE '1' END WHERE id=${id}`,
|
||||
);
|
||||
if (before && userId)
|
||||
await recordHistory(tx, historyKind, id, userId, before);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRef, useState } from "react";
|
||||
import { previewHistoryRestore, restoreHistory } from "@/actions/history";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
export function HistoryRestore({ id }: { id: number }) {
|
||||
const t = useTranslations("pages.admin.logs.history");
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const pending = useRef<"preview" | "restore" | null>(null);
|
||||
const [busy, setBusy] = useState<"preview" | "restore" | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [preview, setPreview] = useState<Awaited<
|
||||
ReturnType<typeof previewHistoryRestore>
|
||||
> | null>(null);
|
||||
async function load() {
|
||||
if (pending.current) return;
|
||||
pending.current = "preview";
|
||||
setOpen(true);
|
||||
setBusy("preview");
|
||||
setError("");
|
||||
setPreview(null);
|
||||
try {
|
||||
setPreview(await previewHistoryRestore(id));
|
||||
} catch {
|
||||
setError(t("unavailable"));
|
||||
} finally {
|
||||
pending.current = null;
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
async function restore() {
|
||||
if (pending.current || !preview?.canRestore || !preview.changes.length)
|
||||
return;
|
||||
pending.current = "restore";
|
||||
setBusy("restore");
|
||||
setError("");
|
||||
try {
|
||||
const result = await restoreHistory(id);
|
||||
if (!result.ok) {
|
||||
setError(t(result.code));
|
||||
setPreview(null);
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError(t("failed"));
|
||||
} finally {
|
||||
pending.current = null;
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={load}
|
||||
disabled={busy !== null}
|
||||
>
|
||||
{t("preview")}
|
||||
</Button>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen, details) => {
|
||||
if (!nextOpen && pending.current === "restore") {
|
||||
details.cancel();
|
||||
return;
|
||||
}
|
||||
setOpen(nextOpen);
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="w-[calc(100%-2rem)] min-w-0 overflow-x-hidden sm:max-w-3xl"
|
||||
showCloseButton={busy !== "restore"}
|
||||
>
|
||||
<DialogTitle className="pr-6 leading-snug">{t("title")}</DialogTitle>
|
||||
<DialogDescription>{t("description")}</DialogDescription>
|
||||
{busy && (
|
||||
<p role="status">
|
||||
{t(busy === "restore" ? "restoring" : "loading")}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{preview && (
|
||||
<>
|
||||
{!preview.canRestore && (
|
||||
<p role="alert" className="text-destructive">
|
||||
{t("conflict")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="max-h-[50dvh] min-w-0 space-y-4 overflow-y-auto pr-1">
|
||||
{preview.changes.map((change) => (
|
||||
<section
|
||||
key={change.key}
|
||||
className="min-w-0 rounded-lg border p-3"
|
||||
>
|
||||
<h3 className="mb-2 text-sm font-semibold">
|
||||
{t.has(`fields.${change.key}`)
|
||||
? t(`fields.${change.key}`)
|
||||
: t("field")}
|
||||
</h3>
|
||||
<dl className="grid min-w-0 gap-3 sm:grid-cols-2">
|
||||
<div className="min-w-0">
|
||||
<dt className="mb-1 text-xs text-muted-foreground">
|
||||
{t("current")}
|
||||
</dt>
|
||||
<dd className="min-w-0">
|
||||
<pre className="max-h-52 overflow-y-auto whitespace-pre-wrap [overflow-wrap:anywhere] rounded-md bg-muted/40 p-2 text-xs">
|
||||
{String(change.current ?? "—")}
|
||||
</pre>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<dt className="mb-1 text-xs text-muted-foreground">
|
||||
{t("restore")}
|
||||
</dt>
|
||||
<dd className="min-w-0">
|
||||
<pre className="max-h-52 overflow-y-auto whitespace-pre-wrap [overflow-wrap:anywhere] rounded-md bg-muted/40 p-2 text-xs">
|
||||
{String(change.restore ?? "—")}
|
||||
</pre>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
disabled={
|
||||
busy !== null ||
|
||||
!preview.canRestore ||
|
||||
!preview.changes.length
|
||||
}
|
||||
onClick={restore}
|
||||
>
|
||||
{t("confirm")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import { MySqlDialect } from "drizzle-orm/mysql-core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", async () => ({ ...(await import("@/db/schema")), db: {} }));
|
||||
|
||||
import { historyChanges, sameSnapshot } from "./model";
|
||||
import {
|
||||
applyHistory,
|
||||
type HistoryTransaction,
|
||||
historySnapshot,
|
||||
} from "./server";
|
||||
|
||||
describe("history restore safety", () => {
|
||||
const before = { costCredits: 10, costPoints: 0, pointsType: 0 };
|
||||
const after = { costCredits: 20, costPoints: 0, pointsType: 0 };
|
||||
function transaction(current = after) {
|
||||
const writes: string[] = [];
|
||||
const audit = vi.fn();
|
||||
let snapshot = current;
|
||||
const tx = {
|
||||
execute: vi.fn(async (query: SQL) => {
|
||||
const text = new MySqlDialect().sqlToQuery(query).sql;
|
||||
if (text.startsWith("SELECT")) return [[snapshot]];
|
||||
writes.push(text);
|
||||
snapshot = before;
|
||||
return [{}];
|
||||
}),
|
||||
insert: () => ({ values: audit }),
|
||||
} as unknown as HistoryTransaction;
|
||||
return { tx, writes, audit };
|
||||
}
|
||||
it("compares all recorded fields and does not confuse null with empty", () => {
|
||||
expect(sameSnapshot({ a: null }, { a: "" })).toBe(false);
|
||||
expect(sameSnapshot(after, { ...after, extra: 0 })).toBe(false);
|
||||
expect(historyChanges(after, before)).toEqual([
|
||||
{ key: "costCredits", current: 20, restore: 10 },
|
||||
]);
|
||||
});
|
||||
it("rejects concurrent edits without any writes or audit", async () => {
|
||||
const { tx, writes, audit } = transaction({ ...after, costPoints: 5 });
|
||||
await expect(
|
||||
applyHistory(tx, { kind: "prices", targetId: 1, before, after }, 7),
|
||||
).rejects.toThrow("conflict");
|
||||
expect(writes).toEqual([]);
|
||||
expect(audit).not.toHaveBeenCalled();
|
||||
});
|
||||
it("restores only the price fields and audits in the same transaction", async () => {
|
||||
const { tx, writes, audit } = transaction();
|
||||
await applyHistory(tx, { kind: "prices", targetId: 1, before, after }, 7);
|
||||
expect(writes).toHaveLength(1);
|
||||
expect(writes[0]).not.toContain("limited_sells");
|
||||
expect(audit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 7,
|
||||
action: "history_restore",
|
||||
before: JSON.stringify(after),
|
||||
after: JSON.stringify(before),
|
||||
}),
|
||||
);
|
||||
});
|
||||
it("propagates audit failure so the caller transaction rolls back", async () => {
|
||||
const { tx, audit } = transaction();
|
||||
audit.mockRejectedValue(Error("audit unavailable"));
|
||||
await expect(
|
||||
applyHistory(tx, { kind: "prices", targetId: 1, before, after }, 7),
|
||||
).rejects.toThrow("audit unavailable");
|
||||
});
|
||||
it("rejects a second restore of the same revision", async () => {
|
||||
const { tx, writes, audit } = transaction();
|
||||
const entry = { kind: "prices" as const, targetId: 1, before, after };
|
||||
await applyHistory(tx, entry, 7);
|
||||
await expect(applyHistory(tx, entry, 7)).rejects.toThrow("conflict");
|
||||
expect(writes).toHaveLength(1);
|
||||
expect(audit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("reads only columns present in the smaller builder club schema", async () => {
|
||||
const { tx } = transaction();
|
||||
const snapshot = await historySnapshot(tx, "category_bc", 1);
|
||||
expect(snapshot).not.toHaveProperty("captionSave");
|
||||
expect(snapshot).not.toHaveProperty("includes");
|
||||
expect(snapshot).toHaveProperty("caption");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
export type HistorySnapshot = Record<string, string | number | null>;
|
||||
export function sameSnapshot(a: HistorySnapshot, b: HistorySnapshot): boolean {
|
||||
const keys = Object.keys(a);
|
||||
return (
|
||||
keys.length === Object.keys(b).length &&
|
||||
keys.every((key) => Object.hasOwn(b, key) && a[key] === b[key])
|
||||
);
|
||||
}
|
||||
export function historyChanges(
|
||||
current: HistorySnapshot,
|
||||
restore: HistorySnapshot,
|
||||
) {
|
||||
return Object.keys(restore)
|
||||
.filter((key) => current[key] !== restore[key])
|
||||
.map((key) => ({ key, current: current[key], restore: restore[key] }));
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import "server-only";
|
||||
import { eq, getTableColumns, sql } from "drizzle-orm";
|
||||
import {
|
||||
AdminAuditLog,
|
||||
CatalogItems,
|
||||
CatalogPages,
|
||||
CatalogPagesBc,
|
||||
WebsiteArticles,
|
||||
} from "@/db/schema";
|
||||
import type { db } from "@/lib/db";
|
||||
import { type HistorySnapshot, sameSnapshot } from "./model";
|
||||
export type HistoryKind = "category" | "category_bc" | "prices" | "news";
|
||||
export type HistoryTransaction = Parameters<
|
||||
Parameters<typeof db.transaction>[0]
|
||||
>[0];
|
||||
const categoryFields = [
|
||||
"caption",
|
||||
"captionSave",
|
||||
"pageLayout",
|
||||
"enabled",
|
||||
"visible",
|
||||
"clubOnly",
|
||||
"vipOnly",
|
||||
"minRank",
|
||||
"iconImage",
|
||||
"iconColor",
|
||||
"pageHeadline",
|
||||
"pageTeaser",
|
||||
"pageSpecial",
|
||||
"pageText1",
|
||||
"pageText2",
|
||||
"pageTextDetails",
|
||||
"pageTextTeaser",
|
||||
"includes",
|
||||
];
|
||||
const configs = {
|
||||
category: { table: CatalogPages, fields: categoryFields },
|
||||
category_bc: { table: CatalogPagesBc, fields: categoryFields },
|
||||
prices: {
|
||||
table: CatalogItems,
|
||||
fields: ["costCredits", "costPoints", "pointsType"],
|
||||
},
|
||||
news: {
|
||||
table: WebsiteArticles,
|
||||
fields: [
|
||||
"title",
|
||||
"slug",
|
||||
"image",
|
||||
"shortStory",
|
||||
"fullStory",
|
||||
"status",
|
||||
"publishAt",
|
||||
"publishedAt",
|
||||
],
|
||||
},
|
||||
};
|
||||
export function historyConfig(kind: string) {
|
||||
if (!Object.hasOwn(configs, kind)) throw Error("unavailable");
|
||||
const config = configs[kind as HistoryKind];
|
||||
const columns = getTableColumns(config.table);
|
||||
return {
|
||||
...config,
|
||||
fields: config.fields.filter((key) => Object.hasOwn(columns, key)),
|
||||
};
|
||||
}
|
||||
export async function historySnapshot(
|
||||
tx: HistoryTransaction,
|
||||
kind: HistoryKind,
|
||||
id: number | string,
|
||||
): Promise<HistorySnapshot> {
|
||||
const { table, fields } = historyConfig(kind);
|
||||
const columns = getTableColumns(table);
|
||||
const selected = fields.map(
|
||||
(key) =>
|
||||
sql`${sql.identifier(columns[key as keyof typeof columns].name)} AS ${sql.identifier(key)}`,
|
||||
);
|
||||
const [rows] = await tx.execute(
|
||||
sql`SELECT ${sql.join(selected, sql`, `)} FROM ${table} WHERE id=${id} FOR UPDATE`,
|
||||
);
|
||||
const row = (rows as unknown as Record<string, unknown>[])[0];
|
||||
if (!row) throw Error("unavailable");
|
||||
return Object.fromEntries(
|
||||
fields.map((key) => {
|
||||
const value = row[key];
|
||||
return [
|
||||
key,
|
||||
value instanceof Date
|
||||
? value.toISOString()
|
||||
: value == null
|
||||
? null
|
||||
: typeof value === "number"
|
||||
? value
|
||||
: String(value),
|
||||
];
|
||||
}),
|
||||
);
|
||||
}
|
||||
export async function recordHistory(
|
||||
tx: HistoryTransaction,
|
||||
kind: HistoryKind,
|
||||
id: number | string,
|
||||
userId: number,
|
||||
before: HistorySnapshot,
|
||||
action = "history_update",
|
||||
) {
|
||||
const after = await historySnapshot(tx, kind, id);
|
||||
if (sameSnapshot(before, after)) return;
|
||||
const serializedBefore = JSON.stringify(before);
|
||||
const serializedAfter = JSON.stringify(after);
|
||||
if (
|
||||
Buffer.byteLength(serializedBefore, "utf8") > 16_000_000 ||
|
||||
Buffer.byteLength(serializedAfter, "utf8") > 16_000_000
|
||||
)
|
||||
throw Error("History snapshot is too large");
|
||||
await tx.insert(AdminAuditLog).values({
|
||||
userId,
|
||||
action,
|
||||
target: kind,
|
||||
targetId: Number(id) <= 2147483647 ? Number(id) : null,
|
||||
details:
|
||||
Number(id) > 2147483647 ? JSON.stringify({ targetId: String(id) }) : null,
|
||||
before: serializedBefore,
|
||||
after: serializedAfter,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
export async function readHistory(tx: HistoryTransaction, id: number) {
|
||||
const [entry] = await tx
|
||||
.select()
|
||||
.from(AdminAuditLog)
|
||||
.where(eq(AdminAuditLog.id, id))
|
||||
.limit(1);
|
||||
if (!entry || !["history_update", "history_restore"].includes(entry.action))
|
||||
throw Error("unavailable");
|
||||
let targetId: number | string | null = entry.targetId;
|
||||
if (targetId === null && entry.target === "news") {
|
||||
const details = JSON.parse(entry.details ?? "null");
|
||||
if (
|
||||
typeof details?.targetId === "string" &&
|
||||
/^[1-9]\d{0,19}$/.test(details.targetId)
|
||||
)
|
||||
targetId = details.targetId;
|
||||
}
|
||||
if (targetId === null) throw Error("unavailable");
|
||||
const config = historyConfig(entry.target);
|
||||
const parse = (raw: string | null): HistorySnapshot => {
|
||||
const value: unknown = JSON.parse(raw ?? "null");
|
||||
if (!value || typeof value !== "object" || Array.isArray(value))
|
||||
throw Error("unavailable");
|
||||
const data = value as HistorySnapshot;
|
||||
if (
|
||||
Object.keys(data).length !== config.fields.length ||
|
||||
!config.fields.every(
|
||||
(key) =>
|
||||
Object.hasOwn(data, key) &&
|
||||
(data[key] === null ||
|
||||
typeof data[key] === "string" ||
|
||||
typeof data[key] === "number"),
|
||||
)
|
||||
)
|
||||
throw Error("unavailable");
|
||||
return data;
|
||||
};
|
||||
return {
|
||||
kind: entry.target as HistoryKind,
|
||||
targetId,
|
||||
before: parse(entry.before),
|
||||
after: parse(entry.after),
|
||||
};
|
||||
}
|
||||
export async function applyHistory(
|
||||
tx: HistoryTransaction,
|
||||
entry: Awaited<ReturnType<typeof readHistory>>,
|
||||
userId: number,
|
||||
) {
|
||||
const current = await historySnapshot(tx, entry.kind, entry.targetId);
|
||||
if (!sameSnapshot(current, entry.after)) throw Error("conflict");
|
||||
const { table } = historyConfig(entry.kind);
|
||||
const columns = getTableColumns(table);
|
||||
const assignments = Object.entries(entry.before).map(
|
||||
([key, value]) =>
|
||||
sql`${sql.identifier(columns[key as keyof typeof columns].name)}=${entry.kind === "news" && (key === "publishAt" || key === "publishedAt") && value ? new Date(String(value)) : value}`,
|
||||
);
|
||||
if (entry.kind === "news") assignments.push(sql`updated_at=${new Date()}`);
|
||||
await tx.execute(
|
||||
sql`UPDATE ${table} SET ${sql.join(assignments, sql`, `)} WHERE id=${entry.targetId}`,
|
||||
);
|
||||
await recordHistory(
|
||||
tx,
|
||||
entry.kind,
|
||||
entry.targetId,
|
||||
userId,
|
||||
current,
|
||||
"history_restore",
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export const operationsManifest = {
|
||||
descriptionKey: "pages.housekeeping.domains.operations.description",
|
||||
iconId: "inbox",
|
||||
previewHref: "/admin-next/operations",
|
||||
capability: anyCapability(PERMS.ADMIN_DASHBOARD),
|
||||
capability: anyCapability(PERMS.ADMIN_DASHBOARD, PERMS.ASSETS_IMPORT),
|
||||
routes: [],
|
||||
searchProviders: [],
|
||||
inboxSources: [],
|
||||
|
||||
@@ -605,15 +605,15 @@ describe("housekeeping foundation completion contracts", () => {
|
||||
expect(html).toContain(`>${sentinel}</button>`);
|
||||
});
|
||||
|
||||
it("validates the complete 139-row migration matrix without issues", () => {
|
||||
it("validates the complete 141-row migration matrix without issues", () => {
|
||||
const discovered = discoverLegacyPages();
|
||||
const issues = validateMigrationEntries(
|
||||
discovered,
|
||||
HOUSEKEEPING_MIGRATION_MATRIX,
|
||||
);
|
||||
|
||||
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(139);
|
||||
expect(discovered).toHaveLength(139);
|
||||
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(141);
|
||||
expect(discovered).toHaveLength(141);
|
||||
expect(issues).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -76,7 +76,7 @@ const expectedManifests = [
|
||||
previewHref: "/admin-next/operations",
|
||||
labelKey: "pages.housekeeping.domains.operations.title",
|
||||
descriptionKey: "pages.housekeeping.domains.operations.description",
|
||||
slugs: [PERMS.ADMIN_DASHBOARD],
|
||||
slugs: [PERMS.ADMIN_DASHBOARD, PERMS.ASSETS_IMPORT],
|
||||
},
|
||||
{
|
||||
id: "people",
|
||||
|
||||
@@ -29,7 +29,7 @@ describe("discoverLegacyPages", () => {
|
||||
it("discovers the exact legacy administration inventory", () => {
|
||||
const pages = discoverLegacyPages();
|
||||
|
||||
expect(pages).toHaveLength(139);
|
||||
expect(pages).toHaveLength(141);
|
||||
expect(pages).toContainEqual({
|
||||
surface: "admin",
|
||||
legacyPath: "/admin/users/:id/edit",
|
||||
|
||||
@@ -4,10 +4,10 @@ import { HOUSEKEEPING_MIGRATION_MATRIX } from "./matrix";
|
||||
import { validateMigrationEntries } from "./validate-matrix";
|
||||
|
||||
describe("HOUSEKEEPING_MIGRATION_MATRIX", () => {
|
||||
it("covers all 139 legacy pages exactly once", () => {
|
||||
it("covers all 141 legacy pages exactly once", () => {
|
||||
const discovered = discoverLegacyPages();
|
||||
|
||||
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(139);
|
||||
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(141);
|
||||
expect(
|
||||
validateMigrationEntries(discovered, HOUSEKEEPING_MIGRATION_MATRIX),
|
||||
).toEqual([]);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { operationsMigrationEntries } from "./operations";
|
||||
|
||||
describe("operationsMigrationEntries", () => {
|
||||
it("covers the legacy dashboard once", () => {
|
||||
expect(operationsMigrationEntries).toHaveLength(1);
|
||||
expect(operationsMigrationEntries).toHaveLength(2);
|
||||
expect(operationsMigrationEntries[0]).toMatchObject({
|
||||
surface: "admin",
|
||||
legacyPath: "/admin",
|
||||
|
||||
@@ -24,4 +24,27 @@ export const operationsMigrationEntries: readonly MigrationEntry[] = [
|
||||
"Replace metric dashboard with capability-derived operational home",
|
||||
],
|
||||
},
|
||||
{
|
||||
surface: "admin",
|
||||
legacyPath: "/admin/operations",
|
||||
sourceFile: "src/app/admin/operations/page.tsx",
|
||||
targetDomain: "operations",
|
||||
targetPath: "/admin/work/jobs",
|
||||
decision: "REHOST",
|
||||
capabilities: {
|
||||
read: [PERMS.ASSETS_IMPORT],
|
||||
mutate: [PERMS.ASSETS_IMPORT],
|
||||
},
|
||||
dependencies: {
|
||||
queries: ["ImportJobStore", "catalogExportStatus"],
|
||||
mutations: ["retry furniture import", "cancel furniture import"],
|
||||
},
|
||||
auditRequirement: "MUTATION",
|
||||
localization: "PARTIAL",
|
||||
accessibility: "PARTIAL",
|
||||
requiredTests: ["unit", "integration", "e2e", "visual"],
|
||||
parityEvidence: [],
|
||||
status: "PLANNED",
|
||||
notes: ["Owner-scoped import history and shared export status"],
|
||||
},
|
||||
];
|
||||
@@ -53,6 +53,7 @@ describe("peopleMigrationEntries", () => {
|
||||
const expected = discoverLegacyPages().filter(
|
||||
(page) =>
|
||||
page.legacyPath === "/admin" ||
|
||||
page.legacyPath === "/admin/operations" ||
|
||||
PEOPLE_PREFIXES.some(
|
||||
(prefix) =>
|
||||
page.legacyPath === prefix ||
|
||||
|
||||
@@ -18,8 +18,8 @@ const SYSTEM_PREFIXES = [
|
||||
] as const;
|
||||
|
||||
describe("systemMigrationEntries", () => {
|
||||
it("covers all 20 System pages exactly once", () => {
|
||||
expect(systemMigrationEntries).toHaveLength(20);
|
||||
it("covers all 21 System pages exactly once", () => {
|
||||
expect(systemMigrationEntries).toHaveLength(21);
|
||||
expect(
|
||||
validateMigrationEntries(
|
||||
ownedLegacyPages(SYSTEM_PREFIXES),
|
||||
|
||||
@@ -21,6 +21,28 @@ function plannedSystemEntry(entry: PlannedSystemEntry): MigrationEntry {
|
||||
}
|
||||
|
||||
export const systemMigrationEntries: readonly MigrationEntry[] = [
|
||||
plannedSystemEntry({
|
||||
surface: "admin",
|
||||
legacyPath: "/admin/permissions/preview",
|
||||
sourceFile: "src/app/admin/permissions/preview/page.tsx",
|
||||
targetPath: "/admin/system/access/preview",
|
||||
decision: "REHOST",
|
||||
capabilities: { read: [PERMS.PERMISSIONS_MANAGE], mutate: [] },
|
||||
dependencies: {
|
||||
queries: [
|
||||
"acl_roles",
|
||||
"acl_permissions",
|
||||
"acl_model_permissions",
|
||||
"permission_ranks",
|
||||
"users highest rank",
|
||||
],
|
||||
mutations: [],
|
||||
},
|
||||
auditRequirement: "NONE",
|
||||
localization: "PARTIAL",
|
||||
accessibility: "PARTIAL",
|
||||
notes: ["Read-only role simulation; never changes sessions or grants"],
|
||||
}),
|
||||
plannedSystemEntry({
|
||||
surface: "admin",
|
||||
legacyPath: "/admin/devops/installation",
|
||||
|
||||
@@ -251,6 +251,12 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
|
||||
icon: LayoutDashboard,
|
||||
permission: PERMS.ADMIN_DASHBOARD,
|
||||
},
|
||||
{
|
||||
href: "/admin/operations",
|
||||
labelKey: "operationsCenter",
|
||||
icon: Activity,
|
||||
permission: PERMS.ASSETS_IMPORT,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -54,6 +54,9 @@ vi.mock("@/lib/db", () => {
|
||||
where() {
|
||||
return query;
|
||||
},
|
||||
orderBy() {
|
||||
return query;
|
||||
},
|
||||
async limit() {
|
||||
state.queried.push(table);
|
||||
return [{ id: 1, title: "Match", subtitle: "Data" }];
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AdminSearchRequest } from "./admin-search-request";
|
||||
|
||||
describe("admin search request ownership", () => {
|
||||
it("rejects a late response after the input changes, even before the next debounce fires", async () => {
|
||||
const requests = new AdminSearchRequest();
|
||||
const first = requests.start();
|
||||
const late = Promise.resolve().then(() => requests.accepts(first));
|
||||
requests.cancel();
|
||||
expect(await late).toBe(false);
|
||||
expect(first.signal.aborted).toBe(true);
|
||||
});
|
||||
it("accepts only the newest request and rejects completion after closing", () => {
|
||||
const requests = new AdminSearchRequest();
|
||||
const first = requests.start();
|
||||
const second = requests.start();
|
||||
expect(requests.accepts(first)).toBe(false);
|
||||
expect(requests.accepts(second)).toBe(true);
|
||||
requests.cancel();
|
||||
expect(requests.accepts(second)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
/** Owns the single request allowed to update a search dialog. */
|
||||
export class AdminSearchRequest {
|
||||
private current: AbortController | null = null;
|
||||
|
||||
cancel() {
|
||||
this.current?.abort();
|
||||
this.current = null;
|
||||
}
|
||||
|
||||
start() {
|
||||
this.cancel();
|
||||
const controller = new AbortController();
|
||||
this.current = controller;
|
||||
return controller;
|
||||
}
|
||||
|
||||
accepts(controller: AbortController) {
|
||||
return this.current === controller && !controller.signal.aborted;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { PERMS } from "../permission-slugs";
|
||||
import { previewRoleAccess } from "./permission-preview";
|
||||
|
||||
it("requires dashboard access before reporting a module usable", () => {
|
||||
const p = previewRoleAccess([PERMS.USERS_VIEW], false);
|
||||
expect(p.canEnter).toBe(false);
|
||||
expect(p.sections.find((s) => s.href === "/admin/users")?.allowed).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
it("shows grants and denies without combining another role", () => {
|
||||
const p = previewRoleAccess([PERMS.ADMIN_DASHBOARD, PERMS.USERS_VIEW], false);
|
||||
expect(p.sections.find((s) => s.href === "/admin/users")?.allowed).toBe(true);
|
||||
expect(p.actions.find((s) => s.slug === PERMS.USERS_EDIT)?.granted).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
it("applies the existing highest-rank override", () => {
|
||||
const p = previewRoleAccess([], true);
|
||||
expect(p.canEnter).toBe(true);
|
||||
expect(p.actions.every((a) => a.granted)).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ADMIN_NAV_GROUPS } from "../admin-nav";
|
||||
import { PERMS } from "../permission-slugs";
|
||||
/** Role-only simulation; never used to authorize a request. */
|
||||
export function previewRoleAccess(
|
||||
slugs: readonly string[],
|
||||
superAdmin: boolean,
|
||||
) {
|
||||
const grants = new Set(slugs);
|
||||
const has = (slug: string) => superAdmin || grants.has(slug);
|
||||
const canEnter = has(PERMS.ADMIN_DASHBOARD);
|
||||
return {
|
||||
canEnter,
|
||||
sections: ADMIN_NAV_GROUPS.flatMap((group) =>
|
||||
group.items.map((item) => ({
|
||||
href: item.href,
|
||||
labelKey: item.labelKey,
|
||||
required:
|
||||
typeof item.permission === "string"
|
||||
? [item.permission]
|
||||
: [...(item.permission ?? [])],
|
||||
allowed:
|
||||
canEnter &&
|
||||
(!item.permission ||
|
||||
(typeof item.permission === "string"
|
||||
? has(item.permission)
|
||||
: item.permission.some(has))),
|
||||
})),
|
||||
),
|
||||
actions: [...new Set(Object.values(PERMS))]
|
||||
.sort()
|
||||
.map((slug) => ({ slug, granted: has(slug) })),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { articleInputField } from "./article-input";
|
||||
|
||||
it("maps correctable article failures and keeps conflicts global", () => {
|
||||
expect(articleInputField("titleRequired")).toBe("title");
|
||||
expect(articleInputField("summaryTooLong")).toBe("shortStory");
|
||||
expect(articleInputField("publishDateRequired")).toBe("publishAt");
|
||||
expect(articleInputField("editConflict")).toBeUndefined();
|
||||
expect(articleInputField("articleNotFound")).toBeUndefined();
|
||||
});
|
||||
@@ -47,3 +47,18 @@ export function readArticleInput(form: FormData) {
|
||||
export function articleSlug(value: string | string[]): string {
|
||||
return Array.isArray(value) ? value.join("/") : value;
|
||||
}
|
||||
|
||||
/** Associate validation failures with the field the editor can correct. */
|
||||
export function articleInputField(
|
||||
code: ArticleInputError["code"],
|
||||
): string | undefined {
|
||||
const fields: Partial<Record<ArticleInputError["code"], string>> = {
|
||||
titleRequired: "title",
|
||||
titleTooLong: "title",
|
||||
summaryTooLong: "shortStory",
|
||||
imageTooLong: "image",
|
||||
invalidStatus: "status",
|
||||
publishDateRequired: "publishAt",
|
||||
};
|
||||
return fields[code];
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { delimiter, dirname, join, resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const root = process.cwd();
|
||||
const bash =
|
||||
process.platform === "win32"
|
||||
? ((process.env.PATH ?? "")
|
||||
.split(delimiter)
|
||||
.flatMap((dir) => [
|
||||
join(dir, "bash.exe"),
|
||||
join(dirname(dir), "bin", "bash.exe"),
|
||||
join(dirname(dirname(dir)), "bin", "bash.exe"),
|
||||
])
|
||||
.find((path) => existsSync(path)) ?? "bash")
|
||||
: "bash";
|
||||
function configure(
|
||||
input: string,
|
||||
existing?: string,
|
||||
profile?: string,
|
||||
start = false,
|
||||
) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cms-install-test-"));
|
||||
try {
|
||||
mkdirSync(join(dir, "scripts"));
|
||||
for (const name of ["docker-install.sh", "docker-config.sh"])
|
||||
copyFileSync(resolve(root, "scripts", name), join(dir, "scripts", name));
|
||||
writeFileSync(
|
||||
join(dir, "scripts/docker-update.sh"),
|
||||
"#!/usr/bin/env bash\nprintf 'Update invoked\\n'\n",
|
||||
);
|
||||
copyFileSync(
|
||||
resolve(root, "docker-image.txt"),
|
||||
join(dir, "docker-image.txt"),
|
||||
);
|
||||
if (existing !== undefined) writeFileSync(join(dir, ".env"), existing);
|
||||
if (profile !== undefined)
|
||||
writeFileSync(join(dir, ".docker-install"), profile);
|
||||
const result = spawnSync(
|
||||
bash,
|
||||
[
|
||||
join(dir, "scripts/docker-install.sh"),
|
||||
...(start ? [] : ["--configure-only"]),
|
||||
],
|
||||
{
|
||||
cwd: dir,
|
||||
input,
|
||||
encoding: "utf8",
|
||||
timeout: 15000,
|
||||
env: {
|
||||
...process.env,
|
||||
BASH_ENV: resolve(root, "src/test/docker-install-harness.sh"),
|
||||
CMS_PUBLIC_URL: undefined,
|
||||
CMS_IMAGE_REPOSITORY: undefined,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (result.error) throw result.error;
|
||||
return {
|
||||
status: result.status,
|
||||
output: result.stdout + result.stderr,
|
||||
env: existsSync(join(dir, ".env"))
|
||||
? readFileSync(join(dir, ".env"), "utf8")
|
||||
: "",
|
||||
profile: existsSync(join(dir, ".docker-install"))
|
||||
? readFileSync(join(dir, ".docker-install"), "utf8")
|
||||
: "",
|
||||
injected: existsSync(join(dir, "injected")),
|
||||
};
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
const answers = [
|
||||
"https://hotel.example",
|
||||
"prebuilt",
|
||||
"Test Hotel",
|
||||
"",
|
||||
"",
|
||||
"hotel",
|
||||
"cms",
|
||||
"p@ss'$ word",
|
||||
"",
|
||||
"https://imager.example/imaging",
|
||||
"",
|
||||
].join("\n");
|
||||
describe("guided Docker installation", () => {
|
||||
it("generates a unique secret and encodes database credentials without exposing them", () => {
|
||||
const result = configure(answers);
|
||||
expect(result.status, result.output).toBe(0);
|
||||
expect(result.env).toContain(
|
||||
"mysql://cms:p%40ss%27%24%[email protected]:3306/hotel",
|
||||
);
|
||||
const secret = result.env.match(/AUTH_SECRET='([a-f0-9]{64})'/)?.[1];
|
||||
expect(secret).toHaveLength(64);
|
||||
expect(result.output).not.toContain(secret);
|
||||
expect(result.output).not.toContain("p@ss");
|
||||
expect(result.profile).toBe(
|
||||
"MODE=prebuilt\nPUBLIC_URL=https://hotel.example\n",
|
||||
);
|
||||
expect(result.output).toContain("No container was started");
|
||||
});
|
||||
it("preserves an existing environment byte for byte", () => {
|
||||
const existing = "AUTH_SECRET=keep-me\nDATABASE_URL=existing\n";
|
||||
const result = configure("https://hotel.example\nsource\n", existing);
|
||||
expect(result.status, result.output).toBe(0);
|
||||
expect(result.env).toBe(existing);
|
||||
expect(result.profile).toContain("MODE=source");
|
||||
});
|
||||
it("does not create configuration when input is cancelled", () => {
|
||||
const result = configure("https://hotel.example\nprebuilt\n");
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.env).toBe("");
|
||||
expect(result.profile).toBe("");
|
||||
});
|
||||
it("rejects unsafe dotenv values", () => {
|
||||
const result = configure(answers.replace("Test Hotel", "Hotel '$BAD"));
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.env).toBe("");
|
||||
});
|
||||
it("treats saved settings as data, never shell commands", () => {
|
||||
const result = configure(
|
||||
"",
|
||||
"original",
|
||||
"MODE=$(touch injected)\nPUBLIC_URL=https://hotel.example\n",
|
||||
);
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.env).toBe("original");
|
||||
expect(result.injected).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("hands a completed installation to the existing updater", () => {
|
||||
const result = configure(answers, undefined, undefined, true);
|
||||
expect(result.status, result.output).toBe(0);
|
||||
expect(result.output).toContain("Update invoked");
|
||||
expect(result.profile).toContain("MODE=prebuilt");
|
||||
});
|
||||
@@ -30,6 +30,14 @@ function simulate(scenario: string) {
|
||||
try {
|
||||
mkdirSync(join(dir, "scripts"));
|
||||
mkdirSync(join(dir, "logs"));
|
||||
copyFileSync(
|
||||
resolve(root, "scripts/docker-config.sh"),
|
||||
join(dir, "scripts/docker-config.sh"),
|
||||
);
|
||||
copyFileSync(
|
||||
resolve(root, "docker-image.txt"),
|
||||
join(dir, "docker-image.txt"),
|
||||
);
|
||||
writeFileSync(
|
||||
join(dir, "logs/docker-release-history.log"),
|
||||
`${"b".repeat(40)}\n${"c".repeat(40)}\n`,
|
||||
@@ -39,6 +47,11 @@ function simulate(scenario: string) {
|
||||
join(dir, "scripts/docker-update.sh"),
|
||||
);
|
||||
writeFileSync(join(dir, ".env"), "HOTEL_NAME=Test\n");
|
||||
if (scenario.startsWith("saved-"))
|
||||
writeFileSync(
|
||||
join(dir, ".docker-install"),
|
||||
`MODE=${scenario === "saved-source" ? "source" : "prebuilt"}\nPUBLIC_URL=https://saved.test\n`,
|
||||
);
|
||||
const result = spawnSync(bash, [join(dir, "scripts/docker-update.sh")], {
|
||||
cwd: dir,
|
||||
encoding: "utf8",
|
||||
@@ -49,10 +62,14 @@ function simulate(scenario: string) {
|
||||
TEST_DIR: dir.replaceAll("\\", "/"),
|
||||
TEST_SHA: sha,
|
||||
SCENARIO: scenario,
|
||||
CMS_PUBLIC_URL: "https://example.test",
|
||||
CMS_IMAGE_REPOSITORY: scenario.startsWith("registry")
|
||||
? "registry.test/team/cms"
|
||||
: "",
|
||||
CMS_PUBLIC_URL: scenario.startsWith("saved-")
|
||||
? undefined
|
||||
: "https://example.test",
|
||||
CMS_IMAGE_REPOSITORY: scenario.startsWith("saved-")
|
||||
? undefined
|
||||
: scenario.startsWith("registry")
|
||||
? "registry.test/team/cms"
|
||||
: "",
|
||||
},
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
@@ -169,3 +186,19 @@ describe("HTTP release verification", () => {
|
||||
expect(result.status, result.stderr).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it("updates using the saved profile and repository-provided image without arguments", () => {
|
||||
const r = simulate("saved-prebuilt");
|
||||
expect(r.status, r.output).toBe(0);
|
||||
expect(r.calls).toContain(
|
||||
`docker pull gitlab.epicnabbo.nl/simo/epicnext-cms:${sha}`,
|
||||
);
|
||||
expect(r.calls).toContain("https://saved.test/api/health");
|
||||
expect(r.calls).not.toContain("docker compose build");
|
||||
});
|
||||
it("keeps source builds available for a saved source installation", () => {
|
||||
const r = simulate("saved-source");
|
||||
expect(r.status, r.output).toBe(0);
|
||||
expect(r.calls).toContain("docker compose build");
|
||||
expect(r.calls).not.toContain("docker pull");
|
||||
});
|
||||
Loaded 100 of 152 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user