feat(hk): expand search and add operational user overview

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

File diff suppressed because it is too large. Load diff
@@ -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 (
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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);
});
File diff suppressed because it is too large. Load diff
@@ -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);
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff