fix subscription page
Build & Deploy / Build & Push Docker Image (push) Successful in 7m57s
Test / Type Check (all packages) (push) Successful in 4m27s
Build & Deploy / Deploy to VPS (push) Successful in 7s
Test / API Unit Tests (push) Failing after 3m2s
Test / Homepage Unit Tests (push) Successful in 4m3s
Test / Storefront Unit Tests (push) Successful in 3m32s
Test / Admin Unit Tests (push) Successful in 3m27s
Test / Dashboard Unit Tests (push) Successful in 3m3s
Test / API Integration Tests (push) Failing after 3m53s

This commit is contained in:
root
2026-06-29 23:15:55 -04:00
parent a752a399c2
commit f22e0d45e1
22 changed files with 842 additions and 72 deletions
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'
import { resolveDashboardRoutePolicy, roleCanAccessPolicy } from '@/lib/dashboardRoutePolicies'
import { flattenInternalRoutes, isAllowedRoute, resolveAccessRedirect } from './DashboardAccessGuard'
describe('DashboardAccessGuard route helpers', () => {
@@ -38,4 +39,19 @@ describe('DashboardAccessGuard route helpers', () => {
expect(resolveAccessRedirect('/settings', ['/', '/fleet'])).toBe('/')
expect(resolveAccessRedirect('/fleet/123', ['/', '/fleet'])).toBeNull()
})
it('treats subscription as an owner-only recovery route independent of menu registration', () => {
const policy = resolveDashboardRoutePolicy('/subscription')
expect(policy).toMatchObject({
authenticationRequired: true,
allowedRoles: ['OWNER'],
subscriptionRequired: false,
menuRegistrationRequired: false,
billingRecoveryRoute: true,
})
expect(roleCanAccessPolicy('OWNER', policy)).toBe(true)
expect(roleCanAccessPolicy('MANAGER', policy)).toBe(false)
expect(roleCanAccessPolicy('AGENT', policy)).toBe(false)
})
})
@@ -4,6 +4,11 @@ import { usePathname, useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { apiFetch } from '@/lib/api'
import { toDashboardAppPath } from '@/lib/dashboardPaths'
import {
getDashboardFallbackRoute,
resolveDashboardRoutePolicy,
roleCanAccessPolicy,
} from '@/lib/dashboardRoutePolicies'
type GeneratedMenuItem = {
id: string
@@ -16,6 +21,12 @@ type EmployeeMenuResponse = {
items: GeneratedMenuItem[]
}
type EmployeeProfileResponse = {
employee: {
role: string
}
}
export function flattenInternalRoutes(items: GeneratedMenuItem[]): string[] {
const routes: string[] = []
@@ -45,40 +56,75 @@ export function resolveAccessRedirect(currentPath: string, allowedRoutes: string
return fallbackRoute === currentPath ? null : fallbackRoute
}
function buildSignInRedirect(currentPath: string) {
const params = new URLSearchParams()
params.set('redirect', currentPath)
return `/sign-in?${params.toString()}`
}
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)
const currentPath = toDashboardAppPath(pathname)
const menu = await apiFetch<EmployeeMenuResponse>('/auth/employee/menu')
setError(null)
const { employee } = await apiFetch<EmployeeProfileResponse>('/auth/employee/me')
if (cancelled) return
const allowedRoutes = flattenInternalRoutes(menu.items)
const redirectPath = resolveAccessRedirect(currentPath, allowedRoutes)
if (!roleCanAccessPolicy(employee.role, policy)) {
const fallbackRoute = getDashboardFallbackRoute(employee.role)
if (fallbackRoute !== currentPath) {
router.replace(fallbackRoute)
return
}
}
if (redirectPath) {
router.replace(redirectPath)
return
if (policy.menuRegistrationRequired) {
const menu = await apiFetch<EmployeeMenuResponse>('/auth/employee/menu')
if (cancelled) return
const allowedRoutes = flattenInternalRoutes(menu.items)
const redirectPath = resolveAccessRedirect(currentPath, allowedRoutes)
if (redirectPath) {
router.replace(redirectPath)
return
}
}
setReady(true)
} catch (error: any) {
if (cancelled) return
if (error?.statusCode === 401 || error?.statusCode === 403 || error?.statusCode === 402) {
router.replace('/')
if (error?.statusCode === 401) {
const target = buildSignInRedirect(currentPath)
if (target !== currentPath) router.replace(target)
return
}
// Do not deadlock the dashboard on transient network failures.
setReady(true)
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.')
}
}
@@ -89,6 +135,16 @@ export default function DashboardAccessGuard({ children }: { children: React.Rea
}
}, [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">
@@ -95,6 +95,10 @@ const NAV_ITEMS = [
{ href: '/settings', key: 'settings', icon: Settings, minRole: 'OWNER' },
] as const
const OWNER_SYSTEM_NAV_ITEMS = [
{ href: '/subscription', key: 'subscription', icon: 'CreditCard', minRole: 'OWNER' },
] as const
export const APPROVED_BASELINE_MENU_KEYS = NAV_ITEMS.map((item) => item.key)
const ICON_MAP = {
@@ -280,7 +284,30 @@ export default function Sidebar() {
}))
const useGeneratedMenu = menuLoadState === 'loaded'
const resolvedMenuItems = useGeneratedMenu ? (menuItems ?? []) : fallbackMenuItems
const ownerSystemMenuItems = OWNER_SYSTEM_NAV_ITEMS
.filter((item) => !mounted || hasMinRole(role, item.minRole))
.map((item) => ({
id: `system:${item.href}`,
systemKey: item.key,
label: dict.nav[item.key] ?? item.key,
itemType: 'INTERNAL_PAGE' as const,
routeOrUrl: item.href,
icon: item.icon,
parentId: null,
openInNewTab: false,
displayOrder: 0,
children: [],
}))
const generatedMenuItems = useGeneratedMenu ? (menuItems ?? []) : fallbackMenuItems
const generatedRoutes = new Set(
generatedMenuItems
.filter((item) => item.itemType === 'INTERNAL_PAGE' && item.routeOrUrl)
.map((item) => toDashboardAppPath(item.routeOrUrl)),
)
const resolvedMenuItems = [
...generatedMenuItems,
...ownerSystemMenuItems.filter((item) => !generatedRoutes.has(toDashboardAppPath(item.routeOrUrl))),
]
function renderGeneratedMenu(items: GeneratedMenuItem[], depth = 0): ReactNode {
return items.map((item) => {