Compare commits

...

10 Commits

Author SHA1 Message Date
Mireya Cueto Garrido 699a0364b6 Update README with image and Tech Stack section
Added an image and updated the Tech Stack section.
2026-06-04 13:35:16 +02:00
Mireya Cueto Garrido d1b50c8f34 Delete deploy directory 2026-06-04 13:34:04 +02:00
Mireya Cueto Garrido 4d2ced85a3 Harden LLM access: secrets only in server .env, no URL in repo.
Require LLM_BASE_URL and LLM_API_KEY for automatic generation, add per-user rate limits, stop publishing backend/LLM settings in docker-compose, and document secure deployment.
2026-06-04 13:24:40 +02:00
Mireya Cueto Garrido 182eae1e36 Redirect app root to login when visiting /generadorexamenesllm without a session.
This fixes the blank page on the base URL by normalizing the no-trailing-slash path in nginx and redirecting unauthenticated users to /login before React mounts.
2026-06-03 12:45:24 +02:00
Mireya Cueto Garrido d06b961a73 Use Sinbad2 production ports 8068 (backend) and 8069 (frontend).
Apache ProxyPass targets host port 8069; update compose, CI, CORS defaults, deployment docs, and production .env files for https://sinbad2.ujaen.es/generadorexamenesllm/.
2026-06-03 12:13:02 +02:00
Mireya Cueto Garrido 7dcc7dc0e1 Align Sinbad2 HTTPS deployment with orcid2sword reverse-proxy pattern.
This adds nginx dual-path routing, forwarded proxy headers, Uvicorn proxy-headers, production security settings, and deployment docs for https://sinbad2.ujaen.es/generadorexamenesllm/.
2026-06-03 10:12:05 +02:00
Mireya Cueto Garrido ca6d370585 Fix production base path and ProxyPass docs for /generadorexamenesllm.
This removes the incorrect /deckofcars prefix, aligns CI/build defaults with the real public URL, and adds an Apache ProxyPass snippet so the request is routed to the Docker frontend instead of the Sinbad2 site.
2026-06-02 13:26:34 +02:00
Mireya Cueto Garrido 63f2d07bb3 Fix public base path for Sinbad2 /deckofcars reverse proxy.
This aligns frontend build defaults and CI deploy variables with the real proxy mount so the app is served under /deckofcars/generadorexamenesllm instead of falling back to the Sinbad homepage.
2026-06-02 13:17:02 +02:00
Mireya Cueto Garrido 944482b96c Support HTTPS deployment under /generadorexamenesllm behind reverse proxy.
This updates frontend base-path routing, same-origin API proxying, and deployment defaults/docs so the app works correctly through the Sinbad2 Apache ProxyPass setup.
2026-06-02 13:10:52 +02:00
Mireya Cueto Garrido d7f9ae8841 Improve responsive UX/UI consistency across all frontend screens.
This polish pass unifies mobile navigation, spacing, typography hierarchy, and CTA behavior so all core exam workflows remain clear and fully usable on both mobile and desktop.
2026-06-02 12:56:30 +02:00
39 changed files with 1214 additions and 278 deletions
+9 -3
View File
@@ -3,8 +3,8 @@ stages:
variables:
APP_NAME: "generadorexamenesllms"
BACKEND_PORT: "8074"
FRONTEND_PORT: "8075"
BACKEND_PORT: "8068"
FRONTEND_PORT: "8069"
deploy_to_sinbad2:
stage: deploy
@@ -46,7 +46,12 @@ deploy_to_sinbad2:
export BACKEND_PORT=$BACKEND_PORT
export FRONTEND_PORT=$FRONTEND_PORT
export ALLOWED_ORIGINS="http://sinbad2.ujaen.es,http://sinbad2.ujaen.es:$FRONTEND_PORT"
export ENVIRONMENT=production
export PUBLIC_BASE_URL="https://sinbad2.ujaen.es/generadorexamenesllm"
export TRUSTED_HOSTS="sinbad2.ujaen.es,localhost,127.0.0.1"
export ALLOWED_ORIGINS="https://sinbad2.ujaen.es,http://sinbad2.ujaen.es,http://sinbad2.ujaen.es:$FRONTEND_PORT"
export VITE_APP_BASE_PATH="/generadorexamenesllm/"
export VITE_API_URL=""
docker compose down --remove-orphans
docker compose build --no-cache frontend backend
@@ -56,6 +61,7 @@ deploy_to_sinbad2:
- echo "Despliegue completado."
- echo "Backend -> http://sinbad2.ujaen.es:$BACKEND_PORT"
- echo "Frontend -> http://sinbad2.ujaen.es:$FRONTEND_PORT"
- echo "Public URL -> https://sinbad2.ujaen.es/generadorexamenesllm/"
only:
- branches
+3 -2
View File
@@ -581,14 +581,15 @@ Todas con `Authorization: Bearer <token>`.
| Código | Ejemplo |
|--------|---------|
| 503 | `LLM_API_KEY` no configurada (`llm_unavailable`). |
| 503 | LLM no configurado en el servidor (`llm_unavailable`: faltan `LLM_BASE_URL` y/o `LLM_API_KEY` en `.env`). |
| 429 | Demasiadas generaciones automáticas por usuario (`llm_rate_limited`). |
| 422 | JSON del modelo inválido (`parse_error`). |
```json
{
"error": {
"code": "llm_unavailable",
"message": "LLM_API_KEY is not configured"
"message": "Automatic AI generation is not available"
}
}
```
+377 -154
View File
@@ -1,185 +1,408 @@
# GenExamenes IA
# GenExámenes IA
Backend para generar exámenes con IA, procesar la salida de un LLM y exportar preguntas a Moodle XML.
<div align="center">
**Guía detallada de flujo, endpoints, ejemplos y errores:** [GUIA_API_Y_FLUJO.md](GUIA_API_Y_FLUJO.md)
![FastAPI](https://img.shields.io/badge/FastAPI-109989?style=for-the-badge&logo=fastapi&logoColor=white)
![Python](https://img.shields.io/badge/Python-3776AB?style=for-the-badge&logo=python&logoColor=white)
![React](https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB)
![Vite](https://img.shields.io/badge/Vite-646CFF?style=for-the-badge&logo=vite&logoColor=white)
![PostgreSQL](https://img.shields.io/badge/PostgreSQL-316192?style=for-the-badge&logo=postgresql&logoColor=white)
![Docker](https://img.shields.io/badge/Docker-0db7ed?style=for-the-badge&logo=docker&logoColor=white)
![Moodle](https://img.shields.io/badge/Moodle_XML-FF7800?style=for-the-badge)
![LLM](https://img.shields.io/badge/Sinbad2IA-LLM-6C63FF?style=for-the-badge)
El proyecto está centrado en backend. La carpeta `frontend` se mantiene vacía a nivel de aplicación, aunque existe un servicio en Docker Compose para reservar el despliegue futuro.
</div>
## Stack
<div align="center">
<strong>Plataforma full-stack para diseñar exámenes con IA, gestionar materiales de contexto y exportar bancos de preguntas a Moodle XML, TXT y JSON.</strong>
</div>
---
## ![certificate](https://www.readmecodegen.com/api/social-icon?name=certificate&size=20) Overview: What is this project meant
`GenExámenes IA` está pensado para flujos docentes en los que hay que crear evaluaciones estructuradas, apoyarse en un LLM y exportar el resultado a Moodle.
Capacidades principales:
- Registro e inicio de sesión (email/contraseña y Google opcional)
- Creación de plantillas de examen (tipos de pregunta, dificultad, barajar, feedback)
- Subida de **Material IA** (PDF, DOCX, TXT, MD…) con extracción de texto para contexto
- Subida de **imágenes** para preguntas visuales (embebidas en Moodle XML)
- Generación de preguntas: **automática** (LLM en servidor), **solo prompt** o **importar JSON/TXT**
- Revisión, vinculación imagenpregunta y exportación **Moodle XML**, TXT o JSON
- Cupo de almacenamiento por examen y controles de seguridad en producción
> [!NOTE]
> El despliegue en Sinbad2 (UJA) sigue el patrón **orcid2sword**: HTTPS en Apache, contenedores en HTTP en puertos internos (`8069` frontend, `8068` backend). Ver [deploy/DESPLIEGUE_SINBAD2.md](deploy/DESPLIEGUE_SINBAD2.md).
**URL pública (producción):** `https://sinbad2.ujaen.es/generadorexamenesllm/`
**Guía ampliada de API, flujo y errores:** [GUIA_API_Y_FLUJO.md](GUIA_API_Y_FLUJO.md)
<img width="1181" height="705" alt="image" src="https://github.com/user-attachments/assets/8176ba2d-682b-479a-abbd-14dabfa123cd" />
---
## ![certificate](https://www.readmecodegen.com/api/social-icon?name=certificate&size=20) Tech Stack
### Backend
- FastAPI
- PostgreSQL
- SQLAlchemy
- Cliente LLM para Sinbad2IA UJA (`POST /api/chat`, modelo `qwen3.5:35b`)
- Docker Compose con servicios `backend`, `frontend` y `db`
- PostgreSQL
- python-jose (JWT)
- bcrypt (contraseñas)
- httpx (cliente LLM Sinbad2IA)
- pypdf / python-docx / Pillow / pytesseract (extracción de materiales)
## Puesta en Marcha
### Frontend
- React 18
- Vite 5
- React Router 6
- Axios
- CSS propio (UI responsive, menú móvil)
Copia el ejemplo de variables dentro de la carpeta del backend:
### Infrastructure
- Docker / Docker Compose
- Nginx (contenedor frontend: SPA + proxy `/auth` y `/exam`)
- Apache reverse proxy (HTTPS en Sinbad2, fuera del repo)
### IA
- Sinbad2IA UJA — `POST {LLM_BASE_URL}/api/chat` (modelo por defecto `qwen3.5:35b`)
---
## ![certificate](https://www.readmecodegen.com/api/social-icon?name=certificate&size=20) Quick Start
Desde la raíz del proyecto:
```bash
cp backend/.env.example backend/.env
# Edita backend/.env: LLM_BASE_URL y LLM_API_KEY (solo en el servidor, no en Git)
docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
```
Después levanta los servicios:
URLs por defecto con Docker Compose:
| Servicio | URL local |
| :--------- | :--------------------------------- |
| Frontend | `http://localhost:8069` |
| Frontend (ruta pública) | `http://localhost:8069/generadorexamenesllm/` |
| Backend | `http://localhost:8068` |
| PostgreSQL | `localhost:5432` |
Desarrollo del frontend sin Docker (solo UI):
```bash
docker compose up --build
cd frontend
npm install
npm run dev
```
La API queda disponible en:
Vite escucha en `http://localhost:8075` (configurable en `vite.config.js`).
> [!IMPORTANT]
> Las rutas bajo `/exam` requieren JWT de usuario (`Authorization: Bearer <token>`). El material y las imágenes son **opcionales** para generar; basta el campo «Tema / instrucciones para la IA» (mínimo 5 caracteres).
> [!IMPORTANT]
> **Seguridad del LLM:** la URL y la clave del servicio de IA **no** están en el repositorio. `POST /exam/generate` solo funciona si el servidor tiene `LLM_BASE_URL` y `LLM_API_KEY` en `backend/.env`. El backend no se publica en el host en producción (solo nginx en `:8069`). Quien clone el repo **no** puede llamar al LLM sin esas variables en el despliegue.
> [!TIP]
> Si la generación automática devuelve 503 (*Automatic AI generation is not available*), faltan variables LLM en el servidor o el servicio no es alcanzable desde el contenedor. Usa **Solo prompt** → LLM externo → **Pegar respuesta IA**.
---
## ![certificate](https://www.readmecodegen.com/api/social-icon?name=certificate&size=20) Environment Configuration
**Backend**
- Archivo principal: `backend/.env`
- Referencia: `backend/.env.example`
**Frontend**
- Build Docker / ejemplo: `frontend/.env.example`
- Variables inyectadas en build: `VITE_APP_BASE_PATH`, `VITE_API_URL`, `VITE_GOOGLE_CLIENT_ID`
Variables backend importantes:
| Variable | Descripción |
| :------- | :---------- |
| `JWT_SECRET_KEY` | Secreto JWT (≥ 32 caracteres) |
| `JWT_EXPIRE_MINUTES` | Duración del token |
| `GOOGLE_CLIENT_ID` | OAuth Google (`POST /auth/google`) |
| `DATABASE_URL` | PostgreSQL |
| `ALLOWED_ORIGINS` | Orígenes CORS (HTTPS Sinbad2) |
| `TRUSTED_HOSTS` | Hosts permitidos (`TrustedHostMiddleware`) |
| `PUBLIC_BASE_URL` | URL pública con prefijo de despliegue |
| `LLM_BASE_URL` | URL interna del LLM (**solo** en `.env` del servidor, no en el repo) |
| `LLM_API_KEY` | Clave para autorizar llamadas al LLM (**obligatoria** para generación automática) |
| `LLM_MODEL` | Modelo (p. ej. `qwen3.5:35b`) |
| `LLM_TIMEOUT_SECONDS` | Timeout llamada LLM (180 s por defecto) |
| `LLM_GENERATE_RATE_LIMIT_*` | Cuota de `POST /exam/generate` por usuario |
| `MAX_STORAGE_BYTES_PER_TEMPLATE` | Cupo materiales + imágenes por examen |
Variables frontend importantes:
| Variable | Descripción |
| :------- | :---------- |
| `VITE_APP_BASE_PATH` | Prefijo SPA (`/generadorexamenesllm/` en producción) |
| `VITE_API_URL` | Vacío en producción: API en el mismo origen vía nginx |
| `VITE_GOOGLE_CLIENT_ID` | Debe coincidir con `GOOGLE_CLIENT_ID` del backend |
> [!WARNING]
> No subas secretos reales al repositorio. Rota `JWT_SECRET_KEY` y credenciales de Google antes de producción.
---
## ![certificate](https://www.readmecodegen.com/api/social-icon?name=certificate&size=20) API Endpoints
Base backend (Docker local): `http://localhost:8068`
Todas las rutas `/exam/*` requieren **Bearer JWT** salvo que se indique lo contrario.
| Módulo | Método | Endpoint | Auth | Notas |
| :----- | :----: | :------- | :--- | :---- |
| Health | `GET` | `/health` | Ninguna | Liveness |
| Auth | `POST` | `/auth/register` | Ninguna | Email + contraseña |
| Auth | `POST` | `/auth/login` | Ninguna | Devuelve `access_token` |
| Auth | `POST` | `/auth/google` | Ninguna | Body: `{ "id_token": "..." }` |
| Auth | `GET` | `/auth/me` | Bearer | Usuario actual |
| Plantillas | `POST` | `/exam/templates` | Bearer | Crear examen |
| Plantillas | `GET` | `/exam/templates` | Bearer | Listar del usuario |
| Plantillas | `GET` | `/exam/templates/{id}` | Bearer | Detalle |
| Plantillas | `GET` | `/exam/templates/{id}/storage` | Bearer | Uso de almacenamiento |
| Materiales | `POST` | `/exam/templates/{id}/materials` | Bearer | `multipart/form-data` |
| Materiales | `GET` | `/exam/templates/{id}/materials` | Bearer | Listado |
| Imágenes | `POST` | `/exam/templates/{id}/images` | Bearer | Imagen para preguntas |
| Imágenes | `GET` | `/exam/images/{image_id}/content` | Bearer | Contenido binario |
| Preguntas | `GET` | `/exam/templates/{id}/questions` | Bearer | Listado |
| Preguntas | `PATCH` | `/exam/questions/{id}/image` | Bearer | Vincular imagen |
| IA | `POST` | `/exam/prompts/{template_id}` | Bearer | Construir prompt |
| IA | `POST` | `/exam/generate` | Bearer | LLM + guardar preguntas |
| IA | `POST` | `/exam/parse` | Bearer | Importar JSON/TXT externo |
| Export | `GET` | `/exam/export/xml/{template_id}` | Bearer | Moodle XML |
| Export | `GET` | `/exam/export/txt/{template_id}` | Bearer | Texto plano |
| Export | `GET` | `/exam/export/json/{template_id}` | Bearer | JSON |
| Historial | `GET` | `/exam/history` | Bearer | Resumen de exámenes |
---
## ![certificate](https://www.readmecodegen.com/api/social-icon?name=certificate&size=20) Request Examples
### Health
```bash
curl http://localhost:8068/health
```
### Registro e inicio de sesión
```bash
curl -X POST "http://localhost:8068/auth/register" \
-H "Content-Type: application/json" \
-d "{\"email\":\"docente@ujaen.es\",\"password\":\"MiClaveSegura1\",\"password_confirm\":\"MiClaveSegura1\"}"
curl -X POST "http://localhost:8068/auth/login" \
-H "Content-Type: application/json" \
-d "{\"email\":\"docente@ujaen.es\",\"password\":\"MiClaveSegura1\"}"
```
### Crear plantilla de examen
```bash
curl -X POST "http://localhost:8068/exam/templates" \
-H "Authorization: Bearer YOUR_JWT" \
-H "Content-Type: application/json" \
-d "{
\"title\": \"Examen Tema 3\",
\"subject\": \"Sistemas Operativos\",
\"educational_level\": \"Grado\",
\"language\": \"es\",
\"settings\": {
\"question_types\": [{\"type\": \"multichoice\", \"count\": 5, \"score\": 1, \"penalty\": 0}],
\"shuffle_questions\": true,
\"shuffle_answers\": true,
\"include_feedback\": true
},
\"difficulty_profile\": {\"easy\": 2, \"medium\": 3, \"hard\": 0, \"very_hard\": 0}
}"
```
### Generar preguntas (solo con tema, sin materiales)
```bash
curl -X POST "http://localhost:8068/exam/generate" \
-H "Authorization: Bearer YOUR_JWT" \
-H "Content-Type: application/json" \
-d "{
\"template_id\": \"TEMPLATE_UUID\",
\"topic_prompt\": \"Genera preguntas sobre aritmética y trigonometría\",
\"material_ids\": null
}"
```
### Exportar Moodle XML
```bash
curl "http://localhost:8068/exam/export/xml/TEMPLATE_UUID" \
-H "Authorization: Bearer YOUR_JWT" \
-o examen_moodle.xml
```
---
## ![certificate](https://www.readmecodegen.com/api/social-icon?name=certificate&size=20) Security Controls
Controles implementados en backend:
- CORS con lista de orígenes permitidos
- filtrado de hosts de confianza (`TrustedHostMiddleware`)
- cabeceras de seguridad (HSTS en producción)
- límite de tamaño de petición
- rate limiting por cliente
- **LLM solo desde el servidor:** credenciales en `.env`, sin URL en el código público
- **`POST /exam/generate`:** exige JWT + `LLM_API_KEY` configurada + límite por usuario
- backend **no expuesto** en `docker-compose.yml` (solo red interna Docker; Apache → frontend)
- contraseñas con bcrypt
- JWT por usuario; cada plantilla pertenece a un único usuario
- sanitización de prompts y texto persistido
- contenedores sin privilegios innecesarios
> [!WARNING]
> En producción, fuerza HTTPS detrás de Apache y mantén `ALLOWED_ORIGINS` y `TRUSTED_HOSTS` alineados con `https://sinbad2.ujaen.es`. El servicio LLM de la UJA debería aceptar peticiones **solo** desde la red del backend y exigir `LLM_API_KEY` (firewall + clave en el propio Sinbad2IA).
---
## ![certificate](https://www.readmecodegen.com/api/social-icon?name=certificate&size=20) Frontend Details
SPA React con rutas protegidas y cliente API centralizado (`frontend/src/api/client.js`).
### Rutas principales
| Ruta | Página |
| :--- | :----- |
| `/login` | Inicio de sesión |
| `/registro` | Alta de usuario |
| `/` | Panel «Mis exámenes» |
| `/plantillas/nueva` | Asistente «Nuevo examen» |
| `/plantillas/:templateId` | Detalle con pestañas: Resumen, Material IA, Imágenes, Generar, Preguntas, Exportar |
### Pestaña Generar (tres modos)
- **Generación automática:** `POST /exam/generate` — llama al LLM del servidor.
- **Solo prompt:** `POST /exam/prompts/{id}` — copiar prompt a un LLM externo.
- **Pegar respuesta IA:** `POST /exam/parse` — importar JSON o TXT.
### Cliente API (`frontend/src/api/`)
- `client.js` — Axios, JWT en `localStorage`, errores normalizados (`ApiError`)
- `auth.js`, `templates.js`, `materials.js`, `images.js`, `generation.js`, `exports.js`, `questions.js`
### Nginx en Docker (`frontend/nginx.conf`)
- Sirve la SPA bajo `/` y `/generadorexamenesllm/`
- Proxy de `/auth/` y `/exam/` al backend (`backend:8074` en red interna)
- Redirección de raíz sin barra final al login
---
## ![certificate](https://www.readmecodegen.com/api/social-icon?name=certificate&size=20) Project Structure
```text
http://sinbad2.ujaen.es:8074
03_GeneradorExamenes/
├── backend/
│ ├── app/
│ │ ├── api/
│ │ │ ├── routes/
│ │ │ │ ├── auth.py
│ │ │ │ ├── templates.py
│ │ │ │ ├── generation.py
│ │ │ │ ├── materials.py
│ │ │ │ ├── images.py
│ │ │ │ ├── questions.py
│ │ │ │ ├── exports.py
│ │ │ │ └── history.py
│ │ │ └── dependencies.py
│ │ ├── core/
│ │ │ ├── config.py
│ │ │ ├── auth.py
│ │ │ ├── errors.py
│ │ │ ├── middleware.py
│ │ │ └── security_headers.py
│ │ ├── db/
│ │ ├── models/
│ │ ├── schemas/
│ │ ├── services/
│ │ │ ├── exam_service.py
│ │ │ ├── llm.py
│ │ │ ├── prompt_builder.py
│ │ │ ├── parser.py
│ │ │ ├── material_service.py
│ │ │ ├── image_service.py
│ │ │ └── moodle_exporter.py
│ │ └── main.py
│ ├── .env.example
│ ├── Dockerfile
│ └── requirements.txt
├── frontend/
│ ├── src/
│ │ ├── api/
│ │ ├── components/
│ │ ├── context/
│ │ ├── pages/
│ │ │ ├── DashboardPage.jsx
│ │ │ ├── CreateTemplatePage.jsx
│ │ │ ├── TemplateDetailPage.jsx
│ │ │ └── template/
│ │ │ ├── GenerateTab.jsx
│ │ │ ├── MaterialsTab.jsx
│ │ │ └── ExportTab.jsx
│ │ ├── App.jsx
│ │ └── main.jsx
│ ├── nginx.conf
│ ├── .env.example
│ ├── package.json
│ └── vite.config.js
├── deploy/
│ ├── DESPLIEGUE_SINBAD2.md
│ └── apache-reverse-proxy.conf
├── docs/
│ └── Manual_de_Usuario_GenExamenes_IA.docx
├── scripts/
│ └── generar_manual_usuario.py
├── docker-compose.yml
├── GUIA_API_Y_FLUJO.md
└── README.md
```
## Configuración
---
El archivo de entorno debe estar en `backend/.env`.
## ![certificate](https://www.readmecodegen.com/api/social-icon?name=certificate&size=20) Production Checklist
Variables principales:
- [ ] `ENVIRONMENT=production`
- [ ] Rotar `JWT_SECRET_KEY` y `GOOGLE_CLIENT_ID` de desarrollo
- [ ] `ALLOWED_ORIGINS` con orígenes HTTPS reales
- [ ] `TRUSTED_HOSTS` con `sinbad2.ujaen.es`
- [ ] `PUBLIC_BASE_URL=https://sinbad2.ujaen.es/generadorexamenesllm`
- [ ] Apache `ProxyPass` a puerto **8069** (frontend), no al backend directo
- [ ] Reglas Apache **antes** de WordPress en Sinbad2
- [ ] `LLM_BASE_URL` y `LLM_API_KEY` solo en `backend/.env` del servidor (no en Git)
- [ ] Comprobar conectividad backend → LLM (red interna UJA)
- [ ] Despliegue con `docker compose up` **sin** `docker-compose.dev.yml` (backend no publicado en host)
- [ ] Volumen `uploads_data` y backups de PostgreSQL
- [ ] Manual de usuario actualizado en `docs/`
- `JWT_SECRET_KEY`: secreto para firmar tokens JWT (mínimo 32 caracteres).
- `JWT_EXPIRE_MINUTES`: duración del token de acceso.
- `GOOGLE_CLIENT_ID`: Client ID de OAuth 2.0 en Google Cloud Console (para `/auth/google`).
- `DATABASE_URL`: conexión PostgreSQL usada por el backend.
- `LLM_BASE_URL`: URL base del servidor (por defecto ``; el cliente usa `/api/chat`).
- `LLM_MODEL`: modelo (por defecto `qwen3.5:35b`).
- `LLM_TIMEOUT_SECONDS`: tiempo máximo de espera (por defecto 180 s).
- `LLM_API_KEY`: opcional, solo si el servidor exige autenticación.
- `ALLOWED_ORIGINS`: orígenes permitidos por CORS.
- `MAX_STORAGE_BYTES_PER_TEMPLATE`: cupo total de almacenamiento por examen (materiales + imágenes).
---
Todas las rutas bajo `/exam` requieren autenticación de usuario con:
## ![github](https://www.readmecodegen.com/api/social-icon?name=github&size=20&color=%238b5cf6) Authors and Team
```http
Authorization: Bearer <access_token>
```
Este proyecto es el resultado de la colaboración con la **Universidad de Jaén** (grupo Sinbad²).
Si ya tenías una base de datos creada antes de añadir usuarios, recrea el volumen:
| Rol | Developer | GitHub |
| :--- | :--- | :--- |
| **Frontend** | Alexis López Moral | [@AlexisLopez-Dev](https://github.com/AlexisLopez-Dev) |
| **Backend** | Mireya Cueto Garrido | [@MireyaCueto](https://github.com/MireyaCueto) |
```bash
docker compose down -v
docker compose up --build
```
### Direction
* **Project Supervisor:** Luis Martínez López
## Flujo de Usuario
---
1. Registrarse o iniciar sesión.
2. Crear una plantilla de examen (queda asociada al usuario).
3. Subir materiales de referencia (PDF, DOCX, TXT, PNG, JPG…) a la plantilla.
4. Generar un prompt guiado para el LLM (incluye el texto extraído de los ficheros).
5. Generar preguntas automáticamente con el LLM o parsear una salida externa en JSON/TXT.
6. Guardar las preguntas validadas en PostgreSQL.
7. Consultar el historial de exámenes creados.
8. Exportar el examen a Moodle XML, TXT o JSON.
## Endpoints
`GET /health`
Comprueba que la API está levantada.
`POST /auth/register`
Registra un usuario con email y contraseña.
`POST /auth/login`
Devuelve un token JWT para usar en las rutas protegidas.
`POST /auth/google`
Recibe el `id_token` de Google (Sign in with Google en el frontend), verifica la cuenta y devuelve el mismo JWT de la API.
`GET /auth/me`
Devuelve los datos del usuario autenticado.
`GET /exam/history`
Lista el historial de exámenes del usuario (plantillas, preguntas y exportaciones).
`POST /exam/templates/{template_id}/materials`
Sube un fichero (`multipart/form-data`, campo `file`). Formatos: PDF, DOCX, TXT, MD, PNG, JPG, WEBP. Extrae texto y lo guarda como contexto.
`GET /exam/templates/{template_id}/materials`
Lista los materiales subidos a una plantilla.
`DELETE /exam/templates/{template_id}/materials/{material_id}`
Elimina un material.
`POST /exam/templates/{template_id}/images`
Sube una imagen para preguntas visuales (`file`, opcional `caption`). No se usa OCR: la imagen se muestra en el examen y se embebe en el XML de Moodle.
`GET /exam/templates/{template_id}/images`
Lista las imágenes de la plantilla.
`GET /exam/images/{image_id}/content`
Devuelve la imagen (requiere JWT). Para previsualizar en el frontend o en Moodle tras importar.
`DELETE /exam/templates/{template_id}/images/{image_id}`
Elimina una imagen.
`PATCH /exam/questions/{question_id}/image`
Vincula o desvincula una imagen a una pregunta existente (`{"image_id": "uuid"}` o `null`).
`POST /exam/templates`
Crea una plantilla con materia, nivel educativo, tipos de pregunta, puntuación, penalización y dificultad.
`GET /exam/templates`
Lista las plantillas del usuario autenticado.
`GET /exam/templates/{template_id}`
Obtiene una plantilla concreta.
`GET /exam/templates/{template_id}/storage`
Muestra cuánto espacio usa el examen (materiales + imágenes) y el límite configurado.
`POST /exam/prompts/{template_id}`
Genera un prompt estructurado para IA.
`POST /exam/generate`
Llama al LLM configurado, parsea la respuesta y guarda las preguntas.
`POST /exam/parse`
Procesa una salida externa de IA en formato `json` o `txt`.
`GET /exam/export/xml/{template_id}`
Exporta las preguntas en Moodle XML.
`GET /exam/export/txt/{template_id}`
Exporta las preguntas en texto plano.
`GET /exam/export/json/{template_id}`
Exporta las preguntas en JSON.
## Seguridad
- Registro e inicio de sesión con contraseña hasheada (bcrypt).
- Autenticación JWT por usuario.
- Cada examen pertenece a un único usuario; no se puede acceder al de otro.
- Rate limiting por cliente.
- Límite de tamaño de petición.
- Validación de entrada con Pydantic.
- Manejo uniforme de errores HTTP.
- Sanitización básica de prompts y respuestas antes de persistir/exportar.
<p align="center">
Built with professional care and ❤️ for AI-assisted exam workflows at the University of Jaén.
</p>
+24 -11
View File
@@ -1,23 +1,36 @@
# --- Aplicación ---
# --- Aplicación (producción Sinbad2) ---
APP_NAME=GenExamenes IA
ENVIRONMENT=local
# Clave legacy (reservada; las rutas /exam usan JWT de usuario).
ENVIRONMENT=production
PUBLIC_BASE_URL=https://sinbad2.ujaen.es/generadorexamenesllm
TRUSTED_HOSTS=sinbad2.ujaen.es,localhost,127.0.0.1
SECURITY_HSTS_SECONDS=31536000
API_KEY=change-me-in-production-min-16-chars
# --- Base de datos (Docker: host "db") ---
DATABASE_URL=postgresql+psycopg://genexamenes:genexamenes@db:5432/genexamenes
# --- CORS (orígenes del frontend, separados por coma) ---
ALLOWED_ORIGINS=http://sinbad2.ujaen.es,http://sinbad2.ujaen.es:8075
# --- Rate limiting y tamaño de petición ---
ALLOWED_ORIGINS=https://sinbad2.ujaen.es,http://sinbad2.ujaen.es,http://sinbad2.ujaen.es:8069
RATE_LIMIT_REQUESTS=60
RATE_LIMIT_WINDOW_SECONDS=60
MAX_REQUEST_BYTES=1048576
# --- JWT (login email/contraseña y sesión tras Google) ---
MAX_REQUEST_BYTES=25165824
UPLOAD_DIR=/app/uploads
MAX_UPLOAD_BYTES=20971520
MAX_MATERIALS_PER_TEMPLATE=10
MAX_REFERENCE_CHARS=12000
MAX_IMAGE_BYTES=5242880
MAX_IMAGES_PER_TEMPLATE=20
MAX_STORAGE_BYTES_PER_TEMPLATE=52428800
JWT_SECRET_KEY=f3c9e7a1b4d8c2f6a9e1d3b7c5f2e8a4d1c7b9e3f6a2c4e8b1d7f3a9c6e2b4d8
JWT_ALGORITHM=HS256
JWT_EXPIRE_MINUTES=1440
# --- Google Sign-In ---
GOOGLE_CLIENT_ID=123456789012-abcdefghijklmnopqrstuvwxyz123456.apps.googleusercontent.com
# --- LLM (Sinbad2IA UJA — sin clave) ---
LLM_BASE_URL=
LLM_MODEL=qwen3.5:35b
LLM_TIMEOUT_SECONDS=180
+19 -7
View File
@@ -1,6 +1,15 @@
# --- Aplicación ---
APP_NAME=GenExamenes IA
ENVIRONMENT=local
ENVIRONMENT=production
# URL pública HTTPS (Apache termina TLS; contenedores en HTTP interno)
PUBLIC_BASE_URL=https://sinbad2.ujaen.es/generadorexamenesllm
# Hosts aceptados por TrustedHostMiddleware (sin esquema ni puerto)
TRUSTED_HOSTS=sinbad2.ujaen.es,localhost,127.0.0.1
# HSTS (segundos; 1 año por defecto)
SECURITY_HSTS_SECONDS=31536000
# Clave legacy (reservada; las rutas /exam usan JWT de usuario).
API_KEY=change-me-in-production-min-16-chars
@@ -8,8 +17,8 @@ API_KEY=change-me-in-production-min-16-chars
# --- Base de datos (Docker: host "db") ---
DATABASE_URL=postgresql+psycopg://genexamenes:genexamenes@db:5432/genexamenes
# --- CORS (orígenes del frontend, separados por coma) ---
ALLOWED_ORIGINS=http://sinbad2.ujaen.es,http://sinbad2.ujaen.es:8075
# --- CORS (orígenes HTTPS del frontend; separados por coma) ---
ALLOWED_ORIGINS=https://sinbad2.ujaen.es,http://sinbad2.ujaen.es,http://sinbad2.ujaen.es:8069
# --- Rate limiting y tamaño de petición ---
RATE_LIMIT_REQUESTS=60
@@ -39,10 +48,13 @@ JWT_EXPIRE_MINUTES=1440
# El frontend obtiene un id_token con Google Identity Services y lo envía a POST /auth/google.
GOOGLE_CLIENT_ID=123456789012-abcdefghijklmnopqrstuvwxyz123456.apps.googleusercontent.com
# --- LLM (Sinbad2IA UJA — sin clave) ---
# URL base del servidor; el cliente llama a {LLM_BASE_URL}/api/chat
# --- LLM (solo servidor; NO commitear valores reales) ---
# Obligatorias para POST /exam/generate. Sin ellas, la generación automática queda desactivada.
# La URL no debe aparecer en el repositorio: configúrala solo en el .env del servidor.
LLM_BASE_URL=
LLM_API_KEY=
LLM_MODEL=qwen3.5:35b
LLM_TIMEOUT_SECONDS=180
# Opcional, solo si el servidor exige autenticación:
# LLM_API_KEY=
# Límite por usuario (generación automática)
LLM_GENERATE_RATE_LIMIT_REQUESTS=5
LLM_GENERATE_RATE_LIMIT_WINDOW_SECONDS=3600
+6 -1
View File
@@ -25,4 +25,9 @@ USER app
EXPOSE 8074
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8074"]
CMD ["uvicorn", "app.main:app", \
"--host", "0.0.0.0", \
"--port", "8074", \
"--proxy-headers", \
"--forwarded-allow-ips", "*", \
"--no-server-header"]
+20
View File
@@ -0,0 +1,20 @@
from typing import Annotated
from fastapi import Depends
from app.core.auth import get_current_user
from app.core.config import Settings, get_settings
from app.core.errors import LLMUnavailableError
from app.core.llm_rate_limit import enforce_llm_rate_limit
from app.models.user import User
def require_llm_generation(
settings: Annotated[Settings, Depends(get_settings)],
current_user: Annotated[User, Depends(get_current_user)],
) -> User:
"""Solo permite generación automática si el LLM está configurado por entorno (no en el repo)."""
if not settings.llm_ready:
raise LLMUnavailableError("Automatic AI generation is not available")
enforce_llm_rate_limit(current_user.id, settings)
return current_user
+2 -1
View File
@@ -4,6 +4,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends
from app.api.dependencies import get_exam_service, get_llm_client
from app.api.llm_guard import require_llm_generation
from app.core.auth import get_current_user
from app.models.user import User
from app.schemas.exam import (
@@ -37,7 +38,7 @@ def build_prompt(
@router.post("/generate", response_model=ParsedQuestionsResponse)
async def generate_exam(
payload: GenerateExamRequest,
current_user: Annotated[User, Depends(get_current_user)],
current_user: Annotated[User, Depends(require_llm_generation)],
service: Annotated[ExamService, Depends(get_exam_service)],
llm_client: Annotated[LLMClient, Depends(get_llm_client)],
) -> ParsedQuestionsResponse:
+33 -2
View File
@@ -7,17 +7,33 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_name: str = "GenExamenes IA"
environment: str = "local"
public_base_url: str = "https://sinbad2.ujaen.es/generadorexamenesllm"
trusted_hosts: str = "sinbad2.ujaen.es,localhost,127.0.0.1"
security_hsts_seconds: int = Field(default=31_536_000, ge=0)
api_prefix: str = ""
api_key: str = Field(min_length=16)
database_url: str = "postgresql+psycopg://genexamenes:genexamenes@localhost:5432/genexamenes"
allowed_origins: str = "http://sinbad2.ujaen.es,http://sinbad2.ujaen.es:8075"
allowed_origins: str = "https://sinbad2.ujaen.es,http://sinbad2.ujaen.es,http://sinbad2.ujaen.es:8069"
rate_limit_requests: int = Field(default=60, ge=1)
rate_limit_window_seconds: int = Field(default=60, ge=1)
max_request_bytes: int = Field(default=1_048_576, ge=1_024)
llm_api_key: str | None = None
llm_base_url: str = ""
llm_base_url: str = Field(
default="",
description="URL base del LLM (solo servidor). No incluir en el repositorio.",
)
llm_model: str = "qwen3.5:35b"
llm_timeout_seconds: int = Field(default=180, ge=5)
llm_generate_rate_limit_requests: int = Field(
default=5,
ge=1,
description="Máximo de POST /exam/generate por usuario y ventana.",
)
llm_generate_rate_limit_window_seconds: int = Field(
default=3600,
ge=60,
description="Ventana en segundos para el límite de generación con LLM.",
)
jwt_secret_key: str = Field(min_length=32)
jwt_algorithm: str = "HS256"
jwt_expire_minutes: int = Field(default=60 * 24, ge=5)
@@ -41,10 +57,25 @@ class Settings(BaseSettings):
extra="ignore",
)
@property
def is_production(self) -> bool:
return self.environment.lower() in {"production", "prod"}
@property
def cors_origins(self) -> list[str]:
return [origin.strip() for origin in self.allowed_origins.split(",") if origin.strip()]
@property
def trusted_hosts_list(self) -> list[str]:
return [host.strip() for host in self.trusted_hosts.split(",") if host.strip()]
@property
def llm_ready(self) -> bool:
"""True solo si URL y clave del LLM están definidas en el entorno del servidor."""
return bool(self.llm_base_url.strip()) and bool(
self.llm_api_key and self.llm_api_key.strip()
)
@lru_cache
def get_settings() -> Settings:
+5
View File
@@ -51,9 +51,14 @@ def error_payload(code: str, message: str, details: object | None = None) -> dic
def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(AppError)
async def app_error_handler(_: Request, exc: AppError) -> ORJSONResponse:
headers: dict[str, str] | None = None
retry_after = getattr(exc, "retry_after", None)
if retry_after is not None:
headers = {"Retry-After": str(retry_after)}
return ORJSONResponse(
status_code=exc.status_code,
content=error_payload(exc.code, exc.message),
headers=headers,
)
@app.exception_handler(StarletteHTTPException)
+35
View File
@@ -0,0 +1,35 @@
import time
from collections import defaultdict, deque
from threading import Lock
from uuid import UUID
from app.core.config import Settings
from app.core.errors import AppError
_lock = Lock()
_buckets: dict[str, deque[float]] = defaultdict(deque)
class LLMRateLimitError(AppError):
def __init__(self, retry_after: int) -> None:
super().__init__(
message="Too many AI generation requests. Try again later.",
status_code=429,
code="llm_rate_limited",
)
self.retry_after = retry_after
def enforce_llm_rate_limit(user_id: UUID, settings: Settings) -> None:
key = str(user_id)
now = time.monotonic()
limit = settings.llm_generate_rate_limit_requests
window = settings.llm_generate_rate_limit_window_seconds
with _lock:
bucket = _buckets[key]
while bucket and now - bucket[0] > window:
bucket.popleft()
if len(bucket) >= limit:
raise LLMRateLimitError(retry_after=window)
bucket.append(now)
+30
View File
@@ -0,0 +1,30 @@
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from app.core.config import Settings
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""Cabeceras de seguridad; HSTS en producción o cuando el proxy indica HTTPS."""
def __init__(self, app: object, settings: Settings) -> None:
super().__init__(app)
self._settings = settings
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
response = await call_next(request)
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "SAMEORIGIN")
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
forwarded_proto = request.headers.get("x-forwarded-proto", request.url.scheme)
is_https = forwarded_proto == "https" or request.url.scheme == "https"
if is_https or self._settings.is_production:
hsts = f"max-age={self._settings.security_hsts_seconds}"
if self._settings.is_production:
hsts += "; includeSubDomains"
response.headers.setdefault("Strict-Transport-Security", hsts)
return response
+6 -1
View File
@@ -1,13 +1,15 @@
from contextlib import asynccontextmanager
from collections.abc import AsyncIterator
from fastapi import Depends, FastAPI
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.trustedhost import TrustedHostMiddleware
from app.api.routes import auth, exports, generation, health, history, images, materials, questions, templates
from app.core.config import get_settings
from app.core.errors import register_exception_handlers
from app.core.middleware import RateLimitMiddleware, RequestSizeLimitMiddleware
from app.core.security_headers import SecurityHeadersMiddleware
from app.db.init_db import init_db
@@ -25,6 +27,9 @@ def create_app() -> FastAPI:
# las peticiones OPTIONS (preflight) respondan antes que rate limit, etc.
app.add_middleware(RequestSizeLimitMiddleware, settings=settings)
app.add_middleware(RateLimitMiddleware, settings=settings)
app.add_middleware(SecurityHeadersMiddleware, settings=settings)
if settings.trusted_hosts_list:
app.add_middleware(TrustedHostMiddleware, allowed_hosts=settings.trusted_hosts_list)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
+3
View File
@@ -17,6 +17,9 @@ class LLMClient:
return f"{base}/api/chat"
async def generate(self, prompt: str) -> str:
if not self.settings.llm_ready:
raise LLMUnavailableError("Automatic AI generation is not available")
payload = {
"model": self.settings.llm_model,
"messages": [
+13
View File
@@ -0,0 +1,13 @@
# Desarrollo local: publica backend y PostgreSQL solo en loopback.
# Uso: docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
#
# Configura LLM_BASE_URL y LLM_API_KEY en backend/.env (no subir al repositorio).
services:
backend:
ports:
- "127.0.0.1:8068:8074"
db:
ports:
- "127.0.0.1:5432:5432"
+11 -10
View File
@@ -6,13 +6,13 @@ services:
- ./backend/.env
environment:
DATABASE_URL: postgresql+psycopg://genexamenes:genexamenes@db:5432/genexamenes
ENVIRONMENT: ${ENVIRONMENT:-production}
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-https://sinbad2.ujaen.es/generadorexamenesllm}
TRUSTED_HOSTS: ${TRUSTED_HOSTS:-sinbad2.ujaen.es,localhost,127.0.0.1}
# Sobrescribe backend/.env con el origen público del frontend en despliegue.
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://sinbad2.ujaen.es,http://sinbad2.ujaen.es:8075}
LLM_BASE_URL:
LLM_MODEL: qwen3.5:35b
LLM_TIMEOUT_SECONDS: "180"
ports:
- "${BACKEND_PORT:-8074}:8074"
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-https://sinbad2.ujaen.es,http://sinbad2.ujaen.es,http://sinbad2.ujaen.es:8069}
expose:
- "8074"
depends_on:
db:
condition: service_healthy
@@ -24,10 +24,11 @@ services:
build:
context: ./frontend
args:
VITE_API_URL: ${VITE_API_URL:-http://sinbad2.ujaen.es:8074}
VITE_APP_BASE_PATH: ${VITE_APP_BASE_PATH:-/generadorexamenesllm/}
VITE_API_URL: ${VITE_API_URL:-}
VITE_GOOGLE_CLIENT_ID: ${VITE_GOOGLE_CLIENT_ID:-}
ports:
- "${FRONTEND_PORT:-8075}:80"
- "${FRONTEND_PORT:-8069}:80"
depends_on:
- backend
restart: unless-stopped
@@ -38,8 +39,8 @@ services:
POSTGRES_DB: genexamenes
POSTGRES_USER: genexamenes
POSTGRES_PASSWORD: genexamenes
ports:
- "5432:5432"
expose:
- "5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
+3
View File
@@ -0,0 +1,3 @@
VITE_APP_BASE_PATH=/generadorexamenesllm/
VITE_API_URL=
VITE_GOOGLE_CLIENT_ID=
+7 -2
View File
@@ -1,5 +1,10 @@
# URL base del backend (accesible desde el navegador)
VITE_API_URL=http://sinbad2.ujaen.es:8074
# Base pública de la app cuando va tras proxy (debe terminar en /)
# Producción Sinbad2 (ProxyPass en Apache, sin /deckofcars):
VITE_APP_BASE_PATH=/generadorexamenesllm/
# URL base del backend (accesible desde el navegador).
# Si se deja vacía, usa la misma base de la app (recomendado tras proxy HTTPS).
VITE_API_URL=
# (Opcional) Client ID de Google para "Iniciar sesión con Google".
# Debe coincidir con GOOGLE_CLIENT_ID del backend.
+4 -1
View File
@@ -2,8 +2,10 @@
FROM node:20-alpine AS build
WORKDIR /app
ARG VITE_API_URL=http://sinbad2.ujaen.es:8074
ARG VITE_APP_BASE_PATH=/generadorexamenesllm/
ARG VITE_API_URL=
ARG VITE_GOOGLE_CLIENT_ID=
ENV VITE_APP_BASE_PATH=$VITE_APP_BASE_PATH
ENV VITE_API_URL=$VITE_API_URL
ENV VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID
@@ -15,6 +17,7 @@ RUN npm run build
# --- Serve stage ---
FROM nginx:1.27-alpine AS serve
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY proxy_params.conf /etc/nginx/snippets/proxy_params.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+35 -3
View File
@@ -25,7 +25,7 @@ src/
## Desarrollo local
Requisitos: Node 20+ y el backend corriendo en `http://sinbad2.ujaen.es:8074`.
Requisitos: Node 20+ y el backend corriendo en `http://sinbad2.ujaen.es:8068`.
```bash
cd frontend
@@ -38,7 +38,8 @@ npm run dev # http://sinbad2.ujaen.es:8075
| Variable | Descripción |
| ----------------------- | -------------------------------------------------------- |
| `VITE_API_URL` | URL base del backend (por defecto `http://sinbad2.ujaen.es:8074`). |
| `VITE_APP_BASE_PATH` | Base pública de la SPA (debe terminar en `/`). En producción UJA: `/generadorexamenesllm/`. |
| `VITE_API_URL` | URL base de la API. Si se deja vacía, usa la misma base pública de la app. |
| `VITE_GOOGLE_CLIENT_ID` | (Opcional) Client ID de Google. Si está vacío, se oculta el botón de Google. |
> Las variables `VITE_*` se incrustan en el build, por lo que apuntan al backend
@@ -60,9 +61,40 @@ El `docker-compose.yml` de la raíz construye el frontend con un build multi-sta
docker compose up --build
```
Las variables `VITE_API_URL` y `VITE_GOOGLE_CLIENT_ID` pueden pasarse como
Las variables `VITE_APP_BASE_PATH`, `VITE_API_URL` y `VITE_GOOGLE_CLIENT_ID` pueden pasarse como
variables de entorno al ejecutar `docker compose`.
## Despliegue HTTPS en Sinbad2 (patrón orcid2sword)
Documentación completa: [`deploy/DESPLIEGUE_SINBAD2.md`](../deploy/DESPLIEGUE_SINBAD2.md)
Resumen:
| Capa | Responsabilidad |
|------|-----------------|
| Apache (UJA) | Certificado SSL, `ProxyPass` a `:8069` |
| GitLab CI | `docker compose up`, build con base path |
| Nginx contenedor | SPA + proxy `/auth/` y `/exam/` al backend |
| Backend Uvicorn | `--proxy-headers`, HSTS, CORS HTTPS |
URL pública: **`https://sinbad2.ujaen.es/generadorexamenesllm/`**
Apache (fragmento):
```apache
ProxyPass /generadorexamenesllm http://host.docker.internal:8069/
ProxyPassReverse /generadorexamenesllm http://host.docker.internal:8069/
```
Build:
```env
VITE_APP_BASE_PATH=/generadorexamenesllm/
VITE_API_URL=
```
El navegador habla solo con HTTPS (Apache → nginx → backend); no hay mixed content ni CORS cruzado entre puertos.
## Manejo de errores
Todas las respuestas de error del backend siguen el formato
+72 -6
View File
@@ -1,21 +1,87 @@
# Nginx del contenedor frontend (HTTP interno, puerto 80 → publicado en 8069 en Sinbad2).
#
# Flujo HTTPS (igual que orcid2sword en Sinbad2):
# 1. Usuario → https://sinbad2.ujaen.es/generadorexamenesllm/
# 2. Apache termina TLS y hace ProxyPass al puerto 8069 (HTTP).
# 3. Con ProxyPass ... http://host:8069/ Apache QUITA el prefijo /generadorexamenesllm
# y el contenedor recibe /, /assets/, /auth/, etc.
# 4. Acceso directo al puerto 8069 (sin Apache) usa el prefijo /generadorexamenesllm/
# porque el build de Vite lleva VITE_APP_BASE_PATH=/generadorexamenesllm/
map $http_x_forwarded_proto $forwarded_proto {
default $http_x_forwarded_proto;
"" $scheme;
}
map $http_x_forwarded_host $forwarded_host {
default $http_x_forwarded_host;
"" $host;
}
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# SPA: cualquier ruta desconocida sirve index.html (React Router).
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
# --- API: rutas sin prefijo (Apache quita /generadorexamenesllm) ---
location /auth/ {
proxy_pass http://backend:8074/auth/;
include /etc/nginx/snippets/proxy_params.conf;
}
location /exam/ {
proxy_pass http://backend:8074/exam/;
include /etc/nginx/snippets/proxy_params.conf;
}
location = /health {
proxy_pass http://backend:8074/health;
include /etc/nginx/snippets/proxy_params.conf;
}
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# Raíz sin barra final → login (evita pantalla en blanco por basename)
location = /generadorexamenesllm {
return 301 /generadorexamenesllm/login;
}
location / {
try_files $uri $uri/ /index.html;
}
# Cache de assets con hash.
location /assets/ {
# --- Mismo contenido/API bajo prefijo público (acceso directo :8069 o si Apache no quita prefijo) ---
location ^~ /generadorexamenesllm/auth/ {
proxy_pass http://backend:8074/auth/;
include /etc/nginx/snippets/proxy_params.conf;
}
location ^~ /generadorexamenesllm/exam/ {
proxy_pass http://backend:8074/exam/;
include /etc/nginx/snippets/proxy_params.conf;
}
location = /generadorexamenesllm/health {
proxy_pass http://backend:8074/health;
include /etc/nginx/snippets/proxy_params.conf;
}
location ^~ /generadorexamenesllm/assets/ {
alias /usr/share/nginx/html/assets/;
expires 1y;
add_header Cache-Control "public, immutable";
}
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
location ^~ /generadorexamenesllm/ {
try_files $uri $uri/ /index.html;
}
}
+7
View File
@@ -0,0 +1,7 @@
proxy_http_version 1.1;
proxy_set_header Host $forwarded_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $forwarded_proto;
proxy_set_header X-Forwarded-Host $forwarded_host;
proxy_redirect off;
+2 -2
View File
@@ -1,9 +1,9 @@
import { Navigate, Route, Routes } from "react-router-dom";
import Layout from "./components/layout/Layout";
import ProtectedRoute from "./components/layout/ProtectedRoute";
import RootRoute from "./components/layout/RootRoute";
import LoginPage from "./pages/LoginPage";
import RegisterPage from "./pages/RegisterPage";
import DashboardPage from "./pages/DashboardPage";
import CreateTemplatePage from "./pages/CreateTemplatePage";
import TemplateDetailPage from "./pages/TemplateDetailPage";
import NotFoundPage from "./pages/NotFoundPage";
@@ -21,7 +21,7 @@ export default function App() {
</ProtectedRoute>
}
>
<Route path="/" element={<DashboardPage />} />
<Route path="/" element={<RootRoute />} />
<Route path="/plantillas/nueva" element={<CreateTemplatePage />} />
<Route path="/plantillas/:templateId" element={<TemplateDetailPage />} />
</Route>
+1 -1
View File
@@ -1,7 +1,7 @@
import axios from "axios";
export const API_URL =
import.meta.env.VITE_API_URL?.replace(/\/$/, "") || "http://sinbad2.ujaen.es:8074";
import.meta.env.VITE_API_URL?.replace(/\/$/, "") || import.meta.env.BASE_URL.replace(/\/$/, "");
const TOKEN_KEY = "genex_token";
+40 -17
View File
@@ -10,37 +10,60 @@ export default function Navbar() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const [confirmOut, setConfirmOut] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const doLogout = () => {
logout();
navigate("/login");
};
const closeMobileMenu = () => setMobileOpen(false);
return (
<header className="navbar">
<div className="navbar-inner">
<Link to="/" className="brand">
<Link to="/" className="brand" onClick={closeMobileMenu}>
<span className="brand-logo">
<Icon name="document" size={18} />
</span>
GenExámenes IA
<span className="brand-text">GenExámenes IA</span>
</Link>
<nav className="nav-links">
<NavLink to="/" end className="nav-link">
Mis exámenes
</NavLink>
<NavLink to="/plantillas/nueva" className="nav-link">
Crear examen
</NavLink>
</nav>
<span className="nav-spacer" />
<div className="nav-user">
<div className="avatar" title={user?.email}>
{initials(user?.full_name || user?.email)}
<Button
variant="ghost"
size="sm"
className="nav-mobile-toggle"
aria-label={mobileOpen ? "Cerrar menú" : "Abrir menú"}
onClick={() => setMobileOpen((open) => !open)}
>
<Icon name={mobileOpen ? "close" : "listChecks"} size={18} />
</Button>
<div className={`nav-collapse ${mobileOpen ? "open" : ""}`}>
<nav className="nav-links">
<NavLink to="/" end className="nav-link" onClick={closeMobileMenu}>
Mis exámenes
</NavLink>
<NavLink to="/plantillas/nueva" className="nav-link" onClick={closeMobileMenu}>
Crear examen
</NavLink>
</nav>
<span className="nav-spacer" />
<div className="nav-user">
<div className="avatar" title={user?.email}>
{initials(user?.full_name || user?.email)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
closeMobileMenu();
setConfirmOut(true);
}}
>
Salir
</Button>
</div>
<Button variant="ghost" size="sm" onClick={() => setConfirmOut(true)}>
Salir
</Button>
</div>
</div>
@@ -0,0 +1,17 @@
import { Navigate } from "react-router-dom";
import { useAuth } from "../../context/AuthContext";
import { SpinnerCenter } from "../ui/Spinner";
import DashboardPage from "../../pages/DashboardPage";
/** Raíz de la app: login si no hay sesión, dashboard si la hay. */
export default function RootRoute() {
const { isAuthenticated, loading } = useAuth();
if (loading) return <SpinnerCenter label="Cargando tu sesión…" />;
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <DashboardPage />;
}
+342 -26
View File
@@ -31,6 +31,10 @@
--font: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
--maxw: 1180px;
--h1: 30px;
--h2: 24px;
--h3: 19px;
--h4: 16px;
}
* {
@@ -48,7 +52,7 @@ body {
background: var(--c-bg);
color: var(--c-text);
font-size: 15px;
line-height: 1.55;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
@@ -64,10 +68,22 @@ h1,
h2,
h3,
h4 {
margin: 0 0 0.4em;
margin: 0 0 0.45em;
line-height: 1.25;
font-weight: 700;
letter-spacing: -0.01em;
letter-spacing: -0.012em;
}
h1 {
font-size: var(--h1);
}
h2 {
font-size: var(--h2);
}
h3 {
font-size: var(--h3);
}
h4 {
font-size: var(--h4);
}
button {
@@ -82,6 +98,12 @@ button {
.icon-inline {
margin-right: 6px;
}
.card-head .icon,
.tab .icon,
.btn .icon,
.badge .icon {
stroke-width: 1.9;
}
.icon-lg {
width: 40px;
height: 40px;
@@ -181,7 +203,7 @@ button {
.nav-link {
padding: 8px 14px;
border-radius: var(--radius-sm);
color: var(--c-text-soft);
color: #4e566c;
font-weight: 500;
font-size: 14px;
}
@@ -228,16 +250,16 @@ button {
display: flex;
align-items: flex-start;
gap: 16px;
margin-bottom: 24px;
margin-bottom: 26px;
flex-wrap: wrap;
}
.page-header h1 {
font-size: 26px;
font-size: clamp(24px, 3vw, 30px);
margin: 0;
}
.page-header p {
margin: 4px 0 0;
color: var(--c-text-soft);
margin: 7px 0 0;
color: #5a6277;
}
.page-header-actions {
margin-left: auto;
@@ -245,6 +267,23 @@ button {
gap: 10px;
}
.page-lead {
max-width: 68ch;
}
.section-title {
font-size: 15px;
font-weight: 700;
color: var(--c-text);
margin: 0 0 8px;
}
.section-subtle {
font-size: 13px;
color: var(--c-text-faint);
margin: 0;
}
/* ---------- Cards ---------- */
.card {
background: var(--c-surface);
@@ -253,10 +292,10 @@ button {
box-shadow: var(--shadow-sm);
}
.card-pad {
padding: 22px;
padding: 20px;
}
.card-head {
padding: 18px 22px;
padding: 16px 20px;
border-bottom: 1px solid var(--c-border);
display: flex;
align-items: center;
@@ -265,9 +304,10 @@ button {
.card-head h3 {
margin: 0;
font-size: 16px;
letter-spacing: -0.01em;
}
.card-body {
padding: 22px;
padding: 20px;
}
.grid {
@@ -365,12 +405,12 @@ button {
display: block;
font-size: 13px;
font-weight: 600;
color: var(--c-text);
color: #2a3143;
margin-bottom: 7px;
}
.field-hint {
font-size: 12.5px;
color: var(--c-text-faint);
color: #78809a;
margin-top: 6px;
}
.field-error {
@@ -440,12 +480,12 @@ button {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 10px;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
background: var(--c-surface-2);
color: var(--c-text-soft);
color: #505a74;
}
.badge-primary {
background: var(--c-primary-soft);
@@ -477,10 +517,10 @@ button {
overflow-x: auto;
}
.tab {
padding: 11px 16px;
padding: 11px 15px;
border: none;
background: none;
color: var(--c-text-soft);
color: #556079;
font-weight: 600;
font-size: 14px;
cursor: pointer;
@@ -492,7 +532,7 @@ button {
gap: 7px;
}
.tab:hover {
color: var(--c-text);
color: #252c3f;
}
.tab.active {
color: var(--c-primary);
@@ -627,7 +667,7 @@ button {
display: flex;
gap: 16px;
flex-wrap: wrap;
color: var(--c-text-soft);
color: #586178;
font-size: 13px;
}
.meta-row span {
@@ -668,13 +708,17 @@ button {
}
.list-item-sub {
font-size: 13px;
color: var(--c-text-faint);
color: #7a839c;
}
.empty-state {
text-align: center;
padding: 56px 24px;
color: var(--c-text-soft);
color: #5c657a;
}
.empty-state p {
margin: 6px auto 0;
max-width: 56ch;
}
.empty-state-icon {
margin: 0 auto 12px;
@@ -813,15 +857,15 @@ button {
}
.toast-content {
flex: 1;
font-size: 14px;
font-size: 13.5px;
}
.toast-title {
font-weight: 600;
margin-bottom: 2px;
}
.toast-msg {
color: var(--c-text-soft);
font-size: 13px;
color: #5b6378;
font-size: 12.5px;
}
.toast-close {
background: none;
@@ -863,14 +907,17 @@ button {
.mt-lg {
margin-top: 26px;
}
.mb-sm {
margin-bottom: 10px;
}
.mb {
margin-bottom: 16px;
}
.text-soft {
color: var(--c-text-soft);
color: #5a6379;
}
.text-faint {
color: var(--c-text-faint);
color: #7b849c;
}
.text-sm {
font-size: 13px;
@@ -933,3 +980,272 @@ button {
border: 1px solid var(--c-border);
background: var(--c-surface-2);
}
.layout-split {
grid-template-columns: minmax(0, 1fr) 320px;
}
.layout-split-wide {
grid-template-columns: minmax(0, 1.4fr) minmax(260px, 1fr);
}
.tabs-select-wrap {
display: none;
margin-bottom: 14px;
}
/* ---------- Responsive ---------- */
@media (max-width: 1080px) {
.navbar-inner {
padding: 0 16px;
gap: 12px;
}
.page {
padding: 24px 16px 42px;
}
}
@media (max-width: 900px) {
.list-item {
flex-wrap: wrap;
align-items: flex-start;
}
.list-item-actions {
width: 100%;
justify-content: flex-end;
}
.layout-split,
.layout-split-wide {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
:root {
--radius: 10px;
--radius-lg: 14px;
--h1: 26px;
--h2: 22px;
--h3: 18px;
--h4: 15px;
}
.navbar-inner {
height: auto;
min-height: 64px;
flex-wrap: wrap;
align-items: center;
padding-top: 10px;
padding-bottom: 10px;
}
.brand {
min-width: 0;
flex: 1;
}
.brand-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.nav-mobile-toggle {
display: inline-flex;
}
.nav-collapse {
width: 100%;
display: none;
flex-direction: column;
gap: 10px;
padding: 8px 0 2px;
border-top: 1px solid var(--c-border);
}
.nav-collapse.open {
display: flex;
}
.nav-links {
margin-left: 0;
width: 100%;
flex-direction: column;
gap: 6px;
}
.nav-link {
width: 100%;
min-height: 42px;
display: inline-flex;
align-items: center;
padding: 10px 12px;
}
.nav-spacer {
display: none;
}
.nav-user {
width: 100%;
justify-content: space-between;
border-top: 1px solid var(--c-border);
padding-top: 10px;
}
.page-header h1 {
font-size: 25px;
}
.page-header p,
.page-lead {
font-size: 14px;
}
.page-header-actions {
margin-left: 0;
width: 100%;
}
.page-header-actions .btn {
width: 100%;
}
.card-head,
.card-body,
.card-pad {
padding: 15px;
}
.card-head {
flex-wrap: wrap;
}
.card-head > .btn {
width: 100%;
}
.card-head h3 {
font-size: 15px;
}
.tabs {
gap: 6px;
margin-left: -4px;
margin-right: -4px;
padding: 0 4px;
scrollbar-width: thin;
}
.tabs-select-wrap {
display: block;
}
.tab {
min-height: 44px;
padding: 10px 13px;
}
.btn {
min-height: 42px;
}
.btn.btn-sm {
min-height: 38px;
}
.btn.btn-lg {
min-height: 46px;
}
.mobile-stack {
flex-direction: column;
align-items: stretch;
}
.mobile-stack > .btn {
width: 100%;
}
.modal-overlay {
padding: 12px;
align-items: flex-end;
}
.modal {
max-height: 94vh;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.modal-head,
.modal-body,
.modal-foot {
padding-left: 16px;
padding-right: 16px;
}
.modal-foot {
flex-wrap: wrap;
}
.modal-foot .btn {
flex: 1;
min-width: 130px;
}
.toast-stack {
top: auto;
bottom: 12px;
right: 12px;
left: 12px;
max-width: none;
}
.spinner-center {
padding: 36px 0;
}
.empty-state {
padding: 34px 16px;
}
.empty-state h3 {
font-size: 18px;
margin-bottom: 6px;
}
.empty-state .btn {
width: 100%;
}
.code-block {
max-height: 320px;
font-size: 12px;
padding: 12px;
}
.progress {
height: 9px;
}
.list-item-sub {
margin-top: 2px;
}
}
@media (min-width: 761px) {
.nav-mobile-toggle {
display: none;
}
.nav-collapse {
display: flex;
flex: 1;
align-items: center;
gap: 12px;
}
}
+4 -1
View File
@@ -4,11 +4,14 @@ import { BrowserRouter } from "react-router-dom";
import App from "./App";
import { AuthProvider } from "./context/AuthContext";
import { ToastProvider } from "./context/ToastContext";
import { redirectRootToLoginIfNeeded } from "./utils/redirectRoot";
import "./index.css";
redirectRootToLoginIfNeeded();
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<BrowserRouter>
<BrowserRouter basename={import.meta.env.BASE_URL}>
<ToastProvider>
<AuthProvider>
<App />
+4 -4
View File
@@ -122,7 +122,7 @@ export default function CreateTemplatePage() {
<div className="page-header">
<div>
<h1>Nuevo examen</h1>
<p>Define la estructura. Después podrás subir material y generar preguntas.</p>
<p className="page-lead">Define la estructura. Después podrás subir material y generar preguntas.</p>
</div>
</div>
@@ -192,7 +192,7 @@ export default function CreateTemplatePage() {
className="card"
style={{ padding: 16, marginBottom: 14, background: "var(--c-surface-2)" }}
>
<div className="flex justify-between items-center mb">
<div className="flex justify-between items-center mb wrap gap-sm">
<strong>Bloque {idx + 1}</strong>
{types.length > 1 && (
<Button
@@ -263,7 +263,7 @@ export default function CreateTemplatePage() {
}
/>
</Field>
<div style={{ paddingTop: 26 }}>
<div style={{ paddingTop: 8 }}>
<Checkbox
label="Permitir varias respuestas correctas"
checked={t.multiple_correct}
@@ -343,7 +343,7 @@ export default function CreateTemplatePage() {
</div>
</div>
<div className="flex gap justify-between">
<div className="flex gap justify-between wrap mobile-stack mt">
<Button type="button" variant="ghost" onClick={() => navigate("/")}>
Cancelar
</Button>
+2 -2
View File
@@ -32,10 +32,10 @@ export default function DashboardPage() {
<div className="page-header">
<div>
<h1>Mis exámenes</h1>
<p>Gestiona tus plantillas de examen y genera preguntas con IA.</p>
<p className="page-lead">Gestiona tus plantillas de examen y genera preguntas con IA.</p>
</div>
<div className="page-header-actions">
<Button onClick={() => navigate("/plantillas/nueva")}>
<Button onClick={() => navigate("/plantillas/nueva")} size="lg">
<Icon name="plus" size={16} className="icon-inline" />
Nuevo examen
</Button>
+18
View File
@@ -154,6 +154,24 @@ export default function TemplateDetailPage() {
</div>
</div>
<div className="tabs-select-wrap">
<label className="field-label" htmlFor="template-tab-select">
Sección actual
</label>
<select
id="template-tab-select"
className="select"
value={tab}
onChange={(e) => setTab(e.target.value)}
>
{TABS.map((t) => (
<option key={t.id} value={t.id}>
{t.label}
</option>
))}
</select>
</div>
<div className="tabs">
{TABS.map((t) => (
<button
+2 -2
View File
@@ -73,7 +73,7 @@ export default function ExportTab({ templateId, template, questions }) {
return (
<div>
<p className="text-soft mb">
<p className="text-soft page-lead mb">
Tu examen tiene <strong>{questions.length} preguntas</strong>. Elige un
formato para descargarlo o previsualizarlo.
</p>
@@ -87,7 +87,7 @@ export default function ExportTab({ templateId, template, questions }) {
<p className="text-sm text-soft" style={{ minHeight: 60 }}>
{fmt.desc}
</p>
<div className="flex gap-sm">
<div className="flex gap-sm mobile-stack">
<Button
onClick={() => run(fmt, { download: true })}
loading={loadingFormat === fmt.id}
+6 -3
View File
@@ -104,7 +104,7 @@ export default function GenerateTab({
const topicTooShort = topic.trim().length < 5;
return (
<div className="grid" style={{ gridTemplateColumns: "1fr 340px" }}>
<div className="grid layout-split">
<div>
<div className="tabs" style={{ marginBottom: 18 }}>
{MODES.map((m) => (
@@ -122,6 +122,9 @@ export default function GenerateTab({
{mode !== "parse" && (
<div className="card mb">
<div className="card-body">
<h3 className="section-title mb-sm">
{mode === "auto" ? "Generación automática" : "Construcción de prompt"}
</h3>
<Field
label="Tema / instrucciones para la IA"
hint="Describe el contenido o enfoque del examen (mínimo 5 caracteres)."
@@ -157,7 +160,7 @@ export default function GenerateTab({
</Field>
)}
<div className="flex gap mt">
<div className="flex gap mt mobile-stack">
{mode === "auto" ? (
<Button
size="lg"
@@ -244,7 +247,7 @@ export default function GenerateTab({
{generated.length > 0 && (
<div className="mt-lg">
<div className="flex justify-between items-center mb">
<div className="flex justify-between items-center mb wrap gap-sm">
<h3 style={{ margin: 0 }}>
Resultado ({generated.length} preguntas)
</h3>
+3 -1
View File
@@ -65,10 +65,12 @@ export default function ImagesTab({
};
return (
<div className="grid" style={{ gridTemplateColumns: "1fr 320px" }}>
<div className="grid layout-split">
<div>
<div className="card mb">
<div className="card-body">
<h3 className="section-title">Subir imágenes del examen</h3>
<p className="section-subtle mb">Añade recursos visuales para preguntas con soporte gráfico.</p>
<FileDropzone
accept={IMAGE_ACCEPT}
icon="image"
+4 -2
View File
@@ -67,10 +67,12 @@ export default function MaterialsTab({
};
return (
<div className="grid" style={{ gridTemplateColumns: "1fr 320px" }}>
<div className="grid layout-split">
<div>
<div className="card mb">
<div className="card-body">
<h3 className="section-title">Subir material para IA</h3>
<p className="section-subtle mb">Soporta PDF, DOCX, TXT, MD e imágenes con OCR.</p>
{uploading ? (
<div className="text-center" style={{ padding: 24 }}>
<Spinner large />
@@ -127,7 +129,7 @@ export default function MaterialsTab({
</div>
)}
</div>
<div className="flex gap-sm items-center" style={{ flex: "none" }}>
<div className="flex gap-sm items-center mobile-stack list-item-actions" style={{ flex: "none" }}>
<Badge variant={badge.variant}>{badge.label}</Badge>
<Button
variant="danger-ghost"
+5 -4
View File
@@ -13,18 +13,18 @@ export default function OverviewTab({ template, storage, goToTab }) {
const profile = template.difficulty_profile || {};
return (
<div className="grid" style={{ gridTemplateColumns: "1.4fr 1fr" }}>
<div className="grid layout-split-wide">
<div>
<div className="card mb">
<div className="card-head">
<h3>Estructura del examen</h3>
</div>
<div className="card-body">
<h4 className="text-soft text-sm">Tipos de pregunta</h4>
<h4 className="section-title">Tipos de pregunta</h4>
{qTypes.map((qt, i) => (
<div
key={i}
className="flex justify-between items-center"
className="flex justify-between items-center wrap gap-sm"
style={{
padding: "10px 0",
borderBottom:
@@ -50,7 +50,7 @@ export default function OverviewTab({ template, storage, goToTab }) {
))}
<div className="divider-line" />
<h4 className="text-soft text-sm">Reparto por dificultad</h4>
<h4 className="section-title">Reparto por dificultad</h4>
<div className="flex gap-sm wrap">
{Object.entries(profile).map(([key, val]) =>
val > 0 ? (
@@ -62,6 +62,7 @@ export default function OverviewTab({ template, storage, goToTab }) {
</div>
<div className="divider-line" />
<h4 className="section-title">Opciones activas</h4>
<div className="flex gap-sm wrap text-sm">
<Badge variant={template.settings?.shuffle_questions ? "success" : undefined}>
<Icon
+2 -2
View File
@@ -52,8 +52,8 @@ export default function QuestionsTab({
return (
<div>
<div className="flex justify-between items-center mb">
<p className="text-soft" style={{ margin: 0 }}>
<div className="flex justify-between items-center mb wrap gap-sm">
<p className="text-soft page-lead" style={{ margin: 0 }}>
{questions.length} preguntas guardadas. Vincula imágenes a las
preguntas que las necesiten.
</p>
+25
View File
@@ -0,0 +1,25 @@
const TOKEN_KEY = "genex_token";
/**
* Si el usuario entra en la raíz de la app sin sesión, redirige a /login
* antes de montar React (evita pantalla en blanco por basename sin barra final).
*/
export function redirectRootToLoginIfNeeded() {
const base = import.meta.env.BASE_URL || "/";
const baseNoSlash = base.replace(/\/$/, "");
const { pathname, search, hash } = window.location;
const normalizedPath = pathname.replace(/\/$/, "") || "/";
const isAppRoot =
normalizedPath === baseNoSlash ||
normalizedPath === "/" ||
pathname === base;
if (!isAppRoot) return;
if (localStorage.getItem(TOKEN_KEY)) return;
const loginUrl = `${baseNoSlash}/login${search}${hash}`;
if (pathname + search + hash !== `${baseNoSlash}/login${search}${hash}`) {
window.location.replace(loginUrl);
}
}
+13 -7
View File
@@ -1,10 +1,16 @@
import { defineConfig } from "vite";
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
host: true,
port: 8075,
},
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
const base = env.VITE_APP_BASE_PATH || "/";
return {
base,
plugins: [react()],
server: {
host: true,
port: 8075,
},
};
});