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