mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 18:48:11 +00:00
fix: ( bookings ) require explicit tickets:generate to issue reservations
This commit is contained in:
@@ -11,4 +11,18 @@ export const PassengerStaff = (permission: string | string[]) =>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like {@link PassengerStaff} but the permission must be explicitly granted —
|
||||||
|
* super admins / org admins get no automatic bypass.
|
||||||
|
*/
|
||||||
|
export const PassengerStaffStrict = (permission: string | string[]) =>
|
||||||
|
applyDecorators(
|
||||||
|
UseGuards(
|
||||||
|
JwtGuard,
|
||||||
|
PassengerPermissionGuard(Array.isArray(permission) ? permission : [permission], {
|
||||||
|
strict: true,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
export const PassengerAdmin = () => PassengerStaff(PASSENGER_PERMS.admin);
|
export const PassengerAdmin = () => PassengerStaff(PASSENGER_PERMS.admin);
|
||||||
|
|||||||
@@ -6,9 +6,25 @@ import {
|
|||||||
Type,
|
Type,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { hasPassengerPermission } from './passenger-permission.util';
|
import {
|
||||||
|
hasPassengerPermission,
|
||||||
|
hasPassengerPermissionStrict,
|
||||||
|
} from './passenger-permission.util';
|
||||||
|
|
||||||
|
export type PassengerPermissionGuardOptions = {
|
||||||
|
/**
|
||||||
|
* When true, super admins and org admins do NOT bypass the check — the
|
||||||
|
* permission key must be explicitly granted to them like anyone else.
|
||||||
|
*/
|
||||||
|
strict?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PassengerPermissionGuard(
|
||||||
|
permissions: string[],
|
||||||
|
options: PassengerPermissionGuardOptions = {},
|
||||||
|
): Type<CanActivate> {
|
||||||
|
const check = options.strict ? hasPassengerPermissionStrict : hasPassengerPermission;
|
||||||
|
|
||||||
export function PassengerPermissionGuard(permissions: string[]): Type<CanActivate> {
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
class PassengerPermissionsGuard implements CanActivate {
|
class PassengerPermissionsGuard implements CanActivate {
|
||||||
canActivate(context: ExecutionContext): boolean {
|
canActivate(context: ExecutionContext): boolean {
|
||||||
@@ -18,10 +34,11 @@ export function PassengerPermissionGuard(permissions: string[]): Type<CanActivat
|
|||||||
if (!permissions?.length) return true;
|
if (!permissions?.length) return true;
|
||||||
if (!user) throw new UnauthorizedException('Authentication required');
|
if (!user) throw new UnauthorizedException('Authentication required');
|
||||||
|
|
||||||
if (permissions.some((p) => hasPassengerPermission(user, p))) return true;
|
if (permissions.some((p) => check(user, p))) return true;
|
||||||
|
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
`Missing permission. Required one of: ${permissions.join(', ')}`,
|
`Missing permission. Required one of: ${permissions.join(', ')}` +
|
||||||
|
(options.strict ? ' (granted explicitly — admin role does not bypass)' : ''),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,20 @@ export function hasPassengerPermission(
|
|||||||
return collectPermissionKeys(user).includes(permissionKey);
|
return collectPermissionKeys(user).includes(permissionKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same check as {@link hasPassengerPermission} but WITHOUT the super-admin /
|
||||||
|
* org-admin bypass — the permission key must be explicitly granted, whether via
|
||||||
|
* a role or an employee position. Use for actions that must stay auditable to a
|
||||||
|
* deliberate grant (e.g. ticket generation, which can waive a fare).
|
||||||
|
*/
|
||||||
|
export function hasPassengerPermissionStrict(
|
||||||
|
user: MeLikeUser | null | undefined,
|
||||||
|
permissionKey: string,
|
||||||
|
): boolean {
|
||||||
|
if (!user) return false;
|
||||||
|
return collectPermissionKeys(user).includes(permissionKey);
|
||||||
|
}
|
||||||
|
|
||||||
export function assertPassengerPermission(
|
export function assertPassengerPermission(
|
||||||
user: MeLikeUser | null | undefined,
|
user: MeLikeUser | null | undefined,
|
||||||
permissionKey: string,
|
permissionKey: string,
|
||||||
|
|||||||
@@ -35,7 +35,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 } from "../../common/passenger-guards";
|
import { PassengerAdmin, PassengerStaff, PassengerStaffStrict } from "../../common/passenger-guards";
|
||||||
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
||||||
|
|
||||||
@ApiTags("Booking")
|
@ApiTags("Booking")
|
||||||
@@ -352,12 +352,12 @@ export class BookingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("reservations/:seatId/issue")
|
@Post("reservations/:seatId/issue")
|
||||||
@PassengerAdmin()
|
@PassengerStaffStrict(PASSENGER_PERMS.tickets.generate)
|
||||||
@ApiBearerAuth("IAM-auth")
|
@ApiBearerAuth("IAM-auth")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Issue a booking from a reserved (blocked) seat — admin only",
|
summary: "Issue a booking from a reserved (blocked) seat — requires tickets:generate",
|
||||||
description:
|
description:
|
||||||
"Converts an admin-reserved seat into a real booking for one traveler. bookingKind STAFF waives the fee and issues the ticket immediately; bookingKind PASSENGER creates the booking as PENDING_PAYMENT and texts a payment link to the traveler's phone. Restricted to admins (edr_passenger_app:admin) because STAFF issuance waives the fare.",
|
"Converts an admin-reserved seat into a real booking for one traveler. bookingKind STAFF waives the fee and issues the ticket immediately; bookingKind PASSENGER creates the booking as PENDING_PAYMENT and texts a payment link to the traveler's phone. Because STAFF issuance waives the fare, this requires edr_passenger_app:tickets:generate to be explicitly granted — super admins and org admins do NOT bypass it.",
|
||||||
})
|
})
|
||||||
@ApiBody({ type: IssueReservationBookingDto })
|
@ApiBody({ type: IssueReservationBookingDto })
|
||||||
issueBookingFromReservation(
|
issueBookingFromReservation(
|
||||||
|
|||||||
@@ -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 { usePermission } from '@/lib/use-permission';
|
import { usePermissionStrict } from '@/lib/use-permission';
|
||||||
import { PERMS } from '@/lib/permissions';
|
import { PERMS } from '@/lib/permissions';
|
||||||
import Modal from '@/components/ui/Modal';
|
import Modal from '@/components/ui/Modal';
|
||||||
import ActionButton from '@/components/ui/ActionButton'
|
import ActionButton from '@/components/ui/ActionButton'
|
||||||
@@ -44,9 +44,10 @@ export default function SeatsPage() {
|
|||||||
const [issueBookingResult, setIssueBookingResult] = useState<{ payUrl?: string; bookingRef?: string } | null>(null);
|
const [issueBookingResult, setIssueBookingResult] = useState<{ payUrl?: string; bookingRef?: string } | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Issuing a booking off a reserved seat is admin-only (POST /bookings/reservations/:seatId/issue
|
// Issuing a booking off a reserved seat needs edr_passenger_app:tickets:generate
|
||||||
// is guarded by @PassengerAdmin) — hide the ticket action for everyone else.
|
// (POST /bookings/reservations/:seatId/issue is guarded by @PassengerStaffStrict).
|
||||||
const canIssueBooking = usePermission(PERMS.admin);
|
// Strict: being an admin is not enough, the permission has to be granted.
|
||||||
|
const canIssueBooking = usePermissionStrict(PERMS.tickets.generate);
|
||||||
|
|
||||||
const { data: schedulesData } = useQuery({
|
const { data: schedulesData } = useQuery({
|
||||||
queryKey: ['schedules'],
|
queryKey: ['schedules'],
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ interface AuthState {
|
|||||||
setUser: (user: AdminUser, token: string) => void;
|
setUser: (user: AdminUser, token: string) => void;
|
||||||
initialize: () => void;
|
initialize: () => void;
|
||||||
hasPermission: (key: string) => boolean;
|
hasPermission: (key: string) => boolean;
|
||||||
|
hasPermissionStrict: (key: string) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||||
@@ -123,4 +124,12 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
if (user.isSuperAdmin || user.isOrgAdmin) return true;
|
if (user.isSuperAdmin || user.isOrgAdmin) return true;
|
||||||
return user.permissions.includes(key);
|
return user.permissions.includes(key);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// No super-admin / org-admin bypass — mirrors PassengerStaffStrict on the API,
|
||||||
|
// so we don't render actions that would 403.
|
||||||
|
hasPermissionStrict: (key: string) => {
|
||||||
|
const { user } = get();
|
||||||
|
if (!user) return false;
|
||||||
|
return user.permissions.includes(key);
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -13,3 +13,15 @@ import { useAuthStore } from './auth-store';
|
|||||||
export function usePermission(key: string): boolean {
|
export function usePermission(key: string): boolean {
|
||||||
return useAuthStore((s) => s.hasPermission(key));
|
return useAuthStore((s) => s.hasPermission(key));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same as usePermission but WITHOUT the super-admin / org-admin bypass — the
|
||||||
|
* permission must be explicitly granted. Use it wherever the API endpoint is
|
||||||
|
* guarded with PassengerStaffStrict, so the UI matches what the API allows.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const canIssue = usePermissionStrict(PERMS.tickets.generate);
|
||||||
|
*/
|
||||||
|
export function usePermissionStrict(key: string): boolean {
|
||||||
|
return useAuthStore((s) => s.hasPermissionStrict(key));
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user