Mounts L1EscalationsSection on EscalationQueuePage (Finding 2a — it was never rendered) and renders the correct fields: step.question ?? step.text, timeAgo, and the session problem_text (Finding 2b). ProposalDetail gates the /pilot link on source_session_id and shows an L1-source block for l1_session_id-sourced proposals (Finding 3 — was a broken /pilot/null link). Collapses the three near-identical intake handlers into one runIntake: "Use this flow" now passes near_miss.flow_id (Finding 4 — it previously re-suggested forever) and a navigate guard prevents /l1/walk/undefined; out_of_scope gains a "Walk it ad-hoc" button (Finding 5). Aligns L1-category permissions to owner+admin: usePermissions.canManageAccount includes account admins, User.account_role TS type gains 'admin', and a new ProtectedRoute requireAccountManager guard fronts the route (Finding 7). Drops the unused NextNodeRequest.acknowledged field. tsc -b + eslint + vite build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
77 lines
2.8 KiB
TypeScript
77 lines
2.8 KiB
TypeScript
import { Navigate, useLocation } from 'react-router-dom'
|
|
import { useAuthStore } from '@/store/authStore'
|
|
import { usePermissions, type EffectiveRole } from '@/hooks/usePermissions'
|
|
import { Spinner } from '@/components/common/Spinner'
|
|
|
|
interface ProtectedRouteProps {
|
|
requiredRole?: EffectiveRole
|
|
// Gate on account-management capability (owner OR account-admin OR super_admin),
|
|
// mirroring backend require_account_owner_or_admin. Use instead of
|
|
// requiredRole="owner" when account admins must also pass — the role hierarchy
|
|
// has no 'admin' rung, so requiredRole alone wrongly bounces admins.
|
|
requireAccountManager?: boolean
|
|
children: React.ReactNode
|
|
}
|
|
|
|
export function ProtectedRoute({ requiredRole, requireAccountManager, children }: ProtectedRouteProps) {
|
|
const { isAuthenticated, isLoading, user } = useAuthStore()
|
|
const location = useLocation()
|
|
const { effectiveRole, canManageAccount } = usePermissions()
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex h-screen items-center justify-center">
|
|
<Spinner className="border-t-foreground" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!isAuthenticated) {
|
|
return <Navigate to="/" state={{ from: location }} replace />
|
|
}
|
|
|
|
// Enforce must_change_password — redirect unless already on /change-password
|
|
if (user?.must_change_password && location.pathname !== '/change-password') {
|
|
return <Navigate to="/change-password" replace />
|
|
}
|
|
|
|
// L1 techs are confined to their focused surface. The sidebar only exposes
|
|
// /l1*, /guides, and /account for them, so any other authed path (the engineer
|
|
// dashboard at /home, /pilot, /trees/*, /escalations, …) bounces to /l1. This
|
|
// also covers post-login landing: auth sends users to /home, which is not in
|
|
// the allowlist, so l1_tech users end up on /l1. Engineer-only AI surfaces
|
|
// (/pilot, /assistant) would 403 at POST /api/v1/ai-sessions anyway — this
|
|
// turns that backend error into a clean redirect. Runs before the requiredRole
|
|
// check so L1 users never trip the engineer-route role logic.
|
|
if (effectiveRole === 'l1_tech') {
|
|
const L1_ALLOWED_PREFIXES = ['/l1', '/guides', '/account', '/change-password']
|
|
const allowed = L1_ALLOWED_PREFIXES.some(
|
|
(p) => location.pathname === p || location.pathname.startsWith(p + '/'),
|
|
)
|
|
if (!allowed) {
|
|
return <Navigate to="/l1" replace />
|
|
}
|
|
}
|
|
|
|
if (requireAccountManager && !canManageAccount) {
|
|
return <Navigate to="/trees" replace />
|
|
}
|
|
|
|
if (requiredRole) {
|
|
const ROLE_HIERARCHY: Record<EffectiveRole, number> = {
|
|
super_admin: 5,
|
|
owner: 4,
|
|
engineer: 3,
|
|
l1_tech: 2,
|
|
viewer: 1,
|
|
}
|
|
if (ROLE_HIERARCHY[effectiveRole] < ROLE_HIERARCHY[requiredRole]) {
|
|
return <Navigate to="/trees" replace />
|
|
}
|
|
}
|
|
|
|
return <>{children}</>
|
|
}
|
|
|
|
export default ProtectedRoute
|