Compare commits

...

8 Commits

Author SHA1 Message Date
f10b15d91b Mejora de fromts 2026-03-03 11:25:37 -07:00
49dfb3ef24 Mejora de seguridad 2026-03-03 09:29:53 -07:00
b187aa1b46 minimo 2026-02-27 10:24:06 -07:00
cd3d7e816f Recuperar implementado 2026-02-27 10:08:30 -07:00
63925fe305 Login resuelto 2026-02-27 09:16:08 -07:00
c146a6c3c3 funcion dashboard y reporte de endpoints v1.16.0 2026-02-26 12:48:28 -07:00
ba779bde55 docs: guardar README original como README.legacy.md 2026-02-26 09:13:29 -07:00
ba94152074 docs: README completo para Windows/Linux/macOS + fix conflicto de puertos
- README.md reescrito con instrucciones detalladas para principiantes y expertos
  * Tabla de contenidos con 14 secciones
  * Inicio rápido con Docker (Windows, Linux, macOS)
  * Configuración de variables de entorno con explicaciones
  * Sección de desarrollo local sin Docker
  * Comandos útiles (Docker, Alembic, calidad de código, testing)
  * Solución de problemas extensa (puertos, módulos, tenant, migraciones, Node.js)
  * Historial de versiones

- Fix: conflicto de puertos cuando ambos frontends corren en local
  * frontend-internal/vite.config.js: usa PORT=3001 por defecto (3000 en Docker)
  * frontend-internal/package.json: dev script sin puerto hardcodeado
  * docker-compose.yml: frontend-internal recibe PORT=3000 como variable de env
2026-02-26 09:02:04 -07:00
48 changed files with 1877 additions and 2478 deletions

178
README.legacy.md Normal file
View File

@@ -0,0 +1,178 @@
# ServiceManagerWeb - Mesa de Ayuda B2B
Sistema multi-tenant de Mesa de Ayuda/Soporte Técnico empresarial para Aduanasoft.
## Arquitectura
- **Frontend**: SvelteKit + TypeScript (portal clientes + panel interno)
- **Backend**: Python FastAPI + Pydantic v2
- **Workers**: Celery + Redis (notificaciones, SLAs, jobs)
- **BD**: PostgreSQL + Alembic migrations
- **Auth**: JWT + Refresh tokens + 2FA opcional (TOTP)
- **Infra**: Docker Compose local, preparado para producción
## Estructura del Monorepo
```
ServiceManagerWeb/
├── backend/ # FastAPI app
├── frontend-client/ # SvelteKit app para clientes
├── frontend-internal/ # SvelteKit app para staff interno
├── workers/ # Celery tasks
├── db/ # Migrations y esquemas
├── docker/ # Dockerfiles específicos
├── docs/ # Documentación adicional
├── scripts/ # Scripts de desarrollo/despliegue
├── docker-compose.yml # Orquestación completa
└── .env.example # Variables de entorno
```
## Stack Tecnológico
### Backend (Python)
- FastAPI (async)
- Pydantic v2
- SQLAlchemy 2.0 (async)
- Alembic (migrations)
- Argon2 (hashing passwords)
- PyJWT
- Celery + Redis
### Frontend (JavaScript/TypeScript)
- SvelteKit
- TypeScript
- TailwindCSS
- shadcn/ui o similar
- Zod (validación)
### Infraestructura
- PostgreSQL 15+
- Redis 7+
- Docker & Docker Compose
- Nginx (reverse proxy)
## Dominios del Sistema
1. **Auth**: Usuarios, roles, permisos, 2FA
2. **Tenants**: Multi-tenancy, organizaciones
3. **Tickets**: Gestión de tickets, estados, SLAs
4. **Notifications**: Email, plantillas, logs
5. **Audit**: Bitácora de acciones
## Roles de Usuario
### Internos (Staff)
- `ADMIN`: Control total del sistema
- `SUPPORT_MANAGER`: Gestión de equipos y SLAs
- `AGENT`: Atención de tickets
- `AUDITOR`: Solo lectura para auditoría
### Clientes
- `CLIENT_ADMIN`: Gestión de organización cliente
- `CLIENT_USER`: Creación y seguimiento de tickets
## Quick Start
```bash
# Clonar y configurar
git clone <repo>
cd ServiceManagerWeb
cp .env.example .env
# Levantar servicios
docker-compose up -d
# Verificar estado
docker-compose ps
```
## URLs por Defecto
- Frontend Clientes: http://localhost:3000
- Frontend Interno: http://localhost:3001
- API Backend: http://localhost:8000
- API Docs: http://localhost:8000/docs
- Adminer (DB): http://localhost:8080
## Scripts de Desarrollo
```bash
# Backend
cd backend
python -m uvicorn app.main:app --reload --port 8000
# Frontend Cliente
cd frontend-client
npm run dev -- --port 3000
# Frontend Interno
cd frontend-internal
npm run dev -- --port 3001
# Workers
cd workers
celery -A app.worker worker --loglevel=info
celery -A app.worker beat --loglevel=info
```
## Testing
```bash
# Backend tests
cd backend
pytest
# Frontend tests
cd frontend-client
npm test
cd ../frontend-internal
npm test
```
## Troubleshooting
### Error 500 en Login / Proxy Error
**Síntoma**: Error 500 al intentar hacer login, o error de proxy de Vite "connect ECONNREFUSED".
**Causa**: Configuración incorrecta de la comunicación entre servicios de Docker.
**Solución**:
1. En desarrollo con Docker, los servicios usan nombres de servicio (no `localhost`)
2. Verificar `vite.config.js`: el proxy debe apuntar a `http://backend:8000`
3. Verificar `docker-compose.yml`: `PUBLIC_API_URL` debe ser `http://backend:8000`
4. Después de cambios, reiniciar contenedor: `docker-compose restart frontend-internal`
**Nota**: Para desarrollo local sin Docker, cambiar el proxy a `http://localhost:8000`.
### Tenant Slug Incorrecto
**Síntoma**: Error de autenticación incluso con credenciales correctas.
**Causa**: El `tenant_slug` en el login no coincide con los tenants en la BD.
**Solución**:
1. Verificar tenants existentes: `docker exec servicemanager-backend python check_tenants.py`
2. Actualizar el tenant_slug en el código de login
3. Tenants por defecto: `aduanasoft-demo`, `test-tenant`
### Credenciales de Prueba
```
Email: admin@aduanasoft.com
Password: admin123
Tenant: aduanasoft-demo
Role: ADMIN
```
## Contribución
1. Fork del proyecto
2. Crear feature branch (`git checkout -b feature/nueva-funcionalidad`)
3. Commit cambios (`git commit -am 'Agregar nueva funcionalidad'`)
4. Push a branch (`git push origin feature/nueva-funcionalidad`)
5. Crear Pull Request
## Licencia
Propietario - Aduanasoft © 2026

835
README.md
View File

