From c2446d85eb9db7d80b403f4a344cd97f9f4cfae2 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 16 Sep 2026 11:26:37 +0200 Subject: [PATCH] feat(admin): data loaders and owner-only route gate --- src/app/admin/layout.tsx | 23 +++++++++++ src/lib/data/admin.ts | 84 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 src/app/admin/layout.tsx create mode 100644 src/lib/data/admin.ts diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx new file mode 100644 index 0000000..c98f761 --- /dev/null +++ b/src/app/admin/layout.tsx @@ -0,0 +1,23 @@ +import { notFound, redirect } from "next/navigation"; + +import { getSession } from "@/lib/auth/session"; + +/** + * Owner-only. + * + * A layout guard is convenience, not security — the Server Actions each + * re-check the role, and PocketBase refuses a non-owner write regardless. + * Renders 404 rather than 403 so the existence of an admin area is not + * confirmed to a client manager who goes looking. + */ +export default async function AdminLayout({ + children, +}: { + children: React.ReactNode; +}) { + const session = await getSession(); + if (!session) redirect("/login"); + if (session.role !== "owner") notFound(); + + return <>{children}; +} \ No newline at end of file diff --git a/src/lib/data/admin.ts b/src/lib/data/admin.ts new file mode 100644 index 0000000..b0f61fb --- /dev/null +++ b/src/lib/data/admin.ts @@ -0,0 +1,84 @@ +import "server-only"; + +import { getServerPb } from "@/lib/pb/server"; +import type { + ClientRecord, + ProjectRecord, + UserRecord, +} from "@/lib/pb/types"; + +export interface ClientRow { + client: ClientRecord; + projectCount: number; + managerCount: number; +} + +export interface ProjectRow { + project: ProjectRecord; + client: ClientRecord; + cardCount: number; +} + +export async function loadClients(): Promise { + const pb = await getServerPb(); + + const clients = await pb + .collection("clients") + .getFullList({ sort: "name" }); + + const rows: ClientRow[] = []; + for (const client of clients) { + const projects = await pb.collection("projects").getList(1, 1, { + filter: `client = "${client.id}"`, + fields: "id", + }); + const managers = await pb.collection("users").getList(1, 1, { + filter: `client = "${client.id}"`, + fields: "id", + }); + rows.push({ + client, + projectCount: projects.totalItems, + managerCount: managers.totalItems, + }); + } + return rows; +} + +export async function loadProjectsAdmin(): Promise { + const pb = await getServerPb(); + + const projects = await pb + .collection("projects") + .getFullList({ sort: "name" }); + + const clients = await pb.collection("clients").getFullList(); + const byId = new Map(clients.map((c) => [c.id, c])); + + const rows: ProjectRow[] = []; + for (const project of projects) { + const client = byId.get(project.client); + if (!client) continue; + const cards = await pb.collection("cards").getList(1, 1, { + filter: `project = "${project.id}" && archived = false`, + fields: "id", + }); + rows.push({ project, client, cardCount: cards.totalItems }); + } + return rows; +} + +export async function loadUsers(): Promise { + const pb = await getServerPb(); + // The owner-or-self read rule means this returns everyone for the owner and + // only the caller for anyone else. The admin layout has already refused + // anyone else, so in practice this is the full list. + return pb.collection("users").getFullList({ sort: "name" }); +} + +export async function loadClientOptions(): Promise { + const pb = await getServerPb(); + return pb + .collection("clients") + .getFullList({ filter: "active = true", sort: "name" }); +} \ No newline at end of file