Compare commits

...

9 Commits

Author SHA1 Message Date
3a061a005c Release v1.6.0 - Audit System Cross-Tenant Viewing
Features:
-  Cross-tenant audit log viewing for ADMIN/SUPPORT_MANAGER
-  New 'all_tenants' parameter in audit endpoints
-  Frontend toggle to view all clients' logs
-  Multi-tenant stats support in /audit/stats
-  Fixed timezone issue with date filters (UTC consistency)
-  Fixed date_to filter overlap causing duplicate records

Changes:
- backend/app/api/v1/endpoints/audit.py:
  * Added tenant_id and all_tenants query parameters
  * Permission checks for cross-tenant viewing
  * Dynamic tenant filtering based on user role
  * Fixed date_to filter (removed +1 day overlap)
  * Updated all stats queries for multi-tenant support

- frontend-internal/src/routes/audit/+page.svelte:
  * Import auth store for role detection
  * Added 'Ver todos los clientes' toggle for admins
  * Pass all_tenants parameter to API calls
  * UI badge indicating multi-tenant mode

- Version bump: 1.5.1.2 → 1.6.0 across all packages
2026-02-16 09:50:30 -07:00
d9b783107f fix: Corregir filtro de fechas en auditoría por zona horaria
- El filtro usaba hora local que se convertía incorrectamente a UTC
- Los registros de 'hoy' aparecían en 'ayer' por desfase horario
- Ahora trabaja directamente en UTC para consistencia
- Afecta todos los filtros: today, yesterday, last7days, last30days
- Custom dates también parseados correctamente como UTC
2026-02-16 09:26:43 -07:00
5a292daa0b feat: Script para crear usuario de prueba con contraseña conocida
- Permite crear/actualizar usuario admin@aduanasoft.com
- Password: admin123
- Facilita testing del sistema de auditoría
- Genera hash correcto con Argon2
2026-02-16 09:21:06 -07:00
87e094b668 feat: Integrar AuditService en todos los endpoints críticos
- Auth: login, logout, login_failed con registro automático
- Tickets: create, update, delete, assign con old/new values
- Users: create, update, delete con sanitización de passwords
- Stats: implementar top_users con JOIN a tabla users
- Schema: agregar índices compuestos para mejor performance
- Frontend: limpiar logs de debug en viewDetail
- Manejo de errores: audit logs no afectan flujo principal
2026-02-16 09:08:03 -07:00
2cb8b58808 Fix: Corregir funcionalidad del botón Ver en auditoría
- Eliminar click en fila completa que causaba conflicto
- Eliminar stopPropagation innecesario de botones
- Agregar type='button' a todos los botones Ver
- Mejorar hover states y transiciones
- Agregar títulos para accesibilidad
- Simplificar lógica de eventos de click
- Botones Ver ahora funcionan correctamente en tabla y tarjetas
- Tarjeta de vulnerabilidad: solo botón Ver es clickeable
2026-02-16 08:43:03 -07:00
9f973464b9 Implementar tarjeta de Vulnerabilidad y mejorar interactividad
- Reemplazar tarjeta 'Más Común' por 'Vulnerabilidad'
- Backend: Agregar campo critical_actions_today al schema de stats
- Backend: Calcular acciones críticas (delete, update sensibles, logout)
- Frontend: Mostrar contador de acciones críticas con código de color
- Frontend: Click en tarjeta filtra por acciones críticas del día
- Frontend: Botón 'Ver' funcional con icono
- Color rojo si >10 críticas, ámbar si >5, verde si <=5
- Función filterCriticalActions() para búsqueda rápida
- UX mejorada con hover effects y transiciones
2026-02-16 08:38:39 -07:00
90f9c9c6e6 Modernizar UI de registros de auditoría (UX mejorada)
- Diseño responsivo: tabla en desktop, tarjetas en móvil/tablet
- Altura máxima con scroll interno (evita scroll de página completa)
- Avatares circulares con iniciales del usuario
- Filas/tarjetas más compactas y eficientes
- Paginación sticky (siempre visible al fondo)
- Botones primera/última página para navegación rápida
- Página actual resaltada en color primary
- Click en toda la fila para ver detalles
- Diseño más moderno y limpio
- Mejor uso del espacio vertical
2026-02-16 08:31:07 -07:00
51a8515b40 Fix: Corregir formato de fechas en auditoría
- Enviar datetime ISO completo en lugar de solo fecha (YYYY-MM-DD)
- Backend requiere formato datetime ISO 8601
- Agregar hora 00:00:00 para fecha inicio y 23:59:59 para fecha fin
- Manejar correctamente fechas custom vacías
- Soluciona error 422 (Unprocessable Entity)
2026-02-16 08:24:14 -07:00
ff9a8998b2 Mejora UI de página de auditoría
- Por defecto muestra solo logs del día actual
- Agregado selector de período rápido (Hoy, Ayer, 7 días, 30 días, Personalizado)
- Filtros avanzados colapsables para mejor UX
- Interfaz más limpia y organizada
- Formato de fechas mejorado (hora corta para hoy)
- Estadísticas visuales mejoradas
- Tabla simplificada con información esencial
- Modal de detalles mantiene toda la información completa
2026-02-16 08:17:39 -07:00
12 changed files with 1070 additions and 415 deletions

