- 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)
308 lines
10 KiB
TypeScript
308 lines
10 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useRef, useEffect } from 'react';
|
|
import { motion } from 'framer-motion';
|
|
import { Mail, Shield, Clock, AlertCircle, Loader2, ArrowRight } from 'lucide-react';
|
|
|
|
interface OTPVerificationProps {
|
|
signerId: string;
|
|
signerName: string;
|
|
signerEmail: string;
|
|
documentTitle: string;
|
|
onVerified: (token: string, signer: any, request: any) => void;
|
|
}
|
|
|
|
export default function OTPVerification({
|
|
signerId,
|
|
signerName,
|
|
signerEmail,
|
|
documentTitle,
|
|
onVerified,
|
|
}: OTPVerificationProps) {
|
|
const [otpSent, setOtpSent] = useState(false);
|
|
const [otpCode, setOtpCode] = useState(['', '', '', '', '', '']);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [remainingTime, setRemainingTime] = useState(900); // 15 minutes
|
|
const [attemptsLeft, setAttemptsLeft] = useState(3);
|
|
|
|
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
|
|
|
// Countdown timer
|
|
useEffect(() => {
|
|
if (!otpSent) return;
|
|
|
|
const interval = setInterval(() => {
|
|
setRemainingTime((prev) => {
|
|
if (prev <= 0) {
|
|
clearInterval(interval);
|
|
return 0;
|
|
}
|
|
return prev - 1;
|
|
});
|
|
}, 1000);
|
|
|
|
return () => clearInterval(interval);
|
|
}, [otpSent]);
|
|
|
|
const formatTime = (seconds: number) => {
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = seconds % 60;
|
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
};
|
|
|
|
async function sendOTP() {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const response = await fetch(`/api/odentas-sign/signers/${signerId}/send-otp`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const data = await response.json();
|
|
throw new Error(data.error || 'Erreur lors de l\'envoi du code');
|
|
}
|
|
|
|
setOtpSent(true);
|
|
setRemainingTime(900);
|
|
setAttemptsLeft(3);
|
|
|
|
// Auto-focus premier input
|
|
setTimeout(() => {
|
|
inputRefs.current[0]?.focus();
|
|
}, 100);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Erreur inconnue');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
|
|
function handleOTPChange(index: number, value: string) {
|
|
if (!/^\d*$/.test(value)) return;
|
|
|
|
const newOtp = [...otpCode];
|
|
newOtp[index] = value.slice(-1);
|
|
setOtpCode(newOtp);
|
|
|
|
// Auto-focus next input
|
|
if (value && index < 5) {
|
|
inputRefs.current[index + 1]?.focus();
|
|
}
|
|
|
|
// Auto-submit when complete
|
|
if (newOtp.every(digit => digit !== '') && index === 5) {
|
|
verifyOTP(newOtp.join(''));
|
|
}
|
|
}
|
|
|
|
function handleKeyDown(index: number, e: React.KeyboardEvent<HTMLInputElement>) {
|
|
if (e.key === 'Backspace' && !otpCode[index] && index > 0) {
|
|
inputRefs.current[index - 1]?.focus();
|
|
}
|
|
}
|
|
|
|
function handlePaste(e: React.ClipboardEvent) {
|
|
e.preventDefault();
|
|
const pastedData = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, 6);
|
|
const newOtp = pastedData.split('').concat(Array(6).fill('')).slice(0, 6);
|
|
setOtpCode(newOtp);
|
|
|
|
if (pastedData.length === 6) {
|
|
verifyOTP(pastedData);
|
|
}
|
|
}
|
|
|
|
async function verifyOTP(code: string) {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const response = await fetch(`/api/odentas-sign/signers/${signerId}/verify-otp`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ otp: code }),
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (!response.ok) {
|
|
if (data.remainingAttempts !== undefined) {
|
|
setAttemptsLeft(data.remainingAttempts);
|
|
}
|
|
throw new Error(data.error || 'Code invalide');
|
|
}
|
|
|
|
// Success! Pass data to parent
|
|
onVerified(data.sessionToken, data.signer, data.request);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Erreur de vérification');
|
|
setOtpCode(['', '', '', '', '', '']);
|
|
inputRefs.current[0]?.focus();
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -20 }}
|
|
className="max-w-2xl 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-12 text-white text-center">
|
|
<motion.div
|
|
initial={{ scale: 0 }}
|
|
animate={{ scale: 1 }}
|
|
transition={{ type: 'spring', delay: 0.2 }}
|
|
className="w-20 h-20 bg-white/20 rounded-full flex items-center justify-center mx-auto mb-6"
|
|
>
|
|
<Shield className="w-10 h-10" />
|
|
</motion.div>
|
|
<h2 className="text-3xl font-bold mb-2">Vérification d'identité</h2>
|
|
<p className="text-indigo-100 text-lg">
|
|
Bonjour {signerName.split(' ')[0]}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="p-8">
|
|
{/* Document info */}
|
|
<div className="bg-slate-50 rounded-xl p-6 mb-8">
|
|
<p className="text-sm text-slate-600 mb-1">Document à signer</p>
|
|
<p className="text-lg font-semibold text-slate-900">{documentTitle}</p>
|
|
</div>
|
|
|
|
{!otpSent ? (
|
|
// Initial state - send OTP
|
|
<div className="text-center">
|
|
<div className="flex items-center justify-center gap-2 mb-4">
|
|
<Mail className="w-5 h-5 text-indigo-600" />
|
|
<p className="text-slate-600">{signerEmail}</p>
|
|
</div>
|
|
<p className="text-slate-700 mb-8">
|
|
Un code de vérification à 6 chiffres va être envoyé à votre adresse email.
|
|
</p>
|
|
|
|
<button
|
|
onClick={sendOTP}
|
|
disabled={isLoading}
|
|
className="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 inline-flex items-center gap-2"
|
|
>
|
|
{isLoading ? (
|
|
<>
|
|
<Loader2 className="w-5 h-5 animate-spin" />
|
|
Envoi en cours...
|
|
</>
|
|
) : (
|
|
<>
|
|
Recevoir le code
|
|
<ArrowRight className="w-5 h-5" />
|
|
</>
|
|
)}
|
|
</button>
|
|
|
|
{error && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: -10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className="mt-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>
|
|
)}
|
|
</div>
|
|
) : (
|
|
// OTP input state
|
|
<div>
|
|
<div className="text-center mb-8">
|
|
<p className="text-slate-700 mb-2">
|
|
Entrez le code reçu par email
|
|
</p>
|
|
<div className="flex items-center justify-center gap-2 text-sm text-slate-500">
|
|
<Clock className="w-4 h-4" />
|
|
<span>Expire dans {formatTime(remainingTime)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* OTP Input */}
|
|
<div className="flex justify-center gap-3 mb-8" onPaste={handlePaste}>
|
|
{otpCode.map((digit, index) => (
|
|
<input
|
|
key={index}
|
|
ref={(el) => {
|
|
inputRefs.current[index] = el;
|
|
}}
|
|
type="text"
|
|
inputMode="numeric"
|
|
maxLength={1}
|
|
value={digit}
|
|
onChange={(e) => handleOTPChange(index, e.target.value)}
|
|
onKeyDown={(e) => handleKeyDown(index, e)}
|
|
disabled={isLoading}
|
|
className="w-14 h-16 text-center text-2xl font-bold border-2 border-slate-300 rounded-xl focus:border-indigo-600 focus:ring-4 focus:ring-indigo-100 outline-none transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* Error */}
|
|
{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" />
|
|
<div className="flex-1">
|
|
<p className="text-sm text-red-800 font-medium">{error}</p>
|
|
{attemptsLeft > 0 && (
|
|
<p className="text-xs text-red-600 mt-1">
|
|
{attemptsLeft} tentative{attemptsLeft > 1 ? 's' : ''} restante{attemptsLeft > 1 ? 's' : ''}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
|
|
{/* Loading indicator */}
|
|
{isLoading && (
|
|
<div className="text-center mb-6">
|
|
<Loader2 className="w-6 h-6 text-indigo-600 animate-spin mx-auto" />
|
|
</div>
|
|
)}
|
|
|
|
{/* Resend button */}
|
|
<div className="text-center">
|
|
<button
|
|
onClick={sendOTP}
|
|
disabled={isLoading || remainingTime > 840} // Allow resend after 1 minute
|
|
className="text-sm text-indigo-600 hover:text-indigo-700 font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
Renvoyer le code
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Security notice */}
|
|
<div className="mt-8 pt-6 border-t border-slate-200">
|
|
<div className="flex items-start gap-3 text-sm text-slate-600">
|
|
<Shield className="w-5 h-5 text-green-600 flex-shrink-0 mt-0.5" />
|
|
<div>
|
|
<p className="font-medium text-slate-900 mb-1">Authentification sécurisée</p>
|
|
<p>Le code est valable 15 minutes et ne peut être utilisé qu'une seule fois.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
}
|