mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: better navigation in backoffice
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
57
apps/edr-freight-web/backoffice/src/lib/landing.test.ts
Normal file
57
apps/edr-freight-web/backoffice/src/lib/landing.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
51
apps/edr-freight-web/backoffice/src/lib/landing.ts
Normal file
51
apps/edr-freight-web/backoffice/src/lib/landing.ts
Normal 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;
|
||||
}
|
||||
32
apps/edr-freight-web/backoffice/src/pages/NoAccessPage.tsx
Normal file
32
apps/edr-freight-web/backoffice/src/pages/NoAccessPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 />;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user