diff --git a/.env.docker.production.example b/.env.docker.production.example index 19b7c23..285e36d 100644 --- a/.env.docker.production.example +++ b/.env.docker.production.example @@ -58,7 +58,7 @@ NODE_ENV=production EMAIL_FROM=rentaldrivego@gmail.com EMAIL_FROM_NAME=RentalDriveGo # Option A — Resend -RESEND_API_KEY=C8qPDuFwsv5l@KsGhL/V +#RESEND_API_KEY=C8qPDuFwsv5l@KsGhL/V # Option B — SMTP (Gmail) MAIL_HOST=smtp.gmail.com MAIL_PORT=587 diff --git a/apps/api/src/modules/admin/admin.repo.test.ts b/apps/api/src/modules/admin/admin.repo.test.ts new file mode 100644 index 0000000..d2bea7a --- /dev/null +++ b/apps/api/src/modules/admin/admin.repo.test.ts @@ -0,0 +1,31 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../../lib/prisma', () => ({ + prisma: { + adminUser: { + findFirst: vi.fn(), + }, + }, +})) + +import { prisma } from '../../lib/prisma' +import { findAdminByEmail } from './admin.repo' + +describe('admin.repo', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('looks up admin email case-insensitively', async () => { + await findAdminByEmail('Admin@Example.com') + + expect(prisma.adminUser.findFirst).toHaveBeenCalledWith({ + where: { + email: { + equals: 'Admin@Example.com', + mode: 'insensitive', + }, + }, + }) + }) +}) diff --git a/apps/api/src/modules/admin/admin.repo.ts b/apps/api/src/modules/admin/admin.repo.ts index 46258d1..af1cd79 100644 --- a/apps/api/src/modules/admin/admin.repo.ts +++ b/apps/api/src/modules/admin/admin.repo.ts @@ -31,7 +31,14 @@ function parseOptionalDate(value?: string | null) { } export function findAdminByEmail(email: string) { - return prisma.adminUser.findUnique({ where: { email } }) + return prisma.adminUser.findFirst({ + where: { + email: { + equals: email, + mode: 'insensitive', + }, + }, + }) } export function findAdminByIdOrThrow(id: string) { diff --git a/apps/api/src/modules/admin/admin.service.test.ts b/apps/api/src/modules/admin/admin.service.test.ts new file mode 100644 index 0000000..87d2500 --- /dev/null +++ b/apps/api/src/modules/admin/admin.service.test.ts @@ -0,0 +1,49 @@ +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('./admin.repo', () => ({ + findAdminByEmail: vi.fn(), + setAdminPasswordReset: vi.fn(), +})) + +vi.mock('../../services/notificationService', () => ({ + sendTransactionalEmail: vi.fn().mockResolvedValue(undefined), +})) + +import * as repo from './admin.repo' +import { sendTransactionalEmail } from '../../services/notificationService' +import { forgotPassword } from './admin.service' + +describe('admin.service forgotPassword', () => { + const originalAdminUrl = process.env.ADMIN_URL + + beforeEach(() => { + vi.clearAllMocks() + process.env.ADMIN_URL = 'http://localhost:3002' + }) + + afterAll(() => { + process.env.ADMIN_URL = originalAdminUrl + }) + + it('sends the reset email to the canonical stored admin address', async () => { + vi.mocked(repo.findAdminByEmail).mockResolvedValue({ + id: 'admin_1', + email: 'admin@rentaldrivego.com', + firstName: 'Samir', + isActive: true, + } as any) + + await forgotPassword('Admin@RentalDriveGo.com') + + expect(repo.setAdminPasswordReset).toHaveBeenCalledWith( + 'admin_1', + expect.any(String), + expect.any(Date), + ) + expect(sendTransactionalEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'admin@rentaldrivego.com', + }), + ) + }) +}) diff --git a/apps/api/src/modules/admin/admin.service.ts b/apps/api/src/modules/admin/admin.service.ts index e523afe..e64d923 100644 --- a/apps/api/src/modules/admin/admin.service.ts +++ b/apps/api/src/modules/admin/admin.service.ts @@ -93,7 +93,7 @@ export async function forgotPassword(email: string) { const resetUrl = `${adminUrl}/reset-password?token=${rawToken}` await sendTransactionalEmail({ - to: email, + to: admin.email, subject: 'Reset your RentalDriveGo admin password', html: `
Hi ${admin.firstName},
Expires in ${ADMIN_RESET_TTL_MINUTES} minutes.
`, text: `Hi ${admin.firstName},\n\nReset here: ${resetUrl}\n\nExpires in ${ADMIN_RESET_TTL_MINUTES} minutes.`, diff --git a/apps/api/src/modules/auth/auth.employee.repo.test.ts b/apps/api/src/modules/auth/auth.employee.repo.test.ts new file mode 100644 index 0000000..ecb93f6 --- /dev/null +++ b/apps/api/src/modules/auth/auth.employee.repo.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../../lib/prisma', () => ({ + prisma: { + employee: { + findFirst: vi.fn(), + }, + }, +})) + +import { prisma } from '../../lib/prisma' +import { findActiveEmployeeByEmail, findEmployeeWithCompanyByEmail } from './auth.employee.repo' + +describe('auth.employee.repo', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('looks up employee login email case-insensitively', async () => { + await findEmployeeWithCompanyByEmail('Owner@Example.com') + + expect(prisma.employee.findFirst).toHaveBeenCalledWith({ + where: { + email: { + equals: 'Owner@Example.com', + mode: 'insensitive', + }, + }, + include: { company: true }, + }) + }) + + it('looks up active employee reset email case-insensitively', async () => { + await findActiveEmployeeByEmail('Owner@Example.com') + + expect(prisma.employee.findFirst).toHaveBeenCalledWith({ + where: { + email: { + equals: 'Owner@Example.com', + mode: 'insensitive', + }, + isActive: true, + }, + }) + }) +}) diff --git a/apps/api/src/modules/auth/auth.employee.repo.ts b/apps/api/src/modules/auth/auth.employee.repo.ts index fba6b6a..b93f8b2 100644 --- a/apps/api/src/modules/auth/auth.employee.repo.ts +++ b/apps/api/src/modules/auth/auth.employee.repo.ts @@ -9,14 +9,25 @@ export function findEmployeeWithCompanyById(id: string) { export function findEmployeeWithCompanyByEmail(email: string) { return prisma.employee.findFirst({ - where: { email }, + where: { + email: { + equals: email, + mode: 'insensitive', + }, + }, include: { company: true }, }) } export function findActiveEmployeeByEmail(email: string) { return prisma.employee.findFirst({ - where: { email, isActive: true }, + where: { + email: { + equals: email, + mode: 'insensitive', + }, + isActive: true, + }, }) } diff --git a/apps/api/src/modules/auth/auth.employee.service.test.ts b/apps/api/src/modules/auth/auth.employee.service.test.ts new file mode 100644 index 0000000..42727de --- /dev/null +++ b/apps/api/src/modules/auth/auth.employee.service.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('./auth.employee.repo', () => ({ + findActiveEmployeeByEmail: vi.fn(), + setPasswordResetToken: vi.fn(), +})) + +vi.mock('../../services/notificationService', () => ({ + sendTransactionalEmail: vi.fn().mockResolvedValue(undefined), +})) + +import * as repo from './auth.employee.repo' +import { sendTransactionalEmail } from '../../services/notificationService' +import { forgotPassword } from './auth.employee.service' + +describe('auth.employee.service forgotPassword', () => { + const originalDashboardUrl = process.env.DASHBOARD_URL + + beforeEach(() => { + vi.clearAllMocks() + process.env.DASHBOARD_URL = 'http://localhost:3001' + }) + + afterAll(() => { + process.env.DASHBOARD_URL = originalDashboardUrl + }) + + it('sends the reset email to the canonical stored address', async () => { + vi.mocked(repo.findActiveEmployeeByEmail).mockResolvedValue({ + id: 'emp_1', + email: 'owner@company.com', + firstName: 'Aya', + preferredLanguage: 'en', + } as any) + + await forgotPassword('Owner@Company.com') + + expect(repo.setPasswordResetToken).toHaveBeenCalledWith( + 'emp_1', + expect.any(String), + expect.any(Date), + ) + expect(sendTransactionalEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'owner@company.com', + }), + ) + }) +}) diff --git a/apps/api/src/modules/auth/auth.employee.service.ts b/apps/api/src/modules/auth/auth.employee.service.ts index 6eb3104..f23dede 100644 --- a/apps/api/src/modules/auth/auth.employee.service.ts +++ b/apps/api/src/modules/auth/auth.employee.service.ts @@ -78,7 +78,7 @@ export async function forgotPassword(email: string) { const lang = (employee.preferredLanguage as Lang) ?? 'fr' await sendTransactionalEmail({ - to: email, + to: employee.email, subject: resetPasswordEmail.subject(lang), html: resetPasswordEmail.html(resetUrl, employee.firstName, RESET_TOKEN_TTL_MINUTES, lang), text: resetPasswordEmail.text(resetUrl, employee.firstName, RESET_TOKEN_TTL_MINUTES, lang), diff --git a/apps/api/src/services/teamService.test.ts b/apps/api/src/services/teamService.test.ts new file mode 100644 index 0000000..6b12248 --- /dev/null +++ b/apps/api/src/services/teamService.test.ts @@ -0,0 +1,70 @@ +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../lib/prisma', () => ({ + prisma: { + employee: { + findFirst: vi.fn(), + create: vi.fn(), + }, + company: { + findUniqueOrThrow: vi.fn(), + }, + }, +})) + +vi.mock('./notificationService', () => ({ + sendTransactionalEmail: vi.fn().mockResolvedValue(undefined), +})) + +import crypto from 'crypto' +import { prisma } from '../lib/prisma' +import { sendTransactionalEmail } from './notificationService' +import { inviteEmployee } from './teamService' + +describe('teamService inviteEmployee', () => { + const originalDashboardUrl = process.env.DASHBOARD_URL + const originalPublicDashboardUrl = process.env.NEXT_PUBLIC_DASHBOARD_URL + + beforeEach(() => { + vi.clearAllMocks() + process.env.DASHBOARD_URL = 'http://localhost:3001' + process.env.NEXT_PUBLIC_DASHBOARD_URL = '' + + vi.spyOn(crypto, 'randomBytes').mockReturnValue(Buffer.from('token-123')) + vi.spyOn(crypto, 'randomUUID').mockReturnValue('uuid-123') + + vi.mocked(prisma.employee.findFirst).mockResolvedValue(null as any) + vi.mocked(prisma.company.findUniqueOrThrow).mockResolvedValue({ name: 'Atlas Cars' } as any) + vi.mocked(prisma.employee.create).mockResolvedValue({ + id: 'emp_1', + firstName: 'Aya', + lastName: 'Benali', + email: 'aya@example.com', + role: 'AGENT', + isActive: true, + } as any) + }) + + afterAll(() => { + process.env.DASHBOARD_URL = originalDashboardUrl + process.env.NEXT_PUBLIC_DASHBOARD_URL = originalPublicDashboardUrl + vi.restoreAllMocks() + }) + + it('builds invite reset links under the dashboard base path', async () => { + await inviteEmployee('company_1', 'owner_1', { + firstName: 'Aya', + lastName: 'Benali', + email: 'aya@example.com', + role: 'AGENT', + }) + + expect(sendTransactionalEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'aya@example.com', + html: expect.stringContaining('http://localhost:3001/dashboard/reset-password?token=746f6b656e2d313233'), + text: expect.stringContaining('http://localhost:3001/dashboard/reset-password?token=746f6b656e2d313233'), + }), + ) + }) +}) diff --git a/apps/api/src/services/teamService.ts b/apps/api/src/services/teamService.ts index 050402a..b42b9fe 100644 --- a/apps/api/src/services/teamService.ts +++ b/apps/api/src/services/teamService.ts @@ -27,6 +27,18 @@ export interface TeamMember { invitationStatus?: 'accepted' | 'pending' | 'revoked' } +function ensureDashboardBasePath(baseUrl: string) { + try { + const url = new URL(baseUrl) + const pathname = url.pathname.replace(/\/$/, '') + if (!pathname.endsWith('/dashboard')) url.pathname = `${pathname}/dashboard` + return url.toString().replace(/\/$/, '') + } catch { + const trimmed = baseUrl.replace(/\/$/, '') + return trimmed.endsWith('/dashboard') ? trimmed : `${trimmed}/dashboard` + } +} + function buildInviteEmail(resetUrl: string, companyName: string, firstName: string, role: EmployeeRole) { const greetingName = firstName.trim() || 'there' @@ -84,7 +96,9 @@ export async function inviteEmployee(companyId: string, inviterId: string, paylo }, }) - const dashboardUrl = process.env.DASHBOARD_URL ?? 'http://localhost:3001' + const dashboardUrl = ensureDashboardBasePath( + process.env.DASHBOARD_URL ?? process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001/dashboard', + ) const resetUrl = `${dashboardUrl}/reset-password?token=${rawToken}` const message = buildInviteEmail(resetUrl, company.name, payload.firstName, payload.role) diff --git a/docker-compose.production.yml b/docker-compose.production.yml index 0bafb60..519d004 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -164,8 +164,12 @@ services: condition: service_healthy environment: PGMANAGE_LISTEN_PORT: "8000" + PGMANAGE_DEFAULT_USERNAME: ${PGMANAGE_DEFAULT_USERNAME:-admin} + PGMANAGE_DEFAULT_PASSWORD: ${PGMANAGE_DEFAULT_PASSWORD:-admin} + PGMANAGE_SECURE_COOKIES: "True" volumes: - pgmanage_prod_data:/appdata + - ./docker/pgmanage/override.py:/appdata/override.py:ro labels: - traefik.enable=true - traefik.http.routers.pgmanage.rule=Host(`${PGMANAGE_DOMAIN}`) diff --git a/docker/pgmanage/override.py b/docker/pgmanage/override.py new file mode 100644 index 0000000..50f6d2f --- /dev/null +++ b/docker/pgmanage/override.py @@ -0,0 +1,6 @@ +CSRF_TRUSTED_ORIGINS = ["https://pgmanage.rentaldrivego.ma"] +ALLOWED_HOSTS = ["pgmanage.rentaldrivego.ma"] +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") +USE_X_FORWARDED_HOST = True +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True