espace-paie-odentas/app/api/access/route.ts
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

545 lines
19 KiB
TypeScript

// app/api/access/route.ts
export const dynamic = "force-dynamic";
import { NextResponse } from "next/server";
import { cookies, headers } from "next/headers";
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { createClient } from "@supabase/supabase-js";
import { sendAccessUpdatedEmail, sendInvitationWithActivationEmail } from "@/lib/emailMigrationHelpers";
// import type { Database } from "@/types/supabase"; // Temporairement commenté
type OrgRow = { id: string; name?: string; structure_api?: string | null };
type MemberRow = { role: string; revoked: boolean };
function makeServerClient() {
return createRouteHandlerClient({ cookies });
}
/**
* Fonction simplifiée pour résoudre l'ID d'organisation
* Compatible avec la nouvelle approche utilisant /api/me et les headers injectés par api()
*/
async function resolveOrgId(sb: ReturnType<typeof makeServerClient>): Promise<string | null> {
const h = headers();
const c = cookies();
// PRIORITÉ 1: Déterminer si l'utilisateur est staff ou non
try {
const { data: { user } } = await sb.auth.getUser();
if (!user) {
console.log("❌ Aucun utilisateur authentifié");
return null;
}
const { data: staffData } = await sb.from("staff_users").select("is_staff").eq("user_id", user.id).maybeSingle();
const isStaff = !!staffData?.is_staff;
console.log("👤 Utilisateur:", { id: user.id, email: user.email, isStaff });
if (!isStaff) {
// UTILISATEUR CLIENT: Utiliser get_my_org (source de vérité)
try {
const { data: myOrgRaw } = await sb.rpc("get_my_org").maybeSingle();
const myOrg = (myOrgRaw as OrgRow | null) ?? null;
if (myOrg?.id) {
console.log("✅ Org client via get_my_org:", { id: myOrg.id, name: myOrg.name });
return myOrg.id;
}
} catch (e: unknown) {
const message = e instanceof Error ? e.message : String(e);
console.warn("⚠️ Erreur get_my_org:", message);
}
console.log("❌ Aucune organisation trouvée pour le client");
return null;
}
// UTILISATEUR STAFF: Utiliser cookies/headers
console.log("👨‍💼 Utilisateur staff - résolution via cookies/headers");
} catch (e: any) {
console.warn("⚠️ Erreur détection utilisateur:", e.message);
return null;
}
// --- CODE STAFF (conservé tel quel) ---
// 0.a) ID direct
const serverCookieOrgId = c.get("active_org_id")?.value;
if (serverCookieOrgId) {
console.log("🔍 Org ID via cookie httpOnly active_org_id:", serverCookieOrgId);
return serverCookieOrgId;
}
// 0.b) Clé/API (structure_api) - ex: active_org_key
const activeOrgKey = c.get("active_org_key")?.value;
if (activeOrgKey) {
console.log("🔍 Recherche org via active_org_key (structure_api):", activeOrgKey);
const { data } = await sb.from("organizations").select("id").eq("structure_api", activeOrgKey).maybeSingle();
const org = (data as OrgRow | null) ?? null;
if (org?.id) {
console.log("✅ Org ID trouvé via active_org_key:", org.id);
return org.id;
}
}
// 0.c) Nom encodé en base64 - ex: active_org_name_b64
const nameB64 = c.get("active_org_name_b64")?.value;
if (nameB64) {
try {
const decoded = Buffer.from(nameB64, "base64").toString("utf8");
console.log("🔍 Recherche org via active_org_name_b64 décodé:", decoded);
const { data } = await sb.from("organizations").select("id").eq("name", decoded).maybeSingle();
const org = (data as OrgRow | null) ?? null;
if (org?.id) {
console.log("✅ Org ID trouvé via active_org_name_b64:", org.id);
return org.id;
}
} catch (e) {
console.warn("⚠️ Erreur décodage active_org_name_b64:", e);
}
}
// --- 1) Headers injectés par le helper client (compat héritage) ---
const fromHeader = h.get("x-active-org-id");
if (fromHeader) {
console.log("🔍 Org ID via header x-active-org-id:", fromHeader);
return fromHeader;
}
// --- 2) Cookies hérités (compat) ---
const legacyCookieId = c.get("active_org_id")?.value;
if (legacyCookieId) {
console.log("🔍 Org ID via cookie legacy active_org_id:", legacyCookieId);
return legacyCookieId;
}
// --- 3) Nom d'entreprise via headers (compat) ---
let name = h.get("x-company-name") || "";
if (!name) {
const b64 = h.get("x-company-name-b64");
if (b64) {
try {
name = Buffer.from(b64, "base64").toString("utf8");
console.log("🔍 Nom d'entreprise décodé depuis header base64:", name);
} catch (e) {
console.warn("⚠️ Erreur décodage header x-company-name-b64:", e);
}
}
}
if (name) {
console.log("🔍 Recherche org via nom (headers):", name);
const { data } = await sb.from("organizations").select("id").eq("name", name).maybeSingle();
const org = (data as OrgRow | null) ?? null;
if (org?.id) {
console.log("✅ Org ID trouvé via nom (headers):", org.id);
return org.id;
}
}
console.log("❌ Aucune organisation trouvée pour le staff");
return null;
}
async function guardRole(sb: ReturnType<typeof makeServerClient>, org_id: string) {
const { data: { user } } = await sb.auth.getUser();
if (!user) {
console.log("❌ Pas d'utilisateur authentifié");
return { ok: false as const, status: 401 as const };
}
// Vérifier d'abord si c'est un staff
const { data: staffData } = await sb.from("staff_users").select("is_staff").eq("user_id", user.id).maybeSingle();
if (staffData?.is_staff) {
console.log("✅ Utilisateur staff autorisé");
return { ok: true as const, status: 200 as const, user };
}
// Si pas staff, vérifier les permissions d'organisation
const { data: meRow, error } = await sb
.from("organization_members")
.select("role, revoked")
.eq("org_id", org_id)
.eq("user_id", user.id)
.maybeSingle();
const me = (meRow as MemberRow | null) ?? null;
if (error) {
console.log("❌ Erreur lors de la vérification du rôle:", error);
return { ok: false as const, status: 403 as const };
}
if (!me) {
console.log("❌ Utilisateur non membre de l'organisation");
return { ok: false as const, status: 403 as const };
}
if (me.revoked) {
console.log("❌ Utilisateur révoqué");
return { ok: false as const, status: 403 as const };
}
if (!["ADMIN", "SUPER_ADMIN"].includes(me.role)) {
console.log("❌ Rôle insuffisant:", me.role);
return { ok: false as const, status: 403 as const };
}
console.log("✅ Utilisateur autorisé avec rôle:", me.role);
return { ok: true as const, status: 200 as const, user };
}
export async function GET() {
console.log("📥 GET /api/access - Début du traitement");
const sb = makeServerClient();
const org_id = await resolveOrgId(sb);
// If no org_id resolved, only allow staff users to proceed (null-org semantics).
// Non-staff callers must have an active org.
let staffMode = false;
if (!org_id) {
// detect staff status
const { data: { user } } = await sb.auth.getUser();
if (!user) {
console.log("❌ Aucun utilisateur authentifié");
return new NextResponse("Unauthorized", { status: 401 });
}
try {
const { data: staffData } = await sb.from("staff_users").select("is_staff").eq("user_id", user.id).maybeSingle();
staffMode = !!staffData?.is_staff;
} catch (e) {
staffMode = false;
}
if (!staffMode) {
console.log("❌ Aucune organisation active trouvée (non-staff)");
return json(400, { error: "no_active_org", message: "Aucune organisation active trouvée" });
}
console.log("👨‍💼 Staff mode: no active org selected — proceeding with global access");
}
console.log("🏢 Organisation active:", org_id);
// For non-staff flows, validate role on the resolved org
if (!staffMode) {
const g = await guardRole(sb, org_id as string);
if (!g.ok) {
console.log("❌ Accès refusé");
return new NextResponse("Forbidden", { status: g.status });
}
}
console.log("✅ Accès autorisé, récupération des membres...");
// Récupération directe des membres de l'organisation
let data: any[] = [];
try {
console.log("🔍 Récupération des membres via requête directe...");
// Requête directe pour récupérer les membres avec leurs informations utilisateur
// If staffMode (global), don't filter by org_id and include org_id in the result.
let membersQuery = sb.from("organization_members").select(
staffMode
? `
org_id,
user_id,
role,
revoked,
created_at,
revoked_at
`
: `
user_id,
role,
revoked,
created_at,
revoked_at
`
);
if (!staffMode) {
membersQuery = membersQuery.eq("org_id", org_id as string);
}
const { data: members, error } = await membersQuery.order("created_at", { ascending: false });
if (error) {
console.log("❌ Erreur récupération members:", error);
return json(500, { error: "database_error", message: error.message });
}
if (!members || members.length === 0) {
console.log("⚠️ Aucun membre trouvé pour cette organisation");
return json(200, { items: [] });
}
// Récupérer les informations utilisateur pour chaque membre
const userIds = members.map((m: any) => m.user_id).filter(Boolean);
// Utiliser le service role pour récupérer les infos utilisateur
const admin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { autoRefreshToken: false, persistSession: false } }
);
// Récupérer les utilisateurs par batch
const usersData: any[] = [];
for (const userId of userIds) {
try {
const { data: userData } = await admin.auth.admin.getUserById(userId);
if (userData?.user) {
usersData.push({
id: userData.user.id,
email: userData.user.email,
first_name: userData.user.user_metadata?.first_name || userData.user.user_metadata?.display_name?.split(' ')[0] || null
});
}
} catch (e) {
console.warn("⚠️ Erreur récupération user", userId, ":", e);
}
}
// Combiner les données
data = members.map((member: any) => {
const user = usersData.find(u => u.id === member.user_id);
return {
org_id: member.org_id || null,
user_id: member.user_id,
first_name: user?.first_name || null,
email: user?.email || "Email non disponible",
role: member.role,
created_at: member.created_at,
revoked: member.revoked,
revoked_at: member.revoked_at
};
});
console.log("✅ Membres récupérés avec succès:", data.length);
} catch (e: any) {
console.log("❌ Erreur inattendue:", e.message);
return json(500, { error: "server_error", message: e.message });
}
console.log("📤 Retour des données:", { count: data.length });
return json(200, { items: data });
}
export async function POST(req: Request) {
console.log("📥 POST /api/access - Création d'un nouveau membre");
const sb = makeServerClient();
// Parse body early so we can reuse it for org resolution
const body = await req.json().catch(() => ({} as any));
let org_id = await resolveOrgId(sb) || (body?.org_id || null);
// If no org_id resolved, only allow staff callers to proceed and try staff fallbacks.
let staffMode = false;
if (!org_id) {
const { data: { user } } = await sb.auth.getUser();
if (!user) {
console.log("❌ Aucun utilisateur authentifié");
return new NextResponse("Unauthorized", { status: 401 });
}
try {
const { data: staffData } = await sb.from("staff_users").select("is_staff").eq("user_id", user.id).maybeSingle();
staffMode = !!staffData?.is_staff;
} catch (e) {
staffMode = false;
}
// If staff, try staff_settings.active_org_id as a fallback
if (staffMode && !org_id) {
try {
const { data: ss } = await sb.from('staff_settings').select('active_org_id').eq('user_id', user.id).maybeSingle();
if (ss?.active_org_id) org_id = ss.active_org_id;
} catch {}
}
if (!org_id && !staffMode) {
console.log("❌ Aucune organisation active trouvée (non-staff)");
return json(400, { error: "no_active_org", message: "Aucune organisation active trouvée" });
}
}
// For non-staff flows, validate role on the resolved org
if (!staffMode) {
const g = await guardRole(sb, org_id as string);
if (!g.ok) {
console.log("❌ Accès refusé");
return new NextResponse("Forbidden", { status: g.status });
}
}
// body was parsed above
const email = String(body.email || "").trim().toLowerCase();
const role = (body.role || "AGENT").toString().toUpperCase(); // ADMIN | SUPER_ADMIN | AGENT | COMPTA
const first_name = String(body.first_name || "").trim();
console.log("📝 Données reçues:", { email, role, first_name, org_id });
if (!email) {
console.log("❌ Email manquant");
return json(400, { error: "missing_email", message: "L'email est requis" });
}
if (role === "SUPER_ADMIN") {
console.log("❌ Tentative de création SUPER_ADMIN");
return json(403, { error: "forbidden_role", message: "Le rôle SUPER_ADMIN ne peut être assigné" });
}
// Service role pour les opérations administratives
const admin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { autoRefreshToken: false, persistSession: false } }
);
try {
// 1) Trouver l'utilisateur existant par email
console.log("🔍 Recherche utilisateur par email...");
const { data: existingUser, error: findUserErr } = await admin
.rpc("find_user_by_email", { email_param: email })
.maybeSingle();
const existing = (existingUser as { id: string } | null) ?? null;
let user_id: string | undefined = existing?.id;
if (!user_id) {
console.log("👤 Utilisateur non trouvé, création d'une invitation avec envoi d'email...");
// Générer un lien d'invitation et envoyer l'email
const origin = process.env.NEXT_PUBLIC_BASE_URL || "https://paie.odentas.fr";
const { data: linkData, error: linkError } = await admin.auth.admin.generateLink({
type: "invite",
email,
options: {
redirectTo: `${origin}/activate`,
data: {
first_name: first_name || null,
role,
org_id,
org_name: org_id, // Sera enrichi après
},
},
});
if (linkError) {
console.log("❌ Erreur génération lien:", linkError.message);
return json(400, { error: linkError.message });
}
const actionLink = linkData?.properties?.action_link;
const newUserId = linkData?.user?.id;
if (!newUserId || !actionLink) {
console.log("❌ Erreur: lien ou user_id manquant");
return json(400, { error: "invite_failed" });
}
// Attacher à l'organisation
const { error: memberError } = await admin
.from("organization_members")
.insert({ org_id, user_id: newUserId, role, revoked: false });
if (memberError) {
console.log("❌ Erreur création membership:", memberError.message);
return json(400, { error: memberError.message });
}
// Configurer les préférences d'auth
await admin
.from("user_auth_prefs")
.upsert({ user_id: newUserId, allow_magic_link: true, allow_password: false });
// Récupérer le nom de l'organisation
const { data: orgData } = await admin
.from('organizations')
.select('name')
.eq('id', org_id)
.maybeSingle();
// Envoyer l'email d'activation
try {
await sendInvitationWithActivationEmail(email, {
firstName: first_name || undefined,
organizationName: (orgData as any)?.name || '',
activationUrl: actionLink,
role,
});
} catch (emailErr) {
console.warn('⚠️ Envoi email invitation échoué (non-bloquant):', (emailErr as any)?.message || emailErr);
}
console.log("✅ Invitation créée et email envoyé avec succès");
return json(200, { ok: true, message: "Invitation envoyée" });
} else {
console.log("👤 Utilisateur existant trouvé, ajout/mise à jour membership...");
// 2) Upsert membership si user déjà existant
// → Détecter un éventuel changement de rôle pour notifier l'utilisateur
// Lire l'ancien rôle si membership existant
let oldRole: string | null = null;
try {
const { data: existingMember } = await admin
.from("organization_members")
.select("role")
.eq("org_id", org_id)
.eq("user_id", user_id)
.maybeSingle();
oldRole = (existingMember as any)?.role || null;
} catch {}
const { error } = await admin
.from("organization_members")
.upsert({ org_id, user_id, role, revoked: false }, { onConflict: "org_id,user_id" });
if (error) {
console.log("❌ Erreur upsert membership:", error.message);
return json(400, { error: error.message });
}
console.log("✅ Membership créé/mis à jour avec succès");
// Si le rôle a changé, envoyer l'email de modification d'habilitation
try {
if (oldRole && oldRole !== role) {
const { data: userData } = await admin.auth.admin.getUserById(user_id);
const to = userData?.user?.email;
const firstName = userData?.user?.user_metadata?.first_name || undefined;
const { data: orgRow } = await admin.from('organizations').select('name').eq('id', org_id).maybeSingle();
// Acteur (modifié par)
const sb = makeServerClient();
const { data: { user: actor } } = await sb.auth.getUser();
const updatedBy = actor?.email || 'Administrateur';
if (to) {
await sendAccessUpdatedEmail(to, {
firstName: firstName || undefined,
organizationName: String((orgRow as any)?.name || ''),
oldRole,
newRole: role,
updatedBy,
updateDate: new Date().toLocaleString('fr-FR'),
ctaUrl: `${process.env.NEXT_PUBLIC_BASE_URL || 'https://paie.odentas.fr'}/vos-acces`,
});
}
}
} catch (e) {
console.warn('⚠️ Envoi email habilitation échoué (non-bloquant):', (e as any)?.message || e);
}
return json(200, { ok: true, message: "Utilisateur ajouté à l'organisation" });
}
} catch (e: any) {
console.log("❌ Erreur inattendue:", e.message);
return json(500, { error: "server_error", message: e.message });
}
}
function json(status: number, body: any) {
return NextResponse.json(body, { status });
}