fix billing and 2fa admin
Build & Push / Pipeline Tests (push) Failing after 1m58s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 58s
Test / API Unit Tests (push) Successful in 1m9s
Test / Homepage Unit Tests (push) Successful in 46s
Test / Carplace Unit Tests (push) Successful in 43s
Test / Admin Unit Tests (push) Successful in 41s
Test / Dashboard Unit Tests (push) Successful in 45s
Test / API Integration Tests (push) Failing after 1m9s

This commit is contained in:
root
2026-08-10 22:35:55 -04:00
parent 10ca76fc1e
commit 5f06256271
73 changed files with 8803 additions and 570 deletions
@@ -9,6 +9,7 @@ interface AdminUser {
lastName: string
email: string
role: string
preferredLocale: 'ar' | 'en' | 'fr'
isActive: boolean
createdAt: string
permissions?: { id: string; resource: string; actions: string[] }[]
@@ -21,6 +22,7 @@ const EMPTY_FORM = {
email: '',
password: '',
role: 'SUPPORT',
preferredLocale: 'en',
isActive: true,
}
@@ -68,6 +70,7 @@ export default function AdminUsersPage() {
email: admin.email,
password: '',
role: admin.role,
preferredLocale: admin.preferredLocale,
isActive: admin.isActive,
})
setError(null)
@@ -92,6 +95,7 @@ export default function AdminUsersPage() {
lastName: form.lastName,
email: form.email,
role: form.role,
preferredLocale: form.preferredLocale,
isActive: form.isActive,
...(form.password ? { password: form.password } : {}),
}
@@ -273,6 +277,18 @@ export default function AdminUsersPage() {
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-medium text-zinc-400 mb-1">Notification language</label>
<select
className="w-full px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
value={form.preferredLocale}
onChange={(e) => setForm({ ...form, preferredLocale: e.target.value as 'ar' | 'en' | 'fr' })}
>
<option value="en">English</option>
<option value="fr">Français</option>
<option value="ar">العربية</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-zinc-400 mb-1">Status</label>
<select
File diff suppressed because it is too large Load Diff
@@ -80,7 +80,6 @@ interface CompanyDetail {
terms: string
fuelPolicyType: string
lateFeePerHour: number | null
taxRate: number | null
signatureRequired: boolean
showTax: boolean
} | null
@@ -180,7 +179,6 @@ interface FormState {
terms: string
fuelPolicyType: string
lateFeePerHour: string
taxRate: string
signatureRequired: boolean
showTax: boolean
}
@@ -297,7 +295,6 @@ function createFormState(company: CompanyDetail): FormState {
terms: company.contractSettings?.terms ?? '',
fuelPolicyType: company.contractSettings?.fuelPolicyType ?? 'FULL_TO_FULL',
lateFeePerHour: company.contractSettings?.lateFeePerHour?.toString() ?? '',
taxRate: company.contractSettings?.taxRate?.toString() ?? '',
signatureRequired: company.contractSettings?.signatureRequired ?? true,
showTax: company.contractSettings?.showTax ?? false,
},
@@ -441,7 +438,6 @@ export default function AdminCompanyDetailPage() {
terms: form.contractSettings.terms,
fuelPolicyType: form.contractSettings.fuelPolicyType,
lateFeePerHour: form.contractSettings.lateFeePerHour ? Number(form.contractSettings.lateFeePerHour) : null,
taxRate: form.contractSettings.taxRate ? Number(form.contractSettings.taxRate) : null,
signatureRequired: form.contractSettings.signatureRequired,
showTax: form.contractSettings.showTax,
},
@@ -915,10 +911,6 @@ export default function AdminCompanyDetailPage() {
<span className={LABEL_CLASS}>Late fee per hour</span>
<input className={INPUT_CLASS} type="number" value={form.contractSettings.lateFeePerHour} onChange={(e) => updateSection('contractSettings', { lateFeePerHour: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Tax rate</span>
<input className={INPUT_CLASS} type="number" step="0.01" value={form.contractSettings.taxRate} onChange={(e) => updateSection('contractSettings', { taxRate: e.target.value })} />
</label>
<label className="flex items-center gap-3 pt-7 text-sm text-zinc-300">
<input type="checkbox" checked={form.contractSettings.signatureRequired} onChange={(e) => updateSection('contractSettings', { signatureRequired: e.target.checked })} />
Signature required
+198 -1
View File
@@ -2,7 +2,7 @@
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState, type FormEvent } from 'react'
import {
AdminLanguageSwitcher,
AdminThemeSwitcher,
@@ -40,6 +40,7 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
const pathname = usePathname()
const [ready, setReady] = useState(false)
const [admin, setAdmin] = useState<AdminSessionUser | null>(null)
const [unreadNotifications, setUnreadNotifications] = useState(0)
useEffect(() => {
let cancelled = false
@@ -58,6 +59,14 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
}
setAdmin(resolvedAdmin)
setReady(true)
if (resolvedAdmin.totpEnabled) {
fetch(`${ADMIN_API_BASE}/admin/notifications/me`, { credentials: 'include', cache: 'no-store' })
.then((inboxResponse) => inboxResponse.ok ? inboxResponse.json() : null)
.then((inbox) => {
if (!cancelled) setUnreadNotifications(Number(inbox?.data?.unread ?? 0))
})
.catch(() => {})
}
} else {
window.location.replace(buildUnifiedLoginUrl(pathname))
}
@@ -84,6 +93,16 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
)
}
if (admin && !admin.totpEnabled) {
return (
<Admin2FAEnrollmentGate
admin={admin}
onEnrolled={setAdmin}
onLogout={handleLogout}
/>
)
}
return (
<AdminSessionProvider admin={admin as AdminSessionUser}>
<div className="flex h-screen bg-[linear-gradient(180deg,#ffffff_0%,#f5f8ff_28%,#eef4ff_58%,#ffffff_100%)] text-stone-900 transition-colors dark:bg-[linear-gradient(180deg,#0a1128_0%,#0d1b38_35%,#07101e_100%)] dark:text-slate-100">
@@ -107,6 +126,9 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
<path strokeLinecap="round" strokeLinejoin="round" d={link.icon} />
</svg>
{dict.nav[link.key]}
{link.key === 'notifications' && unreadNotifications > 0 ? (
<span className="ms-auto rounded-full bg-red-500 px-1.5 py-0.5 text-[10px] font-bold text-white">{unreadNotifications > 99 ? '99+' : unreadNotifications}</span>
) : null}
</Link>
)
})}
@@ -132,3 +154,178 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
</AdminSessionProvider>
)
}
function Admin2FAEnrollmentGate({
admin,
onEnrolled,
onLogout,
}: {
admin: AdminSessionUser
onEnrolled: (admin: AdminSessionUser) => void
onLogout: () => void
}) {
const { dict } = useAdminI18n()
const [secret, setSecret] = useState('')
const [qrCode, setQrCode] = useState('')
const [code, setCode] = useState('')
const [error, setError] = useState<string | null>(null)
const [loadingSetup, setLoadingSetup] = useState(true)
const [verifying, setVerifying] = useState(false)
const [verifiedAdmin, setVerifiedAdmin] = useState<AdminSessionUser | null>(null)
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([])
const setupStarted = useRef(false)
useEffect(() => {
if (setupStarted.current) return
setupStarted.current = true
fetch(`${ADMIN_API_BASE}/admin/auth/2fa/setup`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
.then(async (response) => {
const json = await response.json().catch(() => null)
if (!response.ok) throw new Error(json?.message ?? 'Failed to start 2FA setup.')
const data = json?.data ?? json
setSecret(data?.secret ?? '')
setQrCode(data?.qrCode ?? '')
})
.catch((err: any) => setError(err?.message ?? 'Failed to start 2FA setup.'))
.finally(() => setLoadingSetup(false))
}, [])
async function verifyCode(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const normalizedCode = code.trim()
if (!/^\d{6}$/.test(normalizedCode)) {
setError('Enter the 6-digit code from your authenticator app.')
return
}
setError(null)
setVerifying(true)
try {
const response = await fetch(`${ADMIN_API_BASE}/admin/auth/2fa/verify`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: normalizedCode }),
})
const json = await response.json().catch(() => null)
if (!response.ok) throw new Error(json?.message ?? 'Invalid 2FA code.')
const data = json?.data ?? json
setVerifiedAdmin((data?.admin ?? { ...admin, totpEnabled: true }) as AdminSessionUser)
setRecoveryCodes(Array.isArray(data?.recoveryCodes) ? data.recoveryCodes : [])
} catch (err: any) {
setError(err?.message ?? 'Invalid 2FA code.')
} finally {
setVerifying(false)
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-[linear-gradient(180deg,#ffffff_0%,#f5f8ff_28%,#eef4ff_58%,#ffffff_100%)] p-6 text-stone-900 transition-colors dark:bg-[linear-gradient(180deg,#0a1128_0%,#0d1b38_35%,#07101e_100%)] dark:text-slate-100">
<section className="w-full max-w-2xl rounded-3xl border border-stone-200/80 bg-white/90 p-8 shadow-xl backdrop-blur dark:border-blue-900 dark:bg-[#07101e]/90">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-orange-700 dark:text-orange-300">{dict.admin}</p>
<h1 className="mt-2 text-2xl font-black text-blue-950 dark:text-stone-50">Set up admin 2FA</h1>
<p className="mt-2 text-sm leading-6 text-stone-600 dark:text-slate-300">
Admin 2FA enrollment is required before using privileged admin routes.
</p>
<p className="mt-1 text-xs text-stone-500 dark:text-slate-400">{admin.email}</p>
</div>
<button
type="button"
onClick={onLogout}
className="rounded-xl border border-stone-200 px-4 py-2 text-sm font-semibold text-stone-600 transition hover:bg-stone-100 dark:border-blue-800 dark:text-slate-300 dark:hover:bg-[#162038]"
>
{dict.logout}
</button>
</div>
{verifiedAdmin ? (
<div className="mt-8 space-y-5">
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800 dark:border-emerald-900/50 dark:bg-emerald-950/30 dark:text-emerald-200">
2FA is enabled. Save your recovery codes before continuing.
</div>
{recoveryCodes.length > 0 ? (
<div className="rounded-2xl border border-stone-200 bg-stone-50 p-4 dark:border-blue-900 dark:bg-[#0d1b38]">
<p className="text-sm font-semibold text-blue-950 dark:text-stone-100">Recovery codes</p>
<div className="mt-3 grid gap-2 sm:grid-cols-2">
{recoveryCodes.map((recoveryCode) => (
<code key={recoveryCode} className="rounded-lg bg-white px-3 py-2 text-sm text-stone-800 dark:bg-[#07101e] dark:text-slate-200">
{recoveryCode}
</code>
))}
</div>
</div>
) : null}
<button
type="button"
onClick={() => onEnrolled(verifiedAdmin)}
className="w-full rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400"
>
Continue to admin dashboard
</button>
</div>
) : (
<form onSubmit={verifyCode} className="mt-8 space-y-6">
{loadingSetup ? (
<div className="flex items-center gap-3 rounded-2xl border border-stone-200 bg-stone-50 p-4 text-sm text-stone-600 dark:border-blue-900 dark:bg-[#0d1b38] dark:text-slate-300">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-orange-500 border-t-transparent" />
Preparing authenticator setup...
</div>
) : (
<div className="grid gap-5 md:grid-cols-[180px,1fr]">
<div className="flex h-44 items-center justify-center rounded-2xl border border-stone-200 bg-white p-3 dark:border-blue-900 dark:bg-white">
{qrCode ? <img src={qrCode} alt="Admin 2FA QR code" className="h-full w-full object-contain" /> : <span className="text-sm text-stone-500">No QR code</span>}
</div>
<div>
<p className="text-sm font-semibold text-blue-950 dark:text-stone-100">Authenticator app</p>
<p className="mt-2 text-sm leading-6 text-stone-600 dark:text-slate-300">
Scan the QR code with your authenticator app, or enter the setup key manually.
</p>
{secret ? (
<code className="mt-3 block break-all rounded-xl border border-stone-200 bg-stone-50 px-3 py-2 text-sm text-stone-800 dark:border-blue-900 dark:bg-[#0d1b38] dark:text-slate-200">
{secret}
</code>
) : null}
</div>
</div>
)}
<label className="block">
<span className="mb-2 block text-sm font-semibold text-blue-950 dark:text-stone-100">6-digit code</span>
<input
value={code}
onChange={(event) => setCode(event.target.value.replace(/\D/g, '').slice(0, 6))}
inputMode="numeric"
autoComplete="one-time-code"
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-lg font-semibold tracking-[0.2em] text-stone-900 outline-none transition focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-blue-950/80 dark:text-stone-100"
placeholder="000000"
disabled={loadingSetup || verifying}
/>
</label>
{error ? (
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-300">
{error}
</div>
) : null}
<button
type="submit"
disabled={loadingSetup || verifying || code.length !== 6}
className="w-full rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-orange-500 dark:hover:bg-orange-400"
>
{verifying ? 'Verifying...' : 'Enable 2FA'}
</button>
</form>
)}
</section>
</div>
)
}
@@ -6,6 +6,14 @@ import {
formatNotificationDate,
type NotificationsPageResult,
} from '@/lib/adminNotifications'
import { ADMIN_API_BASE } from '@/lib/api'
interface AdminInboxItem {
id: string
readAt: string | null
createdAt: string
notificationEvent: { title: string; body: string; type: string; locale: string; data?: Record<string, unknown> }
}
const CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'IN_APP', 'PUSH']
const STATUSES = ['PENDING', 'QUEUED', 'SENT', 'DELIVERED', 'FAILED', 'SKIPPED', 'DEAD_LETTER', 'READ']
@@ -49,6 +57,14 @@ export default function AdminNotificationsPage() {
const [filterStatus, setFilterStatus] = useState('')
const [filterCompany, setFilterCompany] = useState('')
const [page, setPage] = useState(1)
const [inbox, setInbox] = useState<{ data: AdminInboxItem[]; unread: number }>({ data: [], unread: 0 })
function loadInbox() {
fetch(`${ADMIN_API_BASE}/admin/notifications/me`, { credentials: 'include', cache: 'no-store' })
.then((response) => response.ok ? response.json() : Promise.reject(new Error('Failed to load personal inbox')))
.then((json) => setInbox(json.data))
.catch(() => {})
}
function load(p: number, signal?: AbortSignal) {
setLoading(true)
@@ -73,6 +89,7 @@ export default function AdminNotificationsPage() {
const controller = new AbortController()
setPage(1)
load(1, controller.signal)
loadInbox()
return () => controller.abort()
}, [filterChannel, filterStatus, filterCompany])
@@ -83,6 +100,11 @@ export default function AdminNotificationsPage() {
const totalPages = result ? (result.totalPages ?? Math.ceil(result.total / result.pageSize)) : 0
async function markRead(recipientId: string) {
const response = await fetch(`${ADMIN_API_BASE}/admin/notifications/me/${recipientId}/read`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: '{}' })
if (response.ok) loadInbox()
}
return (
<div className="shell py-8 space-y-6">
<div className="flex flex-wrap items-start justify-between gap-4">
@@ -100,6 +122,27 @@ export default function AdminNotificationsPage() {
)}
</div>
<section className="panel p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-orange-400">My inbox</p>
<h2 className="mt-1 text-lg font-semibold text-zinc-100">Assigned operational notices</h2>
</div>
<span className="rounded-full bg-red-500/15 px-3 py-1 text-xs font-semibold text-red-300">{inbox.unread} unread</span>
</div>
{inbox.data.length === 0 ? <p className="mt-4 text-sm text-zinc-500">No assigned notices.</p> : (
<div className="mt-4 grid gap-3 lg:grid-cols-2">
{inbox.data.map((item) => (
<button key={item.id} type="button" onClick={() => markRead(item.id)} className={`rounded-xl border p-4 text-left ${item.readAt ? 'border-zinc-800 bg-zinc-950/50' : 'border-orange-500/40 bg-orange-500/5'}`}>
<p className="text-xs uppercase tracking-wide text-zinc-500">{item.notificationEvent.type.replaceAll('_', ' ')} · {item.notificationEvent.locale}</p>
<p className="mt-2 font-semibold text-zinc-100">{item.notificationEvent.title}</p>
<p className="mt-1 text-sm text-zinc-400">{item.notificationEvent.body}</p>
</button>
))}
</div>
)}
</section>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
<select
+30
View File
@@ -154,6 +154,10 @@ html.dark .panel {
color: rgb(28 25 23);
}
.light .text-zinc-50 {
color: rgb(28 25 23);
}
.light .text-zinc-500 {
color: rgb(120 113 108);
}
@@ -171,4 +175,30 @@ html.dark .panel {
.light .hover\:text-zinc-200:hover {
color: rgb(28 25 23);
}
.light .text-amber-100,
.light .text-amber-100\/80,
.light .text-amber-200,
.light .text-amber-300 {
color: rgb(146 64 14);
}
.light .text-emerald-300 {
color: rgb(4 120 87);
}
.light .text-rose-200,
.light .text-rose-300,
.light .text-red-400 {
color: rgb(190 18 60);
}
.light .text-sky-300 {
color: rgb(3 105 161);
}
.light .text-red-200,
.light .text-red-300 {
color: rgb(185 28 28);
}
}