@@ -1,178 +1,755 @@
# ServiceManagerWeb - Mesa de Ayuda B2B # ServiceManagerWeb Mesa de Ayuda B2B
Sistema multi-tenant de Mesa de Ayuda/Soporte Técnico empresarial para Aduanasoft. > **Versión actual:** v1.15.1 — Módulo de reportes implementado
>
> Sistema multi-tenant de Mesa de Ayuda / Soporte Técnico empresarial desarrollado para Aduanasoft.
> Arquitectura Modular Monolith con Clean Architecture, preparado para escalar a microservicios.
## Arquitectura ---
- **Frontend**: SvelteKit + TypeScript (portal clientes + panel interno) ## Tabla de Contenidos
- **Backend**: Python FastAPI + Pydantic v2
- **Workers**: Celery + Redis (notificaciones, SLAs, jobs)
- **BD**: PostgreSQL + Alembic migrations
- **Auth**: JWT + Refresh tokens + 2FA opcional (TOTP)
- **Infra**: Docker Compose local, preparado para producción
## Estructura del Monorepo 1. [Requisitos previos](#requisitos-previos)
2. [Inicio rápido con Docker (recomendado)](#inicio-rápido-con-docker-recomendado)
3. [Configuración de variables de entorno](#configuración-de-variables-de-entorno)
4. [Cargar datos de prueba](#cargar-datos-de-prueba)
5. [URLs y puertos por defecto](#urls-y-puertos-por-defecto)
6. [Credenciales de prueba](#credenciales-de-prueba)
7. [Desarrollo local sin Docker](#desarrollo-local-sin-docker)
8. [Arquitectura del proyecto](#arquitectura-del-proyecto)
9. [Roles y permisos](#roles-y-permisos)
10. [Comandos útiles](#comandos-útiles)
11. [Pruebas (testing)](#pruebas-testing)
12. [Solución de problemas](#solución-de-problemas)
13. [Contribución](#contribución)
14. [Historial de versiones](#historial-de-versiones)
---
## Requisitos previos
Antes de clonar el proyecto, asegúrate de tener instalado:
| Herramienta | Versión mínima | Descarga |
|-------------|---------------|---------|
| **Git** | 2.x | https://git-scm.com/downloads |
| **Docker Desktop** | 24.x | https://www.docker.com/products/docker-desktop |
| **Docker Compose** | v2.x (incluido en Docker Desktop) | — |
> **Nota para desarrolladores que quieran editar código localmente (sin Docker):**
> también necesitarás Python 3.11+ y Node.js 18+. Ver sección
> [Desarrollo local sin Docker](#desarrollo-local-sin-docker).
### Verificar que Docker esté corriendo
```bash
docker --version # Debe mostrar Docker version 24.x o superior
docker compose version # Debe mostrar Docker Compose version v2.x
```
Si `docker compose version` falla, prueba `docker-compose --version` (versión standalone).
---
## Inicio rápido con Docker (recomendado)
Este es el método más simple y funciona igual en **Windows, Linux y macOS**.
Solo necesitas Docker Desktop instalado y corriendo.
### Paso 1 — Clonar el repositorio
```bash
git clone https://git.aduanasoft.com/ADUANASOFT/service_manager.git
cd service_manager
```
### Paso 2 — Crear el archivo de variables de entorno
**Linux / macOS:**
```bash
cp .env.example .env
```
**Windows (PowerShell):**
```powershell
Copy-Item .env.example .env
```
**Windows (CMD):**
```cmd
copy .env.example .env
```
> **Importante:** El archivo `.env` nunca se sube a git (está en `.gitignore`).
> Para desarrollo local los valores del `.env.example` funcionan sin cambios.
> En producción **debes** generar claves secretas únicas (ver sección de variables de entorno).
### Paso 3 — Levantar todos los servicios
```bash
docker compose up -d
```
Este comando descarga las imágenes, construye los contenedores e inicia todo el stack.
La primera vez tarda entre 3 y 8 minutos dependiendo de la conexión a internet.
> **Alternativa con herramientas de desarrollo** (Adminer, MailHog, Redis Commander):
> ```bash
> docker compose --profile dev up -d
> ```
### Paso 4 — Verificar que todo esté funcionando
```bash
docker compose ps
```
Deberías ver todos los servicios con estado `Up` o `healthy`:
```
NAME STATUS
servicemanager-db Up (healthy)
servicemanager-redis Up (healthy)
servicemanager-backend Up (healthy)
servicemanager-worker Up
servicemanager-beat Up
servicemanager-client-frontend Up
servicemanager-internal-... Up
servicemanager-nginx Up
```
Si algún servicio muestra `Exit` o `Restarting`, revisa la sección
[Solución de problemas](#solución-de-problemas).
### Paso 5 — Cargar datos de ejemplo (opcional pero recomendado)
```bash
docker exec servicemanager-backend python /scripts/seed_data.py
```
Esto crea el tenant de demostración, categorías, usuarios y tickets de prueba.
### ¡Listo! Abre el navegador
| Aplicación | URL |
|------------|-----|
| Portal de clientes | http://localhost:3000 |
| Panel interno (staff) | http://localhost:3001 |
| API REST | http://localhost:8000 |
| Documentación API (Swagger) | http://localhost:8000/docs |
| Documentación API (ReDoc) | http://localhost:8000/redoc |
| Health check | http://localhost:8000/health |
> **Con perfil dev** activo también tendrás:
> - Adminer (gestor visual de PostgreSQL): http://localhost:8080
> - MailHog (pruebas de email): http://localhost:8025
> - Redis Commander (inspector de Redis): http://localhost:8081
---
## Configuración de variables de entorno
El archivo `.env` controla todo el comportamiento de la aplicación.
Copia `.env.example` como `.env` y revisa los valores siguientes:
### Variables críticas
| Variable | Descripción | Valor por defecto (dev) |
|----------|-------------|-------------------------|
| `SECRET_KEY` | Clave secreta general de Flask/FastAPI | _(cambiar en producción)_ |
| `JWT_SECRET_KEY` | Clave para firmar tokens JWT | _(cambiar en producción)_ |
| `DATABASE_URL` | Cadena de conexión a PostgreSQL | `postgresql+asyncpg://servicemanager:...@postgres:5432/servicemanager` |
| `REDIS_URL` | URL de conexión a Redis | `redis://redis:6379/0` |
| `ENVIRONMENT` | Entorno actual | `development` |
| `DEBUG` | Modo debug (muestra errores detallados) | `true` |
### Generar claves seguras para producción
**Linux / macOS:**
```bash
openssl rand -base64 32 # Genera SECRET_KEY
openssl rand -base64 32 # Genera JWT_SECRET_KEY
```
**Windows (PowerShell):**
```powershell
[Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Maximum 256 }))
```
> **Advertencia:** Nunca uses las claves del `.env.example` en producción.
> Cambiar las claves en producción invalida todas las sesiones activas.
### Desarrollo local vs Docker
En `.env.example` las URLs apuntan a nombres de servicio Docker (`postgres`, `redis`, `backend`).
Si ejecutas el backend directamente en tu máquina (sin Docker), cambia:
```dotenv
# Para desarrollo local sin Docker:
DATABASE_URL=postgresql+asyncpg://servicemanager:servicemanager123@localhost:5432/servicemanager
REDIS_URL=redis://localhost:6379/0
CELERY_BROKER_URL=redis://localhost:6379/0
```
---
## Cargar datos de prueba
El script `seed_data.py` crea datos iniciales en la base de datos.
**Con Docker (recomendado):**
```bash
docker exec servicemanager-backend python /scripts/seed_data.py
```
**Sin Docker:**
```bash
cd backend
python ../scripts/seed_data.py
```
El script crea:
- Tenant de demostración: `aduanasoft-demo`
- Categorías de tickets (Soporte Técnico, Facturación, Incidentes Críticos, etc.)
- Sistemas registrados
- Usuarios de prueba con distintos roles
---
## URLs y puertos por defecto
| Servicio | Puerto | Descripción |
|----------|--------|-------------|
| Frontend Clientes | **3000** | Portal para usuarios clientes |
| Frontend Interno | **3001** | Panel para staff (agentes, admins) |
| Backend API | **8000** | FastAPI — endpoints REST |
| PostgreSQL | **5432** | Base de datos (no exponer en producción) |
| Redis | **6379** | Cache y broker Celery (no exponer en producción) |
| Nginx | **80** | Reverse proxy |
| Adminer *(perfil dev)* | **8080** | GUI para PostgreSQL |
| MailHog *(perfil dev)* | **8025** | Capturador de emails en desarrollo |
| Redis Commander *(perfil dev)* | **8081** | GUI para Redis |
### ¿Conflicto de puertos?
Si algún puerto ya está en uso en tu máquina, edita `docker-compose.yml` y cambia
el número **izquierdo** del mapeo `host:container`. Por ejemplo, para backend en el 8080:
```yaml
ports:
- "8080:8000" # ahora accesible en localhost:8080
```
---
## Credenciales de prueba
Después de ejecutar el seed, puedes iniciar sesión con:
| Campo | Valor |
|-------|-------|
| Email | `admin@aduanasoft.com` |
| Contraseña | `admin123` |
| Tenant | `aduanasoft-demo` |
| Rol | `ADMIN` |
> Otros usuarios creados por el seed tienen el mismo sufijo de contraseña (`123`).
> Revisa `scripts/seed_data.py` para ver la lista completa.
---
## Desarrollo local sin Docker
Útil cuando necesitas depurar el código con breakpoints o acelerar el ciclo de desarrollo.
Requiere que **PostgreSQL y Redis sí corran en Docker** (o instalación nativa).
### Requisitos adicionales
| Herramienta | Versión | Descarga |
|------------|---------|---------|
| Python | 3.11 o 3.12 | https://www.python.org/downloads/ |
| Node.js (con npm) | 18 LTS | https://nodejs.org/ |
| pip | incluido con Python | — |
### Iniciar solo la base de datos y Redis
```bash
docker compose up -d postgres redis
```
### Backend (FastAPI)
```bash
cd backend
# Crear entorno virtual (solo la primera vez)
python -m venv ../.venv
# Activar entorno virtual
# Linux / macOS:
source ../.venv/bin/activate
# Windows (PowerShell):
..\.venv\Scripts\Activate.ps1
# Windows (CMD):
..\.venv\Scripts\activate.bat
# Instalar dependencias (solo la primera vez o cuando cambie requirements.txt)
pip install -r requirements.txt
# Ejecutar migraciones de base de datos
alembic upgrade head
# Iniciar servidor de desarrollo
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
```
> Si `uvicorn` no se encuentra, asegúrate de que el entorno virtual está activado
> (`(.venv)` debe aparecer en tu terminal).
### Frontend Clientes
```bash
cd frontend-client
# Instalar dependencias (solo la primera vez)
npm install
# Iniciar servidor de desarrollo en puerto 3000
npm run dev
```
### Frontend Interno (staff)
```bash
cd frontend-internal
# Instalar dependencias (solo la primera vez)
npm install
# Iniciar servidor de desarrollo en puerto 3001
npm run dev
```
> Los dos frontends tienen puertos distintos (3000 y 3001) para que no haya conflicto
> cuando corren al mismo tiempo.
### Workers Celery (opcional en desarrollo)
Necesario solo si desarrollas funcionalidades de notificaciones o SLAs automáticos.
```bash
cd workers
# Activar el mismo entorno virtual del backend:
# Linux / macOS:
source ../.venv/bin/activate
# Windows:
..\.venv\Scripts\Activate.ps1
pip install -r requirements.txt
# Worker principal
celery -A app.celery worker --loglevel=info
# Scheduler de tareas periódicas (en otra terminal)
celery -A app.celery beat --loglevel=info --schedule=/tmp/celerybeat-schedule
```
---
## Arquitectura del proyecto
``` ```
ServiceManagerWeb/ ServiceManagerWeb/
├── backend/ # FastAPI app ├── backend/ # Aplicación FastAPI (Python 3.11)
├── frontend-client/ # SvelteKit app para clientes │ ├── app/
├── frontend-internal/ # SvelteKit app para staff interno │ │ ├── main.py # Punto de entrada, lifespan, middlewares
├── workers/ # Celery tasks ├── api/v1/
├── db/ # Migrations y esquemas ├── router.py # Registro de todos los routers
├── docker/ # Dockerfiles específicos └── endpoints/ # Endpoints REST por dominio
├── docs/ # Documentación adicional │ │ ├── core/ # Config, seguridad, base de datos, caché
├── scripts/ # Scripts de desarrollo/despliegue │ │ ├── models/ # Modelos SQLAlchemy (ORM)
│ │ ├── services/ # Lógica de negocio
│ │ └── middleware/ # Tenant context, Correlation ID
│ ├── migrations/ # Migraciones Alembic
│ ├── tests/ # Pruebas backend
│ └── requirements.txt # Dependencias Python
├── frontend-client/ # Portal de clientes (SvelteKit + TypeScript)
│ └── src/routes/ # Páginas: login, tickets, perfil
├── frontend-internal/ # Panel de staff (SvelteKit + TypeScript)
│ └── src/routes/ # Páginas: dashboard, tickets, reportes, auditoría
├── workers/ # Tareas asíncronas Celery
│ └── app/tasks/ # email_tasks.py, sla_tasks.py, etc.
├── docker/ # Dockerfiles y configuración Nginx
├── db/ # schema.sql inicial
├── docs/ # Documentación técnica adicional
├── scripts/ # seed_data.py, setup-dev.sh, etc.
├── docker-compose.yml # Orquestación completa ├── docker-compose.yml # Orquestación completa
└── .env.example # Variables de entorno └── .env.example # Plantilla de variables de entorno
``` ```
## Stack Tecnológico ### Stack tecnológico
### Backend (Python) **Backend:** Python 3.11 · FastAPI · Pydantic v2 · SQLAlchemy 2.0 (async) · Alembic · Argon2 · PyJWT · Celery · Redis
- FastAPI (async)
- Pydantic v2
- SQLAlchemy 2.0 (async)
- Alembic (migrations)
- Argon2 (hashing passwords)
- PyJWT
- Celery + Redis
### Frontend (JavaScript/TypeScript) **Frontend:** Node.js 18 · SvelteKit · TypeScript · TailwindCSS · Zod
- SvelteKit
- TypeScript
- TailwindCSS
- shadcn/ui o similar
- Zod (validación)
### Infraestructura **Infraestructura:** PostgreSQL 15 · Redis 7 · Docker Compose · Nginx
- PostgreSQL 15+
- Redis 7+
- Docker & Docker Compose
- Nginx (reverse proxy)
## Dominios del Sistema ---
1. **Auth**: Usuarios, roles, permisos, 2FA ## Roles y permisos
2. **Tenants**: Multi-tenancy, organizaciones
3. **Tickets**: Gestión de tickets, estados, SLAs
4. **Notifications**: Email, plantillas, logs
5. **Audit**: Bitácora de acciones
## Roles de Usuario ### Personal interno (staff)
| Rol | Descripción |
### Internos (Staff) |-----|-------------|
- `ADMIN`: Control total del sistema | `ADMIN` | Control total del sistema |
- `SUPPORT_MANAGER`: Gestión de equipos y SLAs | `SUPPORT_MANAGER` | Gestión de equipos y configuración de SLAs |
- `AGENT`: Atención de tickets | `AGENT` | Atención y resolución de tickets |
- `AUDITOR`: Solo lectura para auditoría | `AUDITOR` | Solo lectura para revisiones y cumplimiento |
### Clientes ### Clientes
- `CLIENT_ADMIN`: Gestión de organización cliente | Rol | Descripción |
- `CLIENT_USER`: Creación y seguimiento de tickets |-----|-------------|
| `CLIENT_ADMIN` | Gestión de su organización cliente |
| `CLIENT_USER` | Creación y seguimiento de sus propios tickets |
## Quick Start ---
## Comandos útiles
### Docker Compose
```bash ```bash
# Clonar y configurar # Levantar todos los servicios (segundo plano)
git clone <repo> docker compose up -d
cd ServiceManagerWeb
cp .env.example .env
# Levantar servicios # Levantar con herramientas de desarrollo
docker-compose up -d docker compose --profile dev up -d
# Verificar estado # Ver logs en tiempo real de todos los servicios
docker-compose ps docker compose logs -f
# Ver logs de un servicio específico
docker compose logs -f backend
docker compose logs -f frontend-internal
# Detener todos los servicios (mantiene los datos)
docker compose down
# Detener Y borrar todos los volúmenes (¡borra la base de datos!)
docker compose down -v
# Reconstruir imagen de un servicio (después de cambiar Dockerfile o requirements)
docker compose build backend
docker compose up -d backend
# Reiniciar un servicio
docker compose restart backend
``` ```
## URLs por Defecto ### Base de datos (Alembic)
- Frontend Clientes: http://localhost:3000
- Frontend Interno: http://localhost:3001
- API Backend: http://localhost:8000
- API Docs: http://localhost:8000/docs
- Adminer (DB): http://localhost:8080
## Scripts de Desarrollo
```bash ```bash
# Backend # Aplicar todas las migraciones pendientes
cd backend cd backend
python -m uvicorn app.main:app --reload --port 8000 alembic upgrade head
# Frontend Cliente # Ver estado de migraciones
cd frontend-client alembic current
npm run dev -- --port 3000
# Frontend Interno # Revertir última migración
cd frontend-internal alembic downgrade -1
npm run dev -- --port 3001
# Workers # Crear nueva migración (después de modificar models/)
cd workers alembic revision --autogenerate -m "nombre descriptivo del cambio"
celery -A app.worker worker --loglevel=info
celery -A app.worker beat --loglevel=info # Con Docker:
docker exec servicemanager-backend alembic upgrade head
``` ```
## Testing ### Calidad de código
```bash ```bash
# Backend tests
cd backend cd backend
# Linter y auto-fix
ruff check . --fix
# Formateador
black .
# Verificación de tipos
mypy .
# Todo de una vez
ruff check . --fix && black . && mypy .
```
---
## Pruebas (testing)
### Backend
```bash
cd backend
# Ejecutar todas las pruebas
pytest pytest
# Frontend tests # Con cobertura detallada
cd frontend-client pytest --cov=app --cov-report=html
npm test
cd ../frontend-internal # Abrir reporte de cobertura (Linux/macOS)
npm test open htmlcov/index.html
# Windows
start htmlcov/index.html
# Prueba específica
pytest tests/test_auth.py -v
# Con Docker
docker exec servicemanager-backend pytest -v --cov=app
``` ```
## Troubleshooting ### Frontend
### Error 500 en Login / Proxy Error
**Síntoma**: Error 500 al intentar hacer login, o error de proxy de Vite "connect ECONNREFUSED".
**Causa**: Configuración incorrecta de la comunicación entre servicios de Docker.
**Solución**:
1. En desarrollo con Docker, los servicios usan nombres de servicio (no `localhost`)
2. Verificar `vite.config.js`: el proxy debe apuntar a `http://backend:8000`
3. Verificar `docker-compose.yml`: `PUBLIC_API_URL` debe ser `http://backend:8000`
4. Después de cambios, reiniciar contenedor: `docker-compose restart frontend-internal`
**Nota**: Para desarrollo local sin Docker, cambiar el proxy a `http://localhost:8000`.
### Tenant Slug Incorrecto
**Síntoma**: Error de autenticación incluso con credenciales correctas.
**Causa**: El `tenant_slug` en el login no coincide con los tenants en la BD.
**Solución**:
1. Verificar tenants existentes: `docker exec servicemanager-backend python check_tenants.py`
2. Actualizar el tenant_slug en el código de login
3. Tenants por defecto: `aduanasoft-demo`, `test-tenant`
### Credenciales de Prueba
```bash
cd frontend-internal # o frontend-client
npm test # Ejecutar una vez
npm run test:watch # Modo observador
``` ```
Email: admin@aduanasoft.com
Password: admin123 ---
Tenant: aduanasoft-demo
Role: ADMIN ## Solución de problemas
### El backend no inicia — error en `DATABASE_URL`
**Síntoma:** El contenedor `servicemanager-backend` reinicia continuamente.
**Causa frecuente:** El archivo `.env` no existe o tiene `DATABASE_URL` apuntando a `localhost`
en lugar del nombre del servicio Docker `postgres`.
**Solución:**
```bash
# Verificar que .env existe
ls .env # Linux/macOS
dir .env # Windows
# Si no existe, crearlo
cp .env.example .env # Linux/macOS
Copy-Item .env.example .env # Windows PowerShell
# Verificar el valor correcto en .env:
# DATABASE_URL=postgresql+asyncpg://servicemanager:servicemanager123@postgres:5432/servicemanager
# ^^^^^^^
# Nombre de servicio Docker, NO localhost
``` ```
---
### Error 500 en login / "connect ECONNREFUSED"
**Síntoma:** El frontend muestra error 500 al hacer login, o la consola del navegador
muestra `ECONNREFUSED 127.0.0.1:8000`.
**Causa:** El proxy de Vite no encuentra el backend.
**Solución en Docker:** El proxy ya está configurado para usar `PUBLIC_API_URL`.
Verifica en `docker-compose.yml` que `frontend-internal` y `frontend-client` tienen:
```yaml
environment:
- PUBLIC_API_URL=http://backend:8000
```
Después reinicia:
```bash
docker compose restart frontend-internal frontend-client
```
**Solución en desarrollo local:** Asegúrate de que el backend está corriendo:
```bash
curl http://localhost:8000/health
# Debe responder: {"status": "ok", ...}
```
---
### El frontend-internal y frontend-client usan el mismo puerto localmente
**Síntoma:** Al correr ambos frontends sin Docker, uno de los dos falla
con `Port 3000 is already in use`.
**Solución:**
- `frontend-client` → usa el puerto **3000** (por defecto con `npm run dev`)
- `frontend-internal` → usa el puerto **3001** (configurado en `vite.config.js`)
Nunca hay conflicto si los iniciaste con `npm run dev` en cada carpeta por separado.
Si aún hay conflicto, mata el proceso en ese puerto:
```bash
# Linux / macOS
lsof -ti:3000 | xargs kill -9
# Windows (PowerShell)
Get-Process -Id (Get-NetTCPConnection -LocalPort 3000).OwningProcess | Stop-Process -Force
```
---
### El tenant slug es incorrecto al hacer login
**Síntoma:** Login falla con "credenciales inválidas" aunque el email y contraseña son correctos.
**Causa:** El campo `tenant_slug` no corresponde a ningún tenant en la base de datos.
**Solución:**
```bash
# Ver los tenants disponibles
docker exec servicemanager-backend python -c "
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy import text
import os
async def main():
engine = create_async_engine(os.environ['DATABASE_URL'])
async with AsyncSession(engine) as s:
result = await s.execute(text('SELECT slug, name FROM tenants'))
for row in result:
print(row)
asyncio.run(main())
"
```
Tenant por defecto (después del seed): **`aduanasoft-demo`**
---
### Puerto ocupado — cambiar puertos de los servicios
Edita `docker-compose.yml` y modifica **solo el número izquierdo** del mapeo de puertos:
```yaml
# Ejemplo: mover el backend al puerto 9000
backend:
ports:
- "9000:8000" # accesible en localhost:9000
# Ejemplo: mover el frontend al puerto 4000
frontend-client:
ports:
- "4000:3000" # accesible en localhost:4000
```
---
### Migraciones fallidas — `alembic upgrade head` da error
```bash
# Verificar el estado actual
docker exec servicemanager-backend alembic current
# Si hay conflicto, hacer downgrade hasta la base y volver a subir
docker exec servicemanager-backend alembic downgrade base
docker exec servicemanager-backend alembic upgrade head
```
---
### Módulo Python no encontrado (`ModuleNotFoundError`)
**Con Docker:** El módulo no está en `requirements.txt` o la imagen no fue reconstruida.
```bash
# Reconstruir la imagen del backend
docker compose build backend
docker compose up -d backend
```
**Local:** El entorno virtual no está activado.
```bash
# Verificar que el venv está activo (debe aparecer (.venv) en el prompt)
which python # Linux/macOS — debe apuntar a .venv/
# Windows:
where python # debe apuntar a .venv\Scripts\python.exe
```
---
### `npm: command not found` o versión de Node incorrecta
```bash
node --version # Debe ser v18.x o superior
npm --version # Debe ser 9.x o superior
```
Si Node no está instalado, descárgalo desde https://nodejs.org/ (elige "LTS").
En macOS con Homebrew:
```bash
brew install node@18
```
En Linux (Ubuntu/Debian):
```bash
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt-get install -y nodejs
```
---
### `docker-compose` no se reconoce como comando
En versiones modernas de Docker Desktop, el comando es `docker compose` (con espacio, sin guion).
Si tienes instalación separada de Docker Compose v1, usa `docker-compose` (con guion).
---
### Logs de los contenedores
```bash
# Ver qué está fallando
docker compose logs backend --tail=50
docker compose logs frontend-internal --tail=50
docker compose logs postgres --tail=20
```
---
## Contribución ## Contribución
1. Fork del proyecto 1. Haz fork del proyecto
2. Crear feature branch (`git checkout -b feature/nueva-funcionalidad`) 2. Crea una rama de funcionalidad: `git checkout -b feature/nombre-funcionalidad`
3. Commit cambios (`git commit -am 'Agregar nueva funcionalidad'`) 3. Realiza tus cambios siguiendo las convenciones del proyecto
4. Push a branch (`git push origin feature/nueva-funcionalidad`) 4. Ejecuta las pruebas: `pytest` y el linter: `ruff check .`
5. Crear Pull Request 5. Haz commit con un mensaje descriptivo: `git commit -m "feat: agregar exportación a CSV"`
6. Sube tu rama: `git push origin feature/nombre-funcionalidad`
7. Abre un Pull Request hacia `main`
### Convenciones de nombres
- **Modelos**: `PascalCase``User`, `Ticket`, `TenantOrganization`
- **Endpoints (URL)**: `kebab-case``/api/v1/user-management/`
- **Componentes Svelte**: `PascalCase.svelte``TicketCard.svelte`
- **Stores**: `camelCase``ticketStore.ts`
---
## Historial de versiones
| Versión | Descripción |
|---------|-------------|
| **v1.15.1** | Módulo de reportes implementado |
| v1.14.x | Mejoras al módulo de auditoría |
| v1.13.x | Sistema de SLAs automático |
| v1.12.x | Notificaciones por email |
| v1.0.0 | MVP inicial — tickets, tenants, autenticación |
---
## Licencia ## Licencia
Propietario - Aduanasoft © 2026 Propietario Aduanasoft © 2026. Todos los derechos reservados.

Binary file not shown.

View File

@@ -1,3 +1,4 @@
from typing import Optional
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from starlette.requests import Request from starlette.requests import Request
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
@@ -15,7 +16,48 @@ from app.models.tenant import Tenant
settings = get_settings() settings = get_settings()
# Esquema OAuth2 centralizado — auth.py importa desde aquí # Esquema OAuth2 centralizado — auth.py importa desde aquí
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"/{settings.API_VERSION}/auth/login") # Soporta: 1) Authorization: Bearer header (Swagger/API clients)
# 2) Cookie access_token HttpOnly (apps web)
_bearer_scheme = OAuth2PasswordBearer(
tokenUrl=f"/{settings.API_VERSION}/auth/login",
auto_error=False,
)
async def oauth2_scheme(
request: Request,
bearer_token: Optional[str] = Depends(_bearer_scheme),
) -> str:
"""Extrae JWT desde header Authorization (prioridad) o cookie del frontend correcto.
Usa el header X-App para seleccionar la cookie:
- X-App: internal → solo 'internal_access_token'
- X-App: client → solo 'client_access_token'
- sin header → prueba ambas (compatibilidad con Swagger/CLI)
"""
if bearer_token:
return bearer_token
app_hint = request.headers.get("X-App", "").lower()
if app_hint == "internal":
token = request.cookies.get("internal_access_token")
elif app_hint == "client":
token = request.cookies.get("client_access_token")
else:
# Fallback para Swagger, tests y clientes sin header
token = (
request.cookies.get("internal_access_token")
or request.cookies.get("client_access_token")
)
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
return token
async def get_current_user( async def get_current_user(
request: Request, request: Request,

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@ Authentication Endpoints - ServiceManagerWeb
Endpoints para autenticación y autorización Endpoints para autenticación y autorización
""" """
from fastapi import APIRouter, HTTPException, status, Depends, Request from fastapi import APIRouter, HTTPException, status, Depends, Request, Response
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
@@ -21,6 +21,15 @@ from app.services.audit_service import AuditService
from app.services.token_service import TokenService from app.services.token_service import TokenService
from app.api.deps import oauth2_scheme, get_current_user from app.api.deps import oauth2_scheme, get_current_user
from app.core.cache import cache, cache_key from app.core.cache import cache, cache_key
from app.core.limiter import limiter
# Nombres de cookie por tipo de usuario
CLIENT_ROLES = {"CLIENT_ADMIN", "CLIENT_USER"}
def _cookie_name_for_role(role: str) -> str:
"""Devuelve el nombre de cookie según el rol del usuario."""
return "client_access_token" if role in CLIENT_ROLES else "internal_access_token"
from app.api.schemas.auth import ( from app.api.schemas.auth import (
LoginRequest, LoginResponse, RefreshTokenRequest, TokenResponse, LoginRequest, LoginResponse, RefreshTokenRequest, TokenResponse,
TwoFactorStatusResponse, TwoFactorSetupResponse, TwoFactorStatusResponse, TwoFactorSetupResponse,
@@ -38,9 +47,11 @@ settings = get_settings()
# =================================== # ===================================
@router.post("/login", response_model=LoginResponse) @router.post("/login", response_model=LoginResponse)
@limiter.limit("10/minute")
async def login( async def login(
login_data: LoginRequest, login_data: LoginRequest,
request: Request, request: Request,
response: Response,
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
""" """
@@ -248,6 +259,19 @@ async def login(
if ident_key: if ident_key:
await cache.delete(ident_key) await cache.delete(ident_key)
# Cookie diferenciada por rol para aislar sesiones entre frontends
cookie_name = _cookie_name_for_role(
user.role.value if hasattr(user.role, "value") else user.role
)
response.set_cookie(
key=cookie_name,
value=access_token,
httponly=True,
secure=settings.is_production(),
samesite="strict" if settings.is_production() else "lax",
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
)
return LoginResponse( return LoginResponse(
access_token=access_token, access_token=access_token,
refresh_token=refresh_token, refresh_token=refresh_token,
@@ -330,6 +354,7 @@ async def refresh_token(
@router.post("/logout") @router.post("/logout")
async def logout( async def logout(
response: Response,
token: str = Depends(oauth2_scheme), token: str = Depends(oauth2_scheme),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
@@ -388,6 +413,9 @@ async def logout(
logger.info("Logout successful", user_id=payload["sub"]) logger.info("Logout successful", user_id=payload["sub"])
# Borrar la cookie correcta según el rol del usuario
cookie_name = _cookie_name_for_role(payload.get("role", ""))
response.delete_cookie(key=cookie_name)
return {"message": "Successfully logged out"} return {"message": "Successfully logged out"}
@@ -681,7 +709,9 @@ _RESET_KEY_PREFIX = "pwd_reset:"
@router.post("/forgot-password", status_code=status.HTTP_200_OK) @router.post("/forgot-password", status_code=status.HTTP_200_OK)
@limiter.limit("5/minute")
async def forgot_password( async def forgot_password(
request: Request,
data: ForgotPasswordRequest, data: ForgotPasswordRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
@@ -748,7 +778,9 @@ async def forgot_password(
@router.post("/reset-password", status_code=status.HTTP_200_OK) @router.post("/reset-password", status_code=status.HTTP_200_OK)
@limiter.limit("5/minute")
async def reset_password( async def reset_password(
request: Request,
data: ResetPasswordRequest, data: ResetPasswordRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):

View File

@@ -7,7 +7,7 @@ Accesible por ADMIN y SUPPORT_MANAGER.
from fastapi import APIRouter, Depends, Query, HTTPException, status from fastapi import APIRouter, Depends, Query, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_, case, text from sqlalchemy import select, func, and_, case, text, literal_column
from typing import Optional, List from typing import Optional, List
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import uuid import uuid
@@ -505,20 +505,23 @@ async def get_report_trends(
tenant_filter = Ticket.tenant_id == current_user.tenant_id tenant_filter = Ticket.tenant_id == current_user.tenant_id
# Tickets creados por día # Tickets creados por día
# literal_column("'day'") evita que SQLAlchemy genere múltiples parámetros
# ($1, $4, $5) para 'day', lo que confunde a PostgreSQL en el GROUP BY.
_day_lit = literal_column("'day'")
created_rows = (await db.execute( created_rows = (await db.execute(
select( select(
func.date_trunc("day", Ticket.created_at).label("day"), func.date_trunc(_day_lit, Ticket.created_at).label("day"),
func.count(Ticket.id).label("cnt"), func.count(Ticket.id).label("cnt"),
) )
.where(and_(tenant_filter, Ticket.created_at >= period_start)) .where(and_(tenant_filter, Ticket.created_at >= period_start))
.group_by(func.date_trunc("day", Ticket.created_at)) .group_by(func.date_trunc(_day_lit, Ticket.created_at))
.order_by(func.date_trunc("day", Ticket.created_at)) .order_by(func.date_trunc(_day_lit, Ticket.created_at))
)).all() )).all()
# Tickets resueltos por día (según resolved_at) # Tickets resueltos por día (según resolved_at)
resolved_rows = (await db.execute( resolved_rows = (await db.execute(
select( select(
func.date_trunc("day", Ticket.resolved_at).label("day"), func.date_trunc(_day_lit, Ticket.resolved_at).label("day"),
func.count(Ticket.id).label("cnt"), func.count(Ticket.id).label("cnt"),
) )
.where(and_( .where(and_(
@@ -526,8 +529,8 @@ async def get_report_trends(
Ticket.resolved_at >= period_start, Ticket.resolved_at >= period_start,
Ticket.resolved_at.isnot(None), Ticket.resolved_at.isnot(None),
)) ))
.group_by(func.date_trunc("day", Ticket.resolved_at)) .group_by(func.date_trunc(_day_lit, Ticket.resolved_at))
.order_by(func.date_trunc("day", Ticket.resolved_at)) .order_by(func.date_trunc(_day_lit, Ticket.resolved_at))
)).all() )).all()
created_map: dict[str, int] = {r.day.strftime("%Y-%m-%d"): r.cnt for r in created_rows} created_map: dict[str, int] = {r.day.strftime("%Y-%m-%d"): r.cnt for r in created_rows}

View File

@@ -48,11 +48,17 @@ async def create_ticket(ticket: TicketCreate, db: AsyncSession = Depends(get_db)
category = await db.get(Category, category_uuid) category = await db.get(Category, category_uuid)
if not category: if not category:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"La categoría con ID {ticket.category_id} no existe.") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"La categoría con ID {ticket.category_id} no existe.")
# ✅ SECURITY: Validate category belongs to current tenant (prevents cross-tenant category injection)
if category.tenant_id != current_user.tenant_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"La categoría con ID {ticket.category_id} no existe.")
if system_uuid: if system_uuid:
system = await db.get(System, system_uuid) system = await db.get(System, system_uuid)
if not system: if not system:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"El sistema con ID {ticket.affected_system_id} no existe.") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"El sistema con ID {ticket.affected_system_id} no existe.")
# ✅ SECURITY: Validate system belongs to current tenant (prevents cross-tenant system injection)
if system.tenant_id != current_user.tenant_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"El sistema con ID {ticket.affected_system_id} no existe.")
sla_response_due, sla_resolution_due = calculate_sla_deadlines(category) sla_response_due, sla_resolution_due = calculate_sla_deadlines(category)
assigned_to_user = category.auto_assign_to if category and category.auto_assign_to else None assigned_to_user = category.auto_assign_to if category and category.auto_assign_to else None

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,6 @@ Configuración centralizada usando Pydantic Settings v2
""" """
from functools import lru_cache from functools import lru_cache
from typing import List, Optional
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
from pydantic import field_validator, Field from pydantic import field_validator, Field
import os import os
@@ -60,8 +59,8 @@ class Settings(BaseSettings):
# =================================== # ===================================
SMTP_HOST: str = Field(default="localhost") SMTP_HOST: str = Field(default="localhost")
SMTP_PORT: int = Field(default=587) SMTP_PORT: int = Field(default=587)
SMTP_USER: Optional[str] = Field(default=None) SMTP_USER: str | None = Field(default=None)
SMTP_PASSWORD: Optional[str] = Field(default=None) SMTP_PASSWORD: str | None = Field(default=None)
SMTP_USE_TLS: bool = Field(default=True) SMTP_USE_TLS: bool = Field(default=True)
SMTP_USE_SSL: bool = Field(default=False) SMTP_USE_SSL: bool = Field(default=False)
@@ -79,7 +78,7 @@ class Settings(BaseSettings):
UPLOAD_PATH: str = Field(default="/app/uploads") UPLOAD_PATH: str = Field(default="/app/uploads")
@property @property
def ALLOWED_FILE_EXTENSIONS(self) -> List[str]: def ALLOWED_FILE_EXTENSIONS(self) -> list[str]:
"""Parse the comma-separated file extensions.""" """Parse the comma-separated file extensions."""
return [ext.strip().lower() for ext in self.ALLOWED_FILE_EXTENSIONS_STR.split(",")] return [ext.strip().lower() for ext in self.ALLOWED_FILE_EXTENSIONS_STR.split(",")]
@@ -102,7 +101,7 @@ class Settings(BaseSettings):
# =================================== # ===================================
LOG_LEVEL: str = Field(default="INFO") LOG_LEVEL: str = Field(default="INFO")
LOG_FORMAT: str = Field(default="json") LOG_FORMAT: str = Field(default="json")
LOG_FILE: Optional[str] = Field(default=None) LOG_FILE: str | None = Field(default=None)
# =================================== # ===================================
# FRONTEND URLS # FRONTEND URLS

View File

@@ -0,0 +1,20 @@
"""
Rate Limiter - ServiceManagerWeb
Configura slowapi con Redis como storage backend.
Respeta settings.RATE_LIMIT_ENABLED: si está desactivado usa memoria
y el limiter queda en modo noop (enabled=False).
"""
from slowapi import Limiter
from slowapi.util import get_remote_address
from app.core.config import get_settings
settings = get_settings()
limiter = Limiter(
key_func=get_remote_address,
storage_uri=settings.REDIS_URL if settings.RATE_LIMIT_ENABLED else "memory://",
enabled=settings.RATE_LIMIT_ENABLED,
)

View File

@@ -9,6 +9,9 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
import structlog import structlog
import time import time
import uuid import uuid
@@ -31,6 +34,7 @@ from app.api.v1.router import api_router
from app.middleware.tenant import TenantMiddleware from app.middleware.tenant import TenantMiddleware
from app.middleware.correlation_id import CorrelationIDMiddleware from app.middleware.correlation_id import CorrelationIDMiddleware
from app.core.cache import cache from app.core.cache import cache
from app.core.limiter import limiter
settings = get_settings() settings = get_settings()
setup_logging() setup_logging()
@@ -70,6 +74,11 @@ app = FastAPI(
openapi_url=f"/{settings.API_VERSION}/openapi.json" openapi_url=f"/{settings.API_VERSION}/openapi.json"
) )
# SlowAPI rate limiting
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware(SlowAPIMiddleware)
# =================================== # ===================================
# MIDDLEWARE # MIDDLEWARE
# =================================== # ===================================
@@ -87,8 +96,14 @@ if settings.is_production():
"X-Correlation-ID", "X-Correlation-ID",
] ]
else: else:
cors_allow_methods = ["*"] cors_allow_methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
cors_allow_headers = ["*"] cors_allow_headers = [
"Authorization",
"Content-Type",
"X-Tenant-ID",
"X-Tenant-Slug",
"X-Correlation-ID",
]
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,

View File

@@ -42,6 +42,8 @@ class TenantMiddleware(BaseHTTPMiddleware):
"/v1/auth/refresh", "/v1/auth/refresh",
"/api/v1/auth/logout", "/api/v1/auth/logout",
"/v1/auth/logout", "/v1/auth/logout",
"/api/v1/auth/me",
"/v1/auth/me",
"/api/v1/auth/forgot-password", "/api/v1/auth/forgot-password",
"/v1/auth/forgot-password", "/v1/auth/forgot-password",
"/api/v1/auth/reset-password", "/api/v1/auth/reset-password",

View File

@@ -0,0 +1,43 @@
"""add_ticket_indexes
Revision ID: b7c8d9e0f1a2
Revises: fix_client_timestamps
Create Date: 2026-03-03 00:00:00.000000
Agrega índices a la tabla tickets para optimizar queries frecuentes:
- idx_tickets_status → filtros por estado
- idx_tickets_priority → filtros por prioridad
- idx_tickets_assigned_to → tickets por agente asignado
- idx_tickets_tenant_status → compuesto multi-tenant (tenant_id, status)
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = 'b7c8d9e0f1a2'
down_revision = 'fix_client_timestamps'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets (status)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS idx_tickets_priority ON tickets (priority)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS idx_tickets_assigned_to "
"ON tickets (assigned_to) WHERE assigned_to IS NOT NULL"
)
op.execute(
"CREATE INDEX IF NOT EXISTS idx_tickets_tenant_status "
"ON tickets (tenant_id, status)"
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS idx_tickets_tenant_status")
op.execute("DROP INDEX IF EXISTS idx_tickets_assigned_to")
op.execute("DROP INDEX IF EXISTS idx_tickets_priority")
op.execute("DROP INDEX IF EXISTS idx_tickets_status")

View File

@@ -31,6 +31,7 @@ pyotp==2.9.0 # TOTP/2FA support
# =================================== # ===================================
celery==5.3.4 celery==5.3.4
redis==5.0.1 redis==5.0.1
slowapi==0.1.9 # Rate limiting middleware
# =================================== # ===================================
# EMAIL # EMAIL

View File

@@ -0,0 +1,49 @@
"""
Script para resetear contraseñas de todos los usuarios a valores conocidos.
Ejecutar con: python -m scripts.reset_passwords (desde /app en el contenedor)
"""
import asyncio
from sqlalchemy import select, update
from app.core.database import AsyncSessionLocal
from app.core.security import security
from app.models.user import User
# Mapa email -> nueva contraseña
PASSWORD_MAP = {
"admin@aduanasoft.com": "admin123",
"admin@test.com": "admin123",
"manager@aduanasoft.com": "manager123",
"agente@aduanasoft.com": "agente123",
"auditor1@test.com": "auditor123",
"admin-cliente@empresa-demo.com": "clienteadmin123",
"cliente@empresa-demo.com": "cliente123",
"test_user@aduanasoft.com": "test123",
}
async def reset_all_passwords():
async with AsyncSessionLocal() as db:
result = await db.execute(select(User))
users = result.scalars().all()
updated = 0
skipped = 0
for user in users:
if user.email in PASSWORD_MAP:
plain = PASSWORD_MAP[user.email]
user.password_hash = security.hash_password(plain)
user.email_verified = True
user.is_active = True
updated += 1
print(f"{user.email}{plain}")
else:
skipped += 1
print(f" ⚠️ {user.email} (sin contraseña definida, se omite)")
await db.commit()
print(f"\nResumen: {updated} actualizados, {skipped} omitidos")
print("\n📋 Credenciales listas:")
for email, pwd in PASSWORD_MAP.items():
print(f" {email} / {pwd}")
if __name__ == "__main__":
asyncio.run(reset_all_passwords())

View File

@@ -415,18 +415,19 @@ INSERT INTO tenants (name, slug, contact_email) VALUES
('Aduanasoft Demo', 'aduanasoft-demo', 'demo@aduanasoft.com'); ('Aduanasoft Demo', 'aduanasoft-demo', 'demo@aduanasoft.com');
-- Usuario admin por defecto (password: admin123) -- Usuario admin por defecto (password: admin123)
-- Hash generado con Argon2: $argon2id$v=19$m=65536,t=3,p=4$... -- Hash Argon2id generado con m=65536,t=3,p=4
INSERT INTO users (tenant_id, email, first_name, last_name, password_hash, role, is_active, email_verified) INSERT INTO users (tenant_id, email, first_name, last_name, password_hash, role, is_active, email_verified)
SELECT SELECT
id, id,
'admin@aduanasoft.com', 'admin@aduanasoft.com',
'Admin', 'Admin',
'Sistema', 'Sistema',
'$argon2id$v=19$m=65536,t=3,p=4$example_hash_here', '$argon2id$v=19$m=65536,t=3,p=4$wpjz/t+bM4bQmtM6B6A0pg$ELwnGUL4S1Y6tywp0LS6cre0bvWEoVuJ845spZ9Z9IQ',
'ADMIN', 'ADMIN',
true, true,
true true
FROM tenants WHERE slug = 'aduanasoft-demo'; FROM tenants WHERE slug = 'aduanasoft-demo'
ON CONFLICT (tenant_id, email) DO NOTHING;
-- Categorías por defecto -- Categorías por defecto
INSERT INTO ticket_categories (tenant_id, name, description, sla_response_hours, sla_resolution_hours) INSERT INTO ticket_categories (tenant_id, name, description, sla_response_hours, sla_resolution_hours)

View File

@@ -164,6 +164,8 @@ services:
- NODE_ENV=${ENVIRONMENT:-development} - NODE_ENV=${ENVIRONMENT:-development}
- PUBLIC_API_URL=http://backend:8000 - PUBLIC_API_URL=http://backend:8000
- PUBLIC_APP_NAME=ServiceManager Cliente - PUBLIC_APP_NAME=ServiceManager Cliente
- PORT=3000
- HMR_CLIENT_PORT=3000
volumes: volumes:
- ./frontend-client:/app - ./frontend-client:/app
- /app/node_modules - /app/node_modules
@@ -189,6 +191,8 @@ services:
- NODE_ENV=${ENVIRONMENT:-development} - NODE_ENV=${ENVIRONMENT:-development}
- PUBLIC_API_URL=http://backend:8000 - PUBLIC_API_URL=http://backend:8000
- PUBLIC_APP_NAME=ServiceManager Admin - PUBLIC_APP_NAME=ServiceManager Admin
- PORT=3000
- HMR_CLIENT_PORT=3001
volumes: volumes:
- ./frontend-internal:/app - ./frontend-internal:/app
- /app/node_modules - /app/node_modules

11
frontend-client/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
import type { User } from '$lib/stores/auth';
declare global {
namespace App {
interface Locals {
user: User | null;
}
}
}
export {};

View File

@@ -2,7 +2,7 @@
<html lang="es"> <html lang="es">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" /> <link rel="icon" href="%sveltekit.assets%/favicon.png" type="image/png" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#3b82f6" /> <meta name="theme-color" content="#3b82f6" />

View File

@@ -0,0 +1,19 @@
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
const cookie = event.request.headers.get('cookie') ?? '';
if (cookie) {
try {
const apiUrl = process.env.PUBLIC_API_URL ?? 'http://backend:8000';
const response = await fetch(`${apiUrl}/v1/auth/me`, {
headers: { cookie, 'X-App': 'client' }
});
event.locals.user = response.ok ? await response.json() : null;
} catch {
event.locals.user = null;
}
} else {
event.locals.user = null;
}
return resolve(event);
};

View File

@@ -29,15 +29,17 @@ const initialState: AppState = {
// API helper function // API helper function
async function apiCall(endpoint: string, options: RequestInit = {}) { async function apiCall(endpoint: string, options: RequestInit = {}) {
const authState = get(auth); const authState = get(auth);
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-App': 'client',
...(authState.user?.tenant_id ? { 'X-Tenant-ID': authState.user.tenant_id } : {}),
...(options.headers as Record<string, string> ?? {})
};
if (authState.token) headers['Authorization'] = `Bearer ${authState.token}`;
const response = await fetch(`/api/v1${endpoint}`, { const response = await fetch(`/api/v1${endpoint}`, {
...options, ...options,
headers: { credentials: 'include',
'Content-Type': 'application/json', headers
'Authorization': `Bearer ${authState.token}`,
...(authState.user?.tenant_id ? { 'X-Tenant-ID': authState.user.tenant_id } : {}),
...options.headers
}
}); });
if (!response.ok) { if (!response.ok) {

View File

@@ -50,26 +50,26 @@ function createAuthStore() {
return { return {
subscribe, subscribe,
// Initialize auth from localStorage // Rehidrata sesión desde cookie HttpOnly (no toca localStorage)
init: () => { init: async () => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const token = localStorage.getItem('auth_token');
const user = localStorage.getItem('auth_user');
if (token && user) {
try { try {
const parsedUser = JSON.parse(user); const response = await fetch('/api/v1/auth/me', {
credentials: 'include',
headers: { 'X-App': 'client' }
});
if (response.ok) {
const user = await response.json();
set({ set({
user: parsedUser, user,
token, token: null,
isAuthenticated: true, isAuthenticated: true,
isLoading: false isLoading: false
}); });
} catch (error) {
console.error('Error parsing stored auth data:', error);
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_user');
} }
// 401/400 es esperado cuando no hay sesión activa — no es un error
} catch (error) {
// Ignorar errores de red en init
} }
} }
}, },
@@ -81,6 +81,7 @@ function createAuthStore() {
try { try {
const response = await fetch('/api/v1/auth/login', { const response = await fetch('/api/v1/auth/login', {
method: 'POST', method: 'POST',
credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
@@ -94,12 +95,6 @@ function createAuthStore() {
const data: LoginResponse = await response.json(); const data: LoginResponse = await response.json();
// Store auth data
if (typeof window !== 'undefined') {
localStorage.setItem('auth_token', data.access_token);
localStorage.setItem('auth_user', JSON.stringify(data.user));
}
set({ set({
user: data.user, user: data.user,
token: data.access_token, token: data.access_token,
@@ -113,22 +108,29 @@ function createAuthStore() {
}, },
// Logout // Logout
logout: () => { logout: async () => {
// Llamar al backend para que borre la cookie HttpOnly
try {
await fetch('/api/v1/auth/logout', {
method: 'POST',
credentials: 'include',
headers: { 'X-App': 'client' }
});
} catch { /* ignorar errores de red */ }
set(initialState);
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_user');
// Immediate redirect after cleanup
window.location.href = '/login'; window.location.href = '/login';
} }
set(initialState);
}, },
// Update user data // Update user data
updateUser: (user: User) => { updateUser: (user: User) => {
update(state => ({ ...state, user })); update(state => ({ ...state, user }));
if (typeof window !== 'undefined') { },
localStorage.setItem('auth_user', JSON.stringify(user));
} // Set user from SSR pre-load (no fetch required)
setUser: (user: User) => {
set({ user, token: null, isAuthenticated: true, isLoading: false });
}, },
// Set loading state // Set loading state

View File

@@ -80,18 +80,22 @@ const initialState: TicketsState = {
async function apiCall(endpoint: string, options: RequestInit = {}) { async function apiCall(endpoint: string, options: RequestInit = {}) {
const authState = get(auth); const authState = get(auth);
if (!authState.token || !authState.user) { if (!authState.user) {
throw new Error('Not authenticated'); throw new Error('Not authenticated');
} }
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-App': 'client',
...(options.headers as Record<string, string>)
};
if (authState.token) headers['Authorization'] = `Bearer ${authState.token}`;
if (authState.user.tenant_id) headers['X-Tenant-ID'] = authState.user.tenant_id;
const response = await fetch(`/api/v1${endpoint}`, { const response = await fetch(`/api/v1${endpoint}`, {
...options, ...options,
headers: { credentials: 'include',
'Content-Type': 'application/json', headers
'Authorization': `Bearer ${authState.token}`,
'X-Tenant-ID': authState.user.tenant_id,
...options.headers
}
}); });
if (!response.ok) { if (!response.ok) {
@@ -259,16 +263,18 @@ function createTicketsStore() {
const authState = get(auth); const authState = get(auth);
if (!authState.token || !authState.user) { if (!authState.user) {
throw new Error('Not authenticated'); throw new Error('Not authenticated');
} }
const uploadHeaders: Record<string, string> = { 'X-App': 'client' };
if (authState.token) uploadHeaders['Authorization'] = `Bearer ${authState.token}`;
if (authState.user.tenant_id) uploadHeaders['X-Tenant-ID'] = authState.user.tenant_id;
const response = await fetch(`/api/v1/tickets/${ticketId}/attachments`, { const response = await fetch(`/api/v1/tickets/${ticketId}/attachments`, {
method: 'POST', method: 'POST',
headers: { credentials: 'include',
'Authorization': `Bearer ${authState.token}`, headers: uploadHeaders,
'X-Tenant-ID': authState.user.tenant_id
},
body: formData body: formData
}); });
@@ -338,16 +344,18 @@ function createTicketsStore() {
downloadAttachment: async (ticketId: string, attachmentId: string, filename: string) => { downloadAttachment: async (ticketId: string, attachmentId: string, filename: string) => {
const authState = get(auth); const authState = get(auth);
if (!authState.token || !authState.user) { if (!authState.user) {
throw new Error('Not authenticated'); throw new Error('Not authenticated');
} }
const dlHeaders: Record<string, string> = { 'X-App': 'client' };
if (authState.token) dlHeaders['Authorization'] = `Bearer ${authState.token}`;
if (authState.user.tenant_id) dlHeaders['X-Tenant-ID'] = authState.user.tenant_id;
const response = await fetch(`/api/v1/tickets/${ticketId}/attachments/${attachmentId}/download`, { const response = await fetch(`/api/v1/tickets/${ticketId}/attachments/${attachmentId}/download`, {
method: 'GET', method: 'GET',
headers: { credentials: 'include',
'Authorization': `Bearer ${authState.token}`, headers: dlHeaders
'X-Tenant-ID': authState.user.tenant_id
}
}); });
if (!response.ok) { if (!response.ok) {

View File

@@ -0,0 +1,126 @@
/**
* Cliente HTTP centralizado para frontend-client.
* Usa cookies HttpOnly (client_access_token) como fuente primaria de auth,
* con Bearer token como complemento cuando está disponible en memoria.
*/
import { auth } from '$lib/stores/auth';
import { get } from 'svelte/store';
const API_BASE = '/api/v1';
interface RequestOptions extends RequestInit {
params?: Record<string, string>;
}
async function request<T>(endpoint: string, options: RequestOptions = {}): Promise<T> {
const { params, ...init } = options;
let url = `${API_BASE}${endpoint}`;
if (params) {
const filteredParams = Object.entries(params)
.filter(([, value]) => value !== undefined && value !== null && value !== '')
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {});
if (Object.keys(filteredParams).length > 0) {
url += `?${new URLSearchParams(filteredParams).toString()}`;
}
}
const authState = get(auth);
const headers = new Headers(init.headers);
// Bearer header cuando el token está en memoria (sesión activa sin reload)
if (authState.token) {
headers.set('Authorization', `Bearer ${authState.token}`);
}
if (authState.user?.tenant_id && !headers.has('X-Tenant-ID')) {
headers.set('X-Tenant-ID', authState.user.tenant_id);
}
if (!headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
// Identifica este frontend para que el backend use client_access_token
headers.set('X-App', 'client');
const response = await fetch(url, {
...init,
credentials: 'include',
headers
});
if (response.status === 401) {
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
throw new Error('Unauthorized');
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `API error: ${response.statusText}`);
}
if (response.status === 204) {
return {} as T;
}
return response.json();
}
async function downloadFile(endpoint: string, filename: string): Promise<void> {
const authState = get(auth);
const headers = new Headers();
if (authState.token) {
headers.set('Authorization', `Bearer ${authState.token}`);
}
if (authState.user?.tenant_id) {
headers.set('X-Tenant-ID', authState.user.tenant_id);
}
headers.set('X-App', 'client');
const response = await fetch(`${API_BASE}${endpoint}`, {
method: 'GET',
credentials: 'include',
headers
});
if (response.status === 401) {
if (typeof window !== 'undefined') window.location.href = '/login';
throw new Error('Unauthorized');
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `Download error: ${response.statusText}`);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}
export const api = {
get: <T>(endpoint: string, params?: Record<string, string>) =>
request<T>(endpoint, { method: 'GET', params }),
post: <T>(endpoint: string, body?: any) =>
request<T>(endpoint, { method: 'POST', body: body !== undefined ? JSON.stringify(body) : undefined }),
put: <T>(endpoint: string, body?: any) =>
request<T>(endpoint, { method: 'PUT', body: body !== undefined ? JSON.stringify(body) : undefined }),
patch: <T>(endpoint: string, body?: any) =>
request<T>(endpoint, { method: 'PATCH', body: body !== undefined ? JSON.stringify(body) : undefined }),
delete: <T>(endpoint: string) =>
request<T>(endpoint, { method: 'DELETE' }),
downloadFile: (endpoint: string, filename: string) =>
downloadFile(endpoint, filename)
};

View File

@@ -0,0 +1,7 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = ({ locals }) => {
return {
user: locals.user ?? null
};
};

View File

@@ -4,17 +4,39 @@
import Toast from '$lib/components/Toast.svelte'; import Toast from '$lib/components/Toast.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { auth } from '$lib/stores/auth.js'; import { auth } from '$lib/stores/auth.js';
import { goto } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { browser } from '$app/environment';
import '../app.css'; import '../app.css';
export let data;
let mounted = false;
onMount(() => { onMount(() => {
auth.init(); if (data.user && !$auth.isAuthenticated) {
auth.setUser(data.user);
}
mounted = true;
}); });
$: showHeader = !$page.url.pathname.startsWith('/login') && !$page.url.pathname.startsWith('/register'); // Guard reactivo global: redirige a /login si no está autenticado en rutas protegidas
const publicRoutes = ['/login', '/register', '/forgot-password', '/reset-password'];
$: if (browser && mounted && !$auth.isAuthenticated &&
!publicRoutes.some(r => $page.url.pathname.startsWith(r))) {
goto('/login');
}
$: showHeader = !publicRoutes.some(r => $page.url.pathname.startsWith(r));
</script> </script>
<div class="min-h-screen bg-gray-50 font-sans"> <div class="min-h-screen bg-gray-50 font-sans">
{#if !mounted}
<!-- Esperando inicialización de sesión -->
<div class="flex items-center justify-center min-h-screen bg-gray-50">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
{:else}
{#if showHeader} {#if showHeader}
<Header /> <Header />
{/if} {/if}
@@ -27,6 +49,7 @@
<footer class="py-4 text-center border-t border-gray-200 bg-white"> <footer class="py-4 text-center border-t border-gray-200 bg-white">
<p class="text-xs text-gray-400">ServiceManagerWeb v1.9.0 · © 2026 Aduanasoft</p> <p class="text-xs text-gray-400">ServiceManagerWeb v1.9.0 · © 2026 Aduanasoft</p>
</footer> </footer>
{/if}
<!-- Toast notifications --> <!-- Toast notifications -->
{#each $toast.toasts as toastMessage (toastMessage.id)} {#each $toast.toasts as toastMessage (toastMessage.id)}

View File

@@ -216,12 +216,13 @@
>Recordar en este equipo</label >Recordar en este equipo</label
> >
</div> </div>
<a <button
href="/forgot-password" type="button"
class="text-sm font-medium text-blue-600 hover:text-blue-500" class="text-sm font-medium text-blue-600 hover:text-blue-500 bg-transparent border-none p-0 cursor-pointer"
on:click={() => goto('/forgot-password')}
> >
Olvide mi clave Olvide mi clave
</a> </button>
</div> </div>
</div> </div>
{:else} {:else}

View File

@@ -38,11 +38,14 @@
async function loadProfile() { async function loadProfile() {
isLoading = true; isLoading = true;
try { try {
const response = await fetch('/api/v1/client-profile/', { const headers: Record<string, string> = {
headers: { 'X-App': 'client',
Authorization: `Bearer ${$auth.token}`,
'X-Tenant-ID': $auth.user?.tenant_id ?? '' 'X-Tenant-ID': $auth.user?.tenant_id ?? ''
} };
if ($auth.token) headers['Authorization'] = `Bearer ${$auth.token}`;
const response = await fetch('/api/v1/client-profile/', {
credentials: 'include',
headers
}); });
if (!response.ok) throw new Error((await response.json()).detail); if (!response.ok) throw new Error((await response.json()).detail);
profile = await response.json(); profile = await response.json();
@@ -62,13 +65,16 @@
async function saveProfile() { async function saveProfile() {
isSaving = true; isSaving = true;
try { try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-App': 'client',
'X-Tenant-ID': $auth.user?.tenant_id ?? ''
};
if ($auth.token) headers['Authorization'] = `Bearer ${$auth.token}`;
const response = await fetch('/api/v1/client-profile/', { const response = await fetch('/api/v1/client-profile/', {
method: 'PUT', method: 'PUT',
headers: { credentials: 'include',
'Content-Type': 'application/json', headers,
Authorization: `Bearer ${$auth.token}`,
'X-Tenant-ID': $auth.user?.tenant_id ?? ''
},
body: JSON.stringify(form) body: JSON.stringify(form)
}); });
if (!response.ok) throw new Error((await response.json()).detail); if (!response.ok) throw new Error((await response.json()).detail);

View File

@@ -34,7 +34,11 @@
try { try {
const response = await fetch('/api/v1/auth/2fa/setup', { const response = await fetch('/api/v1/auth/2fa/setup', {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${$auth.token}` } credentials: 'include',
headers: {
'X-App': 'client',
...($auth.token ? { Authorization: `Bearer ${$auth.token}` } : {})
}
}); });
if (!response.ok) throw new Error((await response.json()).detail); if (!response.ok) throw new Error((await response.json()).detail);
const data = await response.json(); const data = await response.json();
@@ -57,7 +61,12 @@
try { try {
const response = await fetch('/api/v1/auth/2fa/enable', { const response = await fetch('/api/v1/auth/2fa/enable', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${$auth.token}` }, credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-App': 'client',
...($auth.token ? { Authorization: `Bearer ${$auth.token}` } : {})
},
body: JSON.stringify({ totp_code: totpSetupCode }) body: JSON.stringify({ totp_code: totpSetupCode })
}); });
if (!response.ok) throw new Error((await response.json()).detail); if (!response.ok) throw new Error((await response.json()).detail);
@@ -84,7 +93,12 @@
try { try {
const response = await fetch('/api/v1/auth/2fa/disable', { const response = await fetch('/api/v1/auth/2fa/disable', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${$auth.token}` }, credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-App': 'client',
...($auth.token ? { Authorization: `Bearer ${$auth.token}` } : {})
},
body: JSON.stringify({ totp_code: disableTotpCode }) body: JSON.stringify({ totp_code: disableTotpCode })
}); });
if (!response.ok) throw new Error((await response.json()).detail); if (!response.ok) throw new Error((await response.json()).detail);
@@ -157,16 +171,20 @@
async function loadBusinessProfile() { async function loadBusinessProfile() {
try { try {
if (!$auth.token || !$auth.user) { if (!$auth.user) {
console.warn('Usuario no autenticado'); console.warn('Usuario no autenticado');
return; return;
} }
const response = await fetch('/api/v1/client-profile/', { const _lpHeaders: Record<string, string> = {
headers: { 'X-App': 'client',
Authorization: `Bearer ${$auth.token}`,
'X-Tenant-ID': $auth.user.tenant_id 'X-Tenant-ID': $auth.user.tenant_id
} };
if ($auth.token) _lpHeaders['Authorization'] = `Bearer ${$auth.token}`;
const response = await fetch('/api/v1/client-profile/', {
credentials: 'include',
headers: _lpHeaders
}); });
if (response.ok) { if (response.ok) {
@@ -267,9 +285,11 @@
try { try {
const response = await fetch('/api/v1/auth/profile', { const response = await fetch('/api/v1/auth/profile', {
method: 'PATCH', method: 'PATCH',
credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${$auth.token}` 'X-App': 'client',
...($auth.token ? { Authorization: `Bearer ${$auth.token}` } : {})
}, },
body: JSON.stringify({ body: JSON.stringify({
first_name: firstName.trim(), first_name: firstName.trim(),
@@ -300,9 +320,11 @@
try { try {
const response = await fetch('/api/v1/auth/change-password', { const response = await fetch('/api/v1/auth/change-password', {
method: 'POST', method: 'POST',
credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${$auth.token}` 'X-App': 'client',
...($auth.token ? { Authorization: `Bearer ${$auth.token}` } : {})
}, },
body: JSON.stringify({ body: JSON.stringify({
current_password: currentPassword, current_password: currentPassword,
@@ -349,13 +371,16 @@
profileData.credit_limit = parseFloat(profileData.credit_limit); profileData.credit_limit = parseFloat(profileData.credit_limit);
} }
const _bpHeaders: Record<string, string> = {
'Content-Type': 'application/json',
'X-App': 'client',
'X-Tenant-ID': $auth.user?.tenant_id ?? ''
};
if ($auth.token) _bpHeaders['Authorization'] = `Bearer ${$auth.token}`;
const response = await fetch('/api/v1/client-profile/', { const response = await fetch('/api/v1/client-profile/', {
method: 'POST', method: 'POST',
headers: { credentials: 'include',
'Content-Type': 'application/json', headers: _bpHeaders,
Authorization: `Bearer ${$auth.token}`,
'X-Tenant-ID': $auth.user.tenant_id
},
body: JSON.stringify(profileData) body: JSON.stringify(profileData)
}); });

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -10,6 +10,16 @@ export default defineConfig({
usePolling: true, usePolling: true,
interval: 500 interval: 500
}, },
// HMR: el browser llega al contenedor en el mismo puerto 3000
hmr: {
host: 'localhost',
clientPort: parseInt(process.env.HMR_CLIENT_PORT || '3000')
},
// Permitir que Vite sirva archivos del filesystem del contenedor
fs: {
allow: ['/app', '.'],
strict: false
},
proxy: { proxy: {
'/api': { '/api': {
target: process.env.PUBLIC_API_URL || 'http://localhost:8000', target: process.env.PUBLIC_API_URL || 'http://localhost:8000',

View File

@@ -4,7 +4,7 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev --port 3000 --host 0.0.0.0", "dev": "vite dev --host 0.0.0.0",
"build": "vite build", "build": "vite build",
"preview": "vite preview --port 3000 --host 0.0.0.0", "preview": "vite preview --port 3000 --host 0.0.0.0",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",

11
frontend-internal/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
import type { InternalUser } from '$lib/stores/auth';
declare global {
namespace App {
interface Locals {
user: InternalUser | null;
}
}
}
export {};

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="description" content="ServiceManager - Mesa de Ayuda Empresarial - Portal Interno" /> <meta name="description" content="ServiceManager - Mesa de Ayuda Empresarial - Portal Interno" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="%sveltekit.assets%/favicon.ico" /> <link rel="icon" href="%sveltekit.assets%/favicon.png" type="image/png" />
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">

View File

@@ -0,0 +1,19 @@
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
const cookie = event.request.headers.get('cookie') ?? '';
if (cookie) {
try {
const apiUrl = process.env.PUBLIC_API_URL ?? 'http://backend:8000';
const response = await fetch(`${apiUrl}/v1/auth/me`, {
headers: { cookie, 'X-App': 'internal' }
});
event.locals.user = response.ok ? await response.json() : null;
} catch {
event.locals.user = null;
}
} else {
event.locals.user = null;
}
return resolve(event);
};

View File

@@ -72,6 +72,11 @@
name: 'Seguridad', name: 'Seguridad',
href: '/audit/security', href: '/audit/security',
icon: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z' icon: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z'
},
{
name: 'Reporte Endpoints',
href: '/test-report',
icon: 'M9 3H5a2 2 0 00-2 2v4m6-6h10a2 2 0 012 2v4M9 3v18m0 0h10a2 2 0 002-2V9M9 21H5a2 2 0 01-2-2V9m0 0h18'
} }
); );
} }

View File

@@ -60,32 +60,34 @@ const initialState: AuthState = {
function createAuthStore() { function createAuthStore() {
const { subscribe, set, update } = writable<AuthState>(initialState); const { subscribe, set, update } = writable<AuthState>(initialState);
// Track current state for uso interno (evita dependencias circulares)
let _state = initialState;
subscribe(s => { _state = s; });
return { return {
subscribe, subscribe,
// Initialize auth from localStorage // Rehidrata sesión desde cookie HttpOnly (no toca localStorage)
init: () => { init: async () => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const token = localStorage.getItem('internal_auth_token');
const refreshToken = localStorage.getItem('internal_auth_refresh_token');
const user = localStorage.getItem('internal_auth_user');
if (token && user) {
try { try {
const parsedUser = JSON.parse(user); const response = await fetch('/api/v1/auth/me', {
credentials: 'include',
headers: { 'X-App': 'internal' }
});
if (response.ok) {
const user = await response.json();
set({ set({
user: parsedUser, user,
token, token: null,
refreshToken: refreshToken || null, refreshToken: null,
isAuthenticated: true, isAuthenticated: true,
isLoading: false isLoading: false
}); });
} catch (error) {
console.error('Error parsing stored auth data:', error);
localStorage.removeItem('internal_auth_token');
localStorage.removeItem('internal_auth_refresh_token');
localStorage.removeItem('internal_auth_user');
} }
// 401/400 es esperado cuando no hay sesión activa — no es un error
} catch (error) {
// Ignorar errores de red en init
} }
} }
}, },
@@ -97,6 +99,7 @@ function createAuthStore() {
try { try {
const response = await fetch('/api/v1/auth/login', { const response = await fetch('/api/v1/auth/login', {
method: 'POST', method: 'POST',
credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
@@ -110,15 +113,6 @@ function createAuthStore() {
const data: LoginResponse = await response.json(); const data: LoginResponse = await response.json();
// Store auth data
if (typeof window !== 'undefined') {
localStorage.setItem('internal_auth_token', data.access_token);
if (data.refresh_token) {
localStorage.setItem('internal_auth_refresh_token', data.refresh_token);
}
localStorage.setItem('internal_auth_user', JSON.stringify(data.user));
}
set({ set({
user: data.user, user: data.user,
token: data.access_token, token: data.access_token,
@@ -134,11 +128,7 @@ function createAuthStore() {
// Refresh Session // Refresh Session
refreshSession: async (): Promise<void> => { refreshSession: async (): Promise<void> => {
// Need to get current state to access refresh token, logic simplified const currentRefreshToken = _state.refreshToken;
let currentRefreshToken: string | null = null;
if (typeof window !== 'undefined') {
currentRefreshToken = localStorage.getItem('internal_auth_refresh_token');
}
if (!currentRefreshToken) { if (!currentRefreshToken) {
throw new Error("No refresh token available"); throw new Error("No refresh token available");
@@ -149,6 +139,7 @@ function createAuthStore() {
try { try {
const response = await fetch('/api/v1/auth/refresh', { const response = await fetch('/api/v1/auth/refresh', {
method: 'POST', method: 'POST',
credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
@@ -166,11 +157,6 @@ function createAuthStore() {
const data: TokenResponse = await response.json(); const data: TokenResponse = await response.json();
// Update token in storage and state
if (typeof window !== 'undefined') {
localStorage.setItem('internal_auth_token', data.access_token);
}
update(state => ({ update(state => ({
...state, ...state,
token: data.access_token, token: data.access_token,
@@ -184,14 +170,16 @@ function createAuthStore() {
}, },
// Logout // Logout
logout: () => { logout: async () => {
if (typeof window !== 'undefined') { // Llamar al backend para que borre la cookie HttpOnly
localStorage.removeItem('internal_auth_token'); try {
localStorage.removeItem('internal_auth_refresh_token'); await fetch('/api/v1/auth/logout', {
localStorage.removeItem('internal_auth_user'); method: 'POST',
} credentials: 'include',
headers: { 'X-App': 'internal' }
});
} catch { /* ignorar errores de red */ }
set(initialState); set(initialState);
// Optional: Redirect to login
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.location.href = '/login'; window.location.href = '/login';
} }
@@ -200,9 +188,11 @@ function createAuthStore() {
// Update user data // Update user data
updateUser: (user: InternalUser) => { updateUser: (user: InternalUser) => {
update(state => ({ ...state, user })); update(state => ({ ...state, user }));
if (typeof window !== 'undefined') { },
localStorage.setItem('internal_auth_user', JSON.stringify(user));
} // Set user from SSR pre-load (no fetch required)
setUser: (user: InternalUser) => {
set({ user, token: null, refreshToken: null, isAuthenticated: true, isLoading: false });
}, },
// Set loading state // Set loading state

View File

@@ -0,0 +1,161 @@
import { writable } from 'svelte/store';
/** All available dashboard modules */
export interface DashboardModule {
id: string;
title: string;
description: string;
icon: string;
href: string;
color: string;
/** Minimum role required to see this module */
roles: string[];
}
export const ALL_MODULES: DashboardModule[] = [
{
id: 'tenants',
title: 'Clientes',
description: 'Gestión de organizaciones y tenants',
icon: 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4',
href: '/tenants',
color: 'bg-blue-600',
roles: ['ADMIN', 'SUPPORT_MANAGER']
},
{
id: 'users',
title: 'Usuarios',
description: 'Administración de usuarios y roles',
icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z',
href: '/users',
color: 'bg-green-600',
roles: ['ADMIN', 'SUPPORT_MANAGER']
},
{
id: 'tickets',
title: 'Tickets',
description: 'Gestión y seguimiento de tickets de soporte',
icon: 'M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2',
href: '/tickets',
color: 'bg-indigo-600',
roles: ['ADMIN', 'SUPPORT_MANAGER', 'AGENT']
},
{
id: 'systems',
title: 'Sistemas',
description: 'Catálogo de sistemas soportados',
icon: 'M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01',
href: '/systems',
color: 'bg-gray-700',
roles: ['ADMIN', 'SUPPORT_MANAGER']
},
{
id: 'categories',
title: 'Categorías',
description: 'Clasificación de tickets por área',
icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10',
href: '/categories',
color: 'bg-orange-600',
roles: ['ADMIN', 'SUPPORT_MANAGER']
},
{
id: 'sla',
title: 'SLA Management',
description: 'Monitoreo de tiempos de respuesta y SLAs',
icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z',
href: '/sla',
color: 'bg-teal-600',
roles: ['ADMIN', 'SUPPORT_MANAGER']
},
{
id: 'reports',
title: 'Reportes',
description: 'Informes estadísticos y análisis de rendimiento',
icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z',
href: '/reports',
color: 'bg-purple-600',
roles: ['ADMIN', 'SUPPORT_MANAGER']
},
{
id: 'audit',
title: 'Auditoría',
description: 'Bitácora de acciones y trazabilidad del sistema',
icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z',
href: '/audit',
color: 'bg-red-700',
roles: ['ADMIN', 'AUDITOR']
},
{
id: 'security',
title: 'Seguridad',
description: 'Análisis de amenazas y eventos de seguridad',
icon: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z',
href: '/audit/security',
color: 'bg-yellow-600',
roles: ['ADMIN']
},
{
id: 'endpoints',
title: 'Reporte de Endpoints',
description: 'Estado y diagnóstico de todos los endpoints API',
icon: 'M9 3H5a2 2 0 00-2 2v4m6-6h10a2 2 0 012 2v4M9 3v18m0 0h10a2 2 0 002-2V9M9 21H5a2 2 0 01-2-2V9m0 0h18',
href: '/test-report',
color: 'bg-cyan-600',
roles: ['ADMIN']
}
];
const STORAGE_KEY = 'dashboard_module_visibility';
function getInitialVisibility(): Record<string, boolean> {
if (typeof window === 'undefined') {
return Object.fromEntries(ALL_MODULES.map(m => [m.id, true]));
}
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) return JSON.parse(stored);
} catch { /* ignore */ }
return Object.fromEntries(ALL_MODULES.map(m => [m.id, true]));
}
function createDashboardConfig() {
const { subscribe, set, update } = writable<Record<string, boolean>>(getInitialVisibility());
return {
subscribe,
toggle(id: string) {
update(state => {
const next = { ...state, [id]: !state[id] };
if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
}
return next;
});
},
setVisible(id: string, visible: boolean) {
update(state => {
const next = { ...state, [id]: visible };
if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
}
return next;
});
},
showAll() {
const all = Object.fromEntries(ALL_MODULES.map(m => [m.id, true]));
if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
}
set(all);
},
reset() {
const defaults = Object.fromEntries(ALL_MODULES.map(m => [m.id, true]));
if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_KEY, JSON.stringify(defaults));
}
set(defaults);
}
};
}
export const dashboardConfig = createDashboardConfig();

View File

@@ -24,16 +24,8 @@ async function request<T>(endpoint: string, options: RequestOptions = {}): Promi
} }
const authState = get(auth); const authState = get(auth);
const token = authState.token || (typeof window !== 'undefined' ? localStorage.getItem('internal_auth_token') : null); const token = authState.token;
const tenantId = authState.user?.tenant_id ?? null;
// Resolve tenant_id from store or from the persisted user object in localStorage
let tenantId = authState.user?.tenant_id ?? null;
if (!tenantId && typeof window !== 'undefined') {
try {
const stored = localStorage.getItem('internal_auth_user');
if (stored) tenantId = JSON.parse(stored)?.tenant_id ?? null;
} catch { /* ignore */ }
}
const headers = new Headers(init.headers); const headers = new Headers(init.headers);
if (token) { if (token) {
@@ -45,17 +37,18 @@ async function request<T>(endpoint: string, options: RequestOptions = {}): Promi
if (!headers.has('Content-Type')) { if (!headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json'); headers.set('Content-Type', 'application/json');
} }
// Identifica este frontend para que el backend use la cookie correcta
headers.set('X-App', 'internal');
const response = await fetch(url, { const response = await fetch(url, {
...init, ...init,
credentials: 'include',
headers headers
}); });
if (response.status === 401) { if (response.status === 401) {
// Token expired or invalid // Token expired or invalid
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
localStorage.removeItem('internal_auth_token');
localStorage.removeItem('internal_auth_user');
window.location.href = '/login'; window.location.href = '/login';
} }
throw new Error('Unauthorized'); throw new Error('Unauthorized');
@@ -76,15 +69,8 @@ async function request<T>(endpoint: string, options: RequestOptions = {}): Promi
async function downloadFile(endpoint: string, filename: string): Promise<void> { async function downloadFile(endpoint: string, filename: string): Promise<void> {
const authState = get(auth); const authState = get(auth);
const token = authState.token || (typeof window !== 'undefined' ? localStorage.getItem('internal_auth_token') : null); const token = authState.token;
const tenantId = authState.user?.tenant_id ?? null;
let tenantId = authState.user?.tenant_id ?? null;
if (!tenantId && typeof window !== 'undefined') {
try {
const stored = localStorage.getItem('internal_auth_user');
if (stored) tenantId = JSON.parse(stored)?.tenant_id ?? null;
} catch { /* ignore */ }
}
const headers = new Headers(); const headers = new Headers();
if (token) { if (token) {
@@ -93,16 +79,16 @@ async function downloadFile(endpoint: string, filename: string): Promise<void> {
if (tenantId) { if (tenantId) {
headers.set('X-Tenant-ID', tenantId); headers.set('X-Tenant-ID', tenantId);
} }
headers.set('X-App', 'internal');
const response = await fetch(`${API_BASE}${endpoint}`, { const response = await fetch(`${API_BASE}${endpoint}`, {
method: 'GET', method: 'GET',
credentials: 'include',
headers headers
}); });
if (response.status === 401) { if (response.status === 401) {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
localStorage.removeItem('internal_auth_token');
localStorage.removeItem('internal_auth_user');
window.location.href = '/login'; window.location.href = '/login';
} }
throw new Error('Unauthorized'); throw new Error('Unauthorized');

View File

@@ -0,0 +1,7 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = ({ locals }) => {
return {
user: locals.user ?? null
};
};

View File

@@ -5,21 +5,40 @@
import { toast } from '$lib/stores/toast.js'; import { toast } from '$lib/stores/toast.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { auth } from '$lib/stores/auth.js'; import { auth } from '$lib/stores/auth.js';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { browser } from '$app/environment';
import '../app.css'; import '../app.css';
export let data;
let sidebarOpen = false; let sidebarOpen = false;
let mounted = false;
onMount(() => { onMount(() => {
auth.init(); if (data.user && !$auth.isAuthenticated) {
auth.setUser(data.user);
}
mounted = true;
}); });
// Guard reactivo global: redirige a /login si no está autenticado
$: if (browser && mounted && !$auth.isAuthenticated && $page.url.pathname !== '/login') {
goto('/login');
}
function toggleSidebar() { function toggleSidebar() {
sidebarOpen = !sidebarOpen; sidebarOpen = !sidebarOpen;
} }
</script> </script>
<div class="min-h-screen bg-gray-50"> <div class="min-h-screen bg-gray-50">
{#if $auth.isAuthenticated} {#if !mounted}
<!-- Esperando inicialización de sesión -->
<div class="flex items-center justify-center min-h-screen bg-gray-50">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
{:else if $auth.isAuthenticated}
<!-- Internal Layout with Sidebar --> <!-- Internal Layout with Sidebar -->
<div class="flex h-screen overflow-hidden"> <div class="flex h-screen overflow-hidden">
<!-- Sidebar --> <!-- Sidebar -->

View File

@@ -2,7 +2,9 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { auth } from '$lib/stores/auth.js'; import { auth } from '$lib/stores/auth.js';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import Icon from '$lib/components/Icon.svelte'; import { dashboardConfig, ALL_MODULES, type DashboardModule } from '$lib/stores/dashboardConfig.js';
let showSettings = false;
onMount(() => { onMount(() => {
if (!$auth.isAuthenticated) { if (!$auth.isAuthenticated) {
@@ -10,36 +12,17 @@
} }
}); });
const cards = [ const role = $auth.user?.role ?? '';
{
title: 'Clientes', /** Only modules the current role can access */
description: 'Gestión de organizaciones y tenants', $: accessibleModules = ALL_MODULES.filter(m => m.roles.includes(role) || role === 'ADMIN');
icon: 'users',
href: '/tenants', /** Modules that are visible (enabled by user + accessible by role) */
color: 'bg-blue-600' $: visibleModules = accessibleModules.filter(m => $dashboardConfig[m.id] !== false);
},
{ function toggleSettings() {
title: 'Usuarios', showSettings = !showSettings;
description: 'Administración de usuarios y roles',
icon: 'user-plus',
href: '/users',
color: 'bg-green-600'
},
{
title: 'Sistemas',
description: 'Catálogo de sistemas soportados',
icon: 'server',
href: '/systems',
color: 'bg-gray-700'
},
{
title: 'Categorías',
description: 'Clasificación de tickets',
icon: 'tag',
href: '/categories',
color: 'bg-orange-600'
} }
];
</script> </script>
<svelte:head> <svelte:head>
@@ -47,6 +30,7 @@
</svelte:head> </svelte:head>
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8"> <div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
<!-- Header -->
<div class="md:flex md:items-center md:justify-between"> <div class="md:flex md:items-center md:justify-between">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<h2 class="text-2xl font-bold leading-7 text-gray-900 sm:text-3xl sm:truncate"> <h2 class="text-2xl font-bold leading-7 text-gray-900 sm:text-3xl sm:truncate">
@@ -56,31 +40,96 @@
Bienvenido al sistema de gestión interna. Bienvenido al sistema de gestión interna.
</p> </p>
</div> </div>
<div class="mt-4 flex md:mt-0 md:ml-4 gap-2">
<button
on:click={toggleSettings}
class="inline-flex items-center gap-1.5 px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Configurar
</button>
</div>
</div> </div>
<div class="mt-8 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4"> <!-- Settings Panel -->
{#each cards as card} {#if showSettings}
<a href={card.href} class="bg-white overflow-hidden shadow rounded-lg hover:shadow-md transition-shadow duration-200 cursor-pointer group"> <div class="mt-6 bg-white border border-gray-200 rounded-lg shadow-sm p-6">
<div class="p-5"> <div class="flex items-center justify-between mb-4">
<dl> <h3 class="text-base font-semibold text-gray-900">Módulos visibles en el dashboard</h3>
<dt class="text-sm font-medium text-gray-500 truncate"> <div class="flex gap-2">
{card.title} <button
</dt> on:click={() => dashboardConfig.showAll()}
<dd> class="text-xs text-blue-600 hover:text-blue-800 underline"
<div class="text-xs text-gray-900 font-light mt-1"> >
{card.description} Mostrar todos
</button>
</div> </div>
</dd>
</dl>
</div> </div>
<div class="bg-gray-50 px-5 py-3"> <div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
<div class="text-sm"> {#each accessibleModules as mod}
<span class="font-medium text-blue-700 hover:text-blue-900"> <label class="flex items-center gap-2 p-3 border rounded-lg cursor-pointer hover:bg-gray-50 {$dashboardConfig[mod.id] !== false ? 'border-blue-300 bg-blue-50' : 'border-gray-200'}">
Ver detalles <input
type="checkbox"
checked={$dashboardConfig[mod.id] !== false}
on:change={() => dashboardConfig.toggle(mod.id)}
class="rounded text-blue-600 focus:ring-blue-500"
/>
<div class="min-w-0">
<div class="flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full {mod.color} flex-shrink-0"></span>
<span class="text-sm font-medium text-gray-800 truncate">{mod.title}</span>
</div>
</div>
</label>
{/each}
</div>
<p class="mt-3 text-xs text-gray-400">Las preferencias se guardan automáticamente en este navegador.</p>
</div>
{/if}
<!-- Module Cards -->
{#if visibleModules.length === 0}
<div class="mt-10 text-center py-16 bg-white rounded-lg border-2 border-dashed border-gray-200">
<svg class="mx-auto h-10 w-10 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
<p class="mt-3 text-sm text-gray-500">No hay módulos visibles.</p>
<button on:click={() => dashboardConfig.showAll()} class="mt-3 text-sm text-blue-600 hover:underline">
Restaurar todos los módulos
</button>
</div>
{:else}
<div class="mt-8 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{#each visibleModules as mod}
<a
href={mod.href}
class="bg-white overflow-hidden shadow rounded-lg hover:shadow-md transition-all duration-200 cursor-pointer group flex flex-col"
>
<div class="p-5 flex-1">
<div class="flex items-center gap-3 mb-2">
<div class="w-9 h-9 rounded-lg {mod.color} flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={mod.icon} />
</svg>
</div>
<span class="text-sm font-semibold text-gray-800 group-hover:text-blue-700 transition-colors">
{mod.title}
</span> </span>
</div> </div>
<p class="text-xs text-gray-500 leading-relaxed">{mod.description}</p>
</div>
<div class="bg-gray-50 px-5 py-2.5 border-t border-gray-100">
<span class="text-xs font-medium text-blue-600 group-hover:text-blue-800 transition-colors">
Abrir módulo →
</span>
</div> </div>
</a> </a>
{/each} {/each}
</div> </div>
{/if}
</div> </div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -4,12 +4,23 @@ import { defineConfig } from 'vite';
export default defineConfig({ export default defineConfig({
plugins: [sveltekit()], plugins: [sveltekit()],
server: { server: {
port: 3000, // Puerto: dentro del contenedor siempre 3000; Docker mapea 3001:3000 al host
port: parseInt(process.env.PORT || '3001'),
host: '0.0.0.0', host: '0.0.0.0',
watch: { watch: {
usePolling: true, usePolling: true,
interval: 500 interval: 500
}, },
// HMR: el browser llega al contenedor a través del puerto 3001 del host
hmr: {
host: 'localhost',
clientPort: parseInt(process.env.HMR_CLIENT_PORT || '3001')
},
// Permitir que Vite sirva archivos del filesystem del contenedor
fs: {
allow: ['/app', '.'],
strict: false
},
proxy: { proxy: {
'/api': { '/api': {
target: process.env.PUBLIC_API_URL || 'http://localhost:8000', target: process.env.PUBLIC_API_URL || 'http://localhost:8000',
@@ -19,7 +30,7 @@ export default defineConfig({
} }
}, },
preview: { preview: {
port: 3000, port: parseInt(process.env.PORT || '3001'),
host: '0.0.0.0' host: '0.0.0.0'
}, },
build: { build: {

View File

@@ -216,15 +216,18 @@ async def main():
if user_data["email"] in existing_emails: if user_data["email"] in existing_emails:
print(f" ⏭ Ya existe: {user_data['email']}") print(f" ⏭ Ya existe: {user_data['email']}")
continue continue
pwd = user_data.pop("password") # Usar copia para no mutar el dict original (permite re-ejecutar el script)
ud = user_data.copy()
pwd = ud.pop("password")
hashed_pwd = security.hash_password(pwd) hashed_pwd = security.hash_password(pwd)
user = User( user = User(
tenant_id=tenant_id, tenant_id=tenant_id,
password_hash=hashed_pwd, password_hash=hashed_pwd,
**user_data, email_verified=True, # Marcar como verificado para permitir login
**ud,
) )
session.add(user) session.add(user)
print(f"{user_data['email']} [{user_data['role'].value}] pwd={pwd}") print(f"{ud['email']} [{ud['role'].value}] pwd={pwd}")
created_users += 1 created_users += 1
await session.commit() await session.commit()

View File

@@ -7,11 +7,42 @@ Async database session management para Celery workers
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy import DateTime, func from sqlalchemy import DateTime, func
from sqlalchemy import types as sa_types
from sqlalchemy.types import TypeDecorator, CHAR
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import AsyncGenerator from typing import AsyncGenerator
import uuid import uuid
from datetime import datetime from datetime import datetime
class GUID(TypeDecorator):
"""UUID portable: UUID nativo en Postgres, CHAR(36) en otros dialectos (SQLite para tests)."""
impl = CHAR
cache_ok = True
def load_dialect_impl(self, dialect):
if dialect.name == "postgresql":
return dialect.type_descriptor(PG_UUID(as_uuid=True))
return dialect.type_descriptor(CHAR(36))
def process_bind_param(self, value, dialect):
if value is None:
return None
if dialect.name == "postgresql":
return value
if isinstance(value, uuid.UUID):
return str(value)
return str(uuid.UUID(str(value)))
def process_result_value(self, value, dialect):
if value is None:
return None
if not isinstance(value, uuid.UUID):
return uuid.UUID(str(value))
return value
from app.core.config import get_settings from app.core.config import get_settings
settings = get_settings() settings = get_settings()