'use server' import { createServerClient } from '@supabase/ssr' import { cookies } from 'next/headers' import { getSupabaseAdmin } from '@/lib/supabase' export type InviteUserResult = { success: true } | { success: false; error: string } const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ // Cliente ligado a la sesión (cookies) — anon key, respeta RLS. Se usa solo // para identificar a quien llama y leer SU PROPIA fila de user_roles // (policy user_roles_select_own: user_id = auth.uid()). Nunca para las // escrituras de esta acción — esas requieren service_role (ver getSupabaseAdmin). async function getSessionClient() { const cookieStore = await cookies() return createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { getAll() { return cookieStore.getAll() }, setAll(cookiesToSet) { try { cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options) ) } catch { // Server Action no siempre puede mutar cookies — se ignora } }, }, } ) } // Invita a un usuario de empresa y lo asocia a su organización con rol org_admin. // Seguridad: quien llama debe ser platform_admin — se verifica leyendo user_roles // en la DB con la sesión real (auth.uid()), nunca confiando en datos del formData. // El rol del nuevo usuario es siempre 'org_admin', fijo en código — no es un // valor que el formulario pueda controlar para escalar privilegios. export async function inviteUser(formData: FormData): Promise { const sessionClient = await getSessionClient() // auth.getUser() valida el token contra el servidor de Auth — no decodifica // la cookie localmente (mismo patrón que middleware.ts). const { data: { user: caller } } = await sessionClient.auth.getUser() if (!caller) return { success: false, error: 'no_autenticado' } // Verificación de autorización — lee la fila real de user_roles del llamante. const { data: callerRole, error: roleErr } = await sessionClient .from('user_roles') .select('app_role') .eq('user_id', caller.id) .maybeSingle() if (roleErr || callerRole?.app_role !== 'platform_admin') { return { success: false, error: 'forbidden' } } const email = String(formData.get('email') ?? '').trim().toLowerCase() const orgId = String(formData.get('orgId') ?? '').trim() if (!EMAIL_RE.test(email)) return { success: false, error: 'email_invalido' } if (!orgId) return { success: false, error: 'organizacion_requerida' } const admin = getSupabaseAdmin() // La organización debe existir — admin client porque ya está autorizado el // llamante (paso anterior) y queremos evitar depender del claim app_role del // JWT (puede estar stale si el rol cambió desde la última emisión de token). const { data: org } = await admin .from('organizations') .select('id') .eq('id', orgId) .maybeSingle() if (!org) return { success: false, error: 'organizacion_no_encontrada' } const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? '' const { data: inviteData, error: inviteErr } = await admin.auth.admin.inviteUserByEmail( email, { redirectTo: `${siteUrl}/auth/confirm` } ) if (inviteErr || !inviteData?.user) { return { success: false, error: `invite_failed: ${inviteErr?.message ?? 'sin detalle'}` } } const newUserId = inviteData.user.id // Insert inmediato — antes de que el usuario confirme nada. user_roles no // tiene policy de INSERT (RLS deniega por defecto), por eso requiere admin. const { error: roleInsertErr } = await admin .from('user_roles') .insert({ user_id: newUserId, app_role: 'org_admin', org_id: orgId }) if (roleInsertErr) { // Rollback — no dejar un usuario en auth.users sin rol asignado. await admin.auth.admin.deleteUser(newUserId) return { success: false, error: `role_insert_failed: ${roleInsertErr.message}` } } return { success: true } }