From 0e4c0d77858149a8749556fb7e7c2d4b6ceee3c8 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 1 Apr 2026 19:51:57 +0200 Subject: [PATCH] fix email and documentation api --- CLAUDE.md | 14 +- apps/backend/.env.example | 14 +- .../src/application/admin/admin.module.ts | 4 + .../controllers/admin.controller.ts | 74 ++- .../controllers/invitations.controller.ts | 24 + .../src/application/dto/invitation.dto.ts | 1 + .../services/csv-booking.service.ts | 44 ++ .../services/invitation.service.ts | 19 + .../src/domain/ports/out/email.port.ts | 1 + .../ports/out/invitation-token.repository.ts | 5 + .../src/infrastructure/email/email.adapter.ts | 163 ++++-- .../typeorm-invitation-token.repository.ts | 4 + .../app/dashboard/settings/users/page.tsx | 507 ++++++++++-------- apps/frontend/src/lib/api/invitations.ts | 9 +- 14 files changed, 616 insertions(+), 267 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3f17afb..02f2b3b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,9 +33,10 @@ npm run frontend:dev # http://localhost:3000 ```bash # Backend (from apps/backend/) -npm test # Unit tests (Jest) -npm test -- booking.entity.spec.ts # Single file -npm run test:cov # With coverage +npm test # Unit tests (Jest) +npm test -- booking.entity.spec.ts # Single file +npm test -- --testNamePattern="should create" # Filter by test name +npm run test:cov # With coverage npm run test:integration # Integration tests (needs DB/Redis, 30s timeout) npm run test:e2e # E2E tests @@ -75,6 +76,7 @@ npm run migration:revert ```bash npm run backend:build # NestJS build with tsc-alias for path resolution npm run frontend:build # Next.js production build (standalone output) +npm run clean # Remove all node_modules, dist, .next directories ``` ## Local Infrastructure @@ -210,6 +212,12 @@ All other routes redirect to `/login?redirect=` when the cookie is abs - `@Roles()` — role-based access control - `@CurrentUser()` — inject authenticated user +### API Key Authentication +A second auth mechanism alongside JWT. `ApiKey` domain entity (`domain/entities/api-key.entity.ts`) — keys are hashed with Argon2. `ApiKeyGuard` in `application/guards/` checks the `x-api-key` header. Routes can accept either JWT or API key; see `admin.controller.ts` for examples. + +### WebSocket (Real-time Notifications) +Socket.IO gateway at `application/gateways/notifications.gateway.ts`. Clients connect to `/` namespace with a JWT bearer token in the handshake auth. Server emits `notification` events. The frontend `useNotifications` hook handles subscriptions. + ### Carrier Connectors Five carrier connectors (Maersk, MSC, CMA CGM, Hapag-Lloyd, ONE) extending `base-carrier.connector.ts`, each with request/response mappers. Circuit breaker via `opossum` (5s timeout). diff --git a/apps/backend/.env.example b/apps/backend/.env.example index a91ee8a..7db48cd 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -37,12 +37,14 @@ MICROSOFT_CALLBACK_URL=http://localhost:4000/api/v1/auth/microsoft/callback APP_URL=http://localhost:3000 # Email (SMTP) -SMTP_HOST=smtp.sendgrid.net -SMTP_PORT=587 -SMTP_SECURE=false -SMTP_USER=apikey -SMTP_PASS=your-sendgrid-api-key -SMTP_FROM=noreply@xpeditis.com + SMTP_HOST=smtp-relay.brevo.com + SMTP_PORT=587 + SMTP_USER=ton-email@brevo.com + SMTP_PASS=ta-cle-smtp-brevo + SMTP_SECURE=false + +# SMTP_FROM devient le fallback uniquement (chaque méthode a son propre from maintenant) + SMTP_FROM=noreply@xpeditis.com # AWS S3 / Storage (or MinIO for development) AWS_ACCESS_KEY_ID=your-aws-access-key diff --git a/apps/backend/src/application/admin/admin.module.ts b/apps/backend/src/application/admin/admin.module.ts index d3435ad..dd92262 100644 --- a/apps/backend/src/application/admin/admin.module.ts +++ b/apps/backend/src/application/admin/admin.module.ts @@ -26,6 +26,9 @@ import { PappersSiretAdapter } from '@infrastructure/external/pappers-siret.adap // CSV Booking Service import { CsvBookingsModule } from '../csv-bookings.module'; +// Email +import { EmailModule } from '@infrastructure/email/email.module'; + /** * Admin Module * @@ -37,6 +40,7 @@ import { CsvBookingsModule } from '../csv-bookings.module'; TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, CsvBookingOrmEntity]), ConfigModule, CsvBookingsModule, + EmailModule, ], controllers: [AdminController], providers: [ diff --git a/apps/backend/src/application/controllers/admin.controller.ts b/apps/backend/src/application/controllers/admin.controller.ts index b1f0666..d3c314f 100644 --- a/apps/backend/src/application/controllers/admin.controller.ts +++ b/apps/backend/src/application/controllers/admin.controller.ts @@ -53,6 +53,9 @@ import { SIRET_VERIFICATION_PORT, } from '@domain/ports/out/siret-verification.port'; +// Email imports +import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port'; + /** * Admin Controller * @@ -76,7 +79,8 @@ export class AdminController { private readonly csvBookingRepository: TypeOrmCsvBookingRepository, private readonly csvBookingService: CsvBookingService, @Inject(SIRET_VERIFICATION_PORT) - private readonly siretVerificationPort: SiretVerificationPort + private readonly siretVerificationPort: SiretVerificationPort, + @Inject(EMAIL_PORT) private readonly emailPort: EmailPort ) {} // ==================== USERS ENDPOINTS ==================== @@ -608,6 +612,30 @@ export class AdminController { return this.csvBookingToDto(updatedBooking); } + /** + * Resend carrier email for a booking (admin only) + * + * Manually sends the booking request email to the carrier. + * Useful when the automatic email failed (SMTP error) or for testing without Stripe. + */ + @Post('bookings/:id/resend-carrier-email') + @ApiOperation({ + summary: 'Resend carrier email (Admin only)', + description: + 'Manually resend the booking request email to the carrier. Works regardless of payment status.', + }) + @ApiParam({ name: 'id', description: 'Booking ID (UUID)' }) + @ApiResponse({ status: 200, description: 'Email sent to carrier' }) + @ApiNotFoundResponse({ description: 'Booking not found' }) + async resendCarrierEmail( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: UserPayload + ) { + this.logger.log(`[ADMIN: ${user.email}] Resending carrier email for booking: ${id}`); + await this.csvBookingService.resendCarrierEmail(id); + return { success: true, message: 'Email sent to carrier' }; + } + /** * Validate bank transfer for a booking (admin only) * @@ -701,6 +729,50 @@ export class AdminController { }; } + // ==================== EMAIL TEST ENDPOINT ==================== + + /** + * Send a test email to verify SMTP configuration (admin only) + * + * Returns the exact SMTP error in the response instead of only logging it. + */ + @Post('test-email') + @ApiOperation({ + summary: 'Send test email (Admin only)', + description: + 'Sends a simple test email to the given address. Returns the exact SMTP error if delivery fails — useful for diagnosing Brevo/SMTP issues.', + }) + @ApiResponse({ status: 200, description: 'Email sent successfully' }) + @ApiResponse({ status: 400, description: 'SMTP error — check the message field' }) + async sendTestEmail( + @Body() body: { to: string }, + @CurrentUser() user: UserPayload + ) { + if (!body?.to) { + throw new BadRequestException('Field "to" is required'); + } + + this.logger.log(`[ADMIN: ${user.email}] Sending test email to ${body.to}`); + + try { + await this.emailPort.send({ + to: body.to, + subject: '[Xpeditis] Test SMTP', + html: `

