From da2d856b738ba2f26d4aac49d7e1f53bf0a3e4dd Mon Sep 17 00:00:00 2001 From: Hardik Date: Mon, 22 Jun 2026 04:57:11 +0530 Subject: [PATCH 1/2] feat(po): submitter view-all of POs + History + export (feature-flagged) Gated behind NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED (opt-in, "true"). When on, submitter roles (TECHNICAL/MANNING) get read-only access to every PO: the History page + report export, any other user's PO detail page, and the per-PO Export PDF/XLSX buttons. No approval/payment/edit rights are added. - lib/feature-flags.ts: SUBMITTER_VIEW_ALL_ENABLED flag - lib/permissions.ts: isSubmitterRole / submitterCanViewAll / canViewAllPos - po/[id] page + export route: gate via canViewAllPos - history page + reports/export route: OR submitterCanViewAll into export_reports - sidebar: show History to submitters when flag on - tests: permission helpers, both flag states - docs: .env.example, CLAUDE.md (wiki updated separately) Co-Authored-By: Claude Opus 4.8 --- App/.env.example | 7 +++ App/CLAUDE.md | 1 + App/app/(portal)/history/page.tsx | 11 ++++- App/app/(portal)/po/[id]/page.tsx | 11 ++--- App/app/api/po/[id]/export/route.ts | 6 ++- App/app/api/reports/export/route.ts | 7 ++- App/components/layout/sidebar.tsx | 11 ++++- App/lib/feature-flags.ts | 9 ++++ App/lib/permissions.ts | 29 ++++++++++++ App/tests/unit/permissions.test.ts | 68 ++++++++++++++++++++++++++++- 10 files changed, 145 insertions(+), 15 deletions(-) diff --git a/App/.env.example b/App/.env.example index a22649f..a80b2c7 100644 --- a/App/.env.example +++ b/App/.env.example @@ -55,6 +55,13 @@ FORGEJO_URL=https://git.pelagiamarine.com FORGEJO_REPO=shad0w/pelagia-portal FORGEJO_TOKEN= +# ── Feature flags (NEXT_PUBLIC_, available to client + server) ─ +# Inventory tracking (site stock / consumption). On unless explicitly "false". +# NEXT_PUBLIC_INVENTORY_ENABLED=false +# Let submitters (TECHNICAL/MANNING) read & export every PO and open the History +# page (read-only). Opt-in — on only when exactly "true". +# NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED=true + # ── Non-production banner ───────────────────────────────────── # When set, a fixed "internal dev / staging" banner is shown (EnvBanner). # Leave UNSET in production. Staging sets this automatically. diff --git a/App/CLAUDE.md b/App/CLAUDE.md index 6037430..dfc1951 100644 --- a/App/CLAUDE.md +++ b/App/CLAUDE.md @@ -142,6 +142,7 @@ FORGEJO_URL, FORGEJO_REPO, FORGEJO_TOKEN GST_SERVICE_URL # GstService microservice (defaults to localhost:3003) NEXT_PUBLIC_INVENTORY_ENABLED # Inventory feature flag +NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED # Opt-in ("true"): submitters (TECHNICAL/MANNING) read & export every PO + History (read-only) NEXT_PUBLIC_ENV_LABEL # When set, shows a non-prod banner (EnvBanner). Leave unset in prod. ``` diff --git a/App/app/(portal)/history/page.tsx b/App/app/(portal)/history/page.tsx index 56b91bf..23d7f17 100644 --- a/App/app/(portal)/history/page.tsx +++ b/App/app/(portal)/history/page.tsx @@ -1,6 +1,6 @@ import { auth } from "@/auth"; import { db } from "@/lib/db"; -import { hasPermission } from "@/lib/permissions"; +import { hasPermission, submitterCanViewAll } from "@/lib/permissions"; import { redirect } from "next/navigation"; import Link from "next/link"; import { formatCurrency, formatDate } from "@/lib/utils"; @@ -27,7 +27,14 @@ export default async function HistoryPage({ searchParams }: Props) { const session = await auth(); if (!session?.user) redirect("/login"); - if (!hasPermission(session.user.role, "export_reports")) redirect("/dashboard"); + // Report-export holders see History; submitters get read+export access when the + // submitter-view-all feature flag is on. + if ( + !hasPermission(session.user.role, "export_reports") && + !submitterCanViewAll(session.user.role) + ) { + redirect("/dashboard"); + } const { dateFrom, dateTo, approvedFrom, approvedTo, vesselId, status } = await searchParams; diff --git a/App/app/(portal)/po/[id]/page.tsx b/App/app/(portal)/po/[id]/page.tsx index e61d47b..cb5cabd 100644 --- a/App/app/(portal)/po/[id]/page.tsx +++ b/App/app/(portal)/po/[id]/page.tsx @@ -2,6 +2,7 @@ import { auth } from "@/auth"; import { db } from "@/lib/db"; import { notFound, redirect } from "next/navigation"; import { PoDetail } from "@/components/po/po-detail"; +import { canViewAllPos } from "@/lib/permissions"; import { VendorIdForm } from "./vendor-id-form"; import type { Metadata } from "next"; @@ -39,11 +40,11 @@ export default async function PoDetailPage({ params }: Props) { if (!po) notFound(); - // Submitters can only view their own POs (unless they have view_all_pos) - const canViewAll = ["ACCOUNTS", "MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"].includes( - session.user.role - ); - if (!canViewAll && po.submitterId !== session.user.id) redirect("/dashboard"); + // Submitters can only view their own POs — unless they hold view_all_pos, or the + // submitter-view-all feature flag grants them read access to every PO. + if (!canViewAllPos(session.user.role) && po.submitterId !== session.user.id) { + redirect("/dashboard"); + } const canProvideVendorId = po.status === "VENDOR_ID_PENDING" && diff --git a/App/app/api/po/[id]/export/route.ts b/App/app/api/po/[id]/export/route.ts index 054a9e4..41e895e 100644 --- a/App/app/api/po/[id]/export/route.ts +++ b/App/app/api/po/[id]/export/route.ts @@ -7,6 +7,7 @@ import { downloadBuffer } from "@/lib/storage"; import { CANCELLED_WATERMARK_PNG_BASE64, CANCELLED_WATERMARK_W, CANCELLED_WATERMARK_H } from "@/lib/cancelled-watermark"; import { getImageSize, scaleToBox } from "@/lib/image-size"; import { signatoryLayout } from "@/lib/po-export-layout"; +import { canViewAllPos } from "@/lib/permissions"; // ── Company fallback constants (used when no company is linked to a PO) ────── @@ -66,8 +67,9 @@ export async function GET(request: NextRequest, { params }: Props) { }); if (!po) return NextResponse.json({ error: "Not found" }, { status: 404 }); - const canViewAll = ["ACCOUNTS", "MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"].includes(session.user.role); - if (!canViewAll && po.submitterId !== session.user.id) { + // view_all_pos holders, or submitters when the view-all feature flag is on, may export + // any PO; everyone else only their own. + if (!canViewAllPos(session.user.role) && po.submitterId !== session.user.id) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } diff --git a/App/app/api/reports/export/route.ts b/App/app/api/reports/export/route.ts index 19bdf4b..ee82eae 100644 --- a/App/app/api/reports/export/route.ts +++ b/App/app/api/reports/export/route.ts @@ -1,6 +1,6 @@ import { auth } from "@/auth"; import { db } from "@/lib/db"; -import { hasPermission } from "@/lib/permissions"; +import { hasPermission, submitterCanViewAll } from "@/lib/permissions"; import { NextRequest, NextResponse } from "next/server"; import type { POStatus } from "@prisma/client"; @@ -16,7 +16,10 @@ export async function GET(request: NextRequest) { if (!session?.user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - if (!hasPermission(session.user.role, "export_reports")) { + if ( + !hasPermission(session.user.role, "export_reports") && + !submitterCanViewAll(session.user.role) + ) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } diff --git a/App/components/layout/sidebar.tsx b/App/components/layout/sidebar.tsx index fcd17bc..95aaa64 100644 --- a/App/components/layout/sidebar.tsx +++ b/App/components/layout/sidebar.tsx @@ -2,7 +2,7 @@ import { usePathname } from "next/navigation"; import Link from "next/link"; -import { INVENTORY_ENABLED } from "@/lib/feature-flags"; +import { INVENTORY_ENABLED, SUBMITTER_VIEW_ALL_ENABLED } from "@/lib/feature-flags"; import { cn } from "@/lib/utils"; import { LayoutDashboard, @@ -34,6 +34,13 @@ interface NavItem { roles?: Role[]; } +// History is open to all-PO viewers; when the submitter-view-all flag is on, submitters +// (TECHNICAL / MANNING) get read+export access to it too. +const HISTORY_ROLES: Role[] = [ + "MANAGER", "SUPERUSER", "AUDITOR", "ADMIN", + ...(SUBMITTER_VIEW_ALL_ENABLED ? (["TECHNICAL", "MANNING"] as Role[]) : []), +]; + const NAV_ITEMS: NavItem[] = [ { href: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, { href: "/po/new", label: "New PO", icon: Plus, roles: ["TECHNICAL", "MANNING", "MANAGER", "SUPERUSER"] }, @@ -42,7 +49,7 @@ const NAV_ITEMS: NavItem[] = [ { href: "/approvals", label: "Approvals", icon: CheckSquare, roles: ["MANAGER", "SUPERUSER"] }, { href: "/payments", label: "Payments", icon: CreditCard, roles: ["ACCOUNTS"] }, { href: "/payments/history", label: "Payment History", icon: Receipt, roles: ["ACCOUNTS", "SUPERUSER"] }, - { href: "/history", label: "History", icon: History, roles: ["MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"] }, + { href: "/history", label: "History", icon: History, roles: HISTORY_ROLES }, { href: "/profile", label: "My Profile", icon: UserCircle }, ]; diff --git a/App/lib/feature-flags.ts b/App/lib/feature-flags.ts index 3b662a2..cd442f6 100644 --- a/App/lib/feature-flags.ts +++ b/App/lib/feature-flags.ts @@ -4,7 +4,16 @@ * * NEXT_PUBLIC_INVENTORY_ENABLED=false → hides inventory tracking (site qty/consumption) * Vendor list, product catalogue, and cart remain available for PO creation regardless. + * + * NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED=true → lets submitters (TECHNICAL / MANNING) + * read every PO (not just their own), open the History page, and use the export buttons. + * Opt-in (off unless explicitly "true") because it widens read access. Submitters stay + * read-only — it grants no approval, payment, or edit rights. See lib/permissions.ts + * (canViewAllPos / submitterCanViewAll). */ export const INVENTORY_ENABLED = process.env.NEXT_PUBLIC_INVENTORY_ENABLED !== "false"; + +export const SUBMITTER_VIEW_ALL_ENABLED = + process.env.NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED === "true"; diff --git a/App/lib/permissions.ts b/App/lib/permissions.ts index 24c549c..ebfec96 100644 --- a/App/lib/permissions.ts +++ b/App/lib/permissions.ts @@ -1,4 +1,5 @@ import type { Role } from "@prisma/client"; +import { SUBMITTER_VIEW_ALL_ENABLED } from "./feature-flags"; export type Permission = | "create_po" @@ -92,3 +93,31 @@ export function requirePermission(role: Role, permission: Permission): void { export function getPermissions(role: Role): Permission[] { return ROLE_PERMISSIONS[role] ?? []; } + +// ── Submitter roles & feature-flagged view-all ──────────────────────────────── +// Submitters raise and track their own POs. The two "submitter" roles below hold +// `view_own_pos` but not `view_all_pos`. + +export const SUBMITTER_ROLES: Role[] = ["TECHNICAL", "MANNING"]; + +export function isSubmitterRole(role: Role): boolean { + return SUBMITTER_ROLES.includes(role); +} + +/** + * Feature-flagged: when NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED=true, submitters may + * read & export every PO (not just their own) and reach the History page. This is a + * read-only widening — it does not grant approval, payment, or edit rights. + */ +export function submitterCanViewAll(role: Role): boolean { + return SUBMITTER_VIEW_ALL_ENABLED && isSubmitterRole(role); +} + +/** + * Whether a role may view/export any PO, not just the ones they submitted. + * True for `view_all_pos` holders (ACCOUNTS, MANAGER, SUPERUSER, AUDITOR, ADMIN) and, + * when the feature flag is on, for submitters too. + */ +export function canViewAllPos(role: Role): boolean { + return hasPermission(role, "view_all_pos") || submitterCanViewAll(role); +} diff --git a/App/tests/unit/permissions.test.ts b/App/tests/unit/permissions.test.ts index ef514bd..2afdf4d 100644 --- a/App/tests/unit/permissions.test.ts +++ b/App/tests/unit/permissions.test.ts @@ -1,5 +1,11 @@ -import { describe, it, expect } from "vitest"; -import { hasPermission, requirePermission } from "@/lib/permissions"; +import { describe, it, expect, vi, afterEach } from "vitest"; +import { + hasPermission, + requirePermission, + isSubmitterRole, + submitterCanViewAll, + canViewAllPos, +} from "@/lib/permissions"; describe("Permissions", () => { describe("hasPermission", () => { @@ -99,6 +105,64 @@ describe("Permissions", () => { }); }); + // ── Submitter view-all (feature-flagged) ────────────────────────────────── + describe("isSubmitterRole", () => { + it("is true for the two submitter roles", () => { + expect(isSubmitterRole("TECHNICAL")).toBe(true); + expect(isSubmitterRole("MANNING")).toBe(true); + }); + + it("is false for every other role", () => { + for (const role of ["ACCOUNTS", "MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"] as const) { + expect(isSubmitterRole(role)).toBe(false); + } + }); + }); + + describe("canViewAllPos / submitterCanViewAll — flag OFF (default)", () => { + it("submitters cannot view all POs", () => { + expect(canViewAllPos("TECHNICAL")).toBe(false); + expect(canViewAllPos("MANNING")).toBe(false); + expect(submitterCanViewAll("TECHNICAL")).toBe(false); + }); + + it("view_all_pos holders can still view all POs", () => { + for (const role of ["ACCOUNTS", "MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"] as const) { + expect(canViewAllPos(role)).toBe(true); + } + }); + }); + + describe("canViewAllPos / submitterCanViewAll — flag ON", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it("submitters gain view-all when NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED=true", async () => { + vi.resetModules(); + vi.stubEnv("NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED", "true"); + const perms = await import("@/lib/permissions"); + + expect(perms.submitterCanViewAll("TECHNICAL")).toBe(true); + expect(perms.submitterCanViewAll("MANNING")).toBe(true); + expect(perms.canViewAllPos("TECHNICAL")).toBe(true); + expect(perms.canViewAllPos("MANNING")).toBe(true); + }); + + it("does not widen non-submitter roles, and is read-only (no approve/edit)", async () => { + vi.resetModules(); + vi.stubEnv("NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED", "true"); + const perms = await import("@/lib/permissions"); + + expect(perms.submitterCanViewAll("MANAGER")).toBe(false); + expect(perms.canViewAllPos("ACCOUNTS")).toBe(true); // unchanged + // The flag grants read access only — no approval or edit rights. + expect(perms.hasPermission("TECHNICAL", "approve_po")).toBe(false); + expect(perms.hasPermission("TECHNICAL", "view_all_pos")).toBe(false); + }); + }); + describe("requirePermission", () => { it("does not throw when permission is granted", () => { expect(() => requirePermission("MANAGER", "approve_po")).not.toThrow(); From 964af311f8450a214dfa604b0d89eff7a1da36ca Mon Sep 17 00:00:00 2001 From: "Claude (auto-fix)" Date: Tue, 23 Jun 2026 20:44:37 +0530 Subject: [PATCH 2/2] feat(sidebar): make section headings collapsible accordion Purchasing, Crewing and Administration headings are now collapsible buttons (chevron + aria-expanded/aria-controls) that collapse by default. Single-open accordion: opening one heading collapses any other open one. The section containing the active route auto-expands on mount/navigation so the user is never stranded on a hidden link. Adds a jsdom/Testing Library unit test covering default-collapsed, toggle, single-open accordion, and active-route auto-expand. Fixes #96 Co-Authored-By: Claude Opus 4.8 (1M context) --- App/components/layout/sidebar.tsx | 125 ++++++++++++++++++++---------- App/tests/unit/sidebar.test.tsx | 102 ++++++++++++++++++++++++ 2 files changed, 184 insertions(+), 43 deletions(-) create mode 100644 App/tests/unit/sidebar.test.tsx diff --git a/App/components/layout/sidebar.tsx b/App/components/layout/sidebar.tsx index e894771..65a335c 100644 --- a/App/components/layout/sidebar.tsx +++ b/App/components/layout/sidebar.tsx @@ -1,5 +1,6 @@ "use client"; +import { useEffect, useState } from "react"; import { usePathname } from "next/navigation"; import Link from "next/link"; import { INVENTORY_ENABLED, CREWING_ENABLED } from "@/lib/feature-flags"; @@ -33,6 +34,7 @@ import { UserCog, Gauge, BadgeCheck, + ChevronRight, } from "lucide-react"; import type { Role } from "@prisma/client"; @@ -117,6 +119,16 @@ const ADMIN_ITEMS: NavItem[] = [ { href: "/admin/companies", label: "Companies", icon: Briefcase }, ]; +interface Section { + id: string; + label: string; + items: NavItem[]; +} + +function isItemActive(href: string, pathname: string) { + return pathname === href || pathname.startsWith(href + "/"); +} + export function Sidebar({ userRole }: { userRole: Role }) { const pathname = usePathname(); const isAdmin = userRole === "ADMIN"; @@ -125,6 +137,31 @@ export function Sidebar({ userRole }: { userRole: Role }) { const visiblePurchasing = PURCHASING_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole)); const visibleCrewing = CREWING_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole)); const visibleMgrAdmin = MANAGER_ADMIN_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole)); + const adminItems = isAdmin ? [...MANAGER_ADMIN_ITEMS, ...ADMIN_ITEMS] : visibleMgrAdmin; + + // Headed, collapsible sections (the main links above sit outside any section). + const sections: Section[] = [ + { id: "purchasing", label: "Purchasing", items: visiblePurchasing }, + { id: "crewing", label: "Crewing", items: visibleCrewing }, + { id: "administration", label: "Administration", items: adminItems }, + ].filter((s) => s.items.length > 0); + + // The section (if any) that holds the currently active route. + const activeSectionId = + sections.find((s) => s.items.some((i) => isItemActive(i.href, pathname)))?.id ?? null; + + // Single-open accordion, collapsed by default. Auto-expand the section that + // contains the active route so the user is never stranded on a hidden link. + const [openSection, setOpenSection] = useState(activeSectionId); + + // On navigation, open the section holding the new active route (which, being a + // single-open accordion, collapses any other open heading). + useEffect(() => { + if (activeSectionId) setOpenSection(activeSectionId); + }, [activeSectionId]); + + const toggleSection = (id: string) => + setOpenSection((current) => (current === id ? null : id)); return ( ); } -function SectionHeader({ label }: { label: string }) { +function SectionHeader({ + label, + isOpen, + regionId, + onToggle, +}: { + label: string; + isOpen: boolean; + regionId: string; + onToggle: () => void; +}) { return ( -
-

