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,
|
||||
# it does NOT own or migrate the emulator tables. Format:
|
||||
DATABASE_URL=mysql://user:[email protected]:3306/atomcms
|
||||
# ==============================================================================
|
||||
# Epicnextcms — Ultimate Speed & Low-Latency Example Configuration
|
||||
# ==============================================================================
|
||||
|
||||
# Optional pool tuning (defaults shown)
|
||||
DATABASE_POOL_SIZE=10
|
||||
DATABASE_IDLE_TIMEOUT_MS=300000
|
||||
DATABASE_CONNECT_TIMEOUT_MS=10000
|
||||
# --- DATABASE (High Performance Pooling & Strict Timeouts) ---
|
||||
DATABASE_URL="mysql://user:password@localhost:3306/dbname?charset=utf8mb4&connection_limit=150&connect_timeout=5"
|
||||
DATABASE_POOL_SIZE=150
|
||||
DATABASE_IDLE_TIMEOUT_MS=60000
|
||||
DATABASE_CONNECT_TIMEOUT_MS=5000
|
||||
|
||||
# Redis for rate limits, site-settings cache, and JWT invalidation
|
||||
# REDIS_URL=redis://localhost:6379
|
||||
# --- REDIS (Lightning Fast Caching & Sessions) ---
|
||||
REDIS_URL=redis://127.0.0.1:6379?connect_timeout=2
|
||||
REDIS_CACHE_TTL_DEFAULT=7200
|
||||
|
||||
# Used by SSO ticket generation ({HOTEL_NAME}-{uuid})
|
||||
HOTEL_NAME=Atom
|
||||
APP_URL=http://localhost:3000
|
||||
# NEXT_PUBLIC_APP_URL=https://yourdomain.com
|
||||
AUTH_URL=http://localhost:3000
|
||||
# --- CORE RUNTIME & PERFORMANCE FLAGS ---
|
||||
NODE_ENV=production
|
||||
PORT=3002
|
||||
NEXT_TELEMETRY_DISABLED=1
|
||||
UV_THREADPOOL_SIZE=16
|
||||
|
||||
# NextAuth — required in production (>=32 chars). Optional in development.
|
||||
# Laravel APP_KEY (base64:...) for existing 2FA secrets.
|
||||
AUTH_SECRET=
|
||||
APP_KEY=
|
||||
CONVERT_PASSWORDS=false
|
||||
# --- HOTEL & URLS ---
|
||||
HOTEL_NAME=EPIC WEB CONTROL
|
||||
APP_URL=http://localhost:3002
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3002
|
||||
AUTH_URL=http://localhost:3002
|
||||
|
||||
# Password hashing for NEW/upgraded passwords: "bcrypt" (default; 60-char $2y$,
|
||||
# fits a varchar(64) users.password) or "argon2id" (~97 chars, needs a wider
|
||||
# column). Existing accounts in either format still verify on login.
|
||||
PASSWORD_HASH=bcrypt
|
||||
# --- IMAGER ---
|
||||
IMAGING_UPSTREAM_URL=http://127.0.0.1:3030/imaging
|
||||
NEXT_PUBLIC_IMAGER_URL=http://localhost:3002/imaging
|
||||
|
||||
# Filesystem directory the badge uploader (/admin/badges) writes <code>.gif into
|
||||
# — the emulator's badge image folder (e.g. .../assets/c_images/album1584).
|
||||
# Leave unset to disable badge uploads.
|
||||
BADGE_UPLOAD_DIR=
|
||||
# --- SECURITY & HASHING ---
|
||||
AUTH_SECRET=your-super-secret-auth-key-change-this-min-32-chars
|
||||
APP_KEY=base64:your-app-key-here=
|
||||
CONVERT_PASSWORDS=true
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
# Emulator JAR backup job (jobs-worker, runs host-side). When both are set, the
|
||||
# worker copies the JAR daily into the backup dir, keeping the newest N.
|
||||
EMULATOR_JAR_PATH=
|
||||
EMULATOR_BACKUP_DIR=
|
||||
# --- PATHS ---
|
||||
BADGE_UPLOAD_DIR=./public/assets/images/badges
|
||||
EMULATOR_JAR_PATH=./emulator/Arcturus.jar
|
||||
EMULATOR_BACKUP_DIR=./backups/emulator
|
||||
EMULATOR_BACKUP_KEEP=7
|
||||
|
||||
# RCON link to the Arcturus emulator
|
||||
# --- RCON (Low Latency Loop) ---
|
||||
RCON_HOST=127.0.0.1
|
||||
RCON_PORT=3001
|
||||
RCON_TIMEOUT_MS=2000
|
||||
|
||||
# Public imager URL — overrides the default /imaging relative path.
|
||||
# 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)
|
||||
# --- EMAIL & NOTIFICATIONS ---
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=
|
||||
SMTP_FROM=[email protected]
|
||||
|
||||
# Optional alerting (jobs worker / alert service)
|
||||
# --- ALERTING & MONITORING ---
|
||||
DISCORD_WEBHOOK_URL=
|
||||
ALERT_EMAIL=
|
||||
|
||||
# Optional AI content moderation (user comments / guestbook).
|
||||
# When set, posts are checked against the OpenAI Moderations endpoint in
|
||||
# addition to the website_wordfilter blocklist. Fail-open if unset/erroring.
|
||||
# --- MODERATION & PAYMENTS ---
|
||||
OPENAI_API_KEY=
|
||||
|
||||
# Optional PayPal top-up (sandbox by default)
|
||||
PAYPAL_CLIENT_ID=
|
||||
PAYPAL_SECRET=
|
||||
PAYPAL_API=https://api-m.sandbox.paypal.com
|
||||
|
||||
# Redis — REQUIRED for production (shared rate limits, site-settings cache,
|
||||
# JWT session invalidation cache). Without REDIS_URL the app falls back to
|
||||
# in-process memory: limits reset on restart and do not work across instances.
|
||||
# Deploy logs a loud warning when this is unset in production.
|
||||
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=
|
||||
# --- LOGGING & SENTRY (Zod-Proof Dummy URLs) ---
|
||||
LOG_LEVEL=error
|
||||
SENTRY_DSN=https://[email protected]/0
|
||||
NEXT_PUBLIC_SENTRY_DSN=https://[email protected]/0
|
||||
@@ -1,8 +1,12 @@
|
||||
name: CI
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
@@ -30,12 +34,14 @@ jobs:
|
||||
git checkout -f "${REF}"
|
||||
|
||||
export SKIP_ENV_VALIDATION=1
|
||||
export ARGON2_MEMORY_SIZE=1024
|
||||
export ARGON2_ITERATIONS=1
|
||||
export NODE_ENV=test
|
||||
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
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm prisma:generate
|
||||
pnpm biome:lint
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
echo "--- CI checks passed ---"
|
||||
echo "--- CI checks passed ---"
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
echo '<a id="what-is-epicnext-cms"></a>'
|
||||
echo "## What is EpicNext-CMS?"
|
||||
echo ""
|
||||
echo "EpicNext-CMS is a full public-facing hotel website plus an administrative panel. It features NextAuth authentication (argon2id/bcrypt with MD5 upgrade), real-time RCON communication with the emulator, Server-Sent Events for live radio, smooth page transitions and extensive extensibility. Full documentation: https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/README.md"
|
||||
echo "EpicNext-CMS is a full public-facing hotel website plus an administrative panel. It features NextAuth authentication (bcrypt with MD5 upgrade), real-time RCON communication with the emulator, Server-Sent Events for live radio, smooth page transitions and extensive extensibility. Full documentation: https://gitlab.epicnabbo.nl/remco/EpicNext-Cms/src/branch/main/README.md"
|
||||
echo ""
|
||||
echo '<a id="system-requirements"></a>'
|
||||
echo "## System Requirements"
|
||||
@@ -314,7 +314,7 @@ jobs:
|
||||
pnpm install --frozen-lockfile
|
||||
# prisma generate does not need a live DB connection.
|
||||
pnpm prisma:generate
|
||||
export ARGON2_MEMORY_SIZE=1024 ARGON2_ITERATIONS=1 BCRYPT_ROUNDS=4
|
||||
export BCRYPT_ROUNDS=4
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
# Validate production env (AUTH_SECRET, DATABASE_URL, …) during build.
|
||||
@@ -377,10 +377,39 @@ jobs:
|
||||
# Prisma client is gitignored — regenerate into live src/generated.
|
||||
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)..."
|
||||
pm2 reload next --update-env || pm2 start next --update-env
|
||||
# Next.js prefers an already-set process PORT over .env. PM2 may still
|
||||
# have PORT=3000 from an older start, while .env (and nginx) expect 3002.
|
||||
# Export PORT before start so the process matches health checks.
|
||||
DEPLOY_PORT="$(grep -E '^[[:space:]]*PORT=' "${LIVE}/.env" 2>/dev/null | tail -1 | cut -d= -f2- || true)"
|
||||
DEPLOY_PORT="$(printf '%s' "${DEPLOY_PORT}" | tr -cd '0-9')"
|
||||
DEPLOY_PORT="${DEPLOY_PORT:-3002}"
|
||||
export PORT="${DEPLOY_PORT}"
|
||||
echo "PM2/health PORT=${PORT}"
|
||||
|
||||
free_tcp_port() {
|
||||
local port="$1"
|
||||
[ -n "${port}" ] || return 0
|
||||
if command -v fuser >/dev/null 2>&1; then
|
||||
fuser -k "${port}/tcp" 2>/dev/null || true
|
||||
elif command -v lsof >/dev/null 2>&1; then
|
||||
# Portable fallback when fuser is unavailable.
|
||||
lsof -tiTCP:"${port}" -sTCP:LISTEN 2>/dev/null | xargs -r kill -9 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
echo "Starting PM2 with a clean PORT=${PORT} listener..."
|
||||
cd "${LIVE}"
|
||||
# Cutover already stopped the app, but failed deploys can leave orphans
|
||||
# on 3002 (or an old PM2 env still bound to 3000).
|
||||
pm2 stop next --kill-timeout 10000 2>/dev/null || true
|
||||
free_tcp_port "${PORT}"
|
||||
free_tcp_port 3000
|
||||
sleep 1
|
||||
pm2 delete next 2>/dev/null || true
|
||||
PORT="${PORT}" pm2 start pnpm --name next -- start
|
||||
pm2 save 2>/dev/null || true
|
||||
|
||||
sleep 3
|
||||
if ! pm2 show next 2>/dev/null | grep -q 'online'; then
|
||||
@@ -389,22 +418,25 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Waiting for HTTP health check..."
|
||||
HEALTH_URL="${DEPLOY_HEALTH_URL:-http://127.0.0.1:3000/api/health}"
|
||||
echo "Waiting for HTTP health check on port ${PORT}..."
|
||||
HEALTH_URL="${DEPLOY_HEALTH_URL:-http://127.0.0.1:${PORT}/api/health}"
|
||||
HEALTH_OK=0
|
||||
for i in $(seq 1 15); do
|
||||
for i in $(seq 1 20); do
|
||||
BODY="$(curl -sf --max-time 5 "${HEALTH_URL}" 2>/dev/null || true)"
|
||||
if echo "${BODY}" | grep -q '"database":true'; then
|
||||
echo "Health OK (${HEALTH_URL})"
|
||||
HEALTH_OK=1
|
||||
break
|
||||
fi
|
||||
echo "Health attempt ${i}/15 failed, retrying..."
|
||||
echo "Health attempt ${i}/20 failed (body=${BODY:-<empty>}), retrying..."
|
||||
sleep 2
|
||||
done
|
||||
if [ "${HEALTH_OK}" != "1" ]; then
|
||||
echo "ERROR: Health check failed after deploy (${HEALTH_URL})" >&2
|
||||
echo "Last body: ${BODY:-<empty>}" >&2
|
||||
echo "Listeners on PORT ${PORT}:" >&2
|
||||
ss -tlnp 2>/dev/null | grep ":${PORT} " >&2 || netstat -tlnp 2>/dev/null | grep ":${PORT} " >&2 || true
|
||||
pm2 env 0 2>/dev/null | grep -E '^PORT=' >&2 || true
|
||||
pm2 logs next --lines 40 --nostream >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -11,9 +11,8 @@ jobs:
|
||||
- name: Self-hosted Renovate
|
||||
run: |
|
||||
set -e
|
||||
|
||||
# Zorg ervoor dat de cache-map lokaal bestaat vóór Docker start
|
||||
# Dit voorkomt dat Docker de map automatisch als 'root' aanmaakt
|
||||
|
||||
# Ensure cache dir exists before Docker starts
|
||||
mkdir -p /var/tmp/renovate-cache
|
||||
|
||||
docker run --rm \
|
||||
@@ -25,4 +24,4 @@ jobs:
|
||||
-e RENOVATE_CONFIG_FILE='{"extends":["config:recommended"]}' \
|
||||
-e LOG_LEVEL=info \
|
||||
-v /var/tmp/renovate-cache:/tmp/renovate-cache \
|
||||
ghcr.io/renovatebot/renovate:latest
|
||||
ghcr.io/renovatebot/renovate:latest
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
pnpm exec lint-staged
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
A modern, high-performance content management system for Habbo hotel emulators, built on **Next.js 16** (App Router) with **Prisma 7** and **React 19**. Designed to integrate seamlessly with Polaris / Arcturus Morningstar MySQL/MariaDB databases.
|
||||
|
||||
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 }],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
+6
-1
@@ -40,6 +40,11 @@ const nextConfig: NextConfig = {
|
||||
// Disable Next.js telemetry
|
||||
productionBrowserSourceMaps: false,
|
||||
|
||||
// Enable TypeScript CLI integration for TypeScript 7 compatibility
|
||||
experimental: {
|
||||
useTypeScriptCli: true,
|
||||
},
|
||||
|
||||
// Add caching headers for static assets
|
||||
async headers() {
|
||||
return [
|
||||
@@ -94,4 +99,4 @@ export default withBA(
|
||||
},
|
||||
telemetry: false,
|
||||
}),
|
||||
);
|
||||
);
|
||||
+12
-33
@@ -11,19 +11,13 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"prisma:generate": "prisma generate",
|
||||
"typecheck": "tsc6 --noEmit --incremental false",
|
||||
"typecheck": "tsc --noEmit --incremental",
|
||||
"biome:check": "biome check --write .",
|
||||
"biome:lint": "biome lint .",
|
||||
"biome:format": "biome format --write .",
|
||||
"knip": "knip",
|
||||
"analyze": "ANALYZE=true pnpm build",
|
||||
"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:status": "tsx scripts/apply-migrations.ts --status",
|
||||
"jobs:worker": "tsx scripts/jobs-worker.ts",
|
||||
@@ -31,8 +25,7 @@
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx}": [
|
||||
"biome check --write",
|
||||
"eslint --fix --max-warnings=50"
|
||||
"biome check --write"
|
||||
],
|
||||
"*.{json,md,css,scss,html}": [
|
||||
"biome format --write"
|
||||
@@ -46,36 +39,33 @@
|
||||
"@hookform/resolvers": "^5.5.7",
|
||||
"@prisma/adapter-mariadb": "^7.9.1",
|
||||
"@prisma/client": "^7.9.1",
|
||||
"@sentry/nextjs": "^10.68.0",
|
||||
"@tanstack/react-virtual": "^3.14.8",
|
||||
"bcrypt": "^6.0.0",
|
||||
"@sentry/nextjs": "^10.69.0",
|
||||
"@tanstack/react-virtual": "^3.14.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"croner": "^10.0.1",
|
||||
"framer-motion": "^12.42.2",
|
||||
"hash-wasm": "^4.12.0",
|
||||
"ioredis": "^5.11.1",
|
||||
"isomorphic-dompurify": "^3.20.0",
|
||||
"jpeg-js": "^0.4.4",
|
||||
"json5": "^2.2.3",
|
||||
"jszip": "^3.10.1",
|
||||
"lenis": "^1.3.25",
|
||||
"lucide-react": "^1.27.0",
|
||||
"lucide-react": "^1.28.0",
|
||||
"lzma": "^2.3.2",
|
||||
"motion": "^12.43.0",
|
||||
"music-metadata": "^11.14.0",
|
||||
"mysql2": "^3.23.2",
|
||||
"next": "^16.2.12",
|
||||
"next-auth": "5.0.0-beta.32",
|
||||
"next-intl": "^4.13.4",
|
||||
"next-view-transitions": "^0.3.5",
|
||||
"nodemailer": "^9.0.3",
|
||||
"otplib": "^13.4.1",
|
||||
"pino": "^10.3.1",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-hook-form": "^7.83.0",
|
||||
"resend": "^6.18.1",
|
||||
"sanitize-html": "^2.17.6",
|
||||
"server-only": "^0.0.1",
|
||||
"sharp": "^0.35.3",
|
||||
"sonner": "^2.0.7",
|
||||
@@ -85,37 +75,26 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.6",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@lhci/cli": "^0.15.1",
|
||||
"@next/bundle-analyzer": "^16.2.12",
|
||||
"@next/eslint-plugin-next": "^16.2.12",
|
||||
"@playwright/test": "1.62.0",
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/sanitize-html": "^2.16.1",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"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",
|
||||
"knip": "^6.29.0",
|
||||
"lint-staged": "^17.2.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"postcss": "^8.5.24",
|
||||
"postcss": "^8.5.25",
|
||||
"prisma": "^7.9.1",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tsx": "^4.23.1",
|
||||
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
||||
"typescript-eslint": "^8.65.0",
|
||||
"vite": "8.1.5",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "8.2.0",
|
||||
"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);
|
||||
revalidatePath("/admin/help-tickets");
|
||||
revalidatePath(`/admin/help-tickets/${id}`);
|
||||
revalidatePath("/mod/help-tickets");
|
||||
revalidatePath(`/mod/help-tickets/${id}`);
|
||||
revalidatePath("/help/tickets");
|
||||
revalidatePath(`/help/tickets/${id}`);
|
||||
}
|
||||
|
||||
const HELP_TICKET_EDIT = [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT] as const;
|
||||
|
||||
export const replyHelpCenterTicket = adminAction(
|
||||
{ permission: PERMS.TICKETS_EDIT, schema: replyHelpCenterTicketSchema },
|
||||
{ permission: HELP_TICKET_EDIT, schema: replyHelpCenterTicketSchema },
|
||||
async (ctx) => {
|
||||
const ticketId = ctx.data.ticketId;
|
||||
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
||||
@@ -72,7 +76,7 @@ export const replyHelpCenterTicket = adminAction(
|
||||
);
|
||||
|
||||
export const closeHelpCenterTicket = adminAction(
|
||||
{ permission: PERMS.TICKETS_EDIT, schema: helpCenterTicketIdSchema },
|
||||
{ permission: HELP_TICKET_EDIT, schema: helpCenterTicketIdSchema },
|
||||
async (ctx) => {
|
||||
const ticketId = ctx.data.ticketId;
|
||||
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
||||
@@ -104,7 +108,7 @@ export const closeHelpCenterTicket = adminAction(
|
||||
);
|
||||
|
||||
export const reopenHelpCenterTicket = adminAction(
|
||||
{ permission: PERMS.TICKETS_EDIT, schema: helpCenterTicketIdSchema },
|
||||
{ permission: HELP_TICKET_EDIT, schema: helpCenterTicketIdSchema },
|
||||
async (ctx) => {
|
||||
const ticketId = ctx.data.ticketId;
|
||||
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,
|
||||
maintenance,
|
||||
activity,
|
||||
onlineUsersRaw,
|
||||
] = await Promise.all([
|
||||
prisma.user.count({ where: { online: "1" } }).catch(() => 0),
|
||||
prisma.user.count().catch(() => -1),
|
||||
@@ -70,8 +71,30 @@ export default async function CommandoCentrum() {
|
||||
.catch(
|
||||
() => [] 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(
|
||||
new Set(activity.map((a) => Number(a.userId))),
|
||||
).filter((n) => n > 0);
|
||||
@@ -134,7 +157,7 @@ export default async function CommandoCentrum() {
|
||||
{/* ── Online users widget ───────────────────────────────── */}
|
||||
<section className="admin-card mb-6">
|
||||
<h2 className="admin-section-title">{t("onlineUsers")}</h2>
|
||||
<OnlineUsersWidget users={[]} isLoading={false} />
|
||||
<OnlineUsersWidget users={onlineUsers} isLoading={false} />
|
||||
</section>
|
||||
|
||||
{/* ── Server info + diagnostics ──────────────────────────── */}
|
||||
|
||||
@@ -42,10 +42,15 @@ export function AdminHelpTicketDetail({
|
||||
ticket,
|
||||
messages: initialMessages,
|
||||
canEdit,
|
||||
listHref = "/admin/help-tickets",
|
||||
hideRequesterContact = false,
|
||||
}: {
|
||||
ticket: HelpTicketInfo;
|
||||
messages: ThreadMessage[];
|
||||
canEdit: boolean;
|
||||
listHref?: string;
|
||||
/** Hide email/IP-style contact for mod-lite surfaces. */
|
||||
hideRequesterContact?: boolean;
|
||||
}) {
|
||||
const t = useTranslations("pages.admin.helpTickets");
|
||||
const [messages, setMessages] = useState(initialMessages);
|
||||
@@ -92,7 +97,7 @@ export function AdminHelpTicketDetail({
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<Link href="/admin/help-tickets">
|
||||
<Link href={listHref}>
|
||||
<Button variant="ghost" size="icon" aria-label={t("backToList")}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -241,7 +246,11 @@ export function AdminHelpTicketDetail({
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<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"
|
||||
>
|
||||
<User className="h-3.5 w-3.5" />
|
||||
@@ -250,7 +259,7 @@ export function AdminHelpTicketDetail({
|
||||
{t("rankLabel", { rank: ticket.creator.rank })}
|
||||
</Badge>
|
||||
</Link>
|
||||
{ticket.creator.mail ? (
|
||||
{!hideRequesterContact && ticket.creator.mail ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Mail className="h-3 w-3" />
|
||||
{ticket.creator.mail}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
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";
|
||||
@@ -6,8 +7,6 @@ import { AdminHelpTicketDetail } from "./admin-help-ticket-detail";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const STAFF_RANK_THRESHOLD = 4;
|
||||
|
||||
export default async function AdminHelpTicketDetailPage({
|
||||
params,
|
||||
}: {
|
||||
@@ -70,6 +69,7 @@ export default async function AdminHelpTicketDetailPage({
|
||||
|
||||
const userById = new Map(users.map((u) => [u.id, u]));
|
||||
const openerId = ticket.userId;
|
||||
const minStaffRank = await getMinStaffRank();
|
||||
|
||||
const messages = [
|
||||
{
|
||||
@@ -86,7 +86,7 @@ export default async function AdminHelpTicketDetailPage({
|
||||
...replies.map((r) => {
|
||||
const user = userById.get(r.userId);
|
||||
const isStaff =
|
||||
r.userId !== openerId && (user?.rank ?? 0) >= STAFF_RANK_THRESHOLD;
|
||||
r.userId !== openerId && (user?.rank ?? 0) >= minStaffRank;
|
||||
return {
|
||||
key: String(r.id),
|
||||
userId: r.userId,
|
||||
|
||||
@@ -19,8 +19,10 @@ export interface HelpTicketRow {
|
||||
|
||||
export function HelpTicketsTable({
|
||||
data,
|
||||
basePath = "/admin/help-tickets",
|
||||
}: {
|
||||
data: PaginatedResult<HelpTicketRow>;
|
||||
basePath?: string;
|
||||
}) {
|
||||
const t = useTranslations("pages.admin.helpTickets");
|
||||
|
||||
@@ -36,7 +38,7 @@ export function HelpTicketsTable({
|
||||
#{row.id}
|
||||
</span>
|
||||
<Link
|
||||
href={`/admin/help-tickets/${row.id}`}
|
||||
href={`${basePath}/${row.id}`}
|
||||
className="font-medium truncate hover:underline"
|
||||
>
|
||||
{row.title}
|
||||
|
||||
@@ -160,6 +160,20 @@ export default async function AdminHelpTicketsPage({
|
||||
|
||||
return (
|
||||
<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">
|
||||
<StatusCard label={t("statusOpen")} value={openCount} icon="🎫" />
|
||||
<StatusCard label={t("statusClosed")} value={closedCount} icon="✅" />
|
||||
|
||||
@@ -116,6 +116,20 @@ export default async function AdminTicketsPage({
|
||||
|
||||
return (
|
||||
<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">
|
||||
<StatusCard label={t("statusOpen")} value={openCount} icon="🎫" />
|
||||
<StatusCard
|
||||
|
||||
@@ -33,22 +33,7 @@ export async function GET() {
|
||||
|
||||
const emulator = await rcon.send("ping", null).catch(() => false);
|
||||
|
||||
let smtp = null;
|
||||
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 resendAvailable = !!env.RESEND_API_KEY;
|
||||
|
||||
const degraded = !database || redisOk === false;
|
||||
return apiJson({
|
||||
@@ -56,7 +41,7 @@ export async function GET() {
|
||||
database,
|
||||
redis: redisOk,
|
||||
emulator,
|
||||
smtp,
|
||||
resend: resendAvailable,
|
||||
node: process.version,
|
||||
uptime: Math.round(process.uptime()),
|
||||
time: new Date().toISOString(),
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ import { headers } from "next/headers";
|
||||
import Script from "next/script";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getLocale, getMessages } from "next-intl/server";
|
||||
import { ViewTransitions } from "next-view-transitions";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Toaster } from "sonner";
|
||||
import { PwaRegister } from "@/components/pwa-register";
|
||||
@@ -90,7 +90,7 @@ export default async function RootLayout({
|
||||
>
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
<ThemeVars nonce={nonce} />
|
||||
<ViewTransitions>{children}</ViewTransitions>
|
||||
{children}
|
||||
<PwaRegister />
|
||||
<SmoothScroll />
|
||||
<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 { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
@@ -59,6 +68,12 @@ export default async function ModLayout({ children }: { children: ReactNode }) {
|
||||
icon: Ticket,
|
||||
show: showTickets,
|
||||
},
|
||||
{
|
||||
href: "/mod/help-tickets",
|
||||
label: t("nav.helpTickets"),
|
||||
icon: LifeBuoy,
|
||||
show: showTickets,
|
||||
},
|
||||
{
|
||||
href: "/mod/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 { getTranslations } from "next-intl/server";
|
||||
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.MODERATION_VIEW, rank);
|
||||
|
||||
const [openCfh, todayBans, modsOnline, openTickets] = await Promise.all([
|
||||
showCfh
|
||||
? prisma.supportTickets.count({ where: { state: 0 } }).catch(() => 0)
|
||||
: Promise.resolve(0),
|
||||
showBans
|
||||
? prisma.ban
|
||||
.count({ where: { timestamp: { gte: todayStart } } })
|
||||
.catch(() => 0)
|
||||
: Promise.resolve(0),
|
||||
prisma.user
|
||||
.count({ where: { online: "1", rank: { gte: minStaffRank } } })
|
||||
.catch(() => 0),
|
||||
showTickets
|
||||
? prisma.websiteTicket
|
||||
.count({ where: { status: { not: "closed" } } })
|
||||
.catch(() => 0)
|
||||
: Promise.resolve(0),
|
||||
]);
|
||||
const [openCfh, todayBans, modsOnline, openTickets, openHelpTickets] =
|
||||
await Promise.all([
|
||||
showCfh
|
||||
? prisma.supportTickets.count({ where: { state: 0 } }).catch(() => 0)
|
||||
: Promise.resolve(0),
|
||||
showBans
|
||||
? prisma.ban
|
||||
.count({ where: { timestamp: { gte: todayStart } } })
|
||||
.catch(() => 0)
|
||||
: Promise.resolve(0),
|
||||
prisma.user
|
||||
.count({ where: { online: "1", rank: { gte: minStaffRank } } })
|
||||
.catch(() => 0),
|
||||
showTickets
|
||||
? prisma.websiteTicket
|
||||
.count({ where: { status: { not: "closed" } } })
|
||||
.catch(() => 0)
|
||||
: Promise.resolve(0),
|
||||
showTickets
|
||||
? prisma.websiteHelpCenterTickets
|
||||
.count({ where: { open: true } })
|
||||
.catch(() => 0)
|
||||
: Promise.resolve(0),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -75,6 +89,14 @@ export default async function ModDashboardPage() {
|
||||
href="/mod/tickets"
|
||||
/>
|
||||
) : null}
|
||||
{showTickets ? (
|
||||
<StatsCard
|
||||
title={t("stats.openHelpTickets")}
|
||||
value={openHelpTickets}
|
||||
icon={<LifeBuoy className="h-4 w-4" />}
|
||||
href="/mod/help-tickets"
|
||||
/>
|
||||
) : null}
|
||||
{showBans ? (
|
||||
<StatsCard
|
||||
title={t("stats.bansToday")}
|
||||
@@ -120,14 +142,24 @@ export default async function ModDashboardPage() {
|
||||
</li>
|
||||
) : null}
|
||||
{showTickets ? (
|
||||
<li>
|
||||
<Link
|
||||
href="/mod/tickets"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{t("nav.tickets")}
|
||||
</Link>
|
||||
</li>
|
||||
<>
|
||||
<li>
|
||||
<Link
|
||||
href="/mod/tickets"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{t("nav.tickets")}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/mod/help-tickets"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{t("nav.helpTickets")}
|
||||
</Link>
|
||||
</li>
|
||||
</>
|
||||
) : null}
|
||||
{showActions ? (
|
||||
<li>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useInView } from "framer-motion";
|
||||
import { motion, useInView } from "motion/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export function AnimatedCounter({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const cmsDetails = [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import Image from "next/image";
|
||||
import { type ReactNode, useRef, useState } from "react";
|
||||
import { FALLBACK_HOTEL_NAME } from "@/lib/brand";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { pageTransition } from "@/lib/motion";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface RevealProps {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import Image from "next/image";
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { dropdownVariants } from "@/lib/motion";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useEventSource } from "@/lib/use-event-source";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function TypewriterText({
|
||||
|
||||
+1
-7
@@ -50,17 +50,11 @@ const schema = z
|
||||
// Laravel APP_KEY (base64:...) — needed to read existing 2FA secrets.
|
||||
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
|
||||
.string()
|
||||
.optional()
|
||||
.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_ROUNDS: z.coerce.number().int().positive().default(12),
|
||||
// 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"],
|
||||
["transactions", "PERMS.SHOP_VIEW"],
|
||||
["vouchers", "PERMS.SHOP_VIEW"],
|
||||
["marketplace", "PERMS.SHOP_VIEW"],
|
||||
["vpn", "PERMS.SETTINGS_VIEW"],
|
||||
["ip", "PERMS.SETTINGS_VIEW"],
|
||||
["maintenance", "PERMS.SETTINGS_VIEW"],
|
||||
@@ -32,11 +33,26 @@ const ROUTES: Array<[string, string]> = [
|
||||
["badges", "PERMS.CATALOG_VIEW"],
|
||||
["teams", "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"],
|
||||
["catalog", "PERMS.CATALOG_VIEW"],
|
||||
["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]> = [
|
||||
["admin-settings.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"],
|
||||
["commandocentrum.ts", "PERMS.RCON_EXECUTE"],
|
||||
["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", () => {
|
||||
@@ -63,6 +84,19 @@ describe("admin operations route contract", () => {
|
||||
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", () => {
|
||||
const path = "src/app/admin/radio/layout.tsx";
|
||||
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;
|
||||
}
|
||||
|
||||
// Byte-compatible AtomCMS check (argon2id/bcrypt + md5->argon2id upgrade).
|
||||
// Byte-compatible AtomCMS check (bcrypt + md5->bcrypt upgrade).
|
||||
const res = await checkLogin(password, user.password, {
|
||||
convertPasswords: env.CONVERT_PASSWORDS,
|
||||
});
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { hash as bcryptHash } from "bcrypt";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockEnv = vi.hoisted(() => ({
|
||||
PASSWORD_HASH: undefined as string | undefined,
|
||||
ARGON2_PARALLELISM: 1,
|
||||
ARGON2_ITERATIONS: 4,
|
||||
ARGON2_MEMORY_SIZE: 65536,
|
||||
BCRYPT_ROUNDS: 12,
|
||||
}));
|
||||
|
||||
@@ -28,36 +23,20 @@ describe("md5Hex", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("hashPassword (default driver: bcrypt)", () => {
|
||||
it("emits a PHP-style $2y$ bcrypt hash that fits varchar(64) and round-trips", async () => {
|
||||
mockEnv.PASSWORD_HASH = undefined;
|
||||
describe("hashPassword", () => {
|
||||
it("emits a bcrypt hash and round-trips", async () => {
|
||||
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.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);
|
||||
const phpStyle = h.replace(/^\$2[ab]\$/, "$2y$");
|
||||
expect(await verifyPassword("hunter2", phpStyle)).toBe(true);
|
||||
expect(await verifyPassword("nope", h)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -71,13 +50,11 @@ describe("isMd5Of", () => {
|
||||
});
|
||||
|
||||
describe("checkLogin", () => {
|
||||
it("upgrades a legacy md5 hash to the configured hash when conversion is enabled", async () => {
|
||||
mockEnv.PASSWORD_HASH = undefined;
|
||||
it("upgrades a legacy md5 hash to bcrypt when conversion is enabled", async () => {
|
||||
const stored = await md5Hex("oldpass");
|
||||
const res = await checkLogin("oldpass", stored, { convertPasswords: true });
|
||||
expect(res.valid).toBe(true);
|
||||
expect(res.upgradedHash).toMatch(/^\$2y\$/);
|
||||
expect((res.upgradedHash as string).length).toBeLessThanOrEqual(60);
|
||||
expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe(
|
||||
true,
|
||||
);
|
||||
@@ -93,10 +70,9 @@ describe("checkLogin", () => {
|
||||
});
|
||||
|
||||
it("validates an existing modern hash with no upgrade", async () => {
|
||||
mockEnv.PASSWORD_HASH = undefined;
|
||||
const stored = await hashPassword("modern");
|
||||
const res = await checkLogin("modern", stored, { convertPasswords: true });
|
||||
expect(res.valid).toBe(true);
|
||||
expect(res.upgradedHash).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
+15
-75
@@ -1,66 +1,26 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { compare as bcryptCompare, hash as bcryptHash } from "bcrypt";
|
||||
import { argon2id, argon2Verify, md5 } from "hash-wasm";
|
||||
import {
|
||||
bcrypt,
|
||||
bcryptVerify,
|
||||
md5,
|
||||
} from "hash-wasm";
|
||||
|
||||
import { env } from "@/env";
|
||||
|
||||
// AtomCMS hashing (config/hashing.php): argon2id with memory=65536 KiB, time=4,
|
||||
// threads=1; bcrypt rounds=12 as the legacy fallback. The game emulator
|
||||
// validates the SAME users.password hash, so these must match.
|
||||
function argon2Params() {
|
||||
return {
|
||||
parallelism: env.ARGON2_PARALLELISM,
|
||||
iterations: env.ARGON2_ITERATIONS,
|
||||
memorySize: env.ARGON2_MEMORY_SIZE,
|
||||
hashLength: 32,
|
||||
} as const;
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const h = await bcrypt({
|
||||
password,
|
||||
salt: randomBytes(16),
|
||||
costFactor: env.BCRYPT_ROUNDS,
|
||||
outputType: "encoded",
|
||||
});
|
||||
return h.replace(/^\$2[ab]\$/, "$2y$");
|
||||
}
|
||||
|
||||
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> {
|
||||
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(
|
||||
password: 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(
|
||||
password: string,
|
||||
stored: string,
|
||||
): Promise<boolean> {
|
||||
if (stored.startsWith("$argon2")) {
|
||||
try {
|
||||
return await argon2Verify({ password, hash: stored });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (/^\$2[aby]\$/.test(stored)) {
|
||||
try {
|
||||
// PHP/AtomCMS store $2y$; native bcrypt only accepts $2a$/$2b$.
|
||||
const normalized = stored.replace(/^\$2y\$/, "$2a$");
|
||||
return await bcryptCompare(password, normalized);
|
||||
return await bcryptVerify({ password, hash: stored });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -101,15 +47,9 @@ export async function verifyPassword(
|
||||
|
||||
export interface LoginCheck {
|
||||
valid: boolean;
|
||||
/** Set when a legacy md5 hash was upgraded — persist it to users.password. */
|
||||
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(
|
||||
password: string,
|
||||
stored: string,
|
||||
@@ -119,4 +59,4 @@ export async function checkLogin(
|
||||
return { valid: true, upgradedHash: await hashPassword(password) };
|
||||
}
|
||||
return { valid: await verifyPassword(password, stored) };
|
||||
}
|
||||
}
|
||||
@@ -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 migrateAt = deployJob.indexOf("pnpm db:migrate");
|
||||
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(stopAt).toBeGreaterThan(buildAt);
|
||||
expect(migrateAt).toBeGreaterThan(stopAt);
|
||||
@@ -103,7 +106,9 @@ describe("production deploy workflow", () => {
|
||||
it("runs an HTTP health check before declaring deploy success", () => {
|
||||
expect(workflow).toContain("/api/health");
|
||||
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 successAt = deployJob.indexOf("--- Deployed successfully ---");
|
||||
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 = {
|
||||
initial: { opacity: 0, y: 8 },
|
||||
|
||||
+3
-71
@@ -1,76 +1,8 @@
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
import DOMPurify from "isomorphic-dompurify";
|
||||
|
||||
/**
|
||||
* 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",
|
||||
}),
|
||||
},
|
||||
};
|
||||
const sanitizeHtml = (html: string) => DOMPurify.sanitize(html);
|
||||
|
||||
export function sanitize(html: string | null | undefined): string {
|
||||
if (!html) return "";
|
||||
return sanitizeHtml(html, OPTIONS);
|
||||
return sanitizeHtml(html);
|
||||
}
|
||||
@@ -1,29 +1,12 @@
|
||||
import { exec } from "node:child_process";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import nodemailer, { type Transporter } from "nodemailer";
|
||||
import { Resend } from "resend";
|
||||
import { env } from "@/env";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
let transporter: Transporter | 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 {
|
||||
if (!env.RESEND_API_KEY) return null;
|
||||
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(
|
||||
to: 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);
|
||||
if (ok) return true;
|
||||
|
||||
|
||||
+82
-5
@@ -23,7 +23,8 @@
|
||||
"closeMenu": "Затваряне на менюто",
|
||||
"drawBadge": "Начертайте значка",
|
||||
"events": "Events",
|
||||
"polls": "Polls"
|
||||
"polls": "Polls",
|
||||
"mod": "Mod"
|
||||
},
|
||||
"header": {
|
||||
"online": "{count} {hotel} онлайн",
|
||||
@@ -906,7 +907,8 @@
|
||||
"chatLog": "Chat log",
|
||||
"commandLog": "Command log",
|
||||
"tradeLog": "Trade log",
|
||||
"tools": "Tools"
|
||||
"tools": "Tools",
|
||||
"guilds": "Guilds"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Табло за управление",
|
||||
@@ -2213,7 +2215,24 @@
|
||||
"auditTarget": "Цел",
|
||||
"selectAllMatching": "Select all {count} matching",
|
||||
"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": {
|
||||
"title": "Стаи",
|
||||
@@ -2442,7 +2461,9 @@
|
||||
"colPriority": "Priority",
|
||||
"colAssigned": "Assigned",
|
||||
"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": {
|
||||
"title": "Call for Help",
|
||||
@@ -2704,7 +2725,9 @@
|
||||
"replied": "Reply sent.",
|
||||
"closed": "Ticket closed.",
|
||||
"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": {
|
||||
"title": "Marketplace",
|
||||
@@ -2799,6 +2822,31 @@
|
||||
"less": "Less",
|
||||
"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": {
|
||||
@@ -2944,6 +2992,35 @@
|
||||
"voteCount": "{count} ({pct}%)",
|
||||
"totalVotes": "{count} total votes",
|
||||
"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": {
|
||||
|
||||
+82
-5
@@ -23,7 +23,8 @@
|
||||
"closeMenu": "Zavřít nabídku",
|
||||
"drawBadge": "Nakreslete odznak",
|
||||
"events": "Events",
|
||||
"polls": "Polls"
|
||||
"polls": "Polls",
|
||||
"mod": "Mod"
|
||||
},
|
||||
"header": {
|
||||
"online": "{count} {hotel} online",
|
||||
@@ -906,7 +907,8 @@
|
||||
"chatLog": "Chat log",
|
||||
"commandLog": "Command log",
|
||||
"tradeLog": "Trade log",
|
||||
"tools": "Tools"
|
||||
"tools": "Tools",
|
||||
"guilds": "Guilds"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -2213,7 +2215,24 @@
|
||||
"auditTarget": "Cíl",
|
||||
"selectAllMatching": "Select all {count} matching",
|
||||
"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": {
|
||||
"title": "Pokoje",
|
||||
@@ -2442,7 +2461,9 @@
|
||||
"colPriority": "Priority",
|
||||
"colAssigned": "Assigned",
|
||||
"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": {
|
||||
"title": "Call for Help",
|
||||
@@ -2704,7 +2725,9 @@
|
||||
"replied": "Reply sent.",
|
||||
"closed": "Ticket closed.",
|
||||
"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": {
|
||||
"title": "Marketplace",
|
||||
@@ -2799,6 +2822,31 @@
|
||||
"less": "Less",
|
||||
"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": {
|
||||
@@ -2944,6 +2992,35 @@
|
||||
"voteCount": "{count} ({pct}%)",
|
||||
"totalVotes": "{count} total votes",
|
||||
"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": {
|
||||
|
||||
+82
-5
@@ -23,7 +23,8 @@
|
||||
"closeMenu": "Luk menuen",
|
||||
"drawBadge": "Tegn badge",
|
||||
"events": "Events",
|
||||
"polls": "Polls"
|
||||
"polls": "Polls",
|
||||
"mod": "Mod"
|
||||
},
|
||||
"header": {
|
||||
"online": "{count} {hotel} online",
|
||||
@@ -906,7 +907,8 @@
|
||||
"chatLog": "Chat log",
|
||||
"commandLog": "Command log",
|
||||
"tradeLog": "Trade log",
|
||||
"tools": "Tools"
|
||||
"tools": "Tools",
|
||||
"guilds": "Guilds"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -2213,7 +2215,24 @@
|
||||
"auditTarget": "Mål",
|
||||
"selectAllMatching": "Select all {count} matching",
|
||||
"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": {
|
||||
"title": "Værelser",
|
||||
@@ -2442,7 +2461,9 @@
|
||||
"colPriority": "Priority",
|
||||
"colAssigned": "Assigned",
|
||||
"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": {
|
||||
"title": "Call for Help",
|
||||
@@ -2704,7 +2725,9 @@
|
||||
"replied": "Reply sent.",
|
||||
"closed": "Ticket closed.",
|
||||
"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": {
|
||||
"title": "Marketplace",
|
||||
@@ -2799,6 +2822,31 @@
|
||||
"less": "Less",
|
||||
"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": {
|
||||
@@ -2944,6 +2992,35 @@
|
||||
"voteCount": "{count} ({pct}%)",
|
||||
"totalVotes": "{count} total votes",
|
||||
"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": {
|
||||
|
||||
+82
-5
@@ -23,7 +23,8 @@
|
||||
"closeMenu": "Close menu",
|
||||
"drawBadge": "Abzeichen zeichnen",
|
||||
"events": "Events",
|
||||
"polls": "Polls"
|
||||
"polls": "Polls",
|
||||
"mod": "Mod"
|
||||
},
|
||||
"header": {
|
||||
"online": "{count} {hotel} online",
|
||||
@@ -876,7 +877,8 @@
|
||||
"chatLog": "Chat log",
|
||||
"commandLog": "Command log",
|
||||
"tradeLog": "Trade log",
|
||||
"tools": "Tools"
|
||||
"tools": "Tools",
|
||||
"guilds": "Guilds"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -2183,7 +2185,24 @@
|
||||
"auditTarget": "Ziel",
|
||||
"selectAllMatching": "Select all {count} matching",
|
||||
"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": {
|
||||
"title": "Räume",
|
||||
@@ -2412,7 +2431,9 @@
|
||||
"colPriority": "Priority",
|
||||
"colAssigned": "Assigned",
|
||||
"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": {
|
||||
"title": "Call for Help",
|
||||
@@ -2674,7 +2695,9 @@
|
||||
"replied": "Reply sent.",
|
||||
"closed": "Ticket closed.",
|
||||
"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": {
|
||||
"title": "Marketplace",
|
||||
@@ -2769,6 +2792,31 @@
|
||||
"less": "Less",
|
||||
"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": {
|
||||
@@ -2944,6 +2992,35 @@
|
||||
"voteCount": "{count} ({pct}%)",
|
||||
"totalVotes": "{count} total votes",
|
||||
"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": {
|
||||
|
||||
+82
-5
@@ -23,7 +23,8 @@
|
||||
"closeMenu": "Κλείσιμο μενού",
|
||||
"drawBadge": "Σχεδιάστε το σήμα",
|
||||
"events": "Events",
|
||||
"polls": "Polls"
|
||||
"polls": "Polls",
|
||||
"mod": "Mod"
|
||||
},
|
||||
"header": {
|
||||
"online": "{count} {hotel} στο διαδίκτυο",
|
||||
@@ -906,7 +907,8 @@
|
||||
"chatLog": "Chat log",
|
||||
"commandLog": "Command log",
|
||||
"tradeLog": "Trade log",
|
||||
"tools": "Tools"
|
||||
"tools": "Tools",
|
||||
"guilds": "Guilds"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Ταμπλό",
|
||||
@@ -2213,7 +2215,24 @@
|
||||
"auditTarget": "Στόχος",
|
||||
"selectAllMatching": "Select all {count} matching",
|
||||
"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": {
|
||||
"title": "Δωμάτια",
|
||||
@@ -2442,7 +2461,9 @@
|
||||
"colPriority": "Priority",
|
||||
"colAssigned": "Assigned",
|
||||
"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": {
|
||||
"title": "Call for Help",
|
||||
@@ -2704,7 +2725,9 @@
|
||||
"replied": "Reply sent.",
|
||||
"closed": "Ticket closed.",
|
||||
"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": {
|
||||
"title": "Marketplace",
|
||||
@@ -2799,6 +2822,31 @@
|
||||
"less": "Less",
|
||||
"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": {
|
||||
@@ -2944,6 +2992,35 @@
|
||||
"voteCount": "{count} ({pct}%)",
|
||||
"totalVotes": "{count} total votes",
|
||||
"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": {
|
||||
|
||||
+12
-6
@@ -1492,8 +1492,10 @@
|
||||
"settingsSaved": "VPN settings saved successfully"
|
||||
},
|
||||
"tickets": {
|
||||
"title": "Tickets",
|
||||
"subtitle": "Support tickets from players",
|
||||
"title": "CMS support desk",
|
||||
"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",
|
||||
"searchPlaceholder": "Search subject, user, category or ID…",
|
||||
"presetActive": "Active",
|
||||
@@ -1510,7 +1512,9 @@
|
||||
},
|
||||
"helpTickets": {
|
||||
"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",
|
||||
"searchPlaceholder": "Search title, user or ID…",
|
||||
"presetOpen": "Open",
|
||||
@@ -2520,7 +2524,7 @@
|
||||
"events": "Events",
|
||||
"eventsSubtitle": "Hotel events and event types",
|
||||
"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",
|
||||
"staffAccessSubtitle": "Staff ranks and live access. Archive is compare/export only.",
|
||||
"analytics": "Analytics",
|
||||
@@ -3004,7 +3008,8 @@
|
||||
"bans": "Bans",
|
||||
"fullAdmin": "Full admin",
|
||||
"backToSite": "Back to site",
|
||||
"tickets": "Tickets",
|
||||
"tickets": "CMS tickets",
|
||||
"helpTickets": "Help-center tickets",
|
||||
"team": "Team"
|
||||
},
|
||||
"stats": {
|
||||
@@ -3013,7 +3018,8 @@
|
||||
"modsOnline": "Mods online",
|
||||
"quickActions": "Quick actions",
|
||||
"quickActionsHint": "Kick, mute, alert",
|
||||
"openTickets": "Open tickets",
|
||||
"openTickets": "Open CMS tickets",
|
||||
"openHelpTickets": "Open help tickets",
|
||||
"team": "Mod team",
|
||||
"teamHint": "Online staff & workload"
|
||||
}
|
||||
|
||||
+82
-5
@@ -23,7 +23,8 @@
|
||||
"closeMenu": "Close menu",
|
||||
"drawBadge": "Diseñar insignia",
|
||||
"events": "Events",
|
||||
"polls": "Polls"
|
||||
"polls": "Polls",
|
||||
"mod": "Mod"
|
||||
},
|
||||
"header": {
|
||||
"online": "{count} {hotel} en línea",
|
||||
@@ -876,7 +877,8 @@
|
||||
"chatLog": "Chat log",
|
||||
"commandLog": "Command log",
|
||||
"tradeLog": "Trade log",
|
||||
"tools": "Tools"
|
||||
"tools": "Tools",
|
||||
"guilds": "Guilds"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Panel",
|
||||
@@ -2183,7 +2185,24 @@
|
||||
"auditTarget": "Objetivo",
|
||||
"selectAllMatching": "Select all {count} matching",
|
||||
"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": {
|
||||
"title": "Salas",
|
||||
@@ -2412,7 +2431,9 @@
|
||||
"colPriority": "Priority",
|
||||
"colAssigned": "Assigned",
|
||||
"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": {
|
||||
"title": "Call for Help",
|
||||
@@ -2674,7 +2695,9 @@
|
||||
"replied": "Reply sent.",
|
||||
"closed": "Ticket closed.",
|
||||
"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": {
|
||||
"title": "Marketplace",
|
||||
@@ -2769,6 +2792,31 @@
|
||||
"less": "Less",
|
||||
"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": {
|
||||
@@ -2944,6 +2992,35 @@
|
||||
"voteCount": "{count} ({pct}%)",
|
||||
"totalVotes": "{count} total votes",
|
||||
"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": {
|
||||
|
||||
+82
-5
@@ -23,7 +23,8 @@
|
||||
"closeMenu": "Close menu",
|
||||
"drawBadge": "Dessiner un badge",
|
||||
"events": "Events",
|
||||
"polls": "Polls"
|
||||
"polls": "Polls",
|
||||
"mod": "Mod"
|
||||
},
|
||||
"header": {
|
||||
"online": "{count} {hotel} en ligne",
|
||||
@@ -876,7 +877,8 @@
|
||||
"chatLog": "Chat log",
|
||||
"commandLog": "Command log",
|
||||
"tradeLog": "Trade log",
|
||||
"tools": "Tools"
|
||||
"tools": "Tools",
|
||||
"guilds": "Guilds"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord",
|
||||
@@ -2183,7 +2185,24 @@
|
||||
"auditTarget": "Cible",
|
||||
"selectAllMatching": "Select all {count} matching",
|
||||
"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": {
|
||||
"title": "Salons",
|
||||
@@ -2412,7 +2431,9 @@
|
||||
"colPriority": "Priority",
|
||||
"colAssigned": "Assigned",
|
||||
"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": {
|
||||
"title": "Call for Help",
|
||||
@@ -2674,7 +2695,9 @@
|
||||
"replied": "Reply sent.",
|
||||
"closed": "Ticket closed.",
|
||||
"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": {
|
||||
"title": "Marketplace",
|
||||
@@ -2769,6 +2792,31 @@
|
||||
"less": "Less",
|
||||
"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": {
|
||||
@@ -2944,6 +2992,35 @@
|
||||
"voteCount": "{count} ({pct}%)",
|
||||
"totalVotes": "{count} total votes",
|
||||
"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": {
|
||||
|
||||
+82
-5
@@ -23,7 +23,8 @@
|
||||
"closeMenu": "Zatvori izbornik",
|
||||
"drawBadge": "Nacrtajte značku",
|
||||
"events": "Events",
|
||||
"polls": "Polls"
|
||||
"polls": "Polls",
|
||||
"mod": "Mod"
|
||||
},
|
||||
"header": {
|
||||
"online": "{count} {hotel} online",
|
||||
@@ -906,7 +907,8 @@
|
||||
"chatLog": "Chat log",
|
||||
"commandLog": "Command log",
|
||||
"tradeLog": "Trade log",
|
||||
"tools": "Tools"
|
||||
"tools": "Tools",
|
||||
"guilds": "Guilds"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Nadzorna ploča",
|
||||
@@ -2213,7 +2215,24 @@
|
||||
"auditTarget": "cilj",
|
||||
"selectAllMatching": "Select all {count} matching",
|
||||
"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": {
|
||||
"title": "Sobe",
|
||||
@@ -2442,7 +2461,9 @@
|
||||
"colPriority": "Priority",
|
||||
"colAssigned": "Assigned",
|
||||
"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": {
|
||||
"title": "Call for Help",
|
||||
@@ -2704,7 +2725,9 @@
|
||||
"replied": "Reply sent.",
|
||||
"closed": "Ticket closed.",
|
||||
"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": {
|
||||
"title": "Marketplace",
|
||||
@@ -2799,6 +2822,31 @@
|
||||
"less": "Less",
|
||||
"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": {
|
||||
@@ -2944,6 +2992,35 @@
|
||||
"voteCount": "{count} ({pct}%)",
|
||||
"totalVotes": "{count} total votes",
|
||||
"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": {
|
||||
|
||||
+82
-5
@@ -23,7 +23,8 @@
|
||||
"closeMenu": "Menü bezárása",
|
||||
"drawBadge": "Rajzolj jelvényt",
|
||||
"events": "Events",
|
||||
"polls": "Polls"
|
||||
"polls": "Polls",
|
||||
"mod": "Mod"
|
||||
},
|
||||
"header": {
|
||||
"online": "{count} {hotel} online",
|
||||
@@ -906,7 +907,8 @@
|
||||
"chatLog": "Chat log",
|
||||
"commandLog": "Command log",
|
||||
"tradeLog": "Trade log",
|
||||
"tools": "Tools"
|
||||
"tools": "Tools",
|
||||
"guilds": "Guilds"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Irányítópult",
|
||||
@@ -2213,7 +2215,24 @@
|
||||
"auditTarget": "Cél",
|
||||
"selectAllMatching": "Select all {count} matching",
|
||||
"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": {
|
||||
"title": "Szobák",
|
||||
@@ -2442,7 +2461,9 @@
|
||||
"colPriority": "Priority",
|
||||
"colAssigned": "Assigned",
|
||||
"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": {
|
||||
"title": "Call for Help",
|
||||
@@ -2704,7 +2725,9 @@
|
||||
"replied": "Reply sent.",
|
||||
"closed": "Ticket closed.",
|
||||
"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": {
|
||||
"title": "Marketplace",
|
||||
@@ -2799,6 +2822,31 @@
|
||||
"less": "Less",
|
||||
"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": {
|
||||
@@ -2944,6 +2992,35 @@
|
||||
"voteCount": "{count} ({pct}%)",
|
||||
"totalVotes": "{count} total votes",
|
||||
"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": {
|
||||
|
||||
+15
-9
@@ -1467,8 +1467,8 @@
|
||||
"settingsSaved": "Impostazioni VPN salvate con successo"
|
||||
},
|
||||
"tickets": {
|
||||
"title": "Ticket",
|
||||
"subtitle": "Ticket di supporto dai giocatori",
|
||||
"title": "Supporto CMS",
|
||||
"subtitle": "Ticket interni CMS (assegnatario, priorita, workflow staff). Non sono i ticket del help center /help.",
|
||||
"noTickets": "Nessun ticket attivo",
|
||||
"searchPlaceholder": "Cerca oggetto, utente, categoria o ID…",
|
||||
"presetActive": "Attivi",
|
||||
@@ -1481,11 +1481,13 @@
|
||||
"colPriority": "Priorità",
|
||||
"colAssigned": "Assegnato",
|
||||
"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": {
|
||||
"title": "Ticket centro assistenza",
|
||||
"subtitle": "Ticket dei giocatori da /help/tickets",
|
||||
"title": "Ticket help center",
|
||||
"subtitle": "Ticket aperti dai player su /help/tickets. Separati dal supporto CMS.",
|
||||
"noTickets": "Nessun ticket aperto nel centro assistenza",
|
||||
"searchPlaceholder": "Cerca titolo, utente o ID…",
|
||||
"presetOpen": "Aperti",
|
||||
@@ -1516,7 +1518,9 @@
|
||||
"replied": "Risposta inviata.",
|
||||
"closed": "Ticket chiuso.",
|
||||
"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": {
|
||||
"title": "Call for Help",
|
||||
@@ -2791,7 +2795,7 @@
|
||||
"events": "Eventi",
|
||||
"eventsSubtitle": "Eventi hotel e tipi evento",
|
||||
"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",
|
||||
"staffAccessSubtitle": "Gradi staff e accesso live. L'archivio e solo confronto/export.",
|
||||
"analytics": "Analitiche",
|
||||
@@ -3011,7 +3015,8 @@
|
||||
"bans": "Ban",
|
||||
"fullAdmin": "Admin completo",
|
||||
"backToSite": "Torna al sito",
|
||||
"tickets": "Ticket",
|
||||
"tickets": "Ticket CMS",
|
||||
"helpTickets": "Ticket help center",
|
||||
"team": "Team"
|
||||
},
|
||||
"stats": {
|
||||
@@ -3020,7 +3025,8 @@
|
||||
"modsOnline": "Mod online",
|
||||
"quickActions": "Azioni rapide",
|
||||
"quickActionsHint": "Kick, mute, alert",
|
||||
"openTickets": "Ticket aperti",
|
||||
"openTickets": "Ticket CMS aperti",
|
||||
"openHelpTickets": "Help ticket aperti",
|
||||
"team": "Team mod",
|
||||
"teamHint": "Staff online e carico"
|
||||
}
|
||||
|
||||
+15
-9
@@ -2842,7 +2842,7 @@
|
||||
"events": "Evenementen",
|
||||
"eventsSubtitle": "Hotel-evenementen en evenementtypes",
|
||||
"tickets": "Supportbalie",
|
||||
"ticketsSubtitle": "CMS-tickets, helpcentrum-tickets en antwoordtemplates",
|
||||
"ticketsSubtitle": "Twee aparte wachtrijen: CMS support desk en publieke helpcenter-tickets",
|
||||
"staffAccess": "Rangen & rechten",
|
||||
"staffAccessSubtitle": "Staffrangen en live toegang. Archief is alleen vergelijken/exporteren.",
|
||||
"analytics": "Analytics",
|
||||
@@ -2851,8 +2851,8 @@
|
||||
"devopsSubtitle": "Servicegezondheid en emulatorfouten"
|
||||
},
|
||||
"tickets": {
|
||||
"title": "Tickets",
|
||||
"subtitle": "Ondersteuningstickets van spelers",
|
||||
"title": "CMS support desk",
|
||||
"subtitle": "Interne CMS-tickets (assignee, prioriteit, staff-workflow). Niet hetzelfde als helpcenter-tickets van /help.",
|
||||
"noTickets": "Geen actieve tickets",
|
||||
"searchPlaceholder": "Zoek op onderwerp, gebruiker, categorie of ID…",
|
||||
"presetActive": "Actief",
|
||||
@@ -2865,11 +2865,13 @@
|
||||
"colPriority": "Prioriteit",
|
||||
"colAssigned": "Toegewezen",
|
||||
"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": {
|
||||
"title": "Helpcentrum-tickets",
|
||||
"subtitle": "Spelertickets van /help/tickets",
|
||||
"title": "Helpcenter-tickets",
|
||||
"subtitle": "Tickets die spelers openen op /help/tickets. Apart van de CMS support desk.",
|
||||
"noTickets": "Geen open helpcentrum-tickets",
|
||||
"searchPlaceholder": "Zoek op titel, gebruiker of ID…",
|
||||
"presetOpen": "Open",
|
||||
@@ -2900,7 +2902,9 @@
|
||||
"replied": "Antwoord verzonden.",
|
||||
"closed": "Ticket gesloten.",
|
||||
"reopened": "Ticket heropend."
|
||||
}
|
||||
},
|
||||
"queueHint": "Deze wachtrij is het publieke helpcenter. CMS support desk tickets staan onder Support desk.",
|
||||
"queueOtherCta": "Open CMS support desk"
|
||||
},
|
||||
"cfh": {
|
||||
"title": "Call for Help",
|
||||
@@ -3009,7 +3013,8 @@
|
||||
"bans": "Bans",
|
||||
"fullAdmin": "Volledige admin",
|
||||
"backToSite": "Terug naar site",
|
||||
"tickets": "Tickets",
|
||||
"tickets": "CMS-tickets",
|
||||
"helpTickets": "Helpcenter-tickets",
|
||||
"team": "Team"
|
||||
},
|
||||
"stats": {
|
||||
@@ -3018,7 +3023,8 @@
|
||||
"modsOnline": "Mods online",
|
||||
"quickActions": "Snelle acties",
|
||||
"quickActionsHint": "Kick, mute, alert",
|
||||
"openTickets": "Open tickets",
|
||||
"openTickets": "Open CMS-tickets",
|
||||
"openHelpTickets": "Open help-tickets",
|
||||
"team": "Mod team",
|
||||
"teamHint": "Online staff & werklast"
|
||||
}
|
||||
|
||||
+82
-5
@@ -23,7 +23,8 @@
|
||||
"closeMenu": "Lukk menyen",
|
||||
"drawBadge": "Tegn merke",
|
||||
"events": "Events",
|
||||
"polls": "Polls"
|
||||
"polls": "Polls",
|
||||
"mod": "Mod"
|
||||
},
|
||||
"header": {
|
||||
"online": "{count} {hotel} online",
|
||||
@@ -906,7 +907,8 @@
|
||||
"chatLog": "Chat log",
|
||||
"commandLog": "Command log",
|
||||
"tradeLog": "Trade log",
|
||||
"tools": "Tools"
|
||||
"tools": "Tools",
|
||||
"guilds": "Guilds"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashbord",
|
||||
@@ -2213,7 +2215,24 @@
|
||||
"auditTarget": "Mål",
|
||||
"selectAllMatching": "Select all {count} matching",
|
||||
"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": {
|
||||
"title": "Rom",
|
||||
@@ -2442,7 +2461,9 @@
|
||||
"colPriority": "Priority",
|
||||
"colAssigned": "Assigned",
|
||||
"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": {
|
||||
"title": "Call for Help",
|
||||
@@ -2704,7 +2725,9 @@
|
||||
"replied": "Reply sent.",
|
||||
"closed": "Ticket closed.",
|
||||
"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": {
|
||||
"title": "Marketplace",
|
||||
@@ -2799,6 +2822,31 @@
|
||||
"less": "Less",
|
||||
"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": {
|
||||
@@ -2944,6 +2992,35 @@
|
||||
"voteCount": "{count} ({pct}%)",
|
||||
"totalVotes": "{count} total votes",
|
||||
"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": {
|
||||
|
||||
+82
-5
@@ -23,7 +23,8 @@
|
||||
"closeMenu": "Zamknij menu",
|
||||
"drawBadge": "Narysuj odznakę",
|
||||
"events": "Events",
|
||||
"polls": "Polls"
|
||||
"polls": "Polls",
|
||||
"mod": "Mod"
|
||||
},
|
||||
"header": {
|
||||
"online": "{count} {hotel} online",
|
||||
@@ -906,7 +907,8 @@
|
||||
"chatLog": "Chat log",
|
||||
"commandLog": "Command log",
|
||||
"tradeLog": "Trade log",
|
||||
"tools": "Tools"
|
||||
"tools": "Tools",
|
||||
"guilds": "Guilds"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Pulpit nawigacyjny",
|
||||
@@ -2213,7 +2215,24 @@
|
||||
"auditTarget": "Cel",
|
||||
"selectAllMatching": "Select all {count} matching",
|
||||
"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": {
|
||||
"title": "Pokoje",
|
||||
@@ -2442,7 +2461,9 @@
|
||||
"colPriority": "Priority",
|
||||
"colAssigned": "Assigned",
|
||||
"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": {
|
||||
"title": "Call for Help",
|
||||
@@ -2704,7 +2725,9 @@
|
||||
"replied": "Reply sent.",
|
||||
"closed": "Ticket closed.",
|
||||
"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": {
|
||||
"title": "Marketplace",
|
||||
@@ -2799,6 +2822,31 @@
|
||||
"less": "Less",
|
||||
"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": {
|
||||
@@ -2944,6 +2992,35 @@
|
||||
"voteCount": "{count} ({pct}%)",
|
||||
"totalVotes": "{count} total votes",
|
||||
"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": {
|
||||
|
||||
+82
-5
@@ -23,7 +23,8 @@
|
||||
"closeMenu": "Fechar menu",
|
||||
"drawBadge": "Desenhar distintivo",
|
||||
"events": "Events",
|
||||
"polls": "Polls"
|
||||
"polls": "Polls",
|
||||
"mod": "Mod"
|
||||
},
|
||||
"header": {
|
||||
"online": "{count} {hotel} on-line",
|
||||
@@ -906,7 +907,8 @@
|
||||
"chatLog": "Chat log",
|
||||
"commandLog": "Command log",
|
||||
"tradeLog": "Trade log",
|
||||
"tools": "Tools"
|
||||
"tools": "Tools",
|
||||
"guilds": "Guilds"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Painel",
|
||||
@@ -2213,7 +2215,24 @@
|
||||
"auditTarget": "Alvo",
|
||||
"selectAllMatching": "Select all {count} matching",
|
||||
"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": {
|
||||
"title": "Quartos",
|
||||
@@ -2442,7 +2461,9 @@
|
||||
"colPriority": "Priority",
|
||||
"colAssigned": "Assigned",
|
||||
"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": {
|
||||
"title": "Call for Help",
|
||||
@@ -2704,7 +2725,9 @@
|
||||
"replied": "Reply sent.",
|
||||
"closed": "Ticket closed.",
|
||||
"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": {
|
||||
"title": "Marketplace",
|
||||
@@ -2799,6 +2822,31 @@
|
||||
"less": "Less",
|
||||
"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": {
|
||||
@@ -2944,6 +2992,35 @@
|
||||
"voteCount": "{count} ({pct}%)",
|
||||
"totalVotes": "{count} total votes",
|
||||
"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": {
|
||||
|
||||
+82
-5
@@ -23,7 +23,8 @@
|
||||
"closeMenu": "Închide meniul",
|
||||
"drawBadge": "Trage insigna",
|
||||
"events": "Events",
|
||||
"polls": "Polls"
|
||||
"polls": "Polls",
|
||||
"mod": "Mod"
|
||||
},
|
||||
"header": {
|
||||
"online": "{count} {hotel} online",
|
||||
@@ -906,7 +907,8 @@
|
||||
"chatLog": "Chat log",
|
||||
"commandLog": "Command log",
|
||||
"tradeLog": "Trade log",
|
||||
"tools": "Tools"
|
||||
"tools": "Tools",
|
||||
"guilds": "Guilds"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Tabloul de bord",
|
||||
@@ -2213,7 +2215,24 @@
|
||||
"auditTarget": "țintă",
|
||||
"selectAllMatching": "Select all {count} matching",
|
||||
"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": {
|
||||
"title": "Camerele",
|
||||
@@ -2442,7 +2461,9 @@
|
||||
"colPriority": "Priority",
|
||||
"colAssigned": "Assigned",
|
||||
"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": {
|
||||
"title": "Call for Help",
|
||||
@@ -2704,7 +2725,9 @@
|
||||
"replied": "Reply sent.",
|
||||
"closed": "Ticket closed.",
|
||||
"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": {
|
||||
"title": "Marketplace",
|
||||
@@ -2799,6 +2822,31 @@
|
||||
"less": "Less",
|
||||
"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": {
|
||||
@@ -2944,6 +2992,35 @@
|
||||
"voteCount": "{count} ({pct}%)",
|
||||
"totalVotes": "{count} total votes",
|
||||
"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": {
|
||||
|
||||
+82
-5
@@ -23,7 +23,8 @@
|
||||
"closeMenu": "Закрыть меню",
|
||||
"drawBadge": "Нарисуйте значок",
|
||||
"events": "Events",
|
||||
"polls": "Polls"
|
||||
"polls": "Polls",
|
||||
"mod": "Mod"
|
||||
},
|
||||
"header": {
|
||||
"online": "{count} {hotel} онлайн",
|
||||
@@ -904,7 +905,8 @@
|
||||
"chatLog": "Chat log",
|
||||
"commandLog": "Command log",
|
||||
"tradeLog": "Trade log",
|
||||
"tools": "Tools"
|
||||
"tools": "Tools",
|
||||
"guilds": "Guilds"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Панель управления",
|
||||
@@ -2211,7 +2213,24 @@
|
||||
"auditTarget": "Target",
|
||||
"selectAllMatching": "Select all {count} matching",
|
||||
"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": {
|
||||
"title": "Rooms",
|
||||
@@ -2440,7 +2459,9 @@
|
||||
"colPriority": "Priority",
|
||||
"colAssigned": "Assigned",
|
||||
"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": {
|
||||
"title": "Call for Help",
|
||||
@@ -2702,7 +2723,9 @@
|
||||
"replied": "Reply sent.",
|
||||
"closed": "Ticket closed.",
|
||||
"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": {
|
||||
"title": "Marketplace",
|
||||
@@ -2797,6 +2820,31 @@
|
||||
"less": "Less",
|
||||
"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": {
|
||||
@@ -2942,6 +2990,35 @@
|
||||
"voteCount": "{count} ({pct}%)",
|
||||
"totalVotes": "{count} total votes",
|
||||
"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": {
|
||||
|
||||
+82
-5
@@ -23,7 +23,8 @@
|
||||
"closeMenu": "Zatvoriť ponuku",
|
||||
"drawBadge": "Nakreslite odznak",
|
||||
"events": "Events",
|
||||
"polls": "Polls"
|
||||
"polls": "Polls",
|
||||
"mod": "Mod"
|
||||
},
|
||||
"header": {
|
||||
"online": "{count} {hotel} online",
|
||||
@@ -906,7 +907,8 @@
|
||||
"chatLog": "Chat log",
|
||||
"commandLog": "Command log",
|
||||
"tradeLog": "Trade log",
|
||||
"tools": "Tools"
|
||||
"tools": "Tools",
|
||||
"guilds": "Guilds"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -2213,7 +2215,24 @@
|
||||
"auditTarget": "Cieľ",
|
||||
"selectAllMatching": "Select all {count} matching",
|
||||
"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": {
|
||||
"title": "Izby",
|
||||
@@ -2442,7 +2461,9 @@
|
||||
"colPriority": "Priority",
|
||||
"colAssigned": "Assigned",
|
||||
"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": {
|
||||
"title": "Call for Help",
|
||||
@@ -2704,7 +2725,9 @@
|
||||
"replied": "Reply sent.",
|
||||
"closed": "Ticket closed.",
|
||||
"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": {
|
||||
"title": "Marketplace",
|
||||
@@ -2799,6 +2822,31 @@
|
||||
"less": "Less",
|
||||
"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": {
|
||||
@@ -2944,6 +2992,35 @@
|
||||
"voteCount": "{count} ({pct}%)",
|
||||
"totalVotes": "{count} total votes",
|
||||
"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": {
|
||||
|
||||
+82
-5
@@ -23,7 +23,8 @@
|
||||
"closeMenu": "Затвори мени",
|
||||
"drawBadge": "Нацртај значку",
|
||||
"events": "Events",
|
||||
"polls": "Polls"
|
||||
"polls": "Polls",
|
||||
"mod": "Mod"
|
||||
},
|
||||
"header": {
|
||||
"online": "{count} {hotel} на мрежи",
|
||||
@@ -906,7 +907,8 @@
|
||||
"chatLog": "Chat log",
|
||||
"commandLog": "Command log",
|
||||
"tradeLog": "Trade log",
|
||||
"tools": "Tools"
|
||||
"tools": "Tools",
|
||||
"guilds": "Guilds"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Дасхбоард",
|
||||
@@ -2213,7 +2215,24 @@
|
||||
"auditTarget": "Таргет",
|
||||
"selectAllMatching": "Select all {count} matching",
|
||||
"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": {
|
||||
"title": "Собе",
|
||||
@@ -2442,7 +2461,9 @@
|
||||
"colPriority": "Priority",
|
||||
"colAssigned": "Assigned",
|
||||
"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": {
|
||||
"title": "Call for Help",
|
||||
@@ -2704,7 +2725,9 @@
|
||||
"replied": "Reply sent.",
|
||||
"closed": "Ticket closed.",
|
||||
"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": {
|
||||
"title": "Marketplace",
|
||||
@@ -2799,6 +2822,31 @@
|
||||
"less": "Less",
|
||||
"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": {
|
||||
@@ -2944,6 +2992,35 @@
|
||||
"voteCount": "{count} ({pct}%)",
|
||||
"totalVotes": "{count} total votes",
|
||||
"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": {
|
||||
|
||||
+82
-5
@@ -23,7 +23,8 @@
|
||||
"closeMenu": "Stäng menyn",
|
||||
"drawBadge": "Rita märke",
|
||||
"events": "Events",
|
||||
"polls": "Polls"
|
||||
"polls": "Polls",
|
||||
"mod": "Mod"
|
||||
},
|
||||
"header": {
|
||||
"online": "{count} {hotel} online",
|
||||
@@ -906,7 +907,8 @@
|
||||
"chatLog": "Chat log",
|
||||
"commandLog": "Command log",
|
||||
"tradeLog": "Trade log",
|
||||
"tools": "Tools"
|
||||
"tools": "Tools",
|
||||
"guilds": "Guilds"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Instrumentbräda",
|
||||
@@ -2213,7 +2215,24 @@
|
||||
"auditTarget": "Mål",
|
||||
"selectAllMatching": "Select all {count} matching",
|
||||
"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": {
|
||||
"title": "Rum",
|
||||
@@ -2442,7 +2461,9 @@
|
||||
"colPriority": "Priority",
|
||||
"colAssigned": "Assigned",
|
||||
"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": {
|
||||
"title": "Call for Help",
|
||||
@@ -2704,7 +2725,9 @@
|
||||
"replied": "Reply sent.",
|
||||
"closed": "Ticket closed.",
|
||||
"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": {
|
||||
"title": "Marketplace",
|
||||
@@ -2799,6 +2822,31 @@
|
||||
"less": "Less",
|
||||
"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": {
|
||||
@@ -2944,6 +2992,35 @@
|
||||
"voteCount": "{count} ({pct}%)",
|
||||
"totalVotes": "{count} total votes",
|
||||
"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": {
|
||||
|
||||
+82
-5
@@ -23,7 +23,8 @@
|
||||
"closeMenu": "Menüyü kapat",
|
||||
"drawBadge": "Rozet çiz",
|
||||
"events": "Events",
|
||||
"polls": "Polls"
|
||||
"polls": "Polls",
|
||||
"mod": "Mod"
|
||||
},
|
||||
"header": {
|
||||
"online": "{count} {hotel} çevrimiçi",
|
||||
@@ -906,7 +907,8 @@
|
||||
"chatLog": "Chat log",
|
||||
"commandLog": "Command log",
|
||||
"tradeLog": "Trade log",
|
||||
"tools": "Tools"
|
||||
"tools": "Tools",
|
||||
"guilds": "Guilds"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Kontrol Paneli",
|
||||
@@ -2213,7 +2215,24 @@
|
||||
"auditTarget": "Hedef",
|
||||
"selectAllMatching": "Select all {count} matching",
|
||||
"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": {
|
||||
"title": "Odalar",
|
||||
@@ -2442,7 +2461,9 @@
|
||||
"colPriority": "Priority",
|
||||
"colAssigned": "Assigned",
|
||||
"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": {
|
||||
"title": "Call for Help",
|
||||
@@ -2704,7 +2725,9 @@
|
||||
"replied": "Reply sent.",
|
||||
"closed": "Ticket closed.",
|
||||
"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": {
|
||||
"title": "Marketplace",
|
||||
@@ -2799,6 +2822,31 @@
|
||||
"less": "Less",
|
||||
"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": {
|
||||
@@ -2944,6 +2992,35 @@
|
||||
"voteCount": "{count} ({pct}%)",
|
||||
"totalVotes": "{count} total votes",
|
||||
"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": {
|
||||
|
||||
+82
-5
@@ -23,7 +23,8 @@
|
||||
"closeMenu": "Закрити меню",
|
||||
"drawBadge": "Намалюйте значок",
|
||||
"events": "Events",
|
||||
"polls": "Polls"
|
||||
"polls": "Polls",
|
||||
"mod": "Mod"
|
||||
},
|
||||
"header": {
|
||||
"online": "{count} {hotel} онлайн",
|
||||
@@ -906,7 +907,8 @@
|
||||
"chatLog": "Chat log",
|
||||
"commandLog": "Command log",
|
||||
"tradeLog": "Trade log",
|
||||
"tools": "Tools"
|
||||
"tools": "Tools",
|
||||
"guilds": "Guilds"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Приладова панель",
|
||||
@@ -2213,7 +2215,24 @@
|
||||
"auditTarget": "Цільова",
|
||||
"selectAllMatching": "Select all {count} matching",
|
||||
"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": {
|
||||
"title": "Кімнати",
|
||||
@@ -2442,7 +2461,9 @@
|
||||
"colPriority": "Priority",
|
||||
"colAssigned": "Assigned",
|
||||
"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": {
|
||||
"title": "Call for Help",
|
||||
@@ -2704,7 +2725,9 @@
|
||||
"replied": "Reply sent.",
|
||||
"closed": "Ticket closed.",
|
||||
"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": {
|
||||
"title": "Marketplace",
|
||||
@@ -2799,6 +2822,31 @@
|
||||
"less": "Less",
|
||||
"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": {
|
||||
@@ -2944,6 +2992,35 @@
|
||||
"voteCount": "{count} ({pct}%)",
|
||||
"totalVotes": "{count} total votes",
|
||||
"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": {
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"baseUrl": ".",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ export default defineConfig({
|
||||
// Modules under test transitively import @/env; skip its strict parse so
|
||||
// unit tests run without a populated .env.
|
||||
env: { SKIP_ENV_VALIDATION: "1" },
|
||||
exclude: ["e2e/**", "node_modules/**", ".next/**"],
|
||||
exclude: ["e2e/**", "node_modules/**", ".next/**", ".next.prev/**"],
|
||||
coverage: {
|
||||
enabled: true,
|
||||
provider: "v8",
|
||||
|
||||
Reference in new issue
Block a user