Platform admin invita usuarios de empresa desde /admin/usuarios. inviteUserByEmail + insert inmediato en user_roles (org_admin). Rollback explícito si falla el insert. /auth/confirm maneja el link de invitación con PASSWORD_RECOVERY event. Middleware no requirió cambios — /auth/confirm ya queda fuera del matcher de rutas protegidas. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
107 lines
4.0 KiB
TypeScript
107 lines
4.0 KiB
TypeScript
'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<InviteUserResult> {
|
|
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 }
|
|
}
|