feat(hk): expand search and add operational user overview
CI / check (push) Successful in 54s
CI / deploy (push) Successful in 1m16s
CI / publish-container (push) Successful in 1m57s

This commit is contained in:
Simo committed 2026-09-09 21:14:10 +02:00
1 parent 27447ce029
commit 89526af344
44 files changed
+3740 -416

No files matched your search

@@ -1,6 +1,7 @@
"use client";
import { Loader2, UserPlus } from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { toast } from "sonner";
import { createUser } from "@/actions/users";
@@ -28,17 +29,10 @@ interface CreateUserDialogProps {
sessionRank: number;
}
const RANK_OPTIONS: { value: number; label: string }[] = [
{ value: 1, label: "User (1)" },
{ value: 2, label: "Bronze (2)" },
{ value: 3, label: "Silver (3)" },
{ value: 4, label: "Gold (4)" },
{ value: 5, label: "Moderator (5)" },
{ value: 6, label: "Admin (6)" },
{ value: 7, label: "Super Admin (7)" },
];
const RANK_OPTIONS = [1, 2, 3, 4, 5, 6, 7];
export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) {
const t = useTranslations("pages.admin.users.create");
const { run, isPending } = useServerAction();
const [open, setOpen] = useState(false);
const [username, setUsername] = useState("");
@@ -48,7 +42,7 @@ export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) {
const [motto, setMotto] = useState("");
const allowedRanks = RANK_OPTIONS.filter((r) =>
sessionRank >= 7 ? true : r.value < sessionRank,
sessionRank >= 7 ? true : r < sessionRank,
);
function reset() {
@@ -67,22 +61,22 @@ export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) {
for (let i = 0; i < buf.length; i++)
out += charset[buf[i] % charset.length];
setPassword(out);
toast.message("Password generated", { description: out });
toast.message(t("passwordGenerated"));
}
function handleCreate() {
const u = username.trim();
const m = mail.trim();
if (!u || !m || !password) {
toast.error("Username, email and password are required");
toast.error(t("required"));
return;
}
if (u.length < 3 || u.length > 20 || !/^[a-zA-Z0-9_-]+$/.test(u)) {
toast.error("Invalid username (3-20 chars, letters/digits/_/-)");
toast.error(t("invalidUsername"));
return;
}
if (password.length < 6) {
toast.error("Password must be at least 6 characters");
toast.error(t("passwordLength"));
return;
}
@@ -96,7 +90,7 @@ export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) {
motto: motto.trim(),
}),
{
successMessage: `User "${u}" created.`,
successMessage: t("created", { username: u }),
onSuccess: () => {
setOpen(false);
reset();
@@ -116,31 +110,28 @@ export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) {
<DialogTrigger asChild>
<Button>
<UserPlus className="mr-2 h-4 w-4" />
New User
{t("newUser")}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create User</DialogTitle>
<DialogDescription>
Account is created immediately with default look and 5,000
credits/duckets/diamonds.
</DialogDescription>
<DialogTitle>{t("title")}</DialogTitle>
<DialogDescription>{t("description")}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="cu-username">Username</Label>
<Label htmlFor="cu-username">{t("username")}</Label>
<Input
id="cu-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="HabboName"
placeholder={t("usernamePlaceholder")}
autoComplete="off"
maxLength={20}
/>
</div>
<div className="space-y-2">
<Label htmlFor="cu-mail">Email</Label>
<Label htmlFor="cu-mail">{t("email")}</Label>
<Input
id="cu-mail"
type="email"
@@ -151,14 +142,14 @@ export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) {
/>
</div>
<div className="space-y-2">
<Label htmlFor="cu-password">Password</Label>
<Label htmlFor="cu-password">{t("password")}</Label>
<div className="flex gap-2">
<Input
id="cu-password"
type="text"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Minimum 6 characters"
placeholder={t("passwordPlaceholder")}
autoComplete="new-password"
className="font-mono"
/>
@@ -167,33 +158,33 @@ export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) {
variant="outline"
onClick={generatePassword}
>
Generate
{t("generate")}
</Button>
</div>
</div>
<div className="space-y-2">
<Label>Rank</Label>
<Label>{t("rank")}</Label>
<Select value={rank} onValueChange={setRank}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{allowedRanks.map((r) => (
<SelectItem key={r.value} value={String(r.value)}>
{r.label}
<SelectItem key={r} value={String(r)}>
{t("rankOption", { rank: r })}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="cu-motto">Motto (optional)</Label>
<Label htmlFor="cu-motto">{t("motto")}</Label>
<Input
id="cu-motto"
value={motto}
onChange={(e) => setMotto(e.target.value)}
maxLength={127}
placeholder="I'm new here!"
placeholder={t("mottoPlaceholder")}
/>
</div>
@@ -207,7 +198,7 @@ export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) {
) : (
<UserPlus className="mr-2 h-4 w-4" />
)}
Create User
{t("title")}
</Button>
</div>
</DialogContent>
@@ -6,6 +6,7 @@ import {
MonitorSmartphone,
Shield,
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "@/components/link";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -25,40 +26,41 @@ interface UserAccountInfoProps {
}
export function UserAccountInfo({ user }: UserAccountInfoProps) {
const t = useTranslations("pages.admin.users.presentation");
return (
<Card>
<CardHeader>
<CardTitle>Account Details</CardTitle>
<CardTitle>{t("accountDetails")}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-3">
<div className="flex items-center gap-3 text-sm">
<Calendar className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-muted-foreground">Registered</span>
<span className="text-muted-foreground">{t("registered")}</span>
<span className="ml-auto font-medium">
{formatDate(new Date(user.accountCreated * 1000), "date")}
</span>
</div>
<div className="flex items-center gap-3 text-sm">
<Clock className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-muted-foreground">Last Login</span>
<span className="text-muted-foreground">{t("lastLogin")}</span>
<span className="ml-auto font-medium">
{user.lastLogin
? formatDate(new Date(user.lastLogin * 1000))
: "Never"}
: t("never")}
</span>
</div>
<div className="flex items-center gap-3 text-sm">
<Globe className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-muted-foreground">IP Register</span>
<span className="text-muted-foreground">{t("ipRegister")}</span>
<span className="ml-auto flex items-center gap-1.5">
<code className="text-xs font-mono bg-muted px-2 py-0.5 rounded">
{user.ipRegister}
</code>
<Link
href={`/admin/users?search=${user.ipRegister}`}
title="Find other users with this IP"
title={t("findIp")}
>
<ExternalLink className="h-3 w-3 text-primary" />
</Link>
@@ -66,14 +68,14 @@ export function UserAccountInfo({ user }: UserAccountInfoProps) {
</div>
<div className="flex items-center gap-3 text-sm">
<Globe className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-muted-foreground">IP Current</span>
<span className="text-muted-foreground">{t("ipCurrent")}</span>
<span className="ml-auto flex items-center gap-1.5">
<code className="text-xs font-mono bg-muted px-2 py-0.5 rounded">
{user.ipCurrent}
</code>
<Link
href={`/admin/users?search=${user.ipCurrent}`}
title="Find other users with this IP"
title={t("findIp")}
>
<ExternalLink className="h-3 w-3 text-primary" />
</Link>
@@ -88,22 +90,22 @@ export function UserAccountInfo({ user }: UserAccountInfoProps) {
variant={user.twoFactorConfirmed ? "default" : "outline"}
className="ml-auto"
>
{user.twoFactorConfirmed ? "Enabled" : "Disabled"}
{user.twoFactorConfirmed ? t("enabled") : t("disabled")}
</Badge>
</div>
<div className="flex items-center gap-3 text-sm">
<MonitorSmartphone className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-muted-foreground">Machine ID</span>
<span className="text-muted-foreground">{t("machineId")}</span>
<code className="ml-auto text-xs font-mono bg-muted px-2 py-0.5 rounded truncate max-w-[180px]">
{user.machine_id || "N/A"}
{user.machine_id || t("unavailable")}
</code>
</div>
<div className="flex items-center gap-3 text-sm">
<span className="text-muted-foreground">Email</span>
<span className="text-muted-foreground">{t("email")}</span>
<span className="ml-auto font-medium">{user.mail}</span>
</div>
<div className="flex items-center gap-3 text-sm">
<span className="text-muted-foreground">Gender</span>
<span className="text-muted-foreground">{t("gender")}</span>
<Badge variant="outline" className="ml-auto">
{user.gender}
</Badge>
@@ -1,4 +1,5 @@
"use client";
import { useTranslations } from "next-intl";
interface Badge {
id: number;
@@ -6,8 +7,9 @@ interface Badge {
}
export function UserBadgesReadonly({ badges }: { badges: Badge[] }) {
const t = useTranslations("pages.admin.users.presentation");
if (badges.length === 0) {
return <p className="text-sm text-muted-foreground">No badges</p>;
return <p className="text-sm text-muted-foreground">{t("noBadges")}</p>;
}
return (
+45 -23
View File
@@ -10,6 +10,7 @@ import {
WifiOff,
} from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { AdminBreadcrumb } from "@/components/admin/admin-breadcrumb";
import { UserWatchToggle } from "@/components/admin/user-watch-toggle";
import Link from "@/components/link";
@@ -42,6 +43,7 @@ interface UserHeroProps {
activeBan: boolean;
mode: "show" | "edit";
canEdit: boolean;
canViewLogs?: boolean;
/** When non-null, render the watch toggle with this initial state. */
watching?: boolean | null;
}
@@ -58,7 +60,9 @@ export function UserHero({
mode,
canEdit,
watching,
canViewLogs = true,
}: UserHeroProps) {
const t = useTranslations("pages.admin.users.presentation");
const params = useSearchParams();
const list = params.get("list") ?? "";
const listHref = userListHref(list);
@@ -66,9 +70,9 @@ export function UserHero({
<>
<AdminBreadcrumb
items={[
{ label: "Users", href: listHref },
{ label: t("users"), href: listHref },
{ label: user.username },
{ label: mode === "edit" ? "Edit" : "View" },
{ label: mode === "edit" ? t("edit") : t("view") },
]}
/>
@@ -107,14 +111,14 @@ export function UserHero({
{mode === "show" && canEdit && (
<Button variant="outline" size="sm" asChild className="ml-2">
<Link href={userDetailHref(user.id, "edit", list)}>
<Pencil className="mr-1.5 h-3.5 w-3.5" /> Edit
<Pencil className="mr-1.5 h-3.5 w-3.5" /> {t("edit")}
</Link>
</Button>
)}
{mode === "edit" && (
<Button variant="outline" size="sm" asChild className="ml-2">
<Link href={userDetailHref(user.id, "show", list)}>
<Eye className="mr-1.5 h-3.5 w-3.5" /> View
<Eye className="mr-1.5 h-3.5 w-3.5" /> {t("view")}
</Link>
</Button>
)}
@@ -134,22 +138,24 @@ export function UserHero({
)}
<div className="flex flex-wrap items-center gap-2">
<Badge>{user.permission.rankName || `Rank ${user.rank}`}</Badge>
<Badge>
{user.permission.rankName || t("rank", { rank: user.rank })}
</Badge>
<Badge
variant={isOnline ? "default" : "outline"}
className={isOnline ? "bg-[var(--admin-success-subtle)]" : ""}
>
{isOnline ? (
<>
<Wifi className="mr-1 h-3 w-3" /> Online
<Wifi className="mr-1 h-3 w-3" /> {t("online")}
</>
) : (
<>
<WifiOff className="mr-1 h-3 w-3" /> Offline
<WifiOff className="mr-1 h-3 w-3" /> {t("offline")}
</>
)}
</Badge>
{activeBan && <Badge variant="destructive">Banned</Badge>}
{activeBan && <Badge variant="destructive">{t("banned")}</Badge>}
{user.twoFactorConfirmed ? (
<Badge
variant="outline"
@@ -167,24 +173,32 @@ export function UserHero({
<span className="font-semibold">
{formatNumber(user.credits)}
</span>
<span className="text-muted-foreground text-xs">credits</span>
<span className="text-muted-foreground text-xs">
{t("credits")}
</span>
</div>
<div className="flex items-center gap-1.5 text-sm">
<CurrencyIcon kind="diamonds" size={18} />
<span className="font-semibold">{formatNumber(diamonds)}</span>
<span className="text-muted-foreground text-xs">diamonds</span>
<span className="text-muted-foreground text-xs">
{t("diamonds")}
</span>
</div>
<div className="flex items-center gap-1.5 text-sm">
<CurrencyIcon kind="duckets" size={18} />
<span className="font-semibold">{formatNumber(duckets)}</span>
<span className="text-muted-foreground text-xs">duckets</span>
<span className="text-muted-foreground text-xs">
{t("duckets")}
</span>
</div>
<div className="flex items-center gap-1.5 text-sm">
<CurrencyIcon kind="pixels" size={18} />
<span className="font-semibold">
{formatNumber(user.pixels)}
</span>
<span className="text-muted-foreground text-xs">pixels</span>
<span className="text-muted-foreground text-xs">
{t("pixels")}
</span>
</div>
</div>
</div>
@@ -197,18 +211,26 @@ export function UserHero({
>
<DoorOpen className="h-4 w-4 mx-auto text-muted-foreground" />
<p className="text-lg font-bold">{roomCount}</p>
<p className="text-[10px] text-muted-foreground">Rooms</p>
<p className="text-[10px] text-muted-foreground">{t("rooms")}</p>
</Link>
<div className="rounded-lg border bg-card px-3 py-2 text-center">
<MessageSquare className="h-4 w-4 mx-auto text-muted-foreground" />
<p className="text-lg font-bold">{formatNumber(chatCount)}</p>
<p className="text-[10px] text-muted-foreground">Messages</p>
</div>
<div className="rounded-lg border bg-card px-3 py-2 text-center">
<ArrowLeftRight className="h-4 w-4 mx-auto text-muted-foreground" />
<p className="text-lg font-bold">{tradeCount}</p>
<p className="text-[10px] text-muted-foreground">Trades</p>
</div>
{canViewLogs && (
<div className="rounded-lg border bg-card px-3 py-2 text-center">
<MessageSquare className="h-4 w-4 mx-auto text-muted-foreground" />
<p className="text-lg font-bold">{formatNumber(chatCount)}</p>
<p className="text-[10px] text-muted-foreground">
{t("messages")}
</p>
</div>
)}
{canViewLogs && (
<div className="rounded-lg border bg-card px-3 py-2 text-center">
<ArrowLeftRight className="h-4 w-4 mx-auto text-muted-foreground" />
<p className="text-lg font-bold">{tradeCount}</p>
<p className="text-[10px] text-muted-foreground">
{t("trades")}
</p>
</div>
)}
</div>
</div>
</div>
@@ -0,0 +1,123 @@
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
import { formatDate } from "@/lib/format-date";
import type { OverviewSection } from "../_lib/load-user-overview";
function eventDate(value: string | number | Date | null | undefined) {
if (!value) return "—";
const numeric =
typeof value === "number" ||
(typeof value === "string" && /^\d+$/.test(value));
const date = numeric ? new Date(Number(value) * 1000) : new Date(value);
return Number.isNaN(date.getTime()) ? "—" : formatDate(date);
}
export async function UserOperationalOverview({
sections,
}: {
sections: OverviewSection[];
}) {
const t = await getTranslations("pages.admin.users.overview");
return (
<section className="space-y-4" aria-label={t("title")}>
<p className="text-sm text-muted-foreground">{t("hint")}</p>
{sections.length === 0 && (
<p className="rounded-lg border bg-card p-4 text-sm text-muted-foreground">
{t("restricted")}
</p>
)}
<div className="grid gap-4 xl:grid-cols-2">
{sections.map((section) => (
<section
key={section.key}
className="min-w-0 rounded-lg border bg-card p-4"
>
<div className="mb-3 flex items-center justify-between gap-3">
<h3 className="font-semibold text-sm">{t(section.key)}</h3>
{section.href && (
<Link
href={section.href}
className="text-xs text-primary hover:underline"
>
{t("openModule")}
</Link>
)}
</div>
{section.key === "purchases" && (
<p className="mb-2 text-xs text-muted-foreground">
{t("purchaseHint")}
</p>
)}
{section.key === "activity" && (
<p className="mb-2 text-xs text-muted-foreground">
{t("activityHint")}
</p>
)}
{section.state === "error" ? (
<p role="status" className="text-sm text-destructive">
{t("error")}
</p>
) : section.rows.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("empty")}</p>
) : (
<ul className="divide-y">
{section.rows.map((row) => (
<li key={String(row.id)} className="space-y-1 py-2 text-sm">
<div className="flex items-start justify-between gap-2">
{row.href ? (
<Link
href={row.href}
className="min-w-0 break-words font-medium text-primary hover:underline"
>
#{String(row.id)} · {row.title || "—"}
</Link>
) : (
<span className="min-w-0 break-words font-medium">
#{String(row.id)} · {row.title || "—"}
</span>
)}
{row.status && (
<span className="max-w-28 shrink-0 break-words rounded bg-muted px-1.5 py-0.5 text-xs">
{[
"active",
"expired",
"open",
"closed",
"reported",
"reporter",
].includes(row.status)
? t(`status.${row.status}`)
: row.status}
</span>
)}
</div>
{section.key === "purchases" && (
<p className="text-xs text-muted-foreground">
{t("purchaseDetail", {
amount: row.amount ?? "—",
credits: row.credits ?? "—",
points: row.points ?? "—",
type: row.pointsType ?? "—",
})}
</p>
)}
{row.detail && (
<p className="break-words text-muted-foreground">
{row.detail}
</p>
)}
<p className="text-xs text-muted-foreground">
{section.key === "mute"
? t("muteUntil", { date: eventDate(row.at) })
: eventDate(row.at)}
</p>
</li>
))}
</ul>
)}
</section>
))}
</div>
</section>
);
}
@@ -0,0 +1,95 @@
import { MySqlDialect } from "drizzle-orm/mysql-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import type { PermissionSet } from "@/lib/permissions";
const state = vi.hoisted(() => ({
allowed: new Set<string>(),
execute: vi.fn(),
}));
vi.mock("@/lib/db", () => ({ db: { execute: state.execute } }));
vi.mock("@/lib/permissions", async () => ({
...(await import("@/lib/permission-slugs")),
canAccess: (_: unknown, permission: string) => state.allowed.has(permission),
}));
import { loadUserOverview } from "./load-user-overview";
const permissions = {} as PermissionSet;
const dialect = new MySqlDialect();
beforeEach(() => {
state.allowed.clear();
state.execute
.mockReset()
.mockResolvedValue([[{ id: 12, title: "Existing record" }], []]);
});
describe("user operational overview", () => {
it("does not query or expose any restricted source", async () => {
expect(await loadUserOverview(42, permissions, 4)).toEqual([]);
expect(state.execute).not.toHaveBeenCalled();
});
it.each([
[PERMS.BANS_VIEW, ["sanctions"]],
[PERMS.TICKETS_VIEW, ["tickets", "help"]],
[PERMS.MODERATION_VIEW, ["mute", "reports"]],
[PERMS.SHOP_VIEW, ["transactions"]],
[PERMS.LOGS_VIEW, ["purchases", "activity"]],
])("queries only sources allowed by %s", async (permission, keys) => {
state.allowed.add(permission);
const result = await loadUserOverview(42, permissions, 4);
expect(result.map((section) => section.key)).toEqual(keys);
expect(state.execute).toHaveBeenCalledTimes(keys.length);
for (const [query] of state.execute.mock.calls) {
const compiled = dialect.sqlToQuery(query);
expect(compiled.sql).toMatch(/LIMIT 5$/);
expect(compiled.params).toContain(42);
}
});
it("keeps accessible sources available when another query fails", async () => {
state.allowed.add(PERMS.TICKETS_VIEW);
state.execute.mockRejectedValueOnce(new Error("database unavailable"));
const result = await loadUserOverview(42, permissions, 4);
expect(result[0]).toMatchObject({
key: "tickets",
state: "error",
rows: [],
});
expect(result[1]).toMatchObject({
key: "help",
state: "ready",
rows: [{ href: "/admin/help-tickets/12" }],
});
});
it("distinguishes an empty source from a failed source", async () => {
state.allowed.add(PERMS.SHOP_VIEW);
state.execute.mockResolvedValueOnce([[], []]);
expect(await loadUserOverview(42, permissions, 4)).toEqual([
{
key: "transactions",
state: "ready",
rows: [],
href: "/admin/transactions",
},
]);
});
});
it("selects and preserves recorded purchase quantities and currency costs", async () => {
state.allowed.add(PERMS.LOGS_VIEW);
const purchase = {
id: 5,
title: "Chair",
amount: 2,
credits: 10,
points: 3,
pointsType: 5,
};
state.execute.mockResolvedValueOnce([[purchase], []]);
const result = await loadUserOverview(42, permissions, 4);
const query = dialect.sqlToQuery(state.execute.mock.calls[0][0]).sql;
expect(query).toContain(
"amount, cost_credits AS credits, cost_points AS points, points_type AS pointsType",
);
expect(
result.find((section) => section.key === "purchases")?.rows[0],
).toMatchObject(purchase);
});
@@ -0,0 +1,137 @@
import { type SQL, sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { canAccess, PERMS, type PermissionSet } from "@/lib/permissions";
export type OverviewRow = {
id: string | number;
title: string;
detail?: string | null;
status?: string | null;
at?: string | number | Date | null;
href?: string;
amount?: number | null;
credits?: number | null;
points?: number | null;
pointsType?: number | null;
};
export type OverviewSection = {
key: string;
href?: string;
state: "ready" | "error";
rows: OverviewRow[];
};
export async function loadUserOverview(
userId: number,
permissions: PermissionSet,
rank: number,
): Promise<OverviewSection[]> {
const read = async (query: SQL): Promise<OverviewRow[]> => {
const [rows] = await db.execute(query);
return rows as unknown as OverviewRow[];
};
const sources: {
key: string;
permission: string;
href?: string;
query: () => Promise<OverviewRow[]>;
rowHref?: (id: string | number) => string;
}[] = [
{
key: "sanctions",
permission: PERMS.BANS_VIEW,
href: "/admin/bans",
query: () =>
read(
sql`SELECT id, type AS title, LEFT(ban_reason, 180) AS detail, timestamp AS at, CASE WHEN ban_expire = 0 OR ban_expire > UNIX_TIMESTAMP() THEN 'active' ELSE 'expired' END AS status FROM bans WHERE user_id = ${userId} ORDER BY timestamp DESC, id DESC LIMIT 5`,
),
},
{
key: "mute",
permission: PERMS.MODERATION_VIEW,
query: () =>
read(
sql`SELECT user_id AS id, '' AS title, mute_end_timestamp AS at FROM users_settings WHERE user_id = ${userId} AND mute_end_timestamp > UNIX_TIMESTAMP() LIMIT 5`,
),
},
{
key: "tickets",
permission: PERMS.TICKETS_VIEW,
query: () =>
read(
sql`SELECT id, subject AS title, status, updated_at AS at FROM website_tickets WHERE creator_id = ${userId} ORDER BY updated_at DESC, id DESC LIMIT 5`,
),
rowHref: (id) => `/admin/tickets/${id}`,
},
{
key: "help",
permission: PERMS.TICKETS_VIEW,
query: () =>
read(
sql`SELECT id, title, CASE WHEN open = 1 THEN 'open' ELSE 'closed' END AS status, created_at AS at FROM website_help_center_tickets WHERE user_id = ${userId} ORDER BY created_at DESC, id DESC LIMIT 5`,
),
rowHref: (id) => `/admin/help-tickets/${id}`,
},
{
key: "reports",
permission: PERMS.MODERATION_VIEW,
query: () =>
read(
sql`SELECT id, LEFT(issue, 180) AS title, CASE WHEN reported_id = ${userId} THEN 'reported' ELSE 'reporter' END AS status, timestamp AS at FROM support_tickets WHERE reported_id = ${userId} OR sender_id = ${userId} ORDER BY timestamp DESC, id DESC LIMIT 5`,
),
rowHref: (id) => `/admin/moderation/cfh/${id}`,
},
{
key: "transactions",
permission: PERMS.SHOP_VIEW,
href: "/admin/transactions",
query: () =>
read(
sql`SELECT id, COALESCE(description, transaction_id) AS title, CONCAT(amount, ' ', currency) AS detail, status, created_at AS at FROM website_paypal_transactions WHERE user_id = ${userId} ORDER BY created_at DESC, id DESC LIMIT 5`,
),
},
{
key: "purchases",
permission: PERMS.LOGS_VIEW,
query: () =>
read(
sql`SELECT id, catalog_name AS title, amount, cost_credits AS credits, cost_points AS points, points_type AS pointsType, timestamp AS at FROM logs_shop_purchases WHERE user_id = ${userId} ORDER BY timestamp DESC, id DESC LIMIT 5`,
),
},
{
key: "activity",
permission: PERMS.LOGS_VIEW,
href: "/admin/logs/audit",
query: () =>
read(
sql`SELECT id, action AS title, created_at AS at FROM admin_audit_log WHERE target = 'User' AND target_id = ${userId} ORDER BY id DESC LIMIT 5`,
),
},
];
// Filter before invoking the query; an inaccessible source must never touch its table.
return Promise.all(
sources
.filter((source) => canAccess(permissions, source.permission, rank))
.map(async (source) => {
try {
const rows = await source.query();
return {
key: source.key,
href: source.href,
state: "ready" as const,
rows: rows.map((row) => ({
...row,
href: source.rowHref?.(row.id),
})),
};
} catch {
return {
key: source.key,
href: source.href,
state: "error" as const,
rows: [],
};
}
}),
);
}
@@ -0,0 +1,87 @@
import { MySqlDialect } from "drizzle-orm/mysql-core";
import { beforeEach, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
sql: [] as string[],
tables: [] as string[],
failLogs: false,
}));
vi.mock("next/navigation", () => ({
notFound: () => {
throw new Error("not found");
},
}));
vi.mock("@/lib/db", () => {
const tables = Object.fromEntries(
["User", "Rooms", "RoomTradeLog", "UsersBadges", "UsersCurrency"].map(
(name) => [name, { name }],
),
);
return {
...tables,
db: {
select() {
let name = "";
const rows = () =>
name === "User"
? [{ id: 42, username: "User", rank: 2, online: "0" }]
: [];
const query = {
from(table: { name: string }) {
name = table.name;
state.tables.push(name);
return query;
},
where() {
return query;
},
orderBy() {
return query;
},
limit() {
return query;
},
// biome-ignore lint/suspicious/noThenProperty: Drizzle queries are intentionally awaitable.
then(resolve: (value: unknown[]) => unknown) {
return Promise.resolve(rows()).then(resolve);
},
catch() {
return Promise.resolve(rows());
},
};
return query;
},
async execute(query: Parameters<MySqlDialect["sqlToQuery"]>[0]) {
const compiled = new MySqlDialect().sqlToQuery(query).sql;
state.sql.push(compiled);
if (state.failLogs && compiled.includes("chatlogs_room"))
throw new Error("unavailable");
return [[], []];
},
},
};
});
import { loadUserById } from "./load-user";
beforeEach(() => {
state.sql = [];
state.tables = [];
state.failLogs = false;
});
it("skips chat, commands, trade and ban queries when excluded by profile ACL", async () => {
const user = await loadUserById("42", { logs: false, bans: false });
expect(state.sql.join(" ")).not.toMatch(
/chatlogs_room|commandlogs|FROM bans/,
);
expect(state.tables).not.toContain("RoomTradeLog");
expect(user.chatLogs).toEqual([]);
expect(user.commandLogs).toEqual([]);
expect(user.user.bansReceived).toEqual([]);
});
it("keeps the user profile available and identifies a failed activity source", async () => {
state.failLogs = true;
const user = await loadUserById("42", { logs: true, bans: false });
expect(user.user.id).toBe(42);
expect(user.activityUnavailable).toBe(true);
});
+63 -34
View File
@@ -14,7 +14,10 @@ async function rawRows<T>(query: ReturnType<typeof sql>): Promise<T[]> {
return rows ?? [];
}
export async function loadUserById(id: string) {
export async function loadUserById(
id: string,
access: { logs: boolean; bans: boolean },
) {
const numericId = Number(id);
const isNumeric =
Number.isSafeInteger(numericId) &&
@@ -44,6 +47,7 @@ export async function loadUserById(id: string) {
.limit(1);
if (!user) notFound();
let activityUnavailable = false;
const [
currencies,
badges,
@@ -64,30 +68,40 @@ export async function loadUserById(id: string) {
.from(UsersBadges)
.where(eq(UsersBadges.userId, user.id))
.orderBy(asc(UsersBadges.slotId)),
rawRows<{
id: number;
banReason: string;
type: string;
timestamp: number;
banExpire: number;
staffName: string;
}>(sql`
access.bans
? rawRows<{
id: number;
banReason: string;
type: string;
timestamp: number;
banExpire: number;
staffName: string;
}>(sql`
SELECT b.id, b.ban_reason AS banReason, b.type, b.timestamp, b.ban_expire AS banExpire,
COALESCE(s.username, 'Unknown') AS staffName
FROM bans b LEFT JOIN users s ON s.id = b.user_staff_id
WHERE b.user_id = ${user.id} ORDER BY b.id DESC LIMIT 5`),
WHERE b.user_id = ${user.id} ORDER BY b.id DESC LIMIT 5`).catch(
() => [],
)
: [],
rawRows<{ rankName: string }>(sql`
SELECT COALESCE(rank_name, CONCAT('Rank ', id)) AS rankName FROM permissions WHERE id = ${user.rank} LIMIT 1`),
db.select({ total: count() }).from(Rooms).where(eq(Rooms.ownerId, user.id)),
db
.select({ total: count() })
.from(RoomTradeLog)
.where(
or(
eq(RoomTradeLog.userOneId, user.id),
eq(RoomTradeLog.userTwoId, user.id),
),
),
access.logs
? db
.select({ total: count() })
.from(RoomTradeLog)
.where(
or(
eq(RoomTradeLog.userOneId, user.id),
eq(RoomTradeLog.userTwoId, user.id),
),
)
.catch(() => {
activityUnavailable = true;
return [];
})
: [],
db
.select({
id: Rooms.id,
@@ -100,23 +114,37 @@ export async function loadUserById(id: string) {
.where(eq(Rooms.ownerId, user.id))
.orderBy(desc(Rooms.users))
.limit(20),
rawRows<{
id: number;
roomId: number;
timestamp: number;
message: string;
}>(sql`
access.logs
? rawRows<{
id: number;
roomId: number;
timestamp: number;
message: string;
}>(sql`
SELECT ROW_NUMBER() OVER (ORDER BY timestamp DESC) AS id, room_id AS roomId, timestamp, message
FROM chatlogs_room WHERE user_from_id = ${user.id} ORDER BY timestamp DESC LIMIT 20`),
rawRows<{
id: number;
timestamp: number;
command: string;
params: string;
succes: string;
}>(sql`
FROM chatlogs_room WHERE user_from_id = ${user.id} ORDER BY timestamp DESC LIMIT 20`).catch(
() => {
activityUnavailable = true;
return [];
},
)
: [],
access.logs
? rawRows<{
id: number;
timestamp: number;
command: string;
params: string;
succes: string;
}>(sql`
SELECT ROW_NUMBER() OVER (ORDER BY timestamp DESC) AS id, timestamp, command, params, succes
FROM commandlogs WHERE user_id = ${user.id} ORDER BY timestamp DESC LIMIT 10`),
FROM commandlogs WHERE user_id = ${user.id} ORDER BY timestamp DESC LIMIT 10`).catch(
() => {
activityUnavailable = true;
return [];
},
)
: [],
]);
const roomCount = Number(roomCountRows[0]?.total ?? 0);
@@ -157,6 +185,7 @@ export async function loadUserById(id: string) {
userRooms,
chatLogs,
commandLogs,
activityUnavailable,
isOnline: user.online === "1",
activeBan,
};
+58 -28
View File
@@ -1,5 +1,6 @@
import { sql } from "drizzle-orm";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { db } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
@@ -25,7 +26,18 @@ export default async function UserEditPage({
redirect("/admin");
}
const data = await loadUserById(id);
const overviewT = await getTranslations("pages.admin.users.overview");
const canViewLogs = canAccess(
permissions,
PERMS.LOGS_VIEW,
session.user.rank,
);
const canViewBans = canAccess(
permissions,
PERMS.BANS_VIEW,
session.user.rank,
);
const data = await loadUserById(id, { logs: canViewLogs, bans: canViewBans });
const watched = await getWatchedUserIds(session.user.id);
// Fetch rank list from permissions table for the rank dropdown
@@ -45,6 +57,7 @@ export default async function UserEditPage({
duckets={data.duckets}
roomCount={data.roomCount}
chatCount={data.chatCount}
canViewLogs={canViewLogs && !data.activityUnavailable}
tradeCount={data.tradeCount}
isOnline={data.isOnline}
activeBan={!!data.activeBan}
@@ -57,17 +70,21 @@ export default async function UserEditPage({
<Tabs defaultValue="edit" className="space-y-4 min-w-0">
<TabsList className="flex-wrap">
<TabsTrigger value="edit">Edit Profile</TabsTrigger>
<TabsTrigger value="bans">
Bans
{data.activeBan && (
<span className="ml-1.5 flex h-2 w-2 rounded-full bg-destructive" />
)}
</TabsTrigger>
{canViewBans && (
<TabsTrigger value="bans">
Bans
{data.activeBan && (
<span className="ml-1.5 flex h-2 w-2 rounded-full bg-destructive" />
)}
</TabsTrigger>
)}
<TabsTrigger value="badges">
Badges ({data.user.badges.length})
</TabsTrigger>
<TabsTrigger value="rooms">Rooms ({data.roomCount})</TabsTrigger>
<TabsTrigger value="activity">Activity</TabsTrigger>
{canViewLogs && (
<TabsTrigger value="activity">Activity</TabsTrigger>
)}
<TabsTrigger value="info">Account Info</TabsTrigger>
</TabsList>
@@ -88,20 +105,22 @@ export default async function UserEditPage({
/>
</TabsContent>
<TabsContent value="bans">
<UserBanSection
userId={data.user.id}
username={data.user.username}
bans={data.user.bansReceived.map((b) => ({
id: b.id,
reason: b.banReason,
type: b.type,
staffName: b.staff.username,
timestamp: b.timestamp,
banExpire: b.banExpire,
}))}
/>
</TabsContent>
{canViewBans && (
<TabsContent value="bans">
<UserBanSection
userId={data.user.id}
username={data.user.username}
bans={data.user.bansReceived.map((b) => ({
id: b.id,
reason: b.banReason,
type: b.type,
staffName: b.staff.username,
timestamp: b.timestamp,
banExpire: b.banExpire,
}))}
/>
</TabsContent>
)}
<TabsContent value="badges">
<UserBadgesSection
@@ -117,12 +136,23 @@ export default async function UserEditPage({
<UserRooms rooms={data.userRooms} />
</TabsContent>
<TabsContent value="activity">
<UserActivityLog
chatLogs={data.chatLogs}
commandLogs={data.commandLogs}
/>
</TabsContent>
{canViewLogs && (
<TabsContent value="activity">
{data.activityUnavailable ? (
<p
role="status"
className="rounded-lg border bg-card p-4 text-sm text-destructive"
>
{overviewT("error")}
</p>
) : (
<UserActivityLog
chatLogs={data.chatLogs}
commandLogs={data.commandLogs}
/>
)}
</TabsContent>
)}
<TabsContent value="info">
<UserAccountInfo user={data.user} />
+41 -23
View File
@@ -6,9 +6,9 @@ import { getWatchedUserIds } from "@/lib/services/watch";
import { UserAccountInfo } from "../../_components/user-account-info";
import { UserBadgesReadonly } from "../../_components/user-badges-readonly";
import { UserHero } from "../../_components/user-hero";
import { UserSanctionTimeline } from "../../_components/user-sanction-timeline";
import { UserOperationalOverview } from "../../_components/user-operational-overview";
import { loadUserById } from "../../_lib/load-user";
import { loadUserSanctions } from "../../_lib/load-user-sanctions";
import { loadUserOverview } from "../../_lib/load-user-overview";
import { UserActivityLog } from "../../[id]/user-activity-log";
import { UserQuickActions } from "../../[id]/user-quick-actions";
import { UserRooms } from "../../[id]/user-rooms";
@@ -25,10 +25,19 @@ export default async function UserShowPage({
}
const t = await getTranslations("pages.admin.users.detail");
const overviewT = await getTranslations("pages.admin.users.overview");
const canEdit = canAccess(permissions, PERMS.USERS_EDIT, session.user.rank);
const data = await loadUserById(id);
const [sanctions, watched] = await Promise.all([
loadUserSanctions(data.user.id),
const canViewLogs = canAccess(
permissions,
PERMS.LOGS_VIEW,
session.user.rank,
);
const data = await loadUserById(id, {
logs: canViewLogs,
bans: canAccess(permissions, PERMS.BANS_VIEW, session.user.rank),
});
const [overview, watched] = await Promise.all([
loadUserOverview(data.user.id, permissions, session.user.rank),
getWatchedUserIds(session.user.id),
]);
@@ -40,6 +49,7 @@ export default async function UserShowPage({
duckets={data.duckets}
roomCount={data.roomCount}
chatCount={data.chatCount}
canViewLogs={canViewLogs && !data.activityUnavailable}
tradeCount={data.tradeCount}
isOnline={data.isOnline}
activeBan={!!data.activeBan}
@@ -55,8 +65,9 @@ export default async function UserShowPage({
: undefined
}
>
<Tabs defaultValue="info" className="space-y-4 min-w-0">
<Tabs defaultValue="overview" className="space-y-4 min-w-0">
<TabsList className="h-auto flex-wrap justify-start">
<TabsTrigger value="overview">{overviewT("title")}</TabsTrigger>
<TabsTrigger value="info">{t("tabInfo")}</TabsTrigger>
<TabsTrigger value="badges">
{t("tabBadges", { count: data.user.badges.length })}
@@ -64,12 +75,15 @@ export default async function UserShowPage({
<TabsTrigger value="rooms">
{t("tabRooms", { count: data.roomCount })}
</TabsTrigger>
<TabsTrigger value="sanctions">
{t("tabSanctions", { count: sanctions.events.length })}
</TabsTrigger>
<TabsTrigger value="activity">{t("tabActivity")}</TabsTrigger>
{canViewLogs && (
<TabsTrigger value="activity">{t("tabActivity")}</TabsTrigger>
)}
</TabsList>
<TabsContent value="overview">
<UserOperationalOverview sections={overview} />
</TabsContent>
<TabsContent value="info">
<UserAccountInfo user={data.user} />
</TabsContent>
@@ -87,19 +101,23 @@ export default async function UserShowPage({
<UserRooms rooms={data.userRooms} />
</TabsContent>
<TabsContent value="sanctions">
<UserSanctionTimeline
events={sanctions.events}
muteUntil={sanctions.muteUntil}
/>
</TabsContent>
<TabsContent value="activity">
<UserActivityLog
chatLogs={data.chatLogs}
commandLogs={data.commandLogs}
/>
</TabsContent>
{canViewLogs && (
<TabsContent value="activity">
{data.activityUnavailable ? (
<p
role="status"
className="rounded-lg border bg-card p-4 text-sm text-destructive"
>
{overviewT("error")}
</p>
) : (
<UserActivityLog
chatLogs={data.chatLogs}
commandLogs={data.commandLogs}
/>
)}
</TabsContent>
)}
</Tabs>
{canEdit && (
+247
View File
@@ -0,0 +1,247 @@
import { MySqlDialect } from "drizzle-orm/mysql-core";
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
select: vi.fn(),
guard: vi.fn(),
permissions: new Set<string>(),
rateLimit: vi.fn(),
}));
vi.mock("@/lib/db", async () => ({
...(await import("@/db/schema")),
db: { select: mocks.select },
}));
vi.mock("@/lib/permissions", async () => ({
...(await import("@/lib/permission-slugs")),
canAccess: (permissions: { has: (slug: string) => boolean }, slug: string) =>
permissions.has(slug),
}));
vi.mock("@/lib/rate-limit", () => ({ rateLimit: mocks.rateLimit }));
vi.mock("@/lib/api-handler", () => ({
withAdmin: (
options: unknown,
handler: (request: NextRequest, context: unknown) => Promise<Response>,
) => {
mocks.guard(options);
return (request: NextRequest) =>
handler(request, {
session: { user: { id: 1, rank: 5 } },
permissions: {
isSuperAdmin: false,
has: (slug: string) => mocks.permissions.has(slug),
},
});
},
}));
import {
CatalogPages,
CatalogPagesBc,
Guilds,
ItemsBase,
User,
WebsiteArticles,
WebsiteHelpCenterTickets,
WebsiteTicket,
} from "@/lib/db";
import { PERMS } from "@/lib/permission-slugs";
import { GET } from "./route";
const calls: {
table: unknown;
fields: Record<string, unknown>;
condition: unknown;
limit: number;
}[] = [];
const dialect = new MySqlDialect();
const rows = new Map<unknown, unknown[]>();
beforeEach(() => {
mocks.permissions.clear();
mocks.rateLimit.mockResolvedValue({ ok: true });
mocks.select.mockReset();
rows.clear();
calls.length = 0;
mocks.select.mockImplementation((fields) => {
const call = {
table: undefined as unknown,
fields,
condition: undefined as unknown,
limit: 0,
};
const builder = {
from(table: unknown) {
call.table = table;
return builder;
},
where(condition: unknown) {
call.condition = condition;
return builder;
},
orderBy() {
return builder;
},
limit(limit: number) {
call.limit = limit;
calls.push(call);
return Promise.resolve(rows.get(call.table) ?? []);
},
};
return builder;
});
});
const search = (q: string) =>
GET(
new NextRequest(
`https://cms.test/api/admin/search?q=${encodeURIComponent(q)}`,
),
);
describe("unified admin search", () => {
it("requires the staff dashboard gate and performs no entity reads without category permission", async () => {
expect(mocks.guard).toHaveBeenCalledWith({
permission: PERMS.ADMIN_DASHBOARD,
});
expect(await (await search("chair")).json()).toEqual({
ok: true,
results: [],
partial: false,
});
expect(mocks.select).not.toHaveBeenCalled();
});
it.each([
[PERMS.USERS_VIEW, [User, Guilds]],
[PERMS.NEWS_VIEW, [WebsiteArticles]],
[PERMS.CATALOG_VIEW, [ItemsBase, CatalogPages, CatalogPagesBc]],
[PERMS.TICKETS_VIEW, [WebsiteTicket, WebsiteHelpCenterTickets]],
])("reads only categories granted by %s", async (permission, tables) => {
mocks.permissions.add(permission);
await search("chair");
expect(calls.map((call) => call.table)).toEqual(tables);
expect(calls.every((call) => call.limit === 5)).toBe(true);
});
it("returns exact detail destinations without private user or ticket fields", async () => {
[PERMS.USERS_VIEW, PERMS.CATALOG_VIEW, PERMS.TICKETS_VIEW].forEach((p) => {
mocks.permissions.add(p);
});
rows.set(User, [{ id: 12, title: "Alice" }]);
rows.set(ItemsBase, [{ id: 12, title: "Chair", subtitle: "chair" }]);
rows.set(CatalogPagesBc, [{ id: 12, title: "Chairs" }]);
rows.set(WebsiteHelpCenterTickets, [{ id: 12n, title: "Help" }]);
const payload = await (await search("12")).json();
expect(payload.results.map((r: { url: string }) => r.url)).toEqual([
"/admin/users/show/12",
"/admin/items/12",
"/admin/catalog/builder-club/12",
"/admin/help-tickets/12",
]);
expect(calls.find((call) => call.table === User)?.fields).toEqual({
id: User.id,
title: User.username,
});
expect(
calls.find((call) => call.table === WebsiteHelpCenterTickets)?.fields,
).toEqual({
id: WebsiteHelpCenterTickets.id,
title: WebsiteHelpCenterTickets.title,
});
});
it("ignores short queries and treats wildcard input literally", async () => {
mocks.permissions.add(PERMS.USERS_VIEW);
await search(" a ");
expect(calls).toHaveLength(0);
await search("_%");
const query = dialect.sqlToQuery(
calls[0].condition as Parameters<MySqlDialect["sqlToQuery"]>[0],
);
expect(query.params).toEqual(["%\\_\\%%"]);
expect(query.sql).not.toContain("mail");
});
it("bounds oversized queries and rejects partial numeric identifiers", async () => {
mocks.permissions.add(PERMS.USERS_VIEW);
await search("a".repeat(200));
expect(
dialect.sqlToQuery(
calls[0].condition as Parameters<MySqlDialect["sqlToQuery"]>[0],
).params,
).toEqual([`%${"a".repeat(64)}%`]);
await search("12abc");
expect(
dialect.sqlToQuery(
calls[2].condition as Parameters<MySqlDialect["sqlToQuery"]>[0],
).params,
).toEqual(["%12abc%"]);
});
it("supports one-digit identifiers", async () => {
mocks.permissions.add(PERMS.USERS_VIEW);
await search("1");
expect(
dialect.sqlToQuery(
calls[0].condition as Parameters<MySqlDialect["sqlToQuery"]>[0],
).params,
).toEqual(["%1%", 1]);
});
it("reports a partial failure while retaining authorized results", async () => {
mocks.permissions.add(PERMS.CATALOG_VIEW);
rows.set(ItemsBase, [{ id: 12, title: "Chair", subtitle: "chair" }]);
const implementation = mocks.select.getMockImplementation();
mocks.select.mockImplementation((fields) => {
const builder = implementation?.(fields);
const originalFrom = builder.from;
builder.from = (table: unknown) => {
originalFrom(table);
if (table === CatalogPagesBc)
builder.limit = () => Promise.reject(new Error("missing table"));
return builder;
};
return builder;
});
const payload = await (await search("chair")).json();
expect(payload.partial).toBe(true);
expect(payload.results).toHaveLength(1);
expect(payload.results[0].url).toBe("/admin/items/12");
});
it("caps the combined results while retaining each requested category", async () => {
[
PERMS.USERS_VIEW,
PERMS.NEWS_VIEW,
PERMS.CATALOG_VIEW,
PERMS.TICKETS_VIEW,
].forEach((permission) => {
mocks.permissions.add(permission);
});
[
User,
WebsiteArticles,
ItemsBase,
CatalogPages,
CatalogPagesBc,
WebsiteTicket,
WebsiteHelpCenterTickets,
].forEach((table) => {
rows.set(
table,
Array.from({ length: 5 }, (_, index) => ({
id: index + 1,
title: "Match",
subtitle: "Name",
})),
);
});
const payload = await (await search("Match")).json();
expect(payload.results).toHaveLength(20);
expect(
new Set(payload.results.map((row: { type: string }) => row.type)),
).toEqual(
new Set(["users", "articles", "furniture", "catalog", "tickets"]),
);
});
it("rate limits without reading records", async () => {
mocks.permissions.add(PERMS.USERS_VIEW);
mocks.rateLimit.mockResolvedValue({ ok: false, retryAfter: 20 });
expect((await search("Alice")).status).toBe(429);
expect(mocks.select).not.toHaveBeenCalled();
});
});
+314 -125
View File
@@ -1,20 +1,24 @@
import { eq, like, or } from "drizzle-orm";
import { asc, eq, like, or } from "drizzle-orm";
import { NextResponse } from "next/server";
import { ADMIN_NAV_GROUPS, navItemIsAllowed } from "@/lib/admin-nav";
import { apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import {
CatalogPages,
CatalogPagesBc,
db,
Guilds,
ItemsBase,
Rooms,
User,
WebsiteArticles,
WebsiteHelpCenterTickets,
WebsiteRareValues,
WebsiteShopArticles,
WebsiteTicket,
} from "@/lib/db";
import { canAccess, PERMS } from "@/lib/permissions";
import { rateLimit } from "@/lib/rate-limit";
export type AdminSearchResult = {
type: string;
id: number | string;
@@ -22,16 +26,13 @@ export type AdminSearchResult = {
subtitle: string;
url: string;
};
const PER_TYPE = 5;
const MAX_TOTAL = 20;
const MAX_QUERY_LENGTH = 64;
/** Escape LIKE wildcards so user input can't widen the match. */
export function escapeLike(input: string): string {
function escapeLike(input: string): string {
return input.replace(/[\\%_]/g, (m) => `\\${m}`);
}
export const GET = withAdmin(
{ permission: PERMS.ADMIN_DASHBOARD },
async (request, context) => {
@@ -46,14 +47,12 @@ export const GET = withAdmin(
{ status: 429, headers: { "retry-after": String(limited.retryAfter) } },
);
}
const q = (request.nextUrl.searchParams.get("q") || "")
.trim()
.slice(0, MAX_QUERY_LENGTH);
if (q.length < 2) {
if (q.length < 2 && !/^[1-9]$/.test(q)) {
return apiOk({ results: [] });
}
const canSearch = (href: string) => {
const item = ADMIN_NAV_GROUPS.flatMap((group) => group.items).find(
(item) => item.href === href,
@@ -67,123 +66,272 @@ export const GET = withAdmin(
})
);
};
const pattern = `%${escapeLike(q)}%`;
const idExact = Number.parseInt(q, 10);
const hasId = Number.isFinite(idExact) && String(idExact) === q;
const [users, articles, rooms, guilds, shopArticles, rareValues] =
await Promise.all([
canSearch("/admin/users")
? db
.select({
id: User.id,
title: User.username,
subtitle: User.mail,
})
.from(User)
.where(
or(
like(User.username, pattern),
like(User.mail, pattern),
...(hasId ? [eq(User.id, idExact)] : []),
),
)
.limit(PER_TYPE)
: Promise.resolve([]),
canSearch("/admin/articles")
? db
.select({
id: WebsiteArticles.id,
title: WebsiteArticles.title,
subtitle: WebsiteArticles.slug,
})
.from(WebsiteArticles)
.where(
or(
like(WebsiteArticles.title, pattern),
like(WebsiteArticles.slug, pattern),
),
)
.limit(PER_TYPE)
: Promise.resolve([]),
canSearch("/admin/rooms")
? db
.select({
id: Rooms.id,
title: Rooms.name,
subtitle: Rooms.ownerName,
})
.from(Rooms)
.where(
or(
like(Rooms.name, pattern),
...(hasId ? [eq(Rooms.id, idExact)] : []),
),
)
.limit(PER_TYPE)
: Promise.resolve([]),
canSearch("/admin/guilds")
? db
.select({
id: Guilds.id,
title: Guilds.name,
subtitle: Guilds.description,
})
.from(Guilds)
.where(
or(
like(Guilds.name, pattern),
...(hasId ? [eq(Guilds.id, idExact)] : []),
),
)
.limit(PER_TYPE)
: Promise.resolve([]),
canSearch("/admin/shop")
? db
.select({
id: WebsiteShopArticles.id,
title: WebsiteShopArticles.name,
subtitle: WebsiteShopArticles.info,
})
.from(WebsiteShopArticles)
.where(
or(
like(WebsiteShopArticles.name, pattern),
...(hasId
? [eq(WebsiteShopArticles.id, BigInt(idExact))]
: []),
),
)
.limit(PER_TYPE)
: Promise.resolve([]),
canSearch("/admin/rare-values")
? db
.select({
id: WebsiteRareValues.id,
title: WebsiteRareValues.name,
subtitle: WebsiteRareValues.itemId,
})
.from(WebsiteRareValues)
.where(
or(
like(WebsiteRareValues.name, pattern),
...(hasId ? [eq(WebsiteRareValues.itemId, idExact)] : []),
),
)
.limit(PER_TYPE)
: Promise.resolve([]),
]);
const hasId =
Number.isSafeInteger(idExact) && idExact > 0 && String(idExact) === q;
let partial = false;
const [
users,
articles,
rooms,
guilds,
shopArticles,
rareValues,
furniture,
catalog,
catalogBc,
tickets,
helpTickets,
] = await Promise.all([
canSearch("/admin/users")
? db
.select({
id: User.id,
title: User.username,
})
.from(User)
.where(
or(
like(User.username, pattern),
...(hasId ? [eq(User.id, idExact)] : []),
),
)
.orderBy(asc(User.id))
.limit(PER_TYPE)
.catch(() => {
partial = true;
return [];
})
: Promise.resolve([]),
canSearch("/admin/articles")
? db
.select({
id: WebsiteArticles.id,
title: WebsiteArticles.title,
subtitle: WebsiteArticles.slug,
})
.from(WebsiteArticles)
.where(
or(
like(WebsiteArticles.title, pattern),
like(WebsiteArticles.slug, pattern),
),
)
.orderBy(asc(WebsiteArticles.id))
.limit(PER_TYPE)
.catch(() => {
partial = true;
return [];
})
: Promise.resolve([]),
canSearch("/admin/rooms")
? db
.select({
id: Rooms.id,
title: Rooms.name,
subtitle: Rooms.ownerName,
})
.from(Rooms)
.where(
or(
like(Rooms.name, pattern),
...(hasId ? [eq(Rooms.id, idExact)] : []),
),
)
.orderBy(asc(Rooms.id))
.limit(PER_TYPE)
.catch(() => {
partial = true;
return [];
})
: Promise.resolve([]),
canSearch("/admin/guilds")
? db
.select({
id: Guilds.id,
title: Guilds.name,
subtitle: Guilds.description,
})
.from(Guilds)
.where(
or(
like(Guilds.name, pattern),
...(hasId ? [eq(Guilds.id, idExact)] : []),
),
)
.orderBy(asc(Guilds.id))
.limit(PER_TYPE)
.catch(() => {
partial = true;
return [];
})
: Promise.resolve([]),
canSearch("/admin/shop")
? db
.select({
id: WebsiteShopArticles.id,
title: WebsiteShopArticles.name,
subtitle: WebsiteShopArticles.info,
})
.from(WebsiteShopArticles)
.where(
or(
like(WebsiteShopArticles.name, pattern),
...(hasId ? [eq(WebsiteShopArticles.id, BigInt(idExact))] : []),
),
)
.orderBy(asc(WebsiteShopArticles.id))
.limit(PER_TYPE)
.catch(() => {
partial = true;
return [];
})
: Promise.resolve([]),
canSearch("/admin/rare-values")
? db
.select({
id: WebsiteRareValues.id,
title: WebsiteRareValues.name,
subtitle: WebsiteRareValues.itemId,
})
.from(WebsiteRareValues)
.where(
or(
like(WebsiteRareValues.name, pattern),
...(hasId ? [eq(WebsiteRareValues.itemId, idExact)] : []),
),
)
.orderBy(asc(WebsiteRareValues.id))
.limit(PER_TYPE)
.catch(() => {
partial = true;
return [];
})
: Promise.resolve([]),
canAccess(
context.permissions,
PERMS.CATALOG_VIEW,
context.session.user.rank,
)
? db
.select({
id: ItemsBase.id,
title: ItemsBase.publicName,
subtitle: ItemsBase.itemName,
})
.from(ItemsBase)
.where(
or(
like(ItemsBase.publicName, pattern),
like(ItemsBase.itemName, pattern),
...(hasId ? [eq(ItemsBase.id, idExact)] : []),
),
)
.orderBy(asc(ItemsBase.id))
.limit(PER_TYPE)
.catch(() => {
partial = true;
return [];
})
: Promise.resolve([]),
canAccess(
context.permissions,
PERMS.CATALOG_VIEW,
context.session.user.rank,
)
? db
.select({ id: CatalogPages.id, title: CatalogPages.caption })
.from(CatalogPages)
.where(
or(
like(CatalogPages.caption, pattern),
...(hasId ? [eq(CatalogPages.id, idExact)] : []),
),
)
.orderBy(asc(CatalogPages.id))
.limit(PER_TYPE)
.catch(() => {
partial = true;
return [];
})
: Promise.resolve([]),
canAccess(
context.permissions,
PERMS.CATALOG_VIEW,
context.session.user.rank,
)
? db
.select({ id: CatalogPagesBc.id, title: CatalogPagesBc.caption })
.from(CatalogPagesBc)
.where(
or(
like(CatalogPagesBc.caption, pattern),
...(hasId ? [eq(CatalogPagesBc.id, idExact)] : []),
),
)
.orderBy(asc(CatalogPagesBc.id))
.limit(PER_TYPE)
.catch(() => {
partial = true;
return [];
})
: Promise.resolve([]),
canAccess(
context.permissions,
PERMS.TICKETS_VIEW,
context.session.user.rank,
)
? db
.select({ id: WebsiteTicket.id, title: WebsiteTicket.subject })
.from(WebsiteTicket)
.where(
or(
like(WebsiteTicket.subject, pattern),
...(hasId ? [eq(WebsiteTicket.id, idExact)] : []),
),
)
.orderBy(asc(WebsiteTicket.id))
.limit(PER_TYPE)
.catch(() => {
partial = true;
return [];
})
: Promise.resolve([]),
canAccess(
context.permissions,
PERMS.TICKETS_VIEW,
context.session.user.rank,
)
? db
.select({
id: WebsiteHelpCenterTickets.id,
title: WebsiteHelpCenterTickets.title,
})
.from(WebsiteHelpCenterTickets)
.where(
or(
like(WebsiteHelpCenterTickets.title, pattern),
...(hasId
? [eq(WebsiteHelpCenterTickets.id, BigInt(idExact))]
: []),
),
)
.orderBy(asc(WebsiteHelpCenterTickets.id))
.limit(PER_TYPE)
.catch(() => {
partial = true;
return [];
})
: Promise.resolve([]),
]);
const groupMap: Record<
string,
{ type: string; items: Array<AdminSearchResult> }
> = {
furniture: { type: "furniture", items: [] },
catalog: { type: "catalog", items: [] },
tickets: { type: "tickets", items: [] },
users: { type: "users", items: [] },
articles: { type: "articles", items: [] },
rooms: { type: "rooms", items: [] },
@@ -191,13 +339,12 @@ export const GET = withAdmin(
shop: { type: "shop", items: [] },
rareValues: { type: "rareValues", items: [] },
};
for (const r of users) {
groupMap.users.items.push({
type: "users",
id: r.id,
title: r.title,
subtitle: r.subtitle || "",
subtitle: `#${r.id}`,
url: `/admin/users/show/${r.id}`,
});
}
@@ -246,10 +393,53 @@ export const GET = withAdmin(
url: `/admin/rare-values/${r.id}`,
});
}
for (const r of furniture) {
groupMap.furniture.items.push({
type: "furniture",
id: r.id,
title: r.title || r.subtitle || `#${r.id}`,
subtitle: `${r.subtitle || ""} · #${r.id}`,
url: `/admin/items/${r.id}`,
});
}
for (const [rows, kind] of [
[catalog, "normal"],
[catalogBc, "bc"],
] as const) {
for (const r of rows)
groupMap.catalog.items.push({
type: "catalog",
id: `${kind}-${r.id}`,
title: r.title || `#${r.id}`,
subtitle: kind === "bc" ? `BC · #${r.id}` : `#${r.id}`,
url:
kind === "bc"
? `/admin/catalog/builder-club/${r.id}`
: `/admin/catalog/${r.id}`,
});
}
for (const r of tickets)
groupMap.tickets.items.push({
type: "tickets",
id: `cms-${r.id}`,
title: r.title,
subtitle: `#${r.id}`,
url: `/admin/tickets/${r.id}`,
});
for (const r of helpTickets)
groupMap.tickets.items.push({
type: "tickets",
id: `help-${r.id}`,
title: r.title,
subtitle: `#${r.id}`,
url: `/admin/help-tickets/${r.id}`,
});
const results: AdminSearchResult[] = [];
const order = [
"users",
"furniture",
"catalog",
"tickets",
"articles",
"rooms",
"guilds",
@@ -268,7 +458,6 @@ export const GET = withAdmin(
}
if (!added) break;
}
return apiOk({ results });
return apiOk({ results, partial });
},
);