feat: jwt cache, redis health, help-ticket admin, and write rate limits
Cut Auth.js DB load with cached jwtVersion checks, surface Redis in /api/health and deploy warnings, add admin help-center ticket reply UI, rate-limit API tickets/reactions/referral claims, and revoke PATs on sign-out-everywhere. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
3bb96eb6f3
commit
968ca15c27
23 files changed
+1344
-205
No files matched your search
+4
-3
@@ -74,9 +74,10 @@ PAYPAL_CLIENT_ID=
|
|||||||
PAYPAL_SECRET=
|
PAYPAL_SECRET=
|
||||||
PAYPAL_API=https://api-m.sandbox.paypal.com
|
PAYPAL_API=https://api-m.sandbox.paypal.com
|
||||||
|
|
||||||
# Redis — strongly recommended in production (required for multi-instance).
|
# Redis — REQUIRED for production (shared rate limits, site-settings cache,
|
||||||
# Shared rate limiting + site-settings cache. Without it, limits are in-process
|
# JWT session invalidation cache). Without REDIS_URL the app falls back to
|
||||||
# only and do not hold across restarts or multiple app instances.
|
# 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
|
REDIS_URL=redis://127.0.0.1:6379
|
||||||
|
|
||||||
# Logging level (debug | info | warn | error). Defaults to 'info' in production,
|
# Logging level (debug | info | warn | error). Defaults to 'info' in production,
|
||||||
|
|||||||
@@ -288,6 +288,11 @@ jobs:
|
|||||||
# Production env stays on the live tree; stage only needs a symlink for build/migrate.
|
# Production env stays on the live tree; stage only needs a symlink for build/migrate.
|
||||||
ln -sfn "${LIVE}/.env" "${STAGE}/.env"
|
ln -sfn "${LIVE}/.env" "${STAGE}/.env"
|
||||||
|
|
||||||
|
if ! grep -qE '^[[:space:]]*REDIS_URL=.+' "${LIVE}/.env" 2>/dev/null; then
|
||||||
|
echo "WARNING: REDIS_URL is unset in ${LIVE}/.env" >&2
|
||||||
|
echo "WARNING: Rate limits, site-settings cache, and JWT invalidation cache will be in-process only." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
cd "${STAGE}"
|
cd "${STAGE}"
|
||||||
rm -f tsconfig.tsbuildinfo .tsbuildinfo
|
rm -f tsconfig.tsbuildinfo .tsbuildinfo
|
||||||
find . -maxdepth 3 -name '*.tsbuildinfo' -delete 2>/dev/null || true
|
find . -maxdepth 3 -name '*.tsbuildinfo' -delete 2>/dev/null || true
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+95
-86
File diff suppressed because it is too large.
Load diff
+13
-1
@@ -1,11 +1,13 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { auth, signOut } from "@/lib/auth";
|
import { auth, signOut } from "@/lib/auth";
|
||||||
|
import { invalidateJwtVersionCache } from "@/lib/auth/jwt-version-cache";
|
||||||
|
import { personalTokenScope } from "@/lib/auth/personal-token-scope";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invalidate every CMS JWT for the signed-in user by bumping website_jwt_version,
|
* Invalidate every CMS JWT for the signed-in user by bumping website_jwt_version,
|
||||||
* then end the current browser session too.
|
* revoke personal access tokens, then end the current browser session too.
|
||||||
*/
|
*/
|
||||||
export async function signOutEverywhere(): Promise<void> {
|
export async function signOutEverywhere(): Promise<void> {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
@@ -20,6 +22,16 @@ export async function signOutEverywhere(): Promise<void> {
|
|||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: { websiteJwtVersion: { increment: 1 } },
|
data: { websiteJwtVersion: { increment: 1 } },
|
||||||
});
|
});
|
||||||
|
await invalidateJwtVersionCache(userId);
|
||||||
|
} catch {
|
||||||
|
/* still continue */
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke API bearer tokens (Sanctum / personal_access_tokens).
|
||||||
|
try {
|
||||||
|
await prisma.personalAccessTokens.deleteMany({
|
||||||
|
where: personalTokenScope(userId),
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
/* still sign out locally */
|
/* still sign out locally */
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,89 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { DataTable } from "@/components/admin/data-table";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import type { DataTableColumn, PaginatedResult } from "@/types";
|
||||||
|
|
||||||
|
export interface HelpTicketRow {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
user: string;
|
||||||
|
userId: number | null;
|
||||||
|
replies: number;
|
||||||
|
open: boolean;
|
||||||
|
date: string;
|
||||||
|
updated: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HelpTicketsTable({
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
data: PaginatedResult<HelpTicketRow>;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("pages.admin.helpTickets");
|
||||||
|
|
||||||
|
const columns: DataTableColumn<HelpTicketRow>[] = [
|
||||||
|
{
|
||||||
|
key: "title",
|
||||||
|
label: t("colTicket"),
|
||||||
|
sortable: true,
|
||||||
|
render: (_value, row) => (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground font-mono">
|
||||||
|
#{row.id}
|
||||||
|
</span>
|
||||||
|
<Link
|
||||||
|
href={`/admin/help-tickets/${row.id}`}
|
||||||
|
className="font-medium truncate hover:underline"
|
||||||
|
>
|
||||||
|
{row.title}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t("meta", { user: row.user, count: row.replies })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "open",
|
||||||
|
label: t("colStatus"),
|
||||||
|
sortable: true,
|
||||||
|
filterKey: "filter_status",
|
||||||
|
filterOptions: [
|
||||||
|
{ label: t("presetOpen"), value: "open" },
|
||||||
|
{ label: t("statusClosed"), value: "closed" },
|
||||||
|
],
|
||||||
|
render: (value) => {
|
||||||
|
const open = value === true || value === "true";
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant={open ? "default" : "secondary"}
|
||||||
|
className="text-[0.7rem]"
|
||||||
|
>
|
||||||
|
{open ? t("statusOpen") : t("statusClosed")}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ key: "user", label: t("colUser"), sortable: true },
|
||||||
|
{ key: "date", label: t("colCreated"), sortable: true },
|
||||||
|
{ key: "updated", label: t("colUpdated"), sortable: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
data={data}
|
||||||
|
columns={columns}
|
||||||
|
searchPlaceholder={t("searchPlaceholder")}
|
||||||
|
presets={[
|
||||||
|
{ label: t("presetOpen"), params: { filter_status: "open" } },
|
||||||
|
{ label: t("statusClosed"), params: { filter_status: "closed" } },
|
||||||
|
{ label: t("presetAll"), params: { filter_status: "all" } },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,21 +1,36 @@
|
|||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
import { apiJson } from "@/lib/api";
|
import { apiJson } from "@/lib/api";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { redis } from "@/lib/redis";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ops health probe: database reachability, emulator RCON reachability, SMTP
|
* Ops health probe: database reachability, Redis (when configured), emulator
|
||||||
* (when configured), and runtime info. Returns HTTP 200 always (read the
|
* RCON, SMTP (when configured), and runtime info. Returns HTTP 200 always
|
||||||
* `status`/`database` fields), so it's safe for uptime monitors that only care
|
* (read the `status`/`database` fields), so it's safe for uptime monitors that
|
||||||
* about reachability.
|
* only care about reachability.
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const database = await prisma.$queryRaw`SELECT 1`
|
const database = await prisma.$queryRaw`SELECT 1`
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch(() => false);
|
.catch(() => false);
|
||||||
|
|
||||||
|
let redisOk: boolean | null = null;
|
||||||
|
if (env.REDIS_URL) {
|
||||||
|
if (!redis) {
|
||||||
|
redisOk = false;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const pong = await redis.ping();
|
||||||
|
redisOk = pong === "PONG";
|
||||||
|
} catch {
|
||||||
|
redisOk = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const emulator = await rcon.send("ping", null).catch(() => false);
|
const emulator = await rcon.send("ping", null).catch(() => false);
|
||||||
|
|
||||||
let smtp = null;
|
let smtp = null;
|
||||||
@@ -35,9 +50,11 @@ export async function GET() {
|
|||||||
.catch(() => false);
|
.catch(() => false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const degraded = !database || redisOk === false;
|
||||||
return apiJson({
|
return apiJson({
|
||||||
status: database ? "ok" : "degraded",
|
status: degraded ? "degraded" : "ok",
|
||||||
database,
|
database,
|
||||||
|
redis: redisOk,
|
||||||
emulator,
|
emulator,
|
||||||
smtp,
|
smtp,
|
||||||
node: process.version,
|
node: process.version,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import { apiError, apiJson, positiveBigInt } from "@/lib/api";
|
import { apiError, apiJson, positiveBigInt } from "@/lib/api";
|
||||||
import { bearerUserId } from "@/lib/api-auth";
|
import { bearerUserId } from "@/lib/api-auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { rateLimit } from "@/lib/rate-limit";
|
||||||
import { createOwnedTicketReply } from "@/lib/services/ticket-replies";
|
import { createOwnedTicketReply } from "@/lib/services/ticket-replies";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -19,6 +20,10 @@ export async function POST(
|
|||||||
const uid = await bearerUserId(req);
|
const uid = await bearerUserId(req);
|
||||||
if (!uid) return apiError("Unauthorized", 401);
|
if (!uid) return apiError("Unauthorized", 401);
|
||||||
|
|
||||||
|
if (!(await rateLimit(`api-ticket-reply:${uid}`, 10, 60_000)).ok) {
|
||||||
|
return apiError("Too many requests", 429);
|
||||||
|
}
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const ticketId = positiveBigInt(id);
|
const ticketId = positiveBigInt(id);
|
||||||
if (!ticketId) return apiError("Invalid ticket id", 422);
|
if (!ticketId) return apiError("Invalid ticket id", 422);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import { apiError, apiJson, positiveBigInt } from "@/lib/api";
|
import { apiError, apiJson, positiveBigInt } from "@/lib/api";
|
||||||
import { bearerUserId } from "@/lib/api-auth";
|
import { bearerUserId } from "@/lib/api-auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { rateLimit } from "@/lib/rate-limit";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -40,6 +41,10 @@ export async function POST(req: Request) {
|
|||||||
const uid = await bearerUserId(req);
|
const uid = await bearerUserId(req);
|
||||||
if (!uid) return apiError("Unauthorized", 401);
|
if (!uid) return apiError("Unauthorized", 401);
|
||||||
|
|
||||||
|
if (!(await rateLimit(`api-ticket:${uid}`, 5, 60_000)).ok) {
|
||||||
|
return apiError("Too many requests", 429);
|
||||||
|
}
|
||||||
|
|
||||||
const body = (await req.json().catch(() => ({}))) as {
|
const body = (await req.json().catch(() => ({}))) as {
|
||||||
title?: unknown;
|
title?: unknown;
|
||||||
content?: unknown;
|
content?: unknown;
|
||||||
|
|||||||
@@ -97,13 +97,18 @@ export const ADMIN_HUBS: AdminHubDefinition[] = [
|
|||||||
titleKey: "tickets",
|
titleKey: "tickets",
|
||||||
subtitleKey: "ticketsSubtitle",
|
subtitleKey: "ticketsSubtitle",
|
||||||
icon: Ticket,
|
icon: Ticket,
|
||||||
prefixes: ["/admin/tickets"],
|
prefixes: ["/admin/tickets", "/admin/help-tickets"],
|
||||||
tabs: [
|
tabs: [
|
||||||
{
|
{
|
||||||
href: "/admin/tickets",
|
href: "/admin/tickets",
|
||||||
labelKey: "tickets",
|
labelKey: "tickets",
|
||||||
match: ["/admin/tickets"],
|
match: ["/admin/tickets"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: "/admin/help-tickets",
|
||||||
|
labelKey: "helpTickets",
|
||||||
|
match: ["/admin/help-tickets"],
|
||||||
|
},
|
||||||
{ href: "/admin/tickets/templates", labelKey: "templates" },
|
{ href: "/admin/tickets/templates", labelKey: "templates" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -285,7 +290,7 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
|
|||||||
href: "/admin/tickets",
|
href: "/admin/tickets",
|
||||||
labelKey: "tickets",
|
labelKey: "tickets",
|
||||||
icon: Ticket,
|
icon: Ticket,
|
||||||
matchPrefixes: ["/admin/tickets"],
|
matchPrefixes: ["/admin/tickets", "/admin/help-tickets"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
+76
-71
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,51 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mockFindUnique = vi.hoisted(() => vi.fn());
|
||||||
|
const mockRedisGet = vi.hoisted(() => vi.fn());
|
||||||
|
const mockRedisSetex = vi.hoisted(() => vi.fn());
|
||||||
|
const mockRedisDel = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("@/lib/prisma", () => ({
|
||||||
|
prisma: {
|
||||||
|
user: { findUnique: mockFindUnique },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/redis", () => ({
|
||||||
|
redis: {
|
||||||
|
get: mockRedisGet,
|
||||||
|
setex: mockRedisSetex,
|
||||||
|
del: mockRedisDel,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("jwt-version-cache", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockRedisGet.mockResolvedValue(null);
|
||||||
|
mockRedisSetex.mockResolvedValue("OK");
|
||||||
|
mockRedisDel.mockResolvedValue(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns DB version and caches it", async () => {
|
||||||
|
mockFindUnique.mockResolvedValue({ websiteJwtVersion: 3 });
|
||||||
|
const { getCachedJwtVersion } = await import("./jwt-version-cache");
|
||||||
|
await expect(getCachedJwtVersion(42)).resolves.toBe(3);
|
||||||
|
expect(mockFindUnique).toHaveBeenCalledTimes(1);
|
||||||
|
await expect(getCachedJwtVersion(42)).resolves.toBe(3);
|
||||||
|
expect(mockFindUnique).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invalidates memory and redis entries", async () => {
|
||||||
|
mockFindUnique.mockResolvedValue({ websiteJwtVersion: 1 });
|
||||||
|
const { getCachedJwtVersion, invalidateJwtVersionCache } = await import(
|
||||||
|
"./jwt-version-cache"
|
||||||
|
);
|
||||||
|
await getCachedJwtVersion(7);
|
||||||
|
await invalidateJwtVersionCache(7);
|
||||||
|
expect(mockRedisDel).toHaveBeenCalled();
|
||||||
|
mockFindUnique.mockResolvedValue({ websiteJwtVersion: 2 });
|
||||||
|
await expect(getCachedJwtVersion(7)).resolves.toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { redis } from "@/lib/redis";
|
||||||
|
|
||||||
|
const MEMORY_TTL_MS = 60_000;
|
||||||
|
const REDIS_TTL_SEC = 60;
|
||||||
|
const memory = new Map<number, { version: number; expiresAt: number }>();
|
||||||
|
|
||||||
|
function redisKey(userId: number): string {
|
||||||
|
return `jwtver:${userId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cached `users.website_jwt_version` for Auth.js JWT validation.
|
||||||
|
* Avoids a DB round-trip on every authenticated request.
|
||||||
|
*/
|
||||||
|
export async function getCachedJwtVersion(
|
||||||
|
userId: number,
|
||||||
|
): Promise<number | null> {
|
||||||
|
if (!Number.isInteger(userId) || userId <= 0) return null;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const hit = memory.get(userId);
|
||||||
|
if (hit && hit.expiresAt > now) return hit.version;
|
||||||
|
|
||||||
|
if (redis) {
|
||||||
|
try {
|
||||||
|
const raw = await redis.get(redisKey(userId));
|
||||||
|
if (raw !== null && raw !== undefined) {
|
||||||
|
const version = Number.parseInt(raw, 10);
|
||||||
|
if (Number.isFinite(version)) {
|
||||||
|
memory.set(userId, { version, expiresAt: now + MEMORY_TTL_MS });
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* fall through to DB */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const row = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { websiteJwtVersion: true },
|
||||||
|
});
|
||||||
|
if (!row) return null;
|
||||||
|
const version = row.websiteJwtVersion;
|
||||||
|
memory.set(userId, { version, expiresAt: now + MEMORY_TTL_MS });
|
||||||
|
if (redis) {
|
||||||
|
try {
|
||||||
|
await redis.setex(redisKey(userId), REDIS_TTL_SEC, String(version));
|
||||||
|
} catch {
|
||||||
|
/* non-critical */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return version;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Call after bumping website_jwt_version so other instances drop sessions ASAP. */
|
||||||
|
export async function invalidateJwtVersionCache(userId: number): Promise<void> {
|
||||||
|
memory.delete(userId);
|
||||||
|
if (redis) {
|
||||||
|
try {
|
||||||
|
await redis.del(redisKey(userId));
|
||||||
|
} catch {
|
||||||
|
/* non-critical */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ describe("production deploy workflow", () => {
|
|||||||
expect(deployJob).toContain('ln -sfn "${LIVE}/.env"');
|
expect(deployJob).toContain('ln -sfn "${LIVE}/.env"');
|
||||||
expect(deployJob).toContain("-e storage");
|
expect(deployJob).toContain("-e storage");
|
||||||
expect(deployJob).toContain("DATABASE_POOL_SIZE=");
|
expect(deployJob).toContain("DATABASE_POOL_SIZE=");
|
||||||
|
expect(deployJob).toContain("REDIS_URL is unset");
|
||||||
expect(deployJob).not.toContain("SKIP_ENV_VALIDATION=1");
|
expect(deployJob).not.toContain("SKIP_ENV_VALIDATION=1");
|
||||||
expect(deployJob).toContain("pnpm install --frozen-lockfile");
|
expect(deployJob).toContain("pnpm install --frozen-lockfile");
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ function createRedis(): Redis | null {
|
|||||||
) {
|
) {
|
||||||
globalForRedis.redisMissingWarned = true;
|
globalForRedis.redisMissingWarned = true;
|
||||||
console.error(
|
console.error(
|
||||||
"[redis] REDIS_URL is unset in production. Rate limits and shared caches fall back to in-process memory and will not work correctly across multiple instances.",
|
"[redis] REDIS_URL is unset in production. Rate limits, site-settings cache, and JWT session invalidation fall back to in-process memory and will not work correctly across multiple instances or restarts. Set REDIS_URL in .env.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1001,6 +1001,7 @@
|
|||||||
"writeableBoxes": "Writeable boxes",
|
"writeableBoxes": "Writeable boxes",
|
||||||
"photos": "Photos",
|
"photos": "Photos",
|
||||||
"tickets": "Tickets",
|
"tickets": "Tickets",
|
||||||
|
"helpTickets": "Help center tickets",
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"polls": "Polls",
|
"polls": "Polls",
|
||||||
"banners": "Banners",
|
"banners": "Banners",
|
||||||
@@ -1523,6 +1524,41 @@
|
|||||||
"colDate": "Date",
|
"colDate": "Date",
|
||||||
"meta": "by {user} · {count} msg · {category}"
|
"meta": "by {user} · {count} msg · {category}"
|
||||||
},
|
},
|
||||||
|
"helpTickets": {
|
||||||
|
"title": "Help center tickets",
|
||||||
|
"subtitle": "Player tickets from /help/tickets",
|
||||||
|
"noTickets": "No open help center tickets",
|
||||||
|
"searchPlaceholder": "Search title, user or ID…",
|
||||||
|
"presetOpen": "Open",
|
||||||
|
"presetAll": "All",
|
||||||
|
"statusOpen": "Open",
|
||||||
|
"statusClosed": "Closed",
|
||||||
|
"colTicket": "Ticket",
|
||||||
|
"colStatus": "Status",
|
||||||
|
"colUser": "User",
|
||||||
|
"colCreated": "Created",
|
||||||
|
"colUpdated": "Updated",
|
||||||
|
"meta": "by {user} · {count} replies",
|
||||||
|
"backToList": "Back to list",
|
||||||
|
"threadTitle": "Conversation",
|
||||||
|
"staffBadge": "Staff",
|
||||||
|
"replyPlaceholder": "Reply as staff…",
|
||||||
|
"replySubmit": "Send reply",
|
||||||
|
"replyHint": "Ctrl+Enter to send.",
|
||||||
|
"closeSubmit": "Close ticket",
|
||||||
|
"reopenSubmit": "Reopen ticket",
|
||||||
|
"closedHint": "This ticket is closed.",
|
||||||
|
"actionsTitle": "Actions",
|
||||||
|
"requesterTitle": "Requester",
|
||||||
|
"detailsTitle": "Details",
|
||||||
|
"rankLabel": "Rank {rank}",
|
||||||
|
"messageCountLabel": "Messages",
|
||||||
|
"success": {
|
||||||
|
"replied": "Reply sent.",
|
||||||
|
"closed": "Ticket closed.",
|
||||||
|
"reopened": "Ticket reopened."
|
||||||
|
}
|
||||||
|
},
|
||||||
"cfh": {
|
"cfh": {
|
||||||
"title": "Call for Help",
|
"title": "Call for Help",
|
||||||
"subtitle": "In-game CFH / support tickets",
|
"subtitle": "In-game CFH / support tickets",
|
||||||
@@ -2393,6 +2429,7 @@
|
|||||||
"eventTypes": "Event types",
|
"eventTypes": "Event types",
|
||||||
"polls": "Polls",
|
"polls": "Polls",
|
||||||
"tickets": "Tickets",
|
"tickets": "Tickets",
|
||||||
|
"helpTickets": "Help center tickets",
|
||||||
"templates": "Templates",
|
"templates": "Templates",
|
||||||
"directory": "Directory",
|
"directory": "Directory",
|
||||||
"multiAccounts": "Multi-accounts",
|
"multiAccounts": "Multi-accounts",
|
||||||
|
|||||||
@@ -953,6 +953,7 @@
|
|||||||
"writeableBoxes": "Box modificabili",
|
"writeableBoxes": "Box modificabili",
|
||||||
"photos": "Foto",
|
"photos": "Foto",
|
||||||
"tickets": "Ticket",
|
"tickets": "Ticket",
|
||||||
|
"helpTickets": "Ticket centro assistenza",
|
||||||
"events": "Eventi",
|
"events": "Eventi",
|
||||||
"polls": "Sondaggi",
|
"polls": "Sondaggi",
|
||||||
"banners": "Banner",
|
"banners": "Banner",
|
||||||
@@ -1479,6 +1480,41 @@
|
|||||||
"colDate": "Data",
|
"colDate": "Data",
|
||||||
"meta": "di {user} · {count} msg · {category}"
|
"meta": "di {user} · {count} msg · {category}"
|
||||||
},
|
},
|
||||||
|
"helpTickets": {
|
||||||
|
"title": "Ticket centro assistenza",
|
||||||
|
"subtitle": "Ticket dei giocatori da /help/tickets",
|
||||||
|
"noTickets": "Nessun ticket aperto nel centro assistenza",
|
||||||
|
"searchPlaceholder": "Cerca titolo, utente o ID…",
|
||||||
|
"presetOpen": "Aperti",
|
||||||
|
"presetAll": "Tutti",
|
||||||
|
"statusOpen": "Aperti",
|
||||||
|
"statusClosed": "Chiusi",
|
||||||
|
"colTicket": "Ticket",
|
||||||
|
"colStatus": "Stato",
|
||||||
|
"colUser": "Utente",
|
||||||
|
"colCreated": "Creato",
|
||||||
|
"colUpdated": "Aggiornato",
|
||||||
|
"meta": "di {user} · {count} risposte",
|
||||||
|
"backToList": "Torna all'elenco",
|
||||||
|
"threadTitle": "Conversazione",
|
||||||
|
"staffBadge": "Staff",
|
||||||
|
"replyPlaceholder": "Rispondi come staff…",
|
||||||
|
"replySubmit": "Invia risposta",
|
||||||
|
"replyHint": "Ctrl+Invio per inviare.",
|
||||||
|
"closeSubmit": "Chiudi ticket",
|
||||||
|
"reopenSubmit": "Riapri ticket",
|
||||||
|
"closedHint": "Questo ticket è chiuso.",
|
||||||
|
"actionsTitle": "Azioni",
|
||||||
|
"requesterTitle": "Richiedente",
|
||||||
|
"detailsTitle": "Dettagli",
|
||||||
|
"rankLabel": "Rank {rank}",
|
||||||
|
"messageCountLabel": "Messaggi",
|
||||||
|
"success": {
|
||||||
|
"replied": "Risposta inviata.",
|
||||||
|
"closed": "Ticket chiuso.",
|
||||||
|
"reopened": "Ticket riaperto."
|
||||||
|
}
|
||||||
|
},
|
||||||
"cfh": {
|
"cfh": {
|
||||||
"title": "Call for Help",
|
"title": "Call for Help",
|
||||||
"subtitle": "Ticket CFH in-game",
|
"subtitle": "Ticket CFH in-game",
|
||||||
@@ -2628,6 +2664,7 @@
|
|||||||
"eventTypes": "Tipi evento",
|
"eventTypes": "Tipi evento",
|
||||||
"polls": "Sondaggi",
|
"polls": "Sondaggi",
|
||||||
"tickets": "Ticket",
|
"tickets": "Ticket",
|
||||||
|
"helpTickets": "Ticket centro assistenza",
|
||||||
"templates": "Template",
|
"templates": "Template",
|
||||||
"directory": "Directory",
|
"directory": "Directory",
|
||||||
"multiAccounts": "Multi-account",
|
"multiAccounts": "Multi-account",
|
||||||
|
|||||||
@@ -998,6 +998,7 @@
|
|||||||
"writeableBoxes": "Bewerkbare boxes",
|
"writeableBoxes": "Bewerkbare boxes",
|
||||||
"photos": "Foto's",
|
"photos": "Foto's",
|
||||||
"tickets": "Tickets",
|
"tickets": "Tickets",
|
||||||
|
"helpTickets": "Helpcentrum-tickets",
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"polls": "Polls",
|
"polls": "Polls",
|
||||||
"banners": "Banners",
|
"banners": "Banners",
|
||||||
@@ -2729,6 +2730,7 @@
|
|||||||
"eventTypes": "Evenementtypen",
|
"eventTypes": "Evenementtypen",
|
||||||
"polls": "Polls",
|
"polls": "Polls",
|
||||||
"tickets": "Tickets",
|
"tickets": "Tickets",
|
||||||
|
"helpTickets": "Helpcentrum-tickets",
|
||||||
"templates": "Sjablonen",
|
"templates": "Sjablonen",
|
||||||
"directory": "Adresboek",
|
"directory": "Adresboek",
|
||||||
"multiAccounts": "Multi-accounts",
|
"multiAccounts": "Multi-accounts",
|
||||||
@@ -2815,6 +2817,41 @@
|
|||||||
"colDate": "Datum",
|
"colDate": "Datum",
|
||||||
"meta": "door {user} · {count} berichten · {category}"
|
"meta": "door {user} · {count} berichten · {category}"
|
||||||
},
|
},
|
||||||
|
"helpTickets": {
|
||||||
|
"title": "Helpcentrum-tickets",
|
||||||
|
"subtitle": "Spelertickets van /help/tickets",
|
||||||
|
"noTickets": "Geen open helpcentrum-tickets",
|
||||||
|
"searchPlaceholder": "Zoek op titel, gebruiker of ID…",
|
||||||
|
"presetOpen": "Open",
|
||||||
|
"presetAll": "Alle",
|
||||||
|
"statusOpen": "Open",
|
||||||
|
"statusClosed": "Gesloten",
|
||||||
|
"colTicket": "Ticket",
|
||||||
|
"colStatus": "Status",
|
||||||
|
"colUser": "Gebruiker",
|
||||||
|
"colCreated": "Aangemaakt",
|
||||||
|
"colUpdated": "Bijgewerkt",
|
||||||
|
"meta": "door {user} · {count} antwoorden",
|
||||||
|
"backToList": "Terug naar lijst",
|
||||||
|
"threadTitle": "Gesprek",
|
||||||
|
"staffBadge": "Staff",
|
||||||
|
"replyPlaceholder": "Antwoord als staff…",
|
||||||
|
"replySubmit": "Antwoord versturen",
|
||||||
|
"replyHint": "Ctrl+Enter om te versturen.",
|
||||||
|
"closeSubmit": "Ticket sluiten",
|
||||||
|
"reopenSubmit": "Ticket heropenen",
|
||||||
|
"closedHint": "Dit ticket is gesloten.",
|
||||||
|
"actionsTitle": "Acties",
|
||||||
|
"requesterTitle": "Aanvrager",
|
||||||
|
"detailsTitle": "Details",
|
||||||
|
"rankLabel": "Rank {rank}",
|
||||||
|
"messageCountLabel": "Berichten",
|
||||||
|
"success": {
|
||||||
|
"replied": "Antwoord verzonden.",
|
||||||
|
"closed": "Ticket gesloten.",
|
||||||
|
"reopened": "Ticket heropend."
|
||||||
|
}
|
||||||
|
},
|
||||||
"cfh": {
|
"cfh": {
|
||||||
"title": "Call for Help",
|
"title": "Call for Help",
|
||||||
"subtitle": "In-game CFH / ondersteuningstickets",
|
"subtitle": "In-game CFH / ondersteuningstickets",
|
||||||
|
|||||||
Vendored
+1
@@ -10,6 +10,7 @@ declare module "next-auth/jwt" {
|
|||||||
interface JWT {
|
interface JWT {
|
||||||
rank?: number;
|
rank?: number;
|
||||||
jwtVersion?: number;
|
jwtVersion?: number;
|
||||||
|
jwtCheckedAt?: number;
|
||||||
invalid?: boolean;
|
invalid?: boolean;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user