feat(admin): data loaders and owner-only route gate
This commit is contained in:
parent
287d70c42c
commit
c2446d85eb
2 changed files with 107 additions and 0 deletions
23
src/app/admin/layout.tsx
Normal file
23
src/app/admin/layout.tsx
Normal 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
84
src/lib/data/admin.ts
Normal 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" });
|
||||
}
|
||||
Loading…
Reference in a new issue