Email de test envoyé depuis le panel admin par ${user.email}.

Si vous lisez ceci, la configuration SMTP fonctionne correctement.

`, + text: `Email de test envoyé par ${user.email}. Si vous lisez ceci, le SMTP fonctionne.`, + }); + + this.logger.log(`[ADMIN] Test email sent successfully to ${body.to}`); + return { success: true, message: `Email envoyé avec succès à ${body.to}` }; + } catch (error: any) { + this.logger.error(`[ADMIN] Test email FAILED to ${body.to}: ${error?.message}`, error?.stack); + throw new BadRequestException( + `Échec SMTP — ${error?.message ?? 'erreur inconnue'}. ` + + `Code: ${error?.code ?? 'N/A'}, Response: ${error?.response ?? 'N/A'}` + ); + } + } + // ==================== DOCUMENTS ENDPOINTS ==================== /** diff --git a/apps/backend/src/application/controllers/invitations.controller.ts b/apps/backend/src/application/controllers/invitations.controller.ts index 57b01b1..e596276 100644 --- a/apps/backend/src/application/controllers/invitations.controller.ts +++ b/apps/backend/src/application/controllers/invitations.controller.ts @@ -2,6 +2,7 @@ import { Controller, Post, Get, + Delete, Body, UseGuards, HttpCode, @@ -137,6 +138,29 @@ export class InvitationsController { }; } + /** + * Cancel (delete) a pending invitation + */ + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin', 'manager') + @ApiBearerAuth() + @ApiOperation({ + summary: 'Cancel invitation', + description: 'Delete a pending invitation. Admin/manager only.', + }) + @ApiResponse({ status: 204, description: 'Invitation cancelled' }) + @ApiResponse({ status: 404, description: 'Invitation not found' }) + @ApiResponse({ status: 400, description: 'Invitation already used' }) + async cancelInvitation( + @Param('id') id: string, + @CurrentUser() user: UserPayload + ): Promise { + this.logger.log(`[User: ${user.email}] Cancelling invitation: ${id}`); + await this.invitationService.cancelInvitation(id, user.organizationId); + } + /** * List organization invitations */ diff --git a/apps/backend/src/application/dto/invitation.dto.ts b/apps/backend/src/application/dto/invitation.dto.ts index 07aa0c4..e8840f8 100644 --- a/apps/backend/src/application/dto/invitation.dto.ts +++ b/apps/backend/src/application/dto/invitation.dto.ts @@ -2,6 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsEmail, IsString, MinLength, IsEnum, IsOptional } from 'class-validator'; export enum InvitationRole { + ADMIN = 'ADMIN', MANAGER = 'MANAGER', USER = 'USER', VIEWER = 'VIEWER', diff --git a/apps/backend/src/application/services/csv-booking.service.ts b/apps/backend/src/application/services/csv-booking.service.ts index 9064a7a..5588347 100644 --- a/apps/backend/src/application/services/csv-booking.service.ts +++ b/apps/backend/src/application/services/csv-booking.service.ts @@ -447,6 +447,50 @@ export class CsvBookingService { return this.toResponseDto(updatedBooking); } + /** + * Resend carrier email for a booking (admin action) + * Works regardless of payment status — useful for retrying failed emails or testing without Stripe. + */ + async resendCarrierEmail(bookingId: string): Promise { + const booking = await this.csvBookingRepository.findById(bookingId); + + if (!booking) { + throw new NotFoundException(`Booking with ID ${bookingId} not found`); + } + + const ormBooking = await this.csvBookingRepository['repository'].findOne({ + where: { id: bookingId }, + }); + const bookingNumber = ormBooking?.bookingNumber; + const documentPassword = bookingNumber + ? this.extractPasswordFromBookingNumber(bookingNumber) + : undefined; + + await this.emailAdapter.sendCsvBookingRequest(booking.carrierEmail, { + bookingId: booking.id, + bookingNumber: bookingNumber || '', + documentPassword: documentPassword || '', + origin: booking.origin.getValue(), + destination: booking.destination.getValue(), + volumeCBM: booking.volumeCBM, + weightKG: booking.weightKG, + palletCount: booking.palletCount, + priceUSD: booking.priceUSD, + priceEUR: booking.priceEUR, + primaryCurrency: booking.primaryCurrency, + transitDays: booking.transitDays, + containerType: booking.containerType, + documents: booking.documents.map(doc => ({ + type: doc.type, + fileName: doc.fileName, + })), + confirmationToken: booking.confirmationToken, + notes: booking.notes, + }); + + this.logger.log(`[ADMIN] Carrier email resent to ${booking.carrierEmail} for booking ${bookingId}`); + } + /** * Admin validates bank transfer — confirms receipt and activates booking * Transitions booking from PENDING_BANK_TRANSFER → PENDING then sends email to carrier diff --git a/apps/backend/src/application/services/invitation.service.ts b/apps/backend/src/application/services/invitation.service.ts index ec95a65..06ff751 100644 --- a/apps/backend/src/application/services/invitation.service.ts +++ b/apps/backend/src/application/services/invitation.service.ts @@ -220,6 +220,25 @@ export class InvitationService { } } + /** + * Cancel (delete) a pending invitation + */ + async cancelInvitation(invitationId: string, organizationId: string): Promise { + const invitations = await this.invitationRepository.findByOrganization(organizationId); + const invitation = invitations.find(inv => inv.id === invitationId); + + if (!invitation) { + throw new NotFoundException('Invitation not found'); + } + + if (invitation.isUsed) { + throw new BadRequestException('Cannot delete an invitation that has already been used'); + } + + await this.invitationRepository.deleteById(invitationId); + this.logger.log(`Invitation ${invitationId} cancelled`); + } + /** * Cleanup expired invitations (can be called by a cron job) */ diff --git a/apps/backend/src/domain/ports/out/email.port.ts b/apps/backend/src/domain/ports/out/email.port.ts index 75c1375..596293b 100644 --- a/apps/backend/src/domain/ports/out/email.port.ts +++ b/apps/backend/src/domain/ports/out/email.port.ts @@ -15,6 +15,7 @@ export interface EmailAttachment { export interface EmailOptions { to: string | string[]; + from?: string; cc?: string | string[]; bcc?: string | string[]; replyTo?: string; diff --git a/apps/backend/src/domain/ports/out/invitation-token.repository.ts b/apps/backend/src/domain/ports/out/invitation-token.repository.ts index b3fcc59..285c575 100644 --- a/apps/backend/src/domain/ports/out/invitation-token.repository.ts +++ b/apps/backend/src/domain/ports/out/invitation-token.repository.ts @@ -35,6 +35,11 @@ export interface InvitationTokenRepository { */ deleteExpired(): Promise; + /** + * Delete an invitation by id + */ + deleteById(id: string): Promise; + /** * Update an invitation token */ diff --git a/apps/backend/src/infrastructure/email/email.adapter.ts b/apps/backend/src/infrastructure/email/email.adapter.ts index d32ea2a..78501d1 100644 --- a/apps/backend/src/infrastructure/email/email.adapter.ts +++ b/apps/backend/src/infrastructure/email/email.adapter.ts @@ -4,69 +4,157 @@ * Implements EmailPort using nodemailer */ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as nodemailer from 'nodemailer'; +import * as https from 'https'; import { EmailPort, EmailOptions } from '@domain/ports/out/email.port'; import { EmailTemplates } from './templates/email-templates'; +// Display names included → moins susceptibles d'être marqués spam +const EMAIL_SENDERS = { + SECURITY: '"Xpeditis Sécurité" ', + BOOKINGS: '"Xpeditis Bookings" ', + TEAM: '"Équipe Xpeditis" ', + CARRIERS: '"Xpeditis Transporteurs" ', + NOREPLY: '"Xpeditis" ', +} as const; + +/** + * Génère une version plain text à partir du HTML pour améliorer la délivrabilité. + * Les emails sans version texte sont pénalisés par les filtres anti-spam. + */ +function htmlToPlainText(html: string): string { + return html + .replace(/]*>[\s\S]*?<\/style>/gi, '') + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(//gi, '\n') + .replace(/<\/p>/gi, '\n\n') + .replace(/<\/div>/gi, '\n') + .replace(/<\/h[1-6]>/gi, '\n\n') + .replace(/]*href="([^"]*)"[^>]*>([^<]*)<\/a>/gi, '$2 ($1)') + .replace(/<[^>]+>/g, '') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/ /g, ' ') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + @Injectable() -export class EmailAdapter implements EmailPort { +export class EmailAdapter implements EmailPort, OnModuleInit { private readonly logger = new Logger(EmailAdapter.name); private transporter: nodemailer.Transporter; constructor( private readonly configService: ConfigService, private readonly emailTemplates: EmailTemplates - ) { - this.initializeTransporter(); + ) {} + + async onModuleInit(): Promise { + const host = this.configService.get('SMTP_HOST', 'localhost'); + + // 🔧 FIX: Mailtrap — IP directe hardcodée + if (host.includes('mailtrap.io')) { + this.buildTransporter('3.209.246.195', host); + return; + } + + // 🔧 FIX: DNS over HTTPS — contourne le port 53 UDP (bloqué sur certains réseaux). + // On appelle l'API DoH de Cloudflare via HTTPS (port 443) pour résoudre l'IP + // AVANT de créer le transporter, puis on passe l'IP directement à nodemailer. + if (!/^\d+\.\d+\.\d+\.\d+$/.test(host) && host !== 'localhost') { + try { + const ip = await this.resolveViaDoH(host); + this.logger.log(`[DNS-DoH] ${host} → ${ip}`); + this.buildTransporter(ip, host); + return; + } catch (err: any) { + this.logger.warn(`[DNS-DoH] Failed to resolve ${host}: ${err.message} — using hostname directly`); + } + } + + this.buildTransporter(host, host); } - private initializeTransporter(): void { - const host = this.configService.get('SMTP_HOST', 'localhost'); + /** + * Résout un hostname en IP via l'API DNS over HTTPS de Cloudflare. + * Utilise HTTPS (port 443) donc fonctionne même quand le port 53 UDP est bloqué. + */ + private resolveViaDoH(hostname: string): Promise { + return new Promise((resolve, reject) => { + const url = `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(hostname)}&type=A`; + const req = https.get(url, { headers: { Accept: 'application/dns-json' } }, (res) => { + let raw = ''; + res.on('data', (chunk) => (raw += chunk)); + res.on('end', () => { + try { + const json = JSON.parse(raw); + const aRecord = (json.Answer ?? []).find((r: any) => r.type === 1); + if (aRecord?.data) { + resolve(aRecord.data); + } else { + reject(new Error(`No A record returned by DoH for ${hostname}`)); + } + } catch (e) { + reject(e); + } + }); + }); + req.on('error', reject); + req.setTimeout(10000, () => { + req.destroy(); + reject(new Error('DoH request timed out')); + }); + }); + } + + private buildTransporter(actualHost: string, serverName: string): void { const port = this.configService.get('SMTP_PORT', 2525); const user = this.configService.get('SMTP_USER'); const pass = this.configService.get('SMTP_PASS'); const secure = this.configService.get('SMTP_SECURE', false); - // 🔧 FIX: Contournement DNS pour Mailtrap - // Utilise automatiquement l'IP directe quand 'mailtrap.io' est détecté - // Cela évite les timeouts DNS (queryA ETIMEOUT) sur certains réseaux - const useDirectIP = host.includes('mailtrap.io'); - const actualHost = useDirectIP ? '3.209.246.195' : host; - const serverName = useDirectIP ? 'smtp.mailtrap.io' : host; // Pour TLS - this.transporter = nodemailer.createTransport({ host: actualHost, port, secure, - auth: { - user, - pass, - }, - // Configuration TLS avec servername pour IP directe + auth: { user, pass }, tls: { rejectUnauthorized: false, - servername: serverName, // ⚠️ CRITIQUE pour TLS avec IP directe + servername: serverName, }, - // Timeouts optimisés - connectionTimeout: 10000, // 10s - greetingTimeout: 10000, // 10s - socketTimeout: 30000, // 30s - dnsTimeout: 10000, // 10s - }); + connectionTimeout: 15000, + greetingTimeout: 15000, + socketTimeout: 30000, + } as any); this.logger.log( - `Email adapter initialized with SMTP host: ${host}:${port} (secure: ${secure})` + - (useDirectIP ? ` [Using direct IP: ${actualHost} with servername: ${serverName}]` : '') + `Email transporter ready — ${serverName}:${port} (IP: ${actualHost}) user: ${user}` ); + + this.transporter.verify((error) => { + if (error) { + this.logger.error(`❌ SMTP connection FAILED: ${error.message}`); + } else { + this.logger.log(`✅ SMTP connection verified — ready to send emails`); + } + }); } async send(options: EmailOptions): Promise { try { - const from = this.configService.get('SMTP_FROM', 'noreply@xpeditis.com'); + const from = + options.from ?? + this.configService.get('SMTP_FROM', EMAIL_SENDERS.NOREPLY); - await this.transporter.sendMail({ + // Génère automatiquement la version plain text si absente (améliore le score anti-spam) + const text = options.text ?? (options.html ? htmlToPlainText(options.html) : undefined); + + const info = await this.transporter.sendMail({ from, to: options.to, cc: options.cc, @@ -74,11 +162,13 @@ export class EmailAdapter implements EmailPort { replyTo: options.replyTo, subject: options.subject, html: options.html, - text: options.text, + text, attachments: options.attachments, }); - this.logger.log(`Email sent to ${options.to}: ${options.subject}`); + this.logger.log( + `✅ Email submitted — to: ${options.to} | from: ${from} | subject: "${options.subject}" | messageId: ${info.messageId} | accepted: ${JSON.stringify(info.accepted)} | rejected: ${JSON.stringify(info.rejected)}` + ); } catch (error) { this.logger.error(`Failed to send email to ${options.to}`, error); throw error; @@ -108,6 +198,7 @@ export class EmailAdapter implements EmailPort { await this.send({ to: email, + from: EMAIL_SENDERS.BOOKINGS, subject: `Booking Confirmation - ${bookingNumber}`, html, attachments, @@ -122,6 +213,7 @@ export class EmailAdapter implements EmailPort { await this.send({ to: email, + from: EMAIL_SENDERS.SECURITY, subject: 'Verify your email - Xpeditis', html, }); @@ -135,6 +227,7 @@ export class EmailAdapter implements EmailPort { await this.send({ to: email, + from: EMAIL_SENDERS.SECURITY, subject: 'Reset your password - Xpeditis', html, }); @@ -148,6 +241,7 @@ export class EmailAdapter implements EmailPort { await this.send({ to: email, + from: EMAIL_SENDERS.NOREPLY, subject: 'Welcome to Xpeditis', html, }); @@ -169,6 +263,7 @@ export class EmailAdapter implements EmailPort { await this.send({ to: email, + from: EMAIL_SENDERS.TEAM, subject: `You've been invited to join ${organizationName} on Xpeditis`, html, }); @@ -209,6 +304,7 @@ export class EmailAdapter implements EmailPort { await this.send({ to: email, + from: EMAIL_SENDERS.TEAM, subject: `Invitation à rejoindre ${organizationName} sur Xpeditis`, html, }); @@ -273,6 +369,7 @@ export class EmailAdapter implements EmailPort { await this.send({ to: carrierEmail, + from: EMAIL_SENDERS.BOOKINGS, subject: `Nouvelle demande de réservation ${bookingData.bookingNumber || ''} - ${bookingData.origin} → ${bookingData.destination}`, html, }); @@ -349,6 +446,7 @@ export class EmailAdapter implements EmailPort { await this.send({ to: email, + from: EMAIL_SENDERS.CARRIERS, subject: '🚢 Votre compte transporteur Xpeditis a été créé', html, }); @@ -424,6 +522,7 @@ export class EmailAdapter implements EmailPort { await this.send({ to: email, + from: EMAIL_SENDERS.SECURITY, subject: '🔑 Réinitialisation de votre mot de passe Xpeditis', html, }); @@ -535,6 +634,7 @@ export class EmailAdapter implements EmailPort { await this.send({ to: carrierEmail, + from: EMAIL_SENDERS.BOOKINGS, subject: `Documents disponibles - Reservation ${data.bookingNumber || ''} ${data.origin} → ${data.destination}`, html, }); @@ -614,6 +714,7 @@ export class EmailAdapter implements EmailPort { await this.send({ to: carrierEmail, + from: EMAIL_SENDERS.BOOKINGS, subject: `Nouveaux documents - Reservation ${data.origin} → ${data.destination}`, html, }); diff --git a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-invitation-token.repository.ts b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-invitation-token.repository.ts index 696e405..298ced7 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-invitation-token.repository.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-invitation-token.repository.ts @@ -78,6 +78,10 @@ export class TypeOrmInvitationTokenRepository implements InvitationTokenReposito return result.affected || 0; } + async deleteById(id: string): Promise { + await this.repository.delete({ id }); + } + async update(invitationToken: InvitationToken): Promise { const ormEntity = InvitationTokenOrmMapper.toOrm(invitationToken); const updated = await this.repository.save(ormEntity); diff --git a/apps/frontend/app/dashboard/settings/users/page.tsx b/apps/frontend/app/dashboard/settings/users/page.tsx index cb742d4..b084e1f 100644 --- a/apps/frontend/app/dashboard/settings/users/page.tsx +++ b/apps/frontend/app/dashboard/settings/users/page.tsx @@ -10,11 +10,63 @@ import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { listUsers, updateUser, deleteUser, canInviteUser } from '@/lib/api'; -import { createInvitation } from '@/lib/api/invitations'; +import { createInvitation, listInvitations, cancelInvitation } from '@/lib/api/invitations'; import { useAuth } from '@/lib/context/auth-context'; import Link from 'next/link'; import ExportButton from '@/components/ExportButton'; +const PAGE_SIZE = 5; + +function Pagination({ + page, + total, + onPage, +}: { + page: number; + total: number; + onPage: (p: number) => void; +}) { + const totalPages = Math.ceil(total / PAGE_SIZE); + if (totalPages <= 1) return null; + + return ( +
+

