refactor(router): guards AdminLayout + ImportPage → RequireRole wrapper (Etapa 6A)
Some checks are pending
/ Deploy to Cloudflare Pages (push) Waiting to run
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:
13
CHANGELOG.md
13
CHANGELOG.md
@@ -7,21 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
### Added
|
### 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)
|
- 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)
|
- 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)
|
- 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
|
### Fixed
|
||||||
- ImportPage: texto de marca corregido — "Process Cost Platform · MVP Fase 0" → "InQ ROI" (Sprint 8)
|
- 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)
|
- 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)
|
- 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)
|
- 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)
|
- 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)
|
- 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)
|
- 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)
|
- 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)
|
- OrganizationDetailPage: tercer tab "Áreas" con CRUD completo — crear, renombrar, eliminar con confirmación (Sprint 8)
|
||||||
|
|||||||
30
src/components/RequireRole.tsx
Normal file
30
src/components/RequireRole.tsx
Normal file
@@ -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}</>
|
||||||
|
}
|
||||||
@@ -1,33 +1,13 @@
|
|||||||
import { useEffect } from 'react'
|
import { Outlet, Link, useRouterState } from '@tanstack/react-router'
|
||||||
import { Outlet, Link, useNavigate, useRouterState } from '@tanstack/react-router'
|
|
||||||
import { Loader2 } from 'lucide-react'
|
|
||||||
import { useAuth } from '@/auth/AuthContext'
|
|
||||||
import { AppHeader } from '@/components/AppHeader'
|
import { AppHeader } from '@/components/AppHeader'
|
||||||
|
import { RequireRole } from '@/components/RequireRole'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
export function AdminLayout() {
|
export function AdminLayout() {
|
||||||
const { user, loading } = useAuth()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
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 (
|
return (
|
||||||
|
<RequireRole roles={['platform_admin']}>
|
||||||
<div className="min-h-screen flex flex-col bg-slate-50">
|
<div className="min-h-screen flex flex-col bg-slate-50">
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
<div className="flex-1 max-w-6xl mx-auto w-full px-6 py-8">
|
<div className="flex-1 max-w-6xl mx-auto w-full px-6 py-8">
|
||||||
@@ -42,6 +22,7 @@ export function AdminLayout() {
|
|||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</RequireRole>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Limitaciones conocidas documentadas:
|
* Limitaciones conocidas:
|
||||||
* 1. email no está en public.users (vive en auth.users, requiere service_role) → solo se muestra name.
|
* - "Reactivar" restaura siempre a 'client_editor' — no se guarda el rol previo a la suspensión.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
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 { supabase } from '@/lib/supabase'
|
||||||
import { useAuth } from '@/auth/AuthContext'
|
import { useAuth } from '@/auth/AuthContext'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -34,6 +32,7 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { useToast } from '@/components/ui/use-toast'
|
||||||
import { RoleBadge } from './RoleBadge'
|
import { RoleBadge } from './RoleBadge'
|
||||||
|
|
||||||
interface AdminUser {
|
interface AdminUser {
|
||||||
@@ -44,6 +43,7 @@ interface AdminUser {
|
|||||||
org_id: string | null
|
org_id: string | null
|
||||||
avatar_url: string | null
|
avatar_url: string | null
|
||||||
organization: { name: string; is_provider: boolean } | null
|
organization: { name: string; is_provider: boolean } | null
|
||||||
|
email?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OrgOption {
|
interface OrgOption {
|
||||||
@@ -116,6 +116,7 @@ const CHANGE_ROLES = ['platform_admin', 'member', 'client_editor', 'client_viewe
|
|||||||
|
|
||||||
export function UsersPage() {
|
export function UsersPage() {
|
||||||
const { user: adminUser } = useAuth()
|
const { user: adminUser } = useAuth()
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
const [users, setUsers] = useState<AdminUser[]>([])
|
const [users, setUsers] = useState<AdminUser[]>([])
|
||||||
const [orgs, setOrgs] = useState<OrgOption[]>([])
|
const [orgs, setOrgs] = useState<OrgOption[]>([])
|
||||||
@@ -155,8 +156,25 @@ export function UsersPage() {
|
|||||||
const finalQuery = filterOrgId !== 'all'
|
const finalQuery = filterOrgId !== 'all'
|
||||||
? query.eq('org_id', filterOrgId)
|
? query.eq('org_id', filterOrgId)
|
||||||
: query
|
: 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)
|
setLoadingUsers(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,16 +278,24 @@ export function UsersPage() {
|
|||||||
void fetchUsers()
|
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 () => {
|
const handleDelete = async () => {
|
||||||
if (!deleteTarget) return
|
if (!deleteTarget) return
|
||||||
setDeleting(true)
|
setDeleting(true)
|
||||||
const { error } = await supabase.functions.invoke('revoke-user', {
|
const { error } = await supabase.functions.invoke('delete-user', {
|
||||||
body: { userId: deleteTarget.id },
|
body: { userId: deleteTarget.id },
|
||||||
})
|
})
|
||||||
if (error) {
|
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)
|
setDeleteTarget(null)
|
||||||
setDeleting(false)
|
setDeleting(false)
|
||||||
@@ -334,6 +360,7 @@ export function UsersPage() {
|
|||||||
<thead className="bg-slate-50 border-b border-slate-200">
|
<thead className="bg-slate-50 border-b border-slate-200">
|
||||||
<tr>
|
<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">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">Rol</th>
|
||||||
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Organización</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" />
|
<th className="py-2.5 px-4" />
|
||||||
@@ -363,6 +390,9 @@ export function UsersPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="py-3 px-4 text-slate-500 text-xs font-mono">
|
||||||
|
{u.email ?? '—'}
|
||||||
|
</td>
|
||||||
<td className="py-3 px-4">
|
<td className="py-3 px-4">
|
||||||
<RoleBadge role={u.platform_role} />
|
<RoleBadge role={u.platform_role} />
|
||||||
</td>
|
</td>
|
||||||
@@ -403,7 +433,8 @@ export function UsersPage() {
|
|||||||
className="text-destructive focus:text-destructive"
|
className="text-destructive focus:text-destructive"
|
||||||
onClick={() => setDeleteTarget(u)}
|
onClick={() => setDeleteTarget(u)}
|
||||||
>
|
>
|
||||||
Eliminar
|
<Trash2 className="h-3.5 w-3.5 mr-2" />
|
||||||
|
Eliminar permanentemente
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -537,13 +568,13 @@ export function UsersPage() {
|
|||||||
destructive
|
destructive
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ─── ConfirmDialog: Eliminar ─────────────────────────────────────── */}
|
{/* ─── ConfirmDialog: Eliminar permanentemente ────────────────────── */}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={!!deleteTarget}
|
open={!!deleteTarget}
|
||||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||||
title={`¿Eliminar a ${deleteTarget?.name ?? 'este usuario'}?`}
|
title={`¿Eliminar permanentemente 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.)"
|
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"
|
confirmLabel="Eliminar permanentemente"
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
loading={deleting}
|
loading={deleting}
|
||||||
destructive
|
destructive
|
||||||
@@ -580,6 +611,7 @@ function TableSkeleton({ rows }: { rows: number }) {
|
|||||||
{Array.from({ length: rows }).map((_, i) => (
|
{Array.from({ length: rows }).map((_, i) => (
|
||||||
<div key={i} className="flex gap-4 px-4 py-3 bg-white items-center">
|
<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 flex-1" />
|
||||||
|
<Skeleton className="h-4 w-40" />
|
||||||
<Skeleton className="h-5 w-20 rounded" />
|
<Skeleton className="h-5 w-20 rounded" />
|
||||||
<Skeleton className="h-4 w-28" />
|
<Skeleton className="h-4 w-28" />
|
||||||
<Skeleton className="h-6 w-6 rounded" />
|
<Skeleton className="h-6 w-6 rounded" />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useNavigate, useSearch } from '@tanstack/react-router'
|
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 { UploadCloud, FileText, Loader2, FlaskConical } from 'lucide-react'
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
@@ -13,6 +13,7 @@ import { useToast } from '@/components/ui/use-toast'
|
|||||||
import { useAuth } from '@/auth/AuthContext'
|
import { useAuth } from '@/auth/AuthContext'
|
||||||
import { AppFooter } from '@/components/AppFooter'
|
import { AppFooter } from '@/components/AppFooter'
|
||||||
import { AppHeader } from '@/components/AppHeader'
|
import { AppHeader } from '@/components/AppHeader'
|
||||||
|
import { RequireRole } from '@/components/RequireRole'
|
||||||
|
|
||||||
const SAMPLE_PROCESSES = [
|
const SAMPLE_PROCESSES = [
|
||||||
{
|
{
|
||||||
@@ -38,22 +39,11 @@ const SAMPLE_PROCESSES = [
|
|||||||
export function ImportPage() {
|
export function ImportPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const { user, isProfileLoaded } = useAuth()
|
const { user } = useAuth()
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
const [isProcessing, setIsProcessing] = useState(false)
|
const [isProcessing, setIsProcessing] = useState(false)
|
||||||
const [loadingSampleId, setLoadingSampleId] = useState<string | null>(null)
|
const [loadingSampleId, setLoadingSampleId] = useState<string | null>(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
|
// groupId viene de la URL cuando el usuario importa desde dentro de un grupo
|
||||||
const search = useSearch({ from: '/import' })
|
const search = useSearch({ from: '/import' })
|
||||||
const targetGroupId = (search as { groupId?: string }).groupId ?? null
|
const targetGroupId = (search as { groupId?: string }).groupId ?? null
|
||||||
@@ -203,9 +193,8 @@ export function ImportPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user?.platformRole === 'client_viewer') return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<RequireRole roles={['platform_admin', 'member', 'client_editor']}>
|
||||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex flex-col">
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex flex-col">
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
<div className="flex-1 flex flex-col items-center justify-center p-6">
|
<div className="flex-1 flex flex-col items-center justify-center p-6">
|
||||||
@@ -317,5 +306,6 @@ export function ImportPage() {
|
|||||||
<AppFooter />
|
<AppFooter />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</RequireRole>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
99
supabase/functions/delete-user/index.ts
Normal file
99
supabase/functions/delete-user/index.ts
Normal file
@@ -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 })
|
||||||
|
})
|
||||||
74
supabase/functions/list-users/index.ts
Normal file
74
supabase/functions/list-users/index.ts
Normal file
@@ -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 })
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user