fix architecture and write new tests
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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[] }) {
|
||||
|
||||
Reference in New Issue
Block a user