feat: Système unifié d'invitation avec emails d'activation
- Créé sendInvitationWithActivationEmail() pour unifier les invitations - Modifié /api/staff/users/invite pour utiliser generateLink + email - Modifié /api/access/nouveau pour envoyer email d'activation - Modifié /api/access POST pour remplacer pending_invites par système direct - Template account-activation mis à jour : * Titre 'Activez votre compte' * Encart avec infos : invitant (statut), organisation, niveau d'accès * Message de contact formaté comme autres emails * Renommage 'Odentas Paie' → 'Espace Paie Odentas' - Fix page /activate : délai 100ms pour hash fragment + redirection 1s - Liens d'activation forcés vers paie.odentas.fr (tests depuis localhost) - Messages UI cohérents : 'Invitation envoyée' au lieu de 'Compte créé'
This commit is contained in:
parent
fc93866d82
commit
e9cb6e7e0e
9 changed files with 994 additions and 78 deletions
792
ODENTAS_SIGN_RASPBERRY_PI_ARCHITECTURE.md
Normal file
792
ODENTAS_SIGN_RASPBERRY_PI_ARCHITECTURE.md
Normal file
|
|
@ -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<Buffer> {
|
||||||
|
|
||||||
|
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<string, any>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **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<Buffer> {
|
||||||
|
// 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<Buffer> {
|
||||||
|
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 ?** 🍓
|
||||||
|
|
@ -14,6 +14,9 @@ export default function ActivateContent() {
|
||||||
const activateAccount = async () => {
|
const activateAccount = async () => {
|
||||||
console.log("🔄 [ACTIVATE] Début de l'activation");
|
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 (#)
|
// Récupérer les paramètres depuis le fragment URL (#)
|
||||||
const hashParams = new URLSearchParams(window.location.hash.substring(1));
|
const hashParams = new URLSearchParams(window.location.hash.substring(1));
|
||||||
const access_token = hashParams.get("access_token");
|
const access_token = hashParams.get("access_token");
|
||||||
|
|
@ -93,10 +96,10 @@ export default function ActivateContent() {
|
||||||
// Ne pas faire échouer l'activation pour ça
|
// 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(() => {
|
setTimeout(() => {
|
||||||
router.push(next);
|
router.push(next);
|
||||||
}, 2000);
|
}, 1000);
|
||||||
} else {
|
} else {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
setMessage("Erreur: Session non créée après l'activation.");
|
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
|
// 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(() => {
|
setTimeout(() => {
|
||||||
router.push(next);
|
router.push(next);
|
||||||
}, 2000);
|
}, 1000);
|
||||||
} else {
|
} else {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
setMessage("Erreur: Session non créée après l'activation.");
|
setMessage("Erreur: Session non créée après l'activation.");
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
|
||||||
import { createClient } from "@supabase/supabase-js";
|
import { createClient } from "@supabase/supabase-js";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
|
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)
|
// Types de rôles autorisés (alignés avec public.role)
|
||||||
const ROLES = ["SUPER_ADMIN","ADMIN","AGENT","COMPTA"] as const;
|
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
|
// Envoyer l'email via le système universel
|
||||||
console.log("📧 [API] Envoi de l'email d'activation 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,
|
firstName,
|
||||||
organizationName: org.name,
|
organizationName: org.name,
|
||||||
activationUrl: actionLink,
|
activationUrl: productionLink,
|
||||||
|
role: requestedRole,
|
||||||
|
inviterName,
|
||||||
|
inviterStatus,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Configurer les préférences d'authentification
|
// Configurer les préférences d'authentification
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { NextResponse } from "next/server";
|
||||||
import { cookies, headers } from "next/headers";
|
import { cookies, headers } from "next/headers";
|
||||||
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
|
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
|
||||||
import { createClient } from "@supabase/supabase-js";
|
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é
|
// import type { Database } from "@/types/supabase"; // Temporairement commenté
|
||||||
|
|
||||||
type OrgRow = { id: string; name?: string; structure_api?: string | null };
|
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;
|
let user_id: string | undefined = existing?.id;
|
||||||
|
|
||||||
if (!user_id) {
|
if (!user_id) {
|
||||||
console.log("👤 Utilisateur non trouvé, création d'une invitation...");
|
console.log("👤 Utilisateur non trouvé, création d'une invitation avec envoi d'email...");
|
||||||
// Enregistrer une invite en attente (gérée par n8n ou tâche backend)
|
|
||||||
const { error: createErr } = await admin
|
// Générer un lien d'invitation et envoyer l'email
|
||||||
.from("pending_invites")
|
const origin = process.env.NEXT_PUBLIC_BASE_URL || "https://paie.odentas.fr";
|
||||||
.insert({ email, first_name, org_id, role })
|
|
||||||
.select("id")
|
const { data: linkData, error: linkError } = await admin.auth.admin.generateLink({
|
||||||
.maybeSingle();
|
type: "invite",
|
||||||
|
email,
|
||||||
if (createErr) {
|
options: {
|
||||||
console.log("❌ Erreur création invitation:", createErr.message);
|
redirectTo: `${origin}/activate`,
|
||||||
return json(400, { error: createErr.message });
|
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" });
|
return json(200, { ok: true, message: "Invitation envoyée" });
|
||||||
} else {
|
} else {
|
||||||
console.log("👤 Utilisateur existant trouvé, ajout/mise à jour membership...");
|
console.log("👤 Utilisateur existant trouvé, ajout/mise à jour membership...");
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,13 @@ import { NextResponse } from "next/server";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
|
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
|
||||||
import { createClient } from "@supabase/supabase-js";
|
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;
|
const ROLES = ["SUPER_ADMIN","ADMIN","AGENT","COMPTA"] as const;
|
||||||
type Role = typeof ROLES[number];
|
type Role = typeof ROLES[number];
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
// 1) Vérifier que l’appelant est Staff
|
|
||||||
const sb = createRouteHandlerClient({ cookies });
|
const sb = createRouteHandlerClient({ cookies });
|
||||||
const { data: { user } } = await sb.auth.getUser();
|
const { data: { user } } = await sb.auth.getUser();
|
||||||
if (!user) return new NextResponse("Unauthorized", { status: 401 });
|
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 });
|
if (!staff?.is_staff) return new NextResponse("Forbidden", { status: 403 });
|
||||||
|
|
||||||
// 2) Lire input
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const email = String(body.email || "").trim().toLowerCase();
|
const email = String(body.email || "").trim().toLowerCase();
|
||||||
const firstName = String(body.firstName || "").trim();
|
const firstName = String(body.firstName || "").trim();
|
||||||
|
|
@ -33,7 +31,6 @@ export async function POST(req: Request) {
|
||||||
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
|
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) Vérifier que l’org est accessible (RLS)
|
|
||||||
const { data: org } = await sb
|
const { data: org } = await sb
|
||||||
.from("organizations")
|
.from("organizations")
|
||||||
.select("id,name,structure_api")
|
.select("id,name,structure_api")
|
||||||
|
|
@ -41,43 +38,70 @@ export async function POST(req: Request) {
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
if (!org) return NextResponse.json({ error: "org_not_found" }, { status: 404 });
|
if (!org) return NextResponse.json({ error: "org_not_found" }, { status: 404 });
|
||||||
|
|
||||||
// 4) Client admin Supabase (service role) — server only
|
|
||||||
const admin = createClient(
|
const admin = createClient(
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
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 } }
|
{ auth: { autoRefreshToken: false, persistSession: false } }
|
||||||
);
|
);
|
||||||
|
|
||||||
// 5) Créer l'utilisateur SANS envoyer d'email (désactivation temporaire des notifications Supabase)
|
const origin = process.env.NEXT_PUBLIC_BASE_URL || "https://paie.odentas.fr";
|
||||||
// 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 { data: linkData, error: linkError } = await admin.auth.admin.generateLink({
|
||||||
|
type: "invite",
|
||||||
email,
|
email,
|
||||||
// Confirmer immédiatement le compte et activer complètement l'utilisateur
|
options: {
|
||||||
email_confirm: true,
|
redirectTo: `${origin}/activate`,
|
||||||
phone_confirm: true,
|
data: {
|
||||||
user_metadata: { first_name: firstName || null },
|
first_name: firstName || null,
|
||||||
// Important : définir une date de confirmation pour que le compte soit complètement activé
|
role,
|
||||||
app_metadata: {},
|
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
|
const { error: memberErr } = await admin
|
||||||
.from("organization_members")
|
.from("organization_members")
|
||||||
.insert({ org_id: orgId, user_id: newUserId, role });
|
.insert({ org_id: orgId, user_id: newUserId, role });
|
||||||
if (memberErr) {
|
if (memberErr) {
|
||||||
// rollback léger : on pourrait désactiver l’utilisateur si besoin
|
|
||||||
return NextResponse.json({ error: memberErr.message }, { status: 400 });
|
return NextResponse.json({ error: memberErr.message }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7) (Optionnel) Préférences d’auth par défaut (magic link activé)
|
|
||||||
await admin
|
await admin
|
||||||
.from("user_auth_prefs")
|
.from("user_auth_prefs")
|
||||||
.upsert({ user_id: newUserId, allow_magic_link: true, allow_password: false });
|
.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({
|
return NextResponse.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
user_id: newUserId,
|
user_id: newUserId,
|
||||||
|
|
@ -88,4 +112,4 @@ export async function POST(req: Request) {
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return new NextResponse(e?.message || "Internal Server Error", { status: 500 });
|
return new NextResponse(e?.message || "Internal Server Error", { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -95,14 +95,14 @@ export default function InviteForm({ orgs, defaultOrgId, hideOrgSelect }: { orgs
|
||||||
<option value="COMPTA">Compta</option>
|
<option value="COMPTA">Compta</option>
|
||||||
</select>
|
</select>
|
||||||
<p className="mt-1 text-xs text-slate-500">
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
Le nouveau compte sera créé par <em>invitation e-mail</em>. 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.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
{done && (
|
{done && (
|
||||||
<div className="rounded-lg border p-3 text-sm">
|
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">
|
||||||
✅ Invitation envoyée à <strong>{done.email}</strong> pour <strong>{done.orgName}</strong> ({done.role})
|
✅ Invitation envoyée à <strong>{done.email}</strong> pour <strong>{done.orgName}</strong> ({done.role})
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -113,7 +113,7 @@ export default function InviteForm({ orgs, defaultOrgId, hideOrgSelect }: { orgs
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className="px-4 py-2 rounded-lg bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-60"
|
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"}
|
||||||
</button>
|
</button>
|
||||||
<a href={hideOrgSelect ? "/vos-acces" : "/staff/utilisateurs"} className="px-4 py-2 rounded-lg border">Annuler</a>
|
<a href={hideOrgSelect ? "/vos-acces" : "/staff/utilisateurs"} className="px-4 py-2 rounded-lg border">Annuler</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -92,15 +92,15 @@ export default function InviteForm({ orgs }: { orgs: Org[] }) {
|
||||||
<option value="COMPTA">Compta</option>
|
<option value="COMPTA">Compta</option>
|
||||||
</select>
|
</select>
|
||||||
<p className="mt-1 text-xs text-slate-500">
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
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é.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
{done && (
|
{done && (
|
||||||
<div className="rounded-lg border p-3 text-sm">
|
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">
|
||||||
✅ Compte créé pour <strong>{done.email}</strong> dans <strong>{done.orgName}</strong> ({done.role}) — aucun e-mail n’a été envoyé.
|
✅ Invitation envoyée à <strong>{done.email}</strong> pour <strong>{done.orgName}</strong> ({done.role})
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -110,7 +110,7 @@ export default function InviteForm({ orgs }: { orgs: Org[] }) {
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className="px-4 py-2 rounded-lg bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-60"
|
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"}
|
||||||
</button>
|
</button>
|
||||||
<a href="/staff/utilisateurs" className="px-4 py-2 rounded-lg border">Annuler</a>
|
<a href="/staff/utilisateurs" className="px-4 py-2 rounded-lg border">Annuler</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,47 @@
|
||||||
import { sendUniversalEmailV2, EmailDataV2 } from "./emailTemplateService";
|
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(
|
export async function sendInvitationEmail(
|
||||||
toEmail: string,
|
toEmail: string,
|
||||||
|
|
@ -316,21 +356,11 @@ export async function sendAccountActivationEmail(
|
||||||
activationUrl: string;
|
activationUrl: string;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const emailData: EmailDataV2 = {
|
// Délégué vers le helper unifié en conservant la compatibilité
|
||||||
|
await sendInvitationWithActivationEmail(toEmail, {
|
||||||
firstName: data.firstName,
|
firstName: data.firstName,
|
||||||
organizationName: data.organizationName,
|
organizationName: data.organizationName,
|
||||||
userEmail: toEmail,
|
activationUrl: data.activationUrl,
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -400,13 +400,14 @@ const EMAIL_TEMPLATES_V2: Record<EmailTypeV2, EmailTemplateV2> = {
|
||||||
},
|
},
|
||||||
|
|
||||||
'account-activation': {
|
'account-activation': {
|
||||||
subject: 'Activez votre espace Odentas Paie – {{organizationName}}',
|
subject: 'Activez votre compte – {{organizationName}}',
|
||||||
title: 'Activez votre espace',
|
title: 'Activez votre compte',
|
||||||
greeting: '{{#if firstName}}Bonjour {{firstName}},{{/if}}',
|
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. <a href="mailto:paie@odentas.fr" style="color:#0B5FFF; text-decoration:none;">Contactez-nous</a> ou répondez à cet e-mail.',
|
||||||
ctaText: 'Activer mon compte',
|
ctaText: 'Activer mon compte',
|
||||||
footerText: 'Vous recevez cet e-mail car un accès vous a été créé sur Odentas Paie.',
|
footerText: 'Vous recevez cet e-mail car un accès vous a été créé sur Espace Paie Odentas.',
|
||||||
preheaderText: 'Activation de votre espace · {{organizationName}} · Activez maintenant',
|
preheaderText: 'Activation de votre compte · {{organizationName}} · Activez maintenant',
|
||||||
colors: {
|
colors: {
|
||||||
headerColor: STANDARD_COLORS.HEADER,
|
headerColor: STANDARD_COLORS.HEADER,
|
||||||
titleColor: '#0F172A',
|
titleColor: '#0F172A',
|
||||||
|
|
@ -418,17 +419,10 @@ const EMAIL_TEMPLATES_V2: Record<EmailTypeV2, EmailTemplateV2> = {
|
||||||
alertIndicatorColor: '#22C55E',
|
alertIndicatorColor: '#22C55E',
|
||||||
},
|
},
|
||||||
infoCard: [
|
infoCard: [
|
||||||
|
{ label: 'Invité par', key: 'inviterNameWithStatus' },
|
||||||
{ label: 'Organisation', key: 'organizationName' },
|
{ label: 'Organisation', key: 'organizationName' },
|
||||||
{ label: 'Votre email', key: 'userEmail' },
|
{ label: 'Niveau d\'accès', key: 'role' },
|
||||||
{ label: 'Statut', key: 'status' },
|
|
||||||
],
|
],
|
||||||
detailsCard: {
|
|
||||||
title: 'Informations',
|
|
||||||
rows: [
|
|
||||||
{ label: 'Plateforme', key: 'platform' },
|
|
||||||
{ label: 'Action requise', key: 'actionRequired' },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
'access-updated': {
|
'access-updated': {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue