Browse Source

only use utb api for auth

Matthew Trejo 2 months ago
parent
commit
fe7b5b4d7d

+ 175 - 0
docs/UTB_API_MIGRATION.md

@@ -0,0 +1,175 @@
+# Migración a Autenticación API UTB
+
+## Estado: ✅ Completado
+
+---
+
+## 1. Variables de Entorno
+
+### Archivo: `.env`
+
+- [x] Agregar `UTB_API_URL=https://sai.utb.edu.ec/v2/ws/auth/login`
+- [x] Agregar `UTB_API_APP_ID=6d834518ee8832b9455d3887aa0dc1a369bccee2d017812994ab066faae42067`
+- [x] Agregar `UTB_API_APP_TOKEN=c9449f3452c6645081282a01240c0679eb1ca1e440eebf322eb23d5f2260c2d4`
+
+---
+
+## 2. Configuración
+
+### Archivo: `src/lib/config.ts`
+
+- [x] Agregar sección `utbApi`
+- [x] Agregar validación de credenciales en logger
+
+---
+
+## 3. Cliente API UTB
+
+### Archivo: `src/lib/utb-api.ts` (nuevo)
+
+- [x] Crear función `authenticateUser(username: string, password: string)`
+- [x] Configurar headers: `X-App-Id`, `X-App-Token`, `Content-Type`
+- [x] Manejo de respuesta exitosa (200)
+- [x] Manejo de errores (400, 401, 405)
+- [x] Retornar interface `UTBAuthResponse`
+
+---
+
+## 4. Schema Prisma
+
+### Archivo: `prisma/schema.prisma`
+
+- [x] Agregar campo `identificacion String? @unique` al modelo `User`
+- [x] Agregar campo `isExternalAuth Boolean @default(false)` al modelo `User`
+- [x] Modificar `email String? @unique` (hacerlo opcional)
+- [x] Modificar `password String?` (hacerlo opcional)
+
+---
+
+## 5. Migración Base de Datos
+
+- [x] Ejecutar `npx prisma migrate dev --name add_utb_fields`
+- [x] Verificar que la migración se aplicó correctamente
+
+---
+
+## 6. Lógica de Autenticación
+
+### Archivo: `src/lib/auth.ts`
+
+- [x] Importar cliente UTB API
+- [x] Modificar `authorize()`:
+  - [x] Validar contra API UTB (no bcrypt)
+  - [x] Buscar usuario por `identificacion` o `username`
+  - [x] Si no existe, crear usuario nuevo
+  - [x] Mapear datos de API a modelo User
+  - [x] Mapear roles: ESTUDIANTE→PATIENT, DOCENTE→DOCTOR, ADMINISTRADOR→ADMIN
+  - [x] Si existe, actualizar datos desde API
+
+---
+
+## 7. Formulario de Login
+
+### Archivo: `src/app/auth/login/page.tsx`
+
+- [x] Cambiar campo `email` por `username`
+- [x] Actualizar labels y placeholders
+- [x] Actualizar mensaje informativo
+
+---
+
+## 8. API Account Update
+
+### Archivo: `src/app/api/account/update/route.ts`
+
+- [x] Prevenir cambio de contraseña para usuarios UTB
+- [x] Validar que `password` no sea null antes de bcrypt.compare
+
+---
+
+## 9. Tipos TypeScript
+
+### Archivo: `src/types/utb-api.d.ts` (nuevo)
+
+- [x] Definir `UTBAuthResponse`
+- [x] Definir `UTBUser`
+- [x] Definir `UTBErrorResponse`
+
+---
+
+## 10. Build y Pruebas
+
+- [x] Verificar que el proyecto compila sin errores
+- [x] Probar login con usuario estudiante
+- [x] Probar login con usuario docente
+- [x] Verificar creación de usuario en DB
+- [x] Verificar actualización de datos existentes
+- [x] Probar manejo de credenciales inválidas
+
+---
+
+## 11. UI de Cuenta
+
+### Archivos: `src/components/account/*.tsx`, `src/app/account/page.tsx`
+
+- [x] Banner informativo para usuarios UTB
+- [x] Mostrar `identificacion` (solo lectura)
+- [x] Bloquear campos nombre/apellido para usuarios UTB
+- [x] Email opcional y editable
+- [x] Deshabilitar cambio de contraseña para usuarios UTB
+- [x] Link a SAI UTB para cambios
+
+---
+
+## Estado Final: ✅ COMPLETADO Y SIMPLIFICADO
+
+**Migración exitosa a autenticación API UTB.**
+
+### Simplificación UTB-Only (100% dependencia API)
+
+**Decisión:** El sistema ya no soporta usuarios locales. Todos los usuarios deben autenticarse mediante la API de UTB.
+
+**Cambios de simplificación realizados:**
+
+1. **Componentes de cuenta simplificados:**
+   - `PersonalInfoSection.tsx`: Siempre muestra banner UTB, campos nombre/apellido bloqueados
+   - `PasswordChangeSection.tsx`: Solo muestra mensaje de gestión por UTB, sin campos de contraseña
+
+2. **API de actualización simplificada:**
+   - Eliminados checks de `isExternalAuth`
+   - Solo permite actualizar: email y profileImage
+   - Bloquea cualquier intento de cambio de contraseña
+   - Removidos imports y lógica de bcrypt no utilizados
+
+3. **Props simplificadas:**
+   - Eliminado prop `isExternalAuth` de componentes de cuenta
+   - Props de contraseña en `PasswordChangeSection` marcadas como opcionales por compatibilidad
+
+**Estado actual:**
+- ✅ Autenticación mediante API externa
+- ✅ Sincronización automática de datos
+- ✅ Protección de campos gestionados por UTB
+- ✅ UI simplificada sin lógica condicional
+- ✅ Build exitoso sin errores
+- ✅ Código más limpio y mantenible
+
+**Campos editables:**
+- ✅ Email (opcional)
+- ✅ Foto de perfil
+
+**Campos bloqueados (gestionados por UTB):**
+- 🔒 Nombre
+- 🔒 Apellido  
+- 🔒 Fecha de nacimiento
+- 🔒 Contraseña
+
+---
+
+## Notas Importantes
+
+- **Email opcional:** Los usuarios pueden agregarlo después en `/account`
+- **Password:** Se guarda vacío para usuarios UTB (no se usa)
+- **Identificación:** Campo único que vincula con API UTB
+- **Roles:** Mapeo automático según `tipo` de la API
+- **Reportes:** Se mantienen vinculados por `userId` interno
+- **Sin usuarios locales:** Ya no se soporta autenticación local, solo UTB

