9.3 KiB
Etapa 4 — Panel de administración de InQuality
Sprint: 7
Fecha: 2026-07-06
Alcance: nuevas rutas /admin/* con guard de rol + componentes de admin. Sin cambios en migraciones, Edge Functions, WorkspacePage, ReportPage, o LibraryPage.
Contexto
Las Edge Functions invite-user y revoke-user ya están desplegadas (Etapa 2). Esta etapa construye la UI que las consume. El acceso es exclusivo para platform_admin.
ANTES de empezar: leer src/router.tsx para entender el patrón de rutas y guards existentes (beforeLoad). La estructura de archivos debe seguir el patrón de features/ ya establecido.
Nuevas rutas
/admin → redirect a /admin/organizations
/admin/organizations → lista de orgs clientes
/admin/organizations/$orgId → detalle de org (procesos + usuarios)
/admin/users → lista de todos los usuarios
Todas requieren platform_role === 'platform_admin' — si no, redirect a /.
Guard en TanStack Router (patrón beforeLoad)
// Crear layout route para /admin que incluya el guard:
beforeLoad: ({ context }) => {
const user = context.auth?.user
if (!user || user.platformRole !== 'platform_admin') {
throw redirect({ to: '/' })
}
}
Layout de admin
No crear un layout nuevo de toda la app. Usar el AppHeader existente. Las páginas de admin son pages normales con su propia estructura interna.
Estructura interna sugerida para cada page de admin:
<div className="max-w-6xl mx-auto px-6 py-8">
{/* Nav tabs entre Organizations y Users */}
<div className="flex gap-4 border-b mb-6">
<NavLink to="/admin/organizations">Organizaciones</NavLink>
<NavLink to="/admin/users">Usuarios</NavLink>
</div>
{/* Contenido de la page */}
</div>
Ícono admin en AppHeader
Agregar un ícono Shield (Lucide) visible solo cuando user.platformRole === 'platform_admin'. Posición: junto al avatar/dropdown, a la izquierda.
{user.platformRole === 'platform_admin' && (
<Button variant="ghost" size="icon" asChild>
<Link to="/admin">
<Shield className="h-5 w-5 text-muted-foreground" />
</Link>
</Button>
)}
Página Organizations (/admin/organizations)
Lista de organizaciones (clientes únicamente — is_provider = false)
Query:
const { data: orgs } = await supabase
.from('organizations')
.select(`
id, name, created_at,
processes:processes(count),
users:users(count)
`)
.eq('is_provider', false)
.order('name')
Columnas de la tabla:
| Organización | Procesos | Usuarios | Creada | Acciones |
|---|---|---|---|---|
| Banco Solar | 3 | 2 | 12 jun 2026 | Ver detalle |
Crear organización
Botón "Nueva organización" → Sheet (no modal) con:
- Campo
Nombre(requerido, trim, no vacío) - Botón "Crear" →
supabase.from('organizations').insert({ name, is_provider: false }) - Al crear: cerrar sheet + refetch lista
Detalle de organización (/admin/organizations/$orgId)
Dos secciones en tabs o acordeón:
Procesos de esta org:
const { data: processes } = await supabase
.from('processes')
.select('id, name, client, updated_at')
.eq('org_id', orgId)
.order('updated_at', { ascending: false })
Mostrar como lista simple: nombre del proceso, fecha de última actualización, link al workspace.
Botón "Asignar proceso existente" → Sheet con dropdown de procesos sin org asignada (org_id IS NULL) + botón asignar:
await supabase
.from('processes')
.update({ org_id: orgId })
.eq('id', selectedProcessId)
Usuarios de esta org:
const { data: users } = await supabase
.from('users')
.select('id, name, company, platform_role')
.eq('org_id', orgId)
.order('name')
Mostrar como lista: nombre, rol, acciones (ver Usuarios page para acciones).
Página Users (/admin/users)
Lista de usuarios
const { data: users } = await supabase
.from('users')
.select(`
id, name, company, platform_role, org_id,
organization:organizations(name, is_provider)
`)
.order('name')
Columnas:
| Nombre | Email* | Rol | Organización | Estado | Acciones |
|---|
*Note: email está en auth.users, no en public.users. Para esta etapa, omitir el email en la lista o usar name únicamente. El email se puede agregar en sprint futuro via Edge Function o join con auth.users (solo disponible con service_role).
Badges de rol con color:
platform_admin→ badge naranja (#F59845)member→ badge slateclient_editor→ badge azulclient_viewer→ badge verde claroguest→ badge gris, con indicador visual de "suspendido"
Filtro por organización
Dropdown "Filtrar por org" encima de la tabla. Opción "Todas". Al seleccionar una org, filtra con .eq('org_id', selectedOrgId).
Invitar usuario
Botón "Invitar usuario" → Sheet con campos:
Nombre completo: [__________________]
Email: [__________________]
Rol: [client_editor ▾] (opciones: client_editor, client_viewer, member)
Organización: [Banco Solar ▾] (solo si rol es client_editor o client_viewer)
Al submit → llamar Edge Function:
const { data, error } = await supabase.functions.invoke('invite-user', {
body: {
email,
name,
platform_role: rol,
org_name: selectedOrg?.name ?? '',
}
})
if (error) {
// mostrar error inline
} else {
// "Invitación enviada a {email}" — toast o inline
// cerrar sheet + refetch lista
}
Nota: la Edge Function invite-user recibe org_name (string), no org_id. Busca o crea la org internamente.
Acciones por usuario (menú de 3 puntos o dropdown)
[ Cambiar rol ] → Sheet con dropdown de roles + botón guardar
→ supabase.from('users').update({ platform_role: nuevoRol }).eq('id', userId)
[ Suspender ] → cambia platform_role a 'guest' vía Edge Function revoke-user
Texto de confirmación: "¿Suspender a {nombre}? No podrá acceder a la plataforma."
[ Reactivar ] → visible solo si platform_role === 'guest'
→ supabase.from('users').update({ platform_role: 'client_editor' }).eq('id', userId)
IMPORTANTE: al reactivar, restaurar al rol anterior. Como no lo tenemos guardado,
restaurar a 'client_editor' como default. Documentar esto como limitación.
[ Eliminar ] → confirmación con texto del nombre del usuario
→ supabase.functions.invoke('revoke-user', { body: { userId } })
Nota: la función actual baja el rol a 'guest'. Eliminar real queda para sprint futuro.
Mostrar al admin: "Usuario suspendido permanentemente (eliminación real en versión futura)".
No permitir acciones sobre el usuario propio del admin — deshabilitar las acciones si user.id === item.id.
Versión wow (requerida si costo < 40%)
- Loading skeletons en la tabla mientras carga (usar
Skeletonde shadcn) - Empty state ilustrado: cuando no hay usuarios en la org, mostrar texto + icono, no tabla vacía
- Confirmación de acciones destructivas con
AlertDialogde shadcn (nowindow.confirm) - Badge de estado en la fila del usuario invitado recientemente: "Invitación pendiente" si el usuario no tiene
nameoavatar_url(indicador de que no aceptó el invite aún) - En el sheet de invite: deshabilitar el campo "Organización" cuando el rol es
member, mostrar "InQuality" como valor fijo
Archivos a crear
src/features/admin/
├── AdminLayout.tsx ← nav tabs, guard visual
├── OrganizationsPage.tsx ← lista + crear
├── OrganizationDetailPage.tsx ← procesos + usuarios de la org
└── UsersPage.tsx ← lista + invite + acciones
Agregar en src/router.tsx las 4 rutas con beforeLoad de guard.
Criterios de validación
- Navegar a
/adminsin serplatform_adminredirige a/ platform_adminve el ícono Shield en AppHeader → navega a/admin- Lista de organizaciones muestra orgs clientes (no InQuality)
- Crear organización → aparece en la lista sin recargar la página
- Detalle de org muestra procesos y usuarios de esa org
- Asignar proceso a org → proceso aparece en el detalle
- Lista de usuarios muestra todos con badge de rol correcto
- Filtro por org funciona
- Invitar usuario llama la Edge Function y muestra confirmación
- Suspender usuario: confirmación con AlertDialog, cambio a 'guest' efectivo
- No se puede ejecutar ninguna acción sobre el propio usuario admin
- Loading skeletons visibles durante fetch inicial
npm run test→ 558+ tests verdesnpm run buildlimpio- Validación visual del director antes del OK formal
NO modificar
WorkspacePage,ReportPage,LibraryPage- Migraciones (001-015)
- Edge Functions
- Lógica de dominio, simulación, ROI
- Componentes de PDF/CSV
Limitaciones conocidas a documentar en comentarios
emailno disponible enpublic.users— la columna vive enauth.usersque requiere service_role. Mostrar solonamepor ahora.- "Eliminar" usuario usa
revoke-user(baja a 'guest'). La eliminación real requiere una Edge Functiondelete-userfutura. - "Reactivar" restaura siempre a
client_editor— no se guarda el rol previo a la suspensión.