EmpiezaTuSaaS
Funcionalidades

Panel de Administracion

Gestióna usuarios, roles y baneos desde el panel de admin

Panel de Administracion

EmpiezaTuSaaS incluye un panel de administracion para gestiónar usuarios de tu plataforma. El panel tiene sidebar de navegación, página de overview con estadisticas y página de gestión de usuarios con acciones por usuario.

Caracteristicas

  • Vista general: Dashboard con 4 tarjetas de estadisticas (Total usuarios, MRR, Conversion, Activos hoy)
  • Gestión de usuarios: Lista de usuarios con avatar, email, estado de verificación, suscripcion, rol y acciones
  • Roles: admin y user (dos roles, sin superadmin)
  • Baneos: Banear y desbanear usuarios
  • Acciones por usuario: Menu dropdown con opciones de cambiar rol y banear/desbanear

Estructura de archivos

app/
└── (admin)/
    ├── layout.tsx              # Sidebar de navegación + verificación de rol
    └── admin/
        ├── page.tsx            # Overview con tarjetas de estadisticas
        └── users/
            ├── page.tsx        # Lista de usuarios con Prisma
            └── user-actions.tsx # Componente client con acciones (dropdown)

No hay archivos separados en components/admin/. El sidebar está inline en el layout y las acciones de usuario estan en user-actions.tsx junto a la página de usuarios.

Acceso al Panel

El panel está disponible en /admin. La navegación lateral incluye 3 enlaces:

app/(admin)/layout.tsx
const adminNavItems = [
  { href: "/admin", label: "Overview", icon: BarChart3 },
  { href: "/admin/users", label: "Usuarios", icon: Users },
  { href: "/admin/settings", label: "Configuración", icon: Settings },
];

El layout incluye un sidebar oscuro (bg-zinc-950) con el nombre de la app, badge "Admin", links de navegación y un enlace para volver al dashboard.

app/(admin)/layout.tsx
import Link from "next/link";
import { Shield, Users, BarChart3, Settings, Zap } from "lucide-react";
import { siteConfig } from "@/config/site";

const adminNavItems = [
  { href: "/admin", label: "Overview", icon: BarChart3 },
  { href: "/admin/users", label: "Usuarios", icon: Users },
  { href: "/admin/settings", label: "Configuración", icon: Settings },
];

export default function AdminLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="min-h-screen flex">
      <aside className="w-64 border-r border-border bg-zinc-950 hidden md:flex flex-col">
        <div className="p-6 border-b border-border">
          <Link href="/admin" className="flex items-center gap-2 font-bold">
            <Zap className="h-5 w-5" />
            <span>{siteConfig.name}</span>
            <span className="text-xs bg-primary text-primary-foreground px-1.5 py-0.5 rounded ml-1">
              Admin
            </span>
          </Link>
        </div>

        <nav className="flex-1 p-4 space-y-1">
          {adminNavItems.map((item) => (
            <Link
              key={item.href}
              href={item.href}
              className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
            >
              <item.icon className="h-4 w-4" />
              {item.label}
            </Link>
          ))}
        </nav>

        <div className="p-4 border-t border-border">
          <Link
            href="/dashboard"
            className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
          >
            <Shield className="h-4 w-4" />
            Volver al dashboard
          </Link>
        </div>
      </aside>

      <main className="flex-1 overflow-auto bg-background">
        <div className="p-8">{children}</div>
      </main>
    </div>
  );
}

Crear el primer admin

Al inicio, no tendrás usuarios admin. Puedes crear uno de dos formas:

Opcion 1: Via base de datos

UPDATE "user" SET role = 'admin' WHERE email = 'tu@email.com';

Opcion 2: Via Prisma Studio

npx prisma studio

Abre Prisma Studio en el navegador, busca tu usuario y cambia el campo role a admin.

Overview (Dashboard de admin)

La página /admin muestra 4 tarjetas de estadisticas con valores placeholder (para que los conectes a datos reales) y una tabla de usuarios recientes.

app/(admin)/admin/page.tsx
import { Users, DollarSign, TrendingUp, Activity } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";

const adminStats = [
  { title: "Total usuarios", value: "0", icon: Users, trend: "+0%" },
  { title: "MRR", value: "€0", icon: DollarSign, trend: "+0%" },
  { title: "Conversión", value: "0%", icon: TrendingUp, trend: "+0%" },
  { title: "Activos hoy", value: "0", icon: Activity, trend: "+0%" },
];

export default function AdminPage() {
  return (
    <div className="space-y-8">
      <div className="flex items-center justify-between">
        <div>
          <h1 className="text-2xl font-bold">Panel de Administración</h1>
          <p className="text-muted-foreground mt-1">
            Vista general del sistema
          </p>
        </div>
        <Badge variant="outline" className="text-xs">
          Admin
        </Badge>
      </div>

      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
        {adminStats.map((stat) => (
          <Card key={stat.title}>
            <CardHeader className="pb-2">
              <div className="flex items-center justify-between">
                <CardDescription>{stat.title}</CardDescription>
                <stat.icon className="h-4 w-4 text-muted-foreground" />
              </div>
            </CardHeader>
            <CardContent>
              <div className="text-2xl font-bold">{stat.value}</div>
              <p className="text-xs text-green-500 mt-1">{stat.trend}</p>
            </CardContent>
          </Card>
        ))}
      </div>

      <Card>
        <CardHeader>
          <CardTitle>Usuarios recientes</CardTitle>
          <CardDescription>
            Los últimos usuarios registrados en el sistema
          </CardDescription>
        </CardHeader>
        <CardContent>
          <div className="text-sm text-center text-muted-foreground py-8">
            No hay usuarios registrados todavía
          </div>
        </CardContent>
      </Card>
    </div>
  );
}

