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
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:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user