feat(admin): onboarding por invitación — /admin/usuarios + /auth/confirm
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>
This commit is contained in:
106
app/actions/invite-user.ts
Normal file
106
app/actions/invite-user.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
'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 }
|
||||
}
|
||||
Reference in New Issue
Block a user