fix 2fa and move communication language to setting
Build & Push / Pipeline Tests (push) Failing after 1m26s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 50s
Test / API Unit Tests (push) Failing after 1m6s
Test / Homepage Unit Tests (push) Successful in 48s
Test / Carplace Unit Tests (push) Successful in 44s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 41s
Test / API Integration Tests (push) Successful in 1m6s
Build & Push / Pipeline Tests (push) Failing after 1m26s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 50s
Test / API Unit Tests (push) Failing after 1m6s
Test / Homepage Unit Tests (push) Successful in 48s
Test / Carplace Unit Tests (push) Successful in 44s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 41s
Test / API Integration Tests (push) Successful in 1m6s
This commit is contained in:
@@ -95,18 +95,19 @@ describe('requireAdminAuth middleware', () => {
|
||||
expect(next).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('blocks non-enrolled admins from privileged routes', async () => {
|
||||
it('allows non-enrolled admins through regular admin auth', async () => {
|
||||
vi.mocked(jwt.verify).mockReturnValue({ sub: 'admin_1', type: 'admin' } as any)
|
||||
vi.mocked(prisma.adminUser.findUnique).mockResolvedValue({ id: 'admin_1', isActive: true, role: 'ADMIN', totpEnabled: false } as any)
|
||||
const admin = { id: 'admin_1', isActive: true, role: 'ADMIN', totpEnabled: false }
|
||||
vi.mocked(prisma.adminUser.findUnique).mockResolvedValue(admin as any)
|
||||
const req = { headers: { authorization: 'Bearer admin-token' }, path: '/companies' } as Request
|
||||
const res = responseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireAdminAuth(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403)
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'admin_2fa_required', message: 'Admin 2FA enrollment is required before using privileged admin routes', statusCode: 403 })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
expect(req.admin).toEqual(admin)
|
||||
expect(next).toHaveBeenCalledTimes(1)
|
||||
expect(res.status).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -13,17 +13,6 @@ const ADMIN_ROLE_ALLOWLIST: Record<AdminRole, readonly AdminRole[]> = {
|
||||
VIEWER: ['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER'],
|
||||
}
|
||||
|
||||
const ADMIN_2FA_ENROLLMENT_EXEMPT_PATHS = new Set([
|
||||
'/auth/me',
|
||||
'/auth/logout',
|
||||
'/auth/2fa/setup',
|
||||
'/auth/2fa/verify',
|
||||
])
|
||||
|
||||
function is2faEnrollmentExempt(req: Request) {
|
||||
return ADMIN_2FA_ENROLLMENT_EXEMPT_PATHS.has(req.path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Requires a valid admin session token.
|
||||
*
|
||||
@@ -47,10 +36,6 @@ export async function requireAdminAuth(req: Request, res: Response, next: NextFu
|
||||
return sendUnauthorized(res, 'unauthenticated', 'Admin account not found or deactivated')
|
||||
}
|
||||
|
||||
if (!admin.totpEnabled && !is2faEnrollmentExempt(req)) {
|
||||
return sendForbidden(res, 'admin_2fa_required', 'Admin 2FA enrollment is required before using privileged admin routes')
|
||||
}
|
||||
|
||||
req.admin = admin
|
||||
req.adminAuthLast2faAt = typeof payload.last2faAt === 'number' ? payload.last2faAt : undefined
|
||||
next()
|
||||
|
||||
@@ -59,6 +59,10 @@ export function enableAdminTotp(id: string) {
|
||||
return prisma.adminUser.update({ where: { id }, data: { totpEnabled: true } })
|
||||
}
|
||||
|
||||
export function enableAdminEmail2fa(id: string) {
|
||||
return prisma.adminUser.update({ where: { id }, data: { totpEnabled: true, totpSecret: null } })
|
||||
}
|
||||
|
||||
|
||||
export async function replaceAdminRecoveryCodes(adminUserId: string, codeHashes: string[]) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import * as upgradeService from '../subscriptions/subscription.upgrade.service'
|
||||
import { getAdminNotificationInbox, markAdminNotificationRead } from '../../services/notificationService'
|
||||
import { presentAdminUser } from './admin.presenter'
|
||||
import {
|
||||
loginSchema, forgotPasswordSchema, resetPasswordSchema, totpVerifySchema,
|
||||
loginSchema, forgotPasswordSchema, resetPasswordSchema, totpVerifySchema, email2faVerifySchema,
|
||||
companiesQuerySchema, rentersQuerySchema, auditLogQuerySchema, billingQuerySchema, notificationsQuerySchema,
|
||||
invoicesQuerySchema, adminCompanyUpdateSchema, companyStatusSchema,
|
||||
createAdminSchema, adminRoleSchema, adminPermissionsSchema,
|
||||
@@ -57,7 +57,7 @@ router.post('/auth/login', async (req, res, next) => {
|
||||
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', statusCode: 401 })
|
||||
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')
|
||||
@@ -100,6 +100,22 @@ router.post('/auth/2fa/setup', requireAdminAuth, requireFreshAdmin2FAWhenEnabled
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/auth/2fa/email/setup', requireAdminAuth, requireFreshAdmin2FAWhenEnabled, async (req, res, next) => {
|
||||
try {
|
||||
ok(res, await service.setupEmail2fa(req.admin.id))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/auth/2fa/email/verify', requireAdminAuth, requireFreshAdmin2FAWhenEnabled, async (req, res, next) => {
|
||||
try {
|
||||
const { code } = parseBody(email2faVerifySchema, req)
|
||||
const result = await service.verifyEmail2fa(req.admin.id, code)
|
||||
if (!result) return res.status(400).json({ error: 'invalid_code', message: 'Invalid email verification code', statusCode: 400 })
|
||||
setSessionCookie(res, 'admin', result.token, 8 * 60 * 60 * 1000)
|
||||
ok(res, { success: true, admin: result.admin, recoveryCodes: result.recoveryCodes })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/auth/2fa/verify', requireAdminAuth, async (req, res, next) => {
|
||||
try {
|
||||
const { code } = parseBody(totpVerifySchema, req)
|
||||
|
||||
@@ -29,6 +29,10 @@ export const totpVerifySchema = z.object({
|
||||
code: z.string().length(6),
|
||||
})
|
||||
|
||||
export const email2faVerifySchema = z.object({
|
||||
code: z.string().length(6),
|
||||
})
|
||||
|
||||
export const companiesQuerySchema = z.object({
|
||||
q: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
|
||||
@@ -89,10 +89,10 @@ describe('admin.service forgotPassword', () => {
|
||||
isActive: true,
|
||||
passwordHash: await bcrypt.hash('password123', 4),
|
||||
totpEnabled: true,
|
||||
totpSecret: 'JBSWY3DPEHPK3PXP',
|
||||
totpSecret: null,
|
||||
} as any)
|
||||
|
||||
await expect(login('admin@example.test', 'password123')).resolves.toEqual({ totpRequired: true })
|
||||
await expect(login('admin@example.test', 'password123')).resolves.toEqual({ totpRequired: true, method: 'email' })
|
||||
|
||||
expect(sendTransactionalEmail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -113,7 +113,7 @@ describe('admin.service forgotPassword', () => {
|
||||
isActive: true,
|
||||
passwordHash: await bcrypt.hash('password123', 4),
|
||||
totpEnabled: true,
|
||||
totpSecret: 'JBSWY3DPEHPK3PXP',
|
||||
totpSecret: null,
|
||||
} as any)
|
||||
|
||||
await login('admin3@example.test', 'password123')
|
||||
|
||||
@@ -142,14 +142,14 @@ export async function login(email: string, password: string, totpCode?: string,
|
||||
|
||||
if (admin.totpEnabled) {
|
||||
if (!totpCode && !recoveryCode) {
|
||||
await sendAdminEmailOtp(admin)
|
||||
return { totpRequired: true } as const
|
||||
if (!admin.totpSecret) await sendAdminEmailOtp(admin)
|
||||
return { totpRequired: true, method: admin.totpSecret ? 'authenticator' : 'email' } as const
|
||||
}
|
||||
|
||||
const validTotp = totpCode
|
||||
const validTotp = totpCode && admin.totpSecret
|
||||
? authenticator.verify({ token: totpCode, secret: admin.totpSecret! })
|
||||
: false
|
||||
const validEmailOtp = !validTotp && await consumeAdminEmailOtp(admin.id, totpCode)
|
||||
const validEmailOtp = !validTotp && !admin.totpSecret && await consumeAdminEmailOtp(admin.id, totpCode)
|
||||
const validRecoveryCode = !validTotp && !validEmailOtp && recoveryCode
|
||||
? await consumeAdminRecoveryCode(admin.id, recoveryCode)
|
||||
: false
|
||||
@@ -183,6 +183,31 @@ export async function setupTotp(adminId: string, email: string) {
|
||||
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
|
||||
|
||||
@@ -72,6 +72,18 @@ export function updatePreferredLanguage(id: string, preferredLanguage: 'en' | 'f
|
||||
})
|
||||
}
|
||||
|
||||
export function updateEmployeeTotpSecret(id: string, secret: string) {
|
||||
return prisma.employee.update({ where: { id }, data: { totpSecret: secret } })
|
||||
}
|
||||
|
||||
export function enableEmployeeTotp(id: string) {
|
||||
return prisma.employee.update({ where: { id }, data: { totpEnabled: true } })
|
||||
}
|
||||
|
||||
export function enableEmployeeEmail2fa(id: string) {
|
||||
return prisma.employee.update({ where: { id }, data: { totpEnabled: true, totpSecret: null } })
|
||||
}
|
||||
|
||||
export function findEmployeeByResetToken(token: string) {
|
||||
const tokenHash = hashPublicAccessToken(token)
|
||||
return prisma.employee.findFirst({
|
||||
|
||||
@@ -6,6 +6,7 @@ import { setSessionCookie, clearSessionCookie } from '../../security/sessionCook
|
||||
import { getEmployeeMenu } from '../menu/menu.service'
|
||||
import {
|
||||
employeeForgotPasswordSchema,
|
||||
employee2faVerifySchema,
|
||||
employeeLanguageSchema,
|
||||
employeeLoginSchema,
|
||||
employeeResetPasswordSchema,
|
||||
@@ -30,9 +31,12 @@ router.post('/login', async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(employeeLoginSchema, req)
|
||||
const result = await service.login(body)
|
||||
if ('twoFactorRequired' in result) {
|
||||
return res.status(401).json({ error: 'two_factor_required', message: '2FA code required', method: result.method, statusCode: 401 })
|
||||
}
|
||||
if ('token' in result) {
|
||||
clearSessionCookie(res, 'admin')
|
||||
setSessionCookie(res, 'employee', result.token, 8 * 60 * 60 * 1000)
|
||||
setSessionCookie(res, 'employee', String(result.token), 8 * 60 * 60 * 1000)
|
||||
}
|
||||
ok(res, result)
|
||||
} catch (err) { next(err) }
|
||||
@@ -43,6 +47,38 @@ router.post('/logout', (_req, res) => {
|
||||
ok(res, { success: true })
|
||||
})
|
||||
|
||||
router.post('/2fa/setup', requireCompanyAuth, async (req, res, next) => {
|
||||
try {
|
||||
ok(res, await service.setupTotp(req.employee.id))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/2fa/verify', requireCompanyAuth, async (req, res, next) => {
|
||||
try {
|
||||
const { code } = parseBody(employee2faVerifySchema, req)
|
||||
const result = await service.verifyTotp(req.employee.id, code)
|
||||
if (!result) return res.status(400).json({ error: 'invalid_code', message: 'Invalid 2FA code', statusCode: 400 })
|
||||
if ('token' in result) setSessionCookie(res, 'employee', String(result.token), 8 * 60 * 60 * 1000)
|
||||
ok(res, result)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/2fa/email/setup', requireCompanyAuth, async (req, res, next) => {
|
||||
try {
|
||||
ok(res, await service.setupEmail2fa(req.employee.id))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/2fa/email/verify', requireCompanyAuth, async (req, res, next) => {
|
||||
try {
|
||||
const { code } = parseBody(employee2faVerifySchema, req)
|
||||
const result = await service.verifyEmail2fa(req.employee.id, code)
|
||||
if (!result) return res.status(400).json({ error: 'invalid_code', message: 'Invalid email verification code', statusCode: 400 })
|
||||
if ('token' in result) setSessionCookie(res, 'employee', String(result.token), 8 * 60 * 60 * 1000)
|
||||
ok(res, result)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/forgot-password', async (req, res, next) => {
|
||||
try {
|
||||
const { email } = parseBody(employeeForgotPasswordSchema, req)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from 'zod'
|
||||
export const employeeLoginSchema = z.object({
|
||||
email: z.string().email().max(255).trim().toLowerCase(),
|
||||
password: z.string().max(128),
|
||||
totpCode: z.string().length(6).optional(),
|
||||
})
|
||||
|
||||
export const employeeForgotPasswordSchema = z.object({
|
||||
@@ -17,3 +18,7 @@ export const employeeResetPasswordSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
password: z.string().min(8).max(128),
|
||||
})
|
||||
|
||||
export const employee2faVerifySchema = z.object({
|
||||
code: z.string().length(6),
|
||||
})
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import bcrypt from 'bcryptjs'
|
||||
import crypto from 'crypto'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { authenticator } from 'otplib'
|
||||
import qrcode from 'qrcode'
|
||||
import { signActorToken } from '../../security/tokens'
|
||||
import { AppError } from '../../http/errors'
|
||||
import { hashPublicAccessToken } from '../../security/publicAccessTokens'
|
||||
import { describeEmailProviderConfig, sendTransactionalEmail } from '../../services/notificationService'
|
||||
import { redis } from '../../lib/redis'
|
||||
import { resetPasswordEmail, type Lang } from '../../lib/emailTranslations'
|
||||
import { presentEmployeeSession } from './auth.presenter'
|
||||
import * as repo from './auth.employee.repo'
|
||||
@@ -12,6 +15,10 @@ import { employeeLanguageSchema, employeeLoginSchema } from './auth.employee.sch
|
||||
import type { output } from 'zod'
|
||||
|
||||
const RESET_TOKEN_TTL_MINUTES = 60
|
||||
const EMPLOYEE_EMAIL_OTP_TTL_MINUTES = 10
|
||||
const EMPLOYEE_EMAIL_OTP_TTL_SECONDS = EMPLOYEE_EMAIL_OTP_TTL_MINUTES * 60
|
||||
|
||||
const pendingEmployeeEmailOtps = new Map<string, { code: string; expiresAt: number }>()
|
||||
|
||||
type EmployeeLoginInput = output<typeof employeeLoginSchema>
|
||||
type EmployeeLanguageInput = output<typeof employeeLanguageSchema>
|
||||
@@ -21,12 +28,68 @@ type EmployeePasswordResetPayload = jwt.JwtPayload & {
|
||||
pwdv: string
|
||||
}
|
||||
|
||||
function signEmployeeToken(employeeId: string) {
|
||||
function signEmployeeToken(employeeId: string, last2faAt?: number) {
|
||||
return signActorToken(employeeId, 'employee', {
|
||||
expiresIn: (process.env.JWT_EXPIRY ?? '8h') as jwt.SignOptions['expiresIn'],
|
||||
last2faAt,
|
||||
})
|
||||
}
|
||||
|
||||
function generateEmployeeEmailOtp() {
|
||||
return crypto.randomInt(100000, 1000000).toString()
|
||||
}
|
||||
|
||||
function employeeEmailOtpKey(employeeId: string) {
|
||||
return `employee:email-otp:${employeeId}`
|
||||
}
|
||||
|
||||
async function sendEmployeeEmailOtp(employee: { id: string; email: string; firstName?: string | null }) {
|
||||
const code = generateEmployeeEmailOtp()
|
||||
const codeHash = hashPublicAccessToken(code)
|
||||
pendingEmployeeEmailOtps.set(employee.id, {
|
||||
code: codeHash,
|
||||
expiresAt: Date.now() + EMPLOYEE_EMAIL_OTP_TTL_MINUTES * 60 * 1000,
|
||||
})
|
||||
await redis
|
||||
.set(employeeEmailOtpKey(employee.id), codeHash, 'EX', EMPLOYEE_EMAIL_OTP_TTL_SECONDS)
|
||||
.catch((err) => console.error('[EmployeeLoginEmailOtpRedisSet]', err?.message))
|
||||
|
||||
await sendTransactionalEmail({
|
||||
to: employee.email,
|
||||
subject: 'Your RentalDriveGo login code',
|
||||
html: `<p>Hi ${employee.firstName ?? 'there'},</p><p>Your workspace login code is <strong>${code}</strong>.</p><p>It expires in ${EMPLOYEE_EMAIL_OTP_TTL_MINUTES} minutes.</p>`,
|
||||
text: `Hi ${employee.firstName ?? 'there'},\n\nYour workspace login code is ${code}.\n\nIt expires in ${EMPLOYEE_EMAIL_OTP_TTL_MINUTES} minutes.`,
|
||||
}).catch((err) => console.error('[EmployeeLoginEmailOtp]', err?.message))
|
||||
}
|
||||
|
||||
async function consumeEmployeeEmailOtp(employeeId: string, code: string | undefined) {
|
||||
if (!code) return false
|
||||
const codeHash = hashPublicAccessToken(code.trim())
|
||||
const key = employeeEmailOtpKey(employeeId)
|
||||
const persistedHash = await redis
|
||||
.get(key)
|
||||
.catch((err) => {
|
||||
console.error('[EmployeeLoginEmailOtpRedisGet]', err?.message)
|
||||
return null
|
||||
})
|
||||
if (persistedHash) {
|
||||
if (persistedHash !== codeHash) return false
|
||||
await redis.del(key).catch((err) => console.error('[EmployeeLoginEmailOtpRedisDel]', err?.message))
|
||||
pendingEmployeeEmailOtps.delete(employeeId)
|
||||
return true
|
||||
}
|
||||
|
||||
const pending = pendingEmployeeEmailOtps.get(employeeId)
|
||||
if (!pending) return false
|
||||
if (pending.expiresAt <= Date.now()) {
|
||||
pendingEmployeeEmailOtps.delete(employeeId)
|
||||
return false
|
||||
}
|
||||
if (pending.code !== codeHash) return false
|
||||
pendingEmployeeEmailOtps.delete(employeeId)
|
||||
return true
|
||||
}
|
||||
|
||||
function getEmployeePasswordResetVersion(passwordHash: string | null | undefined) {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
@@ -115,7 +178,76 @@ export async function login(body: EmployeeLoginInput) {
|
||||
throw new AppError('Invalid email or password', 401, 'invalid_credentials')
|
||||
}
|
||||
|
||||
return presentEmployeeSession(employee, signEmployeeToken(employee.id))
|
||||
if (employee.totpEnabled) {
|
||||
if (!body.totpCode) {
|
||||
if (!employee.totpSecret) await sendEmployeeEmailOtp(employee)
|
||||
return { twoFactorRequired: true, method: employee.totpSecret ? 'authenticator' : 'email' } as const
|
||||
}
|
||||
|
||||
const validTotp = employee.totpSecret
|
||||
? authenticator.verify({ token: body.totpCode, secret: employee.totpSecret })
|
||||
: false
|
||||
const validEmailOtp = !validTotp && !employee.totpSecret
|
||||
? await consumeEmployeeEmailOtp(employee.id, body.totpCode)
|
||||
: false
|
||||
|
||||
if (!validTotp && !validEmailOtp) {
|
||||
throw new AppError('Invalid 2FA code', 401, 'invalid_totp')
|
||||
}
|
||||
}
|
||||
|
||||
return presentEmployeeSession(employee, signEmployeeToken(employee.id, employee.totpEnabled ? Date.now() : undefined))
|
||||
}
|
||||
|
||||
export async function setupTotp(employeeId: string) {
|
||||
const employee = await repo.findEmployeeWithCompanyById(employeeId)
|
||||
if (!employee || !employee.isActive) {
|
||||
throw new AppError('Employee account not found or inactive', 401, 'unauthenticated')
|
||||
}
|
||||
const secret = employee.totpSecret && !employee.totpEnabled
|
||||
? employee.totpSecret
|
||||
: authenticator.generateSecret()
|
||||
if (secret !== employee.totpSecret) {
|
||||
await repo.updateEmployeeTotpSecret(employeeId, secret)
|
||||
}
|
||||
const otpauth = authenticator.keyuri(employee.email, 'RentalDriveGo Workspace', secret)
|
||||
const qrCode = await qrcode.toDataURL(otpauth)
|
||||
return { secret, qrCode }
|
||||
}
|
||||
|
||||
export async function verifyTotp(employeeId: string, code: string) {
|
||||
const employee = await repo.findEmployeeWithCompanyById(employeeId)
|
||||
if (!employee || !employee.isActive) {
|
||||
throw new AppError('Employee account not found or inactive', 401, 'unauthenticated')
|
||||
}
|
||||
if (!employee.totpSecret) return false
|
||||
|
||||
const valid = authenticator.verify({ token: code, secret: employee.totpSecret })
|
||||
if (!valid) return false
|
||||
|
||||
const updated = await repo.enableEmployeeTotp(employeeId)
|
||||
return presentEmployeeSession({ ...employee, ...updated, totpEnabled: true }, signEmployeeToken(employeeId, Date.now()))
|
||||
}
|
||||
|
||||
export async function setupEmail2fa(employeeId: string) {
|
||||
const employee = await repo.findEmployeeWithCompanyById(employeeId)
|
||||
if (!employee || !employee.isActive) {
|
||||
throw new AppError('Employee account not found or inactive', 401, 'unauthenticated')
|
||||
}
|
||||
await sendEmployeeEmailOtp(employee)
|
||||
return { message: 'Verification code sent.' }
|
||||
}
|
||||
|
||||
export async function verifyEmail2fa(employeeId: string, code: string) {
|
||||
const employee = await repo.findEmployeeWithCompanyById(employeeId)
|
||||
if (!employee || !employee.isActive) {
|
||||
throw new AppError('Employee account not found or inactive', 401, 'unauthenticated')
|
||||
}
|
||||
const valid = await consumeEmployeeEmailOtp(employeeId, code)
|
||||
if (!valid) return false
|
||||
|
||||
const updated = await repo.enableEmployeeEmail2fa(employeeId)
|
||||
return presentEmployeeSession({ ...employee, ...updated, totpEnabled: true }, signEmployeeToken(employeeId, Date.now()))
|
||||
}
|
||||
|
||||
export async function forgotPassword(email: string) {
|
||||
|
||||
@@ -5,6 +5,7 @@ type EmployeeWithCompany = {
|
||||
lastName: string
|
||||
role: string
|
||||
preferredLanguage?: string | null
|
||||
totpEnabled?: boolean
|
||||
companyId: string
|
||||
company: {
|
||||
name: string
|
||||
@@ -52,6 +53,7 @@ export function presentEmployeeSession(employee: EmployeeWithCompany, token?: st
|
||||
lastName: employee.lastName,
|
||||
role: employee.role,
|
||||
preferredLanguage: employee.preferredLanguage,
|
||||
totpEnabled: Boolean(employee.totpEnabled),
|
||||
companyId: employee.companyId,
|
||||
companyName: employee.company.name,
|
||||
companySlug: employee.company.slug,
|
||||
|
||||
@@ -20,14 +20,17 @@ router.post('/login', async (req, res, next) => {
|
||||
try {
|
||||
const { email, password, totpCode, recoveryCode } = parseBody(unifiedLoginSchema, req)
|
||||
|
||||
if (!totpCode && !recoveryCode) {
|
||||
if (!recoveryCode) {
|
||||
try {
|
||||
const employeeResult = await employeeService.login({ email, password })
|
||||
const employeeResult = await employeeService.login({ email, password, totpCode })
|
||||
if ('twoFactorRequired' in employeeResult) {
|
||||
return res.status(401).json({ error: 'two_factor_required', message: '2FA code required', method: employeeResult.method, statusCode: 401 })
|
||||
}
|
||||
if (!('token' in employeeResult)) {
|
||||
throw new AppError('Invalid email or password', 401, 'invalid_credentials')
|
||||
}
|
||||
clearSessionCookie(res, 'admin')
|
||||
setSessionCookie(res, 'employee', employeeResult.token, 8 * 60 * 60 * 1000)
|
||||
setSessionCookie(res, 'employee', String(employeeResult.token), 8 * 60 * 60 * 1000)
|
||||
return ok(res, employeeResult)
|
||||
} catch (err) {
|
||||
if (!(err instanceof AppError) || err.error !== 'invalid_credentials') throw err
|
||||
@@ -40,7 +43,7 @@ router.post('/login', async (req, res, next) => {
|
||||
}
|
||||
if ('totpRequired' in adminResult) {
|
||||
clearSessionCookie(res, 'employee')
|
||||
return res.status(401).json({ error: 'totp_required', message: '2FA code required', statusCode: 401 })
|
||||
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 })
|
||||
|
||||
@@ -28,6 +28,38 @@ export async function upsertBrand(companyId: string, updateData: any, createData
|
||||
})
|
||||
}
|
||||
|
||||
export async function syncOfficialLanguage(companyId: string, employeeId: string | undefined, locale: 'ar' | 'en' | 'fr') {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const accounts = await tx.billingAccount.findMany({
|
||||
where: { companyId, isPrimary: true },
|
||||
select: { id: true, enabledCommunicationLocales: true },
|
||||
})
|
||||
|
||||
for (const account of accounts) {
|
||||
const enabledCommunicationLocales = Array.from(new Set([...account.enabledCommunicationLocales, locale]))
|
||||
await tx.billingAccount.update({
|
||||
where: { id: account.id },
|
||||
data: {
|
||||
preferredLanguage: locale,
|
||||
defaultCommunicationLocale: locale,
|
||||
enabledCommunicationLocales,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (employeeId) {
|
||||
await tx.employee.updateMany({
|
||||
where: { id: employeeId, companyId },
|
||||
data: { preferredLanguage: locale },
|
||||
})
|
||||
await tx.billingContact.updateMany({
|
||||
where: { companyId, employeeId },
|
||||
data: { locale },
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function findContractSettings(companyId: string) {
|
||||
return prisma.contractSettings.findUnique({ where: { companyId } })
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ router.get('/me/brand', async (req, res, next) => {
|
||||
router.patch('/me/brand', requireSubscriptionWrite, requireRole('OWNER'), requireSettingsFeature('settings.branding_basic'), async (req, res, next) => {
|
||||
try {
|
||||
const body = parseBody(brandSchema, req)
|
||||
const brand = await service.updateBrand(req.companyId, body, req.company.name, req.company.slug)
|
||||
const brand = await service.updateBrand(req.companyId, body, req.company.name, req.company.slug, req.employee.id)
|
||||
ok(res, brand)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
@@ -20,7 +20,7 @@ export const brandSchema = z.object({
|
||||
publicCountry: optionalTextField('country'),
|
||||
websiteUrl: z.string().url().optional(),
|
||||
whatsappNumber: z.string().optional(),
|
||||
defaultLocale: z.string().optional(),
|
||||
defaultLocale: z.enum(['ar', 'en', 'fr']).optional(),
|
||||
defaultCurrency: z.literal('MAD').optional(),
|
||||
isListedOnCarplace: z.boolean().optional(),
|
||||
homePageConfig: z.object({
|
||||
|
||||
@@ -9,6 +9,7 @@ vi.mock('./company.repo', () => ({
|
||||
updateCompany: vi.fn(),
|
||||
findBrand: vi.fn(),
|
||||
upsertBrand: vi.fn(),
|
||||
syncOfficialLanguage: vi.fn(),
|
||||
findBrandBySubdomain: vi.fn(),
|
||||
findBrandByCustomDomain: vi.fn(),
|
||||
clearCustomDomain: vi.fn(),
|
||||
@@ -96,6 +97,14 @@ describe('company.service edge behavior', () => {
|
||||
expect(result).toMatchObject({ tagline: 'Premium rentals' })
|
||||
})
|
||||
|
||||
it('syncs the official language when brand default locale changes', async () => {
|
||||
vi.mocked(repo.upsertBrand).mockResolvedValue({ ...currentBrand, defaultLocale: 'fr' } as any)
|
||||
|
||||
await service.updateBrand('company_1', { defaultLocale: 'fr' }, 'Atlas Cars', 'atlas', 'employee_1')
|
||||
|
||||
expect(repo.syncOfficialLanguage).toHaveBeenCalledWith('company_1', 'employee_1', 'fr')
|
||||
})
|
||||
|
||||
it('normalizes custom domains and marks them pending verification', async () => {
|
||||
vi.mocked(repo.findBrandByCustomDomain).mockResolvedValue(null as any)
|
||||
vi.mocked(repo.upsertBrand).mockResolvedValue({ id: 'brand_1', customDomain: 'cars.example.com' } as any)
|
||||
|
||||
@@ -16,15 +16,26 @@ export async function getBrand(companyId: string) {
|
||||
return presentBrand(await repo.findBrand(companyId))
|
||||
}
|
||||
|
||||
export async function updateBrand(companyId: string, body: any, companyName: string, companySlug: string) {
|
||||
function isSupportedOfficialLanguage(value: unknown): value is 'ar' | 'en' | 'fr' {
|
||||
return value === 'ar' || value === 'en' || value === 'fr'
|
||||
}
|
||||
|
||||
export async function updateBrand(companyId: string, body: any, companyName: string, companySlug: string, employeeId?: string) {
|
||||
await assertSettingsFeature(companyId, 'settings.branding_basic')
|
||||
if (body.primaryColor || body.accentColor) await assertSettingsFeature(companyId, 'settings.branding_custom')
|
||||
if (body.defaultLocale) await assertSettingsFeature(companyId, 'settings.locale_currency')
|
||||
|
||||
return presentBrand(await repo.upsertBrand(
|
||||
const brand = await repo.upsertBrand(
|
||||
companyId,
|
||||
body,
|
||||
{ displayName: body.displayName ?? companyName, subdomain: companySlug, ...body },
|
||||
))
|
||||
)
|
||||
|
||||
if (isSupportedOfficialLanguage(body.defaultLocale)) {
|
||||
await repo.syncOfficialLanguage(companyId, employeeId, body.defaultLocale)
|
||||
}
|
||||
|
||||
return presentBrand(brand)
|
||||
}
|
||||
|
||||
export async function uploadLogo(companyId: string, companyName: string, companySlug: string, file: Buffer) {
|
||||
|
||||
@@ -1394,6 +1394,20 @@ export async function updateCommunicationSettings(companyId: string, employeeId:
|
||||
preferredLanguage: data.defaultCommunicationLocale,
|
||||
},
|
||||
})
|
||||
await tx.brandSettings.upsert({
|
||||
where: { companyId },
|
||||
update: { defaultLocale: data.defaultCommunicationLocale },
|
||||
create: {
|
||||
companyId,
|
||||
displayName: account.company?.name ?? 'Company',
|
||||
subdomain: account.company?.slug ?? companyId,
|
||||
defaultLocale: data.defaultCommunicationLocale,
|
||||
},
|
||||
})
|
||||
await tx.employee.updateMany({
|
||||
where: { id: employeeId, companyId },
|
||||
data: { preferredLanguage: data.defaultCommunicationLocale },
|
||||
})
|
||||
await createBillingEvent(tx, {
|
||||
billingAccountId: account.id,
|
||||
companyId,
|
||||
|
||||
@@ -268,6 +268,18 @@ export const openApiDocument: JsonObject = {
|
||||
responses: { '200': ok, '401': err401 },
|
||||
},
|
||||
},
|
||||
'/auth/employee/2fa/setup': {
|
||||
post: { tags: ['Auth — Employee'], summary: 'Setup authenticator app 2FA', responses: { '200': ok } },
|
||||
},
|
||||
'/auth/employee/2fa/verify': {
|
||||
post: { tags: ['Auth — Employee'], summary: 'Enable authenticator app 2FA', responses: { '200': ok } },
|
||||
},
|
||||
'/auth/employee/2fa/email/setup': {
|
||||
post: { tags: ['Auth — Employee'], summary: 'Send email 2FA setup code', responses: { '200': ok } },
|
||||
},
|
||||
'/auth/employee/2fa/email/verify': {
|
||||
post: { tags: ['Auth — Employee'], summary: 'Enable email 2FA', responses: { '200': ok } },
|
||||
},
|
||||
'/auth/employee/me/language': {
|
||||
patch: {
|
||||
tags: ['Auth — Employee'],
|
||||
@@ -1133,10 +1145,16 @@ export const openApiDocument: JsonObject = {
|
||||
get: { tags: ['Admin'], summary: 'Current admin profile', responses: { '200': ok } },
|
||||
},
|
||||
'/admin/auth/2fa/setup': {
|
||||
post: { tags: ['Admin'], summary: 'Setup 2FA', responses: { '200': ok } },
|
||||
post: { tags: ['Admin'], summary: 'Setup authenticator app 2FA', responses: { '200': ok } },
|
||||
},
|
||||
'/admin/auth/2fa/email/setup': {
|
||||
post: { tags: ['Admin'], summary: 'Send email 2FA setup code', responses: { '200': ok } },
|
||||
},
|
||||
'/admin/auth/2fa/email/verify': {
|
||||
post: { tags: ['Admin'], summary: 'Enable email 2FA', responses: { '200': ok } },
|
||||
},
|
||||
'/admin/auth/2fa/verify': {
|
||||
post: { tags: ['Admin'], summary: 'Verify 2FA code', responses: { '200': ok } },
|
||||
post: { tags: ['Admin'], summary: 'Enable authenticator app 2FA', responses: { '200': ok } },
|
||||
},
|
||||
'/admin/companies': {
|
||||
get: { tags: ['Admin'], summary: 'List all companies', responses: { '200': ok } },
|
||||
|
||||
@@ -92,7 +92,7 @@ describe('auth middleware API boundaries', () => {
|
||||
|
||||
it('falls through to admin 2FA 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 } as never)
|
||||
vi.mocked(adminService.login).mockResolvedValue({ totpRequired: true, method: 'email' } as never)
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/v1/auth/login')
|
||||
@@ -209,7 +209,7 @@ describe('auth middleware API boundaries', () => {
|
||||
})
|
||||
|
||||
it('clears any employee session when admin credentials require 2FA', async () => {
|
||||
vi.mocked(adminService.login).mockResolvedValue({ totpRequired: true } as never)
|
||||
vi.mocked(adminService.login).mockResolvedValue({ totpRequired: true, method: 'email' } as never)
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/v1/admin/auth/login')
|
||||
|
||||
Reference in New Issue
Block a user