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:
2026-05-28 13:58:34 -04:00
parent 4586010b87
commit fbe25b3d68
2 changed files with 75 additions and 51 deletions

View File

@@ -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}</>
} }

View File

@@ -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,7 +79,16 @@ 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
// an appended "L1 Workspace" entry in the library group.
const workItems: NavEntry[] = isL1Tech
? [
{ href: '/l1', icon: LayoutGrid, label: 'Workspace', shortLabel: 'Work', matchPaths: ['/l1'] },
{ href: '/l1/tickets', icon: Ticket, label: 'Tickets', shortLabel: 'Tickets', matchPaths: ['/l1/tickets'] },
{ href: '/l1/drafts', icon: FileText, label: 'My Drafts', shortLabel: 'Drafts', matchPaths: ['/l1/drafts'] },
]
: [
{ {
href: '/', icon: LayoutGrid, label: 'Dashboard', shortLabel: 'Dash', href: '/', icon: LayoutGrid, label: 'Dashboard', shortLabel: 'Dash',
matchPaths: ['/'], matchPaths: ['/'],
@@ -98,7 +109,9 @@ export function Sidebar() {
}, },
] ]
const libraryItems: NavEntry[] = [ const libraryItems: NavEntry[] = isL1Tech
? []
: [
{ {
href: '/trees', icon: GitBranch, label: 'Flows', shortLabel: 'Flows', href: '/trees', icon: GitBranch, label: 'Flows', shortLabel: 'Flows',
badge: stats?.tree_counts.total || undefined, badge: stats?.tree_counts.total || undefined,
@@ -128,6 +141,11 @@ export function Sidebar() {
{ href: '/shares', label: 'Exports' }, { 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[] = [