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