+ {Math.min((page - 1) * PAGE_SIZE + 1, total)}–{Math.min(page * PAGE_SIZE, total)} sur {total} +

+
+ + {Array.from({ length: totalPages }, (_, i) => i + 1).map(p => ( + + ))} + +
+
+ ); +} + export default function UsersManagementPage() { const router = useRouter(); const queryClient = useQueryClient(); @@ -22,11 +74,13 @@ export default function UsersManagementPage() { const [showInviteModal, setShowInviteModal] = useState(false); const [openMenuId, setOpenMenuId] = useState(null); const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null); + const [usersPage, setUsersPage] = useState(1); + const [invitationsPage, setInvitationsPage] = useState(1); const [inviteForm, setInviteForm] = useState({ email: '', firstName: '', lastName: '', - role: 'USER' as 'MANAGER' | 'USER' | 'VIEWER', + role: 'USER' as 'ADMIN' | 'MANAGER' | 'USER' | 'VIEWER', }); const [error, setError] = useState(''); const [success, setSuccess] = useState(''); @@ -36,44 +90,37 @@ export default function UsersManagementPage() { queryFn: () => listUsers(), }); - // Check license availability const { data: licenseStatus } = useQuery({ queryKey: ['canInvite'], queryFn: () => canInviteUser(), }); + const { data: pendingInvitations } = useQuery({ + queryKey: ['invitations'], + queryFn: () => listInvitations(), + }); + const inviteMutation = useMutation({ - mutationFn: (data: typeof inviteForm) => { - return createInvitation({ - email: data.email, - firstName: data.firstName, - lastName: data.lastName, - role: data.role, - }); - }, + mutationFn: (data: typeof inviteForm) => createInvitation(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['users'] }); queryClient.invalidateQueries({ queryKey: ['canInvite'] }); - setSuccess('Invitation envoyée avec succès ! L\'utilisateur recevra un email avec un lien d\'inscription.'); + queryClient.invalidateQueries({ queryKey: ['invitations'] }); + setSuccess("Invitation envoyée avec succès ! L'utilisateur recevra un email avec un lien d'inscription."); setShowInviteModal(false); - setInviteForm({ - email: '', - firstName: '', - lastName: '', - role: 'USER', - }); + setInviteForm({ email: '', firstName: '', lastName: '', role: 'USER' }); + setInvitationsPage(1); setTimeout(() => setSuccess(''), 5000); }, onError: (err: any) => { - setError(err.response?.data?.message || 'Échec de l\'envoi de l\'invitation'); + setError(err.response?.data?.message || "Échec de l'envoi de l'invitation"); setTimeout(() => setError(''), 5000); }, }); const changeRoleMutation = useMutation({ - mutationFn: ({ id, role }: { id: string; role: 'ADMIN' | 'MANAGER' | 'USER' | 'VIEWER' }) => { - return updateUser(id, { role }); - }, + mutationFn: ({ id, role }: { id: string; role: 'ADMIN' | 'MANAGER' | 'USER' | 'VIEWER' }) => + updateUser(id, { role }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['users'] }); setSuccess('Rôle mis à jour avec succès'); @@ -86,13 +133,12 @@ export default function UsersManagementPage() { }); const toggleActiveMutation = useMutation({ - mutationFn: ({ id, isActive }: { id: string; isActive: boolean }) => { - return updateUser(id, { isActive: !isActive }); - }, + mutationFn: ({ id, isActive }: { id: string; isActive: boolean }) => + updateUser(id, { isActive: !isActive }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['users'] }); queryClient.invalidateQueries({ queryKey: ['canInvite'] }); - setSuccess('Statut de l\'utilisateur mis à jour avec succès'); + setSuccess("Statut de l'utilisateur mis à jour avec succès"); setTimeout(() => setSuccess(''), 3000); }, onError: (err: any) => { @@ -110,19 +156,31 @@ export default function UsersManagementPage() { setTimeout(() => setSuccess(''), 3000); }, onError: (err: any) => { - setError(err.response?.data?.message || 'Échec de la suppression de l\'utilisateur'); + setError(err.response?.data?.message || "Échec de la suppression de l'utilisateur"); + setTimeout(() => setError(''), 5000); + }, + }); + + const cancelInvitationMutation = useMutation({ + mutationFn: (id: string) => cancelInvitation(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['invitations'] }); + queryClient.invalidateQueries({ queryKey: ['canInvite'] }); + setSuccess('Invitation annulée avec succès'); + setTimeout(() => setSuccess(''), 3000); + }, + onError: (err: any) => { + setError(err.response?.data?.message || "Échec de l'annulation de l'invitation"); setTimeout(() => setError(''), 5000); }, }); - // Restrict access to ADMIN and MANAGER only useEffect(() => { if (currentUser && currentUser.role !== 'ADMIN' && currentUser.role !== 'MANAGER') { router.push('/dashboard'); } }, [currentUser, router]); - // Don't render until we've checked permissions if (!currentUser || (currentUser.role !== 'ADMIN' && currentUser.role !== 'MANAGER')) { return (
@@ -134,7 +192,6 @@ export default function UsersManagementPage() { const handleInvite = (e: React.FormEvent) => { e.preventDefault(); setError(''); - inviteMutation.mutate(inviteForm); }; @@ -143,21 +200,23 @@ export default function UsersManagementPage() { }; const handleToggleActive = (userId: string, isActive: boolean) => { - if ( - window.confirm(`Êtes-vous sûr de vouloir ${isActive ? 'désactiver' : 'activer'} cet utilisateur ?`) - ) { + if (window.confirm(`Êtes-vous sûr de vouloir ${isActive ? 'désactiver' : 'activer'} cet utilisateur ?`)) { toggleActiveMutation.mutate({ id: userId, isActive }); } }; const handleDelete = (userId: string) => { - if ( - window.confirm('Êtes-vous sûr de vouloir supprimer cet utilisateur ? Cette action est irréversible.') - ) { + if (window.confirm('Êtes-vous sûr de vouloir supprimer cet utilisateur ? Cette action est irréversible.')) { deleteMutation.mutate(userId); } }; + const handleCancelInvitation = (invId: string, name: string) => { + if (window.confirm(`Annuler l'invitation envoyée à ${name} ?`)) { + cancelInvitationMutation.mutate(invId); + } + }; + const getRoleBadgeColor = (role: string) => { const colors: Record = { ADMIN: 'bg-red-100 text-red-800', @@ -168,6 +227,12 @@ export default function UsersManagementPage() { return colors[role] || 'bg-gray-100 text-gray-800'; }; + const allUsers = users?.users || []; + const pagedUsers = allUsers.slice((usersPage - 1) * PAGE_SIZE, usersPage * PAGE_SIZE); + + const allPending = (pendingInvitations || []).filter(inv => !inv.isUsed); + const pagedInvitations = allPending.slice((invitationsPage - 1) * PAGE_SIZE, invitationsPage * PAGE_SIZE); + return (
{/* License Warning */} @@ -186,10 +251,7 @@ export default function UsersManagementPage() { Mettez à niveau votre abonnement pour inviter plus d'utilisateurs.

- + Mettre à niveau l'abonnement
@@ -210,10 +272,7 @@ export default function UsersManagementPage() { {licenseStatus.availableLicenses} licence{licenseStatus.availableLicenses !== 1 ? 's' : ''} restante{licenseStatus.availableLicenses !== 1 ? 's' : ''} ({licenseStatus.usedLicenses}/{licenseStatus.maxLicenses} utilisées)
- + Gérer l'abonnement
@@ -228,21 +287,13 @@ export default function UsersManagementPage() {
{ - const labels: Record = { - ADMIN: 'Administrateur', - MANAGER: 'Manager', - USER: 'Utilisateur', - VIEWER: 'Lecteur', - }; - return labels[v] || v; - }}, + { key: 'role', label: 'Rôle', format: (v) => ({ ADMIN: 'Administrateur', MANAGER: 'Manager', USER: 'Utilisateur', VIEWER: 'Lecteur' }[v] || v) }, { key: 'isActive', label: 'Statut', format: (v) => v ? 'Actif' : 'Inactif' }, { key: 'createdAt', label: 'Date de création', format: (v) => v ? new Date(v).toLocaleDateString('fr-FR') : '' }, ]} @@ -281,152 +332,116 @@ export default function UsersManagementPage() { {/* Users Table */}
+
+

Utilisateurs

+ {allUsers.length > 0 && ( +

{allUsers.length} membre{allUsers.length !== 1 ? 's' : ''}

+ )} +
{isLoading ? (
Chargement des utilisateurs...
- ) : users?.users && users.users.length > 0 ? ( -
- - - - - - - - - - - - - {users.users.map(user => ( - - - - - - - + ) : pagedUsers.length > 0 ? ( + <> +
+
- Utilisateur - - Email - - Rôle - - Statut - - Date de création - - Actions -
-
-
- {user.firstName[0]} - {user.lastName[0]} -
-
-
- {user.firstName} {user.lastName} -
-
- {user.email} -
-
-
-
-
{user.email}
-
- - - - {user.isActive ? 'Actif' : 'Inactif'} - - - {new Date(user.createdAt).toLocaleDateString()} - - -
+ + + + + + + + - ))} - -
UtilisateurEmailRôleStatutDate de créationActions
-
+ + + {pagedUsers.map(user => ( + + +
+
+ {user.firstName[0]}{user.lastName[0]} +
+
+
{user.firstName} {user.lastName}
+
{user.email}
+
+
+ + +
{user.email}
+ + + + + + + {user.isActive ? 'Actif' : 'Inactif'} + + + + {new Date(user.createdAt).toLocaleDateString('fr-FR')} + + + + + + ))} + + +
+ { setUsersPage(p); setOpenMenuId(null); }} /> + ) : (
- - + +

Aucun utilisateur

Commencez par inviter un membre de l'équipe

{licenseStatus?.canInvite ? ( - ) : ( - + + - Upgrade to Invite + Mettre à niveau )}
@@ -434,30 +449,94 @@ export default function UsersManagementPage() { )}
+ {/* Pending Invitations */} + {allPending.length > 0 && ( +
+
+

Invitations en attente

+

+ Utilisateurs invités mais n'ayant pas encore créé leur compte — {allPending.length} invitation{allPending.length !== 1 ? 's' : ''} +

+
+
+ + + + + + + + + + + + + {pagedInvitations.map(inv => { + const isExpired = new Date(inv.expiresAt) < new Date(); + return ( + + + + + + + + + ); + })} + +
UtilisateurEmailRôleExpire leStatutActions
+
+
+ {inv.firstName[0]}{inv.lastName[0]} +
+
+
{inv.firstName} {inv.lastName}
+
+
+
{inv.email} + + {inv.role} + + + {new Date(inv.expiresAt).toLocaleDateString('fr-FR')} + + + {isExpired ? 'Expirée' : 'En attente'} + + + +
+
+ +
+ )} + {/* Actions Menu Modal */} {openMenuId && menuPosition && ( <>
{ - setOpenMenuId(null); - setMenuPosition(null); - }} + onClick={() => { setOpenMenuId(null); setMenuPosition(null); }} />
-
- +
-
-
{currentUser?.role !== 'ADMIN' && ( -

- Seuls les administrateurs peuvent attribuer le rôle ADMIN -

+

Seuls les administrateurs peuvent attribuer le rôle ADMIN

)}
-
diff --git a/apps/frontend/src/lib/api/invitations.ts b/apps/frontend/src/lib/api/invitations.ts index f20064f..ff949fa 100644 --- a/apps/frontend/src/lib/api/invitations.ts +++ b/apps/frontend/src/lib/api/invitations.ts @@ -1,4 +1,4 @@ -import { get, post } from './client'; +import { get, post, del } from './client'; /** * Invitation API Types @@ -49,3 +49,10 @@ export async function verifyInvitation(token: string): Promise { return get('/api/v1/invitations'); } + +/** + * Cancel (delete) a pending invitation + */ +export async function cancelInvitation(id: string): Promise { + return del(`/api/v1/invitations/${id}`); +}