fix admin login issue
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

This commit is contained in:
root
2026-08-23 01:38:26 -04:00
parent fe68f4c6b0
commit 66877d66c2
8 changed files with 71 additions and 44 deletions
@@ -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)
@@ -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({
+5 -10
View File
@@ -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) {
@@ -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 })
}
@@ -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 },
},
})
})
@@ -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<string, Record<string, Record<string, number>>> = {}
const result: Record<string, Record<string, Record<string, number>>> = structuredClone(PLAN_PRICES)
for (const c of configs) {
if (!result[c.plan]) result[c.plan] = {}
result[c.plan]![c.billingPeriod] = { MAD: c.amount }
@@ -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=/),
]))
})
})
@@ -139,6 +139,36 @@ const PLAN_LABELS: Record<Plan, string> = {
ENTERPRISE: 'Enterprise',
}
function mergePlanPrices(overrides?: Record<string, Record<string, Record<string, number>>> | null) {
const result: Record<string, Record<string, Record<string, number>>> = {
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<Record<string, Record<string, Record<string, number>>>>('/subscriptions/plans'),
apiFetch<PlanFeature[]>('/subscriptions/features'),
])
if (prices && Object.keys(prices).length > 0) setPlanPrices(prices)
setPlanPrices(mergePlanPrices(prices))
if (features) setPlanFeaturesList(features)
}, [])