espace-paie-odentas/app/signer/[requestId]/[signerId]/components/OTPVerification.tsx

320 lines
11 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 }}
>
{/* Carte principale épurée */}
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
{/* Header sobre */}
<div className="border-b border-slate-200 px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-indigo-50 flex items-center justify-center">
<Shield className="w-5 h-5 text-indigo-600" />
</div>
<div>
<h2 className="text-lg font-semibold text-slate-900">Vérification d'identité</h2>
<p className="text-sm text-slate-600">Document : {documentTitle}</p>
</div>
</div>
</div>
{/* Content */}
<div className="p-6">
{!otpSent ? (
// État initial - envoi du code
<div className="space-y-6">
{/* Info signataire */}
<div className="flex items-start gap-4 p-4 bg-slate-50 rounded-lg border border-slate-200">
<Mail className="w-5 h-5 text-slate-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-slate-900 mb-1">
{signerName}
</p>
<p className="text-sm text-slate-600">{signerEmail}</p>
</div>
</div>
{/* Explication */}
<div className="space-y-2">
<p className="text-sm text-slate-700">
Pour garantir votre identité, nous allons vous envoyer un code de vérification à 6 chiffres par email.
</p>
<div className="flex items-center gap-2 text-xs text-slate-500">
<Clock className="w-4 h-4" />
<span>Le code sera valable pendant 15 minutes</span>
</div>
</div>
{/* Bouton d'envoi */}
<button
onClick={sendOTP}
disabled={isLoading}
className="w-full px-4 py-3 bg-indigo-600 text-white rounded-lg font-medium hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors inline-flex items-center justify-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>
{/* Erreur */}
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="p-3 bg-red-50 border border-red-200 rounded-lg flex items-start gap-2"
>
<AlertCircle className="w-4 h-4 text-red-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-700">{error}</p>
</motion.div>
)}
</div>
) : (
// État saisie du code
<div className="space-y-6">
{/* Instructions */}
<div className="text-center space-y-2">
<p className="text-sm text-slate-700">
Entrez le code reçu à l'adresse <strong className="text-slate-900">{signerEmail}</strong>
</p>
<div className="flex items-center justify-center gap-2 text-xs text-slate-500">
<Clock className="w-4 h-4" />
<span>Expire dans {formatTime(remainingTime)}</span>
</div>
</div>
{/* Champ OTP minimaliste */}
<div className="flex justify-center gap-2" 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-12 h-14 text-center text-xl font-semibold border border-slate-300 rounded-lg focus:border-indigo-600 focus:ring-2 focus:ring-indigo-100 outline-none transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-slate-50"
/>
))}
</div>
{/* Erreur */}
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="p-3 bg-red-50 border border-red-200 rounded-lg"
>
<div className="flex items-start gap-2">
<AlertCircle className="w-4 h-4 text-red-600 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm text-red-700 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>
</div>
</motion.div>
)}
{/* Indicateur de chargement */}
{isLoading && (
<div className="flex justify-center">
<Loader2 className="w-5 h-5 text-indigo-600 animate-spin" />
</div>
)}
{/* Renvoyer le code */}
<div className="text-center pt-4 border-t border-slate-200">
<button
onClick={sendOTP}
disabled={isLoading || remainingTime > 840}
className="text-sm text-indigo-600 hover:text-indigo-700 font-medium disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Vous n'avez pas reçu le code ? Renvoyer
</button>
</div>
</div>
)}
</div>
{/* Footer sécurité */}
<div className="border-t border-slate-200 bg-slate-50 px-6 py-4">
<div className="flex items-start gap-3 text-sm">
<Shield className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-slate-900 text-xs mb-0.5">Authentification sécurisée</p>
<p className="text-xs text-slate-600">
Le code est valable 15 minutes et ne peut être utilisé qu'une seule fois.
</p>
</div>
</div>
</div>
</div>
</motion.div>
);
}