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
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:
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
}, [])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user