feat(hk): persist named table views for staff accounts
CI / check (push) Failing after 55s
CI / deploy (push) Skipped
CI / publish-container (push) Skipped

This commit is contained in:
Simo committed 2026-09-11 00:32:15 +02:00
1 parent 74223984dc
commit a647b5451f
7 files changed
+480

No files matched your search

+48
View File
@@ -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);
});
});
+36
View File
@@ -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 });
}