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 { email, password, totpCode, recoveryCode } = parseBody(loginSchema, req)
|
||||||
const result = await service.login(email, password, totpCode, recoveryCode)
|
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 (!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 })
|
if ('invalidTotp' in result) return res.status(401).json({ error: 'invalid_totp', message: 'Invalid 2FA code', statusCode: 401 })
|
||||||
clearSessionCookie(res, 'employee')
|
clearSessionCookie(res, 'employee')
|
||||||
setSessionCookie(res, 'admin', result.token, 8 * 60 * 60 * 1000)
|
setSessionCookie(res, 'admin', result.token, 8 * 60 * 60 * 1000)
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ vi.mock('../../lib/redis', () => ({
|
|||||||
|
|
||||||
import * as repo from './admin.repo'
|
import * as repo from './admin.repo'
|
||||||
import { sendTransactionalEmail } from '../../services/notificationService'
|
import { sendTransactionalEmail } from '../../services/notificationService'
|
||||||
|
import { hashPublicAccessToken } from '../../security/publicAccessTokens'
|
||||||
import { forgotPassword, login, setupTotp } from './admin.service'
|
import { forgotPassword, login, setupTotp } from './admin.service'
|
||||||
|
|
||||||
describe('admin.service forgotPassword', () => {
|
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({
|
vi.mocked(repo.findAdminByEmail).mockResolvedValue({
|
||||||
id: 'admin_2',
|
id: 'admin_2',
|
||||||
email: 'admin@example.test',
|
email: 'admin@example.test',
|
||||||
@@ -92,18 +93,15 @@ describe('admin.service forgotPassword', () => {
|
|||||||
totpSecret: null,
|
totpSecret: null,
|
||||||
} as any)
|
} 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(sendTransactionalEmail).not.toHaveBeenCalled()
|
||||||
expect.objectContaining({
|
|
||||||
to: 'admin@example.test',
|
|
||||||
subject: 'Your RentalDriveGo admin login code',
|
|
||||||
text: expect.stringMatching(/\b\d{6}\b/),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
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({
|
vi.mocked(repo.findAdminByEmail).mockResolvedValue({
|
||||||
id: 'admin_3',
|
id: 'admin_3',
|
||||||
email: 'admin3@example.test',
|
email: 'admin3@example.test',
|
||||||
@@ -116,11 +114,9 @@ describe('admin.service forgotPassword', () => {
|
|||||||
totpSecret: null,
|
totpSecret: null,
|
||||||
} as any)
|
} as any)
|
||||||
|
|
||||||
await login('admin3@example.test', 'password123')
|
const code = '123456'
|
||||||
const emailText = vi.mocked(sendTransactionalEmail).mock.calls[0]?.[0]?.text ?? ''
|
redisStore.set('admin:email-otp:admin_3', hashPublicAccessToken(code))
|
||||||
const code = emailText.match(/\b\d{6}\b/)?.[0]
|
|
||||||
|
|
||||||
expect(code).toBeTruthy()
|
|
||||||
const result = await login('admin3@example.test', 'password123', code)
|
const result = await login('admin3@example.test', 'password123', code)
|
||||||
|
|
||||||
expect(result).toEqual(expect.objectContaining({
|
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)
|
const valid = await bcrypt.compare(password, admin.passwordHash)
|
||||||
if (!valid) return null
|
if (!valid) return null
|
||||||
|
|
||||||
if (admin.totpEnabled) {
|
let last2faAt: number | undefined
|
||||||
if (!totpCode && !recoveryCode) {
|
if (admin.totpEnabled && (totpCode || recoveryCode)) {
|
||||||
if (!admin.totpSecret) await sendAdminEmailOtp(admin)
|
|
||||||
return { totpRequired: true, method: admin.totpSecret ? 'authenticator' : 'email' } as const
|
|
||||||
}
|
|
||||||
|
|
||||||
const validTotp = totpCode && admin.totpSecret
|
const validTotp = totpCode && admin.totpSecret
|
||||||
? authenticator.verify({ token: totpCode, secret: admin.totpSecret! })
|
? authenticator.verify({ token: totpCode, secret: admin.totpSecret! })
|
||||||
: false
|
: false
|
||||||
@@ -154,9 +150,8 @@ export async function login(email: string, password: string, totpCode?: string,
|
|||||||
? await consumeAdminRecoveryCode(admin.id, recoveryCode)
|
? await consumeAdminRecoveryCode(admin.id, recoveryCode)
|
||||||
: false
|
: false
|
||||||
|
|
||||||
if (!validTotp && !validEmailOtp && !validRecoveryCode) {
|
if (!validTotp && !validEmailOtp && !validRecoveryCode) return { invalidTotp: true } as const
|
||||||
return { invalidTotp: true } as const
|
last2faAt = Date.now()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await repo.updateAdminLastLogin(admin.id)
|
await repo.updateAdminLastLogin(admin.id)
|
||||||
@@ -167,7 +162,7 @@ export async function login(email: string, password: string, totpCode?: string,
|
|||||||
resourceId: admin.id,
|
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) {
|
export async function setupTotp(adminId: string, email: string) {
|
||||||
|
|||||||
@@ -41,10 +41,6 @@ router.post('/login', async (req, res, next) => {
|
|||||||
if (!adminResult) {
|
if (!adminResult) {
|
||||||
return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 })
|
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) {
|
if ('invalidTotp' in adminResult) {
|
||||||
return res.status(401).json({ error: 'invalid_totp', message: 'Invalid 2FA code', statusCode: 401 })
|
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()
|
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([
|
vi.mocked(prisma.pricingConfig.findMany).mockResolvedValue([
|
||||||
{ plan: 'STARTER', billingPeriod: 'MONTHLY', amount: 14900 },
|
{ plan: 'STARTER', billingPeriod: 'MONTHLY', amount: 14900 },
|
||||||
{ plan: 'STARTER', billingPeriod: 'ANNUAL', amount: 143040 },
|
{ plan: 'STARTER', billingPeriod: 'ANNUAL', amount: 143040 },
|
||||||
@@ -63,8 +63,17 @@ describe('subscription.service operational edges', () => {
|
|||||||
MONTHLY: { MAD: 14900 },
|
MONTHLY: { MAD: 14900 },
|
||||||
ANNUAL: { MAD: 143040 },
|
ANNUAL: { MAD: 143040 },
|
||||||
},
|
},
|
||||||
|
GROWTH: {
|
||||||
|
MONTHLY: { MAD: 29900 },
|
||||||
|
ANNUAL: { MAD: 287040 },
|
||||||
|
},
|
||||||
PRO: {
|
PRO: {
|
||||||
MONTHLY: { MAD: 39900 },
|
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() {
|
export async function getPlans() {
|
||||||
const configs = await prisma.pricingConfig.findMany()
|
const configs = await prisma.pricingConfig.findMany()
|
||||||
if (configs.length === 0) return PLAN_PRICES
|
const result: Record<string, Record<string, Record<string, number>>> = structuredClone(PLAN_PRICES)
|
||||||
const result: Record<string, Record<string, Record<string, number>>> = {}
|
|
||||||
for (const c of configs) {
|
for (const c of configs) {
|
||||||
if (!result[c.plan]) result[c.plan] = {}
|
if (!result[c.plan]) result[c.plan] = {}
|
||||||
result[c.plan]![c.billingPeriod] = { MAD: c.amount }
|
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(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)
|
const res = await request(app)
|
||||||
.post('/api/v1/auth/login')
|
.post('/api/v1/auth/login')
|
||||||
.send({ email: 'admin@example.test', password: 'valid-password' })
|
.send({ email: 'admin@example.test', password: 'valid-password' })
|
||||||
|
|
||||||
expect(res.status).toBe(401)
|
expect(res.status).toBe(200)
|
||||||
expect(res.body.error).toBe('totp_required')
|
|
||||||
expect(adminService.login).toHaveBeenCalledWith('admin@example.test', 'valid-password', undefined, undefined)
|
expect(adminService.login).toHaveBeenCalledWith('admin@example.test', 'valid-password', undefined, undefined)
|
||||||
expect(res.headers['set-cookie']).toEqual(expect.arrayContaining([
|
expect(res.headers['set-cookie']).toEqual(expect.arrayContaining([
|
||||||
expect.stringMatching(/^employee_session=;/),
|
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 () => {
|
it('clears any employee session when 2FA-enabled admin login succeeds', async () => {
|
||||||
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)
|
const res = await request(app)
|
||||||
.post('/api/v1/admin/auth/login')
|
.post('/api/v1/admin/auth/login')
|
||||||
.send({ email: 'admin@example.test', password: 'valid-password' })
|
.send({ email: 'admin@example.test', password: 'valid-password' })
|
||||||
|
|
||||||
expect(res.status).toBe(401)
|
expect(res.status).toBe(200)
|
||||||
expect(res.body.error).toBe('totp_required')
|
|
||||||
expect(res.headers['set-cookie']).toEqual(expect.arrayContaining([
|
expect(res.headers['set-cookie']).toEqual(expect.arrayContaining([
|
||||||
expect.stringMatching(/^employee_session=;/),
|
expect.stringMatching(/^employee_session=;/),
|
||||||
|
expect.stringMatching(/^admin_session=/),
|
||||||
]))
|
]))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -139,6 +139,36 @@ const PLAN_LABELS: Record<Plan, string> = {
|
|||||||
ENTERPRISE: 'Enterprise',
|
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_FILES = 3
|
||||||
const PAYMENT_EVIDENCE_MAX_FILE_SIZE = 10 * 1024 * 1024
|
const PAYMENT_EVIDENCE_MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||||
const PAYMENT_EVIDENCE_ACCEPT = 'application/pdf,image/jpeg,image/png,.pdf,.jpg,.jpeg,.png'
|
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<Record<string, Record<string, Record<string, number>>>>('/subscriptions/plans'),
|
||||||
apiFetch<PlanFeature[]>('/subscriptions/features'),
|
apiFetch<PlanFeature[]>('/subscriptions/features'),
|
||||||
])
|
])
|
||||||
if (prices && Object.keys(prices).length > 0) setPlanPrices(prices)
|
setPlanPrices(mergePlanPrices(prices))
|
||||||
if (features) setPlanFeaturesList(features)
|
if (features) setPlanFeaturesList(features)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user