{label}

-
+ ); } function NavLink({ item, pathname }: { item: NavItem; pathname: string }) { - const isActive = pathname === item.href || pathname.startsWith(item.href + "/"); + const isActive = isItemActive(item.href, pathname); const Icon = item.icon; return ( ({ usePathname: () => mockPathname })); + +import { Sidebar } from "@/components/layout/sidebar"; + +beforeEach(() => { + mockPathname = "/dashboard"; +}); + +function headerButton(label: string) { + return screen.getByRole("button", { name: new RegExp(`^${label}`, "i") }); +} + +describe("Sidebar collapsible sections", () => { + it("renders section headings as toggle buttons, collapsed by default", () => { + // ADMIN sees a Purchasing-less layout? No — render a MANAGER who has + // Purchasing + Administration headed sections. + render(); + + const purchasing = headerButton("Purchasing"); + const administration = headerButton("Administration"); + + expect(purchasing).toHaveAttribute("aria-expanded", "false"); + expect(administration).toHaveAttribute("aria-expanded", "false"); + + // Collapsed → section links are not in the DOM. + expect(screen.queryByRole("link", { name: /Cost Centres/i })).not.toBeInTheDocument(); + }); + + it("expands a section and reveals its links when its header is clicked", () => { + render(); + + const purchasing = headerButton("Purchasing"); + fireEvent.click(purchasing); + + expect(purchasing).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByRole("link", { name: /Cost Centres/i })).toBeInTheDocument(); + }); + + it("collapses other sections when one is opened (single-open accordion)", () => { + render(); + + const purchasing = headerButton("Purchasing"); + const administration = headerButton("Administration"); + + fireEvent.click(purchasing); + expect(purchasing).toHaveAttribute("aria-expanded", "true"); + + fireEvent.click(administration); + expect(administration).toHaveAttribute("aria-expanded", "true"); + // Opening Administration collapses Purchasing. + expect(purchasing).toHaveAttribute("aria-expanded", "false"); + }); + + it("toggles a section closed when its header is clicked again", () => { + render(); + + const purchasing = headerButton("Purchasing"); + fireEvent.click(purchasing); + expect(purchasing).toHaveAttribute("aria-expanded", "true"); + + fireEvent.click(purchasing); + expect(purchasing).toHaveAttribute("aria-expanded", "false"); + }); + + it("auto-expands the section containing the active route on mount", () => { + mockPathname = "/admin/vessels"; // Cost Centres lives under Administration (manager mgmt → Purchasing) + render(); + + // /admin/vessels is in the Purchasing management block for a MANAGER. + const purchasing = headerButton("Purchasing"); + expect(purchasing).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByRole("link", { name: /Cost Centres/i })).toBeInTheDocument(); + }); + + it("keeps the PPMS brand outside any collapsible section", () => { + render(); + // Brand text is always visible regardless of section state. + expect(screen.getByText("PPMS")).toBeInTheDocument(); + }); + + it("renders the always-visible main links outside the sections", () => { + render(); + expect(screen.getByRole("link", { name: /Dashboard/i })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /My Profile/i })).toBeInTheDocument(); + }); + + it("scopes revealed links to the opened section", () => { + render(); + const administration = headerButton("Administration"); + fireEvent.click(administration); + + // Vendors appears under Administration for a manager. + const adminVendors = screen.getByRole("link", { name: /Vendors/i }); + expect(adminVendors).toBeInTheDocument(); + expect(within(adminVendors).queryByText("Vendors")).toBeTruthy(); + }); +});