View File

@@ -84,6 +84,7 @@ class AuditLogStats(BaseModel):
total_actions: int = Field(description="Total de acciones registradas")
actions_today: int = Field(description="Acciones en las ├║ltimas 24 horas")
actions_this_week: int = Field(description="Acciones en los últimos 7 días")
critical_actions_today: int = Field(description="Acciones críticas hoy (delete, cambios sensibles)")
# Top acciones
top_actions: Dict[str, int] = Field(description="Acciones más frecuentes")

View File

@@ -61,8 +61,10 @@ async def get_audit_logs(
date_from: Optional[datetime] = Query(None, description="Fecha desde"),
date_to: Optional[datetime] = Query(None, description="Fecha hasta"),
search: Optional[str] = Query(None, description="B├║squeda en acci├│n o email"),
# Dependencies
# Multi-tenant filters (solo ADMIN/SUPPORT_MANAGER)
tenant_id: Optional[uuid.UUID] = Query(None, description="Ver logs de un tenant específico"),
all_tenants: bool = Query(False, description="Ver logs de todos los tenants"),
# Dependencies
current_user: User = Depends(require_auditor_role),
current_tenant: Tenant = Depends(get_current_tenant),
db: AsyncSession = Depends(get_db)
@@ -90,17 +92,28 @@ async def get_audit_logs(
"user_id": str(user_id) if user_id else None,
"action": action,
"resource_type": resource_type,
"page": page
"page": page,
"tenant_filter": str(tenant_id) if tenant_id else None,
"all_tenants": all_tenants
}
)
# Query base - solo logs del tenant actual
# Usar selectinload para cargar la relaci├│n user (eager loading para async)
query = (
select(AuditLog)
.where(AuditLog.tenant_id == current_tenant.id)
.options(selectinload(AuditLog.user))
)
# Determinar el filtro de tenant
# Solo ADMIN y SUPPORT_MANAGER pueden ver otros tenants o todos los tenants
can_see_all_tenants = current_user.role in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]
# Query base con filtro de tenant dinámico
query = select(AuditLog).options(selectinload(AuditLog.user))
if all_tenants and can_see_all_tenants:
# Ver todos los tenants (no agregar filtro de tenant)
pass
elif tenant_id and can_see_all_tenants:
# Ver un tenant específico
query = query.where(AuditLog.tenant_id == tenant_id)
else:
# Ver solo el tenant actual (comportamiento default)
query = query.where(AuditLog.tenant_id == current_tenant.id)
# Aplicar filtros
if user_id:
@@ -119,9 +132,8 @@ async def get_audit_logs(
query = query.where(AuditLog.created_at >= date_from)
if date_to:
# Agregar 1 día para incluir todo el día
date_to_end = date_to + timedelta(days=1)
query = query.where(AuditLog.created_at < date_to_end)
# El frontend ya envía el timestamp correcto
query = query.where(AuditLog.created_at < date_to)
if search:
# B├║squeda en action
@@ -188,51 +200,57 @@ async def get_audit_logs(
@router.get("/stats", response_model=AuditLogStats)
async def get_audit_stats(
all_tenants: bool = Query(False, description="Ver stats de todos los tenants"),
current_user: User = Depends(require_auditor_role),
current_tenant: Tenant = Depends(get_current_tenant),
db: AsyncSession = Depends(get_db)
):
"""
Obtener estadísticas de auditoría del tenant.
Obtener estadísticas de auditoría del tenant (o todos los tenants si es ADMIN).
**Permisos**: ADMIN, SUPPORT_MANAGER, AUDITOR
**Retorna**: Estadísticas de actividad
**Retorna**: Estadísticas de actividad
"""
can_see_all_tenants = current_user.role in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]
logger.info(
"Fetching audit stats",
user_id=str(current_user.id),
tenant_id=str(current_tenant.id)
tenant_id=str(current_tenant.id),
all_tenants=all_tenants,
can_see_all=can_see_all_tenants
)
now = datetime.utcnow()
# Determinar si aplicar filtro de tenant
apply_tenant_filter = not (all_tenants and can_see_all_tenants)
# Total de acciones
total_query = select(func.count()).select_from(AuditLog).where(
AuditLog.tenant_id == current_tenant.id
)
total_query = select(func.count()).select_from(AuditLog)
if apply_tenant_filter:
total_query = total_query.where(AuditLog.tenant_id == current_tenant.id)
total_result = await db.execute(total_query)
total_actions = total_result.scalar() or 0
# Acciones hoy (├║ltimas 24 horas)
today_start = now - timedelta(days=1)
today_query = select(func.count()).select_from(AuditLog).where(
and_(
AuditLog.tenant_id == current_tenant.id,
AuditLog.created_at >= today_start
)
AuditLog.created_at >= today_start
)
if apply_tenant_filter:
today_query = today_query.where(AuditLog.tenant_id == current_tenant.id)
today_result = await db.execute(today_query)
actions_today = today_result.scalar() or 0
# Acciones esta semana (últimos 7 días)
week_start = now - timedelta(days=7)
week_query = select(func.count()).select_from(AuditLog).where(
and_(
AuditLog.tenant_id == current_tenant.id,
AuditLog.created_at >= week_start
)
AuditLog.created_at >= week_start
)
if apply_tenant_filter:
week_query = week_query.where(AuditLog.tenant_id == current_tenant.id)
week_result = await db.execute(week_query)
actions_this_week = week_result.scalar() or 0
@@ -240,9 +258,10 @@ async def get_audit_stats(
top_actions_query = select(
AuditLog.action,
func.count(AuditLog.id).label('count')
).where(
AuditLog.tenant_id == current_tenant.id
).group_by(
)
if apply_tenant_filter:
top_actions_query = top_actions_query.where(AuditLog.tenant_id == current_tenant.id)
top_actions_query = top_actions_query.group_by(
AuditLog.action
).order_by(
desc('count')
@@ -255,9 +274,10 @@ async def get_audit_stats(
by_resource_query = select(
AuditLog.resource_type,
func.count(AuditLog.id).label('count')
).where(
AuditLog.tenant_id == current_tenant.id
).group_by(
)
if apply_tenant_filter:
by_resource_query = by_resource_query.where(AuditLog.tenant_id == current_tenant.id)
by_resource_query = by_resource_query.group_by(
AuditLog.resource_type
).order_by(
desc('count')
@@ -266,14 +286,48 @@ async def get_audit_stats(
by_resource_result = await db.execute(by_resource_query)
by_resource_type = {row.resource_type: row.count for row in by_resource_result}
# Top usuarios (necesitamos hacer join - simplificado por ahora)
# En producción podrías hacer un join con users para obtener nombres
top_users = {} # Placeholder - implementar con join si es necesario
# Top usuarios (con join a users para obtener nombres)
top_users_query = select(
User.email,
func.count(AuditLog.id).label('count')
).join(
User, AuditLog.user_id == User.id
)
if apply_tenant_filter:
top_users_query = top_users_query.where(AuditLog.tenant_id == current_tenant.id)
top_users_query = top_users_query.group_by(
User.email
).order_by(
desc('count')
).limit(5)
top_users_result = await db.execute(top_users_query)
top_users = {row.email: row.count for row in top_users_result}
# Acciones críticas hoy (delete, update sensibles, etc.)
critical_conditions = [
AuditLog.created_at >= today_start,
or_(
AuditLog.action.like('%.delete'),
AuditLog.action.like('user.update'),
AuditLog.action.like('%.assign'),
AuditLog.action.in_(['user.login_failed', 'user.logout'])
)
]
if apply_tenant_filter:
critical_conditions.append(AuditLog.tenant_id == current_tenant.id)
critical_actions_query = select(func.count()).select_from(AuditLog).where(
and_(*critical_conditions)
)
critical_result = await db.execute(critical_actions_query)
critical_actions_today = critical_result.scalar() or 0
return AuditLogStats(
total_actions=total_actions,
actions_today=actions_today,
actions_this_week=actions_this_week,
critical_actions_today=critical_actions_today,
top_actions=top_actions,
top_users=top_users,
by_resource_type=by_resource_type

View File

@@ -18,6 +18,7 @@ from app.core.security import security
from app.core.config import get_settings
from app.models.user import User
from app.models.tenant import Tenant
from app.services.audit_service import AuditService
router = APIRouter()
logger = structlog.get_logger(__name__)
@@ -99,6 +100,23 @@ async def login(
"Login failed - invalid credentials",
email=login_data.email
)
# Registrar intento fallido en auditoría (si el usuario existe)
if user:
try:
await AuditService.log(
db=db,
tenant_id=user.tenant_id,
user_id=None, # Login fallido = sin user_id
action="user.login_failed",
resource_type="user",
resource_id=user.id,
metadata={"email": login_data.email, "reason": "invalid_password"}
)
await db.commit()
except Exception as e:
logger.warning("Failed to log audit entry", error=str(e))
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Credenciales inválidas"
@@ -126,6 +144,21 @@ async def login(
access_token = security.create_access_token(token_data)
refresh_token = security.create_refresh_token(token_data)
# Registrar login exitoso en auditoría
try:
await AuditService.log(
db=db,
tenant_id=user.tenant_id,
user_id=user.id,
action="user.login",
resource_type="user",
resource_id=user.id,
metadata={"email": user.email, "success": True}
)
await db.commit()
except Exception as e:
logger.warning("Failed to log audit entry", error=str(e))
logger.info(
"Login successful",
email=login_data.email,
@@ -227,6 +260,25 @@ async def logout(
# TODO: Revoke refresh token in database
# Registrar logout en auditoría
try:
import uuid
user_id = uuid.UUID(payload["sub"])
tenant_id = uuid.UUID(payload["tenant_id"])
await AuditService.log(
db=db,
tenant_id=tenant_id,
user_id=user_id,
action="user.logout",
resource_type="user",
resource_id=user_id,
metadata={"email": payload.get("email")}
)
await db.commit()
except Exception as e:
logger.warning("Failed to log audit entry", error=str(e))
logger.info("Logout successful", user_id=payload["sub"])
return {"message": "Successfully logged out"}

View File

@@ -21,6 +21,7 @@ from app.models.comment import TicketComment
from app.models.attachment import TicketAttachment
from app.api.schemas.attachment import AttachmentResponse
from app.core.file_handler import file_handler
from app.services.audit_service import AuditService
import uuid
router = APIRouter()
@@ -143,6 +144,27 @@ async def create_ticket(
await db.commit()
await db.refresh(db_ticket)
# Registrar creación en auditoría
try:
await AuditService.log(
db=db,
tenant_id=current_user.tenant_id,
user_id=current_user.id,
action="ticket.create",
resource_type="ticket",
resource_id=db_ticket.id,
new_values={
"ticket_number": db_ticket.ticket_number,
"subject": db_ticket.subject,
"priority": db_ticket.priority.value,
"status": db_ticket.status.value
}
)
await db.commit()
except Exception as e:
# No fallar si falla el audit log
pass
# ✅ Éxito - retornar ticket creado
return {
"id": str(db_ticket.id),
@@ -501,6 +523,15 @@ async def update_ticket(
detail=f"Ticket {ticket_id} not found"
)
# Guardar valores anteriores para audit
old_values = {
"subject": db_ticket.subject,
"description": db_ticket.description,
"status": db_ticket.status.value,
"priority": db_ticket.priority.value,
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None
}
try:
update_data = ticket_update.dict(exclude_unset=True)
@@ -519,6 +550,34 @@ async def update_ticket(
await db.commit()
await db.refresh(db_ticket)
# Registrar actualización en auditoría
try:
new_values = {
"subject": db_ticket.subject,
"description": db_ticket.description,
"status": db_ticket.status.value,
"priority": db_ticket.priority.value,
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None
}
# Si cambió assigned_to, registrar como acción de asignación
action = "ticket.assign" if old_values["assigned_to"] != new_values["assigned_to"] else "ticket.update"
await AuditService.log(
db=db,
tenant_id=current_user.tenant_id,
user_id=current_user.id,
action=action,
resource_type="ticket",
resource_id=db_ticket.id,
old_values=old_values,
new_values=new_values
)
await db.commit()
except Exception as e:
# No fallar si falla el audit log
pass
# ✅ CORREGIDO: Usar affected_system_id
return {
"id": str(db_ticket.id),
@@ -787,9 +846,33 @@ async def delete_ticket(
detail=f"Ticket {ticket_id} not found"
)
# Guardar datos del ticket antes de eliminar para audit
old_values = {
"ticket_number": db_ticket.ticket_number,
"subject": db_ticket.subject,
"status": db_ticket.status.value,
"priority": db_ticket.priority.value
}
await db.delete(db_ticket)
await db.commit()
# Registrar eliminación en auditoría
try:
await AuditService.log(
db=db,
tenant_id=current_user.tenant_id,
user_id=current_user.id,
action="ticket.delete",
resource_type="ticket",
resource_id=ticket_uuid,
old_values=old_values
)
await db.commit()
except Exception as e:
# No fallar si falla el audit log
pass
return {"message": "Ticket deleted successfully"}
# ===================================

View File

@@ -9,6 +9,7 @@ import uuid
from app.core.database import get_db
from app.core.security import security
from app.models.user import User, UserRole
from app.services.audit_service import AuditService
from app.api import deps
router = APIRouter()
@@ -147,6 +148,28 @@ async def create_user(
db.add(db_user)
await db.commit()
await db.refresh(db_user)
# Registrar creación en auditoría
try:
await AuditService.log(
db=db,
tenant_id=current_user.tenant_id,
user_id=current_user.id,
action="user.create",
resource_type="user",
resource_id=db_user.id,
new_values=AuditService.sanitize_values({
"email": db_user.email,
"first_name": db_user.first_name,
"last_name": db_user.last_name,
"role": db_user.role.value
})
)
await db.commit()
except Exception as e:
# No fallar si falla el audit log
pass
return db_user
@@ -214,6 +237,15 @@ async def update_user(
detail="User not found"
)
# Guardar valores anteriores para audit
old_values = {
"email": db_user.email,
"first_name": db_user.first_name,
"last_name": db_user.last_name,
"role": db_user.role.value,
"is_active": db_user.is_active
}
# Verificar email único si se está cambiando
update_data = user_update.model_dump(exclude_unset=True)
if "email" in update_data and update_data["email"] != db_user.email:
@@ -239,6 +271,32 @@ async def update_user(
await db.commit()
await db.refresh(db_user)
# Registrar actualización en auditoría
try:
new_values = {
"email": db_user.email,
"first_name": db_user.first_name,
"last_name": db_user.last_name,
"role": db_user.role.value,
"is_active": db_user.is_active
}
await AuditService.log(
db=db,
tenant_id=current_user.tenant_id,
user_id=current_user.id,
action="user.update",
resource_type="user",
resource_id=db_user.id,
old_values=AuditService.sanitize_values(old_values),
new_values=AuditService.sanitize_values(new_values)
)
await db.commit()
except Exception as e:
# No fallar si falla el audit log
pass
return db_user
@@ -306,6 +364,28 @@ async def delete_user(
# Soft delete
db_user.is_active = False
await db.commit()
# Registrar eliminación en auditoría
try:
await AuditService.log(
db=db,
tenant_id=current_user.tenant_id,
user_id=current_user.id,
action="user.delete",
resource_type="user",
resource_id=db_user.id,
old_values={
"email": db_user.email,
"role": db_user.role.value,
"was_active": True
},
metadata={"action_type": "soft_delete"}
)
await db.commit()
except Exception as e:
# No fallar si falla el audit log
pass
return None

View File

@@ -0,0 +1,67 @@
"""
Script para crear/actualizar usuario de prueba con contraseña conocida
"""
import asyncio
from sqlalchemy import select, update
from app.core.database import AsyncSessionLocal
from app.core.security import security
from app.models.user import User, UserRole
from app.models.tenant import Tenant
import uuid
async def create_test_user():
async with AsyncSessionLocal() as db:
# Buscar tenant
tenant_query = select(Tenant).where(Tenant.slug.like('%aduanasoft%')).limit(1)
result = await db.execute(tenant_query)
tenant = result.scalar_one_or_none()
if not tenant:
print("❌ No se encontró tenant")
return
print(f"✅ Tenant encontrado: {tenant.name} ({tenant.slug})")
# Buscar o crear usuario admin
user_query = select(User).where(
User.email == "admin@aduanasoft.com",
User.tenant_id == tenant.id
)
result = await db.execute(user_query)
user = result.scalar_one_or_none()
# Hash de la contraseña "admin123"
password_hash = security.hash_password("admin123")
if user:
# Actualizar contraseña
user.password_hash = password_hash
user.is_active = True
user.email_verified = True
await db.commit()
print(f"✅ Usuario actualizado: {user.email}")
else:
# Crear usuario nuevo
user = User(
id=uuid.uuid4(),
tenant_id=tenant.id,
email="admin@aduanasoft.com",
first_name="Admin",
last_name="Sistema",
password_hash=password_hash,
role=UserRole.ADMIN,
is_active=True,
email_verified=True
)
db.add(user)
await db.commit()
print(f"✅ Usuario creado: {user.email}")
print(f"\n📋 Credenciales de prueba:")
print(f" Email: admin@aduanasoft.com")
print(f" Password: admin123")
print(f" Tenant: {tenant.slug}")
print(f" Role: ADMIN")
if __name__ == "__main__":
asyncio.run(create_test_user())

View File

@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "servicemanager-backend"
version = "1.5.1.2"
version = "1.6.0"
description = "ServiceManagerWeb Backend - Mesa de Ayuda B2B"
authors = [
{name = "Aduanasoft", email = "dev@aduanasoft.com"}

View File

@@ -369,10 +369,14 @@ CREATE TABLE audit_logs (
CREATE INDEX idx_audit_logs_tenant_id ON audit_logs(tenant_id);
CREATE INDEX idx_audit_logs_user_id ON audit_logs(user_id);
CREATE INDEX idx_audit_logs_action ON audit_logs(action);
CREATE INDEX idx_audit_logs_resource ON audit_logs(resource_type, resource_id);
CREATE INDEX idx_audit_logs_correlation_id ON audit_logs(correlation_id);
CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at);
-- Índices compuestos para queries comunes de auditoría
CREATE INDEX idx_audit_logs_tenant_action ON audit_logs(tenant_id, action);
CREATE INDEX idx_audit_logs_resource ON audit_logs(resource_type, resource_id);
CREATE INDEX idx_audit_logs_user_created ON audit_logs(user_id, created_at);
-- ===================================
-- FUNCIONES Y TRIGGERS
-- ===================================

View File

@@ -1,6 +1,6 @@
{
"name": "@servicemanager/client-frontend",
"version": "1.5.1.2",
"version": "1.6.0",
"private": true,
"type": "module",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@servicemanager/internal-frontend",
"version": "1.5.1.2",
"version": "1.6.0",
"private": true,
"type": "module",
"scripts": {

View File

@@ -1,8 +1,9 @@
<script>
import { createEventDispatcher, onMount, onDestroy } from 'svelte';
import { createEventDispatcher } from 'svelte';
export let open = false;
export let title = '';
export let size = 'lg'; // sm, md, lg, xl, 2xl
const dispatch = createEventDispatcher();
@@ -15,6 +16,20 @@
close();
}
}
function handleBackdropClick(e) {
if (e.target === e.currentTarget) {
close();
}
}
const sizeClasses = {
sm: 'sm:max-w-sm',
md: 'sm:max-w-md',
lg: 'sm:max-w-lg',
xl: 'sm:max-w-xl',
'2xl': 'sm:max-w-2xl'
};
</script>
<svelte:window on:keydown={handleKeydown}/>
@@ -23,21 +38,45 @@
<div class="fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div class="flex items-end justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75" aria-hidden="true" on:click={close}></div>
<!-- Backdrop -->
<div
class="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75"
aria-hidden="true"
on:click={handleBackdropClick}
></div>
<!-- Center trick -->
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div class="inline-block px-4 pt-5 pb-4 overflow-hidden text-left align-bottom transition-all transform bg-white rounded-lg shadow-xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div class="sm:flex sm:items-start">
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
<h3 class="text-lg leading-6 font-medium text-gray-900" id="modal-title">
{title}
</h3>
<div class="mt-2 text-sm text-gray-500">
<slot />
</div>
</div>
<!-- Modal panel -->
<div class="inline-block w-full align-bottom bg-white rounded-lg shadow-xl transform transition-all sm:my-8 sm:align-middle {sizeClasses[size]} sm:w-full">
<!-- Header -->
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900" id="modal-title">
{title}
</h3>
<button
type="button"
on:click={close}
class="text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 rounded-lg p-1"
>
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Body -->
<div class="px-6 py-4 max-h-[70vh] overflow-y-auto">
<slot />
</div>
<!-- Footer (optional) -->
{#if $$slots.footer}
<div class="px-6 py-4 bg-gray-50 border-t border-gray-200 rounded-b-lg">
<slot name="footer" />
</div>
{/if}
</div>
</div>
</div>

File diff suppressed because it is too large Load Diff