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

This commit is contained in:
root
2026-08-23 00:45:39 -04:00
parent c8d7a3954a
commit e3f80af47d
29 changed files with 890 additions and 255 deletions
@@ -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()
+4
View File
@@ -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) => {
+18 -2
View File
@@ -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')
+29 -4
View File
@@ -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,
+20 -2
View File
@@ -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')