5649ab02c7
Build & Push / Pipeline Tests (push) Failing after 1m31s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 55s
Test / API Unit Tests (push) Successful in 1m8s
Test / Homepage Unit Tests (push) Successful in 47s
Test / Carplace Unit Tests (push) Successful in 44s
Test / Admin Unit Tests (push) Successful in 43s
Test / Dashboard Unit Tests (push) Failing after 46s
Test / API Integration Tests (push) Successful in 1m7s
200 lines
6.1 KiB
TypeScript
200 lines
6.1 KiB
TypeScript
'use client'
|
|
|
|
import { usePathname, useRouter } from 'next/navigation'
|
|
import { useEffect, useState } from 'react'
|
|
import { apiFetch } from '@/lib/api'
|
|
import { buildHomepageSignInPath, toDashboardAppPath, toPublicDashboardPath } from '@/lib/dashboardPaths'
|
|
import {
|
|
getDashboardFallbackRoute,
|
|
resolveDashboardRoutePolicy,
|
|
roleCanAccessPolicy,
|
|
} from '@/lib/dashboardRoutePolicies'
|
|
|
|
type GeneratedMenuItem = {
|
|
id: string
|
|
itemType: 'INTERNAL_PAGE' | 'EXTERNAL_LINK' | 'PARENT_MENU' | 'SECTION_LABEL' | 'DIVIDER'
|
|
routeOrUrl: string | null
|
|
children: GeneratedMenuItem[]
|
|
}
|
|
|
|
type EmployeeMenuResponse = {
|
|
items: GeneratedMenuItem[]
|
|
subscriptionAccessLevel?: 'full' | 'limited' | 'read_only' | 'none'
|
|
}
|
|
|
|
type EmployeeProfileResponse = {
|
|
employee: {
|
|
role: string
|
|
}
|
|
}
|
|
|
|
const ROLE_RANK: Record<string, number> = { OWNER: 3, MANAGER: 2, AGENT: 1 }
|
|
const BASELINE_MENU_ROUTES = [
|
|
{ route: '/', minRole: 'AGENT' },
|
|
{ route: '/reservations', minRole: 'AGENT' },
|
|
{ route: '/contracts', minRole: 'AGENT' },
|
|
{ route: '/fleet', minRole: 'AGENT' },
|
|
{ route: '/customers', minRole: 'AGENT' },
|
|
{ route: '/reports', minRole: 'MANAGER' },
|
|
{ route: '/billing', minRole: 'MANAGER' },
|
|
{ route: '/settings', minRole: 'OWNER' },
|
|
] as const
|
|
|
|
const MENU_RECOVERY_ROUTES = new Set(['/subscription', '/subscription/success', '/subscription/cancel'])
|
|
|
|
function hasMinRole(employeeRole: string, minRole: string): boolean {
|
|
return (ROLE_RANK[employeeRole] ?? 0) >= (ROLE_RANK[minRole] ?? 0)
|
|
}
|
|
|
|
export function getBaselineInternalRoutes(role: string): string[] {
|
|
return BASELINE_MENU_ROUTES
|
|
.filter((item) => hasMinRole(role, item.minRole))
|
|
.map((item) => item.route)
|
|
}
|
|
|
|
export function flattenInternalRoutes(items: GeneratedMenuItem[]): string[] {
|
|
const routes: string[] = []
|
|
|
|
const walk = (entry: GeneratedMenuItem) => {
|
|
if (entry.itemType === 'INTERNAL_PAGE' && entry.routeOrUrl) {
|
|
routes.push(toDashboardAppPath(entry.routeOrUrl))
|
|
}
|
|
entry.children.forEach(walk)
|
|
}
|
|
|
|
items.forEach(walk)
|
|
return Array.from(new Set(routes))
|
|
}
|
|
|
|
export function resolveAllowedRoutes(menu: EmployeeMenuResponse, role: string): string[] {
|
|
const routes = flattenInternalRoutes(menu.items)
|
|
const featureRoutes = routes.filter((route) => route !== '/' && !MENU_RECOVERY_ROUTES.has(route))
|
|
const shouldUseBaselineFallback =
|
|
menu.subscriptionAccessLevel !== 'none' &&
|
|
featureRoutes.length === 0
|
|
|
|
const resolvedRoutes = shouldUseBaselineFallback ? getBaselineInternalRoutes(role) : routes
|
|
if (resolvedRoutes.includes('/reservations') && !resolvedRoutes.includes('/contracts')) {
|
|
return [...resolvedRoutes, '/contracts']
|
|
}
|
|
return resolvedRoutes
|
|
}
|
|
|
|
export function isAllowedRoute(currentPath: string, allowedRoutes: string[]) {
|
|
return allowedRoutes.some((route) => {
|
|
if (route === '/') return currentPath === '/'
|
|
return currentPath === route || currentPath.startsWith(`${route}/`)
|
|
})
|
|
}
|
|
|
|
export function resolveAccessRedirect(currentPath: string, allowedRoutes: string[]) {
|
|
if (allowedRoutes.length === 0) return null
|
|
if (isAllowedRoute(currentPath, allowedRoutes)) return null
|
|
|
|
const fallbackRoute = allowedRoutes[0] ?? '/'
|
|
return fallbackRoute === currentPath ? null : fallbackRoute
|
|
}
|
|
|
|
export function buildSignInRedirect(currentPath: string) {
|
|
return buildHomepageSignInPath(currentPath)
|
|
}
|
|
|
|
export default function DashboardAccessGuard({ children }: { children: React.ReactNode }) {
|
|
const pathname = usePathname()
|
|
const router = useRouter()
|
|
const [ready, setReady] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
|
|
async function enforceAccess() {
|
|
const currentPath = toDashboardAppPath(pathname)
|
|
const policy = resolveDashboardRoutePolicy(currentPath)
|
|
try {
|
|
setReady(false)
|
|
setError(null)
|
|
|
|
const { employee } = await apiFetch<EmployeeProfileResponse>('/auth/employee/me')
|
|
if (cancelled) return
|
|
|
|
if (!roleCanAccessPolicy(employee.role, policy)) {
|
|
const fallbackRoute = getDashboardFallbackRoute(employee.role)
|
|
if (fallbackRoute !== currentPath) {
|
|
router.replace(fallbackRoute)
|
|
return
|
|
}
|
|
}
|
|
|
|
if (policy.menuRegistrationRequired) {
|
|
const menu = await apiFetch<EmployeeMenuResponse>('/auth/employee/menu')
|
|
if (cancelled) return
|
|
|
|
if (menu.subscriptionAccessLevel === 'none') {
|
|
router.replace('/subscription')
|
|
return
|
|
}
|
|
|
|
const allowedRoutes = resolveAllowedRoutes(menu, employee.role)
|
|
const redirectPath = resolveAccessRedirect(currentPath, allowedRoutes)
|
|
|
|
if (redirectPath) {
|
|
router.replace(redirectPath)
|
|
return
|
|
}
|
|
}
|
|
|
|
setReady(true)
|
|
} catch (error: any) {
|
|
if (cancelled) return
|
|
|
|
if (error?.statusCode === 401) {
|
|
const target = buildSignInRedirect(currentPath)
|
|
if (target !== toPublicDashboardPath(currentPath)) window.location.replace(target)
|
|
return
|
|
}
|
|
|
|
if (error?.statusCode === 402) {
|
|
if (policy.billingRecoveryRoute) setReady(true)
|
|
else router.replace('/subscription')
|
|
return
|
|
}
|
|
|
|
if (error?.statusCode === 403) {
|
|
if (currentPath !== '/') router.replace('/')
|
|
else setReady(true)
|
|
return
|
|
}
|
|
|
|
setError(error?.message ?? 'Unable to verify dashboard access. Please try again.')
|
|
}
|
|
}
|
|
|
|
enforceAccess()
|
|
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [pathname, router])
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="flex min-h-[40vh] items-center justify-center px-6">
|
|
<div className="max-w-md rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
|
{error}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!ready) {
|
|
return (
|
|
<div className="flex min-h-[40vh] items-center justify-center">
|
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-orange-500 border-t-transparent" aria-label="Checking access" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return <>{children}</>
|
|
}
|