feat: improved sidebar UI with larger icons and user info in footer

- Increased all sidebar navigation icons from text-sm to text-xl for better visibility
- Moved user information and logout button from header to sidebar footer
- Added user avatar with name and email in expanded sidebar state
- Collapsed sidebar shows avatar icon and logout icon button stacked vertically
- Simplified header by removing user badge and logout button
- Added version info (v1.0.0) in sidebar footer
- Improved responsive design for both collapsed and expanded states
This commit is contained in:
2026-02-19 12:08:56 -06:00
parent 7e7773578d
commit 5fa97105c9
33 changed files with 6160 additions and 912 deletions

View File

@@ -1,196 +1,8 @@
:root {
/* Google Material Colors */
--md-primary: #1a73e8;
--md-primary-hover: #1557b0;
--md-secondary: #5f6368;
--md-success: #1e8e3e;
--md-danger: #d93025;
--md-warning: #f9ab00;
--md-bg: #f8f9fa; /* Light gray background */
--md-surface: #ffffff;
--md-text-primary: #202124;
--md-text-secondary: #5f6368;
/* Elevation Shadows */
--shadow-1: 0 1px 2px 0 rgba(60,64,67,0.3), 0 1px 3px 1px rgba(60,64,67,0.15);
--shadow-2: 0 1px 3px 0 rgba(60,64,67,0.3), 0 4px 8px 3px rgba(60,64,67,0.15);
--border-radius: 8px;
--transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
font-family: 'Inter', Roboto, sans-serif;
background-color: var(--md-bg);
color: var(--md-text-primary);
min-height: 100vh;
}
h1, h2, h3, h4, h5, h6 {
color: var(--md-text-primary);
font-weight: 500;
}
/* Navbar */
.md-navbar {
background: var(--md-surface);
height: 64px;
box-shadow: var(--shadow-1);
z-index: 1040;
}
.brand-text {
font-size: 1.25rem;
font-weight: 500;
color: var(--md-text-secondary);
}
.brand-logo {
color: var(--md-primary);
}
/* Sidebar */
.md-sidebar {
background: var(--md-surface);
width: 260px;
height: 100vh;
position: fixed;
top: 0;
left: 0;
z-index: 1030;
padding-top: 64px; /* Matches navbar height */
border-right: none; /* Shadow handles separation */
box-shadow: 1px 0 0 rgba(0,0,0,0.12); /* Subtle divider */
transition: transform 0.3s ease;
}
.md-nav-link {
display: flex;
align-items: center;
padding: 0 24px;
height: 48px;
color: var(--md-text-secondary);
text-decoration: none;
font-weight: 500;
font-size: 0.875rem;
border-radius: 0 24px 24px 0; /* Material pill shape on one side or full pill */
margin: 4px 12px 4px 0;
border: none;
background: transparent;
width: 100%;
text-align: left;
cursor: pointer;
transition: background 0.2s;
}
.md-nav-link:hover {
background-color: #f1f3f4;
color: var(--md-text-primary);
}
.md-nav-link.active {
background-color: #e8f0fe;
color: var(--md-primary);
}
.md-nav-icon {
margin-right: 16px;
font-size: 24px;
}
/* Main Content */
.md-content-wrapper {
margin-left: 260px;
padding: 24px;
padding-top: 88px; /* 64px navbar + 24px spacing */
min-height: 100vh;
transition: margin-left 0.3s ease;
}
/* Cards */
.md-card {
background: var(--md-surface);
border-radius: var(--border-radius);
padding: 24px;
box-shadow: var(--shadow-1);
margin-bottom: 24px;
border: none;
}
.md-card-title {
font-size: 1.125rem;
color: var(--md-text-primary);
margin-bottom: 24px;
}
/* Metrics */
.md-metric-card {
background: var(--md-surface);
border-radius: var(--border-radius);
padding: 24px;
box-shadow: var(--shadow-1);
display: flex;
flex-direction: column;
height: 100%;
}
.md-metric-value {
font-size: 2.25rem;
font-weight: 400;
color: var(--md-text-primary);
margin: 8px 0;
}
.md-metric-label {
color: var(--md-text-secondary);
font-size: 0.875rem;
}
.md-metric-icon {
color: var(--md-primary);
font-size: 32px;
}
/* Tables */
table.dataTable {
width: 100% !important;
border-collapse: collapse !important;
}
table.dataTable thead th {
border-bottom: 1px solid rgba(0,0,0,0.12) !important;
color: var(--md-text-secondary);
font-weight: 500;
font-size: 0.75rem;
text-transform: uppercase;
padding: 16px !important;
}
table.dataTable tbody td {
padding: 16px !important;
border-bottom: 1px solid rgba(0,0,0,0.06);
color: var(--md-text-primary);
font-size: 0.875rem;
}
/* Responsive */
@media (max-width: 992px) {
.md-sidebar {
transform: translateX(-100%);
}
.md-sidebar.show {
transform: translateX(0);
box-shadow: var(--shadow-2);
}
.md-content-wrapper {
margin-left: 0;
}
@apply bg-slate-100 text-slate-900 min-h-screen antialiased;
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}

View File

@@ -2,42 +2,16 @@
<html lang="es">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/png" href="/aduanasoft-icon.png" />
<meta name="viewport" content="width=device-width" />
<!-- Fuentes Premium -->
<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@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<!-- CSS Frameworks -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.7/css/dataTables.bootstrap5.min.css">
<link rel="stylesheet" href="https://cdn.datatables.net/buttons/2.4.2/css/buttons.bootstrap5.min.css">
<link rel="stylesheet" href="https://cdn.datatables.net/responsive/2.5.0/css/responsive.bootstrap5.min.css">
<!-- Iconos -->
<link href="https://fonts.googleapis.com/icon?family=Material+Icons+Outlined" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<!-- Animaciones -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/aos@2.3.4/dist/aos.css">
<!-- Scripts (DataTables requires jQuery) -->
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/aos@2.3.4/dist/aos.js"></script>
<!-- DataTables Scripts -->
<script src="https://cdn.datatables.net/1.13.7/js/jquery.dataTables.min.js"></script>
<script src="https://cdn.datatables.net/1.13.7/js/dataTables.bootstrap5.min.js"></script>
<script src="https://cdn.datatables.net/buttons/2.4.2/js/dataTables.buttons.min.js"></script>
<script src="https://cdn.datatables.net/buttons/2.4.2/js/buttons.bootstrap5.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<script src="https://cdn.datatables.net/buttons/2.4.2/js/buttons.html5.min.js"></script>
<script src="https://cdn.datatables.net/buttons/2.4.2/js/buttons.print.min.js"></script>
<script src="https://cdn.datatables.net/responsive/2.5.0/js/dataTables.responsive.min.js"></script>
<script src="https://cdn.datatables.net/responsive/2.5.0/js/responsive.bootstrap5.min.js"></script>
<!-- Fuentes -->
<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@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<!-- Iconos Material -->
<link href="https://fonts.googleapis.com/icon?family=Material+Icons+Outlined" rel="stylesheet">
%sveltekit.head%
</head>

BIN
src/lib/assets/icono.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
src/lib/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -2,36 +2,42 @@
import { page } from '$app/stores';
</script>
<nav class="navbar navbar-expand-lg md-navbar position-fixed w-100 top-0">
<div class="container-fluid px-4">
<button class="btn sidebar-toggle d-lg-none me-3" type="button" onclick={() => document.getElementById('sidebar')?.classList.toggle('show')}>
<i class="bi bi-list fs-4"></i>
</button>
<a class="navbar-brand d-flex align-items-center" href="/">
<div class="md-nav-icon me-3 brand-logo" style="width: 40px; height: 40px; font-size: 1.2rem; display: flex; align-items: center; justify-content: center;">
<i class="material-icons-outlined">storage</i>
<nav class="fixed inset-x-0 top-0 z-40 border-b border-slate-200 bg-white/90 backdrop-blur">
<div class="mx-auto flex h-14 max-w-6xl items-center justify-between px-4 md:px-6">
<div class="flex items-center gap-3">
<button
type="button"
class="inline-flex items-center justify-center rounded-lg border border-slate-200 bg-white px-2 py-1 text-slate-700 shadow-sm hover:bg-slate-50 md:hidden"
onclick={() => document.getElementById('sidebar')?.classList.toggle('hidden')}
>
<span class="material-icons-outlined text-base">menu</span>
</button>
<a href="/" class="flex items-center gap-2">
<div
class="flex h-9 w-9 items-center justify-center rounded-lg bg-slate-900 text-slate-50 shadow-sm"
>
<span class="material-icons-outlined text-base">storage</span>
</div>
<div class="hidden flex-col md:flex">
<span class="text-sm font-semibold text-slate-900">Database Management System</span>
<span class="text-[0.7rem] text-slate-500">TransmitirAS</span>
</div>
</a>
</div>
<div class="flex items-center gap-3 text-sm">
<div class="hidden items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs text-slate-600 shadow-sm md:flex">
<span class="material-icons-outlined text-xs">person</span>
<span>Admin</span>
</div>
<span class="brand-text d-none d-sm-block">Database Management System</span>
</a>
<div class="navbar-nav ms-auto d-flex align-items-center">
<div class="nav-item dropdown me-3">
<a class="nav-link dropdown-toggle px-3" href="#" id="userDropdown" role="button" data-bs-toggle="dropdown">
<i class="bi bi-person-circle me-2"></i>
<span class="d-none d-md-inline">Admin</span>
</a>
<ul class="dropdown-menu dropdown-menu-end md-card border-0 p-2">
<li><a class="dropdown-item rounded" href="#"><i class="bi bi-person me-2"></i>Perfil</a></li>
<li><a class="dropdown-item rounded" href="#"><i class="bi bi-gear me-2"></i>Configuración</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item text-danger rounded" href="/logout"><i class="bi bi-box-arrow-right me-2"></i>Cerrar Sesión</a></li>
</ul>
</div>
<a href="/logout" class="btn btn-primary d-flex align-items-center">
<i class="bi bi-box-arrow-right me-2"></i>
<span class="d-none d-md-inline">Salir</span>
<a
href="/logout"
class="inline-flex items-center gap-1 rounded-full bg-slate-900 px-3 py-1.5 text-xs font-medium text-slate-50 shadow-sm hover:bg-slate-800"
>
<span class="material-icons-outlined text-xs">logout</span>
<span class="hidden md:inline">Salir</span>
</a>
</div>
</div>

View File

