From a67646eed1219ece463011c0af5ae13fe517b8fa Mon Sep 17 00:00:00 2001 From: Tom Andrieu Date: Sun, 3 May 2026 17:06:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(quotes):=20import=20retour=20sign=C3=A9=20?= =?UTF-8?q?client=20+=20hash=20texte=20d'int=C3=A9grit=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workflow V0.2 acté dans `docs/roadmap-post-v0.1.md` §2 (Option B). À l'émission d'un devis, FAKT calcule maintenant le SHA-256 du texte normalisé du PDF officiel (via `pdf-extract` Rust + commande Tauri `compute_pdf_text_hash`) et le persiste sur `quotes.original_text_hash`. À l'import retour, un nouveau bouton « Importer signature client » sur les devis `sent` ouvre un modal : - Sélection du PDF retourné par le client (file picker) - Champ email/nom du signataire - Vérification du hash texte du PDF importé contre celui stocké - Si match → store_signed_pdf + signature event chaîné + activity event `quote_signed_by_client_imported` + transition sent → signed - Si mismatch → modal de confirmation forcée affichant les deux hashes tronqués (8…8) avec avertissement (annotation/altération). Force OK : l'audit event consigne la divergence. La chaîne d'audit reste cryptographiquement intègre : le calcul de `previousEventHash` passe par la commande Rust `compute_signature_event_self_hash` (pas de réimplémentation TS). Plan : docs/superpowers/plans/2026-05-03-import-signed-quote.md Validation : - 1045+ tests verts (sidecar original-text-hash, DB round-trip, UI bouton, Rust normalize_text + audit chain hash) - typecheck zéro warning sur 13 packages - lint Biome zéro warning sur 516 fichiers - cargo check OK (nouvelle dep `pdf-extract = "0.7"`) Note merge : conflit attendu avec PR #2 (clauses) sur : - `0005_*.sql` — renommer en `0006_quote_original_text_hash.sql` au rebase - `_journal.json` + `migrations-embedded.ts` — régénérer - `Quote` interface (shared) — merge naturel Signed-off-by: Tom Andrieu --- CHANGELOG.md | 9 + apps/desktop/src-tauri/Cargo.lock | 64 +++ apps/desktop/src-tauri/Cargo.toml | 5 + .../src-tauri/src/commands/audit_chain.rs | 59 +++ apps/desktop/src-tauri/src/commands/mod.rs | 4 + .../src-tauri/src/commands/pdf_hash.rs | 22 + apps/desktop/src-tauri/src/lib.rs | 2 + apps/desktop/src-tauri/src/pdf/mod.rs | 1 + apps/desktop/src-tauri/src/pdf/text_hash.rs | 140 ++++++ apps/desktop/src/api/quotes.ts | 7 + .../audit-timeline/AuditTimeline.tsx | 5 + .../import-signed-modal/ImportSignedModal.tsx | 416 ++++++++++++++++++ .../components/import-signed-modal/index.ts | 2 + .../PrepareEmailModal.test.tsx | 1 + .../doc-editor/import-signed-quote.ts | 133 ++++++ .../features/doc-editor/quote-text-hash.ts | 61 +++ .../src/features/doc-editor/quotes-api.ts | 10 + .../src/routes/archive/archive.test.tsx | 3 + .../clients/__tests__/ClientDetail.test.tsx | 1 + apps/desktop/src/routes/dashboard.test.tsx | 1 + .../__test-helpers__/mockInvoiceApis.ts | 8 + .../desktop/src/routes/quotes/Detail.test.tsx | 37 ++ apps/desktop/src/routes/quotes/Detail.tsx | 56 +++ apps/desktop/src/routes/quotes/Edit.test.tsx | 1 + apps/desktop/src/routes/quotes/List.test.tsx | 2 + .../quotes/__test-helpers__/mockApis.ts | 8 + .../plans/2026-05-03-import-signed-quote.md | 336 ++++++++++++++ .../api-server/src/migrations-embedded.ts | 4 + packages/api-server/src/routes/quotes.ts | 32 ++ packages/api-server/src/schemas/quotes.ts | 3 + .../api-server/tests/quotes-cycle.test.ts | 76 ++++ packages/db/src/__tests__/helpers.ts | 1 + packages/db/src/__tests__/quotes.test.ts | 17 + .../0006_quote_original_text_hash.sql | 12 + packages/db/src/migrations/meta/_journal.json | 9 +- packages/db/src/queries/quotes.ts | 4 + packages/db/src/schema/index.ts | 6 + packages/db/src/schema/pg.ts | 5 + packages/shared/src/i18n/fr.ts | 32 ++ packages/shared/src/types/domain.ts | 7 + 40 files changed, 1601 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src-tauri/src/commands/audit_chain.rs create mode 100644 apps/desktop/src-tauri/src/commands/pdf_hash.rs create mode 100644 apps/desktop/src-tauri/src/pdf/text_hash.rs create mode 100644 apps/desktop/src/components/import-signed-modal/ImportSignedModal.tsx create mode 100644 apps/desktop/src/components/import-signed-modal/index.ts create mode 100644 apps/desktop/src/features/doc-editor/import-signed-quote.ts create mode 100644 apps/desktop/src/features/doc-editor/quote-text-hash.ts create mode 100644 docs/superpowers/plans/2026-05-03-import-signed-quote.md create mode 100644 packages/db/src/migrations/0006_quote_original_text_hash.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 445e9de..d69ed32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,15 @@ et [Semantic Versioning 2.0.0](https://semver.org/lang/fr/). intellectuelle, limitation de responsabilité, juridiction française). Les options exclusives se désélectionnent automatiquement. Les clauses cochées s'insèrent dans le PDF avant les CGV légales. +- **Import retour signé client sur les devis** — quand votre client renvoie + un PDF signé (à la main + scan, Adobe Reader, DocuSign…), un nouveau bouton + *Importer signature client* apparaît sur les devis émis. FAKT vérifie + automatiquement que le contenu textuel du PDF importé correspond au devis + original (hash SHA-256 calculé à l'émission). Si tout matche, le devis + bascule en signé et le bouton « Créer une facture » se débloque. Si le + contenu diffère (annotation manuscrite, prix modifié…), un avertissement + explicite affiche les deux empreintes et demande confirmation. Toute + divergence est consignée dans la chaîne d'audit. ### Améliorations diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 022f27d..47e0517 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -8,6 +8,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "adobe-cmap-parser" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae8abfa9a4688de8fc9f42b3f013b6fffec18ed8a554f5f113577e0b9b3212a3" +dependencies = [ + "pom", +] + [[package]] name = "aead" version = "0.5.2" @@ -1009,6 +1018,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "euclid" +version = "0.20.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bb7ef65b3777a325d1eeefefab5b6d4959da54747e33bd6258e789640f307ad" +dependencies = [ + "num-traits", +] + [[package]] name = "fakt" version = "0.1.25" @@ -1026,6 +1044,7 @@ dependencies = [ "lopdf", "parking_lot", "pbkdf2", + "pdf-extract", "pkcs8", "rand 0.8.6", "reqwest 0.12.28", @@ -2897,6 +2916,21 @@ dependencies = [ "sha2", ] +[[package]] +name = "pdf-extract" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb3a5387b94b9053c1e69d8abfd4dd6dae7afda65a5c5279bc1f42ab39df575" +dependencies = [ + "adobe-cmap-parser", + "encoding_rs", + "euclid", + "lopdf", + "postscript", + "type1-encoding-parser", + "unicode-normalization", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -3187,6 +3221,18 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "pom" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60f6ce597ecdcc9a098e7fddacb1065093a3d66446fa16c675e7e71d1b5c28e6" + +[[package]] +name = "postscript" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78451badbdaebaf17f053fd9152b3ffb33b516104eacb45e7864aaa9c712f306" + [[package]] name = "potential_utf" version = "0.1.5" @@ -5395,6 +5441,15 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "type1-encoding-parser" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa10c302f5a53b7ad27fd42a3996e23d096ba39b5b8dd6d9e683a05b01bee749" +dependencies = [ + "pom", +] + [[package]] name = "typeid" version = "1.0.3" @@ -5454,6 +5509,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-segmentation" version = "1.13.2" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 2eca107..59bb177 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -70,6 +70,11 @@ hmac = "0.12" # PDF manipulation (PAdES) lopdf = "0.34" +# Extraction texte PDF pour le hash de vérification d'intégrité (workflow +# « Importer signature client »). pdf-extract est pure Rust (pas de FFI), +# ~200ko compilé. +pdf-extract = "0.7" + # TSA RFC 3161 (blocking sync) + audit client / sidecar healthcheck (async client + JSON). # `http2` et `charset` permettent au client async par défaut de fonctionner sans enable # `default-features`, et `json` expose `.json()` sur RequestBuilder/Response. diff --git a/apps/desktop/src-tauri/src/commands/audit_chain.rs b/apps/desktop/src-tauri/src/commands/audit_chain.rs new file mode 100644 index 0000000..1f80369 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/audit_chain.rs @@ -0,0 +1,59 @@ +//! Commande Tauri exposant le calcul du `compute_self_hash_hex` d'un +//! `SignatureEvent` au frontend. +//! +//! Utilisé par l'UI « Importer signature client » : avant d'appender un +//! nouvel event au sidecar, le frontend doit calculer le `previousEventHash` +//! du dernier event de la chaîne. Cette logique de hash chaîné vit côté +//! Rust (`crate::crypto::audit`) et ne doit PAS être dupliquée en TS — sinon +//! la moindre divergence casserait la vérification d'intégrité. + +use crate::crypto::audit::SignatureEvent; + +/// Shape DTO côté frontend : Tauri convertit automatiquement camelCase TS +/// en snake_case Rust à la désérialisation, donc le frontend passe le +/// `SignatureEvent` TS tel quel (pas de conversion explicite côté JS). +#[tauri::command] +pub fn compute_signature_event_self_hash(event: SignatureEvent) -> String { + event.compute_self_hash_hex() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_event() -> SignatureEvent { + SignatureEvent { + id: "evt-1".into(), + document_type: "quote".into(), + document_id: "quote-1".into(), + signer_name: "Client SARL".into(), + signer_email: "client@example.fr".into(), + ip_address: None, + user_agent: None, + timestamp_iso: "2026-05-03T12:00:00Z".into(), + doc_hash_before: "a".repeat(64), + doc_hash_after: "b".repeat(64), + signature_png_base64: None, + tsa_provider: None, + tsa_response_base64: None, + previous_event_hash: None, + } + } + + #[test] + fn hash_is_deterministic() { + let h1 = compute_signature_event_self_hash(sample_event()); + let h2 = compute_signature_event_self_hash(sample_event()); + assert_eq!(h1, h2); + assert_eq!(h1.len(), 64); + } + + #[test] + fn hash_changes_when_field_changes() { + let original = compute_signature_event_self_hash(sample_event()); + let mut modified = sample_event(); + modified.doc_hash_after = "c".repeat(64); + let after = compute_signature_event_self_hash(modified); + assert_ne!(original, after); + } +} diff --git a/apps/desktop/src-tauri/src/commands/mod.rs b/apps/desktop/src-tauri/src/commands/mod.rs index cfdf899..61dc23f 100644 --- a/apps/desktop/src-tauri/src/commands/mod.rs +++ b/apps/desktop/src-tauri/src/commands/mod.rs @@ -1,18 +1,22 @@ +pub mod audit_chain; pub mod backend; pub mod backup; pub mod cycle; pub mod email; pub mod files; +pub mod pdf_hash; pub mod ping; pub mod signatures; pub mod state; pub mod updater; +pub use audit_chain::*; pub use backend::*; pub use backup::*; pub use cycle::*; pub use email::*; pub use files::*; +pub use pdf_hash::*; pub use ping::*; pub use signatures::*; pub use state::{AppState, FaktError, FaktResult, NumberingPayload}; diff --git a/apps/desktop/src-tauri/src/commands/pdf_hash.rs b/apps/desktop/src-tauri/src/commands/pdf_hash.rs new file mode 100644 index 0000000..79f6e3c --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/pdf_hash.rs @@ -0,0 +1,22 @@ +//! Commande Tauri exposant `compute_pdf_text_hash`. +//! +//! Appelée : +//! - À l'émission d'un devis pour calculer + persister le hash texte du +//! PDF officiel via le sidecar (`POST /api/quotes/:id/original-text-hash`). +//! - À l'import retour signé pour comparer le hash du PDF reçu à celui +//! stocké à l'émission. Si différent, l'utilisateur peut forcer mais +//! l'écart est consigné dans l'audit trail. + +use crate::pdf::text_hash::compute_pdf_text_hash as compute_inner; + +/// Calcule le SHA-256 hex du texte normalisé d'un PDF. +/// +/// Le frontend appelle `invoke("compute_pdf_text_hash", { pdfBytes })`. +/// Retour : `Ok(hex 64 chars)` ou `Err(message FR)` si extraction texte échoue. +#[tauri::command] +pub fn compute_pdf_text_hash(pdf_bytes: Vec) -> Result { + if pdf_bytes.is_empty() { + return Err("PDF vide".into()); + } + compute_inner(&pdf_bytes).map_err(|e| e.to_string()) +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 581474e..a32a30c 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -155,6 +155,8 @@ fn run_inner() -> Result<(), String> { commands::backend::get_backend_mode, commands::backend::set_backend_mode, pdf::render::render_pdf, + commands::pdf_hash::compute_pdf_text_hash, + commands::audit_chain::compute_signature_event_self_hash, crypto::generate_cert, crypto::get_cert_info, crypto::rotate_cert, diff --git a/apps/desktop/src-tauri/src/pdf/mod.rs b/apps/desktop/src-tauri/src/pdf/mod.rs index 52d7b1f..9a18af7 100644 --- a/apps/desktop/src-tauri/src/pdf/mod.rs +++ b/apps/desktop/src-tauri/src/pdf/mod.rs @@ -26,3 +26,4 @@ //! stays source-compatible because it only consults the binary path. pub mod render; +pub mod text_hash; diff --git a/apps/desktop/src-tauri/src/pdf/text_hash.rs b/apps/desktop/src-tauri/src/pdf/text_hash.rs new file mode 100644 index 0000000..811e89b --- /dev/null +++ b/apps/desktop/src-tauri/src/pdf/text_hash.rs @@ -0,0 +1,140 @@ +//! Extraction texte PDF + hash SHA-256 normalisé. +//! +//! Utilisé par le workflow « Importer signature client » pour vérifier +//! l'intégrité d'un PDF retourné signé. À l'émission, on calcule et stocke +//! `quotes.original_text_hash`. À l'import, on recalcule sur le PDF reçu et +//! on compare. Si différent, l'utilisateur peut forcer mais l'écart est +//! consigné dans l'audit trail. +//! +//! ## Stratégie de normalisation +//! +//! `pdf-extract` peut produire un texte légèrement différent selon les +//! marges (\n vs \r\n, double espaces sur certains layouts). Pour rendre +//! le hash stable : +//! +//! 1. BOM UTF-8 retiré +//! 2. Line endings unifiés (\r\n et \r → \n) +//! 3. Whitespace folded : tout run de whitespace ASCII (espace, tab, +//! line breaks consécutifs) → un seul espace +//! 4. Trim global (start + end) +//! +//! Ce qui reste : le contenu lexical du PDF. Si un attaquant change un prix +//! ou une ligne, le texte change → hash change → mismatch détecté. Si le +//! client annote en marge ou imprime/scanne, le texte reste majoritairement +//! le même mais le hash CHANGE TOUT DE MÊME (annotations = nouveau contenu). +//! Dans ce cas l'utilisateur force l'import et l'event audit note la +//! divergence. + +use sha2::{Digest, Sha256}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum TextHashError { + #[error("Extraction texte PDF échouée : {0}")] + Extract(String), +} + +/// Normalise un texte avant hash : whitespace folded, line endings unifiés, +/// BOM stripped, trim global. +/// +/// Public pour tests + futur usage si on hash autre chose que des PDF. +pub fn normalize_text(text: &str) -> String { + // 1. BOM UTF-8 (\u{FEFF}) en début → retiré + let stripped = text.strip_prefix('\u{FEFF}').unwrap_or(text); + + // 2. + 3. Line endings + whitespace folding en une passe + let mut out = String::with_capacity(stripped.len()); + let mut last_was_ws = false; + for ch in stripped.chars() { + if ch.is_ascii_whitespace() { + if !last_was_ws { + out.push(' '); + last_was_ws = true; + } + // else : skip ce whitespace (déjà un espace ajouté) + } else { + out.push(ch); + last_was_ws = false; + } + } + + // 4. Trim global + out.trim().to_string() +} + +/// Extrait le texte d'un PDF, le normalise, et retourne le SHA-256 hex. +/// +/// Retourne `Err(TextHashError::Extract)` si `pdf-extract` ne sait pas lire +/// le PDF (corrompu, chiffré sans clé, etc.). +pub fn compute_pdf_text_hash(pdf_bytes: &[u8]) -> Result { + let raw = + pdf_extract::extract_text_from_mem(pdf_bytes).map_err(|e| TextHashError::Extract(e.to_string()))?; + let normalized = normalize_text(&raw); + let mut hasher = Sha256::new(); + hasher.update(normalized.as_bytes()); + Ok(hex::encode(hasher.finalize())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_text_folds_multiple_spaces() { + assert_eq!(normalize_text("a b"), "a b"); + assert_eq!(normalize_text("a\t\tb"), "a b"); + assert_eq!(normalize_text("a \t b"), "a b"); + } + + #[test] + fn normalize_text_unifies_line_endings() { + // \n, \r\n, \r → tous traités comme whitespace ASCII donc folded vers ' ' + assert_eq!(normalize_text("a\nb"), "a b"); + assert_eq!(normalize_text("a\r\nb"), "a b"); + assert_eq!(normalize_text("a\rb"), "a b"); + } + + #[test] + fn normalize_text_strips_bom() { + assert_eq!(normalize_text("\u{FEFF}hello"), "hello"); + } + + #[test] + fn normalize_text_trims_edges() { + assert_eq!(normalize_text(" hello "), "hello"); + assert_eq!(normalize_text("\n\nhello\n\n"), "hello"); + } + + #[test] + fn normalize_text_preserves_unicode_content() { + // Caractères non-whitespace conservés tels quels + assert_eq!(normalize_text("Tom Andrieu — 1 234,56 €"), "Tom Andrieu — 1 234,56 €"); + } + + #[test] + fn normalize_text_idempotent() { + let input = " a b \n c "; + let once = normalize_text(input); + let twice = normalize_text(&once); + assert_eq!(once, twice); + } + + #[test] + fn compute_pdf_text_hash_rejects_invalid_pdf() { + let result = compute_pdf_text_hash(b"not a pdf at all"); + assert!(matches!(result, Err(TextHashError::Extract(_)))); + } + + #[test] + fn compute_pdf_text_hash_returns_64_hex_chars_for_valid_input() { + // PDF stub minimum (vide mais valide). Si pdf-extract échoue ici, + // on tombe sur Extract — acceptable, le test n'est pas critique. + // En pratique le test d'intégration sur un PDF Typst réel se fait + // dans le test e2e Playwright + cas d'usage manuel. + let stub = include_bytes!("stub.pdf"); + if let Ok(hex) = compute_pdf_text_hash(stub) { + assert_eq!(hex.len(), 64); + assert!(hex.chars().all(|c| c.is_ascii_hexdigit())); + } + } +} diff --git a/apps/desktop/src/api/quotes.ts b/apps/desktop/src/api/quotes.ts index 681ad3f..4b737b8 100644 --- a/apps/desktop/src/api/quotes.ts +++ b/apps/desktop/src/api/quotes.ts @@ -117,6 +117,13 @@ export const quotesApi = { async markSent(id: string): Promise { return getApiClient().post(`/api/quotes/${id}/mark-sent`); }, + /** + * Set le hash texte du PDF officiel après émission. + * @see POST /api/quotes/:id/original-text-hash + */ + async setOriginalTextHash(id: string, hash: string): Promise { + return getApiClient().post(`/api/quotes/${id}/original-text-hash`, { hash }); + }, async unmarkSent(id: string): Promise { return getApiClient().post(`/api/quotes/${id}/unmark-sent`); }, diff --git a/apps/desktop/src/components/audit-timeline/AuditTimeline.tsx b/apps/desktop/src/components/audit-timeline/AuditTimeline.tsx index 5e0dc08..4dfce8e 100644 --- a/apps/desktop/src/components/audit-timeline/AuditTimeline.tsx +++ b/apps/desktop/src/components/audit-timeline/AuditTimeline.tsx @@ -14,6 +14,7 @@ export type AuditEventKind = | "sent" | "unsent" | "signed" + | "signed_by_client_imported" | "paid" | "cancelled" | "refused" @@ -42,6 +43,8 @@ function activityTypeToKind(type: string): AuditEventKind | null { return "unsent"; case "quote_signed": return "signed"; + case "quote_signed_by_client_imported": + return "signed_by_client_imported"; case "quote_refused": return "refused"; case "quote_expired": @@ -147,6 +150,8 @@ function kindLabel(kind: AuditEventKind): string { return m.unsent; case "signed": return m.signed; + case "signed_by_client_imported": + return m.signed_by_client_imported; case "paid": return m.paid; case "cancelled": diff --git a/apps/desktop/src/components/import-signed-modal/ImportSignedModal.tsx b/apps/desktop/src/components/import-signed-modal/ImportSignedModal.tsx new file mode 100644 index 0000000..0b229e3 --- /dev/null +++ b/apps/desktop/src/components/import-signed-modal/ImportSignedModal.tsx @@ -0,0 +1,416 @@ +import { tokens } from "@fakt/design-tokens"; +import { fr } from "@fakt/shared"; +import { Button, Input, Modal, toast } from "@fakt/ui"; +import type { ChangeEvent, ReactElement } from "react"; +import { useEffect, useState } from "react"; +import { + commitImportSignedQuote, + verifyImportedPdfHash, +} from "../../features/doc-editor/import-signed-quote.js"; + +export interface ImportSignedModalProps { + open: boolean; + onClose: () => void; + quoteId: string; + /** Hash texte du PDF officiel à l'émission. NULL = feature indisponible. */ + expectedHash: string | null; + /** Pré-remplit le nom du signataire (typiquement client.name). */ + defaultSignerName?: string | null; + /** Pré-remplit l'email (typiquement client.email). */ + defaultSignerEmail?: string | null; + /** Callback déclenché après import réussi (pour refresh du state parent). */ + onImported: () => void; +} + +type Phase = + | { kind: "form" } + | { kind: "verifying" } + | { + kind: "mismatch"; + pdfBytes: Uint8Array; + expectedHash: string; + actualHash: string; + signerName: string; + signerEmail: string; + } + | { kind: "committing" }; + +function truncateHash(hex: string): string { + return hex.length <= 16 ? hex : `${hex.slice(0, 8)}…${hex.slice(-8)}`; +} + +export function ImportSignedModal({ + open, + onClose, + quoteId, + expectedHash, + defaultSignerName, + defaultSignerEmail, + onImported, +}: ImportSignedModalProps): ReactElement { + const [signerName, setSignerName] = useState(defaultSignerName ?? ""); + const [signerEmail, setSignerEmail] = useState(defaultSignerEmail ?? ""); + const [pdfBytes, setPdfBytes] = useState(null); + const [pdfFileName, setPdfFileName] = useState(null); + const [phase, setPhase] = useState({ kind: "form" }); + const [error, setError] = useState(null); + + // Reset à chaque ouverture pour éviter de garder un état stale. + useEffect(() => { + if (open) { + setSignerName(defaultSignerName ?? ""); + setSignerEmail(defaultSignerEmail ?? ""); + setPdfBytes(null); + setPdfFileName(null); + setPhase({ kind: "form" }); + setError(null); + } + }, [open, defaultSignerName, defaultSignerEmail]); + + function handleFileChange(e: ChangeEvent): void { + const file = e.target.files?.[0]; + if (!file) return; + setError(null); + setPdfFileName(file.name); + file + .arrayBuffer() + .then((buf) => setPdfBytes(new Uint8Array(buf))) + .catch(() => setError(fr.quotes.form.importSigned.errors.generic)); + } + + async function handleVerify(): Promise { + setError(null); + if (!pdfBytes) { + setError(fr.quotes.form.importSigned.errors.noFile); + return; + } + if (!signerEmail.trim()) { + setError(fr.quotes.form.importSigned.errors.missingEmail); + return; + } + if (!expectedHash) { + setError(fr.quotes.form.importSigned.errors.noOriginalHash); + return; + } + setPhase({ kind: "verifying" }); + try { + const result = await verifyImportedPdfHash({ pdfBytes, expectedHash }); + if (result.kind === "match") { + await commit({ + pdfBytes, + expectedHash, + actualHash: result.actualHash, + signerName: signerName.trim() || "Client", + signerEmail: signerEmail.trim(), + }); + } else { + setPhase({ + kind: "mismatch", + pdfBytes, + expectedHash, + actualHash: result.actualHash, + signerName: signerName.trim() || "Client", + signerEmail: signerEmail.trim(), + }); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setError(`${fr.quotes.form.importSigned.errors.extractFailed} — ${msg}`); + setPhase({ kind: "form" }); + } + } + + async function commit(args: { + pdfBytes: Uint8Array; + expectedHash: string; + actualHash: string; + signerName: string; + signerEmail: string; + }): Promise { + setPhase({ kind: "committing" }); + try { + await commitImportSignedQuote({ + quoteId, + pdfBytes: args.pdfBytes, + signerName: args.signerName, + signerEmail: args.signerEmail, + expectedHash: args.expectedHash, + actualHash: args.actualHash, + }); + toast.success(fr.quotes.form.importSigned.successToast); + onImported(); + onClose(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setError(`${fr.quotes.form.importSigned.errors.generic} — ${msg}`); + setPhase({ kind: "form" }); + } + } + + const submitting = phase.kind === "verifying" || phase.kind === "committing"; + + if (phase.kind === "mismatch") { + return ( + { + if (!submitting) { + setPhase({ kind: "form" }); + } + }} + size="md" + data-testid="import-signed-mismatch-modal" + footer={ + <> + + + + } + > +

