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
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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('-')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' })
|
||||
|
||||
|
||||
@@ -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 d’authentification.',
|
||||
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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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.')
|
||||
|
||||
Reference in New Issue
Block a user