@@ -10,70 +10,106 @@
}
</script>
<aside class="md-sidebar" id="sidebar">
<div class="px-3 py-4">
<div class="text-center mb-4 d-flex align-items-center px-3">
<div style="font-size: 2rem; color: var(--md-primary)" class="me-2">
<i class="material-icons-outlined">dashboard</i>
<aside
id="sidebar"
class="hidden md:flex md:flex-col md:w-64 fixed inset-y-0 left-0 bg-white border-r border-slate-200 z-30"
>
<div class="flex flex-col h-full px-4 py-4">
<!-- Brand -->
<div class="flex items-center gap-2 mb-6 px-1">
<div
class="inline-flex h-9 w-9 items-center justify-center rounded-lg bg-slate-900 text-slate-100 text-xl shadow-sm"
>
<span class="material-icons-outlined text-base">dashboard</span>
</div>
<div class="text-start">
<h4 class="brand-text mb-0" style="font-size: 1.1rem; color: var(--md-text-primary)">TransmitirAS</h4>
<p class="text-muted small mb-0" style="font-size: 0.75rem">Management System</p>
<div class="flex flex-col">
<span class="text-sm font-semibold text-slate-900">TransmitirAS</span>
<span class="text-[0.7rem] text-slate-500">Management System</span>
</div>
</div>
<nav class="nav flex-column">
<button class="md-nav-link {activeSection === 'summary' ? 'active' : ''}" onclick={() => setActive('summary')}>
<i class="bi bi-speedometer2 md-nav-icon"></i>
<span>Panel Principal</span>
</button>
<button class="md-nav-link {activeSection === 'backups' ? 'active' : ''}" onclick={() => setActive('backups')}>
<i class="bi bi-hdd-network md-nav-icon"></i>
<span>Respaldos Almacenados</span>
<!-- Nav -->
<nav class="flex-1 space-y-1 text-sm">
<button
class={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left transition hover:bg-slate-100 ${
activeSection === 'summary'
? 'bg-slate-900 text-slate-50'
: 'text-slate-600'
}`}
onclick={() => setActive('summary')}
>
<span class="material-icons-outlined text-base">dashboard</span>
<span class="text-xs font-medium">Panel Principal</span>
</button>
<button class="md-nav-link {activeSection === 'clients' ? 'active' : ''}" onclick={() => setActive('clients')}>
<i class="bi bi-people md-nav-icon"></i>
<span>Catálogo de Clientes</span>
<button
class={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left transition hover:bg-slate-100 ${
activeSection === 'backups'
? 'bg-slate-900 text-slate-50'
: 'text-slate-600'
}`}
onclick={() => setActive('backups')}
>
<span class="material-icons-outlined text-base">backup</span>
<span class="text-xs font-medium">Respaldos Almacenados</span>
</button>
<button class="md-nav-link {activeSection === 'alerts' ? 'active' : ''}" onclick={() => setActive('alerts')}>
<i class="bi bi-exclamation-triangle md-nav-icon"></i>
<span>Alertas Críticas</span>
<button
class={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left transition hover:bg-slate-100 ${
activeSection === 'clients'
? 'bg-slate-900 text-slate-50'
: 'text-slate-600'
}`}
onclick={() => setActive('clients')}
>
<span class="material-icons-outlined text-base">people</span>
<span class="text-xs font-medium">Catálogo de Clientes</span>
</button>
<button class="md-nav-link {activeSection === 'azure' ? 'active' : ''}" onclick={() => setActive('azure')}>
<i class="bi bi-cloud md-nav-icon"></i>
<span>Azure Cloud</span>
<button
class={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left transition hover:bg-slate-100 ${
activeSection === 'alerts'
? 'bg-slate-900 text-slate-50'
: 'text-slate-600'
}`}
onclick={() => setActive('alerts')}
>
<span class="material-icons-outlined text-base">warning</span>
<span class="text-xs font-medium">Alertas Críticas</span>
</button>
<button class="md-nav-link {activeSection === 'databases' ? 'active' : ''}" onclick={() => setActive('databases')}>
<i class="bi bi-database md-nav-icon"></i>
<span>Gestión de Bases de Datos</span>
<button
class={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left transition hover:bg-slate-100 ${
activeSection === 'azure'
? 'bg-slate-900 text-slate-50'
: 'text-slate-600'
}`}
onclick={() => setActive('azure')}
>
<span class="material-icons-outlined text-base">cloud</span>
<span class="text-xs font-medium">Azure Cloud</span>
</button>
<button
class={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left transition hover:bg-slate-100 ${
activeSection === 'databases'
? 'bg-slate-900 text-slate-50'
: 'text-slate-600'
}`}
onclick={() => setActive('databases')}
>
<span class="material-icons-outlined text-base">dns</span>
<span class="text-xs font-medium">Gestión de Bases de Datos</span>
</button>
</nav>
<div class="mt-5 px-3">
<h6 class="text-uppercase text-muted small fw-bold mb-3 ms-2">Configuración</h6>
<button class="md-nav-link" onclick={() => console.log('Config')}>
<i class="bi bi-gear md-nav-icon"></i>
<span>Configuración</span>
</button>
<a href="/api-docs" class="md-nav-link text-decoration-none">
<i class="bi bi-code-square md-nav-icon"></i>
<span>API</span>
</a>
</div>
<div class="mt-auto px-4 pt-4 text-center">
<div class="d-flex align-items-center justify-content-center text-success mb-2">
<i class="bi bi-shield-check me-2"></i>
<small class="fw-bold">Sistema Seguro</small>
</div>
<div class="text-muted small" style="font-size: 0.7rem;">
{new Date().toLocaleDateString()}
<!-- Footer -->
<div class="mt-6 border-t border-slate-200 pt-3 text-center text-[0.7rem] text-slate-500">
<div class="flex items-center justify-center gap-1 mb-1 text-emerald-600">
<span class="material-icons-outlined text-xs">shield</span>
<span class="font-medium">Sistema Seguro</span>
</div>
<div>{new Date().toLocaleDateString()}</div>
</div>
</div>
</aside>

87
src/lib/server/auth.ts Normal file
View File

@@ -0,0 +1,87 @@
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { env } from '$env/dynamic/private';
const JWT_SECRET = env.JWT_SECRET || 'change-this-secret-in-production-please';
const JWT_EXPIRES_IN = '7d'; // 7 días
export interface Usuario {
id: number;
username: string;
email: string;
nombre_completo: string;
activo: boolean;
es_admin: boolean;
}
export interface SessionPayload {
userId: number;
username: string;
es_admin: boolean;
}
/**
* Hash de contraseña usando bcrypt
*/
export async function hashPassword(password: string): Promise<string> {
const saltRounds = 10;
return bcrypt.hash(password, saltRounds);
}
/**
* Verificar contraseña
*/
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
try {
return await bcrypt.compare(password, hash);
} catch (error) {
console.error('Error verificando contraseña:', error);
return false;
}
}
/**
* Crear token JWT
*/
export function createToken(payload: SessionPayload): string {
return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
}
/**
* Verificar y decodificar token JWT
*/
export function verifyToken(token: string): SessionPayload | null {
try {
return jwt.verify(token, JWT_SECRET) as SessionPayload;
} catch (error) {
console.error('Error verificando token:', error);
return null;
}
}
/**
* Validar fuerza de contraseña
*/
export function validatePassword(password: string): { valid: boolean; message?: string } {
if (password.length < 8) {
return { valid: false, message: 'La contraseña debe tener al menos 8 caracteres' };
}
if (!/[A-Z]/.test(password)) {
return { valid: false, message: 'La contraseña debe contener al menos una mayúscula' };
}
if (!/[a-z]/.test(password)) {
return { valid: false, message: 'La contraseña debe contener al menos una minúscula' };
}
if (!/[0-9]/.test(password)) {
return { valid: false, message: 'La contraseña debe contener al menos un número' };
}
if (!/[!@#$%^&*(),.?":{}|<>]/.test(password)) {
return { valid: false, message: 'La contraseña debe contener al menos un carácter especial' };
}
return { valid: true };
}

View File

@@ -1,14 +1,28 @@
import sql from 'mssql';
import pkg from 'pg';
const { Pool } = pkg;
import { env } from '$env/dynamic/private';
// Pool de PostgreSQL para usuarios y permisos
const pgPool = new Pool({
host: env.DB_POSTGRES_HOST || '10.0.20.152',
port: parseInt(env.DB_POSTGRES_PORT || '5432'),
database: env.DB_POSTGRES_DB || 'CONTROLDESK',
user: env.DB_POSTGRES_USER || 'postgres',
password: env.DB_POSTGRES_PASS || 'Control.',
max: 10,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000
});
const primaryConfig: sql.config = {
user: env.DB_PRIMARY_USER,
password: env.DB_PRIMARY_PASS,
server: env.DB_PRIMARY_HOST,
database: env.DB_PRIMARY_DB,
options: {
encrypt: true, // For Azure/Remote
trustServerCertificate: true // Self-signed certs
encrypt: true,
trustServerCertificate: true
}
};
@@ -56,7 +70,11 @@ class Database {
this.azurePool = await new sql.ConnectionPool(azureConfig).connect();
return this.azurePool;
}
async getPostgres() {
return pgPool.connect();
}
}
export const db = new Database();
export { sql };
export { sql, pgPool };

312
src/lib/server/users.ts Normal file
View File

