espace-paie-odentas/app/activate/ActivateContent.tsx
odentas e9cb6e7e0e feat: Système unifié d'invitation avec emails d'activation
- Créé sendInvitationWithActivationEmail() pour unifier les invitations
- Modifié /api/staff/users/invite pour utiliser generateLink + email
- Modifié /api/access/nouveau pour envoyer email d'activation
- Modifié /api/access POST pour remplacer pending_invites par système direct
- Template account-activation mis à jour :
  * Titre 'Activez votre compte'
  * Encart avec infos : invitant (statut), organisation, niveau d'accès
  * Message de contact formaté comme autres emails
  * Renommage 'Odentas Paie' → 'Espace Paie Odentas'
- Fix page /activate : délai 100ms pour hash fragment + redirection 1s
- Liens d'activation forcés vers paie.odentas.fr (tests depuis localhost)
- Messages UI cohérents : 'Invitation envoyée' au lieu de 'Compte créé'
2025-11-14 17:41:46 +01:00

248 lines
10 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { createClientComponentClient } from "@supabase/auth-helpers-nextjs";
export default function ActivateContent() {
const router = useRouter();
const searchParams = useSearchParams();
const [status, setStatus] = useState<"loading" | "success" | "error">("loading");
const [message, setMessage] = useState("");
useEffect(() => {
const activateAccount = async () => {
console.log("🔄 [ACTIVATE] Début de l'activation");
// Attendre un instant pour que le hash fragment soit disponible
await new Promise(resolve => setTimeout(resolve, 100));
// Récupérer les paramètres depuis le fragment URL (#)
const hashParams = new URLSearchParams(window.location.hash.substring(1));
const access_token = hashParams.get("access_token");
const refresh_token = hashParams.get("refresh_token");
const type = hashParams.get("type");
// Fallback sur les paramètres de requête normaux
const queryToken = searchParams.get("token_hash");
const queryType = searchParams.get("type");
const next = searchParams.get("next") || "/";
console.log("📋 [ACTIVATE] Paramètres reçus:", {
access_token: access_token ? `${access_token.substring(0, 10)}...` : null,
refresh_token: refresh_token ? `${refresh_token.substring(0, 10)}...` : null,
type: type || queryType,
queryToken: queryToken ? `${queryToken.substring(0, 10)}...` : null,
next,
fullUrl: window.location.href
});
// Vérifier si on a les tokens dans le fragment URL (nouveau format)
if (access_token && refresh_token && type === "invite") {
console.log("🆕 [ACTIVATE] Nouveau format d'invitation (fragment URL)");
try {
const supabase = createClientComponentClient();
// Établir la session avec les tokens du fragment
const { data, error } = await supabase.auth.setSession({
access_token,
refresh_token
});
if (error) {
console.log("❌ [ACTIVATE] Erreur lors de l'établissement de la session:", error);
setStatus("error");
setMessage(`Erreur lors de l'activation: ${error.message}`);
return;
}
console.log("✅ [ACTIVATE] Session établie avec succès:", {
userId: data?.user?.id,
email: data?.user?.email,
sessionExists: !!data?.session
});
if (data?.session && data?.user) {
setStatus("success");
setMessage("Compte activé avec succès ! Redirection en cours...");
// Nettoyer l'URL (supprimer le fragment)
window.history.replaceState({}, document.title, window.location.pathname);
// Récupérer et définir l'organisation active de l'utilisateur
try {
const { data: memberData } = await supabase
.from("organization_members")
.select("org_id, organizations(id, name)")
.eq("user_id", data.user.id)
.eq("revoked", false)
.maybeSingle();
if (memberData?.org_id) {
console.log("🏛️ [ACTIVATE] Définition de l'organisation active:", memberData.org_id);
// Définir le cookie active_org_id pour que l'utilisateur soit connecté à sa structure
document.cookie = `active_org_id=${memberData.org_id}; path=/; secure; samesite=lax`;
// Optionnel : stocker le nom de l'organisation en localStorage pour l'UI
const orgData = memberData.organizations as any;
if (orgData?.name) {
localStorage.setItem("company_name", orgData.name);
}
}
} catch (orgError) {
console.log("⚠️ [ACTIVATE] Impossible de récupérer l'organisation:", orgError);
// Ne pas faire échouer l'activation pour ça
}
// Redirection immédiate - les cookies sont déjà définis
setTimeout(() => {
router.push(next);
}, 1000);
} else {
setStatus("error");
setMessage("Erreur: Session non créée après l'activation.");
}
} catch (error: any) {
console.log("💥 [ACTIVATE] Erreur lors de l'établissement de la session:", error);
setStatus("error");
setMessage(`Erreur inattendue: ${error.message || "Erreur inconnue"}`);
}
return;
}
// Fallback sur l'ancien format avec token_hash
if (!queryToken || !queryType) {
console.log("❌ [ACTIVATE] Paramètres manquants");
setStatus("error");
setMessage("Lien d'activation invalide. Paramètres manquants.");
return;
}
try {
const supabase = createClientComponentClient();
console.log("🔄 [ACTIVATE] Vérification de la session...");
// Vérifier le type d'invitation (ancien format)
if (queryType === "invite") {
console.log("👥 [ACTIVATE] Traitement d'une invitation (ancien format)");
// Accepter l'invitation avec le token
const { data, error } = await supabase.auth.verifyOtp({
token_hash: queryToken,
type: "invite"
});
if (error) {
console.log("❌ [ACTIVATE] Erreur lors de l'acceptation de l'invitation:", error);
setStatus("error");
setMessage(`Erreur lors de l'activation: ${error.message}`);
return;
}
console.log("✅ [ACTIVATE] Invitation acceptée:", {
userId: data?.user?.id,
email: data?.user?.email,
sessionExists: !!data?.session
});
if (data?.session && data?.user) {
setStatus("success");
setMessage("Compte activé avec succès ! Redirection en cours...");
// Récupérer et définir l'organisation active de l'utilisateur
try {
const { data: memberData } = await supabase
.from("organization_members")
.select("org_id, organizations(id, name)")
.eq("user_id", data.user.id)
.eq("revoked", false)
.maybeSingle();
if (memberData?.org_id) {
console.log("🏛️ [ACTIVATE] Définition de l'organisation active:", memberData.org_id);
// Définir le cookie active_org_id pour que l'utilisateur soit connecté à sa structure
document.cookie = `active_org_id=${memberData.org_id}; path=/; secure; samesite=lax`;
// Optionnel : stocker le nom de l'organisation en localStorage pour l'UI
const orgData = memberData.organizations as any;
if (orgData?.name) {
localStorage.setItem("company_name", orgData.name);
}
}
} catch (orgError) {
console.log("⚠️ [ACTIVATE] Impossible de récupérer l'organisation:", orgError);
// Ne pas faire échouer l'activation pour ça
}
// Redirection immédiate - les cookies sont déjà définis
setTimeout(() => {
router.push(next);
}, 1000);
} else {
setStatus("error");
setMessage("Erreur: Session non créée après l'activation.");
}
} else {
console.log("❓ [ACTIVATE] Type d'activation non géré:", queryType);
setStatus("error");
setMessage(`Type d'activation non supporté: ${queryType}`);
}
} catch (error: any) {
console.log("💥 [ACTIVATE] Erreur inattendue:", error);
setStatus("error");
setMessage(`Erreur inattendue: ${error.message || "Erreur inconnue"}`);
}
};
activateAccount();
}, [searchParams, router]);
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full space-y-8">
<div className="text-center">
<h2 className="mt-6 text-3xl font-extrabold text-gray-900">
Activation de votre compte
</h2>
{status === "loading" && (
<div className="mt-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-2 text-sm text-gray-600">Activation en cours...</p>
</div>
)}
{status === "success" && (
<div className="mt-4">
<div className="text-green-600">
<svg className="mx-auto h-12 w-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<p className="mt-2 text-sm text-green-600">{message}</p>
</div>
)}
{status === "error" && (
<div className="mt-4">
<div className="text-red-600">
<svg className="mx-auto h-12 w-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<p className="mt-2 text-sm text-red-600">{message}</p>
<p className="mt-4 text-xs text-gray-500">
Si le problème persiste, contactez le support ou demandez un nouveau lien d'invitation.
</p>
</div>
)}
</div>
</div>
</div>
);
}