feat: Implémentation complète du système Odentas Sign
- Remplacement de DocuSeal par solution souveraine Odentas Sign - Système d'authentification OTP pour signataires (bcryptjs + JWT) - 8 routes API: send-otp, verify-otp, sign, pdf-url, positions, status, webhook, signers - Interface moderne avec canvas de signature et animations (framer-motion, confetti) - Système de templates pour auto-détection des positions de signature (CDDU, RG, avenants) - PDF viewer avec @react-pdf-viewer (compatible Next.js) - Stockage S3: source/, signatures/, evidence/, signed/, certs/ - Tables Supabase: sign_requests, signers, sign_positions, sign_events, sign_assets - Evidence bundle automatique (JSON metadata + timestamps) - Templates emails: OTP et completion - Scripts Lambda prêts: pades-sign (KMS seal) et tsaStamp (RFC3161) - Mode test détecté automatiquement (emails whitelist) - Tests complets avec PDF CDDU réel (2 signataires)
This commit is contained in:
parent
032ae49ed4
commit
b790faf12c
84 changed files with 10106 additions and 17 deletions
290
GUIDE_TEST_INTERFACE_ODENTAS_SIGN.md
Normal file
290
GUIDE_TEST_INTERFACE_ODENTAS_SIGN.md
Normal file
|
|
@ -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 = '<votre-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
|
||||||
496
ODENTAS_SIGN_API.md
Normal file
496
ODENTAS_SIGN_API.md
Normal file
|
|
@ -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<Position>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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 <sessionToken>`
|
||||||
|
|
||||||
|
```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 <sessionToken>`
|
||||||
|
|
||||||
|
```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
|
||||||
407
ODENTAS_SIGN_INTERFACE.md
Normal file
407
ODENTAS_SIGN_INTERFACE.md
Normal file
|
|
@ -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 <token>`
|
||||||
|
- 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
|
||||||
371
ODENTAS_SIGN_TEST_GUIDE.md
Normal file
371
ODENTAS_SIGN_TEST_GUIDE.md
Normal file
|
|
@ -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
|
||||||
103
TEST-RECAP.md
Normal file
103
TEST-RECAP.md
Normal file
|
|
@ -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 ! 🎯**
|
||||||
92
app/api/odentas-sign/requests/[id]/cancel/route.ts
Normal file
92
app/api/odentas-sign/requests/[id]/cancel/route.ts
Normal file
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
66
app/api/odentas-sign/requests/[id]/pdf-url/route.ts
Normal file
66
app/api/odentas-sign/requests/[id]/pdf-url/route.ts
Normal file
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
71
app/api/odentas-sign/requests/[id]/positions/route.ts
Normal file
71
app/api/odentas-sign/requests/[id]/positions/route.ts
Normal file
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
71
app/api/odentas-sign/requests/[id]/route.ts
Normal file
71
app/api/odentas-sign/requests/[id]/route.ts
Normal file
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
163
app/api/odentas-sign/requests/create/route.ts
Normal file
163
app/api/odentas-sign/requests/create/route.ts
Normal file
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
218
app/api/odentas-sign/signers/[id]/send-otp/route.ts
Normal file
218
app/api/odentas-sign/signers/[id]/send-otp/route.ts
Normal file
|
|
@ -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) };
|
||||||
|
}
|
||||||
|
}
|
||||||
219
app/api/odentas-sign/signers/[id]/sign/route.ts
Normal file
219
app/api/odentas-sign/signers/[id]/sign/route.ts
Normal file
|
|
@ -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<void> {
|
||||||
|
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`);
|
||||||
|
}
|
||||||
107
app/api/odentas-sign/signers/[id]/status/route.ts
Normal file
107
app/api/odentas-sign/signers/[id]/status/route.ts
Normal file
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
186
app/api/odentas-sign/signers/[id]/verify-otp/route.ts
Normal file
186
app/api/odentas-sign/signers/[id]/verify-otp/route.ts
Normal file
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
251
app/api/odentas-sign/test/create-mock/route.ts
Normal file
251
app/api/odentas-sign/test/create-mock/route.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
240
app/api/odentas-sign/webhooks/completion/route.ts
Normal file
240
app/api/odentas-sign/webhooks/completion/route.ts
Normal file
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ type: 'spring' }}
|
||||||
|
className="max-w-2xl mx-auto"
|
||||||
|
>
|
||||||
|
<div className="bg-white rounded-2xl shadow-xl overflow-hidden">
|
||||||
|
{/* Success header */}
|
||||||
|
<div className="bg-gradient-to-br from-green-500 via-emerald-500 to-teal-500 px-8 py-12 text-white text-center relative overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ type: 'spring', delay: 0.2 }}
|
||||||
|
className="relative z-10"
|
||||||
|
>
|
||||||
|
<div className="w-24 h-24 bg-white/20 backdrop-blur-sm rounded-full flex items-center justify-center mx-auto mb-6">
|
||||||
|
<CheckCircle className="w-14 h-14" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl font-bold mb-2">Signature enregistrée !</h2>
|
||||||
|
<p className="text-green-50 text-lg">
|
||||||
|
Merci {signerName.split(' ')[0]} 🎉
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Animated sparkles */}
|
||||||
|
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||||
|
{[...Array(6)].map((_, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={i}
|
||||||
|
initial={{ opacity: 0, y: 100, rotate: 0 }}
|
||||||
|
animate={{ opacity: [0, 1, 0], y: -100, rotate: 360 }}
|
||||||
|
transition={{
|
||||||
|
duration: 3,
|
||||||
|
delay: i * 0.3,
|
||||||
|
repeat: Infinity,
|
||||||
|
repeatDelay: 2,
|
||||||
|
}}
|
||||||
|
className="absolute"
|
||||||
|
style={{
|
||||||
|
left: `${20 + i * 12}%`,
|
||||||
|
top: '50%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Sparkles className="w-6 h-6 text-yellow-300" />
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-8">
|
||||||
|
{/* Document info */}
|
||||||
|
<div className="bg-slate-50 rounded-xl p-6 mb-6">
|
||||||
|
<h3 className="text-sm font-medium text-slate-600 mb-3">Détails du document</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<span className="text-sm text-slate-600">Titre</span>
|
||||||
|
<span className="text-sm font-medium text-slate-900 text-right">{documentTitle}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm text-slate-600">Référence</span>
|
||||||
|
<span className="text-sm font-mono font-medium text-slate-900">{documentRef}</span>
|
||||||
|
</div>
|
||||||
|
{signedAt && (
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm text-slate-600">Date de signature</span>
|
||||||
|
<span className="text-sm font-medium text-slate-900">
|
||||||
|
{new Date(signedAt).toLocaleDateString('fr-FR', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress indicator */}
|
||||||
|
<div className="bg-gradient-to-br from-indigo-50 to-purple-50 rounded-xl p-6 mb-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="w-5 h-5 text-indigo-600" />
|
||||||
|
<span className="font-semibold text-slate-900">Progression des signatures</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-2xl font-bold text-indigo-600">
|
||||||
|
{progress.signed}/{progress.total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="w-full bg-white rounded-full h-3 overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: `${progress.percentage}%` }}
|
||||||
|
transition={{ duration: 1, ease: 'easeOut' }}
|
||||||
|
className="h-full bg-gradient-to-r from-indigo-500 to-purple-500 rounded-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-slate-600 mt-3">
|
||||||
|
{isFullyCompleted ? (
|
||||||
|
<span className="flex items-center gap-2 text-green-600 font-medium">
|
||||||
|
<CheckCircle className="w-4 h-4" />
|
||||||
|
Tous les signataires ont signé !
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{progress.total - progress.signed} signataire{progress.total - progress.signed > 1 ? 's' : ''} restant{progress.total - progress.signed > 1 ? 's' : ''}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status message */}
|
||||||
|
{isFullyCompleted ? (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.3 }}
|
||||||
|
className="bg-green-50 border border-green-200 rounded-xl p-6 mb-6"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Clock className="w-5 h-5 text-green-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="font-medium text-green-900 mb-1">Document finalisé</p>
|
||||||
|
<p className="text-green-700">
|
||||||
|
Le document est en cours de scellement cryptographique et d'horodatage. Vous recevrez une copie signée par email d'ici quelques instants.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-xl p-6 mb-6">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Clock className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="font-medium text-blue-900 mb-1">En attente des autres signatures</p>
|
||||||
|
<p className="text-blue-700">
|
||||||
|
Nous vous informerons par email dès que tous les signataires auront validé le document.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Download button (disabled for now) */}
|
||||||
|
<button
|
||||||
|
disabled
|
||||||
|
className="w-full px-6 py-4 bg-slate-100 text-slate-400 rounded-xl font-semibold cursor-not-allowed flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<Download className="w-5 h-5" />
|
||||||
|
Télécharger le document signé
|
||||||
|
<span className="text-xs">(disponible après finalisation)</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Close button */}
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.href = '/'}
|
||||||
|
className="w-full px-6 py-4 bg-white border-2 border-slate-200 text-slate-700 rounded-xl font-semibold hover:bg-slate-50 transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
Retour à l'accueil
|
||||||
|
<ArrowRight className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Security footer */}
|
||||||
|
<div className="mt-8 pt-6 border-t border-slate-200">
|
||||||
|
<div className="text-center text-sm text-slate-600">
|
||||||
|
<p className="font-medium text-slate-900 mb-2">🔒 Sécurité et conformité</p>
|
||||||
|
<p className="leading-relaxed">
|
||||||
|
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é.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
308
app/signer/[requestId]/[signerId]/components/OTPVerification.tsx
Normal file
308
app/signer/[requestId]/[signerId]/components/OTPVerification.tsx
Normal file
|
|
@ -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<string | null>(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<HTMLInputElement>) {
|
||||||
|
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 (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -20 }}
|
||||||
|
className="max-w-2xl mx-auto"
|
||||||
|
>
|
||||||
|
<div className="bg-white rounded-2xl shadow-xl overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-gradient-to-r from-indigo-600 to-purple-600 px-8 py-12 text-white text-center">
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ type: 'spring', delay: 0.2 }}
|
||||||
|
className="w-20 h-20 bg-white/20 rounded-full flex items-center justify-center mx-auto mb-6"
|
||||||
|
>
|
||||||
|
<Shield className="w-10 h-10" />
|
||||||
|
</motion.div>
|
||||||
|
<h2 className="text-3xl font-bold mb-2">Vérification d'identité</h2>
|
||||||
|
<p className="text-indigo-100 text-lg">
|
||||||
|
Bonjour {signerName.split(' ')[0]}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-8">
|
||||||
|
{/* Document info */}
|
||||||
|
<div className="bg-slate-50 rounded-xl p-6 mb-8">
|
||||||
|
<p className="text-sm text-slate-600 mb-1">Document à signer</p>
|
||||||
|
<p className="text-lg font-semibold text-slate-900">{documentTitle}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!otpSent ? (
|
||||||
|
// Initial state - send OTP
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="flex items-center justify-center gap-2 mb-4">
|
||||||
|
<Mail className="w-5 h-5 text-indigo-600" />
|
||||||
|
<p className="text-slate-600">{signerEmail}</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-slate-700 mb-8">
|
||||||
|
Un code de vérification à 6 chiffres va être envoyé à votre adresse email.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={sendOTP}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="px-8 py-4 bg-indigo-600 text-white rounded-xl font-semibold hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 hover:shadow-lg inline-flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
Envoi en cours...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Recevoir le code
|
||||||
|
<ArrowRight className="w-5 h-5" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="mt-6 p-4 bg-red-50 border border-red-200 rounded-xl flex items-start gap-3"
|
||||||
|
>
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<p className="text-sm text-red-800">{error}</p>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// OTP input state
|
||||||
|
<div>
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<p className="text-slate-700 mb-2">
|
||||||
|
Entrez le code reçu par email
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-center gap-2 text-sm text-slate-500">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
<span>Expire dans {formatTime(remainingTime)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* OTP Input */}
|
||||||
|
<div className="flex justify-center gap-3 mb-8" onPaste={handlePaste}>
|
||||||
|
{otpCode.map((digit, index) => (
|
||||||
|
<input
|
||||||
|
key={index}
|
||||||
|
ref={(el) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl flex items-start gap-3"
|
||||||
|
>
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm text-red-800 font-medium">{error}</p>
|
||||||
|
{attemptsLeft > 0 && (
|
||||||
|
<p className="text-xs text-red-600 mt-1">
|
||||||
|
{attemptsLeft} tentative{attemptsLeft > 1 ? 's' : ''} restante{attemptsLeft > 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading indicator */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<Loader2 className="w-6 h-6 text-indigo-600 animate-spin mx-auto" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Resend button */}
|
||||||
|
<div className="text-center">
|
||||||
|
<button
|
||||||
|
onClick={sendOTP}
|
||||||
|
disabled={isLoading || remainingTime > 840} // Allow resend after 1 minute
|
||||||
|
className="text-sm text-indigo-600 hover:text-indigo-700 font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Renvoyer le code
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Security notice */}
|
||||||
|
<div className="mt-8 pt-6 border-t border-slate-200">
|
||||||
|
<div className="flex items-start gap-3 text-sm text-slate-600">
|
||||||
|
<Shield className="w-5 h-5 text-green-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-slate-900 mb-1">Authentification sécurisée</p>
|
||||||
|
<p>Le code est valable 15 minutes et ne peut être utilisé qu'une seule fois.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
app/signer/[requestId]/[signerId]/components/PDFViewer.tsx
Normal file
84
app/signer/[requestId]/[signerId]/components/PDFViewer.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="flex items-center justify-center h-full bg-gray-50 rounded-lg border border-gray-200">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||||
|
<p className="text-gray-600">Initialisation du viewer...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full bg-gray-50 rounded-lg overflow-hidden border border-gray-200">
|
||||||
|
<Worker workerUrl={`https://unpkg.com/pdfjs-dist@3.11.174/build/pdf.worker.min.js`}>
|
||||||
|
<div className="relative h-full">
|
||||||
|
<Viewer
|
||||||
|
fileUrl={pdfUrl}
|
||||||
|
plugins={[defaultLayoutPluginInstance]}
|
||||||
|
defaultScale={1.2}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Overlay custom pour les zones de signature */}
|
||||||
|
<div className="absolute top-16 right-4 bg-white/95 backdrop-blur-sm p-3 rounded-lg shadow-lg border border-gray-200">
|
||||||
|
<h4 className="text-xs font-semibold text-gray-700 mb-2">Zones de signature</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{positions.map((pos, index) => {
|
||||||
|
const isCurrentSigner = pos.role === currentSignerRole;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`flex items-center gap-2 text-xs ${
|
||||||
|
isCurrentSigner ? 'text-blue-600 font-semibold' : 'text-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isCurrentSigner ? '✍️' : '📝'}
|
||||||
|
<span>Page {pos.page}: {pos.role}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{positions.length === 0 && (
|
||||||
|
<p className="text-xs text-gray-500 italic">Aucune zone définie</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Worker>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
app/signer/[requestId]/[signerId]/components/ProgressBar.tsx
Normal file
86
app/signer/[requestId]/[signerId]/components/ProgressBar.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="bg-white border-b border-slate-200 py-6">
|
||||||
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{steps.map((step, index) => {
|
||||||
|
const isCompleted = currentStep > step.number;
|
||||||
|
const isCurrent = currentStep === step.number;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={step.number} className="flex items-center flex-1">
|
||||||
|
{/* Step circle */}
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.8, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
transition={{ delay: index * 0.1 }}
|
||||||
|
className={`
|
||||||
|
w-10 h-10 rounded-full flex items-center justify-center font-bold text-sm
|
||||||
|
transition-all duration-300
|
||||||
|
${
|
||||||
|
isCompleted
|
||||||
|
? 'bg-green-600 text-white'
|
||||||
|
: isCurrent
|
||||||
|
? 'bg-indigo-600 text-white ring-4 ring-indigo-100'
|
||||||
|
: 'bg-slate-200 text-slate-500'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{isCompleted ? (
|
||||||
|
<Check className="w-5 h-5" />
|
||||||
|
) : (
|
||||||
|
step.number
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0, y: 5 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: index * 0.1 + 0.1 }}
|
||||||
|
className={`
|
||||||
|
mt-2 text-xs font-medium whitespace-nowrap
|
||||||
|
${
|
||||||
|
isCurrent || isCompleted
|
||||||
|
? 'text-slate-900'
|
||||||
|
: 'text-slate-500'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{step.label}
|
||||||
|
</motion.p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connector line */}
|
||||||
|
{index < steps.length - 1 && (
|
||||||
|
<div className="flex-1 h-1 mx-4 bg-slate-200 rounded-full overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: isCompleted ? '100%' : '0%' }}
|
||||||
|
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||||
|
className="h-full bg-green-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<HTMLCanvasElement>(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<string | null>(null);
|
||||||
|
const [lastPoint, setLastPoint] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
|
||||||
|
// PDF Viewer state
|
||||||
|
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||||
|
const [signaturePositions, setSignaturePositions] = useState<SignPosition[]>([]);
|
||||||
|
const [isPdfLoading, setIsPdfLoading] = useState(true);
|
||||||
|
const [PDFViewerComponent, setPDFViewerComponent] = useState<any>(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<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) {
|
||||||
|
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<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) {
|
||||||
|
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<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) {
|
||||||
|
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 (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -20 }}
|
||||||
|
className="max-w-3xl mx-auto"
|
||||||
|
>
|
||||||
|
<div className="bg-white rounded-2xl shadow-xl overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-gradient-to-r from-indigo-600 to-purple-600 px-8 py-8 text-white">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold mb-2">Signature du document</h2>
|
||||||
|
<p className="text-indigo-100">{documentTitle}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-xs text-indigo-200 uppercase tracking-wider mb-1">Signataire</p>
|
||||||
|
<p className="font-semibold">{signerName}</p>
|
||||||
|
<p className="text-sm text-indigo-100">{signerRole}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-8">
|
||||||
|
{/* PDF Viewer */}
|
||||||
|
{isPdfLoading ? (
|
||||||
|
<div className="mb-8 bg-slate-50 rounded-xl p-12 flex flex-col items-center justify-center">
|
||||||
|
<Loader2 className="w-8 h-8 text-indigo-600 animate-spin mb-3" />
|
||||||
|
<p className="text-slate-600">Chargement du document...</p>
|
||||||
|
</div>
|
||||||
|
) : pdfUrl && PDFViewerComponent ? (
|
||||||
|
<div className="mb-8">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
||||||
|
<FileText className="w-5 h-5 text-indigo-600" />
|
||||||
|
Aperçu du document
|
||||||
|
</h3>
|
||||||
|
<div className="h-[600px]">
|
||||||
|
<PDFViewerComponent
|
||||||
|
pdfUrl={pdfUrl}
|
||||||
|
positions={signaturePositions}
|
||||||
|
currentSignerRole={signerRole}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Info notice */}
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 mb-8 flex gap-3">
|
||||||
|
<Info className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="text-sm text-blue-900">
|
||||||
|
<p className="font-medium mb-1">Dessinez votre signature</p>
|
||||||
|
<p className="text-blue-700">
|
||||||
|
Utilisez votre souris, trackpad ou doigt pour signer dans le cadre ci-dessous. Vous pouvez recommencer à tout moment.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Signature canvas */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="border-2 border-dashed border-slate-300 rounded-xl overflow-hidden bg-white relative group hover:border-indigo-400 transition-colors">
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
onMouseDown={startDrawing}
|
||||||
|
onMouseMove={draw}
|
||||||
|
onMouseUp={stopDrawing}
|
||||||
|
onMouseLeave={stopDrawing}
|
||||||
|
onTouchStart={startDrawing}
|
||||||
|
onTouchMove={draw}
|
||||||
|
onTouchEnd={stopDrawing}
|
||||||
|
className="w-full h-64 cursor-crosshair touch-none"
|
||||||
|
style={{ touchAction: 'none' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!hasDrawn && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||||
|
<div className="text-center">
|
||||||
|
<PenTool className="w-8 h-8 text-slate-400 mx-auto mb-2" />
|
||||||
|
<p className="text-slate-500 text-sm">Signez ici</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Clear button */}
|
||||||
|
{hasDrawn && (
|
||||||
|
<motion.button
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
onClick={clearSignature}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="mt-4 px-4 py-2 text-sm text-slate-700 hover:text-slate-900 font-medium flex items-center gap-2 hover:bg-slate-100 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4" />
|
||||||
|
Recommencer
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Consent checkbox */}
|
||||||
|
<div className="bg-slate-50 rounded-xl p-6 mb-6">
|
||||||
|
<label className="flex items-start gap-4 cursor-pointer group">
|
||||||
|
<div className="flex-shrink-0 pt-1">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={consentChecked}
|
||||||
|
onChange={(e) => setConsentChecked(e.target.checked)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="w-5 h-5 text-indigo-600 border-slate-300 rounded focus:ring-indigo-500 focus:ring-2 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-slate-700 leading-relaxed">
|
||||||
|
<p>
|
||||||
|
Je consens à signer électroniquement le document <span className="font-semibold">"{documentTitle}"</span> et confirme que cette signature a la même valeur juridique qu'une signature manuscrite.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-xs text-slate-500">
|
||||||
|
Votre signature sera horodatée, scellée et archivée de manière sécurisée pendant 10 ans conformément à la réglementation eIDAS.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{error && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl flex items-start gap-3"
|
||||||
|
>
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<p className="text-sm text-red-800">{error}</p>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submit button */}
|
||||||
|
<button
|
||||||
|
onClick={submitSignature}
|
||||||
|
disabled={!hasDrawn || !consentChecked || isSubmitting}
|
||||||
|
className="w-full px-8 py-4 bg-indigo-600 text-white rounded-xl font-semibold hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 hover:shadow-lg flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
Signature en cours...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Check className="w-5 h-5" />
|
||||||
|
Valider ma signature
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Help text */}
|
||||||
|
<p className="mt-4 text-center text-sm text-slate-500">
|
||||||
|
En validant, vous acceptez que votre signature soit juridiquement contraignante.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Document preview (placeholder for future PDF viewer) */}
|
||||||
|
<div className="mt-8 bg-white rounded-2xl shadow-xl p-8">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<FileText className="w-6 h-6 text-slate-600" />
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900">Aperçu du document</h3>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-100 rounded-xl p-8 text-center">
|
||||||
|
<p className="text-slate-600">Le visualiseur de PDF sera intégré prochainement</p>
|
||||||
|
<p className="text-sm text-slate-500 mt-2">Référence: {requestId.slice(0, 8)}...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
233
app/signer/[requestId]/[signerId]/page.tsx
Normal file
233
app/signer/[requestId]/[signerId]/page.tsx
Normal file
|
|
@ -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<string | null>(null);
|
||||||
|
const [signerInfo, setSignerInfo] = useState<SignerStatus | null>(null);
|
||||||
|
const [requestInfo, setRequestInfo] = useState<RequestInfo | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50 flex items-center justify-center p-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="bg-white rounded-2xl shadow-xl p-8 max-w-md w-full"
|
||||||
|
>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Shield className="w-8 h-8 text-red-600" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 mb-2">
|
||||||
|
Erreur de chargement
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-600 mb-6">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/')}
|
||||||
|
className="px-6 py-3 bg-slate-900 text-white rounded-xl font-medium hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
Retour à l'accueil
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentStep === 'loading') {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50 flex items-center justify-center">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="text-center"
|
||||||
|
>
|
||||||
|
<Loader2 className="w-12 h-12 text-indigo-600 animate-spin mx-auto mb-4" />
|
||||||
|
<p className="text-slate-600 font-medium">Chargement...</p>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50">
|
||||||
|
{/* Header avec branding Odentas */}
|
||||||
|
<header className="bg-white border-b border-slate-200 sticky top-0 z-50 backdrop-blur-sm bg-white/90">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-gradient-to-br from-indigo-600 to-purple-600 rounded-xl flex items-center justify-center">
|
||||||
|
<Shield className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-bold text-slate-900">Odentas Sign</h1>
|
||||||
|
<p className="text-xs text-slate-500">Signature électronique sécurisée</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{requestInfo && (
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-xs text-slate-500">Référence</p>
|
||||||
|
<p className="text-sm font-mono font-medium text-slate-900">{requestInfo.ref}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Barre de progression */}
|
||||||
|
{currentStep !== 'completed' && (
|
||||||
|
<ProgressBar
|
||||||
|
currentStep={currentStep === 'otp' ? 1 : 2}
|
||||||
|
totalSteps={2}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Contenu principal avec transitions */}
|
||||||
|
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{currentStep === 'otp' && signerInfo && (
|
||||||
|
<OTPVerification
|
||||||
|
key="otp"
|
||||||
|
signerId={params.signerId}
|
||||||
|
signerName={signerInfo.name}
|
||||||
|
signerEmail={signerInfo.email}
|
||||||
|
documentTitle={requestInfo?.title || ''}
|
||||||
|
onVerified={handleOTPVerified}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentStep === 'signature' && signerInfo && sessionToken && requestInfo && (
|
||||||
|
<SignatureCapture
|
||||||
|
key="signature"
|
||||||
|
signerId={params.signerId}
|
||||||
|
requestId={params.requestId}
|
||||||
|
signerName={signerInfo.name}
|
||||||
|
signerRole={signerInfo.role}
|
||||||
|
documentTitle={requestInfo.title}
|
||||||
|
sessionToken={sessionToken}
|
||||||
|
onCompleted={handleSignatureCompleted}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentStep === 'completed' && signerInfo && requestInfo && (
|
||||||
|
<CompletionScreen
|
||||||
|
key="completed"
|
||||||
|
signerName={signerInfo.name}
|
||||||
|
documentTitle={requestInfo.title}
|
||||||
|
documentRef={requestInfo.ref}
|
||||||
|
signedAt={signerInfo.has_signed ? new Date().toISOString() : null}
|
||||||
|
progress={requestInfo.progress}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Footer avec infos de sécurité */}
|
||||||
|
<footer className="mt-16 border-t border-slate-200 bg-white">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 text-sm text-slate-600">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Check className="w-4 h-4 text-green-600" />
|
||||||
|
<span>Signature conforme eIDAS</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Shield className="w-4 h-4 text-blue-600" />
|
||||||
|
<span>Données cryptées et archivées 10 ans</span>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="mailto:support@odentas.fr"
|
||||||
|
className="text-indigo-600 hover:text-indigo-700 font-medium"
|
||||||
|
>
|
||||||
|
Besoin d'aide ?
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
BIN
contrat_cddu_LYXHX3GI_240V001.pdf
Normal file
BIN
contrat_cddu_LYXHX3GI_240V001.pdf
Normal file
Binary file not shown.
218
create-real-signature.js
Executable file
218
create-real-signature.js
Executable file
|
|
@ -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 <chemin-pdf>');
|
||||||
|
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);
|
||||||
260
create-signature-from-pdf.js
Executable file
260
create-signature-from-pdf.js
Executable file
|
|
@ -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 <chemin-pdf>
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 <chemin-pdf>');
|
||||||
|
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);
|
||||||
308
extract-signature-positions.js
Executable file
308
extract-signature-positions.js
Executable file
|
|
@ -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 <chemin-pdf> [--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);
|
||||||
|
});
|
||||||
13
lambda-odentas-pades-sign/Dockerfile
Normal file
13
lambda-odentas-pades-sign/Dockerfile
Normal file
|
|
@ -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"]
|
||||||
31
lambda-odentas-pades-sign/certs/ca.crt
Normal file
31
lambda-odentas-pades-sign/certs/ca.crt
Normal file
|
|
@ -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-----
|
||||||
52
lambda-odentas-pades-sign/certs/ca.key
Normal file
52
lambda-odentas-pades-sign/certs/ca.key
Normal file
|
|
@ -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-----
|
||||||
1
lambda-odentas-pades-sign/certs/ca.srl
Normal file
1
lambda-odentas-pades-sign/certs/ca.srl
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
6DA9E183C3764EAD480BC6B043B14DAE8F9200EC
|
||||||
58
lambda-odentas-pades-sign/certs/chain-full.pem
Normal file
58
lambda-odentas-pades-sign/certs/chain-full.pem
Normal file
|
|
@ -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-----
|
||||||
27
lambda-odentas-pades-sign/certs/chain.pem
Normal file
27
lambda-odentas-pades-sign/certs/chain.pem
Normal file
|
|
@ -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-----
|
||||||
26
lambda-odentas-pades-sign/certs/signer-new.crt
Normal file
26
lambda-odentas-pades-sign/certs/signer-new.crt
Normal file
|
|
@ -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-----
|
||||||
16
lambda-odentas-pades-sign/certs/signer-new.csr
Normal file
16
lambda-odentas-pades-sign/certs/signer-new.csr
Normal file
|
|
@ -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-----
|
||||||
27
lambda-odentas-pades-sign/certs/signer.crt
Normal file
27
lambda-odentas-pades-sign/certs/signer.crt
Normal file
|
|
@ -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-----
|
||||||
16
lambda-odentas-pades-sign/certs/signer.csr
Normal file
16
lambda-odentas-pades-sign/certs/signer.csr
Normal file
|
|
@ -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-----
|
||||||
5
lambda-odentas-pades-sign/certs/signer_v3.ext
Normal file
5
lambda-odentas-pades-sign/certs/signer_v3.ext
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
basicConstraints=critical,CA:FALSE
|
||||||
|
keyUsage=critical,digitalSignature,nonRepudiation
|
||||||
|
extendedKeyUsage=emailProtection
|
||||||
|
subjectKeyIdentifier=hash
|
||||||
|
authorityKeyIdentifier=keyid,issuer
|
||||||
28
lambda-odentas-pades-sign/certs/tmp.key
Normal file
28
lambda-odentas-pades-sign/certs/tmp.key
Normal file
|
|
@ -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-----
|
||||||
BIN
lambda-odentas-pades-sign/contrat_cddu_LYXHX3GI_240V001.pdf
Normal file
BIN
lambda-odentas-pades-sign/contrat_cddu_LYXHX3GI_240V001.pdf
Normal file
Binary file not shown.
23
lambda-odentas-pades-sign/ecr-read-inline.json
Normal file
23
lambda-odentas-pades-sign/ecr-read-inline.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
4
lambda-odentas-pades-sign/event.json
Normal file
4
lambda-odentas-pades-sign/event.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"requestRef": "CDDU-2025-0102",
|
||||||
|
"sourceKey": "source/contrat_cddu_2025_0102.pdf"
|
||||||
|
}
|
||||||
5
lambda-odentas-pades-sign/event.sample.json
Normal file
5
lambda-odentas-pades-sign/event.sample.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"requestRef": "CDDU-2025-0102",
|
||||||
|
"sourceKey": "source/contrat_cddu_2025_0102.pdf",
|
||||||
|
"meta": { "employer": "Acme", "employee": "Jean Dupont" }
|
||||||
|
}
|
||||||
531
lambda-odentas-pades-sign/helpers/pades.js
Normal file
531
lambda-odentas-pades-sign/helpers/pades.js
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
662
lambda-odentas-pades-sign/helpers/pades_backup.js
Normal file
662
lambda-odentas-pades-sign/helpers/pades_backup.js
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
115
lambda-odentas-pades-sign/index.js
Normal file
115
lambda-odentas-pades-sign/index.js
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
94
lambda-odentas-pades-sign/index_backup.js
Normal file
94
lambda-odentas-pades-sign/index_backup.js
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
1
lambda-odentas-pades-sign/out.json
Normal file
1
lambda-odentas-pades-sign/out.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"statusCode":200,"body":"{\"status\":\"signed\",\"requestRef\":\"CDDU-2025-0102\",\"signed_pdf_s3_key\":\"signed-pades/CDDU-2025-0102.pdf\",\"sha256\":\"f7f128afa4e1e7165fd1dd38cb87b72482bd7c3ea5c34289aa2fd402882cd771\"}"}
|
||||||
17
lambda-odentas-pades-sign/package.json
Normal file
17
lambda-odentas-pades-sign/package.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
lambda-tsaStamp/CDDU-2025-0102.tsr
Normal file
BIN
lambda-tsaStamp/CDDU-2025-0102.tsr
Normal file
Binary file not shown.
21
lambda-tsaStamp/Dockerfile
Normal file
21
lambda-tsaStamp/Dockerfile
Normal file
|
|
@ -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"]
|
||||||
4
lambda-tsaStamp/event.json
Normal file
4
lambda-tsaStamp/event.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"requestRef": "CDDU-2025-0102",
|
||||||
|
"pdfS3Key": "source/contrat_cddu_2025_0102.pdf"
|
||||||
|
}
|
||||||
9
lambda-tsaStamp/iam-policy-s3.json
Normal file
9
lambda-tsaStamp/iam-policy-s3.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [
|
||||||
|
{ "Sid": "S3AccessOdentasSign", "Effect": "Allow",
|
||||||
|
"Action": ["s3:GetObject","s3:PutObject"],
|
||||||
|
"Resource": ["arn:aws:s3:::odentas-sign/*"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
26
lambda-tsaStamp/iam-policy.json
Normal file
26
lambda-tsaStamp/iam-policy.json
Normal file
|
|
@ -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": "*"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
110
lambda-tsaStamp/index.js
Normal file
110
lambda-tsaStamp/index.js
Normal file
|
|
@ -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": "<hex>", // 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 <hex> -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) }) };
|
||||||
|
}
|
||||||
|
|
||||||
1
lambda-tsaStamp/out.json
Normal file
1
lambda-tsaStamp/out.json
Normal file
|
|
@ -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\"}"}
|
||||||
11
lambda-tsaStamp/package.json
Normal file
11
lambda-tsaStamp/package.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
lambda-tsaStamp/trust-policy.json
Normal file
6
lambda-tsaStamp/trust-policy.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [
|
||||||
|
{ "Effect": "Allow", "Principal": { "Service": "lambda.amazonaws.com" }, "Action": "sts:AssumeRole" }
|
||||||
|
]
|
||||||
|
}
|
||||||
81
lib/odentas-sign/crypto.ts
Normal file
81
lib/odentas-sign/crypto.ts
Normal file
|
|
@ -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<string> {
|
||||||
|
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<boolean> {
|
||||||
|
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');
|
||||||
|
}
|
||||||
12
lib/odentas-sign/index.ts
Normal file
12
lib/odentas-sign/index.ts
Normal file
|
|
@ -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';
|
||||||
50
lib/odentas-sign/jwt.ts
Normal file
50
lib/odentas-sign/jwt.ts
Normal file
|
|
@ -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];
|
||||||
|
}
|
||||||
187
lib/odentas-sign/s3.ts
Normal file
187
lib/odentas-sign/s3.ts
Normal file
|
|
@ -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<string, string>;
|
||||||
|
}): 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<Buffer> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<void> {
|
||||||
|
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<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
87
lib/odentas-sign/supabase.ts
Normal file
87
lib/odentas-sign/supabase.ts
Normal file
|
|
@ -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<string, any>;
|
||||||
|
}): Promise<void> {
|
||||||
|
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<boolean> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
152
lib/odentas-sign/types.ts
Normal file
152
lib/odentas-sign/types.ts
Normal file
|
|
@ -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<string, any> | 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<string, any> | 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';
|
||||||
|
};
|
||||||
|
}
|
||||||
1190
package-lock.json
generated
1190
package-lock.json
generated
File diff suppressed because it is too large
Load diff
13
package.json
13
package.json
|
|
@ -8,7 +8,7 @@
|
||||||
"dev:mobile": "./dev-with-network.sh",
|
"dev:mobile": "./dev-with-network.sh",
|
||||||
"dev:network:alt": "PORT=3001 node server.js",
|
"dev:network:alt": "PORT=3001 node server.js",
|
||||||
"test:network": "node test-server.js",
|
"test:network": "node test-server.js",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
|
|
@ -18,22 +18,32 @@
|
||||||
"@aws-sdk/client-ses": "^3.896.0",
|
"@aws-sdk/client-ses": "^3.896.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.894.0",
|
"@aws-sdk/s3-request-presigner": "^3.894.0",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@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/auth-helpers-nextjs": "^0.10.0",
|
||||||
"@supabase/ssr": "^0.7.0",
|
"@supabase/ssr": "^0.7.0",
|
||||||
"@supabase/supabase-js": "^2.57.4",
|
"@supabase/supabase-js": "^2.57.4",
|
||||||
"@tanstack/react-query": "^5.56.2",
|
"@tanstack/react-query": "^5.56.2",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
"aws-sdk": "^2.1692.0",
|
"aws-sdk": "^2.1692.0",
|
||||||
"axios": "^1.12.2",
|
"axios": "^1.12.2",
|
||||||
|
"bcryptjs": "^3.0.2",
|
||||||
|
"canvas-confetti": "^1.9.4",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"dotenv": "^17.2.2",
|
"dotenv": "^17.2.2",
|
||||||
"framer-motion": "^12.23.24",
|
"framer-motion": "^12.23.24",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
"lucide-react": "^0.460.0",
|
"lucide-react": "^0.460.0",
|
||||||
"next": "^14.2.5",
|
"next": "^14.2.5",
|
||||||
"nprogress": "^0.2.0",
|
"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-js": "^1.275.1",
|
||||||
"posthog-node": "^5.9.5",
|
"posthog-node": "^5.9.5",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
|
|
@ -45,6 +55,7 @@
|
||||||
"use-debounce": "^10.0.6"
|
"use-debounce": "^10.0.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/node": "24.3.1",
|
"@types/node": "24.3.1",
|
||||||
"@types/react": "19.1.12",
|
"@types/react": "19.1.12",
|
||||||
|
|
|
||||||
26
signature-real-info.json
Normal file
26
signature-real-info.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
138
signature-templates/README.md
Normal file
138
signature-templates/README.md
Normal file
|
|
@ -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
|
||||||
33
signature-templates/avenant.json
Normal file
33
signature-templates/avenant.json
Normal file
|
|
@ -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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
34
signature-templates/contrat_cddu.json
Normal file
34
signature-templates/contrat_cddu.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
33
signature-templates/contrat_rg.json
Normal file
33
signature-templates/contrat_rg.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
supabase/.temp/cli-latest
Normal file
1
supabase/.temp/cli-latest
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
v2.53.6
|
||||||
1
supabase/.temp/gotrue-version
Normal file
1
supabase/.temp/gotrue-version
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
v2.180.0
|
||||||
1
supabase/.temp/pooler-url
Normal file
1
supabase/.temp/pooler-url
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
postgresql://postgres.fusqtpjififcmgbhmosq@aws-1-eu-west-3.pooler.supabase.com:5432/postgres
|
||||||
1
supabase/.temp/postgres-version
Normal file
1
supabase/.temp/postgres-version
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
17.6.1.003
|
||||||
1
supabase/.temp/project-ref
Normal file
1
supabase/.temp/project-ref
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
fusqtpjififcmgbhmosq
|
||||||
1
supabase/.temp/rest-version
Normal file
1
supabase/.temp/rest-version
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
v13.0.5
|
||||||
1
supabase/.temp/storage-migration
Normal file
1
supabase/.temp/storage-migration
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
fix-object-level
|
||||||
1
supabase/.temp/storage-version
Normal file
1
supabase/.temp/storage-version
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
v1.27.6
|
||||||
118
templates-mails/otp-signature.html
Normal file
118
templates-mails/otp-signature.html
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<body style="margin:0;padding:0;background:#f7f9fc;">
|
||||||
|
<!-- Preheader (masqué) -->
|
||||||
|
<div style="display:none;font-size:1px;line-height:1px;max-height:0;max-width:0;opacity:0;overflow:hidden;">
|
||||||
|
Code de vérification pour signer votre document électroniquement avec Odentas Sign.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background:#f7f9fc;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding:24px;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="max-width:600px;background:#ffffff;border-radius:8px;">
|
||||||
|
<!-- Logo -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:24px;text-align:center;">
|
||||||
|
<img src="https://newstaging.odentas.fr/wp-content/uploads/2025/08/Odentas-Logo-Bleu-Fond-Transparent-4-1.png" width="200" alt="Odentas" style="display:block;border:0;outline:none;text-decoration:none;margin:0 auto;height:auto;">
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Titre -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0 24px 8px 24px;text-align:center;">
|
||||||
|
<div style="font-family:Arial, Helvetica, sans-serif;font-size:22px;font-weight:bold;color:#333;">
|
||||||
|
🔐 Code de vérification
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Sous-titre -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:8px 24px 0 24px;text-align:center;">
|
||||||
|
<div style="font-family:Arial, Helvetica, sans-serif;font-size:14px;color:#666;">
|
||||||
|
Signature électronique Odentas Sign
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Message -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:16px 24px 0 24px;">
|
||||||
|
<div style="font-family:Arial, Helvetica, sans-serif;font-size:16px;line-height:1.5;color:#333;">
|
||||||
|
Bonjour <strong>{{name}}</strong>,<br><br>
|
||||||
|
Vous avez demandé à signer électroniquement le document suivant :<br>
|
||||||
|
<strong>{{documentTitle}}</strong> <span style="color:#666;">({{documentRef}})</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Code OTP (grand format) -->
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding:24px;">
|
||||||
|
<div style="background:linear-gradient(135deg, #667eea 0%, #764ba2 100%);border-radius:12px;padding:24px;">
|
||||||
|
<div style="font-family:'Courier New', monospace;font-size:42px;font-weight:bold;color:#ffffff;letter-spacing:8px;text-align:center;">
|
||||||
|
{{otpCode}}
|
||||||
|
</div>
|
||||||
|
<div style="font-family:Arial, Helvetica, sans-serif;font-size:12px;color:#ffffff;opacity:0.9;text-align:center;margin-top:8px;">
|
||||||
|
Code de vérification
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Instructions -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0 24px 16px 24px;">
|
||||||
|
<div style="font-family:Arial, Helvetica, sans-serif;font-size:14px;line-height:1.5;color:#555;">
|
||||||
|
📌 <strong>Instructions :</strong><br>
|
||||||
|
1. Saisissez ce code sur la page de signature<br>
|
||||||
|
2. Dessinez ou uploadez votre signature<br>
|
||||||
|
3. Acceptez les conditions de signature électronique<br>
|
||||||
|
4. Validez pour signer le document
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Sécurité -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0 24px 16px 24px;">
|
||||||
|
<div style="background:#fff3cd;border-left:4px solid #ffc107;padding:12px;border-radius:4px;">
|
||||||
|
<div style="font-family:Arial, Helvetica, sans-serif;font-size:13px;color:#856404;">
|
||||||
|
⚠️ <strong>Sécurité :</strong><br>
|
||||||
|
• Ce code expire dans <strong>{{expirationMinutes}} minutes</strong><br>
|
||||||
|
• Maximum 3 tentatives de saisie<br>
|
||||||
|
• Ne partagez jamais ce code
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Infos légales -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0 24px 24px 24px;">
|
||||||
|
<div style="font-family:Arial, Helvetica, sans-serif;font-size:12px;color:#666;line-height:1.4;">
|
||||||
|
<strong>📋 Conformité eIDAS</strong><br>
|
||||||
|
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.
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:16px 24px;border-top:1px solid #e5e7eb;background:#f9fafb;border-radius:0 0 8px 8px;">
|
||||||
|
<div style="font-family:Arial, Helvetica, sans-serif;font-size:12px;color:#666;text-align:center;line-height:1.4;">
|
||||||
|
Vous recevez cet email car une signature électronique a été demandée pour vous.<br>
|
||||||
|
Si vous n'êtes pas à l'origine de cette demande, ignorez ce message.<br><br>
|
||||||
|
<a href="mailto:support@odentas.fr" style="color:#2563eb;text-decoration:none;">support@odentas.fr</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
test-contrat.pdf
Normal file
BIN
test-contrat.pdf
Normal file
Binary file not shown.
139
test-interface-signature.sh
Executable file
139
test-interface-signature.sh
Executable file
|
|
@ -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
|
||||||
26
test-odentas-sign-info.json
Normal file
26
test-odentas-sign-info.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
184
test-odentas-sign.js
Executable file
184
test-odentas-sign.js
Executable file
|
|
@ -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);
|
||||||
164
test-signature-flow.sh
Executable file
164
test-signature-flow.sh
Executable file
|
|
@ -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 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
Loading…
Reference in a new issue