fix(l1): resolve PR #193 frontend review findings (2a,2b,3,4,5,7)
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>
This commit is contained in:
@@ -88,18 +88,35 @@ export function ProposalDetail({ proposal, onReview }: ProposalDetailProps) {
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-5">
|
||||
{/* Source session link */}
|
||||
<div className="card-flat p-4">
|
||||
<h4 className="font-sans text-xs text-[0.625rem] uppercase tracking-wider text-text-muted mb-2">Source Session</h4>
|
||||
<Link
|
||||
to={`/pilot/${proposal.source_session_id}`}
|
||||
target="_blank"
|
||||
className="flex items-center gap-2 text-sm text-primary hover:underline"
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
View session that generated this proposal
|
||||
</Link>
|
||||
</div>
|
||||
{/* Source — exactly one of a FlowPilot session XOR an L1 walk is set
|
||||
(DB CHECK). Never link to /pilot for an L1-sourced proposal:
|
||||
source_session_id is NULL there, so the old unconditional link
|
||||
rendered a broken /pilot/null. */}
|
||||
{proposal.source_session_id ? (
|
||||
<div className="card-flat p-4">
|
||||
<h4 className="font-sans text-xs text-[0.625rem] uppercase tracking-wider text-text-muted mb-2">Source Session</h4>
|
||||
<Link
|
||||
to={`/pilot/${proposal.source_session_id}`}
|
||||
target="_blank"
|
||||
className="flex items-center gap-2 text-sm text-primary hover:underline"
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
View session that generated this proposal
|
||||
</Link>
|
||||
</div>
|
||||
) : proposal.l1_session_id ? (
|
||||
<div className="card-flat p-4">
|
||||
<h4 className="font-sans text-xs text-[0.625rem] uppercase tracking-wider text-text-muted mb-2">Source — L1 AI walkthrough</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Captured from an L1 technician's AI-guided walk and validated by a
|
||||
successful resolution. The proposed flow is the path that resolved the ticket.
|
||||
</p>
|
||||
<p className="mt-2 flex items-center gap-1.5 text-xs text-text-muted">
|
||||
<Hash size={11} />
|
||||
<span className="font-mono">L1 session {proposal.l1_session_id.slice(0, 8)}</span>
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Proposed diff (for enhancements) */}
|
||||
{proposal.proposed_diff && (() => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { l1Api } from '@/api/l1'
|
||||
import { timeAgo } from '@/lib/timeAgo'
|
||||
import type { WalkSession } from '@/types/l1'
|
||||
|
||||
/**
|
||||
@@ -43,11 +44,13 @@ export function L1EscalationsSection() {
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span className="font-mono text-xs text-text-muted">#{s.id.slice(0, 8)}</span>
|
||||
<span className="text-sm text-text-primary truncate">
|
||||
{s.walked_path.length} step{s.walked_path.length === 1 ? '' : 's'} walked
|
||||
{s.problem_text
|
||||
? s.problem_text
|
||||
: `${s.walked_path.length} step${s.walked_path.length === 1 ? '' : 's'} walked`}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-text-muted whitespace-nowrap">
|
||||
{new Date(s.last_step_at).toLocaleString()}
|
||||
{timeAgo(s.last_step_at)}
|
||||
</span>
|
||||
</button>
|
||||
{isOpen && (
|
||||
@@ -58,7 +61,7 @@ export function L1EscalationsSection() {
|
||||
<ol className="space-y-1.5 text-sm">
|
||||
{s.walked_path.map((step, i) => (
|
||||
<li key={i} className="flex flex-col">
|
||||
<span className="text-text-muted text-xs">{step.question}</span>
|
||||
<span className="text-text-muted text-xs">{step.question ?? step.text}</span>
|
||||
{step.answer && (
|
||||
<span className="font-medium text-text-primary">→ {step.answer}</span>
|
||||
)}
|
||||
|
||||
@@ -44,7 +44,7 @@ export function L1WalkTreeVariant({ session, onSessionUpdate, onDone }: Props) {
|
||||
}, [isAiBuild, session.id, session.status])
|
||||
|
||||
const advanceNode = useCallback(
|
||||
async (body: { answer?: 'yes' | 'no'; acknowledged?: boolean }) => {
|
||||
async (body: { answer?: 'yes' | 'no' }) => {
|
||||
if (!node) return
|
||||
setNodeLoading(true)
|
||||
setNodeError(null)
|
||||
@@ -183,7 +183,7 @@ export function L1WalkTreeVariant({ session, onSessionUpdate, onDone }: Props) {
|
||||
<>
|
||||
<p className="text-lg">{node.text}</p>
|
||||
<button
|
||||
onClick={() => advanceNode({ acknowledged: true })}
|
||||
onClick={() => advanceNode({})}
|
||||
className="rounded-md bg-accent text-white px-5 py-3 text-base font-medium hover:bg-accent/90 min-h-[44px] transition-colors"
|
||||
>
|
||||
Done — next step
|
||||
@@ -251,8 +251,8 @@ export function L1WalkTreeVariant({ session, onSessionUpdate, onDone }: Props) {
|
||||
<ol className="space-y-3 text-sm">
|
||||
{session.walked_path.map((step, i) => (
|
||||
<li key={i} className="flex flex-col">
|
||||
<span className="text-muted-foreground text-xs">{step.question}</span>
|
||||
<span className="font-medium">→ {step.answer}</span>
|
||||
<span className="text-muted-foreground text-xs">{step.question ?? step.text}</span>
|
||||
{step.answer && <span className="font-medium">→ {step.answer}</span>}
|
||||
{step.l1_note && <span className="text-muted-foreground text-xs italic mt-0.5">{step.l1_note}</span>}
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -5,13 +5,18 @@ 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, children }: ProtectedRouteProps) {
|
||||
export function ProtectedRoute({ requiredRole, requireAccountManager, children }: ProtectedRouteProps) {
|
||||
const { isAuthenticated, isLoading, user } = useAuthStore()
|
||||
const location = useLocation()
|
||||
const { effectiveRole } = usePermissions()
|
||||
const { effectiveRole, canManageAccount } = usePermissions()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -48,6 +53,10 @@ export function ProtectedRoute({ requiredRole, children }: ProtectedRouteProps)
|
||||
}
|
||||
}
|
||||
|
||||
if (requireAccountManager && !canManageAccount) {
|
||||
return <Navigate to="/trees" replace />
|
||||
}
|
||||
|
||||
if (requiredRole) {
|
||||
const ROLE_HIERARCHY: Record<EffectiveRole, number> = {
|
||||
super_admin: 5,
|
||||
|
||||
Reference in New Issue
Block a user