@@ -0,0 +1,312 @@
import { pgPool } from './db';
import { hashPassword, verifyPassword, type Usuario } from './auth';
import type { PoolClient } from 'pg';
/**
* Autenticar usuario por username y password
*/
export async function authenticateUser(username: string, password: string): Promise<Usuario | null> {
const client = await pgPool.connect();
try {
const result = await client.query(
'SELECT id, username, email, password_hash, nombre_completo, activo, es_admin FROM usuarios WHERE username = $1 AND activo = true',
[username]
);
if (result.rows.length === 0) {
return null;
}
const user = result.rows[0];
const isValid = await verifyPassword(password, user.password_hash);
if (!isValid) {
return null;
}
// Actualizar último acceso
await client.query(
'UPDATE usuarios SET ultimo_acceso = CURRENT_TIMESTAMP WHERE id = $1',
[user.id]
);
return {
id: user.id,
username: user.username,
email: user.email,
nombre_completo: user.nombre_completo,
activo: user.activo,
es_admin: user.es_admin
};
} finally {
client.release();
}
}
/**
* Obtener usuario por ID
*/
export async function getUserById(userId: number): Promise<Usuario | null> {
const client = await pgPool.connect();
try {
const result = await client.query(
'SELECT id, username, email, nombre_completo, activo, es_admin FROM usuarios WHERE id = $1',
[userId]
);
if (result.rows.length === 0) {
return null;
}
return result.rows[0];
} finally {
client.release();
}
}
/**
* Crear nuevo usuario
*/
export async function createUser(data: {
username: string;
email: string;
password: string;
nombre_completo?: string;
es_admin?: boolean;
}): Promise<Usuario> {
const client = await pgPool.connect();
try {
const passwordHash = await hashPassword(data.password);
const result = await client.query(
`INSERT INTO usuarios (username, email, password_hash, nombre_completo, es_admin)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, username, email, nombre_completo, activo, es_admin`,
[data.username, data.email, passwordHash, data.nombre_completo || '', data.es_admin || false]
);
return result.rows[0];
} finally {
client.release();
}
}
/**
* Actualizar usuario
*/
export async function updateUser(
userId: number,
data: {
email?: string;
nombre_completo?: string;
activo?: boolean;
es_admin?: boolean;
password?: string;
}
): Promise<Usuario | null> {
const client = await pgPool.connect();
try {
const updates: string[] = [];
const values: any[] = [];
let paramIndex = 1;
if (data.email !== undefined) {
updates.push(`email = $${paramIndex++}`);
values.push(data.email);
}
if (data.nombre_completo !== undefined) {
updates.push(`nombre_completo = $${paramIndex++}`);
values.push(data.nombre_completo);
}
if (data.activo !== undefined) {
updates.push(`activo = $${paramIndex++}`);
values.push(data.activo);
}
if (data.es_admin !== undefined) {
updates.push(`es_admin = $${paramIndex++}`);
values.push(data.es_admin);
}
if (data.password) {
const passwordHash = await hashPassword(data.password);
updates.push(`password_hash = $${paramIndex++}`);
values.push(passwordHash);
}
if (updates.length === 0) {
return getUserById(userId);
}
values.push(userId);
const result = await client.query(
`UPDATE usuarios
SET ${updates.join(', ')}
WHERE id = $${paramIndex}
RETURNING id, username, email, nombre_completo, activo, es_admin`,
values
);
return result.rows[0] || null;
} finally {
client.release();
}
}
/**
* Eliminar usuario
*/
export async function deleteUser(userId: number): Promise<boolean> {
const client = await pgPool.connect();
try {
const result = await client.query('DELETE FROM usuarios WHERE id = $1', [userId]);
return result.rowCount ? result.rowCount > 0 : false;
} finally {
client.release();
}
}
/**
* Listar todos los usuarios
*/
export async function listUsers(): Promise<Usuario[]> {
const client = await pgPool.connect();
try {
const result = await client.query(
'SELECT id, username, email, nombre_completo, activo, es_admin FROM usuarios ORDER BY id DESC'
);
return result.rows;
} finally {
client.release();
}
}
/**
* Obtener permisos de bases de datos de un usuario
*/
export async function getUserDatabasePermissions(userId: number): Promise<string[]> {
const client = await pgPool.connect();
try {
const result = await client.query(
'SELECT base_datos_nombre FROM usuario_base_datos WHERE usuario_id = $1 AND puede_ver = true',
[userId]
);
return result.rows.map(row => row.base_datos_nombre);
} finally {
client.release();
}
}
/**
* Asignar permiso de base de datos a usuario
*/
export async function assignDatabaseToUser(
userId: number,
baseDatosNombre: string,
permisos: {
puede_ver?: boolean;
puede_descargar_backup?: boolean;
puede_restaurar?: boolean;
} = {}
): Promise<void> {
const client = await pgPool.connect();
try {
await client.query(
`INSERT INTO usuario_base_datos (usuario_id, base_datos_nombre, puede_ver, puede_descargar_backup, puede_restaurar)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (usuario_id, base_datos_nombre)
DO UPDATE SET
puede_ver = $3,
puede_descargar_backup = $4,
puede_restaurar = $5`,
[
userId,
baseDatosNombre,
permisos.puede_ver !== false,
permisos.puede_descargar_backup || false,
permisos.puede_restaurar || false
]
);
} finally {
client.release();
}
}
/**
* Remover permiso de base de datos a usuario
*/
export async function removeDatabaseFromUser(userId: number, baseDatosNombre: string): Promise<void> {
const client = await pgPool.connect();
try {
await client.query(
'DELETE FROM usuario_base_datos WHERE usuario_id = $1 AND base_datos_nombre = $2',
[userId, baseDatosNombre]
);
} finally {
client.release();
}
}
/**
* Verificar si un usuario tiene permiso para ver una base de datos
*/
export async function userCanViewDatabase(userId: number, baseDatosNombre: string): Promise<boolean> {
const client = await pgPool.connect();
try {
// Los admins pueden ver todo
const userResult = await client.query(
'SELECT es_admin FROM usuarios WHERE id = $1',
[userId]
);
if (userResult.rows.length > 0 && userResult.rows[0].es_admin) {
return true;
}
// Verificar permiso específico
const permResult = await client.query(
'SELECT puede_ver FROM usuario_base_datos WHERE usuario_id = $1 AND base_datos_nombre = $2',
[userId, baseDatosNombre]
);
return permResult.rows.length > 0 && permResult.rows[0].puede_ver;
} finally {
client.release();
}
}
/**
* Filtrar bases de datos según permisos del usuario
*/
export async function filterDatabasesByUserPermissions<T extends { visible_name: string }>(
userId: number,
databases: T[]
): Promise<T[]> {
const client = await pgPool.connect();
try {
// Los admins ven todo
const userResult = await client.query(
'SELECT es_admin FROM usuarios WHERE id = $1',
[userId]
);
if (userResult.rows.length > 0 && userResult.rows[0].es_admin) {
return databases;
}
// Obtener bases de datos permitidas
const permResult = await client.query(
'SELECT base_datos_nombre FROM usuario_base_datos WHERE usuario_id = $1 AND puede_ver = true',
[userId]
);
const allowedDatabases = new Set(permResult.rows.map(row => row.base_datos_nombre.toLowerCase()));
return databases.filter(db => allowedDatabases.has(db.visible_name.toLowerCase()));
} finally {
client.release();
}
}

View File

