diff --git a/ODENTAS_SIGN_RASPBERRY_PI_ARCHITECTURE.md b/ODENTAS_SIGN_RASPBERRY_PI_ARCHITECTURE.md new file mode 100644 index 0000000..e300541 --- /dev/null +++ b/ODENTAS_SIGN_RASPBERRY_PI_ARCHITECTURE.md @@ -0,0 +1,792 @@ +# 🍓 Odentas Sign + Raspberry Pi 5 + EIDUCIO+ - Architecture Complète + +**Système de signature électronique souverain avec scellement qualifié in-house** + +--- + +## 🎯 Vue d'ensemble + +### Workflow Complet + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 1. SIGNATURE EMPLOYEUR (Odentas Sign) │ +│ ↓ Interface web /signer/[requestId]/[employerId] │ +│ ↓ OTP par email → Canvas signature → Upload S3 │ +└─────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────┐ +│ 2. SIGNATURE SALARIÉ (Odentas Sign) │ +│ ↓ Interface web /signer/[requestId]/[salarierId] │ +│ ↓ OTP par email → Canvas signature → Upload S3 │ +└─────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────┐ +│ 3. INJECTION SIGNATURES DANS PDF │ +│ ↓ Webhook completion détecte toutes signatures │ +│ ↓ pdf-lib injecte les images aux positions définies │ +│ ↓ Upload PDF "avec signatures" dans S3 │ +└─────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────┐ +│ 4. SCELLEMENT PAdES (Raspberry Pi 5 + EIDUCIO+) │ +│ ↓ Appel API Raspberry Pi: POST /seal-contract │ +│ ↓ Signature qualifiée avec clé USB EIDUCIO+ │ +│ ↓ Format PAdES avec métadonnées : │ +│ - Name: "Renaud [NOM], Président ODENTAS SAS" │ +│ - Reason: "Certification tiers de confiance Odentas Sign" │ +│ - Location: "Paris, France" │ +│ ↓ Upload PDF scellé dans S3 │ +└─────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────┐ +│ 5. HORODATAGE TSA QUALIFIÉ │ +│ ↓ Calcul hash SHA-256 du PDF scellé │ +│ ↓ Appel TSA (FreeTSA ou Universign) │ +│ ↓ Réception token RFC 3161 │ +│ ↓ Upload token TSA dans S3 │ +└─────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────┐ +│ 6. ARCHIVAGE CONFORME (S3 Object Lock) │ +│ ↓ Bucket: odentas-sign/sealed/ │ +│ ↓ Object Lock: COMPLIANCE mode, 10 ans │ +│ ↓ Mise à jour sign_assets (hashes, dates, clés S3) │ +│ ↓ Evidence bundle complet │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 🏗️ Architecture Technique + +### **1. Frontend : Odentas Sign (Existant)** + +**Interface de signature** : `/app/signer/[requestId]/[signerId]/` + +- ✅ **Authentification OTP** par email (15 min) +- ✅ **Canvas HTML5** pour dessiner la signature +- ✅ **Upload d'image** de signature (JPG/PNG) +- ✅ **Consentement explicite** (case à cocher) +- ✅ **Responsive** (mobile-friendly) + +**Composants clés** : +- `SignatureCapture.tsx` : Interface de dessin/upload +- `OTPVerification.tsx` : Vérification code OTP +- `DocumentPreview.tsx` : Aperçu du PDF à signer + +**API Routes utilisées** : +``` +POST /api/odentas-sign/requests/create +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 +``` + +--- + +### **2. Backend : Next.js API Routes** + +#### **A. Création de demande** +**Route** : `/app/api/odentas-sign/requests/create/route.ts` + +```typescript +// INPUT +{ + contractId: "CDDU-2025-0102", + contractRef: "CDDU-2025-0102", + pdfS3Key: "source/contract-xxx.pdf", + title: "Contrat CDDU - Jean Dupont", + signers: [ + { role: "Employeur", name: "Théâtre X", email: "contact@theatre-x.fr" }, + { role: "Salarié", name: "Jean Dupont", email: "jean.dupont@example.com" } + ], + positions: [ + { role: "Employeur", page: 3, x: 70, y: 120, w: 180, h: 70 }, + { role: "Salarié", page: 3, x: 350, y: 120, w: 180, h: 70 } + ] +} + +// ACTIONS +1. INSERT INTO sign_requests (ref, title, source_s3_key, status='pending') +2. INSERT INTO signers (2 lignes : Employeur + Salarié) +3. INSERT INTO sign_positions (2 lignes) +4. INSERT INTO sign_events ('request_created') +5. Envoyer email avec lien de signature à l'employeur + +// OUTPUT +{ + requestId: "uuid-xxx", + signers: [ + { id: "uuid-employeur", role: "Employeur", signUrl: "/signer/xxx/uuid-employeur" }, + { id: "uuid-salarie", role: "Salarié", signUrl: "/signer/xxx/uuid-salarie" } + ] +} +``` + +#### **B. Webhook de Completion** +**Route** : `/app/api/odentas-sign/webhooks/completion/route.ts` + +**Déclenché quand** : Les 2 signataires ont signé + +```typescript +async function handleCompletion(requestId: string) { + // 1. Récupérer la demande et les signataires + const { data: request } = await supabase + .from('sign_requests') + .select('*, signers(*)') + .eq('id', requestId) + .single(); + + // 2. Vérifier que tous ont signé + const allSigned = request.signers.every(s => s.signed_at !== null); + if (!allSigned) return; + + // 3. Télécharger le PDF source depuis S3 + const sourcePdf = await downloadFromS3(request.source_s3_key); + + // 4. Injecter les signatures graphiques avec pdf-lib + const pdfWithSignatures = await injectSignatureImages(sourcePdf, request.signers); + + // 5. Upload PDF avec signatures (non scellé encore) + const intermediateKey = `intermediate/${request.ref}.pdf`; + await uploadToS3(intermediateKey, pdfWithSignatures); + + // 6. NOUVEAU : Envoyer au Raspberry Pi pour scellement PAdES + const sealedPdf = await sealWithRaspberryPi(intermediateKey, request); + + // 7. NOUVEAU : Obtenir horodatage TSA + const tsaToken = await getTSATimestamp(sealedPdf); + + // 8. Upload final avec Object Lock + const finalKey = `sealed/${request.ref}.pdf`; + await uploadToS3WithObjectLock(finalKey, sealedPdf, { retention: '10 years' }); + + // 9. Mettre à jour sign_assets + await supabase.from('sign_assets').upsert({ + request_id: requestId, + signed_pdf_s3_key: finalKey, + pdf_sha256: sha256(sealedPdf), + tsa_token_sha256: sha256(tsaToken), + sealed_at: new Date(), + seal_algo: 'RSASSA_PKCS1_SHA_256', + seal_certificate_issuer: 'ChamberSign EIDUCIO+', + tsa_provider: 'FreeTSA', + retain_until: new Date(Date.now() + 10 * 365 * 24 * 60 * 60 * 1000) + }); + + // 10. Mettre à jour le statut + await supabase + .from('sign_requests') + .update({ status: 'completed' }) + .eq('id', requestId); +} + +async function sealWithRaspberryPi(pdfS3Key: string, request: SignRequest) { + const authToken = crypto + .createHmac('sha256', process.env.RASPBERRY_PI_SECRET!) + .update('seal-server') + .digest('hex'); + + // Télécharger le PDF + const pdfBuffer = await downloadFromS3(pdfS3Key); + + // Envoyer au Raspberry Pi + const response = await fetch(`${process.env.RASPBERRY_PI_URL}/seal-contract`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${authToken}`, + 'Content-Type': 'application/pdf', + 'X-Request-Id': request.id, + 'X-Contract-Ref': request.ref, + }, + body: pdfBuffer, + }); + + if (!response.ok) { + throw new Error(`Raspberry Pi seal failed: ${await response.text()}`); + } + + return Buffer.from(await response.arrayBuffer()); +} +``` + +--- + +### **3. Serveur Raspberry Pi 5** + +#### **Configuration Matérielle** + +```yaml +Hardware: + Model: Raspberry Pi 5 (8GB RAM) + Storage: SD 64GB + SSD USB 128GB (logs) + Network: Ethernet Gigabit (fibre 8Gb/s) + USB: Clé ChamberSign EIDUCIO+ branchée 24/7 + Power: Alimentation officielle 27W USB-C + Case: Boîtier avec ventilateur actif + +Software: + OS: Raspberry Pi OS Lite (64-bit) + Runtime: Node.js 20 LTS (via nvm) + Process Manager: PM2 (auto-restart) + VPN: Tailscale (accès sécurisé depuis Vercel) + Firewall: UFW (port 3001 uniquement depuis LAN) + +Coût: + One-time: ~230€ (Pi 5 + accessoires) + Annuel: 108€ TTC (EIDUCIO+) + ~10€ (électricité) +``` + +#### **API Node.js Express** + +**Fichier** : `/home/pi/signature-server/src/index.ts` + +```typescript +import express from 'express'; +import bodyParser from 'body-parser'; +import helmet from 'helmet'; +import rateLimit from 'express-rate-limit'; +import crypto from 'crypto'; +import { PKCS11 } from 'pkcs11js'; +import { signPdfPAdES } from './pades-signer'; +import { createLogger } from 'winston'; + +// Configuration +const PORT = 3001; +const PIN = process.env.USB_PIN; // PIN EIDUCIO+ +const PKCS11_MODULE = '/usr/lib/aarch64-linux-gnu/opensc-pkcs11.so'; +const API_SECRET = process.env.API_SECRET; + +// Logger +const logger = createLogger({ + transports: [ + new transports.File({ filename: '/var/log/signature-server/seal.log' }), + new transports.Console() + ] +}); + +const app = express(); + +// Sécurité +app.use(helmet()); +app.use(bodyParser.raw({ type: 'application/pdf', limit: '10mb' })); +app.use(rateLimit({ windowMs: 60 * 60 * 1000, max: 100 })); + +// Authentification +const authenticate = (req, res, next) => { + const token = req.headers['authorization']?.substring(7); + const expectedToken = crypto + .createHmac('sha256', API_SECRET) + .update('seal-server') + .digest('hex'); + + if (token !== expectedToken) { + logger.warn('Token invalide', { ip: req.ip }); + return res.status(403).json({ error: 'Unauthorized' }); + } + next(); +}; + +// Route de scellement pour contrats +app.post('/seal-contract', authenticate, async (req, res) => { + const requestId = req.headers['x-request-id']; + const contractRef = req.headers['x-contract-ref']; + + logger.info('Début scellement', { requestId, contractRef }); + + try { + // Metadata PAdES + const metadata = { + name: "Renaud [NOM]", + organization: "ODENTAS SAS", + role: "Président", + reason: `Certification et archivage sécurisé du contrat ${contractRef} - Odentas Sign`, + location: "Paris, France", + contactInfo: "odentas-sign@odentas.fr", + customProperties: { + service: "Odentas Sign", + tiersDConfiance: true, + contractRef, + requestId, + } + }; + + // Signer avec EIDUCIO+ + const sealedPdf = await signPdfPAdES(req.body, metadata, { + pkcs11Module: PKCS11_MODULE, + pin: PIN, + }); + + logger.info('Scellement réussi', { + requestId, + size: sealedPdf.length, + duration: Date.now() - startTime + }); + + res.setHeader('Content-Type', 'application/pdf'); + res.send(sealedPdf); + + } catch (error) { + logger.error('Erreur scellement', { error: error.message, requestId }); + res.status(500).json({ error: 'Seal failed', details: error.message }); + } +}); + +// Route pour factures +app.post('/seal-invoice', authenticate, async (req, res) => { + const invoiceNumber = req.headers['x-invoice-number']; + + logger.info('Début scellement facture', { invoiceNumber }); + + try { + const metadata = { + name: "Renaud [NOM]", + organization: "ODENTAS SAS", + role: "Président", + reason: `Facture électronique conforme - Article 289 CGI`, + location: "Paris, France", + contactInfo: "facturation@odentas.fr", + customProperties: { + invoiceNumber, + type: "invoice", + } + }; + + const sealedPdf = await signPdfPAdES(req.body, metadata, { + pkcs11Module: PKCS11_MODULE, + pin: PIN, + }); + + logger.info('Facture scellée', { invoiceNumber }); + + res.setHeader('Content-Type', 'application/pdf'); + res.send(sealedPdf); + + } catch (error) { + logger.error('Erreur facture', { error: error.message, invoiceNumber }); + res.status(500).json({ error: 'Seal failed' }); + } +}); + +// Health check +app.get('/health', (req, res) => { + res.json({ + status: 'ok', + uptime: process.uptime(), + usb_key_detected: checkUSBKey(), + timestamp: new Date().toISOString() + }); +}); + +function checkUSBKey() { + try { + const pkcs11 = new PKCS11(); + pkcs11.load(PKCS11_MODULE); + pkcs11.C_Initialize(); + const slots = pkcs11.C_GetSlotList(true); + pkcs11.C_Finalize(); + return slots.length > 0; + } catch { + return false; + } +} + +app.listen(PORT, '0.0.0.0', () => { + logger.info(`Serveur de scellement démarré`, { port: PORT }); +}); +``` + +#### **Module de Signature PAdES** + +**Fichier** : `/home/pi/signature-server/src/pades-signer.ts` + +```typescript +import { PKCS11 } from 'pkcs11js'; +import forge from 'node-forge'; +import { PDFDocument } from 'pdf-lib'; + +export async function signPdfPAdES( + pdfBuffer: Buffer, + metadata: SignatureMetadata, + options: { pkcs11Module: string; pin: string } +): Promise { + + const pkcs11 = new PKCS11(); + + try { + // 1. Initialiser PKCS#11 + pkcs11.load(options.pkcs11Module); + pkcs11.C_Initialize(); + + // 2. Trouver le slot de la clé USB + const slots = pkcs11.C_GetSlotList(true); + if (slots.length === 0) throw new Error('Clé USB non détectée'); + const slotId = slots[0]; + + // 3. Ouvrir session et se connecter + const session = pkcs11.C_OpenSession( + slotId, + pkcs11.CKF_SERIAL_SESSION | pkcs11.CKF_RW_SESSION + ); + pkcs11.C_Login(session, pkcs11.CKU_USER, options.pin); + + // 4. Trouver la clé privée + pkcs11.C_FindObjectsInit(session, [ + { type: pkcs11.CKA_CLASS, value: pkcs11.CKO_PRIVATE_KEY }, + { type: pkcs11.CKA_SIGN, value: true } + ]); + const privateKeys = pkcs11.C_FindObjects(session, 1); + pkcs11.C_FindObjectsFinal(session); + + if (privateKeys.length === 0) throw new Error('Clé privée non trouvée'); + const privateKey = privateKeys[0]; + + // 5. Récupérer le certificat public + pkcs11.C_FindObjectsInit(session, [ + { type: pkcs11.CKA_CLASS, value: pkcs11.CKO_CERTIFICATE } + ]); + const certs = pkcs11.C_FindObjects(session, 1); + pkcs11.C_FindObjectsFinal(session); + + const certDer = pkcs11.C_GetAttributeValue(session, certs[0], [ + { type: pkcs11.CKA_VALUE } + ])[0].value as Buffer; + + const cert = forge.pki.certificateFromAsn1( + forge.asn1.fromDer(forge.util.createBuffer(certDer)) + ); + + // 6. Préparer le PDF pour signature + const pdfDoc = await PDFDocument.load(pdfBuffer); + + // 7. Créer le dictionnaire de signature PAdES + const signingTime = new Date(); + const signatureDict = { + Type: 'Sig', + Filter: 'Adobe.PPKLite', + SubFilter: 'ETSI.CAdES.detached', // Format PAdES + Name: `${metadata.name}, ${metadata.role} - ${metadata.organization}`, + Reason: metadata.reason, + Location: metadata.location, + ContactInfo: metadata.contactInfo, + M: `D:${formatPdfDate(signingTime)}`, + // ByteRange et Contents seront calculés + }; + + // 8. Calculer le hash du PDF + const pdfHash = crypto.createHash('sha256').update(pdfBuffer).digest(); + + // 9. Signer avec PKCS#11 + const mechanism = { mechanism: pkcs11.CKM_SHA256_RSA_PKCS }; + pkcs11.C_SignInit(session, mechanism, privateKey); + const signature = pkcs11.C_Sign(session, pdfHash, Buffer.alloc(256)); + + // 10. Créer structure CAdES (PAdES requiert CAdES) + const signedData = createCAdESStructure(pdfHash, signature, cert, signingTime); + + // 11. Intégrer la signature dans le PDF + const signedPdf = await embedSignatureInPdf(pdfBuffer, signatureDict, signedData); + + // 12. Nettoyer + pkcs11.C_Logout(session); + pkcs11.C_CloseSession(session); + pkcs11.C_Finalize(); + + return signedPdf; + + } catch (error) { + // Toujours nettoyer en cas d'erreur + try { pkcs11.C_Finalize(); } catch {} + throw error; + } +} + +function createCAdESStructure( + messageDigest: Buffer, + signature: Buffer, + certificate: forge.pki.Certificate, + signingTime: Date +): Buffer { + // Créer structure CMS SignedData (simplifié) + const signedAttrs = [ + { oid: '1.2.840.113549.1.9.3', value: 'application/pdf' }, // contentType + { oid: '1.2.840.113549.1.9.5', value: signingTime }, // signingTime + { oid: '1.2.840.113549.1.9.4', value: messageDigest }, // messageDigest + ]; + + // Utiliser node-forge pour créer structure ASN.1 + const p7 = forge.pkcs7.createSignedData(); + p7.addCertificate(certificate); + p7.addSigner({ + key: { sign: () => signature }, // Signature déjà calculée + certificate: certificate, + digestAlgorithm: forge.pki.oids.sha256, + authenticatedAttributes: signedAttrs + }); + + return Buffer.from(forge.asn1.toDer(p7.toAsn1()).getBytes(), 'binary'); +} + +function embedSignatureInPdf( + pdfBuffer: Buffer, + signatureDict: any, + signedData: Buffer +): Buffer { + // Utiliser pdf-lib ou manipulation directe du PDF + // Insérer le dictionnaire de signature dans le PDF + // ByteRange = [0, offsetBeforeSignature, offsetAfterSignature, lengthAfterSignature] + // Contents = signedData en hexadécimal + + // Implémentation simplifiée (voir librairies comme node-signpdf) + // ... + + return modifiedPdfBuffer; +} + +function formatPdfDate(date: Date): string { + // Format: YYYYMMDDHHmmssZ + return date.toISOString().replace(/[-:TZ]/g, '').slice(0, 14) + 'Z'; +} + +interface SignatureMetadata { + name: string; + organization: string; + role: string; + reason: string; + location: string; + contactInfo: string; + customProperties?: Record; +} +``` + +--- + +### **4. Service d'Horodatage TSA** + +#### **Option A : FreeTSA (Gratuit)** + +```typescript +// /lib/tsa/freetsa.ts +import fetch from 'node-fetch'; +import crypto from 'crypto'; +import forge from 'node-forge'; + +export async function getFreeTSATimestamp(pdfHash: Buffer): Promise { + // 1. Créer requête TSA (RFC 3161) + const tsq = forge.pki.createTimeStampRequest({ + messageImprint: { + hashAlgorithm: forge.pki.oids.sha256, + digest: pdfHash.toString('binary') + }, + certReq: true + }); + + const tsqDer = Buffer.from(forge.asn1.toDer(tsq.toAsn1()).getBytes(), 'binary'); + + // 2. Envoyer à FreeTSA + const response = await fetch('https://freetsa.org/tsr', { + method: 'POST', + headers: { 'Content-Type': 'application/timestamp-query' }, + body: tsqDer, + }); + + if (!response.ok) { + throw new Error(`TSA request failed: ${response.statusText}`); + } + + // 3. Récupérer réponse (TSR) + const tsrDer = Buffer.from(await response.arrayBuffer()); + + // 4. Valider la réponse + const tsr = forge.pki.createTimeStampResponse( + forge.asn1.fromDer(forge.util.createBuffer(tsrDer)) + ); + + if (tsr.status !== 0) { + throw new Error(`TSA response error: ${tsr.statusString}`); + } + + return tsrDer; +} +``` + +#### **Option B : Universign TSA (50-100€/an)** + +```typescript +// /lib/tsa/universign.ts +export async function getUniversignTimestamp(pdfHash: Buffer): Promise { + const response = await fetch('https://timestamp.universign.eu/rfc3161', { + method: 'POST', + headers: { + 'Content-Type': 'application/timestamp-query', + 'Authorization': `Bearer ${process.env.UNIVERSIGN_API_KEY}` + }, + body: createTSQRequest(pdfHash), + }); + + return Buffer.from(await response.arrayBuffer()); +} +``` + +--- + +### **5. Tables Supabase - Ajouts** + +#### **Migration : Colonnes Raspberry Pi** + +```sql +-- Ajouter colonnes pour certificat EIDUCIO+ +ALTER TABLE sign_assets +ADD COLUMN seal_certificate_issuer TEXT DEFAULT 'ChamberSign EIDUCIO+', +ADD COLUMN seal_certificate_subject TEXT DEFAULT 'Renaud [NOM], Président ODENTAS SAS', +ADD COLUMN seal_certificate_serial TEXT, +ADD COLUMN tsa_provider TEXT DEFAULT 'FreeTSA', +ADD COLUMN tsa_url TEXT; + +-- Index pour recherche +CREATE INDEX idx_sign_assets_sealed_at ON sign_assets(sealed_at); +CREATE INDEX idx_sign_assets_retain_until ON sign_assets(retain_until); +``` + +--- + +### **6. Configuration S3 Object Lock** + +```bash +# Créer bucket dédié avec Object Lock +aws s3api create-bucket \ + --bucket odentas-sign-sealed \ + --region eu-west-3 \ + --object-lock-enabled-for-bucket + +# Configurer Object Lock par défaut (10 ans) +aws s3api put-object-lock-configuration \ + --bucket odentas-sign-sealed \ + --object-lock-configuration '{ + "ObjectLockEnabled": "Enabled", + "Rule": { + "DefaultRetention": { + "Mode": "COMPLIANCE", + "Years": 10 + } + } + }' + +# Activer versioning (requis pour Object Lock) +aws s3api put-bucket-versioning \ + --bucket odentas-sign-sealed \ + --versioning-configuration Status=Enabled +``` + +--- + +## 🔒 Sécurité + +### **1. Réseau Raspberry Pi** + +```bash +# Tailscale VPN (recommandé) +curl -fsSL https://tailscale.com/install.sh | sh +sudo tailscale up + +# Firewall UFW +sudo ufw allow from 192.168.1.0/24 to any port 3001 +sudo ufw enable + +# Fail2ban (protection brute-force) +sudo apt install fail2ban +``` + +### **2. Secrets Management** + +```bash +# .env sur Raspberry Pi +PORT=3001 +USB_PIN=YOUR_EIDUCIO_PIN +API_SECRET=your-super-secret-key-256-bits +LOG_LEVEL=info + +# Variables Vercel +RASPBERRY_PI_URL=http://100.64.0.50:3001 # Tailscale IP +RASPBERRY_PI_SECRET=same-as-above +``` + +### **3. Monitoring & Alertes** + +```bash +# PM2 monitoring +pm2 install pm2-logrotate +pm2 set pm2-logrotate:max_size 100M +pm2 set pm2-logrotate:retain 30 + +# Alertes email en cas d'erreur +npm install nodemailer +# Configurer dans le serveur pour envoyer email si clé USB déconnectée +``` + +--- + +## 📊 Performance + +### **Temps de Traitement** + +| Étape | Durée | +|-------|-------| +| Signature Employeur (OTP + Canvas) | ~1-2 min (utilisateur) | +| Signature Salarié (OTP + Canvas) | ~1-2 min (utilisateur) | +| Injection signatures dans PDF | ~500ms | +| Scellement PAdES (Raspberry Pi) | ~800ms | +| Horodatage TSA | ~200ms | +| Upload S3 Object Lock | ~300ms | +| **Total automatique** | **~2 secondes** | + +--- + +## 💰 Coûts Récapitulatifs + +| Élément | Coût | +|---------|------| +| **Raspberry Pi 5 + accessoires** | 230€ one-time | +| **Clé EIDUCIO+ ChamberSign** | 108€ TTC/an | +| **TSA FreeTSA** | Gratuit | +| **TSA Universign (optionnel)** | 50-100€/an | +| **S3 Storage (10 000 contrats/an)** | ~5€/an | +| **Électricité Raspberry Pi** | ~10€/an | +| **Total première année** | ~353€ | +| **Années suivantes** | ~123€/an | + +**Comparaison** : Service cloud (Universign/DocuSign) = 1500-3000€/an + +--- + +## 🚀 Mise en Production + +### **Checklist** + +- [ ] Commander clé EIDUCIO+ (90€ HT/an) +- [ ] Acheter Raspberry Pi 5 + accessoires +- [ ] Installer Raspberry Pi OS + Node.js + PM2 +- [ ] Brancher clé USB EIDUCIO+ et tester détection +- [ ] Déployer serveur signature-server +- [ ] Configurer Tailscale VPN +- [ ] Créer bucket S3 avec Object Lock +- [ ] Migrer tables Supabase (colonnes seal_*) +- [ ] Tester workflow complet en staging +- [ ] Déployer en production +- [ ] Former équipe staff sur nouvelle interface +- [ ] Désactiver DocuSeal progressivement + +--- + +## 📚 Documentation Complémentaire + +- **ODENTAS_SIGN_API.md** : API complète Odentas Sign +- **SIGNATURE_MULTI_PARTIES.md** : Signatures multiples avec metadata +- **TEST_PADES_TSA.md** : Tests du workflow complet +- **ODENTAS_SIGN_INTERFACE.md** : Interface utilisateur + +--- + +**Questions ou besoin d'aide pour l'implémentation ?** 🍓 diff --git a/app/activate/ActivateContent.tsx b/app/activate/ActivateContent.tsx index bdac7c8..4380573 100644 --- a/app/activate/ActivateContent.tsx +++ b/app/activate/ActivateContent.tsx @@ -14,6 +14,9 @@ export default function ActivateContent() { const activateAccount = async () => { console.log("🔄 [ACTIVATE] Début de l'activation"); + // Attendre un instant pour que le hash fragment soit disponible + await new Promise(resolve => setTimeout(resolve, 100)); + // Récupérer les paramètres depuis le fragment URL (#) const hashParams = new URLSearchParams(window.location.hash.substring(1)); const access_token = hashParams.get("access_token"); @@ -93,10 +96,10 @@ export default function ActivateContent() { // Ne pas faire échouer l'activation pour ça } - // Attendre un peu pour laisser le temps aux cookies de se propager + // Redirection immédiate - les cookies sont déjà définis setTimeout(() => { router.push(next); - }, 2000); + }, 1000); } else { setStatus("error"); setMessage("Erreur: Session non créée après l'activation."); @@ -176,10 +179,10 @@ export default function ActivateContent() { // Ne pas faire échouer l'activation pour ça } - // Attendre un peu pour laisser le temps aux cookies de se propager + // Redirection immédiate - les cookies sont déjà définis setTimeout(() => { router.push(next); - }, 2000); + }, 1000); } else { setStatus("error"); setMessage("Erreur: Session non créée après l'activation."); diff --git a/app/api/access/nouveau/route.ts b/app/api/access/nouveau/route.ts index 7f28d6e..1e4acf5 100644 --- a/app/api/access/nouveau/route.ts +++ b/app/api/access/nouveau/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { createClient } from "@supabase/supabase-js"; import { cookies } from "next/headers"; import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; -import { sendAccountActivationEmail } from "@/lib/emailMigrationHelpers"; +import { sendInvitationWithActivationEmail } from "@/lib/emailMigrationHelpers"; // Types de rôles autorisés (alignés avec public.role) const ROLES = ["SUPER_ADMIN","ADMIN","AGENT","COMPTA"] as const; @@ -161,10 +161,30 @@ export async function POST(req: Request) { // Envoyer l'email via le système universel console.log("📧 [API] Envoi de l'email d'activation via le système universel..."); - await sendAccountActivationEmail(email, { + // Récupérer le rôle du créateur dans l'organisation (si disponible) + let inviterStatus: string | undefined = undefined; + try { + const { data: inviterMember } = await sb + .from('organization_members') + .select('role') + .eq('org_id', org.id) + .eq('user_id', user.id) + .maybeSingle(); + inviterStatus = (inviterMember as any)?.role || undefined; + } catch {} + + const inviterName = (user.user_metadata as any)?.first_name || user.email || 'Administrateur'; + + // Forcer le domaine production dans le lien d'activation pour les tests depuis localhost + const productionLink = actionLink.replace(/http:\/\/localhost:\d+/, 'https://paie.odentas.fr'); + + await sendInvitationWithActivationEmail(email, { firstName, organizationName: org.name, - activationUrl: actionLink, + activationUrl: productionLink, + role: requestedRole, + inviterName, + inviterStatus, }); // Configurer les préférences d'authentification diff --git a/app/api/access/route.ts b/app/api/access/route.ts index 395beca..5c2be1e 100644 --- a/app/api/access/route.ts +++ b/app/api/access/route.ts @@ -4,7 +4,7 @@ import { NextResponse } from "next/server"; import { cookies, headers } from "next/headers"; import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; import { createClient } from "@supabase/supabase-js"; -import { sendAccessUpdatedEmail } from "@/lib/emailMigrationHelpers"; +import { sendAccessUpdatedEmail, sendInvitationWithActivationEmail } from "@/lib/emailMigrationHelpers"; // import type { Database } from "@/types/supabase"; // Temporairement commenté type OrgRow = { id: string; name?: string; structure_api?: string | null }; @@ -408,20 +408,73 @@ export async function POST(req: Request) { let user_id: string | undefined = existing?.id; if (!user_id) { - console.log("👤 Utilisateur non trouvé, création d'une invitation..."); - // Enregistrer une invite en attente (gérée par n8n ou tâche backend) - const { error: createErr } = await admin - .from("pending_invites") - .insert({ email, first_name, org_id, role }) - .select("id") - .maybeSingle(); - - if (createErr) { - console.log("❌ Erreur création invitation:", createErr.message); - return json(400, { error: createErr.message }); + console.log("👤 Utilisateur non trouvé, création d'une invitation avec envoi d'email..."); + + // Générer un lien d'invitation et envoyer l'email + const origin = process.env.NEXT_PUBLIC_BASE_URL || "https://paie.odentas.fr"; + + const { data: linkData, error: linkError } = await admin.auth.admin.generateLink({ + type: "invite", + email, + options: { + redirectTo: `${origin}/activate`, + data: { + first_name: first_name || null, + role, + org_id, + org_name: org_id, // Sera enrichi après + }, + }, + }); + + if (linkError) { + console.log("❌ Erreur génération lien:", linkError.message); + return json(400, { error: linkError.message }); } - console.log("✅ Invitation créée avec succès"); + const actionLink = linkData?.properties?.action_link; + const newUserId = linkData?.user?.id; + + if (!newUserId || !actionLink) { + console.log("❌ Erreur: lien ou user_id manquant"); + return json(400, { error: "invite_failed" }); + } + + // Attacher à l'organisation + const { error: memberError } = await admin + .from("organization_members") + .insert({ org_id, user_id: newUserId, role, revoked: false }); + + if (memberError) { + console.log("❌ Erreur création membership:", memberError.message); + return json(400, { error: memberError.message }); + } + + // Configurer les préférences d'auth + await admin + .from("user_auth_prefs") + .upsert({ user_id: newUserId, allow_magic_link: true, allow_password: false }); + + // Récupérer le nom de l'organisation + const { data: orgData } = await admin + .from('organizations') + .select('name') + .eq('id', org_id) + .maybeSingle(); + + // Envoyer l'email d'activation + try { + await sendInvitationWithActivationEmail(email, { + firstName: first_name || undefined, + organizationName: (orgData as any)?.name || '', + activationUrl: actionLink, + role, + }); + } catch (emailErr) { + console.warn('⚠️ Envoi email invitation échoué (non-bloquant):', (emailErr as any)?.message || emailErr); + } + + console.log("✅ Invitation créée et email envoyé avec succès"); return json(200, { ok: true, message: "Invitation envoyée" }); } else { console.log("👤 Utilisateur existant trouvé, ajout/mise à jour membership..."); diff --git a/app/api/staff/users/invite/route.ts b/app/api/staff/users/invite/route.ts index 79ee14f..dde6571 100644 --- a/app/api/staff/users/invite/route.ts +++ b/app/api/staff/users/invite/route.ts @@ -2,14 +2,13 @@ import { NextResponse } from "next/server"; import { cookies } from "next/headers"; import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; import { createClient } from "@supabase/supabase-js"; +import { sendInvitationWithActivationEmail } from "@/lib/emailMigrationHelpers"; -// Types de rôles autorisés (alignés avec public.role) const ROLES = ["SUPER_ADMIN","ADMIN","AGENT","COMPTA"] as const; type Role = typeof ROLES[number]; export async function POST(req: Request) { try { - // 1) Vérifier que l’appelant est Staff const sb = createRouteHandlerClient({ cookies }); const { data: { user } } = await sb.auth.getUser(); if (!user) return new NextResponse("Unauthorized", { status: 401 }); @@ -22,7 +21,6 @@ export async function POST(req: Request) { if (!staff?.is_staff) return new NextResponse("Forbidden", { status: 403 }); - // 2) Lire input const body = await req.json(); const email = String(body.email || "").trim().toLowerCase(); const firstName = String(body.firstName || "").trim(); @@ -33,7 +31,6 @@ export async function POST(req: Request) { return NextResponse.json({ error: "invalid_input" }, { status: 400 }); } - // 3) Vérifier que l’org est accessible (RLS) const { data: org } = await sb .from("organizations") .select("id,name,structure_api") @@ -41,43 +38,70 @@ export async function POST(req: Request) { .maybeSingle(); if (!org) return NextResponse.json({ error: "org_not_found" }, { status: 404 }); - // 4) Client admin Supabase (service role) — server only const admin = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.SUPABASE_SERVICE_ROLE_KEY!, // ⚠️ server-side only + process.env.SUPABASE_SERVICE_ROLE_KEY!, { auth: { autoRefreshToken: false, persistSession: false } } ); - // 5) Créer l'utilisateur SANS envoyer d'email (désactivation temporaire des notifications Supabase) - // On utilise createUser plutôt que inviteUserByEmail pour éviter l'envoi d'un e-mail automatique. - const { data: created, error: createErr } = await admin.auth.admin.createUser({ + const origin = process.env.NEXT_PUBLIC_BASE_URL || "https://paie.odentas.fr"; + + const { data: linkData, error: linkError } = await admin.auth.admin.generateLink({ + type: "invite", email, - // Confirmer immédiatement le compte et activer complètement l'utilisateur - email_confirm: true, - phone_confirm: true, - user_metadata: { first_name: firstName || null }, - // Important : définir une date de confirmation pour que le compte soit complètement activé - app_metadata: {}, + options: { + redirectTo: `${origin}/activate`, + data: { + first_name: firstName || null, + role, + org_id: org.id, + org_name: org.name, + structure_api: org.structure_api ?? null, + }, + } }); - if (createErr) return NextResponse.json({ error: createErr.message }, { status: 400 }); + + if (linkError) { + return NextResponse.json({ error: "link_generation_failed", message: linkError.message }, { status: 400 }); + } + + const actionLink = linkData?.properties?.action_link; + const newUserId = linkData?.user?.id; + + if (!newUserId || !actionLink) { + return NextResponse.json({ error: "invite_failed" }, { status: 400 }); + } - const newUserId = created.user?.id; - if (!newUserId) return NextResponse.json({ error: "create_failed" }, { status: 400 }); - - // 6) Attacher à l’organisation avec le rôle const { error: memberErr } = await admin .from("organization_members") .insert({ org_id: orgId, user_id: newUserId, role }); if (memberErr) { - // rollback léger : on pourrait désactiver l’utilisateur si besoin return NextResponse.json({ error: memberErr.message }, { status: 400 }); } - // 7) (Optionnel) Préférences d’auth par défaut (magic link activé) await admin .from("user_auth_prefs") .upsert({ user_id: newUserId, allow_magic_link: true, allow_password: false }); + try { + const inviterName = (user.user_metadata as any)?.first_name || user.email || 'Administrateur'; + const inviterStatus = staff?.is_staff ? 'Staff' : 'Utilisateur'; + + // Forcer le domaine production dans le lien d'activation pour les tests depuis localhost + const productionLink = actionLink.replace(/http:\/\/localhost:\d+/, 'https://paie.odentas.fr'); + + await sendInvitationWithActivationEmail(email, { + firstName: firstName || undefined, + organizationName: org.name, + activationUrl: productionLink, + role, + inviterName, + inviterStatus, + }); + } catch (emailErr) { + console.warn('⚠️ Envoi email invitation échoué (non-bloquant):', (emailErr as any)?.message || emailErr); + } + return NextResponse.json({ ok: true, user_id: newUserId, @@ -88,4 +112,4 @@ export async function POST(req: Request) { } catch (e: any) { return new NextResponse(e?.message || "Internal Server Error", { status: 500 }); } -} \ No newline at end of file +} diff --git a/components/InviteForm.tsx b/components/InviteForm.tsx index 1758330..104c46e 100644 --- a/components/InviteForm.tsx +++ b/components/InviteForm.tsx @@ -95,14 +95,14 @@ export default function InviteForm({ orgs, defaultOrgId, hideOrgSelect }: { orgs

