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:
196
app/admin/usuarios/page.tsx
Normal file
196
app/admin/usuarios/page.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { createServerClient } from '@supabase/ssr'
|
||||
import { cookies } from 'next/headers'
|
||||
import { getSupabaseAdmin } from '@/lib/supabase'
|
||||
import { formatDate } from '../responses/admin-format'
|
||||
import { SignOutButton } from '../sign-out-button'
|
||||
import { InviteForm } from './invite-form'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
interface OrgOption {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface RoleRow {
|
||||
user_id: string
|
||||
app_role: string
|
||||
org_id: string | null
|
||||
organizations: { name: string } | null
|
||||
}
|
||||
|
||||
interface UserDisplayRow {
|
||||
userId: string
|
||||
email: string
|
||||
orgName: string
|
||||
role: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
async function getSupabaseSession() {
|
||||
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 Component — no puede mutar cookies, se ignora
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// auth.users no está expuesto vía PostgREST (no es schema público) — no hay
|
||||
// forma de hacer un .from('auth.users') ni un join SQL directo desde
|
||||
// supabase-js. listUsers() es la API admin soportada para resolver el email;
|
||||
// pagina hasta agotar resultados (tope defensivo de 2000 usuarios).
|
||||
async function fetchAllUsersMap(admin: ReturnType<typeof getSupabaseAdmin>) {
|
||||
const map = new Map<string, { email: string; createdAt: string }>()
|
||||
const perPage = 200
|
||||
for (let page = 1; page <= 10; page++) {
|
||||
const { data, error } = await admin.auth.admin.listUsers({ page, perPage })
|
||||
if (error || !data) break
|
||||
data.users.forEach((u) => {
|
||||
map.set(u.id, { email: u.email ?? '—', createdAt: u.created_at })
|
||||
})
|
||||
if (data.users.length < perPage) break
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
export default async function AdminUsuariosPage() {
|
||||
const supabase = await getSupabaseSession()
|
||||
|
||||
// 1. Identificar a quien llama — defensivo, el middleware ya debería haber
|
||||
// redirigido a /login si no hay sesión.
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="admin-page" role="main">
|
||||
<h1 className="admin-title">Acceso restringido</h1>
|
||||
<p className="admin-error">No se encontró una sesión activa.</p>
|
||||
<a href="/login" className="admin-back-link">← Iniciar sesión</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 2. Verificar platform_admin leyendo la fila REAL en user_roles — esta
|
||||
// página usa getSupabaseAdmin() más abajo (bypasa RLS por completo), así
|
||||
// que el gate de autorización tiene que ir acá, no delegarse a las policies.
|
||||
const { data: callerRole } = await supabase
|
||||
.from('user_roles')
|
||||
.select('app_role')
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
|
||||
if (callerRole?.app_role !== 'platform_admin') {
|
||||
return (
|
||||
<div className="admin-page" role="main">
|
||||
<h1 className="admin-title">Acceso restringido</h1>
|
||||
<p className="admin-error">
|
||||
Esta sección es exclusiva para administradores de plataforma.
|
||||
</p>
|
||||
<a href="/admin/responses" className="admin-back-link">← Volver</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 3. Datos — admin client para todo a partir de acá. La autorización ya
|
||||
// quedó probada en el paso 2 con una lectura directa a la DB; usar admin
|
||||
// para el resto evita depender de policies/claims del JWT que podrían
|
||||
// haber quedado stale.
|
||||
const admin = getSupabaseAdmin()
|
||||
|
||||
const [{ data: orgsData, error: orgsError }, { data: rolesData, error: rolesError }] =
|
||||
await Promise.all([
|
||||
admin.from('organizations').select('id, name').order('name'),
|
||||
admin.from('user_roles').select('user_id, app_role, org_id, organizations(name)'),
|
||||
])
|
||||
|
||||
if (orgsError || rolesError) {
|
||||
return (
|
||||
<div className="admin-page" role="main">
|
||||
<h1 className="admin-title">Usuarios</h1>
|
||||
<p className="admin-error">No fue posible conectarse a la base de datos.</p>
|
||||
<pre className="admin-error-detail">
|
||||
{(orgsError ?? rolesError)?.message}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const organizations = (orgsData ?? []) as OrgOption[]
|
||||
const roles = (rolesData ?? []) as unknown as RoleRow[]
|
||||
const usersMap = await fetchAllUsersMap(admin)
|
||||
|
||||
const userRows: UserDisplayRow[] = roles.map((r) => {
|
||||
const u = usersMap.get(r.user_id)
|
||||
return {
|
||||
userId: r.user_id,
|
||||
email: u?.email ?? '—',
|
||||
orgName: r.organizations?.name ?? '—',
|
||||
role: r.app_role,
|
||||
createdAt: u?.createdAt ? formatDate(u.createdAt) : '—',
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="admin-page" role="main">
|
||||
<header className="admin-header">
|
||||
<div>
|
||||
<h1 className="admin-title">Usuarios</h1>
|
||||
<p className="admin-subtitle">{userRows.length} usuarios registrados</p>
|
||||
</div>
|
||||
<div className="admin-header-actions">
|
||||
<a href="/admin/responses" className="admin-refresh">Respuestas</a>
|
||||
<SignOutButton />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="admin-detail-section">
|
||||
<h2 className="admin-detail-section-title">Invitar usuario</h2>
|
||||
<InviteForm organizations={organizations} />
|
||||
</section>
|
||||
|
||||
<section className="admin-detail-section">
|
||||
<h2 className="admin-detail-section-title">Usuarios existentes</h2>
|
||||
<div className="admin-table-wrapper">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Organización</th>
|
||||
<th>Rol</th>
|
||||
<th>Fecha de creación</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{userRows.map((row) => (
|
||||
<tr key={row.userId}>
|
||||
<td>{row.email}</td>
|
||||
<td>{row.orgName}</td>
|
||||
<td>{row.role}</td>
|
||||
<td>{row.createdAt}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{userRows.length === 0 && (
|
||||
<p className="admin-empty">Todavía no hay usuarios invitados.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user