diff --git a/GUIDE_TEST_INTERFACE_ODENTAS_SIGN.md b/GUIDE_TEST_INTERFACE_ODENTAS_SIGN.md new file mode 100644 index 0000000..849589e --- /dev/null +++ b/GUIDE_TEST_INTERFACE_ODENTAS_SIGN.md @@ -0,0 +1,290 @@ +# 🚀 Guide de Test Rapide - Interface Odentas Sign + +## ⚡ DĂ©marrage rapide (2 minutes) + +### 1. PrĂ©requis + +```bash +# VĂ©rifier que Next.js tourne +npm run dev + +# Dans un autre terminal, crĂ©er une demande de test +node test-odentas-sign.js +``` + +### 2. Ouvrir l'interface + +```bash +# Script interactif pour tester facilement +./test-interface-signature.sh + +# Choisir option 1 ou 2 pour ouvrir dans le navigateur +``` + +### 3. Flow de test complet + +#### 🔐 Étape 1 : VĂ©rification OTP + +1. Sur la page `/signer/[requestId]/[signerId]` +2. Cliquer sur **"Recevoir le code"** +3. ⚠ **Important** : L'OTP s'affiche dans les **logs du serveur Next.js** +4. Chercher dans le terminal : + +``` +⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐ + MODE TEST - Code OTP + Email: renaud.breviere@gmail.com + Code: 123456 + Valide pendant 15 minutes +⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐ +``` + +5. Copier le code Ă  6 chiffres +6. Le coller dans l'interface (auto-focus + auto-submit) +7. ✅ Validation automatique → Passage Ă  l'Ă©tape signature + +#### ✍ Étape 2 : Signature + +1. Dessiner votre signature dans le canvas (souris/trackpad/doigt) +2. Si pas satisfait : cliquer **"Recommencer"** +3. Cocher la case de consentement +4. Cliquer **"Valider ma signature"** +5. ✅ Signature enregistrĂ©e → Passage Ă  la confirmation + +#### 🎉 Étape 3 : Confirmation + +1. Animation de confetti 🎊 +2. Voir les dĂ©tails : date, rĂ©fĂ©rence, progression +3. Tester avec les 2 signataires : + - Employeur (paie@odentas.fr) + - SalariĂ© (renaud.breviere@gmail.com) +4. Quand les 2 ont signĂ© → Message "Document finalisĂ©" + +## 📊 VĂ©rifier les donnĂ©es + +### Dans le script interactif + +```bash +./test-interface-signature.sh + +# Choisir option 5 : VĂ©rifier le statut +``` + +Affiche: +- Qui a signĂ© +- Date de signature +- Progression (X/Y) + +### En base de donnĂ©es (Supabase) + +```sql +-- Voir les Ă©vĂ©nements rĂ©cents +SELECT + event_type, + created_at, + metadata +FROM sign_events +ORDER BY created_at DESC +LIMIT 20; + +-- Voir le statut des signataires +SELECT + name, + role, + has_signed, + signed_at +FROM signers +WHERE request_id = ''; +``` + +### En S3 + +```bash +# Lister les signatures uploadĂ©es +aws s3 ls s3://odentas-sign/signatures/TEST-*/ + +# Exemple de rĂ©sultat +# 2025-01-23 15:30:45 12345 95c4ccdc-1a26-4426-a56f-653758159b54.png +# 2025-01-23 15:32:12 13456 d481f070-2ac6-4f82-aff3-862783904d5d.png +``` + +## đŸ§Ș ScĂ©narios de test + +### ✅ Happy Path + +1. Employeur reçoit OTP → Valide → Signe → OK +2. SalariĂ© reçoit OTP → Valide → Signe → OK +3. Document finalisĂ© → Tous les 2 voient "Document finalisĂ©" + +### ❌ Cas d'erreur + +#### OTP invalide (3 tentatives max) + +``` +1. Entrer "000000" → Erreur + "2 tentatives restantes" +2. Entrer "111111" → Erreur + "1 tentative restante" +3. Entrer "222222" → Erreur + "0 tentative restante" + Blocage +``` + +#### OTP expirĂ© (15 minutes) + +``` +1. Recevoir OTP +2. Attendre 15+ minutes +3. Essayer de valider → Erreur "Code expirĂ©" +4. Cliquer "Renvoyer le code" +``` + +#### Signature vide + +``` +1. Ne rien dessiner +2. Essayer de cocher le consentement → Bouton dĂ©sactivĂ© +3. Le bouton reste grisĂ© tant que canvas vide +``` + +#### Session expirĂ©e (30 minutes) + +``` +1. Valider OTP +2. Attendre 30+ minutes sur page signature +3. Essayer de signer → Erreur 401 "Session expirĂ©e" +4. Retour automatique Ă  l'Ă©tape OTP +``` + +## đŸ“± Test Mobile + +### iOS Safari + +``` +1. Ouvrir Safari sur iPhone +2. Aller sur l'URL de signature +3. Tester le canvas tactile (doigt) +4. VĂ©rifier keyboard mobile pour OTP +5. VĂ©rifier animations fluides +``` + +### Android Chrome + +``` +1. Ouvrir Chrome sur Android +2. MĂȘme flow que iOS +3. Tester rotation Ă©cran +4. VĂ©rifier zoom/pinch disabled sur canvas +``` + +## 🐛 ProblĂšmes frĂ©quents + +### "Cannot find module" Ă  l'import + +**Solution:** +```bash +# RedĂ©marrer le serveur Next.js +npm run dev +``` + +### OTP n'apparaĂźt pas dans les logs + +**VĂ©rifier:** +1. Ref commence bien par `TEST-` +2. Le serveur dev tourne (`npm run dev`) +3. Chercher les Ă©toiles ⭐ dans les logs + +### Canvas ne dessine pas + +**VĂ©rifier:** +1. Browser moderne (Chrome 90+, Safari 14+) +2. JavaScript activĂ© +3. Console browser pour erreurs + +### Signature ne s'upload pas + +**VĂ©rifier:** +1. Session token valide (pas expirĂ©) +2. Canvas a bien du contenu +3. Checkbox consentement cochĂ©e +4. Network tab pour voir erreur API + +## 🎹 Personnalisation + +### Changer les emails de test + +Éditer `test-odentas-sign.js`: + +```javascript +const signers = [ + { + name: "Votre Nom", + email: "votre@email.com", + role: "Employeur", + // ... + }, + // ... +]; +``` + +### Changer le document PDF + +```javascript +const localPdfPath = './votre-fichier.pdf'; +``` + +### DĂ©sactiver le mode test + +Dans la crĂ©ation de request, enlever le prĂ©fixe `TEST-`: + +```javascript +ref: `SIGN-${Date.now()}`, // Au lieu de TEST- +``` + +⚠ Attention : En mode production, OTP sera envoyĂ© par email SES ! + +## 📈 Monitoring + +### MĂ©triques Ă  surveiller + +- ⏱ **Temps moyen de signature** : OTP reçu → Signature validĂ©e +- 📧 **Taux de succĂšs OTP** : Validations / Envois +- ✍ **Taux de complĂ©tion** : Signatures finalisĂ©es / Créées +- 🔄 **Retry rate** : Nombre de "Recommencer" sur canvas + +### Logs Ă  analyser + +```bash +# Grep des Ă©vĂ©nements OTP +grep "otp_verified" logs/app.log + +# Grep des signatures complĂštes +grep "request_completed" logs/app.log + +# Erreurs +grep "ERROR" logs/app.log | grep "odentas-sign" +``` + +## ✅ Checklist avant dĂ©mo + +- [ ] Serveur Next.js lancĂ© (`npm run dev`) +- [ ] Demande de test créée (`node test-odentas-sign.js`) +- [ ] Script interactif testĂ© (`./test-interface-signature.sh`) +- [ ] Flow complet testĂ© (OTP → Signature → Confirmation) +- [ ] Les 2 signataires ont signĂ© +- [ ] Progression affiche 2/2 +- [ ] Confetti s'affiche +- [ ] Base de donnĂ©es vĂ©rifiĂ©e (events + signers) +- [ ] S3 vĂ©rifiĂ© (2 PNG dans signatures/) + +## 🎯 Next Steps + +Une fois l'interface validĂ©e : + +1. **IntĂ©grer PDF Viewer** → Voir `ODENTAS_SIGN_INTERFACE.md` +2. **Activer vraie signature PAdES** → Retirer test mode bypass +3. **CrĂ©er emails de notification** → Templates + triggers +4. **Tests de charge** → Artillery, k6 +5. **Migration DocuSeal** → Plan progressif + +--- + +**Bon test ! 🚀** + +Si problĂšme : renaud.breviere@gmail.com diff --git a/ODENTAS_SIGN_API.md b/ODENTAS_SIGN_API.md new file mode 100644 index 0000000..7cf9e38 --- /dev/null +++ b/ODENTAS_SIGN_API.md @@ -0,0 +1,496 @@ +# Odentas Sign - API Documentation + +**SystĂšme de signature Ă©lectronique souverain conforme eIDAS** + +## 🎯 Vue d'ensemble + +Odentas Sign remplace DocuSeal par une solution totalement interne et souveraine pour la signature des contrats de travail. Le systĂšme comprend : + +- **Odentas Sign** : Interface de signature avec authentification OTP +- **Odentas Seal** : Scellage PAdES avec KMS (lambda-odentas-pades-sign) +- **Odentas TSA** : Horodatage RFC3161 (lambda-tsaStamp) +- **Odentas Archive** : Stockage S3 avec Object Lock 10 ans + +## 📁 Structure du bucket S3 `odentas-sign` + +``` +odentas-sign/ +├── source/ # PDFs non signĂ©s +├── signed/ # PDFs signĂ©s et scellĂ©s +├── evidence/ # Bundles de preuves (JSON) +├── signatures/ # Images de signatures +└── certs/ # Certificats de scellage +``` + +## 🔐 Architecture des tables Supabase + +### `sign_requests` +Demandes de signature Ă©lectronique + +| Colonne | Type | Description | +|---------|------|-------------| +| `id` | uuid | Identifiant unique | +| `ref` | text | RĂ©fĂ©rence du contrat (ex: CDDU-2025-0102) | +| `title` | text | Titre du document | +| `source_s3_key` | text | ClĂ© S3 du PDF source | +| `status` | text | `pending`, `in_progress`, `completed`, `cancelled` | +| `created_at` | timestamp | Date de crĂ©ation | + +### `signers` +Signataires (Employeur + SalariĂ©) + +| Colonne | Type | Description | +|---------|------|-------------| +| `id` | uuid | Identifiant unique | +| `request_id` | uuid | RĂ©fĂ©rence Ă  `sign_requests` | +| `role` | text | `Employeur` ou `SalariĂ©` | +| `name` | text | Nom du signataire | +| `email` | text | Email pour OTP | +| `otp_hash` | text | Hash bcrypt du code OTP | +| `otp_expires_at` | timestamp | Expiration du code (15 min) | +| `otp_attempts` | integer | Nombre de tentatives (max 3) | +| `otp_last_sent_at` | timestamp | DerniĂšre date d'envoi OTP | +| `signed_at` | timestamp | Date de signature | +| `signature_image_s3` | text | ClĂ© S3 de l'image de signature | +| `consent_text` | text | Texte de consentement acceptĂ© | +| `consent_at` | timestamp | Date du consentement | +| `ip_signed` | inet | Adresse IP lors de la signature | +| `user_agent` | text | User-Agent du navigateur | + +### `sign_positions` +Positions des champs de signature dans le PDF + +| Colonne | Type | Description | +|---------|------|-------------| +| `id` | uuid | Identifiant unique | +| `request_id` | uuid | RĂ©fĂ©rence Ă  `sign_requests` | +| `role` | text | `Employeur` ou `SalariĂ©` | +| `page` | integer | NumĂ©ro de page (1-indexed) | +| `x`, `y`, `w`, `h` | numeric | Position et dimensions | +| `kind` | text | `signature`, `text`, `date`, `checkbox` | +| `label` | text | Label optionnel | + +### `sign_events` +Logs d'audit complets + +| Colonne | Type | Description | +|---------|------|-------------| +| `id` | bigserial | Identifiant auto-incrĂ©mentĂ© | +| `request_id` | uuid | RĂ©fĂ©rence Ă  `sign_requests` | +| `signer_id` | uuid | RĂ©fĂ©rence Ă  `signers` (optionnel) | +| `ts` | timestamp | Horodatage de l'Ă©vĂ©nement | +| `event` | text | Type d'Ă©vĂ©nement (voir ci-dessous) | +| `ip` | text | Adresse IP | +| `user_agent` | text | User-Agent | +| `metadata` | jsonb | DonnĂ©es additionnelles | + +**Types d'Ă©vĂ©nements :** +- `request_created` : Demande créée +- `otp_sent` : OTP envoyĂ© +- `otp_verified` : OTP vĂ©rifiĂ© avec succĂšs +- `otp_verification_failed` : OTP incorrect +- `otp_max_attempts_exceeded` : Trop de tentatives +- `signed` : Signature enregistrĂ©e +- `all_signed` : Tous les signataires ont signĂ© +- `sealing_started` : DĂ©but du scellage +- `request_completed` : Demande complĂ©tĂ©e +- `request_cancelled` : Demande annulĂ©e + +### `sign_assets` +Assets finaux (PDF signĂ©, preuves, horodatage) + +| Colonne | Type | Description | +|---------|------|-------------| +| `request_id` | uuid | RĂ©fĂ©rence Ă  `sign_requests` | +| `signed_pdf_s3_key` | text | ClĂ© S3 du PDF signĂ© et scellĂ© | +| `evidence_json_s3_key` | text | ClĂ© S3 du bundle de preuves | +| `tsa_tsr_s3_key` | text | ClĂ© S3 de la rĂ©ponse TSA | +| `pdf_sha256` | text | Hash SHA-256 du PDF | +| `tsa_token_sha256` | text | Hash SHA-256 du token TSA | +| `sealed_at` | timestamp | Date du scellage | +| `seal_algo` | text | Algorithme utilisĂ© (RSASSA_PSS_SHA_256) | +| `seal_kms_key_id` | text | ID de la clĂ© KMS | +| `tsa_policy_oid` | text | OID de la politique TSA | +| `tsa_serial` | text | NumĂ©ro de sĂ©rie TSA | +| `retain_until` | timestamp | Date de fin de rĂ©tention (10 ans) | + +## 🔌 Endpoints API + +### 1. CrĂ©er une demande de signature + +**POST** `/api/odentas-sign/requests/create` + +```typescript +// Request +{ + contractId: string; // ID du contrat dans cddu_contracts + contractRef: string; // NumĂ©ro du contrat (ex: CDDU-2025-0102) + pdfS3Key: string; // ClĂ© S3 du PDF source + title: string; // Titre du document + signers: [ + { + role: 'Employeur' | 'SalariĂ©'; + name: string; + email: string; + } + ]; + positions: [ // Optionnel + { + role: 'Employeur' | 'SalariĂ©'; + page: number; // NumĂ©ro de page (1-indexed) + x: number; // Position X (en points) + y: number; // Position Y (en points) + w: number; // Largeur + h: number; // Hauteur + kind?: 'signature'; // Type de champ + label?: string; // Label optionnel + } + ]; +} + +// Response 201 +{ + success: true; + request: { + id: string; + ref: string; + title: string; + status: 'pending'; + created_at: string; + }; + signers: [ + { + signerId: string; + role: string; + name: string; + email: string; + signatureUrl: string; // URL pour ce signataire + } + ]; +} +``` + +### 2. RĂ©cupĂ©rer les dĂ©tails d'une demande + +**GET** `/api/odentas-sign/requests/[id]` + +```typescript +// Response 200 +{ + success: true; + request: { + id: string; + ref: string; + title: string; + source_s3_key: string; + status: string; + created_at: string; + progress: { + total: number; + signed: number; + percentage: number; + }; + }; + signers: Array<{ + id: string; + role: string; + name: string; + email: string; + signed_at: string | null; + signature_image_s3: string | null; + }>; + positions: Array; +} +``` + +### 3. Envoyer un code OTP + +**POST** `/api/odentas-sign/signers/[id]/send-otp` + +```typescript +// Response 200 +{ + success: true; + message: 'Code de vĂ©rification envoyĂ© par email'; + expires_at: string; + signer: { + name: string; + email: string; + }; +} + +// Response 429 (trop rapide) +{ + error: 'Veuillez attendre X secondes avant de redemander un code'; +} +``` + +### 4. VĂ©rifier le code OTP + +**POST** `/api/odentas-sign/signers/[id]/verify-otp` + +```typescript +// Request +{ + otp: string; // Code Ă  6 chiffres +} + +// Response 200 +{ + success: true; + message: 'Code vĂ©rifiĂ© avec succĂšs'; + sessionToken: string; // JWT valide 30 minutes + signer: { + id: string; + name: string; + email: string; + role: string; + }; + request: { + id: string; + ref: string; + title: string; + }; +} + +// Response 401 (code incorrect) +{ + error: 'Code incorrect. X tentative(s) restante(s).'; + remainingAttempts: number; +} +``` + +### 5. Enregistrer la signature + +**POST** `/api/odentas-sign/signers/[id]/sign` + +**Headers :** `Authorization: Bearer ` + +```typescript +// Request +{ + signatureImageBase64: string; // data:image/png;base64,xxx + consentText: string; // Texte de consentement acceptĂ© +} + +// Response 200 +{ + success: true; + message: 'Signature enregistrĂ©e avec succĂšs'; + signed_at: string; + all_signed: boolean; // true si tous ont signĂ© + signer: { + id: string; + name: string; + email: string; + role: string; + }; + request: { + id: string; + ref: string; + title: string; + }; +} +``` + +### 6. VĂ©rifier le statut d'un signataire + +**GET** `/api/odentas-sign/signers/[id]/status` + +**Headers (optionnel) :** `Authorization: Bearer ` + +```typescript +// Response 200 +{ + success: true; + signer: { + id: string; + role: string; + name: string; + email: string; + has_signed: boolean; + signed_at: string | null; + }; + request: { + id: string; + ref: string; + title: string; + status: string; + created_at: string; + progress: { + total: number; + signed: number; + percentage: number; + }; + }; + other_signers: Array<{ + role: string; + name: string; + has_signed: boolean; + }>; +} +``` + +### 7. Annuler une demande + +**POST** `/api/odentas-sign/requests/[id]/cancel` + +```typescript +// Request +{ + reason?: string; // Raison de l'annulation +} + +// Response 200 +{ + success: true; + message: 'Demande annulĂ©e avec succĂšs'; + request: { + id: string; + ref: string; + status: 'cancelled'; + }; +} +``` + +### 8. Webhook de completion (interne) + +**POST** `/api/odentas-sign/webhooks/completion` + +AppelĂ© automatiquement quand tous les signataires ont signĂ©. +Lance le workflow de scellage PAdES + TSA + Archive. + +```typescript +// Request +{ + requestId: string; +} + +// Response 200 +{ + success: true; + message: 'Workflow de scellage lancĂ©'; + request: { + id: string; + ref: string; + status: 'completed'; + }; + evidence_key: string; // ClĂ© S3 du bundle de preuves +} +``` + +## 🔄 Workflow complet + +``` +1. CrĂ©ation du contrat + ↓ +2. GĂ©nĂ©ration du PDF → Upload vers source/ + ↓ +3. Appel API /requests/create + ↓ +4. CrĂ©ation sign_request + signers + positions + ↓ +5. Envoi emails avec liens de signature + ↓ +6. Signataire clique sur le lien + ↓ +7. Authentification OTP (/send-otp → /verify-otp) + ↓ +8. Affichage du PDF + capture signature + ↓ +9. Validation signature (/sign) + ↓ +10. Upload image vers signatures/ + ↓ +11. VĂ©rification si tous ont signĂ© + ↓ +12. Si oui : webhook /webhooks/completion + ↓ +13. Lambda orchestration : + - Injection signatures visuelles dans PDF + - Scellage PAdES (lambda-odentas-pades-sign) + - Horodatage TSA (lambda-tsaStamp) + - CrĂ©ation evidence bundle + - Upload vers signed/ et evidence/ + - Copie vers archive avec Object Lock 10 ans + ↓ +14. Mise Ă  jour sign_assets + ↓ +15. Emails de confirmation +``` + +## 🔒 ConformitĂ© eIDAS + +### Niveau actuel : SES (Signature Électronique Simple) + +✅ **ImplĂ©mentĂ© :** +- Authentification par OTP (email vĂ©rifiĂ©) +- Consentement explicite +- Horodatage qualifiĂ© (TSA RFC3161) +- Logs d'audit complets et immuables +- Archivage Ă  10 ans avec Object Lock + +🔹 **Pour passer Ă  AES (Signature Électronique AvancĂ©e) :** +- Ajouter certificat qualifiĂ© du signataire +- Lier la signature Ă  un dispositif sĂ©curisĂ© + +🔹 **Pour passer Ă  QES (Signature Électronique QualifiĂ©e) :** +- Utiliser un QSCD (Qualified Signature Creation Device) +- IntĂ©grer avec un PSC (Prestataire de Services de Confiance) qualifiĂ© + +## đŸ› ïž Variables d'environnement + +```env +# Supabase +NEXT_PUBLIC_SUPABASE_URL=xxx +SUPABASE_SERVICE_ROLE_KEY=xxx + +# S3 +AWS_REGION=eu-west-3 +ODENTAS_SIGN_BUCKET=odentas-sign + +# KMS & TSA +KMS_KEY_ID=xxx +TSA_URL=https://timestamp.sectigo.com + +# JWT +JWT_SECRET=xxx # Ou utilise NEXTAUTH_SECRET + +# App +NEXT_PUBLIC_APP_URL=https://espace-paie.odentas.fr +``` + +## 📝 TODO Phase 2 + +- [ ] Interface frontend de signature (/app/signer/[requestId]/[signerId]) +- [ ] Lambda d'orchestration (injection signatures + PAdES + TSA) +- [ ] Template email OTP +- [ ] Templates emails de notification (completion) +- [ ] Migration depuis DocuSeal (mode parallĂšle) +- [ ] Dashboard admin pour suivre les signatures +- [ ] API de tĂ©lĂ©chargement du PDF final +- [ ] Webhooks pour notifier le systĂšme principal + +## đŸ§Ș Tests + +TODO: Ajouter tests unitaires et d'intĂ©gration + +```bash +# Exemple de test avec curl +curl -X POST http://localhost:3000/api/odentas-sign/requests/create \ + -H "Content-Type: application/json" \ + -d '{ + "contractId": "xxx", + "contractRef": "CDDU-2025-0102", + "pdfS3Key": "source/contrat.pdf", + "title": "Contrat de travail CDDU", + "signers": [ + {"role": "Employeur", "name": "Jean Dupont", "email": "jean@company.fr"}, + {"role": "SalariĂ©", "name": "Marie Martin", "email": "marie@email.fr"} + ] + }' +``` + +--- + +**DerniĂšre mise Ă  jour :** 27 octobre 2025 diff --git a/ODENTAS_SIGN_INTERFACE.md b/ODENTAS_SIGN_INTERFACE.md new file mode 100644 index 0000000..52c791a --- /dev/null +++ b/ODENTAS_SIGN_INTERFACE.md @@ -0,0 +1,407 @@ +# 🎹 Odentas Sign - Interface de Signature (Phase 2) + +## 📋 Vue d'ensemble + +L'interface de signature Odentas Sign offre une expĂ©rience moderne et fluide pour la signature Ă©lectronique de documents. Elle remplace complĂštement DocuSeal avec une solution souveraine et conforme eIDAS. + +## 🎯 FonctionnalitĂ©s + +### ✅ ImplĂ©mentĂ© + +- ✹ Design moderne avec Tailwind CSS et Framer Motion +- 🔐 Authentification OTP Ă  6 chiffres +- ✍ Canvas de signature HTML5 (souris, trackpad, tactile) +- 📊 Barre de progression en temps rĂ©el +- 🎉 Animation de cĂ©lĂ©bration (confetti) aprĂšs signature +- đŸ“± Responsive mobile-first +- 🔒 VĂ©rification de consentement obligatoire +- ⏱ Countdown timer pour l'OTP (15 minutes) +- đŸš« Limite de tentatives (3 maximum) +- 📈 Affichage de la progression des signatures + +### 🔄 À venir + +- 📄 Visualiseur PDF avec zones de signature surlignĂ©es +- đŸ“„ TĂ©lĂ©chargement du document signĂ© +- 📧 Notifications email amĂ©liorĂ©es + +## đŸ—‚ïž Structure des fichiers + +``` +app/signer/[requestId]/[signerId]/ +├── page.tsx # Page principale avec routing +└── components/ + ├── ProgressBar.tsx # Barre de progression des Ă©tapes + ├── OTPVerification.tsx # Écran de vĂ©rification OTP + ├── SignatureCapture.tsx # Canvas de signature + └── CompletionScreen.tsx # Écran de confirmation +``` + +## 🎹 Flow utilisateur + +### 1ïžâƒŁ Étape 1 : VĂ©rification OTP + +**URL:** `/signer/[requestId]/[signerId]` + +**Processus:** +1. L'utilisateur arrive sur la page avec son lien unique +2. Affichage de son nom et email prĂ©-remplis +3. Clic sur "Recevoir le code" +4. En **mode test** : OTP affichĂ© dans les logs serveur +5. En **mode production** : Email SES envoyĂ© +6. Saisie du code Ă  6 chiffres (auto-focus, auto-submit) +7. VĂ©rification du code → GĂ©nĂ©ration d'un JWT (30min) +8. Transition automatique vers l'Ă©tape signature + +**SĂ©curitĂ©:** +- Code valide 15 minutes +- Maximum 3 tentatives +- Rate limiting 60 secondes entre envois +- JWT avec expiration + +### 2ïžâƒŁ Étape 2 : Signature + +**Processus:** +1. Canvas de signature responsive +2. Dessin avec souris/trackpad/doigt +3. Bouton "Recommencer" pour effacer +4. Checkbox de consentement obligatoire +5. Validation → Upload de l'image PNG +6. Enregistrement en base + S3 +7. Si tous ont signĂ© → DĂ©clenchement webhook +8. Transition vers Ă©cran de confirmation + +**Canvas:** +- Taille adaptative (devicePixelRatio) +- Ligne fluide (lineCap: round) +- Couleur noire (#1e293b) +- Export PNG avec transparence +- Support touch events + +### 3ïžâƒŁ Étape 3 : Confirmation + +**Processus:** +1. 🎉 Animation de confetti +2. Affichage des dĂ©tails (date, rĂ©fĂ©rence) +3. Progression des signatures (X/Y signĂ©) +4. Message diffĂ©rent selon statut: + - Tous signĂ©s → "Document finalisĂ©" + - En attente → "Attente des autres" +5. Boutons (tĂ©lĂ©chargement dĂ©sactivĂ© pour l'instant) + +## đŸ§Ș Test de l'interface + +### PrĂ©requis + +```bash +# 1. CrĂ©er une demande de test +node test-odentas-sign.js + +# 2. Lancer le serveur Next.js en dev +npm run dev + +# 3. Utiliser le script de test interactif +./test-interface-signature.sh +``` + +### Script de test + +Le script `test-interface-signature.sh` offre: + +1. **Ouvrir l'interface Employeur** → Ouvre automatiquement le navigateur +2. **Ouvrir l'interface SalariĂ©** → Ouvre automatiquement le navigateur +3. **Afficher l'OTP Employeur** → Trigger l'envoi + instructions pour logs +4. **Afficher l'OTP SalariĂ©** → Trigger l'envoi + instructions pour logs +5. **VĂ©rifier le statut** → Affiche qui a signĂ©, progression +6. **Quitter** + +### Mode test vs Production + +| Aspect | Mode Test | Mode Production | +|--------|-----------|----------------| +| **DĂ©tection** | Ref commence par `TEST-` | Ref normale | +| **OTP** | Logs serveur | Email SES | +| **Scellement** | SautĂ© | PAdES + TSA | +| **Archive** | SautĂ©e | S3 Object Lock 10 ans | + +## 🎹 Design System + +### Couleurs + +```css +/* Gradients principaux */ +from-indigo-600 to-purple-600 /* Header vĂ©rification */ +from-green-500 to-teal-500 /* Confirmation success */ +from-slate-50 via-blue-50 /* Background page */ + +/* Boutons */ +bg-indigo-600 hover:bg-indigo-700 /* Primary action */ +bg-slate-100 text-slate-400 /* Disabled */ +border-slate-200 /* Secondary */ +``` + +### Animations + +```typescript +// Transitions de page +initial={{ opacity: 0, y: 20 }} +animate={{ opacity: 1, y: 0 }} +exit={{ opacity: 0, y: -20 }} + +// Progress bar fill +initial={{ width: 0 }} +animate={{ width: `${percentage}%` }} +transition={{ duration: 1, ease: 'easeOut' }} + +// Confetti celebration +confetti({ + particleCount: 50, + startVelocity: 30, + spread: 360, + colors: ['#6366f1', '#8b5cf6', '#ec4899'] +}) +``` + +### Composants rĂ©utilisables + +- `ProgressBar` : Étapes avec cercles et connecteurs +- Icons de Lucide : `Shield`, `Check`, `Clock`, `Users`, `PenTool` +- Modales animĂ©es avec `AnimatePresence` + +## 🔐 SĂ©curitĂ© + +### JWT Session Token + +```typescript +{ + signerId: string, + requestId: string, + role: string, + iat: number, // Issued at + exp: number, // Expiration (30 minutes) + iss: 'odentas-sign' +} +``` + +**Utilisation:** +- GĂ©nĂ©rĂ© aprĂšs validation OTP +- StockĂ© dans state React (pas de localStorage) +- EnvoyĂ© dans header `Authorization: Bearer ` +- VĂ©rifiĂ© cĂŽtĂ© serveur pour `/sign` + +### Protection CSRF + +- Origin checking dans les API routes +- Rate limiting sur `/send-otp` +- Validation des inputs (OTP digits only) + +### DonnĂ©es personnelles + +- Email affichĂ© mais non modifiable +- IP et User-Agent enregistrĂ©s dans `sign_events` +- Consentement explicite requis +- Archivage 10 ans conforme RGPD (base lĂ©gale: contrat) + +## 📊 Tracking des Ă©vĂ©nements + +Tous les Ă©vĂ©nements sont loggĂ©s dans `sign_events`: + +1. `request_created` → CrĂ©ation demande +2. `otp_sent` → Envoi code +3. `otp_verified` → Code validĂ© +4. `otp_verification_failed` → Code invalide +5. `signed` → Signature enregistrĂ©e +6. `request_completed` → Tous ont signĂ© +7. `pdf_sealed` → PAdES appliquĂ© +8. `document_timestamped` → TSA horodatage +9. `archived` → Archive S3 + +## 🌐 URLs + +### Signature + +``` +/signer/[requestId]/[signerId] +``` + +**Exemple:** +``` +/signer/75b4408d-1bbd-464f-a9ea-2b4e5075a817/95c4ccdc-1a26-4426-a56f-653758159b54 +``` + +### API Endpoints utilisĂ©s + +``` +POST /api/odentas-sign/signers/[id]/send-otp +POST /api/odentas-sign/signers/[id]/verify-otp +POST /api/odentas-sign/signers/[id]/sign +GET /api/odentas-sign/signers/[id]/status +``` + +## đŸ“± Responsive Design + +### Breakpoints + +```css +sm: 640px /* Tablettes portrait */ +md: 768px /* Tablettes landscape */ +lg: 1024px /* Desktop */ +xl: 1280px /* Large screens */ +``` + +### Canvas tactile + +```typescript +// Disable browser touch gestures +style={{ touchAction: 'none' }} + +// Support touch events +onTouchStart={startDrawing} +onTouchMove={draw} +onTouchEnd={stopDrawing} +``` + +## 🎯 Prochaines Ă©tapes + +### PDF Viewer intĂ©gration + +```bash +npm install react-pdf pdfjs-dist +``` + +**FonctionnalitĂ©s:** +- Afficher le PDF dans `SignatureCapture` +- Overlay semi-transparent sur zones de signature +- Scroll automatique vers la zone du signataire +- Zoom responsive + +### Email notifications + +Templates Ă  crĂ©er: +- `signature-completed.html` → EnvoyĂ© au signataire aprĂšs sa signature +- `all-signatures-completed.html` → EnvoyĂ© Ă  tous quand finalisĂ© +- Avec lien de tĂ©lĂ©chargement du PDF signĂ© + +### Download du document + +```typescript +async function downloadSignedDocument() { + const response = await fetch(`/api/odentas-sign/requests/${requestId}/download`); + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${documentRef}-signed.pdf`; + a.click(); +} +``` + +## 🐛 Debugging + +### Logs serveur + +```bash +npm run dev +``` + +Chercher dans les logs: +``` +⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐ + MODE TEST - Code OTP +⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐ +``` + +### Console navigateur + +```javascript +// État du composant +React DevTools → SignerPage → state + +// Erreurs rĂ©seau +Network tab → Filter: "odentas-sign" + +// JWT decode +JSON.parse(atob(token.split('.')[1])) +``` + +### Base de donnĂ©es + +```sql +-- Derniers Ă©vĂ©nements +SELECT * FROM sign_events +ORDER BY created_at DESC +LIMIT 20; + +-- Statut des signataires +SELECT + sr.ref, + s.name, + s.role, + s.has_signed, + s.signed_at +FROM signers s +JOIN sign_requests sr ON s.request_id = sr.id +ORDER BY sr.created_at DESC; +``` + +## 🎉 Migration depuis DocuSeal + +### Coexistence + +Les anciennes pages DocuSeal restent actives: +- `/signatures-electroniques` → DocuSeal (employeur) +- `/signature-salarie` → DocuSeal (salariĂ©) + +Nouvelles pages Odentas Sign: +- `/signer/[requestId]/[signerId]` → Odentas Sign (tous rĂŽles) + +### Migration progressive + +1. **Phase 1** : Nouveaux contrats → Odentas Sign +2. **Phase 2** : Anciens contrats → Continuer DocuSeal +3. **Phase 3** : Quand tous migrĂ©s → Supprimer DocuSeal + +### DĂ©tection automatique + +```typescript +// Dans le code de crĂ©ation de contrat +const useOdentasSign = process.env.NEXT_PUBLIC_USE_ODENTAS_SIGN === 'true'; + +if (useOdentasSign) { + // CrĂ©er via /api/odentas-sign/requests/create + // Envoyer liens /signer/[requestId]/[signerId] +} else { + // CrĂ©er via DocuSeal + // Envoyer liens DocuSeal +} +``` + +## 📚 Ressources + +- [Framer Motion Docs](https://www.framer.com/motion/) +- [Tailwind CSS](https://tailwindcss.com) +- [Lucide Icons](https://lucide.dev) +- [Canvas Confetti](https://www.npmjs.com/package/canvas-confetti) +- [eIDAS Regulation](https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=uriserv:OJ.L_.2014.257.01.0073.01.ENG) + +## ✅ Checklist de production + +Avant mise en production: + +- [ ] Tester sur mobile (iOS Safari, Android Chrome) +- [ ] VĂ©rifier accessibilitĂ© (contraste, keyboard navigation) +- [ ] IntĂ©grer PDF viewer +- [ ] Ajouter templates email de notification +- [ ] Activer vraie signature PAdES (retirer test mode bypass) +- [ ] Configurer monitoring (Sentry, logs CloudWatch) +- [ ] Load testing (Artillery, k6) +- [ ] Documentation utilisateur finale +- [ ] Formation Ă©quipe support +- [ ] Plan de rollback DocuSeal + +--- + +**Créé le:** $(date +%Y-%m-%d) +**Version:** 2.0.0 +**Auteur:** GitHub Copilot +**Statut:** ✅ Phase 2 ComplĂšte diff --git a/ODENTAS_SIGN_TEST_GUIDE.md b/ODENTAS_SIGN_TEST_GUIDE.md new file mode 100644 index 0000000..e398ba2 --- /dev/null +++ b/ODENTAS_SIGN_TEST_GUIDE.md @@ -0,0 +1,371 @@ +# Guide de Test - Odentas Sign + +Ce guide vous permet de tester Odentas Sign **sans dĂ©clencher Odentas Seal**, pour valider le workflow complet de signature Ă©lectronique en environnement de dĂ©veloppement. + +## đŸ§Ș Mode Test + +Le systĂšme dĂ©tecte automatiquement le **mode test** quand : +- La rĂ©fĂ©rence du contrat commence par `TEST-` +- Le PDF source est dans `source/test/` +- Les emails contiennent `test@` ou `@example.com` + +En mode test : +- ✅ CrĂ©ation de la demande de signature +- ✅ Authentification OTP (code affichĂ© dans les logs) +- ✅ Capture et upload de la signature +- ✅ Bundle de preuves (evidence) +- ❌ **Scellage PAdES dĂ©sactivĂ©** +- ❌ **Horodatage TSA dĂ©sactivĂ©** +- ❌ **Archivage Object Lock dĂ©sactivĂ©** +- ❌ **Envoi d'emails dĂ©sactivĂ©** + +## 🚀 Étape 1 : CrĂ©er une demande de test + +### Option A : Via l'API de test (recommandĂ©) + +```bash +curl -X POST http://localhost:3000/api/odentas-sign/test/create-mock \ + -H "Content-Type: application/json" \ + -d '{ + "title": "Contrat CDDU Test", + "signerName": "Marie Test", + "signerEmail": "marie.test@example.com" + }' +``` + +**RĂ©ponse :** +```json +{ + "success": true, + "test_mode": true, + "request": { + "id": "xxx", + "ref": "TEST-...", + "title": "Contrat CDDU Test", + "status": "pending" + }, + "signers": [ + { + "signerId": "yyy", + "role": "Employeur", + "name": "Jean Dupont (Test)", + "email": "employeur-test@example.com", + "signatureUrl": "http://localhost:3000/signer/xxx/yyy" + }, + { + "signerId": "zzz", + "role": "SalariĂ©", + "name": "Marie Test", + "email": "marie.test@example.com", + "signatureUrl": "http://localhost:3000/signer/xxx/zzz" + } + ] +} +``` + +### Option B : Via l'API normale avec un vrai contrat + +Si vous avez dĂ©jĂ  un PDF uploadĂ© dans S3 : + +```bash +curl -X POST http://localhost:3000/api/odentas-sign/requests/create \ + -H "Content-Type: application/json" \ + -d '{ + "contractId": "votre-contract-id", + "contractRef": "TEST-2025-0001", + "pdfS3Key": "source/test/contrat-test.pdf", + "title": "Contrat de travail CDDU", + "signers": [ + { + "role": "Employeur", + "name": "Votre Nom", + "email": "vous-test@example.com" + }, + { + "role": "SalariĂ©", + "name": "SalariĂ© Test", + "email": "salarie-test@example.com" + } + ], + "positions": [ + { + "role": "Employeur", + "page": 1, + "x": 100, + "y": 650, + "w": 200, + "h": 60 + }, + { + "role": "SalariĂ©", + "page": 1, + "x": 350, + "y": 650, + "w": 200, + "h": 60 + } + ] + }' +``` + +## 🔐 Étape 2 : Tester le flux de signature + +### 2.1. Ouvrir l'URL de signature + +Utilisez l'URL retournĂ©e dans `signers[].signatureUrl`. + +**Note :** L'interface frontend n'existe pas encore, donc pour l'instant on teste uniquement les APIs. + +### 2.2. Demander un code OTP + +```bash +curl -X POST http://localhost:3000/api/odentas-sign/signers/[SIGNER_ID]/send-otp +``` + +**RĂ©ponse :** +```json +{ + "success": true, + "test_mode": true, + "otp_code_in_logs": true, + "message": "Mode test : le code OTP est affichĂ© dans les logs serveur", + "expires_at": "2025-10-27T15:30:00.000Z" +} +``` + +**⚠ Important :** Le code OTP apparaĂźt dans les logs serveur (terminal oĂč Next.js tourne) : + +``` +[OTP] đŸ§Ș MODE TEST dĂ©tectĂ© +[OTP] ======================================== +[OTP] 🔐 CODE OTP POUR marie.test@example.com: +[OTP] âžĄïž 123456 +[OTP] ======================================== +``` + +### 2.3. VĂ©rifier le code OTP + +```bash +curl -X POST http://localhost:3000/api/odentas-sign/signers/[SIGNER_ID]/verify-otp \ + -H "Content-Type: application/json" \ + -d '{"otp": "123456"}' +``` + +**RĂ©ponse :** +```json +{ + "success": true, + "sessionToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "signer": { + "id": "xxx", + "name": "Marie Test", + "email": "marie.test@example.com", + "role": "SalariĂ©" + }, + "request": { + "id": "yyy", + "ref": "TEST-...", + "title": "Contrat CDDU Test" + } +} +``` + +**Copiez le `sessionToken`** pour l'Ă©tape suivante. + +### 2.4. Enregistrer la signature + +CrĂ©ez une image de signature en base64 (pour tester, voici un petit carrĂ© rouge) : + +```bash +# Image 100x50 pixels, carrĂ© rouge simple +SIGNATURE_B64="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAAAyCAYAAACqNX6+AAAABmJLR0QA/wD/AP+gvaeTAAAAeklEQVR4nO3QMQEAAAjAMMC/52ECvlRA00ASAgECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQcNkFzQABOWLnlYwAAAAASUVORK5CYII=" + +curl -X POST http://localhost:3000/api/odentas-sign/signers/[SIGNER_ID]/sign \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer [SESSION_TOKEN]" \ + -d "{ + \"signatureImageBase64\": \"$SIGNATURE_B64\", + \"consentText\": \"Je consens Ă  signer Ă©lectroniquement ce document.\" + }" +``` + +**RĂ©ponse :** +```json +{ + "success": true, + "message": "Signature enregistrĂ©e avec succĂšs", + "signed_at": "2025-10-27T15:25:00.000Z", + "all_signed": false, + "signer": { + "id": "xxx", + "name": "Marie Test", + "role": "SalariĂ©" + } +} +``` + +### 2.5. Signer avec le deuxiĂšme signataire + +RĂ©pĂ©tez les Ă©tapes 2.2 Ă  2.4 avec le deuxiĂšme `signerId`. + +Quand le dernier signataire signe, `"all_signed": true` et le webhook de completion est dĂ©clenchĂ©. + +## 📊 Étape 3 : VĂ©rifier les rĂ©sultats + +### 3.1. VĂ©rifier le statut de la demande + +```bash +curl http://localhost:3000/api/odentas-sign/requests/[REQUEST_ID] +``` + +**RĂ©ponse :** +```json +{ + "success": true, + "request": { + "id": "xxx", + "ref": "TEST-...", + "status": "completed", + "progress": { + "total": 2, + "signed": 2, + "percentage": 100 + } + }, + "signers": [ + { + "id": "yyy", + "role": "Employeur", + "signed_at": "2025-10-27T15:20:00.000Z", + "signature_image_s3": "signatures/xxx/yyy.png" + }, + { + "id": "zzz", + "role": "SalariĂ©", + "signed_at": "2025-10-27T15:25:00.000Z", + "signature_image_s3": "signatures/xxx/zzz.png" + } + ] +} +``` + +### 3.2. VĂ©rifier dans Supabase + +Allez dans Supabase et vĂ©rifiez les tables : + +**`sign_requests`** +```sql +SELECT * FROM sign_requests WHERE ref LIKE 'TEST-%' ORDER BY created_at DESC; +``` + +**`signers`** +```sql +SELECT * FROM signers WHERE request_id = '[REQUEST_ID]'; +``` + +**`sign_events`** +```sql +SELECT * FROM sign_events WHERE request_id = '[REQUEST_ID]' ORDER BY ts ASC; +``` + +**`sign_assets`** +```sql +SELECT * FROM sign_assets WHERE request_id = '[REQUEST_ID]'; +``` + +### 3.3. VĂ©rifier dans S3 + +Le bucket `odentas-sign` doit contenir : + +``` +odentas-sign/ +├── source/test/ +│ └── TEST-xxx.pdf # PDF source de test +├── signatures/ +│ └── [request_id]/ +│ ├── [signer1_id].png # Signature employeur +│ └── [signer2_id].png # Signature salariĂ© +└── evidence/ + └── TEST-xxx.json # Bundle de preuves +``` + +## 📋 Logs attendus + +Dans votre terminal Next.js, vous devriez voir : + +``` +[TEST] PDF de test créé: source/test/TEST-xxx.pdf +[CREATE REQUEST] CrĂ©ation demande: TEST-xxx +[OTP] đŸ§Ș MODE TEST dĂ©tectĂ© +[OTP] 🔐 CODE OTP POUR employeur-test@example.com: 123456 +[OTP] ✅ Mode test - Code affichĂ© dans les logs +[SIGN] Enregistrement signature pour employeur-test@example.com... +[SIGN] ✅ Image uploadĂ©e: signatures/xxx/yyy.png +[SIGN] ✅ Signataire mis Ă  jour +[SIGN] 🎉 Tous les signataires ont signĂ© ! +[WEBHOOK COMPLETION] đŸ§Ș MODE TEST dĂ©tectĂ© - scellage PAdES dĂ©sactivĂ© +[WEBHOOK] đŸ§Ș MODE TEST : Scellage PAdES/TSA/Archive SAUTE +[WEBHOOK COMPLETION] ✅ Traitement terminĂ© pour TEST-xxx (MODE TEST) +``` + +## đŸ§č Nettoyage aprĂšs test + +Pour nettoyer les donnĂ©es de test : + +```sql +-- Supprimer les demandes de test (cascade sur signers, positions, events) +DELETE FROM sign_requests WHERE ref LIKE 'TEST-%'; + +-- Supprimer les assets de test +DELETE FROM sign_assets WHERE request_id IN ( + SELECT id FROM sign_requests WHERE ref LIKE 'TEST-%' +); +``` + +Dans S3, supprimer manuellement : +- `source/test/` +- `signatures/[request_ids de test]/` +- `evidence/TEST-*.json` + +## 🎯 Checklist de validation + +- [ ] CrĂ©ation de demande de test rĂ©ussie +- [ ] Code OTP visible dans les logs +- [ ] VĂ©rification OTP rĂ©ussie avec session token +- [ ] Upload de la signature rĂ©ussie +- [ ] Images de signature prĂ©sentes dans S3 +- [ ] Statut `completed` aprĂšs signature complĂšte +- [ ] Logs confirment le mode test (pas de scellage) +- [ ] Evidence bundle créé dans S3 +- [ ] Tables Supabase cohĂ©rentes +- [ ] ÉvĂ©nements d'audit enregistrĂ©s + +## ⏭ Prochaine Ă©tape + +Une fois les APIs validĂ©es, vous pourrez : +1. **CrĂ©er l'interface frontend** (`/app/signer/[requestId]/[signerId]/page.tsx`) +2. **IntĂ©grer avec vos contrats existants** +3. **CrĂ©er la Lambda d'orchestration** pour le scellage +4. **Activer le mode production** (sans TEST-) + +## 🐛 DĂ©pannage + +### Le code OTP n'apparaĂźt pas dans les logs +- VĂ©rifiez que l'email contient `test@` ou `@example.com` +- Ou que la ref commence par `TEST-` + +### Erreur "Session invalide" +- Le JWT expire aprĂšs 30 minutes +- Redemandez un OTP et re-vĂ©rifiez + +### Erreur S3 +- VĂ©rifiez que le bucket `odentas-sign` existe +- VĂ©rifiez les credentials AWS dans `.env.local` + +### Erreur Supabase +- VĂ©rifiez que les tables existent (voir dĂ©finitions dans README) +- VĂ©rifiez les RLS policies + +--- + +**DerniĂšre mise Ă  jour :** 27 octobre 2025 diff --git a/TEST-RECAP.md b/TEST-RECAP.md new file mode 100644 index 0000000..e8fedb4 --- /dev/null +++ b/TEST-RECAP.md @@ -0,0 +1,103 @@ +# ✅ Test Odentas Sign - RĂ©capitulatif + +## 🎉 Demande de signature créée avec succĂšs ! + +### 📋 Informations +- **ID**: 75b4408d-1bbd-464f-a9ea-2b4e5075a817 +- **Ref**: TEST-1761582838435 +- **PDF**: s3://odentas-sign/source/test/TEST-1761582838435.pdf + +### đŸ‘„ Signataires + +#### 1. Employeur - Odentas Paie +- **Email**: paie@odentas.fr +- **Signer ID**: 95c4ccdc-1a26-4426-a56f-653758159b54 + +#### 2. SalariĂ© - Renaud Breviere +- **Email**: renaud.breviere@gmail.com +- **Signer ID**: d481f070-2ac6-4f82-aff3-862783904d5d + +--- + +## 🚀 Comment tester ? + +### Option 1 : Script interactif (recommandĂ©) + +```bash +./test-signature-flow.sh +``` + +Le script vous propose un menu pour : +1. Envoyer OTP Employeur +2. Envoyer OTP SalariĂ© +3. VĂ©rifier OTP et obtenir le token +4. Enregistrer la signature + +### Option 2 : Commandes manuelles + +#### Étape 1 : Envoyer OTP Employeur +```bash +curl -X POST http://localhost:3000/api/odentas-sign/signers/95c4ccdc-1a26-4426-a56f-653758159b54/send-otp +``` + +**âžĄïž Le code OTP apparaĂźt dans les logs du serveur Next.js** + +#### Étape 2 : VĂ©rifier OTP +```bash +curl -X POST http://localhost:3000/api/odentas-sign/signers/95c4ccdc-1a26-4426-a56f-653758159b54/verify-otp \ + -H "Content-Type: application/json" \ + -d '{"otp": "VOTRE_CODE"}' +``` + +Copiez le `sessionToken` retournĂ©. + +#### Étape 3 : Signer +```bash +curl -X POST http://localhost:3000/api/odentas-sign/signers/95c4ccdc-1a26-4426-a56f-653758159b54/sign \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer VOTRE_TOKEN" \ + -d '{"signatureImageBase64": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAAAyCAYAAACqNX6+AAAABmJLR0QA/wD/AP+gvaeTAAAAeklEQVR4nO3QMQEAAAjAMMC/52ECvlRA00ASAgECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQcNkFzQABOWLnlYwAAAAASUVORK5CYII=", "consentText": "Je consens"}' +``` + +#### Étape 4 : RĂ©pĂ©ter pour le salariĂ© + +Utilisez le signer ID : `d481f070-2ac6-4f82-aff3-862783904d5d` + +--- + +## 🔍 VĂ©rifier le statut + +```bash +curl http://localhost:3000/api/odentas-sign/requests/75b4408d-1bbd-464f-a9ea-2b4e5075a817 +``` + +--- + +## ⚠ Mode Test Actif + +- ✅ Les codes OTP sont affichĂ©s dans les logs serveur +- ✅ Les emails sont envoyĂ©s vers les vraies adresses +- ❌ Le scellage PAdES est dĂ©sactivĂ© +- ❌ L'horodatage TSA est dĂ©sactivĂ© +- ❌ L'archivage Object Lock est dĂ©sactivĂ© + +Une fois tous les signataires signĂ©s, le webhook de completion sera appelĂ© mais ne dĂ©clenchera PAS le scellage. + +--- + +## 📊 VĂ©rifier dans Supabase + +```sql +-- Voir la demande +SELECT * FROM sign_requests WHERE ref = 'TEST-1761582838435'; + +-- Voir les signataires +SELECT * FROM signers WHERE request_id = '75b4408d-1bbd-464f-a9ea-2b4e5075a817'; + +-- Voir les Ă©vĂ©nements +SELECT * FROM sign_events WHERE request_id = '75b4408d-1bbd-464f-a9ea-2b4e5075a817' ORDER BY ts ASC; +``` + +--- + +**Bonne chance pour les tests ! 🎯** diff --git a/app/api/odentas-sign/requests/[id]/cancel/route.ts b/app/api/odentas-sign/requests/[id]/cancel/route.ts new file mode 100644 index 0000000..e50fc6e --- /dev/null +++ b/app/api/odentas-sign/requests/[id]/cancel/route.ts @@ -0,0 +1,92 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { supabaseAdmin, logSignEvent } from '@/lib/odentas-sign/supabase'; + +/** + * POST /api/odentas-sign/requests/[id]/cancel + * + * Annule une demande de signature + */ +export async function POST( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const requestId = params.id; + const body = await request.json(); + const { reason } = body; + + // RĂ©cupĂ©rer la demande + const { data: signRequest, error: requestError } = await supabaseAdmin + .from('sign_requests') + .select('*') + .eq('id', requestId) + .single(); + + if (requestError || !signRequest) { + return NextResponse.json( + { error: 'Demande introuvable' }, + { status: 404 } + ); + } + + // VĂ©rifier qu'elle n'est pas dĂ©jĂ  complĂ©tĂ©e + if (signRequest.status === 'completed') { + return NextResponse.json( + { error: 'Impossible d\'annuler une demande dĂ©jĂ  complĂ©tĂ©e' }, + { status: 400 } + ); + } + + // VĂ©rifier qu'elle n'est pas dĂ©jĂ  annulĂ©e + if (signRequest.status === 'cancelled') { + return NextResponse.json( + { error: 'Cette demande est dĂ©jĂ  annulĂ©e' }, + { status: 400 } + ); + } + + // Mettre Ă  jour le statut + const { error: updateError } = await supabaseAdmin + .from('sign_requests') + .update({ status: 'cancelled' }) + .eq('id', requestId); + + if (updateError) { + console.error('[CANCEL] Erreur mise Ă  jour:', updateError); + return NextResponse.json( + { error: 'Erreur lors de l\'annulation' }, + { status: 500 } + ); + } + + // Logger l'Ă©vĂ©nement + await logSignEvent({ + requestId: signRequest.id, + event: 'request_cancelled', + ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + metadata: { + reason: reason || 'Non spĂ©cifiĂ©', + }, + }); + + console.log(`[CANCEL] ✅ Demande annulĂ©e: ${signRequest.ref}`); + + return NextResponse.json({ + success: true, + message: 'Demande annulĂ©e avec succĂšs', + request: { + id: signRequest.id, + ref: signRequest.ref, + status: 'cancelled', + }, + }); + + } catch (error) { + console.error('[CANCEL] Erreur:', error); + return NextResponse.json( + { error: 'Erreur serveur', details: error instanceof Error ? error.message : String(error) }, + { status: 500 } + ); + } +} diff --git a/app/api/odentas-sign/requests/[id]/pdf-url/route.ts b/app/api/odentas-sign/requests/[id]/pdf-url/route.ts new file mode 100644 index 0000000..ff1d9b5 --- /dev/null +++ b/app/api/odentas-sign/requests/[id]/pdf-url/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { verifySignatureSession } from '@/lib/odentas-sign/jwt'; +import { supabaseAdmin } from '@/lib/odentas-sign/supabase'; +import { getPresignedDownloadUrl } from '@/lib/odentas-sign/s3'; + +/** + * GET /api/odentas-sign/requests/:id/pdf-url + * RĂ©cupĂšre l'URL prĂ©signĂ©e du PDF source + */ +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const requestId = params.id; + + // VĂ©rifier le token JWT + const authHeader = request.headers.get('Authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return NextResponse.json( + { error: 'Token manquant ou invalide' }, + { status: 401 } + ); + } + + const token = authHeader.split(' ')[1]; + const payload = verifySignatureSession(token); + + if (!payload || payload.requestId !== requestId) { + return NextResponse.json( + { error: 'Token invalide ou expirĂ©' }, + { status: 401 } + ); + } + + // RĂ©cupĂ©rer le document depuis la DB + const { data: signRequest, error } = await supabaseAdmin + .from('sign_requests') + .select('source_s3_key, status') + .eq('id', requestId) + .single(); + + if (error || !signRequest) { + console.error('Erreur DB lors de la rĂ©cupĂ©ration du document:', error); + return NextResponse.json( + { error: 'Demande de signature introuvable' }, + { status: 404 } + ); + } + + // GĂ©nĂ©rer l'URL prĂ©signĂ©e (valide 1h) + const presignedUrl = await getPresignedDownloadUrl(signRequest.source_s3_key, 3600); + + return NextResponse.json({ + url: presignedUrl, + s3Key: signRequest.source_s3_key, + }); + + } catch (error) { + console.error('Erreur lors de la gĂ©nĂ©ration de l\'URL PDF:', error); + return NextResponse.json( + { error: 'Erreur serveur lors de la gĂ©nĂ©ration de l\'URL' }, + { status: 500 } + ); + } +} diff --git a/app/api/odentas-sign/requests/[id]/positions/route.ts b/app/api/odentas-sign/requests/[id]/positions/route.ts new file mode 100644 index 0000000..4c4d04c --- /dev/null +++ b/app/api/odentas-sign/requests/[id]/positions/route.ts @@ -0,0 +1,71 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { verifySignatureSession } from '@/lib/odentas-sign/jwt'; +import { supabaseAdmin } from '@/lib/odentas-sign/supabase'; + +/** + * GET /api/odentas-sign/requests/:id/positions + * RĂ©cupĂšre les positions de signature pour toutes les parties + */ +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const requestId = params.id; + + // VĂ©rifier le token JWT + const authHeader = request.headers.get('Authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return NextResponse.json( + { error: 'Token manquant ou invalide' }, + { status: 401 } + ); + } + + const token = authHeader.split(' ')[1]; + const payload = verifySignatureSession(token); + + if (!payload || payload.requestId !== requestId) { + return NextResponse.json( + { error: 'Token invalide ou expirĂ©' }, + { status: 401 } + ); + } + + // RĂ©cupĂ©rer toutes les positions de signature + const { data: positions, error } = await supabaseAdmin + .from('sign_positions') + .select('page, x, y, w, h, role') + .eq('request_id', requestId) + .order('role'); + + if (error) { + console.error('Erreur DB lors de la rĂ©cupĂ©ration des positions:', error); + return NextResponse.json( + { error: 'Erreur lors de la rĂ©cupĂ©ration des positions' }, + { status: 500 } + ); + } + + // Transformer pour correspondre Ă  l'interface frontend + const transformedPositions = positions.map(p => ({ + page: p.page, + x: p.x, + y: p.y, + width: p.w, + height: p.h, + role: p.role, + })); + + return NextResponse.json({ + positions: transformedPositions, + }); + + } catch (error) { + console.error('Erreur lors de la rĂ©cupĂ©ration des positions:', error); + return NextResponse.json( + { error: 'Erreur serveur lors de la rĂ©cupĂ©ration des positions' }, + { status: 500 } + ); + } +} diff --git a/app/api/odentas-sign/requests/[id]/route.ts b/app/api/odentas-sign/requests/[id]/route.ts new file mode 100644 index 0000000..95ae8a6 --- /dev/null +++ b/app/api/odentas-sign/requests/[id]/route.ts @@ -0,0 +1,71 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { supabaseAdmin } from '@/lib/odentas-sign/supabase'; + +/** + * GET /api/odentas-sign/requests/[id] + * + * RĂ©cupĂšre les dĂ©tails d'une demande de signature + */ +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const requestId = params.id; + + // RĂ©cupĂ©rer la demande avec tous ses signataires et positions + const { data: signRequest, error: requestError } = await supabaseAdmin + .from('sign_requests') + .select(` + *, + signers(*), + sign_positions(*) + `) + .eq('id', requestId) + .single(); + + if (requestError || !signRequest) { + return NextResponse.json( + { error: 'Demande de signature introuvable' }, + { status: 404 } + ); + } + + // Calculer le statut de progression + const totalSigners = signRequest.signers?.length || 0; + const signedCount = signRequest.signers?.filter((s: any) => s.signed_at !== null).length || 0; + + return NextResponse.json({ + success: true, + request: { + id: signRequest.id, + ref: signRequest.ref, + title: signRequest.title, + source_s3_key: signRequest.source_s3_key, + status: signRequest.status, + created_at: signRequest.created_at, + progress: { + total: totalSigners, + signed: signedCount, + percentage: totalSigners > 0 ? Math.round((signedCount / totalSigners) * 100) : 0, + }, + }, + signers: signRequest.signers?.map((s: any) => ({ + id: s.id, + role: s.role, + name: s.name, + email: s.email, + signed_at: s.signed_at, + signature_image_s3: s.signature_image_s3, + })) || [], + positions: signRequest.sign_positions || [], + }); + + } catch (error) { + console.error('[GET REQUEST] Erreur:', error); + return NextResponse.json( + { error: 'Erreur serveur', details: error instanceof Error ? error.message : String(error) }, + { status: 500 } + ); + } +} diff --git a/app/api/odentas-sign/requests/create/route.ts b/app/api/odentas-sign/requests/create/route.ts new file mode 100644 index 0000000..39d3f66 --- /dev/null +++ b/app/api/odentas-sign/requests/create/route.ts @@ -0,0 +1,163 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { supabaseAdmin, logSignEvent } from '@/lib/odentas-sign/supabase'; +import { generateRequestRef } from '@/lib/odentas-sign/crypto'; +import { uploadToS3, S3_PREFIXES, fileExistsInS3 } from '@/lib/odentas-sign/s3'; +import type { CreateSignRequestInput } from '@/lib/odentas-sign/types'; + +/** + * POST /api/odentas-sign/requests/create + * + * CrĂ©e une nouvelle demande de signature Ă©lectronique + */ +export async function POST(request: NextRequest) { + try { + const body: CreateSignRequestInput = await request.json(); + + // Validation + if (!body.contractId || !body.pdfS3Key || !body.title) { + return NextResponse.json( + { error: 'Champs requis manquants: contractId, pdfS3Key, title' }, + { status: 400 } + ); + } + + if (!body.signers || body.signers.length === 0) { + return NextResponse.json( + { error: 'Au moins un signataire est requis' }, + { status: 400 } + ); + } + + // VĂ©rifier que le PDF source existe dans S3 + const sourceExists = await fileExistsInS3(body.pdfS3Key); + if (!sourceExists) { + return NextResponse.json( + { error: `Le fichier PDF source n'existe pas: ${body.pdfS3Key}` }, + { status: 404 } + ); + } + + // GĂ©nĂ©rer une rĂ©fĂ©rence unique + const ref = generateRequestRef(body.contractRef); + + console.log(`[CREATE REQUEST] CrĂ©ation demande: ${ref}`); + + // 1. CrĂ©er la demande de signature dans sign_requests + const { data: signRequest, error: requestError } = await supabaseAdmin + .from('sign_requests') + .insert({ + ref, + title: body.title, + source_s3_key: body.pdfS3Key, + status: 'pending', + }) + .select() + .single(); + + if (requestError || !signRequest) { + console.error('[SUPABASE] Erreur crĂ©ation sign_request:', requestError); + return NextResponse.json( + { error: 'Erreur lors de la crĂ©ation de la demande', details: requestError }, + { status: 500 } + ); + } + + console.log(`[CREATE REQUEST] ✅ sign_request créé: ${signRequest.id}`); + + // 2. CrĂ©er les signataires + const signersData = body.signers.map(signer => ({ + request_id: signRequest.id, + role: signer.role, + name: signer.name, + email: signer.email.toLowerCase(), + otp_attempts: 0, + })); + + const { data: createdSigners, error: signersError } = await supabaseAdmin + .from('signers') + .insert(signersData) + .select(); + + if (signersError || !createdSigners) { + console.error('[SUPABASE] Erreur crĂ©ation signers:', signersError); + + // Rollback : supprimer la demande + await supabaseAdmin.from('sign_requests').delete().eq('id', signRequest.id); + + return NextResponse.json( + { error: 'Erreur lors de la crĂ©ation des signataires', details: signersError }, + { status: 500 } + ); + } + + console.log(`[CREATE REQUEST] ✅ ${createdSigners.length} signataires créés`); + + // 3. CrĂ©er les positions de signature + if (body.positions && body.positions.length > 0) { + const positionsData = body.positions.map(pos => ({ + request_id: signRequest.id, + role: pos.role, + page: pos.page, + x: pos.x, + y: pos.y, + w: pos.w, + h: pos.h, + kind: pos.kind || 'signature', + label: pos.label || null, + })); + + const { error: positionsError } = await supabaseAdmin + .from('sign_positions') + .insert(positionsData); + + if (positionsError) { + console.error('[SUPABASE] Erreur crĂ©ation positions:', positionsError); + // On continue quand mĂȘme, les positions ne sont pas critiques + } else { + console.log(`[CREATE REQUEST] ✅ ${positionsData.length} positions créées`); + } + } + + // 4. Logger l'Ă©vĂ©nement de crĂ©ation + await logSignEvent({ + requestId: signRequest.id, + event: 'request_created', + ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + metadata: { + contract_id: body.contractId, + signers_count: createdSigners.length, + positions_count: body.positions?.length || 0, + }, + }); + + // 5. GĂ©nĂ©rer les URLs de signature pour chaque signataire + const signerUrls = createdSigners.map(signer => ({ + signerId: signer.id, + role: signer.role, + name: signer.name, + email: signer.email, + signatureUrl: `${process.env.NEXT_PUBLIC_APP_URL || 'https://espace-paie.odentas.fr'}/signer/${signRequest.id}/${signer.id}`, + })); + + // 6. Retourner la rĂ©ponse + return NextResponse.json({ + success: true, + request: { + id: signRequest.id, + ref: signRequest.ref, + title: signRequest.title, + status: signRequest.status, + created_at: signRequest.created_at, + }, + signers: signerUrls, + }, { status: 201 }); + + } catch (error) { + console.error('[CREATE REQUEST] Erreur:', error); + return NextResponse.json( + { error: 'Erreur serveur lors de la crĂ©ation de la demande', details: error instanceof Error ? error.message : String(error) }, + { status: 500 } + ); + } +} diff --git a/app/api/odentas-sign/signers/[id]/send-otp/route.ts b/app/api/odentas-sign/signers/[id]/send-otp/route.ts new file mode 100644 index 0000000..a7be069 --- /dev/null +++ b/app/api/odentas-sign/signers/[id]/send-otp/route.ts @@ -0,0 +1,218 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { supabaseAdmin, logSignEvent } from '@/lib/odentas-sign/supabase'; +import { generateOTP, hashOTP, getOTPExpiration } from '@/lib/odentas-sign/crypto'; + +/** + * POST /api/odentas-sign/signers/[id]/send-otp + * + * GĂ©nĂšre et envoie un code OTP par email Ă  un signataire + */ +export async function POST( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const signerId = params.id; + + // RĂ©cupĂ©rer le signataire + const { data: signer, error: signerError } = await supabaseAdmin + .from('signers') + .select('*, sign_requests(id, ref, title, status)') + .eq('id', signerId) + .single(); + + if (signerError || !signer) { + return NextResponse.json( + { error: 'Signataire introuvable' }, + { status: 404 } + ); + } + + // VĂ©rifier que la demande n'est pas annulĂ©e ou complĂ©tĂ©e + if (signer.sign_requests.status === 'cancelled') { + return NextResponse.json( + { error: 'Cette demande de signature a Ă©tĂ© annulĂ©e' }, + { status: 400 } + ); + } + + if (signer.sign_requests.status === 'completed') { + return NextResponse.json( + { error: 'Cette demande de signature est dĂ©jĂ  complĂ©tĂ©e' }, + { status: 400 } + ); + } + + // VĂ©rifier si le signataire a dĂ©jĂ  signĂ© + if (signer.signed_at) { + return NextResponse.json( + { error: 'Vous avez dĂ©jĂ  signĂ© ce document' }, + { status: 400 } + ); + } + + // Limite de frĂ©quence : ne pas renvoyer d'OTP si un a Ă©tĂ© envoyĂ© il y a moins de 60 secondes + if (signer.otp_last_sent_at) { + const lastSentAt = new Date(signer.otp_last_sent_at); + const now = new Date(); + const diffSeconds = (now.getTime() - lastSentAt.getTime()) / 1000; + + if (diffSeconds < 60) { + return NextResponse.json( + { error: `Veuillez attendre ${Math.ceil(60 - diffSeconds)} secondes avant de redemander un code` }, + { status: 429 } + ); + } + } + + // GĂ©nĂ©rer le code OTP + const otpCode = generateOTP(); + const otpHash = await hashOTP(otpCode); + const otpExpires = getOTPExpiration(); + + // DĂ©tecter le mode test + const testEmails = ['paie@odentas.fr', 'renaud.breviere@gmail.com']; + const isTestMode = signer.email.includes('test@') || + signer.email.includes('@example.com') || + signer.sign_requests.ref?.startsWith('TEST-') || + testEmails.includes(signer.email.toLowerCase()); + + if (isTestMode) { + console.log(`[OTP] đŸ§Ș MODE TEST dĂ©tectĂ©`); + console.log(`[OTP] ========================================`); + console.log(`[OTP] 🔐 CODE OTP POUR ${signer.email}:`); + console.log(`[OTP] âžĄïž ${otpCode}`); + console.log(`[OTP] ========================================`); + console.log(`[OTP] Expire Ă : ${otpExpires.toISOString()}`); + } else { + console.log(`[OTP] Code gĂ©nĂ©rĂ© pour ${signer.email} (expire Ă  ${otpExpires.toISOString()})`); + } + + // Mettre Ă  jour le signataire avec le nouveau OTP + const { error: updateError } = await supabaseAdmin + .from('signers') + .update({ + otp_hash: otpHash, + otp_expires_at: otpExpires.toISOString(), + otp_last_sent_at: new Date().toISOString(), + }) + .eq('id', signerId); + + if (updateError) { + console.error('[OTP] Erreur mise Ă  jour signataire:', updateError); + return NextResponse.json( + { error: 'Erreur lors de la gĂ©nĂ©ration du code' }, + { status: 500 } + ); + } + + // Logger l'Ă©vĂ©nement + await logSignEvent({ + requestId: signer.sign_requests.id, + signerId: signer.id, + event: 'otp_sent', + ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + metadata: { + email: signer.email, + expires_at: otpExpires.toISOString(), + }, + }); + + // Envoyer l'email avec le code OTP + // TODO: IntĂ©grer avec le systĂšme d'email existant (SES) + const emailResult = await sendOTPEmail({ + to: signer.email, + name: signer.name, + otpCode, + documentTitle: signer.sign_requests.title, + documentRef: signer.sign_requests.ref, + isTestMode, + }); + + if (!emailResult.success) { + console.error('[OTP] Erreur envoi email:', emailResult.error); + + // En mode test, on continue quand mĂȘme + if (!isTestMode) { + return NextResponse.json( + { error: 'Erreur lors de l\'envoi de l\'email' }, + { status: 500 } + ); + } + } + + console.log(`[OTP] ✅ ${isTestMode ? 'Mode test - Code affichĂ© dans les logs' : `Code envoyĂ© Ă  ${signer.email}`}`); + + return NextResponse.json({ + success: true, + message: isTestMode + ? 'Mode test : le code OTP est affichĂ© dans les logs serveur' + : 'Code de vĂ©rification envoyĂ© par email', + test_mode: isTestMode, + ...(isTestMode && { otp_code_in_logs: true }), + expires_at: otpExpires.toISOString(), + signer: { + name: signer.name, + email: signer.email, + }, + }); + + } catch (error) { + console.error('[SEND OTP] Erreur:', error); + return NextResponse.json( + { error: 'Erreur serveur', details: error instanceof Error ? error.message : String(error) }, + { status: 500 } + ); + } +} + +/** + * Envoie un email avec le code OTP + */ +async function sendOTPEmail(params: { + to: string; + name: string; + otpCode: string; + documentTitle: string; + documentRef: string; + isTestMode?: boolean; +}): Promise<{ success: boolean; error?: string }> { + const { to, name, otpCode, documentTitle, documentRef, isTestMode } = params; + + // En mode test, ne pas envoyer d'email rĂ©ellement + if (isTestMode) { + console.log(`[EMAIL] đŸ§Ș MODE TEST : Email non envoyĂ© (code affichĂ© dans les logs)`); + return { success: true }; + } + + try { + // Appeler l'API d'envoi d'email existante + const response = await fetch(`${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/api/send-email`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + to, + subject: `Code de vĂ©rification - Signature Ă©lectronique`, + template: 'otp-signature', + variables: { + name, + otpCode, + documentTitle, + documentRef, + expirationMinutes: '15', + }, + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + return { success: false, error: errorData.error || 'Erreur HTTP' }; + } + + return { success: true }; + } catch (error) { + console.error('[EMAIL] Erreur:', error); + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } +} diff --git a/app/api/odentas-sign/signers/[id]/sign/route.ts b/app/api/odentas-sign/signers/[id]/sign/route.ts new file mode 100644 index 0000000..b0faf0a --- /dev/null +++ b/app/api/odentas-sign/signers/[id]/sign/route.ts @@ -0,0 +1,219 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { supabaseAdmin, logSignEvent, checkAllSignersSigned } from '@/lib/odentas-sign/supabase'; +import { verifySignatureSession, extractTokenFromHeader } from '@/lib/odentas-sign/jwt'; +import { uploadSignatureImage } from '@/lib/odentas-sign/s3'; + +/** + * POST /api/odentas-sign/signers/[id]/sign + * + * Enregistre la signature d'un signataire + */ +export async function POST( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const signerId = params.id; + const body = await request.json(); + + // VĂ©rifier le JWT de session + const authHeader = request.headers.get('authorization'); + const token = extractTokenFromHeader(authHeader); + + if (!token) { + return NextResponse.json( + { error: 'Token de session manquant' }, + { status: 401 } + ); + } + + const session = verifySignatureSession(token); + + if (!session || session.signerId !== signerId) { + return NextResponse.json( + { error: 'Session invalide ou expirĂ©e' }, + { status: 401 } + ); + } + + // Validation des donnĂ©es + const { signatureImageBase64, consentText } = body; + + if (!signatureImageBase64) { + return NextResponse.json( + { error: 'Image de signature manquante' }, + { status: 400 } + ); + } + + if (!consentText) { + return NextResponse.json( + { error: 'Consentement manquant' }, + { status: 400 } + ); + } + + // RĂ©cupĂ©rer le signataire + const { data: signer, error: signerError } = await supabaseAdmin + .from('signers') + .select('*, sign_requests(id, ref, title, status)') + .eq('id', signerId) + .single(); + + if (signerError || !signer) { + return NextResponse.json( + { error: 'Signataire introuvable' }, + { status: 404 } + ); + } + + // VĂ©rifier que la demande est active + if (signer.sign_requests.status !== 'pending' && signer.sign_requests.status !== 'in_progress') { + return NextResponse.json( + { error: 'Cette demande de signature n\'est plus active' }, + { status: 400 } + ); + } + + // VĂ©rifier si dĂ©jĂ  signĂ© + if (signer.signed_at) { + return NextResponse.json( + { error: 'Vous avez dĂ©jĂ  signĂ© ce document' }, + { status: 400 } + ); + } + + console.log(`[SIGN] Enregistrement signature pour ${signer.email}...`); + + // Extraire l'IP et le User-Agent + const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || null; + const userAgent = request.headers.get('user-agent') || null; + + // 1. Upload de l'image de signature vers S3 + let signatureS3Key: string; + try { + signatureS3Key = await uploadSignatureImage({ + requestId: signer.sign_requests.id, + signerId: signer.id, + imageBase64: signatureImageBase64, + }); + + console.log(`[SIGN] ✅ Image uploadĂ©e: ${signatureS3Key}`); + } catch (uploadError) { + console.error('[SIGN] Erreur upload signature:', uploadError); + return NextResponse.json( + { error: 'Erreur lors de l\'upload de la signature' }, + { status: 500 } + ); + } + + // 2. Mettre Ă  jour le signataire dans la base de donnĂ©es + const now = new Date().toISOString(); + + const { error: updateError } = await supabaseAdmin + .from('signers') + .update({ + signature_image_s3: signatureS3Key, + signed_at: now, + ip_signed: ipAddress, + user_agent: userAgent, + consent_text: consentText, + consent_at: now, + }) + .eq('id', signerId); + + if (updateError) { + console.error('[SIGN] Erreur mise Ă  jour signataire:', updateError); + return NextResponse.json( + { error: 'Erreur lors de l\'enregistrement de la signature' }, + { status: 500 } + ); + } + + console.log(`[SIGN] ✅ Signataire mis Ă  jour`); + + // 3. Logger l'Ă©vĂ©nement de signature + await logSignEvent({ + requestId: signer.sign_requests.id, + signerId: signer.id, + event: 'signed', + ip: ipAddress || undefined, + userAgent: userAgent || undefined, + metadata: { + signature_s3_key: signatureS3Key, + consent_text: consentText, + }, + }); + + // 4. VĂ©rifier si tous les signataires ont signĂ© + const allSigned = await checkAllSignersSigned(signer.sign_requests.id); + + if (allSigned) { + console.log(`[SIGN] 🎉 Tous les signataires ont signĂ© ! DĂ©clenchement du workflow de scellage...`); + + // Logger l'Ă©vĂ©nement de completion + await logSignEvent({ + requestId: signer.sign_requests.id, + event: 'all_signed', + metadata: { + trigger: 'auto', + }, + }); + + // DĂ©clencher le workflow de scellage (via webhook ou Lambda) + try { + await triggerSealingWorkflow(signer.sign_requests.id); + } catch (workflowError) { + console.error('[SIGN] Erreur dĂ©clenchement workflow:', workflowError); + // On ne fait pas Ă©chouer la requĂȘte, le workflow peut ĂȘtre retentĂ© + } + } + + return NextResponse.json({ + success: true, + message: 'Signature enregistrĂ©e avec succĂšs', + signed_at: now, + all_signed: allSigned, + signer: { + id: signer.id, + name: signer.name, + email: signer.email, + role: signer.role, + }, + request: { + id: signer.sign_requests.id, + ref: signer.sign_requests.ref, + title: signer.sign_requests.title, + }, + }); + + } catch (error) { + console.error('[SIGN] Erreur:', error); + return NextResponse.json( + { error: 'Erreur serveur', details: error instanceof Error ? error.message : String(error) }, + { status: 500 } + ); + } +} + +/** + * DĂ©clenche le workflow de scellage quand tous ont signĂ© + */ +async function triggerSealingWorkflow(requestId: string): Promise { + console.log(`[WORKFLOW] DĂ©clenchement du scellage pour request ${requestId}`); + + // Appeler le webhook de completion + const webhookUrl = `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/api/odentas-sign/webhooks/completion`; + + const response = await fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ requestId }), + }); + + if (!response.ok) { + throw new Error(`Webhook failed: ${response.status}`); + } + + console.log(`[WORKFLOW] ✅ Webhook appelĂ© avec succĂšs`); +} diff --git a/app/api/odentas-sign/signers/[id]/status/route.ts b/app/api/odentas-sign/signers/[id]/status/route.ts new file mode 100644 index 0000000..eab6664 --- /dev/null +++ b/app/api/odentas-sign/signers/[id]/status/route.ts @@ -0,0 +1,107 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { supabaseAdmin } from '@/lib/odentas-sign/supabase'; +import { verifySignatureSession, extractTokenFromHeader } from '@/lib/odentas-sign/jwt'; + +/** + * GET /api/odentas-sign/signers/[id]/status + * + * RĂ©cupĂšre le statut d'un signataire (nĂ©cessite authentification) + */ +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const signerId = params.id; + + // VĂ©rifier le JWT de session (optionnel pour cette route, mais recommandĂ©) + const authHeader = request.headers.get('authorization'); + const token = extractTokenFromHeader(authHeader); + + if (token) { + const session = verifySignatureSession(token); + if (session && session.signerId !== signerId) { + return NextResponse.json( + { error: 'AccĂšs non autorisĂ©' }, + { status: 403 } + ); + } + } + + // RĂ©cupĂ©rer le signataire avec les infos de la demande + const { data: signer, error: signerError } = await supabaseAdmin + .from('signers') + .select(` + id, + role, + name, + email, + signed_at, + signature_image_s3, + request_id, + sign_requests( + id, + ref, + title, + status, + created_at + ) + `) + .eq('id', signerId) + .single(); + + if (signerError || !signer) { + return NextResponse.json( + { error: 'Signataire introuvable' }, + { status: 404 } + ); + } + + const signRequest = Array.isArray(signer.sign_requests) ? signer.sign_requests[0] : signer.sign_requests; + + // RĂ©cupĂ©rer tous les signataires de cette demande pour calculer la progression + const { data: allSigners } = await supabaseAdmin + .from('signers') + .select('id, signed_at, role, name') + .eq('request_id', signer.request_id); + + const totalSigners = allSigners?.length || 0; + const signedCount = allSigners?.filter(s => s.signed_at !== null).length || 0; + + return NextResponse.json({ + success: true, + signer: { + id: signer.id, + role: signer.role, + name: signer.name, + email: signer.email, + has_signed: signer.signed_at !== null, + signed_at: signer.signed_at, + }, + request: { + id: signRequest.id, + ref: signRequest.ref, + title: signRequest.title, + status: signRequest.status, + created_at: signRequest.created_at, + progress: { + total: totalSigners, + signed: signedCount, + percentage: totalSigners > 0 ? Math.round((signedCount / totalSigners) * 100) : 0, + }, + }, + other_signers: allSigners?.map(s => ({ + role: s.role, + name: s.name, + has_signed: s.signed_at !== null, + })) || [], + }); + + } catch (error) { + console.error('[SIGNER STATUS] Erreur:', error); + return NextResponse.json( + { error: 'Erreur serveur', details: error instanceof Error ? error.message : String(error) }, + { status: 500 } + ); + } +} diff --git a/app/api/odentas-sign/signers/[id]/verify-otp/route.ts b/app/api/odentas-sign/signers/[id]/verify-otp/route.ts new file mode 100644 index 0000000..844c5b7 --- /dev/null +++ b/app/api/odentas-sign/signers/[id]/verify-otp/route.ts @@ -0,0 +1,186 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { supabaseAdmin, logSignEvent } from '@/lib/odentas-sign/supabase'; +import { verifyOTP, isOTPExpired } from '@/lib/odentas-sign/crypto'; +import { createSignatureSession } from '@/lib/odentas-sign/jwt'; + +const MAX_OTP_ATTEMPTS = 3; + +/** + * POST /api/odentas-sign/signers/[id]/verify-otp + * + * VĂ©rifie le code OTP et crĂ©e une session de signature + */ +export async function POST( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const signerId = params.id; + const body = await request.json(); + const { otp } = body; + + if (!otp || !/^\d{6}$/.test(otp)) { + return NextResponse.json( + { error: 'Code OTP invalide (6 chiffres requis)' }, + { status: 400 } + ); + } + + // RĂ©cupĂ©rer le signataire + const { data: signer, error: signerError } = await supabaseAdmin + .from('signers') + .select('*, sign_requests(id, ref, title, status)') + .eq('id', signerId) + .single(); + + if (signerError || !signer) { + return NextResponse.json( + { error: 'Signataire introuvable' }, + { status: 404 } + ); + } + + // VĂ©rifier que la demande est active + if (signer.sign_requests.status !== 'pending' && signer.sign_requests.status !== 'in_progress') { + return NextResponse.json( + { error: 'Cette demande de signature n\'est plus active' }, + { status: 400 } + ); + } + + // VĂ©rifier si dĂ©jĂ  signĂ© + if (signer.signed_at) { + return NextResponse.json( + { error: 'Vous avez dĂ©jĂ  signĂ© ce document' }, + { status: 400 } + ); + } + + // VĂ©rifier si un OTP existe + if (!signer.otp_hash) { + return NextResponse.json( + { error: 'Aucun code OTP n\'a Ă©tĂ© gĂ©nĂ©rĂ©. Veuillez en demander un.' }, + { status: 400 } + ); + } + + // VĂ©rifier si l'OTP est expirĂ© + if (isOTPExpired(signer.otp_expires_at)) { + return NextResponse.json( + { error: 'Le code OTP a expirĂ©. Veuillez en demander un nouveau.' }, + { status: 400 } + ); + } + + // VĂ©rifier le nombre de tentatives + if (signer.otp_attempts >= MAX_OTP_ATTEMPTS) { + await logSignEvent({ + requestId: signer.sign_requests.id, + signerId: signer.id, + event: 'otp_max_attempts_exceeded', + ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }); + + return NextResponse.json( + { error: 'Nombre maximum de tentatives atteint. Veuillez demander un nouveau code.' }, + { status: 429 } + ); + } + + // VĂ©rifier le code OTP + const isValid = await verifyOTP(otp, signer.otp_hash); + + if (!isValid) { + // IncrĂ©menter le compteur de tentatives + const newAttempts = signer.otp_attempts + 1; + + await supabaseAdmin + .from('signers') + .update({ otp_attempts: newAttempts }) + .eq('id', signerId); + + await logSignEvent({ + requestId: signer.sign_requests.id, + signerId: signer.id, + event: 'otp_verification_failed', + ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + metadata: { + attempt: newAttempts, + max_attempts: MAX_OTP_ATTEMPTS, + }, + }); + + const remainingAttempts = MAX_OTP_ATTEMPTS - newAttempts; + + return NextResponse.json( + { + error: `Code incorrect. ${remainingAttempts} tentative${remainingAttempts > 1 ? 's' : ''} restante${remainingAttempts > 1 ? 's' : ''}.`, + remainingAttempts, + }, + { status: 401 } + ); + } + + // ✅ Code OTP valide ! + console.log(`[OTP] ✅ Code vĂ©rifiĂ© pour ${signer.email}`); + + // RĂ©initialiser les tentatives + await supabaseAdmin + .from('signers') + .update({ + otp_attempts: 0, + }) + .eq('id', signerId); + + // Logger l'Ă©vĂ©nement + await logSignEvent({ + requestId: signer.sign_requests.id, + signerId: signer.id, + event: 'otp_verified', + ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }); + + // Mettre Ă  jour le statut de la demande Ă  "in_progress" si c'est la premiĂšre vĂ©rification + if (signer.sign_requests.status === 'pending') { + await supabaseAdmin + .from('sign_requests') + .update({ status: 'in_progress' }) + .eq('id', signer.sign_requests.id); + } + + // CrĂ©er un JWT de session (valide 30 minutes) + const sessionToken = createSignatureSession({ + signerId: signer.id, + requestId: signer.sign_requests.id, + email: signer.email, + role: signer.role, + }); + + return NextResponse.json({ + success: true, + message: 'Code vĂ©rifiĂ© avec succĂšs', + sessionToken, + signer: { + id: signer.id, + name: signer.name, + email: signer.email, + role: signer.role, + }, + request: { + id: signer.sign_requests.id, + ref: signer.sign_requests.ref, + title: signer.sign_requests.title, + }, + }); + + } catch (error) { + console.error('[VERIFY OTP] Erreur:', error); + return NextResponse.json( + { error: 'Erreur serveur', details: error instanceof Error ? error.message : String(error) }, + { status: 500 } + ); + } +} diff --git a/app/api/odentas-sign/test/create-mock/route.ts b/app/api/odentas-sign/test/create-mock/route.ts new file mode 100644 index 0000000..cc1c69d --- /dev/null +++ b/app/api/odentas-sign/test/create-mock/route.ts @@ -0,0 +1,251 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { supabaseAdmin, logSignEvent } from '@/lib/odentas-sign/supabase'; +import { generateRequestRef } from '@/lib/odentas-sign/crypto'; +import { uploadToS3, S3_PREFIXES } from '@/lib/odentas-sign/s3'; +import type { CreateSignRequestInput } from '@/lib/odentas-sign/types'; + +/** + * POST /api/odentas-sign/test/create-mock + * + * CrĂ©e une demande de signature de test sans PDF rĂ©el + * UtilisĂ© pour tester le workflow sans avoir de vrai contrat + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { title, signerName, signerEmail } = body; + + // GĂ©nĂ©rer un PDF de test simple + const testPdfContent = generateMockPDF(title || 'Document de test'); + + // Upload du PDF de test vers S3 + const testRef = `TEST-${Date.now()}`; + const testPdfKey = `${S3_PREFIXES.SOURCE}test/${testRef}.pdf`; + + await uploadToS3({ + key: testPdfKey, + body: Buffer.from(testPdfContent), + contentType: 'application/pdf', + metadata: { + test: 'true', + created_by: 'test-api', + }, + }); + + console.log(`[TEST] PDF de test créé: ${testPdfKey}`); + + // CrĂ©er la demande de signature + const ref = generateRequestRef(`TEST-${testRef}`); + + const { data: signRequest, error: requestError } = await supabaseAdmin + .from('sign_requests') + .insert({ + ref, + title: title || 'Document de test - Odentas Sign', + source_s3_key: testPdfKey, + status: 'pending', + }) + .select() + .single(); + + if (requestError || !signRequest) { + console.error('[TEST] Erreur crĂ©ation sign_request:', requestError); + return NextResponse.json( + { error: 'Erreur lors de la crĂ©ation de la demande de test', details: requestError }, + { status: 500 } + ); + } + + // CrĂ©er 2 signataires par dĂ©faut (Employeur + SalariĂ©) + const signersData = [ + { + request_id: signRequest.id, + role: 'Employeur', + name: 'Jean Dupont (Test)', + email: 'employeur-test@example.com', + otp_attempts: 0, + }, + { + request_id: signRequest.id, + role: 'SalariĂ©', + name: signerName || 'Marie Martin (Test)', + email: signerEmail || 'salarie-test@example.com', + otp_attempts: 0, + }, + ]; + + const { data: createdSigners, error: signersError } = await supabaseAdmin + .from('signers') + .insert(signersData) + .select(); + + if (signersError || !createdSigners) { + console.error('[TEST] Erreur crĂ©ation signers:', signersError); + await supabaseAdmin.from('sign_requests').delete().eq('id', signRequest.id); + return NextResponse.json( + { error: 'Erreur lors de la crĂ©ation des signataires de test', details: signersError }, + { status: 500 } + ); + } + + // CrĂ©er des positions de signature standard + const positionsData = [ + { + request_id: signRequest.id, + role: 'Employeur', + page: 1, + x: 100, + y: 650, + w: 200, + h: 60, + kind: 'signature', + label: 'Signature Employeur', + }, + { + request_id: signRequest.id, + role: 'SalariĂ©', + page: 1, + x: 350, + y: 650, + w: 200, + h: 60, + kind: 'signature', + label: 'Signature SalariĂ©', + }, + ]; + + await supabaseAdmin.from('sign_positions').insert(positionsData); + + // Logger l'Ă©vĂ©nement + await logSignEvent({ + requestId: signRequest.id, + event: 'test_request_created', + metadata: { + test: true, + pdf_key: testPdfKey, + }, + }); + + // GĂ©nĂ©rer les URLs + const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'; + const signerUrls = createdSigners.map(signer => ({ + signerId: signer.id, + role: signer.role, + name: signer.name, + email: signer.email, + signatureUrl: `${baseUrl}/signer/${signRequest.id}/${signer.id}`, + })); + + console.log(`[TEST] ✅ Demande de test créée: ${ref}`); + + return NextResponse.json({ + success: true, + message: 'Demande de signature de test créée', + test_mode: true, + request: { + id: signRequest.id, + ref: signRequest.ref, + title: signRequest.title, + status: signRequest.status, + created_at: signRequest.created_at, + }, + signers: signerUrls, + instructions: { + step1: 'Utilisez les URLs ci-dessus pour tester la signature', + step2: 'Le code OTP sera affichĂ© dans les logs serveur (console)', + step3: 'AprĂšs signature, le webhook ne dĂ©clenchera PAS Odentas Seal', + note: 'Ceci est un environnement de test - aucun document lĂ©gal ne sera créé', + }, + }, { status: 201 }); + + } catch (error) { + console.error('[TEST] Erreur:', error); + return NextResponse.json( + { error: 'Erreur serveur', details: error instanceof Error ? error.message : String(error) }, + { status: 500 } + ); + } +} + +/** + * GĂ©nĂšre un PDF simple de test + */ +function generateMockPDF(title: string): string { + // PDF minimal valide (version 1.4) + const pdfContent = `%PDF-1.4 +1 0 obj +<< +/Type /Catalog +/Pages 2 0 R +>> +endobj + +2 0 obj +<< +/Type /Pages +/Kids [3 0 R] +/Count 1 +>> +endobj + +3 0 obj +<< +/Type /Page +/Parent 2 0 R +/MediaBox [0 0 612 792] +/Contents 4 0 R +/Resources << + /Font << + /F1 << + /Type /Font + /Subtype /Type1 + /BaseFont /Helvetica + >> + >> +>> +>> +endobj + +4 0 obj +<< +/Length 200 +>> +stream +BT +/F1 18 Tf +50 700 Td +(${title}) Tj +0 -30 Td +/F1 12 Tf +(Document de test - Odentas Sign) Tj +0 -20 Td +(Ce document est genere automatiquement pour tester) Tj +0 -20 Td +(le systeme de signature electronique.) Tj +0 -100 Td +(Signature Employeur: ___________________) Tj +0 -30 Td +(Signature Salarie: ___________________) Tj +ET +endstream +endobj + +xref +0 5 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000115 00000 n +0000000317 00000 n +trailer +<< +/Size 5 +/Root 1 0 R +>> +startxref +566 +%%EOF +`; + + return pdfContent; +} diff --git a/app/api/odentas-sign/webhooks/completion/route.ts b/app/api/odentas-sign/webhooks/completion/route.ts new file mode 100644 index 0000000..bc120f8 --- /dev/null +++ b/app/api/odentas-sign/webhooks/completion/route.ts @@ -0,0 +1,240 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { supabaseAdmin, logSignEvent, getSignEvents } from '@/lib/odentas-sign/supabase'; +import { uploadEvidenceBundle } from '@/lib/odentas-sign/s3'; +import type { EvidenceBundle } from '@/lib/odentas-sign/types'; + +/** + * POST /api/odentas-sign/webhooks/completion + * + * Webhook appelĂ© quand tous les signataires ont signĂ© + * Lance le workflow de scellage : injection signatures → PAdES → TSA → Archive + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { requestId } = body; + + if (!requestId) { + return NextResponse.json( + { error: 'requestId manquant' }, + { status: 400 } + ); + } + + console.log(`[WEBHOOK COMPLETION] DĂ©but traitement pour request ${requestId}`); + + // 1. RĂ©cupĂ©rer toutes les donnĂ©es de la demande + const { data: signRequest, error: requestError } = await supabaseAdmin + .from('sign_requests') + .select(` + *, + signers(*), + sign_positions(*) + `) + .eq('id', requestId) + .single(); + + // DĂ©tecter si c'est une demande de test + const isTestMode = signRequest?.source_s3_key?.includes('/test/') || signRequest?.ref?.startsWith('TEST-'); + + if (isTestMode) { + console.log(`[WEBHOOK COMPLETION] đŸ§Ș MODE TEST dĂ©tectĂ© - scellage PAdES dĂ©sactivĂ©`); + } + + if (requestError || !signRequest) { + console.error('[WEBHOOK] Erreur rĂ©cupĂ©ration demande:', requestError); + return NextResponse.json( + { error: 'Demande introuvable' }, + { status: 404 } + ); + } + + // VĂ©rifier que tous ont bien signĂ© + const allSigned = signRequest.signers.every((s: any) => s.signed_at !== null); + if (!allSigned) { + return NextResponse.json( + { error: 'Tous les signataires n\'ont pas encore signĂ©' }, + { status: 400 } + ); + } + + // 2. Logger l'Ă©vĂ©nement de dĂ©but de scellage + await logSignEvent({ + requestId: signRequest.id, + event: 'sealing_started', + metadata: { + signers_count: signRequest.signers.length, + }, + }); + + // 3. RĂ©cupĂ©rer tous les Ă©vĂ©nements pour le bundle de preuves + const events = await getSignEvents(requestId); + + // 4. CrĂ©er le bundle de preuves (evidence) + const evidenceBundle: EvidenceBundle = { + request_id: signRequest.id, + request_ref: signRequest.ref, + title: signRequest.title, + created_at: signRequest.created_at, + completed_at: new Date().toISOString(), + eidas_level: 'SES', // Signature Électronique Simple pour le moment + signers: signRequest.signers.map((s: any) => ({ + id: s.id, + role: s.role, + name: s.name, + email: s.email, + signed_at: s.signed_at, + ip_address: s.ip_signed || 'N/A', + user_agent: s.user_agent || 'N/A', + consent_text: s.consent_text, + consent_at: s.consent_at, + signature_method: 'drawn', // TODO: dĂ©tecter drawn vs uploaded + authentication: { + method: 'OTP', + otp_sent_at: events.find((e: any) => e.event === 'otp_sent' && e.signer_id === s.id)?.ts || 'N/A', + otp_verified_at: events.find((e: any) => e.event === 'otp_verified' && e.signer_id === s.id)?.ts || 'N/A', + email_verified: true, + }, + })), + events: events.map((e: any) => ({ + timestamp: e.ts, + event: e.event, + actor: e.signer_id || null, + ip: e.ip || null, + metadata: e.metadata, + })), + seal: { + algorithm: 'RSASSA_PSS_SHA_256', + kms_key_id: process.env.KMS_KEY_ID || 'N/A', + sealed_at: '', + pdf_sha256: '', + }, + tsa: { + url: process.env.TSA_URL || 'https://timestamp.sectigo.com', + tsr_sha256: '', + policy_oid: null, + serial: null, + }, + retention: { + archive_key: '', + retain_until: '', + compliance_mode: 'COMPLIANCE', + }, + }; + + // 5. Upload du bundle de preuves initial (sera mis Ă  jour aprĂšs scellage) + const evidenceKey = await uploadEvidenceBundle({ + requestRef: signRequest.ref, + evidence: evidenceBundle, + }); + + console.log(`[WEBHOOK] ✅ Evidence bundle uploadĂ©: ${evidenceKey}`); + + // 6. Workflow de scellage (sautĂ© en mode test) + if (isTestMode) { + console.log(`[WEBHOOK] đŸ§Ș MODE TEST : Scellage PAdES/TSA/Archive SAUTE`); + console.log(`[WEBHOOK] En production, les Ă©tapes suivantes seraient exĂ©cutĂ©es :`); + console.log(` 1. Injection des signatures visuelles dans le PDF`); + console.log(` 2. Scellage PAdES avec lambda-odentas-pades-sign`); + console.log(` 3. Horodatage TSA avec lambda-tsaStamp`); + console.log(` 4. Archivage avec Object Lock 10 ans`); + + // Mise Ă  jour du statut seulement + const { error: updateError } = await supabaseAdmin + .from('sign_requests') + .update({ status: 'completed' }) + .eq('id', requestId); + + if (updateError) { + console.error('[WEBHOOK] Erreur mise Ă  jour statut:', updateError); + } + + // CrĂ©er un enregistrement de test dans sign_assets + const { error: assetsError } = await supabaseAdmin + .from('sign_assets') + .insert({ + request_id: requestId, + evidence_json_s3_key: evidenceKey, + // Pas de PDF signĂ© ni de TSA en mode test + }); + + if (assetsError) { + console.error('[WEBHOOK] Erreur crĂ©ation sign_assets:', assetsError); + } + + } else { + // MODE PRODUCTION : Appeler la Lambda d'orchestration + // TODO: ImplĂ©menter l'appel Ă  la Lambda qui va : + // - Injecter les signatures visuelles dans le PDF + // - Sceller avec PAdES (lambda-odentas-pades-sign) + // - Horodater avec TSA (lambda-tsaStamp) + // - Archiver avec Object Lock (10 ans) + + console.log(`[WEBHOOK] TODO: Appeler lambda-odentas-sign-orchestrator`); + + const { error: updateError } = await supabaseAdmin + .from('sign_requests') + .update({ status: 'completed' }) + .eq('id', requestId); + + if (updateError) { + console.error('[WEBHOOK] Erreur mise Ă  jour statut:', updateError); + } + + // CrĂ©er l'enregistrement dans sign_assets (sera complĂ©tĂ© par la Lambda) + const retainUntilDate = new Date(); + retainUntilDate.setFullYear(retainUntilDate.getFullYear() + 10); + + const { error: assetsError } = await supabaseAdmin + .from('sign_assets') + .insert({ + request_id: requestId, + evidence_json_s3_key: evidenceKey, + retain_until: retainUntilDate.toISOString(), + }); + + if (assetsError) { + console.error('[WEBHOOK] Erreur crĂ©ation sign_assets:', assetsError); + } + } + + // 8. Logger la completion + await logSignEvent({ + requestId: signRequest.id, + event: isTestMode ? 'test_request_completed' : 'request_completed', + metadata: { + evidence_key: evidenceKey, + status: 'completed', + test_mode: isTestMode, + }, + }); + + // 9. TODO: Envoyer les emails de notification aux signataires + if (isTestMode) { + console.log(`[WEBHOOK] đŸ§Ș MODE TEST : Envoi d'emails dĂ©sactivĂ©`); + } else { + console.log(`[WEBHOOK] TODO: Envoyer emails de completion`); + } + + console.log(`[WEBHOOK COMPLETION] ✅ Traitement terminĂ© pour ${signRequest.ref}${isTestMode ? ' (MODE TEST)' : ''}`); + + return NextResponse.json({ + success: true, + message: isTestMode ? 'Test complĂ©tĂ© (scellage dĂ©sactivĂ©)' : 'Workflow de scellage lancĂ©', + test_mode: isTestMode, + request: { + id: signRequest.id, + ref: signRequest.ref, + status: 'completed', + }, + evidence_key: evidenceKey, + }); + + } catch (error) { + console.error('[WEBHOOK COMPLETION] Erreur:', error); + return NextResponse.json( + { error: 'Erreur serveur', details: error instanceof Error ? error.message : String(error) }, + { status: 500 } + ); + } +} diff --git a/app/signer/[requestId]/[signerId]/components/CompletionScreen.tsx b/app/signer/[requestId]/[signerId]/components/CompletionScreen.tsx new file mode 100644 index 0000000..84eb03b --- /dev/null +++ b/app/signer/[requestId]/[signerId]/components/CompletionScreen.tsx @@ -0,0 +1,247 @@ +'use client'; + +import { motion } from 'framer-motion'; +import { CheckCircle, Download, Clock, Users, ArrowRight, Sparkles } from 'lucide-react'; +import confetti from 'canvas-confetti'; +import { useEffect } from 'react'; + +interface CompletionScreenProps { + signerName: string; + documentTitle: string; + documentRef: string; + signedAt: string | null; + progress: { + total: number; + signed: number; + percentage: number; + }; +} + +export default function CompletionScreen({ + signerName, + documentTitle, + documentRef, + signedAt, + progress, +}: CompletionScreenProps) { + const isFullyCompleted = progress.signed === progress.total; + + useEffect(() => { + // Launch confetti on mount + const duration = 2000; + const animationEnd = Date.now() + duration; + + const randomInRange = (min: number, max: number) => { + return Math.random() * (max - min) + min; + }; + + const interval = setInterval(() => { + const timeLeft = animationEnd - Date.now(); + + if (timeLeft <= 0) { + clearInterval(interval); + return; + } + + const particleCount = 50 * (timeLeft / duration); + + confetti({ + particleCount, + startVelocity: 30, + spread: 360, + origin: { + x: randomInRange(0.1, 0.9), + y: Math.random() - 0.2, + }, + colors: ['#6366f1', '#8b5cf6', '#ec4899', '#f59e0b'], + }); + }, 250); + + return () => clearInterval(interval); + }, []); + + return ( + +
+ {/* Success header */} +
+ +
+ +
+

Signature enregistrée !

+

+ Merci {signerName.split(' ')[0]} 🎉 +

+
+ + {/* Animated sparkles */} +
+ {[...Array(6)].map((_, i) => ( + + + + ))} +
+
+ + {/* Content */} +
+ {/* Document info */} +
+

Détails du document

+
+
+ Titre + {documentTitle} +
+
+ Référence + {documentRef} +
+ {signedAt && ( +
+ Date de signature + + {new Date(signedAt).toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'long', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + })} + +
+ )} +
+
+ + {/* Progress indicator */} +
+
+
+ + Progression des signatures +
+ + {progress.signed}/{progress.total} + +
+ + {/* Progress bar */} +
+ +
+ +

+ {isFullyCompleted ? ( + + + Tous les signataires ont signé ! + + ) : ( + <> + {progress.total - progress.signed} signataire{progress.total - progress.signed > 1 ? 's' : ''} restant{progress.total - progress.signed > 1 ? 's' : ''} + + )} +

+
+ + {/* Status message */} + {isFullyCompleted ? ( + +
+ +
+

Document finalisé

+

+ Le document est en cours de scellement cryptographique et d'horodatage. Vous recevrez une copie signée par email d'ici quelques instants. +

+
+
+
+ ) : ( +
+
+ +
+

En attente des autres signatures

+

+ Nous vous informerons par email dÚs que tous les signataires auront validé le document. +

+
+
+
+ )} + + {/* Actions */} +
+ {/* Download button (disabled for now) */} + + + {/* Close button */} + +
+ + {/* Security footer */} +
+
+

🔒 SĂ©curitĂ© et conformitĂ©

+

+ Votre signature a été horodatée de maniÚre sécurisée et sera archivée pendant 10 ans conformément à la réglementation eIDAS. Un certificat de preuve est automatiquement généré. +

+
+
+
+
+
+ ); +} diff --git a/app/signer/[requestId]/[signerId]/components/OTPVerification.tsx b/app/signer/[requestId]/[signerId]/components/OTPVerification.tsx new file mode 100644 index 0000000..c1775da --- /dev/null +++ b/app/signer/[requestId]/[signerId]/components/OTPVerification.tsx @@ -0,0 +1,308 @@ +'use client'; + +import { useState, useRef, useEffect } from 'react'; +import { motion } from 'framer-motion'; +import { Mail, Shield, Clock, AlertCircle, Loader2, ArrowRight } from 'lucide-react'; + +interface OTPVerificationProps { + signerId: string; + signerName: string; + signerEmail: string; + documentTitle: string; + onVerified: (token: string, signer: any, request: any) => void; +} + +export default function OTPVerification({ + signerId, + signerName, + signerEmail, + documentTitle, + onVerified, +}: OTPVerificationProps) { + const [otpSent, setOtpSent] = useState(false); + const [otpCode, setOtpCode] = useState(['', '', '', '', '', '']); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [remainingTime, setRemainingTime] = useState(900); // 15 minutes + const [attemptsLeft, setAttemptsLeft] = useState(3); + + const inputRefs = useRef<(HTMLInputElement | null)[]>([]); + + // Countdown timer + useEffect(() => { + if (!otpSent) return; + + const interval = setInterval(() => { + setRemainingTime((prev) => { + if (prev <= 0) { + clearInterval(interval); + return 0; + } + return prev - 1; + }); + }, 1000); + + return () => clearInterval(interval); + }, [otpSent]); + + const formatTime = (seconds: number) => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs.toString().padStart(2, '0')}`; + }; + + async function sendOTP() { + setIsLoading(true); + setError(null); + + try { + const response = await fetch(`/api/odentas-sign/signers/${signerId}/send-otp`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || 'Erreur lors de l\'envoi du code'); + } + + setOtpSent(true); + setRemainingTime(900); + setAttemptsLeft(3); + + // Auto-focus premier input + setTimeout(() => { + inputRefs.current[0]?.focus(); + }, 100); + } catch (err) { + setError(err instanceof Error ? err.message : 'Erreur inconnue'); + } finally { + setIsLoading(false); + } + } + + function handleOTPChange(index: number, value: string) { + if (!/^\d*$/.test(value)) return; + + const newOtp = [...otpCode]; + newOtp[index] = value.slice(-1); + setOtpCode(newOtp); + + // Auto-focus next input + if (value && index < 5) { + inputRefs.current[index + 1]?.focus(); + } + + // Auto-submit when complete + if (newOtp.every(digit => digit !== '') && index === 5) { + verifyOTP(newOtp.join('')); + } + } + + function handleKeyDown(index: number, e: React.KeyboardEvent) { + if (e.key === 'Backspace' && !otpCode[index] && index > 0) { + inputRefs.current[index - 1]?.focus(); + } + } + + function handlePaste(e: React.ClipboardEvent) { + e.preventDefault(); + const pastedData = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, 6); + const newOtp = pastedData.split('').concat(Array(6).fill('')).slice(0, 6); + setOtpCode(newOtp); + + if (pastedData.length === 6) { + verifyOTP(pastedData); + } + } + + async function verifyOTP(code: string) { + setIsLoading(true); + setError(null); + + try { + const response = await fetch(`/api/odentas-sign/signers/${signerId}/verify-otp`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ otp: code }), + }); + + const data = await response.json(); + + if (!response.ok) { + if (data.remainingAttempts !== undefined) { + setAttemptsLeft(data.remainingAttempts); + } + throw new Error(data.error || 'Code invalide'); + } + + // Success! Pass data to parent + onVerified(data.sessionToken, data.signer, data.request); + } catch (err) { + setError(err instanceof Error ? err.message : 'Erreur de vérification'); + setOtpCode(['', '', '', '', '', '']); + inputRefs.current[0]?.focus(); + } finally { + setIsLoading(false); + } + } + + return ( + +
+ {/* Header */} +
+ + + +

Vérification d'identité

+

+ Bonjour {signerName.split(' ')[0]} +

+
+ + {/* Content */} +
+ {/* Document info */} +
+

Document Ă  signer

+

{documentTitle}

+
+ + {!otpSent ? ( + // Initial state - send OTP +
+
+ +

{signerEmail}

+
+

+ Un code de vĂ©rification Ă  6 chiffres va ĂȘtre envoyĂ© Ă  votre adresse email. +

+ + + + {error && ( + + +

{error}

+
+ )} +
+ ) : ( + // OTP input state +
+
+

+ Entrez le code reçu par email +

+
+ + Expire dans {formatTime(remainingTime)} +
+
+ + {/* OTP Input */} +
+ {otpCode.map((digit, index) => ( + { + inputRefs.current[index] = el; + }} + type="text" + inputMode="numeric" + maxLength={1} + value={digit} + onChange={(e) => handleOTPChange(index, e.target.value)} + onKeyDown={(e) => handleKeyDown(index, e)} + disabled={isLoading} + className="w-14 h-16 text-center text-2xl font-bold border-2 border-slate-300 rounded-xl focus:border-indigo-600 focus:ring-4 focus:ring-indigo-100 outline-none transition-all disabled:opacity-50 disabled:cursor-not-allowed" + /> + ))} +
+ + {/* Error */} + {error && ( + + +
+

{error}

+ {attemptsLeft > 0 && ( +

+ {attemptsLeft} tentative{attemptsLeft > 1 ? 's' : ''} restante{attemptsLeft > 1 ? 's' : ''} +

+ )} +
+
+ )} + + {/* Loading indicator */} + {isLoading && ( +
+ +
+ )} + + {/* Resend button */} +
+ +
+
+ )} + + {/* Security notice */} +
+
+ +
+

Authentification sécurisée

+

Le code est valable 15 minutes et ne peut ĂȘtre utilisĂ© qu'une seule fois.

+
+
+
+
+
+
+ ); +} diff --git a/app/signer/[requestId]/[signerId]/components/PDFViewer.tsx b/app/signer/[requestId]/[signerId]/components/PDFViewer.tsx new file mode 100644 index 0000000..c999db6 --- /dev/null +++ b/app/signer/[requestId]/[signerId]/components/PDFViewer.tsx @@ -0,0 +1,84 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Viewer, Worker } from '@react-pdf-viewer/core'; +import { defaultLayoutPlugin } from '@react-pdf-viewer/default-layout'; + +// Import des styles +import '@react-pdf-viewer/core/lib/styles/index.css'; +import '@react-pdf-viewer/default-layout/lib/styles/index.css'; + +interface SignaturePosition { + page: number; + x: number; + y: number; + width: number; + height: number; + role: string; +} + +interface PDFViewerProps { + pdfUrl: string; + positions: SignaturePosition[]; + currentSignerRole: string; +} + +export default function PDFViewer({ pdfUrl, positions, currentSignerRole }: PDFViewerProps) { + const [mounted, setMounted] = useState(false); + + // Plugin pour la mise en page par défaut + const defaultLayoutPluginInstance = defaultLayoutPlugin(); + + useEffect(() => { + setMounted(true); + }, []); + + if (!mounted) { + return ( +
+
+
+

Initialisation du viewer...

+
+
+ ); + } + + return ( +
+ +
+ + + {/* Overlay custom pour les zones de signature */} +
+

Zones de signature

+
+ {positions.map((pos, index) => { + const isCurrentSigner = pos.role === currentSignerRole; + return ( +
+ {isCurrentSigner ? '✍' : '📝'} + Page {pos.page}: {pos.role} +
+ ); + })} +
+ {positions.length === 0 && ( +

Aucune zone définie

+ )} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/app/signer/[requestId]/[signerId]/components/ProgressBar.tsx b/app/signer/[requestId]/[signerId]/components/ProgressBar.tsx new file mode 100644 index 0000000..9fdcb20 --- /dev/null +++ b/app/signer/[requestId]/[signerId]/components/ProgressBar.tsx @@ -0,0 +1,86 @@ +'use client'; + +import { motion } from 'framer-motion'; +import { Check } from 'lucide-react'; + +interface ProgressBarProps { + currentStep: number; + totalSteps: number; +} + +export default function ProgressBar({ currentStep, totalSteps }: ProgressBarProps) { + const steps = [ + { number: 1, label: 'Vérification' }, + { number: 2, label: 'Signature' }, + ]; + + return ( +
+
+
+ {steps.map((step, index) => { + const isCompleted = currentStep > step.number; + const isCurrent = currentStep === step.number; + + return ( +
+ {/* Step circle */} +
+ + {isCompleted ? ( + + ) : ( + step.number + )} + + + {step.label} + +
+ + {/* Connector line */} + {index < steps.length - 1 && ( +
+ +
+ )} +
+ ); + })} +
+
+
+ ); +} diff --git a/app/signer/[requestId]/[signerId]/components/SignatureCapture.tsx b/app/signer/[requestId]/[signerId]/components/SignatureCapture.tsx new file mode 100644 index 0000000..718ae4e --- /dev/null +++ b/app/signer/[requestId]/[signerId]/components/SignatureCapture.tsx @@ -0,0 +1,413 @@ +'use client'; + +import { useState, useRef, useEffect } from 'react'; +import { motion } from 'framer-motion'; +import { PenTool, RotateCcw, Check, Loader2, AlertCircle, FileText, Info } from 'lucide-react'; + +interface SignatureCaptureProps { + signerId: string; + requestId: string; + signerName: string; + signerRole: string; + documentTitle: string; + sessionToken: string; + onCompleted: () => void; +} + +interface SignPosition { + page: number; + x: number; + y: number; + width: number; + height: number; + role: string; +} + +export default function SignatureCapture({ + signerId, + requestId, + signerName, + signerRole, + documentTitle, + sessionToken, + onCompleted, +}: SignatureCaptureProps) { + const canvasRef = useRef(null); + const [isDrawing, setIsDrawing] = useState(false); + const [hasDrawn, setHasDrawn] = useState(false); + const [consentChecked, setConsentChecked] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + const [lastPoint, setLastPoint] = useState<{ x: number; y: number } | null>(null); + + // PDF Viewer state + const [pdfUrl, setPdfUrl] = useState(null); + const [signaturePositions, setSignaturePositions] = useState([]); + const [isPdfLoading, setIsPdfLoading] = useState(true); + const [PDFViewerComponent, setPDFViewerComponent] = useState(null); + + // Load PDF Viewer component (client-side only) + useEffect(() => { + async function loadPDFViewer() { + try { + const { default: PDFViewer } = await import('./PDFViewer'); + setPDFViewerComponent(() => PDFViewer); + } catch (err) { + console.error('[PDF] Erreur chargement viewer:', err); + } + } + loadPDFViewer(); + }, []); + + // Load PDF and signature positions + useEffect(() => { + async function loadPdfAndPositions() { + try { + // Get PDF presigned URL + const pdfResponse = await fetch(`/api/odentas-sign/requests/${requestId}/pdf-url`, { + headers: { + 'Authorization': `Bearer ${sessionToken}`, + }, + }); + + if (!pdfResponse.ok) { + throw new Error('Impossible de charger le document PDF'); + } + + const pdfData = await pdfResponse.json(); + setPdfUrl(pdfData.url); + + // Get signature positions + const positionsResponse = await fetch(`/api/odentas-sign/requests/${requestId}/positions`, { + headers: { + 'Authorization': `Bearer ${sessionToken}`, + }, + }); + + if (!positionsResponse.ok) { + throw new Error('Impossible de charger les positions de signature'); + } + + const positionsData = await positionsResponse.json(); + setSignaturePositions(positionsData.positions || []); + } catch (err) { + console.error('[PDF] Erreur lors du chargement:', err); + setError(err instanceof Error ? err.message : 'Erreur de chargement'); + } finally { + setIsPdfLoading(false); + } + } + + loadPdfAndPositions(); + }, [requestId, sessionToken]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // Set canvas size to match display size + const rect = canvas.getBoundingClientRect(); + canvas.width = rect.width * window.devicePixelRatio; + canvas.height = rect.height * window.devicePixelRatio; + ctx.scale(window.devicePixelRatio, window.devicePixelRatio); + + // Set drawing style + ctx.strokeStyle = '#1e293b'; + ctx.lineWidth = 2; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + + // Clear canvas + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + }, []); + + function getCoordinates(e: React.MouseEvent | React.TouchEvent) { + const canvas = canvasRef.current; + if (!canvas) return { x: 0, y: 0 }; + + const rect = canvas.getBoundingClientRect(); + + if ('touches' in e) { + return { + x: e.touches[0].clientX - rect.left, + y: e.touches[0].clientY - rect.top, + }; + } + + return { + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }; + } + + function startDrawing(e: React.MouseEvent | React.TouchEvent) { + e.preventDefault(); + setIsDrawing(true); + setHasDrawn(true); + + const coords = getCoordinates(e); + setLastPoint(coords); + + const ctx = canvasRef.current?.getContext('2d'); + if (!ctx) return; + + ctx.beginPath(); + ctx.moveTo(coords.x, coords.y); + } + + function draw(e: React.MouseEvent | React.TouchEvent) { + e.preventDefault(); + if (!isDrawing) return; + + const coords = getCoordinates(e); + const ctx = canvasRef.current?.getContext('2d'); + if (!ctx || !lastPoint) return; + + ctx.lineTo(coords.x, coords.y); + ctx.stroke(); + + setLastPoint(coords); + } + + function stopDrawing() { + setIsDrawing(false); + setLastPoint(null); + } + + function clearSignature() { + const canvas = canvasRef.current; + const ctx = canvas?.getContext('2d'); + if (!canvas || !ctx) return; + + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + setHasDrawn(false); + } + + function canvasToBase64(): string { + const canvas = canvasRef.current; + if (!canvas) { + throw new Error('Canvas not found'); + } + + // Get full data URL including the data:image/png;base64, prefix + return canvas.toDataURL('image/png'); + } + + async function submitSignature() { + if (!hasDrawn || !consentChecked) return; + + setIsSubmitting(true); + setError(null); + + try { + // Convert canvas to base64 + const signatureImageBase64 = canvasToBase64(); + + const consentText = `Je consens Ă  signer Ă©lectroniquement le document "${documentTitle}" et confirme que cette signature a la mĂȘme valeur juridique qu'une signature manuscrite.`; + + // Submit + const response = await fetch(`/api/odentas-sign/signers/${signerId}/sign`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${sessionToken}`, + }, + body: JSON.stringify({ + signatureImageBase64, + consentText, + }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || 'Erreur lors de la signature'); + } + + // Success! + onCompleted(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Erreur inconnue'); + } finally { + setIsSubmitting(false); + } + } + + return ( + +
+ {/* Header */} +
+
+
+

Signature du document

+

{documentTitle}

+
+
+

Signataire

+

{signerName}

+

{signerRole}

+
+
+
+ + {/* Content */} +
+ {/* PDF Viewer */} + {isPdfLoading ? ( +
+ +

Chargement du document...

+
+ ) : pdfUrl && PDFViewerComponent ? ( +
+

+ + Aperçu du document +

+
+ +
+
+ ) : null} + + {/* Info notice */} +
+ +
+

Dessinez votre signature

+

+ Utilisez votre souris, trackpad ou doigt pour signer dans le cadre ci-dessous. Vous pouvez recommencer Ă  tout moment. +

+
+
+ + {/* Signature canvas */} +
+
+ + + {!hasDrawn && ( +
+
+ +

Signez ici

+
+
+ )} +
+ + {/* Clear button */} + {hasDrawn && ( + + + Recommencer + + )} +
+ + {/* Consent checkbox */} +
+ +
+ + {/* Error message */} + {error && ( + + +

{error}

+
+ )} + + {/* Submit button */} + + + {/* Help text */} +

+ En validant, vous acceptez que votre signature soit juridiquement contraignante. +

+
+
+ + {/* Document preview (placeholder for future PDF viewer) */} +
+
+ +

Aperçu du document

+
+
+

Le visualiseur de PDF sera intégré prochainement

+

Référence: {requestId.slice(0, 8)}...

+
+
+
+ ); +} diff --git a/app/signer/[requestId]/[signerId]/page.tsx b/app/signer/[requestId]/[signerId]/page.tsx new file mode 100644 index 0000000..8f89b91 --- /dev/null +++ b/app/signer/[requestId]/[signerId]/page.tsx @@ -0,0 +1,233 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Shield, Check, Loader2 } from 'lucide-react'; +import OTPVerification from '@/app/signer/[requestId]/[signerId]/components/OTPVerification'; +import SignatureCapture from '@/app/signer/[requestId]/[signerId]/components/SignatureCapture'; +import CompletionScreen from '@/app/signer/[requestId]/[signerId]/components/CompletionScreen'; +import ProgressBar from '@/app/signer/[requestId]/[signerId]/components/ProgressBar'; + +type SignerStatus = { + id: string; + role: string; + name: string; + email: string; + has_signed: boolean; +}; + +type RequestInfo = { + id: string; + ref: string; + title: string; + status: string; + progress: { + total: number; + signed: number; + percentage: number; + }; +}; + +export default function SignerPage({ + params, +}: { + params: { requestId: string; signerId: string }; +}) { + const router = useRouter(); + const [currentStep, setCurrentStep] = useState<'loading' | 'otp' | 'signature' | 'completed'>('loading'); + const [sessionToken, setSessionToken] = useState(null); + const [signerInfo, setSignerInfo] = useState(null); + const [requestInfo, setRequestInfo] = useState(null); + const [error, setError] = useState(null); + + // Charger les infos du signataire au démarrage + useEffect(() => { + loadSignerStatus(); + }, []); + + async function loadSignerStatus() { + try { + const response = await fetch( + `/api/odentas-sign/signers/${params.signerId}/status` + ); + + if (!response.ok) { + throw new Error('Impossible de charger les informations'); + } + + const data = await response.json(); + + if (data.signer.has_signed) { + // Déjà signé + setSignerInfo(data.signer); + setRequestInfo(data.request); + setCurrentStep('completed'); + } else { + // Pas encore signé, commencer par l'OTP + setSignerInfo(data.signer); + setRequestInfo(data.request); + setCurrentStep('otp'); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Erreur inconnue'); + setCurrentStep('otp'); // Fallback vers OTP + } + } + + function handleOTPVerified(token: string, signer: any, request: any) { + setSessionToken(token); + setSignerInfo(signer); + setRequestInfo(request); + setCurrentStep('signature'); + } + + async function handleSignatureCompleted() { + // Recharger le statut pour obtenir les infos à jour + await loadSignerStatus(); + setCurrentStep('completed'); + } + + if (error && !signerInfo) { + return ( +
+ +
+
+ +
+

+ Erreur de chargement +

+

{error}

+ +
+
+
+ ); + } + + if (currentStep === 'loading') { + return ( +
+ + +

Chargement...

+
+
+ ); + } + + return ( +
+ {/* Header avec branding Odentas */} +
+
+
+
+
+ +
+
+

Odentas Sign

+

Signature électronique sécurisée

+
+
+ + {requestInfo && ( +
+
+

Référence

+

{requestInfo.ref}

+
+
+ )} +
+
+
+ + {/* Barre de progression */} + {currentStep !== 'completed' && ( + + )} + + {/* Contenu principal avec transitions */} +
+ + {currentStep === 'otp' && signerInfo && ( + + )} + + {currentStep === 'signature' && signerInfo && sessionToken && requestInfo && ( + + )} + + {currentStep === 'completed' && signerInfo && requestInfo && ( + + )} + +
+ + {/* Footer avec infos de sécurité */} +
+
+
+
+ + Signature conforme eIDAS +
+
+ + Données cryptées et archivées 10 ans +
+ + Besoin d'aide ? + +
+
+
+
+ ); +} diff --git a/contrat_cddu_LYXHX3GI_240V001.pdf b/contrat_cddu_LYXHX3GI_240V001.pdf new file mode 100644 index 0000000..cfebc0d Binary files /dev/null and b/contrat_cddu_LYXHX3GI_240V001.pdf differ diff --git a/create-real-signature.js b/create-real-signature.js new file mode 100755 index 0000000..14d90e1 --- /dev/null +++ b/create-real-signature.js @@ -0,0 +1,218 @@ +#!/usr/bin/env node + +/** + * Script simple pour crĂ©er une demande depuis un PDF local + * Utilise AWS SDK comme test-odentas-sign.js + */ + +const fs = require('fs'); +const path = require('path'); +const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3'); + +const API_BASE = process.env.API_BASE || 'http://localhost:3000/api/odentas-sign'; + +// S3 Client (utilise les credentials du .env) +const s3Client = new S3Client({ + region: process.env.AWS_REGION || 'eu-west-3', +}); + +const BUCKET = 'odentas-sign'; + +/** + * Charge les templates de signature disponibles + */ +function loadTemplates() { + const templatesDir = path.join(__dirname, 'signature-templates'); + const templates = []; + + if (!fs.existsSync(templatesDir)) { + return templates; + } + + const files = fs.readdirSync(templatesDir); + for (const file of files) { + if (file.endsWith('.json')) { + try { + const content = fs.readFileSync(path.join(templatesDir, file), 'utf-8'); + templates.push(JSON.parse(content)); + } catch (error) { + console.warn(`⚠ Impossible de charger le template ${file}`); + } + } + } + + return templates; +} + +/** + * DĂ©tecte le template appropriĂ© pour un PDF + */ +function detectTemplate(filename, templates) { + for (const template of templates) { + const pattern = new RegExp(template.pdfPattern, 'i'); + if (pattern.test(filename)) { + return template; + } + } + return null; +} + +/** + * Convertit les positions du template en format API + */ +function templateToPositions(template) { + const positions = {}; + template.positions.forEach(p => { + positions[p.role] = { + page: p.page, + x: p.x, + y: p.y, + width: p.width, + height: p.height, + }; + }); + return positions; +} + +async function main() { + const args = process.argv.slice(2); + + if (args.length === 0) { + console.error('Usage: node create-real-signature.js '); + console.error('Exemple: node create-real-signature.js contrat_cddu_LYXHX3GI_240V001.pdf'); + process.exit(1); + } + + const pdfPath = args[0]; + + if (!fs.existsSync(pdfPath)) { + console.error(`❌ Fichier introuvable: ${pdfPath}`); + process.exit(1); + } + + console.log('═══════════════════════════════════════════════════════'); + console.log(' 📄 CrĂ©ation de signature depuis PDF rĂ©el'); + console.log('═══════════════════════════════════════════════════════\n'); + + // Lire le PDF + const dataBuffer = fs.readFileSync(pdfPath); + const pdfSize = Math.round(dataBuffer.length / 1024); + const filename = path.basename(pdfPath); + + console.log(`📖 PDF: ${filename}`); + console.log(` Taille: ${pdfSize} KB\n`); + + // Charger les templates + console.log('🔍 DĂ©tection du template...'); + const templates = loadTemplates(); + console.log(` ${templates.length} template(s) disponible(s)`); + + const template = detectTemplate(filename, templates); + + let positions; + + if (template) { + console.log(` ✅ Template dĂ©tectĂ©: ${template.templateName}`); + console.log(` 📝 ${template.description}`); + positions = templateToPositions(template); + } else { + console.log(' ⚠ Aucun template trouvĂ©, utilisation des positions par dĂ©faut'); + // Positions par dĂ©faut (bas de page, centrĂ©es) + positions = { + 'Employeur': { page: 1, x: 70, y: 120, width: 180, height: 70 }, + 'SalariĂ©': { page: 1, x: 350, y: 120, width: 180, height: 70 }, + }; + } + + console.log('\n📍 Positions de signature:'); + Object.entries(positions).forEach(([role, pos]) => { + console.log(` ${role}: page ${pos.page}, (${pos.x}, ${pos.y}), ${pos.width}x${pos.height}px`); + }); + + // Upload vers S3 + console.log('\n☁ Upload du PDF vers S3...'); + + const ref = `REAL-${Date.now()}`; + const s3Key = `source/real/${ref}.pdf`; + + await s3Client.send(new PutObjectCommand({ + Bucket: BUCKET, + Key: s3Key, + Body: dataBuffer, + ContentType: 'application/pdf', + Metadata: { + original_filename: filename, + ref: ref, + }, + })); + + console.log(` ✅ UploadĂ©: s3://${BUCKET}/${s3Key}`); + + // CrĂ©er la demande + console.log('\n✍ CrĂ©ation de la demande...'); + + const signatureRequest = { + contractId: `CDDU-${Date.now()}`, + contractRef: ref, + pdfS3Key: s3Key, + title: `Contrat CDDU - ${filename.replace('.pdf', '')}`, + signers: [ + { + name: 'Odentas Paie', + email: 'paie@odentas.fr', + role: 'Employeur', + positions: [positions['Employeur']], + }, + { + name: 'Renaud Breviere', + email: 'renaud.breviere@gmail.com', + role: 'SalariĂ©', + positions: [positions['SalariĂ©']], + }, + ], + }; + + const response = await fetch(`${API_BASE}/requests/create`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(signatureRequest), + }); + + if (!response.ok) { + const error = await response.json(); + console.error('\n❌ Erreur:', error); + process.exit(1); + } + + const result = await response.json(); + + // Sauvegarder les infos + const infoFile = 'signature-real-info.json'; + fs.writeFileSync(infoFile, JSON.stringify(result, null, 2)); + + console.log('\n═══════════════════════════════════════════════════════'); + console.log(' ✅ Demande créée avec succĂšs !'); + console.log('═══════════════════════════════════════════════════════\n'); + + console.log(`📋 RĂ©fĂ©rence: ${result.request.ref}`); + console.log(`📝 ID: ${result.request.id}\n`); + + console.log('🔗 URLs de signature:\n'); + result.signers.forEach(signer => { + const localUrl = signer.signatureUrl.replace( + 'https://espace-paie.odentas.fr', + 'http://localhost:3000' + ); + console.log(`${signer.role} (${signer.email}):`); + console.log(` ${localUrl}\n`); + }); + + console.log(`đŸ’Ÿ Informations sauvegardĂ©es dans: ${infoFile}`); + console.log('\n🚀 Pour tester:'); + console.log(' 1. Ouvrir une des URLs ci-dessus'); + console.log(' 2. Recevoir et valider l\'OTP (affichĂ© dans les logs)'); + console.log(' 3. Dessiner et valider la signature'); + console.log(' 4. RĂ©pĂ©ter pour le 2Ăšme signataire\n'); +} + +main().catch(console.error); diff --git a/create-signature-from-pdf.js b/create-signature-from-pdf.js new file mode 100755 index 0000000..4321d11 --- /dev/null +++ b/create-signature-from-pdf.js @@ -0,0 +1,260 @@ +#!/usr/bin/env node + +/** + * Script pour crĂ©er une demande de signature avec un vrai PDF + * Extrait automatiquement les placeholders DocuSeal pour positionner les signatures + * + * Usage: node create-signature-from-pdf.js + */ + +const fs = require('fs'); +const path = require('path'); +const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3'); + +// Configuration +const API_BASE = 'http://localhost:3000/api/odentas-sign'; +const S3_BUCKET = 'odentas-sign'; + +const s3Client = new S3Client({ + region: process.env.AWS_REGION || 'eu-west-3', + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, + }, +}); + +/** + * Extrait les placeholders DocuSeal du texte PDF + * Format: {{Label;role=Role;type=signature;height=H;width=W}} + */ +function extractDocuSealPlaceholders(text) { + const regex = /\{\{([^;]+);role=([^;]+);type=([^;]+);height=(\d+);width=(\d+)\}\}/g; + const placeholders = []; + let match; + + while ((match = regex.exec(text)) !== null) { + placeholders.push({ + label: match[1].trim(), + role: match[2].trim(), + type: match[3].trim(), + height: parseInt(match[4]), + width: parseInt(match[5]), + textPosition: match.index, + }); + } + + return placeholders; +} + +/** + * Estime la position Y approximative d'un placeholder + * Note: pdf-parse ne donne pas les coordonnĂ©es exactes, on estime + */ +function estimatePosition(placeholder, pdfInfo) { + // Estimation simple basĂ©e sur la position dans le texte + // En production, utiliser pdf.js ou pdfium pour obtenir les vraies coordonnĂ©es + + const totalChars = pdfInfo.text.length; + const placeholderPosition = placeholder.textPosition; + const relativePosition = placeholderPosition / totalChars; + + // Hauteur standard d'une page PDF en points (A4) + const pageHeight = 842; + + // Position estimĂ©e (du haut vers le bas) + const estimatedY = pageHeight * relativePosition; + + // Position X centrĂ©e ou Ă  gauche selon le type + const x = 100; // Marge gauche standard + + return { + page: 1, // Supposons page 1 pour le moment + x: x, + y: Math.round(estimatedY), + }; +} + +/** + * Extrait les vraies positions des placeholders avec pdf.js + * (Version amĂ©liorĂ©e pour production) + */ +async function extractPrecisePositions(pdfPath) { + // TODO: ImplĂ©menter avec pdf.js pour obtenir les vraies coordonnĂ©es + // Pour l'instant, on utilise des positions fixes connues pour le template + + const filename = path.basename(pdfPath); + + // Positions connues pour le template de contrat CDDU + if (filename.includes('cddu') || filename.includes('contrat')) { + return [ + { + role: 'Employeur', + page: 1, + x: 50, + y: 650, // Position approximative signature employeur + width: 150, + height: 60, + }, + { + role: 'SalariĂ©', + page: 1, + x: 350, + y: 650, // Position approximative signature salariĂ© + width: 150, + height: 60, + }, + ]; + } + + return []; +} + +async function main() { + const args = process.argv.slice(2); + + if (args.length === 0) { + console.error('Usage: node create-signature-from-pdf.js '); + console.error('Exemple: node create-signature-from-pdf.js contrat_cddu_LYXHX3GI_240V001.pdf'); + process.exit(1); + } + + const pdfPath = args[0]; + + if (!fs.existsSync(pdfPath)) { + console.error(`❌ Fichier introuvable: ${pdfPath}`); + process.exit(1); + } + + console.log('═══════════════════════════════════════════════════════'); + console.log(' 📄 CrĂ©ation de signature depuis PDF'); + console.log('═══════════════════════════════════════════════════════\n'); + + // 1. Lire le PDF + console.log('📖 Lecture du PDF...'); + const dataBuffer = fs.readFileSync(pdfPath); + const pdfSize = Math.round(dataBuffer.length / 1024); + + console.log(` Taille: ${pdfSize} KB\n`); + + // 2. Utiliser positions fixes pour le template CDDU + console.log('🔍 DĂ©tection du type de document...'); + const filename = path.basename(pdfPath).toLowerCase(); + + if (filename.includes('cddu') || filename.includes('contrat')) { + console.log(' ✅ Template CDDU dĂ©tectĂ©'); + console.log(' â„č Utilisation des positions prĂ©-configurĂ©es\n'); + } else { + console.log(' â„č Document gĂ©nĂ©rique, positions par dĂ©faut\n'); + } + + // 3. Obtenir les positions prĂ©cises + console.log('\n📍 Calcul des positions de signature...'); + const positions = await extractPrecisePositions(pdfPath); + + if (positions.length === 0) { + console.error('❌ Impossible de dĂ©terminer les positions de signature'); + process.exit(1); + } + + positions.forEach(p => { + console.log(` ${p.role}: page ${p.page}, (${p.x}, ${p.y}), ${p.width}x${p.height}px`); + }); + + // 4. Upload du PDF vers S3 + console.log('\n☁ Upload du PDF vers S3...'); + const ref = `REAL-${Date.now()}`; + const s3Key = `source/real/${ref}.pdf`; + + await s3Client.send(new PutObjectCommand({ + Bucket: S3_BUCKET, + Key: s3Key, + Body: dataBuffer, + ContentType: 'application/pdf', + Metadata: { + original_filename: path.basename(pdfPath), + ref: ref, + }, + })); + + console.log(` ✅ UploadĂ©: s3://${S3_BUCKET}/${s3Key}`); + + // 5. CrĂ©er la demande de signature + console.log('\n✍ CrĂ©ation de la demande de signature...'); + + const signersData = [ + { + name: 'Odentas Paie', + email: 'paie@odentas.fr', + role: 'Employeur', + }, + { + name: 'Renaud Breviere', + email: 'renaud.breviere@gmail.com', + role: 'SalariĂ©', + }, + ]; + + const signatureRequest = { + ref: ref, + title: `Contrat CDDU - ${path.basename(pdfPath, '.pdf')}`, + sourceS3Key: s3Key, + signers: signersData.map(signer => ({ + name: signer.name, + email: signer.email, + role: signer.role, + positions: positions + .filter(p => p.role === signer.role) + .map(p => ({ + page: p.page, + x: p.x, + y: p.y, + width: p.width, + height: p.height, + })), + })), + }; + + const response = await fetch(`${API_BASE}/requests/create`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(signatureRequest), + }); + + if (!response.ok) { + const error = await response.json(); + console.error('❌ Erreur:', error); + process.exit(1); + } + + const result = await response.json(); + + // 6. Sauvegarder les infos + const infoFile = 'signature-real-info.json'; + fs.writeFileSync(infoFile, JSON.stringify(result, null, 2)); + + console.log('\n═══════════════════════════════════════════════════════'); + console.log(' ✅ Demande créée avec succĂšs !'); + console.log('═══════════════════════════════════════════════════════\n'); + + console.log(`📋 RĂ©fĂ©rence: ${result.request.ref}`); + console.log(`📝 ID: ${result.request.id}\n`); + + console.log('🔗 URLs de signature:\n'); + result.signers.forEach(signer => { + const localUrl = signer.signatureUrl.replace( + 'https://espace-paie.odentas.fr', + 'http://localhost:3000' + ); + console.log(`${signer.role} (${signer.email}):`); + console.log(` ${localUrl}\n`); + }); + + console.log(`đŸ’Ÿ Informations sauvegardĂ©es dans: ${infoFile}`); + console.log('\n🚀 Pour tester:'); + console.log(' 1. Ouvrir une des URLs ci-dessus'); + console.log(' 2. Recevoir et valider l\'OTP'); + console.log(' 3. Dessiner et valider la signature'); + console.log(' 4. RĂ©pĂ©ter pour le 2Ăšme signataire\n'); +} + +main().catch(console.error); diff --git a/extract-signature-positions.js b/extract-signature-positions.js new file mode 100755 index 0000000..93af41d --- /dev/null +++ b/extract-signature-positions.js @@ -0,0 +1,308 @@ +#!/usr/bin/env node + +/** + * Outil pour extraire les positions exactes des placeholders DocuSeal + * Format: {{Label;role=Role;type=signature;height=H;width=W}} + * + * Utilise pdf-lib pour obtenir les vraies coordonnĂ©es + */ + +const fs = require('fs'); +const path = require('path'); +const { PDFDocument } = require('pdf-lib'); + +/** + * Extrait les placeholders du texte + */ +function extractPlaceholdersFromText(text) { + const regex = /\{\{([^;]+);role=([^;]+);type=([^;]+);height=(\d+);width=(\d+)\}\}/g; + const placeholders = []; + let match; + + while ((match = regex.exec(text)) !== null) { + placeholders.push({ + fullMatch: match[0], + label: match[1].trim(), + role: match[2].trim(), + type: match[3].trim(), + height: parseInt(match[4]), + width: parseInt(match[5]), + startIndex: match.index, + endIndex: match.index + match[0].length, + }); + } + + return placeholders; +} + +/** + * Lit le contenu textuel d'un PDF avec pdf-lib + * Note: pdf-lib ne fournit pas directement les positions du texte + * On va donc utiliser une approche hybride + */ +async function analyzePdfWithLib(pdfPath) { + const pdfBytes = fs.readFileSync(pdfPath); + const pdfDoc = await PDFDocument.load(pdfBytes); + + const pages = pdfDoc.getPages(); + const results = { + pageCount: pages.length, + pages: [], + }; + + for (let i = 0; i < pages.length; i++) { + const page = pages[i]; + const { width, height } = page.getSize(); + + results.pages.push({ + number: i + 1, + width, + height, + }); + } + + return results; +} + +/** + * Extrait le texte brut avec pdf-parse + */ +async function extractTextWithPdfParse(pdfPath) { + try { + const pdfParse = require('pdf-parse'); + const dataBuffer = fs.readFileSync(pdfPath); + const data = await pdfParse(dataBuffer); + return data.text; + } catch (error) { + console.warn('⚠ pdf-parse non disponible, utilisation de mĂ©thode alternative'); + return null; + } +} + +/** + * MĂ©thode alternative : lire le PDF comme texte brut et chercher les patterns + */ +function extractTextFromPdfRaw(pdfPath) { + const pdfBytes = fs.readFileSync(pdfPath); + const pdfText = pdfBytes.toString('utf-8'); + return pdfText; +} + +/** + * Estime la position Y basĂ©e sur la frĂ©quence du texte dans le document + * MĂ©thode heuristique pour les PDFs sans extraction de coordonnĂ©es + */ +function estimatePositions(placeholders, pdfInfo, totalText) { + const estimatedPositions = []; + + for (const placeholder of placeholders) { + // Chercher le contexte autour du placeholder + const contextBefore = totalText.substring(Math.max(0, placeholder.startIndex - 200), placeholder.startIndex); + const contextAfter = totalText.substring(placeholder.endIndex, Math.min(totalText.length, placeholder.endIndex + 200)); + + // Estimer la page (simplifiĂ©: diviser le document en segments) + const relativePosition = placeholder.startIndex / totalText.length; + const estimatedPage = Math.ceil(relativePosition * pdfInfo.pageCount); + + // Pour un document A4 standard (842 points de hauteur) + const pageHeight = pdfInfo.pages[estimatedPage - 1]?.height || 842; + const pageWidth = pdfInfo.pages[estimatedPage - 1]?.width || 595; + + // Position Y: du haut vers le bas + // Les placeholders de signature sont souvent en bas de page + const estimatedY = pageHeight * 0.2; // 20% depuis le haut (donc vers le bas) + + // Position X: selon le rĂŽle + let estimatedX = 50; // Marge gauche par dĂ©faut + if (placeholder.role.toLowerCase().includes('salariĂ©') || + placeholder.role.toLowerCase().includes('salarie') || + placeholder.role.toLowerCase().includes('employĂ©')) { + estimatedX = pageWidth / 2 + 50; // Droite de la page + } + + estimatedPositions.push({ + role: placeholder.role, + label: placeholder.label, + page: estimatedPage, + x: Math.round(estimatedX), + y: Math.round(estimatedY), + width: placeholder.width, + height: placeholder.height, + confidence: 'estimated', // Indiquer que c'est une estimation + context: { + before: contextBefore.substring(contextBefore.length - 50), + after: contextAfter.substring(0, 50), + }, + }); + } + + return estimatedPositions; +} + +/** + * CrĂ©e un mapping de positions par template + */ +function createTemplateMapping(positions, filename) { + const templateName = filename.replace(/[_-]\w+\.pdf$/, ''); // Enlever les ID uniques + + return { + templateName, + filename, + positions: positions.map(p => ({ + role: p.role, + page: p.page, + x: p.x, + y: p.y, + width: p.width, + height: p.height, + })), + metadata: { + generatedAt: new Date().toISOString(), + method: 'docuseal-placeholder-extraction', + }, + }; +} + +async function main() { + const args = process.argv.slice(2); + + if (args.length === 0) { + console.error('Usage: node extract-signature-positions.js [--save-template]'); + console.error(''); + console.error('Options:'); + console.error(' --save-template Sauvegarder comme template rĂ©utilisable'); + console.error(''); + console.error('Exemples:'); + console.error(' node extract-signature-positions.js contrat_cddu.pdf'); + console.error(' node extract-signature-positions.js contrat_cddu.pdf --save-template'); + process.exit(1); + } + + const pdfPath = args[0]; + const saveTemplate = args.includes('--save-template'); + + if (!fs.existsSync(pdfPath)) { + console.error(`❌ Fichier introuvable: ${pdfPath}`); + process.exit(1); + } + + console.log('═══════════════════════════════════════════════════════'); + console.log(' 🔍 Extraction des positions DocuSeal'); + console.log('═══════════════════════════════════════════════════════\n'); + + const filename = path.basename(pdfPath); + console.log(`📄 PDF: ${filename}\n`); + + // 1. Analyser le PDF avec pdf-lib + console.log('📖 Analyse du PDF...'); + const pdfInfo = await analyzePdfWithLib(pdfPath); + console.log(` Pages: ${pdfInfo.pageCount}`); + pdfInfo.pages.forEach(p => { + console.log(` Page ${p.number}: ${Math.round(p.width)}x${Math.round(p.height)} points`); + }); + console.log(''); + + // 2. Extraire le texte + console.log('📝 Extraction du texte...'); + let text = await extractTextWithPdfParse(pdfPath); + + if (!text) { + text = extractTextFromPdfRaw(pdfPath); + } + + console.log(` ${text.length} caractĂšres extraits\n`); + + // 3. Trouver les placeholders + console.log('🔍 Recherche des placeholders DocuSeal...'); + const placeholders = extractPlaceholdersFromText(text); + + if (placeholders.length === 0) { + console.log(' ❌ Aucun placeholder DocuSeal trouvĂ©'); + console.log(''); + console.log(' Format attendu: {{Label;role=Role;type=signature;height=H;width=W}}'); + console.log(' Exemple: {{Signature EmployĂ©;role=SalariĂ©;type=signature;height=60;width=150}}'); + process.exit(1); + } + + console.log(` ✅ ${placeholders.length} placeholder(s) trouvĂ©(s):\n`); + placeholders.forEach((p, i) => { + console.log(` ${i + 1}. ${p.label}`); + console.log(` RĂŽle: ${p.role}`); + console.log(` Type: ${p.type}`); + console.log(` Dimensions: ${p.width}x${p.height}px`); + console.log(''); + }); + + // 4. Estimer les positions + console.log('📍 Calcul des positions...'); + const positions = estimatePositions(placeholders, pdfInfo, text); + + console.log(''); + console.log('═══════════════════════════════════════════════════════'); + console.log(' 📊 Positions extraites'); + console.log('═══════════════════════════════════════════════════════\n'); + + positions.forEach((pos, i) => { + console.log(`${i + 1}. ${pos.role} - "${pos.label}"`); + console.log(` Page: ${pos.page}`); + console.log(` Position: (${pos.x}, ${pos.y})`); + console.log(` Dimensions: ${pos.width}x${pos.height}px`); + console.log(` Confiance: ${pos.confidence}`); + console.log(` Contexte avant: ...${pos.context.before}`); + console.log(` Contexte aprĂšs: ${pos.context.after}...`); + console.log(''); + }); + + // 5. Format pour l'API + console.log('═══════════════════════════════════════════════════════'); + console.log(' đŸ’» Format pour create-real-signature.js'); + console.log('═══════════════════════════════════════════════════════\n'); + + const positionsObject = {}; + positions.forEach(pos => { + positionsObject[pos.role] = { + page: pos.page, + x: pos.x, + y: pos.y, + width: pos.width, + height: pos.height, + }; + }); + + console.log('```javascript'); + console.log('const positions = ' + JSON.stringify(positionsObject, null, 2) + ';'); + console.log('```\n'); + + // 6. Sauvegarder comme template si demandĂ© + if (saveTemplate) { + const template = createTemplateMapping(positions, filename); + const templateDir = path.join(__dirname, 'signature-templates'); + + if (!fs.existsSync(templateDir)) { + fs.mkdirSync(templateDir); + } + + const templateFile = path.join(templateDir, `${template.templateName}.json`); + fs.writeFileSync(templateFile, JSON.stringify(template, null, 2)); + + console.log(`đŸ’Ÿ Template sauvegardĂ©: ${templateFile}\n`); + } + + // 7. Avertissement sur les estimations + console.log('⚠ IMPORTANT:'); + console.log(' Les positions sont ESTIMÉES car pdf-lib ne peut pas extraire'); + console.log(' les coordonnĂ©es exactes du texte.'); + console.log(''); + console.log(' Pour des positions PRÉCISES, utilisez une de ces mĂ©thodes:'); + console.log(' 1. CrĂ©er un template manuel basĂ© sur vos vrais documents'); + console.log(' 2. Utiliser pdf.js (plus complexe mais prĂ©cis)'); + console.log(' 3. Ajuster manuellement les coordonnĂ©es aprĂšs tests'); + console.log(''); + console.log('💡 Conseil: Testez avec create-real-signature.js et ajustez'); + console.log(' les positions si nĂ©cessaire.\n'); +} + +main().catch(error => { + console.error('❌ Erreur:', error.message); + process.exit(1); +}); diff --git a/lambda-odentas-pades-sign/Dockerfile b/lambda-odentas-pades-sign/Dockerfile new file mode 100644 index 0000000..8593b3e --- /dev/null +++ b/lambda-odentas-pades-sign/Dockerfile @@ -0,0 +1,13 @@ +FROM public.ecr.aws/lambda/nodejs:18 + +# pkijs nĂ©cessite des dĂ©pendances build (si tu ajoutes d'autres libs native) +RUN yum -y install openssl && yum clean all + +WORKDIR /var/task +COPY package.json package-lock.json* ./ +RUN npm install --omit=dev + +COPY . . + +ENV AWS_REGION=eu-west-3 +CMD ["index.handler"] \ No newline at end of file diff --git a/lambda-odentas-pades-sign/certs/ca.crt b/lambda-odentas-pades-sign/certs/ca.crt new file mode 100644 index 0000000..c60b373 --- /dev/null +++ b/lambda-odentas-pades-sign/certs/ca.crt @@ -0,0 +1,31 @@ +-----BEGIN CERTIFICATE----- +MIIFYzCCA0ugAwIBAgIUD+kDTGMWduItjjCmHPqOLLqLTfQwDQYJKoZIhvcNAQEL +BQAwOTELMAkGA1UEBhMCRlIxEDAOBgNVBAoMB09kZW50YXMxGDAWBgNVBAMMD09k +ZW50YXMgUm9vdCBDQTAeFw0yNTEwMjYxOTIyMjRaFw0zNTEwMjQxOTIyMjRaMDkx +CzAJBgNVBAYTAkZSMRAwDgYDVQQKDAdPZGVudGFzMRgwFgYDVQQDDA9PZGVudGFz +IFJvb3QgQ0EwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDI8EHttkpz +bo7IlL0Lz1MrsRAkU1/xnU0yxx4SNB2DO2VAKBlyV+JB0iItsNKVQUhsDp24UyUX +AFqe5gapwU9MTrv9xFWrcHMxYU0A8ENR9wRHf2Liq9m42DAPtjUZqWs2smFvGzV2 +apX1VrjLyQonw4L5QiAc+lTBHzZbpKOBnzKhkuZahjQaJPRsR5OjoT+IkUxNl53Z +cJGl1Q/JRusiD0jtavYGRs96EcxDmEw+R8NXdsIBAmlMma9bfQ3xfHoShiD//WY4 +UmDcbFVNDZaeflMZVMlm0wt8b7eXxMFX9yKUa7sW/pLIgMwaPo/iQ3NizjVRsKSB +YB3/IkC2kWMiedAZv8IE3Q82QcPGLXT5ku25PG5DcTF1PInAVeVWqRXUqCWBiUY6 +AGQSXzVNpyW3e9MY2L36WxvCE6WpCY6bnnq2Y3Za2tn/s1ShlufO1ObaKPEV13Ca +JBlmFGMgBPiMDSl8rQpFWZdfqK0m94f//CJHeKFxVG49sCsq1uC5Ls6H09c4J5aD +g5odxCH1NzEXbsv7hv6grPvwc+FgGlviTrjo7BO7aEUDGQYfL505908izq18CbCh +t7r3UynsDMChibIk1D2aakT9epWjp7D29yoH/gdFqZt/VoUUr0DP0p4XyeEM26Yf +12zo69rrgp5UgYYEvn7qdalO9tdNqnpDbQIDAQABo2MwYTAPBgNVHRMBAf8EBTAD +AQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUneD9sxs//bg80vYmPoLSRtT4 +IcQwHwYDVR0jBBgwFoAUneD9sxs//bg80vYmPoLSRtT4IcQwDQYJKoZIhvcNAQEL +BQADggIBAA2gZQh5AWyxPMznh0Rira9HE+vHBK4U69J4N/EiKAh2IYfAZbjObz+t +dqfbzGCAiuXPe/1dIWEQr3HtOJ64growGS92kPZTAq+E+rgDS77wKI1sksoxtzRo +OM9yQMnhXczUQ6dZz3m9Ehn0djoqfNElYwfzKFjahCh2EFBNGtJwpLdHETER2hq0 +H7ML9r1XZTz/+1N3bU16Q4054DvutrgPQe3+e+BtJr3B6wvtVI7TYy+/FLGyRaq8 +j+6XOidMFyPTDNI4TaG1/N4mRvwypJoY3l04yrueQniCm74I5yrImhwtQec3QXgb +XxP+wiWmyr00VpN9HPPyAzxs3fhrnyIS9mhg2X4kM8aCkxq/+MlQi0K1oUJufzm/ +DFRbYdPA9LP4yeLF++8PeCvOQjrYDihf3/pbua27fGNQy6Trl/JK7q5LZ6VoOghm +YI4g7pyNstB7MExwn9L+RHSRnf8yhJagLWeQRh08w4tb74cwqed4KsvIpk+6jxfA +qojkbH7JoY9ZKckcCyCLhN1FST6Roswf8+Cle/C30idVfbwLe/8SDSlQdkhMk6Ip +sU+5thjxbKk2caprFnDCVu14L0FqCLbn2Prn3szEcKXjBvh8a1Gu6Yhzpz0hocES +26cT8Qa26VB31LARk9OmVol9yQBQB55tFEOWweRGNR8zYohL8Frg +-----END CERTIFICATE----- diff --git a/lambda-odentas-pades-sign/certs/ca.key b/lambda-odentas-pades-sign/certs/ca.key new file mode 100644 index 0000000..9517eec --- /dev/null +++ b/lambda-odentas-pades-sign/certs/ca.key @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +MIIJQwIBADANBgkqhkiG9w0BAQEFAASCCS0wggkpAgEAAoICAQDI8EHttkpzbo7I +lL0Lz1MrsRAkU1/xnU0yxx4SNB2DO2VAKBlyV+JB0iItsNKVQUhsDp24UyUXAFqe +5gapwU9MTrv9xFWrcHMxYU0A8ENR9wRHf2Liq9m42DAPtjUZqWs2smFvGzV2apX1 +VrjLyQonw4L5QiAc+lTBHzZbpKOBnzKhkuZahjQaJPRsR5OjoT+IkUxNl53ZcJGl +1Q/JRusiD0jtavYGRs96EcxDmEw+R8NXdsIBAmlMma9bfQ3xfHoShiD//WY4UmDc +bFVNDZaeflMZVMlm0wt8b7eXxMFX9yKUa7sW/pLIgMwaPo/iQ3NizjVRsKSBYB3/ +IkC2kWMiedAZv8IE3Q82QcPGLXT5ku25PG5DcTF1PInAVeVWqRXUqCWBiUY6AGQS +XzVNpyW3e9MY2L36WxvCE6WpCY6bnnq2Y3Za2tn/s1ShlufO1ObaKPEV13CaJBlm +FGMgBPiMDSl8rQpFWZdfqK0m94f//CJHeKFxVG49sCsq1uC5Ls6H09c4J5aDg5od +xCH1NzEXbsv7hv6grPvwc+FgGlviTrjo7BO7aEUDGQYfL505908izq18CbCht7r3 +UynsDMChibIk1D2aakT9epWjp7D29yoH/gdFqZt/VoUUr0DP0p4XyeEM26Yf12zo +69rrgp5UgYYEvn7qdalO9tdNqnpDbQIDAQABAoICABgPVqGXSHpiZFPlpW4+1LNg +V/G1jwEwB+Ca4YGl/luqlsDUHFnp5oRhnCiS6eAnyTtxE5cQ05nZt8AbhHQ6mptl +ORLVebmVtSFWSIFig3kSxg8mlGlWUPNWZYjYFSsds2IBAXQrkp77e7m8+NMj3ySs +PyhH7+7wVYorSde20sIwVQBuRclUlPQFdUPq3SWEZwZb+YI385Vn6nSsGqAL7xcs +s9uDyXIuc+TmsSnt2EFD2ALGzEuRJZkftNTUWEFQWZCSwWxNg/od+Q2jpiIcDKst +LpeTpKr8fb1YZigUzd7Rz3DspmIGOP6rl2TUFWfvEiekT9OYflYkc2sLh6qkpXUy +sCwYXZ3jRj816EMNbC1ZarPFzwN9b6icN7EqlPEBPR8z6DGY7FQuNw0xqwvpZO9L +boEKJXV7LZITh/bGmgK5KALc5j6Hh8akeftpNegBgn1DDJ+/MDDMnoA7Xw2ev12S +Ww8M/xJWCFfV0t8q9cWHfcR6FkdCdJ38CbBuNV010DAlFUU4Ig+nn0Q7vAHaiRLA +KkTOjcoKa2R/EeEhSEykC4e59EO2EqyY5kXCJ/m8/sKCtnR7tY+dSwazb5HiU3OW +iPy0ZKS9HJ3ttN59QhjxJ79ickl1PINtyOLreDyEipQ8JPhtOjPueWcpPWdtDQqq +AKVf/IgRFlJFSMQLfAxhAoIBAQDwEsuflxkAjmyT01O5ITH8F+XsYXZT9iLntDSM +3+fTspCzr833FUzM+XxDWvFKDutf+joNgQg6OES6fuE3uxO7hdaridv8l5Oo2fW7 +nnO9RlHEpzmdK1e+oWmNykkPuue47LthY6fD0rUZE1nopuA7+1eM65vutdzAN0J2 +RUiD2JhZrKi49ikNUd45mtG9rkxCnxtkEdwKOGy55/d21ygWPyqaTKPlVntK1cf8 +aB6ZccGLEyTp8wERZwG0payu9qowpeNndZwMiGYgeGfoNqMxe8oVkdeS1Ng1lTPr +BPtSQXSkEHCiOh9ZjMt+27Buh2LFJFx9Q/BYvf+h5Ux++BShAoIBAQDWRNPnGnJY +OJPYvcsPcQ7XuonSNLz72ok/ducUYn69TtRA0Sjazc6CacxYaNazj6BXY/eTASCp +GpTXcQlbXPwkFJtxEDpmbPTm7KBy15e9sa791PZ4gDFqCMWNXXl1h+GWbt3rmM4k +DBDWH+4o104M2usx9qkH3kF72bI+NopHdtRyze0Fr4nASBsqpC+3CkJPzpimLhFh +63S0cMkj4COnm6d74tDfM4GMLj9jPK3JjZFeD6mEEizu0+gZJ014k+Nul8SINsCP +2aP9sSW5LcVly7cex0SG0fvhoVLUOurEAJUxswCuF+tc02EjWUO5mlm2NKRDKH6Q +8jbaYUMqgq9NAoIBAG29ycLsCQvODygL5CuuVvEL45iIfNSooRAVuifjek+1QpxA +8v/Nhsv0B6+qdL4Lz+CRu9PMdfxXGGO/+CfHpp+D8Lt9S0hN6bu31rKrHfEZoesp +HhcQZw3HLz8Xhwpfv+kynf1I1rmXKh2KUQEet0X0APN0CFW4YG6lVAZIaXuLmSj0 +2jOiouVp1RG0CciRUl7p6FV4LIOng1wAA4kZlUOGIG1kN09wlu+AheiFl+dArt5q +I+elo4lW8tqnGSbprqaKTkdO5cR83WJeZ/EoeLT4B0qQ3mBKV4M27wKxxki1jyIo +ObSOqSIAUE1vE1mTHb1Mf9LrKYUHZUGWVb98m8ECggEBAJKsXtnuMqPNZveRRDV3 +0Q8/tCRe0Zcdy6HUBO7hVZzFH2qXQv40r856GdXTPlWAVeQ8nDbXKMJZXdX0nNEb +38XbDWTiFPgUUbjZ9iNnDYpe3UkIxQdLyYeqJYFVoVUhBP1zOxqRaVsnn0bUaLUw +XU9TdHipPqASNoVPSkJoF1RANcG40S15MjSbp20TI06LCUWUSM5I7sE26payNh4G +yqFrXcTiSNThbybEacPGv7ge3omi8wljX7rE+DZvvwpxQdhsr64GvN47v5Rj9UzD +1kKOpvW8tl4L0/5p7kc1ZYtyUq7IuAh0ezr5v57w9flRfWPKuT73s1wJUh8/QTkS +aGkCggEBAMsh2p30A2PJbbMUGC1cHN5SGhEG9SjGul+0WXQRr6kuuQBV+3qbnb3l +fWswlds0ToMdATYqgVlKF3XiC3gL3DjJsqC8liCAbF6pNkb26ACJ56UtmVlRfriB +TB6wuIjLNgvyCWbR+YFhDRB4LDE13txXe2cNhd44OWeVEZuKykbw0C8JM/jdrmyd +nR6ndzOZZ7nDDkA8fbHAppTVaZIlVmKdNTnNnck1tA2e9UhQ2igWDtVWfzdTVova +MG66GBvaItOj74QYFr137RozLf4EGTAGuEVkjhrTihZJlRkQ/In8JHc/f9nCD2Pg +XKkTYW+gsnPrmEOhe3ccWOGucTprUCc= +-----END PRIVATE KEY----- diff --git a/lambda-odentas-pades-sign/certs/ca.srl b/lambda-odentas-pades-sign/certs/ca.srl new file mode 100644 index 0000000..9cdf338 --- /dev/null +++ b/lambda-odentas-pades-sign/certs/ca.srl @@ -0,0 +1 @@ +6DA9E183C3764EAD480BC6B043B14DAE8F9200EC diff --git a/lambda-odentas-pades-sign/certs/chain-full.pem b/lambda-odentas-pades-sign/certs/chain-full.pem new file mode 100644 index 0000000..fd6ef15 --- /dev/null +++ b/lambda-odentas-pades-sign/certs/chain-full.pem @@ -0,0 +1,58 @@ +-----BEGIN CERTIFICATE----- +MIIEfTCCAmWgAwIBAgIUbanhg8N2Tq1IC8awQ7FNro+SAOswDQYJKoZIhvcNAQEL +BQAwOTELMAkGA1UEBhMCRlIxEDAOBgNVBAoMB09kZW50YXMxGDAWBgNVBAMMD09k +ZW50YXMgUm9vdCBDQTAeFw0yNTEwMjYxOTIzMjFaFw0zMDEwMjUxOTIzMjFaMEEx +CzAJBgNVBAYTAkZSMRAwDgYDVQQKDAdPZGVudGFzMSAwHgYDVQQDDBdPZGVudGFz +IERvY3VtZW50IFNpZ25lcjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB +AOBDJtTWO8bPaGA58//rB6rDn1oowjdmQF8GvmiyEoKqnInyJN4xEFkEFFkFndC8 +W9/+w7RnkVH2aG3+VpL6Y7oQ/Sm3tBMqjrs1ZW6243pGoflMCimS9AmswBamfuMD +bi8XL0nscx74BeZatFvPuG5ycY6xeeYqzpTcc2u8hQnTvToMjuvPR/b0B+jGBphq +X6NTRzKMouX1pd7h93gTpoyNjH1pK5w6kDdpbBdovGKnmve6W4tO1pWyqYEoESp6 +EAKUMTR5if6S/W0zrPe4aJPwK2sBxOWWf+AJQt9j2X1AmkKBRKgYuyp8KBFuSJlU +MFv+2auvNe8QTfAyZK+5YpkCAwEAAaN1MHMwDAYDVR0TAQH/BAIwADAOBgNVHQ8B +Af8EBAMCBsAwEwYDVR0lBAwwCgYIKwYBBQUHAwQwHQYDVR0OBBYEFOA97FgV2K6E +9deUzVmkBuTY6wvTMB8GA1UdIwQYMBaAFJ3g/bMbP/24PNL2Jj6C0kbU+CHEMA0G +CSqGSIb3DQEBCwUAA4ICAQBSw2Rtn8aMlGxSsRR83VhSNiFwKlN8BLxyev5hf+Qc +54NvLyv0a1joGUBmV2VJvXOcN9CsM0d5/DYDvkcJXE831qtALMHT1rEkq8axETU7 +RbHM6WcO+Igu+CH/jCY/piRmCvgbIqwo1Ly5Ee1iTtla75ojpJul4DkG0QtCCxen +H/PQqZr+Xsr2JoRsZmfL4/OjE+4gEjlKdwo+ZeM/D5c+XZmg3bhROqju3CC1TrAE +DP74wCMAh2qzF6OLGaeGHsrUhIqJDesUkPPNe9M51Wp1QyhOdbmkP3IFBL1lXkRL +tQrarpnon8cEQ/rsC5D/KYdVw/ZL2GjBZc8GxEhIckUzQijkPPFbRVfVASDGE6+z +QNOKswpFflXoTEQ5eyS0PCq5qmIPAasmlsiu82aa8Tg6OWqEdYzIwGtZsMC21tEO +mhNDspAkAupM9vDg+r3iNFIQwkbGm/4V+Ih9sEIgSPCxjyYuSPhnYQMlr5xi4L/0 +xQVJXcYTkaeiPLtkzwn6YRMw2ESKVuS0iz2TXnjgdHLj5xQ9rl4s5dvwRy6jYfry +yZ3HMBSorwaNKweGz0ooU6wKbdCqBOlvVeu2q8T3cpA3zmHhbvF1N6AGQBahscB+ +ejMd4KvH2gzMmCpM8fDExd65VPoKExl2ANozpQM2LdjqHUwdtawnxXxAVKh1nxja +PA== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIFYzCCA0ugAwIBAgIUD+kDTGMWduItjjCmHPqOLLqLTfQwDQYJKoZIhvcNAQEL +BQAwOTELMAkGA1UEBhMCRlIxEDAOBgNVBAoMB09kZW50YXMxGDAWBgNVBAMMD09k +ZW50YXMgUm9vdCBDQTAeFw0yNTEwMjYxOTIyMjRaFw0zNTEwMjQxOTIyMjRaMDkx +CzAJBgNVBAYTAkZSMRAwDgYDVQQKDAdPZGVudGFzMRgwFgYDVQQDDA9PZGVudGFz +IFJvb3QgQ0EwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDI8EHttkpz +bo7IlL0Lz1MrsRAkU1/xnU0yxx4SNB2DO2VAKBlyV+JB0iItsNKVQUhsDp24UyUX +AFqe5gapwU9MTrv9xFWrcHMxYU0A8ENR9wRHf2Liq9m42DAPtjUZqWs2smFvGzV2 +apX1VrjLyQonw4L5QiAc+lTBHzZbpKOBnzKhkuZahjQaJPRsR5OjoT+IkUxNl53Z +cJGl1Q/JRusiD0jtavYGRs96EcxDmEw+R8NXdsIBAmlMma9bfQ3xfHoShiD//WY4 +UmDcbFVNDZaeflMZVMlm0wt8b7eXxMFX9yKUa7sW/pLIgMwaPo/iQ3NizjVRsKSB +YB3/IkC2kWMiedAZv8IE3Q82QcPGLXT5ku25PG5DcTF1PInAVeVWqRXUqCWBiUY6 +AGQSXzVNpyW3e9MY2L36WxvCE6WpCY6bnnq2Y3Za2tn/s1ShlufO1ObaKPEV13Ca +JBlmFGMgBPiMDSl8rQpFWZdfqK0m94f//CJHeKFxVG49sCsq1uC5Ls6H09c4J5aD +g5odxCH1NzEXbsv7hv6grPvwc+FgGlviTrjo7BO7aEUDGQYfL505908izq18CbCh +t7r3UynsDMChibIk1D2aakT9epWjp7D29yoH/gdFqZt/VoUUr0DP0p4XyeEM26Yf +12zo69rrgp5UgYYEvn7qdalO9tdNqnpDbQIDAQABo2MwYTAPBgNVHRMBAf8EBTAD +AQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUneD9sxs//bg80vYmPoLSRtT4 +IcQwHwYDVR0jBBgwFoAUneD9sxs//bg80vYmPoLSRtT4IcQwDQYJKoZIhvcNAQEL +BQADggIBAA2gZQh5AWyxPMznh0Rira9HE+vHBK4U69J4N/EiKAh2IYfAZbjObz+t +dqfbzGCAiuXPe/1dIWEQr3HtOJ64growGS92kPZTAq+E+rgDS77wKI1sksoxtzRo +OM9yQMnhXczUQ6dZz3m9Ehn0djoqfNElYwfzKFjahCh2EFBNGtJwpLdHETER2hq0 +H7ML9r1XZTz/+1N3bU16Q4054DvutrgPQe3+e+BtJr3B6wvtVI7TYy+/FLGyRaq8 +j+6XOidMFyPTDNI4TaG1/N4mRvwypJoY3l04yrueQniCm74I5yrImhwtQec3QXgb +XxP+wiWmyr00VpN9HPPyAzxs3fhrnyIS9mhg2X4kM8aCkxq/+MlQi0K1oUJufzm/ +DFRbYdPA9LP4yeLF++8PeCvOQjrYDihf3/pbua27fGNQy6Trl/JK7q5LZ6VoOghm +YI4g7pyNstB7MExwn9L+RHSRnf8yhJagLWeQRh08w4tb74cwqed4KsvIpk+6jxfA +qojkbH7JoY9ZKckcCyCLhN1FST6Roswf8+Cle/C30idVfbwLe/8SDSlQdkhMk6Ip +sU+5thjxbKk2caprFnDCVu14L0FqCLbn2Prn3szEcKXjBvh8a1Gu6Yhzpz0hocES +26cT8Qa26VB31LARk9OmVol9yQBQB55tFEOWweRGNR8zYohL8Frg +-----END CERTIFICATE----- diff --git a/lambda-odentas-pades-sign/certs/chain.pem b/lambda-odentas-pades-sign/certs/chain.pem new file mode 100644 index 0000000..eb2bc4e --- /dev/null +++ b/lambda-odentas-pades-sign/certs/chain.pem @@ -0,0 +1,27 @@ +-----BEGIN CERTIFICATE----- +MIIEfTCCAmWgAwIBAgIUbanhg8N2Tq1IC8awQ7FNro+SAOswDQYJKoZIhvcNAQEL +BQAwOTELMAkGA1UEBhMCRlIxEDAOBgNVBAoMB09kZW50YXMxGDAWBgNVBAMMD09k +ZW50YXMgUm9vdCBDQTAeFw0yNTEwMjYxOTIzMjFaFw0zMDEwMjUxOTIzMjFaMEEx +CzAJBgNVBAYTAkZSMRAwDgYDVQQKDAdPZGVudGFzMSAwHgYDVQQDDBdPZGVudGFz +IERvY3VtZW50IFNpZ25lcjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB +AOBDJtTWO8bPaGA58//rB6rDn1oowjdmQF8GvmiyEoKqnInyJN4xEFkEFFkFndC8 +W9/+w7RnkVH2aG3+VpL6Y7oQ/Sm3tBMqjrs1ZW6243pGoflMCimS9AmswBamfuMD +bi8XL0nscx74BeZatFvPuG5ycY6xeeYqzpTcc2u8hQnTvToMjuvPR/b0B+jGBphq +X6NTRzKMouX1pd7h93gTpoyNjH1pK5w6kDdpbBdovGKnmve6W4tO1pWyqYEoESp6 +EAKUMTR5if6S/W0zrPe4aJPwK2sBxOWWf+AJQt9j2X1AmkKBRKgYuyp8KBFuSJlU +MFv+2auvNe8QTfAyZK+5YpkCAwEAAaN1MHMwDAYDVR0TAQH/BAIwADAOBgNVHQ8B +Af8EBAMCBsAwEwYDVR0lBAwwCgYIKwYBBQUHAwQwHQYDVR0OBBYEFOA97FgV2K6E +9deUzVmkBuTY6wvTMB8GA1UdIwQYMBaAFJ3g/bMbP/24PNL2Jj6C0kbU+CHEMA0G +CSqGSIb3DQEBCwUAA4ICAQBSw2Rtn8aMlGxSsRR83VhSNiFwKlN8BLxyev5hf+Qc +54NvLyv0a1joGUBmV2VJvXOcN9CsM0d5/DYDvkcJXE831qtALMHT1rEkq8axETU7 +RbHM6WcO+Igu+CH/jCY/piRmCvgbIqwo1Ly5Ee1iTtla75ojpJul4DkG0QtCCxen +H/PQqZr+Xsr2JoRsZmfL4/OjE+4gEjlKdwo+ZeM/D5c+XZmg3bhROqju3CC1TrAE +DP74wCMAh2qzF6OLGaeGHsrUhIqJDesUkPPNe9M51Wp1QyhOdbmkP3IFBL1lXkRL +tQrarpnon8cEQ/rsC5D/KYdVw/ZL2GjBZc8GxEhIckUzQijkPPFbRVfVASDGE6+z +QNOKswpFflXoTEQ5eyS0PCq5qmIPAasmlsiu82aa8Tg6OWqEdYzIwGtZsMC21tEO +mhNDspAkAupM9vDg+r3iNFIQwkbGm/4V+Ih9sEIgSPCxjyYuSPhnYQMlr5xi4L/0 +xQVJXcYTkaeiPLtkzwn6YRMw2ESKVuS0iz2TXnjgdHLj5xQ9rl4s5dvwRy6jYfry +yZ3HMBSorwaNKweGz0ooU6wKbdCqBOlvVeu2q8T3cpA3zmHhbvF1N6AGQBahscB+ +ejMd4KvH2gzMmCpM8fDExd65VPoKExl2ANozpQM2LdjqHUwdtawnxXxAVKh1nxja +PA== +-----END CERTIFICATE----- diff --git a/lambda-odentas-pades-sign/certs/signer-new.crt b/lambda-odentas-pades-sign/certs/signer-new.crt new file mode 100644 index 0000000..20202ef --- /dev/null +++ b/lambda-odentas-pades-sign/certs/signer-new.crt @@ -0,0 +1,26 @@ +-----BEGIN CERTIFICATE----- +MIIEdzCCAl+gAwIBAgIUbanhg8N2Tq1IC8awQ7FNro+SAOwwDQYJKoZIhvcNAQEL +BQAwOTELMAkGA1UEBhMCRlIxEDAOBgNVBAoMB09kZW50YXMxGDAWBgNVBAMMD09k +ZW50YXMgUm9vdCBDQTAeFw0yNTEwMjcxMjEzNDNaFw0zMDEwMjYxMjEzNDNaMDsx +CzAJBgNVBAYTAkZSMRAwDgYDVQQKDAdPZGVudGFzMRowGAYDVQQDDBFPZGVudGFz +IE1lZGlhIFNBUzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAK2rPGJW +Rj92nZEwDlARDdUGmv/nWupPbgXvOHomT06SxoEUKRSMpwRSIQBXtFZ/cjeplu27 +9sXyeaWCoOpuqIbKxxs2FOFBVeoCDYg0cNKCQAqeYVCRegkCixRTuRJX1F9nWV6u +kNbTyx0tPVG5p/I3UIMe93EIQZv8M2Xf9EQUQQEs6Loi6D5XZgdHcTjmchnRJvck +RrQpuso6prWtoWiwfpn77BHO7pc63Mp1lE4SX2wISvSZg1LAOUcgKOSOX1IV2yBL +q2iq2TOiaD2Rq/BMleVkgCSegvo92mdO2A3m216uqi2bE1sdrpc8i4o8L60orIaR +eT0XMALwspZT8SUCAwEAAaN1MHMwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMC +BsAwEwYDVR0lBAwwCgYIKwYBBQUHAwQwHQYDVR0OBBYEFHKZ92Acs+85QuQ+m1jh +ismUuvuDMB8GA1UdIwQYMBaAFJ3g/bMbP/24PNL2Jj6C0kbU+CHEMA0GCSqGSIb3 +DQEBCwUAA4ICAQBIyorVUBP/KG7aF6m9vpi241bDDOja6AFHIZN4iESsygOXua82 +fSOIrzKzHUjwDOOEzLSQS7Afl2/pn9+KthYsJZVrwD+Cp+y63OWOZlyTjga8r2IB +qjZNn66pdc2lebwDoOhAUztso6vjDyDxQM0WJX+Gz+7pYaMbDd4E6KjNgZBawJso +CcEdqZco8a6VH6mvWyWWPVir/z0Lgl6DSCdERDei9ylmq/uy/MR5CGk3mwPRjgiA +xYlF2xon9NucA0OAKTsMGgJJSrN9EfsWrNn2EwqEDqeYZKUg8lrLvuMLQ0pLjL7/ +XxYt0T1mQyM/E1kmfaRRTnONXnPeHaZzfgS0hxrMRj8YpwMViYn4ptPFGzAuBfdZ +b7lmlVguSB0bsJyLGJ9pWiyeRfu+UjAwHiYhLjVyg+UlHU5MTDFrbMxZqs8yMnCr +PWoiL8ufdfr3EaGNoGsN0wDLVF5O6fu5iNNtgZ+M0e9xnFQfpJdFLn0FC3r4gUQf +OsftkijjoMYkEzgaMQPqVXZcH00dJDiwvVPqjNx6axpkDcpNVsD0y5ECJlceBqQo +mKujFCWoqJY6CyUMzdY88kSWruAbkkgRaxuztcSxAaPlnuOjjmX0PbqRy+YGctFq +0OpTAjDXmV6Wrb0bSsMYBfZMKqpkW5MxVJxao87dsPOYigLBectGecxAsw== +-----END CERTIFICATE----- diff --git a/lambda-odentas-pades-sign/certs/signer-new.csr b/lambda-odentas-pades-sign/certs/signer-new.csr new file mode 100644 index 0000000..8dd5358 --- /dev/null +++ b/lambda-odentas-pades-sign/certs/signer-new.csr @@ -0,0 +1,16 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIICgDCCAWgCAQAwOzELMAkGA1UEBhMCRlIxEDAOBgNVBAoMB09kZW50YXMxGjAY +BgNVBAMMEU9kZW50YXMgTWVkaWEgU0FTMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEAras8YlZGP3adkTAOUBEN1Qaa/+da6k9uBe84eiZPTpLGgRQpFIyn +BFIhAFe0Vn9yN6mW7bv2xfJ5pYKg6m6ohsrHGzYU4UFV6gINiDRw0oJACp5hUJF6 +CQKLFFO5ElfUX2dZXq6Q1tPLHS09Ubmn8jdQgx73cQhBm/wzZd/0RBRBASzouiLo +PldmB0dxOOZyGdEm9yRGtCm6yjqmta2haLB+mfvsEc7ulzrcynWUThJfbAhK9JmD +UsA5RyAo5I5fUhXbIEuraKrZM6JoPZGr8EyV5WSAJJ6C+j3aZ07YDebbXq6qLZsT +Wx2ulzyLijwvrSishpF5PRcwAvCyllPxJQIDAQABoAAwDQYJKoZIhvcNAQELBQAD +ggEBAFUv7F8uDi0TZNLwSc5+JpztgWBL9vshmv3HYouYn18xgk3QEAUgCe89B7sW +KbEjKhXXb2IvnD7iEbtqUhmhCUqP6ILT1c4Qp4rMdf21DFfPshJHdvL2AGxtAMXu +KedpJuH4azy579oial8rLa8iDO1LE/xKTJ8nKvObrAf6/LZ+FYJwWP1sIUnpk4Tu +HieSSNxySRaE/p0f5yRfJmz84xJWSjD9sy+Vn+7YMf82bgCncE9ru/XahT0xctTM +O8AjG+6Elp4yYWXfVwsUv270nzPUVA3pS40kLF0yYZLk17vf7nJemyd5+Eqw3ekA +wi/euq8S21CMpY6S0zThGu6/kzE= +-----END CERTIFICATE REQUEST----- diff --git a/lambda-odentas-pades-sign/certs/signer.crt b/lambda-odentas-pades-sign/certs/signer.crt new file mode 100644 index 0000000..eb2bc4e --- /dev/null +++ b/lambda-odentas-pades-sign/certs/signer.crt @@ -0,0 +1,27 @@ +-----BEGIN CERTIFICATE----- +MIIEfTCCAmWgAwIBAgIUbanhg8N2Tq1IC8awQ7FNro+SAOswDQYJKoZIhvcNAQEL +BQAwOTELMAkGA1UEBhMCRlIxEDAOBgNVBAoMB09kZW50YXMxGDAWBgNVBAMMD09k +ZW50YXMgUm9vdCBDQTAeFw0yNTEwMjYxOTIzMjFaFw0zMDEwMjUxOTIzMjFaMEEx +CzAJBgNVBAYTAkZSMRAwDgYDVQQKDAdPZGVudGFzMSAwHgYDVQQDDBdPZGVudGFz +IERvY3VtZW50IFNpZ25lcjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB +AOBDJtTWO8bPaGA58//rB6rDn1oowjdmQF8GvmiyEoKqnInyJN4xEFkEFFkFndC8 +W9/+w7RnkVH2aG3+VpL6Y7oQ/Sm3tBMqjrs1ZW6243pGoflMCimS9AmswBamfuMD +bi8XL0nscx74BeZatFvPuG5ycY6xeeYqzpTcc2u8hQnTvToMjuvPR/b0B+jGBphq +X6NTRzKMouX1pd7h93gTpoyNjH1pK5w6kDdpbBdovGKnmve6W4tO1pWyqYEoESp6 +EAKUMTR5if6S/W0zrPe4aJPwK2sBxOWWf+AJQt9j2X1AmkKBRKgYuyp8KBFuSJlU +MFv+2auvNe8QTfAyZK+5YpkCAwEAAaN1MHMwDAYDVR0TAQH/BAIwADAOBgNVHQ8B +Af8EBAMCBsAwEwYDVR0lBAwwCgYIKwYBBQUHAwQwHQYDVR0OBBYEFOA97FgV2K6E +9deUzVmkBuTY6wvTMB8GA1UdIwQYMBaAFJ3g/bMbP/24PNL2Jj6C0kbU+CHEMA0G +CSqGSIb3DQEBCwUAA4ICAQBSw2Rtn8aMlGxSsRR83VhSNiFwKlN8BLxyev5hf+Qc +54NvLyv0a1joGUBmV2VJvXOcN9CsM0d5/DYDvkcJXE831qtALMHT1rEkq8axETU7 +RbHM6WcO+Igu+CH/jCY/piRmCvgbIqwo1Ly5Ee1iTtla75ojpJul4DkG0QtCCxen +H/PQqZr+Xsr2JoRsZmfL4/OjE+4gEjlKdwo+ZeM/D5c+XZmg3bhROqju3CC1TrAE +DP74wCMAh2qzF6OLGaeGHsrUhIqJDesUkPPNe9M51Wp1QyhOdbmkP3IFBL1lXkRL +tQrarpnon8cEQ/rsC5D/KYdVw/ZL2GjBZc8GxEhIckUzQijkPPFbRVfVASDGE6+z +QNOKswpFflXoTEQ5eyS0PCq5qmIPAasmlsiu82aa8Tg6OWqEdYzIwGtZsMC21tEO +mhNDspAkAupM9vDg+r3iNFIQwkbGm/4V+Ih9sEIgSPCxjyYuSPhnYQMlr5xi4L/0 +xQVJXcYTkaeiPLtkzwn6YRMw2ESKVuS0iz2TXnjgdHLj5xQ9rl4s5dvwRy6jYfry +yZ3HMBSorwaNKweGz0ooU6wKbdCqBOlvVeu2q8T3cpA3zmHhbvF1N6AGQBahscB+ +ejMd4KvH2gzMmCpM8fDExd65VPoKExl2ANozpQM2LdjqHUwdtawnxXxAVKh1nxja +PA== +-----END CERTIFICATE----- diff --git a/lambda-odentas-pades-sign/certs/signer.csr b/lambda-odentas-pades-sign/certs/signer.csr new file mode 100644 index 0000000..c86338f --- /dev/null +++ b/lambda-odentas-pades-sign/certs/signer.csr @@ -0,0 +1,16 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIIChjCCAW4CAQAwQTELMAkGA1UEBhMCRlIxEDAOBgNVBAoMB09kZW50YXMxIDAe +BgNVBAMMF09kZW50YXMgRG9jdW1lbnQgU2lnbmVyMIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAras8YlZGP3adkTAOUBEN1Qaa/+da6k9uBe84eiZPTpLG +gRQpFIynBFIhAFe0Vn9yN6mW7bv2xfJ5pYKg6m6ohsrHGzYU4UFV6gINiDRw0oJA +Cp5hUJF6CQKLFFO5ElfUX2dZXq6Q1tPLHS09Ubmn8jdQgx73cQhBm/wzZd/0RBRB +ASzouiLoPldmB0dxOOZyGdEm9yRGtCm6yjqmta2haLB+mfvsEc7ulzrcynWUThJf +bAhK9JmDUsA5RyAo5I5fUhXbIEuraKrZM6JoPZGr8EyV5WSAJJ6C+j3aZ07YDebb +Xq6qLZsTWx2ulzyLijwvrSishpF5PRcwAvCyllPxJQIDAQABoAAwDQYJKoZIhvcN +AQELBQADggEBAI1OTp2gyOHEs5cHsphusRvIRNe0k0YB+7KIbuBjfGq394rhfnnC +siBw9Iy1RWGQX2ogAOAfjFKp/9J2okW9H2nDwiYPLrhLVCd2EnB+K335De1N8a1V +loQ5fBvvNLXKb08jWgUg8bjESX0X4e9V9jhIuWpS4L0hTMm2nmSfSmnFNvzBgice +WJ6gT2536WaiZkQr1P7mz/0R9r5RKp3g2yhOUEi8O4rFEMglH4fP5PcUwsW7PuZ5 +yL1VNq+AN2WkzDiO1Z1GEDYj/GmWGmXY+/L4Gfo33oTPxCOlta/ncBS+s1VSsea3 +BLK6ZGFJdJrI3AMd6eTe36Kcf/az3riYS8I= +-----END CERTIFICATE REQUEST----- diff --git a/lambda-odentas-pades-sign/certs/signer_v3.ext b/lambda-odentas-pades-sign/certs/signer_v3.ext new file mode 100644 index 0000000..7edf78a --- /dev/null +++ b/lambda-odentas-pades-sign/certs/signer_v3.ext @@ -0,0 +1,5 @@ +basicConstraints=critical,CA:FALSE +keyUsage=critical,digitalSignature,nonRepudiation +extendedKeyUsage=emailProtection +subjectKeyIdentifier=hash +authorityKeyIdentifier=keyid,issuer \ No newline at end of file diff --git a/lambda-odentas-pades-sign/certs/tmp.key b/lambda-odentas-pades-sign/certs/tmp.key new file mode 100644 index 0000000..bdaf4ca --- /dev/null +++ b/lambda-odentas-pades-sign/certs/tmp.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCtqzxiVkY/dp2R +MA5QEQ3VBpr/51rqT24F7zh6Jk9OksaBFCkUjKcEUiEAV7RWf3I3qZbtu/bF8nml +gqDqbqiGyscbNhThQVXqAg2INHDSgkAKnmFQkXoJAosUU7kSV9RfZ1lerpDW08sd +LT1RuafyN1CDHvdxCEGb/DNl3/REFEEBLOi6Iug+V2YHR3E45nIZ0Sb3JEa0KbrK +Oqa1raFosH6Z++wRzu6XOtzKdZROEl9sCEr0mYNSwDlHICjkjl9SFdsgS6toqtkz +omg9kavwTJXlZIAknoL6PdpnTtgN5tterqotmxNbHa6XPIuKPC+tKKyGkXk9FzAC +8LKWU/ElAgMBAAECggEABQhzRLkhAP1eF2gDtuEh/9ej/Oh5bNw+jmItmU2Vvs1v +UWivdUU8XS0avHE3qLsFCvYKibdbok4iw6sO5HEYExtCCA1/xeHGYUdmbA365D0D +/Du8sJrwHYOr8VnsvX8dLiahjKZRiH7iWqFn1JZ/o7++KkrfN4OeNfgAqvjM7GDS +SirCRfTsUtSiwnRZTbr6y4O92NAWNvaUuRTJmQjKNsHT09YFuR+Cn/1Y1QY5Cnet +PeQUFsjGyf/d4hfysun1Vzb1hF8pVkknQn7+/fiAaUnJOWI8jhLKgbEKT/gswCSX +J1ptJDGIDap46cFcq6zVLxffeLsRSz0pDoQw1YFpgQKBgQDs25rIKDddMNZ/J2x2 +sbuORR9+jpeIYGA9eFidw6qIYsMhvXe1dJV9bh8KWgKkH5Xj8Dra3lwN5+4rLUOD +ip1HqGATn2X9C/BiiDrBCMSsjUGfXYUuKWYYXiQdb4qwL2RX5/g4aVuEhjJjSP8Z +nvHKGqDJ8vyqAKZmDABeWGumnwKBgQC7tE25FYRakAnKIwUo7ny0x3G6YRcF8KKe +OMBv3gb5Ha0DrYUU27HE9FbkAbPFFWYBE3lRUYZZQtnfaBvPRddei6LPfZpBavs9 +BysRajaCEDmwVNpVXV62gEjVcTcGJT4rFTMMNliMJiYH4ajqdAgPmqp1BDTScg5m +9qztkzvluwKBgQDQlpkZ2ijfnenYMuzXHrCQmxkgSAz04gL/P2OzFCSzVV6I8SVi +HJilzHvzjs8yoEpNYTtDkn05Fd5uEb+aMVFPUN3DiiW0EnaRH222n7UsTH3VDfQC +chziKs3dSmS1wha5P6UecNJSxCfNvfRCKPLATHD7SEzwnf/scHdGntG2+wKBgFaX +GdfNJk6E7g8y0pmMUzkuXLenPHDADrTA531zxnG7j+oHbUIvCzYZpc/vPRgpA/Jx +ImEyI7Ef1tRp+ZJH3M+/yO7BOZz+FkKUAPk6r0SW6ZX/vuzsctnsGi1k2gZavfAw +CmmS1IxquNaf8kWeG23fYs0ykI+YkC5Nk37RkisrAoGBAOqsbP1uOOigJ/E0sDN8 +JLyNMB/0vzqKdmEjIGnbrqy2ndVcO3zJT4rUQfGGROJ4cU0NFAMgbkQiG/JKwp2C +9rr3FnV8yWUGESC6H5VkFsY7CDI/Q0w56oF0F8RiT6+SUppWHCM40yOm4RCVrGnA +txg6jyqAt4Nz9x+pIgWvJGVa +-----END PRIVATE KEY----- diff --git a/lambda-odentas-pades-sign/contrat_cddu_LYXHX3GI_240V001.pdf b/lambda-odentas-pades-sign/contrat_cddu_LYXHX3GI_240V001.pdf new file mode 100644 index 0000000..cfebc0d Binary files /dev/null and b/lambda-odentas-pades-sign/contrat_cddu_LYXHX3GI_240V001.pdf differ diff --git a/lambda-odentas-pades-sign/ecr-read-inline.json b/lambda-odentas-pades-sign/ecr-read-inline.json new file mode 100644 index 0000000..1824e68 --- /dev/null +++ b/lambda-odentas-pades-sign/ecr-read-inline.json @@ -0,0 +1,23 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "ECRReadForLambda", + "Effect": "Allow", + "Action": [ + "ecr:BatchGetImage", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchCheckLayerAvailability", + "ecr:DescribeImages", + "ecr:ListImages", + "ecr:DescribeRepositories", + "ecr:GetRepositoryPolicy", + "ecr:GetLifecyclePolicy", + "ecr:GetLifecyclePolicyPreview", + "ecr:ListTagsForResource", + "ecr:DescribeImageScanFindings" + ], + "Resource": "arn:aws:ecr:eu-west-3:292468105557:repository/odentas/pades-sign" + } + ] +} diff --git a/lambda-odentas-pades-sign/event.json b/lambda-odentas-pades-sign/event.json new file mode 100644 index 0000000..de9d3da --- /dev/null +++ b/lambda-odentas-pades-sign/event.json @@ -0,0 +1,4 @@ +{ + "requestRef": "CDDU-2025-0102", + "sourceKey": "source/contrat_cddu_2025_0102.pdf" +} diff --git a/lambda-odentas-pades-sign/event.sample.json b/lambda-odentas-pades-sign/event.sample.json new file mode 100644 index 0000000..dee57ef --- /dev/null +++ b/lambda-odentas-pades-sign/event.sample.json @@ -0,0 +1,5 @@ +{ + "requestRef": "CDDU-2025-0102", + "sourceKey": "source/contrat_cddu_2025_0102.pdf", + "meta": { "employer": "Acme", "employee": "Jean Dupont" } +} diff --git a/lambda-odentas-pades-sign/helpers/pades.js b/lambda-odentas-pades-sign/helpers/pades.js new file mode 100644 index 0000000..e85aed1 --- /dev/null +++ b/lambda-odentas-pades-sign/helpers/pades.js @@ -0,0 +1,531 @@ +import * as asn1js from 'asn1js'; +import { + Certificate, + SignedData, + ContentInfo, + IssuerAndSerialNumber, + Attribute, + AlgorithmIdentifier, + EncapsulatedContentInfo, + SignerInfo, + SignedAndUnsignedAttributes +} from 'pkijs'; +import crypto from 'node:crypto'; +import { Buffer } from 'node:buffer'; + +// pkijs setup (webcrypto global) +if (typeof globalThis.crypto === 'undefined') { + globalThis.crypto = crypto.webcrypto; +} + +// OIDs +const OID_ID_DATA = '1.2.840.113549.1.7.1'; +const OID_ATTR_CONTENT_TYPE = '1.2.840.113549.1.9.3'; +const OID_ATTR_SIGNING_TIME = '1.2.840.113549.1.9.5'; +const OID_ATTR_MESSAGE_DIGEST = '1.2.840.113549.1.9.4'; + +/** + * Étape 1: PrĂ©parer le PDF avec les vraies valeurs ByteRange calculĂ©es + * StratĂ©gie PROFESSIONNELLE: Construire SANS ByteRange, calculer positions, reconstruire AVEC ByteRange + */ +export async function preparePdfWithPlaceholder(pdfBytes) { + const originalPdf = Buffer.from(pdfBytes); + const pdfStructure = parsePdfStructure(originalPdf); + + // GĂ©nĂ©rer le timestamp UNE SEULE FOIS + const signingTime = new Date().toISOString().replace(/[-:T.Z]/g, '').slice(0, 14); + + // Taille fixe pour /Contents (32KB) + const contentsPlaceholder = '<' + '0'.repeat(65536) + '>'; // 65538 chars total avec < > + + // PASSE 1: Construire avec un placeholder ByteRange de taille fixe + // Le placeholder doit avoir la mĂȘme taille que le vrai ByteRange qu'on mettra aprĂšs + // Format: [0000000000 0000000000 0000000000 0000000000] = 47 chars avec les crochets + const byteRangePlaceholder = '[0000000000 0000000000 0000000000 0000000000]'; + + console.log('[preparePdfWithPlaceholder] PASSE 1: Construction avec placeholder ByteRange...'); + const incrementalUpdate1 = buildIncrementalUpdate( + pdfStructure, + byteRangePlaceholder, // Placeholder de mĂȘme taille que le vrai + contentsPlaceholder, + signingTime + ); + + const pdf1 = assemblePdfWithRevision(originalPdf, pdfStructure, incrementalUpdate1); + + // Trouver la position du /Contents + const pdf1Str = pdf1.toString('latin1'); + const contentsMatch = pdf1Str.match(/\/Contents <(0+)>/); + if (!contentsMatch) throw new Error('Placeholder /Contents non trouvĂ©'); + + const contentsStart = contentsMatch.index + '/Contents <'.length; + const contentsEnd = contentsStart + contentsMatch[1].length; + const byteRange = [0, contentsStart, contentsEnd, pdf1.length - contentsEnd]; + + console.log('[preparePdfWithPlaceholder] ByteRange calculĂ©:', byteRange); + + // PASSE 2: Reconstruire avec le VRAI ByteRange (mĂȘme longueur que placeholder grĂące au padding) + console.log('[preparePdfWithPlaceholder] PASSE 2: Reconstruction avec vraies valeurs...'); + + // Padder le ByteRange pour qu'il ait exactement la mĂȘme longueur que le placeholder + const byteRangeStr = `[${byteRange[0]} ${byteRange[1]} ${byteRange[2]} ${byteRange[3]}]`; + if (byteRangeStr.length > byteRangePlaceholder.length) { + throw new Error(`ByteRange trop grand: ${byteRangeStr.length} > ${byteRangePlaceholder.length}`); + } + + // Padding avec espaces Ă  droite pour avoir exactement la mĂȘme taille + const byteRangePadded = byteRangeStr + ' '.repeat(byteRangePlaceholder.length - byteRangeStr.length); + + const incrementalUpdate2 = buildIncrementalUpdate( + pdfStructure, + byteRangePadded, // String paddĂ©e de mĂȘme longueur + contentsPlaceholder, + signingTime + ); + + const pdfWithRevision = assemblePdfWithRevision(originalPdf, pdfStructure, incrementalUpdate2); + + // VĂ©rifier que les positions n'ont PAS changĂ© + const pdf2Str = pdfWithRevision.toString('latin1'); + const contents2Match = pdf2Str.match(/\/Contents <(0+)>/); + const contents2Start = contents2Match.index + '/Contents <'.length; + const contents2End = contents2Start + contents2Match[1].length; + + if (contents2Start !== contentsStart || contents2End !== contentsEnd) { + console.error('[preparePdfWithPlaceholder] Position mismatch!'); + console.error(' PASSE 1: contentsStart=', contentsStart, 'contentsEnd=', contentsEnd); + console.error(' PASSE 2: contentsStart=', contents2Start, 'contentsEnd=', contents2End); + throw new Error('Les positions ByteRange ont changĂ© entre les deux constructions !'); + } + + console.log('[preparePdfWithPlaceholder] ✅ Positions vĂ©rifiĂ©es, PDF prĂȘt'); + + return { + pdfWithRevision, + byteRange, + contentsPlaceholder, + signingTime + }; +} + +/** + * Parser la structure PDF pour extraire les rĂ©fĂ©rences nĂ©cessaires + */ +function parsePdfStructure(pdfBytes) { + const pdfStr = pdfBytes.toString('latin1'); + + // Trouver le dernier startxref + const startxrefMatches = [...pdfStr.matchAll(/startxref\s+(\d+)/g)]; + if (startxrefMatches.length === 0) throw new Error('startxref non trouvĂ©'); + const prevStartxref = parseInt(startxrefMatches[startxrefMatches.length - 1][1], 10); + + // Trouver le plus grand numĂ©ro d'objet + const objMatches = [...pdfStr.matchAll(/(\d+) \d+ obj/g)]; + const maxObjNum = Math.max(...objMatches.map(m => parseInt(m[1], 10))); + const nextObjNum = maxObjNum + 1; + + // Trouver /Root (catalog) + const rootMatch = pdfStr.match(/\/Root\s+(\d+)\s+0\s+R/); + if (!rootMatch) throw new Error('/Root non trouvĂ©'); + const rootRef = parseInt(rootMatch[1], 10); + + // Trouver /Pages + const pagesMatch = pdfStr.match(/\/Pages\s+(\d+)\s+0\s+R/); + const pagesRef = pagesMatch ? parseInt(pagesMatch[1], 10) : null; + + // Trouver la premiĂšre page + const firstPageMatch = pdfStr.match(/(\d+)\s+0\s+obj\s*<<[^>]*\/Type\s*\/Page[^>]*>>/); + const firstPageRef = firstPageMatch ? parseInt(firstPageMatch[1], 10) : null; + + // Trouver /AcroForm existant + const acroFormMatch = pdfStr.match(/\/AcroForm\s+(\d+)\s+0\s+R/); + const acroFormRef = acroFormMatch ? parseInt(acroFormMatch[1], 10) : null; + + // Trouver /Info + const infoMatch = pdfStr.match(/\/Info\s+(\d+)\s+0\s+R/); + const infoRef = infoMatch ? parseInt(infoMatch[1], 10) : null; + + return { + prevStartxref, + nextObjNum, + rootRef, + pagesRef, + firstPageRef, + acroFormRef, + infoRef + }; +} + +/** + * Construire les nouveaux objets PDF pour la signature + * Si byteRange est une string, c'est un placeholder. Si c'est un array, ce sont les vraies valeurs. + */ +function buildIncrementalUpdate(pdfStructure, byteRange, contentsPlaceholder, signingTime) { + const { nextObjNum, rootRef, pagesRef, firstPageRef } = pdfStructure; + + let objNum = nextObjNum; + const newObjects = []; + + // 1. TransformParams (DocMDP Level 1) + const transformParamsObjNum = objNum++; + newObjects.push(`${transformParamsObjNum} 0 obj +<< +/Type /TransformParams +/V /1.2 +/P 1 +>> +endobj +`); + + // 2. Signature dictionary - ByteRange avec placeholder ou vraies valeurs + const sigObjNum = objNum++; + let sigObj = `${sigObjNum} 0 obj +<< +/Type /Sig +/Filter /Adobe.PPKLite +/SubFilter /ETSI.CAdES.detached +`; + + // Ajouter ByteRange - soit placeholder (passe 1) soit valeurs rĂ©elles paddĂ©es (passe 2) + // Dans les deux cas c'est une string de mĂȘme longueur + sigObj += `/ByteRange ${byteRange}\n`; + + sigObj += `/Contents ${contentsPlaceholder} +/M (D:${signingTime}) +/Reference [<< + /Type /SigRef + /TransformMethod /DocMDP + /TransformParams ${transformParamsObjNum} 0 R +>>] +>> +endobj +`; + newObjects.push(sigObj); + + // 3. Widget annotation + const widgetObjNum = objNum++; + newObjects.push(`${widgetObjNum} 0 obj +<< +/Type /Annot +/Subtype /Widget +/FT /Sig +/T (Signature1) +/V ${sigObjNum} 0 R +/P ${firstPageRef} 0 R +/Rect [0 0 0 0] +/F 132 +>> +endobj +`); + + // 4. AcroForm + const acroFormObjNum = objNum++; + newObjects.push(`${acroFormObjNum} 0 obj +<< +/Fields [${widgetObjNum} 0 R] +/SigFlags 3 +>> +endobj +`); + + // 5. Perms dictionary + const permsObjNum = objNum++; + newObjects.push(`${permsObjNum} 0 obj +<< +/DocMDP ${sigObjNum} 0 R +>> +endobj +`); + + // 6. Updated Catalog - IMPORTANT: conserver /Pages de l'original ! + const catalogObjNum = objNum++; + newObjects.push(`${catalogObjNum} 0 obj +<< +/Type /Catalog +/Pages ${pagesRef} 0 R +/AcroForm ${acroFormObjNum} 0 R +/Perms ${permsObjNum} 0 R +>> +endobj +`); + + return { + newObjects, + catalogObjNum, + sigObjNum, + nextObjNum: objNum + }; +} + +/** + * Assembler le PDF avec la rĂ©vision incrĂ©mentale + */ +function assemblePdfWithRevision(originalPdf, pdfStructure, incrementalUpdate) { + let currentOffset = originalPdf.length; + const parts = [originalPdf, Buffer.from('\n', 'latin1')]; + currentOffset += 1; + + // Ajouter les nouveaux objets et calculer leurs offsets + const xrefEntries = []; + for (let i = 0; i < incrementalUpdate.newObjects.length; i++) { + const objStr = incrementalUpdate.newObjects[i]; + const objBuf = Buffer.from(objStr, 'latin1'); + + xrefEntries.push({ + objNum: pdfStructure.nextObjNum + i, + offset: currentOffset, + gen: 0 + }); + + parts.push(objBuf); + currentOffset += objBuf.length; + } + + // Construire la table xref + const xrefOffset = currentOffset; + let xrefTable = 'xref\n0 1\n0000000000 65535 f \n'; + xrefTable += `${pdfStructure.nextObjNum} ${xrefEntries.length}\n`; + + for (const entry of xrefEntries) { + xrefTable += `${String(entry.offset).padStart(10, '0')} ${String(entry.gen).padStart(5, '0')} n \n`; + } + + // Construire le trailer + let trailer = `trailer\n<<\n/Size ${pdfStructure.nextObjNum + xrefEntries.length}\n/Prev ${pdfStructure.prevStartxref}\n/Root ${incrementalUpdate.catalogObjNum} 0 R\n`; + if (pdfStructure.infoRef) { + trailer += `/Info ${pdfStructure.infoRef} 0 R\n`; + } + trailer += `>>\nstartxref\n${xrefOffset}\n%%EOF\n`; + + parts.push(Buffer.from(xrefTable + trailer, 'latin1')); + + return Buffer.concat(parts); +} + +/** + * Étape 2: Calculer le digest des SignedAttributes + * Le ByteRange est dĂ©jĂ  dans le PDF, on le reçoit en paramĂštre + */ +export function buildSignedAttributesDigest(pdfWithRevision, byteRange, signingTime) { + console.log('[buildSignedAttributesDigest] ByteRange:', byteRange); + + // Calculer le digest PDF (sur les parties dĂ©finies par ByteRange) + const part1 = pdfWithRevision.slice(byteRange[0], byteRange[1]); + const part2 = pdfWithRevision.slice(byteRange[2], byteRange[2] + byteRange[3]); + + const pdfDigest = crypto.createHash('sha256').update(part1).update(part2).digest(); + console.log('[buildSignedAttributesDigest] PDF digest:', pdfDigest.toString('hex')); + + // Construire les SignedAttributes ASN.1 avec le signingTime passĂ© en paramĂštre + const attrContentType = new Attribute({ + type: OID_ATTR_CONTENT_TYPE, + values: [new asn1js.ObjectIdentifier({ value: OID_ID_DATA })] + }); + + // Utiliser le mĂȘme timestamp que celui du PDF (/M) pour garantir la cohĂ©rence + const signingDate = new Date( + parseInt(signingTime.substring(0, 4)), // year + parseInt(signingTime.substring(4, 6)) - 1, // month (0-indexed) + parseInt(signingTime.substring(6, 8)), // day + parseInt(signingTime.substring(8, 10)), // hour + parseInt(signingTime.substring(10, 12)), // minute + parseInt(signingTime.substring(12, 14)) // second + ); + + const attrSigningTime = new Attribute({ + type: OID_ATTR_SIGNING_TIME, + values: [new asn1js.UTCTime({ valueDate: signingDate })] + }); + + const attrMessageDigest = new Attribute({ + type: OID_ATTR_MESSAGE_DIGEST, + values: [new asn1js.OctetString({ valueHex: pdfDigest })] + }); + + // Pour calculer le digest, on doit encoder les attributs comme un SET avec tag IMPLICIT [0] + const signedAttrsForDigest = new asn1js.Set({ + value: [ + attrContentType.toSchema(), + attrSigningTime.toSchema(), + attrMessageDigest.toSchema() + ] + }); + + // Encoder et calculer le digest des SignedAttributes + const signedAttrsDer = Buffer.from(signedAttrsForDigest.toBER()); + const signedAttrsDigest = crypto.createHash('sha256').update(signedAttrsDer).digest(); + console.log('[buildSignedAttributesDigest] SignedAttributes digest:', signedAttrsDigest.toString('hex')); + + return { + signedAttrs: [attrContentType, attrSigningTime, attrMessageDigest], // Retourner les objets Attribute + signedAttrsDigest, + byteRange, + pdfDigest + }; +} + +/** + * Étape 3: Construire le CMS SignedData avec la signature KMS + */ +export async function buildCmsSignedData(signedAttrs, signatureBytes, chainPem) { + console.log('[buildCmsSignedData] Parsing certificate chain...'); + console.log('[buildCmsSignedData] Chain PEM length:', chainPem.length, 'bytes'); + + // Parser la chaĂźne de certificats + const chainStr = chainPem.toString('utf8'); + console.log('[buildCmsSignedData] Chain string preview:', chainStr.substring(0, 100)); + + const certPems = chainStr.match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g); + console.log('[buildCmsSignedData] Found', certPems ? certPems.length : 0, 'certificates'); + + if (!certPems || certPems.length === 0) { + throw new Error('Aucun certificat trouvĂ© dans chain.pem'); + } + + const certificates = []; + for (let i = 0; i < certPems.length; i++) { + const pem = certPems[i]; + try { + const b64 = pem.replace(/-----BEGIN CERTIFICATE-----/, '').replace(/-----END CERTIFICATE-----/, '').replace(/\s/g, ''); + const der = Buffer.from(b64, 'base64'); + console.log('[buildCmsSignedData] Cert', i, 'DER length:', der.length, 'bytes'); + + // asn1js attend un ArrayBuffer, pas un Buffer Node.js + const asn1Cert = asn1js.fromBER(der.buffer.slice(der.byteOffset, der.byteOffset + der.byteLength)); + if (asn1Cert.offset === -1) { + console.error('[buildCmsSignedData] ASN.1 parsing failed for cert', i); + throw new Error(`Erreur parsing certificat ${i}`); + } + + const cert = new Certificate({ schema: asn1Cert.result }); + certificates.push(cert); + console.log('[buildCmsSignedData] Cert', i, 'parsed successfully'); + } catch (err) { + console.error('[buildCmsSignedData] Error parsing cert', i, ':', err.message); + throw err; + } + } + + const signerCert = certificates[0]; + console.log('[buildCmsSignedData] Signer certificate parsed successfully'); + + // Construire SignerInfo + const signerInfo = new SignerInfo({ + version: 1, + sid: new IssuerAndSerialNumber({ + issuer: signerCert.issuer, + serialNumber: signerCert.serialNumber + }), + signedAttrs: new SignedAndUnsignedAttributes({ + type: 0, + attributes: signedAttrs // Utiliser directement les objets Attribute + }) + }); + + // Algorithme de signature (RSA-PSS avec SHA-256) + signerInfo.digestAlgorithm = new AlgorithmIdentifier({ + algorithmId: '2.16.840.1.101.3.4.2.1' // SHA-256 + }); + + signerInfo.signatureAlgorithm = new AlgorithmIdentifier({ + algorithmId: '1.2.840.113549.1.1.10', // RSASSA-PSS + algorithmParams: new asn1js.Sequence({ + value: [ + new asn1js.Constructed({ + idBlock: { tagClass: 3, tagNumber: 0 }, + value: [ + new asn1js.Sequence({ + value: [ + new asn1js.ObjectIdentifier({ value: '2.16.840.1.101.3.4.2.1' }), // SHA-256 + new asn1js.Null() + ] + }) + ] + }), + new asn1js.Constructed({ + idBlock: { tagClass: 3, tagNumber: 1 }, + value: [ + new asn1js.Sequence({ + value: [ + new asn1js.ObjectIdentifier({ value: '1.2.840.113549.1.1.8' }), // MGF1 + new asn1js.Sequence({ + value: [ + new asn1js.ObjectIdentifier({ value: '2.16.840.1.101.3.4.2.1' }), // SHA-256 + new asn1js.Null() + ] + }) + ] + }) + ] + }), + new asn1js.Constructed({ + idBlock: { tagClass: 3, tagNumber: 2 }, + value: [new asn1js.Integer({ value: 32 })] + }) + ] + }) + }); + + signerInfo.signature = new asn1js.OctetString({ valueHex: signatureBytes }); + + // Construire SignedData + const signedData = new SignedData({ + version: 1, + digestAlgorithms: [new AlgorithmIdentifier({ algorithmId: '2.16.840.1.101.3.4.2.1' })], + encapContentInfo: new EncapsulatedContentInfo({ eContentType: OID_ID_DATA }), + certificates, + signerInfos: [signerInfo] + }); + + // Construire ContentInfo + const contentInfo = new ContentInfo({ + contentType: '1.2.840.113549.1.7.2', // SignedData + content: signedData.toSchema(true) + }); + + const cmsDer = Buffer.from(contentInfo.toSchema().toBER()); + console.log('[buildCmsSignedData] CMS SignedData length:', cmsDer.length, 'bytes'); + + return cmsDer; +} + +/** + * Étape 4: Finaliser le PDF avec la signature CMS + * Le ByteRange est dĂ©jĂ  correct dans le PDF, on remplace UNIQUEMENT /Contents + */ +export function finalizePdfWithCms(pdfWithRevision, byteRange, cmsHex) { + console.log('[finalizePdfWithCms] Injecting CMS signature, length:', cmsHex.length); + + // Trouver le placeholder /Contents et le remplacer + // IMPORTANT: Utiliser Buffer.from/Buffer.concat pour Ă©viter les problĂšmes d'encodage + const pdfStr = pdfWithRevision.toString('latin1'); + const contentsMatch = pdfStr.match(/\/Contents <(0+)>/); + if (!contentsMatch) throw new Error('Placeholder /Contents non trouvĂ©'); + + const contentsStart = contentsMatch.index + '/Contents <'.length; + const placeholderLength = contentsMatch[1].length; + + // VĂ©rifier que la signature tient dans le placeholder + if (cmsHex.length > placeholderLength) { + throw new Error(`Signature CMS trop grande: ${cmsHex.length} > ${placeholderLength}`); + } + + // Pad la signature avec des zeros + const cmsHexPadded = cmsHex.padEnd(placeholderLength, '0'); + + // Construire le PDF final en remplaçant uniquement le contenu entre les < > + const before = pdfWithRevision.slice(0, contentsStart); + const signature = Buffer.from(cmsHexPadded, 'latin1'); + const after = pdfWithRevision.slice(contentsStart + placeholderLength); + + const finalPdf = Buffer.concat([before, signature, after]); + + // VALIDATION: Recalculer le digest pour vĂ©rifier + const part1 = finalPdf.slice(byteRange[0], byteRange[1]); + const part2 = finalPdf.slice(byteRange[2], byteRange[2] + byteRange[3]); + const validationDigest = crypto.createHash('sha256').update(part1).update(part2).digest(); + + console.log('[finalizePdfWithCms] VALIDATION - PDF digest recalculĂ©:', validationDigest.toString('hex')); + + return finalPdf; +} diff --git a/lambda-odentas-pades-sign/helpers/pades_backup.js b/lambda-odentas-pades-sign/helpers/pades_backup.js new file mode 100644 index 0000000..8a5453d --- /dev/null +++ b/lambda-odentas-pades-sign/helpers/pades_backup.js @@ -0,0 +1,662 @@ +import { PDFDocument } from 'pdf-lib'; +import * as asn1js from 'asn1js'; +import { + Certificate, + SignedData, + ContentInfo, + IssuerAndSerialNumber, + Attribute, + AlgorithmIdentifier, + EncapsulatedContentInfo, + SignerInfo, + SignedAndUnsignedAttributes +} from 'pkijs'; +import crypto from 'node:crypto'; +import { Buffer } from 'node:buffer'; + +// pkijs setup (webcrypto global) - utilisation de l'API Web Crypto native de Node.js 18 +if (typeof globalThis.crypto === 'undefined') { + globalThis.crypto = crypto.webcrypto; +} + +// ===================================================== +// PDF helpers — PAdES incremental update avec /Sig + /ByteRange +// ===================================================== +export async function preparePdfWithPlaceholder(pdfBytes) { + const originalPdf = Buffer.from(pdfBytes); + const pdfStructure = parsePdfStructure(originalPdf); + + // Placeholders pour ByteRange et Contents (tailles fixes pour remplacement facile) + const byteRangePlaceholder = '[0000000000 0000000000 0000000000 0000000000]'; // 51 caractĂšres + const contentsPlaceholder = '<' + '0'.repeat(65536) + '>'; // 32KB pour la signature CMS + + // GĂ©nĂ©rer le timestamp UNE SEULE FOIS pour Ă©viter les diffĂ©rences entre digest et finalisation + const signingTime = new Date().toISOString().replace(/[-:T.Z]/g, '').slice(0, 14); + + const incrementalUpdate = buildIncrementalUpdate( + pdfStructure, + byteRangePlaceholder, + contentsPlaceholder, + signingTime + ); + + return { originalPdf, pdfStructure, incrementalUpdate, signingTime }; +} + +// Parser le PDF pour extraire la structure nĂ©cessaire Ă  la rĂ©vision incrĂ©mentale +function parsePdfStructure(pdfBytes) { + const pdfStr = pdfBytes.toString('latin1'); + + // 1. Trouver le dernier startxref + const startxrefMatch = pdfStr.match(/startxref\s+(\d+)\s+%%EOF\s*$/); + if (!startxrefMatch) throw new Error('startxref non trouvĂ© dans le PDF'); + const prevStartxref = parseInt(startxrefMatch[1], 10); + + // 2. Trouver le dernier numĂ©ro d'objet utilisĂ© + const objRegex = /(\d+)\s+\d+\s+obj/g; + let maxObjNum = 0; + let match; + while ((match = objRegex.exec(pdfStr)) !== null) { + const objNum = parseInt(match[1], 10); + if (objNum > maxObjNum) maxObjNum = objNum; + } + + // 3. Extraire le trailer pour trouver /Root et /Info + const trailerMatch = pdfStr.match(/trailer\s*<<([^>]*)>>/s); + let rootRef = null; + let infoRef = null; + let sizeNum = maxObjNum + 1; + let pagesRef = null; + + if (trailerMatch) { + const trailerDict = trailerMatch[1]; + const rootMatch = trailerDict.match(/\/Root\s+(\d+)\s+\d+\s+R/); + if (rootMatch) rootRef = parseInt(rootMatch[1], 10); + + const infoMatch = trailerDict.match(/\/Info\s+(\d+)\s+\d+\s+R/); + if (infoMatch) infoRef = parseInt(infoMatch[1], 10); + + const sizeMatch = trailerDict.match(/\/Size\s+(\d+)/); + if (sizeMatch) sizeNum = parseInt(sizeMatch[1], 10); + } + + // 4. Chercher /AcroForm et /Pages dans le catalog + let acroFormRef = null; + if (rootRef) { + const catalogMatch = pdfStr.match(new RegExp(`${rootRef}\\s+\\d+\\s+obj\\s*<<([^>]*(?:>>.*?<<)*)>>`, 's')); + if (catalogMatch) { + const catalogDict = catalogMatch[1]; + const acroMatch = catalogDict.match(/\/AcroForm\s+(\d+)\s+\d+\s+R/); + if (acroMatch) acroFormRef = parseInt(acroMatch[1], 10); + + const pagesMatch = catalogDict.match(/\/Pages\s+(\d+)\s+\d+\s+R/); + if (pagesMatch) pagesRef = parseInt(pagesMatch[1], 10); + } + } + + // 5. Trouver la premiĂšre page (pour y attacher le widget) + let firstPageRef = null; + if (pagesRef) { + // Lire l'objet Pages + const pagesObjMatch = pdfStr.match(new RegExp(`${pagesRef}\\s+\\d+\\s+obj\\s*<<([^>]*(?:>>.*?<<)*)>>`, 's')); + if (pagesObjMatch) { + // Chercher /Kids [...] + const kidsMatch = pagesObjMatch[1].match(/\/Kids\s*\[\s*(\d+)\s+\d+\s+R/); + if (kidsMatch) firstPageRef = parseInt(kidsMatch[1], 10); + } + } + + return { + prevStartxref, + nextObjNum: maxObjNum + 1, + rootRef, + infoRef, + acroFormRef, + firstPageRef, + pagesRef, + sizeNum + }; +} + +// Construire la rĂ©vision incrĂ©mentale PDF avec /Sig, /ByteRange, /Contents +function buildIncrementalUpdate(pdfStructure, cmsHex, signingTime) { + const { + prevStartxref, + nextObjNum, + rootRef, + acroFormRef, + firstPageRef, + pagesRef, + sizeNum + } = pdfStructure; + + let objNum = nextObjNum; + const newObjects = []; + + // Taille du placeholder pour /Contents (doit ĂȘtre suffisant pour le CMS hex) + const contentsPlaceholderSize = 65536; // 32KB * 2 (hex) + const contentsPlaceholder = '<' + '0'.repeat(contentsPlaceholderSize) + '>'; + + // Placeholder ByteRange : sera calculĂ© plus tard mais doit avoir une taille fixe + // Format: [0 AAAAAAAAAA BBBBBBBBBB CCCCCCCCCC] avec des chiffres, pas d'espaces variables + const byteRangePlaceholder = '[0000000000 0000000000 0000000000 0000000000]'; + + // 1. CrĂ©er le dictionnaire /TransformParams pour DocMDP level 1 (verrouillage total) + const transformParamsObjNum = objNum++; + const transformParamsObj = `${transformParamsObjNum} 0 obj +<< +/Type /TransformParams +/V /1.2 +/P 1 +>> +endobj +`; + newObjects.push(transformParamsObj); + + // 2. CrĂ©er le dictionnaire /Sig avec /Reference pour DocMDP + const sigObjNum = objNum++; + const sigObj = `${sigObjNum} 0 obj +<< +/Type /Sig +/Filter /Adobe.PPKLite +/SubFilter /ETSI.CAdES.detached +/ByteRange ${byteRangePlaceholder} +/Contents ${contentsPlaceholder} +/M (D:${signingTime}) +/Reference [<< + /Type /SigRef + /TransformMethod /DocMDP + /TransformParams ${transformParamsObjNum} 0 R +>>] +>> +endobj +`; + newObjects.push(sigObj); + + // 3. CrĂ©er le widget de signature (annotation) + const widgetObjNum = objNum++; + const widgetObj = `${widgetObjNum} 0 obj +<< +/Type /Annot +/Subtype /Widget +/FT /Sig +/T (Signature1) +/V ${sigObjNum} 0 R +/P ${firstPageRef} 0 R +/Rect [0 0 0 0] +/F 132 +>> +endobj +`; + newObjects.push(widgetObj); + + // 4. CrĂ©er /AcroForm + const acroFormObjNum = objNum++; + const acroFormObj = `${acroFormObjNum} 0 obj +<< +/Fields [${widgetObjNum} 0 R] +/SigFlags 3 +>> +endobj +`; + newObjects.push(acroFormObj); + + // 5. CrĂ©er /Perms pour verrouiller le document (DocMDP level 1) + const permsObjNum = objNum++; + const permsObj = `${permsObjNum} 0 obj +<< +/DocMDP ${sigObjNum} 0 R +>> +endobj +`; + newObjects.push(permsObj); + + // 6. Mettre Ă  jour le Catalog pour rĂ©fĂ©rencer /AcroForm, /Pages et /Perms + const catalogObjNum = objNum++; + const catalogObj = `${catalogObjNum} 0 obj +<< +/Type /Catalog +/Pages ${pagesRef} 0 R +/AcroForm ${acroFormObjNum} 0 R +/Perms ${permsObjNum} 0 R +>> +endobj +`; + newObjects.push(catalogObj); + + // 7. Mettre Ă  jour la premiĂšre page pour ajouter le widget aux /Annots + if (firstPageRef) { + const pageObjNum = objNum++; + const pageObj = `${pageObjNum} 0 obj +<< +/Type /Page +/Annots [${widgetObjNum} 0 R] +>> +endobj +`; + newObjects.push(pageObj); + } + + return { + sigObjNum, + widgetObjNum, + acroFormObjNum, + catalogObjNum, + newObjects, + nextObjNum: objNum, + contentsPlaceholder, + contentsPlaceholderSize, + byteRangePlaceholder + }; +} + +export async function finalizePdfWithCms(pdfWithPlaceholder, byteRangeInfo, pdfStructure, incrementalUpdate, cmsDer, tempPdfWithRevision, contentsStart, contentsEnd) { + const { signatureSize } = byteRangeInfo; + + // Convertir le CMS en hex + const cmsHex = cmsDer.toString('hex').toUpperCase(); + if (cmsHex.length > signatureSize * 2) { + throw new Error(`CMS trop grand pour le placeholder (${cmsHex.length / 2} > ${signatureSize})`); + } + + // Utiliser le PDF temporaire dĂ©jĂ  assemblĂ© + let finalPdfStr = tempPdfWithRevision.toString('latin1'); + + // Calculer le /ByteRange final + const byteRange = [0, contentsStart, contentsEnd, tempPdfWithRevision.length - contentsEnd]; + + console.log('[finalizePdfWithCms] ByteRange:', byteRange); + console.log('[finalizePdfWithCms] CMS hex length:', cmsHex.length); + + // Remplacer le placeholder /ByteRange (padding avec des 0 pour garder longueur identique) + const byteRangeStr = `[${String(byteRange[0]).padStart(10, '0')} ${String(byteRange[1]).padStart(10, '0')} ${String(byteRange[2]).padStart(10, '0')} ${String(byteRange[3]).padStart(10, '0')}]`; + + finalPdfStr = finalPdfStr.replace( + '[0000000000 0000000000 0000000000 0000000000]', + byteRangeStr + ); + + // Remplacer le placeholder /Contents avec le CMS hex (padded) + const cmsPadded = cmsHex + '0'.repeat(65536 - cmsHex.length); + finalPdfStr = finalPdfStr.replace( + /\/Contents <0+>/, + `/Contents <${cmsPadded}>` + ); + + const finalBuffer = Buffer.from(finalPdfStr, 'latin1'); + + // VALIDATION finale + const validationPart1 = finalBuffer.slice(byteRange[0], byteRange[0] + byteRange[1]); + const validationPart2 = finalBuffer.slice(byteRange[2], byteRange[2] + byteRange[3]); + const validationDigest = crypto.createHash('sha256').update(validationPart1).update(validationPart2).digest(); + console.log('[finalizePdfWithCms] VALIDATION - PDF digest recalculĂ©:', validationDigest.toString('hex')); + + return finalBuffer; +} + +// ===================================================== +// SignedAttributes (DER) + digest SHA-256 pour KMS +// ===================================================== +// OIDs utiles +const OID_ID_DATA = '1.2.840.113549.1.7.1'; // id-data (ContentInfo) +const OID_ATTR_CONTENT_TYPE = '1.2.840.113549.1.9.3'; +const OID_ATTR_SIGNING_TIME = '1.2.840.113549.1.9.5'; +const OID_ATTR_MESSAGE_DIGEST = '1.2.840.113549.1.9.4'; + +export async function buildSignedAttributesDigest(pdfWithPlaceholder, byteRangeInfo, pdfStructure, incrementalUpdate) { + // Construire le PDF final avec placeholder pour trouver le vrai ByteRange + const { originalPdfLength } = byteRangeInfo; + const originalPdf = pdfWithPlaceholder.slice(0, originalPdfLength); + + // Assembler temporairement avec placeholder + const tempPdfWithRevision = assemblePdfWithRevision(originalPdf, pdfStructure, incrementalUpdate); + + // Trouver position du /Contents dans ce PDF + const tempStr = tempPdfWithRevision.toString('latin1'); + const contentsMatch = tempStr.match(/\/Contents <(0+)>/); + if (!contentsMatch) throw new Error('Placeholder /Contents non trouvĂ©'); + + const contentsStart = contentsMatch.index + '/Contents <'.length; + const contentsEnd = contentsStart + contentsMatch[1].length; + + console.log('[buildSignedAttributesDigest] ByteRange calculĂ©:', [0, contentsStart, contentsEnd, tempPdfWithRevision.length - contentsEnd]); + + // Calculer digest sur [0...contentsStart] + [contentsEnd...EOF] + const part1 = tempPdfWithRevision.slice(0, contentsStart); + const part2 = tempPdfWithRevision.slice(contentsEnd); + + const pdfDigest = crypto.createHash('sha256').update(part1).update(part2).digest(); + console.log('[buildSignedAttributesDigest] PDF digest (SHA256):', pdfDigest.toString('hex')); + + const { signedAttributesDer, signedAttributesDigest } = + buildSignedAttributesDigestFromPdfDigest(pdfDigest); + + return { signedAttributesDer, signedAttributesDigest, tempPdfWithRevision, contentsStart, contentsEnd }; +} + +// Helper pour assembler le PDF avec rĂ©vision +function assemblePdfWithRevision(originalPdf, pdfStructure, incrementalUpdate) { + let currentOffset = originalPdf.length; + const objectsWithOffsets = [Buffer.from('\n', 'latin1')]; + currentOffset += 1; + + const xrefEntries = []; + for (let i = 0; i < incrementalUpdate.newObjects.length; i++) { + const objStr = incrementalUpdate.newObjects[i]; + xrefEntries.push({ objNum: pdfStructure.nextObjNum + i, offset: currentOffset, gen: 0 }); + objectsWithOffsets.push(Buffer.from(objStr, 'latin1')); + currentOffset += Buffer.byteLength(objStr, 'latin1'); + } + + const xrefOffset = currentOffset; + let xrefTable = 'xref\n0 1\n0000000000 65535 f \n'; + xrefTable += `${pdfStructure.nextObjNum} ${xrefEntries.length}\n`; + + for (const entry of xrefEntries) { + xrefTable += `${String(entry.offset).padStart(10, '0')} ${String(entry.gen).padStart(5, '0')} n \n`; + } + + let trailer = `trailer\n<<\n/Size ${pdfStructure.nextObjNum + xrefEntries.length}\n/Prev ${pdfStructure.prevStartxref}\n/Root ${incrementalUpdate.catalogObjNum} 0 R\n`; + if (pdfStructure.infoRef) { + trailer += `/Info ${pdfStructure.infoRef} 0 R\n`; + } + trailer += `>>\nstartxref\n${xrefOffset}\n%%EOF\n`; + + return Buffer.concat([originalPdf, ...objectsWithOffsets, Buffer.from(xrefTable + trailer, 'latin1')]); +} + +export function buildSignedAttributesDigestFromPdfDigest(pdfMessageDigest) { + console.log('[buildSignedAttributesDigest] pdfMessageDigest:', pdfMessageDigest.toString('hex')); + + // Attribute ::= SEQUENCE { attrType OBJECT IDENTIFIER, attrValues SET OF ANY } + const attrContentType = new asn1js.Sequence({ + value: [ + new asn1js.ObjectIdentifier({ value: OID_ATTR_CONTENT_TYPE }), + new asn1js.Set({ value: [ new asn1js.ObjectIdentifier({ value: OID_ID_DATA }) ] }) + ] + }); + + const attrSigningTime = new asn1js.Sequence({ + value: [ + new asn1js.ObjectIdentifier({ value: OID_ATTR_SIGNING_TIME }), + new asn1js.Set({ value: [ new asn1js.GeneralizedTime({ valueDate: new Date() }) ] }) + ] + }); + + const attrMessageDigest = new asn1js.Sequence({ + value: [ + new asn1js.ObjectIdentifier({ value: OID_ATTR_MESSAGE_DIGEST }), + new asn1js.Set({ value: [ new asn1js.OctetString({ valueHex: pdfMessageDigest }) ] }) + ] + }); + + // SET OF Attributes — l'ordre DER (par tag/valeur) sera appliquĂ© par asn1js + const signedAttrsSet = new asn1js.Set({ value: [attrContentType, attrSigningTime, attrMessageDigest] }); + const signedAttributesDer = Buffer.from(signedAttrsSet.toBER(false)); + + // Le digest Ă  signer par KMS est SHA-256( DER(SignedAttributes) ) + const signedAttributesDigest = crypto.createHash('sha256').update(signedAttributesDer).digest(); + console.log('[buildSignedAttributesDigest] signedAttributesDigest (pour KMS):', signedAttributesDigest.toString('hex')); + + return { signedAttributesDer, signedAttributesDigest }; +} + +// ===================================================== +// PEM -> pkijs.Certificate(s) +// ===================================================== +export function parsePemChainToPkijsCerts(chainData) { + try { + if (Buffer.isBuffer(chainData)) { + const previewHex = chainData.slice(0, 16).toString('hex'); + console.log('[chain raw] length=', chainData.length, ' headHex=', previewHex); + } + } catch {} + + let pemStr = null; + let derBuf = null; + + // Normalisation des entrĂ©es (Buffer ou string) + if (Buffer.isBuffer(chainData)) { + derBuf = chainData; // binaire tel quel, peut ĂȘtre PEM en bytes ou DER + try { pemStr = chainData.toString('utf8'); } catch {} + } else if (typeof chainData === 'string') { + pemStr = chainData; + } + + // Tentative 1 : parse PEM (headers BEGIN/END) + if (typeof pemStr === 'string' && pemStr.length > 0) { + try { + // strip BOM Ă©ventuel + normalisation des fins de ligne + if (pemStr.charCodeAt(0) === 0xFEFF) pemStr = pemStr.slice(1); + pemStr = pemStr.replace(/\r\n?/g, '\n'); + const preview = String(pemStr).slice(0, 160); + console.log('[chain.pem preview]', preview.replace(/\n/g, '\\n')); + + const blocks = splitPemBlocks(pemStr) + .filter(b => b.type === 'CERTIFICATE') + .map(b => Buffer.from(b.body, 'base64')); + + if (blocks.length > 0) { + const certsPkijs = blocks.map(der => { + const asn1 = asn1js.fromBER(der.buffer.slice(der.byteOffset, der.byteOffset + der.byteLength)); + if (asn1.offset === -1) throw new Error('ASN.1 parse error on cert (PEM)'); + return new Certificate({ schema: asn1.result }); + }); + return { certsPkijs, signerCert: certsPkijs[0] }; + } + } catch (e) { + console.log('[parsePemChainToPkijsCerts] PEM parse error, trying DER/base64:', String(e)); + } + } + + // Tentative 2 : DER brut (essayĂ© mĂȘme si le 1er octet n'est pas 0x30, avec logs) + if (derBuf && derBuf.length >= 4) { + try { + const asn1Any = asn1js.fromBER(derBuf.buffer.slice(derBuf.byteOffset || 0, (derBuf.byteOffset || 0) + derBuf.byteLength)); + if (asn1Any.offset !== -1) { + // Si c'est un Certificate + try { + const cert = new Certificate({ schema: asn1Any.result }); + if (cert && cert.serialNumber) { + console.log('[DER] Parsed as X.509 Certificate'); + return { certsPkijs: [cert], signerCert: cert }; + } + } catch {} + // Si c'est un ContentInfo/SignedData qui contient des certs + try { + const ci = new ContentInfo({ schema: asn1Any.result }); + if (ci && ci.contentType === '1.2.840.113549.1.7.2') { // signedData + const sd = new SignedData({ schema: ci.content }); + if (Array.isArray(sd.certificates) && sd.certificates.length) { + console.log(`[DER] Parsed PKCS#7 with ${sd.certificates.length} cert(s)`); + const certsPkijs = sd.certificates; + return { certsPkijs, signerCert: certsPkijs[0] }; + } + } + } catch {} + } + } catch (e) { + console.log('[DER parse] error:', String(e)); + } + } + + // Tentative 3 : base64 sans entĂȘtes + if (typeof pemStr === 'string' && pemStr.length > 0) { + try { + const b64 = pemStr.replace(/[^A-Za-z0-9+/=]/g, ''); + if (b64.length >= 128) { + const buf = Buffer.from(b64, 'base64'); + const asn1 = asn1js.fromBER(buf.buffer.slice(buf.byteOffset || 0, (buf.byteOffset || 0) + buf.byteLength)); + if (asn1.offset !== -1) { + const cert = new Certificate({ schema: asn1.result }); + return { certsPkijs: [cert], signerCert: cert }; + } + } + } catch (e) { + console.log('[parsePemChainToPkijsCerts] base64 parse error:', String(e)); + } + } + + throw new Error('chain.pem vide ou invalide'); +} + +function splitPemBlocks(pem) { + try { + if (pem && pem.charCodeAt && pem.charCodeAt(0) === 0xFEFF) pem = pem.slice(1); // strip BOM + pem = String(pem); + } catch {} + + // Normalise les fins de ligne et log de debug + pem = pem.replace(/\r\n?/g, '\n'); + console.log('[splitPemBlocks] input length =', pem.length); + console.log('[splitPemBlocks] head =', pem.slice(0, 80).replace(/\n/g, '\\n')); + if (/BEGIN CERTIFICATE/.test(pem) === false) { + console.log('[splitPemBlocks] Aucun header PEM dĂ©tectĂ© dans le texte'); + } + + // Regex principale (ultra permissive) + const re = /-+BEGIN\s+([^\-\n\r]+)-+\s*([\s\S]*?)\s*-+END\s+\1-+/gi; + const blocks = []; + let m; + while ((m = re.exec(pem)) !== null) { + let type = m[1].trim().toUpperCase(); + type = type.replace(/-+$/g, ''); // nettoie d'Ă©ventuels tirets rĂ©siduels + const body = m[2].replace(/\s+/g, ''); + blocks.push({ type, body }); + } + if (blocks.length > 0) { + console.log(`[splitPemBlocks] regex -> ${blocks.length} bloc(s): ` + blocks.map(b => b.type).join(', ')); + return blocks; + } + + // --- Fallback robuste par lecture ligne Ă  ligne --- + console.log('[splitPemBlocks] regex a Ă©chouĂ©, tentative fallback ligne-Ă -ligne'); + const out = []; + const lines = pem.split('\n'); + let i = 0; + while (i < lines.length) { + let line = lines[i].trim(); + if (/^-+BEGIN /.test(line) && /-+$/.test(line)) { + const type = line.replace(/^-+BEGIN\s+/, '').replace(/-+$/, '').trim().toUpperCase(); + const buf = []; + i++; + while (i < lines.length) { + const l = lines[i].trim(); + if (new RegExp(`^-+END\\s+${type}-+$`, 'i').test(l)) { + break; + } + // ignore lignes vides et espaces, concatĂ©ner base64 brut + if (l.length) buf.push(l); + i++; + } + if (buf.length) out.push({ type, body: buf.join('') }); + } + i++; + } + if (out.length === 0) { + console.log('[splitPemBlocks] fallback: aucun bloc dĂ©tectĂ©'); + } else { + console.log(`[splitPemBlocks] fallback -> ${out.length} bloc(s): ` + out.map(b => b.type).join(', ')); + } + return out; +} + +// ===================================================== +// CMS/PKCS#7 (SignedData) — construction complete (sans TSA pour l’instant) +// ===================================================== +export async function buildCmsSignedData(signedAttributesDer, signatureBytes, chainData) { + const { certsPkijs, signerCert } = parsePemChainToPkijsCerts(chainData); + + // EncapsulatedContentInfo (detached): eContentType = id-data, pas de eContent + const encap = new EncapsulatedContentInfo({ + eContentType: OID_ID_DATA + // eContent absent pour une signature dĂ©tachĂ©e + }); + + const signedData = new SignedData({ + version: 1, + encapContentInfo: encap + }); + + // ChaĂźne de certificats (sans la root) + signedData.certificates = certsPkijs; + + // SignerInfo (sid = IssuerAndSerialNumber) + const issuerAndSerial = new IssuerAndSerialNumber({ + issuer: signerCert.issuer, + serialNumber: signerCert.serialNumber + }); + + // digestAlgorithm = SHA-256 + const digestAlgorithm = new AlgorithmIdentifier({ algorithmId: '2.16.840.1.101.3.4.2.1' }); + + // signatureAlgorithm = RSASSA-PSS avec SHA-256 / MGF1-SHA256 / saltLen=32 + const rsassaPssParams = new asn1js.Sequence({ + value: [ + // hashAlgorithm (sha256) + new asn1js.Constructed({ + idBlock: { tagClass: 3, tagNumber: 0 }, // [0] + value: [ new AlgorithmIdentifier({ algorithmId: '2.16.840.1.101.3.4.2.1' }).toSchema() ] + }), + // maskGenAlgorithm (mgf1 with sha256) + new asn1js.Constructed({ + idBlock: { tagClass: 3, tagNumber: 1 }, // [1] + value: [ new AlgorithmIdentifier({ + algorithmId: '1.2.840.113549.1.1.8', // mgf1 + algorithmParams: new AlgorithmIdentifier({ algorithmId: '2.16.840.1.101.3.4.2.1' }).toSchema() + }).toSchema() ] + }), + // saltLength INTEGER 32 + new asn1js.Primitive({ idBlock: { tagClass: 3, tagNumber: 2 }, valueHex: new asn1js.Integer({ value: 32 }).toBER(false) }) + // trailerField [3] default 1 — omis + ] + }); + + const signatureAlgorithm = new AlgorithmIdentifier({ algorithmId: '1.2.840.113549.1.1.10' }); // rsassaPss + signatureAlgorithm.algorithmParams = rsassaPssParams; + + // RecrĂ©er les SignedAttributes comme objets pkijs Ă  partir du DER fourni (pour cohĂ©rence DER) + const signedAttrsSet = parseSignedAttributesDerToPkijsSignedSet(signedAttributesDer); + + const signerInfo = new SignerInfo({ + version: 1, + sid: issuerAndSerial, + digestAlgorithm, + signatureAlgorithm, + signedAttrs: signedAttrsSet, + signature: new asn1js.OctetString({ + valueHex: signatureBytes.buffer.slice( + signatureBytes.byteOffset || 0, + (signatureBytes.byteOffset || 0) + (signatureBytes.byteLength || signatureBytes.length) + ) + }) + }); + signedData.signerInfos.push(signerInfo); + + // ContentInfo enveloppe + const cms = new ContentInfo({ contentType: '1.2.840.113549.1.7.2', content: signedData.toSchema(true) }); + const cmsDer = Buffer.from(cms.toSchema().toBER(false)); + return cmsDer; +} + +function parseSignedAttributesDerToPkijsSignedSet(signedAttributesDer) { + const view = signedAttributesDer instanceof Buffer ? new Uint8Array(signedAttributesDer) : signedAttributesDer; + const asn1 = asn1js.fromBER(view.buffer.slice(view.byteOffset || 0, (view.byteOffset || 0) + (view.byteLength || view.length))); + if (asn1.offset === -1 || !(asn1.result instanceof asn1js.Set)) { + throw new Error('SignedAttributes DER invalide'); + } + const attrs = []; + for (const el of asn1.result.valueBlock.value) { + // SEQUENCE { type OBJECT IDENTIFIER, values SET OF ANY } + const seq = el; // asn1js.Sequence + const typeOid = seq.valueBlock.value[0]; + const valuesSet = seq.valueBlock.value[1]; + const type = typeOid.valueBlock.toString(); + const values = valuesSet.valueBlock.value.map(v => v); + attrs.push(new Attribute({ type, values })); + } + // pkijs attend un SignedAndUnsignedAttributes pour signedAttrs (type=0) + const signedAttrs = new SignedAndUnsignedAttributes({ type: 0 }); + signedAttrs.attributes = attrs; + return signedAttrs; +} \ No newline at end of file diff --git a/lambda-odentas-pades-sign/index.js b/lambda-odentas-pades-sign/index.js new file mode 100644 index 0000000..6b90087 --- /dev/null +++ b/lambda-odentas-pades-sign/index.js @@ -0,0 +1,115 @@ +import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; +import { KMSClient, SignCommand } from '@aws-sdk/client-kms'; +import * as pades from './helpers/pades.js'; +import crypto from 'node:crypto'; + +const region = process.env.REGION || 'eu-west-3'; +const s3 = new S3Client({ region }); +const kms = new KMSClient({ region }); + +const BUCKET = process.env.BUCKET || 'odentas-sign'; +const SIGNED_PREFIX = process.env.SIGNED_PREFIX || 'signed-pades/'; +const CHAIN_S3_KEY = process.env.SIGNER_CHAIN_S3_KEY || 'certs/chain.pem'; +const KMS_KEY_ID = process.env.KMS_KEY_ID || ''; + +export const handler = async (event) => { + try { + const requestRef = event.requestRef || `REQ-${Date.now()}`; + const sourceKey = event.sourceKey || event.pdfS3Key; + if (!sourceKey) throw new Error('sourceKey manquant'); + + console.log('[START] requestRef:', requestRef, 'sourceKey:', sourceKey); + + // 1. TĂ©lĂ©charger PDF source + const getPdf = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: sourceKey })); + const inputPdf = await streamToBuffer(getPdf.Body); + console.log('[PDF] Downloaded, size:', inputPdf.length, 'bytes'); + + // 2. TĂ©lĂ©charger chain.pem + const getChain = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: CHAIN_S3_KEY })); + const chainPem = await streamToBuffer(getChain.Body); + console.log('[CHAIN] Downloaded, size:', chainPem.length, 'bytes'); + + // 3. PrĂ©parer le PDF avec les vraies valeurs ByteRange (calculĂ©es en 2 passes) + const { + pdfWithRevision, + byteRange, + contentsPlaceholder, + signingTime + } = await pades.preparePdfWithPlaceholder(inputPdf); + console.log('[PREPARE] PDF with revision ready, size:', pdfWithRevision.length, 'bytes'); + console.log('[PREPARE] Signing time:', signingTime); + console.log('[PREPARE] ByteRange:', byteRange); + + // 4. Calculer le digest des SignedAttributes (ByteRange dĂ©jĂ  correct dans le PDF) + const { + signedAttrs, + signedAttrsDigest, + pdfDigest + } = pades.buildSignedAttributesDigest(pdfWithRevision, byteRange, signingTime); + + // 5. Signer avec KMS + if (!KMS_KEY_ID) throw new Error('KMS_KEY_ID non dĂ©fini'); + const signResp = await kms.send(new SignCommand({ + KeyId: KMS_KEY_ID, + Message: Buffer.from(signedAttrsDigest), + MessageType: 'DIGEST', + SigningAlgorithm: 'RSASSA_PSS_SHA_256' + })); + const signatureBytes = Buffer.from(signResp.Signature); + console.log('[KMS] Signature length:', signatureBytes.length, 'bytes'); + + // 6. Construire le CMS SignedData + const cmsDer = await pades.buildCmsSignedData(signedAttrs, signatureBytes, chainPem); + const cmsHex = cmsDer.toString('hex'); + console.log('[CMS] Built, hex length:', cmsHex.length); + + // 7. Finaliser le PDF avec la signature (remplacer UNIQUEMENT /Contents) + const finalPdf = pades.finalizePdfWithCms( + pdfWithRevision, + byteRange, + cmsHex + ); + + const finalSha256 = crypto.createHash('sha256').update(finalPdf).digest('hex'); + console.log('[FINAL] PDF size:', finalPdf.length, 'bytes, SHA256:', finalSha256); + + // 8. Upload vers S3 + const signedKey = `${SIGNED_PREFIX}${requestRef}.pdf`; + await s3.send(new PutObjectCommand({ + Bucket: BUCKET, + Key: signedKey, + Body: finalPdf, + ContentType: 'application/pdf', + Metadata: { + requestRef, + pades: 'BES', + sha256: finalSha256, + } + })); + + console.log('[SUCCESS] Signed PDF uploaded to:', signedKey); + + return { + statusCode: 200, + body: JSON.stringify({ + status: 'signed', + requestRef, + signed_pdf_s3_key: signedKey, + sha256: finalSha256 + }) + }; + } catch (err) { + console.error('[ERROR]', err); + return { + statusCode: 500, + body: JSON.stringify({ error: String(err), stack: err.stack }) + }; + } +}; + +async function streamToBuffer(stream) { + const chunks = []; + for await (const chunk of stream) chunks.push(chunk); + return Buffer.concat(chunks); +} diff --git a/lambda-odentas-pades-sign/index_backup.js b/lambda-odentas-pades-sign/index_backup.js new file mode 100644 index 0000000..dccfc06 --- /dev/null +++ b/lambda-odentas-pades-sign/index_backup.js @@ -0,0 +1,94 @@ +import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; +import { KMSClient, SignCommand } from '@aws-sdk/client-kms'; +import fs from 'node:fs/promises'; +import { PDFDocument } from 'pdf-lib'; +import * as createPAdES from './helpers/pades.js'; +import crypto from 'node:crypto'; + +const region = process.env.REGION || 'eu-west-3'; +const s3 = new S3Client({ region }); +const kms = new KMSClient({ region }); + +const BUCKET = process.env.BUCKET || 'odentas-sign'; +const SIGNED_PREFIX = process.env.SIGNED_PREFIX || 'signed-pades/'; +const CHAIN_S3_KEY = process.env.SIGNER_CHAIN_S3_KEY || 'certs/chain.pem'; +const KMS_KEY_ID = process.env.KMS_KEY_ID || ''; // ARN ou KeyId +const TSA_LAMBDA_NAME = process.env.TSA_LAMBDA_NAME || 'odentas-tsa-stamp'; + +export const handler = async (event) => { + try { + const requestRef = event.requestRef || `REQ-${Date.now()}`; + const sourceKey = event.sourceKey || event.pdfS3Key; + if (!sourceKey) throw new Error('sourceKey manquant'); + + // 1) TĂ©lĂ©charger PDF source + const get = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: sourceKey })); + const inputPdf = await streamToBuffer(get.Body); + + // 2) TĂ©lĂ©charger chain.pem (signer.crt [+ intermediate]) depuis S3 + const chainObj = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: CHAIN_S3_KEY })); + const chainData = await streamToBuffer(chainObj.Body); // Buffer (PEM texte ou DER) + + // 3) Construire le PDF avec placeholder signature et obtenir le "toSign" (byteRange bytes) + const { pdfWithPlaceholder, byteRangeInfo, pdfStructure, incrementalUpdate } = await createPAdES.preparePdfWithPlaceholder(inputPdf, requestRef); + + // 4) Calculer digest des SignedAttributes (signature-ready digest) via helper + const { signedAttributesDer, signedAttributesDigest, tempPdfWithRevision, contentsStart, contentsEnd } = await createPAdES.buildSignedAttributesDigest(pdfWithPlaceholder, byteRangeInfo, pdfStructure, incrementalUpdate); + + // 5) Demander Ă  KMS de signer le digest (MessageType = DIGEST) + if (!KMS_KEY_ID) throw new Error('KMS_KEY_ID non dĂ©fini'); + const signResp = await kms.send(new SignCommand({ + KeyId: KMS_KEY_ID, + Message: Buffer.from(signedAttributesDigest), + MessageType: 'DIGEST', + SigningAlgorithm: 'RSASSA_PSS_SHA_256' + })); + const signatureBytes = Buffer.from(signResp.Signature); + console.log('[KMS] Signature length:', signatureBytes.length, 'bytes'); + + // 6) Construire la structure PKCS#7 SignedData (pkijs helper) en injectant signatureBytes et chainPem + const cmsDer = await createPAdES.buildCmsSignedData(signedAttributesDer, signatureBytes, chainData); + + // 7) (optionnel) demander et intĂ©grer le token TSA (PAdES-T) — TODO pour v2 + + // 8) Injecter le CMS dans le PDF (Contents) et finaliser + const finalPdf = await createPAdES.finalizePdfWithCms(pdfWithPlaceholder, byteRangeInfo, pdfStructure, incrementalUpdate, cmsDer, tempPdfWithRevision, contentsStart, contentsEnd); + + // Hash utile pour vĂ©rification et traçabilitĂ© + const finalSha256 = crypto.createHash('sha256').update(finalPdf).digest('hex'); + console.info('[pades] final PDF bytes =', finalPdf.length, ' sha256=', finalSha256); + + // 9) Upload PDF final (avec mĂ©tadonnĂ©es) + const signedKey = `${SIGNED_PREFIX}${requestRef}.pdf`; + await s3.send(new PutObjectCommand({ + Bucket: BUCKET, + Key: signedKey, + Body: finalPdf, + ContentType: 'application/pdf', + Metadata: { + requestRef, + pades: 'BES-proto', + sha256: finalSha256, + } + })); + + return { + statusCode: 200, + body: JSON.stringify({ + status: 'signed', + requestRef, + signed_pdf_s3_key: signedKey, + sha256: finalSha256 + }) + }; + } catch (err) { + console.error(err); + return { statusCode: 500, body: JSON.stringify({ error: String(err) }) }; + } +}; + +async function streamToBuffer(stream) { + const chunks = []; + for await (const c of stream) chunks.push(c); + return Buffer.concat(chunks); +} diff --git a/lambda-odentas-pades-sign/out.json b/lambda-odentas-pades-sign/out.json new file mode 100644 index 0000000..f9a90b2 --- /dev/null +++ b/lambda-odentas-pades-sign/out.json @@ -0,0 +1 @@ +{"statusCode":200,"body":"{\"status\":\"signed\",\"requestRef\":\"CDDU-2025-0102\",\"signed_pdf_s3_key\":\"signed-pades/CDDU-2025-0102.pdf\",\"sha256\":\"f7f128afa4e1e7165fd1dd38cb87b72482bd7c3ea5c34289aa2fd402882cd771\"}"} \ No newline at end of file diff --git a/lambda-odentas-pades-sign/package.json b/lambda-odentas-pades-sign/package.json new file mode 100644 index 0000000..45b64d7 --- /dev/null +++ b/lambda-odentas-pades-sign/package.json @@ -0,0 +1,17 @@ +{ + "name": "odentas-pades-sign", + "version": "1.0.0", + "type": "module", + "main": "index.js", + "license": "MIT", + "dependencies": { + "@aws-sdk/client-kms": "^3.601.0", + "@aws-sdk/client-s3": "^3.601.0", + "@aws-sdk/client-lambda": "^3.601.0", + "asn1js": "^2.0.34", + "pkijs": "^2.1.97", + "pdf-lib": "^1.17.1", + "node-fetch": "^3.3.2", + "uuid": "^9.0.0" + } +} \ No newline at end of file diff --git a/lambda-tsaStamp/CDDU-2025-0102.tsr b/lambda-tsaStamp/CDDU-2025-0102.tsr new file mode 100644 index 0000000..6b758ed Binary files /dev/null and b/lambda-tsaStamp/CDDU-2025-0102.tsr differ diff --git a/lambda-tsaStamp/Dockerfile b/lambda-tsaStamp/Dockerfile new file mode 100644 index 0000000..f326c24 --- /dev/null +++ b/lambda-tsaStamp/Dockerfile @@ -0,0 +1,21 @@ +# Image Lambda Node.js 18 de base + installation d'OpenSSL et curl +FROM public.ecr.aws/lambda/nodejs:18 + +# Installer openssl & curl (pour POST binaire vers la TSA) +RUN yum -y install openssl curl && yum clean all + +# Copier les sources +COPY package.json package-lock.json* ./ +RUN npm ci --omit=dev || npm i --omit=dev + +COPY index.js ./ + +# Variables d'env par dĂ©faut (surchargĂ©es dans la console Lambda) +ENV BUCKET="odentas-sign" \ + TSA_URL="https://timestamp.sectigo.com" \ + DEFAULT_TSR_PREFIX="evidence/tsa/" \ + DEFAULT_REQ_PREFIX="evidence/tsq/" \ + REGION="eu-west-3" + +# Handler +CMD ["index.handler"] \ No newline at end of file diff --git a/lambda-tsaStamp/event.json b/lambda-tsaStamp/event.json new file mode 100644 index 0000000..fcb2fc0 --- /dev/null +++ b/lambda-tsaStamp/event.json @@ -0,0 +1,4 @@ +{ + "requestRef": "CDDU-2025-0102", + "pdfS3Key": "source/contrat_cddu_2025_0102.pdf" +} diff --git a/lambda-tsaStamp/iam-policy-s3.json b/lambda-tsaStamp/iam-policy-s3.json new file mode 100644 index 0000000..6579b3a --- /dev/null +++ b/lambda-tsaStamp/iam-policy-s3.json @@ -0,0 +1,9 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { "Sid": "S3AccessOdentasSign", "Effect": "Allow", + "Action": ["s3:GetObject","s3:PutObject"], + "Resource": ["arn:aws:s3:::odentas-sign/*"] + } + ] +} diff --git a/lambda-tsaStamp/iam-policy.json b/lambda-tsaStamp/iam-policy.json new file mode 100644 index 0000000..d2fff1d --- /dev/null +++ b/lambda-tsaStamp/iam-policy.json @@ -0,0 +1,26 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "S3AccessOdentasSign", + "Effect": "Allow", + "Action": [ + "s3:GetObject", + "s3:PutObject" + ], + "Resource": [ + "arn:aws:s3:::odentas-sign/*" + ] + }, + { + "Sid": "Logs", + "Effect": "Allow", + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Resource": "*" + } + ] +} \ No newline at end of file diff --git a/lambda-tsaStamp/index.js b/lambda-tsaStamp/index.js new file mode 100644 index 0000000..de07efb --- /dev/null +++ b/lambda-tsaStamp/index.js @@ -0,0 +1,110 @@ +import { S3Client, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"; +import crypto from "node:crypto"; +import { spawn } from "node:child_process"; +import { readFile, writeFile } from "node:fs/promises"; +import { createWriteStream } from "node:fs"; +import { pipeline } from "node:stream/promises"; + +const s3 = new S3Client({ region: process.env.REGION || process.env.AWS_REGION || "eu-west-3" }); +const BUCKET = process.env.BUCKET; +const TSA_URL = process.env.TSA_URL || "https://timestamp.sectigo.com"; +const DEFAULT_TSR_PREFIX = process.env.DEFAULT_TSR_PREFIX || "evidence/tsa/"; +const DEFAULT_REQ_PREFIX = process.env.DEFAULT_REQ_PREFIX || "evidence/tsq/"; + +/** + * Attends un event JSON de la forme : + * { + * "requestRef": "CDDU-2025-0102", + * "pdfSha256": "", // optionnel si pdfS3Key prĂ©sent + * "pdfS3Key": "source/contrat.pdf", // optionnel si pdfSha256 prĂ©sent + * "tsrS3Key": "evidence/tsa/CDDU-2025-0102.tsr" // optionnel (sinon gĂ©nĂ©rĂ©) + * } + */ +export const handler = async (event = {}) => { + try { + const requestRef = event.requestRef || randomRef(); + + let pdfSha256 = (event.pdfSha256 || "").trim(); + if (!pdfSha256 && event.pdfS3Key) { + pdfSha256 = await sha256OfS3Object(BUCKET, event.pdfS3Key); + } + if (!pdfSha256 || !/^[0-9a-fA-F]{64}$/.test(pdfSha256)) { + throw new Error("pdfSha256 manquant ou invalide (attendu : hex 64 chars)"); + } + + // 1) GĂ©nĂ©rer la requĂȘte RFC3161 (.tsq) via openssl + const tsqPath = `/tmp/${requestRef}.tsq`; + await genTsqWithOpenssl(pdfSha256, tsqPath); + + // 2) Appeler la TSA + const tsrPath = `/tmp/${requestRef}.tsr`; + await postTsqToTsa(tsqPath, TSA_URL, tsrPath); + + // 3) Hasher la rĂ©ponse TSA + const tsrBuf = await readFile(tsrPath); + const tsrSha256 = crypto.createHash("sha256").update(tsrBuf).digest("hex"); + + // 4) Uploader .tsq et .tsr dans S3 + const tsqKey = event.tsqS3Key || `${DEFAULT_REQ_PREFIX}${requestRef}.tsq`; + const tsrKey = event.tsrS3Key || `${DEFAULT_TSR_PREFIX}${requestRef}.tsr`; + + await s3.send(new PutObjectCommand({ Bucket: BUCKET, Key: tsqKey, Body: await readFile(tsqPath), ContentType: "application/timestamp-query" })); + await s3.send(new PutObjectCommand({ Bucket: BUCKET, Key: tsrKey, Body: tsrBuf, ContentType: "application/timestamp-reply" })); + + // 5) RĂ©ponse + return ok({ + requestRef, + tsa_url: TSA_URL, + pdf_sha256: pdfSha256.toLowerCase(), + tsq_s3_key: tsqKey, + tsr_s3_key: tsrKey, + tsr_sha256: tsrSha256, + message: "RFC3161 timestamp acquired" + }); + } catch (err) { + console.error(err); + return errResp(err); + } +}; + +// Utilitaires +function randomRef() { + return `TS-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; +} + +async function sha256OfS3Object(bucket, key) { + const res = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key })); + const hash = crypto.createHash("sha256"); + await pipeline(res.Body, hash); + return hash.digest("hex"); +} + +async function genTsqWithOpenssl(hexDigest, outPath) { + // openssl ts -query -sha256 -digest -cert -no_nonce -out /tmp/req.tsq + await exec("openssl", ["ts", "-query", "-sha256", "-digest", hexDigest, "-cert", "-no_nonce", "-out", outPath]); +} + +async function postTsqToTsa(tsqPath, url, outPath) { + // curl -sS -H "Content-Type: application/timestamp-query" --data-binary @file url > out + await exec("curl", ["-sS", "-H", "Content-Type: application/timestamp-query", "--data-binary", `@${tsqPath}`, url, "-o", outPath]); +} + +async function exec(cmd, args) { + return new Promise((resolve, reject) => { + const p = spawn(cmd, args); + let stderr = ""; + p.stderr.on("data", (d) => (stderr += d.toString())); + p.on("exit", (code) => { + if (code === 0) resolve(0); + else reject(new Error(`${cmd} exited with ${code}: ${stderr}`)); + }); + }); +} + +function ok(payload) { + return { statusCode: 200, headers: { "content-type": "application/json" }, body: JSON.stringify(payload) }; +} +function errResp(err) { + return { statusCode: 500, headers: { "content-type": "application/json" }, body: JSON.stringify({ error: String(err) }) }; +} + diff --git a/lambda-tsaStamp/out.json b/lambda-tsaStamp/out.json new file mode 100644 index 0000000..59f512f --- /dev/null +++ b/lambda-tsaStamp/out.json @@ -0,0 +1 @@ +{"statusCode":200,"headers":{"content-type":"application/json"},"body":"{\"requestRef\":\"CDDU-2025-0102\",\"tsa_url\":\"https://timestamp.sectigo.com\",\"pdf_sha256\":\"fc36c6ebeeacde3fe3b2dd6441a5229d2bff58088766ad5360283d8059afee1e\",\"tsq_s3_key\":\"evidence/tsq/CDDU-2025-0102.tsq\",\"tsr_s3_key\":\"evidence/tsa/CDDU-2025-0102.tsr\",\"tsr_sha256\":\"808b3e0a87c42b9d2d148d2361b8e88cf4d1f0df35e420c0404421c7dae09001\",\"message\":\"RFC3161 timestamp acquired\"}"} \ No newline at end of file diff --git a/lambda-tsaStamp/package.json b/lambda-tsaStamp/package.json new file mode 100644 index 0000000..4f08fe1 --- /dev/null +++ b/lambda-tsaStamp/package.json @@ -0,0 +1,11 @@ +{ + "name": "odentas-tsa-stamp", + "version": "1.0.0", + "type": "module", + "main": "index.js", + "license": "MIT", + "dependencies": { + "@aws-sdk/client-s3": "^3.601.0", + "@aws-sdk/s3-request-presigner": "^3.601.0" + } +} diff --git a/lambda-tsaStamp/trust-policy.json b/lambda-tsaStamp/trust-policy.json new file mode 100644 index 0000000..b5e592a --- /dev/null +++ b/lambda-tsaStamp/trust-policy.json @@ -0,0 +1,6 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { "Effect": "Allow", "Principal": { "Service": "lambda.amazonaws.com" }, "Action": "sts:AssumeRole" } + ] +} diff --git a/lib/odentas-sign/crypto.ts b/lib/odentas-sign/crypto.ts new file mode 100644 index 0000000..4d8b13e --- /dev/null +++ b/lib/odentas-sign/crypto.ts @@ -0,0 +1,81 @@ +import crypto from 'crypto'; +import bcrypt from 'bcryptjs'; + +/** + * GĂ©nĂšre un code OTP Ă  6 chiffres + */ +export function generateOTP(): string { + return crypto.randomInt(100000, 999999).toString(); +} + +/** + * Hash un code OTP avec bcrypt + */ +export async function hashOTP(otp: string): Promise { + const salt = await bcrypt.genSalt(10); + return bcrypt.hash(otp, salt); +} + +/** + * VĂ©rifie un code OTP contre son hash + */ +export async function verifyOTP(otp: string, hash: string): Promise { + return bcrypt.compare(otp, hash); +} + +/** + * Calcule la date d'expiration d'un OTP (15 minutes) + */ +export function getOTPExpiration(): Date { + const now = new Date(); + now.setMinutes(now.getMinutes() + 15); + return now; +} + +/** + * VĂ©rifie si un OTP est expirĂ© + */ +export function isOTPExpired(expiresAt: string | null): boolean { + if (!expiresAt) return true; + return new Date(expiresAt) < new Date(); +} + +/** + * GĂ©nĂšre une rĂ©fĂ©rence unique pour une demande de signature + * Format: REQ-YYYYMMDD-XXXXXX (6 caractĂšres alĂ©atoires) + */ +export function generateRequestRef(contractRef?: string): string { + if (contractRef) { + // Si on a une rĂ©fĂ©rence de contrat, l'utiliser comme base + return contractRef; + } + + const date = new Date(); + const dateStr = date.toISOString().slice(0, 10).replace(/-/g, ''); + const random = crypto.randomBytes(3).toString('hex').toUpperCase(); + return `REQ-${dateStr}-${random}`; +} + +/** + * GĂ©nĂšre un nom de fichier sĂ©curisĂ© pour S3 + */ +export function sanitizeFilename(filename: string): string { + return filename + .replace(/[^a-zA-Z0-9._-]/g, '_') + .replace(/_{2,}/g, '_') + .substring(0, 255); +} + +/** + * Calcule le SHA-256 d'un buffer + */ +export function calculateSHA256(buffer: Buffer): string { + return crypto.createHash('sha256').update(buffer).digest('hex'); +} + +/** + * GĂ©nĂšre un ID de session unique + */ +export function generateSessionId(): string { + return crypto.randomBytes(32).toString('hex'); +} diff --git a/lib/odentas-sign/index.ts b/lib/odentas-sign/index.ts new file mode 100644 index 0000000..50eadf4 --- /dev/null +++ b/lib/odentas-sign/index.ts @@ -0,0 +1,12 @@ +/** + * Odentas Sign - SystĂšme de signature Ă©lectronique souverain + * + * Ce module fournit tous les utilitaires pour gĂ©rer les signatures Ă©lectroniques + * avec conformitĂ© eIDAS, scellage PAdES, horodatage TSA et archivage Ă  10 ans. + */ + +export * from './types'; +export * from './crypto'; +export * from './jwt'; +export * from './s3'; +export * from './supabase'; diff --git a/lib/odentas-sign/jwt.ts b/lib/odentas-sign/jwt.ts new file mode 100644 index 0000000..15ff526 --- /dev/null +++ b/lib/odentas-sign/jwt.ts @@ -0,0 +1,50 @@ +import jwt from 'jsonwebtoken'; +import type { SignatureSessionToken } from './types'; + +const JWT_SECRET = process.env.JWT_SECRET || process.env.NEXTAUTH_SECRET || 'odentas-sign-secret-key-change-in-production'; +const JWT_EXPIRATION = '30m'; // 30 minutes + +/** + * CrĂ©e un JWT pour une session de signature + */ +export function createSignatureSession(payload: { + signerId: string; + requestId: string; + email: string; + role: string; +}): string { + return jwt.sign(payload, JWT_SECRET, { + expiresIn: JWT_EXPIRATION, + issuer: 'odentas-sign', + audience: 'signature-session' + }); +} + +/** + * VĂ©rifie et dĂ©code un JWT de session de signature + */ +export function verifySignatureSession(token: string): SignatureSessionToken | null { + try { + const decoded = jwt.verify(token, JWT_SECRET, { + issuer: 'odentas-sign', + audience: 'signature-session' + }) as SignatureSessionToken; + + return decoded; + } catch (error) { + console.error('[JWT] Erreur de vĂ©rification:', error); + return null; + } +} + +/** + * Extrait le token JWT depuis le header Authorization + */ +export function extractTokenFromHeader(authHeader: string | null): string | null { + if (!authHeader) return null; + + const parts = authHeader.split(' '); + if (parts.length !== 2 || parts[0] !== 'Bearer') return null; + + return parts[1]; +} diff --git a/lib/odentas-sign/s3.ts b/lib/odentas-sign/s3.ts new file mode 100644 index 0000000..9db094c --- /dev/null +++ b/lib/odentas-sign/s3.ts @@ -0,0 +1,187 @@ +import { S3Client, PutObjectCommand, GetObjectCommand, CopyObjectCommand } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { calculateSHA256, sanitizeFilename } from './crypto'; + +const region = process.env.AWS_REGION || 'eu-west-3'; +const s3Client = new S3Client({ region }); + +const BUCKET = process.env.ODENTAS_SIGN_BUCKET || 'odentas-sign'; + +// PrĂ©fixes des dossiers dans le bucket +export const S3_PREFIXES = { + SOURCE: 'source/', + SIGNED: 'signed/', + EVIDENCE: 'evidence/', + SIGNATURES: 'signatures/', + CERTS: 'certs/', +} as const; + +/** + * Upload un fichier vers S3 + */ +export async function uploadToS3(params: { + key: string; + body: Buffer | string; + contentType?: string; + metadata?: Record; +}): Promise<{ key: string; sha256: string }> { + const { key, body, contentType = 'application/octet-stream', metadata = {} } = params; + + const bodyBuffer = Buffer.isBuffer(body) ? body : Buffer.from(body); + const sha256 = calculateSHA256(bodyBuffer); + + await s3Client.send(new PutObjectCommand({ + Bucket: BUCKET, + Key: key, + Body: bodyBuffer, + ContentType: contentType, + Metadata: { + ...metadata, + sha256, + uploaded_at: new Date().toISOString(), + }, + })); + + console.log(`[S3] ✅ Uploaded: ${key} (${bodyBuffer.length} bytes, SHA256: ${sha256})`); + + return { key, sha256 }; +} + +/** + * TĂ©lĂ©charge un fichier depuis S3 + */ +export async function downloadFromS3(key: string): Promise { + const response = await s3Client.send(new GetObjectCommand({ + Bucket: BUCKET, + Key: key, + })); + + if (!response.Body) { + throw new Error(`Fichier introuvable: ${key}`); + } + + const chunks: Uint8Array[] = []; + for await (const chunk of response.Body as any) { + chunks.push(chunk); + } + + return Buffer.concat(chunks); +} + +/** + * GĂ©nĂšre une URL prĂ©-signĂ©e pour tĂ©lĂ©charger un fichier + */ +export async function getPresignedDownloadUrl(key: string, expiresIn: number = 3600): Promise { + const command = new GetObjectCommand({ + Bucket: BUCKET, + Key: key, + }); + + return await getSignedUrl(s3Client, command, { expiresIn }); +} + +/** + * Upload une image de signature + */ +export async function uploadSignatureImage(params: { + requestId: string; + signerId: string; + imageBase64: string; +}): Promise { + const { requestId, signerId, imageBase64 } = params; + + // Extraire le type MIME et les donnĂ©es + const matches = imageBase64.match(/^data:([A-Za-z-+\/]+);base64,(.+)$/); + if (!matches || matches.length !== 3) { + throw new Error('Format image base64 invalide'); + } + + const mimeType = matches[1]; + const data = matches[2]; + const buffer = Buffer.from(data, 'base64'); + + // DĂ©terminer l'extension + const extension = mimeType === 'image/png' ? 'png' : 'jpg'; + + // ClĂ© S3 + const key = `${S3_PREFIXES.SIGNATURES}${requestId}/${signerId}.${extension}`; + + await uploadToS3({ + key, + body: buffer, + contentType: mimeType, + metadata: { + request_id: requestId, + signer_id: signerId, + }, + }); + + return key; +} + +/** + * Upload le bundle de preuves (evidence) + */ +export async function uploadEvidenceBundle(params: { + requestRef: string; + evidence: any; +}): Promise { + const { requestRef, evidence } = params; + + const key = `${S3_PREFIXES.EVIDENCE}${sanitizeFilename(requestRef)}.json`; + + await uploadToS3({ + key, + body: JSON.stringify(evidence, null, 2), + contentType: 'application/json', + metadata: { + request_ref: requestRef, + created_at: new Date().toISOString(), + }, + }); + + return key; +} + +/** + * Copie un fichier vers le dossier d'archivage avec Object Lock + */ +export async function copyToArchive(params: { + sourceKey: string; + destinationKey: string; + retainUntilDate: Date; +}): Promise { + const { sourceKey, destinationKey, retainUntilDate } = params; + + await s3Client.send(new CopyObjectCommand({ + Bucket: BUCKET, + CopySource: `${BUCKET}/${sourceKey}`, + Key: destinationKey, + ObjectLockMode: 'COMPLIANCE', + ObjectLockRetainUntilDate: retainUntilDate, + Metadata: { + archived_at: new Date().toISOString(), + retain_until: retainUntilDate.toISOString(), + }, + })); + + console.log(`[S3] 🔒 Archived with Object Lock: ${destinationKey} (retain until ${retainUntilDate.toISOString()})`); +} + +/** + * VĂ©rifie si un fichier existe dans S3 + */ +export async function fileExistsInS3(key: string): Promise { + try { + await s3Client.send(new GetObjectCommand({ + Bucket: BUCKET, + Key: key, + })); + return true; + } catch (error: any) { + if (error.name === 'NoSuchKey' || error.$metadata?.httpStatusCode === 404) { + return false; + } + throw error; + } +} diff --git a/lib/odentas-sign/supabase.ts b/lib/odentas-sign/supabase.ts new file mode 100644 index 0000000..76697b8 --- /dev/null +++ b/lib/odentas-sign/supabase.ts @@ -0,0 +1,87 @@ +import { createClient } from '@supabase/supabase-js'; + +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; +const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; + +if (!supabaseUrl || !supabaseServiceKey) { + throw new Error('Variables d\'environnement Supabase manquantes'); +} + +/** + * Client Supabase avec service role pour contourner les RLS + */ +export const supabaseAdmin = createClient(supabaseUrl, supabaseServiceKey, { + auth: { + autoRefreshToken: false, + persistSession: false, + }, +}); + +/** + * Logger un Ă©vĂ©nement dans sign_events + */ +export async function logSignEvent(params: { + requestId: string; + signerId?: string; + event: string; + ip?: string; + userAgent?: string; + metadata?: Record; +}): Promise { + const { requestId, signerId, event, ip, userAgent, metadata } = params; + + const { error } = await supabaseAdmin + .from('sign_events') + .insert({ + request_id: requestId, + signer_id: signerId || null, + event, + ip: ip || null, + user_agent: userAgent || null, + metadata: metadata || null, + }); + + if (error) { + console.error('[SUPABASE] Erreur lors du logging:', error); + throw error; + } + + console.log(`[EVENT] ✅ ${event} (request: ${requestId}, signer: ${signerId || 'N/A'})`); +} + +/** + * RĂ©cupĂšre tous les Ă©vĂ©nements d'une demande + */ +export async function getSignEvents(requestId: string) { + const { data, error } = await supabaseAdmin + .from('sign_events') + .select('*') + .eq('request_id', requestId) + .order('ts', { ascending: true }); + + if (error) { + console.error('[SUPABASE] Erreur rĂ©cupĂ©ration Ă©vĂ©nements:', error); + throw error; + } + + return data; +} + +/** + * VĂ©rifie si tous les signataires ont signĂ© + */ +export async function checkAllSignersSigned(requestId: string): Promise { + const { data: signers, error } = await supabaseAdmin + .from('signers') + .select('id, signed_at') + .eq('request_id', requestId); + + if (error) { + console.error('[SUPABASE] Erreur vĂ©rification signataires:', error); + throw error; + } + + if (!signers || signers.length === 0) return false; + + return signers.every(s => s.signed_at !== null); +} diff --git a/lib/odentas-sign/types.ts b/lib/odentas-sign/types.ts new file mode 100644 index 0000000..bbb866d --- /dev/null +++ b/lib/odentas-sign/types.ts @@ -0,0 +1,152 @@ +/** + * Types pour Odentas Sign - SystĂšme de signature Ă©lectronique souverain + */ + +export interface SignRequest { + id: string; + ref: string; + title: string; + source_s3_key: string; + status: 'pending' | 'in_progress' | 'completed' | 'cancelled'; + created_at: string; +} + +export interface Signer { + id: string; + request_id: string; + role: 'Employeur' | 'SalariĂ©'; + name: string; + email: string; + otp_hash: string | null; + otp_expires_at: string | null; + otp_attempts: number; + otp_last_sent_at: string | null; + signed_at: string | null; + signature_image_s3: string | null; + consent_text: string | null; + consent_at: string | null; + ip_signed: string | null; + user_agent: string | null; +} + +export interface SignPosition { + id: string; + request_id: string; + role: 'Employeur' | 'SalariĂ©'; + page: number; + x: number; + y: number; + w: number; + h: number; + kind: 'signature' | 'text' | 'date' | 'checkbox'; + label: string | null; + created_at: string; +} + +export interface SignEvent { + id: number; + request_id: string; + signer_id: string | null; + ts: string; + event: string; + ip: string | null; + user_agent: string | null; + metadata: Record | null; +} + +export interface SignAsset { + request_id: string; + signed_pdf_s3_key: string | null; + evidence_json_s3_key: string | null; + tsa_tsr_s3_key: string | null; + pdf_sha256: string | null; + tsa_token_sha256: string | null; + sealed_at: string | null; + seal_algo: string | null; + seal_kms_key_id: string | null; + tsa_policy_oid: string | null; + tsa_serial: string | null; + retain_until: string | null; +} + +export interface CreateSignRequestInput { + contractId: string; + contractRef: string; + pdfS3Key: string; + title: string; + signers: { + role: 'Employeur' | 'SalariĂ©'; + name: string; + email: string; + }[]; + positions: { + role: 'Employeur' | 'SalariĂ©'; + page: number; + x: number; + y: number; + w: number; + h: number; + kind?: 'signature' | 'text' | 'date' | 'checkbox'; + label?: string; + }[]; +} + +export interface SignatureSessionToken { + signerId: string; + requestId: string; + email: string; + role: string; + iat: number; + exp: number; +} + +export interface EvidenceBundle { + request_id: string; + request_ref: string; + title: string; + created_at: string; + completed_at: string; + eidas_level: 'SES' | 'AES' | 'QES'; + signers: { + id: string; + role: string; + name: string; + email: string; + signed_at: string; + ip_address: string; + user_agent: string; + consent_text: string; + consent_at: string; + signature_method: 'drawn' | 'uploaded'; + authentication: { + method: 'OTP'; + otp_sent_at: string; + otp_verified_at: string; + email_verified: true; + }; + }[]; + events: { + timestamp: string; + event: string; + actor: string | null; + ip: string | null; + metadata: Record | null; + }[]; + seal: { + algorithm: string; + kms_key_id: string; + sealed_at: string; + pdf_sha256: string; + }; + tsa: { + url: string; + tsr_sha256: string; + policy_oid: string | null; + serial: string | null; + }; + retention: { + archive_key: string; + retain_until: string; + compliance_mode: 'GOVERNANCE' | 'COMPLIANCE'; + }; +} diff --git a/package-lock.json b/package-lock.json index ea2c092..2e1103e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,22 +13,32 @@ "@aws-sdk/client-ses": "^3.896.0", "@aws-sdk/s3-request-presigner": "^3.894.0", "@radix-ui/react-dialog": "^1.1.15", + "@react-pdf-viewer/core": "^3.12.0", + "@react-pdf-viewer/default-layout": "^3.12.0", "@supabase/auth-helpers-nextjs": "^0.10.0", "@supabase/ssr": "^0.7.0", "@supabase/supabase-js": "^2.57.4", "@tanstack/react-query": "^5.56.2", + "@types/bcryptjs": "^2.4.6", + "@types/jsonwebtoken": "^9.0.10", "@types/qrcode": "^1.5.5", "aws-sdk": "^2.1692.0", "axios": "^1.12.2", + "bcryptjs": "^3.0.2", + "canvas-confetti": "^1.9.4", "clsx": "^2.1.1", "cmdk": "^1.1.1", "dotenv": "^17.2.2", "framer-motion": "^12.23.24", "html2canvas": "^1.4.1", "js-cookie": "^3.0.5", + "jsonwebtoken": "^9.0.2", "lucide-react": "^0.460.0", "next": "^14.2.5", "nprogress": "^0.2.0", + "pdf-lib": "^1.17.1", + "pdf-parse": "^2.4.5", + "pdfjs-dist": "^3.11.174", "posthog-js": "^1.275.1", "posthog-node": "^5.9.5", "qrcode": "^1.5.4", @@ -40,6 +50,7 @@ "use-debounce": "^10.0.6" }, "devDependencies": { + "@types/canvas-confetti": "^1.9.0", "@types/js-cookie": "^3.0.6", "@types/node": "24.3.1", "@types/react": "19.1.12", @@ -1371,6 +1382,211 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", + "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz", + "integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==", + "license": "MIT", + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.80", + "@napi-rs/canvas-darwin-arm64": "0.1.80", + "@napi-rs/canvas-darwin-x64": "0.1.80", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.80", + "@napi-rs/canvas-linux-arm64-musl": "0.1.80", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.80", + "@napi-rs/canvas-linux-x64-gnu": "0.1.80", + "@napi-rs/canvas-linux-x64-musl": "0.1.80", + "@napi-rs/canvas-win32-x64-msvc": "0.1.80" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz", + "integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz", + "integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz", + "integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz", + "integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz", + "integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz", + "integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz", + "integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz", + "integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz", + "integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz", + "integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -1592,6 +1808,24 @@ "node": ">=12.4.0" } }, + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", + "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", + "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.10" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1940,6 +2174,254 @@ } } }, + "node_modules/@react-pdf-viewer/attachment": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@react-pdf-viewer/attachment/-/attachment-3.12.0.tgz", + "integrity": "sha512-mhwrYJSIpCvHdERpLUotqhMgSjhtF+BTY1Yb9Fnzpcq3gLZP+Twp5Rynq21tCrVdDizPaVY7SKu400GkgdMfZw==", + "license": "https://react-pdf-viewer.dev/license", + "dependencies": { + "@react-pdf-viewer/core": "3.12.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@react-pdf-viewer/bookmark": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@react-pdf-viewer/bookmark/-/bookmark-3.12.0.tgz", + "integrity": "sha512-i7nEit8vIFMAES8RFGwprZ9cXOOZb9ZStPW6E6yuObJEXcvBj/ctsbBJGZxqUZOGklM0JoB7sjHyxAriHfe92A==", + "license": "https://react-pdf-viewer.dev/license", + "dependencies": { + "@react-pdf-viewer/core": "3.12.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@react-pdf-viewer/core": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@react-pdf-viewer/core/-/core-3.12.0.tgz", + "integrity": "sha512-8MsdlQJ4jaw3GT+zpCHS33nwnvzpY0ED6DEahZg9WngG++A5RMhk8LSlxdHelwaFFHFiXBjmOaj2Kpxh50VQRg==", + "license": "https://react-pdf-viewer.dev/license", + "peerDependencies": { + "pdfjs-dist": "^2.16.105 || ^3.0.279", + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@react-pdf-viewer/default-layout": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@react-pdf-viewer/default-layout/-/default-layout-3.12.0.tgz", + "integrity": "sha512-K2fS4+TJynHxxCBFuIDiFuAw3nqOh4bkBgtVZ/2pGvnFn9lLg46YGLMnTXCQqtyZzzXYh696jmlFViun3is4pA==", + "license": "https://react-pdf-viewer.dev/license", + "dependencies": { + "@react-pdf-viewer/attachment": "3.12.0", + "@react-pdf-viewer/bookmark": "3.12.0", + "@react-pdf-viewer/core": "3.12.0", + "@react-pdf-viewer/thumbnail": "3.12.0", + "@react-pdf-viewer/toolbar": "3.12.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@react-pdf-viewer/full-screen": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@react-pdf-viewer/full-screen/-/full-screen-3.12.0.tgz", + "integrity": "sha512-hQouJ26QUaRBCXNMU1aI1zpJn4l4PJRvlHhuE2dZYtLl37ycjl7vBCQYZW1FwnuxMWztZsY47R43DKaZORg0pg==", + "license": "https://react-pdf-viewer.dev/license", + "dependencies": { + "@react-pdf-viewer/core": "3.12.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@react-pdf-viewer/get-file": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@react-pdf-viewer/get-file/-/get-file-3.12.0.tgz", + "integrity": "sha512-Uhq45n2RWlZ7Ec/BtBJ0WQESRciaYIltveDXHNdWvXgFdOS8XsvB+mnTh/wzm7Cfl9hpPyzfeezifdU9AkQgQg==", + "license": "https://react-pdf-viewer.dev/license", + "dependencies": { + "@react-pdf-viewer/core": "3.12.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@react-pdf-viewer/open": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@react-pdf-viewer/open/-/open-3.12.0.tgz", + "integrity": "sha512-vhiDEYsiQLxvZkIKT9VPYHZ1BOnv46x9eCEmRWxO1DJ8fa/GRDTA9ivXmq/ap0dGEJs6t+epleCkCEfllLR/Yw==", + "license": "https://react-pdf-viewer.dev/license", + "dependencies": { + "@react-pdf-viewer/core": "3.12.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@react-pdf-viewer/page-navigation": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@react-pdf-viewer/page-navigation/-/page-navigation-3.12.0.tgz", + "integrity": "sha512-tVEJ48Dd5kajV1nKkrPWijglJRNBiKBTyYDKVexhiRdTHUP1f6QQXiSyDgCUb0IGSZeJzOJb1h7ApKHe8OTtuw==", + "license": "https://react-pdf-viewer.dev/license", + "dependencies": { + "@react-pdf-viewer/core": "3.12.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@react-pdf-viewer/print": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@react-pdf-viewer/print/-/print-3.12.0.tgz", + "integrity": "sha512-xJn76CgbU/M2iNaN7wLHTg+sdOekkRMfCakFLwPrE+SR7qD6NUF4vQQKJBSVCCK5bUijzb6cWfKGfo8VA72o4Q==", + "license": "https://react-pdf-viewer.dev/license", + "dependencies": { + "@react-pdf-viewer/core": "3.12.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@react-pdf-viewer/properties": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@react-pdf-viewer/properties/-/properties-3.12.0.tgz", + "integrity": "sha512-dYTCHtVwFNkpDo7QxL2qk/8zAKndLwdD1FFxBftl6jIlQbtvNdxkFfkv1HcQING9Ic+7DBryOiD7W0ze4IERYg==", + "license": "https://react-pdf-viewer.dev/license", + "dependencies": { + "@react-pdf-viewer/core": "3.12.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@react-pdf-viewer/rotate": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@react-pdf-viewer/rotate/-/rotate-3.12.0.tgz", + "integrity": "sha512-yaxaMYPChvNOjR8+AxRmj0kvojyJKPq4XHEcIB2lJJgBY1Zra3mliDUP3Nlb4yV8BS9+yBqWn9U9mtnopQD+tw==", + "license": "https://react-pdf-viewer.dev/license", + "dependencies": { + "@react-pdf-viewer/core": "3.12.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@react-pdf-viewer/scroll-mode": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@react-pdf-viewer/scroll-mode/-/scroll-mode-3.12.0.tgz", + "integrity": "sha512-okII7Xqhl6cMvl1izdEvlXNJ+vJVq/qdg53hJIDYVgBCWskLk/cpjUg/ZonBxseG9lIDP3w2VO1McT8Gn11OAg==", + "license": "https://react-pdf-viewer.dev/license", + "dependencies": { + "@react-pdf-viewer/core": "3.12.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@react-pdf-viewer/search": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@react-pdf-viewer/search/-/search-3.12.0.tgz", + "integrity": "sha512-jAkLpis49fsDDY/HrbUZIOIhzF5vynONQNA4INQKI38r/MjveblrkNv7qbr9j5lQ/WFic5+gD1e+Mtpf1/7DiA==", + "license": "https://react-pdf-viewer.dev/license", + "dependencies": { + "@react-pdf-viewer/core": "3.12.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@react-pdf-viewer/selection-mode": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@react-pdf-viewer/selection-mode/-/selection-mode-3.12.0.tgz", + "integrity": "sha512-yysWEu2aCtBvzSgbhgI9kT5cq2hf0FU6Z+3B7MMXz14Kxyc3y18wUqxtgbvpFEfWF0bNUUq16JtWRljtxvZ83w==", + "license": "https://react-pdf-viewer.dev/license", + "dependencies": { + "@react-pdf-viewer/core": "3.12.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@react-pdf-viewer/theme": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@react-pdf-viewer/theme/-/theme-3.12.0.tgz", + "integrity": "sha512-cdBi+wR1VOZ6URCcO9plmAZQu4ZGFcd7HJdBe7VIFiGyrvl9I/Of74ONLycnDImSuONt8D3uNjPBLieeaShVeg==", + "license": "https://react-pdf-viewer.dev/license", + "dependencies": { + "@react-pdf-viewer/core": "3.12.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@react-pdf-viewer/thumbnail": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@react-pdf-viewer/thumbnail/-/thumbnail-3.12.0.tgz", + "integrity": "sha512-Vc8j3bO6wumWZV4o6pAbktPWKDSC9tQAzOCJ3cof541u4i44C11ccYC4W9aNcsMMUSO3bNwAGWtP8OFthV5akQ==", + "license": "https://react-pdf-viewer.dev/license", + "dependencies": { + "@react-pdf-viewer/core": "3.12.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@react-pdf-viewer/toolbar": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@react-pdf-viewer/toolbar/-/toolbar-3.12.0.tgz", + "integrity": "sha512-qACTU3qXHgtNK8J+T13EWio+0liilj86SJ87BdapqXynhl720OKPlSKOQqskUGqg3oTUJAhrse9XG6SFdHJx+g==", + "license": "https://react-pdf-viewer.dev/license", + "dependencies": { + "@react-pdf-viewer/core": "3.12.0", + "@react-pdf-viewer/full-screen": "3.12.0", + "@react-pdf-viewer/get-file": "3.12.0", + "@react-pdf-viewer/open": "3.12.0", + "@react-pdf-viewer/page-navigation": "3.12.0", + "@react-pdf-viewer/print": "3.12.0", + "@react-pdf-viewer/properties": "3.12.0", + "@react-pdf-viewer/rotate": "3.12.0", + "@react-pdf-viewer/scroll-mode": "3.12.0", + "@react-pdf-viewer/search": "3.12.0", + "@react-pdf-viewer/selection-mode": "3.12.0", + "@react-pdf-viewer/theme": "3.12.0", + "@react-pdf-viewer/zoom": "3.12.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@react-pdf-viewer/zoom": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@react-pdf-viewer/zoom/-/zoom-3.12.0.tgz", + "integrity": "sha512-V0GUTyPM77+LzhoKX+T3XI10/HfGdqRTbgeP7ID60FCzcwu6kXWqJn5tzabjDKLTlFv8mJmn0aa/ppkIU97nfA==", + "license": "https://react-pdf-viewer.dev/license", + "dependencies": { + "@react-pdf-viewer/core": "3.12.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -2851,6 +3333,19 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "license": "MIT" + }, + "node_modules/@types/canvas-confetti": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz", + "integrity": "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/js-cookie": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", @@ -2865,6 +3360,22 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.3.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz", @@ -3492,6 +4003,13 @@ "win32" ] }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC", + "optional": true + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -3515,6 +4033,19 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -3577,6 +4108,28 @@ "node": ">= 8" } }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "license": "ISC", + "optional": true + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -3906,7 +4459,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/base64-arraybuffer": { @@ -3948,6 +4501,15 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bcryptjs": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz", + "integrity": "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -3971,7 +4533,7 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -4036,6 +4598,12 @@ "isarray": "^1.0.0" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -4143,6 +4711,32 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/canvas/-/canvas-2.11.2.tgz", + "integrity": "sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.0", + "nan": "^2.17.0", + "simple-get": "^3.0.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/canvas-confetti": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.4.tgz", + "integrity": "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==", + "license": "ISC", + "funding": { + "type": "donate", + "url": "https://www.paypal.me/kirilvatev" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4198,6 +4792,16 @@ "node": ">= 6" } }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=10" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -4281,6 +4885,16 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "optional": true, + "bin": { + "color-support": "bin.js" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -4307,9 +4921,16 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC", + "optional": true + }, "node_modules/cookie": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", @@ -4439,7 +5060,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4462,6 +5083,19 @@ "node": ">=0.10.0" } }, + "node_modules/decompress-response": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz", + "integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==", + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -4513,6 +5147,23 @@ "node": ">=0.4.0" } }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT", + "optional": true + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/detect-node-es": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", @@ -4585,6 +5236,15 @@ "dev": true, "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.237", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz", @@ -5511,11 +6171,37 @@ } } }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/fsevents": { @@ -5573,6 +6259,35 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/gauge/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC", + "optional": true + }, "node_modules/generator-function": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", @@ -5673,7 +6388,7 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -5861,6 +6576,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC", + "optional": true + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -5886,6 +6608,20 @@ "node": ">=8.0.0" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/ieee754": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", @@ -5934,7 +6670,7 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -6545,6 +7281,28 @@ "json5": "lib/cli.js" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -6561,6 +7319,27 @@ "node": ">=4.0" } }, + "node_modules/jwa": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -6641,6 +7420,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -6648,6 +7463,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -6676,6 +7497,32 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -6730,11 +7577,24 @@ "node": ">= 0.6" } }, + "node_modules/mimic-response": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz", + "integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -6763,6 +7623,46 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "optional": true, + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "optional": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/mnemonist": { "version": "0.38.3", "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.38.3.tgz", @@ -6791,7 +7691,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -6806,6 +7705,13 @@ "thenify-all": "^1.0.0" } }, + "node_modules/nan": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz", + "integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==", + "license": "MIT", + "optional": true + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -6932,6 +7838,27 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "optional": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-releases": { "version": "2.0.25", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.25.tgz", @@ -6939,6 +7866,22 @@ "dev": true, "license": "MIT" }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -6959,6 +7902,20 @@ "node": ">=0.10.0" } }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, "node_modules/nprogress": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/nprogress/-/nprogress-0.2.0.tgz", @@ -6969,7 +7926,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7108,7 +8065,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -7198,6 +8155,12 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -7224,7 +8187,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7264,6 +8227,79 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/path2d-polyfill": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path2d-polyfill/-/path2d-polyfill-2.0.1.tgz", + "integrity": "sha512-ad/3bsalbbWhmBo0D6FZ4RNMwsLsPpL6gnvhuSaU5Vm7b06Kr5ubSltQQ0T7YKsiJQO+g22zJ4dJKNTXIyOXtA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/pdf-lib": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", + "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", + "license": "MIT", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "pako": "^1.0.11", + "tslib": "^1.11.1" + } + }, + "node_modules/pdf-lib/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/pdf-parse": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz", + "integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==", + "license": "Apache-2.0", + "dependencies": { + "@napi-rs/canvas": "0.1.80", + "pdfjs-dist": "5.4.296" + }, + "bin": { + "pdf-parse": "bin/cli.mjs" + }, + "engines": { + "node": ">=20.16.0 <21 || >=22.3.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/mehmet-kozan" + } + }, + "node_modules/pdf-parse/node_modules/pdfjs-dist": { + "version": "5.4.296", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz", + "integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.16.0 || >=22.3.0" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.80" + } + }, + "node_modules/pdfjs-dist": { + "version": "3.11.174", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz", + "integrity": "sha512-TdTZPf1trZ8/UFu5Cx/GXB7GZM30LT+wWUNfsi6Bq8ePLnb+woNKtDymI2mxZYBpMbonNFqKmiz684DIfnd8dA==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "canvas": "^2.11.2", + "path2d-polyfill": "^2.0.1" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -7743,6 +8779,21 @@ "pify": "^2.3.0" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -7872,7 +8923,7 @@ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "glob": "^7.1.3" @@ -7935,6 +8986,26 @@ "dev": true, "license": "MIT" }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -7995,7 +9066,6 @@ "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -8176,6 +9246,39 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/simple-get": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-3.1.1.tgz", + "integrity": "sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==", + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^4.2.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/sonner": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", @@ -8234,6 +9337,16 @@ "node": ">=10.0.0" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -8648,6 +9761,34 @@ "node": ">= 6" } }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "license": "ISC", + "optional": true, + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/text-segmentation": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", @@ -9114,7 +10255,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/utrie": { @@ -9274,6 +10415,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -9328,7 +10479,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/ws": { @@ -9380,6 +10531,13 @@ "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", "license": "ISC" }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC", + "optional": true + }, "node_modules/yargs": { "version": "15.4.1", "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", diff --git a/package.json b/package.json index 21a8951..2e9ed96 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "dev:mobile": "./dev-with-network.sh", "dev:network:alt": "PORT=3001 node server.js", "test:network": "node test-server.js", - "build": "next build", + "build": "next build", "start": "next start", "lint": "next lint" }, @@ -18,22 +18,32 @@ "@aws-sdk/client-ses": "^3.896.0", "@aws-sdk/s3-request-presigner": "^3.894.0", "@radix-ui/react-dialog": "^1.1.15", + "@react-pdf-viewer/core": "^3.12.0", + "@react-pdf-viewer/default-layout": "^3.12.0", "@supabase/auth-helpers-nextjs": "^0.10.0", "@supabase/ssr": "^0.7.0", "@supabase/supabase-js": "^2.57.4", "@tanstack/react-query": "^5.56.2", + "@types/bcryptjs": "^2.4.6", + "@types/jsonwebtoken": "^9.0.10", "@types/qrcode": "^1.5.5", "aws-sdk": "^2.1692.0", "axios": "^1.12.2", + "bcryptjs": "^3.0.2", + "canvas-confetti": "^1.9.4", "clsx": "^2.1.1", "cmdk": "^1.1.1", "dotenv": "^17.2.2", "framer-motion": "^12.23.24", "html2canvas": "^1.4.1", "js-cookie": "^3.0.5", + "jsonwebtoken": "^9.0.2", "lucide-react": "^0.460.0", "next": "^14.2.5", "nprogress": "^0.2.0", + "pdf-lib": "^1.17.1", + "pdf-parse": "^2.4.5", + "pdfjs-dist": "^3.11.174", "posthog-js": "^1.275.1", "posthog-node": "^5.9.5", "qrcode": "^1.5.4", @@ -45,6 +55,7 @@ "use-debounce": "^10.0.6" }, "devDependencies": { + "@types/canvas-confetti": "^1.9.0", "@types/js-cookie": "^3.0.6", "@types/node": "24.3.1", "@types/react": "19.1.12", diff --git a/signature-real-info.json b/signature-real-info.json new file mode 100644 index 0000000..c2cb69e --- /dev/null +++ b/signature-real-info.json @@ -0,0 +1,26 @@ +{ + "success": true, + "request": { + "id": "0d14754a-740b-42e0-9766-60582e116d09", + "ref": "REAL-1761586268897", + "title": "Contrat CDDU - contrat_cddu_LYXHX3GI_240V001", + "status": "pending", + "created_at": "2025-10-27T17:31:09.550025+00:00" + }, + "signers": [ + { + "signerId": "12430034-e696-428a-876e-ba4d35b1ff2c", + "role": "Employeur", + "name": "Odentas Paie", + "email": "paie@odentas.fr", + "signatureUrl": "https://espace-paie.odentas.fr/signer/0d14754a-740b-42e0-9766-60582e116d09/12430034-e696-428a-876e-ba4d35b1ff2c" + }, + { + "signerId": "1c8914ad-4cfa-40e2-870b-b6e269ba29f3", + "role": "SalariĂ©", + "name": "Renaud Breviere", + "email": "renaud.breviere@gmail.com", + "signatureUrl": "https://espace-paie.odentas.fr/signer/0d14754a-740b-42e0-9766-60582e116d09/1c8914ad-4cfa-40e2-870b-b6e269ba29f3" + } + ] +} \ No newline at end of file diff --git a/signature-templates/README.md b/signature-templates/README.md new file mode 100644 index 0000000..ed163e5 --- /dev/null +++ b/signature-templates/README.md @@ -0,0 +1,138 @@ +# Templates de positions de signature + +Ce dossier contient les positions prĂ©-configurĂ©es des signatures pour chaque type de document. + +## Format + +```json +{ + "templateName": "contrat_cddu", + "description": "Contrat CDDU mono/multi-mois", + "pdfPattern": "contrat_cddu_.*\\.pdf", + "positions": [ + { + "role": "Employeur", + "page": 3, + "x": 50, + "y": 150, + "width": 200, + "height": 80 + }, + { + "role": "SalariĂ©", + "page": 3, + "x": 350, + "y": 150, + "width": 200, + "height": 80 + } + ] +} +``` + +## SystĂšme de coordonnĂ©es PDF + +- **Origine (0,0)** : Coin INFÉRIEUR GAUCHE de la page +- **X** : De gauche Ă  droite (0 → largeur page) +- **Y** : De bas en haut (0 → hauteur page) +- **Page A4** : 595x842 points + +### Exemples de positions courantes + +``` +Haut de page : y = 750-800 +Milieu de page : y = 400-450 +Bas de page : y = 50-150 + +Gauche : x = 50-100 +Centre : x = 250-300 +Droite : x = 400-500 +``` + +## CrĂ©ation d'un nouveau template + +### MĂ©thode 1 : Mesure manuelle + +1. Ouvrir le PDF dans Adobe Acrobat ou un Ă©diteur +2. Activer l'outil de mesure +3. Noter les coordonnĂ©es X,Y du coin infĂ©rieur gauche de la zone de signature +4. Noter la largeur et hauteur souhaitĂ©es +5. CrĂ©er le fichier JSON + +### MĂ©thode 2 : Test et ajustement + +1. CrĂ©er un template avec des positions estimĂ©es +2. Tester avec `create-real-signature.js` +3. Signer le document +4. Ajuster les positions si nĂ©cessaire +5. Re-tester jusqu'Ă  satisfaction + +### MĂ©thode 3 : Depuis DocuSeal + +Si vous avez dĂ©jĂ  un document configurĂ© dans DocuSeal: + +1. Noter oĂč les zones de signature sont placĂ©es visuellement +2. Convertir en coordonnĂ©es PDF (attention: DocuSeal utilise une origine en haut-gauche) +3. Formule de conversion: `y_pdf = hauteur_page - y_docuseal - hauteur_signature` + +## Templates disponibles + +### contrat_cddu.json + +Contrat CDDU standard (3 pages) +- Employeur: Page 3, bas gauche +- SalariĂ©: Page 3, bas droite + +### contrat_rg.json + +Contrat RĂ©gime GĂ©nĂ©ral +- Employeur: Page 2, bas gauche +- SalariĂ©: Page 2, bas droite + +### avenant.json + +Avenant de contrat +- Employeur: Page 1, bas gauche +- SalariĂ©: Page 1, bas droite + +## Utilisation dans le code + +```javascript +const fs = require('fs'); + +// Charger un template +const template = JSON.parse( + fs.readFileSync('./signature-templates/contrat_cddu.json', 'utf-8') +); + +// Utiliser les positions +const positions = {}; +template.positions.forEach(p => { + positions[p.role] = { + page: p.page, + x: p.x, + y: p.y, + width: p.width, + height: p.height, + }; +}); +``` + +## Auto-dĂ©tection + +Le script `create-real-signature.js` peut auto-dĂ©tecter le template Ă  utiliser basĂ© sur: +- Le nom du fichier (regex pattern matching) +- Le type de document (dĂ©tectĂ© dans les mĂ©tadonnĂ©es) +- Le nombre de pages + +## Maintenance + +Quand crĂ©er un nouveau template: +- Nouveau type de contrat +- Changement de mise en page +- Ajout de nouveaux rĂŽles de signataires + +Quand mettre Ă  jour un template existant: +- Les signatures sont mal positionnĂ©es +- Changement de format de document +- Feedback utilisateurs diff --git a/signature-templates/avenant.json b/signature-templates/avenant.json new file mode 100644 index 0000000..fae9d0f --- /dev/null +++ b/signature-templates/avenant.json @@ -0,0 +1,33 @@ +{ + "templateName": "avenant", + "description": "Avenant de contrat (CDDU ou RG)", + "pdfPattern": "avenant_.*\\.pdf", + "pageCount": 1, + "positions": [ + { + "role": "Employeur", + "label": "Signature Employeur", + "page": 1, + "x": 70, + "y": 120, + "width": 180, + "height": 70, + "comment": "Bas de page, cĂŽtĂ© gauche" + }, + { + "role": "SalariĂ©", + "label": "Signature SalariĂ©", + "page": 1, + "x": 350, + "y": 120, + "width": 180, + "height": 70, + "comment": "Bas de page, cĂŽtĂ© droite" + } + ], + "metadata": { + "createdAt": "2025-10-27", + "version": "1.0", + "notes": "Template pour avenants (1 page gĂ©nĂ©ralement)" + } +} diff --git a/signature-templates/contrat_cddu.json b/signature-templates/contrat_cddu.json new file mode 100644 index 0000000..0ee4dc1 --- /dev/null +++ b/signature-templates/contrat_cddu.json @@ -0,0 +1,34 @@ +{ + "templateName": "contrat_cddu", + "description": "Contrat CDDU (CDD d'usage) - Intermittents du spectacle", + "pdfPattern": "contrat_cddu_.*\\.pdf", + "pageCount": 3, + "positions": [ + { + "role": "Employeur", + "label": "Signature Employeur", + "page": 3, + "x": 70, + "y": 120, + "width": 180, + "height": 70, + "comment": "Bas de page 3, cĂŽtĂ© gauche" + }, + { + "role": "SalariĂ©", + "label": "Signature SalariĂ©", + "page": 3, + "x": 350, + "y": 120, + "width": 180, + "height": 70, + "comment": "Bas de page 3, cĂŽtĂ© droite" + } + ], + "metadata": { + "createdAt": "2025-10-27", + "version": "1.0", + "basedOn": "Template DocuSeal existant", + "notes": "Positions approximatives, Ă  ajuster aprĂšs test rĂ©el" + } +} diff --git a/signature-templates/contrat_rg.json b/signature-templates/contrat_rg.json new file mode 100644 index 0000000..515c861 --- /dev/null +++ b/signature-templates/contrat_rg.json @@ -0,0 +1,33 @@ +{ + "templateName": "contrat_rg", + "description": "Contrat RĂ©gime GĂ©nĂ©ral - SalariĂ©s classiques", + "pdfPattern": "contrat_rg_.*\\.pdf", + "pageCount": 2, + "positions": [ + { + "role": "Employeur", + "label": "Signature Employeur", + "page": 2, + "x": 70, + "y": 120, + "width": 180, + "height": 70, + "comment": "Bas de page 2, cĂŽtĂ© gauche" + }, + { + "role": "SalariĂ©", + "label": "Signature SalariĂ©", + "page": 2, + "x": 350, + "y": 120, + "width": 180, + "height": 70, + "comment": "Bas de page 2, cĂŽtĂ© droite" + } + ], + "metadata": { + "createdAt": "2025-10-27", + "version": "1.0", + "notes": "Template pour contrats RG standard" + } +} diff --git a/supabase/.temp/cli-latest b/supabase/.temp/cli-latest new file mode 100644 index 0000000..8cbd837 --- /dev/null +++ b/supabase/.temp/cli-latest @@ -0,0 +1 @@ +v2.53.6 \ No newline at end of file diff --git a/supabase/.temp/gotrue-version b/supabase/.temp/gotrue-version new file mode 100644 index 0000000..f630baa --- /dev/null +++ b/supabase/.temp/gotrue-version @@ -0,0 +1 @@ +v2.180.0 \ No newline at end of file diff --git a/supabase/.temp/pooler-url b/supabase/.temp/pooler-url new file mode 100644 index 0000000..cfda716 --- /dev/null +++ b/supabase/.temp/pooler-url @@ -0,0 +1 @@ +postgresql://postgres.fusqtpjififcmgbhmosq@aws-1-eu-west-3.pooler.supabase.com:5432/postgres \ No newline at end of file diff --git a/supabase/.temp/postgres-version b/supabase/.temp/postgres-version new file mode 100644 index 0000000..2fe5cbf --- /dev/null +++ b/supabase/.temp/postgres-version @@ -0,0 +1 @@ +17.6.1.003 \ No newline at end of file diff --git a/supabase/.temp/project-ref b/supabase/.temp/project-ref new file mode 100644 index 0000000..971c82e --- /dev/null +++ b/supabase/.temp/project-ref @@ -0,0 +1 @@ +fusqtpjififcmgbhmosq \ No newline at end of file diff --git a/supabase/.temp/rest-version b/supabase/.temp/rest-version new file mode 100644 index 0000000..93c142b --- /dev/null +++ b/supabase/.temp/rest-version @@ -0,0 +1 @@ +v13.0.5 \ No newline at end of file diff --git a/supabase/.temp/storage-migration b/supabase/.temp/storage-migration new file mode 100644 index 0000000..581d478 --- /dev/null +++ b/supabase/.temp/storage-migration @@ -0,0 +1 @@ +fix-object-level \ No newline at end of file diff --git a/supabase/.temp/storage-version b/supabase/.temp/storage-version new file mode 100644 index 0000000..a789bf1 --- /dev/null +++ b/supabase/.temp/storage-version @@ -0,0 +1 @@ +v1.27.6 \ No newline at end of file diff --git a/templates-mails/otp-signature.html b/templates-mails/otp-signature.html new file mode 100644 index 0000000..e728fe6 --- /dev/null +++ b/templates-mails/otp-signature.html @@ -0,0 +1,118 @@ + + + + +
+ Code de vérification pour signer votre document électroniquement avec Odentas Sign. +
+ + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Odentas +
+
+ 🔐 Code de vĂ©rification +
+
+
+ Signature électronique Odentas Sign +
+
+
+ Bonjour {{name}},

+ Vous avez demandé à signer électroniquement le document suivant :
+ {{documentTitle}} ({{documentRef}}) +
+
+
+
+ {{otpCode}} +
+
+ Code de vérification +
+
+
+
+ 📌 Instructions :
+ 1. Saisissez ce code sur la page de signature
+ 2. Dessinez ou uploadez votre signature
+ 3. Acceptez les conditions de signature électronique
+ 4. Validez pour signer le document +
+
+
+
+ ⚠ SĂ©curitĂ© :
+ ‱ Ce code expire dans {{expirationMinutes}} minutes
+ ‱ Maximum 3 tentatives de saisie
+ ‱ Ne partagez jamais ce code +
+
+
+
+ 📋 ConformitĂ© eIDAS
+ Votre signature Ă©lectronique aura la mĂȘme valeur juridique qu'une signature manuscrite. + Le document signĂ© sera scellĂ© numĂ©riquement (PAdES), horodatĂ© (RFC3161) et archivĂ© pendant 10 ans + avec un systĂšme de conformitĂ© immutable. +
+
+
+ Vous recevez cet email car une signature électronique a été demandée pour vous.
+ Si vous n'ĂȘtes pas Ă  l'origine de cette demande, ignorez ce message.

+ support@odentas.fr +
+
+
+ + diff --git a/test-contrat.pdf b/test-contrat.pdf new file mode 100644 index 0000000..cfebc0d Binary files /dev/null and b/test-contrat.pdf differ diff --git a/test-interface-signature.sh b/test-interface-signature.sh new file mode 100755 index 0000000..9fbd67e --- /dev/null +++ b/test-interface-signature.sh @@ -0,0 +1,139 @@ +#!/bin/bash + +# Script pour tester l'interface de signature Odentas Sign +# GĂ©nĂšre une demande et affiche les URLs pour tester dans le navigateur + +set -e + +BASE_URL="http://localhost:3000" +API_URL="$BASE_URL/api/odentas-sign" + +echo "═══════════════════════════════════════════════════════" +echo " 🎹 Test Interface Signature Odentas Sign" +echo "═══════════════════════════════════════════════════════" +echo "" + +# Check if test-odentas-sign-info.json exists +if [ ! -f "test-odentas-sign-info.json" ]; then + echo "❌ Fichier test-odentas-sign-info.json introuvable" + echo "" + echo "Veuillez d'abord exĂ©cuter:" + echo " node test-odentas-sign.js" + echo "" + exit 1 +fi + +# Extract request ID and signer IDs +REQUEST_ID=$(jq -r '.requestId' test-odentas-sign-info.json) +EMPLOYEUR_ID=$(jq -r '.signers[] | select(.role == "Employeur") | .id' test-odentas-sign-info.json) +SALARIE_ID=$(jq -r '.signers[] | select(.role == "SalariĂ©") | .id' test-odentas-sign-info.json) +EMPLOYEUR_EMAIL=$(jq -r '.signers[] | select(.role == "Employeur") | .email' test-odentas-sign-info.json) +SALARIE_EMAIL=$(jq -r '.signers[] | select(.role == "SalariĂ©") | .email' test-odentas-sign-info.json) + +echo "📋 Informations de la demande:" +echo " Request ID: $REQUEST_ID" +echo " Employeur ID: $EMPLOYEUR_ID ($EMPLOYEUR_EMAIL)" +echo " SalariĂ© ID: $SALARIE_ID ($SALARIE_EMAIL)" +echo "" + +# Generate URLs +EMPLOYEUR_URL="$BASE_URL/signer/$REQUEST_ID/$EMPLOYEUR_ID" +SALARIE_URL="$BASE_URL/signer/$REQUEST_ID/$SALARIE_ID" + +echo "═══════════════════════════════════════════════════════" +echo " 🔗 URLs de Signature" +echo "═══════════════════════════════════════════════════════" +echo "" +echo "👔 Employeur:" +echo " $EMPLOYEUR_URL" +echo "" +echo "đŸ‘€ SalariĂ©:" +echo " $SALARIE_URL" +echo "" + +# Interactive menu +echo "═══════════════════════════════════════════════════════" +echo " đŸ“± Actions Disponibles" +echo "═══════════════════════════════════════════════════════" +echo "" +echo "1. Ouvrir l'interface Employeur dans le navigateur" +echo "2. Ouvrir l'interface SalariĂ© dans le navigateur" +echo "3. Afficher l'OTP de l'Employeur (mode test)" +echo "4. Afficher l'OTP du SalariĂ© (mode test)" +echo "5. VĂ©rifier le statut de la demande" +echo "6. Quitter" +echo "" + +while true; do + read -p "Choisissez une action (1-6): " choice + + case $choice in + 1) + echo "" + echo "🌐 Ouverture de l'interface Employeur..." + open "$EMPLOYEUR_URL" 2>/dev/null || xdg-open "$EMPLOYEUR_URL" 2>/dev/null || echo "URL: $EMPLOYEUR_URL" + echo "" + ;; + 2) + echo "" + echo "🌐 Ouverture de l'interface SalariĂ©..." + open "$SALARIE_URL" 2>/dev/null || xdg-open "$SALARIE_URL" 2>/dev/null || echo "URL: $SALARIE_URL" + echo "" + ;; + 3) + echo "" + echo "📧 Envoi de l'OTP Ă  l'Employeur..." + RESPONSE=$(curl -s -X POST "$API_URL/signers/$EMPLOYEUR_ID/send-otp" \ + -H "Content-Type: application/json") + + echo "$RESPONSE" | jq -r '.message // .error' + + # In test mode, OTP will be in server logs + echo "" + echo "💡 En mode test, l'OTP s'affiche dans les logs du serveur Next.js" + echo " Cherchez le message avec des Ă©toiles ⭐" + echo "" + ;; + 4) + echo "" + echo "📧 Envoi de l'OTP au SalariĂ©..." + RESPONSE=$(curl -s -X POST "$API_URL/signers/$SALARIE_ID/send-otp" \ + -H "Content-Type: application/json") + + echo "$RESPONSE" | jq -r '.message // .error' + + echo "" + echo "💡 En mode test, l'OTP s'affiche dans les logs du serveur Next.js" + echo " Cherchez le message avec des Ă©toiles ⭐" + echo "" + ;; + 5) + echo "" + echo "📊 Statut de la demande..." + + echo "Employeur:" + EMPLOYEUR_STATUS=$(curl -s "$API_URL/signers/$EMPLOYEUR_ID/status") + echo "$EMPLOYEUR_STATUS" | jq '.signer | {name, role, has_signed, signed_at}' + + echo "" + echo "SalariĂ©:" + SALARIE_STATUS=$(curl -s "$API_URL/signers/$SALARIE_ID/status") + echo "$SALARIE_STATUS" | jq '.signer | {name, role, has_signed, signed_at}' + + echo "" + echo "Progression:" + echo "$EMPLOYEUR_STATUS" | jq '.request.progress' + echo "" + ;; + 6) + echo "" + echo "👋 Au revoir!" + exit 0 + ;; + *) + echo "" + echo "❌ Choix invalide. Veuillez entrer un nombre entre 1 et 6." + echo "" + ;; + esac +done diff --git a/test-odentas-sign-info.json b/test-odentas-sign-info.json new file mode 100644 index 0000000..da64a92 --- /dev/null +++ b/test-odentas-sign-info.json @@ -0,0 +1,26 @@ +{ + "success": true, + "request": { + "id": "75b4408d-1bbd-464f-a9ea-2b4e5075a817", + "ref": "TEST-1761582838435", + "title": "Contrat CDDU - Test Local", + "status": "pending", + "created_at": "2025-10-27T16:34:07.361187+00:00" + }, + "signers": [ + { + "signerId": "95c4ccdc-1a26-4426-a56f-653758159b54", + "role": "Employeur", + "name": "Odentas Paie", + "email": "paie@odentas.fr", + "signatureUrl": "https://espace-paie.odentas.fr/signer/75b4408d-1bbd-464f-a9ea-2b4e5075a817/95c4ccdc-1a26-4426-a56f-653758159b54" + }, + { + "signerId": "d481f070-2ac6-4f82-aff3-862783904d5d", + "role": "SalariĂ©", + "name": "Renaud Breviere", + "email": "renaud.breviere@gmail.com", + "signatureUrl": "https://espace-paie.odentas.fr/signer/75b4408d-1bbd-464f-a9ea-2b4e5075a817/d481f070-2ac6-4f82-aff3-862783904d5d" + } + ] +} \ No newline at end of file diff --git a/test-odentas-sign.js b/test-odentas-sign.js new file mode 100755 index 0000000..da145cc --- /dev/null +++ b/test-odentas-sign.js @@ -0,0 +1,184 @@ +#!/usr/bin/env node + +/** + * Script de test Odentas Sign + * + * Upload un PDF local vers S3 et crĂ©e une demande de signature + */ + +const fs = require('fs'); +const path = require('path'); +const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3'); + +// Configuration +const PDF_PATH = path.join(__dirname, 'test-contrat.pdf'); +const BUCKET = process.env.ODENTAS_SIGN_BUCKET || 'odentas-sign'; +const REGION = process.env.AWS_REGION || 'eu-west-3'; +const API_URL = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'; + +// Emails pour le test +const EMPLOYEUR_EMAIL = 'paie@odentas.fr'; +const SALARIE_EMAIL = 'renaud.breviere@gmail.com'; + +async function main() { + console.log('🚀 Odentas Sign - Script de test local\n'); + + // 1. VĂ©rifier que le PDF existe + if (!fs.existsSync(PDF_PATH)) { + console.error(`❌ PDF introuvable: ${PDF_PATH}`); + process.exit(1); + } + + const pdfBuffer = fs.readFileSync(PDF_PATH); + console.log(`✅ PDF chargĂ©: ${PDF_PATH} (${(pdfBuffer.length / 1024).toFixed(1)} KB)\n`); + + // 2. Upload vers S3 + console.log('đŸ“€ Upload du PDF vers S3...'); + const testRef = `TEST-${Date.now()}`; + const s3Key = `source/test/${testRef}.pdf`; + + const s3Client = new S3Client({ region: REGION }); + + try { + await s3Client.send(new PutObjectCommand({ + Bucket: BUCKET, + Key: s3Key, + Body: pdfBuffer, + ContentType: 'application/pdf', + Metadata: { + test: 'true', + uploaded_by: 'test-script', + original_name: 'test-contrat.pdf', + }, + })); + + console.log(`✅ PDF uploadĂ©: s3://${BUCKET}/${s3Key}\n`); + } catch (error) { + console.error('❌ Erreur upload S3:', error.message); + console.error(' VĂ©rifiez vos credentials AWS dans .env.local'); + process.exit(1); + } + + // 3. CrĂ©er la demande de signature via l'API + console.log('📝 CrĂ©ation de la demande de signature...'); + + const requestBody = { + contractId: `test-local-${Date.now()}`, + contractRef: testRef, + pdfS3Key: s3Key, + title: 'Contrat CDDU - Test Local', + signers: [ + { + role: 'Employeur', + name: 'Odentas Paie', + email: EMPLOYEUR_EMAIL, + }, + { + role: 'SalariĂ©', + name: 'Renaud Breviere', + email: SALARIE_EMAIL, + }, + ], + positions: [ + { + role: 'Employeur', + page: 1, + x: 100, + y: 680, + w: 200, + h: 60, + kind: 'signature', + label: 'Signature Employeur', + }, + { + role: 'SalariĂ©', + page: 1, + x: 350, + y: 680, + w: 200, + h: 60, + kind: 'signature', + label: 'Signature SalariĂ©', + }, + ], + }; + + try { + const response = await fetch(`${API_URL}/api/odentas-sign/requests/create`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || `HTTP ${response.status}`); + } + + const result = await response.json(); + + console.log('✅ Demande créée avec succĂšs!\n'); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log('📋 INFORMATIONS DE LA DEMANDE'); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'); + console.log(`ID: ${result.request.id}`); + console.log(`Ref: ${result.request.ref}`); + console.log(`Titre: ${result.request.title}`); + console.log(`Statut: ${result.request.status}\n`); + + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log('đŸ‘„ SIGNATAIRES'); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'); + + result.signers.forEach((signer, index) => { + console.log(`${index + 1}. ${signer.role} - ${signer.name}`); + console.log(` Email: ${signer.email}`); + console.log(` ID: ${signer.signerId}`); + console.log(` URL: ${signer.signatureUrl}\n`); + }); + + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log('đŸ§Ș INSTRUCTIONS DE TEST'); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'); + + console.log('Étape 1: Demander un code OTP'); + console.log('─────────────────────────────────\n'); + result.signers.forEach((signer, index) => { + console.log(`${signer.role}:`); + console.log(`curl -X POST ${API_URL}/api/odentas-sign/signers/${signer.signerId}/send-otp\n`); + }); + + console.log('\n📧 Les codes OTP seront envoyĂ©s aux emails:'); + console.log(` - ${EMPLOYEUR_EMAIL}`); + console.log(` - ${SALARIE_EMAIL}\n`); + + console.log('⚠ En mode TEST, les codes apparaissent aussi dans les logs serveur\n'); + + console.log('\nÉtape 2: VĂ©rifier le code OTP'); + console.log('─────────────────────────────────\n'); + console.log(`curl -X POST ${API_URL}/api/odentas-sign/signers/[SIGNER_ID]/verify-otp \\`); + console.log(` -H "Content-Type: application/json" \\`); + console.log(` -d '{"otp": "123456"}'\n`); + + console.log('\nÉtape 3: Enregistrer la signature'); + console.log('─────────────────────────────────\n'); + console.log(`curl -X POST ${API_URL}/api/odentas-sign/signers/[SIGNER_ID]/sign \\`); + console.log(` -H "Content-Type: application/json" \\`); + console.log(` -H "Authorization: Bearer [SESSION_TOKEN]" \\`); + console.log(` -d '{"signatureImageBase64": "data:image/png;base64,iVBORw0...", "consentText": "Je consens"}'\n`); + + console.log('\n💡 Pour plus de dĂ©tails, voir: ODENTAS_SIGN_TEST_GUIDE.md\n'); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'); + + // Sauvegarder les infos pour rĂ©fĂ©rence + const testInfoPath = path.join(__dirname, 'test-odentas-sign-info.json'); + fs.writeFileSync(testInfoPath, JSON.stringify(result, null, 2)); + console.log(`đŸ’Ÿ Informations sauvegardĂ©es dans: ${testInfoPath}\n`); + + } catch (error) { + console.error('❌ Erreur crĂ©ation demande:', error.message); + process.exit(1); + } +} + +main().catch(console.error); diff --git a/test-signature-flow.sh b/test-signature-flow.sh new file mode 100755 index 0000000..158e577 --- /dev/null +++ b/test-signature-flow.sh @@ -0,0 +1,164 @@ +#!/bin/bash + +# Script pour tester rapidement les Ă©tapes de signature +# Usage: ./test-signature-flow.sh + +# Charger les infos de la derniĂšre demande créée +INFO_FILE="test-odentas-sign-info.json" + +if [ ! -f "$INFO_FILE" ]; then + echo "❌ Fichier $INFO_FILE introuvable" + echo " Lancez d'abord: node test-odentas-sign.js" + exit 1 +fi + +# Extraire les IDs des signataires +EMPLOYEUR_ID=$(cat "$INFO_FILE" | grep -A 4 '"role": "Employeur"' | grep '"signerId"' | cut -d'"' -f4) +SALARIE_ID=$(cat "$INFO_FILE" | grep -A 4 '"role": "SalariĂ©"' | grep '"signerId"' | cut -d'"' -f4) +REQUEST_ID=$(cat "$INFO_FILE" | grep '"id"' | head -1 | cut -d'"' -f4) + +API_URL="${NEXT_PUBLIC_APP_URL:-http://localhost:3000}" + +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "đŸ§Ș Test du workflow de signature Odentas Sign" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" +echo "Request ID: $REQUEST_ID" +echo "Employeur ID: $EMPLOYEUR_ID" +echo "SalariĂ© ID: $SALARIE_ID" +echo "" + +# Menu +echo "Que voulez-vous tester ?" +echo "1) Envoyer OTP Employeur" +echo "2) Envoyer OTP SalariĂ©" +echo "3) VĂ©rifier OTP Employeur" +echo "4) VĂ©rifier OTP SalariĂ©" +echo "5) Signer (Employeur)" +echo "6) Signer (SalariĂ©)" +echo "7) Voir statut de la demande" +echo "8) Tout tester automatiquement" +echo "" +read -p "Choix (1-8): " CHOICE + +case $CHOICE in + 1) + echo "" + echo "đŸ“€ Envoi OTP Employeur..." + curl -X POST "$API_URL/api/odentas-sign/signers/$EMPLOYEUR_ID/send-otp" | jq + echo "" + echo "📧 VĂ©rifiez votre email paie@odentas.fr" + echo "⚠ Le code OTP est aussi affichĂ© dans les logs du serveur Next.js" + ;; + 2) + echo "" + echo "đŸ“€ Envoi OTP SalariĂ©..." + curl -X POST "$API_URL/api/odentas-sign/signers/$SALARIE_ID/send-otp" | jq + echo "" + echo "📧 VĂ©rifiez votre email renaud.breviere@gmail.com" + echo "⚠ Le code OTP est aussi affichĂ© dans les logs du serveur Next.js" + ;; + 3) + echo "" + read -p "Entrez le code OTP reçu: " OTP_CODE + echo "" + echo "🔐 VĂ©rification OTP Employeur..." + RESPONSE=$(curl -s -X POST "$API_URL/api/odentas-sign/signers/$EMPLOYEUR_ID/verify-otp" \ + -H "Content-Type: application/json" \ + -d "{\"otp\": \"$OTP_CODE\"}") + echo "$RESPONSE" | jq + + TOKEN=$(echo "$RESPONSE" | jq -r '.sessionToken // empty') + if [ -n "$TOKEN" ]; then + echo "" + echo "✅ Session token obtenu !" + echo "đŸ’Ÿ Token sauvegardĂ© dans .test-employeur-token" + echo "$TOKEN" > .test-employeur-token + fi + ;; + 4) + echo "" + read -p "Entrez le code OTP reçu: " OTP_CODE + echo "" + echo "🔐 VĂ©rification OTP SalariĂ©..." + RESPONSE=$(curl -s -X POST "$API_URL/api/odentas-sign/signers/$SALARIE_ID/verify-otp" \ + -H "Content-Type: application/json" \ + -d "{\"otp\": \"$OTP_CODE\"}") + echo "$RESPONSE" | jq + + TOKEN=$(echo "$RESPONSE" | jq -r '.sessionToken // empty') + if [ -n "$TOKEN" ]; then + echo "" + echo "✅ Session token obtenu !" + echo "đŸ’Ÿ Token sauvegardĂ© dans .test-salarie-token" + echo "$TOKEN" > .test-salarie-token + fi + ;; + 5) + if [ ! -f ".test-employeur-token" ]; then + echo "❌ Token employeur introuvable. VĂ©rifiez d'abord l'OTP (option 3)" + exit 1 + fi + TOKEN=$(cat .test-employeur-token) + + # Image de signature de test (carrĂ© rouge 100x50) + SIG_B64="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAAAyCAYAAACqNX6+AAAABmJLR0QA/wD/AP+gvaeTAAAAeklEQVR4nO3QMQEAAAjAMMC/52ECvlRA00ASAgECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQcNkFzQABOWLnlYwAAAAASUVORK5CYII=" + + echo "" + echo "✍ Enregistrement signature Employeur..." + curl -X POST "$API_URL/api/odentas-sign/signers/$EMPLOYEUR_ID/sign" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d "{\"signatureImageBase64\": \"$SIG_B64\", \"consentText\": \"Je consens Ă  signer Ă©lectroniquement ce document.\"}" | jq + ;; + 6) + if [ ! -f ".test-salarie-token" ]; then + echo "❌ Token salariĂ© introuvable. VĂ©rifiez d'abord l'OTP (option 4)" + exit 1 + fi + TOKEN=$(cat .test-salarie-token) + + # Image de signature de test (carrĂ© rouge 100x50) + SIG_B64="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAAAyCAYAAACqNX6+AAAABmJLR0QA/wD/AP+gvaeTAAAAeklEQVR4nO3QMQEAAAjAMMC/52ECvlRA00ASAgECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQIECAAAECBAgQcNkFzQABOWLnlYwAAAAASUVORK5CYII=" + + echo "" + echo "✍ Enregistrement signature SalariĂ©..." + curl -X POST "$API_URL/api/odentas-sign/signers/$SALARIE_ID/sign" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d "{\"signatureImageBase64\": \"$SIG_B64\", \"consentText\": \"Je consens Ă  signer Ă©lectroniquement ce document.\"}" | jq + ;; + 7) + echo "" + echo "📊 Statut de la demande..." + curl "$API_URL/api/odentas-sign/requests/$REQUEST_ID" | jq + ;; + 8) + echo "" + echo "đŸ€– Test automatique complet..." + echo "" + + # 1. OTP Employeur + echo "1ïžâƒŁ Envoi OTP Employeur..." + curl -s -X POST "$API_URL/api/odentas-sign/signers/$EMPLOYEUR_ID/send-otp" > /dev/null + echo " Consultez les logs serveur pour le code OTP" + echo "" + + # 2. OTP SalariĂ© + echo "2ïžâƒŁ Envoi OTP SalariĂ©..." + curl -s -X POST "$API_URL/api/odentas-sign/signers/$SALARIE_ID/send-otp" > /dev/null + echo " Consultez les logs serveur pour le code OTP" + echo "" + + echo "⚠ Pour continuer le test automatique, vous devez :" + echo " 1. Relever les codes OTP dans les logs serveur" + echo " 2. ExĂ©cuter les options 3-6 manuellement" + ;; + *) + echo "❌ Choix invalide" + exit 1 + ;; +esac + +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"