fix both admin and company login
Build & Push / Pipeline Tests (push) Failing after 1m27s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 55s
Test / API Unit Tests (push) Successful in 1m9s
Test / Homepage Unit Tests (push) Successful in 50s
Test / Carplace Unit Tests (push) Has been cancelled
Test / Admin Unit Tests (push) Has been cancelled
Test / Dashboard Unit Tests (push) Has been cancelled
Test / API Integration Tests (push) Has been cancelled
Build & Push / Pipeline Tests (push) Failing after 1m27s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 55s
Test / API Unit Tests (push) Successful in 1m9s
Test / Homepage Unit Tests (push) Successful in 50s
Test / Carplace Unit Tests (push) Has been cancelled
Test / Admin Unit Tests (push) Has been cancelled
Test / Dashboard Unit Tests (push) Has been cancelled
Test / API Integration Tests (push) Has been cancelled
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -101,17 +101,6 @@ const dicts: Record<string, Dict> = {
|
||||
},
|
||||
};
|
||||
|
||||
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<string | null>(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 {
|
||||
|
||||
@@ -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(<SignInForm locale="en" />);
|
||||
|
||||
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(<SignInForm locale="en" />);
|
||||
|
||||
@@ -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(<SignInForm locale="en" />);
|
||||
|
||||
@@ -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(<SignInForm locale="en" />);
|
||||
|
||||
@@ -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(<SignInForm locale="en" authMode="admin" />);
|
||||
|
||||
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(<SignInForm locale="en" />);
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user