From eeec8f0a827206a9b749abad81bdc624220b6f23 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 28 Jul 2026 23:11:46 -0400 Subject: [PATCH] fix both admin and company login --- apps/api/src/app.ts | 2 + .../src/modules/auth/auth.unified.routes.ts | 57 +++++++ .../src/tests/api/auth-middleware.api.test.ts | 41 ++++++ .../src/components/auth/SignInForm.tsx | 139 ++++++------------ .../unit/components/sign-in-form.test.tsx | 45 +++--- 5 files changed, 167 insertions(+), 117 deletions(-) create mode 100644 apps/api/src/modules/auth/auth.unified.routes.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 01b9e71..2f8eac1 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -15,6 +15,7 @@ import webhookRouter from './modules/webhooks/webhook.routes' import companyAuthRouter from './modules/auth/auth.company.routes' import employeeAuthRouter from './modules/auth/auth.employee.routes' import accountAuthRouter from './modules/auth/auth.account.routes' +import unifiedAuthRouter from './modules/auth/auth.unified.routes' import renterAuthRouter from './modules/auth/auth.renter.routes' import teamRouter from './modules/team/team.routes' import offersRouter from './modules/offers/offer.routes' @@ -207,6 +208,7 @@ export function createApp() { app.use(express.json({ limit: '10mb' })) // ─── API Routes ───────────────────────────────────────────── + app.use(`${v1}/auth`, authLimiter, unifiedAuthRouter) app.use(`${v1}/auth/account`, authLimiter, accountAuthRouter) app.use(`${v1}/auth/renter`, authLimiter, renterAuthRouter) app.use(`${v1}/auth/company`, authLimiter, companyAuthRouter) diff --git a/apps/api/src/modules/auth/auth.unified.routes.ts b/apps/api/src/modules/auth/auth.unified.routes.ts new file mode 100644 index 0000000..9c810d0 --- /dev/null +++ b/apps/api/src/modules/auth/auth.unified.routes.ts @@ -0,0 +1,57 @@ +import { Router } from 'express' +import { z } from 'zod' +import { parseBody } from '../../http/validate' +import { ok } from '../../http/respond' +import { AppError } from '../../http/errors' +import { setSessionCookie, clearSessionCookie } from '../../security/sessionCookies' +import * as employeeService from './auth.employee.service' +import * as adminService from '../admin/admin.service' + +const unifiedLoginSchema = z.object({ + email: z.string().email().max(255).trim().toLowerCase(), + password: z.string().max(128), + totpCode: z.string().length(6).optional(), + recoveryCode: z.string().min(8).max(32).optional(), +}) + +const router = Router() + +router.post('/login', async (req, res, next) => { + try { + const { email, password, totpCode, recoveryCode } = parseBody(unifiedLoginSchema, req) + + if (!totpCode && !recoveryCode) { + try { + const employeeResult = await employeeService.login({ email, password }) + 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) + return ok(res, employeeResult) + } catch (err) { + if (!(err instanceof AppError) || err.error !== 'invalid_credentials') throw err + } + } + + const adminResult = await adminService.login(email, password, totpCode, recoveryCode) + if (!adminResult) { + return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 }) + } + if ('totpRequired' in adminResult) { + clearSessionCookie(res, 'employee') + return res.status(401).json({ error: 'totp_required', message: '2FA code required', statusCode: 401 }) + } + if ('invalidTotp' in adminResult) { + return res.status(401).json({ error: 'invalid_totp', message: 'Invalid 2FA code', statusCode: 401 }) + } + + clearSessionCookie(res, 'employee') + setSessionCookie(res, 'admin', adminResult.token, 8 * 60 * 60 * 1000) + return ok(res, adminResult) + } catch (err) { + return next(err) + } +}) + +export default router diff --git a/apps/api/src/tests/api/auth-middleware.api.test.ts b/apps/api/src/tests/api/auth-middleware.api.test.ts index 4292989..90a095c 100644 --- a/apps/api/src/tests/api/auth-middleware.api.test.ts +++ b/apps/api/src/tests/api/auth-middleware.api.test.ts @@ -30,12 +30,18 @@ vi.mock('../../modules/admin/admin.service', () => ({ resetPassword: vi.fn(), })) +vi.mock('../../modules/auth/auth.employee.service', () => ({ + login: vi.fn(), +})) + import request from 'supertest' import jwt from 'jsonwebtoken' import { createApp } from '../../app' import { prisma } from '../../lib/prisma' import * as vehicleService from '../../modules/vehicles/vehicle.service' import * as adminService from '../../modules/admin/admin.service' +import * as employeeService from '../../modules/auth/auth.employee.service' +import { AppError } from '../../http/errors' const app = createApp() @@ -65,6 +71,41 @@ describe('auth middleware API boundaries', () => { expect(vehicleService.listVehicles).not.toHaveBeenCalled() }) + it('uses the unified login endpoint for employee credentials', async () => { + vi.mocked(employeeService.login).mockResolvedValue({ + token: 'employee-jwt', + employee: { id: 'employee_1', email: 'owner@example.test' }, + } as never) + + const res = await request(app) + .post('/api/v1/auth/login') + .send({ email: 'owner@example.test', password: 'valid-password' }) + + expect(res.status).toBe(200) + expect(employeeService.login).toHaveBeenCalledWith({ email: 'owner@example.test', password: 'valid-password' }) + expect(adminService.login).not.toHaveBeenCalled() + expect(res.headers['set-cookie']).toEqual(expect.arrayContaining([ + expect.stringMatching(/^admin_session=;/), + expect.stringMatching(/^employee_session=/), + ])) + }) + + 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) + + const res = await request(app) + .post('/api/v1/auth/login') + .send({ email: 'admin@example.test', password: 'valid-password' }) + + expect(res.status).toBe(401) + expect(res.body.error).toBe('totp_required') + expect(adminService.login).toHaveBeenCalledWith('admin@example.test', 'valid-password', undefined, undefined) + expect(res.headers['set-cookie']).toEqual(expect.arrayContaining([ + expect.stringMatching(/^employee_session=;/), + ])) + }) + it('rejects employee-protected routes when a renter token is used', async () => { vi.mocked(jwt.verify).mockReturnValue({ sub: 'renter_1', type: 'renter' } as never) diff --git a/apps/homepage/src/components/auth/SignInForm.tsx b/apps/homepage/src/components/auth/SignInForm.tsx index b951f90..a028399 100644 --- a/apps/homepage/src/components/auth/SignInForm.tsx +++ b/apps/homepage/src/components/auth/SignInForm.tsx @@ -101,17 +101,6 @@ const dicts: Record = { }, }; -function isAdminDestination(value: string) { - if (!value) return false; - - try { - const url = new URL(value, typeof window === 'undefined' ? 'http://localhost' : window.location.origin); - return url.pathname.startsWith('/admin'); - } catch { - return value.startsWith('/admin'); - } -} - export function SignInForm({ locale, authMode = 'auto', @@ -119,6 +108,7 @@ export function SignInForm({ locale: Locale; authMode?: 'auto' | 'admin' | 'employee'; }) { + void authMode; const dict = (dicts[locale] ?? dicts.en) as Dict; const searchParams = useSearchParams(); const pathname = usePathname(); @@ -130,13 +120,27 @@ export function SignInForm({ const [step, setStep] = useState<'credentials' | 'totp'>('credentials'); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const requestedPortal = searchParams.get('portal'); - const requestedNext = searchParams.get('next') || searchParams.get('redirect') || ''; const employeeRedirect = searchParams.get('redirect') || '/dashboard'; - const preferAdminAuth = - authMode === 'admin' || - pathname.includes('/admin-sign-in') || - (authMode === 'auto' && (requestedPortal === 'admin' || isAdminDestination(requestedNext))); + + function completeLogin(data: any) { + if (data?.admin) { + window.location.href = `${window.location.origin}/admin/dashboard`; + return true; + } + + if (data?.employee) { + localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(data.employee)); + const prefLang = data.employee?.preferredLanguage; + if (prefLang === 'en' || prefLang === 'fr' || prefLang === 'ar') { + document.cookie = `rentaldrivego-language=${prefLang}; path=/; max-age=31536000; samesite=lax`; + } + window.dispatchEvent(new CustomEvent('rentaldrivego:auth-changed')); + window.location.replace(employeeRedirect); + return true; + } + + return false; + } async function handleCredentials(e: React.FormEvent) { e.preventDefault(); @@ -144,81 +148,35 @@ export function SignInForm({ setError(null); try { - const tryAdminLogin = async () => { - const adminRes = await fetch(`${API_BASE}/admin/auth/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ email, password }), - }); - const adminJson = await adminRes.json(); + const res = await fetch(`${API_BASE}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ email, password }), + }); + const json = await res.json(); - if (adminRes.ok && adminJson?.data?.admin) { - window.location.href = `${window.location.origin}/admin/dashboard`; - return true; - } + if (res.ok && completeLogin(json?.data)) return; - if (adminRes.status === 401 && adminJson?.error === 'totp_required') { - setStep('totp'); - return true; - } - - if (adminRes.status === 429) { - setError(dict.tooManyRequests); - return true; - } - - return false; - }; - - const tryEmployeeLogin = async () => { - const empRes = await fetch(`${API_BASE}/auth/employee/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ email, password }), - }); - const empJson = await empRes.json(); - - if (empRes.ok && empJson?.data?.employee) { - if (empJson?.data?.employee) { - localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(empJson.data.employee)); - const prefLang = empJson.data.employee?.preferredLanguage; - if (prefLang === 'en' || prefLang === 'fr' || prefLang === 'ar') { - document.cookie = `rentaldrivego-language=${prefLang}; path=/; max-age=31536000; samesite=lax`; - } - } - window.dispatchEvent(new CustomEvent('rentaldrivego:auth-changed')); - window.location.replace(employeeRedirect); - return true; - } - - if (empJson?.error === 'password_not_set') { - setError(dict.passwordNotSet); - return true; - } - - if (empJson?.error === 'email_not_verified') { - setError(dict.emailNotVerified); - return true; - } - - if (empRes.status === 429) { - setError(dict.tooManyRequests); - return true; - } - - return false; - }; - - if (preferAdminAuth) { - if (await tryAdminLogin()) return; - setError(dict.invalidCredentials); + if (res.status === 401 && json?.error === 'totp_required') { + setStep('totp'); return; } - if (await tryEmployeeLogin()) return; - if (await tryAdminLogin()) return; + if (json?.error === 'password_not_set') { + setError(dict.passwordNotSet); + return; + } + + if (json?.error === 'email_not_verified') { + setError(dict.emailNotVerified); + return; + } + + if (res.status === 429) { + setError(dict.tooManyRequests); + return; + } setError(dict.invalidCredentials); } catch { @@ -239,7 +197,7 @@ export function SignInForm({ ? { totpCode: normalizedCode } : { recoveryCode: normalizedCode }; - const adminRes = await fetch(`${API_BASE}/admin/auth/login`, { + const adminRes = await fetch(`${API_BASE}/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', @@ -247,10 +205,7 @@ export function SignInForm({ }); const adminJson = await adminRes.json(); - if (adminRes.ok && adminJson?.data?.admin) { - window.location.href = `${window.location.origin}/admin/dashboard`; - return; - } + if (adminRes.ok && completeLogin(adminJson?.data)) return; setError(dict.invalidCredentials); } catch { diff --git a/apps/homepage/tests/unit/components/sign-in-form.test.tsx b/apps/homepage/tests/unit/components/sign-in-form.test.tsx index 76bd207..a9b355b 100644 --- a/apps/homepage/tests/unit/components/sign-in-form.test.tsx +++ b/apps/homepage/tests/unit/components/sign-in-form.test.tsx @@ -40,7 +40,7 @@ describe('SignInForm auth routing', () => { )); }); - it('uses employee login only on the default sign-in page', async () => { + it('uses unified login on the default sign-in page', async () => { vi.mocked(fetch).mockResolvedValueOnce( jsonResponse(200, { data: { employee: { id: 'employee_1' } } }), ); @@ -50,7 +50,7 @@ describe('SignInForm auth routing', () => { await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); expect(fetch).toHaveBeenCalledWith( - `${API_BASE}/auth/employee/login`, + `${API_BASE}/auth/login`, expect.objectContaining({ method: 'POST' }), ); expect(fetch).not.toHaveBeenCalledWith( @@ -59,30 +59,25 @@ describe('SignInForm auth routing', () => { ); }); - it('falls back to admin login when default employee login rejects the credentials', async () => { - vi.mocked(fetch) - .mockResolvedValueOnce(jsonResponse(401, { error: 'invalid_credentials' })) - .mockResolvedValueOnce(jsonResponse(401, { error: 'totp_required' })); - + it('shows invalid credentials when unified login rejects the credentials', async () => { render(); await submitForm(); - await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); expect(fetch).toHaveBeenNthCalledWith( 1, - `${API_BASE}/auth/employee/login`, + `${API_BASE}/auth/login`, expect.objectContaining({ method: 'POST' }), ); - expect(fetch).toHaveBeenNthCalledWith( - 2, + expect(fetch).not.toHaveBeenCalledWith( `${API_BASE}/admin/auth/login`, - expect.objectContaining({ method: 'POST' }), + expect.anything(), ); - expect(await screen.findByText('Authentication code')).toBeInTheDocument(); + expect(await screen.findByText('Invalid email or password.')).toBeInTheDocument(); }); - it('uses employee login only when the requested destination is a dashboard path', async () => { + it('uses unified login when the requested destination is a dashboard path', async () => { vi.mocked(fetch).mockResolvedValueOnce( jsonResponse(200, { data: { employee: { id: 'employee_1' } } }), ); @@ -93,7 +88,7 @@ describe('SignInForm auth routing', () => { await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); expect(fetch).toHaveBeenCalledWith( - `${API_BASE}/auth/employee/login`, + `${API_BASE}/auth/login`, expect.objectContaining({ method: 'POST' }), ); expect(fetch).not.toHaveBeenCalledWith( @@ -102,7 +97,7 @@ describe('SignInForm auth routing', () => { ); }); - it('uses admin login only when the admin portal is requested', async () => { + it('uses unified login when the admin portal is requested', async () => { searchParams = new URLSearchParams('portal=admin'); render(); @@ -110,7 +105,7 @@ describe('SignInForm auth routing', () => { await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); expect(fetch).toHaveBeenCalledWith( - `${API_BASE}/admin/auth/login`, + `${API_BASE}/auth/login`, expect.objectContaining({ method: 'POST' }), ); expect(fetch).not.toHaveBeenCalledWith( @@ -119,7 +114,7 @@ describe('SignInForm auth routing', () => { ); }); - it('uses admin login when the requested destination is an admin path', async () => { + it('uses unified login when the requested destination is an admin path', async () => { searchParams = new URLSearchParams('next=/admin/dashboard'); render(); @@ -127,12 +122,12 @@ describe('SignInForm auth routing', () => { await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); expect(fetch).toHaveBeenCalledWith( - `${API_BASE}/admin/auth/login`, + `${API_BASE}/auth/login`, expect.objectContaining({ method: 'POST' }), ); }); - it('uses admin login when the requested destination is an absolute admin URL', async () => { + it('uses unified login when the requested destination is an absolute admin URL', async () => { searchParams = new URLSearchParams('redirect=http://localhost:3000/admin/dashboard'); render(); @@ -140,7 +135,7 @@ describe('SignInForm auth routing', () => { await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); expect(fetch).toHaveBeenCalledWith( - `${API_BASE}/admin/auth/login`, + `${API_BASE}/auth/login`, expect.objectContaining({ method: 'POST' }), ); expect(fetch).not.toHaveBeenCalledWith( @@ -149,14 +144,14 @@ describe('SignInForm auth routing', () => { ); }); - it('uses admin login when the page forces admin auth mode', async () => { + it('uses unified login when the page forces admin auth mode', async () => { render(); await submitForm(); await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); expect(fetch).toHaveBeenCalledWith( - `${API_BASE}/admin/auth/login`, + `${API_BASE}/auth/login`, expect.objectContaining({ method: 'POST' }), ); expect(fetch).not.toHaveBeenCalledWith( @@ -165,7 +160,7 @@ describe('SignInForm auth routing', () => { ); }); - it('uses admin login on the admin sign-in path', async () => { + it('uses unified login on the admin sign-in path', async () => { pathname = '/en/light/admin-sign-in'; render(); @@ -173,7 +168,7 @@ describe('SignInForm auth routing', () => { await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); expect(fetch).toHaveBeenCalledWith( - `${API_BASE}/admin/auth/login`, + `${API_BASE}/auth/login`, expect.objectContaining({ method: 'POST' }), ); expect(fetch).not.toHaveBeenCalledWith(