Compare commits

..

5 Commits

Author SHA1 Message Date
49dfb3ef24 Mejora de seguridad 2026-03-03 09:29:53 -07:00
b187aa1b46 minimo 2026-02-27 10:24:06 -07:00
cd3d7e816f Recuperar implementado 2026-02-27 10:08:30 -07:00
63925fe305 Login resuelto 2026-02-27 09:16:08 -07:00
c146a6c3c3 funcion dashboard y reporte de endpoints v1.16.0 2026-02-26 12:48:28 -07:00
33 changed files with 815 additions and 238 deletions

Binary file not shown.

View File

@@ -1,3 +1,4 @@
from typing import Optional
from fastapi import Depends, HTTPException, status
from starlette.requests import Request
from fastapi.security import OAuth2PasswordBearer
@@ -15,7 +16,48 @@ from app.models.tenant import Tenant
settings = get_settings()
# Esquema OAuth2 centralizado — auth.py importa desde aquí
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"/{settings.API_VERSION}/auth/login")
# Soporta: 1) Authorization: Bearer header (Swagger/API clients)
# 2) Cookie access_token HttpOnly (apps web)
_bearer_scheme = OAuth2PasswordBearer(
tokenUrl=f"/{settings.API_VERSION}/auth/login",
auto_error=False,
)
async def oauth2_scheme(
request: Request,
bearer_token: Optional[str] = Depends(_bearer_scheme),
) -> str:
"""Extrae JWT desde header Authorization (prioridad) o cookie del frontend correcto.
Usa el header X-App para seleccionar la cookie:
- X-App: internal → solo 'internal_access_token'
- X-App: client → solo 'client_access_token'
- sin header → prueba ambas (compatibilidad con Swagger/CLI)
"""
if bearer_token:
return bearer_token
app_hint = request.headers.get("X-App", "").lower()
if app_hint == "internal":
token = request.cookies.get("internal_access_token")
elif app_hint == "client":
token = request.cookies.get("client_access_token")
else:
# Fallback para Swagger, tests y clientes sin header
token = (
request.cookies.get("internal_access_token")
or request.cookies.get("client_access_token")
)
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
return token
async def get_current_user(
request: Request,

View File

@@ -4,7 +4,7 @@ Authentication Endpoints - ServiceManagerWeb
Endpoints para autenticación y autorización
"""
from fastapi import APIRouter, HTTPException, status, Depends, Request
from fastapi import APIRouter, HTTPException, status, Depends, Request, Response
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
@@ -21,6 +21,14 @@ from app.services.audit_service import AuditService
from app.services.token_service import TokenService
from app.api.deps import oauth2_scheme, get_current_user
from app.core.cache import cache, cache_key
# Nombres de cookie por tipo de usuario
CLIENT_ROLES = {"CLIENT_ADMIN", "CLIENT_USER"}
def _cookie_name_for_role(role: str) -> str:
"""Devuelve el nombre de cookie según el rol del usuario."""
return "client_access_token" if role in CLIENT_ROLES else "internal_access_token"
from app.api.schemas.auth import (
LoginRequest, LoginResponse, RefreshTokenRequest, TokenResponse,
TwoFactorStatusResponse, TwoFactorSetupResponse,
@@ -41,6 +49,7 @@ settings = get_settings()
async def login(
login_data: LoginRequest,
request: Request,
response: Response,
db: AsyncSession = Depends(get_db)
):
"""
@@ -247,7 +256,20 @@ async def login(
# Best-effort: clear per-identity limiter on success.
if ident_key:
await cache.delete(ident_key)
# Cookie diferenciada por rol para aislar sesiones entre frontends
cookie_name = _cookie_name_for_role(
user.role.value if hasattr(user.role, "value") else user.role
)
response.set_cookie(
key=cookie_name,
value=access_token,
httponly=True,
secure=settings.is_production(),
samesite="strict" if settings.is_production() else "lax",
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
)
return LoginResponse(
access_token=access_token,
refresh_token=refresh_token,
@@ -330,6 +352,7 @@ async def refresh_token(
@router.post("/logout")
async def logout(
response: Response,
token: str = Depends(oauth2_scheme),
db: AsyncSession = Depends(get_db)
):
@@ -387,7 +410,10 @@ async def logout(
logger.warning("Failed to log audit entry", error=str(e))
logger.info("Logout successful", user_id=payload["sub"])
# Borrar la cookie correcta según el rol del usuario
cookie_name = _cookie_name_for_role(payload.get("role", ""))
response.delete_cookie(key=cookie_name)
return {"message": "Successfully logged out"}

View File

@@ -7,7 +7,7 @@ Accesible por ADMIN y SUPPORT_MANAGER.
from fastapi import APIRouter, Depends, Query, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_, case, text
from sqlalchemy import select, func, and_, case, text, literal_column
from typing import Optional, List
from datetime import datetime, timedelta, timezone
import uuid
@@ -505,20 +505,23 @@ async def get_report_trends(
tenant_filter = Ticket.tenant_id == current_user.tenant_id
# Tickets creados por día
# literal_column("'day'") evita que SQLAlchemy genere múltiples parámetros
# ($1, $4, $5) para 'day', lo que confunde a PostgreSQL en el GROUP BY.
_day_lit = literal_column("'day'")
created_rows = (await db.execute(
select(
func.date_trunc("day", Ticket.created_at).label("day"),
func.date_trunc(_day_lit, Ticket.created_at).label("day"),
func.count(Ticket.id).label("cnt"),
)
.where(and_(tenant_filter, Ticket.created_at >= period_start))
.group_by(func.date_trunc("day", Ticket.created_at))
.order_by(func.date_trunc("day", Ticket.created_at))
.group_by(func.date_trunc(_day_lit, Ticket.created_at))
.order_by(func.date_trunc(_day_lit, Ticket.created_at))
)).all()
# Tickets resueltos por día (según resolved_at)
resolved_rows = (await db.execute(
select(
func.date_trunc("day", Ticket.resolved_at).label("day"),
func.date_trunc(_day_lit, Ticket.resolved_at).label("day"),
func.count(Ticket.id).label("cnt"),
)
.where(and_(
@@ -526,8 +529,8 @@ async def get_report_trends(
Ticket.resolved_at >= period_start,
Ticket.resolved_at.isnot(None),
))
.group_by(func.date_trunc("day", Ticket.resolved_at))
.order_by(func.date_trunc("day", Ticket.resolved_at))
.group_by(func.date_trunc(_day_lit, Ticket.resolved_at))
.order_by(func.date_trunc(_day_lit, Ticket.resolved_at))
)).all()
created_map: dict[str, int] = {r.day.strftime("%Y-%m-%d"): r.cnt for r in created_rows}

View File

@@ -48,11 +48,17 @@ async def create_ticket(ticket: TicketCreate, db: AsyncSession = Depends(get_db)
category = await db.get(Category, category_uuid)
if not category:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"La categoría con ID {ticket.category_id} no existe.")
# ✅ SECURITY: Validate category belongs to current tenant (prevents cross-tenant category injection)
if category.tenant_id != current_user.tenant_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"La categoría con ID {ticket.category_id} no existe.")
if system_uuid:
system = await db.get(System, system_uuid)
if not system:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"El sistema con ID {ticket.affected_system_id} no existe.")
# ✅ SECURITY: Validate system belongs to current tenant (prevents cross-tenant system injection)
if system.tenant_id != current_user.tenant_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"El sistema con ID {ticket.affected_system_id} no existe.")
sla_response_due, sla_resolution_due = calculate_sla_deadlines(category)
assigned_to_user = category.auto_assign_to if category and category.auto_assign_to else None

View File

@@ -87,8 +87,14 @@ if settings.is_production():
"X-Correlation-ID",
]
else:
cors_allow_methods = ["*"]
cors_allow_headers = ["*"]
cors_allow_methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
cors_allow_headers = [
"Authorization",
"Content-Type",
"X-Tenant-ID",
"X-Tenant-Slug",
"X-Correlation-ID",
]
app.add_middleware(
CORSMiddleware,

View File

@@ -42,6 +42,8 @@ class TenantMiddleware(BaseHTTPMiddleware):
"/v1/auth/refresh",
"/api/v1/auth/logout",
"/v1/auth/logout",
"/api/v1/auth/me",
"/v1/auth/me",
"/api/v1/auth/forgot-password",
"/v1/auth/forgot-password",
"/api/v1/auth/reset-password",

View File

@@ -0,0 +1,49 @@
"""
Script para resetear contraseñas de todos los usuarios a valores conocidos.
Ejecutar con: python -m scripts.reset_passwords (desde /app en el contenedor)
"""
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
# Mapa email -> nueva contraseña
PASSWORD_MAP = {
"admin@aduanasoft.com": "admin123",
"admin@test.com": "admin123",
"manager@aduanasoft.com": "manager123",
"agente@aduanasoft.com": "agente123",
"auditor1@test.com": "auditor123",
"admin-cliente@empresa-demo.com": "clienteadmin123",
"cliente@empresa-demo.com": "cliente123",
"test_user@aduanasoft.com": "test123",
}
async def reset_all_passwords():
async with AsyncSessionLocal() as db:
result = await db.execute(select(User))
users = result.scalars().all()
updated = 0
skipped = 0
for user in users:
if user.email in PASSWORD_MAP:
plain = PASSWORD_MAP[user.email]
user.password_hash = security.hash_password(plain)
user.email_verified = True
user.is_active = True
updated += 1
print(f"{user.email}{plain}")
else:
skipped += 1
print(f" ⚠️ {user.email} (sin contraseña definida, se omite)")
await db.commit()
print(f"\nResumen: {updated} actualizados, {skipped} omitidos")
print("\n📋 Credenciales listas:")
for email, pwd in PASSWORD_MAP.items():
print(f" {email} / {pwd}")
if __name__ == "__main__":
asyncio.run(reset_all_passwords())

View File

@@ -415,18 +415,19 @@ INSERT INTO tenants (name, slug, contact_email) VALUES
('Aduanasoft Demo', 'aduanasoft-demo', 'demo@aduanasoft.com');
-- Usuario admin por defecto (password: admin123)
-- Hash generado con Argon2: $argon2id$v=19$m=65536,t=3,p=4$...
-- Hash Argon2id generado con m=65536,t=3,p=4
INSERT INTO users (tenant_id, email, first_name, last_name, password_hash, role, is_active, email_verified)
SELECT
id,
'admin@aduanasoft.com',
'Admin',
'Sistema',
'$argon2id$v=19$m=65536,t=3,p=4$example_hash_here',
'$argon2id$v=19$m=65536,t=3,p=4$wpjz/t+bM4bQmtM6B6A0pg$ELwnGUL4S1Y6tywp0LS6cre0bvWEoVuJ845spZ9Z9IQ',
'ADMIN',
true,
true
FROM tenants WHERE slug = 'aduanasoft-demo';
FROM tenants WHERE slug = 'aduanasoft-demo'
ON CONFLICT (tenant_id, email) DO NOTHING;
-- Categorías por defecto
INSERT INTO ticket_categories (tenant_id, name, description, sla_response_hours, sla_resolution_hours)

View File

@@ -164,6 +164,8 @@ services:
- NODE_ENV=${ENVIRONMENT:-development}
- PUBLIC_API_URL=http://backend:8000
- PUBLIC_APP_NAME=ServiceManager Cliente
- PORT=3000
- HMR_CLIENT_PORT=3000
volumes:
- ./frontend-client:/app
- /app/node_modules
@@ -189,7 +191,8 @@ services:
- NODE_ENV=${ENVIRONMENT:-development}
- PUBLIC_API_URL=http://backend:8000
- PUBLIC_APP_NAME=ServiceManager Admin
- PORT=3000 # El contendor corre en 3000; docker mapea 3001:3000 al host
- PORT=3000
- HMR_CLIENT_PORT=3001
volumes:
- ./frontend-internal:/app
- /app/node_modules

View File

@@ -2,7 +2,7 @@
<html lang="es">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" type="image/png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#3b82f6" />

View File

@@ -29,15 +29,17 @@ const initialState: AppState = {
// API helper function
async function apiCall(endpoint: string, options: RequestInit = {}) {
const authState = get(auth);
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-App': 'client',
...(authState.user?.tenant_id ? { 'X-Tenant-ID': authState.user.tenant_id } : {}),
...(options.headers as Record<string, string> ?? {})
};
if (authState.token) headers['Authorization'] = `Bearer ${authState.token}`;
const response = await fetch(`/api/v1${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authState.token}`,
...(authState.user?.tenant_id ? { 'X-Tenant-ID': authState.user.tenant_id } : {}),
...options.headers
}
credentials: 'include',
headers
});
if (!response.ok) {

View File

@@ -50,26 +50,26 @@ function createAuthStore() {
return {
subscribe,
// Initialize auth from localStorage
init: () => {
// Rehidrata sesión desde cookie HttpOnly (no toca localStorage)
init: async () => {
if (typeof window !== 'undefined') {
const token = localStorage.getItem('auth_token');
const user = localStorage.getItem('auth_user');
if (token && user) {
try {
const parsedUser = JSON.parse(user);
try {
const response = await fetch('/api/v1/auth/me', {
credentials: 'include',
headers: { 'X-App': 'client' }
});
if (response.ok) {
const user = await response.json();
set({
user: parsedUser,
token,
user,
token: null,
isAuthenticated: true,
isLoading: false
});
} catch (error) {
console.error('Error parsing stored auth data:', error);
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_user');
}
// 401/400 es esperado cuando no hay sesión activa — no es un error
} catch (error) {
// Ignorar errores de red en init
}
}
},
@@ -81,6 +81,7 @@ function createAuthStore() {
try {
const response = await fetch('/api/v1/auth/login', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
@@ -94,12 +95,6 @@ function createAuthStore() {
const data: LoginResponse = await response.json();
// Store auth data
if (typeof window !== 'undefined') {
localStorage.setItem('auth_token', data.access_token);
localStorage.setItem('auth_user', JSON.stringify(data.user));
}
set({
user: data.user,
token: data.access_token,
@@ -113,22 +108,24 @@ function createAuthStore() {
},
// Logout
logout: () => {
logout: async () => {
// Llamar al backend para que borre la cookie HttpOnly
try {
await fetch('/api/v1/auth/logout', {
method: 'POST',
credentials: 'include',
headers: { 'X-App': 'client' }
});
} catch { /* ignorar errores de red */ }
set(initialState);
if (typeof window !== 'undefined') {
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_user');
// Immediate redirect after cleanup
window.location.href = '/login';
}
set(initialState);
},
// Update user data
updateUser: (user: User) => {
update(state => ({ ...state, user }));
if (typeof window !== 'undefined') {
localStorage.setItem('auth_user', JSON.stringify(user));
}
},
// Set loading state

View File

@@ -80,18 +80,22 @@ const initialState: TicketsState = {
async function apiCall(endpoint: string, options: RequestInit = {}) {
const authState = get(auth);
if (!authState.token || !authState.user) {
if (!authState.user) {
throw new Error('Not authenticated');
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-App': 'client',
...(options.headers as Record<string, string>)
};
if (authState.token) headers['Authorization'] = `Bearer ${authState.token}`;
if (authState.user.tenant_id) headers['X-Tenant-ID'] = authState.user.tenant_id;
const response = await fetch(`/api/v1${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authState.token}`,
'X-Tenant-ID': authState.user.tenant_id,
...options.headers
}
credentials: 'include',
headers
});
if (!response.ok) {
@@ -259,16 +263,18 @@ function createTicketsStore() {
const authState = get(auth);
if (!authState.token || !authState.user) {
if (!authState.user) {
throw new Error('Not authenticated');
}
const uploadHeaders: Record<string, string> = { 'X-App': 'client' };
if (authState.token) uploadHeaders['Authorization'] = `Bearer ${authState.token}`;
if (authState.user.tenant_id) uploadHeaders['X-Tenant-ID'] = authState.user.tenant_id;
const response = await fetch(`/api/v1/tickets/${ticketId}/attachments`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${authState.token}`,
'X-Tenant-ID': authState.user.tenant_id
},
credentials: 'include',
headers: uploadHeaders,
body: formData
});
@@ -338,16 +344,18 @@ function createTicketsStore() {
downloadAttachment: async (ticketId: string, attachmentId: string, filename: string) => {
const authState = get(auth);
if (!authState.token || !authState.user) {
if (!authState.user) {
throw new Error('Not authenticated');
}
const dlHeaders: Record<string, string> = { 'X-App': 'client' };
if (authState.token) dlHeaders['Authorization'] = `Bearer ${authState.token}`;
if (authState.user.tenant_id) dlHeaders['X-Tenant-ID'] = authState.user.tenant_id;
const response = await fetch(`/api/v1/tickets/${ticketId}/attachments/${attachmentId}/download`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${authState.token}`,
'X-Tenant-ID': authState.user.tenant_id
}
credentials: 'include',
headers: dlHeaders
});
if (!response.ok) {

View File

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

View File

@@ -4,17 +4,35 @@
import Toast from '$lib/components/Toast.svelte';
import { onMount } from 'svelte';
import { auth } from '$lib/stores/auth.js';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { browser } from '$app/environment';
import '../app.css';
onMount(() => {
auth.init();
let mounted = false;
onMount(async () => {
await auth.init();
mounted = true;
});
$: showHeader = !$page.url.pathname.startsWith('/login') && !$page.url.pathname.startsWith('/register');
// Guard reactivo global: redirige a /login si no está autenticado en rutas protegidas
const publicRoutes = ['/login', '/register', '/forgot-password', '/reset-password'];
$: if (browser && mounted && !$auth.isAuthenticated &&
!publicRoutes.some(r => $page.url.pathname.startsWith(r))) {
goto('/login');
}
$: showHeader = !publicRoutes.some(r => $page.url.pathname.startsWith(r));
</script>
<div class="min-h-screen bg-gray-50 font-sans">
{#if !mounted}
<!-- Esperando inicialización de sesión -->
<div class="flex items-center justify-center min-h-screen bg-gray-50">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
{:else}
{#if showHeader}
<Header />
{/if}
@@ -27,6 +45,7 @@
<footer class="py-4 text-center border-t border-gray-200 bg-white">
<p class="text-xs text-gray-400">ServiceManagerWeb v1.9.0 · © 2026 Aduanasoft</p>
</footer>
{/if}
<!-- Toast notifications -->
{#each $toast.toasts as toastMessage (toastMessage.id)}

View File

@@ -216,12 +216,13 @@
>Recordar en este equipo</label
>
</div>
<a
href="/forgot-password"
class="text-sm font-medium text-blue-600 hover:text-blue-500"
<button
type="button"
class="text-sm font-medium text-blue-600 hover:text-blue-500 bg-transparent border-none p-0 cursor-pointer"
on:click={() => goto('/forgot-password')}
>
Olvide mi clave
</a>
</button>
</div>
</div>
{:else}

View File

@@ -38,11 +38,14 @@
async function loadProfile() {
isLoading = true;
try {
const headers: Record<string, string> = {
'X-App': 'client',
'X-Tenant-ID': $auth.user?.tenant_id ?? ''
};
if ($auth.token) headers['Authorization'] = `Bearer ${$auth.token}`;
const response = await fetch('/api/v1/client-profile/', {
headers: {
Authorization: `Bearer ${$auth.token}`,
'X-Tenant-ID': $auth.user?.tenant_id ?? ''
}
credentials: 'include',
headers
});
if (!response.ok) throw new Error((await response.json()).detail);
profile = await response.json();
@@ -62,13 +65,16 @@
async function saveProfile() {
isSaving = true;
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-App': 'client',
'X-Tenant-ID': $auth.user?.tenant_id ?? ''
};
if ($auth.token) headers['Authorization'] = `Bearer ${$auth.token}`;
const response = await fetch('/api/v1/client-profile/', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${$auth.token}`,
'X-Tenant-ID': $auth.user?.tenant_id ?? ''
},
credentials: 'include',
headers,
body: JSON.stringify(form)
});
if (!response.ok) throw new Error((await response.json()).detail);

View File

@@ -34,7 +34,11 @@
try {
const response = await fetch('/api/v1/auth/2fa/setup', {
method: 'POST',
headers: { Authorization: `Bearer ${$auth.token}` }
credentials: 'include',
headers: {
'X-App': 'client',
...($auth.token ? { Authorization: `Bearer ${$auth.token}` } : {})
}
});
if (!response.ok) throw new Error((await response.json()).detail);
const data = await response.json();
@@ -57,7 +61,12 @@
try {
const response = await fetch('/api/v1/auth/2fa/enable', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${$auth.token}` },
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-App': 'client',
...($auth.token ? { Authorization: `Bearer ${$auth.token}` } : {})
},
body: JSON.stringify({ totp_code: totpSetupCode })
});
if (!response.ok) throw new Error((await response.json()).detail);
@@ -84,7 +93,12 @@
try {
const response = await fetch('/api/v1/auth/2fa/disable', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${$auth.token}` },
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-App': 'client',
...($auth.token ? { Authorization: `Bearer ${$auth.token}` } : {})
},
body: JSON.stringify({ totp_code: disableTotpCode })
});
if (!response.ok) throw new Error((await response.json()).detail);
@@ -157,16 +171,20 @@
async function loadBusinessProfile() {
try {
if (!$auth.token || !$auth.user) {
if (!$auth.user) {
console.warn('Usuario no autenticado');
return;
}
const _lpHeaders: Record<string, string> = {
'X-App': 'client',
'X-Tenant-ID': $auth.user.tenant_id
};
if ($auth.token) _lpHeaders['Authorization'] = `Bearer ${$auth.token}`;
const response = await fetch('/api/v1/client-profile/', {
headers: {
Authorization: `Bearer ${$auth.token}`,
'X-Tenant-ID': $auth.user.tenant_id
}
credentials: 'include',
headers: _lpHeaders
});
if (response.ok) {
@@ -267,9 +285,11 @@
try {
const response = await fetch('/api/v1/auth/profile', {
method: 'PATCH',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${$auth.token}`
'X-App': 'client',
...($auth.token ? { Authorization: `Bearer ${$auth.token}` } : {})
},
body: JSON.stringify({
first_name: firstName.trim(),
@@ -300,9 +320,11 @@
try {
const response = await fetch('/api/v1/auth/change-password', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${$auth.token}`
'X-App': 'client',
...($auth.token ? { Authorization: `Bearer ${$auth.token}` } : {})
},
body: JSON.stringify({
current_password: currentPassword,
@@ -349,13 +371,16 @@
profileData.credit_limit = parseFloat(profileData.credit_limit);
}
const _bpHeaders: Record<string, string> = {
'Content-Type': 'application/json',
'X-App': 'client',
'X-Tenant-ID': $auth.user?.tenant_id ?? ''
};
if ($auth.token) _bpHeaders['Authorization'] = `Bearer ${$auth.token}`;
const response = await fetch('/api/v1/client-profile/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${$auth.token}`,
'X-Tenant-ID': $auth.user.tenant_id
},
credentials: 'include',
headers: _bpHeaders,
body: JSON.stringify(profileData)
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -10,6 +10,16 @@ export default defineConfig({
usePolling: true,
interval: 500
},
// HMR: el browser llega al contenedor en el mismo puerto 3000
hmr: {
host: 'localhost',
clientPort: parseInt(process.env.HMR_CLIENT_PORT || '3000')
},
// Permitir que Vite sirva archivos del filesystem del contenedor
fs: {
allow: ['/app', '.'],
strict: false
},
proxy: {
'/api': {
target: process.env.PUBLIC_API_URL || 'http://localhost:8000',

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="description" content="ServiceManager - Mesa de Ayuda Empresarial - Portal Interno" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" type="image/png" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">

View File

@@ -72,6 +72,11 @@
name: 'Seguridad',
href: '/audit/security',
icon: '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'
},
{
name: 'Reporte Endpoints',
href: '/test-report',
icon: 'M9 3H5a2 2 0 00-2 2v4m6-6h10a2 2 0 012 2v4M9 3v18m0 0h10a2 2 0 002-2V9M9 21H5a2 2 0 01-2-2V9m0 0h18'
}
);
}

View File

@@ -60,32 +60,34 @@ const initialState: AuthState = {
function createAuthStore() {
const { subscribe, set, update } = writable<AuthState>(initialState);
// Track current state for uso interno (evita dependencias circulares)
let _state = initialState;
subscribe(s => { _state = s; });
return {
subscribe,
// Initialize auth from localStorage
init: () => {
// Rehidrata sesión desde cookie HttpOnly (no toca localStorage)
init: async () => {
if (typeof window !== 'undefined') {
const token = localStorage.getItem('internal_auth_token');
const refreshToken = localStorage.getItem('internal_auth_refresh_token');
const user = localStorage.getItem('internal_auth_user');
if (token && user) {
try {
const parsedUser = JSON.parse(user);
try {
const response = await fetch('/api/v1/auth/me', {
credentials: 'include',
headers: { 'X-App': 'internal' }
});
if (response.ok) {
const user = await response.json();
set({
user: parsedUser,
token,
refreshToken: refreshToken || null,
user,
token: null,
refreshToken: null,
isAuthenticated: true,
isLoading: false
});
} catch (error) {
console.error('Error parsing stored auth data:', error);
localStorage.removeItem('internal_auth_token');
localStorage.removeItem('internal_auth_refresh_token');
localStorage.removeItem('internal_auth_user');
}
// 401/400 es esperado cuando no hay sesión activa — no es un error
} catch (error) {
// Ignorar errores de red en init
}
}
},
@@ -97,6 +99,7 @@ function createAuthStore() {
try {
const response = await fetch('/api/v1/auth/login', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
@@ -109,15 +112,6 @@ function createAuthStore() {
}
const data: LoginResponse = await response.json();
// Store auth data
if (typeof window !== 'undefined') {
localStorage.setItem('internal_auth_token', data.access_token);
if (data.refresh_token) {
localStorage.setItem('internal_auth_refresh_token', data.refresh_token);
}
localStorage.setItem('internal_auth_user', JSON.stringify(data.user));
}
set({
user: data.user,
@@ -134,21 +128,18 @@ function createAuthStore() {
// Refresh Session
refreshSession: async (): Promise<void> => {
// Need to get current state to access refresh token, logic simplified
let currentRefreshToken: string | null = null;
if (typeof window !== 'undefined') {
currentRefreshToken = localStorage.getItem('internal_auth_refresh_token');
}
const currentRefreshToken = _state.refreshToken;
if (!currentRefreshToken) {
throw new Error("No refresh token available");
}
update (state => ({ ...state, isLoading: true }));
update(state => ({ ...state, isLoading: true }));
try {
const response = await fetch('/api/v1/auth/refresh', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
@@ -166,11 +157,6 @@ function createAuthStore() {
const data: TokenResponse = await response.json();
// Update token in storage and state
if (typeof window !== 'undefined') {
localStorage.setItem('internal_auth_token', data.access_token);
}
update(state => ({
...state,
token: data.access_token,
@@ -184,25 +170,24 @@ function createAuthStore() {
},
// Logout
logout: () => {
if (typeof window !== 'undefined') {
localStorage.removeItem('internal_auth_token');
localStorage.removeItem('internal_auth_refresh_token');
localStorage.removeItem('internal_auth_user');
}
logout: async () => {
// Llamar al backend para que borre la cookie HttpOnly
try {
await fetch('/api/v1/auth/logout', {
method: 'POST',
credentials: 'include',
headers: { 'X-App': 'internal' }
});
} catch { /* ignorar errores de red */ }
set(initialState);
// Optional: Redirect to login
if (typeof window !== 'undefined') {
window.location.href = '/login';
window.location.href = '/login';
}
},
// Update user data
updateUser: (user: InternalUser) => {
update(state => ({ ...state, user }));
if (typeof window !== 'undefined') {
localStorage.setItem('internal_auth_user', JSON.stringify(user));
}
},
// Set loading state

View File

@@ -0,0 +1,161 @@
import { writable } from 'svelte/store';
/** All available dashboard modules */
export interface DashboardModule {
id: string;
title: string;
description: string;
icon: string;
href: string;
color: string;
/** Minimum role required to see this module */
roles: string[];
}
export const ALL_MODULES: DashboardModule[] = [
{
id: 'tenants',
title: 'Clientes',
description: 'Gestión de organizaciones y tenants',
icon: 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4',
href: '/tenants',
color: 'bg-blue-600',
roles: ['ADMIN', 'SUPPORT_MANAGER']
},
{
id: 'users',
title: 'Usuarios',
description: 'Administración de usuarios y roles',
icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z',
href: '/users',
color: 'bg-green-600',
roles: ['ADMIN', 'SUPPORT_MANAGER']
},
{
id: 'tickets',
title: 'Tickets',
description: 'Gestión y seguimiento de tickets de soporte',
icon: 'M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2',
href: '/tickets',
color: 'bg-indigo-600',
roles: ['ADMIN', 'SUPPORT_MANAGER', 'AGENT']
},
{
id: 'systems',
title: 'Sistemas',
description: 'Catálogo de sistemas soportados',
icon: 'M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01',
href: '/systems',
color: 'bg-gray-700',
roles: ['ADMIN', 'SUPPORT_MANAGER']
},
{
id: 'categories',
title: 'Categorías',
description: 'Clasificación de tickets por área',
icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10',
href: '/categories',
color: 'bg-orange-600',
roles: ['ADMIN', 'SUPPORT_MANAGER']
},
{
id: 'sla',
title: 'SLA Management',
description: 'Monitoreo de tiempos de respuesta y SLAs',
icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z',
href: '/sla',
color: 'bg-teal-600',
roles: ['ADMIN', 'SUPPORT_MANAGER']
},
{
id: 'reports',
title: 'Reportes',
description: 'Informes estadísticos y análisis de rendimiento',
icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z',
href: '/reports',
color: 'bg-purple-600',
roles: ['ADMIN', 'SUPPORT_MANAGER']
},
{
id: 'audit',
title: 'Auditoría',
description: 'Bitácora de acciones y trazabilidad del sistema',
icon: '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',
href: '/audit',
color: 'bg-red-700',
roles: ['ADMIN', 'AUDITOR']
},
{
id: 'security',
title: 'Seguridad',
description: 'Análisis de amenazas y eventos de seguridad',
icon: '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',
href: '/audit/security',
color: 'bg-yellow-600',
roles: ['ADMIN']
},
{
id: 'endpoints',
title: 'Reporte de Endpoints',
description: 'Estado y diagnóstico de todos los endpoints API',
icon: 'M9 3H5a2 2 0 00-2 2v4m6-6h10a2 2 0 012 2v4M9 3v18m0 0h10a2 2 0 002-2V9M9 21H5a2 2 0 01-2-2V9m0 0h18',
href: '/test-report',
color: 'bg-cyan-600',
roles: ['ADMIN']
}
];
const STORAGE_KEY = 'dashboard_module_visibility';
function getInitialVisibility(): Record<string, boolean> {
if (typeof window === 'undefined') {
return Object.fromEntries(ALL_MODULES.map(m => [m.id, true]));
}
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) return JSON.parse(stored);
} catch { /* ignore */ }
return Object.fromEntries(ALL_MODULES.map(m => [m.id, true]));
}
function createDashboardConfig() {
const { subscribe, set, update } = writable<Record<string, boolean>>(getInitialVisibility());
return {
subscribe,
toggle(id: string) {
update(state => {
const next = { ...state, [id]: !state[id] };
if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
}
return next;
});
},
setVisible(id: string, visible: boolean) {
update(state => {
const next = { ...state, [id]: visible };
if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
}
return next;
});
},
showAll() {
const all = Object.fromEntries(ALL_MODULES.map(m => [m.id, true]));
if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
}
set(all);
},
reset() {
const defaults = Object.fromEntries(ALL_MODULES.map(m => [m.id, true]));
if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_KEY, JSON.stringify(defaults));
}
set(defaults);
}
};
}
export const dashboardConfig = createDashboardConfig();

View File

@@ -24,16 +24,8 @@ async function request<T>(endpoint: string, options: RequestOptions = {}): Promi
}
const authState = get(auth);
const token = authState.token || (typeof window !== 'undefined' ? localStorage.getItem('internal_auth_token') : null);
// Resolve tenant_id from store or from the persisted user object in localStorage
let tenantId = authState.user?.tenant_id ?? null;
if (!tenantId && typeof window !== 'undefined') {
try {
const stored = localStorage.getItem('internal_auth_user');
if (stored) tenantId = JSON.parse(stored)?.tenant_id ?? null;
} catch { /* ignore */ }
}
const token = authState.token;
const tenantId = authState.user?.tenant_id ?? null;
const headers = new Headers(init.headers);
if (token) {
@@ -45,17 +37,18 @@ async function request<T>(endpoint: string, options: RequestOptions = {}): Promi
if (!headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
// Identifica este frontend para que el backend use la cookie correcta
headers.set('X-App', 'internal');
const response = await fetch(url, {
...init,
credentials: 'include',
headers
});
if (response.status === 401) {
// Token expired or invalid
if (typeof window !== 'undefined') {
localStorage.removeItem('internal_auth_token');
localStorage.removeItem('internal_auth_user');
window.location.href = '/login';
}
throw new Error('Unauthorized');
@@ -76,15 +69,8 @@ async function request<T>(endpoint: string, options: RequestOptions = {}): Promi
async function downloadFile(endpoint: string, filename: string): Promise<void> {
const authState = get(auth);
const token = authState.token || (typeof window !== 'undefined' ? localStorage.getItem('internal_auth_token') : null);
let tenantId = authState.user?.tenant_id ?? null;
if (!tenantId && typeof window !== 'undefined') {
try {
const stored = localStorage.getItem('internal_auth_user');
if (stored) tenantId = JSON.parse(stored)?.tenant_id ?? null;
} catch { /* ignore */ }
}
const token = authState.token;
const tenantId = authState.user?.tenant_id ?? null;
const headers = new Headers();
if (token) {
@@ -93,16 +79,16 @@ async function downloadFile(endpoint: string, filename: string): Promise<void> {
if (tenantId) {
headers.set('X-Tenant-ID', tenantId);
}
headers.set('X-App', 'internal');
const response = await fetch(`${API_BASE}${endpoint}`, {
method: 'GET',
credentials: 'include',
headers
});
if (response.status === 401) {
if (typeof window !== 'undefined') {
localStorage.removeItem('internal_auth_token');
localStorage.removeItem('internal_auth_user');
window.location.href = '/login';
}
throw new Error('Unauthorized');

View File

@@ -5,21 +5,36 @@
import { toast } from '$lib/stores/toast.js';
import { onMount } from 'svelte';
import { auth } from '$lib/stores/auth.js';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { browser } from '$app/environment';
import '../app.css';
let sidebarOpen = false;
let mounted = false;
onMount(() => {
auth.init();
onMount(async () => {
await auth.init();
mounted = true;
});
// Guard reactivo global: redirige a /login si no está autenticado
$: if (browser && mounted && !$auth.isAuthenticated && $page.url.pathname !== '/login') {
goto('/login');
}
function toggleSidebar() {
sidebarOpen = !sidebarOpen;
}
</script>
<div class="min-h-screen bg-gray-50">
{#if $auth.isAuthenticated}
{#if !mounted}
<!-- Esperando inicialización de sesión -->
<div class="flex items-center justify-center min-h-screen bg-gray-50">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
{:else if $auth.isAuthenticated}
<!-- Internal Layout with Sidebar -->
<div class="flex h-screen overflow-hidden">
<!-- Sidebar -->

View File

@@ -2,44 +2,27 @@
import { onMount } from 'svelte';
import { auth } from '$lib/stores/auth.js';
import { goto } from '$app/navigation';
import Icon from '$lib/components/Icon.svelte';
import { dashboardConfig, ALL_MODULES, type DashboardModule } from '$lib/stores/dashboardConfig.js';
let showSettings = false;
onMount(() => {
if (!$auth.isAuthenticated) {
goto('/login');
}
});
const cards = [
{
title: 'Clientes',
description: 'Gestión de organizaciones y tenants',
icon: 'users',
href: '/tenants',
color: 'bg-blue-600'
},
{
title: 'Usuarios',
description: 'Administración de usuarios y roles',
icon: 'user-plus',
href: '/users',
color: 'bg-green-600'
},
{
title: 'Sistemas',
description: 'Catálogo de sistemas soportados',
icon: 'server',
href: '/systems',
color: 'bg-gray-700'
},
{
title: 'Categorías',
description: 'Clasificación de tickets',
icon: 'tag',
href: '/categories',
color: 'bg-orange-600'
}
];
const role = $auth.user?.role ?? '';
/** Only modules the current role can access */
$: accessibleModules = ALL_MODULES.filter(m => m.roles.includes(role) || role === 'ADMIN');
/** Modules that are visible (enabled by user + accessible by role) */
$: visibleModules = accessibleModules.filter(m => $dashboardConfig[m.id] !== false);
function toggleSettings() {
showSettings = !showSettings;
}
</script>
<svelte:head>
@@ -47,40 +30,106 @@
</svelte:head>
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
<!-- Header -->
<div class="md:flex md:items-center md:justify-between">
<div class="flex-1 min-w-0">
<h2 class="text-2xl font-bold leading-7 text-gray-900 sm:text-3xl sm:truncate">
Panel de Administración
</h2>
<p class="mt-1 text-sm text-gray-500">
Bienvenido al sistema de gestión interna.
Bienvenido al sistema de gestión interna.
</p>
</div>
<div class="mt-4 flex md:mt-0 md:ml-4 gap-2">
<button
on:click={toggleSettings}
class="inline-flex items-center gap-1.5 px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Configurar
</button>
</div>
</div>
<div class="mt-8 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
{#each cards as card}
<a href={card.href} class="bg-white overflow-hidden shadow rounded-lg hover:shadow-md transition-shadow duration-200 cursor-pointer group">
<div class="p-5">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">
{card.title}
</dt>
<dd>
<div class="text-xs text-gray-900 font-light mt-1">
{card.description}
</div>
</dd>
</dl>
<!-- Settings Panel -->
{#if showSettings}
<div class="mt-6 bg-white border border-gray-200 rounded-lg shadow-sm p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-semibold text-gray-900">Módulos visibles en el dashboard</h3>
<div class="flex gap-2">
<button
on:click={() => dashboardConfig.showAll()}
class="text-xs text-blue-600 hover:text-blue-800 underline"
>
Mostrar todos
</button>
</div>
<div class="bg-gray-50 px-5 py-3">
<div class="text-sm">
<span class="font-medium text-blue-700 hover:text-blue-900">
Ver detalles
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{#each accessibleModules as mod}
<label class="flex items-center gap-2 p-3 border rounded-lg cursor-pointer hover:bg-gray-50 {$dashboardConfig[mod.id] !== false ? 'border-blue-300 bg-blue-50' : 'border-gray-200'}">
<input
type="checkbox"
checked={$dashboardConfig[mod.id] !== false}
on:change={() => dashboardConfig.toggle(mod.id)}
class="rounded text-blue-600 focus:ring-blue-500"
/>
<div class="min-w-0">
<div class="flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full {mod.color} flex-shrink-0"></span>
<span class="text-sm font-medium text-gray-800 truncate">{mod.title}</span>
</div>
</div>
</label>
{/each}
</div>
<p class="mt-3 text-xs text-gray-400">Las preferencias se guardan automáticamente en este navegador.</p>
</div>
{/if}
<!-- Module Cards -->
{#if visibleModules.length === 0}
<div class="mt-10 text-center py-16 bg-white rounded-lg border-2 border-dashed border-gray-200">
<svg class="mx-auto h-10 w-10 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
<p class="mt-3 text-sm text-gray-500">No hay módulos visibles.</p>
<button on:click={() => dashboardConfig.showAll()} class="mt-3 text-sm text-blue-600 hover:underline">
Restaurar todos los módulos
</button>
</div>
{:else}
<div class="mt-8 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{#each visibleModules as mod}
<a
href={mod.href}
class="bg-white overflow-hidden shadow rounded-lg hover:shadow-md transition-all duration-200 cursor-pointer group flex flex-col"
>
<div class="p-5 flex-1">
<div class="flex items-center gap-3 mb-2">
<div class="w-9 h-9 rounded-lg {mod.color} flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={mod.icon} />
</svg>
</div>
<span class="text-sm font-semibold text-gray-800 group-hover:text-blue-700 transition-colors">
{mod.title}
</span>
</div>
<p class="text-xs text-gray-500 leading-relaxed">{mod.description}</p>
</div>
<div class="bg-gray-50 px-5 py-2.5 border-t border-gray-100">
<span class="text-xs font-medium text-blue-600 group-hover:text-blue-800 transition-colors">
Abrir módulo →
</span>
</div>
</div>
</a>
{/each}
</div>
</a>
{/each}
</div>
{/if}
</div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -4,13 +4,23 @@ import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
server: {
// Puerto: 3001 por defecto en local; Docker lo sobreescribe con PORT=3000
// Puerto: dentro del contenedor siempre 3000; Docker mapea 3001:3000 al host
port: parseInt(process.env.PORT || '3001'),
host: '0.0.0.0',
watch: {
usePolling: true,
interval: 500
},
// HMR: el browser llega al contenedor a través del puerto 3001 del host
hmr: {
host: 'localhost',
clientPort: parseInt(process.env.HMR_CLIENT_PORT || '3001')
},
// Permitir que Vite sirva archivos del filesystem del contenedor
fs: {
allow: ['/app', '.'],
strict: false
},
proxy: {
'/api': {
target: process.env.PUBLIC_API_URL || 'http://localhost:8000',
@@ -23,7 +33,7 @@ export default defineConfig({
port: parseInt(process.env.PORT || '3001'),
host: '0.0.0.0'
},
build: {
target: 'esnext'
}
build: {
target: 'esnext'
}
});

View File

@@ -216,15 +216,18 @@ async def main():
if user_data["email"] in existing_emails:
print(f" ⏭ Ya existe: {user_data['email']}")
continue
pwd = user_data.pop("password")
# Usar copia para no mutar el dict original (permite re-ejecutar el script)
ud = user_data.copy()
pwd = ud.pop("password")
hashed_pwd = security.hash_password(pwd)
user = User(
tenant_id=tenant_id,
password_hash=hashed_pwd,
**user_data,
email_verified=True, # Marcar como verificado para permitir login
**ud,
)
session.add(user)
print(f"{user_data['email']} [{user_data['role'].value}] pwd={pwd}")
print(f"{ud['email']} [{ud['role'].value}] pwd={pwd}")
created_users += 1
await session.commit()

View File

@@ -7,11 +7,42 @@ Async database session management para Celery workers
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy import DateTime, func
from sqlalchemy import types as sa_types
from sqlalchemy.types import TypeDecorator, CHAR
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from contextlib import asynccontextmanager
from typing import AsyncGenerator
import uuid
from datetime import datetime
class GUID(TypeDecorator):
"""UUID portable: UUID nativo en Postgres, CHAR(36) en otros dialectos (SQLite para tests)."""
impl = CHAR
cache_ok = True
def load_dialect_impl(self, dialect):
if dialect.name == "postgresql":
return dialect.type_descriptor(PG_UUID(as_uuid=True))
return dialect.type_descriptor(CHAR(36))
def process_bind_param(self, value, dialect):
if value is None:
return None
if dialect.name == "postgresql":
return value
if isinstance(value, uuid.UUID):
return str(value)
return str(uuid.UUID(str(value)))
def process_result_value(self, value, dialect):
if value is None:
return None
if not isinstance(value, uuid.UUID):
return uuid.UUID(str(value))
return value
from app.core.config import get_settings
settings = get_settings()