From e089cbc000609f3fba8b6238201b09c8783ea746 Mon Sep 17 00:00:00 2001 From: DEls Date: Tue, 10 Nov 2026 13:51:24 +0200 Subject: [PATCH] feat(creditors): Creditors is its own user-settings section for office users Adds "creditors" to the grantable modules (office users only; site managers need it switched on and the settings screen does not offer it). Creditors pages, APIs and statement/invoice files check this section instead of Financial. Migration 2026-11-10-creditors-module.py adds it to users.modules (applied). Co-Authored-By: Claude Sonnet 5.5 --- .../migrations/2026-11-10-creditors-module.py | 90 +++++++++++++++++++ .../test_2026_11_10_creditors_module.py | 39 ++++++++ src/app/api/creditors/invoices/[id]/route.ts | 2 +- src/app/api/creditors/invoices/route.ts | 2 +- .../api/creditors/statements/[id]/route.ts | 2 +- src/app/api/creditors/statements/route.ts | 2 +- .../[recordId]/[...filename]/route.ts | 5 +- .../dashboard/settings/users/users-view.tsx | 2 +- src/lib/auth/access.ts | 8 +- src/lib/auth/path-access.test.ts | 19 +++- src/lib/auth/path-access.ts | 8 +- 11 files changed, 162 insertions(+), 17 deletions(-) create mode 100644 scripts/migrations/2026-11-10-creditors-module.py create mode 100644 scripts/migrations/test_2026_11_10_creditors_module.py diff --git a/scripts/migrations/2026-11-10-creditors-module.py b/scripts/migrations/2026-11-10-creditors-module.py new file mode 100644 index 0000000..02cb2e5 --- /dev/null +++ b/scripts/migrations/2026-11-10-creditors-module.py @@ -0,0 +1,90 @@ +"""Add "creditors" to the users.modules choices so Office users can be given Creditors. Dry-run by default.""" + +import argparse +import copy +import json +import os +from pathlib import Path +import sys +import urllib.error +import urllib.request + +NEW_MODULE = "creditors" + + +def prepare_fields(collection): + if collection.get("type") != "auth": + raise RuntimeError("users must be an auth collection") + fields = copy.deepcopy(collection["fields"]) + modules = next((field for field in fields if field["name"] == "modules"), None) + if not modules or modules.get("type") != "select": + raise RuntimeError("users.modules must be the existing select field (run 2026-10-07-office-access.py first)") + if NEW_MODULE not in modules["values"]: + modules["values"].append(NEW_MODULE) + modules["maxSelect"] = max(modules.get("maxSelect", 0), len(modules["values"])) + return fields + + +def environment(path): + values = {} + if path.is_file(): + for raw in path.read_text().splitlines(): + line = raw.strip() + if line and not line.startswith("#") and "=" in line: + key, value = line.split("=", 1) + values[key.strip()] = value.strip().strip('"').strip("'") + return {**values, **os.environ} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--env-file", type=Path, default=Path(__file__).resolve().parents[2] / ".env.local") + parser.add_argument("--apply", action="store_true") + parser.add_argument("--confirm-host", help="Exact PocketBase base URL for applying the additive schema update") + args = parser.parse_args() + env = environment(args.env_file) + base = (env.get("POCKETBASE_URL") or env.get("NEXT_PUBLIC_POCKETBASE_URL") or "").rstrip("/") + email, password = env.get("POCKETBASE_ADMIN_EMAIL"), env.get("POCKETBASE_ADMIN_PASSWORD") + if not base or not email or not password: + parser.error("PocketBase URL and superuser credentials are required") + if args.apply and args.confirm_host != base: + parser.error("--apply requires --confirm-host matching the configured PocketBase URL") + + def api(method, path, token=None, body=None): + data = json.dumps(body).encode() if body is not None else None + request = urllib.request.Request(f"{base}{path}", data=data, method=method) + if data is not None: + request.add_header("Content-Type", "application/json") + if token: + request.add_header("Authorization", token) + try: + with urllib.request.urlopen(request, timeout=30) as response: + return json.load(response) + except urllib.error.HTTPError as error: + raise RuntimeError(f"{method} {path} failed (HTTP {error.code})") from None + + print(f"Target: {base} | {'APPLY' if args.apply else 'DRY RUN'}") + auth = api("POST", "/api/collections/_superusers/auth-with-password", body={"identity": email, "password": password}) + token = auth["token"] + users = api("GET", "/api/collections/users", token) + fields = prepare_fields(users) + if fields == users["fields"]: + print("users.modules already offers Creditors. No changes needed.") + return + print("Add 'creditors' to users.modules; preserve current users, their sections and collection rules.") + if not args.apply: + print("Dry run complete. Apply this schema before deploying the Creditors section setting.") + return + api("PATCH", "/api/collections/users", token, {"fields": fields}) + verified = api("GET", "/api/collections/users", token) + if prepare_fields(verified) != verified["fields"]: + raise RuntimeError("Creditors module schema verification failed") + print("Creditors module schema verified. No user was changed.") + + +if __name__ == "__main__": + try: + main() + except (RuntimeError, urllib.error.URLError) as error: + print(f"ERROR: {error}", file=sys.stderr) + sys.exit(1) diff --git a/scripts/migrations/test_2026_11_10_creditors_module.py b/scripts/migrations/test_2026_11_10_creditors_module.py new file mode 100644 index 0000000..6ac7d29 --- /dev/null +++ b/scripts/migrations/test_2026_11_10_creditors_module.py @@ -0,0 +1,39 @@ +import copy +import importlib.util +from pathlib import Path +import unittest + +spec = importlib.util.spec_from_file_location("creditors_module", Path(__file__).with_name("2026-11-10-creditors-module.py")) +migration = importlib.util.module_from_spec(spec) +spec.loader.exec_module(migration) + + +class CreditorsModuleTests(unittest.TestCase): + def collection(self): + return {"type": "auth", "fields": [ + {"name": "email", "type": "email"}, + {"name": "modules", "type": "select", "maxSelect": 5, "values": ["overview", "sites", "workforce", "plant", "financial"]}, + ]} + + def test_adds_the_value_and_widens_max_select(self): + original = self.collection() + snapshot = copy.deepcopy(original) + fields = migration.prepare_fields(original) + modules = next(f for f in fields if f["name"] == "modules") + self.assertEqual(modules["values"][-1], "creditors") + self.assertEqual(modules["maxSelect"], 6) + self.assertEqual(fields[0], snapshot["fields"][0]) + self.assertEqual(original, snapshot) + + def test_second_run_is_a_no_op(self): + collection = self.collection() + collection["fields"] = migration.prepare_fields(collection) + self.assertEqual(migration.prepare_fields(collection), collection["fields"]) + + def test_requires_the_modules_field(self): + with self.assertRaises(RuntimeError): + migration.prepare_fields({"type": "auth", "fields": []}) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/app/api/creditors/invoices/[id]/route.ts b/src/app/api/creditors/invoices/[id]/route.ts index e455c8f..f586d8d 100644 --- a/src/app/api/creditors/invoices/[id]/route.ts +++ b/src/app/api/creditors/invoices/[id]/route.ts @@ -8,7 +8,7 @@ import { isSameOrigin } from "@/lib/agent/same-origin"; export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) { if (!isSameOrigin(request)) return NextResponse.json({ error: "Request origin is not allowed" }, { status: 403 }); if (authMode() === "users") { - const user = await requireApiUser("financial"); + const user = await requireApiUser("creditors"); if (user instanceof Response) return user; } const { id } = await params; diff --git a/src/app/api/creditors/invoices/route.ts b/src/app/api/creditors/invoices/route.ts index c88eff4..20baaaa 100644 --- a/src/app/api/creditors/invoices/route.ts +++ b/src/app/api/creditors/invoices/route.ts @@ -23,7 +23,7 @@ export async function POST(request: Request) { if (!isSameOrigin(request)) return NextResponse.json({ error: "Request origin is not allowed" }, { status: 403 }); let uploadedBy = ""; if (authMode() === "users") { - const user = await requireApiUser("financial"); + const user = await requireApiUser("creditors"); if (user instanceof Response) return user; uploadedBy = user.email ?? user.id ?? ""; } diff --git a/src/app/api/creditors/statements/[id]/route.ts b/src/app/api/creditors/statements/[id]/route.ts index e5f513c..7312ae8 100644 --- a/src/app/api/creditors/statements/[id]/route.ts +++ b/src/app/api/creditors/statements/[id]/route.ts @@ -8,7 +8,7 @@ import { isSameOrigin } from "@/lib/agent/same-origin"; export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) { if (!isSameOrigin(request)) return NextResponse.json({ error: "Request origin is not allowed" }, { status: 403 }); if (authMode() === "users") { - const user = await requireApiUser("financial"); + const user = await requireApiUser("creditors"); if (user instanceof Response) return user; } const { id } = await params; diff --git a/src/app/api/creditors/statements/route.ts b/src/app/api/creditors/statements/route.ts index eb2660f..30050b4 100644 --- a/src/app/api/creditors/statements/route.ts +++ b/src/app/api/creditors/statements/route.ts @@ -25,7 +25,7 @@ export async function POST(request: Request) { if (!isSameOrigin(request)) return NextResponse.json({ error: "Request origin is not allowed" }, { status: 403 }); let uploadedBy = ""; if (authMode() === "users") { - const user = await requireApiUser("financial"); + const user = await requireApiUser("creditors"); if (user instanceof Response) return user; uploadedBy = user.email ?? user.id ?? ""; } diff --git a/src/app/api/pb-files/[collection]/[recordId]/[...filename]/route.ts b/src/app/api/pb-files/[collection]/[recordId]/[...filename]/route.ts index 8c6657e..d9025de 100644 --- a/src/app/api/pb-files/[collection]/[recordId]/[...filename]/route.ts +++ b/src/app/api/pb-files/[collection]/[recordId]/[...filename]/route.ts @@ -42,7 +42,7 @@ const ALLOWED_COLLECTIONS = new Set([ "creditor_invoices", ]); -/** Creditor documents belong to a supplier, not a site, so Financial access is the whole check. */ +/** Creditor documents belong to a supplier, not a site, so Creditors access is the whole check. */ const NO_SITE_COLLECTIONS = new Set(["creditor_statements", "creditor_invoices"]); export async function GET( @@ -53,7 +53,8 @@ export async function GET( params: Promise<{ collection: string; recordId: string; filename: string[] }>; }, ) { - const user = await requireApiUser("financial"); + const { collection: requested } = await params; + const user = await requireApiUser(NO_SITE_COLLECTIONS.has(requested) ? "creditors" : "financial"); if (user instanceof Response) return user; const { collection, recordId, filename } = await params; diff --git a/src/app/dashboard/settings/users/users-view.tsx b/src/app/dashboard/settings/users/users-view.tsx index d547313..6720077 100644 --- a/src/app/dashboard/settings/users/users-view.tsx +++ b/src/app/dashboard/settings/users/users-view.tsx @@ -423,7 +423,7 @@ function UserDialog({ open, onOpenChange, user, sites, isAdmin, onWarning }: { {role === "office_user" && <>
Sections
{DASHBOARD_MODULES.map((module) => )}
diff --git a/src/lib/auth/access.ts b/src/lib/auth/access.ts index ef6aa0e..a1126e7 100644 --- a/src/lib/auth/access.ts +++ b/src/lib/auth/access.ts @@ -1,12 +1,12 @@ export type Role = "admin" | "site_user" | "clock_only" | "office_user"; -export const DASHBOARD_MODULES = ["overview", "sites", "workforce", "plant", "financial"] as const; +export const DASHBOARD_MODULES = ["overview", "sites", "workforce", "plant", "financial", "creditors"] as const; export type DashboardModule = typeof DASHBOARD_MODULES[number]; // Surveying (site maps) is not a grantable module: everyone who can open // the dashboard sees it, scoped to their own sites. export type AccessArea = "dashboard" | "clock" | "management" | "surveying" | DashboardModule; export const MODULE_LABELS: Record = { - overview: "Overview", sites: "Sites", workforce: "Workforce", plant: "Plant", financial: "Financial", + overview: "Overview", sites: "Sites", workforce: "Workforce", plant: "Plant", financial: "Financial", creditors: "Creditors", }; export const ROLE_LABELS: Record = { admin: "Admin", site_user: "Site manager", clock_only: "Clock user", office_user: "Office user", @@ -62,10 +62,10 @@ export function canSeeWorkerRates(user: AppUser): boolean { return user.role === "admin" || user.hideRates !== true; } -/** Sites shows whether a job makes money, so site managers see it only when switched on. */ +/** Sites shows whether a job makes money, and Creditors shows what the company owes suppliers, so site managers see them only when switched on. */ export function canAccessModule(user: AppUser, module: DashboardModule): boolean { if (user.role === "admin") return true; - if (user.role === "site_user") return module !== "sites" || user.modules?.includes("sites") === true; + if (user.role === "site_user") return (module !== "sites" && module !== "creditors") || user.modules?.includes(module) === true; return user.role === "office_user" && user.modules?.includes(module) === true; } diff --git a/src/lib/auth/path-access.test.ts b/src/lib/auth/path-access.test.ts index 21c0967..b6d7b01 100644 --- a/src/lib/auth/path-access.test.ts +++ b/src/lib/auth/path-access.test.ts @@ -16,9 +16,6 @@ it.each([ ["/api/purchase-orders/order/status", true], ["/api/purchase-orders/order/retry", true], ["/api/purchase-orders/order/items", true], ["/api/delivery-notes/upload", true], ["/api/dn-batches/batch/notes/note/post", true], ["/api/pb-files/purchase_orders/order/document.pdf", true], - ["/dashboard/creditors", true], ["/dashboard/creditors/suppliers/supplier", true], - ["/api/creditors/statements", true], ["/api/creditors/invoices/invoice", true], - ["/api/pb-files/creditor_statements/statement/file.pdf", true], ["/api/timesheets/import", false], ["/api/employees/worker/calendar-weather", false], ["/dashboard/workforce", false], ["/dashboard/financially-unrelated", false], ["/clock", false], ["/api/clock/scan", false], ["/api/clock/vehicle-scan", false], @@ -83,3 +80,19 @@ it("opens Surveying maps to every dashboard user and keeps Sites to admins and o expect(canAccessPath({ ...user, role: "admin" }, path)).toBe(true); } }); + +it.each([ + ["/dashboard/creditors", true], ["/dashboard/creditors/suppliers/supplier", true], + ["/api/creditors/statements", true], ["/api/pb-files/creditor_statements/statement/file.pdf", true], + ["/dashboard/financial", false], ["/api/suppliers", false], +] as const)("Creditors-only office access to %s is %s", (path, allowed) => { + expect(canAccessPath({ ...user, modules: ["creditors"] }, path)).toBe(allowed); +}); + +it("keeps Creditors away from Financial-only office users and from site managers unless switched on", () => { + expect(canAccessPath(user, "/dashboard/creditors")).toBe(false); + expect(canAccessPath(user, "/api/creditors/statements")).toBe(false); + const manager: AppUser = { ...user, role: "site_user", modules: undefined, siteIds: ["a"], allSites: false }; + expect(canAccessPath(manager, "/dashboard/creditors")).toBe(false); + expect(canAccessPath({ ...manager, modules: ["creditors"] }, "/dashboard/creditors")).toBe(true); +}); diff --git a/src/lib/auth/path-access.ts b/src/lib/auth/path-access.ts index 3cb687b..64abdbd 100644 --- a/src/lib/auth/path-access.ts +++ b/src/lib/auth/path-access.ts @@ -11,13 +11,15 @@ export function moduleForPath(path: string): DashboardModule | null { } if (within(path, "/dashboard/employees")) return "workforce"; if (within(path, "/dashboard/fuel")) return "plant"; - if (within(path, "/dashboard/purchasing") || within(path, "/dashboard/creditors")) return "financial"; + if (within(path, "/dashboard/purchasing")) return "financial"; + if (within(path, "/dashboard/creditors") || within(path, "/api/creditors")) return "creditors"; + if (/^\/api\/pb-files\/(creditor_statements|creditor_invoices)(\/|$)/.test(path)) return "creditors"; if (within(path, "/api/employees") || within(path, "/api/employers") || within(path, "/api/workforce") || within(path, "/api/timesheets") || within(path, "/api/clock/events")) return "workforce"; if (["vehicles", "fuel-logs", "fuel-prices", "plant-hours"].some((resource) => within(path, `/api/${resource}`))) return "plant"; if (within(path, "/api/sites")) return "sites"; - if (["suppliers", "creditors", "purchase-orders", "delivery-notes", "dn-batches", "data-import-batches"].some((resource) => within(path, `/api/${resource}`))) return "financial"; + if (["suppliers", "purchase-orders", "delivery-notes", "dn-batches", "data-import-batches"].some((resource) => within(path, `/api/${resource}`))) return "financial"; if (/^\/api\/records\/(purchase_orders|delivery_notes)(\/|$)/.test(path) || - /^\/api\/pb-files\/(purchase_orders|delivery_notes|creditor_statements|creditor_invoices)(\/|$)/.test(path)) return "financial"; + /^\/api\/pb-files\/(purchase_orders|delivery_notes)(\/|$)/.test(path)) return "financial"; return null; }