Files
markosbenitez 9461ee1e2e feat(admin): menú de navegación compartido — dashboard, respuestas, usuarios
AdminNavBar reemplaza los links sueltos y duplicados entre páginas
(cada una solo enlazaba a una de las otras dos). Ahora las tres
pantallas detrás del login (/dashboard, /admin/responses,
/admin/usuarios) comparten la misma barra con las 3 secciones +
cerrar sesión, sin importar por dónde se entre primero.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 12:43:28 -03:00

196 lines
6.1 KiB
TypeScript

import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
import { getSupabaseAdmin } from '@/lib/supabase'
import { formatDate } from '../responses/admin-format'
import { AdminNavBar } from '@/components/admin-nav-bar'
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 (
<>
<AdminNavBar active="usuarios" />
<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>
</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>
</>
)
}