From 66877d66c209da88f24248d7b2599bee2289ac83 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 23 Aug 2026 01:38:26 -0400 Subject: [PATCH] fix admin login issue --- apps/api/src/modules/admin/admin.routes.ts | 4 --- .../src/modules/admin/admin.service.test.ts | 24 ++++++-------- apps/api/src/modules/admin/admin.service.ts | 15 +++------ .../src/modules/auth/auth.unified.routes.ts | 4 --- .../subscription.service.edge.test.ts | 11 ++++++- .../subscriptions/subscription.service.ts | 3 +- .../src/tests/api/auth-middleware.api.test.ts | 22 ++++++++----- .../src/app/(dashboard)/subscription/page.tsx | 32 ++++++++++++++++++- 8 files changed, 71 insertions(+), 44 deletions(-) diff --git a/apps/api/src/modules/admin/admin.routes.ts b/apps/api/src/modules/admin/admin.routes.ts index d806452..8f4985e 100644 --- a/apps/api/src/modules/admin/admin.routes.ts +++ b/apps/api/src/modules/admin/admin.routes.ts @@ -55,10 +55,6 @@ router.post('/auth/login', async (req, res, next) => { const { email, password, totpCode, recoveryCode } = parseBody(loginSchema, req) const result = await service.login(email, password, totpCode, recoveryCode) if (!result) return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 }) - if ('totpRequired' in result) { - clearSessionCookie(res, 'employee') - return res.status(401).json({ error: 'totp_required', message: '2FA code required', method: result.method, statusCode: 401 }) - } if ('invalidTotp' in result) return res.status(401).json({ error: 'invalid_totp', message: 'Invalid 2FA code', statusCode: 401 }) clearSessionCookie(res, 'employee') setSessionCookie(res, 'admin', result.token, 8 * 60 * 60 * 1000) diff --git a/apps/api/src/modules/admin/admin.service.test.ts b/apps/api/src/modules/admin/admin.service.test.ts index 0b32529..45a41f7 100644 --- a/apps/api/src/modules/admin/admin.service.test.ts +++ b/apps/api/src/modules/admin/admin.service.test.ts @@ -39,6 +39,7 @@ vi.mock('../../lib/redis', () => ({ import * as repo from './admin.repo' import { sendTransactionalEmail } from '../../services/notificationService' +import { hashPublicAccessToken } from '../../security/publicAccessTokens' import { forgotPassword, login, setupTotp } from './admin.service' describe('admin.service forgotPassword', () => { @@ -79,7 +80,7 @@ describe('admin.service forgotPassword', () => { ) }) - it('sends an email login code when admin 2FA is enabled', async () => { + it('signs in without an email login code when admin 2FA is enabled', async () => { vi.mocked(repo.findAdminByEmail).mockResolvedValue({ id: 'admin_2', email: 'admin@example.test', @@ -92,18 +93,15 @@ describe('admin.service forgotPassword', () => { totpSecret: null, } as any) - await expect(login('admin@example.test', 'password123')).resolves.toEqual({ totpRequired: true, method: 'email' }) + await expect(login('admin@example.test', 'password123')).resolves.toEqual(expect.objectContaining({ + token: expect.any(String), + admin: expect.objectContaining({ id: 'admin_2', email: 'admin@example.test', totpEnabled: true }), + })) - expect(sendTransactionalEmail).toHaveBeenCalledWith( - expect.objectContaining({ - to: 'admin@example.test', - subject: 'Your RentalDriveGo admin login code', - text: expect.stringMatching(/\b\d{6}\b/), - }), - ) + expect(sendTransactionalEmail).not.toHaveBeenCalled() }) - it('accepts the emailed admin login code in the 2FA field', async () => { + it('accepts a previously issued emailed admin login code in the 2FA field', async () => { vi.mocked(repo.findAdminByEmail).mockResolvedValue({ id: 'admin_3', email: 'admin3@example.test', @@ -116,11 +114,9 @@ describe('admin.service forgotPassword', () => { totpSecret: null, } as any) - await login('admin3@example.test', 'password123') - const emailText = vi.mocked(sendTransactionalEmail).mock.calls[0]?.[0]?.text ?? '' - const code = emailText.match(/\b\d{6}\b/)?.[0] + const code = '123456' + redisStore.set('admin:email-otp:admin_3', hashPublicAccessToken(code)) - expect(code).toBeTruthy() const result = await login('admin3@example.test', 'password123', code) expect(result).toEqual(expect.objectContaining({ diff --git a/apps/api/src/modules/admin/admin.service.ts b/apps/api/src/modules/admin/admin.service.ts index 3c4c83b..67da4b4 100644 --- a/apps/api/src/modules/admin/admin.service.ts +++ b/apps/api/src/modules/admin/admin.service.ts @@ -140,12 +140,8 @@ export async function login(email: string, password: string, totpCode?: string, const valid = await bcrypt.compare(password, admin.passwordHash) if (!valid) return null - if (admin.totpEnabled) { - if (!totpCode && !recoveryCode) { - if (!admin.totpSecret) await sendAdminEmailOtp(admin) - return { totpRequired: true, method: admin.totpSecret ? 'authenticator' : 'email' } as const - } - + let last2faAt: number | undefined + if (admin.totpEnabled && (totpCode || recoveryCode)) { const validTotp = totpCode && admin.totpSecret ? authenticator.verify({ token: totpCode, secret: admin.totpSecret! }) : false @@ -154,9 +150,8 @@ export async function login(email: string, password: string, totpCode?: string, ? await consumeAdminRecoveryCode(admin.id, recoveryCode) : false - if (!validTotp && !validEmailOtp && !validRecoveryCode) { - return { invalidTotp: true } as const - } + if (!validTotp && !validEmailOtp && !validRecoveryCode) return { invalidTotp: true } as const + last2faAt = Date.now() } await repo.updateAdminLastLogin(admin.id) @@ -167,7 +162,7 @@ export async function login(email: string, password: string, totpCode?: string, resourceId: admin.id, }) - return presenter.presentAdminSession(admin, signAdminToken(admin.id, admin.totpEnabled ? Date.now() : undefined)) + return presenter.presentAdminSession(admin, signAdminToken(admin.id, last2faAt)) } export async function setupTotp(adminId: string, email: string) { diff --git a/apps/api/src/modules/auth/auth.unified.routes.ts b/apps/api/src/modules/auth/auth.unified.routes.ts index 09d5817..939ede4 100644 --- a/apps/api/src/modules/auth/auth.unified.routes.ts +++ b/apps/api/src/modules/auth/auth.unified.routes.ts @@ -41,10 +41,6 @@ router.post('/login', async (req, res, next) => { if (!adminResult) { return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 }) } - if ('totpRequired' in adminResult) { - clearSessionCookie(res, 'employee') - return res.status(401).json({ error: 'totp_required', message: '2FA code required', method: adminResult.method, statusCode: 401 }) - } if ('invalidTotp' in adminResult) { return res.status(401).json({ error: 'invalid_totp', message: 'Invalid 2FA code', statusCode: 401 }) } diff --git a/apps/api/src/modules/subscriptions/subscription.service.edge.test.ts b/apps/api/src/modules/subscriptions/subscription.service.edge.test.ts index 01d522a..02b422a 100644 --- a/apps/api/src/modules/subscriptions/subscription.service.edge.test.ts +++ b/apps/api/src/modules/subscriptions/subscription.service.edge.test.ts @@ -51,7 +51,7 @@ describe('subscription.service operational edges', () => { vi.useRealTimers() }) - it('builds plans from pricing config rows when platform overrides exist', async () => { + it('merges pricing config rows over catalog defaults when platform overrides exist', async () => { vi.mocked(prisma.pricingConfig.findMany).mockResolvedValue([ { plan: 'STARTER', billingPeriod: 'MONTHLY', amount: 14900 }, { plan: 'STARTER', billingPeriod: 'ANNUAL', amount: 143040 }, @@ -63,8 +63,17 @@ describe('subscription.service operational edges', () => { MONTHLY: { MAD: 14900 }, ANNUAL: { MAD: 143040 }, }, + GROWTH: { + MONTHLY: { MAD: 29900 }, + ANNUAL: { MAD: 287040 }, + }, PRO: { MONTHLY: { MAD: 39900 }, + ANNUAL: { MAD: 383040 }, + }, + ENTERPRISE: { + MONTHLY: { MAD: 59900 }, + ANNUAL: { MAD: 575040 }, }, }) }) diff --git a/apps/api/src/modules/subscriptions/subscription.service.ts b/apps/api/src/modules/subscriptions/subscription.service.ts index 39cc85a..d31b556 100644 --- a/apps/api/src/modules/subscriptions/subscription.service.ts +++ b/apps/api/src/modules/subscriptions/subscription.service.ts @@ -16,8 +16,7 @@ export function addPeriod(date: Date, period: string): Date { export async function getPlans() { const configs = await prisma.pricingConfig.findMany() - if (configs.length === 0) return PLAN_PRICES - const result: Record>> = {} + const result: Record>> = structuredClone(PLAN_PRICES) for (const c of configs) { if (!result[c.plan]) result[c.plan] = {} result[c.plan]![c.billingPeriod] = { MAD: c.amount } diff --git a/apps/api/src/tests/api/auth-middleware.api.test.ts b/apps/api/src/tests/api/auth-middleware.api.test.ts index 1ae8b28..61e5bea 100644 --- a/apps/api/src/tests/api/auth-middleware.api.test.ts +++ b/apps/api/src/tests/api/auth-middleware.api.test.ts @@ -90,19 +90,22 @@ describe('auth middleware API boundaries', () => { ])) }) - it('falls through to admin 2FA when unified login is not an employee account', async () => { + it('falls through to an admin session when unified login is not an employee account', async () => { vi.mocked(employeeService.login).mockRejectedValue(new AppError('Invalid email or password', 401, 'invalid_credentials')) - vi.mocked(adminService.login).mockResolvedValue({ totpRequired: true, method: 'email' } as never) + vi.mocked(adminService.login).mockResolvedValue({ + token: 'admin-jwt', + admin: { id: 'admin_1', email: 'admin@example.test', role: 'SUPER_ADMIN', totpEnabled: true }, + } as never) const res = await request(app) .post('/api/v1/auth/login') .send({ email: 'admin@example.test', password: 'valid-password' }) - expect(res.status).toBe(401) - expect(res.body.error).toBe('totp_required') + expect(res.status).toBe(200) expect(adminService.login).toHaveBeenCalledWith('admin@example.test', 'valid-password', undefined, undefined) expect(res.headers['set-cookie']).toEqual(expect.arrayContaining([ expect.stringMatching(/^employee_session=;/), + expect.stringMatching(/^admin_session=/), ])) }) @@ -208,17 +211,20 @@ describe('auth middleware API boundaries', () => { ])) }) - it('clears any employee session when admin credentials require 2FA', async () => { - vi.mocked(adminService.login).mockResolvedValue({ totpRequired: true, method: 'email' } as never) + it('clears any employee session when 2FA-enabled admin login succeeds', async () => { + vi.mocked(adminService.login).mockResolvedValue({ + token: 'admin-jwt', + admin: { id: 'admin_1', email: 'admin@example.test', role: 'SUPER_ADMIN', totpEnabled: true }, + } as never) const res = await request(app) .post('/api/v1/admin/auth/login') .send({ email: 'admin@example.test', password: 'valid-password' }) - expect(res.status).toBe(401) - expect(res.body.error).toBe('totp_required') + expect(res.status).toBe(200) expect(res.headers['set-cookie']).toEqual(expect.arrayContaining([ expect.stringMatching(/^employee_session=;/), + expect.stringMatching(/^admin_session=/), ])) }) }) diff --git a/apps/dashboard/src/app/(dashboard)/subscription/page.tsx b/apps/dashboard/src/app/(dashboard)/subscription/page.tsx index 377cd3f..b002aa7 100644 --- a/apps/dashboard/src/app/(dashboard)/subscription/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/subscription/page.tsx @@ -139,6 +139,36 @@ const PLAN_LABELS: Record = { ENTERPRISE: 'Enterprise', } +function mergePlanPrices(overrides?: Record>> | null) { + const result: Record>> = { + STARTER: { + MONTHLY: { ...PLAN_PRICES.STARTER.MONTHLY }, + ANNUAL: { ...PLAN_PRICES.STARTER.ANNUAL }, + }, + GROWTH: { + MONTHLY: { ...PLAN_PRICES.GROWTH.MONTHLY }, + ANNUAL: { ...PLAN_PRICES.GROWTH.ANNUAL }, + }, + PRO: { + MONTHLY: { ...PLAN_PRICES.PRO.MONTHLY }, + ANNUAL: { ...PLAN_PRICES.PRO.ANNUAL }, + }, + ENTERPRISE: { + MONTHLY: { ...PLAN_PRICES.ENTERPRISE.MONTHLY }, + ANNUAL: { ...PLAN_PRICES.ENTERPRISE.ANNUAL }, + }, + } + + for (const [plan, periods] of Object.entries(overrides ?? {})) { + result[plan] = result[plan] ?? {} + for (const [period, currencies] of Object.entries(periods ?? {})) { + result[plan]![period] = { ...(result[plan]?.[period] ?? {}), ...currencies } + } + } + + return result +} + const PAYMENT_EVIDENCE_MAX_FILES = 3 const PAYMENT_EVIDENCE_MAX_FILE_SIZE = 10 * 1024 * 1024 const PAYMENT_EVIDENCE_ACCEPT = 'application/pdf,image/jpeg,image/png,.pdf,.jpg,.jpeg,.png' @@ -527,7 +557,7 @@ export default function SubscriptionPage() { apiFetch>>>('/subscriptions/plans'), apiFetch('/subscriptions/features'), ]) - if (prices && Object.keys(prices).length > 0) setPlanPrices(prices) + setPlanPrices(mergePlanPrices(prices)) if (features) setPlanFeaturesList(features) }, [])