This commit is contained in:
70
src/features/admin/AdminLayout.tsx
Normal file
70
src/features/admin/AdminLayout.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useEffect } from 'react'
|
||||
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 { 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 />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AdminNavTab({
|
||||
to,
|
||||
active,
|
||||
children,
|
||||
}: {
|
||||
to: string
|
||||
active: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className={cn(
|
||||
'px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',
|
||||
active
|
||||
? 'border-[#F59845] text-[#F59845]'
|
||||
: 'border-transparent text-slate-600 hover:text-slate-900'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
291
src/features/admin/OrganizationDetailPage.tsx
Normal file
291
src/features/admin/OrganizationDetailPage.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useNavigate, Link } from '@tanstack/react-router'
|
||||
import { ArrowLeft, FileText, Users, FolderX, UserX, GitBranch } from 'lucide-react'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetFooter,
|
||||
} from '@/components/ui/sheet'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { RoleBadge } from './RoleBadge'
|
||||
|
||||
interface OrgProcess {
|
||||
id: string
|
||||
name: string
|
||||
client: string | null
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface OrgUser {
|
||||
id: string
|
||||
name: string | null
|
||||
company: string | null
|
||||
platform_role: string
|
||||
avatar_url?: string | null
|
||||
}
|
||||
|
||||
interface UnassignedProcess {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('es-PY', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
export function OrganizationDetailPage() {
|
||||
const { orgId } = useParams({ from: '/admin/organizations/$orgId' })
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [orgName, setOrgName] = useState<string>('')
|
||||
const [processes, setProcesses] = useState<OrgProcess[]>([])
|
||||
const [users, setUsers] = useState<OrgUser[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// Asignar proceso
|
||||
const [assignOpen, setAssignOpen] = useState(false)
|
||||
const [unassigned, setUnassigned] = useState<UnassignedProcess[]>([])
|
||||
const [loadingUnassigned, setLoadingUnassigned] = useState(false)
|
||||
const [selectedProcessId, setSelectedProcessId] = useState<string>('')
|
||||
const [assigning, setAssigning] = useState(false)
|
||||
const [assignError, setAssignError] = useState<string | null>(null)
|
||||
|
||||
const fetchDetail = async () => {
|
||||
const [orgRes, procRes, userRes] = await Promise.all([
|
||||
supabase.from('organizations').select('name').eq('id', orgId).single(),
|
||||
supabase.from('processes').select('id, name, client, updated_at').eq('org_id', orgId).order('updated_at', { ascending: false }),
|
||||
supabase.from('users').select('id, name, company, platform_role, avatar_url').eq('org_id', orgId).order('name'),
|
||||
])
|
||||
if (orgRes.data) setOrgName((orgRes.data as { name: string }).name)
|
||||
setProcesses((procRes.data as OrgProcess[] | null) ?? [])
|
||||
setUsers((userRes.data as OrgUser[] | null) ?? [])
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => { void fetchDetail() }, [orgId])
|
||||
|
||||
const openAssign = async () => {
|
||||
setSelectedProcessId('')
|
||||
setAssignError(null)
|
||||
setAssignOpen(true)
|
||||
setLoadingUnassigned(true)
|
||||
const { data } = await supabase
|
||||
.from('processes')
|
||||
.select('id, name')
|
||||
.is('org_id', null)
|
||||
.order('name')
|
||||
setUnassigned((data as UnassignedProcess[] | null) ?? [])
|
||||
setLoadingUnassigned(false)
|
||||
}
|
||||
|
||||
const handleAssign = async () => {
|
||||
if (!selectedProcessId) {
|
||||
setAssignError('Seleccioná un proceso')
|
||||
return
|
||||
}
|
||||
setAssigning(true)
|
||||
const { error } = await supabase
|
||||
.from('processes')
|
||||
.update({ org_id: orgId })
|
||||
.eq('id', selectedProcessId)
|
||||
if (error) {
|
||||
setAssignError(error.message)
|
||||
setAssigning(false)
|
||||
} else {
|
||||
setAssignOpen(false)
|
||||
setAssigning(false)
|
||||
void fetchDetail()
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate({ to: '/admin/organizations' })}
|
||||
className="text-slate-500 hover:text-slate-900"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
Volver
|
||||
</Button>
|
||||
<h2 className="text-lg font-semibold text-slate-900">{orgName}</h2>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="processes">
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="processes">
|
||||
<FileText className="h-4 w-4 mr-1.5" />
|
||||
Procesos ({processes.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="users">
|
||||
<Users className="h-4 w-4 mr-1.5" />
|
||||
Usuarios ({users.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ─── Tab: Procesos ─────────────────────────────────────────────── */}
|
||||
<TabsContent value="processes">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="text-sm text-slate-500">
|
||||
{processes.length === 0
|
||||
? 'No hay procesos asignados a esta organización.'
|
||||
: `${processes.length} proceso${processes.length !== 1 ? 's' : ''} asignado${processes.length !== 1 ? 's' : ''}.`}
|
||||
</p>
|
||||
<Button size="sm" variant="outline" onClick={openAssign}>
|
||||
<GitBranch className="h-4 w-4 mr-1.5" />
|
||||
Asignar proceso existente
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{processes.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<FolderX className="h-10 w-10 text-slate-300" />}
|
||||
message="Esta organización no tiene procesos asignados. Podés asignar uno existente o importar uno nuevo."
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-lg border border-slate-200 overflow-hidden bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<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">Proceso</th>
|
||||
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Última actualización</th>
|
||||
<th className="py-2.5 px-4" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{processes.map((p) => (
|
||||
<tr key={p.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/50">
|
||||
<td className="py-3 px-4 font-medium text-slate-900">{p.name}</td>
|
||||
<td className="py-3 px-4 text-slate-500">{formatDate(p.updated_at)}</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<Link
|
||||
to="/workspace/$processId"
|
||||
params={{ processId: p.id }}
|
||||
className="text-xs font-semibold text-[#F59845] hover:text-[#e0872e]"
|
||||
>
|
||||
Abrir workspace →
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ─── Tab: Usuarios ─────────────────────────────────────────────── */}
|
||||
<TabsContent value="users">
|
||||
{users.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<UserX className="h-10 w-10 text-slate-300" />}
|
||||
message="No hay usuarios en esta organización. Invitá usuarios desde la sección Usuarios."
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-lg border border-slate-200 overflow-hidden bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<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">Rol</th>
|
||||
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Empresa</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} className="border-b border-slate-100 last:border-0">
|
||||
<td className="py-3 px-4">
|
||||
<span className="font-medium text-slate-900">{u.name || '—'}</span>
|
||||
{!u.name && !u.avatar_url && (
|
||||
<span className="ml-2 text-[10px] font-medium px-1.5 py-0.5 rounded bg-amber-50 text-amber-700 border border-amber-200">
|
||||
Invitación pendiente
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<RoleBadge role={u.platform_role} />
|
||||
</td>
|
||||
<td className="py-3 px-4 text-slate-500">{u.company ?? '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* ─── Sheet: Asignar proceso ─────────────────────────────────────── */}
|
||||
<Sheet open={assignOpen} onOpenChange={setAssignOpen}>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Asignar proceso a {orgName}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="mt-5 flex flex-col gap-4">
|
||||
{loadingUnassigned ? (
|
||||
<Skeleton className="h-9 w-full" />
|
||||
) : unassigned.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">No hay procesos sin organización asignada.</p>
|
||||
) : (
|
||||
<Select value={selectedProcessId} onValueChange={setSelectedProcessId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Seleccioná un proceso" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{unassigned.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{assignError && <p className="text-xs text-red-600">{assignError}</p>}
|
||||
<SheetFooter>
|
||||
<Button
|
||||
onClick={handleAssign}
|
||||
disabled={assigning || loadingUnassigned || unassigned.length === 0}
|
||||
>
|
||||
{assigning ? 'Asignando…' : 'Asignar proceso'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({ icon, message }: { icon: React.ReactNode; message: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
|
||||
{icon}
|
||||
<p className="text-sm text-slate-500 max-w-sm">{message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
196
src/features/admin/OrganizationsPage.tsx
Normal file
196
src/features/admin/OrganizationsPage.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Building2, Plus } from 'lucide-react'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetFooter,
|
||||
} from '@/components/ui/sheet'
|
||||
|
||||
interface OrgRow {
|
||||
id: string
|
||||
name: string
|
||||
created_at: string
|
||||
processes: [{ count: number }]
|
||||
users: [{ count: number }]
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('es-PY', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
export function OrganizationsPage() {
|
||||
const [orgs, setOrgs] = useState<OrgRow[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [orgName, setOrgName] = useState('')
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const fetchOrgs = async () => {
|
||||
const { data } = await supabase
|
||||
.from('organizations')
|
||||
.select('id, name, created_at, processes:processes(count), users:users(count)')
|
||||
.eq('is_provider', false)
|
||||
.order('name')
|
||||
setOrgs((data as OrgRow[] | null) ?? [])
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => { void fetchOrgs() }, [])
|
||||
|
||||
const openCreate = () => {
|
||||
setOrgName('')
|
||||
setCreateError(null)
|
||||
setCreateOpen(true)
|
||||
}
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const trimmed = orgName.trim()
|
||||
if (!trimmed) {
|
||||
setCreateError('El nombre es requerido')
|
||||
return
|
||||
}
|
||||
setCreating(true)
|
||||
const { error } = await supabase
|
||||
.from('organizations')
|
||||
.insert({ name: trimmed, is_provider: false })
|
||||
if (error) {
|
||||
setCreateError(
|
||||
error.message.toLowerCase().includes('duplicate') || error.message.toLowerCase().includes('unique')
|
||||
? 'Ya existe una organización con ese nombre'
|
||||
: error.message
|
||||
)
|
||||
setCreating(false)
|
||||
} else {
|
||||
setCreateOpen(false)
|
||||
setCreating(false)
|
||||
void fetchOrgs()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h2 className="text-lg font-semibold text-slate-900">Organizaciones clientes</h2>
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
Nueva organización
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<TableSkeleton rows={5} />
|
||||
) : orgs.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Building2 className="h-10 w-10 text-slate-300" />}
|
||||
message="No hay organizaciones clientes aún. Creá la primera con el botón de arriba."
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-lg border border-slate-200 overflow-hidden bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<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">Organización</th>
|
||||
<th className="text-right py-2.5 px-4 font-medium text-slate-600">Procesos</th>
|
||||
<th className="text-right py-2.5 px-4 font-medium text-slate-600">Usuarios</th>
|
||||
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Creada</th>
|
||||
<th className="py-2.5 px-4" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{orgs.map((org) => (
|
||||
<tr key={org.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/50 transition-colors">
|
||||
<td className="py-3 px-4 font-medium text-slate-900">{org.name}</td>
|
||||
<td className="py-3 px-4 text-right text-slate-600 tabular-nums">
|
||||
{org.processes[0]?.count ?? 0}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right text-slate-600 tabular-nums">
|
||||
{org.users[0]?.count ?? 0}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-slate-500">{formatDate(org.created_at)}</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<Link
|
||||
to="/admin/organizations/$orgId"
|
||||
params={{ orgId: org.id }}
|
||||
className="text-xs font-semibold text-[#F59845] hover:text-[#e0872e] transition-colors"
|
||||
>
|
||||
Ver detalle →
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Sheet open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Nueva organización</SheetTitle>
|
||||
</SheetHeader>
|
||||
<form onSubmit={handleCreate} className="mt-5 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="create-org-name">Nombre</Label>
|
||||
<Input
|
||||
id="create-org-name"
|
||||
value={orgName}
|
||||
onChange={(e) => setOrgName(e.target.value)}
|
||||
placeholder="ej. Banco Solar SA"
|
||||
disabled={creating}
|
||||
autoFocus
|
||||
/>
|
||||
{createError && <p className="text-xs text-red-600">{createError}</p>}
|
||||
</div>
|
||||
<SheetFooter className="mt-2">
|
||||
<Button type="submit" disabled={creating}>
|
||||
{creating ? 'Creando…' : 'Crear organización'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function TableSkeleton({ rows }: { rows: number }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 overflow-hidden">
|
||||
<div className="bg-slate-50 border-b border-slate-200 h-10" />
|
||||
<div className="flex flex-col divide-y divide-slate-100">
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<div key={i} className="flex gap-4 px-4 py-3 bg-white">
|
||||
<Skeleton className="h-4 flex-1" />
|
||||
<Skeleton className="h-4 w-12" />
|
||||
<Skeleton className="h-4 w-12" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({ icon, message }: { icon: React.ReactNode; message: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-3 text-center">
|
||||
{icon}
|
||||
<p className="text-sm text-slate-500 max-w-xs">{message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
38
src/features/admin/RoleBadge.tsx
Normal file
38
src/features/admin/RoleBadge.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
// Badges de rol con colores por spec: platform_admin naranja, member slate,
|
||||
// client_editor azul, client_viewer verde, guest gris/suspendido
|
||||
const ROLE_STYLES: Record<string, { label: string; className: string }> = {
|
||||
platform_admin: {
|
||||
label: 'Platform Admin',
|
||||
className: 'bg-orange-100 text-orange-700 border-orange-200',
|
||||
},
|
||||
member: {
|
||||
label: 'Member',
|
||||
className: 'bg-slate-100 text-slate-700 border-slate-200',
|
||||
},
|
||||
client_editor: {
|
||||
label: 'Client Editor',
|
||||
className: 'bg-blue-100 text-blue-700 border-blue-200',
|
||||
},
|
||||
client_viewer: {
|
||||
label: 'Client Viewer',
|
||||
className: 'bg-emerald-100 text-emerald-700 border-emerald-200',
|
||||
},
|
||||
guest: {
|
||||
label: 'Suspendido',
|
||||
className: 'bg-gray-100 text-gray-500 border-gray-200',
|
||||
},
|
||||
}
|
||||
|
||||
export function RoleBadge({ role }: { role: string }) {
|
||||
const style = ROLE_STYLES[role] ?? {
|
||||
label: role,
|
||||
className: 'bg-gray-100 text-gray-500 border-gray-200',
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-[11px] font-medium border ${style.className}`}
|
||||
>
|
||||
{style.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
600
src/features/admin/UsersPage.tsx
Normal file
600
src/features/admin/UsersPage.tsx
Normal file
@@ -0,0 +1,600 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { UserX, UserPlus, MoreVertical } from 'lucide-react'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { useAuth } from '@/auth/AuthContext'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetFooter,
|
||||
} from '@/components/ui/sheet'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { RoleBadge } from './RoleBadge'
|
||||
|
||||
interface AdminUser {
|
||||
id: string
|
||||
name: string | null
|
||||
company: string | null
|
||||
platform_role: string
|
||||
org_id: string | null
|
||||
avatar_url: string | null
|
||||
organization: { name: string; is_provider: boolean } | null
|
||||
}
|
||||
|
||||
interface OrgOption {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
// ─── ConfirmDialog usando @radix-ui/react-dialog (sin @radix-ui/react-alert-dialog) ──
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
title: string
|
||||
description: string
|
||||
confirmLabel?: string
|
||||
onConfirm: () => void
|
||||
loading?: boolean
|
||||
destructive?: boolean
|
||||
}
|
||||
|
||||
function ConfirmDialog({
|
||||
open, onOpenChange, title, description,
|
||||
confirmLabel = 'Confirmar',
|
||||
onConfirm, loading = false, destructive = false,
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/40 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
|
||||
<DialogPrimitive.Content
|
||||
className="fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2 w-full max-w-md bg-white rounded-xl shadow-xl p-6 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogPrimitive.Title className="text-base font-semibold text-slate-900">
|
||||
{title}
|
||||
</DialogPrimitive.Title>
|
||||
<DialogPrimitive.Description className="mt-2 text-sm text-slate-600">
|
||||
{description}
|
||||
</DialogPrimitive.Description>
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={destructive ? 'destructive' : 'default'}
|
||||
onClick={onConfirm}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Procesando…' : confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Página principal ─────────────────────────────────────────────────────────
|
||||
|
||||
const INVITE_ROLES = ['client_editor', 'client_viewer', 'member'] as const
|
||||
type InviteRole = typeof INVITE_ROLES[number]
|
||||
|
||||
const CHANGE_ROLES = ['platform_admin', 'member', 'client_editor', 'client_viewer', 'guest'] as const
|
||||
|
||||
export function UsersPage() {
|
||||
const { user: adminUser } = useAuth()
|
||||
|
||||
const [users, setUsers] = useState<AdminUser[]>([])
|
||||
const [orgs, setOrgs] = useState<OrgOption[]>([])
|
||||
const [loadingUsers, setLoadingUsers] = useState(true)
|
||||
const [filterOrgId, setFilterOrgId] = useState<string>('all')
|
||||
|
||||
// Invite sheet
|
||||
const [inviteOpen, setInviteOpen] = useState(false)
|
||||
const [inviteName, setInviteName] = useState('')
|
||||
const [inviteEmail, setInviteEmail] = useState('')
|
||||
const [inviteRole, setInviteRole] = useState<InviteRole>('client_editor')
|
||||
const [inviteOrgName, setInviteOrgName] = useState('')
|
||||
const [inviteError, setInviteError] = useState<string | null>(null)
|
||||
const [inviteSuccess, setInviteSuccess] = useState<string | null>(null)
|
||||
const [inviting, setInviting] = useState(false)
|
||||
|
||||
// Change role sheet
|
||||
const [changeRoleUser, setChangeRoleUser] = useState<AdminUser | null>(null)
|
||||
const [changeRoleOpen, setChangeRoleOpen] = useState(false)
|
||||
const [newRole, setNewRole] = useState<string>('')
|
||||
const [changingRole, setChangingRole] = useState(false)
|
||||
const [changeRoleError, setChangeRoleError] = useState<string | null>(null)
|
||||
|
||||
// Confirmations
|
||||
const [suspendTarget, setSuspendTarget] = useState<AdminUser | null>(null)
|
||||
const [suspending, setSuspending] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState<AdminUser | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [reactivateTarget, setReactivateTarget] = useState<AdminUser | null>(null)
|
||||
const [reactivating, setReactivating] = useState(false)
|
||||
|
||||
const fetchUsers = async () => {
|
||||
const query = supabase
|
||||
.from('users')
|
||||
.select('id, name, company, platform_role, org_id, avatar_url, organization:organizations(name, is_provider)')
|
||||
.order('name')
|
||||
const finalQuery = filterOrgId !== 'all'
|
||||
? query.eq('org_id', filterOrgId)
|
||||
: query
|
||||
const { data } = await finalQuery
|
||||
setUsers((data as AdminUser[] | null) ?? [])
|
||||
setLoadingUsers(false)
|
||||
}
|
||||
|
||||
const fetchOrgs = async () => {
|
||||
const { data } = await supabase
|
||||
.from('organizations')
|
||||
.select('id, name')
|
||||
.order('name')
|
||||
setOrgs((data as OrgOption[] | null) ?? [])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void fetchOrgs()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setLoadingUsers(true)
|
||||
void fetchUsers()
|
||||
}, [filterOrgId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ── Invite user ──────────────────────────────────────────────────────────
|
||||
|
||||
const openInvite = () => {
|
||||
setInviteName('')
|
||||
setInviteEmail('')
|
||||
setInviteRole('client_editor')
|
||||
setInviteOrgName('')
|
||||
setInviteError(null)
|
||||
setInviteSuccess(null)
|
||||
setInviteOpen(true)
|
||||
}
|
||||
|
||||
const handleInvite = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setInviteError(null)
|
||||
setInviteSuccess(null)
|
||||
if (!inviteName.trim()) { setInviteError('El nombre es requerido'); return }
|
||||
if (!inviteEmail.trim() || !inviteEmail.includes('@')) { setInviteError('Email inválido'); return }
|
||||
if ((inviteRole === 'client_editor' || inviteRole === 'client_viewer') && !inviteOrgName.trim()) {
|
||||
setInviteError('La organización es requerida para este rol')
|
||||
return
|
||||
}
|
||||
setInviting(true)
|
||||
const { error } = await supabase.functions.invoke('invite-user', {
|
||||
body: {
|
||||
email: inviteEmail.trim(),
|
||||
name: inviteName.trim(),
|
||||
platform_role: inviteRole,
|
||||
org_name: inviteRole === 'member' ? 'InQuality' : inviteOrgName.trim(),
|
||||
},
|
||||
})
|
||||
if (error) {
|
||||
setInviteError(typeof error === 'object' && 'message' in error ? String(error.message) : String(error))
|
||||
setInviting(false)
|
||||
} else {
|
||||
setInviteSuccess(`Invitación enviada a ${inviteEmail.trim()}`)
|
||||
setInviting(false)
|
||||
void fetchUsers()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Change role ──────────────────────────────────────────────────────────
|
||||
|
||||
const openChangeRole = (u: AdminUser) => {
|
||||
setChangeRoleUser(u)
|
||||
setNewRole(u.platform_role)
|
||||
setChangeRoleError(null)
|
||||
setChangeRoleOpen(true)
|
||||
}
|
||||
|
||||
const handleChangeRole = async () => {
|
||||
if (!changeRoleUser || !newRole) return
|
||||
setChangingRole(true)
|
||||
const { error } = await supabase
|
||||
.from('users')
|
||||
.update({ platform_role: newRole })
|
||||
.eq('id', changeRoleUser.id)
|
||||
if (error) {
|
||||
setChangeRoleError(error.message)
|
||||
setChangingRole(false)
|
||||
} else {
|
||||
setChangeRoleOpen(false)
|
||||
setChangingRole(false)
|
||||
void fetchUsers()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Suspend (revoke-user vía Edge Function) ───────────────────────────────
|
||||
|
||||
const handleSuspend = async () => {
|
||||
if (!suspendTarget) return
|
||||
setSuspending(true)
|
||||
const { error } = await supabase.functions.invoke('revoke-user', {
|
||||
body: { userId: suspendTarget.id },
|
||||
})
|
||||
if (error) {
|
||||
console.error('Error al suspender usuario:', error)
|
||||
}
|
||||
setSuspendTarget(null)
|
||||
setSuspending(false)
|
||||
void fetchUsers()
|
||||
}
|
||||
|
||||
// ── Delete (también usa revoke-user — baja a guest, eliminación real futura) ─
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return
|
||||
setDeleting(true)
|
||||
const { error } = await supabase.functions.invoke('revoke-user', {
|
||||
body: { userId: deleteTarget.id },
|
||||
})
|
||||
if (error) {
|
||||
console.error('Error al eliminar usuario:', error)
|
||||
}
|
||||
setDeleteTarget(null)
|
||||
setDeleting(false)
|
||||
void fetchUsers()
|
||||
}
|
||||
|
||||
// ── Reactivate (restore to client_editor — rol previo no se guarda) ───────
|
||||
|
||||
const handleReactivate = async () => {
|
||||
if (!reactivateTarget) return
|
||||
setReactivating(true)
|
||||
await supabase
|
||||
.from('users')
|
||||
.update({ platform_role: 'client_editor' })
|
||||
.eq('id', reactivateTarget.id)
|
||||
setReactivateTarget(null)
|
||||
setReactivating(false)
|
||||
void fetchUsers()
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const displayedUsers = users
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold text-slate-900">Usuarios</h2>
|
||||
<Select value={filterOrgId} onValueChange={setFilterOrgId}>
|
||||
<SelectTrigger className="w-48 h-8 text-xs">
|
||||
<SelectValue placeholder="Filtrar por org" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todas las organizaciones</SelectItem>
|
||||
{orgs.map((o) => (
|
||||
<SelectItem key={o.id} value={o.id}>{o.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button size="sm" onClick={openInvite}>
|
||||
<UserPlus className="h-4 w-4 mr-1.5" />
|
||||
Invitar usuario
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loadingUsers ? (
|
||||
<TableSkeleton rows={6} />
|
||||
) : displayedUsers.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<UserX className="h-10 w-10 text-slate-300" />}
|
||||
message={
|
||||
filterOrgId !== 'all'
|
||||
? 'No hay usuarios en esta organización.'
|
||||
: 'No hay usuarios registrados aún.'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-lg border border-slate-200 overflow-hidden bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<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">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" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{displayedUsers.map((u) => {
|
||||
const isSelf = adminUser?.id === u.id
|
||||
const isPending = !u.name && !u.avatar_url
|
||||
|
||||
return (
|
||||
<tr key={u.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/50">
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-medium ${u.platform_role === 'guest' ? 'text-slate-400' : 'text-slate-900'}`}>
|
||||
{u.name || <span className="italic text-slate-400">Sin nombre</span>}
|
||||
</span>
|
||||
{isPending && (
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-amber-50 text-amber-700 border border-amber-200">
|
||||
Invitación pendiente
|
||||
</span>
|
||||
)}
|
||||
{isSelf && (
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-slate-100 text-slate-500">
|
||||
Tú
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<RoleBadge role={u.platform_role} />
|
||||
</td>
|
||||
<td className="py-3 px-4 text-slate-500">
|
||||
{u.organization?.name ?? '—'}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
disabled={isSelf}
|
||||
title={isSelf ? 'No puedes modificar tu propio usuario' : undefined}
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => openChangeRole(u)}>
|
||||
Cambiar rol
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{u.platform_role === 'guest' ? (
|
||||
<DropdownMenuItem onClick={() => setReactivateTarget(u)}>
|
||||
Reactivar usuario
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
className="text-amber-600 focus:text-amber-600"
|
||||
onClick={() => setSuspendTarget(u)}
|
||||
>
|
||||
Suspender
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setDeleteTarget(u)}
|
||||
>
|
||||
Eliminar
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Sheet: Invitar usuario ──────────────────────────────────────── */}
|
||||
<Sheet open={inviteOpen} onOpenChange={setInviteOpen}>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Invitar usuario</SheetTitle>
|
||||
</SheetHeader>
|
||||
<form onSubmit={handleInvite} className="mt-5 flex flex-col gap-4">
|
||||
<Field label="Nombre completo" id="invite-name">
|
||||
<Input
|
||||
id="invite-name"
|
||||
value={inviteName}
|
||||
onChange={(e) => setInviteName(e.target.value)}
|
||||
placeholder="Ej. Ana Martínez"
|
||||
disabled={inviting}
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Email" id="invite-email">
|
||||
<Input
|
||||
id="invite-email"
|
||||
type="email"
|
||||
value={inviteEmail}
|
||||
onChange={(e) => setInviteEmail(e.target.value)}
|
||||
placeholder="ana@empresa.com"
|
||||
disabled={inviting}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Rol" id="invite-role">
|
||||
<Select
|
||||
value={inviteRole}
|
||||
onValueChange={(v) => setInviteRole(v as InviteRole)}
|
||||
disabled={inviting}
|
||||
>
|
||||
<SelectTrigger id="invite-role">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="client_editor">Client Editor</SelectItem>
|
||||
<SelectItem value="client_viewer">Client Viewer</SelectItem>
|
||||
<SelectItem value="member">Member (InQuality)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
{inviteRole === 'member' ? (
|
||||
<Field label="Organización" id="invite-org-member">
|
||||
<Input id="invite-org-member" value="InQuality" disabled />
|
||||
</Field>
|
||||
) : (
|
||||
<Field label="Organización" id="invite-org">
|
||||
<Input
|
||||
id="invite-org"
|
||||
list="orgs-datalist"
|
||||
value={inviteOrgName}
|
||||
onChange={(e) => setInviteOrgName(e.target.value)}
|
||||
placeholder="Nombre de la organización"
|
||||
disabled={inviting}
|
||||
/>
|
||||
<datalist id="orgs-datalist">
|
||||
{orgs.map((o) => (
|
||||
<option key={o.id} value={o.name} />
|
||||
))}
|
||||
</datalist>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{inviteError && <p className="text-xs text-red-600">{inviteError}</p>}
|
||||
{inviteSuccess && <p className="text-xs text-emerald-600 font-medium">{inviteSuccess}</p>}
|
||||
|
||||
<SheetFooter className="mt-2">
|
||||
<Button type="submit" disabled={inviting}>
|
||||
{inviting ? 'Enviando invitación…' : 'Enviar invitación'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{/* ─── Sheet: Cambiar rol ──────────────────────────────────────────── */}
|
||||
<Sheet open={changeRoleOpen} onOpenChange={setChangeRoleOpen}>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Cambiar rol — {changeRoleUser?.name}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="mt-5 flex flex-col gap-4">
|
||||
<Field label="Nuevo rol" id="change-role-select">
|
||||
<Select value={newRole} onValueChange={setNewRole} disabled={changingRole}>
|
||||
<SelectTrigger id="change-role-select">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CHANGE_ROLES.map((r) => (
|
||||
<SelectItem key={r} value={r}>{r}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{changeRoleError && <p className="text-xs text-red-600">{changeRoleError}</p>}
|
||||
<SheetFooter className="mt-2">
|
||||
<Button onClick={handleChangeRole} disabled={changingRole}>
|
||||
{changingRole ? 'Guardando…' : 'Guardar rol'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{/* ─── ConfirmDialog: Suspender ────────────────────────────────────── */}
|
||||
<ConfirmDialog
|
||||
open={!!suspendTarget}
|
||||
onOpenChange={(open) => !open && setSuspendTarget(null)}
|
||||
title={`¿Suspender a ${suspendTarget?.name ?? 'este usuario'}?`}
|
||||
description="No podrá acceder a la plataforma mientras esté suspendido. Podés reactivarlo después."
|
||||
confirmLabel="Suspender"
|
||||
onConfirm={handleSuspend}
|
||||
loading={suspending}
|
||||
destructive
|
||||
/>
|
||||
|
||||
{/* ─── ConfirmDialog: Eliminar ─────────────────────────────────────── */}
|
||||
<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"
|
||||
onConfirm={handleDelete}
|
||||
loading={deleting}
|
||||
destructive
|
||||
/>
|
||||
|
||||
{/* ─── ConfirmDialog: Reactivar ────────────────────────────────────── */}
|
||||
<ConfirmDialog
|
||||
open={!!reactivateTarget}
|
||||
onOpenChange={(open) => !open && setReactivateTarget(null)}
|
||||
title={`¿Reactivar a ${reactivateTarget?.name ?? 'este usuario'}?`}
|
||||
description="Se le asignará el rol Client Editor. (Nota: el rol original no se conservó al momento de la suspensión.)"
|
||||
confirmLabel="Reactivar"
|
||||
onConfirm={handleReactivate}
|
||||
loading={reactivating}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, id, children }: { label: string; id: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableSkeleton({ rows }: { rows: number }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 overflow-hidden">
|
||||
<div className="bg-slate-50 border-b border-slate-200 h-10" />
|
||||
<div className="flex flex-col divide-y divide-slate-100">
|
||||
{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-5 w-20 rounded" />
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-6 w-6 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({ icon, message }: { icon: React.ReactNode; message: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-3 text-center">
|
||||
{icon}
|
||||
<p className="text-sm text-slate-500 max-w-xs">{message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
103
src/features/auth/ResetPasswordPage.tsx
Normal file
103
src/features/auth/ResetPasswordPage.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { useAuth } from '@/auth/AuthContext'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export function ResetPasswordPage() {
|
||||
const { clearPasswordReset } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
|
||||
if (password.length < 8) {
|
||||
setError('La contraseña debe tener al menos 8 caracteres')
|
||||
return
|
||||
}
|
||||
if (password !== confirm) {
|
||||
setError('Las contraseñas no coinciden')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
const { error: updateError } = await supabase.auth.updateUser({ password })
|
||||
if (updateError) {
|
||||
setError(updateError.message)
|
||||
setLoading(false)
|
||||
} else {
|
||||
clearPasswordReset()
|
||||
void navigate({ to: '/' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex items-center justify-center">
|
||||
<div
|
||||
className="flex flex-col items-center gap-6 px-8 w-full max-w-sm"
|
||||
style={{ animation: 'fadeIn 0.4s ease-out both' }}
|
||||
>
|
||||
<style>{`
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<img
|
||||
src="/inquality-logo-color.png"
|
||||
alt="InQuality"
|
||||
className="h-12 object-contain"
|
||||
/>
|
||||
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-slate-900 tracking-tight">Establecer contraseña</h1>
|
||||
<p className="text-slate-500 mt-1 text-sm">Creá una contraseña para tu cuenta InQ ROI</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="w-full flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="new-password" className="text-sm text-slate-700">Nueva contraseña</Label>
|
||||
<Input
|
||||
id="new-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Mínimo 8 caracteres"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="confirm-password" className="text-sm text-slate-700">Confirmar contraseña</Label>
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
placeholder="Repetí la contraseña"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-600">{error}</p>
|
||||
)}
|
||||
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
{loading ? 'Guardando…' : 'Establecer contraseña'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,14 @@
|
||||
import { useState } from 'react'
|
||||
import { z } from 'zod'
|
||||
import { useAuth } from '@/auth/AuthContext'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email('Email inválido'),
|
||||
password: z.string().min(1, 'La contraseña es requerida'),
|
||||
})
|
||||
|
||||
function GoogleIcon() {
|
||||
return (
|
||||
@@ -12,12 +22,39 @@ function GoogleIcon() {
|
||||
}
|
||||
|
||||
export function LoginPage() {
|
||||
const { signIn, loading } = useAuth()
|
||||
const { signIn, signInWithEmailPassword, loading } = useAuth()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const handleEmailLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setFormError(null)
|
||||
|
||||
const result = loginSchema.safeParse({ email, password })
|
||||
if (!result.success) {
|
||||
setFormError(result.error.issues[0].message)
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
const { error } = await signInWithEmailPassword(email, password)
|
||||
if (error) {
|
||||
setFormError(
|
||||
error === 'Invalid login credentials'
|
||||
? 'Email o contraseña incorrectos'
|
||||
: error
|
||||
)
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
// En login exitoso no resetear isSubmitting — el router redirige y desmonta el componente
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex items-center justify-center">
|
||||
<div
|
||||
className="flex flex-col items-center gap-6 px-8"
|
||||
className="flex flex-col items-center gap-6 px-8 w-full max-w-sm"
|
||||
style={{ animation: 'loginFadeIn 0.5s ease-out both' }}
|
||||
>
|
||||
<style>{`
|
||||
@@ -40,16 +77,63 @@ export function LoginPage() {
|
||||
|
||||
<button
|
||||
onClick={signIn}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-3 px-6 py-3 rounded-xl font-semibold text-white text-sm shadow-sm transition-opacity hover:opacity-90 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
disabled={loading || isSubmitting}
|
||||
className="w-full flex items-center justify-center gap-3 px-6 py-3 rounded-xl font-semibold text-white text-sm shadow-sm transition-opacity hover:opacity-90 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
style={{ backgroundColor: '#F59845' }}
|
||||
>
|
||||
<GoogleIcon />
|
||||
Iniciar sesión con Google
|
||||
Continuar con Google
|
||||
</button>
|
||||
|
||||
<p className="text-xs text-slate-400">
|
||||
Solo para usuarios con cuenta @inquality.com.py
|
||||
<div className="w-full flex items-center gap-3">
|
||||
<div className="flex-1 h-px bg-slate-200" />
|
||||
<span className="text-xs text-slate-400">o</span>
|
||||
<div className="flex-1 h-px bg-slate-200" />
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleEmailLogin} className="w-full flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="email" className="text-sm text-slate-700">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="usuario@empresa.com"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="password" className="text-sm text-slate-700">Contraseña</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formError && (
|
||||
<p className="text-xs text-red-600">{formError}</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || loading}
|
||||
className="w-full"
|
||||
>
|
||||
{isSubmitting ? 'Ingresando…' : 'Ingresar'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-xs text-slate-400 text-center">
|
||||
Los usuarios externos acceden por invitación.<br />
|
||||
Si no recibiste una, contactá a tu consultor InQuality.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user