From fd1f57dd1d2277cfaf7c7ff8a8edc1c44d8d24d6 Mon Sep 17 00:00:00 2001 From: David Date: Thu, 5 Feb 2026 11:53:22 +0100 Subject: [PATCH] fix --- .../csv-booking-actions.controller.ts | 72 +++- .../application/dto/carrier-documents.dto.ts | 30 +- .../services/csv-booking.service.ts | 126 +++++- .../src/domain/ports/out/email.port.ts | 4 + .../src/infrastructure/email/email.adapter.ts | 27 +- .../email/templates/email-templates.ts | 17 + .../entities/csv-booking.orm-entity.ts | 7 + .../1738200000000-AddPasswordToCsvBookings.ts | 48 +++ .../app/carrier/documents/[token]/page.tsx | 271 ++++++++++++- .../frontend/app/dashboard/documents/page.tsx | 14 +- .../app/dashboard/search-advanced/page.tsx | 10 - apps/frontend/src/components/PortRouteMap.tsx | 362 +++++++++++++++++- 12 files changed, 927 insertions(+), 61 deletions(-) create mode 100644 apps/backend/src/infrastructure/persistence/typeorm/migrations/1738200000000-AddPasswordToCsvBookings.ts diff --git a/apps/backend/src/application/controllers/csv-booking-actions.controller.ts b/apps/backend/src/application/controllers/csv-booking-actions.controller.ts index 576c984..1ef266a 100644 --- a/apps/backend/src/application/controllers/csv-booking-actions.controller.ts +++ b/apps/backend/src/application/controllers/csv-booking-actions.controller.ts @@ -1,8 +1,12 @@ -import { Controller, Get, Param, Query } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger'; +import { Controller, Get, Post, Param, Query, Body } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery, ApiBody } from '@nestjs/swagger'; import { Public } from '../decorators/public.decorator'; import { CsvBookingService } from '../services/csv-booking.service'; -import { CarrierDocumentsResponseDto } from '../dto/carrier-documents.dto'; +import { + CarrierDocumentsResponseDto, + VerifyDocumentAccessDto, + DocumentAccessRequirementsDto, +} from '../dto/carrier-documents.dto'; /** * CSV Booking Actions Controller (Public Routes) @@ -91,16 +95,71 @@ export class CsvBookingActionsController { } /** - * Get booking documents for carrier (PUBLIC - token-based) + * Check document access requirements (PUBLIC - token-based) + * + * GET /api/v1/csv-booking-actions/documents/:token/requirements + */ + @Public() + @Get('documents/:token/requirements') + @ApiOperation({ + summary: 'Check document access requirements (public)', + description: + 'Check if a password is required to access booking documents. Use this before showing the password form.', + }) + @ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' }) + @ApiResponse({ + status: 200, + description: 'Access requirements retrieved successfully.', + type: DocumentAccessRequirementsDto, + }) + @ApiResponse({ status: 404, description: 'Booking not found or invalid token' }) + async getDocumentAccessRequirements( + @Param('token') token: string + ): Promise { + return this.csvBookingService.checkDocumentAccessRequirements(token); + } + + /** + * Get booking documents for carrier with password verification (PUBLIC - token-based) + * + * POST /api/v1/csv-booking-actions/documents/:token + */ + @Public() + @Post('documents/:token') + @ApiOperation({ + summary: 'Get booking documents with password (public)', + description: + 'Public endpoint for carriers to access booking documents after acceptance. Requires password verification. Returns booking summary and documents with signed download URLs.', + }) + @ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' }) + @ApiBody({ type: VerifyDocumentAccessDto }) + @ApiResponse({ + status: 200, + description: 'Booking documents retrieved successfully.', + type: CarrierDocumentsResponseDto, + }) + @ApiResponse({ status: 404, description: 'Booking not found or invalid token' }) + @ApiResponse({ status: 400, description: 'Booking has not been accepted yet' }) + @ApiResponse({ status: 401, description: 'Invalid password' }) + async getBookingDocumentsWithPassword( + @Param('token') token: string, + @Body() dto: VerifyDocumentAccessDto + ): Promise { + return this.csvBookingService.getDocumentsForCarrier(token, dto.password); + } + + /** + * Get booking documents for carrier (PUBLIC - token-based) - Legacy without password + * Kept for backward compatibility with bookings created before password protection * * GET /api/v1/csv-booking-actions/documents/:token */ @Public() @Get('documents/:token') @ApiOperation({ - summary: 'Get booking documents (public)', + summary: 'Get booking documents (public) - Legacy', description: - 'Public endpoint for carriers to access booking documents after acceptance. Returns booking summary and documents with signed download URLs.', + 'Public endpoint for carriers to access booking documents. For new bookings, use POST with password instead.', }) @ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' }) @ApiResponse({ @@ -110,6 +169,7 @@ export class CsvBookingActionsController { }) @ApiResponse({ status: 404, description: 'Booking not found or invalid token' }) @ApiResponse({ status: 400, description: 'Booking has not been accepted yet' }) + @ApiResponse({ status: 401, description: 'Password required for this booking' }) async getBookingDocuments(@Param('token') token: string): Promise { return this.csvBookingService.getDocumentsForCarrier(token); } diff --git a/apps/backend/src/application/dto/carrier-documents.dto.ts b/apps/backend/src/application/dto/carrier-documents.dto.ts index 1dd8d2e..7bdb79a 100644 --- a/apps/backend/src/application/dto/carrier-documents.dto.ts +++ b/apps/backend/src/application/dto/carrier-documents.dto.ts @@ -1,4 +1,29 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +/** + * DTO for verifying document access password + */ +export class VerifyDocumentAccessDto { + @ApiProperty({ description: 'Password for document access (booking number code)' }) + @IsString() + @IsNotEmpty() + password: string; +} + +/** + * Response DTO for checking document access requirements + */ +export class DocumentAccessRequirementsDto { + @ApiProperty({ description: 'Whether password is required to access documents' }) + requiresPassword: boolean; + + @ApiPropertyOptional({ description: 'Booking number (if available)' }) + bookingNumber?: string; + + @ApiProperty({ description: 'Current booking status' }) + status: string; +} /** * Booking Summary DTO for Carrier Documents Page @@ -7,6 +32,9 @@ export class BookingSummaryDto { @ApiProperty({ description: 'Booking unique ID' }) id: string; + @ApiPropertyOptional({ description: 'Human-readable booking number' }) + bookingNumber?: string; + @ApiProperty({ description: 'Carrier/Company name' }) carrierName: string; diff --git a/apps/backend/src/application/services/csv-booking.service.ts b/apps/backend/src/application/services/csv-booking.service.ts index ab685dc..677067b 100644 --- a/apps/backend/src/application/services/csv-booking.service.ts +++ b/apps/backend/src/application/services/csv-booking.service.ts @@ -1,5 +1,13 @@ -import { Injectable, Logger, NotFoundException, BadRequestException, Inject } from '@nestjs/common'; +import { + Injectable, + Logger, + NotFoundException, + BadRequestException, + Inject, + UnauthorizedException, +} from '@nestjs/common'; import { v4 as uuidv4 } from 'uuid'; +import * as argon2 from 'argon2'; import { CsvBooking, CsvBookingStatus, DocumentType } from '@domain/entities/csv-booking.entity'; import { PortCode } from '@domain/value-objects/port-code.vo'; import { TypeOrmCsvBookingRepository } from '../../infrastructure/persistence/typeorm/repositories/csv-booking.repository'; @@ -57,6 +65,27 @@ export class CsvBookingService { private readonly storageAdapter: StoragePort ) {} + /** + * Generate a unique booking number + * Format: XPD-YYYY-XXXXXX (e.g., XPD-2025-A3B7K9) + */ + private generateBookingNumber(): string { + const year = new Date().getFullYear(); + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // No 0, O, 1, I for clarity + let code = ''; + for (let i = 0; i < 6; i++) { + code += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return `XPD-${year}-${code}`; + } + + /** + * Extract the password from booking number (last 6 characters) + */ + private extractPasswordFromBookingNumber(bookingNumber: string): string { + return bookingNumber.split('-').pop() || bookingNumber.slice(-6); + } + /** * Create a new CSV booking request */ @@ -73,9 +102,14 @@ export class CsvBookingService { throw new BadRequestException('At least one document is required'); } - // Generate unique confirmation token + // Generate unique confirmation token and booking number const confirmationToken = uuidv4(); const bookingId = uuidv4(); + const bookingNumber = this.generateBookingNumber(); + const documentPassword = this.extractPasswordFromBookingNumber(bookingNumber); + + // Hash the password for storage + const passwordHash = await argon2.hash(documentPassword); // Upload documents to S3 const documents = await this.uploadDocuments(files, bookingId); @@ -107,13 +141,26 @@ export class CsvBookingService { // Save to database const savedBooking = await this.csvBookingRepository.create(booking); - this.logger.log(`CSV booking created with ID: ${bookingId}`); + + // Update ORM entity with booking number and password hash + const ormBooking = await this.csvBookingRepository['repository'].findOne({ + where: { id: bookingId }, + }); + if (ormBooking) { + ormBooking.bookingNumber = bookingNumber; + ormBooking.passwordHash = passwordHash; + await this.csvBookingRepository['repository'].save(ormBooking); + } + + this.logger.log(`CSV booking created with ID: ${bookingId}, number: ${bookingNumber}`); // Send email to carrier and WAIT for confirmation // The button waits for the email to be sent before responding try { await this.emailAdapter.sendCsvBookingRequest(dto.carrierEmail, { bookingId, + bookingNumber, + documentPassword, origin: dto.origin, destination: dto.destination, volumeCBM: dto.volumeCBM, @@ -203,21 +250,45 @@ export class CsvBookingService { } /** - * Get booking documents for carrier (public endpoint) - * Only accessible for ACCEPTED bookings + * Verify password and get booking documents for carrier (public endpoint) + * Only accessible for ACCEPTED bookings with correct password */ - async getDocumentsForCarrier(token: string): Promise { + async getDocumentsForCarrier( + token: string, + password?: string + ): Promise { this.logger.log(`Getting documents for carrier with token: ${token}`); - const booking = await this.csvBookingRepository.findByToken(token); + // Get ORM entity to access passwordHash + const ormBooking = await this.csvBookingRepository['repository'].findOne({ + where: { confirmationToken: token }, + }); - if (!booking) { + if (!ormBooking) { throw new NotFoundException('Réservation introuvable'); } // Only allow access for ACCEPTED bookings - if (booking.status !== CsvBookingStatus.ACCEPTED) { - throw new BadRequestException('Cette réservation n\'a pas encore été acceptée'); + if (ormBooking.status !== 'ACCEPTED') { + throw new BadRequestException("Cette réservation n'a pas encore été acceptée"); + } + + // Check if password protection is enabled for this booking + if (ormBooking.passwordHash) { + if (!password) { + throw new UnauthorizedException('Mot de passe requis pour accéder aux documents'); + } + + const isPasswordValid = await argon2.verify(ormBooking.passwordHash, password); + if (!isPasswordValid) { + throw new UnauthorizedException('Mot de passe incorrect'); + } + } + + // Get domain booking for business logic + const booking = await this.csvBookingRepository.findByToken(token); + if (!booking) { + throw new NotFoundException('Réservation introuvable'); } // Generate signed URLs for all documents @@ -240,6 +311,7 @@ export class CsvBookingService { return { booking: { id: booking.id, + bookingNumber: ormBooking.bookingNumber || undefined, carrierName: booking.carrierName, origin: booking.origin.getValue(), destination: booking.destination.getValue(), @@ -257,6 +329,27 @@ export class CsvBookingService { }; } + /** + * Check if a booking requires password for document access + */ + async checkDocumentAccessRequirements( + token: string + ): Promise<{ requiresPassword: boolean; bookingNumber?: string; status: string }> { + const ormBooking = await this.csvBookingRepository['repository'].findOne({ + where: { confirmationToken: token }, + }); + + if (!ormBooking) { + throw new NotFoundException('Réservation introuvable'); + } + + return { + requiresPassword: !!ormBooking.passwordHash, + bookingNumber: ormBooking.bookingNumber || undefined, + status: ormBooking.status, + }; + } + /** * Generate signed URL for a document file path */ @@ -292,6 +385,11 @@ export class CsvBookingService { throw new NotFoundException('Booking not found'); } + // Get ORM entity for bookingNumber + const ormBooking = await this.csvBookingRepository['repository'].findOne({ + where: { confirmationToken: token }, + }); + // Accept the booking (domain logic validates status) booking.accept(); @@ -299,11 +397,19 @@ export class CsvBookingService { const updatedBooking = await this.csvBookingRepository.update(booking); this.logger.log(`Booking ${booking.id} accepted`); + // Extract password from booking number for the email + const bookingNumber = ormBooking?.bookingNumber; + const documentPassword = bookingNumber + ? this.extractPasswordFromBookingNumber(bookingNumber) + : undefined; + // Send document access email to carrier try { await this.emailAdapter.sendDocumentAccessEmail(booking.carrierEmail, { carrierName: booking.carrierName, bookingId: booking.id, + bookingNumber: bookingNumber || undefined, + documentPassword: documentPassword, origin: booking.origin.getValue(), destination: booking.destination.getValue(), volumeCBM: booking.volumeCBM, diff --git a/apps/backend/src/domain/ports/out/email.port.ts b/apps/backend/src/domain/ports/out/email.port.ts index 5fa3ed1..ab7321c 100644 --- a/apps/backend/src/domain/ports/out/email.port.ts +++ b/apps/backend/src/domain/ports/out/email.port.ts @@ -85,6 +85,8 @@ export interface EmailPort { carrierEmail: string, bookingDetails: { bookingId: string; + bookingNumber?: string; + documentPassword?: string; origin: string; destination: string; volumeCBM: number; @@ -129,6 +131,8 @@ export interface EmailPort { data: { carrierName: string; bookingId: string; + bookingNumber?: string; + documentPassword?: string; origin: string; destination: string; volumeCBM: number; diff --git a/apps/backend/src/infrastructure/email/email.adapter.ts b/apps/backend/src/infrastructure/email/email.adapter.ts index 7277ba3..e0ff9f3 100644 --- a/apps/backend/src/infrastructure/email/email.adapter.ts +++ b/apps/backend/src/infrastructure/email/email.adapter.ts @@ -239,6 +239,8 @@ export class EmailAdapter implements EmailPort { carrierEmail: string, bookingData: { bookingId: string; + bookingNumber?: string; + documentPassword?: string; origin: string; destination: string; volumeCBM: number; @@ -270,7 +272,7 @@ export class EmailAdapter implements EmailPort { await this.send({ to: carrierEmail, - subject: `Nouvelle demande de réservation - ${bookingData.origin} → ${bookingData.destination}`, + subject: `Nouvelle demande de réservation ${bookingData.bookingNumber || ''} - ${bookingData.origin} → ${bookingData.destination}`, html, }); @@ -436,6 +438,8 @@ export class EmailAdapter implements EmailPort { data: { carrierName: string; bookingId: string; + bookingNumber?: string; + documentPassword?: string; origin: string; destination: string; volumeCBM: number; @@ -447,6 +451,20 @@ export class EmailAdapter implements EmailPort { const frontendUrl = this.configService.get('APP_URL', 'http://localhost:3000'); const documentsUrl = `${frontendUrl}/carrier/documents/${data.confirmationToken}`; + // Password section HTML - only show if password is set + const passwordSection = data.documentPassword + ? ` +
+

🔐 Mot de passe d'accès aux documents

+

Pour accéder aux documents, vous aurez besoin du mot de passe suivant :

+
+ ${data.documentPassword} +
+

⚠️ Conservez ce mot de passe, il vous sera demandé à chaque accès.

+
+ ` + : ''; + const html = ` @@ -474,6 +492,7 @@ export class EmailAdapter implements EmailPort {

Documents disponibles

Votre reservation a ete acceptee

+ ${data.bookingNumber ? `

N° ${data.bookingNumber}

` : ''}

Bonjour ${data.carrierName},

@@ -498,12 +517,14 @@ export class EmailAdapter implements EmailPort { ${data.documentCount} document${data.documentCount > 1 ? 's' : ''} disponible${data.documentCount > 1 ? 's' : ''}
+ ${passwordSection} + Acceder aux documents

Ce lien est permanent. Vous pouvez y acceder a tout moment.

@@ -513,7 +534,7 @@ export class EmailAdapter implements EmailPort { await this.send({ to: carrierEmail, - subject: `Documents disponibles - Reservation ${data.origin} → ${data.destination}`, + subject: `Documents disponibles - Reservation ${data.bookingNumber || ''} ${data.origin} → ${data.destination}`, html, }); diff --git a/apps/backend/src/infrastructure/email/templates/email-templates.ts b/apps/backend/src/infrastructure/email/templates/email-templates.ts index b5bc0fc..7348082 100644 --- a/apps/backend/src/infrastructure/email/templates/email-templates.ts +++ b/apps/backend/src/infrastructure/email/templates/email-templates.ts @@ -261,6 +261,8 @@ export class EmailTemplates { */ async renderCsvBookingRequest(data: { bookingId: string; + bookingNumber?: string; + documentPassword?: string; origin: string; destination: string; volumeCBM: number; @@ -481,6 +483,21 @@ export class EmailTemplates { Vous avez reçu une nouvelle demande de réservation via Xpeditis. Veuillez examiner les détails ci-dessous et confirmer ou refuser cette demande.

+ {{#if bookingNumber}} + +
+

Numéro de devis

+

{{bookingNumber}}

+ {{#if documentPassword}} +
+

🔐 Mot de passe pour accéder aux documents

+

{{documentPassword}}

+

Conservez ce mot de passe, il vous sera demandé pour télécharger les documents

+
+ {{/if}} +
+ {{/if}} +
📋 Détails du transport
diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/csv-booking.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/csv-booking.orm-entity.ts index e2d5051..aa1e8a4 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/entities/csv-booking.orm-entity.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/csv-booking.orm-entity.ts @@ -96,6 +96,13 @@ export class CsvBookingOrmEntity { @Index() confirmationToken: string; + @Column({ name: 'booking_number', type: 'varchar', length: 20, nullable: true }) + @Index() + bookingNumber: string | null; + + @Column({ name: 'password_hash', type: 'text', nullable: true }) + passwordHash: string | null; + @Column({ name: 'requested_at', type: 'timestamp with time zone' }) @Index() requestedAt: Date; diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1738200000000-AddPasswordToCsvBookings.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1738200000000-AddPasswordToCsvBookings.ts new file mode 100644 index 0000000..f03d131 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1738200000000-AddPasswordToCsvBookings.ts @@ -0,0 +1,48 @@ +/** + * Migration: Add Password Protection to CSV Bookings + * + * Adds password protection for carrier document access + * Including: booking_number (readable ID) and password_hash + */ + +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddPasswordToCsvBookings1738200000000 implements MigrationInterface { + name = 'AddPasswordToCsvBookings1738200000000'; + + public async up(queryRunner: QueryRunner): Promise { + // Add password-related columns to csv_bookings + await queryRunner.query(` + ALTER TABLE "csv_bookings" + ADD COLUMN "booking_number" VARCHAR(20) NULL, + ADD COLUMN "password_hash" TEXT NULL + `); + + // Create unique index for booking_number + await queryRunner.query(` + CREATE UNIQUE INDEX "idx_csv_bookings_booking_number" + ON "csv_bookings" ("booking_number") + WHERE "booking_number" IS NOT NULL + `); + + // Add comments + await queryRunner.query(` + COMMENT ON COLUMN "csv_bookings"."booking_number" IS 'Human-readable booking number (format: XPD-YYYY-XXXXXX)' + `); + await queryRunner.query(` + COMMENT ON COLUMN "csv_bookings"."password_hash" IS 'Argon2 hashed password for carrier document access' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Remove index first + await queryRunner.query(`DROP INDEX IF EXISTS "idx_csv_bookings_booking_number"`); + + // Remove columns + await queryRunner.query(` + ALTER TABLE "csv_bookings" + DROP COLUMN IF EXISTS "booking_number", + DROP COLUMN IF EXISTS "password_hash" + `); + } +} diff --git a/apps/frontend/app/carrier/documents/[token]/page.tsx b/apps/frontend/app/carrier/documents/[token]/page.tsx index d81c434..e670ba4 100644 --- a/apps/frontend/app/carrier/documents/[token]/page.tsx +++ b/apps/frontend/app/carrier/documents/[token]/page.tsx @@ -12,6 +12,9 @@ import { Clock, AlertCircle, ArrowRight, + Lock, + Eye, + EyeOff, } from 'lucide-react'; interface Document { @@ -25,6 +28,7 @@ interface Document { interface BookingSummary { id: string; + bookingNumber?: string; carrierName: string; origin: string; destination: string; @@ -44,6 +48,12 @@ interface CarrierDocumentsData { documents: Document[]; } +interface AccessRequirements { + requiresPassword: boolean; + bookingNumber?: string; + status: string; +} + const documentTypeLabels: Record = { BILL_OF_LADING: 'Connaissement', PACKING_LIST: 'Liste de colisage', @@ -75,9 +85,18 @@ export default function CarrierDocumentsPage() { const [data, setData] = useState(null); const [downloading, setDownloading] = useState(null); - const hasCalledApi = useRef(false); + // Password protection state + const [requirements, setRequirements] = useState(null); + const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [passwordError, setPasswordError] = useState(null); + const [verifying, setVerifying] = useState(false); - const fetchDocuments = async () => { + const hasCalledApi = useRef(false); + const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'; + + // Check access requirements first + const checkRequirements = async () => { if (!token) { setError('Lien invalide'); setLoading(false); @@ -85,7 +104,61 @@ export default function CarrierDocumentsPage() { } try { - const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'; + const response = await fetch( + `${apiUrl}/api/v1/csv-booking-actions/documents/${token}/requirements`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + } + ); + + if (!response.ok) { + let errorData; + try { + errorData = await response.json(); + } catch { + errorData = { message: `Erreur HTTP ${response.status}` }; + } + + const errorMessage = errorData.message || 'Erreur lors du chargement'; + + if (errorMessage.includes('introuvable') || errorMessage.includes('not found')) { + throw new Error('Réservation introuvable. Vérifiez que le lien est correct.'); + } + + throw new Error(errorMessage); + } + + const reqData: AccessRequirements = await response.json(); + setRequirements(reqData); + + // If booking is not accepted yet + if (reqData.status !== 'ACCEPTED') { + setError( + "Cette réservation n'a pas encore été acceptée. Les documents seront disponibles après l'acceptation." + ); + setLoading(false); + return; + } + + // If no password required, fetch documents directly + if (!reqData.requiresPassword) { + await fetchDocumentsWithoutPassword(); + } else { + setLoading(false); + } + } catch (err) { + console.error('Error checking requirements:', err); + setError(err instanceof Error ? err.message : 'Erreur lors du chargement'); + setLoading(false); + } + }; + + // Fetch documents without password (legacy bookings) + const fetchDocumentsWithoutPassword = async () => { + try { const response = await fetch(`${apiUrl}/api/v1/csv-booking-actions/documents/${token}`, { method: 'GET', headers: { @@ -103,10 +176,20 @@ export default function CarrierDocumentsPage() { const errorMessage = errorData.message || 'Erreur lors du chargement des documents'; - if (errorMessage.includes('pas encore été acceptée') || errorMessage.includes('not accepted')) { - throw new Error('Cette réservation n\'a pas encore été acceptée. Les documents seront disponibles après l\'acceptation.'); + if ( + errorMessage.includes('pas encore été acceptée') || + errorMessage.includes('not accepted') + ) { + throw new Error( + "Cette réservation n'a pas encore été acceptée. Les documents seront disponibles après l'acceptation." + ); } else if (errorMessage.includes('introuvable') || errorMessage.includes('not found')) { throw new Error('Réservation introuvable. Vérifiez que le lien est correct.'); + } else if (errorMessage.includes('Mot de passe requis') || errorMessage.includes('required')) { + // Password is now required, show the form + setRequirements({ requiresPassword: true, status: 'ACCEPTED' }); + setLoading(false); + return; } throw new Error(errorMessage); @@ -122,12 +205,68 @@ export default function CarrierDocumentsPage() { } }; + // Fetch documents with password + const fetchDocumentsWithPassword = async (pwd: string) => { + setVerifying(true); + setPasswordError(null); + + try { + const response = await fetch(`${apiUrl}/api/v1/csv-booking-actions/documents/${token}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ password: pwd }), + }); + + if (!response.ok) { + let errorData; + try { + errorData = await response.json(); + } catch { + errorData = { message: `Erreur HTTP ${response.status}` }; + } + + const errorMessage = errorData.message || 'Erreur lors de la vérification'; + + if ( + response.status === 401 || + errorMessage.includes('incorrect') || + errorMessage.includes('invalid') + ) { + setPasswordError('Mot de passe incorrect. Vérifiez votre email pour retrouver le mot de passe.'); + setVerifying(false); + return; + } + + throw new Error(errorMessage); + } + + const responseData = await response.json(); + setData(responseData); + setVerifying(false); + } catch (err) { + console.error('Error verifying password:', err); + setPasswordError(err instanceof Error ? err.message : 'Erreur lors de la vérification'); + setVerifying(false); + } + }; + useEffect(() => { if (hasCalledApi.current) return; hasCalledApi.current = true; - fetchDocuments(); + checkRequirements(); }, [token]); + const handlePasswordSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!password.trim()) { + setPasswordError('Veuillez entrer le mot de passe'); + return; + } + fetchDocumentsWithPassword(password.trim()); + }; + const handleDownload = async (doc: Document) => { setDownloading(doc.id); @@ -146,24 +285,28 @@ export default function CarrierDocumentsPage() { const handleRefresh = () => { setLoading(true); setError(null); + setData(null); + setRequirements(null); + setPassword(''); + setPasswordError(null); hasCalledApi.current = false; - fetchDocuments(); + checkRequirements(); }; + // Loading state if (loading) { return (
-

- Chargement des documents... -

+

Chargement...

Veuillez patienter

); } + // Error state if (error) { return (
@@ -182,6 +325,91 @@ export default function CarrierDocumentsPage() { ); } + // Password form state + if (requirements?.requiresPassword && !data) { + return ( +
+
+
+
+ +
+

Accès sécurisé

+

+ Cette page est protégée. Entrez le mot de passe reçu par email pour accéder aux + documents. +

+ {requirements.bookingNumber && ( +

+ Réservation: {requirements.bookingNumber} +

+ )} +
+ +
+
+ +
+ setPassword(e.target.value.toUpperCase())} + placeholder="Ex: A3B7K9" + className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-sky-500 focus:border-sky-500 text-center text-xl tracking-widest font-mono uppercase" + autoComplete="off" + autoFocus + /> + +
+ {passwordError && ( +

+ + {passwordError} +

+ )} +
+ + + + +
+

+ Où trouver le mot de passe ? +
+ Le mot de passe vous a été envoyé dans l'email de confirmation de la réservation. Il + correspond aux 6 derniers caractères du numéro de devis. +

+
+
+
+ ); + } + if (!data) return null; const { booking, documents } = data; @@ -213,6 +441,11 @@ export default function CarrierDocumentsPage() { {booking.destination}
+ {booking.bookingNumber && ( +

+ N° {booking.bookingNumber} +

+ )}
@@ -241,10 +474,14 @@ export default function CarrierDocumentsPage() {
- Transporteur: {booking.carrierName} + Transporteur:{' '} + {booking.carrierName} - Ref: {booking.id.substring(0, 8).toUpperCase()} + Ref:{' '} + + {booking.bookingNumber || booking.id.substring(0, 8).toUpperCase()} +
@@ -263,11 +500,13 @@ export default function CarrierDocumentsPage() {

Aucun document disponible pour le moment.

-

Les documents apparaîtront ici une fois ajoutés.

+

+ Les documents apparaîtront ici une fois ajoutés. +

) : (
- {documents.map((doc) => ( + {documents.map(doc => (
{documentTypeLabels[doc.type] || doc.type} - - {formatFileSize(doc.size)} - + {formatFileSize(doc.size)}
diff --git a/apps/frontend/app/dashboard/documents/page.tsx b/apps/frontend/app/dashboard/documents/page.tsx index 7a59945..49998e4 100644 --- a/apps/frontend/app/dashboard/documents/page.tsx +++ b/apps/frontend/app/dashboard/documents/page.tsx @@ -259,8 +259,10 @@ export default function UserDocumentsPage() { } }; - // Get unique bookings for add document modal - const bookingsWithPendingStatus = bookings.filter(b => b.status === 'PENDING'); + // Get bookings available for adding documents (PENDING or ACCEPTED) + const bookingsAvailableForDocuments = bookings.filter( + b => b.status === 'PENDING' || b.status === 'ACCEPTED' + ); const handleAddDocumentClick = () => { setShowAddModal(true); @@ -435,7 +437,7 @@ export default function UserDocumentsPage() { />