Las estadisticas usan valores placeholder ("0", "€0", "0%"). Para conectarlas a datos reales, reemplaza los valores con queries de Prisma.

Gestión de usuarios

La página /admin/users lista todos los usuarios del sistema usando Prisma. Muestra avatar, nombre, email, badges de rol y estado, fecha de registro y un menu de acciones.

app/(admin)/admin/users/page.tsx
import { db } from "@/lib/db";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { AdminUserActions } from "./user-actions";
import { formatDistanceToNow } from "@/lib/utils";

export default async function AdminUsersPage() {
  let users = [];

  try {
    users = await db.user.findMany({
      orderBy: { createdAt: "desc" },
      take: 100,
      select: {
        id: true,
        name: true,
        email: true,
        emailVerified: true,
        image: true,
        createdAt: true,
        banned: true,
        role: true,
        subscription: {
          select: { status: true, plan: true },
        },
      },
    });
  } catch {
    // DB not connected in development
  }

  return (
    <div className="space-y-6">
      <div>
        <h1 className="text-2xl font-bold">Gestión de Usuarios</h1>
        <p className="text-muted-foreground mt-1">
          {users.length} usuarios registrados
        </p>
      </div>
      {/* Tabla de usuarios con acciones */}
    </div>
  );
}

Cada usuario muestra:

  • Avatar con imagen o iniciales como fallback
  • Nombre con badge "Admin" si role === "admin" y badge "Baneado" si banned === true
  • Email debajo del nombre
  • Estado de suscripcion: badge verde si tiene suscripcion activa
  • Verificación de email: badge "Verificado" o "Sin verificar"
  • Fecha de registro: usando formatDistanceToNow() (con locale en español)
  • Menu de acciones: dropdown con opciones

Acciones de usuario

El componente AdminUserActions es un Client Component que usa el dropdown de shadcn/ui y authClient de Better Auth para ejecutar acciones:

app/(admin)/admin/users/user-actions.tsx
"use client";

import { useState } from "react";
import { MoreHorizontal, ShieldCheck, ShieldOff, Ban, UserCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { authClient } from "@/lib/auth-client";
import { toast } from "sonner";

interface AdminUserActionsProps {
  userId: string;
  currentRole: string;
  isBanned: boolean;
}

export function AdminUserActions({ userId, currentRole, isBanned }: AdminUserActionsProps) {
  const [loading, setLoading] = useState(false);

  // ... handleAction helper

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="ghost" size="icon" className="h-8 w-8" disabled={loading}>
          <MoreHorizontal className="h-4 w-4" />
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        <DropdownMenuLabel>Acciones</DropdownMenuLabel>
        <DropdownMenuSeparator />
        {currentRole !== "admin" ? (
          <DropdownMenuItem onClick={setAdmin}>
            <ShieldCheck className="h-4 w-4 mr-2" />
            Hacer admin
          </DropdownMenuItem>
        ) : (
          <DropdownMenuItem onClick={setUser}>
            <ShieldOff className="h-4 w-4 mr-2" />
            Quitar admin
          </DropdownMenuItem>
        )}
        <DropdownMenuSeparator />
        {isBanned ? (
          <DropdownMenuItem onClick={unbanUser}>
            <UserCheck className="h-4 w-4 mr-2" />
            Desbanear
          </DropdownMenuItem>
        ) : (
          <DropdownMenuItem onClick={banUser} className="text-destructive">
            <Ban className="h-4 w-4 mr-2" />
            Banear usuario
          </DropdownMenuItem>
        )}
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

Las acciones disponibles son:

AccionMetodoDescripción
Hacer adminauthClient.admin.setRole({ userId, role: "admin" })Promueve a un usuario al rol admin
Quitar adminauthClient.admin.setRole({ userId, role: "user" })Revierte al rol user
BanearauthClient.admin.banUser({ userId })Banea al usuario
DesbanearauthClient.admin.unbanUser({ userId })Desbanea al usuario

Despues de cada accion, la página se recarga con window.location.reload() para mostrar los datos actualizados.

Personalizacion

Conectar estadisticas reales

Reemplaza los valores placeholder en app/(admin)/admin/page.tsx con queries de Prisma:

const totalUsers = await db.user.count();
const activeSubscriptions = await db.subscription.count({
  where: { status: "active" },
});

Añadir nuevas páginas admin

  1. Crea la página en app/(admin)/admin/tu-pagina/page.tsx
  2. Añade la ruta al array adminNavItems en app/(admin)/layout.tsx:
const adminNavItems = [
  { href: "/admin", label: "Overview", icon: BarChart3 },
  { href: "/admin/users", label: "Usuarios", icon: Users },
  { href: "/admin/tu-pagina", label: "Tu Página", icon: TuIcono },
  { href: "/admin/settings", label: "Configuración", icon: Settings },
];

Añadir nuevas acciones de usuario

Extiende el dropdown en user-actions.tsx:

<DropdownMenuItem onClick={() => handleAction(tuAccion, "Mensaje de exito")}>
  <TuIcono className="h-4 w-4 mr-2" />
  Tu acción personalizada
</DropdownMenuItem>

Seguridad

  • Las rutas admin deben protegerse verificando session.user.role === "admin" en el layout o proxy
  • Las acciones de usuario usan authClient.admin que verifica el rol en el servidor a traves de Better Auth
  • Solo existen dos roles: admin y user

On this page