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}
+
+ )}
+
+
+
+
+
+
+ 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.
+