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
+5 -5
View File
@@ -71,7 +71,7 @@
{
"number": "1",
"title": "Create Account",
"description": "Sign up for your company workspace and choose your pricing plan for the 90-day free trial."
"description": "Sign up for your company workspace and choose your pricing plan for the 30-day free trial."
},
{
"number": "2",
@@ -89,7 +89,7 @@
{
"step": "1",
"title": "Create your company workspace",
"body": "Pick a plan, launch your 90-day trial, and verify the owner account."
"body": "Pick a plan, launch your 30-day trial, and verify the owner account."
},
{
"step": "2",
@@ -180,7 +180,7 @@
{
"number": "1",
"title": "Créer un compte",
"description": "Inscrivez-vous à votre espace entreprise et choisissez votre forfait pour l'essai gratuit de 90 jours."
"description": "Inscrivez-vous à votre espace entreprise et choisissez votre forfait pour l'essai gratuit de 30 jours."
},
{
"number": "2",
@@ -198,7 +198,7 @@
{
"step": "1",
"title": "Créez votre espace entreprise",
"body": "Choisissez un plan, lancez votre essai de 90 jours et vérifiez le compte propriétaire."
"body": "Choisissez un plan, lancez votre essai de 30 jours et vérifiez le compte propriétaire."
},
{
"step": "2",
@@ -289,7 +289,7 @@
{
"number": "1",
"title": "إنشاء حساب",
"description": "قم بالتسجيل للحصول على مساحة عمل شركتك واختر خطتك للتجربة المجانية لمدة 90 يوماً."
"description": "قم بالتسجيل للحصول على مساحة عمل شركتك واختر خطتك للتجربة المجانية لمدة 30 يوماً."
},
{
"number": "2",
@@ -27,7 +27,7 @@ describe('requireSubscription middleware', () => {
expect(next).not.toHaveBeenCalled()
})
it('blocks suspended companies with a billing URL', () => {
it('blocks suspended companies with a subscription recovery URL', () => {
const req = { company: { status: 'SUSPENDED' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
@@ -39,7 +39,7 @@ describe('requireSubscription middleware', () => {
error: 'subscription_suspended',
message: 'Your account has been suspended. Please contact support or renew your subscription.',
statusCode: 402,
billingUrl: 'https://dashboard.example.test/billing',
billingUrl: 'https://dashboard.example.test/subscription',
})
expect(next).not.toHaveBeenCalled()
})
@@ -21,7 +21,7 @@ export function requireSubscription(req: Request, res: Response, next: NextFunct
company.status === 'SUSPENDED'
? 'Your account has been suspended. Please contact support or renew your subscription.'
: 'Your account is pending activation. Please complete your subscription setup.',
{ billingUrl: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/billing` },
{ billingUrl: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/subscription` },
)
}
@@ -37,7 +37,7 @@ export async function startAccount(body: AccountStartInput) {
const result = await prisma.$transaction(async (tx: any) => {
const now = new Date()
const trialEndAt = new Date(now.getTime() + 90 * 24 * 60 * 60 * 1000)
const trialEndAt = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000)
const company = await tx.company.create({
data: {
@@ -13,7 +13,7 @@ import { companySignupSchema } from './auth.company.schemas'
import type { output } from 'zod'
type CompanySignupInput = output<typeof companySignupSchema>
const TRIAL_PERIOD_DAYS = 90
const TRIAL_PERIOD_DAYS = 30
function slugify(value: string) {
return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50) || 'company'
@@ -2,7 +2,7 @@ export type AccessLevel = 'full' | 'limited' | 'read_only' | 'none'
export const SUBSCRIPTION_POLICY = {
trial: {
durationDays: 14,
durationDays: 30,
requiresPaymentMethod: false, // set true when payment capture is mandatory
oneTrialPerCompany: true,
},
@@ -70,18 +70,18 @@ describe('subscription.service operational edges', () => {
it('builds plans from pricing config rows when platform overrides exist', async () => {
vi.mocked(prisma.pricingConfig.findMany).mockResolvedValue([
{ plan: 'STARTER', billingPeriod: 'MONTHLY', amount: 9900 },
{ plan: 'STARTER', billingPeriod: 'ANNUAL', amount: 99000 },
{ plan: 'PRO', billingPeriod: 'MONTHLY', amount: 29900 },
{ plan: 'STARTER', billingPeriod: 'MONTHLY', amount: 14900 },
{ plan: 'STARTER', billingPeriod: 'ANNUAL', amount: 143040 },
{ plan: 'PRO', billingPeriod: 'MONTHLY', amount: 39900 },
] as never)
await expect(service.getPlans()).resolves.toEqual({
STARTER: {
MONTHLY: { MAD: 9900 },
ANNUAL: { MAD: 99000 },
MONTHLY: { MAD: 14900 },
ANNUAL: { MAD: 143040 },
},
PRO: {
MONTHLY: { MAD: 29900 },
MONTHLY: { MAD: 39900 },
},
})
})
@@ -102,7 +102,7 @@ describe('subscription.service operational edges', () => {
'PRO',
'MONTHLY',
'MAD',
new Date('2026-06-15T00:00:00.000Z'),
new Date('2026-07-01T00:00:00.000Z'),
)
expect(repo.createEvent).toHaveBeenCalledWith(expect.objectContaining({
subscriptionId: 'sub_1',
@@ -94,7 +94,7 @@ describe('auth middleware API boundaries', () => {
expect(res.status).toBe(402)
expect(res.body).toEqual(expect.objectContaining({
error: 'subscription_suspended',
billingUrl: 'https://dashboard.example.test/billing',
billingUrl: 'https://dashboard.example.test/subscription',
}))
expect(vehicleService.listVehicles).not.toHaveBeenCalled()
})
@@ -8,7 +8,7 @@ function uniqueEmail(prefix: string) {
}
describe('Company signup API', () => {
it('creates new accounts with a 90-day trial period', async () => {
it('creates new accounts with a 30-day trial period', async () => {
const startedAt = Date.now()
const res = await request(app)
@@ -54,7 +54,7 @@ describe('Company signup API', () => {
const trialEndsAt = new Date(res.body.data.trialEndsAt).getTime()
const trialDurationDays = (trialEndsAt - startedAt) / (24 * 60 * 60 * 1000)
expect(trialDurationDays).toBeGreaterThan(89.9)
expect(trialDurationDays).toBeLessThan(90.1)
expect(trialDurationDays).toBeGreaterThan(29.9)
expect(trialDurationDays).toBeLessThan(30.1)
})
})
@@ -51,7 +51,10 @@ const STATUS_BADGE: Record<string, string> = {
ACTIVE: 'bg-green-100 text-green-700',
PAST_DUE: 'bg-orange-100 text-orange-700',
CANCELLED: 'bg-slate-100 text-slate-600',
CANCELED: 'bg-slate-100 text-slate-600',
UNPAID: 'bg-red-100 text-red-700',
EXPIRED: 'bg-red-100 text-red-700',
SUSPENDED: 'bg-red-100 text-red-700',
}
const INVOICE_STATUS: Record<string, string> = {
@@ -70,6 +73,8 @@ export default function SubscriptionPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [canViewPage, setCanViewPage] = useState<boolean | null>(null)
const [verificationError, setVerificationError] = useState<string | null>(null)
const [verificationAttempt, setVerificationAttempt] = useState(0)
const [selectedPlan, setSelectedPlan] = useState<Plan>('STARTER')
const [billingPeriod, setBillingPeriod] = useState<BillingPeriod>('MONTHLY')
@@ -96,7 +101,7 @@ export default function SubscriptionPage() {
subscribe: 'Subscribe',
selectPlan: 'Select a plan and payment provider to proceed.',
monthly: 'Monthly',
annual: 'Annual (save ~17%)',
annual: 'Annual (save 20%)',
active: 'Active',
perMonthShort: 'mo',
perYearShort: 'yr',
@@ -113,10 +118,15 @@ export default function SubscriptionPage() {
paid: 'Paid',
amount: 'Amount',
loading: 'Loading…',
verifying: 'Verifying access…',
accessDenied: 'Access denied',
accessDeniedBody: 'Only account owners can manage subscriptions.',
retry: 'Retry',
accessUnavailable: 'Unable to verify your access right now. Please try again.',
noInvoices: 'No invoices yet.',
noProviderConfigured: 'No payment provider is configured. Contact support to enable AmanPay or PayPal.',
providerUnavailable: 'This payment provider is not configured.',
statusLabels: { TRIALING: 'Trialing', ACTIVE: 'Active', PAST_DUE: 'Past due', CANCELLED: 'Cancelled', UNPAID: 'Unpaid' } as Record<string, string>,
statusLabels: { TRIALING: 'Trialing', ACTIVE: 'Active', PAST_DUE: 'Past due', CANCELLED: 'Cancelled', CANCELED: 'Canceled', UNPAID: 'Unpaid', EXPIRED: 'Expired', SUSPENDED: 'Suspended' } as Record<string, string>,
invoiceStatusLabels: { PAID: 'Paid', PENDING: 'Pending', FAILED: 'Failed', REFUNDED: 'Refunded' } as Record<string, string>,
planFeatures: {
STARTER: ['Up to 10 vehicles', '1 user seat', 'Basic analytics', 'Storefront listing'],
@@ -139,7 +149,7 @@ export default function SubscriptionPage() {
subscribe: 'Sabonner',
selectPlan: 'Sélectionnez un plan et un prestataire de paiement.',
monthly: 'Mensuel',
annual: 'Annuel (économie ~17%)',
annual: 'Annuel (économie 20%)',
active: 'Actif',
perMonthShort: 'mois',
perYearShort: 'an',
@@ -156,10 +166,15 @@ export default function SubscriptionPage() {
paid: 'Payé',
amount: 'Montant',
loading: 'Chargement…',
verifying: 'Vérification de laccès…',
accessDenied: 'Accès refusé',
accessDeniedBody: 'Seuls les propriétaires du compte peuvent gérer les abonnements.',
retry: 'Réessayer',
accessUnavailable: 'Impossible de vérifier votre accès pour le moment. Veuillez réessayer.',
noInvoices: 'Aucune facture pour le moment.',
noProviderConfigured: 'Aucun prestataire de paiement nest configuré. Contactez le support pour activer AmanPay ou PayPal.',
providerUnavailable: 'Ce prestataire de paiement nest pas configuré.',
statusLabels: { TRIALING: 'Essai', ACTIVE: 'Actif', PAST_DUE: 'En retard', CANCELLED: 'Annulé', UNPAID: 'Impayé' } as Record<string, string>,
statusLabels: { TRIALING: 'Essai', ACTIVE: 'Actif', PAST_DUE: 'En retard', CANCELLED: 'Annulé', CANCELED: 'Annulé', UNPAID: 'Impayé', EXPIRED: 'Expiré', SUSPENDED: 'Suspendu' } as Record<string, string>,
invoiceStatusLabels: { PAID: 'Payé', PENDING: 'En attente', FAILED: 'Échec', REFUNDED: 'Remboursé' } as Record<string, string>,
planFeatures: {
STARTER: ['Jusqu’à 10 véhicules', '1 utilisateur', 'Analyses de base', 'Présence storefront'],
@@ -182,7 +197,7 @@ export default function SubscriptionPage() {
subscribe: 'اشتراك',
selectPlan: 'اختر خطة ومزوّد دفع للمتابعة.',
monthly: 'شهري',
annual: 'سنوي (توفير ~17%)',
annual: 'سنوي (توفير 20%)',
active: 'نشط',
perMonthShort: 'شهر',
perYearShort: 'سنة',
@@ -199,10 +214,15 @@ export default function SubscriptionPage() {
paid: 'مدفوع',
amount: 'المبلغ',
loading: 'جارٍ التحميل…',
verifying: 'جارٍ التحقق من الوصول…',
accessDenied: 'تم رفض الوصول',
accessDeniedBody: 'يمكن لمالكي الحساب فقط إدارة الاشتراكات.',
retry: 'إعادة المحاولة',
accessUnavailable: 'تعذر التحقق من وصولك الآن. يرجى المحاولة مرة أخرى.',
noInvoices: 'لا توجد فواتير حتى الآن.',
noProviderConfigured: 'لا يوجد مزوّد دفع مهيأ. تواصل مع الدعم لتفعيل AmanPay أو PayPal.',
providerUnavailable: 'مزوّد الدفع هذا غير مهيأ.',
statusLabels: { TRIALING: 'تجريبي', ACTIVE: 'نشط', PAST_DUE: 'متأخر', CANCELLED: 'ملغى', UNPAID: 'غير مدفوع' } as Record<string, string>,
statusLabels: { TRIALING: 'تجريبي', ACTIVE: 'نشط', PAST_DUE: 'متأخر', CANCELLED: 'ملغى', CANCELED: 'ملغى', UNPAID: 'غير مدفوع', EXPIRED: 'منتهي', SUSPENDED: 'معلّق' } as Record<string, string>,
invoiceStatusLabels: { PAID: 'مدفوع', PENDING: 'قيد الانتظار', FAILED: 'فشل', REFUNDED: 'مسترد' } as Record<string, string>,
planFeatures: {
STARTER: ['حتى 10 مركبات', 'مستخدم واحد', 'تحليلات أساسية', 'إدراج في السوق'],
@@ -213,29 +233,34 @@ export default function SubscriptionPage() {
}[language]
useEffect(() => {
const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY)
if (cached) {
try {
const profile = JSON.parse(cached) as EmployeeProfile
const allowed = profile.role === 'OWNER'
setCanViewPage(allowed)
if (!allowed) router.replace('/')
return
} catch {}
}
let cancelled = false
setCanViewPage(null)
setVerificationError(null)
apiFetch<{ employee: EmployeeProfile }>('/auth/employee/me')
.then(({ employee }) => {
if (cancelled) return
window.localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(employee))
const allowed = employee.role === 'OWNER'
setCanViewPage(allowed)
if (!allowed) router.replace('/')
})
.catch(() => {
setCanViewPage(false)
router.replace('/')
.catch((err: any) => {
if (cancelled) return
if (err?.statusCode === 401) {
router.replace('/sign-in?redirect=%2Fsubscription')
return
}
if (err?.statusCode === 403) {
setCanViewPage(false)
return
}
setVerificationError(err?.message ?? copy.accessUnavailable)
})
}, [router])
return () => {
cancelled = true
}
}, [copy.accessUnavailable, router, verificationAttempt])
const fetchPlanData = useCallback(async () => {
const [prices, features] = await Promise.all([
@@ -285,8 +310,41 @@ export default function SubscriptionPage() {
}
}, [canViewPage, fetchPlanData])
if (verificationError) {
return (
<div className="flex min-h-[40vh] items-center justify-center px-6">
<div className="card max-w-md p-6 text-center">
<h2 className="text-base font-semibold text-slate-900">{copy.accessUnavailable}</h2>
<p className="mt-2 text-sm text-slate-500">{verificationError}</p>
<button
type="button"
onClick={() => setVerificationAttempt((value) => value + 1)}
className="btn-primary mt-5"
>
{copy.retry}
</button>
</div>
</div>
)
}
if (canViewPage === null) {
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={copy.verifying} />
</div>
)
}
if (canViewPage !== true) {
return null
return (
<div className="flex min-h-[40vh] items-center justify-center px-6">
<div className="card max-w-md p-6 text-center">
<h2 className="text-base font-semibold text-slate-900">{copy.accessDenied}</h2>
<p className="mt-2 text-sm text-slate-500">{copy.accessDeniedBody}</p>
</div>
</div>
)
}
async function handleCheckout() {
@@ -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) => {
@@ -0,0 +1,60 @@
import { toDashboardAppPath } from './dashboardPaths'
export type DashboardRole = 'OWNER' | 'MANAGER' | 'AGENT'
export type DashboardRoutePolicy = {
authenticationRequired: boolean
allowedRoles: DashboardRole[] | null
subscriptionRequired: boolean
menuRegistrationRequired: boolean
billingRecoveryRoute?: boolean
}
export const OWNER_ONLY_BILLING_RECOVERY_POLICY: DashboardRoutePolicy = {
authenticationRequired: true,
allowedRoles: ['OWNER'],
subscriptionRequired: false,
menuRegistrationRequired: false,
billingRecoveryRoute: true,
}
export const DASHBOARD_FEATURE_POLICY: DashboardRoutePolicy = {
authenticationRequired: true,
allowedRoles: null,
subscriptionRequired: true,
menuRegistrationRequired: true,
}
export const dashboardRoutePolicies: Record<string, DashboardRoutePolicy> = {
'/': {
authenticationRequired: true,
allowedRoles: null,
subscriptionRequired: false,
menuRegistrationRequired: false,
},
'/subscription': OWNER_ONLY_BILLING_RECOVERY_POLICY,
'/subscription/success': OWNER_ONLY_BILLING_RECOVERY_POLICY,
'/subscription/cancel': OWNER_ONLY_BILLING_RECOVERY_POLICY,
}
export function resolveDashboardRoutePolicy(pathname: string): DashboardRoutePolicy {
const currentPath = toDashboardAppPath(pathname)
const matchingRoute = Object.keys(dashboardRoutePolicies)
.sort((a, b) => b.length - a.length)
.find((route) => {
if (route === '/') return currentPath === '/'
return currentPath === route || currentPath.startsWith(`${route}/`)
})
return matchingRoute ? dashboardRoutePolicies[matchingRoute] : DASHBOARD_FEATURE_POLICY
}
export function roleCanAccessPolicy(role: string | null | undefined, policy: DashboardRoutePolicy): boolean {
if (!policy.allowedRoles) return true
return Boolean(role && policy.allowedRoles.includes(role as DashboardRole))
}
export function getDashboardFallbackRoute(role: string | null | undefined): string {
if (role === 'OWNER' || role === 'MANAGER' || role === 'AGENT') return '/'
return '/'
}