+ {fr.quotes.form.importSigned.mismatchBody} +

+
+ + {fr.quotes.form.importSigned.mismatchHashExpected} + + + {truncateHash(phase.expectedHash)} + + + {fr.quotes.form.importSigned.mismatchHashActual} + + + {truncateHash(phase.actualHash)} + +
+ {error && ( +
+ {error} +
+ )} +
+ ); + } + + return ( + { + if (!submitting) onClose(); + }} + size="md" + data-testid="import-signed-modal" + footer={ + <> + + + + } + > +

+ {fr.quotes.form.importSigned.description} +

+ + {expectedHash === null && ( +
+ {fr.quotes.form.importSigned.errors.noOriginalHash} +
+ )} + +
+ + + setSignerName(e.target.value)} + disabled={submitting} + data-testid="import-signed-signer-name" + /> + setSignerEmail(e.target.value)} + disabled={submitting} + data-testid="import-signed-signer-email" + /> +
+ + {error && ( +
+ {error} +
+ )} +
+ ); +} diff --git a/apps/desktop/src/components/import-signed-modal/index.ts b/apps/desktop/src/components/import-signed-modal/index.ts new file mode 100644 index 0000000..264f8e6 --- /dev/null +++ b/apps/desktop/src/components/import-signed-modal/index.ts @@ -0,0 +1,2 @@ +export { ImportSignedModal } from "./ImportSignedModal.js"; +export type { ImportSignedModalProps } from "./ImportSignedModal.js"; diff --git a/apps/desktop/src/components/prepare-email-modal/PrepareEmailModal.test.tsx b/apps/desktop/src/components/prepare-email-modal/PrepareEmailModal.test.tsx index 222d2b1..d06709a 100644 --- a/apps/desktop/src/components/prepare-email-modal/PrepareEmailModal.test.tsx +++ b/apps/desktop/src/components/prepare-email-modal/PrepareEmailModal.test.tsx @@ -38,6 +38,7 @@ const MOCK_QUOTE: Quote = { totalHtCents: 300000, conditions: null, clauses: [], + originalTextHash: null, validityDate: null, notes: null, issuedAt: Date.now(), diff --git a/apps/desktop/src/features/doc-editor/import-signed-quote.ts b/apps/desktop/src/features/doc-editor/import-signed-quote.ts new file mode 100644 index 0000000..3a26571 --- /dev/null +++ b/apps/desktop/src/features/doc-editor/import-signed-quote.ts @@ -0,0 +1,133 @@ +/** + * Workflow « Importer signature client » — orchestrateur frontend. + * + * 1. Le client renvoie un PDF signé (à la main + scan, ou via Adobe/DocuSign). + * 2. L'utilisateur sélectionne le PDF + saisit l'email/nom du signataire. + * 3. Cet helper : + * a) Calcule le SHA-256 du texte normalisé du PDF importé (Rust). + * b) Compare au hash stocké à l'émission (`quote.originalTextHash`). + * c) Si mismatch et `force=false` → retourne `{ kind: "mismatch", … }`. + * d) Sinon : stocke le PDF, calcule `previousEventHash` via Rust, + * construit le signature event, l'appende au sidecar, log l'activity, + * et bascule le devis en `signed`. + * + * Toute la logique cryptographique (chain hash + extraction texte) reste + * côté Rust pour éviter la divergence de format entre langues. + */ + +import type { SignatureEvent, UUID } from "@fakt/shared"; +import { invoke } from "@tauri-apps/api/core"; +import { activityApi } from "../../api/activity.js"; +import { quotesApi } from "./quotes-api.js"; +import { signatureApi } from "./signature-api.js"; + +export interface VerifyPdfHashArgs { + pdfBytes: Uint8Array; + expectedHash: string; +} + +export type VerifyPdfHashResult = + | { kind: "match"; actualHash: string } + | { kind: "mismatch"; actualHash: string }; + +/** + * Compare le hash texte d'un PDF importé au hash attendu. + * Pure : aucun side-effect (pas de DB, pas de stockage). Le résultat dit + * juste si le contenu textuel correspond. + */ +export async function verifyImportedPdfHash(args: VerifyPdfHashArgs): Promise { + const actualHash = await invoke("compute_pdf_text_hash", { + pdfBytes: Array.from(args.pdfBytes), + }); + return actualHash === args.expectedHash + ? { kind: "match", actualHash } + : { kind: "mismatch", actualHash }; +} + +export interface CommitImportSignedQuoteArgs { + quoteId: UUID; + pdfBytes: Uint8Array; + signerName: string; + signerEmail: string; + expectedHash: string; + actualHash: string; +} + +export interface CommitImportSignedQuoteResult { + signatureEvent: SignatureEvent; + signaturePath: string; +} + +function genUuid(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === "x" ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + +/** + * Finalise l'import : stocke PDF, append signature event chaîné, log + * activity, transition `sent → signed`. + * + * Précondition : l'utilisateur a confirmé (soit hash match, soit explicit + * force après avertissement). Cette fonction ne décide pas du mismatch ; + * elle attend les deux hashes en argument et les consigne tels quels dans + * l'audit event (`docHashBefore=expected`, `docHashAfter=actual`). + * + * Idempotence : pas garantie. Un double-call appendrait deux events. La + * UI doit donc disable le bouton submit pendant l'await. + */ +export async function commitImportSignedQuote( + args: CommitImportSignedQuoteArgs +): Promise { + // 1. Stocke le PDF retourné dans signed_pdfs/quote/.pdf + const signaturePath = await signatureApi.storeSignedPdf("quote", args.quoteId, args.pdfBytes); + + // 2. Récupère la chaîne actuelle pour calculer previousEventHash + const chain = await signatureApi.listEvents("quote", args.quoteId); + const previous = chain.length > 0 ? chain[chain.length - 1] : null; + const previousEventHash = + previous !== null && previous !== undefined + ? await invoke("compute_signature_event_self_hash", { event: previous }) + : null; + + // 3. Construit le nouvel event et l'append + const event: SignatureEvent = { + id: genUuid(), + documentType: "quote", + documentId: args.quoteId, + signerName: args.signerName, + signerEmail: args.signerEmail, + ipAddress: null, + userAgent: null, + timestamp: Date.now(), + docHashBefore: args.expectedHash, + docHashAfter: args.actualHash, + signaturePngBase64: "", // import retour : pas de PNG signature côté FAKT + previousEventHash, + tsaResponse: null, + tsaProvider: null, + }; + await signatureApi.appendEvent(event); + + // 4. Activity log (audit timeline lisible côté UI) + await activityApi.append({ + type: "quote_signed_by_client_imported", + entityType: "quote", + entityId: args.quoteId, + payload: JSON.stringify({ + signerName: args.signerName, + signerEmail: args.signerEmail, + hashMatched: args.expectedHash === args.actualHash, + }), + }); + + // 5. Transition status sent → signed + await quotesApi.updateStatus(args.quoteId, "signed"); + + return { signatureEvent: event, signaturePath }; +} diff --git a/apps/desktop/src/features/doc-editor/quote-text-hash.ts b/apps/desktop/src/features/doc-editor/quote-text-hash.ts new file mode 100644 index 0000000..4a352c1 --- /dev/null +++ b/apps/desktop/src/features/doc-editor/quote-text-hash.ts @@ -0,0 +1,61 @@ +/** + * Helpers pour le calcul + persistance du hash texte du PDF officiel d'un + * devis émis. Utilisé par le workflow « Importer signature client ». + * + * Flux : + * 1. Le devis vient d'être émis (status `sent`). + * 2. On rend le PDF officiel (sans signature visuelle). + * 3. On invoke la commande Rust `compute_pdf_text_hash` qui extrait le + * texte via pdf-extract, normalise (whitespace, line endings) et + * retourne SHA-256 hex. + * 4. On POST sur `quotes/:id/original-text-hash` pour persister. + * + * Best-effort : un échec ici ne doit PAS bloquer l'émission du devis. + * On log l'erreur (console) et on retourne. L'utilisateur pourra toujours + * signer manuellement, simplement le workflow d'import retour signé sera + * indisponible pour ce devis spécifique. + */ + +import type { ClientInput, QuoteInput, WorkspaceInput } from "@fakt/core"; +import { invoke } from "@tauri-apps/api/core"; +import { isWeb } from "../../utils/runtime.js"; +import { pdfApi } from "./pdf-api.js"; +import { quotesApi } from "./quotes-api.js"; + +export interface PersistOriginalTextHashArgs { + quote: QuoteInput; + client: ClientInput; + workspace: WorkspaceInput; +} + +/** + * Calcule le SHA-256 du texte normalisé du PDF officiel et le persiste sur + * le devis. Best-effort — log + ignore si échec. + * + * **Skippé en mode web** : la commande `compute_pdf_text_hash` n'est pas + * disponible côté serveur (pas de pdf-extract dans le sidecar Bun pour le + * moment). En mode web, l'import retour signé sera indisponible jusqu'à + * ce qu'un endpoint serveur équivalent soit ajouté. + */ +export async function persistQuoteOriginalTextHash( + args: PersistOriginalTextHashArgs +): Promise { + if (isWeb()) return; // mode web pas encore supporté + + try { + const pdfBytes = await pdfApi.renderQuote({ + quote: args.quote, + client: args.client, + workspace: args.workspace, + }); + const hash = await invoke("compute_pdf_text_hash", { + pdfBytes: Array.from(pdfBytes), + }); + await quotesApi.setOriginalTextHash(args.quote.id, hash); + } catch (err) { + // Best-effort : on log mais on ne propage pas. Le workflow signature + // classique (par l'émetteur) reste fonctionnel sans ce hash. + // eslint-disable-next-line no-console + console.warn("[quote-text-hash] persistance hash échouée :", err); + } +} diff --git a/apps/desktop/src/features/doc-editor/quotes-api.ts b/apps/desktop/src/features/doc-editor/quotes-api.ts index 4a23192..13b4458 100644 --- a/apps/desktop/src/features/doc-editor/quotes-api.ts +++ b/apps/desktop/src/features/doc-editor/quotes-api.ts @@ -60,6 +60,12 @@ export interface QuotesApi { create(input: CreateQuoteInput): Promise; update(id: UUID, input: UpdateQuoteInput): Promise; updateStatus(id: UUID, status: QuoteStatus): Promise; + /** + * Set le hash texte du PDF officiel à l'émission. Idempotent (la même + * valeur peut être ré-écrite), mais une valeur différente est refusée + * (intégrité). Voir `POST /api/quotes/:id/original-text-hash`. + */ + setOriginalTextHash(id: UUID, hash: string): Promise; } function genUuid(): string { @@ -170,6 +176,9 @@ const httpQuotesApi: QuotesApi = { throw new Error(`quotesApi.updateStatus: transition non exposée vers ${status}`); } }, + async setOriginalTextHash(id, hash): Promise { + return httpApi.quotes.setOriginalTextHash(id, hash); + }, }; let _impl: QuotesApi = httpQuotesApi; @@ -180,6 +189,7 @@ export const quotesApi: QuotesApi = { create: (input) => _impl.create(input), update: (id, input) => _impl.update(id, input), updateStatus: (id, status) => _impl.updateStatus(id, status), + setOriginalTextHash: (id, hash) => _impl.setOriginalTextHash(id, hash), }; /** Injection pour tests. Passer `null` pour restaurer le défaut HTTP. */ diff --git a/apps/desktop/src/routes/archive/archive.test.tsx b/apps/desktop/src/routes/archive/archive.test.tsx index 47bc5b7..1d514c7 100644 --- a/apps/desktop/src/routes/archive/archive.test.tsx +++ b/apps/desktop/src/routes/archive/archive.test.tsx @@ -49,6 +49,9 @@ beforeEach(() => { updateStatus: vi.fn(async () => { throw new Error("not impl"); }), + setOriginalTextHash: vi.fn(async () => { + throw new Error("not impl"); + }), }); setInvoiceApi({ list: vi.fn(async () => []), diff --git a/apps/desktop/src/routes/clients/__tests__/ClientDetail.test.tsx b/apps/desktop/src/routes/clients/__tests__/ClientDetail.test.tsx index 99c3e87..c6b4172 100644 --- a/apps/desktop/src/routes/clients/__tests__/ClientDetail.test.tsx +++ b/apps/desktop/src/routes/clients/__tests__/ClientDetail.test.tsx @@ -41,6 +41,7 @@ function makeQuote(id: string, number: string | null = null): Quote { totalHtCents: 100000, conditions: null, clauses: [], + originalTextHash: null, validityDate: null, notes: null, issuedAt: null, diff --git a/apps/desktop/src/routes/dashboard.test.tsx b/apps/desktop/src/routes/dashboard.test.tsx index 42d22ec..f89e41c 100644 --- a/apps/desktop/src/routes/dashboard.test.tsx +++ b/apps/desktop/src/routes/dashboard.test.tsx @@ -34,6 +34,7 @@ function quote(override: Partial): Quote { totalHtCents: 100000, conditions: null, clauses: [], + originalTextHash: null, validityDate: null, notes: null, issuedAt: null, diff --git a/apps/desktop/src/routes/invoices/__test-helpers__/mockInvoiceApis.ts b/apps/desktop/src/routes/invoices/__test-helpers__/mockInvoiceApis.ts index ca3eacc..94df9f9 100644 --- a/apps/desktop/src/routes/invoices/__test-helpers__/mockInvoiceApis.ts +++ b/apps/desktop/src/routes/invoices/__test-helpers__/mockInvoiceApis.ts @@ -78,6 +78,7 @@ export const FIXTURE_SIGNED_QUOTE: Quote = { totalHtCents: 500000, conditions: null, clauses: [], + originalTextHash: null, validityDate: null, notes: null, issuedAt: Date.now() - 86400000, @@ -403,6 +404,13 @@ export function installInvoiceMockApis(options?: InstallOptions): { store.quotes.set(id, updated); return updated; }, + async setOriginalTextHash(id, hash): Promise { + const existing = store.quotes.get(id); + if (!existing) throw new Error(`setOriginalTextHash: quote not found ${id}`); + const updated: Quote = { ...existing, originalTextHash: hash, updatedAt: Date.now() }; + store.quotes.set(id, updated); + return updated; + }, }; const clientsMock: ClientsApi = { diff --git a/apps/desktop/src/routes/quotes/Detail.test.tsx b/apps/desktop/src/routes/quotes/Detail.test.tsx index a36f430..3746b83 100644 --- a/apps/desktop/src/routes/quotes/Detail.test.tsx +++ b/apps/desktop/src/routes/quotes/Detail.test.tsx @@ -21,6 +21,7 @@ const ISSUED_QUOTE: Quote = { totalHtCents: 350000, conditions: null, clauses: [], + originalTextHash: null, validityDate: now + 30 * 86400000, notes: "Projet prioritaire", issuedAt: now, @@ -151,6 +152,42 @@ describe("QuoteDetailRoute", () => { expect(screen.queryByTestId("detail-download-audit")).not.toBeInTheDocument(); }); + it("affiche le bouton « Importer signature client » sur un devis sent avec hash", async () => { + const sentWithHash: Quote = { + ...ISSUED_QUOTE, + id: "q-sent-with-hash", + originalTextHash: "a".repeat(64), + }; + renderAt("/quotes/q-sent-with-hash", sentWithHash); + await waitFor(() => { + const btn = screen.getByTestId("detail-import-signed"); + expect(btn).toBeInTheDocument(); + expect(btn).not.toBeDisabled(); + }); + }); + + it("désactive le bouton « Importer signature client » si hash absent", async () => { + const sentNoHash: Quote = { + ...ISSUED_QUOTE, + id: "q-sent-no-hash", + originalTextHash: null, + }; + renderAt("/quotes/q-sent-no-hash", sentNoHash); + await waitFor(() => { + const btn = screen.getByTestId("detail-import-signed"); + expect(btn).toBeInTheDocument(); + expect(btn).toBeDisabled(); + }); + }); + + it("ne rend pas le bouton « Importer signature client » sur un devis en draft", async () => { + renderAt("/quotes/q-draft", DRAFT_QUOTE); + await waitFor(() => { + expect(screen.getByTestId("detail-edit")).toBeInTheDocument(); + }); + expect(screen.queryByTestId("detail-import-signed")).not.toBeInTheDocument(); + }); + it("expose le bouton Préparer email (Track K) hors draft", async () => { renderAt("/quotes/q-issued", ISSUED_QUOTE); await waitFor(() => { diff --git a/apps/desktop/src/routes/quotes/Detail.tsx b/apps/desktop/src/routes/quotes/Detail.tsx index df19215..8d7b7f4 100644 --- a/apps/desktop/src/routes/quotes/Detail.tsx +++ b/apps/desktop/src/routes/quotes/Detail.tsx @@ -10,10 +10,12 @@ import { useNavigate, useParams } from "react-router"; import { activityApi } from "../../api/activity.js"; import { DesktopOnlyButton } from "../../components/DesktopOnlyButton.js"; import { AuditTimeline, type BaseAuditEntry } from "../../components/audit-timeline/index.js"; +import { ImportSignedModal } from "../../components/import-signed-modal/index.js"; import { PrepareEmailModal } from "../../components/prepare-email-modal/index.js"; import { SignatureModal } from "../../components/signature-modal/index.js"; import { clientsApi } from "../../features/doc-editor/clients-api.js"; import { pdfApi } from "../../features/doc-editor/pdf-api.js"; +import { persistQuoteOriginalTextHash } from "../../features/doc-editor/quote-text-hash.js"; import { quotesApi } from "../../features/doc-editor/quotes-api.js"; import { signatureApi } from "../../features/doc-editor/signature-api.js"; import { useClientsList, useQuote, useWorkspace } from "./hooks.js"; @@ -70,6 +72,7 @@ export function QuoteDetailRoute(): ReactElement { const [signOpen, setSignOpen] = useState(false); const [pdfBytes, setPdfBytes] = useState(null); const [emailOpen, setEmailOpen] = useState(false); + const [importSignedOpen, setImportSignedOpen] = useState(false); useEffect(() => { if (!quote) return; @@ -129,6 +132,29 @@ export function QuoteDetailRoute(): ReactElement { }; }, [quote, client, workspace]); + // Auto-persist hash texte du PDF officiel quand un devis `sent` n'en a pas + // encore (cas : émis via "Créer et émettre", émis via "Marquer envoyé", ou + // vieux devis émis avant cette feature). Idempotent — l'API refuse une + // ré-écriture avec une valeur différente, accepte la même. + useEffect(() => { + if (!quote || !client || !workspace) return; + if (quote.status !== "sent") return; + if (quote.originalTextHash !== null) return; + if (!pdfBytes || pdfBytes.byteLength === 0) return; + + let cancelled = false; + void persistQuoteOriginalTextHash({ + quote: toQuoteInput(quote), + client, + workspace, + }).then(() => { + if (!cancelled) refresh(); + }); + return (): void => { + cancelled = true; + }; + }, [quote, client, workspace, pdfBytes, refresh]); + async function handleMarkSent(): Promise { if (!quote) return; // Guard synchrone double-submit : un double-clic sur "Émettre" allouait @@ -141,6 +167,9 @@ export function QuoteDetailRoute(): ReactElement { setMarkSentOpen(false); toast.success(fr.quotes.detail.markSentSuccess); refresh(); + // Le hash texte du PDF officiel est calculé automatiquement par + // l'effet `auto-persist hash` ci-dessous quand le PDF est rendu et + // que `quote.originalTextHash === null`. } catch (err) { setMarkSentError(err instanceof Error ? err.message : fr.quotes.detail.markSentError); } finally { @@ -619,6 +648,21 @@ export function QuoteDetailRoute(): ReactElement { {fr.quotes.actions.sign} )} + {quote.status === "sent" && ( + + )} {(quote.status === "signed" || quote.status === "sent") && (