diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0ff3b55..7d914d5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,21 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
+- `RequireRole` componente wrapper para guards de rol declarativos (Approach B — sin router context) (Sprint 8)
+- Edge Function `delete-user`: hard delete permanente de usuario en auth.users + public.users (Sprint 8)
+- Edge Function `list-users`: lista usuarios con email desde auth.users vía service_role (Sprint 8)
+- UsersPage: columna "Email" entre Nombre y Rol (via Edge Function list-users) (Sprint 8)
- Migración 018: policy INSERT en `processes` extendida para `client_editor` en su org (Sprint 8)
- ImportPage: `client_editor` puede importar BPMN; proceso se crea con `orgId` de su cuenta (Sprint 8)
- LibraryPage: botón "Importar BPMN" visible para `client_editor`; empty state con call-to-action de importar (Sprint 8)
+### Changed
+- AdminLayout: guard useEffect + spinner → RequireRole wrapper (sin flash de contenido protegido) (Sprint 8)
+- ImportPage: guard useEffect → RequireRole wrapper (Sprint 8)
+- UsersPage: "Eliminar" (soft delete / revoke-user) → "Eliminar permanentemente" (hard delete / delete-user) (Sprint 8)
+
### Fixed
- ImportPage: texto de marca corregido — "Process Cost Platform · MVP Fase 0" → "InQ ROI" (Sprint 8)
- ImportPage: párrafo "Sin backend · Sin registro · Datos guardados localmente" eliminado (obsoleto desde Sprint 4) (Sprint 8)
-
-### Fixed
- OrganizationsPage: InQuality visible en lista con badge naranja — quitar filtro is_provider (Sprint 8)
- WorkspacePage: back button pasa ?org=orgId para scroll contextual al volver a biblioteca (Sprint 8)
- LibraryPage: scroll automático a sección de org al recibir ?org param desde workspace (Sprint 8)
- router.tsx: redirect de settingsRoute pasa search: { org: undefined } para compatibilidad con validateSearch (Sprint 8)
-### Added
+### Added (Etapas 1-4)
- GlobalSettingsPanel: selector de área reemplaza campo libre "Cliente" (Sprint 8)
- WorkspacePage topbar: display de `areaName` de solo lectura reemplaza edición inline de cliente (Sprint 8)
- OrganizationDetailPage: tercer tab "Áreas" con CRUD completo — crear, renombrar, eliminar con confirmación (Sprint 8)
diff --git a/src/components/RequireRole.tsx b/src/components/RequireRole.tsx
new file mode 100644
index 0000000..79cf9bd
--- /dev/null
+++ b/src/components/RequireRole.tsx
@@ -0,0 +1,30 @@
+import { useEffect } from 'react'
+import { useNavigate } from '@tanstack/react-router'
+import { useAuth } from '@/auth/AuthContext'
+
+export function RequireRole({
+ roles,
+ redirectTo = '/',
+ children,
+}: {
+ roles: string[]
+ redirectTo?: '/' | '/library'
+ children: React.ReactNode
+}) {
+ const { user, isProfileLoaded } = useAuth()
+ const navigate = useNavigate()
+
+ useEffect(() => {
+ if (!isProfileLoaded) return
+ if (!user || !roles.includes(user.platformRole)) {
+ void navigate({ to: redirectTo })
+ }
+ // roles y redirectTo son constantes en cada call site — excluidos intencionalmente
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [user?.platformRole, isProfileLoaded, navigate])
+
+ if (!isProfileLoaded || !user) return null
+ if (!roles.includes(user.platformRole)) return null
+
+ return <>{children}>
+}
diff --git a/src/features/admin/AdminLayout.tsx b/src/features/admin/AdminLayout.tsx
index f434336..3286cb6 100644
--- a/src/features/admin/AdminLayout.tsx
+++ b/src/features/admin/AdminLayout.tsx
@@ -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 (
-
-
-
- )
- }
-
- if (!user || user.platformRole !== 'platform_admin') return null
-
return (
-
-
-
-
-
+
+
+
+
+
+
+
-
+
)
}
diff --git a/src/features/admin/UsersPage.tsx b/src/features/admin/UsersPage.tsx
index 1485447..da3c77b 100644
--- a/src/features/admin/UsersPage.tsx
+++ b/src/features/admin/UsersPage.tsx
@@ -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
([])
const [orgs, setOrgs] = useState([])
@@ -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()
+ 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() {
| Nombre |
+ Email |
Rol |
Organización |
|
@@ -363,6 +390,9 @@ export function UsersPage() {
)}
+
+ {u.email ?? '—'}
+ |
|
@@ -403,7 +433,8 @@ export function UsersPage() {
className="text-destructive focus:text-destructive"
onClick={() => setDeleteTarget(u)}
>
- Eliminar
+
+ Eliminar permanentemente
@@ -537,13 +568,13 @@ export function UsersPage() {
destructive
/>
- {/* ─── ConfirmDialog: Eliminar ─────────────────────────────────────── */}
+ {/* ─── ConfirmDialog: Eliminar permanentemente ────────────────────── */}
!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) => (
+
diff --git a/src/features/import/ImportPage.tsx b/src/features/import/ImportPage.tsx
index 6c315e4..cd69c4f 100644
--- a/src/features/import/ImportPage.tsx
+++ b/src/features/import/ImportPage.tsx
@@ -1,5 +1,5 @@
import { useNavigate, useSearch } from '@tanstack/react-router'
-import { useCallback, useEffect, useState } from 'react'
+import { useCallback, useState } from 'react'
import { UploadCloud, FileText, Loader2, FlaskConical } from 'lucide-react'
import { v4 as uuidv4 } from 'uuid'
import { Card, CardContent } from '@/components/ui/card'
@@ -13,6 +13,7 @@ import { useToast } from '@/components/ui/use-toast'
import { useAuth } from '@/auth/AuthContext'
import { AppFooter } from '@/components/AppFooter'
import { AppHeader } from '@/components/AppHeader'
+import { RequireRole } from '@/components/RequireRole'
const SAMPLE_PROCESSES = [
{
@@ -38,22 +39,11 @@ const SAMPLE_PROCESSES = [
export function ImportPage() {
const navigate = useNavigate()
const { toast } = useToast()
- const { user, isProfileLoaded } = useAuth()
+ const { user } = useAuth()
const [isDragging, setIsDragging] = useState(false)
const [isProcessing, setIsProcessing] = useState(false)
const [loadingSampleId, setLoadingSampleId] = useState
(null)
- useEffect(() => {
- // Esperar a que el perfil esté cargado antes de evaluar el rol.
- // Sin esto, buildAuthUser inicializa con 'guest' para emails externos
- // y el guard redirige antes de que syncProfileFromDB cargue el rol real.
- if (!user || !isProfileLoaded) return
- const allowed = ['platform_admin', 'member', 'client_editor']
- if (!allowed.includes(user.platformRole)) {
- void navigate({ to: '/' })
- }
- }, [user, isProfileLoaded, navigate])
-
// groupId viene de la URL cuando el usuario importa desde dentro de un grupo
const search = useSearch({ from: '/import' })
const targetGroupId = (search as { groupId?: string }).groupId ?? null
@@ -203,9 +193,8 @@ export function ImportPage() {
}
}
- if (user?.platformRole === 'client_viewer') return null
-
return (
+
@@ -317,5 +306,6 @@ export function ImportPage() {
+
)
}
diff --git a/supabase/functions/delete-user/index.ts b/supabase/functions/delete-user/index.ts
new file mode 100644
index 0000000..ccffa67
--- /dev/null
+++ b/supabase/functions/delete-user/index.ts
@@ -0,0 +1,99 @@
+/**
+ * Edge Function: delete-user
+ * Propósito: eliminación permanente de un usuario (hard delete de auth.users + public.users).
+ * Solo accesible por platform_admin. No reversible.
+ * Variables de entorno: SUPABASE_URL y SUPABASE_SERVICE_ROLE_KEY (auto-disponibles en Edge Functions).
+ * Deploy: supabase functions deploy delete-user
+ */
+
+import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
+
+interface DeleteUserRequest {
+ userId: string
+}
+
+Deno.serve(async (req: Request) => {
+ const headers = {
+ 'Content-Type': 'application/json',
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Methods': 'POST, OPTIONS',
+ 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
+ }
+
+ if (req.method === 'OPTIONS') {
+ return new Response('ok', { headers })
+ }
+
+ if (req.method !== 'POST') {
+ return new Response(JSON.stringify({ error: 'Método no permitido' }), { status: 405, headers })
+ }
+
+ const adminClient = createClient(
+ Deno.env.get('SUPABASE_URL')!,
+ Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
+ )
+
+ // ── 1. Autenticar al llamador ──────────────────────────────────────────────
+
+ const callerToken = req.headers.get('Authorization')?.replace('Bearer ', '')
+ if (!callerToken) {
+ return new Response(JSON.stringify({ error: 'Authorization header requerido' }), { status: 401, headers })
+ }
+
+ const { data: { user: caller }, error: authError } = await adminClient.auth.getUser(callerToken)
+ if (authError || !caller) {
+ return new Response(JSON.stringify({ error: 'Token inválido o expirado' }), { status: 401, headers })
+ }
+
+ const { data: callerProfile, error: profileError } = await adminClient
+ .from('users')
+ .select('platform_role')
+ .eq('id', caller.id)
+ .single()
+
+ if (profileError || callerProfile?.platform_role !== 'platform_admin') {
+ return new Response(
+ JSON.stringify({ error: 'Forbidden: solo platform_admin puede eliminar usuarios' }),
+ { status: 403, headers },
+ )
+ }
+
+ // ── 2. Parsear body ────────────────────────────────────────────────────────
+
+ let body: DeleteUserRequest
+ try {
+ body = await req.json() as DeleteUserRequest
+ } catch {
+ return new Response(JSON.stringify({ error: 'Body JSON inválido' }), { status: 400, headers })
+ }
+
+ const { userId } = body
+ if (!userId?.trim()) {
+ return new Response(JSON.stringify({ error: 'El campo userId es requerido' }), { status: 400, headers })
+ }
+
+ // ── 3. Prevenir auto-eliminación ──────────────────────────────────────────
+
+ if (userId === caller.id) {
+ return new Response(
+ JSON.stringify({ error: 'No puedes eliminar tu propia cuenta' }),
+ { status: 400, headers },
+ )
+ }
+
+ // ── 4. Eliminar de public.users primero (CASCADE, pero por seguridad explícita) ─
+
+ await adminClient.from('users').delete().eq('id', userId)
+
+ // ── 5. Hard delete de auth.users ─────────────────────────────────────────
+
+ const { error: deleteError } = await adminClient.auth.admin.deleteUser(userId)
+ if (deleteError) {
+ return new Response(
+ JSON.stringify({ error: `Error al eliminar usuario: ${deleteError.message}` }),
+ { status: 500, headers },
+ )
+ }
+
+ return new Response(JSON.stringify({ success: true }), { status: 200, headers })
+})
diff --git a/supabase/functions/list-users/index.ts b/supabase/functions/list-users/index.ts
new file mode 100644
index 0000000..852ca94
--- /dev/null
+++ b/supabase/functions/list-users/index.ts
@@ -0,0 +1,74 @@
+/**
+ * Edge Function: list-users
+ * Propósito: devuelve usuarios de public.users con email de auth.users (requiere service_role).
+ * Solo accesible por platform_admin.
+ * Deploy: supabase functions deploy list-users
+ */
+
+import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
+
+Deno.serve(async (req: Request) => {
+ const headers = {
+ 'Content-Type': 'application/json',
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
+ 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
+ }
+
+ if (req.method === 'OPTIONS') {
+ return new Response('ok', { headers })
+ }
+
+ const adminClient = createClient(
+ Deno.env.get('SUPABASE_URL')!,
+ Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
+ )
+
+ // ── 1. Autenticar al llamador ──────────────────────────────────────────────
+
+ const callerToken = req.headers.get('Authorization')?.replace('Bearer ', '')
+ if (!callerToken) {
+ return new Response(JSON.stringify({ error: 'Authorization header requerido' }), { status: 401, headers })
+ }
+
+ const { data: { user: caller }, error: authError } = await adminClient.auth.getUser(callerToken)
+ if (authError || !caller) {
+ return new Response(JSON.stringify({ error: 'Token inválido o expirado' }), { status: 401, headers })
+ }
+
+ const { data: callerProfile, error: profileError } = await adminClient
+ .from('users')
+ .select('platform_role')
+ .eq('id', caller.id)
+ .single()
+
+ if (profileError || callerProfile?.platform_role !== 'platform_admin') {
+ return new Response(
+ JSON.stringify({ error: 'Forbidden: solo platform_admin puede listar usuarios con email' }),
+ { status: 403, headers },
+ )
+ }
+
+ // ── 2. Obtener emails de auth.users ───────────────────────────────────────
+
+ const { data: authData, error: listError } = await adminClient.auth.admin.listUsers({
+ page: 1,
+ perPage: 1000,
+ })
+
+ if (listError) {
+ return new Response(
+ JSON.stringify({ error: `Error al listar usuarios: ${listError.message}` }),
+ { status: 500, headers },
+ )
+ }
+
+ // ── 3. Devolver mapa id → email ───────────────────────────────────────────
+
+ const result = (authData?.users ?? []).map((u) => ({
+ id: u.id,
+ email: u.email ?? null,
+ }))
+
+ return new Response(JSON.stringify(result), { status: 200, headers })
+})