From da08a9b0859da246d7c609cf44f467e73498291c Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 7 Aug 2026 08:42:53 +0000 Subject: [PATCH 1/3] fix(auth): list every route key on the class-level guard Nest runs class and method guards together, so a class gate naming only the view key silently required view AND action. Staff granted just an action were denied before their key was checked. Each class gate now names every key its routes use, and FleetView accepts an array so the fleet controllers keep their coarse fallback. Drops the one-off grant mapping SQL with it: already applied to dev, and this fix removes the companion-view rule that was its recurring part. --- .../src/common/booking-guards.ts | 9 +- .../src/modules/billing/billing.controller.ts | 7 +- .../src/modules/cargoes/cargoes.controller.ts | 9 +- .../compliance/compliance.controller.ts | 7 +- .../consignments/consignments.controller.ts | 7 +- .../containers.controller.ts | 9 +- .../src/modules/drivers/drivers.controller.ts | 9 +- .../facilities/facilities.controller.ts | 7 +- .../gps-tracking/gps-tracking.controller.ts | 7 +- .../modules/incidents/incidents.controller.ts | 9 +- .../interchange-documents.controller.ts | 9 +- .../procurement/procurement.controller.ts | 9 +- .../src/modules/routes/routes.controller.ts | 10 +- .../trains/train-builder.controller.ts | 10 +- .../src/modules/trains/trains.controller.ts | 9 +- .../modules/vehicles/vehicles.controller.ts | 9 +- .../warehouse-inspection.controller.ts | 4 + .../warehouses/warehouse-zones.controller.ts | 8 +- deploy/position-type-grant-mapping.sql | 119 ------------------ 19 files changed, 130 insertions(+), 137 deletions(-) delete mode 100644 deploy/position-type-grant-mapping.sql diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index 49bd31c61..d1b5364c3 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -95,9 +95,14 @@ export const TrainSchedulingRulesManage = () => * wagons:delete, …). The legacy coarse fleet:view / fleet:manage keys remain * valid as a one-of fallback so existing role grants keep working. */ -export const FleetView = (granular?: string) => +export const FleetView = (granular?: string | string[]) => BookingStaff( - granular ? [granular, FREIGHT_PERMS.fleet.view] : FREIGHT_PERMS.fleet.view, + granular + ? [ + ...(Array.isArray(granular) ? granular : [granular]), + FREIGHT_PERMS.fleet.view, + ] + : FREIGHT_PERMS.fleet.view, ); export const FleetManage = (granular?: string) => diff --git a/apps/edr-freight-api/src/modules/billing/billing.controller.ts b/apps/edr-freight-api/src/modules/billing/billing.controller.ts index d72528310..ea43c9c74 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts @@ -20,7 +20,12 @@ import { FilterInvoiceDto } from "./dto/filter-invoice.dto"; @ApiTags("billing") @Controller("billing") -@BookingStaff(FREIGHT_PERMS.invoices.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@BookingStaff([ + FREIGHT_PERMS.invoices.view, + FREIGHT_PERMS.invoices.export, +]) @ApiBearerAuth() export class BillingController { constructor( diff --git a/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts b/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts index ac3adb7e8..09c9b677a 100644 --- a/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts +++ b/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts @@ -20,7 +20,14 @@ import { CargoesService } from './cargoes.service'; @ApiTags('cargoes') @Controller('cargoes') -@FleetView(FREIGHT_PERMS.cargoes.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@FleetView([ + FREIGHT_PERMS.cargoes.view, + FREIGHT_PERMS.cargoes.create, + FREIGHT_PERMS.cargoes.update, + FREIGHT_PERMS.cargoes.delete, +]) export class CargoesController { constructor(private readonly cargoesService: CargoesService) {} diff --git a/apps/edr-freight-api/src/modules/compliance/compliance.controller.ts b/apps/edr-freight-api/src/modules/compliance/compliance.controller.ts index 0e5949300..25d010253 100644 --- a/apps/edr-freight-api/src/modules/compliance/compliance.controller.ts +++ b/apps/edr-freight-api/src/modules/compliance/compliance.controller.ts @@ -11,7 +11,12 @@ import { ComplianceType } from './entities/compliance-record.entity'; @ApiTags('Vehicle Compliance') @Controller('compliance') -@BookingStaff(FREIGHT_PERMS.compliance.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@BookingStaff([ + FREIGHT_PERMS.compliance.view, + FREIGHT_PERMS.compliance.manage, +]) export class ComplianceController { constructor(private readonly complianceService: ComplianceService) {} diff --git a/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts b/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts index 579b9ee26..a9aaa73e3 100644 --- a/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts +++ b/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts @@ -17,7 +17,12 @@ import { FilterConsignmentDto } from "./dto/filter-consignment.dto"; @ApiTags("consignments") @Controller("consignments") -@FleetView(FREIGHT_PERMS.consignments.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@FleetView([ + FREIGHT_PERMS.consignments.view, + FREIGHT_PERMS.consignments.create, +]) export class ConsignmentsController { constructor(private readonly consignmentsService: ConsignmentsService) {} diff --git a/apps/edr-freight-api/src/modules/container-management/containers.controller.ts b/apps/edr-freight-api/src/modules/container-management/containers.controller.ts index 0a5e6bb0f..a209e2609 100644 --- a/apps/edr-freight-api/src/modules/container-management/containers.controller.ts +++ b/apps/edr-freight-api/src/modules/container-management/containers.controller.ts @@ -19,7 +19,14 @@ import { ContainersService } from './containers.service'; @ApiTags('containers') @Controller('containers') -@FleetView(FREIGHT_PERMS.containers.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@FleetView([ + FREIGHT_PERMS.containers.view, + FREIGHT_PERMS.containers.create, + FREIGHT_PERMS.containers.update, + FREIGHT_PERMS.containers.delete, +]) export class ContainersController { constructor(private readonly containersService: ContainersService) {} diff --git a/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts b/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts index e5b6ae146..8d2be55df 100644 --- a/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts +++ b/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts @@ -24,7 +24,14 @@ import { FleetHistoryService } from '../fleet-history/fleet-history.service'; @ApiTags('drivers') @ApiBearerAuth() @Controller('drivers') -@BookingStaff(FREIGHT_PERMS.drivers.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@BookingStaff([ + FREIGHT_PERMS.drivers.view, + FREIGHT_PERMS.drivers.create, + FREIGHT_PERMS.drivers.update, + FREIGHT_PERMS.drivers.delete, +]) export class DriversController { constructor( private readonly driversService: DriversService, diff --git a/apps/edr-freight-api/src/modules/facilities/facilities.controller.ts b/apps/edr-freight-api/src/modules/facilities/facilities.controller.ts index 451c4d03b..7db35d432 100644 --- a/apps/edr-freight-api/src/modules/facilities/facilities.controller.ts +++ b/apps/edr-freight-api/src/modules/facilities/facilities.controller.ts @@ -10,7 +10,12 @@ import { FacilitiesService } from './facilities.service'; @ApiTags('Facilities') @Controller('facilities') -@BookingStaff(FREIGHT_PERMS.facilities.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@BookingStaff([ + FREIGHT_PERMS.facilities.view, + FREIGHT_PERMS.facilities.manage, +]) export class FacilitiesController { constructor(private readonly facilitiesService: FacilitiesService) {} diff --git a/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts index 8bd4a31d8..d0cc2d1b4 100644 --- a/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts +++ b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts @@ -19,7 +19,12 @@ import { RegisterDeviceDto, UpdateDeviceDto } from './dto/gps-device.dto'; @ApiTags('gps-tracking') @ApiBearerAuth() @Controller('gps') -@BookingStaff(FREIGHT_PERMS.tracking.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@BookingStaff([ + FREIGHT_PERMS.tracking.view, + FREIGHT_PERMS.tracking.manage, +]) export class GpsTrackingController { constructor(private readonly gps: GpsTrackingService) {} diff --git a/apps/edr-freight-api/src/modules/incidents/incidents.controller.ts b/apps/edr-freight-api/src/modules/incidents/incidents.controller.ts index b9cebd653..2e9dfb169 100644 --- a/apps/edr-freight-api/src/modules/incidents/incidents.controller.ts +++ b/apps/edr-freight-api/src/modules/incidents/incidents.controller.ts @@ -22,7 +22,14 @@ import { IncidentStatus, IncidentType } from './entities/incident.entity'; // No incidents-specific permission exists in the registry, so this reuses the // (real) drivers.* fleet-road keys — incident records are driver-safety data // (driver stats / incident history). TODO: add a dedicated incidents:* key. -@BookingStaff(FREIGHT_PERMS.drivers.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@BookingStaff([ + FREIGHT_PERMS.drivers.view, + FREIGHT_PERMS.drivers.create, + FREIGHT_PERMS.drivers.update, + FREIGHT_PERMS.drivers.delete, +]) export class IncidentsController { constructor(private readonly incidentsService: IncidentsService) {} diff --git a/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.controller.ts b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.controller.ts index 61a13744e..b5e92ca39 100644 --- a/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.controller.ts +++ b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.controller.ts @@ -15,7 +15,14 @@ import { InterchangeDocumentsService } from './interchange-documents.service'; @ApiBearerAuth() @Controller('interchange-documents') // Class-level view guard; each write route adds its own manage permission below. -@BookingStaff(FREIGHT_PERMS.interchangeDocuments.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@BookingStaff([ + FREIGHT_PERMS.interchangeDocuments.view, + FREIGHT_PERMS.interchangeDocuments.generate, + FREIGHT_PERMS.interchangeDocuments.acknowledge, + FREIGHT_PERMS.interchangeDocuments.dispute, +]) export class InterchangeDocumentsController { constructor(private readonly service: InterchangeDocumentsService) {} diff --git a/apps/edr-freight-api/src/modules/procurement/procurement.controller.ts b/apps/edr-freight-api/src/modules/procurement/procurement.controller.ts index 374b2bd53..e96b9dc74 100644 --- a/apps/edr-freight-api/src/modules/procurement/procurement.controller.ts +++ b/apps/edr-freight-api/src/modules/procurement/procurement.controller.ts @@ -13,7 +13,14 @@ import { @ApiTags('Procurement & Asset Lifecycle') @Controller('procurement') -@BookingStaff(FREIGHT_PERMS.procurement.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@BookingStaff([ + FREIGHT_PERMS.procurement.view, + FREIGHT_PERMS.procurement.vendorManage, + FREIGHT_PERMS.procurement.acquisitionManage, + FREIGHT_PERMS.procurement.disposalManage, +]) export class ProcurementController { constructor(private readonly procurementService: ProcurementService) {} diff --git a/apps/edr-freight-api/src/modules/routes/routes.controller.ts b/apps/edr-freight-api/src/modules/routes/routes.controller.ts index ed61f08b0..96812275d 100644 --- a/apps/edr-freight-api/src/modules/routes/routes.controller.ts +++ b/apps/edr-freight-api/src/modules/routes/routes.controller.ts @@ -27,7 +27,15 @@ import { RoutesService } from './routes.service'; @ApiTags('routes') @ApiBearerAuth() @Controller('routes') -@FleetView(FREIGHT_PERMS.routes.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@FleetView([ + FREIGHT_PERMS.routes.view, + FREIGHT_PERMS.routes.create, + FREIGHT_PERMS.routes.update, + FREIGHT_PERMS.routes.hardDelete, + FREIGHT_PERMS.routes.delete, +]) export class RoutesController { constructor(private readonly routesService: RoutesService) {} diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts index a5cd1ff5c..834663170 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts @@ -31,7 +31,15 @@ import { TrainBuilderService } from './train-builder.service'; @ApiTags('train-builder') @ApiBearerAuth() @Controller('train-builder') -@FleetView(FREIGHT_PERMS.trains.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@FleetView([ + FREIGHT_PERMS.trains.view, + FREIGHT_PERMS.trains.create, + FREIGHT_PERMS.trains.update, + FREIGHT_PERMS.trains.assignWagons, + FREIGHT_PERMS.trains.delete, +]) export class TrainBuilderController { constructor(private readonly trainBuilderService: TrainBuilderService) {} diff --git a/apps/edr-freight-api/src/modules/trains/trains.controller.ts b/apps/edr-freight-api/src/modules/trains/trains.controller.ts index 173a738fa..ede540ce2 100644 --- a/apps/edr-freight-api/src/modules/trains/trains.controller.ts +++ b/apps/edr-freight-api/src/modules/trains/trains.controller.ts @@ -19,7 +19,14 @@ import { TrainsService } from "./trains.service"; @ApiTags("trains") @Controller("trains") -@FleetView(FREIGHT_PERMS.trains.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@FleetView([ + FREIGHT_PERMS.trains.view, + FREIGHT_PERMS.trains.create, + FREIGHT_PERMS.trains.update, + FREIGHT_PERMS.trains.delete, +]) export class TrainsController { constructor(private readonly trainsService: TrainsService) {} diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts index 737574dd9..d9e2ba224 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts @@ -20,7 +20,14 @@ import { FleetHistoryService } from '../fleet-history/fleet-history.service'; @ApiTags('vehicles') @ApiBearerAuth() @Controller('vehicles') -@BookingStaff(FREIGHT_PERMS.vehicles.view) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@BookingStaff([ + FREIGHT_PERMS.vehicles.view, + FREIGHT_PERMS.vehicles.create, + FREIGHT_PERMS.vehicles.update, + FREIGHT_PERMS.vehicles.delete, +]) export class VehiclesController { constructor( private readonly vehiclesService: VehiclesService, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts index cdf34cd66..551f840b3 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts @@ -23,9 +23,13 @@ import { WarehouseInspectionService } from './warehouse-inspection.service'; // Baseline read: inspection reports are opened from inventory screens too — // either view permission grants reads; writes stack their own per route. @Controller() +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. @BookingStaff([ FREIGHT_PERMS.warehouseInspectionReports.view, FREIGHT_PERMS.warehouseInventory.view, + FREIGHT_PERMS.warehouseInspectionReports.create, + FREIGHT_PERMS.warehouseInspectionReports.update, ]) export class WarehouseInspectionController { constructor(private readonly inspectionService: WarehouseInspectionService) {} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts index 594fd7a6f..04cbacd28 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts @@ -12,7 +12,13 @@ import { WarehouseZonesService } from './warehouse-zones.service'; // receive/move pickers) — either view permission grants reads; writes stack // their specific permission per route. @Controller('warehouse-zones') -@BookingStaff([FREIGHT_PERMS.warehouseZones.view, FREIGHT_PERMS.warehouseInventory.view]) +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@BookingStaff([ + FREIGHT_PERMS.warehouseZones.view, + FREIGHT_PERMS.warehouseInventory.view, + FREIGHT_PERMS.warehouseZones.update, +]) export class WarehouseZonesController { constructor(private readonly zonesService: WarehouseZonesService) {} diff --git a/deploy/position-type-grant-mapping.sql b/deploy/position-type-grant-mapping.sql deleted file mode 100644 index 901191dd4..000000000 --- a/deploy/position-type-grant-mapping.sql +++ /dev/null @@ -1,119 +0,0 @@ --- Position-type grant mapping for the granular permission system rollout. --- Grants the NEW granular keys to every hand-curated position type that holds --- the old broad key whose routes the new keys took over. Idempotent (unique --- constraint on (position_type_id, permission_id) + ON CONFLICT DO NOTHING). --- --- PREREQUISITE: run the seeded API once first (SEED_EDR_ORG=true) so --- EdrOrgSeeder has created the new permission rows this script references. --- Running it too early is not destructive but silently under-applies: keys that --- do not exist yet simply match nothing (measured: 11 of 42 rows land pre-seed, --- because invoices:view/export and payments:view already exist on dev). Re-run --- after seeding — it is safe to run any number of times. --- --- Verified 2026-08-07 on a virgin restore of the live dev DB: seeded boot, then --- this script → 42 rows inserted, second run → 0 rows, final per-key grant --- counts identical to the reference environment. Per-user API probes across 20 --- departmental test accounts confirm the keys resolve through /me and gate --- routes correctly. - -BEGIN; - --- Helper shape used throughout: --- holders of => also grant - --- 1. bookings:view holders => dashboard/read keys that replaced blanket access -INSERT INTO iam.position_type_permissions (position_type_id, permission_id) -SELECT DISTINCT ptp.position_type_id, pnew.id -FROM iam.position_type_permissions ptp -JOIN iam.permissions pold ON pold.id = ptp.permission_id - AND pold.key = 'edr_freight_app:bookings:view' -JOIN iam.permissions pnew ON pnew.key IN ( - 'edr_freight_app:overview:view', - 'edr_freight_app:reports:view', - 'edr_freight_app:invoices:view', - 'edr_freight_app:invoices:export', - 'edr_freight_app:payments:view' -) -ON CONFLICT (position_type_id, permission_id) DO NOTHING; - --- 2. train_scheduling:update holders => the write actions split out of it -INSERT INTO iam.position_type_permissions (position_type_id, permission_id) -SELECT DISTINCT ptp.position_type_id, pnew.id -FROM iam.position_type_permissions ptp -JOIN iam.permissions pold ON pold.id = ptp.permission_id - AND pold.key = 'edr_freight_app:train_scheduling:update' -JOIN iam.permissions pnew ON pnew.key IN ( - 'edr_freight_app:train_scheduling:dispatch', - 'edr_freight_app:train_scheduling:mark_paid', - 'edr_freight_app:train_scheduling:expire_booking' -) -ON CONFLICT (position_type_id, permission_id) DO NOTHING; - --- 3. GL Djibouti clearance holders => final-invoice raise + confirm -INSERT INTO iam.position_type_permissions (position_type_id, permission_id) -SELECT DISTINCT ptp.position_type_id, pnew.id -FROM iam.position_type_permissions ptp -JOIN iam.permissions pold ON pold.id = ptp.permission_id - AND pold.key = 'edr_freight_app:contracts:clearance_dj_actions' -JOIN iam.permissions pnew ON pnew.key IN ( - 'edr_freight_app:contracts:final_invoice_raise', - 'edr_freight_app:contracts:final_invoice_confirm' -) -ON CONFLICT (position_type_id, permission_id) DO NOTHING; - --- 4. GL Ethiopia clearance holders => final-invoice confirm -INSERT INTO iam.position_type_permissions (position_type_id, permission_id) -SELECT DISTINCT ptp.position_type_id, pnew.id -FROM iam.position_type_permissions ptp -JOIN iam.permissions pold ON pold.id = ptp.permission_id - AND pold.key = 'edr_freight_app:contracts:clearance_et_actions' -JOIN iam.permissions pnew ON pnew.key = 'edr_freight_app:contracts:final_invoice_confirm' -ON CONFLICT (position_type_id, permission_id) DO NOTHING; - --- 5. Contract-intake holders (any staff_accept flavour) => edit_document -INSERT INTO iam.position_type_permissions (position_type_id, permission_id) -SELECT DISTINCT ptp.position_type_id, pnew.id -FROM iam.position_type_permissions ptp -JOIN iam.permissions pold ON pold.id = ptp.permission_id - AND pold.key IN ( - 'edr_freight_app:bookings:staff_accept', - 'edr_freight_app:contracts:staff_accept:bulk', - 'edr_freight_app:contracts:staff_accept:container' - ) -JOIN iam.permissions pnew ON pnew.key = 'edr_freight_app:contracts:edit_document' -ON CONFLICT (position_type_id, permission_id) DO NOTHING; - --- 6. Support inbox ownership (decision 2026-08-07): marketing department types -INSERT INTO iam.position_type_permissions (position_type_id, permission_id) -SELECT pt.id, p.id -FROM iam.position_types pt -CROSS JOIN iam.permissions p -WHERE (pt.name::text ILIKE '%marketing%' OR pt.key ILIKE '%marketing%') - AND p.key IN ( - 'edr_freight_app:support:agent_view', - 'edr_freight_app:support:agent_send' - ) -ON CONFLICT (position_type_id, permission_id) DO NOTHING; - --- 7. Companion view keys. --- Most freight controllers carry a class-level `:view` guard, and Nest --- runs class AND method guards — so a type holding only `:` is --- denied before the action key is ever checked. Grant the module's view key --- alongside every action key the type already holds. View-only, so it widens --- reads within a module the type already operates in, never across modules. -INSERT INTO iam.position_type_permissions (position_type_id, permission_id) -SELECT DISTINCT ptp.position_type_id, pview.id -FROM iam.position_type_permissions ptp -JOIN iam.permissions pact ON pact.id = ptp.permission_id - AND pact.key LIKE 'edr_freight_app:%' -JOIN iam.permissions pview ON pview.key = regexp_replace(pact.key, ':[^:]+$', ':view') -ON CONFLICT (position_type_id, permission_id) DO NOTHING; - -COMMIT; - --- Verification: expected non-zero counts per new key after running. --- SELECT p.key, count(*) FROM iam.position_type_permissions ptp --- JOIN iam.permissions p ON p.id = ptp.permission_id --- WHERE p.key IN ('edr_freight_app:overview:view','edr_freight_app:support:agent_view', --- 'edr_freight_app:train_scheduling:dispatch','edr_freight_app:contracts:final_invoice_raise') --- GROUP BY p.key; From d5d7c91e24f4f90c40cbfba1e5afb49f47301883 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 7 Aug 2026 11:48:50 +0000 Subject: [PATCH 2/3] feat(auth): add :read for API access without UI exposure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `:view` gates the backoffice sidebar entry, the route, and the API read all at once, so granting a user another module's list endpoint for a form dropdown also hands them that module's whole page. Seed a `:read` twin for every `:view` key and teach the freight guards to accept it wherever the matching `:view` is required — on GET/HEAD/OPTIONS only, since class and method guards AND together and a write route without its own method gate would otherwise be reachable. The frontend never checks `:read`, which is what keeps the module hidden. Twins are derived, not hand-written, so a new `:view` gets one for free. Grants stay hand-curated in iam.position_type_permissions. --- .../src/common/freight-permission.guard.ts | 41 ++++++++++-- .../src/seed/edr-freight.seed.ts | 12 +++- .../seed/freight-permissions.registry.spec.ts | 37 ++++++++++ .../src/seed/freight-permissions.registry.ts | 67 ++++++++++++++++++- 4 files changed, 151 insertions(+), 6 deletions(-) diff --git a/apps/edr-freight-api/src/common/freight-permission.guard.ts b/apps/edr-freight-api/src/common/freight-permission.guard.ts index db6275c07..9cb002891 100644 --- a/apps/edr-freight-api/src/common/freight-permission.guard.ts +++ b/apps/edr-freight-api/src/common/freight-permission.guard.ts @@ -9,6 +9,7 @@ import { import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { hasFreightPermission, isSuperAdmin } from './freight-permission.util'; +import { readTwinOf } from '../seed/freight-permissions.registry'; // String literals on purpose (same reasoning as login-audience.middleware.ts): // the values are wire-format constants from iam.users.user_type, and importing @@ -22,13 +23,43 @@ const userTypeOf = (user: TCurrentUser): string | undefined => const isEmployee = (user: TCurrentUser): boolean => userTypeOf(user) === 'employee' || isSuperAdmin(user); +const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); + +/** + * Does the caller satisfy a required permission? + * + * Holding the key outright always passes. A required `:view` is ALSO + * satisfied by the weaker `:read` — the key that buys API reads + * without putting the module in the backoffice sidebar — but only on a safe + * HTTP method. + * + * The method restriction is load-bearing, not caution. Nest runs class AND + * method guards, so controllers list every route key on the class gate, + * `:view` among the write keys. Without this check a `:read` holder would + * clear that class gate and then reach any write route that has no method + * gate of its own. Keying on the HTTP verb closes that by construction rather + * than by an audit that goes stale the next time a route is added. + */ +const satisfiedBy = ( + user: TCurrentUser, + required: string, + method: string, +): boolean => { + if (hasFreightPermission(user, required)) return true; + if (!SAFE_METHODS.has(method)) return false; + const readTwin = readTwinOf(required); + return Boolean(readTwin && hasFreightPermission(user, readTwin)); +}; + export function FreightPermissionGuard( permissions: string[], ): Type { @Injectable() class FreightPermissionsGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { - const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>(); + const request = context + .switchToHttp() + .getRequest<{ user?: TCurrentUser; method: string }>(); const user = request.user; if (!user) { @@ -39,7 +70,7 @@ export function FreightPermissionGuard( } if (!permissions?.length) return true; - if (permissions.some((p) => hasFreightPermission(user, p))) { + if (permissions.some((p) => satisfiedBy(user, p, request.method))) { return true; } @@ -79,7 +110,9 @@ export function MixedAudienceGuard(permissions: string[]): Type { @Injectable() class MixedAudiencesGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { - const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>(); + const request = context + .switchToHttp() + .getRequest<{ user?: TCurrentUser; method: string }>(); const user = request.user; if (!user) { @@ -93,7 +126,7 @@ export function MixedAudienceGuard(permissions: string[]): Type { } if ( !permissions?.length || - permissions.some((p) => hasFreightPermission(user, p)) + permissions.some((p) => satisfiedBy(user, p, request.method)) ) { return true; } diff --git a/apps/edr-freight-api/src/seed/edr-freight.seed.ts b/apps/edr-freight-api/src/seed/edr-freight.seed.ts index 22f7fe5ef..d90fe7b80 100644 --- a/apps/edr-freight-api/src/seed/edr-freight.seed.ts +++ b/apps/edr-freight-api/src/seed/edr-freight.seed.ts @@ -1,6 +1,7 @@ import { BOOKING_RULE_ENGINE_PERMISSIONS, BOOKING_RULE_ENGINE_PERMISSION_KEYS, + deriveReadPermissions, POSITION_PERMISSION_PRESETS, ROLE_PERMISSION_PRESETS, } from './freight-permissions.registry'; @@ -190,7 +191,7 @@ const POSITION_TYPE_PERMISSIONS = [ }, ] as const; -export const EDR_FREIGHT_PERMISSIONS = [ +const EDR_FREIGHT_VIEWABLE_PERMISSIONS = [ ...EMPLOYEE_REGISTRATION_PERMISSIONS, ...ROLE_ASSIGNMENT_PERMISSIONS, ...HIERARCHY_UNIT_PERMISSIONS, @@ -200,6 +201,15 @@ export const EDR_FREIGHT_PERMISSIONS = [ ...BOOKING_RULE_ENGINE_PERMISSIONS, ]; +export const EDR_FREIGHT_PERMISSIONS = [ + ...EDR_FREIGHT_VIEWABLE_PERMISSIONS, + // API-read twin of every `:view` key above — grants the module's GET routes + // without putting it in the backoffice sidebar. Seeded so they can be + // assigned; no role or position preset below grants one, that stays + // hand-curated in iam.position_type_permissions. + ...deriveReadPermissions(EDR_FREIGHT_VIEWABLE_PERMISSIONS), +]; + export { BOOKING_RULE_ENGINE_PERMISSION_KEYS } from './freight-permissions.registry'; export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [ diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.spec.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.spec.ts index caab85c3e..b69dcff71 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.spec.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.spec.ts @@ -1,4 +1,5 @@ import { EDR_FREIGHT_PERMISSIONS } from './edr-freight.seed'; +import { readTwinOf } from './freight-permissions.registry'; describe('EDR_FREIGHT_PERMISSIONS', () => { // The seeder inserts the whole catalog in one ON CONFLICT (key) DO UPDATE @@ -10,4 +11,40 @@ describe('EDR_FREIGHT_PERMISSIONS', () => { expect(duplicates).toEqual([]); }); + + // The `:read` ids are derived rather than hand-written, so a collision + // would surface here instead of as a primary-key violation on a fresh + // database. + it('has no duplicate ids', () => { + const ids = EDR_FREIGHT_PERMISSIONS.map((permission) => permission.id); + const duplicates = [...new Set(ids.filter((id, i) => ids.indexOf(id) !== i))]; + + expect(duplicates).toEqual([]); + }); + + // `:read` grants a module's GET routes without putting it in the backoffice + // sidebar. Every `:view` needs its twin or that module has no way to be + // granted API-only access. + it('gives every :view key a :read twin', () => { + const keys = new Set(EDR_FREIGHT_PERMISSIONS.map((p) => p.key)); + const missing = [...keys] + .filter((key) => key.endsWith(':view')) + .map((key) => readTwinOf(key)) + .filter((twin): twin is string => twin !== null && !keys.has(twin)); + + expect(missing).toEqual([]); + }); + + it('mints every :read id in the v5 block', () => { + const reads = EDR_FREIGHT_PERMISSIONS.filter((p) => p.key.endsWith(':read')); + + expect(reads.length).toBeGreaterThan(0); + // Version nibble 5 — the source `:view` ids are all v4, so the two sets + // cannot overlap however many keys are added. + for (const read of reads) { + expect(read.id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); + } + }); }); diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 11f0ee40c..cbe2fcf11 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -31,6 +31,15 @@ export type RuleEngineResourceSlug = const slugToResourceKey = (slug: RuleEngineResourceSlug): string => slug.replace(/-/g, "_"); +const VIEW_KEY_SUFFIX = ":view"; +const READ_KEY_SUFFIX = ":read"; + +/** The `:read` twin of a `:view` key, or null if `key` is not a view key. */ +export const readTwinOf = (key: string): string | null => + key.endsWith(VIEW_KEY_SUFFIX) + ? `${key.slice(0, -VIEW_KEY_SUFFIX.length)}${READ_KEY_SUFFIX}` + : null; + const perm = (id: string, key: string, en: string): FreightPermissionSeed => ({ id, key, @@ -1352,6 +1361,59 @@ export const BOOKING_RULE_ENGINE_PERMISSIONS = [ export const BOOKING_RULE_ENGINE_PERMISSION_KEYS = BOOKING_RULE_ENGINE_PERMISSIONS.map((p) => p.key); +/** + * Id for a derived `:read` key: the source `:view` id with its version nibble + * moved 4 → 5. + * + * Unique because the source ids are, and disjoint from every hand-written id + * because those are all v4-shaped. Nothing index-derived, so a new `:view` + * landing mid-list cannot shift ids already seeded — the trap the + * RULE_ENGINE_RESOURCE_SLUGS comment warns about. + * + * (`EdrOrgSeeder.ensurePermissions` deliberately never sends an id — the + * column default wins and `key` is the identity every consumer resolves by. + * These exist to satisfy the seed type and keep the array self-consistent.) + */ +const readPermissionId = (viewId: string): string => + `${viewId.slice(0, 14)}5${viewId.slice(15)}`; + +/** + * API-read twin of every `:view` key. + * + * `:view` does three jobs at once — backoffice sidebar entry (App.tsx), route + * admission (RequirePermission), and API read. That bundling means a user who + * only needs another module's list endpoint for a form dropdown has to be + * granted the whole module, page and all. `:read` unbundles it: the + * guard accepts it wherever the matching `:view` is required on a GET, and the + * frontend never looks at it, so the module stays out of the menu. + * + * Derived rather than hand-written so a new `:view` key gets its twin for + * free. Grants stay hand-curated in `iam.position_type_permissions` — nothing + * here hands a `:read` to anyone. + */ +export const deriveReadPermissions = ( + seeds: readonly FreightPermissionSeed[], +): FreightPermissionSeed[] => + seeds + .filter((p) => p.key.endsWith(VIEW_KEY_SUFFIX)) + .map((p) => + perm( + readPermissionId(p.id), + readTwinOf(p.key) as string, + `Read ${p.name.en.replace(/^View /, "")} (API only)`, + ), + ); + +/** + * Read twins of the freight-domain catalog, for `PERMISSIONS_CATALOG`. The + * IAM/hierarchy keys seeded alongside it live in `edr-freight.seed.ts` and get + * theirs there — each twin is derived from its own source row, so the two call + * sites agree on any key they share without coordination. + */ +export const FREIGHT_READ_PERMISSIONS = deriveReadPermissions( + BOOKING_RULE_ENGINE_PERMISSIONS, +); + export const FREIGHT_PERMS = { bookings: { view: "edr_freight_app:bookings:view", @@ -2069,7 +2131,10 @@ export const POSITION_PERMISSION_PRESETS = { /** Derive the module bucket from the resource segment of a permission key. */ const moduleOf = (key: string): string => key.split(":")[1] ?? "other"; -export const PERMISSIONS_CATALOG = BOOKING_RULE_ENGINE_PERMISSIONS.map((p) => ({ +export const PERMISSIONS_CATALOG = [ + ...BOOKING_RULE_ENGINE_PERMISSIONS, + ...FREIGHT_READ_PERMISSIONS, +].map((p) => ({ key: p.key, label: p.name.en, module: moduleOf(p.key), From 1e9149ce0032550d9de2d7e530bfeda465804879 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 7 Aug 2026 12:24:30 +0000 Subject: [PATCH 3/3] feat: better navigation in backoffice --- apps/edr-freight-web/backoffice/src/App.tsx | 678 +----------------- .../src/components/auth/RequirePermission.tsx | 24 +- .../components/layout/sidebar-sections.tsx | 660 +++++++++++++++++ .../backoffice/src/lib/landing.test.ts | 57 ++ .../backoffice/src/lib/landing.ts | 51 ++ .../backoffice/src/pages/NoAccessPage.tsx | 32 + .../backoffice/src/pages/auth/LoginPage.tsx | 4 +- .../src/pages/ruleEngine/CargoTypesPage.tsx | 5 +- .../ruleEngine/RuleEngineResourcePage.tsx | 3 +- .../backoffice/src/routes/RootRedirect.tsx | 12 +- .../src/user-management/Applayout.tsx | 4 +- .../backoffice/src/user-management/route.tsx | 7 +- 12 files changed, 861 insertions(+), 676 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx create mode 100644 apps/edr-freight-web/backoffice/src/lib/landing.test.ts create mode 100644 apps/edr-freight-web/backoffice/src/lib/landing.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/NoAccessPage.tsx diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 6fd666a8d..bad3cbfe5 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -1,36 +1,3 @@ -import { - ArrowLeftRight, - Boxes, - Building2, - BarChart3, - Container, - FileSignature, - FileText, - Hammer, - History, - LayoutDashboard, - LayoutGrid, - MapPin, - Network, - Package, - PackageCheck, - PackageOpen, - Paperclip, - Receipt, - ScrollText, - Send, - Settings, - ShieldCheck, - Ship, - SlidersHorizontal, - Train, - Truck, - Users, - Wallet, - LifeBuoy, - TrainFront, - XCircle, -} from "lucide-react"; import { useEffect } from "react"; import { Navigate, @@ -42,11 +9,7 @@ import { useParams, } from "react-router-dom"; -import { - FreightDashboardLayout, - type SidebarItem, - type SidebarSection, -} from "@/components/layout"; +import { FreightDashboardLayout, type SidebarItem } from "@/components/layout"; import { useAuth } from "./auth/useAuth"; import LoadingScreen from "./components/LoadingScreen"; import LoginPage from "./pages/auth/LoginPage"; @@ -84,11 +47,12 @@ import AuditLogsPage from "./pages/audit/AuditLogsPage"; import { RequirePermission } from "./components/auth/RequirePermission"; import { FREIGHT_PERMS, - hasPermission as hasFreightPermission, isDjiboutiGl, isEthiopianGl, isSuperAdmin, } from "./lib/permissions"; +import { NO_ACCESS_PATH, resolveLandingPath } from "./lib/landing"; +import NoAccessPage from "./pages/NoAccessPage"; import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage"; @@ -108,7 +72,6 @@ import CompliancePage from "./pages/fleet/CompliancePage"; import IncidentsPage from "./pages/fleet/IncidentsPage"; import WorkOrdersPage from "./pages/fleet/WorkOrdersPage"; import ProcurementPage from "./pages/fleet/ProcurementPage"; -import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; import CargoTypesPage from "./pages/ruleEngine/CargoTypesPage"; @@ -151,615 +114,15 @@ import FaydaCallbackPage from "./pages/FaydaCallbackPage"; import { UserManagementRoutes } from "./user-management/route"; import SetPassword from "./shared/components/SetPassword"; import SupportInboxPage from "./pages/support/SupportInboxPage"; - -const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ - { - title: "Main menu", - items: [ - { - label: "Overview", - href: "/dashboard/overview", - icon: , - permission: FREIGHT_PERMS.overview.view, - }, - { - label: "Reports", - href: "/dashboard/reports", - icon: , - permission: FREIGHT_PERMS.reports.view, - }, - { - label: "Customers", - href: "/dashboard/customers", - icon: , - permission: FREIGHT_PERMS.customers.view, - }, - { - label: "Contracts", - href: "/dashboard/contract-requests", - icon: , - permission: FREIGHT_PERMS.contracts.view, - }, - { - label: "Bookings", - href: "/dashboard/booking-requests", - icon: , - permission: FREIGHT_PERMS.bookings.view, - }, - { - label: "Wagon cancellations", - href: "/dashboard/wagon-cancellations", - icon: , - permission: FREIGHT_PERMS.bookings.wagonCancellationView, - }, - // Operations hub: per-shipment clearance-document review for services - // WITHOUT customs clearing (self-clearance) — bookings only. - { - label: "Clearance Documents", - href: "/dashboard/contracts/clearance-documents", - icon: , - permission: FREIGHT_PERMS.contracts.opsClearanceReview, - }, - { - label: "Payments", - href: "/dashboard/payments", - icon: , - permission: FREIGHT_PERMS.payments.view, - }, - { - label: "Invoices", - href: "/dashboard/invoices", - icon: , - permission: FREIGHT_PERMS.invoices.view, - }, - { - label: "Support", - href: "/dashboard/support", - icon: , - permission: FREIGHT_PERMS.support.agentView, - }, - ...demoItems, - ], - }, - { - // title: "Port & Terminal", - items: [ - { - label: "Operations", - icon: , - children: [ - { - label: "Clearance", - href: "/dashboard/contracts/clearance", - icon: , - permission: [ - FREIGHT_PERMS.contracts.clearanceReview, - FREIGHT_PERMS.contracts.clearanceEtActions, - ], - }, - // { - // label: "Shipment Requests", - // href: "/dashboard/shipment-requests", - // icon: , - // permission: FREIGHT_PERMS.contracts.createBooking, - // }, - // Operations Path A queue: per-booking self-clearance review for - // GENERAL non-customs booking instances (and legacy self-clear bookings). - // { - // label: "Self-Clearance Review", - // href: "/dashboard/contracts/ops-clearance", - // icon: , - // permission: FREIGHT_PERMS.contracts.opsClearanceReview, - // }, - { - label: "GL Djibouti Clearance", - href: "/dashboard/gl-djibouti/clearance", - icon: , - permission: FREIGHT_PERMS.contracts.clearanceDjActions, - }, - { - label: "Train Schedules", - href: "/dashboard/operations/train-scheduling-v2", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - }, - { - label: "Batch Board", - href: "/dashboard/operations/batch-board", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - }, - { - label: "First Mile", - href: "/dashboard/operations/first-mile", - icon: , - permission: FREIGHT_PERMS.firstMile.view, - }, - { - label: "Last Mile", - href: "/dashboard/operations/last-mile", - icon: , - permission: FREIGHT_PERMS.lastMile.view, - }, - ], - }, - { - label: "Fleet Management", - icon: , - children: [ - { - label: "Fleet Dashboard", - href: "/dashboard/fleet-dashboard", - icon: , - permission: FREIGHT_PERMS.fleetDashboard.view, - }, - { - label: "Routes", - href: "/dashboard/routes", - icon: , - permission: [FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view], - }, - { - label: "Locomotives", - href: "/dashboard/locomotives", - icon: , - permission: [ - FREIGHT_PERMS.locomotives.view, - FREIGHT_PERMS.fleet.view, - ], - }, - { - label: "Train Builder", - href: "/dashboard/train-builder", - icon: , - permission: [FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view], - }, - - // { - // label: "Wagon types", - // href: "/dashboard/wagon-types", - // icon: , - // }, - { - label: "Wagons", - href: "/dashboard/wagons", - icon: , - permission: [FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view], - }, - { - label: "Wagon Transfers", - href: "/dashboard/wagon-transfers", - icon: , - permission: [ - FREIGHT_PERMS.wagons.transferView, - FREIGHT_PERMS.wagons.view, - ], - }, - { - label: "Vehicles", - href: "/dashboard/vehicles", - icon: , - permission: FREIGHT_PERMS.vehicles.view, - }, - { - label: "Drivers", - href: "/dashboard/drivers", - icon: , - permission: FREIGHT_PERMS.drivers.view, - }, - { - label: "Track Vehicles", - href: "/dashboard/tracking", - icon: , - permission: FREIGHT_PERMS.tracking.view, - }, - { - label: "Fuel Purchases", - href: "/dashboard/fuel-purchases", - icon: , - permission: FREIGHT_PERMS.fuel.view, - }, - { - label: "Fuel Analytics", - href: "/dashboard/fuel-stats", - icon: , - permission: FREIGHT_PERMS.fuel.view, - }, - { - label: "Maintenance", - href: "/dashboard/maintenance", - icon: , - permission: FREIGHT_PERMS.maintenance.view, - }, - { - label: "Work Orders", - href: "/dashboard/work-orders", - icon: , - permission: FREIGHT_PERMS.maintenance.view, - }, - { - label: "Compliance & Alerts", - href: "/dashboard/compliance", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Incidents", - href: "/dashboard/incidents", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Procurement", - href: "/dashboard/procurement", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Financial Reports", - href: "/dashboard/financial-reports", - icon: , - permission: FREIGHT_PERMS.fleetReports.view, - }, - // { - // label: "Containers", - // href: "/dashboard/containers", - // icon: , - // }, - // { - // label: "Cargoes", - // href: "/dashboard/cargoes", - // icon: , - // }, - ], - }, - { - label: "Imports", - href: "/dashboard/import-warehouse", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - children: [ - { - label: "Import Overview", - href: "/dashboard/import-warehouse", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Arrival Queue", - href: "/dashboard/arrival-queue", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Import Trucks", - href: "/dashboard/import-trucks", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "EDR Last Mile Returns", - href: "/dashboard/edr-last-mile-returns", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Container Returns", - href: "/dashboard/container-returns", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Terminal Inventory", - href: "/dashboard/warehouse-inventory?direction=IMPORT", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Inventory Inquiry", - href: "/dashboard/inventory-inquiry", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - ], - }, - { - label: "Exports", - href: "/dashboard/export-warehouse", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - children: [ - { - label: "Export Overview", - href: "/dashboard/export-warehouse", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Loading Queue", - href: "/dashboard/loading-queue", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Loaded Inventory", - href: "/dashboard/loaded-inventory", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Dispatch Queue", - href: "/dashboard/dispatch-queue", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Djibouti Unloading", - href: "/dashboard/export-djibouti-unloading", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Interchange Documents", - href: "/dashboard/interchange-documents", - icon: , - permission: FREIGHT_PERMS.interchangeDocuments.view, - }, - { - label: "Terminal Inventory", - href: "/dashboard/warehouse-inventory?direction=EXPORT", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - ], - }, - { - label: "Intercity", - href: "/dashboard/intercity", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - children: [ - { - label: "Intercity Cargo", - href: "/dashboard/intercity", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - ], - }, - { - label: "Warehouse Management", - icon: , - children: [ - { - label: "Warehouse Dashboard", - href: "/dashboard/warehouse-dashboard", - icon: , - permission: FREIGHT_PERMS.warehouseDashboard.view, - }, - { - // Yard-wide, not per-direction: the gate sees import and export - // trucks at the same barrier. - 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, - FREIGHT_PERMS.warehouseFeeRules.view, - ], - }, - { - label: "Fee Invoices", - href: "/dashboard/warehouse-fee-invoices", - icon: , - permission: FREIGHT_PERMS.warehouseFeeInvoices.view, - }, - ], - }, - ], - }, - { - title: "Freight configuration", - mutedTitle: true, - items: [ - { - label: "File settings", - href: "/dashboard/file-settings", - icon: , - permission: FREIGHT_PERMS.admin, - }, - { - label: "Dropdown settings", - href: "/dashboard/dropdown-settings", - icon: , - permission: FREIGHT_PERMS.admin, - }, - { - label: "Contract templates", - href: "/dashboard/contract-templates", - icon: , - permission: FREIGHT_PERMS.admin, - }, - { - label: "Audit logs", - href: "/dashboard/audit-logs", - icon: , - permission: FREIGHT_PERMS.audit.view, - }, - { - label: "Configuration", - href: "/dashboard/configuration", - icon: , - children: [ - ...getCategorySidebarChildren("configuration"), - { - label: "Train scheduling rules", - href: "/dashboard/configuration/train-scheduling-rules", - permission: FREIGHT_PERMS.trainScheduling.rulesManage, - }, - { - label: "Trade access", - href: "/dashboard/configuration/trade-access", - permission: FREIGHT_PERMS.admin, - }, - { - label: "Exchange rate", - href: "/dashboard/configuration/exchange-rate", - permission: FREIGHT_PERMS.admin, - }, - ], - }, - { - label: "Rules", - href: "/dashboard/rules", - icon: , - children: getCategorySidebarChildren("rules"), - }, - - { - label: "Staff", - href: "/user-management", - icon: , - permission: [ - FREIGHT_PERMS.admin, - FREIGHT_PERMS.staff.roles.view, - FREIGHT_PERMS.staff.employeeRegistration.view, - FREIGHT_PERMS.staff.roleAssignment.view, - ], - }, - ], - }, -]; - -/** Hrefs of the two document-clearance menu items (stable identifiers). */ -const ET_CLEARANCE_HREF = "/dashboard/contracts/clearance"; -const DJ_CLEARANCE_HREF = "/dashboard/gl-djibouti/clearance"; - -// Routes a GL officer may reach beyond their clearance hub. Path B booking is -// part of their job (create/rebook under a cleared contract, then view that -// booking's clearance), but those routes live outside the clearance prefix — -// without this allowlist the single-prefix lock bounces them out of their own -// workflow. Matched against location.pathname (no query string). -const GL_WORKFLOW_PATH_PATTERNS: RegExp[] = [ - /^\/dashboard\/contracts\/[^/]+\/create-booking(\/|$)/, - /^\/dashboard\/bookings\/[^/]+\/clearance(\/|$)/, - // The ET hub's rows open the shipment clearance detail at this URL. - /^\/dashboard\/clearance\/[^/]+(\/|$)/, -]; - -const isEtClearanceItem = (item: SidebarItem): boolean => - item.href === ET_CLEARANCE_HREF; -const isDjClearanceItem = (item: SidebarItem): boolean => - item.href === DJ_CLEARANCE_HREF; -const isClearanceItem = (item: SidebarItem): boolean => - isEtClearanceItem(item) || isDjClearanceItem(item); - -/** - * Keep only items the user is permitted to see; drop now-empty sections. - * - * Position-scoped visibility (super_admin sees everything): - * - Super Admin → sees all items (all permissions pass, all tabs visible) - * - Ethiopian GL → sees ONLY the ET document-clearance page. - * - Djibouti GL → sees ONLY the DJ clearance page. - * - Everyone else → sees everything they have permission for, EXCEPT the two - * clearance pages (those are GL-only). - */ -const filterSidebarByPermission = ( - sections: SidebarSection[], - user: ReturnType["user"], -): SidebarSection[] => { - // Superadmin sees every section and item — no permission filtering. - if (isSuperAdmin(user)) return sections; - - const etGl = isEthiopianGl(user); - const djGl = isDjiboutiGl(user); - - const permissionAllowed = (item: SidebarItem): boolean => { - if (!item.permission) return true; - const keys = Array.isArray(item.permission) - ? item.permission - : [item.permission]; - return keys.some((key) => hasFreightPermission(user, key)); - }; - - // Recursive: children are filtered first; a group (item with children) stays - // only while it still has visible children — so parents without their own - // permission key never leak a whole subtree the user cannot open. - const filterItems = (items: SidebarItem[]): SidebarItem[] => - items - .map((item) => - item.children - ? { ...item, children: filterItems(item.children) } - : item, - ) - .filter((item) => { - if (etGl || djGl) { - // GL positions are locked to their single clearance page (parents - // survive only as the path to that page). - const isTarget = etGl ? isEtClearanceItem : isDjClearanceItem; - return isTarget(item) || (item.children?.length ?? 0) > 0; - } - // Everyone else: hide the GL-only clearance pages entirely. - if (isClearanceItem(item)) return false; - if (!permissionAllowed(item)) return false; - if (item.children) return item.children.length > 0; - return true; - }); - - return sections - .map((section) => ({ - ...section, - items: filterItems(section.items), - })) - .filter((section) => section.items.length > 0); -}; - -const APP_TITLE = "EDR Freight Backoffice"; - -/** Flatten sidebar sections (incl. nested children) into {href, label} pairs. */ -const flattenSidebarItems = ( - sections: SidebarSection[], -): { href: string; label: string }[] => - sections.flatMap((section) => - section.items.flatMap((item) => [ - ...(item.href ? [{ href: item.href, label: item.label }] : []), - ...(item.children ?? []) - .filter((child): child is SidebarItem & { href: string } => - Boolean(child.href), - ) - .map((child) => ({ href: child.href, label: child.label })), - ]), - ); - -/** Find the sidebar label whose href matches (exactly or as a prefix of) the current path. */ -const findActiveSidebarLabel = ( - pathname: string, - sections: SidebarSection[], -): string | undefined => { - const path = pathname.toLowerCase(); - const candidates = flattenSidebarItems(sections) - .map(({ href, label }) => ({ - label, - href: href.split("?")[0].toLowerCase(), - })) - .sort((a, b) => b.href.length - a.href.length); - - return candidates.find( - ({ href }) => path === href || path.startsWith(`${href}/`), - )?.label; -}; +import { + APP_TITLE, + buildSidebarSections, + DJ_CLEARANCE_HREF, + ET_CLEARANCE_HREF, + filterSidebarByPermission, + findActiveSidebarLabel, + GL_WORKFLOW_PATH_PATTERNS, +} from "@/components/layout/sidebar-sections"; const DashboardShell = () => { const navigate = useNavigate(); @@ -840,17 +203,19 @@ const App = () => { ); } + const landingPath = resolveLandingPath(user); + return ( {UserManagementRoutes()} {/* } /> */} } /> } /> - } /> - } - /> + } /> + {/* Landing is per-user: /dashboard/overview is gated on overview:view, so + a fixed target strands anyone without that key on a blank page. */} + } /> + } /> }> } /> } /> @@ -1554,7 +919,9 @@ const App = () => { } /> - } /> + {/* Post-login landing: the browser is still on /auth, which matches + nothing here, so this is what actually decides where users start. */} + } /> ); }; @@ -1575,3 +942,4 @@ function LegacyGlEthiopiaClearanceRedirect() { } export default App; + diff --git a/apps/edr-freight-web/backoffice/src/components/auth/RequirePermission.tsx b/apps/edr-freight-web/backoffice/src/components/auth/RequirePermission.tsx index 4e35fc712..c67a4da69 100644 --- a/apps/edr-freight-web/backoffice/src/components/auth/RequirePermission.tsx +++ b/apps/edr-freight-web/backoffice/src/components/auth/RequirePermission.tsx @@ -1,30 +1,42 @@ import type { ReactNode } from "react"; -import { Navigate } from "react-router-dom"; +import { Navigate, useLocation } from "react-router-dom"; import { useAuth } from "@/auth/useAuth"; +import { resolveLandingPath } from "@/lib/landing"; import { hasPermission } from "@/lib/permissions"; +import NoAccessPage from "@/pages/NoAccessPage"; interface RequirePermissionProps { /** Permission key(s); access is granted if the user has ANY of them. */ permission: string | string[]; - /** Where to send users who lack the permission. */ + /** Where to send users who lack the permission. Defaults to their landing page. */ redirectTo?: string; children: ReactNode; } /** * Page-level guard: renders children only when the current user holds one of - * the given permissions, otherwise redirects (default: overview). + * the given permissions, otherwise redirects to a page they can actually reach. + * + * The fallback must not be a fixed path. It used to be `/dashboard/overview`, + * which is itself gated on `overview:view` — a user without that key was sent + * to the page that had just rejected them, and React Router rendered a blank + * frame instead of navigating. */ export function RequirePermission({ permission, - redirectTo = "/dashboard/overview", + redirectTo, children, }: RequirePermissionProps) { const { user } = useAuth(); + const location = useLocation(); const keys = Array.isArray(permission) ? permission : [permission]; const allowed = keys.some((key) => hasPermission(user, key)); - if (!allowed) return ; - return <>{children}; + if (allowed) return <>{children}; + + const target = redirectTo ?? resolveLandingPath(user); + // Belt and braces: never navigate to the page we are already on. + if (target === location.pathname) return ; + return ; } diff --git a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx new file mode 100644 index 000000000..914a9cceb --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx @@ -0,0 +1,660 @@ +import { + ArrowLeftRight, + Boxes, + Building2, + BarChart3, + Container, + FileSignature, + FileText, + Hammer, + History, + LayoutDashboard, + LayoutGrid, + MapPin, + Network, + Package, + PackageCheck, + PackageOpen, + Paperclip, + Receipt, + ScrollText, + Send, + Settings, + ShieldCheck, + Ship, + SlidersHorizontal, + Train, + Truck, + Users, + Wallet, + LifeBuoy, + TrainFront, + XCircle, +} from "lucide-react"; + +import type { AuthUser } from "@/auth/types"; + +import type { SidebarItem, SidebarSection } from "./types"; +import { + FREIGHT_PERMS, + hasPermission as hasFreightPermission, + isDjiboutiGl, + isEthiopianGl, + isSuperAdmin, +} from "@/lib/permissions"; +import { getCategorySidebarChildren } from "@/pages/ruleEngine/config/resources"; + +/** + * Backoffice sidebar model: the nav tree, its permission filter, and the + * flatten/lookup helpers. Lives outside App.tsx so `lib/landing.ts` can resolve + * a user's first reachable route without importing the route tree (App.tsx + * imports RequirePermission, which imports landing — that would cycle). + */ +export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ + { + title: "Main menu", + items: [ + { + label: "Overview", + href: "/dashboard/overview", + icon: , + permission: FREIGHT_PERMS.overview.view, + }, + { + label: "Reports", + href: "/dashboard/reports", + icon: , + permission: FREIGHT_PERMS.reports.view, + }, + { + label: "Customers", + href: "/dashboard/customers", + icon: , + permission: FREIGHT_PERMS.customers.view, + }, + { + label: "Contracts", + href: "/dashboard/contract-requests", + icon: , + permission: FREIGHT_PERMS.contracts.view, + }, + { + label: "Bookings", + href: "/dashboard/booking-requests", + icon: , + permission: FREIGHT_PERMS.bookings.view, + }, + { + label: "Wagon cancellations", + href: "/dashboard/wagon-cancellations", + icon: , + permission: FREIGHT_PERMS.bookings.wagonCancellationView, + }, + // Operations hub: per-shipment clearance-document review for services + // WITHOUT customs clearing (self-clearance) — bookings only. + { + label: "Clearance Documents", + href: "/dashboard/contracts/clearance-documents", + icon: , + permission: FREIGHT_PERMS.contracts.opsClearanceReview, + }, + { + label: "Payments", + href: "/dashboard/payments", + icon: , + permission: FREIGHT_PERMS.payments.view, + }, + { + label: "Invoices", + href: "/dashboard/invoices", + icon: , + permission: FREIGHT_PERMS.invoices.view, + }, + { + label: "Support", + href: "/dashboard/support", + icon: , + permission: FREIGHT_PERMS.support.agentView, + }, + ...demoItems, + ], + }, + { + // title: "Port & Terminal", + items: [ + { + label: "Operations", + icon: , + children: [ + { + label: "Clearance", + href: "/dashboard/contracts/clearance", + icon: , + permission: [ + FREIGHT_PERMS.contracts.clearanceReview, + FREIGHT_PERMS.contracts.clearanceEtActions, + ], + }, + // { + // label: "Shipment Requests", + // href: "/dashboard/shipment-requests", + // icon: , + // permission: FREIGHT_PERMS.contracts.createBooking, + // }, + // Operations Path A queue: per-booking self-clearance review for + // GENERAL non-customs booking instances (and legacy self-clear bookings). + // { + // label: "Self-Clearance Review", + // href: "/dashboard/contracts/ops-clearance", + // icon: , + // permission: FREIGHT_PERMS.contracts.opsClearanceReview, + // }, + { + label: "GL Djibouti Clearance", + href: "/dashboard/gl-djibouti/clearance", + icon: , + permission: FREIGHT_PERMS.contracts.clearanceDjActions, + }, + { + label: "Train Schedules", + href: "/dashboard/operations/train-scheduling-v2", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, + { + label: "Batch Board", + href: "/dashboard/operations/batch-board", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, + { + label: "First Mile", + href: "/dashboard/operations/first-mile", + icon: , + permission: FREIGHT_PERMS.firstMile.view, + }, + { + label: "Last Mile", + href: "/dashboard/operations/last-mile", + icon: , + permission: FREIGHT_PERMS.lastMile.view, + }, + ], + }, + { + label: "Fleet Management", + icon: , + children: [ + { + label: "Fleet Dashboard", + href: "/dashboard/fleet-dashboard", + icon: , + permission: FREIGHT_PERMS.fleetDashboard.view, + }, + { + label: "Routes", + href: "/dashboard/routes", + icon: , + permission: [FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view], + }, + { + label: "Locomotives", + href: "/dashboard/locomotives", + icon: , + permission: [ + FREIGHT_PERMS.locomotives.view, + FREIGHT_PERMS.fleet.view, + ], + }, + { + label: "Train Builder", + href: "/dashboard/train-builder", + icon: , + permission: [FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view], + }, + + // { + // label: "Wagon types", + // href: "/dashboard/wagon-types", + // icon: , + // }, + { + label: "Wagons", + href: "/dashboard/wagons", + icon: , + permission: [FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view], + }, + { + label: "Wagon Transfers", + href: "/dashboard/wagon-transfers", + icon: , + permission: [ + FREIGHT_PERMS.wagons.transferView, + FREIGHT_PERMS.wagons.view, + ], + }, + { + label: "Vehicles", + href: "/dashboard/vehicles", + icon: , + permission: FREIGHT_PERMS.vehicles.view, + }, + { + label: "Drivers", + href: "/dashboard/drivers", + icon: , + permission: FREIGHT_PERMS.drivers.view, + }, + { + label: "Track Vehicles", + href: "/dashboard/tracking", + icon: , + permission: FREIGHT_PERMS.tracking.view, + }, + { + label: "Fuel Purchases", + href: "/dashboard/fuel-purchases", + icon: , + permission: FREIGHT_PERMS.fuel.view, + }, + { + label: "Fuel Analytics", + href: "/dashboard/fuel-stats", + icon: , + permission: FREIGHT_PERMS.fuel.view, + }, + { + label: "Maintenance", + href: "/dashboard/maintenance", + icon: , + permission: FREIGHT_PERMS.maintenance.view, + }, + { + label: "Work Orders", + href: "/dashboard/work-orders", + icon: , + permission: FREIGHT_PERMS.maintenance.view, + }, + { + label: "Compliance & Alerts", + href: "/dashboard/compliance", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Incidents", + href: "/dashboard/incidents", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Procurement", + href: "/dashboard/procurement", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Financial Reports", + href: "/dashboard/financial-reports", + icon: , + permission: FREIGHT_PERMS.fleetReports.view, + }, + // { + // label: "Containers", + // href: "/dashboard/containers", + // icon: , + // }, + // { + // label: "Cargoes", + // href: "/dashboard/cargoes", + // icon: , + // }, + ], + }, + { + label: "Imports", + href: "/dashboard/import-warehouse", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + children: [ + { + label: "Import Overview", + href: "/dashboard/import-warehouse", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Arrival Queue", + href: "/dashboard/arrival-queue", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Import Trucks", + href: "/dashboard/import-trucks", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "EDR Last Mile Returns", + href: "/dashboard/edr-last-mile-returns", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Container Returns", + href: "/dashboard/container-returns", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Terminal Inventory", + href: "/dashboard/warehouse-inventory?direction=IMPORT", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Inventory Inquiry", + href: "/dashboard/inventory-inquiry", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + ], + }, + { + label: "Exports", + href: "/dashboard/export-warehouse", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + children: [ + { + label: "Export Overview", + href: "/dashboard/export-warehouse", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Loading Queue", + href: "/dashboard/loading-queue", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Loaded Inventory", + href: "/dashboard/loaded-inventory", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Dispatch Queue", + href: "/dashboard/dispatch-queue", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Djibouti Unloading", + href: "/dashboard/export-djibouti-unloading", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Interchange Documents", + href: "/dashboard/interchange-documents", + icon: , + permission: FREIGHT_PERMS.interchangeDocuments.view, + }, + { + label: "Terminal Inventory", + href: "/dashboard/warehouse-inventory?direction=EXPORT", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + ], + }, + { + label: "Intercity", + href: "/dashboard/intercity", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + children: [ + { + label: "Intercity Cargo", + href: "/dashboard/intercity", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + ], + }, + { + label: "Warehouse Management", + icon: , + children: [ + { + label: "Warehouse Dashboard", + href: "/dashboard/warehouse-dashboard", + icon: , + permission: FREIGHT_PERMS.warehouseDashboard.view, + }, + { + // Yard-wide, not per-direction: the gate sees import and export + // trucks at the same barrier. + 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, + FREIGHT_PERMS.warehouseFeeRules.view, + ], + }, + { + label: "Fee Invoices", + href: "/dashboard/warehouse-fee-invoices", + icon: , + permission: FREIGHT_PERMS.warehouseFeeInvoices.view, + }, + ], + }, + ], + }, + { + title: "Freight configuration", + mutedTitle: true, + items: [ + { + label: "File settings", + href: "/dashboard/file-settings", + icon: , + permission: FREIGHT_PERMS.admin, + }, + { + label: "Dropdown settings", + href: "/dashboard/dropdown-settings", + icon: , + permission: FREIGHT_PERMS.admin, + }, + { + label: "Contract templates", + href: "/dashboard/contract-templates", + icon: , + permission: FREIGHT_PERMS.admin, + }, + { + label: "Audit logs", + href: "/dashboard/audit-logs", + icon: , + permission: FREIGHT_PERMS.audit.view, + }, + { + label: "Configuration", + href: "/dashboard/configuration", + icon: , + children: [ + ...getCategorySidebarChildren("configuration"), + { + label: "Train scheduling rules", + href: "/dashboard/configuration/train-scheduling-rules", + permission: FREIGHT_PERMS.trainScheduling.rulesManage, + }, + { + label: "Trade access", + href: "/dashboard/configuration/trade-access", + permission: FREIGHT_PERMS.admin, + }, + { + label: "Exchange rate", + href: "/dashboard/configuration/exchange-rate", + permission: FREIGHT_PERMS.admin, + }, + ], + }, + { + label: "Rules", + href: "/dashboard/rules", + icon: , + children: getCategorySidebarChildren("rules"), + }, + + { + label: "Staff", + href: "/user-management", + icon: , + permission: [ + FREIGHT_PERMS.admin, + FREIGHT_PERMS.staff.roles.view, + FREIGHT_PERMS.staff.employeeRegistration.view, + FREIGHT_PERMS.staff.roleAssignment.view, + ], + }, + ], + }, +]; + +/** Hrefs of the two document-clearance menu items (stable identifiers). */ +export const ET_CLEARANCE_HREF = "/dashboard/contracts/clearance"; +export const DJ_CLEARANCE_HREF = "/dashboard/gl-djibouti/clearance"; + +// Routes a GL officer may reach beyond their clearance hub. Path B booking is +// part of their job (create/rebook under a cleared contract, then view that +// booking's clearance), but those routes live outside the clearance prefix — +// without this allowlist the single-prefix lock bounces them out of their own +// workflow. Matched against location.pathname (no query string). +export const GL_WORKFLOW_PATH_PATTERNS: RegExp[] = [ + /^\/dashboard\/contracts\/[^/]+\/create-booking(\/|$)/, + /^\/dashboard\/bookings\/[^/]+\/clearance(\/|$)/, + // The ET hub's rows open the shipment clearance detail at this URL. + /^\/dashboard\/clearance\/[^/]+(\/|$)/, +]; + +const isEtClearanceItem = (item: SidebarItem): boolean => + item.href === ET_CLEARANCE_HREF; +const isDjClearanceItem = (item: SidebarItem): boolean => + item.href === DJ_CLEARANCE_HREF; +const isClearanceItem = (item: SidebarItem): boolean => + isEtClearanceItem(item) || isDjClearanceItem(item); + +/** + * Keep only items the user is permitted to see; drop now-empty sections. + * + * Position-scoped visibility (super_admin sees everything): + * - Super Admin → sees all items (all permissions pass, all tabs visible) + * - Ethiopian GL → sees ONLY the ET document-clearance page. + * - Djibouti GL → sees ONLY the DJ clearance page. + * - Everyone else → sees everything they have permission for, EXCEPT the two + * clearance pages (those are GL-only). + */ +export const filterSidebarByPermission = ( + sections: SidebarSection[], + user: AuthUser | null | undefined, +): SidebarSection[] => { + // Superadmin sees every section and item — no permission filtering. + if (isSuperAdmin(user)) return sections; + + const etGl = isEthiopianGl(user); + const djGl = isDjiboutiGl(user); + + const permissionAllowed = (item: SidebarItem): boolean => { + if (!item.permission) return true; + const keys = Array.isArray(item.permission) + ? item.permission + : [item.permission]; + return keys.some((key) => hasFreightPermission(user, key)); + }; + + // Recursive: children are filtered first; a group (item with children) stays + // only while it still has visible children — so parents without their own + // permission key never leak a whole subtree the user cannot open. + const filterItems = (items: SidebarItem[]): SidebarItem[] => + items + .map((item) => + item.children + ? { ...item, children: filterItems(item.children) } + : item, + ) + .filter((item) => { + if (etGl || djGl) { + // GL positions are locked to their single clearance page (parents + // survive only as the path to that page). + const isTarget = etGl ? isEtClearanceItem : isDjClearanceItem; + return isTarget(item) || (item.children?.length ?? 0) > 0; + } + // Everyone else: hide the GL-only clearance pages entirely. + if (isClearanceItem(item)) return false; + if (!permissionAllowed(item)) return false; + if (item.children) return item.children.length > 0; + return true; + }); + + return sections + .map((section) => ({ + ...section, + items: filterItems(section.items), + })) + .filter((section) => section.items.length > 0); +}; + +export const APP_TITLE = "EDR Freight Backoffice"; + +/** Flatten sidebar sections (incl. nested children) into {href, label} pairs. */ +export const flattenSidebarItems = ( + sections: SidebarSection[], +): { href: string; label: string }[] => + sections.flatMap((section) => + section.items.flatMap((item) => [ + ...(item.href ? [{ href: item.href, label: item.label }] : []), + ...(item.children ?? []) + .filter((child): child is SidebarItem & { href: string } => + Boolean(child.href), + ) + .map((child) => ({ href: child.href, label: child.label })), + ]), + ); + +/** Find the sidebar label whose href matches (exactly or as a prefix of) the current path. */ +export const findActiveSidebarLabel = ( + pathname: string, + sections: SidebarSection[], +): string | undefined => { + const path = pathname.toLowerCase(); + const candidates = flattenSidebarItems(sections) + .map(({ href, label }) => ({ + label, + href: href.split("?")[0].toLowerCase(), + })) + .sort((a, b) => b.href.length - a.href.length); + + return candidates.find( + ({ href }) => path === href || path.startsWith(`${href}/`), + )?.label; +}; diff --git a/apps/edr-freight-web/backoffice/src/lib/landing.test.ts b/apps/edr-freight-web/backoffice/src/lib/landing.test.ts new file mode 100644 index 000000000..e286e197d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/lib/landing.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; + +import type { AuthUser } from "@/auth/types"; +import { FREIGHT_PERMS } from "./permissions"; +import { NO_ACCESS_PATH, resolveLandingPath } from "./landing"; + +const withPermissions = (...keys: string[]): AuthUser => ({ + permissionKeys: keys, +}); + +const withRole = (roleKey: string): AuthUser => ({ roles: [{ key: roleKey }] }); + +describe("resolveLandingPath", () => { + it("keeps normal staff on the overview", () => { + expect(resolveLandingPath(withPermissions(FREIGHT_PERMS.overview.view))).toBe( + "/dashboard/overview", + ); + }); + + it("sends super admins to the overview (they pass every check)", () => { + expect(resolveLandingPath(withRole("super_admin"))).toBe( + "/dashboard/overview", + ); + }); + + it("sends IAM-only admins to the user-management dashboard", () => { + // A pure unit_admin holds `can:*` IAM keys and no edr_freight_app:* key, so + // the freight sidebar filters down to nothing. Landing them on the overview + // is what produced the blank page. + for (const role of ["unit_admin", "admin", "organization_admin"]) { + expect(resolveLandingPath(withRole(role))).toBe( + "/user-management/user_management-dashboard", + ); + } + }); + + it("lands staff without overview:view on a page they can see", () => { + const target = resolveLandingPath( + withPermissions(FREIGHT_PERMS.warehouses.view), + ); + expect(target).not.toBe("/dashboard/overview"); + expect(target.startsWith("/dashboard/")).toBe(true); + }); + + it("never returns /user-management for a freight-only staff key", () => { + // The Staff sidebar item points at /user-management, which redirects by + // ROLE — returning it here would ping-pong for a user with no IAM role. + expect(resolveLandingPath(withPermissions(FREIGHT_PERMS.staff.roles.view))).toBe( + NO_ACCESS_PATH, + ); + }); + + it("falls back to the no-access page when nothing is granted", () => { + expect(resolveLandingPath({})).toBe(NO_ACCESS_PATH); + expect(resolveLandingPath(null)).toBe(NO_ACCESS_PATH); + }); +}); diff --git a/apps/edr-freight-web/backoffice/src/lib/landing.ts b/apps/edr-freight-web/backoffice/src/lib/landing.ts new file mode 100644 index 000000000..2cfd6f5d1 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/lib/landing.ts @@ -0,0 +1,51 @@ +import { + buildSidebarSections, + filterSidebarByPermission, + flattenSidebarItems, +} from "@/components/layout/sidebar-sections"; + +import type { AuthUser } from "@/auth/types"; + +import { isSuperAdmin } from "./permissions"; + +/** + * IAM roles that grant the user-management subtree but no freight permissions. + * Mirrors the `allowedRoles` on the org-admin route group in + * `user-management/route.tsx` — a role outside this list is bounced by its + * PrivateRoute, so landing anyone else there would just bounce again. + */ +const IAM_ADMIN_ROLES = ["admin", "organization_admin", "unit_admin"]; + +/** Terminal page for accounts with nothing granted. Never permission-gated. */ +export const NO_ACCESS_PATH = "/no-access"; + +/** + * The first route a user can actually reach. + * + * Every redirect in the app funnels through here instead of hardcoding + * `/dashboard/overview`: that page is itself gated on `overview:view`, so + * sending a user who lacks the key there redirects them to the page that just + * rejected them — React Router renders nothing and the user sees a blank frame. + * A pure `unit_admin` holds only `can:*` IAM keys and no `edr_freight_app:*` + * key at all, so this hit them on every login. + */ +export function resolveLandingPath(user: AuthUser | null | undefined): string { + const visible = filterSidebarByPermission(buildSidebarSections([]), user); + + // Only /dashboard/* items are safe landings. The "Staff" item points at + // /user-management, which re-redirects by ROLE — a freight user holding + // staff:* keys but no IAM role would ping-pong between the two. + const firstFreightPage = flattenSidebarItems(visible).find((item) => + item.href.startsWith("/dashboard/"), + )?.href; + if (firstFreightPage) return firstFreightPage; + + if (isSuperAdmin(user)) return "/user-management/dashboard"; + + const roleKeys = (user?.roles ?? []).map((role) => role.key ?? ""); + if (roleKeys.some((key) => IAM_ADMIN_ROLES.includes(key))) { + return "/user-management/user_management-dashboard"; + } + + return NO_ACCESS_PATH; +} diff --git a/apps/edr-freight-web/backoffice/src/pages/NoAccessPage.tsx b/apps/edr-freight-web/backoffice/src/pages/NoAccessPage.tsx new file mode 100644 index 000000000..06d19ac5f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/NoAccessPage.tsx @@ -0,0 +1,32 @@ +import { ShieldOff } from "lucide-react"; + +import { useAuth } from "@/auth/useAuth"; +import { Button } from "@/components/ui/button"; + +/** + * Terminal page for an account with no freight permissions and no IAM admin + * role. Reached via `resolveLandingPath`, which needs one destination that can + * never bounce — every other page is permission-gated. + */ +export default function NoAccessPage() { + const { user, logout } = useAuth(); + + return ( +
+
+ +

No access assigned

+

+ Your account has no permissions assigned yet, so there are no pages to + show. Contact your administrator to have a position or role assigned. +

+ {user?.email ? ( +

Signed in as {user.email}

+ ) : null} + +
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx b/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx index 811d7e34b..db4533d21 100644 --- a/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx @@ -64,7 +64,9 @@ const LoginPage = () => { try { await verifyMfa({ email: normalizedIdentifier, otp: otp.trim() }); - navigate("/dashboard/overview", { replace: true }); + // "/" resolves to the user's own landing page once the session lands — + // not every user can see the overview. + navigate("/", { replace: true }); } catch (err) { setError(extractApiError(err).message); } finally { diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx index 57c239885..e3fb54526 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx @@ -30,6 +30,7 @@ import { } from "lucide-react"; import { useAuth } from "@/auth/useAuth"; +import { resolveLandingPath } from "@/lib/landing"; import { canAccessRuleEngineResource } from "@/lib/permissions"; import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog"; import { @@ -239,8 +240,8 @@ const CargoTypesPage = () => { [levelNodes, term], ); - if (!config) return ; - if (!canView) return ; + if (!config) return ; + if (!canView) return ; // A bad/stale :id (after data loads) → fall back to the root list. if (!isLoading && currentId && !current) return ; diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx index 885fcb82b..3a3629155 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx @@ -1,4 +1,5 @@ import { useAuth } from "@/auth/useAuth"; +import { resolveLandingPath } from "@/lib/landing"; import { canAccessRuleEngineResource, canApproveRuleEngineChange, @@ -584,7 +585,7 @@ const RuleEngineResourcePage = () => { } if (!canView) { - return ; + return ; } const openCreate = () => { diff --git a/apps/edr-freight-web/backoffice/src/routes/RootRedirect.tsx b/apps/edr-freight-web/backoffice/src/routes/RootRedirect.tsx index 77ad29531..48f3ce6aa 100644 --- a/apps/edr-freight-web/backoffice/src/routes/RootRedirect.tsx +++ b/apps/edr-freight-web/backoffice/src/routes/RootRedirect.tsx @@ -2,19 +2,15 @@ import type { ReactElement } from "react"; import { Navigate } from "react-router-dom"; import { useAuth } from "@/auth/useAuth"; +import { resolveLandingPath } from "@/lib/landing"; /** - * Root landing redirect for the vendored IAM pages. Sends super admins to the - * user-management dashboard and everyone else to the freight overview. Minimal - * replacement for the source app's @/routes/RootRedirect (not copied over). + * Root landing redirect for the vendored IAM pages. Delegates to the shared + * landing resolver so it cannot drift from the app's other redirect sinks. */ export function RootRedirect(): ReactElement { const { user } = useAuth(); - const roles = (user?.roles ?? []).map((r) => r.key).filter(Boolean); - if (roles.includes("super_admin")) { - return ; - } - return ; + return ; } export default RootRedirect; diff --git a/apps/edr-freight-web/backoffice/src/user-management/Applayout.tsx b/apps/edr-freight-web/backoffice/src/user-management/Applayout.tsx index ade96250d..37ab26be8 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/Applayout.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/Applayout.tsx @@ -40,8 +40,10 @@ export const AppLayout = () => { drives collapse on desktop and the Sheet drawer on mobile. */} + {/* "/" resolves to the user's own landing page — an IAM-only admin + has no overview to go back to. */} diff --git a/apps/edr-freight-web/backoffice/src/user-management/route.tsx b/apps/edr-freight-web/backoffice/src/user-management/route.tsx index af29cdc5e..f1e2a8611 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/route.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/route.tsx @@ -2,6 +2,7 @@ import type { ReactElement } from "react"; import { Navigate, Outlet, Route } from "react-router-dom"; import { useAuth } from "@/auth/useAuth"; +import { NO_ACCESS_PATH, resolveLandingPath } from "@/lib/landing"; import { isSuperAdmin } from "@/lib/permissions"; import { WithPermission } from "@/shared/hooks/useHas"; import PendingExternalUsers from "@/super-admin/components/externalUsers/PendingExternalUsers"; @@ -85,7 +86,7 @@ function PrivateRoute({ allowedRoles }: { allowedRoles: string[] }): ReactElemen .filter((k): k is string => Boolean(k)); const allowed = isSuperAdmin(user) || allowedRoles.some((r) => roleKeys.includes(r)); - return allowed ? : ; + return allowed ? : ; } export const UserManagementRedirect = () => { @@ -100,7 +101,9 @@ export const UserManagementRedirect = () => { return ; } - return ; // fallback + // No IAM role: "/" resolves back here for anyone whose only sidebar entry is + // Staff, so bounce to the terminal page instead of ping-ponging. + return ; }; /**