From 56fe4fbcd3240e58f230b87d36a1a3308851c36b Mon Sep 17 00:00:00 2001 From: Sosthene Date: Thu, 3 Jul 2025 18:00:18 +0200 Subject: [PATCH] Add document verification page --- .../DocumentVerification/classes.module.scss | 171 ++++++++++++ .../Folder/DocumentVerification/index.tsx | 260 ++++++++++++++++++ .../InformationSection/index.tsx | 36 ++- src/front/Config/Module/development.json | 7 + src/front/Config/Module/preprod.json | 7 + src/front/Config/Module/production.json | 7 + src/front/Config/Module/staging.json | 7 + .../folders/[folderUid]/verify-documents.tsx | 20 ++ 8 files changed, 512 insertions(+), 3 deletions(-) create mode 100644 src/front/Components/Layouts/Folder/DocumentVerification/classes.module.scss create mode 100644 src/front/Components/Layouts/Folder/DocumentVerification/index.tsx create mode 100644 src/pages/folders/[folderUid]/verify-documents.tsx diff --git a/src/front/Components/Layouts/Folder/DocumentVerification/classes.module.scss b/src/front/Components/Layouts/Folder/DocumentVerification/classes.module.scss new file mode 100644 index 00000000..c99b4817 --- /dev/null +++ b/src/front/Components/Layouts/Folder/DocumentVerification/classes.module.scss @@ -0,0 +1,171 @@ +@import "@Themes/constants.scss"; + +.root { + display: flex; + flex-direction: column; + gap: var(--spacing-2xl, 40px); + max-width: 800px; + margin: 0 auto; + padding: var(--spacing-xl, 32px); + + .header { + display: flex; + flex-direction: column; + gap: var(--spacing-md, 16px); + text-align: center; + } + + .content { + display: flex; + flex-direction: column; + gap: var(--spacing-2xl, 40px); + + .drag-drop-container { + display: flex; + flex-direction: column; + gap: var(--spacing-xl, 32px); + + @media (min-width: $screen-m) { + flex-direction: row; + gap: var(--spacing-lg, 24px); + } + + .drag-drop-box { + flex: 1; + min-height: 200px; + display: flex; + flex-direction: column; + gap: var(--spacing-sm, 8px); + + // Force the DragAndDrop component to take full width and height + > div { + width: 100% !important; + height: 100% !important; + min-height: 200px !important; + display: flex !important; + flex-direction: column !important; + + // Override the fit-content width from DragAndDrop + &.root { + width: 100% !important; + height: 100% !important; + min-height: 200px !important; + } + } + + .file-info { + padding: var(--spacing-sm, 8px) var(--spacing-md, 16px); + background-color: var(--color-success-50, #f0f9ff); + border: 1px solid var(--color-success-200, #bae6fd); + border-radius: var(--radius-md, 8px); + margin-top: var(--spacing-sm, 8px); + } + } + } + + .warning { + text-align: center; + padding: var(--spacing-md, 16px); + background-color: var(--color-warning-50, #fffbeb); + border: 1px solid var(--color-warning-200, #fde68a); + border-radius: var(--radius-md, 8px); + } + + .verification-result { + text-align: center; + padding: var(--spacing-lg, 24px); + border-radius: var(--radius-md, 8px); + border: 2px solid; + display: flex; + flex-direction: column; + gap: var(--spacing-md, 16px); + + &.success { + background-color: var(--color-success-50, #f0fdf4); + border-color: var(--color-success-200, #bbf7d0); + } + + &.error { + background-color: var(--color-error-50, #fef2f2); + border-color: var(--color-error-200, #fecaca); + } + + .verification-details { + background-color: rgba(0, 0, 0, 0.05); + padding: var(--spacing-md, 16px); + border-radius: var(--radius-sm, 4px); + font-family: monospace; + white-space: pre-line; + text-align: left; + max-width: 100%; + overflow-x: auto; + } + + .merkle-proof-section { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-md, 16px); + padding: var(--spacing-lg, 24px); + background-color: rgba(0, 0, 0, 0.02); + border-radius: var(--radius-md, 8px); + border: 1px solid rgba(0, 0, 0, 0.1); + } + } + + .qr-container { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-sm, 8px); + + .qr-code { + width: 150px; + height: 150px; + border: 2px solid var(--color-neutral-200, #e5e7eb); + border-radius: var(--radius-sm, 4px); + padding: var(--spacing-sm, 8px); + background-color: white; + } + + .qr-description { + text-align: center; + max-width: 200px; + } + } + + .qr-loading { + display: flex; + justify-content: center; + align-items: center; + width: 150px; + height: 150px; + border: 2px dashed var(--color-neutral-300, #d1d5db); + border-radius: var(--radius-sm, 4px); + background-color: var(--color-neutral-50, #f9fafb); + } + + .qr-error { + display: flex; + justify-content: center; + align-items: center; + width: 150px; + height: 150px; + border: 2px solid var(--color-error-200, #fecaca); + border-radius: var(--radius-sm, 4px); + background-color: var(--color-error-50, #fef2f2); + } + + .actions { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--spacing-lg, 24px); + + @media (max-width: $screen-s) { + flex-direction: column; + gap: var(--spacing-md, 16px); + } + } + } +} \ No newline at end of file diff --git a/src/front/Components/Layouts/Folder/DocumentVerification/index.tsx b/src/front/Components/Layouts/Folder/DocumentVerification/index.tsx new file mode 100644 index 00000000..2c1bdad6 --- /dev/null +++ b/src/front/Components/Layouts/Folder/DocumentVerification/index.tsx @@ -0,0 +1,260 @@ +import Button, { EButtonSize, EButtonstyletype, EButtonVariant } from "@Front/Components/DesignSystem/Button"; +import DragAndDrop from "@Front/Components/DesignSystem/DragAndDrop"; +import Typography, { ETypo, ETypoColor } from "@Front/Components/DesignSystem/Typography"; +import Module from "@Front/Config/Module"; +import PdfService from "@Front/Services/PdfService"; +import { FileBlob } from "@Front/Api/Entities/types"; +import { ShieldCheckIcon } from "@heroicons/react/24/outline"; +import { useRouter } from "next/router"; +import React, { useState } from "react"; +import MessageBus from "src/sdk/MessageBus"; + +import classes from "./classes.module.scss"; +import DocumentService from "src/common/Api/LeCoffreApi/sdk/DocumentService"; +import FileService from "src/common/Api/LeCoffreApi/sdk/FileService"; + +type IProps = { + folderUid: string; +}; + +/** + * Convert a File object to FileBlob + * @param file - The File object to convert + * @returns Promise - The converted FileBlob + */ +const convertFileToFileBlob = async (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const arrayBuffer = reader.result as ArrayBuffer; + const uint8Array = new Uint8Array(arrayBuffer); + resolve({ + type: file.type, + data: uint8Array + }); + }; + reader.onerror = () => reject(new Error('Failed to read file')); + reader.readAsArrayBuffer(file); + }); +}; + + + +export default function DocumentVerification(props: IProps) { + const { folderUid } = props; + const router = useRouter(); + + const [documentToVerify, setDocumentToVerify] = useState(null); + const [validationCertificate, setValidationCertificate] = useState(null); + const [isVerifying, setIsVerifying] = useState(false); + const [verificationResult, setVerificationResult] = useState<{ + success: boolean; + message: string; + details?: string; + merkleProof?: string; + } | null>(null); + + const handleDocumentToVerifyChange = (files: File[]) => { + if (files.length > 0 && files[0]) { + setDocumentToVerify(files[0]); + } else { + setDocumentToVerify(null); + } + }; + + const handleValidationCertificateChange = (files: File[]) => { + if (files.length > 0 && files[0]) { + setValidationCertificate(files[0]); + } else { + setValidationCertificate(null); + } + }; + + const handleVerifyDocuments = async () => { + if (!documentToVerify || !validationCertificate) { + console.error("Both documents are required for verification"); + return; + } + + setIsVerifying(true); + setVerificationResult(null); + + const messageBus = MessageBus.getInstance(); + + try { + // Here the things we need to verify: + // - we can produce the same hash from the document provided than what is in the validation certificate + // - the merkle proof is valid with that hash + // - the root of the merkle tree is a state id from a commited state in the process + // - that process is a file process linked to the right folder + // Step 1: Parse the validation certificate + const validationData = await PdfService.getInstance().parseCertificate(validationCertificate); + + // Step 2: Convert File to FileBlob and hash the document using MessageBus + const fileBlob = await convertFileToFileBlob(documentToVerify); + + await messageBus.isReady(); + const documentHash = await messageBus.hashDocument(fileBlob, validationData.commitmentId); + + // Step 3: Compare hashes + const hashesMatch = documentHash.toLowerCase() === validationData.documentHash.toLowerCase(); + + if (!hashesMatch) { + throw new Error('Hash du document invalide, le document fourni n\'est pas celui qui a été certifié'); + } + + // Step 4: Verify the merkle proof + const merkleProof = validationData.merkleProof; + const merkleProofValid = await messageBus.verifyMerkleProof(merkleProof, documentHash); + + if (!merkleProofValid) { + throw new Error('Preuve de Merkle invalide, le document n\'a pas été certifié là où le certificat le prétend'); + } + + // Step 5: Verify that this file process depends on the right folder process + // First pin all the validated documents related to the folder + const documentProcesses = await DocumentService.getDocuments(); + + const documents = documentProcesses.filter((process: any) => + process.processData.document_status === "VALIDATED" && + process.processData.folder.uid === folderUid + ); + + if (!documents || documents.length === 0) { + throw new Error(`Aucune demande de document trouvé pour le dossier ${folderUid}`); + } + + // Step 6: verify that the merkle proof match the last commited state for the file process + const stateId = JSON.parse(validationData.merkleProof)['root']; + + let stateIdExists = false; + for (const doc of documents) { + const processData = doc.processData; + + for (const file of processData.files) { + const fileUid = file.uid; + const fileProcess = await FileService.getFileByUid(fileUid); + const lastUpdatedStateId = fileProcess.lastUpdatedFileState.state_id; + + stateIdExists = lastUpdatedStateId === stateId; // we assume that last state is the validated document, that seems reasonable + if (stateIdExists) break; + } + + if (stateIdExists) break; + } + + if (!stateIdExists) { + throw new Error('La preuve fournie ne correspond à aucun document demandé pour ce dossier.'); + } + + setVerificationResult({ + success: true, + message: "✅ Vérification réussie ! Le document est authentique et intègre.", + }); + } catch (error) { + console.error("Verification failed:", error); + setVerificationResult({ + success: false, + message: `❌ Erreur lors de la vérification: ${error}`, + }); + } finally { + setIsVerifying(false); + } + }; + + const handleBackToFolder = () => { + const folderPath = Module.getInstance() + .get() + .modules.pages.Folder.pages.FolderInformation.props.path.replace("[folderUid]", folderUid); + router.push(folderPath); + }; + + const bothDocumentsPresent = documentToVerify && validationCertificate; + + return ( +
+
+ + Vérification de Documents + + + Vérifiez l'intégrité et l'authenticité de vos documents + +
+ +
+
+
+ + {documentToVerify && ( +
+ + ✓ {documentToVerify.name} + +
+ )} +
+ +
+ + {validationCertificate && ( +
+ + ✓ {validationCertificate.name} + +
+ )} +
+
+ + {!bothDocumentsPresent && ( +
+ + ⚠️ Veuillez sélectionner les deux documents pour procéder à la vérification + +
+ )} + + {verificationResult && ( +
+ + {verificationResult.message} + +
+ )} + +
+ + + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/front/Components/Layouts/Folder/FolderInformation/InformationSection/index.tsx b/src/front/Components/Layouts/Folder/FolderInformation/InformationSection/index.tsx index 6dff266c..ba604883 100644 --- a/src/front/Components/Layouts/Folder/FolderInformation/InformationSection/index.tsx +++ b/src/front/Components/Layouts/Folder/FolderInformation/InformationSection/index.tsx @@ -5,11 +5,12 @@ import { IItem } from "@Front/Components/DesignSystem/Menu/MenuItem"; import Tag, { ETagColor } from "@Front/Components/DesignSystem/Tag"; import Typography, { ETypo, ETypoColor } from "@Front/Components/DesignSystem/Typography"; import Module from "@Front/Config/Module"; -import { ArchiveBoxIcon, EllipsisHorizontalIcon, PaperAirplaneIcon, PencilSquareIcon, UsersIcon } from "@heroicons/react/24/outline"; +import { ArchiveBoxIcon, EllipsisHorizontalIcon, PaperAirplaneIcon, PencilSquareIcon, ShieldCheckIcon, UsersIcon } from "@heroicons/react/24/outline"; import classNames from "classnames"; import { OfficeFolder } from "le-coffre-resources/dist/Notary"; import Link from "next/link"; import { useMemo } from "react"; +import { useRouter } from "next/router"; import { AnchorStatus } from ".."; import classes from "./classes.module.scss"; @@ -24,6 +25,7 @@ type IProps = { export default function InformationSection(props: IProps) { const { folder, progress, onArchive, anchorStatus, isArchived } = props; + const router = useRouter(); const menuItemsDekstop = useMemo(() => { let elements: IItem[] = []; @@ -43,6 +45,17 @@ export default function InformationSection(props: IProps) { link: Module.getInstance() .get() .modules.pages.Folder.pages.EditInformations.props.path.replace("[folderUid]", folder?.uid ?? ""), + hasSeparator: true, + }; + const verifyDocumentElement = { + icon: , + text: "Vérifier le document", + onClick: () => { + const verifyPath = Module.getInstance() + .get() + .modules.pages.Folder.pages.VerifyDocuments.props.path.replace("[folderUid]", folder?.uid ?? ""); + router.push(verifyPath); + }, hasSeparator: false, }; @@ -52,8 +65,11 @@ export default function InformationSection(props: IProps) { elements.push(modifyInformationsElement); } + // Add verify document option + elements.push(verifyDocumentElement); + return elements; - }, [anchorStatus, folder?.uid]); + }, [anchorStatus, folder?.uid, router]); const menuItemsMobile = useMemo(() => { let elements: IItem[] = []; @@ -75,6 +91,17 @@ export default function InformationSection(props: IProps) { .modules.pages.Folder.pages.EditInformations.props.path.replace("[folderUid]", folder?.uid ?? ""), hasSeparator: true, }; + const verifyDocumentElement = { + icon: , + text: "Vérifier le document", + onClick: () => { + const verifyPath = Module.getInstance() + .get() + .modules.pages.Folder.pages.VerifyDocuments.props.path.replace("[folderUid]", folder?.uid ?? ""); + router.push(verifyPath); + }, + hasSeparator: true, + }; // If the folder is not anchored, we can modify the collaborators and the informations if (anchorStatus === AnchorStatus.NOT_ANCHORED) { @@ -82,6 +109,9 @@ export default function InformationSection(props: IProps) { elements.push(modifyInformationsElement); } + // Add verify document option + elements.push(verifyDocumentElement); + elements.push({ icon: , text: "Envoyer des documents", @@ -101,7 +131,7 @@ export default function InformationSection(props: IProps) { } return elements; - }, [anchorStatus, folder?.uid, isArchived, onArchive]); + }, [anchorStatus, folder?.uid, isArchived, onArchive, router]); return (
diff --git a/src/front/Config/Module/development.json b/src/front/Config/Module/development.json index ffbcdb99..a6032c66 100644 --- a/src/front/Config/Module/development.json +++ b/src/front/Config/Module/development.json @@ -160,6 +160,13 @@ "path": "/folders/select", "labelKey": "select_folder" } + }, + "VerifyDocuments": { + "enabled": true, + "props": { + "path": "/folders/[folderUid]/verify-documents", + "labelKey": "verify_documents" + } } } }, diff --git a/src/front/Config/Module/preprod.json b/src/front/Config/Module/preprod.json index ffbcdb99..a6032c66 100644 --- a/src/front/Config/Module/preprod.json +++ b/src/front/Config/Module/preprod.json @@ -160,6 +160,13 @@ "path": "/folders/select", "labelKey": "select_folder" } + }, + "VerifyDocuments": { + "enabled": true, + "props": { + "path": "/folders/[folderUid]/verify-documents", + "labelKey": "verify_documents" + } } } }, diff --git a/src/front/Config/Module/production.json b/src/front/Config/Module/production.json index ffbcdb99..a6032c66 100644 --- a/src/front/Config/Module/production.json +++ b/src/front/Config/Module/production.json @@ -160,6 +160,13 @@ "path": "/folders/select", "labelKey": "select_folder" } + }, + "VerifyDocuments": { + "enabled": true, + "props": { + "path": "/folders/[folderUid]/verify-documents", + "labelKey": "verify_documents" + } } } }, diff --git a/src/front/Config/Module/staging.json b/src/front/Config/Module/staging.json index ffbcdb99..a6032c66 100644 --- a/src/front/Config/Module/staging.json +++ b/src/front/Config/Module/staging.json @@ -160,6 +160,13 @@ "path": "/folders/select", "labelKey": "select_folder" } + }, + "VerifyDocuments": { + "enabled": true, + "props": { + "path": "/folders/[folderUid]/verify-documents", + "labelKey": "verify_documents" + } } } }, diff --git a/src/pages/folders/[folderUid]/verify-documents.tsx b/src/pages/folders/[folderUid]/verify-documents.tsx new file mode 100644 index 00000000..4c468779 --- /dev/null +++ b/src/pages/folders/[folderUid]/verify-documents.tsx @@ -0,0 +1,20 @@ +import { useRouter } from "next/router"; +import React from "react"; + +import DefaultNotaryDashboard from "@Front/Components/LayoutTemplates/DefaultNotaryDashboard"; +import DocumentVerification from "@Front/Components/Layouts/Folder/DocumentVerification"; + +export default function VerifyDocuments() { + const router = useRouter(); + const { folderUid } = router.query; + + if (!folderUid || Array.isArray(folderUid)) { + return null; + } + + return ( + + + + ); +} \ No newline at end of file