Compare commits
57
Commits
v1.0.2
..
1e3b7bc31d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
+43
-70
@@ -1,95 +1,68 @@
|
|||||||
# 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=3001
|
||||||
|
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=
|
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
name: CI
|
name: CI
|
||||||
on:
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check:
|
check:
|
||||||
@@ -30,8 +34,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
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ jobs:
|
|||||||
echo '<a id="what-is-epicnext-cms"></a>'
|
echo '<a id="what-is-epicnext-cms"></a>'
|
||||||
echo "## What is EpicNext-CMS?"
|
echo "## What is EpicNext-CMS?"
|
||||||
echo ""
|
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 "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 ""
|
||||||
echo '<a id="system-requirements"></a>'
|
echo '<a id="system-requirements"></a>'
|
||||||
echo "## System Requirements"
|
echo "## System Requirements"
|
||||||
@@ -314,7 +314,7 @@ jobs:
|
|||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
# prisma generate does not need a live DB connection.
|
# prisma generate does not need a live DB connection.
|
||||||
pnpm prisma:generate
|
pnpm prisma:generate
|
||||||
export ARGON2_MEMORY_SIZE=1024 ARGON2_ITERATIONS=1 BCRYPT_ROUNDS=4
|
export BCRYPT_ROUNDS=4
|
||||||
pnpm typecheck
|
pnpm typecheck
|
||||||
pnpm test
|
pnpm test
|
||||||
# Validate production env (AUTH_SECRET, DATABASE_URL, …) during build.
|
# Validate production env (AUTH_SECRET, DATABASE_URL, …) during build.
|
||||||
@@ -377,10 +377,39 @@ jobs:
|
|||||||
# Prisma client is gitignored — regenerate into live src/generated.
|
# Prisma client is gitignored — regenerate into live src/generated.
|
||||||
pnpm prisma:generate
|
pnpm prisma:generate
|
||||||
|
|
||||||
sudo chown -R www-data:www-data "${LIVE}" 2>/dev/null || true
|
sudo chown -R "${DEPLOY_USER}:${DEPLOY_GROUP}" "${LIVE}" 2>/dev/null || true
|
||||||
|
|
||||||
echo "Starting PM2 (zero-downtime reload)..."
|
# Next.js prefers an already-set process PORT over .env. PM2 may still
|
||||||
pm2 reload next --update-env || pm2 start next --update-env
|
# 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
|
sleep 3
|
||||||
if ! pm2 show next 2>/dev/null | grep -q 'online'; then
|
if ! pm2 show next 2>/dev/null | grep -q 'online'; then
|
||||||
@@ -389,22 +418,25 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Waiting for HTTP health check..."
|
echo "Waiting for HTTP health check on port ${PORT}..."
|
||||||
HEALTH_URL="${DEPLOY_HEALTH_URL:-http://127.0.0.1:3000/api/health}"
|
HEALTH_URL="${DEPLOY_HEALTH_URL:-http://127.0.0.1:${PORT}/api/health}"
|
||||||
HEALTH_OK=0
|
HEALTH_OK=0
|
||||||
for i in $(seq 1 15); do
|
for i in $(seq 1 20); do
|
||||||
BODY="$(curl -sf --max-time 5 "${HEALTH_URL}" 2>/dev/null || true)"
|
BODY="$(curl -sf --max-time 5 "${HEALTH_URL}" 2>/dev/null || true)"
|
||||||
if echo "${BODY}" | grep -q '"database":true'; then
|
if echo "${BODY}" | grep -q '"database":true'; then
|
||||||
echo "Health OK (${HEALTH_URL})"
|
echo "Health OK (${HEALTH_URL})"
|
||||||
HEALTH_OK=1
|
HEALTH_OK=1
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
echo "Health attempt ${i}/15 failed, retrying..."
|
echo "Health attempt ${i}/20 failed (body=${BODY:-<empty>}), retrying..."
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
if [ "${HEALTH_OK}" != "1" ]; then
|
if [ "${HEALTH_OK}" != "1" ]; then
|
||||||
echo "ERROR: Health check failed after deploy (${HEALTH_URL})" >&2
|
echo "ERROR: Health check failed after deploy (${HEALTH_URL})" >&2
|
||||||
echo "Last body: ${BODY:-<empty>}" >&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
|
pm2 logs next --lines 40 --nostream >&2 || true
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ jobs:
|
|||||||
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 \
|
||||||
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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 }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -40,6 +40,11 @@ const nextConfig: NextConfig = {
|
|||||||
// Disable Next.js telemetry
|
// Disable Next.js telemetry
|
||||||
productionBrowserSourceMaps: false,
|
productionBrowserSourceMaps: false,
|
||||||
|
|
||||||
|
// Enable TypeScript CLI integration for TypeScript 7 compatibility
|
||||||
|
experimental: {
|
||||||
|
useTypeScriptCli: true,
|
||||||
|
},
|
||||||
|
|
||||||
// Add caching headers for static assets
|
// Add caching headers for static assets
|
||||||
async headers() {
|
async headers() {
|
||||||
return [
|
return [
|
||||||
|
|||||||
+11
-32
@@ -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.20.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.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@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.29.0",
|
||||||
|
"lint-staged": "^17.2.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
+800
-3235
File diff suppressed because it is too large.
Load diff
@@ -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,16 @@ 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}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 +76,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 +108,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,56 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
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", () => ({
|
||||||
|
MEDIA_ROOT: "/tmp/nexst-test-media",
|
||||||
|
resolveMediaPath: vi.fn((name: string) => `/tmp/nexst-test-media/${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,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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -53,6 +53,7 @@ export default async function CommandoCentrum() {
|
|||||||
emulatorOnline,
|
emulatorOnline,
|
||||||
maintenance,
|
maintenance,
|
||||||
activity,
|
activity,
|
||||||
|
onlineUsersRaw,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
prisma.user.count({ where: { online: "1" } }).catch(() => 0),
|
prisma.user.count({ where: { online: "1" } }).catch(() => 0),
|
||||||
prisma.user.count().catch(() => -1),
|
prisma.user.count().catch(() => -1),
|
||||||
@@ -70,8 +71,30 @@ export default async function CommandoCentrum() {
|
|||||||
.catch(
|
.catch(
|
||||||
() => [] as Awaited<ReturnType<typeof prisma.staffActivities.findMany>>,
|
() => [] as Awaited<ReturnType<typeof prisma.staffActivities.findMany>>,
|
||||||
),
|
),
|
||||||
|
prisma.user
|
||||||
|
.findMany({
|
||||||
|
where: { online: "1" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
look: true,
|
||||||
|
lastOnline: true,
|
||||||
|
},
|
||||||
|
orderBy: { lastOnline: "desc" },
|
||||||
|
take: 40,
|
||||||
|
})
|
||||||
|
.catch(() => []),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const onlineUsers = onlineUsersRaw.map((u) => ({
|
||||||
|
id: u.id,
|
||||||
|
username: u.username,
|
||||||
|
look: u.look,
|
||||||
|
roomId: null as number | null,
|
||||||
|
roomName: null as string | null,
|
||||||
|
lastActivity: u.lastOnline > 0 ? new Date(u.lastOnline * 1000) : new Date(),
|
||||||
|
}));
|
||||||
|
|
||||||
const staffIds = Array.from(
|
const staffIds = Array.from(
|
||||||
new Set(activity.map((a) => Number(a.userId))),
|
new Set(activity.map((a) => Number(a.userId))),
|
||||||
).filter((n) => n > 0);
|
).filter((n) => n > 0);
|
||||||
@@ -134,7 +157,7 @@ export default async function CommandoCentrum() {
|
|||||||
{/* ── Online users widget ───────────────────────────────── */}
|
{/* ── Online users widget ───────────────────────────────── */}
|
||||||
<section className="admin-card mb-6">
|
<section className="admin-card mb-6">
|
||||||
<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>
|
</section>
|
||||||
|
|
||||||
{/* ── Server info + diagnostics ──────────────────────────── */}
|
{/* ── Server info + diagnostics ──────────────────────────── */}
|
||||||
|
|||||||
@@ -42,10 +42,15 @@ export function AdminHelpTicketDetail({
|
|||||||
ticket,
|
ticket,
|
||||||
messages: initialMessages,
|
messages: initialMessages,
|
||||||
canEdit,
|
canEdit,
|
||||||
|
listHref = "/admin/help-tickets",
|
||||||
|
hideRequesterContact = false,
|
||||||
}: {
|
}: {
|
||||||
ticket: HelpTicketInfo;
|
ticket: HelpTicketInfo;
|
||||||
messages: ThreadMessage[];
|
messages: ThreadMessage[];
|
||||||
canEdit: boolean;
|
canEdit: 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);
|
||||||
@@ -92,7 +97,7 @@ export function AdminHelpTicketDetail({
|
|||||||
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>
|
||||||
@@ -241,7 +246,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 +259,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,4 +1,5 @@
|
|||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { getMinStaffRank } from "@/lib/admin/min-staff-rank";
|
||||||
import { positiveBigInt } from "@/lib/api";
|
import { positiveBigInt } from "@/lib/api";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
@@ -6,8 +7,6 @@ import { AdminHelpTicketDetail } from "./admin-help-ticket-detail";
|
|||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const STAFF_RANK_THRESHOLD = 4;
|
|
||||||
|
|
||||||
export default async function AdminHelpTicketDetailPage({
|
export default async function AdminHelpTicketDetailPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
@@ -70,6 +69,7 @@ export default async function AdminHelpTicketDetailPage({
|
|||||||
|
|
||||||
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 +86,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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -160,6 +160,20 @@ export default async function AdminHelpTicketsPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<div
|
||||||
|
className="rounded-lg border border-[var(--admin-info-border)] bg-[var(--admin-info-subtle)] px-4 py-3 text-sm"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<p className="m-0">{t("queueHint")}</p>
|
||||||
|
<p className="m-0 mt-1">
|
||||||
|
<a
|
||||||
|
href="/admin/tickets"
|
||||||
|
className="font-medium text-[var(--admin-accent)] underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
{t("queueOtherCta")}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<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="✅" />
|
||||||
|
|||||||
@@ -116,6 +116,20 @@ export default async function AdminTicketsPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<div
|
||||||
|
className="rounded-lg border border-[var(--admin-info-border)] bg-[var(--admin-info-subtle)] px-4 py-3 text-sm"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<p className="m-0">{t("queueHint")}</p>
|
||||||
|
<p className="m-0 mt-1">
|
||||||
|
<a
|
||||||
|
href="/admin/help-tickets"
|
||||||
|
className="font-medium text-[var(--admin-accent)] underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
{t("queueOtherCta")}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<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
|
<StatusCard
|
||||||
|
|||||||
@@ -33,22 +33,7 @@ export async function GET() {
|
|||||||
|
|
||||||
const emulator = await rcon.send("ping", null).catch(() => false);
|
const emulator = await rcon.send("ping", null).catch(() => false);
|
||||||
|
|
||||||
let smtp = null;
|
const resendAvailable = !!env.RESEND_API_KEY;
|
||||||
if (env.SMTP_HOST) {
|
|
||||||
const nodemailer = await import("nodemailer");
|
|
||||||
const test = nodemailer.createTransport({
|
|
||||||
host: env.SMTP_HOST,
|
|
||||||
port: env.SMTP_PORT,
|
|
||||||
secure: env.SMTP_SECURE,
|
|
||||||
auth: env.SMTP_USER
|
|
||||||
? { user: env.SMTP_USER, pass: env.SMTP_PASSWORD ?? "" }
|
|
||||||
: undefined,
|
|
||||||
});
|
|
||||||
smtp = await test
|
|
||||||
.verify()
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false);
|
|
||||||
}
|
|
||||||
|
|
||||||
const degraded = !database || redisOk === false;
|
const degraded = !database || redisOk === false;
|
||||||
return apiJson({
|
return apiJson({
|
||||||
@@ -56,7 +41,7 @@ export async function GET() {
|
|||||||
database,
|
database,
|
||||||
redis: redisOk,
|
redis: redisOk,
|
||||||
emulator,
|
emulator,
|
||||||
smtp,
|
resend: resendAvailable,
|
||||||
node: process.version,
|
node: process.version,
|
||||||
uptime: Math.round(process.uptime()),
|
uptime: Math.round(process.uptime()),
|
||||||
time: new Date().toISOString(),
|
time: new Date().toISOString(),
|
||||||
|
|||||||
+2
-2
@@ -4,7 +4,7 @@ import { headers } from "next/headers";
|
|||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
import { NextIntlClientProvider } from "next-intl";
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
import { getLocale, getMessages } from "next-intl/server";
|
import { getLocale, getMessages } from "next-intl/server";
|
||||||
import { ViewTransitions } from "next-view-transitions";
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
import { PwaRegister } from "@/components/pwa-register";
|
import { PwaRegister } from "@/components/pwa-register";
|
||||||
@@ -90,7 +90,7 @@ export default async function RootLayout({
|
|||||||
>
|
>
|
||||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||||
<ThemeVars nonce={nonce} />
|
<ThemeVars nonce={nonce} />
|
||||||
<ViewTransitions>{children}</ViewTransitions>
|
{children}
|
||||||
<PwaRegister />
|
<PwaRegister />
|
||||||
<SmoothScroll />
|
<SmoothScroll />
|
||||||
<Toaster
|
<Toaster
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { AdminHelpTicketDetail } from "@/app/admin/help-tickets/[id]/admin-help-ticket-detail";
|
||||||
|
import { requireModPermission } from "@/lib/admin/guard";
|
||||||
|
import { getMinStaffRank } from "@/lib/admin/min-staff-rank";
|
||||||
|
import { positiveBigInt } from "@/lib/api";
|
||||||
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function ModHelpTicketDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
await requireModPermission([PERMS.MOD_TICKETS_VIEW, PERMS.TICKETS_VIEW]);
|
||||||
|
|
||||||
|
const { session, permissions } = await getAdminContext();
|
||||||
|
const { id } = await params;
|
||||||
|
const ticketId = positiveBigInt(id);
|
||||||
|
if (!ticketId) notFound();
|
||||||
|
|
||||||
|
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
||||||
|
where: { id: ticketId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
userId: true,
|
||||||
|
title: true,
|
||||||
|
content: true,
|
||||||
|
open: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ticket) notFound();
|
||||||
|
|
||||||
|
const replies = await prisma.websiteHelpCenterTicketReplies.findMany({
|
||||||
|
where: { ticketId: ticket.id },
|
||||||
|
orderBy: { id: "asc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
userId: true,
|
||||||
|
content: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const authorIds = [
|
||||||
|
...new Set([
|
||||||
|
...(ticket.userId != null ? [ticket.userId] : []),
|
||||||
|
...replies.map((r) => r.userId),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
const users =
|
||||||
|
authorIds.length > 0
|
||||||
|
? await prisma.user.findMany({
|
||||||
|
where: { id: { in: authorIds } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
rank: true,
|
||||||
|
mail: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const userById = new Map(users.map((u) => [u.id, u]));
|
||||||
|
const openerId = ticket.userId;
|
||||||
|
const minStaffRank = await getMinStaffRank();
|
||||||
|
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
key: "opening",
|
||||||
|
userId: openerId ?? 0,
|
||||||
|
username:
|
||||||
|
openerId != null
|
||||||
|
? (userById.get(openerId)?.username ?? `#${openerId}`)
|
||||||
|
: "—",
|
||||||
|
isStaff: false,
|
||||||
|
content: ticket.content,
|
||||||
|
createdAt: (ticket.createdAt ?? new Date()).toISOString(),
|
||||||
|
},
|
||||||
|
...replies.map((r) => {
|
||||||
|
const user = userById.get(r.userId);
|
||||||
|
const isStaff =
|
||||||
|
r.userId !== openerId && (user?.rank ?? 0) >= minStaffRank;
|
||||||
|
return {
|
||||||
|
key: String(r.id),
|
||||||
|
userId: r.userId,
|
||||||
|
username: user?.username ?? `#${r.userId}`,
|
||||||
|
isStaff,
|
||||||
|
content: r.content,
|
||||||
|
createdAt: (r.createdAt ?? new Date()).toISOString(),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const creator = openerId != null ? userById.get(openerId) : undefined;
|
||||||
|
|
||||||
|
const canEdit =
|
||||||
|
canAccess(permissions, PERMS.MOD_TICKETS_EDIT, session.user.rank) ||
|
||||||
|
canAccess(permissions, PERMS.TICKETS_EDIT, session.user.rank);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminHelpTicketDetail
|
||||||
|
ticket={{
|
||||||
|
id: String(ticket.id),
|
||||||
|
title: ticket.title,
|
||||||
|
open: ticket.open,
|
||||||
|
createdAt: (ticket.createdAt ?? new Date()).toISOString(),
|
||||||
|
updatedAt: (ticket.updatedAt ?? new Date()).toISOString(),
|
||||||
|
creator: creator
|
||||||
|
? {
|
||||||
|
id: creator.id,
|
||||||
|
username: creator.username,
|
||||||
|
rank: creator.rank,
|
||||||
|
mail: creator.mail ?? "",
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}}
|
||||||
|
messages={messages}
|
||||||
|
canEdit={canEdit}
|
||||||
|
listHref="/mod/help-tickets"
|
||||||
|
hideRequesterContact
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import {
|
||||||
|
type HelpTicketRow,
|
||||||
|
HelpTicketsTable,
|
||||||
|
} from "@/app/admin/help-tickets/help-tickets-table";
|
||||||
|
import { StatusCard } from "@/components/admin/dashboard";
|
||||||
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
|
import { requireModPermission } from "@/lib/admin/guard";
|
||||||
|
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||||
|
import { formatDate } from "@/lib/format-date";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function ModHelpTicketsPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<Record<string, string>>;
|
||||||
|
}) {
|
||||||
|
await requireModPermission([PERMS.MOD_TICKETS_VIEW, PERMS.TICKETS_VIEW]);
|
||||||
|
|
||||||
|
const t = await getTranslations("pages.admin.helpTickets");
|
||||||
|
const raw = await searchParams;
|
||||||
|
const parsed = parseListParams(new URLSearchParams(raw));
|
||||||
|
const statusFilter = raw.filter_status || "open";
|
||||||
|
|
||||||
|
const conditions: Prisma.WebsiteHelpCenterTicketsWhereInput[] = [];
|
||||||
|
|
||||||
|
if (statusFilter === "open") {
|
||||||
|
conditions.push({ open: true });
|
||||||
|
} else if (statusFilter === "closed") {
|
||||||
|
conditions.push({ open: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.search.trim()) {
|
||||||
|
const q = parsed.search.trim();
|
||||||
|
const asId = /^\d+$/.test(q) ? BigInt(q) : null;
|
||||||
|
const matchingUsers = await prisma.user
|
||||||
|
.findMany({
|
||||||
|
where: { username: { contains: q } },
|
||||||
|
select: { id: true },
|
||||||
|
take: 50,
|
||||||
|
})
|
||||||
|
.catch(() => []);
|
||||||
|
|
||||||
|
conditions.push({
|
||||||
|
OR: [
|
||||||
|
{ title: { contains: q } },
|
||||||
|
{ content: { contains: q } },
|
||||||
|
...(matchingUsers.length > 0
|
||||||
|
? [{ userId: { in: matchingUsers.map((u) => u.id) } }]
|
||||||
|
: []),
|
||||||
|
...(asId !== null ? [{ id: asId }] : []),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const where: Prisma.WebsiteHelpCenterTicketsWhereInput =
|
||||||
|
conditions.length === 0
|
||||||
|
? {}
|
||||||
|
: conditions.length === 1
|
||||||
|
? conditions[0]
|
||||||
|
: { AND: conditions };
|
||||||
|
|
||||||
|
const SORT_MAP: Record<
|
||||||
|
string,
|
||||||
|
Prisma.WebsiteHelpCenterTicketsOrderByWithRelationInput
|
||||||
|
> = {
|
||||||
|
title: { title: "asc" },
|
||||||
|
open: { open: "desc" },
|
||||||
|
user: { userId: "asc" },
|
||||||
|
date: { createdAt: "desc" },
|
||||||
|
updated: { updatedAt: "desc" },
|
||||||
|
id: { id: "desc" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseOrder = SORT_MAP[parsed.sort ?? "updated"] ?? { updatedAt: "desc" };
|
||||||
|
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
||||||
|
const orderBy = {
|
||||||
|
[orderField]: parsed.order,
|
||||||
|
} as Prisma.WebsiteHelpCenterTicketsOrderByWithRelationInput;
|
||||||
|
|
||||||
|
const [total, openCount, closedCount] = await Promise.all([
|
||||||
|
prisma.websiteHelpCenterTickets.count({ where }).catch(() => 0),
|
||||||
|
prisma.websiteHelpCenterTickets
|
||||||
|
.count({ where: { open: true } })
|
||||||
|
.catch(() => 0),
|
||||||
|
prisma.websiteHelpCenterTickets
|
||||||
|
.count({ where: { open: false } })
|
||||||
|
.catch(() => 0),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const pagination = calcPagination(total, parsed.page, parsed.perPage);
|
||||||
|
|
||||||
|
const tickets = await prisma.websiteHelpCenterTickets
|
||||||
|
.findMany({
|
||||||
|
where,
|
||||||
|
orderBy,
|
||||||
|
skip: pagination.offset,
|
||||||
|
take: pagination.perPage,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
userId: true,
|
||||||
|
title: true,
|
||||||
|
open: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => []);
|
||||||
|
|
||||||
|
const ticketIds = tickets.map((ticket) => ticket.id);
|
||||||
|
const userIds = [
|
||||||
|
...new Set(
|
||||||
|
tickets
|
||||||
|
.map((ticket) => ticket.userId)
|
||||||
|
.filter((id): id is number => id != null),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
const [replyGroups, users] = await Promise.all([
|
||||||
|
ticketIds.length > 0
|
||||||
|
? prisma.websiteHelpCenterTicketReplies
|
||||||
|
.groupBy({
|
||||||
|
by: ["ticketId"],
|
||||||
|
where: { ticketId: { in: ticketIds } },
|
||||||
|
_count: true,
|
||||||
|
})
|
||||||
|
.catch(() => [])
|
||||||
|
: Promise.resolve([]),
|
||||||
|
userIds.length > 0
|
||||||
|
? prisma.user
|
||||||
|
.findMany({
|
||||||
|
where: { id: { in: userIds } },
|
||||||
|
select: { id: true, username: true },
|
||||||
|
})
|
||||||
|
.catch(() => [])
|
||||||
|
: Promise.resolve([]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const replyCountByTicket = new Map(
|
||||||
|
replyGroups.map((g) => [String(g.ticketId), g._count]),
|
||||||
|
);
|
||||||
|
const usernameById = new Map(users.map((u) => [u.id, u.username]));
|
||||||
|
|
||||||
|
const rows: HelpTicketRow[] = tickets.map((ticket) => ({
|
||||||
|
id: String(ticket.id),
|
||||||
|
title: ticket.title,
|
||||||
|
user:
|
||||||
|
ticket.userId != null
|
||||||
|
? (usernameById.get(ticket.userId) ?? `#${ticket.userId}`)
|
||||||
|
: "—",
|
||||||
|
userId: ticket.userId,
|
||||||
|
replies: replyCountByTicket.get(String(ticket.id)) ?? 0,
|
||||||
|
open: ticket.open,
|
||||||
|
date: formatDate(ticket.createdAt, "date"),
|
||||||
|
updated: formatDate(ticket.updatedAt, "date"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5">
|
||||||
|
<StatusCard label={t("statusOpen")} value={openCount} icon="🎫" />
|
||||||
|
<StatusCard label={t("statusClosed")} value={closedCount} icon="✅" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{total === 0 && !parsed.search && statusFilter === "open" ? (
|
||||||
|
<div className="admin-empty">{t("noTickets")}</div>
|
||||||
|
) : (
|
||||||
|
<HelpTicketsTable
|
||||||
|
basePath="/mod/help-tickets"
|
||||||
|
data={{
|
||||||
|
rows,
|
||||||
|
total,
|
||||||
|
page: pagination.page,
|
||||||
|
perPage: pagination.perPage,
|
||||||
|
lastPage: pagination.lastPage,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+16
-1
@@ -1,4 +1,13 @@
|
|||||||
import { Ban, Gavel, LogOut, Radio, Shield, Ticket, Users } from "lucide-react";
|
import {
|
||||||
|
Ban,
|
||||||
|
Gavel,
|
||||||
|
LifeBuoy,
|
||||||
|
LogOut,
|
||||||
|
Radio,
|
||||||
|
Shield,
|
||||||
|
Ticket,
|
||||||
|
Users,
|
||||||
|
} 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";
|
||||||
@@ -59,6 +68,12 @@ export default async function ModLayout({ children }: { children: ReactNode }) {
|
|||||||
icon: Ticket,
|
icon: Ticket,
|
||||||
show: showTickets,
|
show: showTickets,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: "/mod/help-tickets",
|
||||||
|
label: t("nav.helpTickets"),
|
||||||
|
icon: LifeBuoy,
|
||||||
|
show: showTickets,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: "/mod/actions",
|
href: "/mod/actions",
|
||||||
label: t("nav.actions"),
|
label: t("nav.actions"),
|
||||||
|
|||||||
+59
-27
@@ -1,4 +1,12 @@
|
|||||||
import { Ban, Gavel, Radio, Shield, Ticket, Users } from "lucide-react";
|
import {
|
||||||
|
Ban,
|
||||||
|
Gavel,
|
||||||
|
LifeBuoy,
|
||||||
|
Radio,
|
||||||
|
Shield,
|
||||||
|
Ticket,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { StatsCard } from "@/components/admin/stats-card";
|
import { StatsCard } from "@/components/admin/stats-card";
|
||||||
@@ -30,24 +38,30 @@ export default async function ModDashboardPage() {
|
|||||||
canAccess(permissions, PERMS.MOD_TEAM_VIEW, rank) ||
|
canAccess(permissions, PERMS.MOD_TEAM_VIEW, rank) ||
|
||||||
canAccess(permissions, PERMS.MODERATION_VIEW, rank);
|
canAccess(permissions, PERMS.MODERATION_VIEW, rank);
|
||||||
|
|
||||||
const [openCfh, todayBans, modsOnline, openTickets] = await Promise.all([
|
const [openCfh, todayBans, modsOnline, openTickets, openHelpTickets] =
|
||||||
showCfh
|
await Promise.all([
|
||||||
? prisma.supportTickets.count({ where: { state: 0 } }).catch(() => 0)
|
showCfh
|
||||||
: Promise.resolve(0),
|
? prisma.supportTickets.count({ where: { state: 0 } }).catch(() => 0)
|
||||||
showBans
|
: Promise.resolve(0),
|
||||||
? prisma.ban
|
showBans
|
||||||
.count({ where: { timestamp: { gte: todayStart } } })
|
? prisma.ban
|
||||||
.catch(() => 0)
|
.count({ where: { timestamp: { gte: todayStart } } })
|
||||||
: Promise.resolve(0),
|
.catch(() => 0)
|
||||||
prisma.user
|
: Promise.resolve(0),
|
||||||
.count({ where: { online: "1", rank: { gte: minStaffRank } } })
|
prisma.user
|
||||||
.catch(() => 0),
|
.count({ where: { online: "1", rank: { gte: minStaffRank } } })
|
||||||
showTickets
|
.catch(() => 0),
|
||||||
? prisma.websiteTicket
|
showTickets
|
||||||
.count({ where: { status: { not: "closed" } } })
|
? prisma.websiteTicket
|
||||||
.catch(() => 0)
|
.count({ where: { status: { not: "closed" } } })
|
||||||
: Promise.resolve(0),
|
.catch(() => 0)
|
||||||
]);
|
: Promise.resolve(0),
|
||||||
|
showTickets
|
||||||
|
? prisma.websiteHelpCenterTickets
|
||||||
|
.count({ where: { open: true } })
|
||||||
|
.catch(() => 0)
|
||||||
|
: Promise.resolve(0),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -75,6 +89,14 @@ export default async function ModDashboardPage() {
|
|||||||
href="/mod/tickets"
|
href="/mod/tickets"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{showTickets ? (
|
||||||
|
<StatsCard
|
||||||
|
title={t("stats.openHelpTickets")}
|
||||||
|
value={openHelpTickets}
|
||||||
|
icon={<LifeBuoy className="h-4 w-4" />}
|
||||||
|
href="/mod/help-tickets"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{showBans ? (
|
{showBans ? (
|
||||||
<StatsCard
|
<StatsCard
|
||||||
title={t("stats.bansToday")}
|
title={t("stats.bansToday")}
|
||||||
@@ -120,14 +142,24 @@ export default async function ModDashboardPage() {
|
|||||||
</li>
|
</li>
|
||||||
) : null}
|
) : null}
|
||||||
{showTickets ? (
|
{showTickets ? (
|
||||||
<li>
|
<>
|
||||||
<Link
|
<li>
|
||||||
href="/mod/tickets"
|
<Link
|
||||||
className="text-primary hover:underline"
|
href="/mod/tickets"
|
||||||
>
|
className="text-primary hover:underline"
|
||||||
{t("nav.tickets")}
|
>
|
||||||
</Link>
|
{t("nav.tickets")}
|
||||||
</li>
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href="/mod/help-tickets"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{t("nav.helpTickets")}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{showActions ? (
|
{showActions ? (
|
||||||
<li>
|
<li>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion, useInView } from "framer-motion";
|
import { motion, useInView } from "motion/react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
export function AnimatedCounter({
|
export function AnimatedCounter({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
const cmsDetails = [
|
const cmsDetails = [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { type ReactNode, useRef, useState } from "react";
|
import { type ReactNode, useRef, useState } from "react";
|
||||||
import { FALLBACK_HOTEL_NAME } from "@/lib/brand";
|
import { FALLBACK_HOTEL_NAME } from "@/lib/brand";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { pageTransition } from "@/lib/motion";
|
import { pageTransition } from "@/lib/motion";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "motion/react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
interface RevealProps {
|
interface RevealProps {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||||
import { dropdownVariants } from "@/lib/motion";
|
import { dropdownVariants } from "@/lib/motion";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "motion/react";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useEventSource } from "@/lib/use-event-source";
|
import { useEventSource } from "@/lib/use-event-source";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "motion/react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
export function TypewriterText({
|
export function TypewriterText({
|
||||||
|
|||||||
+1
-7
@@ -50,17 +50,11 @@ const schema = z
|
|||||||
// Laravel APP_KEY (base64:...) — needed to read existing 2FA secrets.
|
// Laravel APP_KEY (base64:...) — needed to read existing 2FA secrets.
|
||||||
APP_KEY: z.string().optional(),
|
APP_KEY: z.string().optional(),
|
||||||
|
|
||||||
// Mirrors Laravel config('habbo.site.convert_passwords') — enables md5->argon2id.
|
// Mirrors Laravel config('habbo.site.convert_passwords') — enables md5->bcrypt upgrade.
|
||||||
CONVERT_PASSWORDS: z
|
CONVERT_PASSWORDS: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.transform((v) => v === "true" || v === "1"),
|
.transform((v) => v === "true" || v === "1"),
|
||||||
// Hashing driver for NEW passwords: bcrypt (default, fits varchar(64)) | argon2id.
|
|
||||||
PASSWORD_HASH: z.enum(["bcrypt", "argon2id"]).optional(),
|
|
||||||
// Argon2id parameters (mirrors config/hashing.php).
|
|
||||||
ARGON2_PARALLELISM: z.coerce.number().int().positive().default(1),
|
|
||||||
ARGON2_ITERATIONS: z.coerce.number().int().positive().default(4),
|
|
||||||
ARGON2_MEMORY_SIZE: z.coerce.number().int().positive().default(65536),
|
|
||||||
// Bcrypt cost factor (rounds).
|
// Bcrypt cost factor (rounds).
|
||||||
BCRYPT_ROUNDS: z.coerce.number().int().positive().default(12),
|
BCRYPT_ROUNDS: z.coerce.number().int().positive().default(12),
|
||||||
// Filesystem dir the badge uploader writes <code>.gif into (the emulator's
|
// Filesystem dir the badge uploader writes <code>.gif into (the emulator's
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const ROUTES: Array<[string, string]> = [
|
|||||||
["shop", "PERMS.SHOP_VIEW"],
|
["shop", "PERMS.SHOP_VIEW"],
|
||||||
["transactions", "PERMS.SHOP_VIEW"],
|
["transactions", "PERMS.SHOP_VIEW"],
|
||||||
["vouchers", "PERMS.SHOP_VIEW"],
|
["vouchers", "PERMS.SHOP_VIEW"],
|
||||||
|
["marketplace", "PERMS.SHOP_VIEW"],
|
||||||
["vpn", "PERMS.SETTINGS_VIEW"],
|
["vpn", "PERMS.SETTINGS_VIEW"],
|
||||||
["ip", "PERMS.SETTINGS_VIEW"],
|
["ip", "PERMS.SETTINGS_VIEW"],
|
||||||
["maintenance", "PERMS.SETTINGS_VIEW"],
|
["maintenance", "PERMS.SETTINGS_VIEW"],
|
||||||
@@ -32,11 +33,26 @@ const ROUTES: Array<[string, string]> = [
|
|||||||
["badges", "PERMS.CATALOG_VIEW"],
|
["badges", "PERMS.CATALOG_VIEW"],
|
||||||
["teams", "PERMS.USERS_VIEW"],
|
["teams", "PERMS.USERS_VIEW"],
|
||||||
["applications", "PERMS.USERS_VIEW"],
|
["applications", "PERMS.USERS_VIEW"],
|
||||||
|
["guilds", "PERMS.USERS_VIEW"],
|
||||||
|
["tickets", "PERMS.TICKETS_VIEW"],
|
||||||
|
["help-tickets", "PERMS.TICKETS_VIEW"],
|
||||||
|
["permissions", "PERMS.PERMISSIONS_MANAGE"],
|
||||||
|
["housekeeping", "PERMS.SETTINGS_VIEW"],
|
||||||
["logs", "PERMS.LOGS_VIEW"],
|
["logs", "PERMS.LOGS_VIEW"],
|
||||||
["catalog", "PERMS.CATALOG_VIEW"],
|
["catalog", "PERMS.CATALOG_VIEW"],
|
||||||
["rooms/edit/[id]", "PERMS.ROOMS_EDIT"],
|
["rooms/edit/[id]", "PERMS.ROOMS_EDIT"],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const MOD_ROUTES: Array<[string, string]> = [
|
||||||
|
["", "requireMod"],
|
||||||
|
["cfh", "PERMS.MOD_CFH_VIEW"],
|
||||||
|
["actions", "PERMS.MOD_ACTIONS"],
|
||||||
|
["bans", "PERMS.MOD_BANS_VIEW"],
|
||||||
|
["tickets", "PERMS.MOD_TICKETS_VIEW"],
|
||||||
|
["help-tickets", "PERMS.MOD_TICKETS_VIEW"],
|
||||||
|
["team", "PERMS.MOD_TEAM_VIEW"],
|
||||||
|
];
|
||||||
|
|
||||||
const ACTION_GATES: Array<[string, string]> = [
|
const ACTION_GATES: Array<[string, string]> = [
|
||||||
["admin-settings.ts", "PERMS.SETTINGS_EDIT"],
|
["admin-settings.ts", "PERMS.SETTINGS_EDIT"],
|
||||||
["admin-theme.ts", "PERMS.SETTINGS_EDIT"],
|
["admin-theme.ts", "PERMS.SETTINGS_EDIT"],
|
||||||
@@ -54,6 +70,11 @@ const ACTION_GATES: Array<[string, string]> = [
|
|||||||
["admin-alerts.ts", "PERMS.NOTIFICATIONS_EDIT"],
|
["admin-alerts.ts", "PERMS.NOTIFICATIONS_EDIT"],
|
||||||
["commandocentrum.ts", "PERMS.RCON_EXECUTE"],
|
["commandocentrum.ts", "PERMS.RCON_EXECUTE"],
|
||||||
["translations.ts", "PERMS.SETTINGS_EDIT"],
|
["translations.ts", "PERMS.SETTINGS_EDIT"],
|
||||||
|
["tickets.ts", "PERMS.TICKETS_EDIT"],
|
||||||
|
["admin-help-tickets.ts", "PERMS.TICKETS_EDIT"],
|
||||||
|
["permissions.ts", "PERMS.PERMISSIONS_MANAGE"],
|
||||||
|
["admin-guilds.ts", "PERMS.USERS_EDIT"],
|
||||||
|
["moderation.ts", "PERMS.MODERATION_EDIT"],
|
||||||
];
|
];
|
||||||
|
|
||||||
describe("admin operations route contract", () => {
|
describe("admin operations route contract", () => {
|
||||||
@@ -63,6 +84,19 @@ describe("admin operations route contract", () => {
|
|||||||
expect(readFileSync(path, "utf8"), path).toContain(permission);
|
expect(readFileSync(path, "utf8"), path).toContain(permission);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(MOD_ROUTES)("provides and guards /mod/%s", (route, permission) => {
|
||||||
|
const path =
|
||||||
|
route === "" ? "src/app/mod/page.tsx" : `src/app/mod/${route}/page.tsx`;
|
||||||
|
expect(existsSync(path), path).toBe(true);
|
||||||
|
const source = readFileSync(path, "utf8");
|
||||||
|
const layout = readFileSync("src/app/mod/layout.tsx", "utf8");
|
||||||
|
if (permission === "requireMod") {
|
||||||
|
expect(layout).toContain("requireMod");
|
||||||
|
} else {
|
||||||
|
expect(source).toContain(permission);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("guards radio section via layout", () => {
|
it("guards radio section via layout", () => {
|
||||||
const path = "src/app/admin/radio/layout.tsx";
|
const path = "src/app/admin/radio/layout.tsx";
|
||||||
expect(existsSync(path), path).toBe(true);
|
expect(existsSync(path), path).toBe(true);
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { redirectSafe } from "@/lib/foundation/security";
|
||||||
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
|
import {
|
||||||
|
requireMod,
|
||||||
|
requireModPermission,
|
||||||
|
requirePermission,
|
||||||
|
requirePermissionRateLimited,
|
||||||
|
requireStaff,
|
||||||
|
requireStaffRateLimited,
|
||||||
|
} from "./guard";
|
||||||
|
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
getAdminContext: vi.fn(),
|
||||||
|
canAccess: vi.fn(),
|
||||||
|
PERMS: {
|
||||||
|
ADMIN_DASHBOARD: "admin.dashboard",
|
||||||
|
MOD_DASHBOARD: "mod.dashboard",
|
||||||
|
MODERATION_VIEW: "moderation.view",
|
||||||
|
MOD_CFH_VIEW: "mod.cfh.view",
|
||||||
|
MOD_ACTIONS: "mod.actions",
|
||||||
|
MOD_BANS_VIEW: "mod.bans.view",
|
||||||
|
MOD_TICKETS_VIEW: "mod.tickets.view",
|
||||||
|
MOD_TEAM_VIEW: "mod.team.view",
|
||||||
|
TICKETS_VIEW: "tickets.view",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/foundation/security", () => ({ redirectSafe: vi.fn() }));
|
||||||
|
vi.mock("@/lib/rate-limit", () => ({ clientIp: vi.fn(), rateLimit: vi.fn() }));
|
||||||
|
|
||||||
|
const session = {
|
||||||
|
session: { user: { id: 1, rank: 7, username: "admin" } },
|
||||||
|
permissions: { "admin.dashboard": 7 },
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(getAdminContext).mockResolvedValue(session as never);
|
||||||
|
vi.mocked(canAccess).mockImplementation(
|
||||||
|
(_p: unknown, _s: string, r: number) => r >= 7,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("requireStaff", () => {
|
||||||
|
it("returns staff when authorized", async () => {
|
||||||
|
const s = await requireStaff();
|
||||||
|
expect(s).toEqual({ id: 1, rank: 7, username: "admin" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects when unauthorized", async () => {
|
||||||
|
vi.mocked(getAdminContext).mockResolvedValue({
|
||||||
|
session: { user: { id: 2, rank: 1, username: "user" } },
|
||||||
|
permissions: {},
|
||||||
|
} as never);
|
||||||
|
await requireStaff();
|
||||||
|
expect(redirectSafe).toHaveBeenCalledWith("/", "/");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("requirePermission", () => {
|
||||||
|
it("returns staff when both dashboard and specific perm ok", async () => {
|
||||||
|
vi.mocked(canAccess).mockReturnValue(true);
|
||||||
|
expect(await requirePermission("user.edit")).toEqual({
|
||||||
|
id: 1,
|
||||||
|
rank: 7,
|
||||||
|
username: "admin",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("requireMod", () => {
|
||||||
|
it("allows with MOD_DASHBOARD", async () => {
|
||||||
|
vi.mocked(getAdminContext).mockResolvedValue({
|
||||||
|
session: { user: { id: 1, rank: 5, username: "mod" } },
|
||||||
|
permissions: {},
|
||||||
|
} as never);
|
||||||
|
vi.mocked(canAccess).mockImplementation(
|
||||||
|
(_p: unknown, slug: string) => slug === "mod.dashboard",
|
||||||
|
);
|
||||||
|
expect(await requireMod()).toEqual({ id: 1, rank: 5, username: "mod" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects without mod perms", async () => {
|
||||||
|
vi.mocked(canAccess).mockReturnValue(false);
|
||||||
|
await requireMod();
|
||||||
|
expect(redirectSafe).toHaveBeenCalledWith("/", "/");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("requireModPermission", () => {
|
||||||
|
it("allows with matching perm", async () => {
|
||||||
|
vi.mocked(canAccess).mockImplementation(
|
||||||
|
(_p: unknown, slug: string) => slug === "mod.tickets.view",
|
||||||
|
);
|
||||||
|
await requireModPermission(["mod.bans.view", "mod.tickets.view"]);
|
||||||
|
expect(redirectSafe).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("requirePermissionRateLimited", () => {
|
||||||
|
it("returns staff when rate limit ok", async () => {
|
||||||
|
vi.mocked(canAccess).mockReturnValue(true);
|
||||||
|
vi.mocked(clientIp).mockResolvedValue("1.2.3.4");
|
||||||
|
vi.mocked(rateLimit).mockResolvedValue({ ok: true });
|
||||||
|
expect(await requirePermissionRateLimited("x")).toEqual({
|
||||||
|
id: 1,
|
||||||
|
rank: 7,
|
||||||
|
username: "admin",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("requireStaffRateLimited", () => {
|
||||||
|
it("redirects on rate limit", async () => {
|
||||||
|
vi.mocked(clientIp).mockResolvedValue("1.2.3.4");
|
||||||
|
vi.mocked(rateLimit).mockResolvedValue({ ok: false });
|
||||||
|
await requireStaffRateLimited();
|
||||||
|
expect(redirectSafe).toHaveBeenCalledWith(
|
||||||
|
"/admin?error=ratelimit",
|
||||||
|
"/admin",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
import { getMinStaffRank } from "./min-staff-rank";
|
||||||
|
|
||||||
|
vi.mock("@/lib/services/site-settings", () => ({
|
||||||
|
siteSettings: { get: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("getMinStaffRank", () => {
|
||||||
|
it("returns numeric value", async () => {
|
||||||
|
vi.mocked(siteSettings.get).mockResolvedValue("7");
|
||||||
|
expect(await getMinStaffRank()).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses fallback when setting missing", async () => {
|
||||||
|
vi.mocked(siteSettings.get).mockResolvedValue("");
|
||||||
|
expect(await getMinStaffRank(5)).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("floors values", async () => {
|
||||||
|
vi.mocked(siteSettings.get).mockResolvedValue("7.8");
|
||||||
|
expect(await getMinStaffRank()).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects zero", async () => {
|
||||||
|
vi.mocked(siteSettings.get).mockResolvedValue("0");
|
||||||
|
expect(await getMinStaffRank(3)).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
+1
-1
@@ -96,7 +96,7 @@ export const { handlers, signOut, auth } = NextAuth({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Byte-compatible AtomCMS check (argon2id/bcrypt + md5->argon2id upgrade).
|
// Byte-compatible AtomCMS check (bcrypt + md5->bcrypt upgrade).
|
||||||
const res = await checkLogin(password, user.password, {
|
const res = await checkLogin(password, user.password, {
|
||||||
convertPasswords: env.CONVERT_PASSWORDS,
|
convertPasswords: env.CONVERT_PASSWORDS,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import { hash as bcryptHash } from "bcrypt";
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const mockEnv = vi.hoisted(() => ({
|
const mockEnv = vi.hoisted(() => ({
|
||||||
PASSWORD_HASH: undefined as string | undefined,
|
|
||||||
ARGON2_PARALLELISM: 1,
|
|
||||||
ARGON2_ITERATIONS: 4,
|
|
||||||
ARGON2_MEMORY_SIZE: 65536,
|
|
||||||
BCRYPT_ROUNDS: 12,
|
BCRYPT_ROUNDS: 12,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -28,36 +23,20 @@ describe("md5Hex", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("hashPassword (default driver: bcrypt)", () => {
|
describe("hashPassword", () => {
|
||||||
it("emits a PHP-style $2y$ bcrypt hash that fits varchar(64) and round-trips", async () => {
|
it("emits a bcrypt hash and round-trips", async () => {
|
||||||
mockEnv.PASSWORD_HASH = undefined;
|
|
||||||
const h = await hashPassword("s3cret!");
|
const h = await hashPassword("s3cret!");
|
||||||
|
expect(h).toMatch(/^\$2y\$\d{2}\$/);
|
||||||
|
expect(await verifyPassword("s3cret!", h)).toBe(true);
|
||||||
|
expect(await verifyPassword("wrong", h)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("verifyPassword", () => {
|
||||||
|
it("verifies legacy bcrypt hashes ($2y$)", async () => {
|
||||||
|
const h = await hashPassword("hunter2");
|
||||||
expect(h).toMatch(/^\$2y\$/);
|
expect(h).toMatch(/^\$2y\$/);
|
||||||
expect(h.length).toBeLessThanOrEqual(60);
|
|
||||||
expect(await verifyPassword("s3cret!", h)).toBe(true);
|
|
||||||
expect(await verifyPassword("wrong", h)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("hashPassword (PASSWORD_HASH=argon2id)", () => {
|
|
||||||
it("hashes with the AtomCMS params and round-trips", async () => {
|
|
||||||
mockEnv.PASSWORD_HASH = "argon2id";
|
|
||||||
mockEnv.ARGON2_MEMORY_SIZE = 1024;
|
|
||||||
mockEnv.ARGON2_ITERATIONS = 1;
|
|
||||||
const h = await hashPassword("s3cret!");
|
|
||||||
expect(h).toMatch(/^\$argon2id\$v=19\$m=1024,t=1,p=1\$/);
|
|
||||||
expect(await verifyPassword("s3cret!", h)).toBe(true);
|
|
||||||
expect(await verifyPassword("wrong", h)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("bcrypt", () => {
|
|
||||||
it("verifies a bcrypt hash and accepts the PHP $2y$ prefix", async () => {
|
|
||||||
mockEnv.BCRYPT_ROUNDS = 4;
|
|
||||||
const h = await bcryptHash("hunter2", 4);
|
|
||||||
expect(await verifyPassword("hunter2", h)).toBe(true);
|
expect(await verifyPassword("hunter2", h)).toBe(true);
|
||||||
const phpStyle = h.replace(/^\$2[ab]\$/, "$2y$");
|
|
||||||
expect(await verifyPassword("hunter2", phpStyle)).toBe(true);
|
|
||||||
expect(await verifyPassword("nope", h)).toBe(false);
|
expect(await verifyPassword("nope", h)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -71,13 +50,11 @@ describe("isMd5Of", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("checkLogin", () => {
|
describe("checkLogin", () => {
|
||||||
it("upgrades a legacy md5 hash to the configured hash when conversion is enabled", async () => {
|
it("upgrades a legacy md5 hash to bcrypt when conversion is enabled", async () => {
|
||||||
mockEnv.PASSWORD_HASH = undefined;
|
|
||||||
const stored = await md5Hex("oldpass");
|
const stored = await md5Hex("oldpass");
|
||||||
const res = await checkLogin("oldpass", stored, { convertPasswords: true });
|
const res = await checkLogin("oldpass", stored, { convertPasswords: true });
|
||||||
expect(res.valid).toBe(true);
|
expect(res.valid).toBe(true);
|
||||||
expect(res.upgradedHash).toMatch(/^\$2y\$/);
|
expect(res.upgradedHash).toMatch(/^\$2y\$/);
|
||||||
expect((res.upgradedHash as string).length).toBeLessThanOrEqual(60);
|
|
||||||
expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe(
|
expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe(
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
@@ -93,7 +70,6 @@ describe("checkLogin", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("validates an existing modern hash with no upgrade", async () => {
|
it("validates an existing modern hash with no upgrade", async () => {
|
||||||
mockEnv.PASSWORD_HASH = undefined;
|
|
||||||
const stored = await hashPassword("modern");
|
const stored = await hashPassword("modern");
|
||||||
const res = await checkLogin("modern", stored, { convertPasswords: true });
|
const res = await checkLogin("modern", stored, { convertPasswords: true });
|
||||||
expect(res.valid).toBe(true);
|
expect(res.valid).toBe(true);
|
||||||
|
|||||||
+14
-74
@@ -1,66 +1,26 @@
|
|||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import { compare as bcryptCompare, hash as bcryptHash } from "bcrypt";
|
import {
|
||||||
import { argon2id, argon2Verify, md5 } from "hash-wasm";
|
bcrypt,
|
||||||
|
bcryptVerify,
|
||||||
|
md5,
|
||||||
|
} from "hash-wasm";
|
||||||
|
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
|
|
||||||
// AtomCMS hashing (config/hashing.php): argon2id with memory=65536 KiB, time=4,
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
// threads=1; bcrypt rounds=12 as the legacy fallback. The game emulator
|
const h = await bcrypt({
|
||||||
// validates the SAME users.password hash, so these must match.
|
password,
|
||||||
function argon2Params() {
|
salt: randomBytes(16),
|
||||||
return {
|
costFactor: env.BCRYPT_ROUNDS,
|
||||||
parallelism: env.ARGON2_PARALLELISM,
|
outputType: "encoded",
|
||||||
iterations: env.ARGON2_ITERATIONS,
|
});
|
||||||
memorySize: env.ARGON2_MEMORY_SIZE,
|
return h.replace(/^\$2[ab]\$/, "$2y$");
|
||||||
hashLength: 32,
|
|
||||||
} as const;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function bcryptRounds(): number {
|
|
||||||
return env.BCRYPT_ROUNDS;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Which algorithm hashPassword() emits for NEW/upgraded passwords.
|
|
||||||
// - "bcrypt" (DEFAULT): 60-char $2y$ hash. Fits varchar(255) users.password.
|
|
||||||
// - "argon2id": ~97-char PHC hash. Opt in with PASSWORD_HASH=argon2id.
|
|
||||||
// verifyPassword() always accepts BOTH, so logins keep working either way.
|
|
||||||
function hashDriver(): "bcrypt" | "argon2id" {
|
|
||||||
return env.PASSWORD_HASH === "argon2id" ? "argon2id" : "bcrypt";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lowercase hex md5 of a UTF-8 string (matches PHP md5()).
|
|
||||||
*
|
|
||||||
* This uses hash-wasm's MD5 (not node:crypto) to match PHP's md5() output,
|
|
||||||
* enabling verification of legacy AtomCMS password hashes during the on-login
|
|
||||||
* upgrade path (isMd5Of → checkLogin). It is NOT used to hash new passwords
|
|
||||||
* and does NOT affect credential security.
|
|
||||||
*/
|
|
||||||
export async function md5Hex(input: string): Promise<string> {
|
export async function md5Hex(input: string): Promise<string> {
|
||||||
return await md5(input);
|
return await md5(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Hash a new password with the configured driver. Defaults to bcrypt ($2y$,
|
|
||||||
* rounds=12) so the result fits a varchar(64) column; set PASSWORD_HASH=argon2id
|
|
||||||
* for argon2id (requires a wider column). Both are verifiable by verifyPassword.
|
|
||||||
*/
|
|
||||||
export async function hashPassword(password: string): Promise<string> {
|
|
||||||
if (hashDriver() === "argon2id") {
|
|
||||||
return argon2id({
|
|
||||||
password,
|
|
||||||
salt: randomBytes(16),
|
|
||||||
outputType: "encoded",
|
|
||||||
...argon2Params(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// native bcrypt emits $2a$/$2b$; normalise to the PHP-canonical $2y$ the
|
|
||||||
// emulator and existing AtomCMS rows use.
|
|
||||||
const h = await bcryptHash(password, bcryptRounds());
|
|
||||||
return h.replace(/^\$2[ab]\$/, "$2y$");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True when `stored` is exactly the md5 of `password` (legacy AtomCMS accounts). */
|
|
||||||
export async function isMd5Of(
|
export async function isMd5Of(
|
||||||
password: string,
|
password: string,
|
||||||
stored: string,
|
stored: string,
|
||||||
@@ -71,27 +31,13 @@ export async function isMd5Of(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify a password against a stored hash, auto-detecting the algorithm the way
|
|
||||||
* Laravel's Hash::check does. Returns false for unknown/legacy formats (md5 is
|
|
||||||
* handled by the conversion path in checkLogin, not here).
|
|
||||||
*/
|
|
||||||
export async function verifyPassword(
|
export async function verifyPassword(
|
||||||
password: string,
|
password: string,
|
||||||
stored: string,
|
stored: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (stored.startsWith("$argon2")) {
|
|
||||||
try {
|
|
||||||
return await argon2Verify({ password, hash: stored });
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (/^\$2[aby]\$/.test(stored)) {
|
if (/^\$2[aby]\$/.test(stored)) {
|
||||||
try {
|
try {
|
||||||
// PHP/AtomCMS store $2y$; native bcrypt only accepts $2a$/$2b$.
|
return await bcryptVerify({ password, hash: stored });
|
||||||
const normalized = stored.replace(/^\$2y\$/, "$2a$");
|
|
||||||
return await bcryptCompare(password, normalized);
|
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -101,15 +47,9 @@ export async function verifyPassword(
|
|||||||
|
|
||||||
export interface LoginCheck {
|
export interface LoginCheck {
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
/** Set when a legacy md5 hash was upgraded — persist it to users.password. */
|
|
||||||
upgradedHash?: string;
|
upgradedHash?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Full AtomCMS credential check including the md5 -> argon2id on-login upgrade
|
|
||||||
* (gated by `convertPasswords`, i.e. config('habbo.site.convert_passwords')).
|
|
||||||
* Mirrors RedirectIfTwoFactorAuthenticatable::convertUserPassword + validate.
|
|
||||||
*/
|
|
||||||
export async function checkLogin(
|
export async function checkLogin(
|
||||||
password: string,
|
password: string,
|
||||||
stored: string,
|
stored: string,
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
personalTokenScope,
|
||||||
|
USER_TOKENABLE_TYPE,
|
||||||
|
} from "./personal-token-scope";
|
||||||
|
|
||||||
|
describe("personal-token-scope", () => {
|
||||||
|
it("exports USER_TOKENABLE_TYPE constant", () => {
|
||||||
|
expect(USER_TOKENABLE_TYPE).toBe("App\\Models\\User");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns correct scope with BigInt", () => {
|
||||||
|
const r = personalTokenScope(42);
|
||||||
|
expect(r.tokenableId).toBe(BigInt(42));
|
||||||
|
expect(r.tokenableType).toBe("App\\Models\\User");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -68,7 +68,10 @@ describe("production deploy workflow", () => {
|
|||||||
const stopAt = deployJob.indexOf("pm2 stop next");
|
const stopAt = deployJob.indexOf("pm2 stop next");
|
||||||
const migrateAt = deployJob.indexOf("pnpm db:migrate");
|
const migrateAt = deployJob.indexOf("pnpm db:migrate");
|
||||||
const startLabelAt = deployJob.indexOf("Starting PM2");
|
const startLabelAt = deployJob.indexOf("Starting PM2");
|
||||||
const startAt = deployJob.indexOf("pm2 reload next", startLabelAt);
|
const startAt = deployJob.indexOf(
|
||||||
|
"pm2 start pnpm --name next -- start",
|
||||||
|
startLabelAt,
|
||||||
|
);
|
||||||
expect(buildAt).toBeGreaterThan(-1);
|
expect(buildAt).toBeGreaterThan(-1);
|
||||||
expect(stopAt).toBeGreaterThan(buildAt);
|
expect(stopAt).toBeGreaterThan(buildAt);
|
||||||
expect(migrateAt).toBeGreaterThan(stopAt);
|
expect(migrateAt).toBeGreaterThan(stopAt);
|
||||||
@@ -103,7 +106,9 @@ describe("production deploy workflow", () => {
|
|||||||
it("runs an HTTP health check before declaring deploy success", () => {
|
it("runs an HTTP health check before declaring deploy success", () => {
|
||||||
expect(workflow).toContain("/api/health");
|
expect(workflow).toContain("/api/health");
|
||||||
expect(workflow).toContain('"database":true');
|
expect(workflow).toContain('"database":true');
|
||||||
const startAt = deployJob.indexOf("pm2 reload next");
|
expect(deployJob).toContain("export PORT=");
|
||||||
|
expect(deployJob).toContain("free_tcp_port");
|
||||||
|
const startAt = deployJob.indexOf("pm2 start pnpm --name next -- start");
|
||||||
const healthAt = deployJob.indexOf("/api/health");
|
const healthAt = deployJob.indexOf("/api/health");
|
||||||
const successAt = deployJob.indexOf("--- Deployed successfully ---");
|
const successAt = deployJob.indexOf("--- Deployed successfully ---");
|
||||||
expect(startAt).toBeGreaterThan(-1);
|
expect(startAt).toBeGreaterThan(-1);
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { Variants } from "framer-motion";
|
import type { Variants } from "motion/react";
|
||||||
|
|
||||||
export const pageTransition: Variants = {
|
export const pageTransition: Variants = {
|
||||||
initial: { opacity: 0, y: 8 },
|
initial: { opacity: 0, y: 8 },
|
||||||
|
|||||||
+3
-71
@@ -1,76 +1,8 @@
|
|||||||
import sanitizeHtml from "sanitize-html";
|
import DOMPurify from "isomorphic-dompurify";
|
||||||
|
|
||||||
/**
|
const sanitizeHtml = (html: string) => DOMPurify.sanitize(html);
|
||||||
* Server-side HTML sanitiser for user/staff-authored rich content before it is
|
|
||||||
* injected via dangerouslySetInnerHTML — the AtomCMS HTMLPurifier equivalent.
|
|
||||||
* Allows a safe formatting subset (no <script>/<style>/<iframe>, no on* event
|
|
||||||
* handlers, no javascript: URLs); images/links are permitted with safe schemes.
|
|
||||||
*/
|
|
||||||
const OPTIONS: sanitizeHtml.IOptions = {
|
|
||||||
allowedTags: [
|
|
||||||
"a",
|
|
||||||
"b",
|
|
||||||
"i",
|
|
||||||
"em",
|
|
||||||
"strong",
|
|
||||||
"u",
|
|
||||||
"s",
|
|
||||||
"p",
|
|
||||||
"br",
|
|
||||||
"hr",
|
|
||||||
"span",
|
|
||||||
"div",
|
|
||||||
"ul",
|
|
||||||
"ol",
|
|
||||||
"li",
|
|
||||||
"blockquote",
|
|
||||||
"code",
|
|
||||||
"pre",
|
|
||||||
"h1",
|
|
||||||
"h2",
|
|
||||||
"h3",
|
|
||||||
"h4",
|
|
||||||
"h5",
|
|
||||||
"h6",
|
|
||||||
"img",
|
|
||||||
"figure",
|
|
||||||
"figcaption",
|
|
||||||
"table",
|
|
||||||
"thead",
|
|
||||||
"tbody",
|
|
||||||
"tr",
|
|
||||||
"th",
|
|
||||||
"td",
|
|
||||||
],
|
|
||||||
allowedAttributes: {
|
|
||||||
a: ["href", "title", "target", "rel"],
|
|
||||||
img: ["src", "alt", "title", "width", "height"],
|
|
||||||
"*": ["style", "class"],
|
|
||||||
},
|
|
||||||
allowedSchemes: ["http", "https", "mailto"],
|
|
||||||
allowedSchemesByTag: { img: ["http", "https", "data"] },
|
|
||||||
// Drop any style declarations that aren't simple, safe properties.
|
|
||||||
allowedStyles: {
|
|
||||||
"*": {
|
|
||||||
color: [/.*/],
|
|
||||||
"background-color": [/.*/],
|
|
||||||
"text-align": [/^left$|^right$|^center$|^justify$/],
|
|
||||||
"font-weight": [/.*/],
|
|
||||||
"font-style": [/.*/],
|
|
||||||
"text-decoration": [/.*/],
|
|
||||||
"font-size": [/.*/],
|
|
||||||
margin: [/.*/],
|
|
||||||
padding: [/.*/],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
transformTags: {
|
|
||||||
a: sanitizeHtml.simpleTransform("a", {
|
|
||||||
rel: "noopener noreferrer nofollow",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export function sanitize(html: string | null | undefined): string {
|
export function sanitize(html: string | null | undefined): string {
|
||||||
if (!html) return "";
|
if (!html) return "";
|
||||||
return sanitizeHtml(html, OPTIONS);
|
return sanitizeHtml(html);
|
||||||
}
|
}
|
||||||
@@ -1,29 +1,12 @@
|
|||||||
import { exec } from "node:child_process";
|
import { exec } from "node:child_process";
|
||||||
import { mkdir, writeFile } from "node:fs/promises";
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import nodemailer, { type Transporter } from "nodemailer";
|
|
||||||
import { Resend } from "resend";
|
import { Resend } from "resend";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
let transporter: Transporter | null = null;
|
|
||||||
let resend: Resend | null = null;
|
let resend: Resend | null = null;
|
||||||
|
|
||||||
function getTransport(): Transporter | null {
|
|
||||||
if (!env.SMTP_HOST) return null;
|
|
||||||
if (!transporter) {
|
|
||||||
transporter = nodemailer.createTransport({
|
|
||||||
host: env.SMTP_HOST,
|
|
||||||
port: env.SMTP_PORT ?? 587,
|
|
||||||
secure: env.SMTP_SECURE,
|
|
||||||
auth: env.SMTP_USER
|
|
||||||
? { user: env.SMTP_USER, pass: env.SMTP_PASSWORD }
|
|
||||||
: undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return transporter;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getResend(): Resend | null {
|
function getResend(): Resend | null {
|
||||||
if (!env.RESEND_API_KEY) return null;
|
if (!env.RESEND_API_KEY) return null;
|
||||||
if (!resend) resend = new Resend(env.RESEND_API_KEY);
|
if (!resend) resend = new Resend(env.RESEND_API_KEY);
|
||||||
@@ -90,7 +73,7 @@ async function writeToFile(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Send an HTML email. Tries Resend → SMTP → local sendmail → file fallback. Always returns true. */
|
/** Send an HTML email. Tries Resend → local sendmail → file fallback. Always returns true. */
|
||||||
export async function sendMail(
|
export async function sendMail(
|
||||||
to: string,
|
to: string,
|
||||||
subject: string,
|
subject: string,
|
||||||
@@ -111,19 +94,6 @@ export async function sendMail(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const t = getTransport();
|
|
||||||
if (t) {
|
|
||||||
try {
|
|
||||||
await t.sendMail({ from, to, subject, html });
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
logger.error("SMTP failed", {
|
|
||||||
module: "email",
|
|
||||||
error: (e as Error).message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ok = await sendViaSendmail(to, subject, html, from);
|
const ok = await sendViaSendmail(to, subject, html, from);
|
||||||
if (ok) return true;
|
if (ok) return true;
|
||||||
|
|
||||||
|
|||||||
+82
-5
@@ -23,7 +23,8 @@
|
|||||||
"closeMenu": "Затваряне на менюто",
|
"closeMenu": "Затваряне на менюто",
|
||||||
"drawBadge": "Начертайте значка",
|
"drawBadge": "Начертайте значка",
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"polls": "Polls"
|
"polls": "Polls",
|
||||||
|
"mod": "Mod"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"online": "{count} {hotel} онлайн",
|
"online": "{count} {hotel} онлайн",
|
||||||
@@ -906,7 +907,8 @@
|
|||||||
"chatLog": "Chat log",
|
"chatLog": "Chat log",
|
||||||
"commandLog": "Command log",
|
"commandLog": "Command log",
|
||||||
"tradeLog": "Trade log",
|
"tradeLog": "Trade log",
|
||||||
"tools": "Tools"
|
"tools": "Tools",
|
||||||
|
"guilds": "Guilds"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Табло за управление",
|
"title": "Табло за управление",
|
||||||
@@ -2213,7 +2215,24 @@
|
|||||||
"auditTarget": "Цел",
|
"auditTarget": "Цел",
|
||||||
"selectAllMatching": "Select all {count} matching",
|
"selectAllMatching": "Select all {count} matching",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"importTitle": "Import permissions"
|
"importTitle": "Import permissions",
|
||||||
|
"legacyBannerTitle": "Use Permissions to change who can do what",
|
||||||
|
"legacyBannerBody": "This archive does not control the site. Open Permissions to grant or revoke access for ranks.",
|
||||||
|
"legacyBannerCta": "Open Permissions",
|
||||||
|
"writesRetired": "Editing here is disabled on purpose. Change access only in Permissions.",
|
||||||
|
"tabCompare": "ACL compare",
|
||||||
|
"tabExportOnly": "Export archive",
|
||||||
|
"compareHint": "Compares legacy housekeeping min-rank rows to live ACL rank_* role grants (lowest rank that has the slug).",
|
||||||
|
"compareAligned": "{count} aligned",
|
||||||
|
"compareMissingAcl": "{count} missing in ACL",
|
||||||
|
"compareMismatch": "{count} rank mismatch",
|
||||||
|
"colAclPresent": "In ACL",
|
||||||
|
"colAclMinRank": "ACL min rank",
|
||||||
|
"colStatus": "Status",
|
||||||
|
"statusAligned": "Aligned",
|
||||||
|
"statusMissingAcl": "Missing in ACL",
|
||||||
|
"statusMismatch": "Rank differs",
|
||||||
|
"exportOnlyHint": "Writes are disabled. You can still export the legacy table for archival."
|
||||||
},
|
},
|
||||||
"rooms": {
|
"rooms": {
|
||||||
"title": "Стаи",
|
"title": "Стаи",
|
||||||
@@ -2442,7 +2461,9 @@
|
|||||||
"colPriority": "Priority",
|
"colPriority": "Priority",
|
||||||
"colAssigned": "Assigned",
|
"colAssigned": "Assigned",
|
||||||
"colDate": "Date",
|
"colDate": "Date",
|
||||||
"meta": "by {user} · {count} msg · {category}"
|
"meta": "by {user} · {count} msg · {category}",
|
||||||
|
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
|
||||||
|
"queueOtherCta": "Open help-center tickets"
|
||||||
},
|
},
|
||||||
"cfh": {
|
"cfh": {
|
||||||
"title": "Call for Help",
|
"title": "Call for Help",
|
||||||
@@ -2704,7 +2725,9 @@
|
|||||||
"replied": "Reply sent.",
|
"replied": "Reply sent.",
|
||||||
"closed": "Ticket closed.",
|
"closed": "Ticket closed.",
|
||||||
"reopened": "Ticket reopened."
|
"reopened": "Ticket reopened."
|
||||||
}
|
},
|
||||||
|
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
|
||||||
|
"queueOtherCta": "Open CMS support desk"
|
||||||
},
|
},
|
||||||
"marketplace": {
|
"marketplace": {
|
||||||
"title": "Marketplace",
|
"title": "Marketplace",
|
||||||
@@ -2799,6 +2822,31 @@
|
|||||||
"less": "Less",
|
"less": "Less",
|
||||||
"more": "More"
|
"more": "More"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"guilds": {
|
||||||
|
"title": "Guilds",
|
||||||
|
"subtitle": "Hotel groups and membership",
|
||||||
|
"totalGuilds": "Guilds",
|
||||||
|
"searchPlaceholder": "Search by name, description or ID…",
|
||||||
|
"search": "Search",
|
||||||
|
"empty": "No guilds found.",
|
||||||
|
"loadError": "Could not load guilds (database unavailable).",
|
||||||
|
"colId": "ID",
|
||||||
|
"colBadge": "Badge",
|
||||||
|
"colName": "Name",
|
||||||
|
"colOwner": "Owner",
|
||||||
|
"colMembers": "Members",
|
||||||
|
"colCreated": "Created",
|
||||||
|
"colThreads": "Forum threads",
|
||||||
|
"colRoom": "Room",
|
||||||
|
"colUser": "User",
|
||||||
|
"colLevel": "Level",
|
||||||
|
"colJoined": "Joined",
|
||||||
|
"members": "Members",
|
||||||
|
"noMembers": "No members.",
|
||||||
|
"back": "Back to guilds",
|
||||||
|
"viewPublic": "Open public page",
|
||||||
|
"disband": "Disband guild"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -2944,6 +2992,35 @@
|
|||||||
"voteCount": "{count} ({pct}%)",
|
"voteCount": "{count} ({pct}%)",
|
||||||
"totalVotes": "{count} total votes",
|
"totalVotes": "{count} total votes",
|
||||||
"textResponses": "{count} free-text responses"
|
"textResponses": "{count} free-text responses"
|
||||||
|
},
|
||||||
|
"mod": {
|
||||||
|
"title": "Moderation panel",
|
||||||
|
"badge": "Mod",
|
||||||
|
"overviewTitle": "Moderator overview",
|
||||||
|
"overviewSubtitle": "CFH, quick actions and bans without full admin access.",
|
||||||
|
"shortcutsTitle": "Shortcuts",
|
||||||
|
"nav": {
|
||||||
|
"overview": "Overview",
|
||||||
|
"cfh": "Call for Help",
|
||||||
|
"actions": "Quick actions",
|
||||||
|
"bans": "Bans",
|
||||||
|
"fullAdmin": "Full admin",
|
||||||
|
"backToSite": "Back to site",
|
||||||
|
"tickets": "Tickets",
|
||||||
|
"team": "Team",
|
||||||
|
"helpTickets": "Help-center tickets"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"openCfh": "Open CFH",
|
||||||
|
"bansToday": "Bans today",
|
||||||
|
"modsOnline": "Mods online",
|
||||||
|
"quickActions": "Quick actions",
|
||||||
|
"quickActionsHint": "Kick, mute, alert",
|
||||||
|
"openTickets": "Open tickets",
|
||||||
|
"team": "Mod team",
|
||||||
|
"teamHint": "Online staff & workload",
|
||||||
|
"openHelpTickets": "Open help tickets"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"emails": {
|
"emails": {
|
||||||
|
|||||||
+82
-5
@@ -23,7 +23,8 @@
|
|||||||
"closeMenu": "Zavřít nabídku",
|
"closeMenu": "Zavřít nabídku",
|
||||||
"drawBadge": "Nakreslete odznak",
|
"drawBadge": "Nakreslete odznak",
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"polls": "Polls"
|
"polls": "Polls",
|
||||||
|
"mod": "Mod"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"online": "{count} {hotel} online",
|
"online": "{count} {hotel} online",
|
||||||
@@ -906,7 +907,8 @@
|
|||||||
"chatLog": "Chat log",
|
"chatLog": "Chat log",
|
||||||
"commandLog": "Command log",
|
"commandLog": "Command log",
|
||||||
"tradeLog": "Trade log",
|
"tradeLog": "Trade log",
|
||||||
"tools": "Tools"
|
"tools": "Tools",
|
||||||
|
"guilds": "Guilds"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
@@ -2213,7 +2215,24 @@
|
|||||||
"auditTarget": "Cíl",
|
"auditTarget": "Cíl",
|
||||||
"selectAllMatching": "Select all {count} matching",
|
"selectAllMatching": "Select all {count} matching",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"importTitle": "Import permissions"
|
"importTitle": "Import permissions",
|
||||||
|
"legacyBannerTitle": "Use Permissions to change who can do what",
|
||||||
|
"legacyBannerBody": "This archive does not control the site. Open Permissions to grant or revoke access for ranks.",
|
||||||
|
"legacyBannerCta": "Open Permissions",
|
||||||
|
"writesRetired": "Editing here is disabled on purpose. Change access only in Permissions.",
|
||||||
|
"tabCompare": "ACL compare",
|
||||||
|
"tabExportOnly": "Export archive",
|
||||||
|
"compareHint": "Compares legacy housekeeping min-rank rows to live ACL rank_* role grants (lowest rank that has the slug).",
|
||||||
|
"compareAligned": "{count} aligned",
|
||||||
|
"compareMissingAcl": "{count} missing in ACL",
|
||||||
|
"compareMismatch": "{count} rank mismatch",
|
||||||
|
"colAclPresent": "In ACL",
|
||||||
|
"colAclMinRank": "ACL min rank",
|
||||||
|
"colStatus": "Status",
|
||||||
|
"statusAligned": "Aligned",
|
||||||
|
"statusMissingAcl": "Missing in ACL",
|
||||||
|
"statusMismatch": "Rank differs",
|
||||||
|
"exportOnlyHint": "Writes are disabled. You can still export the legacy table for archival."
|
||||||
},
|
},
|
||||||
"rooms": {
|
"rooms": {
|
||||||
"title": "Pokoje",
|
"title": "Pokoje",
|
||||||
@@ -2442,7 +2461,9 @@
|
|||||||
"colPriority": "Priority",
|
"colPriority": "Priority",
|
||||||
"colAssigned": "Assigned",
|
"colAssigned": "Assigned",
|
||||||
"colDate": "Date",
|
"colDate": "Date",
|
||||||
"meta": "by {user} · {count} msg · {category}"
|
"meta": "by {user} · {count} msg · {category}",
|
||||||
|
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
|
||||||
|
"queueOtherCta": "Open help-center tickets"
|
||||||
},
|
},
|
||||||
"cfh": {
|
"cfh": {
|
||||||
"title": "Call for Help",
|
"title": "Call for Help",
|
||||||
@@ -2704,7 +2725,9 @@
|
|||||||
"replied": "Reply sent.",
|
"replied": "Reply sent.",
|
||||||
"closed": "Ticket closed.",
|
"closed": "Ticket closed.",
|
||||||
"reopened": "Ticket reopened."
|
"reopened": "Ticket reopened."
|
||||||
}
|
},
|
||||||
|
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
|
||||||
|
"queueOtherCta": "Open CMS support desk"
|
||||||
},
|
},
|
||||||
"marketplace": {
|
"marketplace": {
|
||||||
"title": "Marketplace",
|
"title": "Marketplace",
|
||||||
@@ -2799,6 +2822,31 @@
|
|||||||
"less": "Less",
|
"less": "Less",
|
||||||
"more": "More"
|
"more": "More"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"guilds": {
|
||||||
|
"title": "Guilds",
|
||||||
|
"subtitle": "Hotel groups and membership",
|
||||||
|
"totalGuilds": "Guilds",
|
||||||
|
"searchPlaceholder": "Search by name, description or ID…",
|
||||||
|
"search": "Search",
|
||||||
|
"empty": "No guilds found.",
|
||||||
|
"loadError": "Could not load guilds (database unavailable).",
|
||||||
|
"colId": "ID",
|
||||||
|
"colBadge": "Badge",
|
||||||
|
"colName": "Name",
|
||||||
|
"colOwner": "Owner",
|
||||||
|
"colMembers": "Members",
|
||||||
|
"colCreated": "Created",
|
||||||
|
"colThreads": "Forum threads",
|
||||||
|
"colRoom": "Room",
|
||||||
|
"colUser": "User",
|
||||||
|
"colLevel": "Level",
|
||||||
|
"colJoined": "Joined",
|
||||||
|
"members": "Members",
|
||||||
|
"noMembers": "No members.",
|
||||||
|
"back": "Back to guilds",
|
||||||
|
"viewPublic": "Open public page",
|
||||||
|
"disband": "Disband guild"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -2944,6 +2992,35 @@
|
|||||||
"voteCount": "{count} ({pct}%)",
|
"voteCount": "{count} ({pct}%)",
|
||||||
"totalVotes": "{count} total votes",
|
"totalVotes": "{count} total votes",
|
||||||
"textResponses": "{count} free-text responses"
|
"textResponses": "{count} free-text responses"
|
||||||
|
},
|
||||||
|
"mod": {
|
||||||
|
"title": "Moderation panel",
|
||||||
|
"badge": "Mod",
|
||||||
|
"overviewTitle": "Moderator overview",
|
||||||
|
"overviewSubtitle": "CFH, quick actions and bans without full admin access.",
|
||||||
|
"shortcutsTitle": "Shortcuts",
|
||||||
|
"nav": {
|
||||||
|
"overview": "Overview",
|
||||||
|
"cfh": "Call for Help",
|
||||||
|
"actions": "Quick actions",
|
||||||
|
"bans": "Bans",
|
||||||
|
"fullAdmin": "Full admin",
|
||||||
|
"backToSite": "Back to site",
|
||||||
|
"tickets": "Tickets",
|
||||||
|
"team": "Team",
|
||||||
|
"helpTickets": "Help-center tickets"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"openCfh": "Open CFH",
|
||||||
|
"bansToday": "Bans today",
|
||||||
|
"modsOnline": "Mods online",
|
||||||
|
"quickActions": "Quick actions",
|
||||||
|
"quickActionsHint": "Kick, mute, alert",
|
||||||
|
"openTickets": "Open tickets",
|
||||||
|
"team": "Mod team",
|
||||||
|
"teamHint": "Online staff & workload",
|
||||||
|
"openHelpTickets": "Open help tickets"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"emails": {
|
"emails": {
|
||||||
|
|||||||
+82
-5
@@ -23,7 +23,8 @@
|
|||||||
"closeMenu": "Luk menuen",
|
"closeMenu": "Luk menuen",
|
||||||
"drawBadge": "Tegn badge",
|
"drawBadge": "Tegn badge",
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"polls": "Polls"
|
"polls": "Polls",
|
||||||
|
"mod": "Mod"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"online": "{count} {hotel} online",
|
"online": "{count} {hotel} online",
|
||||||
@@ -906,7 +907,8 @@
|
|||||||
"chatLog": "Chat log",
|
"chatLog": "Chat log",
|
||||||
"commandLog": "Command log",
|
"commandLog": "Command log",
|
||||||
"tradeLog": "Trade log",
|
"tradeLog": "Trade log",
|
||||||
"tools": "Tools"
|
"tools": "Tools",
|
||||||
|
"guilds": "Guilds"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
@@ -2213,7 +2215,24 @@
|
|||||||
"auditTarget": "Mål",
|
"auditTarget": "Mål",
|
||||||
"selectAllMatching": "Select all {count} matching",
|
"selectAllMatching": "Select all {count} matching",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"importTitle": "Import permissions"
|
"importTitle": "Import permissions",
|
||||||
|
"legacyBannerTitle": "Use Permissions to change who can do what",
|
||||||
|
"legacyBannerBody": "This archive does not control the site. Open Permissions to grant or revoke access for ranks.",
|
||||||
|
"legacyBannerCta": "Open Permissions",
|
||||||
|
"writesRetired": "Editing here is disabled on purpose. Change access only in Permissions.",
|
||||||
|
"tabCompare": "ACL compare",
|
||||||
|
"tabExportOnly": "Export archive",
|
||||||
|
"compareHint": "Compares legacy housekeeping min-rank rows to live ACL rank_* role grants (lowest rank that has the slug).",
|
||||||
|
"compareAligned": "{count} aligned",
|
||||||
|
"compareMissingAcl": "{count} missing in ACL",
|
||||||
|
"compareMismatch": "{count} rank mismatch",
|
||||||
|
"colAclPresent": "In ACL",
|
||||||
|
"colAclMinRank": "ACL min rank",
|
||||||
|
"colStatus": "Status",
|
||||||
|
"statusAligned": "Aligned",
|
||||||
|
"statusMissingAcl": "Missing in ACL",
|
||||||
|
"statusMismatch": "Rank differs",
|
||||||
|
"exportOnlyHint": "Writes are disabled. You can still export the legacy table for archival."
|
||||||
},
|
},
|
||||||
"rooms": {
|
"rooms": {
|
||||||
"title": "Værelser",
|
"title": "Værelser",
|
||||||
@@ -2442,7 +2461,9 @@
|
|||||||
"colPriority": "Priority",
|
"colPriority": "Priority",
|
||||||
"colAssigned": "Assigned",
|
"colAssigned": "Assigned",
|
||||||
"colDate": "Date",
|
"colDate": "Date",
|
||||||
"meta": "by {user} · {count} msg · {category}"
|
"meta": "by {user} · {count} msg · {category}",
|
||||||
|
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
|
||||||
|
"queueOtherCta": "Open help-center tickets"
|
||||||
},
|
},
|
||||||
"cfh": {
|
"cfh": {
|
||||||
"title": "Call for Help",
|
"title": "Call for Help",
|
||||||
@@ -2704,7 +2725,9 @@
|
|||||||
"replied": "Reply sent.",
|
"replied": "Reply sent.",
|
||||||
"closed": "Ticket closed.",
|
"closed": "Ticket closed.",
|
||||||
"reopened": "Ticket reopened."
|
"reopened": "Ticket reopened."
|
||||||
}
|
},
|
||||||
|
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
|
||||||
|
"queueOtherCta": "Open CMS support desk"
|
||||||
},
|
},
|
||||||
"marketplace": {
|
"marketplace": {
|
||||||
"title": "Marketplace",
|
"title": "Marketplace",
|
||||||
@@ -2799,6 +2822,31 @@
|
|||||||
"less": "Less",
|
"less": "Less",
|
||||||
"more": "More"
|
"more": "More"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"guilds": {
|
||||||
|
"title": "Guilds",
|
||||||
|
"subtitle": "Hotel groups and membership",
|
||||||
|
"totalGuilds": "Guilds",
|
||||||
|
"searchPlaceholder": "Search by name, description or ID…",
|
||||||
|
"search": "Search",
|
||||||
|
"empty": "No guilds found.",
|
||||||
|
"loadError": "Could not load guilds (database unavailable).",
|
||||||
|
"colId": "ID",
|
||||||
|
"colBadge": "Badge",
|
||||||
|
"colName": "Name",
|
||||||
|
"colOwner": "Owner",
|
||||||
|
"colMembers": "Members",
|
||||||
|
"colCreated": "Created",
|
||||||
|
"colThreads": "Forum threads",
|
||||||
|
"colRoom": "Room",
|
||||||
|
"colUser": "User",
|
||||||
|
"colLevel": "Level",
|
||||||
|
"colJoined": "Joined",
|
||||||
|
"members": "Members",
|
||||||
|
"noMembers": "No members.",
|
||||||
|
"back": "Back to guilds",
|
||||||
|
"viewPublic": "Open public page",
|
||||||
|
"disband": "Disband guild"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -2944,6 +2992,35 @@
|
|||||||
"voteCount": "{count} ({pct}%)",
|
"voteCount": "{count} ({pct}%)",
|
||||||
"totalVotes": "{count} total votes",
|
"totalVotes": "{count} total votes",
|
||||||
"textResponses": "{count} free-text responses"
|
"textResponses": "{count} free-text responses"
|
||||||
|
},
|
||||||
|
"mod": {
|
||||||
|
"title": "Moderation panel",
|
||||||
|
"badge": "Mod",
|
||||||
|
"overviewTitle": "Moderator overview",
|
||||||
|
"overviewSubtitle": "CFH, quick actions and bans without full admin access.",
|
||||||
|
"shortcutsTitle": "Shortcuts",
|
||||||
|
"nav": {
|
||||||
|
"overview": "Overview",
|
||||||
|
"cfh": "Call for Help",
|
||||||
|
"actions": "Quick actions",
|
||||||
|
"bans": "Bans",
|
||||||
|
"fullAdmin": "Full admin",
|
||||||
|
"backToSite": "Back to site",
|
||||||
|
"tickets": "Tickets",
|
||||||
|
"team": "Team",
|
||||||
|
"helpTickets": "Help-center tickets"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"openCfh": "Open CFH",
|
||||||
|
"bansToday": "Bans today",
|
||||||
|
"modsOnline": "Mods online",
|
||||||
|
"quickActions": "Quick actions",
|
||||||
|
"quickActionsHint": "Kick, mute, alert",
|
||||||
|
"openTickets": "Open tickets",
|
||||||
|
"team": "Mod team",
|
||||||
|
"teamHint": "Online staff & workload",
|
||||||
|
"openHelpTickets": "Open help tickets"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"emails": {
|
"emails": {
|
||||||
|
|||||||
+82
-5
@@ -23,7 +23,8 @@
|
|||||||
"closeMenu": "Close menu",
|
"closeMenu": "Close menu",
|
||||||
"drawBadge": "Abzeichen zeichnen",
|
"drawBadge": "Abzeichen zeichnen",
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"polls": "Polls"
|
"polls": "Polls",
|
||||||
|
"mod": "Mod"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"online": "{count} {hotel} online",
|
"online": "{count} {hotel} online",
|
||||||
@@ -876,7 +877,8 @@
|
|||||||
"chatLog": "Chat log",
|
"chatLog": "Chat log",
|
||||||
"commandLog": "Command log",
|
"commandLog": "Command log",
|
||||||
"tradeLog": "Trade log",
|
"tradeLog": "Trade log",
|
||||||
"tools": "Tools"
|
"tools": "Tools",
|
||||||
|
"guilds": "Guilds"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
@@ -2183,7 +2185,24 @@
|
|||||||
"auditTarget": "Ziel",
|
"auditTarget": "Ziel",
|
||||||
"selectAllMatching": "Select all {count} matching",
|
"selectAllMatching": "Select all {count} matching",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"importTitle": "Import permissions"
|
"importTitle": "Import permissions",
|
||||||
|
"legacyBannerTitle": "Use Permissions to change who can do what",
|
||||||
|
"legacyBannerBody": "This archive does not control the site. Open Permissions to grant or revoke access for ranks.",
|
||||||
|
"legacyBannerCta": "Open Permissions",
|
||||||
|
"writesRetired": "Editing here is disabled on purpose. Change access only in Permissions.",
|
||||||
|
"tabCompare": "ACL compare",
|
||||||
|
"tabExportOnly": "Export archive",
|
||||||
|
"compareHint": "Compares legacy housekeeping min-rank rows to live ACL rank_* role grants (lowest rank that has the slug).",
|
||||||
|
"compareAligned": "{count} aligned",
|
||||||
|
"compareMissingAcl": "{count} missing in ACL",
|
||||||
|
"compareMismatch": "{count} rank mismatch",
|
||||||
|
"colAclPresent": "In ACL",
|
||||||
|
"colAclMinRank": "ACL min rank",
|
||||||
|
"colStatus": "Status",
|
||||||
|
"statusAligned": "Aligned",
|
||||||
|
"statusMissingAcl": "Missing in ACL",
|
||||||
|
"statusMismatch": "Rank differs",
|
||||||
|
"exportOnlyHint": "Writes are disabled. You can still export the legacy table for archival."
|
||||||
},
|
},
|
||||||
"rooms": {
|
"rooms": {
|
||||||
"title": "Räume",
|
"title": "Räume",
|
||||||
@@ -2412,7 +2431,9 @@
|
|||||||
"colPriority": "Priority",
|
"colPriority": "Priority",
|
||||||
"colAssigned": "Assigned",
|
"colAssigned": "Assigned",
|
||||||
"colDate": "Date",
|
"colDate": "Date",
|
||||||
"meta": "by {user} · {count} msg · {category}"
|
"meta": "by {user} · {count} msg · {category}",
|
||||||
|
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
|
||||||
|
"queueOtherCta": "Open help-center tickets"
|
||||||
},
|
},
|
||||||
"cfh": {
|
"cfh": {
|
||||||
"title": "Call for Help",
|
"title": "Call for Help",
|
||||||
@@ -2674,7 +2695,9 @@
|
|||||||
"replied": "Reply sent.",
|
"replied": "Reply sent.",
|
||||||
"closed": "Ticket closed.",
|
"closed": "Ticket closed.",
|
||||||
"reopened": "Ticket reopened."
|
"reopened": "Ticket reopened."
|
||||||
}
|
},
|
||||||
|
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
|
||||||
|
"queueOtherCta": "Open CMS support desk"
|
||||||
},
|
},
|
||||||
"marketplace": {
|
"marketplace": {
|
||||||
"title": "Marketplace",
|
"title": "Marketplace",
|
||||||
@@ -2769,6 +2792,31 @@
|
|||||||
"less": "Less",
|
"less": "Less",
|
||||||
"more": "More"
|
"more": "More"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"guilds": {
|
||||||
|
"title": "Guilds",
|
||||||
|
"subtitle": "Hotel groups and membership",
|
||||||
|
"totalGuilds": "Guilds",
|
||||||
|
"searchPlaceholder": "Search by name, description or ID…",
|
||||||
|
"search": "Search",
|
||||||
|
"empty": "No guilds found.",
|
||||||
|
"loadError": "Could not load guilds (database unavailable).",
|
||||||
|
"colId": "ID",
|
||||||
|
"colBadge": "Badge",
|
||||||
|
"colName": "Name",
|
||||||
|
"colOwner": "Owner",
|
||||||
|
"colMembers": "Members",
|
||||||
|
"colCreated": "Created",
|
||||||
|
"colThreads": "Forum threads",
|
||||||
|
"colRoom": "Room",
|
||||||
|
"colUser": "User",
|
||||||
|
"colLevel": "Level",
|
||||||
|
"colJoined": "Joined",
|
||||||
|
"members": "Members",
|
||||||
|
"noMembers": "No members.",
|
||||||
|
"back": "Back to guilds",
|
||||||
|
"viewPublic": "Open public page",
|
||||||
|
"disband": "Disband guild"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"radioRequests": {
|
"radioRequests": {
|
||||||
@@ -2944,6 +2992,35 @@
|
|||||||
"voteCount": "{count} ({pct}%)",
|
"voteCount": "{count} ({pct}%)",
|
||||||
"totalVotes": "{count} total votes",
|
"totalVotes": "{count} total votes",
|
||||||
"textResponses": "{count} free-text responses"
|
"textResponses": "{count} free-text responses"
|
||||||
|
},
|
||||||
|
"mod": {
|
||||||
|
"title": "Moderation panel",
|
||||||
|
"badge": "Mod",
|
||||||
|
"overviewTitle": "Moderator overview",
|
||||||
|
"overviewSubtitle": "CFH, quick actions and bans without full admin access.",
|
||||||
|
"shortcutsTitle": "Shortcuts",
|
||||||
|
"nav": {
|
||||||
|
"overview": "Overview",
|
||||||
|
"cfh": "Call for Help",
|
||||||
|
"actions": "Quick actions",
|
||||||
|
"bans": "Bans",
|
||||||
|
"fullAdmin": "Full admin",
|
||||||
|
"backToSite": "Back to site",
|
||||||
|
"tickets": "Tickets",
|
||||||
|
"team": "Team",
|
||||||
|
"helpTickets": "Help-center tickets"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"openCfh": "Open CFH",
|
||||||
|
"bansToday": "Bans today",
|
||||||
|
"modsOnline": "Mods online",
|
||||||
|
"quickActions": "Quick actions",
|
||||||
|
"quickActionsHint": "Kick, mute, alert",
|
||||||
|
"openTickets": "Open tickets",
|
||||||
|
"team": "Mod team",
|
||||||
|
"teamHint": "Online staff & workload",
|
||||||
|
"openHelpTickets": "Open help tickets"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"emails": {
|
"emails": {
|
||||||
|
|||||||
+82
-5
@@ -23,7 +23,8 @@
|
|||||||
"closeMenu": "Κλείσιμο μενού",
|
"closeMenu": "Κλείσιμο μενού",
|
||||||
"drawBadge": "Σχεδιάστε το σήμα",
|
"drawBadge": "Σχεδιάστε το σήμα",
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"polls": "Polls"
|
"polls": "Polls",
|
||||||
|
"mod": "Mod"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"online": "{count} {hotel} στο διαδίκτυο",
|
"online": "{count} {hotel} στο διαδίκτυο",
|
||||||
@@ -906,7 +907,8 @@
|
|||||||
"chatLog": "Chat log",
|
"chatLog": "Chat log",
|
||||||
"commandLog": "Command log",
|
"commandLog": "Command log",
|
||||||
"tradeLog": "Trade log",
|
"tradeLog": "Trade log",
|
||||||
"tools": "Tools"
|
"tools": "Tools",
|
||||||
|
"guilds": "Guilds"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Ταμπλό",
|
"title": "Ταμπλό",
|
||||||
@@ -2213,7 +2215,24 @@
|
|||||||
"auditTarget": "Στόχος",
|
"auditTarget": "Στόχος",
|
||||||
"selectAllMatching": "Select all {count} matching",
|
"selectAllMatching": "Select all {count} matching",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"importTitle": "Import permissions"
|
"importTitle": "Import permissions",
|
||||||
|
"legacyBannerTitle": "Use Permissions to change who can do what",
|
||||||
|
"legacyBannerBody": "This archive does not control the site. Open Permissions to grant or revoke access for ranks.",
|
||||||
|
"legacyBannerCta": "Open Permissions",
|
||||||
|
"writesRetired": "Editing here is disabled on purpose. Change access only in Permissions.",
|
||||||
|
"tabCompare": "ACL compare",
|
||||||
|
"tabExportOnly": "Export archive",
|
||||||
|
"compareHint": "Compares legacy housekeeping min-rank rows to live ACL rank_* role grants (lowest rank that has the slug).",
|
||||||
|
"compareAligned": "{count} aligned",
|
||||||
|
"compareMissingAcl": "{count} missing in ACL",
|
||||||
|
"compareMismatch": "{count} rank mismatch",
|
||||||
|
"colAclPresent": "In ACL",
|
||||||
|
"colAclMinRank": "ACL min rank",
|
||||||
|
"colStatus": "Status",
|
||||||
|
"statusAligned": "Aligned",
|
||||||
|
"statusMissingAcl": "Missing in ACL",
|
||||||
|
"statusMismatch": "Rank differs",
|
||||||
|
"exportOnlyHint": "Writes are disabled. You can still export the legacy table for archival."
|
||||||
},
|
},
|
||||||
"rooms": {
|
"rooms": {
|
||||||
"title": "Δωμάτια",
|
"title": "Δωμάτια",
|
||||||
@@ -2442,7 +2461,9 @@
|
|||||||
"colPriority": "Priority",
|
"colPriority": "Priority",
|
||||||
"colAssigned": "Assigned",
|
"colAssigned": "Assigned",
|
||||||
"colDate": "Date",
|
"colDate": "Date",
|
||||||
"meta": "by {user} · {count} msg · {category}"
|
"meta": "by {user} · {count} msg · {category}",
|
||||||
|
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
|
||||||
|
"queueOtherCta": "Open help-center tickets"
|
||||||
},
|
},
|
||||||
"cfh": {
|
"cfh": {
|
||||||
"title": "Call for Help",
|
"title": "Call for Help",
|
||||||
@@ -2704,7 +2725,9 @@
|
|||||||
"replied": "Reply sent.",
|
"replied": "Reply sent.",
|
||||||
"closed": "Ticket closed.",
|
"closed": "Ticket closed.",
|
||||||
"reopened": "Ticket reopened."
|
"reopened": "Ticket reopened."
|
||||||
}
|
},
|
||||||
|
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
|
||||||
|
"queueOtherCta": "Open CMS support desk"
|
||||||
},
|
},
|
||||||
"marketplace": {
|
"marketplace": {
|
||||||
"title": "Marketplace",
|
"title": "Marketplace",
|
||||||
@@ -2799,6 +2822,31 @@
|
|||||||
"less": "Less",
|
"less": "Less",
|
||||||
"more": "More"
|
"more": "More"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"guilds": {
|
||||||
|
"title": "Guilds",
|
||||||
|
"subtitle": "Hotel groups and membership",
|
||||||
|
"totalGuilds": "Guilds",
|
||||||
|
"searchPlaceholder": "Search by name, description or ID…",
|
||||||
|
"search": "Search",
|
||||||
|
"empty": "No guilds found.",
|
||||||
|
"loadError": "Could not load guilds (database unavailable).",
|
||||||
|
"colId": "ID",
|
||||||
|
"colBadge": "Badge",
|
||||||
|
"colName": "Name",
|
||||||
|
"colOwner": "Owner",
|
||||||
|
"colMembers": "Members",
|
||||||
|
"colCreated": "Created",
|
||||||
|
"colThreads": "Forum threads",
|
||||||
|
"colRoom": "Room",
|
||||||
|
"colUser": "User",
|
||||||
|
"colLevel": "Level",
|
||||||
|
"colJoined": "Joined",
|
||||||
|
"members": "Members",
|
||||||
|
"noMembers": "No members.",
|
||||||
|
"back": "Back to guilds",
|
||||||
|
"viewPublic": "Open public page",
|
||||||
|
"disband": "Disband guild"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -2944,6 +2992,35 @@
|
|||||||
"voteCount": "{count} ({pct}%)",
|
"voteCount": "{count} ({pct}%)",
|
||||||
"totalVotes": "{count} total votes",
|
"totalVotes": "{count} total votes",
|
||||||
"textResponses": "{count} free-text responses"
|
"textResponses": "{count} free-text responses"
|
||||||
|
},
|
||||||
|
"mod": {
|
||||||
|
"title": "Moderation panel",
|
||||||
|
"badge": "Mod",
|
||||||
|
"overviewTitle": "Moderator overview",
|
||||||
|
"overviewSubtitle": "CFH, quick actions and bans without full admin access.",
|
||||||
|
"shortcutsTitle": "Shortcuts",
|
||||||
|
"nav": {
|
||||||
|
"overview": "Overview",
|
||||||
|
"cfh": "Call for Help",
|
||||||
|
"actions": "Quick actions",
|
||||||
|
"bans": "Bans",
|
||||||
|
"fullAdmin": "Full admin",
|
||||||
|
"backToSite": "Back to site",
|
||||||
|
"tickets": "Tickets",
|
||||||
|
"team": "Team",
|
||||||
|
"helpTickets": "Help-center tickets"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"openCfh": "Open CFH",
|
||||||
|
"bansToday": "Bans today",
|
||||||
|
"modsOnline": "Mods online",
|
||||||
|
"quickActions": "Quick actions",
|
||||||
|
"quickActionsHint": "Kick, mute, alert",
|
||||||
|
"openTickets": "Open tickets",
|
||||||
|
"team": "Mod team",
|
||||||
|
"teamHint": "Online staff & workload",
|
||||||
|
"openHelpTickets": "Open help tickets"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"emails": {
|
"emails": {
|
||||||
|
|||||||
+12
-6
@@ -1492,8 +1492,10 @@
|
|||||||
"settingsSaved": "VPN settings saved successfully"
|
"settingsSaved": "VPN settings saved successfully"
|
||||||
},
|
},
|
||||||
"tickets": {
|
"tickets": {
|
||||||
"title": "Tickets",
|
"title": "CMS support desk",
|
||||||
"subtitle": "Support tickets from players",
|
"subtitle": "Internal CMS tickets (assignee, priority, staff workflow). Not the same as help-center tickets from /help.",
|
||||||
|
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
|
||||||
|
"queueOtherCta": "Open help-center tickets",
|
||||||
"noTickets": "No active tickets",
|
"noTickets": "No active tickets",
|
||||||
"searchPlaceholder": "Search subject, user, category or ID…",
|
"searchPlaceholder": "Search subject, user, category or ID…",
|
||||||
"presetActive": "Active",
|
"presetActive": "Active",
|
||||||
@@ -1510,7 +1512,9 @@
|
|||||||
},
|
},
|
||||||
"helpTickets": {
|
"helpTickets": {
|
||||||
"title": "Help center tickets",
|
"title": "Help center tickets",
|
||||||
"subtitle": "Player tickets from /help/tickets",
|
"subtitle": "Tickets players open at /help/tickets. Separate from the CMS support desk.",
|
||||||
|
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
|
||||||
|
"queueOtherCta": "Open CMS support desk",
|
||||||
"noTickets": "No open help center tickets",
|
"noTickets": "No open help center tickets",
|
||||||
"searchPlaceholder": "Search title, user or ID…",
|
"searchPlaceholder": "Search title, user or ID…",
|
||||||
"presetOpen": "Open",
|
"presetOpen": "Open",
|
||||||
@@ -2520,7 +2524,7 @@
|
|||||||
"events": "Events",
|
"events": "Events",
|
||||||
"eventsSubtitle": "Hotel events and event types",
|
"eventsSubtitle": "Hotel events and event types",
|
||||||
"tickets": "Support desk",
|
"tickets": "Support desk",
|
||||||
"ticketsSubtitle": "CMS tickets, help-center tickets, and reply templates",
|
"ticketsSubtitle": "Two separate queues: CMS support desk and public help-center tickets",
|
||||||
"staffAccess": "Ranks & permissions",
|
"staffAccess": "Ranks & permissions",
|
||||||
"staffAccessSubtitle": "Staff ranks and live access. Archive is compare/export only.",
|
"staffAccessSubtitle": "Staff ranks and live access. Archive is compare/export only.",
|
||||||
"analytics": "Analytics",
|
"analytics": "Analytics",
|
||||||
@@ -3004,7 +3008,8 @@
|
|||||||
"bans": "Bans",
|
"bans": "Bans",
|
||||||
"fullAdmin": "Full admin",
|
"fullAdmin": "Full admin",
|
||||||
"backToSite": "Back to site",
|
"backToSite": "Back to site",
|
||||||
"tickets": "Tickets",
|
"tickets": "CMS tickets",
|
||||||
|
"helpTickets": "Help-center tickets",
|
||||||
"team": "Team"
|
"team": "Team"
|
||||||
},
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
@@ -3013,7 +3018,8 @@
|
|||||||
"modsOnline": "Mods online",
|
"modsOnline": "Mods online",
|
||||||
"quickActions": "Quick actions",
|
"quickActions": "Quick actions",
|
||||||
"quickActionsHint": "Kick, mute, alert",
|
"quickActionsHint": "Kick, mute, alert",
|
||||||
"openTickets": "Open tickets",
|
"openTickets": "Open CMS tickets",
|
||||||
|
"openHelpTickets": "Open help tickets",
|
||||||
"team": "Mod team",
|
"team": "Mod team",
|
||||||
"teamHint": "Online staff & workload"
|
"teamHint": "Online staff & workload"
|
||||||
}
|
}
|
||||||
|
|||||||
+82
-5
@@ -23,7 +23,8 @@
|
|||||||
"closeMenu": "Close menu",
|
"closeMenu": "Close menu",
|
||||||
"drawBadge": "Diseñar insignia",
|
"drawBadge": "Diseñar insignia",
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"polls": "Polls"
|
"polls": "Polls",
|
||||||
|
"mod": "Mod"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"online": "{count} {hotel} en línea",
|
"online": "{count} {hotel} en línea",
|
||||||
@@ -876,7 +877,8 @@
|
|||||||
"chatLog": "Chat log",
|
"chatLog": "Chat log",
|
||||||
"commandLog": "Command log",
|
"commandLog": "Command log",
|
||||||
"tradeLog": "Trade log",
|
"tradeLog": "Trade log",
|
||||||
"tools": "Tools"
|
"tools": "Tools",
|
||||||
|
"guilds": "Guilds"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Panel",
|
"title": "Panel",
|
||||||
@@ -2183,7 +2185,24 @@
|
|||||||
"auditTarget": "Objetivo",
|
"auditTarget": "Objetivo",
|
||||||
"selectAllMatching": "Select all {count} matching",
|
"selectAllMatching": "Select all {count} matching",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"importTitle": "Import permissions"
|
"importTitle": "Import permissions",
|
||||||
|
"legacyBannerTitle": "Use Permissions to change who can do what",
|
||||||
|
"legacyBannerBody": "This archive does not control the site. Open Permissions to grant or revoke access for ranks.",
|
||||||
|
"legacyBannerCta": "Open Permissions",
|
||||||
|
"writesRetired": "Editing here is disabled on purpose. Change access only in Permissions.",
|
||||||
|
"tabCompare": "ACL compare",
|
||||||
|
"tabExportOnly": "Export archive",
|
||||||
|
"compareHint": "Compares legacy housekeeping min-rank rows to live ACL rank_* role grants (lowest rank that has the slug).",
|
||||||
|
"compareAligned": "{count} aligned",
|
||||||
|
"compareMissingAcl": "{count} missing in ACL",
|
||||||
|
"compareMismatch": "{count} rank mismatch",
|
||||||
|
"colAclPresent": "In ACL",
|
||||||
|
"colAclMinRank": "ACL min rank",
|
||||||
|
"colStatus": "Status",
|
||||||
|
"statusAligned": "Aligned",
|
||||||
|
"statusMissingAcl": "Missing in ACL",
|
||||||
|
"statusMismatch": "Rank differs",
|
||||||
|
"exportOnlyHint": "Writes are disabled. You can still export the legacy table for archival."
|
||||||
},
|
},
|
||||||
"rooms": {
|
"rooms": {
|
||||||
"title": "Salas",
|
"title": "Salas",
|
||||||
@@ -2412,7 +2431,9 @@
|
|||||||
"colPriority": "Priority",
|
"colPriority": "Priority",
|
||||||
"colAssigned": "Assigned",
|
"colAssigned": "Assigned",
|
||||||
"colDate": "Date",
|
"colDate": "Date",
|
||||||
"meta": "by {user} · {count} msg · {category}"
|
"meta": "by {user} · {count} msg · {category}",
|
||||||
|
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
|
||||||
|
"queueOtherCta": "Open help-center tickets"
|
||||||
},
|
},
|
||||||
"cfh": {
|
"cfh": {
|
||||||
"title": "Call for Help",
|
"title": "Call for Help",
|
||||||
@@ -2674,7 +2695,9 @@
|
|||||||
"replied": "Reply sent.",
|
"replied": "Reply sent.",
|
||||||
"closed": "Ticket closed.",
|
"closed": "Ticket closed.",
|
||||||
"reopened": "Ticket reopened."
|
"reopened": "Ticket reopened."
|
||||||
}
|
},
|
||||||
|
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
|
||||||
|
"queueOtherCta": "Open CMS support desk"
|
||||||
},
|
},
|
||||||
"marketplace": {
|
"marketplace": {
|
||||||
"title": "Marketplace",
|
"title": "Marketplace",
|
||||||
@@ -2769,6 +2792,31 @@
|
|||||||
"less": "Less",
|
"less": "Less",
|
||||||
"more": "More"
|
"more": "More"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"guilds": {
|
||||||
|
"title": "Guilds",
|
||||||
|
"subtitle": "Hotel groups and membership",
|
||||||
|
"totalGuilds": "Guilds",
|
||||||
|
"searchPlaceholder": "Search by name, description or ID…",
|
||||||
|
"search": "Search",
|
||||||
|
"empty": "No guilds found.",
|
||||||
|
"loadError": "Could not load guilds (database unavailable).",
|
||||||
|
"colId": "ID",
|
||||||
|
"colBadge": "Badge",
|
||||||
|
"colName": "Name",
|
||||||
|
"colOwner": "Owner",
|
||||||
|
"colMembers": "Members",
|
||||||
|
"colCreated": "Created",
|
||||||
|
"colThreads": "Forum threads",
|
||||||
|
"colRoom": "Room",
|
||||||
|
"colUser": "User",
|
||||||
|
"colLevel": "Level",
|
||||||
|
"colJoined": "Joined",
|
||||||
|
"members": "Members",
|
||||||
|
"noMembers": "No members.",
|
||||||
|
"back": "Back to guilds",
|
||||||
|
"viewPublic": "Open public page",
|
||||||
|
"disband": "Disband guild"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"radioRequests": {
|
"radioRequests": {
|
||||||
@@ -2944,6 +2992,35 @@
|
|||||||
"voteCount": "{count} ({pct}%)",
|
"voteCount": "{count} ({pct}%)",
|
||||||
"totalVotes": "{count} total votes",
|
"totalVotes": "{count} total votes",
|
||||||
"textResponses": "{count} free-text responses"
|
"textResponses": "{count} free-text responses"
|
||||||
|
},
|
||||||
|
"mod": {
|
||||||
|
"title": "Moderation panel",
|
||||||
|
"badge": "Mod",
|
||||||
|
"overviewTitle": "Moderator overview",
|
||||||
|
"overviewSubtitle": "CFH, quick actions and bans without full admin access.",
|
||||||
|
"shortcutsTitle": "Shortcuts",
|
||||||
|
"nav": {
|
||||||
|
"overview": "Overview",
|
||||||
|
"cfh": "Call for Help",
|
||||||
|
"actions": "Quick actions",
|
||||||
|
"bans": "Bans",
|
||||||
|
"fullAdmin": "Full admin",
|
||||||
|
"backToSite": "Back to site",
|
||||||
|
"tickets": "Tickets",
|
||||||
|
"team": "Team",
|
||||||
|
"helpTickets": "Help-center tickets"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"openCfh": "Open CFH",
|
||||||
|
"bansToday": "Bans today",
|
||||||
|
"modsOnline": "Mods online",
|
||||||
|
"quickActions": "Quick actions",
|
||||||
|
"quickActionsHint": "Kick, mute, alert",
|
||||||
|
"openTickets": "Open tickets",
|
||||||
|
"team": "Mod team",
|
||||||
|
"teamHint": "Online staff & workload",
|
||||||
|
"openHelpTickets": "Open help tickets"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"emails": {
|
"emails": {
|
||||||
|
|||||||
+82
-5
@@ -23,7 +23,8 @@
|
|||||||
"closeMenu": "Close menu",
|
"closeMenu": "Close menu",
|
||||||
"drawBadge": "Dessiner un badge",
|
"drawBadge": "Dessiner un badge",
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"polls": "Polls"
|
"polls": "Polls",
|
||||||
|
"mod": "Mod"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"online": "{count} {hotel} en ligne",
|
"online": "{count} {hotel} en ligne",
|
||||||
@@ -876,7 +877,8 @@
|
|||||||
"chatLog": "Chat log",
|
"chatLog": "Chat log",
|
||||||
"commandLog": "Command log",
|
"commandLog": "Command log",
|
||||||
"tradeLog": "Trade log",
|
"tradeLog": "Trade log",
|
||||||
"tools": "Tools"
|
"tools": "Tools",
|
||||||
|
"guilds": "Guilds"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Tableau de bord",
|
"title": "Tableau de bord",
|
||||||
@@ -2183,7 +2185,24 @@
|
|||||||
"auditTarget": "Cible",
|
"auditTarget": "Cible",
|
||||||
"selectAllMatching": "Select all {count} matching",
|
"selectAllMatching": "Select all {count} matching",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"importTitle": "Import permissions"
|
"importTitle": "Import permissions",
|
||||||
|
"legacyBannerTitle": "Use Permissions to change who can do what",
|
||||||
|
"legacyBannerBody": "This archive does not control the site. Open Permissions to grant or revoke access for ranks.",
|
||||||
|
"legacyBannerCta": "Open Permissions",
|
||||||
|
"writesRetired": "Editing here is disabled on purpose. Change access only in Permissions.",
|
||||||
|
"tabCompare": "ACL compare",
|
||||||
|
"tabExportOnly": "Export archive",
|
||||||
|
"compareHint": "Compares legacy housekeeping min-rank rows to live ACL rank_* role grants (lowest rank that has the slug).",
|
||||||
|
"compareAligned": "{count} aligned",
|
||||||
|
"compareMissingAcl": "{count} missing in ACL",
|
||||||
|
"compareMismatch": "{count} rank mismatch",
|
||||||
|
"colAclPresent": "In ACL",
|
||||||
|
"colAclMinRank": "ACL min rank",
|
||||||
|
"colStatus": "Status",
|
||||||
|
"statusAligned": "Aligned",
|
||||||
|
"statusMissingAcl": "Missing in ACL",
|
||||||
|
"statusMismatch": "Rank differs",
|
||||||
|
"exportOnlyHint": "Writes are disabled. You can still export the legacy table for archival."
|
||||||
},
|
},
|
||||||
"rooms": {
|
"rooms": {
|
||||||
"title": "Salons",
|
"title": "Salons",
|
||||||
@@ -2412,7 +2431,9 @@
|
|||||||
"colPriority": "Priority",
|
"colPriority": "Priority",
|
||||||
"colAssigned": "Assigned",
|
"colAssigned": "Assigned",
|
||||||
"colDate": "Date",
|
"colDate": "Date",
|
||||||
"meta": "by {user} · {count} msg · {category}"
|
"meta": "by {user} · {count} msg · {category}",
|
||||||
|
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
|
||||||
|
"queueOtherCta": "Open help-center tickets"
|
||||||
},
|
},
|
||||||
"cfh": {
|
"cfh": {
|
||||||
"title": "Call for Help",
|
"title": "Call for Help",
|
||||||
@@ -2674,7 +2695,9 @@
|
|||||||
"replied": "Reply sent.",
|
"replied": "Reply sent.",
|
||||||
"closed": "Ticket closed.",
|
"closed": "Ticket closed.",
|
||||||
"reopened": "Ticket reopened."
|
"reopened": "Ticket reopened."
|
||||||
}
|
},
|
||||||
|
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
|
||||||
|
"queueOtherCta": "Open CMS support desk"
|
||||||
},
|
},
|
||||||
"marketplace": {
|
"marketplace": {
|
||||||
"title": "Marketplace",
|
"title": "Marketplace",
|
||||||
@@ -2769,6 +2792,31 @@
|
|||||||
"less": "Less",
|
"less": "Less",
|
||||||
"more": "More"
|
"more": "More"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"guilds": {
|
||||||
|
"title": "Guilds",
|
||||||
|
"subtitle": "Hotel groups and membership",
|
||||||
|
"totalGuilds": "Guilds",
|
||||||
|
"searchPlaceholder": "Search by name, description or ID…",
|
||||||
|
"search": "Search",
|
||||||
|
"empty": "No guilds found.",
|
||||||
|
"loadError": "Could not load guilds (database unavailable).",
|
||||||
|
"colId": "ID",
|
||||||
|
"colBadge": "Badge",
|
||||||
|
"colName": "Name",
|
||||||
|
"colOwner": "Owner",
|
||||||
|
"colMembers": "Members",
|
||||||
|
"colCreated": "Created",
|
||||||
|
"colThreads": "Forum threads",
|
||||||
|
"colRoom": "Room",
|
||||||
|
"colUser": "User",
|
||||||
|
"colLevel": "Level",
|
||||||
|
"colJoined": "Joined",
|
||||||
|
"members": "Members",
|
||||||
|
"noMembers": "No members.",
|
||||||
|
"back": "Back to guilds",
|
||||||
|
"viewPublic": "Open public page",
|
||||||
|
"disband": "Disband guild"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"radioRequests": {
|
"radioRequests": {
|
||||||
@@ -2944,6 +2992,35 @@
|
|||||||
"voteCount": "{count} ({pct}%)",
|
"voteCount": "{count} ({pct}%)",
|
||||||
"totalVotes": "{count} total votes",
|
"totalVotes": "{count} total votes",
|
||||||
"textResponses": "{count} free-text responses"
|
"textResponses": "{count} free-text responses"
|
||||||
|
},
|
||||||
|
"mod": {
|
||||||
|
"title": "Moderation panel",
|
||||||
|
"badge": "Mod",
|
||||||
|
"overviewTitle": "Moderator overview",
|
||||||
|
"overviewSubtitle": "CFH, quick actions and bans without full admin access.",
|
||||||
|
"shortcutsTitle": "Shortcuts",
|
||||||
|
"nav": {
|
||||||
|
"overview": "Overview",
|
||||||
|
"cfh": "Call for Help",
|
||||||
|
"actions": "Quick actions",
|
||||||
|
"bans": "Bans",
|
||||||
|
"fullAdmin": "Full admin",
|
||||||
|
"backToSite": "Back to site",
|
||||||
|
"tickets": "Tickets",
|
||||||
|
"team": "Team",
|
||||||
|
"helpTickets": "Help-center tickets"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"openCfh": "Open CFH",
|
||||||
|
"bansToday": "Bans today",
|
||||||
|
"modsOnline": "Mods online",
|
||||||
|
"quickActions": "Quick actions",
|
||||||
|
"quickActionsHint": "Kick, mute, alert",
|
||||||
|
"openTickets": "Open tickets",
|
||||||
|
"team": "Mod team",
|
||||||
|
"teamHint": "Online staff & workload",
|
||||||
|
"openHelpTickets": "Open help tickets"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"emails": {
|
"emails": {
|
||||||
|
|||||||
+82
-5
@@ -23,7 +23,8 @@
|
|||||||
"closeMenu": "Zatvori izbornik",
|
"closeMenu": "Zatvori izbornik",
|
||||||
"drawBadge": "Nacrtajte značku",
|
"drawBadge": "Nacrtajte značku",
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"polls": "Polls"
|
"polls": "Polls",
|
||||||
|
"mod": "Mod"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"online": "{count} {hotel} online",
|
"online": "{count} {hotel} online",
|
||||||
@@ -906,7 +907,8 @@
|
|||||||
"chatLog": "Chat log",
|
"chatLog": "Chat log",
|
||||||
"commandLog": "Command log",
|
"commandLog": "Command log",
|
||||||
"tradeLog": "Trade log",
|
"tradeLog": "Trade log",
|
||||||
"tools": "Tools"
|
"tools": "Tools",
|
||||||
|
"guilds": "Guilds"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Nadzorna ploča",
|
"title": "Nadzorna ploča",
|
||||||
@@ -2213,7 +2215,24 @@
|
|||||||
"auditTarget": "cilj",
|
"auditTarget": "cilj",
|
||||||
"selectAllMatching": "Select all {count} matching",
|
"selectAllMatching": "Select all {count} matching",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"importTitle": "Import permissions"
|
"importTitle": "Import permissions",
|
||||||
|
"legacyBannerTitle": "Use Permissions to change who can do what",
|
||||||
|
"legacyBannerBody": "This archive does not control the site. Open Permissions to grant or revoke access for ranks.",
|
||||||
|
"legacyBannerCta": "Open Permissions",
|
||||||
|
"writesRetired": "Editing here is disabled on purpose. Change access only in Permissions.",
|
||||||
|
"tabCompare": "ACL compare",
|
||||||
|
"tabExportOnly": "Export archive",
|
||||||
|
"compareHint": "Compares legacy housekeeping min-rank rows to live ACL rank_* role grants (lowest rank that has the slug).",
|
||||||
|
"compareAligned": "{count} aligned",
|
||||||
|
"compareMissingAcl": "{count} missing in ACL",
|
||||||
|
"compareMismatch": "{count} rank mismatch",
|
||||||
|
"colAclPresent": "In ACL",
|
||||||
|
"colAclMinRank": "ACL min rank",
|
||||||
|
"colStatus": "Status",
|
||||||
|
"statusAligned": "Aligned",
|
||||||
|
"statusMissingAcl": "Missing in ACL",
|
||||||
|
"statusMismatch": "Rank differs",
|
||||||
|
"exportOnlyHint": "Writes are disabled. You can still export the legacy table for archival."
|
||||||
},
|
},
|
||||||
"rooms": {
|
"rooms": {
|
||||||
"title": "Sobe",
|
"title": "Sobe",
|
||||||
@@ -2442,7 +2461,9 @@
|
|||||||
"colPriority": "Priority",
|
"colPriority": "Priority",
|
||||||
"colAssigned": "Assigned",
|
"colAssigned": "Assigned",
|
||||||
"colDate": "Date",
|
"colDate": "Date",
|
||||||
"meta": "by {user} · {count} msg · {category}"
|
"meta": "by {user} · {count} msg · {category}",
|
||||||
|
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
|
||||||
|
"queueOtherCta": "Open help-center tickets"
|
||||||
},
|
},
|
||||||
"cfh": {
|
"cfh": {
|
||||||
"title": "Call for Help",
|
"title": "Call for Help",
|
||||||
@@ -2704,7 +2725,9 @@
|
|||||||
"replied": "Reply sent.",
|
"replied": "Reply sent.",
|
||||||
"closed": "Ticket closed.",
|
"closed": "Ticket closed.",
|
||||||
"reopened": "Ticket reopened."
|
"reopened": "Ticket reopened."
|
||||||
}
|
},
|
||||||
|
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
|
||||||
|
"queueOtherCta": "Open CMS support desk"
|
||||||
},
|
},
|
||||||
"marketplace": {
|
"marketplace": {
|
||||||
"title": "Marketplace",
|
"title": "Marketplace",
|
||||||
@@ -2799,6 +2822,31 @@
|
|||||||
"less": "Less",
|
"less": "Less",
|
||||||
"more": "More"
|
"more": "More"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"guilds": {
|
||||||
|
"title": "Guilds",
|
||||||
|
"subtitle": "Hotel groups and membership",
|
||||||
|
"totalGuilds": "Guilds",
|
||||||
|
"searchPlaceholder": "Search by name, description or ID…",
|
||||||
|
"search": "Search",
|
||||||
|
"empty": "No guilds found.",
|
||||||
|
"loadError": "Could not load guilds (database unavailable).",
|
||||||
|
"colId": "ID",
|
||||||
|
"colBadge": "Badge",
|
||||||
|
"colName": "Name",
|
||||||
|
"colOwner": "Owner",
|
||||||
|
"colMembers": "Members",
|
||||||
|
"colCreated": "Created",
|
||||||
|
"colThreads": "Forum threads",
|
||||||
|
"colRoom": "Room",
|
||||||
|
"colUser": "User",
|
||||||
|
"colLevel": "Level",
|
||||||
|
"colJoined": "Joined",
|
||||||
|
"members": "Members",
|
||||||
|
"noMembers": "No members.",
|
||||||
|
"back": "Back to guilds",
|
||||||
|
"viewPublic": "Open public page",
|
||||||
|
"disband": "Disband guild"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -2944,6 +2992,35 @@
|
|||||||
"voteCount": "{count} ({pct}%)",
|
"voteCount": "{count} ({pct}%)",
|
||||||
"totalVotes": "{count} total votes",
|
"totalVotes": "{count} total votes",
|
||||||
"textResponses": "{count} free-text responses"
|
"textResponses": "{count} free-text responses"
|
||||||
|
},
|
||||||
|
"mod": {
|
||||||
|
"title": "Moderation panel",
|
||||||
|
"badge": "Mod",
|
||||||
|
"overviewTitle": "Moderator overview",
|
||||||
|
"overviewSubtitle": "CFH, quick actions and bans without full admin access.",
|
||||||
|
"shortcutsTitle": "Shortcuts",
|
||||||
|
"nav": {
|
||||||
|
"overview": "Overview",
|
||||||
|
"cfh": "Call for Help",
|
||||||
|
"actions": "Quick actions",
|
||||||
|
"bans": "Bans",
|
||||||
|
"fullAdmin": "Full admin",
|
||||||
|
"backToSite": "Back to site",
|
||||||
|
"tickets": "Tickets",
|
||||||
|
"team": "Team",
|
||||||
|
"helpTickets": "Help-center tickets"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"openCfh": "Open CFH",
|
||||||
|
"bansToday": "Bans today",
|
||||||
|
"modsOnline": "Mods online",
|
||||||
|
"quickActions": "Quick actions",
|
||||||
|
"quickActionsHint": "Kick, mute, alert",
|
||||||
|
"openTickets": "Open tickets",
|
||||||
|
"team": "Mod team",
|
||||||
|
"teamHint": "Online staff & workload",
|
||||||
|
"openHelpTickets": "Open help tickets"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"emails": {
|
"emails": {
|
||||||
|
|||||||
+82
-5
@@ -23,7 +23,8 @@
|
|||||||
"closeMenu": "Menü bezárása",
|
"closeMenu": "Menü bezárása",
|
||||||
"drawBadge": "Rajzolj jelvényt",
|
"drawBadge": "Rajzolj jelvényt",
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"polls": "Polls"
|
"polls": "Polls",
|
||||||
|
"mod": "Mod"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"online": "{count} {hotel} online",
|
"online": "{count} {hotel} online",
|
||||||
@@ -906,7 +907,8 @@
|
|||||||
"chatLog": "Chat log",
|
"chatLog": "Chat log",
|
||||||
"commandLog": "Command log",
|
"commandLog": "Command log",
|
||||||
"tradeLog": "Trade log",
|
"tradeLog": "Trade log",
|
||||||
"tools": "Tools"
|
"tools": "Tools",
|
||||||
|
"guilds": "Guilds"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Irányítópult",
|
"title": "Irányítópult",
|
||||||
@@ -2213,7 +2215,24 @@
|
|||||||
"auditTarget": "Cél",
|
"auditTarget": "Cél",
|
||||||
"selectAllMatching": "Select all {count} matching",
|
"selectAllMatching": "Select all {count} matching",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"importTitle": "Import permissions"
|
"importTitle": "Import permissions",
|
||||||
|
"legacyBannerTitle": "Use Permissions to change who can do what",
|
||||||
|
"legacyBannerBody": "This archive does not control the site. Open Permissions to grant or revoke access for ranks.",
|
||||||
|
"legacyBannerCta": "Open Permissions",
|
||||||
|
"writesRetired": "Editing here is disabled on purpose. Change access only in Permissions.",
|
||||||
|
"tabCompare": "ACL compare",
|
||||||
|
"tabExportOnly": "Export archive",
|
||||||
|
"compareHint": "Compares legacy housekeeping min-rank rows to live ACL rank_* role grants (lowest rank that has the slug).",
|
||||||
|
"compareAligned": "{count} aligned",
|
||||||
|
"compareMissingAcl": "{count} missing in ACL",
|
||||||
|
"compareMismatch": "{count} rank mismatch",
|
||||||
|
"colAclPresent": "In ACL",
|
||||||
|
"colAclMinRank": "ACL min rank",
|
||||||
|
"colStatus": "Status",
|
||||||
|
"statusAligned": "Aligned",
|
||||||
|
"statusMissingAcl": "Missing in ACL",
|
||||||
|
"statusMismatch": "Rank differs",
|
||||||
|
"exportOnlyHint": "Writes are disabled. You can still export the legacy table for archival."
|
||||||
},
|
},
|
||||||
"rooms": {
|
"rooms": {
|
||||||
"title": "Szobák",
|
"title": "Szobák",
|
||||||
@@ -2442,7 +2461,9 @@
|
|||||||
"colPriority": "Priority",
|
"colPriority": "Priority",
|
||||||
"colAssigned": "Assigned",
|
"colAssigned": "Assigned",
|
||||||
"colDate": "Date",
|
"colDate": "Date",
|
||||||
"meta": "by {user} · {count} msg · {category}"
|
"meta": "by {user} · {count} msg · {category}",
|
||||||
|
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
|
||||||
|
"queueOtherCta": "Open help-center tickets"
|
||||||
},
|
},
|
||||||
"cfh": {
|
"cfh": {
|
||||||
"title": "Call for Help",
|
"title": "Call for Help",
|
||||||
@@ -2704,7 +2725,9 @@
|
|||||||
"replied": "Reply sent.",
|
"replied": "Reply sent.",
|
||||||
"closed": "Ticket closed.",
|
"closed": "Ticket closed.",
|
||||||
"reopened": "Ticket reopened."
|
"reopened": "Ticket reopened."
|
||||||
}
|
},
|
||||||
|
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
|
||||||
|
"queueOtherCta": "Open CMS support desk"
|
||||||
},
|
},
|
||||||
"marketplace": {
|
"marketplace": {
|
||||||
"title": "Marketplace",
|
"title": "Marketplace",
|
||||||
@@ -2799,6 +2822,31 @@
|
|||||||
"less": "Less",
|
"less": "Less",
|
||||||
"more": "More"
|
"more": "More"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"guilds": {
|
||||||
|
"title": "Guilds",
|
||||||
|
"subtitle": "Hotel groups and membership",
|
||||||
|
"totalGuilds": "Guilds",
|
||||||
|
"searchPlaceholder": "Search by name, description or ID…",
|
||||||
|
"search": "Search",
|
||||||
|
"empty": "No guilds found.",
|
||||||
|
"loadError": "Could not load guilds (database unavailable).",
|
||||||
|
"colId": "ID",
|
||||||
|
"colBadge": "Badge",
|
||||||
|
"colName": "Name",
|
||||||
|
"colOwner": "Owner",
|
||||||
|
"colMembers": "Members",
|
||||||
|
"colCreated": "Created",
|
||||||
|
"colThreads": "Forum threads",
|
||||||
|
"colRoom": "Room",
|
||||||
|
"colUser": "User",
|
||||||
|
"colLevel": "Level",
|
||||||
|
"colJoined": "Joined",
|
||||||
|
"members": "Members",
|
||||||
|
"noMembers": "No members.",
|
||||||
|
"back": "Back to guilds",
|
||||||
|
"viewPublic": "Open public page",
|
||||||
|
"disband": "Disband guild"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -2944,6 +2992,35 @@
|
|||||||
"voteCount": "{count} ({pct}%)",
|
"voteCount": "{count} ({pct}%)",
|
||||||
"totalVotes": "{count} total votes",
|
"totalVotes": "{count} total votes",
|
||||||
"textResponses": "{count} free-text responses"
|
"textResponses": "{count} free-text responses"
|
||||||
|
},
|
||||||
|
"mod": {
|
||||||
|
"title": "Moderation panel",
|
||||||
|
"badge": "Mod",
|
||||||
|
"overviewTitle": "Moderator overview",
|
||||||
|
"overviewSubtitle": "CFH, quick actions and bans without full admin access.",
|
||||||
|
"shortcutsTitle": "Shortcuts",
|
||||||
|
"nav": {
|
||||||
|
"overview": "Overview",
|
||||||
|
"cfh": "Call for Help",
|
||||||
|
"actions": "Quick actions",
|
||||||
|
"bans": "Bans",
|
||||||
|
"fullAdmin": "Full admin",
|
||||||
|
"backToSite": "Back to site",
|
||||||
|
"tickets": "Tickets",
|
||||||
|
"team": "Team",
|
||||||
|
"helpTickets": "Help-center tickets"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"openCfh": "Open CFH",
|
||||||
|
"bansToday": "Bans today",
|
||||||
|
"modsOnline": "Mods online",
|
||||||
|
"quickActions": "Quick actions",
|
||||||
|
"quickActionsHint": "Kick, mute, alert",
|
||||||
|
"openTickets": "Open tickets",
|
||||||
|
"team": "Mod team",
|
||||||
|
"teamHint": "Online staff & workload",
|
||||||
|
"openHelpTickets": "Open help tickets"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"emails": {
|
"emails": {
|
||||||
|
|||||||
+15
-9
@@ -1467,8 +1467,8 @@
|
|||||||
"settingsSaved": "Impostazioni VPN salvate con successo"
|
"settingsSaved": "Impostazioni VPN salvate con successo"
|
||||||
},
|
},
|
||||||
"tickets": {
|
"tickets": {
|
||||||
"title": "Ticket",
|
"title": "Supporto CMS",
|
||||||
"subtitle": "Ticket di supporto dai giocatori",
|
"subtitle": "Ticket interni CMS (assegnatario, priorita, workflow staff). Non sono i ticket del help center /help.",
|
||||||
"noTickets": "Nessun ticket attivo",
|
"noTickets": "Nessun ticket attivo",
|
||||||
"searchPlaceholder": "Cerca oggetto, utente, categoria o ID…",
|
"searchPlaceholder": "Cerca oggetto, utente, categoria o ID…",
|
||||||
"presetActive": "Attivi",
|
"presetActive": "Attivi",
|
||||||
@@ -1481,11 +1481,13 @@
|
|||||||
"colPriority": "Priorità",
|
"colPriority": "Priorità",
|
||||||
"colAssigned": "Assegnato",
|
"colAssigned": "Assegnato",
|
||||||
"colDate": "Data",
|
"colDate": "Data",
|
||||||
"meta": "di {user} · {count} msg · {category}"
|
"meta": "di {user} · {count} msg · {category}",
|
||||||
|
"queueHint": "Questa coda e il supporto CMS. I ticket del help center pubblici stanno nella tab Help-center tickets.",
|
||||||
|
"queueOtherCta": "Apri help-center tickets"
|
||||||
},
|
},
|
||||||
"helpTickets": {
|
"helpTickets": {
|
||||||
"title": "Ticket centro assistenza",
|
"title": "Ticket help center",
|
||||||
"subtitle": "Ticket dei giocatori da /help/tickets",
|
"subtitle": "Ticket aperti dai player su /help/tickets. Separati dal supporto CMS.",
|
||||||
"noTickets": "Nessun ticket aperto nel centro assistenza",
|
"noTickets": "Nessun ticket aperto nel centro assistenza",
|
||||||
"searchPlaceholder": "Cerca titolo, utente o ID…",
|
"searchPlaceholder": "Cerca titolo, utente o ID…",
|
||||||
"presetOpen": "Aperti",
|
"presetOpen": "Aperti",
|
||||||
@@ -1516,7 +1518,9 @@
|
|||||||
"replied": "Risposta inviata.",
|
"replied": "Risposta inviata.",
|
||||||
"closed": "Ticket chiuso.",
|
"closed": "Ticket chiuso.",
|
||||||
"reopened": "Ticket riaperto."
|
"reopened": "Ticket riaperto."
|
||||||
}
|
},
|
||||||
|
"queueHint": "Questa coda e il help center pubblico. I ticket del supporto CMS stanno nella tab Support desk.",
|
||||||
|
"queueOtherCta": "Apri supporto CMS"
|
||||||
},
|
},
|
||||||
"cfh": {
|
"cfh": {
|
||||||
"title": "Call for Help",
|
"title": "Call for Help",
|
||||||
@@ -2791,7 +2795,7 @@
|
|||||||
"events": "Eventi",
|
"events": "Eventi",
|
||||||
"eventsSubtitle": "Eventi hotel e tipi evento",
|
"eventsSubtitle": "Eventi hotel e tipi evento",
|
||||||
"tickets": "Assistenza",
|
"tickets": "Assistenza",
|
||||||
"ticketsSubtitle": "Ticket CMS, ticket centro assistenza e template risposte",
|
"ticketsSubtitle": "Due code separate: supporto CMS e ticket help center pubblici",
|
||||||
"staffAccess": "Gradi e permessi",
|
"staffAccess": "Gradi e permessi",
|
||||||
"staffAccessSubtitle": "Gradi staff e accesso live. L'archivio e solo confronto/export.",
|
"staffAccessSubtitle": "Gradi staff e accesso live. L'archivio e solo confronto/export.",
|
||||||
"analytics": "Analitiche",
|
"analytics": "Analitiche",
|
||||||
@@ -3011,7 +3015,8 @@
|
|||||||
"bans": "Ban",
|
"bans": "Ban",
|
||||||
"fullAdmin": "Admin completo",
|
"fullAdmin": "Admin completo",
|
||||||
"backToSite": "Torna al sito",
|
"backToSite": "Torna al sito",
|
||||||
"tickets": "Ticket",
|
"tickets": "Ticket CMS",
|
||||||
|
"helpTickets": "Ticket help center",
|
||||||
"team": "Team"
|
"team": "Team"
|
||||||
},
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
@@ -3020,7 +3025,8 @@
|
|||||||
"modsOnline": "Mod online",
|
"modsOnline": "Mod online",
|
||||||
"quickActions": "Azioni rapide",
|
"quickActions": "Azioni rapide",
|
||||||
"quickActionsHint": "Kick, mute, alert",
|
"quickActionsHint": "Kick, mute, alert",
|
||||||
"openTickets": "Ticket aperti",
|
"openTickets": "Ticket CMS aperti",
|
||||||
|
"openHelpTickets": "Help ticket aperti",
|
||||||
"team": "Team mod",
|
"team": "Team mod",
|
||||||
"teamHint": "Staff online e carico"
|
"teamHint": "Staff online e carico"
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-9
@@ -2842,7 +2842,7 @@
|
|||||||
"events": "Evenementen",
|
"events": "Evenementen",
|
||||||
"eventsSubtitle": "Hotel-evenementen en evenementtypes",
|
"eventsSubtitle": "Hotel-evenementen en evenementtypes",
|
||||||
"tickets": "Supportbalie",
|
"tickets": "Supportbalie",
|
||||||
"ticketsSubtitle": "CMS-tickets, helpcentrum-tickets en antwoordtemplates",
|
"ticketsSubtitle": "Twee aparte wachtrijen: CMS support desk en publieke helpcenter-tickets",
|
||||||
"staffAccess": "Rangen & rechten",
|
"staffAccess": "Rangen & rechten",
|
||||||
"staffAccessSubtitle": "Staffrangen en live toegang. Archief is alleen vergelijken/exporteren.",
|
"staffAccessSubtitle": "Staffrangen en live toegang. Archief is alleen vergelijken/exporteren.",
|
||||||
"analytics": "Analytics",
|
"analytics": "Analytics",
|
||||||
@@ -2851,8 +2851,8 @@
|
|||||||
"devopsSubtitle": "Servicegezondheid en emulatorfouten"
|
"devopsSubtitle": "Servicegezondheid en emulatorfouten"
|
||||||
},
|
},
|
||||||
"tickets": {
|
"tickets": {
|
||||||
"title": "Tickets",
|
"title": "CMS support desk",
|
||||||
"subtitle": "Ondersteuningstickets van spelers",
|
"subtitle": "Interne CMS-tickets (assignee, prioriteit, staff-workflow). Niet hetzelfde als helpcenter-tickets van /help.",
|
||||||
"noTickets": "Geen actieve tickets",
|
"noTickets": "Geen actieve tickets",
|
||||||
"searchPlaceholder": "Zoek op onderwerp, gebruiker, categorie of ID…",
|
"searchPlaceholder": "Zoek op onderwerp, gebruiker, categorie of ID…",
|
||||||
"presetActive": "Actief",
|
"presetActive": "Actief",
|
||||||
@@ -2865,11 +2865,13 @@
|
|||||||
"colPriority": "Prioriteit",
|
"colPriority": "Prioriteit",
|
||||||
"colAssigned": "Toegewezen",
|
"colAssigned": "Toegewezen",
|
||||||
"colDate": "Datum",
|
"colDate": "Datum",
|
||||||
"meta": "door {user} · {count} berichten · {category}"
|
"meta": "door {user} · {count} berichten · {category}",
|
||||||
|
"queueHint": "Deze wachtrij is de CMS support desk. Speler-helpcenter tickets staan onder Help-center tickets.",
|
||||||
|
"queueOtherCta": "Open helpcenter-tickets"
|
||||||
},
|
},
|
||||||
"helpTickets": {
|
"helpTickets": {
|
||||||
"title": "Helpcentrum-tickets",
|
"title": "Helpcenter-tickets",
|
||||||
"subtitle": "Spelertickets van /help/tickets",
|
"subtitle": "Tickets die spelers openen op /help/tickets. Apart van de CMS support desk.",
|
||||||
"noTickets": "Geen open helpcentrum-tickets",
|
"noTickets": "Geen open helpcentrum-tickets",
|
||||||
"searchPlaceholder": "Zoek op titel, gebruiker of ID…",
|
"searchPlaceholder": "Zoek op titel, gebruiker of ID…",
|
||||||
"presetOpen": "Open",
|
"presetOpen": "Open",
|
||||||
@@ -2900,7 +2902,9 @@
|
|||||||
"replied": "Antwoord verzonden.",
|
"replied": "Antwoord verzonden.",
|
||||||
"closed": "Ticket gesloten.",
|
"closed": "Ticket gesloten.",
|
||||||
"reopened": "Ticket heropend."
|
"reopened": "Ticket heropend."
|
||||||
}
|
},
|
||||||
|
"queueHint": "Deze wachtrij is het publieke helpcenter. CMS support desk tickets staan onder Support desk.",
|
||||||
|
"queueOtherCta": "Open CMS support desk"
|
||||||
},
|
},
|
||||||
"cfh": {
|
"cfh": {
|
||||||
"title": "Call for Help",
|
"title": "Call for Help",
|
||||||
@@ -3009,7 +3013,8 @@
|
|||||||
"bans": "Bans",
|
"bans": "Bans",
|
||||||
"fullAdmin": "Volledige admin",
|
"fullAdmin": "Volledige admin",
|
||||||
"backToSite": "Terug naar site",
|
"backToSite": "Terug naar site",
|
||||||
"tickets": "Tickets",
|
"tickets": "CMS-tickets",
|
||||||
|
"helpTickets": "Helpcenter-tickets",
|
||||||
"team": "Team"
|
"team": "Team"
|
||||||
},
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
@@ -3018,7 +3023,8 @@
|
|||||||
"modsOnline": "Mods online",
|
"modsOnline": "Mods online",
|
||||||
"quickActions": "Snelle acties",
|
"quickActions": "Snelle acties",
|
||||||
"quickActionsHint": "Kick, mute, alert",
|
"quickActionsHint": "Kick, mute, alert",
|
||||||
"openTickets": "Open tickets",
|
"openTickets": "Open CMS-tickets",
|
||||||
|
"openHelpTickets": "Open help-tickets",
|
||||||
"team": "Mod team",
|
"team": "Mod team",
|
||||||
"teamHint": "Online staff & werklast"
|
"teamHint": "Online staff & werklast"
|
||||||
}
|
}
|
||||||
|
|||||||
+82
-5
@@ -23,7 +23,8 @@
|
|||||||
"closeMenu": "Lukk menyen",
|
"closeMenu": "Lukk menyen",
|
||||||
"drawBadge": "Tegn merke",
|
"drawBadge": "Tegn merke",
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"polls": "Polls"
|
"polls": "Polls",
|
||||||
|
"mod": "Mod"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"online": "{count} {hotel} online",
|
"online": "{count} {hotel} online",
|
||||||
@@ -906,7 +907,8 @@
|
|||||||
"chatLog": "Chat log",
|
"chatLog": "Chat log",
|
||||||
"commandLog": "Command log",
|
"commandLog": "Command log",
|
||||||
"tradeLog": "Trade log",
|
"tradeLog": "Trade log",
|
||||||
"tools": "Tools"
|
"tools": "Tools",
|
||||||
|
"guilds": "Guilds"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashbord",
|
"title": "Dashbord",
|
||||||
@@ -2213,7 +2215,24 @@
|
|||||||
"auditTarget": "Mål",
|
"auditTarget": "Mål",
|
||||||
"selectAllMatching": "Select all {count} matching",
|
"selectAllMatching": "Select all {count} matching",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"importTitle": "Import permissions"
|
"importTitle": "Import permissions",
|
||||||
|
"legacyBannerTitle": "Use Permissions to change who can do what",
|
||||||
|
"legacyBannerBody": "This archive does not control the site. Open Permissions to grant or revoke access for ranks.",
|
||||||
|
"legacyBannerCta": "Open Permissions",
|
||||||
|
"writesRetired": "Editing here is disabled on purpose. Change access only in Permissions.",
|
||||||
|
"tabCompare": "ACL compare",
|
||||||
|
"tabExportOnly": "Export archive",
|
||||||
|
"compareHint": "Compares legacy housekeeping min-rank rows to live ACL rank_* role grants (lowest rank that has the slug).",
|
||||||
|
"compareAligned": "{count} aligned",
|
||||||
|
"compareMissingAcl": "{count} missing in ACL",
|
||||||
|
"compareMismatch": "{count} rank mismatch",
|
||||||
|
"colAclPresent": "In ACL",
|
||||||
|
"colAclMinRank": "ACL min rank",
|
||||||
|
"colStatus": "Status",
|
||||||
|
"statusAligned": "Aligned",
|
||||||
|
"statusMissingAcl": "Missing in ACL",
|
||||||
|
"statusMismatch": "Rank differs",
|
||||||
|
"exportOnlyHint": "Writes are disabled. You can still export the legacy table for archival."
|
||||||
},
|
},
|
||||||
"rooms": {
|
"rooms": {
|
||||||
"title": "Rom",
|
"title": "Rom",
|
||||||
@@ -2442,7 +2461,9 @@
|
|||||||
"colPriority": "Priority",
|
"colPriority": "Priority",
|
||||||
"colAssigned": "Assigned",
|
"colAssigned": "Assigned",
|
||||||
"colDate": "Date",
|
"colDate": "Date",
|
||||||
"meta": "by {user} · {count} msg · {category}"
|
"meta": "by {user} · {count} msg · {category}",
|
||||||
|
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
|
||||||
|
"queueOtherCta": "Open help-center tickets"
|
||||||
},
|
},
|
||||||
"cfh": {
|
"cfh": {
|
||||||
"title": "Call for Help",
|
"title": "Call for Help",
|
||||||
@@ -2704,7 +2725,9 @@
|
|||||||
"replied": "Reply sent.",
|
"replied": "Reply sent.",
|
||||||
"closed": "Ticket closed.",
|
"closed": "Ticket closed.",
|
||||||
"reopened": "Ticket reopened."
|
"reopened": "Ticket reopened."
|
||||||
}
|
},
|
||||||
|
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
|
||||||
|
"queueOtherCta": "Open CMS support desk"
|
||||||
},
|
},
|
||||||
"marketplace": {
|
"marketplace": {
|
||||||
"title": "Marketplace",
|
"title": "Marketplace",
|
||||||
@@ -2799,6 +2822,31 @@
|
|||||||
"less": "Less",
|
"less": "Less",
|
||||||
"more": "More"
|
"more": "More"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"guilds": {
|
||||||
|
"title": "Guilds",
|
||||||
|
"subtitle": "Hotel groups and membership",
|
||||||
|
"totalGuilds": "Guilds",
|
||||||
|
"searchPlaceholder": "Search by name, description or ID…",
|
||||||
|
"search": "Search",
|
||||||
|
"empty": "No guilds found.",
|
||||||
|
"loadError": "Could not load guilds (database unavailable).",
|
||||||
|
"colId": "ID",
|
||||||
|
"colBadge": "Badge",
|
||||||
|
"colName": "Name",
|
||||||
|
"colOwner": "Owner",
|
||||||
|
"colMembers": "Members",
|
||||||
|
"colCreated": "Created",
|
||||||
|
"colThreads": "Forum threads",
|
||||||
|
"colRoom": "Room",
|
||||||
|
"colUser": "User",
|
||||||
|
"colLevel": "Level",
|
||||||
|
"colJoined": "Joined",
|
||||||
|
"members": "Members",
|
||||||
|
"noMembers": "No members.",
|
||||||
|
"back": "Back to guilds",
|
||||||
|
"viewPublic": "Open public page",
|
||||||
|
"disband": "Disband guild"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -2944,6 +2992,35 @@
|
|||||||
"voteCount": "{count} ({pct}%)",
|
"voteCount": "{count} ({pct}%)",
|
||||||
"totalVotes": "{count} total votes",
|
"totalVotes": "{count} total votes",
|
||||||
"textResponses": "{count} free-text responses"
|
"textResponses": "{count} free-text responses"
|
||||||
|
},
|
||||||
|
"mod": {
|
||||||
|
"title": "Moderation panel",
|
||||||
|
"badge": "Mod",
|
||||||
|
"overviewTitle": "Moderator overview",
|
||||||
|
"overviewSubtitle": "CFH, quick actions and bans without full admin access.",
|
||||||
|
"shortcutsTitle": "Shortcuts",
|
||||||
|
"nav": {
|
||||||
|
"overview": "Overview",
|
||||||
|
"cfh": "Call for Help",
|
||||||
|
"actions": "Quick actions",
|
||||||
|
"bans": "Bans",
|
||||||
|
"fullAdmin": "Full admin",
|
||||||
|
"backToSite": "Back to site",
|
||||||
|
"tickets": "Tickets",
|
||||||
|
"team": "Team",
|
||||||
|
"helpTickets": "Help-center tickets"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"openCfh": "Open CFH",
|
||||||
|
"bansToday": "Bans today",
|
||||||
|
"modsOnline": "Mods online",
|
||||||
|
"quickActions": "Quick actions",
|
||||||
|
"quickActionsHint": "Kick, mute, alert",
|
||||||
|
"openTickets": "Open tickets",
|
||||||
|
"team": "Mod team",
|
||||||
|
"teamHint": "Online staff & workload",
|
||||||
|
"openHelpTickets": "Open help tickets"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"emails": {
|
"emails": {
|
||||||
|
|||||||
+82
-5
@@ -23,7 +23,8 @@
|
|||||||
"closeMenu": "Zamknij menu",
|
"closeMenu": "Zamknij menu",
|
||||||
"drawBadge": "Narysuj odznakę",
|
"drawBadge": "Narysuj odznakę",
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"polls": "Polls"
|
"polls": "Polls",
|
||||||
|
"mod": "Mod"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"online": "{count} {hotel} online",
|
"online": "{count} {hotel} online",
|
||||||
@@ -906,7 +907,8 @@
|
|||||||
"chatLog": "Chat log",
|
"chatLog": "Chat log",
|
||||||
"commandLog": "Command log",
|
"commandLog": "Command log",
|
||||||
"tradeLog": "Trade log",
|
"tradeLog": "Trade log",
|
||||||
"tools": "Tools"
|
"tools": "Tools",
|
||||||
|
"guilds": "Guilds"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Pulpit nawigacyjny",
|
"title": "Pulpit nawigacyjny",
|
||||||
@@ -2213,7 +2215,24 @@
|
|||||||
"auditTarget": "Cel",
|
"auditTarget": "Cel",
|
||||||
"selectAllMatching": "Select all {count} matching",
|
"selectAllMatching": "Select all {count} matching",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"importTitle": "Import permissions"
|
"importTitle": "Import permissions",
|
||||||
|
"legacyBannerTitle": "Use Permissions to change who can do what",
|
||||||
|
"legacyBannerBody": "This archive does not control the site. Open Permissions to grant or revoke access for ranks.",
|
||||||
|
"legacyBannerCta": "Open Permissions",
|
||||||
|
"writesRetired": "Editing here is disabled on purpose. Change access only in Permissions.",
|
||||||
|
"tabCompare": "ACL compare",
|
||||||
|
"tabExportOnly": "Export archive",
|
||||||
|
"compareHint": "Compares legacy housekeeping min-rank rows to live ACL rank_* role grants (lowest rank that has the slug).",
|
||||||
|
"compareAligned": "{count} aligned",
|
||||||
|
"compareMissingAcl": "{count} missing in ACL",
|
||||||
|
"compareMismatch": "{count} rank mismatch",
|
||||||
|
"colAclPresent": "In ACL",
|
||||||
|
"colAclMinRank": "ACL min rank",
|
||||||
|
"colStatus": "Status",
|
||||||
|
"statusAligned": "Aligned",
|
||||||
|
"statusMissingAcl": "Missing in ACL",
|
||||||
|
"statusMismatch": "Rank differs",
|
||||||
|
"exportOnlyHint": "Writes are disabled. You can still export the legacy table for archival."
|
||||||
},
|
},
|
||||||
"rooms": {
|
"rooms": {
|
||||||
"title": "Pokoje",
|
"title": "Pokoje",
|
||||||
@@ -2442,7 +2461,9 @@
|
|||||||
"colPriority": "Priority",
|
"colPriority": "Priority",
|
||||||
"colAssigned": "Assigned",
|
"colAssigned": "Assigned",
|
||||||
"colDate": "Date",
|
"colDate": "Date",
|
||||||
"meta": "by {user} · {count} msg · {category}"
|
"meta": "by {user} · {count} msg · {category}",
|
||||||
|
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
|
||||||
|
"queueOtherCta": "Open help-center tickets"
|
||||||
},
|
},
|
||||||
"cfh": {
|
"cfh": {
|
||||||
"title": "Call for Help",
|
"title": "Call for Help",
|
||||||
@@ -2704,7 +2725,9 @@
|
|||||||
"replied": "Reply sent.",
|
"replied": "Reply sent.",
|
||||||
"closed": "Ticket closed.",
|
"closed": "Ticket closed.",
|
||||||
"reopened": "Ticket reopened."
|
"reopened": "Ticket reopened."
|
||||||
}
|
},
|
||||||
|
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
|
||||||
|
"queueOtherCta": "Open CMS support desk"
|
||||||
},
|
},
|
||||||
"marketplace": {
|
"marketplace": {
|
||||||
"title": "Marketplace",
|
"title": "Marketplace",
|
||||||
@@ -2799,6 +2822,31 @@
|
|||||||
"less": "Less",
|
"less": "Less",
|
||||||
"more": "More"
|
"more": "More"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"guilds": {
|
||||||
|
"title": "Guilds",
|
||||||
|
"subtitle": "Hotel groups and membership",
|
||||||
|
"totalGuilds": "Guilds",
|
||||||
|
"searchPlaceholder": "Search by name, description or ID…",
|
||||||
|
"search": "Search",
|
||||||
|
"empty": "No guilds found.",
|
||||||
|
"loadError": "Could not load guilds (database unavailable).",
|
||||||
|
"colId": "ID",
|
||||||
|
"colBadge": "Badge",
|
||||||
|
"colName": "Name",
|
||||||
|
"colOwner": "Owner",
|
||||||
|
"colMembers": "Members",
|
||||||
|
"colCreated": "Created",
|
||||||
|
"colThreads": "Forum threads",
|
||||||
|
"colRoom": "Room",
|
||||||
|
"colUser": "User",
|
||||||
|
"colLevel": "Level",
|
||||||
|
"colJoined": "Joined",
|
||||||
|
"members": "Members",
|
||||||
|
"noMembers": "No members.",
|
||||||
|
"back": "Back to guilds",
|
||||||
|
"viewPublic": "Open public page",
|
||||||
|
"disband": "Disband guild"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -2944,6 +2992,35 @@
|
|||||||
"voteCount": "{count} ({pct}%)",
|
"voteCount": "{count} ({pct}%)",
|
||||||
"totalVotes": "{count} total votes",
|
"totalVotes": "{count} total votes",
|
||||||
"textResponses": "{count} free-text responses"
|
"textResponses": "{count} free-text responses"
|
||||||
|
},
|
||||||
|
"mod": {
|
||||||
|
"title": "Moderation panel",
|
||||||
|
"badge": "Mod",
|
||||||
|
"overviewTitle": "Moderator overview",
|
||||||
|
"overviewSubtitle": "CFH, quick actions and bans without full admin access.",
|
||||||
|
"shortcutsTitle": "Shortcuts",
|
||||||
|
"nav": {
|
||||||
|
"overview": "Overview",
|
||||||
|
"cfh": "Call for Help",
|
||||||
|
"actions": "Quick actions",
|
||||||
|
"bans": "Bans",
|
||||||
|
"fullAdmin": "Full admin",
|
||||||
|
"backToSite": "Back to site",
|
||||||
|
"tickets": "Tickets",
|
||||||
|
"team": "Team",
|
||||||
|
"helpTickets": "Help-center tickets"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"openCfh": "Open CFH",
|
||||||
|
"bansToday": "Bans today",
|
||||||
|
"modsOnline": "Mods online",
|
||||||
|
"quickActions": "Quick actions",
|
||||||
|
"quickActionsHint": "Kick, mute, alert",
|
||||||
|
"openTickets": "Open tickets",
|
||||||
|
"team": "Mod team",
|
||||||
|
"teamHint": "Online staff & workload",
|
||||||
|
"openHelpTickets": "Open help tickets"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"emails": {
|
"emails": {
|
||||||
|
|||||||
+82
-5
@@ -23,7 +23,8 @@
|
|||||||
"closeMenu": "Fechar menu",
|
"closeMenu": "Fechar menu",
|
||||||
"drawBadge": "Desenhar distintivo",
|
"drawBadge": "Desenhar distintivo",
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"polls": "Polls"
|
"polls": "Polls",
|
||||||
|
"mod": "Mod"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"online": "{count} {hotel} on-line",
|
"online": "{count} {hotel} on-line",
|
||||||
@@ -906,7 +907,8 @@
|
|||||||
"chatLog": "Chat log",
|
"chatLog": "Chat log",
|
||||||
"commandLog": "Command log",
|
"commandLog": "Command log",
|
||||||
"tradeLog": "Trade log",
|
"tradeLog": "Trade log",
|
||||||
"tools": "Tools"
|
"tools": "Tools",
|
||||||
|
"guilds": "Guilds"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Painel",
|
"title": "Painel",
|
||||||
@@ -2213,7 +2215,24 @@
|
|||||||
"auditTarget": "Alvo",
|
"auditTarget": "Alvo",
|
||||||
"selectAllMatching": "Select all {count} matching",
|
"selectAllMatching": "Select all {count} matching",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"importTitle": "Import permissions"
|
"importTitle": "Import permissions",
|
||||||
|
"legacyBannerTitle": "Use Permissions to change who can do what",
|
||||||
|
"legacyBannerBody": "This archive does not control the site. Open Permissions to grant or revoke access for ranks.",
|
||||||
|
"legacyBannerCta": "Open Permissions",
|
||||||
|
"writesRetired": "Editing here is disabled on purpose. Change access only in Permissions.",
|
||||||
|
"tabCompare": "ACL compare",
|
||||||
|
"tabExportOnly": "Export archive",
|
||||||
|
"compareHint": "Compares legacy housekeeping min-rank rows to live ACL rank_* role grants (lowest rank that has the slug).",
|
||||||
|
"compareAligned": "{count} aligned",
|
||||||
|
"compareMissingAcl": "{count} missing in ACL",
|
||||||
|
"compareMismatch": "{count} rank mismatch",
|
||||||
|
"colAclPresent": "In ACL",
|
||||||
|
"colAclMinRank": "ACL min rank",
|
||||||
|
"colStatus": "Status",
|
||||||
|
"statusAligned": "Aligned",
|
||||||
|
"statusMissingAcl": "Missing in ACL",
|
||||||
|
"statusMismatch": "Rank differs",
|
||||||
|
"exportOnlyHint": "Writes are disabled. You can still export the legacy table for archival."
|
||||||
},
|
},
|
||||||
"rooms": {
|
"rooms": {
|
||||||
"title": "Quartos",
|
"title": "Quartos",
|
||||||
@@ -2442,7 +2461,9 @@
|
|||||||
"colPriority": "Priority",
|
"colPriority": "Priority",
|
||||||
"colAssigned": "Assigned",
|
"colAssigned": "Assigned",
|
||||||
"colDate": "Date",
|
"colDate": "Date",
|
||||||
"meta": "by {user} · {count} msg · {category}"
|
"meta": "by {user} · {count} msg · {category}",
|
||||||
|
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
|
||||||
|
"queueOtherCta": "Open help-center tickets"
|
||||||
},
|
},
|
||||||
"cfh": {
|
"cfh": {
|
||||||
"title": "Call for Help",
|
"title": "Call for Help",
|
||||||
@@ -2704,7 +2725,9 @@
|
|||||||
"replied": "Reply sent.",
|
"replied": "Reply sent.",
|
||||||
"closed": "Ticket closed.",
|
"closed": "Ticket closed.",
|
||||||
"reopened": "Ticket reopened."
|
"reopened": "Ticket reopened."
|
||||||
}
|
},
|
||||||
|
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
|
||||||
|
"queueOtherCta": "Open CMS support desk"
|
||||||
},
|
},
|
||||||
"marketplace": {
|
"marketplace": {
|
||||||
"title": "Marketplace",
|
"title": "Marketplace",
|
||||||
@@ -2799,6 +2822,31 @@
|
|||||||
"less": "Less",
|
"less": "Less",
|
||||||
"more": "More"
|
"more": "More"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"guilds": {
|
||||||
|
"title": "Guilds",
|
||||||
|
"subtitle": "Hotel groups and membership",
|
||||||
|
"totalGuilds": "Guilds",
|
||||||
|
"searchPlaceholder": "Search by name, description or ID…",
|
||||||
|
"search": "Search",
|
||||||
|
"empty": "No guilds found.",
|
||||||
|
"loadError": "Could not load guilds (database unavailable).",
|
||||||
|
"colId": "ID",
|
||||||
|
"colBadge": "Badge",
|
||||||
|
"colName": "Name",
|
||||||
|
"colOwner": "Owner",
|
||||||
|
"colMembers": "Members",
|
||||||
|
"colCreated": "Created",
|
||||||
|
"colThreads": "Forum threads",
|
||||||
|
"colRoom": "Room",
|
||||||
|
"colUser": "User",
|
||||||
|
"colLevel": "Level",
|
||||||
|
"colJoined": "Joined",
|
||||||
|
"members": "Members",
|
||||||
|
"noMembers": "No members.",
|
||||||
|
"back": "Back to guilds",
|
||||||
|
"viewPublic": "Open public page",
|
||||||
|
"disband": "Disband guild"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -2944,6 +2992,35 @@
|
|||||||
"voteCount": "{count} ({pct}%)",
|
"voteCount": "{count} ({pct}%)",
|
||||||
"totalVotes": "{count} total votes",
|
"totalVotes": "{count} total votes",
|
||||||
"textResponses": "{count} free-text responses"
|
"textResponses": "{count} free-text responses"
|
||||||
|
},
|
||||||
|
"mod": {
|
||||||
|
"title": "Moderation panel",
|
||||||
|
"badge": "Mod",
|
||||||
|
"overviewTitle": "Moderator overview",
|
||||||
|
"overviewSubtitle": "CFH, quick actions and bans without full admin access.",
|
||||||
|
"shortcutsTitle": "Shortcuts",
|
||||||
|
"nav": {
|
||||||
|
"overview": "Overview",
|
||||||
|
"cfh": "Call for Help",
|
||||||
|
"actions": "Quick actions",
|
||||||
|
"bans": "Bans",
|
||||||
|
"fullAdmin": "Full admin",
|
||||||
|
"backToSite": "Back to site",
|
||||||
|
"tickets": "Tickets",
|
||||||
|
"team": "Team",
|
||||||
|
"helpTickets": "Help-center tickets"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"openCfh": "Open CFH",
|
||||||
|
"bansToday": "Bans today",
|
||||||
|
"modsOnline": "Mods online",
|
||||||
|
"quickActions": "Quick actions",
|
||||||
|
"quickActionsHint": "Kick, mute, alert",
|
||||||
|
"openTickets": "Open tickets",
|
||||||
|
"team": "Mod team",
|
||||||
|
"teamHint": "Online staff & workload",
|
||||||
|
"openHelpTickets": "Open help tickets"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"emails": {
|
"emails": {
|
||||||
|
|||||||
+82
-5
@@ -23,7 +23,8 @@
|
|||||||
"closeMenu": "Închide meniul",
|
"closeMenu": "Închide meniul",
|
||||||
"drawBadge": "Trage insigna",
|
"drawBadge": "Trage insigna",
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"polls": "Polls"
|
"polls": "Polls",
|
||||||
|
"mod": "Mod"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"online": "{count} {hotel} online",
|
"online": "{count} {hotel} online",
|
||||||
@@ -906,7 +907,8 @@
|
|||||||
"chatLog": "Chat log",
|
"chatLog": "Chat log",
|
||||||
"commandLog": "Command log",
|
"commandLog": "Command log",
|
||||||
"tradeLog": "Trade log",
|
"tradeLog": "Trade log",
|
||||||
"tools": "Tools"
|
"tools": "Tools",
|
||||||
|
"guilds": "Guilds"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Tabloul de bord",
|
"title": "Tabloul de bord",
|
||||||
@@ -2213,7 +2215,24 @@
|
|||||||
"auditTarget": "țintă",
|
"auditTarget": "țintă",
|
||||||
"selectAllMatching": "Select all {count} matching",
|
"selectAllMatching": "Select all {count} matching",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"importTitle": "Import permissions"
|
"importTitle": "Import permissions",
|
||||||
|
"legacyBannerTitle": "Use Permissions to change who can do what",
|
||||||
|
"legacyBannerBody": "This archive does not control the site. Open Permissions to grant or revoke access for ranks.",
|
||||||
|
"legacyBannerCta": "Open Permissions",
|
||||||
|
"writesRetired": "Editing here is disabled on purpose. Change access only in Permissions.",
|
||||||
|
"tabCompare": "ACL compare",
|
||||||
|
"tabExportOnly": "Export archive",
|
||||||
|
"compareHint": "Compares legacy housekeeping min-rank rows to live ACL rank_* role grants (lowest rank that has the slug).",
|
||||||
|
"compareAligned": "{count} aligned",
|
||||||
|
"compareMissingAcl": "{count} missing in ACL",
|
||||||
|
"compareMismatch": "{count} rank mismatch",
|
||||||
|
"colAclPresent": "In ACL",
|
||||||
|
"colAclMinRank": "ACL min rank",
|
||||||
|
"colStatus": "Status",
|
||||||
|
"statusAligned": "Aligned",
|
||||||
|
"statusMissingAcl": "Missing in ACL",
|
||||||
|
"statusMismatch": "Rank differs",
|
||||||
|
"exportOnlyHint": "Writes are disabled. You can still export the legacy table for archival."
|
||||||
},
|
},
|
||||||
"rooms": {
|
"rooms": {
|
||||||
"title": "Camerele",
|
"title": "Camerele",
|
||||||
@@ -2442,7 +2461,9 @@
|
|||||||
"colPriority": "Priority",
|
"colPriority": "Priority",
|
||||||
"colAssigned": "Assigned",
|
"colAssigned": "Assigned",
|
||||||
"colDate": "Date",
|
"colDate": "Date",
|
||||||
"meta": "by {user} · {count} msg · {category}"
|
"meta": "by {user} · {count} msg · {category}",
|
||||||
|
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
|
||||||
|
"queueOtherCta": "Open help-center tickets"
|
||||||
},
|
},
|
||||||
"cfh": {
|
"cfh": {
|
||||||
"title": "Call for Help",
|
"title": "Call for Help",
|
||||||
@@ -2704,7 +2725,9 @@
|
|||||||
"replied": "Reply sent.",
|
"replied": "Reply sent.",
|
||||||
"closed": "Ticket closed.",
|
"closed": "Ticket closed.",
|
||||||
"reopened": "Ticket reopened."
|
"reopened": "Ticket reopened."
|
||||||
}
|
},
|
||||||
|
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
|
||||||
|
"queueOtherCta": "Open CMS support desk"
|
||||||
},
|
},
|
||||||
"marketplace": {
|
"marketplace": {
|
||||||
"title": "Marketplace",
|
"title": "Marketplace",
|
||||||
@@ -2799,6 +2822,31 @@
|
|||||||
"less": "Less",
|
"less": "Less",
|
||||||
"more": "More"
|
"more": "More"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"guilds": {
|
||||||
|
"title": "Guilds",
|
||||||
|
"subtitle": "Hotel groups and membership",
|
||||||
|
"totalGuilds": "Guilds",
|
||||||
|
"searchPlaceholder": "Search by name, description or ID…",
|
||||||
|
"search": "Search",
|
||||||
|
"empty": "No guilds found.",
|
||||||
|
"loadError": "Could not load guilds (database unavailable).",
|
||||||
|
"colId": "ID",
|
||||||
|
"colBadge": "Badge",
|
||||||
|
"colName": "Name",
|
||||||
|
"colOwner": "Owner",
|
||||||
|
"colMembers": "Members",
|
||||||
|
"colCreated": "Created",
|
||||||
|
"colThreads": "Forum threads",
|
||||||
|
"colRoom": "Room",
|
||||||
|
"colUser": "User",
|
||||||
|
"colLevel": "Level",
|
||||||
|
"colJoined": "Joined",
|
||||||
|
"members": "Members",
|
||||||
|
"noMembers": "No members.",
|
||||||
|
"back": "Back to guilds",
|
||||||
|
"viewPublic": "Open public page",
|
||||||
|
"disband": "Disband guild"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -2944,6 +2992,35 @@
|
|||||||
"voteCount": "{count} ({pct}%)",
|
"voteCount": "{count} ({pct}%)",
|
||||||
"totalVotes": "{count} total votes",
|
"totalVotes": "{count} total votes",
|
||||||
"textResponses": "{count} free-text responses"
|
"textResponses": "{count} free-text responses"
|
||||||
|
},
|
||||||
|
"mod": {
|
||||||
|
"title": "Moderation panel",
|
||||||
|
"badge": "Mod",
|
||||||
|
"overviewTitle": "Moderator overview",
|
||||||
|
"overviewSubtitle": "CFH, quick actions and bans without full admin access.",
|
||||||
|
"shortcutsTitle": "Shortcuts",
|
||||||
|
"nav": {
|
||||||
|
"overview": "Overview",
|
||||||
|
"cfh": "Call for Help",
|
||||||
|
"actions": "Quick actions",
|
||||||
|
"bans": "Bans",
|
||||||
|
"fullAdmin": "Full admin",
|
||||||
|
"backToSite": "Back to site",
|
||||||
|
"tickets": "Tickets",
|
||||||
|
"team": "Team",
|
||||||
|
"helpTickets": "Help-center tickets"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"openCfh": "Open CFH",
|
||||||
|
"bansToday": "Bans today",
|
||||||
|
"modsOnline": "Mods online",
|
||||||
|
"quickActions": "Quick actions",
|
||||||
|
"quickActionsHint": "Kick, mute, alert",
|
||||||
|
"openTickets": "Open tickets",
|
||||||
|
"team": "Mod team",
|
||||||
|
"teamHint": "Online staff & workload",
|
||||||
|
"openHelpTickets": "Open help tickets"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"emails": {
|
"emails": {
|
||||||
|
|||||||
+82
-5
@@ -23,7 +23,8 @@
|
|||||||
"closeMenu": "Закрыть меню",
|
"closeMenu": "Закрыть меню",
|
||||||
"drawBadge": "Нарисуйте значок",
|
"drawBadge": "Нарисуйте значок",
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"polls": "Polls"
|
"polls": "Polls",
|
||||||
|
"mod": "Mod"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"online": "{count} {hotel} онлайн",
|
"online": "{count} {hotel} онлайн",
|
||||||
@@ -904,7 +905,8 @@
|
|||||||
"chatLog": "Chat log",
|
"chatLog": "Chat log",
|
||||||
"commandLog": "Command log",
|
"commandLog": "Command log",
|
||||||
"tradeLog": "Trade log",
|
"tradeLog": "Trade log",
|
||||||
"tools": "Tools"
|
"tools": "Tools",
|
||||||
|
"guilds": "Guilds"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Панель управления",
|
"title": "Панель управления",
|
||||||
@@ -2211,7 +2213,24 @@
|
|||||||
"auditTarget": "Target",
|
"auditTarget": "Target",
|
||||||
"selectAllMatching": "Select all {count} matching",
|
"selectAllMatching": "Select all {count} matching",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"importTitle": "Import permissions"
|
"importTitle": "Import permissions",
|
||||||
|
"legacyBannerTitle": "Use Permissions to change who can do what",
|
||||||
|
"legacyBannerBody": "This archive does not control the site. Open Permissions to grant or revoke access for ranks.",
|
||||||
|
"legacyBannerCta": "Open Permissions",
|
||||||
|
"writesRetired": "Editing here is disabled on purpose. Change access only in Permissions.",
|
||||||
|
"tabCompare": "ACL compare",
|
||||||
|
"tabExportOnly": "Export archive",
|
||||||
|
"compareHint": "Compares legacy housekeeping min-rank rows to live ACL rank_* role grants (lowest rank that has the slug).",
|
||||||
|
"compareAligned": "{count} aligned",
|
||||||
|
"compareMissingAcl": "{count} missing in ACL",
|
||||||
|
"compareMismatch": "{count} rank mismatch",
|
||||||
|
"colAclPresent": "In ACL",
|
||||||
|
"colAclMinRank": "ACL min rank",
|
||||||
|
"colStatus": "Status",
|
||||||
|
"statusAligned": "Aligned",
|
||||||
|
"statusMissingAcl": "Missing in ACL",
|
||||||
|
"statusMismatch": "Rank differs",
|
||||||
|
"exportOnlyHint": "Writes are disabled. You can still export the legacy table for archival."
|
||||||
},
|
},
|
||||||
"rooms": {
|
"rooms": {
|
||||||
"title": "Rooms",
|
"title": "Rooms",
|
||||||
@@ -2440,7 +2459,9 @@
|
|||||||
"colPriority": "Priority",
|
"colPriority": "Priority",
|
||||||
"colAssigned": "Assigned",
|
"colAssigned": "Assigned",
|
||||||
"colDate": "Date",
|
"colDate": "Date",
|
||||||
"meta": "by {user} · {count} msg · {category}"
|
"meta": "by {user} · {count} msg · {category}",
|
||||||
|
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
|
||||||
|
"queueOtherCta": "Open help-center tickets"
|
||||||
},
|
},
|
||||||
"cfh": {
|
"cfh": {
|
||||||
"title": "Call for Help",
|
"title": "Call for Help",
|
||||||
@@ -2702,7 +2723,9 @@
|
|||||||
"replied": "Reply sent.",
|
"replied": "Reply sent.",
|
||||||
"closed": "Ticket closed.",
|
"closed": "Ticket closed.",
|
||||||
"reopened": "Ticket reopened."
|
"reopened": "Ticket reopened."
|
||||||
}
|
},
|
||||||
|
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
|
||||||
|
"queueOtherCta": "Open CMS support desk"
|
||||||
},
|
},
|
||||||
"marketplace": {
|
"marketplace": {
|
||||||
"title": "Marketplace",
|
"title": "Marketplace",
|
||||||
@@ -2797,6 +2820,31 @@
|
|||||||
"less": "Less",
|
"less": "Less",
|
||||||
"more": "More"
|
"more": "More"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"guilds": {
|
||||||
|
"title": "Guilds",
|
||||||
|
"subtitle": "Hotel groups and membership",
|
||||||
|
"totalGuilds": "Guilds",
|
||||||
|
"searchPlaceholder": "Search by name, description or ID…",
|
||||||
|
"search": "Search",
|
||||||
|
"empty": "No guilds found.",
|
||||||
|
"loadError": "Could not load guilds (database unavailable).",
|
||||||
|
"colId": "ID",
|
||||||
|
"colBadge": "Badge",
|
||||||
|
"colName": "Name",
|
||||||
|
"colOwner": "Owner",
|
||||||
|
"colMembers": "Members",
|
||||||
|
"colCreated": "Created",
|
||||||
|
"colThreads": "Forum threads",
|
||||||
|
"colRoom": "Room",
|
||||||
|
"colUser": "User",
|
||||||
|
"colLevel": "Level",
|
||||||
|
"colJoined": "Joined",
|
||||||
|
"members": "Members",
|
||||||
|
"noMembers": "No members.",
|
||||||
|
"back": "Back to guilds",
|
||||||
|
"viewPublic": "Open public page",
|
||||||
|
"disband": "Disband guild"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -2942,6 +2990,35 @@
|
|||||||
"voteCount": "{count} ({pct}%)",
|
"voteCount": "{count} ({pct}%)",
|
||||||
"totalVotes": "{count} total votes",
|
"totalVotes": "{count} total votes",
|
||||||
"textResponses": "{count} free-text responses"
|
"textResponses": "{count} free-text responses"
|
||||||
|
},
|
||||||
|
"mod": {
|
||||||
|
"title": "Moderation panel",
|
||||||
|
"badge": "Mod",
|
||||||
|
"overviewTitle": "Moderator overview",
|
||||||
|
"overviewSubtitle": "CFH, quick actions and bans without full admin access.",
|
||||||
|
"shortcutsTitle": "Shortcuts",
|
||||||
|
"nav": {
|
||||||
|
"overview": "Overview",
|
||||||
|
"cfh": "Call for Help",
|
||||||
|
"actions": "Quick actions",
|
||||||
|
"bans": "Bans",
|
||||||
|
"fullAdmin": "Full admin",
|
||||||
|
"backToSite": "Back to site",
|
||||||
|
"tickets": "Tickets",
|
||||||
|
"team": "Team",
|
||||||
|
"helpTickets": "Help-center tickets"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"openCfh": "Open CFH",
|
||||||
|
"bansToday": "Bans today",
|
||||||
|
"modsOnline": "Mods online",
|
||||||
|
"quickActions": "Quick actions",
|
||||||
|
"quickActionsHint": "Kick, mute, alert",
|
||||||
|
"openTickets": "Open tickets",
|
||||||
|
"team": "Mod team",
|
||||||
|
"teamHint": "Online staff & workload",
|
||||||
|
"openHelpTickets": "Open help tickets"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"emails": {
|
"emails": {
|
||||||
|
|||||||
+82
-5
@@ -23,7 +23,8 @@
|
|||||||
"closeMenu": "Zatvoriť ponuku",
|
"closeMenu": "Zatvoriť ponuku",
|
||||||
"drawBadge": "Nakreslite odznak",
|
"drawBadge": "Nakreslite odznak",
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"polls": "Polls"
|
"polls": "Polls",
|
||||||
|
"mod": "Mod"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"online": "{count} {hotel} online",
|
"online": "{count} {hotel} online",
|
||||||
@@ -906,7 +907,8 @@
|
|||||||
"chatLog": "Chat log",
|
"chatLog": "Chat log",
|
||||||
"commandLog": "Command log",
|
"commandLog": "Command log",
|
||||||
"tradeLog": "Trade log",
|
"tradeLog": "Trade log",
|
||||||
"tools": "Tools"
|
"tools": "Tools",
|
||||||
|
"guilds": "Guilds"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
@@ -2213,7 +2215,24 @@
|
|||||||
"auditTarget": "Cieľ",
|
"auditTarget": "Cieľ",
|
||||||
"selectAllMatching": "Select all {count} matching",
|
"selectAllMatching": "Select all {count} matching",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"importTitle": "Import permissions"
|
"importTitle": "Import permissions",
|
||||||
|
"legacyBannerTitle": "Use Permissions to change who can do what",
|
||||||
|
"legacyBannerBody": "This archive does not control the site. Open Permissions to grant or revoke access for ranks.",
|
||||||
|
"legacyBannerCta": "Open Permissions",
|
||||||
|
"writesRetired": "Editing here is disabled on purpose. Change access only in Permissions.",
|
||||||
|
"tabCompare": "ACL compare",
|
||||||
|
"tabExportOnly": "Export archive",
|
||||||
|
"compareHint": "Compares legacy housekeeping min-rank rows to live ACL rank_* role grants (lowest rank that has the slug).",
|
||||||
|
"compareAligned": "{count} aligned",
|
||||||
|
"compareMissingAcl": "{count} missing in ACL",
|
||||||
|
"compareMismatch": "{count} rank mismatch",
|
||||||
|
"colAclPresent": "In ACL",
|
||||||
|
"colAclMinRank": "ACL min rank",
|
||||||
|
"colStatus": "Status",
|
||||||
|
"statusAligned": "Aligned",
|
||||||
|
"statusMissingAcl": "Missing in ACL",
|
||||||
|
"statusMismatch": "Rank differs",
|
||||||
|
"exportOnlyHint": "Writes are disabled. You can still export the legacy table for archival."
|
||||||
},
|
},
|
||||||
"rooms": {
|
"rooms": {
|
||||||
"title": "Izby",
|
"title": "Izby",
|
||||||
@@ -2442,7 +2461,9 @@
|
|||||||
"colPriority": "Priority",
|
"colPriority": "Priority",
|
||||||
"colAssigned": "Assigned",
|
"colAssigned": "Assigned",
|
||||||
"colDate": "Date",
|
"colDate": "Date",
|
||||||
"meta": "by {user} · {count} msg · {category}"
|
"meta": "by {user} · {count} msg · {category}",
|
||||||
|
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
|
||||||
|
"queueOtherCta": "Open help-center tickets"
|
||||||
},
|
},
|
||||||
"cfh": {
|
"cfh": {
|
||||||
"title": "Call for Help",
|
"title": "Call for Help",
|
||||||
@@ -2704,7 +2725,9 @@
|
|||||||
"replied": "Reply sent.",
|
"replied": "Reply sent.",
|
||||||
"closed": "Ticket closed.",
|
"closed": "Ticket closed.",
|
||||||
"reopened": "Ticket reopened."
|
"reopened": "Ticket reopened."
|
||||||
}
|
},
|
||||||
|
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
|
||||||
|
"queueOtherCta": "Open CMS support desk"
|
||||||
},
|
},
|
||||||
"marketplace": {
|
"marketplace": {
|
||||||
"title": "Marketplace",
|
"title": "Marketplace",
|
||||||
@@ -2799,6 +2822,31 @@
|
|||||||
"less": "Less",
|
"less": "Less",
|
||||||
"more": "More"
|
"more": "More"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"guilds": {
|
||||||
|
"title": "Guilds",
|
||||||
|
"subtitle": "Hotel groups and membership",
|
||||||
|
"totalGuilds": "Guilds",
|
||||||
|
"searchPlaceholder": "Search by name, description or ID…",
|
||||||
|
"search": "Search",
|
||||||
|
"empty": "No guilds found.",
|
||||||
|
"loadError": "Could not load guilds (database unavailable).",
|
||||||
|
"colId": "ID",
|
||||||
|
"colBadge": "Badge",
|
||||||
|
"colName": "Name",
|
||||||
|
"colOwner": "Owner",
|
||||||
|
"colMembers": "Members",
|
||||||
|
"colCreated": "Created",
|
||||||
|
"colThreads": "Forum threads",
|
||||||
|
"colRoom": "Room",
|
||||||
|
"colUser": "User",
|
||||||
|
"colLevel": "Level",
|
||||||
|
"colJoined": "Joined",
|
||||||
|
"members": "Members",
|
||||||
|
"noMembers": "No members.",
|
||||||
|
"back": "Back to guilds",
|
||||||
|
"viewPublic": "Open public page",
|
||||||
|
"disband": "Disband guild"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -2944,6 +2992,35 @@
|
|||||||
"voteCount": "{count} ({pct}%)",
|
"voteCount": "{count} ({pct}%)",
|
||||||
"totalVotes": "{count} total votes",
|
"totalVotes": "{count} total votes",
|
||||||
"textResponses": "{count} free-text responses"
|
"textResponses": "{count} free-text responses"
|
||||||
|
},
|
||||||
|
"mod": {
|
||||||
|
"title": "Moderation panel",
|
||||||
|
"badge": "Mod",
|
||||||
|
"overviewTitle": "Moderator overview",
|
||||||
|
"overviewSubtitle": "CFH, quick actions and bans without full admin access.",
|
||||||
|
"shortcutsTitle": "Shortcuts",
|
||||||
|
"nav": {
|
||||||
|
"overview": "Overview",
|
||||||
|
"cfh": "Call for Help",
|
||||||
|
"actions": "Quick actions",
|
||||||
|
"bans": "Bans",
|
||||||
|
"fullAdmin": "Full admin",
|
||||||
|
"backToSite": "Back to site",
|
||||||
|
"tickets": "Tickets",
|
||||||
|
"team": "Team",
|
||||||
|
"helpTickets": "Help-center tickets"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"openCfh": "Open CFH",
|
||||||
|
"bansToday": "Bans today",
|
||||||
|
"modsOnline": "Mods online",
|
||||||
|
"quickActions": "Quick actions",
|
||||||
|
"quickActionsHint": "Kick, mute, alert",
|
||||||
|
"openTickets": "Open tickets",
|
||||||
|
"team": "Mod team",
|
||||||
|
"teamHint": "Online staff & workload",
|
||||||
|
"openHelpTickets": "Open help tickets"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"emails": {
|
"emails": {
|
||||||
|
|||||||
+82
-5
@@ -23,7 +23,8 @@
|
|||||||
"closeMenu": "Затвори мени",
|
"closeMenu": "Затвори мени",
|
||||||
"drawBadge": "Нацртај значку",
|
"drawBadge": "Нацртај значку",
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"polls": "Polls"
|
"polls": "Polls",
|
||||||
|
"mod": "Mod"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"online": "{count} {hotel} на мрежи",
|
"online": "{count} {hotel} на мрежи",
|
||||||
@@ -906,7 +907,8 @@
|
|||||||
"chatLog": "Chat log",
|
"chatLog": "Chat log",
|
||||||
"commandLog": "Command log",
|
"commandLog": "Command log",
|
||||||
"tradeLog": "Trade log",
|
"tradeLog": "Trade log",
|
||||||
"tools": "Tools"
|
"tools": "Tools",
|
||||||
|
"guilds": "Guilds"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Дасхбоард",
|
"title": "Дасхбоард",
|
||||||
@@ -2213,7 +2215,24 @@
|
|||||||
"auditTarget": "Таргет",
|
"auditTarget": "Таргет",
|
||||||
"selectAllMatching": "Select all {count} matching",
|
"selectAllMatching": "Select all {count} matching",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"importTitle": "Import permissions"
|
"importTitle": "Import permissions",
|
||||||
|
"legacyBannerTitle": "Use Permissions to change who can do what",
|
||||||
|
"legacyBannerBody": "This archive does not control the site. Open Permissions to grant or revoke access for ranks.",
|
||||||
|
"legacyBannerCta": "Open Permissions",
|
||||||
|
"writesRetired": "Editing here is disabled on purpose. Change access only in Permissions.",
|
||||||
|
"tabCompare": "ACL compare",
|
||||||
|
"tabExportOnly": "Export archive",
|
||||||
|
"compareHint": "Compares legacy housekeeping min-rank rows to live ACL rank_* role grants (lowest rank that has the slug).",
|
||||||
|
"compareAligned": "{count} aligned",
|
||||||
|
"compareMissingAcl": "{count} missing in ACL",
|
||||||
|
"compareMismatch": "{count} rank mismatch",
|
||||||
|
"colAclPresent": "In ACL",
|
||||||
|
"colAclMinRank": "ACL min rank",
|
||||||
|
"colStatus": "Status",
|
||||||
|
"statusAligned": "Aligned",
|
||||||
|
"statusMissingAcl": "Missing in ACL",
|
||||||
|
"statusMismatch": "Rank differs",
|
||||||
|
"exportOnlyHint": "Writes are disabled. You can still export the legacy table for archival."
|
||||||
},
|
},
|
||||||
"rooms": {
|
"rooms": {
|
||||||
"title": "Собе",
|
"title": "Собе",
|
||||||
@@ -2442,7 +2461,9 @@
|
|||||||
"colPriority": "Priority",
|
"colPriority": "Priority",
|
||||||
"colAssigned": "Assigned",
|
"colAssigned": "Assigned",
|
||||||
"colDate": "Date",
|
"colDate": "Date",
|
||||||
"meta": "by {user} · {count} msg · {category}"
|
"meta": "by {user} · {count} msg · {category}",
|
||||||
|
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
|
||||||
|
"queueOtherCta": "Open help-center tickets"
|
||||||
},
|
},
|
||||||
"cfh": {
|
"cfh": {
|
||||||
"title": "Call for Help",
|
"title": "Call for Help",
|
||||||
@@ -2704,7 +2725,9 @@
|
|||||||
"replied": "Reply sent.",
|
"replied": "Reply sent.",
|
||||||
"closed": "Ticket closed.",
|
"closed": "Ticket closed.",
|
||||||
"reopened": "Ticket reopened."
|
"reopened": "Ticket reopened."
|
||||||
}
|
},
|
||||||
|
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
|
||||||
|
"queueOtherCta": "Open CMS support desk"
|
||||||
},
|
},
|
||||||
"marketplace": {
|
"marketplace": {
|
||||||
"title": "Marketplace",
|
"title": "Marketplace",
|
||||||
@@ -2799,6 +2822,31 @@
|
|||||||
"less": "Less",
|
"less": "Less",
|
||||||
"more": "More"
|
"more": "More"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"guilds": {
|
||||||
|
"title": "Guilds",
|
||||||
|
"subtitle": "Hotel groups and membership",
|
||||||
|
"totalGuilds": "Guilds",
|
||||||
|
"searchPlaceholder": "Search by name, description or ID…",
|
||||||
|
"search": "Search",
|
||||||
|
"empty": "No guilds found.",
|
||||||
|
"loadError": "Could not load guilds (database unavailable).",
|
||||||
|
"colId": "ID",
|
||||||
|
"colBadge": "Badge",
|
||||||
|
"colName": "Name",
|
||||||
|
"colOwner": "Owner",
|
||||||
|
"colMembers": "Members",
|
||||||
|
"colCreated": "Created",
|
||||||
|
"colThreads": "Forum threads",
|
||||||
|
"colRoom": "Room",
|
||||||
|
"colUser": "User",
|
||||||
|
"colLevel": "Level",
|
||||||
|
"colJoined": "Joined",
|
||||||
|
"members": "Members",
|
||||||
|
"noMembers": "No members.",
|
||||||
|
"back": "Back to guilds",
|
||||||
|
"viewPublic": "Open public page",
|
||||||
|
"disband": "Disband guild"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -2944,6 +2992,35 @@
|
|||||||
"voteCount": "{count} ({pct}%)",
|
"voteCount": "{count} ({pct}%)",
|
||||||
"totalVotes": "{count} total votes",
|
"totalVotes": "{count} total votes",
|
||||||
"textResponses": "{count} free-text responses"
|
"textResponses": "{count} free-text responses"
|
||||||
|
},
|
||||||
|
"mod": {
|
||||||
|
"title": "Moderation panel",
|
||||||
|
"badge": "Mod",
|
||||||
|
"overviewTitle": "Moderator overview",
|
||||||
|
"overviewSubtitle": "CFH, quick actions and bans without full admin access.",
|
||||||
|
"shortcutsTitle": "Shortcuts",
|
||||||
|
"nav": {
|
||||||
|
"overview": "Overview",
|
||||||
|
"cfh": "Call for Help",
|
||||||
|
"actions": "Quick actions",
|
||||||
|
"bans": "Bans",
|
||||||
|
"fullAdmin": "Full admin",
|
||||||
|
"backToSite": "Back to site",
|
||||||
|
"tickets": "Tickets",
|
||||||
|
"team": "Team",
|
||||||
|
"helpTickets": "Help-center tickets"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"openCfh": "Open CFH",
|
||||||
|
"bansToday": "Bans today",
|
||||||
|
"modsOnline": "Mods online",
|
||||||
|
"quickActions": "Quick actions",
|
||||||
|
"quickActionsHint": "Kick, mute, alert",
|
||||||
|
"openTickets": "Open tickets",
|
||||||
|
"team": "Mod team",
|
||||||
|
"teamHint": "Online staff & workload",
|
||||||
|
"openHelpTickets": "Open help tickets"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"emails": {
|
"emails": {
|
||||||
|
|||||||
+82
-5
@@ -23,7 +23,8 @@
|
|||||||
"closeMenu": "Stäng menyn",
|
"closeMenu": "Stäng menyn",
|
||||||
"drawBadge": "Rita märke",
|
"drawBadge": "Rita märke",
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"polls": "Polls"
|
"polls": "Polls",
|
||||||
|
"mod": "Mod"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"online": "{count} {hotel} online",
|
"online": "{count} {hotel} online",
|
||||||
@@ -906,7 +907,8 @@
|
|||||||
"chatLog": "Chat log",
|
"chatLog": "Chat log",
|
||||||
"commandLog": "Command log",
|
"commandLog": "Command log",
|
||||||
"tradeLog": "Trade log",
|
"tradeLog": "Trade log",
|
||||||
"tools": "Tools"
|
"tools": "Tools",
|
||||||
|
"guilds": "Guilds"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Instrumentbräda",
|
"title": "Instrumentbräda",
|
||||||
@@ -2213,7 +2215,24 @@
|
|||||||
"auditTarget": "Mål",
|
"auditTarget": "Mål",
|
||||||
"selectAllMatching": "Select all {count} matching",
|
"selectAllMatching": "Select all {count} matching",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"importTitle": "Import permissions"
|
"importTitle": "Import permissions",
|
||||||
|
"legacyBannerTitle": "Use Permissions to change who can do what",
|
||||||
|
"legacyBannerBody": "This archive does not control the site. Open Permissions to grant or revoke access for ranks.",
|
||||||
|
"legacyBannerCta": "Open Permissions",
|
||||||
|
"writesRetired": "Editing here is disabled on purpose. Change access only in Permissions.",
|
||||||
|
"tabCompare": "ACL compare",
|
||||||
|
"tabExportOnly": "Export archive",
|
||||||
|
"compareHint": "Compares legacy housekeeping min-rank rows to live ACL rank_* role grants (lowest rank that has the slug).",
|
||||||
|
"compareAligned": "{count} aligned",
|
||||||
|
"compareMissingAcl": "{count} missing in ACL",
|
||||||
|
"compareMismatch": "{count} rank mismatch",
|
||||||
|
"colAclPresent": "In ACL",
|
||||||
|
"colAclMinRank": "ACL min rank",
|
||||||
|
"colStatus": "Status",
|
||||||
|
"statusAligned": "Aligned",
|
||||||
|
"statusMissingAcl": "Missing in ACL",
|
||||||
|
"statusMismatch": "Rank differs",
|
||||||
|
"exportOnlyHint": "Writes are disabled. You can still export the legacy table for archival."
|
||||||
},
|
},
|
||||||
"rooms": {
|
"rooms": {
|
||||||
"title": "Rum",
|
"title": "Rum",
|
||||||
@@ -2442,7 +2461,9 @@
|
|||||||
"colPriority": "Priority",
|
"colPriority": "Priority",
|
||||||
"colAssigned": "Assigned",
|
"colAssigned": "Assigned",
|
||||||
"colDate": "Date",
|
"colDate": "Date",
|
||||||
"meta": "by {user} · {count} msg · {category}"
|
"meta": "by {user} · {count} msg · {category}",
|
||||||
|
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
|
||||||
|
"queueOtherCta": "Open help-center tickets"
|
||||||
},
|
},
|
||||||
"cfh": {
|
"cfh": {
|
||||||
"title": "Call for Help",
|
"title": "Call for Help",
|
||||||
@@ -2704,7 +2725,9 @@
|
|||||||
"replied": "Reply sent.",
|
"replied": "Reply sent.",
|
||||||
"closed": "Ticket closed.",
|
"closed": "Ticket closed.",
|
||||||
"reopened": "Ticket reopened."
|
"reopened": "Ticket reopened."
|
||||||
}
|
},
|
||||||
|
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
|
||||||
|
"queueOtherCta": "Open CMS support desk"
|
||||||
},
|
},
|
||||||
"marketplace": {
|
"marketplace": {
|
||||||
"title": "Marketplace",
|
"title": "Marketplace",
|
||||||
@@ -2799,6 +2822,31 @@
|
|||||||
"less": "Less",
|
"less": "Less",
|
||||||
"more": "More"
|
"more": "More"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"guilds": {
|
||||||
|
"title": "Guilds",
|
||||||
|
"subtitle": "Hotel groups and membership",
|
||||||
|
"totalGuilds": "Guilds",
|
||||||
|
"searchPlaceholder": "Search by name, description or ID…",
|
||||||
|
"search": "Search",
|
||||||
|
"empty": "No guilds found.",
|
||||||
|
"loadError": "Could not load guilds (database unavailable).",
|
||||||
|
"colId": "ID",
|
||||||
|
"colBadge": "Badge",
|
||||||
|
"colName": "Name",
|
||||||
|
"colOwner": "Owner",
|
||||||
|
"colMembers": "Members",
|
||||||
|
"colCreated": "Created",
|
||||||
|
"colThreads": "Forum threads",
|
||||||
|
"colRoom": "Room",
|
||||||
|
"colUser": "User",
|
||||||
|
"colLevel": "Level",
|
||||||
|
"colJoined": "Joined",
|
||||||
|
"members": "Members",
|
||||||
|
"noMembers": "No members.",
|
||||||
|
"back": "Back to guilds",
|
||||||
|
"viewPublic": "Open public page",
|
||||||
|
"disband": "Disband guild"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -2944,6 +2992,35 @@
|
|||||||
"voteCount": "{count} ({pct}%)",
|
"voteCount": "{count} ({pct}%)",
|
||||||
"totalVotes": "{count} total votes",
|
"totalVotes": "{count} total votes",
|
||||||
"textResponses": "{count} free-text responses"
|
"textResponses": "{count} free-text responses"
|
||||||
|
},
|
||||||
|
"mod": {
|
||||||
|
"title": "Moderation panel",
|
||||||
|
"badge": "Mod",
|
||||||
|
"overviewTitle": "Moderator overview",
|
||||||
|
"overviewSubtitle": "CFH, quick actions and bans without full admin access.",
|
||||||
|
"shortcutsTitle": "Shortcuts",
|
||||||
|
"nav": {
|
||||||
|
"overview": "Overview",
|
||||||
|
"cfh": "Call for Help",
|
||||||
|
"actions": "Quick actions",
|
||||||
|
"bans": "Bans",
|
||||||
|
"fullAdmin": "Full admin",
|
||||||
|
"backToSite": "Back to site",
|
||||||
|
"tickets": "Tickets",
|
||||||
|
"team": "Team",
|
||||||
|
"helpTickets": "Help-center tickets"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"openCfh": "Open CFH",
|
||||||
|
"bansToday": "Bans today",
|
||||||
|
"modsOnline": "Mods online",
|
||||||
|
"quickActions": "Quick actions",
|
||||||
|
"quickActionsHint": "Kick, mute, alert",
|
||||||
|
"openTickets": "Open tickets",
|
||||||
|
"team": "Mod team",
|
||||||
|
"teamHint": "Online staff & workload",
|
||||||
|
"openHelpTickets": "Open help tickets"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"emails": {
|
"emails": {
|
||||||
|
|||||||
+82
-5
@@ -23,7 +23,8 @@
|
|||||||
"closeMenu": "Menüyü kapat",
|
"closeMenu": "Menüyü kapat",
|
||||||
"drawBadge": "Rozet çiz",
|
"drawBadge": "Rozet çiz",
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"polls": "Polls"
|
"polls": "Polls",
|
||||||
|
"mod": "Mod"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"online": "{count} {hotel} çevrimiçi",
|
"online": "{count} {hotel} çevrimiçi",
|
||||||
@@ -906,7 +907,8 @@
|
|||||||
"chatLog": "Chat log",
|
"chatLog": "Chat log",
|
||||||
"commandLog": "Command log",
|
"commandLog": "Command log",
|
||||||
"tradeLog": "Trade log",
|
"tradeLog": "Trade log",
|
||||||
"tools": "Tools"
|
"tools": "Tools",
|
||||||
|
"guilds": "Guilds"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Kontrol Paneli",
|
"title": "Kontrol Paneli",
|
||||||
@@ -2213,7 +2215,24 @@
|
|||||||
"auditTarget": "Hedef",
|
"auditTarget": "Hedef",
|
||||||
"selectAllMatching": "Select all {count} matching",
|
"selectAllMatching": "Select all {count} matching",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"importTitle": "Import permissions"
|
"importTitle": "Import permissions",
|
||||||
|
"legacyBannerTitle": "Use Permissions to change who can do what",
|
||||||
|
"legacyBannerBody": "This archive does not control the site. Open Permissions to grant or revoke access for ranks.",
|
||||||
|
"legacyBannerCta": "Open Permissions",
|
||||||
|
"writesRetired": "Editing here is disabled on purpose. Change access only in Permissions.",
|
||||||
|
"tabCompare": "ACL compare",
|
||||||
|
"tabExportOnly": "Export archive",
|
||||||
|
"compareHint": "Compares legacy housekeeping min-rank rows to live ACL rank_* role grants (lowest rank that has the slug).",
|
||||||
|
"compareAligned": "{count} aligned",
|
||||||
|
"compareMissingAcl": "{count} missing in ACL",
|
||||||
|
"compareMismatch": "{count} rank mismatch",
|
||||||
|
"colAclPresent": "In ACL",
|
||||||
|
"colAclMinRank": "ACL min rank",
|
||||||
|
"colStatus": "Status",
|
||||||
|
"statusAligned": "Aligned",
|
||||||
|
"statusMissingAcl": "Missing in ACL",
|
||||||
|
"statusMismatch": "Rank differs",
|
||||||
|
"exportOnlyHint": "Writes are disabled. You can still export the legacy table for archival."
|
||||||
},
|
},
|
||||||
"rooms": {
|
"rooms": {
|
||||||
"title": "Odalar",
|
"title": "Odalar",
|
||||||
@@ -2442,7 +2461,9 @@
|
|||||||
"colPriority": "Priority",
|
"colPriority": "Priority",
|
||||||
"colAssigned": "Assigned",
|
"colAssigned": "Assigned",
|
||||||
"colDate": "Date",
|
"colDate": "Date",
|
||||||
"meta": "by {user} · {count} msg · {category}"
|
"meta": "by {user} · {count} msg · {category}",
|
||||||
|
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
|
||||||
|
"queueOtherCta": "Open help-center tickets"
|
||||||
},
|
},
|
||||||
"cfh": {
|
"cfh": {
|
||||||
"title": "Call for Help",
|
"title": "Call for Help",
|
||||||
@@ -2704,7 +2725,9 @@
|
|||||||
"replied": "Reply sent.",
|
"replied": "Reply sent.",
|
||||||
"closed": "Ticket closed.",
|
"closed": "Ticket closed.",
|
||||||
"reopened": "Ticket reopened."
|
"reopened": "Ticket reopened."
|
||||||
}
|
},
|
||||||
|
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
|
||||||
|
"queueOtherCta": "Open CMS support desk"
|
||||||
},
|
},
|
||||||
"marketplace": {
|
"marketplace": {
|
||||||
"title": "Marketplace",
|
"title": "Marketplace",
|
||||||
@@ -2799,6 +2822,31 @@
|
|||||||
"less": "Less",
|
"less": "Less",
|
||||||
"more": "More"
|
"more": "More"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"guilds": {
|
||||||
|
"title": "Guilds",
|
||||||
|
"subtitle": "Hotel groups and membership",
|
||||||
|
"totalGuilds": "Guilds",
|
||||||
|
"searchPlaceholder": "Search by name, description or ID…",
|
||||||
|
"search": "Search",
|
||||||
|
"empty": "No guilds found.",
|
||||||
|
"loadError": "Could not load guilds (database unavailable).",
|
||||||
|
"colId": "ID",
|
||||||
|
"colBadge": "Badge",
|
||||||
|
"colName": "Name",
|
||||||
|
"colOwner": "Owner",
|
||||||
|
"colMembers": "Members",
|
||||||
|
"colCreated": "Created",
|
||||||
|
"colThreads": "Forum threads",
|
||||||
|
"colRoom": "Room",
|
||||||
|
"colUser": "User",
|
||||||
|
"colLevel": "Level",
|
||||||
|
"colJoined": "Joined",
|
||||||
|
"members": "Members",
|
||||||
|
"noMembers": "No members.",
|
||||||
|
"back": "Back to guilds",
|
||||||
|
"viewPublic": "Open public page",
|
||||||
|
"disband": "Disband guild"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -2944,6 +2992,35 @@
|
|||||||
"voteCount": "{count} ({pct}%)",
|
"voteCount": "{count} ({pct}%)",
|
||||||
"totalVotes": "{count} total votes",
|
"totalVotes": "{count} total votes",
|
||||||
"textResponses": "{count} free-text responses"
|
"textResponses": "{count} free-text responses"
|
||||||
|
},
|
||||||
|
"mod": {
|
||||||
|
"title": "Moderation panel",
|
||||||
|
"badge": "Mod",
|
||||||
|
"overviewTitle": "Moderator overview",
|
||||||
|
"overviewSubtitle": "CFH, quick actions and bans without full admin access.",
|
||||||
|
"shortcutsTitle": "Shortcuts",
|
||||||
|
"nav": {
|
||||||
|
"overview": "Overview",
|
||||||
|
"cfh": "Call for Help",
|
||||||
|
"actions": "Quick actions",
|
||||||
|
"bans": "Bans",
|
||||||
|
"fullAdmin": "Full admin",
|
||||||
|
"backToSite": "Back to site",
|
||||||
|
"tickets": "Tickets",
|
||||||
|
"team": "Team",
|
||||||
|
"helpTickets": "Help-center tickets"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"openCfh": "Open CFH",
|
||||||
|
"bansToday": "Bans today",
|
||||||
|
"modsOnline": "Mods online",
|
||||||
|
"quickActions": "Quick actions",
|
||||||
|
"quickActionsHint": "Kick, mute, alert",
|
||||||
|
"openTickets": "Open tickets",
|
||||||
|
"team": "Mod team",
|
||||||
|
"teamHint": "Online staff & workload",
|
||||||
|
"openHelpTickets": "Open help tickets"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"emails": {
|
"emails": {
|
||||||
|
|||||||
+82
-5
@@ -23,7 +23,8 @@
|
|||||||
"closeMenu": "Закрити меню",
|
"closeMenu": "Закрити меню",
|
||||||
"drawBadge": "Намалюйте значок",
|
"drawBadge": "Намалюйте значок",
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"polls": "Polls"
|
"polls": "Polls",
|
||||||
|
"mod": "Mod"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"online": "{count} {hotel} онлайн",
|
"online": "{count} {hotel} онлайн",
|
||||||
@@ -906,7 +907,8 @@
|
|||||||
"chatLog": "Chat log",
|
"chatLog": "Chat log",
|
||||||
"commandLog": "Command log",
|
"commandLog": "Command log",
|
||||||
"tradeLog": "Trade log",
|
"tradeLog": "Trade log",
|
||||||
"tools": "Tools"
|
"tools": "Tools",
|
||||||
|
"guilds": "Guilds"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Приладова панель",
|
"title": "Приладова панель",
|
||||||
@@ -2213,7 +2215,24 @@
|
|||||||
"auditTarget": "Цільова",
|
"auditTarget": "Цільова",
|
||||||
"selectAllMatching": "Select all {count} matching",
|
"selectAllMatching": "Select all {count} matching",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"importTitle": "Import permissions"
|
"importTitle": "Import permissions",
|
||||||
|
"legacyBannerTitle": "Use Permissions to change who can do what",
|
||||||
|
"legacyBannerBody": "This archive does not control the site. Open Permissions to grant or revoke access for ranks.",
|
||||||
|
"legacyBannerCta": "Open Permissions",
|
||||||
|
"writesRetired": "Editing here is disabled on purpose. Change access only in Permissions.",
|
||||||
|
"tabCompare": "ACL compare",
|
||||||
|
"tabExportOnly": "Export archive",
|
||||||
|
"compareHint": "Compares legacy housekeeping min-rank rows to live ACL rank_* role grants (lowest rank that has the slug).",
|
||||||
|
"compareAligned": "{count} aligned",
|
||||||
|
"compareMissingAcl": "{count} missing in ACL",
|
||||||
|
"compareMismatch": "{count} rank mismatch",
|
||||||
|
"colAclPresent": "In ACL",
|
||||||
|
"colAclMinRank": "ACL min rank",
|
||||||
|
"colStatus": "Status",
|
||||||
|
"statusAligned": "Aligned",
|
||||||
|
"statusMissingAcl": "Missing in ACL",
|
||||||
|
"statusMismatch": "Rank differs",
|
||||||
|
"exportOnlyHint": "Writes are disabled. You can still export the legacy table for archival."
|
||||||
},
|
},
|
||||||
"rooms": {
|
"rooms": {
|
||||||
"title": "Кімнати",
|
"title": "Кімнати",
|
||||||
@@ -2442,7 +2461,9 @@
|
|||||||
"colPriority": "Priority",
|
"colPriority": "Priority",
|
||||||
"colAssigned": "Assigned",
|
"colAssigned": "Assigned",
|
||||||
"colDate": "Date",
|
"colDate": "Date",
|
||||||
"meta": "by {user} · {count} msg · {category}"
|
"meta": "by {user} · {count} msg · {category}",
|
||||||
|
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
|
||||||
|
"queueOtherCta": "Open help-center tickets"
|
||||||
},
|
},
|
||||||
"cfh": {
|
"cfh": {
|
||||||
"title": "Call for Help",
|
"title": "Call for Help",
|
||||||
@@ -2704,7 +2725,9 @@
|
|||||||
"replied": "Reply sent.",
|
"replied": "Reply sent.",
|
||||||
"closed": "Ticket closed.",
|
"closed": "Ticket closed.",
|
||||||
"reopened": "Ticket reopened."
|
"reopened": "Ticket reopened."
|
||||||
}
|
},
|
||||||
|
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
|
||||||
|
"queueOtherCta": "Open CMS support desk"
|
||||||
},
|
},
|
||||||
"marketplace": {
|
"marketplace": {
|
||||||
"title": "Marketplace",
|
"title": "Marketplace",
|
||||||
@@ -2799,6 +2822,31 @@
|
|||||||
"less": "Less",
|
"less": "Less",
|
||||||
"more": "More"
|
"more": "More"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"guilds": {
|
||||||
|
"title": "Guilds",
|
||||||
|
"subtitle": "Hotel groups and membership",
|
||||||
|
"totalGuilds": "Guilds",
|
||||||
|
"searchPlaceholder": "Search by name, description or ID…",
|
||||||
|
"search": "Search",
|
||||||
|
"empty": "No guilds found.",
|
||||||
|
"loadError": "Could not load guilds (database unavailable).",
|
||||||
|
"colId": "ID",
|
||||||
|
"colBadge": "Badge",
|
||||||
|
"colName": "Name",
|
||||||
|
"colOwner": "Owner",
|
||||||
|
"colMembers": "Members",
|
||||||
|
"colCreated": "Created",
|
||||||
|
"colThreads": "Forum threads",
|
||||||
|
"colRoom": "Room",
|
||||||
|
"colUser": "User",
|
||||||
|
"colLevel": "Level",
|
||||||
|
"colJoined": "Joined",
|
||||||
|
"members": "Members",
|
||||||
|
"noMembers": "No members.",
|
||||||
|
"back": "Back to guilds",
|
||||||
|
"viewPublic": "Open public page",
|
||||||
|
"disband": "Disband guild"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -2944,6 +2992,35 @@
|
|||||||
"voteCount": "{count} ({pct}%)",
|
"voteCount": "{count} ({pct}%)",
|
||||||
"totalVotes": "{count} total votes",
|
"totalVotes": "{count} total votes",
|
||||||
"textResponses": "{count} free-text responses"
|
"textResponses": "{count} free-text responses"
|
||||||
|
},
|
||||||
|
"mod": {
|
||||||
|
"title": "Moderation panel",
|
||||||
|
"badge": "Mod",
|
||||||
|
"overviewTitle": "Moderator overview",
|
||||||
|
"overviewSubtitle": "CFH, quick actions and bans without full admin access.",
|
||||||
|
"shortcutsTitle": "Shortcuts",
|
||||||
|
"nav": {
|
||||||
|
"overview": "Overview",
|
||||||
|
"cfh": "Call for Help",
|
||||||
|
"actions": "Quick actions",
|
||||||
|
"bans": "Bans",
|
||||||
|
"fullAdmin": "Full admin",
|
||||||
|
"backToSite": "Back to site",
|
||||||
|
"tickets": "Tickets",
|
||||||
|
"team": "Team",
|
||||||
|
"helpTickets": "Help-center tickets"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"openCfh": "Open CFH",
|
||||||
|
"bansToday": "Bans today",
|
||||||
|
"modsOnline": "Mods online",
|
||||||
|
"quickActions": "Quick actions",
|
||||||
|
"quickActionsHint": "Kick, mute, alert",
|
||||||
|
"openTickets": "Open tickets",
|
||||||
|
"team": "Mod team",
|
||||||
|
"teamHint": "Online staff & workload",
|
||||||
|
"openHelpTickets": "Open help tickets"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"emails": {
|
"emails": {
|
||||||
|
|||||||
@@ -20,7 +20,6 @@
|
|||||||
"name": "next"
|
"name": "next"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"baseUrl": ".",
|
|
||||||
"ignoreDeprecations": "6.0",
|
"ignoreDeprecations": "6.0",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ export default defineConfig({
|
|||||||
// Modules under test transitively import @/env; skip its strict parse so
|
// Modules under test transitively import @/env; skip its strict parse so
|
||||||
// unit tests run without a populated .env.
|
// unit tests run without a populated .env.
|
||||||
env: { SKIP_ENV_VALIDATION: "1" },
|
env: { SKIP_ENV_VALIDATION: "1" },
|
||||||
exclude: ["e2e/**", "node_modules/**", ".next/**"],
|
exclude: ["e2e/**", "node_modules/**", ".next/**", ".next.prev/**"],
|
||||||
coverage: {
|
coverage: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
provider: "v8",
|
provider: "v8",
|
||||||
|
|||||||
Reference in new issue
Block a user