Compare commits

...

3 Commits

Author SHA1 Message Date
ae0bfc9d62 fix: Agregar safelist de colores en Tailwind para auditoría
Problema: Las clases de colores retornadas por funciones JavaScript
no eran detectadas por el purge de Tailwind, causando que los colores
no se mostraran (aparecían grises).

Solución: Agregar safelist en tailwind.config.js con todas las clases
de colores usadas dinámicamente:
- Colores de acciones: red, blue, green, indigo, orange, yellow
- Colores de severidad/riesgo con variantes 100, 300, 600, 800
- Bordes y textos correspondientes
- Estados hover y focus

Esto asegura que Tailwind incluya estas clases en el CSS compilado
independientemente de si se usan de forma estática o dinámica.
2026-02-16 13:01:17 -07:00
0bc4caf65d style: Restaurar esquema de colores en páginas de auditoría
- Cambiar funciones de color de grayscale a colores semánticos:
  * getActionColor: delete (red), update (blue), login (indigo), create (green)
  * getSeverityColor: critical (red), high (orange), medium (yellow), low (blue)
  * getStatusColor: active (blue), resolved (green), investigating (yellow)
  * getRiskColor: safe (green), low (blue), medium (yellow), high (orange), critical (red)

- Actualizar botones a esquema indigo del proyecto:
  * Botones de período de análisis: bg-indigo-600
  * Paginación: bg-indigo-600 para página actual
  * Botón actualizar: bg-indigo-600
  * Botón ejecutar acción: bg-indigo-600

- Botones de acción con colores apropiados:
  * Bloquear IP: bg-red-600 (acción crítica)
  * Resetear contraseña: bg-orange-600 (acción importante)
  * Notificar admin: bg-blue-600 (acción informativa)

Mantiene consistencia con el estilo visual del proyecto.
2026-02-16 12:53:26 -07:00
be762585d2 feat: Mejoras en auditoría - incidentes de seguridad y esquema de colores
- Backend:
  * Agregado endpoint /v1/audit/security/incidents con paginación y filtros
  * Nuevos schemas SecurityIncidentResponse y SecurityIncidentListResponse
  * Fix timezone: datetime.utcnow() → datetime.now(timezone.utc) en 4 ubicaciones
  * Detección automática de incidentes: mass deletion, brute force, privilege escalation

- Frontend (Internal):
  * Nueva sección de Incidentes de Seguridad con modal de detalles
  * Filtros por severidad, estado y tipo de incidente
  * Conversión completa a esquema grayscale (gray-100 a gray-900)
  * Eliminados todos los emojis de páginas audit y security
  * Implementada paginación para incidentes

- Fixes:
  * Resuelto error 500: TypeError con datetimes timezone-aware/naive
  * Resuelto error 404: endpoint de incidentes faltante
2026-02-16 12:45:33 -07:00
7 changed files with 1082 additions and 85 deletions

View File

@@ -100,6 +100,38 @@ class SecurityActionResponse(BaseModel):
action_id: Optional[UUID4] = Field(None, description="ID de la acción registrada") action_id: Optional[UUID4] = Field(None, description="ID de la acción registrada")
class SecurityIncidentResponse(BaseModel):
"""Respuesta para incidentes de seguridad."""
id: str = Field(description="ID único del incidente")
title: str = Field(description="Título del incidente")
description: Optional[str] = Field(None, description="Descripción detallada")
severity: str = Field(description="Severidad: low, medium, high, critical")
status: str = Field(description="Estado: active, investigating, resolved")
incident_type: str = Field(description="Tipo de incidente")
affected_user: Optional[str] = Field(None, description="Usuario afectado")
source_ip: Optional[str] = Field(None, description="IP origen del incidente")
evidence: list[str] = Field(default=[], description="Evidencia del incidente")
metadata: Optional[Dict[str, Any]] = Field(None, description="Metadata adicional")
created_at: datetime = Field(description="Fecha de creación")
updated_at: Optional[datetime] = Field(None, description="Última actualización")
resolved_at: Optional[datetime] = Field(None, description="Fecha de resolución")
class Config:
from_attributes = True
class SecurityIncidentListResponse(BaseModel):
"""Respuesta paginada de incidentes de seguridad."""
incidents: list[SecurityIncidentResponse]
total: int = Field(description="Total de incidentes")
page: int = Field(description="Página actual")
per_page: int = Field(description="Incidentes por página")
total_pages: int = Field(description="Total de páginas")
class Config:
from_attributes = True
class AuditLogFilters(BaseModel): class AuditLogFilters(BaseModel):
""" """
Filtros para consulta de audit logs. Filtros para consulta de audit logs.

View File

