Compare commits
87
Commits
v1.0.2
..
dd46094256
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd46094256 | ||
|
|
9d1c71d926 | ||
|
|
744e224fd0 | ||
|
|
a2acac2c4c | ||
|
|
0224b34f15 | ||
|
|
1127807aaa | ||
|
|
3ac5d6f4f6 | ||
|
|
3e584aadaf | ||
|
|
bfbc02c75d | ||
|
|
98613af875 | ||
|
|
7138ae4445 | ||
|
|
3ad3f9512c | ||
|
|
fe46bd0544 | ||
|
|
822dfd6a1c | ||
|
|
525f58cd24 | ||
|
|
d805e54053 | ||
|
|
583d05eee9 | ||
|
|
58fae1f90f | ||
|
|
474de0717b | ||
|
|
ed5b9a6f7c | ||
|
|
6eab5e5343 | ||
|
|
686279eb25 | ||
|
|
c0a2c9db5e | ||
|
|
d8bb117114 | ||
|
|
63ad651b9b | ||
|
|
b03dbb295f | ||
|
|
4b2d893905 | ||
|
|
e07da3d052 | ||
|
|
10932a8799 | ||
|
|
4ec75c2c1d | ||
|
|
1e3b7bc31d | ||
|
|
ea7d861e69 | ||
|
|
c433e5a52f | ||
|
|
540bce911f | ||
|
|
749dc237f1 | ||
|
|
8c193936f6 | ||
|
|
d3068ce88b | ||
|
|
340ecb42c8 | ||
|
|
39b211084d | ||
|
|
22162fa8b9 | ||
|
|
ae2b9328b9 | ||
|
|
84f64b6615 | ||
|
|
91357aca3b | ||
|
|
cc95a0d690 | ||
|
|
da390e447f | ||
|
|
4bd717d627 | ||
|
|
1311d36933 | ||
|
|
ded8fa62af | ||
|
|
3bf0644a9a | ||
|
|
d87c4284fe | ||
|
|
9cdb0b85fb | ||
|
|
7cdb785218 | ||
|
|
7f58e428ed | ||
|
|
a8d86a10bc | ||
|
|
b926ffa93c | ||
|
|
65fae257ba | ||
|
|
22a9fc13f7 | ||
|
|
3b853efba0 | ||
|
|
72079050f4 | ||
|
|
e08e366266 | ||
|
|
1e661a2b41 | ||
|
|
a637d09ca5 | ||
|
|
15eeab8a59 | ||
|
|
54fa1aecdd | ||
|
|
5140784dc7 | ||
|
|
41e41f0d22 | ||
|
|
46db76219f | ||
|
|
5b9e2166df | ||
|
|
738d7b8223 | ||
|
|
ab63de000b | ||
|
|
3532972357 | ||
|
|
e644362d09 | ||
|
|
b6b8625246 | ||
|
|
01126207dc | ||
|
|
9177230dc2 | ||
|
|
db39fb335c | ||
|
|
9ea67ecf72 | ||
|
|
15a76ffe84 | ||
|
|
9c0b339736 | ||
|
|
7384041bb6 | ||
|
|
a72646c933 | ||
|
|
a513d9b7bd | ||
|
|
3827f3e686 | ||
|
|
e408fdd5e6 | ||
|
|
78fab3c9ac | ||
|
|
e69c2fbb04 | ||
|
|
2e2aba11af |
No files matched your search
+45
-71
@@ -1,95 +1,69 @@
|
|||||||
# Connection to the LIVE/COPY emulator MySQL/MariaDB database.
|
# ==============================================================================
|
||||||
# The schema is owned by the Arcturus emulator — this app reads/writes data,
|
# Epicnextcms — Ultimate Speed & Low-Latency Example Configuration
|
||||||
# it does NOT own or migrate the emulator tables. Format:
|
# ==============================================================================
|
||||||
DATABASE_URL=mysql://user:[email protected]:3306/atomcms
|
|
||||||
|
|
||||||
# Optional pool tuning (defaults shown)
|
# --- DATABASE (High Performance Pooling & Strict Timeouts) ---
|
||||||
DATABASE_POOL_SIZE=10
|
DATABASE_URL="mysql://user:password@localhost:3306/dbname?charset=utf8mb4&connection_limit=150&connect_timeout=5"
|
||||||
DATABASE_IDLE_TIMEOUT_MS=300000
|
DATABASE_POOL_SIZE=150
|
||||||
DATABASE_CONNECT_TIMEOUT_MS=10000
|
DATABASE_IDLE_TIMEOUT_MS=60000
|
||||||
|
DATABASE_CONNECT_TIMEOUT_MS=5000
|
||||||
|
|
||||||
# Redis for rate limits, site-settings cache, and JWT invalidation
|
# --- REDIS (Lightning Fast Caching & Sessions) ---
|
||||||
# REDIS_URL=redis://localhost:6379
|
REDIS_URL=redis://127.0.0.1:6379?connect_timeout=2
|
||||||
|
REDIS_CACHE_TTL_DEFAULT=7200
|
||||||
|
|
||||||
# Used by SSO ticket generation ({HOTEL_NAME}-{uuid})
|
# --- CORE RUNTIME & PERFORMANCE FLAGS ---
|
||||||
HOTEL_NAME=Atom
|
NODE_ENV=production
|
||||||
APP_URL=http://localhost:3000
|
PORT=3002
|
||||||
# NEXT_PUBLIC_APP_URL=https://yourdomain.com
|
NEXT_TELEMETRY_DISABLED=1
|
||||||
AUTH_URL=http://localhost:3000
|
UV_THREADPOOL_SIZE=16
|
||||||
|
|
||||||
# NextAuth — required in production (>=32 chars). Optional in development.
|
# --- HOTEL & URLS ---
|
||||||
# Laravel APP_KEY (base64:...) for existing 2FA secrets.
|
HOTEL_NAME=EPIC WEB CONTROL
|
||||||
AUTH_SECRET=
|
APP_URL=http://localhost:3002
|
||||||
APP_KEY=
|
NEXT_PUBLIC_APP_URL=http://localhost:3002
|
||||||
CONVERT_PASSWORDS=false
|
AUTH_URL=http://localhost:3002
|
||||||
|
|
||||||
# Password hashing for NEW/upgraded passwords: "bcrypt" (default; 60-char $2y$,
|
# --- IMAGER ---
|
||||||
# fits a varchar(64) users.password) or "argon2id" (~97 chars, needs a wider
|
IMAGING_UPSTREAM_URL=http://127.0.0.1:3030/imaging
|
||||||
# column). Existing accounts in either format still verify on login.
|
NEXT_PUBLIC_IMAGER_URL=http://localhost:3002/imaging
|
||||||
PASSWORD_HASH=bcrypt
|
|
||||||
|
|
||||||
# Filesystem directory the badge uploader (/admin/badges) writes <code>.gif into
|
# --- SECURITY & HASHING ---
|
||||||
# — the emulator's badge image folder (e.g. .../assets/c_images/album1584).
|
AUTH_SECRET=your-super-secret-auth-key-change-this-min-32-chars
|
||||||
# Leave unset to disable badge uploads.
|
APP_KEY=base64:your-app-key-here=
|
||||||
BADGE_UPLOAD_DIR=
|
CONVERT_PASSWORDS=true
|
||||||
|
BCRYPT_ROUNDS=12
|
||||||
|
|
||||||
# Emulator JAR backup job (jobs-worker, runs host-side). When both are set, the
|
# --- PATHS ---
|
||||||
# worker copies the JAR daily into the backup dir, keeping the newest N.
|
BADGE_UPLOAD_DIR=./public/assets/images/badges
|
||||||
EMULATOR_JAR_PATH=
|
EMULATOR_JAR_PATH=./emulator/Arcturus.jar
|
||||||
EMULATOR_BACKUP_DIR=
|
EMULATOR_BACKUP_DIR=./backups/emulator
|
||||||
EMULATOR_BACKUP_KEEP=7
|
EMULATOR_BACKUP_KEEP=7
|
||||||
|
|
||||||
# RCON link to the Arcturus emulator
|
# --- RCON (Low Latency Loop) ---
|
||||||
RCON_HOST=127.0.0.1
|
RCON_HOST=127.0.0.1
|
||||||
RCON_PORT=3001
|
RCON_PORT=3003
|
||||||
|
EMU_PORT=3004
|
||||||
|
RCON_TIMEOUT_MS=2000
|
||||||
|
|
||||||
# Public imager URL — overrides the default /imaging relative path.
|
# --- EMAIL & NOTIFICATIONS ---
|
||||||
# Falls back to NEXT_PUBLIC_APP_URL/imaging when only the app URL is set.
|
|
||||||
# NEXT_PUBLIC_IMAGER_URL=https://epicnabbo.nl/imaging
|
|
||||||
|
|
||||||
# Preferred email provider (HTTP API). Used before SMTP when set.
|
|
||||||
RESEND_API_KEY=
|
|
||||||
|
|
||||||
# Optional SMTP fallback (password reset / alert emails)
|
|
||||||
SMTP_HOST=
|
SMTP_HOST=
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
SMTP_USER=
|
SMTP_USER=
|
||||||
SMTP_PASSWORD=
|
SMTP_PASSWORD=
|
||||||
SMTP_FROM=
|
SMTP_FROM=[email protected]
|
||||||
|
|
||||||
# Optional alerting (jobs worker / alert service)
|
# --- ALERTING & MONITORING ---
|
||||||
DISCORD_WEBHOOK_URL=
|
DISCORD_WEBHOOK_URL=
|
||||||
ALERT_EMAIL=
|
ALERT_EMAIL=
|
||||||
|
|
||||||
# Optional AI content moderation (user comments / guestbook).
|
# --- MODERATION & PAYMENTS ---
|
||||||
# When set, posts are checked against the OpenAI Moderations endpoint in
|
|
||||||
# addition to the website_wordfilter blocklist. Fail-open if unset/erroring.
|
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
|
|
||||||
# Optional PayPal top-up (sandbox by default)
|
|
||||||
PAYPAL_CLIENT_ID=
|
PAYPAL_CLIENT_ID=
|
||||||
PAYPAL_SECRET=
|
PAYPAL_SECRET=
|
||||||
PAYPAL_API=https://api-m.sandbox.paypal.com
|
PAYPAL_API=https://api-m.sandbox.paypal.com
|
||||||
|
|
||||||
# Redis — REQUIRED for production (shared rate limits, site-settings cache,
|
# --- LOGGING & SENTRY (Zod-Proof Dummy URLs) ---
|
||||||
# JWT session invalidation cache). Without REDIS_URL the app falls back to
|
LOG_LEVEL=error
|
||||||
# in-process memory: limits reset on restart and do not work across instances.
|
SENTRY_DSN=https://[email protected]/0
|
||||||
# Deploy logs a loud warning when this is unset in production.
|
NEXT_PUBLIC_SENTRY_DSN=https://[email protected]/0
|
||||||
REDIS_URL=redis://127.0.0.1:6379
|
|
||||||
|
|
||||||
# Logging level (debug | info | warn | error). Defaults to 'info' in production,
|
|
||||||
# 'debug' in development. Production logs use structured JSON via pino.
|
|
||||||
LOG_LEVEL=info
|
|
||||||
|
|
||||||
# Optional Sentry error monitoring (no-op when unset).
|
|
||||||
# Server/edge use SENTRY_DSN; browser uses NEXT_PUBLIC_SENTRY_DSN.
|
|
||||||
SENTRY_DSN=
|
|
||||||
NEXT_PUBLIC_SENTRY_DSN=
|
|
||||||
# Optional source-map upload during CI builds (requires SENTRY_AUTH_TOKEN).
|
|
||||||
SENTRY_ORG=
|
|
||||||
SENTRY_PROJECT=
|
|
||||||
SENTRY_AUTH_TOKEN=
|
|
||||||
# Optional release tag shown in Sentry (e.g. git sha).
|
|
||||||
# Deploy sets APP_VERSION + NEXT_PUBLIC_APP_VERSION from git sha.
|
|
||||||
APP_VERSION=
|
|
||||||
NEXT_PUBLIC_APP_VERSION=
|
|
||||||
+482
-2
@@ -1,11 +1,18 @@
|
|||||||
name: CI
|
name: CI
|
||||||
on:
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check:
|
check:
|
||||||
|
if: startsWith(gitea.ref_name, 'v') == false
|
||||||
runs-on: shell
|
runs-on: shell
|
||||||
steps:
|
steps:
|
||||||
- name: Typecheck, lint, and test
|
- name: Typecheck, lint, and test
|
||||||
@@ -30,8 +37,10 @@ jobs:
|
|||||||
git checkout -f "${REF}"
|
git checkout -f "${REF}"
|
||||||
|
|
||||||
export SKIP_ENV_VALIDATION=1
|
export SKIP_ENV_VALIDATION=1
|
||||||
export ARGON2_MEMORY_SIZE=1024
|
export NODE_ENV=test
|
||||||
export ARGON2_ITERATIONS=1
|
export DATABASE_URL="mysql://test:test@localhost:3306/test?charset=utf8mb4"
|
||||||
|
export AUTH_SECRET="ci-test-secret-key-that-is-long-enough"
|
||||||
|
export REDIS_URL="redis://127.0.0.1:6379?connect_timeout=1"
|
||||||
export BCRYPT_ROUNDS=4
|
export BCRYPT_ROUNDS=4
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
pnpm prisma:generate
|
pnpm prisma:generate
|
||||||
@@ -39,3 +48,474 @@ jobs:
|
|||||||
pnpm typecheck
|
pnpm typecheck
|
||||||
pnpm test
|
pnpm test
|
||||||
echo "--- CI checks passed ---"
|
echo "--- CI checks passed ---"
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: check
|
||||||
|
if: gitea.event_name == 'push' && gitea.ref_name == 'main'
|
||||||
|
runs-on: shell
|
||||||
|
steps:
|
||||||
|
- name: Deploy
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
|
||||||
|
exec 9>/var/tmp/epic_web_control_deploy.lock
|
||||||
|
flock -n 9 || { echo "ERROR: Another deployment is already running! Cancelling."; exit 1; }
|
||||||
|
|
||||||
|
echo "--- Deploying ---"
|
||||||
|
|
||||||
|
LIVE="/var/www/atom-nexst"
|
||||||
|
STAGE=""
|
||||||
|
CUTOVER_STARTED=0
|
||||||
|
|
||||||
|
error_handler() {
|
||||||
|
cd /var/www/atom-nexst 2>/dev/null || cd / || true
|
||||||
|
echo "!!! DEPLOYMENT FAILED on line $1 !!!" >&2
|
||||||
|
# Roll back the build artifact if cutover already moved .next into place.
|
||||||
|
if [ "${CUTOVER_STARTED}" = "1" ] && [ -d "${LIVE}/.next.prev" ]; then
|
||||||
|
echo "Rolling back .next to previous artifact..." >&2
|
||||||
|
rm -rf "${LIVE}/.next" || true
|
||||||
|
mv "${LIVE}/.next.prev" "${LIVE}/.next" || true
|
||||||
|
fi
|
||||||
|
if [ -n "${STAGE}" ] && [ -d "${STAGE}" ]; then
|
||||||
|
git -C "${LIVE}" worktree remove --force "${STAGE}" 2>/dev/null || rm -rf "${STAGE}" || true
|
||||||
|
fi
|
||||||
|
pm2 restart next --update-env 2>/dev/null || pm2 start pnpm --name "next" -- start 2>/dev/null || true
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
trap 'error_handler $LINENO' ERR
|
||||||
|
|
||||||
|
docker image prune -f
|
||||||
|
|
||||||
|
DEPLOY_USER="$(id -un)"
|
||||||
|
DEPLOY_GROUP="$(id -gn)"
|
||||||
|
sudo chown -R "${DEPLOY_USER}:${DEPLOY_GROUP}" "${LIVE}" 2>/dev/null || true
|
||||||
|
git config --global --add safe.directory "${LIVE}"
|
||||||
|
git -C "${LIVE}" remote set-url origin /docker/gitea/gitea/git/repositories/remco/epicnext-cms.git/
|
||||||
|
|
||||||
|
echo "Fetching origin/main..."
|
||||||
|
git -C "${LIVE}" fetch origin --prune
|
||||||
|
|
||||||
|
echo "Clearing sticky git index bits (if any)..."
|
||||||
|
STICKY_LIST="$(git -C "${LIVE}" ls-files -v | awk '/^[a-zS]/ {print substr($0,3)}' || true)"
|
||||||
|
if [ -n "${STICKY_LIST}" ]; then
|
||||||
|
echo "${STICKY_LIST}" | while IFS= read -r f; do
|
||||||
|
[ -n "$f" ] || continue
|
||||||
|
git -C "${LIVE}" update-index --no-skip-worktree --no-assume-unchanged -- "$f" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
export APP_VERSION="$(git -C "${LIVE}" rev-parse --short origin/main)"
|
||||||
|
export NEXT_PUBLIC_APP_VERSION="${APP_VERSION}"
|
||||||
|
echo "APP_VERSION=${APP_VERSION}"
|
||||||
|
|
||||||
|
STAGE="/var/tmp/atom-nexst-stage-${APP_VERSION}"
|
||||||
|
echo "Preparing stage worktree at ${STAGE} (live site stays up)..."
|
||||||
|
git -C "${LIVE}" worktree remove --force "${STAGE}" 2>/dev/null || rm -rf "${STAGE}" || true
|
||||||
|
git -C "${LIVE}" worktree add --detach "${STAGE}" origin/main
|
||||||
|
|
||||||
|
# Production env stays on the live tree; stage only needs a symlink for build/migrate.
|
||||||
|
ln -sfn "${LIVE}/.env" "${STAGE}/.env"
|
||||||
|
|
||||||
|
if ! grep -qE '^[[:space:]]*REDIS_URL=.+' "${LIVE}/.env" 2>/dev/null; then
|
||||||
|
echo "WARNING: REDIS_URL is unset in ${LIVE}/.env" >&2
|
||||||
|
echo "WARNING: Rate limits, site-settings cache, and JWT invalidation cache will be in-process only." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "${STAGE}"
|
||||||
|
rm -f tsconfig.tsbuildinfo .tsbuildinfo
|
||||||
|
find . -maxdepth 3 -name '*.tsbuildinfo' -delete 2>/dev/null || true
|
||||||
|
rm -rf .output dist .next .next/types .next/dev
|
||||||
|
|
||||||
|
# Restore build cache from last deploy so Turbopack can do
|
||||||
|
# incremental compilation (much faster rebuilds).
|
||||||
|
if [ -d "${LIVE}/.next/cache" ]; then
|
||||||
|
mkdir -p .next/cache
|
||||||
|
cp -r "${LIVE}/.next/cache/." .next/cache/
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Stage shares MySQL with the live app + emulator. Keep the stage pool
|
||||||
|
# tiny so install/test/build cannot exhaust max_connections.
|
||||||
|
export DATABASE_POOL_SIZE="${DEPLOY_DATABASE_POOL_SIZE:-5}"
|
||||||
|
echo "STAGE DATABASE_POOL_SIZE=${DATABASE_POOL_SIZE}"
|
||||||
|
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
# prisma generate only needs DATABASE_URL to resolve the schema — no live DB connection.
|
||||||
|
# Use a placeholder so it doesn't fail if .env isn't loaded yet; db:migrate later
|
||||||
|
# will use the real DATABASE_URL from the live .env symlink.
|
||||||
|
export DATABASE_URL="mysql://placeholder:please@localhost/placeholder"
|
||||||
|
pnpm prisma:generate
|
||||||
|
unset DATABASE_URL
|
||||||
|
export BCRYPT_ROUNDS=4
|
||||||
|
pnpm typecheck
|
||||||
|
# Validate production env (AUTH_SECRET, DATABASE_URL, …) during build.
|
||||||
|
# Do not set SKIP_ENV_VALIDATION here — that flag is for tests/tooling only.
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
if [ ! -d "${STAGE}/.next" ]; then
|
||||||
|
echo "ERROR: stage build produced no .next/" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Cutover: stop service (free DB connections), migrate, swap .next..."
|
||||||
|
CUTOVER_STARTED=1
|
||||||
|
pm2 stop next --kill-timeout 10000 || true
|
||||||
|
# Wait for PM2 to fully exit and MariaDB to reclaim connections.
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
# Migrate only after live is stopped — avoids ER_CON_COUNT_ERROR while
|
||||||
|
# the old process still holds DATABASE_POOL_SIZE connections.
|
||||||
|
cd "${STAGE}"
|
||||||
|
MIGRATE_OK=0
|
||||||
|
for i in $(seq 1 10); do
|
||||||
|
if pnpm db:migrate; then
|
||||||
|
MIGRATE_OK=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "migrate attempt ${i}/10 failed (likely DB connections), retrying..."
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
if [ "${MIGRATE_OK}" != "1" ]; then
|
||||||
|
echo "ERROR: db:migrate failed after retries" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "${LIVE}"
|
||||||
|
echo "Hard reset live tree to origin/main (no nuclear src wipe)..."
|
||||||
|
git reset --hard origin/main
|
||||||
|
# Keep env, uploads, and deps we are about to replace from stage.
|
||||||
|
git clean -fd \
|
||||||
|
-e .env -e .env.local -e .env.production -e .env*.local \
|
||||||
|
-e storage -e public/cache -e node_modules -e .next -e .next.prev
|
||||||
|
|
||||||
|
if ! git diff --exit-code HEAD -- src >/dev/null; then
|
||||||
|
echo "ERROR: live src/ still differs from HEAD after reset:" >&2
|
||||||
|
git diff --stat HEAD -- src >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Verified live src/ matches HEAD"
|
||||||
|
|
||||||
|
# Save current .next as backup before swapping (kept until health check passes).
|
||||||
|
if [ -d .next ]; then
|
||||||
|
mv .next .next.prev
|
||||||
|
fi
|
||||||
|
mv "${STAGE}/.next" .next
|
||||||
|
|
||||||
|
# Use the exact node_modules the stage build resolved against.
|
||||||
|
rm -rf node_modules
|
||||||
|
mv "${STAGE}/node_modules" node_modules
|
||||||
|
|
||||||
|
# Prisma client is gitignored — regenerate into live src/generated.
|
||||||
|
pnpm prisma:generate
|
||||||
|
|
||||||
|
sudo chown -R "${DEPLOY_USER}:${DEPLOY_GROUP}" "${LIVE}" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Next.js prefers an already-set process PORT over .env. PM2 may still
|
||||||
|
# have PORT=3000 from an older start, while .env (and nginx) expect 3002.
|
||||||
|
# Export PORT before start so the process matches health checks.
|
||||||
|
DEPLOY_PORT="$(grep -E '^[[:space:]]*PORT=' "${LIVE}/.env" 2>/dev/null | tail -1 | cut -d= -f2- || true)"
|
||||||
|
DEPLOY_PORT="$(printf '%s' "${DEPLOY_PORT}" | tr -cd '0-9')"
|
||||||
|
DEPLOY_PORT="${DEPLOY_PORT:-3002}"
|
||||||
|
export PORT="${DEPLOY_PORT}"
|
||||||
|
echo "PM2/health PORT=${PORT}"
|
||||||
|
|
||||||
|
free_tcp_port() {
|
||||||
|
local port="$1"
|
||||||
|
[ -n "${port}" ] || return 0
|
||||||
|
if command -v fuser >/dev/null 2>&1; then
|
||||||
|
fuser -k "${port}/tcp" 2>/dev/null || true
|
||||||
|
elif command -v lsof >/dev/null 2>&1; then
|
||||||
|
# Portable fallback when fuser is unavailable.
|
||||||
|
lsof -tiTCP:"${port}" -sTCP:LISTEN 2>/dev/null | xargs -r kill -9 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Starting PM2 with a clean PORT=${PORT} listener..."
|
||||||
|
cd "${LIVE}"
|
||||||
|
# Cutover already stopped the app, but failed deploys can leave orphans
|
||||||
|
# on 3002 (or an old PM2 env still bound to 3000).
|
||||||
|
pm2 stop next --kill-timeout 10000 2>/dev/null || true
|
||||||
|
free_tcp_port "${PORT}"
|
||||||
|
free_tcp_port 3000
|
||||||
|
sleep 1
|
||||||
|
pm2 delete next 2>/dev/null || true
|
||||||
|
PORT="${PORT}" pm2 start pnpm --name next -- start
|
||||||
|
pm2 save 2>/dev/null || true
|
||||||
|
|
||||||
|
sleep 3
|
||||||
|
if ! pm2 show next 2>/dev/null | grep -q 'online'; then
|
||||||
|
echo "ERROR: PM2 next failed to start!" >&2
|
||||||
|
pm2 logs next --lines 20 --nostream >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Waiting for HTTP health check on port ${PORT}..."
|
||||||
|
HEALTH_URL="${DEPLOY_HEALTH_URL:-http://127.0.0.1:${PORT}/api/health}"
|
||||||
|
HEALTH_OK=0
|
||||||
|
for i in $(seq 1 20); do
|
||||||
|
BODY="$(curl -sf --max-time 5 "${HEALTH_URL}" 2>/dev/null || true)"
|
||||||
|
if echo "${BODY}" | grep -q '"database":true'; then
|
||||||
|
echo "Health OK (${HEALTH_URL})"
|
||||||
|
HEALTH_OK=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "Health attempt ${i}/20 failed (body=${BODY:-<empty>}), retrying..."
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
if [ "${HEALTH_OK}" != "1" ]; then
|
||||||
|
echo "ERROR: Health check failed after deploy (${HEALTH_URL})" >&2
|
||||||
|
echo "Last body: ${BODY:-<empty>}" >&2
|
||||||
|
echo "Listeners on PORT ${PORT}:" >&2
|
||||||
|
ss -tlnp 2>/dev/null | grep ":${PORT} " >&2 || netstat -tlnp 2>/dev/null | grep ":${PORT} " >&2 || true
|
||||||
|
pm2 env 0 2>/dev/null | grep -E '^PORT=' >&2 || true
|
||||||
|
pm2 logs next --lines 40 --nostream >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Cleaning stage worktree and previous .next backup..."
|
||||||
|
rm -rf "${LIVE}/.next.prev"
|
||||||
|
git -C "${LIVE}" worktree remove --force "${STAGE}" 2>/dev/null || rm -rf "${STAGE}" || true
|
||||||
|
STAGE=""
|
||||||
|
|
||||||
|
echo "--- Deployed successfully ---"
|
||||||
|
|
||||||
|
release:
|
||||||
|
if: startsWith(gitea.ref_name, 'v')
|
||||||
|
runs-on: shell
|
||||||
|
steps:
|
||||||
|
- name: Build and deploy
|
||||||
|
env:
|
||||||
|
VERSION: ${{ gitea.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
exec 2>&1
|
||||||
|
WORK="$(mktemp -d /var/tmp/epicnext-deploy.XXXXXX)"
|
||||||
|
cleanup() { rm -rf "${WORK}"; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
echo "=== Deploying ${VERSION} ==="
|
||||||
|
git clone --depth 50 \
|
||||||
|
/docker/gitea/gitea/git/repositories/remco/epicnext-cms.git \
|
||||||
|
"${WORK}"
|
||||||
|
cd "${WORK}"
|
||||||
|
git checkout "${VERSION}"
|
||||||
|
export NODE_ENV=production
|
||||||
|
export SKIP_ENV_VALIDATION=1
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
export DATABASE_URL="mysql://placeholder:please@localhost/placeholder"
|
||||||
|
pnpm prisma:generate
|
||||||
|
pnpm build
|
||||||
|
pm2 restart next --update-env
|
||||||
|
pm2 save
|
||||||
|
echo "=== Deploy complete ==="
|
||||||
|
- name: Create Release
|
||||||
|
env:
|
||||||
|
VERSION: ${{ gitea.ref_name }}
|
||||||
|
GITEA_API: ${{ gitea.api_url }}
|
||||||
|
GITEA_REPO: ${{ gitea.repository }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
exec 2>&1
|
||||||
|
BARE="/docker/gitea/gitea/git/repositories/remco/epicnext-cms.git"
|
||||||
|
echo "=== Creating release for ${VERSION} ==="
|
||||||
|
|
||||||
|
PREV_TAG="$(git -C "$BARE" tag --sort=-creatordate | head -2 | tail -1 || echo '')"
|
||||||
|
|
||||||
|
if [ -n "$PREV_TAG" ] && [ "$PREV_TAG" != "$VERSION" ]; then
|
||||||
|
CHANGELOG="$(git -C "$BARE" log --oneline --no-decorate --max-count=50 "${PREV_TAG}..${VERSION}")"
|
||||||
|
[ -z "$CHANGELOG" ] && CHANGELOG="No commit changes since ${PREV_TAG}"
|
||||||
|
else
|
||||||
|
TOTAL="$(git -C "$BARE" rev-list --count "${VERSION}" 2>/dev/null || echo '?')"
|
||||||
|
CHANGELOG="Initial release of EpicNext-CMS (${TOTAL} commits)."
|
||||||
|
fi
|
||||||
|
[ -z "$CHANGELOG" ] && CHANGELOG="Initial release"
|
||||||
|
|
||||||
|
# Pin the other components at their current commits so the release is reproducible.
|
||||||
|
CAT_REF="$(git ls-remote https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily.git Beta-3 2>/dev/null | awk '{print $1}')"
|
||||||
|
NITRO_REF="$(git ls-remote https://github.com/duckietm/Nitro-V3.git main 2>/dev/null | awk '{print $1}')"
|
||||||
|
RENDER_REF="$(git ls-remote https://github.com/duckietm/Nitro_Render_V3.git main 2>/dev/null | awk '{print $1}')"
|
||||||
|
EMU_REF="$(git ls-remote https://github.com/duckietm/Polaris-Emulator.git main 2>/dev/null | awk '{print $1}')"
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "# EpicNext-CMS ${VERSION}"
|
||||||
|
echo ""
|
||||||
|
echo "> Modern, high-performance CMS for Habbo hotel emulators — built on Next.js 16, React 19 and Prisma 7. Integrates with Polaris / Arcturus Morningstar databases."
|
||||||
|
echo ""
|
||||||
|
echo "## Menu"
|
||||||
|
echo "- [What is EpicNext-CMS?](#what-is-epicnext-cms)"
|
||||||
|
echo "- [System Requirements](#system-requirements)"
|
||||||
|
echo "- [Installation Wizard](#installation-wizard)"
|
||||||
|
echo "- [How it is used](#how-it-is-used)"
|
||||||
|
echo "- [Changes](#changes)"
|
||||||
|
echo "- [Linked repositories](#linked-repositories)"
|
||||||
|
echo ""
|
||||||
|
echo '<a id="what-is-epicnext-cms"></a>'
|
||||||
|
echo "## What is EpicNext-CMS?"
|
||||||
|
echo ""
|
||||||
|
echo "EpicNext-CMS is a full public-facing hotel website plus an administrative panel. It features NextAuth authentication (bcrypt with MD5 upgrade), real-time RCON communication with the emulator, Server-Sent Events for live radio, smooth page transitions and extensive extensibility. Full documentation: https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/README.md"
|
||||||
|
echo ""
|
||||||
|
echo '<a id="system-requirements"></a>'
|
||||||
|
echo "## System Requirements"
|
||||||
|
echo ""
|
||||||
|
echo "What you need to install before running the CMS:"
|
||||||
|
echo ""
|
||||||
|
echo "| Component | Version | Notes |"
|
||||||
|
echo "| --------- | ------- | ----- |"
|
||||||
|
echo "| Node.js | >= 22 | Required by Next.js 16 |"
|
||||||
|
echo "| pnpm | >= 10.33.4 | Package manager (npm/yarn not supported) |"
|
||||||
|
echo "| MySQL / MariaDB | 8.0+ / 10.6+ | Shared with the emulator |"
|
||||||
|
echo "| Redis | 7.x+ | Optional — caching, rate limiting, SSE |"
|
||||||
|
echo "| Java | 17+ | Only if building the emulator |"
|
||||||
|
echo "| Maven | 3.9+ | Only if building the emulator |"
|
||||||
|
echo ""
|
||||||
|
echo "The CMS shares its database with the Polaris / Arcturus emulator. It only reads/writes emulator-owned tables and never alters them."
|
||||||
|
echo ""
|
||||||
|
echo '<a id="installation-wizard"></a>'
|
||||||
|
echo "## Installation Wizard"
|
||||||
|
echo ""
|
||||||
|
echo "A complete hotel stack = **EpicNext-CMS** (this repo) + **Polaris Emulator** + **Nitro V3 client** + **Catalogus** data. Follow the steps in order."
|
||||||
|
echo ""
|
||||||
|
echo "**Quick links:** [Full setup guide](https://github.com/duckietm/Complete-Retro-on-Ubuntu) · [EpicNext-CMS repo](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms) · [Reference configs in this repo](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup)"
|
||||||
|
echo ""
|
||||||
|
echo "### 1. Clone & Install the CMS"
|
||||||
|
echo '```bash'
|
||||||
|
echo "git clone https://gitlab.epicnabbo.nl/remco/EpicNext-Cms.git"
|
||||||
|
echo "cd EpicNext-Cms"
|
||||||
|
echo "pnpm install"
|
||||||
|
echo '```'
|
||||||
|
echo ""
|
||||||
|
echo "### 2. Database Setup"
|
||||||
|
echo ""
|
||||||
|
echo "The CMS shares the emulator database. Import the Polaris/Arcturus database first, then create the CMS schema:"
|
||||||
|
echo '```sql'
|
||||||
|
echo "CREATE DATABASE IF NOT EXISTS epicnext_cms CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
||||||
|
echo '```'
|
||||||
|
echo ""
|
||||||
|
echo "### 3. Configure Environment"
|
||||||
|
echo '```bash'
|
||||||
|
echo "cp .env.example .env"
|
||||||
|
echo '```'
|
||||||
|
echo ""
|
||||||
|
echo "Edit .env with at minimum: DATABASE_URL, AUTH_SECRET, HOTEL_NAME and APP_URL. See .env.example for RCON, email, Redis, OAuth and PayPal options."
|
||||||
|
echo ""
|
||||||
|
echo "### 4. Generate Prisma Client"
|
||||||
|
echo '```bash'
|
||||||
|
echo "pnpm prisma:generate"
|
||||||
|
echo '```'
|
||||||
|
echo ""
|
||||||
|
echo "### 5. Run CMS Migrations"
|
||||||
|
echo '```bash'
|
||||||
|
echo "pnpm db:migrate"
|
||||||
|
echo '```'
|
||||||
|
echo ""
|
||||||
|
echo "Creates all CMS-owned tables (website_*, radio_*, acl_*, admin_audit_log). Emulator tables are never touched. Check status with pnpm db:migrate:status."
|
||||||
|
echo ""
|
||||||
|
echo "### 6. Polaris Emulator"
|
||||||
|
echo ""
|
||||||
|
echo "Clone and build the emulator (requires Java 17+ and Maven 3.9+):"
|
||||||
|
echo '```bash'
|
||||||
|
echo "git clone https://github.com/duckietm/Polaris-Emulator.git /var/www/emulator"
|
||||||
|
echo "cd /var/www/emulator/Emulator"
|
||||||
|
echo "mvn clean package"
|
||||||
|
echo '```'
|
||||||
|
echo ""
|
||||||
|
echo "Place the built Habbo-*-jar-with-dependencies.jar next to **config.ini** (see [setup/emulator/config.ini](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup/emulator/config.ini)), then create a systemd unit from [setup/emulator/emulator.service](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup/emulator/emulator.service) with the [emulator](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup/emulator/emulator) launcher so it starts on boot. The bundled update-Nitrov3.sh in this repo automates cloning, building and updating the emulator and Nitro — run it any time to pull the latest commits and rebuild:"
|
||||||
|
echo '```bash'
|
||||||
|
echo "./update-Nitrov3.sh"
|
||||||
|
echo '```'
|
||||||
|
echo ""
|
||||||
|
echo "### 7. Nitro V3 & Renderer"
|
||||||
|
echo ""
|
||||||
|
echo "Clone both Nitro repos and build the client:"
|
||||||
|
echo '```bash'
|
||||||
|
echo "git clone https://github.com/duckietm/Nitro_Render_V3.git /var/www/Nitro_Render_V3"
|
||||||
|
echo "git clone https://github.com/duckietm/Nitro-V3.git /var/www/Nitro-V3"
|
||||||
|
echo "cd /var/www/Nitro_Render_V3 && yarn install && yarn link"
|
||||||
|
echo "cd /var/www/Nitro-V3 && yarn install && yarn link \"@nitrots/nitro-renderer\" && yarn build"
|
||||||
|
echo '```'
|
||||||
|
echo ""
|
||||||
|
echo "Copy the reference configs from [setup/nitro/](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup/nitro) into /var/www/Nitro-V3/public/configuration, keep them as *.json, and replace **MY_DOMAIN** with your domain, API URL and gamedata paths (see the Full setup guide, NitroV3_And_Emulator.md)."
|
||||||
|
echo ""
|
||||||
|
echo "### 8. Catalogus (catalog & gamedata)"
|
||||||
|
echo ""
|
||||||
|
echo "Catalogus holds the daily-updated catalog/gamedata. Clone the Beta-3 branch alongside the other components:"
|
||||||
|
echo '```bash'
|
||||||
|
echo "git clone -b Beta-3 https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily.git /var/www/catalogus"
|
||||||
|
echo '```'
|
||||||
|
echo ""
|
||||||
|
echo "### 9. Build & Start the CMS"
|
||||||
|
echo '```bash'
|
||||||
|
echo "# Development (hot reload)"
|
||||||
|
echo "pnpm dev"
|
||||||
|
echo ""
|
||||||
|
echo "# Production"
|
||||||
|
echo "pnpm build && pnpm start"
|
||||||
|
echo '```'
|
||||||
|
echo ""
|
||||||
|
echo "Open http://localhost:3000 in your browser."
|
||||||
|
echo ""
|
||||||
|
echo "### 10. First Login"
|
||||||
|
echo ""
|
||||||
|
echo "1. Register at /register, or log in with an existing emulator account."
|
||||||
|
echo "2. Grant admin access: UPDATE users SET rank = 7 WHERE username = 'yourname';"
|
||||||
|
echo "3. Visit /admin and configure your hotel via Admin -> CMS Settings."
|
||||||
|
echo ""
|
||||||
|
echo '<a id="how-it-is-used"></a>'
|
||||||
|
echo "## How it is used"
|
||||||
|
echo ""
|
||||||
|
echo "- Public site: browse the hotel, news, radio and the Nitro client at /client."
|
||||||
|
echo "- Admin panel: /admin for CMS settings, theming (12 presets), users, radio and more."
|
||||||
|
echo "- Background jobs: run pnpm jobs:worker for daily backups and cleanup."
|
||||||
|
echo "- Optional: Cloudflare Turnstile / reCAPTCHA, OpenAI moderation and email/PayPal via .env."
|
||||||
|
echo ""
|
||||||
|
echo '<a id="changes"></a>'
|
||||||
|
echo "## Changes"
|
||||||
|
echo '```'
|
||||||
|
echo "${CHANGELOG}"
|
||||||
|
echo '```'
|
||||||
|
echo ""
|
||||||
|
echo '<a id="linked-repositories"></a>'
|
||||||
|
echo "## Linked repositories (exact commits)"
|
||||||
|
echo ""
|
||||||
|
echo "The game components below are pinned to the exact commits used by this release and are deployed alongside the CMS:"
|
||||||
|
echo ""
|
||||||
|
echo "| Component | Repository | Commit |"
|
||||||
|
echo "|-----------|------------|--------|"
|
||||||
|
echo "| Catalogus | https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily | ${CAT_REF:-?} |"
|
||||||
|
echo "| Nitro-V3 | https://github.com/duckietm/Nitro-V3 | ${NITRO_REF:-?} |"
|
||||||
|
echo "| Nitro-Render-V3 | https://github.com/duckietm/Nitro_Render_V3 | ${RENDER_REF:-?} |"
|
||||||
|
echo "| Polaris Emulator | https://github.com/duckietm/Polaris-Emulator | ${EMU_REF:-?} |"
|
||||||
|
echo ""
|
||||||
|
echo "**[Nitro-V3](https://github.com/duckietm/Nitro-V3)** · **[Nitro Renderer](https://github.com/duckietm/Nitro_Render_V3)** · **[Polaris Emulator](https://github.com/duckietm/Polaris-Emulator)** · **[Catalogus](https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily)**"
|
||||||
|
echo ""
|
||||||
|
echo "---"
|
||||||
|
echo "*Automated release from Gitea Actions*"
|
||||||
|
} > /tmp/release-body.md
|
||||||
|
|
||||||
|
PAYLOAD="$(jq -Rs --arg v "${VERSION}" '{tag_name: $v, name: $v, body: ., draft: false, prerelease: false}' < /tmp/release-body.md)"
|
||||||
|
|
||||||
|
TOKEN="${GITEA_TOKEN:-${{ secrets.GITEA_TOKEN }}}"
|
||||||
|
|
||||||
|
HTTP_CODE="$(curl -s -w '%{http_code}' -o /tmp/release-resp.json \
|
||||||
|
-X POST "${GITEA_API}/repos/${GITEA_REPO}/releases" \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$PAYLOAD")"
|
||||||
|
|
||||||
|
if [ "${HTTP_CODE}" = "409" ]; then
|
||||||
|
RELEASES="$(curl -sf "${GITEA_API}/repos/${GITEA_REPO}/releases" \
|
||||||
|
-H "Authorization: token ${TOKEN}")"
|
||||||
|
REL_ID="$(echo "$RELEASES" | jq -r ".[] | select(.tag_name==\"${VERSION}\") | .id")"
|
||||||
|
HTTP_CODE="$(curl -s -w '%{http_code}' -o /tmp/release-resp.json \
|
||||||
|
-X PATCH "${GITEA_API}/repos/${GITEA_REPO}/releases/${REL_ID}" \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$PAYLOAD")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${HTTP_CODE:-0}" -ge 200 ] && [ "${HTTP_CODE:-0}" -lt 300 ]; then
|
||||||
|
echo "SUCCESS: Release ${VERSION} created/updated"
|
||||||
|
cat /tmp/release-resp.json | jq -r '.html_url // .id'
|
||||||
|
else
|
||||||
|
echo "FAILED HTTP ${HTTP_CODE}"
|
||||||
|
cat /tmp/release-resp.json
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -1,417 +0,0 @@
|
|||||||
name: Deploy
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
tags:
|
|
||||||
- "v*"
|
|
||||||
jobs:
|
|
||||||
release:
|
|
||||||
if: startsWith(gitea.ref_name, 'v')
|
|
||||||
runs-on: shell
|
|
||||||
steps:
|
|
||||||
- name: Create Release
|
|
||||||
env:
|
|
||||||
VERSION: ${{ gitea.ref_name }}
|
|
||||||
GITEA_API: ${{ gitea.api_url }}
|
|
||||||
GITEA_REPO: ${{ gitea.repository }}
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
exec 2>&1
|
|
||||||
BARE="/docker/gitea/gitea/git/repositories/remco/epicnext-cms.git"
|
|
||||||
echo "=== Creating release for ${VERSION} ==="
|
|
||||||
|
|
||||||
PREV_TAG="$(git -C "$BARE" tag --sort=-creatordate | head -2 | tail -1 || echo '')"
|
|
||||||
|
|
||||||
if [ -n "$PREV_TAG" ] && [ "$PREV_TAG" != "$VERSION" ]; then
|
|
||||||
CHANGELOG="$(git -C "$BARE" log --oneline --no-decorate --max-count=50 "${PREV_TAG}..${VERSION}")"
|
|
||||||
[ -z "$CHANGELOG" ] && CHANGELOG="No commit changes since ${PREV_TAG}"
|
|
||||||
else
|
|
||||||
TOTAL="$(git -C "$BARE" rev-list --count "${VERSION}" 2>/dev/null || echo '?')"
|
|
||||||
CHANGELOG="Initial release of EpicNext-CMS (${TOTAL} commits)."
|
|
||||||
fi
|
|
||||||
[ -z "$CHANGELOG" ] && CHANGELOG="Initial release"
|
|
||||||
|
|
||||||
# Pin the other components at their current commits so the release is reproducible.
|
|
||||||
CAT_REF="$(git ls-remote https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily.git Beta-3 2>/dev/null | awk '{print $1}')"
|
|
||||||
NITRO_REF="$(git ls-remote https://github.com/duckietm/Nitro-V3.git main 2>/dev/null | awk '{print $1}')"
|
|
||||||
RENDER_REF="$(git ls-remote https://github.com/duckietm/Nitro_Render_V3.git main 2>/dev/null | awk '{print $1}')"
|
|
||||||
EMU_REF="$(git ls-remote https://github.com/duckietm/Polaris-Emulator.git main 2>/dev/null | awk '{print $1}')"
|
|
||||||
|
|
||||||
{
|
|
||||||
echo "# EpicNext-CMS ${VERSION}"
|
|
||||||
echo ""
|
|
||||||
echo "> Modern, high-performance CMS for Habbo hotel emulators — built on Next.js 16, React 19 and Prisma 7. Integrates with Polaris / Arcturus Morningstar databases."
|
|
||||||
echo ""
|
|
||||||
echo "## Menu"
|
|
||||||
echo "- [What is EpicNext-CMS?](#what-is-epicnext-cms)"
|
|
||||||
echo "- [System Requirements](#system-requirements)"
|
|
||||||
echo "- [Installation Wizard](#installation-wizard)"
|
|
||||||
echo "- [How it is used](#how-it-is-used)"
|
|
||||||
echo "- [Changes](#changes)"
|
|
||||||
echo "- [Linked repositories](#linked-repositories)"
|
|
||||||
echo ""
|
|
||||||
echo '<a id="what-is-epicnext-cms"></a>'
|
|
||||||
echo "## What is EpicNext-CMS?"
|
|
||||||
echo ""
|
|
||||||
echo "EpicNext-CMS is a full public-facing hotel website plus an administrative panel. It features NextAuth authentication (argon2id/bcrypt with MD5 upgrade), real-time RCON communication with the emulator, Server-Sent Events for live radio, smooth page transitions and extensive extensibility. Full documentation: https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/README.md"
|
|
||||||
echo ""
|
|
||||||
echo '<a id="system-requirements"></a>'
|
|
||||||
echo "## System Requirements"
|
|
||||||
echo ""
|
|
||||||
echo "What you need to install before running the CMS:"
|
|
||||||
echo ""
|
|
||||||
echo "| Component | Version | Notes |"
|
|
||||||
echo "| --------- | ------- | ----- |"
|
|
||||||
echo "| Node.js | >= 22 | Required by Next.js 16 |"
|
|
||||||
echo "| pnpm | >= 10.33.4 | Package manager (npm/yarn not supported) |"
|
|
||||||
echo "| MySQL / MariaDB | 8.0+ / 10.6+ | Shared with the emulator |"
|
|
||||||
echo "| Redis | 7.x+ | Optional — caching, rate limiting, SSE |"
|
|
||||||
echo "| Java | 17+ | Only if building the emulator |"
|
|
||||||
echo "| Maven | 3.9+ | Only if building the emulator |"
|
|
||||||
echo ""
|
|
||||||
echo "The CMS shares its database with the Polaris / Arcturus emulator. It only reads/writes emulator-owned tables and never alters them."
|
|
||||||
echo ""
|
|
||||||
echo '<a id="installation-wizard"></a>'
|
|
||||||
echo "## Installation Wizard"
|
|
||||||
echo ""
|
|
||||||
echo "A complete hotel stack = **EpicNext-CMS** (this repo) + **Polaris Emulator** + **Nitro V3 client** + **Catalogus** data. Follow the steps in order."
|
|
||||||
echo ""
|
|
||||||
echo "**Quick links:** [Full setup guide](https://github.com/duckietm/Complete-Retro-on-Ubuntu) · [EpicNext-CMS repo](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms) · [Reference configs in this repo](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup)"
|
|
||||||
echo ""
|
|
||||||
echo "### 1. Clone & Install the CMS"
|
|
||||||
echo '```bash'
|
|
||||||
echo "git clone https://gitlab.epicnabbo.nl/remco/EpicNext-Cms.git"
|
|
||||||
echo "cd EpicNext-Cms"
|
|
||||||
echo "pnpm install"
|
|
||||||
echo '```'
|
|
||||||
echo ""
|
|
||||||
echo "### 2. Database Setup"
|
|
||||||
echo ""
|
|
||||||
echo "The CMS shares the emulator database. Import the Polaris/Arcturus database first, then create the CMS schema:"
|
|
||||||
echo '```sql'
|
|
||||||
echo "CREATE DATABASE IF NOT EXISTS epicnext_cms CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
|
||||||
echo '```'
|
|
||||||
echo ""
|
|
||||||
echo "### 3. Configure Environment"
|
|
||||||
echo '```bash'
|
|
||||||
echo "cp .env.example .env"
|
|
||||||
echo '```'
|
|
||||||
echo ""
|
|
||||||
echo "Edit .env with at minimum: DATABASE_URL, AUTH_SECRET, HOTEL_NAME and APP_URL. See .env.example for RCON, email, Redis, OAuth and PayPal options."
|
|
||||||
echo ""
|
|
||||||
echo "### 4. Generate Prisma Client"
|
|
||||||
echo '```bash'
|
|
||||||
echo "pnpm prisma:generate"
|
|
||||||
echo '```'
|
|
||||||
echo ""
|
|
||||||
echo "### 5. Run CMS Migrations"
|
|
||||||
echo '```bash'
|
|
||||||
echo "pnpm db:migrate"
|
|
||||||
echo '```'
|
|
||||||
echo ""
|
|
||||||
echo "Creates all CMS-owned tables (website_*, radio_*, acl_*, admin_audit_log). Emulator tables are never touched. Check status with pnpm db:migrate:status."
|
|
||||||
echo ""
|
|
||||||
echo "### 6. Polaris Emulator"
|
|
||||||
echo ""
|
|
||||||
echo "Clone and build the emulator (requires Java 17+ and Maven 3.9+):"
|
|
||||||
echo '```bash'
|
|
||||||
echo "git clone https://github.com/duckietm/Polaris-Emulator.git /var/www/emulator"
|
|
||||||
echo "cd /var/www/emulator/Emulator"
|
|
||||||
echo "mvn clean package"
|
|
||||||
echo '```'
|
|
||||||
echo ""
|
|
||||||
echo "Place the built Habbo-*-jar-with-dependencies.jar next to **config.ini** (see [setup/emulator/config.ini](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup/emulator/config.ini)), then create a systemd unit from [setup/emulator/emulator.service](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup/emulator/emulator.service) with the [emulator](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup/emulator/emulator) launcher so it starts on boot. The bundled update-Nitrov3.sh in this repo automates cloning, building and updating the emulator and Nitro — run it any time to pull the latest commits and rebuild:"
|
|
||||||
echo '```bash'
|
|
||||||
echo "./update-Nitrov3.sh"
|
|
||||||
echo '```'
|
|
||||||
echo ""
|
|
||||||
echo "### 7. Nitro V3 & Renderer"
|
|
||||||
echo ""
|
|
||||||
echo "Clone both Nitro repos and build the client:"
|
|
||||||
echo '```bash'
|
|
||||||
echo "git clone https://github.com/duckietm/Nitro_Render_V3.git /var/www/Nitro_Render_V3"
|
|
||||||
echo "git clone https://github.com/duckietm/Nitro-V3.git /var/www/Nitro-V3"
|
|
||||||
echo "cd /var/www/Nitro_Render_V3 && yarn install && yarn link"
|
|
||||||
echo "cd /var/www/Nitro-V3 && yarn install && yarn link \"@nitrots/nitro-renderer\" && yarn build"
|
|
||||||
echo '```'
|
|
||||||
echo ""
|
|
||||||
echo "Copy the reference configs from [setup/nitro/](https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/setup/nitro) into /var/www/Nitro-V3/public/configuration, keep them as *.json, and replace **MY_DOMAIN** with your domain, API URL and gamedata paths (see the Full setup guide, NitroV3_And_Emulator.md)."
|
|
||||||
echo ""
|
|
||||||
echo "### 8. Catalogus (catalog & gamedata)"
|
|
||||||
echo ""
|
|
||||||
echo "Catalogus holds the daily-updated catalog/gamedata. Clone the Beta-3 branch alongside the other components:"
|
|
||||||
echo '```bash'
|
|
||||||
echo "git clone -b Beta-3 https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily.git /var/www/catalogus"
|
|
||||||
echo '```'
|
|
||||||
echo ""
|
|
||||||
echo "### 9. Build & Start the CMS"
|
|
||||||
echo '```bash'
|
|
||||||
echo "# Development (hot reload)"
|
|
||||||
echo "pnpm dev"
|
|
||||||
echo ""
|
|
||||||
echo "# Production"
|
|
||||||
echo "pnpm build && pnpm start"
|
|
||||||
echo '```'
|
|
||||||
echo ""
|
|
||||||
echo "Open http://localhost:3000 in your browser."
|
|
||||||
echo ""
|
|
||||||
echo "### 10. First Login"
|
|
||||||
echo ""
|
|
||||||
echo "1. Register at /register, or log in with an existing emulator account."
|
|
||||||
echo "2. Grant admin access: UPDATE users SET rank = 7 WHERE username = 'yourname';"
|
|
||||||
echo "3. Visit /admin and configure your hotel via Admin -> CMS Settings."
|
|
||||||
echo ""
|
|
||||||
echo '<a id="how-it-is-used"></a>'
|
|
||||||
echo "## How it is used"
|
|
||||||
echo ""
|
|
||||||
echo "- Public site: browse the hotel, news, radio and the Nitro client at /client."
|
|
||||||
echo "- Admin panel: /admin for CMS settings, theming (12 presets), users, radio and more."
|
|
||||||
echo "- Background jobs: run pnpm jobs:worker for daily backups and cleanup."
|
|
||||||
echo "- Optional: Cloudflare Turnstile / reCAPTCHA, OpenAI moderation and email/PayPal via .env."
|
|
||||||
echo ""
|
|
||||||
echo '<a id="changes"></a>'
|
|
||||||
echo "## Changes"
|
|
||||||
echo '```'
|
|
||||||
echo "${CHANGELOG}"
|
|
||||||
echo '```'
|
|
||||||
echo ""
|
|
||||||
echo '<a id="linked-repositories"></a>'
|
|
||||||
echo "## Linked repositories (exact commits)"
|
|
||||||
echo ""
|
|
||||||
echo "The game components below are pinned to the exact commits used by this release and are deployed alongside the CMS:"
|
|
||||||
echo ""
|
|
||||||
echo "| Component | Repository | Commit |"
|
|
||||||
echo "|-----------|------------|--------|"
|
|
||||||
echo "| Catalogus | https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily | ${CAT_REF:-?} |"
|
|
||||||
echo "| Nitro-V3 | https://github.com/duckietm/Nitro-V3 | ${NITRO_REF:-?} |"
|
|
||||||
echo "| Nitro-Render-V3 | https://github.com/duckietm/Nitro_Render_V3 | ${RENDER_REF:-?} |"
|
|
||||||
echo "| Polaris Emulator | https://github.com/duckietm/Polaris-Emulator | ${EMU_REF:-?} |"
|
|
||||||
echo ""
|
|
||||||
echo "**[Nitro-V3](https://github.com/duckietm/Nitro-V3)** · **[Nitro Renderer](https://github.com/duckietm/Nitro_Render_V3)** · **[Polaris Emulator](https://github.com/duckietm/Polaris-Emulator)** · **[Catalogus](https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily)**"
|
|
||||||
echo ""
|
|
||||||
echo "---"
|
|
||||||
echo "*Automated release from Gitea Actions*"
|
|
||||||
} > /tmp/release-body.md
|
|
||||||
|
|
||||||
PAYLOAD="$(jq -Rs --arg v "${VERSION}" '{tag_name: $v, name: $v, body: ., draft: false, prerelease: false}' < /tmp/release-body.md)"
|
|
||||||
|
|
||||||
TOKEN="${GITEA_TOKEN:-${{ secrets.GITEA_TOKEN }}}"
|
|
||||||
|
|
||||||
HTTP_CODE="$(curl -s -w '%{http_code}' -o /tmp/release-resp.json \
|
|
||||||
-X POST "${GITEA_API}/repos/${GITEA_REPO}/releases" \
|
|
||||||
-H "Authorization: token ${TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "$PAYLOAD")"
|
|
||||||
|
|
||||||
if [ "${HTTP_CODE}" = "409" ]; then
|
|
||||||
RELEASES="$(curl -sf "${GITEA_API}/repos/${GITEA_REPO}/releases" \
|
|
||||||
-H "Authorization: token ${TOKEN}")"
|
|
||||||
REL_ID="$(echo "$RELEASES" | jq -r ".[] | select(.tag_name==\"${VERSION}\") | .id")"
|
|
||||||
HTTP_CODE="$(curl -s -w '%{http_code}' -o /tmp/release-resp.json \
|
|
||||||
-X PATCH "${GITEA_API}/repos/${GITEA_REPO}/releases/${REL_ID}" \
|
|
||||||
-H "Authorization: token ${TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "$PAYLOAD")"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "${HTTP_CODE:-0}" -ge 200 ] && [ "${HTTP_CODE:-0}" -lt 300 ]; then
|
|
||||||
echo "SUCCESS: Release ${VERSION} created/updated"
|
|
||||||
cat /tmp/release-resp.json | jq -r '.html_url // .id'
|
|
||||||
else
|
|
||||||
echo "FAILED HTTP ${HTTP_CODE}"
|
|
||||||
cat /tmp/release-resp.json
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
deploy:
|
|
||||||
if: startsWith(gitea.ref_name, 'v') == false
|
|
||||||
runs-on: shell
|
|
||||||
steps:
|
|
||||||
- name: Deploy
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
|
|
||||||
exec 9>/var/tmp/epic_web_control_deploy.lock
|
|
||||||
flock -n 9 || { echo "ERROR: Another deployment is already running! Cancelling."; exit 1; }
|
|
||||||
|
|
||||||
echo "--- Deploying ---"
|
|
||||||
|
|
||||||
LIVE="/var/www/atom-nexst"
|
|
||||||
STAGE=""
|
|
||||||
CUTOVER_STARTED=0
|
|
||||||
|
|
||||||
error_handler() {
|
|
||||||
cd /var/www/atom-nexst 2>/dev/null || cd / || true
|
|
||||||
echo "!!! DEPLOYMENT FAILED on line $1 !!!" >&2
|
|
||||||
# Roll back the build artifact if cutover already moved .next into place.
|
|
||||||
if [ "${CUTOVER_STARTED}" = "1" ] && [ -d "${LIVE}/.next.prev" ]; then
|
|
||||||
echo "Rolling back .next to previous artifact..." >&2
|
|
||||||
rm -rf "${LIVE}/.next" || true
|
|
||||||
mv "${LIVE}/.next.prev" "${LIVE}/.next" || true
|
|
||||||
fi
|
|
||||||
if [ -n "${STAGE}" ] && [ -d "${STAGE}" ]; then
|
|
||||||
git -C "${LIVE}" worktree remove --force "${STAGE}" 2>/dev/null || rm -rf "${STAGE}" || true
|
|
||||||
fi
|
|
||||||
pm2 restart next --update-env 2>/dev/null || pm2 start pnpm --name "next" -- start 2>/dev/null || true
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
trap 'error_handler $LINENO' ERR
|
|
||||||
|
|
||||||
docker image prune -f
|
|
||||||
|
|
||||||
DEPLOY_USER="$(id -un)"
|
|
||||||
DEPLOY_GROUP="$(id -gn)"
|
|
||||||
sudo chown -R "${DEPLOY_USER}:${DEPLOY_GROUP}" "${LIVE}" 2>/dev/null || true
|
|
||||||
git config --global --add safe.directory "${LIVE}"
|
|
||||||
git -C "${LIVE}" remote set-url origin /docker/gitea/gitea/git/repositories/remco/epicnext-cms.git/
|
|
||||||
|
|
||||||
echo "Fetching origin/main..."
|
|
||||||
git -C "${LIVE}" fetch origin --prune
|
|
||||||
|
|
||||||
echo "Clearing sticky git index bits (if any)..."
|
|
||||||
STICKY_LIST="$(git -C "${LIVE}" ls-files -v | awk '/^[a-zS]/ {print substr($0,3)}' || true)"
|
|
||||||
if [ -n "${STICKY_LIST}" ]; then
|
|
||||||
echo "${STICKY_LIST}" | while IFS= read -r f; do
|
|
||||||
[ -n "$f" ] || continue
|
|
||||||
git -C "${LIVE}" update-index --no-skip-worktree --no-assume-unchanged -- "$f" 2>/dev/null || true
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
export APP_VERSION="$(git -C "${LIVE}" rev-parse --short origin/main)"
|
|
||||||
export NEXT_PUBLIC_APP_VERSION="${APP_VERSION}"
|
|
||||||
echo "APP_VERSION=${APP_VERSION}"
|
|
||||||
|
|
||||||
STAGE="/var/tmp/atom-nexst-stage-${APP_VERSION}"
|
|
||||||
echo "Preparing stage worktree at ${STAGE} (live site stays up)..."
|
|
||||||
git -C "${LIVE}" worktree remove --force "${STAGE}" 2>/dev/null || rm -rf "${STAGE}" || true
|
|
||||||
git -C "${LIVE}" worktree add --detach "${STAGE}" origin/main
|
|
||||||
|
|
||||||
# Production env stays on the live tree; stage only needs a symlink for build/migrate.
|
|
||||||
ln -sfn "${LIVE}/.env" "${STAGE}/.env"
|
|
||||||
|
|
||||||
if ! grep -qE '^[[:space:]]*REDIS_URL=.+' "${LIVE}/.env" 2>/dev/null; then
|
|
||||||
echo "WARNING: REDIS_URL is unset in ${LIVE}/.env" >&2
|
|
||||||
echo "WARNING: Rate limits, site-settings cache, and JWT invalidation cache will be in-process only." >&2
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd "${STAGE}"
|
|
||||||
rm -f tsconfig.tsbuildinfo .tsbuildinfo
|
|
||||||
find . -maxdepth 3 -name '*.tsbuildinfo' -delete 2>/dev/null || true
|
|
||||||
rm -rf .output dist .next .next/types .next/dev
|
|
||||||
|
|
||||||
# Restore build cache from last deploy so Turbopack can do
|
|
||||||
# incremental compilation (much faster rebuilds).
|
|
||||||
if [ -d "${LIVE}/.next/cache" ]; then
|
|
||||||
mkdir -p .next/cache
|
|
||||||
cp -r "${LIVE}/.next/cache/." .next/cache/
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Stage shares MySQL with the live app + emulator. Keep the stage pool
|
|
||||||
# tiny so install/test/build cannot exhaust max_connections.
|
|
||||||
export DATABASE_POOL_SIZE="${DEPLOY_DATABASE_POOL_SIZE:-5}"
|
|
||||||
echo "STAGE DATABASE_POOL_SIZE=${DATABASE_POOL_SIZE}"
|
|
||||||
|
|
||||||
pnpm install --frozen-lockfile
|
|
||||||
# prisma generate does not need a live DB connection.
|
|
||||||
pnpm prisma:generate
|
|
||||||
export ARGON2_MEMORY_SIZE=1024 ARGON2_ITERATIONS=1 BCRYPT_ROUNDS=4
|
|
||||||
pnpm typecheck
|
|
||||||
pnpm test
|
|
||||||
# Validate production env (AUTH_SECRET, DATABASE_URL, …) during build.
|
|
||||||
# Do not set SKIP_ENV_VALIDATION here — that flag is for tests/tooling only.
|
|
||||||
pnpm build
|
|
||||||
|
|
||||||
if [ ! -d "${STAGE}/.next" ]; then
|
|
||||||
echo "ERROR: stage build produced no .next/" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Cutover: stop service (free DB connections), migrate, swap .next..."
|
|
||||||
CUTOVER_STARTED=1
|
|
||||||
pm2 stop next --kill-timeout 10000 || true
|
|
||||||
# Wait for PM2 to fully exit and MariaDB to reclaim connections.
|
|
||||||
sleep 10
|
|
||||||
|
|
||||||
# Migrate only after live is stopped — avoids ER_CON_COUNT_ERROR while
|
|
||||||
# the old process still holds DATABASE_POOL_SIZE connections.
|
|
||||||
cd "${STAGE}"
|
|
||||||
MIGRATE_OK=0
|
|
||||||
for i in $(seq 1 10); do
|
|
||||||
if pnpm db:migrate; then
|
|
||||||
MIGRATE_OK=1
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
echo "migrate attempt ${i}/10 failed (likely DB connections), retrying..."
|
|
||||||
sleep 5
|
|
||||||
done
|
|
||||||
if [ "${MIGRATE_OK}" != "1" ]; then
|
|
||||||
echo "ERROR: db:migrate failed after retries" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd "${LIVE}"
|
|
||||||
echo "Hard reset live tree to origin/main (no nuclear src wipe)..."
|
|
||||||
git reset --hard origin/main
|
|
||||||
# Keep env, uploads, and deps we are about to replace from stage.
|
|
||||||
git clean -fd \
|
|
||||||
-e .env -e .env.local -e .env.production -e .env*.local \
|
|
||||||
-e storage -e public/cache -e node_modules -e .next -e .next.prev
|
|
||||||
|
|
||||||
if ! git diff --exit-code HEAD -- src >/dev/null; then
|
|
||||||
echo "ERROR: live src/ still differs from HEAD after reset:" >&2
|
|
||||||
git diff --stat HEAD -- src >&2 || true
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "Verified live src/ matches HEAD"
|
|
||||||
|
|
||||||
# Save current .next as backup before swapping (kept until health check passes).
|
|
||||||
if [ -d .next ]; then
|
|
||||||
mv .next .next.prev
|
|
||||||
fi
|
|
||||||
mv "${STAGE}/.next" .next
|
|
||||||
|
|
||||||
# Use the exact node_modules the stage build resolved against.
|
|
||||||
rm -rf node_modules
|
|
||||||
mv "${STAGE}/node_modules" node_modules
|
|
||||||
|
|
||||||
# Prisma client is gitignored — regenerate into live src/generated.
|
|
||||||
pnpm prisma:generate
|
|
||||||
|
|
||||||
sudo chown -R www-data:www-data "${LIVE}" 2>/dev/null || true
|
|
||||||
|
|
||||||
echo "Starting PM2 (zero-downtime reload)..."
|
|
||||||
pm2 reload next --update-env || pm2 start next --update-env
|
|
||||||
|
|
||||||
sleep 3
|
|
||||||
if ! pm2 show next 2>/dev/null | grep -q 'online'; then
|
|
||||||
echo "ERROR: PM2 next failed to start!" >&2
|
|
||||||
pm2 logs next --lines 20 --nostream >&2 || true
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Waiting for HTTP health check..."
|
|
||||||
HEALTH_URL="${DEPLOY_HEALTH_URL:-http://127.0.0.1:3000/api/health}"
|
|
||||||
HEALTH_OK=0
|
|
||||||
for i in $(seq 1 15); do
|
|
||||||
BODY="$(curl -sf --max-time 5 "${HEALTH_URL}" 2>/dev/null || true)"
|
|
||||||
if echo "${BODY}" | grep -q '"database":true'; then
|
|
||||||
echo "Health OK (${HEALTH_URL})"
|
|
||||||
HEALTH_OK=1
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
echo "Health attempt ${i}/15 failed, retrying..."
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
if [ "${HEALTH_OK}" != "1" ]; then
|
|
||||||
echo "ERROR: Health check failed after deploy (${HEALTH_URL})" >&2
|
|
||||||
echo "Last body: ${BODY:-<empty>}" >&2
|
|
||||||
pm2 logs next --lines 40 --nostream >&2 || true
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Cleaning stage worktree and previous .next backup..."
|
|
||||||
rm -rf "${LIVE}/.next.prev"
|
|
||||||
git -C "${LIVE}" worktree remove --force "${STAGE}" 2>/dev/null || rm -rf "${STAGE}" || true
|
|
||||||
STAGE=""
|
|
||||||
|
|
||||||
echo "--- Deployed successfully ---"
|
|
||||||
@@ -11,9 +11,8 @@ jobs:
|
|||||||
- name: Self-hosted Renovate
|
- name: Self-hosted Renovate
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Zorg ervoor dat de cache-map lokaal bestaat vóór Docker start
|
# Ensure cache dir exists before Docker starts
|
||||||
# Dit voorkomt dat Docker de map automatisch als 'root' aanmaakt
|
|
||||||
mkdir -p /var/tmp/renovate-cache
|
mkdir -p /var/tmp/renovate-cache
|
||||||
|
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
@@ -25,4 +24,4 @@ jobs:
|
|||||||
-e RENOVATE_CONFIG_FILE='{"extends":["config:recommended"]}' \
|
-e RENOVATE_CONFIG_FILE='{"extends":["config:recommended"]}' \
|
||||||
-e LOG_LEVEL=info \
|
-e LOG_LEVEL=info \
|
||||||
-v /var/tmp/renovate-cache:/tmp/renovate-cache \
|
-v /var/tmp/renovate-cache:/tmp/renovate-cache \
|
||||||
ghcr.io/renovatebot/renovate:latest
|
ghcr.io/renovatebot/renovate:latest
|
||||||
Executable
+1
@@ -0,0 +1 @@
|
|||||||
|
pnpm exec lint-staged
|
||||||
Executable
+2
@@ -0,0 +1,2 @@
|
|||||||
|
pnpm typecheck
|
||||||
|
pnpm test
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
A modern, high-performance content management system for Habbo hotel emulators, built on **Next.js 16** (App Router) with **Prisma 7** and **React 19**. Designed to integrate seamlessly with Polaris / Arcturus Morningstar MySQL/MariaDB databases.
|
A modern, high-performance content management system for Habbo hotel emulators, built on **Next.js 16** (App Router) with **Prisma 7** and **React 19**. Designed to integrate seamlessly with Polaris / Arcturus Morningstar MySQL/MariaDB databases.
|
||||||
|
|
||||||
Features a premium animated homepage (typewriter hero, floating orbs, scroll counters), a full admin panel, NextAuth authentication (argon2id/bcrypt with MD5-to-argon2id upgrade), real-time RCON communication, Server-Sent Events for live radio data, smooth page transitions, and PM2 production deployment.
|
Features a premium animated homepage (typewriter hero, floating orbs, scroll counters), a full admin panel, NextAuth authentication (bcrypt with MD5-to-bcrypt upgrade), real-time RCON communication, Server-Sent Events for live radio data, smooth page transitions, and PM2 production deployment.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
|
||||||
|
|
||||||
test.describe("Admin panel", () => {
|
|
||||||
test("admin login page redirects unauthenticated users", async ({ page }) => {
|
|
||||||
await page.goto("/admin");
|
|
||||||
await expect(page).toHaveURL(/login/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("admin page has login form", async ({ page }) => {
|
|
||||||
await page.goto("/admin");
|
|
||||||
await expect(page.locator('input[name="username"]')).toBeVisible();
|
|
||||||
await expect(page.locator('input[name="password"]')).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
|
||||||
|
|
||||||
test.describe("Authentication flows", () => {
|
|
||||||
test("login form validates required fields", async ({ page }) => {
|
|
||||||
await page.goto("/login");
|
|
||||||
await page.click('button[type="submit"]');
|
|
||||||
await expect(page.locator("text=required")).toBeVisible({ timeout: 5000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("login with invalid credentials shows error", async ({ page }) => {
|
|
||||||
await page.goto("/login");
|
|
||||||
await page.fill('input[name="username"]', "nonexistent");
|
|
||||||
await page.fill('input[name="password"]', "wrongpassword");
|
|
||||||
await page.click('button[type="submit"]');
|
|
||||||
await expect(page.locator("text=invalid")).toBeVisible({ timeout: 5000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("register page has password confirmation field", async ({ page }) => {
|
|
||||||
await page.goto("/register");
|
|
||||||
await expect(page.locator('input[name="confirmPassword"]')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("register form validates password match", async ({ page }) => {
|
|
||||||
await page.goto("/register");
|
|
||||||
await page.fill('input[name="password"]', "Password123!");
|
|
||||||
await page.fill('input[name="confirmPassword"]', "DifferentPass123!");
|
|
||||||
await page.click('button[type="submit"]');
|
|
||||||
await expect(page.locator("text=match|komen overeen|kloppen")).toBeVisible({
|
|
||||||
timeout: 5000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
|
||||||
|
|
||||||
test("homepage has title", async ({ page }) => {
|
|
||||||
await page.goto("/");
|
|
||||||
await expect(page).toHaveTitle(/Magic Hotel|Atom/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("register page loads", async ({ page }) => {
|
|
||||||
await page.goto("/register");
|
|
||||||
await expect(page).toHaveTitle(/registreer|register|konto/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("login page loads", async ({ page }) => {
|
|
||||||
await page.goto("/login");
|
|
||||||
await expect(page).toHaveTitle(/inloggen|login/i);
|
|
||||||
});
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
|
||||||
|
|
||||||
test.describe("Public navigation", () => {
|
|
||||||
test("homepage loads with expected elements", async ({ page }) => {
|
|
||||||
await page.goto("/");
|
|
||||||
await expect(page.locator("nav")).toBeVisible();
|
|
||||||
await expect(page.locator("footer")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("community page loads", async ({ page }) => {
|
|
||||||
await page.goto("/community");
|
|
||||||
await expect(page).toHaveTitle(/community/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shop page loads", async ({ page }) => {
|
|
||||||
await page.goto("/shop");
|
|
||||||
await expect(page.locator("h1, h2").first()).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("404 page for unknown routes", async ({ page }) => {
|
|
||||||
const response = await page.goto("/this-page-does-not-exist");
|
|
||||||
expect(response?.status()).toBe(404);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
module.exports = {
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
name: "next",
|
||||||
|
script: ".next/standalone/server.js",
|
||||||
|
exec_mode: "cluster",
|
||||||
|
instances: 0,
|
||||||
|
max_memory_restart: "512M",
|
||||||
|
env: {
|
||||||
|
NODE_ENV: "production",
|
||||||
|
PORT: 3002,
|
||||||
|
RCON_PORT: 3003,
|
||||||
|
NEXT_PHASE: "phase-production-server",
|
||||||
|
},
|
||||||
|
merge_logs: true,
|
||||||
|
error_file: ".pm2/errors.log",
|
||||||
|
out_file: ".pm2/out.log",
|
||||||
|
log_date_format: "YYYY-MM-DD HH:mm:ss",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
ci: {
|
|
||||||
collect: {
|
|
||||||
url: ["http://localhost:3000"],
|
|
||||||
numberOfRuns: 3,
|
|
||||||
},
|
|
||||||
assert: {
|
|
||||||
preset: "lighthouse:no-pwa",
|
|
||||||
assertions: {
|
|
||||||
"categories:performance": ["error", { minScore: 0.9 }],
|
|
||||||
"categories:accessibility": ["error", { minScore: 0.9 }],
|
|
||||||
"categories:best-practices": ["error", { minScore: 0.9 }],
|
|
||||||
"categories:seo": ["error", { minScore: 0.8 }],
|
|
||||||
"first-contentful-paint": ["error", { maxNumericValue: 2000 }],
|
|
||||||
"largest-contentful-paint": ["error", { maxNumericValue: 2500 }],
|
|
||||||
"cumulative-layout-shift": ["error", { maxNumericValue: 0.1 }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
+9
-2
@@ -34,12 +34,19 @@ const nextConfig: NextConfig = {
|
|||||||
// Enable React Compiler for automatic memoization
|
// Enable React Compiler for automatic memoization
|
||||||
reactCompiler: true,
|
reactCompiler: true,
|
||||||
|
|
||||||
// Compress responses with gzip
|
// Compress responses with gzip/brotli
|
||||||
compress: true,
|
compress: true,
|
||||||
|
|
||||||
// Disable Next.js telemetry
|
// Disable Next.js telemetry and browser sourcemaps in production
|
||||||
productionBrowserSourceMaps: false,
|
productionBrowserSourceMaps: false,
|
||||||
|
|
||||||
|
// Standalone output for smaller, faster Docker deployments and cold starts
|
||||||
|
output: "standalone",
|
||||||
|
|
||||||
|
experimental: {
|
||||||
|
useTypeScriptCli: true,
|
||||||
|
},
|
||||||
|
|
||||||
// Add caching headers for static assets
|
// Add caching headers for static assets
|
||||||
async headers() {
|
async headers() {
|
||||||
return [
|
return [
|
||||||
|
|||||||
+15
-36
@@ -11,19 +11,13 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
"typecheck": "tsc6 --noEmit --incremental false",
|
"typecheck": "tsc --noEmit --incremental",
|
||||||
"biome:check": "biome check --write .",
|
"biome:check": "biome check --write .",
|
||||||
"biome:lint": "biome lint .",
|
"biome:lint": "biome lint .",
|
||||||
"biome:format": "biome format --write .",
|
"biome:format": "biome format --write .",
|
||||||
"knip": "knip",
|
"knip": "knip",
|
||||||
"analyze": "ANALYZE=true pnpm build",
|
"analyze": "ANALYZE=true pnpm build",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:e2e": "playwright test",
|
|
||||||
"lint": "eslint . --ext .ts,.tsx --max-warnings=50",
|
|
||||||
"lint:fix": "eslint . --ext .ts,.tsx --fix --max-warnings=50",
|
|
||||||
"lhci:collect": "lhci collect",
|
|
||||||
"lhci:assert": "lhci assert",
|
|
||||||
"lhci:server": "lhci server",
|
|
||||||
"db:migrate": "tsx scripts/apply-migrations.ts",
|
"db:migrate": "tsx scripts/apply-migrations.ts",
|
||||||
"db:migrate:status": "tsx scripts/apply-migrations.ts --status",
|
"db:migrate:status": "tsx scripts/apply-migrations.ts --status",
|
||||||
"jobs:worker": "tsx scripts/jobs-worker.ts",
|
"jobs:worker": "tsx scripts/jobs-worker.ts",
|
||||||
@@ -31,8 +25,7 @@
|
|||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{js,jsx,ts,tsx}": [
|
"*.{js,jsx,ts,tsx}": [
|
||||||
"biome check --write",
|
"biome check --write"
|
||||||
"eslint --fix --max-warnings=50"
|
|
||||||
],
|
],
|
||||||
"*.{json,md,css,scss,html}": [
|
"*.{json,md,css,scss,html}": [
|
||||||
"biome format --write"
|
"biome format --write"
|
||||||
@@ -46,36 +39,33 @@
|
|||||||
"@hookform/resolvers": "^5.5.7",
|
"@hookform/resolvers": "^5.5.7",
|
||||||
"@prisma/adapter-mariadb": "^7.9.1",
|
"@prisma/adapter-mariadb": "^7.9.1",
|
||||||
"@prisma/client": "^7.9.1",
|
"@prisma/client": "^7.9.1",
|
||||||
"@sentry/nextjs": "^10.68.0",
|
"@sentry/nextjs": "^10.69.0",
|
||||||
"@tanstack/react-virtual": "^3.14.8",
|
"@tanstack/react-virtual": "^3.14.9",
|
||||||
"bcrypt": "^6.0.0",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"croner": "^10.0.1",
|
"croner": "^10.0.1",
|
||||||
"framer-motion": "^12.42.2",
|
|
||||||
"hash-wasm": "^4.12.0",
|
"hash-wasm": "^4.12.0",
|
||||||
"ioredis": "^5.11.1",
|
"ioredis": "^5.11.1",
|
||||||
|
"isomorphic-dompurify": "^3.21.0",
|
||||||
"jpeg-js": "^0.4.4",
|
"jpeg-js": "^0.4.4",
|
||||||
"json5": "^2.2.3",
|
"json5": "^2.2.3",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"lenis": "^1.3.25",
|
"lenis": "^1.3.25",
|
||||||
"lucide-react": "^1.27.0",
|
"lucide-react": "^1.28.0",
|
||||||
"lzma": "^2.3.2",
|
"lzma": "^2.3.2",
|
||||||
|
"motion": "^12.43.0",
|
||||||
"music-metadata": "^11.14.0",
|
"music-metadata": "^11.14.0",
|
||||||
"mysql2": "^3.23.2",
|
"mysql2": "^3.23.2",
|
||||||
"next": "^16.2.12",
|
"next": "^16.2.12",
|
||||||
"next-auth": "5.0.0-beta.32",
|
"next-auth": "5.0.0-beta.32",
|
||||||
"next-intl": "^4.13.4",
|
"next-intl": "^4.13.4",
|
||||||
"next-view-transitions": "^0.3.5",
|
|
||||||
"nodemailer": "^9.0.3",
|
|
||||||
"otplib": "^13.4.1",
|
"otplib": "^13.4.1",
|
||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-hook-form": "^7.83.0",
|
"react-hook-form": "^7.83.0",
|
||||||
"resend": "^6.18.1",
|
"resend": "^6.18.1",
|
||||||
"sanitize-html": "^2.17.6",
|
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"sharp": "^0.35.3",
|
"sharp": "^0.35.3",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
@@ -85,37 +75,26 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.5.6",
|
"@biomejs/biome": "2.5.6",
|
||||||
"@eslint/js": "10.0.1",
|
|
||||||
"@lhci/cli": "^0.15.1",
|
|
||||||
"@next/bundle-analyzer": "^16.2.12",
|
"@next/bundle-analyzer": "^16.2.12",
|
||||||
"@next/eslint-plugin-next": "^16.2.12",
|
|
||||||
"@playwright/test": "1.62.0",
|
|
||||||
"@tailwindcss/forms": "^0.5.11",
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
"@tailwindcss/postcss": "^4.3.3",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"@tailwindcss/typography": "^0.5.20",
|
"@tailwindcss/typography": "^0.5.20",
|
||||||
"@types/bcrypt": "^6.0.0",
|
|
||||||
"@types/node": "^26.1.2",
|
"@types/node": "^26.1.2",
|
||||||
"@types/nodemailer": "^8.0.1",
|
"@types/react": "^19.2.18",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react-dom": "^19.2.4",
|
||||||
"@types/react-dom": "^19.2.3",
|
|
||||||
"@types/sanitize-html": "^2.16.1",
|
|
||||||
"@vitest/coverage-v8": "4.1.10",
|
"@vitest/coverage-v8": "4.1.10",
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"eslint": "10.8.0",
|
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
|
||||||
"eslint-plugin-security": "^4.0.1",
|
|
||||||
"eslint-plugin-unused-imports": "4.4.1",
|
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"knip": "^6.29.0",
|
"knip": "^6.30.0",
|
||||||
|
"lint-staged": "^17.3.0",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"postcss": "^8.5.24",
|
"postcss": "^8.5.25",
|
||||||
"prisma": "^7.9.1",
|
"prisma": "^7.9.1",
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
"tsx": "^4.23.1",
|
"tsx": "^4.23.1",
|
||||||
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
"typescript": "^7.0.2",
|
||||||
"typescript-eslint": "^8.65.0",
|
"vite": "8.2.0",
|
||||||
"vite": "8.1.5",
|
|
||||||
"vitest": "4.1.10"
|
"vitest": "4.1.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { defineConfig, devices } from "@playwright/test";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
testDir: "./e2e",
|
|
||||||
fullyParallel: true,
|
|
||||||
forbidOnly: !!process.env.CI,
|
|
||||||
retries: process.env.CI ? 2 : 0,
|
|
||||||
workers: process.env.CI ? 1 : undefined,
|
|
||||||
reporter: "html",
|
|
||||||
use: {
|
|
||||||
baseURL: process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
|
|
||||||
trace: "on-first-retry",
|
|
||||||
},
|
|
||||||
projects: [
|
|
||||||
{
|
|
||||||
name: "chromium",
|
|
||||||
use: { ...devices["Desktop Chrome"] },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "firefox",
|
|
||||||
use: { ...devices["Desktop Firefox"] },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "webkit",
|
|
||||||
use: { ...devices["Desktop Safari"] },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
webServer: {
|
|
||||||
command: "pnpm dev",
|
|
||||||
url: "http://localhost:3000",
|
|
||||||
reuseExistingServer: !process.env.CI,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Generated
+1025
-3465
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -20,7 +20,7 @@ overrides:
|
|||||||
uuid: "^9.0.1"
|
uuid: "^9.0.1"
|
||||||
fast-uri: "^3.1.3"
|
fast-uri: "^3.1.3"
|
||||||
"@hono/node-server": "^1.19.13"
|
"@hono/node-server": "^1.19.13"
|
||||||
postcss: "^8.5.19"
|
postcss: "^8.5.25"
|
||||||
# Dwingt alle diepe subdependencies (zoals lhci) naar de veilige tmp-versie
|
# Dwingt alle diepe subdependencies (zoals lhci) naar de veilige tmp-versie
|
||||||
tmp: "^0.2.6"
|
tmp: "^0.2.6"
|
||||||
# NIEUWE SECURITY PATCHES:
|
# NIEUWE SECURITY PATCHES:
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const JSON5 = require('json5');
|
||||||
|
|
||||||
|
const exampleFile = process.argv[2];
|
||||||
|
const targetFile = process.argv[3];
|
||||||
|
|
||||||
|
if (!exampleFile || !targetFile) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deepMerge(target, source) {
|
||||||
|
const result = { ...target };
|
||||||
|
for (const key of Object.keys(source)) {
|
||||||
|
if (source[key] !== null && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
||||||
|
result[key] = deepMerge(target[key] || {}, source[key]);
|
||||||
|
} else {
|
||||||
|
if (!(key in result)) {
|
||||||
|
result[key] = source[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
let example;
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(exampleFile, 'utf-8');
|
||||||
|
example = JSON5.parse(content);
|
||||||
|
} catch {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = {};
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(targetFile)) {
|
||||||
|
const content = fs.readFileSync(targetFile, 'utf-8');
|
||||||
|
current = JSON5.parse(content);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
current = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = deepMerge(current, example);
|
||||||
|
|
||||||
|
const isJson5 = targetFile.endsWith('.json5');
|
||||||
|
const output = isJson5 ? JSON5.stringify(merged, null, 4) : JSON.stringify(merged, null, 4);
|
||||||
|
fs.writeFileSync(targetFile, output + '\n');
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
# ===========================================================================
|
|
||||||
# Polaris Emulator — reference config.ini
|
|
||||||
# Copy this to /var/www/emulator/Emulator/target/config.ini and fill in the
|
|
||||||
# values marked CHANGE_ME. The emulator reads this file on startup.
|
|
||||||
# Full guide: https://github.com/duckietm/Complete-Retro-on-Ubuntu
|
|
||||||
# ===========================================================================
|
|
||||||
|
|
||||||
# ---- Database configuration ----
|
|
||||||
db.hostname=127.0.0.1
|
|
||||||
db.port=3306
|
|
||||||
db.database=habbo # DB name (shared with the CMS)
|
|
||||||
db.username=root # Username
|
|
||||||
db.password=CHANGE_ME # Password
|
|
||||||
db.params=?characterEncoding=utf8&useSSL=false&serverTimezone=Europe/Amsterdam
|
|
||||||
db.pool.minsize=25
|
|
||||||
db.pool.maxsize=100
|
|
||||||
db.pool.connection_timeout_ms=10000
|
|
||||||
db.pool.idle_timeout_ms=600000
|
|
||||||
db.pool.max_lifetime_ms=1800000
|
|
||||||
db.pool.validation_timeout_ms=5000
|
|
||||||
db.pool.leak_detection_ms=20000 # set to 0 to disable leak detection
|
|
||||||
|
|
||||||
# ---- Game configuration ----
|
|
||||||
# Host IP. Use 0.0.0.0 in most cases. Use 127.0.0.1 for LAN only.
|
|
||||||
game.host=0.0.0.0
|
|
||||||
game.port=3000
|
|
||||||
|
|
||||||
# ---- RCON configuration ----
|
|
||||||
# Leave host at 127.0.0.1 if the CMS runs on the same server as the emulator.
|
|
||||||
rcon.host=127.0.0.1
|
|
||||||
rcon.port=3001
|
|
||||||
rcon.allowed=127.0.0.1;127.0.0.2
|
|
||||||
|
|
||||||
# ---- Nitro secure runtime assets (disabled by default) ----
|
|
||||||
nitro.secure.assets.enabled=false
|
|
||||||
nitro.secure.api.enabled=false
|
|
||||||
nitro.secure.session_ttl_sec=900
|
|
||||||
nitro.secure.config.root=
|
|
||||||
nitro.secure.gamedata.root=
|
|
||||||
# Set a persistent secret when using Cloudflare or multiple backend requests.
|
|
||||||
nitro.secure.master_key=change-me-to-a-long-random-secret
|
|
||||||
|
|
||||||
# ---- Login ----
|
|
||||||
login.remember.enabled=true
|
|
||||||
login.remember.duration.days=30
|
|
||||||
# Optional: set a persistent remember-me JWT secret here.
|
|
||||||
login.remember.jwt.secret=
|
|
||||||
login.news.limit=5
|
|
||||||
|
|
||||||
# ---- Secure runtime ECDH session TTL in seconds ----
|
|
||||||
# (kept for compatibility; unused when secure assets are disabled)
|
|
||||||
|
|
||||||
# ---- WebSockets (Nitro client) ----
|
|
||||||
ws.enabled=true
|
|
||||||
ws.host=0.0.0.0
|
|
||||||
ws.port=2096
|
|
||||||
# Header used to obtain the real client IP when behind a proxy
|
|
||||||
# (usually X-Forwarded-For, or CF-Connecting-IP behind Cloudflare).
|
|
||||||
ws.ip.header=X-Forwarded-For
|
|
||||||
|
|
||||||
# ---- Console / emulator_settings ----
|
|
||||||
# Run these SQL statements ONCE, after importing the emulator database:
|
|
||||||
# USE habbo;
|
|
||||||
# UPDATE emulator_settings SET `value` = '0' WHERE `key` = 'console.mode';
|
|
||||||
# UPDATE emulator_settings SET `value` = 'MY_DOMAIN.COM,*.MY_DOMAIN.COM,localhost,127.0.0.1' WHERE `key` = 'websockets.whitelist';
|
|
||||||
# console.mode MUST be 0 and the whitelist MUST match your domain.
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# Launcher for the Polaris emulator.
|
|
||||||
# Place at /var/www/emulator/Emulator/target/emulator and chmod +x.
|
|
||||||
# Update the JAR name to match the version built by `mvn clean package`.
|
|
||||||
|
|
||||||
file_name_emulator=emulator.log
|
|
||||||
current_time=$(date "+%H%M_%d-%m-%Y")
|
|
||||||
file_name=$file_name_emulator.$current_time
|
|
||||||
|
|
||||||
# Rotate the previous log
|
|
||||||
mkdir -p /var/log/emu
|
|
||||||
mv /var/log/emu/emulator.log /var/log/emu/$file_name 2>/dev/null || true
|
|
||||||
|
|
||||||
# -Xmx4096m = 4 GB. 1 GB=1024, 2 GB=2048, 3 GB=3072, 4 GB=4096
|
|
||||||
java -Dfile.encoding=UTF8 -Xmx4096m \
|
|
||||||
-jar /var/www/emulator/Emulator/target/Habbo-*-jar-with-dependencies.jar \
|
|
||||||
>/var/log/emu/emulator.log
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Habbo Emulator
|
|
||||||
|
|
||||||
# Make sure the database has started before the emulator can start
|
|
||||||
After=mariadb.service
|
|
||||||
Requires=mariadb.service
|
|
||||||
|
|
||||||
# If you want to run as a less privileged account, change User below.
|
|
||||||
# Make sure that account has the right permissions on the working directory and target folders.
|
|
||||||
[Service]
|
|
||||||
User=root
|
|
||||||
|
|
||||||
# Working directory where config.ini and the JAR live
|
|
||||||
WorkingDirectory=/var/www/emulator/Emulator/target
|
|
||||||
|
|
||||||
# The launcher script we created below. It is a shell wrapper that calls the JAR.
|
|
||||||
ExecStart=/var/www/emulator/Emulator/target/emulator
|
|
||||||
|
|
||||||
SuccessExitStatus=143
|
|
||||||
TimeoutStopSec=10
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=5
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"distObfuscationEnabled": false,
|
|
||||||
"secureAssetsEnabled": false,
|
|
||||||
"secureApiEnabled": false,
|
|
||||||
"apiBaseUrl": "https://MY_DOMAIN:2096",
|
|
||||||
"plainConfigBaseUrl": "https://MY_DOMAIN/configuration/",
|
|
||||||
"plainGamedataBaseUrl": "https://MY_DOMAIN/gamedata/"
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
{
|
|
||||||
"socket.url": "wss://MY_DOMAIN.COM:2096",
|
|
||||||
"crypto.ws.enabled": false,
|
|
||||||
"crypto.ws.signing.enabled": false,
|
|
||||||
"crypto.ws.signing.public_key": "",
|
|
||||||
"api.url": "https://MY_DOMAIN.COM:2096",
|
|
||||||
"asset.url": "https://MY_DOMAIN.COM/gamedata",
|
|
||||||
"image.library.url": "http://MY_DOMAIN.COM/gamedata/c_images/",
|
|
||||||
"hof.furni.url": "https://MY_DOMAIN.COM",
|
|
||||||
"images.url": "${asset.url}/images",
|
|
||||||
"gamedata.url": "${asset.url}",
|
|
||||||
"sounds.url": "${asset.url}/sounds/%sample%.mp3",
|
|
||||||
"external.texts.url": [
|
|
||||||
"${gamedata.url}/config/ExternalTexts.json?v=1",
|
|
||||||
"${gamedata.url}/config/UITexts.json?v=1"
|
|
||||||
],
|
|
||||||
"external.samples.url": "${gamedata.url}/sounds/sound_machine_sample_%sample%.mp3",
|
|
||||||
"furnidata.url": "${gamedata.url}/config/FurnitureData.json?v=1",
|
|
||||||
"productdata.url": "${gamedata.url}/config/ProductData.json?v=1",
|
|
||||||
"avatar.actions.url": "${gamedata.url}/config/HabboAvatarActions.json?v=1",
|
|
||||||
"avatar.figuredata.url": "${gamedata.url}/config/FigureData.json?v=1",
|
|
||||||
"avatar.figuremap.url": "${gamedata.url}/config/FigureMap.json?v=1",
|
|
||||||
"avatar.effectmap.url": "${gamedata.url}/config/EffectMap.json?v=1",
|
|
||||||
"avatar.asset.url": "${asset.url}/clothes/%libname%.nitro",
|
|
||||||
"avatar.asset.effect.url": "${asset.url}/effect/%libname%.nitro",
|
|
||||||
"furni.asset.url": "${asset.url}/furniture/%libname%.nitro",
|
|
||||||
"furni.asset.icon.url": "http://MY_DOMAIN.COM/gamedata/icons/%libname%%param%_icon.png",
|
|
||||||
"pet.asset.url": "${asset.url}/pets/%libname%.nitro",
|
|
||||||
"generic.asset.url": "${asset.url}/bundled/generic/%libname%.nitro",
|
|
||||||
"room.asset.url": "${asset.url}/room/%libname%/%libname%.json",
|
|
||||||
"badge.asset.url": "${image.library.url}album1584/%badgename%.gif",
|
|
||||||
"badge.asset.group.url": "http://MY_DOMAIN.COM/habbo-imaging/badge/%badgedata%",
|
|
||||||
"badge.asset.group.external.url": "",
|
|
||||||
"badge.asset.grouparts.url": "https://MY_DOMAIN.COM/gamedata/badgeparts/badgepart_%part%.png",
|
|
||||||
"furni.rotation.bounce.steps": 20,
|
|
||||||
"furni.rotation.bounce.height": 0.0625,
|
|
||||||
"room.color.skip.transition": false,
|
|
||||||
"enable.avatar.arrow": false,
|
|
||||||
"system.animation.fps": 60,
|
|
||||||
"system.limits.fps": false,
|
|
||||||
"system.dispatcher.log": false,
|
|
||||||
"system.packet.log": false,
|
|
||||||
"system.pong.manually": true,
|
|
||||||
"system.pong.interval.ms": 20000,
|
|
||||||
"room.color.skip.transition": true,
|
|
||||||
"user.badges.group.slot.enabled": true,
|
|
||||||
"timezone.settings": "Europe/Amsterdam",
|
|
||||||
"login.screen.enabled": true,
|
|
||||||
"login.endpoint": "${api.url}/api/auth/login",
|
|
||||||
"login.register.endpoint": "${api.url}/api/auth/register",
|
|
||||||
"login.forgot.endpoint": "${api.url}/api/auth/forgot-password",
|
|
||||||
"login.logout.endpoint": "${api.url}/api/auth/logout",
|
|
||||||
"login.health.endpoint": "${api.url}/api/auth/health",
|
|
||||||
"login.check-email.endpoint": "${api.url}/api/auth/check-email",
|
|
||||||
"login.check-username.endpoint": "${api.url}/api/auth/check-username",
|
|
||||||
"login.room_templates.endpoint": "${api.url}/api/auth/room-templates",
|
|
||||||
"login.remember.endpoint": "${api.url}/api/auth/remember",
|
|
||||||
"login.server_key.endpoint": "${api.url}/api/auth/server-key",
|
|
||||||
"login.sso-token.endpoint": "${api.url}/api/auth/sso-token",
|
|
||||||
"login.refresh.endpoint": "${api.url}/api/auth/refresh",
|
|
||||||
"badges.custom.list.endpoint": "${api.url}/api/badges/custom",
|
|
||||||
"badges.custom.create.endpoint": "${api.url}/api/badges/custom",
|
|
||||||
"badges.custom.update.endpoint": "${api.url}/api/badges/custom/%badgeId%",
|
|
||||||
"badges.custom.delete.endpoint": "${api.url}/api/badges/custom/%badgeId%",
|
|
||||||
"badges.custom.texts.endpoint": "${api.url}/api/badges/custom/texts",
|
|
||||||
"account.change-password.endpoint": "${api.url}/api/auth/change-password",
|
|
||||||
"account.change-email.endpoint": "${api.url}/api/auth/change-email",
|
|
||||||
"account.change-username.endpoint": "${api.url}/api/auth/change-username",
|
|
||||||
"login.health.method": "GET",
|
|
||||||
"login.news.url": "${asset.url}/news/news.json",
|
|
||||||
"login.turnstile.enabled": false,
|
|
||||||
"login.turnstile.sitekey": "",
|
|
||||||
"avatar.mandatory.libraries": ["bd:1", "li:0"],
|
|
||||||
"avatar.mandatory.effect.libraries": ["dance.1", "dance.2", "dance.3", "dance.4"]
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
{
|
|
||||||
"image.library.notifications.url": "${image.library.url}notifications/%image%.png",
|
|
||||||
"achievements.images.url": "${image.library.url}Quests/%image%.png",
|
|
||||||
"camera.url": "https://MY_DOMAIN.COM/camera/photo",
|
|
||||||
"thumbnails.url": "https://MY_DOMAIN.COM/camera/photo/thumb/%thumbnail%.png",
|
|
||||||
"url.prefix": "",
|
|
||||||
"habbopages.url": "/gamedata/habbopages/",
|
|
||||||
"group.homepage.url": "${url.prefix}/groups/%groupid%/id",
|
|
||||||
"guide.help.alpha.groupid": 0,
|
|
||||||
"chat.viewer.height.percentage": 0.4,
|
|
||||||
"widget.dimmer.colorwheel": false,
|
|
||||||
"avatar.wardrobe.max.slots": 10,
|
|
||||||
"user.badges.max.slots": 5,
|
|
||||||
"camera.publish.disabled": false,
|
|
||||||
"hc.disabled": false,
|
|
||||||
"badge.descriptions.enabled": true,
|
|
||||||
"motto.max.length": 38,
|
|
||||||
"bot.name.max.length": 15,
|
|
||||||
"wired.action.bot.talk.to.avatar.max.length": 64,
|
|
||||||
"wired.action.bot.talk.max.length": 64,
|
|
||||||
"wired.action.chat.max.length": 100,
|
|
||||||
"wired.action.kick.from.room.max.length": 100,
|
|
||||||
"wired.action.mute.user.max.length": 100,
|
|
||||||
"game.center.enabled": false,
|
|
||||||
"catalog.style.new": true,
|
|
||||||
"show.google.ads": false,
|
|
||||||
"loginview": {
|
|
||||||
"images": {
|
|
||||||
"background": "${asset.url}/c_images/reception/stretch_blue.png",
|
|
||||||
"background.colour": "#6eadc8",
|
|
||||||
"sun": "${asset.url}/c_images/reception/sun.png",
|
|
||||||
"drape": "${asset.url}/c_images/reception/drape.png",
|
|
||||||
"left": "${asset.url}/c_images/reception/ts.png",
|
|
||||||
"right": "${asset.url}/c_images/reception/US_right.png",
|
|
||||||
"right.repeat": "${asset.url}/c_images/reception/US_top_right.png"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { ActionError } from "@/lib/safe-action-shared";
|
||||||
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
import { createAd, deleteAd } from "./admin-ads";
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||||
|
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
||||||
|
vi.mock("@/lib/prisma", () => ({
|
||||||
|
prisma: { websiteAds: { create: vi.fn(), update: vi.fn(), delete: vi.fn() } },
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
|
||||||
|
vi.mock("@/lib/safe-action", () => ({
|
||||||
|
adminAction: vi.fn((_o: unknown, f: Function) => f),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/safe-action-shared", () => ({
|
||||||
|
ActionError: class extends Error {},
|
||||||
|
actionOk: vi.fn(() => "ok"),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
||||||
|
|
||||||
|
const staff = { id: 1, rank: 7, username: "admin" };
|
||||||
|
const fakeForm = (data: Record<string, string>) => ({
|
||||||
|
get: (k: string) => data[k] ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createAd", () => {
|
||||||
|
it("creates ad and redirects", async () => {
|
||||||
|
vi.mocked(prisma.websiteAds.create).mockResolvedValue({
|
||||||
|
id: BigInt(1),
|
||||||
|
} as never);
|
||||||
|
await createAd(
|
||||||
|
fakeForm({ image: "https://example.com/ad.png" }) as unknown as FormData,
|
||||||
|
);
|
||||||
|
expect(prisma.websiteAds.create).toHaveBeenCalled();
|
||||||
|
expect(logStaffActivity).toHaveBeenCalled();
|
||||||
|
expect(redirect).toHaveBeenCalledWith("/admin/ads");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early when image empty", async () => {
|
||||||
|
await createAd(fakeForm({ image: "" }) as unknown as FormData);
|
||||||
|
expect(prisma.websiteAds.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs error on db failure", async () => {
|
||||||
|
vi.mocked(prisma.websiteAds.create).mockRejectedValue(new Error("db"));
|
||||||
|
await createAd(fakeForm({ image: "x" }) as unknown as FormData);
|
||||||
|
expect(logger.error).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteAd", () => {
|
||||||
|
it("deletes ad and returns ok", async () => {
|
||||||
|
vi.mocked(prisma.websiteAds.delete).mockResolvedValue({} as never);
|
||||||
|
const h = deleteAd as unknown as (ctx: {
|
||||||
|
data: { id: bigint };
|
||||||
|
session: { user: { id: string } };
|
||||||
|
}) => Promise<string>;
|
||||||
|
expect(
|
||||||
|
await h({ data: { id: BigInt(99) }, session: { user: { id: "1" } } }),
|
||||||
|
).toBe("ok");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws ActionError when not found", async () => {
|
||||||
|
vi.mocked(prisma.websiteAds.delete).mockRejectedValue(new Error("nf"));
|
||||||
|
const h = deleteAd as unknown as (ctx: {
|
||||||
|
data: { id: bigint };
|
||||||
|
session: { user: { id: string } };
|
||||||
|
}) => Promise<string>;
|
||||||
|
await expect(
|
||||||
|
h({ data: { id: BigInt(999) }, session: { user: { id: "1" } } }),
|
||||||
|
).rejects.toThrow(ActionError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { rcon } from "@/lib/services/rcon";
|
||||||
|
import { sendHotelAlert } from "./admin-alerts";
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
PERMS: { NOTIFICATIONS_EDIT: "notifications.edit" },
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/rcon", () => ({ rcon: { send: vi.fn() } }));
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
|
||||||
|
const fakeForm = (data: Record<string, string>) => ({
|
||||||
|
get: (key: string) => data[key] ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(requirePermission).mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
rank: 7,
|
||||||
|
username: "admin",
|
||||||
|
} as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sendHotelAlert", () => {
|
||||||
|
it("sends hotel alert and revalidates", async () => {
|
||||||
|
await sendHotelAlert(
|
||||||
|
fakeForm({ message: "Hello!" }) as unknown as FormData,
|
||||||
|
);
|
||||||
|
expect(rcon.send).toHaveBeenCalledWith("hotelalert", { message: "Hello!" });
|
||||||
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/alerts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early when message is empty", async () => {
|
||||||
|
await sendHotelAlert(fakeForm({ message: "" }) as unknown as FormData);
|
||||||
|
expect(rcon.send).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { rcon } from "@/lib/services/rcon";
|
||||||
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
import { createBan, liftBan } from "./admin-bans";
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({ requirePermissionRateLimited: vi.fn() }));
|
||||||
|
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_BAN: "users.ban" } }));
|
||||||
|
vi.mock("@/lib/prisma", () => ({
|
||||||
|
prisma: {
|
||||||
|
user: { findUnique: vi.fn() },
|
||||||
|
ban: { create: vi.fn(), delete: vi.fn() },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/rcon", () => ({ rcon: { disconnectUser: vi.fn() } }));
|
||||||
|
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
|
||||||
|
const staff = { id: 1, rank: 7, username: "admin" };
|
||||||
|
const fakeForm = (data: Record<string, string>) => ({
|
||||||
|
get: (key: string) => data[key] ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(requirePermissionRateLimited).mockResolvedValue(staff as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createBan", () => {
|
||||||
|
it("creates a ban for valid inputs", async () => {
|
||||||
|
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||||
|
username: "baduser",
|
||||||
|
} as never);
|
||||||
|
await createBan(
|
||||||
|
fakeForm({
|
||||||
|
userId: "42",
|
||||||
|
reason: "Spam",
|
||||||
|
hours: "24",
|
||||||
|
type: "account",
|
||||||
|
}) as unknown as FormData,
|
||||||
|
);
|
||||||
|
expect(prisma.ban.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({ userId: 42, type: "account" }),
|
||||||
|
});
|
||||||
|
expect(rcon.disconnectUser).toHaveBeenCalledWith(42, "baduser");
|
||||||
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/bans");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early when userId is invalid", async () => {
|
||||||
|
await createBan(
|
||||||
|
fakeForm({
|
||||||
|
userId: "0",
|
||||||
|
hours: "1",
|
||||||
|
type: "account",
|
||||||
|
}) as unknown as FormData,
|
||||||
|
);
|
||||||
|
expect(prisma.ban.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("liftBan", () => {
|
||||||
|
it("deletes ban and revalidates", async () => {
|
||||||
|
await liftBan(fakeForm({ id: "42" }) as unknown as FormData);
|
||||||
|
expect(prisma.ban.delete).toHaveBeenCalledWith({ where: { id: 42 } });
|
||||||
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/bans");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
import { disbandGuild } from "./admin-guilds";
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({ requirePermissionRateLimited: vi.fn() }));
|
||||||
|
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_EDIT: "users.edit" } }));
|
||||||
|
vi.mock("@/lib/prisma", () => ({
|
||||||
|
prisma: {
|
||||||
|
guilds: { findUnique: vi.fn(), delete: vi.fn() },
|
||||||
|
guildsForumsThreads: { findMany: vi.fn(), deleteMany: vi.fn() },
|
||||||
|
guildsForumsComments: { deleteMany: vi.fn() },
|
||||||
|
guildForumViews: { deleteMany: vi.fn() },
|
||||||
|
guildsMembers: { deleteMany: vi.fn() },
|
||||||
|
rooms: { updateMany: vi.fn() },
|
||||||
|
items: { updateMany: vi.fn() },
|
||||||
|
$transaction: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
|
||||||
|
const staff = { id: 1, rank: 7, username: "admin" };
|
||||||
|
const fakeForm = (data: Record<string, string>) => ({
|
||||||
|
get: (key: string) => data[key] ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(requirePermissionRateLimited).mockResolvedValue(staff as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("disbandGuild", () => {
|
||||||
|
it("disbands guild and cleans related data", async () => {
|
||||||
|
vi.mocked(prisma.guilds.findUnique).mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
name: "TestGuild",
|
||||||
|
userId: 42,
|
||||||
|
} as never);
|
||||||
|
vi.mocked(prisma.guildsForumsThreads.findMany).mockResolvedValue([
|
||||||
|
{ id: 10 },
|
||||||
|
] as never);
|
||||||
|
vi.mocked(prisma.$transaction).mockImplementation(async (fn: unknown) => {
|
||||||
|
const tx = {
|
||||||
|
guildsForumsComments: { deleteMany: vi.fn().mockResolvedValue({}) },
|
||||||
|
guildsForumsThreads: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([{ id: 10 }]),
|
||||||
|
deleteMany: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
guildForumViews: { deleteMany: vi.fn().mockResolvedValue({}) },
|
||||||
|
guildsMembers: { deleteMany: vi.fn().mockResolvedValue({}) },
|
||||||
|
rooms: { updateMany: vi.fn().mockResolvedValue({}) },
|
||||||
|
items: { updateMany: vi.fn().mockResolvedValue({}) },
|
||||||
|
guilds: { delete: vi.fn().mockResolvedValue({}) },
|
||||||
|
} as never;
|
||||||
|
await (fn as (tx: never) => Promise<void>)(tx);
|
||||||
|
});
|
||||||
|
await disbandGuild(fakeForm({ id: "1" }) as unknown as FormData);
|
||||||
|
expect(logStaffActivity).toHaveBeenCalled();
|
||||||
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/guilds");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early when id is not positive", async () => {
|
||||||
|
await disbandGuild(fakeForm({ id: "0" }) as unknown as FormData);
|
||||||
|
expect(prisma.guilds.findUnique).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,12 +25,63 @@ function revalidateHelpCenterTicketPaths(ticketId: bigint) {
|
|||||||
const id = String(ticketId);
|
const id = String(ticketId);
|
||||||
revalidatePath("/admin/help-tickets");
|
revalidatePath("/admin/help-tickets");
|
||||||
revalidatePath(`/admin/help-tickets/${id}`);
|
revalidatePath(`/admin/help-tickets/${id}`);
|
||||||
|
revalidatePath("/mod/help-tickets");
|
||||||
|
revalidatePath(`/mod/help-tickets/${id}`);
|
||||||
revalidatePath("/help/tickets");
|
revalidatePath("/help/tickets");
|
||||||
revalidatePath(`/help/tickets/${id}`);
|
revalidatePath(`/help/tickets/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const liftBanFromHelpTicket = adminAction(
|
||||||
|
{
|
||||||
|
permission: PERMS.USERS_BAN,
|
||||||
|
schema: helpCenterTicketIdSchema,
|
||||||
|
},
|
||||||
|
async (ctx) => {
|
||||||
|
const ticketId = ctx.data.ticketId;
|
||||||
|
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
||||||
|
where: { id: ticketId },
|
||||||
|
select: { id: true, userId: true, open: true, title: true },
|
||||||
|
});
|
||||||
|
if (!ticket) throw new ActionError("Ticket not found");
|
||||||
|
if (ticket.userId == null) {
|
||||||
|
throw new ActionError("Ticket has no requester to unban");
|
||||||
|
}
|
||||||
|
|
||||||
|
const removed = await prisma.ban.deleteMany({
|
||||||
|
where: { userId: ticket.userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
if (ticket.open) {
|
||||||
|
await prisma.websiteHelpCenterTickets.update({
|
||||||
|
where: { id: ticketId },
|
||||||
|
data: { open: false, updatedAt: now },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logAudit({
|
||||||
|
userId: ctx.session.user.id,
|
||||||
|
action: "unban_via_help_ticket",
|
||||||
|
target: "User",
|
||||||
|
targetId: ticket.userId,
|
||||||
|
after: {
|
||||||
|
ticketId: String(ticketId),
|
||||||
|
removedBans: removed.count,
|
||||||
|
title: ticket.title,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidateHelpCenterTicketPaths(ticketId);
|
||||||
|
revalidatePath("/admin/bans");
|
||||||
|
revalidatePath(`/admin/users/show/${ticket.userId}`);
|
||||||
|
return actionOk({ removed: removed.count, userId: ticket.userId });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const HELP_TICKET_EDIT = [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT] as const;
|
||||||
|
|
||||||
export const replyHelpCenterTicket = adminAction(
|
export const replyHelpCenterTicket = adminAction(
|
||||||
{ permission: PERMS.TICKETS_EDIT, schema: replyHelpCenterTicketSchema },
|
{ permission: HELP_TICKET_EDIT, schema: replyHelpCenterTicketSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const ticketId = ctx.data.ticketId;
|
const ticketId = ctx.data.ticketId;
|
||||||
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
||||||
@@ -72,7 +123,7 @@ export const replyHelpCenterTicket = adminAction(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const closeHelpCenterTicket = adminAction(
|
export const closeHelpCenterTicket = adminAction(
|
||||||
{ permission: PERMS.TICKETS_EDIT, schema: helpCenterTicketIdSchema },
|
{ permission: HELP_TICKET_EDIT, schema: helpCenterTicketIdSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const ticketId = ctx.data.ticketId;
|
const ticketId = ctx.data.ticketId;
|
||||||
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
||||||
@@ -104,7 +155,7 @@ export const closeHelpCenterTicket = adminAction(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const reopenHelpCenterTicket = adminAction(
|
export const reopenHelpCenterTicket = adminAction(
|
||||||
{ permission: PERMS.TICKETS_EDIT, schema: helpCenterTicketIdSchema },
|
{ permission: HELP_TICKET_EDIT, schema: helpCenterTicketIdSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const ticketId = ctx.data.ticketId;
|
const ticketId = ctx.data.ticketId;
|
||||||
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
import {
|
||||||
|
createHelpQuestion,
|
||||||
|
deleteHelpQuestion,
|
||||||
|
updateHelpQuestion,
|
||||||
|
} from "./admin-help";
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||||
|
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
||||||
|
vi.mock("@/lib/prisma", () => ({
|
||||||
|
prisma: {
|
||||||
|
websiteHelpCenterCategories: {
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
||||||
|
|
||||||
|
const staff = { id: 1, rank: 7, username: "admin" };
|
||||||
|
const fakeForm = (data: Record<string, string | null>) => ({
|
||||||
|
get: (key: string) => (key in data ? data[key] : null),
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createHelpQuestion", () => {
|
||||||
|
it("creates a help question and redirects", async () => {
|
||||||
|
vi.mocked(prisma.websiteHelpCenterCategories.create).mockResolvedValue({
|
||||||
|
id: BigInt(5),
|
||||||
|
} as never);
|
||||||
|
await createHelpQuestion(
|
||||||
|
fakeForm({
|
||||||
|
name: "FAQ",
|
||||||
|
content: "<p>Answer</p>",
|
||||||
|
}) as unknown as FormData,
|
||||||
|
);
|
||||||
|
expect(prisma.websiteHelpCenterCategories.create).toHaveBeenCalled();
|
||||||
|
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateHelpQuestion", () => {
|
||||||
|
it("updates and redirects", async () => {
|
||||||
|
vi.mocked(prisma.websiteHelpCenterCategories.update).mockResolvedValue(
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
await updateHelpQuestion(
|
||||||
|
fakeForm({
|
||||||
|
id: "42",
|
||||||
|
name: "Updated",
|
||||||
|
content: "New",
|
||||||
|
}) as unknown as FormData,
|
||||||
|
);
|
||||||
|
expect(prisma.websiteHelpCenterCategories.update).toHaveBeenCalled();
|
||||||
|
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteHelpQuestion", () => {
|
||||||
|
it("deletes and redirects", async () => {
|
||||||
|
vi.mocked(prisma.websiteHelpCenterCategories.delete).mockResolvedValue(
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
await deleteHelpQuestion(fakeForm({ id: "42" }) as unknown as FormData);
|
||||||
|
expect(prisma.websiteHelpCenterCategories.delete).toHaveBeenCalled();
|
||||||
|
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import {
|
||||||
|
addBlacklist,
|
||||||
|
addWhitelist,
|
||||||
|
deleteBlacklist,
|
||||||
|
deleteWhitelist,
|
||||||
|
} from "./admin-ip";
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
PERMS: { SETTINGS_EDIT: "settings.edit" },
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/prisma", () => ({
|
||||||
|
prisma: {
|
||||||
|
websiteIpWhitelist: { create: vi.fn(), delete: vi.fn() },
|
||||||
|
websiteIpBlacklist: { create: vi.fn(), delete: vi.fn() },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
|
||||||
|
const staff = { id: 1, rank: 7, username: "admin" };
|
||||||
|
const fakeForm = (data: Record<string, string>) => ({
|
||||||
|
get: (key: string) => data[key] ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("addWhitelist", () => {
|
||||||
|
it("creates whitelist entry", async () => {
|
||||||
|
await addWhitelist(
|
||||||
|
fakeForm({ ipAddress: "192.168.1.1" }) as unknown as FormData,
|
||||||
|
);
|
||||||
|
expect(prisma.websiteIpWhitelist.create).toHaveBeenCalledWith({
|
||||||
|
data: { ipAddress: "192.168.1.1", asn: null, whitelistAsn: false },
|
||||||
|
});
|
||||||
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/ip");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early when ip is empty", async () => {
|
||||||
|
await addWhitelist(fakeForm({ ipAddress: "" }) as unknown as FormData);
|
||||||
|
expect(prisma.websiteIpWhitelist.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteWhitelist", () => {
|
||||||
|
it("deletes whitelist entry", async () => {
|
||||||
|
await deleteWhitelist(fakeForm({ id: "42" }) as unknown as FormData);
|
||||||
|
expect(prisma.websiteIpWhitelist.delete).toHaveBeenCalledWith({
|
||||||
|
where: { id: BigInt(42) },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("addBlacklist", () => {
|
||||||
|
it("creates blacklist entry", async () => {
|
||||||
|
await addBlacklist(
|
||||||
|
fakeForm({ ipAddress: "203.0.113.1" }) as unknown as FormData,
|
||||||
|
);
|
||||||
|
expect(prisma.websiteIpBlacklist.create).toHaveBeenCalledWith({
|
||||||
|
data: { ipAddress: "203.0.113.1", asn: null, blacklistAsn: false },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteBlacklist", () => {
|
||||||
|
it("deletes blacklist entry", async () => {
|
||||||
|
await deleteBlacklist(fakeForm({ id: "99" }) as unknown as FormData);
|
||||||
|
expect(prisma.websiteIpBlacklist.delete).toHaveBeenCalledWith({
|
||||||
|
where: { id: BigInt(99) },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { MEDIA_ROOT, resolveMediaPath } from "@/lib/media-storage";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { deleteMedia, uploadMedia, uploadMediaAndReturn } from "./admin-media";
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||||
|
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
||||||
|
vi.mock("@/lib/media-storage", () => {
|
||||||
|
const root = path.join("/tmp", "nexst-test-media");
|
||||||
|
return {
|
||||||
|
MEDIA_ROOT: root,
|
||||||
|
resolveMediaPath: vi.fn((name: string) => path.join(root, name)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock("node:fs/promises", () => ({
|
||||||
|
mkdir: vi.fn(),
|
||||||
|
writeFile: vi.fn(),
|
||||||
|
unlink: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
|
||||||
|
const staff = { id: 1, rank: 7, username: "admin" };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("uploadMedia", () => {
|
||||||
|
it("returns error when no file provided", async () => {
|
||||||
|
const result = await uploadMedia(new FormData());
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.error).toBe("No file provided");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("uploadMediaAndReturn", () => {
|
||||||
|
it("returns empty string when no file", async () => {
|
||||||
|
expect(await uploadMediaAndReturn(new FormData())).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteMedia", () => {
|
||||||
|
it("deletes media file and revalidates", async () => {
|
||||||
|
await deleteMedia("photo.png");
|
||||||
|
expect(revalidatePath).toHaveBeenCalledWith("/api/media");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips deletion when path is outside media root", async () => {
|
||||||
|
vi.mocked(resolveMediaPath).mockReturnValue("/etc/passwd");
|
||||||
|
await deleteMedia("../../../etc/passwd");
|
||||||
|
const { unlink } = await import("node:fs/promises");
|
||||||
|
expect(unlink).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
ADMIN_NAV_CONFIG_KEY,
|
||||||
|
type AdminNavConfig,
|
||||||
|
serializeAdminNavConfig,
|
||||||
|
} from "@/lib/admin-nav-config";
|
||||||
|
import { actionOk, adminAction } from "@/lib/foundation/action";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
groupOrder: z.array(z.string()),
|
||||||
|
hiddenGroups: z.array(z.string()),
|
||||||
|
hiddenItems: z.array(z.string()),
|
||||||
|
itemOrder: z.record(z.string(), z.array(z.string())),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const saveAdminNavConfig = adminAction(
|
||||||
|
{
|
||||||
|
permission: PERMS.SETTINGS_EDIT,
|
||||||
|
schema,
|
||||||
|
rateLimitKey: "admin-nav-config-save",
|
||||||
|
rateLimitMax: 30,
|
||||||
|
},
|
||||||
|
async (ctx) => {
|
||||||
|
const config: AdminNavConfig = {
|
||||||
|
groupOrder: ctx.data.groupOrder,
|
||||||
|
hiddenGroups: ctx.data.hiddenGroups,
|
||||||
|
hiddenItems: ctx.data.hiddenItems,
|
||||||
|
itemOrder: ctx.data.itemOrder,
|
||||||
|
};
|
||||||
|
await siteSettings.update(
|
||||||
|
ADMIN_NAV_CONFIG_KEY,
|
||||||
|
serializeAdminNavConfig(config),
|
||||||
|
);
|
||||||
|
revalidatePath("/admin", "layout");
|
||||||
|
revalidatePath("/admin/menu");
|
||||||
|
return actionOk({ saved: true });
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { deletePhoto } from "./admin-photos";
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||||
|
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
||||||
|
vi.mock("@/lib/prisma", () => ({ prisma: { cameraWeb: { delete: vi.fn() } } }));
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
|
||||||
|
const fakeForm = (data: Record<string, string>) => ({
|
||||||
|
get: (key: string) => data[key] ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(requirePermission).mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
rank: 7,
|
||||||
|
username: "admin",
|
||||||
|
} as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deletePhoto", () => {
|
||||||
|
it("deletes a photo and revalidates", async () => {
|
||||||
|
await deletePhoto(fakeForm({ id: "42" }) as unknown as FormData);
|
||||||
|
expect(prisma.cameraWeb.delete).toHaveBeenCalledWith({ where: { id: 42 } });
|
||||||
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/photos");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early when id is not positive", async () => {
|
||||||
|
await deletePhoto(fakeForm({ id: "0" }) as unknown as FormData);
|
||||||
|
expect(prisma.cameraWeb.delete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,17 +4,46 @@ import { revalidatePath } from "next/cache";
|
|||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
export async function deletePhoto(formData: FormData): Promise<void> {
|
export async function deletePhoto(formData: FormData): Promise<void> {
|
||||||
await requirePermission(PERMS.PAGES_EDIT);
|
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||||
const id = Number(formData.get("id"));
|
const id = Number(formData.get("id"));
|
||||||
if (!(id > 0)) return;
|
if (!(id > 0)) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.cameraWeb.delete({ where: { id } });
|
await prisma.cameraWeb.delete({ where: { id } });
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "photo_delete",
|
||||||
|
description: `Deleted camera photo #${id}`,
|
||||||
|
targetType: "camera_web",
|
||||||
|
targetId: id,
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Record may have already been removed; ignore.
|
// Record may have already been removed; ignore.
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath("/admin/photos");
|
revalidatePath("/admin/photos");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function bulkDeletePhotos(formData: FormData): Promise<void> {
|
||||||
|
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||||
|
const raw = String(formData.get("ids") ?? "");
|
||||||
|
const ids = raw
|
||||||
|
.split(",")
|
||||||
|
.map((s) => Number(s.trim()))
|
||||||
|
.filter((n) => Number.isFinite(n) && n > 0);
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
|
||||||
|
const result = await prisma.cameraWeb.deleteMany({
|
||||||
|
where: { id: { in: ids } },
|
||||||
|
});
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "photo_bulk_delete",
|
||||||
|
description: `Deleted ${result.count} camera photo(s)`,
|
||||||
|
targetType: "camera_web",
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/photos");
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
import { createTag, deleteTag, updateTag } from "./admin-tags";
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||||
|
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
||||||
|
vi.mock("@/lib/prisma", () => ({
|
||||||
|
prisma: {
|
||||||
|
tags: { create: vi.fn(), update: vi.fn(), delete: vi.fn() },
|
||||||
|
taggables: { deleteMany: vi.fn() },
|
||||||
|
$transaction: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
|
||||||
|
const staff = { id: 1, rank: 7, username: "admin" };
|
||||||
|
const fakeForm = (data: Record<string, string>) => ({
|
||||||
|
get: (key: string) => data[key] ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createTag", () => {
|
||||||
|
it("creates a tag and revalidates", async () => {
|
||||||
|
vi.mocked(prisma.tags.create).mockResolvedValue({ id: BigInt(1) } as never);
|
||||||
|
|
||||||
|
await createTag(
|
||||||
|
fakeForm({
|
||||||
|
name: "News",
|
||||||
|
backgroundColor: "#ff0000",
|
||||||
|
}) as unknown as FormData,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(prisma.tags.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({ name: "News" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(logStaffActivity).toHaveBeenCalled();
|
||||||
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early when name is empty", async () => {
|
||||||
|
await createTag(fakeForm({ name: "" }) as unknown as FormData);
|
||||||
|
expect(prisma.tags.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses default color when not provided", async () => {
|
||||||
|
vi.mocked(prisma.tags.create).mockResolvedValue({ id: BigInt(1) } as never);
|
||||||
|
|
||||||
|
await createTag(fakeForm({ name: "Test" }) as unknown as FormData);
|
||||||
|
|
||||||
|
expect(prisma.tags.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({ backgroundColor: "#888888" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles db error gracefully", async () => {
|
||||||
|
vi.mocked(prisma.tags.create).mockRejectedValue(new Error("DB error"));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
createTag(fakeForm({ name: "News" }) as unknown as FormData),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateTag", () => {
|
||||||
|
it("updates a tag and revalidates", async () => {
|
||||||
|
vi.mocked(prisma.tags.update).mockResolvedValue({} as never);
|
||||||
|
|
||||||
|
await updateTag(
|
||||||
|
fakeForm({
|
||||||
|
id: "42",
|
||||||
|
name: "Updated",
|
||||||
|
backgroundColor: "#00ff00",
|
||||||
|
}) as unknown as FormData,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(prisma.tags.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: BigInt(42) },
|
||||||
|
data: expect.objectContaining({ name: "Updated" }),
|
||||||
|
});
|
||||||
|
expect(logStaffActivity).toHaveBeenCalled();
|
||||||
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early when id is invalid", async () => {
|
||||||
|
await updateTag(fakeForm({ id: "", name: "Test" }) as unknown as FormData);
|
||||||
|
expect(prisma.tags.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early when name is empty after update", async () => {
|
||||||
|
await updateTag(fakeForm({ id: "42", name: "" }) as unknown as FormData);
|
||||||
|
expect(prisma.tags.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteTag", () => {
|
||||||
|
it("deletes a tag and its taggables", async () => {
|
||||||
|
vi.mocked(prisma.taggables.deleteMany).mockResolvedValue({
|
||||||
|
count: 1,
|
||||||
|
} as never);
|
||||||
|
vi.mocked(prisma.tags.delete).mockResolvedValue({} as never);
|
||||||
|
vi.mocked(prisma.$transaction).mockImplementation(async (ops: unknown) => {
|
||||||
|
const arr = ops as [
|
||||||
|
typeof prisma.taggables.deleteMany,
|
||||||
|
typeof prisma.tags.delete,
|
||||||
|
];
|
||||||
|
await arr[0];
|
||||||
|
await arr[1];
|
||||||
|
});
|
||||||
|
|
||||||
|
await deleteTag(fakeForm({ id: "42" }) as unknown as FormData);
|
||||||
|
|
||||||
|
expect(prisma.taggables.deleteMany).toHaveBeenCalledWith({
|
||||||
|
where: { tagId: BigInt(42) },
|
||||||
|
});
|
||||||
|
expect(prisma.tags.delete).toHaveBeenCalledWith({
|
||||||
|
where: { id: BigInt(42) },
|
||||||
|
});
|
||||||
|
expect(logStaffActivity).toHaveBeenCalled();
|
||||||
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early when id is invalid", async () => {
|
||||||
|
await deleteTag(fakeForm({ id: "" }) as unknown as FormData);
|
||||||
|
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { createTeam, deleteTeam } from "./admin-teams";
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||||
|
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_EDIT: "users.edit" } }));
|
||||||
|
vi.mock("@/lib/prisma", () => ({
|
||||||
|
prisma: { websiteTeams: { create: vi.fn(), delete: vi.fn() } },
|
||||||
|
}));
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
|
||||||
|
const staff = { id: 1, rank: 7, username: "admin" };
|
||||||
|
const fakeForm = (data: Record<string, string | null>) => ({
|
||||||
|
get: (key: string) => (key in data ? data[key] : null),
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createTeam", () => {
|
||||||
|
it("creates a team entry", async () => {
|
||||||
|
await createTeam(
|
||||||
|
fakeForm({ rankName: "Moderator" }) as unknown as FormData,
|
||||||
|
);
|
||||||
|
expect(prisma.websiteTeams.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({ rankName: "Moderator" }),
|
||||||
|
});
|
||||||
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/teams");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early when rankName is empty", async () => {
|
||||||
|
await createTeam(fakeForm({ rankName: "" }) as unknown as FormData);
|
||||||
|
expect(prisma.websiteTeams.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteTeam", () => {
|
||||||
|
it("deletes a team entry", async () => {
|
||||||
|
vi.mocked(prisma.websiteTeams.delete).mockResolvedValue({} as never);
|
||||||
|
await deleteTeam(fakeForm({ id: "42" }) as unknown as FormData);
|
||||||
|
expect(prisma.websiteTeams.delete).toHaveBeenCalledWith({
|
||||||
|
where: { id: BigInt(42) },
|
||||||
|
});
|
||||||
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/teams");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
import { saveVpn } from "./admin-vpn";
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
PERMS: { SETTINGS_EDIT: "settings.edit" },
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/prisma", () => ({
|
||||||
|
prisma: { websiteSetting: { upsert: vi.fn() } },
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/site-settings", () => ({
|
||||||
|
siteSettings: { reload: vi.fn() },
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
||||||
|
|
||||||
|
const staff = { id: 1, rank: 7, username: "admin" };
|
||||||
|
const fakeForm = (data: Record<string, string | null>) => ({
|
||||||
|
get: (key: string) => (key in data ? data[key] : null),
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||||
|
vi.mocked(prisma.websiteSetting.upsert).mockResolvedValue({} as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("saveVpn", () => {
|
||||||
|
it("saves VPN settings and redirects", async () => {
|
||||||
|
await saveVpn(
|
||||||
|
fakeForm({
|
||||||
|
vpn_block_enabled: "1",
|
||||||
|
vpn_provider: "proxycheck",
|
||||||
|
vpn_api_key: "abc123",
|
||||||
|
}) as unknown as FormData,
|
||||||
|
);
|
||||||
|
expect(prisma.websiteSetting.upsert).toHaveBeenCalledTimes(4);
|
||||||
|
expect(siteSettings.reload).toHaveBeenCalled();
|
||||||
|
expect(redirect).toHaveBeenCalledWith("/admin/vpn?saved=1");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
|
import { applyStaff, applyTeam } from "./applications";
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth", () => ({ auth: vi.fn() }));
|
||||||
|
vi.mock("@/lib/prisma", () => ({
|
||||||
|
prisma: { websiteStaffApplications: { findFirst: vi.fn(), create: vi.fn() } },
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/rate-limit", () => ({ clientIp: vi.fn(), rateLimit: vi.fn() }));
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
||||||
|
|
||||||
|
const fakeForm = (data: Record<string, string>) => ({
|
||||||
|
get: (key: string) => data[key] ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(auth).mockResolvedValue({ user: { id: "42" } } as never);
|
||||||
|
vi.mocked(clientIp).mockResolvedValue("127.0.0.1");
|
||||||
|
vi.mocked(rateLimit).mockResolvedValue({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("applyStaff", () => {
|
||||||
|
it("submits staff application", async () => {
|
||||||
|
vi.mocked(prisma.websiteStaffApplications.findFirst).mockResolvedValue(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
vi.mocked(prisma.websiteStaffApplications.create).mockResolvedValue(
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
await applyStaff(
|
||||||
|
fakeForm({
|
||||||
|
rankId: "3",
|
||||||
|
content: "I want to help!",
|
||||||
|
}) as unknown as FormData,
|
||||||
|
);
|
||||||
|
expect(prisma.websiteStaffApplications.create).toHaveBeenCalled();
|
||||||
|
expect(redirect).toHaveBeenCalledWith("/apply/staff?submitted=1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects to login when not authenticated", async () => {
|
||||||
|
vi.mocked(auth).mockResolvedValue(null);
|
||||||
|
await applyStaff(fakeForm({}) as unknown as FormData);
|
||||||
|
expect(redirect).toHaveBeenCalledWith("/login");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns duplicate status when application exists", async () => {
|
||||||
|
vi.mocked(prisma.websiteStaffApplications.findFirst).mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
} as never);
|
||||||
|
await applyStaff(
|
||||||
|
fakeForm({
|
||||||
|
rankId: "3",
|
||||||
|
content: "I want to help!",
|
||||||
|
}) as unknown as FormData,
|
||||||
|
);
|
||||||
|
expect(prisma.websiteStaffApplications.create).not.toHaveBeenCalled();
|
||||||
|
expect(redirect).toHaveBeenCalledWith("/apply/staff?error=duplicate");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles rate limit", async () => {
|
||||||
|
vi.mocked(rateLimit).mockResolvedValue({ ok: false });
|
||||||
|
await applyStaff(
|
||||||
|
fakeForm({
|
||||||
|
rankId: "3",
|
||||||
|
content: "I want to help!",
|
||||||
|
}) as unknown as FormData,
|
||||||
|
);
|
||||||
|
expect(redirect).toHaveBeenCalledWith("/apply/staff?error=ratelimit");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("applyTeam", () => {
|
||||||
|
it("submits team application", async () => {
|
||||||
|
vi.mocked(prisma.websiteStaffApplications.findFirst).mockResolvedValue(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
vi.mocked(prisma.websiteStaffApplications.create).mockResolvedValue(
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
await applyTeam(
|
||||||
|
fakeForm({
|
||||||
|
teamId: "2",
|
||||||
|
content: "I want to join team!",
|
||||||
|
}) as unknown as FormData,
|
||||||
|
);
|
||||||
|
expect(prisma.websiteStaffApplications.create).toHaveBeenCalled();
|
||||||
|
expect(redirect).toHaveBeenCalledWith("/apply/team?submitted=1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects to login when not authenticated", async () => {
|
||||||
|
vi.mocked(auth).mockResolvedValue(null);
|
||||||
|
await applyTeam(fakeForm({}) as unknown as FormData);
|
||||||
|
expect(redirect).toHaveBeenCalledWith("/login");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { env } from "@/env";
|
||||||
|
import { checkLogin } from "@/lib/auth/password";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
|
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
|
||||||
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
import { precheckLogin } from "./auth-precheck";
|
||||||
|
|
||||||
|
vi.mock("@/env", () => ({ env: { CONVERT_PASSWORDS: false } }));
|
||||||
|
vi.mock("@/lib/auth/password", () => ({ checkLogin: vi.fn() }));
|
||||||
|
vi.mock("@/lib/prisma", () => ({ prisma: { user: { findUnique: vi.fn() } } }));
|
||||||
|
vi.mock("@/lib/rate-limit", () => ({ clientIp: vi.fn(), rateLimit: vi.fn() }));
|
||||||
|
vi.mock("@/lib/services/captcha", () => ({
|
||||||
|
captchaConfig: vi.fn(),
|
||||||
|
verifyCaptcha: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/site-settings", () => ({
|
||||||
|
siteSettings: { getBool: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(clientIp).mockResolvedValue("1.2.3.4");
|
||||||
|
vi.mocked(rateLimit).mockResolvedValue({ ok: true });
|
||||||
|
vi.mocked(checkLogin).mockResolvedValue({ valid: true } as never);
|
||||||
|
vi.mocked(captchaConfig).mockResolvedValue({ provider: "none" } as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("precheckLogin", () => {
|
||||||
|
it("returns ok for valid login without 2FA", async () => {
|
||||||
|
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||||
|
password: "hash",
|
||||||
|
twoFactorConfirmedAt: null,
|
||||||
|
mail: null,
|
||||||
|
mailVerified: "0",
|
||||||
|
} as never);
|
||||||
|
expect(await precheckLogin("user", "pass")).toBe("ok");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns twofactor when 2FA is set up", async () => {
|
||||||
|
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||||
|
password: "hash",
|
||||||
|
twoFactorConfirmedAt: new Date(),
|
||||||
|
mail: null,
|
||||||
|
mailVerified: "0",
|
||||||
|
} as never);
|
||||||
|
expect(await precheckLogin("user", "pass")).toBe("twofactor");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns invalid for empty inputs", async () => {
|
||||||
|
expect(await precheckLogin("", "")).toBe("invalid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns captcha when captcha required", async () => {
|
||||||
|
vi.mocked(captchaConfig).mockResolvedValue({
|
||||||
|
provider: "hcaptcha",
|
||||||
|
} as never);
|
||||||
|
vi.mocked(verifyCaptcha).mockResolvedValue(false);
|
||||||
|
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||||
|
password: "hash",
|
||||||
|
twoFactorConfirmedAt: null,
|
||||||
|
mail: null,
|
||||||
|
mailVerified: "0",
|
||||||
|
} as never);
|
||||||
|
expect(await precheckLogin("user", "pass", "bad-token")).toBe("captcha");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns invalid when user not found (dummy hash check)", async () => {
|
||||||
|
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||||||
|
const result = await precheckLogin("nonexistent", "pass");
|
||||||
|
expect(result).toBe("invalid");
|
||||||
|
expect(checkLogin).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns unverified when email verification required", async () => {
|
||||||
|
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||||
|
password: "hash",
|
||||||
|
twoFactorConfirmedAt: null,
|
||||||
|
mail: "[email protected]",
|
||||||
|
mailVerified: "0",
|
||||||
|
} as never);
|
||||||
|
vi.mocked(siteSettings.getBool).mockResolvedValue(true);
|
||||||
|
expect(await precheckLogin("user", "pass")).toBe("unverified");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { rcon } from "@/lib/services/rcon";
|
||||||
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
import {
|
||||||
|
bulkBan,
|
||||||
|
bulkGiveBadge,
|
||||||
|
bulkGiveCurrency,
|
||||||
|
bulkUnban,
|
||||||
|
} from "./bulk-users";
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||||
|
vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_EDIT: "users.edit" } }));
|
||||||
|
vi.mock("@/lib/prisma", () => ({
|
||||||
|
prisma: {
|
||||||
|
ban: { deleteMany: vi.fn(), create: vi.fn() },
|
||||||
|
user: { update: vi.fn() },
|
||||||
|
usersCurrency: { upsert: vi.fn() },
|
||||||
|
usersBadges: { findFirst: vi.fn(), aggregate: vi.fn(), create: vi.fn() },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/rcon", () => ({
|
||||||
|
rcon: {
|
||||||
|
giveCredits: vi.fn(),
|
||||||
|
giveDuckets: vi.fn(),
|
||||||
|
givePointsGotw: vi.fn(),
|
||||||
|
giveBadge: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
||||||
|
|
||||||
|
const staff = { id: 1, rank: 7, username: "admin" };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bulkUnban", () => {
|
||||||
|
it("unbans users", async () => {
|
||||||
|
vi.mocked(prisma.ban.deleteMany).mockResolvedValue({ count: 3 } as never);
|
||||||
|
const r = await bulkUnban({ userIds: [1, 2, 3] });
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(r.data).toEqual({ unbanned: 3, total: 3 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bulkBan", () => {
|
||||||
|
it("bans users", async () => {
|
||||||
|
vi.mocked(prisma.ban.create).mockResolvedValue({} as never);
|
||||||
|
const r = await bulkBan({
|
||||||
|
userIds: [1, 2],
|
||||||
|
reason: "Spam",
|
||||||
|
duration: 3600,
|
||||||
|
});
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(r.data.banned).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bulkGiveCurrency", () => {
|
||||||
|
it("gives credits", async () => {
|
||||||
|
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||||
|
const r = await bulkGiveCurrency({
|
||||||
|
userIds: [1],
|
||||||
|
amount: 100,
|
||||||
|
type: "credits",
|
||||||
|
});
|
||||||
|
expect(r.data.given).toBe(1);
|
||||||
|
expect(rcon.giveCredits).toHaveBeenCalledWith(1, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives pixels", async () => {
|
||||||
|
vi.mocked(prisma.usersCurrency.upsert).mockResolvedValue({} as never);
|
||||||
|
const r = await bulkGiveCurrency({
|
||||||
|
userIds: [2],
|
||||||
|
amount: 50,
|
||||||
|
type: "pixels",
|
||||||
|
});
|
||||||
|
expect(r.data.given).toBe(1);
|
||||||
|
expect(rcon.giveDuckets).toHaveBeenCalledWith(2, 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives points", async () => {
|
||||||
|
vi.mocked(prisma.usersCurrency.upsert).mockResolvedValue({} as never);
|
||||||
|
const r = await bulkGiveCurrency({
|
||||||
|
userIds: [3],
|
||||||
|
amount: 25,
|
||||||
|
type: "points",
|
||||||
|
});
|
||||||
|
expect(r.data.given).toBe(1);
|
||||||
|
expect(rcon.givePointsGotw).toHaveBeenCalledWith(3, 25);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bulkGiveBadge", () => {
|
||||||
|
it("gives badge to user", async () => {
|
||||||
|
vi.mocked(prisma.usersBadges.findFirst).mockResolvedValue(null);
|
||||||
|
vi.mocked(prisma.usersBadges.aggregate).mockResolvedValue({
|
||||||
|
_max: { slotId: 5 },
|
||||||
|
} as never);
|
||||||
|
vi.mocked(prisma.usersBadges.create).mockResolvedValue({} as never);
|
||||||
|
const r = await bulkGiveBadge({ userIds: [1], badgeCode: "ADM" });
|
||||||
|
expect(r.data.given).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -181,3 +181,139 @@ export async function bulkGiveBadge({
|
|||||||
data: { given, total: userIds.length, failedIds },
|
data: { given, total: userIds.length, failedIds },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function bulkAdjustCurrency({
|
||||||
|
userIds,
|
||||||
|
amount,
|
||||||
|
type,
|
||||||
|
}: {
|
||||||
|
userIds: number[];
|
||||||
|
/** Positive = give, negative = take. Balances clamped at 0. */
|
||||||
|
amount: number;
|
||||||
|
type: "credits" | "pixels" | "points";
|
||||||
|
}): Promise<
|
||||||
|
ActionResult<{
|
||||||
|
adjusted: number;
|
||||||
|
total: number;
|
||||||
|
failedIds: Array<{ userId: number; reason: string }>;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
const staff = await requirePermission(PERMS.USERS_EDIT);
|
||||||
|
if (!Number.isFinite(amount) || amount === 0) {
|
||||||
|
return { ok: false as const, error: "Amount must be a non-zero number" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (amount > 0) {
|
||||||
|
const given = await bulkGiveCurrency({ userIds, amount, type });
|
||||||
|
if (!given.ok) return given;
|
||||||
|
if (!given.data) {
|
||||||
|
return { ok: false as const, error: "Currency adjustment failed" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true as const,
|
||||||
|
data: {
|
||||||
|
adjusted: given.data.given,
|
||||||
|
total: given.data.total,
|
||||||
|
failedIds: given.data.failedIds,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const take = Math.abs(Math.trunc(amount));
|
||||||
|
let adjusted = 0;
|
||||||
|
const failedIds: Array<{ userId: number; reason: string }> = [];
|
||||||
|
|
||||||
|
for (const userId of userIds) {
|
||||||
|
try {
|
||||||
|
if (type === "credits") {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { credits: true },
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
failedIds.push({ userId, reason: "Not found" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const next = Math.max(0, user.credits - take);
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { credits: next },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const currencyType = type === "pixels" ? 0 : 101;
|
||||||
|
const row = await prisma.usersCurrency.findUnique({
|
||||||
|
where: { userId_type: { userId, type: currencyType } },
|
||||||
|
});
|
||||||
|
const current = row?.amount ?? 0;
|
||||||
|
const next = Math.max(0, current - take);
|
||||||
|
await prisma.usersCurrency.upsert({
|
||||||
|
where: { userId_type: { userId, type: currencyType } },
|
||||||
|
update: { amount: next },
|
||||||
|
create: { userId, type: currencyType, amount: next },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
adjusted++;
|
||||||
|
} catch {
|
||||||
|
failedIds.push({ userId, reason: "Database error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "bulk_adjust_currency",
|
||||||
|
description: `Adjusted ${amount} ${type} for ${adjusted} user(s) (DB-only take; no RCON debit)`,
|
||||||
|
targetType: "user",
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
ok: true as const,
|
||||||
|
data: { adjusted, total: userIds.length, failedIds },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist trade lock on `sanctions.trade_locked_until`.
|
||||||
|
* No first-class RCON trade-lock helper in this CMS — DB only.
|
||||||
|
*/
|
||||||
|
export async function setTradeLock({
|
||||||
|
userId,
|
||||||
|
untilUnix,
|
||||||
|
}: {
|
||||||
|
userId: number;
|
||||||
|
/** Unix seconds; 0 clears the lock. */
|
||||||
|
untilUnix: number;
|
||||||
|
}): Promise<ActionResult<{ userId: number; untilUnix: number }>> {
|
||||||
|
const staff = await requirePermission(PERMS.USERS_EDIT);
|
||||||
|
const until = Math.max(0, Math.trunc(untilUnix));
|
||||||
|
|
||||||
|
const existing = await prisma.sanctions.findFirst({
|
||||||
|
where: { habboId: userId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
await prisma.sanctions.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { tradeLockedUntil: until },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await prisma.sanctions.create({
|
||||||
|
data: {
|
||||||
|
habboId: userId,
|
||||||
|
tradeLockedUntil: until,
|
||||||
|
reason: until > 0 ? "Trade lock (CMS)" : "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: until > 0 ? "trade_lock" : "trade_unlock",
|
||||||
|
description:
|
||||||
|
until > 0
|
||||||
|
? `Trade-locked user #${userId} until ${until}`
|
||||||
|
: `Cleared trade lock for user #${userId}`,
|
||||||
|
targetType: "user",
|
||||||
|
targetId: userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true as const, data: { userId, untilUnix: until } };
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { adminAction } from "@/lib/safe-action";
|
||||||
|
import { actionOk } from "@/lib/safe-action-shared";
|
||||||
|
import { logAudit } from "@/lib/services/audit";
|
||||||
|
import { rcon } from "@/lib/services/rcon";
|
||||||
|
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
PERMS: { SETTINGS_EDIT: "settings.edit" },
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/prisma", () => ({
|
||||||
|
prisma: { emulatorSettings: { upsert: vi.fn() } },
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/safe-action", () => ({
|
||||||
|
adminAction: vi.fn((_opts: unknown, fn: Function) => fn),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/safe-action-shared", () => ({ actionOk: vi.fn(() => "ok") }));
|
||||||
|
vi.mock("@/lib/services/audit", () => ({ logAudit: vi.fn() }));
|
||||||
|
vi.mock("@/lib/services/rcon", () => ({ rcon: { updateConfig: vi.fn() } }));
|
||||||
|
|
||||||
|
describe("saveEmulatorSettings", () => {
|
||||||
|
it("saves settings and calls rcon update", async () => {
|
||||||
|
vi.mocked(prisma.emulatorSettings.upsert).mockResolvedValue({} as never);
|
||||||
|
const handler = (await import("./emulator").then(
|
||||||
|
(m) => m.saveEmulatorSettings,
|
||||||
|
)) as unknown as (ctx: {
|
||||||
|
data: { settings: Record<string, string> };
|
||||||
|
session: { user: { id: string } };
|
||||||
|
}) => Promise<string>;
|
||||||
|
|
||||||
|
const result = await handler({
|
||||||
|
data: { settings: { key1: "val1", key2: "val2" } },
|
||||||
|
session: { user: { id: "1" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prisma.emulatorSettings.upsert).toHaveBeenCalledTimes(2);
|
||||||
|
expect(rcon.updateConfig).toHaveBeenCalled();
|
||||||
|
expect(result).toBe("ok");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -99,12 +99,61 @@ export async function createTicket(formData: FormData): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!moderated) {
|
if (!moderated) {
|
||||||
|
const banAppeal =
|
||||||
|
String(formData.get("banAppeal") ?? "") === "1" ||
|
||||||
|
String(formData.get("banAppeal") ?? "") === "on";
|
||||||
|
let ticketTitle = title;
|
||||||
|
let categoryId: bigint | null = null;
|
||||||
|
|
||||||
|
if (banAppeal) {
|
||||||
|
if (!/ban\s*appeal/i.test(ticketTitle)) {
|
||||||
|
ticketTitle = `[Ban appeal] ${ticketTitle}`.slice(0, 255);
|
||||||
|
}
|
||||||
|
const existing = await prisma.websiteHelpCenterCategories.findFirst(
|
||||||
|
{
|
||||||
|
where: { name: { equals: "Ban appeal" } },
|
||||||
|
select: { id: true },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
categoryId = existing.id;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const created = await prisma.websiteHelpCenterCategories.create(
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
name: "Ban appeal",
|
||||||
|
content:
|
||||||
|
"Appeals for account bans. Staff can lift bans from the ticket.",
|
||||||
|
position: 0,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
categoryId = created.id;
|
||||||
|
} catch {
|
||||||
|
const again =
|
||||||
|
await prisma.websiteHelpCenterCategories.findFirst({
|
||||||
|
where: { name: { equals: "Ban appeal" } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
categoryId = again?.id ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const rawCat = String(formData.get("categoryId") ?? "").trim();
|
||||||
|
if (/^\d+$/.test(rawCat)) {
|
||||||
|
categoryId = BigInt(rawCat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await prisma.websiteHelpCenterTickets.create({
|
await prisma.websiteHelpCenterTickets.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
title,
|
title: ticketTitle,
|
||||||
content,
|
content,
|
||||||
|
categoryId,
|
||||||
open: true,
|
open: true,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Prisma } from "@/generated/prisma/client";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { adminAction } from "@/lib/safe-action";
|
||||||
|
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
|
import { rcon } from "@/lib/services/rcon";
|
||||||
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
|
const ITEMS_BASE_FIELDS = [
|
||||||
|
"publicName",
|
||||||
|
"itemName",
|
||||||
|
"type",
|
||||||
|
"spriteId",
|
||||||
|
"width",
|
||||||
|
"length",
|
||||||
|
"stackHeight",
|
||||||
|
"allowStack",
|
||||||
|
"allowSit",
|
||||||
|
"allowLay",
|
||||||
|
"allowWalk",
|
||||||
|
"allowGift",
|
||||||
|
"allowTrade",
|
||||||
|
"allowRecycle",
|
||||||
|
"allowMarketplaceSell",
|
||||||
|
"allowInventoryStack",
|
||||||
|
"interactionType",
|
||||||
|
"interactionModesCount",
|
||||||
|
"vendingIds",
|
||||||
|
"multiheight",
|
||||||
|
"customparams",
|
||||||
|
"effectIdMale",
|
||||||
|
"effectIdFemale",
|
||||||
|
"clothingOnWalk",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const updateSchema = z.object({
|
||||||
|
id: z.coerce.number().int().positive(),
|
||||||
|
fields: z.record(z.string(), z.unknown()),
|
||||||
|
});
|
||||||
|
|
||||||
|
function pickAllowed(
|
||||||
|
fields: Record<string, unknown>,
|
||||||
|
allowed: readonly string[],
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const key of allowed) {
|
||||||
|
if (Object.hasOwn(fields, key) && fields[key] !== undefined) {
|
||||||
|
out[key] = fields[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateItemsBase = adminAction(
|
||||||
|
{ permission: PERMS.CATALOG_EDIT, schema: updateSchema },
|
||||||
|
async (ctx) => {
|
||||||
|
const { id, fields } = ctx.data;
|
||||||
|
const safe = pickAllowed(fields, ITEMS_BASE_FIELDS);
|
||||||
|
if (Object.keys(safe).length === 0) {
|
||||||
|
throw new ActionError("No valid fields to update");
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.itemsBase.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!existing) throw new ActionError("Item not found");
|
||||||
|
|
||||||
|
// Coerce common numeric / decimal fields from form strings.
|
||||||
|
const data: Record<string, unknown> = { ...safe };
|
||||||
|
for (const key of [
|
||||||
|
"spriteId",
|
||||||
|
"width",
|
||||||
|
"length",
|
||||||
|
"allowStack",
|
||||||
|
"allowSit",
|
||||||
|
"allowLay",
|
||||||
|
"allowWalk",
|
||||||
|
"allowGift",
|
||||||
|
"allowTrade",
|
||||||
|
"allowRecycle",
|
||||||
|
"allowMarketplaceSell",
|
||||||
|
"allowInventoryStack",
|
||||||
|
"interactionModesCount",
|
||||||
|
"effectIdMale",
|
||||||
|
"effectIdFemale",
|
||||||
|
]) {
|
||||||
|
if (data[key] !== undefined) data[key] = Number(data[key]);
|
||||||
|
}
|
||||||
|
if (data.stackHeight !== undefined) {
|
||||||
|
data.stackHeight = new Prisma.Decimal(String(data.stackHeight));
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.itemsBase.update({
|
||||||
|
where: { id },
|
||||||
|
data: data as Prisma.ItemsBaseUpdateInput,
|
||||||
|
});
|
||||||
|
await rcon.updateCatalog().catch(() => false);
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: Number(ctx.session.user.id),
|
||||||
|
action: "items_base_update",
|
||||||
|
description: `Updated items_base #${id}`,
|
||||||
|
targetType: "items_base",
|
||||||
|
targetId: id,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/items");
|
||||||
|
revalidatePath(`/admin/items/${id}`);
|
||||||
|
revalidatePath("/admin/catalog");
|
||||||
|
return actionOk({ id });
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -149,3 +149,96 @@ export const setCmsPermissions = adminAction(
|
|||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-apply the same grant repair as migration 0018:
|
||||||
|
* - ranks with admin.dashboard get all admin.*
|
||||||
|
* - ranks >= 6 get admin.*.view + dashboard
|
||||||
|
* - ranks >= 7 get edit/manage/execute tools used by the sidebar
|
||||||
|
*/
|
||||||
|
export const repairAdminNavAclGrants = adminAction(
|
||||||
|
{ permission: PERMS.PERMISSIONS_MANAGE },
|
||||||
|
async (ctx) => {
|
||||||
|
const dashboardFill = await prisma.$executeRaw`
|
||||||
|
INSERT INTO \`acl_model_permissions\` (\`model_type\`, \`model_id\`, \`permission_id\`)
|
||||||
|
SELECT 'Role', ar.id, ap.id
|
||||||
|
FROM \`acl_roles\` ar
|
||||||
|
JOIN \`acl_permissions\` ap ON ap.slug LIKE 'admin.%'
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM \`acl_model_permissions\` amp
|
||||||
|
JOIN \`acl_permissions\` apdash ON apdash.id = amp.permission_id
|
||||||
|
WHERE amp.model_type = 'Role'
|
||||||
|
AND amp.model_id = ar.id
|
||||||
|
AND apdash.slug = 'admin.dashboard'
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM \`acl_model_permissions\` amp2
|
||||||
|
WHERE amp2.model_type = 'Role'
|
||||||
|
AND amp2.model_id = ar.id
|
||||||
|
AND amp2.permission_id = ap.id
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
const midRankViews = await prisma.$executeRaw`
|
||||||
|
INSERT INTO \`acl_model_permissions\` (\`model_type\`, \`model_id\`, \`permission_id\`)
|
||||||
|
SELECT 'Role', ar.id, ap.id
|
||||||
|
FROM \`permission_ranks\` pr
|
||||||
|
JOIN \`acl_roles\` ar ON ar.slug = CONCAT('rank_', pr.id)
|
||||||
|
JOIN \`acl_permissions\` ap ON (
|
||||||
|
ap.slug = 'admin.dashboard'
|
||||||
|
OR (ap.slug LIKE 'admin.%' AND ap.slug LIKE '%.view')
|
||||||
|
)
|
||||||
|
WHERE pr.id >= 6
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM \`acl_model_permissions\` amp
|
||||||
|
WHERE amp.model_type = 'Role'
|
||||||
|
AND amp.model_id = ar.id
|
||||||
|
AND amp.permission_id = ap.id
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
const highRankTools = await prisma.$executeRaw`
|
||||||
|
INSERT INTO \`acl_model_permissions\` (\`model_type\`, \`model_id\`, \`permission_id\`)
|
||||||
|
SELECT 'Role', ar.id, ap.id
|
||||||
|
FROM \`permission_ranks\` pr
|
||||||
|
JOIN \`acl_roles\` ar ON ar.slug = CONCAT('rank_', pr.id)
|
||||||
|
JOIN \`acl_permissions\` ap ON (
|
||||||
|
(ap.slug LIKE 'admin.%' AND ap.slug LIKE '%.edit')
|
||||||
|
OR ap.slug IN (
|
||||||
|
'admin.permissions.manage',
|
||||||
|
'admin.rcon.execute',
|
||||||
|
'admin.assets.import',
|
||||||
|
'admin.export',
|
||||||
|
'admin.analytics.export',
|
||||||
|
'admin.users.ban',
|
||||||
|
'admin.users.reset_password',
|
||||||
|
'admin.room.delete'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
WHERE pr.id >= 7
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM \`acl_model_permissions\` amp
|
||||||
|
WHERE amp.model_type = 'Role'
|
||||||
|
AND amp.model_id = ar.id
|
||||||
|
AND amp.permission_id = ap.id
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
const inserted =
|
||||||
|
Number(dashboardFill) + Number(midRankViews) + Number(highRankTools);
|
||||||
|
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: ctx.session.user.id,
|
||||||
|
action: "acl_nav_grants_repair",
|
||||||
|
description: `Repaired admin nav ACL grants (${inserted} rows inserted)`,
|
||||||
|
targetType: "acl",
|
||||||
|
targetId: 0,
|
||||||
|
});
|
||||||
|
revalidateTag("permissions", { expire: 0 });
|
||||||
|
return actionOk({ inserted });
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -107,6 +107,17 @@ export default async function HelpTicketsPage({
|
|||||||
maxLength={5000}
|
maxLength={5000}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="checkbox" name="banAppeal" value="1" />
|
||||||
|
{t("banAppealLabel")}
|
||||||
|
</label>
|
||||||
<div>
|
<div>
|
||||||
<button type="submit" className="btn btn-primary">
|
<button type="submit" className="btn btn-primary">
|
||||||
{t("submit")}
|
{t("submit")}
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { EditAdDeleteButton } from "../ads-table";
|
import { EditAdDeleteButton } from "../ads-table";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function EditAd({
|
export default async function EditAd({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { AdsTable } from "./ads-table";
|
import { AdsTable } from "./ads-table";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function AdminAds() {
|
export default async function AdminAds() {
|
||||||
const { session, permissions } = await getAdminContext();
|
const { session, permissions } = await getAdminContext();
|
||||||
if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) {
|
if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { StatsCard } from "@/components/admin/stats-card";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { redisCache } from "@/lib/redis-cache";
|
||||||
|
|
||||||
type DayRow = {
|
type DayRow = {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -192,7 +193,9 @@ export default async function ActivityPage() {
|
|||||||
const locale = await getLocale();
|
const locale = await getLocale();
|
||||||
const t = await getTranslations("pages.admin.analytics.activity");
|
const t = await getTranslations("pages.admin.analytics.activity");
|
||||||
const tRoot = await getTranslations("pages.admin.analytics");
|
const tRoot = await getTranslations("pages.admin.analytics");
|
||||||
const data = await getActivityData(locale);
|
const data = await redisCache(`admin:analytics:activity:${locale}`, 120, () =>
|
||||||
|
getActivityData(locale),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { CurrencyIcon } from "@/components/shared/currency-icon";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { redisCache } from "@/lib/redis-cache";
|
||||||
|
|
||||||
type EconomyData = {
|
type EconomyData = {
|
||||||
totalCredits: number;
|
totalCredits: number;
|
||||||
@@ -172,7 +173,11 @@ export default async function EconomyPage() {
|
|||||||
const locale = await getLocale();
|
const locale = await getLocale();
|
||||||
const t = await getTranslations("pages.admin.analytics.economy");
|
const t = await getTranslations("pages.admin.analytics.economy");
|
||||||
const tRoot = await getTranslations("pages.admin.analytics");
|
const tRoot = await getTranslations("pages.admin.analytics");
|
||||||
const { data, degraded } = await getEconomyData(locale);
|
const { data, degraded } = await redisCache(
|
||||||
|
`admin:analytics:economy:${locale}`,
|
||||||
|
120,
|
||||||
|
() => getEconomyData(locale),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { StatsCard } from "@/components/admin/stats-card";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { redisCache } from "@/lib/redis-cache";
|
||||||
|
|
||||||
type AnalyticsData = {
|
type AnalyticsData = {
|
||||||
totalUsers: number;
|
totalUsers: number;
|
||||||
@@ -202,7 +203,11 @@ export default async function AnalyticsPage() {
|
|||||||
|
|
||||||
const locale = await getLocale();
|
const locale = await getLocale();
|
||||||
const t = await getTranslations("pages.admin.analytics");
|
const t = await getTranslations("pages.admin.analytics");
|
||||||
const { data, degraded } = await getAnalyticsData(locale);
|
const { data, degraded } = await redisCache(
|
||||||
|
`admin:analytics:overview:${locale}`,
|
||||||
|
120,
|
||||||
|
() => getAnalyticsData(locale),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import { formatDate } from "@/lib/format-date";
|
|||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
type ApplicationRow = {
|
type ApplicationRow = {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
userId: number;
|
userId: number;
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function EditArticle({
|
export default async function EditArticle({
|
||||||
params,
|
params,
|
||||||
searchParams,
|
searchParams,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { Newspaper } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||||
import { ArticleCard } from "@/components/admin/article-card";
|
import { ArticleCard } from "@/components/admin/article-card";
|
||||||
import { StatusCard } from "@/components/admin/dashboard";
|
import { StatusCard } from "@/components/admin/dashboard";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -8,8 +10,6 @@ import { formatDate } from "@/lib/format-date";
|
|||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function AdminArticles({
|
export default async function AdminArticles({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
@@ -56,17 +56,18 @@ export default async function AdminArticles({
|
|||||||
: "—";
|
: "—";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<AdminPageShell
|
||||||
<div className="flex justify-end mb-6">
|
icon={Newspaper}
|
||||||
|
title={t("title")}
|
||||||
|
subtitle={t("subtitle")}
|
||||||
|
actions={
|
||||||
<Button variant="default" asChild>
|
<Button variant="default" asChild>
|
||||||
<Link href="/admin/articles/new">{t("newArticle")}</Link>
|
<Link href="/admin/articles/new">{t("newArticle")}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
}
|
||||||
|
>
|
||||||
{error ? (
|
{error ? (
|
||||||
<p className="text-xs text-[var(--admin-error)] dark:text-[var(--admin-error)] mb-4">
|
<p className="text-xs text-[var(--admin-error)] mb-4">Error: {error}</p>
|
||||||
Error: {error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
@@ -83,7 +84,7 @@ export default async function AdminArticles({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="mt-6">
|
<section className="mt-2">
|
||||||
<h2 className="admin-section-title">{t("recentArticles")}</h2>
|
<h2 className="admin-section-title">{t("recentArticles")}</h2>
|
||||||
{articles.length === 0 ? (
|
{articles.length === 0 ? (
|
||||||
<div className="admin-empty">{t("noArticles")}</div>
|
<div className="admin-empty">{t("noArticles")}</div>
|
||||||
@@ -102,6 +103,6 @@ export default async function AdminArticles({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</AdminPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6,8 +6,6 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export const metadata = { title: "Badges" };
|
export const metadata = { title: "Badges" };
|
||||||
|
|
||||||
export default async function AdminBadges({
|
export default async function AdminBadges({
|
||||||
|
|||||||
+106
-104
@@ -1,6 +1,8 @@
|
|||||||
|
import { Ban } from "lucide-react";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { createBan, liftBan } from "@/actions/admin-bans";
|
import { createBan, liftBan } from "@/actions/admin-bans";
|
||||||
|
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||||
import { StatusCard } from "@/components/admin/dashboard";
|
import { StatusCard } from "@/components/admin/dashboard";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { activeBanWhere, unixNow } from "@/lib/bans";
|
import { activeBanWhere, unixNow } from "@/lib/bans";
|
||||||
@@ -8,8 +10,6 @@ import { formatDate } from "@/lib/format-date";
|
|||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function AdminBans() {
|
export default async function AdminBans() {
|
||||||
const { session, permissions } = await getAdminContext();
|
const { session, permissions } = await getAdminContext();
|
||||||
if (!canAccess(permissions, PERMS.BANS_VIEW, session.user.rank)) {
|
if (!canAccess(permissions, PERMS.BANS_VIEW, session.user.rank)) {
|
||||||
@@ -33,112 +33,114 @@ export default async function AdminBans() {
|
|||||||
const accountBans = bans.filter((b) => b.type === "account").length;
|
const accountBans = bans.filter((b) => b.type === "account").length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<AdminPageShell icon={Ban} title={t("title")} subtitle={t("subtitle")}>
|
||||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
<main>
|
||||||
<StatusCard
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
label={t("activeBans")}
|
<StatusCard
|
||||||
value={bans.length}
|
label={t("activeBans")}
|
||||||
state={bans.length > 0 ? "warn" : "ok"}
|
value={bans.length}
|
||||||
icon="🔨"
|
state={bans.length > 0 ? "warn" : "ok"}
|
||||||
/>
|
icon="🔨"
|
||||||
<StatusCard
|
|
||||||
label={t("permanent")}
|
|
||||||
value={permanent}
|
|
||||||
state={permanent > 0 ? "danger" : "neutral"}
|
|
||||||
icon="⛔"
|
|
||||||
/>
|
|
||||||
<StatusCard label={t("accountBans")} value={accountBans} icon="👤" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form action={createBan} className="admin-card mb-6">
|
|
||||||
<h3 className="text-sm font-bold text-[var(--admin-text)] m-0 mb-3">
|
|
||||||
{t("newBan")}
|
|
||||||
</h3>
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
<input
|
|
||||||
name="userId"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
placeholder={t("userIdPlaceholder")}
|
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
<select name="type">
|
<StatusCard
|
||||||
<option value="account">{t("typeAccount")}</option>
|
label={t("permanent")}
|
||||||
<option value="ip">{t("typeIp")}</option>
|
value={permanent}
|
||||||
<option value="machine">{t("typeMachine")}</option>
|
state={permanent > 0 ? "danger" : "neutral"}
|
||||||
<option value="super">{t("typeSuper")}</option>
|
icon="⛔"
|
||||||
</select>
|
|
||||||
<input
|
|
||||||
name="hours"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
placeholder={t("hoursPlaceholder")}
|
|
||||||
/>
|
/>
|
||||||
<input
|
<StatusCard label={t("accountBans")} value={accountBans} icon="👤" />
|
||||||
name="reason"
|
|
||||||
placeholder={t("reasonPlaceholder")}
|
|
||||||
className="flex-1 min-w-[160px]"
|
|
||||||
/>
|
|
||||||
<Button type="submit" variant="destructive">
|
|
||||||
{t("ban")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
|
|
||||||
<section className="mt-6">
|
<form action={createBan} className="admin-card mb-6">
|
||||||
<h2 className="admin-section-title">
|
<h3 className="text-sm font-bold text-[var(--admin-text)] m-0 mb-3">
|
||||||
{t("activeBans")} ({bans.length})
|
{t("newBan")}
|
||||||
</h2>
|
</h3>
|
||||||
{bans.length === 0 ? (
|
<div className="flex gap-2 flex-wrap">
|
||||||
<div className="admin-empty">{t("noBans")}</div>
|
<input
|
||||||
) : (
|
name="userId"
|
||||||
<div className="overflow-x-auto">
|
type="number"
|
||||||
<table>
|
min={1}
|
||||||
<thead>
|
placeholder={t("userIdPlaceholder")}
|
||||||
<tr>
|
required
|
||||||
<th>{t("colUserId")}</th>
|
/>
|
||||||
<th>{t("colType")}</th>
|
<select name="type">
|
||||||
<th>{t("colReason")}</th>
|
<option value="account">{t("typeAccount")}</option>
|
||||||
<th>{t("colExpires")}</th>
|
<option value="ip">{t("typeIp")}</option>
|
||||||
<th />
|
<option value="machine">{t("typeMachine")}</option>
|
||||||
</tr>
|
<option value="super">{t("typeSuper")}</option>
|
||||||
</thead>
|
</select>
|
||||||
<tbody>
|
<input
|
||||||
{bans.map((b) => (
|
name="hours"
|
||||||
<tr key={b.id}>
|
type="number"
|
||||||
<td>{b.userId}</td>
|
min={0}
|
||||||
<td>
|
placeholder={t("hoursPlaceholder")}
|
||||||
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
|
/>
|
||||||
{b.type}
|
<input
|
||||||
</span>
|
name="reason"
|
||||||
</td>
|
placeholder={t("reasonPlaceholder")}
|
||||||
<td>{b.banReason || "—"}</td>
|
className="flex-1 min-w-[160px]"
|
||||||
<td>
|
/>
|
||||||
{b.banExpire === 0 ? (
|
<Button type="submit" variant="destructive">
|
||||||
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-error-subtle)] text-[var(--admin-text)]">
|
{t("ban")}
|
||||||
{t("permanent")}
|
</Button>
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
|
||||||
{formatDate(new Date(b.banExpire * 1000))}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<form action={liftBan}>
|
|
||||||
<input type="hidden" name="id" value={b.id} />
|
|
||||||
<Button type="submit" variant="outline">
|
|
||||||
{t("lift")}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</form>
|
||||||
</section>
|
|
||||||
</main>
|
<section className="mt-6">
|
||||||
|
<h2 className="admin-section-title">
|
||||||
|
{t("activeBans")} ({bans.length})
|
||||||
|
</h2>
|
||||||
|
{bans.length === 0 ? (
|
||||||
|
<div className="admin-empty">{t("noBans")}</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t("colUserId")}</th>
|
||||||
|
<th>{t("colType")}</th>
|
||||||
|
<th>{t("colReason")}</th>
|
||||||
|
<th>{t("colExpires")}</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{bans.map((b) => (
|
||||||
|
<tr key={b.id}>
|
||||||
|
<td>{b.userId}</td>
|
||||||
|
<td>
|
||||||
|
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
|
||||||
|
{b.type}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{b.banReason || "—"}</td>
|
||||||
|
<td>
|
||||||
|
{b.banExpire === 0 ? (
|
||||||
|
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-error-subtle)] text-[var(--admin-text)]">
|
||||||
|
{t("permanent")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||||
|
{formatDate(new Date(b.banExpire * 1000))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<form action={liftBan}>
|
||||||
|
<input type="hidden" name="id" value={b.id} />
|
||||||
|
<Button type="submit" variant="outline">
|
||||||
|
{t("lift")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</AdminPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -5,8 +5,6 @@ import { formatDate } from "@/lib/format-date";
|
|||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
type Campaign = {
|
type Campaign = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import { formatDate } from "@/lib/format-date";
|
|||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
type Campaign = {
|
type Campaign = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import { loadCatalogItemsData } from "@/lib/services/catalog-items-loader";
|
|||||||
import { getAncestors } from "@/lib/services/catalog-tree";
|
import { getAncestors } from "@/lib/services/catalog-tree";
|
||||||
import { CatalogDetailTabs } from "./catalog-detail-tabs";
|
import { CatalogDetailTabs } from "./catalog-detail-tabs";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function CatalogEditPage({
|
export default async function CatalogEditPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import { Store } from "lucide-react";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||||
import { CatalogTree } from "@/components/admin/catalog-tree";
|
import { CatalogTree } from "@/components/admin/catalog-tree";
|
||||||
import { Prisma } from "@/generated/prisma/client";
|
import { Prisma } from "@/generated/prisma/client";
|
||||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||||
@@ -23,6 +26,7 @@ export default async function CatalogPagesPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank);
|
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank);
|
||||||
|
const t = await getTranslations("pages.admin.catalog");
|
||||||
|
|
||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
const sp = new URLSearchParams(params);
|
const sp = new URLSearchParams(params);
|
||||||
@@ -200,50 +204,52 @@ export default async function CatalogPagesPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<AdminPageShell icon={Store} title={t("title")} subtitle={t("subtitle")}>
|
||||||
<div className="flex items-center justify-between">
|
<div className="space-y-6">
|
||||||
<p className="text-muted-foreground">
|
<div className="flex items-center justify-between">
|
||||||
{totalBcPages} BC pages ({enabledBcPages} enabled) ·{" "}
|
<p className="text-muted-foreground">
|
||||||
{totalBcItems} items
|
{totalBcPages} BC pages ({enabledBcPages} enabled) ·{" "}
|
||||||
</p>
|
{totalBcItems} items
|
||||||
<div className="flex items-center gap-2">
|
</p>
|
||||||
<CatalogViewToggle currentView={viewMode} catalogType="bc" />
|
<div className="flex items-center gap-2">
|
||||||
{canEdit && (
|
<CatalogViewToggle currentView={viewMode} catalogType="bc" />
|
||||||
<CatalogManagerDialog
|
{canEdit && (
|
||||||
catalogType="bc"
|
<CatalogManagerDialog
|
||||||
canEdit={canEdit}
|
catalogType="bc"
|
||||||
totals={{
|
canEdit={canEdit}
|
||||||
totalPages: totalBcPages,
|
totals={{
|
||||||
enabledPages: enabledBcPages,
|
totalPages: totalBcPages,
|
||||||
totalItems: totalBcItems,
|
enabledPages: enabledBcPages,
|
||||||
}}
|
totalItems: totalBcItems,
|
||||||
initialRoots={bcRootPages.map((p) => ({
|
}}
|
||||||
id: p.id,
|
initialRoots={bcRootPages.map((p) => ({
|
||||||
caption: p.caption,
|
id: p.id,
|
||||||
iconImage: p.iconImage,
|
caption: p.caption,
|
||||||
enabled: p.enabled,
|
iconImage: p.iconImage,
|
||||||
orderNum: 0,
|
enabled: p.enabled,
|
||||||
}))}
|
orderNum: 0,
|
||||||
/>
|
}))}
|
||||||
)}
|
/>
|
||||||
{canEdit && (
|
)}
|
||||||
<CatalogActions
|
{canEdit && (
|
||||||
catalogType="bc"
|
<CatalogActions
|
||||||
initialParentId={sp.get("newPageParent")}
|
catalogType="bc"
|
||||||
/>
|
initialParentId={sp.get("newPageParent")}
|
||||||
)}
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{viewMode === "tree" ? (
|
||||||
|
<CatalogTree rootPages={bcRootPages} catalogType="bc" />
|
||||||
|
) : (
|
||||||
|
<CatalogTable
|
||||||
|
data={{ rows: bcRows, ...bcPagination }}
|
||||||
|
canEdit={canEdit}
|
||||||
|
catalogType="bc"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{viewMode === "tree" ? (
|
</AdminPageShell>
|
||||||
<CatalogTree rootPages={bcRootPages} catalogType="bc" />
|
|
||||||
) : (
|
|
||||||
<CatalogTable
|
|
||||||
data={{ rows: bcRows, ...bcPagination }}
|
|
||||||
canEdit={canEdit}
|
|
||||||
catalogType="bc"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,43 +402,45 @@ export default async function CatalogPagesPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<AdminPageShell icon={Store} title={t("title")} subtitle={t("subtitle")}>
|
||||||
<div className="flex items-center justify-between">
|
<div className="space-y-6">
|
||||||
<p className="text-muted-foreground">
|
<div className="flex items-center justify-between">
|
||||||
{totalPages} pages ({enabledPages} enabled) · {totalItems}{" "}
|
<p className="text-muted-foreground">
|
||||||
items
|
{totalPages} pages ({enabledPages} enabled) · {totalItems}{" "}
|
||||||
</p>
|
items
|
||||||
<div className="flex items-center gap-2">
|
</p>
|
||||||
<CatalogViewToggle currentView={viewMode} catalogType="normal" />
|
<div className="flex items-center gap-2">
|
||||||
{canEdit && (
|
<CatalogViewToggle currentView={viewMode} catalogType="normal" />
|
||||||
<CatalogManagerDialog
|
{canEdit && (
|
||||||
catalogType="normal"
|
<CatalogManagerDialog
|
||||||
canEdit={canEdit}
|
catalogType="normal"
|
||||||
totals={{
|
canEdit={canEdit}
|
||||||
totalPages,
|
totals={{
|
||||||
enabledPages,
|
totalPages,
|
||||||
totalItems,
|
enabledPages,
|
||||||
}}
|
totalItems,
|
||||||
initialRoots={rootPages.map((p) => ({
|
}}
|
||||||
id: p.id,
|
initialRoots={rootPages.map((p) => ({
|
||||||
caption: p.caption,
|
id: p.id,
|
||||||
iconImage: p.iconImage,
|
caption: p.caption,
|
||||||
enabled: p.enabled,
|
iconImage: p.iconImage,
|
||||||
orderNum: 0,
|
enabled: p.enabled,
|
||||||
}))}
|
orderNum: 0,
|
||||||
/>
|
}))}
|
||||||
)}
|
/>
|
||||||
{canEdit && (
|
)}
|
||||||
<CatalogActions initialParentId={sp.get("newPageParent")} />
|
{canEdit && (
|
||||||
)}
|
<CatalogActions initialParentId={sp.get("newPageParent")} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{viewMode === "tree" ? (
|
{viewMode === "tree" ? (
|
||||||
<CatalogTree rootPages={rootPages} catalogType="normal" />
|
<CatalogTree rootPages={rootPages} catalogType="normal" />
|
||||||
) : (
|
) : (
|
||||||
<CatalogTable data={{ rows, ...pagination }} canEdit={canEdit} />
|
<CatalogTable data={{ rows, ...pagination }} canEdit={canEdit} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</AdminPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,21 +1,22 @@
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
|
import { Terminal } from "lucide-react";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||||
import {
|
import {
|
||||||
DiagnosticRow,
|
DiagnosticRow,
|
||||||
InfoItem,
|
InfoItem,
|
||||||
OnlineUsersWidget,
|
OnlineUsersWidget,
|
||||||
StatusCard,
|
StatusCard,
|
||||||
} from "@/components/admin/dashboard";
|
} from "@/components/admin/dashboard";
|
||||||
|
import { fetchOpsHealth } from "@/lib/admin/ops-health";
|
||||||
|
import { fetchOpsOnlineUsers } from "@/lib/admin/ops-online-users";
|
||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
import { CommandocentrumControls } from "./commandocentrum-controls";
|
import { CommandocentrumControls } from "./commandocentrum-controls";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
function decodeStacktrace(raw: unknown): string {
|
function decodeStacktrace(raw: unknown): string {
|
||||||
if (raw == null) return "";
|
if (raw == null) return "";
|
||||||
try {
|
try {
|
||||||
@@ -45,32 +46,28 @@ export default async function CommandoCentrum() {
|
|||||||
|
|
||||||
const t = await getTranslations("pages.admin.commandocentrum");
|
const t = await getTranslations("pages.admin.commandocentrum");
|
||||||
|
|
||||||
const [
|
const [totalUsers, errors, health, maintenance, activity, onlineRoster] =
|
||||||
onlineCount,
|
await Promise.all([
|
||||||
totalUsers,
|
prisma.user.count().catch(() => -1),
|
||||||
errors,
|
prisma.emulatorErrors
|
||||||
dbOk,
|
.findMany({ orderBy: { timestamp: "desc" }, take: 50 })
|
||||||
emulatorOnline,
|
.catch(() => []),
|
||||||
maintenance,
|
fetchOpsHealth(),
|
||||||
activity,
|
siteSettings.getBool("maintenance_enabled", false),
|
||||||
] = await Promise.all([
|
prisma.staffActivities
|
||||||
prisma.user.count({ where: { online: "1" } }).catch(() => 0),
|
.findMany({ orderBy: { createdAt: "desc" }, take: 12 })
|
||||||
prisma.user.count().catch(() => -1),
|
.catch(
|
||||||
prisma.emulatorErrors
|
() =>
|
||||||
.findMany({ orderBy: { timestamp: "desc" }, take: 50 })
|
[] as Awaited<ReturnType<typeof prisma.staffActivities.findMany>>,
|
||||||
.catch(() => []),
|
),
|
||||||
prisma.websiteSetting
|
fetchOpsOnlineUsers(40),
|
||||||
.count()
|
]);
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false),
|
const dbOk = health.dbOk;
|
||||||
rcon.send("ping", null).catch(() => false),
|
const redisOk = health.redisOk;
|
||||||
siteSettings.getBool("maintenance_enabled", false),
|
const emulatorOnline = health.emulatorOk;
|
||||||
prisma.staffActivities
|
const onlineCount = onlineRoster.count;
|
||||||
.findMany({ orderBy: { createdAt: "desc" }, take: 12 })
|
const onlineUsers = onlineRoster.users;
|
||||||
.catch(
|
|
||||||
() => [] as Awaited<ReturnType<typeof prisma.staffActivities.findMany>>,
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const staffIds = Array.from(
|
const staffIds = Array.from(
|
||||||
new Set(activity.map((a) => Number(a.userId))),
|
new Set(activity.map((a) => Number(a.userId))),
|
||||||
@@ -91,205 +88,236 @@ export default async function CommandoCentrum() {
|
|||||||
const rconSet = !!process.env.RCON_HOST;
|
const rconSet = !!process.env.RCON_HOST;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<AdminPageShell icon={Terminal} title={t("title")} subtitle={t("subtitle")}>
|
||||||
{/* ── Live status ────────────────────────────────────────── */}
|
<main>
|
||||||
<div
|
{/* ── Live status ────────────────────────────────────────── */}
|
||||||
className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6"
|
<div
|
||||||
style={{ marginTop: "1.1rem" }}
|
className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6"
|
||||||
>
|
style={{ marginTop: "1.1rem" }}
|
||||||
<StatusCard
|
>
|
||||||
label={t("playersOnline")}
|
<StatusCard
|
||||||
value={onlineCount}
|
label={t("playersOnline")}
|
||||||
hint={
|
value={onlineCount}
|
||||||
totalUsers >= 0
|
hint={
|
||||||
? t("totalAccounts", { count: totalUsers })
|
totalUsers >= 0
|
||||||
: t("dbUnavailable")
|
? t("totalAccounts", { count: totalUsers })
|
||||||
}
|
: t("dbUnavailable")
|
||||||
state="neutral"
|
}
|
||||||
icon="👤"
|
state="neutral"
|
||||||
/>
|
icon="👤"
|
||||||
<StatusCard
|
/>
|
||||||
label={t("emulator")}
|
<StatusCard
|
||||||
value={emulatorOnline ? t("online") : t("offline")}
|
label={t("emulator")}
|
||||||
hint={emulatorOnline ? t("rconReachable") : t("rconNotResponding")}
|
value={emulatorOnline ? t("online") : t("offline")}
|
||||||
state={emulatorOnline ? "ok" : "danger"}
|
hint={emulatorOnline ? t("rconReachable") : t("rconNotResponding")}
|
||||||
icon="🎮"
|
state={emulatorOnline ? "ok" : "danger"}
|
||||||
/>
|
icon="🎮"
|
||||||
<StatusCard
|
/>
|
||||||
label={t("database")}
|
<StatusCard
|
||||||
value={dbOk ? t("connected") : t("down")}
|
label={t("database")}
|
||||||
hint={dbOk ? t("queriesResponding") : t("cannotReachDb")}
|
value={dbOk ? t("connected") : t("down")}
|
||||||
state={dbOk ? "ok" : "danger"}
|
hint={dbOk ? t("queriesResponding") : t("cannotReachDb")}
|
||||||
icon="🗄️"
|
state={dbOk ? "ok" : "danger"}
|
||||||
/>
|
icon="🗄️"
|
||||||
<StatusCard
|
/>
|
||||||
label={t("emulatorErrors")}
|
<StatusCard
|
||||||
value={errors.length}
|
label={t("emulatorErrors")}
|
||||||
hint={t("last50Logged")}
|
value={errors.length}
|
||||||
state={errors.length === 0 ? "ok" : "warn"}
|
hint={t("last50Logged")}
|
||||||
icon="⚠️"
|
state={errors.length === 0 ? "ok" : "warn"}
|
||||||
/>
|
icon="⚠️"
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── Online users widget ───────────────────────────────── */}
|
{/* ── Online users widget ───────────────────────────────── */}
|
||||||
<section className="admin-card mb-6">
|
<section id="online" className="admin-card mb-6 scroll-mt-4">
|
||||||
<h2 className="admin-section-title">{t("onlineUsers")}</h2>
|
<h2 className="admin-section-title">{t("onlineUsers")}</h2>
|
||||||
<OnlineUsersWidget users={[]} isLoading={false} />
|
<OnlineUsersWidget users={onlineUsers} isLoading={false} />
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ── Server info + diagnostics ──────────────────────────── */}
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<section className="admin-card p-5">
|
|
||||||
<h2 className="admin-section-title">{t("serverInfo")}</h2>
|
|
||||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] gap-2.5">
|
|
||||||
<InfoItem label={t("node")} value={process.version} />
|
|
||||||
<InfoItem
|
|
||||||
label={t("platform")}
|
|
||||||
value={`${process.platform} ${process.arch}`}
|
|
||||||
/>
|
|
||||||
<InfoItem label={t("appUptime")} value={uptime(process.uptime())} />
|
|
||||||
<InfoItem
|
|
||||||
label={t("heapUsed")}
|
|
||||||
value={mb(process.memoryUsage().heapUsed)}
|
|
||||||
/>
|
|
||||||
<InfoItem label={t("rss")} value={mb(process.memoryUsage().rss)} />
|
|
||||||
<InfoItem label={t("hostUptime")} value={uptime(os.uptime())} />
|
|
||||||
<InfoItem
|
|
||||||
label={t("load1m")}
|
|
||||||
value={os.loadavg()[0]?.toFixed(2) ?? "—"}
|
|
||||||
/>
|
|
||||||
<InfoItem label={t("cpus")} value={os.cpus().length} />
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="admin-card p-5">
|
{/* ── Server info + diagnostics ──────────────────────────── */}
|
||||||
<h2 className="admin-section-title">{t("systemDiagnostics")}</h2>
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div className="flex flex-col">
|
<section className="admin-card p-5">
|
||||||
<DiagnosticRow
|
<h2 className="admin-section-title">{t("serverInfo")}</h2>
|
||||||
label={t("database")}
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] gap-2.5">
|
||||||
detail={dbOk ? t("connectionHealthy") : t("unreachable")}
|
<InfoItem label={t("node")} value={process.version} />
|
||||||
state={dbOk ? "ok" : "danger"}
|
<InfoItem
|
||||||
/>
|
label={t("platform")}
|
||||||
<DiagnosticRow
|
value={`${process.platform} ${process.arch}`}
|
||||||
label={t("emulatorRcon")}
|
/>
|
||||||
detail={emulatorOnline ? t("respondingToPing") : t("noResponse")}
|
<InfoItem
|
||||||
state={emulatorOnline ? "ok" : "warn"}
|
label={t("appUptime")}
|
||||||
/>
|
value={uptime(process.uptime())}
|
||||||
<DiagnosticRow
|
/>
|
||||||
label={t("rconConfig")}
|
<InfoItem
|
||||||
detail={rconSet ? t("rconHostSet") : t("rconHostNotConfigured")}
|
label={t("heapUsed")}
|
||||||
state={rconSet ? "ok" : "warn"}
|
value={mb(process.memoryUsage().heapUsed)}
|
||||||
/>
|
/>
|
||||||
<DiagnosticRow
|
<InfoItem
|
||||||
label={t("twoFaEncryptionKey")}
|
label={t("rss")}
|
||||||
detail={appKeySet ? t("appKeyConfigured") : t("appKeyMissing")}
|
value={mb(process.memoryUsage().rss)}
|
||||||
state={appKeySet ? "ok" : "warn"}
|
/>
|
||||||
/>
|
<InfoItem label={t("hostUptime")} value={uptime(os.uptime())} />
|
||||||
<DiagnosticRow
|
<InfoItem
|
||||||
label={t("emailSmtp")}
|
label={t("load1m")}
|
||||||
detail={smtpSet ? t("smtpHostSet") : t("notConfigured")}
|
value={os.loadavg()[0]?.toFixed(2) ?? "—"}
|
||||||
state={smtpSet ? "ok" : "warn"}
|
/>
|
||||||
/>
|
<InfoItem label={t("cpus")} value={os.cpus().length} />
|
||||||
<DiagnosticRow
|
</div>
|
||||||
label={t("maintenanceMode")}
|
</section>
|
||||||
detail={maintenance ? t("siteIsInMaintenance") : t("siteIsLive")}
|
|
||||||
state={maintenance ? "warn" : "ok"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CommandocentrumControls />
|
<section className="admin-card p-5">
|
||||||
|
<h2 className="admin-section-title">{t("systemDiagnostics")}</h2>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<DiagnosticRow
|
||||||
|
label={t("database")}
|
||||||
|
detail={dbOk ? t("connectionHealthy") : t("unreachable")}
|
||||||
|
state={dbOk ? "ok" : "danger"}
|
||||||
|
/>
|
||||||
|
<DiagnosticRow
|
||||||
|
label="Redis"
|
||||||
|
detail={
|
||||||
|
redisOk === true
|
||||||
|
? "PONG"
|
||||||
|
: redisOk === false
|
||||||
|
? "Unreachable"
|
||||||
|
: "REDIS_URL not configured"
|
||||||
|
}
|
||||||
|
state={
|
||||||
|
redisOk === true
|
||||||
|
? "ok"
|
||||||
|
: redisOk === false
|
||||||
|
? "danger"
|
||||||
|
: "warn"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DiagnosticRow
|
||||||
|
label={t("emulatorRcon")}
|
||||||
|
detail={
|
||||||
|
emulatorOnline ? t("respondingToPing") : t("noResponse")
|
||||||
|
}
|
||||||
|
state={emulatorOnline ? "ok" : "warn"}
|
||||||
|
/>
|
||||||
|
<DiagnosticRow
|
||||||
|
label={t("rconConfig")}
|
||||||
|
detail={rconSet ? t("rconHostSet") : t("rconHostNotConfigured")}
|
||||||
|
state={rconSet ? "ok" : "warn"}
|
||||||
|
/>
|
||||||
|
<DiagnosticRow
|
||||||
|
label={t("twoFaEncryptionKey")}
|
||||||
|
detail={appKeySet ? t("appKeyConfigured") : t("appKeyMissing")}
|
||||||
|
state={appKeySet ? "ok" : "warn"}
|
||||||
|
/>
|
||||||
|
<DiagnosticRow
|
||||||
|
label={t("emailSmtp")}
|
||||||
|
detail={smtpSet ? t("smtpHostSet") : t("notConfigured")}
|
||||||
|
state={smtpSet ? "ok" : "warn"}
|
||||||
|
/>
|
||||||
|
<DiagnosticRow
|
||||||
|
label={t("maintenanceMode")}
|
||||||
|
detail={
|
||||||
|
maintenance ? t("siteIsInMaintenance") : t("siteIsLive")
|
||||||
|
}
|
||||||
|
state={maintenance ? "warn" : "ok"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── Staff activity ─────────────────────────────────────── */}
|
<CommandocentrumControls />
|
||||||
<section className="mt-6">
|
|
||||||
<h2 className="admin-section-title">{t("recentStaffActivity")}</h2>
|
{/* ── Staff activity ─────────────────────────────────────── */}
|
||||||
{activity.length === 0 ? (
|
<section className="mt-6">
|
||||||
<div className="admin-empty">{t("noStaffActivity")}</div>
|
<h2 className="admin-section-title">{t("recentStaffActivity")}</h2>
|
||||||
) : (
|
{activity.length === 0 ? (
|
||||||
<div className="admin-card overflow-x-auto">
|
<div className="admin-empty">{t("noStaffActivity")}</div>
|
||||||
<table>
|
) : (
|
||||||
<thead>
|
<div className="admin-card overflow-x-auto">
|
||||||
<tr>
|
<table>
|
||||||
<th>{t("colWhen")}</th>
|
<thead>
|
||||||
<th>{t("colStaff")}</th>
|
<tr>
|
||||||
<th>{t("colAction")}</th>
|
<th>{t("colWhen")}</th>
|
||||||
<th>{t("colDescription")}</th>
|
<th>{t("colStaff")}</th>
|
||||||
</tr>
|
<th>{t("colAction")}</th>
|
||||||
</thead>
|
<th>{t("colDescription")}</th>
|
||||||
<tbody>
|
|
||||||
{activity.map((a) => (
|
|
||||||
<tr key={String(a.id)}>
|
|
||||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
|
||||||
{formatDate(a.createdAt, "datetime-seconds")}
|
|
||||||
</td>
|
|
||||||
<td>{nameById.get(Number(a.userId)) ?? `#${a.userId}`}</td>
|
|
||||||
<td>
|
|
||||||
<span className="inline-block text-[0.6rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
|
|
||||||
{a.action}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="text-sm theme-text-muted dark:theme-text-muted">
|
|
||||||
{a.description}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{activity.map((a) => (
|
||||||
</div>
|
<tr key={String(a.id)}>
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ── Emulator errors ────────────────────────────────────── */}
|
|
||||||
<section className="mt-6">
|
|
||||||
<h2 className="admin-section-title">{t("recentEmulatorErrors")}</h2>
|
|
||||||
{errors.length === 0 ? (
|
|
||||||
<div className="admin-empty">{t("noEmulatorErrors")}</div>
|
|
||||||
) : (
|
|
||||||
<div className="admin-card overflow-x-auto">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>{t("colWhen")}</th>
|
|
||||||
<th>{t("colType")}</th>
|
|
||||||
<th>{t("colVersion")}</th>
|
|
||||||
<th>{t("colStacktrace")}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{errors.map((e) => {
|
|
||||||
const trace = decodeStacktrace(e.stacktrace);
|
|
||||||
const snippet = trace
|
|
||||||
? trace.split("\n").slice(0, 20).join("\n") +
|
|
||||||
(trace.split("\n").length > 20
|
|
||||||
? `\n… ${t("truncated")}`
|
|
||||||
: "")
|
|
||||||
: t("empty");
|
|
||||||
return (
|
|
||||||
<tr key={e.id}>
|
|
||||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||||
{formatDate(
|
{formatDate(a.createdAt, "datetime-seconds")}
|
||||||
e.timestamp ? new Date(e.timestamp * 1000) : null,
|
|
||||||
"datetime-seconds",
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>{e.type}</td>
|
|
||||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
|
||||||
{e.version}
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<pre className="text-xs p-2 bg-[var(--admin-canvas)] text-[var(--color-text-readable)] rounded overflow-auto max-h-[160px]">
|
{nameById.get(Number(a.userId)) ?? `#${a.userId}`}
|
||||||
{snippet}
|
</td>
|
||||||
</pre>
|
<td>
|
||||||
|
<span className="inline-block text-[0.6rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
|
||||||
|
{a.action}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="text-sm theme-text-muted dark:theme-text-muted">
|
||||||
|
{a.description}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
))}
|
||||||
})}
|
</tbody>
|
||||||
</tbody>
|
</table>
|
||||||
</table>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</section>
|
||||||
</section>
|
|
||||||
</main>
|
{/* ── Emulator errors ────────────────────────────────────── */}
|
||||||
|
<section className="mt-6">
|
||||||
|
<h2 className="admin-section-title">{t("recentEmulatorErrors")}</h2>
|
||||||
|
{errors.length === 0 ? (
|
||||||
|
<div className="admin-empty">{t("noEmulatorErrors")}</div>
|
||||||
|
) : (
|
||||||
|
<div className="admin-card overflow-x-auto">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t("colWhen")}</th>
|
||||||
|
<th>{t("colType")}</th>
|
||||||
|
<th>{t("colVersion")}</th>
|
||||||
|
<th>{t("colStacktrace")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{errors.map((e) => {
|
||||||
|
const trace = decodeStacktrace(e.stacktrace);
|
||||||
|
const snippet = trace
|
||||||
|
? trace.split("\n").slice(0, 20).join("\n") +
|
||||||
|
(trace.split("\n").length > 20
|
||||||
|
? `\n… ${t("truncated")}`
|
||||||
|
: "")
|
||||||
|
: t("empty");
|
||||||
|
return (
|
||||||
|
<tr key={e.id}>
|
||||||
|
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||||
|
{formatDate(
|
||||||
|
e.timestamp ? new Date(e.timestamp * 1000) : null,
|
||||||
|
"datetime-seconds",
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>{e.type}</td>
|
||||||
|
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||||
|
{e.version}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<pre className="text-xs p-2 bg-[var(--admin-canvas)] text-[var(--color-text-readable)] rounded overflow-auto max-h-[160px]">
|
||||||
|
{snippet}
|
||||||
|
</pre>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</AdminPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { AlertTriangle, Database, HeartPulse, Server } from "lucide-react";
|
import { AlertTriangle, Database, HeartPulse, Server } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { fetchOpsHealth } from "@/lib/admin/ops-health";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { HealthCheckClient } from "./health-check-client";
|
import { HealthCheckClient } from "./health-check-client";
|
||||||
@@ -11,42 +13,28 @@ async function getDevOpsData() {
|
|||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const dayAgo = now - 86400;
|
const dayAgo = now - 86400;
|
||||||
|
|
||||||
// DB health — simple count query
|
const [health, recentErrors, errorsToday, emuVersion] = await Promise.all([
|
||||||
let dbStatus: "healthy" | "error" = "healthy";
|
fetchOpsHealth(),
|
||||||
let dbLatency = 0;
|
prisma.emulatorErrors.findMany({
|
||||||
try {
|
orderBy: { id: "desc" },
|
||||||
const start = Date.now();
|
take: 20,
|
||||||
await prisma.user.count({ take: 1 });
|
}),
|
||||||
dbLatency = Date.now() - start;
|
prisma.emulatorErrors.count({
|
||||||
} catch {
|
where: { timestamp: { gte: dayAgo } },
|
||||||
dbStatus = "error";
|
}),
|
||||||
}
|
prisma.emulatorSettings.findFirst({
|
||||||
|
where: { key: "version" },
|
||||||
// Recent emulator errors
|
}),
|
||||||
const recentErrors = await prisma.emulatorErrors.findMany({
|
]);
|
||||||
orderBy: { id: "desc" },
|
|
||||||
take: 20,
|
|
||||||
});
|
|
||||||
|
|
||||||
const errorsToday = await prisma.emulatorErrors.count({
|
|
||||||
where: { timestamp: { gte: dayAgo } },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Emulator settings (version info)
|
|
||||||
const emuVersion = await prisma.emulatorSettings.findFirst({
|
|
||||||
where: { key: "version" },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Online user count as proxy for emulator health
|
|
||||||
const onlineUsers = await prisma.user.count({ where: { online: "1" } });
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dbStatus,
|
dbStatus: health.dbOk ? ("healthy" as const) : ("error" as const),
|
||||||
dbLatency,
|
dbLatency: health.dbLatencyMs,
|
||||||
|
redisOk: health.redisOk,
|
||||||
recentErrors,
|
recentErrors,
|
||||||
errorsToday,
|
errorsToday,
|
||||||
emuVersion: emuVersion?.value ?? "Unknown",
|
emuVersion: emuVersion?.value ?? "Unknown",
|
||||||
onlineUsers,
|
onlineUsers: health.onlineUsers,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +50,7 @@ export default async function DevOpsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Status cards */}
|
{/* Status cards */}
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Database</CardTitle>
|
<CardTitle className="text-sm font-medium">Database</CardTitle>
|
||||||
@@ -80,6 +68,33 @@ export default async function DevOpsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Redis</CardTitle>
|
||||||
|
<Server className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
data.redisOk === false
|
||||||
|
? "destructive"
|
||||||
|
: data.redisOk === true
|
||||||
|
? "default"
|
||||||
|
: "secondary"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{data.redisOk === true
|
||||||
|
? "Healthy"
|
||||||
|
: data.redisOk === false
|
||||||
|
? "Error"
|
||||||
|
: "Not configured"}
|
||||||
|
</Badge>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Cache / rate limits / sessions
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Emulator</CardTitle>
|
<CardTitle className="text-sm font-medium">Emulator</CardTitle>
|
||||||
@@ -100,8 +115,13 @@ export default async function DevOpsPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{data.onlineUsers}</div>
|
<div className="text-2xl font-bold">{data.onlineUsers}</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
Current active connections
|
<Link
|
||||||
|
href="/admin/commandocentrum#online"
|
||||||
|
className="text-[var(--admin-accent)] underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
Live roster in CommandoCentrum
|
||||||
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
const TEXTS_LIMIT = 300;
|
const TEXTS_LIMIT = 300;
|
||||||
|
|
||||||
export default async function AdminEmulator() {
|
export default async function AdminEmulator() {
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import { formatDate } from "@/lib/format-date";
|
|||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function AdminGuildDetailPage({
|
export default async function AdminGuildDetailPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
+101
-99
@@ -1,6 +1,8 @@
|
|||||||
|
import { Shield } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||||
import { AdminSimplePager } from "@/components/admin/admin-simple-pager";
|
import { AdminSimplePager } from "@/components/admin/admin-simple-pager";
|
||||||
import { StatusCard } from "@/components/admin/dashboard";
|
import { StatusCard } from "@/components/admin/dashboard";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -9,8 +11,6 @@ import { formatDate } from "@/lib/format-date";
|
|||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function AdminGuildsPage({
|
export default async function AdminGuildsPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
@@ -104,103 +104,105 @@ export default async function AdminGuildsPage({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<AdminPageShell icon={Shield} title={t("title")} subtitle={t("subtitle")}>
|
||||||
{loadError ? (
|
<main>
|
||||||
<div className="mb-4 rounded-lg bg-[var(--admin-error-subtle)] p-3 text-xs text-[var(--admin-error)]">
|
{loadError ? (
|
||||||
{t("loadError")}
|
<div className="mb-4 rounded-lg bg-[var(--admin-error-subtle)] p-3 text-xs text-[var(--admin-error)]">
|
||||||
</div>
|
{t("loadError")}
|
||||||
) : null}
|
|
||||||
|
|
||||||
<form className="mb-4 flex gap-2" method="get">
|
|
||||||
<input
|
|
||||||
type="search"
|
|
||||||
name="search"
|
|
||||||
defaultValue={q}
|
|
||||||
placeholder={t("searchPlaceholder")}
|
|
||||||
className="admin-input flex-1"
|
|
||||||
/>
|
|
||||||
<Button type="submit" variant="default" size="sm">
|
|
||||||
{t("search")}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
|
||||||
<StatusCard label={t("totalGuilds")} value={total} icon="🛡️" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2 className="admin-section-title">
|
|
||||||
{t("title")} ({total})
|
|
||||||
</h2>
|
|
||||||
{rows.length === 0 ? (
|
|
||||||
<div className="admin-empty">{t("empty")}</div>
|
|
||||||
) : (
|
|
||||||
<div className="admin-card overflow-x-auto" style={{ padding: 0 }}>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>{t("colId")}</th>
|
|
||||||
<th>{t("colBadge")}</th>
|
|
||||||
<th>{t("colName")}</th>
|
|
||||||
<th>{t("colOwner")}</th>
|
|
||||||
<th>{t("colMembers")}</th>
|
|
||||||
<th>{t("colCreated")}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{rows.map((g) => (
|
|
||||||
<tr key={g.id}>
|
|
||||||
<td className="muted">{g.id}</td>
|
|
||||||
<td>
|
|
||||||
{g.badge ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
|
||||||
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${g.badge}.gif`}
|
|
||||||
alt=""
|
|
||||||
width={40}
|
|
||||||
height={40}
|
|
||||||
className="h-10 w-10 object-contain"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="muted">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<Link
|
|
||||||
href={`/admin/guilds/${g.id}`}
|
|
||||||
className="font-medium hover:underline"
|
|
||||||
>
|
|
||||||
{g.name || `#${g.id}`}
|
|
||||||
</Link>
|
|
||||||
{g.description ? (
|
|
||||||
<div className="muted text-xs line-clamp-1 max-w-xs">
|
|
||||||
{g.description}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<Link href={`/admin/users/show/${g.userId}`}>
|
|
||||||
{ownerById.get(g.userId) ?? `#${g.userId}`}
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
<td>{membersByGuild.get(g.id) ?? 0}</td>
|
|
||||||
<td className="muted">
|
|
||||||
{formatDate(new Date(g.dateCreated * 1000), "date")}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
<AdminSimplePager
|
|
||||||
page={pagination.page}
|
<form className="mb-4 flex gap-2" method="get">
|
||||||
lastPage={pagination.lastPage}
|
<input
|
||||||
total={total}
|
type="search"
|
||||||
basePath="/admin/guilds"
|
name="search"
|
||||||
params={q ? { search: q } : undefined}
|
defaultValue={q}
|
||||||
/>
|
placeholder={t("searchPlaceholder")}
|
||||||
</section>
|
className="admin-input flex-1"
|
||||||
</main>
|
/>
|
||||||
|
<Button type="submit" variant="default" size="sm">
|
||||||
|
{t("search")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
|
<StatusCard label={t("totalGuilds")} value={total} icon="🛡️" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="admin-section-title">
|
||||||
|
{t("listHeading", { count: total })}
|
||||||
|
</h2>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<div className="admin-empty">{t("empty")}</div>
|
||||||
|
) : (
|
||||||
|
<div className="admin-card overflow-x-auto" style={{ padding: 0 }}>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t("colId")}</th>
|
||||||
|
<th>{t("colBadge")}</th>
|
||||||
|
<th>{t("colName")}</th>
|
||||||
|
<th>{t("colOwner")}</th>
|
||||||
|
<th>{t("colMembers")}</th>
|
||||||
|
<th>{t("colCreated")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((g) => (
|
||||||
|
<tr key={g.id}>
|
||||||
|
<td className="muted">{g.id}</td>
|
||||||
|
<td>
|
||||||
|
{g.badge ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={`https://habbo-stories-content.s3.amazonaws.com/badge-part-images/${g.badge}.gif`}
|
||||||
|
alt=""
|
||||||
|
width={40}
|
||||||
|
height={40}
|
||||||
|
className="h-10 w-10 object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="muted">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Link
|
||||||
|
href={`/admin/guilds/${g.id}`}
|
||||||
|
className="font-medium hover:underline"
|
||||||
|
>
|
||||||
|
{g.name || `#${g.id}`}
|
||||||
|
</Link>
|
||||||
|
{g.description ? (
|
||||||
|
<div className="muted text-xs line-clamp-1 max-w-xs">
|
||||||
|
{g.description}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Link href={`/admin/users/show/${g.userId}`}>
|
||||||
|
{ownerById.get(g.userId) ?? `#${g.userId}`}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td>{membersByGuild.get(g.id) ?? 0}</td>
|
||||||
|
<td className="muted">
|
||||||
|
{formatDate(new Date(g.dateCreated * 1000), "date")}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<AdminSimplePager
|
||||||
|
page={pagination.page}
|
||||||
|
lastPage={pagination.lastPage}
|
||||||
|
total={total}
|
||||||
|
basePath="/admin/guilds"
|
||||||
|
params={q ? { search: q } : undefined}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</AdminPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6,8 +6,6 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function EditHelpQuestion({
|
export default async function EditHelpQuestion({
|
||||||
params,
|
params,
|
||||||
searchParams,
|
searchParams,
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function AdminHelpQuestions() {
|
export default async function AdminHelpQuestions() {
|
||||||
const { session, permissions } = await getAdminContext();
|
const { session, permissions } = await getAdminContext();
|
||||||
if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) {
|
if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useTranslations } from "next-intl";
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
closeHelpCenterTicket,
|
closeHelpCenterTicket,
|
||||||
|
liftBanFromHelpTicket,
|
||||||
reopenHelpCenterTicket,
|
reopenHelpCenterTicket,
|
||||||
replyHelpCenterTicket,
|
replyHelpCenterTicket,
|
||||||
} from "@/actions/admin-help-tickets";
|
} from "@/actions/admin-help-tickets";
|
||||||
@@ -30,6 +31,7 @@ interface HelpTicketInfo {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
categoryName?: string | null;
|
||||||
creator: {
|
creator: {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -42,10 +44,20 @@ export function AdminHelpTicketDetail({
|
|||||||
ticket,
|
ticket,
|
||||||
messages: initialMessages,
|
messages: initialMessages,
|
||||||
canEdit,
|
canEdit,
|
||||||
|
canLiftBan = false,
|
||||||
|
hasActiveBan = false,
|
||||||
|
listHref = "/admin/help-tickets",
|
||||||
|
hideRequesterContact = false,
|
||||||
}: {
|
}: {
|
||||||
ticket: HelpTicketInfo;
|
ticket: HelpTicketInfo;
|
||||||
messages: ThreadMessage[];
|
messages: ThreadMessage[];
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
|
/** Staff may lift requester bans (USERS_BAN). */
|
||||||
|
canLiftBan?: boolean;
|
||||||
|
hasActiveBan?: boolean;
|
||||||
|
listHref?: string;
|
||||||
|
/** Hide email/IP-style contact for mod-lite surfaces. */
|
||||||
|
hideRequesterContact?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("pages.admin.helpTickets");
|
const t = useTranslations("pages.admin.helpTickets");
|
||||||
const [messages, setMessages] = useState(initialMessages);
|
const [messages, setMessages] = useState(initialMessages);
|
||||||
@@ -89,10 +101,17 @@ export function AdminHelpTicketDetail({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleLiftBan() {
|
||||||
|
if (!ticket.creator || isPending) return;
|
||||||
|
run(() => liftBanFromHelpTicket({ ticketId: ticket.id }), {
|
||||||
|
successMessage: t("success.banLifted"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<Link href="/admin/help-tickets">
|
<Link href={listHref}>
|
||||||
<Button variant="ghost" size="icon" aria-label={t("backToList")}>
|
<Button variant="ghost" size="icon" aria-label={t("backToList")}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -105,6 +124,12 @@ export function AdminHelpTicketDetail({
|
|||||||
<Badge variant={ticket.open ? "default" : "secondary"}>
|
<Badge variant={ticket.open ? "default" : "secondary"}>
|
||||||
{ticket.open ? t("statusOpen") : t("statusClosed")}
|
{ticket.open ? t("statusOpen") : t("statusClosed")}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{ticket.categoryName ? (
|
||||||
|
<Badge variant="outline">{ticket.categoryName}</Badge>
|
||||||
|
) : null}
|
||||||
|
{hasActiveBan ? (
|
||||||
|
<Badge variant="destructive">{t("requesterBanned")}</Badge>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold">{ticket.title}</h1>
|
<h1 className="text-2xl font-bold">{ticket.title}</h1>
|
||||||
</div>
|
</div>
|
||||||
@@ -203,13 +228,24 @@ export function AdminHelpTicketDetail({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{canEdit && (
|
{(canEdit || (canLiftBan && hasActiveBan)) && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm">{t("actionsTitle")}</CardTitle>
|
<CardTitle className="text-sm">{t("actionsTitle")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-2">
|
||||||
{ticket.open ? (
|
{canLiftBan && hasActiveBan && ticket.creator ? (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={handleLiftBan}
|
||||||
|
>
|
||||||
|
{t("liftBanSubmit")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{canEdit && ticket.open ? (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -219,7 +255,8 @@ export function AdminHelpTicketDetail({
|
|||||||
>
|
>
|
||||||
{t("closeSubmit")}
|
{t("closeSubmit")}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : null}
|
||||||
|
{canEdit && !ticket.open ? (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -229,7 +266,7 @@ export function AdminHelpTicketDetail({
|
|||||||
>
|
>
|
||||||
{t("reopenSubmit")}
|
{t("reopenSubmit")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -241,7 +278,11 @@ export function AdminHelpTicketDetail({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-2">
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/users/show/${ticket.creator.id}`}
|
href={
|
||||||
|
hideRequesterContact
|
||||||
|
? `/u/${encodeURIComponent(ticket.creator.username)}`
|
||||||
|
: `/admin/users/show/${ticket.creator.id}`
|
||||||
|
}
|
||||||
className="flex items-center gap-2 text-sm font-medium hover:text-primary"
|
className="flex items-center gap-2 text-sm font-medium hover:text-primary"
|
||||||
>
|
>
|
||||||
<User className="h-3.5 w-3.5" />
|
<User className="h-3.5 w-3.5" />
|
||||||
@@ -250,7 +291,7 @@ export function AdminHelpTicketDetail({
|
|||||||
{t("rankLabel", { rank: ticket.creator.rank })}
|
{t("rankLabel", { rank: ticket.creator.rank })}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Link>
|
</Link>
|
||||||
{ticket.creator.mail ? (
|
{!hideRequesterContact && ticket.creator.mail ? (
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<Mail className="h-3 w-3" />
|
<Mail className="h-3 w-3" />
|
||||||
{ticket.creator.mail}
|
{ticket.creator.mail}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
|
||||||
|
import { getMinStaffRank } from "@/lib/admin/min-staff-rank";
|
||||||
import { positiveBigInt } from "@/lib/api";
|
import { positiveBigInt } from "@/lib/api";
|
||||||
|
import { activeBanWhere } from "@/lib/bans";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { AdminHelpTicketDetail } from "./admin-help-ticket-detail";
|
import { AdminHelpTicketDetail } from "./admin-help-ticket-detail";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
const STAFF_RANK_THRESHOLD = 4;
|
|
||||||
|
|
||||||
export default async function AdminHelpTicketDetailPage({
|
export default async function AdminHelpTicketDetailPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
@@ -18,6 +18,7 @@ export default async function AdminHelpTicketDetailPage({
|
|||||||
redirect("/admin");
|
redirect("/admin");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const t = await getTranslations("pages.admin.helpTickets");
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const ticketId = positiveBigInt(id);
|
const ticketId = positiveBigInt(id);
|
||||||
if (!ticketId) notFound();
|
if (!ticketId) notFound();
|
||||||
@@ -27,6 +28,7 @@ export default async function AdminHelpTicketDetailPage({
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
userId: true,
|
userId: true,
|
||||||
|
categoryId: true,
|
||||||
title: true,
|
title: true,
|
||||||
content: true,
|
content: true,
|
||||||
open: true,
|
open: true,
|
||||||
@@ -55,9 +57,9 @@ export default async function AdminHelpTicketDetailPage({
|
|||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
|
|
||||||
const users =
|
const [users, category, activeBan] = await Promise.all([
|
||||||
authorIds.length > 0
|
authorIds.length > 0
|
||||||
? await prisma.user.findMany({
|
? prisma.user.findMany({
|
||||||
where: { id: { in: authorIds } },
|
where: { id: { in: authorIds } },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -66,10 +68,24 @@ export default async function AdminHelpTicketDetailPage({
|
|||||||
mail: true,
|
mail: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
: [];
|
: Promise.resolve([]),
|
||||||
|
ticket.categoryId != null
|
||||||
|
? prisma.websiteHelpCenterCategories.findUnique({
|
||||||
|
where: { id: ticket.categoryId },
|
||||||
|
select: { name: true },
|
||||||
|
})
|
||||||
|
: Promise.resolve(null),
|
||||||
|
ticket.userId != null
|
||||||
|
? prisma.ban.findFirst({
|
||||||
|
where: { userId: ticket.userId, ...activeBanWhere() },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
: Promise.resolve(null),
|
||||||
|
]);
|
||||||
|
|
||||||
const userById = new Map(users.map((u) => [u.id, u]));
|
const userById = new Map(users.map((u) => [u.id, u]));
|
||||||
const openerId = ticket.userId;
|
const openerId = ticket.userId;
|
||||||
|
const minStaffRank = await getMinStaffRank();
|
||||||
|
|
||||||
const messages = [
|
const messages = [
|
||||||
{
|
{
|
||||||
@@ -86,7 +102,7 @@ export default async function AdminHelpTicketDetailPage({
|
|||||||
...replies.map((r) => {
|
...replies.map((r) => {
|
||||||
const user = userById.get(r.userId);
|
const user = userById.get(r.userId);
|
||||||
const isStaff =
|
const isStaff =
|
||||||
r.userId !== openerId && (user?.rank ?? 0) >= STAFF_RANK_THRESHOLD;
|
r.userId !== openerId && (user?.rank ?? 0) >= minStaffRank;
|
||||||
return {
|
return {
|
||||||
key: String(r.id),
|
key: String(r.id),
|
||||||
userId: r.userId,
|
userId: r.userId,
|
||||||
@@ -101,26 +117,40 @@ export default async function AdminHelpTicketDetailPage({
|
|||||||
const creator = openerId != null ? userById.get(openerId) : undefined;
|
const creator = openerId != null ? userById.get(openerId) : undefined;
|
||||||
|
|
||||||
const canEdit = canAccess(permissions, PERMS.TICKETS_EDIT, session.user.rank);
|
const canEdit = canAccess(permissions, PERMS.TICKETS_EDIT, session.user.rank);
|
||||||
|
const canLiftBan = canAccess(permissions, PERMS.USERS_BAN, session.user.rank);
|
||||||
|
const isBanAppeal =
|
||||||
|
/ban\s*appeal/i.test(ticket.title) ||
|
||||||
|
/ban\s*appeal/i.test(category?.name ?? "");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminHelpTicketDetail
|
<div className="space-y-6">
|
||||||
ticket={{
|
<TicketQueueBanner
|
||||||
id: String(ticket.id),
|
hint={t("queueHint")}
|
||||||
title: ticket.title,
|
otherHref="/admin/tickets"
|
||||||
open: ticket.open,
|
otherLabel={t("queueOtherCta")}
|
||||||
createdAt: (ticket.createdAt ?? new Date()).toISOString(),
|
/>
|
||||||
updatedAt: (ticket.updatedAt ?? new Date()).toISOString(),
|
<AdminHelpTicketDetail
|
||||||
creator: creator
|
ticket={{
|
||||||
? {
|
id: String(ticket.id),
|
||||||
id: creator.id,
|
title: ticket.title,
|
||||||
username: creator.username,
|
open: ticket.open,
|
||||||
rank: creator.rank,
|
createdAt: (ticket.createdAt ?? new Date()).toISOString(),
|
||||||
mail: creator.mail ?? "",
|
updatedAt: (ticket.updatedAt ?? new Date()).toISOString(),
|
||||||
}
|
categoryName: category?.name ?? (isBanAppeal ? "Ban appeal" : null),
|
||||||
: null,
|
creator: creator
|
||||||
}}
|
? {
|
||||||
messages={messages}
|
id: creator.id,
|
||||||
canEdit={canEdit}
|
username: creator.username,
|
||||||
/>
|
rank: creator.rank,
|
||||||
|
mail: creator.mail ?? "",
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}}
|
||||||
|
messages={messages}
|
||||||
|
canEdit={canEdit}
|
||||||
|
canLiftBan={canLiftBan}
|
||||||
|
hasActiveBan={!!activeBan}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -19,8 +19,10 @@ export interface HelpTicketRow {
|
|||||||
|
|
||||||
export function HelpTicketsTable({
|
export function HelpTicketsTable({
|
||||||
data,
|
data,
|
||||||
|
basePath = "/admin/help-tickets",
|
||||||
}: {
|
}: {
|
||||||
data: PaginatedResult<HelpTicketRow>;
|
data: PaginatedResult<HelpTicketRow>;
|
||||||
|
basePath?: string;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("pages.admin.helpTickets");
|
const t = useTranslations("pages.admin.helpTickets");
|
||||||
|
|
||||||
@@ -36,7 +38,7 @@ export function HelpTicketsTable({
|
|||||||
#{row.id}
|
#{row.id}
|
||||||
</span>
|
</span>
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/help-tickets/${row.id}`}
|
href={`${basePath}/${row.id}`}
|
||||||
className="font-medium truncate hover:underline"
|
className="font-medium truncate hover:underline"
|
||||||
>
|
>
|
||||||
{row.title}
|
{row.title}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { StatusCard } from "@/components/admin/dashboard";
|
import { StatusCard } from "@/components/admin/dashboard";
|
||||||
|
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
|
||||||
import type { Prisma } from "@/generated/prisma/client";
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
|
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
|
||||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { type HelpTicketRow, HelpTicketsTable } from "./help-tickets-table";
|
import { type HelpTicketRow, HelpTicketsTable } from "./help-tickets-table";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function AdminHelpTicketsPage({
|
export default async function AdminHelpTicketsPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
@@ -81,7 +81,7 @@ export default async function AdminHelpTicketsPage({
|
|||||||
[orderField]: parsed.order,
|
[orderField]: parsed.order,
|
||||||
} as Prisma.WebsiteHelpCenterTicketsOrderByWithRelationInput;
|
} as Prisma.WebsiteHelpCenterTicketsOrderByWithRelationInput;
|
||||||
|
|
||||||
const [total, openCount, closedCount] = await Promise.all([
|
const [total, openCount, closedCount, queues] = await Promise.all([
|
||||||
prisma.websiteHelpCenterTickets.count({ where }).catch(() => 0),
|
prisma.websiteHelpCenterTickets.count({ where }).catch(() => 0),
|
||||||
prisma.websiteHelpCenterTickets
|
prisma.websiteHelpCenterTickets
|
||||||
.count({ where: { open: true } })
|
.count({ where: { open: true } })
|
||||||
@@ -89,6 +89,7 @@ export default async function AdminHelpTicketsPage({
|
|||||||
prisma.websiteHelpCenterTickets
|
prisma.websiteHelpCenterTickets
|
||||||
.count({ where: { open: false } })
|
.count({ where: { open: false } })
|
||||||
.catch(() => 0),
|
.catch(() => 0),
|
||||||
|
fetchTicketQueueOpenCounts(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const pagination = calcPagination(total, parsed.page, parsed.perPage);
|
const pagination = calcPagination(total, parsed.page, parsed.perPage);
|
||||||
@@ -160,6 +161,15 @@ export default async function AdminHelpTicketsPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<TicketQueueBanner
|
||||||
|
hint={t("queueHint")}
|
||||||
|
otherHref="/admin/tickets"
|
||||||
|
otherLabel={t("queueOtherCta")}
|
||||||
|
countsLine={t("queueCounts", {
|
||||||
|
here: queues.helpOpen,
|
||||||
|
other: queues.cmsOpen,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5">
|
||||||
<StatusCard label={t("statusOpen")} value={openCount} icon="🎫" />
|
<StatusCard label={t("statusOpen")} value={openCount} icon="🎫" />
|
||||||
<StatusCard label={t("statusClosed")} value={closedCount} icon="✅" />
|
<StatusCard label={t("statusClosed")} value={closedCount} icon="✅" />
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import { prisma } from "@/lib/prisma";
|
|||||||
import { getAuditLogs } from "@/lib/services/audit";
|
import { getAuditLogs } from "@/lib/services/audit";
|
||||||
import { AuditSection, ExportSection } from "./client";
|
import { AuditSection, ExportSection } from "./client";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function AdminHousekeeping({
|
export default async function AdminHousekeeping({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ import { formatDate } from "@/lib/format-date";
|
|||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
type IpRow = {
|
type IpRow = {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
ipAddress: string;
|
ipAddress: string;
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { updateItemsBase } from "@/actions/items-base";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { useServerAction } from "@/hooks/use-server-action";
|
||||||
|
|
||||||
|
export type ItemsBaseEditValues = {
|
||||||
|
id: number;
|
||||||
|
spriteId: number;
|
||||||
|
publicName: string;
|
||||||
|
itemName: string;
|
||||||
|
type: string;
|
||||||
|
width: number;
|
||||||
|
length: number;
|
||||||
|
stackHeight: string;
|
||||||
|
allowStack: number;
|
||||||
|
allowSit: number;
|
||||||
|
allowLay: number;
|
||||||
|
allowWalk: number;
|
||||||
|
allowGift: number;
|
||||||
|
allowTrade: number;
|
||||||
|
allowRecycle: number;
|
||||||
|
allowMarketplaceSell: number;
|
||||||
|
allowInventoryStack: number;
|
||||||
|
interactionType: string;
|
||||||
|
interactionModesCount: number;
|
||||||
|
vendingIds: string;
|
||||||
|
multiheight: string;
|
||||||
|
customparams: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ALLOW_FLAGS: Array<{ key: keyof ItemsBaseEditValues; label: string }> = [
|
||||||
|
{ key: "allowStack", label: "Stack" },
|
||||||
|
{ key: "allowSit", label: "Sit" },
|
||||||
|
{ key: "allowLay", label: "Lay" },
|
||||||
|
{ key: "allowWalk", label: "Walk" },
|
||||||
|
{ key: "allowGift", label: "Gift" },
|
||||||
|
{ key: "allowTrade", label: "Trade" },
|
||||||
|
{ key: "allowRecycle", label: "Recycle" },
|
||||||
|
{ key: "allowMarketplaceSell", label: "Marketplace" },
|
||||||
|
{ key: "allowInventoryStack", label: "Inv. stack" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ItemsBaseEditForm({
|
||||||
|
initial,
|
||||||
|
canEdit,
|
||||||
|
}: {
|
||||||
|
initial: ItemsBaseEditValues;
|
||||||
|
canEdit: boolean;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("pages.admin.items");
|
||||||
|
const router = useRouter();
|
||||||
|
const { run, isPending } = useServerAction();
|
||||||
|
const [form, setForm] = useState(initial);
|
||||||
|
|
||||||
|
function setField<K extends keyof ItemsBaseEditValues>(
|
||||||
|
key: K,
|
||||||
|
value: ItemsBaseEditValues[K],
|
||||||
|
) {
|
||||||
|
setForm((prev) => ({ ...prev, [key]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
if (!canEdit || isPending) return;
|
||||||
|
const { id, ...fields } = form;
|
||||||
|
run(() => updateItemsBase({ id, fields }), {
|
||||||
|
successMessage: t("saved"),
|
||||||
|
onSuccess: () => router.refresh(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground m-0 font-mono">
|
||||||
|
#{form.id}
|
||||||
|
</p>
|
||||||
|
<h2 className="text-xl font-bold m-0">
|
||||||
|
{form.publicName || form.itemName || t("untitled")}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href="/admin/items">{t("back")}</Link>
|
||||||
|
</Button>
|
||||||
|
{canEdit ? (
|
||||||
|
<Button onClick={handleSave} disabled={isPending}>
|
||||||
|
{t("save")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm">{t("sectionIdentity")}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="publicName">{t("publicName")}</Label>
|
||||||
|
<Input
|
||||||
|
id="publicName"
|
||||||
|
value={form.publicName}
|
||||||
|
disabled={!canEdit}
|
||||||
|
onChange={(e) => setField("publicName", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="itemName">{t("itemName")}</Label>
|
||||||
|
<Input
|
||||||
|
id="itemName"
|
||||||
|
value={form.itemName}
|
||||||
|
disabled={!canEdit}
|
||||||
|
className="font-mono"
|
||||||
|
onChange={(e) => setField("itemName", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="type">{t("type")}</Label>
|
||||||
|
<Input
|
||||||
|
id="type"
|
||||||
|
value={form.type}
|
||||||
|
disabled={!canEdit}
|
||||||
|
maxLength={3}
|
||||||
|
onChange={(e) => setField("type", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="spriteId">{t("spriteId")}</Label>
|
||||||
|
<Input
|
||||||
|
id="spriteId"
|
||||||
|
type="number"
|
||||||
|
value={form.spriteId}
|
||||||
|
disabled={!canEdit}
|
||||||
|
onChange={(e) => setField("spriteId", Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm">{t("sectionDims")}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="width">{t("width")}</Label>
|
||||||
|
<Input
|
||||||
|
id="width"
|
||||||
|
type="number"
|
||||||
|
value={form.width}
|
||||||
|
disabled={!canEdit}
|
||||||
|
onChange={(e) => setField("width", Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="length">{t("length")}</Label>
|
||||||
|
<Input
|
||||||
|
id="length"
|
||||||
|
type="number"
|
||||||
|
value={form.length}
|
||||||
|
disabled={!canEdit}
|
||||||
|
onChange={(e) => setField("length", Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="stackHeight">{t("stackHeight")}</Label>
|
||||||
|
<Input
|
||||||
|
id="stackHeight"
|
||||||
|
value={form.stackHeight}
|
||||||
|
disabled={!canEdit}
|
||||||
|
onChange={(e) => setField("stackHeight", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="interactionType">{t("interactionType")}</Label>
|
||||||
|
<Input
|
||||||
|
id="interactionType"
|
||||||
|
value={form.interactionType}
|
||||||
|
disabled={!canEdit}
|
||||||
|
onChange={(e) => setField("interactionType", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="interactionModesCount">
|
||||||
|
{t("interactionModes")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="interactionModesCount"
|
||||||
|
type="number"
|
||||||
|
value={form.interactionModesCount}
|
||||||
|
disabled={!canEdit}
|
||||||
|
onChange={(e) =>
|
||||||
|
setField("interactionModesCount", Number(e.target.value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm">{t("sectionFlags")}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||||
|
{ALLOW_FLAGS.map(({ key, label }) => (
|
||||||
|
<label
|
||||||
|
key={key}
|
||||||
|
className="flex items-center gap-2 text-sm rounded-md border px-2 py-1.5"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Number(form[key]) === 1}
|
||||||
|
disabled={!canEdit}
|
||||||
|
onChange={(e) => setField(key, e.target.checked ? 1 : 0)}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm">{t("sectionExtra")}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="vendingIds">{t("vendingIds")}</Label>
|
||||||
|
<Input
|
||||||
|
id="vendingIds"
|
||||||
|
value={form.vendingIds}
|
||||||
|
disabled={!canEdit}
|
||||||
|
onChange={(e) => setField("vendingIds", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="multiheight">{t("multiheight")}</Label>
|
||||||
|
<Input
|
||||||
|
id="multiheight"
|
||||||
|
value={form.multiheight}
|
||||||
|
disabled={!canEdit}
|
||||||
|
onChange={(e) => setField("multiheight", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="customparams">{t("customparams")}</Label>
|
||||||
|
<Input
|
||||||
|
id="customparams"
|
||||||
|
value={form.customparams}
|
||||||
|
disabled={!canEdit}
|
||||||
|
onChange={(e) => setField("customparams", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { Package } from "lucide-react";
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||||
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { ItemsBaseEditForm } from "./items-base-edit-form";
|
||||||
|
|
||||||
|
export default async function ItemsBaseDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
const { session, permissions } = await getAdminContext();
|
||||||
|
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
|
||||||
|
redirect("/admin");
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = await getTranslations("pages.admin.items");
|
||||||
|
const { id: rawId } = await params;
|
||||||
|
const id = Number(rawId);
|
||||||
|
if (!Number.isFinite(id) || id <= 0) notFound();
|
||||||
|
|
||||||
|
const item = await prisma.itemsBase.findUnique({ where: { id } });
|
||||||
|
if (!item) notFound();
|
||||||
|
|
||||||
|
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminPageShell
|
||||||
|
icon={Package}
|
||||||
|
title={t("editTitle", { name: item.publicName || item.itemName })}
|
||||||
|
subtitle={t("editSubtitle", { id: item.id })}
|
||||||
|
>
|
||||||
|
<ItemsBaseEditForm
|
||||||
|
canEdit={canEdit}
|
||||||
|
initial={{
|
||||||
|
id: item.id,
|
||||||
|
spriteId: item.spriteId,
|
||||||
|
publicName: item.publicName,
|
||||||
|
itemName: item.itemName,
|
||||||
|
type: item.type,
|
||||||
|
width: item.width,
|
||||||
|
length: item.length,
|
||||||
|
stackHeight: String(item.stackHeight),
|
||||||
|
allowStack: item.allowStack,
|
||||||
|
allowSit: item.allowSit,
|
||||||
|
allowLay: item.allowLay,
|
||||||
|
allowWalk: item.allowWalk,
|
||||||
|
allowGift: item.allowGift,
|
||||||
|
allowTrade: item.allowTrade,
|
||||||
|
allowRecycle: item.allowRecycle,
|
||||||
|
allowMarketplaceSell: item.allowMarketplaceSell,
|
||||||
|
allowInventoryStack: item.allowInventoryStack,
|
||||||
|
interactionType: item.interactionType,
|
||||||
|
interactionModesCount: item.interactionModesCount,
|
||||||
|
vendingIds: item.vendingIds,
|
||||||
|
multiheight: item.multiheight,
|
||||||
|
customparams: item.customparams,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</AdminPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Eye, Package, Pencil } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { DataTable } from "@/components/admin/data-table";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
||||||
|
|
||||||
|
export interface ItemsBaseRow {
|
||||||
|
id: number;
|
||||||
|
spriteId: number;
|
||||||
|
publicName: string;
|
||||||
|
itemName: string;
|
||||||
|
type: string;
|
||||||
|
interactionType: string;
|
||||||
|
width: number;
|
||||||
|
length: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getColumns(canEdit: boolean): DataTableColumn<ItemsBaseRow>[] {
|
||||||
|
return [
|
||||||
|
{ key: "id", label: "ID", sortable: true },
|
||||||
|
{
|
||||||
|
key: "publicName",
|
||||||
|
label: "Name",
|
||||||
|
sortable: true,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Link
|
||||||
|
href={`/admin/items/${row.id}`}
|
||||||
|
className="hover:opacity-80 flex items-center gap-2 min-w-0"
|
||||||
|
>
|
||||||
|
<Package className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="font-medium block truncate">
|
||||||
|
{row.publicName || "—"}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground font-mono truncate block">
|
||||||
|
{row.itemName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "spriteId",
|
||||||
|
label: "Sprite",
|
||||||
|
sortable: true,
|
||||||
|
render: (v) => <span className="tabular-nums text-sm">{String(v)}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "type",
|
||||||
|
label: "Type",
|
||||||
|
sortable: true,
|
||||||
|
filterKey: "filter_type",
|
||||||
|
filterOptions: [
|
||||||
|
{ label: "Floor (s)", value: "s" },
|
||||||
|
{ label: "Wall (i)", value: "i" },
|
||||||
|
{ label: "Effect (e)", value: "e" },
|
||||||
|
{ label: "Badge (b)", value: "b" },
|
||||||
|
{ label: "Robot (r)", value: "r" },
|
||||||
|
],
|
||||||
|
render: (v) => <Badge variant="outline">{String(v)}</Badge>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "interactionType",
|
||||||
|
label: "Interaction",
|
||||||
|
sortable: true,
|
||||||
|
render: (v) => (
|
||||||
|
<span className="text-xs font-mono text-muted-foreground">
|
||||||
|
{String(v || "default")}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "width",
|
||||||
|
label: "Size",
|
||||||
|
render: (_, row) => (
|
||||||
|
<span className="tabular-nums text-sm">
|
||||||
|
{row.width}×{row.length}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
label: "",
|
||||||
|
render: (_, row) => (
|
||||||
|
<div className="flex gap-1 justify-end">
|
||||||
|
<Button variant="ghost" size="icon" asChild>
|
||||||
|
<Link href={`/admin/items/${row.id}`} aria-label="View">
|
||||||
|
{canEdit ? (
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ItemsTable({
|
||||||
|
data,
|
||||||
|
canEdit,
|
||||||
|
}: {
|
||||||
|
data: PaginatedResult<ItemsBaseRow>;
|
||||||
|
canEdit: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
columns={getColumns(canEdit)}
|
||||||
|
data={data}
|
||||||
|
searchPlaceholder="Search name, classname or ID…"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { Package } from "lucide-react";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||||
|
import { fetchAdminList } from "@/lib/admin-list";
|
||||||
|
import { canAccess, PERMS } from "@/lib/permissions";
|
||||||
|
import { type ItemsBaseRow, ItemsTable } from "./items-table";
|
||||||
|
|
||||||
|
export default async function ItemsBasePage({
|
||||||
|
params: paramsPromise,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
searchParams: Promise<Record<string, string>>;
|
||||||
|
}) {
|
||||||
|
const result = await fetchAdminList<ItemsBaseRow>(
|
||||||
|
{
|
||||||
|
permission: PERMS.CATALOG_VIEW,
|
||||||
|
model: "itemsBase",
|
||||||
|
searchFields: ["itemName", "publicName"],
|
||||||
|
defaultSort: { id: "desc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
spriteId: true,
|
||||||
|
publicName: true,
|
||||||
|
itemName: true,
|
||||||
|
type: true,
|
||||||
|
interactionType: true,
|
||||||
|
width: true,
|
||||||
|
length: true,
|
||||||
|
},
|
||||||
|
filterMap: {
|
||||||
|
filter_type: (v) => ({ type: v }),
|
||||||
|
},
|
||||||
|
mapRow: (r) => ({
|
||||||
|
id: r.id as number,
|
||||||
|
spriteId: r.spriteId as number,
|
||||||
|
publicName: (r.publicName as string) || "",
|
||||||
|
itemName: (r.itemName as string) || "",
|
||||||
|
type: (r.type as string) || "s",
|
||||||
|
interactionType: (r.interactionType as string) || "default",
|
||||||
|
width: r.width as number,
|
||||||
|
length: r.length as number,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
paramsPromise,
|
||||||
|
searchParams,
|
||||||
|
);
|
||||||
|
|
||||||
|
const t = await getTranslations("pages.admin.items");
|
||||||
|
const canEdit = canAccess(
|
||||||
|
result.permissions,
|
||||||
|
PERMS.CATALOG_EDIT,
|
||||||
|
result.rank,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminPageShell icon={Package} title={t("title")} subtitle={t("subtitle")}>
|
||||||
|
<p className="text-muted-foreground m-0">
|
||||||
|
{t("total", { count: result.total })}
|
||||||
|
</p>
|
||||||
|
<ItemsTable
|
||||||
|
data={{
|
||||||
|
rows: result.rows,
|
||||||
|
total: result.total,
|
||||||
|
page: result.page,
|
||||||
|
perPage: result.perPage,
|
||||||
|
lastPage: result.lastPage,
|
||||||
|
}}
|
||||||
|
canEdit={canEdit}
|
||||||
|
/>
|
||||||
|
</AdminPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,10 @@ import { LanguageSwitcher } from "@/components/language-switcher";
|
|||||||
import { ThemeSwitcher } from "@/components/theme-switcher";
|
import { ThemeSwitcher } from "@/components/theme-switcher";
|
||||||
import { requireStaff } from "@/lib/admin/guard";
|
import { requireStaff } from "@/lib/admin/guard";
|
||||||
import { collectNavPermissionSlugs } from "@/lib/admin-nav";
|
import { collectNavPermissionSlugs } from "@/lib/admin-nav";
|
||||||
|
import {
|
||||||
|
ADMIN_NAV_CONFIG_KEY,
|
||||||
|
parseAdminNavConfig,
|
||||||
|
} from "@/lib/admin-nav-config";
|
||||||
import { setCsrfCookie } from "@/lib/foundation/security";
|
import { setCsrfCookie } from "@/lib/foundation/security";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
@@ -86,6 +90,10 @@ async function Sidebar({
|
|||||||
return slug.endsWith(".view") || slug === PERMS.ADMIN_DASHBOARD;
|
return slug.endsWith(".view") || slug === PERMS.ADMIN_DASHBOARD;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const navConfig = parseAdminNavConfig(
|
||||||
|
await siteSettings.get(ADMIN_NAV_CONFIG_KEY, ""),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
data-admin
|
data-admin
|
||||||
@@ -115,6 +123,7 @@ async function Sidebar({
|
|||||||
<AdminSidebarNav
|
<AdminSidebarNav
|
||||||
allowedPermissions={allowedPermissions}
|
allowedPermissions={allowedPermissions}
|
||||||
isSuperAdmin={isSuperAdmin}
|
isSuperAdmin={isSuperAdmin}
|
||||||
|
navConfig={navConfig}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="shrink-0 border-t border-[var(--admin-border)] px-3 py-3">
|
<div className="shrink-0 border-t border-[var(--admin-border)] px-3 py-3">
|
||||||
|
|||||||
+32
-26
@@ -1,5 +1,7 @@
|
|||||||
|
import { ClipboardList } from "lucide-react";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||||
import { StatusCard } from "@/components/admin/dashboard";
|
import { StatusCard } from "@/components/admin/dashboard";
|
||||||
import { buildStaffActivityWhere } from "@/lib/admin/log-filters";
|
import { buildStaffActivityWhere } from "@/lib/admin/log-filters";
|
||||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||||
@@ -8,8 +10,6 @@ import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { StaffActivitiesTable } from "./staff-activities-table";
|
import { StaffActivitiesTable } from "./staff-activities-table";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
const SORT_MAP: Record<string, "createdAt" | "userId" | "action"> = {
|
const SORT_MAP: Record<string, "createdAt" | "userId" | "action"> = {
|
||||||
when: "createdAt",
|
when: "createdAt",
|
||||||
staff: "userId",
|
staff: "userId",
|
||||||
@@ -97,31 +97,37 @@ export default async function AdminStaffActivities({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<AdminPageShell
|
||||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5">
|
icon={ClipboardList}
|
||||||
<StatusCard label={t("totalActivities")} value={total} icon="📋" />
|
title={t("title")}
|
||||||
<StatusCard
|
subtitle={t("subtitle")}
|
||||||
label={t("onThisPage")}
|
>
|
||||||
value={activities.length}
|
<div className="space-y-6">
|
||||||
icon="📄"
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5">
|
||||||
/>
|
<StatusCard label={t("totalActivities")} value={total} icon="📋" />
|
||||||
<StatusCard
|
<StatusCard
|
||||||
label={t("recent")}
|
label={t("onThisPage")}
|
||||||
value={recentCount}
|
value={activities.length}
|
||||||
state={recentCount > 0 ? "ok" : "neutral"}
|
icon="📄"
|
||||||
icon="🟢"
|
/>
|
||||||
|
<StatusCard
|
||||||
|
label={t("recent")}
|
||||||
|
value={recentCount}
|
||||||
|
state={recentCount > 0 ? "ok" : "neutral"}
|
||||||
|
icon="🟢"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StaffActivitiesTable
|
||||||
|
data={{
|
||||||
|
rows,
|
||||||
|
total,
|
||||||
|
page: pagination.page,
|
||||||
|
perPage: pagination.perPage,
|
||||||
|
lastPage: pagination.lastPage,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</AdminPageShell>
|
||||||
<StaffActivitiesTable
|
|
||||||
data={{
|
|
||||||
rows,
|
|
||||||
total,
|
|
||||||
page: pagination.page,
|
|
||||||
perPage: pagination.perPage,
|
|
||||||
lastPage: pagination.lastPage,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6,8 +6,6 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
const KEYS = [
|
const KEYS = [
|
||||||
"maintenance_enabled",
|
"maintenance_enabled",
|
||||||
"maintenance_message",
|
"maintenance_message",
|
||||||
|
|||||||
+106
-100
@@ -1,7 +1,9 @@
|
|||||||
|
import { Store } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { cancelMarketplaceListing } from "@/actions/admin-marketplace";
|
import { cancelMarketplaceListing } from "@/actions/admin-marketplace";
|
||||||
|
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||||
import { AdminSimplePager } from "@/components/admin/admin-simple-pager";
|
import { AdminSimplePager } from "@/components/admin/admin-simple-pager";
|
||||||
import { StatusCard } from "@/components/admin/dashboard";
|
import { StatusCard } from "@/components/admin/dashboard";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -10,8 +12,6 @@ import { formatDate } from "@/lib/format-date";
|
|||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function AdminMarketplacePage({
|
export default async function AdminMarketplacePage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
@@ -69,109 +69,115 @@ export default async function AdminMarketplacePage({
|
|||||||
const usernameById = new Map(users.map((u) => [u.id, u.username]));
|
const usernameById = new Map(users.map((u) => [u.id, u.username]));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<AdminPageShell icon={Store} title={t("title")} subtitle={t("subtitle")}>
|
||||||
{loadError ? (
|
<main>
|
||||||
<div className="mb-4 rounded-lg bg-[var(--admin-error-subtle)] p-3 text-xs text-[var(--admin-error)]">
|
{loadError ? (
|
||||||
{t("loadError")}
|
<div className="mb-4 rounded-lg bg-[var(--admin-error-subtle)] p-3 text-xs text-[var(--admin-error)]">
|
||||||
|
{t("loadError")}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mb-4 flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant={filter === "active" ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link href="/admin/marketplace?filter=active">
|
||||||
|
{t("filterActive")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={filter === "sold" ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link href="/admin/marketplace?filter=sold">{t("filterSold")}</Link>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="mb-4 flex flex-wrap gap-2">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<Button
|
<StatusCard
|
||||||
variant={filter === "active" ? "default" : "ghost"}
|
label={t("activeListings")}
|
||||||
size="sm"
|
value={activeCount}
|
||||||
asChild
|
icon="🏪"
|
||||||
>
|
/>
|
||||||
<Link href="/admin/marketplace?filter=active">
|
<StatusCard label={t("soldListings")} value={soldCount} icon="✅" />
|
||||||
{t("filterActive")}
|
</div>
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={filter === "sold" ? "default" : "ghost"}
|
|
||||||
size="sm"
|
|
||||||
asChild
|
|
||||||
>
|
|
||||||
<Link href="/admin/marketplace?filter=sold">{t("filterSold")}</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
<section>
|
||||||
<StatusCard label={t("activeListings")} value={activeCount} icon="🏪" />
|
<h2 className="admin-section-title">
|
||||||
<StatusCard label={t("soldListings")} value={soldCount} icon="✅" />
|
{filter === "sold" ? t("soldListings") : t("activeListings")} (
|
||||||
</div>
|
{total})
|
||||||
|
</h2>
|
||||||
<section>
|
{rows.length === 0 ? (
|
||||||
<h2 className="admin-section-title">
|
<div className="admin-empty">{t("empty")}</div>
|
||||||
{filter === "sold" ? t("soldListings") : t("activeListings")} ({total}
|
) : (
|
||||||
)
|
<div className="admin-card overflow-x-auto" style={{ padding: 0 }}>
|
||||||
</h2>
|
<table>
|
||||||
{rows.length === 0 ? (
|
<thead>
|
||||||
<div className="admin-empty">{t("empty")}</div>
|
<tr>
|
||||||
) : (
|
<th>{t("colId")}</th>
|
||||||
<div className="admin-card overflow-x-auto" style={{ padding: 0 }}>
|
<th>{t("colItem")}</th>
|
||||||
<table>
|
<th>{t("colSeller")}</th>
|
||||||
<thead>
|
<th>{t("colPrice")}</th>
|
||||||
<tr>
|
<th>{t("colListed")}</th>
|
||||||
<th>{t("colId")}</th>
|
{filter === "sold" ? <th>{t("colSold")}</th> : null}
|
||||||
<th>{t("colItem")}</th>
|
|
||||||
<th>{t("colSeller")}</th>
|
|
||||||
<th>{t("colPrice")}</th>
|
|
||||||
<th>{t("colListed")}</th>
|
|
||||||
{filter === "sold" ? <th>{t("colSold")}</th> : null}
|
|
||||||
{canEdit && filter === "active" ? (
|
|
||||||
<th>{t("colActions")}</th>
|
|
||||||
) : null}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{rows.map((row) => (
|
|
||||||
<tr key={row.id}>
|
|
||||||
<td className="muted">{row.id}</td>
|
|
||||||
<td>
|
|
||||||
<code>{row.itemId}</code>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<Link href={`/admin/users/show/${row.userId}`}>
|
|
||||||
{usernameById.get(row.userId) ?? `#${row.userId}`}
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
<td>{row.price}</td>
|
|
||||||
<td className="muted">
|
|
||||||
{formatDate(new Date(row.timestamp * 1000))}
|
|
||||||
</td>
|
|
||||||
{filter === "sold" ? (
|
|
||||||
<td className="muted">
|
|
||||||
{formatDate(
|
|
||||||
row.soldTimestamp
|
|
||||||
? new Date(row.soldTimestamp * 1000)
|
|
||||||
: null,
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
) : null}
|
|
||||||
{canEdit && filter === "active" ? (
|
{canEdit && filter === "active" ? (
|
||||||
<td>
|
<th>{t("colActions")}</th>
|
||||||
<form action={cancelMarketplaceListing}>
|
|
||||||
<input type="hidden" name="id" value={row.id} />
|
|
||||||
<Button type="submit" variant="ghost" size="sm">
|
|
||||||
{t("cancel")}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
) : null}
|
) : null}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{rows.map((row) => (
|
||||||
</div>
|
<tr key={row.id}>
|
||||||
)}
|
<td className="muted">{row.id}</td>
|
||||||
<AdminSimplePager
|
<td>
|
||||||
page={pagination.page}
|
<code>{row.itemId}</code>
|
||||||
lastPage={pagination.lastPage}
|
</td>
|
||||||
total={total}
|
<td>
|
||||||
basePath="/admin/marketplace"
|
<Link href={`/admin/users/show/${row.userId}`}>
|
||||||
params={filter === "sold" ? { filter: "sold" } : undefined}
|
{usernameById.get(row.userId) ?? `#${row.userId}`}
|
||||||
/>
|
</Link>
|
||||||
</section>
|
</td>
|
||||||
</main>
|
<td>{row.price}</td>
|
||||||
|
<td className="muted">
|
||||||
|
{formatDate(new Date(row.timestamp * 1000))}
|
||||||
|
</td>
|
||||||
|
{filter === "sold" ? (
|
||||||
|
<td className="muted">
|
||||||
|
{formatDate(
|
||||||
|
row.soldTimestamp
|
||||||
|
? new Date(row.soldTimestamp * 1000)
|
||||||
|
: null,
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
) : null}
|
||||||
|
{canEdit && filter === "active" ? (
|
||||||
|
<td>
|
||||||
|
<form action={cancelMarketplaceListing}>
|
||||||
|
<input type="hidden" name="id" value={row.id} />
|
||||||
|
<Button type="submit" variant="ghost" size="sm">
|
||||||
|
{t("cancel")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
) : null}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<AdminSimplePager
|
||||||
|
page={pagination.page}
|
||||||
|
lastPage={pagination.lastPage}
|
||||||
|
total={total}
|
||||||
|
basePath="/admin/marketplace"
|
||||||
|
params={filter === "sold" ? { filter: "sold" } : undefined}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</AdminPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
RotateCcw,
|
||||||
|
Save,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useMemo, useState, useTransition } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { saveAdminNavConfig } from "@/actions/admin-nav-menu";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
ADMIN_NAV_PINNED_HREFS,
|
||||||
|
type AdminNavConfig,
|
||||||
|
} from "@/lib/admin-nav-config";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type EditableNavGroup = {
|
||||||
|
labelKey: string;
|
||||||
|
items: { href: string; labelKey: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function moveIndex<T>(arr: T[], from: number, to: number): T[] {
|
||||||
|
if (to < 0 || to >= arr.length || from === to) return arr;
|
||||||
|
const next = [...arr];
|
||||||
|
const [item] = next.splice(from, 1);
|
||||||
|
if (item === undefined) return arr;
|
||||||
|
next.splice(to, 0, item);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConfig(
|
||||||
|
groups: EditableNavGroup[],
|
||||||
|
hiddenGroups: Set<string>,
|
||||||
|
hiddenItems: Set<string>,
|
||||||
|
): Required<AdminNavConfig> {
|
||||||
|
return {
|
||||||
|
groupOrder: groups.map((g) => g.labelKey),
|
||||||
|
hiddenGroups: [...hiddenGroups],
|
||||||
|
hiddenItems: [...hiddenItems].filter((h) => !ADMIN_NAV_PINNED_HREFS.has(h)),
|
||||||
|
itemOrder: Object.fromEntries(
|
||||||
|
groups.map((g) => [g.labelKey, g.items.map((i) => i.href)]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminMenuEditor({
|
||||||
|
catalog,
|
||||||
|
initialConfig,
|
||||||
|
canEdit,
|
||||||
|
}: {
|
||||||
|
catalog: EditableNavGroup[];
|
||||||
|
initialConfig: AdminNavConfig;
|
||||||
|
canEdit: boolean;
|
||||||
|
}) {
|
||||||
|
const tNav = useTranslations("pages.admin.nav");
|
||||||
|
const t = useTranslations("pages.admin.menu");
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const initialGroups = useMemo(() => {
|
||||||
|
const order = initialConfig.groupOrder ?? [];
|
||||||
|
const byKey = new Map(catalog.map((g) => [g.labelKey, g]));
|
||||||
|
const ordered: EditableNavGroup[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const key of order) {
|
||||||
|
const g = byKey.get(key);
|
||||||
|
if (!g || seen.has(key)) continue;
|
||||||
|
const itemOrder = initialConfig.itemOrder?.[key] ?? [];
|
||||||
|
const itemsByHref = new Map(g.items.map((i) => [i.href, i]));
|
||||||
|
const items = [
|
||||||
|
...itemOrder
|
||||||
|
.map((href) => itemsByHref.get(href))
|
||||||
|
.filter((i): i is NonNullable<typeof i> => Boolean(i)),
|
||||||
|
...g.items.filter((i) => !itemOrder.includes(i.href)),
|
||||||
|
];
|
||||||
|
ordered.push({ labelKey: g.labelKey, items });
|
||||||
|
seen.add(key);
|
||||||
|
}
|
||||||
|
for (const g of catalog) {
|
||||||
|
if (seen.has(g.labelKey)) continue;
|
||||||
|
ordered.push({ ...g, items: [...g.items] });
|
||||||
|
}
|
||||||
|
return ordered;
|
||||||
|
}, [catalog, initialConfig]);
|
||||||
|
|
||||||
|
const [groups, setGroups] = useState(initialGroups);
|
||||||
|
const [hiddenGroups, setHiddenGroups] = useState(
|
||||||
|
() => new Set(initialConfig.hiddenGroups ?? []),
|
||||||
|
);
|
||||||
|
const [hiddenItems, setHiddenItems] = useState(
|
||||||
|
() => new Set(initialConfig.hiddenItems ?? []),
|
||||||
|
);
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
setGroups(catalog.map((g) => ({ ...g, items: [...g.items] })));
|
||||||
|
setHiddenGroups(new Set());
|
||||||
|
setHiddenItems(new Set());
|
||||||
|
}
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
if (!canEdit) return;
|
||||||
|
const config = buildConfig(groups, hiddenGroups, hiddenItems);
|
||||||
|
startTransition(async () => {
|
||||||
|
const res = await saveAdminNavConfig(config);
|
||||||
|
if (res.ok) {
|
||||||
|
toast.success(t("saved"));
|
||||||
|
} else {
|
||||||
|
toast.error(res.error ?? t("saveFailed"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{canEdit ? (
|
||||||
|
<>
|
||||||
|
<Button type="button" onClick={save} disabled={pending}>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
{pending ? t("saving") : t("save")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={reset}
|
||||||
|
disabled={pending}
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
{t("reset")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">{t("viewOnly")}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-muted-foreground w-full sm:w-auto sm:ml-auto">
|
||||||
|
{t("hint")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{groups.map((group, gi) => {
|
||||||
|
const groupHidden = hiddenGroups.has(group.labelKey);
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
key={group.labelKey}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-3",
|
||||||
|
groupHidden && "opacity-55",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mb-2">
|
||||||
|
<span className="font-semibold text-sm uppercase tracking-wide flex-1 min-w-0">
|
||||||
|
{tNav(group.labelKey)}
|
||||||
|
</span>
|
||||||
|
{canEdit && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={gi === 0}
|
||||||
|
aria-label={t("moveUp")}
|
||||||
|
onClick={() =>
|
||||||
|
setGroups((prev) => moveIndex(prev, gi, gi - 1))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={gi === groups.length - 1}
|
||||||
|
aria-label={t("moveDown")}
|
||||||
|
onClick={() =>
|
||||||
|
setGroups((prev) => moveIndex(prev, gi, gi + 1))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={groupHidden ? t("showGroup") : t("hideGroup")}
|
||||||
|
onClick={() =>
|
||||||
|
setHiddenGroups((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(group.labelKey))
|
||||||
|
next.delete(group.labelKey);
|
||||||
|
else next.add(group.labelKey);
|
||||||
|
return next;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{groupHidden ? (
|
||||||
|
<EyeOff className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{group.items.map((item, ii) => {
|
||||||
|
const pinned = ADMIN_NAV_PINNED_HREFS.has(item.href);
|
||||||
|
const itemHidden = !pinned && hiddenItems.has(item.href);
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={item.href}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-wrap items-center gap-2 rounded-md px-2 py-1.5 text-sm",
|
||||||
|
"bg-[var(--admin-canvas)]/60",
|
||||||
|
itemHidden && "opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex-1 min-w-0 truncate">
|
||||||
|
{tNav(item.labelKey)}
|
||||||
|
<span className="ml-2 text-xs text-muted-foreground font-mono">
|
||||||
|
{item.href}
|
||||||
|
</span>
|
||||||
|
{pinned && (
|
||||||
|
<span className="ml-2 text-[0.65rem] uppercase tracking-wide text-muted-foreground">
|
||||||
|
{t("pinned")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{canEdit && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={ii === 0}
|
||||||
|
aria-label={t("moveUp")}
|
||||||
|
onClick={() =>
|
||||||
|
setGroups((prev) =>
|
||||||
|
prev.map((g, idx) =>
|
||||||
|
idx === gi
|
||||||
|
? {
|
||||||
|
...g,
|
||||||
|
items: moveIndex(g.items, ii, ii - 1),
|
||||||
|
}
|
||||||
|
: g,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={ii === group.items.length - 1}
|
||||||
|
aria-label={t("moveDown")}
|
||||||
|
onClick={() =>
|
||||||
|
setGroups((prev) =>
|
||||||
|
prev.map((g, idx) =>
|
||||||
|
idx === gi
|
||||||
|
? {
|
||||||
|
...g,
|
||||||
|
items: moveIndex(g.items, ii, ii + 1),
|
||||||
|
}
|
||||||
|
: g,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={pinned}
|
||||||
|
aria-label={
|
||||||
|
itemHidden ? t("showItem") : t("hideItem")
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
if (pinned) return;
|
||||||
|
setHiddenItems((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(item.href)) next.delete(item.href);
|
||||||
|
else next.add(item.href);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{itemHidden || pinned ? (
|
||||||
|
itemHidden ? (
|
||||||
|
<EyeOff className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4 opacity-40" />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { ListOrdered } from "lucide-react";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||||
|
import { ADMIN_NAV_GROUPS } from "@/lib/admin-nav";
|
||||||
|
import {
|
||||||
|
ADMIN_NAV_CONFIG_KEY,
|
||||||
|
parseAdminNavConfig,
|
||||||
|
} from "@/lib/admin-nav-config";
|
||||||
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
import { AdminMenuEditor } from "./admin-menu-editor";
|
||||||
|
|
||||||
|
export const metadata = { title: "Admin menu" };
|
||||||
|
|
||||||
|
export default async function AdminMenuPage() {
|
||||||
|
const { session, permissions } = await getAdminContext();
|
||||||
|
if (!canAccess(permissions, PERMS.SETTINGS_VIEW, session.user.rank)) {
|
||||||
|
redirect("/admin");
|
||||||
|
}
|
||||||
|
const canEdit = canAccess(
|
||||||
|
permissions,
|
||||||
|
PERMS.SETTINGS_EDIT,
|
||||||
|
session.user.rank,
|
||||||
|
);
|
||||||
|
const t = await getTranslations("pages.admin.menu");
|
||||||
|
|
||||||
|
const catalog = ADMIN_NAV_GROUPS.map((g) => ({
|
||||||
|
labelKey: g.labelKey,
|
||||||
|
items: g.items.map((i) => ({ href: i.href, labelKey: i.labelKey })),
|
||||||
|
}));
|
||||||
|
const initialConfig = parseAdminNavConfig(
|
||||||
|
await siteSettings.get(ADMIN_NAV_CONFIG_KEY, ""),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminPageShell
|
||||||
|
icon={ListOrdered}
|
||||||
|
title={t("title")}
|
||||||
|
subtitle={t("subtitle")}
|
||||||
|
>
|
||||||
|
<AdminMenuEditor
|
||||||
|
catalog={catalog}
|
||||||
|
initialConfig={initialConfig}
|
||||||
|
canEdit={canEdit}
|
||||||
|
/>
|
||||||
|
</AdminPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,8 +7,6 @@ import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { type CfhRow, CfhTable } from "./cfh-table";
|
import { type CfhRow, CfhTable } from "./cfh-table";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function CfhListPage({
|
export default async function CfhListPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { Wifi } from "lucide-react";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||||
import { fetchAdminList } from "@/lib/admin-list";
|
import { fetchAdminList } from "@/lib/admin-list";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { OnlineTable } from "./online-table";
|
import { OnlineTable } from "./online-table";
|
||||||
@@ -27,10 +30,12 @@ export default async function OnlineUsersPage({
|
|||||||
searchParams,
|
searchParams,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const t = await getTranslations("pages.admin.online");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<AdminPageShell icon={Wifi} title={t("title")} subtitle={t("subtitle")}>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground m-0">
|
||||||
Currently online users ({result.total} connected)
|
{t("connected", { count: result.total })}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<OnlineTable
|
<OnlineTable
|
||||||
@@ -42,6 +47,6 @@ export default async function OnlineUsersPage({
|
|||||||
lastPage: result.lastPage,
|
lastPage: result.lastPage,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</AdminPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+16
-9
@@ -13,8 +13,6 @@ import { activeBanWhere } from "@/lib/bans";
|
|||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
type RankCount = { rank: number; count: number };
|
type RankCount = { rank: number; count: number };
|
||||||
|
|
||||||
export default async function AdminDashboard() {
|
export default async function AdminDashboard() {
|
||||||
@@ -83,13 +81,22 @@ export default async function AdminDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||||
<StatusCard
|
<Link
|
||||||
label={t("playersOnline")}
|
href="/admin/commandocentrum#online"
|
||||||
value={online}
|
className="no-underline text-inherit"
|
||||||
hint={dbOk ? t("totalAccounts", { count: users }) : t("databaseDown")}
|
>
|
||||||
state={dbOk ? "neutral" : "danger"}
|
<StatusCard
|
||||||
icon={<Users size={18} />}
|
label={t("playersOnline")}
|
||||||
/>
|
value={online}
|
||||||
|
hint={
|
||||||
|
dbOk
|
||||||
|
? `${t("totalAccounts", { count: users })} · live roster →`
|
||||||
|
: t("databaseDown")
|
||||||
|
}
|
||||||
|
state={dbOk ? "neutral" : "danger"}
|
||||||
|
icon={<Users size={18} />}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
<StatusCard
|
<StatusCard
|
||||||
label={t("articles")}
|
label={t("articles")}
|
||||||
value={dbOk ? articles : "—"}
|
value={dbOk ? articles : "—"}
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ import {
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { createRank, deleteRank } from "@/actions/permissions";
|
import {
|
||||||
|
createRank,
|
||||||
|
deleteRank,
|
||||||
|
repairAdminNavAclGrants,
|
||||||
|
} from "@/actions/permissions";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -123,15 +127,30 @@ export function PermissionsList({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
{ranks.length} ranks · {totalUsers.toLocaleString()} users
|
{ranks.length} ranks · {totalUsers.toLocaleString()} users
|
||||||
total
|
total
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={() => setShowCreate(true)}>
|
<div className="flex flex-wrap gap-2">
|
||||||
<Plus className="h-4 w-4 mr-1.5" />
|
<Button
|
||||||
New Rank
|
variant="outline"
|
||||||
</Button>
|
disabled={isPending}
|
||||||
|
onClick={() =>
|
||||||
|
run(() => repairAdminNavAclGrants(), {
|
||||||
|
successMessage: "Admin nav ACL grants repaired",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
title="Fill missing admin.* grants for ranks that already have dashboard access (same as migration 0018)"
|
||||||
|
>
|
||||||
|
<ShieldCheck className="h-4 w-4 mr-1.5" />
|
||||||
|
Repair nav grants
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setShowCreate(true)}>
|
||||||
|
<Plus className="h-4 w-4 mr-1.5" />
|
||||||
|
New Rank
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Rank cards */}
|
{/* Rank cards */}
|
||||||
|
|||||||
+106
-54
@@ -1,35 +1,60 @@
|
|||||||
|
import { Camera } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { deletePhoto } from "@/actions/admin-photos";
|
import { deletePhoto } from "@/actions/admin-photos";
|
||||||
|
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||||
import { StatusCard } from "@/components/admin/dashboard";
|
import { StatusCard } from "@/components/admin/dashboard";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export default async function AdminPhotos({
|
||||||
|
searchParams,
|
||||||
export default async function AdminPhotos() {
|
}: {
|
||||||
|
searchParams: Promise<Record<string, string>>;
|
||||||
|
}) {
|
||||||
const { session, permissions } = await getAdminContext();
|
const { session, permissions } = await getAdminContext();
|
||||||
if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) {
|
if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) {
|
||||||
redirect("/admin");
|
redirect("/admin");
|
||||||
}
|
}
|
||||||
|
|
||||||
const t = await getTranslations("pages.admin.photos");
|
const t = await getTranslations("pages.admin.photos");
|
||||||
|
const parsed = parseListParams(new URLSearchParams(await searchParams));
|
||||||
|
|
||||||
|
const total = await prisma.cameraWeb.count().catch(() => 0);
|
||||||
|
const pagination = calcPagination(total, parsed.page, parsed.perPage || 24);
|
||||||
|
|
||||||
let photos: Awaited<ReturnType<typeof prisma.cameraWeb.findMany>> = [];
|
let photos: Awaited<ReturnType<typeof prisma.cameraWeb.findMany>> = [];
|
||||||
try {
|
try {
|
||||||
photos = await prisma.cameraWeb.findMany({
|
photos = await prisma.cameraWeb.findMany({
|
||||||
orderBy: { timestamp: "desc" },
|
orderBy: { timestamp: "desc" },
|
||||||
take: 60,
|
skip: pagination.offset,
|
||||||
|
take: pagination.perPage,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
photos = [];
|
photos = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userIds = [...new Set(photos.map((p) => p.userId))];
|
||||||
|
const users =
|
||||||
|
userIds.length > 0
|
||||||
|
? await prisma.user
|
||||||
|
.findMany({
|
||||||
|
where: { id: { in: userIds } },
|
||||||
|
select: { id: true, username: true },
|
||||||
|
})
|
||||||
|
.catch(() => [])
|
||||||
|
: [];
|
||||||
|
const usernameById = new Map(users.map((u) => [u.id, u.username]));
|
||||||
|
|
||||||
const uniqueRooms = new Set(photos.map((p) => p.roomId)).size;
|
const uniqueRooms = new Set(photos.map((p) => p.roomId)).size;
|
||||||
const uniqueUsers = new Set(photos.map((p) => p.userId)).size;
|
const uniqueUsers = new Set(photos.map((p) => p.userId)).size;
|
||||||
|
const canEdit = canAccess(permissions, PERMS.PAGES_EDIT, session.user.rank);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<AdminPageShell icon={Camera} title={t("title")} subtitle={t("subtitle")}>
|
||||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
<StatusCard label={t("photosShown")} value={photos.length} icon="📸" />
|
<StatusCard label={t("photosShown")} value={photos.length} icon="📸" />
|
||||||
<StatusCard
|
<StatusCard
|
||||||
@@ -44,68 +69,95 @@ export default async function AdminPhotos() {
|
|||||||
hint={t("distinctPhotographers")}
|
hint={t("distinctPhotographers")}
|
||||||
icon="👤"
|
icon="👤"
|
||||||
/>
|
/>
|
||||||
|
<StatusCard label={t("total")} value={total} icon="🗂" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="mt-6">
|
<section className="mt-2">
|
||||||
<h2 className="admin-section-title">{t("recentPhotos")}</h2>
|
<h2 className="admin-section-title">{t("recentPhotos")}</h2>
|
||||||
{photos.length === 0 ? (
|
{photos.length === 0 ? (
|
||||||
<div className="admin-empty">{t("noPhotos")}</div>
|
<div className="admin-empty">{t("noPhotos")}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
{photos.map((p) => (
|
{photos.map((p) => {
|
||||||
<div
|
const username = usernameById.get(p.userId) ?? `#${p.userId}`;
|
||||||
key={String(p.id)}
|
return (
|
||||||
className="admin-card"
|
<div
|
||||||
style={{ padding: 0, overflow: "hidden" }}
|
key={String(p.id)}
|
||||||
>
|
className="admin-card"
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
style={{ padding: 0, overflow: "hidden" }}
|
||||||
<img
|
>
|
||||||
src={p.url}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
alt={t("photoAlt", { id: String(p.id) })}
|
<img
|
||||||
style={{
|
src={p.url}
|
||||||
width: "100%",
|
alt={t("photoAlt", { id: String(p.id) })}
|
||||||
height: 200,
|
|
||||||
objectFit: "cover",
|
|
||||||
display: "block",
|
|
||||||
background: "#f1f5f9",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div style={{ padding: "0.85rem 1.05rem 1.05rem" }}>
|
|
||||||
<div
|
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
width: "100%",
|
||||||
justifyContent: "space-between",
|
height: 200,
|
||||||
gap: "0.5rem",
|
objectFit: "cover",
|
||||||
alignItems: "baseline",
|
display: "block",
|
||||||
|
background: "#f1f5f9",
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<strong>#{String(p.id)}</strong>
|
<div style={{ padding: "0.85rem 1.05rem 1.05rem" }}>
|
||||||
<span className="text-sm theme-text-muted dark:theme-text-muted">
|
<div
|
||||||
{t("colRoom")} {p.roomId}
|
style={{
|
||||||
</span>
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: "0.5rem",
|
||||||
|
alignItems: "baseline",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>#{String(p.id)}</strong>
|
||||||
|
<Link
|
||||||
|
href={`/admin/rooms/edit/${p.roomId}`}
|
||||||
|
className="text-sm theme-text-muted dark:theme-text-muted hover:underline"
|
||||||
|
>
|
||||||
|
{t("colRoom")} {p.roomId}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className="text-xs theme-text-muted dark:theme-text-muted"
|
||||||
|
style={{ margin: "0.3rem 0 0" }}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={`/admin/users/show/${p.userId}`}
|
||||||
|
className="hover:underline"
|
||||||
|
>
|
||||||
|
{t("byUser", { username })}
|
||||||
|
</Link>{" "}
|
||||||
|
·{" "}
|
||||||
|
{new Date(p.timestamp * 1000)
|
||||||
|
.toISOString()
|
||||||
|
.slice(0, 16)
|
||||||
|
.replace("T", " ")}
|
||||||
|
</p>
|
||||||
|
{canEdit ? (
|
||||||
|
<form
|
||||||
|
action={deletePhoto}
|
||||||
|
style={{ marginTop: "0.75rem" }}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={String(p.id)} />
|
||||||
|
<Button type="submit" variant="destructive">
|
||||||
|
{t("delete")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<p
|
|
||||||
className="text-xs theme-text-muted dark:theme-text-muted"
|
|
||||||
style={{ margin: "0.3rem 0 0" }}
|
|
||||||
>
|
|
||||||
{t("colUser")} {p.userId} ·{" "}
|
|
||||||
{new Date(p.timestamp * 1000)
|
|
||||||
.toISOString()
|
|
||||||
.slice(0, 16)
|
|
||||||
.replace("T", " ")}
|
|
||||||
</p>
|
|
||||||
<form action={deletePhoto} style={{ marginTop: "0.75rem" }}>
|
|
||||||
<input type="hidden" name="id" value={String(p.id)} />
|
|
||||||
<Button type="submit" variant="destructive">
|
|
||||||
{t("delete")}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{pagination.lastPage > 1 ? (
|
||||||
|
<p className="text-sm text-muted-foreground mt-4">
|
||||||
|
{t("pageOf", {
|
||||||
|
page: pagination.page,
|
||||||
|
last: pagination.lastPage,
|
||||||
|
total,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</AdminPageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -9,8 +9,6 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export const metadata = { title: "Radio API Keys" };
|
export const metadata = { title: "Radio API Keys" };
|
||||||
|
|
||||||
type ApiKey = {
|
type ApiKey = {
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export const metadata = { title: "Radio AutoDJ" };
|
export const metadata = { title: "Radio AutoDJ" };
|
||||||
|
|
||||||
type Track = {
|
type Track = {
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
type Banner = {
|
type Banner = {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
userId: bigint;
|
userId: bigint;
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import { resolveHotelName } from "@/lib/hotel-name";
|
|||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
import { EmbedSnippet } from "./embed-snippet";
|
import { EmbedSnippet } from "./embed-snippet";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export const metadata = { title: "Radio Embed" };
|
export const metadata = { title: "Radio Embed" };
|
||||||
|
|
||||||
export default async function AdminRadioEmbedPage() {
|
export default async function AdminRadioEmbedPage() {
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import { StatusCard } from "@/components/admin/dashboard";
|
|||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
type History = {
|
type History = {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
userId: bigint;
|
userId: bigint;
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
type Shout = {
|
type Shout = {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
userId: bigint;
|
userId: bigint;
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import { getTranslations } from "next-intl/server";
|
|||||||
import { DiagnosticRow, StatusCard } from "@/components/admin/dashboard";
|
import { DiagnosticRow, StatusCard } from "@/components/admin/dashboard";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export const metadata = { title: "Radio Monitoring" };
|
export const metadata = { title: "Radio Monitoring" };
|
||||||
|
|
||||||
const FETCH_TIMEOUT_MS = 4000;
|
const FETCH_TIMEOUT_MS = 4000;
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
type Shout = {
|
type Shout = {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
userId: bigint;
|
userId: bigint;
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import { StatusCard } from "@/components/admin/dashboard";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export const metadata = { title: "Radio Points" };
|
export const metadata = { title: "Radio Points" };
|
||||||
|
|
||||||
const DEFAULTS = {
|
const DEFAULTS = {
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import { StatusCard } from "@/components/admin/dashboard";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
type Rank = {
|
type Rank = {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
Loaded 100 of 191 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user