espace-paie-odentas/app/signer/[requestId]/[signerId]/components/SignatureCapture.tsx
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

413 lines
14 KiB
TypeScript

'use client';
import { useState, useRef, useEffect } from 'react';
import { motion } from 'framer-motion';
import { PenTool, RotateCcw, Check, Loader2, AlertCircle, FileText, Info } from 'lucide-react';
interface SignatureCaptureProps {
signerId: string;
requestId: string;
signerName: string;
signerRole: string;
documentTitle: string;
sessionToken: string;
onCompleted: () => void;
}
interface SignPosition {
page: number;
x: number;
y: number;
width: number;
height: number;
role: string;
}
export default function SignatureCapture({
signerId,
requestId,
signerName,
signerRole,
documentTitle,
sessionToken,
onCompleted,
}: SignatureCaptureProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [isDrawing, setIsDrawing] = useState(false);
const [hasDrawn, setHasDrawn] = useState(false);
const [consentChecked, setConsentChecked] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastPoint, setLastPoint] = useState<{ x: number; y: number } | null>(null);
// PDF Viewer state
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const [signaturePositions, setSignaturePositions] = useState<SignPosition[]>([]);
const [isPdfLoading, setIsPdfLoading] = useState(true);
const [PDFViewerComponent, setPDFViewerComponent] = useState<any>(null);
// Load PDF Viewer component (client-side only)
useEffect(() => {
async function loadPDFViewer() {
try {
const { default: PDFViewer } = await import('./PDFViewer');
setPDFViewerComponent(() => PDFViewer);
} catch (err) {
console.error('[PDF] Erreur chargement viewer:', err);
}
}
loadPDFViewer();
}, []);
// Load PDF and signature positions
useEffect(() => {
async function loadPdfAndPositions() {
try {
// Get PDF presigned URL
const pdfResponse = await fetch(`/api/odentas-sign/requests/${requestId}/pdf-url`, {
headers: {
'Authorization': `Bearer ${sessionToken}`,
},
});
if (!pdfResponse.ok) {
throw new Error('Impossible de charger le document PDF');
}
const pdfData = await pdfResponse.json();
setPdfUrl(pdfData.url);
// Get signature positions
const positionsResponse = await fetch(`/api/odentas-sign/requests/${requestId}/positions`, {
headers: {
'Authorization': `Bearer ${sessionToken}`,
},
});
if (!positionsResponse.ok) {
throw new Error('Impossible de charger les positions de signature');
}
const positionsData = await positionsResponse.json();
setSignaturePositions(positionsData.positions || []);
} catch (err) {
console.error('[PDF] Erreur lors du chargement:', err);
setError(err instanceof Error ? err.message : 'Erreur de chargement');
} finally {
setIsPdfLoading(false);
}
}
loadPdfAndPositions();
}, [requestId, sessionToken]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Set canvas size to match display size
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * window.devicePixelRatio;
canvas.height = rect.height * window.devicePixelRatio;
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
// Set drawing style
ctx.strokeStyle = '#1e293b';
ctx.lineWidth = 2;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// Clear canvas
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
}, []);
function getCoordinates(e: React.MouseEvent<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) {
const canvas = canvasRef.current;
if (!canvas) return { x: 0, y: 0 };
const rect = canvas.getBoundingClientRect();
if ('touches' in e) {
return {
x: e.touches[0].clientX - rect.left,
y: e.touches[0].clientY - rect.top,
};
}
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
}
function startDrawing(e: React.MouseEvent<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) {
e.preventDefault();
setIsDrawing(true);
setHasDrawn(true);
const coords = getCoordinates(e);
setLastPoint(coords);
const ctx = canvasRef.current?.getContext('2d');
if (!ctx) return;
ctx.beginPath();
ctx.moveTo(coords.x, coords.y);
}
function draw(e: React.MouseEvent<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) {
e.preventDefault();
if (!isDrawing) return;
const coords = getCoordinates(e);
const ctx = canvasRef.current?.getContext('2d');
if (!ctx || !lastPoint) return;
ctx.lineTo(coords.x, coords.y);
ctx.stroke();
setLastPoint(coords);
}
function stopDrawing() {
setIsDrawing(false);
setLastPoint(null);
}
function clearSignature() {
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
if (!canvas || !ctx) return;
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
setHasDrawn(false);
}
function canvasToBase64(): string {
const canvas = canvasRef.current;
if (!canvas) {
throw new Error('Canvas not found');
}
// Get full data URL including the data:image/png;base64, prefix
return canvas.toDataURL('image/png');
}
async function submitSignature() {
if (!hasDrawn || !consentChecked) return;
setIsSubmitting(true);
setError(null);
try {
// Convert canvas to base64
const signatureImageBase64 = canvasToBase64();
const consentText = `Je consens à signer électroniquement le document "${documentTitle}" et confirme que cette signature a la même valeur juridique qu'une signature manuscrite.`;
// Submit
const response = await fetch(`/api/odentas-sign/signers/${signerId}/sign`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${sessionToken}`,
},
body: JSON.stringify({
signatureImageBase64,
consentText,
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Erreur lors de la signature');
}
// Success!
onCompleted();
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur inconnue');
} finally {
setIsSubmitting(false);
}
}
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="max-w-3xl mx-auto"
>
<div className="bg-white rounded-2xl shadow-xl overflow-hidden">
{/* Header */}
<div className="bg-gradient-to-r from-indigo-600 to-purple-600 px-8 py-8 text-white">
<div className="flex items-start justify-between">
<div>
<h2 className="text-2xl font-bold mb-2">Signature du document</h2>
<p className="text-indigo-100">{documentTitle}</p>
</div>
<div className="text-right">
<p className="text-xs text-indigo-200 uppercase tracking-wider mb-1">Signataire</p>
<p className="font-semibold">{signerName}</p>
<p className="text-sm text-indigo-100">{signerRole}</p>
</div>
</div>
</div>
{/* Content */}
<div className="p-8">
{/* PDF Viewer */}
{isPdfLoading ? (
<div className="mb-8 bg-slate-50 rounded-xl p-12 flex flex-col items-center justify-center">
<Loader2 className="w-8 h-8 text-indigo-600 animate-spin mb-3" />
<p className="text-slate-600">Chargement du document...</p>
</div>
) : pdfUrl && PDFViewerComponent ? (
<div className="mb-8">
<h3 className="text-lg font-semibold text-slate-900 mb-4 flex items-center gap-2">
<FileText className="w-5 h-5 text-indigo-600" />
Aperçu du document
</h3>
<div className="h-[600px]">
<PDFViewerComponent
pdfUrl={pdfUrl}
positions={signaturePositions}
currentSignerRole={signerRole}
/>
</div>
</div>
) : null}
{/* Info notice */}
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 mb-8 flex gap-3">
<Info className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-900">
<p className="font-medium mb-1">Dessinez votre signature</p>
<p className="text-blue-700">
Utilisez votre souris, trackpad ou doigt pour signer dans le cadre ci-dessous. Vous pouvez recommencer à tout moment.
</p>
</div>
</div>
{/* Signature canvas */}
<div className="mb-6">
<div className="border-2 border-dashed border-slate-300 rounded-xl overflow-hidden bg-white relative group hover:border-indigo-400 transition-colors">
<canvas
ref={canvasRef}
onMouseDown={startDrawing}
onMouseMove={draw}
onMouseUp={stopDrawing}
onMouseLeave={stopDrawing}
onTouchStart={startDrawing}
onTouchMove={draw}
onTouchEnd={stopDrawing}
className="w-full h-64 cursor-crosshair touch-none"
style={{ touchAction: 'none' }}
/>
{!hasDrawn && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="text-center">
<PenTool className="w-8 h-8 text-slate-400 mx-auto mb-2" />
<p className="text-slate-500 text-sm">Signez ici</p>
</div>
</div>
)}
</div>
{/* Clear button */}
{hasDrawn && (
<motion.button
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
onClick={clearSignature}
disabled={isSubmitting}
className="mt-4 px-4 py-2 text-sm text-slate-700 hover:text-slate-900 font-medium flex items-center gap-2 hover:bg-slate-100 rounded-lg transition-colors disabled:opacity-50"
>
<RotateCcw className="w-4 h-4" />
Recommencer
</motion.button>
)}
</div>
{/* Consent checkbox */}
<div className="bg-slate-50 rounded-xl p-6 mb-6">
<label className="flex items-start gap-4 cursor-pointer group">
<div className="flex-shrink-0 pt-1">
<input
type="checkbox"
checked={consentChecked}
onChange={(e) => setConsentChecked(e.target.checked)}
disabled={isSubmitting}
className="w-5 h-5 text-indigo-600 border-slate-300 rounded focus:ring-indigo-500 focus:ring-2 cursor-pointer"
/>
</div>
<div className="text-sm text-slate-700 leading-relaxed">
<p>
Je consens à signer électroniquement le document <span className="font-semibold">"{documentTitle}"</span> et confirme que cette signature a la même valeur juridique qu'une signature manuscrite.
</p>
<p className="mt-2 text-xs text-slate-500">
Votre signature sera horodatée, scellée et archivée de manière sécurisée pendant 10 ans conformément à la réglementation eIDAS.
</p>
</div>
</label>
</div>
{/* Error message */}
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl flex items-start gap-3"
>
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-800">{error}</p>
</motion.div>
)}
{/* Submit button */}
<button
onClick={submitSignature}
disabled={!hasDrawn || !consentChecked || isSubmitting}
className="w-full px-8 py-4 bg-indigo-600 text-white rounded-xl font-semibold hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 hover:shadow-lg flex items-center justify-center gap-2"
>
{isSubmitting ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Signature en cours...
</>
) : (
<>
<Check className="w-5 h-5" />
Valider ma signature
</>
)}
</button>
{/* Help text */}
<p className="mt-4 text-center text-sm text-slate-500">
En validant, vous acceptez que votre signature soit juridiquement contraignante.
</p>
</div>
</div>
{/* Document preview (placeholder for future PDF viewer) */}
<div className="mt-8 bg-white rounded-2xl shadow-xl p-8">
<div className="flex items-center gap-3 mb-4">
<FileText className="w-6 h-6 text-slate-600" />
<h3 className="text-lg font-semibold text-slate-900">Aperçu du document</h3>
</div>
<div className="bg-slate-100 rounded-xl p-8 text-center">
<p className="text-slate-600">Le visualiseur de PDF sera intégré prochainement</p>
<p className="text-sm text-slate-500 mt-2">Référence: {requestId.slice(0, 8)}...</p>
</div>
</div>
</motion.div>
);
}