mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge pull request #993 from Tria-plc/alpha
fix: ( bookings ) require explicit tickets:generate to issue reservat…
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);
|
||||
|
||||
@@ -6,9 +6,25 @@ import {
|
||||
Type,
|
||||
UnauthorizedException,
|
||||
} 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()
|
||||
class PassengerPermissionsGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
@@ -18,10 +34,11 @@ export function PassengerPermissionGuard(permissions: string[]): Type<CanActivat
|
||||
if (!permissions?.length) return true;
|
||||
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(
|
||||
`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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
user: MeLikeUser | null | undefined,
|
||||
permissionKey: string,
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
IssueReservationBookingDto,
|
||||
} from "./guest-booking.dto";
|
||||
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";
|
||||
|
||||
@ApiTags("Booking")
|
||||
@@ -352,12 +352,12 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Post("reservations/:seatId/issue")
|
||||
@PassengerAdmin()
|
||||
@PassengerStaffStrict(PASSENGER_PERMS.tickets.generate)
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@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:
|
||||
"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 })
|
||||
issueBookingFromReservation(
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { seatsApi, schedulesApi, fleetApi, routeCoachTemplatesApi, bookingsApi } from '@/lib/api';
|
||||
import { routesApi } from '@/lib/api/routes';
|
||||
import { usePermission } from '@/lib/use-permission';
|
||||
import { usePermissionStrict } from '@/lib/use-permission';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
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 queryClient = useQueryClient();
|
||||
|
||||
// Issuing a booking off a reserved seat is admin-only (POST /bookings/reservations/:seatId/issue
|
||||
// is guarded by @PassengerAdmin) — hide the ticket action for everyone else.
|
||||
const canIssueBooking = usePermission(PERMS.admin);
|
||||
// Issuing a booking off a reserved seat needs edr_passenger_app:tickets:generate
|
||||
// (POST /bookings/reservations/:seatId/issue is guarded by @PassengerStaffStrict).
|
||||
// Strict: being an admin is not enough, the permission has to be granted.
|
||||
const canIssueBooking = usePermissionStrict(PERMS.tickets.generate);
|
||||
|
||||
const { data: schedulesData } = useQuery({
|
||||
queryKey: ['schedules'],
|
||||
|
||||
@@ -23,6 +23,7 @@ interface AuthState {
|
||||
setUser: (user: AdminUser, token: string) => void;
|
||||
initialize: () => void;
|
||||
hasPermission: (key: string) => boolean;
|
||||
hasPermissionStrict: (key: string) => boolean;
|
||||
}
|
||||
|
||||
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;
|
||||
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 {
|
||||
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