438 lines
16 KiB
TypeScript
438 lines
16 KiB
TypeScript
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { AdminLayout } from '@/features/admin/admin-layout';
|
|
import { ApiClientError, apiClient } from '@/lib/api-client';
|
|
import type { AdminUser, AdminUserSession } from '@repo/api-contract';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { useLocation } from '@tanstack/react-router';
|
|
import {
|
|
Ban,
|
|
CheckCircle2,
|
|
Globe,
|
|
LogIn,
|
|
Monitor,
|
|
RefreshCw,
|
|
ShieldAlert,
|
|
Users,
|
|
} from 'lucide-react';
|
|
import { useState } from 'react';
|
|
|
|
function SessionsDialog({
|
|
user,
|
|
onClose,
|
|
}: {
|
|
user: AdminUser;
|
|
onClose: () => void;
|
|
}) {
|
|
const queryClient = useQueryClient();
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: ['admin-user-sessions', user.id],
|
|
queryFn: () => apiClient.admin.getUserSessions(user.id),
|
|
});
|
|
|
|
const revokeMutation = useMutation({
|
|
mutationFn: () => apiClient.admin.revokeAllSessions(user.id),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['admin-user-sessions', user.id] });
|
|
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
|
},
|
|
});
|
|
|
|
return (
|
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
|
<DialogContent className="rounded-2xl sm:max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle>Sesiones de {user.name}</DialogTitle>
|
|
<DialogDescription>{data?.length ?? 0} sesiones activas</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="max-h-80 space-y-3 overflow-y-auto">
|
|
{isLoading ? (
|
|
<p className="py-8 text-center text-sm text-muted-foreground">Cargando sesiones...</p>
|
|
) : data?.length === 0 ? (
|
|
<p className="py-8 text-center text-sm text-muted-foreground">Sin sesiones activas.</p>
|
|
) : (
|
|
data?.map((session) => <SessionRow key={session.id} session={session} />)
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between gap-3">
|
|
{revokeMutation.error && (
|
|
<p className="flex-1 text-sm text-destructive">
|
|
{revokeMutation.error instanceof ApiClientError
|
|
? String(revokeMutation.error.details ?? revokeMutation.error.message)
|
|
: revokeMutation.error.message}
|
|
</p>
|
|
)}
|
|
<div className="flex gap-3">
|
|
<Button variant="outline" className="rounded-xl" onClick={onClose}>
|
|
Cerrar
|
|
</Button>
|
|
{data && data.length > 0 && (
|
|
<Button
|
|
variant="destructive"
|
|
className="rounded-xl"
|
|
onClick={() => revokeMutation.mutate()}
|
|
disabled={revokeMutation.isPending}
|
|
>
|
|
{revokeMutation.isPending ? 'Cerrando...' : 'Cerrar todas las sesiones'}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function SessionRow({ session }: { session: AdminUserSession }) {
|
|
const isExpired = new Date(session.expiresAt) < new Date();
|
|
const userAgent = session.userAgent ?? 'Desconocido';
|
|
const ip = session.ipAddress ?? '—';
|
|
|
|
return (
|
|
<div className="rounded-xl border border-border/60 p-4">
|
|
<div className="mb-2 flex items-center gap-2">
|
|
<Monitor className="size-4 text-muted-foreground" />
|
|
<span className="flex-1 truncate text-sm font-medium">{userAgent}</span>
|
|
{isExpired ? (
|
|
<Badge
|
|
variant="outline"
|
|
className="border-gray-300 bg-gray-50 text-gray-600 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-400"
|
|
>
|
|
Expirada
|
|
</Badge>
|
|
) : (
|
|
<Badge
|
|
variant="outline"
|
|
className="border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
|
|
>
|
|
Activa
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
|
<span className="flex items-center gap-1">
|
|
<Globe className="size-3" />
|
|
{ip}
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<LogIn className="size-3" />
|
|
{new Date(session.createdAt).toLocaleString()}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function BlockDialog({
|
|
user,
|
|
onClose,
|
|
}: {
|
|
user: AdminUser;
|
|
onClose: () => void;
|
|
}) {
|
|
const queryClient = useQueryClient();
|
|
const [reason, setReason] = useState('');
|
|
|
|
const mutation = useMutation({
|
|
mutationFn: (banReason?: string) => apiClient.admin.blockUser(user.id, { banReason }),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
|
onClose();
|
|
},
|
|
});
|
|
|
|
return (
|
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
|
<DialogContent className="rounded-2xl sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>Bloquear usuario</DialogTitle>
|
|
<DialogDescription>
|
|
¿Estás seguro de bloquear a <strong>{user.name}</strong> ({user.email})? No podrá
|
|
iniciar sesión en Playzer.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="reason">Motivo (opcional)</Label>
|
|
<Input
|
|
id="reason"
|
|
value={reason}
|
|
onChange={(e) => setReason(e.target.value)}
|
|
placeholder="Violación de términos..."
|
|
maxLength={500}
|
|
/>
|
|
</div>
|
|
|
|
{mutation.error && (
|
|
<p className="text-sm text-destructive">
|
|
{mutation.error instanceof ApiClientError
|
|
? String(mutation.error.details ?? mutation.error.message)
|
|
: mutation.error.message}
|
|
</p>
|
|
)}
|
|
|
|
<div className="flex justify-end gap-3">
|
|
<Button type="button" variant="outline" className="rounded-xl" onClick={onClose}>
|
|
Cancelar
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="destructive"
|
|
className="rounded-xl"
|
|
onClick={() => mutation.mutate(reason || undefined)}
|
|
disabled={mutation.isPending}
|
|
>
|
|
{mutation.isPending ? 'Bloqueando...' : 'Bloquear'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function UnblockDialog({
|
|
user,
|
|
onClose,
|
|
}: {
|
|
user: AdminUser;
|
|
onClose: () => void;
|
|
}) {
|
|
const queryClient = useQueryClient();
|
|
|
|
const mutation = useMutation({
|
|
mutationFn: () => apiClient.admin.unblockUser(user.id),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
|
onClose();
|
|
},
|
|
});
|
|
|
|
return (
|
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
|
<DialogContent className="rounded-2xl sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>Desbloquear usuario</DialogTitle>
|
|
<DialogDescription>
|
|
¿Estás seguro de desbloquear a <strong>{user.name}</strong>? Podrá volver a iniciar
|
|
sesión en Playzer.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{mutation.error && (
|
|
<p className="text-sm text-destructive">
|
|
{mutation.error instanceof ApiClientError
|
|
? String(mutation.error.details ?? mutation.error.message)
|
|
: mutation.error.message}
|
|
</p>
|
|
)}
|
|
|
|
<div className="flex justify-end gap-3">
|
|
<Button type="button" variant="outline" className="rounded-xl" onClick={onClose}>
|
|
Cancelar
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="default"
|
|
className="rounded-xl"
|
|
onClick={() => mutation.mutate()}
|
|
disabled={mutation.isPending}
|
|
>
|
|
{mutation.isPending ? 'Desbloqueando...' : 'Desbloquear'}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function RoleBadge({ role }: { role: string }) {
|
|
if (role === 'super_admin') {
|
|
return (
|
|
<Badge
|
|
variant="outline"
|
|
className="border-purple-300 bg-purple-50 text-purple-700 dark:border-purple-700 dark:bg-purple-950 dark:text-purple-400"
|
|
>
|
|
<ShieldAlert className="mr-1 size-3" />
|
|
Super Admin
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Badge variant="secondary" className="text-xs">
|
|
{role}
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
export function UsersPage() {
|
|
const location = useLocation();
|
|
const queryClient = useQueryClient();
|
|
const [blockingUser, setBlockingUser] = useState<AdminUser | null>(null);
|
|
const [unblockingUser, setUnblockingUser] = useState<AdminUser | null>(null);
|
|
const [sessionsUser, setSessionsUser] = useState<AdminUser | null>(null);
|
|
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: ['admin-users'],
|
|
queryFn: () => apiClient.admin.listUsers(),
|
|
});
|
|
|
|
return (
|
|
<AdminLayout currentPath={location.pathname}>
|
|
<div className="mb-6 flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold tracking-tight">Usuarios</h1>
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
{data?.length ?? 0} usuarios registrados en Playzer.
|
|
</p>
|
|
</div>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
className="rounded-xl"
|
|
onClick={() => queryClient.invalidateQueries({ queryKey: ['admin-users'] })}
|
|
>
|
|
<RefreshCw className="size-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
{blockingUser && <BlockDialog user={blockingUser} onClose={() => setBlockingUser(null)} />}
|
|
|
|
{unblockingUser && (
|
|
<UnblockDialog user={unblockingUser} onClose={() => setUnblockingUser(null)} />
|
|
)}
|
|
|
|
{sessionsUser && <SessionsDialog user={sessionsUser} onClose={() => setSessionsUser(null)} />}
|
|
|
|
{isLoading ? (
|
|
<div className="flex items-center justify-center py-20">
|
|
<p className="text-sm text-muted-foreground">Cargando usuarios...</p>
|
|
</div>
|
|
) : data?.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-20">
|
|
<Users className="mb-3 size-10 text-muted-foreground/50" />
|
|
<p className="text-sm text-muted-foreground">No hay usuarios registrados.</p>
|
|
</div>
|
|
) : (
|
|
<div className="overflow-hidden rounded-2xl border border-border/60">
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="border-b border-border/60 bg-muted/50">
|
|
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
Usuario
|
|
</th>
|
|
<th className="hidden px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-muted-foreground md:table-cell">
|
|
Rol
|
|
</th>
|
|
<th className="hidden px-5 py-3 text-center text-xs font-semibold uppercase tracking-wider text-muted-foreground sm:table-cell">
|
|
Complejos
|
|
</th>
|
|
<th className="hidden px-5 py-3 text-center text-xs font-semibold uppercase tracking-wider text-muted-foreground lg:table-cell">
|
|
Sesiones
|
|
</th>
|
|
<th className="hidden px-5 py-3 text-center text-xs font-semibold uppercase tracking-wider text-muted-foreground lg:table-cell">
|
|
Registro
|
|
</th>
|
|
<th className="px-5 py-3 text-center text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
Estado
|
|
</th>
|
|
<th className="px-5 py-3 text-right text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
Acción
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border/40">
|
|
{data?.map((user) => (
|
|
<tr key={user.id} className="transition-colors hover:bg-accent/30">
|
|
<td className="px-5 py-4">
|
|
<div className="min-w-0">
|
|
<p className="truncate font-medium">{user.name}</p>
|
|
<p className="truncate text-xs text-muted-foreground">{user.email}</p>
|
|
</div>
|
|
</td>
|
|
<td className="hidden px-5 py-4 md:table-cell">
|
|
<RoleBadge role={user.role} />
|
|
</td>
|
|
<td className="hidden px-5 py-4 text-center text-sm text-muted-foreground sm:table-cell">
|
|
{user.complexCount}
|
|
</td>
|
|
<td className="hidden px-5 py-4 text-center lg:table-cell">
|
|
<button
|
|
type="button"
|
|
className="cursor-pointer text-sm font-medium text-emerald-600 underline-offset-2 hover:underline"
|
|
onClick={() => setSessionsUser(user)}
|
|
>
|
|
{user.activeSessions}
|
|
</button>
|
|
</td>
|
|
<td className="hidden px-5 py-4 text-sm text-muted-foreground lg:table-cell">
|
|
{new Date(user.createdAt).toLocaleDateString()}
|
|
</td>
|
|
<td className="px-5 py-4 text-center">
|
|
{user.banned ? (
|
|
<Badge
|
|
variant="outline"
|
|
className="border-red-300 bg-red-50 text-red-700 dark:border-red-700 dark:bg-red-950 dark:text-red-400"
|
|
>
|
|
Bloqueado
|
|
</Badge>
|
|
) : (
|
|
<Badge
|
|
variant="outline"
|
|
className="border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
|
|
>
|
|
Activo
|
|
</Badge>
|
|
)}
|
|
</td>
|
|
<td className="px-5 py-4 text-right">
|
|
<div className="flex items-center justify-end gap-1">
|
|
{user.banned ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="rounded-xl text-xs"
|
|
disabled={user.role === 'super_admin'}
|
|
onClick={() => setUnblockingUser(user)}
|
|
>
|
|
<CheckCircle2 className="mr-1.5 size-3.5 text-emerald-600" />
|
|
Desbloquear
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="rounded-xl text-xs text-destructive hover:bg-destructive/10 hover:text-destructive disabled:opacity-40"
|
|
disabled={user.role === 'super_admin'}
|
|
onClick={() => setBlockingUser(user)}
|
|
>
|
|
<Ban className="mr-1.5 size-3.5" />
|
|
Bloquear
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</AdminLayout>
|
|
);
|
|
}
|