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

This commit is contained in:
root
2026-07-28 22:56:18 -04:00
parent 5649ab02c7
commit 50c74b9007
29 changed files with 1155 additions and 118 deletions
@@ -212,11 +212,12 @@ const draftLineItem = (): DraftLineItem => ({
})
function money(amount: number, currency: string) {
const safeAmount = Number.isFinite(amount) ? amount : 0
return new Intl.NumberFormat('en-GB', {
style: 'currency',
currency,
minimumFractionDigits: 2,
}).format(amount / 100)
}).format(safeAmount / 100)
}
function dateLabel(value: string | null | undefined) {
@@ -207,6 +207,17 @@ const STATUS_COLORS: Record<string, string> = {
const INPUT_CLASS = 'mt-1 w-full rounded-xl border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-emerald-500'
const LABEL_CLASS = 'text-xs font-medium uppercase tracking-wide text-zinc-500'
const COMPANY_TABS = [
{ id: 'company', label: 'Company' },
{ id: 'subscription', label: 'Subscription' },
{ id: 'publicProfile', label: 'Public profile' },
{ id: 'legalProfile', label: 'Legal profile' },
{ id: 'operations', label: 'Operations & finance' },
{ id: 'activity', label: 'Activity' },
] as const
type CompanyTabId = (typeof COMPANY_TABS)[number]['id']
function toDateInput(value: string | null | undefined) {
return value ? new Date(value).toISOString().slice(0, 10) : ''
}
@@ -318,6 +329,7 @@ export default function AdminCompanyDetailPage() {
const [saving, setSaving] = useState(false)
const [savedMessage, setSavedMessage] = useState<string | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState(false)
const [activeTab, setActiveTab] = useState<CompanyTabId>('company')
async function fetchData() {
try {
@@ -566,8 +578,40 @@ export default function AdminCompanyDetailPage() {
))}
</div>
<div className="grid gap-6 xl:grid-cols-2">
<section className="panel p-6">
<div className="space-y-6">
<div className="overflow-x-auto border-b border-zinc-800">
<div className="flex min-w-max gap-1" role="tablist" aria-label="Company detail subjects">
{COMPANY_TABS.map((tab) => {
const selected = activeTab === tab.id
return (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={selected}
aria-controls={`company-tab-${tab.id}`}
id={`company-tab-trigger-${tab.id}`}
onClick={() => setActiveTab(tab.id)}
className={`rounded-t-xl px-4 py-3 text-sm font-semibold transition-colors ${
selected
? 'bg-zinc-800 text-white'
: 'text-zinc-500 hover:bg-zinc-900 hover:text-zinc-200'
}`}
>
{tab.label}
</button>
)
})}
</div>
</div>
<section
id="company-tab-company"
role="tabpanel"
aria-labelledby="company-tab-trigger-company"
hidden={activeTab !== 'company'}
className="panel p-6"
>
<h2 className="text-base font-semibold">Company</h2>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<label>
@@ -597,7 +641,13 @@ export default function AdminCompanyDetailPage() {
</div>
</section>
<section className="panel p-6">
<section
id="company-tab-subscription"
role="tabpanel"
aria-labelledby="company-tab-trigger-subscription"
hidden={activeTab !== 'subscription'}
className="panel p-6"
>
<h2 className="text-base font-semibold">Subscription</h2>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<label>
@@ -663,7 +713,13 @@ export default function AdminCompanyDetailPage() {
</div>
</section>
<section className="panel p-6">
<section
id="company-tab-publicProfile"
role="tabpanel"
aria-labelledby="company-tab-trigger-publicProfile"
hidden={activeTab !== 'publicProfile'}
className="panel p-6"
>
<h2 className="text-base font-semibold">Brand and public profile</h2>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<label>
@@ -729,7 +785,13 @@ export default function AdminCompanyDetailPage() {
</div>
</section>
<section className="panel p-6">
<section
id="company-tab-legalProfile"
role="tabpanel"
aria-labelledby="company-tab-trigger-legalProfile"
hidden={activeTab !== 'legalProfile'}
className="panel p-6"
>
<h2 className="text-base font-semibold">Company legal profile</h2>
<div className="mt-4 grid gap-6">
<div className="grid gap-4 md:grid-cols-2">
@@ -817,7 +879,13 @@ export default function AdminCompanyDetailPage() {
</div>
</section>
<section className="panel p-6">
<section
id="company-tab-operations"
role="tabpanel"
aria-labelledby="company-tab-trigger-operations"
hidden={activeTab !== 'operations'}
className="panel p-6"
>
<h2 className="text-base font-semibold">Operations and finance</h2>
<div className="mt-4 grid gap-6">
<div className="grid gap-4 md:grid-cols-2">
@@ -915,7 +983,13 @@ export default function AdminCompanyDetailPage() {
</section>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<div
id="company-tab-activity"
role="tabpanel"
aria-labelledby="company-tab-trigger-activity"
hidden={activeTab !== 'activity'}
className="grid gap-6 lg:grid-cols-2"
>
<div className="space-y-4">
<div className="panel p-6 space-y-4">
<h2 className="text-base font-semibold">Company actions</h2>
+3 -4
View File
@@ -13,12 +13,11 @@ import { ADMIN_API_BASE } from '@/lib/api'
import { AdminSessionProvider, type AdminSessionUser } from './AdminSessionContext'
function buildUnifiedLoginUrl(nextPath: string) {
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard')
const websiteUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_WEBSITE_URL ?? 'http://localhost:3000')
const params = new URLSearchParams({
portal: 'admin',
next: nextPath || '/dashboard',
next: `/admin${nextPath || '/dashboard'}`,
})
return `${dashboardUrl}/sign-in?${params.toString()}`
return `${websiteUrl}/en/light/admin-sign-in?${params.toString()}`
}
const navLinks = [
@@ -1,32 +1,14 @@
'use client'
import { useEffect, useState } from 'react'
import { ADMIN_API_BASE } from '@/lib/api'
interface NotificationItem {
id: string
type: string
title: string
body: string
channel: string
status: string
locale: string
sentAt: string | null
createdAt: string
company: { name: string } | null
companyId: string | null
renterId: string | null
}
interface Paginated {
data: NotificationItem[]
total: number
page: number
pageSize: number
}
import {
fetchAdminNotifications,
formatNotificationDate,
type NotificationsPageResult,
} from '@/lib/adminNotifications'
const CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'IN_APP', 'PUSH']
const STATUSES = ['PENDING', 'SENT', 'DELIVERED', 'FAILED', 'READ']
const STATUSES = ['PENDING', 'QUEUED', 'SENT', 'DELIVERED', 'FAILED', 'SKIPPED', 'DEAD_LETTER', 'READ']
const CHANNEL_BADGE: Record<string, string> = {
EMAIL: 'text-sky-400 bg-sky-950/40',
@@ -41,11 +23,26 @@ const STATUS_BADGE: Record<string, string> = {
SENT: 'text-emerald-400 bg-emerald-950/40',
DELIVERED: 'text-emerald-400 bg-emerald-950/40',
FAILED: 'text-red-400 bg-red-950/40',
QUEUED: 'text-sky-400 bg-sky-950/40',
SKIPPED: 'text-zinc-400 bg-zinc-800',
DEAD_LETTER: 'text-red-300 bg-red-950/60',
READ: 'text-zinc-400 bg-zinc-800',
}
function formatRecipient(item: {
recipientType: string | null
recipientName: string | null
recipientEmail: string | null
employeeId: string | null
renterId: string | null
}) {
const fallbackId = item.employeeId ?? item.renterId
const label = item.recipientName || item.recipientEmail || (fallbackId ? `${fallbackId.slice(0, 10)}...` : '-')
return item.recipientType ? `${item.recipientType}: ${label}` : label
}
export default function AdminNotificationsPage() {
const [result, setResult] = useState<Paginated | null>(null)
const [result, setResult] = useState<NotificationsPageResult | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [filterChannel, setFilterChannel] = useState('')
@@ -53,27 +50,30 @@ export default function AdminNotificationsPage() {
const [filterCompany, setFilterCompany] = useState('')
const [page, setPage] = useState(1)
function load(p: number) {
function load(p: number, signal?: AbortSignal) {
setLoading(true)
setError(null)
const params = new URLSearchParams({ page: String(p), pageSize: '50' })
if (filterChannel) params.set('channel', filterChannel)
if (filterStatus) params.set('status', filterStatus)
if (filterCompany) params.set('companyId', filterCompany)
fetch(`${ADMIN_API_BASE}/admin/notifications?${params.toString()}`, {
credentials: 'include',
cache: 'no-store',
})
.then((r) => r.json())
.then((json) => setResult(json.data ?? null))
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
fetchAdminNotifications(
p,
{ channel: filterChannel, status: filterStatus, companyId: filterCompany },
signal,
)
.then((data) => setResult(data))
.catch((err) => {
if (err instanceof DOMException && err.name === 'AbortError') return
setResult(null)
setError(err instanceof Error ? err.message : 'Failed to load notifications')
})
.finally(() => {
if (!signal?.aborted) setLoading(false)
})
}
useEffect(() => {
const controller = new AbortController()
setPage(1)
load(1)
load(1, controller.signal)
return () => controller.abort()
}, [filterChannel, filterStatus, filterCompany])
function goToPage(p: number) {
@@ -81,7 +81,7 @@ export default function AdminNotificationsPage() {
load(p)
}
const totalPages = result ? Math.ceil(result.total / result.pageSize) : 0
const totalPages = result ? (result.totalPages ?? Math.ceil(result.total / result.pageSize)) : 0
return (
<div className="shell py-8 space-y-6">
@@ -118,6 +118,12 @@ export default function AdminNotificationsPage() {
<option value="">All statuses</option>
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<input
value={filterCompany}
onChange={(e) => setFilterCompany(e.target.value)}
placeholder="Company ID"
className="w-64 rounded-xl border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-orange-500"
/>
</div>
{error && <div className="panel p-4 text-sm text-red-400">{error}</div>}
@@ -129,6 +135,7 @@ export default function AdminNotificationsPage() {
<tr className="border-b border-zinc-800">
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Date</th>
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Company</th>
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Recipient</th>
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Event</th>
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Channel</th>
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Title</th>
@@ -138,17 +145,23 @@ export default function AdminNotificationsPage() {
</thead>
<tbody className="divide-y divide-zinc-800/60">
{loading ? (
<tr><td colSpan={7} className="px-5 py-12 text-center text-zinc-500">Loading</td></tr>
<tr><td colSpan={8} className="px-5 py-12 text-center text-zinc-500">Loading</td></tr>
) : !result || result.data.length === 0 ? (
<tr><td colSpan={7} className="px-5 py-12 text-center text-zinc-500">No notifications found.</td></tr>
<tr><td colSpan={8} className="px-5 py-12 text-center text-zinc-500">No notifications found.</td></tr>
) : result.data.map((item) => (
<tr key={item.id} className="hover:bg-zinc-800/30 transition-colors">
<td className="whitespace-nowrap px-5 py-3 text-xs text-zinc-500">
{new Date(item.createdAt).toLocaleString()}
{formatNotificationDate(item.createdAt)}
</td>
<td className="whitespace-nowrap px-5 py-3 text-xs text-zinc-300">
{item.company?.name ?? (item.companyId ? item.companyId.slice(0, 10) + '…' : '—')}
</td>
<td className="max-w-[220px] px-5 py-3 text-xs text-zinc-300">
<p className="truncate">{formatRecipient(item)}</p>
{item.recipientEmail && item.recipientName ? (
<p className="truncate text-zinc-500">{item.recipientEmail}</p>
) : null}
</td>
<td className="whitespace-nowrap px-5 py-3 text-xs font-medium text-zinc-300">
{item.type.replaceAll('_', ' ')}
</td>
@@ -167,7 +180,7 @@ export default function AdminNotificationsPage() {
</span>
</td>
<td className="whitespace-nowrap px-5 py-3 text-xs text-zinc-500">
{item.sentAt ? new Date(item.sentAt).toLocaleString() : '—'}
{formatNotificationDate(item.sentAt)}
</td>
</tr>
))}
+3 -3
View File
@@ -4,10 +4,10 @@ import { resolveServerAppUrl } from '@/lib/appUrls'
export default async function AdminLoginPage() {
const requestHeaders = await headers()
const dashboardUrl = resolveServerAppUrl(
process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard',
const websiteUrl = resolveServerAppUrl(
process.env.NEXT_PUBLIC_WEBSITE_URL ?? 'http://localhost:3000',
requestHeaders.get('host'),
requestHeaders.get('x-forwarded-proto'),
)
redirect(`${dashboardUrl}/sign-in?portal=admin&next=/dashboard`)
redirect(`${websiteUrl}/en/light/admin-sign-in?next=/admin/dashboard`)
}