fix architecture and write new tests

This commit is contained in:
root
2026-06-10 00:40:19 -04:00
parent 560da1cadf
commit 80a597bc10
377 changed files with 84020 additions and 1337 deletions
@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react'
import { formatCurrency } from '@rentaldrivego/types'
import { API_BASE, apiFetch, EMPLOYEE_TOKEN_KEY } from '@/lib/api'
import { API_BASE, apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
interface ReportRow {
@@ -110,9 +110,8 @@ export default function ReportsPage() {
setExporting(true)
setError(null)
try {
const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
const res = await fetch(`${API_BASE}/analytics/report?period=${period}&format=CSV`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
credentials: 'include',
})
if (!res.ok) {
const json = await res.json().catch(() => null)
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from 'react'
import InviteModal from '@/components/team/InviteModal'
import EditMemberModal from '@/components/team/EditMemberModal'
import PermissionsMatrix from '@/components/team/PermissionsMatrix'
import { EMPLOYEE_TOKEN_KEY } from '@/lib/api'
import { apiFetch } from '@/lib/api'
import { useTeam, TeamMember, InvitePayload } from '@/hooks/useTeam'
import { useDashboardI18n } from '@/components/I18nProvider'
@@ -193,18 +193,17 @@ export default function TeamPage() {
const [toast, setToast] = useState<string | null>(null)
useEffect(() => {
const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
if (!token) return
let cancelled = false
try {
const encoded = token.split('.')[1] ?? ''
const normalized = encoded.replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
const payload = JSON.parse(atob(padded))
setCurrentEmployeeId(typeof payload?.sub === 'string' ? payload.sub : null)
} catch {
setCurrentEmployeeId(null)
}
apiFetch<{ employee: { id: string } }>('/auth/employee/me')
.then(({ employee }) => {
if (!cancelled) setCurrentEmployeeId(employee.id)
})
.catch(() => {
if (!cancelled) setCurrentEmployeeId(null)
})
return () => { cancelled = true }
}, [])
const currentEmployee = members.find((member) => member.id === currentEmployeeId)
+2 -2
View File
@@ -13,8 +13,8 @@ function resolveInitialLanguage(value: string | undefined): 'en' | 'fr' | 'ar' {
return value === 'fr' || value === 'ar' || value === 'en' ? value : 'en'
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
const cookieStore = cookies()
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const cookieStore = await cookies()
const initialLanguage = resolveInitialLanguage(
cookieStore.get(SHARED_LANGUAGE_COOKIE)?.value ?? cookieStore.get('dashboard-language')?.value,
)
@@ -0,0 +1,57 @@
import React, { isValidElement } from 'react'
import { describe, expect, it } from 'vitest'
import ForgotPasswordPageClient from './forgot-password/ForgotPasswordPageClient'
import ResetPasswordPageClient from './reset-password/ResetPasswordPageClient'
import ForgotPasswordPage from './forgot-password/page'
import ResetPasswordPage from './reset-password/page'
import AcceptInvitePage from './onboarding/accept-invite/page'
function collectText(node: unknown): string[] {
if (node === null || node === undefined || typeof node === 'boolean') return []
if (typeof node === 'string' || typeof node === 'number') return [String(node)]
if (Array.isArray(node)) return node.flatMap(collectText)
if (isValidElement<{ children?: React.ReactNode }>(node)) return collectText(node.props.children)
return []
}
function findElement(node: unknown, predicate: (element: React.ReactElement) => boolean): React.ReactElement | null {
if (node === null || node === undefined || typeof node === 'boolean') return null
if (Array.isArray(node)) {
for (const child of node) {
const found = findElement(child, predicate)
if (found) return found
}
return null
}
if (!isValidElement<{ children?: React.ReactNode }>(node)) return null
if (predicate(node)) return node
return findElement(node.props.children, predicate)
}
describe('dashboard public auth pages', () => {
it('renders forgot-password in embedded mode for the public shell route', () => {
const page = ForgotPasswordPage()
expect(isValidElement(page)).toBe(true)
expect(page.type).toBe(ForgotPasswordPageClient)
expect(page.props.embedded).toBe(true)
})
it('renders reset-password in embedded mode for the public shell route', () => {
const page = ResetPasswordPage()
expect(isValidElement(page)).toBe(true)
expect(page.type).toBe(ResetPasswordPageClient)
expect(page.props.embedded).toBe(true)
})
it('keeps accept-invite copy and sign-in handoff visible', () => {
const page = AcceptInvitePage()
const text = collectText(page).join(' ')
const signInLink = findElement(page, (element) => element.props.href === '/sign-in')
expect(text).toContain('Invitation accepted')
expect(text).toContain('Sign in to dashboard')
expect(signInLink).not.toBeNull()
})
})
@@ -7,7 +7,7 @@ import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { useDashboardI18n } from '@/components/I18nProvider'
import PublicShell from '@/components/layout/PublicShell'
import { adminUrl, marketplaceUrl } from '@/lib/urls'
import { API_BASE, EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY } from '@/lib/api'
import { API_BASE, EMPLOYEE_PROFILE_KEY } from '@/lib/api'
import { toPublicDashboardPath } from '@/lib/dashboardPaths'
const DASHBOARD_LOGO_SRC = '/dashboard/rentalcardrive.png'
@@ -235,14 +235,13 @@ function LocalSignInForm({
const empRes = await fetch(`${API_BASE}/auth/employee/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ email, password }),
})
const empJson = await empRes.json()
if (empRes.ok && empJson?.data?.token) {
const token = empJson.data.token
if (empRes.ok && empJson?.data?.employee) {
const targetPath = toPublicDashboardPath(employeeRedirect)
localStorage.setItem(EMPLOYEE_TOKEN_KEY, token)
if (empJson?.data?.employee) {
localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(empJson.data.employee))
const prefLang = empJson.data.employee?.preferredLanguage
@@ -251,7 +250,6 @@ function LocalSignInForm({
document.cookie = `rentaldrivego-language=${prefLang}; path=/; max-age=31536000; samesite=lax`
}
}
document.cookie = `employee_token=${token}; path=/; max-age=28800; samesite=lax`
window.dispatchEvent(new CustomEvent('rentaldrivego:auth-changed'))
notifyParent({ type: 'rentaldrivego:employee-login', path: targetPath })
// Use a document navigation here so the authenticated dashboard bootstraps
@@ -268,12 +266,13 @@ function LocalSignInForm({
const adminRes = await fetch(`${API_BASE}/admin/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ email, password }),
})
const adminJson = await adminRes.json()
if (adminRes.ok && adminJson?.data?.token) {
redirectAdmin(adminJson.data.token)
if (adminRes.ok && adminJson?.data?.admin) {
window.location.href = `${adminUrl}/dashboard`
return
}
@@ -296,15 +295,21 @@ function LocalSignInForm({
setError(null)
try {
const normalizedCode = totpCode.trim().toUpperCase()
const secondFactor = /^\d{6}$/.test(normalizedCode)
? { totpCode: normalizedCode }
: { recoveryCode: normalizedCode }
const adminRes = await fetch(`${API_BASE}/admin/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, totpCode }),
credentials: 'include',
body: JSON.stringify({ email, password, ...secondFactor }),
})
const adminJson = await adminRes.json()
if (adminRes.ok && adminJson?.data?.token) {
redirectAdmin(adminJson.data.token)
if (adminRes.ok && adminJson?.data?.admin) {
window.location.href = `${adminUrl}/dashboard`
return
}
@@ -393,13 +398,13 @@ function LocalSignInForm({
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">{dict.authCode}</label>
<input
type="text"
inputMode="numeric"
pattern="[0-9]{6}"
maxLength={6}
inputMode="text"
pattern="[0-9A-Za-z-]{6,14}"
maxLength={14}
required
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
placeholder="000000"
onChange={(e) => setTotpCode(e.target.value.replace(/[^0-9A-Za-z-]/g, '').toUpperCase())}
placeholder="000000 or XXXX-XXXX-XXXX"
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-center text-xl tracking-[0.45em] text-stone-900 transition-colors placeholder:text-stone-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-blue-950/80 dark:text-stone-100 dark:placeholder:text-stone-500"
/>
</div>
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest'
import {
calendarGridDays,
daysBetween,
eventsForCalendarDay,
isSameDay,
parseDateStr,
selectedBlockRange,
toDateStr,
type CalendarEvent,
} from './VehicleCalendar'
describe('VehicleCalendar helpers', () => {
it('formats and parses local date-only strings without shifting the calendar day', () => {
const parsed = parseDateStr('2026-02-09')
expect(parsed.getFullYear()).toBe(2026)
expect(parsed.getMonth()).toBe(1)
expect(parsed.getDate()).toBe(9)
expect(toDateStr(parsed)).toBe('2026-02-09')
})
it('builds a full week-aligned month grid with leading and trailing blanks', () => {
const grid = calendarGridDays(2026, 2)
expect(grid).toHaveLength(28)
expect(grid[0]?.getDate()).toBe(1)
expect(grid.at(-1)?.getDate()).toBe(28)
const march = calendarGridDays(2026, 3)
expect(march).toHaveLength(35)
expect(march[0]?.getDate()).toBe(1)
expect(march[6]?.getDate()).toBe(7)
expect(march.at(-1)).toBeNull()
})
it('treats reservation end dates as exclusive when mapping events to days', () => {
const events: CalendarEvent[] = [
{ id: 'r1', type: 'RESERVATION', startDate: '2026-06-10T10:00:00.000Z', endDate: '2026-06-13T10:00:00.000Z', status: 'CONFIRMED', label: 'Reservation' },
{ id: 'b1', type: 'BLOCK', startDate: '2026-06-13T00:00:00.000Z', endDate: '2026-06-14T00:00:00.000Z', status: null, label: 'Block' },
]
expect(eventsForCalendarDay(events, parseDateStr('2026-06-10')).map((event) => event.id)).toEqual(['r1'])
expect(eventsForCalendarDay(events, parseDateStr('2026-06-12')).map((event) => event.id)).toEqual(['r1'])
expect(eventsForCalendarDay(events, parseDateStr('2026-06-13')).map((event) => event.id)).toEqual(['b1'])
})
it('normalizes two clicked days into an inclusive visual range with exclusive end date', () => {
expect(selectedBlockRange('2026-06-12', parseDateStr('2026-06-10'))).toEqual({
startDate: '2026-06-10',
endDate: '2026-06-13',
})
expect(selectedBlockRange('2026-06-10', parseDateStr('2026-06-12'))).toEqual({
startDate: '2026-06-10',
endDate: '2026-06-13',
})
})
it('compares calendar days and calculates day spans independent of clock time', () => {
expect(isSameDay(new Date(2026, 5, 9, 1), new Date(2026, 5, 9, 23))).toBe(true)
expect(isSameDay(new Date(2026, 5, 9), new Date(2026, 5, 10))).toBe(false)
expect(daysBetween(parseDateStr('2026-06-09'), parseDateStr('2026-06-12'))).toBe(3)
})
})
@@ -7,7 +7,7 @@ import { useDashboardI18n } from '@/components/I18nProvider'
type EventType = 'RESERVATION' | 'MAINTENANCE' | 'BLOCK'
interface CalendarEvent {
export interface CalendarEvent {
id: string
type: EventType
startDate: string
@@ -26,23 +26,56 @@ const EVENT_STYLES: Record<EventType, { bg: string; text: string; border: string
BLOCK: { bg: 'bg-red-100', text: 'text-red-800', border: 'border-red-300', dot: 'bg-red-500' },
}
function toDateStr(d: Date): string {
export function toDateStr(d: Date): string {
return d.toISOString().slice(0, 10)
}
function parseDateStr(s: string): Date {
export function parseDateStr(s: string): Date {
const [y, m, day] = s.split('-').map(Number)
return new Date(y, m - 1, day)
}
function isSameDay(a: Date, b: Date): boolean {
export function isSameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
}
function daysBetween(start: Date, end: Date): number {
export function daysBetween(start: Date, end: Date): number {
return Math.round((end.getTime() - start.getTime()) / 86400000)
}
export function calendarGridDays(year: number, month: number): (Date | null)[] {
const firstDay = new Date(year, month - 1, 1)
const daysInMonth = new Date(year, month, 0).getDate()
const startOffset = firstDay.getDay()
const gridDays: (Date | null)[] = []
for (let i = 0; i < startOffset; i++) gridDays.push(null)
for (let d = 1; d <= daysInMonth; d++) gridDays.push(new Date(year, month - 1, d))
while (gridDays.length % 7 !== 0) gridDays.push(null)
return gridDays
}
export function eventsForCalendarDay(events: CalendarEvent[], day: Date): CalendarEvent[] {
const dayStr = toDateStr(day)
return events.filter((e) => {
const start = e.startDate.slice(0, 10)
const end = e.endDate.slice(0, 10)
return start <= dayStr && dayStr < end
})
}
export function selectedBlockRange(selectStart: string, clickedDay: Date): { startDate: string; endDate: string } {
const clicked = toDateStr(clickedDay)
const start = selectStart < clicked ? selectStart : clicked
const end = selectStart < clicked ? clicked : selectStart
return {
startDate: start,
endDate: toDateStr(new Date(parseDateStr(end).getTime() + 86400000)),
}
}
export default function VehicleCalendar({ vehicleId }: Props) {
const { dict, language } = useDashboardI18n()
const c = dict.calendar
@@ -92,24 +125,10 @@ export default function VehicleCalendar({ vehicleId }: Props) {
}
// Build calendar grid (6 rows × 7 cols)
const firstDay = new Date(year, month - 1, 1)
const daysInMonth = new Date(year, month, 0).getDate()
const startOffset = firstDay.getDay() // 0=Sun
const gridDays: (Date | null)[] = []
for (let i = 0; i < startOffset; i++) gridDays.push(null)
for (let d = 1; d <= daysInMonth; d++) gridDays.push(new Date(year, month - 1, d))
while (gridDays.length % 7 !== 0) gridDays.push(null)
const gridDays = calendarGridDays(year, month)
// Map events to date strings for fast lookup
const eventsOnDay = (day: Date): CalendarEvent[] => {
const dayStr = toDateStr(day)
return events.filter((e) => {
const s = e.startDate.slice(0, 10)
const end = e.endDate.slice(0, 10)
return s <= dayStr && dayStr < end
})
}
const eventsOnDay = (day: Date): CalendarEvent[] => eventsForCalendarDay(events, day)
const isToday = (day: Date) => isSameDay(day, today)
@@ -118,12 +137,9 @@ export default function VehicleCalendar({ vehicleId }: Props) {
if (!selectStart) {
setSelectStart(str)
} else {
const start = selectStart < str ? selectStart : str
const end = selectStart < str ? str : selectStart
// end date in the modal is the last night, so add one day
const endDate = toDateStr(new Date(parseDateStr(end).getTime() + 86400000))
setBlockStart(start)
setBlockEnd(endDate)
const range = selectedBlockRange(selectStart, day)
setBlockStart(range.startDate)
setBlockEnd(range.endDate)
setSelectStart(null)
setShowModal(true)
}
@@ -0,0 +1,129 @@
import { describe, expect, it, vi } from 'vitest'
import {
buildConfigForm,
buildRuleForm,
centsToInput,
inputToCents,
numericInputToInt,
toDateInput,
type PricingConfiguration,
type PricingRule,
} from './VehiclePricingManager'
const configuration: PricingConfiguration = {
id: 'cfg_1',
vehicleId: 'veh_1',
pricingMode: 'AUTOMATIC',
baseDailyRate: 50000,
weeklyRate: 280000,
weekendRate: 65000,
holidayRate: null,
monthlyRate: 950000,
longTermDailyRate: null,
minimumDailyRate: 40000,
maximumDailyRate: 80000,
maxDailyPriceMovementPct: 12,
automaticPricingEnabled: true,
approvalRequired: false,
createdAt: '2026-06-01T00:00:00.000Z',
updatedAt: '2026-06-02T00:00:00.000Z',
}
const rule: PricingRule = {
id: 'rule_1',
name: 'Summer',
ruleType: 'SEASONAL',
startDate: '2026-07-01T00:00:00.000Z',
endDate: '2026-08-31T00:00:00.000Z',
dailyRate: 70000,
weeklyRate: null,
weekendRate: 90000,
monthlyRate: null,
minDailyRate: 60000,
maxDailyRate: 110000,
automaticAdjustmentPct: 15,
priceAdjustment: 5000,
isActive: false,
sortOrder: 4,
createdAt: '2026-06-01T00:00:00.000Z',
updatedAt: '2026-06-02T00:00:00.000Z',
}
describe('VehiclePricingManager helpers', () => {
it('converts cents to display input strings and empty nullable values', () => {
expect(centsToInput(123456)).toBe('1234.56')
expect(centsToInput(0)).toBe('0.00')
expect(centsToInput(null)).toBe('')
expect(centsToInput(undefined)).toBe('')
})
it('converts currency inputs to cents while rejecting blank or invalid values', () => {
expect(inputToCents('499.99')).toBe(49999)
expect(inputToCents(' 50 ')).toBe(5000)
expect(inputToCents('')).toBeNull()
expect(inputToCents('abc')).toBeNull()
})
it('parses integer-only fields without accepting blank values', () => {
expect(numericInputToInt('12')).toBe(12)
expect(numericInputToInt('12.8')).toBe(12)
expect(numericInputToInt('')).toBeNull()
expect(numericInputToInt('nope')).toBeNull()
})
it('builds a stable editable configuration form from API pricing data', () => {
expect(buildConfigForm(configuration)).toEqual({
pricingMode: 'AUTOMATIC',
baseDailyRate: '500.00',
weeklyRate: '2800.00',
weekendRate: '650.00',
holidayRate: '',
monthlyRate: '9500.00',
longTermDailyRate: '',
minimumDailyRate: '400.00',
maximumDailyRate: '800.00',
maxDailyPriceMovementPct: '12',
automaticPricingEnabled: true,
approvalRequired: false,
})
})
it('builds an editable rule form from an existing pricing rule', () => {
expect(buildRuleForm(rule)).toEqual({
id: 'rule_1',
name: 'Summer',
ruleType: 'SEASONAL',
startDate: '2026-07-01',
endDate: '2026-08-31',
dailyRate: '700.00',
weeklyRate: '',
weekendRate: '900.00',
monthlyRate: '',
minDailyRate: '600.00',
maxDailyRate: '1100.00',
automaticAdjustmentPct: '15',
priceAdjustment: '50.00',
isActive: false,
sortOrder: '4',
})
})
it('defaults new rules to today through six days later', () => {
vi.setSystemTime(new Date('2026-06-09T12:00:00.000Z'))
expect(buildRuleForm()).toMatchObject({
name: '',
ruleType: 'DATE_RANGE',
startDate: '2026-06-09',
endDate: '2026-06-15',
isActive: true,
sortOrder: '0',
})
vi.useRealTimers()
})
it('trims ISO timestamps for date input controls', () => {
expect(toDateInput('2026-10-04T22:15:00.000Z')).toBe('2026-10-04')
})
})
@@ -6,7 +6,7 @@ import { formatCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
interface PricingConfiguration {
export interface PricingConfiguration {
id: string
vehicleId: string
pricingMode: 'MANUAL' | 'AUTOMATIC'
@@ -25,7 +25,7 @@ interface PricingConfiguration {
updatedAt: string
}
interface PricingRule {
export interface PricingRule {
id: string
name: string
ruleType: 'DATE_RANGE' | 'SEASONAL' | 'HOLIDAY' | 'WEEKEND' | 'SPECIAL_EVENT' | 'MANUAL_OVERRIDE'
@@ -45,7 +45,7 @@ interface PricingRule {
updatedAt: string
}
interface PricingHistoryEntry {
export interface PricingHistoryEntry {
id: string
source: 'CONFIG_UPDATE' | 'RULE_CREATED' | 'RULE_UPDATED' | 'RULE_DELETED' | 'MANUAL_OVERRIDE'
changedByEmployeeId: string | null
@@ -58,14 +58,14 @@ interface PricingHistoryEntry {
createdAt: string
}
interface PricingPreviewItem {
export interface PricingPreviewItem {
date: string
dailyRate: number
pricingType: 'MANUAL_BASE' | 'AUTOMATIC_BASE' | 'WEEKEND_RATE' | 'RULE_RATE' | 'AUTOMATIC_RULE'
matchedRuleName: string | null
}
interface PricingResponse {
export interface PricingResponse {
configuration: PricingConfiguration
rules: PricingRule[]
history: PricingHistoryEntry[]
@@ -75,7 +75,7 @@ interface PricingResponse {
}
}
interface ConfigForm {
export interface ConfigForm {
pricingMode: 'MANUAL' | 'AUTOMATIC'
baseDailyRate: string
weeklyRate: string
@@ -90,7 +90,7 @@ interface ConfigForm {
approvalRequired: boolean
}
interface RuleForm {
export interface RuleForm {
id?: string
name: string
ruleType: PricingRule['ruleType']
@@ -108,28 +108,28 @@ interface RuleForm {
sortOrder: string
}
function centsToInput(value: number | null | undefined) {
export function centsToInput(value: number | null | undefined) {
return value == null ? '' : (value / 100).toFixed(2)
}
function inputToCents(value: string) {
export function inputToCents(value: string) {
if (!value.trim()) return null
const parsed = Number.parseFloat(value)
if (!Number.isFinite(parsed)) return null
return Math.round(parsed * 100)
}
function numericInputToInt(value: string) {
export function numericInputToInt(value: string) {
if (!value.trim()) return null
const parsed = Number.parseInt(value, 10)
return Number.isFinite(parsed) ? parsed : null
}
function toDateInput(value: string) {
export function toDateInput(value: string) {
return value.slice(0, 10)
}
function buildConfigForm(configuration: PricingConfiguration): ConfigForm {
export function buildConfigForm(configuration: PricingConfiguration): ConfigForm {
return {
pricingMode: configuration.pricingMode,
baseDailyRate: centsToInput(configuration.baseDailyRate),
@@ -146,7 +146,7 @@ function buildConfigForm(configuration: PricingConfiguration): ConfigForm {
}
}
function buildRuleForm(rule?: PricingRule): RuleForm {
export function buildRuleForm(rule?: PricingRule): RuleForm {
const today = new Date().toISOString().slice(0, 10)
const nextWeek = new Date(Date.now() + 6 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)
@@ -0,0 +1,32 @@
import React, { isValidElement } from 'react'
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/components/layout/PublicHeader', () => ({ default: function MockPublicHeader() { return React.createElement('mock-header') } }))
vi.mock('@/components/layout/PublicFooter', () => ({ default: function MockPublicFooter() { return React.createElement('mock-footer') } }))
import PublicShell from './PublicShell'
function childTypes(node: React.ReactElement): string[] {
return React.Children.toArray(node.props.children).filter(isValidElement).map((child) => {
const type = child.type as any
if (typeof type === 'string') return type
return type.displayName ?? type.name ?? 'anonymous'
})
}
describe('dashboard PublicShell', () => {
it('wraps public pages with header, content, and footer by default', () => {
const shell = PublicShell({ children: React.createElement('main', { id: 'content' }) })
expect(isValidElement(shell)).toBe(true)
expect(childTypes(shell)).toEqual(['MockPublicHeader', 'div', 'MockPublicFooter'])
})
it('omits chrome when embedded so partner surfaces do not get nested headers', () => {
const shell = PublicShell({ embedded: true, children: React.createElement('main', { id: 'content' }) })
expect(childTypes(shell)).toEqual(['div'])
const contentWrapper = React.Children.toArray(shell.props.children).find(isValidElement) as React.ReactElement
expect(contentWrapper.props.children).toEqual(React.createElement('main', { id: 'content' }))
})
})
@@ -25,7 +25,7 @@ import {
} from 'lucide-react'
import { DashboardLanguageSwitcher, DashboardThemeSwitcher, useDashboardI18n } from '@/components/I18nProvider'
import { marketplaceUrl } from '@/lib/urls'
import { EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api'
import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api'
import { toDashboardAppPath } from '@/lib/dashboardPaths'
import { SHARED_LANGUAGE_KEY, readCurrentUserScopedPreference } from '@/lib/preferences'
@@ -166,9 +166,6 @@ export default function Sidebar() {
}, [])
useEffect(() => {
const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
if (!token) return
let cancelled = false
async function loadBrand() {
@@ -202,16 +199,6 @@ export default function Sidebar() {
}, [])
useEffect(() => {
const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
if (!token) {
setUser({
displayName: dict.workspaceUser,
subtitle: dict.localAuth,
initials: 'W',
})
return
}
const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY)
if (cached) {
try {
@@ -344,9 +331,8 @@ export default function Sidebar() {
}
function signOut() {
localStorage.removeItem(EMPLOYEE_TOKEN_KEY)
localStorage.removeItem(EMPLOYEE_PROFILE_KEY)
document.cookie = 'employee_token=; path=/; max-age=0; samesite=lax'
void fetch('/dashboard/api/v1/auth/employee/logout', { method: 'POST', credentials: 'include' })
window.dispatchEvent(new CustomEvent('rentaldrivego:auth-changed'))
notifyParent({ type: 'rentaldrivego:employee-logout' })
window.location.href = marketplaceUrl
@@ -4,7 +4,7 @@ import { Bell } from 'lucide-react'
import { usePathname, useRouter } from 'next/navigation'
import { useState, useEffect } from 'react'
import { io } from 'socket.io-client'
import { EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api'
import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
import { toDashboardAppPath } from '@/lib/dashboardPaths'
@@ -52,11 +52,6 @@ export default function TopBar() {
const [mounted, setMounted] = useState(false)
useEffect(() => { setMounted(true) }, [])
function getEmployeeToken() {
if (typeof window === 'undefined') return null
return window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
}
const title = (() => {
if (!mounted) return dict.titles['/']
if (dict.titles[appPath]) return dict.titles[appPath]
@@ -65,15 +60,12 @@ export default function TopBar() {
return dict.titles['/']
})()
async function refreshUnreadCount() {
if (!getEmployeeToken()) {
setUnreadCount(0)
return
}
try {
const data = await apiFetch<{ unread: number }>('/notifications/unread-count')
setUnreadCount(data.unread)
} catch {}
} catch {
setUnreadCount(0)
}
}
useEffect(() => {
@@ -89,15 +81,12 @@ export default function TopBar() {
}, [])
useEffect(() => {
const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
if (!token) return
const socketOrigin = resolveSocketOrigin()
if (!socketOrigin) return
const socket = io(socketOrigin, {
autoConnect: false,
auth: { token },
withCredentials: true,
transports: ['polling', 'websocket'],
})
@@ -121,11 +110,6 @@ export default function TopBar() {
useEffect(() => {
if (!showNotifs) return
if (!getEmployeeToken()) {
setNotifications([])
setLoadingNotifs(false)
return
}
let cancelled = false
setLoadingNotifs(true)
@@ -157,12 +141,6 @@ export default function TopBar() {
}, [pathname])
useEffect(() => {
const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
if (!token) {
setUserInitials(computeInitials(dict.workspaceUser))
return
}
const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY)
if (cached) {
try {
@@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest'
import {
buildInspectionSavePayload,
comparisonPointsForCheckout,
editableDamagePointsForInspection,
pointKey,
type DamageInspection,
type DamagePoint,
} from './DamageInspectionCard'
function point(overrides: Partial<DamagePoint> = {}): DamagePoint {
return {
viewType: 'TOP',
x: 10,
y: 20,
damageType: 'SCRATCH',
severity: 'MINOR',
description: null,
isPreExisting: false,
...overrides,
}
}
function inspection(overrides: Partial<DamageInspection> = {}): DamageInspection {
return {
id: 'inspection-1',
type: 'CHECKOUT',
mileage: 45000,
fuelLevel: 'FULL',
fuelCharge: null,
generalCondition: 'Clean',
employeeNotes: 'No issues',
customerAgreed: true,
damagePoints: [],
...overrides,
}
}
describe('DamageInspectionCard helpers', () => {
it('builds stable duplicate keys using rounded coordinates and damage metadata', () => {
expect(pointKey(point({ x: 12.3456, y: 78.994 }))).toBe('TOP:12.35:78.99:SCRATCH:MINOR')
expect(pointKey(point({ viewType: 'LEFT', damageType: 'DENT', severity: 'MAJOR' }))).toBe('LEFT:10.00:20.00:DENT:MAJOR')
})
it('deduplicates checkout comparison points against stored pre-existing points', () => {
const duplicate = point({ id: 'cmp-1', isPreExisting: true, description: 'old scratch' })
const storedDuplicate = point({ id: 'stored-1', isPreExisting: true, description: 'stored copy' })
const storedUnique = point({ id: 'stored-2', viewType: 'REAR', x: 33, y: 44, isPreExisting: true })
const result = comparisonPointsForCheckout(
inspection({ damagePoints: [storedDuplicate, storedUnique] }),
[duplicate]
)
expect(result).toEqual([duplicate, storedUnique])
})
it('keeps checkout editing focused on new damage while check-in exposes all points', () => {
const oldDamage = point({ id: 'old', isPreExisting: true })
const newDamage = point({ id: 'new', isPreExisting: false })
const record = inspection({ damagePoints: [oldDamage, newDamage] })
expect(editableDamagePointsForInspection('CHECKOUT', record)).toEqual([newDamage])
expect(editableDamagePointsForInspection('CHECKIN', record)).toEqual([oldDamage, newDamage])
})
it('builds compact save payloads and omits nullable/empty fields', () => {
const payload = buildInspectionSavePayload(
inspection({
mileage: null,
fuelCharge: null,
generalCondition: '',
employeeNotes: '',
customerAgreed: false,
damagePoints: [point({ description: '' })],
}),
[point({ id: 'pre', viewType: 'FRONT', description: 'pre-existing chip', isPreExisting: true })]
)
expect(payload).toEqual({
fuelLevel: 'FULL',
customerAgreed: false,
damagePoints: [
{
viewType: 'FRONT',
x: 10,
y: 20,
damageType: 'SCRATCH',
severity: 'MINOR',
description: 'pre-existing chip',
isPreExisting: true,
},
{
viewType: 'TOP',
x: 10,
y: 20,
damageType: 'SCRATCH',
severity: 'MINOR',
isPreExisting: false,
},
],
})
})
})
@@ -41,10 +41,54 @@ const severityColors: Record<DamageSeverity, string> = {
MAJOR: '#dc2626',
}
function pointKey(point: Pick<DamagePoint, 'viewType' | 'x' | 'y' | 'damageType' | 'severity'>) {
export function pointKey(point: Pick<DamagePoint, 'viewType' | 'x' | 'y' | 'damageType' | 'severity'>) {
return `${point.viewType}:${point.x.toFixed(2)}:${point.y.toFixed(2)}:${point.damageType}:${point.severity}`
}
export function comparisonPointsForCheckout(initialInspection: DamageInspection | null | undefined, comparisonPoints: DamagePoint[]): DamagePoint[] {
const storedPreExistingPoints = initialInspection?.damagePoints?.filter((point) => point.isPreExisting) ?? []
return [...comparisonPoints, ...storedPreExistingPoints].filter((point, index, all) => all.findIndex((item) => pointKey(item) === pointKey(point)) === index)
}
export function editableDamagePointsForInspection(type: InspectionType, initialInspection: DamageInspection | null | undefined): DamagePoint[] {
const points = initialInspection?.damagePoints ?? []
return type === 'CHECKOUT' ? points.filter((point) => !point.isPreExisting) : points
}
export function buildInspectionSavePayload(inspection: DamageInspection, overlayComparisonPoints: DamagePoint[]) {
const payloadPoints = [
...overlayComparisonPoints.map(({ viewType, x, y, damageType, severity, description }) => ({
viewType,
x,
y,
damageType,
severity,
...(description ? { description } : {}),
isPreExisting: true,
})),
...inspection.damagePoints.map(({ viewType, x, y, damageType, severity, description, isPreExisting }) => ({
viewType,
x,
y,
damageType,
severity,
...(description ? { description } : {}),
isPreExisting,
})),
]
return {
...(inspection.mileage !== null ? { mileage: inspection.mileage } : {}),
fuelLevel: inspection.fuelLevel,
...(inspection.fuelCharge !== null ? { fuelCharge: inspection.fuelCharge } : {}),
...(inspection.generalCondition ? { generalCondition: inspection.generalCondition } : {}),
...(inspection.employeeNotes ? { employeeNotes: inspection.employeeNotes } : {}),
customerAgreed: inspection.customerAgreed,
damagePoints: payloadPoints,
}
}
export default function DamageInspectionCard({
reservationId,
type,
@@ -64,11 +108,8 @@ export default function DamageInspectionCard({
}) {
const { dict } = useDashboardI18n()
const r = dict.reservations
const storedPreExistingPoints = type === 'CHECKOUT'
? (initialInspection?.damagePoints ?? []).filter((point) => point.isPreExisting)
: []
const overlayComparisonPoints = type === 'CHECKOUT'
? [...comparisonPoints, ...storedPreExistingPoints].filter((point, index, all) => all.findIndex((item) => pointKey(item) === pointKey(point)) === index)
? comparisonPointsForCheckout(initialInspection, comparisonPoints)
: []
const [inspection, setInspection] = useState<DamageInspection>({
@@ -80,9 +121,7 @@ export default function DamageInspectionCard({
generalCondition: initialInspection?.generalCondition ?? '',
employeeNotes: initialInspection?.employeeNotes ?? '',
customerAgreed: initialInspection?.customerAgreed ?? false,
damagePoints: type === 'CHECKOUT'
? (initialInspection?.damagePoints ?? []).filter((point) => !point.isPreExisting)
: (initialInspection?.damagePoints ?? []),
damagePoints: editableDamagePointsForInspection(type, initialInspection),
})
const [damageType, setDamageType] = useState<DamageType>('SCRATCH')
const [severity, setSeverity] = useState<DamageSeverity>('MINOR')
@@ -93,36 +132,7 @@ export default function DamageInspectionCard({
setSaving(true)
setError(null)
try {
const payloadPoints = [
...overlayComparisonPoints.map(({ viewType, x, y, damageType, severity, description }) => ({
viewType,
x,
y,
damageType,
severity,
...(description ? { description } : {}),
isPreExisting: true,
})),
...inspection.damagePoints.map(({ viewType, x, y, damageType, severity, description, isPreExisting }) => ({
viewType,
x,
y,
damageType,
severity,
...(description ? { description } : {}),
isPreExisting,
})),
]
const payload = {
...(inspection.mileage !== null ? { mileage: inspection.mileage } : {}),
fuelLevel: inspection.fuelLevel,
...(inspection.fuelCharge !== null ? { fuelCharge: inspection.fuelCharge } : {}),
...(inspection.generalCondition ? { generalCondition: inspection.generalCondition } : {}),
...(inspection.employeeNotes ? { employeeNotes: inspection.employeeNotes } : {}),
customerAgreed: inspection.customerAgreed,
damagePoints: payloadPoints,
}
const payload = buildInspectionSavePayload(inspection, overlayComparisonPoints)
const result = await apiFetch<DamageInspection>(`/reservations/${reservationId}/inspections/${type.toLowerCase()}`, {
method: 'PUT',
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import {
SHEET_HEIGHT,
SHEET_WIDTH,
resolvePointPosition,
resolveVehicleSheetClick,
severityColors,
viewBoxes,
type VehicleSheetPoint,
} from './VehicleConditionSheet'
const rect = { left: 10, top: 20, width: SHEET_WIDTH * 2, height: SHEET_HEIGHT * 2 } as Pick<DOMRect, 'left' | 'top' | 'width' | 'height'>
function sheetClientPoint(x: number, y: number) {
return {
clientX: rect.left + (x / SHEET_WIDTH) * rect.width,
clientY: rect.top + (y / SHEET_HEIGHT) * rect.height,
}
}
describe('VehicleConditionSheet geometry helpers', () => {
it('maps percentage positions into absolute sheet coordinates per view', () => {
expect(resolvePointPosition({ viewType: 'TOP', x: 50, y: 50, severity: 'MINOR' })).toEqual({
cx: viewBoxes.TOP.x + viewBoxes.TOP.w / 2,
cy: viewBoxes.TOP.y + viewBoxes.TOP.h / 2,
})
expect(resolvePointPosition({ viewType: 'LEFT', x: 0, y: 100, severity: 'MAJOR' })).toEqual({
cx: viewBoxes.LEFT.x,
cy: viewBoxes.LEFT.y + viewBoxes.LEFT.h,
})
})
it('defaults missing viewType to TOP for legacy damage points', () => {
const legacyPoint = { x: 25, y: 10, severity: 'MODERATE' } satisfies VehicleSheetPoint
expect(resolvePointPosition(legacyPoint)).toEqual({
cx: viewBoxes.TOP.x + viewBoxes.TOP.w * 0.25,
cy: viewBoxes.TOP.y + viewBoxes.TOP.h * 0.1,
})
})
it('resolves clicks inside each vehicle region to local percentages', () => {
const topCenter = sheetClientPoint(viewBoxes.TOP.x + viewBoxes.TOP.w / 2, viewBoxes.TOP.y + viewBoxes.TOP.h / 2)
const frontCorner = sheetClientPoint(viewBoxes.FRONT.x, viewBoxes.FRONT.y)
const rearCorner = sheetClientPoint(viewBoxes.REAR.x + viewBoxes.REAR.w, viewBoxes.REAR.y + viewBoxes.REAR.h)
expect(resolveVehicleSheetClick(topCenter.clientX, topCenter.clientY, rect)).toEqual({ viewType: 'TOP', x: 50, y: 50 })
expect(resolveVehicleSheetClick(frontCorner.clientX, frontCorner.clientY, rect)).toEqual({ viewType: 'FRONT', x: 0, y: 0 })
expect(resolveVehicleSheetClick(rearCorner.clientX, rearCorner.clientY, rect)).toEqual({ viewType: 'REAR', x: 100, y: 100 })
})
it('rejects clicks outside vehicle regions instead of creating impossible damage points', () => {
const outside = sheetClientPoint(40, 40)
expect(resolveVehicleSheetClick(outside.clientX, outside.clientY, rect)).toBeNull()
})
it('keeps severity color mapping explicit for rendering and comparison markers', () => {
expect(severityColors).toEqual({
MINOR: '#f59e0b',
MODERATE: '#f97316',
MAJOR: '#dc2626',
})
})
})
@@ -9,17 +9,17 @@ export type VehicleSheetPoint = {
severity: 'MINOR' | 'MODERATE' | 'MAJOR'
}
const SHEET_WIDTH = 573
const SHEET_HEIGHT = 876
export const SHEET_WIDTH = 573
export const SHEET_HEIGHT = 876
const SHEET_IMAGE_PATH = '/dashboard/vehicle-condition-template.png'
const severityColors: Record<VehicleSheetPoint['severity'], string> = {
export const severityColors: Record<VehicleSheetPoint['severity'], string> = {
MINOR: '#f59e0b',
MODERATE: '#f97316',
MAJOR: '#dc2626',
}
const viewBoxes: Record<VehicleSheetView, { x: number; y: number; w: number; h: number }> = {
export const viewBoxes: Record<VehicleSheetView, { x: number; y: number; w: number; h: number }> = {
FRONT: { x: 161, y: 4, w: 251, h: 108 },
TOP: { x: 150, y: 112, w: 273, h: 613 },
REAR: { x: 156, y: 724, w: 252, h: 123 },
@@ -27,7 +27,7 @@ const viewBoxes: Record<VehicleSheetView, { x: number; y: number; w: number; h:
RIGHT: { x: 423, y: 114, w: 146, h: 618 },
}
function resolvePointPosition(point: VehicleSheetPoint) {
export function resolvePointPosition(point: VehicleSheetPoint) {
const viewType = point.viewType ?? 'TOP'
const box = viewBoxes[viewType]
@@ -37,6 +37,25 @@ function resolvePointPosition(point: VehicleSheetPoint) {
}
}
export function resolveVehicleSheetClick(clientX: number, clientY: number, rect: Pick<DOMRect, 'left' | 'top' | 'width' | 'height'>): { viewType: VehicleSheetView; x: number; y: number } | null {
const x = ((clientX - rect.left) / rect.width) * SHEET_WIDTH
const y = ((clientY - rect.top) / rect.height) * SHEET_HEIGHT
const target = (Object.entries(viewBoxes) as Array<[VehicleSheetView, { x: number; y: number; w: number; h: number }]>).find(([, box]) =>
x >= box.x && x <= box.x + box.w && y >= box.y && y <= box.y + box.h
)
if (!target) return null
const [viewType, box] = target
return {
viewType,
x: ((x - box.x) / box.w) * 100,
y: ((y - box.y) / box.h) * 100,
}
}
function renderMarker(point: VehicleSheetPoint, index: number, variant: 'current' | 'comparison') {
const { cx, cy } = resolvePointPosition(point)
const color = severityColors[point.severity]
@@ -79,20 +98,10 @@ export default function VehicleConditionSheet({
function handleClick(event: React.MouseEvent<SVGSVGElement>) {
if (!interactive || !onAddPoint) return
const rect = event.currentTarget.getBoundingClientRect()
const x = ((event.clientX - rect.left) / rect.width) * SHEET_WIDTH
const y = ((event.clientY - rect.top) / rect.height) * SHEET_HEIGHT
const resolved = resolveVehicleSheetClick(event.clientX, event.clientY, event.currentTarget.getBoundingClientRect())
if (!resolved) return
const target = (Object.entries(viewBoxes) as Array<[VehicleSheetView, { x: number; y: number; w: number; h: number }]>)
.find(([, box]) => x >= box.x && x <= box.x + box.w && y >= box.y && y <= box.y + box.h)
if (!target) return
const [viewType, box] = target
const localX = ((x - box.x) / box.w) * 100
const localY = ((y - box.y) / box.h) * 100
onAddPoint(viewType, localX, localY)
onAddPoint(resolved.viewType, resolved.x, resolved.y)
}
return (
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { bilingualPrimary, emptyBilingual } from './BilingualInput'
describe('bilingual field helpers', () => {
it('returns a stable empty field shape for French and Arabic labels', () => {
expect(emptyBilingual()).toEqual({ fr: '', ar: '' })
expect(emptyBilingual()).not.toBe(emptyBilingual())
})
it('prefers French as the primary display value when both translations exist', () => {
expect(bilingualPrimary({ fr: 'Assurance', ar: 'تأمين' })).toBe('Assurance')
})
it('falls back to Arabic when the French value is empty', () => {
expect(bilingualPrimary({ fr: '', ar: 'تأمين' })).toBe('تأمين')
})
it('does not trim values implicitly so form state remains lossless', () => {
expect(bilingualPrimary({ fr: ' Service premium ', ar: 'خدمة' })).toBe(' Service premium ')
expect(bilingualPrimary({ fr: '', ar: ' خدمة ' })).toBe(' خدمة ')
})
})
@@ -0,0 +1,72 @@
import React, { isValidElement } from 'react'
import { describe, expect, it } from 'vitest'
import StatCard from './StatCard'
import type { LucideIcon } from 'lucide-react'
function collectText(node: unknown): string[] {
if (node === null || node === undefined || typeof node === 'boolean') return []
if (typeof node === 'string' || typeof node === 'number') return [String(node)]
if (Array.isArray(node)) return node.flatMap(collectText)
if (isValidElement<{ children?: React.ReactNode }>(node)) return collectText(node.props.children)
return []
}
function collectElements(node: unknown): React.ReactElement[] {
if (node === null || node === undefined || typeof node === 'boolean') return []
if (Array.isArray(node)) return node.flatMap(collectElements)
if (!isValidElement<{ children?: React.ReactNode }>(node)) return []
return [node, ...collectElements(node.props.children)]
}
function FakeIcon(props: { className?: string }) {
return React.createElement('svg', props)
}
const icon = FakeIcon as unknown as LucideIcon
describe('StatCard', () => {
it('renders title, numeric value, and positive changes with a plus sign', () => {
const element = StatCard({ title: 'Revenue', value: 12450, change: 8.238, icon })
const text = collectText(element)
const renderedText = text.join('')
expect(text).toContain('Revenue')
expect(text).toContain('12450')
expect(renderedText).toContain('+8.2%')
expect(text).toContain('vs last month')
})
it('renders negative changes without forcing a positive marker', () => {
const element = StatCard({ title: 'Cancellations', value: '12', change: -3.15, vsLastMonthLabel: 'vs previous period', icon })
const text = collectText(element)
const renderedText = text.join('')
expect(renderedText).toContain('-3.1%')
expect(text).toContain('vs previous period')
expect(renderedText).not.toContain('+-3.1')
})
it('omits the trend row when no change is provided', () => {
const element = StatCard({ title: 'Active cars', value: 31, icon })
const text = collectText(element)
expect(text).toContain('Active cars')
expect(text).toContain('31')
expect(text).not.toContain('vs last month')
expect(text.join('')).not.toContain('%')
})
it('passes custom icon color and background classes to the rendered icon shell', () => {
const element = StatCard({
title: 'Fleet',
value: 9,
icon,
iconColor: 'text-violet-700',
iconBg: 'bg-violet-50',
})
const elements = collectElements(element)
expect(elements.some((node) => String(node.props.className ?? '').includes('bg-violet-50'))).toBe(true)
expect(elements.some((node) => String(node.props.className ?? '').includes('text-violet-700'))).toBe(true)
})
})
@@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest'
import {
appendInvitedMember,
applyActivationStats,
applyMemberActivation,
applyMemberRoleUpdate,
incrementInvitedStats,
removeTeamMemberState,
type TeamMember,
type TeamStats,
} from './useTeam'
function member(overrides: Partial<TeamMember>): TeamMember {
return {
id: 'member-1',
firstName: 'Amina',
lastName: 'Bennis',
email: 'amina@example.com',
phone: null,
role: 'AGENT',
isActive: true,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
lastActiveAt: null,
invitationStatus: 'accepted',
...overrides,
}
}
const stats: TeamStats = { total: 3, active: 2, pending: 1, inactive: 0 }
describe('useTeam state helpers', () => {
it('appends invited employees and increments the pending counters immutably', () => {
const existing = [member({ id: 'owner', role: 'OWNER' })]
const invited = member({ id: 'invite-1', role: 'MANAGER', invitationStatus: 'pending', isActive: false })
expect(appendInvitedMember(existing, invited)).toEqual([...existing, invited])
expect(existing).toHaveLength(1)
expect(incrementInvitedStats(stats)).toEqual({ total: 4, active: 2, pending: 2, inactive: 0 })
})
it('updates roles and activation flags only on the targeted member', () => {
const members = [member({ id: 'a' }), member({ id: 'b', role: 'MANAGER', isActive: false })]
expect(applyMemberRoleUpdate(members, 'a', 'MANAGER')).toEqual([
{ ...members[0], role: 'MANAGER' },
members[1],
])
expect(applyMemberActivation(members, 'b', true)).toEqual([
members[0],
{ ...members[1], isActive: true },
])
})
it('moves counters between active and inactive for deactivate/reactivate actions', () => {
expect(applyActivationStats({ total: 5, active: 4, pending: 0, inactive: 1 }, 'deactivate')).toEqual({
total: 5,
active: 3,
pending: 0,
inactive: 2,
})
expect(applyActivationStats({ total: 5, active: 3, pending: 0, inactive: 2 }, 'reactivate')).toEqual({
total: 5,
active: 4,
pending: 0,
inactive: 1,
})
})
it('removes active, pending, and inactive accepted members with the correct counter impact', () => {
const members = [
member({ id: 'active', isActive: true, invitationStatus: 'accepted' }),
member({ id: 'pending', isActive: false, invitationStatus: 'pending' }),
member({ id: 'inactive', isActive: false, invitationStatus: 'accepted' }),
]
expect(removeTeamMemberState(members, { total: 3, active: 1, pending: 1, inactive: 1 }, 'active').stats).toEqual({
total: 2,
active: 0,
pending: 1,
inactive: 1,
})
expect(removeTeamMemberState(members, { total: 3, active: 1, pending: 1, inactive: 1 }, 'pending').stats).toEqual({
total: 2,
active: 1,
pending: 0,
inactive: 1,
})
expect(removeTeamMemberState(members, { total: 3, active: 1, pending: 1, inactive: 1 }, 'inactive').stats).toEqual({
total: 2,
active: 1,
pending: 1,
inactive: 0,
})
})
it('leaves counters untouched when asked to remove an unknown member', () => {
const members = [member({ id: 'only' })]
const result = removeTeamMemberState(members, { total: 1, active: 1, pending: 0, inactive: 0 }, 'missing')
expect(result.members).toEqual(members)
expect(result.stats).toEqual({ total: 1, active: 1, pending: 0, inactive: 0 })
})
})
+51 -29
View File
@@ -34,6 +34,46 @@ export interface InvitePayload {
role: 'MANAGER' | 'AGENT'
}
export function appendInvitedMember(members: TeamMember[], employee: TeamMember): TeamMember[] {
return [...members, employee]
}
export function incrementInvitedStats(stats: TeamStats): TeamStats {
return { ...stats, total: stats.total + 1, pending: stats.pending + 1 }
}
export function applyMemberRoleUpdate(members: TeamMember[], memberId: string, role: 'MANAGER' | 'AGENT'): TeamMember[] {
return members.map((member) => (member.id === memberId ? { ...member, role } : member))
}
export function applyMemberActivation(members: TeamMember[], memberId: string, isActive: boolean): TeamMember[] {
return members.map((member) => (member.id === memberId ? { ...member, isActive } : member))
}
export function applyActivationStats(stats: TeamStats, direction: 'deactivate' | 'reactivate'): TeamStats {
return direction === 'deactivate'
? { ...stats, active: stats.active - 1, inactive: stats.inactive + 1 }
: { ...stats, active: stats.active + 1, inactive: stats.inactive - 1 }
}
export function removeTeamMemberState(members: TeamMember[], stats: TeamStats, memberId: string): { members: TeamMember[]; stats: TeamStats } {
const target = members.find((member) => member.id === memberId)
return {
members: members.filter((member) => member.id !== memberId),
stats: {
...stats,
total: stats.total - (target ? 1 : 0),
active: target?.isActive ? stats.active - 1 : stats.active,
pending: target?.invitationStatus === 'pending' ? stats.pending - 1 : stats.pending,
inactive: !target?.isActive && target?.invitationStatus === 'accepted'
? stats.inactive - 1
: stats.inactive,
},
}
}
export function useTeam() {
const [members, setMembers] = useState<TeamMember[]>([])
const [stats, setStats] = useState<TeamStats>({ total: 0, active: 0, pending: 0, inactive: 0 })
@@ -64,12 +104,8 @@ export function useTeam() {
'/team/invite',
{ method: 'POST', body: JSON.stringify(payload) }
)
setMembers((prev) => [...prev, result.employee])
setStats((prev) => ({
...prev,
total: prev.total + 1,
pending: prev.pending + 1,
}))
setMembers((prev) => appendInvitedMember(prev, result.employee))
setStats(incrementInvitedStats)
return result
}, [])
@@ -78,9 +114,7 @@ export function useTeam() {
method: 'PATCH',
body: JSON.stringify({ role }),
})
setMembers((prev) =>
prev.map((m) => (m.id === memberId ? { ...m, role: updated.role } : m))
)
setMembers((prev) => applyMemberRoleUpdate(prev, memberId, updated.role as 'MANAGER' | 'AGENT'))
return updated
}, [])
@@ -88,10 +122,8 @@ export function useTeam() {
const updated = await apiFetch<TeamMember>(`/team/${memberId}/deactivate`, {
method: 'POST',
})
setMembers((prev) =>
prev.map((m) => (m.id === memberId ? { ...m, isActive: false } : m))
)
setStats((prev) => ({ ...prev, active: prev.active - 1, inactive: prev.inactive + 1 }))
setMembers((prev) => applyMemberActivation(prev, memberId, false))
setStats((prev) => applyActivationStats(prev, 'deactivate'))
return updated
}, [])
@@ -99,10 +131,8 @@ export function useTeam() {
const updated = await apiFetch<TeamMember>(`/team/${memberId}/reactivate`, {
method: 'POST',
})
setMembers((prev) =>
prev.map((m) => (m.id === memberId ? { ...m, isActive: true } : m))
)
setStats((prev) => ({ ...prev, active: prev.active + 1, inactive: prev.inactive - 1 }))
setMembers((prev) => applyMemberActivation(prev, memberId, true))
setStats((prev) => applyActivationStats(prev, 'reactivate'))
return updated
}, [])
@@ -110,18 +140,10 @@ export function useTeam() {
await apiFetch<{ success: boolean }>(`/team/${memberId}`, {
method: 'DELETE',
})
const target = members.find((m) => m.id === memberId)
setMembers((prev) => prev.filter((m) => m.id !== memberId))
setStats((prev) => ({
...prev,
total: prev.total - 1,
active: target?.isActive ? prev.active - 1 : prev.active,
pending: target?.invitationStatus === 'pending' ? prev.pending - 1 : prev.pending,
inactive: !target?.isActive && target?.invitationStatus === 'accepted'
? prev.inactive - 1
: prev.inactive,
}))
}, [members])
const nextState = removeTeamMemberState(members, stats, memberId)
setMembers(nextState.members)
setStats(nextState.stats)
}, [members, stats])
return {
members,
+81
View File
@@ -0,0 +1,81 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
function installBrowser(token?: string) {
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
localStorage: {
getItem: vi.fn(() => token ?? null),
},
},
})
}
afterEach(() => {
vi.restoreAllMocks()
Reflect.deleteProperty(globalThis, 'window')
Reflect.deleteProperty(globalThis, 'fetch')
vi.resetModules()
})
describe('dashboard apiFetch', () => {
it('adds JSON headers and sends cookies for browser requests', async () => {
installBrowser()
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({ data: { ok: true } }),
}))
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
const { apiFetch } = await import('./api')
await expect(apiFetch('/team')).resolves.toEqual({ ok: true })
expect(fetchMock).toHaveBeenCalledWith('/dashboard/api/v1/team', expect.objectContaining({
credentials: 'include',
headers: expect.objectContaining({
'Content-Type': 'application/json',
}),
}))
})
it('does not force JSON content type for FormData payloads', async () => {
installBrowser()
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: { uploaded: true } }) }))
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
const { apiFetch } = await import('./api')
await apiFetch('/uploads', { method: 'POST', body: new FormData() })
expect((fetchMock as any).mock.calls[0]?.[1].headers).not.toHaveProperty('Content-Type')
expect((fetchMock as any).mock.calls[0]?.[1]).toEqual(expect.objectContaining({ credentials: 'include' }))
})
it('surfaces API error details on failed responses', async () => {
installBrowser()
Object.defineProperty(globalThis, 'fetch', {
configurable: true,
value: vi.fn(async () => ({
ok: false,
status: 403,
json: async () => ({ message: 'Forbidden', error: 'FORBIDDEN' }),
})),
})
const { apiFetch } = await import('./api')
await expect(apiFetch('/team')).rejects.toMatchObject({ message: 'Forbidden', code: 'FORBIDDEN', statusCode: 403 })
})
it('uses explicit server token and disables caching for server requests', async () => {
Reflect.deleteProperty(globalThis, 'window')
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: { ok: true } }) }))
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
const { apiFetchServer } = await import('./api')
await apiFetchServer('/fleet', 'server-token')
expect(fetchMock).toHaveBeenCalledWith('http://localhost:4000/api/v1/fleet', expect.objectContaining({
cache: 'no-store',
headers: expect.objectContaining({ Authorization: 'Bearer server-token' }),
}))
})
})
-15
View File
@@ -3,20 +3,9 @@ export const API_BASE =
? (process.env.API_INTERNAL_URL || 'http://localhost:4000/api/v1')
: (process.env.NEXT_PUBLIC_API_URL || '/dashboard/api/v1')
export const EMPLOYEE_TOKEN_KEY = 'employee_token'
export const EMPLOYEE_PROFILE_KEY = 'employee_profile'
async function getAuthToken(): Promise<string | null> {
if (typeof window === 'undefined') return null
try {
return localStorage.getItem(EMPLOYEE_TOKEN_KEY)
} catch {
return null
}
}
export async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
const token = await getAuthToken()
const isFormData = typeof FormData !== 'undefined' && options?.body instanceof FormData
const headers: Record<string, string> = {
@@ -27,10 +16,6 @@ export async function apiFetch<T>(path: string, options?: RequestInit): Promise<
headers['Content-Type'] = 'application/json'
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers,
+34
View File
@@ -0,0 +1,34 @@
import { afterEach, describe, expect, it } from 'vitest'
import { resolveBrowserAppUrl } from './appUrls'
function installWindow(hostname: string, protocol = 'https:') {
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { location: { hostname, protocol } },
})
}
afterEach(() => {
Reflect.deleteProperty(globalThis, 'window')
})
describe('dashboard resolveBrowserAppUrl', () => {
it('returns the fallback untouched on the server', () => {
expect(resolveBrowserAppUrl('http://localhost:3001/dashboard')).toBe('http://localhost:3001/dashboard')
})
it('keeps local browser fallbacks local and removes trailing slash', () => {
installWindow('localhost')
expect(resolveBrowserAppUrl('http://localhost:3001/dashboard/')).toBe('http://localhost:3001/dashboard')
})
it('rewrites fallback origin to the current production host', () => {
installWindow('tenant.example.com', 'https:')
expect(resolveBrowserAppUrl('http://localhost:3001/dashboard')).toBe('https://tenant.example.com/dashboard')
})
it('falls back safely when the configured value is not a URL', () => {
installWindow('tenant.example.com')
expect(resolveBrowserAppUrl('/dashboard/')).toBe('/dashboard/')
})
})
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { toDashboardAppPath, toPublicDashboardPath } from './dashboardPaths'
describe('dashboard path normalization', () => {
it('collapses empty and root values to the app root', () => {
expect(toDashboardAppPath()).toBe('/')
expect(toDashboardAppPath('')).toBe('/')
expect(toDashboardAppPath('/')).toBe('/')
})
it('strips repeated dashboard base prefixes', () => {
expect(toDashboardAppPath('/dashboard')).toBe('/')
expect(toDashboardAppPath('/dashboard/fleet')).toBe('/fleet')
expect(toDashboardAppPath('/dashboard/dashboard/reservations')).toBe('/reservations')
})
it('adds the public deployment prefix exactly once', () => {
expect(toPublicDashboardPath('/')).toBe('/dashboard')
expect(toPublicDashboardPath('/dashboard/fleet')).toBe('/dashboard/fleet')
expect(toPublicDashboardPath('customers')).toBe('/dashboard/customers')
})
})
@@ -0,0 +1,96 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
getScopedPreferenceCookieName,
getScopedPreferenceKey,
readCurrentUserScopedPreference,
readScopedPreference,
writeScopedPreference,
} from './preferences'
function installBrowser(cookie = '') {
const store = new Map<string, string>()
let cookieValue = cookie
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
localStorage: {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => store.set(key, value),
removeItem: (key: string) => store.delete(key),
},
},
})
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
get cookie() {
return cookieValue
},
set cookie(value: string) {
const [pair] = value.split(';')
const [name, val] = pair.split('=')
const existing = cookieValue
.split(';')
.map((chunk) => chunk.trim())
.filter(Boolean)
.filter((chunk) => !chunk.startsWith(`${name}=`))
existing.push(`${name}=${val}`)
cookieValue = existing.join('; ')
},
},
})
Object.defineProperty(globalThis, 'atob', {
configurable: true,
value: (value: string) => Buffer.from(value, 'base64').toString('binary'),
})
return { store, get cookie() { return cookieValue } }
}
beforeEach(() => {
installBrowser()
})
afterEach(() => {
Reflect.deleteProperty(globalThis, 'window')
Reflect.deleteProperty(globalThis, 'document')
Reflect.deleteProperty(globalThis, 'atob')
})
describe('dashboard scoped preferences', () => {
it('uses base keys when no employee token is available', () => {
expect(getScopedPreferenceKey('theme')).toBe('theme')
expect(getScopedPreferenceCookieName('theme')).toBe('theme')
})
it('prefers scoped cookie/current user values before local storage', () => {
Reflect.deleteProperty(globalThis, 'window')
Reflect.deleteProperty(globalThis, 'document')
const browser = installBrowser('theme=dark')
browser.store.set('theme', 'local-dark')
expect(readCurrentUserScopedPreference('theme')).toBe('dark')
expect(readScopedPreference('theme')).toBe('dark')
})
it('falls back through shared cookie, scoped local storage, base key, and legacy keys', () => {
const browser = installBrowser('theme=dark')
expect(readScopedPreference('theme', ['legacy-theme'])).toBe('dark')
Reflect.deleteProperty(globalThis, 'window')
Reflect.deleteProperty(globalThis, 'document')
const second = installBrowser()
second.store.set('theme', 'base')
second.store.set('legacy-theme', 'legacy')
expect(readScopedPreference('theme', ['legacy-theme'])).toBe('base')
})
it('writes shared and legacy preference values without auth-token scoping', () => {
const browser = installBrowser()
writeScopedPreference('language', 'fr', ['old-language'])
expect(browser.store.get('language')).toBe('fr')
expect(browser.store.get('old-language')).toBe('fr')
expect(browser.cookie).toContain('language=fr')
})
})
+1 -10
View File
@@ -3,16 +3,7 @@ export const SHARED_LANGUAGE_KEY = 'rentaldrivego-language'
export const SHARED_THEME_KEY = 'rentaldrivego-theme'
function readEmployeeToken() {
if (typeof window === 'undefined') return null
const localToken = window.localStorage.getItem('employee_token')
if (localToken) return localToken
return document.cookie
.split(';')
.map((chunk) => chunk.trim())
.find((chunk) => chunk.startsWith('employee_token='))
?.split('=')[1] ?? null
return null
}
function decodeEmployeeId(token: string | null) {
+64
View File
@@ -0,0 +1,64 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
function installWindow(hostname: string, protocol = 'https:') {
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { location: { hostname, protocol } },
})
}
async function loadUrls(env: Record<string, string | undefined> = {}) {
vi.resetModules()
for (const key of ['NEXT_PUBLIC_MARKETPLACE_URL', 'NEXT_PUBLIC_ADMIN_URL']) {
if (env[key] === undefined) {
delete process.env[key]
} else {
process.env[key] = env[key]
}
}
return import('./urls')
}
afterEach(() => {
Reflect.deleteProperty(globalThis, 'window')
delete process.env.NEXT_PUBLIC_MARKETPLACE_URL
delete process.env.NEXT_PUBLIC_ADMIN_URL
vi.resetModules()
})
describe('dashboard cross-app URLs', () => {
it('uses default marketplace and admin bases on the server', async () => {
const { marketplaceUrl, adminUrl } = await loadUrls()
expect(marketplaceUrl).toBe('http://localhost:3000')
expect(adminUrl).toBe('http://localhost:3000/admin')
})
it('strips trailing slashes from configured absolute URLs', async () => {
const { marketplaceUrl, adminUrl } = await loadUrls({
NEXT_PUBLIC_MARKETPLACE_URL: 'https://market.example.com/',
NEXT_PUBLIC_ADMIN_URL: 'https://ops.example.com/admin/',
})
expect(marketplaceUrl).toBe('https://market.example.com')
expect(adminUrl).toBe('https://ops.example.com/admin')
})
it('rewrites local browser fallbacks onto the current production host', async () => {
installWindow('tenant.rentaldrivego.com', 'https:')
const { marketplaceUrl, adminUrl } = await loadUrls()
expect(marketplaceUrl).toBe('https://tenant.rentaldrivego.com')
expect(adminUrl).toBe('https://tenant.rentaldrivego.com/admin')
})
it('preserves non-URL values after normalizing their trailing slash', async () => {
const { marketplaceUrl, adminUrl } = await loadUrls({
NEXT_PUBLIC_MARKETPLACE_URL: '/marketplace/',
NEXT_PUBLIC_ADMIN_URL: '/admin/',
})
expect(marketplaceUrl).toBe('/marketplace')
expect(adminUrl).toBe('/admin')
})
})
+127
View File
@@ -0,0 +1,127 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const nextServer = vi.hoisted(() => {
const redirect = vi.fn((url: URL) => ({ kind: 'redirect', url: url.toString() }))
const next = vi.fn(() => ({ kind: 'next' }))
const MockNextResponse = vi.fn((body?: string, init?: { status?: number }) => ({
kind: 'response',
body,
status: init?.status,
})) as any
MockNextResponse.redirect = redirect
MockNextResponse.next = next
return { redirect, next, MockNextResponse }
})
vi.mock('next/server', () => ({
NextResponse: nextServer.MockNextResponse,
}))
function cloneableUrl(input: string): URL {
const url = new URL(input)
;(url as URL & { clone: () => URL }).clone = () => cloneableUrl(url.toString())
return url
}
function request(input: string, options: { token?: string; headers?: Record<string, string> } = {}) {
const headers = new Map(Object.entries(options.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]))
return {
nextUrl: cloneableUrl(input),
cookies: {
get: vi.fn((name: string) => (name === 'employee_session' && options.token ? { value: options.token } : undefined)),
},
headers: {
get: vi.fn((name: string) => headers.get(name.toLowerCase()) ?? null),
has: vi.fn((name: string) => headers.has(name.toLowerCase())),
},
}
}
async function loadMiddleware(marketplaceUrl = 'https://market.example.com') {
vi.resetModules()
process.env.NEXT_PUBLIC_MARKETPLACE_URL = marketplaceUrl
return import('./middleware')
}
beforeEach(() => {
nextServer.redirect.mockClear()
nextServer.next.mockClear()
})
afterEach(() => {
delete process.env.NEXT_PUBLIC_MARKETPLACE_URL
vi.resetModules()
})
describe('dashboard middleware', () => {
it('rejects middleware subrequest headers at the app layer', async () => {
const { default: middleware } = await loadMiddleware()
const response = middleware(request('https://workspace.example.com/dashboard', {
headers: { 'x-middleware-subrequest': 'middleware:middleware' },
}) as never)
expect(response).toEqual({ kind: 'response', body: 'Unsupported internal request header', status: 400 })
})
it('deduplicates accidental repeated /dashboard prefixes before auth handling', async () => {
const { default: middleware } = await loadMiddleware()
const response = middleware(request('https://workspace.example.com/dashboard/dashboard/fleet') as never)
expect(response).toEqual({ kind: 'redirect', url: 'https://workspace.example.com/dashboard/fleet' })
expect(nextServer.redirect).toHaveBeenCalledTimes(1)
expect(nextServer.next).not.toHaveBeenCalled()
})
it('redirects unauthenticated protected internal-host requests to dashboard sign-in on the resolved origin', async () => {
const { default: middleware } = await loadMiddleware('https://rentaldrivego.example')
const response = middleware(request('http://dashboard:3001/dashboard/team') as never)
expect(response).toEqual({ kind: 'redirect', url: 'https://rentaldrivego.example:3001/dashboard/sign-in?redirect=%2Fdashboard%2Fteam' })
})
it('uses trusted forwarded host/proto when building the dashboard sign-in redirect', async () => {
const { default: middleware } = await loadMiddleware('https://market.example.com')
const response = middleware(request('http://dashboard:3001/dashboard/billing', {
headers: {
'x-forwarded-host': 'workspace.customer.example',
'x-forwarded-proto': 'https',
},
}) as never)
expect(response).toEqual({ kind: 'redirect', url: 'https://workspace.customer.example:3001/dashboard/sign-in?redirect=%2Fdashboard%2Fbilling' })
})
it('ignores internal forwarded hosts when building the dashboard sign-in redirect', async () => {
const { default: middleware } = await loadMiddleware('https://market.example.com')
const response = middleware(request('http://dashboard:3001/dashboard/fleet', {
headers: {
'x-forwarded-host': 'api:4000',
'x-forwarded-proto': 'https',
},
}) as never)
expect(response).toEqual({ kind: 'redirect', url: 'https://market.example.com:3001/dashboard/sign-in?redirect=%2Fdashboard%2Ffleet' })
})
it('redirects signed-in users away from the sign-in page', async () => {
const { default: middleware } = await loadMiddleware()
const response = middleware(request('https://workspace.example.com/dashboard/sign-in?redirect=/dashboard/fleet', { token: 'employee-token' }) as never)
expect(response).toEqual({ kind: 'redirect', url: 'https://workspace.example.com/dashboard' })
})
it('allows public dashboard auth pages without a token', async () => {
const { default: middleware } = await loadMiddleware()
const response = middleware(request('https://workspace.example.com/dashboard/forgot-password') as never)
expect(response).toEqual({ kind: 'next' })
expect(nextServer.next).toHaveBeenCalledTimes(1)
})
})
+10 -1
View File
@@ -4,6 +4,12 @@ import type { NextRequest } from 'next/server'
const PUBLIC_DASHBOARD_PREFIXES = ['/dashboard/sign-in', '/dashboard/forgot-password']
const MARKETPLACE_URL = process.env.NEXT_PUBLIC_MARKETPLACE_URL ?? 'http://localhost:3000'
function rejectInternalSubrequest(req: NextRequest): NextResponse | null {
if (!req.headers.has('x-middleware-subrequest')) return null
return new NextResponse('Unsupported internal request header', { status: 400 })
}
function dedupeDashboardPath(pathname: string): string {
return pathname.replace(/^\/dashboard\/dashboard(?=\/|$)/, '/dashboard')
}
@@ -21,7 +27,7 @@ function isProtectedRoute(req: NextRequest) {
}
function localJwtMiddleware(req: NextRequest): NextResponse {
const token = req.cookies.get('employee_token')?.value
const token = req.cookies.get('employee_session')?.value
if (token && req.nextUrl.pathname === '/dashboard/sign-in') {
const dashboardUrl = req.nextUrl.clone()
dashboardUrl.pathname = '/dashboard'
@@ -54,6 +60,9 @@ function localJwtMiddleware(req: NextRequest): NextResponse {
}
export default function middleware(req: NextRequest) {
const rejected = rejectInternalSubrequest(req)
if (rejected) return rejected
const dedupedPath = dedupeDashboardPath(req.nextUrl.pathname)
if (dedupedPath !== req.nextUrl.pathname) {
const redirectUrl = req.nextUrl.clone()