瀏覽代碼

wtf is my uni thinking

Matthew Trejo 1 月之前
父節點
當前提交
847cb2f3dc

+ 77 - 0
docs/DUAL_AUTH_IMPLEMENTATION.md

@@ -0,0 +1,77 @@
+# Implementación Autenticación Dual
+
+## Objetivo
+- PACIENTES: Solo API UTB
+- DOCTORES/ADMINS: Credenciales locales (BD)
+
+## Estado: ✅ COMPLETADO
+
+---
+
+## Cambios Realizados
+
+### 1. `src/lib/auth.ts`
+- ✅ Importar bcrypt
+- ✅ Paso 1: Intentar API UTB solo para PACIENTES
+- ✅ Paso 2: Autenticación local para DOCTOR/ADMIN
+- ✅ Validación de contraseña con bcrypt
+
+### 2. `src/app/api/auth/register/route.ts`
+- ✅ Validar rol (solo DOCTOR/ADMIN)
+- ✅ Marcar `isExternalAuth: false`
+- ✅ Cambiar rol por defecto a DOCTOR
+
+### 3. `scripts/seed-admin.ts`
+- ✅ Usuario con identificación `0000000001` y username `0000000001-ADMIN`
+- ✅ Usuario con identificación `0000000002` y username `0000000002-DOCTOR`
+- ✅ Formato consistente con estudiantes: `identificacion-ROL`
+- ✅ Marcar `isExternalAuth: false`
+- ✅ Script ejecutado exitosamente
+
+### 4. `src/app/auth/login/page.tsx`
+- ✅ Banner dual explicando tipos de usuario
+
+### 5. `src/app/api/account/update/route.ts`
+- ✅ Importar bcrypt
+- ✅ Leer campo `isExternalAuth` del usuario
+- ✅ Si UTB: bloquear cambio contraseña
+- ✅ Si local: permitir cambio con validación bcrypt
+
+### 6. Componentes de cuenta
+- ✅ `PersonalInfoSection`: Prop `isExternalAuth`, campos condicionales
+- ✅ `PasswordChangeSection`: Formulario completo si local, mensaje si UTB
+- ✅ `page.tsx`: Pasar `isExternalAuth` a componentes
+
+---
+
+## Usuarios Creados
+
+```
+Admin:  0000000001-ADMIN / admin123
+Doctor: 0000000002-DOCTOR / doctor123
+```
+
+---
+
+## Comportamiento
+
+### Estudiantes (PATIENT)
+- Login con credenciales UTB (ej: `1206706838-EST`)
+- Autenticación vía API UTB
+- Campos nombre/apellido bloqueados
+- Contraseña gestionada por UTB
+- `isExternalAuth: true`
+
+### Doctores/Admins (DOCTOR/ADMIN)
+- Login con formato: `identificacion-ROL` (ej: `0000000001-ADMIN`)
+- Autenticación vía bcrypt en BD
+- Campos nombre/apellido editables
+- Pueden cambiar contraseña
+- `isExternalAuth: false`
+
+---
+
+## Notas
+- No hay errores de compilación
+- Script de seeding listo para reutilizar
+- Sistema soporta credenciales mixtas

+ 36 - 0
package-lock.json

@@ -23,6 +23,7 @@
         "@radix-ui/react-select": "^2.2.5",
         "@radix-ui/react-separator": "^1.1.7",
         "@radix-ui/react-slot": "^1.2.3",
+        "@radix-ui/react-switch": "^1.2.6",
         "@radix-ui/react-tabs": "^1.1.13",
         "@radix-ui/react-toast": "^1.2.14",
         "@react-pdf/renderer": "^4.3.0",
@@ -3443,6 +3444,41 @@
         }
       }
     },
