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
@@ -85,6 +85,25 @@ function calculateLineAmounts(items: Array<{ type: string; amount: number }>) {
return { subtotalAmount, discountAmount, creditAmount, taxAmount, totalAmount }
}
function withBillingAccountBalances<T extends { invoices?: any[]; creditBalances?: any[] }>(account: T) {
const invoices = account.invoices ?? []
const creditBalances = account.creditBalances ?? []
const openBalance = invoices
.filter((invoice: any) => ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(invoice.status))
.reduce((sum: number, invoice: any) => sum + (invoice.amountDue ?? 0), 0)
const paidBalance = invoices
.filter((invoice: any) => ['PAID', 'PARTIALLY_REFUNDED', 'REFUNDED'].includes(invoice.status))
.reduce((sum: number, invoice: any) => sum + (invoice.amountPaid ?? 0), 0)
const creditBalance = creditBalances.reduce((sum: number, item: any) => sum + (item.balanceAmount ?? 0), 0)
return {
...account,
openBalance,
paidBalance,
creditBalance,
}
}
async function createBillingEvent(tx: any, data: {
billingAccountId?: string | null
invoiceId?: string | null
@@ -455,20 +474,7 @@ export async function listBillingAccounts(query: { q?: string; status?: string;
.reduce((sum, item) => sum + (item._sum.totalAmount ?? 0), 0),
}
const data = (accounts as any[]).map((account) => {
const openBalance = (account.invoices as any[])
.filter((invoice: any) => ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(invoice.status))
.reduce((sum: number, invoice: any) => sum + invoice.amountDue, 0)
const paidBalance = (account.invoices as any[])
.filter((invoice: any) => ['PAID', 'PARTIALLY_REFUNDED', 'REFUNDED'].includes(invoice.status))
.reduce((sum: number, invoice: any) => sum + invoice.amountPaid, 0)
return {
...account,
openBalance,
paidBalance,
creditBalance: (account.creditBalances as any[]).reduce((sum: number, item: any) => sum + item.balanceAmount, 0),
}
})
const data = (accounts as any[]).map((account) => withBillingAccountBalances(account))
return { data, total, stats }
}
@@ -513,7 +519,7 @@ export async function getBillingAccountDetail(companyId: string) {
})
if (!account) throw new NotFoundError('Billing account not found')
return account
return withBillingAccountBalances(account)
}
export async function updateBillingAccount(
+117 -11
View File
@@ -564,20 +564,126 @@ export async function listNotificationsPage(query: {
page: number
pageSize: number
}) {
const where: any = {}
if (query.channel) where.channel = query.channel
if (query.status) where.status = query.status
if (query.companyId) where.companyId = query.companyId
const legacyStatuses = new Set(['PENDING', 'SENT', 'DELIVERED', 'FAILED', 'READ'])
const legacyWhere: any = {}
if (query.channel) legacyWhere.channel = query.channel
if (query.status && legacyStatuses.has(query.status)) {
legacyWhere.status = query.status
} else if (query.status) {
legacyWhere.id = '__delivery_status_only__'
}
if (query.companyId) legacyWhere.companyId = query.companyId
const [data, total] = await Promise.all([
const deliveryWhere: any = {}
if (query.channel) deliveryWhere.channel = query.channel
if (query.companyId) {
deliveryWhere.notificationRecipient = {
notificationEvent: { companyId: query.companyId },
}
}
if (query.status === 'READ') {
deliveryWhere.notificationRecipient = {
...(deliveryWhere.notificationRecipient ?? {}),
readAt: { not: null },
}
} else if (query.status) {
deliveryWhere.status = query.status
deliveryWhere.notificationRecipient = {
...(deliveryWhere.notificationRecipient ?? {}),
readAt: null,
}
}
const take = query.page * query.pageSize
const [legacyNotifications, deliveryNotifications, legacyTotal, deliveryTotal] = await Promise.all([
prisma.notification.findMany({
where,
where: legacyWhere,
orderBy: { createdAt: 'desc' },
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
include: { company: { select: { name: true } } },
take,
include: {
company: { select: { id: true, name: true } },
employee: { select: { id: true, firstName: true, lastName: true, email: true } },
renter: { select: { id: true, firstName: true, lastName: true, email: true } },
},
}),
prisma.notification.count({ where }),
prisma.notificationDelivery.findMany({
where: deliveryWhere,
orderBy: { createdAt: 'desc' },
take,
include: {
notificationRecipient: {
include: {
employee: { select: { id: true, firstName: true, lastName: true, email: true } },
renter: { select: { id: true, firstName: true, lastName: true, email: true } },
notificationEvent: {
include: {
company: { select: { id: true, name: true } },
},
},
},
},
},
}),
prisma.notification.count({ where: legacyWhere }),
prisma.notificationDelivery.count({ where: deliveryWhere }),
])
return { data, total }
const legacyRows = legacyNotifications.map((notification: any) => {
const recipient = notification.employee ?? notification.renter ?? null
return {
id: `legacy:${notification.id}`,
notificationId: notification.id,
deliveryId: null,
source: 'LEGACY',
type: notification.type,
title: notification.title,
body: notification.body,
channel: notification.channel,
status: notification.status,
locale: notification.locale,
sentAt: notification.sentAt,
createdAt: notification.createdAt,
company: notification.company,
companyId: notification.companyId,
recipientType: notification.employeeId ? 'EMPLOYEE' : notification.renterId ? 'RENTER' : null,
recipientName: recipient ? `${recipient.firstName} ${recipient.lastName}`.trim() : null,
recipientEmail: recipient?.email ?? null,
employeeId: notification.employeeId,
renterId: notification.renterId,
}
})
const deliveryRows = deliveryNotifications.map((delivery: any) => {
const recipientRecord = delivery.notificationRecipient
const event = recipientRecord.notificationEvent
const recipient = recipientRecord.employee ?? recipientRecord.renter ?? null
return {
id: `delivery:${delivery.id}`,
notificationId: event.id,
deliveryId: delivery.id,
source: 'DELIVERY',
type: event.type,
title: event.title,
body: event.body,
channel: delivery.channel,
status: recipientRecord.readAt ? 'READ' : delivery.status,
locale: event.locale,
sentAt: delivery.sentAt ?? delivery.deliveredAt ?? delivery.lastAttemptAt,
createdAt: delivery.createdAt,
company: event.company,
companyId: event.companyId,
recipientType: recipientRecord.recipientType,
recipientName: recipient ? `${recipient.firstName} ${recipient.lastName}`.trim() : null,
recipientEmail: recipient?.email ?? null,
employeeId: recipientRecord.employeeId,
renterId: recipientRecord.renterId,
}
})
const data = [...legacyRows, ...deliveryRows]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice((query.page - 1) * query.pageSize, query.page * query.pageSize)
return { data, total: legacyTotal + deliveryTotal }
}
+5 -1
View File
@@ -45,8 +45,12 @@ router.post('/auth/login', async (req, res, next) => {
const { email, password, totpCode, recoveryCode } = parseBody(loginSchema, req)
const result = await service.login(email, password, totpCode, recoveryCode)
if (!result) return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 })
if ('totpRequired' in result) return res.status(401).json({ error: 'totp_required', message: '2FA code required', statusCode: 401 })
if ('totpRequired' in result) {
clearSessionCookie(res, 'employee')
return res.status(401).json({ error: 'totp_required', message: '2FA code required', statusCode: 401 })
}
if ('invalidTotp' in result) return res.status(401).json({ error: 'invalid_totp', message: 'Invalid 2FA code', statusCode: 401 })
clearSessionCookie(res, 'employee')
setSessionCookie(res, 'admin', result.token, 8 * 60 * 60 * 1000)
ok(res, result)
} catch (err) { next(err) }
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { notificationsQuerySchema } from './admin.schemas'
describe('admin notification schemas', () => {
it('accepts valid notification filters with pagination defaults', () => {
expect(notificationsQuerySchema.parse({
channel: 'EMAIL',
status: 'QUEUED',
companyId: 'company_1',
page: '2',
})).toEqual({
channel: 'EMAIL',
status: 'QUEUED',
companyId: 'company_1',
page: 2,
pageSize: 50,
})
})
it('rejects invalid notification enum filters before querying Prisma', () => {
expect(notificationsQuerySchema.safeParse({ channel: 'FAX' }).success).toBe(false)
expect(notificationsQuerySchema.safeParse({ status: 'BOUNCED' }).success).toBe(false)
})
})
+5 -2
View File
@@ -53,9 +53,12 @@ export const auditLogQuerySchema = z.object({
pageSize: z.coerce.number().int().min(1).max(100).default(50),
})
const notificationChannelSchema = z.enum(['EMAIL', 'SMS', 'WHATSAPP', 'IN_APP', 'PUSH'])
const notificationStatusSchema = z.enum(['PENDING', 'QUEUED', 'SENT', 'DELIVERED', 'FAILED', 'SKIPPED', 'DEAD_LETTER', 'READ'])
export const notificationsQuerySchema = z.object({
channel: z.string().optional(),
status: z.string().optional(),
channel: notificationChannelSchema.optional(),
status: notificationStatusSchema.optional(),
companyId: z.string().optional(),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(200).default(50),
@@ -1,28 +1,54 @@
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import bcrypt from 'bcryptjs'
vi.mock('./admin.repo', () => ({
findAdminByEmail: vi.fn(),
setAdminPasswordReset: vi.fn(),
updateAdminLastLogin: vi.fn(),
createAuditLog: vi.fn(),
}))
vi.mock('../../services/notificationService', () => ({
sendTransactionalEmail: vi.fn().mockResolvedValue(undefined),
}))
const redisStore = new Map<string, string>()
vi.mock('../../lib/redis', () => ({
redis: {
on: vi.fn(),
get: vi.fn((key: string) => Promise.resolve(redisStore.get(key) ?? null)),
set: vi.fn((key: string, value: string) => {
redisStore.set(key, value)
return Promise.resolve('OK')
}),
del: vi.fn((key: string) => {
const deleted = redisStore.delete(key) ? 1 : 0
return Promise.resolve(deleted)
}),
quit: vi.fn(),
duplicate: vi.fn(),
},
}))
import * as repo from './admin.repo'
import { sendTransactionalEmail } from '../../services/notificationService'
import { forgotPassword } from './admin.service'
import { forgotPassword, login } from './admin.service'
describe('admin.service forgotPassword', () => {
const originalAdminUrl = process.env.ADMIN_URL
const originalJwtSecret = process.env.JWT_SECRET
beforeEach(() => {
vi.clearAllMocks()
redisStore.clear()
process.env.ADMIN_URL = 'http://localhost:3000/admin'
process.env.JWT_SECRET = 'test-jwt-secret'
})
afterAll(() => {
process.env.ADMIN_URL = originalAdminUrl
process.env.JWT_SECRET = originalJwtSecret
})
it('sends the reset email to the canonical stored admin address', async () => {
@@ -46,4 +72,55 @@ describe('admin.service forgotPassword', () => {
}),
)
})
it('sends an email login code when admin 2FA is enabled', async () => {
vi.mocked(repo.findAdminByEmail).mockResolvedValue({
id: 'admin_2',
email: 'admin@example.test',
firstName: 'Amal',
lastName: 'Admin',
role: 'SUPER_ADMIN',
isActive: true,
passwordHash: await bcrypt.hash('password123', 4),
totpEnabled: true,
totpSecret: 'JBSWY3DPEHPK3PXP',
} as any)
await expect(login('admin@example.test', 'password123')).resolves.toEqual({ totpRequired: true })
expect(sendTransactionalEmail).toHaveBeenCalledWith(
expect.objectContaining({
to: 'admin@example.test',
subject: 'Your RentalDriveGo admin login code',
text: expect.stringMatching(/\b\d{6}\b/),
}),
)
})
it('accepts the emailed admin login code in the 2FA field', async () => {
vi.mocked(repo.findAdminByEmail).mockResolvedValue({
id: 'admin_3',
email: 'admin3@example.test',
firstName: 'Mina',
lastName: 'Admin',
role: 'SUPER_ADMIN',
isActive: true,
passwordHash: await bcrypt.hash('password123', 4),
totpEnabled: true,
totpSecret: 'JBSWY3DPEHPK3PXP',
} as any)
await login('admin3@example.test', 'password123')
const emailText = vi.mocked(sendTransactionalEmail).mock.calls[0]?.[0]?.text ?? ''
const code = emailText.match(/\b\d{6}\b/)?.[0]
expect(code).toBeTruthy()
const result = await login('admin3@example.test', 'password123', code)
expect(result).toEqual(expect.objectContaining({
token: expect.any(String),
admin: expect.objectContaining({ id: 'admin_3', email: 'admin3@example.test' }),
}))
expect(repo.updateAdminLastLogin).toHaveBeenCalledWith('admin_3')
})
})
+67 -3
View File
@@ -6,12 +6,17 @@ import { signActorToken } from '../../security/tokens'
import qrcode from 'qrcode'
import { getCarplaceHomepageContent, saveCarplaceHomepageContent } from '../../services/platformContentService'
import { sendTransactionalEmail } from '../../services/notificationService'
import { redis } from '../../lib/redis'
import * as presenter from './admin.presenter'
import * as repo from './admin.repo'
import * as billingService from './admin.billing.service'
const ADMIN_RESET_TTL_MINUTES = 60
const ADMIN_RECOVERY_CODE_COUNT = 10
const ADMIN_EMAIL_OTP_TTL_MINUTES = 10
const ADMIN_EMAIL_OTP_TTL_SECONDS = ADMIN_EMAIL_OTP_TTL_MINUTES * 60
const pendingAdminEmailOtps = new Map<string, { code: string; expiresAt: number }>()
function generateRecoveryCode() {
@@ -57,6 +62,61 @@ function signAdminToken(adminId: string, last2faAt?: number) {
return signActorToken(adminId, 'admin', { expiresIn: '8h', last2faAt })
}
function generateAdminEmailOtp() {
return crypto.randomInt(100000, 1000000).toString()
}
function adminEmailOtpKey(adminId: string) {
return `admin:email-otp:${adminId}`
}
async function sendAdminEmailOtp(admin: { id: string; email: string; firstName?: string | null }) {
const code = generateAdminEmailOtp()
const codeHash = hashPublicAccessToken(code)
pendingAdminEmailOtps.set(admin.id, {
code: codeHash,
expiresAt: Date.now() + ADMIN_EMAIL_OTP_TTL_MINUTES * 60 * 1000,
})
await redis
.set(adminEmailOtpKey(admin.id), codeHash, 'EX', ADMIN_EMAIL_OTP_TTL_SECONDS)
.catch((err) => console.error('[AdminLoginEmailOtpRedisSet]', err?.message))
await sendTransactionalEmail({
to: admin.email,
subject: 'Your RentalDriveGo admin login code',
html: `<p>Hi ${admin.firstName ?? 'Admin'},</p><p>Your admin login code is <strong>${code}</strong>.</p><p>It expires in ${ADMIN_EMAIL_OTP_TTL_MINUTES} minutes.</p>`,
text: `Hi ${admin.firstName ?? 'Admin'},\n\nYour admin login code is ${code}.\n\nIt expires in ${ADMIN_EMAIL_OTP_TTL_MINUTES} minutes.`,
}).catch((err) => console.error('[AdminLoginEmailOtp]', err?.message))
}
async function consumeAdminEmailOtp(adminId: string, code: string | undefined) {
if (!code) return false
const codeHash = hashPublicAccessToken(code.trim())
const key = adminEmailOtpKey(adminId)
const persistedHash = await redis
.get(key)
.catch((err) => {
console.error('[AdminLoginEmailOtpRedisGet]', err?.message)
return null
})
if (persistedHash) {
if (persistedHash !== codeHash) return false
await redis.del(key).catch((err) => console.error('[AdminLoginEmailOtpRedisDel]', err?.message))
pendingAdminEmailOtps.delete(adminId)
return true
}
const pending = pendingAdminEmailOtps.get(adminId)
if (!pending) return false
if (pending.expiresAt <= Date.now()) {
pendingAdminEmailOtps.delete(adminId)
return false
}
if (pending.code !== codeHash) return false
pendingAdminEmailOtps.delete(adminId)
return true
}
function toAuditJson<T>(value: T) {
return JSON.parse(JSON.stringify(value))
}
@@ -81,16 +141,20 @@ export async function login(email: string, password: string, totpCode?: string,
if (!valid) return null
if (admin.totpEnabled) {
if (!totpCode && !recoveryCode) return { totpRequired: true } as const
if (!totpCode && !recoveryCode) {
await sendAdminEmailOtp(admin)
return { totpRequired: true } as const
}
const validTotp = totpCode
? authenticator.verify({ token: totpCode, secret: admin.totpSecret! })
: false
const validRecoveryCode = !validTotp && recoveryCode
const validEmailOtp = !validTotp && await consumeAdminEmailOtp(admin.id, totpCode)
const validRecoveryCode = !validTotp && !validEmailOtp && recoveryCode
? await consumeAdminRecoveryCode(admin.id, recoveryCode)
: false
if (!validTotp && !validRecoveryCode) {
if (!validTotp && !validEmailOtp && !validRecoveryCode) {
return { invalidTotp: true } as const
}
}