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:
@@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
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.models.audit import AuditEvent
|
||||||
from app.schemas import AuditEventOut, CurrentUser
|
from app.schemas import AuditEventOut, CurrentUser
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ def list_audit_events(
|
|||||||
correlation_id: str | None = Query(default=None),
|
correlation_id: str | None = Query(default=None),
|
||||||
limit: int = Query(default=100, le=500),
|
limit: int = Query(default=100, le=500),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_user: CurrentUser = Depends(get_current_user),
|
_user: CurrentUser = Depends(require_operations_manager),
|
||||||
) -> list[AuditEventOut]:
|
) -> list[AuditEventOut]:
|
||||||
stmt = select(AuditEvent).order_by(AuditEvent.occurred_at.desc()).limit(limit)
|
stmt = select(AuditEvent).order_by(AuditEvent.occurred_at.desc()).limit(limit)
|
||||||
if actor_label:
|
if actor_label:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
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.customer import Customer
|
||||||
from app.models.data_quality import DataQualityIssue
|
from app.models.data_quality import DataQualityIssue
|
||||||
from app.models.vehicle import Vehicle
|
from app.models.vehicle import Vehicle
|
||||||
@@ -41,7 +41,7 @@ def list_issues(
|
|||||||
rule_type: str | None = Query(default=None),
|
rule_type: str | None = Query(default=None),
|
||||||
severity: str | None = Query(default=None),
|
severity: str | None = Query(default=None),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_user: CurrentUser = Depends(get_current_user),
|
_user: CurrentUser = Depends(require_operations_manager),
|
||||||
) -> list[DataQualityIssueOut]:
|
) -> list[DataQualityIssueOut]:
|
||||||
stmt = select(DataQualityIssue).order_by(DataQualityIssue.detected_at.desc())
|
stmt = select(DataQualityIssue).order_by(DataQualityIssue.detected_at.desc())
|
||||||
if status:
|
if status:
|
||||||
@@ -85,7 +85,7 @@ def _snapshot(entity_type: str, ref: str, db: Session) -> dict | None:
|
|||||||
def get_issue(
|
def get_issue(
|
||||||
public_ref: str,
|
public_ref: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_user: CurrentUser = Depends(get_current_user),
|
_user: CurrentUser = Depends(require_operations_manager),
|
||||||
) -> DataQualityIssueDetailOut:
|
) -> DataQualityIssueDetailOut:
|
||||||
issue = db.scalar(select(DataQualityIssue).where(DataQualityIssue.public_ref == public_ref))
|
issue = db.scalar(select(DataQualityIssue).where(DataQualityIssue.public_ref == public_ref))
|
||||||
if issue is None:
|
if issue is None:
|
||||||
@@ -110,7 +110,7 @@ def get_issue(
|
|||||||
def defer(
|
def defer(
|
||||||
public_ref: str,
|
public_ref: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user: CurrentUser = Depends(get_current_user),
|
user: CurrentUser = Depends(require_operations_manager),
|
||||||
) -> DataQualityIssueOut:
|
) -> DataQualityIssueOut:
|
||||||
issue = defer_issue(db, public_ref, user)
|
issue = defer_issue(db, public_ref, user)
|
||||||
return _to_out(issue)
|
return _to_out(issue)
|
||||||
@@ -120,7 +120,7 @@ def defer(
|
|||||||
def reject(
|
def reject(
|
||||||
public_ref: str,
|
public_ref: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user: CurrentUser = Depends(get_current_user),
|
user: CurrentUser = Depends(require_operations_manager),
|
||||||
) -> DataQualityIssueOut:
|
) -> DataQualityIssueOut:
|
||||||
issue = reject_issue(db, public_ref, user)
|
issue = reject_issue(db, public_ref, user)
|
||||||
return _to_out(issue)
|
return _to_out(issue)
|
||||||
|
|||||||
@@ -9,3 +9,8 @@ def test_demo_login_is_audited(ops_client):
|
|||||||
def test_audit_requires_authentication(client):
|
def test_audit_requires_authentication(client):
|
||||||
response = client.get("/api/v1/audit")
|
response = client.get("/api/v1/audit")
|
||||||
assert response.status_code == 401
|
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
|
||||||
|
|||||||
@@ -25,6 +25,26 @@ def test_scan_requires_operations_manager(employee_client):
|
|||||||
assert response.status_code == 403
|
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):
|
def test_s2_duplicate_customer_issue_detail(ops_client):
|
||||||
response = ops_client.get("/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE")
|
response = ops_client.get("/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|||||||
@@ -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 { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
import type { Role } from "../api/types";
|
||||||
import { BrandMark, Icon, type IconName } from "./Icons";
|
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",
|
label: "Operate",
|
||||||
items: [
|
items: [
|
||||||
{ to: "/dashboard", label: "Overview", shortLabel: "Overview", icon: "activity" },
|
{ to: "/dashboard", label: "Overview", shortLabel: "Overview", icon: "activity" },
|
||||||
{ to: "/vehicles", label: "Fleet", shortLabel: "Fleet", icon: "fleet" },
|
{ to: "/vehicles", label: "Fleet", shortLabel: "Fleet", icon: "fleet" },
|
||||||
{ to: "/bookings", label: "Bookings", shortLabel: "Bookings", icon: "bookings" },
|
{ 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",
|
label: "Assure",
|
||||||
items: [
|
items: [
|
||||||
{ to: "/knowledge", label: "Knowledge", shortLabel: "Knowledge", icon: "knowledge" },
|
{ to: "/knowledge", label: "Knowledge", shortLabel: "Knowledge", icon: "knowledge" },
|
||||||
{ to: "/automation", label: "Integrations", shortLabel: "Systems", icon: "integrations" },
|
{ to: "/automation", label: "Integrations", shortLabel: "Systems", icon: "integrations", roles: ["operations_manager"] },
|
||||||
{ to: "/audit", label: "Audit trail", shortLabel: "Audit", icon: "audit" },
|
{ 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 = [
|
const SEARCH_DESTINATIONS = [
|
||||||
{ to: "/dashboard", terms: ["overview", "dashboard", "readiness"] },
|
{ to: "/dashboard", terms: ["overview", "dashboard", "readiness"] },
|
||||||
{ to: "/vehicles", terms: ["fleet", "vehicle", "vehicles"] },
|
{ to: "/vehicles", terms: ["fleet", "vehicle", "vehicles"] },
|
||||||
@@ -43,6 +50,16 @@ export function Layout() {
|
|||||||
const [searchStatus, setSearchStatus] = useState("");
|
const [searchStatus, setSearchStatus] = useState("");
|
||||||
const searchInput = useRef<HTMLInputElement>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
function focusGlobalSearch(event: KeyboardEvent) {
|
function focusGlobalSearch(event: KeyboardEvent) {
|
||||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
|
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
|
||||||
@@ -55,8 +72,8 @@ export function Layout() {
|
|||||||
return () => window.removeEventListener("keydown", focusGlobalSearch);
|
return () => window.removeEventListener("keydown", focusGlobalSearch);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function handleLogout() {
|
async function handleLogout() {
|
||||||
logout();
|
await logout();
|
||||||
navigate("/login");
|
navigate("/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +116,7 @@ export function Layout() {
|
|||||||
<div><strong>MobilityOps</strong><span>Control centre</span></div>
|
<div><strong>MobilityOps</strong><span>Control centre</span></div>
|
||||||
</div>
|
</div>
|
||||||
<nav aria-label="Primary navigation">
|
<nav aria-label="Primary navigation">
|
||||||
{NAV_GROUPS.map((group) => (
|
{navGroups.map((group) => (
|
||||||
<div className="nav-group" key={group.label}>
|
<div className="nav-group" key={group.label}>
|
||||||
<p>{group.label}</p>
|
<p>{group.label}</p>
|
||||||
<ul>
|
<ul>
|
||||||
@@ -166,7 +183,7 @@ export function Layout() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="mobile-nav" aria-label="Mobile navigation">
|
<nav className="mobile-nav" aria-label="Mobile navigation">
|
||||||
{MOBILE_ITEMS.map((item) => (
|
{mobileItems.map((item) => (
|
||||||
<NavLink key={item.to} to={item.to}>
|
<NavLink key={item.to} to={item.to}>
|
||||||
<Icon name={item.icon} />
|
<Icon name={item.icon} />
|
||||||
<span>{item.shortLabel}</span>
|
<span>{item.shortLabel}</span>
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import type { AuditEvent } from "../api/types";
|
import type { AuditEvent } from "../api/types";
|
||||||
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
|
import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
|
||||||
|
|
||||||
export function Audit() {
|
export function Audit() {
|
||||||
|
const { user } = useAuth();
|
||||||
const [events, setEvents] = useState<AuditEvent[] | null>(null);
|
const [events, setEvents] = useState<AuditEvent[] | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [action, setAction] = useState("");
|
const [action, setAction] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (user?.role !== "operations_manager") return;
|
||||||
setEvents(null);
|
setEvents(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -17,7 +20,16 @@ export function Audit() {
|
|||||||
.get<AuditEvent[]>(`/api/v1/audit?${params.toString()}`)
|
.get<AuditEvent[]>(`/api/v1/audit?${params.toString()}`)
|
||||||
.then(setEvents)
|
.then(setEvents)
|
||||||
.catch(() => setError("Audit trail is unavailable right now."));
|
.catch(() => setError("Audit trail is unavailable right now."));
|
||||||
}, [action]);
|
}, [action, user]);
|
||||||
|
|
||||||
|
if (user?.role !== "operations_manager") {
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<PageHeader eyebrow="Assurance / Immutable history" title="Audit trail" description="The audit trail is visible to Operations Managers only." />
|
||||||
|
<p>Audit history is visible to Operations Managers only.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import type { Dashboard as DashboardData, KnowledgeHealth } from "../api/types";
|
import type { Dashboard as DashboardData, KnowledgeHealth } from "../api/types";
|
||||||
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { SeverityBadge, StatusBadge } from "../components/Badge";
|
import { SeverityBadge, StatusBadge } from "../components/Badge";
|
||||||
import { Icon } from "../components/Icons";
|
import { Icon } from "../components/Icons";
|
||||||
import { ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
|
import { ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
|
||||||
@@ -24,6 +25,9 @@ function localTime(value: string, withDate = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const canSeeQuality = user?.role === "operations_manager";
|
||||||
|
const canSeeAutomation = user?.role === "operations_manager";
|
||||||
const [data, setData] = useState<DashboardData | null>(null);
|
const [data, setData] = useState<DashboardData | null>(null);
|
||||||
const [knowledge, setKnowledge] = useState<KnowledgeHealth | null>(null);
|
const [knowledge, setKnowledge] = useState<KnowledgeHealth | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -68,7 +72,7 @@ export function Dashboard() {
|
|||||||
|
|
||||||
<div className="operations-grid">
|
<div className="operations-grid">
|
||||||
<section className="work-panel attention-panel" aria-labelledby="attention-heading">
|
<section className="work-panel attention-panel" aria-labelledby="attention-heading">
|
||||||
<SectionHeading title="Attention queue" description={`${data.metrics.open_quality_issues} open quality issues · ${data.metrics.pending_or_failed_workflows} workflow exceptions`} action={<Link to="/data-quality">Review queue <Icon name="chevron" /></Link>} />
|
<SectionHeading title="Attention queue" description={`${data.metrics.open_quality_issues} open quality issues · ${data.metrics.pending_or_failed_workflows} workflow exceptions`} action={canSeeQuality ? <Link to="/data-quality">Review queue <Icon name="chevron" /></Link> : undefined} />
|
||||||
<div className="queue-controls">
|
<div className="queue-controls">
|
||||||
<label className="compact-search"><Icon name="search" /><span className="visually-hidden">Search attention queue</span><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Filter issues…" /></label>
|
<label className="compact-search"><Icon name="search" /><span className="visually-hidden">Search attention queue</span><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Filter issues…" /></label>
|
||||||
<label><span className="visually-hidden">Severity</span><select value={severity} onChange={(event) => setSeverity(event.target.value)}><option value="all">All severity</option><option value="high">Critical</option><option value="medium">Warning</option><option value="low">Info</option></select></label>
|
<label><span className="visually-hidden">Severity</span><select value={severity} onChange={(event) => setSeverity(event.target.value)}><option value="all">All severity</option><option value="high">Critical</option><option value="medium">Warning</option><option value="low">Info</option></select></label>
|
||||||
@@ -80,7 +84,13 @@ export function Dashboard() {
|
|||||||
<SeverityBadge severity={item.severity} />
|
<SeverityBadge severity={item.severity} />
|
||||||
<div className="queue-copy">
|
<div className="queue-copy">
|
||||||
<p className="attention-title">
|
<p className="attention-title">
|
||||||
{item.issue_ref ? <Link to={`/data-quality/${item.issue_ref}`}>{item.title}</Link> : item.link_type === "vehicle" ? <Link to={`/vehicles/${item.link_ref}`}>{item.title}</Link> : item.title}
|
{item.issue_ref && canSeeQuality ? (
|
||||||
|
<Link to={`/data-quality/${item.issue_ref}`}>{item.title}</Link>
|
||||||
|
) : item.link_type === "vehicle" ? (
|
||||||
|
<Link to={`/vehicles/${item.link_ref}`}>{item.title}</Link>
|
||||||
|
) : (
|
||||||
|
item.title
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p className="attention-detail">{item.detail}</p>
|
<p className="attention-detail">{item.detail}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -110,7 +120,7 @@ export function Dashboard() {
|
|||||||
|
|
||||||
<div className="secondary-grid">
|
<div className="secondary-grid">
|
||||||
<section className="work-panel integration-panel" aria-labelledby="integration-heading">
|
<section className="work-panel integration-panel" aria-labelledby="integration-heading">
|
||||||
<SectionHeading title="Integration pulse" description="Current evidence from connected services" action={<Link to="/automation">System detail <Icon name="chevron" /></Link>} />
|
<SectionHeading title="Integration pulse" description="Current evidence from connected services" action={canSeeAutomation ? <Link to="/automation">System detail <Icon name="chevron" /></Link> : undefined} />
|
||||||
<ul className="integration-list">
|
<ul className="integration-list">
|
||||||
<li><IntegrationMark kind="n8n" /><div><strong>n8n delivery</strong><span>{latestRun ? `Latest event ${latestRun.aggregate_ref}` : "No workflow evidence recorded"}</span></div><StatusBadge status={n8nState.toLowerCase().replace(/ /g, "_")} /></li>
|
<li><IntegrationMark kind="n8n" /><div><strong>n8n delivery</strong><span>{latestRun ? `Latest event ${latestRun.aggregate_ref}` : "No workflow evidence recorded"}</span></div><StatusBadge status={n8nState.toLowerCase().replace(/ /g, "_")} /></li>
|
||||||
<li><IntegrationMark kind="rag" /><div><strong>RAGcore knowledge</strong><span>{knowledge ? `${knowledge.document_count} procedures indexed` : "Health check unavailable"}</span></div><StatusBadge status={knowledge?.available ? "available" : "unavailable"} /></li>
|
<li><IntegrationMark kind="rag" /><div><strong>RAGcore knowledge</strong><span>{knowledge ? `${knowledge.document_count} procedures indexed` : "Health check unavailable"}</span></div><StatusBadge status={knowledge?.available ? "available" : "unavailable"} /></li>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import type { DataQualityIssue } from "../api/types";
|
import type { DataQualityIssue } from "../api/types";
|
||||||
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { SeverityBadge, StatusBadge } from "../components/Badge";
|
import { SeverityBadge, StatusBadge } from "../components/Badge";
|
||||||
import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
|
import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
|
||||||
|
|
||||||
@@ -14,12 +15,14 @@ const RULE_TYPES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function DataQuality() {
|
export function DataQuality() {
|
||||||
|
const { user } = useAuth();
|
||||||
const [issues, setIssues] = useState<DataQualityIssue[] | null>(null);
|
const [issues, setIssues] = useState<DataQualityIssue[] | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [status, setStatus] = useState("open");
|
const [status, setStatus] = useState("open");
|
||||||
const [ruleType, setRuleType] = useState("");
|
const [ruleType, setRuleType] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (user?.role !== "operations_manager") return;
|
||||||
setIssues(null);
|
setIssues(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -29,7 +32,16 @@ export function DataQuality() {
|
|||||||
.get<DataQualityIssue[]>(`/api/v1/data-quality/issues?${params.toString()}`)
|
.get<DataQualityIssue[]>(`/api/v1/data-quality/issues?${params.toString()}`)
|
||||||
.then(setIssues)
|
.then(setIssues)
|
||||||
.catch(() => setError("Data-quality issues are unavailable right now."));
|
.catch(() => setError("Data-quality issues are unavailable right now."));
|
||||||
}, [status, ruleType]);
|
}, [status, ruleType, user]);
|
||||||
|
|
||||||
|
if (user?.role !== "operations_manager") {
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<PageHeader eyebrow="Assurance / Workbench" title="Data quality" description="The quality workbench is visible to Operations Managers only." />
|
||||||
|
<p>Data-quality evidence and resolutions are visible to Operations Managers only.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DataQualityIssueDetail() {
|
export function DataQualityIssueDetail() {
|
||||||
|
const { user } = useAuth();
|
||||||
const { publicRef } = useParams<{ publicRef: string }>();
|
const { publicRef } = useParams<{ publicRef: string }>();
|
||||||
const [issue, setIssue] = useState<IssueDetail | null>(null);
|
const [issue, setIssue] = useState<IssueDetail | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -181,10 +182,11 @@ export function DataQualityIssueDetail() {
|
|||||||
}, [publicRef]);
|
}, [publicRef]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (user?.role !== "operations_manager") return;
|
||||||
setIssue(null);
|
setIssue(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
load();
|
load();
|
||||||
}, [load]);
|
}, [load, user]);
|
||||||
|
|
||||||
async function handleAction(action: "defer" | "reject") {
|
async function handleAction(action: "defer" | "reject") {
|
||||||
if (!issue) return;
|
if (!issue) return;
|
||||||
@@ -197,6 +199,15 @@ export function DataQualityIssueDetail() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (user?.role !== "operations_manager") {
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<PageHeader eyebrow="Assurance / Workbench" title="Data quality issue" description="The quality workbench is visible to Operations Managers only." />
|
||||||
|
<p>Data-quality evidence and resolutions are visible to Operations Managers only.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (error) return <ErrorState message={error} />;
|
if (error) return <ErrorState message={error} />;
|
||||||
if (!issue) return <LoadingState label="Loading issue evidence…" />;
|
if (!issue) return <LoadingState label="Loading issue evidence…" />;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user