Mejora de seguridad

This commit is contained in:
2026-03-03 09:29:53 -07:00
parent b187aa1b46
commit 49dfb3ef24
19 changed files with 428 additions and 657 deletions

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',
},
@@ -109,15 +112,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,
@@ -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>