feat(l1): role-based sidebar nav + L1 post-login redirect
L1 users see a focused sidebar with only their L1 surfaces (Workspace, Tickets, My Drafts, Guides, Account). Engineers with can_cover_l1 (plus owners/super_admins) get an appended "L1 Workspace" entry in their existing sidebar. ProtectedRoute redirects L1 users from / to /l1 on login. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -43,6 +43,12 @@ export function ProtectedRoute({ requiredRole, children }: ProtectedRouteProps)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// L1 users landing on / (e.g. post-login) get redirected to their workspace.
|
||||||
|
// Does not fire when already on /l1 or any other path, preventing loops.
|
||||||
|
if (effectiveRole === 'l1_tech' && location.pathname === '/') {
|
||||||
|
return <Navigate to="/l1" replace />
|
||||||
|
}
|
||||||
|
|
||||||
return <>{children}</>
|
return <>{children}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
|||||||
import { sidebarApi } from '@/api'
|
import { sidebarApi } from '@/api'
|
||||||
import type { SidebarStatsResponse } from '@/api/sidebar'
|
import type { SidebarStatsResponse } from '@/api/sidebar'
|
||||||
import { prefetchForRoute } from '@/lib/routePrefetch'
|
import { prefetchForRoute } from '@/lib/routePrefetch'
|
||||||
|
import { usePermissions } from '@/hooks/usePermissions'
|
||||||
|
|
||||||
/* ── Types ──────────────────────────────────────────── */
|
/* ── Types ──────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ export function Sidebar() {
|
|||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const sidebarPinned = useUserPreferencesStore(s => s.sidebarPinned)
|
const sidebarPinned = useUserPreferencesStore(s => s.sidebarPinned)
|
||||||
const toggleSidebarPinned = useUserPreferencesStore(s => s.toggleSidebarPinned)
|
const toggleSidebarPinned = useUserPreferencesStore(s => s.toggleSidebarPinned)
|
||||||
|
const { isL1Tech, canCoverL1 } = usePermissions()
|
||||||
|
|
||||||
const [stats, setStats] = useState<SidebarStatsResponse | null>(null)
|
const [stats, setStats] = useState<SidebarStatsResponse | null>(null)
|
||||||
// Phase 6: pending-drafts badge on the Scripts nav. Fetched independently
|
// Phase 6: pending-drafts badge on the Scripts nav. Fetched independently
|
||||||
@@ -77,58 +79,74 @@ export function Sidebar() {
|
|||||||
* and pinned modes. Pin/unpin is a width/label affordance, not an
|
* and pinned modes. Pin/unpin is a width/label affordance, not an
|
||||||
* IA switch. A hairline divider separates the two groups; no labels. */
|
* IA switch. A hairline divider separates the two groups; no labels. */
|
||||||
|
|
||||||
const workItems: NavEntry[] = [
|
// L1 users get a focused sidebar with only their surfaces.
|
||||||
{
|
// Engineers/owners get the full sidebar; those with canCoverL1 also get
|
||||||
href: '/', icon: LayoutGrid, label: 'Dashboard', shortLabel: 'Dash',
|
// an appended "L1 Workspace" entry in the library group.
|
||||||
matchPaths: ['/'],
|
const workItems: NavEntry[] = isL1Tech
|
||||||
},
|
? [
|
||||||
{
|
{ href: '/l1', icon: LayoutGrid, label: 'Workspace', shortLabel: 'Work', matchPaths: ['/l1'] },
|
||||||
href: '/tickets', icon: Ticket, label: 'Tickets', shortLabel: 'Tickets',
|
{ href: '/l1/tickets', icon: Ticket, label: 'Tickets', shortLabel: 'Tickets', matchPaths: ['/l1/tickets'] },
|
||||||
matchPaths: ['/tickets'],
|
{ href: '/l1/drafts', icon: FileText, label: 'My Drafts', shortLabel: 'Drafts', matchPaths: ['/l1/drafts'] },
|
||||||
},
|
]
|
||||||
{
|
: [
|
||||||
href: '/sessions', icon: Clock, label: 'Sessions', shortLabel: 'Sessions',
|
{
|
||||||
badge: stats?.active_count || undefined,
|
href: '/', icon: LayoutGrid, label: 'Dashboard', shortLabel: 'Dash',
|
||||||
matchPaths: ['/sessions'],
|
matchPaths: ['/'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/escalations', icon: AlertTriangle, label: 'Escalations', shortLabel: 'Escal',
|
href: '/tickets', icon: Ticket, label: 'Tickets', shortLabel: 'Tickets',
|
||||||
badge: stats?.escalation_count || undefined,
|
matchPaths: ['/tickets'],
|
||||||
matchPaths: ['/escalations'],
|
},
|
||||||
},
|
{
|
||||||
]
|
href: '/sessions', icon: Clock, label: 'Sessions', shortLabel: 'Sessions',
|
||||||
|
badge: stats?.active_count || undefined,
|
||||||
|
matchPaths: ['/sessions'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/escalations', icon: AlertTriangle, label: 'Escalations', shortLabel: 'Escal',
|
||||||
|
badge: stats?.escalation_count || undefined,
|
||||||
|
matchPaths: ['/escalations'],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
const libraryItems: NavEntry[] = [
|
const libraryItems: NavEntry[] = isL1Tech
|
||||||
{
|
? []
|
||||||
href: '/trees', icon: GitBranch, label: 'Flows', shortLabel: 'Flows',
|
: [
|
||||||
badge: stats?.tree_counts.total || undefined,
|
{
|
||||||
matchPaths: ['/trees', '/flows', '/my-trees', '/step-library', '/network-diagrams'],
|
href: '/trees', icon: GitBranch, label: 'Flows', shortLabel: 'Flows',
|
||||||
children: [
|
badge: stats?.tree_counts.total || undefined,
|
||||||
{ href: '/trees?type=procedural', label: 'Projects', count: stats?.tree_counts.procedural || undefined },
|
matchPaths: ['/trees', '/flows', '/my-trees', '/step-library', '/network-diagrams'],
|
||||||
{ href: '/step-library', label: 'Solutions Library' },
|
children: [
|
||||||
{ href: '/network-diagrams', label: 'Network Maps' },
|
{ href: '/trees?type=procedural', label: 'Projects', count: stats?.tree_counts.procedural || undefined },
|
||||||
],
|
{ href: '/step-library', label: 'Solutions Library' },
|
||||||
},
|
{ href: '/network-diagrams', label: 'Network Maps' },
|
||||||
{
|
],
|
||||||
href: '/scripts', icon: FileText, label: 'Scripts', shortLabel: 'Scripts',
|
},
|
||||||
badge: pendingDraftCount || undefined,
|
{
|
||||||
matchPaths: ['/scripts', '/script-builder'],
|
href: '/scripts', icon: FileText, label: 'Scripts', shortLabel: 'Scripts',
|
||||||
children: [
|
badge: pendingDraftCount || undefined,
|
||||||
{ href: '/script-builder', label: 'Script Builder' },
|
matchPaths: ['/scripts', '/script-builder'],
|
||||||
],
|
children: [
|
||||||
},
|
{ href: '/script-builder', label: 'Script Builder' },
|
||||||
{
|
],
|
||||||
href: '/review-queue', icon: ListChecks, label: 'Review Queue', shortLabel: 'Review',
|
},
|
||||||
matchPaths: ['/review-queue'],
|
{
|
||||||
},
|
href: '/review-queue', icon: ListChecks, label: 'Review Queue', shortLabel: 'Review',
|
||||||
{
|
matchPaths: ['/review-queue'],
|
||||||
href: '/analytics', icon: BarChart3, label: 'Analytics', shortLabel: 'Stats',
|
},
|
||||||
matchPaths: ['/analytics', '/shares'],
|
{
|
||||||
children: [
|
href: '/analytics', icon: BarChart3, label: 'Analytics', shortLabel: 'Stats',
|
||||||
{ href: '/shares', label: 'Exports' },
|
matchPaths: ['/analytics', '/shares'],
|
||||||
],
|
children: [
|
||||||
},
|
{ href: '/shares', label: 'Exports' },
|
||||||
]
|
],
|
||||||
|
},
|
||||||
|
// Engineers/owners with L1 coverage access also get the L1 Workspace entry
|
||||||
|
...(canCoverL1 ? [{
|
||||||
|
href: '/l1', icon: LayoutGrid, label: 'L1 Workspace', shortLabel: 'L1',
|
||||||
|
matchPaths: ['/l1'],
|
||||||
|
}] : []),
|
||||||
|
]
|
||||||
|
|
||||||
const footerItems: NavEntry[] = [
|
const footerItems: NavEntry[] = [
|
||||||
{ href: '/guides', icon: BookOpen, label: 'Guides', shortLabel: 'Guides', matchPaths: ['/guides'] },
|
{ href: '/guides', icon: BookOpen, label: 'Guides', shortLabel: 'Guides', matchPaths: ['/guides'] },
|
||||||
|
|||||||
Reference in New Issue
Block a user