Преглед изворни кода

implement selfhost jitsi meet support

Matthew Trejo пре 1 месец
родитељ
комит
e5d9a6cbad

+ 232 - 0
JITSI_SETUP.md

@@ -0,0 +1,232 @@
+# Configuración de Jitsi Meet con JWT
+
+## ✅ Completado en la aplicación
+
+La aplicación Next.js ya está configurada para usar tu instancia de Jitsi Meet con autenticación JWT:
+
+- ✅ Configuración agregada a [src/lib/config.ts](src/lib/config.ts)
+- ✅ Utilidad JWT creada en [src/lib/jitsi-jwt.ts](src/lib/jitsi-jwt.ts)
+- ✅ Endpoint API en [src/app/api/appointments/[id]/jitsi-token/route.ts](src/app/api/appointments/[id]/jitsi-token/route.ts)
+- ✅ Página de meet actualizada en [src/app/appointments/[id]/meet/page.tsx](src/app/appointments/[id]/meet/page.tsx)
+- ✅ Variables de entorno configuradas en [.env](.env)
+
+## 🔧 Configuración requerida en tu servidor Jitsi
+
+### 1. Actualizar tu archivo `.env` de Docker de Jitsi
+
+Basado en la configuración que ya tienes, actualiza las líneas comentadas:
+
+```env
+# JWT authentication
+AUTH_TYPE=jwt
+
+# Application identifier
+JWT_APP_ID=reuniones_utb
+
+# Application secret known only to your token generator
+JWT_APP_SECRET=theystolemyfuckingmoney
+
+# (Recomendado) Set asap_accepted_issuers as a comma separated list
+JWT_ACCEPTED_ISSUERS=reuniones_utb
+
+# (Recomendado) Set asap_accepted_audiences as a comma separated list
+JWT_ACCEPTED_AUDIENCES=reuniones_utb
+
+# (Opcional) Permitir invitados sin autenticación
+# Si quieres que solo usuarios autenticados puedan unirse, déjalo comentado
+# ENABLE_GUESTS=1
+```
+
+### 2. Reiniciar los contenedores de Jitsi
+
+Después de actualizar el `.env`, reinicia los servicios:
+
+```bash
+cd /ruta/a/tu/jitsi-docker
+docker compose down
+docker compose up -d
+```
+
+### 3. Verificar la configuración
+
+Verifica que los contenedores estén corriendo correctamente:
+
+```bash
+docker compose ps
+docker compose logs -f web
+```
+
+## 🔐 Cómo funciona la autenticación JWT
+
+### Flujo de autenticación:
+
+1. **Usuario accede a una cita aprobada** → [/appointments/[id]/meet](src/app/appointments/[id]/meet/page.tsx)
+
+2. **Frontend solicita JWT token** → `GET /api/appointments/[id]/jitsi-token`
+   - Valida que el usuario sea PATIENT, DOCTOR o ADMIN
+   - Valida que la cita esté aprobada
+   - Valida el tiempo (15 min antes hasta 1 hora después)
+
+3. **Backend genera JWT token** usando [src/lib/jitsi-jwt.ts](src/lib/jitsi-jwt.ts):
+   ```javascript
+   {
+     "iss": "reuniones_utb",        // JWT_APP_ID
+     "aud": "reuniones_utb",         // JWT_APP_ID
+     "sub": "meet.checkthis.space",  // Tu dominio
+     "room": "appointment-123",      // Sala específica
+     "context": {
+       "user": {
+         "id": "user-id",
+         "name": "Juan Pérez",
+         "email": "juan@example.com",
+         "avatar": "https://..."
+       }
+     },
+     "moderator": true,              // true para DOCTOR/ADMIN
+     "exp": 1699123456               // 2 horas de expiración
+   }
+   ```
+
+4. **Frontend inicializa Jitsi** con el token JWT:
+   ```javascript
+   new JitsiMeetExternalAPI("meet.checkthis.space", {
+     roomName: "appointment-123",
+     jwt: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
+     // ... otras opciones
+   })
+   ```
+
+5. **Jitsi valida el token** usando `JWT_APP_SECRET`:
+   - Verifica la firma del token
+   - Verifica que `iss` esté en `JWT_ACCEPTED_ISSUERS`
+   - Verifica que `aud` esté en `JWT_ACCEPTED_AUDIENCES`
+   - Verifica que el token no haya expirado
+
+## 👥 Roles y permisos
+
+### DOCTOR y ADMIN (moderadores):
+- ✅ Pueden grabar la reunión
+- ✅ Pueden hacer streaming
+- ✅ Pueden expulsar participantes
+- ✅ Pueden silenciar a otros participantes
+- ✅ Pueden finalizar la reunión para todos
+
+### PATIENT (participantes):
+- ✅ Pueden unirse a la reunión
+- ✅ Pueden compartir audio/video
+- ✅ Pueden usar el chat
+- ❌ No pueden expulsar a otros
+- ❌ No pueden grabar
+
+## 🔑 Variables de entorno en tu aplicación
+
+Tu archivo [.env](.env) ya está configurado con:
+
+```env
+JITSI_DOMAIN="meet.checkthis.space"
+JITSI_APP_ID="reuniones_utb"
+JITSI_APP_SECRET="theystolemyfuckingmoney"
+JITSI_USE_JWT="true"
+```
+
+**⚠️ IMPORTANTE:**
+- `JITSI_APP_ID` debe coincidir con `JWT_APP_ID` en Jitsi
+- `JITSI_APP_SECRET` debe coincidir con `JWT_APP_SECRET` en Jitsi
+- Estos valores deben ser EXACTAMENTE iguales
+
+## 🧪 Testing
+
+### 1. Verificar que el servidor esté accesible:
+
+```bash
+curl https://meet.checkthis.space/
+```
+
+### 2. Crear una cita de prueba y probar el flujo completo:
+
+1. Crear una cita como PATIENT
+2. Aprobarla como DOCTOR
+3. Ambos usuarios intentar unirse a la videollamada
+4. Verificar que:
+   - El token JWT se genera correctamente
+   - Jitsi acepta el token
+   - Los permisos de moderador funcionan correctamente
+
+### 3. Verificar logs del backend:
+
+```bash
+npm run dev
+```
+
+Deberías ver en la consola:
+```
+🎥 Jitsi Meet:
+   Domain: meet.checkthis.space
+   App ID: ✅ Configurado
+   App Secret: ✅ Configurado
+   Use JWT: ✅ Habilitado
+```
+
+## 🐛 Troubleshooting
+
+### Problema: "No se pudo conectar con el servidor de videollamadas"
+
+**Solución:**
+- Verifica que `meet.checkthis.space` sea accesible desde el navegador
+- Verifica que el certificado SSL sea válido
+- Verifica que el script `https://meet.checkthis.space/external_api.js` cargue correctamente
+
+### Problema: "Invalid JWT token"
+
+**Solución:**
+1. Verifica que `JITSI_APP_ID` y `JWT_APP_ID` coincidan exactamente
+2. Verifica que `JITSI_APP_SECRET` y `JWT_APP_SECRET` coincidan exactamente
+3. Verifica que `JWT_ACCEPTED_ISSUERS` incluya el valor de `JWT_APP_ID`
+4. Verifica que `JWT_ACCEPTED_AUDIENCES` incluya el valor de `JWT_APP_ID`
+5. Revisa los logs de Jitsi: `docker compose logs -f prosody`
+
+### Problema: Token expirado
+
+**Solución:**
+- El token expira después de 2 horas por defecto
+- Si necesitas más tiempo, modifica el parámetro `expiresInSeconds` en [src/app/api/appointments/[id]/jitsi-token/route.ts:146](src/app/api/appointments/[id]/jitsi-token/route.ts#L146)
+
+### Problema: Usuario no tiene permisos de moderador
+
+**Solución:**
+- Solo usuarios con rol `DOCTOR` o `ADMIN` son moderadores
+- Verifica el rol del usuario en la base de datos
+- El campo `isModerator` se calcula en [src/app/api/appointments/[id]/jitsi-token/route.ts:138](src/app/api/appointments/[id]/jitsi-token/route.ts#L138)
+
+## 📚 Referencias
+
+- [Jitsi JWT Authentication](https://jitsi.github.io/handbook/docs/devops-guide/devops-guide-docker/#authentication)
+- [JWT.io Debugger](https://jwt.io/) - Para debugging de tokens
+- [Jitsi Meet API](https://jitsi.github.io/handbook/docs/dev-guide/dev-guide-iframe)
+
+## 🔒 Seguridad
+
+**IMPORTANTE:** En producción:
+
+1. ✅ Cambia `JWT_APP_SECRET` por un valor secreto y seguro
+2. ✅ Usa HTTPS para todo (tu dominio y tu app)
+3. ✅ No expongas `JITSI_APP_SECRET` en el frontend
+4. ✅ Los tokens se generan en el backend (ya implementado)
+5. ✅ Configura `JWT_ACCEPTED_ISSUERS` y `JWT_ACCEPTED_AUDIENCES`
+
+## 🎯 Próximos pasos opcionales
+
+### Configuración adicional de Jitsi:
+
+- **Grabar reuniones:** Configurar Jibri para grabación
+- **Transcripción:** Configurar Jigasi para transcripción
+- **TURN server:** Para mejorar conectividad detrás de firewalls
+- **Límites de sala:** Limitar número de participantes por sala
+- **Branding:** Personalizar logo y colores de Jitsi
+
+### Mejoras en la aplicación:
+
+- Agregar notificaciones cuando un participante se une
+- Guardar duración de la videollamada en la base de datos
+- Agregar grabación automática para citas específicas
+- Implementar espera virtual (waiting room)

+ 161 - 0
src/app/api/appointments/[id]/jitsi-token/route.ts

@@ -0,0 +1,161 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/lib/auth";
+import { prisma } from "@/lib/prisma";
+import { generateJitsiToken, isJitsiJWTConfigured } from "@/lib/jitsi-jwt";
+import { config } from "@/lib/config";
+
+// GET /api/appointments/[id]/jitsi-token - Generar JWT token para Jitsi
+export async function GET(
+  request: NextRequest,
+  { params }: { params: Promise<{ id: string }> }
+) {
+  try {
+    const { id } = await params;
+    const session = await getServerSession(authOptions);
+
+    if (!session?.user?.id) {
+      return NextResponse.json({ error: "No autorizado" }, { status: 401 });
+    }
+
+    // Verificar si JWT está configurado
+    if (!isJitsiJWTConfigured()) {
+      return NextResponse.json(
+        {
+          error: "Jitsi JWT no está configurado",
+          useJWT: false,
+          domain: config.jitsi.domain,
+        },
+        { status: 200 }
+      );
+    }
+
+    const user = await prisma.user.findUnique({
+      where: { id: session.user.id },
+    });
+
+    if (!user) {
+      return NextResponse.json(
+        { error: "Usuario no encontrado" },
+        { status: 404 }
+      );
+    }
+
+    const appointment = await prisma.appointment.findUnique({
+      where: { id },
+      include: {
+        paciente: true,
+        medico: true,
+      },
+    });
+
+    if (!appointment) {
+      return NextResponse.json(
+        { error: "Cita no encontrada" },
+        { status: 404 }
+      );
+    }
+
+    // Validar que el usuario sea parte de la cita
+    const isPatient = appointment.pacienteId === user.id;
+    const isDoctor = appointment.medicoId === user.id;
+
+    if (!isPatient && !isDoctor) {
+      return NextResponse.json(
+        { error: "No tienes acceso a esta cita" },
+        { status: 403 }
+      );
+    }
+
+    // Validar que la cita esté aprobada o completada
+    if (
+      appointment.estado !== "APROBADA" &&
+      appointment.estado !== "COMPLETADA"
+    ) {
+      return NextResponse.json(
+        { error: "Solo se pueden acceder a citas aprobadas" },
+        { status: 400 }
+      );
+    }
+
+    // Validar que tenga fecha asignada
+    if (!appointment.fechaSolicitada) {
+      return NextResponse.json(
+        { error: "La cita no tiene fecha asignada" },
+        { status: 400 }
+      );
+    }
+
+    // Validar el tiempo: permitir unirse 15 minutos antes hasta 1 hora después
+    const now = new Date();
+    const appointmentTime = new Date(appointment.fechaSolicitada);
+    const fifteenMinutesBefore = new Date(
+      appointmentTime.getTime() - 15 * 60 * 1000
+    );
+    const oneHourAfter = new Date(
+      appointmentTime.getTime() + 60 * 60 * 1000
+    );
+
+    if (now < fifteenMinutesBefore) {
+      const minutesUntil = Math.floor(
+        (appointmentTime.getTime() - now.getTime()) / (60 * 1000)
+      );
+      return NextResponse.json(
+        {
+          error: "Aún no es tiempo de la cita",
+          message: `La cita será en ${minutesUntil} minutos. Podrás unirte 15 minutos antes.`,
+          minutesUntil,
+        },
+        { status: 400 }
+      );
+    }
+
+    if (now > oneHourAfter) {
+      return NextResponse.json(
+        { error: "La cita ya finalizó" },
+        { status: 400 }
+      );
+    }
+
+    // Determinar nombre de sala
+    const roomName = appointment.roomName || `appointment-${id}`;
+
+    // Si no existe roomName, crearlo
+    if (!appointment.roomName) {
+      await prisma.appointment.update({
+        where: { id },
+        data: { roomName },
+      });
+    }
+
+    // Generar JWT token
+    const userName = `${user.name} ${user.lastname || ""}`.trim();
+    const isModerator = user.role === "DOCTOR" || user.role === "ADMIN";
+    const avatarUrl = user.profileImage || undefined;
+
+    const token = generateJitsiToken(
+      user.id,
+      userName,
+      user.email || undefined,
+      roomName,
+      isModerator,
+      avatarUrl,
+      7200 // 2 horas de expiración
+    );
+
+    return NextResponse.json({
+      token,
+      roomName,
+      domain: config.jitsi.domain,
+      userName,
+      isModerator,
+      useJWT: true,
+    });
+  } catch (error) {
+    console.error("Error al generar JWT token de Jitsi:", error);
+    return NextResponse.json(
+      { error: "Error al generar token de autenticación" },
+      { status: 500 }
+    );
+  }
+}

+ 49 - 16
src/app/appointments/[id]/meet/page.tsx

@@ -45,8 +45,12 @@ export default function MeetPage() {
   const [loading, setLoading] = useState(true);
   const [accessDenied, setAccessDenied] = useState(false);
   const [denialReason, setDenialReason] = useState("");
+  const [jitsiToken, setJitsiToken] = useState<string | null>(null);
+  const [jitsiDomain, setJitsiDomain] = useState<string>("meet.jit.si");
+  const [jitsiRoomName, setJitsiRoomName] = useState<string>("");
+  const [useJWT, setUseJWT] = useState<boolean>(false);
 
-  // Cargar información del appointment
+  // Cargar información del appointment y JWT token
   useEffect(() => {
     const loadAppointment = async () => {
       try {
@@ -54,18 +58,35 @@ export default function MeetPage() {
         if (response.ok) {
           const data = await response.json();
           setAppointment(data);
-          
+
           // Validar acceso por tiempo
           const timeCheck = canJoinMeeting(data.fechaSolicitada);
           if (!timeCheck.canJoin) {
             setAccessDenied(true);
             setDenialReason(timeCheck.reason || "No puedes acceder a esta videollamada");
+            setLoading(false);
+            return;
           }
-          
+
           // Validar que la cita esté aprobada
           if (data.estado !== "APROBADA" && data.estado !== "COMPLETADA") {
             setAccessDenied(true);
             setDenialReason("Esta cita no está aprobada");
+            setLoading(false);
+            return;
+          }
+
+          // Obtener JWT token para Jitsi
+          const tokenResponse = await fetch(`/api/appointments/${params.id}/jitsi-token`);
+          if (tokenResponse.ok) {
+            const tokenData = await tokenResponse.json();
+            setJitsiToken(tokenData.token || null);
+            setJitsiDomain(tokenData.domain || "meet.jit.si");
+            setJitsiRoomName(tokenData.roomName || `appointment-${params.id}`);
+            setUseJWT(tokenData.useJWT || false);
+          } else {
+            console.warn("No se pudo obtener JWT token, usando configuración por defecto");
+            setJitsiRoomName(`appointment-${params.id}`);
           }
         } else {
           setAccessDenied(true);
@@ -108,13 +129,12 @@ export default function MeetPage() {
   };
 
   const initJitsi = useCallback(() => {
-    if (!jitsiContainer.current || !session || isInitialized.current) return;
+    if (!jitsiContainer.current || !session || isInitialized.current || !jitsiRoomName) return;
 
     isInitialized.current = true;
 
-    const domain = "meet.jit.si";
-    const options = {
-      roomName: `appointment-${params.id}`,
+    const options: Record<string, unknown> = {
+      roomName: jitsiRoomName,
       width: "100%",
       height: 600,
       parentNode: jitsiContainer.current,
@@ -142,12 +162,17 @@ export default function MeetPage() {
         SHOW_WATERMARK_FOR_GUESTS: false,
       },
       userInfo: {
-        displayName: session.user?.name || "Usuario",
+        displayName: `${session.user?.name || "Usuario"} ${session.user?.lastname || ""}`.trim(),
         email: session.user?.email || undefined,
       },
     };
 
-    jitsiApi.current = new window.JitsiMeetExternalAPI(domain, options);
+    // Si se usa JWT, agregar el token
+    if (useJWT && jitsiToken) {
+      options.jwt = jitsiToken;
+    }
+
+    jitsiApi.current = new window.JitsiMeetExternalAPI(jitsiDomain, options);
 
     // Event listeners - Solo redirigir si el usuario salió desde Jitsi directamente
     jitsiApi.current.addEventListener("videoConferenceLeft", () => {
@@ -166,7 +191,7 @@ export default function MeetPage() {
         }
       }, 100);
     });
-  }, [session, params.id, router]);
+  }, [session, jitsiRoomName, jitsiDomain, jitsiToken, useJWT, router]);
 
   const handleExitClick = () => {
     setShowExitDialog(true);
@@ -205,11 +230,14 @@ export default function MeetPage() {
   }, []);
 
   useEffect(() => {
-    if (status === "loading" || !session || !jitsiContainer.current || isInitialized.current) return;
+    if (status === "loading" || !session || !jitsiContainer.current || isInitialized.current || !jitsiDomain || !jitsiRoomName) return;
+
+    // Construir la URL del script usando el dominio configurado
+    const scriptSrc = `https://${jitsiDomain}/external_api.js`;
 
     // Verificar si el script ya está cargado
-    const existingScript = document.querySelector('script[src="https://meet.jit.si/external_api.js"]');
-    
+    const existingScript = document.querySelector(`script[src="${scriptSrc}"]`);
+
     if (existingScript) {
       // Si el script ya existe y window.JitsiMeetExternalAPI está disponible, inicializar directamente
       if (window.JitsiMeetExternalAPI) {
@@ -218,11 +246,16 @@ export default function MeetPage() {
       return;
     }
 
-    // Cargar Jitsi script
+    // Cargar Jitsi script desde el dominio configurado
     const script = document.createElement("script");
-    script.src = "https://meet.jit.si/external_api.js";
+    script.src = scriptSrc;
     script.async = true;
     script.onload = () => initJitsi();
+    script.onerror = () => {
+      console.error(`Error al cargar el script de Jitsi desde ${scriptSrc}`);
+      setAccessDenied(true);
+      setDenialReason("No se pudo conectar con el servidor de videollamadas");
+    };
     document.body.appendChild(script);
 
     return () => {
@@ -233,7 +266,7 @@ export default function MeetPage() {
       isInitialized.current = false;
       // No eliminar el script aquí para evitar conflictos
     };
-  }, [status, session, initJitsi]);
+  }, [status, session, jitsiDomain, jitsiRoomName, initJitsi]);
 
   if (status === "loading" || loading) {
     return (

+ 23 - 4
src/lib/config.ts

@@ -35,7 +35,15 @@ export const config = {
     appId: process.env.UTB_API_APP_ID || '',
     appToken: process.env.UTB_API_APP_TOKEN || '',
   },
-  
+
+  // Jitsi Meet
+  jitsi: {
+    domain: process.env.JITSI_DOMAIN || 'meet.jit.si',
+    appId: process.env.JITSI_APP_ID || '',
+    appSecret: process.env.JITSI_APP_SECRET || '',
+    useJWT: process.env.JITSI_USE_JWT === 'true',
+  },
+
   // Environment
   env: process.env.NODE_ENV || 'development',
   port: process.env.PORT || '3000',
@@ -71,12 +79,19 @@ export function logEnvironmentConfig() {
   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'}`)
-  
+
+  // Jitsi Meet
+  console.log('🎥 Jitsi Meet:')
+  console.log(`   Domain: ${config.jitsi.domain}`)
+  console.log(`   App ID: ${config.jitsi.appId ? '✅ Configurado' : '❌ No configurado'}`)
+  console.log(`   App Secret: ${config.jitsi.appSecret ? '✅ Configurado' : '❌ No configurado'}`)
+  console.log(`   Use JWT: ${config.jitsi.useJWT ? '✅ Habilitado' : '❌ Deshabilitado'}`)
+
   // Environment
   console.log('🌍 Environment:')
   console.log(`   NODE_ENV: ${config.env}`)
   console.log(`   PORT: ${config.port}`)
-  
+
   console.log('==========================================')
   
   // Advertencias
@@ -91,7 +106,11 @@ export function logEnvironmentConfig() {
   if (!config.utbApi.appId || !config.utbApi.appToken) {
     console.log('⚠️ ADVERTENCIA: UTB API credentials no están configuradas')
   }
-  
+
+  if (config.jitsi.useJWT && (!config.jitsi.appId || !config.jitsi.appSecret)) {
+    console.log('⚠️ ADVERTENCIA: Jitsi JWT está habilitado pero las credenciales no están configuradas')
+  }
+
   console.log('')
 }
 

+ 100 - 0
src/lib/jitsi-jwt.ts

@@ -0,0 +1,100 @@
+import jwt from 'jsonwebtoken';
+import { config } from './config';
+
+export interface JitsiJWTPayload {
+  context: {
+    user: {
+      id: string;
+      name: string;
+      email?: string;
+      avatar?: string;
+    };
+    features?: {
+      livestreaming?: boolean | string;
+      recording?: boolean | string;
+      transcription?: boolean | string;
+      'outbound-call'?: boolean | string;
+      'sip-outbound-call'?: boolean | string;
+    };
+  };
+  moderator?: boolean;
+  aud: string;
+  iss: string;
+  sub: string;
+  room: string;
+  exp?: number;
+  nbf?: number;
+}
+
+/**
+ * Genera un JWT token para autenticación en Jitsi Meet
+ *
+ * @param userId - ID del usuario
+ * @param userName - Nombre completo del usuario
+ * @param email - Email del usuario (opcional)
+ * @param roomName - Nombre de la sala de Jitsi
+ * @param isModerator - Si el usuario tiene permisos de moderador
+ * @param avatarUrl - URL del avatar del usuario (opcional)
+ * @param expiresInSeconds - Tiempo de expiración en segundos (por defecto 2 horas)
+ * @returns JWT token firmado
+ */
+export function generateJitsiToken(
+  userId: string,
+  userName: string,
+  email: string | undefined,
+  roomName: string,
+  isModerator: boolean = false,
+  avatarUrl?: string,
+  expiresInSeconds: number = 7200 // 2 horas por defecto
+): string {
+  if (!config.jitsi.appId || !config.jitsi.appSecret) {
+    throw new Error('Jitsi JWT credentials not configured');
+  }
+
+  const now = Math.floor(Date.now() / 1000);
+
+  const payload: JitsiJWTPayload = {
+    context: {
+      user: {
+        id: userId,
+        name: userName,
+        email: email,
+        avatar: avatarUrl,
+      },
+      features: {
+        livestreaming: isModerator ? 'true' : false,
+        recording: isModerator ? 'true' : false,
+        transcription: isModerator ? 'true' : false,
+      },
+    },
+    moderator: isModerator,
+    aud: config.jitsi.appId, // Debe coincidir con JWT_APP_ID en Jitsi
+    iss: config.jitsi.appId, // Debe coincidir con JWT_APP_ID en Jitsi
+    sub: config.jitsi.domain, // Tu dominio de Jitsi
+    room: roomName,
+    exp: now + expiresInSeconds,
+    nbf: now - 10, // Not before: 10 segundos antes para compensar desincronización de relojes
+  };
+
+  const token = jwt.sign(payload, config.jitsi.appSecret, {
+    algorithm: 'HS256',
+    header: {
+      typ: 'JWT',
+      alg: 'HS256',
+    },
+  });
+
+  return token;
+}
+
+/**
+ * Verifica si la configuración de Jitsi JWT está completa
+ */
+export function isJitsiJWTConfigured(): boolean {
+  return !!(
+    config.jitsi.useJWT &&
+    config.jitsi.appId &&
+    config.jitsi.appSecret &&
+    config.jitsi.domain
+  );
+}