diff --git a/docs/operations.md b/docs/operations.md index 5ec242f..f6c9215 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -55,9 +55,10 @@ Coolify will not pick up the change otherwise. (Source: `AUTH_MODE=users` uses the PocketBase `users` collection. An app admin sees all sites and can add users at `/dashboard/settings/users`. New accounts default to the editable initial password `mega2026` and must choose a new password on -first sign-in. Site users have read-only dashboard access to assigned sites; -unscoped editing APIs are admin-only. Clock-only users can use the clock PWA -and delivery capture for assigned sites. An admin can reset a user's password +first sign-in. Site users can view assigned sites and upload purchase orders +and delivery notes for those sites; unscoped editing APIs are admin-only. +Clock-only users can use the clock PWA and delivery capture for assigned sites. +An admin can reset a user's password in the app or PocketBase and set `must_change_password` for the next login. The migration is dry-run by default: diff --git a/src/app/api/clock/events/[id]/correct/route.test.ts b/src/app/api/clock/events/[id]/correct/route.test.ts index d343d9e..7a66def 100644 --- a/src/app/api/clock/events/[id]/correct/route.test.ts +++ b/src/app/api/clock/events/[id]/correct/route.test.ts @@ -13,15 +13,15 @@ import { POST } from "./route"; function request(pin = "2468", origin = "https://mega.example", body: unknown = { actualOutLocal: "2026-09-23T17:00", reason: "Missed exit scan", -}) { - return POST(new Request("https://mega.example/api/clock/events/oldin/correct", { - method: "POST", headers: { origin, "X-App-Pin": pin, "Content-Type": "application/json" }, +}, requestUrl = "https://mega.example/api/clock/events/oldin/correct") { + return POST(new Request(requestUrl, { + method: "POST", headers: { origin, host: "mega.example", "X-App-Pin": pin, "Content-Type": "application/json" }, body: JSON.stringify(body), }), { params: Promise.resolve({ id: "oldin" }) }); } describe("manual clock-out route", () => { - beforeEach(() => { correct.mockReset(); vi.stubEnv("APP_PIN", "2468"); }); + beforeEach(() => { correct.mockReset(); vi.stubEnv("APP_PIN", "2468"); vi.stubEnv("AUTH_MODE", "pin"); }); it("rejects a missing or wrong PIN and cross-origin request before writing", async () => { expect((await request("")).status).toBe(401); expect((await request("wrong")).status).toBe(401); @@ -37,4 +37,14 @@ describe("manual clock-out route", () => { actualOutLocal: "2026-09-23T17:00", reason: "Missed exit scan", })); }); + it("accepts the browser origin when a reverse proxy reports an internal request URL", async () => { + correct.mockResolvedValue({ correctionId: "corr1", sessionHours: 1, dailyTotalHours: 10.24 }); + const response = await request( + "2468", "https://mega.example", + { actualOutLocal: "2026-09-23T17:00", reason: "Missed exit scan" }, + "http://localhost:3000/api/clock/events/oldin/correct", + ); + expect(response.status).toBe(200); + expect(correct).toHaveBeenCalledOnce(); + }); }); diff --git a/src/app/api/clock/events/[id]/correct/route.ts b/src/app/api/clock/events/[id]/correct/route.ts index c1048ff..98804f3 100644 --- a/src/app/api/clock/events/[id]/correct/route.ts +++ b/src/app/api/clock/events/[id]/correct/route.ts @@ -4,6 +4,7 @@ import { ClockCorrectionError, correctMissedClockOut } from "@/lib/clock-correct import { pbAdmin } from "@/lib/pb"; import { authMode } from "@/lib/auth/session"; import { requireApiUser } from "@/lib/auth/guard"; +import { isSameOrigin } from "@/lib/agent/same-origin"; function samePin(provided: string | null, expected: string | undefined): boolean { if (!provided || !expected) return false; @@ -16,8 +17,7 @@ export async function POST( request: Request, { params }: { params: Promise<{ id: string }> }, ) { - const origin = request.headers.get("origin"); - if (!origin || origin !== new URL(request.url).origin) { + if (!isSameOrigin(request)) { return NextResponse.json({ error: "Request origin is not allowed" }, { status: 403 }); } if (authMode() === "users") { diff --git a/src/app/api/delivery-notes/upload/route.test.ts b/src/app/api/delivery-notes/upload/route.test.ts index b305172..55aef75 100644 --- a/src/app/api/delivery-notes/upload/route.test.ts +++ b/src/app/api/delivery-notes/upload/route.test.ts @@ -1,5 +1,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +const authUser = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/auth/session", () => ({ getCurrentUser: authUser })); + const batchCreate = vi.fn(); const batchList = vi.fn(); const batchGet = vi.fn(); @@ -38,6 +41,7 @@ function form(files: File[], fields: Record = {}) { describe("POST /api/delivery-notes/upload", () => { beforeEach(() => { vi.clearAllMocks(); + authUser.mockResolvedValue({ id: "admin", role: "admin", siteIds: [], mustChangePassword: false }); batchList.mockResolvedValue([]); sourceList.mockResolvedValue([]); siteGet.mockResolvedValue({ project_code: "P-001" }); @@ -47,6 +51,14 @@ describe("POST /api/delivery-notes/upload", () => { batchDelete.mockResolvedValue(true); }); + it("accepts an assigned site user and rejects an unassigned site", async () => { + authUser.mockResolvedValue({ id: "u1", role: "site_user", siteIds: ["siteabc"], mustChangePassword: false }); + expect((await POST(request(form([file("assigned.jpg")])))).status).toBe(201); + authUser.mockResolvedValue({ id: "u1", role: "site_user", siteIds: ["other"], mustChangePassword: false }); + expect((await POST(request(form([file("unassigned.jpg")])))).status).toBe(404); + expect(batchCreate).toHaveBeenCalledTimes(1); + }); + it("creates one batch and one immutable source row per accepted file", async () => { const res = await POST(request(form([file("one.jpg", 32), file("two.jpg", 64), file("three.jpg", 96)], { source: "mobile", capture_session_id: ["cap-1", "cap-2", "cap-3"], delivery_session_id: "delivery-1", diff --git a/src/app/api/purchase-orders/[id]/status/route.test.ts b/src/app/api/purchase-orders/[id]/status/route.test.ts index fa66aad..4f8418e 100644 --- a/src/app/api/purchase-orders/[id]/status/route.test.ts +++ b/src/app/api/purchase-orders/[id]/status/route.test.ts @@ -1,4 +1,7 @@ -import { describe, it, expect, vi, beforeAll, afterAll } from "vitest"; +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from "vitest"; + +const authUser = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/auth/session", () => ({ getCurrentUser: authUser })); // Mock pbAdmin so the route doesn't hit a real PocketBase instance. const getOneMock = vi.fn(); @@ -22,6 +25,19 @@ describe("GET /api/purchase-orders/[id]/status", () => { vi.stubEnv("POCKETBASE_ADMIN_PASSWORD", "test-password"); }); + beforeEach(() => { + authUser.mockResolvedValue({ id: "admin", role: "admin", siteIds: [], mustChangePassword: false }); + getOneMock.mockReset(); + }); + + it("scopes progress checks to an assigned site", async () => { + authUser.mockResolvedValue({ id: "u1", role: "site_user", siteIds: ["site0000000001"], mustChangePassword: false }); + getOneMock.mockResolvedValueOnce({ id: "abcdefghijklmno", site: "site0000000001", processing_status: "pending" }); + expect((await GET(new Request("http://x"), { params: Promise.resolve({ id: "abcdefghijklmno" }) })).status).toBe(200); + getOneMock.mockResolvedValueOnce({ id: "abcdefghijklmno", site: "site0000000002", processing_status: "pending" }); + expect((await GET(new Request("http://x"), { params: Promise.resolve({ id: "abcdefghijklmno" }) })).status).toBe(404); + }); + afterAll(() => { vi.unstubAllEnvs(); }); diff --git a/src/app/api/purchase-orders/[id]/status/route.ts b/src/app/api/purchase-orders/[id]/status/route.ts index 1d52e12..edf5026 100644 --- a/src/app/api/purchase-orders/[id]/status/route.ts +++ b/src/app/api/purchase-orders/[id]/status/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { pbAdmin } from "@/lib/pb"; +import { requireApiUser, requireSiteAccess } from "@/lib/auth/guard"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -20,6 +21,8 @@ export async function GET( _request: Request, ctx: { params: Promise<{ id: string }> }, ) { + const user = await requireApiUser("dashboard"); + if (user instanceof Response) return user; const { id } = await ctx.params; if (!ID_PATTERN.test(id)) { return NextResponse.json({ error: "bad_id" }, { status: 400 }); @@ -28,8 +31,10 @@ export async function GET( const pb = await pbAdmin(); try { const po = await pb.collection("purchase_orders").getOne(id, { - fields: "id,processing_status,attempts,last_error,purchase_order_number", + fields: "id,site,processing_status,attempts,last_error,purchase_order_number", }); + const denied = requireSiteAccess(user, po.site); + if (denied) return denied; const status = po.processing_status ?? "pending"; const processed = status === "processed"; diff --git a/src/app/api/purchase-orders/upload/route.test.ts b/src/app/api/purchase-orders/upload/route.test.ts index 440280a..e532581 100644 --- a/src/app/api/purchase-orders/upload/route.test.ts +++ b/src/app/api/purchase-orders/upload/route.test.ts @@ -1,13 +1,19 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { MAX_FILE_BYTES } from "@/lib/purchase-order-schema"; +const authUser = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/auth/session", () => ({ getCurrentUser: authUser })); + // Mock pbAdmin. Two methods on the collection are used: getFullList (dedup) // and create (new PO row). Both are mocked below. const getFullListMock = vi.fn(); const createMock = vi.fn(); +const filterMock = vi.fn((expression: string, params: Record) => + expression.replace(/\{:(\w+)\}/g, (_, key: string) => JSON.stringify(params[key]))); vi.mock("@/lib/pb", () => ({ pbAdmin: vi.fn(async () => ({ + filter: filterMock, collection: (name: string) => { if (name !== "purchase_orders") { throw new Error(`unexpected collection in test: ${name}`); @@ -35,12 +41,37 @@ function pdf(bytes = 128, type = "application/pdf", name = "po-1234.pdf"): File describe("POST /api/purchase-orders/upload", () => { beforeEach(() => { + authUser.mockResolvedValue({ id: "admin", role: "admin", siteIds: [], mustChangePassword: false }); createMock.mockReset(); getFullListMock.mockReset(); // Default: no prior POs (dedup misses). getFullListMock.mockResolvedValue([]); }); + it("allows an assigned site user to upload and rejects another site's upload", async () => { + authUser.mockResolvedValue({ id: "u1", role: "site_user", siteIds: ["site0000000001"], mustChangePassword: false }); + createMock.mockResolvedValueOnce({ id: "abcdefghijklmno" }); + const allowed = new FormData(); + allowed.append("file", pdf()); + allowed.append("site", "site0000000001"); + expect((await POST(makeRequest(allowed))).status).toBe(202); + + const denied = new FormData(); + denied.append("file", pdf(129)); + denied.append("site", "site0000000002"); + expect((await POST(makeRequest(denied))).status).toBe(404); + expect(createMock).toHaveBeenCalledTimes(1); + }); + + it("keeps clock-only users out of purchase order uploads", async () => { + authUser.mockResolvedValue({ id: "u1", role: "clock_only", siteIds: ["site0000000001"], mustChangePassword: false }); + const form = new FormData(); + form.append("file", pdf()); + form.append("site", "site0000000001"); + expect((await POST(makeRequest(form))).status).toBe(403); + expect(createMock).not.toHaveBeenCalled(); + }); + it("returns 400 when the body is not multipart form data", async () => { const res = await POST( new Request("http://x/api/purchase-orders/upload", { @@ -183,7 +214,9 @@ describe("POST /api/purchase-orders/upload", () => { const filter = getFullListMock.mock.calls[0][0].filter; expect(filter).toMatch(/file_hash = "[a-f0-9]{64}"/); expect(filter).toContain('processing_status != "failed"'); + expect(filter).toContain('site = "site0000000001"'); expect(filter).toMatch(/date_uploaded >= "\d{4}-\d{2}-\d{2}T/); + expect(filterMock).toHaveBeenCalledWith(expect.stringContaining("site = {:site}"), expect.objectContaining({ site: "site0000000001" })); }); it("falls through to create when the dedup query throws", async () => { diff --git a/src/app/api/purchase-orders/upload/route.ts b/src/app/api/purchase-orders/upload/route.ts index 3e621ff..606cbec 100644 --- a/src/app/api/purchase-orders/upload/route.ts +++ b/src/app/api/purchase-orders/upload/route.ts @@ -3,6 +3,7 @@ import { createHash } from "node:crypto"; import { pbAdmin } from "@/lib/pb"; import { ALLOWED_MIME, MAX_FILE_BYTES } from "@/lib/purchase-order-schema"; import { placeholderPoNumber } from "@/lib/po-placeholder"; +import { requireApiUser, requireSiteAccess } from "@/lib/auth/guard"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -41,6 +42,8 @@ export const runtime = "nodejs"; const DEDUP_WINDOW_DAYS = 30; export async function POST(request: Request) { + const user = await requireApiUser("dashboard"); + if (user instanceof Response) return user; const start = Date.now(); let form: FormData; @@ -74,6 +77,8 @@ export async function POST(request: Request) { if (!siteId) { return NextResponse.json({ error: "missing_site" }, { status: 400 }); } + const denied = requireSiteAccess(user, siteId); + if (denied) return denied; // Hash the file bytes. Cheap enough to do before the PB round-trip // (20 MB worst case, but in practice POs are a few hundred KB to ~2 MB). @@ -103,10 +108,10 @@ export async function POST(request: Request) { Date.now() - DEDUP_WINDOW_DAYS * 86400_000, ).toISOString(); existing = await pb.collection("purchase_orders").getFullList({ - filter: - `file_hash = "${file_hash}" && ` + - `processing_status != "failed" && ` + - `date_uploaded >= "${cutoff}"`, + filter: pb.filter( + 'file_hash = {:hash} && site = {:site} && processing_status != "failed" && date_uploaded >= {:cutoff}', + { hash: file_hash, site: siteId, cutoff }, + ), fields: "id,purchase_order_number", }); } catch (err) { diff --git a/src/lib/auth/proxy-path.test.ts b/src/lib/auth/proxy-path.test.ts index f25c788..5b0371f 100644 --- a/src/lib/auth/proxy-path.test.ts +++ b/src/lib/auth/proxy-path.test.ts @@ -16,6 +16,11 @@ describe("auth proxy paths", () => { expect(classifyAuthPath("/api/clock/events/ev1/correct")).toBe("admin"); }); + it("allows dashboard users through the purchase order upload and progress paths", () => { + expect(classifyAuthPath("/api/purchase-orders/upload")).toBe("dashboard"); + expect(classifyAuthPath("/api/purchase-orders/abcdefghijklmno/status")).toBe("dashboard"); + }); + it("keeps unscoped data APIs behind admin access", () => { expect(classifyAuthPath("/api/sites")).toBe("admin"); expect(classifyAuthPath("/api/pb-files/delivery_notes/rec/file.pdf")).toBe("dashboard"); diff --git a/src/lib/auth/proxy-path.ts b/src/lib/auth/proxy-path.ts index f8bebaa..e7b4daa 100644 --- a/src/lib/auth/proxy-path.ts +++ b/src/lib/auth/proxy-path.ts @@ -20,6 +20,8 @@ export function classifyAuthPath(pathname: string): AuthPath { ) return "admin"; if (/^\/api\/clock\/events\/[^/]+\/correct$/.test(pathname)) return "admin"; if (pathname.startsWith("/api/pb-files/")) return "dashboard"; + if (pathname === "/api/purchase-orders/upload" || + /^\/api\/purchase-orders\/[^/]+\/status$/.test(pathname)) return "dashboard"; if (/^\/api\/dn-batches\/[^/]+\/pages\/[^/]+$/.test(pathname) || /^\/api\/purchase-orders\/[^/]+\/(document|pages\/[^/]+)$/.test(pathname)) return "dashboard"; if (pathname === "/clock" || pathname.startsWith("/clock/") || pathname.startsWith("/api/clock/") ||