diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index 9769a7f18..d478da8f3 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -14,6 +14,13 @@ export const BookingStaff = (permission: string | string[]) => ), ); +/** + * Read-only reference data (yard dropdowns, search filters): any signed-in + * staff. Menu/page visibility stays permission-gated in the frontend — this + * only lets forms populate their lookups. + */ +export const StaffReference = () => applyDecorators(UseGuards(JwtGuard)); + export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view); export const TrainSchedulingView = () => diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts index 421e49165..3b1bf6ce1 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts @@ -3,7 +3,8 @@ import { Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { RuleEngineManage } from '../../../common/rule-engine-guards'; +import { StaffReference } from '../../../common/booking-guards'; import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto'; import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto'; import { MoveOrderDto } from '../dto/move-order.dto'; @@ -18,7 +19,7 @@ export class CargoTypesController { constructor(private readonly service: CargoTypesService) {} @Get() - @RuleEngineView('cargo-types') + @StaffReference() @ApiOperation({ summary: 'List cargo types' }) findAll(@Query() query: ListCargoTypesQueryDto) { return this.service.findAll(query); @@ -41,7 +42,7 @@ export class CargoTypesController { } @Get(':id') - @RuleEngineView('cargo-types') + @StaffReference() @ApiOperation({ summary: 'Get a cargo type by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.service.findById(id); diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts index 3c1c27c7c..9ae96af9b 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts @@ -3,7 +3,8 @@ import { Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { RuleEngineManage } from '../../../common/rule-engine-guards'; +import { StaffReference } from '../../../common/booking-guards'; import { CreateContainerTypeDto } from '../dto/create-container-type.dto'; import { ListContainerTypesQueryDto } from '../dto/list-rule-engine-query.dto'; import { MoveOrderDto } from '../dto/move-order.dto'; @@ -18,7 +19,7 @@ export class ContainerTypesController { constructor(private readonly service: ContainerTypesService) {} @Get() - @RuleEngineView('container-types') + @StaffReference() @ApiOperation({ summary: 'List container types' }) findAll(@Query() query: ListContainerTypesQueryDto) { return this.service.findAll(query); @@ -41,7 +42,7 @@ export class ContainerTypesController { } @Get(':id') - @RuleEngineView('container-types') + @StaffReference() @ApiOperation({ summary: 'Get a container type by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.service.findById(id); diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts index c36d4fd79..85d76b326 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts @@ -2,7 +2,8 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; -import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { RuleEngineManage } from '../../../common/rule-engine-guards'; +import { StaffReference } from '../../../common/booking-guards'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CreateServiceTypeDto } from '../dto/create-service-type.dto'; import { ListServiceTypesQueryDto } from '../dto/list-rule-engine-query.dto'; @@ -18,7 +19,7 @@ export class ServiceTypesController { constructor(private readonly service: ServiceTypesService) {} @Get() - @RuleEngineView('service-types') + @StaffReference() @ApiOperation({ summary: 'List service types' }) findAll(@Query() query: ListServiceTypesQueryDto) { return this.service.findAll(query); @@ -41,7 +42,7 @@ export class ServiceTypesController { } @Get(':id') - @RuleEngineView('service-types') + @StaffReference() @ApiOperation({ summary: 'Get a service type by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.service.findById(id); diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/shipping-lines.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/shipping-lines.controller.ts index baec6b785..f078624eb 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/shipping-lines.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/shipping-lines.controller.ts @@ -2,7 +2,8 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; -import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { RuleEngineManage } from '../../../common/rule-engine-guards'; +import { StaffReference } from '../../../common/booking-guards'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CreateShippingLineDto } from '../dto/create-shipping-line.dto'; import { ListRuleEngineQueryDto } from '../dto/list-rule-engine-query.dto'; @@ -16,14 +17,14 @@ export class ShippingLinesController { constructor(private readonly service: ShippingLinesService) {} @Get() - @RuleEngineView('shipping-lines') + @StaffReference() @ApiOperation({ summary: 'List shipping lines' }) findAll(@Query() query: ListRuleEngineQueryDto) { return this.service.findAll(query); } @Get(':id') - @RuleEngineView('shipping-lines') + @StaffReference() @ApiOperation({ summary: 'Get a shipping line by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.service.findById(id); diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/yard-distances.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/yard-distances.controller.ts index c43e7e4e0..b0ba2c8d5 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/yard-distances.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/yard-distances.controller.ts @@ -12,7 +12,8 @@ import { Query, } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { RuleEngineManage } from '../../../common/rule-engine-guards'; +import { StaffReference } from '../../../common/booking-guards'; import { CreateYardDistanceDto } from '../dto/create-yard-distance.dto'; import { ListYardDistancesQueryDto } from '../dto/list-rule-engine-query.dto'; import { UpdateYardDistanceDto } from '../dto/update-yard-distance.dto'; @@ -25,14 +26,14 @@ export class YardDistancesController { constructor(private readonly service: YardDistancesService) {} @Get() - @RuleEngineView('yard-distances') + @StaffReference() @ApiOperation({ summary: 'List yard distances' }) findAll(@Query() query: ListYardDistancesQueryDto) { return this.service.findAll(query); } @Get(':id') - @RuleEngineView('yard-distances') + @StaffReference() @ApiOperation({ summary: 'Get a yard distance by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.service.findById(id); diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts index 40b2764bf..b8f88b6b3 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts @@ -2,7 +2,8 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; -import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { RuleEngineManage } from '../../../common/rule-engine-guards'; +import { StaffReference } from '../../../common/booking-guards'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CreateYardDto } from '../dto/create-yard.dto'; import { ListYardsQueryDto } from '../dto/list-rule-engine-query.dto'; @@ -18,7 +19,9 @@ export class YardsController { constructor(private readonly service: YardsService) {} @Get() - @RuleEngineView('yards') + // Reference read: every staff form/search needs the yard list (origin / + // destination pickers), so login is the only requirement. + @StaffReference() @ApiOperation({ summary: 'List yards' }) findAll(@Query() query: ListYardsQueryDto) { return this.service.findAll(query); @@ -41,7 +44,7 @@ export class YardsController { } @Get(':id') - @RuleEngineView('yards') + @StaffReference() @ApiOperation({ summary: 'Get a yard by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.service.findById(id); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts index fdfbc36be..5f4205815 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts @@ -1,7 +1,7 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { BookingStaff } from '../../common/booking-guards'; +import { BookingStaff, StaffReference } from '../../common/booking-guards'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto'; import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto'; @@ -10,8 +10,9 @@ import { WarehouseZonesService } from './warehouse-zones.service'; @ApiTags('warehouse-yards') @ApiBearerAuth() +// No class-level guard: the two reference GETs are open to any signed-in +// staff (StaffReference), every other route carries its own permission. @Controller('warehouse-yards') -@BookingStaff(FREIGHT_PERMS.warehouseYards.view) export class WarehouseYardsController { constructor( private readonly yardsService: WarehouseYardsService, @@ -19,12 +20,14 @@ export class WarehouseYardsController { ) {} @Get() + @StaffReference() @ApiOperation({ summary: 'List all warehouse yards' }) findAll() { return this.yardsService.findAll(); } @Get(':id') + @StaffReference() @ApiOperation({ summary: 'Get warehouse yard by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.yardsService.findById(id); diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 8467d18b7..aed710e2e 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -151,6 +151,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Customers", href: "/dashboard/customers", icon: , + permission: FREIGHT_PERMS.customers.view, }, { label: "Contracts", @@ -162,6 +163,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Bookings", href: "/dashboard/booking-requests", icon: , + permission: FREIGHT_PERMS.bookings.view, }, // Operations hub: clearance-document review for contracts WITHOUT // customs clearing (contract-level for one-time, per-booking for general). @@ -375,6 +377,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Imports", href: "/dashboard/import-warehouse", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, children: [ { label: "Import Overview", @@ -407,6 +410,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Exports", href: "/dashboard/export-warehouse", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, children: [ { label: "Export Overview", @@ -449,6 +453,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Intercity", href: "/dashboard/intercity", icon: , + permission: FREIGHT_PERMS.trainScheduling.view, children: [ { label: "Intercity Cargo", @@ -465,6 +470,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Warehouse Dashboard", href: "/dashboard/warehouse-dashboard", icon: , + permission: FREIGHT_PERMS.warehouseDashboard.view, }, { // Yard-wide, not per-direction: the gate sees import and export @@ -472,21 +478,25 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Trucks on Site", href: "/dashboard/trucks-on-site", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Warehouses", href: "/dashboard/warehouses", icon: , + permission: FREIGHT_PERMS.warehouses.view, }, { label: "Allocation & Fees", href: "/dashboard/warehouse-rules", icon: , + permission: FREIGHT_PERMS.warehouseAllocationRules.view, }, { label: "Fee Invoices", href: "/dashboard/warehouse-fee-invoices", icon: , + permission: FREIGHT_PERMS.warehouseFeeInvoices.view, }, ], }, @@ -523,10 +533,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ { label: "Contract validity", href: "/dashboard/configuration/contract-validity-periods", + permission: FREIGHT_PERMS.config.contractValidity.view, }, { label: "Train scheduling rules", href: "/dashboard/configuration/train-scheduling-rules", + permission: FREIGHT_PERMS.trainScheduling.rulesManage, }, ], }, @@ -541,6 +553,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Staff", href: "/user-management", icon: , + permission: FREIGHT_PERMS.admin, }, ], }, @@ -596,10 +609,22 @@ const filterSidebarByPermission = ( return permissionAllowed(item); }; + // Recursive: a group's own permission gates the whole subtree, leaves are + // checked individually, and a group with no surviving children disappears. + const filterItems = (items: SidebarItem[]): SidebarItem[] => + items.flatMap((item) => { + if (item.children?.length) { + if (item.permission && !permissionAllowed(item)) return []; + const children = filterItems(item.children); + return children.length ? [{ ...item, children }] : []; + } + return itemAllowed(item) ? [item] : []; + }); + return sections .map((section) => ({ ...section, - items: section.items.filter(itemAllowed), + items: filterItems(section.items), })) .filter((section) => section.items.length > 0); }; diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index 7a2d18aee..2543addc0 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -1,4 +1,5 @@ import type { SidebarItem } from "@/components/layout/types"; +import { ruleEngineViewKey } from "@/lib/permissions"; import type { RuleEngineResourceSlug } from "@/types/rule-engine"; export type RuleEngineNavCategory = "configuration" | "rules"; @@ -787,6 +788,7 @@ export const getCategorySidebarChildren = ( RULE_ENGINE_RESOURCES.filter((r) => r.category === category).map((r) => ({ label: r.label, href: ruleEngineResourcePath(r.slug), + permission: ruleEngineViewKey(r.slug), })); export const DEFAULT_CONFIGURATION_SLUG: RuleEngineResourceSlug = "cargo-types";