espace-paie-odentas/app/api/odentas-sign/signers/[id]/send-otp/route.ts
odentas b790faf12c feat: Implémentation complète du système Odentas Sign
- Remplacement de DocuSeal par solution souveraine Odentas Sign
- Système d'authentification OTP pour signataires (bcryptjs + JWT)
- 8 routes API: send-otp, verify-otp, sign, pdf-url, positions, status, webhook, signers
- Interface moderne avec canvas de signature et animations (framer-motion, confetti)
- Système de templates pour auto-détection des positions de signature (CDDU, RG, avenants)
- PDF viewer avec @react-pdf-viewer (compatible Next.js)
- Stockage S3: source/, signatures/, evidence/, signed/, certs/
- Tables Supabase: sign_requests, signers, sign_positions, sign_events, sign_assets
- Evidence bundle automatique (JSON metadata + timestamps)
- Templates emails: OTP et completion
- Scripts Lambda prêts: pades-sign (KMS seal) et tsaStamp (RFC3161)
- Mode test détecté automatiquement (emails whitelist)
- Tests complets avec PDF CDDU réel (2 signataires)
2025-10-27 19:03:07 +01:00

218 lines
6.9 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { supabaseAdmin, logSignEvent } from '@/lib/odentas-sign/supabase';
import { generateOTP, hashOTP, getOTPExpiration } from '@/lib/odentas-sign/crypto';
/**
* POST /api/odentas-sign/signers/[id]/send-otp
*
* Génère et envoie un code OTP par email à un signataire
*/
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const signerId = params.id;
// Récupérer le signataire
const { data: signer, error: signerError } = await supabaseAdmin
.from('signers')
.select('*, sign_requests(id, ref, title, status)')
.eq('id', signerId)
.single();
if (signerError || !signer) {
return NextResponse.json(
{ error: 'Signataire introuvable' },
{ status: 404 }
);
}
// Vérifier que la demande n'est pas annulée ou complétée
if (signer.sign_requests.status === 'cancelled') {
return NextResponse.json(
{ error: 'Cette demande de signature a été annulée' },
{ status: 400 }
);
}
if (signer.sign_requests.status === 'completed') {
return NextResponse.json(
{ error: 'Cette demande de signature est déjà complétée' },
{ status: 400 }
);
}
// Vérifier si le signataire a déjà signé
if (signer.signed_at) {
return NextResponse.json(
{ error: 'Vous avez déjà signé ce document' },
{ status: 400 }
);
}
// Limite de fréquence : ne pas renvoyer d'OTP si un a été envoyé il y a moins de 60 secondes
if (signer.otp_last_sent_at) {
const lastSentAt = new Date(signer.otp_last_sent_at);
const now = new Date();
const diffSeconds = (now.getTime() - lastSentAt.getTime()) / 1000;
if (diffSeconds < 60) {
return NextResponse.json(
{ error: `Veuillez attendre ${Math.ceil(60 - diffSeconds)} secondes avant de redemander un code` },
{ status: 429 }
);
}
}
// Générer le code OTP
const otpCode = generateOTP();
const otpHash = await hashOTP(otpCode);
const otpExpires = getOTPExpiration();
// Détecter le mode test
const testEmails = ['paie@odentas.fr', 'renaud.breviere@gmail.com'];
const isTestMode = signer.email.includes('test@') ||
signer.email.includes('@example.com') ||
signer.sign_requests.ref?.startsWith('TEST-') ||
testEmails.includes(signer.email.toLowerCase());
if (isTestMode) {
console.log(`[OTP] 🧪 MODE TEST détecté`);
console.log(`[OTP] ========================================`);
console.log(`[OTP] 🔐 CODE OTP POUR ${signer.email}:`);
console.log(`[OTP] ➡️ ${otpCode}`);
console.log(`[OTP] ========================================`);
console.log(`[OTP] Expire à: ${otpExpires.toISOString()}`);
} else {
console.log(`[OTP] Code généré pour ${signer.email} (expire à ${otpExpires.toISOString()})`);
}
// Mettre à jour le signataire avec le nouveau OTP
const { error: updateError } = await supabaseAdmin
.from('signers')
.update({
otp_hash: otpHash,
otp_expires_at: otpExpires.toISOString(),
otp_last_sent_at: new Date().toISOString(),
})
.eq('id', signerId);
if (updateError) {
console.error('[OTP] Erreur mise à jour signataire:', updateError);
return NextResponse.json(
{ error: 'Erreur lors de la génération du code' },
{ status: 500 }
);
}
// Logger l'événement
await logSignEvent({
requestId: signer.sign_requests.id,
signerId: signer.id,
event: 'otp_sent',
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined,
userAgent: request.headers.get('user-agent') || undefined,
metadata: {
email: signer.email,
expires_at: otpExpires.toISOString(),
},
});
// Envoyer l'email avec le code OTP
// TODO: Intégrer avec le système d'email existant (SES)
const emailResult = await sendOTPEmail({
to: signer.email,
name: signer.name,
otpCode,
documentTitle: signer.sign_requests.title,
documentRef: signer.sign_requests.ref,
isTestMode,
});
if (!emailResult.success) {
console.error('[OTP] Erreur envoi email:', emailResult.error);
// En mode test, on continue quand même
if (!isTestMode) {
return NextResponse.json(
{ error: 'Erreur lors de l\'envoi de l\'email' },
{ status: 500 }
);
}
}
console.log(`[OTP] ✅ ${isTestMode ? 'Mode test - Code affiché dans les logs' : `Code envoyé à ${signer.email}`}`);
return NextResponse.json({
success: true,
message: isTestMode
? 'Mode test : le code OTP est affiché dans les logs serveur'
: 'Code de vérification envoyé par email',
test_mode: isTestMode,
...(isTestMode && { otp_code_in_logs: true }),
expires_at: otpExpires.toISOString(),
signer: {
name: signer.name,
email: signer.email,
},
});
} catch (error) {
console.error('[SEND OTP] Erreur:', error);
return NextResponse.json(
{ error: 'Erreur serveur', details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}
/**
* Envoie un email avec le code OTP
*/
async function sendOTPEmail(params: {
to: string;
name: string;
otpCode: string;
documentTitle: string;
documentRef: string;
isTestMode?: boolean;
}): Promise<{ success: boolean; error?: string }> {
const { to, name, otpCode, documentTitle, documentRef, isTestMode } = params;
// En mode test, ne pas envoyer d'email réellement
if (isTestMode) {
console.log(`[EMAIL] 🧪 MODE TEST : Email non envoyé (code affiché dans les logs)`);
return { success: true };
}
try {
// Appeler l'API d'envoi d'email existante
const response = await fetch(`${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/api/send-email`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
to,
subject: `Code de vérification - Signature électronique`,
template: 'otp-signature',
variables: {
name,
otpCode,
documentTitle,
documentRef,
expirationMinutes: '15',
},
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return { success: false, error: errorData.error || 'Erreur HTTP' };
}
return { success: true };
} catch (error) {
console.error('[EMAIL] Erreur:', error);
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}