- Le nouveau compte sera créé par invitation e-mail. Par défaut, il pourra se connecter par envoi de code. + Un email d'activation sera envoyé automatiquement avec un lien sécurisé pour activer le compte.

{error &&

{error}

} {done && ( -
+
✅ Invitation envoyée à {done.email} pour {done.orgName} ({done.role})
)} @@ -113,7 +113,7 @@ export default function InviteForm({ orgs, defaultOrgId, hideOrgSelect }: { orgs disabled={submitting} className="px-4 py-2 rounded-lg bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-60" > - {submitting ? "Envoi…" : "Créer le compte"} + {submitting ? "Envoi en cours…" : "Inviter l'utilisateur"} Annuler
diff --git a/components/staff/InviteForm.tsx b/components/staff/InviteForm.tsx index f2bcc48..8aa7c97 100644 --- a/components/staff/InviteForm.tsx +++ b/components/staff/InviteForm.tsx @@ -92,15 +92,15 @@ export default function InviteForm({ orgs }: { orgs: Org[] }) {

- Le compte sera créé sans envoyer d’e-mail automatique pour l’instant. Vous pourrez partager un lien de connexion manuellement. + Un email d'activation sera automatiquement envoyé à l'utilisateur avec un lien sécurisé.

{error &&

{error}

} {done && ( -
- ✅ Compte créé pour {done.email} dans {done.orgName} ({done.role}) — aucun e-mail n’a été envoyé. +
+ ✅ Invitation envoyée à {done.email} pour {done.orgName} ({done.role})
)} @@ -110,7 +110,7 @@ export default function InviteForm({ orgs }: { orgs: Org[] }) { disabled={submitting} className="px-4 py-2 rounded-lg bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-60" > - {submitting ? "Envoi…" : "Créer le compte"} + {submitting ? "Envoi en cours…" : "Inviter l'utilisateur"} Annuler
diff --git a/lib/emailMigrationHelpers.ts b/lib/emailMigrationHelpers.ts index ac2ccf1..9f82f09 100644 --- a/lib/emailMigrationHelpers.ts +++ b/lib/emailMigrationHelpers.ts @@ -2,7 +2,47 @@ import { sendUniversalEmailV2, EmailDataV2 } from "./emailTemplateService"; /** - * Migration helper pour les emails d'invitation + * Envoie un email d'invitation avec activation de compte (système unifié) + * Utilisé pour les nouvelles invitations d'utilisateurs (staff ou clients) + */ +export async function sendInvitationWithActivationEmail( + toEmail: string, + data: { + firstName?: string; + organizationName: string; + activationUrl: string; + role?: string; + inviterName?: string; + inviterStatus?: string; + } +) { + // Créer le champ combiné "Nom (Statut)" + const inviterNameWithStatus = data.inviterName + ? data.inviterStatus + ? `${data.inviterName} (${data.inviterStatus})` + : data.inviterName + : undefined; + + const emailData: EmailDataV2 = { + firstName: data.firstName, + organizationName: data.organizationName, + userEmail: toEmail, + platform: 'Espace Paie Odentas', + ctaUrl: data.activationUrl, + role: data.role, + inviterNameWithStatus, + }; + + await sendUniversalEmailV2({ + type: 'account-activation', + toEmail, + subject: `Activez votre compte – ${data.organizationName}`, + data: emailData, + }); +} + +/** + * Migration helper pour les emails d'invitation (ancienne version, maintenir pour compatibilité) */ export async function sendInvitationEmail( toEmail: string, @@ -316,21 +356,11 @@ export async function sendAccountActivationEmail( activationUrl: string; } ) { - const emailData: EmailDataV2 = { + // Délégué vers le helper unifié en conservant la compatibilité + await sendInvitationWithActivationEmail(toEmail, { firstName: data.firstName, organizationName: data.organizationName, - userEmail: toEmail, - status: "En attente d'activation", - platform: 'Odentas Paie', - actionRequired: 'Activation du compte', - ctaUrl: data.activationUrl, - }; - - await sendUniversalEmailV2({ - type: 'account-activation', - toEmail, - subject: `Activez votre espace Odentas Paie – ${data.organizationName}`, - data: emailData, + activationUrl: data.activationUrl, }); } diff --git a/lib/emailTemplateService.ts b/lib/emailTemplateService.ts index beadf35..2d7061f 100644 --- a/lib/emailTemplateService.ts +++ b/lib/emailTemplateService.ts @@ -400,13 +400,14 @@ const EMAIL_TEMPLATES_V2: Record = { }, 'account-activation': { - subject: 'Activez votre espace Odentas Paie – {{organizationName}}', - title: 'Activez votre espace', + subject: 'Activez votre compte – {{organizationName}}', + title: 'Activez votre compte', greeting: '{{#if firstName}}Bonjour {{firstName}},{{/if}}', - mainMessage: 'Bienvenue ! Vous avez été invité à rejoindre {{organizationName}} sur Odentas Paie.', + mainMessage: 'Bienvenue ! Vous avez été invité à rejoindre {{organizationName}} sur Espace Paie Odentas.', + closingMessage: 'Toute l\'équipe Odentas reste à votre disposition si vous avez des questions. Contactez-nous ou répondez à cet e-mail.', ctaText: 'Activer mon compte', - footerText: 'Vous recevez cet e-mail car un accès vous a été créé sur Odentas Paie.', - preheaderText: 'Activation de votre espace · {{organizationName}} · Activez maintenant', + footerText: 'Vous recevez cet e-mail car un accès vous a été créé sur Espace Paie Odentas.', + preheaderText: 'Activation de votre compte · {{organizationName}} · Activez maintenant', colors: { headerColor: STANDARD_COLORS.HEADER, titleColor: '#0F172A', @@ -418,17 +419,10 @@ const EMAIL_TEMPLATES_V2: Record = { alertIndicatorColor: '#22C55E', }, infoCard: [ + { label: 'Invité par', key: 'inviterNameWithStatus' }, { label: 'Organisation', key: 'organizationName' }, - { label: 'Votre email', key: 'userEmail' }, - { label: 'Statut', key: 'status' }, + { label: 'Niveau d\'accès', key: 'role' }, ], - detailsCard: { - title: 'Informations', - rows: [ - { label: 'Plateforme', key: 'platform' }, - { label: 'Action requise', key: 'actionRequired' }, - ] - } }, 'access-updated': {