diff --git a/drizzle/migrations/0029_admin_table_views.sql b/drizzle/migrations/0029_admin_table_views.sql new file mode 100644 index 00000000..c8e148e6 --- /dev/null +++ b/drizzle/migrations/0029_admin_table_views.sql @@ -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; diff --git a/src/actions/admin-table-views.test.ts b/src/actions/admin-table-views.test.ts new file mode 100644 index 00000000..b5d05739 --- /dev/null +++ b/src/actions/admin-table-views.test.ts @@ -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: [] }, + }); + }); +}); diff --git a/src/actions/admin-table-views.ts b/src/actions/admin-table-views.ts new file mode 100644 index 00000000..2f4af2a2 --- /dev/null +++ b/src/actions/admin-table-views.ts @@ -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 }; + }, +); diff --git a/src/components/admin/data-table.tsx b/src/components/admin/data-table.tsx index 5f544306..ee1bdba9 100644 --- a/src/components/admin/data-table.tsx +++ b/src/components/admin/data-table.tsx @@ -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({ /> +