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 && (