Compare commits

..

2 Commits

Author SHA1 Message Date
49dfb3ef24 Mejora de seguridad 2026-03-03 09:29:53 -07:00
b187aa1b46 minimo 2026-02-27 10:24:06 -07:00
23 changed files with 430 additions and 659 deletions

Binary file not shown.

View File

@@ -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,

View File

@@ -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"}

View File

@@ -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}

View File

@@ -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

View File

@@ -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,

View File

@@ -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",

View File

@@ -2,7 +2,7 @@
<html lang="es">
<head>
<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="theme-color" content="#3b82f6" />

View File

@@ -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) {

View File

@@ -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

View File

@@ -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) {

View File

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

View File

@@ -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)}

View File

@@ -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);

View File

@@ -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)
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="description" content="ServiceManager - Mesa de Ayuda Empresarial - Portal Interno" />
<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.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">

View File

@@ -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

View File

@@ -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');

View File

@@ -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 -->

View File

@@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -7,11 +7,42 @@ Async database session management para Celery workers
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.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()