diff --git a/apps/edr-passenger-api/src/common/passenger-guards.ts b/apps/edr-passenger-api/src/common/passenger-guards.ts index 936ee73ef..93c9e1ee7 100644 --- a/apps/edr-passenger-api/src/common/passenger-guards.ts +++ b/apps/edr-passenger-api/src/common/passenger-guards.ts @@ -26,3 +26,30 @@ export const PassengerStaffStrict = (permission: string | string[]) => ); export const PassengerAdmin = () => PassengerStaff(PASSENGER_PERMS.admin); + +/** + * A create / edit / domain action: the narrow key, the resource's `:manage` + * umbrella, or admin. + * + * Keeping `:manage` in the array is what makes the fine-grained keys additive — + * every position already granted `:manage` keeps working without being + * re-granted in IAM. Grant the narrow key *instead of* `:manage` to restrict + * someone. + * + * @PassengerWrite(PASSENGER_PERMS.schedules.create, PASSENGER_PERMS.schedules.manage) + */ +export const PassengerWrite = (narrow: string, umbrella: string) => + PassengerStaff([narrow, umbrella, PASSENGER_PERMS.admin]); + +/** + * A delete: the narrow `:delete` key or admin. **`:manage` is deliberately not + * accepted.** + * + * Every DELETE in this app was `@PassengerAdmin()` before the fine-grained keys + * existed, and most are hard cascading deletes. Letting `:manage` through here + * would silently hand deletion to `operationsManager`, `marketingManager` and + * every other role holding a `:manage` key — access they do not have today. + * So `:manage` means create + edit, never delete. + */ +export const PassengerDelete = (narrow: string) => + PassengerStaff([narrow, PASSENGER_PERMS.admin]); diff --git a/apps/edr-passenger-api/src/common/passenger-permission.util.ts b/apps/edr-passenger-api/src/common/passenger-permission.util.ts index 628d39dfe..69a2320a5 100644 --- a/apps/edr-passenger-api/src/common/passenger-permission.util.ts +++ b/apps/edr-passenger-api/src/common/passenger-permission.util.ts @@ -145,3 +145,31 @@ export function assertPassengerPermission( if (hasPassengerPermission(user, permissionKey)) return; throw new ForbiddenException(`Missing permission: ${permissionKey}`); } + +/** Holds at least one of the keys. Same OR semantics as `PassengerPermissionGuard`. */ +export function hasAnyPassengerPermission( + user: MeLikeUser | null | undefined, + permissionKeys: string[], +): boolean { + return permissionKeys.some((key) => hasPassengerPermission(user, key)); +} + +/** + * The in-handler equivalent of `@PassengerStaff([...])`, for actions a decorator + * cannot see — where the destructive variant is chosen by a body field rather + * than by the route. Cancelling a schedule is the case this exists for: + * `PATCH /schedules/:id/status` carries `{ status: 'CANCELLED' }` on the same + * route as every routine transition. + * + * Pass the umbrella and admin keys alongside the narrow one, exactly as a guard + * array would, so existing grants keep working. + */ +export function assertAnyPassengerPermission( + user: MeLikeUser | null | undefined, + permissionKeys: string[], +): void { + if (hasAnyPassengerPermission(user, permissionKeys)) return; + throw new ForbiddenException( + `Missing permission. Required one of: ${permissionKeys.join(', ')}`, + ); +} diff --git a/apps/edr-passenger-api/src/modules/agents/agents.controller.ts b/apps/edr-passenger-api/src/modules/agents/agents.controller.ts index 21f71a8c6..5ed724df2 100644 --- a/apps/edr-passenger-api/src/modules/agents/agents.controller.ts +++ b/apps/edr-passenger-api/src/modules/agents/agents.controller.ts @@ -3,7 +3,8 @@ import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { AgentsService } from './agents.service'; import { CreateAgentDto, CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto'; import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; -import { PassengerAdmin } from '../../common/passenger-guards'; +import { PassengerDelete, PassengerWrite } from '../../common/passenger-guards'; +import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; @ApiTags('Agents') @Controller('agents') @@ -26,18 +27,20 @@ export class AgentsController { @Post() @ApiOperation({ summary: 'Create agent profile linked to an IAM user' }) + @PassengerWrite(PASSENGER_PERMS.agents.create, PASSENGER_PERMS.agents.manage) createAgent(@Body() dto: CreateAgentDto) { return this.service.createAgent(dto); } @Patch(':id') @ApiOperation({ summary: 'Update agent profile' }) + @PassengerWrite(PASSENGER_PERMS.agents.edit, PASSENGER_PERMS.agents.manage) updateAgent(@Param('id') id: string, @Body() dto: Partial & { active?: boolean }) { return this.service.updateAgent(id, dto); } @Delete(':id') - @PassengerAdmin() + @PassengerDelete(PASSENGER_PERMS.agents.delete) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete agent profile' }) deleteAgent(@Param('id') id: string) { diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts index 36f2b7250..935741b6f 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -34,7 +34,7 @@ import { IssueReservationBookingDto, } from "./guest-booking.dto"; import { JwtGuard } from "../../common/jwt.guard"; -import { PassengerAdmin, PassengerStaff, PassengerStaffStrict } from "../../common/passenger-guards"; +import { PassengerDelete, PassengerStaff, PassengerStaffStrict, PassengerWrite } from "../../common/passenger-guards"; import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; import { SeatsService } from "../seats/seats.service"; @@ -372,7 +372,7 @@ export class BookingsController { } @Post("group") - @PassengerStaff([PASSENGER_PERMS.bookings.manage]) + @PassengerWrite(PASSENGER_PERMS.bookings.create, PASSENGER_PERMS.bookings.manage) @ApiBearerAuth("IAM-auth") @ApiOperation({ summary: "Create a group booking — staff bulk/group reservation, one PNR for the whole group", @@ -417,7 +417,14 @@ If booking creation fails after the seats were already held, every hold involved } @Delete("reservations/:seatId") - @PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.bookings.manage, PASSENGER_PERMS.admin]) + // Releasing a held seat is a cancel, not a booking delete — `bookings:cancel` is the + // narrow key for it; the two `:manage` keys stay so today's holders are unaffected. + @PassengerStaff([ + PASSENGER_PERMS.bookings.cancel, + PASSENGER_PERMS.seats.manage, + PASSENGER_PERMS.bookings.manage, + PASSENGER_PERMS.admin, + ]) @ApiBearerAuth("IAM-auth") @ApiOperation({ summary: "Cancel a seat's pending-payment reservation and release the seat", @@ -703,7 +710,7 @@ Results are ordered most-recent first. Use the returned \`bookingRef\` to open b } @Delete(":id") - @PassengerAdmin() + @PassengerDelete(PASSENGER_PERMS.bookings.delete) @ApiBearerAuth("IAM-auth") @ApiOperation({ description: "Permanently deletes a booking record", diff --git a/apps/edr-passenger-api/src/modules/currency/currency.controller.ts b/apps/edr-passenger-api/src/modules/currency/currency.controller.ts index 79f8f2c3c..1487c09f7 100644 --- a/apps/edr-passenger-api/src/modules/currency/currency.controller.ts +++ b/apps/edr-passenger-api/src/modules/currency/currency.controller.ts @@ -3,7 +3,8 @@ import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { IsEnum, IsNumber, IsOptional, IsString, Min } from 'class-validator'; import { Currency } from '@prisma/client'; import { CurrencyService } from './currency.service'; -import { PassengerAdmin } from '../../common/passenger-guards'; +import { PassengerDelete, PassengerWrite } from '../../common/passenger-guards'; +import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; class CreateRateDto { @IsEnum(Currency) fromCurrency: Currency; @@ -30,7 +31,7 @@ export class CurrencyController { } @Post() - @PassengerAdmin() + @PassengerWrite(PASSENGER_PERMS.currencies.create, PASSENGER_PERMS.currencies.manage) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Create exchange rate' }) create(@Body() dto: CreateRateDto) { @@ -38,7 +39,7 @@ export class CurrencyController { } @Patch(':id') - @PassengerAdmin() + @PassengerWrite(PASSENGER_PERMS.currencies.edit, PASSENGER_PERMS.currencies.manage) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Update exchange rate by ID' }) update(@Param('id') id: string, @Body() dto: UpdateRateDto) { @@ -46,7 +47,7 @@ export class CurrencyController { } @Delete(':id') - @PassengerAdmin() + @PassengerDelete(PASSENGER_PERMS.currencies.delete) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete exchange rate by ID' }) delete(@Param('id') id: string) { diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts index 0255c8a2b..4e5478093 100644 --- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts @@ -9,7 +9,20 @@ import { ConfirmExcessOtpDto, } from './excess-baggage.dto'; import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; -import { PassengerAdmin } from '../../common/passenger-guards'; +import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards'; +import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; + +/** + * Logging or re-sending a luggage charge bills a passenger and texts them a payment + * link, so it is its own grant. `bookings:manage` stays in the array as the umbrella — + * baggage hangs off a booking, and that is the key the backoffice already assumed. + * + * These two routes previously required no permission at all: the class-level + * `IamJwtGuard` is only authentication, so any signed-in account — a portal customer + * included — could raise a charge. + */ +const CanCharge = () => + PassengerStaff([PASSENGER_PERMS.excessBaggage.charge, PASSENGER_PERMS.bookings.manage, PASSENGER_PERMS.admin]); class UpsertBaggageAllowanceDto { @IsString() seatClassId: string; @@ -27,6 +40,7 @@ export class ExcessBaggageAgentController { constructor(private service: ExcessBaggageService) {} @Post() + @CanCharge() @ApiOperation({ summary: 'Log excess baggage charge and optionally collect cash' }) logCharge(@Request() req: any, @Body() dto: LogExcessBaggageDto) { dto.agentId = req.user?.id ?? req.user?.sub ?? ''; @@ -86,6 +100,7 @@ export class ExcessBaggageAgentController { } @Post(':id/resend') + @CanCharge() @ApiOperation({ summary: 'Resend payment link (extends expiry by 30 min)' }) resendLink(@Param('id') id: string) { return this.service.resendLink(id); diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts index a8172e223..791268aea 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts @@ -3,7 +3,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiBody, ApiR import { FleetService } from './fleet.service'; import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto, GenerateSeatMapDto } from './fleet.dto'; import { JwtGuard } from '../../common/jwt.guard'; -import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards'; +import { PassengerDelete, PassengerStaff, PassengerWrite } from '../../common/passenger-guards'; import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; @ApiTags('Fleet') @@ -22,7 +22,7 @@ export class FleetController { } @Post('coach-types') - @PassengerStaff([PASSENGER_PERMS.coaches.manage, PASSENGER_PERMS.admin]) + @PassengerWrite(PASSENGER_PERMS.coaches.create, PASSENGER_PERMS.coaches.manage) @ApiOperation({ summary: 'Create a coach type' }) @ApiBody({ type: CreateCoachTypeDto }) @ApiResponse({ status: 201, description: 'Coach type created' }) @@ -31,7 +31,7 @@ export class FleetController { } @Patch('coach-types/:id') - @PassengerStaff([PASSENGER_PERMS.coaches.manage, PASSENGER_PERMS.admin]) + @PassengerWrite(PASSENGER_PERMS.coaches.edit, PASSENGER_PERMS.coaches.manage) @ApiOperation({ summary: 'Update a coach type' }) @ApiParam({ name: 'id', description: 'Coach Type UUID' }) @ApiBody({ type: UpdateCoachTypeDto }) @@ -42,7 +42,7 @@ export class FleetController { } @Delete('coach-types/:id') - @PassengerAdmin() + @PassengerDelete(PASSENGER_PERMS.coaches.delete) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete a coach type' }) @ApiParam({ name: 'id', description: 'Coach Type UUID' }) @@ -62,7 +62,7 @@ export class FleetController { } @Post('classes') - @PassengerStaff([PASSENGER_PERMS.classes.manage, PASSENGER_PERMS.admin]) + @PassengerWrite(PASSENGER_PERMS.classes.create, PASSENGER_PERMS.classes.manage) @ApiOperation({ summary: 'Create a class' }) @ApiBody({ type: CreateClassDto }) @ApiResponse({ status: 201, description: 'Class created' }) @@ -71,7 +71,7 @@ export class FleetController { } @Patch('classes/:id') - @PassengerStaff([PASSENGER_PERMS.classes.manage, PASSENGER_PERMS.admin]) + @PassengerWrite(PASSENGER_PERMS.classes.edit, PASSENGER_PERMS.classes.manage) @ApiOperation({ summary: 'Update a class' }) @ApiParam({ name: 'id', description: 'Class UUID' }) @ApiBody({ type: UpdateClassDto }) @@ -82,7 +82,7 @@ export class FleetController { } @Delete('classes/:id') - @PassengerAdmin() + @PassengerDelete(PASSENGER_PERMS.classes.delete) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete a class' }) @ApiParam({ name: 'id', description: 'Class UUID' }) @@ -103,7 +103,7 @@ export class FleetController { } @Post('seat-classes') - @PassengerStaff([PASSENGER_PERMS.classes.manage, PASSENGER_PERMS.admin]) + @PassengerWrite(PASSENGER_PERMS.classes.create, PASSENGER_PERMS.classes.manage) @ApiOperation({ summary: 'Create a class (DEPRECATED - use /fleet/classes)' }) @ApiBody({ type: CreateClassDto }) @ApiResponse({ status: 201, description: 'Class created' }) @@ -112,7 +112,7 @@ export class FleetController { } @Patch('seat-classes/:id') - @PassengerStaff([PASSENGER_PERMS.classes.manage, PASSENGER_PERMS.admin]) + @PassengerWrite(PASSENGER_PERMS.classes.edit, PASSENGER_PERMS.classes.manage) @ApiOperation({ summary: 'Update a class (DEPRECATED - use /fleet/classes)' }) @ApiParam({ name: 'id', description: 'Class UUID' }) @ApiBody({ type: UpdateClassDto }) @@ -123,7 +123,7 @@ export class FleetController { } @Delete('seat-classes/:id') - @PassengerAdmin() + @PassengerDelete(PASSENGER_PERMS.classes.delete) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete a class (DEPRECATED - use /fleet/classes)' }) @ApiParam({ name: 'id', description: 'Class UUID' }) @@ -143,7 +143,7 @@ export class FleetController { } @Post('trains') - @PassengerStaff([PASSENGER_PERMS.trains.manage, PASSENGER_PERMS.admin]) + @PassengerWrite(PASSENGER_PERMS.trains.create, PASSENGER_PERMS.trains.manage) @ApiOperation({ summary: 'Create a train service' }) @ApiBody({ type: CreateTrainDto }) @ApiResponse({ status: 201, description: 'Train created' }) @@ -152,7 +152,7 @@ export class FleetController { } @Patch('trains/:id') - @PassengerStaff([PASSENGER_PERMS.trains.manage, PASSENGER_PERMS.admin]) + @PassengerWrite(PASSENGER_PERMS.trains.edit, PASSENGER_PERMS.trains.manage) @ApiOperation({ summary: 'Update a train service' }) @ApiParam({ name: 'id', description: 'Train UUID' }) @ApiBody({ type: CreateTrainDto }) @@ -163,7 +163,7 @@ export class FleetController { } @Delete('trains/:id') - @PassengerAdmin() + @PassengerDelete(PASSENGER_PERMS.trains.delete) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete a train service' }) @ApiParam({ name: 'id', description: 'Train UUID' }) @@ -175,7 +175,7 @@ export class FleetController { } @Patch('trains/:id/restore') - @PassengerStaff([PASSENGER_PERMS.trains.manage, PASSENGER_PERMS.admin]) + @PassengerWrite(PASSENGER_PERMS.trains.edit, PASSENGER_PERMS.trains.manage) @ApiOperation({ summary: 'Restore (reactivate) a deactivated train' }) @ApiParam({ name: 'id', description: 'Train UUID' }) @ApiResponse({ status: 200, description: 'Train restored' }) @@ -229,6 +229,7 @@ export class FleetController { } @Get('coaches/utilization') + @PassengerStaff([PASSENGER_PERMS.reports.coachUtilization.view, PASSENGER_PERMS.reports.view, PASSENGER_PERMS.admin]) @ApiOperation({ summary: 'Coach utilization report — seats, bookings, and assignment history per coach for a selected schedule' }) @ApiQuery({ name: 'scheduleId', required: false, description: 'Optional schedule UUID to scope the utilization report to that schedule.' }) @ApiResponse({ status: 200, description: 'Coach utilization data' }) @@ -279,7 +280,7 @@ export class FleetController { } @Post('coaches') - @PassengerStaff([PASSENGER_PERMS.coaches.manage, PASSENGER_PERMS.admin]) + @PassengerWrite(PASSENGER_PERMS.coaches.create, PASSENGER_PERMS.coaches.manage) @ApiOperation({ summary: 'Create a coach with auto-generated seat numbers' }) @ApiBody({ type: CreateCoachDto }) @ApiResponse({ @@ -305,7 +306,7 @@ export class FleetController { } @Patch('coaches/:id') - @PassengerStaff([PASSENGER_PERMS.coaches.manage, PASSENGER_PERMS.admin]) + @PassengerWrite(PASSENGER_PERMS.coaches.edit, PASSENGER_PERMS.coaches.manage) @ApiOperation({ summary: 'Update coach properties' }) @ApiParam({ name: 'id', description: 'Coach UUID' }) @ApiBody({ type: UpdateCoachDto }) @@ -332,7 +333,7 @@ export class FleetController { } @Delete('coaches/:id') - @PassengerAdmin() + @PassengerDelete(PASSENGER_PERMS.coaches.delete) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete a coach' }) @ApiParam({ name: 'id', description: 'Coach UUID' }) @@ -344,7 +345,7 @@ export class FleetController { } @Post('assignments') - @PassengerStaff([PASSENGER_PERMS.coaches.manage, PASSENGER_PERMS.admin]) + @PassengerWrite(PASSENGER_PERMS.coaches.edit, PASSENGER_PERMS.coaches.manage) @ApiOperation({ summary: 'Assign a coach to a schedule' }) @ApiBody({ type: AssignCoachDto }) @ApiResponse({ status: 201, description: 'Coach assigned' }) @@ -354,7 +355,7 @@ export class FleetController { } @Delete('assignments/:id') - @PassengerAdmin() + @PassengerWrite(PASSENGER_PERMS.coaches.edit, PASSENGER_PERMS.coaches.manage) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Remove a coach assignment' }) @ApiParam({ name: 'id', description: 'Assignment UUID' }) @@ -365,6 +366,7 @@ export class FleetController { } @Post('seatmap/generate') + @PassengerWrite(PASSENGER_PERMS.coaches.edit, PASSENGER_PERMS.coaches.manage) @ApiOperation({ summary: 'Preview bed seat map — ECONOMY_BED or VIP_BED', description: `Generates a structured seat map for bed coaches without persisting anything. diff --git a/apps/edr-passenger-api/src/modules/fraud/fraud.controller.ts b/apps/edr-passenger-api/src/modules/fraud/fraud.controller.ts index 87007f16a..e92d4b98a 100644 --- a/apps/edr-passenger-api/src/modules/fraud/fraud.controller.ts +++ b/apps/edr-passenger-api/src/modules/fraud/fraud.controller.ts @@ -1,7 +1,7 @@ import { Controller, Get, Post, Patch, Param, Body, Query, Logger } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { FraudService, FraudRuleConfig } from './fraud.service'; -import { PassengerStaff } from '../../common/passenger-guards'; +import { PassengerStaff, PassengerWrite } from '../../common/passenger-guards'; import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; @ApiTags('Fraud Detection') @@ -41,7 +41,7 @@ export class FraudController { * Create or update fraud rule */ @Post('rules') - @PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin]) + @PassengerWrite(PASSENGER_PERMS.fraud.create, PASSENGER_PERMS.fraud.manage) @ApiOperation({ summary: 'Create or update fraud rule' }) async upsertRule(@Body() body: { type: string; config: FraudRuleConfig }) { const rule = await this.fraudService.upsertRule(body.type, body.config); @@ -52,7 +52,7 @@ export class FraudController { * Acknowledge a fraud alert */ @Patch('alerts/:id/acknowledge') - @PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin]) + @PassengerWrite(PASSENGER_PERMS.fraud.edit, PASSENGER_PERMS.fraud.manage) @ApiOperation({ summary: 'Acknowledge a fraud alert' }) async acknowledgeAlert(@Param('id') id: string) { const alert = await this.fraudService.acknowledgeAlert(id); @@ -63,7 +63,7 @@ export class FraudController { * Block user via userId */ @Post('users/:userId/block') - @PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin]) + @PassengerWrite(PASSENGER_PERMS.fraud.edit, PASSENGER_PERMS.fraud.manage) @ApiOperation({ summary: 'Block user by userId' }) async blockUserById( @Param('userId') userId: string, @@ -77,7 +77,7 @@ export class FraudController { * Block user temporarily */ @Post('actions/block') - @PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin]) + @PassengerWrite(PASSENGER_PERMS.fraud.edit, PASSENGER_PERMS.fraud.manage) @ApiOperation({ summary: 'Block user temporarily' }) async blockUser(@Body() body: { iamUserId: string; durationMinutes: number }) { await this.fraudService.blockUserTemporarily(body.iamUserId, body.durationMinutes); @@ -88,7 +88,7 @@ export class FraudController { * Unblock user */ @Post('actions/unblock') - @PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin]) + @PassengerWrite(PASSENGER_PERMS.fraud.edit, PASSENGER_PERMS.fraud.manage) @ApiOperation({ summary: 'Unblock user' }) async unblockUser(@Body() body: { iamUserId: string }) { await this.fraudService.unblockUser(body.iamUserId); diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts index 99ce32b08..767605ae1 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts @@ -97,6 +97,7 @@ export class NotificationsController { } @Post('test') + @PassengerStaff([PASSENGER_PERMS.notifications.send, PASSENGER_PERMS.admin]) @ApiOperation({ summary: 'Test notification delivery (Admin only)' }) async testNotification(@Body() dto: TestNotificationDto) { return this.service.send( diff --git a/apps/edr-passenger-api/src/modules/packages/packages.controller.ts b/apps/edr-passenger-api/src/modules/packages/packages.controller.ts index 6ef20bf40..197a6b4e9 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.controller.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.controller.ts @@ -7,7 +7,7 @@ import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDt import { PACKAGE_IMAGE_FIELD, packageImageMulterOptions } from './package-image-upload.options'; import { JwtGuard } from '../../common/jwt.guard'; import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard'; -import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards'; +import { PassengerDelete, PassengerStaff, PassengerWrite } from '../../common/passenger-guards'; import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; @ApiTags('Packages') @@ -36,7 +36,7 @@ export class PackagesController { } @Patch('inquiries/:id/status') - @PassengerStaff([PASSENGER_PERMS.inquiries.manage, PASSENGER_PERMS.admin]) + @PassengerWrite(PASSENGER_PERMS.inquiries.edit, PASSENGER_PERMS.inquiries.manage) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Update inquiry status (backoffice)' }) updateInquiryStatus(@Param('id') id: string, @Body() dto: UpdateInquiryStatusDto) { @@ -44,7 +44,7 @@ export class PackagesController { } @Delete('inquiries/:id') - @PassengerAdmin() + @PassengerDelete(PASSENGER_PERMS.inquiries.delete) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete inquiry (backoffice)' }) deleteInquiry(@Param('id') id: string) { @@ -126,7 +126,7 @@ export class PackagesController { } @Post() - @PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin]) + @PassengerWrite(PASSENGER_PERMS.packages.create, PASSENGER_PERMS.packages.manage) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Create package (admin)' }) create(@Body() dto: CreatePackageDto) { @@ -134,7 +134,7 @@ export class PackagesController { } @Patch(':id') - @PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin]) + @PassengerWrite(PASSENGER_PERMS.packages.edit, PASSENGER_PERMS.packages.manage) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Update package (admin)' }) update(@Param('id') id: string, @Body() dto: Partial) { @@ -142,7 +142,7 @@ export class PackagesController { } @Delete(':id') - @PassengerAdmin() + @PassengerDelete(PASSENGER_PERMS.packages.delete) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete package (admin)' }) @ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete even with active bookings' }) @@ -151,7 +151,7 @@ export class PackagesController { } @Post(':id/image') - @PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin]) + @PassengerWrite(PASSENGER_PERMS.packages.edit, PASSENGER_PERMS.packages.manage) @ApiBearerAuth('IAM-auth') @UseInterceptors(FileInterceptor(PACKAGE_IMAGE_FIELD, packageImageMulterOptions)) @ApiConsumes('multipart/form-data') @@ -166,7 +166,7 @@ export class PackagesController { } @Delete(':id/image') - @PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin]) + @PassengerWrite(PASSENGER_PERMS.packages.edit, PASSENGER_PERMS.packages.manage) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Remove a package image without deleting the package (admin)' }) removeImage(@Param('id') id: string) { @@ -174,7 +174,7 @@ export class PackagesController { } @Patch(':id/activate') - @PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin]) + @PassengerWrite(PASSENGER_PERMS.packages.publish, PASSENGER_PERMS.packages.manage) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Activate package (admin)' }) activate(@Param('id') id: string) { @@ -182,7 +182,7 @@ export class PackagesController { } @Patch(':id/deactivate') - @PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin]) + @PassengerWrite(PASSENGER_PERMS.packages.publish, PASSENGER_PERMS.packages.manage) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Deactivate package (admin)' }) deactivate(@Param('id') id: string) { @@ -190,7 +190,7 @@ export class PackagesController { } @Post(':id/tiers') - @PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin]) + @PassengerWrite(PASSENGER_PERMS.packages.edit, PASSENGER_PERMS.packages.manage) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Add price tier to package (admin)' }) addTier(@Param('id') id: string, @Body() dto: CreatePriceTierDto) { @@ -198,7 +198,7 @@ export class PackagesController { } @Patch('tiers/:tierId') - @PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin]) + @PassengerWrite(PASSENGER_PERMS.packages.edit, PASSENGER_PERMS.packages.manage) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Update price tier (admin)' }) updateTier(@Param('tierId') tierId: string, @Body() dto: UpdatePriceTierDto) { @@ -206,7 +206,7 @@ export class PackagesController { } @Delete('tiers/:tierId') - @PassengerAdmin() + @PassengerWrite(PASSENGER_PERMS.packages.edit, PASSENGER_PERMS.packages.manage) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete price tier (admin)' }) deleteTier(@Param('tierId') tierId: string) { diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts index e6c4d2fbc..53e338eb2 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts @@ -28,7 +28,8 @@ import { RegisterPassengerDto, } from "./passengers.dto"; import { JwtGuard } from "../../common/jwt.guard"; -import { PassengerAdmin } from "../../common/passenger-guards"; +import { PassengerDelete } from "../../common/passenger-guards"; +import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; import { VerifaydaService } from "../verifayda/verifayda.service"; import { OptionalJwtGuard } from "../verifayda/optional-jwt.guard"; import { PrismaService } from "../../common/prisma.service"; @@ -566,7 +567,7 @@ Returns saved passenger details with generated IDs and confirmation.`, } @Delete(":id") - @PassengerAdmin() + @PassengerDelete(PASSENGER_PERMS.passengers.delete) @ApiBearerAuth("IAM-auth") @ApiOperation({ summary: "Delete passenger (admin only)", diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index 956de8e06..e5a3f5985 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -37,7 +37,7 @@ import { ForceConfirmDto, ConfirmOtpDto, } from "./payments.dto"; -import { PassengerStaff } from "../../common/passenger-guards"; +import { PassengerDelete, PassengerStaff, PassengerWrite } from "../../common/passenger-guards"; import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; import { resolveActingUser } from "../../common/acting-user"; import { resolveAllowedOrigin } from "../../common/utils/redirect-origin.util"; @@ -85,7 +85,7 @@ export class PaymentsController { ) {} @Delete(":id") - @PassengerStaff([PASSENGER_PERMS.admin]) + @PassengerDelete(PASSENGER_PERMS.payments.delete) @ApiBearerAuth("IAM-auth") @ApiOperation({ summary: "Delete a payment intent record (admin only)" }) deletePayment(@Param("id") id: string) { @@ -252,6 +252,7 @@ export class PaymentsController { @Post("methods") @PassengerStaff([ + PASSENGER_PERMS.paymentMethods.create, PASSENGER_PERMS.paymentMethods.manage, PASSENGER_PERMS.payments.manageMethods, PASSENGER_PERMS.admin, @@ -266,6 +267,7 @@ export class PaymentsController { @Patch("methods/:id") @PassengerStaff([ + PASSENGER_PERMS.paymentMethods.edit, PASSENGER_PERMS.paymentMethods.manage, PASSENGER_PERMS.payments.manageMethods, PASSENGER_PERMS.admin, @@ -394,7 +396,7 @@ export class PaymentsController { // ── Supplementary Charges ────────────────────────────────────────────────── @Post('supplementary') - @PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin]) + @PassengerStaff([PASSENGER_PERMS.payments.supplementary, PASSENGER_PERMS.payments.create, PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Raise a supplementary charge for an underpayment (staff only)' }) createSupplementaryCharge(@Body() dto: CreateSupplementaryChargeDto, @Req() req: any) { @@ -504,7 +506,7 @@ export class PaymentsController { } @Post('supplementary/:id/mark-paid') - @PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin]) + @PassengerWrite(PASSENGER_PERMS.payments.edit, PASSENGER_PERMS.payments.manage) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Manually mark a supplementary charge as paid (staff only)' }) markSupplementaryPaid( @@ -515,7 +517,7 @@ export class PaymentsController { } @Post('supplementary/:id/waive') - @PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin]) + @PassengerWrite(PASSENGER_PERMS.payments.edit, PASSENGER_PERMS.payments.manage) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Waive a supplementary charge (staff only)' }) waiveSupplementaryCharge( @@ -528,7 +530,7 @@ export class PaymentsController { } @Post('supplementary/:id/resend') - @PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin]) + @PassengerStaff([PASSENGER_PERMS.payments.supplementary, PASSENGER_PERMS.payments.create, PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Resend payment link for a supplementary charge (staff only)' }) resendSupplementaryLink(@Param('id') id: string) { diff --git a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts index 6cf12a9f6..cc6a04c91 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts @@ -12,26 +12,60 @@ import { BlockedSeatsRevenueLossQueryDto, FinanceSummaryQueryDto, GenerateReport import { PassengerStaff } from "../../common/passenger-guards"; import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; +const R = PASSENGER_PERMS.reports; + +/** + * One report's guard: its own key, the `reports:view` umbrella, or admin. + * + * The umbrella is kept in every array so the `finance`, `financeManager` and + * `director` presets — which hold `reports:view` — keep seeing every report. + * Granting only a per-report key hands out that report and nothing else. + * + * This is deliberately NOT a class-level decorator. Nest requires controller- + * level AND route-level guards to both pass, so a class-level + * `[reports.view, admin]` plus a per-route key would be an AND and would lock + * out everyone holding only `reports:view`. + */ +const Report = (key: string) => PassengerStaff([key, R.view, PASSENGER_PERMS.admin]); + +/** The schedule picker is shared by five reports, so any report key opens it. */ +const AnyReport = () => + PassengerStaff([ + R.overall.view, + R.finance.view, + R.coachUtilization.view, + R.seatStatus.view, + R.blockedSeats.view, + R.passengers.view, + R.boarding.view, + R.payments.view, + R.catalog.view, + R.view, + PASSENGER_PERMS.admin, + ]); + @ApiTags("Reports") @Controller("reports") -@PassengerStaff([PASSENGER_PERMS.reports.view, PASSENGER_PERMS.admin]) @ApiBearerAuth("IAM-auth") export class ReportsController { constructor(private service: ReportsService) {} @Post("generate") + @Report(R.catalog.view) @ApiOperation({ summary: "Generate operational report" }) generateReport(@Body() dto: GenerateReportDto) { return this.service.generateReport(dto); } @Get('schedules') + @AnyReport() @ApiOperation({ summary: 'List schedules for the passengers report picker' }) listSchedulesForPicker(@Query('all') all?: string) { return this.service.listSchedulesForPicker(all === 'true'); } @Get("passengers/list") + @Report(R.passengers.view) @ApiOperation({ summary: "Flat passenger list for a specific schedule" }) getPassengerList(@Query("scheduleId") scheduleId: string) { return this.service.getPassengerList(scheduleId); @@ -39,6 +73,7 @@ export class ReportsController { // Two segments, so `@Get(":reportId")` below cannot shadow it whatever the order. @Get("passengers/overview") + @Report(R.passengers.view) @ApiOperation({ summary: "Fleet-wide passenger mix across a departure window", description: @@ -56,12 +91,14 @@ export class ReportsController { } @Get("passengers") + @Report(R.passengers.view) @ApiOperation({ summary: "Passengers report for a specific schedule" }) getOccupancyReport(@Query("scheduleId") scheduleId: string) { return this.service.getOccupancyBySchedule(scheduleId); } @Get("payment-discrepancy") + @Report(R.payments.view) @ApiOperation({ summary: "Payment discrepancy report — bookings where paid amount is less than the fare. Pass `search` to look up a specific PNR or ticket number." }) getPaymentDiscrepancy( @Query('from') from?: string, @@ -73,6 +110,7 @@ export class ReportsController { } @Get("seat-status") + @Report(R.seatStatus.view) @ApiOperation({ summary: "Seat status breakdown for a schedule (paid, unpaid, expired holds, blocked)" }) getSeatStatusReport(@Query('scheduleId') scheduleId: string) { return this.service.getSeatStatusReport(scheduleId); @@ -80,6 +118,7 @@ export class ReportsController { // Two segments, so `@Get(":reportId")` below cannot shadow it whatever the order. @Get("seat-status/overview") + @Report(R.seatStatus.view) @ApiOperation({ summary: "Fleet-wide seat status across a departure window", description: @@ -96,18 +135,21 @@ export class ReportsController { } @Get("boarding") + @Report(R.boarding.view) @ApiOperation({ summary: "Boarding report for a schedule — boarded vs not-boarded passengers" }) getBoardingReport(@Query('scheduleId') scheduleId: string) { return this.service.getBoardingReport(scheduleId); } @Get("payments") + @Report(R.payments.view) @ApiOperation({ summary: "Payments collected for a schedule" }) getPaymentsReport(@Query('scheduleId') scheduleId: string) { return this.service.getPaymentsReport(scheduleId); } @Get("payments/discrepancy") + @Report(R.payments.view) @ApiOperation({ summary: "Payment discrepancy breakdown for a schedule" }) getPaymentDiscrepancyBySchedule( @Query('scheduleId') scheduleId: string, @@ -121,6 +163,7 @@ export class ReportsController { // ── Finance Summary ────────────────────────────────────────────────────── @Get("finance") + @Report(R.finance.view) @ApiOperation({ summary: "Finance summary — revenue by period, origin/destination segment, payment method, and currency", description: @@ -138,6 +181,7 @@ export class ReportsController { } @Get("finance/export") + @Report(R.finance.export) @ApiOperation({ summary: "Finance summary as CSV — one row per period + route + payment method" }) @ApiProduces("text/csv") @ApiOkResponse({ description: "CSV export", schema: { type: "string" } }) @@ -154,6 +198,7 @@ export class ReportsController { // ── Blocked Seat Revenue Loss ────────────────────────────────────────────── @Get("blocked-seats-revenue-loss") + @Report(R.blockedSeats.view) @ApiOperation({ summary: "Potential revenue lost to blocked seats, per schedule", description: @@ -176,6 +221,7 @@ export class ReportsController { } @Get("blocked-seats-revenue-loss/export") + @Report(R.blockedSeats.export) @ApiOperation({ summary: "Blocked-seat revenue loss as CSV", description: @@ -200,12 +246,14 @@ export class ReportsController { } @Get(":reportId") + @Report(R.catalog.view) @ApiOperation({ summary: "Get report by ID" }) getReport(@Param("reportId") reportId: string) { return this.service.getReport(reportId); } @Get() + @Report(R.catalog.view) @ApiOperation({ summary: "List reports" }) listReports(@Query("type") type?: string) { return this.service.listReports(type); diff --git a/apps/edr-passenger-api/src/modules/reschedule/reschedule.controller.ts b/apps/edr-passenger-api/src/modules/reschedule/reschedule.controller.ts index 0aba110d1..0d944dd3e 100644 --- a/apps/edr-passenger-api/src/modules/reschedule/reschedule.controller.ts +++ b/apps/edr-passenger-api/src/modules/reschedule/reschedule.controller.ts @@ -1,7 +1,7 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { JwtGuard } from '../../common/jwt.guard'; -import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards'; +import { PassengerDelete, PassengerStaff, PassengerWrite } from '../../common/passenger-guards'; import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; import { RescheduleService } from './reschedule.service'; import { @@ -17,7 +17,7 @@ export class RescheduleController { constructor(private service: RescheduleService) {} @Get('reschedule/policies') - @PassengerStaff(PASSENGER_PERMS.bookings.view) + @PassengerStaff([PASSENGER_PERMS.reschedulePolicies.view, PASSENGER_PERMS.reschedulePolicies.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Every reschedule policy, each with its coach type (fare class)' }) listPolicies() { @@ -25,7 +25,7 @@ export class RescheduleController { } @Get('reschedule/policies/available-coach-types') - @PassengerStaff(PASSENGER_PERMS.bookings.view) + @PassengerStaff([PASSENGER_PERMS.reschedulePolicies.view, PASSENGER_PERMS.reschedulePolicies.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Coach types that do not have a reschedule policy yet (add-dialog dropdown)' }) listUnconfiguredCoachTypes() { @@ -33,7 +33,7 @@ export class RescheduleController { } @Post('reschedule/policies') - @PassengerAdmin() + @PassengerWrite(PASSENGER_PERMS.reschedulePolicies.create, PASSENGER_PERMS.reschedulePolicies.manage) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Create a reschedule policy for a coach type (admin)' }) createPolicy(@Req() req: any, @Body() dto: CreateReschedulePolicyDto) { @@ -41,7 +41,7 @@ export class RescheduleController { } @Patch('reschedule/policies/:coachTypeId') - @PassengerAdmin() + @PassengerWrite(PASSENGER_PERMS.reschedulePolicies.edit, PASSENGER_PERMS.reschedulePolicies.manage) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Update the reschedule policy of a coach type (admin)' }) updatePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string, @Body() dto: UpdateReschedulePolicyDto) { @@ -49,7 +49,7 @@ export class RescheduleController { } @Delete('reschedule/policies/:coachTypeId') - @PassengerAdmin() + @PassengerDelete(PASSENGER_PERMS.reschedulePolicies.delete) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Delete a reschedule policy — rescheduling is then refused for that fare class (admin)' }) deletePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string) { diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts b/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts index 5e3237e93..ef4f4b48d 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts @@ -2,7 +2,7 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseInt import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger'; import { RoutesService } from './routes.service'; import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto'; -import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards'; +import { PassengerDelete, PassengerWrite } from '../../common/passenger-guards'; import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; @ApiTags('Routes') @@ -13,7 +13,7 @@ export class RoutesController { // ── Routes ───────────────────────────────────────────────────────────────── @Post() - @PassengerStaff([PASSENGER_PERMS.routes.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') + @PassengerWrite(PASSENGER_PERMS.routes.create, PASSENGER_PERMS.routes.manage) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Create a reusable route with its ordered stops', description: `Define the physical corridor once (e.g. ADD→ADM→AWS→DDW→AYS→DJI). @@ -41,7 +41,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`, getRoute(@Param('id') id: string) { return this.service.getRoute(id); } @Patch(':id') - @PassengerStaff([PASSENGER_PERMS.routes.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') + @PassengerWrite(PASSENGER_PERMS.routes.edit, PASSENGER_PERMS.routes.manage) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Update route metadata (name, description, active flag, effectiveFrom, effectiveUntil)' }) @ApiParam({ name: 'id', description: 'Route UUID' }) @ApiResponse({ status: 200, description: 'Route updated' }) @@ -49,7 +49,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`, updateRoute(@Param('id') id: string, @Body() dto: UpdateRouteDto) { return this.service.updateRoute(id, dto); } @Delete(':id') - @PassengerAdmin() + @PassengerDelete(PASSENGER_PERMS.routes.delete) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete a route' }) @ApiParam({ name: 'id', description: 'Route UUID' }) @@ -68,7 +68,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`, getStops(@Param('id') id: string) { return this.service.getStops(id); } @Post(':id/stops') - @PassengerStaff([PASSENGER_PERMS.routes.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') + @PassengerWrite(PASSENGER_PERMS.routes.edit, PASSENGER_PERMS.routes.manage) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Add a stop to an existing route' }) @ApiParam({ name: 'id', description: 'Route UUID' }) @ApiResponse({ status: 201, description: 'Stop added' }) @@ -77,7 +77,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`, addStop(@Param('id') id: string, @Body() dto: AddRouteStopDto) { return this.service.addStop(id, dto); } @Delete(':id/stops/:sequence') - @PassengerAdmin() + @PassengerWrite(PASSENGER_PERMS.routes.edit, PASSENGER_PERMS.routes.manage) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Remove a stop from a route by sequence number' }) @ApiParam({ name: 'id', description: 'Route UUID' }) @@ -108,7 +108,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`, getCoachTemplate(@Param('id') id: string) { return this.service.getRouteCoachTemplate(id); } @Put(':id/coaches') - @PassengerStaff([PASSENGER_PERMS.routes.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') + @PassengerWrite(PASSENGER_PERMS.routes.edit, PASSENGER_PERMS.routes.manage) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Set the default coach lineup for this route', description: 'Replaces the entire coach template. Coaches are auto-assigned in this order when a new schedule is created for this route.', @@ -122,7 +122,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`, } @Delete(':id/coaches') - @PassengerAdmin() + @PassengerWrite(PASSENGER_PERMS.routes.edit, PASSENGER_PERMS.routes.manage) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Clear the default coach lineup for this route' }) @ApiParam({ name: 'id', description: 'Route UUID' }) diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts index 3f6228bf3..48df9e548 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts @@ -1,25 +1,39 @@ -import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseIntPipe } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, Req, ParseIntPipe } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger'; import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { SchedulesService } from './schedules.service'; import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto, TripStatus, ApplyDelayDto } from './schedules.dto'; -import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards'; +import { PassengerDelete, PassengerStaff, PassengerWrite } from '../../common/passenger-guards'; +import { assertAnyPassengerPermission } from '../../common/passenger-permission.util'; import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; +const P = PASSENGER_PERMS; +const S = PASSENGER_PERMS.schedules; +const F = PASSENGER_PERMS.scheduleFares; + +/** + * Fare rules live under /schedules but are a separate grant: editing a timetable + * and changing a price are different jobs. `schedules.manage` stays in the array + * so whoever edits fares today is not locked out the day this ships. + */ +const FareWrite = (narrow: string) => + PassengerStaff([narrow, F.manage, S.manage, P.admin]); +const FareDelete = () => PassengerStaff([F.delete, F.manage, S.manage, P.admin]); + @ApiTags('Schedule') @Controller('schedules') export class SchedulesController { constructor(private service: SchedulesService) {} @Post('bulk-generate') - @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') + @PassengerWrite(S.create, S.manage) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Bulk generate repetitive schedules' }) bulkGenerateSchedules(@Body() dto: BulkCreateSchedulesDto) { return this.service.bulkGenerateSchedules(dto); } @Post() - @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') + @PassengerWrite(S.create, S.manage) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Create a train schedule from a route template' }) createSchedule(@Body() dto: CreateScheduleDto) { return this.service.createSchedule(dto); } @@ -42,13 +56,13 @@ export class SchedulesController { // ===== SPECIFIC ROUTES (must come BEFORE generic :id routes) ===== @Post('fares') - @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') + @FareWrite(F.create) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Create a fare rule scoped to a schedule or route code' }) @ApiResponse({ status: 201, description: 'Fare rule created' }) createFareRule(@Body() dto: CreateFareRuleDto) { return this.service.createFareRule(dto); } @Patch('fares/:id') - @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') + @FareWrite(F.edit) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Update a fare rule' }) @ApiParam({ name: 'id', description: 'FareRule UUID' }) @ApiResponse({ status: 200, description: 'Fare rule updated' }) @@ -57,7 +71,7 @@ export class SchedulesController { } @Delete('fares/:id') - @PassengerAdmin() + @FareDelete() @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete a fare rule' }) @ApiParam({ name: 'id', description: 'FareRule UUID' }) @@ -65,13 +79,13 @@ export class SchedulesController { deleteFareRule(@Param('id') id: string) { return this.service.deleteFareRule(id); } @Post('segment-fares') - @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') + @FareWrite(F.create) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Create a segment fare rule' }) createSegmentFareRule(@Body() dto: any) { return this.service.createSegmentFareRule(dto); } // Static sub-path MUST come before routes/:routeId/* to avoid :routeId swallowing 'fare-rules' @Delete('routes/fare-rules/:id') - @PassengerAdmin() + @FareDelete() @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete a route-level fare override' }) @ApiParam({ name: 'id', description: 'RouteFareRule UUID' }) @@ -80,7 +94,7 @@ export class SchedulesController { } @Patch('routes/fare-rules/:id') - @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') + @FareWrite(F.edit) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Update a route-level fare override' }) @ApiParam({ name: 'id', description: 'RouteFareRule UUID' }) updateRouteFareRule(@Param('id') id: string, @Body() dto: any) { @@ -96,7 +110,7 @@ export class SchedulesController { } @Post('routes/:routeId/fare-rules') - @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') + @FareWrite(F.create) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Create a route-level fare override' }) @ApiParam({ name: 'routeId', description: 'Route UUID' }) createRouteFareRule(@Param('routeId') routeId: string, @Body() dto: any) { @@ -110,13 +124,13 @@ export class SchedulesController { getSegmentFares(@Param('routeId') routeId: string) { return this.service.getSegmentFares(routeId); } @Patch('segment-fares/:id') - @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') + @FareWrite(F.edit) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Update a segment fare rule' }) @ApiParam({ name: 'id', description: 'SegmentFareRule UUID' }) updateSegmentFareRule(@Param('id') id: string, @Body() dto: any) { return this.service.updateSegmentFareRule(id, dto); } @Delete('segment-fares/:id') - @PassengerAdmin() + @FareDelete() @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete a segment fare rule' }) @ApiParam({ name: 'id', description: 'SegmentFareRule UUID' }) @@ -131,7 +145,7 @@ export class SchedulesController { getSchedule(@Param('id') id: string) { return this.service.getSchedule(id); } @Patch(':id') - @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') + @PassengerWrite(S.edit, S.manage) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Update a schedule (partial)' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) updateSchedule(@Param('id') id: string, @Body() dto: UpdateScheduleDto) { @@ -139,15 +153,33 @@ export class SchedulesController { } @Patch(':id/status') - @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') - @ApiOperation({ summary: 'Update schedule status' }) + @PassengerWrite(S.edit, S.manage) @ApiBearerAuth('IAM-auth') + @ApiOperation({ + summary: 'Update schedule status', + description: + 'Routine transitions need `schedules:edit`. Moving a schedule to CANCELLED additionally ' + + 'needs `schedules:cancel` — cancelling strands every booked passenger, so it is a separate ' + + 'grant from editing a timetable.', + }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) - updateStatus(@Param('id') id: string, @Body() dto: UpdateScheduleStatusDto) { + @ApiResponse({ status: 403, description: 'Cancelling without `schedules:cancel`' }) + updateStatus( + @Param('id') id: string, + @Body() dto: UpdateScheduleStatusDto, + @Req() req: { user?: unknown }, + ) { + // The guard cannot see the body — CANCELLED arrives on the same route as + // BOARDING or DELAYED — so the narrower check happens here. `schedules.manage` + // is in the list, so a manage holder cancels exactly as they do today; an + // `edit`-only holder can retime a trip but not cancel it. + if (dto.status === TripStatus.CANCELLED) { + assertAnyPassengerPermission(req.user as never, [S.cancel, S.manage, P.admin]); + } return this.service.updateScheduleStatus(id, dto); } @Delete(':id') - @PassengerAdmin() + @PassengerDelete(S.delete) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete a schedule' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) @@ -155,7 +187,7 @@ export class SchedulesController { deleteSchedule(@Param('id') id: string, @Query('cascade') cascade?: string) { return this.service.deleteSchedule(id, cascade === 'true'); } @Post(':id/recalculate-stops') - @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') + @PassengerWrite(S.edit, S.manage) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Recompute TripStopTime records from current route travelMinutesToStop values' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) recalculateStops(@Param('id') id: string) { return this.service.recalculateStopTimes(id); } @@ -167,7 +199,7 @@ export class SchedulesController { getStops(@Param('id') id: string) { return this.service.getStops(id); } @Patch(':id/stops/:sequence') - @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') + @PassengerWrite(S.edit, S.manage) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Update a stop time' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) @ApiParam({ name: 'sequence', description: 'Stop sequence number' }) @@ -178,7 +210,7 @@ export class SchedulesController { ) { return this.service.updateStop(id, sequence, dto); } @Post(':id/delay') - @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') + @PassengerWrite(S.edit, S.manage) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Report a delay — pushes every downstream stop\'s planned times (and check-in cutoffs) back by the same amount', description: `Shifts plannedArrivalAt/plannedDepartureAt on every stop not yet BOARDED/COMPLETED (or from fromSequence @@ -194,7 +226,7 @@ records the accumulated delay on the schedule's live status. Does not change sch } @Put(':scheduleId/fares/:seatClassId') - @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') + @FareWrite(F.edit) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Override fare for a specific seat class on a schedule', description: 'Upserts a schedule-scoped FareRule. Expires any existing active rule for the same schedule+seatClass and creates a new one.', @@ -242,13 +274,13 @@ records the accumulated delay on the schedule's live status. Does not change sch } @Post(':id/fares/sync') - @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') + @FareWrite(F.edit) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Sync fares from fare engine' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) syncFares(@Param('id') id: string) { return this.service.syncFaresFromEngine(id); } @Post(':id/coaches') - @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') + @PassengerWrite(S.edit, S.manage) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Assign coaches to a schedule' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) assignCoaches( @@ -264,7 +296,7 @@ records the accumulated delay on the schedule's live status. Does not change sch getAssignedCoaches(@Param('id') id: string) { return this.service.getAssignedCoaches(id); } @Delete(':id/coaches/:coachId') - @PassengerAdmin() + @PassengerWrite(S.edit, S.manage) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Remove a coach assignment' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) diff --git a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts index 5b75d4b3b..776e01560 100644 --- a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts +++ b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts @@ -3,7 +3,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiResponse, ApiBody } import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { SeatClassesService } from './seat-classes.service'; import { CreateSeatClassDto, UpdateSeatClassDto } from './seat-classes.dto'; -import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards'; +import { PassengerDelete, PassengerWrite } from '../../common/passenger-guards'; import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; @ApiTags('Seat Classes') @@ -26,7 +26,7 @@ export class SeatClassesController { getSeatClass(@Param('id') id: string) { return this.service.getSeatClass(id); } @Post() - @PassengerStaff([PASSENGER_PERMS.tariffRates.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') + @PassengerWrite(PASSENGER_PERMS.tariffRates.create, PASSENGER_PERMS.tariffRates.manage) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Create a seat class' }) @ApiBody({ type: CreateSeatClassDto }) @ApiResponse({ status: 201, description: 'Seat class created' }) @@ -34,7 +34,7 @@ export class SeatClassesController { createSeatClass(@Body() dto: CreateSeatClassDto) { return this.service.createSeatClass(dto); } @Patch(':id') - @PassengerStaff([PASSENGER_PERMS.tariffRates.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') + @PassengerWrite(PASSENGER_PERMS.tariffRates.edit, PASSENGER_PERMS.tariffRates.manage) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Update a seat class' }) @ApiParam({ name: 'id', description: 'Seat class UUID' }) @ApiBody({ type: UpdateSeatClassDto }) @@ -43,7 +43,7 @@ export class SeatClassesController { updateSeatClass(@Param('id') id: string, @Body() dto: UpdateSeatClassDto) { return this.service.updateSeatClass(id, dto); } @Delete(':id') - @PassengerAdmin() + @PassengerDelete(PASSENGER_PERMS.tariffRates.delete) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete a seat class' }) @ApiParam({ name: 'id', description: 'Seat class UUID' }) diff --git a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts index 87bb026d3..519042854 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts @@ -25,7 +25,7 @@ import { AutoAssignHoldDto, BlockSeatDto, HoldSeatsDto, ReleaseHoldDto, SetMaint import { resolveActingUser, RequestWithActingUser } from "../../common/acting-user"; import { GetDuplicateSeatsQuery, ResolveDuplicatesDto } from "./duplicate-seats.dto"; import { JwtGuard } from "../../common/jwt.guard"; -import { PassengerStaff } from "../../common/passenger-guards"; +import { PassengerDelete, PassengerWrite } from "../../common/passenger-guards"; import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; @ApiTags("Seats") @@ -35,7 +35,7 @@ export class SeatsController { // ── Blocked Seats ───────────────────────────────────────────────────────── @Get('blocks') - @PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin]) + @PassengerWrite(PASSENGER_PERMS.seats.block, PASSENGER_PERMS.seats.manage) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'List all blocked seats with reason and coach info' }) @ApiResponse({ status: 200, description: 'Blocked seat records' }) @@ -187,7 +187,7 @@ This makes it clear which segment of the route each seat is held for, enabling s } @Post("auto-assign-hold") - @PassengerStaff([PASSENGER_PERMS.bookings.manage]) + @PassengerWrite(PASSENGER_PERMS.bookings.create, PASSENGER_PERMS.bookings.manage) @ApiBearerAuth("IAM-auth") @ApiOperation({ summary: "Auto-assign and hold N seats of a class — staff bulk/group booking only", @@ -247,7 +247,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av // ── Seat Block / Unblock ─────────────────────────────────────────────────── @Post(":seatId/block") - @PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin]) + @PassengerWrite(PASSENGER_PERMS.seats.block, PASSENGER_PERMS.seats.manage) @ApiBearerAuth("IAM-auth") @ApiOperation({ summary: "Block a seat (e.g., maintenance, damage)", @@ -268,7 +268,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av } @Delete(":seatId/block") - @PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin]) + @PassengerWrite(PASSENGER_PERMS.seats.block, PASSENGER_PERMS.seats.manage) @ApiBearerAuth("IAM-auth") @ApiOperation({ summary: "Unblock a seat" }) @ApiParam({ name: "seatId", description: "Seat UUID" }) @@ -279,7 +279,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av // ── Maintenance ─────────────────────────────────────────────────────────── @Post(":seatId/maintenance") - @PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin]) + @PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage) @ApiBearerAuth("IAM-auth") @ApiOperation({ summary: "Set seat status to Under Maintenance" }) @ApiParam({ name: "seatId", description: "Seat UUID" }) @@ -294,7 +294,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av } @Delete(":seatId/maintenance") - @PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin]) + @PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage) @ApiBearerAuth("IAM-auth") @ApiOperation({ summary: "Clear seat maintenance status" }) @ApiParam({ name: "seatId", description: "Seat UUID" }) @@ -305,7 +305,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av // ── Remove Seat ──────────────────────────────────────────────────────────── @Patch(":seatId/remove") - @PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin]) + @PassengerDelete(PASSENGER_PERMS.seats.delete) @ApiBearerAuth("IAM-auth") @ApiOperation({ summary: "Remove a seat by marking with negative seatNumber", @@ -321,7 +321,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av } @Patch(":seatId/undo-remove") - @PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin]) + @PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage) @ApiBearerAuth("IAM-auth") @ApiOperation({ summary: "Undo seat removal by restoring original seatNumber", @@ -338,7 +338,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av } @Get("export/csv/:scheduleId") - @UseGuards(JwtGuard) + @PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage) @ApiBearerAuth("JWT-auth") @ApiOperation({ summary: "Export seats as CSV" }) async exportCSV(@Param("scheduleId") scheduleId: string) { @@ -347,7 +347,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av } @Post("import/preview") - @UseGuards(JwtGuard) + @PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage) @ApiBearerAuth("JWT-auth") @ApiOperation({ summary: "Preview CSV import" }) previewCSV(@Body() body: { csv: string }) { @@ -355,7 +355,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av } @Post("import/commit") - @UseGuards(JwtGuard) + @PassengerWrite(PASSENGER_PERMS.seats.create, PASSENGER_PERMS.seats.manage) @ApiBearerAuth("JWT-auth") @ApiOperation({ summary: "Commit CSV import" }) importCSV( @@ -367,7 +367,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av // ── Duplicate seat management (backoffice) ──────────────────────────────── @Get("duplicates") - @UseGuards(JwtGuard) + @PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage) @ApiBearerAuth("JWT-auth") @ApiOperation({ summary: "List duplicate seat assignments by schedule date", @@ -418,7 +418,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av } @Post("duplicates/resolve") - @UseGuards(JwtGuard) + @PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage) @ApiBearerAuth("JWT-auth") @ApiOperation({ summary: "Auto-assign duplicate bookings to seats in selected coaches", diff --git a/apps/edr-passenger-api/src/modules/stations/stations.controller.ts b/apps/edr-passenger-api/src/modules/stations/stations.controller.ts index 5ddd64383..036056531 100644 --- a/apps/edr-passenger-api/src/modules/stations/stations.controller.ts +++ b/apps/edr-passenger-api/src/modules/stations/stations.controller.ts @@ -3,7 +3,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiResponse } from '@ne import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { StationsService } from './stations.service'; import { CreateStationDto } from './stations.dto'; -import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards'; +import { PassengerDelete, PassengerWrite } from '../../common/passenger-guards'; import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; @ApiTags('Stations') @@ -79,7 +79,7 @@ export class StationsController { findOne(@Param('id') id: string) { return this.service.findOne(id); } @Post() - @PassengerStaff([PASSENGER_PERMS.stations.manage, PASSENGER_PERMS.admin]) + @PassengerWrite(PASSENGER_PERMS.stations.create, PASSENGER_PERMS.stations.manage) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Create new station' }) @ApiResponse({ @@ -105,7 +105,7 @@ export class StationsController { create(@Body() dto: CreateStationDto) { return this.service.create(dto); } @Patch(':id') - @PassengerStaff([PASSENGER_PERMS.stations.manage, PASSENGER_PERMS.admin]) + @PassengerWrite(PASSENGER_PERMS.stations.edit, PASSENGER_PERMS.stations.manage) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Update station' }) @ApiResponse({ @@ -134,7 +134,7 @@ export class StationsController { } @Delete(':id') - @PassengerAdmin() + @PassengerDelete(PASSENGER_PERMS.stations.delete) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete station' }) @ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' }) diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts index a835a3e67..b6b96b87d 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -1,7 +1,7 @@ import { Body, Controller, Get, Param, Post, Query, Req, UseGuards, Delete, Patch, SetMetadata } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger'; import { TicketsService } from './tickets.service'; -import { PassengerStaff, PassengerAdmin } from '../../common/passenger-guards'; +import { PassengerStaff, PassengerDelete, PassengerWrite } from '../../common/passenger-guards'; import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; import { resolveActingUser } from '../../common/acting-user'; @@ -118,7 +118,7 @@ export class TicketsController { } @Post('scan-board/:qrCodeOrRef') - @PassengerStaff(PASSENGER_PERMS.tickets.manage) + @PassengerWrite(PASSENGER_PERMS.tickets.board, PASSENGER_PERMS.tickets.manage) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Scan QR code or booking ref and automatically board ticket', @@ -146,7 +146,7 @@ export class TicketsController { } @Post(':bookingRef/validate') - @PassengerStaff(PASSENGER_PERMS.tickets.manage) + @PassengerStaff([PASSENGER_PERMS.tickets.board, PASSENGER_PERMS.tickets.edit, PASSENGER_PERMS.tickets.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Validate ticket at gate with audit logging', @@ -194,7 +194,7 @@ export class TicketsController { } @Post('validate/offline') - @PassengerStaff(PASSENGER_PERMS.tickets.manage) + @PassengerStaff([PASSENGER_PERMS.tickets.board, PASSENGER_PERMS.tickets.edit, PASSENGER_PERMS.tickets.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Batch import offline validations', @@ -226,7 +226,7 @@ export class TicketsController { } @Delete(':id') - @PassengerAdmin() + @PassengerDelete(PASSENGER_PERMS.tickets.delete) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete ticket (admin only)', @@ -237,7 +237,7 @@ export class TicketsController { } @Patch(':id/restore') - @PassengerStaff(PASSENGER_PERMS.tickets.manage) + @PassengerWrite(PASSENGER_PERMS.tickets.edit, PASSENGER_PERMS.tickets.manage) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Restore a cancelled ticket by resetting its status to ACTIVE' }) restore(@Param('id') id: string) { diff --git a/apps/edr-passenger-api/src/modules/upgrade/upgrade.controller.ts b/apps/edr-passenger-api/src/modules/upgrade/upgrade.controller.ts index f52abcbc4..738dc3cc9 100644 --- a/apps/edr-passenger-api/src/modules/upgrade/upgrade.controller.ts +++ b/apps/edr-passenger-api/src/modules/upgrade/upgrade.controller.ts @@ -1,7 +1,7 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { JwtGuard } from '../../common/jwt.guard'; -import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards'; +import { PassengerDelete, PassengerStaff, PassengerWrite } from '../../common/passenger-guards'; import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; import { UpgradeService } from './upgrade.service'; import { @@ -18,7 +18,7 @@ export class UpgradeController { constructor(private service: UpgradeService) {} @Get('upgrade/policies') - @PassengerStaff(PASSENGER_PERMS.bookings.view) + @PassengerStaff([PASSENGER_PERMS.upgradePolicies.view, PASSENGER_PERMS.upgradePolicies.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Every upgrade policy, each with its coach type (fare class)' }) listPolicies() { @@ -26,7 +26,7 @@ export class UpgradeController { } @Get('upgrade/policies/available-coach-types') - @PassengerStaff(PASSENGER_PERMS.bookings.view) + @PassengerStaff([PASSENGER_PERMS.upgradePolicies.view, PASSENGER_PERMS.upgradePolicies.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Coach types that do not have an upgrade policy yet (add-dialog dropdown)' }) listUnconfiguredCoachTypes() { @@ -34,7 +34,7 @@ export class UpgradeController { } @Post('upgrade/policies') - @PassengerAdmin() + @PassengerWrite(PASSENGER_PERMS.upgradePolicies.create, PASSENGER_PERMS.upgradePolicies.manage) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Create an upgrade policy for a coach type (admin)' }) createPolicy(@Req() req: any, @Body() dto: CreateUpgradePolicyDto) { @@ -42,7 +42,7 @@ export class UpgradeController { } @Patch('upgrade/policies/:coachTypeId') - @PassengerAdmin() + @PassengerWrite(PASSENGER_PERMS.upgradePolicies.edit, PASSENGER_PERMS.upgradePolicies.manage) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Update the upgrade policy of a coach type (admin)' }) updatePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string, @Body() dto: UpdateUpgradePolicyDto) { @@ -50,7 +50,7 @@ export class UpgradeController { } @Delete('upgrade/policies/:coachTypeId') - @PassengerAdmin() + @PassengerDelete(PASSENGER_PERMS.upgradePolicies.delete) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Delete an upgrade policy — the class can then be neither left nor entered (admin)' }) deletePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string) { diff --git a/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts b/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts index e907ef013..e32f343c7 100644 --- a/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts +++ b/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts @@ -128,6 +128,9 @@ export const PASSENGER_PERMISSIONS: PassengerPermissionSeed[] = [ perm('2ce6d0e3-3426-4424-a239-3ddf95b001c4', 'edr_passenger_app:schedules:cancel', 'Cancel schedules'), perm('045d3f55-d5fc-4ef4-8d4f-949496b77c25', 'edr_passenger_app:seats:block', 'Block and unblock seats'), perm('8ac8f5de-26b0-469b-993e-2abfa005d223', 'edr_passenger_app:packages:publish', 'Activate and deactivate packages'), + perm('4c263527-9868-4ec6-8861-039cd5e37948', 'edr_passenger_app:tickets:board', 'Board passengers'), + perm('635a92ec-def7-4373-a076-5437ccac09b8', 'edr_passenger_app:payments:supplementary', 'Send supplementary payment'), + perm('7f6deaf2-37c9-4420-ab29-9278266e3a5d', 'edr_passenger_app:excess_baggage:charge', 'Send excess luggage payment'), // ── Schedule fares — split out of `schedules:manage` ──────────────────────────── perm('7e7406b3-77dc-422a-b5de-a8286f973b38', 'edr_passenger_app:schedule_fares:view', 'View schedule fares'), @@ -149,6 +152,19 @@ export const PASSENGER_PERMISSIONS: PassengerPermissionSeed[] = [ perm('2a8fb77e-4bf6-42e0-80ca-53ca142b1b23', 'edr_passenger_app:reports_payments:view', 'View payments report'), perm('3476bbe6-47f5-4040-8b3c-88b417b1c0c2', 'edr_passenger_app:reports_catalog:view', 'View generated report catalog'), + + // ── Reschedule & upgrade policies ──────────────────────────────────────────── + perm('9d27002e-7ee1-445a-b732-347f3b18d89a', 'edr_passenger_app:reschedule_policies:view', 'View reschedule policies'), + perm('19b50492-8915-4a05-8c15-61b39fe244d1', 'edr_passenger_app:reschedule_policies:manage', 'Manage reschedule policies'), + perm('9955afb1-61ee-4dd2-a863-b85631de730b', 'edr_passenger_app:reschedule_policies:create', 'Create reschedule policies'), + perm('c7d0e2e2-7894-40ad-95af-a91740658ae4', 'edr_passenger_app:reschedule_policies:edit', 'Edit reschedule policies'), + perm('a45a796e-4fd2-46b0-8d0a-1f2aa227763a', 'edr_passenger_app:reschedule_policies:delete', 'Delete reschedule policies'), + perm('3f088266-af6a-49b8-bfc6-287e8b459022', 'edr_passenger_app:upgrade_policies:view', 'View upgrade policies'), + perm('678363f4-cf2e-4488-80ad-9ed8b7760d55', 'edr_passenger_app:upgrade_policies:manage', 'Manage upgrade policies'), + perm('1383de71-b308-4fc4-9900-6f045d8a74d0', 'edr_passenger_app:upgrade_policies:create', 'Create upgrade policies'), + perm('c2842698-0998-4675-ba28-8cbc4e9ac1a8', 'edr_passenger_app:upgrade_policies:edit', 'Edit upgrade policies'), + perm('f43ef123-65bd-49c3-b5c2-c943f5f4aaf2', 'edr_passenger_app:upgrade_policies:delete', 'Delete upgrade policies'), + perm('49fd28cd-5b58-4403-8e53-1df4b93cbbd2', 'edr_passenger_app:admin', 'Full admin access'), ]; @@ -164,6 +180,25 @@ export const PASSENGER_PERMS = { edit: 'edr_passenger_app:bookings:edit', delete: 'edr_passenger_app:bookings:delete', }, + /** + * Fee/window policy for changing a confirmed booking. Its own resource rather than a + * booking action: an agent who can manage bookings must not be able to rewrite the fee + * schedule those bookings are priced against. + */ + reschedulePolicies: { + view: 'edr_passenger_app:reschedule_policies:view', + manage: 'edr_passenger_app:reschedule_policies:manage', + create: 'edr_passenger_app:reschedule_policies:create', + edit: 'edr_passenger_app:reschedule_policies:edit', + delete: 'edr_passenger_app:reschedule_policies:delete', + }, + upgradePolicies: { + view: 'edr_passenger_app:upgrade_policies:view', + manage: 'edr_passenger_app:upgrade_policies:manage', + create: 'edr_passenger_app:upgrade_policies:create', + edit: 'edr_passenger_app:upgrade_policies:edit', + delete: 'edr_passenger_app:upgrade_policies:delete', + }, passengers: { view: 'edr_passenger_app:passengers:view', manage: 'edr_passenger_app:passengers:manage', @@ -175,6 +210,8 @@ export const PASSENGER_PERMS = { view: 'edr_passenger_app:tickets:view', manage: 'edr_passenger_app:tickets:manage', generate: 'edr_passenger_app:tickets:generate', + /** Marking a passenger boarded — gate scanning separately from editing a ticket. */ + board: 'edr_passenger_app:tickets:board', create: 'edr_passenger_app:tickets:create', edit: 'edr_passenger_app:tickets:edit', delete: 'edr_passenger_app:tickets:delete', @@ -183,6 +220,8 @@ export const PASSENGER_PERMS = { view: 'edr_passenger_app:payments:view', manage: 'edr_passenger_app:payments:manage', create: 'edr_passenger_app:payments:create', + /** Raising and re-sending a supplementary charge — bills a passenger and sends a pay link. */ + supplementary: 'edr_passenger_app:payments:supplementary', edit: 'edr_passenger_app:payments:edit', delete: 'edr_passenger_app:payments:delete', // legacy keys — retained as aliases for backward compatibility @@ -190,6 +229,13 @@ export const PASSENGER_PERMS = { refund: 'edr_passenger_app:payments:refund', manageMethods: 'edr_passenger_app:payments:manage_methods', }, + /** + * Excess luggage. Only the charge action is modelled: logging one bills the passenger + * and sends them a payment link, which is the part worth granting separately. + */ + excessBaggage: { + charge: 'edr_passenger_app:excess_baggage:charge', + }, paymentMethods: { view: 'edr_passenger_app:payment_methods:view', manage: 'edr_passenger_app:payment_methods:manage', diff --git a/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx b/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx index b2967c58d..02c6eb18f 100644 --- a/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx @@ -13,6 +13,9 @@ import Pagination from '@/components/ui/Pagination'; import { usePagination } from '@/lib/use-pagination'; import { formatCurrency, formatDateTime } from '@/lib/utils'; import { useAuthStore } from '@/lib/auth-store'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; +import { useWritePermission, useDeletePermission } from '@/lib/use-permission'; const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
@@ -27,9 +30,13 @@ const SectionHeader = ({ title }: { title: string }) => ( ); -export default function AgentsPage() { +function AgentsPageContent() { const { user } = useAuthStore(); const queryClient = useQueryClient(); + + const canCreate = useWritePermission(PERMS.agents.create, PERMS.agents.manage); + const canEdit = useWritePermission(PERMS.agents.edit, PERMS.agents.manage); + const canDelete = useDeletePermission(PERMS.agents.delete); const [filters, setFilters] = useState({ search: '', active: '' }); const [selected, setSelected] = useState(null); const [createModal, setCreateModal] = useState(false); @@ -131,6 +138,7 @@ export default function AgentsPage() { const actions = [ { label: 'Edit', + show: () => canEdit, onClick: (agent: any) => openEditModal(agent), variant: 'secondary' as const, icon: Edit, @@ -143,6 +151,7 @@ export default function AgentsPage() { }, { label: 'Delete', + show: () => canDelete, onClick: (agent: any) => { setDeleteError(null); setDeleteConfirm({ isOpen: true, agent }); }, variant: 'danger' as const, icon: Trash2, @@ -156,7 +165,7 @@ export default function AgentsPage() {

Agents

Manage agents and their operations

- Add Agent + Add Agent
@@ -394,3 +403,11 @@ export default function AgentsPage() {
); } + +export default function AgentsPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/app-releases/page.tsx b/apps/edr-passenger-web/backoffice/src/app/app-releases/page.tsx index c5d5d7fa9..0974737c1 100644 --- a/apps/edr-passenger-web/backoffice/src/app/app-releases/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/app-releases/page.tsx @@ -10,10 +10,12 @@ import Modal from '@/components/ui/Modal'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { appReleasesApi } from '@/lib/api'; import { formatDateTime } from '@/lib/utils'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; const EMPTY_FORM = { os: 'android', version: '', forceUpdate: false, storeLink: '', notes: '' }; -export default function AppReleasesPage() { +function AppReleasesPageContent() { const queryClient = useQueryClient(); const [formOpen, setFormOpen] = useState(false); const [editing, setEditing] = useState(null); @@ -184,3 +186,11 @@ export default function AppReleasesPage() { ); } + +export default function AppReleasesPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/boarding/page.tsx b/apps/edr-passenger-web/backoffice/src/app/boarding/page.tsx index 75f85cc90..2aeeb421b 100644 --- a/apps/edr-passenger-web/backoffice/src/app/boarding/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/boarding/page.tsx @@ -8,6 +8,9 @@ import { useAuthStore } from '@/lib/auth-store'; import { formatDateTime } from '@/lib/utils'; import { useRouter } from 'next/navigation'; import Header from '@/components/layout/Header'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { useWritePermission } from '@/lib/use-permission'; +import { PERMS } from '@/lib/permissions'; // Add QR Scanner component function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onError: (error: string) => void }) { @@ -315,7 +318,7 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro ); } -export default function BoardingPage() { +function BoardingPageContent() { const [qrInput, setQrInput] = useState(''); const [lastScanned, setLastScanned] = useState(null); const [error, setError] = useState(null); @@ -341,6 +344,8 @@ export default function BoardingPage() { retry: false, }); + const canBoard = useWritePermission(PERMS.tickets.board, PERMS.tickets.manage); + const boardingMutation = useMutation({ mutationFn: (qrCodeOrRef: string) => ticketsApi.scanAndBoard(qrCodeOrRef, { @@ -482,7 +487,8 @@ export default function BoardingPage() {
); -} \ No newline at end of file +} + +export default function BoardingPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx index fffe3722c..188526c27 100644 --- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx @@ -9,7 +9,7 @@ import Pagination from '@/components/ui/Pagination'; import ActionButton from '@/components/ui/ActionButton'; import Modal from '@/components/ui/Modal'; import { PermissionGuard } from '@/components/layout/PermissionGuard'; -import { usePermission } from '@/lib/use-permission'; +import { useDeletePermission, usePermission } from '@/lib/use-permission'; import { PERMS } from '@/lib/permissions'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { bookingsApi, apiClient } from '@/lib/api'; @@ -30,7 +30,7 @@ const SectionHeader = ({ title }: { title: string }) => ( ); function BookingsPageContent() { - const canManage = usePermission(PERMS.bookings.manage); + const canDeleteBooking = useDeletePermission(PERMS.bookings.delete); // Mirrors the API guard on POST /payments/:bookingId/force-confirm — // tickets:generate, with the usual super-admin / org-admin bypass. const canGenerateTicket = usePermission(PERMS.tickets.generate); @@ -302,7 +302,7 @@ function BookingsPageContent() { const actions = [ { label: 'View Details', onClick: (b: any) => setSelectedBooking(b), variant: 'secondary' as const, icon: Eye }, { label: 'Generate Ticket', onClick: (b: any) => { setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); setGenerateTicketTouched({ paymentReference: false, paymentMethod: false }); setGenerateTicketBooking(b); }, variant: 'secondary' as const, icon: Ticket, show: (b: any) => canGenerateTicket && !(b.status === 'CONFIRMED' && b.paymentIntent?.status === 'SUCCEEDED') }, - { label: 'Delete', onClick: (b: any) => { setDeleteError(null); setDeleteCascade(false); setDeleteCascadeChecked(false); setBookingToDelete(b); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2 }, + { label: 'Delete', onClick: (b: any) => { setDeleteError(null); setDeleteCascade(false); setDeleteCascadeChecked(false); setBookingToDelete(b); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2, show: () => canDeleteBooking }, ]; return ( diff --git a/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx index ed6f71acb..73b6f90e9 100644 --- a/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx @@ -12,6 +12,7 @@ import { seatClassesApi, apiClient } from '@/lib/api'; import { formatCurrency } from '@/lib/utils'; import { PermissionGuard } from '@/components/layout/PermissionGuard'; import { PERMS } from '@/lib/permissions'; +import { useWritePermission, useDeletePermission } from '@/lib/use-permission'; function ClassesPageContent() { const [filters, setFilters] = useState({ search: '' }); @@ -21,6 +22,10 @@ function ClassesPageContent() { const [selectedCoachTypeId, setSelectedCoachTypeId] = useState(''); const queryClient = useQueryClient(); + const canCreate = useWritePermission(PERMS.classes.create, PERMS.classes.manage); + const canEdit = useWritePermission(PERMS.classes.edit, PERMS.classes.manage); + const canDelete = useDeletePermission(PERMS.classes.delete); + const { data, isLoading } = useQuery({ queryKey: ['classes', filters], queryFn: () => seatClassesApi.getAll(), @@ -180,12 +185,14 @@ function ClassesPageContent() { const actions = [ { label: 'Edit', + show: () => canEdit, onClick: (cls: any) => handleOpenModal(cls), variant: 'secondary' as const, icon: Edit, }, { label: 'Delete', + show: () => canDelete, onClick: handleDelete, variant: 'danger' as const, icon: Trash2, diff --git a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx index 0b836f141..aee8fe68a 100644 --- a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx @@ -12,6 +12,7 @@ import Pagination from '@/components/ui/Pagination'; import { usePagination } from '@/lib/use-pagination'; import { PermissionGuard } from '@/components/layout/PermissionGuard'; import { PERMS } from '@/lib/permissions'; +import { useWritePermission, useDeletePermission } from '@/lib/use-permission'; type Tab = 'types' | 'coaches'; @@ -155,6 +156,10 @@ function CoachesPageContent() { const queryClient = useQueryClient(); + const canCreate = useWritePermission(PERMS.coaches.create, PERMS.coaches.manage); + const canEdit = useWritePermission(PERMS.coaches.edit, PERMS.coaches.manage); + const canDelete = useDeletePermission(PERMS.coaches.delete); + // Coach Types Queries const { data: coachTypesData, isLoading: typesLoading } = useQuery({ queryKey: ['coach-types'], @@ -438,6 +443,7 @@ function CoachesPageContent() { const coachTypeActions = [ { label: 'Edit', + show: () => canEdit, onClick: (item: any) => { setEditingItem({ ...item, isCoachType: true }); setShowModal(true); @@ -447,6 +453,7 @@ function CoachesPageContent() { }, { label: 'Delete', + show: () => canDelete, onClick: (item: any) => handleDelete(item, true), variant: 'danger' as const, icon: Trash2, @@ -456,6 +463,7 @@ function CoachesPageContent() { const coachActions = [ { label: 'Edit', + show: () => canEdit, onClick: (item: any) => { setEditingItem({ ...item, isCoach: true }); setSelectedCoachTypeId(item.coachTypeId || ''); @@ -467,6 +475,7 @@ function CoachesPageContent() { }, { label: 'Delete', + show: () => canDelete, onClick: (item: any) => handleDelete(item, false), variant: 'danger' as const, icon: Trash2, @@ -489,6 +498,9 @@ function CoachesPageContent() { setSearch(''); setShowModal(true); }} + + disabled={!canCreate} + title={canCreate ? undefined : 'You do not have permission to create coaches'} > {activeTab === 'types' ? 'Add Coach Type' : 'Add Coach'} diff --git a/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx b/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx index 7be021368..56b0f7d9c 100644 --- a/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx @@ -8,6 +8,8 @@ import Modal from '@/components/ui/Modal'; import ActionButton from '@/components/ui/ActionButton'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { apiClient } from '@/lib/api-client'; +import { PERMS } from '@/lib/permissions'; +import { useWritePermission, useDeletePermission } from '@/lib/use-permission'; interface ExchangeRate { id: string; @@ -35,6 +37,10 @@ export default function CurrenciesPage() { const [deleteConfirm, setDeleteConfirm] = useState(null); const queryClient = useQueryClient(); + const canCreate = useWritePermission(PERMS.currencies.create, PERMS.currencies.manage); + const canEdit = useWritePermission(PERMS.currencies.edit, PERMS.currencies.manage); + const canDelete = useDeletePermission(PERMS.currencies.delete); + const { data: rates = [], isLoading } = useQuery({ queryKey: ['currencies'], queryFn: () => apiClient.get('/currencies'), @@ -124,12 +130,14 @@ export default function CurrenciesPage() { const actions = [ { label: 'Edit', + show: () => canEdit, onClick: (r: ExchangeRate) => { setEditingRate(r); setRateInput(String(r.rate)); setError(null); }, variant: 'secondary' as const, icon: Edit, }, { label: 'Delete', + show: () => canDelete, onClick: (r: ExchangeRate) => setDeleteConfirm(r), variant: 'danger' as const, icon: Trash2, @@ -143,7 +151,7 @@ export default function CurrenciesPage() {

Exchange Rates

Manage currency exchange rates

- { setError(null); setShowAddModal(true); }}> + { setError(null); setShowAddModal(true); }} disabled={!canCreate} title={canCreate ? undefined : 'You do not have permission to create currency rates'}> Add Rate diff --git a/apps/edr-passenger-web/backoffice/src/app/discrepancy/page.tsx b/apps/edr-passenger-web/backoffice/src/app/discrepancy/page.tsx index 9545c1d2a..d36decba9 100644 --- a/apps/edr-passenger-web/backoffice/src/app/discrepancy/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/discrepancy/page.tsx @@ -5,6 +5,9 @@ import { useQuery, useMutation } from '@tanstack/react-query'; import { Search, Layers, ChevronDown, ChevronUp, CheckSquare, Square, AlertCircle, CheckCircle2, X, Loader2 } from 'lucide-react'; import { seatsApi } from '@/lib/api'; import { formatDateTime } from '@/lib/utils'; +import { useWritePermission } from '@/lib/use-permission'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; // ── Types ───────────────────────────────────────────────────────────────── @@ -300,9 +303,10 @@ interface CoachCardProps { coach: CoachReport; schedule: ScheduleReport; onResolve: () => void; + canResolve?: boolean; } -function CoachCard({ coach, schedule, onResolve }: CoachCardProps) { +function CoachCard({ coach, schedule, onResolve, canResolve = false }: CoachCardProps) { const [expanded, setExpanded] = useState(false); const hasDuplicates = coach.duplicates.length > 0; @@ -330,7 +334,9 @@ function CoachCard({ coach, schedule, onResolve }: CoachCardProps) { {hasDuplicates && ( @@ -379,7 +385,9 @@ function CoachCard({ coach, schedule, onResolve }: CoachCardProps) { // ── Main page ───────────────────────────────────────────────────────────── -export default function DiscrepancyPage() { +function DiscrepancyPageContent() { + // POST /seats/duplicates/resolve is guarded by seats:edit (it deletes seat rows). + const canResolveDuplicates = useWritePermission(PERMS.seats.edit, PERMS.seats.manage); const [date, setDate] = useState(today()); const [searchDate, setSearchDate] = useState(''); const [resolveTarget, setResolveTarget] = useState<{ schedule: ScheduleReport; coach: CoachReport } | null>(null); @@ -488,6 +496,7 @@ export default function DiscrepancyPage() { coach={coach} schedule={schedule} onResolve={() => setResolveTarget({ schedule, coach })} + canResolve={canResolveDuplicates} /> ))} @@ -517,3 +526,11 @@ export default function DiscrepancyPage() { ); } + +export default function DiscrepancyPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx b/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx index 6e3c98340..5386f8f4c 100644 --- a/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx @@ -10,6 +10,9 @@ import Modal from '@/components/ui/Modal'; import { excessBaggageApi, apiClient, bookingsApi } from '@/lib/api'; import { formatDateTime, formatCurrency } from '@/lib/utils'; import { useAuthStore } from '@/lib/auth-store'; +import { useWritePermission } from '@/lib/use-permission'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; const STATUS_VARIANT: Record = { PENDING: 'PENDING', @@ -19,8 +22,11 @@ const STATUS_VARIANT: Record = { WAIVED: 'CANCELLED', }; -export default function ExcessBaggagePage() { +function ExcessBaggagePageContent() { const queryClient = useQueryClient(); + + // Logging or resending a luggage charge bills the passenger and sends a pay link. + const canCharge = useWritePermission(PERMS.excessBaggage.charge, PERMS.bookings.manage); const [filters, setFilters] = useState({ status: '', bookingRef: '', dateFrom: '', dateTo: '', page: '1' }); const [showExtraFilters, setShowExtraFilters] = useState(false); const user = useAuthStore((s) => s.user); @@ -173,7 +179,7 @@ export default function ExcessBaggagePage() { icon: Send, variant: 'secondary' as const, onClick: (c: any) => { setResendModal(c); setResendSuccess(false); setResendError(null); }, - show: (c: any) => c.status === 'PENDING', + show: (c: any) => canCharge && c.status === 'PENDING', }, { label: 'Waive', @@ -202,7 +208,12 @@ export default function ExcessBaggagePage() {

Excess Lugagge

Track and manage excess luggage charges at boarding

- { setLogModal(true); setLogError(null); setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false, paymentPhone: '' }); }}> + { setLogModal(true); setLogError(null); setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false, paymentPhone: '' }); }} + > Log Excess Luggage @@ -421,3 +432,11 @@ export default function ExcessBaggagePage() { ); } + +export default function ExcessBaggagePage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/fraud/page.tsx b/apps/edr-passenger-web/backoffice/src/app/fraud/page.tsx index cc8ac9e7b..3d04f9a6f 100644 --- a/apps/edr-passenger-web/backoffice/src/app/fraud/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/fraud/page.tsx @@ -9,6 +9,9 @@ import ActionButton from '@/components/ui/ActionButton'; import Modal from '@/components/ui/Modal'; import { fraudApi } from '@/lib/api'; import { formatDateTime } from '@/lib/utils'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; +import { useWritePermission } from '@/lib/use-permission'; const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
@@ -30,11 +33,13 @@ const SEVERITY_GRAD: Record = { LOW: 'from-blue-500 to-blue-600', }; -export default function FraudDetectionPage() { +function FraudDetectionPageContent() { const [filters, setFilters] = useState({ search: '', severity: '', status: '' }); const [selected, setSelected] = useState(null); const queryClient = useQueryClient(); + const canManageFraud = useWritePermission(PERMS.fraud.edit, PERMS.fraud.manage); + const { data, isLoading } = useQuery({ queryKey: ['fraud-alerts', filters], queryFn: () => fraudApi.getAlerts(filters), @@ -130,10 +135,11 @@ export default function FraudDetectionPage() { onClick: handleAcknowledge, variant: 'primary' as const, icon: CheckCircle, - show: (alert: any) => !alert.acknowledged, + show: (alert: any) => canManageFraud && !alert.acknowledged, }, { label: 'Block User', + show: () => canManageFraud, onClick: handleBlockUser, variant: 'danger' as const, icon: Ban, @@ -301,3 +307,11 @@ export default function FraudDetectionPage() {
); } + +export default function FraudDetectionPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/notifications/page.tsx b/apps/edr-passenger-web/backoffice/src/app/notifications/page.tsx index d277eb3de..3f19a52fc 100644 --- a/apps/edr-passenger-web/backoffice/src/app/notifications/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/notifications/page.tsx @@ -9,10 +9,12 @@ import Modal from '@/components/ui/Modal'; import ActionButton from '@/components/ui/ActionButton'; import { notificationsApi } from '@/lib/api'; import { formatDateTime } from '@/lib/utils'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; const CHANNEL_OPTIONS = ['EMAIL', 'SMS', 'PUSH', 'IN_APP']; -export default function NotificationsPage() { +function NotificationsPageContent() { const [showModal, setShowModal] = useState(false); const [editing, setEditing] = useState(null); const [templateError, setTemplateError] = useState(null); @@ -313,3 +315,11 @@ export default function NotificationsPage() { ); } + +export default function NotificationsPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/operational-reports/page.tsx b/apps/edr-passenger-web/backoffice/src/app/operational-reports/page.tsx index f7a0c1887..3be651b39 100644 --- a/apps/edr-passenger-web/backoffice/src/app/operational-reports/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/operational-reports/page.tsx @@ -9,8 +9,10 @@ import ActionButton from '@/components/ui/ActionButton'; import Modal from '@/components/ui/Modal'; import { reportsApi } from '@/lib/api'; import { formatDateTime, formatCurrency } from '@/lib/utils'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; -export default function OperationalReportsPage() { +function OperationalReportsPageContent() { const [filters, setFilters] = useState({ search: '', reportType: '' }); const [selectedReport, setSelectedReport] = useState(null); const [showDetailsModal, setShowDetailsModal] = useState(false); @@ -465,3 +467,11 @@ export default function OperationalReportsPage() { ); } + +export default function OperationalReportsPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/package-bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/package-bookings/page.tsx index 183611374..4925ebaa4 100644 --- a/apps/edr-passenger-web/backoffice/src/app/package-bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/package-bookings/page.tsx @@ -11,6 +11,8 @@ import Pagination from '@/components/ui/Pagination'; import { packagesApi } from '@/lib/api'; import { formatDateTime, formatCurrency } from '@/lib/utils'; import { getErrorMessage } from '@/lib/api-client'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
@@ -25,7 +27,7 @@ const SectionHeader = ({ title }: { title: string }) => ( ); -export default function PackageBookingsPage() { +function PackageBookingsPageContent() { const [filters, setFilters] = useState({ packageId: '', status: '', page: 1, pageSize: 20 }); const [selected, setSelected] = useState(null); @@ -264,3 +266,11 @@ export default function PackageBookingsPage() {
); } + +export default function PackageBookingsPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/package-inquiries/page.tsx b/apps/edr-passenger-web/backoffice/src/app/package-inquiries/page.tsx index 73f0ad658..39855185d 100644 --- a/apps/edr-passenger-web/backoffice/src/app/package-inquiries/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/package-inquiries/page.tsx @@ -9,6 +9,9 @@ import ActionButton from '@/components/ui/ActionButton'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { packageInquiriesApi, packagesApi } from '@/lib/api'; import { formatDateTime, formatCurrency } from '@/lib/utils'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; +import { useWritePermission, useDeletePermission } from '@/lib/use-permission'; const STATUSES = ['NEW', 'CONTACTED', 'CONVERTED', 'CLOSED']; @@ -19,12 +22,15 @@ const statusVariant: Record = { CLOSED: 'default', }; -export default function PackageInquiriesPage() { +function PackageInquiriesPageContent() { const [filters, setFilters] = useState({ packageId: '', status: '' }); const [deleteConfirm, setDeleteConfirm] = useState(null); const [deleteError, setDeleteError] = useState(null); const queryClient = useQueryClient(); + const canEdit = useWritePermission(PERMS.inquiries.edit, PERMS.inquiries.manage); + const canDelete = useDeletePermission(PERMS.inquiries.delete); + const { data, isLoading } = useQuery({ queryKey: ['package-inquiries', filters], queryFn: () => packageInquiriesApi.getAll({ ...filters, pageSize: 50 }), @@ -112,6 +118,8 @@ export default function PackageInquiriesPage() { - setForm({ isOpen: true, rule: null, error: null })} disabled={!selectedRouteId}> + setForm({ isOpen: true, rule: null, error: null })} + disabled={!selectedRouteId || !canCreateFare} + title={canCreateFare ? undefined : 'You do not have permission to create fare overrides'} + > Add Segment Override @@ -176,8 +189,8 @@ export default function SegmentOverridesTab({ routes }: Props) { data={segmentFares} columns={columns} actions={[ - { label: 'Edit', icon: Edit, variant: 'secondary' as const, onClick: (r: SegmentFareRule) => setForm({ isOpen: true, rule: r, error: null }) }, - { label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: (r: SegmentFareRule) => setDeleteConfirm({ isOpen: true, id: r.id, name: `${stopLabel(r.originStopSequence)} → ${stopLabel(r.destinationStopSequence)}` }) }, + { label: 'Edit', icon: Edit, variant: 'secondary' as const, onClick: (r: SegmentFareRule) => setForm({ isOpen: true, rule: r, error: null }), show: () => canEditFare }, + { label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: (r: SegmentFareRule) => setDeleteConfirm({ isOpen: true, id: r.id, name: `${stopLabel(r.originStopSequence)} → ${stopLabel(r.destinationStopSequence)}` }), show: () => canDeleteFare }, ]} loading={isLoading} emptyMessage="No segment overrides for this route." diff --git a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/TariffTab.tsx b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/TariffTab.tsx index 63c9b5d38..d0b16c2d9 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/TariffTab.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/TariffTab.tsx @@ -14,12 +14,14 @@ interface Props { isLoading: boolean; onEdit: (cls: SeatClass) => void; onDelete: (id: string, cascade: boolean) => void; + canEdit?: boolean; + canDelete?: boolean; isDeleting: boolean; deleteError?: string; deleteSuccess?: number; } -export default function TariffTab({ classes, coachTypes, isLoading, onEdit, onDelete, isDeleting, deleteError, deleteSuccess }: Props) { +export default function TariffTab({ classes, coachTypes, isLoading, onEdit, onDelete, isDeleting, deleteError, deleteSuccess, canEdit = false, canDelete = false }: Props) { const [search, setSearch] = useState(''); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; @@ -141,8 +143,8 @@ export default function TariffTab({ classes, coachTypes, isLoading, onEdit, onDe data={displayed} columns={columns} actions={[ - { label: 'Edit', icon: Edit, variant: 'secondary' as const, onClick: onEdit }, - { label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: handleDeleteClick }, + { label: 'Edit', icon: Edit, variant: 'secondary' as const, onClick: onEdit, show: () => canEdit }, + { label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: handleDeleteClick, show: () => canDelete }, ]} loading={isLoading} emptyMessage={search ? 'No tariff rates match your search' : 'No tariff rates found'} diff --git a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx index 7239809f0..baf43e098 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx @@ -10,8 +10,11 @@ import BaggageTab from './BaggageTab'; import RateModal from './RateModal'; import { useSeatClasses, useCoachTypes, useRoutes, useSeatClassMutations, useRouteFareRuleMutations } from './hooks'; import type { SeatClass, TabType } from './types'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; +import { useWritePermission, useDeletePermission } from '@/lib/use-permission'; -export default function TariffRatesPage() { +function TariffRatesPageContent() { const [tab, setTab] = useState('tariff'); const [showRateModal, setShowRateModal] = useState(false); const [editingClass, setEditingClass] = useState(null); @@ -20,6 +23,10 @@ export default function TariffRatesPage() { const [deleteError, setDeleteError] = useState(undefined); const [deleteSuccess, setDeleteSuccess] = useState(0); + const canCreateRate = useWritePermission(PERMS.tariffRates.create, PERMS.tariffRates.manage); + const canEditRate = useWritePermission(PERMS.tariffRates.edit, PERMS.tariffRates.manage); + const canDeleteRate = useDeletePermission(PERMS.tariffRates.delete); + const { allClasses, isLoading } = useSeatClasses(); const { coachTypes } = useCoachTypes(); const { routes } = useRoutes(); @@ -71,7 +78,11 @@ export default function TariffRatesPage() {

{tab !== 'overrides' && tab !== 'segment-overrides' && ( - { + { if (tab === 'baggage') { setShowBaggageModal(true); } else { @@ -105,6 +116,8 @@ export default function TariffRatesPage() { isLoading={isLoading} onEdit={cls => { setEditingClass(cls); setPreselectedRouteId(null); setShowRateModal(true); }} onDelete={handleDelete} + canEdit={canEditRate} + canDelete={canDeleteRate} isDeleting={seatClassMutations.remove.isPending} deleteError={deleteError} deleteSuccess={deleteSuccess} @@ -143,3 +156,11 @@ export default function TariffRatesPage() { ); } + +export default function TariffRatesPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx index 74f1ec9b8..ed2ad79cb 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx @@ -14,8 +14,11 @@ import { ticketsApi, apiClient, stationsApi, excessBaggageApi, bookingsApi } fro import Pagination from '@/components/ui/Pagination'; import { formatDateTime, formatCurrency, formatDateTimeShort } from '@/lib/utils'; import { useAuthStore } from '@/lib/auth-store'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; +import { usePermission, useWritePermission, useDeletePermission } from '@/lib/use-permission'; -export default function TicketsPage() { +function TicketsPageContent() { const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', departureDate: '', arrivalDate: '', dateFrom: '', dateTo: '', coachId: '' }); const [ticketPage, setTicketPage] = useState(1); const resetTicketPage = () => setTicketPage(1); @@ -69,6 +72,13 @@ export default function TicketsPage() { }); const queryClient = useQueryClient(); + const canGenerate = usePermission(PERMS.tickets.generate); + const canEditTicket = useWritePermission(PERMS.tickets.edit, PERMS.tickets.manage); + const canDelete = useDeletePermission(PERMS.tickets.delete); + // Logging luggage bills the passenger and sends a pay link, so it is its own grant. + const canLogBaggage = useWritePermission(PERMS.excessBaggage.charge, PERMS.bookings.manage); + const canBoard = useWritePermission(PERMS.tickets.board, PERMS.tickets.manage); + const { data, isLoading, error } = useQuery({ queryKey: ['tickets', filters, ticketPage], queryFn: () => ticketsApi.getAll({ @@ -554,7 +564,7 @@ export default function TicketsPage() { onClick: openExcessModal, variant: 'secondary' as const, icon: Package, - show: (ticket: any) => !!ticket.booking && ['CONFIRMED', 'BOARDED'].includes(ticket.booking?.status ?? ticket.status), + show: (ticket: any) => canLogBaggage && !!ticket.booking && ['CONFIRMED', 'BOARDED'].includes(ticket.booking?.status ?? ticket.status), }, { label: 'Board', @@ -562,6 +572,7 @@ export default function TicketsPage() { variant: 'primary' as const, icon: LogIn, show: (ticket: any) => { + if (!canBoard) return false; const isRoundTrip = ticket.booking?.bookingType === 'ROUND_TRIP' || ticket.booking?.bookingType === 'ROUND_TRIP_TRANSIT'; if (isRoundTrip) { const inboundBoarded = !!ticket.booking?.returnBoardedAt; @@ -595,10 +606,11 @@ export default function TicketsPage() { onClick: (ticket: any) => restoreMutation.mutate(ticket.id), variant: 'secondary' as const, icon: ListCollapse, - show: (ticket: any) => ticket.status === 'CANCELLED', + show: (ticket: any) => canEditTicket && ticket.status === 'CANCELLED', }, { label: 'Delete', + show: () => canDelete, onClick: handleDeleteClick, variant: 'danger' as const, icon: Trash2, @@ -621,6 +633,8 @@ export default function TicketsPage() { variant="secondary" loading={generateMissingMutation.isPending} onClick={() => generateMissingMutation.mutate()} + disabled={!canGenerate} + title={canGenerate ? undefined : 'You do not have permission to generate tickets'} > Generate Missing @@ -760,7 +774,13 @@ export default function TicketsPage() {
{ setBoardConfirmOpen(false); setTicketToBoard(null); }}>Cancel - Board and Print + Board and Print
@@ -1103,3 +1123,11 @@ export default function TicketsPage() { ); } + +export default function TicketsPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx b/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx index e1be87ff3..7d228e270 100644 --- a/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx @@ -15,6 +15,7 @@ import { Train as TrainType } from '@/types'; import { formatDate } from '@/lib/utils'; import { PermissionGuard } from '@/components/layout/PermissionGuard'; import { PERMS } from '@/lib/permissions'; +import { useWritePermission, useDeletePermission } from '@/lib/use-permission'; function TrainsPageContent() { const [showModal, setShowModal] = useState(false); @@ -24,6 +25,10 @@ function TrainsPageContent() { const queryClient = useQueryClient(); + const canCreate = useWritePermission(PERMS.trains.create, PERMS.trains.manage); + const canEdit = useWritePermission(PERMS.trains.edit, PERMS.trains.manage); + const canDelete = useDeletePermission(PERMS.trains.delete); + const { data: trainsData, isLoading: trainsLoading } = useQuery({ queryKey: ['trains'], queryFn: () => fleetApi.getTrains(), @@ -174,6 +179,7 @@ function TrainsPageContent() { const actions = [ { label: 'Edit', + show: () => canEdit, onClick: (train: TrainType) => { setEditingTrain(train); setShowModal(true); @@ -186,10 +192,11 @@ function TrainsPageContent() { onClick: (train: TrainType) => restoreTrainMutation.mutate(train.id), variant: 'secondary' as const, icon: RotateCcw, - show: (train: TrainType) => !train.isActive, + show: (train: TrainType) => canEdit && !train.isActive, }, { label: 'Delete', + show: () => canDelete, onClick: handleDelete, variant: 'danger' as const, icon: Trash2, @@ -209,6 +216,8 @@ function TrainsPageContent() { setShowModal(true); }} icon={Plus} + disabled={!canCreate} + title={canCreate ? undefined : 'You do not have permission to create trains'} > Add Train
diff --git a/apps/edr-passenger-web/backoffice/src/app/upgrade-policies/page.tsx b/apps/edr-passenger-web/backoffice/src/app/upgrade-policies/page.tsx index 5be9108b9..fefdc9105 100644 --- a/apps/edr-passenger-web/backoffice/src/app/upgrade-policies/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/upgrade-policies/page.tsx @@ -6,12 +6,12 @@ import { PERMS } from '@/lib/permissions'; /** * Master Data → Upgrade Policies. One policy per fare class (coach type); a class with no policy - * can be neither upgraded from nor to. Gated on bookings:view because that is what - * `GET /upgrade/policies` requires; creating, editing and deleting are admin-only server-side. + * can be neither upgraded from nor to. Gated on upgrade_policies:view (or :manage) — bookings:view no longer + * grants it, so the page needs its own grant. Create/edit/delete each have their own key. */ export default function UpgradePoliciesPage() { return ( - +

Upgrade Policies

diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/PermissionGuard.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/PermissionGuard.tsx index 2da55e37f..0a1668bf8 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/PermissionGuard.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/PermissionGuard.tsx @@ -2,17 +2,28 @@ import { useEffect } from 'react'; import { useRouter } from 'next/navigation'; +import { ShieldOff } from 'lucide-react'; import { useAuthStore } from '@/lib/auth-store'; interface Props { - permission?: string; + /** + * A single key, or several of which the user needs **any one**. The any-of form + * is how a report page accepts either its own key or the `reports:view` + * umbrella — mirroring the OR semantics of the API's `PassengerPermissionGuard`. + */ + permission?: string | string[]; children: React.ReactNode; } /** * Wraps a page to enforce auth + optional permission check. * - Not logged in → redirect to /login - * - Missing permission → redirect to /dashboard + * - Missing permission → render an explanation (see below) + * + * This used to redirect a user without the permission to /dashboard. That is a + * dead end for anyone lacking `dashboard:view`, since that page renders nothing + * either — they got a blank screen with no explanation. Saying what happened is + * both kinder and easier to support. */ export function PermissionGuard({ permission, children }: Props) { const router = useRouter(); @@ -20,17 +31,26 @@ export function PermissionGuard({ permission, children }: Props) { const hasPermission = useAuthStore((s) => s.hasPermission); useEffect(() => { - if (!isAuthenticated) { - router.replace('/login'); - return; - } - if (permission && !hasPermission(permission)) { - router.replace('/dashboard'); - } - }, [isAuthenticated, permission, hasPermission, router]); + if (!isAuthenticated) router.replace('/login'); + }, [isAuthenticated, router]); if (!isAuthenticated) return null; - if (permission && !hasPermission(permission)) return null; + + const keys = permission === undefined ? [] : Array.isArray(permission) ? permission : [permission]; + const allowed = keys.length === 0 || keys.some((key) => hasPermission(key)); + + if (!allowed) { + return ( +
+ +

You don't have access to this page

+

+ Your account is missing the permission this page requires. Ask an administrator to grant + it if you need access. +

+
+ ); + } return <>{children}; } diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx index 58f20b17c..26715f077 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -52,7 +52,8 @@ interface NavItem { name: string; href: string; icon: React.ComponentType<{ className?: string }>; - permission?: string; + /** A single key, or several of which the user needs any one. */ + permission?: string | string[]; } const navigationSections: { title: string; items: NavItem[] }[] = [ @@ -92,8 +93,8 @@ const navigationSections: { title: string; items: NavItem[] }[] = [ { name: 'Classes', href: '/classes', icon: Settings, permission: PERMS.classes.view }, { name: 'Routes', href: '/routes', icon: Route, permission: PERMS.routes.view }, { name: 'Schedules', href: '/schedules', icon: Calendar, permission: PERMS.schedules.view }, - { name: 'Reschedule Policies', href: '/reschedule-policies', icon: CalendarClock, permission: PERMS.bookings.view }, - { name: 'Upgrade Policies', href: '/upgrade-policies', icon: ArrowUpNarrowWide, permission: PERMS.bookings.view }, + { name: 'Reschedule Policies', href: '/reschedule-policies', icon: CalendarClock, permission: [PERMS.reschedulePolicies.view, PERMS.reschedulePolicies.manage] }, + { name: 'Upgrade Policies', href: '/upgrade-policies', icon: ArrowUpNarrowWide, permission: [PERMS.upgradePolicies.view, PERMS.upgradePolicies.manage] }, ] }, { @@ -128,16 +129,16 @@ const navigationSections: { title: string; items: NavItem[] }[] = [ { title: 'Analytics & Reports', items: [ - { name: 'Overall', href: '/reports/overall', icon: BarChart3, permission: PERMS.reports.view }, - { name: 'Finance', href: '/reports/finance', icon: DollarSign, permission: PERMS.reports.view }, - { name: 'Coaches', href: '/reports/coach-utilization', icon: Grid3x3, permission: PERMS.reports.view }, - { name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view }, - { name: 'Blocked Seats', href: '/reports/blocked-seats', icon: Ban, permission: PERMS.reports.view }, - { name: 'Passengers', href: '/reports/passengers', icon: Users, permission: PERMS.reports.view }, - { name: 'Boarding', href: '/reports/boarding', icon: LogIn, permission: PERMS.reports.view }, - { name: 'Payments', href: '/reports/payments', icon: CreditCard, permission: PERMS.reports.view }, - // { name: 'Payment Discrepancy', href: '/reports/payment-discrepancy', icon: AlertTriangle, permission: PERMS.reports.view }, - // { name: 'Operational Reports', href: '/operational-reports', icon: FileText, permission: PERMS.reports.view }, + { name: 'Overall', href: '/reports/overall', icon: BarChart3, permission: [PERMS.reports.overall.view, PERMS.reports.view] }, + { name: 'Finance', href: '/reports/finance', icon: DollarSign, permission: [PERMS.reports.finance.view, PERMS.reports.view] }, + { name: 'Coaches', href: '/reports/coach-utilization', icon: Grid3x3, permission: [PERMS.reports.coachUtilization.view, PERMS.reports.view] }, + { name: 'Seats', href: '/reports/seats', icon: Armchair, permission: [PERMS.reports.seatStatus.view, PERMS.reports.view] }, + { name: 'Blocked Seats', href: '/reports/blocked-seats', icon: Ban, permission: [PERMS.reports.blockedSeats.view, PERMS.reports.view] }, + { name: 'Passengers', href: '/reports/passengers', icon: Users, permission: [PERMS.reports.passengers.view, PERMS.reports.view] }, + { name: 'Boarding', href: '/reports/boarding', icon: LogIn, permission: [PERMS.reports.boarding.view, PERMS.reports.view] }, + { name: 'Payments', href: '/reports/payments', icon: CreditCard, permission: [PERMS.reports.payments.view, PERMS.reports.view] }, + // { name: 'Payment Discrepancy', href: '/reports/payment-discrepancy', icon: AlertTriangle, permission: [PERMS.reports.payments.view, PERMS.reports.view] }, + // { name: 'Operational Reports', href: '/operational-reports', icon: FileText, permission: [PERMS.reports.catalog.view, PERMS.reports.view] }, ] }, { @@ -217,9 +218,11 @@ export default function Sidebar() { {/* Navigation */}
diff --git a/apps/edr-passenger-web/backoffice/src/components/ui/ActionButton.tsx b/apps/edr-passenger-web/backoffice/src/components/ui/ActionButton.tsx index 1b70303ec..e0f4acfc6 100644 --- a/apps/edr-passenger-web/backoffice/src/components/ui/ActionButton.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/ui/ActionButton.tsx @@ -14,6 +14,12 @@ interface ActionButtonProps { loading?: boolean; className?: string; type?: 'button' | 'submit' | 'reset'; + /** + * Native tooltip. CLAUDE.md asks for a disabled control with a visible reason + * over a silently hidden one, so permission-gated buttons pass the reason here + * alongside `disabled`. + */ + title?: string; } const variants = { @@ -40,6 +46,7 @@ export default function ActionButton({ loading = false, className, type = 'button', + title, }: ActionButtonProps) { const [isLoading, setIsLoading] = useState(false); @@ -63,6 +70,7 @@ export default function ActionButton({ type={type} onClick={handleClick} disabled={isDisabled} + title={title} className={cn( 'inline-flex items-center justify-center gap-2 rounded-lg font-medium transition-all duration-200', 'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[rgb(20,113,76)]', diff --git a/apps/edr-passenger-web/backoffice/src/components/upgrade/UpgradePolicyManager.tsx b/apps/edr-passenger-web/backoffice/src/components/upgrade/UpgradePolicyManager.tsx index 49e66f607..c6f30d82e 100644 --- a/apps/edr-passenger-web/backoffice/src/components/upgrade/UpgradePolicyManager.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/upgrade/UpgradePolicyManager.tsx @@ -6,6 +6,8 @@ import DataTable from '@/components/ui/DataTable'; import ActionButton from '@/components/ui/ActionButton'; import Modal from '@/components/ui/Modal'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; +import { useWritePermission, useDeletePermission } from '@/lib/use-permission'; +import { PERMS } from '@/lib/permissions'; import { upgradePolicyApi, type UpgradePolicyCoachType, @@ -38,6 +40,10 @@ const feeLabel = (p: UpgradePolicyRow) => * a dialog, the same shape as Reschedule Policies and Coach Management. */ export default function UpgradePolicyManager() { + const canCreate = useWritePermission(PERMS.upgradePolicies.create, PERMS.upgradePolicies.manage); + const canEdit = useWritePermission(PERMS.upgradePolicies.edit, PERMS.upgradePolicies.manage); + const canDelete = useDeletePermission(PERMS.upgradePolicies.delete); + const [rows, setRows] = useState([]); const [available, setAvailable] = useState([]); const [loading, setLoading] = useState(true); @@ -186,9 +192,10 @@ export default function UpgradePolicyManager() { ]; const actions = [ - { label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit }, + { label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit, show: () => canEdit }, { label: 'Delete', + show: () => canDelete, onClick: (row: UpgradePolicyRow) => setDeleting(row), variant: 'danger' as const, icon: Trash2, @@ -204,7 +211,12 @@ export default function UpgradePolicyManager() { to and charged per upgraded passenger. A fare class with no policy here can be neither upgraded from nor to.

- + Add Upgrade Policy
@@ -340,7 +352,13 @@ export default function UpgradePolicyManager() { setShowModal(false)}> Cancel - + {editing ? 'Update Policy' : 'Create Policy'} diff --git a/apps/edr-passenger-web/backoffice/src/lib/api-client.ts b/apps/edr-passenger-web/backoffice/src/lib/api-client.ts index 54d339583..29cb9efe8 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api-client.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api-client.ts @@ -4,6 +4,8 @@ const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'; const GENERIC_ERROR_MESSAGE = 'Something went wrong. Please try again.'; const NETWORK_ERROR_MESSAGE = 'Could not reach the server. Please check your connection and try again.'; +const FORBIDDEN_MESSAGE = + 'You do not have permission to do that. Ask an administrator if you need access.'; /** * Extracts a user-facing message from a failed request. Prefers a real backend-provided message @@ -55,6 +57,21 @@ class ApiClient { } } + // A 403 from this API is always a permission check, and the server's own text + // names raw permission keys ("Missing permission. Required one of: …") which + // means nothing to a user. Replace it with something actionable, but only when + // the server did not send a more specific message of its own. + if (error.response?.status === 403) { + const body = error.response.data; + const serverMessage = typeof body?.message === 'string' ? body.message : ''; + if (!serverMessage || serverMessage.startsWith('Missing permission')) { + const friendly = FORBIDDEN_MESSAGE; + if (body && typeof body === 'object') body.message = friendly; + error.message = friendly; + return Promise.reject(error); + } + } + // Normalize in place so every existing `err?.response?.data?.message || err?.message || // ''` call site across the app picks up a friendly message automatically, // instead of raw axios/network text or an unjoined NestJS validation array. diff --git a/apps/edr-passenger-web/backoffice/src/lib/permissions.ts b/apps/edr-passenger-web/backoffice/src/lib/permissions.ts index 6c8e03dc1..4983d1dff 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/permissions.ts @@ -9,6 +9,20 @@ export const PERMS = { edit: 'edr_passenger_app:bookings:edit', delete: 'edr_passenger_app:bookings:delete', }, + reschedulePolicies: { + view: 'edr_passenger_app:reschedule_policies:view', + manage: 'edr_passenger_app:reschedule_policies:manage', + create: 'edr_passenger_app:reschedule_policies:create', + edit: 'edr_passenger_app:reschedule_policies:edit', + delete: 'edr_passenger_app:reschedule_policies:delete', + }, + upgradePolicies: { + view: 'edr_passenger_app:upgrade_policies:view', + manage: 'edr_passenger_app:upgrade_policies:manage', + create: 'edr_passenger_app:upgrade_policies:create', + edit: 'edr_passenger_app:upgrade_policies:edit', + delete: 'edr_passenger_app:upgrade_policies:delete', + }, passengers: { view: 'edr_passenger_app:passengers:view', manage: 'edr_passenger_app:passengers:manage', @@ -20,6 +34,7 @@ export const PERMS = { view: 'edr_passenger_app:tickets:view', manage: 'edr_passenger_app:tickets:manage', generate: 'edr_passenger_app:tickets:generate', + board: 'edr_passenger_app:tickets:board', create: 'edr_passenger_app:tickets:create', edit: 'edr_passenger_app:tickets:edit', delete: 'edr_passenger_app:tickets:delete', @@ -114,6 +129,7 @@ export const PERMS = { view: 'edr_passenger_app:payments:view', manage: 'edr_passenger_app:payments:manage', create: 'edr_passenger_app:payments:create', + supplementary: 'edr_passenger_app:payments:supplementary', edit: 'edr_passenger_app:payments:edit', delete: 'edr_passenger_app:payments:delete', // legacy aliases — still honoured by the backend guards @@ -121,6 +137,9 @@ export const PERMS = { refund: 'edr_passenger_app:payments:refund', manageMethods: 'edr_passenger_app:payments:manage_methods', }, + excessBaggage: { + charge: 'edr_passenger_app:excess_baggage:charge', + }, paymentMethods: { view: 'edr_passenger_app:payment_methods:view', manage: 'edr_passenger_app:payment_methods:manage', diff --git a/apps/edr-passenger-web/backoffice/src/lib/use-permission.ts b/apps/edr-passenger-web/backoffice/src/lib/use-permission.ts index d064c78f2..6342d27a4 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/use-permission.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/use-permission.ts @@ -1,6 +1,7 @@ 'use client'; import { useAuthStore } from './auth-store'; +import { PERMS } from './permissions'; /** * Returns whether the current user has a given permission key. @@ -14,6 +15,20 @@ export function usePermission(key: string): boolean { return useAuthStore((s) => s.hasPermission(key)); } +/** + * True when the user holds **any one** of the keys — the same OR semantics as the + * API's `PassengerPermissionGuard`. + * + * Use it wherever a route accepts a narrow key or a broader one, so the UI agrees + * with the API instead of hiding a control the server would have allowed: + * + * const canCreate = useAnyPermission([PERMS.schedules.create, PERMS.schedules.manage]); + * const canSeeFinance = useAnyPermission([PERMS.reports.finance.view, PERMS.reports.view]); + */ +export function useAnyPermission(keys: string[]): boolean { + return useAuthStore((s) => keys.some((key) => s.hasPermission(key))); +} + /** * Same as usePermission but WITHOUT the super-admin / org-admin bypass — the * permission must be explicitly granted. Use it wherever the API endpoint is @@ -25,3 +40,23 @@ export function usePermission(key: string): boolean { export function usePermissionStrict(key: string): boolean { return useAuthStore((s) => s.hasPermissionStrict(key)); } + +/** + * The UI mirror of the API's `@PassengerWrite(narrow, umbrella)`: the narrow key, + * the resource's `:manage` umbrella, or `admin`. Use it for create / edit / domain + * actions so a control is shown exactly when the server would accept the call. + * + * const canCreate = useWritePermission(PERMS.schedules.create, PERMS.schedules.manage); + */ +export function useWritePermission(narrow: string, umbrella: string): boolean { + return useAnyPermission([narrow, umbrella, PERMS.admin]); +} + +/** + * The UI mirror of the API's `@PassengerDelete(narrow)`: the narrow `:delete` key + * or `admin`. **`:manage` deliberately does not count** — holding `schedules:manage` + * does not let you delete a schedule, so the button must not appear either. + */ +export function useDeletePermission(narrow: string): boolean { + return useAnyPermission([narrow, PERMS.admin]); +}