From 1e9149ce0032550d9de2d7e530bfeda465804879 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 7 Aug 2026 12:24:30 +0000 Subject: [PATCH] feat: better navigation in backoffice --- apps/edr-freight-web/backoffice/src/App.tsx | 678 +----------------- .../src/components/auth/RequirePermission.tsx | 24 +- .../components/layout/sidebar-sections.tsx | 660 +++++++++++++++++ .../backoffice/src/lib/landing.test.ts | 57 ++ .../backoffice/src/lib/landing.ts | 51 ++ .../backoffice/src/pages/NoAccessPage.tsx | 32 + .../backoffice/src/pages/auth/LoginPage.tsx | 4 +- .../src/pages/ruleEngine/CargoTypesPage.tsx | 5 +- .../ruleEngine/RuleEngineResourcePage.tsx | 3 +- .../backoffice/src/routes/RootRedirect.tsx | 12 +- .../src/user-management/Applayout.tsx | 4 +- .../backoffice/src/user-management/route.tsx | 7 +- 12 files changed, 861 insertions(+), 676 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx create mode 100644 apps/edr-freight-web/backoffice/src/lib/landing.test.ts create mode 100644 apps/edr-freight-web/backoffice/src/lib/landing.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/NoAccessPage.tsx diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 6fd666a8d..bad3cbfe5 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -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: , - permission: FREIGHT_PERMS.overview.view, - }, - { - label: "Reports", - href: "/dashboard/reports", - icon: , - permission: FREIGHT_PERMS.reports.view, - }, - { - label: "Customers", - href: "/dashboard/customers", - icon: , - permission: FREIGHT_PERMS.customers.view, - }, - { - label: "Contracts", - href: "/dashboard/contract-requests", - icon: , - permission: FREIGHT_PERMS.contracts.view, - }, - { - label: "Bookings", - href: "/dashboard/booking-requests", - icon: , - permission: FREIGHT_PERMS.bookings.view, - }, - { - label: "Wagon cancellations", - href: "/dashboard/wagon-cancellations", - icon: , - 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: , - permission: FREIGHT_PERMS.contracts.opsClearanceReview, - }, - { - label: "Payments", - href: "/dashboard/payments", - icon: , - permission: FREIGHT_PERMS.payments.view, - }, - { - label: "Invoices", - href: "/dashboard/invoices", - icon: , - permission: FREIGHT_PERMS.invoices.view, - }, - { - label: "Support", - href: "/dashboard/support", - icon: , - permission: FREIGHT_PERMS.support.agentView, - }, - ...demoItems, - ], - }, - { - // title: "Port & Terminal", - items: [ - { - label: "Operations", - icon: , - children: [ - { - label: "Clearance", - href: "/dashboard/contracts/clearance", - icon: , - permission: [ - FREIGHT_PERMS.contracts.clearanceReview, - FREIGHT_PERMS.contracts.clearanceEtActions, - ], - }, - // { - // label: "Shipment Requests", - // href: "/dashboard/shipment-requests", - // icon: , - // 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: , - // permission: FREIGHT_PERMS.contracts.opsClearanceReview, - // }, - { - label: "GL Djibouti Clearance", - href: "/dashboard/gl-djibouti/clearance", - icon: , - permission: FREIGHT_PERMS.contracts.clearanceDjActions, - }, - { - label: "Train Schedules", - href: "/dashboard/operations/train-scheduling-v2", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - }, - { - label: "Batch Board", - href: "/dashboard/operations/batch-board", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - }, - { - label: "First Mile", - href: "/dashboard/operations/first-mile", - icon: , - permission: FREIGHT_PERMS.firstMile.view, - }, - { - label: "Last Mile", - href: "/dashboard/operations/last-mile", - icon: , - permission: FREIGHT_PERMS.lastMile.view, - }, - ], - }, - { - label: "Fleet Management", - icon: , - children: [ - { - label: "Fleet Dashboard", - href: "/dashboard/fleet-dashboard", - icon: , - permission: FREIGHT_PERMS.fleetDashboard.view, - }, - { - label: "Routes", - href: "/dashboard/routes", - icon: , - permission: [FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view], - }, - { - label: "Locomotives", - href: "/dashboard/locomotives", - icon: , - permission: [ - FREIGHT_PERMS.locomotives.view, - FREIGHT_PERMS.fleet.view, - ], - }, - { - label: "Train Builder", - href: "/dashboard/train-builder", - icon: , - permission: [FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view], - }, - - // { - // label: "Wagon types", - // href: "/dashboard/wagon-types", - // icon: , - // }, - { - label: "Wagons", - href: "/dashboard/wagons", - icon: , - permission: [FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view], - }, - { - label: "Wagon Transfers", - href: "/dashboard/wagon-transfers", - icon: , - permission: [ - FREIGHT_PERMS.wagons.transferView, - FREIGHT_PERMS.wagons.view, - ], - }, - { - label: "Vehicles", - href: "/dashboard/vehicles", - icon: , - permission: FREIGHT_PERMS.vehicles.view, - }, - { - label: "Drivers", - href: "/dashboard/drivers", - icon: , - permission: FREIGHT_PERMS.drivers.view, - }, - { - label: "Track Vehicles", - href: "/dashboard/tracking", - icon: , - permission: FREIGHT_PERMS.tracking.view, - }, - { - label: "Fuel Purchases", - href: "/dashboard/fuel-purchases", - icon: , - permission: FREIGHT_PERMS.fuel.view, - }, - { - label: "Fuel Analytics", - href: "/dashboard/fuel-stats", - icon: , - permission: FREIGHT_PERMS.fuel.view, - }, - { - label: "Maintenance", - href: "/dashboard/maintenance", - icon: , - permission: FREIGHT_PERMS.maintenance.view, - }, - { - label: "Work Orders", - href: "/dashboard/work-orders", - icon: , - permission: FREIGHT_PERMS.maintenance.view, - }, - { - label: "Compliance & Alerts", - href: "/dashboard/compliance", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Incidents", - href: "/dashboard/incidents", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Procurement", - href: "/dashboard/procurement", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Financial Reports", - href: "/dashboard/financial-reports", - icon: , - permission: FREIGHT_PERMS.fleetReports.view, - }, - // { - // label: "Containers", - // href: "/dashboard/containers", - // icon: , - // }, - // { - // label: "Cargoes", - // href: "/dashboard/cargoes", - // icon: , - // }, - ], - }, - { - label: "Imports", - href: "/dashboard/import-warehouse", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - children: [ - { - label: "Import Overview", - href: "/dashboard/import-warehouse", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Arrival Queue", - href: "/dashboard/arrival-queue", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Import Trucks", - href: "/dashboard/import-trucks", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "EDR Last Mile Returns", - href: "/dashboard/edr-last-mile-returns", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Container Returns", - href: "/dashboard/container-returns", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Terminal Inventory", - href: "/dashboard/warehouse-inventory?direction=IMPORT", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Inventory Inquiry", - href: "/dashboard/inventory-inquiry", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - ], - }, - { - label: "Exports", - href: "/dashboard/export-warehouse", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - children: [ - { - label: "Export Overview", - href: "/dashboard/export-warehouse", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Loading Queue", - href: "/dashboard/loading-queue", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Loaded Inventory", - href: "/dashboard/loaded-inventory", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Dispatch Queue", - href: "/dashboard/dispatch-queue", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Djibouti Unloading", - href: "/dashboard/export-djibouti-unloading", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Interchange Documents", - href: "/dashboard/interchange-documents", - icon: , - permission: FREIGHT_PERMS.interchangeDocuments.view, - }, - { - label: "Terminal Inventory", - href: "/dashboard/warehouse-inventory?direction=EXPORT", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - ], - }, - { - label: "Intercity", - href: "/dashboard/intercity", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - children: [ - { - label: "Intercity Cargo", - href: "/dashboard/intercity", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - ], - }, - { - label: "Warehouse Management", - icon: , - children: [ - { - label: "Warehouse Dashboard", - href: "/dashboard/warehouse-dashboard", - icon: , - 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: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Warehouses", - href: "/dashboard/warehouses", - icon: , - permission: FREIGHT_PERMS.warehouses.view, - }, - { - label: "Allocation & Fees", - href: "/dashboard/warehouse-rules", - icon: , - permission: [ - FREIGHT_PERMS.warehouseAllocationRules.view, - FREIGHT_PERMS.warehouseFeeRules.view, - ], - }, - { - label: "Fee Invoices", - href: "/dashboard/warehouse-fee-invoices", - icon: , - permission: FREIGHT_PERMS.warehouseFeeInvoices.view, - }, - ], - }, - ], - }, - { - title: "Freight configuration", - mutedTitle: true, - items: [ - { - label: "File settings", - href: "/dashboard/file-settings", - icon: , - permission: FREIGHT_PERMS.admin, - }, - { - label: "Dropdown settings", - href: "/dashboard/dropdown-settings", - icon: , - permission: FREIGHT_PERMS.admin, - }, - { - label: "Contract templates", - href: "/dashboard/contract-templates", - icon: , - permission: FREIGHT_PERMS.admin, - }, - { - label: "Audit logs", - href: "/dashboard/audit-logs", - icon: , - permission: FREIGHT_PERMS.audit.view, - }, - { - label: "Configuration", - href: "/dashboard/configuration", - icon: , - 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: , - children: getCategorySidebarChildren("rules"), - }, - - { - label: "Staff", - href: "/user-management", - icon: , - 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["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 ( {UserManagementRoutes()} {/* } /> */} } /> } /> - } /> - } - /> + } /> + {/* Landing is per-user: /dashboard/overview is gated on overview:view, so + a fixed target strands anyone without that key on a blank page. */} + } /> + } /> }> } /> } /> @@ -1554,7 +919,9 @@ const App = () => { } /> - } /> + {/* Post-login landing: the browser is still on /auth, which matches + nothing here, so this is what actually decides where users start. */} + } /> ); }; @@ -1575,3 +942,4 @@ function LegacyGlEthiopiaClearanceRedirect() { } export default App; + diff --git a/apps/edr-freight-web/backoffice/src/components/auth/RequirePermission.tsx b/apps/edr-freight-web/backoffice/src/components/auth/RequirePermission.tsx index 4e35fc712..c67a4da69 100644 --- a/apps/edr-freight-web/backoffice/src/components/auth/RequirePermission.tsx +++ b/apps/edr-freight-web/backoffice/src/components/auth/RequirePermission.tsx @@ -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 ; - 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 ; + return ; } diff --git a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx new file mode 100644 index 000000000..914a9cceb --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx @@ -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: , + permission: FREIGHT_PERMS.overview.view, + }, + { + label: "Reports", + href: "/dashboard/reports", + icon: , + permission: FREIGHT_PERMS.reports.view, + }, + { + label: "Customers", + href: "/dashboard/customers", + icon: , + permission: FREIGHT_PERMS.customers.view, + }, + { + label: "Contracts", + href: "/dashboard/contract-requests", + icon: , + permission: FREIGHT_PERMS.contracts.view, + }, + { + label: "Bookings", + href: "/dashboard/booking-requests", + icon: , + permission: FREIGHT_PERMS.bookings.view, + }, + { + label: "Wagon cancellations", + href: "/dashboard/wagon-cancellations", + icon: , + 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: , + permission: FREIGHT_PERMS.contracts.opsClearanceReview, + }, + { + label: "Payments", + href: "/dashboard/payments", + icon: , + permission: FREIGHT_PERMS.payments.view, + }, + { + label: "Invoices", + href: "/dashboard/invoices", + icon: , + permission: FREIGHT_PERMS.invoices.view, + }, + { + label: "Support", + href: "/dashboard/support", + icon: , + permission: FREIGHT_PERMS.support.agentView, + }, + ...demoItems, + ], + }, + { + // title: "Port & Terminal", + items: [ + { + label: "Operations", + icon: , + children: [ + { + label: "Clearance", + href: "/dashboard/contracts/clearance", + icon: , + permission: [ + FREIGHT_PERMS.contracts.clearanceReview, + FREIGHT_PERMS.contracts.clearanceEtActions, + ], + }, + // { + // label: "Shipment Requests", + // href: "/dashboard/shipment-requests", + // icon: , + // 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: , + // permission: FREIGHT_PERMS.contracts.opsClearanceReview, + // }, + { + label: "GL Djibouti Clearance", + href: "/dashboard/gl-djibouti/clearance", + icon: , + permission: FREIGHT_PERMS.contracts.clearanceDjActions, + }, + { + label: "Train Schedules", + href: "/dashboard/operations/train-scheduling-v2", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, + { + label: "Batch Board", + href: "/dashboard/operations/batch-board", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, + { + label: "First Mile", + href: "/dashboard/operations/first-mile", + icon: , + permission: FREIGHT_PERMS.firstMile.view, + }, + { + label: "Last Mile", + href: "/dashboard/operations/last-mile", + icon: , + permission: FREIGHT_PERMS.lastMile.view, + }, + ], + }, + { + label: "Fleet Management", + icon: , + children: [ + { + label: "Fleet Dashboard", + href: "/dashboard/fleet-dashboard", + icon: , + permission: FREIGHT_PERMS.fleetDashboard.view, + }, + { + label: "Routes", + href: "/dashboard/routes", + icon: , + permission: [FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view], + }, + { + label: "Locomotives", + href: "/dashboard/locomotives", + icon: , + permission: [ + FREIGHT_PERMS.locomotives.view, + FREIGHT_PERMS.fleet.view, + ], + }, + { + label: "Train Builder", + href: "/dashboard/train-builder", + icon: , + permission: [FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view], + }, + + // { + // label: "Wagon types", + // href: "/dashboard/wagon-types", + // icon: , + // }, + { + label: "Wagons", + href: "/dashboard/wagons", + icon: , + permission: [FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view], + }, + { + label: "Wagon Transfers", + href: "/dashboard/wagon-transfers", + icon: , + permission: [ + FREIGHT_PERMS.wagons.transferView, + FREIGHT_PERMS.wagons.view, + ], + }, + { + label: "Vehicles", + href: "/dashboard/vehicles", + icon: , + permission: FREIGHT_PERMS.vehicles.view, + }, + { + label: "Drivers", + href: "/dashboard/drivers", + icon: , + permission: FREIGHT_PERMS.drivers.view, + }, + { + label: "Track Vehicles", + href: "/dashboard/tracking", + icon: , + permission: FREIGHT_PERMS.tracking.view, + }, + { + label: "Fuel Purchases", + href: "/dashboard/fuel-purchases", + icon: , + permission: FREIGHT_PERMS.fuel.view, + }, + { + label: "Fuel Analytics", + href: "/dashboard/fuel-stats", + icon: , + permission: FREIGHT_PERMS.fuel.view, + }, + { + label: "Maintenance", + href: "/dashboard/maintenance", + icon: , + permission: FREIGHT_PERMS.maintenance.view, + }, + { + label: "Work Orders", + href: "/dashboard/work-orders", + icon: , + permission: FREIGHT_PERMS.maintenance.view, + }, + { + label: "Compliance & Alerts", + href: "/dashboard/compliance", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Incidents", + href: "/dashboard/incidents", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Procurement", + href: "/dashboard/procurement", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Financial Reports", + href: "/dashboard/financial-reports", + icon: , + permission: FREIGHT_PERMS.fleetReports.view, + }, + // { + // label: "Containers", + // href: "/dashboard/containers", + // icon: , + // }, + // { + // label: "Cargoes", + // href: "/dashboard/cargoes", + // icon: , + // }, + ], + }, + { + label: "Imports", + href: "/dashboard/import-warehouse", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + children: [ + { + label: "Import Overview", + href: "/dashboard/import-warehouse", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Arrival Queue", + href: "/dashboard/arrival-queue", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Import Trucks", + href: "/dashboard/import-trucks", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "EDR Last Mile Returns", + href: "/dashboard/edr-last-mile-returns", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Container Returns", + href: "/dashboard/container-returns", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Terminal Inventory", + href: "/dashboard/warehouse-inventory?direction=IMPORT", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Inventory Inquiry", + href: "/dashboard/inventory-inquiry", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + ], + }, + { + label: "Exports", + href: "/dashboard/export-warehouse", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + children: [ + { + label: "Export Overview", + href: "/dashboard/export-warehouse", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Loading Queue", + href: "/dashboard/loading-queue", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Loaded Inventory", + href: "/dashboard/loaded-inventory", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Dispatch Queue", + href: "/dashboard/dispatch-queue", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Djibouti Unloading", + href: "/dashboard/export-djibouti-unloading", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Interchange Documents", + href: "/dashboard/interchange-documents", + icon: , + permission: FREIGHT_PERMS.interchangeDocuments.view, + }, + { + label: "Terminal Inventory", + href: "/dashboard/warehouse-inventory?direction=EXPORT", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + ], + }, + { + label: "Intercity", + href: "/dashboard/intercity", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + children: [ + { + label: "Intercity Cargo", + href: "/dashboard/intercity", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + ], + }, + { + label: "Warehouse Management", + icon: , + children: [ + { + label: "Warehouse Dashboard", + href: "/dashboard/warehouse-dashboard", + icon: , + 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: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Warehouses", + href: "/dashboard/warehouses", + icon: , + permission: FREIGHT_PERMS.warehouses.view, + }, + { + label: "Allocation & Fees", + href: "/dashboard/warehouse-rules", + icon: , + permission: [ + FREIGHT_PERMS.warehouseAllocationRules.view, + FREIGHT_PERMS.warehouseFeeRules.view, + ], + }, + { + label: "Fee Invoices", + href: "/dashboard/warehouse-fee-invoices", + icon: , + permission: FREIGHT_PERMS.warehouseFeeInvoices.view, + }, + ], + }, + ], + }, + { + title: "Freight configuration", + mutedTitle: true, + items: [ + { + label: "File settings", + href: "/dashboard/file-settings", + icon: , + permission: FREIGHT_PERMS.admin, + }, + { + label: "Dropdown settings", + href: "/dashboard/dropdown-settings", + icon: , + permission: FREIGHT_PERMS.admin, + }, + { + label: "Contract templates", + href: "/dashboard/contract-templates", + icon: , + permission: FREIGHT_PERMS.admin, + }, + { + label: "Audit logs", + href: "/dashboard/audit-logs", + icon: , + permission: FREIGHT_PERMS.audit.view, + }, + { + label: "Configuration", + href: "/dashboard/configuration", + icon: , + 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: , + children: getCategorySidebarChildren("rules"), + }, + + { + label: "Staff", + href: "/user-management", + icon: , + 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; +}; diff --git a/apps/edr-freight-web/backoffice/src/lib/landing.test.ts b/apps/edr-freight-web/backoffice/src/lib/landing.test.ts new file mode 100644 index 000000000..e286e197d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/lib/landing.test.ts @@ -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); + }); +}); diff --git a/apps/edr-freight-web/backoffice/src/lib/landing.ts b/apps/edr-freight-web/backoffice/src/lib/landing.ts new file mode 100644 index 000000000..2cfd6f5d1 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/lib/landing.ts @@ -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; +} diff --git a/apps/edr-freight-web/backoffice/src/pages/NoAccessPage.tsx b/apps/edr-freight-web/backoffice/src/pages/NoAccessPage.tsx new file mode 100644 index 000000000..06d19ac5f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/NoAccessPage.tsx @@ -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 ( +
+
+ +

No access assigned

+

+ Your account has no permissions assigned yet, so there are no pages to + show. Contact your administrator to have a position or role assigned. +

+ {user?.email ? ( +

Signed in as {user.email}

+ ) : null} + +
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx b/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx index 811d7e34b..db4533d21 100644 --- a/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx @@ -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 { diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx index 57c239885..e3fb54526 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx @@ -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 ; - if (!canView) return ; + if (!config) return ; + if (!canView) return ; // A bad/stale :id (after data loads) → fall back to the root list. if (!isLoading && currentId && !current) return ; diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx index 885fcb82b..3a3629155 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx @@ -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 ; + return ; } const openCreate = () => { diff --git a/apps/edr-freight-web/backoffice/src/routes/RootRedirect.tsx b/apps/edr-freight-web/backoffice/src/routes/RootRedirect.tsx index 77ad29531..48f3ce6aa 100644 --- a/apps/edr-freight-web/backoffice/src/routes/RootRedirect.tsx +++ b/apps/edr-freight-web/backoffice/src/routes/RootRedirect.tsx @@ -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 ; - } - return ; + return ; } export default RootRedirect; diff --git a/apps/edr-freight-web/backoffice/src/user-management/Applayout.tsx b/apps/edr-freight-web/backoffice/src/user-management/Applayout.tsx index ade96250d..37ab26be8 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/Applayout.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/Applayout.tsx @@ -40,8 +40,10 @@ export const AppLayout = () => { drives collapse on desktop and the Sheet drawer on mobile. */} + {/* "/" resolves to the user's own landing page — an IAM-only admin + has no overview to go back to. */} diff --git a/apps/edr-freight-web/backoffice/src/user-management/route.tsx b/apps/edr-freight-web/backoffice/src/user-management/route.tsx index af29cdc5e..f1e2a8611 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/route.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/route.tsx @@ -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 ? : ; + return allowed ? : ; } export const UserManagementRedirect = () => { @@ -100,7 +101,9 @@ export const UserManagementRedirect = () => { return ; } - return ; // 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 ; }; /**