feat(hk): persist named table views for staff accounts
This commit is contained in:
1 parent
74223984dc
commit
a647b5451f
7 files changed
+480
No files matched your search
@@ -0,0 +1,7 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS website_admin_table_views (
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
path VARCHAR(191) NOT NULL,
|
||||||
|
name VARCHAR(60) NOT NULL,
|
||||||
|
state TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, path, name)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import type { SQL } from "drizzle-orm";
|
||||||
|
import { MySqlDialect } from "drizzle-orm/mysql-core";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
allowed: true,
|
||||||
|
queries: [] as { sql: string; params: unknown[] }[],
|
||||||
|
rows: [] as unknown[],
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/auth", () => ({ auth: vi.fn() }));
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
getApiAdminContext: async () => ({
|
||||||
|
session: { user: { id: 42, rank: 1 } },
|
||||||
|
permissions: {},
|
||||||
|
}),
|
||||||
|
canAccess: () => state.allowed,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/admin/authorization-events", () => ({
|
||||||
|
logAuthorizationEvent: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/rate-limit", () => ({ rateLimit: async () => ({ ok: true }) }));
|
||||||
|
vi.mock("@/lib/report-error", () => ({ reportError: vi.fn() }));
|
||||||
|
vi.mock("@/lib/foundation/security", () => ({
|
||||||
|
extractClientIpAsync: async () => "127.0.0.1",
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/db", () => {
|
||||||
|
const execute = async (sql: SQL) => {
|
||||||
|
const query = new MySqlDialect().sqlToQuery(sql);
|
||||||
|
state.queries.push(query);
|
||||||
|
return [state.rows];
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
db: {
|
||||||
|
execute,
|
||||||
|
transaction: async (fn: (tx: { execute: typeof execute }) => unknown) =>
|
||||||
|
fn({ execute }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import {
|
||||||
|
deleteTableView,
|
||||||
|
listTableViews,
|
||||||
|
saveTableView,
|
||||||
|
} from "./admin-table-views";
|
||||||
|
|
||||||
|
describe("saved table views access and ownership", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
state.allowed = true;
|
||||||
|
state.rows = [];
|
||||||
|
state.queries = [];
|
||||||
|
});
|
||||||
|
it("denies ordinary signed-in users without staff permissions before SQL", async () => {
|
||||||
|
state.allowed = false;
|
||||||
|
expect((await listTableViews({ path: "/admin/users" })).ok).toBe(false);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await saveTableView({
|
||||||
|
path: "/admin/users",
|
||||||
|
name: "Mine",
|
||||||
|
state: { params: {}, hidden: [] },
|
||||||
|
})
|
||||||
|
).ok,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
(await deleteTableView({ path: "/admin/users", name: "Mine" })).ok,
|
||||||
|
).toBe(false);
|
||||||
|
expect(state.queries).toHaveLength(0);
|
||||||
|
});
|
||||||
|
it("scopes reads and deletes to the session owner, table, and name", async () => {
|
||||||
|
await listTableViews({ path: "/admin/users" });
|
||||||
|
expect(state.queries[0].params).toEqual([42, "/admin/users"]);
|
||||||
|
expect(state.queries[0].sql).toContain("WHERE user_id = ? AND path = ?");
|
||||||
|
await deleteTableView({ path: "/admin/users", name: "Mine" });
|
||||||
|
expect(state.queries[1].params).toEqual([42, "/admin/users", "Mine"]);
|
||||||
|
expect(state.queries[1].sql).toContain(
|
||||||
|
"WHERE user_id = ? AND path = ? AND name = ?",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("serializes changes on the owner row and stores only validated state", async () => {
|
||||||
|
const view = {
|
||||||
|
path: "/admin/users",
|
||||||
|
name: "Mine",
|
||||||
|
state: { params: { search: "Alice" }, hidden: ["mail"] },
|
||||||
|
};
|
||||||
|
expect((await saveTableView(view)).ok).toBe(true);
|
||||||
|
expect(state.queries[0]).toMatchObject({
|
||||||
|
sql: "SELECT id FROM users WHERE id = ? FOR UPDATE",
|
||||||
|
params: [42],
|
||||||
|
});
|
||||||
|
expect(state.queries[2].params).toEqual([
|
||||||
|
42,
|
||||||
|
"/admin/users",
|
||||||
|
"Mine",
|
||||||
|
JSON.stringify(view.state),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
it("rejects invalid stored states without returning them as usable views", async () => {
|
||||||
|
state.rows = [
|
||||||
|
{ name: "Broken", state: '{"params":{"token":"secret"},"hidden":[]}' },
|
||||||
|
];
|
||||||
|
expect(await listTableViews({ path: "/admin/users" })).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
data: { views: [] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"use server";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { namedTableView, tableViewPath } from "@/lib/admin/table-view-state";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
|
import { adminAction } from "@/lib/safe-action";
|
||||||
|
|
||||||
|
const tableViewPermissions = [
|
||||||
|
PERMS.ADMIN_DASHBOARD,
|
||||||
|
PERMS.MOD_DASHBOARD,
|
||||||
|
PERMS.MODERATION_VIEW,
|
||||||
|
PERMS.MOD_CFH_VIEW,
|
||||||
|
PERMS.MOD_ACTIONS,
|
||||||
|
PERMS.MOD_BANS_VIEW,
|
||||||
|
PERMS.MOD_TICKETS_VIEW,
|
||||||
|
PERMS.MOD_TEAM_VIEW,
|
||||||
|
PERMS.MOD_USERS_VIEW,
|
||||||
|
PERMS.TICKETS_VIEW,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const listTableViews = adminAction(
|
||||||
|
{
|
||||||
|
permission: tableViewPermissions,
|
||||||
|
schema: z.object({ path: tableViewPath }),
|
||||||
|
},
|
||||||
|
async ({ session, data }) => {
|
||||||
|
const [rows] = await db.execute(
|
||||||
|
sql`SELECT name, state FROM website_admin_table_views WHERE user_id = ${Number(session.user.id)} AND path = ${data.path} ORDER BY name LIMIT 20`,
|
||||||
|
);
|
||||||
|
const views = (
|
||||||
|
rows as unknown as { name: string; state: string }[]
|
||||||
|
).flatMap((row) => {
|
||||||
|
try {
|
||||||
|
const parsed = namedTableView.safeParse({
|
||||||
|
name: row.name,
|
||||||
|
state: JSON.parse(row.state),
|
||||||
|
});
|
||||||
|
return parsed.success ? [parsed.data] : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { ok: true, data: { views } };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
export const saveTableView = adminAction(
|
||||||
|
{
|
||||||
|
permission: tableViewPermissions,
|
||||||
|
schema: namedTableView.extend({ path: tableViewPath }),
|
||||||
|
rateLimitKey: "table-view-save",
|
||||||
|
rateLimitMax: 30,
|
||||||
|
rateLimitWindowMs: 60000,
|
||||||
|
},
|
||||||
|
async ({ session, data }) => {
|
||||||
|
const userId = Number(session.user.id);
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await tx.execute(
|
||||||
|
sql`SELECT id FROM users WHERE id = ${userId} FOR UPDATE`,
|
||||||
|
);
|
||||||
|
const [rows] = await tx.execute(
|
||||||
|
sql`SELECT name FROM website_admin_table_views WHERE user_id = ${userId} AND path = ${data.path}`,
|
||||||
|
);
|
||||||
|
const names = rows as unknown as { name: string }[];
|
||||||
|
if (
|
||||||
|
names.length >= 20 &&
|
||||||
|
!names.some(
|
||||||
|
(row) =>
|
||||||
|
row.name.toLocaleLowerCase() === data.name.toLocaleLowerCase(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new Error("Maximum 20 saved views per table");
|
||||||
|
await tx.execute(
|
||||||
|
sql`INSERT INTO website_admin_table_views (user_id,path,name,state) VALUES (${userId},${data.path},${data.name},${JSON.stringify(data.state)}) ON DUPLICATE KEY UPDATE state = VALUES(state)`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
export const deleteTableView = adminAction(
|
||||||
|
{
|
||||||
|
permission: tableViewPermissions,
|
||||||
|
schema: z.object({ path: tableViewPath, name: z.string().min(1).max(60) }),
|
||||||
|
},
|
||||||
|
async ({ session, data }) => {
|
||||||
|
await db.execute(
|
||||||
|
sql`DELETE FROM website_admin_table_views WHERE user_id = ${Number(session.user.id)} AND path = ${data.path} AND name = ${data.name}`,
|
||||||
|
);
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -16,6 +16,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useEffect, useRef, useState, useTransition } from "react";
|
import { useEffect, useRef, useState, useTransition } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { TableSavedViews } from "@/components/admin/table-saved-views";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
@@ -327,6 +328,29 @@ export function DataTable<T extends { id: number | string }>({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<TableSavedViews
|
||||||
|
path={pathname}
|
||||||
|
query={searchParams.toString()}
|
||||||
|
hidden={[...hiddenColumns]}
|
||||||
|
onApply={(view) => {
|
||||||
|
setHiddenColumns(
|
||||||
|
() =>
|
||||||
|
new Set(
|
||||||
|
view.state.hidden.filter((key) =>
|
||||||
|
columns.some((column) => column.key === key),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const params = new URLSearchParams(view.state.params);
|
||||||
|
params.set("page", "1");
|
||||||
|
startTransition(() =>
|
||||||
|
router.replace(`${pathname}?${params.toString()}`, {
|
||||||
|
scroll: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Column Filters */}
|
{/* Column Filters */}
|
||||||
{filterColumns.length > 0 && (
|
{filterColumns.length > 0 && (
|
||||||
<Popover>
|
<Popover>
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"use client";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useId, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
deleteTableView,
|
||||||
|
listTableViews,
|
||||||
|
saveTableView,
|
||||||
|
} from "@/actions/admin-table-views";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import {
|
||||||
|
captureTableView,
|
||||||
|
type NamedTableView,
|
||||||
|
} from "@/lib/admin/table-view-state";
|
||||||
|
|
||||||
|
export function TableSavedViews({
|
||||||
|
path,
|
||||||
|
query,
|
||||||
|
hidden,
|
||||||
|
onApply,
|
||||||
|
}: {
|
||||||
|
path: string;
|
||||||
|
query: string;
|
||||||
|
hidden: string[];
|
||||||
|
onApply: (view: NamedTableView) => void;
|
||||||
|
}) {
|
||||||
|
const inputId = useId();
|
||||||
|
const t = useTranslations("pages.admin.table.views");
|
||||||
|
const [views, setViews] = useState<NamedTableView[]>([]);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
async function load() {
|
||||||
|
setBusy(true);
|
||||||
|
setError(false);
|
||||||
|
setLoaded(false);
|
||||||
|
setViews([]);
|
||||||
|
try {
|
||||||
|
const result = await listTableViews({ path });
|
||||||
|
if (!result.ok) throw new Error(result.error);
|
||||||
|
setViews((result.data as { views: NamedTableView[] }).views);
|
||||||
|
setLoaded(true);
|
||||||
|
} catch {
|
||||||
|
setError(true);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function save() {
|
||||||
|
if (busy || !name.trim()) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const result = await saveTableView({
|
||||||
|
path,
|
||||||
|
name: name.trim(),
|
||||||
|
state: captureTableView(query, hidden),
|
||||||
|
});
|
||||||
|
if (!result.ok) throw new Error(result.error);
|
||||||
|
toast.success(t("saved"));
|
||||||
|
setName("");
|
||||||
|
await load();
|
||||||
|
} catch {
|
||||||
|
toast.error(t("error"));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function remove(view: NamedTableView) {
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const result = await deleteTableView({ path, name: view.name });
|
||||||
|
if (!result.ok) throw new Error(result.error);
|
||||||
|
await load();
|
||||||
|
} catch {
|
||||||
|
toast.error(t("error"));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (open) void load();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
{t("title")}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
align="end"
|
||||||
|
className="w-80 max-w-[calc(100vw-2rem)] space-y-3"
|
||||||
|
>
|
||||||
|
<p className="text-sm text-muted-foreground">{t("hint")}</p>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-sm">
|
||||||
|
{t("error")}{" "}
|
||||||
|
<Button variant="link" size="sm" onClick={() => void load()}>
|
||||||
|
{t("retry")}
|
||||||
|
</Button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{busy && (
|
||||||
|
<p role="status" className="text-sm">
|
||||||
|
{t("loading")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{loaded && views.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">{t("empty")}</p>
|
||||||
|
)}
|
||||||
|
<div className="max-h-60 overflow-y-auto space-y-1">
|
||||||
|
{views.map((view) => (
|
||||||
|
<div key={view.name} className="flex gap-2 items-center">
|
||||||
|
<Button
|
||||||
|
disabled={busy}
|
||||||
|
variant="secondary"
|
||||||
|
className="flex-1 min-w-0 justify-start"
|
||||||
|
onClick={() => onApply(view)}
|
||||||
|
>
|
||||||
|
<span className="truncate">{view.name}</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={busy}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
aria-label={t("deleteNamed", { name: view.name })}
|
||||||
|
onClick={() => void remove(view)}
|
||||||
|
>
|
||||||
|
{t("delete")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<label htmlFor={inputId} className="block text-sm">
|
||||||
|
{t("name")}
|
||||||
|
<Input
|
||||||
|
id={inputId}
|
||||||
|
value={name}
|
||||||
|
maxLength={60}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Button
|
||||||
|
disabled={busy || !loaded || !name.trim()}
|
||||||
|
onClick={() => void save()}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{views.some(
|
||||||
|
(view) =>
|
||||||
|
view.name.toLocaleLowerCase() === name.trim().toLocaleLowerCase(),
|
||||||
|
)
|
||||||
|
? t("replace")
|
||||||
|
: t("save")}
|
||||||
|
</Button>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
captureTableView,
|
||||||
|
namedTableView,
|
||||||
|
tableViewPath,
|
||||||
|
} from "./table-view-state";
|
||||||
|
|
||||||
|
describe("saved table views", () => {
|
||||||
|
it("keeps filters and sorting but discards page and credentials", () => {
|
||||||
|
expect(
|
||||||
|
captureTableView(
|
||||||
|
"search=test&page=4&sort=id&order=asc&filter_rank=7&token=secret&redirect=https://evil.invalid",
|
||||||
|
["name"],
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
params: { search: "test", sort: "id", order: "asc", filter_rank: "7" },
|
||||||
|
hidden: ["name"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("rejects external and traversal paths", () => {
|
||||||
|
for (const path of [
|
||||||
|
"https://evil.invalid/admin/users",
|
||||||
|
"/admin/../me",
|
||||||
|
"//admin/users",
|
||||||
|
"/me",
|
||||||
|
])
|
||||||
|
expect(tableViewPath.safeParse(path).success).toBe(false);
|
||||||
|
expect(tableViewPath.safeParse("/admin/tickets/desk").success).toBe(true);
|
||||||
|
});
|
||||||
|
it("rejects unsafe or oversized stored state", () => {
|
||||||
|
expect(
|
||||||
|
namedTableView.safeParse({
|
||||||
|
name: "Test",
|
||||||
|
state: { params: { password: "secret" }, hidden: [] },
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
namedTableView.safeParse({ name: " ", state: { params: {}, hidden: [] } })
|
||||||
|
.success,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
namedTableView.safeParse({
|
||||||
|
name: "Test",
|
||||||
|
state: { params: { search: "x".repeat(201) }, hidden: [] },
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const tableViewPath = z
|
||||||
|
.string()
|
||||||
|
.max(191)
|
||||||
|
.regex(/^\/(admin|mod)\/[a-zA-Z0-9/_-]+$/);
|
||||||
|
export const tableViewState = z.object({
|
||||||
|
params: z
|
||||||
|
.record(
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.regex(
|
||||||
|
/^(search|sort|order|perPage|status|assignment|awaiting|filter_[a-zA-Z0-9_]+)$/,
|
||||||
|
),
|
||||||
|
z.string().max(200),
|
||||||
|
)
|
||||||
|
.refine((value) => Object.keys(value).length <= 30),
|
||||||
|
hidden: z.array(z.string().max(80)).max(60),
|
||||||
|
});
|
||||||
|
export const namedTableView = z.object({
|
||||||
|
name: z.string().trim().min(1).max(60),
|
||||||
|
state: tableViewState,
|
||||||
|
});
|
||||||
|
export type NamedTableView = z.infer<typeof namedTableView>;
|
||||||
|
export function captureTableView(query: string, hidden: string[]) {
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
for (const [key, value] of new URLSearchParams(query)) {
|
||||||
|
if (
|
||||||
|
/^(search|sort|order|perPage|status|assignment|awaiting|filter_[a-zA-Z0-9_]+)$/.test(
|
||||||
|
key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
params[key] = value.slice(0, 200);
|
||||||
|
}
|
||||||
|
return tableViewState.parse({ params, hidden });
|
||||||
|
}
|
||||||
Reference in new issue
Block a user