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

233 lines
8 KiB
TypeScript

'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { motion, AnimatePresence } from 'framer-motion';
import { Shield, Check, Loader2 } from 'lucide-react';
import OTPVerification from '@/app/signer/[requestId]/[signerId]/components/OTPVerification';
import SignatureCapture from '@/app/signer/[requestId]/[signerId]/components/SignatureCapture';
import CompletionScreen from '@/app/signer/[requestId]/[signerId]/components/CompletionScreen';
import ProgressBar from '@/app/signer/[requestId]/[signerId]/components/ProgressBar';
type SignerStatus = {
id: string;
role: string;
name: string;
email: string;
has_signed: boolean;
};
type RequestInfo = {
id: string;
ref: string;
title: string;
status: string;
progress: {
total: number;
signed: number;
percentage: number;
};
};
export default function SignerPage({
params,
}: {
params: { requestId: string; signerId: string };
}) {
const router = useRouter();
const [currentStep, setCurrentStep] = useState<'loading' | 'otp' | 'signature' | 'completed'>('loading');
const [sessionToken, setSessionToken] = useState<string | null>(null);
const [signerInfo, setSignerInfo] = useState<SignerStatus | null>(null);
const [requestInfo, setRequestInfo] = useState<RequestInfo | null>(null);
const [error, setError] = useState<string | null>(null);
// Charger les infos du signataire au démarrage
useEffect(() => {
loadSignerStatus();
}, []);
async function loadSignerStatus() {
try {
const response = await fetch(
`/api/odentas-sign/signers/${params.signerId}/status`
);
if (!response.ok) {
throw new Error('Impossible de charger les informations');
}
const data = await response.json();
if (data.signer.has_signed) {
// Déjà signé
setSignerInfo(data.signer);
setRequestInfo(data.request);
setCurrentStep('completed');
} else {
// Pas encore signé, commencer par l'OTP
setSignerInfo(data.signer);
setRequestInfo(data.request);
setCurrentStep('otp');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur inconnue');
setCurrentStep('otp'); // Fallback vers OTP
}
}
function handleOTPVerified(token: string, signer: any, request: any) {
setSessionToken(token);
setSignerInfo(signer);
setRequestInfo(request);
setCurrentStep('signature');
}
async function handleSignatureCompleted() {
// Recharger le statut pour obtenir les infos à jour
await loadSignerStatus();
setCurrentStep('completed');
}
if (error && !signerInfo) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white rounded-2xl shadow-xl p-8 max-w-md w-full"
>
<div className="text-center">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<Shield className="w-8 h-8 text-red-600" />
</div>
<h2 className="text-2xl font-bold text-slate-900 mb-2">
Erreur de chargement
</h2>
<p className="text-slate-600 mb-6">{error}</p>
<button
onClick={() => router.push('/')}
className="px-6 py-3 bg-slate-900 text-white rounded-xl font-medium hover:bg-slate-800 transition-colors"
>
Retour à l'accueil
</button>
</div>
</motion.div>
</div>
);
}
if (currentStep === 'loading') {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50 flex items-center justify-center">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-center"
>
<Loader2 className="w-12 h-12 text-indigo-600 animate-spin mx-auto mb-4" />
<p className="text-slate-600 font-medium">Chargement...</p>
</motion.div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50">
{/* Header avec branding Odentas */}
<header className="bg-white border-b border-slate-200 sticky top-0 z-50 backdrop-blur-sm bg-white/90">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gradient-to-br from-indigo-600 to-purple-600 rounded-xl flex items-center justify-center">
<Shield className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-lg font-bold text-slate-900">Odentas Sign</h1>
<p className="text-xs text-slate-500">Signature électronique sécurisée</p>
</div>
</div>
{requestInfo && (
<div className="hidden sm:block">
<div className="text-right">
<p className="text-xs text-slate-500">Référence</p>
<p className="text-sm font-mono font-medium text-slate-900">{requestInfo.ref}</p>
</div>
</div>
)}
</div>
</div>
</header>
{/* Barre de progression */}
{currentStep !== 'completed' && (
<ProgressBar
currentStep={currentStep === 'otp' ? 1 : 2}
totalSteps={2}
/>
)}
{/* Contenu principal avec transitions */}
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<AnimatePresence mode="wait">
{currentStep === 'otp' && signerInfo && (
<OTPVerification
key="otp"
signerId={params.signerId}
signerName={signerInfo.name}
signerEmail={signerInfo.email}
documentTitle={requestInfo?.title || ''}
onVerified={handleOTPVerified}
/>
)}
{currentStep === 'signature' && signerInfo && sessionToken && requestInfo && (
<SignatureCapture
key="signature"
signerId={params.signerId}
requestId={params.requestId}
signerName={signerInfo.name}
signerRole={signerInfo.role}
documentTitle={requestInfo.title}
sessionToken={sessionToken}
onCompleted={handleSignatureCompleted}
/>
)}
{currentStep === 'completed' && signerInfo && requestInfo && (
<CompletionScreen
key="completed"
signerName={signerInfo.name}
documentTitle={requestInfo.title}
documentRef={requestInfo.ref}
signedAt={signerInfo.has_signed ? new Date().toISOString() : null}
progress={requestInfo.progress}
/>
)}
</AnimatePresence>
</main>
{/* Footer avec infos de sécurité */}
<footer className="mt-16 border-t border-slate-200 bg-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 text-sm text-slate-600">
<div className="flex items-center gap-2">
<Check className="w-4 h-4 text-green-600" />
<span>Signature conforme eIDAS</span>
</div>
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-blue-600" />
<span>Données cryptées et archivées 10 ans</span>
</div>
<a
href="mailto:support@odentas.fr"
className="text-indigo-600 hover:text-indigo-700 font-medium"
>
Besoin d'aide ?
</a>
</div>
</div>
</footer>
</div>
);
}