@@ -10,7 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_, or_, desc from sqlalchemy import select, func, and_, or_, desc
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from typing import Optional, List from typing import Optional, List
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
import uuid import uuid
import structlog import structlog
@@ -28,7 +28,9 @@ from app.api.schemas.audit import (
SecurityAnalysisResponse, SecurityAnalysisResponse,
SecurityThreatPattern, SecurityThreatPattern,
SecurityActionRequest, SecurityActionRequest,
SecurityActionResponse SecurityActionResponse,
SecurityIncidentResponse,
SecurityIncidentListResponse
) )
router = APIRouter() router = APIRouter()
@@ -227,7 +229,7 @@ async def get_audit_stats(
can_see_all=can_see_all_tenants can_see_all=can_see_all_tenants
) )
now = datetime.utcnow() now = datetime.now(timezone.utc)
# Determinar si aplicar filtro de tenant # Determinar si aplicar filtro de tenant
apply_tenant_filter = not (all_tenants and can_see_all_tenants) apply_tenant_filter = not (all_tenants and can_see_all_tenants)
@@ -429,7 +431,7 @@ async def get_security_analysis(
hours=hours hours=hours
) )
now = datetime.utcnow() now = datetime.now(timezone.utc)
analysis_start = now - timedelta(hours=hours) analysis_start = now - timedelta(hours=hours)
threats = [] threats = []
@@ -713,3 +715,537 @@ async def execute_security_action(
message=message, message=message,
action_id=None # TODO: Retornar ID del audit log creado action_id=None # TODO: Retornar ID del audit log creado
) )
@router.get("/security/incidents", response_model=SecurityIncidentListResponse)
async def get_security_incidents(
# Paginación
page: int = Query(default=1, ge=1, description="Número de página"),
per_page: int = Query(default=20, ge=1, le=100, description="Incidentes por página"),
# Filtros
severity: Optional[str] = Query(None, description="Filtrar por severidad"),
status: Optional[str] = Query(None, description="Filtrar por estado"),
incident_type: Optional[str] = Query(None, description="Filtrar por tipo"),
search: Optional[str] = Query(None, description="Búsqueda en título o descripción"),
# Multi-tenant (solo ADMIN/SUPPORT_MANAGER)
all_tenants: bool = Query(False, description="Ver incidentes de todos los tenants"),
# Dependencies
current_user: User = Depends(require_auditor_role),
current_tenant: Tenant = Depends(get_current_tenant),
db: AsyncSession = Depends(get_db)
):
"""
Obtener incidentes de seguridad.
Los incidentes se generan dinámicamente analizando logs de auditoría
para detectar patrones sospechosos y acciones críticas.
**Permisos**: ADMIN, SUPPORT_MANAGER, AUDITOR
**Retorna**: Lista paginada de incidentes de seguridad
"""
logger.info(
"Fetching security incidents",
user_id=str(current_user.id),
tenant_id=str(current_tenant.id),
filters={
"severity": severity,
"status": status,
"type": incident_type,
"page": page,
"per_page": per_page
}
)
# Generar incidentes a partir de logs de auditoría
incidents = []
now = datetime.now(timezone.utc)
# Determinar rango de tiempo para análisis (últimos 7 días para mejor performance)
analysis_start = now - timedelta(days=7)
# Construir query base
base_query = select(AuditLog).options(
selectinload(AuditLog.user)
).where(
AuditLog.created_at >= analysis_start
)
# Aplicar filtro de tenant
if all_tenants and current_user.role in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]:
# Ver incidentes de todos los tenants
pass
else:
base_query = base_query.where(AuditLog.tenant_id == current_tenant.id)
# 1. DETECTAR ELIMINACIONES MASIVAS
deletion_query = base_query.where(
AuditLog.action.like('%.delete')
).order_by(desc(AuditLog.created_at))
deletion_result = await db.execute(deletion_query)
deletion_logs = deletion_result.scalars().all()
# Agrupar eliminaciones por usuario y fecha
deletion_groups = {}
for log in deletion_logs:
if not log.user:
continue
key = f"{log.user.email}_{log.created_at.date()}"
if key not in deletion_groups:
deletion_groups[key] = {
'user': log.user.email,
'date': log.created_at.date(),
'count': 0,
'logs': [],
'first_seen': log.created_at,
'last_seen': log.created_at
}
deletion_groups[key]['count'] += 1
deletion_groups[key]['logs'].append(log)
if log.created_at < deletion_groups[key]['first_seen']:
deletion_groups[key]['first_seen'] = log.created_at
if log.created_at > deletion_groups[key]['last_seen']:
deletion_groups[key]['last_seen'] = log.created_at
# Crear incidentes para eliminaciones masivas (>=3 eliminaciones)
for key, group in deletion_groups.items():
if group['count'] >= 3: # Umbral para considerar "masivo"
severity = "critical" if group['count'] >= 10 else "high" if group['count'] >= 5 else "medium"
incidents.append(SecurityIncidentResponse(
id=f"mass_del_{key.replace('_', '-')}",
title=f"Eliminaciones masivas - {group['user']}",
description=f"{group['user']} eliminó {group['count']} elementos el {group['date']}",
severity=severity,
status="active" if (now - group['last_seen']).days <= 1 else "resolved",
incident_type="mass_deletion",
affected_user=group['user'],
source_ip=group['logs'][0].ip_address,
evidence=[
f"{log.action} - {log.resource_type} - {log.created_at.strftime('%H:%M:%S')}"
for log in group['logs'][:5] # Solo mostrar los primeros 5
],
metadata={
"total_deletions": group['count'],
"resource_types": list(set(log.resource_type for log in group['logs'])),
"time_span_minutes": int((group['last_seen'] - group['first_seen']).total_seconds() / 60)
},
created_at=group['first_seen'],
updated_at=group['last_seen']
))
# 2. DETECTAR INTENTOS DE LOGIN FALLIDOS
failed_login_query = base_query.where(
AuditLog.action == 'user.login_failed'
).order_by(desc(AuditLog.created_at))
failed_login_result = await db.execute(failed_login_query)
failed_login_logs = failed_login_result.scalars().all()
# Agrupar por IP
ip_groups = {}
for log in failed_login_logs:
if not log.ip_address:
continue
ip = str(log.ip_address)
if ip not in ip_groups:
ip_groups[ip] = {
'count': 0,
'logs': [],
'first_seen': log.created_at,
'last_seen': log.created_at,
'users': set()
}
ip_groups[ip]['count'] += 1
ip_groups[ip]['logs'].append(log)
if log.created_at < ip_groups[ip]['first_seen']:
ip_groups[ip]['first_seen'] = log.created_at
if log.created_at > ip_groups[ip]['last_seen']:
ip_groups[ip]['last_seen'] = log.created_at
if log.user and log.user.email:
ip_groups[ip]['users'].add(log.user.email)
# Crear incidentes para IPs con muchos fallos (>=5)
for ip, group in ip_groups.items():
if group['count'] >= 5:
severity = "critical" if group['count'] >= 20 else "high" if group['count'] >= 10 else "medium"
incidents.append(SecurityIncidentResponse(
id=f"brute_force_{ip.replace('.', '-')}",
title=f"Posible ataque de fuerza bruta desde {ip}",
description=f"Se detectaron {group['count']} intentos fallidos de login desde la IP {ip}",
severity=severity,
status="active" if (now - group['last_seen']).total_seconds() <= 86400 else "investigating", # 24 horas
incident_type="brute_force_attack",
affected_user=', '.join(list(group['users'])[:3]) if group['users'] else None,
source_ip=ip,
evidence=[
f"Login fallido - {log.user.email if log.user else 'Unknown'} - {log.created_at.strftime('%H:%M:%S')}"
for log in group['logs'][:5]
],
metadata={
"total_attempts": group['count'],
"targeted_users": list(group['users']),
"time_span_hours": int((group['last_seen'] - group['first_seen']).total_seconds() / 3600)
},
created_at=group['first_seen'],
updated_at=group['last_seen']
))
# 3. DETECTAR CAMBIOS DE ROLES/PRIVILEGIOS
privilege_query = base_query.where(
and_(
AuditLog.action == 'user.update',
AuditLog.new_values.op('?')('role')
)
).order_by(desc(AuditLog.created_at))
privilege_result = await db.execute(privilege_query)
privilege_logs = privilege_result.scalars().all()
for log in privilege_logs:
if not log.user or not log.new_values or 'role' not in log.new_values:
continue
old_role = log.old_values.get('role') if log.old_values else 'Unknown'
new_role = log.new_values.get('role')
# Solo crear incidente si es escalada de privilegios
role_hierarchy = {'CLIENT_USER': 1, 'CLIENT_ADMIN': 2, 'AGENT': 3, 'SUPPORT_MANAGER': 4, 'ADMIN': 5}
old_level = role_hierarchy.get(old_role, 0)
new_level = role_hierarchy.get(new_role, 0)
if new_level > old_level:
incidents.append(SecurityIncidentResponse(
id=f"priv_esc_{log.id}",
title=f"Escalada de privilegios - {log.user.email}",
description=f"Usuario {log.user.email} cambió de rol {old_role} a {new_role}",
severity="high" if new_role in ['ADMIN', 'SUPPORT_MANAGER'] else "medium",
status="investigating",
incident_type="privilege_escalation",
affected_user=log.user.email,
source_ip=log.ip_address,
evidence=[
f"Cambio de rol: {old_role}{new_role} - {log.created_at.strftime('%Y-%m-%d %H:%M')}"
],
metadata={
"old_role": old_role,
"new_role": new_role,
"correlation_id": str(log.correlation_id) if log.correlation_id else None
},
created_at=log.created_at,
updated_at=log.created_at
))
# Aplicar filtros de búsqueda
filtered_incidents = incidents
if severity:
filtered_incidents = [i for i in filtered_incidents if i.severity == severity]
if status:
filtered_incidents = [i for i in filtered_incidents if i.status == status]
if incident_type:
filtered_incidents = [i for i in filtered_incidents if i.incident_type == incident_type]
if search:
search_lower = search.lower()
filtered_incidents = [
i for i in filtered_incidents
if search_lower in i.title.lower() or (i.description and search_lower in i.description.lower())
]
# Ordenar por fecha de creación (más recientes primero)
filtered_incidents.sort(key=lambda x: x.created_at, reverse=True)
# Aplicar paginación
total = len(filtered_incidents)
total_pages = (total + per_page - 1) // per_page
start_idx = (page - 1) * per_page
end_idx = start_idx + per_page
paginated_incidents = filtered_incidents[start_idx:end_idx]
return SecurityIncidentListResponse(
incidents=paginated_incidents,
total=total,
page=page,
per_page=per_page,
total_pages=total_pages
)
@router.get("/security/incidents", response_model=SecurityIncidentListResponse)
async def get_security_incidents(
# Paginación
page: int = Query(default=1, ge=1, description="Número de página"),
per_page: int = Query(default=20, ge=1, le=100, description="Incidentes por página"),
# Filtros
severity: Optional[str] = Query(None, description="Filtrar por severidad"),
status: Optional[str] = Query(None, description="Filtrar por estado"),
incident_type: Optional[str] = Query(None, description="Filtrar por tipo"),
search: Optional[str] = Query(None, description="Búsqueda en título o descripción"),
# Multi-tenant (solo ADMIN/SUPPORT_MANAGER)
all_tenants: bool = Query(False, description="Ver incidentes de todos los tenants"),
# Dependencies
current_user: User = Depends(require_auditor_role),
current_tenant: Tenant = Depends(get_current_tenant),
db: AsyncSession = Depends(get_db)
):
"""
Obtener incidentes de seguridad.
Los incidentes se generan dinámicamente analizando logs de auditoría
para detectar patrones sospechosos y acciones críticas.
**Permisos**: ADMIN, SUPPORT_MANAGER, AUDITOR
**Retorna**: Lista paginada de incidentes de seguridad
"""
logger.info(
"Fetching security incidents",
user_id=str(current_user.id),
tenant_id=str(current_tenant.id),
filters={
"severity": severity,
"status": status,
"type": incident_type,
"page": page,
"per_page": per_page
}
)
# Generar incidentes a partir de logs de auditoría
incidents = []
now = datetime.now(timezone.utc)
# Determinar rango de tiempo para análisis (últimos 30 días)
analysis_start = now - timedelta(days=30)
# Construir query base
base_query = select(AuditLog).options(
selectinload(AuditLog.user)
).where(
AuditLog.created_at >= analysis_start
)
# Aplicar filtro de tenant
if all_tenants and current_user.role in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]:
# Ver incidentes de todos los tenants
pass
else:
base_query = base_query.where(AuditLog.tenant_id == current_tenant.id)
# 1. DETECTAR ELIMINACIONES MASIVAS
deletion_query = base_query.where(
AuditLog.action.like('%.delete')
).order_by(desc(AuditLog.created_at))
deletion_result = await db.execute(deletion_query)
deletion_logs = deletion_result.scalars().all()
# Agrupar eliminaciones por usuario y fecha
deletion_groups = {}
for log in deletion_logs:
if not log.user:
continue
key = f"{log.user.email}_{log.created_at.date()}"
if key not in deletion_groups:
deletion_groups[key] = {
'user': log.user.email,
'date': log.created_at.date(),
'count': 0,
'logs': [],
'first_seen': log.created_at,
'last_seen': log.created_at
}
deletion_groups[key]['count'] += 1
deletion_groups[key]['logs'].append(log)
if log.created_at < deletion_groups[key]['first_seen']:
deletion_groups[key]['first_seen'] = log.created_at
if log.created_at > deletion_groups[key]['last_seen']:
deletion_groups[key]['last_seen'] = log.created_at
# Crear incidentes para eliminaciones masivas (>=5 eliminaciones)
for key, group in deletion_groups.items():
if group['count'] >= 5: # Umbral para considerar "masivo"
severity = "critical" if group['count'] >= 20 else "high" if group['count'] >= 10 else "medium"
incidents.append(SecurityIncidentResponse(
id=f"mass_del_{key.replace('_', '-')}",
title=f"Eliminaciones masivas detectadas - {group['user']}",
description=f"{group['user']} eliminó {group['count']} elementos el {group['date']}",
severity=severity,
status="active" if (now - group['last_seen']).days <= 1 else "resolved",
incident_type="mass_deletion",
affected_user=group['user'],
source_ip=group['logs'][0].ip_address,
evidence=[
f"{log.action} - {log.resource_type} {log.resource_id or 'N/A'} - {log.created_at.isoformat()}"
for log in group['logs'][:5] # Solo mostrar los primeros 5
],
metadata={
"total_deletions": group['count'],
"resource_types": list(set(log.resource_type for log in group['logs'])),
"time_span_minutes": int((group['last_seen'] - group['first_seen']).total_seconds() / 60)
},
created_at=group['first_seen'],
updated_at=group['last_seen']
))
# 2. DETECTAR INTENTOS DE LOGIN FALLIDOS
failed_login_query = base_query.where(
AuditLog.action == 'user.login_failed'
).order_by(desc(AuditLog.created_at))
failed_login_result = await db.execute(failed_login_query)
failed_login_logs = failed_login_result.scalars().all()
# Agrupar por IP
ip_groups = {}
for log in failed_login_logs:
if not log.ip_address:
continue
ip = str(log.ip_address)
if ip not in ip_groups:
ip_groups[ip] = {
'count': 0,
'logs': [],
'first_seen': log.created_at,
'last_seen': log.created_at,
'users': set()
}
ip_groups[ip]['count'] += 1
ip_groups[ip]['logs'].append(log)
if log.created_at < ip_groups[ip]['first_seen']:
ip_groups[ip]['first_seen'] = log.created_at
if log.created_at > ip_groups[ip]['last_seen']:
ip_groups[ip]['last_seen'] = log.created_at
if log.user and log.user.email:
ip_groups[ip]['users'].add(log.user.email)
# Crear incidentes para IPs con muchos fallos (>=10)
for ip, group in ip_groups.items():
if group['count'] >= 10:
severity = "critical" if group['count'] >= 50 else "high" if group['count'] >= 25 else "medium"
incidents.append(SecurityIncidentResponse(
id=f"brute_force_{ip.replace('.', '-')}",
title=f"Posible ataque de fuerza bruta desde {ip}",
description=f"Se detectaron {group['count']} intentos fallidos de login desde la IP {ip}",
severity=severity,
status="active" if (now - group['last_seen']).hours <= 24 else "investigating",
incident_type="brute_force_attack",
affected_user=', '.join(list(group['users'])[:3]) if group['users'] else None,
source_ip=ip,
evidence=[
f"Login fallido - {log.user.email if log.user else 'Unknown'} - {log.created_at.isoformat()}"
for log in group['logs'][:10]
],
metadata={
"total_attempts": group['count'],
"targeted_users": list(group['users']),
"time_span_hours": int((group['last_seen'] - group['first_seen']).total_seconds() / 3600)
},
created_at=group['first_seen'],
updated_at=group['last_seen']
))
# 3. DETECTAR CAMBIOS DE ROLES/PRIVILEGIOS
privilege_query = base_query.where(
and_(
AuditLog.action == 'user.update',
AuditLog.new_values.op('?')('role')
)
).order_by(desc(AuditLog.created_at))
privilege_result = await db.execute(privilege_query)
privilege_logs = privilege_result.scalars().all()
for log in privilege_logs:
if not log.user or not log.new_values or 'role' not in log.new_values:
continue
old_role = log.old_values.get('role') if log.old_values else 'Unknown'
new_role = log.new_values.get('role')
# Solo crear incidente si es escalada de privilegios
role_hierarchy = {'CLIENT_USER': 1, 'CLIENT_ADMIN': 2, 'AGENT': 3, 'SUPPORT_MANAGER': 4, 'ADMIN': 5}
old_level = role_hierarchy.get(old_role, 0)
new_level = role_hierarchy.get(new_role, 0)
if new_level > old_level:
incidents.append(SecurityIncidentResponse(
id=f"priv_esc_{log.id}",
title=f"Escalada de privilegios detectada - {log.user.email}",
description=f"Usuario {log.user.email} cambió de rol {old_role} a {new_role}",
severity="high" if new_role in ['ADMIN', 'SUPPORT_MANAGER'] else "medium",
status="investigating",
incident_type="privilege_escalation",
affected_user=log.user.email,
source_ip=log.ip_address,
evidence=[
f"Cambio de rol: {old_role}{new_role} - {log.created_at.isoformat()}"
],
metadata={
"old_role": old_role,
"new_role": new_role,
"changed_by": log.correlation_id # En el futuro, trackear quién hizo el cambio
},
created_at=log.created_at,
updated_at=log.created_at
))
# Aplicar filtros de búsqueda
filtered_incidents = incidents
if severity:
filtered_incidents = [i for i in filtered_incidents if i.severity == severity]
if status:
filtered_incidents = [i for i in filtered_incidents if i.status == status]
if incident_type:
filtered_incidents = [i for i in filtered_incidents if i.incident_type == incident_type]
if search:
search_lower = search.lower()
filtered_incidents = [
i for i in filtered_incidents
if search_lower in i.title.lower() or (i.description and search_lower in i.description.lower())
]
# Ordenar por fecha de creación (más recientes primero)
filtered_incidents.sort(key=lambda x: x.created_at, reverse=True)
# Aplicar paginación
total = len(filtered_incidents)
total_pages = (total + per_page - 1) // per_page
start_idx = (page - 1) * per_page
end_idx = start_idx + per_page
paginated_incidents = filtered_incidents[start_idx:end_idx]
return SecurityIncidentListResponse(
incidents=paginated_incidents,
total=total,
page=page,
per_page=per_page,
total_pages=total_pages
)

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { auth } from '$lib/stores/auth.js'; import { auth } from '$lib/stores/auth.js';
import Icon from './Icon.svelte'; import Icon from './Icon.svelte';
@@ -17,8 +18,8 @@
} }
function handleLogout() { function handleLogout() {
auth.logout();
isMenuOpen = false; isMenuOpen = false;
auth.logout(); // El store maneja la redirección automática
} }
</script> </script>

