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
+106 -52
View File
@@ -41,6 +41,7 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
const [ready, setReady] = useState(false)
const [admin, setAdmin] = useState<AdminSessionUser | null>(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 (
<Admin2FAEnrollmentGate
admin={admin}
onEnrolled={setAdmin}
onLogout={handleLogout}
/>
)
}
return (
<AdminSessionProvider admin={admin as AdminSessionUser}>
<div className="flex h-screen bg-[linear-gradient(180deg,#ffffff_0%,#f5f8ff_28%,#eef4ff_58%,#ffffff_100%)] text-stone-900 transition-colors dark:bg-[linear-gradient(180deg,#0a1128_0%,#0d1b38_35%,#07101e_100%)] dark:text-slate-100">
@@ -145,6 +134,17 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
})}
</nav>
<div className="px-3 py-4">
{admin && !admin.totpEnabled ? (
<button
onClick={() => setSecuritySetupOpen(true)}
className="mb-2 flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-orange-700 transition-colors hover:bg-orange-50 hover:text-orange-800 dark:text-orange-300 dark:hover:bg-[#162038] dark:hover:text-orange-200"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75l2 2 4-4M12 3l7 4v5c0 5-3.5 8-7 9-3.5-1-7-4-7-9V7l7-4z" />
</svg>
Enable 2FA
</button>
) : null}
<button
onClick={handleLogout}
className="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-stone-500 transition-colors hover:bg-stone-100 hover:text-red-500 dark:text-stone-400 dark:hover:bg-[#162038] dark:hover:text-red-300"
@@ -161,51 +161,67 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
</div>
</aside>
<main className="flex-1 overflow-y-auto transition-colors">{children}</main>
{admin && securitySetupOpen ? (
<Admin2FASetupDialog
admin={admin}
onEnrolled={(updatedAdmin) => {
setAdmin(updatedAdmin)
setSecuritySetupOpen(false)
}}
onClose={() => setSecuritySetupOpen(false)}
/>
) : null}
</div>
</AdminSessionProvider>
)
}
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<SetupMethod | null>(null)
const [secret, setSecret] = useState('')
const [qrCode, setQrCode] = useState('')
const [code, setCode] = useState('')
const [error, setError] = useState<string | null>(null)
const [loadingSetup, setLoadingSetup] = useState(true)
const [loadingSetup, setLoadingSetup] = useState(false)
const [verifying, setVerifying] = useState(false)
const [verifiedAdmin, setVerifiedAdmin] = useState<AdminSessionUser | null>(null)
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([])
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<HTMLFormElement>) {
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 (
<div className="flex min-h-screen items-center justify-center bg-[linear-gradient(180deg,#ffffff_0%,#f5f8ff_28%,#eef4ff_58%,#ffffff_100%)] p-6 text-stone-900 transition-colors dark:bg-[linear-gradient(180deg,#0a1128_0%,#0d1b38_35%,#07101e_100%)] dark:text-slate-100">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/45 p-6 text-stone-900 backdrop-blur-sm dark:text-slate-100">
<section className="w-full max-w-2xl rounded-3xl border border-stone-200/80 bg-white/90 p-8 shadow-xl backdrop-blur dark:border-blue-900 dark:bg-[#07101e]/90">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-orange-700 dark:text-orange-300">{dict.admin}</p>
<h1 className="mt-2 text-2xl font-black text-blue-950 dark:text-stone-50">Set up admin 2FA</h1>
<p className="mt-2 text-sm leading-6 text-stone-600 dark:text-slate-300">
Admin 2FA enrollment is required before using privileged admin routes.
</p>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-orange-700 dark:text-orange-300">Security</p>
<h1 className="mt-2 text-2xl font-black text-blue-950 dark:text-stone-50">Enable 2FA</h1>
<p className="mt-1 text-xs text-stone-500 dark:text-slate-400">{admin.email}</p>
</div>
<button
type="button"
onClick={onLogout}
onClick={onClose}
className="rounded-xl border border-stone-200 px-4 py-2 text-sm font-semibold text-stone-600 transition hover:bg-stone-100 dark:border-blue-800 dark:text-slate-300 dark:hover:bg-[#162038]"
>
{dict.logout}
Close
</button>
</div>
@@ -284,10 +300,33 @@ function Admin2FAEnrollmentGate({
</div>
) : (
<form onSubmit={verifyCode} className="mt-8 space-y-6">
{loadingSetup ? (
{!method ? (
<div className="grid gap-3 sm:grid-cols-2">
<button
type="button"
onClick={() => startSetup('email')}
className="rounded-2xl border border-stone-200 bg-stone-50 p-4 text-left transition hover:border-orange-300 hover:bg-orange-50 dark:border-blue-900 dark:bg-[#0d1b38] dark:hover:border-orange-400/70 dark:hover:bg-[#162038]"
>
<span className="block text-sm font-semibold text-blue-950 dark:text-stone-100">Email code</span>
<span className="mt-2 block text-sm text-stone-600 dark:text-slate-300">{admin.email}</span>
</button>
<button
type="button"
onClick={() => startSetup('authenticator')}
className="rounded-2xl border border-stone-200 bg-stone-50 p-4 text-left transition hover:border-orange-300 hover:bg-orange-50 dark:border-blue-900 dark:bg-[#0d1b38] dark:hover:border-orange-400/70 dark:hover:bg-[#162038]"
>
<span className="block text-sm font-semibold text-blue-950 dark:text-stone-100">Authenticator app</span>
<span className="mt-2 block text-sm text-stone-600 dark:text-slate-300">TOTP</span>
</button>
</div>
) : loadingSetup ? (
<div className="flex items-center gap-3 rounded-2xl border border-stone-200 bg-stone-50 p-4 text-sm text-stone-600 dark:border-blue-900 dark:bg-[#0d1b38] dark:text-slate-300">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-orange-500 border-t-transparent" />
Preparing authenticator setup...
Preparing setup...
</div>
) : method === 'email' ? (
<div className="rounded-2xl border border-stone-200 bg-stone-50 p-4 text-sm text-stone-600 dark:border-blue-900 dark:bg-[#0d1b38] dark:text-slate-300">
Enter the 6-digit code sent to {admin.email}.
</div>
) : (
<div className="grid gap-5 md:grid-cols-[180px,1fr]">
@@ -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}
/>
</label>
@@ -329,11 +368,26 @@ function Admin2FAEnrollmentGate({
<button
type="submit"
disabled={loadingSetup || verifying || code.length !== 6}
disabled={!method || loadingSetup || verifying || code.length !== 6}
className="w-full rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-orange-500 dark:hover:bg-orange-400"
>
{verifying ? 'Verifying...' : 'Enable 2FA'}
</button>
{method ? (
<button
type="button"
onClick={() => {
setMethod(null)
setCode('')
setError(null)
setSecret('')
setQrCode('')
}}
className="w-full rounded-full border border-stone-200 px-6 py-3 text-sm font-semibold text-stone-600 transition hover:bg-stone-100 dark:border-blue-800 dark:text-slate-300 dark:hover:bg-[#162038]"
>
Choose another method
</button>
) : null}
</form>
)}
</section>
@@ -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')
@@ -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 labonnement', requiredPlan: 'Requiert',
lockedTitle: 'Cette section nest 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 daccès.', companyHint: 'La langue par défaut affecte la vitrine et les contrats générés si disponibles.',
officialLanguageHint: 'Cest la langue officielle de votre espace. Les avis de paiement, notifications, textes Carplace et documents générés lutilisent 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 nest 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<MenuItem[]>([])
const [entitlements, setEntitlements] = useState<Entitlements | null>(null)
const [brand, setBrand] = useState<BrandSettings | null>(null)
const [communicationSettings, setCommunicationSettings] = useState<CommunicationSettings | null>(null)
const [contractSettings, setContractSettings] = useState<ContractSettings | null>(null)
const [insurancePolicies, setInsurancePolicies] = useState<InsurancePolicy[]>([])
const [pricingRules, setPricingRules] = useState<PricingRule[]>([])
@@ -301,6 +330,9 @@ function SettingsPageContent() {
if ((activeSection === 'company' || activeSection === 'carplace' || activeSection === 'payments') && !brand) {
setBrand(await apiFetch<BrandSettings | null>('/companies/me/brand'))
}
if (activeSection === 'company' && !communicationSettings) {
setCommunicationSettings(await apiFetch<CommunicationSettings>('/subscriptions/communication-settings'))
}
if (activeSection === 'rental-policies' && !contractSettings) {
setContractSettings(withRentalPolicyDefaults(await apiFetch<ContractSettings | null>('/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<BrandSettings>('/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<CommunicationSettings>('/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() {
</div>
<Input label={copy.labels.country} value={brand.publicCountry ?? 'Morocco'} disabled={!canEdit('settings.public_contact')} onChange={(v) => setBrand({ ...brand, publicCountry: v })} />
<Input label={copy.labels.websiteUrl} value={brand.websiteUrl ?? ''} disabled={!canEdit('settings.public_contact')} onChange={(v) => setBrand({ ...brand, websiteUrl: v })} />
<Select label={copy.labels.defaultLocale} value={brand.defaultLocale ?? language} disabled={!canEdit('settings.locale_currency')} options={['en', 'fr', 'ar']} onChange={(v) => setBrand({ ...brand, defaultLocale: v })} />
<Select label={copy.labels.defaultLocale} value={brand.defaultLocale ?? language} disabled={!canEdit('settings.locale_currency')} options={['en', 'fr', 'ar']} onChange={(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)
}} />
<Select label={copy.labels.defaultCurrency} value={brand.defaultCurrency ?? 'MAD'} disabled={!canEdit('settings.locale_currency')} options={['MAD', 'EUR', 'USD']} onChange={(v) => setBrand({ ...brand, defaultCurrency: v })} />
</div>
<p className="mt-3 text-xs text-slate-500">{copy.companyHint}</p>
<p className="mt-3 text-xs text-slate-500">{copy.officialLanguageHint}</p>
{communicationSettings ? (
<div className="mt-6 border-t border-slate-200 pt-5">
<h4 className="text-sm font-semibold text-slate-900">{copy.communicationTitle}</h4>
<p className="mt-1 text-xs text-slate-500">{copy.communicationHelp}</p>
<div className="mt-4 grid gap-4 lg:grid-cols-2">
<div>
<p className="text-sm font-medium text-slate-700">{copy.enabledLanguages}</p>
<div className="mt-2 flex flex-wrap gap-3">
{(['ar', 'en', 'fr'] as CommunicationLocale[]).map((locale) => {
const checked = communicationSettings.enabledCommunicationLocales.includes(locale)
const officialLanguage = (brand.defaultLocale ?? language) as CommunicationLocale
return (
<label key={locale} className="flex items-center gap-2 rounded-lg border border-slate-200 px-3 py-2 text-sm uppercase text-slate-700">
<input
type="checkbox"
checked={checked}
disabled={locale === officialLanguage || !canEdit('settings.locale_currency')}
onChange={() => 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}
</label>
)
})}
</div>
</div>
<Input label={copy.timezone} value={communicationSettings.timezone} disabled={!canEdit('settings.locale_currency')} onChange={(v) => setCommunicationSettings({ ...communicationSettings, timezone: v })} />
</div>
<div className="mt-4 space-y-3">
{communicationSettings.contacts.map((contact, index) => (
<div key={contact.id ?? contact.email} className="grid gap-3 rounded-xl border border-slate-200 p-4 sm:grid-cols-[1fr,220px] sm:items-center">
<div>
<p className="text-sm font-medium text-slate-900">{contact.email}</p>
<p className="text-xs text-slate-500">{contact.isPrimary ? 'Primary / ' : ''}{contact.verified ? 'Verified' : 'Verification pending'} / {contact.employeeId ? 'In-app + email' : 'Email'}</p>
</div>
<label className="text-xs font-medium text-slate-600">
{copy.contactLanguage}
<select
value={contact.locale ?? ''}
disabled={!canEdit('settings.locale_currency')}
onChange={(event) => setCommunicationSettings((current) => current ? { ...current, contacts: current.contacts.map((item, itemIndex) => itemIndex === index ? { ...item, locale: (event.target.value || null) as CommunicationLocale | null } : item) } : current)}
className="mt-1 w-full rounded-lg border border-slate-300 px-2 py-2 text-sm text-slate-900"
>
<option value="">{copy.inheritDefault}</option>
{communicationSettings.enabledCommunicationLocales.map((locale) => <option key={locale} value={locale}>{locale.toUpperCase()}</option>)}
</select>
</label>
</div>
))}
</div>
</div>
) : null}
</SectionCard>
)}
@@ -93,25 +93,6 @@ interface UpgradeAcceptResult {
instructions: Record<string, string> | 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<string | null>(null)
const [submissionIdempotencyKey, setSubmissionIdempotencyKey] = useState<string | null>(null)
const [communicationSettings, setCommunicationSettings] = useState<CommunicationSettings | null>(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 lenvoyer à 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<Subscription | null>('/subscriptions/me'),
apiFetch<Invoice[]>('/subscriptions/invoices'),
apiFetch<{ methods: PaymentOption[] }>('/subscriptions/payment-options'),
apiFetch<CommunicationSettings>('/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<CommunicationSettings>('/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}
</div>
{communicationSettings ? (
<div className="card p-6">
<h3 className="text-base font-semibold text-slate-900 dark:text-zinc-100">{copy.communicationTitle}</h3>
<p className="mt-1 text-sm text-slate-500 dark:text-zinc-400">{copy.communicationHelp}</p>
<div className="mt-5 grid gap-5 lg:grid-cols-3">
<div>
<p className="text-sm font-medium text-slate-700 dark:text-zinc-300">{copy.enabledLanguages}</p>
<div className="mt-2 flex flex-wrap gap-3">
{(['ar', 'en', 'fr'] as CommunicationLocale[]).map((locale) => {
const checked = communicationSettings.enabledCommunicationLocales.includes(locale)
return (
<label key={locale} className="flex items-center gap-2 rounded-lg border border-slate-200 px-3 py-2 text-sm uppercase text-slate-700 dark:border-zinc-700 dark:text-zinc-300">
<input
type="checkbox"
checked={checked}
onChange={() => setCommunicationSettings((current) => {
if (!current) return current
const next = checked
? current.enabledCommunicationLocales.filter((item) => item !== locale)
: [...current.enabledCommunicationLocales, locale]
if (next.length === 0) return current
return {
...current,
enabledCommunicationLocales: next,
defaultCommunicationLocale: next.includes(current.defaultCommunicationLocale) ? current.defaultCommunicationLocale : next[0],
contacts: current.contacts.map((contact) => contact.locale && !next.includes(contact.locale) ? { ...contact, locale: null } : contact),
}
})}
/>
{locale}
</label>
)
})}
</div>
</div>
<label className="block text-sm font-medium text-slate-700 dark:text-zinc-300">
{copy.defaultLanguage}
<select value={communicationSettings.defaultCommunicationLocale} onChange={(event) => setCommunicationSettings((current) => current ? { ...current, defaultCommunicationLocale: event.target.value as CommunicationLocale } : current)} className="mt-2 w-full rounded-xl border border-slate-300 px-3 py-2 text-slate-900 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100">
{communicationSettings.enabledCommunicationLocales.map((locale) => <option key={locale} value={locale}>{locale.toUpperCase()}</option>)}
</select>
</label>
<label className="block text-sm font-medium text-slate-700 dark:text-zinc-300">
{copy.timezone}
<input value={communicationSettings.timezone} onChange={(event) => setCommunicationSettings((current) => current ? { ...current, timezone: event.target.value } : current)} className="mt-2 w-full rounded-xl border border-slate-300 px-3 py-2 text-slate-900 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100" />
</label>
</div>
<div className="mt-5 space-y-3">
{communicationSettings.contacts.map((contact, index) => (
<div key={contact.id ?? contact.email} className="grid gap-3 rounded-xl border border-slate-200 p-4 sm:grid-cols-[1fr,220px] sm:items-center dark:border-zinc-700">
<div>
<p className="text-sm font-medium text-slate-900 dark:text-zinc-100">{contact.email}</p>
<p className="text-xs text-slate-500 dark:text-zinc-400">{contact.isPrimary ? 'Primary · ' : ''}{contact.verified ? 'Verified' : 'Verification pending'} · {contact.employeeId ? 'In-app + email' : 'Email'}</p>
</div>
<label className="text-xs font-medium text-slate-600 dark:text-zinc-300">
{copy.contactLanguage}
<select
value={contact.locale ?? ''}
onChange={(event) => setCommunicationSettings((current) => current ? { ...current, contacts: current.contacts.map((item, itemIndex) => itemIndex === index ? { ...item, locale: (event.target.value || null) as CommunicationLocale | null } : item) } : current)}
className="mt-1 w-full rounded-lg border border-slate-300 px-2 py-2 text-sm text-slate-900 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
>
<option value="">{copy.inheritDefault}</option>
{communicationSettings.enabledCommunicationLocales.map((locale) => <option key={locale} value={locale}>{locale.toUpperCase()}</option>)}
</select>
</label>
</div>
))}
</div>
<button type="button" onClick={saveCommunicationSettings} disabled={savingCommunicationSettings} className="btn-primary mt-5">
{savingCommunicationSettings ? copy.loading : copy.saveSettings}
</button>
</div>
) : null}
{/* Invoice history */}
<div className="card overflow-hidden">
<div className="px-6 py-4 border-b border-slate-200 dark:border-zinc-800">
@@ -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<DashboardLanguage, DashboardDictionary> = {
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<DashboardLanguage, DashboardDictionary> = {
theme: 'Mode',
light: 'Clair',
dark: 'Sombre',
security2fa: {
security: 'Sécurité',
enable2fa: 'Activer la 2FA',
close: 'Fermer',
emailCode: 'Code par e-mail',
authenticatorApp: 'Application dauthentification',
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<DashboardLanguage, DashboardDictionary> = {
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: 'تسجيل الصيانة',
+213 -1
View File
@@ -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() {
<Settings className="h-5 w-5" />
</Link>
{employee && !employee.totpEnabled ? (
<button
type="button"
onClick={() => setSecuritySetupOpen(true)}
className="hidden items-center gap-2 rounded-lg border border-orange-200 bg-orange-50 px-3 py-2 text-sm font-semibold text-orange-700 transition-colors hover:bg-orange-100 dark:border-orange-400/20 dark:bg-orange-400/10 dark:text-orange-200 dark:hover:bg-orange-400/15 md:flex"
>
<ShieldCheck className="h-4 w-4" />
{dict.security2fa.enable2fa}
</button>
) : null}
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br from-blue-500 to-orange-500 text-xs font-semibold text-white">
{userInitials}
</div>
</div>
{mounted && employee && securitySetupOpen
? createPortal(
<Employee2FASetupDialog
employee={employee}
copy={dict.security2fa}
onClose={() => setSecuritySetupOpen(false)}
onEnrolled={(updatedEmployee) => {
setEmployee(updatedEmployee)
window.localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(updatedEmployee))
setSecuritySetupOpen(false)
}}
/>,
document.body,
)
: null}
</header>
)
}
function Employee2FASetupDialog({
employee,
copy,
onClose,
onEnrolled,
}: {
employee: { email: string; firstName: string; lastName: string; totpEnabled?: boolean }
copy: ReturnType<typeof useDashboardI18n>['dict']['security2fa']
onClose: () => void
onEnrolled: (employee: { email: string; firstName: string; lastName: string; totpEnabled?: boolean }) => void
}) {
type SetupMethod = 'email' | 'authenticator'
const [method, setMethod] = useState<SetupMethod | null>(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<string | null>(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<HTMLFormElement>) {
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 (
<div className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/45 p-6 backdrop-blur-sm">
<section className="w-full max-w-2xl rounded-2xl border border-blue-200/80 bg-white p-6 text-blue-950 shadow-[0_24px_80px_rgba(15,23,42,0.22)] dark:border-blue-400/15 dark:bg-[#0d1728] dark:text-slate-100">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-orange-700 dark:text-orange-300">{copy.security}</p>
<h2 className="mt-2 text-xl font-black">{copy.enable2fa}</h2>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{employee.email}</p>
</div>
<button
type="button"
onClick={onClose}
className="rounded-lg border border-blue-200 px-3 py-2 text-sm font-semibold text-slate-600 transition hover:bg-blue-50 dark:border-blue-400/20 dark:text-slate-300 dark:hover:bg-blue-400/10"
>
{copy.close}
</button>
</div>
<form onSubmit={verifyCode} className="mt-6 space-y-5">
{!method ? (
<div className="grid gap-3 sm:grid-cols-2">
<button
type="button"
onClick={() => startSetup('email')}
className="rounded-xl border border-blue-200 bg-blue-50/70 p-4 text-left transition hover:border-orange-300 hover:bg-orange-50 dark:border-blue-400/15 dark:bg-blue-500/[0.06] dark:hover:border-orange-400/50"
>
<span className="block text-sm font-semibold">{copy.emailCode}</span>
<span className="mt-2 block text-sm text-slate-500 dark:text-slate-400">{employee.email}</span>
</button>
<button
type="button"
onClick={() => startSetup('authenticator')}
className="rounded-xl border border-blue-200 bg-blue-50/70 p-4 text-left transition hover:border-orange-300 hover:bg-orange-50 dark:border-blue-400/15 dark:bg-blue-500/[0.06] dark:hover:border-orange-400/50"
>
<span className="block text-sm font-semibold">{copy.authenticatorApp}</span>
<span className="mt-2 block text-sm text-slate-500 dark:text-slate-400">{copy.totp}</span>
</button>
</div>
) : loadingSetup ? (
<div className="flex items-center gap-3 rounded-xl border border-blue-200 bg-blue-50/70 p-4 text-sm text-slate-600 dark:border-blue-400/15 dark:bg-blue-500/[0.06] dark:text-slate-300">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-orange-500 border-t-transparent" />
{copy.preparingSetup}
</div>
) : method === 'email' ? (
<div className="rounded-xl border border-blue-200 bg-blue-50/70 p-4 text-sm text-slate-600 dark:border-blue-400/15 dark:bg-blue-500/[0.06] dark:text-slate-300">
{copy.emailCodeSent(employee.email)}
</div>
) : (
<div className="grid gap-4 sm:grid-cols-[180px,1fr]">
<div className="flex h-44 items-center justify-center rounded-xl border border-blue-200 bg-white p-3 dark:border-blue-400/15">
{qrCode ? <img src={qrCode} alt={copy.enable2fa} className="h-full w-full object-contain" /> : <span className="text-sm text-slate-500">{copy.noQrCode}</span>}
</div>
<div>
<p className="text-sm font-semibold">{copy.authenticatorApp}</p>
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300">{copy.scanAuthenticator}</p>
{secret ? (
<code className="mt-3 block break-all rounded-xl border border-blue-200 bg-blue-50 px-3 py-2 text-sm text-blue-950 dark:border-blue-400/15 dark:bg-blue-950/70 dark:text-slate-200">
{secret}
</code>
) : null}
</div>
</div>
)}
<label className="block">
<span className="mb-2 block text-sm font-semibold">{copy.sixDigitCode}</span>
<input
value={code}
onChange={(event) => setCode(event.target.value.replace(/\D/g, '').slice(0, 6))}
inputMode="numeric"
autoComplete="one-time-code"
placeholder={copy.codePlaceholder}
disabled={!method || loadingSetup || verifying}
className="w-full rounded-xl border border-blue-200 bg-white px-4 py-3 text-lg font-semibold tracking-[0.2em] text-blue-950 outline-none transition focus:ring-2 focus:ring-orange-500 disabled:opacity-60 dark:border-blue-400/15 dark:bg-blue-950/70 dark:text-slate-100"
/>
</label>
{error ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-300">
{error}
</div>
) : null}
<div className="flex flex-col gap-3 sm:flex-row">
<button
type="submit"
disabled={!method || loadingSetup || verifying || code.length !== 6}
className="rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-orange-500 dark:hover:bg-orange-400"
>
{verifying ? copy.verifying : copy.enable2fa}
</button>
{method ? (
<button
type="button"
onClick={() => {
setMethod(null)
setCode('')
setError(null)
setSecret('')
setQrCode('')
}}
className="rounded-full border border-blue-200 px-6 py-3 text-sm font-semibold text-slate-600 transition hover:bg-blue-50 dark:border-blue-400/20 dark:text-slate-300 dark:hover:bg-blue-400/10"
>
{copy.chooseAnotherMethod}
</button>
) : null}
</div>
</form>
</section>
</div>
)
}
@@ -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<string, Dict> = {
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<string, Dict> = {
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 dauthentification.',
enterEmailCode: 'Entrez le code à 6 chiffres envoyé à votre e-mail admin.',
enterAuthenticatorCode: 'Entrez le code à 6 chiffres de votre application dauthentification.',
totpPlaceholder: '000000 ou XXXX-XXXX-XXXX',
back: 'Retour aux identifiants',
forgotPassword: 'Mot de passe oublié ?',
@@ -99,7 +102,8 @@ const dicts: Record<string, Dict> = {
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<string | null>(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}
>
<div className={styles.infoBox}>
{dict.enterCode}
{secondFactorMethod === 'authenticator' ? dict.enterAuthenticatorCode : dict.enterEmailCode}
</div>
<FormField id="signin-totp" label={dict.authCode} required>
@@ -0,0 +1,3 @@
ALTER TABLE "employees"
ADD COLUMN "totpSecret" TEXT,
ADD COLUMN "totpEnabled" BOOLEAN NOT NULL DEFAULT false;
+2
View File
@@ -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[]