Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f10b15d91b | |||
| 49dfb3ef24 | |||
| b187aa1b46 | |||
| cd3d7e816f | |||
| 63925fe305 | |||
| c146a6c3c3 | |||
| ba779bde55 | |||
| ba94152074 |
178
README.legacy.md
Normal file
178
README.legacy.md
Normal 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
835
README.md
@@ -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.
@@ -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
@@ -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),
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
20
backend/app/core/limiter.py
Normal file
20
backend/app/core/limiter.py
Normal 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,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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
|
||||||
|
|||||||
49
backend/scripts/reset_passwords.py
Normal file
49
backend/scripts/reset_passwords.py
Normal 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())
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
11
frontend-client/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { User } from '$lib/stores/auth';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
interface Locals {
|
||||||
|
user: User | null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -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" />
|
||||||
|
|
||||||
|
|||||||
19
frontend-client/src/hooks.server.ts
Normal file
19
frontend-client/src/hooks.server.ts
Normal 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);
|
||||||
|
};
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
126
frontend-client/src/lib/utils/api.ts
Normal file
126
frontend-client/src/lib/utils/api.ts
Normal 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)
|
||||||
|
};
|
||||||
7
frontend-client/src/routes/+layout.server.ts
Normal file
7
frontend-client/src/routes/+layout.server.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: LayoutServerLoad = ({ locals }) => {
|
||||||
|
return {
|
||||||
|
user: locals.user ?? null
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
BIN
frontend-client/static/favicon.png
Normal file
BIN
frontend-client/static/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -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',
|
||||||
|
|||||||
@@ -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
11
frontend-internal/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { InternalUser } from '$lib/stores/auth';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
interface Locals {
|
||||||
|
user: InternalUser | null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -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">
|
||||||
|
|||||||
19
frontend-internal/src/hooks.server.ts
Normal file
19
frontend-internal/src/hooks.server.ts
Normal 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);
|
||||||
|
};
|
||||||
@@ -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'
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,21 +128,18 @@ 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");
|
||||||
}
|
}
|
||||||
|
|
||||||
update (state => ({ ...state, isLoading: true }));
|
update(state => ({ ...state, isLoading: true }));
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
161
frontend-internal/src/lib/stores/dashboardConfig.ts
Normal file
161
frontend-internal/src/lib/stores/dashboardConfig.ts
Normal 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();
|
||||||
@@ -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');
|
||||||
|
|||||||
7
frontend-internal/src/routes/+layout.server.ts
Normal file
7
frontend-internal/src/routes/+layout.server.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: LayoutServerLoad = ({ locals }) => {
|
||||||
|
return {
|
||||||
|
user: locals.user ?? null
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
BIN
frontend-internal/static/favicon.png
Normal file
BIN
frontend-internal/static/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -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: {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user