feat: ( permissions ) enforce specific permission keys across API and backoffice

This commit is contained in:
Abubeker Yasin
2026-09-04 15:44:28 +03:00
parent 7c7f5a7a70
commit 7a61ebcb27
69 changed files with 1061 additions and 251 deletions

View File

@@ -26,3 +26,30 @@ export const PassengerStaffStrict = (permission: string | string[]) =>
); );
export const PassengerAdmin = () => PassengerStaff(PASSENGER_PERMS.admin); 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 `<res>: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]);

View File

@@ -145,3 +145,31 @@ export function assertPassengerPermission(
if (hasPassengerPermission(user, permissionKey)) return; if (hasPassengerPermission(user, permissionKey)) return;
throw new ForbiddenException(`Missing permission: ${permissionKey}`); 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(', ')}`,
);
}

View File

@@ -3,7 +3,8 @@ import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { AgentsService } from './agents.service'; import { AgentsService } from './agents.service';
import { CreateAgentDto, CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto'; import { CreateAgentDto, CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto';
import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; 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') @ApiTags('Agents')
@Controller('agents') @Controller('agents')
@@ -26,18 +27,20 @@ export class AgentsController {
@Post() @Post()
@ApiOperation({ summary: 'Create agent profile linked to an IAM user' }) @ApiOperation({ summary: 'Create agent profile linked to an IAM user' })
@PassengerWrite(PASSENGER_PERMS.agents.create, PASSENGER_PERMS.agents.manage)
createAgent(@Body() dto: CreateAgentDto) { createAgent(@Body() dto: CreateAgentDto) {
return this.service.createAgent(dto); return this.service.createAgent(dto);
} }
@Patch(':id') @Patch(':id')
@ApiOperation({ summary: 'Update agent profile' }) @ApiOperation({ summary: 'Update agent profile' })
@PassengerWrite(PASSENGER_PERMS.agents.edit, PASSENGER_PERMS.agents.manage)
updateAgent(@Param('id') id: string, @Body() dto: Partial<CreateAgentDto> & { active?: boolean }) { updateAgent(@Param('id') id: string, @Body() dto: Partial<CreateAgentDto> & { active?: boolean }) {
return this.service.updateAgent(id, dto); return this.service.updateAgent(id, dto);
} }
@Delete(':id') @Delete(':id')
@PassengerAdmin() @PassengerDelete(PASSENGER_PERMS.agents.delete)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete agent profile' }) @ApiOperation({ summary: 'Delete agent profile' })
deleteAgent(@Param('id') id: string) { deleteAgent(@Param('id') id: string) {

View File

@@ -34,7 +34,7 @@ import {
IssueReservationBookingDto, IssueReservationBookingDto,
} from "./guest-booking.dto"; } from "./guest-booking.dto";
import { JwtGuard } from "../../common/jwt.guard"; 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 { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
import { SeatsService } from "../seats/seats.service"; import { SeatsService } from "../seats/seats.service";
@@ -372,7 +372,7 @@ export class BookingsController {
} }
@Post("group") @Post("group")
@PassengerStaff([PASSENGER_PERMS.bookings.manage]) @PassengerWrite(PASSENGER_PERMS.bookings.create, PASSENGER_PERMS.bookings.manage)
@ApiBearerAuth("IAM-auth") @ApiBearerAuth("IAM-auth")
@ApiOperation({ @ApiOperation({
summary: "Create a group booking — staff bulk/group reservation, one PNR for the whole group", 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") @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") @ApiBearerAuth("IAM-auth")
@ApiOperation({ @ApiOperation({
summary: "Cancel a seat's pending-payment reservation and release the seat", 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") @Delete(":id")
@PassengerAdmin() @PassengerDelete(PASSENGER_PERMS.bookings.delete)
@ApiBearerAuth("IAM-auth") @ApiBearerAuth("IAM-auth")
@ApiOperation({ @ApiOperation({
description: "Permanently deletes a booking record", description: "Permanently deletes a booking record",

View File

@@ -3,7 +3,8 @@ import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { IsEnum, IsNumber, IsOptional, IsString, Min } from 'class-validator'; import { IsEnum, IsNumber, IsOptional, IsString, Min } from 'class-validator';
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';
import { CurrencyService } from './currency.service'; 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 { class CreateRateDto {
@IsEnum(Currency) fromCurrency: Currency; @IsEnum(Currency) fromCurrency: Currency;
@@ -30,7 +31,7 @@ export class CurrencyController {
} }
@Post() @Post()
@PassengerAdmin() @PassengerWrite(PASSENGER_PERMS.currencies.create, PASSENGER_PERMS.currencies.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create exchange rate' }) @ApiOperation({ summary: 'Create exchange rate' })
create(@Body() dto: CreateRateDto) { create(@Body() dto: CreateRateDto) {
@@ -38,7 +39,7 @@ export class CurrencyController {
} }
@Patch(':id') @Patch(':id')
@PassengerAdmin() @PassengerWrite(PASSENGER_PERMS.currencies.edit, PASSENGER_PERMS.currencies.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update exchange rate by ID' }) @ApiOperation({ summary: 'Update exchange rate by ID' })
update(@Param('id') id: string, @Body() dto: UpdateRateDto) { update(@Param('id') id: string, @Body() dto: UpdateRateDto) {
@@ -46,7 +47,7 @@ export class CurrencyController {
} }
@Delete(':id') @Delete(':id')
@PassengerAdmin() @PassengerDelete(PASSENGER_PERMS.currencies.delete)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete exchange rate by ID' }) @ApiOperation({ summary: 'Delete exchange rate by ID' })
delete(@Param('id') id: string) { delete(@Param('id') id: string) {

View File

@@ -9,7 +9,20 @@ import {
ConfirmExcessOtpDto, ConfirmExcessOtpDto,
} from './excess-baggage.dto'; } from './excess-baggage.dto';
import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; 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 { class UpsertBaggageAllowanceDto {
@IsString() seatClassId: string; @IsString() seatClassId: string;
@@ -27,6 +40,7 @@ export class ExcessBaggageAgentController {
constructor(private service: ExcessBaggageService) {} constructor(private service: ExcessBaggageService) {}
@Post() @Post()
@CanCharge()
@ApiOperation({ summary: 'Log excess baggage charge and optionally collect cash' }) @ApiOperation({ summary: 'Log excess baggage charge and optionally collect cash' })
logCharge(@Request() req: any, @Body() dto: LogExcessBaggageDto) { logCharge(@Request() req: any, @Body() dto: LogExcessBaggageDto) {
dto.agentId = req.user?.id ?? req.user?.sub ?? ''; dto.agentId = req.user?.id ?? req.user?.sub ?? '';
@@ -86,6 +100,7 @@ export class ExcessBaggageAgentController {
} }
@Post(':id/resend') @Post(':id/resend')
@CanCharge()
@ApiOperation({ summary: 'Resend payment link (extends expiry by 30 min)' }) @ApiOperation({ summary: 'Resend payment link (extends expiry by 30 min)' })
resendLink(@Param('id') id: string) { resendLink(@Param('id') id: string) {
return this.service.resendLink(id); return this.service.resendLink(id);

View File

@@ -3,7 +3,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiBody, ApiR
import { FleetService } from './fleet.service'; import { FleetService } from './fleet.service';
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto, GenerateSeatMapDto } from './fleet.dto'; import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto, GenerateSeatMapDto } from './fleet.dto';
import { JwtGuard } from '../../common/jwt.guard'; 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 { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('Fleet') @ApiTags('Fleet')
@@ -22,7 +22,7 @@ export class FleetController {
} }
@Post('coach-types') @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' }) @ApiOperation({ summary: 'Create a coach type' })
@ApiBody({ type: CreateCoachTypeDto }) @ApiBody({ type: CreateCoachTypeDto })
@ApiResponse({ status: 201, description: 'Coach type created' }) @ApiResponse({ status: 201, description: 'Coach type created' })
@@ -31,7 +31,7 @@ export class FleetController {
} }
@Patch('coach-types/:id') @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' }) @ApiOperation({ summary: 'Update a coach type' })
@ApiParam({ name: 'id', description: 'Coach Type UUID' }) @ApiParam({ name: 'id', description: 'Coach Type UUID' })
@ApiBody({ type: UpdateCoachTypeDto }) @ApiBody({ type: UpdateCoachTypeDto })
@@ -42,7 +42,7 @@ export class FleetController {
} }
@Delete('coach-types/:id') @Delete('coach-types/:id')
@PassengerAdmin() @PassengerDelete(PASSENGER_PERMS.coaches.delete)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a coach type' }) @ApiOperation({ summary: 'Delete a coach type' })
@ApiParam({ name: 'id', description: 'Coach Type UUID' }) @ApiParam({ name: 'id', description: 'Coach Type UUID' })
@@ -62,7 +62,7 @@ export class FleetController {
} }
@Post('classes') @Post('classes')
@PassengerStaff([PASSENGER_PERMS.classes.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.classes.create, PASSENGER_PERMS.classes.manage)
@ApiOperation({ summary: 'Create a class' }) @ApiOperation({ summary: 'Create a class' })
@ApiBody({ type: CreateClassDto }) @ApiBody({ type: CreateClassDto })
@ApiResponse({ status: 201, description: 'Class created' }) @ApiResponse({ status: 201, description: 'Class created' })
@@ -71,7 +71,7 @@ export class FleetController {
} }
@Patch('classes/:id') @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' }) @ApiOperation({ summary: 'Update a class' })
@ApiParam({ name: 'id', description: 'Class UUID' }) @ApiParam({ name: 'id', description: 'Class UUID' })
@ApiBody({ type: UpdateClassDto }) @ApiBody({ type: UpdateClassDto })
@@ -82,7 +82,7 @@ export class FleetController {
} }
@Delete('classes/:id') @Delete('classes/:id')
@PassengerAdmin() @PassengerDelete(PASSENGER_PERMS.classes.delete)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a class' }) @ApiOperation({ summary: 'Delete a class' })
@ApiParam({ name: 'id', description: 'Class UUID' }) @ApiParam({ name: 'id', description: 'Class UUID' })
@@ -103,7 +103,7 @@ export class FleetController {
} }
@Post('seat-classes') @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)' }) @ApiOperation({ summary: 'Create a class (DEPRECATED - use /fleet/classes)' })
@ApiBody({ type: CreateClassDto }) @ApiBody({ type: CreateClassDto })
@ApiResponse({ status: 201, description: 'Class created' }) @ApiResponse({ status: 201, description: 'Class created' })
@@ -112,7 +112,7 @@ export class FleetController {
} }
@Patch('seat-classes/:id') @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)' }) @ApiOperation({ summary: 'Update a class (DEPRECATED - use /fleet/classes)' })
@ApiParam({ name: 'id', description: 'Class UUID' }) @ApiParam({ name: 'id', description: 'Class UUID' })
@ApiBody({ type: UpdateClassDto }) @ApiBody({ type: UpdateClassDto })
@@ -123,7 +123,7 @@ export class FleetController {
} }
@Delete('seat-classes/:id') @Delete('seat-classes/:id')
@PassengerAdmin() @PassengerDelete(PASSENGER_PERMS.classes.delete)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a class (DEPRECATED - use /fleet/classes)' }) @ApiOperation({ summary: 'Delete a class (DEPRECATED - use /fleet/classes)' })
@ApiParam({ name: 'id', description: 'Class UUID' }) @ApiParam({ name: 'id', description: 'Class UUID' })
@@ -143,7 +143,7 @@ export class FleetController {
} }
@Post('trains') @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' }) @ApiOperation({ summary: 'Create a train service' })
@ApiBody({ type: CreateTrainDto }) @ApiBody({ type: CreateTrainDto })
@ApiResponse({ status: 201, description: 'Train created' }) @ApiResponse({ status: 201, description: 'Train created' })
@@ -152,7 +152,7 @@ export class FleetController {
} }
@Patch('trains/:id') @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' }) @ApiOperation({ summary: 'Update a train service' })
@ApiParam({ name: 'id', description: 'Train UUID' }) @ApiParam({ name: 'id', description: 'Train UUID' })
@ApiBody({ type: CreateTrainDto }) @ApiBody({ type: CreateTrainDto })
@@ -163,7 +163,7 @@ export class FleetController {
} }
@Delete('trains/:id') @Delete('trains/:id')
@PassengerAdmin() @PassengerDelete(PASSENGER_PERMS.trains.delete)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a train service' }) @ApiOperation({ summary: 'Delete a train service' })
@ApiParam({ name: 'id', description: 'Train UUID' }) @ApiParam({ name: 'id', description: 'Train UUID' })
@@ -175,7 +175,7 @@ export class FleetController {
} }
@Patch('trains/:id/restore') @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' }) @ApiOperation({ summary: 'Restore (reactivate) a deactivated train' })
@ApiParam({ name: 'id', description: 'Train UUID' }) @ApiParam({ name: 'id', description: 'Train UUID' })
@ApiResponse({ status: 200, description: 'Train restored' }) @ApiResponse({ status: 200, description: 'Train restored' })
@@ -229,6 +229,7 @@ export class FleetController {
} }
@Get('coaches/utilization') @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' }) @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.' }) @ApiQuery({ name: 'scheduleId', required: false, description: 'Optional schedule UUID to scope the utilization report to that schedule.' })
@ApiResponse({ status: 200, description: 'Coach utilization data' }) @ApiResponse({ status: 200, description: 'Coach utilization data' })
@@ -279,7 +280,7 @@ export class FleetController {
} }
@Post('coaches') @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' }) @ApiOperation({ summary: 'Create a coach with auto-generated seat numbers' })
@ApiBody({ type: CreateCoachDto }) @ApiBody({ type: CreateCoachDto })
@ApiResponse({ @ApiResponse({
@@ -305,7 +306,7 @@ export class FleetController {
} }
@Patch('coaches/:id') @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' }) @ApiOperation({ summary: 'Update coach properties' })
@ApiParam({ name: 'id', description: 'Coach UUID' }) @ApiParam({ name: 'id', description: 'Coach UUID' })
@ApiBody({ type: UpdateCoachDto }) @ApiBody({ type: UpdateCoachDto })
@@ -332,7 +333,7 @@ export class FleetController {
} }
@Delete('coaches/:id') @Delete('coaches/:id')
@PassengerAdmin() @PassengerDelete(PASSENGER_PERMS.coaches.delete)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a coach' }) @ApiOperation({ summary: 'Delete a coach' })
@ApiParam({ name: 'id', description: 'Coach UUID' }) @ApiParam({ name: 'id', description: 'Coach UUID' })
@@ -344,7 +345,7 @@ export class FleetController {
} }
@Post('assignments') @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' }) @ApiOperation({ summary: 'Assign a coach to a schedule' })
@ApiBody({ type: AssignCoachDto }) @ApiBody({ type: AssignCoachDto })
@ApiResponse({ status: 201, description: 'Coach assigned' }) @ApiResponse({ status: 201, description: 'Coach assigned' })
@@ -354,7 +355,7 @@ export class FleetController {
} }
@Delete('assignments/:id') @Delete('assignments/:id')
@PassengerAdmin() @PassengerWrite(PASSENGER_PERMS.coaches.edit, PASSENGER_PERMS.coaches.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Remove a coach assignment' }) @ApiOperation({ summary: 'Remove a coach assignment' })
@ApiParam({ name: 'id', description: 'Assignment UUID' }) @ApiParam({ name: 'id', description: 'Assignment UUID' })
@@ -365,6 +366,7 @@ export class FleetController {
} }
@Post('seatmap/generate') @Post('seatmap/generate')
@PassengerWrite(PASSENGER_PERMS.coaches.edit, PASSENGER_PERMS.coaches.manage)
@ApiOperation({ @ApiOperation({
summary: 'Preview bed seat map — ECONOMY_BED or VIP_BED', summary: 'Preview bed seat map — ECONOMY_BED or VIP_BED',
description: `Generates a structured seat map for bed coaches without persisting anything. description: `Generates a structured seat map for bed coaches without persisting anything.

View File

@@ -1,7 +1,7 @@
import { Controller, Get, Post, Patch, Param, Body, Query, Logger } from '@nestjs/common'; import { Controller, Get, Post, Patch, Param, Body, Query, Logger } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { FraudService, FraudRuleConfig } from './fraud.service'; 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'; import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('Fraud Detection') @ApiTags('Fraud Detection')
@@ -41,7 +41,7 @@ export class FraudController {
* Create or update fraud rule * Create or update fraud rule
*/ */
@Post('rules') @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' }) @ApiOperation({ summary: 'Create or update fraud rule' })
async upsertRule(@Body() body: { type: string; config: FraudRuleConfig }) { async upsertRule(@Body() body: { type: string; config: FraudRuleConfig }) {
const rule = await this.fraudService.upsertRule(body.type, body.config); const rule = await this.fraudService.upsertRule(body.type, body.config);
@@ -52,7 +52,7 @@ export class FraudController {
* Acknowledge a fraud alert * Acknowledge a fraud alert
*/ */
@Patch('alerts/:id/acknowledge') @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' }) @ApiOperation({ summary: 'Acknowledge a fraud alert' })
async acknowledgeAlert(@Param('id') id: string) { async acknowledgeAlert(@Param('id') id: string) {
const alert = await this.fraudService.acknowledgeAlert(id); const alert = await this.fraudService.acknowledgeAlert(id);
@@ -63,7 +63,7 @@ export class FraudController {
* Block user via userId * Block user via userId
*/ */
@Post('users/:userId/block') @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' }) @ApiOperation({ summary: 'Block user by userId' })
async blockUserById( async blockUserById(
@Param('userId') userId: string, @Param('userId') userId: string,
@@ -77,7 +77,7 @@ export class FraudController {
* Block user temporarily * Block user temporarily
*/ */
@Post('actions/block') @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' }) @ApiOperation({ summary: 'Block user temporarily' })
async blockUser(@Body() body: { iamUserId: string; durationMinutes: number }) { async blockUser(@Body() body: { iamUserId: string; durationMinutes: number }) {
await this.fraudService.blockUserTemporarily(body.iamUserId, body.durationMinutes); await this.fraudService.blockUserTemporarily(body.iamUserId, body.durationMinutes);
@@ -88,7 +88,7 @@ export class FraudController {
* Unblock user * Unblock user
*/ */
@Post('actions/unblock') @Post('actions/unblock')
@PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.fraud.edit, PASSENGER_PERMS.fraud.manage)
@ApiOperation({ summary: 'Unblock user' }) @ApiOperation({ summary: 'Unblock user' })
async unblockUser(@Body() body: { iamUserId: string }) { async unblockUser(@Body() body: { iamUserId: string }) {
await this.fraudService.unblockUser(body.iamUserId); await this.fraudService.unblockUser(body.iamUserId);

View File

@@ -97,6 +97,7 @@ export class NotificationsController {
} }
@Post('test') @Post('test')
@PassengerStaff([PASSENGER_PERMS.notifications.send, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Test notification delivery (Admin only)' }) @ApiOperation({ summary: 'Test notification delivery (Admin only)' })
async testNotification(@Body() dto: TestNotificationDto) { async testNotification(@Body() dto: TestNotificationDto) {
return this.service.send( return this.service.send(

View File

@@ -7,7 +7,7 @@ import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDt
import { PACKAGE_IMAGE_FIELD, packageImageMulterOptions } from './package-image-upload.options'; import { PACKAGE_IMAGE_FIELD, packageImageMulterOptions } from './package-image-upload.options';
import { JwtGuard } from '../../common/jwt.guard'; import { JwtGuard } from '../../common/jwt.guard';
import { OptionalJwtGuard } from '../verifayda/optional-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'; import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('Packages') @ApiTags('Packages')
@@ -36,7 +36,7 @@ export class PackagesController {
} }
@Patch('inquiries/:id/status') @Patch('inquiries/:id/status')
@PassengerStaff([PASSENGER_PERMS.inquiries.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.inquiries.edit, PASSENGER_PERMS.inquiries.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update inquiry status (backoffice)' }) @ApiOperation({ summary: 'Update inquiry status (backoffice)' })
updateInquiryStatus(@Param('id') id: string, @Body() dto: UpdateInquiryStatusDto) { updateInquiryStatus(@Param('id') id: string, @Body() dto: UpdateInquiryStatusDto) {
@@ -44,7 +44,7 @@ export class PackagesController {
} }
@Delete('inquiries/:id') @Delete('inquiries/:id')
@PassengerAdmin() @PassengerDelete(PASSENGER_PERMS.inquiries.delete)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete inquiry (backoffice)' }) @ApiOperation({ summary: 'Delete inquiry (backoffice)' })
deleteInquiry(@Param('id') id: string) { deleteInquiry(@Param('id') id: string) {
@@ -126,7 +126,7 @@ export class PackagesController {
} }
@Post() @Post()
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.packages.create, PASSENGER_PERMS.packages.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create package (admin)' }) @ApiOperation({ summary: 'Create package (admin)' })
create(@Body() dto: CreatePackageDto) { create(@Body() dto: CreatePackageDto) {
@@ -134,7 +134,7 @@ export class PackagesController {
} }
@Patch(':id') @Patch(':id')
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.packages.edit, PASSENGER_PERMS.packages.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update package (admin)' }) @ApiOperation({ summary: 'Update package (admin)' })
update(@Param('id') id: string, @Body() dto: Partial<CreatePackageDto>) { update(@Param('id') id: string, @Body() dto: Partial<CreatePackageDto>) {
@@ -142,7 +142,7 @@ export class PackagesController {
} }
@Delete(':id') @Delete(':id')
@PassengerAdmin() @PassengerDelete(PASSENGER_PERMS.packages.delete)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete package (admin)' }) @ApiOperation({ summary: 'Delete package (admin)' })
@ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete even with active bookings' }) @ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete even with active bookings' })
@@ -151,7 +151,7 @@ export class PackagesController {
} }
@Post(':id/image') @Post(':id/image')
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.packages.edit, PASSENGER_PERMS.packages.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@UseInterceptors(FileInterceptor(PACKAGE_IMAGE_FIELD, packageImageMulterOptions)) @UseInterceptors(FileInterceptor(PACKAGE_IMAGE_FIELD, packageImageMulterOptions))
@ApiConsumes('multipart/form-data') @ApiConsumes('multipart/form-data')
@@ -166,7 +166,7 @@ export class PackagesController {
} }
@Delete(':id/image') @Delete(':id/image')
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.packages.edit, PASSENGER_PERMS.packages.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Remove a package image without deleting the package (admin)' }) @ApiOperation({ summary: 'Remove a package image without deleting the package (admin)' })
removeImage(@Param('id') id: string) { removeImage(@Param('id') id: string) {
@@ -174,7 +174,7 @@ export class PackagesController {
} }
@Patch(':id/activate') @Patch(':id/activate')
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.packages.publish, PASSENGER_PERMS.packages.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Activate package (admin)' }) @ApiOperation({ summary: 'Activate package (admin)' })
activate(@Param('id') id: string) { activate(@Param('id') id: string) {
@@ -182,7 +182,7 @@ export class PackagesController {
} }
@Patch(':id/deactivate') @Patch(':id/deactivate')
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.packages.publish, PASSENGER_PERMS.packages.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Deactivate package (admin)' }) @ApiOperation({ summary: 'Deactivate package (admin)' })
deactivate(@Param('id') id: string) { deactivate(@Param('id') id: string) {
@@ -190,7 +190,7 @@ export class PackagesController {
} }
@Post(':id/tiers') @Post(':id/tiers')
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.packages.edit, PASSENGER_PERMS.packages.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Add price tier to package (admin)' }) @ApiOperation({ summary: 'Add price tier to package (admin)' })
addTier(@Param('id') id: string, @Body() dto: CreatePriceTierDto) { addTier(@Param('id') id: string, @Body() dto: CreatePriceTierDto) {
@@ -198,7 +198,7 @@ export class PackagesController {
} }
@Patch('tiers/:tierId') @Patch('tiers/:tierId')
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.packages.edit, PASSENGER_PERMS.packages.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update price tier (admin)' }) @ApiOperation({ summary: 'Update price tier (admin)' })
updateTier(@Param('tierId') tierId: string, @Body() dto: UpdatePriceTierDto) { updateTier(@Param('tierId') tierId: string, @Body() dto: UpdatePriceTierDto) {
@@ -206,7 +206,7 @@ export class PackagesController {
} }
@Delete('tiers/:tierId') @Delete('tiers/:tierId')
@PassengerAdmin() @PassengerWrite(PASSENGER_PERMS.packages.edit, PASSENGER_PERMS.packages.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete price tier (admin)' }) @ApiOperation({ summary: 'Delete price tier (admin)' })
deleteTier(@Param('tierId') tierId: string) { deleteTier(@Param('tierId') tierId: string) {

View File

@@ -28,7 +28,8 @@ import {
RegisterPassengerDto, RegisterPassengerDto,
} from "./passengers.dto"; } from "./passengers.dto";
import { JwtGuard } from "../../common/jwt.guard"; 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 { VerifaydaService } from "../verifayda/verifayda.service";
import { OptionalJwtGuard } from "../verifayda/optional-jwt.guard"; import { OptionalJwtGuard } from "../verifayda/optional-jwt.guard";
import { PrismaService } from "../../common/prisma.service"; import { PrismaService } from "../../common/prisma.service";
@@ -566,7 +567,7 @@ Returns saved passenger details with generated IDs and confirmation.`,
} }
@Delete(":id") @Delete(":id")
@PassengerAdmin() @PassengerDelete(PASSENGER_PERMS.passengers.delete)
@ApiBearerAuth("IAM-auth") @ApiBearerAuth("IAM-auth")
@ApiOperation({ @ApiOperation({
summary: "Delete passenger (admin only)", summary: "Delete passenger (admin only)",

View File

@@ -37,7 +37,7 @@ import {
ForceConfirmDto, ForceConfirmDto,
ConfirmOtpDto, ConfirmOtpDto,
} from "./payments.dto"; } 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 { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
import { resolveActingUser } from "../../common/acting-user"; import { resolveActingUser } from "../../common/acting-user";
import { resolveAllowedOrigin } from "../../common/utils/redirect-origin.util"; import { resolveAllowedOrigin } from "../../common/utils/redirect-origin.util";
@@ -85,7 +85,7 @@ export class PaymentsController {
) {} ) {}
@Delete(":id") @Delete(":id")
@PassengerStaff([PASSENGER_PERMS.admin]) @PassengerDelete(PASSENGER_PERMS.payments.delete)
@ApiBearerAuth("IAM-auth") @ApiBearerAuth("IAM-auth")
@ApiOperation({ summary: "Delete a payment intent record (admin only)" }) @ApiOperation({ summary: "Delete a payment intent record (admin only)" })
deletePayment(@Param("id") id: string) { deletePayment(@Param("id") id: string) {
@@ -252,6 +252,7 @@ export class PaymentsController {
@Post("methods") @Post("methods")
@PassengerStaff([ @PassengerStaff([
PASSENGER_PERMS.paymentMethods.create,
PASSENGER_PERMS.paymentMethods.manage, PASSENGER_PERMS.paymentMethods.manage,
PASSENGER_PERMS.payments.manageMethods, PASSENGER_PERMS.payments.manageMethods,
PASSENGER_PERMS.admin, PASSENGER_PERMS.admin,
@@ -266,6 +267,7 @@ export class PaymentsController {
@Patch("methods/:id") @Patch("methods/:id")
@PassengerStaff([ @PassengerStaff([
PASSENGER_PERMS.paymentMethods.edit,
PASSENGER_PERMS.paymentMethods.manage, PASSENGER_PERMS.paymentMethods.manage,
PASSENGER_PERMS.payments.manageMethods, PASSENGER_PERMS.payments.manageMethods,
PASSENGER_PERMS.admin, PASSENGER_PERMS.admin,
@@ -394,7 +396,7 @@ export class PaymentsController {
// ── Supplementary Charges ────────────────────────────────────────────────── // ── Supplementary Charges ──────────────────────────────────────────────────
@Post('supplementary') @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') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Raise a supplementary charge for an underpayment (staff only)' }) @ApiOperation({ summary: 'Raise a supplementary charge for an underpayment (staff only)' })
createSupplementaryCharge(@Body() dto: CreateSupplementaryChargeDto, @Req() req: any) { createSupplementaryCharge(@Body() dto: CreateSupplementaryChargeDto, @Req() req: any) {
@@ -504,7 +506,7 @@ export class PaymentsController {
} }
@Post('supplementary/:id/mark-paid') @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') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Manually mark a supplementary charge as paid (staff only)' }) @ApiOperation({ summary: 'Manually mark a supplementary charge as paid (staff only)' })
markSupplementaryPaid( markSupplementaryPaid(
@@ -515,7 +517,7 @@ export class PaymentsController {
} }
@Post('supplementary/:id/waive') @Post('supplementary/:id/waive')
@PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.payments.edit, PASSENGER_PERMS.payments.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Waive a supplementary charge (staff only)' }) @ApiOperation({ summary: 'Waive a supplementary charge (staff only)' })
waiveSupplementaryCharge( waiveSupplementaryCharge(
@@ -528,7 +530,7 @@ export class PaymentsController {
} }
@Post('supplementary/:id/resend') @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') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Resend payment link for a supplementary charge (staff only)' }) @ApiOperation({ summary: 'Resend payment link for a supplementary charge (staff only)' })
resendSupplementaryLink(@Param('id') id: string) { resendSupplementaryLink(@Param('id') id: string) {

View File

@@ -12,26 +12,60 @@ import { BlockedSeatsRevenueLossQueryDto, FinanceSummaryQueryDto, GenerateReport
import { PassengerStaff } from "../../common/passenger-guards"; import { PassengerStaff } from "../../common/passenger-guards";
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; 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") @ApiTags("Reports")
@Controller("reports") @Controller("reports")
@PassengerStaff([PASSENGER_PERMS.reports.view, PASSENGER_PERMS.admin])
@ApiBearerAuth("IAM-auth") @ApiBearerAuth("IAM-auth")
export class ReportsController { export class ReportsController {
constructor(private service: ReportsService) {} constructor(private service: ReportsService) {}
@Post("generate") @Post("generate")
@Report(R.catalog.view)
@ApiOperation({ summary: "Generate operational report" }) @ApiOperation({ summary: "Generate operational report" })
generateReport(@Body() dto: GenerateReportDto) { generateReport(@Body() dto: GenerateReportDto) {
return this.service.generateReport(dto); return this.service.generateReport(dto);
} }
@Get('schedules') @Get('schedules')
@AnyReport()
@ApiOperation({ summary: 'List schedules for the passengers report picker' }) @ApiOperation({ summary: 'List schedules for the passengers report picker' })
listSchedulesForPicker(@Query('all') all?: string) { listSchedulesForPicker(@Query('all') all?: string) {
return this.service.listSchedulesForPicker(all === 'true'); return this.service.listSchedulesForPicker(all === 'true');
} }
@Get("passengers/list") @Get("passengers/list")
@Report(R.passengers.view)
@ApiOperation({ summary: "Flat passenger list for a specific schedule" }) @ApiOperation({ summary: "Flat passenger list for a specific schedule" })
getPassengerList(@Query("scheduleId") scheduleId: string) { getPassengerList(@Query("scheduleId") scheduleId: string) {
return this.service.getPassengerList(scheduleId); return this.service.getPassengerList(scheduleId);
@@ -39,6 +73,7 @@ export class ReportsController {
// Two segments, so `@Get(":reportId")` below cannot shadow it whatever the order. // Two segments, so `@Get(":reportId")` below cannot shadow it whatever the order.
@Get("passengers/overview") @Get("passengers/overview")
@Report(R.passengers.view)
@ApiOperation({ @ApiOperation({
summary: "Fleet-wide passenger mix across a departure window", summary: "Fleet-wide passenger mix across a departure window",
description: description:
@@ -56,12 +91,14 @@ export class ReportsController {
} }
@Get("passengers") @Get("passengers")
@Report(R.passengers.view)
@ApiOperation({ summary: "Passengers report for a specific schedule" }) @ApiOperation({ summary: "Passengers report for a specific schedule" })
getOccupancyReport(@Query("scheduleId") scheduleId: string) { getOccupancyReport(@Query("scheduleId") scheduleId: string) {
return this.service.getOccupancyBySchedule(scheduleId); return this.service.getOccupancyBySchedule(scheduleId);
} }
@Get("payment-discrepancy") @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." }) @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( getPaymentDiscrepancy(
@Query('from') from?: string, @Query('from') from?: string,
@@ -73,6 +110,7 @@ export class ReportsController {
} }
@Get("seat-status") @Get("seat-status")
@Report(R.seatStatus.view)
@ApiOperation({ summary: "Seat status breakdown for a schedule (paid, unpaid, expired holds, blocked)" }) @ApiOperation({ summary: "Seat status breakdown for a schedule (paid, unpaid, expired holds, blocked)" })
getSeatStatusReport(@Query('scheduleId') scheduleId: string) { getSeatStatusReport(@Query('scheduleId') scheduleId: string) {
return this.service.getSeatStatusReport(scheduleId); return this.service.getSeatStatusReport(scheduleId);
@@ -80,6 +118,7 @@ export class ReportsController {
// Two segments, so `@Get(":reportId")` below cannot shadow it whatever the order. // Two segments, so `@Get(":reportId")` below cannot shadow it whatever the order.
@Get("seat-status/overview") @Get("seat-status/overview")
@Report(R.seatStatus.view)
@ApiOperation({ @ApiOperation({
summary: "Fleet-wide seat status across a departure window", summary: "Fleet-wide seat status across a departure window",
description: description:
@@ -96,18 +135,21 @@ export class ReportsController {
} }
@Get("boarding") @Get("boarding")
@Report(R.boarding.view)
@ApiOperation({ summary: "Boarding report for a schedule — boarded vs not-boarded passengers" }) @ApiOperation({ summary: "Boarding report for a schedule — boarded vs not-boarded passengers" })
getBoardingReport(@Query('scheduleId') scheduleId: string) { getBoardingReport(@Query('scheduleId') scheduleId: string) {
return this.service.getBoardingReport(scheduleId); return this.service.getBoardingReport(scheduleId);
} }
@Get("payments") @Get("payments")
@Report(R.payments.view)
@ApiOperation({ summary: "Payments collected for a schedule" }) @ApiOperation({ summary: "Payments collected for a schedule" })
getPaymentsReport(@Query('scheduleId') scheduleId: string) { getPaymentsReport(@Query('scheduleId') scheduleId: string) {
return this.service.getPaymentsReport(scheduleId); return this.service.getPaymentsReport(scheduleId);
} }
@Get("payments/discrepancy") @Get("payments/discrepancy")
@Report(R.payments.view)
@ApiOperation({ summary: "Payment discrepancy breakdown for a schedule" }) @ApiOperation({ summary: "Payment discrepancy breakdown for a schedule" })
getPaymentDiscrepancyBySchedule( getPaymentDiscrepancyBySchedule(
@Query('scheduleId') scheduleId: string, @Query('scheduleId') scheduleId: string,
@@ -121,6 +163,7 @@ export class ReportsController {
// ── Finance Summary ────────────────────────────────────────────────────── // ── Finance Summary ──────────────────────────────────────────────────────
@Get("finance") @Get("finance")
@Report(R.finance.view)
@ApiOperation({ @ApiOperation({
summary: "Finance summary — revenue by period, origin/destination segment, payment method, and currency", summary: "Finance summary — revenue by period, origin/destination segment, payment method, and currency",
description: description:
@@ -138,6 +181,7 @@ export class ReportsController {
} }
@Get("finance/export") @Get("finance/export")
@Report(R.finance.export)
@ApiOperation({ summary: "Finance summary as CSV — one row per period + route + payment method" }) @ApiOperation({ summary: "Finance summary as CSV — one row per period + route + payment method" })
@ApiProduces("text/csv") @ApiProduces("text/csv")
@ApiOkResponse({ description: "CSV export", schema: { type: "string" } }) @ApiOkResponse({ description: "CSV export", schema: { type: "string" } })
@@ -154,6 +198,7 @@ export class ReportsController {
// ── Blocked Seat Revenue Loss ────────────────────────────────────────────── // ── Blocked Seat Revenue Loss ──────────────────────────────────────────────
@Get("blocked-seats-revenue-loss") @Get("blocked-seats-revenue-loss")
@Report(R.blockedSeats.view)
@ApiOperation({ @ApiOperation({
summary: "Potential revenue lost to blocked seats, per schedule", summary: "Potential revenue lost to blocked seats, per schedule",
description: description:
@@ -176,6 +221,7 @@ export class ReportsController {
} }
@Get("blocked-seats-revenue-loss/export") @Get("blocked-seats-revenue-loss/export")
@Report(R.blockedSeats.export)
@ApiOperation({ @ApiOperation({
summary: "Blocked-seat revenue loss as CSV", summary: "Blocked-seat revenue loss as CSV",
description: description:
@@ -200,12 +246,14 @@ export class ReportsController {
} }
@Get(":reportId") @Get(":reportId")
@Report(R.catalog.view)
@ApiOperation({ summary: "Get report by ID" }) @ApiOperation({ summary: "Get report by ID" })
getReport(@Param("reportId") reportId: string) { getReport(@Param("reportId") reportId: string) {
return this.service.getReport(reportId); return this.service.getReport(reportId);
} }
@Get() @Get()
@Report(R.catalog.view)
@ApiOperation({ summary: "List reports" }) @ApiOperation({ summary: "List reports" })
listReports(@Query("type") type?: string) { listReports(@Query("type") type?: string) {
return this.service.listReports(type); return this.service.listReports(type);

View File

@@ -1,7 +1,7 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtGuard } from '../../common/jwt.guard'; 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 { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
import { RescheduleService } from './reschedule.service'; import { RescheduleService } from './reschedule.service';
import { import {
@@ -17,7 +17,7 @@ export class RescheduleController {
constructor(private service: RescheduleService) {} constructor(private service: RescheduleService) {}
@Get('reschedule/policies') @Get('reschedule/policies')
@PassengerStaff(PASSENGER_PERMS.bookings.view) @PassengerStaff([PASSENGER_PERMS.reschedulePolicies.view, PASSENGER_PERMS.reschedulePolicies.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('JWT-auth') @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Every reschedule policy, each with its coach type (fare class)' }) @ApiOperation({ summary: 'Every reschedule policy, each with its coach type (fare class)' })
listPolicies() { listPolicies() {
@@ -25,7 +25,7 @@ export class RescheduleController {
} }
@Get('reschedule/policies/available-coach-types') @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') @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Coach types that do not have a reschedule policy yet (add-dialog dropdown)' }) @ApiOperation({ summary: 'Coach types that do not have a reschedule policy yet (add-dialog dropdown)' })
listUnconfiguredCoachTypes() { listUnconfiguredCoachTypes() {
@@ -33,7 +33,7 @@ export class RescheduleController {
} }
@Post('reschedule/policies') @Post('reschedule/policies')
@PassengerAdmin() @PassengerWrite(PASSENGER_PERMS.reschedulePolicies.create, PASSENGER_PERMS.reschedulePolicies.manage)
@ApiBearerAuth('JWT-auth') @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Create a reschedule policy for a coach type (admin)' }) @ApiOperation({ summary: 'Create a reschedule policy for a coach type (admin)' })
createPolicy(@Req() req: any, @Body() dto: CreateReschedulePolicyDto) { createPolicy(@Req() req: any, @Body() dto: CreateReschedulePolicyDto) {
@@ -41,7 +41,7 @@ export class RescheduleController {
} }
@Patch('reschedule/policies/:coachTypeId') @Patch('reschedule/policies/:coachTypeId')
@PassengerAdmin() @PassengerWrite(PASSENGER_PERMS.reschedulePolicies.edit, PASSENGER_PERMS.reschedulePolicies.manage)
@ApiBearerAuth('JWT-auth') @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Update the reschedule policy of a coach type (admin)' }) @ApiOperation({ summary: 'Update the reschedule policy of a coach type (admin)' })
updatePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string, @Body() dto: UpdateReschedulePolicyDto) { updatePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string, @Body() dto: UpdateReschedulePolicyDto) {
@@ -49,7 +49,7 @@ export class RescheduleController {
} }
@Delete('reschedule/policies/:coachTypeId') @Delete('reschedule/policies/:coachTypeId')
@PassengerAdmin() @PassengerDelete(PASSENGER_PERMS.reschedulePolicies.delete)
@ApiBearerAuth('JWT-auth') @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Delete a reschedule policy — rescheduling is then refused for that fare class (admin)' }) @ApiOperation({ summary: 'Delete a reschedule policy — rescheduling is then refused for that fare class (admin)' })
deletePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string) { deletePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string) {

View File

@@ -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 { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { RoutesService } from './routes.service'; import { RoutesService } from './routes.service';
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto'; 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'; import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('Routes') @ApiTags('Routes')
@@ -13,7 +13,7 @@ export class RoutesController {
// ── Routes ───────────────────────────────────────────────────────────────── // ── Routes ─────────────────────────────────────────────────────────────────
@Post() @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({ @ApiOperation({
summary: 'Create a reusable route with its ordered stops', summary: 'Create a reusable route with its ordered stops',
description: `Define the physical corridor once (e.g. ADD→ADM→AWS→DDW→AYS→DJI). 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); } getRoute(@Param('id') id: string) { return this.service.getRoute(id); }
@Patch(':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)' }) @ApiOperation({ summary: 'Update route metadata (name, description, active flag, effectiveFrom, effectiveUntil)' })
@ApiParam({ name: 'id', description: 'Route UUID' }) @ApiParam({ name: 'id', description: 'Route UUID' })
@ApiResponse({ status: 200, description: 'Route updated' }) @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); } updateRoute(@Param('id') id: string, @Body() dto: UpdateRouteDto) { return this.service.updateRoute(id, dto); }
@Delete(':id') @Delete(':id')
@PassengerAdmin() @PassengerDelete(PASSENGER_PERMS.routes.delete)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a route' }) @ApiOperation({ summary: 'Delete a route' })
@ApiParam({ name: 'id', description: 'Route UUID' }) @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); } getStops(@Param('id') id: string) { return this.service.getStops(id); }
@Post(':id/stops') @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' }) @ApiOperation({ summary: 'Add a stop to an existing route' })
@ApiParam({ name: 'id', description: 'Route UUID' }) @ApiParam({ name: 'id', description: 'Route UUID' })
@ApiResponse({ status: 201, description: 'Stop added' }) @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); } addStop(@Param('id') id: string, @Body() dto: AddRouteStopDto) { return this.service.addStop(id, dto); }
@Delete(':id/stops/:sequence') @Delete(':id/stops/:sequence')
@PassengerAdmin() @PassengerWrite(PASSENGER_PERMS.routes.edit, PASSENGER_PERMS.routes.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Remove a stop from a route by sequence number' }) @ApiOperation({ summary: 'Remove a stop from a route by sequence number' })
@ApiParam({ name: 'id', description: 'Route UUID' }) @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); } getCoachTemplate(@Param('id') id: string) { return this.service.getRouteCoachTemplate(id); }
@Put(':id/coaches') @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({ @ApiOperation({
summary: 'Set the default coach lineup for this route', 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.', 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') @Delete(':id/coaches')
@PassengerAdmin() @PassengerWrite(PASSENGER_PERMS.routes.edit, PASSENGER_PERMS.routes.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Clear the default coach lineup for this route' }) @ApiOperation({ summary: 'Clear the default coach lineup for this route' })
@ApiParam({ name: 'id', description: 'Route UUID' }) @ApiParam({ name: 'id', description: 'Route UUID' })

View File

@@ -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 { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { SchedulesService } from './schedules.service'; import { SchedulesService } from './schedules.service';
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto, TripStatus, ApplyDelayDto } from './schedules.dto'; 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'; 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') @ApiTags('Schedule')
@Controller('schedules') @Controller('schedules')
export class SchedulesController { export class SchedulesController {
constructor(private service: SchedulesService) {} constructor(private service: SchedulesService) {}
@Post('bulk-generate') @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' }) @ApiOperation({ summary: 'Bulk generate repetitive schedules' })
bulkGenerateSchedules(@Body() dto: BulkCreateSchedulesDto) { bulkGenerateSchedules(@Body() dto: BulkCreateSchedulesDto) {
return this.service.bulkGenerateSchedules(dto); return this.service.bulkGenerateSchedules(dto);
} }
@Post() @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' }) @ApiOperation({ summary: 'Create a train schedule from a route template' })
createSchedule(@Body() dto: CreateScheduleDto) { return this.service.createSchedule(dto); } createSchedule(@Body() dto: CreateScheduleDto) { return this.service.createSchedule(dto); }
@@ -42,13 +56,13 @@ export class SchedulesController {
// ===== SPECIFIC ROUTES (must come BEFORE generic :id routes) ===== // ===== SPECIFIC ROUTES (must come BEFORE generic :id routes) =====
@Post('fares') @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' }) @ApiOperation({ summary: 'Create a fare rule scoped to a schedule or route code' })
@ApiResponse({ status: 201, description: 'Fare rule created' }) @ApiResponse({ status: 201, description: 'Fare rule created' })
createFareRule(@Body() dto: CreateFareRuleDto) { return this.service.createFareRule(dto); } createFareRule(@Body() dto: CreateFareRuleDto) { return this.service.createFareRule(dto); }
@Patch('fares/:id') @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' }) @ApiOperation({ summary: 'Update a fare rule' })
@ApiParam({ name: 'id', description: 'FareRule UUID' }) @ApiParam({ name: 'id', description: 'FareRule UUID' })
@ApiResponse({ status: 200, description: 'Fare rule updated' }) @ApiResponse({ status: 200, description: 'Fare rule updated' })
@@ -57,7 +71,7 @@ export class SchedulesController {
} }
@Delete('fares/:id') @Delete('fares/:id')
@PassengerAdmin() @FareDelete()
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a fare rule' }) @ApiOperation({ summary: 'Delete a fare rule' })
@ApiParam({ name: 'id', description: 'FareRule UUID' }) @ApiParam({ name: 'id', description: 'FareRule UUID' })
@@ -65,13 +79,13 @@ export class SchedulesController {
deleteFareRule(@Param('id') id: string) { return this.service.deleteFareRule(id); } deleteFareRule(@Param('id') id: string) { return this.service.deleteFareRule(id); }
@Post('segment-fares') @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' }) @ApiOperation({ summary: 'Create a segment fare rule' })
createSegmentFareRule(@Body() dto: any) { return this.service.createSegmentFareRule(dto); } createSegmentFareRule(@Body() dto: any) { return this.service.createSegmentFareRule(dto); }
// Static sub-path MUST come before routes/:routeId/* to avoid :routeId swallowing 'fare-rules' // Static sub-path MUST come before routes/:routeId/* to avoid :routeId swallowing 'fare-rules'
@Delete('routes/fare-rules/:id') @Delete('routes/fare-rules/:id')
@PassengerAdmin() @FareDelete()
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a route-level fare override' }) @ApiOperation({ summary: 'Delete a route-level fare override' })
@ApiParam({ name: 'id', description: 'RouteFareRule UUID' }) @ApiParam({ name: 'id', description: 'RouteFareRule UUID' })
@@ -80,7 +94,7 @@ export class SchedulesController {
} }
@Patch('routes/fare-rules/:id') @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' }) @ApiOperation({ summary: 'Update a route-level fare override' })
@ApiParam({ name: 'id', description: 'RouteFareRule UUID' }) @ApiParam({ name: 'id', description: 'RouteFareRule UUID' })
updateRouteFareRule(@Param('id') id: string, @Body() dto: any) { updateRouteFareRule(@Param('id') id: string, @Body() dto: any) {
@@ -96,7 +110,7 @@ export class SchedulesController {
} }
@Post('routes/:routeId/fare-rules') @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' }) @ApiOperation({ summary: 'Create a route-level fare override' })
@ApiParam({ name: 'routeId', description: 'Route UUID' }) @ApiParam({ name: 'routeId', description: 'Route UUID' })
createRouteFareRule(@Param('routeId') routeId: string, @Body() dto: any) { 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); } getSegmentFares(@Param('routeId') routeId: string) { return this.service.getSegmentFares(routeId); }
@Patch('segment-fares/:id') @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' }) @ApiOperation({ summary: 'Update a segment fare rule' })
@ApiParam({ name: 'id', description: 'SegmentFareRule UUID' }) @ApiParam({ name: 'id', description: 'SegmentFareRule UUID' })
updateSegmentFareRule(@Param('id') id: string, @Body() dto: any) { return this.service.updateSegmentFareRule(id, dto); } updateSegmentFareRule(@Param('id') id: string, @Body() dto: any) { return this.service.updateSegmentFareRule(id, dto); }
@Delete('segment-fares/:id') @Delete('segment-fares/:id')
@PassengerAdmin() @FareDelete()
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a segment fare rule' }) @ApiOperation({ summary: 'Delete a segment fare rule' })
@ApiParam({ name: 'id', description: 'SegmentFareRule UUID' }) @ApiParam({ name: 'id', description: 'SegmentFareRule UUID' })
@@ -131,7 +145,7 @@ export class SchedulesController {
getSchedule(@Param('id') id: string) { return this.service.getSchedule(id); } getSchedule(@Param('id') id: string) { return this.service.getSchedule(id); }
@Patch(':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)' }) @ApiOperation({ summary: 'Update a schedule (partial)' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
updateSchedule(@Param('id') id: string, @Body() dto: UpdateScheduleDto) { updateSchedule(@Param('id') id: string, @Body() dto: UpdateScheduleDto) {
@@ -139,15 +153,33 @@ export class SchedulesController {
} }
@Patch(':id/status') @Patch(':id/status')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') @PassengerWrite(S.edit, S.manage) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update schedule status' }) @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' }) @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); return this.service.updateScheduleStatus(id, dto);
} }
@Delete(':id') @Delete(':id')
@PassengerAdmin() @PassengerDelete(S.delete)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a schedule' }) @ApiOperation({ summary: 'Delete a schedule' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) @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'); } deleteSchedule(@Param('id') id: string, @Query('cascade') cascade?: string) { return this.service.deleteSchedule(id, cascade === 'true'); }
@Post(':id/recalculate-stops') @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' }) @ApiOperation({ summary: 'Recompute TripStopTime records from current route travelMinutesToStop values' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
recalculateStops(@Param('id') id: string) { return this.service.recalculateStopTimes(id); } 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); } getStops(@Param('id') id: string) { return this.service.getStops(id); }
@Patch(':id/stops/:sequence') @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' }) @ApiOperation({ summary: 'Update a stop time' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
@ApiParam({ name: 'sequence', description: 'Stop sequence number' }) @ApiParam({ name: 'sequence', description: 'Stop sequence number' })
@@ -178,7 +210,7 @@ export class SchedulesController {
) { return this.service.updateStop(id, sequence, dto); } ) { return this.service.updateStop(id, sequence, dto); }
@Post(':id/delay') @Post(':id/delay')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') @PassengerWrite(S.edit, S.manage) @ApiBearerAuth('IAM-auth')
@ApiOperation({ @ApiOperation({
summary: 'Report a delay — pushes every downstream stop\'s planned times (and check-in cutoffs) back by the same amount', 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 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') @Put(':scheduleId/fares/:seatClassId')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') @FareWrite(F.edit) @ApiBearerAuth('IAM-auth')
@ApiOperation({ @ApiOperation({
summary: 'Override fare for a specific seat class on a schedule', 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.', 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') @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' }) @ApiOperation({ summary: 'Sync fares from fare engine' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
syncFares(@Param('id') id: string) { return this.service.syncFaresFromEngine(id); } syncFares(@Param('id') id: string) { return this.service.syncFaresFromEngine(id); }
@Post(':id/coaches') @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' }) @ApiOperation({ summary: 'Assign coaches to a schedule' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
assignCoaches( 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); } getAssignedCoaches(@Param('id') id: string) { return this.service.getAssignedCoaches(id); }
@Delete(':id/coaches/:coachId') @Delete(':id/coaches/:coachId')
@PassengerAdmin() @PassengerWrite(S.edit, S.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Remove a coach assignment' }) @ApiOperation({ summary: 'Remove a coach assignment' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' })

View File

@@ -3,7 +3,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiResponse, ApiBody }
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { SeatClassesService } from './seat-classes.service'; import { SeatClassesService } from './seat-classes.service';
import { CreateSeatClassDto, UpdateSeatClassDto } from './seat-classes.dto'; 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'; import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('Seat Classes') @ApiTags('Seat Classes')
@@ -26,7 +26,7 @@ export class SeatClassesController {
getSeatClass(@Param('id') id: string) { return this.service.getSeatClass(id); } getSeatClass(@Param('id') id: string) { return this.service.getSeatClass(id); }
@Post() @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' }) @ApiOperation({ summary: 'Create a seat class' })
@ApiBody({ type: CreateSeatClassDto }) @ApiBody({ type: CreateSeatClassDto })
@ApiResponse({ status: 201, description: 'Seat class created' }) @ApiResponse({ status: 201, description: 'Seat class created' })
@@ -34,7 +34,7 @@ export class SeatClassesController {
createSeatClass(@Body() dto: CreateSeatClassDto) { return this.service.createSeatClass(dto); } createSeatClass(@Body() dto: CreateSeatClassDto) { return this.service.createSeatClass(dto); }
@Patch(':id') @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' }) @ApiOperation({ summary: 'Update a seat class' })
@ApiParam({ name: 'id', description: 'Seat class UUID' }) @ApiParam({ name: 'id', description: 'Seat class UUID' })
@ApiBody({ type: UpdateSeatClassDto }) @ApiBody({ type: UpdateSeatClassDto })
@@ -43,7 +43,7 @@ export class SeatClassesController {
updateSeatClass(@Param('id') id: string, @Body() dto: UpdateSeatClassDto) { return this.service.updateSeatClass(id, dto); } updateSeatClass(@Param('id') id: string, @Body() dto: UpdateSeatClassDto) { return this.service.updateSeatClass(id, dto); }
@Delete(':id') @Delete(':id')
@PassengerAdmin() @PassengerDelete(PASSENGER_PERMS.tariffRates.delete)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a seat class' }) @ApiOperation({ summary: 'Delete a seat class' })
@ApiParam({ name: 'id', description: 'Seat class UUID' }) @ApiParam({ name: 'id', description: 'Seat class UUID' })

View File

@@ -25,7 +25,7 @@ import { AutoAssignHoldDto, BlockSeatDto, HoldSeatsDto, ReleaseHoldDto, SetMaint
import { resolveActingUser, RequestWithActingUser } from "../../common/acting-user"; import { resolveActingUser, RequestWithActingUser } from "../../common/acting-user";
import { GetDuplicateSeatsQuery, ResolveDuplicatesDto } from "./duplicate-seats.dto"; import { GetDuplicateSeatsQuery, ResolveDuplicatesDto } from "./duplicate-seats.dto";
import { JwtGuard } from "../../common/jwt.guard"; 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"; import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
@ApiTags("Seats") @ApiTags("Seats")
@@ -35,7 +35,7 @@ export class SeatsController {
// ── Blocked Seats ───────────────────────────────────────────────────────── // ── Blocked Seats ─────────────────────────────────────────────────────────
@Get('blocks') @Get('blocks')
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.seats.block, PASSENGER_PERMS.seats.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'List all blocked seats with reason and coach info' }) @ApiOperation({ summary: 'List all blocked seats with reason and coach info' })
@ApiResponse({ status: 200, description: 'Blocked seat records' }) @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") @Post("auto-assign-hold")
@PassengerStaff([PASSENGER_PERMS.bookings.manage]) @PassengerWrite(PASSENGER_PERMS.bookings.create, PASSENGER_PERMS.bookings.manage)
@ApiBearerAuth("IAM-auth") @ApiBearerAuth("IAM-auth")
@ApiOperation({ @ApiOperation({
summary: "Auto-assign and hold N seats of a class — staff bulk/group booking only", 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 ─────────────────────────────────────────────────── // ── Seat Block / Unblock ───────────────────────────────────────────────────
@Post(":seatId/block") @Post(":seatId/block")
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.seats.block, PASSENGER_PERMS.seats.manage)
@ApiBearerAuth("IAM-auth") @ApiBearerAuth("IAM-auth")
@ApiOperation({ @ApiOperation({
summary: "Block a seat (e.g., maintenance, damage)", 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") @Delete(":seatId/block")
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.seats.block, PASSENGER_PERMS.seats.manage)
@ApiBearerAuth("IAM-auth") @ApiBearerAuth("IAM-auth")
@ApiOperation({ summary: "Unblock a seat" }) @ApiOperation({ summary: "Unblock a seat" })
@ApiParam({ name: "seatId", description: "Seat UUID" }) @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 ─────────────────────────────────────────────────────────── // ── Maintenance ───────────────────────────────────────────────────────────
@Post(":seatId/maintenance") @Post(":seatId/maintenance")
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage)
@ApiBearerAuth("IAM-auth") @ApiBearerAuth("IAM-auth")
@ApiOperation({ summary: "Set seat status to Under Maintenance" }) @ApiOperation({ summary: "Set seat status to Under Maintenance" })
@ApiParam({ name: "seatId", description: "Seat UUID" }) @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") @Delete(":seatId/maintenance")
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage)
@ApiBearerAuth("IAM-auth") @ApiBearerAuth("IAM-auth")
@ApiOperation({ summary: "Clear seat maintenance status" }) @ApiOperation({ summary: "Clear seat maintenance status" })
@ApiParam({ name: "seatId", description: "Seat UUID" }) @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 ──────────────────────────────────────────────────────────── // ── Remove Seat ────────────────────────────────────────────────────────────
@Patch(":seatId/remove") @Patch(":seatId/remove")
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin]) @PassengerDelete(PASSENGER_PERMS.seats.delete)
@ApiBearerAuth("IAM-auth") @ApiBearerAuth("IAM-auth")
@ApiOperation({ @ApiOperation({
summary: "Remove a seat by marking with negative seatNumber", 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") @Patch(":seatId/undo-remove")
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage)
@ApiBearerAuth("IAM-auth") @ApiBearerAuth("IAM-auth")
@ApiOperation({ @ApiOperation({
summary: "Undo seat removal by restoring original seatNumber", 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") @Get("export/csv/:scheduleId")
@UseGuards(JwtGuard) @PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage)
@ApiBearerAuth("JWT-auth") @ApiBearerAuth("JWT-auth")
@ApiOperation({ summary: "Export seats as CSV" }) @ApiOperation({ summary: "Export seats as CSV" })
async exportCSV(@Param("scheduleId") scheduleId: string) { 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") @Post("import/preview")
@UseGuards(JwtGuard) @PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage)
@ApiBearerAuth("JWT-auth") @ApiBearerAuth("JWT-auth")
@ApiOperation({ summary: "Preview CSV import" }) @ApiOperation({ summary: "Preview CSV import" })
previewCSV(@Body() body: { csv: string }) { 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") @Post("import/commit")
@UseGuards(JwtGuard) @PassengerWrite(PASSENGER_PERMS.seats.create, PASSENGER_PERMS.seats.manage)
@ApiBearerAuth("JWT-auth") @ApiBearerAuth("JWT-auth")
@ApiOperation({ summary: "Commit CSV import" }) @ApiOperation({ summary: "Commit CSV import" })
importCSV( importCSV(
@@ -367,7 +367,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av
// ── Duplicate seat management (backoffice) ──────────────────────────────── // ── Duplicate seat management (backoffice) ────────────────────────────────
@Get("duplicates") @Get("duplicates")
@UseGuards(JwtGuard) @PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage)
@ApiBearerAuth("JWT-auth") @ApiBearerAuth("JWT-auth")
@ApiOperation({ @ApiOperation({
summary: "List duplicate seat assignments by schedule date", 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") @Post("duplicates/resolve")
@UseGuards(JwtGuard) @PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage)
@ApiBearerAuth("JWT-auth") @ApiBearerAuth("JWT-auth")
@ApiOperation({ @ApiOperation({
summary: "Auto-assign duplicate bookings to seats in selected coaches", summary: "Auto-assign duplicate bookings to seats in selected coaches",

View File

@@ -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 { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { StationsService } from './stations.service'; import { StationsService } from './stations.service';
import { CreateStationDto } from './stations.dto'; 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'; import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('Stations') @ApiTags('Stations')
@@ -79,7 +79,7 @@ export class StationsController {
findOne(@Param('id') id: string) { return this.service.findOne(id); } findOne(@Param('id') id: string) { return this.service.findOne(id); }
@Post() @Post()
@PassengerStaff([PASSENGER_PERMS.stations.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.stations.create, PASSENGER_PERMS.stations.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create new station' }) @ApiOperation({ summary: 'Create new station' })
@ApiResponse({ @ApiResponse({
@@ -105,7 +105,7 @@ export class StationsController {
create(@Body() dto: CreateStationDto) { return this.service.create(dto); } create(@Body() dto: CreateStationDto) { return this.service.create(dto); }
@Patch(':id') @Patch(':id')
@PassengerStaff([PASSENGER_PERMS.stations.manage, PASSENGER_PERMS.admin]) @PassengerWrite(PASSENGER_PERMS.stations.edit, PASSENGER_PERMS.stations.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update station' }) @ApiOperation({ summary: 'Update station' })
@ApiResponse({ @ApiResponse({
@@ -134,7 +134,7 @@ export class StationsController {
} }
@Delete(':id') @Delete(':id')
@PassengerAdmin() @PassengerDelete(PASSENGER_PERMS.stations.delete)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete station' }) @ApiOperation({ summary: 'Delete station' })
@ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' }) @ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' })

View File

@@ -1,7 +1,7 @@
import { Body, Controller, Get, Param, Post, Query, Req, UseGuards, Delete, Patch, SetMetadata } from '@nestjs/common'; 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 { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger';
import { TicketsService } from './tickets.service'; 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 { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
import { resolveActingUser } from '../../common/acting-user'; import { resolveActingUser } from '../../common/acting-user';
@@ -118,7 +118,7 @@ export class TicketsController {
} }
@Post('scan-board/:qrCodeOrRef') @Post('scan-board/:qrCodeOrRef')
@PassengerStaff(PASSENGER_PERMS.tickets.manage) @PassengerWrite(PASSENGER_PERMS.tickets.board, PASSENGER_PERMS.tickets.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ @ApiOperation({
summary: 'Scan QR code or booking ref and automatically board ticket', summary: 'Scan QR code or booking ref and automatically board ticket',
@@ -146,7 +146,7 @@ export class TicketsController {
} }
@Post(':bookingRef/validate') @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') @ApiBearerAuth('IAM-auth')
@ApiOperation({ @ApiOperation({
summary: 'Validate ticket at gate with audit logging', summary: 'Validate ticket at gate with audit logging',
@@ -194,7 +194,7 @@ export class TicketsController {
} }
@Post('validate/offline') @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') @ApiBearerAuth('IAM-auth')
@ApiOperation({ @ApiOperation({
summary: 'Batch import offline validations', summary: 'Batch import offline validations',
@@ -226,7 +226,7 @@ export class TicketsController {
} }
@Delete(':id') @Delete(':id')
@PassengerAdmin() @PassengerDelete(PASSENGER_PERMS.tickets.delete)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ @ApiOperation({
summary: 'Delete ticket (admin only)', summary: 'Delete ticket (admin only)',
@@ -237,7 +237,7 @@ export class TicketsController {
} }
@Patch(':id/restore') @Patch(':id/restore')
@PassengerStaff(PASSENGER_PERMS.tickets.manage) @PassengerWrite(PASSENGER_PERMS.tickets.edit, PASSENGER_PERMS.tickets.manage)
@ApiBearerAuth('IAM-auth') @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Restore a cancelled ticket by resetting its status to ACTIVE' }) @ApiOperation({ summary: 'Restore a cancelled ticket by resetting its status to ACTIVE' })
restore(@Param('id') id: string) { restore(@Param('id') id: string) {

View File

@@ -1,7 +1,7 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtGuard } from '../../common/jwt.guard'; 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 { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
import { UpgradeService } from './upgrade.service'; import { UpgradeService } from './upgrade.service';
import { import {
@@ -18,7 +18,7 @@ export class UpgradeController {
constructor(private service: UpgradeService) {} constructor(private service: UpgradeService) {}
@Get('upgrade/policies') @Get('upgrade/policies')
@PassengerStaff(PASSENGER_PERMS.bookings.view) @PassengerStaff([PASSENGER_PERMS.upgradePolicies.view, PASSENGER_PERMS.upgradePolicies.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('JWT-auth') @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Every upgrade policy, each with its coach type (fare class)' }) @ApiOperation({ summary: 'Every upgrade policy, each with its coach type (fare class)' })
listPolicies() { listPolicies() {
@@ -26,7 +26,7 @@ export class UpgradeController {
} }
@Get('upgrade/policies/available-coach-types') @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') @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Coach types that do not have an upgrade policy yet (add-dialog dropdown)' }) @ApiOperation({ summary: 'Coach types that do not have an upgrade policy yet (add-dialog dropdown)' })
listUnconfiguredCoachTypes() { listUnconfiguredCoachTypes() {
@@ -34,7 +34,7 @@ export class UpgradeController {
} }
@Post('upgrade/policies') @Post('upgrade/policies')
@PassengerAdmin() @PassengerWrite(PASSENGER_PERMS.upgradePolicies.create, PASSENGER_PERMS.upgradePolicies.manage)
@ApiBearerAuth('JWT-auth') @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Create an upgrade policy for a coach type (admin)' }) @ApiOperation({ summary: 'Create an upgrade policy for a coach type (admin)' })
createPolicy(@Req() req: any, @Body() dto: CreateUpgradePolicyDto) { createPolicy(@Req() req: any, @Body() dto: CreateUpgradePolicyDto) {
@@ -42,7 +42,7 @@ export class UpgradeController {
} }
@Patch('upgrade/policies/:coachTypeId') @Patch('upgrade/policies/:coachTypeId')
@PassengerAdmin() @PassengerWrite(PASSENGER_PERMS.upgradePolicies.edit, PASSENGER_PERMS.upgradePolicies.manage)
@ApiBearerAuth('JWT-auth') @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Update the upgrade policy of a coach type (admin)' }) @ApiOperation({ summary: 'Update the upgrade policy of a coach type (admin)' })
updatePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string, @Body() dto: UpdateUpgradePolicyDto) { updatePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string, @Body() dto: UpdateUpgradePolicyDto) {
@@ -50,7 +50,7 @@ export class UpgradeController {
} }
@Delete('upgrade/policies/:coachTypeId') @Delete('upgrade/policies/:coachTypeId')
@PassengerAdmin() @PassengerDelete(PASSENGER_PERMS.upgradePolicies.delete)
@ApiBearerAuth('JWT-auth') @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Delete an upgrade policy — the class can then be neither left nor entered (admin)' }) @ApiOperation({ summary: 'Delete an upgrade policy — the class can then be neither left nor entered (admin)' })
deletePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string) { deletePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string) {

View File

@@ -128,6 +128,9 @@ export const PASSENGER_PERMISSIONS: PassengerPermissionSeed[] = [
perm('2ce6d0e3-3426-4424-a239-3ddf95b001c4', 'edr_passenger_app:schedules:cancel', 'Cancel schedules'), 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('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('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` ──────────────────────────── // ── Schedule fares — split out of `schedules:manage` ────────────────────────────
perm('7e7406b3-77dc-422a-b5de-a8286f973b38', 'edr_passenger_app:schedule_fares:view', 'View schedule fares'), 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('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'), 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'), 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', edit: 'edr_passenger_app:bookings:edit',
delete: 'edr_passenger_app:bookings:delete', 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: { passengers: {
view: 'edr_passenger_app:passengers:view', view: 'edr_passenger_app:passengers:view',
manage: 'edr_passenger_app:passengers:manage', manage: 'edr_passenger_app:passengers:manage',
@@ -175,6 +210,8 @@ export const PASSENGER_PERMS = {
view: 'edr_passenger_app:tickets:view', view: 'edr_passenger_app:tickets:view',
manage: 'edr_passenger_app:tickets:manage', manage: 'edr_passenger_app:tickets:manage',
generate: 'edr_passenger_app:tickets:generate', 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', create: 'edr_passenger_app:tickets:create',
edit: 'edr_passenger_app:tickets:edit', edit: 'edr_passenger_app:tickets:edit',
delete: 'edr_passenger_app:tickets:delete', delete: 'edr_passenger_app:tickets:delete',
@@ -183,6 +220,8 @@ export const PASSENGER_PERMS = {
view: 'edr_passenger_app:payments:view', view: 'edr_passenger_app:payments:view',
manage: 'edr_passenger_app:payments:manage', manage: 'edr_passenger_app:payments:manage',
create: 'edr_passenger_app:payments:create', 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', edit: 'edr_passenger_app:payments:edit',
delete: 'edr_passenger_app:payments:delete', delete: 'edr_passenger_app:payments:delete',
// legacy keys — retained as aliases for backward compatibility // legacy keys — retained as aliases for backward compatibility
@@ -190,6 +229,13 @@ export const PASSENGER_PERMS = {
refund: 'edr_passenger_app:payments:refund', refund: 'edr_passenger_app:payments:refund',
manageMethods: 'edr_passenger_app:payments:manage_methods', 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: { paymentMethods: {
view: 'edr_passenger_app:payment_methods:view', view: 'edr_passenger_app:payment_methods:view',
manage: 'edr_passenger_app:payment_methods:manage', manage: 'edr_passenger_app:payment_methods:manage',

View File

@@ -13,6 +13,9 @@ import Pagination from '@/components/ui/Pagination';
import { usePagination } from '@/lib/use-pagination'; import { usePagination } from '@/lib/use-pagination';
import { formatCurrency, formatDateTime } from '@/lib/utils'; import { formatCurrency, formatDateTime } from '@/lib/utils';
import { useAuthStore } from '@/lib/auth-store'; 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 }) => ( const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
<div className="bg-muted/40 rounded-lg p-3"> <div className="bg-muted/40 rounded-lg p-3">
@@ -27,9 +30,13 @@ const SectionHeader = ({ title }: { title: string }) => (
</h3> </h3>
); );
export default function AgentsPage() { function AgentsPageContent() {
const { user } = useAuthStore(); const { user } = useAuthStore();
const queryClient = useQueryClient(); 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 [filters, setFilters] = useState({ search: '', active: '' });
const [selected, setSelected] = useState<any>(null); const [selected, setSelected] = useState<any>(null);
const [createModal, setCreateModal] = useState(false); const [createModal, setCreateModal] = useState(false);
@@ -131,6 +138,7 @@ export default function AgentsPage() {
const actions = [ const actions = [
{ {
label: 'Edit', label: 'Edit',
show: () => canEdit,
onClick: (agent: any) => openEditModal(agent), onClick: (agent: any) => openEditModal(agent),
variant: 'secondary' as const, variant: 'secondary' as const,
icon: Edit, icon: Edit,
@@ -143,6 +151,7 @@ export default function AgentsPage() {
}, },
{ {
label: 'Delete', label: 'Delete',
show: () => canDelete,
onClick: (agent: any) => { setDeleteError(null); setDeleteConfirm({ isOpen: true, agent }); }, onClick: (agent: any) => { setDeleteError(null); setDeleteConfirm({ isOpen: true, agent }); },
variant: 'danger' as const, variant: 'danger' as const,
icon: Trash2, icon: Trash2,
@@ -156,7 +165,7 @@ export default function AgentsPage() {
<h1 className="text-2xl font-bold">Agents</h1> <h1 className="text-2xl font-bold">Agents</h1>
<p className="text-muted-foreground">Manage agents and their operations</p> <p className="text-muted-foreground">Manage agents and their operations</p>
</div> </div>
<ActionButton icon={Plus} onClick={openCreateModal}>Add Agent</ActionButton> <ActionButton icon={Plus} onClick={openCreateModal} disabled={!canCreate} title={canCreate ? undefined : 'You do not have permission to create agents'}>Add Agent</ActionButton>
</div> </div>
<div className="card"> <div className="card">
@@ -394,3 +403,11 @@ export default function AgentsPage() {
</div> </div>
); );
} }
export default function AgentsPage() {
return (
<PermissionGuard permission={PERMS.agents.view}>
<AgentsPageContent />
</PermissionGuard>
);
}

View File

@@ -10,10 +10,12 @@ import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog'; import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { appReleasesApi } from '@/lib/api'; import { appReleasesApi } from '@/lib/api';
import { formatDateTime } from '@/lib/utils'; 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: '' }; const EMPTY_FORM = { os: 'android', version: '', forceUpdate: false, storeLink: '', notes: '' };
export default function AppReleasesPage() { function AppReleasesPageContent() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [formOpen, setFormOpen] = useState(false); const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<any>(null); const [editing, setEditing] = useState<any>(null);
@@ -184,3 +186,11 @@ export default function AppReleasesPage() {
</div> </div>
); );
} }
export default function AppReleasesPage() {
return (
<PermissionGuard permission={PERMS.admin}>
<AppReleasesPageContent />
</PermissionGuard>
);
}

View File

@@ -8,6 +8,9 @@ import { useAuthStore } from '@/lib/auth-store';
import { formatDateTime } from '@/lib/utils'; import { formatDateTime } from '@/lib/utils';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Header from '@/components/layout/Header'; 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 // Add QR Scanner component
function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onError: (error: string) => void }) { 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 [qrInput, setQrInput] = useState('');
const [lastScanned, setLastScanned] = useState<any>(null); const [lastScanned, setLastScanned] = useState<any>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -341,6 +344,8 @@ export default function BoardingPage() {
retry: false, retry: false,
}); });
const canBoard = useWritePermission(PERMS.tickets.board, PERMS.tickets.manage);
const boardingMutation = useMutation({ const boardingMutation = useMutation({
mutationFn: (qrCodeOrRef: string) => mutationFn: (qrCodeOrRef: string) =>
ticketsApi.scanAndBoard(qrCodeOrRef, { ticketsApi.scanAndBoard(qrCodeOrRef, {
@@ -482,7 +487,8 @@ export default function BoardingPage() {
<div className="flex gap-3"> <div className="flex gap-3">
<button <button
onClick={handleButtonClick} onClick={handleButtonClick}
disabled={boardingMutation.isPending || !qrInput.trim()} disabled={boardingMutation.isPending || !qrInput.trim() || !canBoard}
title={canBoard ? undefined : 'You do not have permission to board passengers'}
className="flex-1 bg-emerald-600 hover:bg-emerald-700 disabled:bg-gray-300 className="flex-1 bg-emerald-600 hover:bg-emerald-700 disabled:bg-gray-300
text-white font-semibold py-4 px-6 rounded-xl transition-colors text-white font-semibold py-4 px-6 rounded-xl transition-colors
disabled:cursor-not-allowed text-lg" disabled:cursor-not-allowed text-lg"
@@ -605,4 +611,12 @@ export default function BoardingPage() {
</div> </div>
</div> </div>
); );
} }
export default function BoardingPage() {
return (
<PermissionGuard permission={PERMS.tickets.board}>
<BoardingPageContent />
</PermissionGuard>
);
}

View File

@@ -9,7 +9,7 @@ import Pagination from '@/components/ui/Pagination';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
import { PermissionGuard } from '@/components/layout/PermissionGuard'; 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 { PERMS } from '@/lib/permissions';
import ConfirmDialog from '@/components/ui/ConfirmDialog'; import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { bookingsApi, apiClient } from '@/lib/api'; import { bookingsApi, apiClient } from '@/lib/api';
@@ -30,7 +30,7 @@ const SectionHeader = ({ title }: { title: string }) => (
); );
function BookingsPageContent() { function BookingsPageContent() {
const canManage = usePermission(PERMS.bookings.manage); const canDeleteBooking = useDeletePermission(PERMS.bookings.delete);
// Mirrors the API guard on POST /payments/:bookingId/force-confirm — // Mirrors the API guard on POST /payments/:bookingId/force-confirm —
// tickets:generate, with the usual super-admin / org-admin bypass. // tickets:generate, with the usual super-admin / org-admin bypass.
const canGenerateTicket = usePermission(PERMS.tickets.generate); const canGenerateTicket = usePermission(PERMS.tickets.generate);
@@ -302,7 +302,7 @@ function BookingsPageContent() {
const actions = [ const actions = [
{ label: 'View Details', onClick: (b: any) => setSelectedBooking(b), variant: 'secondary' as const, icon: Eye }, { 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: '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 ( return (

View File

@@ -12,6 +12,7 @@ import { seatClassesApi, apiClient } from '@/lib/api';
import { formatCurrency } from '@/lib/utils'; import { formatCurrency } from '@/lib/utils';
import { PermissionGuard } from '@/components/layout/PermissionGuard'; import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions'; import { PERMS } from '@/lib/permissions';
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
function ClassesPageContent() { function ClassesPageContent() {
const [filters, setFilters] = useState({ search: '' }); const [filters, setFilters] = useState({ search: '' });
@@ -21,6 +22,10 @@ function ClassesPageContent() {
const [selectedCoachTypeId, setSelectedCoachTypeId] = useState<string>(''); const [selectedCoachTypeId, setSelectedCoachTypeId] = useState<string>('');
const queryClient = useQueryClient(); 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({ const { data, isLoading } = useQuery({
queryKey: ['classes', filters], queryKey: ['classes', filters],
queryFn: () => seatClassesApi.getAll(), queryFn: () => seatClassesApi.getAll(),
@@ -180,12 +185,14 @@ function ClassesPageContent() {
const actions = [ const actions = [
{ {
label: 'Edit', label: 'Edit',
show: () => canEdit,
onClick: (cls: any) => handleOpenModal(cls), onClick: (cls: any) => handleOpenModal(cls),
variant: 'secondary' as const, variant: 'secondary' as const,
icon: Edit, icon: Edit,
}, },
{ {
label: 'Delete', label: 'Delete',
show: () => canDelete,
onClick: handleDelete, onClick: handleDelete,
variant: 'danger' as const, variant: 'danger' as const,
icon: Trash2, icon: Trash2,

View File

@@ -12,6 +12,7 @@ import Pagination from '@/components/ui/Pagination';
import { usePagination } from '@/lib/use-pagination'; import { usePagination } from '@/lib/use-pagination';
import { PermissionGuard } from '@/components/layout/PermissionGuard'; import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions'; import { PERMS } from '@/lib/permissions';
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
type Tab = 'types' | 'coaches'; type Tab = 'types' | 'coaches';
@@ -155,6 +156,10 @@ function CoachesPageContent() {
const queryClient = useQueryClient(); 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 // Coach Types Queries
const { data: coachTypesData, isLoading: typesLoading } = useQuery({ const { data: coachTypesData, isLoading: typesLoading } = useQuery({
queryKey: ['coach-types'], queryKey: ['coach-types'],
@@ -438,6 +443,7 @@ function CoachesPageContent() {
const coachTypeActions = [ const coachTypeActions = [
{ {
label: 'Edit', label: 'Edit',
show: () => canEdit,
onClick: (item: any) => { onClick: (item: any) => {
setEditingItem({ ...item, isCoachType: true }); setEditingItem({ ...item, isCoachType: true });
setShowModal(true); setShowModal(true);
@@ -447,6 +453,7 @@ function CoachesPageContent() {
}, },
{ {
label: 'Delete', label: 'Delete',
show: () => canDelete,
onClick: (item: any) => handleDelete(item, true), onClick: (item: any) => handleDelete(item, true),
variant: 'danger' as const, variant: 'danger' as const,
icon: Trash2, icon: Trash2,
@@ -456,6 +463,7 @@ function CoachesPageContent() {
const coachActions = [ const coachActions = [
{ {
label: 'Edit', label: 'Edit',
show: () => canEdit,
onClick: (item: any) => { onClick: (item: any) => {
setEditingItem({ ...item, isCoach: true }); setEditingItem({ ...item, isCoach: true });
setSelectedCoachTypeId(item.coachTypeId || ''); setSelectedCoachTypeId(item.coachTypeId || '');
@@ -467,6 +475,7 @@ function CoachesPageContent() {
}, },
{ {
label: 'Delete', label: 'Delete',
show: () => canDelete,
onClick: (item: any) => handleDelete(item, false), onClick: (item: any) => handleDelete(item, false),
variant: 'danger' as const, variant: 'danger' as const,
icon: Trash2, icon: Trash2,
@@ -489,6 +498,9 @@ function CoachesPageContent() {
setSearch(''); setSearch('');
setShowModal(true); setShowModal(true);
}} }}
disabled={!canCreate}
title={canCreate ? undefined : 'You do not have permission to create coaches'}
> >
{activeTab === 'types' ? 'Add Coach Type' : 'Add Coach'} {activeTab === 'types' ? 'Add Coach Type' : 'Add Coach'}
</ActionButton> </ActionButton>

View File

@@ -8,6 +8,8 @@ import Modal from '@/components/ui/Modal';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
import ConfirmDialog from '@/components/ui/ConfirmDialog'; import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { PERMS } from '@/lib/permissions';
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
interface ExchangeRate { interface ExchangeRate {
id: string; id: string;
@@ -35,6 +37,10 @@ export default function CurrenciesPage() {
const [deleteConfirm, setDeleteConfirm] = useState<ExchangeRate | null>(null); const [deleteConfirm, setDeleteConfirm] = useState<ExchangeRate | null>(null);
const queryClient = useQueryClient(); 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<ExchangeRate[]>({ const { data: rates = [], isLoading } = useQuery<ExchangeRate[]>({
queryKey: ['currencies'], queryKey: ['currencies'],
queryFn: () => apiClient.get('/currencies'), queryFn: () => apiClient.get('/currencies'),
@@ -124,12 +130,14 @@ export default function CurrenciesPage() {
const actions = [ const actions = [
{ {
label: 'Edit', label: 'Edit',
show: () => canEdit,
onClick: (r: ExchangeRate) => { setEditingRate(r); setRateInput(String(r.rate)); setError(null); }, onClick: (r: ExchangeRate) => { setEditingRate(r); setRateInput(String(r.rate)); setError(null); },
variant: 'secondary' as const, variant: 'secondary' as const,
icon: Edit, icon: Edit,
}, },
{ {
label: 'Delete', label: 'Delete',
show: () => canDelete,
onClick: (r: ExchangeRate) => setDeleteConfirm(r), onClick: (r: ExchangeRate) => setDeleteConfirm(r),
variant: 'danger' as const, variant: 'danger' as const,
icon: Trash2, icon: Trash2,
@@ -143,7 +151,7 @@ export default function CurrenciesPage() {
<h1 className="text-3xl font-bold text-foreground">Exchange Rates</h1> <h1 className="text-3xl font-bold text-foreground">Exchange Rates</h1>
<p className="text-muted-foreground mt-1">Manage currency exchange rates</p> <p className="text-muted-foreground mt-1">Manage currency exchange rates</p>
</div> </div>
<ActionButton icon={Plus} onClick={() => { setError(null); setShowAddModal(true); }}> <ActionButton icon={Plus} onClick={() => { setError(null); setShowAddModal(true); }} disabled={!canCreate} title={canCreate ? undefined : 'You do not have permission to create currency rates'}>
Add Rate Add Rate
</ActionButton> </ActionButton>
</div> </div>

View File

@@ -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 { Search, Layers, ChevronDown, ChevronUp, CheckSquare, Square, AlertCircle, CheckCircle2, X, Loader2 } from 'lucide-react';
import { seatsApi } from '@/lib/api'; import { seatsApi } from '@/lib/api';
import { formatDateTime } from '@/lib/utils'; import { formatDateTime } from '@/lib/utils';
import { useWritePermission } from '@/lib/use-permission';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
// ── Types ───────────────────────────────────────────────────────────────── // ── Types ─────────────────────────────────────────────────────────────────
@@ -300,9 +303,10 @@ interface CoachCardProps {
coach: CoachReport; coach: CoachReport;
schedule: ScheduleReport; schedule: ScheduleReport;
onResolve: () => void; onResolve: () => void;
canResolve?: boolean;
} }
function CoachCard({ coach, schedule, onResolve }: CoachCardProps) { function CoachCard({ coach, schedule, onResolve, canResolve = false }: CoachCardProps) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const hasDuplicates = coach.duplicates.length > 0; const hasDuplicates = coach.duplicates.length > 0;
@@ -330,7 +334,9 @@ function CoachCard({ coach, schedule, onResolve }: CoachCardProps) {
{hasDuplicates && ( {hasDuplicates && (
<button <button
onClick={onResolve} onClick={onResolve}
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-orange-500 text-white hover:bg-orange-600 transition-colors" disabled={!canResolve}
title={canResolve ? undefined : 'You do not have permission to resolve duplicate seats'}
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-orange-500 text-white hover:bg-orange-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
> >
Resolve Resolve
</button> </button>
@@ -379,7 +385,9 @@ function CoachCard({ coach, schedule, onResolve }: CoachCardProps) {
// ── Main page ───────────────────────────────────────────────────────────── // ── 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 [date, setDate] = useState(today());
const [searchDate, setSearchDate] = useState(''); const [searchDate, setSearchDate] = useState('');
const [resolveTarget, setResolveTarget] = useState<{ schedule: ScheduleReport; coach: CoachReport } | null>(null); const [resolveTarget, setResolveTarget] = useState<{ schedule: ScheduleReport; coach: CoachReport } | null>(null);
@@ -488,6 +496,7 @@ export default function DiscrepancyPage() {
coach={coach} coach={coach}
schedule={schedule} schedule={schedule}
onResolve={() => setResolveTarget({ schedule, coach })} onResolve={() => setResolveTarget({ schedule, coach })}
canResolve={canResolveDuplicates}
/> />
))} ))}
</div> </div>
@@ -517,3 +526,11 @@ export default function DiscrepancyPage() {
</div> </div>
); );
} }
export default function DiscrepancyPage() {
return (
<PermissionGuard permission={PERMS.seats.view}>
<DiscrepancyPageContent />
</PermissionGuard>
);
}

View File

@@ -10,6 +10,9 @@ import Modal from '@/components/ui/Modal';
import { excessBaggageApi, apiClient, bookingsApi } from '@/lib/api'; import { excessBaggageApi, apiClient, bookingsApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils'; import { formatDateTime, formatCurrency } from '@/lib/utils';
import { useAuthStore } from '@/lib/auth-store'; 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<string, any> = { const STATUS_VARIANT: Record<string, any> = {
PENDING: 'PENDING', PENDING: 'PENDING',
@@ -19,8 +22,11 @@ const STATUS_VARIANT: Record<string, any> = {
WAIVED: 'CANCELLED', WAIVED: 'CANCELLED',
}; };
export default function ExcessBaggagePage() { function ExcessBaggagePageContent() {
const queryClient = useQueryClient(); 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 [filters, setFilters] = useState({ status: '', bookingRef: '', dateFrom: '', dateTo: '', page: '1' });
const [showExtraFilters, setShowExtraFilters] = useState(false); const [showExtraFilters, setShowExtraFilters] = useState(false);
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
@@ -173,7 +179,7 @@ export default function ExcessBaggagePage() {
icon: Send, icon: Send,
variant: 'secondary' as const, variant: 'secondary' as const,
onClick: (c: any) => { setResendModal(c); setResendSuccess(false); setResendError(null); }, onClick: (c: any) => { setResendModal(c); setResendSuccess(false); setResendError(null); },
show: (c: any) => c.status === 'PENDING', show: (c: any) => canCharge && c.status === 'PENDING',
}, },
{ {
label: 'Waive', label: 'Waive',
@@ -202,7 +208,12 @@ export default function ExcessBaggagePage() {
<h1 className="text-2xl font-bold text-foreground">Excess Lugagge</h1> <h1 className="text-2xl font-bold text-foreground">Excess Lugagge</h1>
<p className="text-muted-foreground">Track and manage excess luggage charges at boarding</p> <p className="text-muted-foreground">Track and manage excess luggage charges at boarding</p>
</div> </div>
<ActionButton icon={Plus} onClick={() => { setLogModal(true); setLogError(null); setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false, paymentPhone: '' }); }}> <ActionButton
icon={Plus}
disabled={!canCharge}
title={canCharge ? undefined : 'You do not have permission to log a luggage charge'}
onClick={() => { setLogModal(true); setLogError(null); setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false, paymentPhone: '' }); }}
>
Log Excess Luggage Log Excess Luggage
</ActionButton> </ActionButton>
</div> </div>
@@ -421,3 +432,11 @@ export default function ExcessBaggagePage() {
</div> </div>
); );
} }
export default function ExcessBaggagePage() {
return (
<PermissionGuard permission={PERMS.bookings.view}>
<ExcessBaggagePageContent />
</PermissionGuard>
);
}

View File

@@ -9,6 +9,9 @@ import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
import { fraudApi } from '@/lib/api'; import { fraudApi } from '@/lib/api';
import { formatDateTime } from '@/lib/utils'; 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 }) => ( const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
<div className="bg-muted/40 rounded-lg p-3"> <div className="bg-muted/40 rounded-lg p-3">
@@ -30,11 +33,13 @@ const SEVERITY_GRAD: Record<string, string> = {
LOW: 'from-blue-500 to-blue-600', LOW: 'from-blue-500 to-blue-600',
}; };
export default function FraudDetectionPage() { function FraudDetectionPageContent() {
const [filters, setFilters] = useState({ search: '', severity: '', status: '' }); const [filters, setFilters] = useState({ search: '', severity: '', status: '' });
const [selected, setSelected] = useState<any>(null); const [selected, setSelected] = useState<any>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const canManageFraud = useWritePermission(PERMS.fraud.edit, PERMS.fraud.manage);
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['fraud-alerts', filters], queryKey: ['fraud-alerts', filters],
queryFn: () => fraudApi.getAlerts(filters), queryFn: () => fraudApi.getAlerts(filters),
@@ -130,10 +135,11 @@ export default function FraudDetectionPage() {
onClick: handleAcknowledge, onClick: handleAcknowledge,
variant: 'primary' as const, variant: 'primary' as const,
icon: CheckCircle, icon: CheckCircle,
show: (alert: any) => !alert.acknowledged, show: (alert: any) => canManageFraud && !alert.acknowledged,
}, },
{ {
label: 'Block User', label: 'Block User',
show: () => canManageFraud,
onClick: handleBlockUser, onClick: handleBlockUser,
variant: 'danger' as const, variant: 'danger' as const,
icon: Ban, icon: Ban,
@@ -301,3 +307,11 @@ export default function FraudDetectionPage() {
</div> </div>
); );
} }
export default function FraudDetectionPage() {
return (
<PermissionGuard permission={PERMS.fraud.view}>
<FraudDetectionPageContent />
</PermissionGuard>
);
}

View File

@@ -9,10 +9,12 @@ import Modal from '@/components/ui/Modal';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
import { notificationsApi } from '@/lib/api'; import { notificationsApi } from '@/lib/api';
import { formatDateTime } from '@/lib/utils'; import { formatDateTime } from '@/lib/utils';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
const CHANNEL_OPTIONS = ['EMAIL', 'SMS', 'PUSH', 'IN_APP']; const CHANNEL_OPTIONS = ['EMAIL', 'SMS', 'PUSH', 'IN_APP'];
export default function NotificationsPage() { function NotificationsPageContent() {
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [editing, setEditing] = useState<any | null>(null); const [editing, setEditing] = useState<any | null>(null);
const [templateError, setTemplateError] = useState<string | null>(null); const [templateError, setTemplateError] = useState<string | null>(null);
@@ -313,3 +315,11 @@ export default function NotificationsPage() {
</div> </div>
); );
} }
export default function NotificationsPage() {
return (
<PermissionGuard permission={PERMS.notifications.send}>
<NotificationsPageContent />
</PermissionGuard>
);
}

View File

@@ -9,8 +9,10 @@ import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
import { reportsApi } from '@/lib/api'; import { reportsApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils'; 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 [filters, setFilters] = useState({ search: '', reportType: '' });
const [selectedReport, setSelectedReport] = useState<any>(null); const [selectedReport, setSelectedReport] = useState<any>(null);
const [showDetailsModal, setShowDetailsModal] = useState(false); const [showDetailsModal, setShowDetailsModal] = useState(false);
@@ -465,3 +467,11 @@ export default function OperationalReportsPage() {
</div> </div>
); );
} }
export default function OperationalReportsPage() {
return (
<PermissionGuard permission={[PERMS.reports.catalog.view, PERMS.reports.view]}>
<OperationalReportsPageContent />
</PermissionGuard>
);
}

View File

@@ -11,6 +11,8 @@ import Pagination from '@/components/ui/Pagination';
import { packagesApi } from '@/lib/api'; import { packagesApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils'; import { formatDateTime, formatCurrency } from '@/lib/utils';
import { getErrorMessage } from '@/lib/api-client'; 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 }) => ( const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
<div className="bg-muted/40 rounded-lg p-3"> <div className="bg-muted/40 rounded-lg p-3">
@@ -25,7 +27,7 @@ const SectionHeader = ({ title }: { title: string }) => (
</h3> </h3>
); );
export default function PackageBookingsPage() { function PackageBookingsPageContent() {
const [filters, setFilters] = useState({ packageId: '', status: '', page: 1, pageSize: 20 }); const [filters, setFilters] = useState({ packageId: '', status: '', page: 1, pageSize: 20 });
const [selected, setSelected] = useState<any>(null); const [selected, setSelected] = useState<any>(null);
@@ -264,3 +266,11 @@ export default function PackageBookingsPage() {
</div> </div>
); );
} }
export default function PackageBookingsPage() {
return (
<PermissionGuard permission={PERMS.packages.view}>
<PackageBookingsPageContent />
</PermissionGuard>
);
}

View File

@@ -9,6 +9,9 @@ import ActionButton from '@/components/ui/ActionButton';
import ConfirmDialog from '@/components/ui/ConfirmDialog'; import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { packageInquiriesApi, packagesApi } from '@/lib/api'; import { packageInquiriesApi, packagesApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils'; 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']; const STATUSES = ['NEW', 'CONTACTED', 'CONVERTED', 'CLOSED'];
@@ -19,12 +22,15 @@ const statusVariant: Record<string, string> = {
CLOSED: 'default', CLOSED: 'default',
}; };
export default function PackageInquiriesPage() { function PackageInquiriesPageContent() {
const [filters, setFilters] = useState({ packageId: '', status: '' }); const [filters, setFilters] = useState({ packageId: '', status: '' });
const [deleteConfirm, setDeleteConfirm] = useState<any>(null); const [deleteConfirm, setDeleteConfirm] = useState<any>(null);
const [deleteError, setDeleteError] = useState<string | null>(null); const [deleteError, setDeleteError] = useState<string | null>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const canEdit = useWritePermission(PERMS.inquiries.edit, PERMS.inquiries.manage);
const canDelete = useDeletePermission(PERMS.inquiries.delete);
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['package-inquiries', filters], queryKey: ['package-inquiries', filters],
queryFn: () => packageInquiriesApi.getAll({ ...filters, pageSize: 50 }), queryFn: () => packageInquiriesApi.getAll({ ...filters, pageSize: 50 }),
@@ -112,6 +118,8 @@ export default function PackageInquiriesPage() {
<select <select
className="input py-1 text-xs" className="input py-1 text-xs"
value={row.status} value={row.status}
disabled={!canEdit}
title={canEdit ? undefined : 'You do not have permission to change an inquiry status'}
onChange={(e) => statusMutation.mutate({ id: row.id, status: e.target.value })} onChange={(e) => statusMutation.mutate({ id: row.id, status: e.target.value })}
> >
{STATUSES.map((s) => ( {STATUSES.map((s) => (
@@ -125,6 +133,7 @@ export default function PackageInquiriesPage() {
const actions = [ const actions = [
{ {
label: 'Delete', label: 'Delete',
show: () => canDelete,
onClick: (row: any) => { setDeleteConfirm(row); setDeleteError(null); }, onClick: (row: any) => { setDeleteConfirm(row); setDeleteError(null); },
variant: 'danger' as const, variant: 'danger' as const,
icon: Trash2, icon: Trash2,
@@ -191,3 +200,11 @@ export default function PackageInquiriesPage() {
</div> </div>
); );
} }
export default function PackageInquiriesPage() {
return (
<PermissionGuard permission={PERMS.inquiries.view}>
<PackageInquiriesPageContent />
</PermissionGuard>
);
}

View File

@@ -11,6 +11,9 @@ import Modal from '@/components/ui/Modal';
import { packagesApi, stationsApi, schedulesApi, seatClassesApi } from '@/lib/api'; import { packagesApi, stationsApi, schedulesApi, seatClassesApi } from '@/lib/api';
import { getErrorMessage } from '@/lib/api-client'; import { getErrorMessage } from '@/lib/api-client';
import { formatDateTime, formatCurrency } from '@/lib/utils'; import { formatDateTime, formatCurrency } from '@/lib/utils';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
// Mirrors the backend's own limits (packages/package-image-upload.options.ts) so a bad file is // Mirrors the backend's own limits (packages/package-image-upload.options.ts) so a bad file is
// rejected instantly client-side instead of round-tripping to the server first. // rejected instantly client-side instead of round-tripping to the server first.
@@ -33,7 +36,7 @@ const emptyForm = {
validFrom: '', validUntil: '', validFrom: '', validUntil: '',
}; };
export default function PackagesPage() { function PackagesPageContent() {
const [page] = useState(1); const [page] = useState(1);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState(''); const [statusFilter, setStatusFilter] = useState('');
@@ -65,6 +68,11 @@ export default function PackagesPage() {
const [removeImageConfirm, setRemoveImageConfirm] = useState<any>(null); const [removeImageConfirm, setRemoveImageConfirm] = useState<any>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const canCreate = useWritePermission(PERMS.packages.create, PERMS.packages.manage);
const canEdit = useWritePermission(PERMS.packages.edit, PERMS.packages.manage);
const canPublish = useWritePermission(PERMS.packages.publish, PERMS.packages.manage);
const canDelete = useDeletePermission(PERMS.packages.delete);
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['packages', page], queryKey: ['packages', page],
queryFn: () => packagesApi.getAll({ page, pageSize: 20 }), queryFn: () => packagesApi.getAll({ page, pageSize: 20 }),
@@ -368,23 +376,25 @@ export default function PackagesPage() {
const actions = [ const actions = [
{ label: 'View', onClick: (p: any) => setViewPackage(p), variant: 'secondary' as const, icon: Eye }, { label: 'View', onClick: (p: any) => setViewPackage(p), variant: 'secondary' as const, icon: Eye },
{ label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit }, { label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit, show: () => canEdit },
{ {
label: 'Tiers', icon: Layers, variant: 'secondary' as const, label: 'Tiers', icon: Layers, variant: 'secondary' as const,
show: () => canEdit,
onClick: (p: any) => { setTiersPackage(p); setEditingTier(null); setTierForm({ seatClassId: '', seatType: '', label: '', priceMinor: '', availableSeats: '' }); setTierError(null); }, onClick: (p: any) => { setTiersPackage(p); setEditingTier(null); setTierForm({ seatClassId: '', seatType: '', label: '', priceMinor: '', availableSeats: '' }); setTierError(null); },
}, },
{ {
label: 'Activate', icon: CheckCircle, variant: 'primary' as const, label: 'Activate', icon: CheckCircle, variant: 'primary' as const,
onClick: (p: any) => setActivateConfirm(p), onClick: (p: any) => setActivateConfirm(p),
show: (p: any) => p.status !== 'ACTIVE', show: (p: any) => canPublish && p.status !== 'ACTIVE',
}, },
{ {
label: 'Deactivate', icon: CheckCircle, variant: 'secondary' as const, label: 'Deactivate', icon: CheckCircle, variant: 'secondary' as const,
onClick: (p: any) => setDeactivateConfirm(p), onClick: (p: any) => setDeactivateConfirm(p),
show: (p: any) => p.status === 'ACTIVE', show: (p: any) => canPublish && p.status === 'ACTIVE',
}, },
{ {
label: 'Delete', icon: Trash2, variant: 'danger' as const, label: 'Delete', icon: Trash2, variant: 'danger' as const,
show: () => canDelete,
onClick: (p: any) => { setDeletePackageError(null); setDeletePackageCascade(false); setDeletePackageConfirm(p); }, onClick: (p: any) => { setDeletePackageError(null); setDeletePackageCascade(false); setDeletePackageConfirm(p); },
}, },
]; ];
@@ -407,7 +417,7 @@ export default function PackagesPage() {
<h1 className="text-2xl font-bold text-foreground">Packages</h1> <h1 className="text-2xl font-bold text-foreground">Packages</h1>
<p className="text-muted-foreground">Manage travel packages and pilgrimages</p> <p className="text-muted-foreground">Manage travel packages and pilgrimages</p>
</div> </div>
<ActionButton icon={Plus} onClick={openCreate}>New Package</ActionButton> <ActionButton icon={Plus} onClick={openCreate} disabled={!canCreate} title={canCreate ? undefined : 'You do not have permission to create packages'}>New Package</ActionButton>
</div> </div>
{/* The package itself may already be saved and this modal closed by the time an image {/* The package itself may already be saved and this modal closed by the time an image
@@ -827,3 +837,11 @@ export default function PackagesPage() {
</div> </div>
); );
} }
export default function PackagesPage() {
return (
<PermissionGuard permission={PERMS.packages.view}>
<PackagesPageContent />
</PermissionGuard>
);
}

View File

@@ -12,6 +12,9 @@ import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { passengersApi, apiClient } from '@/lib/api'; import { passengersApi, apiClient } from '@/lib/api';
import { formatDate, formatDateTime } from '@/lib/utils'; import { formatDate, formatDateTime } from '@/lib/utils';
import { PassengerFilters } from '@/types'; import { PassengerFilters } from '@/types';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
import { useDeletePermission } from '@/lib/use-permission';
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => ( const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
<div className="bg-muted/40 rounded-lg p-3"> <div className="bg-muted/40 rounded-lg p-3">
@@ -33,7 +36,7 @@ const TIER_COLORS: Record<string, string> = {
PLATINUM: 'bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-400 border-indigo-200 dark:border-indigo-800', PLATINUM: 'bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-400 border-indigo-200 dark:border-indigo-800',
}; };
export default function PassengersPage() { function PassengersPageContent() {
const [filters, setFilters] = useState<PassengerFilters>({ page: 1, pageSize: 20, search: '', role: 'PASSENGER' }); const [filters, setFilters] = useState<PassengerFilters>({ page: 1, pageSize: 20, search: '', role: 'PASSENGER' });
const [showExtraFilters, setShowExtraFilters] = useState(false); const [showExtraFilters, setShowExtraFilters] = useState(false);
const [extraFilters, setExtraFilters] = useState({ gender: '', nationality: '', dateFrom: '', dateTo: '' }); const [extraFilters, setExtraFilters] = useState({ gender: '', nationality: '', dateFrom: '', dateTo: '' });
@@ -51,6 +54,8 @@ export default function PassengersPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const canDelete = useDeletePermission(PERMS.passengers.delete);
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: ({ id, cascade }: { id: string; cascade: boolean }) => passengersApi.delete(id, cascade), mutationFn: ({ id, cascade }: { id: string; cascade: boolean }) => passengersApi.delete(id, cascade),
onSuccess: () => { onSuccess: () => {
@@ -156,7 +161,7 @@ export default function PassengersPage() {
const actions = [ const actions = [
{ label: 'View Details', onClick: (p: any) => setSelectedPassenger(p), variant: 'secondary' as const, icon: Eye }, { label: 'View Details', onClick: (p: any) => setSelectedPassenger(p), variant: 'secondary' as const, icon: Eye },
{ label: 'Delete', onClick: (p: any) => { setDeleteError(null); setDeleteCascade(false); setDeleteConfirm({ isOpen: true, passenger: p }); }, variant: 'danger' as const, icon: Trash2 }, { label: 'Delete', onClick: (p: any) => { setDeleteError(null); setDeleteCascade(false); setDeleteConfirm({ isOpen: true, passenger: p }); }, variant: 'danger' as const, icon: Trash2, show: () => canDelete },
]; ];
return ( return (
@@ -448,3 +453,11 @@ export default function PassengersPage() {
</div> </div>
); );
} }
export default function PassengersPage() {
return (
<PermissionGuard permission={PERMS.passengers.view}>
<PassengersPageContent />
</PermissionGuard>
);
}

View File

@@ -10,9 +10,10 @@ import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog'; import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { apiClient, paymentsApi } from '@/lib/api'; import { apiClient, paymentsApi } from '@/lib/api';
import { usePermission } from '@/lib/use-permission'; import { usePermission } from '@/lib/use-permission';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions'; import { PERMS } from '@/lib/permissions';
export default function PaymentMethodsPage() { function PaymentMethodsPageContent() {
const canManageMethods = usePermission(PERMS.paymentMethods.manage); const canManageMethods = usePermission(PERMS.paymentMethods.manage);
const canManageAdmin = usePermission(PERMS.admin); const canManageAdmin = usePermission(PERMS.admin);
const canManage = canManageMethods || canManageAdmin; const canManage = canManageMethods || canManageAdmin;
@@ -417,4 +418,12 @@ export default function PaymentMethodsPage() {
/> />
</div> </div>
); );
} }
export default function PaymentMethodsPage() {
return (
<PermissionGuard permission={PERMS.paymentMethods.view}>
<PaymentMethodsPageContent />
</PermissionGuard>
);
}

View File

@@ -14,6 +14,9 @@ import Pagination from '@/components/ui/Pagination';
import { usePagination } from '@/lib/use-pagination'; import { usePagination } from '@/lib/use-pagination';
import { formatDateTime, formatCurrency } from '@/lib/utils'; import { formatDateTime, formatCurrency } from '@/lib/utils';
import SupplementaryChargesModal from './SupplementaryChargesModal'; import SupplementaryChargesModal from './SupplementaryChargesModal';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
import { import {
useSupplementaryCharges, useSupplementaryCharges,
useMarkSupplementaryPaid, useMarkSupplementaryPaid,
@@ -120,6 +123,10 @@ function PaymentsPageContent() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// Raising a supplementary charge bills a passenger and sends a pay link — its own grant.
const canRaiseCharge = useWritePermission(PERMS.payments.supplementary, PERMS.payments.manage);
const canDelete = useDeletePermission(PERMS.payments.delete);
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (id: string) => apiClient.delete(`/payments/${id}`), mutationFn: (id: string) => apiClient.delete(`/payments/${id}`),
onSuccess: () => { onSuccess: () => {
@@ -209,7 +216,7 @@ function PaymentsPageContent() {
const paymentActions = [ const paymentActions = [
{ label: 'View Details', onClick: (p: any) => setSelectedPayment(p), variant: 'secondary' as const, icon: Eye }, { label: 'View Details', onClick: (p: any) => setSelectedPayment(p), variant: 'secondary' as const, icon: Eye },
{ label: 'Delete', onClick: (p: any) => { setDeleteError(null); setPaymentToDelete(p); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2 }, { label: 'Delete', onClick: (p: any) => { setDeleteError(null); setPaymentToDelete(p); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2, show: () => canDelete },
]; ];
return ( return (
@@ -221,7 +228,7 @@ function PaymentsPageContent() {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{pageTab === 'supplementary' && ( {pageTab === 'supplementary' && (
<ActionButton icon={AlertCircle} variant="secondary" onClick={() => setSupplementaryOpen(true)}>Raise Charge</ActionButton> <ActionButton icon={AlertCircle} variant="secondary" onClick={() => setSupplementaryOpen(true)} disabled={!canRaiseCharge} title={canRaiseCharge ? undefined : 'You do not have permission to raise a supplementary charge'}>Raise Charge</ActionButton>
)} )}
{pageTab === 'payments' && ( {pageTab === 'payments' && (
<ActionButton icon={Download} variant="export" onClick={() => setExportModalOpen(true)}>Export</ActionButton> <ActionButton icon={Download} variant="export" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
@@ -531,8 +538,10 @@ function PaymentsPageContent() {
export default function PaymentsPage() { export default function PaymentsPage() {
return ( return (
<Suspense fallback={null}> <PermissionGuard permission={PERMS.payments.view}>
<PaymentsPageContent /> <Suspense fallback={null}>
</Suspense> <PaymentsPageContent />
</Suspense>
</PermissionGuard>
); );
} }

View File

@@ -45,6 +45,8 @@ import { usePagination } from "@/lib/use-pagination";
import { formatCurrency, formatDateTime } from "@/lib/utils"; import { formatCurrency, formatDateTime } from "@/lib/utils";
import { categoricalColor, getChartPalette } from "@/lib/chart-palette"; import { categoricalColor, getChartPalette } from "@/lib/chart-palette";
import { useTheme } from "@/lib/theme-store"; import { useTheme } from "@/lib/theme-store";
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
interface RouteOption { interface RouteOption {
id: string; id: string;
@@ -71,7 +73,7 @@ function reasonLabel(category: string | null): string {
const TABLE_PAGE_SIZE = 25; const TABLE_PAGE_SIZE = 25;
export default function BlockedSeatRevenueLossPage() { function BlockedSeatRevenueLossPageContent() {
const isDark = useTheme((s) => s.isDark); const isDark = useTheme((s) => s.isDark);
const palette = getChartPalette(isDark); const palette = getChartPalette(isDark);
@@ -917,3 +919,11 @@ function BlockDetailTable({ blocks }: { blocks: BlockedSeatLossDetail[] }) {
</div> </div>
); );
} }
export default function BlockedSeatRevenueLossPage() {
return (
<PermissionGuard permission={[PERMS.reports.blockedSeats.view, PERMS.reports.view]}>
<BlockedSeatRevenueLossPageContent />
</PermissionGuard>
);
}

View File

@@ -9,6 +9,8 @@ import ActionButton from "@/components/ui/ActionButton";
import { formatDateTime } from "@/lib/utils"; import { formatDateTime } from "@/lib/utils";
import Pagination from "@/components/ui/Pagination"; import Pagination from "@/components/ui/Pagination";
import { usePagination } from "@/lib/use-pagination"; import { usePagination } from "@/lib/use-pagination";
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
interface ScheduleOption { interface ScheduleOption {
id: string; id: string;
@@ -50,7 +52,7 @@ interface BoardingReport {
type Tab = "summary" | "details"; type Tab = "summary" | "details";
export default function BoardingReportPage() { function BoardingReportPageContent() {
const [scheduleId, setScheduleId] = useState(""); const [scheduleId, setScheduleId] = useState("");
const [tab, setTab] = useState<Tab>("summary"); const [tab, setTab] = useState<Tab>("summary");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@@ -380,3 +382,11 @@ export default function BoardingReportPage() {
</div> </div>
); );
} }
export default function BoardingReportPage() {
return (
<PermissionGuard permission={[PERMS.reports.boarding.view, PERMS.reports.view]}>
<BoardingReportPageContent />
</PermissionGuard>
);
}

View File

@@ -7,6 +7,8 @@ import { apiClient } from "@/lib/api-client";
import ActionButton from "@/components/ui/ActionButton"; import ActionButton from "@/components/ui/ActionButton";
import Pagination from "@/components/ui/Pagination"; import Pagination from "@/components/ui/Pagination";
import { usePagination } from "@/lib/use-pagination"; import { usePagination } from "@/lib/use-pagination";
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
interface ScheduleOption { interface ScheduleOption {
id: string; id: string;
@@ -28,7 +30,7 @@ interface CoachUtilizationRow {
totalBookings: number; totalBookings: number;
} }
export default function CoachUtilizationReportPage() { function CoachUtilizationReportPageContent() {
const [scheduleId, setScheduleId] = useState(""); const [scheduleId, setScheduleId] = useState("");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@@ -360,3 +362,11 @@ export default function CoachUtilizationReportPage() {
</div> </div>
); );
} }
export default function CoachUtilizationReportPage() {
return (
<PermissionGuard permission={[PERMS.reports.coachUtilization.view, PERMS.reports.view]}>
<CoachUtilizationReportPageContent />
</PermissionGuard>
);
}

View File

@@ -18,6 +18,8 @@ import { usePagination } from '@/lib/use-pagination';
import { formatCurrency } from '@/lib/utils'; import { formatCurrency } from '@/lib/utils';
import { categoricalColor, getChartPalette } from '@/lib/chart-palette'; import { categoricalColor, getChartPalette } from '@/lib/chart-palette';
import { useTheme } from '@/lib/theme-store'; import { useTheme } from '@/lib/theme-store';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
interface StationOption { interface StationOption {
id: string; id: string;
@@ -101,7 +103,7 @@ function FinanceReportSkeleton() {
); );
} }
export default function FinanceReportPage() { function FinanceReportPageContent() {
const isDark = useTheme((s) => s.isDark); const isDark = useTheme((s) => s.isDark);
const palette = getChartPalette(isDark); const palette = getChartPalette(isDark);
@@ -558,3 +560,11 @@ export default function FinanceReportPage() {
</div> </div>
); );
} }
export default function FinanceReportPage() {
return (
<PermissionGuard permission={[PERMS.reports.finance.view, PERMS.reports.view]}>
<FinanceReportPageContent />
</PermissionGuard>
);
}

View File

@@ -12,6 +12,8 @@ import { apiClient } from '@/lib/api-client';
import { formatCurrency } from '@/lib/utils'; import { formatCurrency } from '@/lib/utils';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
const STATUS_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444']; const STATUS_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444'];
@@ -19,7 +21,7 @@ function esc(s: string) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
} }
export default function ReportsPage() { function ReportsPageContent() {
const [dateRange, setDateRange] = useState('30'); const [dateRange, setDateRange] = useState('30');
const [startDate, setStartDate] = useState(''); const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState(''); const [endDate, setEndDate] = useState('');
@@ -709,3 +711,11 @@ export default function ReportsPage() {
</div> </div>
); );
} }
export default function ReportsPage() {
return (
<PermissionGuard permission={[PERMS.reports.overall.view, PERMS.reports.view]}>
<ReportsPageContent />
</PermissionGuard>
);
}

View File

@@ -9,6 +9,8 @@ import { formatDateTime } from "@/lib/utils";
import ActionButton from "@/components/ui/ActionButton"; import ActionButton from "@/components/ui/ActionButton";
import Pagination from "@/components/ui/Pagination"; import Pagination from "@/components/ui/Pagination";
import { usePagination } from "@/lib/use-pagination"; import { usePagination } from "@/lib/use-pagination";
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
interface ScheduleOption { interface ScheduleOption {
id: string; id: string;
@@ -70,7 +72,7 @@ interface PassengerRow {
type Tab = "occupancy" | "list"; type Tab = "occupancy" | "list";
export default function PassengersReportPage() { function PassengersReportPageContent() {
const [scheduleId, setScheduleId] = useState(""); const [scheduleId, setScheduleId] = useState("");
const [tab, setTab] = useState<Tab>("occupancy"); const [tab, setTab] = useState<Tab>("occupancy");
const [listSearch, setListSearch] = useState(""); const [listSearch, setListSearch] = useState("");
@@ -592,3 +594,11 @@ export default function PassengersReportPage() {
</div> </div>
); );
} }
export default function PassengersReportPage() {
return (
<PermissionGuard permission={[PERMS.reports.passengers.view, PERMS.reports.view]}>
<PassengersReportPageContent />
</PermissionGuard>
);
}

View File

@@ -11,6 +11,8 @@ import DatePicker from '@/components/ui/DatePicker';
import { parse, isValid } from 'date-fns'; import { parse, isValid } from 'date-fns';
import Pagination from '@/components/ui/Pagination'; import Pagination from '@/components/ui/Pagination';
import { usePagination } from '@/lib/use-pagination'; import { usePagination } from '@/lib/use-pagination';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
// ── Types ───────────────────────────────────────────────────────────────────── // ── Types ─────────────────────────────────────────────────────────────────────
@@ -121,7 +123,7 @@ function BalanceBadge({ row }: { row: DiscrepancyRow }) {
type Applied = { from: string; to: string; sortBy: string; search: string }; type Applied = { from: string; to: string; sortBy: string; search: string };
export default function PaymentDiscrepancyPage() { function PaymentDiscrepancyPageContent() {
const [from, setFrom] = useState(''); const [from, setFrom] = useState('');
const [to, setTo] = useState(''); const [to, setTo] = useState('');
const [sortBy, setSortBy] = useState<'balance' | 'departure'>('balance'); const [sortBy, setSortBy] = useState<'balance' | 'departure'>('balance');
@@ -479,3 +481,11 @@ export default function PaymentDiscrepancyPage() {
</div> </div>
); );
} }
export default function PaymentDiscrepancyPage() {
return (
<PermissionGuard permission={[PERMS.reports.payments.view, PERMS.reports.view]}>
<PaymentDiscrepancyPageContent />
</PermissionGuard>
);
}

View File

@@ -7,6 +7,8 @@ import {
ChevronDown, ChevronUp, Loader2, ChevronLeft, ChevronRight, ChevronDown, ChevronUp, Loader2, ChevronLeft, ChevronRight,
} from 'lucide-react'; } from 'lucide-react';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
// ── Types ───────────────────────────────────────────────────────────────────── // ── Types ─────────────────────────────────────────────────────────────────────
@@ -101,7 +103,7 @@ function downloadCsv(csv: string, filename: string) {
// ── Discrepancy page ────────────────────────────────────────────────────────── // ── Discrepancy page ──────────────────────────────────────────────────────────
export default function PaymentsReportPage() { function PaymentsReportPageContent() {
const [scheduleId, setScheduleId] = useState(''); const [scheduleId, setScheduleId] = useState('');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [seatClass, setSeatClass] = useState(''); const [seatClass, setSeatClass] = useState('');
@@ -355,3 +357,11 @@ export default function PaymentsReportPage() {
</div> </div>
); );
} }
export default function PaymentsReportPage() {
return (
<PermissionGuard permission={[PERMS.reports.payments.view, PERMS.reports.view]}>
<PaymentsReportPageContent />
</PermissionGuard>
);
}

View File

@@ -10,6 +10,8 @@ import ActionButton from "@/components/ui/ActionButton";
import { formatDateTime, formatCurrency } from "@/lib/utils"; import { formatDateTime, formatCurrency } from "@/lib/utils";
import Pagination from "@/components/ui/Pagination"; import Pagination from "@/components/ui/Pagination";
import { usePagination } from "@/lib/use-pagination"; import { usePagination } from "@/lib/use-pagination";
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
interface ScheduleOption { interface ScheduleOption {
id: string; id: string;
@@ -56,7 +58,7 @@ interface SeatStatusReport {
type Tab = "seats" | "blocked"; type Tab = "seats" | "blocked";
export default function SeatStatusReportPage() { function SeatStatusReportPageContent() {
const [scheduleId, setScheduleId] = useState(""); const [scheduleId, setScheduleId] = useState("");
const [tab, setTab] = useState<Tab>("seats"); const [tab, setTab] = useState<Tab>("seats");
const [statusFilter, setStatusFilter] = useState<"ALL" | "PAID" | "UNPAID">("ALL"); const [statusFilter, setStatusFilter] = useState<"ALL" | "PAID" | "UNPAID">("ALL");
@@ -383,3 +385,11 @@ export default function SeatStatusReportPage() {
</div> </div>
); );
} }
export default function SeatStatusReportPage() {
return (
<PermissionGuard permission={[PERMS.reports.seatStatus.view, PERMS.reports.view]}>
<SeatStatusReportPageContent />
</PermissionGuard>
);
}

View File

@@ -6,12 +6,12 @@ import { PERMS } from '@/lib/permissions';
/** /**
* Master Data → Reschedule Policies. One policy per fare class (coach type); a class with no * Master Data → Reschedule Policies. One policy per fare class (coach type); a class with no
* policy cannot be rescheduled at all. Gated on bookings:view because that is what * policy cannot be rescheduled at all. Gated on reschedule_policies:view (or :manage) — bookings:view no longer
* `GET /reschedule/policies` requires; creating, editing and deleting are admin-only server-side. * grants it, so the page needs its own grant. Create/edit/delete each have their own key.
*/ */
export default function ReschedulePoliciesPage() { export default function ReschedulePoliciesPage() {
return ( return (
<PermissionGuard permission={PERMS.bookings.view}> <PermissionGuard permission={[PERMS.reschedulePolicies.view, PERMS.reschedulePolicies.manage]}>
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-3xl font-bold text-foreground">Reschedule Policies</h1> <h1 className="text-3xl font-bold text-foreground">Reschedule Policies</h1>

View File

@@ -13,6 +13,7 @@ import { stationsApi, fleetApi, routeCoachTemplatesApi } from '@/lib/api';
import { eatLocalToISO, isoToEATLocal } from '@/lib/timezone'; import { eatLocalToISO, isoToEATLocal } from '@/lib/timezone';
import { PermissionGuard } from '@/components/layout/PermissionGuard'; import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions'; import { PERMS } from '@/lib/permissions';
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
interface RouteStop { interface RouteStop {
stationId: string; stationId: string;
@@ -193,6 +194,10 @@ function RoutesPageContent() {
}, [error]); }, [error]);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const canCreate = useWritePermission(PERMS.routes.create, PERMS.routes.manage);
const canEdit = useWritePermission(PERMS.routes.edit, PERMS.routes.manage);
const canDelete = useDeletePermission(PERMS.routes.delete);
const { data: routes, isLoading: routesLoading } = useQuery({ const { data: routes, isLoading: routesLoading } = useQuery({
queryKey: ['routes'], queryKey: ['routes'],
queryFn: async () => { queryFn: async () => {
@@ -445,12 +450,14 @@ function RoutesPageContent() {
const routeActions = [ const routeActions = [
{ {
label: 'Edit', label: 'Edit',
show: () => canEdit,
onClick: openEditModal, onClick: openEditModal,
variant: 'secondary' as const, variant: 'secondary' as const,
icon: Edit, icon: Edit,
}, },
{ {
label: 'Delete', label: 'Delete',
show: () => canDelete,
onClick: handleDelete, onClick: handleDelete,
variant: 'danger' as const, variant: 'danger' as const,
icon: Trash2, icon: Trash2,
@@ -479,6 +486,9 @@ function RoutesPageContent() {
setError(null); setError(null);
setShowModal(true); setShowModal(true);
}} }}
disabled={!canCreate}
title={canCreate ? undefined : 'You do not have permission to create routes'}
> >
Add Route Add Route
</ActionButton> </ActionButton>

View File

@@ -14,6 +14,7 @@ import { usePagination } from '@/lib/use-pagination';
import { formatDateTime } from '@/lib/utils'; import { formatDateTime } from '@/lib/utils';
import { eatLocalToISO, isoToEATLocal } from '@/lib/timezone'; import { eatLocalToISO, isoToEATLocal } from '@/lib/timezone';
import DateTimePicker from '@/components/ui/DateTimePicker'; import DateTimePicker from '@/components/ui/DateTimePicker';
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
import { PermissionGuard } from '@/components/layout/PermissionGuard'; import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions'; import { PERMS } from '@/lib/permissions';
@@ -80,6 +81,13 @@ function SchedulesPageContent() {
const errorBannerRef = useRef<HTMLDivElement>(null); const errorBannerRef = useRef<HTMLDivElement>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// Mirrors the API guards exactly: create/edit/cancel accept the `:manage` umbrella,
// delete does not (see PassengerWrite / PassengerDelete in the API).
const canCreate = useWritePermission(PERMS.schedules.create, PERMS.schedules.manage);
const canEdit = useWritePermission(PERMS.schedules.edit, PERMS.schedules.manage);
const canCancel = useWritePermission(PERMS.schedules.cancel, PERMS.schedules.manage);
const canDelete = useDeletePermission(PERMS.schedules.delete);
// These modals can scroll internally — a submit failure can land silently off-screen with no // These modals can scroll internally — a submit failure can land silently off-screen with no
// visible indication anything went wrong. Scroll the banner into view when a new error appears. // visible indication anything went wrong. Scroll the banner into view when a new error appears.
useEffect(() => { useEffect(() => {
@@ -536,12 +544,16 @@ function SchedulesPageContent() {
const [delayMinutesInput, setDelayMinutesInput] = useState(''); const [delayMinutesInput, setDelayMinutesInput] = useState('');
const [delayError, setDelayError] = useState<string | null>(null); const [delayError, setDelayError] = useState<string | null>(null);
// `hidden:` was silently doing nothing — DataTable's Action type only reads
// `show` — so Report Delay and Cancel were rendering on already-cancelled
// schedules. Folded into `show` alongside the permission check.
const scheduleActions = [ const scheduleActions = [
{ {
label: 'Edit', label: 'Edit',
onClick: handleEditClick, onClick: handleEditClick,
variant: 'secondary' as const, variant: 'secondary' as const,
icon: Edit, icon: Edit,
show: () => canEdit,
}, },
{ {
label: 'Report Delay', label: 'Report Delay',
@@ -552,20 +564,21 @@ function SchedulesPageContent() {
}, },
variant: 'secondary' as const, variant: 'secondary' as const,
icon: Clock, icon: Clock,
hidden: (schedule: Schedule) => schedule.status === 'CANCELLED', show: (schedule: Schedule) => canEdit && schedule.status !== 'CANCELLED',
}, },
{ {
label: 'Cancel', label: 'Cancel',
onClick: (schedule: Schedule) => setCancelConfirm({ isOpen: true, item: schedule }), onClick: (schedule: Schedule) => setCancelConfirm({ isOpen: true, item: schedule }),
variant: 'danger' as const, variant: 'danger' as const,
icon: X, icon: X,
hidden: (schedule: Schedule) => schedule.status === 'CANCELLED', show: (schedule: Schedule) => canCancel && schedule.status !== 'CANCELLED',
}, },
{ {
label: 'Delete', label: 'Delete',
onClick: handleDelete, onClick: handleDelete,
variant: 'danger' as const, variant: 'danger' as const,
icon: Trash2, icon: Trash2,
show: () => canDelete,
}, },
]; ];
@@ -582,6 +595,8 @@ function SchedulesPageContent() {
onClick={handleBulkDelete} onClick={handleBulkDelete}
variant="danger" variant="danger"
loading={bulkDeleteMutation.isPending} loading={bulkDeleteMutation.isPending}
disabled={!canDelete}
title={canDelete ? undefined : 'You do not have permission to delete schedules'}
> >
Delete {selectedSchedules.size} Schedule{selectedSchedules.size !== 1 ? 's' : ''} Delete {selectedSchedules.size} Schedule{selectedSchedules.size !== 1 ? 's' : ''}
</ActionButton> </ActionButton>
@@ -590,6 +605,8 @@ function SchedulesPageContent() {
icon={Plus} icon={Plus}
variant="secondary" variant="secondary"
onClick={() => { setError(null); setShowAddModal(true); }} onClick={() => { setError(null); setShowAddModal(true); }}
disabled={!canCreate}
title={canCreate ? undefined : 'You do not have permission to create schedules'}
> >
Add Schedule Add Schedule
</ActionButton> </ActionButton>
@@ -599,6 +616,8 @@ function SchedulesPageContent() {
setError(null); setError(null);
setShowModal(true); setShowModal(true);
}} }}
disabled={!canCreate}
title={canCreate ? undefined : 'You do not have permission to create schedules'}
> >
Bulk Generate Bulk Generate
</ActionButton> </ActionButton>

View File

@@ -4,7 +4,7 @@ import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { seatsApi, schedulesApi, fleetApi, routeCoachTemplatesApi, bookingsApi } from '@/lib/api'; import { seatsApi, schedulesApi, fleetApi, routeCoachTemplatesApi, bookingsApi } from '@/lib/api';
import { routesApi } from '@/lib/api/routes'; import { routesApi } from '@/lib/api/routes';
import { usePermissionStrict } from '@/lib/use-permission'; import { useDeletePermission, usePermissionStrict, useWritePermission } from '@/lib/use-permission';
import { PERMS } from '@/lib/permissions'; import { PERMS } from '@/lib/permissions';
import { PermissionGuard } from '@/components/layout/PermissionGuard'; import { PermissionGuard } from '@/components/layout/PermissionGuard';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
@@ -63,6 +63,13 @@ function SeatsPageContent() {
// Strict: being an admin is not enough, the permission has to be granted. // Strict: being an admin is not enough, the permission has to be granted.
const canIssueBooking = usePermissionStrict(PERMS.tickets.generate); const canIssueBooking = usePermissionStrict(PERMS.tickets.generate);
// Blocking takes inventory out of sale, so it is its own grant; removing a seat is
// the app's only soft delete and rides on `seats:delete`; maintenance is an edit.
const canBlockSeats = useWritePermission(PERMS.seats.block, PERMS.seats.manage);
const canEditSeats = useWritePermission(PERMS.seats.edit, PERMS.seats.manage);
const canDeleteSeats = useDeletePermission(PERMS.seats.delete);
const canCancelBooking = useWritePermission(PERMS.bookings.cancel, PERMS.bookings.manage);
const { data: schedulesData } = useQuery({ const { data: schedulesData } = useQuery({
queryKey: ['schedules'], queryKey: ['schedules'],
queryFn: () => schedulesApi.getAll(), queryFn: () => schedulesApi.getAll(),
@@ -478,6 +485,10 @@ function SeatsPageContent() {
handleClearMaintenance={handleClearMaintenance} handleClearMaintenance={handleClearMaintenance}
handleIssueBooking={handleIssueBooking} handleIssueBooking={handleIssueBooking}
canIssueBooking={canIssueBooking} canIssueBooking={canIssueBooking}
canBlockSeats={canBlockSeats}
canEditSeats={canEditSeats}
canDeleteSeats={canDeleteSeats}
canCancelBooking={canCancelBooking}
hideNumber={true} hideNumber={true}
/> />
))} ))}
@@ -576,6 +587,10 @@ function SeatsPageContent() {
handleClearMaintenance={handleClearMaintenance} handleClearMaintenance={handleClearMaintenance}
handleIssueBooking={handleIssueBooking} handleIssueBooking={handleIssueBooking}
canIssueBooking={canIssueBooking} canIssueBooking={canIssueBooking}
canBlockSeats={canBlockSeats}
canEditSeats={canEditSeats}
canDeleteSeats={canDeleteSeats}
canCancelBooking={canCancelBooking}
hideNumber={true} hideNumber={true}
/> />
))} ))}
@@ -601,6 +616,10 @@ function SeatsPageContent() {
handleClearMaintenance={handleClearMaintenance} handleClearMaintenance={handleClearMaintenance}
handleIssueBooking={handleIssueBooking} handleIssueBooking={handleIssueBooking}
canIssueBooking={canIssueBooking} canIssueBooking={canIssueBooking}
canBlockSeats={canBlockSeats}
canEditSeats={canEditSeats}
canDeleteSeats={canDeleteSeats}
canCancelBooking={canCancelBooking}
hideNumber={true} hideNumber={true}
/> />
))} ))}
@@ -846,7 +865,8 @@ function SeatsPageContent() {
size="sm" size="sm"
onClick={() => isCoachBlocked(coach) ? handleUnblockCoach(coach) : handleBlockCoach(coach)} onClick={() => isCoachBlocked(coach) ? handleUnblockCoach(coach) : handleBlockCoach(coach)}
className="ml-2" className="ml-2"
disabled={!isCoachBlocked(coach) && !isCoachUnblocked(coach)} disabled={!canBlockSeats || (!isCoachBlocked(coach) && !isCoachUnblocked(coach))}
title={canBlockSeats ? undefined : 'You do not have permission to block seats'}
> >
{isCoachBlocked(coach) ? ( {isCoachBlocked(coach) ? (
<> <>
@@ -1326,6 +1346,10 @@ interface SeatIconProps {
handleClearMaintenance: (seat: any) => void; handleClearMaintenance: (seat: any) => void;
handleIssueBooking: (seat: any, coach: any) => void; handleIssueBooking: (seat: any, coach: any) => void;
canIssueBooking?: boolean; canIssueBooking?: boolean;
canBlockSeats?: boolean;
canEditSeats?: boolean;
canDeleteSeats?: boolean;
canCancelBooking?: boolean;
} }
function SeatIcon({ function SeatIcon({
@@ -1345,6 +1369,10 @@ function SeatIcon({
handleClearMaintenance, handleClearMaintenance,
handleIssueBooking, handleIssueBooking,
canIssueBooking = false, canIssueBooking = false,
canBlockSeats = false,
canEditSeats = false,
canDeleteSeats = false,
canCancelBooking = false,
}: SeatIconProps) { }: SeatIconProps) {
const isRemoved = seat.seatNumber && seat.seatNumber.startsWith('-'); const isRemoved = seat.seatNumber && seat.seatNumber.startsWith('-');
const seatClassStr = typeof coach?.seatClass === 'string' ? coach.seatClass : (coach?.seatClass?.name || coach?.coachClass || ''); const seatClassStr = typeof coach?.seatClass === 'string' ? coach.seatClass : (coach?.seatClass?.name || coach?.coachClass || '');
@@ -1362,13 +1390,15 @@ function SeatIcon({
<div className="w-11 h-11 rounded border-2 border-dashed border-gray-400 flex items-center justify-center hover:opacity-80 transition-opacity" title="Removed seat"> <div className="w-11 h-11 rounded border-2 border-dashed border-gray-400 flex items-center justify-center hover:opacity-80 transition-opacity" title="Removed seat">
</div> </div>
<div className="absolute top-full mt-1 bg-black/80 rounded shadow-lg flex items-center gap-1 p-1 z-20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none group-hover:pointer-events-auto"> <div className="absolute top-full mt-1 bg-black/80 rounded shadow-lg flex items-center gap-1 p-1 z-20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none group-hover:pointer-events-auto">
<button {canEditSeats && (
onClick={() => handleUndoRemove(seat)} <button
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto" onClick={() => handleUndoRemove(seat)}
title="Undo remove" className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
> title="Undo remove"
<RotateCcw className="h-3 w-3 text-gray-700" /> >
</button> <RotateCcw className="h-3 w-3 text-gray-700" />
</button>
)}
</div> </div>
</div> </div>
); );
@@ -1376,14 +1406,14 @@ function SeatIcon({
const status = getSeatStatus(seat); const status = getSeatStatus(seat);
const color = getSeatColor(status); const color = getSeatColor(status);
const canBlock = status === 'AVAILABLE'; const canBlock = canBlockSeats && status === 'AVAILABLE';
const canUnblock = status === 'BLOCKED'; const canUnblock = canBlockSeats && status === 'BLOCKED';
// A HELD seat with a bookingRef + PENDING_PAYMENT is a backoffice reservation awaiting // A HELD seat with a bookingRef + PENDING_PAYMENT is a backoffice reservation awaiting
// payment (see resolveActiveReservations) — issuing it already released the SeatBlock, so // payment (see resolveActiveReservations) — issuing it already released the SeatBlock, so
// it's not reachable via canUnblock anymore; this is the seat's own release path. // it's not reachable via canUnblock anymore; this is the seat's own release path.
const canCancelReservation = status === 'HELD' && !!seat.bookingRef && seat.reservationStatus === 'PENDING_PAYMENT'; const canCancelReservation = canCancelBooking && status === 'HELD' && !!seat.bookingRef && seat.reservationStatus === 'PENDING_PAYMENT';
const canMaintenance = false; const canMaintenance = false;
const canClearMaintenance = status === 'UNDER_MAINTENANCE'; const canClearMaintenance = canEditSeats && status === 'UNDER_MAINTENANCE';
return ( return (
<div className="relative group flex flex-col items-center"> <div className="relative group flex flex-col items-center">
@@ -1431,13 +1461,15 @@ function SeatIcon({
> >
<Lock className="h-3 w-3 text-gray-700" /> <Lock className="h-3 w-3 text-gray-700" />
</button> </button>
<button {canDeleteSeats && (
onClick={() => handleRemoveSeat(seat)} <button
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto" onClick={() => handleRemoveSeat(seat)}
title="Remove seat" className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
> title="Remove seat"
<X className="h-3 w-3 text-gray-700" /> >
</button> <X className="h-3 w-3 text-gray-700" />
</button>
)}
</> </>
)} )}
{canCancelReservation && ( {canCancelReservation && (

View File

@@ -13,6 +13,7 @@ import Pagination from '@/components/ui/Pagination';
import { usePagination } from '@/lib/use-pagination'; import { usePagination } from '@/lib/use-pagination';
import { PermissionGuard } from '@/components/layout/PermissionGuard'; import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions'; import { PERMS } from '@/lib/permissions';
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
function StationsPageContent() { function StationsPageContent() {
const [filters, setFilters] = useState({ search: '', country: '', operational: '' }); const [filters, setFilters] = useState({ search: '', country: '', operational: '' });
@@ -22,6 +23,10 @@ function StationsPageContent() {
const [formError, setFormError] = useState<string | null>(null); const [formError, setFormError] = useState<string | null>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const canCreate = useWritePermission(PERMS.stations.create, PERMS.stations.manage);
const canEdit = useWritePermission(PERMS.stations.edit, PERMS.stations.manage);
const canDelete = useDeletePermission(PERMS.stations.delete);
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ['stations', filters], queryKey: ['stations', filters],
queryFn: () => stationsApi.getAll(filters), queryFn: () => stationsApi.getAll(filters),
@@ -164,6 +169,7 @@ function StationsPageContent() {
const actions = [ const actions = [
{ {
label: 'Edit', label: 'Edit',
show: () => canEdit,
onClick: (station: any) => { onClick: (station: any) => {
setEditingStation(station); setEditingStation(station);
setFormError(null); setFormError(null);
@@ -174,6 +180,7 @@ function StationsPageContent() {
}, },
{ {
label: 'Delete', label: 'Delete',
show: () => canDelete,
onClick: handleDelete, onClick: handleDelete,
variant: 'danger' as const, variant: 'danger' as const,
icon: Trash2, icon: Trash2,
@@ -194,6 +201,8 @@ function StationsPageContent() {
setFormError(null); setFormError(null);
setShowModal(true); setShowModal(true);
}} }}
disabled={!canCreate}
title={canCreate ? undefined : 'You do not have permission to create stations'}
> >
Add Station Add Station
</ActionButton> </ActionButton>

View File

@@ -9,6 +9,8 @@ import ConfirmDialog from '@/components/ui/ConfirmDialog';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
import { useRouteFareRules, useRouteFareRuleMutations, useSeatClasses } from './hooks'; import { useRouteFareRules, useRouteFareRuleMutations, useSeatClasses } from './hooks';
import type { Route, RouteFareRule, SeatClass } from './types'; import type { Route, RouteFareRule, SeatClass } from './types';
import { PERMS } from '@/lib/permissions';
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
interface Props { interface Props {
routes: Route[]; routes: Route[];
@@ -21,6 +23,12 @@ type OverrideForm = {
}; };
export default function OverridesTab({ routes }: Props) { export default function OverridesTab({ routes }: Props) {
// Fare rules are their own resource on the API (schedule_fares:*), split out of
// schedules:manage so editing a timetable and changing a price are separate grants.
const canCreateFare = useWritePermission(PERMS.scheduleFares.create, PERMS.scheduleFares.manage);
const canEditFare = useWritePermission(PERMS.scheduleFares.edit, PERMS.scheduleFares.manage);
const canDeleteFare = useDeletePermission(PERMS.scheduleFares.delete);
const [selectedRouteId, setSelectedRouteId] = useState<string | null>(null); const [selectedRouteId, setSelectedRouteId] = useState<string | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ const [deleteConfirm, setDeleteConfirm] = useState<{
isOpen: boolean; isOpen: boolean;
@@ -148,6 +156,9 @@ export default function OverridesTab({ routes }: Props) {
<ActionButton <ActionButton
icon={Plus} icon={Plus}
onClick={() => setForm({ isOpen: true, rule: null, error: null })} onClick={() => setForm({ isOpen: true, rule: null, error: null })}
disabled={!canCreateFare}
title={canCreateFare ? undefined : 'You do not have permission to create fare overrides'}
> >
Add Override Add Override
</ActionButton> </ActionButton>
@@ -165,8 +176,9 @@ export default function OverridesTab({ routes }: Props) {
{ {
label: 'Edit', icon: Edit, variant: 'secondary' as const, label: 'Edit', icon: Edit, variant: 'secondary' as const,
onClick: (r: RouteFareRule) => setForm({ isOpen: true, rule: r, error: null }), onClick: (r: RouteFareRule) => setForm({ isOpen: true, rule: r, error: null }),
show: () => canEditFare,
}, },
{ label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: handleDeleteClick }, { label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: handleDeleteClick, show: () => canDeleteFare },
]} ]}
loading={isLoading} loading={isLoading}
emptyMessage="No overrides for this route. Click 'Add Override' to create one." emptyMessage="No overrides for this route. Click 'Add Override' to create one."

View File

@@ -10,6 +10,8 @@ import { useSegmentFareRules, useSegmentFareMutations, useSeatClasses, useRoutes
import type { Route, SegmentFareRule, SeatClass } from './types'; import type { Route, SegmentFareRule, SeatClass } from './types';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { PERMS } from '@/lib/permissions';
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
interface RouteStop { sequence: number; station?: { name: string; code: string } } interface RouteStop { sequence: number; station?: { name: string; code: string } }
@@ -50,6 +52,12 @@ interface Props { routes: Route[] }
type FormState = { isOpen: boolean; rule: SegmentFareRule | null; error: string | null }; type FormState = { isOpen: boolean; rule: SegmentFareRule | null; error: string | null };
export default function SegmentOverridesTab({ routes }: Props) { export default function SegmentOverridesTab({ routes }: Props) {
// Fare rules are their own resource on the API (schedule_fares:*), split out of
// schedules:manage so editing a timetable and changing a price are separate grants.
const canCreateFare = useWritePermission(PERMS.scheduleFares.create, PERMS.scheduleFares.manage);
const canEditFare = useWritePermission(PERMS.scheduleFares.edit, PERMS.scheduleFares.manage);
const canDeleteFare = useDeletePermission(PERMS.scheduleFares.delete);
const [selectedRouteId, setSelectedRouteId] = useState<string | null>(null); const [selectedRouteId, setSelectedRouteId] = useState<string | null>(null);
const [form, setForm] = useState<FormState>({ isOpen: false, rule: null, error: null }); const [form, setForm] = useState<FormState>({ isOpen: false, rule: null, error: null });
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; id: string | null; name: string; error?: string }>({ isOpen: false, id: null, name: '' }); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; id: string | null; name: string; error?: string }>({ isOpen: false, id: null, name: '' });
@@ -164,7 +172,12 @@ export default function SegmentOverridesTab({ routes }: Props) {
))} ))}
</select> </select>
<ActionButton icon={Plus} onClick={() => setForm({ isOpen: true, rule: null, error: null })} disabled={!selectedRouteId}> <ActionButton
icon={Plus}
onClick={() => 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 Add Segment Override
</ActionButton> </ActionButton>
</div> </div>
@@ -176,8 +189,8 @@ export default function SegmentOverridesTab({ routes }: Props) {
data={segmentFares} data={segmentFares}
columns={columns} columns={columns}
actions={[ actions={[
{ label: 'Edit', icon: Edit, variant: 'secondary' as const, onClick: (r: SegmentFareRule) => setForm({ isOpen: true, rule: r, error: null }) }, { 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)}` }) }, { 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} loading={isLoading}
emptyMessage="No segment overrides for this route." emptyMessage="No segment overrides for this route."

View File

@@ -14,12 +14,14 @@ interface Props {
isLoading: boolean; isLoading: boolean;
onEdit: (cls: SeatClass) => void; onEdit: (cls: SeatClass) => void;
onDelete: (id: string, cascade: boolean) => void; onDelete: (id: string, cascade: boolean) => void;
canEdit?: boolean;
canDelete?: boolean;
isDeleting: boolean; isDeleting: boolean;
deleteError?: string; deleteError?: string;
deleteSuccess?: number; 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 [search, setSearch] = useState('');
const [deleteConfirm, setDeleteConfirm] = useState<{ const [deleteConfirm, setDeleteConfirm] = useState<{
isOpen: boolean; isOpen: boolean;
@@ -141,8 +143,8 @@ export default function TariffTab({ classes, coachTypes, isLoading, onEdit, onDe
data={displayed} data={displayed}
columns={columns} columns={columns}
actions={[ actions={[
{ label: 'Edit', icon: Edit, variant: 'secondary' as const, onClick: onEdit }, { label: 'Edit', icon: Edit, variant: 'secondary' as const, onClick: onEdit, show: () => canEdit },
{ label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: handleDeleteClick }, { label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: handleDeleteClick, show: () => canDelete },
]} ]}
loading={isLoading} loading={isLoading}
emptyMessage={search ? 'No tariff rates match your search' : 'No tariff rates found'} emptyMessage={search ? 'No tariff rates match your search' : 'No tariff rates found'}

View File

@@ -10,8 +10,11 @@ import BaggageTab from './BaggageTab';
import RateModal from './RateModal'; import RateModal from './RateModal';
import { useSeatClasses, useCoachTypes, useRoutes, useSeatClassMutations, useRouteFareRuleMutations } from './hooks'; import { useSeatClasses, useCoachTypes, useRoutes, useSeatClassMutations, useRouteFareRuleMutations } from './hooks';
import type { SeatClass, TabType } from './types'; 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<TabType>('tariff'); const [tab, setTab] = useState<TabType>('tariff');
const [showRateModal, setShowRateModal] = useState(false); const [showRateModal, setShowRateModal] = useState(false);
const [editingClass, setEditingClass] = useState<SeatClass | null>(null); const [editingClass, setEditingClass] = useState<SeatClass | null>(null);
@@ -20,6 +23,10 @@ export default function TariffRatesPage() {
const [deleteError, setDeleteError] = useState<string | undefined>(undefined); const [deleteError, setDeleteError] = useState<string | undefined>(undefined);
const [deleteSuccess, setDeleteSuccess] = useState(0); 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 { allClasses, isLoading } = useSeatClasses();
const { coachTypes } = useCoachTypes(); const { coachTypes } = useCoachTypes();
const { routes } = useRoutes(); const { routes } = useRoutes();
@@ -71,7 +78,11 @@ export default function TariffRatesPage() {
</p> </p>
</div> </div>
{tab !== 'overrides' && tab !== 'segment-overrides' && ( {tab !== 'overrides' && tab !== 'segment-overrides' && (
<ActionButton icon={Plus} onClick={() => { <ActionButton
icon={Plus}
disabled={!canCreateRate}
title={canCreateRate ? undefined : 'You do not have permission to create tariff rates'}
onClick={() => {
if (tab === 'baggage') { if (tab === 'baggage') {
setShowBaggageModal(true); setShowBaggageModal(true);
} else { } else {
@@ -105,6 +116,8 @@ export default function TariffRatesPage() {
isLoading={isLoading} isLoading={isLoading}
onEdit={cls => { setEditingClass(cls); setPreselectedRouteId(null); setShowRateModal(true); }} onEdit={cls => { setEditingClass(cls); setPreselectedRouteId(null); setShowRateModal(true); }}
onDelete={handleDelete} onDelete={handleDelete}
canEdit={canEditRate}
canDelete={canDeleteRate}
isDeleting={seatClassMutations.remove.isPending} isDeleting={seatClassMutations.remove.isPending}
deleteError={deleteError} deleteError={deleteError}
deleteSuccess={deleteSuccess} deleteSuccess={deleteSuccess}
@@ -143,3 +156,11 @@ export default function TariffRatesPage() {
</div> </div>
); );
} }
export default function TariffRatesPage() {
return (
<PermissionGuard permission={PERMS.tariffRates.view}>
<TariffRatesPageContent />
</PermissionGuard>
);
}

View File

@@ -14,8 +14,11 @@ import { ticketsApi, apiClient, stationsApi, excessBaggageApi, bookingsApi } fro
import Pagination from '@/components/ui/Pagination'; import Pagination from '@/components/ui/Pagination';
import { formatDateTime, formatCurrency, formatDateTimeShort } from '@/lib/utils'; import { formatDateTime, formatCurrency, formatDateTimeShort } from '@/lib/utils';
import { useAuthStore } from '@/lib/auth-store'; 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 [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', departureDate: '', arrivalDate: '', dateFrom: '', dateTo: '', coachId: '' });
const [ticketPage, setTicketPage] = useState(1); const [ticketPage, setTicketPage] = useState(1);
const resetTicketPage = () => setTicketPage(1); const resetTicketPage = () => setTicketPage(1);
@@ -69,6 +72,13 @@ export default function TicketsPage() {
}); });
const queryClient = useQueryClient(); 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({ const { data, isLoading, error } = useQuery({
queryKey: ['tickets', filters, ticketPage], queryKey: ['tickets', filters, ticketPage],
queryFn: () => ticketsApi.getAll({ queryFn: () => ticketsApi.getAll({
@@ -554,7 +564,7 @@ export default function TicketsPage() {
onClick: openExcessModal, onClick: openExcessModal,
variant: 'secondary' as const, variant: 'secondary' as const,
icon: Package, 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', label: 'Board',
@@ -562,6 +572,7 @@ export default function TicketsPage() {
variant: 'primary' as const, variant: 'primary' as const,
icon: LogIn, icon: LogIn,
show: (ticket: any) => { show: (ticket: any) => {
if (!canBoard) return false;
const isRoundTrip = ticket.booking?.bookingType === 'ROUND_TRIP' || ticket.booking?.bookingType === 'ROUND_TRIP_TRANSIT'; const isRoundTrip = ticket.booking?.bookingType === 'ROUND_TRIP' || ticket.booking?.bookingType === 'ROUND_TRIP_TRANSIT';
if (isRoundTrip) { if (isRoundTrip) {
const inboundBoarded = !!ticket.booking?.returnBoardedAt; const inboundBoarded = !!ticket.booking?.returnBoardedAt;
@@ -595,10 +606,11 @@ export default function TicketsPage() {
onClick: (ticket: any) => restoreMutation.mutate(ticket.id), onClick: (ticket: any) => restoreMutation.mutate(ticket.id),
variant: 'secondary' as const, variant: 'secondary' as const,
icon: ListCollapse, icon: ListCollapse,
show: (ticket: any) => ticket.status === 'CANCELLED', show: (ticket: any) => canEditTicket && ticket.status === 'CANCELLED',
}, },
{ {
label: 'Delete', label: 'Delete',
show: () => canDelete,
onClick: handleDeleteClick, onClick: handleDeleteClick,
variant: 'danger' as const, variant: 'danger' as const,
icon: Trash2, icon: Trash2,
@@ -621,6 +633,8 @@ export default function TicketsPage() {
variant="secondary" variant="secondary"
loading={generateMissingMutation.isPending} loading={generateMissingMutation.isPending}
onClick={() => generateMissingMutation.mutate()} onClick={() => generateMissingMutation.mutate()}
disabled={!canGenerate}
title={canGenerate ? undefined : 'You do not have permission to generate tickets'}
> >
Generate Missing Generate Missing
</ActionButton> </ActionButton>
@@ -760,7 +774,13 @@ export default function TicketsPage() {
</div> </div>
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<ActionButton variant="secondary" onClick={() => { setBoardConfirmOpen(false); setTicketToBoard(null); }}>Cancel</ActionButton> <ActionButton variant="secondary" onClick={() => { setBoardConfirmOpen(false); setTicketToBoard(null); }}>Cancel</ActionButton>
<ActionButton icon={LogIn} loading={boardMutation.isPending} onClick={handleConfirmBoard}>Board and Print</ActionButton> <ActionButton
icon={LogIn}
loading={boardMutation.isPending}
onClick={handleConfirmBoard}
disabled={!canBoard}
title={canBoard ? undefined : 'You do not have permission to board passengers'}
>Board and Print</ActionButton>
</div> </div>
</div> </div>
</Modal> </Modal>
@@ -1103,3 +1123,11 @@ export default function TicketsPage() {
</div> </div>
); );
} }
export default function TicketsPage() {
return (
<PermissionGuard permission={PERMS.tickets.view}>
<TicketsPageContent />
</PermissionGuard>
);
}

View File

@@ -15,6 +15,7 @@ import { Train as TrainType } from '@/types';
import { formatDate } from '@/lib/utils'; import { formatDate } from '@/lib/utils';
import { PermissionGuard } from '@/components/layout/PermissionGuard'; import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions'; import { PERMS } from '@/lib/permissions';
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
function TrainsPageContent() { function TrainsPageContent() {
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
@@ -24,6 +25,10 @@ function TrainsPageContent() {
const queryClient = useQueryClient(); 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({ const { data: trainsData, isLoading: trainsLoading } = useQuery({
queryKey: ['trains'], queryKey: ['trains'],
queryFn: () => fleetApi.getTrains(), queryFn: () => fleetApi.getTrains(),
@@ -174,6 +179,7 @@ function TrainsPageContent() {
const actions = [ const actions = [
{ {
label: 'Edit', label: 'Edit',
show: () => canEdit,
onClick: (train: TrainType) => { onClick: (train: TrainType) => {
setEditingTrain(train); setEditingTrain(train);
setShowModal(true); setShowModal(true);
@@ -186,10 +192,11 @@ function TrainsPageContent() {
onClick: (train: TrainType) => restoreTrainMutation.mutate(train.id), onClick: (train: TrainType) => restoreTrainMutation.mutate(train.id),
variant: 'secondary' as const, variant: 'secondary' as const,
icon: RotateCcw, icon: RotateCcw,
show: (train: TrainType) => !train.isActive, show: (train: TrainType) => canEdit && !train.isActive,
}, },
{ {
label: 'Delete', label: 'Delete',
show: () => canDelete,
onClick: handleDelete, onClick: handleDelete,
variant: 'danger' as const, variant: 'danger' as const,
icon: Trash2, icon: Trash2,
@@ -209,6 +216,8 @@ function TrainsPageContent() {
setShowModal(true); setShowModal(true);
}} }}
icon={Plus} icon={Plus}
disabled={!canCreate}
title={canCreate ? undefined : 'You do not have permission to create trains'}
> >
Add Train Add Train
</ActionButton> </ActionButton>

View File

@@ -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 * 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 * can be neither upgraded from nor to. Gated on upgrade_policies:view (or :manage) — bookings:view no longer
* `GET /upgrade/policies` requires; creating, editing and deleting are admin-only server-side. * grants it, so the page needs its own grant. Create/edit/delete each have their own key.
*/ */
export default function UpgradePoliciesPage() { export default function UpgradePoliciesPage() {
return ( return (
<PermissionGuard permission={PERMS.bookings.view}> <PermissionGuard permission={[PERMS.upgradePolicies.view, PERMS.upgradePolicies.manage]}>
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-3xl font-bold text-foreground">Upgrade Policies</h1> <h1 className="text-3xl font-bold text-foreground">Upgrade Policies</h1>

View File

@@ -2,17 +2,28 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { ShieldOff } from 'lucide-react';
import { useAuthStore } from '@/lib/auth-store'; import { useAuthStore } from '@/lib/auth-store';
interface Props { 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; children: React.ReactNode;
} }
/** /**
* Wraps a page to enforce auth + optional permission check. * Wraps a page to enforce auth + optional permission check.
* - Not logged in → redirect to /login * - 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) { export function PermissionGuard({ permission, children }: Props) {
const router = useRouter(); const router = useRouter();
@@ -20,17 +31,26 @@ export function PermissionGuard({ permission, children }: Props) {
const hasPermission = useAuthStore((s) => s.hasPermission); const hasPermission = useAuthStore((s) => s.hasPermission);
useEffect(() => { useEffect(() => {
if (!isAuthenticated) { if (!isAuthenticated) router.replace('/login');
router.replace('/login'); }, [isAuthenticated, router]);
return;
}
if (permission && !hasPermission(permission)) {
router.replace('/dashboard');
}
}, [isAuthenticated, permission, hasPermission, router]);
if (!isAuthenticated) return null; 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 (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3 px-6 text-center">
<ShieldOff className="h-10 w-10 text-muted-foreground" />
<h2 className="text-lg font-semibold text-foreground">You don&apos;t have access to this page</h2>
<p className="max-w-md text-sm text-muted-foreground">
Your account is missing the permission this page requires. Ask an administrator to grant
it if you need access.
</p>
</div>
);
}
return <>{children}</>; return <>{children}</>;
} }

View File

@@ -52,7 +52,8 @@ interface NavItem {
name: string; name: string;
href: string; href: string;
icon: React.ComponentType<{ className?: 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[] }[] = [ 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: 'Classes', href: '/classes', icon: Settings, permission: PERMS.classes.view },
{ name: 'Routes', href: '/routes', icon: Route, permission: PERMS.routes.view }, { name: 'Routes', href: '/routes', icon: Route, permission: PERMS.routes.view },
{ name: 'Schedules', href: '/schedules', icon: Calendar, permission: PERMS.schedules.view }, { name: 'Schedules', href: '/schedules', icon: Calendar, permission: PERMS.schedules.view },
{ name: 'Reschedule Policies', href: '/reschedule-policies', icon: CalendarClock, 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.bookings.view }, { 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', title: 'Analytics & Reports',
items: [ items: [
{ name: 'Overall', href: '/reports/overall', icon: BarChart3, 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.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.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.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.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.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.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.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.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.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 */} {/* Navigation */}
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-6"> <nav className="flex-1 overflow-y-auto px-3 py-4 space-y-6">
{navigationSections.map((section) => { {navigationSections.map((section) => {
const visibleItems = section.items.filter( const visibleItems = section.items.filter((item) => {
(item) => !item.permission || hasPermission(item.permission) if (!item.permission) return true;
); const keys = Array.isArray(item.permission) ? item.permission : [item.permission];
return keys.some((key) => hasPermission(key));
});
if (visibleItems.length === 0) return null; if (visibleItems.length === 0) return null;
return ( return (
<div key={section.title}> <div key={section.title}>

View File

@@ -6,6 +6,8 @@ import DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog'; import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
import { PERMS } from '@/lib/permissions';
import { import {
reschedulePolicyApi, reschedulePolicyApi,
type ReschedulePolicyCoachType, type ReschedulePolicyCoachType,
@@ -35,6 +37,10 @@ const feeLabel = (percent: number, minMinor: number) =>
* the same shape as Coach Management. A fare class with no row here cannot be rescheduled at all. * the same shape as Coach Management. A fare class with no row here cannot be rescheduled at all.
*/ */
export default function ReschedulePolicyManager() { export default function ReschedulePolicyManager() {
const canCreate = useWritePermission(PERMS.reschedulePolicies.create, PERMS.reschedulePolicies.manage);
const canEdit = useWritePermission(PERMS.reschedulePolicies.edit, PERMS.reschedulePolicies.manage);
const canDelete = useDeletePermission(PERMS.reschedulePolicies.delete);
const [rows, setRows] = useState<ReschedulePolicyRow[]>([]); const [rows, setRows] = useState<ReschedulePolicyRow[]>([]);
const [available, setAvailable] = useState<ReschedulePolicyCoachType[]>([]); const [available, setAvailable] = useState<ReschedulePolicyCoachType[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -188,9 +194,10 @@ export default function ReschedulePolicyManager() {
]; ];
const actions = [ 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', label: 'Delete',
show: () => canDelete,
onClick: (row: ReschedulePolicyRow) => setDeleting(row), onClick: (row: ReschedulePolicyRow) => setDeleting(row),
variant: 'danger' as const, variant: 'danger' as const,
icon: Trash2, icon: Trash2,
@@ -206,7 +213,12 @@ export default function ReschedulePolicyManager() {
not refunded. Same-day = new departure on the same calendar day as the original. A fare class with no not refunded. Same-day = new departure on the same calendar day as the original. A fare class with no
policy here cannot be rescheduled at all. policy here cannot be rescheduled at all.
</p> </p>
<ActionButton icon={Plus} onClick={openCreate} disabled={available.length === 0}> <ActionButton
icon={Plus}
onClick={openCreate}
disabled={available.length === 0 || !canCreate}
title={canCreate ? undefined : 'You do not have permission to create reschedule policies'}
>
Add Reschedule Policy Add Reschedule Policy
</ActionButton> </ActionButton>
</div> </div>
@@ -353,7 +365,13 @@ export default function ReschedulePolicyManager() {
<ActionButton variant="secondary" onClick={() => setShowModal(false)}> <ActionButton variant="secondary" onClick={() => setShowModal(false)}>
Cancel Cancel
</ActionButton> </ActionButton>
<ActionButton icon={Save} onClick={submit} loading={saving}> <ActionButton
icon={Save}
onClick={submit}
loading={saving}
disabled={editing ? !canEdit : !canCreate}
title={(editing ? canEdit : canCreate) ? undefined : 'You do not have permission to change reschedule policies'}
>
{editing ? 'Update Policy' : 'Create Policy'} {editing ? 'Update Policy' : 'Create Policy'}
</ActionButton> </ActionButton>
</div> </div>

View File

@@ -14,6 +14,12 @@ interface ActionButtonProps {
loading?: boolean; loading?: boolean;
className?: string; className?: string;
type?: 'button' | 'submit' | 'reset'; 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 = { const variants = {
@@ -40,6 +46,7 @@ export default function ActionButton({
loading = false, loading = false,
className, className,
type = 'button', type = 'button',
title,
}: ActionButtonProps) { }: ActionButtonProps) {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -63,6 +70,7 @@ export default function ActionButton({
type={type} type={type}
onClick={handleClick} onClick={handleClick}
disabled={isDisabled} disabled={isDisabled}
title={title}
className={cn( className={cn(
'inline-flex items-center justify-center gap-2 rounded-lg font-medium transition-all duration-200', '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)]', 'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[rgb(20,113,76)]',

View File

@@ -6,6 +6,8 @@ import DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog'; import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
import { PERMS } from '@/lib/permissions';
import { import {
upgradePolicyApi, upgradePolicyApi,
type UpgradePolicyCoachType, type UpgradePolicyCoachType,
@@ -38,6 +40,10 @@ const feeLabel = (p: UpgradePolicyRow) =>
* a dialog, the same shape as Reschedule Policies and Coach Management. * a dialog, the same shape as Reschedule Policies and Coach Management.
*/ */
export default function UpgradePolicyManager() { 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<UpgradePolicyRow[]>([]); const [rows, setRows] = useState<UpgradePolicyRow[]>([]);
const [available, setAvailable] = useState<UpgradePolicyCoachType[]>([]); const [available, setAvailable] = useState<UpgradePolicyCoachType[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -186,9 +192,10 @@ export default function UpgradePolicyManager() {
]; ];
const actions = [ 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', label: 'Delete',
show: () => canDelete,
onClick: (row: UpgradePolicyRow) => setDeleting(row), onClick: (row: UpgradePolicyRow) => setDeleting(row),
variant: 'danger' as const, variant: 'danger' as const,
icon: Trash2, icon: Trash2,
@@ -204,7 +211,12 @@ export default function UpgradePolicyManager() {
<em> to</em> and charged per upgraded passenger. A fare class with no policy here can be neither <em> to</em> and charged per upgraded passenger. A fare class with no policy here can be neither
upgraded from nor to. upgraded from nor to.
</p> </p>
<ActionButton icon={Plus} onClick={openCreate} disabled={available.length === 0}> <ActionButton
icon={Plus}
onClick={openCreate}
disabled={available.length === 0 || !canCreate}
title={canCreate ? undefined : 'You do not have permission to create upgrade policies'}
>
Add Upgrade Policy Add Upgrade Policy
</ActionButton> </ActionButton>
</div> </div>
@@ -340,7 +352,13 @@ export default function UpgradePolicyManager() {
<ActionButton variant="secondary" onClick={() => setShowModal(false)}> <ActionButton variant="secondary" onClick={() => setShowModal(false)}>
Cancel Cancel
</ActionButton> </ActionButton>
<ActionButton icon={Save} onClick={submit} loading={saving}> <ActionButton
icon={Save}
onClick={submit}
loading={saving}
disabled={editing ? !canEdit : !canCreate}
title={(editing ? canEdit : canCreate) ? undefined : 'You do not have permission to change upgrade policies'}
>
{editing ? 'Update Policy' : 'Create Policy'} {editing ? 'Update Policy' : 'Create Policy'}
</ActionButton> </ActionButton>
</div> </div>

View File

@@ -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 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 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 * 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 || // Normalize in place so every existing `err?.response?.data?.message || err?.message ||
// '<fallback>'` call site across the app picks up a friendly message automatically, // '<fallback>'` call site across the app picks up a friendly message automatically,
// instead of raw axios/network text or an unjoined NestJS validation array. // instead of raw axios/network text or an unjoined NestJS validation array.

View File

@@ -9,6 +9,20 @@ export const PERMS = {
edit: 'edr_passenger_app:bookings:edit', edit: 'edr_passenger_app:bookings:edit',
delete: 'edr_passenger_app:bookings:delete', 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: { passengers: {
view: 'edr_passenger_app:passengers:view', view: 'edr_passenger_app:passengers:view',
manage: 'edr_passenger_app:passengers:manage', manage: 'edr_passenger_app:passengers:manage',
@@ -20,6 +34,7 @@ export const PERMS = {
view: 'edr_passenger_app:tickets:view', view: 'edr_passenger_app:tickets:view',
manage: 'edr_passenger_app:tickets:manage', manage: 'edr_passenger_app:tickets:manage',
generate: 'edr_passenger_app:tickets:generate', generate: 'edr_passenger_app:tickets:generate',
board: 'edr_passenger_app:tickets:board',
create: 'edr_passenger_app:tickets:create', create: 'edr_passenger_app:tickets:create',
edit: 'edr_passenger_app:tickets:edit', edit: 'edr_passenger_app:tickets:edit',
delete: 'edr_passenger_app:tickets:delete', delete: 'edr_passenger_app:tickets:delete',
@@ -114,6 +129,7 @@ export const PERMS = {
view: 'edr_passenger_app:payments:view', view: 'edr_passenger_app:payments:view',
manage: 'edr_passenger_app:payments:manage', manage: 'edr_passenger_app:payments:manage',
create: 'edr_passenger_app:payments:create', create: 'edr_passenger_app:payments:create',
supplementary: 'edr_passenger_app:payments:supplementary',
edit: 'edr_passenger_app:payments:edit', edit: 'edr_passenger_app:payments:edit',
delete: 'edr_passenger_app:payments:delete', delete: 'edr_passenger_app:payments:delete',
// legacy aliases — still honoured by the backend guards // legacy aliases — still honoured by the backend guards
@@ -121,6 +137,9 @@ export const PERMS = {
refund: 'edr_passenger_app:payments:refund', refund: 'edr_passenger_app:payments:refund',
manageMethods: 'edr_passenger_app:payments:manage_methods', manageMethods: 'edr_passenger_app:payments:manage_methods',
}, },
excessBaggage: {
charge: 'edr_passenger_app:excess_baggage:charge',
},
paymentMethods: { paymentMethods: {
view: 'edr_passenger_app:payment_methods:view', view: 'edr_passenger_app:payment_methods:view',
manage: 'edr_passenger_app:payment_methods:manage', manage: 'edr_passenger_app:payment_methods:manage',

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useAuthStore } from './auth-store'; import { useAuthStore } from './auth-store';
import { PERMS } from './permissions';
/** /**
* Returns whether the current user has a given permission key. * 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)); 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 * Same as usePermission but WITHOUT the super-admin / org-admin bypass — the
* permission must be explicitly granted. Use it wherever the API endpoint is * 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 { export function usePermissionStrict(key: string): boolean {
return useAuthStore((s) => s.hasPermissionStrict(key)); 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]);
}