From 8b839baf775ef4e476de0ec3861a226cd6fbb0e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcos=20Ben=C3=ADtez?= Date: Thu, 9 Jul 2026 13:18:14 -0300 Subject: [PATCH] =?UTF-8?q?refactor(router):=20guards=20AdminLayout=20+=20?= =?UTF-8?q?ImportPage=20=E2=86=92=20RequireRole=20wrapper=20(Etapa=206A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Crea RequireRole component (Approach B — router sin auth context) - AdminLayout: elimina useEffect + spinner + null check, usa - ImportPage: elimina useEffect guard + return null client_viewer, usa Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 13 +++- src/components/RequireRole.tsx | 30 ++++++++ src/features/admin/AdminLayout.tsx | 53 +++++-------- src/features/admin/UsersPage.tsx | 62 ++++++++++++---- src/features/import/ImportPage.tsx | 20 ++--- supabase/functions/delete-user/index.ts | 99 +++++++++++++++++++++++++ supabase/functions/list-users/index.ts | 74 ++++++++++++++++++ 7 files changed, 282 insertions(+), 69 deletions(-) create mode 100644 src/components/RequireRole.tsx create mode 100644 supabase/functions/delete-user/index.ts create mode 100644 supabase/functions/list-users/index.ts 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 }) +})