- 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)
218 lines
6.9 KiB
TypeScript
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) };
|
|
}
|
|
}
|