refactor(router): guards AdminLayout + ImportPage → RequireRole wrapper (Etapa 6A)
Some checks are pending
/ Deploy to Cloudflare Pages (push) Waiting to run

- Crea RequireRole component (Approach B — router sin auth context)
- AdminLayout: elimina useEffect + spinner + null check, usa <RequireRole roles={['platform_admin']}>
- ImportPage: elimina useEffect guard + return null client_viewer, usa <RequireRole roles={['platform_admin','member','client_editor']}>

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 13:18:14 -03:00
parent 1032944f8b
commit 8b839baf77
7 changed files with 282 additions and 69 deletions

View File

@@ -1,47 +1,28 @@
import { useEffect } from 'react'
import { Outlet, Link, useNavigate, useRouterState } from '@tanstack/react-router'
import { Loader2 } from 'lucide-react'
import { useAuth } from '@/auth/AuthContext'
import { Outlet, Link, useRouterState } from '@tanstack/react-router'
import { AppHeader } from '@/components/AppHeader'
import { RequireRole } from '@/components/RequireRole'
import { cn } from '@/lib/utils'
export function AdminLayout() {
const { user, loading } = useAuth()
const navigate = useNavigate()
const pathname = useRouterState({ select: (s) => s.location.pathname })
useEffect(() => {
if (loading) return
if (!user || user.platformRole !== 'platform_admin') {
void navigate({ to: '/' })
}
}, [user, loading, navigate])
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin" style={{ color: '#F59845' }} />
</div>
)
}
if (!user || user.platformRole !== 'platform_admin') return null
return (
<div className="min-h-screen flex flex-col bg-slate-50">
<AppHeader />
<div className="flex-1 max-w-6xl mx-auto w-full px-6 py-8">
<nav className="flex gap-1 border-b border-slate-200 mb-6">
<AdminNavTab to="/admin/organizations" active={pathname.startsWith('/admin/organizations')}>
Organizaciones
</AdminNavTab>
<AdminNavTab to="/admin/users" active={pathname.startsWith('/admin/users')}>
Usuarios
</AdminNavTab>
</nav>
<Outlet />
<RequireRole roles={['platform_admin']}>
<div className="min-h-screen flex flex-col bg-slate-50">
<AppHeader />
<div className="flex-1 max-w-6xl mx-auto w-full px-6 py-8">
<nav className="flex gap-1 border-b border-slate-200 mb-6">
<AdminNavTab to="/admin/organizations" active={pathname.startsWith('/admin/organizations')}>
Organizaciones
</AdminNavTab>
<AdminNavTab to="/admin/users" active={pathname.startsWith('/admin/users')}>
Usuarios
</AdminNavTab>
</nav>
<Outlet />
</div>
</div>
</div>
</RequireRole>
)
}

View File

