fix architecture and write new tests

This commit is contained in:
root
2026-06-10 00:40:19 -04:00
parent 560da1cadf
commit 80a597bc10
377 changed files with 84020 additions and 1337 deletions
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import {
billingAccountUpdateSchema,
billingCreditNoteSchema,
billingRefundSchema,
createBillingInvoiceSchema,
payBillingInvoiceSchema,
} from './admin.schemas'
describe('admin billing schemas', () => {
it('accepts complete draft invoice payloads and preserves nullable billing fields', () => {
const parsed = createBillingInvoiceSchema.parse({
subscriptionId: null,
invoiceType: 'MANUAL',
currency: 'MAD',
dueAt: '2026-08-10',
isSubscriptionBlocking: false,
adminReason: null,
lineItems: [{
type: 'MANUAL_ADJUSTMENT',
description: 'Manual correction',
quantity: 2,
unitAmount: 1500,
periodStart: null,
periodEnd: '2026-08-31T00:00:00.000Z',
}],
})
expect(parsed.lineItems[0]).toMatchObject({ quantity: 2, unitAmount: 1500, periodStart: null })
})
it('rejects invoices without line items because empty invoices are bookkeeping cosplay', () => {
expect(() => createBillingInvoiceSchema.parse({ invoiceType: 'MANUAL', lineItems: [] })).toThrow()
})
it('bounds billing account net terms and validates email formatting', () => {
expect(billingAccountUpdateSchema.parse({
legalName: 'Atlas Cars LLC',
billingEmail: 'billing@example.test',
invoiceTerms: 'NET_30',
netTermsDays: 30,
})).toMatchObject({ netTermsDays: 30 })
expect(() => billingAccountUpdateSchema.parse({ billingEmail: 'not-email', netTermsDays: 366 })).toThrow()
})
it('requires positive money movements for payments, credit notes, and refunds', () => {
expect(payBillingInvoiceSchema.parse({ amount: 5000, paymentMethodId: null })).toEqual({ amount: 5000, paymentMethodId: null })
expect(() => payBillingInvoiceSchema.parse({ amount: 0 })).toThrow()
expect(() => billingCreditNoteSchema.parse({ amount: -1, reason: 'Bad credit' })).toThrow()
expect(() => billingRefundSchema.parse({ amount: 0, reason: 'Bad refund' })).toThrow()
})
})
@@ -315,7 +315,7 @@ async function syncLegacySubscriptionInvoices(companyId?: string) {
})
for (const legacy of unsynced) {
await prisma.$transaction(async (tx) => {
await prisma.$transaction(async (tx: any) => {
const latest = await tx.subscriptionInvoice.findUnique({
where: { id: legacy.id },
include: { subscription: true, attempts: true },
@@ -434,37 +434,39 @@ export async function listBillingAccounts(query: { q?: string; status?: string;
}),
])
const invoiceAggItems = invoiceAgg as any[]
const stats = {
billingAccountCount: total,
openInvoiceCount: invoiceAgg
openInvoiceCount: invoiceAggItems
.filter((item) => ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(item.status))
.reduce((sum, item) => sum + item._count._all, 0),
pastDueInvoiceCount: invoiceAgg
pastDueInvoiceCount: invoiceAggItems
.filter((item) => item.status === 'PAST_DUE')
.reduce((sum, item) => sum + item._count._all, 0),
paidInvoiceCount: invoiceAgg
paidInvoiceCount: invoiceAggItems
.filter((item) => item.status === 'PAID')
.reduce((sum, item) => sum + item._count._all, 0),
accountsReceivableTotal: invoiceAgg
accountsReceivableTotal: invoiceAggItems
.filter((item) => ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(item.status))
.reduce((sum, item) => sum + (item._sum.amountDue ?? 0), 0),
recognizedRevenueTotal: invoiceAgg
recognizedRevenueTotal: invoiceAggItems
.filter((item) => ['PAID', 'PARTIALLY_REFUNDED', 'REFUNDED'].includes(item.status))
.reduce((sum, item) => sum + (item._sum.totalAmount ?? 0), 0),
}
const data = accounts.map((account) => {
const openBalance = account.invoices
.filter((invoice) => ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(invoice.status))
.reduce((sum, invoice) => sum + invoice.amountDue, 0)
const paidBalance = account.invoices
.filter((invoice) => ['PAID', 'PARTIALLY_REFUNDED', 'REFUNDED'].includes(invoice.status))
.reduce((sum, invoice) => sum + invoice.amountPaid, 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.reduce((sum, item) => sum + item.balanceAmount, 0),
creditBalance: (account.creditBalances as any[]).reduce((sum: number, item: any) => sum + item.balanceAmount, 0),
}
})
@@ -630,7 +632,7 @@ export async function createDraftInvoice(
) {
if (!data.lineItems.length) throw new ValidationError('Invoice requires at least one line item')
const invoice = await prisma.$transaction(async (tx) => {
const invoice = await prisma.$transaction(async (tx: any) => {
const account = await tx.billingAccount.findUniqueOrThrow({
where: { id: billingAccountId },
include: { company: true },
@@ -696,7 +698,7 @@ export async function createDraftInvoice(
}
export async function finalizeInvoice(invoiceId: string, adminId: string, ip?: string) {
const invoice = await prisma.$transaction(async (tx) => {
const invoice = await prisma.$transaction(async (tx: any) => {
const current = await tx.billingInvoice.findUnique({
where: { id: invoiceId },
include: {
@@ -891,7 +893,7 @@ export async function payInvoice(
adminId: string,
ip?: string,
) {
const invoice = await prisma.$transaction(async (tx) => {
const invoice = await prisma.$transaction(async (tx: any) => {
const current = await tx.billingInvoice.findUniqueOrThrow({
where: { id: invoiceId },
include: {
@@ -994,7 +996,7 @@ export async function retryInvoicePayment(
adminId: string,
ip?: string,
) {
const invoice = await prisma.$transaction(async (tx) => {
const invoice = await prisma.$transaction(async (tx: any) => {
const current = await tx.billingInvoice.findUniqueOrThrow({
where: { id: invoiceId },
include: { billingAccount: true },
@@ -1069,7 +1071,7 @@ export async function retryInvoicePayment(
}
export async function voidInvoice(invoiceId: string, reason: string, adminId: string, ip?: string) {
const invoice = await prisma.$transaction(async (tx) => {
const invoice = await prisma.$transaction(async (tx: any) => {
const current = await tx.billingInvoice.findUniqueOrThrow({ where: { id: invoiceId } })
if (!['DRAFT', 'OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(current.status)) {
throw new ValidationError('Only unpaid invoices can be voided')
@@ -1122,7 +1124,7 @@ export async function voidInvoice(invoiceId: string, reason: string, adminId: st
}
export async function markInvoiceUncollectible(invoiceId: string, reason: string, adminId: string, ip?: string) {
const invoice = await prisma.$transaction(async (tx) => {
const invoice = await prisma.$transaction(async (tx: any) => {
const current = await tx.billingInvoice.findUniqueOrThrow({ where: { id: invoiceId } })
if (!['OPEN', 'PAST_DUE', 'PAYMENT_PENDING', 'PARTIALLY_PAID'].includes(current.status)) {
throw new ValidationError('Only open invoices can be marked uncollectible')
@@ -1177,7 +1179,7 @@ export async function issueCreditNote(
adminId: string,
ip?: string,
) {
const invoice = await prisma.$transaction(async (tx) => {
const invoice = await prisma.$transaction(async (tx: any) => {
const current = await tx.billingInvoice.findUniqueOrThrow({
where: { id: invoiceId },
include: { creditNotes: true },
@@ -1259,7 +1261,7 @@ export async function issueRefund(
adminId: string,
ip?: string,
) {
const invoice = await prisma.$transaction(async (tx) => {
const invoice = await prisma.$transaction(async (tx: any) => {
const current = await tx.billingInvoice.findUniqueOrThrow({
where: { id: invoiceId },
include: {
@@ -1354,7 +1356,7 @@ export async function getInvoicePdf(invoiceId: string) {
if (!invoice) throw new NotFoundError('Invoice not found')
if (!invoice.invoiceNumber) throw new ValidationError('Invoice must be finalized before a PDF can be generated')
const latestPaymentAttempt = invoice.paymentAttempts.find((attempt) => attempt.status === 'SUCCEEDED') ?? invoice.paymentAttempts[0] ?? null
const latestPaymentAttempt = invoice.paymentAttempts.find((attempt: any) => attempt.status === 'SUCCEEDED') ?? invoice.paymentAttempts[0] ?? null
const pdfBuffer = await generateInvoicePdf({
invoiceNumber: invoice.invoiceNumber,
issueDate: invoice.invoiceDate?.toISOString() ?? invoice.createdAt.toISOString(),
@@ -1380,7 +1382,7 @@ export async function getInvoicePdf(invoiceId: string) {
paymentProvider: invoice.paymentProvider ?? 'MANUAL',
transactionId: latestPaymentAttempt?.providerPaymentId ?? null,
paidAt: invoice.paidAt?.toISOString(),
lineItems: invoice.lineItems.map((item) => ({
lineItems: invoice.lineItems.map((item: any) => ({
description: item.description,
amount: item.amount,
currency: item.currency,
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import {
menuItemSchema,
menuPlanAssignmentsSchema,
menuCompanyAssignmentsSchema,
menuPreviewSchema,
promotionCreateSchema,
promotionUpdateSchema,
} from './admin.schemas'
describe('admin menu and promotion schemas', () => {
it('trims labels and applies safe menu item defaults', () => {
expect(menuItemSchema.parse({ label: ' Fleet ', itemType: 'INTERNAL_PAGE' })).toMatchObject({
label: 'Fleet',
itemType: 'INTERNAL_PAGE',
displayOrder: 0,
openInNewTab: false,
isRequired: false,
isActive: true,
roles: [],
subscriptionPlans: [],
companyAssignments: [],
})
})
it('rejects negative menu ordering and invalid role previews', () => {
expect(() => menuItemSchema.parse({ label: 'Fleet', itemType: 'INTERNAL_PAGE', displayOrder: -1 })).toThrow()
expect(() => menuPreviewSchema.parse({ companyId: 'company_1', role: 'SUPER_ADMIN' })).toThrow()
})
it('requires at least one plan or company assignment', () => {
expect(() => menuPlanAssignmentsSchema.parse({ assignments: [] })).toThrow()
expect(() => menuCompanyAssignmentsSchema.parse({ assignments: [] })).toThrow()
expect(menuPlanAssignmentsSchema.parse({ assignments: [{ plan: 'PRO' }] })).toEqual({ assignments: [{ plan: 'PRO' }] })
})
it('accepts only uppercase promotion codes and preserves update partiality', () => {
const valid = {
code: 'SUMMER_26',
name: 'Summer 2026',
discountType: 'PERCENTAGE',
discountValue: 20,
plans: ['STARTER', 'GROWTH'],
periods: ['MONTHLY'],
validFrom: '2026-07-01T00:00:00.000Z',
}
expect(promotionCreateSchema.parse(valid)).toMatchObject({ ...valid, isActive: true })
expect(() => promotionCreateSchema.parse({ ...valid, code: 'summer' })).toThrow()
expect(promotionUpdateSchema.parse({ name: 'Updated name' })).toEqual({ name: 'Updated name' })
})
})
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { presentAdminSession, presentAdminUser, presentPaginated } from './admin.presenter'
describe('admin.presenter', () => {
it('removes admin secrets from user responses', () => {
const result = presentAdminUser({
id: 'admin_1',
email: 'admin@example.com',
role: 'SUPER_ADMIN',
passwordHash: 'hash',
totpSecret: 'secret',
})
expect(result).toEqual({ id: 'admin_1', email: 'admin@example.com', role: 'SUPER_ADMIN' })
})
it('wraps sessions without leaking credentials', () => {
expect(presentAdminSession({ id: 'admin_1', passwordHash: 'hash', totpSecret: 'secret' }, 'jwt-token')).toEqual({
token: 'jwt-token',
admin: { id: 'admin_1' },
})
})
it('computes pagination metadata and preserves extra aggregate fields', () => {
expect(presentPaginated([{ id: 'row_1' }], 41, 2, 20, { active: 11 })).toEqual({
data: [{ id: 'row_1' }],
total: 41,
page: 2,
pageSize: 20,
totalPages: 3,
active: 11,
})
})
})
@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../../lib/prisma', () => ({
prisma: {
adminUser: { findFirst: vi.fn(), findUniqueOrThrow: vi.fn(), update: vi.fn() },
auditLog: { create: vi.fn() },
company: { findMany: vi.fn(), count: vi.fn(), findUniqueOrThrow: vi.fn(), update: vi.fn(), delete: vi.fn() },
billingAccount: { findMany: vi.fn(), count: vi.fn() },
$transaction: vi.fn(),
},
}))
import { prisma } from '../../lib/prisma'
import * as repo from './admin.repo'
describe('admin.repo edge queries', () => {
beforeEach(() => vi.clearAllMocks())
it('clears reset token metadata when updating an admin password', async () => {
await repo.updateAdminPassword('admin_1', 'hash_1')
expect(prisma.adminUser.update).toHaveBeenCalledWith({
where: { id: 'admin_1' },
data: {
passwordHash: 'hash_1',
passwordResetToken: null,
passwordResetExpiresAt: null,
},
})
})
it('filters company list by search, status and plan with pagination', async () => {
vi.mocked(prisma.company.findMany).mockResolvedValue([] as never)
vi.mocked(prisma.company.count).mockResolvedValue(0 as never)
await repo.listCompaniesPage({ q: 'atlas', status: 'ACTIVE', plan: 'PRO', page: 3, pageSize: 25 })
const where = {
status: 'ACTIVE',
OR: [
{ name: { contains: 'atlas', mode: 'insensitive' } },
{ email: { contains: 'atlas', mode: 'insensitive' } },
{ slug: { contains: 'atlas', mode: 'insensitive' } },
],
subscription: { plan: 'PRO' },
}
expect(prisma.company.findMany).toHaveBeenCalledWith(expect.objectContaining({
where,
skip: 50,
take: 25,
orderBy: { createdAt: 'desc' },
}))
expect(prisma.company.count).toHaveBeenCalledWith({ where })
})
it('looks up reset tokens only when they have not expired', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-01T00:00:00.000Z'))
await repo.findAdminByResetToken('reset-token')
expect(prisma.adminUser.findFirst).toHaveBeenCalledWith({
where: {
passwordResetToken: 'reset-token',
passwordResetExpiresAt: { gt: new Date('2026-06-01T00:00:00.000Z') },
},
})
vi.useRealTimers()
})
})
+23 -1
View File
@@ -58,6 +58,28 @@ export function enableAdminTotp(id: string) {
return prisma.adminUser.update({ where: { id }, data: { totpEnabled: true } })
}
export async function replaceAdminRecoveryCodes(adminUserId: string, codeHashes: string[]) {
return prisma.$transaction(async (tx) => {
await tx.adminRecoveryCode.deleteMany({ where: { adminUserId } })
await tx.adminRecoveryCode.createMany({
data: codeHashes.map((codeHash) => ({ adminUserId, codeHash })),
})
})
}
export function listUnusedAdminRecoveryCodes(adminUserId: string) {
return prisma.adminRecoveryCode.findMany({
where: { adminUserId, usedAt: null },
select: { id: true, codeHash: true },
orderBy: { createdAt: 'asc' },
})
}
export function markAdminRecoveryCodeUsed(id: string) {
return prisma.adminRecoveryCode.update({ where: { id }, data: { usedAt: new Date() } })
}
export function setAdminPasswordReset(id: string, token: string, expiresAt: Date) {
return prisma.adminUser.update({
where: { id },
@@ -136,7 +158,7 @@ export async function applyCompanyUpdate(
brand?: { paymentMethodsEnabled?: any[] | null } | null
},
) {
return prisma.$transaction(async (tx) => {
return prisma.$transaction(async (tx: any) => {
if (body.company) {
const companyData = { ...body.company }
if (companyData.address && typeof companyData.address === 'object' && !Array.isArray(companyData.address)) {
+46 -27
View File
@@ -1,7 +1,8 @@
import { Router } from 'express'
import { requireAdminAuth, requireAdminRole } from '../../middleware/requireAdminAuth'
import { requireAdminAuth, requireAdminRole, requireFreshAdmin2FA } from '../../middleware/requireAdminAuth'
import { parseBody, parseQuery, parseParams } from '../../http/validate'
import { ok, created } from '../../http/respond'
import { setSessionCookie, clearSessionCookie } from '../../security/sessionCookies'
import * as service from './admin.service'
import * as subService from '../subscriptions/subscription.service'
import * as menuService from '../menu/menu.service'
@@ -29,6 +30,10 @@ const adminExtendTrialSchema = z.object({
extraDays: z.number().int().positive(),
reason: z.string().min(1).max(500),
})
const adminImpersonationSchema = z.object({
reason: z.string().min(5).max(500),
durationMinutes: z.number().int().min(1).max(30).default(15),
})
const subIdParamSchema = z.object({ subscriptionId: z.string() })
const router = Router()
@@ -37,15 +42,21 @@ const router = Router()
router.post('/auth/login', async (req, res, next) => {
try {
const { email, password, totpCode } = parseBody(loginSchema, req)
const result = await service.login(email, password, totpCode)
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 ('invalidTotp' in result) return res.status(401).json({ error: 'invalid_totp', message: 'Invalid 2FA code', statusCode: 401 })
setSessionCookie(res, 'admin', result.token, 8 * 60 * 60 * 1000)
ok(res, result)
} catch (err) { next(err) }
})
router.post('/auth/logout', (_req, res) => {
clearSessionCookie(res, 'admin')
ok(res, { success: true })
})
router.post('/auth/forgot-password', async (req, res, next) => {
try {
const { email } = parseBody(forgotPasswordSchema, req)
@@ -78,9 +89,16 @@ router.post('/auth/2fa/setup', requireAdminAuth, async (req, res, next) => {
router.post('/auth/2fa/verify', requireAdminAuth, async (req, res, next) => {
try {
const { code } = parseBody(totpVerifySchema, req)
const valid = await service.verifyTotp(req.admin.id, code)
if (!valid) return res.status(400).json({ error: 'invalid_code', message: 'Invalid 2FA code', statusCode: 400 })
ok(res, { success: true })
const result = await service.verifyTotp(req.admin.id, code)
if (!result) return res.status(400).json({ error: 'invalid_code', message: 'Invalid 2FA code', statusCode: 400 })
setSessionCookie(res, 'admin', result.token, 8 * 60 * 60 * 1000)
ok(res, { success: true, admin: result.admin, recoveryCodes: result.recoveryCodes })
} catch (err) { next(err) }
})
router.post('/auth/2fa/recovery-codes/regenerate', requireAdminAuth, requireFreshAdmin2FA, async (req, res, next) => {
try {
ok(res, await service.regenerateRecoveryCodes(req.admin.id))
} catch (err) { next(err) }
})
@@ -115,7 +133,7 @@ router.patch('/companies/:id/status', requireAdminAuth, requireAdminRole('SUPPOR
} catch (err) { next(err) }
})
router.delete('/companies/:id', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
router.delete('/companies/:id', requireAdminAuth, requireAdminRole('ADMIN'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
await service.deleteCompany(id, req.admin.id, req.ip)
@@ -123,10 +141,11 @@ router.delete('/companies/:id', requireAdminAuth, requireAdminRole('ADMIN'), asy
} catch (err) { next(err) }
})
router.post('/companies/:id/impersonate', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
router.post('/companies/:id/impersonate', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
ok(res, await service.impersonateCompany(id, req.admin.id, req.ip))
const { reason, durationMinutes } = parseBody(adminImpersonationSchema, req)
ok(res, await service.impersonateCompany(id, req.admin.id, req.ip, reason, durationMinutes))
} catch (err) { next(err) }
})
@@ -272,20 +291,20 @@ router.get('/admins', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (
} catch (err) { next(err) }
})
router.post('/admins', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => {
router.post('/admins', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), requireFreshAdmin2FA, async (req, res, next) => {
try {
created(res, { data: await service.createAdmin(parseBody(createAdminSchema, req)) })
} catch (err) { next(err) }
})
router.patch('/admins/:id', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => {
router.patch('/admins/:id', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
ok(res, await service.updateAdmin(id, parseBody(updateAdminSchema, req)))
} catch (err) { next(err) }
})
router.patch('/admins/:id/role', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => {
router.patch('/admins/:id/role', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { role } = parseBody(adminRoleSchema, req)
@@ -294,7 +313,7 @@ router.patch('/admins/:id/role', requireAdminAuth, requireAdminRole('SUPER_ADMIN
} catch (err) { next(err) }
})
router.patch('/admins/:id/permissions', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => {
router.patch('/admins/:id/permissions', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { permissions } = parseBody(adminPermissionsSchema, req)
@@ -370,7 +389,7 @@ router.post('/billing/invoices/:invoiceId/finalize', requireAdminAuth, requireAd
} catch (err) { next(err) }
})
router.post('/billing/invoices/:invoiceId/pay', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
router.post('/billing/invoices/:invoiceId/pay', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
ok(res, await service.payBillingInvoice(invoiceId, parseBody(payBillingInvoiceSchema, req), req.admin.id, req.ip))
@@ -407,7 +426,7 @@ router.post('/billing/invoices/:invoiceId/credit-notes', requireAdminAuth, requi
} catch (err) { next(err) }
})
router.post('/billing/invoices/:invoiceId/refunds', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
router.post('/billing/invoices/:invoiceId/refunds', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
ok(res, await service.issueBillingRefund(invoiceId, parseBody(billingRefundSchema, req), req.admin.id, req.ip))
@@ -422,7 +441,7 @@ router.get('/pricing', requireAdminAuth, requireAdminRole('FINANCE'), async (req
} catch (err) { next(err) }
})
router.patch('/pricing', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
router.patch('/pricing', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { entries } = parseBody(pricingUpdateSchema, req)
ok(res, await service.updatePricingConfigs(entries, req.admin.id, req.ip))
@@ -435,14 +454,14 @@ router.get('/pricing/features', requireAdminAuth, requireAdminRole('FINANCE'), a
} catch (err) { next(err) }
})
router.post('/pricing/features', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
router.post('/pricing/features', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const data = parseBody(planFeatureCreateSchema, req)
created(res, await service.createPlanFeature(data, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.patch('/pricing/features/:featureId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
router.patch('/pricing/features/:featureId', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { featureId } = parseParams(planFeatureIdParamSchema, req)
const data = parseBody(planFeatureUpdateSchema, req)
@@ -450,7 +469,7 @@ router.patch('/pricing/features/:featureId', requireAdminAuth, requireAdminRole(
} catch (err) { next(err) }
})
router.delete('/pricing/features/:featureId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
router.delete('/pricing/features/:featureId', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { featureId } = parseParams(planFeatureIdParamSchema, req)
await service.deletePlanFeature(featureId, req.admin.id, req.ip)
@@ -466,14 +485,14 @@ router.get('/pricing/promotions', requireAdminAuth, requireAdminRole('FINANCE'),
} catch (err) { next(err) }
})
router.post('/pricing/promotions', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
router.post('/pricing/promotions', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const data = parseBody(promotionCreateSchema, req)
created(res, await service.createPromotion(data as any, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.patch('/pricing/promotions/:promotionId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
router.patch('/pricing/promotions/:promotionId', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { promotionId } = parseParams(promotionIdParamSchema, req)
const data = parseBody(promotionUpdateSchema, req)
@@ -481,7 +500,7 @@ router.patch('/pricing/promotions/:promotionId', requireAdminAuth, requireAdminR
} catch (err) { next(err) }
})
router.delete('/pricing/promotions/:promotionId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
router.delete('/pricing/promotions/:promotionId', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { promotionId } = parseParams(promotionIdParamSchema, req)
await service.deletePromotion(promotionId, req.admin.id, req.ip)
@@ -498,7 +517,7 @@ router.get('/subscriptions/:subscriptionId/events', requireAdminAuth, requireAdm
} catch (err) { next(err) }
})
router.post('/subscriptions/:subscriptionId/extend-trial', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
router.post('/subscriptions/:subscriptionId/extend-trial', requireAdminAuth, requireAdminRole('SUPPORT'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { subscriptionId } = parseParams(subIdParamSchema, req)
const { extraDays, reason } = parseBody(adminExtendTrialSchema, req)
@@ -506,7 +525,7 @@ router.post('/subscriptions/:subscriptionId/extend-trial', requireAdminAuth, req
} catch (err) { next(err) }
})
router.post('/subscriptions/:subscriptionId/extend-grace-period', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
router.post('/subscriptions/:subscriptionId/extend-grace-period', requireAdminAuth, requireAdminRole('SUPPORT'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { subscriptionId } = parseParams(subIdParamSchema, req)
const { reason } = parseBody(adminSubOverrideSchema, req)
@@ -514,7 +533,7 @@ router.post('/subscriptions/:subscriptionId/extend-grace-period', requireAdminAu
} catch (err) { next(err) }
})
router.post('/subscriptions/:subscriptionId/suspend', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
router.post('/subscriptions/:subscriptionId/suspend', requireAdminAuth, requireAdminRole('SUPPORT'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { subscriptionId } = parseParams(subIdParamSchema, req)
const { reason } = parseBody(adminSubOverrideSchema, req)
@@ -522,7 +541,7 @@ router.post('/subscriptions/:subscriptionId/suspend', requireAdminAuth, requireA
} catch (err) { next(err) }
})
router.post('/subscriptions/:subscriptionId/reactivate', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
router.post('/subscriptions/:subscriptionId/reactivate', requireAdminAuth, requireAdminRole('SUPPORT'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { subscriptionId } = parseParams(subIdParamSchema, req)
const { reason } = parseBody(adminSubOverrideSchema, req)
@@ -530,7 +549,7 @@ router.post('/subscriptions/:subscriptionId/reactivate', requireAdminAuth, requi
} catch (err) { next(err) }
})
router.post('/subscriptions/:subscriptionId/cancel', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
router.post('/subscriptions/:subscriptionId/cancel', requireAdminAuth, requireAdminRole('SUPPORT'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { subscriptionId } = parseParams(subIdParamSchema, req)
const { reason } = parseBody(adminSubOverrideSchema, req)
@@ -5,6 +5,7 @@ export const loginSchema = z.object({
email: z.string().email().max(255).trim().toLowerCase(),
password: z.string().max(128),
totpCode: z.string().length(6).optional(),
recoveryCode: z.string().min(8).max(32).optional(),
})
export const forgotPasswordSchema = z.object({
+84 -22
View File
@@ -1,7 +1,7 @@
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import crypto from 'crypto'
import { authenticator } from 'otplib'
import { signActorToken } from '../../security/tokens'
import qrcode from 'qrcode'
import { getMarketplaceHomepageContent, saveMarketplaceHomepageContent } from '../../services/platformContentService'
import { sendTransactionalEmail } from '../../services/notificationService'
@@ -10,9 +10,50 @@ 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
function signAdminToken(adminId: string) {
return jwt.sign({ sub: adminId, type: 'admin' }, process.env.JWT_SECRET!, { expiresIn: '8h' })
function generateRecoveryCode() {
const raw = crypto.randomBytes(9).toString('base64url').replace(/[^a-zA-Z0-9]/g, '').toUpperCase().slice(0, 12)
return `${raw.slice(0, 4)}-${raw.slice(4, 8)}-${raw.slice(8, 12)}`
}
async function issueAdminRecoveryCodes(adminId: string) {
const codes = Array.from({ length: ADMIN_RECOVERY_CODE_COUNT }, generateRecoveryCode)
const hashes = await Promise.all(codes.map((code) => bcrypt.hash(code, 12)))
await repo.replaceAdminRecoveryCodes(adminId, hashes)
await repo.createAuditLog({
adminUserId: adminId,
action: 'ADMIN_2FA_RECOVERY_CODES_ISSUED',
resource: 'AdminUser',
resourceId: adminId,
})
return codes
}
async function consumeAdminRecoveryCode(adminId: string, code: string) {
const normalized = code.trim().toUpperCase()
if (!normalized) return false
const codes = await repo.listUnusedAdminRecoveryCodes(adminId)
for (const candidate of codes) {
if (await bcrypt.compare(normalized, candidate.codeHash)) {
await repo.markAdminRecoveryCodeUsed(candidate.id)
await repo.createAuditLog({
adminUserId: adminId,
action: 'ADMIN_2FA_RECOVERY_CODE_USED',
resource: 'AdminUser',
resourceId: adminId,
})
return true
}
}
return false
}
function signAdminToken(adminId: string, last2faAt?: number) {
return signActorToken(adminId, 'admin', { expiresIn: '8h', last2faAt })
}
function toAuditJson<T>(value: T) {
@@ -31,7 +72,7 @@ function ensureAdminBasePath(baseUrl: string) {
}
}
export async function login(email: string, password: string, totpCode?: string) {
export async function login(email: string, password: string, totpCode?: string, recoveryCode?: string) {
const admin = await repo.findAdminByEmail(email)
if (!admin || !admin.isActive) return null
@@ -39,8 +80,16 @@ export async function login(email: string, password: string, totpCode?: string)
if (!valid) return null
if (admin.totpEnabled) {
if (!totpCode) return { totpRequired: true } as const
if (!authenticator.verify({ token: totpCode, secret: admin.totpSecret! })) {
if (!totpCode && !recoveryCode) return { totpRequired: true } as const
const validTotp = totpCode
? authenticator.verify({ token: totpCode, secret: admin.totpSecret! })
: false
const validRecoveryCode = !validTotp && recoveryCode
? await consumeAdminRecoveryCode(admin.id, recoveryCode)
: false
if (!validTotp && !validRecoveryCode) {
return { invalidTotp: true } as const
}
}
@@ -53,7 +102,7 @@ export async function login(email: string, password: string, totpCode?: string)
resourceId: admin.id,
})
return presenter.presentAdminSession(admin, signAdminToken(admin.id))
return presenter.presentAdminSession(admin, signAdminToken(admin.id, admin.totpEnabled ? Date.now() : undefined))
}
export async function setupTotp(adminId: string, email: string) {
@@ -69,8 +118,24 @@ export async function verifyTotp(adminId: string, code: string) {
if (!admin.totpSecret) return false
const valid = authenticator.verify({ token: code, secret: admin.totpSecret })
if (valid) await repo.enableAdminTotp(adminId)
return valid
if (!valid) return false
await repo.enableAdminTotp(adminId)
await repo.createAuditLog({
adminUserId: adminId,
action: 'ADMIN_2FA_VERIFIED',
resource: 'AdminUser',
resourceId: adminId,
})
const recoveryCodes = await issueAdminRecoveryCodes(adminId)
return {
...presenter.presentAdminSession({ ...admin, totpEnabled: true }, signAdminToken(adminId, Date.now())),
recoveryCodes,
}
}
export async function regenerateRecoveryCodes(adminId: string) {
return { recoveryCodes: await issueAdminRecoveryCodes(adminId) }
}
export async function forgotPassword(email: string) {
@@ -155,18 +220,12 @@ export async function deleteCompany(id: string, adminId: string, ip?: string) {
})
}
export async function impersonateCompany(id: string, adminId: string, ip?: string) {
export async function impersonateCompany(id: string, adminId: string, ip?: string, reason?: string, durationMinutes = 15) {
const company = await repo.getCompanyForImpersonation(id)
const token = jwt.sign(
{
sub: company.employees[0]?.id,
companyId: company.id,
isImpersonation: true,
type: 'employee',
},
process.env.JWT_SECRET!,
{ expiresIn: '30m' },
)
const ttlMinutes = Math.min(Math.max(durationMinutes, 1), 30)
const employeeId = company.employees[0]?.id
if (!employeeId) throw new Error('Company has no employee account to impersonate')
const token = signActorToken(employeeId, 'employee', { expiresIn: `${ttlMinutes}m` as any })
await repo.createAuditLog({
adminUserId: adminId,
@@ -174,10 +233,13 @@ export async function impersonateCompany(id: string, adminId: string, ip?: strin
resource: 'Company',
resourceId: id,
companyId: id,
note: reason,
before: { originalAdminId: adminId },
after: { targetCompanyId: id, durationMinutes: ttlMinutes },
ipAddress: ip,
})
return { token, expiresIn: 1800 }
return { token, expiresIn: ttlMinutes * 60, impersonation: { companyId: id, reason, durationMinutes: ttlMinutes } }
}
export async function listRenters(query: { q?: string; blocked?: string; page: number; pageSize: number }) {
@@ -205,7 +267,7 @@ export async function getAuditLogs(query: { adminId?: string; action?: string; c
export async function listAdmins() {
const admins = await repo.listAdmins()
return admins.map((admin) => presenter.presentAdminUser(admin))
return admins.map((admin: any) => presenter.presentAdminUser(admin))
}
export async function createAdmin(body: { email: string; firstName: string; lastName: string; role: string; password: string; permissions?: any[] }) {