+    "node_modules/@radix-ui/react-switch": {
+      "version": "1.2.6",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
+      "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/primitive": "1.1.3",
+        "@radix-ui/react-compose-refs": "1.1.2",
+        "@radix-ui/react-context": "1.1.2",
+        "@radix-ui/react-primitive": "2.1.3",
+        "@radix-ui/react-use-controllable-state": "1.2.2",
+        "@radix-ui/react-use-previous": "1.1.1",
+        "@radix-ui/react-use-size": "1.1.1"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/primitive": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
+      "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
+      "license": "MIT"
+    },
     "node_modules/@radix-ui/react-tabs": {
       "version": "1.1.13",
       "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",

+ 1 - 0
package.json

@@ -38,6 +38,7 @@
     "@radix-ui/react-select": "^2.2.5",
     "@radix-ui/react-separator": "^1.1.7",
     "@radix-ui/react-slot": "^1.2.3",
+    "@radix-ui/react-switch": "^1.2.6",
     "@radix-ui/react-tabs": "^1.1.13",
     "@radix-ui/react-toast": "^1.2.14",
     "@react-pdf/renderer": "^4.3.0",

+ 3 - 0
prisma/migrations/20251028000000_add_soft_delete/migration.sql

@@ -0,0 +1,3 @@
+-- AlterTable
+ALTER TABLE "User" ADD COLUMN "isActive" BOOLEAN NOT NULL DEFAULT true;
+ALTER TABLE "User" ADD COLUMN "deletedAt" TIMESTAMP(3);

+ 2 - 0
prisma/schema.prisma

@@ -26,8 +26,10 @@ model User {
   medicalHistory String?
   allergies    String?
   currentMedications String?
+  isActive     Boolean  @default(true)
   createdAt    DateTime @default(now())
   updatedAt    DateTime @updatedAt
+  deletedAt    DateTime?
   
   // Relaciones
   records      Record[]

+ 62 - 0
scripts/seed-admin.ts

@@ -0,0 +1,62 @@
+import { PrismaClient } from '@prisma/client';
+import bcrypt from 'bcryptjs';
+
+const prisma = new PrismaClient();
+
+async function main() {
+  console.log('🌱 Iniciando seeding de usuarios administrativos...');
+
+  // Admin principal
+  const adminPassword = await bcrypt.hash('admin123', 12);
+  const adminIdentificacion = '0000000001';
+  const admin = await prisma.user.upsert({
+    where: { identificacion: adminIdentificacion },
+    update: {},
+    create: {
+      email: 'amesas@utb.edu.ec',
+      username: `${adminIdentificacion}-ADM`,
+      identificacion: adminIdentificacion,
+      name: 'Armando',
+      lastname: 'Mesas',
+      password: adminPassword,
+      role: 'ADMIN',
+      isExternalAuth: false,
+    },
+  });
+  console.log('✅ Admin creado:', admin.username);
+
+  // Doctor ejemplo
+  const doctorPassword = await bcrypt.hash('doctor123', 12);
+  const doctorIdentificacion = '0000000002';
+  const doctor = await prisma.user.upsert({
+    where: { identificacion: doctorIdentificacion },
+    update: {},
+    create: {
+      email: 'shoria@utb.edu.ec',
+      username: `${doctorIdentificacion}-DOC`,
+      identificacion: doctorIdentificacion,
+      name: 'Susana',
+      lastname: 'Horia',
+      password: doctorPassword,
+      role: 'DOCTOR',
+      isExternalAuth: false,
+    },
+  });
+  console.log('✅ Doctor creado:', doctor.username);
+
+  console.log('\n📋 Credenciales creadas:');
+  console.log('----------------------------');
+  console.log('Admin:  0000000001-ADM / admin123');
+  console.log('Doctor: 0000000002-DOC / doctor123');
+  console.log('----------------------------\n');
+}
+
+main()
+  .then(async () => {
+    await prisma.$disconnect();
+  })
+  .catch(async (e) => {
+    console.error(e);
+    await prisma.$disconnect();
+    process.exit(1);
+  });

+ 2 - 0
src/app/account/page.tsx

@@ -80,11 +80,13 @@ export default function AccountPage() {
               
               <PersonalInfoSection
                 formData={formData}
+                isExternalAuth={session.user.isExternalAuth || false}
                 onInputChange={handleInputChange}
               />
               
               <PasswordChangeSection
                 formData={formData}
+                isExternalAuth={session.user.isExternalAuth || false}
                 onInputChange={handleInputChange}
               />
             </div>

+ 180 - 0
src/app/admin/users/page.tsx

@@ -0,0 +1,180 @@
+"use client"
+
+import { useEffect, useState } from "react"
+import { useSession } from "next-auth/react"
+import { useRouter } from "next/navigation"
+import AuthenticatedLayout from "@/components/AuthenticatedLayout"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Input } from "@/components/ui/input"
+import { Users, Plus, Search, UserCog } from "lucide-react"
+import UsersTable from "@/components/admin/users/UsersTable"
+import CreateUserDialog from "@/components/admin/users/CreateUserDialog"
+import { genericToast } from "@/lib/notifications"
+
+interface User {
+  id: string
+  username: string
+  identificacion: string | null
+  name: string
+  lastname: string
+  email: string | null
+  role: string
+  isExternalAuth: boolean
+  isActive: boolean
+  deletedAt: Date | null
+  createdAt: Date
+  updatedAt: Date
+}
+
+export default function UsersManagementPage() {
+  const { data: session, status } = useSession()
+  const router = useRouter()
+  const [users, setUsers] = useState<User[]>([])
+  const [filteredUsers, setFilteredUsers] = useState<User[]>([])
+  const [loading, setLoading] = useState(true)
+  const [searchTerm, setSearchTerm] = useState("")
+  const [showDeleted, setShowDeleted] = useState(false)
+  const [createDialogOpen, setCreateDialogOpen] = useState(false)
+
+  useEffect(() => {
+    if (status === "unauthenticated") {
+      router.push("/auth/login")
+    } else if (session?.user?.role !== "ADMIN") {
+      router.push("/dashboard")
+    }
+  }, [status, session, router])
+
+  useEffect(() => {
+    if (session?.user?.role === "ADMIN") {
+      loadUsers()
+    }
+  }, [session, showDeleted])
+
+  useEffect(() => {
+    filterUsers()
+  }, [users, searchTerm])
+
+  const loadUsers = async () => {
+    try {
+      setLoading(true)
+      const response = await fetch(`/api/admin/users?includeDeleted=${showDeleted}`)
+      const data = await response.json()
+      
+      if (response.ok) {
+        setUsers(data.users)
+      } else {
+        genericToast.error("Error al cargar usuarios")
+      }
+    } catch (error) {
+      genericToast.error("Error al cargar usuarios")
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  const filterUsers = () => {
+    if (!searchTerm) {
+      setFilteredUsers(users)
+      return
+    }
+
+    const term = searchTerm.toLowerCase()
+    const filtered = users.filter(user =>
+      user.username.toLowerCase().includes(term) ||
+      user.name.toLowerCase().includes(term) ||
+      user.lastname.toLowerCase().includes(term) ||
+      user.email?.toLowerCase().includes(term) ||
+      user.identificacion?.toLowerCase().includes(term)
+    )
+    setFilteredUsers(filtered)
+  }
+
+  const handleUserCreated = () => {
+    loadUsers()
+    setCreateDialogOpen(false)
+  }
+
+  const handleUserUpdated = () => {
+    loadUsers()
+  }
+
+  if (status === "loading" || loading) {
+    return (
+      <div className="min-h-screen flex items-center justify-center">
+        <div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
+      </div>
+    )
+  }
+
+  if (session?.user?.role !== "ADMIN") {
+    return null
+  }
+
+  return (
+    <AuthenticatedLayout>
+      <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
+        {/* Header */}
+        <div className="mb-6">
+          <div className="flex items-center mb-2">
+            <UserCog className="w-8 h-8 text-blue-600 mr-3" />
+            <h1 className="text-3xl font-bold text-gray-900">Gestión de Usuarios</h1>
+          </div>
+          <p className="text-gray-600">Administra los usuarios del sistema</p>
+        </div>
+
+        {/* Filtros y acciones */}
+        <Card className="mb-6">
+          <CardContent className="pt-6">
+            <div className="flex flex-col md:flex-row gap-4">
+              <div className="flex-1 relative">
+                <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
+                <Input
+                  placeholder="Buscar por username, nombre, email o identificación..."
+                  value={searchTerm}
+                  onChange={(e) => setSearchTerm(e.target.value)}
+                  className="pl-10"
+                />
+              </div>
+              <div className="flex gap-2">
+                <Button
+                  variant={showDeleted ? "default" : "outline"}
+                  onClick={() => setShowDeleted(!showDeleted)}
+                >
+                  {showDeleted ? "Mostrar activos" : "Mostrar todos"}
+                </Button>
+                <Button onClick={() => setCreateDialogOpen(true)}>
+                  <Plus className="w-4 h-4 mr-2" />
+                  Nuevo Usuario
+                </Button>
+              </div>
+            </div>
+          </CardContent>
+        </Card>
+
+        {/* Tabla de usuarios */}
+        <Card>
+          <CardHeader>
+            <CardTitle className="flex items-center">
+              <Users className="w-5 h-5 mr-2" />
+              Usuarios ({filteredUsers.length})
+            </CardTitle>
+          </CardHeader>
+          <CardContent>
+            <UsersTable
+              users={filteredUsers}
+              onUserUpdated={handleUserUpdated}
+            />
+          </CardContent>
+        </Card>
+
+        {/* Dialog para crear usuario */}
+        <CreateUserDialog
+          open={createDialogOpen}
+          onOpenChange={setCreateDialogOpen}
+          onUserCreated={handleUserCreated}
+        />
+      </div>
+    </AuthenticatedLayout>
+  )
+}

+ 50 - 6
src/app/api/account/update/route.ts

@@ -1,5 +1,6 @@
 import { NextRequest, NextResponse } from "next/server"
 import { getServerSession } from "next-auth"
+import bcrypt from "bcryptjs"
 import { authOptions } from "@/lib/auth"
 import { prisma } from "@/lib/prisma"
 import fs from 'fs'
@@ -44,7 +45,11 @@ export async function POST(request: NextRequest) {
     // Obtener información del usuario actual
     const currentUser = await prisma.user.findUnique({
       where: { id: session.user.id },
-      select: { profileImage: true }
+      select: { 
+        profileImage: true,
+        password: true,
+        isExternalAuth: true
+      }
     })
 
     if (!currentUser) {
@@ -110,12 +115,51 @@ export async function POST(request: NextRequest) {
       updateData.currentMedications = currentMedications || undefined
     }
 
-    // Los cambios de contraseña no están permitidos - todos los usuarios son UTB
+    // Gestión de contraseñas según tipo de usuario
     if (currentPassword || newPassword) {
-      return NextResponse.json(
-        { error: "La contraseña se gestiona a través de SAI UTB" },
-        { status: 400 }
-      )
+      // Usuarios UTB no pueden cambiar contraseña
+      if (currentUser.isExternalAuth) {
+        return NextResponse.json(
+          { error: "La contraseña se gestiona a través de SAI UTB" },
+          { status: 400 }
+        )
+      }
+
+      // Usuarios locales (DOCTOR/ADMIN) pueden cambiar contraseña
+      if (currentPassword && newPassword) {
+        // Validar contraseña actual
+        if (!currentUser.password) {
+          return NextResponse.json(
+            { error: "Usuario sin contraseña configurada" },
+            { status: 400 }
+          )
+        }
+
+        const isValidPassword = await bcrypt.compare(currentPassword, currentUser.password)
+        
+        if (!isValidPassword) {
+          return NextResponse.json(
+            { error: "Contraseña actual incorrecta" },
+            { status: 400 }
+          )
+        }
+
+        // Validar nueva contraseña
+        if (newPassword.length < 6) {
+          return NextResponse.json(
+            { error: "La nueva contraseña debe tener al menos 6 caracteres" },
+            { status: 400 }
+          )
+        }
+
+        // Hash nueva contraseña
+        updateData.password = await bcrypt.hash(newPassword, 12)
+      } else {
+        return NextResponse.json(
+          { error: "Se requieren ambas contraseñas (actual y nueva)" },
+          { status: 400 }
+        )
+      }
     }
 
     // Procesar imagen de perfil si se proporciona

+ 33 - 0
src/app/api/admin/users/[id]/restore/route.ts

@@ -0,0 +1,33 @@
+import { NextRequest, NextResponse } from "next/server"
+import { getServerSession } from "next-auth"
+import { authOptions } from "@/lib/auth"
+import { prisma } from "@/lib/prisma"
+
+// POST - Restaurar usuario
+export async function POST(
+  request: NextRequest,
+  { params }: { params: Promise<{ id: string }> }
+) {
+  try {
+    const session = await getServerSession(authOptions)
+
+    if (!session?.user || session.user.role !== 'ADMIN') {
+      return NextResponse.json({ error: "No autorizado" }, { status: 403 })
+    }
+
+    const { id } = await params
+
+    const user = await prisma.user.update({
+      where: { id },
+      data: {
+        isActive: true,
+        deletedAt: null
+      }
+    })
+
+    return NextResponse.json({ message: "Usuario restaurado", user })
+  } catch (error) {
+    console.error("Error al restaurar usuario:", error)
+    return NextResponse.json({ error: "Error interno" }, { status: 500 })
+  }
+}

+ 136 - 0
src/app/api/admin/users/[id]/route.ts

@@ -0,0 +1,136 @@
+import { NextRequest, NextResponse } from "next/server"
+import { getServerSession } from "next-auth"
+import bcrypt from "bcryptjs"
+import { authOptions } from "@/lib/auth"
+import { prisma } from "@/lib/prisma"
+
+// GET - Obtener usuario por ID
+export async function GET(
+  request: NextRequest,
+  { params }: { params: Promise<{ id: string }> }
+) {
+  try {
+    const session = await getServerSession(authOptions)
+
+    if (!session?.user || session.user.role !== 'ADMIN') {
+      return NextResponse.json({ error: "No autorizado" }, { status: 403 })
+    }
+
+    const { id } = await params
+
+    const user = await prisma.user.findUnique({
+      where: { id },
+      select: {
+        id: true,
+        username: true,
+        identificacion: true,
+        name: true,
+        lastname: true,
+        email: true,
+        role: true,
+        isExternalAuth: true,
+        isActive: true,
+        phone: true,
+        dateOfBirth: true,
+        gender: true,
+        createdAt: true,
+        updatedAt: true,
+        deletedAt: true,
+      }
+    })
+
+    if (!user) {
+      return NextResponse.json({ error: "Usuario no encontrado" }, { status: 404 })
+    }
+
+    return NextResponse.json({ user })
+  } catch (error) {
+    console.error("Error al obtener usuario:", error)
+    return NextResponse.json({ error: "Error interno" }, { status: 500 })
+  }
+}
+
+// PUT - Actualizar usuario
+export async function PUT(
+  request: NextRequest,
+  { params }: { params: Promise<{ id: string }> }
+) {
+  try {
+    const session = await getServerSession(authOptions)
+
+    if (!session?.user || session.user.role !== 'ADMIN') {
+      return NextResponse.json({ error: "No autorizado" }, { status: 403 })
+    }
+
+    const { id } = await params
+    const body = await request.json()
+    const { name, lastname, email, role, isActive, password } = body
+
+    const updateData: Record<string, string | boolean | null | undefined> = {}
+
+    if (name !== undefined) updateData.name = name
+    if (lastname !== undefined) updateData.lastname = lastname
+    if (email !== undefined) updateData.email = email || null
+    if (role !== undefined) updateData.role = role
+    if (isActive !== undefined) updateData.isActive = isActive
+    if (password) {
+      updateData.password = await bcrypt.hash(password, 12)
+    }
+
+    const user = await prisma.user.update({
+      where: { id },
+      data: updateData,
+      select: {
+        id: true,
+        username: true,
+        name: true,
+        lastname: true,
+        email: true,
+        role: true,
+        isActive: true,
+      }
+    })
+
+    return NextResponse.json({ message: "Usuario actualizado", user })
+  } catch (error) {
+    console.error("Error al actualizar usuario:", error)
+    return NextResponse.json({ error: "Error interno" }, { status: 500 })
+  }
+}
+
+// DELETE - Soft delete de usuario
+export async function DELETE(
+  request: NextRequest,
+  { params }: { params: Promise<{ id: string }> }
+) {
+  try {
+    const session = await getServerSession(authOptions)
+
+    if (!session?.user || session.user.role !== 'ADMIN') {
+      return NextResponse.json({ error: "No autorizado" }, { status: 403 })
+    }
+
+    const { id } = await params
+
+    // No permitir auto-eliminación
+    if (session.user.id === id) {
+      return NextResponse.json(
+        { error: "No puedes desactivar tu propia cuenta" },
+        { status: 400 }
+      )
+    }
+
+    const user = await prisma.user.update({
+      where: { id },
+      data: {
+        isActive: false,
+        deletedAt: new Date()
+      }
+    })
+
+    return NextResponse.json({ message: "Usuario desactivado", user })
+  } catch (error) {
+    console.error("Error al desactivar usuario:", error)
+    return NextResponse.json({ error: "Error interno" }, { status: 500 })
+  }
+}

+ 109 - 0
src/app/api/admin/users/create/route.ts

@@ -0,0 +1,109 @@
+import { NextRequest, NextResponse } from "next/server"
+import { getServerSession } from "next-auth"
+import bcrypt from "bcryptjs"
+import { authOptions } from "@/lib/auth"
+import { prisma } from "@/lib/prisma"
+
+// POST - Crear usuario (solo DOCTOR/ADMIN)
+export async function POST(request: NextRequest) {
+  try {
+    const session = await getServerSession(authOptions)
+
+    if (!session?.user || session.user.role !== 'ADMIN') {
+      return NextResponse.json({ error: "No autorizado" }, { status: 403 })
+    }
+
+    const body = await request.json()
+    const { username, identificacion, name, lastname, email, password, role } = body
+
+    // Validaciones
+    if (!username || !name || !lastname || !password || !role) {
+      return NextResponse.json(
+        { error: "Campos requeridos: username, name, lastname, password, role" },
+        { status: 400 }
+      )
+    }
+
+    if (role !== 'DOCTOR' && role !== 'ADMIN') {
+      return NextResponse.json(
+        { error: "Solo se pueden crear usuarios DOCTOR o ADMIN" },
+        { status: 400 }
+      )
+    }
+
+    if (password.length < 6) {
+      return NextResponse.json(
+        { error: "La contraseña debe tener al menos 6 caracteres" },
+        { status: 400 }
+      )
+    }
+
+    // Verificar username único
+    const existingUsername = await prisma.user.findUnique({
+      where: { username }
+    })
+
+    if (existingUsername) {
+      return NextResponse.json(
+        { error: "El username ya existe" },
+        { status: 400 }
+      )
+    }
+
+    // Verificar email único si se proporciona
+    if (email) {
+      const existingEmail = await prisma.user.findUnique({
+        where: { email }
+      })
+
+      if (existingEmail) {
+        return NextResponse.json(
+          { error: "El email ya existe" },
+          { status: 400 }
+        )
+      }
+    }
+
+    // Verificar identificación única si se proporciona
+    if (identificacion) {
+      const existingIdentificacion = await prisma.user.findUnique({
+        where: { identificacion }
+      })
+
+      if (existingIdentificacion) {
+        return NextResponse.json(
+          { error: "La identificación ya existe" },
+          { status: 400 }
+        )
+      }
+    }
+
+    const hashedPassword = await bcrypt.hash(password, 12)
+
+    const user = await prisma.user.create({
+      data: {
+        username,
+        identificacion: identificacion || null,
+        name,
+        lastname,
+        email: email || null,
+        password: hashedPassword,
+        role,
+        isExternalAuth: false,
+      },
+      select: {
+        id: true,
+        username: true,
+        name: true,
+        lastname: true,
+        email: true,
+        role: true,
+      }
+    })
+
+    return NextResponse.json({ message: "Usuario creado", user }, { status: 201 })
+  } catch (error) {
+    console.error("Error al crear usuario:", error)
+    return NextResponse.json({ error: "Error interno" }, { status: 500 })
+  }
+}

+ 42 - 0
src/app/api/admin/users/route.ts

@@ -0,0 +1,42 @@
+import { NextRequest, NextResponse } from "next/server"
+import { getServerSession } from "next-auth"
+import { authOptions } from "@/lib/auth"
+import { prisma } from "@/lib/prisma"
+
+// GET - Listar usuarios
+export async function GET(request: NextRequest) {
+  try {
+    const session = await getServerSession(authOptions)
+
+    if (!session?.user || session.user.role !== 'ADMIN') {
+      return NextResponse.json({ error: "No autorizado" }, { status: 403 })
+    }
+
+    const { searchParams } = new URL(request.url)
+    const includeDeleted = searchParams.get('includeDeleted') === 'true'
+
+    const users = await prisma.user.findMany({
+      where: includeDeleted ? {} : { isActive: true },
+      select: {
+        id: true,
+        username: true,
+        identificacion: true,
+        name: true,
+        lastname: true,
+        email: true,
+        role: true,
+        isExternalAuth: true,
+        isActive: true,
+        deletedAt: true,
+        createdAt: true,
+        updatedAt: true,
+      },
+      orderBy: { createdAt: 'desc' }
+    })
+
+    return NextResponse.json({ users })
+  } catch (error) {
+    console.error("Error al listar usuarios:", error)
+    return NextResponse.json({ error: "Error interno" }, { status: 500 })
+  }
+}

+ 12 - 3
src/app/api/auth/register/route.ts

@@ -22,6 +22,14 @@ export async function POST(request: NextRequest) {
       )
     }
 
+    // Solo permitir registro de DOCTOR o ADMIN (pacientes usan API UTB)
+    if (role && role !== 'DOCTOR' && role !== 'ADMIN') {
+      return NextResponse.json(
+        { error: "Solo se pueden registrar usuarios DOCTOR o ADMIN. Los pacientes deben usar credenciales UTB." },
+        { status: 403 }
+      )
+    }
+
     // Verificar si el email ya existe
     const existingUser = await prisma.user.findUnique({
       where: { email }
@@ -45,7 +53,7 @@ export async function POST(request: NextRequest) {
     // Encriptar contraseña
     const hashedPassword = await bcrypt.hash(password, 12)
 
-    // Crear usuario
+    // Crear usuario (isExternalAuth = false para autenticación local)
     const user = await prisma.user.create({
       data: {
         name: firstName,
@@ -53,11 +61,12 @@ export async function POST(request: NextRequest) {
         username,
         email,
         password: hashedPassword,
-        role: role || "PATIENT"
+        role: role || "DOCTOR",
+        isExternalAuth: false
       }
     })
 
-    console.log(`Usuario creado: ${user.email} (${user.role})`)
+    console.log(`Usuario local creado: ${user.email} (${user.role})`)
 
     return NextResponse.json(
       { 

+ 17 - 7
src/app/auth/login/page.tsx

@@ -132,13 +132,23 @@ export default function LoginPage() {
             </div>
           </form>
 
-          <div className="mt-6 p-4 bg-blue-50 rounded-lg">
-            <div className="flex items-center">
-              <AlertCircle className="w-4 h-4 text-blue-600 mr-2" />
-              <p className="text-sm text-blue-800">
-                <strong>Acceso UTB:</strong> Usa tus credenciales institucionales 
-                (ejemplo: 1206706838-EST)
-              </p>
+          <div className="mt-6 p-4 bg-blue-50 rounded-lg border border-blue-200">
+            <div className="flex items-start mb-3">
+              <AlertCircle className="w-5 h-5 text-blue-600 mr-2 mt-0.5 flex-shrink-0" />
+              <div className="space-y-2 text-sm">
+                <div>
+                  <p className="font-semibold text-blue-900">Estudiantes:</p>
+                  <p className="text-blue-800">
+                    Usa tus credenciales UTB (ejemplo: 1206706838-EST)
+                  </p>
+                </div>
+                <div>
+                  <p className="font-semibold text-blue-900">Doctores/Administradores:</p>
+                  <p className="text-blue-800">
+                    Usa tu email y contraseña registrados en el sistema
+                  </p>
+                </div>
+              </div>
             </div>
           </div>
         </CardContent>

+ 95 - 19
src/components/account/PasswordChangeSection.tsx

@@ -1,19 +1,61 @@
 "use client"
 
 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
 import { Lock, AlertCircle, ExternalLink } from "lucide-react"
 
 interface PasswordChangeSectionProps {
-  // Props mantenidas por compatibilidad pero no utilizadas
   formData?: {
     currentPassword: string
     newPassword: string
     confirmPassword: string
   }
+  isExternalAuth: boolean
   onInputChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
 }
 
-export default function PasswordChangeSection({}: PasswordChangeSectionProps) {
+export default function PasswordChangeSection({
+  formData,
+  isExternalAuth,
+  onInputChange
+}: PasswordChangeSectionProps) {
+  // Usuarios UTB: solo mostrar mensaje
+  if (isExternalAuth) {
+    return (
+      <Card>
+        <CardHeader>
+          <CardTitle className="flex items-center">
+            <Lock className="w-5 h-5 mr-2 text-primary" />
+            Cambiar Contraseña
+          </CardTitle>
+        </CardHeader>
+        <CardContent>
+          <div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg flex items-start gap-2">
+            <AlertCircle className="w-5 h-5 text-yellow-600 mt-0.5 flex-shrink-0" />
+            <div className="text-sm text-yellow-800">
+              <p className="font-medium">Contraseña gestionada por UTB</p>
+              <p className="mt-1">
+                Tu contraseña es la misma que usas para acceder al sistema de la universidad.{" "}
+                Para cambiarla, dirígete a{" "}
+                <a 
+                  href="https://sai.utb.edu.ec" 
+                  target="_blank" 
+                  rel="noopener noreferrer"
+                  className="inline-flex items-center gap-1 underline hover:text-yellow-900 font-medium"
+                >
+                  SAI UTB
+                  <ExternalLink className="w-3 h-3" />
+                </a>
+              </p>
+            </div>
+          </div>
+        </CardContent>
+      </Card>
+    )
+  }
+
+  // Usuarios locales (DOCTOR/ADMIN): formulario de cambio de contraseña
   return (
     <Card>
       <CardHeader>
@@ -23,24 +65,58 @@ export default function PasswordChangeSection({}: PasswordChangeSectionProps) {
         </CardTitle>
       </CardHeader>
       <CardContent>
-        <div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg flex items-start gap-2">
-          <AlertCircle className="w-5 h-5 text-yellow-600 mt-0.5 flex-shrink-0" />
-          <div className="text-sm text-yellow-800">
-            <p className="font-medium">Contraseña gestionada por UTB</p>
-            <p className="mt-1">
-              Tu contraseña es la misma que usas para acceder al sistema de la universidad.{" "}
-              Para cambiarla, dirígete a{" "}
-              <a 
-                href="https://sai.utb.edu.ec" 
-                target="_blank" 
-                rel="noopener noreferrer"
-                className="inline-flex items-center gap-1 underline hover:text-yellow-900 font-medium"
-              >
-                SAI UTB
-                <ExternalLink className="w-3 h-3" />
-              </a>
-            </p>
+        <div className="space-y-4">
+          <div className="space-y-2">
+            <Label htmlFor="currentPassword">Contraseña Actual</Label>
+            <div className="relative">
+              <Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
+              <Input
+                id="currentPassword"
+                name="currentPassword"
+                type="password"
+                value={formData?.currentPassword || ''}
+                onChange={onInputChange}
+                className="pl-10"
+                placeholder="Ingresa tu contraseña actual"
+              />
+            </div>
+          </div>
+
+          <div className="space-y-2">
+            <Label htmlFor="newPassword">Nueva Contraseña</Label>
+            <div className="relative">
+              <Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
+              <Input
+                id="newPassword"
+                name="newPassword"
+                type="password"
+                value={formData?.newPassword || ''}
+                onChange={onInputChange}
+                className="pl-10"
+                placeholder="Mínimo 6 caracteres"
+              />
+            </div>
+          </div>
+
+          <div className="space-y-2">
+            <Label htmlFor="confirmPassword">Confirmar Nueva Contraseña</Label>
+            <div className="relative">
+              <Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
+              <Input
+                id="confirmPassword"
+                name="confirmPassword"
+                type="password"
+                value={formData?.confirmPassword || ''}
+                onChange={onInputChange}
+                className="pl-10"
+                placeholder="Confirma tu nueva contraseña"
+              />
+            </div>
           </div>
+
+          <p className="text-sm text-gray-500">
+            La contraseña debe tener al menos 6 caracteres.
+          </p>
         </div>
       </CardContent>
     </Card>

+ 20 - 16
src/components/account/PersonalInfoSection.tsx

@@ -13,11 +13,13 @@ interface PersonalInfoSectionProps {
     identificacion?: string
     dateOfBirth?: string
   }
+  isExternalAuth: boolean
   onInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void
 }
 
 export default function PersonalInfoSection({
   formData,
+  isExternalAuth,
   onInputChange
 }: PersonalInfoSectionProps) {
   return (
@@ -29,15 +31,17 @@ export default function PersonalInfoSection({
         </CardTitle>
       </CardHeader>
       <CardContent>
-        <div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg flex items-start gap-2">
-          <AlertCircle className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" />
-          <div className="text-sm text-blue-800">
-            <p className="font-medium">Datos proporcionados por la universidad</p>
-            <p className="mt-1">
-              Nombre, apellido y fecha de nacimiento son gestionados por la universidad.{" "}
-            </p>
+        {isExternalAuth && (
+          <div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg flex items-start gap-2">
+            <AlertCircle className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" />
+            <div className="text-sm text-blue-800">
+              <p className="font-medium">Datos proporcionados por la universidad</p>
+              <p className="mt-1">
+                Nombre, apellido y fecha de nacimiento son gestionados por la universidad.{" "}
+              </p>
+            </div>
           </div>
-        </div>
+        )}
         
         <div className="space-y-6">
           {/* Identificación (solo lectura) */}
@@ -57,7 +61,7 @@ export default function PersonalInfoSection({
             </div>
           )}
 
-          {/* Nombre (bloqueado) */}
+          {/* Nombre */}
           <div className="space-y-2">
             <Label htmlFor="name">Nombre</Label>
             <div className="relative">
@@ -68,14 +72,14 @@ export default function PersonalInfoSection({
                 type="text"
                 value={formData.name}
                 onChange={onInputChange}
-                className="pl-10 bg-gray-50"
-                disabled
-                readOnly
+                className={`pl-10 ${isExternalAuth ? 'bg-gray-50' : ''}`}
+                disabled={isExternalAuth}
+                readOnly={isExternalAuth}
               />
             </div>
           </div>
 
-          {/* Apellido (bloqueado) */}
+          {/* Apellido */}
           <div className="space-y-2">
             <Label htmlFor="lastname">Apellido</Label>
             <div className="relative">
@@ -86,9 +90,9 @@ export default function PersonalInfoSection({
                 type="text"
                 value={formData.lastname}
                 onChange={onInputChange}
-                className="pl-10 bg-gray-50"
-                disabled
-                readOnly
+                className={`pl-10 ${isExternalAuth ? 'bg-gray-50' : ''}`}
+                disabled={isExternalAuth}
+                readOnly={isExternalAuth}
               />
             </div>
           </div>

+ 192 - 0
src/components/admin/users/CreateUserDialog.tsx

@@ -0,0 +1,192 @@
+"use client"
+
+import { useState } from "react"
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+import { genericToast } from "@/lib/notifications"
+
+interface CreateUserDialogProps {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  onUserCreated: () => void
+}
+
+export default function CreateUserDialog({ open, onOpenChange, onUserCreated }: CreateUserDialogProps) {
+  const [loading, setLoading] = useState(false)
+  const [formData, setFormData] = useState({
+    username: "",
+    identificacion: "",
+    name: "",
+    lastname: "",
+    email: "",
+    password: "",
+    confirmPassword: "",
+    role: "DOCTOR",
+  })
+
+  const handleSubmit = async (e: React.FormEvent) => {
+    e.preventDefault()
+
+    if (formData.password !== formData.confirmPassword) {
+      genericToast.error("Las contraseñas no coinciden")
+      return
+    }
+
+    if (formData.password.length < 6) {
+      genericToast.error("La contraseña debe tener al menos 6 caracteres")
+      return
+    }
+
+    try {
+      setLoading(true)
+      const response = await fetch("/api/admin/users/create", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({
+          username: formData.username,
+          identificacion: formData.identificacion || null,
+          name: formData.name,
+          lastname: formData.lastname,
+          email: formData.email || null,
+          password: formData.password,
+          role: formData.role,
+        }),
+      })
+
+      const data = await response.json()
+
+      if (response.ok) {
+        genericToast.success("Usuario creado exitosamente")
+        setFormData({
+          username: "",
+          identificacion: "",
+          name: "",
+          lastname: "",
+          email: "",
+          password: "",
+          confirmPassword: "",
+          role: "DOCTOR",
+        })
+        onUserCreated()
+      } else {
+        genericToast.error(data.error || "Error al crear usuario")
+      }
+    } catch (error) {
+      genericToast.error("Error al crear usuario")
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="max-w-md">
+        <DialogHeader>
+          <DialogTitle>Crear Nuevo Usuario</DialogTitle>
+        </DialogHeader>
+        <form onSubmit={handleSubmit} className="space-y-4">
+          <div className="space-y-2">
+            <Label htmlFor="username">Username *</Label>
+            <Input
+              id="username"
+              value={formData.username}
+              onChange={(e) => setFormData({ ...formData, username: e.target.value })}
+              placeholder="ej: 0000000003-DOCTOR"
+              required
+            />
+          </div>
+
+          <div className="space-y-2">
+            <Label htmlFor="identificacion">Identificación</Label>
+            <Input
+              id="identificacion"
+              value={formData.identificacion}
+              onChange={(e) => setFormData({ ...formData, identificacion: e.target.value })}
+              placeholder="ej: 0000000003"
+            />
+          </div>
+
+          <div className="grid grid-cols-2 gap-4">
+            <div className="space-y-2">
+              <Label htmlFor="name">Nombre *</Label>
+              <Input
+                id="name"
+                value={formData.name}
+                onChange={(e) => setFormData({ ...formData, name: e.target.value })}
+                required
+              />
+            </div>
+            <div className="space-y-2">
+              <Label htmlFor="lastname">Apellido *</Label>
+              <Input
+                id="lastname"
+                value={formData.lastname}
+                onChange={(e) => setFormData({ ...formData, lastname: e.target.value })}
+                required
+              />
+            </div>
+          </div>
+
+          <div className="space-y-2">
+            <Label htmlFor="email">Email</Label>
+            <Input
+              id="email"
+              type="email"
+              value={formData.email}
+              onChange={(e) => setFormData({ ...formData, email: e.target.value })}
+              placeholder="opcional"
+            />
+          </div>
+
+          <div className="space-y-2">
+            <Label htmlFor="role">Rol *</Label>
+            <Select value={formData.role} onValueChange={(value) => setFormData({ ...formData, role: value })}>
+              <SelectTrigger>
+                <SelectValue />
+              </SelectTrigger>
+              <SelectContent>
+                <SelectItem value="DOCTOR">Doctor</SelectItem>
+                <SelectItem value="ADMIN">Admin</SelectItem>
+              </SelectContent>
+            </Select>
+          </div>
+
+          <div className="space-y-2">
+            <Label htmlFor="password">Contraseña *</Label>
+            <Input
+              id="password"
+              type="password"
+              value={formData.password}
+              onChange={(e) => setFormData({ ...formData, password: e.target.value })}
+              placeholder="Mínimo 6 caracteres"
+              required
+            />
+          </div>
+
+          <div className="space-y-2">
+            <Label htmlFor="confirmPassword">Confirmar Contraseña *</Label>
+            <Input
+              id="confirmPassword"
+              type="password"
+              value={formData.confirmPassword}
+              onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
+              required
+            />
+          </div>
+
+          <div className="flex gap-2 pt-4">
+            <Button type="button" variant="outline" onClick={() => onOpenChange(false)} className="flex-1">
+              Cancelar
+            </Button>
+            <Button type="submit" disabled={loading} className="flex-1">
+              {loading ? "Creando..." : "Crear Usuario"}
+            </Button>
+          </div>
+        </form>
+      </DialogContent>
+    </Dialog>
+  )
+}

+ 211 - 0
src/components/admin/users/EditUserDialog.tsx

@@ -0,0 +1,211 @@
+"use client"
+
+import { useState, useEffect } from "react"
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+import { Switch } from "@/components/ui/switch"
+import { genericToast } from "@/lib/notifications"
+
+interface User {
+  id: string
+  username: string
+  identificacion: string | null
+  name: string
+  lastname: string
+  email: string | null
+  role: string
+  isExternalAuth: boolean
+  isActive: boolean
+}
+
+interface EditUserDialogProps {
+  user: User
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  onUserUpdated: () => void
+}
+
+export default function EditUserDialog({ user, open, onOpenChange, onUserUpdated }: EditUserDialogProps) {
+  const [loading, setLoading] = useState(false)
+  const [formData, setFormData] = useState({
+    name: user.name,
+    lastname: user.lastname,
+    email: user.email || "",
+    role: user.role,
+    isActive: user.isActive,
+    password: "",
+    confirmPassword: "",
+  })
+
+  useEffect(() => {
+    setFormData({
+      name: user.name,
+      lastname: user.lastname,
+      email: user.email || "",
+      role: user.role,
+      isActive: user.isActive,
+      password: "",
+      confirmPassword: "",
+    })
+  }, [user])
+
+  const handleSubmit = async (e: React.FormEvent) => {
+    e.preventDefault()
+
+    if (formData.password && formData.password !== formData.confirmPassword) {
+      genericToast.error("Las contraseñas no coinciden")
+      return
+    }
+
+    if (formData.password && formData.password.length < 6) {
+      genericToast.error("La contraseña debe tener al menos 6 caracteres")
+      return
+    }
+
+    try {
+      setLoading(true)
+      const response = await fetch(`/api/admin/users/${user.id}`, {
+        method: "PUT",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({
+          name: formData.name,
+          lastname: formData.lastname,
+          email: formData.email || null,
+          role: formData.role,
+          isActive: formData.isActive,
+          password: formData.password || undefined,
+        }),
+      })
+
+      const data = await response.json()
+
+      if (response.ok) {
+        genericToast.success("Usuario actualizado")
+        onUserUpdated()
+      } else {
+        genericToast.error(data.error || "Error al actualizar usuario")
+      }
+    } catch (error) {
+      genericToast.error("Error al actualizar usuario")
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="max-w-md">
+        <DialogHeader>
+          <DialogTitle>Editar Usuario</DialogTitle>
+        </DialogHeader>
+        <form onSubmit={handleSubmit} className="space-y-4">
+          <div className="space-y-2">
+            <Label>Username</Label>
+            <Input value={user.username} disabled className="bg-gray-50" />
+          </div>
+
+          {user.identificacion && (
+            <div className="space-y-2">
+              <Label>Identificación</Label>
+              <Input value={user.identificacion} disabled className="bg-gray-50" />
+            </div>
+          )}
+
+          <div className="grid grid-cols-2 gap-4">
+            <div className="space-y-2">
+              <Label htmlFor="name">Nombre</Label>
+              <Input
+                id="name"
+                value={formData.name}
+                onChange={(e) => setFormData({ ...formData, name: e.target.value })}
+                disabled={user.isExternalAuth}
+                className={user.isExternalAuth ? "bg-gray-50" : ""}
+              />
+            </div>
+            <div className="space-y-2">
+              <Label htmlFor="lastname">Apellido</Label>
+              <Input
+                id="lastname"
+                value={formData.lastname}
+                onChange={(e) => setFormData({ ...formData, lastname: e.target.value })}
+                disabled={user.isExternalAuth}
+                className={user.isExternalAuth ? "bg-gray-50" : ""}
+              />
+            </div>
+          </div>
+
+          <div className="space-y-2">
+            <Label htmlFor="email">Email</Label>
+            <Input
+              id="email"
+              type="email"
+              value={formData.email}
+              onChange={(e) => setFormData({ ...formData, email: e.target.value })}
+            />
+          </div>
+
+          <div className="space-y-2">
+            <Label htmlFor="role">Rol</Label>
+            <Select value={formData.role} onValueChange={(value) => setFormData({ ...formData, role: value })}>
+              <SelectTrigger>
+                <SelectValue />
+              </SelectTrigger>
+              <SelectContent>
+                <SelectItem value="ADMIN">Admin</SelectItem>
+                <SelectItem value="DOCTOR">Doctor</SelectItem>
+                <SelectItem value="PATIENT">Paciente</SelectItem>
+              </SelectContent>
+            </Select>
+          </div>
+
+          {!user.isExternalAuth && (
+            <>
+              <div className="space-y-2">
+                <Label htmlFor="password">Nueva Contraseña</Label>
+                <Input
+                  id="password"
+                  type="password"
+                  value={formData.password}
+                  onChange={(e) => setFormData({ ...formData, password: e.target.value })}
+                  placeholder="Dejar vacío para no cambiar"
+                />
+              </div>
+
+              <div className="space-y-2">
+                <Label htmlFor="confirmPassword">Confirmar Contraseña</Label>
+                <Input
+                  id="confirmPassword"
+                  type="password"
+                  value={formData.confirmPassword}
+                  onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
+                  placeholder="Confirmar nueva contraseña"
+                />
+              </div>
+            </>
+          )}
+
+          <div className="flex items-center justify-between py-2">
+            <Label htmlFor="isActive">Usuario Activo</Label>
+            <Switch
+              id="isActive"
+              checked={formData.isActive}
+              onCheckedChange={(checked: boolean) => setFormData({ ...formData, isActive: checked })}
+            />
+          </div>
+
+          <div className="flex gap-2 pt-4">
+            <Button type="button" variant="outline" onClick={() => onOpenChange(false)} className="flex-1">
+              Cancelar
+            </Button>
+            <Button type="submit" disabled={loading} className="flex-1">
+              {loading ? "Guardando..." : "Guardar Cambios"}
+            </Button>
+          </div>
+        </form>
+      </DialogContent>
+    </Dialog>
+  )
+}

+ 164 - 0
src/components/admin/users/ToggleUserStatusDialog.tsx

@@ -0,0 +1,164 @@
+"use client"
+
+import { useState } from "react"
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { AlertTriangle, RotateCcw } from "lucide-react"
+import { genericToast } from "@/lib/notifications"
+
+interface User {
+  id: string
+  username: string
+  name: string
+  lastname: string
+  isActive: boolean
+}
+
+interface ToggleUserStatusDialogProps {
+  user: User | null
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  onStatusChanged: () => void
+}
+
+export default function ToggleUserStatusDialog({
+  user,
+  open,
+  onOpenChange,
+  onStatusChanged
+}: ToggleUserStatusDialogProps) {
+  const [loading, setLoading] = useState(false)
+
+  if (!user) return null
+
+  const isDeactivating = user.isActive
+
+  const handleConfirm = async () => {
+    try {
+      setLoading(true)
+
+      if (isDeactivating) {
+        // Desactivar
+        const response = await fetch(`/api/admin/users/${user.id}`, {
+          method: "DELETE",
+        })
+
+        if (response.ok) {
+          genericToast.success("Usuario desactivado exitosamente")
+          onStatusChanged()
+          onOpenChange(false)
+        } else {
+          const data = await response.json()
+          genericToast.error(data.error || "Error al desactivar usuario")
+        }
+      } else {
+        // Restaurar
+        const response = await fetch(`/api/admin/users/${user.id}/restore`, {
+          method: "POST",
+        })
+
+        if (response.ok) {
+          genericToast.success("Usuario restaurado exitosamente")
+          onStatusChanged()
+          onOpenChange(false)
+        } else {
+          genericToast.error("Error al restaurar usuario")
+        }
+      }
+    } catch (error) {
+      genericToast.error(isDeactivating ? "Error al desactivar usuario" : "Error al restaurar usuario")
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="max-w-md">
+        <DialogHeader>
+          <DialogTitle className="flex items-center gap-2">
+            {isDeactivating ? (
+              <>
+                <AlertTriangle className="w-5 h-5 text-orange-600" />
+                Desactivar Usuario
+              </>
+            ) : (
+              <>
+                <RotateCcw className="w-5 h-5 text-green-600" />
+                Restaurar Usuario
+              </>
+            )}
+          </DialogTitle>
+        </DialogHeader>
+
+        <div className="py-4">
+          {isDeactivating ? (
+            <div className="space-y-4">
+              <p className="text-gray-700">
+                ¿Estás seguro de que deseas desactivar al usuario{" "}
+                <span className="font-semibold">
+                  {user.name} {user.lastname}
+                </span>?
+              </p>
+              <div className="bg-orange-50 border border-orange-200 rounded-lg p-3">
+                <p className="text-sm text-orange-800">
+                  <strong>Nota:</strong> El usuario no podrá iniciar sesión, pero sus datos se conservarán.
+                  Podrás reactivarlo en cualquier momento.
+                </p>
+              </div>
+              <div className="bg-gray-50 border border-gray-200 rounded-lg p-3">
+                <p className="text-sm text-gray-600">
+                  <strong>Username:</strong> {user.username}
+                </p>
+              </div>
+            </div>
+          ) : (
+            <div className="space-y-4">
+              <p className="text-gray-700">
+                ¿Deseas restaurar al usuario{" "}
+                <span className="font-semibold">
+                  {user.name} {user.lastname}
+                </span>?
+              </p>
+              <div className="bg-green-50 border border-green-200 rounded-lg p-3">
+                <p className="text-sm text-green-800">
+                  El usuario podrá iniciar sesión nuevamente y acceder al sistema.
+                </p>
+              </div>
+              <div className="bg-gray-50 border border-gray-200 rounded-lg p-3">
+                <p className="text-sm text-gray-600">
+                  <strong>Username:</strong> {user.username}
+                </p>
+              </div>
+            </div>
+          )}
+        </div>
+
+        <DialogFooter>
+          <Button
+            type="button"
+            variant="outline"
+            onClick={() => onOpenChange(false)}
+            disabled={loading}
+          >
+            Cancelar
+          </Button>
+          <Button
+            type="button"
+            variant={isDeactivating ? "destructive" : "default"}
+            onClick={handleConfirm}
+            disabled={loading}
+          >
+            {loading ? (
+              "Procesando..."
+            ) : isDeactivating ? (
+              "Desactivar"
+            ) : (
+              "Restaurar"
+            )}
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  )
+}

+ 156 - 0
src/components/admin/users/UsersTable.tsx

@@ -0,0 +1,156 @@
+"use client"
+
+import { useState } from "react"
+import { Button } from "@/components/ui/button"
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableHead,
+  TableHeader,
+  TableRow,
+} from "@/components/ui/table"
+import { Badge } from "@/components/ui/badge"
+import { Pencil, Trash2, RotateCcw } from "lucide-react"
+import EditUserDialog from "./EditUserDialog"
+import ToggleUserStatusDialog from "./ToggleUserStatusDialog"
+
+interface User {
+  id: string
+  username: string
+  identificacion: string | null
+  name: string
+  lastname: string
+  email: string | null
+  role: string
+  isExternalAuth: boolean
+  isActive: boolean
+  deletedAt: Date | null
+}
+
+interface UsersTableProps {
+  users: User[]
+  onUserUpdated: () => void
+}
+
+export default function UsersTable({ users, onUserUpdated }: UsersTableProps) {
+  const [editingUser, setEditingUser] = useState<User | null>(null)
+  const [togglingUser, setTogglingUser] = useState<User | null>(null)
+
+  const getRoleBadge = (role: string) => {
+    const colors = {
+      ADMIN: "bg-purple-100 text-purple-800",
+      DOCTOR: "bg-blue-100 text-blue-800",
+      PATIENT: "bg-green-100 text-green-800",
+    }
+    return colors[role as keyof typeof colors] || "bg-gray-100 text-gray-800"
+  }
+
+  if (users.length === 0) {
+    return (
+      <div className="text-center py-8 text-gray-500">
+        No se encontraron usuarios
+      </div>
+    )
+  }
+
+  return (
+    <>
+      <div className="overflow-x-auto">
+        <Table>
+          <TableHeader>
+            <TableRow>
+              <TableHead>Username</TableHead>
+              <TableHead>Nombre</TableHead>
+              <TableHead>Email</TableHead>
+              <TableHead>Rol</TableHead>
+              <TableHead>Tipo</TableHead>
+              <TableHead>Estado</TableHead>
+              <TableHead className="text-right">Acciones</TableHead>
+            </TableRow>
+          </TableHeader>
+          <TableBody>
+            {users.map((user) => (
+              <TableRow key={user.id}>
+                <TableCell className="font-medium">{user.username}</TableCell>
+                <TableCell>
+                  {user.name} {user.lastname}
+                </TableCell>
+                <TableCell>{user.email || "-"}</TableCell>
+                <TableCell>
+                  <Badge className={getRoleBadge(user.role)}>{user.role}</Badge>
+                </TableCell>
+                <TableCell>
+                  <Badge variant={user.isExternalAuth ? "default" : "outline"}>
+                    {user.isExternalAuth ? "UTB" : "Local"}
+                  </Badge>
+                </TableCell>
+                <TableCell>
+                  {user.isActive ? (
+                    <Badge className="bg-green-100 text-green-800">Activo</Badge>
+                  ) : (
+                    <Badge variant="secondary">Inactivo</Badge>
+                  )}
+                </TableCell>
+                <TableCell className="text-right">
+                  <div className="flex justify-end gap-2">
+                    {user.isActive ? (
+                      <>
+                        <Button
+                          variant="ghost"
+                          size="sm"
+                          onClick={() => setEditingUser(user)}
+                        >
+                          <Pencil className="w-4 h-4" />
+                        </Button>
+                        {!user.isExternalAuth && (
+                          <Button
+                            variant="ghost"
+                            size="sm"
+                            onClick={() => setTogglingUser(user)}
+                          >
+                            <Trash2 className="w-4 h-4 text-red-600" />
+                          </Button>
+                        )}
+                      </>
+                    ) : (
+                      <Button
+                        variant="ghost"
+                        size="sm"
+                        onClick={() => setTogglingUser(user)}
+                      >
+                        <RotateCcw className="w-4 h-4 text-green-600" />
+                      </Button>
+                    )}
+                  </div>
+                </TableCell>
+              </TableRow>
+            ))}
+          </TableBody>
+        </Table>
+      </div>
+
+      {editingUser && (
+        <EditUserDialog
+          user={editingUser}
+          open={!!editingUser}
+          onOpenChange={(open: boolean) => !open && setEditingUser(null)}
+          onUserUpdated={() => {
+            setEditingUser(null)
+            onUserUpdated()
+          }}
+        />
+      )}
+
+      <ToggleUserStatusDialog
+        user={togglingUser}
+        open={!!togglingUser}
+        onOpenChange={(open: boolean) => !open && setTogglingUser(null)}
+        onStatusChanged={() => {
+          setTogglingUser(null)
+          onUserUpdated()
+        }}
+      />
+    </>
+  )
+}

+ 13 - 5
src/components/sidebar/SidebarNavigation.tsx

@@ -15,7 +15,8 @@ import {
   Sparkles,
   BookOpen,
   User,
-  CalendarDays
+  CalendarDays,
+  UserCog
 } from "lucide-react"
 import { COLOR_PALETTE } from "@/utils/palette"
 import { useAppointmentsBadge } from "@/hooks/useAppointmentsBadge"
@@ -55,7 +56,7 @@ export default function SidebarNavigation({ onItemClick, isCollapsed = false }:
     if (isAdmin) {
       if (currentPath === "/dashboard") {
         sectionsToExpand.push("General")
-      } else if (currentPath.startsWith("/admin") || currentPath.startsWith("/records")) {
+      } else if (currentPath.startsWith("/admin") || currentPath.startsWith("/records") || currentPath.startsWith("/appointments")) {
         sectionsToExpand.push("Administración")
       }
     } else if (isDoctor) {
@@ -107,6 +108,11 @@ export default function SidebarNavigation({ onItemClick, isCollapsed = false }:
               href: "/admin",
               icon: Users
             },
+            {
+              title: "Gestión de Usuarios",
+              href: "/admin/users",
+              icon: UserCog
+            },
             {
               title: "Gestión de Citas",
               href: "/appointments/doctor",
@@ -216,10 +222,12 @@ export default function SidebarNavigation({ onItemClick, isCollapsed = false }:
   }
 
   const isActive = (href: string) => {
-    if (href === "/dashboard") {
-      return pathname === "/dashboard"
+    // Para rutas con sub-rutas, usar startsWith
+    if (href === '/appointments/doctor' && pathname.startsWith('/appointments')) {
+      return true
     }
-    return pathname.startsWith(href)
+    // Para el resto, comparación exacta
+    return pathname === href
   }
 
   return (

+ 31 - 0
src/components/ui/switch.tsx

@@ -0,0 +1,31 @@
+"use client"
+
+import * as React from "react"
+import * as SwitchPrimitive from "@radix-ui/react-switch"
+
+import { cn } from "@/lib/utils"
+
+function Switch({
+  className,
+  ...props
+}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
+  return (
+    <SwitchPrimitive.Root
+      data-slot="switch"
+      className={cn(
+        "peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
+        className
+      )}
+      {...props}
+    >
+      <SwitchPrimitive.Thumb
+        data-slot="switch-thumb"
+        className={cn(
+          "bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
+        )}
+      />
+    </SwitchPrimitive.Root>
+  )
+}
+
+export { Switch }

+ 117 - 0
src/components/ui/table.tsx

@@ -0,0 +1,117 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+const Table = React.forwardRef<
+  HTMLTableElement,
+  React.HTMLAttributes<HTMLTableElement>
+>(({ className, ...props }, ref) => (
+  <div className="relative w-full overflow-auto">
+    <table
+      ref={ref}
+      className={cn("w-full caption-bottom text-sm", className)}
+      {...props}
+    />
+  </div>
+))
+Table.displayName = "Table"
+
+const TableHeader = React.forwardRef<
+  HTMLTableSectionElement,
+  React.HTMLAttributes<HTMLTableSectionElement>
+>(({ className, ...props }, ref) => (
+  <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
+))
+TableHeader.displayName = "TableHeader"
+
+const TableBody = React.forwardRef<
+  HTMLTableSectionElement,
+  React.HTMLAttributes<HTMLTableSectionElement>
+>(({ className, ...props }, ref) => (
+  <tbody
+    ref={ref}
+    className={cn("[&_tr:last-child]:border-0", className)}
+    {...props}
+  />
+))
+TableBody.displayName = "TableBody"
+
+const TableFooter = React.forwardRef<
+  HTMLTableSectionElement,
+  React.HTMLAttributes<HTMLTableSectionElement>
+>(({ className, ...props }, ref) => (
+  <tfoot
+    ref={ref}
+    className={cn(
+      "border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
+      className
+    )}
+    {...props}
+  />
+))
+TableFooter.displayName = "TableFooter"
+
+const TableRow = React.forwardRef<
+  HTMLTableRowElement,
+  React.HTMLAttributes<HTMLTableRowElement>
+>(({ className, ...props }, ref) => (
+  <tr
+    ref={ref}
+    className={cn(
+      "border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
+      className
+    )}
+    {...props}
+  />
+))
+TableRow.displayName = "TableRow"
+
+const TableHead = React.forwardRef<
+  HTMLTableCellElement,
+  React.ThHTMLAttributes<HTMLTableCellElement>
+>(({ className, ...props }, ref) => (
+  <th
+    ref={ref}
+    className={cn(
+      "h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
+      className
+    )}
+    {...props}
+  />
+))
+TableHead.displayName = "TableHead"
+
+const TableCell = React.forwardRef<
+  HTMLTableCellElement,
+  React.TdHTMLAttributes<HTMLTableCellElement>
+>(({ className, ...props }, ref) => (
+  <td
+    ref={ref}
+    className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
+    {...props}
+  />
+))
+TableCell.displayName = "TableCell"
+
+const TableCaption = React.forwardRef<
+  HTMLTableCaptionElement,
+  React.HTMLAttributes<HTMLTableCaptionElement>
+>(({ className, ...props }, ref) => (
+  <caption
+    ref={ref}
+    className={cn("mt-4 text-sm text-muted-foreground", className)}
+    {...props}
+  />
+))
+TableCaption.displayName = "TableCaption"
+
+export {
+  Table,
+  TableHeader,
+  TableBody,
+  TableFooter,
+  TableHead,
+  TableRow,
+  TableCell,
+  TableCaption,
+}

+ 64 - 6
src/lib/auth.ts

@@ -1,5 +1,6 @@
 import { NextAuthOptions } from "next-auth";
 import CredentialsProvider from "next-auth/providers/credentials";
+import bcrypt from "bcryptjs";
 import { prisma } from "@/lib/prisma";
 import { config } from "@/lib/config";
 import { authenticateUser, UTBApiError, UTB_ROLE_MAP } from "@/lib/utb-api";
@@ -19,8 +20,8 @@ export const authOptions: NextAuthOptions = {
           return null;
         }
 
+        // Paso 1: Intentar autenticación con API UTB (para PACIENTES)
         try {
-          // Autenticar con API UTB
           const utbResponse = await authenticateUser(
             credentials.username,
             credentials.password
@@ -29,6 +30,13 @@ export const authOptions: NextAuthOptions = {
           // Mapear rol UTB a rol interno
           const role = UTB_ROLE_MAP[utbResponse.user.tipo] || 'PATIENT';
 
+          // Solo permitir PACIENTES por API UTB
+          if (role !== 'PATIENT') {
+            console.log(`Usuario ${credentials.username} con rol ${role} debe usar autenticación local`);
+            // Continuar al siguiente método de autenticación
+            throw new Error('USE_LOCAL_AUTH');
+          }
+
           // Buscar usuario existente por identificación
           let user = await prisma.user.findFirst({
             where: {
@@ -53,7 +61,7 @@ export const authOptions: NextAuthOptions = {
               }
             });
           } else {
-            // Crear nuevo usuario
+            // Crear nuevo usuario PACIENTE
             user = await prisma.user.create({
               data: {
                 username: credentials.username,
@@ -86,11 +94,61 @@ export const authOptions: NextAuthOptions = {
             currentMedications: user.currentMedications || undefined,
           };
         } catch (error) {
-          if (error instanceof UTBApiError) {
-            console.error("Error UTB API:", error.message);
-          } else {
-            console.error("Error en authorize:", error);
+          // Si no es error de "usar autenticación local", fallar
+          if (error instanceof Error && error.message !== 'USE_LOCAL_AUTH') {
+            if (error instanceof UTBApiError) {
+              console.log("Credenciales UTB inválidas, intentando autenticación local");
+            } else {
+              console.error("Error en autenticación UTB:", error);
+            }
+          }
+        }
+
+        // Paso 2: Autenticación local (para DOCTORES y ADMINS)
+        try {
+          const user = await prisma.user.findFirst({
+            where: {
+              OR: [
+                { username: credentials.username },
+                { email: credentials.username }
+              ],
+              isExternalAuth: false,
+              role: { in: ['DOCTOR', 'ADMIN'] }
+            }
+          });
+
+          if (!user || !user.password) {
+            console.log("Usuario no encontrado o sin contraseña local");
+            return null;
           }
+
+          const isValidPassword = await bcrypt.compare(credentials.password, user.password);
+
+          if (!isValidPassword) {
+            console.log("Contraseña local inválida");
+            return null;
+          }
+
+          return {
+            id: user.id,
+            email: user.email || '',
+            name: user.name,
+            lastname: user.lastname,
+            role: user.role,
+            profileImage: user.profileImage || undefined,
+            isExternalAuth: user.isExternalAuth,
+            identificacion: user.identificacion || undefined,
+            phone: user.phone || undefined,
+            dateOfBirth: user.dateOfBirth || undefined,
+            gender: user.gender || undefined,
+            address: user.address || undefined,
+            emergencyContact: user.emergencyContact || undefined,
+            medicalHistory: user.medicalHistory || undefined,
+            allergies: user.allergies || undefined,
+            currentMedications: user.currentMedications || undefined,
+          };
+        } catch (error) {
+          console.error("Error en autenticación local:", error);
           return null;
         }
       }

+ 14 - 0
src/lib/notifications.ts

@@ -339,3 +339,17 @@ export const promiseToast = {
       error: messages.error || "Ocurrió un error",
     }),
 }
+
+/**
+ * Toasts genéricos
+ */
+export const genericToast = {
+  success: (message: string, description?: string) =>
+    toast.success(message, { description }),
+  error: (message: string, description?: string) =>
+    toast.error(message, { description }),
+  info: (message: string, description?: string) =>
+    toast.info(message, { description }),
+  loading: (message: string) =>
+    toast.loading(message),
+}