This commit is contained in:
195
supabase/functions/invite-user/index.ts
Normal file
195
supabase/functions/invite-user/index.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Edge Function: invite-user
|
||||
* Propósito: invitar usuario externo (client_editor/client_viewer) o interno (member)
|
||||
* Solo accesible por platform_admin.
|
||||
* Variables de entorno requeridas: SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, SITE_URL
|
||||
* - SUPABASE_URL y SUPABASE_SERVICE_ROLE_KEY: disponibles automáticamente en Edge Functions
|
||||
* - SITE_URL: agregar manualmente en Supabase Dashboard → Edge Functions → Secrets
|
||||
* Valor producción: https://inq-roi.inqualityhq.com
|
||||
* Deploy: supabase functions deploy invite-user
|
||||
*/
|
||||
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
|
||||
|
||||
interface InviteUserRequest {
|
||||
email: string
|
||||
name: string
|
||||
platform_role: 'client_editor' | 'client_viewer' | 'member'
|
||||
org_name?: string
|
||||
}
|
||||
|
||||
const ALLOWED_ROLES = ['client_editor', 'client_viewer', 'member'] as const
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
Deno.serve(async (req: Request) => {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': Deno.env.get('SITE_URL') ?? '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
}
|
||||
|
||||
// Manejar CORS preflight
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response('ok', { headers })
|
||||
}
|
||||
|
||||
if (req.method !== 'POST') {
|
||||
return new Response(JSON.stringify({ error: 'Método no permitido' }), { status: 405, headers })
|
||||
}
|
||||
|
||||
const adminClient = createClient(
|
||||
Deno.env.get('SUPABASE_URL')!,
|
||||
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
|
||||
)
|
||||
|
||||
// ── 1. Autenticar al llamador ──────────────────────────────────────────────
|
||||
|
||||
const callerToken = req.headers.get('Authorization')?.replace('Bearer ', '')
|
||||
if (!callerToken) {
|
||||
return new Response(JSON.stringify({ error: 'Authorization header requerido' }), { status: 401, headers })
|
||||
}
|
||||
|
||||
const { data: { user: caller }, error: authError } = await adminClient.auth.getUser(callerToken)
|
||||
if (authError || !caller) {
|
||||
return new Response(JSON.stringify({ error: 'Token inválido o expirado' }), { status: 401, headers })
|
||||
}
|
||||
|
||||
// Verificar platform_role desde DB — el JWT no es fuente confiable para el rol
|
||||
const { data: callerProfile, error: profileError } = await adminClient
|
||||
.from('users')
|
||||
.select('platform_role')
|
||||
.eq('id', caller.id)
|
||||
.single()
|
||||
|
||||
if (profileError || callerProfile?.platform_role !== 'platform_admin') {
|
||||
return new Response(JSON.stringify({ error: 'Forbidden: solo platform_admin puede invitar usuarios' }), { status: 403, headers })
|
||||
}
|
||||
|
||||
// ── 2. Parsear y validar el body ──────────────────────────────────────────
|
||||
|
||||
let body: InviteUserRequest
|
||||
try {
|
||||
body = await req.json() as InviteUserRequest
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: 'Body JSON inválido' }), { status: 400, headers })
|
||||
}
|
||||
|
||||
const { email, name, platform_role, org_name } = body
|
||||
|
||||
if (!email || !EMAIL_REGEX.test(email)) {
|
||||
return new Response(JSON.stringify({ error: 'Email inválido o no proporcionado' }), { status: 400, headers })
|
||||
}
|
||||
|
||||
if (!name?.trim()) {
|
||||
return new Response(JSON.stringify({ error: 'El campo name es requerido' }), { status: 400, headers })
|
||||
}
|
||||
|
||||
if (!platform_role || !ALLOWED_ROLES.includes(platform_role)) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: `platform_role debe ser uno de: ${ALLOWED_ROLES.join(', ')}` }),
|
||||
{ status: 400, headers },
|
||||
)
|
||||
}
|
||||
|
||||
if ((platform_role === 'client_editor' || platform_role === 'client_viewer') && !org_name?.trim()) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'org_name es requerido para roles client_editor y client_viewer' }),
|
||||
{ status: 400, headers },
|
||||
)
|
||||
}
|
||||
|
||||
// ── 3. Resolver org_id ────────────────────────────────────────────────────
|
||||
|
||||
let orgId: string
|
||||
|
||||
if (platform_role === 'member') {
|
||||
// Nuevo miembro InQuality → la org es siempre InQuality (is_provider = true)
|
||||
const { data: inqOrg, error: inqOrgError } = await adminClient
|
||||
.from('organizations')
|
||||
.select('id')
|
||||
.eq('is_provider', true)
|
||||
.single()
|
||||
|
||||
if (inqOrgError || !inqOrg) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'No se encontró la organización InQuality en la base de datos' }),
|
||||
{ status: 500, headers },
|
||||
)
|
||||
}
|
||||
orgId = inqOrg.id
|
||||
} else {
|
||||
// Usuario cliente → buscar org por nombre exacto (trim), crear si no existe
|
||||
const trimmedOrgName = org_name!.trim()
|
||||
|
||||
const { data: existingOrg } = await adminClient
|
||||
.from('organizations')
|
||||
.select('id')
|
||||
.eq('name', trimmedOrgName)
|
||||
.single()
|
||||
|
||||
if (existingOrg) {
|
||||
orgId = existingOrg.id
|
||||
} else {
|
||||
const { data: newOrg, error: createOrgError } = await adminClient
|
||||
.from('organizations')
|
||||
.insert({ name: trimmedOrgName, is_provider: false })
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (createOrgError || !newOrg) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: `Error al crear organización: ${createOrgError?.message ?? 'desconocido'}` }),
|
||||
{ status: 500, headers },
|
||||
)
|
||||
}
|
||||
orgId = newOrg.id
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. Invitar usuario vía Auth Admin API ─────────────────────────────────
|
||||
// inviteUserByEmail crea el usuario en auth.users y envía el email de bienvenida.
|
||||
// Si el email ya existe en auth.users, retorna un error descriptivo.
|
||||
|
||||
const { data: inviteData, error: inviteError } = await adminClient.auth.admin.inviteUserByEmail(
|
||||
email,
|
||||
{
|
||||
data: { full_name: name.trim() }, // se almacena en raw_user_meta_data
|
||||
redirectTo: `${Deno.env.get('SITE_URL') ?? ''}/auth/callback`,
|
||||
},
|
||||
)
|
||||
|
||||
if (inviteError) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: inviteError.message }),
|
||||
{ status: 400, headers },
|
||||
)
|
||||
}
|
||||
|
||||
// ── 5. Crear fila en public.users ─────────────────────────────────────────
|
||||
// UPSERT con ignoreDuplicates: false para manejar la race condition con handle_new_user:
|
||||
// si el trigger ya creó la fila (emails @inquality.com.py), actualizar platform_role y org_id.
|
||||
|
||||
const { error: upsertError } = await adminClient
|
||||
.from('users')
|
||||
.upsert(
|
||||
{
|
||||
id: inviteData.user.id,
|
||||
name: name.trim(),
|
||||
platform_role,
|
||||
org_id: orgId,
|
||||
},
|
||||
{ onConflict: 'id', ignoreDuplicates: false },
|
||||
)
|
||||
|
||||
if (upsertError) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: `Error al crear perfil de usuario: ${upsertError.message}` }),
|
||||
{ status: 500, headers },
|
||||
)
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ success: true, userId: inviteData.user.id }),
|
||||
{ status: 200, headers },
|
||||
)
|
||||
})
|
||||
37
supabase/functions/main/index.ts
Normal file
37
supabase/functions/main/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Edge Function: main (relay)
|
||||
* Enruta todas las requests al worker de la función correspondiente.
|
||||
* Requerido por supabase/edge-runtime en self-hosted (--main-service).
|
||||
* No expuesto directamente — solo opera como router interno.
|
||||
*/
|
||||
|
||||
const FUNCTIONS = ['invite-user', 'revoke-user']
|
||||
|
||||
Deno.serve(async (req: Request) => {
|
||||
const url = new URL(req.url)
|
||||
|
||||
// Extraer nombre de función del path: /functions/v1/invite-user → invite-user
|
||||
const segments = url.pathname.split('/').filter(Boolean)
|
||||
const functionName = segments[segments.length - 1]
|
||||
|
||||
if (!functionName || !FUNCTIONS.includes(functionName)) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: `Función no encontrada: ${functionName}` }),
|
||||
{ status: 404, headers: { 'Content-Type': 'application/json' } },
|
||||
)
|
||||
}
|
||||
|
||||
const servicePath = `/home/deno/functions/${functionName}`
|
||||
|
||||
try {
|
||||
// EdgeRuntime.userWorkers es el API del supabase/edge-runtime para crear workers por función
|
||||
const worker = await EdgeRuntime.userWorkers.create({ servicePath })
|
||||
return await worker.fetch(req)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return new Response(
|
||||
JSON.stringify({ error: message }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } },
|
||||
)
|
||||
}
|
||||
})
|
||||
118
supabase/functions/revoke-user/index.ts
Normal file
118
supabase/functions/revoke-user/index.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Edge Function: revoke-user
|
||||
* Propósito: revocar acceso de un usuario (suspensión suave — cambia platform_role a 'guest')
|
||||
* Solo accesible por platform_admin. No elimina datos ni el usuario de auth.users.
|
||||
* Variables de entorno requeridas: SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, SITE_URL
|
||||
* - SUPABASE_URL y SUPABASE_SERVICE_ROLE_KEY: disponibles automáticamente en Edge Functions
|
||||
* - SITE_URL: agregar manualmente en Supabase Dashboard → Edge Functions → Secrets
|
||||
* Valor producción: https://inq-roi.inqualityhq.com
|
||||
* Deploy: supabase functions deploy revoke-user
|
||||
*/
|
||||
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
|
||||
|
||||
interface RevokeUserRequest {
|
||||
userId: string
|
||||
}
|
||||
|
||||
Deno.serve(async (req: Request) => {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': Deno.env.get('SITE_URL') ?? '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
}
|
||||
|
||||
// Manejar CORS preflight
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response('ok', { headers })
|
||||
}
|
||||
|
||||
if (req.method !== 'POST') {
|
||||
return new Response(JSON.stringify({ error: 'Método no permitido' }), { status: 405, headers })
|
||||
}
|
||||
|
||||
const adminClient = createClient(
|
||||
Deno.env.get('SUPABASE_URL')!,
|
||||
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
|
||||
)
|
||||
|
||||
// ── 1. Autenticar al llamador ──────────────────────────────────────────────
|
||||
|
||||
const callerToken = req.headers.get('Authorization')?.replace('Bearer ', '')
|
||||
if (!callerToken) {
|
||||
return new Response(JSON.stringify({ error: 'Authorization header requerido' }), { status: 401, headers })
|
||||
}
|
||||
|
||||
const { data: { user: caller }, error: authError } = await adminClient.auth.getUser(callerToken)
|
||||
if (authError || !caller) {
|
||||
return new Response(JSON.stringify({ error: 'Token inválido o expirado' }), { status: 401, headers })
|
||||
}
|
||||
|
||||
// Verificar platform_role desde DB — el JWT no es fuente confiable para el rol
|
||||
const { data: callerProfile, error: profileError } = await adminClient
|
||||
.from('users')
|
||||
.select('platform_role')
|
||||
.eq('id', caller.id)
|
||||
.single()
|
||||
|
||||
if (profileError || callerProfile?.platform_role !== 'platform_admin') {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Forbidden: solo platform_admin puede revocar accesos' }),
|
||||
{ status: 403, headers },
|
||||
)
|
||||
}
|
||||
|
||||
// ── 2. Parsear y validar el body ──────────────────────────────────────────
|
||||
|
||||
let body: RevokeUserRequest
|
||||
try {
|
||||
body = await req.json() as RevokeUserRequest
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: 'Body JSON inválido' }), { status: 400, headers })
|
||||
}
|
||||
|
||||
const { userId } = body
|
||||
|
||||
if (!userId?.trim()) {
|
||||
return new Response(JSON.stringify({ error: 'El campo userId es requerido' }), { status: 400, headers })
|
||||
}
|
||||
|
||||
// ── 3. Prevenir auto-revocación ───────────────────────────────────────────
|
||||
|
||||
if (userId === caller.id) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'No puedes revocar tu propio acceso' }),
|
||||
{ status: 400, headers },
|
||||
)
|
||||
}
|
||||
|
||||
// ── 4. Cambiar platform_role a 'guest' ────────────────────────────────────
|
||||
// Suspensión suave: el usuario queda en auth.users pero RLS bloquea acceso a datos.
|
||||
|
||||
const { error: updateError } = await adminClient
|
||||
.from('users')
|
||||
.update({ platform_role: 'guest' })
|
||||
.eq('id', userId)
|
||||
|
||||
if (updateError) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: `Error al revocar acceso: ${updateError.message}` }),
|
||||
{ status: 500, headers },
|
||||
)
|
||||
}
|
||||
|
||||
// ── 5. Revocar sesiones activas (opcional) ────────────────────────────────
|
||||
// Intenta cerrar las sesiones activas del usuario. Si el método no está disponible
|
||||
// en esta versión del cliente, el rol 'guest' ya impide el acceso via RLS.
|
||||
|
||||
try {
|
||||
await adminClient.auth.admin.signOut(userId)
|
||||
} catch {
|
||||
// No crítico: el rol ya fue cambiado. Las sesiones expirarán naturalmente.
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ success: true }),
|
||||
{ status: 200, headers },
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user