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) {
|
function money(amount: number, currency: string) {
|
||||||
|
const safeAmount = Number.isFinite(amount) ? amount : 0
|
||||||
return new Intl.NumberFormat('en-GB', {
|
return new Intl.NumberFormat('en-GB', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency,
|
currency,
|
||||||
minimumFractionDigits: 2,
|
minimumFractionDigits: 2,
|
||||||
}).format(amount / 100)
|
}).format(safeAmount / 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
function dateLabel(value: string | null | undefined) {
|
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 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 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) {
|
function toDateInput(value: string | null | undefined) {
|
||||||
return value ? new Date(value).toISOString().slice(0, 10) : ''
|
return value ? new Date(value).toISOString().slice(0, 10) : ''
|
||||||
}
|
}
|
||||||
@@ -318,6 +329,7 @@ export default function AdminCompanyDetailPage() {
|
|||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [savedMessage, setSavedMessage] = useState<string | null>(null)
|
const [savedMessage, setSavedMessage] = useState<string | null>(null)
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState(false)
|
const [deleteConfirm, setDeleteConfirm] = useState(false)
|
||||||
|
const [activeTab, setActiveTab] = useState<CompanyTabId>('company')
|
||||||
|
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
try {
|
try {
|
||||||
@@ -566,8 +578,40 @@ export default function AdminCompanyDetailPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 xl:grid-cols-2">
|
<div className="space-y-6">
|
||||||
<section className="panel p-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>
|
<h2 className="text-base font-semibold">Company</h2>
|
||||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||||
<label>
|
<label>
|
||||||
@@ -597,7 +641,13 @@ export default function AdminCompanyDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
<h2 className="text-base font-semibold">Subscription</h2>
|
||||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||||
<label>
|
<label>
|
||||||
@@ -663,7 +713,13 @@ export default function AdminCompanyDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
<h2 className="text-base font-semibold">Brand and public profile</h2>
|
||||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||||
<label>
|
<label>
|
||||||
@@ -729,7 +785,13 @@ export default function AdminCompanyDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
<h2 className="text-base font-semibold">Company legal profile</h2>
|
||||||
<div className="mt-4 grid gap-6">
|
<div className="mt-4 grid gap-6">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
@@ -817,7 +879,13 @@ export default function AdminCompanyDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
<h2 className="text-base font-semibold">Operations and finance</h2>
|
||||||
<div className="mt-4 grid gap-6">
|
<div className="mt-4 grid gap-6">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
@@ -915,7 +983,13 @@ export default function AdminCompanyDetailPage() {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</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="space-y-4">
|
||||||
<div className="panel p-6 space-y-4">
|
<div className="panel p-6 space-y-4">
|
||||||
<h2 className="text-base font-semibold">Company actions</h2>
|
<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'
|
import { AdminSessionProvider, type AdminSessionUser } from './AdminSessionContext'
|
||||||
|
|
||||||
function buildUnifiedLoginUrl(nextPath: string) {
|
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({
|
const params = new URLSearchParams({
|
||||||
portal: 'admin',
|
next: `/admin${nextPath || '/dashboard'}`,
|
||||||
next: nextPath || '/dashboard',
|
|
||||||
})
|
})
|
||||||
return `${dashboardUrl}/sign-in?${params.toString()}`
|
return `${websiteUrl}/en/light/admin-sign-in?${params.toString()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
|
|||||||
@@ -1,32 +1,14 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { ADMIN_API_BASE } from '@/lib/api'
|
import {
|
||||||
|
fetchAdminNotifications,
|
||||||
interface NotificationItem {
|
formatNotificationDate,
|
||||||
id: string
|
type NotificationsPageResult,
|
||||||
type: string
|
} from '@/lib/adminNotifications'
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
const CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'IN_APP', 'PUSH']
|
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> = {
|
const CHANNEL_BADGE: Record<string, string> = {
|
||||||
EMAIL: 'text-sky-400 bg-sky-950/40',
|
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',
|
SENT: 'text-emerald-400 bg-emerald-950/40',
|
||||||
DELIVERED: 'text-emerald-400 bg-emerald-950/40',
|
DELIVERED: 'text-emerald-400 bg-emerald-950/40',
|
||||||
FAILED: 'text-red-400 bg-red-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',
|
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() {
|
export default function AdminNotificationsPage() {
|
||||||
const [result, setResult] = useState<Paginated | null>(null)
|
const [result, setResult] = useState<NotificationsPageResult | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [filterChannel, setFilterChannel] = useState('')
|
const [filterChannel, setFilterChannel] = useState('')
|
||||||
@@ -53,27 +50,30 @@ export default function AdminNotificationsPage() {
|
|||||||
const [filterCompany, setFilterCompany] = useState('')
|
const [filterCompany, setFilterCompany] = useState('')
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
function load(p: number) {
|
function load(p: number, signal?: AbortSignal) {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
const params = new URLSearchParams({ page: String(p), pageSize: '50' })
|
fetchAdminNotifications(
|
||||||
if (filterChannel) params.set('channel', filterChannel)
|
p,
|
||||||
if (filterStatus) params.set('status', filterStatus)
|
{ channel: filterChannel, status: filterStatus, companyId: filterCompany },
|
||||||
if (filterCompany) params.set('companyId', filterCompany)
|
signal,
|
||||||
|
)
|
||||||
fetch(`${ADMIN_API_BASE}/admin/notifications?${params.toString()}`, {
|
.then((data) => setResult(data))
|
||||||
credentials: 'include',
|
.catch((err) => {
|
||||||
cache: 'no-store',
|
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||||
})
|
setResult(null)
|
||||||
.then((r) => r.json())
|
setError(err instanceof Error ? err.message : 'Failed to load notifications')
|
||||||
.then((json) => setResult(json.data ?? null))
|
})
|
||||||
.catch((err) => setError(err.message))
|
.finally(() => {
|
||||||
.finally(() => setLoading(false))
|
if (!signal?.aborted) setLoading(false)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const controller = new AbortController()
|
||||||
setPage(1)
|
setPage(1)
|
||||||
load(1)
|
load(1, controller.signal)
|
||||||
|
return () => controller.abort()
|
||||||
}, [filterChannel, filterStatus, filterCompany])
|
}, [filterChannel, filterStatus, filterCompany])
|
||||||
|
|
||||||
function goToPage(p: number) {
|
function goToPage(p: number) {
|
||||||
@@ -81,7 +81,7 @@ export default function AdminNotificationsPage() {
|
|||||||
load(p)
|
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 (
|
return (
|
||||||
<div className="shell py-8 space-y-6">
|
<div className="shell py-8 space-y-6">
|
||||||
@@ -118,6 +118,12 @@ export default function AdminNotificationsPage() {
|
|||||||
<option value="">All statuses</option>
|
<option value="">All statuses</option>
|
||||||
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
|
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||||
</select>
|
</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>
|
</div>
|
||||||
|
|
||||||
{error && <div className="panel p-4 text-sm text-red-400">{error}</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">
|
<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">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">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">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">Channel</th>
|
||||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Title</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>
|
</thead>
|
||||||
<tbody className="divide-y divide-zinc-800/60">
|
<tbody className="divide-y divide-zinc-800/60">
|
||||||
{loading ? (
|
{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 ? (
|
) : !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) => (
|
) : result.data.map((item) => (
|
||||||
<tr key={item.id} className="hover:bg-zinc-800/30 transition-colors">
|
<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">
|
<td className="whitespace-nowrap px-5 py-3 text-xs text-zinc-500">
|
||||||
{new Date(item.createdAt).toLocaleString()}
|
{formatNotificationDate(item.createdAt)}
|
||||||
</td>
|
</td>
|
||||||
<td className="whitespace-nowrap px-5 py-3 text-xs text-zinc-300">
|
<td className="whitespace-nowrap px-5 py-3 text-xs text-zinc-300">
|
||||||
{item.company?.name ?? (item.companyId ? item.companyId.slice(0, 10) + '…' : '—')}
|
{item.company?.name ?? (item.companyId ? item.companyId.slice(0, 10) + '…' : '—')}
|
||||||
</td>
|
</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">
|
<td className="whitespace-nowrap px-5 py-3 text-xs font-medium text-zinc-300">
|
||||||
{item.type.replaceAll('_', ' ')}
|
{item.type.replaceAll('_', ' ')}
|
||||||
</td>
|
</td>
|
||||||
@@ -167,7 +180,7 @@ export default function AdminNotificationsPage() {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="whitespace-nowrap px-5 py-3 text-xs text-zinc-500">
|
<td className="whitespace-nowrap px-5 py-3 text-xs text-zinc-500">
|
||||||
{item.sentAt ? new Date(item.sentAt).toLocaleString() : '—'}
|
{formatNotificationDate(item.sentAt)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import { resolveServerAppUrl } from '@/lib/appUrls'
|
|||||||
|
|
||||||
export default async function AdminLoginPage() {
|
export default async function AdminLoginPage() {
|
||||||
const requestHeaders = await headers()
|
const requestHeaders = await headers()
|
||||||
const dashboardUrl = resolveServerAppUrl(
|
const websiteUrl = resolveServerAppUrl(
|
||||||
process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard',
|
process.env.NEXT_PUBLIC_WEBSITE_URL ?? 'http://localhost:3000',
|
||||||
requestHeaders.get('host'),
|
requestHeaders.get('host'),
|
||||||
requestHeaders.get('x-forwarded-proto'),
|
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')
|
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', () => {
|
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('http://localhost:3002/admin', null)).toBe('http://localhost:3002/admin')
|
||||||
expect(resolveServerAppUrl('/admin', 'admin.example.com')).toBe('/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 {
|
export function resolveServerAppUrl(fallback: string, host: string | null, proto?: string | null): string {
|
||||||
if (!host) return fallback
|
if (!host || isInternalHost(host)) return fallback
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const target = new URL(fallback)
|
const target = new URL(fallback)
|
||||||
|
|||||||
+12
-5
@@ -63,11 +63,18 @@ function isAllowedLocalDevOrigin(origin: string) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const url = new URL(origin)
|
const url = new URL(origin)
|
||||||
return (
|
if (url.protocol !== 'http:') return false
|
||||||
url.protocol === 'http:' &&
|
if (!['3000', '3001', '3002', '3004', '4000'].includes(url.port)) return false
|
||||||
['localhost', '127.0.0.1'].includes(url.hostname) &&
|
if (['localhost', '127.0.0.1'].includes(url.hostname)) return true
|
||||||
['3000', '3001', '3002', '4000'].includes(url.port)
|
|
||||||
)
|
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 {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,21 @@ function isAllowedDevelopmentOrigin(origin: string) {
|
|||||||
if (process.env.NODE_ENV === 'production') return false
|
if (process.env.NODE_ENV === 'production') return false
|
||||||
try {
|
try {
|
||||||
const url = new URL(origin)
|
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 {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,25 @@ function calculateLineAmounts(items: Array<{ type: string; amount: number }>) {
|
|||||||
return { subtotalAmount, discountAmount, creditAmount, taxAmount, totalAmount }
|
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: {
|
async function createBillingEvent(tx: any, data: {
|
||||||
billingAccountId?: string | null
|
billingAccountId?: string | null
|
||||||
invoiceId?: 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),
|
.reduce((sum, item) => sum + (item._sum.totalAmount ?? 0), 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (accounts as any[]).map((account) => {
|
const data = (accounts as any[]).map((account) => withBillingAccountBalances(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),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return { data, total, stats }
|
return { data, total, stats }
|
||||||
}
|
}
|
||||||
@@ -513,7 +519,7 @@ export async function getBillingAccountDetail(companyId: string) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!account) throw new NotFoundError('Billing account not found')
|
if (!account) throw new NotFoundError('Billing account not found')
|
||||||
return account
|
return withBillingAccountBalances(account)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateBillingAccount(
|
export async function updateBillingAccount(
|
||||||
|
|||||||
@@ -564,20 +564,126 @@ export async function listNotificationsPage(query: {
|
|||||||
page: number
|
page: number
|
||||||
pageSize: number
|
pageSize: number
|
||||||
}) {
|
}) {
|
||||||
const where: any = {}
|
const legacyStatuses = new Set(['PENDING', 'SENT', 'DELIVERED', 'FAILED', 'READ'])
|
||||||
if (query.channel) where.channel = query.channel
|
const legacyWhere: any = {}
|
||||||
if (query.status) where.status = query.status
|
if (query.channel) legacyWhere.channel = query.channel
|
||||||
if (query.companyId) where.companyId = query.companyId
|
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({
|
prisma.notification.findMany({
|
||||||
where,
|
where: legacyWhere,
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
skip: (query.page - 1) * query.pageSize,
|
take,
|
||||||
take: query.pageSize,
|
include: {
|
||||||
include: { company: { select: { name: true } } },
|
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 { email, password, totpCode, recoveryCode } = parseBody(loginSchema, req)
|
||||||
const result = await service.login(email, password, totpCode, recoveryCode)
|
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 (!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 })
|
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)
|
setSessionCookie(res, 'admin', result.token, 8 * 60 * 60 * 1000)
|
||||||
ok(res, result)
|
ok(res, result)
|
||||||
} catch (err) { next(err) }
|
} 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),
|
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({
|
export const notificationsQuerySchema = z.object({
|
||||||
channel: z.string().optional(),
|
channel: notificationChannelSchema.optional(),
|
||||||
status: z.string().optional(),
|
status: notificationStatusSchema.optional(),
|
||||||
companyId: z.string().optional(),
|
companyId: z.string().optional(),
|
||||||
page: z.coerce.number().int().min(1).default(1),
|
page: z.coerce.number().int().min(1).default(1),
|
||||||
pageSize: z.coerce.number().int().min(1).max(200).default(50),
|
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 { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
|
|
||||||
vi.mock('./admin.repo', () => ({
|
vi.mock('./admin.repo', () => ({
|
||||||
findAdminByEmail: vi.fn(),
|
findAdminByEmail: vi.fn(),
|
||||||
setAdminPasswordReset: vi.fn(),
|
setAdminPasswordReset: vi.fn(),
|
||||||
|
updateAdminLastLogin: vi.fn(),
|
||||||
|
createAuditLog: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../../services/notificationService', () => ({
|
vi.mock('../../services/notificationService', () => ({
|
||||||
sendTransactionalEmail: vi.fn().mockResolvedValue(undefined),
|
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 * as repo from './admin.repo'
|
||||||
import { sendTransactionalEmail } from '../../services/notificationService'
|
import { sendTransactionalEmail } from '../../services/notificationService'
|
||||||
import { forgotPassword } from './admin.service'
|
import { forgotPassword, login } from './admin.service'
|
||||||
|
|
||||||
describe('admin.service forgotPassword', () => {
|
describe('admin.service forgotPassword', () => {
|
||||||
const originalAdminUrl = process.env.ADMIN_URL
|
const originalAdminUrl = process.env.ADMIN_URL
|
||||||
|
const originalJwtSecret = process.env.JWT_SECRET
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
redisStore.clear()
|
||||||
process.env.ADMIN_URL = 'http://localhost:3000/admin'
|
process.env.ADMIN_URL = 'http://localhost:3000/admin'
|
||||||
|
process.env.JWT_SECRET = 'test-jwt-secret'
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
process.env.ADMIN_URL = originalAdminUrl
|
process.env.ADMIN_URL = originalAdminUrl
|
||||||
|
process.env.JWT_SECRET = originalJwtSecret
|
||||||
})
|
})
|
||||||
|
|
||||||
it('sends the reset email to the canonical stored admin address', async () => {
|
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 qrcode from 'qrcode'
|
||||||
import { getCarplaceHomepageContent, saveCarplaceHomepageContent } from '../../services/platformContentService'
|
import { getCarplaceHomepageContent, saveCarplaceHomepageContent } from '../../services/platformContentService'
|
||||||
import { sendTransactionalEmail } from '../../services/notificationService'
|
import { sendTransactionalEmail } from '../../services/notificationService'
|
||||||
|
import { redis } from '../../lib/redis'
|
||||||
import * as presenter from './admin.presenter'
|
import * as presenter from './admin.presenter'
|
||||||
import * as repo from './admin.repo'
|
import * as repo from './admin.repo'
|
||||||
import * as billingService from './admin.billing.service'
|
import * as billingService from './admin.billing.service'
|
||||||
|
|
||||||
const ADMIN_RESET_TTL_MINUTES = 60
|
const ADMIN_RESET_TTL_MINUTES = 60
|
||||||
const ADMIN_RECOVERY_CODE_COUNT = 10
|
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() {
|
function generateRecoveryCode() {
|
||||||
@@ -57,6 +62,61 @@ function signAdminToken(adminId: string, last2faAt?: number) {
|
|||||||
return signActorToken(adminId, 'admin', { expiresIn: '8h', last2faAt })
|
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) {
|
function toAuditJson<T>(value: T) {
|
||||||
return JSON.parse(JSON.stringify(value))
|
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 (!valid) return null
|
||||||
|
|
||||||
if (admin.totpEnabled) {
|
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
|
const validTotp = totpCode
|
||||||
? authenticator.verify({ token: totpCode, secret: admin.totpSecret! })
|
? authenticator.verify({ token: totpCode, secret: admin.totpSecret! })
|
||||||
: false
|
: false
|
||||||
const validRecoveryCode = !validTotp && recoveryCode
|
const validEmailOtp = !validTotp && await consumeAdminEmailOtp(admin.id, totpCode)
|
||||||
|
const validRecoveryCode = !validTotp && !validEmailOtp && recoveryCode
|
||||||
? await consumeAdminRecoveryCode(admin.id, recoveryCode)
|
? await consumeAdminRecoveryCode(admin.id, recoveryCode)
|
||||||
: false
|
: false
|
||||||
|
|
||||||
if (!validTotp && !validRecoveryCode) {
|
if (!validTotp && !validEmailOtp && !validRecoveryCode) {
|
||||||
return { invalidTotp: true } as const
|
return { invalidTotp: true } as const
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ router.post('/login', async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const body = parseBody(employeeLoginSchema, req)
|
const body = parseBody(employeeLoginSchema, req)
|
||||||
const result = await service.login(body)
|
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)
|
ok(res, result)
|
||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ vi.mock('../../lib/redis', () => ({
|
|||||||
|
|
||||||
import request from 'supertest'
|
import request from 'supertest'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { createApp } from '../../app'
|
import { createApp, isCorsOriginAllowed } from '../../app'
|
||||||
|
import { isTrustedBrowserOrigin } from '../../middleware/csrf'
|
||||||
|
|
||||||
const app = createApp()
|
const app = createApp()
|
||||||
|
|
||||||
@@ -87,6 +88,13 @@ describe('API foundation integration', () => {
|
|||||||
expect(res.headers['access-control-allow-credentials']).toBe('true')
|
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 () => {
|
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')
|
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(res.body).toEqual({ data: { data: [{ id: 'company_1' }], pagination: { page: 1 } } })
|
||||||
expect(adminService.listCompanies).toHaveBeenCalledWith({ page: 1, pageSize: 20 })
|
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' })
|
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 () => {
|
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' })
|
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 {
|
import {
|
||||||
authHeader,
|
authHeader,
|
||||||
createAdminUser,
|
createAdminUser,
|
||||||
|
createCompanyNotification,
|
||||||
createCompanyWithEmployee,
|
createCompanyWithEmployee,
|
||||||
createRenter,
|
createRenter,
|
||||||
|
createRenterNotification,
|
||||||
signAdminToken,
|
signAdminToken,
|
||||||
} from '../helpers/fixtures'
|
} from '../helpers/fixtures'
|
||||||
|
|
||||||
@@ -17,6 +19,7 @@ describe('Admin API', () => {
|
|||||||
let viewerToken: string
|
let viewerToken: string
|
||||||
let adminId: string
|
let adminId: string
|
||||||
let companyId: string
|
let companyId: string
|
||||||
|
let employeeId: string
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const admin = await createAdminUser({
|
const admin = await createAdminUser({
|
||||||
@@ -56,8 +59,9 @@ describe('Admin API', () => {
|
|||||||
})
|
})
|
||||||
viewerToken = signAdminToken(viewerAdmin.id)
|
viewerToken = signAdminToken(viewerAdmin.id)
|
||||||
|
|
||||||
const { company } = await createCompanyWithEmployee()
|
const { company, employee } = await createCompanyWithEmployee()
|
||||||
companyId = company.id
|
companyId = company.id
|
||||||
|
employeeId = employee.id
|
||||||
await createRenter()
|
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', () => {
|
describe('GET /api/v1/admin/metrics', () => {
|
||||||
it('returns 403 for VIEWER role', async () => {
|
it('returns 403 for VIEWER role', async () => {
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
@@ -173,6 +212,9 @@ describe('Admin API', () => {
|
|||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
expect(res.body.data.id).toBe(billingAccountId)
|
expect(res.body.data.id).toBe(billingAccountId)
|
||||||
expect(res.body.data.company.id).toBe(companyId)
|
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)
|
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',
|
verify: 'Verify code',
|
||||||
verifying: 'Verifying…',
|
verifying: 'Verifying…',
|
||||||
authCode: 'Authentication code',
|
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',
|
totpPlaceholder: '000000 or XXXX-XXXX-XXXX',
|
||||||
back: 'Back to credentials',
|
back: 'Back to credentials',
|
||||||
forgotPassword: 'Forgot your password?',
|
forgotPassword: 'Forgot your password?',
|
||||||
@@ -67,7 +67,7 @@ const dicts: Record<string, Dict> = {
|
|||||||
verify: 'Vérifier le code',
|
verify: 'Vérifier le code',
|
||||||
verifying: 'Vérification…',
|
verifying: 'Vérification…',
|
||||||
authCode: "Code d'authentification",
|
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',
|
totpPlaceholder: '000000 ou XXXX-XXXX-XXXX',
|
||||||
back: 'Retour aux identifiants',
|
back: 'Retour aux identifiants',
|
||||||
forgotPassword: 'Mot de passe oublié ?',
|
forgotPassword: 'Mot de passe oublié ?',
|
||||||
@@ -89,7 +89,7 @@ const dicts: Record<string, Dict> = {
|
|||||||
verify: 'تحقق من الرمز',
|
verify: 'تحقق من الرمز',
|
||||||
verifying: 'جارٍ التحقق…',
|
verifying: 'جارٍ التحقق…',
|
||||||
authCode: 'رمز المصادقة',
|
authCode: 'رمز المصادقة',
|
||||||
enterCode: 'أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة.',
|
enterCode: 'أدخل الرمز المكون من 6 أرقام المرسل إلى بريد المسؤول، أو استخدم تطبيق المصادقة.',
|
||||||
totpPlaceholder: '000000 أو XXXX-XXXX-XXXX',
|
totpPlaceholder: '000000 أو XXXX-XXXX-XXXX',
|
||||||
back: 'العودة إلى بيانات الدخول',
|
back: 'العودة إلى بيانات الدخول',
|
||||||
forgotPassword: 'نسيت كلمة المرور؟',
|
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 dict = (dicts[locale] ?? dicts.en) as Dict;
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const mode = modeFromPathname(usePathname());
|
const pathname = usePathname();
|
||||||
|
const mode = modeFromPathname(pathname);
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
@@ -113,8 +131,12 @@ export function SignInForm({ locale }: { locale: Locale }) {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const requestedPortal = searchParams.get('portal');
|
const requestedPortal = searchParams.get('portal');
|
||||||
|
const requestedNext = searchParams.get('next') || searchParams.get('redirect') || '';
|
||||||
const employeeRedirect = searchParams.get('redirect') || '/dashboard';
|
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) {
|
async function handleCredentials(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -196,6 +218,7 @@ export function SignInForm({ locale }: { locale: Locale }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (await tryEmployeeLogin()) return;
|
if (await tryEmployeeLogin()) return;
|
||||||
|
if (await tryAdminLogin()) return;
|
||||||
|
|
||||||
setError(dict.invalidCredentials);
|
setError(dict.invalidCredentials);
|
||||||
} catch {
|
} 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": "turbo db:seed",
|
||||||
"db:seed:admin": "npm run db:seed --workspace @rentaldrivego/database",
|
"db:seed:admin": "npm run db:seed --workspace @rentaldrivego/database",
|
||||||
"admin:2fa:enroll": "node scripts/enroll-admin-2fa.cjs",
|
"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",
|
"db:studio": "cd packages/database && npx prisma studio",
|
||||||
"test:api": "npm run test --workspace @rentaldrivego/api",
|
"test:api": "npm run test --workspace @rentaldrivego/api",
|
||||||
"test:api:integration": "npm run test:integration --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`
|
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) {
|
function updateCookieJar(cookieJar, setCookieHeaders) {
|
||||||
for (const header of setCookieHeaders) {
|
for (const header of setCookieHeaders) {
|
||||||
const pair = header.split(';')[0]
|
const pair = header.split(';')[0]
|
||||||
@@ -71,10 +88,23 @@ function cookieHeader(cookieJar) {
|
|||||||
.join('; ')
|
.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 = {
|
const headers = {
|
||||||
...(options.body ? { 'Content-Type': 'application/json' } : {}),
|
...(options.body ? { 'Content-Type': 'application/json' } : {}),
|
||||||
...(cookieJar.size ? { Cookie: cookieHeader(cookieJar) } : {}),
|
...(cookieJar.size ? { Cookie: cookieHeader(cookieJar) } : {}),
|
||||||
|
...(trustedOrigin && isMutatingMethod(options.method) ? { Origin: trustedOrigin } : {}),
|
||||||
...options.headers,
|
...options.headers,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,12 +132,13 @@ async function main() {
|
|||||||
const localEnv = loadLocalEnv()
|
const localEnv = loadLocalEnv()
|
||||||
const env = { ...localEnv, ...process.env }
|
const env = { ...localEnv, ...process.env }
|
||||||
|
|
||||||
const email = env.ADMIN_SEED_EMAIL || 'admin@rentaldrivego.ma'
|
const email = readValue(env, 'email', ['ADMIN_EMAIL', 'ADMIN_SEED_EMAIL'], 'admin@rentaldrivego.ma')
|
||||||
const password = env.ADMIN_SEED_PASSWORD
|
const password = readValue(env, 'password', ['ADMIN_PASSWORD', 'ADMIN_SEED_PASSWORD'])
|
||||||
const apiBase = normalizeApiBase(env.NEXT_PUBLIC_API_URL || env.API_URL)
|
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)) {
|
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()
|
const cookieJar = new Map()
|
||||||
@@ -115,7 +146,7 @@ async function main() {
|
|||||||
const login = await request(apiBase, cookieJar, '/admin/auth/login', {
|
const login = await request(apiBase, cookieJar, '/admin/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password }),
|
||||||
})
|
}, trustedOrigin)
|
||||||
|
|
||||||
if (!login.response.ok) {
|
if (!login.response.ok) {
|
||||||
const error = login.json?.error || login.response.status
|
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}`)
|
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
|
const admin = me.json?.data ?? me.json
|
||||||
if (admin?.totpEnabled) {
|
if (admin?.totpEnabled) {
|
||||||
console.log(`Admin 2FA is already enabled for ${email}.`)
|
console.log(`Admin 2FA is already enabled for ${email}.`)
|
||||||
return
|
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) {
|
if (!setup.response.ok) {
|
||||||
throw new Error(setup.json?.message || 'Failed to create admin 2FA setup secret.')
|
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', {
|
const verify = await request(apiBase, cookieJar, '/admin/auth/2fa/verify', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ code }),
|
body: JSON.stringify({ code }),
|
||||||
})
|
}, trustedOrigin)
|
||||||
|
|
||||||
if (!verify.response.ok) {
|
if (!verify.response.ok) {
|
||||||
throw new Error(verify.json?.message || 'Failed to verify generated 2FA code.')
|
throw new Error(verify.json?.message || 'Failed to verify generated 2FA code.')
|
||||||
|
|||||||
Reference in New Issue
Block a user