// 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): Promise { 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, 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 }); }