diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index d478da8f3..a412bf990 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -29,9 +29,22 @@ export const TrainSchedulingView = () => export const TrainSchedulingManage = () => BookingStaff(FREIGHT_PERMS.trainScheduling.manage); -export const FleetView = () => BookingStaff(FREIGHT_PERMS.fleet.view); +/** + * Fleet guards take an optional granular per-resource key (locomotives:create, + * 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) => + BookingStaff( + granular ? [granular, FREIGHT_PERMS.fleet.view] : FREIGHT_PERMS.fleet.view, + ); -export const FleetManage = () => BookingStaff(FREIGHT_PERMS.fleet.manage); +export const FleetManage = (granular?: string) => + BookingStaff( + granular + ? [granular, FREIGHT_PERMS.fleet.manage] + : FREIGHT_PERMS.fleet.manage, + ); /** Requester creates a wagon-transfer request (count-only, no wagon picks). */ export const WagonTransferRequest = () => 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 7f3f06ec2..ac3adb7e8 100644 --- a/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts +++ b/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts @@ -11,6 +11,7 @@ import { } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { FleetManage, FleetView } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { CreateCargoDto } from './dto/create-cargo.dto'; import { UpdateCargoDto } from './dto/update-cargo.dto'; import { LoadCargoDto } from './dto/load-cargo.dto'; @@ -19,12 +20,12 @@ import { CargoesService } from './cargoes.service'; @ApiTags('cargoes') @Controller('cargoes') -@FleetView() +@FleetView(FREIGHT_PERMS.cargoes.view) export class CargoesController { constructor(private readonly cargoesService: CargoesService) {} @Post() - @FleetManage() + @FleetManage(FREIGHT_PERMS.cargoes.create) @ApiOperation({ summary: 'Create a new cargo' }) create(@Body() dto: CreateCargoDto) { return this.cargoesService.create(dto); @@ -43,35 +44,35 @@ export class CargoesController { } @Patch(':id') - @FleetManage() + @FleetManage(FREIGHT_PERMS.cargoes.update) @ApiOperation({ summary: 'Update a cargo' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoDto) { return this.cargoesService.update(id, dto); } @Delete(':id') - @FleetManage() + @FleetManage(FREIGHT_PERMS.cargoes.delete) @ApiOperation({ summary: 'Delete a cargo' }) remove(@Param('id', ParseUUIDPipe) id: string) { return this.cargoesService.remove(id); } @Post(':id/load') - @FleetManage() + @FleetManage(FREIGHT_PERMS.cargoes.update) @ApiOperation({ summary: 'Load cargo into a container' }) load(@Param('id', ParseUUIDPipe) id: string, @Body() dto: LoadCargoDto) { return this.cargoesService.loadCargo(id, dto); } @Post(':id/unload') - @FleetManage() + @FleetManage(FREIGHT_PERMS.cargoes.update) @ApiOperation({ summary: 'Unload cargo from container' }) unload(@Param('id', ParseUUIDPipe) id: string) { return this.cargoesService.unloadCargo(id); } @Post(':id/deliver') - @FleetManage() + @FleetManage(FREIGHT_PERMS.cargoes.update) @ApiOperation({ summary: 'Mark cargo as delivered' }) deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto?: DeliverCargoDto) { return this.cargoesService.deliverCargo(id, dto); 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 b107e8935..579b9ee26 100644 --- a/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts +++ b/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts @@ -10,18 +10,19 @@ import { import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { FleetManage, FleetView } from "../../common/booking-guards"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { ConsignmentsService } from "./consignments.service"; import { CreateConsignmentDto } from "./dto/create-consignment.dto"; import { FilterConsignmentDto } from "./dto/filter-consignment.dto"; @ApiTags("consignments") @Controller("consignments") -@FleetView() +@FleetView(FREIGHT_PERMS.consignments.view) export class ConsignmentsController { constructor(private readonly consignmentsService: ConsignmentsService) {} @Post() - @FleetManage() + @FleetManage(FREIGHT_PERMS.consignments.create) @ApiOperation({ summary: "Create a new consignment" }) create(@Body() dto: CreateConsignmentDto) { return this.consignmentsService.create(dto); 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 1a0cdb14f..0a5e6bb0f 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 @@ -11,6 +11,7 @@ import { } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { FleetManage, FleetView } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { CreateContainerDto } from './dto/create-container.dto'; import { UpdateContainerDto } from './dto/update-container.dto'; import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto'; @@ -18,12 +19,12 @@ import { ContainersService } from './containers.service'; @ApiTags('containers') @Controller('containers') -@FleetView() +@FleetView(FREIGHT_PERMS.containers.view) export class ContainersController { constructor(private readonly containersService: ContainersService) {} @Post() - @FleetManage() + @FleetManage(FREIGHT_PERMS.containers.create) @ApiOperation({ summary: 'Create a new container' }) create(@Body() dto: CreateContainerDto) { return this.containersService.create(dto); @@ -42,28 +43,28 @@ export class ContainersController { } @Patch(':id') - @FleetManage() + @FleetManage(FREIGHT_PERMS.containers.update) @ApiOperation({ summary: 'Update a container' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerDto) { return this.containersService.update(id, dto); } @Delete(':id') - @FleetManage() + @FleetManage(FREIGHT_PERMS.containers.delete) @ApiOperation({ summary: 'Delete a container' }) remove(@Param('id', ParseUUIDPipe) id: string) { return this.containersService.remove(id); } @Post(':id/assign-wagon') - @FleetManage() + @FleetManage(FREIGHT_PERMS.containers.update) @ApiOperation({ summary: 'Assign container to a wagon' }) assignToWagon(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignContainerToWagonDto) { return this.containersService.assignToWagon(id, dto); } @Post(':id/unassign-wagon') - @FleetManage() + @FleetManage(FREIGHT_PERMS.containers.update) @ApiOperation({ summary: 'Unassign container from wagon' }) unassignFromWagon(@Param('id', ParseUUIDPipe) id: string) { return this.containersService.unassignFromWagon(id); diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts index fc80b238a..77d8b0df2 100644 --- a/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { FleetManage, StaffReference } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { CreateLocomotiveDto } from './dto/create-locomotive.dto'; import { FilterLocomotivesDto } from './dto/filter-locomotives.dto'; import { UpdateLocomotiveDto } from './dto/update-locomotive.dto'; @@ -31,21 +32,21 @@ export class LocomotivesController { } @Post() - @FleetManage() + @FleetManage(FREIGHT_PERMS.locomotives.create) @ApiOperation({ summary: 'Create a locomotive' }) create(@Body() dto: CreateLocomotiveDto) { return this.locomotivesService.create(dto); } @Patch(':id') - @FleetManage() + @FleetManage(FREIGHT_PERMS.locomotives.update) @ApiOperation({ summary: 'Update a locomotive' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLocomotiveDto) { return this.locomotivesService.update(id, dto); } @Post(':id/decommission') - @FleetManage() + @FleetManage(FREIGHT_PERMS.locomotives.delete) @ApiOperation({ summary: 'Decommission a locomotive' }) decommission(@Param('id', ParseUUIDPipe) id: string) { return this.locomotivesService.decommission(id); 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 8c25d67b3..cf2314156 100644 --- a/apps/edr-freight-api/src/modules/routes/routes.controller.ts +++ b/apps/edr-freight-api/src/modules/routes/routes.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { FleetManage, FleetView } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { CreateRouteDto } from './dto/create-route.dto'; import { FilterRoutesDto } from './dto/filter-routes.dto'; import { UpdateRouteDto } from './dto/update-route.dto'; @@ -10,7 +11,7 @@ import { RoutesService } from './routes.service'; @ApiTags('routes') @ApiBearerAuth() @Controller('routes') -@FleetView() +@FleetView(FREIGHT_PERMS.routes.view) export class RoutesController { constructor(private readonly routesService: RoutesService) {} @@ -27,21 +28,21 @@ export class RoutesController { } @Post() - @FleetManage() + @FleetManage(FREIGHT_PERMS.routes.create) @ApiOperation({ summary: 'Create route' }) create(@Body() dto: CreateRouteDto) { return this.routesService.create(dto); } @Patch(':id') - @FleetManage() + @FleetManage(FREIGHT_PERMS.routes.update) @ApiOperation({ summary: 'Update route' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRouteDto) { return this.routesService.update(id, dto); } @Delete(':id') - @FleetManage() + @FleetManage(FREIGHT_PERMS.routes.delete) @ApiOperation({ summary: 'Deactivate route' }) remove(@Param('id', ParseUUIDPipe) id: string) { return this.routesService.deactivate(id); 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 babf11ef1..19d631fbc 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 @@ -15,6 +15,7 @@ import { import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { FleetManage, FleetView } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto'; import { BuildTrainDto } from './dto/build-train.dto'; import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto'; @@ -27,12 +28,12 @@ import { TrainBuilderService } from './train-builder.service'; @ApiTags('train-builder') @ApiBearerAuth() @Controller('train-builder') -@FleetView() +@FleetView(FREIGHT_PERMS.trains.view) export class TrainBuilderController { constructor(private readonly trainBuilderService: TrainBuilderService) {} @Post() - @FleetManage() + @FleetManage(FREIGHT_PERMS.trains.create) @ApiOperation({ summary: 'Build a train: code + yard + 2+ locomotives (+ optional wagons)' }) build(@Body() dto: BuildTrainDto) { return this.trainBuilderService.buildTrain(dto); @@ -60,7 +61,7 @@ export class TrainBuilderController { } @Put(':id/locomotives') - @FleetManage() + @FleetManage(FREIGHT_PERMS.trains.update) @ApiOperation({ summary: 'Replace the locomotive set (minimum 2, same yard)' }) setLocomotives( @Param('id', ParseUUIDPipe) id: string, @@ -70,7 +71,7 @@ export class TrainBuilderController { } @Patch(':id/details') - @FleetManage() + @FleetManage(FREIGHT_PERMS.trains.update) @ApiOperation({ summary: "Edit the train's name and fixed import/export run numbers", }) @@ -82,7 +83,7 @@ export class TrainBuilderController { } @Patch(':id/yard') - @FleetManage() + @FleetManage(FREIGHT_PERMS.trains.update) @ApiOperation({ summary: 'Relocate the train — its locomotives and wagons move to the new yard with it', }) @@ -91,14 +92,14 @@ export class TrainBuilderController { } @Post(':id/wagons') - @FleetManage() + @FleetManage(FREIGHT_PERMS.trains.assignWagons) @ApiOperation({ summary: "Append AVAILABLE wagons from the train's yard to the consist" }) assignWagons(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignTrainWagonsDto) { return this.trainBuilderService.assignWagons(id, dto); } @Delete(':id/wagons/:wagonId') - @FleetManage() + @FleetManage(FREIGHT_PERMS.trains.assignWagons) @ApiOperation({ summary: 'Detach one wagon from the consist' }) removeWagon( @Param('id', ParseUUIDPipe) id: string, @@ -108,7 +109,7 @@ export class TrainBuilderController { } @Post(':id/wagons/:wagonId/maintenance') - @FleetManage() + @FleetManage(FREIGHT_PERMS.trains.assignWagons) @ApiOperation({ summary: 'Detach one wagon and move it to MAINTENANCE status' }) sendWagonToMaintenance( @Param('id', ParseUUIDPipe) id: string, @@ -118,14 +119,14 @@ export class TrainBuilderController { } @Post(':id/reorder-wagons') - @FleetManage() + @FleetManage(FREIGHT_PERMS.trains.assignWagons) @ApiOperation({ summary: 'Persist a drag-reorder of the full consist' }) reorderWagons(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ReorderTrainWagonsDto) { return this.trainBuilderService.reorderWagons(id, dto); } @Post(':id/deactivate') - @FleetManage() + @FleetManage(FREIGHT_PERMS.trains.update) @ApiOperation({ summary: 'Deactivate the train (park it) — only allowed with no active schedule', }) @@ -134,14 +135,14 @@ export class TrainBuilderController { } @Post(':id/activate') - @FleetManage() + @FleetManage(FREIGHT_PERMS.trains.update) @ApiOperation({ summary: 'Reactivate a deactivated train back to AVAILABLE' }) activate(@Param('id', ParseUUIDPipe) id: string) { return this.trainBuilderService.activate(id); } @Delete(':id') - @FleetManage() + @FleetManage(FREIGHT_PERMS.trains.delete) @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Disband the train (release wagons and locomotives)' }) disband(@Param('id', ParseUUIDPipe) id: string) { 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 0217bc161..173a738fa 100644 --- a/apps/edr-freight-api/src/modules/trains/trains.controller.ts +++ b/apps/edr-freight-api/src/modules/trains/trains.controller.ts @@ -12,18 +12,19 @@ import { import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { FleetManage, FleetView } from "../../common/booking-guards"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { CreateTrainDto } from "./dto/create-train.dto"; import { UpdateTrainDto } from "./dto/update-train.dto"; import { TrainsService } from "./trains.service"; @ApiTags("trains") @Controller("trains") -@FleetView() +@FleetView(FREIGHT_PERMS.trains.view) export class TrainsController { constructor(private readonly trainsService: TrainsService) {} @Post() - @FleetManage() + @FleetManage(FREIGHT_PERMS.trains.create) @ApiOperation({ summary: "Register a new train" }) create(@Body() dto: CreateTrainDto) { return this.trainsService.create(dto); @@ -42,14 +43,14 @@ export class TrainsController { } @Patch(":id") - @FleetManage() + @FleetManage(FREIGHT_PERMS.trains.update) @ApiOperation({ summary: "Update a train" }) update(@Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateTrainDto) { return this.trainsService.update(id, dto); } @Delete(":id") - @FleetManage() + @FleetManage(FREIGHT_PERMS.trains.delete) @ApiOperation({ summary: "Delete a train" }) remove(@Param("id", ParseUUIDPipe) id: string) { return this.trainsService.remove(id); diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts index d6925b71b..b00e4a64f 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts @@ -19,6 +19,7 @@ import { WagonTransferHistoryAll, WagonTransferRequest, } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { BulkFulfillTransferRequestsDto } from './dto/bulk-fulfill-transfer-requests.dto'; import { CreateTransferRequestDto } from './dto/create-transfer-request.dto'; import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto'; @@ -31,7 +32,7 @@ import { WagonTransferRequestsService } from './wagon-transfer-requests.service' */ @ApiTags('wagon-transfer-requests') @Controller('wagon-transfer-requests') -@FleetView() +@FleetView(FREIGHT_PERMS.wagons.view) export class WagonTransferRequestsController { constructor(private readonly service: WagonTransferRequestsService) {} @@ -110,7 +111,7 @@ export class WagonTransferRequestsController { } @Post(':id/cancel') - @FleetManage() + @FleetManage(FREIGHT_PERMS.wagons.transferRequest) @ApiOperation({ summary: 'Withdraw a pending transfer request' }) cancel(@Param('id', ParseUUIDPipe) id: string) { return this.service.cancelRequest(id); diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts index cd501362d..2ef7f00a5 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts @@ -13,6 +13,7 @@ import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { CurrentUser } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { FleetManage, FleetView, StaffReference } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { CreateWagonDto } from './dto/create-wagon.dto'; import { ListWagonsQueryDto } from './dto/list-wagons-query.dto'; import { UpdateWagonDto } from './dto/update-wagon.dto'; @@ -31,7 +32,7 @@ export class WagonsController { constructor(private readonly wagonsService: WagonsService) {} @Post() - @FleetManage() + @FleetManage(FREIGHT_PERMS.wagons.create) @ApiOperation({ summary: 'Create a new wagon' }) create(@Body() dto: CreateWagonDto) { return this.wagonsService.create(dto); @@ -61,42 +62,42 @@ export class WagonsController { } @Patch(':id') - @FleetManage() + @FleetManage(FREIGHT_PERMS.wagons.update) @ApiOperation({ summary: 'Update a wagon' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWagonDto) { return this.wagonsService.update(id, dto); } @Delete(':id') - @FleetManage() + @FleetManage(FREIGHT_PERMS.wagons.delete) @ApiOperation({ summary: 'Delete a wagon' }) remove(@Param('id', ParseUUIDPipe) id: string) { return this.wagonsService.remove(id); } @Post(':id/assign-train') - @FleetManage() + @FleetManage(FREIGHT_PERMS.trains.assignWagons) @ApiOperation({ summary: 'Assign wagon to a train' }) assignToTrain(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignWagonToTrainDto) { return this.wagonsService.assignToTrain(id, dto); } @Post(':id/unassign-train') - @FleetManage() + @FleetManage(FREIGHT_PERMS.trains.assignWagons) @ApiOperation({ summary: 'Unassign wagon from train' }) unassignFromTrain(@Param('id', ParseUUIDPipe) id: string) { return this.wagonsService.unassignFromTrain(id); } @Post('bulk-transfer') - @FleetManage() + @FleetManage(FREIGHT_PERMS.wagons.update) @ApiOperation({ summary: 'Transfer multiple wagons to a destination yard' }) bulkTransfer(@Body() dto: BulkTransferWagonsDto, @CurrentUser() user: TCurrentUser) { return this.wagonsService.bulkTransfer(dto, user?.id); } @Post('bulk-status') - @FleetManage() + @FleetManage(FREIGHT_PERMS.wagons.update) @ApiOperation({ summary: 'Set the status of multiple wagons' }) bulkSetStatus(@Body() dto: BulkSetWagonStatusDto) { return this.wagonsService.bulkSetStatus(dto); @@ -105,12 +106,12 @@ export class WagonsController { // Separate controller for train‑specific reorder (registered in module) @Controller('trains/:trainId/reorder-wagons') -@FleetView() +@FleetView(FREIGHT_PERMS.trains.view) export class TrainWagonsReorderController { constructor(private readonly wagonsService: WagonsService) {} @Post() - @FleetManage() + @FleetManage(FREIGHT_PERMS.trains.assignWagons) @ApiOperation({ summary: 'Reorder wagons of a train' }) reorder(@Param('trainId', ParseUUIDPipe) trainId: string, @Body() dto: ReorderWagonsDto) { return this.wagonsService.reorderWagons(trainId, dto); 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 d8fedcd6e..531abc206 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -212,6 +212,8 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [ perm('e1f00001-0001-4000-8000-000000000002', 'edr_freight_app:cargoes:create', 'Create cargo'), perm('e1f00001-0001-4000-8000-000000000003', 'edr_freight_app:cargoes:update', 'Update cargo'), perm('e1f00001-0001-4000-8000-000000000004', 'edr_freight_app:cargoes:delete', 'Delete cargo'), + perm('e1g00001-0001-4000-8000-000000000001', 'edr_freight_app:consignments:view', 'View consignments'), + perm('e1g00001-0001-4000-8000-000000000002', 'edr_freight_app:consignments:create', 'Create consignment'), ]; // G. Fleet — road & telemetry @@ -493,6 +495,10 @@ export const FREIGHT_PERMS = { update: 'edr_freight_app:cargoes:update', delete: 'edr_freight_app:cargoes:delete', }, + consignments: { + view: 'edr_freight_app:consignments:view', + create: 'edr_freight_app:consignments:create', + }, vehicles: { view: 'edr_freight_app:vehicles:view', create: 'edr_freight_app:vehicles:create', diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index caef6f14e..af49ff263 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -271,19 +271,19 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Routes", href: "/dashboard/routes", icon: , - permission: FREIGHT_PERMS.fleet.view, + permission: [FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view], }, { label: "Locomotives", href: "/dashboard/locomotives", icon: , - permission: FREIGHT_PERMS.fleet.view, + permission: [FREIGHT_PERMS.locomotives.view, FREIGHT_PERMS.fleet.view], }, { label: "Train Builder", href: "/dashboard/train-builder", icon: , - permission: FREIGHT_PERMS.fleet.view, + permission: [FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view], }, // { @@ -295,7 +295,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Wagons", href: "/dashboard/wagons", icon: , - permission: FREIGHT_PERMS.fleet.view, + permission: [FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view], }, { label: "Vehicles", @@ -1111,7 +1111,7 @@ const App = () => { + } @@ -1119,7 +1119,7 @@ const App = () => { + } @@ -1127,7 +1127,7 @@ const App = () => { + } @@ -1135,7 +1135,7 @@ const App = () => { + } @@ -1143,7 +1143,7 @@ const App = () => { + } @@ -1151,7 +1151,7 @@ const App = () => { + } @@ -1159,7 +1159,7 @@ const App = () => { + } @@ -1167,7 +1167,7 @@ const App = () => { + } @@ -1175,7 +1175,7 @@ const App = () => { + } @@ -1261,7 +1261,7 @@ const App = () => { + } @@ -1349,7 +1349,7 @@ const App = () => { + } @@ -1357,7 +1357,7 @@ const App = () => { + } @@ -1365,7 +1365,7 @@ const App = () => { + } @@ -1373,7 +1373,7 @@ const App = () => { + } @@ -1381,7 +1381,7 @@ const App = () => { + } @@ -1389,7 +1389,7 @@ const App = () => { + } @@ -1397,7 +1397,7 @@ const App = () => { + } @@ -1405,7 +1405,7 @@ const App = () => { + } diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetCardGrid.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetCardGrid.tsx index b2eef655f..1f8ab3f9f 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetCardGrid.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetCardGrid.tsx @@ -19,8 +19,9 @@ export interface FleetCardGridProps { pageCount: number; totalCount: number; onPaginationChange: OnChangeFn; - onEdit: (record: FleetRecord) => void; - onRemove: (record: FleetRecord) => void; + /** Omit to hide the action (caller lacks the update/delete permission). */ + onEdit?: (record: FleetRecord) => void; + onRemove?: (record: FleetRecord) => void; } const FleetCardGrid = ({ diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx index 887b235c4..6d9978f2c 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx @@ -8,8 +8,9 @@ import type { FleetRecord } from "@/services/fleet/fleet.service"; export interface FleetRecordActionsProps { record: FleetRecord; config: FleetResourceConfig; - onEdit: (record: FleetRecord) => void; - onRemove: (record: FleetRecord) => void; + /** Omit to hide the action (caller lacks the update/delete permission). */ + onEdit?: (record: FleetRecord) => void; + onRemove?: (record: FleetRecord) => void; onAssignDriver?: (record: FleetRecord) => void; onHistory?: (record: FleetRecord) => void; onViewDetail?: (record: FleetRecord) => void; @@ -42,6 +43,17 @@ const FleetRecordActions = ({ navigate(config.detailPath.replace(":id", String(record.id))); }; + if ( + !onEdit && + !onRemove && + !showDetail && + !showViewDetail && + !showHistory && + !(isVehicle && onAssignDriver) + ) { + return null; + } + if (layout === "compact") { return ( @@ -61,12 +73,14 @@ const FleetRecordActions = ({ Assign Driver ) : null} - onEdit(record)} - leftSection={} - > - Edit - + {onEdit ? ( + onEdit(record)} + leftSection={} + > + Edit + + ) : null} {showViewDetail ? ( onViewDetail?.(record)} @@ -91,13 +105,15 @@ const FleetRecordActions = ({ View details ) : null} - onRemove(record)} - leftSection={} - > - {removeLabel} - + {onRemove ? ( + onRemove(record)} + leftSection={} + > + {removeLabel} + + ) : null} ); @@ -121,12 +137,14 @@ const FleetRecordActions = ({ Assign Driver ) : null} - onEdit(record)} - leftSection={} - > - Edit - + {onEdit ? ( + onEdit(record)} + leftSection={} + > + Edit + + ) : null} {showViewDetail ? ( onViewDetail?.(record)} @@ -143,13 +161,15 @@ const FleetRecordActions = ({ View details ) : null} - onRemove(record)} - leftSection={} - > - {removeLabel} - + {onRemove ? ( + onRemove(record)} + leftSection={} + > + {removeLabel} + + ) : null} ); diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index c2b82b765..dfc55d115 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -507,6 +507,31 @@ export function canViewFleet(user: AuthUser | null | undefined): boolean { return hasPermission(user, FREIGHT_PERMS.fleet.view); } +export type FleetCrudResource = + | "locomotives" + | "wagons" + | "trains" + | "routes" + | "containers" + | "cargoes" + | "vehicles" + | "drivers"; + +/** + * Per-resource fleet CRUD check. The legacy coarse fleet:manage key still + * grants every action (mirrors the API's one-of guard fallback). + */ +export function canFleetAction( + user: AuthUser | null | undefined, + resource: FleetCrudResource, + action: "create" | "update" | "delete", +): boolean { + return ( + hasPermission(user, FREIGHT_PERMS[resource][action]) || + hasPermission(user, FREIGHT_PERMS.fleet.manage) + ); +} + export function isFreightAdmin(user: AuthUser | null | undefined): boolean { return hasPermission(user, FREIGHT_PERMS.admin); } diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx index 95dff563a..63a722550 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx @@ -3,6 +3,8 @@ import { Box, Button, Card, Container, Group, Modal, Select, Stack, Text, Title import { useMutation, useQuery } from "@tanstack/react-query"; import { api } from "@/services/api"; +import { useAuth } from "@/auth/useAuth"; +import { canFleetAction } from "@/lib/permissions"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; import { Inbox, Plus, Warehouse } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; @@ -37,6 +39,10 @@ const FleetResourcePage = () => { const slug = getFleetSlugFromPath(location.pathname) ?? DEFAULT_SLUG; const config = getFleetResource(slug); const { toast } = useToast(); + const { user } = useAuth(); + const canCreate = canFleetAction(user, slug, "create"); + const canUpdate = canFleetAction(user, slug, "update"); + const canDelete = canFleetAction(user, slug, "delete"); const { pagination, setPagination } = usePagination({ pageSize: 10 }); const [search, setSearch] = useState(""); @@ -278,12 +284,16 @@ const FleetResourcePage = () => { record={row.original} config={config} layout="compact" - onEdit={(record) => { - setEditing(record); - setFormOpen(true); - }} - onRemove={setRemoveTarget} - onAssignDriver={setAssigningDriver} + onEdit={ + canUpdate + ? (record) => { + setEditing(record); + setFormOpen(true); + } + : undefined + } + onRemove={canDelete ? setRemoveTarget : undefined} + onAssignDriver={canUpdate ? setAssigningDriver : undefined} onHistory={setHistoryTarget} /> @@ -291,7 +301,7 @@ const FleetResourcePage = () => { }); return base; - }, [config, dynamicOptions.yards]); + }, [config, dynamicOptions.yards, canUpdate, canDelete]); const tableStatus = isLoading ? "loading" : isError ? "error" : "success"; @@ -390,15 +400,17 @@ const FleetResourcePage = () => { {slug === "wagons" ? ( <> - + {canUpdate ? ( + + ) : null} ) : null} - + {canCreate ? ( + + ) : null} @@ -529,11 +543,15 @@ const FleetResourcePage = () => { pageCount={pageCount} totalCount={filteredRows.length} onPaginationChange={setPagination} - onEdit={(record) => { - setEditing(record); - setFormOpen(true); - }} - onRemove={setRemoveTarget} + onEdit={ + canUpdate + ? (record) => { + setEditing(record); + setFormOpen(true); + } + : undefined + } + onRemove={canDelete ? setRemoveTarget : undefined} /> )} diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx index 3ea10df7d..d4a26133f 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx @@ -36,6 +36,8 @@ import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; import { api } from "@/services/api"; import { ruleEngineService } from "@/services/ruleEngine/ruleEngine.service"; +import { useAuth } from "@/auth/useAuth"; +import { canFleetAction } from "@/lib/permissions"; import { useToast } from "@/hooks/use-toast"; import { formatRouteLabel, @@ -160,6 +162,10 @@ export default function RoutesPage() { const { viewMode, setViewMode } = useFleetViewMode("routes"); const { pagination, setPagination } = usePagination({ pageSize: 10 }); const { toast } = useToast(); + const { user } = useAuth(); + const canCreate = canFleetAction(user, "routes", "create"); + const canUpdate = canFleetAction(user, "routes", "update"); + const canDelete = canFleetAction(user, "routes", "delete"); const routesQuery = useQuery(api.routes.list.queryOptions()); const yardsQuery = useQuery(api.routes.yards.queryOptions()); @@ -441,26 +447,30 @@ export default function RoutesPage() { - - openEdit(row.original)}> - - - - - handleDeactivate(row.original)} - > - - - + {canUpdate ? ( + + openEdit(row.original)}> + + + + ) : null} + {canDelete ? ( + + handleDeactivate(row.original)} + > + + + + ) : null} ), }, ]; - }, [deactivateMutation.isPending]); + }, [deactivateMutation.isPending, canUpdate, canDelete]); return ( @@ -468,9 +478,11 @@ export default function RoutesPage() { title="Routes" subtitle="Define rail corridors, segment distances, and operational status for train scheduling." action={ - + canCreate ? ( + + ) : undefined } /> @@ -555,9 +567,11 @@ export default function RoutesPage() { - + {canUpdate ? ( + + ) : null} diff --git a/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx index 3841bc989..f9e4555ae 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx @@ -45,6 +45,8 @@ import { import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram"; import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import { api } from "@/services/api"; +import { useAuth } from "@/auth/useAuth"; +import { canFleetAction, FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { useToast } from "@/hooks/use-toast"; const parseError = (error: unknown, fallback: string) => { @@ -76,6 +78,12 @@ export default function TrainBuilderDetailPage() { const [yardModalOpen, setYardModalOpen] = useState(false); const [disbandOpen, setDisbandOpen] = useState(false); const [deactivateOpen, setDeactivateOpen] = useState(false); + const { user } = useAuth(); + const canUpdate = canFleetAction(user, "trains", "update"); + const canDelete = canFleetAction(user, "trains", "delete"); + const canAssign = + hasPermission(user, FREIGHT_PERMS.trains.assignWagons) || + hasPermission(user, FREIGHT_PERMS.fleet.manage); const compositionQuery = useQuery( api.trainBuilder.composition.queryOptions({ input: { id }, enabled: Boolean(id) }), @@ -162,59 +170,67 @@ export default function TrainBuilderDetailPage() { } action={ - - - - - - } - disabled={!composition.editable} - onClick={() => setLocoModalOpen(true)} - > - Change locomotives - - } - disabled={!composition.editable} - onClick={() => setYardModalOpen(true)} - > - Change yard - - {composition.status === "DEACTIVATED" ? ( - } - disabled={blockingLocomotives.length > 0} - onClick={() => - void withToast(async () => { - await activate.mutateAsync(composition.id); - toast({ title: `Train ${composition.code} reactivated` }); - }, "Could not reactivate train") - } - > - Reactivate train - - ) : ( - } - disabled={composition.activeSchedules.length > 0} - onClick={() => setDeactivateOpen(true)} - > - Deactivate train - - )} - } - disabled={composition.activeSchedules.length > 0} - onClick={() => setDisbandOpen(true)} - > - Disband train - - - + canUpdate || canDelete ? ( + + + + + + {canUpdate ? ( + <> + } + disabled={!composition.editable} + onClick={() => setLocoModalOpen(true)} + > + Change locomotives + + } + disabled={!composition.editable} + onClick={() => setYardModalOpen(true)} + > + Change yard + + {composition.status === "DEACTIVATED" ? ( + } + disabled={blockingLocomotives.length > 0} + onClick={() => + void withToast(async () => { + await activate.mutateAsync(composition.id); + toast({ title: `Train ${composition.code} reactivated` }); + }, "Could not reactivate train") + } + > + Reactivate train + + ) : ( + } + disabled={composition.activeSchedules.length > 0} + onClick={() => setDeactivateOpen(true)} + > + Deactivate train + + )} + + ) : null} + {canDelete ? ( + } + disabled={composition.activeSchedules.length > 0} + onClick={() => setDisbandOpen(true)} + > + Disband train + + ) : null} + + + ) : undefined } /> @@ -259,16 +275,18 @@ export default function TrainBuilderDetailPage() { . - + {canUpdate ? ( + + ) : null} + canCreate ? ( + + ) : undefined } />