Mejora de seguridad
This commit is contained in:
Binary file not shown.
@@ -1,3 +1,4 @@
|
||||
from typing import Optional
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from starlette.requests import Request
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
@@ -15,7 +16,48 @@ from app.models.tenant import Tenant
|
||||
settings = get_settings()
|
||||
|
||||
# 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(
|
||||
request: Request,
|
||||
|
||||
@@ -4,7 +4,7 @@ Authentication Endpoints - ServiceManagerWeb
|
||||
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
@@ -21,6 +21,14 @@ from app.services.audit_service import AuditService
|
||||
from app.services.token_service import TokenService
|
||||
from app.api.deps import oauth2_scheme, get_current_user
|
||||
from app.core.cache import cache, cache_key
|
||||
|
||||
# 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 (
|
||||
LoginRequest, LoginResponse, RefreshTokenRequest, TokenResponse,
|
||||
TwoFactorStatusResponse, TwoFactorSetupResponse,
|
||||
@@ -41,6 +49,7 @@ settings = get_settings()
|
||||
async def login(
|
||||
login_data: LoginRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
@@ -248,6 +257,19 @@ async def login(
|
||||
if 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(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
@@ -330,6 +352,7 @@ async def refresh_token(
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(
|
||||
response: Response,
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
@@ -388,6 +411,9 @@ async def logout(
|
||||
|
||||
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"}
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ Accesible por ADMIN y SUPPORT_MANAGER.
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException, status
|
||||
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 datetime import datetime, timedelta, timezone
|
||||
import uuid
|
||||
@@ -505,20 +505,23 @@ async def get_report_trends(
|
||||
tenant_filter = Ticket.tenant_id == current_user.tenant_id
|
||||
|
||||
# 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(
|
||||
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"),
|
||||
)
|
||||
.where(and_(tenant_filter, Ticket.created_at >= period_start))
|
||||
.group_by(func.date_trunc("day", Ticket.created_at))
|
||||
.order_by(func.date_trunc("day", Ticket.created_at))
|
||||
.group_by(func.date_trunc(_day_lit, Ticket.created_at))
|
||||
.order_by(func.date_trunc(_day_lit, Ticket.created_at))
|
||||
)).all()
|
||||
|
||||
# Tickets resueltos por día (según resolved_at)
|
||||
resolved_rows = (await db.execute(
|
||||
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"),
|
||||
)
|
||||
.where(and_(
|
||||
@@ -526,8 +529,8 @@ async def get_report_trends(
|
||||
Ticket.resolved_at >= period_start,
|
||||
Ticket.resolved_at.isnot(None),
|
||||
))
|
||||
.group_by(func.date_trunc("day", Ticket.resolved_at))
|
||||
.order_by(func.date_trunc("day", Ticket.resolved_at))
|
||||
.group_by(func.date_trunc(_day_lit, Ticket.resolved_at))
|
||||
.order_by(func.date_trunc(_day_lit, Ticket.resolved_at))
|
||||
)).all()
|
||||
|
||||
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)
|
||||
if not category:
|
||||
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:
|
||||
system = await db.get(System, system_uuid)
|
||||
if not system:
|
||||
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)
|
||||
assigned_to_user = category.auto_assign_to if category and category.auto_assign_to else None
|
||||
|
||||
@@ -87,8 +87,14 @@ if settings.is_production():
|
||||
"X-Correlation-ID",
|
||||
]
|
||||
else:
|
||||
cors_allow_methods = ["*"]
|
||||
cors_allow_headers = ["*"]
|
||||
cors_allow_methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
|
||||
cors_allow_headers = [
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"X-Tenant-ID",
|
||||
"X-Tenant-Slug",
|
||||
"X-Correlation-ID",
|
||||
]
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
|
||||
@@ -42,6 +42,8 @@ class TenantMiddleware(BaseHTTPMiddleware):
|
||||
"/v1/auth/refresh",
|
||||
"/api/v1/auth/logout",
|
||||
"/v1/auth/logout",
|
||||
"/api/v1/auth/me",
|
||||
"/v1/auth/me",
|
||||
"/api/v1/auth/forgot-password",
|
||||
"/v1/auth/forgot-password",
|
||||
"/api/v1/auth/reset-password",
|
||||
|
||||
@@ -29,15 +29,17 @@ const initialState: AppState = {
|
||||
// API helper function
|
||||
async function apiCall(endpoint: string, options: RequestInit = {}) {
|
||||
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}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${authState.token}`,
|
||||
...(authState.user?.tenant_id ? { 'X-Tenant-ID': authState.user.tenant_id } : {}),
|
||||
...options.headers
|
||||
}
|
||||
credentials: 'include',
|
||||
headers
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -50,26 +50,26 @@ function createAuthStore() {
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
// Initialize auth from localStorage
|
||||
init: () => {
|
||||
// Rehidrata sesión desde cookie HttpOnly (no toca localStorage)
|
||||
init: async () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const user = localStorage.getItem('auth_user');
|
||||
|
||||
if (token && user) {
|
||||
try {
|
||||
const parsedUser = JSON.parse(user);
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/me', {
|
||||
credentials: 'include',
|
||||
headers: { 'X-App': 'client' }
|
||||
});
|
||||
if (response.ok) {
|
||||
const user = await response.json();
|
||||
set({
|
||||
user: parsedUser,
|
||||
token,
|
||||
user,
|
||||
token: null,
|
||||
isAuthenticated: true,
|
||||
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 {
|
||||
const response = await fetch('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
@@ -94,12 +95,6 @@ function createAuthStore() {
|
||||
|
||||
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({
|
||||
user: data.user,
|
||||
token: data.access_token,
|
||||
@@ -113,22 +108,24 @@ function createAuthStore() {
|
||||
},
|
||||
|
||||
// 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') {
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('auth_user');
|
||||
// Immediate redirect after cleanup
|
||||
window.location.href = '/login';
|
||||
}
|
||||
set(initialState);
|
||||
},
|
||||
|
||||
// Update user data
|
||||
updateUser: (user: User) => {
|
||||
update(state => ({ ...state, user }));
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('auth_user', JSON.stringify(user));
|
||||
}
|
||||
},
|
||||
|
||||
// Set loading state
|
||||
|
||||
@@ -80,18 +80,22 @@ const initialState: TicketsState = {
|
||||
async function apiCall(endpoint: string, options: RequestInit = {}) {
|
||||
const authState = get(auth);
|
||||
|
||||
if (!authState.token || !authState.user) {
|
||||
if (!authState.user) {
|
||||
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}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${authState.token}`,
|
||||
'X-Tenant-ID': authState.user.tenant_id,
|
||||
...options.headers
|
||||
}
|
||||
credentials: 'include',
|
||||
headers
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -259,16 +263,18 @@ function createTicketsStore() {
|
||||
|
||||
const authState = get(auth);
|
||||
|
||||
if (!authState.token || !authState.user) {
|
||||
if (!authState.user) {
|
||||
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`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authState.token}`,
|
||||
'X-Tenant-ID': authState.user.tenant_id
|
||||
},
|
||||
credentials: 'include',
|
||||
headers: uploadHeaders,
|
||||
body: formData
|
||||
});
|
||||
|
||||
@@ -338,16 +344,18 @@ function createTicketsStore() {
|
||||
downloadAttachment: async (ticketId: string, attachmentId: string, filename: string) => {
|
||||
const authState = get(auth);
|
||||
|
||||
if (!authState.token || !authState.user) {
|
||||
if (!authState.user) {
|
||||
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`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authState.token}`,
|
||||
'X-Tenant-ID': authState.user.tenant_id
|
||||
}
|
||||
credentials: 'include',
|
||||
headers: dlHeaders
|
||||
});
|
||||
|
||||
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)
|
||||
};
|
||||
@@ -11,8 +11,8 @@
|
||||
|
||||
let mounted = false;
|
||||
|
||||
onMount(() => {
|
||||
auth.init();
|
||||
onMount(async () => {
|
||||
await auth.init();
|
||||
mounted = true;
|
||||
});
|
||||
|
||||
@@ -27,6 +27,12 @@
|
||||
</script>
|
||||
|
||||
<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}
|
||||
<Header />
|
||||
{/if}
|
||||
@@ -39,6 +45,7 @@
|
||||
<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>
|
||||
</footer>
|
||||
{/if}
|
||||
|
||||
<!-- Toast notifications -->
|
||||
{#each $toast.toasts as toastMessage (toastMessage.id)}
|
||||
|
||||
@@ -38,11 +38,14 @@
|
||||
async function loadProfile() {
|
||||
isLoading = true;
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'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/', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${$auth.token}`,
|
||||
'X-Tenant-ID': $auth.user?.tenant_id ?? ''
|
||||
}
|
||||
credentials: 'include',
|
||||
headers
|
||||
});
|
||||
if (!response.ok) throw new Error((await response.json()).detail);
|
||||
profile = await response.json();
|
||||
@@ -62,13 +65,16 @@
|
||||
async function saveProfile() {
|
||||
isSaving = true;
|
||||
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/', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${$auth.token}`,
|
||||
'X-Tenant-ID': $auth.user?.tenant_id ?? ''
|
||||
},
|
||||
credentials: 'include',
|
||||
headers,
|
||||
body: JSON.stringify(form)
|
||||
});
|
||||
if (!response.ok) throw new Error((await response.json()).detail);
|
||||
|
||||
@@ -34,7 +34,11 @@
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/2fa/setup', {
|
||||
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);
|
||||
const data = await response.json();
|
||||
@@ -57,7 +61,12 @@
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/2fa/enable', {
|
||||
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 })
|
||||
});
|
||||
if (!response.ok) throw new Error((await response.json()).detail);
|
||||
@@ -84,7 +93,12 @@
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/2fa/disable', {
|
||||
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 })
|
||||
});
|
||||
if (!response.ok) throw new Error((await response.json()).detail);
|
||||
@@ -157,16 +171,20 @@
|
||||
|
||||
async function loadBusinessProfile() {
|
||||
try {
|
||||
if (!$auth.token || !$auth.user) {
|
||||
if (!$auth.user) {
|
||||
console.warn('Usuario no autenticado');
|
||||
return;
|
||||
}
|
||||
|
||||
const _lpHeaders: Record<string, string> = {
|
||||
'X-App': 'client',
|
||||
'X-Tenant-ID': $auth.user.tenant_id
|
||||
};
|
||||
if ($auth.token) _lpHeaders['Authorization'] = `Bearer ${$auth.token}`;
|
||||
|
||||
const response = await fetch('/api/v1/client-profile/', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${$auth.token}`,
|
||||
'X-Tenant-ID': $auth.user.tenant_id
|
||||
}
|
||||
credentials: 'include',
|
||||
headers: _lpHeaders
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
@@ -267,9 +285,11 @@
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/profile', {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${$auth.token}`
|
||||
'X-App': 'client',
|
||||
...($auth.token ? { Authorization: `Bearer ${$auth.token}` } : {})
|
||||
},
|
||||
body: JSON.stringify({
|
||||
first_name: firstName.trim(),
|
||||
@@ -300,9 +320,11 @@
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/change-password', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${$auth.token}`
|
||||
'X-App': 'client',
|
||||
...($auth.token ? { Authorization: `Bearer ${$auth.token}` } : {})
|
||||
},
|
||||
body: JSON.stringify({
|
||||
current_password: currentPassword,
|
||||
@@ -349,13 +371,16 @@
|
||||
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/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${$auth.token}`,
|
||||
'X-Tenant-ID': $auth.user.tenant_id
|
||||
},
|
||||
credentials: 'include',
|
||||
headers: _bpHeaders,
|
||||
body: JSON.stringify(profileData)
|
||||
});
|
||||
|
||||
|
||||
@@ -60,32 +60,34 @@ const initialState: AuthState = {
|
||||
function createAuthStore() {
|
||||
const { subscribe, set, update } = writable<AuthState>(initialState);
|
||||
|
||||
// Track current state for uso interno (evita dependencias circulares)
|
||||
let _state = initialState;
|
||||
subscribe(s => { _state = s; });
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
// Initialize auth from localStorage
|
||||
init: () => {
|
||||
// Rehidrata sesión desde cookie HttpOnly (no toca localStorage)
|
||||
init: async () => {
|
||||
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 {
|
||||
const parsedUser = JSON.parse(user);
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/me', {
|
||||
credentials: 'include',
|
||||
headers: { 'X-App': 'internal' }
|
||||
});
|
||||
if (response.ok) {
|
||||
const user = await response.json();
|
||||
set({
|
||||
user: parsedUser,
|
||||
token,
|
||||
refreshToken: refreshToken || null,
|
||||
user,
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
isAuthenticated: true,
|
||||
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 {
|
||||
const response = await fetch('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
@@ -110,15 +113,6 @@ function createAuthStore() {
|
||||
|
||||
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({
|
||||
user: data.user,
|
||||
token: data.access_token,
|
||||
@@ -134,21 +128,18 @@ function createAuthStore() {
|
||||
|
||||
// Refresh Session
|
||||
refreshSession: async (): Promise<void> => {
|
||||
// Need to get current state to access refresh token, logic simplified
|
||||
let currentRefreshToken: string | null = null;
|
||||
if (typeof window !== 'undefined') {
|
||||
currentRefreshToken = localStorage.getItem('internal_auth_refresh_token');
|
||||
}
|
||||
const currentRefreshToken = _state.refreshToken;
|
||||
|
||||
if (!currentRefreshToken) {
|
||||
throw new Error("No refresh token available");
|
||||
}
|
||||
|
||||
update (state => ({ ...state, isLoading: true }));
|
||||
update(state => ({ ...state, isLoading: true }));
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/refresh', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
@@ -166,11 +157,6 @@ function createAuthStore() {
|
||||
|
||||
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 => ({
|
||||
...state,
|
||||
token: data.access_token,
|
||||
@@ -184,25 +170,24 @@ function createAuthStore() {
|
||||
},
|
||||
|
||||
// Logout
|
||||
logout: () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('internal_auth_token');
|
||||
localStorage.removeItem('internal_auth_refresh_token');
|
||||
localStorage.removeItem('internal_auth_user');
|
||||
}
|
||||
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': 'internal' }
|
||||
});
|
||||
} catch { /* ignorar errores de red */ }
|
||||
set(initialState);
|
||||
// Optional: Redirect to login
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
window.location.href = '/login';
|
||||
}
|
||||
},
|
||||
|
||||
// Update user data
|
||||
updateUser: (user: InternalUser) => {
|
||||
update(state => ({ ...state, user }));
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('internal_auth_user', JSON.stringify(user));
|
||||
}
|
||||
},
|
||||
|
||||
// Set loading state
|
||||
|
||||
@@ -24,16 +24,8 @@ async function request<T>(endpoint: string, options: RequestOptions = {}): Promi
|
||||
}
|
||||
|
||||
const authState = get(auth);
|
||||
const token = authState.token || (typeof window !== 'undefined' ? localStorage.getItem('internal_auth_token') : 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 token = authState.token;
|
||||
const tenantId = authState.user?.tenant_id ?? null;
|
||||
|
||||
const headers = new Headers(init.headers);
|
||||
if (token) {
|
||||
@@ -45,17 +37,18 @@ async function request<T>(endpoint: string, options: RequestOptions = {}): Promi
|
||||
if (!headers.has('Content-Type')) {
|
||||
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, {
|
||||
...init,
|
||||
credentials: 'include',
|
||||
headers
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
// Token expired or invalid
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('internal_auth_token');
|
||||
localStorage.removeItem('internal_auth_user');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
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> {
|
||||
const authState = get(auth);
|
||||
const token = authState.token || (typeof window !== 'undefined' ? localStorage.getItem('internal_auth_token') : 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 token = authState.token;
|
||||
const tenantId = authState.user?.tenant_id ?? null;
|
||||
|
||||
const headers = new Headers();
|
||||
if (token) {
|
||||
@@ -93,16 +79,16 @@ async function downloadFile(endpoint: string, filename: string): Promise<void> {
|
||||
if (tenantId) {
|
||||
headers.set('X-Tenant-ID', tenantId);
|
||||
}
|
||||
headers.set('X-App', 'internal');
|
||||
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('internal_auth_token');
|
||||
localStorage.removeItem('internal_auth_user');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
throw new Error('Unauthorized');
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
let sidebarOpen = false;
|
||||
let mounted = false;
|
||||
|
||||
onMount(() => {
|
||||
auth.init();
|
||||
onMount(async () => {
|
||||
await auth.init();
|
||||
mounted = true;
|
||||
});
|
||||
|
||||
@@ -29,7 +29,12 @@
|
||||
</script>
|
||||
|
||||
<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 -->
|
||||
<div class="flex h-screen overflow-hidden">
|
||||
<!-- Sidebar -->
|
||||
|
||||
@@ -1,492 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { auth } from '$lib/stores/auth.js';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
onMount(() => {
|
||||
if (!$auth.isAuthenticated) goto('/login');
|
||||
});
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
interface EndpointDef {
|
||||
id: string;
|
||||
label: string;
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||||
path: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface EndpointResult {
|
||||
status: number | null;
|
||||
ok: boolean | null;
|
||||
ms: number | null;
|
||||
error: string | null;
|
||||
preview: string | null;
|
||||
tested: boolean;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
interface PageDef {
|
||||
label: string;
|
||||
href: string;
|
||||
description: string;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
// ─── Backend Endpoints ───────────────────────────────────────────────────────
|
||||
const GROUPS: { name: string; color: string; endpoints: EndpointDef[] }[] = [
|
||||
{
|
||||
name: 'Auth',
|
||||
color: 'bg-purple-100 text-purple-800',
|
||||
endpoints: [
|
||||
{ id: 'auth-me', label: 'Perfil actual', method: 'GET', path: '/auth/me', description: 'Información del usuario autenticado' },
|
||||
{ id: 'auth-refresh', label: 'Refrescar token', method: 'POST', path: '/auth/refresh', description: 'Renovar access token (POST)' },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Health',
|
||||
color: 'bg-green-100 text-green-800',
|
||||
endpoints: [
|
||||
{ id: 'health', label: 'Health Check', method: 'GET', path: '/health', description: 'Estado general del sistema' },
|
||||
{ id: 'health-details', label: 'Health Detallado', method: 'GET', path: '/health/detailed', description: 'Estado con detalle de dependencias' },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Tenants',
|
||||
color: 'bg-blue-100 text-blue-800',
|
||||
endpoints: [
|
||||
{ id: 'tenants-list', label: 'Listar Tenants', method: 'GET', path: '/tenants/', description: 'Todos los tenants registrados' },
|
||||
{ id: 'tenant-stats', label: 'Stats Tenant', method: 'GET', path: '/tenants/stats', description: 'Estadísticas globales de tenants' },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Users',
|
||||
color: 'bg-indigo-100 text-indigo-800',
|
||||
endpoints: [
|
||||
{ id: 'users-list', label: 'Listar Usuarios', method: 'GET', path: '/users/', description: 'Todos los usuarios del sistema' },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Tickets',
|
||||
color: 'bg-orange-100 text-orange-800',
|
||||
endpoints: [
|
||||
{ id: 'tickets-list', label: 'Listar Tickets', method: 'GET', path: '/tickets/', description: 'Tickets con paginación' },
|
||||
{ id: 'tickets-stats', label: 'Stats Tickets', method: 'GET', path: '/tickets/stats', description: 'Estadísticas de tickets' },
|
||||
{ id: 'tickets-comments',label: 'Comentarios recientes', method: 'GET', path: '/tickets/comments/recent',description: 'Últimos comentarios' },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Categories',
|
||||
color: 'bg-amber-100 text-amber-800',
|
||||
endpoints: [
|
||||
{ id: 'cats-list', label: 'Listar Categorías', method: 'GET', path: '/categories/', description: 'Categorías de tickets' },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Systems',
|
||||
color: 'bg-gray-100 text-gray-800',
|
||||
endpoints: [
|
||||
{ id: 'sys-list', label: 'Listar Sistemas', method: 'GET', path: '/systems/', description: 'Sistemas soportados' },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'SLA',
|
||||
color: 'bg-teal-100 text-teal-800',
|
||||
endpoints: [
|
||||
{ id: 'sla-dashboard', label: 'Dashboard SLA', method: 'GET', path: '/sla/dashboard', description: 'Panel SLA principal' },
|
||||
{ id: 'sla-compliance', label: 'SLA Compliance', method: 'GET', path: '/sla/compliance', description: 'Métricas de cumplimiento SLA' },
|
||||
{ id: 'sla-at-risk', label: 'Tickets en Riesgo', method: 'GET', path: '/sla/at-risk', description: 'Tickets próximos a violar SLA' },
|
||||
{ id: 'sla-violations', label: 'Violaciones SLA', method: 'GET', path: '/sla/violations', description: 'Tickets que violaron SLA' },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Reports',
|
||||
color: 'bg-pink-100 text-pink-800',
|
||||
endpoints: [
|
||||
{ id: 'rep-summary', label: 'Resumen General', method: 'GET', path: '/reports/summary', description: 'Resumen ejecutivo de reportes' },
|
||||
{ id: 'rep-agents', label: 'Por Agente', method: 'GET', path: '/reports/agents', description: 'Rendimiento por agente' },
|
||||
{ id: 'rep-categories', label: 'Por Categoría', method: 'GET', path: '/reports/categories', description: 'Distribución por categoría' },
|
||||
{ id: 'rep-trends', label: 'Tendencias', method: 'GET', path: '/reports/trends', description: 'Tendencias temporales' },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Audit',
|
||||
color: 'bg-red-100 text-red-800',
|
||||
endpoints: [
|
||||
{ id: 'audit-logs', label: 'Logs de Auditoría', method: 'GET', path: '/audit/logs', description: 'Bitácora de acciones' },
|
||||
{ id: 'audit-stats', label: 'Stats Auditoría', method: 'GET', path: '/audit/stats', description: 'Estadísticas de auditoría' },
|
||||
{ id: 'audit-security', label: 'Análisis Seguridad', method: 'GET', path: '/audit/security/analysis',description: 'Análisis de amenazas de seguridad' },
|
||||
{ id: 'audit-users', label: 'Actividad Usuarios', method: 'GET', path: '/audit/users', description: 'Actividad por usuario' },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Client Profile',
|
||||
color: 'bg-cyan-100 text-cyan-800',
|
||||
endpoints: [
|
||||
{ id: 'client-profile', label: 'Perfil Cliente', method: 'GET', path: '/client/profile', description: 'Perfil organización cliente' },
|
||||
{ id: 'client-tickets', label: 'Tickets Cliente', method: 'GET', path: '/client/tickets', description: 'Tickets del cliente' },
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Frontend Pages ──────────────────────────────────────────────────────────
|
||||
const FRONTEND_PAGES: PageDef[] = [
|
||||
{ label: 'Dashboard', href: '/', description: 'Panel principal de administración', roles: ['todos'] },
|
||||
{ label: 'Tickets', href: '/tickets', description: 'Gestión y listado de tickets', roles: ['ADMIN', 'SUPPORT_MANAGER', 'AGENT'] },
|
||||
{ label: 'Clientes (Tenants)', href: '/tenants', description: 'Administración de organizaciones cliente', roles: ['ADMIN', 'SUPPORT_MANAGER'] },
|
||||
{ label: 'Usuarios', href: '/users', description: 'Gestión de usuarios internos', roles: ['ADMIN', 'SUPPORT_MANAGER'] },
|
||||
{ label: 'Categorías', href: '/categories', description: 'Categorías y SLA por área', roles: ['ADMIN', 'SUPPORT_MANAGER'] },
|
||||
{ label: 'Sistemas', href: '/systems', description: 'Catálogo de sistemas soportados', roles: ['ADMIN', 'SUPPORT_MANAGER'] },
|
||||
{ label: 'SLA Dashboard', href: '/sla', description: 'Monitoreo de SLAs y cumplimiento', roles: ['ADMIN', 'SUPPORT_MANAGER'] },
|
||||
{ label: 'SLA En Riesgo', href: '/sla/at-risk', description: 'Tickets próximos a violar SLA', roles: ['ADMIN', 'SUPPORT_MANAGER'] },
|
||||
{ label: 'SLA Violaciones', href: '/sla/violations', description: 'Historial de violaciones SLA', roles: ['ADMIN', 'SUPPORT_MANAGER'] },
|
||||
{ label: 'Reportes', href: '/reports', description: 'Reportes estadísticos e informes', roles: ['ADMIN', 'SUPPORT_MANAGER'] },
|
||||
{ label: 'Auditoría', href: '/audit', description: 'Bitácora de acciones del sistema', roles: ['ADMIN', 'AUDITOR'] },
|
||||
{ label: 'Seguridad', href: '/audit/security', description: 'Análisis de amenazas y eventos de seguridad',roles: ['ADMIN'] },
|
||||
{ label: 'Perfil', href: '/profile', description: 'Perfil y configuración de seguridad', roles: ['todos'] },
|
||||
{ label: 'Rate Limits', href: '/rate-limits', description: 'Estado de rate limiting por IP', roles: ['ADMIN'] },
|
||||
{ label: 'Reporte Endpoints', href: '/test-report', description: 'Esta misma página', roles: ['ADMIN'] },
|
||||
];
|
||||
|
||||
// ─── State ───────────────────────────────────────────────────────────────────
|
||||
let results: Record<string, EndpointResult> = {};
|
||||
let isTesting = false;
|
||||
let testingId: string | null = null;
|
||||
let totalOk = 0;
|
||||
let totalFail = 0;
|
||||
let activeTab: 'endpoints' | 'pages' = 'endpoints';
|
||||
|
||||
// Init results
|
||||
for (const group of GROUPS) {
|
||||
for (const ep of group.endpoints) {
|
||||
results[ep.id] = { status: null, ok: null, ms: null, error: null, preview: null, tested: false, loading: false };
|
||||
}
|
||||
}
|
||||
|
||||
function getToken(): string | null {
|
||||
return (typeof window !== 'undefined')
|
||||
? localStorage.getItem('internal_auth_token')
|
||||
: null;
|
||||
}
|
||||
|
||||
function getTenantId(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
const stored = localStorage.getItem('internal_auth_user');
|
||||
if (stored) return JSON.parse(stored)?.tenant_id ?? null;
|
||||
} catch { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
async function testEndpoint(ep: EndpointDef) {
|
||||
results[ep.id] = { ...results[ep.id], loading: true, tested: false };
|
||||
results = results; // trigger reactivity
|
||||
|
||||
const token = getToken();
|
||||
const tenantId = getTenantId();
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
if (tenantId) headers['X-Tenant-ID'] = tenantId;
|
||||
|
||||
const url = `/api/v1${ep.path}`;
|
||||
const t0 = performance.now();
|
||||
|
||||
try {
|
||||
let fetchOptions: RequestInit = { method: ep.method, headers };
|
||||
// For non-GET we don't send a body to avoid validation errors
|
||||
const res = await fetch(url, fetchOptions);
|
||||
const ms = Math.round(performance.now() - t0);
|
||||
let preview: string | null = null;
|
||||
try {
|
||||
const text = await res.text();
|
||||
const obj = JSON.parse(text);
|
||||
preview = JSON.stringify(obj, null, 2).slice(0, 500);
|
||||
if (JSON.stringify(obj, null, 2).length > 500) preview += '\n...';
|
||||
} catch { /* ignore */ }
|
||||
|
||||
results[ep.id] = { status: res.status, ok: res.ok, ms, error: null, preview, tested: true, loading: false };
|
||||
} catch (e: any) {
|
||||
const ms = Math.round(performance.now() - t0);
|
||||
results[ep.id] = { status: null, ok: false, ms, error: e.message ?? 'Network error', preview: null, tested: true, loading: false };
|
||||
}
|
||||
|
||||
results = results;
|
||||
recalcCounters();
|
||||
}
|
||||
|
||||
async function testAll() {
|
||||
isTesting = true;
|
||||
totalOk = 0;
|
||||
totalFail = 0;
|
||||
for (const group of GROUPS) {
|
||||
for (const ep of group.endpoints) {
|
||||
testingId = ep.id;
|
||||
await testEndpoint(ep);
|
||||
}
|
||||
}
|
||||
testingId = null;
|
||||
isTesting = false;
|
||||
}
|
||||
|
||||
function recalcCounters() {
|
||||
totalOk = Object.values(results).filter(r => r.tested && r.ok).length;
|
||||
totalFail = Object.values(results).filter(r => r.tested && !r.ok).length;
|
||||
}
|
||||
|
||||
function statusBadge(r: EndpointResult): { text: string; cls: string } {
|
||||
if (r.loading) return { text: 'Probando...', cls: 'bg-gray-100 text-gray-600 animate-pulse' };
|
||||
if (!r.tested) return { text: 'Sin probar', cls: 'bg-gray-100 text-gray-400' };
|
||||
if (r.ok) return { text: `${r.status} OK`, cls: 'bg-green-100 text-green-700' };
|
||||
return { text: r.status ? `${r.status} Error` : 'Fallo red', cls: 'bg-red-100 text-red-700' };
|
||||
}
|
||||
|
||||
function methodBadge(method: string): string {
|
||||
const map: Record<string, string> = {
|
||||
GET: 'bg-blue-100 text-blue-700',
|
||||
POST: 'bg-green-100 text-green-700',
|
||||
PUT: 'bg-yellow-100 text-yellow-700',
|
||||
DELETE: 'bg-red-100 text-red-700',
|
||||
PATCH: 'bg-purple-100 text-purple-700',
|
||||
};
|
||||
return map[method] ?? 'bg-gray-100 text-gray-700';
|
||||
}
|
||||
|
||||
let expandedIds = new Set<string>();
|
||||
function toggleExpand(id: string) {
|
||||
if (expandedIds.has(id)) expandedIds.delete(id);
|
||||
else expandedIds.add(id);
|
||||
expandedIds = new Set(expandedIds);
|
||||
}
|
||||
|
||||
const testedCount = () => Object.values(results).filter(r => r.tested).length;
|
||||
const totalEndpoints = GROUPS.reduce((acc, g) => acc + g.endpoints.length, 0);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Reporte de Endpoints - ServiceManager</title>
|
||||
</svelte:head>
|
||||
|
||||
<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 mb-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-gray-900">Reporte de Endpoints & Páginas</h2>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
Diagnóstico de conectividad de todos los endpoints del backend y páginas del frontend.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-4 flex gap-2 md:mt-0">
|
||||
<button
|
||||
on:click={testAll}
|
||||
disabled={isTesting}
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
|
||||
>
|
||||
{#if isTesting}
|
||||
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Probando endpoints...
|
||||
{:else}
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
Probar Todo
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Bar -->
|
||||
{#if testedCount() > 0}
|
||||
<div class="mb-6 grid grid-cols-3 gap-4">
|
||||
<div class="bg-white rounded-lg border p-4 text-center">
|
||||
<div class="text-2xl font-bold text-gray-800">{testedCount()}/{totalEndpoints}</div>
|
||||
<div class="text-xs text-gray-500 mt-1">Endpoints probados</div>
|
||||
</div>
|
||||
<div class="bg-green-50 rounded-lg border border-green-200 p-4 text-center">
|
||||
<div class="text-2xl font-bold text-green-700">{totalOk}</div>
|
||||
<div class="text-xs text-green-600 mt-1">OK / Exitosos</div>
|
||||
</div>
|
||||
<div class="bg-red-50 rounded-lg border border-red-200 p-4 text-center">
|
||||
<div class="text-2xl font-bold text-red-700">{totalFail}</div>
|
||||
<div class="text-xs text-red-600 mt-1">Errores / Fallidos</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex border-b border-gray-200 mb-6">
|
||||
<button
|
||||
on:click={() => activeTab = 'endpoints'}
|
||||
class="px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors {activeTab === 'endpoints' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}"
|
||||
>
|
||||
Endpoints Backend ({totalEndpoints})
|
||||
</button>
|
||||
<button
|
||||
on:click={() => activeTab = 'pages'}
|
||||
class="px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors {activeTab === 'pages' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}"
|
||||
>
|
||||
Páginas Frontend ({FRONTEND_PAGES.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ─── Endpoints Tab ─────────────────────────────────────────────────────── -->
|
||||
{#if activeTab === 'endpoints'}
|
||||
<div class="space-y-6">
|
||||
{#each GROUPS as group}
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||
<!-- Group header -->
|
||||
<div class="flex items-center justify-between px-5 py-3 bg-gray-50 border-b border-gray-200">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider px-2 py-0.5 rounded-full {group.color}">
|
||||
{group.name}
|
||||
</span>
|
||||
<span class="text-xs text-gray-400">{group.endpoints.length} endpoint{group.endpoints.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div class="flex gap-1 items-center">
|
||||
{#each group.endpoints as ep}
|
||||
{#if results[ep.id].tested}
|
||||
<span class="w-2 h-2 rounded-full {results[ep.id].ok ? 'bg-green-400' : 'bg-red-400'}" title={ep.label}></span>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Endpoints list -->
|
||||
<div class="divide-y divide-gray-100">
|
||||
{#each group.endpoints as ep}
|
||||
{@const r = results[ep.id]}
|
||||
{@const badge = statusBadge(r)}
|
||||
<div class="px-5 py-3">
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<!-- Method badge -->
|
||||
<span class="text-xs font-bold px-2 py-0.5 rounded font-mono {methodBadge(ep.method)}">
|
||||
{ep.method}
|
||||
</span>
|
||||
<!-- Path -->
|
||||
<code class="text-xs text-gray-700 bg-gray-50 px-2 py-0.5 rounded border border-gray-200 font-mono flex-shrink-0">
|
||||
/api/v1{ep.path}
|
||||
</code>
|
||||
<!-- Label -->
|
||||
<span class="text-sm text-gray-700 flex-1 min-w-0 truncate">{ep.label}</span>
|
||||
|
||||
<!-- Status + timing -->
|
||||
<div class="flex items-center gap-2 ml-auto flex-shrink-0">
|
||||
{#if r.ms !== null && r.tested}
|
||||
<span class="text-xs text-gray-400">{r.ms}ms</span>
|
||||
{/if}
|
||||
<span class="text-xs font-medium px-2 py-0.5 rounded-full {badge.cls}">{badge.text}</span>
|
||||
<!-- Test individual -->
|
||||
<button
|
||||
on:click={() => testEndpoint(ep)}
|
||||
disabled={r.loading || isTesting}
|
||||
class="ml-1 text-xs px-2 py-1 rounded border border-gray-200 hover:border-blue-300 hover:text-blue-600 text-gray-500 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{r.loading ? '...' : 'Probar'}
|
||||
</button>
|
||||
<!-- Expand preview -->
|
||||
{#if r.tested && r.preview}
|
||||
<button
|
||||
on:click={() => toggleExpand(ep.id)}
|
||||
class="text-xs px-2 py-1 rounded border border-gray-200 hover:border-blue-300 hover:text-blue-600 text-gray-500 transition-colors"
|
||||
>
|
||||
{expandedIds.has(ep.id) ? 'Ocultar' : 'Ver respuesta'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="mt-0.5 text-xs text-gray-400 ml-0.5">{ep.description}</p>
|
||||
|
||||
<!-- Error message -->
|
||||
{#if r.tested && r.error}
|
||||
<div class="mt-2 text-xs text-red-600 bg-red-50 rounded px-2 py-1.5 font-mono">{r.error}</div>
|
||||
{/if}
|
||||
|
||||
<!-- Response preview -->
|
||||
{#if expandedIds.has(ep.id) && r.preview}
|
||||
<pre class="mt-2 text-xs text-gray-700 bg-gray-50 border border-gray-200 rounded p-3 overflow-x-auto whitespace-pre-wrap break-words max-h-48">{r.preview}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ─── Frontend Pages Tab ─────────────────────────────────────────────────── -->
|
||||
{#if activeTab === 'pages'}
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Página</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Ruta</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Descripción</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Roles</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Acción</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-100">
|
||||
{#each FRONTEND_PAGES as pg}
|
||||
<tr class="hover:bg-gray-50 transition-colors">
|
||||
<td class="px-6 py-3">
|
||||
<span class="text-sm font-medium text-gray-900">{pg.label}</span>
|
||||
</td>
|
||||
<td class="px-6 py-3">
|
||||
<code class="text-xs bg-gray-100 text-gray-700 px-2 py-0.5 rounded font-mono">{pg.href}</code>
|
||||
</td>
|
||||
<td class="px-6 py-3">
|
||||
<span class="text-xs text-gray-500">{pg.description}</span>
|
||||
</td>
|
||||
<td class="px-6 py-3">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each pg.roles as role}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded-full bg-blue-50 text-blue-600 font-medium">{role}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-3">
|
||||
<a
|
||||
href={pg.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-xs text-blue-600 hover:text-blue-800 underline font-medium"
|
||||
>
|
||||
Abrir ↗
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="px-6 py-4 bg-gray-50 border-t border-gray-200">
|
||||
<p class="text-xs text-gray-500">
|
||||
<strong>Nota:</strong> Las páginas se abren en una nueva pestaña para verificar su renderizado.
|
||||
Asegúrate de estar autenticado antes de acceder a rutas protegidas.
|
||||
</p>
|
||||
<div class="mt-3 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{#each FRONTEND_PAGES as pg}
|
||||
<a
|
||||
href={pg.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-lg border border-gray-200 hover:border-blue-300 hover:bg-blue-50 text-xs text-gray-700 hover:text-blue-700 transition-all group"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5 text-gray-400 group-hover:text-blue-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
<span class="truncate font-medium">{pg.label}</span>
|
||||
<code class="ml-auto text-gray-400 text-xs flex-shrink-0">{pg.href}</code>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,42 @@ Async database session management para Celery workers
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
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 typing import AsyncGenerator
|
||||
import uuid
|
||||
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
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
Reference in New Issue
Block a user