diff --git a/backend/app/api/routers/audit.py b/backend/app/api/routers/audit.py index f12ef00..b5a5edf 100644 --- a/backend/app/api/routers/audit.py +++ b/backend/app/api/routers/audit.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import select from sqlalchemy.orm import Session -from app.api.deps import get_current_user, get_db +from app.api.deps import get_db, require_operations_manager from app.models.audit import AuditEvent from app.schemas import AuditEventOut, CurrentUser @@ -19,7 +19,7 @@ def list_audit_events( correlation_id: str | None = Query(default=None), limit: int = Query(default=100, le=500), db: Session = Depends(get_db), - _user: CurrentUser = Depends(get_current_user), + _user: CurrentUser = Depends(require_operations_manager), ) -> list[AuditEventOut]: stmt = select(AuditEvent).order_by(AuditEvent.occurred_at.desc()).limit(limit) if actor_label: diff --git a/backend/app/api/routers/data_quality.py b/backend/app/api/routers/data_quality.py index a7f5b22..308551f 100644 --- a/backend/app/api/routers/data_quality.py +++ b/backend/app/api/routers/data_quality.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import select from sqlalchemy.orm import Session -from app.api.deps import get_current_user, get_db, require_operations_manager +from app.api.deps import get_db, require_operations_manager from app.models.customer import Customer from app.models.data_quality import DataQualityIssue from app.models.vehicle import Vehicle @@ -41,7 +41,7 @@ def list_issues( rule_type: str | None = Query(default=None), severity: str | None = Query(default=None), db: Session = Depends(get_db), - _user: CurrentUser = Depends(get_current_user), + _user: CurrentUser = Depends(require_operations_manager), ) -> list[DataQualityIssueOut]: stmt = select(DataQualityIssue).order_by(DataQualityIssue.detected_at.desc()) if status: @@ -85,7 +85,7 @@ def _snapshot(entity_type: str, ref: str, db: Session) -> dict | None: def get_issue( public_ref: str, db: Session = Depends(get_db), - _user: CurrentUser = Depends(get_current_user), + _user: CurrentUser = Depends(require_operations_manager), ) -> DataQualityIssueDetailOut: issue = db.scalar(select(DataQualityIssue).where(DataQualityIssue.public_ref == public_ref)) if issue is None: @@ -110,7 +110,7 @@ def get_issue( def defer( public_ref: str, db: Session = Depends(get_db), - user: CurrentUser = Depends(get_current_user), + user: CurrentUser = Depends(require_operations_manager), ) -> DataQualityIssueOut: issue = defer_issue(db, public_ref, user) return _to_out(issue) @@ -120,7 +120,7 @@ def defer( def reject( public_ref: str, db: Session = Depends(get_db), - user: CurrentUser = Depends(get_current_user), + user: CurrentUser = Depends(require_operations_manager), ) -> DataQualityIssueOut: issue = reject_issue(db, public_ref, user) return _to_out(issue) diff --git a/backend/tests/test_audit.py b/backend/tests/test_audit.py index 9d986c8..ecda136 100644 --- a/backend/tests/test_audit.py +++ b/backend/tests/test_audit.py @@ -9,3 +9,8 @@ def test_demo_login_is_audited(ops_client): def test_audit_requires_authentication(client): response = client.get("/api/v1/audit") assert response.status_code == 401 + + +def test_audit_requires_operations_manager(employee_client): + response = employee_client.get("/api/v1/audit") + assert response.status_code == 403 diff --git a/backend/tests/test_data_quality.py b/backend/tests/test_data_quality.py index 0d44351..6628e10 100644 --- a/backend/tests/test_data_quality.py +++ b/backend/tests/test_data_quality.py @@ -25,6 +25,26 @@ def test_scan_requires_operations_manager(employee_client): assert response.status_code == 403 +def test_list_issues_requires_operations_manager(employee_client): + response = employee_client.get("/api/v1/data-quality/issues") + assert response.status_code == 403 + + +def test_get_issue_requires_operations_manager(employee_client): + response = employee_client.get("/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE") + assert response.status_code == 403 + + +def test_defer_requires_operations_manager(employee_client): + response = employee_client.post("/api/v1/data-quality/issues/DQ-DEMO-OVERLAP/defer") + assert response.status_code == 403 + + +def test_reject_requires_operations_manager(employee_client): + response = employee_client.post("/api/v1/data-quality/issues/DQ-DEMO-OVERLAP/reject") + assert response.status_code == 403 + + def test_s2_duplicate_customer_issue_detail(ops_client): response = ops_client.get("/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE") assert response.status_code == 200 diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index cfad38b..d99196c 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -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(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() {
MobilityOpsControl centre