fix(auth): enforce role boundaries on data quality and audit

The data-quality workbench (list, detail, defer, reject) and the audit trail
had no role gate at all beyond authentication -- confirmed live, a Rental
Employee session could list and resolve data-quality issues and read the
full audit trail through both the API and the UI, with only merge-customers
and scan already restricted.

Per the role matrix, both areas are Operations-Manager-only. Gate the
remaining data-quality and audit endpoints with require_operations_manager,
hide their nav items for Rental Employee, show the same restricted-message
pattern Automation.tsx already used for direct URL access, and stop the
dashboard from linking into now-restricted areas for that role.
This commit is contained in:
NuklearRabbit
2026-08-02 04:52:01 +02:00
parent ffc88e33b4
commit 760f3b6ee2
9 changed files with 111 additions and 24 deletions
+28 -11
View File
@@ -1,30 +1,37 @@
import { FormEvent, useEffect, useRef, useState } from "react";
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import type { Role } from "../api/types";
import { BrandMark, Icon, type IconName } from "./Icons";
const NAV_GROUPS: Array<{ label: string; items: Array<{ to: string; label: string; shortLabel: string; icon: IconName }> }> = [
interface NavItem {
to: string;
label: string;
shortLabel: string;
icon: IconName;
roles?: Role[];
}
const NAV_GROUPS: Array<{ label: string; items: NavItem[] }> = [
{
label: "Operate",
items: [
{ to: "/dashboard", label: "Overview", shortLabel: "Overview", icon: "activity" },
{ to: "/vehicles", label: "Fleet", shortLabel: "Fleet", icon: "fleet" },
{ to: "/bookings", label: "Bookings", shortLabel: "Bookings", icon: "bookings" },
{ to: "/data-quality", label: "Data quality", shortLabel: "Quality", icon: "quality" },
{ to: "/data-quality", label: "Data quality", shortLabel: "Quality", icon: "quality", roles: ["operations_manager"] },
],
},
{
label: "Assure",
items: [
{ to: "/knowledge", label: "Knowledge", shortLabel: "Knowledge", icon: "knowledge" },
{ to: "/automation", label: "Integrations", shortLabel: "Systems", icon: "integrations" },
{ to: "/audit", label: "Audit trail", shortLabel: "Audit", icon: "audit" },
{ to: "/automation", label: "Integrations", shortLabel: "Systems", icon: "integrations", roles: ["operations_manager"] },
{ to: "/audit", label: "Audit trail", shortLabel: "Audit", icon: "audit", roles: ["operations_manager"] },
],
},
];
const MOBILE_ITEMS = NAV_GROUPS.flatMap((group) => group.items).slice(0, 5);
const SEARCH_DESTINATIONS = [
{ to: "/dashboard", terms: ["overview", "dashboard", "readiness"] },
{ to: "/vehicles", terms: ["fleet", "vehicle", "vehicles"] },
@@ -43,6 +50,16 @@ export function Layout() {
const [searchStatus, setSearchStatus] = useState("");
const searchInput = useRef<HTMLInputElement>(null);
const navGroups = useMemo(
() =>
NAV_GROUPS.map((group) => ({
...group,
items: group.items.filter((item) => !item.roles || (user && item.roles.includes(user.role))),
})).filter((group) => group.items.length > 0),
[user],
);
const mobileItems = useMemo(() => navGroups.flatMap((group) => group.items).slice(0, 5), [navGroups]);
useEffect(() => {
function focusGlobalSearch(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
@@ -55,8 +72,8 @@ export function Layout() {
return () => window.removeEventListener("keydown", focusGlobalSearch);
}, []);
function handleLogout() {
logout();
async function handleLogout() {
await logout();
navigate("/login");
}
@@ -99,7 +116,7 @@ export function Layout() {
<div><strong>MobilityOps</strong><span>Control centre</span></div>
</div>
<nav aria-label="Primary navigation">
{NAV_GROUPS.map((group) => (
{navGroups.map((group) => (
<div className="nav-group" key={group.label}>
<p>{group.label}</p>
<ul>
@@ -166,7 +183,7 @@ export function Layout() {
</div>
<nav className="mobile-nav" aria-label="Mobile navigation">
{MOBILE_ITEMS.map((item) => (
{mobileItems.map((item) => (
<NavLink key={item.to} to={item.to}>
<Icon name={item.icon} />
<span>{item.shortLabel}</span>