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

@@ -13,6 +13,9 @@ import Pagination from '@/components/ui/Pagination';
import { usePagination } from '@/lib/use-pagination';
import { formatCurrency, formatDateTime } from '@/lib/utils';
import { useAuthStore } from '@/lib/auth-store';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
<div className="bg-muted/40 rounded-lg p-3">
@@ -27,9 +30,13 @@ const SectionHeader = ({ title }: { title: string }) => (
</h3>
);
export default function AgentsPage() {
function AgentsPageContent() {
const { user } = useAuthStore();
const queryClient = useQueryClient();
const canCreate = useWritePermission(PERMS.agents.create, PERMS.agents.manage);
const canEdit = useWritePermission(PERMS.agents.edit, PERMS.agents.manage);
const canDelete = useDeletePermission(PERMS.agents.delete);
const [filters, setFilters] = useState({ search: '', active: '' });
const [selected, setSelected] = useState<any>(null);
const [createModal, setCreateModal] = useState(false);
@@ -131,6 +138,7 @@ export default function AgentsPage() {
const actions = [
{
label: 'Edit',
show: () => canEdit,
onClick: (agent: any) => openEditModal(agent),
variant: 'secondary' as const,
icon: Edit,
@@ -143,6 +151,7 @@ export default function AgentsPage() {
},
{
label: 'Delete',
show: () => canDelete,
onClick: (agent: any) => { setDeleteError(null); setDeleteConfirm({ isOpen: true, agent }); },
variant: 'danger' as const,
icon: Trash2,
@@ -156,7 +165,7 @@ export default function AgentsPage() {
<h1 className="text-2xl font-bold">Agents</h1>
<p className="text-muted-foreground">Manage agents and their operations</p>
</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 className="card">
@@ -394,3 +403,11 @@ export default function AgentsPage() {
</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 { appReleasesApi } from '@/lib/api';
import { formatDateTime } from '@/lib/utils';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
const EMPTY_FORM = { os: 'android', version: '', forceUpdate: false, storeLink: '', notes: '' };
export default function AppReleasesPage() {
function AppReleasesPageContent() {
const queryClient = useQueryClient();
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<any>(null);
@@ -184,3 +186,11 @@ export default function AppReleasesPage() {
</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 { useRouter } from 'next/navigation';
import Header from '@/components/layout/Header';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { useWritePermission } from '@/lib/use-permission';
import { PERMS } from '@/lib/permissions';
// Add QR Scanner component
function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onError: (error: string) => void }) {
@@ -315,7 +318,7 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
);
}
export default function BoardingPage() {
function BoardingPageContent() {
const [qrInput, setQrInput] = useState('');
const [lastScanned, setLastScanned] = useState<any>(null);
const [error, setError] = useState<string | null>(null);
@@ -341,6 +344,8 @@ export default function BoardingPage() {
retry: false,
});
const canBoard = useWritePermission(PERMS.tickets.board, PERMS.tickets.manage);
const boardingMutation = useMutation({
mutationFn: (qrCodeOrRef: string) =>
ticketsApi.scanAndBoard(qrCodeOrRef, {
@@ -482,7 +487,8 @@ export default function BoardingPage() {
<div className="flex gap-3">
<button
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
text-white font-semibold py-4 px-6 rounded-xl transition-colors
disabled:cursor-not-allowed text-lg"
@@ -605,4 +611,12 @@ export default function BoardingPage() {
</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 Modal from '@/components/ui/Modal';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { usePermission } from '@/lib/use-permission';
import { useDeletePermission, usePermission } from '@/lib/use-permission';
import { PERMS } from '@/lib/permissions';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { bookingsApi, apiClient } from '@/lib/api';
@@ -30,7 +30,7 @@ const SectionHeader = ({ title }: { title: string }) => (
);
function BookingsPageContent() {
const canManage = usePermission(PERMS.bookings.manage);
const canDeleteBooking = useDeletePermission(PERMS.bookings.delete);
// Mirrors the API guard on POST /payments/:bookingId/force-confirm —
// tickets:generate, with the usual super-admin / org-admin bypass.
const canGenerateTicket = usePermission(PERMS.tickets.generate);
@@ -302,7 +302,7 @@ function BookingsPageContent() {
const actions = [
{ label: 'View Details', onClick: (b: any) => setSelectedBooking(b), variant: 'secondary' as const, icon: Eye },
{ label: 'Generate Ticket', onClick: (b: any) => { setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); setGenerateTicketTouched({ paymentReference: false, paymentMethod: false }); setGenerateTicketBooking(b); }, variant: 'secondary' as const, icon: Ticket, show: (b: any) => canGenerateTicket && !(b.status === 'CONFIRMED' && b.paymentIntent?.status === 'SUCCEEDED') },
{ label: 'Delete', onClick: (b: any) => { setDeleteError(null); setDeleteCascade(false); setDeleteCascadeChecked(false); setBookingToDelete(b); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2 },
{ label: 'Delete', onClick: (b: any) => { setDeleteError(null); setDeleteCascade(false); setDeleteCascadeChecked(false); setBookingToDelete(b); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2, show: () => canDeleteBooking },
];
return (

View File

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

View File

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

View File

@@ -8,6 +8,8 @@ import Modal from '@/components/ui/Modal';
import ActionButton from '@/components/ui/ActionButton';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { apiClient } from '@/lib/api-client';
import { PERMS } from '@/lib/permissions';
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
interface ExchangeRate {
id: string;
@@ -35,6 +37,10 @@ export default function CurrenciesPage() {
const [deleteConfirm, setDeleteConfirm] = useState<ExchangeRate | null>(null);
const queryClient = useQueryClient();
const canCreate = useWritePermission(PERMS.currencies.create, PERMS.currencies.manage);
const canEdit = useWritePermission(PERMS.currencies.edit, PERMS.currencies.manage);
const canDelete = useDeletePermission(PERMS.currencies.delete);
const { data: rates = [], isLoading } = useQuery<ExchangeRate[]>({
queryKey: ['currencies'],
queryFn: () => apiClient.get('/currencies'),
@@ -124,12 +130,14 @@ export default function CurrenciesPage() {
const actions = [
{
label: 'Edit',
show: () => canEdit,
onClick: (r: ExchangeRate) => { setEditingRate(r); setRateInput(String(r.rate)); setError(null); },
variant: 'secondary' as const,
icon: Edit,
},
{
label: 'Delete',
show: () => canDelete,
onClick: (r: ExchangeRate) => setDeleteConfirm(r),
variant: 'danger' as const,
icon: Trash2,
@@ -143,7 +151,7 @@ export default function CurrenciesPage() {
<h1 className="text-3xl font-bold text-foreground">Exchange Rates</h1>
<p className="text-muted-foreground mt-1">Manage currency exchange rates</p>
</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
</ActionButton>
</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 { seatsApi } from '@/lib/api';
import { formatDateTime } from '@/lib/utils';
import { useWritePermission } from '@/lib/use-permission';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
// ── Types ─────────────────────────────────────────────────────────────────
@@ -300,9 +303,10 @@ interface CoachCardProps {
coach: CoachReport;
schedule: ScheduleReport;
onResolve: () => void;
canResolve?: boolean;
}
function CoachCard({ coach, schedule, onResolve }: CoachCardProps) {
function CoachCard({ coach, schedule, onResolve, canResolve = false }: CoachCardProps) {
const [expanded, setExpanded] = useState(false);
const hasDuplicates = coach.duplicates.length > 0;
@@ -330,7 +334,9 @@ function CoachCard({ coach, schedule, onResolve }: CoachCardProps) {
{hasDuplicates && (
<button
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
</button>
@@ -379,7 +385,9 @@ function CoachCard({ coach, schedule, onResolve }: CoachCardProps) {
// ── Main page ─────────────────────────────────────────────────────────────
export default function DiscrepancyPage() {
function DiscrepancyPageContent() {
// POST /seats/duplicates/resolve is guarded by seats:edit (it deletes seat rows).
const canResolveDuplicates = useWritePermission(PERMS.seats.edit, PERMS.seats.manage);
const [date, setDate] = useState(today());
const [searchDate, setSearchDate] = useState('');
const [resolveTarget, setResolveTarget] = useState<{ schedule: ScheduleReport; coach: CoachReport } | null>(null);
@@ -488,6 +496,7 @@ export default function DiscrepancyPage() {
coach={coach}
schedule={schedule}
onResolve={() => setResolveTarget({ schedule, coach })}
canResolve={canResolveDuplicates}
/>
))}
</div>
@@ -517,3 +526,11 @@ export default function DiscrepancyPage() {
</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 { formatDateTime, formatCurrency } from '@/lib/utils';
import { useAuthStore } from '@/lib/auth-store';
import { useWritePermission } from '@/lib/use-permission';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
const STATUS_VARIANT: Record<string, any> = {
PENDING: 'PENDING',
@@ -19,8 +22,11 @@ const STATUS_VARIANT: Record<string, any> = {
WAIVED: 'CANCELLED',
};
export default function ExcessBaggagePage() {
function ExcessBaggagePageContent() {
const queryClient = useQueryClient();
// Logging or resending a luggage charge bills the passenger and sends a pay link.
const canCharge = useWritePermission(PERMS.excessBaggage.charge, PERMS.bookings.manage);
const [filters, setFilters] = useState({ status: '', bookingRef: '', dateFrom: '', dateTo: '', page: '1' });
const [showExtraFilters, setShowExtraFilters] = useState(false);
const user = useAuthStore((s) => s.user);
@@ -173,7 +179,7 @@ export default function ExcessBaggagePage() {
icon: Send,
variant: 'secondary' as const,
onClick: (c: any) => { setResendModal(c); setResendSuccess(false); setResendError(null); },
show: (c: any) => c.status === 'PENDING',
show: (c: any) => canCharge && c.status === 'PENDING',
},
{
label: 'Waive',
@@ -202,7 +208,12 @@ export default function ExcessBaggagePage() {
<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>
</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
</ActionButton>
</div>
@@ -421,3 +432,11 @@ export default function ExcessBaggagePage() {
</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 { fraudApi } from '@/lib/api';
import { formatDateTime } from '@/lib/utils';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
import { useWritePermission } from '@/lib/use-permission';
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
<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',
};
export default function FraudDetectionPage() {
function FraudDetectionPageContent() {
const [filters, setFilters] = useState({ search: '', severity: '', status: '' });
const [selected, setSelected] = useState<any>(null);
const queryClient = useQueryClient();
const canManageFraud = useWritePermission(PERMS.fraud.edit, PERMS.fraud.manage);
const { data, isLoading } = useQuery({
queryKey: ['fraud-alerts', filters],
queryFn: () => fraudApi.getAlerts(filters),
@@ -130,10 +135,11 @@ export default function FraudDetectionPage() {
onClick: handleAcknowledge,
variant: 'primary' as const,
icon: CheckCircle,
show: (alert: any) => !alert.acknowledged,
show: (alert: any) => canManageFraud && !alert.acknowledged,
},
{
label: 'Block User',
show: () => canManageFraud,
onClick: handleBlockUser,
variant: 'danger' as const,
icon: Ban,
@@ -301,3 +307,11 @@ export default function FraudDetectionPage() {
</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 { notificationsApi } from '@/lib/api';
import { formatDateTime } from '@/lib/utils';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
const CHANNEL_OPTIONS = ['EMAIL', 'SMS', 'PUSH', 'IN_APP'];
export default function NotificationsPage() {
function NotificationsPageContent() {
const [showModal, setShowModal] = useState(false);
const [editing, setEditing] = useState<any | null>(null);
const [templateError, setTemplateError] = useState<string | null>(null);
@@ -313,3 +315,11 @@ export default function NotificationsPage() {
</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 { reportsApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
export default function OperationalReportsPage() {
function OperationalReportsPageContent() {
const [filters, setFilters] = useState({ search: '', reportType: '' });
const [selectedReport, setSelectedReport] = useState<any>(null);
const [showDetailsModal, setShowDetailsModal] = useState(false);
@@ -465,3 +467,11 @@ export default function OperationalReportsPage() {
</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 { formatDateTime, formatCurrency } from '@/lib/utils';
import { getErrorMessage } from '@/lib/api-client';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
<div className="bg-muted/40 rounded-lg p-3">
@@ -25,7 +27,7 @@ const SectionHeader = ({ title }: { title: string }) => (
</h3>
);
export default function PackageBookingsPage() {
function PackageBookingsPageContent() {
const [filters, setFilters] = useState({ packageId: '', status: '', page: 1, pageSize: 20 });
const [selected, setSelected] = useState<any>(null);
@@ -264,3 +266,11 @@ export default function PackageBookingsPage() {
</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 { packageInquiriesApi, packagesApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
const STATUSES = ['NEW', 'CONTACTED', 'CONVERTED', 'CLOSED'];
@@ -19,12 +22,15 @@ const statusVariant: Record<string, string> = {
CLOSED: 'default',
};
export default function PackageInquiriesPage() {
function PackageInquiriesPageContent() {
const [filters, setFilters] = useState({ packageId: '', status: '' });
const [deleteConfirm, setDeleteConfirm] = useState<any>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const queryClient = useQueryClient();
const canEdit = useWritePermission(PERMS.inquiries.edit, PERMS.inquiries.manage);
const canDelete = useDeletePermission(PERMS.inquiries.delete);
const { data, isLoading } = useQuery({
queryKey: ['package-inquiries', filters],
queryFn: () => packageInquiriesApi.getAll({ ...filters, pageSize: 50 }),
@@ -112,6 +118,8 @@ export default function PackageInquiriesPage() {
<select
className="input py-1 text-xs"
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 })}
>
{STATUSES.map((s) => (
@@ -125,6 +133,7 @@ export default function PackageInquiriesPage() {
const actions = [
{
label: 'Delete',
show: () => canDelete,
onClick: (row: any) => { setDeleteConfirm(row); setDeleteError(null); },
variant: 'danger' as const,
icon: Trash2,
@@ -191,3 +200,11 @@ export default function PackageInquiriesPage() {
</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 { getErrorMessage } from '@/lib/api-client';
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
// rejected instantly client-side instead of round-tripping to the server first.
@@ -33,7 +36,7 @@ const emptyForm = {
validFrom: '', validUntil: '',
};
export default function PackagesPage() {
function PackagesPageContent() {
const [page] = useState(1);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('');
@@ -65,6 +68,11 @@ export default function PackagesPage() {
const [removeImageConfirm, setRemoveImageConfirm] = useState<any>(null);
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({
queryKey: ['packages', page],
queryFn: () => packagesApi.getAll({ page, pageSize: 20 }),
@@ -368,23 +376,25 @@ export default function PackagesPage() {
const actions = [
{ 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,
show: () => canEdit,
onClick: (p: any) => { setTiersPackage(p); setEditingTier(null); setTierForm({ seatClassId: '', seatType: '', label: '', priceMinor: '', availableSeats: '' }); setTierError(null); },
},
{
label: 'Activate', icon: CheckCircle, variant: 'primary' as const,
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,
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,
show: () => canDelete,
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>
<p className="text-muted-foreground">Manage travel packages and pilgrimages</p>
</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>
{/* 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>
);
}
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 { formatDate, formatDateTime } from '@/lib/utils';
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 }) => (
<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',
};
export default function PassengersPage() {
function PassengersPageContent() {
const [filters, setFilters] = useState<PassengerFilters>({ page: 1, pageSize: 20, search: '', role: 'PASSENGER' });
const [showExtraFilters, setShowExtraFilters] = useState(false);
const [extraFilters, setExtraFilters] = useState({ gender: '', nationality: '', dateFrom: '', dateTo: '' });
@@ -51,6 +54,8 @@ export default function PassengersPage() {
const queryClient = useQueryClient();
const canDelete = useDeletePermission(PERMS.passengers.delete);
const deleteMutation = useMutation({
mutationFn: ({ id, cascade }: { id: string; cascade: boolean }) => passengersApi.delete(id, cascade),
onSuccess: () => {
@@ -156,7 +161,7 @@ export default function PassengersPage() {
const actions = [
{ 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 (
@@ -448,3 +453,11 @@ export default function PassengersPage() {
</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 { apiClient, paymentsApi } from '@/lib/api';
import { usePermission } from '@/lib/use-permission';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
export default function PaymentMethodsPage() {
function PaymentMethodsPageContent() {
const canManageMethods = usePermission(PERMS.paymentMethods.manage);
const canManageAdmin = usePermission(PERMS.admin);
const canManage = canManageMethods || canManageAdmin;
@@ -417,4 +418,12 @@ export default function PaymentMethodsPage() {
/>
</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 { formatDateTime, formatCurrency } from '@/lib/utils';
import SupplementaryChargesModal from './SupplementaryChargesModal';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
import {
useSupplementaryCharges,
useMarkSupplementaryPaid,
@@ -120,6 +123,10 @@ function PaymentsPageContent() {
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({
mutationFn: (id: string) => apiClient.delete(`/payments/${id}`),
onSuccess: () => {
@@ -209,7 +216,7 @@ function PaymentsPageContent() {
const paymentActions = [
{ 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 (
@@ -221,7 +228,7 @@ function PaymentsPageContent() {
</div>
<div className="flex items-center gap-2">
{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' && (
<ActionButton icon={Download} variant="export" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
@@ -531,8 +538,10 @@ function PaymentsPageContent() {
export default function PaymentsPage() {
return (
<Suspense fallback={null}>
<PaymentsPageContent />
</Suspense>
<PermissionGuard permission={PERMS.payments.view}>
<Suspense fallback={null}>
<PaymentsPageContent />
</Suspense>
</PermissionGuard>
);
}

View File

@@ -45,6 +45,8 @@ import { usePagination } from "@/lib/use-pagination";
import { formatCurrency, formatDateTime } from "@/lib/utils";
import { categoricalColor, getChartPalette } from "@/lib/chart-palette";
import { useTheme } from "@/lib/theme-store";
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
interface RouteOption {
id: string;
@@ -71,7 +73,7 @@ function reasonLabel(category: string | null): string {
const TABLE_PAGE_SIZE = 25;
export default function BlockedSeatRevenueLossPage() {
function BlockedSeatRevenueLossPageContent() {
const isDark = useTheme((s) => s.isDark);
const palette = getChartPalette(isDark);
@@ -917,3 +919,11 @@ function BlockDetailTable({ blocks }: { blocks: BlockedSeatLossDetail[] }) {
</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 Pagination from "@/components/ui/Pagination";
import { usePagination } from "@/lib/use-pagination";
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
interface ScheduleOption {
id: string;
@@ -50,7 +52,7 @@ interface BoardingReport {
type Tab = "summary" | "details";
export default function BoardingReportPage() {
function BoardingReportPageContent() {
const [scheduleId, setScheduleId] = useState("");
const [tab, setTab] = useState<Tab>("summary");
const [search, setSearch] = useState("");
@@ -380,3 +382,11 @@ export default function BoardingReportPage() {
</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 Pagination from "@/components/ui/Pagination";
import { usePagination } from "@/lib/use-pagination";
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
interface ScheduleOption {
id: string;
@@ -28,7 +30,7 @@ interface CoachUtilizationRow {
totalBookings: number;
}
export default function CoachUtilizationReportPage() {
function CoachUtilizationReportPageContent() {
const [scheduleId, setScheduleId] = useState("");
const [search, setSearch] = useState("");
@@ -360,3 +362,11 @@ export default function CoachUtilizationReportPage() {
</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 { categoricalColor, getChartPalette } from '@/lib/chart-palette';
import { useTheme } from '@/lib/theme-store';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
interface StationOption {
id: string;
@@ -101,7 +103,7 @@ function FinanceReportSkeleton() {
);
}
export default function FinanceReportPage() {
function FinanceReportPageContent() {
const isDark = useTheme((s) => s.isDark);
const palette = getChartPalette(isDark);
@@ -558,3 +560,11 @@ export default function FinanceReportPage() {
</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 ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
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;');
}
export default function ReportsPage() {
function ReportsPageContent() {
const [dateRange, setDateRange] = useState('30');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
@@ -709,3 +711,11 @@ export default function ReportsPage() {
</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 Pagination from "@/components/ui/Pagination";
import { usePagination } from "@/lib/use-pagination";
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
interface ScheduleOption {
id: string;
@@ -70,7 +72,7 @@ interface PassengerRow {
type Tab = "occupancy" | "list";
export default function PassengersReportPage() {
function PassengersReportPageContent() {
const [scheduleId, setScheduleId] = useState("");
const [tab, setTab] = useState<Tab>("occupancy");
const [listSearch, setListSearch] = useState("");
@@ -592,3 +594,11 @@ export default function PassengersReportPage() {
</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 Pagination from '@/components/ui/Pagination';
import { usePagination } from '@/lib/use-pagination';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
// ── Types ─────────────────────────────────────────────────────────────────────
@@ -121,7 +123,7 @@ function BalanceBadge({ row }: { row: DiscrepancyRow }) {
type Applied = { from: string; to: string; sortBy: string; search: string };
export default function PaymentDiscrepancyPage() {
function PaymentDiscrepancyPageContent() {
const [from, setFrom] = useState('');
const [to, setTo] = useState('');
const [sortBy, setSortBy] = useState<'balance' | 'departure'>('balance');
@@ -479,3 +481,11 @@ export default function PaymentDiscrepancyPage() {
</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,
} from 'lucide-react';
import { apiClient } from '@/lib/api-client';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
// ── Types ─────────────────────────────────────────────────────────────────────
@@ -101,7 +103,7 @@ function downloadCsv(csv: string, filename: string) {
// ── Discrepancy page ──────────────────────────────────────────────────────────
export default function PaymentsReportPage() {
function PaymentsReportPageContent() {
const [scheduleId, setScheduleId] = useState('');
const [search, setSearch] = useState('');
const [seatClass, setSeatClass] = useState('');
@@ -355,3 +357,11 @@ export default function PaymentsReportPage() {
</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 Pagination from "@/components/ui/Pagination";
import { usePagination } from "@/lib/use-pagination";
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
interface ScheduleOption {
id: string;
@@ -56,7 +58,7 @@ interface SeatStatusReport {
type Tab = "seats" | "blocked";
export default function SeatStatusReportPage() {
function SeatStatusReportPageContent() {
const [scheduleId, setScheduleId] = useState("");
const [tab, setTab] = useState<Tab>("seats");
const [statusFilter, setStatusFilter] = useState<"ALL" | "PAID" | "UNPAID">("ALL");
@@ -383,3 +385,11 @@ export default function SeatStatusReportPage() {
</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
* policy cannot be rescheduled at all. Gated on bookings:view because that is what
* `GET /reschedule/policies` requires; creating, editing and deleting are admin-only server-side.
* policy cannot be rescheduled at all. Gated on reschedule_policies:view (or :manage) — bookings:view no longer
* grants it, so the page needs its own grant. Create/edit/delete each have their own key.
*/
export default function ReschedulePoliciesPage() {
return (
<PermissionGuard permission={PERMS.bookings.view}>
<PermissionGuard permission={[PERMS.reschedulePolicies.view, PERMS.reschedulePolicies.manage]}>
<div className="space-y-6">
<div>
<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 { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
interface RouteStop {
stationId: string;
@@ -193,6 +194,10 @@ function RoutesPageContent() {
}, [error]);
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({
queryKey: ['routes'],
queryFn: async () => {
@@ -445,12 +450,14 @@ function RoutesPageContent() {
const routeActions = [
{
label: 'Edit',
show: () => canEdit,
onClick: openEditModal,
variant: 'secondary' as const,
icon: Edit,
},
{
label: 'Delete',
show: () => canDelete,
onClick: handleDelete,
variant: 'danger' as const,
icon: Trash2,
@@ -479,6 +486,9 @@ function RoutesPageContent() {
setError(null);
setShowModal(true);
}}
disabled={!canCreate}
title={canCreate ? undefined : 'You do not have permission to create routes'}
>
Add Route
</ActionButton>

View File

@@ -14,6 +14,7 @@ import { usePagination } from '@/lib/use-pagination';
import { formatDateTime } from '@/lib/utils';
import { eatLocalToISO, isoToEATLocal } from '@/lib/timezone';
import DateTimePicker from '@/components/ui/DateTimePicker';
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
@@ -80,6 +81,13 @@ function SchedulesPageContent() {
const errorBannerRef = useRef<HTMLDivElement>(null);
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
// visible indication anything went wrong. Scroll the banner into view when a new error appears.
useEffect(() => {
@@ -536,12 +544,16 @@ function SchedulesPageContent() {
const [delayMinutesInput, setDelayMinutesInput] = useState('');
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 = [
{
label: 'Edit',
onClick: handleEditClick,
variant: 'secondary' as const,
icon: Edit,
show: () => canEdit,
},
{
label: 'Report Delay',
@@ -552,20 +564,21 @@ function SchedulesPageContent() {
},
variant: 'secondary' as const,
icon: Clock,
hidden: (schedule: Schedule) => schedule.status === 'CANCELLED',
show: (schedule: Schedule) => canEdit && schedule.status !== 'CANCELLED',
},
{
label: 'Cancel',
onClick: (schedule: Schedule) => setCancelConfirm({ isOpen: true, item: schedule }),
variant: 'danger' as const,
icon: X,
hidden: (schedule: Schedule) => schedule.status === 'CANCELLED',
show: (schedule: Schedule) => canCancel && schedule.status !== 'CANCELLED',
},
{
label: 'Delete',
onClick: handleDelete,
variant: 'danger' as const,
icon: Trash2,
show: () => canDelete,
},
];
@@ -582,6 +595,8 @@ function SchedulesPageContent() {
onClick={handleBulkDelete}
variant="danger"
loading={bulkDeleteMutation.isPending}
disabled={!canDelete}
title={canDelete ? undefined : 'You do not have permission to delete schedules'}
>
Delete {selectedSchedules.size} Schedule{selectedSchedules.size !== 1 ? 's' : ''}
</ActionButton>
@@ -590,6 +605,8 @@ function SchedulesPageContent() {
icon={Plus}
variant="secondary"
onClick={() => { setError(null); setShowAddModal(true); }}
disabled={!canCreate}
title={canCreate ? undefined : 'You do not have permission to create schedules'}
>
Add Schedule
</ActionButton>
@@ -599,6 +616,8 @@ function SchedulesPageContent() {
setError(null);
setShowModal(true);
}}
disabled={!canCreate}
title={canCreate ? undefined : 'You do not have permission to create schedules'}
>
Bulk Generate
</ActionButton>

View File

@@ -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 { usePermissionStrict } from '@/lib/use-permission';
import { useDeletePermission, usePermissionStrict, useWritePermission } from '@/lib/use-permission';
import { PERMS } from '@/lib/permissions';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
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.
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({
queryKey: ['schedules'],
queryFn: () => schedulesApi.getAll(),
@@ -478,6 +485,10 @@ function SeatsPageContent() {
handleClearMaintenance={handleClearMaintenance}
handleIssueBooking={handleIssueBooking}
canIssueBooking={canIssueBooking}
canBlockSeats={canBlockSeats}
canEditSeats={canEditSeats}
canDeleteSeats={canDeleteSeats}
canCancelBooking={canCancelBooking}
hideNumber={true}
/>
))}
@@ -576,6 +587,10 @@ function SeatsPageContent() {
handleClearMaintenance={handleClearMaintenance}
handleIssueBooking={handleIssueBooking}
canIssueBooking={canIssueBooking}
canBlockSeats={canBlockSeats}
canEditSeats={canEditSeats}
canDeleteSeats={canDeleteSeats}
canCancelBooking={canCancelBooking}
hideNumber={true}
/>
))}
@@ -601,6 +616,10 @@ function SeatsPageContent() {
handleClearMaintenance={handleClearMaintenance}
handleIssueBooking={handleIssueBooking}
canIssueBooking={canIssueBooking}
canBlockSeats={canBlockSeats}
canEditSeats={canEditSeats}
canDeleteSeats={canDeleteSeats}
canCancelBooking={canCancelBooking}
hideNumber={true}
/>
))}
@@ -846,7 +865,8 @@ function SeatsPageContent() {
size="sm"
onClick={() => isCoachBlocked(coach) ? handleUnblockCoach(coach) : handleBlockCoach(coach)}
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) ? (
<>
@@ -1326,6 +1346,10 @@ interface SeatIconProps {
handleClearMaintenance: (seat: any) => void;
handleIssueBooking: (seat: any, coach: any) => void;
canIssueBooking?: boolean;
canBlockSeats?: boolean;
canEditSeats?: boolean;
canDeleteSeats?: boolean;
canCancelBooking?: boolean;
}
function SeatIcon({
@@ -1345,6 +1369,10 @@ function SeatIcon({
handleClearMaintenance,
handleIssueBooking,
canIssueBooking = false,
canBlockSeats = false,
canEditSeats = false,
canDeleteSeats = false,
canCancelBooking = false,
}: SeatIconProps) {
const isRemoved = seat.seatNumber && seat.seatNumber.startsWith('-');
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>
<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
onClick={() => handleUndoRemove(seat)}
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>
{canEditSeats && (
<button
onClick={() => handleUndoRemove(seat)}
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>
)}
</div>
</div>
);
@@ -1376,14 +1406,14 @@ function SeatIcon({
const status = getSeatStatus(seat);
const color = getSeatColor(status);
const canBlock = status === 'AVAILABLE';
const canUnblock = status === 'BLOCKED';
const canBlock = canBlockSeats && status === 'AVAILABLE';
const canUnblock = canBlockSeats && status === 'BLOCKED';
// A HELD seat with a bookingRef + PENDING_PAYMENT is a backoffice reservation awaiting
// 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.
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 canClearMaintenance = status === 'UNDER_MAINTENANCE';
const canClearMaintenance = canEditSeats && status === 'UNDER_MAINTENANCE';
return (
<div className="relative group flex flex-col items-center">
@@ -1431,13 +1461,15 @@ function SeatIcon({
>
<Lock className="h-3 w-3 text-gray-700" />
</button>
<button
onClick={() => handleRemoveSeat(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>
{canDeleteSeats && (
<button
onClick={() => handleRemoveSeat(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>
)}
</>
)}
{canCancelReservation && (

View File

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

View File

@@ -9,6 +9,8 @@ import ConfirmDialog from '@/components/ui/ConfirmDialog';
import Modal from '@/components/ui/Modal';
import { useRouteFareRules, useRouteFareRuleMutations, useSeatClasses } from './hooks';
import type { Route, RouteFareRule, SeatClass } from './types';
import { PERMS } from '@/lib/permissions';
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
interface Props {
routes: Route[];
@@ -21,6 +23,12 @@ type OverrideForm = {
};
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 [deleteConfirm, setDeleteConfirm] = useState<{
isOpen: boolean;
@@ -148,6 +156,9 @@ export default function OverridesTab({ routes }: Props) {
<ActionButton
icon={Plus}
onClick={() => setForm({ isOpen: true, rule: null, error: null })}
disabled={!canCreateFare}
title={canCreateFare ? undefined : 'You do not have permission to create fare overrides'}
>
Add Override
</ActionButton>
@@ -165,8 +176,9 @@ export default function OverridesTab({ routes }: Props) {
{
label: 'Edit', icon: Edit, variant: 'secondary' as const,
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}
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 { useQuery } from '@tanstack/react-query';
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 } }
@@ -50,6 +52,12 @@ interface Props { routes: Route[] }
type FormState = { isOpen: boolean; rule: SegmentFareRule | null; error: string | null };
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 [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: '' });
@@ -164,7 +172,12 @@ export default function SegmentOverridesTab({ routes }: Props) {
))}
</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
</ActionButton>
</div>
@@ -176,8 +189,8 @@ export default function SegmentOverridesTab({ routes }: Props) {
data={segmentFares}
columns={columns}
actions={[
{ label: 'Edit', icon: Edit, variant: 'secondary' as const, onClick: (r: SegmentFareRule) => setForm({ isOpen: true, rule: r, error: null }) },
{ label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: (r: SegmentFareRule) => setDeleteConfirm({ isOpen: true, id: r.id, name: `${stopLabel(r.originStopSequence)}${stopLabel(r.destinationStopSequence)}` }) },
{ label: 'Edit', icon: Edit, variant: 'secondary' as const, onClick: (r: SegmentFareRule) => setForm({ isOpen: true, rule: r, error: null }), show: () => canEditFare },
{ label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: (r: SegmentFareRule) => setDeleteConfirm({ isOpen: true, id: r.id, name: `${stopLabel(r.originStopSequence)}${stopLabel(r.destinationStopSequence)}` }), show: () => canDeleteFare },
]}
loading={isLoading}
emptyMessage="No segment overrides for this route."

View File

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

View File

@@ -10,8 +10,11 @@ import BaggageTab from './BaggageTab';
import RateModal from './RateModal';
import { useSeatClasses, useCoachTypes, useRoutes, useSeatClassMutations, useRouteFareRuleMutations } from './hooks';
import type { SeatClass, TabType } from './types';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
export default function TariffRatesPage() {
function TariffRatesPageContent() {
const [tab, setTab] = useState<TabType>('tariff');
const [showRateModal, setShowRateModal] = useState(false);
const [editingClass, setEditingClass] = useState<SeatClass | null>(null);
@@ -20,6 +23,10 @@ export default function TariffRatesPage() {
const [deleteError, setDeleteError] = useState<string | undefined>(undefined);
const [deleteSuccess, setDeleteSuccess] = useState(0);
const canCreateRate = useWritePermission(PERMS.tariffRates.create, PERMS.tariffRates.manage);
const canEditRate = useWritePermission(PERMS.tariffRates.edit, PERMS.tariffRates.manage);
const canDeleteRate = useDeletePermission(PERMS.tariffRates.delete);
const { allClasses, isLoading } = useSeatClasses();
const { coachTypes } = useCoachTypes();
const { routes } = useRoutes();
@@ -71,7 +78,11 @@ export default function TariffRatesPage() {
</p>
</div>
{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') {
setShowBaggageModal(true);
} else {
@@ -105,6 +116,8 @@ export default function TariffRatesPage() {
isLoading={isLoading}
onEdit={cls => { setEditingClass(cls); setPreselectedRouteId(null); setShowRateModal(true); }}
onDelete={handleDelete}
canEdit={canEditRate}
canDelete={canDeleteRate}
isDeleting={seatClassMutations.remove.isPending}
deleteError={deleteError}
deleteSuccess={deleteSuccess}
@@ -143,3 +156,11 @@ export default function TariffRatesPage() {
</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 { formatDateTime, formatCurrency, formatDateTimeShort } from '@/lib/utils';
import { useAuthStore } from '@/lib/auth-store';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
import { usePermission, useWritePermission, useDeletePermission } from '@/lib/use-permission';
export default function TicketsPage() {
function TicketsPageContent() {
const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', departureDate: '', arrivalDate: '', dateFrom: '', dateTo: '', coachId: '' });
const [ticketPage, setTicketPage] = useState(1);
const resetTicketPage = () => setTicketPage(1);
@@ -69,6 +72,13 @@ export default function TicketsPage() {
});
const queryClient = useQueryClient();
const canGenerate = usePermission(PERMS.tickets.generate);
const canEditTicket = useWritePermission(PERMS.tickets.edit, PERMS.tickets.manage);
const canDelete = useDeletePermission(PERMS.tickets.delete);
// Logging luggage bills the passenger and sends a pay link, so it is its own grant.
const canLogBaggage = useWritePermission(PERMS.excessBaggage.charge, PERMS.bookings.manage);
const canBoard = useWritePermission(PERMS.tickets.board, PERMS.tickets.manage);
const { data, isLoading, error } = useQuery({
queryKey: ['tickets', filters, ticketPage],
queryFn: () => ticketsApi.getAll({
@@ -554,7 +564,7 @@ export default function TicketsPage() {
onClick: openExcessModal,
variant: 'secondary' as const,
icon: Package,
show: (ticket: any) => !!ticket.booking && ['CONFIRMED', 'BOARDED'].includes(ticket.booking?.status ?? ticket.status),
show: (ticket: any) => canLogBaggage && !!ticket.booking && ['CONFIRMED', 'BOARDED'].includes(ticket.booking?.status ?? ticket.status),
},
{
label: 'Board',
@@ -562,6 +572,7 @@ export default function TicketsPage() {
variant: 'primary' as const,
icon: LogIn,
show: (ticket: any) => {
if (!canBoard) return false;
const isRoundTrip = ticket.booking?.bookingType === 'ROUND_TRIP' || ticket.booking?.bookingType === 'ROUND_TRIP_TRANSIT';
if (isRoundTrip) {
const inboundBoarded = !!ticket.booking?.returnBoardedAt;
@@ -595,10 +606,11 @@ export default function TicketsPage() {
onClick: (ticket: any) => restoreMutation.mutate(ticket.id),
variant: 'secondary' as const,
icon: ListCollapse,
show: (ticket: any) => ticket.status === 'CANCELLED',
show: (ticket: any) => canEditTicket && ticket.status === 'CANCELLED',
},
{
label: 'Delete',
show: () => canDelete,
onClick: handleDeleteClick,
variant: 'danger' as const,
icon: Trash2,
@@ -621,6 +633,8 @@ export default function TicketsPage() {
variant="secondary"
loading={generateMissingMutation.isPending}
onClick={() => generateMissingMutation.mutate()}
disabled={!canGenerate}
title={canGenerate ? undefined : 'You do not have permission to generate tickets'}
>
Generate Missing
</ActionButton>
@@ -760,7 +774,13 @@ export default function TicketsPage() {
</div>
<div className="flex justify-end gap-2 pt-2">
<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>
</Modal>
@@ -1103,3 +1123,11 @@ export default function TicketsPage() {
</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 { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
function TrainsPageContent() {
const [showModal, setShowModal] = useState(false);
@@ -24,6 +25,10 @@ function TrainsPageContent() {
const queryClient = useQueryClient();
const canCreate = useWritePermission(PERMS.trains.create, PERMS.trains.manage);
const canEdit = useWritePermission(PERMS.trains.edit, PERMS.trains.manage);
const canDelete = useDeletePermission(PERMS.trains.delete);
const { data: trainsData, isLoading: trainsLoading } = useQuery({
queryKey: ['trains'],
queryFn: () => fleetApi.getTrains(),
@@ -174,6 +179,7 @@ function TrainsPageContent() {
const actions = [
{
label: 'Edit',
show: () => canEdit,
onClick: (train: TrainType) => {
setEditingTrain(train);
setShowModal(true);
@@ -186,10 +192,11 @@ function TrainsPageContent() {
onClick: (train: TrainType) => restoreTrainMutation.mutate(train.id),
variant: 'secondary' as const,
icon: RotateCcw,
show: (train: TrainType) => !train.isActive,
show: (train: TrainType) => canEdit && !train.isActive,
},
{
label: 'Delete',
show: () => canDelete,
onClick: handleDelete,
variant: 'danger' as const,
icon: Trash2,
@@ -209,6 +216,8 @@ function TrainsPageContent() {
setShowModal(true);
}}
icon={Plus}
disabled={!canCreate}
title={canCreate ? undefined : 'You do not have permission to create trains'}
>
Add Train
</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
* can be neither upgraded from nor to. Gated on bookings:view because that is what
* `GET /upgrade/policies` requires; creating, editing and deleting are admin-only server-side.
* can be neither upgraded from nor to. Gated on upgrade_policies:view (or :manage) — bookings:view no longer
* grants it, so the page needs its own grant. Create/edit/delete each have their own key.
*/
export default function UpgradePoliciesPage() {
return (
<PermissionGuard permission={PERMS.bookings.view}>
<PermissionGuard permission={[PERMS.upgradePolicies.view, PERMS.upgradePolicies.manage]}>
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Upgrade Policies</h1>

View File

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

View File

@@ -52,7 +52,8 @@ interface NavItem {
name: string;
href: string;
icon: React.ComponentType<{ className?: string }>;
permission?: string;
/** A single key, or several of which the user needs any one. */
permission?: string | string[];
}
const navigationSections: { title: string; items: NavItem[] }[] = [
@@ -92,8 +93,8 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
{ name: 'Classes', href: '/classes', icon: Settings, permission: PERMS.classes.view },
{ name: 'Routes', href: '/routes', icon: Route, permission: PERMS.routes.view },
{ name: 'Schedules', href: '/schedules', icon: Calendar, permission: PERMS.schedules.view },
{ name: 'Reschedule Policies', href: '/reschedule-policies', icon: CalendarClock, permission: PERMS.bookings.view },
{ name: 'Upgrade Policies', href: '/upgrade-policies', icon: ArrowUpNarrowWide, permission: PERMS.bookings.view },
{ name: 'Reschedule Policies', href: '/reschedule-policies', icon: CalendarClock, permission: [PERMS.reschedulePolicies.view, PERMS.reschedulePolicies.manage] },
{ name: 'Upgrade Policies', href: '/upgrade-policies', icon: ArrowUpNarrowWide, permission: [PERMS.upgradePolicies.view, PERMS.upgradePolicies.manage] },
]
},
{
@@ -128,16 +129,16 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
{
title: 'Analytics & Reports',
items: [
{ name: 'Overall', href: '/reports/overall', icon: BarChart3, permission: PERMS.reports.view },
{ name: 'Finance', href: '/reports/finance', icon: DollarSign, permission: PERMS.reports.view },
{ name: 'Coaches', href: '/reports/coach-utilization', icon: Grid3x3, permission: PERMS.reports.view },
{ name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view },
{ name: 'Blocked Seats', href: '/reports/blocked-seats', icon: Ban, permission: PERMS.reports.view },
{ name: 'Passengers', href: '/reports/passengers', icon: Users, permission: PERMS.reports.view },
{ name: 'Boarding', href: '/reports/boarding', icon: LogIn, permission: PERMS.reports.view },
{ name: 'Payments', href: '/reports/payments', icon: CreditCard, permission: PERMS.reports.view },
// { name: 'Payment Discrepancy', href: '/reports/payment-discrepancy', icon: AlertTriangle, permission: PERMS.reports.view },
// { name: 'Operational Reports', href: '/operational-reports', icon: FileText, permission: PERMS.reports.view },
{ name: 'Overall', href: '/reports/overall', icon: BarChart3, permission: [PERMS.reports.overall.view, PERMS.reports.view] },
{ name: 'Finance', href: '/reports/finance', icon: DollarSign, permission: [PERMS.reports.finance.view, PERMS.reports.view] },
{ name: 'Coaches', href: '/reports/coach-utilization', icon: Grid3x3, permission: [PERMS.reports.coachUtilization.view, PERMS.reports.view] },
{ name: 'Seats', href: '/reports/seats', icon: Armchair, permission: [PERMS.reports.seatStatus.view, PERMS.reports.view] },
{ name: 'Blocked Seats', href: '/reports/blocked-seats', icon: Ban, permission: [PERMS.reports.blockedSeats.view, PERMS.reports.view] },
{ name: 'Passengers', href: '/reports/passengers', icon: Users, permission: [PERMS.reports.passengers.view, PERMS.reports.view] },
{ name: 'Boarding', href: '/reports/boarding', icon: LogIn, permission: [PERMS.reports.boarding.view, PERMS.reports.view] },
{ name: 'Payments', href: '/reports/payments', icon: CreditCard, permission: [PERMS.reports.payments.view, PERMS.reports.view] },
// { name: 'Payment Discrepancy', href: '/reports/payment-discrepancy', icon: AlertTriangle, permission: [PERMS.reports.payments.view, PERMS.reports.view] },
// { name: 'Operational Reports', href: '/operational-reports', icon: FileText, permission: [PERMS.reports.catalog.view, PERMS.reports.view] },
]
},
{
@@ -217,9 +218,11 @@ export default function Sidebar() {
{/* Navigation */}
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-6">
{navigationSections.map((section) => {
const visibleItems = section.items.filter(
(item) => !item.permission || hasPermission(item.permission)
);
const visibleItems = section.items.filter((item) => {
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;
return (
<div key={section.title}>

View File

@@ -6,6 +6,8 @@ import DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
import { PERMS } from '@/lib/permissions';
import {
reschedulePolicyApi,
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.
*/
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 [available, setAvailable] = useState<ReschedulePolicyCoachType[]>([]);
const [loading, setLoading] = useState(true);
@@ -188,9 +194,10 @@ export default function ReschedulePolicyManager() {
];
const actions = [
{ label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit },
{ label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit, show: () => canEdit },
{
label: 'Delete',
show: () => canDelete,
onClick: (row: ReschedulePolicyRow) => setDeleting(row),
variant: 'danger' as const,
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
policy here cannot be rescheduled at all.
</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
</ActionButton>
</div>
@@ -353,7 +365,13 @@ export default function ReschedulePolicyManager() {
<ActionButton variant="secondary" onClick={() => setShowModal(false)}>
Cancel
</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'}
</ActionButton>
</div>

View File

@@ -14,6 +14,12 @@ interface ActionButtonProps {
loading?: boolean;
className?: string;
type?: 'button' | 'submit' | 'reset';
/**
* Native tooltip. CLAUDE.md asks for a disabled control with a visible reason
* over a silently hidden one, so permission-gated buttons pass the reason here
* alongside `disabled`.
*/
title?: string;
}
const variants = {
@@ -40,6 +46,7 @@ export default function ActionButton({
loading = false,
className,
type = 'button',
title,
}: ActionButtonProps) {
const [isLoading, setIsLoading] = useState(false);
@@ -63,6 +70,7 @@ export default function ActionButton({
type={type}
onClick={handleClick}
disabled={isDisabled}
title={title}
className={cn(
'inline-flex items-center justify-center gap-2 rounded-lg font-medium transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[rgb(20,113,76)]',

View File

@@ -6,6 +6,8 @@ import DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
import { PERMS } from '@/lib/permissions';
import {
upgradePolicyApi,
type UpgradePolicyCoachType,
@@ -38,6 +40,10 @@ const feeLabel = (p: UpgradePolicyRow) =>
* a dialog, the same shape as Reschedule Policies and Coach Management.
*/
export default function UpgradePolicyManager() {
const canCreate = useWritePermission(PERMS.upgradePolicies.create, PERMS.upgradePolicies.manage);
const canEdit = useWritePermission(PERMS.upgradePolicies.edit, PERMS.upgradePolicies.manage);
const canDelete = useDeletePermission(PERMS.upgradePolicies.delete);
const [rows, setRows] = useState<UpgradePolicyRow[]>([]);
const [available, setAvailable] = useState<UpgradePolicyCoachType[]>([]);
const [loading, setLoading] = useState(true);
@@ -186,9 +192,10 @@ export default function UpgradePolicyManager() {
];
const actions = [
{ label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit },
{ label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit, show: () => canEdit },
{
label: 'Delete',
show: () => canDelete,
onClick: (row: UpgradePolicyRow) => setDeleting(row),
variant: 'danger' as const,
icon: Trash2,
@@ -204,7 +211,12 @@ export default function UpgradePolicyManager() {
<em> to</em> and charged per upgraded passenger. A fare class with no policy here can be neither
upgraded from nor to.
</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
</ActionButton>
</div>
@@ -340,7 +352,13 @@ export default function UpgradePolicyManager() {
<ActionButton variant="secondary" onClick={() => setShowModal(false)}>
Cancel
</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'}
</ActionButton>
</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 NETWORK_ERROR_MESSAGE = 'Could not reach the server. Please check your connection and try again.';
const FORBIDDEN_MESSAGE =
'You do not have permission to do that. Ask an administrator if you need access.';
/**
* Extracts a user-facing message from a failed request. Prefers a real backend-provided message
@@ -55,6 +57,21 @@ class ApiClient {
}
}
// A 403 from this API is always a permission check, and the server's own text
// names raw permission keys ("Missing permission. Required one of: …") which
// means nothing to a user. Replace it with something actionable, but only when
// the server did not send a more specific message of its own.
if (error.response?.status === 403) {
const body = error.response.data;
const serverMessage = typeof body?.message === 'string' ? body.message : '';
if (!serverMessage || serverMessage.startsWith('Missing permission')) {
const friendly = FORBIDDEN_MESSAGE;
if (body && typeof body === 'object') body.message = friendly;
error.message = friendly;
return Promise.reject(error);
}
}
// Normalize in place so every existing `err?.response?.data?.message || err?.message ||
// '<fallback>'` call site across the app picks up a friendly message automatically,
// 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',
delete: 'edr_passenger_app:bookings:delete',
},
reschedulePolicies: {
view: 'edr_passenger_app:reschedule_policies:view',
manage: 'edr_passenger_app:reschedule_policies:manage',
create: 'edr_passenger_app:reschedule_policies:create',
edit: 'edr_passenger_app:reschedule_policies:edit',
delete: 'edr_passenger_app:reschedule_policies:delete',
},
upgradePolicies: {
view: 'edr_passenger_app:upgrade_policies:view',
manage: 'edr_passenger_app:upgrade_policies:manage',
create: 'edr_passenger_app:upgrade_policies:create',
edit: 'edr_passenger_app:upgrade_policies:edit',
delete: 'edr_passenger_app:upgrade_policies:delete',
},
passengers: {
view: 'edr_passenger_app:passengers:view',
manage: 'edr_passenger_app:passengers:manage',
@@ -20,6 +34,7 @@ export const PERMS = {
view: 'edr_passenger_app:tickets:view',
manage: 'edr_passenger_app:tickets:manage',
generate: 'edr_passenger_app:tickets:generate',
board: 'edr_passenger_app:tickets:board',
create: 'edr_passenger_app:tickets:create',
edit: 'edr_passenger_app:tickets:edit',
delete: 'edr_passenger_app:tickets:delete',
@@ -114,6 +129,7 @@ export const PERMS = {
view: 'edr_passenger_app:payments:view',
manage: 'edr_passenger_app:payments:manage',
create: 'edr_passenger_app:payments:create',
supplementary: 'edr_passenger_app:payments:supplementary',
edit: 'edr_passenger_app:payments:edit',
delete: 'edr_passenger_app:payments:delete',
// legacy aliases — still honoured by the backend guards
@@ -121,6 +137,9 @@ export const PERMS = {
refund: 'edr_passenger_app:payments:refund',
manageMethods: 'edr_passenger_app:payments:manage_methods',
},
excessBaggage: {
charge: 'edr_passenger_app:excess_baggage:charge',
},
paymentMethods: {
view: 'edr_passenger_app:payment_methods:view',
manage: 'edr_passenger_app:payment_methods:manage',

View File

@@ -1,6 +1,7 @@
'use client';
import { useAuthStore } from './auth-store';
import { PERMS } from './permissions';
/**
* Returns whether the current user has a given permission key.
@@ -14,6 +15,20 @@ export function usePermission(key: string): boolean {
return useAuthStore((s) => s.hasPermission(key));
}
/**
* True when the user holds **any one** of the keys — the same OR semantics as the
* API's `PassengerPermissionGuard`.
*
* Use it wherever a route accepts a narrow key or a broader one, so the UI agrees
* with the API instead of hiding a control the server would have allowed:
*
* const canCreate = useAnyPermission([PERMS.schedules.create, PERMS.schedules.manage]);
* const canSeeFinance = useAnyPermission([PERMS.reports.finance.view, PERMS.reports.view]);
*/
export function useAnyPermission(keys: string[]): boolean {
return useAuthStore((s) => keys.some((key) => s.hasPermission(key)));
}
/**
* Same as usePermission but WITHOUT the super-admin / org-admin bypass — the
* permission must be explicitly granted. Use it wherever the API endpoint is
@@ -25,3 +40,23 @@ export function usePermission(key: string): boolean {
export function usePermissionStrict(key: string): boolean {
return useAuthStore((s) => s.hasPermissionStrict(key));
}
/**
* The UI mirror of the API's `@PassengerWrite(narrow, umbrella)`: the narrow key,
* the resource's `:manage` umbrella, or `admin`. Use it for create / edit / domain
* actions so a control is shown exactly when the server would accept the call.
*
* const canCreate = useWritePermission(PERMS.schedules.create, PERMS.schedules.manage);
*/
export function useWritePermission(narrow: string, umbrella: string): boolean {
return useAnyPermission([narrow, umbrella, PERMS.admin]);
}
/**
* The UI mirror of the API's `@PassengerDelete(narrow)`: the narrow `:delete` key
* or `admin`. **`:manage` deliberately does not count** — holding `schedules:manage`
* does not let you delete a schedule, so the button must not appear either.
*/
export function useDeletePermission(narrow: string): boolean {
return useAnyPermission([narrow, PERMS.admin]);
}