Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx
Nathnael d337fa0d85 fix(freight:backoffice): remove coarse fleet.view/fleet.manage/admin fallbacks
Now that every fleet-resource page and settings page has its own
dedicated permission key (previous commit), the broad fallbacks are
redundant and over-grant: anyone holding only fleet:view/fleet:manage
or admin could reach every page in that whole section, not just one.

Removed fleet.view fallback from: Routes, Locomotives, Train Builder,
Wagons, Containers, Cargoes, Compliance & Alerts, Procurement, and the
Overview dashboard's Fleet KPI tab.

Removed fleet.manage fallback from: canFleetAction() (per-resource
fleet CRUD, lib/permissions.ts) and TrainBuilderDetailPage's wagon-
assignment check. Hard-delete already had no such fallback.

Removed admin fallback from: File settings, Dropdown settings,
Contract templates, Portal content, Trade access, Exchange rate.

Left untouched: Incidents (sole gate is fleet.view — no dedicated
edr_freight_app:incidents:* key exists on the backend yet, so there's
nothing to fall back FROM; removing it would make the page
super-admin-only).

Access-narrowing change: anyone currently relying on the coarse grant
without also holding the specific resource/settings key will lose
access to these pages until roles are updated to grant the specific
keys directly. Audit role assignments before this deploys.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-08 13:59:06 +00:00

242 lines
6.7 KiB
TypeScript

import { useState } from "react";
import {
AlertCircle,
Banknote,
FileSignature,
FileText,
Train,
TrainFront,
UserCheck,
Users,
} from "lucide-react";
import {
Alert,
Badge,
Button,
Container,
Skeleton,
Stack,
Tabs,
} from "@mantine/core";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/auth/useAuth";
import { OverviewPageHeader } from "@/components/overview/OverviewPageHeader";
import { OverviewTabContent } from "@/components/overview/OverviewTabContent";
import "@/components/overview/overview.css";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { useOverview } from "@/hooks/useOverview";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import type { OverviewRange, OverviewTabKey } from "@/types/overview";
const TAB_ITEMS: Array<{
value: OverviewTabKey;
label: string;
icon: typeof FileText;
kpiKey:
| "bookings"
| "contracts"
| "billing"
| "operations"
| "customers"
| "staff";
metricKey: string;
/** Any of these keys grants the tab. */
permission: string[];
}> = [
{
value: "bookings",
label: "Bookings",
icon: FileText,
kpiKey: "bookings",
metricKey: "totalActive",
permission: [FREIGHT_PERMS.bookings.view],
},
{
value: "contracts",
label: "Contracts",
icon: FileSignature,
kpiKey: "contracts",
metricKey: "totalActive",
permission: [FREIGHT_PERMS.contracts.view],
},
{
value: "billing",
label: "Billing",
icon: Banknote,
kpiKey: "billing",
metricKey: "successfulPaymentsMtd",
permission: [FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.payments.view],
},
{
value: "operations",
label: "Operations",
icon: Train,
kpiKey: "operations",
metricKey: "trainsActive",
permission: [
FREIGHT_PERMS.trainScheduling.view,
FREIGHT_PERMS.warehouseInventory.view,
FREIGHT_PERMS.firstMile.view,
FREIGHT_PERMS.lastMile.view,
],
},
{
value: "fleet",
label: "Fleet",
icon: TrainFront,
kpiKey: "operations",
metricKey: "wagonsAvailable",
permission: [FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.trainScheduling.view],
},
{
value: "customers",
label: "Customers",
icon: Users,
kpiKey: "customers",
metricKey: "totalCustomers",
permission: [FREIGHT_PERMS.customers.view],
},
{
value: "staff",
label: "Staff",
icon: UserCheck,
kpiKey: "staff",
metricKey: "activeEmployees",
permission: [
FREIGHT_PERMS.admin,
FREIGHT_PERMS.staff.roles.view,
FREIGHT_PERMS.staff.employeeRegistration.view,
FREIGHT_PERMS.staff.roleAssignment.view,
],
},
];
function HeaderSkeleton() {
return (
<Stack gap="md">
<Skeleton height={48} radius="md" />
<Skeleton height={52} radius="lg" />
</Stack>
);
}
const OverviewPage = () => {
const [range, setRange] = useState<OverviewRange>("30d");
const [activeTab, setActiveTab] = useState<OverviewTabKey>("bookings");
const queryClient = useQueryClient();
const { user } = useAuth();
const { data: summary, isLoading, isError, error, refetch, isFetching } = useOverview(range);
// Permission-scoped view: only tabs the user may see; a restricted role
// (e.g. operations) gets a summary 403 — that is not a connection problem.
const visibleTabs = TAB_ITEMS.filter((tab) =>
tab.permission.some((key) => hasPermission(user, key)),
);
const currentTab = visibleTabs.some((t) => t.value === activeTab)
? activeTab
: visibleTabs[0]?.value;
const accessDenied =
(error as { response?: { status?: number } } | null)?.response?.status === 403;
const handleRefresh = () => {
void refetch();
void queryClient.invalidateQueries({ queryKey: QUERY_KEYS.OVERVIEW.ROOT });
};
const getTabBadge = (tab: (typeof TAB_ITEMS)[number]) => {
if (!summary?.kpis) return 0;
const group = summary.kpis[tab.kpiKey] as unknown as Record<string, number>;
return group[tab.metricKey] ?? 0;
};
return (
<Container fluid px="md" py="md">
<Stack gap="lg">
{isLoading && !summary ? (
<HeaderSkeleton />
) : (
<OverviewPageHeader
range={range}
onRangeChange={setRange}
generatedAt={summary?.generatedAt}
onRefresh={handleRefresh}
isRefreshing={isFetching && !isLoading}
/>
)}
{isError && !accessDenied && (
<Alert
icon={<AlertCircle size={16} />}
color="red"
title="Unable to load dashboard summary"
variant="light"
>
<Stack gap="sm" align="flex-start">
<span>Check your connection and try again.</span>
<Button size="xs" variant="light" color="red" onClick={() => void refetch()}>
Retry
</Button>
</Stack>
</Alert>
)}
{visibleTabs.length === 0 && !isLoading && !isError && (
<Alert color="gray" variant="light" title="No dashboard sections available">
Your role has no access to any overview section.
</Alert>
)}
{visibleTabs.length > 0 && (
<Tabs
value={currentTab}
onChange={(value) =>
setActiveTab((value as OverviewTabKey) ?? visibleTabs[0].value)
}
variant="pills"
color="edr-green"
keepMounted={false}
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
>
<Tabs.List>
{visibleTabs.map((tab) => {
const Icon = tab.icon;
const isActive = currentTab === tab.value;
return (
<Tabs.Tab
key={tab.value}
value={tab.value}
leftSection={<Icon size={17} />}
rightSection={
summary ? (
<Badge
size="sm"
radius="sm"
variant={isActive ? "white" : "light"}
color={isActive ? "edr-green" : "gray"}
>
{getTabBadge(tab)}
</Badge>
) : undefined
}
>
{tab.label}
</Tabs.Tab>
);
})}
</Tabs.List>
{visibleTabs.map((tab) => (
<Tabs.Panel key={tab.value} value={tab.value} pt="lg">
<OverviewTabContent tab={tab.value} range={range} />
</Tabs.Panel>
))}
</Tabs>
)}
</Stack>
</Container>
);
};
export default OverviewPage;