Merge pull request #1163 from Tria-plc/freight/nati-2

Freight/nati 2
This commit is contained in:
Nathnael Wondisha
2026-08-07 15:26:32 +03:00
committed by GitHub
35 changed files with 1142 additions and 819 deletions

View File

@@ -95,9 +95,14 @@ export const TrainSchedulingRulesManage = () =>
* wagons:delete, …). The legacy coarse fleet:view / fleet:manage keys remain
* valid as a one-of fallback so existing role grants keep working.
*/
export const FleetView = (granular?: string) =>
export const FleetView = (granular?: string | string[]) =>
BookingStaff(
granular ? [granular, FREIGHT_PERMS.fleet.view] : FREIGHT_PERMS.fleet.view,
granular
? [
...(Array.isArray(granular) ? granular : [granular]),
FREIGHT_PERMS.fleet.view,
]
: FREIGHT_PERMS.fleet.view,
);
export const FleetManage = (granular?: string) =>

View File

@@ -9,6 +9,7 @@ import {
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { hasFreightPermission, isSuperAdmin } from './freight-permission.util';
import { readTwinOf } from '../seed/freight-permissions.registry';
// String literals on purpose (same reasoning as login-audience.middleware.ts):
// the values are wire-format constants from iam.users.user_type, and importing
@@ -22,13 +23,43 @@ const userTypeOf = (user: TCurrentUser): string | undefined =>
const isEmployee = (user: TCurrentUser): boolean =>
userTypeOf(user) === 'employee' || isSuperAdmin(user);
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
/**
* Does the caller satisfy a required permission?
*
* Holding the key outright always passes. A required `<module>:view` is ALSO
* satisfied by the weaker `<module>:read` — the key that buys API reads
* without putting the module in the backoffice sidebar — but only on a safe
* HTTP method.
*
* The method restriction is load-bearing, not caution. Nest runs class AND
* method guards, so controllers list every route key on the class gate,
* `:view` among the write keys. Without this check a `:read` holder would
* clear that class gate and then reach any write route that has no method
* gate of its own. Keying on the HTTP verb closes that by construction rather
* than by an audit that goes stale the next time a route is added.
*/
const satisfiedBy = (
user: TCurrentUser,
required: string,
method: string,
): boolean => {
if (hasFreightPermission(user, required)) return true;
if (!SAFE_METHODS.has(method)) return false;
const readTwin = readTwinOf(required);
return Boolean(readTwin && hasFreightPermission(user, readTwin));
};
export function FreightPermissionGuard(
permissions: string[],
): Type<CanActivate> {
@Injectable()
class FreightPermissionsGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>();
const request = context
.switchToHttp()
.getRequest<{ user?: TCurrentUser; method: string }>();
const user = request.user;
if (!user) {
@@ -39,7 +70,7 @@ export function FreightPermissionGuard(
}
if (!permissions?.length) return true;
if (permissions.some((p) => hasFreightPermission(user, p))) {
if (permissions.some((p) => satisfiedBy(user, p, request.method))) {
return true;
}
@@ -79,7 +110,9 @@ export function MixedAudienceGuard(permissions: string[]): Type<CanActivate> {
@Injectable()
class MixedAudiencesGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>();
const request = context
.switchToHttp()
.getRequest<{ user?: TCurrentUser; method: string }>();
const user = request.user;
if (!user) {
@@ -93,7 +126,7 @@ export function MixedAudienceGuard(permissions: string[]): Type<CanActivate> {
}
if (
!permissions?.length ||
permissions.some((p) => hasFreightPermission(user, p))
permissions.some((p) => satisfiedBy(user, p, request.method))
) {
return true;
}

View File

@@ -20,7 +20,12 @@ import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
@ApiTags("billing")
@Controller("billing")
@BookingStaff(FREIGHT_PERMS.invoices.view)
// Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@BookingStaff([
FREIGHT_PERMS.invoices.view,
FREIGHT_PERMS.invoices.export,
])
@ApiBearerAuth()
export class BillingController {
constructor(

View File

@@ -20,7 +20,14 @@ import { CargoesService } from './cargoes.service';
@ApiTags('cargoes')
@Controller('cargoes')
@FleetView(FREIGHT_PERMS.cargoes.view)
// Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@FleetView([
FREIGHT_PERMS.cargoes.view,
FREIGHT_PERMS.cargoes.create,
FREIGHT_PERMS.cargoes.update,
FREIGHT_PERMS.cargoes.delete,
])
export class CargoesController {
constructor(private readonly cargoesService: CargoesService) {}

View File

@@ -11,7 +11,12 @@ import { ComplianceType } from './entities/compliance-record.entity';
@ApiTags('Vehicle Compliance')
@Controller('compliance')
@BookingStaff(FREIGHT_PERMS.compliance.view)
// Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@BookingStaff([
FREIGHT_PERMS.compliance.view,
FREIGHT_PERMS.compliance.manage,
])
export class ComplianceController {
constructor(private readonly complianceService: ComplianceService) {}

View File

@@ -17,7 +17,12 @@ import { FilterConsignmentDto } from "./dto/filter-consignment.dto";
@ApiTags("consignments")
@Controller("consignments")
@FleetView(FREIGHT_PERMS.consignments.view)
// Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@FleetView([
FREIGHT_PERMS.consignments.view,
FREIGHT_PERMS.consignments.create,
])
export class ConsignmentsController {
constructor(private readonly consignmentsService: ConsignmentsService) {}

View File

@@ -19,7 +19,14 @@ import { ContainersService } from './containers.service';
@ApiTags('containers')
@Controller('containers')
@FleetView(FREIGHT_PERMS.containers.view)
// Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@FleetView([
FREIGHT_PERMS.containers.view,
FREIGHT_PERMS.containers.create,
FREIGHT_PERMS.containers.update,
FREIGHT_PERMS.containers.delete,
])
export class ContainersController {
constructor(private readonly containersService: ContainersService) {}

View File

@@ -24,7 +24,14 @@ import { FleetHistoryService } from '../fleet-history/fleet-history.service';
@ApiTags('drivers')
@ApiBearerAuth()
@Controller('drivers')
@BookingStaff(FREIGHT_PERMS.drivers.view)
// Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@BookingStaff([
FREIGHT_PERMS.drivers.view,
FREIGHT_PERMS.drivers.create,
FREIGHT_PERMS.drivers.update,
FREIGHT_PERMS.drivers.delete,
])
export class DriversController {
constructor(
private readonly driversService: DriversService,

View File

@@ -10,7 +10,12 @@ import { FacilitiesService } from './facilities.service';
@ApiTags('Facilities')
@Controller('facilities')
@BookingStaff(FREIGHT_PERMS.facilities.view)
// Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@BookingStaff([
FREIGHT_PERMS.facilities.view,
FREIGHT_PERMS.facilities.manage,
])
export class FacilitiesController {
constructor(private readonly facilitiesService: FacilitiesService) {}

View File

@@ -19,7 +19,12 @@ import { RegisterDeviceDto, UpdateDeviceDto } from './dto/gps-device.dto';
@ApiTags('gps-tracking')
@ApiBearerAuth()
@Controller('gps')
@BookingStaff(FREIGHT_PERMS.tracking.view)
// Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@BookingStaff([
FREIGHT_PERMS.tracking.view,
FREIGHT_PERMS.tracking.manage,
])
export class GpsTrackingController {
constructor(private readonly gps: GpsTrackingService) {}

View File

@@ -22,7 +22,14 @@ import { IncidentStatus, IncidentType } from './entities/incident.entity';
// No incidents-specific permission exists in the registry, so this reuses the
// (real) drivers.* fleet-road keys — incident records are driver-safety data
// (driver stats / incident history). TODO: add a dedicated incidents:* key.
@BookingStaff(FREIGHT_PERMS.drivers.view)
// Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@BookingStaff([
FREIGHT_PERMS.drivers.view,
FREIGHT_PERMS.drivers.create,
FREIGHT_PERMS.drivers.update,
FREIGHT_PERMS.drivers.delete,
])
export class IncidentsController {
constructor(private readonly incidentsService: IncidentsService) {}

View File

@@ -15,7 +15,14 @@ import { InterchangeDocumentsService } from './interchange-documents.service';
@ApiBearerAuth()
@Controller('interchange-documents')
// Class-level view guard; each write route adds its own manage permission below.
@BookingStaff(FREIGHT_PERMS.interchangeDocuments.view)
// Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@BookingStaff([
FREIGHT_PERMS.interchangeDocuments.view,
FREIGHT_PERMS.interchangeDocuments.generate,
FREIGHT_PERMS.interchangeDocuments.acknowledge,
FREIGHT_PERMS.interchangeDocuments.dispute,
])
export class InterchangeDocumentsController {
constructor(private readonly service: InterchangeDocumentsService) {}

View File

@@ -13,7 +13,14 @@ import {
@ApiTags('Procurement & Asset Lifecycle')
@Controller('procurement')
@BookingStaff(FREIGHT_PERMS.procurement.view)
// Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@BookingStaff([
FREIGHT_PERMS.procurement.view,
FREIGHT_PERMS.procurement.vendorManage,
FREIGHT_PERMS.procurement.acquisitionManage,
FREIGHT_PERMS.procurement.disposalManage,
])
export class ProcurementController {
constructor(private readonly procurementService: ProcurementService) {}

View File

@@ -27,7 +27,15 @@ import { RoutesService } from './routes.service';
@ApiTags('routes')
@ApiBearerAuth()
@Controller('routes')
@FleetView(FREIGHT_PERMS.routes.view)
// Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@FleetView([
FREIGHT_PERMS.routes.view,
FREIGHT_PERMS.routes.create,
FREIGHT_PERMS.routes.update,
FREIGHT_PERMS.routes.hardDelete,
FREIGHT_PERMS.routes.delete,
])
export class RoutesController {
constructor(private readonly routesService: RoutesService) {}

View File

@@ -31,7 +31,15 @@ import { TrainBuilderService } from './train-builder.service';
@ApiTags('train-builder')
@ApiBearerAuth()
@Controller('train-builder')
@FleetView(FREIGHT_PERMS.trains.view)
// Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@FleetView([
FREIGHT_PERMS.trains.view,
FREIGHT_PERMS.trains.create,
FREIGHT_PERMS.trains.update,
FREIGHT_PERMS.trains.assignWagons,
FREIGHT_PERMS.trains.delete,
])
export class TrainBuilderController {
constructor(private readonly trainBuilderService: TrainBuilderService) {}

View File

@@ -19,7 +19,14 @@ import { TrainsService } from "./trains.service";
@ApiTags("trains")
@Controller("trains")
@FleetView(FREIGHT_PERMS.trains.view)
// Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@FleetView([
FREIGHT_PERMS.trains.view,
FREIGHT_PERMS.trains.create,
FREIGHT_PERMS.trains.update,
FREIGHT_PERMS.trains.delete,
])
export class TrainsController {
constructor(private readonly trainsService: TrainsService) {}

View File

@@ -20,7 +20,14 @@ import { FleetHistoryService } from '../fleet-history/fleet-history.service';
@ApiTags('vehicles')
@ApiBearerAuth()
@Controller('vehicles')
@BookingStaff(FREIGHT_PERMS.vehicles.view)
// Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@BookingStaff([
FREIGHT_PERMS.vehicles.view,
FREIGHT_PERMS.vehicles.create,
FREIGHT_PERMS.vehicles.update,
FREIGHT_PERMS.vehicles.delete,
])
export class VehiclesController {
constructor(
private readonly vehiclesService: VehiclesService,

View File

@@ -23,9 +23,13 @@ import { WarehouseInspectionService } from './warehouse-inspection.service';
// Baseline read: inspection reports are opened from inventory screens too —
// either view permission grants reads; writes stack their own per route.
@Controller()
// Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@BookingStaff([
FREIGHT_PERMS.warehouseInspectionReports.view,
FREIGHT_PERMS.warehouseInventory.view,
FREIGHT_PERMS.warehouseInspectionReports.create,
FREIGHT_PERMS.warehouseInspectionReports.update,
])
export class WarehouseInspectionController {
constructor(private readonly inspectionService: WarehouseInspectionService) {}

View File

@@ -12,7 +12,13 @@ import { WarehouseZonesService } from './warehouse-zones.service';
// receive/move pickers) — either view permission grants reads; writes stack
// their specific permission per route.
@Controller('warehouse-zones')
@BookingStaff([FREIGHT_PERMS.warehouseZones.view, FREIGHT_PERMS.warehouseInventory.view])
// Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@BookingStaff([
FREIGHT_PERMS.warehouseZones.view,
FREIGHT_PERMS.warehouseInventory.view,
FREIGHT_PERMS.warehouseZones.update,
])
export class WarehouseZonesController {
constructor(private readonly zonesService: WarehouseZonesService) {}

View File

@@ -1,6 +1,7 @@
import {
BOOKING_RULE_ENGINE_PERMISSIONS,
BOOKING_RULE_ENGINE_PERMISSION_KEYS,
deriveReadPermissions,
POSITION_PERMISSION_PRESETS,
ROLE_PERMISSION_PRESETS,
} from './freight-permissions.registry';
@@ -190,7 +191,7 @@ const POSITION_TYPE_PERMISSIONS = [
},
] as const;
export const EDR_FREIGHT_PERMISSIONS = [
const EDR_FREIGHT_VIEWABLE_PERMISSIONS = [
...EMPLOYEE_REGISTRATION_PERMISSIONS,
...ROLE_ASSIGNMENT_PERMISSIONS,
...HIERARCHY_UNIT_PERMISSIONS,
@@ -200,6 +201,15 @@ export const EDR_FREIGHT_PERMISSIONS = [
...BOOKING_RULE_ENGINE_PERMISSIONS,
];
export const EDR_FREIGHT_PERMISSIONS = [
...EDR_FREIGHT_VIEWABLE_PERMISSIONS,
// API-read twin of every `:view` key above — grants the module's GET routes
// without putting it in the backoffice sidebar. Seeded so they can be
// assigned; no role or position preset below grants one, that stays
// hand-curated in iam.position_type_permissions.
...deriveReadPermissions(EDR_FREIGHT_VIEWABLE_PERMISSIONS),
];
export { BOOKING_RULE_ENGINE_PERMISSION_KEYS } from './freight-permissions.registry';
export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [

View File

@@ -1,4 +1,5 @@
import { EDR_FREIGHT_PERMISSIONS } from './edr-freight.seed';
import { readTwinOf } from './freight-permissions.registry';
describe('EDR_FREIGHT_PERMISSIONS', () => {
// The seeder inserts the whole catalog in one ON CONFLICT (key) DO UPDATE
@@ -10,4 +11,40 @@ describe('EDR_FREIGHT_PERMISSIONS', () => {
expect(duplicates).toEqual([]);
});
// The `:read` ids are derived rather than hand-written, so a collision
// would surface here instead of as a primary-key violation on a fresh
// database.
it('has no duplicate ids', () => {
const ids = EDR_FREIGHT_PERMISSIONS.map((permission) => permission.id);
const duplicates = [...new Set(ids.filter((id, i) => ids.indexOf(id) !== i))];
expect(duplicates).toEqual([]);
});
// `:read` grants a module's GET routes without putting it in the backoffice
// sidebar. Every `:view` needs its twin or that module has no way to be
// granted API-only access.
it('gives every :view key a :read twin', () => {
const keys = new Set(EDR_FREIGHT_PERMISSIONS.map((p) => p.key));
const missing = [...keys]
.filter((key) => key.endsWith(':view'))
.map((key) => readTwinOf(key))
.filter((twin): twin is string => twin !== null && !keys.has(twin));
expect(missing).toEqual([]);
});
it('mints every :read id in the v5 block', () => {
const reads = EDR_FREIGHT_PERMISSIONS.filter((p) => p.key.endsWith(':read'));
expect(reads.length).toBeGreaterThan(0);
// Version nibble 5 — the source `:view` ids are all v4, so the two sets
// cannot overlap however many keys are added.
for (const read of reads) {
expect(read.id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
}
});
});

View File

@@ -31,6 +31,15 @@ export type RuleEngineResourceSlug =
const slugToResourceKey = (slug: RuleEngineResourceSlug): string =>
slug.replace(/-/g, "_");
const VIEW_KEY_SUFFIX = ":view";
const READ_KEY_SUFFIX = ":read";
/** The `:read` twin of a `:view` key, or null if `key` is not a view key. */
export const readTwinOf = (key: string): string | null =>
key.endsWith(VIEW_KEY_SUFFIX)
? `${key.slice(0, -VIEW_KEY_SUFFIX.length)}${READ_KEY_SUFFIX}`
: null;
const perm = (id: string, key: string, en: string): FreightPermissionSeed => ({
id,
key,
@@ -1359,6 +1368,59 @@ export const BOOKING_RULE_ENGINE_PERMISSIONS = [
export const BOOKING_RULE_ENGINE_PERMISSION_KEYS =
BOOKING_RULE_ENGINE_PERMISSIONS.map((p) => p.key);
/**
* Id for a derived `:read` key: the source `:view` id with its version nibble
* moved 4 → 5.
*
* Unique because the source ids are, and disjoint from every hand-written id
* because those are all v4-shaped. Nothing index-derived, so a new `:view`
* landing mid-list cannot shift ids already seeded — the trap the
* RULE_ENGINE_RESOURCE_SLUGS comment warns about.
*
* (`EdrOrgSeeder.ensurePermissions` deliberately never sends an id — the
* column default wins and `key` is the identity every consumer resolves by.
* These exist to satisfy the seed type and keep the array self-consistent.)
*/
const readPermissionId = (viewId: string): string =>
`${viewId.slice(0, 14)}5${viewId.slice(15)}`;
/**
* API-read twin of every `:view` key.
*
* `:view` does three jobs at once — backoffice sidebar entry (App.tsx), route
* admission (RequirePermission), and API read. That bundling means a user who
* only needs another module's list endpoint for a form dropdown has to be
* granted the whole module, page and all. `<module>:read` unbundles it: the
* guard accepts it wherever the matching `:view` is required on a GET, and the
* frontend never looks at it, so the module stays out of the menu.
*
* Derived rather than hand-written so a new `:view` key gets its twin for
* free. Grants stay hand-curated in `iam.position_type_permissions` — nothing
* here hands a `:read` to anyone.
*/
export const deriveReadPermissions = (
seeds: readonly FreightPermissionSeed[],
): FreightPermissionSeed[] =>
seeds
.filter((p) => p.key.endsWith(VIEW_KEY_SUFFIX))
.map((p) =>
perm(
readPermissionId(p.id),
readTwinOf(p.key) as string,
`Read ${p.name.en.replace(/^View /, "")} (API only)`,
),
);
/**
* Read twins of the freight-domain catalog, for `PERMISSIONS_CATALOG`. The
* IAM/hierarchy keys seeded alongside it live in `edr-freight.seed.ts` and get
* theirs there — each twin is derived from its own source row, so the two call
* sites agree on any key they share without coordination.
*/
export const FREIGHT_READ_PERMISSIONS = deriveReadPermissions(
BOOKING_RULE_ENGINE_PERMISSIONS,
);
export const FREIGHT_PERMS = {
bookings: {
view: "edr_freight_app:bookings:view",
@@ -2079,7 +2141,10 @@ export const POSITION_PERMISSION_PRESETS = {
/** Derive the module bucket from the resource segment of a permission key. */
const moduleOf = (key: string): string => key.split(":")[1] ?? "other";
export const PERMISSIONS_CATALOG = BOOKING_RULE_ENGINE_PERMISSIONS.map((p) => ({
export const PERMISSIONS_CATALOG = [
...BOOKING_RULE_ENGINE_PERMISSIONS,
...FREIGHT_READ_PERMISSIONS,
].map((p) => ({
key: p.key,
label: p.name.en,
module: moduleOf(p.key),

View File

@@ -1,36 +1,3 @@
import {
ArrowLeftRight,
Boxes,
Building2,
BarChart3,
Container,
FileSignature,
FileText,
Hammer,
History,
LayoutDashboard,
LayoutGrid,
MapPin,
Network,
Package,
PackageCheck,
PackageOpen,
Paperclip,
Receipt,
ScrollText,
Send,
Settings,
ShieldCheck,
Ship,
SlidersHorizontal,
Train,
Truck,
Users,
Wallet,
LifeBuoy,
TrainFront,
XCircle,
} from "lucide-react";
import { useEffect } from "react";
import {
Navigate,
@@ -42,11 +9,7 @@ import {
useParams,
} from "react-router-dom";
import {
FreightDashboardLayout,
type SidebarItem,
type SidebarSection,
} from "@/components/layout";
import { FreightDashboardLayout, type SidebarItem } from "@/components/layout";
import { useAuth } from "./auth/useAuth";
import LoadingScreen from "./components/LoadingScreen";
import LoginPage from "./pages/auth/LoginPage";
@@ -84,11 +47,12 @@ import AuditLogsPage from "./pages/audit/AuditLogsPage";
import { RequirePermission } from "./components/auth/RequirePermission";
import {
FREIGHT_PERMS,
hasPermission as hasFreightPermission,
isDjiboutiGl,
isEthiopianGl,
isSuperAdmin,
} from "./lib/permissions";
import { NO_ACCESS_PATH, resolveLandingPath } from "./lib/landing";
import NoAccessPage from "./pages/NoAccessPage";
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage";
@@ -108,7 +72,6 @@ import CompliancePage from "./pages/fleet/CompliancePage";
import IncidentsPage from "./pages/fleet/IncidentsPage";
import WorkOrdersPage from "./pages/fleet/WorkOrdersPage";
import ProcurementPage from "./pages/fleet/ProcurementPage";
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
import CargoTypesPage from "./pages/ruleEngine/CargoTypesPage";
@@ -151,615 +114,15 @@ import FaydaCallbackPage from "./pages/FaydaCallbackPage";
import { UserManagementRoutes } from "./user-management/route";
import SetPassword from "./shared/components/SetPassword";
import SupportInboxPage from "./pages/support/SupportInboxPage";
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{
title: "Main menu",
items: [
{
label: "Overview",
href: "/dashboard/overview",
icon: <LayoutDashboard />,
permission: FREIGHT_PERMS.overview.view,
},
{
label: "Reports",
href: "/dashboard/reports",
icon: <BarChart3 />,
permission: FREIGHT_PERMS.reports.view,
},
{
label: "Customers",
href: "/dashboard/customers",
icon: <Building2 />,
permission: FREIGHT_PERMS.customers.view,
},
{
label: "Contracts",
href: "/dashboard/contract-requests",
icon: <FileSignature />,
permission: FREIGHT_PERMS.contracts.view,
},
{
label: "Bookings",
href: "/dashboard/booking-requests",
icon: <FileText />,
permission: FREIGHT_PERMS.bookings.view,
},
{
label: "Wagon cancellations",
href: "/dashboard/wagon-cancellations",
icon: <XCircle />,
permission: FREIGHT_PERMS.bookings.wagonCancellationView,
},
// Operations hub: per-shipment clearance-document review for services
// WITHOUT customs clearing (self-clearance) — bookings only.
{
label: "Clearance Documents",
href: "/dashboard/contracts/clearance-documents",
icon: <ShieldCheck />,
permission: FREIGHT_PERMS.contracts.opsClearanceReview,
},
{
label: "Payments",
href: "/dashboard/payments",
icon: <Wallet />,
permission: FREIGHT_PERMS.payments.view,
},
{
label: "Invoices",
href: "/dashboard/invoices",
icon: <Receipt />,
permission: FREIGHT_PERMS.invoices.view,
},
{
label: "Support",
href: "/dashboard/support",
icon: <LifeBuoy />,
permission: FREIGHT_PERMS.support.agentView,
},
...demoItems,
],
},
{
// title: "Port & Terminal",
items: [
{
label: "Operations",
icon: <Settings />,
children: [
{
label: "Clearance",
href: "/dashboard/contracts/clearance",
icon: <ShieldCheck />,
permission: [
FREIGHT_PERMS.contracts.clearanceReview,
FREIGHT_PERMS.contracts.clearanceEtActions,
],
},
// {
// label: "Shipment Requests",
// href: "/dashboard/shipment-requests",
// icon: <Send />,
// permission: FREIGHT_PERMS.contracts.createBooking,
// },
// Operations Path A queue: per-booking self-clearance review for
// GENERAL non-customs booking instances (and legacy self-clear bookings).
// {
// label: "Self-Clearance Review",
// href: "/dashboard/contracts/ops-clearance",
// icon: <ShieldCheck />,
// permission: FREIGHT_PERMS.contracts.opsClearanceReview,
// },
{
label: "GL Djibouti Clearance",
href: "/dashboard/gl-djibouti/clearance",
icon: <Ship />,
permission: FREIGHT_PERMS.contracts.clearanceDjActions,
},
{
label: "Train Schedules",
href: "/dashboard/operations/train-scheduling-v2",
icon: <Train />,
permission: FREIGHT_PERMS.trainScheduling.view,
},
{
label: "Batch Board",
href: "/dashboard/operations/batch-board",
icon: <LayoutGrid />,
permission: FREIGHT_PERMS.trainScheduling.view,
},
{
label: "First Mile",
href: "/dashboard/operations/first-mile",
icon: <Truck />,
permission: FREIGHT_PERMS.firstMile.view,
},
{
label: "Last Mile",
href: "/dashboard/operations/last-mile",
icon: <Truck />,
permission: FREIGHT_PERMS.lastMile.view,
},
],
},
{
label: "Fleet Management",
icon: <Truck />,
children: [
{
label: "Fleet Dashboard",
href: "/dashboard/fleet-dashboard",
icon: <LayoutDashboard />,
permission: FREIGHT_PERMS.fleetDashboard.view,
},
{
label: "Routes",
href: "/dashboard/routes",
icon: <Network />,
permission: [FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view],
},
{
label: "Locomotives",
href: "/dashboard/locomotives",
icon: <Train />,
permission: [
FREIGHT_PERMS.locomotives.view,
FREIGHT_PERMS.fleet.view,
],
},
{
label: "Train Builder",
href: "/dashboard/train-builder",
icon: <Hammer />,
permission: [FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view],
},
// {
// label: "Wagon types",
// href: "/dashboard/wagon-types",
// icon: <Boxes />,
// },
{
label: "Wagons",
href: "/dashboard/wagons",
icon: <Truck />,
permission: [FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view],
},
{
label: "Wagon Transfers",
href: "/dashboard/wagon-transfers",
icon: <ArrowLeftRight />,
permission: [
FREIGHT_PERMS.wagons.transferView,
FREIGHT_PERMS.wagons.view,
],
},
{
label: "Vehicles",
href: "/dashboard/vehicles",
icon: <Truck />,
permission: FREIGHT_PERMS.vehicles.view,
},
{
label: "Drivers",
href: "/dashboard/drivers",
icon: <Users />,
permission: FREIGHT_PERMS.drivers.view,
},
{
label: "Track Vehicles",
href: "/dashboard/tracking",
icon: <MapPin />,
permission: FREIGHT_PERMS.tracking.view,
},
{
label: "Fuel Purchases",
href: "/dashboard/fuel-purchases",
icon: <Truck />,
permission: FREIGHT_PERMS.fuel.view,
},
{
label: "Fuel Analytics",
href: "/dashboard/fuel-stats",
icon: <Truck />,
permission: FREIGHT_PERMS.fuel.view,
},
{
label: "Maintenance",
href: "/dashboard/maintenance",
icon: <Truck />,
permission: FREIGHT_PERMS.maintenance.view,
},
{
label: "Work Orders",
href: "/dashboard/work-orders",
icon: <SlidersHorizontal />,
permission: FREIGHT_PERMS.maintenance.view,
},
{
label: "Compliance & Alerts",
href: "/dashboard/compliance",
icon: <ShieldCheck />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Incidents",
href: "/dashboard/incidents",
icon: <FileText />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Procurement",
href: "/dashboard/procurement",
icon: <Package />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Financial Reports",
href: "/dashboard/financial-reports",
icon: <Wallet />,
permission: FREIGHT_PERMS.fleetReports.view,
},
// {
// label: "Containers",
// href: "/dashboard/containers",
// icon: <Container />,
// },
// {
// label: "Cargoes",
// href: "/dashboard/cargoes",
// icon: <Package />,
// },
],
},
{
label: "Imports",
href: "/dashboard/import-warehouse",
icon: <PackageOpen />,
permission: FREIGHT_PERMS.warehouseInventory.view,
children: [
{
label: "Import Overview",
href: "/dashboard/import-warehouse",
icon: <PackageOpen />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Arrival Queue",
href: "/dashboard/arrival-queue",
icon: <PackageOpen />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Import Trucks",
href: "/dashboard/import-trucks",
icon: <Truck />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "EDR Last Mile Returns",
href: "/dashboard/edr-last-mile-returns",
icon: <Container />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Container Returns",
href: "/dashboard/container-returns",
icon: <Container />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Terminal Inventory",
href: "/dashboard/warehouse-inventory?direction=IMPORT",
icon: <Package />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Inventory Inquiry",
href: "/dashboard/inventory-inquiry",
icon: <Boxes />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
],
},
{
label: "Exports",
href: "/dashboard/export-warehouse",
icon: <Truck />,
permission: FREIGHT_PERMS.warehouseInventory.view,
children: [
{
label: "Export Overview",
href: "/dashboard/export-warehouse",
icon: <Truck />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Loading Queue",
href: "/dashboard/loading-queue",
icon: <Truck />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Loaded Inventory",
href: "/dashboard/loaded-inventory",
icon: <PackageCheck />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Dispatch Queue",
href: "/dashboard/dispatch-queue",
icon: <Send />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Djibouti Unloading",
href: "/dashboard/export-djibouti-unloading",
icon: <PackageOpen />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Interchange Documents",
href: "/dashboard/interchange-documents",
icon: <FileText />,
permission: FREIGHT_PERMS.interchangeDocuments.view,
},
{
label: "Terminal Inventory",
href: "/dashboard/warehouse-inventory?direction=EXPORT",
icon: <Package />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
],
},
{
label: "Intercity",
href: "/dashboard/intercity",
icon: <TrainFront />,
permission: FREIGHT_PERMS.trainScheduling.view,
children: [
{
label: "Intercity Cargo",
href: "/dashboard/intercity",
icon: <TrainFront />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
],
},
{
label: "Warehouse Management",
icon: <Container />,
children: [
{
label: "Warehouse Dashboard",
href: "/dashboard/warehouse-dashboard",
icon: <LayoutDashboard />,
permission: FREIGHT_PERMS.warehouseDashboard.view,
},
{
// Yard-wide, not per-direction: the gate sees import and export
// trucks at the same barrier.
label: "Trucks on Site",
href: "/dashboard/trucks-on-site",
icon: <Truck />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Warehouses",
href: "/dashboard/warehouses",
icon: <Container />,
permission: FREIGHT_PERMS.warehouses.view,
},
{
label: "Allocation & Fees",
href: "/dashboard/warehouse-rules",
icon: <SlidersHorizontal />,
permission: [
FREIGHT_PERMS.warehouseAllocationRules.view,
FREIGHT_PERMS.warehouseFeeRules.view,
],
},
{
label: "Fee Invoices",
href: "/dashboard/warehouse-fee-invoices",
icon: <Wallet />,
permission: FREIGHT_PERMS.warehouseFeeInvoices.view,
},
],
},
],
},
{
title: "Freight configuration",
mutedTitle: true,
items: [
{
label: "File settings",
href: "/dashboard/file-settings",
icon: <Paperclip />,
permission: FREIGHT_PERMS.admin,
},
{
label: "Dropdown settings",
href: "/dashboard/dropdown-settings",
icon: <Settings />,
permission: FREIGHT_PERMS.admin,
},
{
label: "Contract templates",
href: "/dashboard/contract-templates",
icon: <ScrollText />,
permission: FREIGHT_PERMS.admin,
},
{
label: "Audit logs",
href: "/dashboard/audit-logs",
icon: <History />,
permission: FREIGHT_PERMS.audit.view,
},
{
label: "Configuration",
href: "/dashboard/configuration",
icon: <Boxes />,
children: [
...getCategorySidebarChildren("configuration"),
{
label: "Train scheduling rules",
href: "/dashboard/configuration/train-scheduling-rules",
permission: FREIGHT_PERMS.trainScheduling.rulesManage,
},
{
label: "Trade access",
href: "/dashboard/configuration/trade-access",
permission: FREIGHT_PERMS.admin,
},
{
label: "Exchange rate",
href: "/dashboard/configuration/exchange-rate",
permission: FREIGHT_PERMS.admin,
},
],
},
{
label: "Rules",
href: "/dashboard/rules",
icon: <SlidersHorizontal />,
children: getCategorySidebarChildren("rules"),
},
{
label: "Staff",
href: "/user-management",
icon: <Users />,
permission: [
FREIGHT_PERMS.admin,
FREIGHT_PERMS.staff.roles.view,
FREIGHT_PERMS.staff.employeeRegistration.view,
FREIGHT_PERMS.staff.roleAssignment.view,
],
},
],
},
];
/** Hrefs of the two document-clearance menu items (stable identifiers). */
const ET_CLEARANCE_HREF = "/dashboard/contracts/clearance";
const DJ_CLEARANCE_HREF = "/dashboard/gl-djibouti/clearance";
// Routes a GL officer may reach beyond their clearance hub. Path B booking is
// part of their job (create/rebook under a cleared contract, then view that
// booking's clearance), but those routes live outside the clearance prefix —
// without this allowlist the single-prefix lock bounces them out of their own
// workflow. Matched against location.pathname (no query string).
const GL_WORKFLOW_PATH_PATTERNS: RegExp[] = [
/^\/dashboard\/contracts\/[^/]+\/create-booking(\/|$)/,
/^\/dashboard\/bookings\/[^/]+\/clearance(\/|$)/,
// The ET hub's rows open the shipment clearance detail at this URL.
/^\/dashboard\/clearance\/[^/]+(\/|$)/,
];
const isEtClearanceItem = (item: SidebarItem): boolean =>
item.href === ET_CLEARANCE_HREF;
const isDjClearanceItem = (item: SidebarItem): boolean =>
item.href === DJ_CLEARANCE_HREF;
const isClearanceItem = (item: SidebarItem): boolean =>
isEtClearanceItem(item) || isDjClearanceItem(item);
/**
* Keep only items the user is permitted to see; drop now-empty sections.
*
* Position-scoped visibility (super_admin sees everything):
* - Super Admin → sees all items (all permissions pass, all tabs visible)
* - Ethiopian GL → sees ONLY the ET document-clearance page.
* - Djibouti GL → sees ONLY the DJ clearance page.
* - Everyone else → sees everything they have permission for, EXCEPT the two
* clearance pages (those are GL-only).
*/
const filterSidebarByPermission = (
sections: SidebarSection[],
user: ReturnType<typeof useAuth>["user"],
): SidebarSection[] => {
// Superadmin sees every section and item — no permission filtering.
if (isSuperAdmin(user)) return sections;
const etGl = isEthiopianGl(user);
const djGl = isDjiboutiGl(user);
const permissionAllowed = (item: SidebarItem): boolean => {
if (!item.permission) return true;
const keys = Array.isArray(item.permission)
? item.permission
: [item.permission];
return keys.some((key) => hasFreightPermission(user, key));
};
// Recursive: children are filtered first; a group (item with children) stays
// only while it still has visible children — so parents without their own
// permission key never leak a whole subtree the user cannot open.
const filterItems = (items: SidebarItem[]): SidebarItem[] =>
items
.map((item) =>
item.children
? { ...item, children: filterItems(item.children) }
: item,
)
.filter((item) => {
if (etGl || djGl) {
// GL positions are locked to their single clearance page (parents
// survive only as the path to that page).
const isTarget = etGl ? isEtClearanceItem : isDjClearanceItem;
return isTarget(item) || (item.children?.length ?? 0) > 0;
}
// Everyone else: hide the GL-only clearance pages entirely.
if (isClearanceItem(item)) return false;
if (!permissionAllowed(item)) return false;
if (item.children) return item.children.length > 0;
return true;
});
return sections
.map((section) => ({
...section,
items: filterItems(section.items),
}))
.filter((section) => section.items.length > 0);
};
const APP_TITLE = "EDR Freight Backoffice";
/** Flatten sidebar sections (incl. nested children) into {href, label} pairs. */
const flattenSidebarItems = (
sections: SidebarSection[],
): { href: string; label: string }[] =>
sections.flatMap((section) =>
section.items.flatMap((item) => [
...(item.href ? [{ href: item.href, label: item.label }] : []),
...(item.children ?? [])
.filter((child): child is SidebarItem & { href: string } =>
Boolean(child.href),
)
.map((child) => ({ href: child.href, label: child.label })),
]),
);
/** Find the sidebar label whose href matches (exactly or as a prefix of) the current path. */
const findActiveSidebarLabel = (
pathname: string,
sections: SidebarSection[],
): string | undefined => {
const path = pathname.toLowerCase();
const candidates = flattenSidebarItems(sections)
.map(({ href, label }) => ({
label,
href: href.split("?")[0].toLowerCase(),
}))
.sort((a, b) => b.href.length - a.href.length);
return candidates.find(
({ href }) => path === href || path.startsWith(`${href}/`),
)?.label;
};
import {
APP_TITLE,
buildSidebarSections,
DJ_CLEARANCE_HREF,
ET_CLEARANCE_HREF,
filterSidebarByPermission,
findActiveSidebarLabel,
GL_WORKFLOW_PATH_PATTERNS,
} from "@/components/layout/sidebar-sections";
const DashboardShell = () => {
const navigate = useNavigate();
@@ -840,17 +203,19 @@ const App = () => {
);
}
const landingPath = resolveLandingPath(user);
return (
<Routes>
{UserManagementRoutes()}
{/* <Route path="/um/*" element={<UserManagementHostPage />} /> */}
<Route path="/health" element={<HealthCheck />} />
<Route path="/fayda/callback" element={<FaydaCallbackPage />} />
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
<Route
path="/dashboard"
element={<Navigate to="/dashboard/overview" replace />}
/>
<Route path={NO_ACCESS_PATH} element={<NoAccessPage />} />
{/* Landing is per-user: /dashboard/overview is gated on overview:view, so
a fixed target strands anyone without that key on a blank page. */}
<Route path="/" element={<Navigate to={landingPath} replace />} />
<Route path="/dashboard" element={<Navigate to={landingPath} replace />} />
<Route path="/dashboard" element={<DashboardShell />}>
<Route path="overview" element={<RequirePermission permission={FREIGHT_PERMS.overview.view}><OverviewPage /></RequirePermission>} />
<Route path="reports" element={<RequirePermission permission={FREIGHT_PERMS.reports.view}><ReportsHubPage /></RequirePermission>} />
@@ -1554,7 +919,9 @@ const App = () => {
<Route path="org-structure/*" element={<Navigate to="/um" replace />} />
</Route>
<Route path="*" element={<Navigate to="/dashboard/overview" replace />} />
{/* Post-login landing: the browser is still on /auth, which matches
nothing here, so this is what actually decides where users start. */}
<Route path="*" element={<Navigate to={landingPath} replace />} />
</Routes>
);
};
@@ -1575,3 +942,4 @@ function LegacyGlEthiopiaClearanceRedirect() {
}
export default App;

View File

@@ -1,30 +1,42 @@
import type { ReactNode } from "react";
import { Navigate } from "react-router-dom";
import { Navigate, useLocation } from "react-router-dom";
import { useAuth } from "@/auth/useAuth";
import { resolveLandingPath } from "@/lib/landing";
import { hasPermission } from "@/lib/permissions";
import NoAccessPage from "@/pages/NoAccessPage";
interface RequirePermissionProps {
/** Permission key(s); access is granted if the user has ANY of them. */
permission: string | string[];
/** Where to send users who lack the permission. */
/** Where to send users who lack the permission. Defaults to their landing page. */
redirectTo?: string;
children: ReactNode;
}
/**
* Page-level guard: renders children only when the current user holds one of
* the given permissions, otherwise redirects (default: overview).
* the given permissions, otherwise redirects to a page they can actually reach.
*
* The fallback must not be a fixed path. It used to be `/dashboard/overview`,
* which is itself gated on `overview:view` — a user without that key was sent
* to the page that had just rejected them, and React Router rendered a blank
* frame instead of navigating.
*/
export function RequirePermission({
permission,
redirectTo = "/dashboard/overview",
redirectTo,
children,
}: RequirePermissionProps) {
const { user } = useAuth();
const location = useLocation();
const keys = Array.isArray(permission) ? permission : [permission];
const allowed = keys.some((key) => hasPermission(user, key));
if (!allowed) return <Navigate to={redirectTo} replace />;
return <>{children}</>;
if (allowed) return <>{children}</>;
const target = redirectTo ?? resolveLandingPath(user);
// Belt and braces: never navigate to the page we are already on.
if (target === location.pathname) return <NoAccessPage />;
return <Navigate to={target} replace />;
}

View File

@@ -0,0 +1,660 @@
import {
ArrowLeftRight,
Boxes,
Building2,
BarChart3,
Container,
FileSignature,
FileText,
Hammer,
History,
LayoutDashboard,
LayoutGrid,
MapPin,
Network,
Package,
PackageCheck,
PackageOpen,
Paperclip,
Receipt,
ScrollText,
Send,
Settings,
ShieldCheck,
Ship,
SlidersHorizontal,
Train,
Truck,
Users,
Wallet,
LifeBuoy,
TrainFront,
XCircle,
} from "lucide-react";
import type { AuthUser } from "@/auth/types";
import type { SidebarItem, SidebarSection } from "./types";
import {
FREIGHT_PERMS,
hasPermission as hasFreightPermission,
isDjiboutiGl,
isEthiopianGl,
isSuperAdmin,
} from "@/lib/permissions";
import { getCategorySidebarChildren } from "@/pages/ruleEngine/config/resources";
/**
* Backoffice sidebar model: the nav tree, its permission filter, and the
* flatten/lookup helpers. Lives outside App.tsx so `lib/landing.ts` can resolve
* a user's first reachable route without importing the route tree (App.tsx
* imports RequirePermission, which imports landing — that would cycle).
*/
export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{
title: "Main menu",
items: [
{
label: "Overview",
href: "/dashboard/overview",
icon: <LayoutDashboard />,
permission: FREIGHT_PERMS.overview.view,
},
{
label: "Reports",
href: "/dashboard/reports",
icon: <BarChart3 />,
permission: FREIGHT_PERMS.reports.view,
},
{
label: "Customers",
href: "/dashboard/customers",
icon: <Building2 />,
permission: FREIGHT_PERMS.customers.view,
},
{
label: "Contracts",
href: "/dashboard/contract-requests",
icon: <FileSignature />,
permission: FREIGHT_PERMS.contracts.view,
},
{
label: "Bookings",
href: "/dashboard/booking-requests",
icon: <FileText />,
permission: FREIGHT_PERMS.bookings.view,
},
{
label: "Wagon cancellations",
href: "/dashboard/wagon-cancellations",
icon: <XCircle />,
permission: FREIGHT_PERMS.bookings.wagonCancellationView,
},
// Operations hub: per-shipment clearance-document review for services
// WITHOUT customs clearing (self-clearance) — bookings only.
{
label: "Clearance Documents",
href: "/dashboard/contracts/clearance-documents",
icon: <ShieldCheck />,
permission: FREIGHT_PERMS.contracts.opsClearanceReview,
},
{
label: "Payments",
href: "/dashboard/payments",
icon: <Wallet />,
permission: FREIGHT_PERMS.payments.view,
},
{
label: "Invoices",
href: "/dashboard/invoices",
icon: <Receipt />,
permission: FREIGHT_PERMS.invoices.view,
},
{
label: "Support",
href: "/dashboard/support",
icon: <LifeBuoy />,
permission: FREIGHT_PERMS.support.agentView,
},
...demoItems,
],
},
{
// title: "Port & Terminal",
items: [
{
label: "Operations",
icon: <Settings />,
children: [
{
label: "Clearance",
href: "/dashboard/contracts/clearance",
icon: <ShieldCheck />,
permission: [
FREIGHT_PERMS.contracts.clearanceReview,
FREIGHT_PERMS.contracts.clearanceEtActions,
],
},
// {
// label: "Shipment Requests",
// href: "/dashboard/shipment-requests",
// icon: <Send />,
// permission: FREIGHT_PERMS.contracts.createBooking,
// },
// Operations Path A queue: per-booking self-clearance review for
// GENERAL non-customs booking instances (and legacy self-clear bookings).
// {
// label: "Self-Clearance Review",
// href: "/dashboard/contracts/ops-clearance",
// icon: <ShieldCheck />,
// permission: FREIGHT_PERMS.contracts.opsClearanceReview,
// },
{
label: "GL Djibouti Clearance",
href: "/dashboard/gl-djibouti/clearance",
icon: <Ship />,
permission: FREIGHT_PERMS.contracts.clearanceDjActions,
},
{
label: "Train Schedules",
href: "/dashboard/operations/train-scheduling-v2",
icon: <Train />,
permission: FREIGHT_PERMS.trainScheduling.view,
},
{
label: "Batch Board",
href: "/dashboard/operations/batch-board",
icon: <LayoutGrid />,
permission: FREIGHT_PERMS.trainScheduling.view,
},
{
label: "First Mile",
href: "/dashboard/operations/first-mile",
icon: <Truck />,
permission: FREIGHT_PERMS.firstMile.view,
},
{
label: "Last Mile",
href: "/dashboard/operations/last-mile",
icon: <Truck />,
permission: FREIGHT_PERMS.lastMile.view,
},
],
},
{
label: "Fleet Management",
icon: <Truck />,
children: [
{
label: "Fleet Dashboard",
href: "/dashboard/fleet-dashboard",
icon: <LayoutDashboard />,
permission: FREIGHT_PERMS.fleetDashboard.view,
},
{
label: "Routes",
href: "/dashboard/routes",
icon: <Network />,
permission: [FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view],
},
{
label: "Locomotives",
href: "/dashboard/locomotives",
icon: <Train />,
permission: [
FREIGHT_PERMS.locomotives.view,
FREIGHT_PERMS.fleet.view,
],
},
{
label: "Train Builder",
href: "/dashboard/train-builder",
icon: <Hammer />,
permission: [FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view],
},
// {
// label: "Wagon types",
// href: "/dashboard/wagon-types",
// icon: <Boxes />,
// },
{
label: "Wagons",
href: "/dashboard/wagons",
icon: <Truck />,
permission: [FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view],
},
{
label: "Wagon Transfers",
href: "/dashboard/wagon-transfers",
icon: <ArrowLeftRight />,
permission: [
FREIGHT_PERMS.wagons.transferView,
FREIGHT_PERMS.wagons.view,
],
},
{
label: "Vehicles",
href: "/dashboard/vehicles",
icon: <Truck />,
permission: FREIGHT_PERMS.vehicles.view,
},
{
label: "Drivers",
href: "/dashboard/drivers",
icon: <Users />,
permission: FREIGHT_PERMS.drivers.view,
},
{
label: "Track Vehicles",
href: "/dashboard/tracking",
icon: <MapPin />,
permission: FREIGHT_PERMS.tracking.view,
},
{
label: "Fuel Purchases",
href: "/dashboard/fuel-purchases",
icon: <Truck />,
permission: FREIGHT_PERMS.fuel.view,
},
{
label: "Fuel Analytics",
href: "/dashboard/fuel-stats",
icon: <Truck />,
permission: FREIGHT_PERMS.fuel.view,
},
{
label: "Maintenance",
href: "/dashboard/maintenance",
icon: <Truck />,
permission: FREIGHT_PERMS.maintenance.view,
},
{
label: "Work Orders",
href: "/dashboard/work-orders",
icon: <SlidersHorizontal />,
permission: FREIGHT_PERMS.maintenance.view,
},
{
label: "Compliance & Alerts",
href: "/dashboard/compliance",
icon: <ShieldCheck />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Incidents",
href: "/dashboard/incidents",
icon: <FileText />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Procurement",
href: "/dashboard/procurement",
icon: <Package />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Financial Reports",
href: "/dashboard/financial-reports",
icon: <Wallet />,
permission: FREIGHT_PERMS.fleetReports.view,
},
// {
// label: "Containers",
// href: "/dashboard/containers",
// icon: <Container />,
// },
// {
// label: "Cargoes",
// href: "/dashboard/cargoes",
// icon: <Package />,
// },
],
},
{
label: "Imports",
href: "/dashboard/import-warehouse",
icon: <PackageOpen />,
permission: FREIGHT_PERMS.warehouseInventory.view,
children: [
{
label: "Import Overview",
href: "/dashboard/import-warehouse",
icon: <PackageOpen />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Arrival Queue",
href: "/dashboard/arrival-queue",
icon: <PackageOpen />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Import Trucks",
href: "/dashboard/import-trucks",
icon: <Truck />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "EDR Last Mile Returns",
href: "/dashboard/edr-last-mile-returns",
icon: <Container />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Container Returns",
href: "/dashboard/container-returns",
icon: <Container />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Terminal Inventory",
href: "/dashboard/warehouse-inventory?direction=IMPORT",
icon: <Package />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Inventory Inquiry",
href: "/dashboard/inventory-inquiry",
icon: <Boxes />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
],
},
{
label: "Exports",
href: "/dashboard/export-warehouse",
icon: <Truck />,
permission: FREIGHT_PERMS.warehouseInventory.view,
children: [
{
label: "Export Overview",
href: "/dashboard/export-warehouse",
icon: <Truck />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Loading Queue",
href: "/dashboard/loading-queue",
icon: <Truck />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Loaded Inventory",
href: "/dashboard/loaded-inventory",
icon: <PackageCheck />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Dispatch Queue",
href: "/dashboard/dispatch-queue",
icon: <Send />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Djibouti Unloading",
href: "/dashboard/export-djibouti-unloading",
icon: <PackageOpen />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Interchange Documents",
href: "/dashboard/interchange-documents",
icon: <FileText />,
permission: FREIGHT_PERMS.interchangeDocuments.view,
},
{
label: "Terminal Inventory",
href: "/dashboard/warehouse-inventory?direction=EXPORT",
icon: <Package />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
],
},
{
label: "Intercity",
href: "/dashboard/intercity",
icon: <TrainFront />,
permission: FREIGHT_PERMS.trainScheduling.view,
children: [
{
label: "Intercity Cargo",
href: "/dashboard/intercity",
icon: <TrainFront />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
],
},
{
label: "Warehouse Management",
icon: <Container />,
children: [
{
label: "Warehouse Dashboard",
href: "/dashboard/warehouse-dashboard",
icon: <LayoutDashboard />,
permission: FREIGHT_PERMS.warehouseDashboard.view,
},
{
// Yard-wide, not per-direction: the gate sees import and export
// trucks at the same barrier.
label: "Trucks on Site",
href: "/dashboard/trucks-on-site",
icon: <Truck />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Warehouses",
href: "/dashboard/warehouses",
icon: <Container />,
permission: FREIGHT_PERMS.warehouses.view,
},
{
label: "Allocation & Fees",
href: "/dashboard/warehouse-rules",
icon: <SlidersHorizontal />,
permission: [
FREIGHT_PERMS.warehouseAllocationRules.view,
FREIGHT_PERMS.warehouseFeeRules.view,
],
},
{
label: "Fee Invoices",
href: "/dashboard/warehouse-fee-invoices",
icon: <Wallet />,
permission: FREIGHT_PERMS.warehouseFeeInvoices.view,
},
],
},
],
},
{
title: "Freight configuration",
mutedTitle: true,
items: [
{
label: "File settings",
href: "/dashboard/file-settings",
icon: <Paperclip />,
permission: FREIGHT_PERMS.admin,
},
{
label: "Dropdown settings",
href: "/dashboard/dropdown-settings",
icon: <Settings />,
permission: FREIGHT_PERMS.admin,
},
{
label: "Contract templates",
href: "/dashboard/contract-templates",
icon: <ScrollText />,
permission: FREIGHT_PERMS.admin,
},
{
label: "Audit logs",
href: "/dashboard/audit-logs",
icon: <History />,
permission: FREIGHT_PERMS.audit.view,
},
{
label: "Configuration",
href: "/dashboard/configuration",
icon: <Boxes />,
children: [
...getCategorySidebarChildren("configuration"),
{
label: "Train scheduling rules",
href: "/dashboard/configuration/train-scheduling-rules",
permission: FREIGHT_PERMS.trainScheduling.rulesManage,
},
{
label: "Trade access",
href: "/dashboard/configuration/trade-access",
permission: FREIGHT_PERMS.admin,
},
{
label: "Exchange rate",
href: "/dashboard/configuration/exchange-rate",
permission: FREIGHT_PERMS.admin,
},
],
},
{
label: "Rules",
href: "/dashboard/rules",
icon: <SlidersHorizontal />,
children: getCategorySidebarChildren("rules"),
},
{
label: "Staff",
href: "/user-management",
icon: <Users />,
permission: [
FREIGHT_PERMS.admin,
FREIGHT_PERMS.staff.roles.view,
FREIGHT_PERMS.staff.employeeRegistration.view,
FREIGHT_PERMS.staff.roleAssignment.view,
],
},
],
},
];
/** Hrefs of the two document-clearance menu items (stable identifiers). */
export const ET_CLEARANCE_HREF = "/dashboard/contracts/clearance";
export const DJ_CLEARANCE_HREF = "/dashboard/gl-djibouti/clearance";
// Routes a GL officer may reach beyond their clearance hub. Path B booking is
// part of their job (create/rebook under a cleared contract, then view that
// booking's clearance), but those routes live outside the clearance prefix —
// without this allowlist the single-prefix lock bounces them out of their own
// workflow. Matched against location.pathname (no query string).
export const GL_WORKFLOW_PATH_PATTERNS: RegExp[] = [
/^\/dashboard\/contracts\/[^/]+\/create-booking(\/|$)/,
/^\/dashboard\/bookings\/[^/]+\/clearance(\/|$)/,
// The ET hub's rows open the shipment clearance detail at this URL.
/^\/dashboard\/clearance\/[^/]+(\/|$)/,
];
const isEtClearanceItem = (item: SidebarItem): boolean =>
item.href === ET_CLEARANCE_HREF;
const isDjClearanceItem = (item: SidebarItem): boolean =>
item.href === DJ_CLEARANCE_HREF;
const isClearanceItem = (item: SidebarItem): boolean =>
isEtClearanceItem(item) || isDjClearanceItem(item);
/**
* Keep only items the user is permitted to see; drop now-empty sections.
*
* Position-scoped visibility (super_admin sees everything):
* - Super Admin → sees all items (all permissions pass, all tabs visible)
* - Ethiopian GL → sees ONLY the ET document-clearance page.
* - Djibouti GL → sees ONLY the DJ clearance page.
* - Everyone else → sees everything they have permission for, EXCEPT the two
* clearance pages (those are GL-only).
*/
export const filterSidebarByPermission = (
sections: SidebarSection[],
user: AuthUser | null | undefined,
): SidebarSection[] => {
// Superadmin sees every section and item — no permission filtering.
if (isSuperAdmin(user)) return sections;
const etGl = isEthiopianGl(user);
const djGl = isDjiboutiGl(user);
const permissionAllowed = (item: SidebarItem): boolean => {
if (!item.permission) return true;
const keys = Array.isArray(item.permission)
? item.permission
: [item.permission];
return keys.some((key) => hasFreightPermission(user, key));
};
// Recursive: children are filtered first; a group (item with children) stays
// only while it still has visible children — so parents without their own
// permission key never leak a whole subtree the user cannot open.
const filterItems = (items: SidebarItem[]): SidebarItem[] =>
items
.map((item) =>
item.children
? { ...item, children: filterItems(item.children) }
: item,
)
.filter((item) => {
if (etGl || djGl) {
// GL positions are locked to their single clearance page (parents
// survive only as the path to that page).
const isTarget = etGl ? isEtClearanceItem : isDjClearanceItem;
return isTarget(item) || (item.children?.length ?? 0) > 0;
}
// Everyone else: hide the GL-only clearance pages entirely.
if (isClearanceItem(item)) return false;
if (!permissionAllowed(item)) return false;
if (item.children) return item.children.length > 0;
return true;
});
return sections
.map((section) => ({
...section,
items: filterItems(section.items),
}))
.filter((section) => section.items.length > 0);
};
export const APP_TITLE = "EDR Freight Backoffice";
/** Flatten sidebar sections (incl. nested children) into {href, label} pairs. */
export const flattenSidebarItems = (
sections: SidebarSection[],
): { href: string; label: string }[] =>
sections.flatMap((section) =>
section.items.flatMap((item) => [
...(item.href ? [{ href: item.href, label: item.label }] : []),
...(item.children ?? [])
.filter((child): child is SidebarItem & { href: string } =>
Boolean(child.href),
)
.map((child) => ({ href: child.href, label: child.label })),
]),
);
/** Find the sidebar label whose href matches (exactly or as a prefix of) the current path. */
export const findActiveSidebarLabel = (
pathname: string,
sections: SidebarSection[],
): string | undefined => {
const path = pathname.toLowerCase();
const candidates = flattenSidebarItems(sections)
.map(({ href, label }) => ({
label,
href: href.split("?")[0].toLowerCase(),
}))
.sort((a, b) => b.href.length - a.href.length);
return candidates.find(
({ href }) => path === href || path.startsWith(`${href}/`),
)?.label;
};

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import type { AuthUser } from "@/auth/types";
import { FREIGHT_PERMS } from "./permissions";
import { NO_ACCESS_PATH, resolveLandingPath } from "./landing";
const withPermissions = (...keys: string[]): AuthUser => ({
permissionKeys: keys,
});
const withRole = (roleKey: string): AuthUser => ({ roles: [{ key: roleKey }] });
describe("resolveLandingPath", () => {
it("keeps normal staff on the overview", () => {
expect(resolveLandingPath(withPermissions(FREIGHT_PERMS.overview.view))).toBe(
"/dashboard/overview",
);
});
it("sends super admins to the overview (they pass every check)", () => {
expect(resolveLandingPath(withRole("super_admin"))).toBe(
"/dashboard/overview",
);
});
it("sends IAM-only admins to the user-management dashboard", () => {
// A pure unit_admin holds `can:*` IAM keys and no edr_freight_app:* key, so
// the freight sidebar filters down to nothing. Landing them on the overview
// is what produced the blank page.
for (const role of ["unit_admin", "admin", "organization_admin"]) {
expect(resolveLandingPath(withRole(role))).toBe(
"/user-management/user_management-dashboard",
);
}
});
it("lands staff without overview:view on a page they can see", () => {
const target = resolveLandingPath(
withPermissions(FREIGHT_PERMS.warehouses.view),
);
expect(target).not.toBe("/dashboard/overview");
expect(target.startsWith("/dashboard/")).toBe(true);
});
it("never returns /user-management for a freight-only staff key", () => {
// The Staff sidebar item points at /user-management, which redirects by
// ROLE — returning it here would ping-pong for a user with no IAM role.
expect(resolveLandingPath(withPermissions(FREIGHT_PERMS.staff.roles.view))).toBe(
NO_ACCESS_PATH,
);
});
it("falls back to the no-access page when nothing is granted", () => {
expect(resolveLandingPath({})).toBe(NO_ACCESS_PATH);
expect(resolveLandingPath(null)).toBe(NO_ACCESS_PATH);
});
});

View File

@@ -0,0 +1,51 @@
import {
buildSidebarSections,
filterSidebarByPermission,
flattenSidebarItems,
} from "@/components/layout/sidebar-sections";
import type { AuthUser } from "@/auth/types";
import { isSuperAdmin } from "./permissions";
/**
* IAM roles that grant the user-management subtree but no freight permissions.
* Mirrors the `allowedRoles` on the org-admin route group in
* `user-management/route.tsx` — a role outside this list is bounced by its
* PrivateRoute, so landing anyone else there would just bounce again.
*/
const IAM_ADMIN_ROLES = ["admin", "organization_admin", "unit_admin"];
/** Terminal page for accounts with nothing granted. Never permission-gated. */
export const NO_ACCESS_PATH = "/no-access";
/**
* The first route a user can actually reach.
*
* Every redirect in the app funnels through here instead of hardcoding
* `/dashboard/overview`: that page is itself gated on `overview:view`, so
* sending a user who lacks the key there redirects them to the page that just
* rejected them — React Router renders nothing and the user sees a blank frame.
* A pure `unit_admin` holds only `can:*` IAM keys and no `edr_freight_app:*`
* key at all, so this hit them on every login.
*/
export function resolveLandingPath(user: AuthUser | null | undefined): string {
const visible = filterSidebarByPermission(buildSidebarSections([]), user);
// Only /dashboard/* items are safe landings. The "Staff" item points at
// /user-management, which re-redirects by ROLE — a freight user holding
// staff:* keys but no IAM role would ping-pong between the two.
const firstFreightPage = flattenSidebarItems(visible).find((item) =>
item.href.startsWith("/dashboard/"),
)?.href;
if (firstFreightPage) return firstFreightPage;
if (isSuperAdmin(user)) return "/user-management/dashboard";
const roleKeys = (user?.roles ?? []).map((role) => role.key ?? "");
if (roleKeys.some((key) => IAM_ADMIN_ROLES.includes(key))) {
return "/user-management/user_management-dashboard";
}
return NO_ACCESS_PATH;
}

View File

@@ -0,0 +1,32 @@
import { ShieldOff } from "lucide-react";
import { useAuth } from "@/auth/useAuth";
import { Button } from "@/components/ui/button";
/**
* Terminal page for an account with no freight permissions and no IAM admin
* role. Reached via `resolveLandingPath`, which needs one destination that can
* never bounce — every other page is permission-gated.
*/
export default function NoAccessPage() {
const { user, logout } = useAuth();
return (
<div className="flex min-h-screen items-center justify-center p-6">
<div className="w-full max-w-md space-y-4 rounded-lg border p-8 text-center">
<ShieldOff className="mx-auto h-10 w-10 text-muted-foreground" />
<h1 className="text-lg font-semibold">No access assigned</h1>
<p className="text-sm text-muted-foreground">
Your account has no permissions assigned yet, so there are no pages to
show. Contact your administrator to have a position or role assigned.
</p>
{user?.email ? (
<p className="text-xs text-muted-foreground">Signed in as {user.email}</p>
) : null}
<Button variant="outline" onClick={logout}>
Log out
</Button>
</div>
</div>
);
}

View File

@@ -64,7 +64,9 @@ const LoginPage = () => {
try {
await verifyMfa({ email: normalizedIdentifier, otp: otp.trim() });
navigate("/dashboard/overview", { replace: true });
// "/" resolves to the user's own landing page once the session lands —
// not every user can see the overview.
navigate("/", { replace: true });
} catch (err) {
setError(extractApiError(err).message);
} finally {

View File

@@ -30,6 +30,7 @@ import {
} from "lucide-react";
import { useAuth } from "@/auth/useAuth";
import { resolveLandingPath } from "@/lib/landing";
import { canAccessRuleEngineResource } from "@/lib/permissions";
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
import {
@@ -239,8 +240,8 @@ const CargoTypesPage = () => {
[levelNodes, term],
);
if (!config) return <Navigate to="/dashboard/overview" replace />;
if (!canView) return <Navigate to="/dashboard/overview" replace />;
if (!config) return <Navigate to={resolveLandingPath(user)} replace />;
if (!canView) return <Navigate to={resolveLandingPath(user)} replace />;
// A bad/stale :id (after data loads) → fall back to the root list.
if (!isLoading && currentId && !current) return <Navigate to={BASE_PATH} replace />;

View File

@@ -1,4 +1,5 @@
import { useAuth } from "@/auth/useAuth";
import { resolveLandingPath } from "@/lib/landing";
import {
canAccessRuleEngineResource,
canApproveRuleEngineChange,
@@ -584,7 +585,7 @@ const RuleEngineResourcePage = () => {
}
if (!canView) {
return <Navigate to="/dashboard/overview" replace />;
return <Navigate to={resolveLandingPath(user)} replace />;
}
const openCreate = () => {

View File

@@ -2,19 +2,15 @@ import type { ReactElement } from "react";
import { Navigate } from "react-router-dom";
import { useAuth } from "@/auth/useAuth";
import { resolveLandingPath } from "@/lib/landing";
/**
* Root landing redirect for the vendored IAM pages. Sends super admins to the
* user-management dashboard and everyone else to the freight overview. Minimal
* replacement for the source app's @/routes/RootRedirect (not copied over).
* Root landing redirect for the vendored IAM pages. Delegates to the shared
* landing resolver so it cannot drift from the app's other redirect sinks.
*/
export function RootRedirect(): ReactElement {
const { user } = useAuth();
const roles = (user?.roles ?? []).map((r) => r.key).filter(Boolean);
if (roles.includes("super_admin")) {
return <Navigate to="/user-management/dashboard" replace />;
}
return <Navigate to="/dashboard/overview" replace />;
return <Navigate to={resolveLandingPath(user)} replace />;
}
export default RootRedirect;

View File

@@ -40,8 +40,10 @@ export const AppLayout = () => {
drives collapse on desktop and the Sheet drawer on mobile. */}
<Sidebar collapsible="icon">
<SidebarHeader className="gap-1 border-b border-sidebar-border">
{/* "/" resolves to the user's own landing page — an IAM-only admin
has no overview to go back to. */}
<Link
to="/dashboard/overview"
to="/"
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
>
<ArrowLeft className="size-4 shrink-0" />

View File

@@ -2,6 +2,7 @@ import type { ReactElement } from "react";
import { Navigate, Outlet, Route } from "react-router-dom";
import { useAuth } from "@/auth/useAuth";
import { NO_ACCESS_PATH, resolveLandingPath } from "@/lib/landing";
import { isSuperAdmin } from "@/lib/permissions";
import { WithPermission } from "@/shared/hooks/useHas";
import PendingExternalUsers from "@/super-admin/components/externalUsers/PendingExternalUsers";
@@ -85,7 +86,7 @@ function PrivateRoute({ allowedRoles }: { allowedRoles: string[] }): ReactElemen
.filter((k): k is string => Boolean(k));
const allowed =
isSuperAdmin(user) || allowedRoles.some((r) => roleKeys.includes(r));
return allowed ? <Outlet /> : <Navigate to="/dashboard/overview" replace />;
return allowed ? <Outlet /> : <Navigate to={resolveLandingPath(user)} replace />;
}
export const UserManagementRedirect = () => {
@@ -100,7 +101,9 @@ export const UserManagementRedirect = () => {
return <Navigate to="/user-management/user_management-dashboard" replace />;
}
return <Navigate to="/" replace />; // fallback
// No IAM role: "/" resolves back here for anyone whose only sidebar entry is
// Staff, so bounce to the terminal page instead of ping-ponging.
return <Navigate to={NO_ACCESS_PATH} replace />;
};
/**

View File

@@ -1,119 +0,0 @@
-- Position-type grant mapping for the granular permission system rollout.
-- Grants the NEW granular keys to every hand-curated position type that holds
-- the old broad key whose routes the new keys took over. Idempotent (unique
-- constraint on (position_type_id, permission_id) + ON CONFLICT DO NOTHING).
--
-- PREREQUISITE: run the seeded API once first (SEED_EDR_ORG=true) so
-- EdrOrgSeeder has created the new permission rows this script references.
-- Running it too early is not destructive but silently under-applies: keys that
-- do not exist yet simply match nothing (measured: 11 of 42 rows land pre-seed,
-- because invoices:view/export and payments:view already exist on dev). Re-run
-- after seeding — it is safe to run any number of times.
--
-- Verified 2026-08-07 on a virgin restore of the live dev DB: seeded boot, then
-- this script → 42 rows inserted, second run → 0 rows, final per-key grant
-- counts identical to the reference environment. Per-user API probes across 20
-- departmental test accounts confirm the keys resolve through /me and gate
-- routes correctly.
BEGIN;
-- Helper shape used throughout:
-- holders of <old key> => also grant <new keys>
-- 1. bookings:view holders => dashboard/read keys that replaced blanket access
INSERT INTO iam.position_type_permissions (position_type_id, permission_id)
SELECT DISTINCT ptp.position_type_id, pnew.id
FROM iam.position_type_permissions ptp
JOIN iam.permissions pold ON pold.id = ptp.permission_id
AND pold.key = 'edr_freight_app:bookings:view'
JOIN iam.permissions pnew ON pnew.key IN (
'edr_freight_app:overview:view',
'edr_freight_app:reports:view',
'edr_freight_app:invoices:view',
'edr_freight_app:invoices:export',
'edr_freight_app:payments:view'
)
ON CONFLICT (position_type_id, permission_id) DO NOTHING;
-- 2. train_scheduling:update holders => the write actions split out of it
INSERT INTO iam.position_type_permissions (position_type_id, permission_id)
SELECT DISTINCT ptp.position_type_id, pnew.id
FROM iam.position_type_permissions ptp
JOIN iam.permissions pold ON pold.id = ptp.permission_id
AND pold.key = 'edr_freight_app:train_scheduling:update'
JOIN iam.permissions pnew ON pnew.key IN (
'edr_freight_app:train_scheduling:dispatch',
'edr_freight_app:train_scheduling:mark_paid',
'edr_freight_app:train_scheduling:expire_booking'
)
ON CONFLICT (position_type_id, permission_id) DO NOTHING;
-- 3. GL Djibouti clearance holders => final-invoice raise + confirm
INSERT INTO iam.position_type_permissions (position_type_id, permission_id)
SELECT DISTINCT ptp.position_type_id, pnew.id
FROM iam.position_type_permissions ptp
JOIN iam.permissions pold ON pold.id = ptp.permission_id
AND pold.key = 'edr_freight_app:contracts:clearance_dj_actions'
JOIN iam.permissions pnew ON pnew.key IN (
'edr_freight_app:contracts:final_invoice_raise',
'edr_freight_app:contracts:final_invoice_confirm'
)
ON CONFLICT (position_type_id, permission_id) DO NOTHING;
-- 4. GL Ethiopia clearance holders => final-invoice confirm
INSERT INTO iam.position_type_permissions (position_type_id, permission_id)
SELECT DISTINCT ptp.position_type_id, pnew.id
FROM iam.position_type_permissions ptp
JOIN iam.permissions pold ON pold.id = ptp.permission_id
AND pold.key = 'edr_freight_app:contracts:clearance_et_actions'
JOIN iam.permissions pnew ON pnew.key = 'edr_freight_app:contracts:final_invoice_confirm'
ON CONFLICT (position_type_id, permission_id) DO NOTHING;
-- 5. Contract-intake holders (any staff_accept flavour) => edit_document
INSERT INTO iam.position_type_permissions (position_type_id, permission_id)
SELECT DISTINCT ptp.position_type_id, pnew.id
FROM iam.position_type_permissions ptp
JOIN iam.permissions pold ON pold.id = ptp.permission_id
AND pold.key IN (
'edr_freight_app:bookings:staff_accept',
'edr_freight_app:contracts:staff_accept:bulk',
'edr_freight_app:contracts:staff_accept:container'
)
JOIN iam.permissions pnew ON pnew.key = 'edr_freight_app:contracts:edit_document'
ON CONFLICT (position_type_id, permission_id) DO NOTHING;
-- 6. Support inbox ownership (decision 2026-08-07): marketing department types
INSERT INTO iam.position_type_permissions (position_type_id, permission_id)
SELECT pt.id, p.id
FROM iam.position_types pt
CROSS JOIN iam.permissions p
WHERE (pt.name::text ILIKE '%marketing%' OR pt.key ILIKE '%marketing%')
AND p.key IN (
'edr_freight_app:support:agent_view',
'edr_freight_app:support:agent_send'
)
ON CONFLICT (position_type_id, permission_id) DO NOTHING;
-- 7. Companion view keys.
-- Most freight controllers carry a class-level `<module>:view` guard, and Nest
-- runs class AND method guards — so a type holding only `<module>:<action>` is
-- denied before the action key is ever checked. Grant the module's view key
-- alongside every action key the type already holds. View-only, so it widens
-- reads within a module the type already operates in, never across modules.
INSERT INTO iam.position_type_permissions (position_type_id, permission_id)
SELECT DISTINCT ptp.position_type_id, pview.id
FROM iam.position_type_permissions ptp
JOIN iam.permissions pact ON pact.id = ptp.permission_id
AND pact.key LIKE 'edr_freight_app:%'
JOIN iam.permissions pview ON pview.key = regexp_replace(pact.key, ':[^:]+$', ':view')
ON CONFLICT (position_type_id, permission_id) DO NOTHING;
COMMIT;
-- Verification: expected non-zero counts per new key after running.
-- SELECT p.key, count(*) FROM iam.position_type_permissions ptp
-- JOIN iam.permissions p ON p.id = ptp.permission_id
-- WHERE p.key IN ('edr_freight_app:overview:view','edr_freight_app:support:agent_view',
-- 'edr_freight_app:train_scheduling:dispatch','edr_freight_app:contracts:final_invoice_raise')
-- GROUP BY p.key;