+ 6 - 1
env.sample.txt

@@ -7,4 +7,9 @@ OPENROUTER_MODEL="deepseek/deepseek-chat-v3-0324:free"
 
 # Session Configuration (in hours)
 SESSION_MAX_AGE="4"
-JWT_MAX_AGE="4"
+JWT_MAX_AGE="4"
+
+# UTB API Configuration
+UTB_API_URL="https://sai.utb.edu.ec/v2/ws/auth/login"
+UTB_API_APP_ID="your-secret-key-here"
+UTB_API_APP_TOKEN="your-secret-key-here"

+ 75 - 0
prisma/migrations/20251007212212_add_utb_auth_fields/migration.sql

@@ -0,0 +1,75 @@
+-- CreateEnum
+CREATE TYPE "Role" AS ENUM ('ADMIN', 'DOCTOR', 'PATIENT');
+
+-- CreateEnum
+CREATE TYPE "Gender" AS ENUM ('MALE', 'FEMALE', 'OTHER', 'PREFER_NOT_TO_SAY');
+
+-- CreateTable
+CREATE TABLE "User" (
+    "id" TEXT NOT NULL,
+    "name" TEXT NOT NULL,
+    "lastname" TEXT NOT NULL,
+    "username" TEXT NOT NULL,
+    "email" TEXT,
+    "password" TEXT,
+    "identificacion" TEXT,
+    "isExternalAuth" BOOLEAN NOT NULL DEFAULT false,
+    "role" "Role" NOT NULL DEFAULT 'PATIENT',
+    "profileImage" TEXT,
+    "phone" TEXT,
+    "dateOfBirth" TIMESTAMP(3),
+    "gender" "Gender",
+    "address" TEXT,
+    "emergencyContact" TEXT,
+    "medicalHistory" TEXT,
+    "allergies" TEXT,
+    "currentMedications" TEXT,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL,
+
+    CONSTRAINT "User_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "PatientAssignment" (
+    "id" TEXT NOT NULL,
+    "doctorId" TEXT NOT NULL,
+    "patientId" TEXT NOT NULL,
+    "assignedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "notes" TEXT,
+    "isActive" BOOLEAN NOT NULL DEFAULT true,
+
+    CONSTRAINT "PatientAssignment_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Record" (
+    "id" TEXT NOT NULL,
+    "userId" TEXT NOT NULL,
+    "content" TEXT NOT NULL,
+    "messages" JSONB,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+    CONSTRAINT "Record_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "User_identificacion_key" ON "User"("identificacion");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "PatientAssignment_doctorId_patientId_key" ON "PatientAssignment"("doctorId", "patientId");
+
+-- AddForeignKey
+ALTER TABLE "PatientAssignment" ADD CONSTRAINT "PatientAssignment_doctorId_fkey" FOREIGN KEY ("doctorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "PatientAssignment" ADD CONSTRAINT "PatientAssignment_patientId_fkey" FOREIGN KEY ("patientId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Record" ADD CONSTRAINT "Record_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

+ 3 - 0
prisma/migrations/migration_lock.toml

@@ -0,0 +1,3 @@
+# Please do not edit this file manually
+# It should be added in your version-control system (e.g., Git)
+provider = "postgresql"

+ 4 - 2
prisma/schema.prisma

@@ -12,8 +12,10 @@ model User {
   name         String
   lastname     String
   username     String   @unique
-  email        String   @unique
-  password     String
+  email        String?  @unique
+  password     String?
+  identificacion String? @unique
+  isExternalAuth Boolean @default(false)
   role         Role     @default(PATIENT)
   profileImage String?
   phone        String?

+ 34 - 55
src/app/api/account/update/route.ts

@@ -2,8 +2,8 @@ import { NextRequest, NextResponse } from "next/server"
 import { getServerSession } from "next-auth"
 import { authOptions } from "@/lib/auth"
 import { prisma } from "@/lib/prisma"
-import bcrypt from "bcryptjs"
 import fs from 'fs'
+
 import {
   generateUniqueFileName,
   getProfileImagePath,
@@ -25,79 +25,58 @@ export async function POST(request: NextRequest) {
     }
 
     const formData = await request.formData()
-    const name = formData.get('name') as string
-    const lastname = formData.get('lastname') as string
     const email = formData.get('email') as string
     const currentPassword = formData.get('currentPassword') as string
     const newPassword = formData.get('newPassword') as string
     const profileImage = formData.get('profileImage') as File | null
 
-    // Validaciones básicas
-    if (!name || !lastname || !email) {
-      return NextResponse.json(
-        { error: "Nombre, apellido y email son requeridos" },
-        { status: 400 }
-      )
-    }
-
-    // Verificar si el email ya existe (excluyendo el usuario actual)
-    const existingUser = await prisma.user.findFirst({
-      where: {
-        email,
-        id: { not: session.user.id }
-      }
-    })
-
-    if (existingUser) {
-      return NextResponse.json(
-        { error: "El email ya está en uso por otro usuario" },
-        { status: 400 }
-      )
-    }
-
-    // Obtener usuario actual para verificar imagen previa
+    // Obtener información del usuario actual
     const currentUser = await prisma.user.findUnique({
       where: { id: session.user.id },
       select: { profileImage: true }
     })
 
-    // Preparar datos de actualización
-    const updateData: {
-      name: string
-      lastname: string
-      email: string
-      password?: string
-      profileImage?: string
-    } = {
-      name,
-      lastname,
-      email
+    if (!currentUser) {
+      return NextResponse.json(
+        { error: "Usuario no encontrado" },
+        { status: 404 }
+      )
     }
 
-    // Si se proporciona contraseña actual, verificar y actualizar
-    if (currentPassword && newPassword) {
-      const user = await prisma.user.findUnique({
-        where: { id: session.user.id }
+    // Validar email si se proporciona
+    if (email) {
+      const existingUser = await prisma.user.findFirst({
+        where: {
+          email,
+          id: { not: session.user.id }
+        }
       })
 
-      if (!user) {
-        return NextResponse.json(
-          { error: "Usuario no encontrado" },
-          { status: 404 }
-        )
-      }
-
-      // Verificar contraseña actual
-      const isPasswordValid = await bcrypt.compare(currentPassword, user.password)
-      if (!isPasswordValid) {
+      if (existingUser) {
         return NextResponse.json(
-          { error: "La contraseña actual es incorrecta" },
+          { error: "El email ya está en uso por otro usuario" },
           { status: 400 }
         )
       }
+    }
 
-      // Encriptar nueva contraseña
-      updateData.password = await bcrypt.hash(newPassword, 12)
+    // Preparar datos de actualización (solo email y foto permitidos)
+    const updateData: {
+      email?: string
+      profileImage?: string
+    } = {}
+
+    // Actualizar email (único campo editable aparte de foto)
+    if (email) {
+      updateData.email = email
+    }
+
+    // Los cambios de contraseña no están permitidos - todos los usuarios son UTB
+    if (currentPassword || newPassword) {
+      return NextResponse.json(
+        { error: "La contraseña se gestiona a través de SAI UTB" },
+        { status: 400 }
+      )
     }
 
     // Procesar imagen de perfil si se proporciona

+ 13 - 13
src/app/auth/login/page.tsx

@@ -12,7 +12,7 @@ import { User, Lock, AlertCircle } from "lucide-react"
 import { toast } from "sonner"
 
 export default function LoginPage() {
-  const [email, setEmail] = useState("")
+  const [username, setUsername] = useState("")
   const [password, setPassword] = useState("")
   const [loading, setLoading] = useState(false)
   const router = useRouter()
@@ -52,7 +52,7 @@ export default function LoginPage() {
 
     try {
       const result = await signIn("credentials", {
-        email,
+        username,
         password,
         redirect: false,
       })
@@ -86,13 +86,13 @@ export default function LoginPage() {
         <CardContent>
           <form onSubmit={handleSubmit} className="space-y-4">
             <div className="space-y-2">
-              <Label htmlFor="email">Email</Label>
+              <Label htmlFor="username">Usuario</Label>
               <Input
-                id="email"
-                type="email"
-                placeholder="tu@email.com"
-                value={email}
-                onChange={(e) => setEmail(e.target.value)}
+                id="username"
+                type="text"
+                placeholder="1206706838-EST"
+                value={username}
+                onChange={(e) => setUsername(e.target.value)}
                 required
               />
             </div>
@@ -130,12 +130,12 @@ export default function LoginPage() {
             </p>
           </div>
 
-          <div className="mt-6 p-4 bg-yellow-50 rounded-lg">
+          <div className="mt-6 p-4 bg-blue-50 rounded-lg">
             <div className="flex items-center">
-              <AlertCircle className="w-4 h-4 text-yellow-600 mr-2" />
-              <p className="text-sm text-yellow-800">
-                <strong>Nota:</strong> Esta es una aplicación de demostración. 
-                Usa credenciales de prueba para acceder.
+              <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>
           </div>

+ 23 - 100
src/components/account/PasswordChangeSection.tsx

@@ -1,30 +1,19 @@
-"use client"
+"use client"
 
-import { useState } from "react"
 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
-import { Input } from "@/components/ui/input"
-import { Label } from "@/components/ui/label"
-import { Lock, Eye, EyeOff } from "lucide-react"
+import { Lock, AlertCircle, ExternalLink } from "lucide-react"
 
 interface PasswordChangeSectionProps {
-  formData: {
+  // Props mantenidas por compatibilidad pero no utilizadas
+  formData?: {
     currentPassword: string
     newPassword: string
     confirmPassword: string
   }
-  onInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void
+  onInputChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
 }
 
-export default function PasswordChangeSection({
-  formData,
-  onInputChange
-}: PasswordChangeSectionProps) {
-  const [showPasswords, setShowPasswords] = useState({
-    current: false,
-    new: false,
-    confirm: false
-  })
-
+export default function PasswordChangeSection({}: PasswordChangeSectionProps) {
   return (
     <Card>
       <CardHeader>
@@ -34,92 +23,26 @@ export default function PasswordChangeSection({
         </CardTitle>
       </CardHeader>
       <CardContent>
-        <div className="space-y-4">
-          {/* Contraseña Actual */}
-          <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={showPasswords.current ? "text" : "password"}
-                value={formData.currentPassword}
-                onChange={onInputChange}
-                className="pl-10 pr-10"
-                placeholder="Dejar vacío si no quieres cambiar"
-              />
-              <button
-                type="button"
-                onClick={() => setShowPasswords(prev => ({ ...prev, current: !prev.current }))}
-                className="absolute right-3 top-1/2 transform -translate-y-1/2"
-              >
-                {showPasswords.current ? (
-                  <EyeOff className="w-4 h-4 text-muted-foreground" />
-                ) : (
-                  <Eye className="w-4 h-4 text-muted-foreground" />
-                )}
-              </button>
-            </div>
-          </div>
-
-          {/* Nueva Contraseña */}
-          <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={showPasswords.new ? "text" : "password"}
-                value={formData.newPassword}
-                onChange={onInputChange}
-                className="pl-10 pr-10"
-                placeholder="Mínimo 6 caracteres"
-              />
-              <button
-                type="button"
-                onClick={() => setShowPasswords(prev => ({ ...prev, new: !prev.new }))}
-                className="absolute right-3 top-1/2 transform -translate-y-1/2"
+        <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 académico.{" "}
+              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"
               >
-                {showPasswords.new ? (
-                  <EyeOff className="w-4 h-4 text-muted-foreground" />
-                ) : (
-                  <Eye className="w-4 h-4 text-muted-foreground" />
-                )}
-              </button>
-            </div>
-          </div>
-
-          {/* Confirmar Nueva Contraseña */}
-          <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={showPasswords.confirm ? "text" : "password"}
-                value={formData.confirmPassword}
-                onChange={onInputChange}
-                className="pl-10 pr-10"
-                placeholder="Repite la nueva contraseña"
-              />
-              <button
-                type="button"
-                onClick={() => setShowPasswords(prev => ({ ...prev, confirm: !prev.confirm }))}
-                className="absolute right-3 top-1/2 transform -translate-y-1/2"
-              >
-                {showPasswords.confirm ? (
-                  <EyeOff className="w-4 h-4 text-muted-foreground" />
-                ) : (
-                  <Eye className="w-4 h-4 text-muted-foreground" />
-                )}
-              </button>
-            </div>
+                SAI UTB
+                <ExternalLink className="w-3 h-3" />
+              </a>
+            </p>
           </div>
         </div>
       </CardContent>
     </Card>
   )
-}
+}

+ 49 - 15
src/components/account/PersonalInfoSection.tsx

@@ -1,15 +1,16 @@
-"use client"
+"use client"
 
 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
 import { Input } from "@/components/ui/input"
 import { Label } from "@/components/ui/label"
-import { User, Mail } from "lucide-react"
+import { User, Mail, AlertCircle, ExternalLink } from "lucide-react"
 
 interface PersonalInfoSectionProps {
   formData: {
     name: string
     lastname: string
     email: string
+    identificacion?: string
   }
   onInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void
 }
@@ -27,8 +28,35 @@ 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>
+          </div>
+        </div>
+        
         <div className="space-y-6">
-          {/* Nombre */}
+          {/* Identificación (solo lectura) */}
+          {formData.identificacion && (
+            <div className="space-y-2">
+              <Label htmlFor="identificacion">Identificación</Label>
+              <div className="relative">
+                <User className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
+                <Input
+                  id="identificacion"
+                  value={formData.identificacion}
+                  className="pl-10 bg-gray-50"
+                  disabled
+                  readOnly
+                />
+              </div>
+            </div>
+          )}
+
+          {/* Nombre (bloqueado) */}
           <div className="space-y-2">
             <Label htmlFor="name">Nombre</Label>
             <div className="relative">
@@ -39,14 +67,14 @@ export default function PersonalInfoSection({
                 type="text"
                 value={formData.name}
                 onChange={onInputChange}
-                className="pl-10"
-                required
-                placeholder="Tu nombre"
+                className="pl-10 bg-gray-50"
+                disabled
+                readOnly
               />
             </div>
           </div>
 
-          {/* Apellido */}
+          {/* Apellido (bloqueado) */}
           <div className="space-y-2">
             <Label htmlFor="lastname">Apellido</Label>
             <div className="relative">
@@ -57,16 +85,18 @@ export default function PersonalInfoSection({
                 type="text"
                 value={formData.lastname}
                 onChange={onInputChange}
-                className="pl-10"
-                required
-                placeholder="Tu apellido"
+                className="pl-10 bg-gray-50"
+                disabled
+                readOnly
               />
             </div>
           </div>
 
-          {/* Email */}
+          {/* Email (editable, opcional) */}
           <div className="space-y-2">
-            <Label htmlFor="email">Email</Label>
+            <Label htmlFor="email">
+              Email <span className="text-xs text-gray-500 font-normal">(opcional)</span>
+            </Label>
             <div className="relative">
               <Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
               <Input
@@ -76,13 +106,17 @@ export default function PersonalInfoSection({
                 value={formData.email}
                 onChange={onInputChange}
                 className="pl-10"
-                required
-                placeholder="tu@email.com"
+                placeholder="Agrega tu email personal (opcional)"
               />
             </div>
+            {!formData.email && (
+              <p className="text-xs text-gray-500">
+                Este dato es proporcionado por la universidad, pero esta página no tiene acceso a él.
+              </p>
+            )}
           </div>
         </div>
       </CardContent>
     </Card>
   )
-}
+}

+ 3 - 1
src/hooks/useAccountForm.ts

@@ -8,6 +8,7 @@ interface FormData {
   name: string
   lastname: string
   email: string
+  identificacion?: string
   currentPassword: string
   newPassword: string
   confirmPassword: string
@@ -67,7 +68,8 @@ export function useAccountForm() {
         ...prev,
         name: session.user.name || "",
         lastname: session.user.lastname || "",
-        email: session.user.email || ""
+        email: session.user.email || "",
+        identificacion: session.user.identificacion || ""
       }))
     }
   }, [session])

+ 59 - 20
src/lib/auth.ts

@@ -1,8 +1,9 @@
 import { NextAuthOptions } from "next-auth";
 import CredentialsProvider from "next-auth/providers/credentials";
 import { prisma } from "@/lib/prisma";
-import bcrypt from "bcryptjs";
 import { config } from "@/lib/config";
+import { authenticateUser, UTBApiError, UTB_ROLE_MAP } from "@/lib/utb-api";
+import type { Role } from "@prisma/client";
 
 export const authOptions: NextAuthOptions = {
   secret: config.nextAuth.secret,
@@ -10,44 +11,78 @@ export const authOptions: NextAuthOptions = {
     CredentialsProvider({
       name: "credentials",
       credentials: {
-        email: { label: "Email", type: "email" },
-        password: { label: "Password", type: "password" }
+        username: { label: "Usuario", type: "text" },
+        password: { label: "Contraseña", type: "password" }
       },
       async authorize(credentials) {
-        if (!credentials?.email || !credentials?.password) {
+        if (!credentials?.username || !credentials?.password) {
           return null;
         }
 
         try {
-          const user = await prisma.user.findUnique({
+          // Autenticar con API UTB
+          const utbResponse = await authenticateUser(
+            credentials.username,
+            credentials.password
+          );
+
+          // Mapear rol UTB a rol interno
+          const role = UTB_ROLE_MAP[utbResponse.user.tipo] || 'PATIENT';
+
+          // Buscar usuario existente por identificación
+          let user = await prisma.user.findFirst({
             where: {
-              email: credentials.email
+              OR: [
+                { identificacion: utbResponse.user.identificacion },
+                { username: credentials.username }
+              ]
             }
           });
 
-          if (!user) {
-            return null;
-          }
-
-          const isPasswordValid = await bcrypt.compare(
-            credentials.password,
-            user.password
-          );
-
-          if (!isPasswordValid) {
-            return null;
+          if (user) {
+            // Actualizar datos del usuario existente
+            user = await prisma.user.update({
+              where: { id: user.id },
+              data: {
+                name: utbResponse.user.nombres,
+                lastname: utbResponse.user.apellidos,
+                dateOfBirth: new Date(utbResponse.user.fecha_nacimiento),
+                role,
+                identificacion: utbResponse.user.identificacion,
+                isExternalAuth: true,
+              }
+            });
+          } else {
+            // Crear nuevo usuario
+            user = await prisma.user.create({
+              data: {
+                username: credentials.username,
+                identificacion: utbResponse.user.identificacion,
+                name: utbResponse.user.nombres,
+                lastname: utbResponse.user.apellidos,
+                dateOfBirth: new Date(utbResponse.user.fecha_nacimiento),
+                role,
+                isExternalAuth: true,
+              }
+            });
           }
 
           return {
             id: user.id,
-            email: user.email,
+            email: user.email || '',
             name: user.name,
             lastname: user.lastname,
             role: user.role,
             profileImage: user.profileImage || undefined,
+            isExternalAuth: user.isExternalAuth,
+            identificacion: user.identificacion || undefined,
           };
         } catch (error) {
-          console.error("Error en authorize:", error);
+          if (error instanceof UTBApiError) {
+            console.error("Error UTB API:", error.message);
+          } else {
+            console.error("Error en authorize:", error);
+          }
           return null;
         }
       }
@@ -68,16 +103,20 @@ export const authOptions: NextAuthOptions = {
         token.name = user.name;
         token.lastname = user.lastname;
         token.profileImage = user.profileImage;
+        token.isExternalAuth = user.isExternalAuth;
+        token.identificacion = user.identificacion;
       }
       return token;
     },
     async session({ session, token }) {
       if (token) {
         session.user.id = token.id as string;
-        session.user.role = token.role as "ADMIN" | "DOCTOR" | "PATIENT";
+        session.user.role = token.role;
         session.user.name = token.name as string;
         session.user.lastname = token.lastname as string;
         session.user.profileImage = token.profileImage as string | undefined;
+        session.user.isExternalAuth = token.isExternalAuth as boolean | undefined;
+        session.user.identificacion = token.identificacion as string | undefined;
       }
       return session;
     }

+ 17 - 0
src/lib/config.ts

@@ -29,6 +29,13 @@ export const config = {
     jwtMaxAge: parseInt(process.env.JWT_MAX_AGE || '4') * 60 * 60, // Convert hours to seconds
   },
   
+  // UTB API
+  utbApi: {
+    url: process.env.UTB_API_URL || 'https://sai.utb.edu.ec/v2/ws/auth/login',
+    appId: process.env.UTB_API_APP_ID || '',
+    appToken: process.env.UTB_API_APP_TOKEN || '',
+  },
+  
   // Environment
   env: process.env.NODE_ENV || 'development',
   port: process.env.PORT || '3000',
@@ -59,6 +66,12 @@ export function logEnvironmentConfig() {
   console.log(`   Max Age: ${config.session.maxAge / 3600} horas (${config.session.maxAge} segundos)`)
   console.log(`   JWT Max Age: ${config.session.jwtMaxAge / 3600} horas (${config.session.jwtMaxAge} segundos)`)
   
+  // UTB API
+  console.log('🎓 UTB API:')
+  console.log(`   URL: ${config.utbApi.url}`)
+  console.log(`   App ID: ${config.utbApi.appId ? '✅ Configurado' : '❌ No configurado'}`)
+  console.log(`   App Token: ${config.utbApi.appToken ? '✅ Configurado' : '❌ No configurado'}`)
+  
   // Environment
   console.log('🌍 Environment:')
   console.log(`   NODE_ENV: ${config.env}`)
@@ -75,6 +88,10 @@ export function logEnvironmentConfig() {
     console.log('⚠️ ADVERTENCIA: OPENROUTER_API_KEY no está configurada - El chat funcionará con respuestas de fallback')
   }
   
+  if (!config.utbApi.appId || !config.utbApi.appToken) {
+    console.log('⚠️ ADVERTENCIA: UTB API credentials no están configuradas')
+  }
+  
   console.log('')
 }
 

+ 70 - 0
src/lib/utb-api.ts

@@ -0,0 +1,70 @@
+import { config } from './config';
+import type { UTBAuthRequest, UTBResponse, UTBAuthResponse, UTBRoleType, InternalRoleType } from '@/types/utb-api';
+
+// Mapeo de roles UTB a roles internos
+export const UTB_ROLE_MAP: Record<UTBRoleType, InternalRoleType> = {
+  ESTUDIANTES: 'PATIENT',
+  DOCTORES: 'DOCTOR',
+  ADMINISTRADORES: 'ADMIN',
+} as const;
+
+// Re-exportar tipos para uso en otros módulos
+export type { UTBRoleType, InternalRoleType } from '@/types/utb-api';
+
+export class UTBApiError extends Error {
+  constructor(
+    message: string,
+    public statusCode?: number
+  ) {
+    super(message);
+    this.name = 'UTBApiError';
+  }
+}
+
+export async function authenticateUser(
+  username: string,
+  password: string
+): Promise<UTBAuthResponse> {
+  const { url, appId, appToken } = config.utbApi;
+
+  if (!appId || !appToken) {
+    throw new UTBApiError('UTB API credentials no configuradas');
+  }
+
+  const body: UTBAuthRequest = { username, password };
+
+  try {
+    const response = await fetch(url, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        'X-App-Id': appId,
+        'X-App-Token': appToken,
+      },
+      body: JSON.stringify(body),
+    });
+
+    const data: UTBResponse = await response.json();
+
+    if (!response.ok) {
+      throw new UTBApiError(
+        data.success === false ? data.message : `HTTP ${response.status}`,
+        response.status
+      );
+    }
+
+    if (!data.success) {
+      throw new UTBApiError(data.message);
+    }
+
+    return data;
+  } catch (error) {
+    if (error instanceof UTBApiError) {
+      throw error;
+    }
+    
+    throw new UTBApiError(
+      error instanceof Error ? error.message : 'Error desconocido al conectar con UTB API'
+    );
+  }
+}

+ 10 - 3
src/types/next-auth.d.ts

@@ -1,4 +1,5 @@
 import NextAuth from "next-auth"
+import type { InternalRoleType } from "./utb-api"
 
 declare module "next-auth" {
   interface Session {
@@ -7,8 +8,10 @@ declare module "next-auth" {
       name: string
       lastname: string
       email: string
-      role: "ADMIN" | "DOCTOR" | "PATIENT"
+      role: InternalRoleType
       profileImage?: string
+      isExternalAuth?: boolean
+      identificacion?: string
     }
   }
 
@@ -17,16 +20,20 @@ declare module "next-auth" {
     name: string
     lastname: string
     email: string
-    role: "ADMIN" | "DOCTOR" | "PATIENT"
+    role: InternalRoleType
     profileImage?: string
+    isExternalAuth?: boolean
+    identificacion?: string
   }
 }
 
 declare module "next-auth/jwt" {
   interface JWT {
-    role: "ADMIN" | "DOCTOR" | "PATIENT"
+    role: InternalRoleType
     name: string
     lastname: string
     profileImage?: string
+    isExternalAuth?: boolean
+    identificacion?: string
   }
 } 

+ 30 - 0
src/types/utb-api.d.ts

@@ -0,0 +1,30 @@
+// Tipos de roles de la API UTB
+export type UTBRoleType = 'ESTUDIANTES' | 'DOCTORES' | 'ADMINISTRADORES';
+
+// Tipos de roles internos (Prisma enum)
+export type InternalRoleType = 'ADMIN' | 'DOCTOR' | 'PATIENT';
+
+export interface UTBAuthRequest {
+  username: string;
+  password: string;
+}
+
+export interface UTBUser {
+  identificacion: string;
+  nombres: string;
+  apellidos: string;
+  fecha_nacimiento: string;
+  tipo: UTBRoleType;
+}
+
+export interface UTBAuthResponse {
+  success: true;
+  user: UTBUser;
+}
+
+export interface UTBErrorResponse {
+  success: false;
+  message: string;
+}
+
+export type UTBResponse = UTBAuthResponse | UTBErrorResponse;