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 <noreply@anthropic.com>
This commit is contained in:
DEls 2026-11-10 13:51:24 +02:00
parent 954a92e777
commit e089cbc000
11 changed files with 162 additions and 17 deletions

View file

@ -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)

View file

@ -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()

View file

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

View file

@ -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 ?? "";
}

View file

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

View file

@ -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 ?? "";
}

View file

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

View file

@ -423,7 +423,7 @@ function UserDialog({ open, onOpenChange, user, sites, isAdmin, onWarning }: {
{role === "office_user" && <>
<fieldset className="min-w-0 rounded-lg border border-border p-3"><legend className="px-1 text-sm font-medium">Sections</legend>
<label className="block text-sm">Access preset<select value={modules.length === 1 ? modules[0] : "custom"} onChange={(e) => { if (e.target.value !== "custom") setModules([e.target.value as DashboardModule]); }} className="mt-1 min-h-11 w-full rounded-md border border-input bg-background px-3 py-2">
<option value="workforce">Workforce</option><option value="financial">Financial</option><option value="plant">Plant</option><option value="custom">Custom</option>
<option value="workforce">Workforce</option><option value="financial">Financial</option><option value="creditors">Creditors</option><option value="plant">Plant</option><option value="custom">Custom</option>
{modules.length === 1 && (modules[0] === "overview" || modules[0] === "sites") && <option value={modules[0]}>{MODULE_LABELS[modules[0]]}</option>}
</select></label>
<div className="mt-2 space-y-1">{DASHBOARD_MODULES.map((module) => <label key={module} className="flex min-h-11 items-center gap-3 py-1 text-sm"><input type="checkbox" className="h-5 w-5 shrink-0 accent-primary" checked={modules.includes(module)} onChange={() => setModules((current) => current.includes(module) ? current.filter((item) => item !== module) : [...current, module])} />{MODULE_LABELS[module]}</label>)}</div>

View file

@ -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<DashboardModule, string> = {
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<Role, string> = {
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;
}

View file

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

View file

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