From e3f80af47d37e07069fd2bc387cbe06922ecbec2 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 23 Aug 2026 00:45:39 -0400 Subject: [PATCH] fix 2fa and move communication language to setting --- apps/admin/src/app/dashboard/layout.tsx | 158 ++++++++----- .../src/middleware/requireAdminAuth.test.ts | 11 +- apps/api/src/middleware/requireAdminAuth.ts | 15 -- apps/api/src/modules/admin/admin.repo.ts | 4 + apps/api/src/modules/admin/admin.routes.ts | 20 +- apps/api/src/modules/admin/admin.schemas.ts | 4 + .../src/modules/admin/admin.service.test.ts | 6 +- apps/api/src/modules/admin/admin.service.ts | 33 ++- .../src/modules/auth/auth.employee.repo.ts | 12 + .../src/modules/auth/auth.employee.routes.ts | 38 +++- .../src/modules/auth/auth.employee.schemas.ts | 5 + .../src/modules/auth/auth.employee.service.ts | 136 ++++++++++- apps/api/src/modules/auth/auth.presenter.ts | 2 + .../src/modules/auth/auth.unified.routes.ts | 11 +- .../api/src/modules/companies/company.repo.ts | 32 +++ .../src/modules/companies/company.routes.ts | 2 +- .../src/modules/companies/company.schemas.ts | 2 +- .../companies/company.service.edge.test.ts | 9 + .../src/modules/companies/company.service.ts | 17 +- .../subscription.manual.service.ts | 14 ++ apps/api/src/swagger/openapi.ts | 22 +- .../src/tests/api/auth-middleware.api.test.ts | 4 +- .../src/app/(dashboard)/settings/page.tsx | 127 ++++++++++- .../src/app/(dashboard)/subscription/page.tsx | 148 +----------- .../dashboard/src/components/I18nProvider.tsx | 76 +++++++ .../src/components/layout/TopBar.tsx | 214 +++++++++++++++++- .../src/components/auth/SignInForm.tsx | 18 +- .../migration.sql | 3 + packages/database/prisma/schema.prisma | 2 + 29 files changed, 890 insertions(+), 255 deletions(-) create mode 100644 packages/database/prisma/migrations/20260823001000_add_employee_2fa/migration.sql diff --git a/apps/admin/src/app/dashboard/layout.tsx b/apps/admin/src/app/dashboard/layout.tsx index 3c4bbb3..f025c2a 100644 --- a/apps/admin/src/app/dashboard/layout.tsx +++ b/apps/admin/src/app/dashboard/layout.tsx @@ -41,6 +41,7 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea const [ready, setReady] = useState(false) const [admin, setAdmin] = useState(null) const [unreadNotifications, setUnreadNotifications] = useState(0) + const [securitySetupOpen, setSecuritySetupOpen] = useState(false) const redirectingToLogin = useRef(false) function redirectToLogin() { @@ -68,14 +69,12 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea } setAdmin(resolvedAdmin) setReady(true) - if (resolvedAdmin.totpEnabled) { - fetch(`${ADMIN_API_BASE}/admin/notifications/me`, { credentials: 'include', cache: 'no-store' }) - .then((inboxResponse) => inboxResponse.ok ? inboxResponse.json() : null) - .then((inbox) => { - if (!cancelled) setUnreadNotifications(Number(inbox?.data?.unread ?? 0)) - }) - .catch(() => {}) - } + fetch(`${ADMIN_API_BASE}/admin/notifications/me`, { credentials: 'include', cache: 'no-store' }) + .then((inboxResponse) => inboxResponse.ok ? inboxResponse.json() : null) + .then((inbox) => { + if (!cancelled) setUnreadNotifications(Number(inbox?.data?.unread ?? 0)) + }) + .catch(() => {}) } else { redirectToLogin() } @@ -104,16 +103,6 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea ) } - if (admin && !admin.totpEnabled) { - return ( - - ) - } - return (
@@ -145,6 +134,17 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea })}
+ {admin && !admin.totpEnabled ? ( + + ) : null}
) } -function Admin2FAEnrollmentGate({ +function Admin2FASetupDialog({ admin, onEnrolled, - onLogout, + onClose, }: { admin: AdminSessionUser onEnrolled: (admin: AdminSessionUser) => void - onLogout: () => void + onClose: () => void }) { - const { dict } = useAdminI18n() + type SetupMethod = 'email' | 'authenticator' + const [method, setMethod] = useState(null) const [secret, setSecret] = useState('') const [qrCode, setQrCode] = useState('') const [code, setCode] = useState('') const [error, setError] = useState(null) - const [loadingSetup, setLoadingSetup] = useState(true) + const [loadingSetup, setLoadingSetup] = useState(false) const [verifying, setVerifying] = useState(false) const [verifiedAdmin, setVerifiedAdmin] = useState(null) const [recoveryCodes, setRecoveryCodes] = useState([]) - const setupStarted = useRef(false) - useEffect(() => { - if (setupStarted.current) return - setupStarted.current = true - - fetch(`${ADMIN_API_BASE}/admin/auth/2fa/setup`, { + async function startSetup(nextMethod: SetupMethod) { + setMethod(nextMethod) + setCode('') + setError(null) + setLoadingSetup(true) + const endpoint = nextMethod === 'email' + ? `${ADMIN_API_BASE}/admin/auth/2fa/email/setup` + : `${ADMIN_API_BASE}/admin/auth/2fa/setup` + try { + const response = await fetch(endpoint, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), - }) - .then(async (response) => { - const json = await response.json().catch(() => null) - if (!response.ok) throw new Error(json?.message ?? 'Failed to start 2FA setup.') - const data = json?.data ?? json - setSecret(data?.secret ?? '') - setQrCode(data?.qrCode ?? '') }) - .catch((err: any) => setError(err?.message ?? 'Failed to start 2FA setup.')) - .finally(() => setLoadingSetup(false)) - }, []) + const json = await response.json().catch(() => null) + if (!response.ok) throw new Error(json?.message ?? 'Failed to start 2FA setup.') + const data = json?.data ?? json + setSecret(data?.secret ?? '') + setQrCode(data?.qrCode ?? '') + } catch (err: any) { + setError(err?.message ?? 'Failed to start 2FA setup.') + } finally { + setLoadingSetup(false) + } + } async function verifyCode(event: FormEvent) { event.preventDefault() @@ -217,8 +233,11 @@ function Admin2FAEnrollmentGate({ setError(null) setVerifying(true) + const endpoint = method === 'email' + ? `${ADMIN_API_BASE}/admin/auth/2fa/email/verify` + : `${ADMIN_API_BASE}/admin/auth/2fa/verify` try { - const response = await fetch(`${ADMIN_API_BASE}/admin/auth/2fa/verify`, { + const response = await fetch(endpoint, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, @@ -237,23 +256,20 @@ function Admin2FAEnrollmentGate({ } return ( -
+
-

{dict.admin}

-

Set up admin 2FA

-

- Admin 2FA enrollment is required before using privileged admin routes. -

+

Security

+

Enable 2FA

{admin.email}

@@ -284,10 +300,33 @@ function Admin2FAEnrollmentGate({
) : (
- {loadingSetup ? ( + {!method ? ( +
+ + +
+ ) : loadingSetup ? (
- Preparing authenticator setup... + Preparing setup... +
+ ) : method === 'email' ? ( +
+ Enter the 6-digit code sent to {admin.email}.
) : (
@@ -317,7 +356,7 @@ function Admin2FAEnrollmentGate({ autoComplete="one-time-code" className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-lg font-semibold tracking-[0.2em] text-stone-900 outline-none transition focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-blue-950/80 dark:text-stone-100" placeholder="000000" - disabled={loadingSetup || verifying} + disabled={!method || loadingSetup || verifying} /> @@ -329,11 +368,26 @@ function Admin2FAEnrollmentGate({ + {method ? ( + + ) : null} )} diff --git a/apps/api/src/middleware/requireAdminAuth.test.ts b/apps/api/src/middleware/requireAdminAuth.test.ts index 3813b7e..b3ae167 100644 --- a/apps/api/src/middleware/requireAdminAuth.test.ts +++ b/apps/api/src/middleware/requireAdminAuth.test.ts @@ -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() }) }) diff --git a/apps/api/src/middleware/requireAdminAuth.ts b/apps/api/src/middleware/requireAdminAuth.ts index 7c35c43..176474f 100644 --- a/apps/api/src/middleware/requireAdminAuth.ts +++ b/apps/api/src/middleware/requireAdminAuth.ts @@ -13,17 +13,6 @@ const ADMIN_ROLE_ALLOWLIST: Record = { 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() diff --git a/apps/api/src/modules/admin/admin.repo.ts b/apps/api/src/modules/admin/admin.repo.ts index e6426db..6747da5 100644 --- a/apps/api/src/modules/admin/admin.repo.ts +++ b/apps/api/src/modules/admin/admin.repo.ts @@ -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) => { diff --git a/apps/api/src/modules/admin/admin.routes.ts b/apps/api/src/modules/admin/admin.routes.ts index 828bf75..d806452 100644 --- a/apps/api/src/modules/admin/admin.routes.ts +++ b/apps/api/src/modules/admin/admin.routes.ts @@ -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) diff --git a/apps/api/src/modules/admin/admin.schemas.ts b/apps/api/src/modules/admin/admin.schemas.ts index 96ec096..e3161ca 100644 --- a/apps/api/src/modules/admin/admin.schemas.ts +++ b/apps/api/src/modules/admin/admin.schemas.ts @@ -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(), diff --git a/apps/api/src/modules/admin/admin.service.test.ts b/apps/api/src/modules/admin/admin.service.test.ts index d9dcbc1..0b32529 100644 --- a/apps/api/src/modules/admin/admin.service.test.ts +++ b/apps/api/src/modules/admin/admin.service.test.ts @@ -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') diff --git a/apps/api/src/modules/admin/admin.service.ts b/apps/api/src/modules/admin/admin.service.ts index ebef718..3c4c83b 100644 --- a/apps/api/src/modules/admin/admin.service.ts +++ b/apps/api/src/modules/admin/admin.service.ts @@ -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 diff --git a/apps/api/src/modules/auth/auth.employee.repo.ts b/apps/api/src/modules/auth/auth.employee.repo.ts index ef6fc4c..b6af0a0 100644 --- a/apps/api/src/modules/auth/auth.employee.repo.ts +++ b/apps/api/src/modules/auth/auth.employee.repo.ts @@ -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({ diff --git a/apps/api/src/modules/auth/auth.employee.routes.ts b/apps/api/src/modules/auth/auth.employee.routes.ts index 0bf5e78..6f0b514 100644 --- a/apps/api/src/modules/auth/auth.employee.routes.ts +++ b/apps/api/src/modules/auth/auth.employee.routes.ts @@ -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) diff --git a/apps/api/src/modules/auth/auth.employee.schemas.ts b/apps/api/src/modules/auth/auth.employee.schemas.ts index 602e7de..70144c5 100644 --- a/apps/api/src/modules/auth/auth.employee.schemas.ts +++ b/apps/api/src/modules/auth/auth.employee.schemas.ts @@ -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), +}) diff --git a/apps/api/src/modules/auth/auth.employee.service.ts b/apps/api/src/modules/auth/auth.employee.service.ts index 322667e..6a8b695 100644 --- a/apps/api/src/modules/auth/auth.employee.service.ts +++ b/apps/api/src/modules/auth/auth.employee.service.ts @@ -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() type EmployeeLoginInput = output type EmployeeLanguageInput = output @@ -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: `

Hi ${employee.firstName ?? 'there'},

Your workspace login code is ${code}.

It expires in ${EMPLOYEE_EMAIL_OTP_TTL_MINUTES} minutes.

`, + 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) { diff --git a/apps/api/src/modules/auth/auth.presenter.ts b/apps/api/src/modules/auth/auth.presenter.ts index b630ccc..c9c2f10 100644 --- a/apps/api/src/modules/auth/auth.presenter.ts +++ b/apps/api/src/modules/auth/auth.presenter.ts @@ -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, diff --git a/apps/api/src/modules/auth/auth.unified.routes.ts b/apps/api/src/modules/auth/auth.unified.routes.ts index 9c810d0..09d5817 100644 --- a/apps/api/src/modules/auth/auth.unified.routes.ts +++ b/apps/api/src/modules/auth/auth.unified.routes.ts @@ -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 }) diff --git a/apps/api/src/modules/companies/company.repo.ts b/apps/api/src/modules/companies/company.repo.ts index 8ef636c..79f8ad7 100644 --- a/apps/api/src/modules/companies/company.repo.ts +++ b/apps/api/src/modules/companies/company.repo.ts @@ -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 } }) } diff --git a/apps/api/src/modules/companies/company.routes.ts b/apps/api/src/modules/companies/company.routes.ts index 91f5e61..a43c090 100644 --- a/apps/api/src/modules/companies/company.routes.ts +++ b/apps/api/src/modules/companies/company.routes.ts @@ -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) } }) diff --git a/apps/api/src/modules/companies/company.schemas.ts b/apps/api/src/modules/companies/company.schemas.ts index e76a861..dd77578 100644 --- a/apps/api/src/modules/companies/company.schemas.ts +++ b/apps/api/src/modules/companies/company.schemas.ts @@ -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({ diff --git a/apps/api/src/modules/companies/company.service.edge.test.ts b/apps/api/src/modules/companies/company.service.edge.test.ts index 4087c88..3134e8c 100644 --- a/apps/api/src/modules/companies/company.service.edge.test.ts +++ b/apps/api/src/modules/companies/company.service.edge.test.ts @@ -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) diff --git a/apps/api/src/modules/companies/company.service.ts b/apps/api/src/modules/companies/company.service.ts index 9ea641c..c2a865f 100644 --- a/apps/api/src/modules/companies/company.service.ts +++ b/apps/api/src/modules/companies/company.service.ts @@ -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) { diff --git a/apps/api/src/modules/subscriptions/subscription.manual.service.ts b/apps/api/src/modules/subscriptions/subscription.manual.service.ts index ca0bb51..07ef4c0 100644 --- a/apps/api/src/modules/subscriptions/subscription.manual.service.ts +++ b/apps/api/src/modules/subscriptions/subscription.manual.service.ts @@ -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, diff --git a/apps/api/src/swagger/openapi.ts b/apps/api/src/swagger/openapi.ts index e172f46..f867388 100644 --- a/apps/api/src/swagger/openapi.ts +++ b/apps/api/src/swagger/openapi.ts @@ -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 } }, diff --git a/apps/api/src/tests/api/auth-middleware.api.test.ts b/apps/api/src/tests/api/auth-middleware.api.test.ts index 90a095c..1ae8b28 100644 --- a/apps/api/src/tests/api/auth-middleware.api.test.ts +++ b/apps/api/src/tests/api/auth-middleware.api.test.ts @@ -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') diff --git a/apps/dashboard/src/app/(dashboard)/settings/page.tsx b/apps/dashboard/src/app/(dashboard)/settings/page.tsx index 046d659..316e0c1 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/page.tsx @@ -18,6 +18,7 @@ import { useDashboardI18n } from '@/components/I18nProvider' import { getMoroccanCityOptions } from '@/lib/moroccanCities' type SectionKey = 'company' | 'carplace' | 'payments' | 'rental-policies' | 'insurance' | 'pricing' | 'accounting' +type CommunicationLocale = 'ar' | 'en' | 'fr' type FeatureKey = | 'settings.company_profile' | 'settings.public_contact' @@ -82,6 +83,24 @@ interface BrandSettings { isListedOnCarplace: boolean } +interface CommunicationSettings { + timezone: string + reminderLocalTime: string + enabledCommunicationLocales: CommunicationLocale[] + defaultCommunicationLocale: CommunicationLocale + contacts: Array<{ + id?: string + employeeId?: string | null + email: string + locale?: CommunicationLocale | null + effectiveLocale?: CommunicationLocale + isPrimary: boolean + receivePaymentNotices: boolean + isActive: boolean + verified?: boolean + }> +} + interface ContractSettings { fuelPolicy: string fuelPolicyType: string @@ -192,6 +211,9 @@ function SettingsPageContent() { subscriptionStatus: 'Subscription status', manageSubscription: 'Manage subscription', requiredPlan: 'Requires', lockedTitle: 'This settings area is not included in your current plan.', lockedBody: 'Your saved configuration is preserved and becomes editable again after upgrade.', readOnly: 'This section is read-only while your subscription access is restricted.', companyHint: 'Default language affects Carplace text and generated contracts when available.', + officialLanguageHint: 'This is your official workspace language. Billing notices, notifications, Carplace text, and generated documents use it when available.', + communicationTitle: 'Billing communication', communicationHelp: 'Payment notices use the official language by default. You can still restrict allowed notice languages and contact overrides.', + enabledLanguages: 'Enabled languages', timezone: 'Billing timezone', contactLanguage: 'Contact language', inheritDefault: 'Use official language', publicProfile: 'Public profile', carplaceBasics: 'Carplace basics', premiumBranding: 'Available on GROWTH', paymentsBody: 'Rental payments are recorded as bank transfer or check. Online checkout is not available.', policies: 'Fuel, driver, and damage policies', additionalDriver: 'Additional-driver automation', insuranceNew: 'New insurance policy', @@ -213,6 +235,9 @@ function SettingsPageContent() { subscriptionStatus: 'Statut abonnement', manageSubscription: 'Gérer l’abonnement', requiredPlan: 'Requiert', lockedTitle: 'Cette section n’est pas incluse dans votre plan actuel.', lockedBody: 'La configuration enregistrée est conservée et redevient modifiable après mise à niveau.', readOnly: 'Cette section est en lecture seule pendant la restriction d’accès.', companyHint: 'La langue par défaut affecte la vitrine et les contrats générés si disponibles.', + officialLanguageHint: 'C’est la langue officielle de votre espace. Les avis de paiement, notifications, textes Carplace et documents générés l’utilisent si disponible.', + communicationTitle: 'Communication de facturation', communicationHelp: 'Les avis de paiement utilisent la langue officielle par défaut. Vous pouvez limiter les langues autorisées et les exceptions par contact.', + enabledLanguages: 'Langues activées', timezone: 'Fuseau de facturation', contactLanguage: 'Langue du contact', inheritDefault: 'Utiliser la langue officielle', publicProfile: 'Profil public', carplaceBasics: 'Paramètres Carplace', premiumBranding: 'Disponible avec GROWTH', paymentsBody: 'Les paiements de location sont enregistrés par virement bancaire ou chèque. Le paiement en ligne n’est pas disponible.', policies: 'Carburant, conducteur et dommages', additionalDriver: 'Automatisation conducteur additionnel', insuranceNew: 'Nouvelle police', @@ -234,6 +259,9 @@ function SettingsPageContent() { subscriptionStatus: 'حالة الاشتراك', manageSubscription: 'إدارة الاشتراك', requiredPlan: 'يتطلب', lockedTitle: 'هذا القسم غير مشمول في خطتك الحالية.', lockedBody: 'يتم الاحتفاظ بالإعدادات المحفوظة وتعود قابلة للتعديل بعد الترقية.', readOnly: 'هذا القسم للقراءة فقط أثناء تقييد الوصول.', companyHint: 'تؤثر اللغة الافتراضية على الواجهة والعقود عند توفرها.', + officialLanguageHint: 'هذه هي اللغة الرسمية لمساحة العمل. تستخدمها إشعارات الدفع والتنبيهات ونصوص Carplace والمستندات عند توفرها.', + communicationTitle: 'تواصل الفوترة', communicationHelp: 'تستخدم إشعارات الدفع اللغة الرسمية افتراضياً. يمكن تقييد اللغات المسموحة أو تخصيص لغة كل جهة اتصال.', + enabledLanguages: 'اللغات المفعلة', timezone: 'المنطقة الزمنية للفوترة', contactLanguage: 'لغة جهة الاتصال', inheritDefault: 'استخدام اللغة الرسمية', publicProfile: 'الملف العام', carplaceBasics: 'أساسيات الواجهة', premiumBranding: 'متاح في GROWTH', paymentsBody: 'يتم تسجيل مدفوعات الكراء بالتحويل البنكي أو الشيك. الدفع الإلكتروني غير متاح.', policies: 'سياسات الوقود والسائق والأضرار', additionalDriver: 'أتمتة السائق الإضافي', insuranceNew: 'سياسة تأمين جديدة', @@ -253,6 +281,7 @@ function SettingsPageContent() { const [menu, setMenu] = useState([]) const [entitlements, setEntitlements] = useState(null) const [brand, setBrand] = useState(null) + const [communicationSettings, setCommunicationSettings] = useState(null) const [contractSettings, setContractSettings] = useState(null) const [insurancePolicies, setInsurancePolicies] = useState([]) const [pricingRules, setPricingRules] = useState([]) @@ -301,6 +330,9 @@ function SettingsPageContent() { if ((activeSection === 'company' || activeSection === 'carplace' || activeSection === 'payments') && !brand) { setBrand(await apiFetch('/companies/me/brand')) } + if (activeSection === 'company' && !communicationSettings) { + setCommunicationSettings(await apiFetch('/subscriptions/communication-settings')) + } if (activeSection === 'rental-policies' && !contractSettings) { setContractSettings(withRentalPolicyDefaults(await apiFetch('/companies/me/contract-settings'), language)) } @@ -320,7 +352,7 @@ function SettingsPageContent() { } } loadSection() - }, [activeSection, accountingSettings, brand, contractSettings, insurancePolicies.length, pricingRules.length, entitlements, language]) + }, [activeSection, accountingSettings, brand, communicationSettings, contractSettings, insurancePolicies.length, pricingRules.length, entitlements, language]) useEffect(() => { if (activeSection !== 'rental-policies') return @@ -331,6 +363,9 @@ function SettingsPageContent() { if (!brand) return setSaving(true); setError(null); setMessage(null) try { + const officialLanguage = (brand.defaultLocale === 'ar' || brand.defaultLocale === 'fr' || brand.defaultLocale === 'en') + ? brand.defaultLocale + : language const updated = await apiFetch('/companies/me/brand', { method: 'PATCH', body: JSON.stringify({ @@ -340,11 +375,25 @@ function SettingsPageContent() { publicEmail: brand.publicEmail || undefined, publicPhone: brand.publicPhone || undefined, publicAddress: brand.publicAddress || undefined, publicCity: brand.publicCity || undefined, publicCountry: brand.publicCountry || undefined, - websiteUrl: brand.websiteUrl || undefined, defaultLocale: brand.defaultLocale || undefined, + websiteUrl: brand.websiteUrl || undefined, defaultLocale: officialLanguage, whatsappNumber: brand.whatsappNumber || undefined, defaultCurrency: brand.defaultCurrency || undefined, isListedOnCarplace: brand.isListedOnCarplace, }), }) + if (communicationSettings) { + const enabledCommunicationLocales = Array.from(new Set([...communicationSettings.enabledCommunicationLocales, officialLanguage as CommunicationLocale])) + const savedCommunicationSettings = await apiFetch('/subscriptions/communication-settings', { + method: 'PUT', + body: JSON.stringify({ + timezone: communicationSettings.timezone, + reminderLocalTime: communicationSettings.reminderLocalTime, + enabledCommunicationLocales, + defaultCommunicationLocale: officialLanguage, + contacts: communicationSettings.contacts.map(({ effectiveLocale: _effectiveLocale, verified: _verified, ...contact }) => contact), + }), + }) + setCommunicationSettings(savedCommunicationSettings) + } setBrand(updated); setMessage(copy.saved) } catch (err: any) { setError(err.message ?? 'Failed to save brand settings') @@ -513,10 +562,80 @@ function SettingsPageContent() {
setBrand({ ...brand, publicCountry: v })} /> setBrand({ ...brand, websiteUrl: v })} /> - { + const officialLanguage = v as CommunicationLocale + setBrand({ ...brand, defaultLocale: officialLanguage }) + setCommunicationSettings((current) => current ? { + ...current, + enabledCommunicationLocales: Array.from(new Set([...current.enabledCommunicationLocales, officialLanguage])), + defaultCommunicationLocale: officialLanguage, + } : current) + }} /> setCommunicationSettings((current) => { + if (!current) return current + const next = checked + ? current.enabledCommunicationLocales.filter((item) => item !== locale) + : [...current.enabledCommunicationLocales, locale] + if (next.length === 0 || !next.includes(officialLanguage)) return current + return { + ...current, + enabledCommunicationLocales: next, + contacts: current.contacts.map((contact) => contact.locale && !next.includes(contact.locale) ? { ...contact, locale: null } : contact), + } + })} + /> + {locale} + + ) + })} +
+
+ setCommunicationSettings({ ...communicationSettings, timezone: v })} /> +
+
+ {communicationSettings.contacts.map((contact, index) => ( +
+
+

{contact.email}

+

{contact.isPrimary ? 'Primary / ' : ''}{contact.verified ? 'Verified' : 'Verification pending'} / {contact.employeeId ? 'In-app + email' : 'Email'}

+
+ +
+ ))} +
+ + ) : null} )} diff --git a/apps/dashboard/src/app/(dashboard)/subscription/page.tsx b/apps/dashboard/src/app/(dashboard)/subscription/page.tsx index df59a29..377cd3f 100644 --- a/apps/dashboard/src/app/(dashboard)/subscription/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/subscription/page.tsx @@ -93,25 +93,6 @@ interface UpgradeAcceptResult { instructions: Record | null } -type CommunicationLocale = 'ar' | 'en' | 'fr' -interface CommunicationSettings { - timezone: string - reminderLocalTime: string - enabledCommunicationLocales: CommunicationLocale[] - defaultCommunicationLocale: CommunicationLocale - contacts: Array<{ - id?: string - employeeId?: string | null - email: string - locale?: CommunicationLocale | null - effectiveLocale?: CommunicationLocale - isPrimary: boolean - receivePaymentNotices: boolean - isActive: boolean - verified?: boolean - }> -} - interface PlanFeature { id: string plan: Plan @@ -256,8 +237,6 @@ export default function SubscriptionPage() { const [submittingEvidence, setSubmittingEvidence] = useState(false) const [checkoutIdempotencyKey, setCheckoutIdempotencyKey] = useState(null) const [submissionIdempotencyKey, setSubmissionIdempotencyKey] = useState(null) - const [communicationSettings, setCommunicationSettings] = useState(null) - const [savingCommunicationSettings, setSavingCommunicationSettings] = useState(false) const copy = { en: { title: 'Subscription', @@ -307,15 +286,6 @@ export default function SubscriptionPage() { manualDetailsTitle: 'Payment details', manualDetailsHelp: 'Enter the payment number and attach the supporting file before submitting it for finance review.', evidenceSubmitted: 'Evidence submitted and locked for finance review.', - communicationTitle: 'Billing communication settings', - communicationHelp: 'Choose the languages your company permits for future payment notices. Each contact receives one notice in their effective language.', - enabledLanguages: 'Enabled languages', - defaultLanguage: 'Default language', - timezone: 'Billing timezone', - contactLanguage: 'Contact language', - inheritDefault: 'Inherit company default', - saveSettings: 'Save communication settings', - settingsSaved: 'Communication settings saved.', monthly: 'Monthly', annual: 'Annual (save 20%)', active: 'Active', @@ -400,15 +370,6 @@ export default function SubscriptionPage() { manualDetailsTitle: 'Détails du paiement', manualDetailsHelp: 'Saisissez le numéro de paiement et joignez le justificatif avant de l’envoyer à la finance.', evidenceSubmitted: 'Justificatifs soumis et verrouillés pour vérification.', - communicationTitle: 'Paramètres de communication de facturation', - communicationHelp: 'Choisissez les langues autorisées pour les prochains avis de paiement. Chaque contact reçoit un seul avis dans sa langue effective.', - enabledLanguages: 'Langues activées', - defaultLanguage: 'Langue par défaut', - timezone: 'Fuseau horaire de facturation', - contactLanguage: 'Langue du contact', - inheritDefault: 'Hériter de la langue par défaut', - saveSettings: 'Enregistrer les paramètres', - settingsSaved: 'Paramètres de communication enregistrés.', monthly: 'Mensuel', annual: 'Annuel (économie 20%)', active: 'Actif', @@ -493,15 +454,6 @@ export default function SubscriptionPage() { manualDetailsTitle: 'تفاصيل الدفع', manualDetailsHelp: 'أدخل رقم الدفع وأرفق المستند الداعم قبل إرساله إلى فريق المالية.', evidenceSubmitted: 'تم إرسال المستندات وقفلها لمراجعة فريق المالية.', - communicationTitle: 'إعدادات اتصالات الفوترة', - communicationHelp: 'اختر اللغات التي تسمح بها الشركة لإشعارات الدفع المستقبلية. يتلقى كل مسؤول إشعاراً واحداً بلغته الفعلية.', - enabledLanguages: 'اللغات المفعّلة', - defaultLanguage: 'اللغة الافتراضية', - timezone: 'المنطقة الزمنية للفوترة', - contactLanguage: 'لغة جهة الاتصال', - inheritDefault: 'استخدام لغة الشركة الافتراضية', - saveSettings: 'حفظ إعدادات الاتصال', - settingsSaved: 'تم حفظ إعدادات الاتصال.', monthly: 'شهري', annual: 'سنوي (توفير 20%)', active: 'نشط', @@ -586,12 +538,10 @@ export default function SubscriptionPage() { apiFetch('/subscriptions/me'), apiFetch('/subscriptions/invoices'), apiFetch<{ methods: PaymentOption[] }>('/subscriptions/payment-options'), - apiFetch('/subscriptions/communication-settings'), fetchPlanData(), ]) - .then(([sub, inv, options, settings]) => { + .then(([sub, inv, options]) => { setPaymentOptions(options.methods ?? []) - setCommunicationSettings(settings) const firstEnabled = options.methods?.find((option) => option.enabled) if (firstEnabled) setSelectedMethod(firstEnabled.method) if (sub) { @@ -786,29 +736,6 @@ export default function SubscriptionPage() { } } - async function saveCommunicationSettings() { - if (!communicationSettings) return - setSavingCommunicationSettings(true) - setError(null) - try { - const saved = await apiFetch('/subscriptions/communication-settings', { - method: 'PUT', - body: JSON.stringify({ - timezone: communicationSettings.timezone, - reminderLocalTime: communicationSettings.reminderLocalTime, - enabledCommunicationLocales: communicationSettings.enabledCommunicationLocales, - defaultCommunicationLocale: communicationSettings.defaultCommunicationLocale, - contacts: communicationSettings.contacts.map(({ effectiveLocale: _effectiveLocale, verified: _verified, ...contact }) => contact), - }), - }) - setCommunicationSettings(saved) - } catch (err: any) { - setError(err.message) - } finally { - setSavingCommunicationSettings(false) - } - } - async function handleCancel() { setCancelling(true) setError(null) @@ -1194,79 +1121,6 @@ export default function SubscriptionPage() { ) : null} - {communicationSettings ? ( -
-

{copy.communicationTitle}

-

{copy.communicationHelp}

-
-
-

{copy.enabledLanguages}

-
- {(['ar', 'en', 'fr'] as CommunicationLocale[]).map((locale) => { - const checked = communicationSettings.enabledCommunicationLocales.includes(locale) - return ( - - ) - })} -
-
- - -
-
- {communicationSettings.contacts.map((contact, index) => ( -
-
-

{contact.email}

-

{contact.isPrimary ? 'Primary · ' : ''}{contact.verified ? 'Verified' : 'Verification pending'} · {contact.employeeId ? 'In-app + email' : 'Email'}

-
- -
- ))} -
- -
- ) : null} - {/* Invoice history */}
diff --git a/apps/dashboard/src/components/I18nProvider.tsx b/apps/dashboard/src/components/I18nProvider.tsx index 0b56aff..908c22e 100644 --- a/apps/dashboard/src/components/I18nProvider.tsx +++ b/apps/dashboard/src/components/I18nProvider.tsx @@ -311,6 +311,25 @@ type DashboardDictionary = { theme: string light: string dark: string + security2fa: { + security: string + enable2fa: string + close: string + emailCode: string + authenticatorApp: string + totp: string + preparingSetup: string + emailCodeSent: (email: string) => string + scanAuthenticator: string + noQrCode: string + sixDigitCode: string + codePlaceholder: string + enterSixDigitCode: string + failedStart: string + invalidCode: string + verifying: string + chooseAnotherMethod: string + } fleet: FleetDict vehicleDetail: VehicleDetailDict calendar: CalendarDict @@ -365,6 +384,25 @@ const dictionaries: Record = { theme: 'Theme', light: 'Light', dark: 'Dark', + security2fa: { + security: 'Security', + enable2fa: 'Enable 2FA', + close: 'Close', + emailCode: 'Email code', + authenticatorApp: 'Authenticator app', + totp: 'TOTP', + preparingSetup: 'Preparing setup...', + emailCodeSent: (email) => `Enter the 6-digit code sent to ${email}.`, + scanAuthenticator: 'Scan the QR code or enter the setup key manually.', + noQrCode: 'No QR code', + sixDigitCode: '6-digit code', + codePlaceholder: '000000', + enterSixDigitCode: 'Enter the 6-digit code.', + failedStart: 'Failed to start 2FA setup.', + invalidCode: 'Invalid 2FA code.', + verifying: 'Verifying...', + chooseAnotherMethod: 'Choose another method', + }, fleet: { statusLabels: { AVAILABLE: 'Available', RESERVED: 'Reserved', READY: 'Ready for pickup', RENTED: 'On rent', RETURNED: 'Returned', NEEDS_CLEANING: 'Needs cleaning', MAINTENANCE: 'Maintenance', DAMAGE_REVIEW: 'Damage review', OUT_OF_SERVICE: 'Out of service' }, logMaintenance: 'Log Maintenance', @@ -735,6 +773,25 @@ const dictionaries: Record = { theme: 'Mode', light: 'Clair', dark: 'Sombre', + security2fa: { + security: 'Sécurité', + enable2fa: 'Activer la 2FA', + close: 'Fermer', + emailCode: 'Code par e-mail', + authenticatorApp: 'Application d’authentification', + totp: 'TOTP', + preparingSetup: 'Préparation de la configuration...', + emailCodeSent: (email) => `Saisissez le code à 6 chiffres envoyé à ${email}.`, + scanAuthenticator: 'Scannez le QR code ou saisissez la clé de configuration manuellement.', + noQrCode: 'Aucun QR code', + sixDigitCode: 'Code à 6 chiffres', + codePlaceholder: '000000', + enterSixDigitCode: 'Saisissez le code à 6 chiffres.', + failedStart: 'Échec du démarrage de la configuration 2FA.', + invalidCode: 'Code 2FA invalide.', + verifying: 'Vérification...', + chooseAnotherMethod: 'Choisir une autre méthode', + }, fleet: { statusLabels: { AVAILABLE: 'Disponible', RESERVED: 'Réservé', READY: 'Prêt pour remise', RENTED: 'En location', RETURNED: 'Rendu', NEEDS_CLEANING: 'Nettoyage requis', MAINTENANCE: 'Maintenance', DAMAGE_REVIEW: 'Révision dommages', OUT_OF_SERVICE: 'Hors service' }, logMaintenance: 'Journal de maintenance', @@ -1105,6 +1162,25 @@ const dictionaries: Record = { theme: 'الوضع', light: 'فاتح', dark: 'داكن', + security2fa: { + security: 'الأمان', + enable2fa: 'تفعيل المصادقة الثنائية', + close: 'إغلاق', + emailCode: 'رمز البريد الإلكتروني', + authenticatorApp: 'تطبيق المصادقة', + totp: 'TOTP', + preparingSetup: 'جارٍ تحضير الإعداد...', + emailCodeSent: (email) => `أدخل الرمز المكون من 6 أرقام المرسل إلى ${email}.`, + scanAuthenticator: 'امسح رمز QR أو أدخل مفتاح الإعداد يدويًا.', + noQrCode: 'لا يوجد رمز QR', + sixDigitCode: 'رمز من 6 أرقام', + codePlaceholder: '000000', + enterSixDigitCode: 'أدخل الرمز المكون من 6 أرقام.', + failedStart: 'فشل بدء إعداد المصادقة الثنائية.', + invalidCode: 'رمز المصادقة الثنائية غير صحيح.', + verifying: 'جارٍ التحقق...', + chooseAnotherMethod: 'اختيار طريقة أخرى', + }, fleet: { statusLabels: { AVAILABLE: 'متاح', RESERVED: 'محجوز', READY: 'جاهز للتسليم', RENTED: 'قيد التأجير', RETURNED: 'مُعاد', NEEDS_CLEANING: 'يحتاج تنظيف', MAINTENANCE: 'صيانة', DAMAGE_REVIEW: 'مراجعة أضرار', OUT_OF_SERVICE: 'خارج الخدمة' }, logMaintenance: 'تسجيل الصيانة', diff --git a/apps/dashboard/src/components/layout/TopBar.tsx b/apps/dashboard/src/components/layout/TopBar.tsx index 4833449..ec960d7 100644 --- a/apps/dashboard/src/components/layout/TopBar.tsx +++ b/apps/dashboard/src/components/layout/TopBar.tsx @@ -1,9 +1,10 @@ 'use client' import Link from 'next/link' -import { Bell, Search, Settings } from 'lucide-react' +import { Bell, Search, Settings, ShieldCheck } from 'lucide-react' import { usePathname, useRouter, useSearchParams } from 'next/navigation' import { useState, useEffect } from 'react' +import { createPortal } from 'react-dom' import { io } from 'socket.io-client' import { EMPLOYEE_PROFILE_KEY, apiFetch, resolveRealtimeSocketTarget } from '@/lib/api' import { useDashboardI18n } from '@/components/I18nProvider' @@ -35,6 +36,13 @@ export default function TopBar() { }>>([]) const [loadingNotifs, setLoadingNotifs] = useState(false) const [socketEnabled, setSocketEnabled] = useState(false) + const [employee, setEmployee] = useState<{ + email: string + firstName: string + lastName: string + totpEnabled?: boolean + } | null>(null) + const [securitySetupOpen, setSecuritySetupOpen] = useState(false) const [mounted, setMounted] = useState(false) useEffect(() => { setMounted(true) }, []) @@ -156,10 +164,12 @@ export default function TopBar() { email: string firstName: string lastName: string + totpEnabled?: boolean } }>('/auth/employee/me') .then(({ employee }) => { if (cancelled) return + setEmployee(employee) window.localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(employee)) const fullName = `${employee.firstName ?? ''} ${employee.lastName ?? ''}`.trim() const email = employee.email ?? '' @@ -167,6 +177,7 @@ export default function TopBar() { }) .catch(() => { if (cancelled) return + setEmployee(null) setUserInitials(computeInitials(dict.workspaceUser)) }) @@ -286,10 +297,211 @@ export default function TopBar() { + {employee && !employee.totpEnabled ? ( + + ) : null} +
{userInitials}
+ {mounted && employee && securitySetupOpen + ? createPortal( + setSecuritySetupOpen(false)} + onEnrolled={(updatedEmployee) => { + setEmployee(updatedEmployee) + window.localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(updatedEmployee)) + setSecuritySetupOpen(false) + }} + />, + document.body, + ) + : null} ) } + +function Employee2FASetupDialog({ + employee, + copy, + onClose, + onEnrolled, +}: { + employee: { email: string; firstName: string; lastName: string; totpEnabled?: boolean } + copy: ReturnType['dict']['security2fa'] + onClose: () => void + onEnrolled: (employee: { email: string; firstName: string; lastName: string; totpEnabled?: boolean }) => void +}) { + type SetupMethod = 'email' | 'authenticator' + const [method, setMethod] = useState(null) + const [secret, setSecret] = useState('') + const [qrCode, setQrCode] = useState('') + const [code, setCode] = useState('') + const [loadingSetup, setLoadingSetup] = useState(false) + const [verifying, setVerifying] = useState(false) + const [error, setError] = useState(null) + + async function startSetup(nextMethod: SetupMethod) { + setMethod(nextMethod) + setCode('') + setError(null) + setLoadingSetup(true) + try { + const data = await apiFetch<{ secret?: string; qrCode?: string }>( + nextMethod === 'email' ? '/auth/employee/2fa/email/setup' : '/auth/employee/2fa/setup', + { method: 'POST', body: JSON.stringify({}) }, + ) + setSecret(data.secret ?? '') + setQrCode(data.qrCode ?? '') + } catch (err: any) { + setError(err?.message ?? copy.failedStart) + } finally { + setLoadingSetup(false) + } + } + + async function verifyCode(event: React.FormEvent) { + event.preventDefault() + const normalizedCode = code.trim() + if (!/^\d{6}$/.test(normalizedCode)) { + setError(copy.enterSixDigitCode) + return + } + + setError(null) + setVerifying(true) + try { + const data = await apiFetch<{ employee: typeof employee }>( + method === 'email' ? '/auth/employee/2fa/email/verify' : '/auth/employee/2fa/verify', + { method: 'POST', body: JSON.stringify({ code: normalizedCode }) }, + ) + onEnrolled(data.employee ?? { ...employee, totpEnabled: true }) + } catch (err: any) { + setError(err?.message ?? copy.invalidCode) + } finally { + setVerifying(false) + } + } + + return ( +
+
+
+
+

{copy.security}

+

{copy.enable2fa}

+

{employee.email}

+
+ +
+ +
+ {!method ? ( +
+ + +
+ ) : loadingSetup ? ( +
+
+ {copy.preparingSetup} +
+ ) : method === 'email' ? ( +
+ {copy.emailCodeSent(employee.email)} +
+ ) : ( +
+
+ {qrCode ? {copy.enable2fa} : {copy.noQrCode}} +
+
+

{copy.authenticatorApp}

+

{copy.scanAuthenticator}

+ {secret ? ( + + {secret} + + ) : null} +
+
+ )} + + + + {error ? ( +
+ {error} +
+ ) : null} + +
+ + {method ? ( + + ) : null} +
+ +
+
+ ) +} diff --git a/apps/homepage/src/components/auth/SignInForm.tsx b/apps/homepage/src/components/auth/SignInForm.tsx index 66385f4..13ab0f3 100644 --- a/apps/homepage/src/components/auth/SignInForm.tsx +++ b/apps/homepage/src/components/auth/SignInForm.tsx @@ -32,7 +32,8 @@ interface Dict { verify: string; verifying: string; authCode: string; - enterCode: string; + enterEmailCode: string; + enterAuthenticatorCode: string; totpPlaceholder: string; back: string; forgotPassword: string; @@ -55,7 +56,8 @@ const dicts: Record = { verify: 'Verify code', verifying: 'Verifying…', authCode: 'Authentication code', - enterCode: 'Enter the 6-digit code sent to your admin email, or use your authenticator app.', + enterEmailCode: 'Enter the 6-digit code sent to your admin email.', + enterAuthenticatorCode: 'Enter the 6-digit code from your authenticator app.', totpPlaceholder: '000000 or XXXX-XXXX-XXXX', back: 'Back to credentials', forgotPassword: 'Forgot your password?', @@ -77,7 +79,8 @@ const dicts: Record = { verify: 'Vérifier le code', verifying: 'Vérification…', authCode: "Code d'authentification", - enterCode: 'Entrez le code à 6 chiffres envoyé à votre e-mail admin, ou utilisez votre application d’authentification.', + enterEmailCode: 'Entrez le code à 6 chiffres envoyé à votre e-mail admin.', + enterAuthenticatorCode: 'Entrez le code à 6 chiffres de votre application d’authentification.', totpPlaceholder: '000000 ou XXXX-XXXX-XXXX', back: 'Retour aux identifiants', forgotPassword: 'Mot de passe oublié ?', @@ -99,7 +102,8 @@ const dicts: Record = { verify: 'تحقق من الرمز', verifying: 'جارٍ التحقق…', authCode: 'رمز المصادقة', - enterCode: 'أدخل الرمز المكون من 6 أرقام المرسل إلى بريد المسؤول، أو استخدم تطبيق المصادقة.', + enterEmailCode: 'أدخل الرمز المكون من 6 أرقام المرسل إلى بريد المسؤول.', + enterAuthenticatorCode: 'أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة.', totpPlaceholder: '000000 أو XXXX-XXXX-XXXX', back: 'العودة إلى بيانات الدخول', forgotPassword: 'نسيت كلمة المرور؟', @@ -126,6 +130,7 @@ export function SignInForm({ const [password, setPassword] = useState(''); const [showPassword, setShowPassword] = useState(false); const [totpCode, setTotpCode] = useState(''); + const [secondFactorMethod, setSecondFactorMethod] = useState<'email' | 'authenticator'>('email'); const [step, setStep] = useState<'credentials' | 'totp'>('credentials'); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -170,7 +175,8 @@ export function SignInForm({ if (res.ok && completeLogin(json?.data)) return; - if (res.status === 401 && json?.error === 'totp_required') { + if (res.status === 401 && (json?.error === 'totp_required' || json?.error === 'two_factor_required')) { + setSecondFactorMethod(json?.method === 'authenticator' ? 'authenticator' : 'email'); setStep('totp'); return; } @@ -314,7 +320,7 @@ export function SignInForm({ className={styles.formStack} >
- {dict.enterCode} + {secondFactorMethod === 'authenticator' ? dict.enterAuthenticatorCode : dict.enterEmailCode}
diff --git a/packages/database/prisma/migrations/20260823001000_add_employee_2fa/migration.sql b/packages/database/prisma/migrations/20260823001000_add_employee_2fa/migration.sql new file mode 100644 index 0000000..ac8e325 --- /dev/null +++ b/packages/database/prisma/migrations/20260823001000_add_employee_2fa/migration.sql @@ -0,0 +1,3 @@ +ALTER TABLE "employees" + ADD COLUMN "totpSecret" TEXT, + ADD COLUMN "totpEnabled" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index a341d7e..ad105ae 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -1484,6 +1484,8 @@ model Employee { role EmployeeRole @default(AGENT) preferredLanguage String @default("en") isActive Boolean @default(true) + totpSecret String? + totpEnabled Boolean @default(false) notifications Notification[] @relation("EmployeeNotifications") notificationRecipients NotificationRecipient[]