View File

@@ -1,5 +1,5 @@
import { writable } from 'svelte/store';
import type { Writable } from 'svelte/store'; import type { Writable } from 'svelte/store';
import { writable } from 'svelte/store';
// Types // Types
export interface User { export interface User {
@@ -49,13 +49,13 @@ function createAuthStore() {
return { return {
subscribe, subscribe,
// Initialize auth from localStorage // Initialize auth from localStorage
init: () => { init: () => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const token = localStorage.getItem('auth_token'); const token = localStorage.getItem('auth_token');
const user = localStorage.getItem('auth_user'); const user = localStorage.getItem('auth_user');
if (token && user) { if (token && user) {
try { try {
const parsedUser = JSON.parse(user); const parsedUser = JSON.parse(user);
@@ -77,7 +77,7 @@ function createAuthStore() {
// Login // Login
login: async (credentials: LoginRequest): Promise<void> => { login: async (credentials: LoginRequest): Promise<void> => {
update(state => ({ ...state, isLoading: true })); update(state => ({ ...state, isLoading: true }));
try { try {
const response = await fetch('/api/v1/auth/login', { const response = await fetch('/api/v1/auth/login', {
method: 'POST', method: 'POST',
@@ -93,7 +93,7 @@ function createAuthStore() {
} }
const data: LoginResponse = await response.json(); const data: LoginResponse = await response.json();
// Store auth data // Store auth data
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
localStorage.setItem('auth_token', data.access_token); localStorage.setItem('auth_token', data.access_token);
@@ -117,6 +117,8 @@ function createAuthStore() {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
localStorage.removeItem('auth_token'); localStorage.removeItem('auth_token');
localStorage.removeItem('auth_user'); localStorage.removeItem('auth_user');
// Immediate redirect after cleanup
window.location.href = '/login';
} }
set(initialState); set(initialState);
}, },

View File

@@ -9,9 +9,14 @@
let logs = []; let logs = [];
let stats = null; let stats = null;
let users = []; let users = [];
let incidents = [];
let securityAnalysis = null;
let isLoading = false; let isLoading = false;
let isLoadingIncidents = false;
let selectedLog = null; let selectedLog = null;
let selectedIncident = null;
let showDetailModal = false; let showDetailModal = false;
let showIncidentModal = false;
// Paginación // Paginación
let currentPage = 1; let currentPage = 1;
@@ -19,12 +24,24 @@
let totalLogs = 0; let totalLogs = 0;
const perPage = 20; const perPage = 20;
// Paginación de incidentes
let incidentsPage = 1;
let incidentsTotalPages = 1;
let totalIncidents = 0;
const incidentsPerPage = 10;
// Filtros básicos // Filtros básicos
let filterUserId = ''; let filterUserId = '';
let filterAction = ''; let filterAction = '';
let filterResourceType = ''; let filterResourceType = '';
let searchText = ''; let searchText = '';
// Filtros de incidentes
let filterSeverity = '';
let filterIncidentType = '';
let filterStatus = '';
let incidentSearchText = '';
// Filtro multi-tenant (solo para ADMIN/SUPPORT_MANAGER) // Filtro multi-tenant (solo para ADMIN/SUPPORT_MANAGER)
let allTenants = false; let allTenants = false;
@@ -126,6 +143,57 @@
} }
} }
/**
* Cargar incidentes de seguridad
*/
async function loadIncidents() {
isLoadingIncidents = true;
try {
const params: any = {
page: incidentsPage,
per_page: incidentsPerPage
};
// Aplicar filtros de incidentes
if (filterSeverity) params.severity = filterSeverity;
if (filterIncidentType) params.type = filterIncidentType;
if (filterStatus) params.status = filterStatus;
if (incidentSearchText) params.search = incidentSearchText;
// Aplicar filtro multi-tenant si el usuario tiene permiso
if (allTenants && canSeeAllTenants) {
params.all_tenants = true;
}
const response = await api.get('/audit/security/incidents', params);
incidents = response.incidents || [];
totalIncidents = response.total || 0;
incidentsTotalPages = response.total_pages || 1;
incidentsPage = response.page || 1;
} catch (e) {
console.error('Error cargando incidentes:', e);
incidents = [];
} finally {
isLoadingIncidents = false;
}
}
/**
* Cargar análisis de seguridad
*/
async function loadSecurityAnalysis() {
try {
const params: any = { hours: 24 };
if (allTenants && canSeeAllTenants) {
params.all_tenants = true;
}
securityAnalysis = await api.get('/audit/security/analysis', params);
} catch (e) {
console.error('Error cargando análisis de seguridad:', e);
}
}
/** /**
* Cargar logs de auditoría con filtros * Cargar logs de auditoría con filtros
*/ */
@@ -288,13 +356,47 @@
* Obtener color de badge según tipo de acción * Obtener color de badge según tipo de acción
*/ */
function getActionColor(action: string): string { function getActionColor(action: string): string {
if (action.includes('login')) return 'bg-green-100 text-green-800'; if (action.includes('delete')) return 'bg-red-600 text-white';
if (action.includes('logout')) return 'bg-gray-100 text-gray-800'; if (action.includes('update')) return 'bg-blue-600 text-white';
if (action.includes('create')) return 'bg-blue-100 text-blue-800'; if (action.includes('login') || action.includes('logout')) return 'bg-indigo-600 text-white';
if (action.includes('update')) return 'bg-yellow-100 text-yellow-800'; if (action.includes('create')) return 'bg-green-600 text-white';
if (action.includes('delete')) return 'bg-red-100 text-red-800'; return 'bg-gray-600 text-white';
if (action.includes('assign')) return 'bg-purple-100 text-purple-800'; }
return 'bg-gray-100 text-gray-800';
/**
* Obtener color de severidad
*/
function getSeverityColor(severity: string): string {
switch(severity?.toLowerCase()) {
case 'critical':
return 'bg-red-600 text-white';
case 'high':
return 'bg-orange-600 text-white';
case 'medium':
return 'bg-yellow-500 text-white';
case 'low':
return 'bg-blue-600 text-white';
default:
return 'bg-gray-600 text-white';
}
}
/**
* Obtener color de estado
*/
function getStatusColor(status: string): string {
switch(status?.toLowerCase()) {
case 'active':
case 'open':
return 'bg-blue-600 text-white';
case 'resolved':
case 'closed':
return 'bg-green-600 text-white';
case 'investigating':
return 'bg-yellow-500 text-white';
default:
return 'bg-gray-600 text-white';
}
} }
/** /**
@@ -336,6 +438,58 @@
return roleMap[role] || role; return roleMap[role] || role;
} }
/**
* Ver detalle de un incidente
*/
function viewIncidentDetail(incident: any) {
selectedIncident = incident;
showIncidentModal = true;
}
/**
* Aplicar filtros de incidentes y recargar desde página 1
*/
function applyIncidentFilters() {
incidentsPage = 1;
loadIncidents();
}
/**
* Limpiar filtros de incidentes
*/
function clearIncidentFilters() {
filterSeverity = '';
filterIncidentType = '';
filterStatus = '';
incidentSearchText = '';
incidentsPage = 1;
loadIncidents();
}
/**
* Cambiar página de incidentes
*/
function goToIncidentsPage(page: number) {
if (page >= 1 && page <= incidentsTotalPages) {
incidentsPage = page;
loadIncidents();
}
}
/**
* Formatear fecha simple
*/
function formatSimpleDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString('es-MX', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
/** /**
* Inicializar datos * Inicializar datos
*/ */
@@ -343,6 +497,8 @@
loadStats(); loadStats();
loadUsers(); loadUsers();
loadLogs(); loadLogs();
loadIncidents();
loadSecurityAnalysis();
}); });
</script> </script>
@@ -353,7 +509,7 @@
<h1 class="text-2xl font-semibold text-gray-900">Auditoría del Sistema</h1> <h1 class="text-2xl font-semibold text-gray-900">Auditoría del Sistema</h1>
<p class="mt-1 text-sm text-gray-600"> <p class="mt-1 text-sm text-gray-600">
Registro de actividades • Registro de actividades •
<span class="font-medium text-primary-600"> <span class="font-medium text-gray-800">
{periodFilter === 'today' ? 'Hoy' : {periodFilter === 'today' ? 'Hoy' :
periodFilter === 'yesterday' ? 'Ayer' : periodFilter === 'yesterday' ? 'Ayer' :
periodFilter === 'last7days' ? 'Últimos 7 días' : periodFilter === 'last7days' ? 'Últimos 7 días' :
@@ -369,31 +525,31 @@
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<button <button
on:click={() => changePeriod('today')} on:click={() => changePeriod('today')}
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {periodFilter === 'today' ? 'bg-primary-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}" class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {periodFilter === 'today' ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-indigo-50'}"
> >
Hoy Hoy
</button> </button>
<button <button
on:click={() => changePeriod('yesterday')} on:click={() => changePeriod('yesterday')}
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {periodFilter === 'yesterday' ? 'bg-primary-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}" class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {periodFilter === 'yesterday' ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-indigo-50'}"
> >
Ayer Ayer
</button> </button>
<button <button
on:click={() => changePeriod('last7days')} on:click={() => changePeriod('last7days')}
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {periodFilter === 'last7days' ? 'bg-primary-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}" class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {periodFilter === 'last7days' ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-indigo-50'}"
> >
Últimos 7 días Últimos 7 días
</button> </button>
<button <button
on:click={() => changePeriod('last30days')} on:click={() => changePeriod('last30days')}
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {periodFilter === 'last30days' ? 'bg-primary-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}" class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {periodFilter === 'last30days' ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-indigo-50'}"
> >
Últimos 30 días Últimos 30 días
</button> </button>
<button <button
on:click={() => changePeriod('custom')} on:click={() => changePeriod('custom')}
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {periodFilter === 'custom' ? 'bg-primary-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}" class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {periodFilter === 'custom' ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-indigo-50'}"
> >
<svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
@@ -412,7 +568,7 @@
id="custom-date-from" id="custom-date-from"
bind:value={customDateFrom} bind:value={customDateFrom}
on:change={applyFilters} on:change={applyFilters}
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm" class="block w-full rounded-md border-gray-300 shadow-sm focus:border-gray-500 focus:ring-gray-500 sm:text-sm"
/> />
</div> </div>
<div> <div>
@@ -422,7 +578,7 @@
id="custom-date-to" id="custom-date-to"
bind:value={customDateTo} bind:value={customDateTo}
on:change={applyFilters} on:change={applyFilters}
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm" class="block w-full rounded-md border-gray-300 shadow-sm focus:border-gray-500 focus:ring-gray-500 sm:text-sm"
/> />
</div> </div>
</div> </div>
@@ -444,7 +600,7 @@
loadLogs(); loadLogs();
loadStats(); loadStats();
}} }}
class="rounded border-gray-300 text-primary-600 shadow-sm focus:border-primary-500 focus:ring-primary-500 h-4 w-4 mr-3" class="rounded border-gray-300 text-gray-600 shadow-sm focus:border-gray-500 focus:ring-gray-500 h-4 w-4 mr-3"
/> />
<div> <div>
<span class="text-sm font-medium text-gray-900">Ver todos los clientes</span> <span class="text-sm font-medium text-gray-900">Ver todos los clientes</span>
@@ -453,7 +609,7 @@
</label> </label>
</div> </div>
{#if allTenants} {#if allTenants}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800"> <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-200 text-gray-800">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20"> <svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 2a8 8 0 100 16 8 8 0 000-16zM9 9a1 1 0 012 0v4a1 1 0 11-2 0V9zm1-5a1 1 0 100 2 1 1 0 000-2z" /> <path d="M10 2a8 8 0 100 16 8 8 0 000-16zM9 9a1 1 0 012 0v4a1 1 0 11-2 0V9zm1-5a1 1 0 100 2 1 1 0 000-2z" />
</svg> </svg>
@@ -473,28 +629,28 @@
</div> </div>
<div class="bg-white rounded-lg shadow p-4"> <div class="bg-white rounded-lg shadow p-4">
<div class="text-sm text-gray-500">Hoy</div> <div class="text-sm text-gray-500">Hoy</div>
<div class="text-2xl font-bold text-primary-600">{stats.actions_today}</div> <div class="text-2xl font-bold text-gray-600">{stats.actions_today}</div>
</div> </div>
<div class="bg-white rounded-lg shadow p-4"> <div class="bg-white rounded-lg shadow p-4">
<div class="text-sm text-gray-500">Esta Semana</div> <div class="text-sm text-gray-500">Esta Semana</div>
<div class="text-2xl font-bold text-green-600">{stats.actions_this_week}</div> <div class="text-2xl font-bold text-gray-600">{stats.actions_this_week}</div>
</div> </div>
<div class="bg-white rounded-lg shadow p-4 hover:shadow-md transition-shadow"> <div class="bg-white rounded-lg shadow p-4 hover:shadow-md transition-shadow">
<div class="flex items-center gap-2 mb-1"> <div class="flex items-center gap-2 mb-1">
<svg class="w-4 h-4 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg> </svg>
<div class="text-sm text-gray-500">Vulnerabilidad</div> <div class="text-sm text-gray-500">Incidentes Criticos</div>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="text-2xl font-bold {stats.critical_actions_today > 10 ? 'text-red-600' : stats.critical_actions_today > 5 ? 'text-amber-600' : 'text-green-600'}"> <div class="text-2xl font-bold text-gray-800">
{stats.critical_actions_today} {stats.critical_actions_today || 0}
</div> </div>
<button <button
type="button" type="button"
class="text-xs text-primary-600 hover:text-primary-700 font-medium flex items-center gap-1 px-2 py-1 rounded hover:bg-primary-50 transition-colors" class="text-xs text-gray-600 hover:text-gray-800 font-medium flex items-center gap-1 px-2 py-1 rounded hover:bg-gray-100 transition-colors"
on:click={() => filterCriticalActions()} on:click={() => filterCriticalActions()}
title="Filtrar acciones críticas" title="Ver incidentes críticos"
> >
Ver Ver
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -502,7 +658,7 @@
</svg> </svg>
</button> </button>
</div> </div>
<div class="text-xs text-gray-500 mt-1">Acciones críticas hoy</div> <div class="text-xs text-gray-500 mt-1">Incidentes críticos hoy</div>
</div> </div>
</div> </div>
{/if} {/if}
@@ -519,7 +675,7 @@
</svg> </svg>
<span class="text-sm font-medium text-gray-900">Filtros Avanzados</span> <span class="text-sm font-medium text-gray-900">Filtros Avanzados</span>
{#if activeFiltersCount > 0} {#if activeFiltersCount > 0}
<span class="px-2 py-0.5 rounded-full bg-primary-100 text-primary-700 text-xs font-medium"> <span class="px-2 py-0.5 rounded-full bg-gray-200 text-gray-700 text-xs font-medium">
{activeFiltersCount} {activeFiltersCount}
</span> </span>
{/if} {/if}
@@ -541,7 +697,7 @@
bind:value={searchText} bind:value={searchText}
on:input={applyFilters} on:input={applyFilters}
placeholder="Buscar en acciones..." placeholder="Buscar en acciones..."
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm" class="block w-full rounded-md border-gray-300 shadow-sm focus:border-gray-500 focus:ring-gray-500 sm:text-sm"
/> />
</div> </div>
@@ -552,7 +708,7 @@
id="user" id="user"
bind:value={filterUserId} bind:value={filterUserId}
on:change={applyFilters} on:change={applyFilters}
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm" class="block w-full rounded-md border-gray-300 shadow-sm focus:border-gray-500 focus:ring-gray-500 sm:text-sm"
> >
<option value="">Todos</option> <option value="">Todos</option>
{#each users as user} {#each users as user}
@@ -568,7 +724,7 @@
id="action" id="action"
bind:value={filterAction} bind:value={filterAction}
on:change={applyFilters} on:change={applyFilters}
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm" class="block w-full rounded-md border-gray-300 shadow-sm focus:border-gray-500 focus:ring-gray-500 sm:text-sm"
> >
<option value="">Todas</option> <option value="">Todas</option>
{#each Array.from(availableActions).sort() as action} {#each Array.from(availableActions).sort() as action}
@@ -584,7 +740,7 @@
id="resource-type" id="resource-type"
bind:value={filterResourceType} bind:value={filterResourceType}
on:change={applyFilters} on:change={applyFilters}
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm" class="block w-full rounded-md border-gray-300 shadow-sm focus:border-gray-500 focus:ring-gray-500 sm:text-sm"
> >
<option value="">Todos</option> <option value="">Todos</option>
{#each Array.from(availableResourceTypes).sort() as resourceType} {#each Array.from(availableResourceTypes).sort() as resourceType}
@@ -598,7 +754,7 @@
<div class="mt-4 flex justify-end"> <div class="mt-4 flex justify-end">
<button <button
on:click={clearFilters} on:click={clearFilters}
class="text-sm text-primary-600 hover:text-primary-700 font-medium" class="text-sm text-gray-600 hover:text-gray-800 font-medium"
> >
Limpiar filtros Limpiar filtros
</button> </button>
@@ -608,6 +764,130 @@
{/if} {/if}
</div> </div>
<!-- Sección de Incidentes de Seguridad -->
<div class="bg-white shadow rounded-lg mb-6">
<div class="px-4 py-3 border-b border-gray-200 bg-gray-50">
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium text-gray-900">Incidentes de Seguridad</h3>
<span class="text-sm text-gray-500">{totalIncidents} incidentes</span>
</div>
</div>
<!-- Filtros de Incidentes -->
<div class="px-4 py-3 border-b border-gray-200">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<input
type="text"
placeholder="Buscar incidentes..."
bind:value={incidentSearchText}
on:input={applyIncidentFilters}
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-gray-500 focus:ring-gray-500 sm:text-sm"
/>
<select
bind:value={filterSeverity}
on:change={applyIncidentFilters}
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-gray-500 focus:ring-gray-500 sm:text-sm"
>
<option value="">Toda severidad</option>
<option value="critical">Crítico</option>
<option value="high">Alto</option>
<option value="medium">Medio</option>
<option value="low">Bajo</option>
</select>
<select
bind:value={filterStatus}
on:change={applyIncidentFilters}
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-gray-500 focus:ring-gray-500 sm:text-sm"
>
<option value="">Todo estado</option>
<option value="active">Activo</option>
<option value="investigating">Investigando</option>
<option value="resolved">Resuelto</option>
</select>
<button
on:click={clearIncidentFilters}
class="px-3 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition-colors text-sm"
>
Limpiar filtros
</button>
</div>
</div>
<!-- Lista de Incidentes -->
<div class="overflow-x-auto">
{#if isLoadingIncidents}
<div class="flex items-center justify-center p-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-600"></div>
<span class="ml-2 text-sm text-gray-500">Cargando incidentes...</span>
</div>
{:else if incidents.length === 0}
<div class="text-center py-8">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No hay incidentes</h3>
<p class="mt-1 text-sm text-gray-500">No se encontraron incidentes de seguridad para los filtros seleccionados.</p>
</div>
{:else}
<div class="divide-y divide-gray-200">
{#each incidents as incident (incident.id)}
<div class="p-4 hover:bg-gray-50 transition-colors cursor-pointer" on:click={() => viewIncidentDetail(incident)}>
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="flex-shrink-0">
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-gray-900 truncate">{incident.title}</p>
<p class="text-sm text-gray-500 truncate">{incident.description || 'Sin descripción'}</p>
</div>
</div>
<div class="flex items-center space-x-2">
<span class="px-2 py-1 text-xs font-medium rounded-full {getSeverityColor(incident.severity)}">
{incident.severity?.toUpperCase()}
</span>
<span class="px-2 py-1 text-xs font-medium rounded-full {getStatusColor(incident.status)}">
{incident.status?.toUpperCase()}
</span>
<span class="text-xs text-gray-500">
{formatSimpleDate(incident.created_at)}
</span>
</div>
</div>
</div>
{/each}
</div>
<!-- Paginación de Incidentes -->
{#if incidentsTotalPages > 1}
<div class="px-4 py-3 border-t border-gray-200 flex items-center justify-between">
<div class="text-sm text-gray-700">
Página {incidentsPage} de {incidentsTotalPages}
</div>
<div class="flex space-x-1">
<button
on:click={() => goToIncidentsPage(incidentsPage - 1)}
disabled={incidentsPage === 1}
class="px-3 py-1 text-sm bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Anterior
</button>
<button
on:click={() => goToIncidentsPage(incidentsPage + 1)}
disabled={incidentsPage === incidentsTotalPages}
class="px-3 py-1 text-sm bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Siguiente
</button>
</div>
</div>
{/if}
{/if}
</div>
</div>
<!-- Tabla de Logs --> <!-- Tabla de Logs -->
<div class="bg-white shadow rounded-lg overflow-hidden flex flex-col" style="max-height: calc(100vh - 500px); min-height: 400px;"> <div class="bg-white shadow rounded-lg overflow-hidden flex flex-col" style="max-height: calc(100vh - 500px); min-height: 400px;">
<div class="px-4 py-3 border-b border-gray-200 bg-gray-50 flex-shrink-0"> <div class="px-4 py-3 border-b border-gray-200 bg-gray-50 flex-shrink-0">
@@ -672,8 +952,8 @@
<td class="px-3 py-2 text-sm"> <td class="px-3 py-2 text-sm">
{#if log.user_email} {#if log.user_email}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="flex-shrink-0 w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center"> <div class="flex-shrink-0 w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center">
<span class="text-xs font-medium text-primary-700"> <span class="text-xs font-medium text-gray-700">
{(log.user_name || '?').charAt(0).toUpperCase()} {(log.user_name || '?').charAt(0).toUpperCase()}
</span> </span>
</div> </div>
@@ -701,7 +981,7 @@
<button <button
type="button" type="button"
on:click={() => viewDetail(log)} on:click={() => viewDetail(log)}
class="text-primary-600 hover:text-primary-900 font-medium transition-colors" class="text-gray-600 hover:text-gray-900 font-medium transition-colors"
> >
Ver Ver
</button> </button>
@@ -722,8 +1002,8 @@
<div class="flex items-start gap-3 flex-1 min-w-0"> <div class="flex items-start gap-3 flex-1 min-w-0">
<!-- Avatar --> <!-- Avatar -->
{#if log.user_email} {#if log.user_email}
<div class="flex-shrink-0 w-10 h-10 bg-primary-100 rounded-full flex items-center justify-center"> <div class="flex-shrink-0 w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center">
<span class="text-sm font-medium text-primary-700"> <span class="text-xs font-medium text-gray-700">
{(log.user_name || '?').charAt(0).toUpperCase()} {(log.user_name || '?').charAt(0).toUpperCase()}
</span> </span>
</div> </div>
@@ -760,7 +1040,7 @@
<button <button
type="button" type="button"
on:click={() => viewDetail(log)} on:click={() => viewDetail(log)}
class="flex-shrink-0 text-primary-600 hover:text-primary-900 transition-colors p-1" class="flex-shrink-0 text-gray-600 hover:text-gray-900 transition-colors p-1"
title="Ver detalles" title="Ver detalles"
> >
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -835,7 +1115,7 @@
{#each Array.from({length: Math.min(5, totalPages)}, (_, i) => i + Math.max(1, Math.min(currentPage - 2, totalPages - 4))) as page} {#each Array.from({length: Math.min(5, totalPages)}, (_, i) => i + Math.max(1, Math.min(currentPage - 2, totalPages - 4))) as page}
<button <button
on:click={() => goToPage(page)} on:click={() => goToPage(page)}
class="relative inline-flex items-center px-3 py-1.5 border text-xs font-medium transition-colors {page === currentPage ? 'z-10 bg-primary-600 border-primary-600 text-white' : 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50'}" class="relative inline-flex items-center px-3 py-1.5 border text-xs font-medium transition-colors {page === currentPage ? 'z-10 bg-indigo-600 border-indigo-600 text-white' : 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50'}"
> >
{page} {page}
</button> </button>
@@ -873,8 +1153,99 @@
</div> </div>
</div> </div>
<!-- Modal de Incidentes -->
{#if showIncidentModal && selectedIncident}
<Modal open={showIncidentModal} size="2xl" title="Detalle del Incidente de Seguridad" on:close={() => showIncidentModal = false}>
<div class="space-y-4">
<!-- Información General -->
<div>
<h4 class="text-sm font-medium text-gray-900 mb-2">Información General</h4>
<dl class="grid grid-cols-2 gap-3 text-sm">
<div>
<dt class="font-medium text-gray-500">Título:</dt>
<dd class="text-gray-900">{selectedIncident.title}</dd>
</div>
<div>
<dt class="font-medium text-gray-500">Severidad:</dt>
<dd>
<span class="px-2 py-1 text-xs font-medium rounded-full {getSeverityColor(selectedIncident.severity)}">
{selectedIncident.severity?.toUpperCase()}
</span>
</dd>
</div>
<div>
<dt class="font-medium text-gray-500">Estado:</dt>
<dd>
<span class="px-2 py-1 text-xs font-medium rounded-full {getStatusColor(selectedIncident.status)}">
{selectedIncident.status?.toUpperCase()}
</span>
</dd>
</div>
<div>
<dt class="font-medium text-gray-500">Fecha:</dt>
<dd class="text-gray-900">{formatDate(selectedIncident.created_at)}</dd>
</div>
{#if selectedIncident.affected_user}
<div>
<dt class="font-medium text-gray-500">Usuario Afectado:</dt>
<dd class="text-gray-900">{selectedIncident.affected_user}</dd>
</div>
{/if}
{#if selectedIncident.source_ip}
<div>
<dt class="font-medium text-gray-500">IP Origen:</dt>
<dd class="text-gray-900 font-mono text-xs">{selectedIncident.source_ip}</dd>
</div>
{/if}
</dl>
</div>
<!-- Descripción -->
{#if selectedIncident.description}
<div>
<h4 class="text-sm font-medium text-gray-900 mb-2">Descripción</h4>
<div class="bg-gray-50 rounded-lg p-3 text-sm text-gray-700">
{selectedIncident.description}
</div>
</div>
{/if}
<!-- Evidencia -->
{#if selectedIncident.evidence && selectedIncident.evidence.length > 0}
<div>
<h4 class="text-sm font-medium text-gray-900 mb-2">Evidencia</h4>
<div class="bg-gray-50 rounded-lg p-3">
<ul class="list-disc list-inside text-sm text-gray-700 space-y-1">
{#each selectedIncident.evidence as evidence}
<li>{evidence}</li>
{/each}
</ul>
</div>
</div>
{/if}
<!-- Metadata -->
{#if selectedIncident.metadata && Object.keys(selectedIncident.metadata).length > 0}
<div>
<h4 class="text-sm font-medium text-gray-900 mb-2">Información Adicional</h4>
<pre class="bg-gray-50 rounded-lg p-3 text-xs font-mono text-gray-600 overflow-auto max-h-40">{JSON.stringify(selectedIncident.metadata, null, 2)}</pre>
</div>
{/if}
</div>
<div slot="footer" class="flex justify-end">
<button
on:click={() => showIncidentModal = false}
class="px-4 py-2 bg-white border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50"
>
Cerrar
</button>
</div>
</Modal>
{/if}
<!-- Modal de Detalle --> <!-- Modal de Detalle -->
{#if showDetailModal && selectedLog} {#if showDetailModal && selectedLog}}
<Modal open={showDetailModal} size="2xl" title="Detalle del Registro de Auditoría" on:close={() => showDetailModal = false}> <Modal open={showDetailModal} size="2xl" title="Detalle del Registro de Auditoría" on:close={() => showDetailModal = false}>
<div class="space-y-4"> <div class="space-y-4">
<!-- Información General --> <!-- Información General -->

View File

@@ -62,10 +62,10 @@
*/ */
function getSeverityColor(severity: string) { function getSeverityColor(severity: string) {
const colors: any = { const colors: any = {
low: 'bg-blue-100 text-blue-800', low: 'bg-blue-600 text-white',
medium: 'bg-yellow-100 text-yellow-800', medium: 'bg-yellow-500 text-white',
high: 'bg-orange-100 text-orange-800', high: 'bg-orange-600 text-white',
critical: 'bg-red-100 text-red-800' critical: 'bg-red-600 text-white'
}; };
return colors[severity] || colors.low; return colors[severity] || colors.low;
} }
@@ -179,7 +179,7 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<h1 class="text-2xl font-bold text-gray-900 flex items-center gap-2"> <h1 class="text-2xl font-bold text-gray-900 flex items-center gap-2">
<svg class="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-8 h-8 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg> </svg>
Análisis de Seguridad Análisis de Seguridad
@@ -190,7 +190,7 @@
</div> </div>
<button <button
on:click={() => loadSecurityAnalysis()} on:click={() => loadSecurityAnalysis()}
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 flex items-center gap-2" class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 flex items-center gap-2"
> >
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
@@ -211,19 +211,19 @@
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<button <button
on:click={() => changeAnalysisPeriod(24)} on:click={() => changeAnalysisPeriod(24)}
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 24 ? 'bg-primary-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}" class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 24 ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-indigo-50'}"
> >
Últimas 24 horas Últimas 24 horas
</button> </button>
<button <button
on:click={() => changeAnalysisPeriod(48)} on:click={() => changeAnalysisPeriod(48)}
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 48 ? 'bg-primary-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}" class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 48 ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-indigo-50'}"
> >
Últimas 48 horas Últimas 48 horas
</button> </button>
<button <button
on:click={() => changeAnalysisPeriod(168)} on:click={() => changeAnalysisPeriod(168)}
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 168 ? 'bg-primary-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}" class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 168 ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-indigo-50'}"
> >
Última semana Última semana
</button> </button>
@@ -232,7 +232,7 @@
{#if isLoading} {#if isLoading}
<div class="flex justify-center items-center py-12"> <div class="flex justify-center items-center py-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div> <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-600"></div>
</div> </div>
{:else if analysis} {:else if analysis}
<!-- Resumen de Riesgo --> <!-- Resumen de Riesgo -->
@@ -257,9 +257,9 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="text-sm text-gray-500">Amenazas Detectadas</p> <p class="text-sm text-gray-500">Amenazas Detectadas</p>
<p class="text-2xl font-bold text-red-600">{analysis.total_threats_detected}</p> <p class="text-2xl font-bold text-gray-800">{analysis.total_threats_detected}</p>
</div> </div>
<svg class="w-10 h-10 text-red-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-10 h-10 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg> </svg>
</div> </div>
@@ -269,9 +269,9 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="text-sm text-gray-500">Intentos Fallidos</p> <p class="text-sm text-gray-500">Intentos Fallidos</p>
<p class="text-2xl font-bold text-orange-600">{analysis.failed_login_attempts}</p> <p class="text-2xl font-bold text-gray-700">{analysis.failed_login_attempts}</p>
</div> </div>
<svg class="w-10 h-10 text-orange-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-10 h-10 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg> </svg>
</div> </div>
@@ -281,9 +281,9 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="text-sm text-gray-500">IPs Sospechosas</p> <p class="text-sm text-gray-500">IPs Sospechosas</p>
<p class="text-2xl font-bold text-purple-600">{analysis.suspicious_ips_count}</p> <p class="text-2xl font-bold text-gray-700">{analysis.suspicious_ips_count}</p>
</div> </div>
<svg class="w-10 h-10 text-purple-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-10 h-10 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg> </svg>
</div> </div>
@@ -293,9 +293,9 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="text-sm text-gray-500">Acciones Críticas</p> <p class="text-sm text-gray-500">Acciones Críticas</p>
<p class="text-2xl font-bold text-amber-600">{analysis.critical_actions_count}</p> <p class="text-2xl font-bold text-gray-700">{analysis.critical_actions_count}</p>
</div> </div>
<svg class="w-10 h-10 text-amber-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-10 h-10 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg> </svg>
</div> </div>
@@ -304,17 +304,17 @@
<!-- Recomendaciones Generales --> <!-- Recomendaciones Generales -->
{#if analysis.recommended_actions && analysis.recommended_actions.length > 0} {#if analysis.recommended_actions && analysis.recommended_actions.length > 0}
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6"> <div class="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-6">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<svg class="w-6 h-6 text-blue-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6 text-gray-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
<div class="flex-1"> <div class="flex-1">
<h4 class="text-sm font-semibold text-blue-900 mb-2">Acciones Recomendadas</h4> <h4 class="text-sm font-semibold text-gray-900 mb-2">Acciones Recomendadas</h4>
<ul class="space-y-1"> <ul class="space-y-1">
{#each analysis.recommended_actions as action} {#each analysis.recommended_actions as action}
<li class="text-sm text-blue-800 flex items-start gap-2"> <li class="text-sm text-gray-700 flex items-start gap-2">
<svg class="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 text-gray-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg> </svg>
{action} {action}
@@ -332,12 +332,12 @@
<h3 class="text-lg font-semibold text-gray-900">Amenazas Detectadas</h3> <h3 class="text-lg font-semibold text-gray-900">Amenazas Detectadas</h3>
{#each analysis.threats as threat} {#each analysis.threats as threat}
<div class="bg-white shadow rounded-lg p-6 border-l-4 {threat.severity === 'critical' ? 'border-red-500' : threat.severity === 'high' ? 'border-orange-500' : threat.severity === 'medium' ? 'border-yellow-500' : 'border-blue-500'}"> <div class="bg-white shadow rounded-lg p-6 border-l-4 {threat.severity === 'critical' ? 'border-gray-900' : threat.severity === 'high' ? 'border-gray-600' : threat.severity === 'medium' ? 'border-gray-400' : 'border-gray-200'}">
<!-- Header de Amenaza --> <!-- Header de Amenaza -->
<div class="flex items-start justify-between mb-4"> <div class="flex items-start justify-between mb-4">
<div class="flex items-start gap-3 flex-1"> <div class="flex items-start gap-3 flex-1">
<div class="p-2 rounded-lg {threat.severity === 'critical' ? 'bg-red-100' : threat.severity === 'high' ? 'bg-orange-100' : threat.severity === 'medium' ? 'bg-yellow-100' : 'bg-blue-100'}"> <div class="p-2 rounded-lg {threat.severity === 'critical' ? 'bg-gray-100' : threat.severity === 'high' ? 'bg-gray-100' : threat.severity === 'medium' ? 'bg-gray-100' : 'bg-gray-50'}">
<svg class="w-6 h-6 {threat.severity === 'critical' ? 'text-red-600' : threat.severity === 'high' ? 'text-orange-600' : threat.severity === 'medium' ? 'text-yellow-600' : 'text-blue-600'}" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6 {threat.severity === 'critical' ? 'text-gray-900' : threat.severity === 'high' ? 'text-gray-700' : threat.severity === 'medium' ? 'text-gray-600' : 'text-gray-500'}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={getThreatIcon(threat.type)} /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={getThreatIcon(threat.type)} />
</svg> </svg>
</div> </div>
@@ -453,12 +453,12 @@
</div> </div>
{:else} {:else}
<!-- No hay amenazas --> <!-- No hay amenazas -->
<div class="bg-green-50 border border-green-200 rounded-lg p-8 text-center"> <div class="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
<svg class="w-16 h-16 text-green-600 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-16 h-16 text-gray-500 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg> </svg>
<h3 class="text-lg font-semibold text-green-900 mb-2">Sistema Seguro</h3> <h3 class="text-lg font-semibold text-gray-900 mb-2">Sistema Seguro</h3>
<p class="text-sm text-green-700">No se detectaron amenazas en el período analizado</p> <p class="text-sm text-gray-600">No se detectaron amenazas en el período analizado</p>
</div> </div>
{/if} {/if}
{/if} {/if}
@@ -486,7 +486,7 @@
type="text" type="text"
bind:value={actionTarget} bind:value={actionTarget}
placeholder="IP o email del usuario" placeholder="IP o email del usuario"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm" class="block w-full rounded-md border-gray-300 shadow-sm focus:border-gray-500 focus:ring-gray-500 sm:text-sm"
/> />
</div> </div>
@@ -496,7 +496,7 @@
bind:value={actionReason} bind:value={actionReason}
rows="3" rows="3"
placeholder="Razón de la acción de seguridad" placeholder="Razón de la acción de seguridad"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm" class="block w-full rounded-md border-gray-300 shadow-sm focus:border-gray-500 focus:ring-gray-500 sm:text-sm"
></textarea> ></textarea>
</div> </div>
@@ -508,7 +508,7 @@
bind:value={actionDuration} bind:value={actionDuration}
min="1" min="1"
max="10080" max="10080"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm" class="block w-full rounded-md border-gray-300 shadow-sm focus:border-gray-500 focus:ring-gray-500 sm:text-sm"
/> />
</div> </div>
{/if} {/if}
@@ -516,13 +516,13 @@
<div class="flex justify-end gap-3 pt-4 border-t"> <div class="flex justify-end gap-3 pt-4 border-t">
<button <button
on:click={() => showActionModal = false} on:click={() => showActionModal = false}
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary-500" class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-500"
> >
Cancelar Cancelar
</button> </button>
<button <button
on:click={executeSecurityAction} on:click={executeSecurityAction}
class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500" class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500"
> >
Ejecutar Acción Ejecutar Acción
</button> </button>

View File

@@ -1,6 +1,61 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
content: ['./src/**/*.{html,js,svelte,ts}'], content: ['./src/**/*.{html,js,svelte,ts}'],
safelist: [
// Colores de acciones
'bg-red-600',
'bg-blue-600',
'bg-green-600',
'bg-indigo-600',
'bg-orange-600',
'bg-yellow-500',
'bg-gray-600',
// Colores de severidad/riesgo
'bg-red-100',
'bg-red-800',
'bg-orange-100',
'bg-orange-300',
'bg-orange-600',
'bg-orange-700',
'bg-orange-800',
'bg-yellow-100',
'bg-yellow-300',
'bg-yellow-800',
'bg-blue-100',
'bg-blue-300',
'bg-blue-800',
'bg-green-100',
'bg-green-300',
'bg-green-800',
// Bordes
'border-red-300',
'border-orange-300',
'border-yellow-300',
'border-blue-300',
'border-green-300',
// Text colors
'text-red-600',
'text-red-800',
'text-orange-600',
'text-orange-800',
'text-yellow-800',
'text-blue-600',
'text-blue-800',
'text-green-600',
'text-green-800',
'text-white',
// Hover states
'hover:bg-red-700',
'hover:bg-orange-700',
'hover:bg-blue-700',
'hover:bg-indigo-700',
'hover:bg-indigo-50',
// Focus rings
'focus:ring-red-500',
'focus:ring-orange-500',
'focus:ring-blue-500',
'focus:ring-indigo-500',
],
theme: { theme: {
extend: { extend: {
colors: { colors: {