Files
carmanagement/apps/api/src/modules/admin/admin.service.ts
T
root 66877d66c2
Build & Push / Pipeline Tests (push) Successful in 1m53s
Test / Type Check (all packages) (push) Successful in 54s
Build & Push / Build & Push Docker Image (push) Successful in 3m29s
Test / API Unit Tests (push) Successful in 1m14s
Test / Homepage Unit Tests (push) Successful in 48s
Test / Carplace Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 44s
Test / API Integration Tests (push) Successful in 1m8s
fix admin login issue
2026-08-23 01:38:26 -04:00

619 lines
21 KiB
TypeScript

import bcrypt from 'bcryptjs'
import crypto from 'crypto'
import { hashPublicAccessToken } from '../../security/publicAccessTokens'
import { authenticator } from 'otplib'
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() {
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 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))
}
function ensureAdminBasePath(baseUrl: string) {
try {
const url = new URL(baseUrl)
const pathname = url.pathname.replace(/\/$/, '')
if (!pathname.endsWith('/admin')) url.pathname = `${pathname}/admin`
return url.toString().replace(/\/$/, '')
} catch {
const trimmed = baseUrl.replace(/\/$/, '')
return trimmed.endsWith('/admin') ? trimmed : `${trimmed}/admin`
}
}
export async function login(email: string, password: string, totpCode?: string, recoveryCode?: string) {
const admin = await repo.findAdminByEmail(email)
if (!admin || !admin.isActive) return null
const valid = await bcrypt.compare(password, admin.passwordHash)
if (!valid) return null
let last2faAt: number | undefined
if (admin.totpEnabled && (totpCode || recoveryCode)) {
const validTotp = totpCode && admin.totpSecret
? authenticator.verify({ token: totpCode, secret: admin.totpSecret! })
: false
const validEmailOtp = !validTotp && !admin.totpSecret && await consumeAdminEmailOtp(admin.id, totpCode)
const validRecoveryCode = !validTotp && !validEmailOtp && recoveryCode
? await consumeAdminRecoveryCode(admin.id, recoveryCode)
: false
if (!validTotp && !validEmailOtp && !validRecoveryCode) return { invalidTotp: true } as const
last2faAt = Date.now()
}
await repo.updateAdminLastLogin(admin.id)
await repo.createAuditLog({
adminUserId: admin.id,
action: 'ADMIN_LOGIN',
resource: 'AdminUser',
resourceId: admin.id,
})
return presenter.presentAdminSession(admin, signAdminToken(admin.id, last2faAt))
}
export async function setupTotp(adminId: string, email: string) {
const admin = await repo.findAdminByIdOrThrow(adminId)
const secret = admin.totpSecret && !admin.totpEnabled
? admin.totpSecret
: authenticator.generateSecret()
if (secret !== admin.totpSecret) {
await repo.updateAdminTotpSecret(adminId, secret)
}
const otpauth = authenticator.keyuri(email, 'RentalDriveGo Admin', secret)
const qrCode = await qrcode.toDataURL(otpauth)
return { secret, qrCode }
}
export async function setupEmail2fa(adminId: string) {
const admin = await repo.findAdminByIdOrThrow(adminId)
await sendAdminEmailOtp(admin)
return { message: 'Verification code sent.' }
}
export async function verifyEmail2fa(adminId: string, code: string) {
const admin = await repo.findAdminByIdOrThrow(adminId)
const valid = await consumeAdminEmailOtp(adminId, code)
if (!valid) return false
const updated = await repo.enableAdminEmail2fa(adminId)
await repo.createAuditLog({
adminUserId: adminId,
action: 'ADMIN_2FA_EMAIL_ENABLED',
resource: 'AdminUser',
resourceId: adminId,
})
const recoveryCodes = await issueAdminRecoveryCodes(adminId)
return {
...presenter.presentAdminSession({ ...admin, ...updated, totpEnabled: true }, signAdminToken(adminId, Date.now())),
recoveryCodes,
}
}
export async function verifyTotp(adminId: string, code: string) {
const admin = await repo.findAdminByIdOrThrow(adminId)
if (!admin.totpSecret) return false
const valid = authenticator.verify({ token: code, secret: admin.totpSecret })
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) {
const admin = await repo.findAdminByEmail(email)
if (!admin || !admin.isActive) return
const rawToken = crypto.randomBytes(32).toString('hex')
const expiresAt = new Date(Date.now() + ADMIN_RESET_TTL_MINUTES * 60 * 1000)
await repo.setAdminPasswordReset(admin.id, hashPublicAccessToken(rawToken), expiresAt)
const adminUrl = ensureAdminBasePath(
process.env.ADMIN_URL ?? process.env.NEXT_PUBLIC_ADMIN_URL ?? 'http://localhost:3000/admin',
)
const resetUrl = `${adminUrl}/reset-password?token=${rawToken}`
await sendTransactionalEmail({
to: admin.email,
subject: 'Reset your RentalDriveGo admin password',
html: `<p>Hi ${admin.firstName},</p><p><a href="${resetUrl}">Reset password</a></p><p>Expires in ${ADMIN_RESET_TTL_MINUTES} minutes.</p>`,
text: `Hi ${admin.firstName},\n\nReset here: ${resetUrl}\n\nExpires in ${ADMIN_RESET_TTL_MINUTES} minutes.`,
}).catch((err) => console.error('[AdminForgotPassword]', err?.message))
}
export async function resetPassword(token: string, password: string) {
const admin = await repo.findAdminByResetToken(token)
if (!admin) return false
await repo.updateAdminPassword(admin.id, await bcrypt.hash(password, 12))
return true
}
export async function listCompanies(query: { q?: string; status?: string; plan?: string; page: number; pageSize: number }) {
const { data, total } = await repo.listCompaniesPage(query)
return presenter.presentPaginated(data, total, query.page, query.pageSize)
}
export function getCompany(id: string) {
return repo.getCompanyDetail(id)
}
export async function updateCompany(id: string, body: any, adminId: string, ip?: string) {
const before = await repo.getCompanyUpdateSnapshot(id)
const updated = await repo.applyCompanyUpdate(id, body, before)
await repo.createAuditLog({
adminUserId: adminId,
action: 'UPDATE_COMPANY',
resource: 'Company',
resourceId: id,
companyId: id,
before: toAuditJson(before),
after: toAuditJson(body),
ipAddress: ip,
})
return updated
}
export async function setCompanyStatus(id: string, status: string, reason: string | undefined, adminId: string, ip?: string) {
const before = await repo.getCompanyUpdateSnapshot(id)
const updated = await repo.updateCompanyStatus(id, status)
await repo.createAuditLog({
adminUserId: adminId,
action: `SET_COMPANY_STATUS_${status}`,
resource: 'Company',
resourceId: id,
companyId: id,
before: { status: before.status },
after: { status },
note: reason,
ipAddress: ip,
})
return updated
}
export async function deleteCompany(id: string, adminId: string, ip?: string) {
await repo.deleteCompany(id)
await repo.createAuditLog({
adminUserId: adminId,
action: 'DELETE_COMPANY',
resource: 'Company',
resourceId: id,
ipAddress: ip,
})
}
export async function impersonateCompany(id: string, adminId: string, ip?: string, reason?: string, durationMinutes = 15) {
const company = await repo.getCompanyForImpersonation(id)
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,
action: 'IMPERSONATE_COMPANY',
resource: 'Company',
resourceId: id,
companyId: id,
note: reason,
before: { originalAdminId: adminId },
after: { targetCompanyId: id, durationMinutes: ttlMinutes },
ipAddress: ip,
})
return { token, expiresIn: ttlMinutes * 60, impersonation: { companyId: id, reason, durationMinutes: ttlMinutes } }
}
export async function listRenters(query: { q?: string; blocked?: string; page: number; pageSize: number }) {
const { data, total } = await repo.listRentersPage(query)
return presenter.presentPaginated(data, total, query.page, query.pageSize)
}
export function setRenterActive(id: string, isActive: boolean) {
return repo.updateRenterActive(id, isActive)
}
export function getPlatformMetrics() {
return repo.getPlatformMetricCounts()
}
export async function listNotifications(query: { channel?: string; status?: string; companyId?: string; page: number; pageSize: number }) {
const { data, total } = await repo.listNotificationsPage(query)
return presenter.presentPaginated(data, total, query.page, query.pageSize)
}
export async function getAuditLogs(query: { adminId?: string; action?: string; companyId?: string; entityId?: string; page: number; pageSize: number }) {
const { data, total } = await repo.listAuditLogsPage(query)
return presenter.presentPaginated(data, total, query.page, query.pageSize)
}
export async function listAdmins() {
const admins = await repo.listAdmins()
return admins.map((admin: any) => presenter.presentAdminUser(admin))
}
export async function createAdmin(body: { email: string; firstName: string; lastName: string; role: string; preferredLocale: string; password: string; permissions?: any[] }) {
const admin = await repo.createAdmin({
...body,
passwordHash: await bcrypt.hash(body.password, 12),
})
return presenter.presentAdminUser(admin)
}
export async function updateAdmin(
id: string,
body: {
email?: string
firstName?: string
lastName?: string
role?: string
preferredLocale?: string
password?: string
isActive?: boolean
},
) {
const admin = await repo.updateAdmin(id, {
...(body.email !== undefined ? { email: body.email } : {}),
...(body.firstName !== undefined ? { firstName: body.firstName } : {}),
...(body.lastName !== undefined ? { lastName: body.lastName } : {}),
...(body.role !== undefined ? { role: body.role } : {}),
...(body.preferredLocale !== undefined ? { preferredLocale: body.preferredLocale } : {}),
...(body.isActive !== undefined ? { isActive: body.isActive } : {}),
...(body.password ? { passwordHash: await bcrypt.hash(body.password, 12) } : {}),
})
return presenter.presentAdminUser(admin)
}
export function updateAdminRole(id: string, role: string) {
return repo.updateAdminRole(id, role)
}
export async function updateAdminPermissions(id: string, permissions: any[]) {
const admin = await repo.replaceAdminPermissions(id, permissions)
return presenter.presentAdminUser(admin)
}
export async function getBilling(query: { q?: string; status?: string; plan?: string; page: number; pageSize: number }) {
const { data, total, stats } = await billingService.listBillingAccounts(query)
return presenter.presentPaginated(data, total, query.page, query.pageSize, { stats })
}
export async function getCompanyInvoices(companyId: string, query: { page: number; pageSize: number }) {
const detail = await billingService.getBillingAccountDetail(companyId)
const start = (query.page - 1) * query.pageSize
const end = start + query.pageSize
const data = detail.invoices.slice(start, end)
const total = detail.invoices.length
return presenter.presentPaginated(data, total, query.page, query.pageSize)
}
export async function getInvoicePdf(invoiceId: string) {
return billingService.getInvoicePdf(invoiceId)
}
export function getBillingAccountDetail(companyId: string) {
return billingService.getBillingAccountDetail(companyId)
}
export function updateBillingAccount(
billingAccountId: string,
data: Parameters<typeof billingService.updateBillingAccount>[1],
adminId: string,
ip?: string,
) {
return billingService.updateBillingAccount(billingAccountId, data, adminId, ip)
}
export function getBillingPlatformSettings() {
return billingService.getBillingPlatformSettings()
}
export function updateBillingPlatformSettings(
data: Parameters<typeof billingService.updateBillingPlatformSettings>[0],
adminId: string,
ip?: string,
) {
return billingService.updateBillingPlatformSettings(data, adminId, ip)
}
export function setDunningPaused(billingAccountId: string, paused: boolean, adminId: string, ip?: string) {
return billingService.setDunningPaused(billingAccountId, paused, adminId, ip)
}
export function createBillingInvoice(
billingAccountId: string,
data: Parameters<typeof billingService.createDraftInvoice>[1],
adminId: string,
ip?: string,
) {
return billingService.createDraftInvoice(billingAccountId, data, adminId, ip)
}
export function finalizeBillingInvoice(invoiceId: string, adminId: string, ip?: string) {
return billingService.finalizeInvoice(invoiceId, adminId, ip)
}
export function payBillingInvoice(
invoiceId: string,
data: Parameters<typeof billingService.payInvoice>[1],
adminId: string,
ip?: string,
) {
return billingService.payInvoice(invoiceId, data, adminId, ip)
}
export function retryBillingInvoicePayment(
invoiceId: string,
data: Parameters<typeof billingService.retryInvoicePayment>[1],
adminId: string,
ip?: string,
) {
return billingService.retryInvoicePayment(invoiceId, data, adminId, ip)
}
export function voidBillingInvoice(invoiceId: string, reason: string, adminId: string, ip?: string) {
return billingService.voidInvoice(invoiceId, reason, adminId, ip)
}
export function markBillingInvoiceUncollectible(invoiceId: string, reason: string, adminId: string, ip?: string) {
return billingService.markInvoiceUncollectible(invoiceId, reason, adminId, ip)
}
export function issueBillingCreditNote(
invoiceId: string,
data: Parameters<typeof billingService.issueCreditNote>[1],
adminId: string,
ip?: string,
) {
return billingService.issueCreditNote(invoiceId, data, adminId, ip)
}
export function issueBillingRefund(
invoiceId: string,
data: Parameters<typeof billingService.issueRefund>[1],
adminId: string,
ip?: string,
) {
return billingService.issueRefund(invoiceId, data, adminId, ip)
}
export function getCarplaceHomepage() {
return getCarplaceHomepageContent()
}
export async function updateCarplaceHomepage(homepage: any, adminId: string, ip?: string) {
const saved = await saveCarplaceHomepageContent(homepage)
await repo.createAuditLog({
adminUserId: adminId,
action: 'UPDATE',
resource: 'CarplaceHomepage',
after: toAuditJson(saved),
ipAddress: ip,
userAgent: undefined,
})
return saved
}
export function getPricingConfigs() {
return repo.listPricingConfigs()
}
export async function updatePricingConfigs(entries: { plan: string; billingPeriod: string; amount: number }[], adminId: string, ip?: string) {
const results = await Promise.all(
entries.map((e) => repo.upsertPricingConfig(e.plan, e.billingPeriod, e.amount, adminId)),
)
await repo.createAuditLog({
adminUserId: adminId,
action: 'UPDATE',
resource: 'PricingConfig',
after: toAuditJson(results),
ipAddress: ip,
userAgent: undefined,
})
return results
}
export function listPlanFeatures() {
return repo.listPlanFeatures()
}
export async function createPlanFeature(data: Parameters<typeof repo.createPlanFeature>[0], adminId: string, ip?: string) {
const feature = await repo.createPlanFeature(data)
await repo.createAuditLog({
adminUserId: adminId,
action: 'CREATE',
resource: 'PlanFeature',
after: toAuditJson(feature),
ipAddress: ip,
userAgent: undefined,
})
return feature
}
export async function updatePlanFeature(id: string, data: Parameters<typeof repo.updatePlanFeature>[1], adminId: string, ip?: string) {
const feature = await repo.updatePlanFeature(id, data)
await repo.createAuditLog({
adminUserId: adminId,
action: 'UPDATE',
resource: 'PlanFeature',
resourceId: id,
after: toAuditJson(feature),
ipAddress: ip,
userAgent: undefined,
})
return feature
}
export async function deletePlanFeature(id: string, adminId: string, ip?: string) {
await repo.deletePlanFeature(id)
await repo.createAuditLog({
adminUserId: adminId,
action: 'DELETE',
resource: 'PlanFeature',
resourceId: id,
ipAddress: ip,
userAgent: undefined,
})
}
// ─── Promotions ────────────────────────────────────────────────────────────
export function listPromotions() {
return repo.listPromotions()
}
export async function createPromotion(data: Parameters<typeof repo.createPromotion>[0], adminId: string, ip?: string) {
const promo = await repo.createPromotion({ ...data, createdBy: adminId })
await repo.createAuditLog({
adminUserId: adminId, action: 'CREATE', resource: 'PricingPromotion',
after: toAuditJson(promo), ipAddress: ip, userAgent: undefined,
})
return promo
}
export async function updatePromotion(id: string, data: Parameters<typeof repo.updatePromotion>[1], adminId: string, ip?: string) {
const promo = await repo.updatePromotion(id, data)
await repo.createAuditLog({
adminUserId: adminId, action: 'UPDATE', resource: 'PricingPromotion',
after: toAuditJson(promo), ipAddress: ip, userAgent: undefined,
})
return promo
}
export async function deletePromotion(id: string, adminId: string, ip?: string) {
await repo.deletePromotion(id)
await repo.createAuditLog({
adminUserId: adminId, action: 'DELETE', resource: 'PricingPromotion',
entityId: id, ipAddress: ip, userAgent: undefined,
})
}