espace-paie-odentas/app/signin/page.tsx

786 lines
No EOL
30 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import { Lock, Key, Mail, Eye, EyeOff, Settings, AlertCircle } from "lucide-react";
import styles from "./signin.module.css";
import { usePageTitle } from "@/hooks/usePageTitle";
import RememberMeInfoModal from "@/components/auth/RememberMeInfoModal";
export default function SignIn() {
// Définir le titre de la page
usePageTitle("Connexion");
// Mode d'authentification : "otp" ou "password"
const [authMode, setAuthMode] = useState<"otp" | "password">("otp");
// Shared
const [email, setEmail] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [info, setInfo] = useState("");
// Remember me
const [rememberMe, setRememberMe] = useState(false);
const [showRememberMeModal, setShowRememberMeModal] = useState(false);
// Détection du mode maintenance staff
const [isStaffAccess, setIsStaffAccess] = useState(false);
useEffect(() => {
// Vérifier si on arrive avec le paramètre staff_access
const urlParams = new URLSearchParams(window.location.search);
setIsStaffAccess(urlParams.get('staff_access') === 'true');
}, []);
const resetMessages = useCallback(() => {
setError("");
setInfo("");
}, []);
// Password state
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
// OTP state
const [code, setCode] = useState("");
const [otpStep, setOtpStep] = useState<"email" | "code">("email");
// MFA state
const [mfaRequired, setMfaRequired] = useState(false);
const [mfaCode, setMfaCode] = useState("");
const [savedCredentials, setSavedCredentials] = useState<{email: string, password: string} | null>(null);
// OTP UI (6 inputs)
const CODE_LENGTH = 6;
const [codeDigits, setCodeDigits] = useState<string[]>(Array(CODE_LENGTH).fill(""));
const inputsRef = useRef<Array<HTMLInputElement | null>>([]);
// MFA UI (6 inputs)
const [mfaDigits, setMfaDigits] = useState<string[]>(Array(CODE_LENGTH).fill(""));
const mfaInputsRef = useRef<Array<HTMLInputElement | null>>([]);
// Focus OTP input on step change
useEffect(() => {
if (otpStep === "code") {
setTimeout(() => {
if (inputsRef.current[0]) {
inputsRef.current[0].focus();
}
}, 100);
}
}, [otpStep]);
// Focus MFA input when required
useEffect(() => {
if (mfaRequired) {
setTimeout(() => {
if (mfaInputsRef.current[0]) {
mfaInputsRef.current[0].focus();
}
}, 100);
}
}, [mfaRequired]);
function syncCodeFromDigits(next: string[]) {
const joined = next.join("");
setCode(joined);
}
function syncMfaFromDigits(next: string[]) {
const joined = next.join("");
setMfaCode(joined);
}
function focusInput(idx: number) {
const el = inputsRef.current[idx];
if (el) el.focus();
}
function handleDigitChange(idx: number, value: string) {
const v = (value || "").replace(/\D/g, "").slice(-1);
const next = [...codeDigits];
next[idx] = v;
setCodeDigits(next);
syncCodeFromDigits(next);
if (v && idx < CODE_LENGTH - 1) {
focusInput(idx + 1);
}
}
function handleKeyDown(idx: number, e: React.KeyboardEvent<HTMLInputElement>) {
const key = e.key;
if (key === "Backspace") {
if (codeDigits[idx]) {
const next = [...codeDigits];
next[idx] = "";
setCodeDigits(next);
syncCodeFromDigits(next);
} else if (idx > 0) {
focusInput(idx - 1);
}
} else if (key === "ArrowLeft" && idx > 0) {
e.preventDefault();
focusInput(idx - 1);
} else if (key === "ArrowRight" && idx < CODE_LENGTH - 1) {
e.preventDefault();
focusInput(idx + 1);
}
}
function handlePaste(e: React.ClipboardEvent<HTMLDivElement>) {
e.preventDefault();
const pasted = (e.clipboardData.getData("text") || "").replace(/\D/g, "");
if (!pasted) return;
const next = Array(CODE_LENGTH)
.fill("")
.map((_, i) => pasted[i] || "");
setCodeDigits(next);
syncCodeFromDigits(next);
const lastIdx = Math.min(pasted.length, CODE_LENGTH) - 1;
if (lastIdx >= 0) focusInput(lastIdx);
}
// MFA input handlers
function handleMfaDigitChange(idx: number, value: string) {
const v = (value || "").replace(/\D/g, "").slice(-1);
const next = [...mfaDigits];
next[idx] = v;
setMfaDigits(next);
syncMfaFromDigits(next);
if (v && idx < CODE_LENGTH - 1) {
focusMfaInput(idx + 1);
}
}
function focusMfaInput(idx: number) {
const el = mfaInputsRef.current[idx];
if (el) el.focus();
}
function handleMfaKeyDown(idx: number, e: React.KeyboardEvent<HTMLInputElement>) {
const key = e.key;
if (key === "Backspace") {
if (mfaDigits[idx]) {
const next = [...mfaDigits];
next[idx] = "";
setMfaDigits(next);
syncMfaFromDigits(next);
} else if (idx > 0) {
focusMfaInput(idx - 1);
}
} else if (key === "ArrowLeft" && idx > 0) {
e.preventDefault();
focusMfaInput(idx - 1);
} else if (key === "ArrowRight" && idx < CODE_LENGTH - 1) {
e.preventDefault();
focusMfaInput(idx + 1);
}
}
function handleMfaPaste(e: React.ClipboardEvent<HTMLDivElement>) {
e.preventDefault();
const pasted = (e.clipboardData.getData("text") || "").replace(/\D/g, "");
if (!pasted) return;
const next = Array(CODE_LENGTH)
.fill("")
.map((_, i) => pasted[i] || "");
setMfaDigits(next);
syncMfaFromDigits(next);
const lastIdx = Math.min(pasted.length, CODE_LENGTH) - 1;
if (lastIdx >= 0) focusMfaInput(lastIdx);
}
// Prevent double submission on auto-verify
const hasSubmittedRef = useRef(false);
// PASSWORD FLOW
async function handlePasswordSubmit(e: React.FormEvent) {
e.preventDefault();
if (hasSubmittedRef.current) return;
hasSubmittedRef.current = true;
setLoading(true);
resetMessages();
try {
const res = await fetch("/api/auth/signin-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
email,
password,
mfaCode: mfaRequired ? mfaCode : undefined,
rememberMe
}),
});
if (res.status === 206) {
// MFA requis
const data = await res.json();
if (data.error === "MFA_REQUIRED") {
setMfaRequired(true);
setSavedCredentials({ email, password });
setInfo("Veuillez saisir le code de votre application d'authentification");
hasSubmittedRef.current = false;
setLoading(false);
return;
}
}
if (res.status === 403) {
setError("Votre compte a été révoqué. Merci de contacter votre administrateur.");
hasSubmittedRef.current = false;
setLoading(false);
return;
}
if (!res.ok) {
const txt = await res.text().catch(() => "");
const low = (txt || "").toLowerCase();
if (low.includes("banned") || low.includes("revoked")) {
setError("Votre compte a été révoqué. Merci de contacter votre administrateur.");
} else if (low.includes("incorrect") || low.includes("invalid")) {
setError("Email ou mot de passe incorrect.");
} else if (txt.includes("Code 2FA invalide")) {
setError("Code 2FA invalide. Veuillez réessayer.");
// Reset MFA inputs
setMfaCode("");
setMfaDigits(Array(CODE_LENGTH).fill(""));
setTimeout(() => {
if (mfaInputsRef.current[0]) {
mfaInputsRef.current[0].focus();
}
}, 100);
} else {
setError(txt || "Erreur de connexion.");
}
hasSubmittedRef.current = false;
setLoading(false);
return;
}
// Succès : redirection
window.location.href = "/";
} catch (err: any) {
setError(err?.message || "Erreur inattendue");
hasSubmittedRef.current = false;
setLoading(false);
} finally {
// Ne remettre loading à false que si pas de redirection (cas d'erreur géré dans catch)
}
}
// OTP FLOW (code existant)
async function verifyCode() {
if (hasSubmittedRef.current) return;
if (code.length !== CODE_LENGTH) return;
hasSubmittedRef.current = true;
setLoading(true);
resetMessages();
try {
const res = await fetch("/api/auth/verify-code", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ email, code, rememberMe }),
});
if (res.status === 403) {
setError("Votre compte a été révoqué. Merci de contacter votre administrateur.");
hasSubmittedRef.current = false;
setLoading(false);
return;
}
if (!res.ok) {
const txt = await res.text().catch(() => "");
const low = (txt || "").toLowerCase();
if (low.includes("banned") || low.includes("revoked")) {
setError("Votre compte a été révoqué. Merci de contacter votre administrateur.");
} else {
setError(txt || "Code invalide ou expiré.");
}
hasSubmittedRef.current = false;
setLoading(false);
return;
}
window.location.href = "/";
} catch (err: any) {
setError(err?.message || "Erreur inattendue");
hasSubmittedRef.current = false;
setLoading(false);
}
}
async function handleEmailSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
resetMessages();
try {
const res = await fetch("/api/auth/send-code", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
if (res.status === 403) {
setError("Votre compte a été révoqué. Merci de contacter votre administrateur.");
return;
}
if (!res.ok) {
const txt = await res.text().catch(() => "");
const low = (txt || "").toLowerCase();
if (low.includes("banned") || low.includes("revoked")) {
setError("Votre compte a été révoqué. Merci de contacter votre administrateur.");
} else {
setError(txt || "Impossible d'envoyer le code. Réessayez.");
}
return;
}
setOtpStep("code");
setCode("");
setCodeDigits(Array(CODE_LENGTH).fill(""));
setInfo("Un code vous a été envoyé par e-mail.");
} catch (err: any) {
setError(err?.message || "Erreur inattendue");
} finally {
setLoading(false);
}
}
async function handleCodeSubmit(e: React.FormEvent) {
e.preventDefault();
await verifyCode();
}
useEffect(() => {
if (otpStep === "code" && code.length === CODE_LENGTH && !loading) {
verifyCode();
}
}, [code, otpStep, loading]);
// Auto-submit MFA code when complete
useEffect(() => {
if (mfaRequired && mfaCode.length === CODE_LENGTH && !loading && savedCredentials) {
handlePasswordSubmit({ preventDefault: () => {} } as React.FormEvent);
}
}, [mfaCode, mfaRequired, loading, savedCredentials]);
function backToPasswordStep() {
setMfaRequired(false);
setSavedCredentials(null);
setMfaCode("");
setMfaDigits(Array(CODE_LENGTH).fill(""));
resetMessages();
}
async function resendCode() {
if (!email || loading) return;
setLoading(true);
setError("");
try {
const res = await fetch("/api/auth/send-code", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
if (res.status === 403) {
setError("Votre compte a été révoqué. Merci de contacter votre administrateur.");
return;
}
if (!res.ok) {
setError("Impossible de renvoyer le code.");
return;
}
setInfo("Nouveau code envoyé.");
} catch (_) {
setError("Erreur réseau.");
} finally {
setLoading(false);
}
}
function switchMode(mode: "otp" | "password") {
setAuthMode(mode);
resetMessages();
setOtpStep("email");
setCode("");
setCodeDigits(Array(CODE_LENGTH).fill(""));
setPassword("");
setMfaRequired(false);
setSavedCredentials(null);
setMfaCode("");
setMfaDigits(Array(CODE_LENGTH).fill(""));
hasSubmittedRef.current = false;
}
return (
<div className={styles.meshRoot}>
<div className={styles.simpleBg} aria-hidden="true" />
<div className="relative z-10 min-h-screen flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className={`rounded-3xl p-6 sm:p-8 ${styles.glass} text-[#171424]`}>
<div className="flex flex-col items-center text-center mb-6">
<img
src="/odentas-logo.png"
alt="Logo Odentas"
className="h-20 w-auto mb-3 drop-shadow-lg"
style={{ maxWidth: "80%" }}
/>
<div className="text-xs sm:text-sm text-[#171424]/70 flex items-center gap-1">
<Lock size={16} strokeWidth={2.2} className="inline-block align-middle text-[#6366f1]" aria-label="Espace sécurisé" />
Espace sécurisé
</div>
</div>
<div className="mb-4 text-center">
<h1 className="text-xl sm:text-2xl font-semibold tracking-tight text-[#171424]">
{isStaffAccess ? (
<>
<Settings size={20} className="inline mr-2 text-orange-500" />
Connexion Staff - Mode Maintenance
</>
) : (
"Connexion à l'Espace Paie"
)}
</h1>
{isStaffAccess && (
<p className="text-sm text-orange-600 mt-2 bg-orange-50 px-3 py-2 rounded-lg">
Site en maintenance - Accès équipe uniquement
</p>
)}
</div>
{/* Mode Toggle */}
<div className="flex gap-2 mb-4 p-1 bg-white/20 rounded-xl">
<button
onClick={() => switchMode("otp")}
className={`flex-1 py-2 px-3 rounded-lg text-sm font-medium transition-all ${
authMode === "otp"
? "bg-white/60 text-[#171424] shadow-sm"
: "text-[#171424]/70 hover:text-[#171424]"
}`}
>
<Mail size={16} className="inline mr-2" />
Code par e-mail
</button>
<button
onClick={() => switchMode("password")}
className={`flex-1 py-2 px-3 rounded-lg text-sm font-medium transition-all ${
authMode === "password"
? "bg-white/60 text-[#171424] shadow-sm"
: "text-[#171424]/70 hover:text-[#171424]"
}`}
>
<Key size={16} className="inline mr-2" />
Mot de passe
</button>
</div>
{authMode === "password" && !mfaRequired ? (
<>
<p className="text-sm text-[#171424]/70 mb-5">
Connectez-vous avec votre adresse e-mail et votre mot de passe.
</p>
<form onSubmit={handlePasswordSubmit} className="space-y-3">
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Votre adresse e-mail"
autoComplete="email"
className="w-full px-4 py-3 rounded-2xl border-2 border-[#6366f1]/40 bg-white/30 text-[#171424] placeholder-[#171424]/60 focus:outline-none focus:ring-2 focus:ring-[#6366f1]/40 shadow-md transition"
style={{ fontSize: "1.15rem", fontWeight: 500 }}
/>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Votre mot de passe"
autoComplete="current-password"
className="w-full px-4 py-3 pr-12 rounded-2xl border-2 border-[#6366f1]/40 bg-white/30 text-[#171424] placeholder-[#171424]/60 focus:outline-none focus:ring-2 focus:ring-[#6366f1]/40 shadow-md transition"
style={{ fontSize: "1.15rem", fontWeight: 500 }}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-[#171424]/60 hover:text-[#171424] transition-colors"
>
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</button>
</div>
{/* Checkbox "Rester connecté" */}
<div className={styles.rememberMeSection}>
<label className={styles.rememberMeLabel}>
<input
type="checkbox"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
className={styles.rememberMeCheckbox}
/>
<span className={styles.rememberMeText}>
Rester connecté pendant 30 jours
</span>
</label>
{rememberMe && (
<div className={styles.rememberMeWarning}>
<AlertCircle size={14} className={styles.warningIcon} />
<span className={styles.warningText}>
Recommandé uniquement sur un ordinateur non partagé
</span>
<button
type="button"
onClick={() => setShowRememberMeModal(true)}
className={styles.whyButton}
>
Pourquoi ?
</button>
</div>
)}
</div>
{error && <div className="text-sm text-rose-300">{error}</div>}
{info && <div className="text-sm text-emerald-300">{info}</div>}
<button
type="submit"
disabled={loading}
className="w-full py-2.5 rounded-xl bg-[var(--brand)] text-slate-900 font-semibold disabled:opacity-60 transition active:scale-[0.99]"
>
{loading ? "Connexion en cours…" : "Se connecter"}
</button>
</form>
<div className="mt-4 pt-4 border-t border-[#171424]/10">
<p className="text-xs text-[#171424]/60 text-center">
Mot de passe oublié ? Connectez-vous avec le code par e-mail et modifiez-le depuis la page sécurité de votre compte.
</p>
</div>
</>
) : authMode === "password" && mfaRequired ? (
<>
<p className="text-sm text-[#171424]/70 mb-5">
Saisissez le code de votre application d'authentification.
</p>
<form onSubmit={handlePasswordSubmit} className="space-y-4">
<div
className="flex justify-center gap-1 sm:gap-2"
onPaste={handleMfaPaste}
aria-label="Saisissez le code à 6 chiffres de votre application d'authentification"
>
{Array.from({ length: CODE_LENGTH }).map((_, i) => (
<input
key={i}
ref={(el) => { mfaInputsRef.current[i] = el; }}
type="text"
inputMode="numeric"
autoComplete="one-time-code"
pattern="[0-9]*"
maxLength={1}
value={mfaDigits[i] || ""}
onChange={(e) => handleMfaDigitChange(i, e.target.value)}
onKeyDown={(e) => handleMfaKeyDown(i, e)}
className="w-12 sm:w-14 h-14 sm:h-16 text-center text-2xl sm:text-3xl rounded-xl sm:rounded-2xl bg-white/70 border-2 border-[#6366f1]/40 text-[#171424] placeholder-[#171424]/40 focus:outline-none focus:ring-2 focus:ring-[#6366f1]/40 shadow-lg transition flex-shrink-0"
style={{ fontWeight: 700, letterSpacing: "0.1em", width: "3rem", minWidth: "3rem", maxWidth: "3rem" }}
aria-label={`Chiffre ${i + 1}`}
/>
))}
</div>
{error && <div className="text-sm text-rose-300">{error}</div>}
{info && <div className="text-sm text-emerald-300">{info}</div>}
<button
type="submit"
disabled={loading || mfaCode.length !== CODE_LENGTH}
className="w-full py-2.5 rounded-xl bg-[var(--brand)] text-slate-900 font-semibold disabled:opacity-60 transition active:scale-[0.99]"
>
{loading ? "Vérification en cours" : "Vérifier le code"}
</button>
</form>
<div className="mt-4 pt-4 border-t border-[#171424]/10 flex justify-center">
<button
type="button"
onClick={backToPasswordStep}
className="text-xs text-[#171424]/60 hover:text-[#171424] transition-colors underline"
>
← Retour à la connexion
</button>
</div>
</>
) : (
<>
<p className="text-sm text-[#171424]/70 mb-5">
Saisissez votre e-mail, puis le code reçu pour vous connecter.
</p>
{otpStep === "email" && (
<form onSubmit={handleEmailSubmit} className="space-y-3">
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Votre adresse e-mail"
autoComplete="email"
className="w-full px-4 py-3 rounded-2xl border-2 border-[#6366f1]/40 bg-white/30 text-[#171424] placeholder-[#171424]/60 focus:outline-none focus:ring-2 focus:ring-[#6366f1]/40 shadow-md transition"
style={{ fontSize: "1.15rem", fontWeight: 500 }}
/>
{error && <div className="text-sm text-rose-300">{error}</div>}
{info && <div className="text-sm text-emerald-300">{info}</div>}
<button
type="submit"
disabled={loading}
className="w-full py-2.5 rounded-xl bg-[var(--brand)] text-slate-900 font-semibold disabled:opacity-60 transition active:scale-[0.99]"
>
{loading ? "Envoi en cours" : "Recevoir un code"}
</button>
</form>
)}
{otpStep === "code" && (
<form onSubmit={handleCodeSubmit} className="space-y-4">
<div
className="flex justify-center gap-1 sm:gap-2"
onPaste={handlePaste}
aria-label="Saisissez le code à 6 chiffres"
>
{Array.from({ length: CODE_LENGTH }).map((_, i) => (
<input
key={i}
ref={(el) => { inputsRef.current[i] = el; }}
type="text"
inputMode="numeric"
autoComplete="one-time-code"
pattern="[0-9]*"
maxLength={1}
value={codeDigits[i] || ""}
onChange={(e) => handleDigitChange(i, e.target.value)}
onKeyDown={(e) => handleKeyDown(i, e)}
className="w-12 sm:w-14 h-14 sm:h-16 text-center text-2xl sm:text-3xl rounded-xl sm:rounded-2xl bg-white/70 border-2 border-[#6366f1]/40 text-[#171424] placeholder-[#171424]/40 focus:outline-none focus:ring-2 focus:ring-[#6366f1]/40 shadow-lg transition flex-shrink-0"
style={{ fontWeight: 700, letterSpacing: "0.1em", width: "3rem", minWidth: "3rem", maxWidth: "3rem" }}
aria-label={`Chiffre ${i + 1}`}
/>
))}
</div>
{error && <div className="text-sm text-rose-300">{error}</div>}
{info && <div className="text-sm text-emerald-300">{info}</div>}
{/* Checkbox "Rester connecté" */}
<div className={styles.rememberMeSection}>
<label className={styles.rememberMeLabel}>
<input
type="checkbox"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
className={styles.rememberMeCheckbox}
/>
<span className={styles.rememberMeText}>
Rester connecté pendant 30 jours
</span>
</label>
{rememberMe && (
<div className={styles.rememberMeWarning}>
<AlertCircle size={14} className={styles.warningIcon} />
<span className={styles.warningText}>
Recommandé uniquement sur un ordinateur non partagé
</span>
<button
type="button"
onClick={() => setShowRememberMeModal(true)}
className={styles.whyButton}
>
Pourquoi ?
</button>
</div>
)}
</div>
<div className="flex items-center justify-between gap-3">
<button
type="button"
onClick={() => {
setOtpStep("email");
setCode("");
setCodeDigits(Array(CODE_LENGTH).fill(""));
hasSubmittedRef.current = false;
resetMessages();
}}
className="px-3 py-2 rounded-xl border border-[#171424]/20 text-sm text-[#171424]/80 hover:text-[#171424] hover:border-[#171424]/30 transition"
disabled={loading}
>
Modifier l'e-mail
</button>
<button
type="button"
onClick={resendCode}
className="text-sm text-[#171424]/80 hover:text-[#171424] underline-offset-4 hover:underline"
disabled={loading}
>
Renvoyer le code
</button>
</div>
<input type="hidden" value={code} readOnly />
<button
type="submit"
disabled={loading || code.length !== CODE_LENGTH}
className="w-full py-2.5 rounded-xl bg-[var(--brand)] text-slate-900 font-semibold disabled:opacity-60 transition active:scale-[0.99]"
>
{loading ? "Vérification" : "Se connecter"}
</button>
</form>
)}
<div className="mt-4 pt-4 border-t border-[#171424]/10">
<p className="text-xs text-[#171424]/60 text-center">
Suite à un changement d'infrastructure, les anciens mots de passe ne sont plus fonctionnels. Nous vous invitons à vous connecter grâce à l'envoi d'un code par e-mail et à recréer un mot de passe depuis la page Sécurité de votre Espace Paie.
</p>
</div>
</>
)}
</div>
</div>
</div>
{((authMode === "otp" && otpStep === "code") || (authMode === "password")) && loading && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-label="Connexion en cours"
>
<div className="flex flex-col items-center gap-4 rounded-2xl bg-white/10 border border-white/20 backdrop-blur-xl px-6 py-8 shadow-2xl text-white">
<div className="w-10 h-10 rounded-full border-4 border-white/30 border-t-[var(--brand)] animate-spin" />
<div className="text-white/90 font-medium">
Connexion en cours…
</div>
<div className="text-xs text-white/70">
Vous allez être redirigé vers votre Espace Paie Employeur.
</div>
</div>
</div>
)}
{/* Modal d'information "Rester connecté" */}
<RememberMeInfoModal
isOpen={showRememberMeModal}
onClose={() => setShowRememberMeModal(false)}
/>
</div>
);
}