@@ -1,279 +1,648 @@
import { db } from '$lib/server/db';
import { env } from '$env/dynamic/private';
import fs from 'node:fs/promises';
import path from 'node:path';
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
// Helper to check disk space (Simple Windows implementation)
// Note: In production, consider a specialized library
async function getDiskSpace(drive: string) {
try {
// Using fs.statfs if available (Node 18.15+) or just mock for now
// Implementing proper disk check via Powershell is safer
return { free: 0, total: 0 };
} catch {
return { free: 0, total: 0 };
}
}
export const load: PageServerLoad = async ({ cookies }) => {
// 1. Auth Check (Commented out for dev)
const sessionId = cookies.get('session_id');
// if (!sessionId) {
// throw redirect(303, '/login');
// }
// Initialize result containers
let databaseRows: any[] = [];
let summaryMain: any = null;
let restoredCount = 0;
let notRestoredCount = 0;
let databaseRowsAZ: any[] = [];
let summaryAZ: any = null;
let backupFiles: any[] = [];
let clientsData: any[] = [];
let alertsData: any[] = [];
let basesDeDatosList: any[] = [];
// Connection errors to be passed to UI
let errors = {
primary: null as string | null,
secondary: null as string | null,
azure: null as string | null,
backups: null as string | null
};
// --- 1. Load Primary Data (Local SQL Express) ---
try {
const primary = await db.getPrimary();
// Main Database Rows
const queryMain = `
SELECT
d.name AS visible_name,
REVERSE(SUBSTRING(REVERSE(mf.physical_name), 1, CHARINDEX('\\', REVERSE(mf.physical_name)) - 1)) AS original_name,
SUM(mf.size * 8 / 1024) AS size_mb,
MAX(rh.restore_date) AS last_restore_date
FROM
sys.databases d
LEFT JOIN
sys.master_files mf ON d.database_id = mf.database_id
LEFT JOIN
msdb.dbo.restorehistory rh ON d.name = rh.destination_database_name
WHERE
mf.type = 0 AND d.name != 'tempdb'
GROUP BY
d.name, mf.physical_name
`;
const resultMain = await primary.request().query(queryMain);
databaseRows = resultMain.recordset;
// Calculate restored/not restored
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
for (const row of databaseRows) {
if (row.last_restore_date && new Date(row.last_restore_date) > oneDayAgo) {
restoredCount++;
} else {
notRestoredCount++;
}
}
// Summary Main
const querySummary = `
SELECT
COUNT(DISTINCT d.database_id) AS total_databases,
SUM(mf.size * 8 / 1024 / 1024) AS total_size_gb
FROM
sys.databases d
LEFT JOIN
sys.master_files mf ON d.database_id = mf.database_id
WHERE
mf.type = 0 AND d.name != 'tempdb'
`;
const resSummary = await primary.request().query(querySummary);
summaryMain = resSummary.recordset[0];
// Alerts Data (also from Primary)
const sqlAlerts = `
SELECT
d.name AS visible_name,
MAX(rh.restore_date) AS last_restore_date
FROM sys.databases d
LEFT JOIN msdb.dbo.restorehistory rh ON d.name = rh.destination_database_name
WHERE d.name != 'tempdb'
GROUP BY d.name
HAVING MAX(rh.restore_date) < DATEADD(DAY, -2, GETDATE()) OR MAX(rh.restore_date) IS NULL
`;
const resAlerts = await primary.request().query(sqlAlerts);
alertsData = resAlerts.recordset;
} catch (e: any) {
console.error("Error loading Primary DB data:", e);
errors.primary = `Error conectando al servidor Principal: ${e.message}`;
}
// --- 2. Load Azure Data ---
try {
// Only try connecting if we are meant to
const azure = await db.getAzure();
// Re-use queryMain logic for Azure
const queryMainAz = `
SELECT
d.name AS visible_name,
REVERSE(SUBSTRING(REVERSE(mf.physical_name), 1, CHARINDEX('\\', REVERSE(mf.physical_name)) - 1)) AS original_name,
SUM(mf.size * 8 / 1024) AS size_mb,
MAX(rh.restore_date) AS last_restore_date
FROM
sys.databases d
LEFT JOIN
sys.master_files mf ON d.database_id = mf.database_id
LEFT JOIN
msdb.dbo.restorehistory rh ON d.name = rh.destination_database_name
WHERE
mf.type = 0 AND d.name != 'tempdb'
GROUP BY
d.name, mf.physical_name
`;
const resultAzure = await azure.request().query(queryMainAz);
databaseRowsAZ = resultAzure.recordset;
const querySummaryAz = `
SELECT
COUNT(DISTINCT d.database_id) AS total_databases,
SUM(mf.size * 8 / 1024 / 1024) AS total_size_gb
FROM
sys.databases d
LEFT JOIN
sys.master_files mf ON d.database_id = mf.database_id
WHERE
mf.type = 0 AND d.name != 'tempdb'
`;
const resSummaryAz = await azure.request().query(querySummaryAz);
summaryAZ = resSummaryAz.recordset[0];
} catch (e: any) {
console.error("Error loading Azure DB data:", e);
errors.azure = `Error conectando a Azure: ${e.message}`;
}
// --- 3. Load Secondary Data (Clients & Management) ---
// Note: We need this connection to hydrate backup info and alerts too
let secondary: any = null;
try {
secondary = await db.getSecondary();
// Clients Catalog
const clientsQuery = `SELECT ID, Nombre, NodoSubNodo, CorreoNotificacion, Activo, BDName FROM BasesdeDatos`;
// Assuming 'CONTROLDESK' db context is handled in connection string or default db
const resClients = await secondary.request().query(clientsQuery);
clientsData = resClients.recordset;
// DB Management List
const basesQueries = `SELECT ID, NodoSubNodo, Activo, RFC, Nombre, Sucursal, CorreoNotificacion, ServerName, BDName FROM BasesDeDatos`;
const resBases = await secondary.request().query(basesQueries);
basesDeDatosList = resBases.recordset;
} catch (e: any) {
console.error("Error loading Secondary DB data:", e);
errors.secondary = `Error conectando al servidor Secundario (ControlDesk): ${e.message}`;
}
// --- 4. Process Backups & Hydrate Alerts (Requires Secondary DB) ---
try {
let files: string[] = [];
try {
files = await fs.readdir(env.BACKUP_PATH);
} catch (e) {
files = [];
errors.backups = "No se pudo acceder a la carpeta de respaldos.";
}
for (const file of files) {
if (file === '.' || file === '..') continue;
const filePath = path.join(env.BACKUP_PATH, file);
let stats;
try {
stats = await fs.stat(filePath);
} catch { continue; }
const nodoName = path.parse(file).name;
let clientData: any = null;
// Try to fetch metadata if secondary DB is available
if (secondary) {
try {
const clientQuery = `
SELECT ClienteAutoridad, Nombre, Usuario, BD_Shelter
FROM [CONTROLDESK].[dbo].[Usuarios]
WHERE Usuario = @nodoName
`;
const req = secondary.request();
req.input('nodoName', nodoName);
const res = await req.query(clientQuery);
clientData = res.recordset[0];
} catch {}
}
backupFiles.push({
name: file,
nodo_name: nodoName,
client_name: clientData?.Nombre ?? 'Cliente no identificado',
client_authority: clientData?.ClienteAutoridad ?? 'N/A',
bd_shelter: clientData?.BD_Shelter ?? 'N/A',
date: stats.mtime,
size: (stats.size / 1024 / 1024).toFixed(2) + " MB"
});
}
// Hydrate Alerts if possible
if (secondary && alertsData.length > 0) {
const hydratedAlerts = [];
for (const alert of alertsData) {
const nodoName = alert.visible_name;
let cData: any = null;
try {
const req = secondary.request();
req.input('nodoName', nodoName);
const res = await req.query(`
SELECT u.ClienteAutoridad, u.Nombre, u.Usuario, bd.CorreoNotificacion
FROM [CONTROLDESK].[dbo].[Usuarios] AS u
LEFT JOIN [CONTROLDESK].[dbo].[BasesDeDatos] AS bd ON u.Usuario = bd.NodoSubNodo
WHERE u.Usuario = @nodoName
`);
cData = res.recordset[0];
} catch {}
hydratedAlerts.push({ ...alert, clientData: cData });
}
alertsData = hydratedAlerts;
}
} catch (e: any) {
console.error("Error processing backups/alerts hydration:", e);
}
return {
databaseRows,
summaryMain,
restoredCount,
notRestoredCount,
databaseRowsAZ,
summaryAZ,
backupFiles,
clientsData,
alertsData,
basesDeDatosList,
errors // Return the collected errors
};
};
import { db } from '$lib/server/db';
import { env } from '$env/dynamic/private';
import fs from 'node:fs/promises';
import path from 'node:path';
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { verifyToken } from '$lib/server/auth';
import { getUserById, filterDatabasesByUserPermissions } from '$lib/server/users';
// Helper to check disk space (Simple Windows implementation)
// Note: In production, consider a specialized library
async function getDiskSpace(drive: string) {
try {
// Using fs.statfs if available (Node 18.15+) or just mock for now
// Implementing proper disk check via Powershell is safer
return { free: 0, total: 0 };
} catch {
return { free: 0, total: 0 };
}
}
export const load: PageServerLoad = async ({ cookies }) => {
// 1. Auth Check - Verificar token JWT
const token = cookies.get('session_token');
if (!token) {
throw redirect(303, '/login');
}
const session = verifyToken(token);
if (!session) {
throw redirect(303, '/login');
}
const currentUser = await getUserById(session.userId);
if (!currentUser || !currentUser.activo) {
throw redirect(303, '/login');
}
// Initialize result containers
let databaseRows: any[] = [];
let summaryMain: any = null;
let restoredCount = 0;
let notRestoredCount = 0;
let databaseRowsAZ: any[] = [];
let summaryAZ: any = null;
let backupFiles: any[] = [];
let clientsData: any[] = [];
let alertsData: any[] = [];
let basesDeDatosList: any[] = [];
let restoreHistory: Record<string, { restore_date: Date }[]> = {};
let effectivenessByDb: Record<string, { month: string; effectiveness: number }[]> = {};
// Connection errors to be passed to UI
let errors = {
primary: null as string | null,
secondary: null as string | null,
azure: null as string | null,
backups: null as string | null
};
// --- 1. Load Primary Data (Servidor 152 - Bases Restauradas) ---
try {
// Usamos el servidor SECUNDARIO (152) como fuente del panel principal
const primary = await db.getSecondary();
// Main Database Rows - Bases restauradas localmente
const queryMain = `
SELECT
d.name AS visible_name,
d.name AS original_name,
CAST(SUM(mf.size * 8.0 / 1024) AS DECIMAL(10,2)) AS size_mb,
CAST(SUM(mf.size * 8.0 / 1024 / 1024) AS DECIMAL(10,2)) AS total_size_gb,
MAX(rh.restore_date) AS last_restore_date
FROM
sys.databases d
LEFT JOIN
sys.master_files mf ON d.database_id = mf.database_id AND mf.type = 0
LEFT JOIN
msdb.dbo.restorehistory rh ON d.name = rh.destination_database_name
WHERE
d.name NOT IN ('master', 'tempdb', 'model', 'msdb')
GROUP BY
d.name
ORDER BY
d.name
`;
const resultMain = await primary.request().query(queryMain);
databaseRows = resultMain.recordset;
// Summary Main
const querySummary = `
SELECT
COUNT(DISTINCT d.database_id) AS total_databases,
SUM(mf.size * 8 / 1024 / 1024) AS total_size_gb
FROM
sys.databases d
LEFT JOIN
sys.master_files mf ON d.database_id = mf.database_id
WHERE
mf.type = 0 AND d.name != 'tempdb'
`;
const resSummary = await primary.request().query(querySummary);
summaryMain = resSummary.recordset[0];
// Alerts Data (bases sin restore reciente)
const sqlAlerts = `
SELECT
d.name AS visible_name,
MAX(rh.restore_date) AS last_restore_date
FROM sys.databases d
LEFT JOIN msdb.dbo.restorehistory rh ON d.name = rh.destination_database_name
WHERE d.name != 'tempdb'
GROUP BY d.name
HAVING MAX(rh.restore_date) < DATEADD(DAY, -2, GETDATE()) OR MAX(rh.restore_date) IS NULL
`;
const resAlerts = await primary.request().query(sqlAlerts);
alertsData = resAlerts.recordset;
// Historial de restauraciones por base (últimos 60 días)
const sqlHistory = `
SELECT
destination_database_name AS visible_name,
restore_date
FROM msdb.dbo.restorehistory
WHERE restore_date >= DATEADD(DAY, -120, GETDATE())
`;
const resHistory = await primary.request().query(sqlHistory);
for (const row of resHistory.recordset as any[]) {
const name = String(row.visible_name ?? '').toLowerCase();
if (!restoreHistory[name]) restoreHistory[name] = [];
restoreHistory[name].push({ restore_date: row.restore_date });
}
// Calcular efectividad mensual por base (1 restore esperado por día)
const now = new Date();
const currentYear = now.getFullYear();
const currentMonth = now.getMonth(); // 0-11
const monthsToInclude = [currentMonth, currentMonth - 1, currentMonth - 2].filter(
(m) => m >= 0
);
for (const [dbName, history] of Object.entries(restoreHistory)) {
const monthly: { [monthKey: string]: { daysWithRestore: Set<string>; totalDays: number } } = {};
// inicializar meses con días del mes
for (const m of monthsToInclude) {
const monthKey = `${currentYear}-${String(m + 1).padStart(2, '0')}`;
monthly[monthKey] = {
daysWithRestore: new Set<string>(),
totalDays: new Date(currentYear, m + 1, 0).getDate()
};
}
for (const h of history) {
const d = new Date(h.restore_date);
const y = d.getFullYear();
const m = d.getMonth();
if (y !== currentYear || !monthsToInclude.includes(m)) continue;
const monthKey = `${y}-${String(m + 1).padStart(2, '0')}`;
const dayKey = d.toISOString().slice(0, 10);
monthly[monthKey]?.daysWithRestore.add(dayKey);
}
effectivenessByDb[dbName] = Object.entries(monthly).map(
([month, data]) => {
const eff =
data.totalDays === 0
? 0
: (data.daysWithRestore.size / data.totalDays) * 100;
return { month, effectiveness: Number(eff.toFixed(1)) };
}
);
}
} catch (e: any) {
console.error("Error loading Primary DB data:", e);
errors.primary = `Error conectando al servidor Local (SQL Express): ${e.message}`;
}
// --- 2. Load Secondary Data (Servidor con TODAS las bases de clientes en 202) ---
try {
// Aquí usamos el servidor PRIMARIO (202) para la segunda sección
const secondary = await db.getPrimary();
// Consultar TODAS las bases de datos en el servidor secundario
const queryMainSec = `
SELECT
d.name AS visible_name,
d.name AS original_name,
CAST(SUM(mf.size * 8.0 / 1024) AS DECIMAL(10,2)) AS size_mb,
d.create_date,
d.state_desc,
d.recovery_model_desc
FROM
sys.databases d
LEFT JOIN
sys.master_files mf ON d.database_id = mf.database_id AND mf.type = 0
WHERE
d.name NOT IN ('master', 'tempdb', 'model', 'msdb')
GROUP BY
d.name, d.create_date, d.state_desc, d.recovery_model_desc
ORDER BY
d.name
`;
const resultSecondary = await secondary.request().query(queryMainSec);
databaseRowsAZ = resultSecondary.recordset;
const querySummarySec = `
SELECT
COUNT(d.database_id) AS total_databases,
CAST(SUM(mf.size * 8.0 / 1024 / 1024) AS DECIMAL(10,2)) AS total_size_gb
FROM
sys.databases d
LEFT JOIN
sys.master_files mf ON d.database_id = mf.database_id AND mf.type = 0
WHERE
d.name NOT IN ('master', 'tempdb', 'model', 'msdb')
`;
const resSummarySec = await secondary.request().query(querySummarySec);
summaryAZ = resSummarySec.recordset[0];
} catch (e: any) {
console.error("Error loading Secondary DB data:", e);
errors.secondary = `Error conectando al servidor Secundario (Todas las Bases): ${e.message}`;
}
// --- 3. Load Azure/ControlDesk Data (Catálogo de Clientes) ---
// Note: We need this connection to hydrate backup info and alerts too
let azure: any = null;
try {
azure = await db.getAzure();
// Clients Catalog - desde CONTROLDESK en Azure
const clientsQuery = `SELECT ID, Nombre, NodoSubNodo, CorreoNotificacion, Activo, BDName FROM [CONTROLDESK].[dbo].[BasesdeDatos]`;
const resClients = await azure.request().query(clientsQuery);
clientsData = resClients.recordset;
// DB Management List
const basesQueries = `SELECT ID, NodoSubNodo, Activo, RFC, Nombre, Sucursal, CorreoNotificacion, ServerName, BDName FROM [CONTROLDESK].[dbo].[BasesDeDatos]`;
const resBases = await azure.request().query(basesQueries);
basesDeDatosList = resBases.recordset;
} catch (e: any) {
console.error("Error loading Azure/ControlDesk data:", e);
errors.azure = `Error conectando a Azure/ControlDesk: ${e.message}`;
}
// --- 4. Process Backups, Hydrate Alerts & Enriquecer databaseRows con datos de BasesDeDatos ---
try {
let files: string[] = [];
try {
files = await fs.readdir(env.BACKUP_PATH);
} catch (e) {
files = [];
errors.backups = "No se pudo acceder a la carpeta de respaldos.";
}
for (const file of files) {
if (file === '.' || file === '..') continue;
const filePath = path.join(env.BACKUP_PATH, file);
let stats;
try {
stats = await fs.stat(filePath);
} catch { continue; }
const nodoName = path.parse(file).name;
let clientData: any = null;
// Try to fetch metadata if azure DB is available
if (azure) {
try {
const clientQuery = `
SELECT TOP 1 Nombre, NodoSubNodo, RFC
FROM [CONTROLDESK].[dbo].[BasesDeDatos]
WHERE NodoSubNodo = @nodoName OR BDName = @nodoName
`;
const req = azure.request();
req.input('nodoName', nodoName);
const res = await req.query(clientQuery);
clientData = res.recordset[0];
} catch {}
}
backupFiles.push({
name: file,
nodo_name: nodoName,
client_name: clientData?.Nombre ?? 'Cliente no identificado',
client_authority: clientData?.RFC ?? 'N/A',
bd_shelter: 'N/A',
date: stats.mtime,
size: (stats.size / 1024 / 1024).toFixed(2) + " MB"
});
}
// Ordenar respaldos de más reciente a más antiguo por fecha de modificación
backupFiles.sort((a, b) => {
const da = new Date(a.date).getTime();
const db = new Date(b.date).getTime();
return db - da;
});
// Hydrate Alerts if possible
if (azure && alertsData.length > 0) {
const hydratedAlerts = [];
for (const alert of alertsData) {
const nodoName = alert.visible_name;
let cData: any = null;
try {
const req = azure.request();
req.input('nodoName', nodoName);
const res = await req.query(`
SELECT u.ClienteAutoridad, u.Nombre, u.Usuario, bd.CorreoNotificacion
FROM [CONTROLDESK].[dbo].[Usuarios] AS u
LEFT JOIN [CONTROLDESK].[dbo].[BasesDeDatos] AS bd ON u.Usuario = bd.NodoSubNodo
WHERE u.Usuario = @nodoName
`);
cData = res.recordset[0];
} catch {}
hydratedAlerts.push({ ...alert, clientData: cData });
}
alertsData = hydratedAlerts;
}
// Enriquecer databaseRows (servidor local restaurado) con NodoSubNodo y Nombre desde BasesDeDatos
if (azure && databaseRows.length > 0) {
try {
const req = azure.request();
const res = await req.query(`
SELECT ID, NodoSubNodo, Activo, RFC, Nombre, Sucursal, CorreoNotificacion, ServerName, BDName
FROM [CONTROLDESK].[dbo].[BasesDeDatos]
`);
const bases = res.recordset as any[];
const mapByBdName = new Map<string, any>();
const mapByNodo = new Map<string, any>();
for (const bd of bases) {
if (bd.BDName) {
mapByBdName.set(String(bd.BDName).toLowerCase(), bd);
}
if (bd.NodoSubNodo) {
mapByNodo.set(String(bd.NodoSubNodo).toLowerCase(), bd);
}
}
databaseRows = databaseRows.map((row) => {
const key = String(row.visible_name ?? row.original_name ?? '').toLowerCase();
// Intentar match por BDName primero, luego por NodoSubNodo
let match = mapByBdName.get(key);
if (!match) {
match = mapByNodo.get(key);
}
if (!match) {
console.log(`No match found for database: ${key}`);
return row;
}
return {
...row,
NodoSubNodo: match.NodoSubNodo,
client_name: match.Nombre,
BDName: match.BDName
};
});
} catch (e) {
console.error('Error enriching databaseRows with BasesDeDatos info:', e);
}
}
} catch (e: any) {
console.error("Error processing backups/alerts hydration:", e);
}
// CALCULAR MÉTRICAS DE RESTAURACIÓN basadas en backups reales (ANTES del filtro)
restoredCount = 0;
notRestoredCount = 0;
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
for (const db of databaseRows) {
// Buscar backups de esta base de datos en las últimas 24 horas
// Comparar usando NodoSubNodo (de la BD) con nodo_name (del archivo de backup)
const recentBackups = backupFiles.filter(backup => {
const backupNodo = (backup.nodo_name || '').toLowerCase();
const dbNodo = (db.NodoSubNodo || '').toLowerCase();
const backupDate = new Date(backup.date);
// Si no tiene NodoSubNodo, intentar con el nombre de la BD
const hasMatch = dbNodo ? (backupNodo === dbNodo) :
(backupNodo === (db.visible_name || '').toLowerCase());
return hasMatch && backupDate > oneDayAgo;
});
if (recentBackups.length > 0) {
restoredCount++;
} else {
notRestoredCount++;
}
}
// Aplicar filtro de permisos de usuario (si no es admin)
if (!currentUser.es_admin) {
databaseRows = await filterDatabasesByUserPermissions(currentUser.id, databaseRows);
alertsData = await filterDatabasesByUserPermissions(currentUser.id, alertsData);
// Filtrar backups según las bases de datos permitidas (usar NodoSubNodo)
const allowedNodos = new Set(databaseRows.map(db => (db.NodoSubNodo || db.visible_name).toLowerCase()));
backupFiles = backupFiles.filter(backup => {
const backupNodo = (backup.nodo_name || '').toLowerCase();
return allowedNodos.has(backupNodo);
});
// RECALCULAR MÉTRICAS basadas en las bases de datos filtradas
summaryMain.total_size_gb = databaseRows.reduce((sum, db) => sum + (db.total_size_gb || 0), 0);
summaryMain.total_size_gb = Math.round(summaryMain.total_size_gb * 100) / 100;
restoredCount = 0;
notRestoredCount = 0;
for (const db of databaseRows) {
const recentBackups = backupFiles.filter(backup => {
const backupNodo = (backup.nodo_name || '').toLowerCase();
const dbNodo = (db.NodoSubNodo || '').toLowerCase();
const backupDate = new Date(backup.date);
// Si no tiene NodoSubNodo, intentar con el nombre de la BD
const hasMatch = dbNodo ? (backupNodo === dbNodo) :
(backupNodo === (db.visible_name || '').toLowerCase());
return hasMatch && backupDate > oneDayAgo;
});
if (recentBackups.length > 0) {
restoredCount++;
} else {
notRestoredCount++;
}
}
}
return {
databaseRows,
summaryMain,
restoredCount,
notRestoredCount,
databaseRowsAZ,
summaryAZ,
backupFiles,
clientsData,
alertsData,
basesDeDatosList,
restoreHistory,
effectivenessByDb,
errors, // Return the collected errors
currentUser // Añadir usuario actual para la UI
};
};
// Acciones para actualizar estado de clientes (activar/desactivar) y editar nombre de base de datos
export const actions: Actions = {
toggleClient: async ({ request }) => {
try {
const formData = await request.formData();
const idRaw = formData.get('id');
const activoRaw = formData.get('activo');
if (!idRaw || !activoRaw) {
return { success: false, message: 'Pare1metros incompletos' };
}
const id = Number(idRaw);
const activo = activoRaw === 'true';
const azure = await db.getAzure();
const req = azure.request();
req.input('id', id);
req.input('activo', activo ? 1 : 0);
await req.query(`
UPDATE [CONTROLDESK].[dbo].[BasesdeDatos]
SET Activo = @activo
WHERE ID = @id
`);
return { success: true };
} catch (e: any) {
console.error('Error updating client active state:', e);
return { success: false, message: e.message };
}
},
updateDatabaseName: async ({ request }) => {
try {
const formData = await request.formData();
const idRaw = formData.get('id');
const nombreRaw = formData.get('nombre');
if (!idRaw || !nombreRaw) {
return { success: false, message: 'Pare1metros incompletos' };
}
const id = Number(idRaw);
const nombre = String(nombreRaw).trim();
if (!nombre) {
return { success: false, message: 'El nombre no puede estar vacedo' };
}
const azure = await db.getAzure();
const req = azure.request();
req.input('id', id);
req.input('nombre', nombre);
await req.query(`
UPDATE [CONTROLDESK].[dbo].[BasesdeDatos]
SET Nombre = @nombre
WHERE ID = @id
`);
return { success: true };
} catch (e: any) {
console.error('Error updating database name:', e);
return { success: false, message: e.message };
}
},
createDatabase: async ({ request }) => {
try {
const formData = await request.formData();
const nodoSubNodo = String(formData.get('NodoSubNodo') || '').trim();
const rfc = String(formData.get('RFC') || '').trim();
const nombre = String(formData.get('Nombre') || '').trim();
const sucursal = String(formData.get('Sucursal') || '').trim();
const correo = String(formData.get('CorreoNotificacion') || '').trim();
const serverName = String(formData.get('ServerName') || '').trim();
const bdName = String(formData.get('BDName') || '').trim();
const activo = formData.get('Activo') === 'true' ? 1 : 0;
if (!nodoSubNodo || !rfc || !nombre || !sucursal || !correo || !serverName || !bdName) {
return { success: false, message: 'Todos los campos son requeridos' };
}
const azure = await db.getAzure();
const req = azure.request();
req.input('nodoSubNodo', nodoSubNodo);
req.input('rfc', rfc);
req.input('nombre', nombre);
req.input('sucursal', sucursal);
req.input('correo', correo);
req.input('serverName', serverName);
req.input('bdName', bdName);
req.input('activo', activo);
await req.query(`
INSERT INTO [CONTROLDESK].[dbo].[BasesDeDatos]
(NodoSubNodo, RFC, Nombre, Sucursal, CorreoNotificacion, ServerName, BDName, Activo)
VALUES (@nodoSubNodo, @rfc, @nombre, @sucursal, @correo, @serverName, @bdName, @activo)
`);
return { success: true };
} catch (e: any) {
console.error('Error creating database:', e);
return { success: false, message: e.message };
}
},
updateDatabase: async ({ request }) => {
try {
const formData = await request.formData();
const id = Number(formData.get('ID'));
const nodoSubNodo = String(formData.get('NodoSubNodo') || '').trim();
const rfc = String(formData.get('RFC') || '').trim();
const nombre = String(formData.get('Nombre') || '').trim();
const sucursal = String(formData.get('Sucursal') || '').trim();
const correo = String(formData.get('CorreoNotificacion') || '').trim();
const serverName = String(formData.get('ServerName') || '').trim();
const bdName = String(formData.get('BDName') || '').trim();
const activo = formData.get('Activo') === 'true' ? 1 : 0;
if (!id || !nodoSubNodo || !rfc || !nombre || !sucursal || !correo || !serverName || !bdName) {
return { success: false, message: 'Todos los campos son requeridos' };
}
const azure = await db.getAzure();
const req = azure.request();
req.input('id', id);
req.input('nodoSubNodo', nodoSubNodo);
req.input('rfc', rfc);
req.input('nombre', nombre);
req.input('sucursal', sucursal);
req.input('correo', correo);
req.input('serverName', serverName);
req.input('bdName', bdName);
req.input('activo', activo);
await req.query(`
UPDATE [CONTROLDESK].[dbo].[BasesDeDatos]
SET NodoSubNodo = @nodoSubNodo,
RFC = @rfc,
Nombre = @nombre,
Sucursal = @sucursal,
CorreoNotificacion = @correo,
ServerName = @serverName,
BDName = @bdName,
Activo = @activo
WHERE ID = @id
`);
return { success: true };
} catch (e: any) {
console.error('Error updating database:', e);
return { success: false, message: e.message };
}
},
deleteDatabase: async ({ request }) => {
try {
const formData = await request.formData();
const id = Number(formData.get('ID'));
if (!id) {
return { success: false, message: 'ID requerido' };
}
const azure = await db.getAzure();
const req = azure.request();
req.input('id', id);
await req.query(`
DELETE FROM [CONTROLDESK].[dbo].[BasesDeDatos]
WHERE ID = @id
`);
return { success: true };
} catch (e: any) {
console.error('Error deleting database:', e);
return { success: false, message: e.message };
}
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,60 @@
import { json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
export const GET = async () => {
try {
const primary = await db.getSecondary();
const queryMain = `
SELECT
d.name AS visible_name,
REVERSE(SUBSTRING(REVERSE(mf.physical_name), 1, CHARINDEX('\\', REVERSE(mf.physical_name)) - 1)) AS original_name,
SUM(mf.size * 8 / 1024) AS size_mb,
MAX(rh.restore_date) AS last_restore_date
FROM
sys.databases d
LEFT JOIN
sys.master_files mf ON d.database_id = mf.database_id
LEFT JOIN
msdb.dbo.restorehistory rh ON d.name = rh.destination_database_name
WHERE
mf.type = 0 AND d.name != 'tempdb'
GROUP BY
d.name, mf.physical_name
`;
const resultMain = await primary.request().query(queryMain);
const databaseRows = resultMain.recordset;
const querySummary = `
SELECT
COUNT(DISTINCT d.database_id) AS total_databases,
SUM(mf.size * 8 / 1024 / 1024) AS total_size_gb
FROM
sys.databases d
LEFT JOIN
sys.master_files mf ON d.database_id = mf.database_id
WHERE
mf.type = 0 AND d.name != 'tempdb'
`;
const resSummary = await primary.request().query(querySummary);
const summaryMain = resSummary.recordset[0];
const sqlAlerts = `
SELECT
d.name AS visible_name,
MAX(rh.restore_date) AS last_restore_date
FROM sys.databases d
LEFT JOIN msdb.dbo.restorehistory rh ON d.name = rh.destination_database_name
WHERE d.name != 'tempdb'
GROUP BY d.name
HAVING MAX(rh.restore_date) < DATEADD(DAY, -2, GETDATE()) OR MAX(rh.restore_date) IS NULL
`;
const resAlerts = await primary.request().query(sqlAlerts);
const alertsData = resAlerts.recordset;
return json({ databaseRows, summaryMain, alertsData });
} catch (e: any) {
console.error('Error refreshing dashboard data:', e);
return json({ error: 'Error refreshing dashboard data' }, { status: 500 });
}
};

View File

@@ -0,0 +1,23 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { pgPool } from '$lib/server/db';
export const GET: RequestHandler = async () => {
try {
// Test PostgreSQL
const client = await pgPool.connect();
const result = await client.query('SELECT COUNT(*) as count FROM usuarios');
client.release();
return json({
status: 'ok',
postgres: 'connected',
usuarios: result.rows[0].count
});
} catch (error: any) {
return json({
status: 'error',
message: error.message
}, { status: 500 });
}
};

View File

@@ -0,0 +1,30 @@
import { env } from '$env/dynamic/private';
import fs from 'node:fs/promises';
import path from 'node:path';
// GET /backup?file=nombre.bak -> descarga el archivo físico desde BACKUP_PATH
export const GET = async ({ url }: { url: URL }) => {
const fileName = url.searchParams.get('file');
if (!fileName) {
return new Response('Missing file parameter', { status: 400 });
}
const basePath = env.BACKUP_PATH;
if (!basePath) {
console.error('BACKUP_PATH is not defined in environment');
return new Response('Backup path is not configured', { status: 500 });
}
try {
const filePath = path.join(basePath, fileName);
console.log('Serving backup file from', filePath);
const data = await fs.readFile(filePath);
const headers = new Headers();
headers.set('Content-Type', 'application/octet-stream');
headers.set('Content-Disposition', `attachment; filename="${fileName}"`);
return new Response(data, { status: 200, headers });
} catch (e: any) {
console.error('Error serving backup file:', e?.message ?? e);
return new Response('Backup file not found or inaccessible', { status: 404 });
}
};

View File

@@ -0,0 +1,61 @@
import { redirect, fail } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { authenticateUser } from '$lib/server/users';
import { createToken } from '$lib/server/auth';
export const load: PageServerLoad = async ({ cookies }) => {
// Si ya tiene sesión, redirigir al dashboard
const token = cookies.get('session_token');
if (token) {
throw redirect(303, '/');
}
return {};
};
export const actions: Actions = {
login: async ({ request, cookies }) => {
const data = await request.formData();
const username = data.get('username')?.toString();
const password = data.get('password')?.toString();
if (!username || !password) {
return fail(400, { error: 'Usuario y contraseña son requeridos' });
}
try {
const user = await authenticateUser(username, password);
if (!user) {
return fail(401, { error: 'Usuario o contraseña incorrectos' });
}
// Crear token JWT
const token = createToken({
userId: user.id,
username: user.username,
es_admin: user.es_admin
});
// Guardar token en cookie (7 días)
cookies.set('session_token', token, {
path: '/',
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 7 // 7 días
});
throw redirect(303, '/');
} catch (error: any) {
console.error('Error en login:', error);
// Si es un redirect, déjalo pasar
if (error?.status === 303) {
throw error;
}
return fail(500, { error: 'Error al iniciar sesión. Intente nuevamente.' });
}
}
};

View File

@@ -0,0 +1,175 @@
<script lang="ts">
import aduanasoftLogo from '$lib/assets/logo.png';
let { data, form } = $props();
let username = $state('');
let password = $state('');
let showPassword = $state(false);
let error = $state(form?.error || '');
let loading = $state(false);
</script>
<svelte:head>
<title>Iniciar Sesión - Aduanasoft</title>
<link href="https://fonts.googleapis.com/icon?family=Material+Icons+Outlined" rel="stylesheet">
</svelte:head>
<div class="flex min-h-screen">
<!-- Left Side - Hero Section -->
<div class="hidden lg:flex lg:w-1/2 bg-gradient-to-br from-slate-800 via-slate-700 to-slate-900 relative overflow-hidden">
<!-- Background Image -->
<div class="absolute inset-0 opacity-20">
<img
src="https://images.unsplash.com/photo-1558494949-ef010cbdcc31?w=1200&q=80"
alt="Database servers"
class="w-full h-full object-cover"
/>
</div>
<!-- Background Pattern -->
<div class="absolute inset-0 opacity-10">
<div class="absolute top-0 left-0 w-96 h-96 bg-sky-400 rounded-full mix-blend-multiply filter blur-3xl"></div>
<div class="absolute bottom-0 right-0 w-96 h-96 bg-blue-400 rounded-full mix-blend-multiply filter blur-3xl"></div>
</div>
<!-- Content -->
<div class="relative z-10 flex flex-col justify-between p-12 text-white w-full">
<!-- Logo -->
<div class="flex items-center gap-3">
<img src={aduanasoftLogo} alt="Aduanasoft" class="h-20 w-auto drop-shadow-lg" />
</div>
<!-- Main Content -->
<div class="space-y-6">
<h1 class="text-5xl font-bold leading-tight">
Control Total de<br />Bases de Datos
</h1>
<p class="text-lg text-slate-300 max-w-md">
Gestión inteligente de respaldos. Monitoree, restaure y asegure la integridad de sus bases de datos en tiempo real.
</p>
</div>
<!-- Footer -->
<div class="text-sm text-slate-400">
ADUANASOFT DATABASE PLATFORM
</div>
</div>
</div>
<!-- Right Side - Login Form -->
<div class="flex w-full lg:w-1/2 items-center justify-center p-8 bg-white">
<div class="w-full max-w-md space-y-8">
<!-- Mobile Logo -->
<div class="flex lg:hidden justify-center mb-8">
<img src={aduanasoftLogo} alt="Aduanasoft" class="h-16 w-auto" />
</div>
<!-- Header -->
<div class="text-center lg:text-left">
<div class="flex justify-center lg:justify-start mb-4">
<img src={aduanasoftLogo} alt="Aduanasoft" class="h-14 w-auto hidden lg:block" />
</div>
<h2 class="text-3xl font-bold text-slate-900">Bienvenido</h2>
<p class="mt-2 text-sm text-slate-600">Ingrese a su cuenta corporativa</p>
</div>
<!-- Error Message -->
{#if error}
<div class="rounded-lg bg-red-50 p-3 text-sm text-red-700 ring-1 ring-red-200">
<div class="flex items-center gap-2">
<span class="material-icons-outlined text-base">error</span>
<span>{error}</span>
</div>
</div>
{/if}
<!-- Form -->
<form method="POST" action="?/login" class="space-y-6">
<!-- Email -->
<div>
<label for="username" class="block text-sm font-medium text-slate-700 mb-2">
Correo Electrónico
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<span class="material-icons-outlined text-slate-400 text-lg">email</span>
</div>
<input
type="text"
id="username"
name="username"
bind:value={username}
required
autocomplete="username"
class="w-full pl-10 pr-4 py-3 bg-yellow-50 border border-yellow-200 rounded-lg text-sm text-slate-900 placeholder-slate-500 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 transition-colors"
placeholder="admin@yms.com"
/>
</div>
</div>
<!-- Password -->
<div>
<label for="password" class="block text-sm font-medium text-slate-700 mb-2">
Contraseña
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<span class="material-icons-outlined text-slate-400 text-lg">lock</span>
</div>
<input
type={showPassword ? 'text' : 'password'}
id="password"
name="password"
bind:value={password}
required
autocomplete="current-password"
class="w-full pl-10 pr-12 py-3 bg-yellow-50 border border-yellow-200 rounded-lg text-sm text-slate-900 placeholder-slate-500 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 transition-colors"
placeholder="••••••••"
/>
<button
type="button"
onclick={() => showPassword = !showPassword}
class="absolute inset-y-0 right-0 pr-3 flex items-center text-slate-400 hover:text-slate-600"
>
<span class="material-icons-outlined text-lg">{showPassword ? 'visibility_off' : 'visibility'}</span>
</button>
</div>
</div>
<!-- Remember me -->
<div class="flex items-center">
<input
id="remember"
type="checkbox"
class="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
/>
<label for="remember" class="ml-2 block text-sm text-slate-700">
Recordar en este equipo
</label>
</div>
<!-- Submit Button -->
<button
type="submit"
disabled={loading}
class="w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? 'Iniciando sesión...' : 'Acceder al Portal'}
</button>
</form>
<!-- Footer -->
<div class="text-center text-xs text-slate-500">
<p>© 2026 YMS. Acceso exclusivo autorizado.</p>
</div>
</div>
</div>
</div>
<style>
:global(body) {
margin: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
</style>

View File

@@ -0,0 +1,16 @@
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ cookies }) => {
// Eliminar cookie de sesión
cookies.delete('session_token', { path: '/' });
throw redirect(303, '/login');
};
export const POST: RequestHandler = async ({ cookies }) => {
// Eliminar cookie de sesión
cookies.delete('session_token', { path: '/' });
throw redirect(303, '/login');
};

View File

@@ -0,0 +1,27 @@
import type { PageServerLoad } from './$types';
import { verifyToken } from '$lib/server/auth';
import { getUserById } from '$lib/server/users';
import { redirect } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ cookies }) => {
const token = cookies.get('session_token');
if (!token) {
throw redirect(303, '/login');
}
const session = verifyToken(token);
if (!session) {
throw redirect(303, '/login');
}
const currentUser = await getUserById(session.userId);
if (!currentUser || !currentUser.activo) {
throw redirect(303, '/login');
}
return {
currentUser
};
};

View File

@@ -0,0 +1,201 @@
import { redirect, fail } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { verifyToken } from '$lib/server/auth';
import {
listUsers,
createUser,
updateUser,
deleteUser,
getUserById,
assignDatabaseToUser,
removeDatabaseFromUser
} from '$lib/server/users';
import { db } from '$lib/server/db';
export const load: PageServerLoad = async ({ cookies }) => {
// Verificar autenticación
const token = cookies.get('session_token');
if (!token) {
throw redirect(303, '/login');
}
const session = verifyToken(token);
if (!session) {
throw redirect(303, '/login');
}
const currentUser = await getUserById(session.userId);
if (!currentUser || !currentUser.es_admin) {
throw redirect(303, '/');
}
// Obtener lista de usuarios
const usuarios = await listUsers();
// Obtener lista de todas las bases de datos disponibles
try {
// Primero obtener las bases de datos del servidor secundario
const secondary = await db.getSecondary();
const resultDB = await secondary.request().query(`
SELECT
d.name AS visible_name
FROM sys.databases d
WHERE d.name NOT IN ('master', 'tempdb', 'model', 'msdb')
ORDER BY d.name
`);
let allDatabases: any[] = resultDB.recordset;
// Enriquecer con datos de CONTROLDESK (nombre del cliente, nodo, etc.)
try {
const azure = await db.getAzure();
const resultControlDesk = await azure.request().query(`
SELECT ID, NodoSubNodo, Activo, RFC, Nombre, Sucursal, CorreoNotificacion, ServerName, BDName
FROM [CONTROLDESK].[dbo].[BasesDeDatos]
`);
const basesControlDesk = resultControlDesk.recordset as any[];
const mapByBdName = new Map<string, any>();
for (const bd of basesControlDesk) {
if (bd.BDName) {
mapByBdName.set(String(bd.BDName).toLowerCase(), bd);
}
}
// Enriquecer allDatabases con la información de CONTROLDESK
allDatabases = allDatabases.map((db: any) => {
const key = String(db.visible_name || '').toLowerCase();
const match = mapByBdName.get(key);
if (!match) return db;
return {
...db,
NodoSubNodo: match.NodoSubNodo,
Nombre: match.Nombre, // Nombre del cliente
RFC: match.RFC,
Sucursal: match.Sucursal,
BDName: match.BDName
};
});
} catch (error) {
console.error('Error enriqueciendo bases de datos con CONTROLDESK:', error);
}
return {
usuarios,
currentUser,
allDatabases
};
} catch (error) {
console.error('Error cargando bases de datos:', error);
return {
usuarios,
currentUser,
allDatabases: []
};
}
};
export const actions: Actions = {
createUser: async ({ request }) => {
const data = await request.formData();
const username = data.get('username')?.toString();
const email = data.get('email')?.toString();
const password = data.get('password')?.toString();
const nombre_completo = data.get('nombre_completo')?.toString();
const es_admin = data.get('es_admin') === '1';
if (!username || !email || !password) {
return fail(400, { error: 'Datos incompletos' });
}
try {
await createUser({
username,
email,
password,
nombre_completo,
es_admin
});
return { success: true };
} catch (error: any) {
console.error('Error creando usuario:', error);
return fail(500, { error: error.message || 'Error al crear usuario' });
}
},
updateUser: async ({ request }) => {
const data = await request.formData();
const userId = parseInt(data.get('userId')?.toString() || '0');
const email = data.get('email')?.toString();
const nombre_completo = data.get('nombre_completo')?.toString();
const password = data.get('password')?.toString();
const es_admin = data.get('es_admin') === '1';
const activo = data.get('activo') === '1';
if (!userId) {
return fail(400, { error: 'ID de usuario requerido' });
}
try {
const updateData: any = {};
if (email) updateData.email = email;
if (nombre_completo !== undefined) updateData.nombre_completo = nombre_completo;
if (password) updateData.password = password;
if (data.has('es_admin')) updateData.es_admin = es_admin;
if (data.has('activo')) updateData.activo = activo;
await updateUser(userId, updateData);
return { success: true };
} catch (error: any) {
console.error('Error actualizando usuario:', error);
return fail(500, { error: error.message || 'Error al actualizar usuario' });
}
},
deleteUser: async ({ request }) => {
const data = await request.formData();
const userId = parseInt(data.get('userId')?.toString() || '0');
if (!userId) {
return fail(400, { error: 'ID de usuario requerido' });
}
try {
await deleteUser(userId);
return { success: true };
} catch (error: any) {
console.error('Error eliminando usuario:', error);
return fail(500, { error: error.message || 'Error al eliminar usuario' });
}
},
managePermissions: async ({ request }) => {
const data = await request.formData();
const userId = parseInt(data.get('userId')?.toString() || '0');
const baseDatosNombre = data.get('baseDatosNombre')?.toString();
const action = data.get('action')?.toString();
if (!userId || !baseDatosNombre || !action) {
return fail(400, { error: 'Datos incompletos' });
}
try {
if (action === 'assign') {
await assignDatabaseToUser(userId, baseDatosNombre);
} else if (action === 'remove') {
await removeDatabaseFromUser(userId, baseDatosNombre);
}
return { success: true };
} catch (error: any) {
console.error('Error gestionando permisos:', error);
return fail(500, { error: error.message || 'Error al gestionar permisos' });
}
}
};

View File

@@ -0,0 +1,614 @@
<script lang="ts">
import aduanasoftLogo from '$lib/assets/logo.png';
let { data } = $props();
const { usuarios, currentUser, allDatabases } = data;
let usuariosList = $state(usuarios || []);
let searchTerm = $state('');
let showCreateModal = $state(false);
let showEditModal = $state(false);
let showPermissionsModal = $state(false);
let selectedUser = $state<any>(null);
// Formulario de crear/editar usuario
let formData = $state({
username: '',
email: '',
nombre_completo: '',
password: '',
es_admin: false
});
// Permisos de bases de datos
let userPermissions = $state<string[]>([]);
let permissionSearch = $state('');
const filteredUsers = $derived(
usuariosList.filter(u =>
u.username.toLowerCase().includes(searchTerm.toLowerCase()) ||
u.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
u.nombre_completo?.toLowerCase().includes(searchTerm.toLowerCase())
)
);
const filteredDatabases = $derived(
allDatabases.filter(db => {
const searchLower = permissionSearch.toLowerCase();
return (
(db.client_name || '').toLowerCase().includes(searchLower) ||
(db.Nombre || '').toLowerCase().includes(searchLower) ||
(db.NodoSubNodo || '').toLowerCase().includes(searchLower) ||
db.visible_name.toLowerCase().includes(searchLower)
);
})
);
function openCreateModal() {
formData = {
username: '',
email: '',
nombre_completo: '',
password: '',
es_admin: false
};
showCreateModal = true;
}
function openEditModal(user: any) {
selectedUser = user;
formData = {
username: user.username,
email: user.email,
nombre_completo: user.nombre_completo || '',
password: '',
es_admin: user.es_admin
};
showEditModal = true;
}
async function openPermissionsModal(user: any) {
selectedUser = user;
// Cargar permisos del usuario
const res = await fetch(`/usuarios/api/permissions?userId=${user.id}`);
if (res.ok) {
const data = await res.json();
userPermissions = data.permissions || [];
}
showPermissionsModal = true;
}
async function submitCreate() {
const form = new FormData();
form.append('username', formData.username);
form.append('email', formData.email);
form.append('nombre_completo', formData.nombre_completo);
form.append('password', formData.password);
form.append('es_admin', formData.es_admin ? '1' : '0');
const res = await fetch('?/createUser', {
method: 'POST',
body: form
});
if (res.ok) {
window.location.reload();
} else {
alert('Error al crear usuario');
}
}
async function submitEdit() {
const form = new FormData();
form.append('userId', String(selectedUser.id));
form.append('email', formData.email);
form.append('nombre_completo', formData.nombre_completo);
form.append('es_admin', formData.es_admin ? '1' : '0');
if (formData.password) {
form.append('password', formData.password);
}
const res = await fetch('?/updateUser', {
method: 'POST',
body: form
});
if (res.ok) {
window.location.reload();
} else {
alert('Error al actualizar usuario');
}
}
async function toggleUserStatus(user: any) {
if (!confirm(`¿${user.activo ? 'Desactivar' : 'Activar'} usuario ${user.username}?`)) {
return;
}
const form = new FormData();
form.append('userId', String(user.id));
form.append('activo', user.activo ? '0' : '1');
const res = await fetch('?/updateUser', {
method: 'POST',
body: form
});
if (res.ok) {
window.location.reload();
}
}
async function deleteUser(user: any) {
if (!confirm(`¿Eliminar permanentemente al usuario ${user.username}?`)) {
return;
}
const form = new FormData();
form.append('userId', String(user.id));
const res = await fetch('?/deleteUser', {
method: 'POST',
body: form
});
if (res.ok) {
window.location.reload();
}
}
async function togglePermission(dbName: string) {
const hasPermission = userPermissions.includes(dbName);
const form = new FormData();
form.append('userId', String(selectedUser.id));
form.append('baseDatosNombre', dbName);
form.append('action', hasPermission ? 'remove' : 'assign');
const res = await fetch('?/managePermissions', {
method: 'POST',
body: form
});
if (res.ok) {
if (hasPermission) {
userPermissions = userPermissions.filter(p => p !== dbName);
} else {
userPermissions = [...userPermissions, dbName];
}
}
}
</script>
<svelte:head>
<title>Gestión de Usuarios - Aduanasoft</title>
</svelte:head>
<div class="flex min-h-screen bg-slate-50">
<!-- Sidebar fijo -->
<aside class="fixed inset-y-0 left-0 hidden w-64 border-r bg-white lg:block z-20">
<div class="flex h-full flex-col">
<div class="flex h-20 items-center border-b bg-slate-50/80 px-4">
<div class="flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center rounded-2xl bg-sky-100 shadow-sm ring-1 ring-sky-200/60">
<img
src={aduanasoftLogo}
alt="Aduanasoft"
class="h-9 w-9 object-contain"
/>
</div>
<div class="space-y-0.5">
<p class="text-sm font-semibold text-slate-900 tracking-tight">Aduanasoft</p>
<p class="text-[0.7rem] text-slate-500">Bases de datos</p>
</div>
</div>
</div>
<nav class="flex-1 space-y-6 px-3 py-4 text-xs">
<div>
<p class="mb-1 px-2 text-[0.65rem] font-semibold uppercase tracking-wide text-slate-500">
Home
</p>
<a
href="/"
class="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-[0.75rem] font-medium bg-white text-slate-700 hover:bg-slate-100 border border-slate-200"
>
<span class="material-icons-outlined text-sm">dashboard</span>
Panel Principal
</a>
</div>
{#if currentUser?.es_admin}
<div>
<p class="mb-1 px-2 text-[0.65rem] font-semibold uppercase tracking-wide text-slate-500">
Administración
</p>
<div class="space-y-1.5">
<a
href="/usuarios"
class="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-[0.75rem] text-left bg-sky-50 text-sky-800 border border-sky-100"
>
<span class="material-icons-outlined text-sm">manage_accounts</span>
Gestión de Usuarios
</a>
</div>
</div>
{/if}
</nav>
<div class="border-t px-4 py-3 text-[0.7rem] text-slate-500">
<span class="font-semibold text-slate-700">Aduanasoft</span>
<span class="mx-1">·</span>
<span>Bases de datos SCAII</span>
<span class="mx-1">·</span>
<span class="text-slate-400">v1.0.0</span>
</div>
</div>
</aside>
<!-- Main -->
<div class="flex min-h-screen flex-1 flex-col lg:pl-64">
<!-- Top bar fijo -->
<header class="fixed top-0 right-0 z-30 flex h-14 w-full items-center justify-between border-b bg-white px-4 lg:w-[calc(100%-16rem)]">
<div class="flex items-center gap-3">
<button class="inline-flex items-center justify-center rounded-lg border bg-white px-2.5 py-1.5 text-xs text-slate-700 shadow-sm lg:hidden">
<span class="material-icons-outlined text-base">menu</span>
</button>
<div class="space-y-0.5">
<h1 class="text-sm font-semibold text-slate-900">Gestión de Usuarios</h1>
</div>
</div>
<div class="flex items-center gap-3 text-xs">
<div class="hidden items-center gap-2 rounded-full border bg-white px-2.5 py-1.5 text-slate-600 shadow-sm sm:flex">
<span class="material-icons-outlined text-sm">person</span>
<span>{currentUser?.nombre_completo || currentUser?.username || 'Usuario'}</span>
</div>
<form method="POST" action="/logout" class="inline">
<button type="submit" class="inline-flex items-center gap-1 rounded-full border border-red-200 bg-white px-2.5 py-1.5 text-red-600 shadow-sm hover:bg-red-50 transition-colors">
<span class="material-icons-outlined text-sm">logout</span>
<span class="hidden sm:inline">Cerrar sesión</span>
</button>
</form>
</div>
</header>
<!-- Content -->
<main class="mt-14 flex-1 bg-slate-50 px-1 pb-6 pt-4 sm:px-2 lg:px-3">
<div class="flex w-full min-h-[calc(100vh-5.5rem)] flex-col rounded-2xl bg-white p-3 shadow-[0_16px_40px_rgba(15,23,42,0.03)] sm:p-5 lg:p-6">
<!-- Header -->
<div class="mb-6 flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-slate-900">Usuarios del Sistema</h2>
<p class="text-sm text-slate-500">Administra usuarios y sus permisos de acceso a bases de datos</p>
</div>
<button
onclick={openCreateModal}
class="inline-flex items-center gap-2 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-sky-700"
>
<span class="material-icons-outlined text-base">add</span>
Nuevo Usuario
</button>
</div>
<!-- Search -->
<div class="mb-4">
<input
type="search"
bind:value={searchTerm}
placeholder="Buscar por usuario, email o nombre..."
class="w-full max-w-md rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
/>
</div>
<!-- Users Table -->
<div class="rounded-xl bg-white shadow-sm ring-1 ring-slate-200">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-slate-200">
<thead class="bg-slate-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-slate-500">
Usuario
</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-slate-500">
Email
</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-slate-500">
Nombre Completo
</th>
<th class="px-6 py-3 text-center text-xs font-medium uppercase tracking-wider text-slate-500">
Rol
</th>
<th class="px-6 py-3 text-center text-xs font-medium uppercase tracking-wider text-slate-500">
Estado
</th>
<th class="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-slate-500">
Acciones
</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 bg-white">
{#each filteredUsers as user}
<tr class="hover:bg-slate-50">
<td class="whitespace-nowrap px-6 py-4 text-sm font-medium text-slate-900">
{user.username}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-slate-600">
{user.email}
</td>
<td class="px-6 py-4 text-sm text-slate-600">
{user.nombre_completo || '-'}
</td>
<td class="whitespace-nowrap px-6 py-4 text-center">
{#if user.es_admin}
<span class="inline-flex items-center rounded-full bg-purple-100 px-2.5 py-0.5 text-xs font-medium text-purple-800">
Administrador
</span>
{:else}
<span class="inline-flex items-center rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-medium text-slate-800">
Usuario
</span>
{/if}
</td>
<td class="whitespace-nowrap px-6 py-4 text-center">
{#if user.activo}
<span class="inline-flex items-center rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-medium text-emerald-800">
Activo
</span>
{:else}
<span class="inline-flex items-center rounded-full bg-red-100 px-2.5 py-0.5 text-xs font-medium text-red-800">
Inactivo
</span>
{/if}
</td>
<td class="whitespace-nowrap px-6 py-4 text-right text-sm">
<div class="flex justify-end gap-2">
<button
onclick={() => openPermissionsModal(user)}
class="text-sky-600 hover:text-sky-900"
title="Gestionar permisos"
>
<span class="material-icons-outlined text-base">lock</span>
</button>
<button
onclick={() => openEditModal(user)}
class="text-slate-600 hover:text-slate-900"
title="Editar"
>
<span class="material-icons-outlined text-base">edit</span>
</button>
<button
onclick={() => toggleUserStatus(user)}
class="text-amber-600 hover:text-amber-900"
title={user.activo ? 'Desactivar' : 'Activar'}
>
<span class="material-icons-outlined text-base">
{user.activo ? 'toggle_on' : 'toggle_off'}
</span>
</button>
{#if user.id !== currentUser.id}
<button
onclick={() => deleteUser(user)}
class="text-red-600 hover:text-red-900"
title="Eliminar"
>
<span class="material-icons-outlined text-base">delete</span>
</button>
{/if}
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</div>
</main>
</div>
</div>
<!-- Modal Crear Usuario -->
{#if showCreateModal}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="w-full max-w-md rounded-xl bg-white p-6 shadow-xl">
<h2 class="mb-4 text-lg font-semibold text-slate-900">Crear Nuevo Usuario</h2>
<div class="space-y-4">
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">Usuario</label>
<input
type="text"
bind:value={formData.username}
class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
required
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">Email</label>
<input
type="email"
bind:value={formData.email}
class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
required
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">Nombre Completo</label>
<input
type="text"
bind:value={formData.nombre_completo}
class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">Contraseña</label>
<input
type="password"
bind:value={formData.password}
class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
required
/>
<p class="mt-1 text-xs text-slate-500">Mínimo 8 caracteres, incluir mayúsculas, minúsculas, números y símbolos</p>
</div>
<div class="flex items-center gap-2">
<input
type="checkbox"
id="es_admin_create"
bind:checked={formData.es_admin}
class="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500"
/>
<label for="es_admin_create" class="text-sm text-slate-700">
Es Administrador
</label>
</div>
</div>
<div class="mt-6 flex justify-end gap-2">
<button
onclick={() => showCreateModal = false}
class="rounded-lg border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
>
Cancelar
</button>
<button
onclick={submitCreate}
class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700"
>
Crear Usuario
</button>
</div>
</div>
</div>
{/if}
<!-- Modal Editar Usuario -->
{#if showEditModal && selectedUser}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="w-full max-w-md rounded-xl bg-white p-6 shadow-xl">
<h2 class="mb-4 text-lg font-semibold text-slate-900">Editar Usuario: {selectedUser.username}</h2>
<div class="space-y-4">
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">Email</label>
<input
type="email"
bind:value={formData.email}
class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">Nombre Completo</label>
<input
type="text"
bind:value={formData.nombre_completo}
class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">Nueva Contraseña (dejar vacío para no cambiar)</label>
<input
type="password"
bind:value={formData.password}
class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
/>
</div>
<div class="flex items-center gap-2">
<input
type="checkbox"
id="es_admin_edit"
bind:checked={formData.es_admin}
class="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500"
/>
<label for="es_admin_edit" class="text-sm text-slate-700">
Es Administrador
</label>
</div>
</div>
<div class="mt-6 flex justify-end gap-2">
<button
onclick={() => showEditModal = false}
class="rounded-lg border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
>
Cancelar
</button>
<button
onclick={submitEdit}
class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700"
>
Guardar Cambios
</button>
</div>
</div>
</div>
{/if}
<!-- Modal Permisos -->
{#if showPermissionsModal && selectedUser}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="w-full max-w-2xl rounded-xl bg-white p-6 shadow-xl">
<h2 class="mb-4 text-lg font-semibold text-slate-900">
Permisos de: {selectedUser.username}
</h2>
{#if selectedUser.es_admin}
<div class="mb-4 rounded-lg bg-purple-50 p-3 text-sm text-purple-700">
<p class="font-medium">Este usuario es administrador y tiene acceso a todas las bases de datos.</p>
</div>
{:else}
<div class="mb-4">
<input
type="search"
bind:value={permissionSearch}
placeholder="Buscar base de datos..."
class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
/>
</div>
<div class="max-h-96 overflow-y-auto rounded-lg border border-slate-200">
<div class="divide-y divide-slate-100">
{#each filteredDatabases as db}
{@const hasPermission = userPermissions.includes(db.visible_name)}
<div class="flex items-center justify-between p-3 hover:bg-slate-50">
<div class="flex flex-col">
<span class="text-sm font-medium text-slate-900">{db.Nombre || db.visible_name}</span>
{#if db.NodoSubNodo}
<span class="text-xs text-slate-500">{db.NodoSubNodo}</span>
{/if}
</div>
<button
onclick={() => togglePermission(db.visible_name)}
class={hasPermission
? 'rounded-lg bg-emerald-100 px-3 py-1 text-xs font-medium text-emerald-700'
: 'rounded-lg bg-slate-100 px-3 py-1 text-xs font-medium text-slate-600'}
>
{hasPermission ? 'Con Acceso' : 'Sin Acceso'}
</button>
</div>
{/each}
</div>
</div>
{/if}
<div class="mt-6 flex justify-end">
<button
onclick={() => showPermissionsModal = false}
class="rounded-lg bg-slate-600 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700"
>
Cerrar
</button>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,19 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getUserDatabasePermissions } from '$lib/server/users';
export const GET: RequestHandler = async ({ url }) => {
const userId = parseInt(url.searchParams.get('userId') || '0');
if (!userId) {
return json({ error: 'userId requerido' }, { status: 400 });
}
try {
const permissions = await getUserDatabasePermissions(userId);
return json({ permissions });
} catch (error) {
console.error('Error obteniendo permisos:', error);
return json({ error: 'Error al obtener permisos' }, { status: 500 });
}
};