fix architecture and write new tests
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user