feat(admin): data loaders and owner-only route gate

This commit is contained in:
Dave 2026-09-16 11:26:37 +02:00
parent 287d70c42c
commit c2446d85eb
2 changed files with 107 additions and 0 deletions

23
src/app/admin/layout.tsx Normal file
View file

@ -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}</>;
}

84
src/lib/data/admin.ts Normal file
View file

@ -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<ClientRow[]> {
const pb = await getServerPb();
const clients = await pb
.collection("clients")
.getFullList<ClientRecord>({ 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<ProjectRow[]> {
const pb = await getServerPb();
const projects = await pb
.collection("projects")
.getFullList<ProjectRecord>({ sort: "name" });
const clients = await pb.collection("clients").getFullList<ClientRecord>();
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<UserRecord[]> {
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<UserRecord>({ sort: "name" });
}
export async function loadClientOptions(): Promise<ClientRecord[]> {
const pb = await getServerPb();
return pb
.collection("clients")
.getFullList<ClientRecord>({ filter: "active = true", sort: "name" });
}