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

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();
}
}