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;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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 { useEffect, useRef, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { TableSavedViews } from "@/components/admin/table-saved-views";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
@@ -327,6 +328,29 @@ export function DataTable<T extends { id: number | string }>({
|
||||
/>
|
||||
</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 */}
|
||||
{filterColumns.length > 0 && (
|
||||
<Popover>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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