@@ -1,12 +1,10 @@
/**
* Limitaciones conocidas documentadas:
* 1. email no está en public.users (vive en auth.users, requiere service_role) → solo se muestra name.
* 2. "Eliminar" llama revoke-user que baja platform_role a 'guest'. Eliminación real requiere Edge Function futura.
* 3. "Reactivar" restaura siempre a 'client_editor' — no se guarda el rol previo a la suspensión.
* Limitaciones conocidas:
* - "Reactivar" restaura siempre a 'client_editor' — no se guarda el rol previo a la suspensión.
*/
import { useEffect, useState } from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { UserX, UserPlus, MoreVertical } from 'lucide-react'
import { UserX, UserPlus, MoreVertical, Trash2 } from 'lucide-react'
import { supabase } from '@/lib/supabase'
import { useAuth } from '@/auth/AuthContext'
import { Button } from '@/components/ui/button'
@@ -34,6 +32,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useToast } from '@/components/ui/use-toast'
import { RoleBadge } from './RoleBadge'
interface AdminUser {
@@ -44,6 +43,7 @@ interface AdminUser {
org_id: string | null
avatar_url: string | null
organization: { name: string; is_provider: boolean } | null
email?: string | null
}
interface OrgOption {
@@ -116,6 +116,7 @@ const CHANGE_ROLES = ['platform_admin', 'member', 'client_editor', 'client_viewe
export function UsersPage() {
const { user: adminUser } = useAuth()
const { toast } = useToast()
const [users, setUsers] = useState<AdminUser[]>([])
const [orgs, setOrgs] = useState<OrgOption[]>([])
@@ -155,8 +156,25 @@ export function UsersPage() {
const finalQuery = filterOrgId !== 'all'
? query.eq('org_id', filterOrgId)
: query
const { data } = await finalQuery
setUsers((data as AdminUser[] | null) ?? [])
const [{ data }, { data: emailData }] = await Promise.all([
finalQuery,
supabase.functions.invoke('list-users'),
])
const emailMap = new Map<string, string | null>()
if (emailData) {
for (const u of (emailData as Array<{ id: string; email: string | null }>) ?? []) {
emailMap.set(u.id, u.email ?? null)
}
}
const usersWithEmail = ((data as AdminUser[] | null) ?? []).map((u) => ({
...u,
email: emailMap.get(u.id) ?? null,
}))
setUsers(usersWithEmail)
setLoadingUsers(false)
}
@@ -260,16 +278,24 @@ export function UsersPage() {
void fetchUsers()
}
// ── Delete (también usa revoke-user — baja a guest, eliminación real futura)
// ── Hard delete vía Edge Function delete-user ────────────────────────────
const handleDelete = async () => {
if (!deleteTarget) return
setDeleting(true)
const { error } = await supabase.functions.invoke('revoke-user', {
const { error } = await supabase.functions.invoke('delete-user', {
body: { userId: deleteTarget.id },
})
if (error) {
console.error('Error al eliminar usuario:', error)
toast({
title: 'Error al eliminar usuario',
description: typeof error === 'object' && 'message' in error
? String(error.message)
: 'Ocurrió un error inesperado.',
variant: 'destructive',
})
} else {
toast({ title: 'Usuario eliminado permanentemente' })
}
setDeleteTarget(null)
setDeleting(false)
@@ -334,6 +360,7 @@ export function UsersPage() {
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Nombre</th>
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Email</th>
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Rol</th>
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Organización</th>
<th className="py-2.5 px-4" />
@@ -363,6 +390,9 @@ export function UsersPage() {
)}
</div>
</td>
<td className="py-3 px-4 text-slate-500 text-xs font-mono">
{u.email ?? '—'}
</td>
<td className="py-3 px-4">
<RoleBadge role={u.platform_role} />
</td>
@@ -403,7 +433,8 @@ export function UsersPage() {
className="text-destructive focus:text-destructive"
onClick={() => setDeleteTarget(u)}
>
Eliminar
<Trash2 className="h-3.5 w-3.5 mr-2" />
Eliminar permanentemente
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -537,13 +568,13 @@ export function UsersPage() {
destructive
/>
{/* ─── ConfirmDialog: Eliminar ─────────────────────────────────────── */}
{/* ─── ConfirmDialog: Eliminar permanentemente ────────────────────── */}
<ConfirmDialog
open={!!deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(null)}
title={`¿Eliminar a ${deleteTarget?.name ?? 'este usuario'}?`}
description="El usuario quedará suspendido permanentemente. (Nota: la eliminación real de la cuenta estará disponible en una versión futura.)"
confirmLabel="Eliminar"
title={`¿Eliminar permanentemente a ${deleteTarget?.name ?? 'este usuario'}?`}
description="Esta acción no puede deshacerse. El usuario perderá acceso inmediatamente y sus datos de autenticación serán eliminados de forma permanente."
confirmLabel="Eliminar permanentemente"
onConfirm={handleDelete}
loading={deleting}
destructive
@@ -580,6 +611,7 @@ function TableSkeleton({ rows }: { rows: number }) {
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="flex gap-4 px-4 py-3 bg-white items-center">
<Skeleton className="h-4 flex-1" />
<Skeleton className="h-4 w-40" />
<Skeleton className="h-5 w-20 rounded" />
<Skeleton className="h-4 w-28" />
<Skeleton className="h-6 w-6 rounded" />