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,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 };
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user