admin login fixed.
Build & Push / Pipeline Tests (push) Failing after 1m34s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 56s
Test / API Unit Tests (push) Successful in 1m8s
Test / Homepage Unit Tests (push) Successful in 47s
Test / Carplace Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 46s
Test / Dashboard Unit Tests (push) Failing after 43s
Test / API Integration Tests (push) Successful in 1m8s

This commit is contained in:
root
2026-07-28 22:56:18 -04:00
parent 5649ab02c7
commit 50c74b9007
29 changed files with 1155 additions and 118 deletions
@@ -212,11 +212,12 @@ const draftLineItem = (): DraftLineItem => ({
})
function money(amount: number, currency: string) {
const safeAmount = Number.isFinite(amount) ? amount : 0
return new Intl.NumberFormat('en-GB', {
style: 'currency',
currency,
minimumFractionDigits: 2,
}).format(amount / 100)
}).format(safeAmount / 100)
}
function dateLabel(value: string | null | undefined) {
@@ -207,6 +207,17 @@ const STATUS_COLORS: Record<string, string> = {
const INPUT_CLASS = 'mt-1 w-full rounded-xl border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-emerald-500'
const LABEL_CLASS = 'text-xs font-medium uppercase tracking-wide text-zinc-500'
const COMPANY_TABS = [
{ id: 'company', label: 'Company' },
{ id: 'subscription', label: 'Subscription' },
{ id: 'publicProfile', label: 'Public profile' },
{ id: 'legalProfile', label: 'Legal profile' },
{ id: 'operations', label: 'Operations & finance' },
{ id: 'activity', label: 'Activity' },
] as const
type CompanyTabId = (typeof COMPANY_TABS)[number]['id']
function toDateInput(value: string | null | undefined) {
return value ? new Date(value).toISOString().slice(0, 10) : ''
}
@@ -318,6 +329,7 @@ export default function AdminCompanyDetailPage() {
const [saving, setSaving] = useState(false)
const [savedMessage, setSavedMessage] = useState<string | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState(false)
const [activeTab, setActiveTab] = useState<CompanyTabId>('company')
async function fetchData() {
try {
@@ -566,8 +578,40 @@ export default function AdminCompanyDetailPage() {
))}
</div>
<div className="grid gap-6 xl:grid-cols-2">
<section className="panel p-6">
<div className="space-y-6">
<div className="overflow-x-auto border-b border-zinc-800">
<div className="flex min-w-max gap-1" role="tablist" aria-label="Company detail subjects">
{COMPANY_TABS.map((tab) => {
const selected = activeTab === tab.id
return (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={selected}
aria-controls={`company-tab-${tab.id}`}
id={`company-tab-trigger-${tab.id}`}
onClick={() => setActiveTab(tab.id)}
className={`rounded-t-xl px-4 py-3 text-sm font-semibold transition-colors ${
selected
? 'bg-zinc-800 text-white'
: 'text-zinc-500 hover:bg-zinc-900 hover:text-zinc-200'
}`}
>
{tab.label}
</button>
)
})}
</div>
</div>
<section
id="company-tab-company"
role="tabpanel"
aria-labelledby="company-tab-trigger-company"
hidden={activeTab !== 'company'}
className="panel p-6"
>
<h2 className="text-base font-semibold">Company</h2>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<label>
@@ -597,7 +641,13 @@ export default function AdminCompanyDetailPage() {
</div>
</section>
<section className="panel p-6">
<section
id="company-tab-subscription"
role="tabpanel"
aria-labelledby="company-tab-trigger-subscription"
hidden={activeTab !== 'subscription'}
className="panel p-6"
>
<h2 className="text-base font-semibold">Subscription</h2>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<label>
@@ -663,7 +713,13 @@ export default function AdminCompanyDetailPage() {
</div>
</section>
<section className="panel p-6">
<section
id="company-tab-publicProfile"
role="tabpanel"
aria-labelledby="company-tab-trigger-publicProfile"
hidden={activeTab !== 'publicProfile'}
className="panel p-6"
>
<h2 className="text-base font-semibold">Brand and public profile</h2>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<label>
@@ -729,7 +785,13 @@ export default function AdminCompanyDetailPage() {
</div>
</section>
<section className="panel p-6">
<section
id="company-tab-legalProfile"
role="tabpanel"
aria-labelledby="company-tab-trigger-legalProfile"
hidden={activeTab !== 'legalProfile'}
className="panel p-6"
>
<h2 className="text-base font-semibold">Company legal profile</h2>
<div className="mt-4 grid gap-6">
<div className="grid gap-4 md:grid-cols-2">
@@ -817,7 +879,13 @@ export default function AdminCompanyDetailPage() {
</div>
</section>
<section className="panel p-6">
<section
id="company-tab-operations"
role="tabpanel"
aria-labelledby="company-tab-trigger-operations"
hidden={activeTab !== 'operations'}
className="panel p-6"
>
<h2 className="text-base font-semibold">Operations and finance</h2>
<div className="mt-4 grid gap-6">
<div className="grid gap-4 md:grid-cols-2">
@@ -915,7 +983,13 @@ export default function AdminCompanyDetailPage() {
</section>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<div
id="company-tab-activity"
role="tabpanel"
aria-labelledby="company-tab-trigger-activity"
hidden={activeTab !== 'activity'}
className="grid gap-6 lg:grid-cols-2"
>
<div className="space-y-4">
<div className="panel p-6 space-y-4">
<h2 className="text-base font-semibold">Company actions</h2>
+3 -4
View File
@@ -13,12 +13,11 @@ import { ADMIN_API_BASE } from '@/lib/api'
import { AdminSessionProvider, type AdminSessionUser } from './AdminSessionContext'
function buildUnifiedLoginUrl(nextPath: string) {
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard')
const websiteUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_WEBSITE_URL ?? 'http://localhost:3000')
const params = new URLSearchParams({
portal: 'admin',
next: nextPath || '/dashboard',
next: `/admin${nextPath || '/dashboard'}`,
})
return `${dashboardUrl}/sign-in?${params.toString()}`
return `${websiteUrl}/en/light/admin-sign-in?${params.toString()}`
}
const navLinks = [
@@ -1,32 +1,14 @@
'use client'
import { useEffect, useState } from 'react'
import { ADMIN_API_BASE } from '@/lib/api'
interface NotificationItem {
id: string
type: string
title: string
body: string
channel: string
status: string
locale: string
sentAt: string | null
createdAt: string
company: { name: string } | null
companyId: string | null
renterId: string | null
}
interface Paginated {
data: NotificationItem[]
total: number
page: number
pageSize: number
}
import {
fetchAdminNotifications,
formatNotificationDate,
type NotificationsPageResult,
} from '@/lib/adminNotifications'
const CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'IN_APP', 'PUSH']
const STATUSES = ['PENDING', 'SENT', 'DELIVERED', 'FAILED', 'READ']
const STATUSES = ['PENDING', 'QUEUED', 'SENT', 'DELIVERED', 'FAILED', 'SKIPPED', 'DEAD_LETTER', 'READ']
const CHANNEL_BADGE: Record<string, string> = {
EMAIL: 'text-sky-400 bg-sky-950/40',
@@ -41,11 +23,26 @@ const STATUS_BADGE: Record<string, string> = {
SENT: 'text-emerald-400 bg-emerald-950/40',
DELIVERED: 'text-emerald-400 bg-emerald-950/40',
FAILED: 'text-red-400 bg-red-950/40',
QUEUED: 'text-sky-400 bg-sky-950/40',
SKIPPED: 'text-zinc-400 bg-zinc-800',
DEAD_LETTER: 'text-red-300 bg-red-950/60',
READ: 'text-zinc-400 bg-zinc-800',
}
function formatRecipient(item: {
recipientType: string | null
recipientName: string | null
recipientEmail: string | null
employeeId: string | null
renterId: string | null
}) {
const fallbackId = item.employeeId ?? item.renterId
const label = item.recipientName || item.recipientEmail || (fallbackId ? `${fallbackId.slice(0, 10)}...` : '-')
return item.recipientType ? `${item.recipientType}: ${label}` : label
}
export default function AdminNotificationsPage() {
const [result, setResult] = useState<Paginated | null>(null)
const [result, setResult] = useState<NotificationsPageResult | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [filterChannel, setFilterChannel] = useState('')
@@ -53,27 +50,30 @@ export default function AdminNotificationsPage() {
const [filterCompany, setFilterCompany] = useState('')
const [page, setPage] = useState(1)
function load(p: number) {
function load(p: number, signal?: AbortSignal) {
setLoading(true)
setError(null)
const params = new URLSearchParams({ page: String(p), pageSize: '50' })
if (filterChannel) params.set('channel', filterChannel)
if (filterStatus) params.set('status', filterStatus)
if (filterCompany) params.set('companyId', filterCompany)
fetch(`${ADMIN_API_BASE}/admin/notifications?${params.toString()}`, {
credentials: 'include',
cache: 'no-store',
})
.then((r) => r.json())
.then((json) => setResult(json.data ?? null))
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
fetchAdminNotifications(
p,
{ channel: filterChannel, status: filterStatus, companyId: filterCompany },
signal,
)
.then((data) => setResult(data))
.catch((err) => {
if (err instanceof DOMException && err.name === 'AbortError') return
setResult(null)
setError(err instanceof Error ? err.message : 'Failed to load notifications')
})
.finally(() => {
if (!signal?.aborted) setLoading(false)
})
}
useEffect(() => {
const controller = new AbortController()
setPage(1)
load(1)
load(1, controller.signal)
return () => controller.abort()
}, [filterChannel, filterStatus, filterCompany])
function goToPage(p: number) {
@@ -81,7 +81,7 @@ export default function AdminNotificationsPage() {
load(p)
}
const totalPages = result ? Math.ceil(result.total / result.pageSize) : 0
const totalPages = result ? (result.totalPages ?? Math.ceil(result.total / result.pageSize)) : 0
return (
<div className="shell py-8 space-y-6">
@@ -118,6 +118,12 @@ export default function AdminNotificationsPage() {
<option value="">All statuses</option>
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<input
value={filterCompany}
onChange={(e) => setFilterCompany(e.target.value)}
placeholder="Company ID"
className="w-64 rounded-xl border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-orange-500"
/>
</div>
{error && <div className="panel p-4 text-sm text-red-400">{error}</div>}
@@ -129,6 +135,7 @@ export default function AdminNotificationsPage() {
<tr className="border-b border-zinc-800">
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Date</th>
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Company</th>
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Recipient</th>
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Event</th>
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Channel</th>
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Title</th>
@@ -138,17 +145,23 @@ export default function AdminNotificationsPage() {
</thead>
<tbody className="divide-y divide-zinc-800/60">
{loading ? (
<tr><td colSpan={7} className="px-5 py-12 text-center text-zinc-500">Loading</td></tr>
<tr><td colSpan={8} className="px-5 py-12 text-center text-zinc-500">Loading</td></tr>
) : !result || result.data.length === 0 ? (
<tr><td colSpan={7} className="px-5 py-12 text-center text-zinc-500">No notifications found.</td></tr>
<tr><td colSpan={8} className="px-5 py-12 text-center text-zinc-500">No notifications found.</td></tr>
) : result.data.map((item) => (
<tr key={item.id} className="hover:bg-zinc-800/30 transition-colors">
<td className="whitespace-nowrap px-5 py-3 text-xs text-zinc-500">
{new Date(item.createdAt).toLocaleString()}
{formatNotificationDate(item.createdAt)}
</td>
<td className="whitespace-nowrap px-5 py-3 text-xs text-zinc-300">
{item.company?.name ?? (item.companyId ? item.companyId.slice(0, 10) + '…' : '—')}
</td>
<td className="max-w-[220px] px-5 py-3 text-xs text-zinc-300">
<p className="truncate">{formatRecipient(item)}</p>
{item.recipientEmail && item.recipientName ? (
<p className="truncate text-zinc-500">{item.recipientEmail}</p>
) : null}
</td>
<td className="whitespace-nowrap px-5 py-3 text-xs font-medium text-zinc-300">
{item.type.replaceAll('_', ' ')}
</td>
@@ -167,7 +180,7 @@ export default function AdminNotificationsPage() {
</span>
</td>
<td className="whitespace-nowrap px-5 py-3 text-xs text-zinc-500">
{item.sentAt ? new Date(item.sentAt).toLocaleString() : '—'}
{formatNotificationDate(item.sentAt)}
</td>
</tr>
))}
+3 -3
View File
@@ -4,10 +4,10 @@ import { resolveServerAppUrl } from '@/lib/appUrls'
export default async function AdminLoginPage() {
const requestHeaders = await headers()
const dashboardUrl = resolveServerAppUrl(
process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard',
const websiteUrl = resolveServerAppUrl(
process.env.NEXT_PUBLIC_WEBSITE_URL ?? 'http://localhost:3000',
requestHeaders.get('host'),
requestHeaders.get('x-forwarded-proto'),
)
redirect(`${dashboardUrl}/sign-in?portal=admin&next=/dashboard`)
redirect(`${websiteUrl}/en/light/admin-sign-in?next=/admin/dashboard`)
}
@@ -0,0 +1,82 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
afterEach(() => {
delete process.env.API_INTERNAL_URL
delete process.env.NEXT_PUBLIC_API_URL
vi.restoreAllMocks()
Reflect.deleteProperty(globalThis, 'fetch')
vi.resetModules()
})
describe('buildNotificationsQuery', () => {
it('builds page, page size, and non-empty filters', async () => {
const { buildNotificationsQuery } = await import('./adminNotifications')
expect(buildNotificationsQuery(3, {
channel: 'EMAIL',
status: 'FAILED',
companyId: ' company_1 ',
}).toString()).toBe('page=3&pageSize=50&channel=EMAIL&status=FAILED&companyId=company_1')
})
it('omits empty filters', async () => {
const { buildNotificationsQuery } = await import('./adminNotifications')
expect(buildNotificationsQuery(1, {
channel: '',
status: '',
companyId: ' ',
}).toString()).toBe('page=1&pageSize=50')
})
})
describe('fetchAdminNotifications', () => {
it('returns the paginated data envelope', async () => {
process.env.API_INTERNAL_URL = 'http://internal-api/api/v1'
const payload = { data: [], total: 0, page: 1, pageSize: 50, totalPages: 0 }
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({ data: payload }),
}))
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
const { fetchAdminNotifications } = await import('./adminNotifications')
await expect(fetchAdminNotifications(1, { status: 'READ' })).resolves.toEqual(payload)
expect(fetchMock).toHaveBeenCalledWith(
'http://internal-api/api/v1/admin/notifications?page=1&pageSize=50&status=READ',
expect.objectContaining({ credentials: 'include', cache: 'no-store' }),
)
})
it('throws API error messages', async () => {
const fetchMock = vi.fn(async () => ({
ok: false,
json: async () => ({ message: 'Support role required' }),
}))
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
const { fetchAdminNotifications } = await import('./adminNotifications')
await expect(fetchAdminNotifications(1)).rejects.toThrow('Support role required')
})
it('throws when the API envelope is malformed', async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({ data: { notifications: [] } }),
}))
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
const { fetchAdminNotifications } = await import('./adminNotifications')
await expect(fetchAdminNotifications(1)).rejects.toThrow('Notifications response was not in the expected format')
})
})
describe('formatNotificationDate', () => {
it('formats missing or invalid dates as a placeholder', async () => {
const { formatNotificationDate } = await import('./adminNotifications')
expect(formatNotificationDate(null)).toBe('-')
expect(formatNotificationDate('not-a-date')).toBe('-')
})
})
+89
View File
@@ -0,0 +1,89 @@
import { ADMIN_API_BASE } from './api'
export interface NotificationItem {
id: string
type: string
title: string
body: string
channel: string
status: string
locale: string
sentAt: string | null
createdAt: string
company: { name: string } | null
companyId: string | null
recipientType: 'EMPLOYEE' | 'RENTER' | null
recipientName: string | null
recipientEmail: string | null
employeeId: string | null
renterId: string | null
}
export interface NotificationsPageResult {
data: NotificationItem[]
total: number
page: number
pageSize: number
totalPages?: number
}
export interface NotificationFilters {
channel?: string
status?: string
companyId?: string
}
function isNotificationsPageResult(value: unknown): value is NotificationsPageResult {
if (!value || typeof value !== 'object') return false
const candidate = value as Partial<NotificationsPageResult>
return (
Array.isArray(candidate.data) &&
typeof candidate.total === 'number' &&
typeof candidate.page === 'number' &&
typeof candidate.pageSize === 'number'
)
}
export function buildNotificationsQuery(page: number, filters: NotificationFilters = {}) {
const params = new URLSearchParams({
page: String(page),
pageSize: '50',
})
if (filters.channel) params.set('channel', filters.channel)
if (filters.status) params.set('status', filters.status)
if (filters.companyId?.trim()) params.set('companyId', filters.companyId.trim())
return params
}
export async function fetchAdminNotifications(
page: number,
filters: NotificationFilters = {},
signal?: AbortSignal,
): Promise<NotificationsPageResult> {
const params = buildNotificationsQuery(page, filters)
const response = await fetch(`${ADMIN_API_BASE}/admin/notifications?${params.toString()}`, {
credentials: 'include',
cache: 'no-store',
signal,
})
const json = await response.json().catch(() => null)
if (!response.ok) {
throw new Error(json?.message ?? 'Failed to load notifications')
}
if (!isNotificationsPageResult(json?.data)) {
throw new Error('Notifications response was not in the expected format')
}
return json.data
}
export function formatNotificationDate(value: string | null) {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '-'
return date.toLocaleString()
}
+6
View File
@@ -31,6 +31,12 @@ describe('admin app URL resolution', () => {
expect(resolveServerAppUrl('http://localhost:3002/admin', 'admin.example.com', 'https')).toBe('https://admin.example.com:3002/admin')
})
it('does not expose internal development hosts in server redirects', () => {
expect(resolveServerAppUrl('http://localhost:3000/dashboard', 'host.docker.internal:3002', 'http')).toBe('http://localhost:3000/dashboard')
expect(resolveServerAppUrl('http://localhost:3000/dashboard', 'admin:3002', 'http')).toBe('http://localhost:3000/dashboard')
expect(resolveServerAppUrl('http://localhost:3000/dashboard', 'localhost:3002', 'http')).toBe('http://localhost:3000/dashboard')
})
it('falls back when host is missing or the fallback is not parseable', () => {
expect(resolveServerAppUrl('http://localhost:3002/admin', null)).toBe('http://localhost:3002/admin')
expect(resolveServerAppUrl('/admin', 'admin.example.com')).toBe('/admin')
+6 -1
View File
@@ -14,8 +14,13 @@ export function resolveBrowserAppUrl(fallback: string): string {
}
}
function isInternalHost(host: string): boolean {
const hostname = host.split(':')[0]?.toLowerCase()
return ['localhost', '127.0.0.1', 'host.docker.internal', 'dashboard', 'admin', 'api', 'homepage', 'carplace'].includes(hostname)
}
export function resolveServerAppUrl(fallback: string, host: string | null, proto?: string | null): string {
if (!host) return fallback
if (!host || isInternalHost(host)) return fallback
try {
const target = new URL(fallback)
+12 -5
View File
@@ -63,11 +63,18 @@ function isAllowedLocalDevOrigin(origin: string) {
try {
const url = new URL(origin)
return (
url.protocol === 'http:' &&
['localhost', '127.0.0.1'].includes(url.hostname) &&
['3000', '3001', '3002', '4000'].includes(url.port)
)
if (url.protocol !== 'http:') return false
if (!['3000', '3001', '3002', '3004', '4000'].includes(url.port)) return false
if (['localhost', '127.0.0.1'].includes(url.hostname)) return true
const octets = url.hostname.split('.').map((part) => Number(part))
if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
return false
}
const first = octets[0]!
const second = octets[1]!
return first === 10 || (first === 172 && second >= 16 && second <= 31) || (first === 192 && second === 168)
} catch {
return false
}
+15 -1
View File
@@ -43,7 +43,21 @@ function isAllowedDevelopmentOrigin(origin: string) {
if (process.env.NODE_ENV === 'production') return false
try {
const url = new URL(origin)
return url.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(url.hostname)
if (url.protocol !== 'http:') return false
const trustedDevPorts = new Set(['3000', '3001', '3002', '3004', '4000'])
if (!trustedDevPorts.has(url.port)) return false
if (['localhost', '127.0.0.1'].includes(url.hostname)) return true
const octets = url.hostname.split('.').map((part) => Number(part))
if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
return false
}
const first = octets[0]!
const second = octets[1]!
return first === 10 || (first === 172 && second >= 16 && second <= 31) || (first === 192 && second === 168)
} catch {
return false
}
@@ -85,6 +85,25 @@ function calculateLineAmounts(items: Array<{ type: string; amount: number }>) {
return { subtotalAmount, discountAmount, creditAmount, taxAmount, totalAmount }
}
function withBillingAccountBalances<T extends { invoices?: any[]; creditBalances?: any[] }>(account: T) {
const invoices = account.invoices ?? []
const creditBalances = account.creditBalances ?? []
const openBalance = invoices
.filter((invoice: any) => ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(invoice.status))
.reduce((sum: number, invoice: any) => sum + (invoice.amountDue ?? 0), 0)
const paidBalance = invoices
.filter((invoice: any) => ['PAID', 'PARTIALLY_REFUNDED', 'REFUNDED'].includes(invoice.status))
.reduce((sum: number, invoice: any) => sum + (invoice.amountPaid ?? 0), 0)
const creditBalance = creditBalances.reduce((sum: number, item: any) => sum + (item.balanceAmount ?? 0), 0)
return {
...account,
openBalance,
paidBalance,
creditBalance,
}
}
async function createBillingEvent(tx: any, data: {
billingAccountId?: string | null
invoiceId?: string | null
@@ -455,20 +474,7 @@ export async function listBillingAccounts(query: { q?: string; status?: string;
.reduce((sum, item) => sum + (item._sum.totalAmount ?? 0), 0),
}
const data = (accounts as any[]).map((account) => {
const openBalance = (account.invoices as any[])
.filter((invoice: any) => ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(invoice.status))
.reduce((sum: number, invoice: any) => sum + invoice.amountDue, 0)
const paidBalance = (account.invoices as any[])
.filter((invoice: any) => ['PAID', 'PARTIALLY_REFUNDED', 'REFUNDED'].includes(invoice.status))
.reduce((sum: number, invoice: any) => sum + invoice.amountPaid, 0)
return {
...account,
openBalance,
paidBalance,
creditBalance: (account.creditBalances as any[]).reduce((sum: number, item: any) => sum + item.balanceAmount, 0),
}
})
const data = (accounts as any[]).map((account) => withBillingAccountBalances(account))
return { data, total, stats }
}
@@ -513,7 +519,7 @@ export async function getBillingAccountDetail(companyId: string) {
})
if (!account) throw new NotFoundError('Billing account not found')
return account
return withBillingAccountBalances(account)
}
export async function updateBillingAccount(
+117 -11
View File
@@ -564,20 +564,126 @@ export async function listNotificationsPage(query: {
page: number
pageSize: number
}) {
const where: any = {}
if (query.channel) where.channel = query.channel
if (query.status) where.status = query.status
if (query.companyId) where.companyId = query.companyId
const legacyStatuses = new Set(['PENDING', 'SENT', 'DELIVERED', 'FAILED', 'READ'])
const legacyWhere: any = {}
if (query.channel) legacyWhere.channel = query.channel
if (query.status && legacyStatuses.has(query.status)) {
legacyWhere.status = query.status
} else if (query.status) {
legacyWhere.id = '__delivery_status_only__'
}
if (query.companyId) legacyWhere.companyId = query.companyId
const [data, total] = await Promise.all([
const deliveryWhere: any = {}
if (query.channel) deliveryWhere.channel = query.channel
if (query.companyId) {
deliveryWhere.notificationRecipient = {
notificationEvent: { companyId: query.companyId },
}
}
if (query.status === 'READ') {
deliveryWhere.notificationRecipient = {
...(deliveryWhere.notificationRecipient ?? {}),
readAt: { not: null },
}
} else if (query.status) {
deliveryWhere.status = query.status
deliveryWhere.notificationRecipient = {
...(deliveryWhere.notificationRecipient ?? {}),
readAt: null,
}
}
const take = query.page * query.pageSize
const [legacyNotifications, deliveryNotifications, legacyTotal, deliveryTotal] = await Promise.all([
prisma.notification.findMany({
where,
where: legacyWhere,
orderBy: { createdAt: 'desc' },
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
include: { company: { select: { name: true } } },
take,
include: {
company: { select: { id: true, name: true } },
employee: { select: { id: true, firstName: true, lastName: true, email: true } },
renter: { select: { id: true, firstName: true, lastName: true, email: true } },
},
}),
prisma.notification.count({ where }),
prisma.notificationDelivery.findMany({
where: deliveryWhere,
orderBy: { createdAt: 'desc' },
take,
include: {
notificationRecipient: {
include: {
employee: { select: { id: true, firstName: true, lastName: true, email: true } },
renter: { select: { id: true, firstName: true, lastName: true, email: true } },
notificationEvent: {
include: {
company: { select: { id: true, name: true } },
},
},
},
},
},
}),
prisma.notification.count({ where: legacyWhere }),
prisma.notificationDelivery.count({ where: deliveryWhere }),
])
return { data, total }
const legacyRows = legacyNotifications.map((notification: any) => {
const recipient = notification.employee ?? notification.renter ?? null
return {
id: `legacy:${notification.id}`,
notificationId: notification.id,
deliveryId: null,
source: 'LEGACY',
type: notification.type,
title: notification.title,
body: notification.body,
channel: notification.channel,
status: notification.status,
locale: notification.locale,
sentAt: notification.sentAt,
createdAt: notification.createdAt,
company: notification.company,
companyId: notification.companyId,
recipientType: notification.employeeId ? 'EMPLOYEE' : notification.renterId ? 'RENTER' : null,
recipientName: recipient ? `${recipient.firstName} ${recipient.lastName}`.trim() : null,
recipientEmail: recipient?.email ?? null,
employeeId: notification.employeeId,
renterId: notification.renterId,
}
})
const deliveryRows = deliveryNotifications.map((delivery: any) => {
const recipientRecord = delivery.notificationRecipient
const event = recipientRecord.notificationEvent
const recipient = recipientRecord.employee ?? recipientRecord.renter ?? null
return {
id: `delivery:${delivery.id}`,
notificationId: event.id,
deliveryId: delivery.id,
source: 'DELIVERY',
type: event.type,
title: event.title,
body: event.body,
channel: delivery.channel,
status: recipientRecord.readAt ? 'READ' : delivery.status,
locale: event.locale,
sentAt: delivery.sentAt ?? delivery.deliveredAt ?? delivery.lastAttemptAt,
createdAt: delivery.createdAt,
company: event.company,
companyId: event.companyId,
recipientType: recipientRecord.recipientType,
recipientName: recipient ? `${recipient.firstName} ${recipient.lastName}`.trim() : null,
recipientEmail: recipient?.email ?? null,
employeeId: recipientRecord.employeeId,
renterId: recipientRecord.renterId,
}
})
const data = [...legacyRows, ...deliveryRows]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice((query.page - 1) * query.pageSize, query.page * query.pageSize)
return { data, total: legacyTotal + deliveryTotal }
}
+5 -1
View File
@@ -45,8 +45,12 @@ router.post('/auth/login', async (req, res, next) => {
const { email, password, totpCode, recoveryCode } = parseBody(loginSchema, req)
const result = await service.login(email, password, totpCode, recoveryCode)
if (!result) return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 })
if ('totpRequired' in result) return res.status(401).json({ error: 'totp_required', message: '2FA code required', statusCode: 401 })
if ('totpRequired' in result) {
clearSessionCookie(res, 'employee')
return res.status(401).json({ error: 'totp_required', message: '2FA code required', statusCode: 401 })
}
if ('invalidTotp' in result) return res.status(401).json({ error: 'invalid_totp', message: 'Invalid 2FA code', statusCode: 401 })
clearSessionCookie(res, 'employee')
setSessionCookie(res, 'admin', result.token, 8 * 60 * 60 * 1000)
ok(res, result)
} catch (err) { next(err) }
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { notificationsQuerySchema } from './admin.schemas'
describe('admin notification schemas', () => {
it('accepts valid notification filters with pagination defaults', () => {
expect(notificationsQuerySchema.parse({
channel: 'EMAIL',
status: 'QUEUED',
companyId: 'company_1',
page: '2',
})).toEqual({
channel: 'EMAIL',
status: 'QUEUED',
companyId: 'company_1',
page: 2,
pageSize: 50,
})
})
it('rejects invalid notification enum filters before querying Prisma', () => {
expect(notificationsQuerySchema.safeParse({ channel: 'FAX' }).success).toBe(false)
expect(notificationsQuerySchema.safeParse({ status: 'BOUNCED' }).success).toBe(false)
})
})
+5 -2
View File
@@ -53,9 +53,12 @@ export const auditLogQuerySchema = z.object({
pageSize: z.coerce.number().int().min(1).max(100).default(50),
})
const notificationChannelSchema = z.enum(['EMAIL', 'SMS', 'WHATSAPP', 'IN_APP', 'PUSH'])
const notificationStatusSchema = z.enum(['PENDING', 'QUEUED', 'SENT', 'DELIVERED', 'FAILED', 'SKIPPED', 'DEAD_LETTER', 'READ'])
export const notificationsQuerySchema = z.object({
channel: z.string().optional(),
status: z.string().optional(),
channel: notificationChannelSchema.optional(),
status: notificationStatusSchema.optional(),
companyId: z.string().optional(),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(200).default(50),
@@ -1,28 +1,54 @@
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import bcrypt from 'bcryptjs'
vi.mock('./admin.repo', () => ({
findAdminByEmail: vi.fn(),
setAdminPasswordReset: vi.fn(),
updateAdminLastLogin: vi.fn(),
createAuditLog: vi.fn(),
}))
vi.mock('../../services/notificationService', () => ({
sendTransactionalEmail: vi.fn().mockResolvedValue(undefined),
}))
const redisStore = new Map<string, string>()
vi.mock('../../lib/redis', () => ({
redis: {
on: vi.fn(),
get: vi.fn((key: string) => Promise.resolve(redisStore.get(key) ?? null)),
set: vi.fn((key: string, value: string) => {
redisStore.set(key, value)
return Promise.resolve('OK')
}),
del: vi.fn((key: string) => {
const deleted = redisStore.delete(key) ? 1 : 0
return Promise.resolve(deleted)
}),
quit: vi.fn(),
duplicate: vi.fn(),
},
}))
import * as repo from './admin.repo'
import { sendTransactionalEmail } from '../../services/notificationService'
import { forgotPassword } from './admin.service'
import { forgotPassword, login } from './admin.service'
describe('admin.service forgotPassword', () => {
const originalAdminUrl = process.env.ADMIN_URL
const originalJwtSecret = process.env.JWT_SECRET
beforeEach(() => {
vi.clearAllMocks()
redisStore.clear()
process.env.ADMIN_URL = 'http://localhost:3000/admin'
process.env.JWT_SECRET = 'test-jwt-secret'
})
afterAll(() => {
process.env.ADMIN_URL = originalAdminUrl
process.env.JWT_SECRET = originalJwtSecret
})
it('sends the reset email to the canonical stored admin address', async () => {
@@ -46,4 +72,55 @@ describe('admin.service forgotPassword', () => {
}),
)
})
it('sends an email login code when admin 2FA is enabled', async () => {
vi.mocked(repo.findAdminByEmail).mockResolvedValue({
id: 'admin_2',
email: 'admin@example.test',
firstName: 'Amal',
lastName: 'Admin',
role: 'SUPER_ADMIN',
isActive: true,
passwordHash: await bcrypt.hash('password123', 4),
totpEnabled: true,
totpSecret: 'JBSWY3DPEHPK3PXP',
} as any)
await expect(login('admin@example.test', 'password123')).resolves.toEqual({ totpRequired: true })
expect(sendTransactionalEmail).toHaveBeenCalledWith(
expect.objectContaining({
to: 'admin@example.test',
subject: 'Your RentalDriveGo admin login code',
text: expect.stringMatching(/\b\d{6}\b/),
}),
)
})
it('accepts the emailed admin login code in the 2FA field', async () => {
vi.mocked(repo.findAdminByEmail).mockResolvedValue({
id: 'admin_3',
email: 'admin3@example.test',
firstName: 'Mina',
lastName: 'Admin',
role: 'SUPER_ADMIN',
isActive: true,
passwordHash: await bcrypt.hash('password123', 4),
totpEnabled: true,
totpSecret: 'JBSWY3DPEHPK3PXP',
} as any)
await login('admin3@example.test', 'password123')
const emailText = vi.mocked(sendTransactionalEmail).mock.calls[0]?.[0]?.text ?? ''
const code = emailText.match(/\b\d{6}\b/)?.[0]
expect(code).toBeTruthy()
const result = await login('admin3@example.test', 'password123', code)
expect(result).toEqual(expect.objectContaining({
token: expect.any(String),
admin: expect.objectContaining({ id: 'admin_3', email: 'admin3@example.test' }),
}))
expect(repo.updateAdminLastLogin).toHaveBeenCalledWith('admin_3')
})
})
+67 -3
View File
@@ -6,12 +6,17 @@ import { signActorToken } from '../../security/tokens'
import qrcode from 'qrcode'
import { getCarplaceHomepageContent, saveCarplaceHomepageContent } from '../../services/platformContentService'
import { sendTransactionalEmail } from '../../services/notificationService'
import { redis } from '../../lib/redis'
import * as presenter from './admin.presenter'
import * as repo from './admin.repo'
import * as billingService from './admin.billing.service'
const ADMIN_RESET_TTL_MINUTES = 60
const ADMIN_RECOVERY_CODE_COUNT = 10
const ADMIN_EMAIL_OTP_TTL_MINUTES = 10
const ADMIN_EMAIL_OTP_TTL_SECONDS = ADMIN_EMAIL_OTP_TTL_MINUTES * 60
const pendingAdminEmailOtps = new Map<string, { code: string; expiresAt: number }>()
function generateRecoveryCode() {
@@ -57,6 +62,61 @@ function signAdminToken(adminId: string, last2faAt?: number) {
return signActorToken(adminId, 'admin', { expiresIn: '8h', last2faAt })
}
function generateAdminEmailOtp() {
return crypto.randomInt(100000, 1000000).toString()
}
function adminEmailOtpKey(adminId: string) {
return `admin:email-otp:${adminId}`
}
async function sendAdminEmailOtp(admin: { id: string; email: string; firstName?: string | null }) {
const code = generateAdminEmailOtp()
const codeHash = hashPublicAccessToken(code)
pendingAdminEmailOtps.set(admin.id, {
code: codeHash,
expiresAt: Date.now() + ADMIN_EMAIL_OTP_TTL_MINUTES * 60 * 1000,
})
await redis
.set(adminEmailOtpKey(admin.id), codeHash, 'EX', ADMIN_EMAIL_OTP_TTL_SECONDS)
.catch((err) => console.error('[AdminLoginEmailOtpRedisSet]', err?.message))
await sendTransactionalEmail({
to: admin.email,
subject: 'Your RentalDriveGo admin login code',
html: `<p>Hi ${admin.firstName ?? 'Admin'},</p><p>Your admin login code is <strong>${code}</strong>.</p><p>It expires in ${ADMIN_EMAIL_OTP_TTL_MINUTES} minutes.</p>`,
text: `Hi ${admin.firstName ?? 'Admin'},\n\nYour admin login code is ${code}.\n\nIt expires in ${ADMIN_EMAIL_OTP_TTL_MINUTES} minutes.`,
}).catch((err) => console.error('[AdminLoginEmailOtp]', err?.message))
}
async function consumeAdminEmailOtp(adminId: string, code: string | undefined) {
if (!code) return false
const codeHash = hashPublicAccessToken(code.trim())
const key = adminEmailOtpKey(adminId)
const persistedHash = await redis
.get(key)
.catch((err) => {
console.error('[AdminLoginEmailOtpRedisGet]', err?.message)
return null
})
if (persistedHash) {
if (persistedHash !== codeHash) return false
await redis.del(key).catch((err) => console.error('[AdminLoginEmailOtpRedisDel]', err?.message))
pendingAdminEmailOtps.delete(adminId)
return true
}
const pending = pendingAdminEmailOtps.get(adminId)
if (!pending) return false
if (pending.expiresAt <= Date.now()) {
pendingAdminEmailOtps.delete(adminId)
return false
}
if (pending.code !== codeHash) return false
pendingAdminEmailOtps.delete(adminId)
return true
}
function toAuditJson<T>(value: T) {
return JSON.parse(JSON.stringify(value))
}
@@ -81,16 +141,20 @@ export async function login(email: string, password: string, totpCode?: string,
if (!valid) return null
if (admin.totpEnabled) {
if (!totpCode && !recoveryCode) return { totpRequired: true } as const
if (!totpCode && !recoveryCode) {
await sendAdminEmailOtp(admin)
return { totpRequired: true } as const
}
const validTotp = totpCode
? authenticator.verify({ token: totpCode, secret: admin.totpSecret! })
: false
const validRecoveryCode = !validTotp && recoveryCode
const validEmailOtp = !validTotp && await consumeAdminEmailOtp(admin.id, totpCode)
const validRecoveryCode = !validTotp && !validEmailOtp && recoveryCode
? await consumeAdminRecoveryCode(admin.id, recoveryCode)
: false
if (!validTotp && !validRecoveryCode) {
if (!validTotp && !validEmailOtp && !validRecoveryCode) {
return { invalidTotp: true } as const
}
}
@@ -30,7 +30,10 @@ router.post('/login', async (req, res, next) => {
try {
const body = parseBody(employeeLoginSchema, req)
const result = await service.login(body)
if ('token' in result) setSessionCookie(res, 'employee', result.token, 8 * 60 * 60 * 1000)
if ('token' in result) {
clearSessionCookie(res, 'admin')
setSessionCookie(res, 'employee', result.token, 8 * 60 * 60 * 1000)
}
ok(res, result)
} catch (err) { next(err) }
})
@@ -16,7 +16,8 @@ vi.mock('../../lib/redis', () => ({
import request from 'supertest'
import { describe, expect, it } from 'vitest'
import { createApp } from '../../app'
import { createApp, isCorsOriginAllowed } from '../../app'
import { isTrustedBrowserOrigin } from '../../middleware/csrf'
const app = createApp()
@@ -87,6 +88,13 @@ describe('API foundation integration', () => {
expect(res.headers['access-control-allow-credentials']).toBe('true')
})
it('trusts private LAN app origins during local development only on known app ports', () => {
expect(isCorsOriginAllowed('http://192.168.3.3:3000')).toBe(true)
expect(isTrustedBrowserOrigin('http://192.168.3.3:3000')).toBe(true)
expect(isCorsOriginAllowed('http://192.168.3.3:8080')).toBe(false)
expect(isTrustedBrowserOrigin('http://192.168.3.3:8080')).toBe(false)
})
it('blocks legacy anonymous access to customer identity document storage paths', async () => {
const res = await request(app).get('/storage/companies/company_1/customers/customer_1/passport.jpg')
@@ -149,4 +149,35 @@ describe('auth middleware API boundaries', () => {
expect(res.body).toEqual({ data: { data: [{ id: 'company_1' }], pagination: { page: 1 } } })
expect(adminService.listCompanies).toHaveBeenCalledWith({ page: 1, pageSize: 20 })
})
it('clears any employee session when admin login succeeds', async () => {
vi.mocked(adminService.login).mockResolvedValue({
token: 'admin-jwt',
admin: { id: 'admin_1', email: 'admin@example.test', role: 'SUPER_ADMIN' },
} as never)
const res = await request(app)
.post('/api/v1/admin/auth/login')
.send({ email: 'admin@example.test', password: 'valid-password' })
expect(res.status).toBe(200)
expect(res.headers['set-cookie']).toEqual(expect.arrayContaining([
expect.stringMatching(/^employee_session=;/),
expect.stringMatching(/^admin_session=/),
]))
})
it('clears any employee session when admin credentials require 2FA', async () => {
vi.mocked(adminService.login).mockResolvedValue({ totpRequired: true } as never)
const res = await request(app)
.post('/api/v1/admin/auth/login')
.send({ email: 'admin@example.test', password: 'valid-password' })
expect(res.status).toBe(401)
expect(res.body.error).toBe('totp_required')
expect(res.headers['set-cookie']).toEqual(expect.arrayContaining([
expect.stringMatching(/^employee_session=;/),
]))
})
})
@@ -95,6 +95,19 @@ describe('employee, notification, and carplace API validation contracts', () =>
expect(employeeService.login).toHaveBeenCalledWith({ email: 'agent@example.test', password: 'valid-password' })
})
it('clears any admin session when employee login succeeds', async () => {
const res = await request(app).post('/api/v1/auth/employee/login').send({
email: 'agent@example.test',
password: 'valid-password',
})
expect(res.status).toBe(200)
expect(res.headers['set-cookie']).toEqual(expect.arrayContaining([
expect.stringMatching(/^admin_session=;/),
expect.stringMatching(/^employee_session=/),
]))
})
it('rejects empty employee reset tokens before service execution', async () => {
const res = await request(app).post('/api/v1/auth/employee/reset-password').send({ token: '', password: 'new-password' })
+43 -1
View File
@@ -3,8 +3,10 @@ import { createApp } from '../../app'
import {
authHeader,
createAdminUser,
createCompanyNotification,
createCompanyWithEmployee,
createRenter,
createRenterNotification,
signAdminToken,
} from '../helpers/fixtures'
@@ -17,6 +19,7 @@ describe('Admin API', () => {
let viewerToken: string
let adminId: string
let companyId: string
let employeeId: string
beforeAll(async () => {
const admin = await createAdminUser({
@@ -56,8 +59,9 @@ describe('Admin API', () => {
})
viewerToken = signAdminToken(viewerAdmin.id)
const { company } = await createCompanyWithEmployee()
const { company, employee } = await createCompanyWithEmployee()
companyId = company.id
employeeId = employee.id
await createRenter()
})
@@ -126,6 +130,41 @@ describe('Admin API', () => {
})
})
describe('GET /api/v1/admin/notifications', () => {
it('returns employee and renter notification deliveries in the platform log', async () => {
const renter = await createRenter()
const employeeNotification = await createCompanyNotification(companyId, employeeId, {
title: 'Employee audit notification',
})
const renterNotification = await createRenterNotification(renter.id, {
companyId,
title: 'Renter audit notification',
})
const res = await request(app)
.get('/api/v1/admin/notifications?pageSize=100')
.set(authHeader(protectedAdminToken))
expect(res.status).toBe(200)
expect(res.body.data.data).toEqual(
expect.arrayContaining([
expect.objectContaining({
notificationId: employeeNotification.notificationEventId,
recipientType: 'EMPLOYEE',
employeeId,
title: 'Employee audit notification',
}),
expect.objectContaining({
notificationId: renterNotification.notificationEventId,
recipientType: 'RENTER',
renterId: renter.id,
title: 'Renter audit notification',
}),
]),
)
})
})
describe('GET /api/v1/admin/metrics', () => {
it('returns 403 for VIEWER role', async () => {
const res = await request(app)
@@ -173,6 +212,9 @@ describe('Admin API', () => {
expect(res.status).toBe(200)
expect(res.body.data.id).toBe(billingAccountId)
expect(res.body.data.company.id).toBe(companyId)
expect(res.body.data.openBalance).toEqual(expect.any(Number))
expect(res.body.data.paidBalance).toEqual(expect.any(Number))
expect(res.body.data.creditBalance).toEqual(expect.any(Number))
expect(Array.isArray(res.body.data.invoices)).toBe(true)
})
@@ -0,0 +1,20 @@
import { SignInForm } from '@/components/auth/SignInForm'
import { isLocale, isMode, type Locale } from '@/lib/localization/config'
import { notFound } from 'next/navigation'
import { Suspense } from 'react'
export default async function AdminSignInPage({
params,
}: {
params: Promise<{ locale: string; mode: string }>
}) {
const { locale: localeValue, mode } = await params
if (!isLocale(localeValue)) notFound()
if (!isMode(mode)) notFound()
return (
<Suspense fallback={null}>
<SignInForm locale={localeValue as Locale} authMode="admin" />
</Suspense>
)
}
@@ -45,7 +45,7 @@ const dicts: Record<string, Dict> = {
verify: 'Verify code',
verifying: 'Verifying…',
authCode: 'Authentication code',
enterCode: 'Enter the 6-digit code from your authenticator app.',
enterCode: 'Enter the 6-digit code sent to your admin email, or use your authenticator app.',
totpPlaceholder: '000000 or XXXX-XXXX-XXXX',
back: 'Back to credentials',
forgotPassword: 'Forgot your password?',
@@ -67,7 +67,7 @@ const dicts: Record<string, Dict> = {
verify: 'Vérifier le code',
verifying: 'Vérification…',
authCode: "Code d'authentification",
enterCode: "Entrez le code à 6 chiffres de votre application d'authentification.",
enterCode: 'Entrez le code à 6 chiffres envoyé à votre e-mail admin, ou utilisez votre application dauthentification.',
totpPlaceholder: '000000 ou XXXX-XXXX-XXXX',
back: 'Retour aux identifiants',
forgotPassword: 'Mot de passe oublié ?',
@@ -89,7 +89,7 @@ const dicts: Record<string, Dict> = {
verify: 'تحقق من الرمز',
verifying: 'جارٍ التحقق…',
authCode: 'رمز المصادقة',
enterCode: 'أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة.',
enterCode: 'أدخل الرمز المكون من 6 أرقام المرسل إلى بريد المسؤول، أو استخدم تطبيق المصادقة.',
totpPlaceholder: '000000 أو XXXX-XXXX-XXXX',
back: 'العودة إلى بيانات الدخول',
forgotPassword: 'نسيت كلمة المرور؟',
@@ -101,10 +101,28 @@ const dicts: Record<string, Dict> = {
},
};
export function SignInForm({ locale }: { locale: Locale }) {
function isAdminDestination(value: string) {
if (!value) return false;
try {
const url = new URL(value, typeof window === 'undefined' ? 'http://localhost' : window.location.origin);
return url.pathname.startsWith('/admin');
} catch {
return value.startsWith('/admin');
}
}
export function SignInForm({
locale,
authMode = 'auto',
}: {
locale: Locale;
authMode?: 'auto' | 'admin' | 'employee';
}) {
const dict = (dicts[locale] ?? dicts.en) as Dict;
const searchParams = useSearchParams();
const mode = modeFromPathname(usePathname());
const pathname = usePathname();
const mode = modeFromPathname(pathname);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
@@ -113,8 +131,12 @@ export function SignInForm({ locale }: { locale: Locale }) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const requestedPortal = searchParams.get('portal');
const requestedNext = searchParams.get('next') || searchParams.get('redirect') || '';
const employeeRedirect = searchParams.get('redirect') || '/dashboard';
const preferAdminAuth = requestedPortal === 'admin';
const preferAdminAuth =
authMode === 'admin' ||
pathname.includes('/admin-sign-in') ||
(authMode === 'auto' && (requestedPortal === 'admin' || isAdminDestination(requestedNext)));
async function handleCredentials(e: React.FormEvent) {
e.preventDefault();
@@ -196,6 +218,7 @@ export function SignInForm({ locale }: { locale: Locale }) {
}
if (await tryEmployeeLogin()) return;
if (await tryAdminLogin()) return;
setError(dict.invalidCredentials);
} catch {
@@ -0,0 +1,184 @@
import { SignInForm } from '@/components/auth/SignInForm';
import { API_BASE } from '@/lib/api';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
let searchParams = new URLSearchParams();
let pathname = '/en/light/sign-in';
vi.mock('next/navigation', () => ({
usePathname: () => pathname,
useSearchParams: () => searchParams,
}));
vi.mock('next/image', () => ({
default: ({ priority, unoptimized, ...props }: any) => <img {...props} />,
}));
function jsonResponse(status: number, body: unknown) {
return {
ok: status >= 200 && status < 300,
status,
json: vi.fn().mockResolvedValue(body),
} as unknown as Response;
}
async function submitForm() {
const user = userEvent.setup();
await user.type(screen.getByLabelText(/Email/), 'owner@example.com');
await user.type(screen.getByLabelText(/Password/), 'password123');
await user.click(screen.getByRole('button', { name: 'Sign in' }));
}
describe('SignInForm auth routing', () => {
beforeEach(() => {
searchParams = new URLSearchParams();
pathname = '/en/light/sign-in';
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
jsonResponse(401, { error: 'invalid_credentials' }),
));
});
it('uses employee login only on the default sign-in page', async () => {
vi.mocked(fetch).mockResolvedValueOnce(
jsonResponse(200, { data: { employee: { id: 'employee_1' } } }),
);
render(<SignInForm locale="en" />);
await submitForm();
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
expect(fetch).toHaveBeenCalledWith(
`${API_BASE}/auth/employee/login`,
expect.objectContaining({ method: 'POST' }),
);
expect(fetch).not.toHaveBeenCalledWith(
`${API_BASE}/admin/auth/login`,
expect.anything(),
);
});
it('falls back to admin login when default employee login rejects the credentials', async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(jsonResponse(401, { error: 'invalid_credentials' }))
.mockResolvedValueOnce(jsonResponse(401, { error: 'totp_required' }));
render(<SignInForm locale="en" />);
await submitForm();
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
expect(fetch).toHaveBeenNthCalledWith(
1,
`${API_BASE}/auth/employee/login`,
expect.objectContaining({ method: 'POST' }),
);
expect(fetch).toHaveBeenNthCalledWith(
2,
`${API_BASE}/admin/auth/login`,
expect.objectContaining({ method: 'POST' }),
);
expect(await screen.findByText('Authentication code')).toBeInTheDocument();
});
it('uses employee login only when the requested destination is a dashboard path', async () => {
vi.mocked(fetch).mockResolvedValueOnce(
jsonResponse(200, { data: { employee: { id: 'employee_1' } } }),
);
searchParams = new URLSearchParams('redirect=/dashboard/billing');
render(<SignInForm locale="en" />);
await submitForm();
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
expect(fetch).toHaveBeenCalledWith(
`${API_BASE}/auth/employee/login`,
expect.objectContaining({ method: 'POST' }),
);
expect(fetch).not.toHaveBeenCalledWith(
`${API_BASE}/admin/auth/login`,
expect.anything(),
);
});
it('uses admin login only when the admin portal is requested', async () => {
searchParams = new URLSearchParams('portal=admin');
render(<SignInForm locale="en" />);
await submitForm();
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
expect(fetch).toHaveBeenCalledWith(
`${API_BASE}/admin/auth/login`,
expect.objectContaining({ method: 'POST' }),
);
expect(fetch).not.toHaveBeenCalledWith(
`${API_BASE}/auth/employee/login`,
expect.anything(),
);
});
it('uses admin login when the requested destination is an admin path', async () => {
searchParams = new URLSearchParams('next=/admin/dashboard');
render(<SignInForm locale="en" />);
await submitForm();
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
expect(fetch).toHaveBeenCalledWith(
`${API_BASE}/admin/auth/login`,
expect.objectContaining({ method: 'POST' }),
);
});
it('uses admin login when the requested destination is an absolute admin URL', async () => {
searchParams = new URLSearchParams('redirect=http://localhost:3000/admin/dashboard');
render(<SignInForm locale="en" />);
await submitForm();
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
expect(fetch).toHaveBeenCalledWith(
`${API_BASE}/admin/auth/login`,
expect.objectContaining({ method: 'POST' }),
);
expect(fetch).not.toHaveBeenCalledWith(
`${API_BASE}/auth/employee/login`,
expect.anything(),
);
});
it('uses admin login when the page forces admin auth mode', async () => {
render(<SignInForm locale="en" authMode="admin" />);
await submitForm();
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
expect(fetch).toHaveBeenCalledWith(
`${API_BASE}/admin/auth/login`,
expect.objectContaining({ method: 'POST' }),
);
expect(fetch).not.toHaveBeenCalledWith(
`${API_BASE}/auth/employee/login`,
expect.anything(),
);
});
it('uses admin login on the admin sign-in path', async () => {
pathname = '/en/light/admin-sign-in';
render(<SignInForm locale="en" />);
await submitForm();
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
expect(fetch).toHaveBeenCalledWith(
`${API_BASE}/admin/auth/login`,
expect.objectContaining({ method: 'POST' }),
);
expect(fetch).not.toHaveBeenCalledWith(
`${API_BASE}/auth/employee/login`,
expect.anything(),
);
});
});
+1
View File
@@ -54,6 +54,7 @@
"db:seed": "turbo db:seed",
"db:seed:admin": "npm run db:seed --workspace @rentaldrivego/database",
"admin:2fa:enroll": "node scripts/enroll-admin-2fa.cjs",
"admin:create-user": "node scripts/create-admin-user.cjs",
"db:studio": "cd packages/database && npx prisma studio",
"test:api": "npm run test --workspace @rentaldrivego/api",
"test:api:integration": "npm run test:integration --workspace @rentaldrivego/api",
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env node
const bcrypt = require('bcryptjs')
const path = require('node:path')
const {
ensureDatabaseUrl,
loadDefaultEnvFiles,
normalizeDatabaseUrlForExecution,
} = require('../packages/database/src/runtime-config')
const ADMIN_ROLES = new Set(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER'])
function readArg(name) {
const prefix = `--${name}=`
const match = process.argv.slice(2).find((arg) => arg.startsWith(prefix))
return match ? match.slice(prefix.length) : undefined
}
function readValue(argName, envName, fallback) {
return readArg(argName) ?? process.env[envName] ?? fallback
}
function requireValue(argName, envName) {
const value = readValue(argName, envName)
if (!value || !value.trim()) {
throw new Error(`Missing required value. Provide --${argName}=... or ${envName}=...`)
}
return value.trim()
}
function readBoolean(argName, envName, fallback = false) {
const value = readValue(argName, envName)
if (value === undefined) return fallback
return ['1', 'true', 'yes', 'y'].includes(String(value).toLowerCase())
}
async function main() {
loadDefaultEnvFiles(path.resolve(__dirname, '..'))
ensureDatabaseUrl()
normalizeDatabaseUrlForExecution()
const email = requireValue('email', 'ADMIN_EMAIL').toLowerCase()
const password = requireValue('password', 'ADMIN_PASSWORD')
const firstName = readValue('first-name', 'ADMIN_FIRST_NAME', 'Admin').trim()
const lastName = readValue('last-name', 'ADMIN_LAST_NAME', 'User').trim()
const role = readValue('role', 'ADMIN_ROLE', 'SUPER_ADMIN').trim().toUpperCase()
const updateExisting = readBoolean('update-existing', 'ADMIN_UPDATE_EXISTING', false)
if (!ADMIN_ROLES.has(role)) {
throw new Error(`Invalid ADMIN_ROLE "${role}". Use one of: ${Array.from(ADMIN_ROLES).join(', ')}`)
}
if (password.length < 8) {
throw new Error('Admin password must be at least 8 characters long.')
}
const { PrismaClient } = require('../packages/database/generated')
const prisma = new PrismaClient()
try {
const existing = await prisma.adminUser.findUnique({ where: { email } })
const passwordHash = await bcrypt.hash(password, 12)
if (existing) {
if (!updateExisting) {
console.log(`Admin user already exists: ${email}`)
console.log('Set ADMIN_UPDATE_EXISTING=true or pass --update-existing=true to update the name, role, password, and active status.')
return
}
const admin = await prisma.adminUser.update({
where: { email },
data: {
firstName,
lastName,
role,
passwordHash,
isActive: true,
passwordResetToken: null,
passwordResetExpiresAt: null,
},
})
console.log(`Updated ${admin.role}: ${admin.email} (id: ${admin.id})`)
return
}
const admin = await prisma.adminUser.create({
data: {
email,
firstName,
lastName,
passwordHash,
role,
isActive: true,
},
})
console.log(`Created ${admin.role}: ${admin.email} (id: ${admin.id})`)
} finally {
await prisma.$disconnect()
}
}
main().catch((error) => {
console.error(error.message || error)
process.exit(1)
})
+40 -9
View File
@@ -52,6 +52,23 @@ function normalizeApiBase(value) {
return base.endsWith('/api/v1') ? base : `${base}/api/v1`
}
function readArg(name) {
const prefix = `--${name}=`
const match = process.argv.slice(2).find((arg) => arg.startsWith(prefix))
return match ? match.slice(prefix.length) : undefined
}
function readValue(env, argName, envNames, fallback) {
const argValue = readArg(argName)
if (argValue !== undefined) return argValue
for (const envName of envNames) {
if (env[envName] !== undefined) return env[envName]
}
return fallback
}
function updateCookieJar(cookieJar, setCookieHeaders) {
for (const header of setCookieHeaders) {
const pair = header.split(';')[0]
@@ -71,10 +88,23 @@ function cookieHeader(cookieJar) {
.join('; ')
}
async function request(apiBase, cookieJar, pathName, options = {}) {
function originFromUrl(value) {
try {
return new URL(value).origin
} catch {
return undefined
}
}
function isMutatingMethod(method) {
return ['POST', 'PUT', 'PATCH', 'DELETE'].includes(String(method || 'GET').toUpperCase())
}
async function request(apiBase, cookieJar, pathName, options = {}, trustedOrigin) {
const headers = {
...(options.body ? { 'Content-Type': 'application/json' } : {}),
...(cookieJar.size ? { Cookie: cookieHeader(cookieJar) } : {}),
...(trustedOrigin && isMutatingMethod(options.method) ? { Origin: trustedOrigin } : {}),
...options.headers,
}
@@ -102,12 +132,13 @@ async function main() {
const localEnv = loadLocalEnv()
const env = { ...localEnv, ...process.env }
const email = env.ADMIN_SEED_EMAIL || 'admin@rentaldrivego.ma'
const password = env.ADMIN_SEED_PASSWORD
const apiBase = normalizeApiBase(env.NEXT_PUBLIC_API_URL || env.API_URL)
const email = readValue(env, 'email', ['ADMIN_EMAIL', 'ADMIN_SEED_EMAIL'], 'admin@rentaldrivego.ma')
const password = readValue(env, 'password', ['ADMIN_PASSWORD', 'ADMIN_SEED_PASSWORD'])
const apiBase = normalizeApiBase(readValue(env, 'api-url', ['NEXT_PUBLIC_API_URL', 'API_URL']))
const trustedOrigin = originFromUrl(readValue(env, 'origin', ['ADMIN_URL', 'NEXT_PUBLIC_ADMIN_URL', 'DASHBOARD_URL', 'NEXT_PUBLIC_DASHBOARD_URL'], 'http://localhost:3000/admin'))
if (!password || /^(placeholder|changeme|change-me)$/i.test(password)) {
throw new Error('Set ADMIN_SEED_PASSWORD in .env.local or in the command environment.')
throw new Error('Set ADMIN_PASSWORD or ADMIN_SEED_PASSWORD, or pass --password=...')
}
const cookieJar = new Map()
@@ -115,7 +146,7 @@ async function main() {
const login = await request(apiBase, cookieJar, '/admin/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
})
}, trustedOrigin)
if (!login.response.ok) {
const error = login.json?.error || login.response.status
@@ -125,14 +156,14 @@ async function main() {
throw new Error(login.json?.message || `Admin login failed: ${error}`)
}
const me = await request(apiBase, cookieJar, '/admin/auth/me')
const me = await request(apiBase, cookieJar, '/admin/auth/me', {}, trustedOrigin)
const admin = me.json?.data ?? me.json
if (admin?.totpEnabled) {
console.log(`Admin 2FA is already enabled for ${email}.`)
return
}
const setup = await request(apiBase, cookieJar, '/admin/auth/2fa/setup', { method: 'POST' })
const setup = await request(apiBase, cookieJar, '/admin/auth/2fa/setup', { method: 'POST' }, trustedOrigin)
if (!setup.response.ok) {
throw new Error(setup.json?.message || 'Failed to create admin 2FA setup secret.')
}
@@ -144,7 +175,7 @@ async function main() {
const verify = await request(apiBase, cookieJar, '/admin/auth/2fa/verify', {
method: 'POST',
body: JSON.stringify({ code }),
})
}, trustedOrigin)
if (!verify.response.ok) {
throw new Error(verify.json?.message || 'Failed to verify generated 2FA code.')