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

30
.env.example Normal file
View File

@@ -0,0 +1,30 @@
# SQL Server - Servidor Primary (.202)
DB_PRIMARY_HOST=104.197.7.202
DB_PRIMARY_USER=sa
DB_PRIMARY_PASS=Clave.2025
DB_PRIMARY_DB=master
# SQL Server - Servidor Secondary (.152)
DB_SECONDARY_HOST=104.192.7.152
DB_SECONDARY_USER=sa
DB_SECONDARY_PASS=Clave.2025
DB_SECONDARY_DB=master
# SQL Server - Azure/CONTROLDESK
DB_AZURE_HOST=104.192.7.152
DB_AZURE_USER=sa
DB_AZURE_PASS=Clave.2025
DB_AZURE_DB=CONTROLDESK
# PostgreSQL - Base de datos de usuarios (Docker local)
DB_POSTGRES_HOST=localhost
DB_POSTGRES_PORT=5432
DB_POSTGRES_USER=postgres
DB_POSTGRES_PASS=Control.
DB_POSTGRES_DB=CONTROLDESK
# JWT Secret (cambiar en producción)
JWT_SECRET=change-this-secret-in-production-please-use-a-long-random-string
# Ruta de backups
BACKUP_PATH=D:/BackupSFTP/

322
INSTALACION_USUARIOS.md Normal file
View File

@@ -0,0 +1,322 @@
# 🚀 Sistema Completo de Gestión de Usuarios y Permisos
## ✅ ¿Qué se ha implementado?
### 1. **Sistema de Autenticación**
- ✅ Login con usuario y contraseña
- ✅ Tokens JWT seguros
- ✅ Cookies HTTP-only
- ✅ Sesiones persistentes (7 días)
- ✅ Logout seguro
- ✅ Protección de rutas
### 2. **Gestión de Usuarios (Solo Admins)**
- ✅ Crear nuevos usuarios
- ✅ Editar usuarios existentes
- ✅ Activar/desactivar usuarios
- ✅ Eliminar usuarios
- ✅ Asignar rol de administrador
### 3. **Sistema de Permisos**
- ✅ Permisos granulares por base de datos
- ✅ Asignación/revocación de permisos
- ✅ Filtrado automático de bases de datos según permisos
- ✅ Administradores con acceso total
### 4. **Base de Datos PostgreSQL**
- ✅ Tabla de usuarios
- ✅ Tabla de permisos
- ✅ Tabla de sesiones
- ✅ Índices para rendimiento
- ✅ Constraints de integridad
### 5. **Seguridad**
- ✅ Contraseñas hasheadas con bcrypt
- ✅ Validación de contraseñas fuertes
- ✅ Tokens JWT con expiración
- ✅ Protección contra SQL injection
- ✅ Sanitización de entradas
## 📦 Archivos Creados
### Backend (Server)
```
src/lib/server/
├── auth.ts # Utilidades de autenticación JWT y bcrypt
├── users.ts # CRUD de usuarios y permisos
└── db.ts # Conexión a PostgreSQL (actualizado)
src/routes/
├── login/
│ ├── +page.svelte # Interfaz de login
│ └── +page.server.ts # Lógica de autenticación
├── logout/
│ └── +server.ts # Cerrar sesión
├── usuarios/
│ ├── +page.svelte # Gestión de usuarios (UI)
│ ├── +page.server.ts # CRUD y permisos
│ └── api/permissions/
│ └── +server.ts # API de permisos
└── +page.server.ts # Panel principal (actualizado con filtros)
```
### Base de Datos
```
database/
└── schema.sql # Script de creación de tablas
```
### Documentación
```
docs/
└── USUARIOS.md # Guía completa del sistema
scripts/
├── generate-password-hash.js # Generar hash de contraseñas
└── init-users.sh # Script de inicialización
```
### Configuración
```
.env.example # Variables de entorno de ejemplo
```
## 🛠️ Instalación y Configuración
### Paso 1: Instalar Dependencias
```bash
npm install
```
Las dependencias ya están en package.json:
- `bcrypt` - Hash de contraseñas
- `jsonwebtoken` - Tokens JWT
- `pg` - Cliente PostgreSQL
- `@types/pg`, `@types/bcrypt`, `@types/jsonwebtoken` - Tipos TypeScript
### Paso 2: Configurar PostgreSQL
#### Opción A: Desde la línea de comandos
```bash
psql -h 10.0.20.152 -U postgres -d CONTROLDESK -f database/schema.sql
```
#### Opción B: Desde pgAdmin o DBeaver
1. Abre el archivo `database/schema.sql`
2. Copia todo el contenido
3. Pégalo en la consola SQL
4. Ejecuta el script
### Paso 3: Generar Contraseña del Admin
```bash
node scripts/generate-password-hash.js
```
Este script generará un hash para la contraseña `Admin123!`.
**IMPORTANTE:** Copia el hash generado y actualiza el archivo `database/schema.sql` en la línea del INSERT del usuario admin, reemplazando el hash temporal.
Luego vuelve a ejecutar el INSERT en PostgreSQL:
```sql
INSERT INTO usuarios (username, email, password_hash, nombre_completo, es_admin, activo)
VALUES (
'admin',
'admin@aduanasoft.com',
'EL_HASH_QUE_GENERASTE', -- <-- Pega aquí el hash
'Administrador del Sistema',
true,
true
)
ON CONFLICT (username) DO UPDATE SET
password_hash = EXCLUDED.password_hash;
```
### Paso 4: Configurar Variables de Entorno
```bash
cp .env.example .env
```
Edita `.env` y actualiza los valores:
```env
# PostgreSQL
DB_POSTGRES_HOST=10.0.20.152
DB_POSTGRES_PORT=5432
DB_POSTGRES_USER=postgres
DB_POSTGRES_PASS=Control.
DB_POSTGRES_DB=CONTROLDESK
# JWT Secret (¡CÁMBIALO!)
JWT_SECRET=un-secreto-muy-largo-y-aleatorio-para-produccion
```
**⚠️ IMPORTANTE:** En producción, genera un JWT_SECRET aleatorio y seguro:
```bash
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
```
### Paso 5: Iniciar el Servidor
```bash
npm run dev
```
### Paso 6: Primer Login
1. Ve a `http://localhost:5173/login`
2. Usuario: `admin`
3. Contraseña: `Admin123!` (o la que configuraste)
## 📖 Uso del Sistema
### Para Administradores
#### 1. Crear un Nuevo Usuario
1. Ve a "Gestión de Usuarios" en el sidebar
2. Haz clic en "Nuevo Usuario"
3. Completa el formulario:
- **Usuario**: Nombre de usuario único
- **Email**: Correo electrónico
- **Nombre Completo**: Nombre real
- **Contraseña**: Mínimo 8 caracteres con mayúsculas, minúsculas, números y símbolos
- **Es Administrador**: Marca si quieres que sea admin
4. Haz clic en "Crear Usuario"
#### 2. Asignar Permisos de Bases de Datos
1. En la lista de usuarios, haz clic en el ícono 🔒 (candado)
2. Verás todas las bases de datos disponibles
3. Haz clic en "Sin Acceso" para otorgar acceso
4. Haz clic en "Con Acceso" para revocar acceso
5. Los cambios se guardan automáticamente
#### 3. Editar un Usuario
1. Haz clic en el ícono ✏️ (editar)
2. Modifica los campos que necesites
3. Para cambiar la contraseña, escribe una nueva (si dejas vacío, no se cambia)
4. Haz clic en "Guardar Cambios"
#### 4. Desactivar un Usuario
1. Haz clic en el ícono de toggle (activar/desactivar)
2. Confirma la acción
3. El usuario no podrá iniciar sesión, pero sus datos se conservan
#### 5. Eliminar un Usuario
1. Haz clic en el ícono 🗑️ (eliminar)
2. Confirma la acción
3. **¡CUIDADO!** Esta acción es permanente
### Para Usuarios Normales
Los usuarios normales solo verán:
- Las bases de datos que les han sido asignadas
- No tendrán acceso a "Gestión de Usuarios"
- El resto del panel funciona igual
## 🔒 Cómo Funciona la Seguridad
### Flujo de Autenticación
```
1. Usuario ingresa credenciales
2. Servidor verifica con bcrypt
3. Se genera token JWT
4. Token se guarda en cookie HTTP-only
5. Cada petición verifica el token
6. Se aplican filtros de permisos
```
### Filtrado de Bases de Datos
```typescript
// En +page.server.ts (líneas 376-379)
if (!currentUser.es_admin) {
// Filtra solo las bases de datos que el usuario puede ver
databaseRows = await filterDatabasesByUserPermissions(currentUser.id, databaseRows);
alertsData = await filterDatabasesByUserPermissions(currentUser.id, alertsData);
}
```
## 📊 Estructura de la Base de Datos
### Tabla `usuarios`
Almacena información de los usuarios del sistema.
| Campo | Tipo | Descripción |
|-------|------|-------------|
| id | SERIAL | ID único |
| username | VARCHAR(50) | Nombre de usuario único |
| email | VARCHAR(100) | Email único |
| password_hash | VARCHAR(255) | Contraseña hasheada |
| nombre_completo | VARCHAR(100) | Nombre completo |
| activo | BOOLEAN | Si el usuario está activo |
| es_admin | BOOLEAN | Si es administrador |
| fecha_creacion | TIMESTAMP | Fecha de creación |
| ultimo_acceso | TIMESTAMP | Último login |
### Tabla `usuario_base_datos`
Relaciona usuarios con bases de datos y sus permisos.
| Campo | Tipo | Descripción |
|-------|------|-------------|
| id | SERIAL | ID único |
| usuario_id | INTEGER | FK a usuarios |
| base_datos_nombre | VARCHAR(255) | Nombre de la BD |
| puede_ver | BOOLEAN | Permiso de visualización |
| puede_descargar_backup | BOOLEAN | Permiso de descarga (futuro) |
| puede_restaurar | BOOLEAN | Permiso de restauración (futuro) |
| fecha_asignacion | TIMESTAMP | Fecha de asignación |
## 🐛 Solución de Problemas
### "No puedo iniciar sesión"
1. Verifica que el hash de contraseña sea correcto en la BD
2. Revisa los logs del servidor (terminal donde corre `npm run dev`)
3. Asegúrate de que PostgreSQL esté corriendo
4. Verifica las credenciales en `.env`
### "No veo ninguna base de datos"
1. Si eres admin, deberías ver todas
2. Si eres usuario normal, verifica que tengas permisos asignados:
```sql
SELECT * FROM usuario_base_datos WHERE usuario_id = TU_ID;
```
### "Error de conexión a PostgreSQL"
1. Verifica que PostgreSQL esté corriendo:
```bash
psql -h 10.0.20.152 -U postgres -c "SELECT version();"
```
2. Revisa el firewall del servidor
3. Verifica las credenciales en `.env`
### "Invalid token" o "Session expired"
1. Cierra sesión y vuelve a iniciar
2. Limpia las cookies del navegador
3. Verifica que `JWT_SECRET` sea el mismo en `.env`
## 🎯 Próximos Pasos Recomendados
1. **Cambiar el JWT_SECRET** en `.env` a un valor aleatorio y seguro
2. **Actualizar la contraseña del admin** después del primer login
3. **Crear usuarios** para tu equipo
4. **Asignar permisos** según las responsabilidades de cada usuario
5. **Probar el sistema** con diferentes usuarios
6. **Configurar backups** de la base de datos PostgreSQL
7. **Documentar** los usuarios y sus permisos para tu organización
## 📞 Soporte
Para preguntas o problemas, contacta al equipo de desarrollo.
---
**Desarrollado para Aduanasoft** - Panel de Gestión de Bases de Datos SCAII

66
database/schema.sql Normal file
View File

@@ -0,0 +1,66 @@
-- Schema para sistema de usuarios y permisos
-- Ejecutar en PostgreSQL (base de datos CONTROLDESK)
-- Tabla de usuarios
CREATE TABLE IF NOT EXISTS usuarios (
id SERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
nombre_completo VARCHAR(100),
activo BOOLEAN DEFAULT true,
es_admin BOOLEAN DEFAULT false,
fecha_creacion TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ultimo_acceso TIMESTAMP,
CONSTRAINT chk_username_length CHECK (char_length(username) >= 3)
);
-- Tabla de permisos de usuario a bases de datos
CREATE TABLE IF NOT EXISTS usuario_base_datos (
id SERIAL PRIMARY KEY,
usuario_id INTEGER NOT NULL REFERENCES usuarios(id) ON DELETE CASCADE,
base_datos_nombre VARCHAR(255) NOT NULL, -- Nombre de la base de datos (visible_name)
puede_ver BOOLEAN DEFAULT true,
puede_descargar_backup BOOLEAN DEFAULT false,
puede_restaurar BOOLEAN DEFAULT false,
fecha_asignacion TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(usuario_id, base_datos_nombre)
);
-- Tabla de sesiones
CREATE TABLE IF NOT EXISTS sesiones (
id SERIAL PRIMARY KEY,
usuario_id INTEGER NOT NULL REFERENCES usuarios(id) ON DELETE CASCADE,
token VARCHAR(255) UNIQUE NOT NULL,
ip_address VARCHAR(50),
user_agent TEXT,
fecha_creacion TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
fecha_expiracion TIMESTAMP NOT NULL,
activa BOOLEAN DEFAULT true
);
-- Índices para mejor rendimiento
CREATE INDEX IF NOT EXISTS idx_usuario_base_datos_usuario ON usuario_base_datos(usuario_id);
CREATE INDEX IF NOT EXISTS idx_usuario_base_datos_nombre ON usuario_base_datos(base_datos_nombre);
CREATE INDEX IF NOT EXISTS idx_usuarios_activo ON usuarios(activo);
CREATE INDEX IF NOT EXISTS idx_sesiones_token ON sesiones(token);
CREATE INDEX IF NOT EXISTS idx_sesiones_usuario ON sesiones(usuario_id);
-- Insertar usuario admin por defecto
-- Password: Admin123!
-- Nota: Este hash es temporal, debes cambiarlo después del primer inicio de sesión
INSERT INTO usuarios (username, email, password_hash, nombre_completo, es_admin, activo)
VALUES (
'admin',
'admin@aduanasoft.com',
'$2b$10$rKvvkYpNwF5J5K5J5J5J5uX5X5X5X5X5X5X5X5X5X5X5X5X5X5X5X',
'Administrador del Sistema',
true,
true
)
ON CONFLICT (username) DO NOTHING;
-- Comentarios en tablas
COMMENT ON TABLE usuarios IS 'Usuarios del sistema de gestión de bases de datos';
COMMENT ON TABLE usuario_base_datos IS 'Permisos de acceso de usuarios a bases de datos específicas';
COMMENT ON TABLE sesiones IS 'Sesiones activas de usuarios';

View File

@@ -0,0 +1,52 @@
version: '3.8'
services:
postgres:
image: postgres:15-alpine
container_name: aduanasoft-postgres
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: Control.
POSTGRES_DB: CONTROLDESK
PGDATA: /var/lib/postgresql/data/pgdata
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./database/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
networks:
- aduanasoft-network
# Tu aplicación Node.js (opcional, comentado por ahora)
# app:
# build: .
# container_name: aduanasoft-app
# restart: unless-stopped
# ports:
# - "3000:3000"
# environment:
# - NODE_ENV=production
# - DB_POSTGRES_HOST=postgres
# - DB_POSTGRES_PORT=5432
# - DB_POSTGRES_USER=postgres
# - DB_POSTGRES_PASS=Control.
# - DB_POSTGRES_DB=CONTROLDESK
# depends_on:
# postgres:
# condition: service_healthy
# networks:
# - aduanasoft-network
volumes:
postgres_data:
driver: local
networks:
aduanasoft-network:
driver: bridge

220
docs/USUARIOS.md Normal file
View File

@@ -0,0 +1,220 @@
# Sistema de Gestión de Usuarios y Permisos
Este sistema permite administrar usuarios y controlar qué bases de datos puede ver cada usuario.
## Características
- ✅ Autenticación con JWT
- ✅ Roles de usuario (Admin y Usuario normal)
- ✅ Permisos granulares por base de datos
- ✅ Gestión completa de usuarios (CRUD)
- ✅ Sesiones seguras con cookies HTTP-only
- ✅ Validación de contraseñas fuertes
## Instalación
### 1. Instalar dependencias
```bash
npm install bcrypt jsonwebtoken pg @types/pg @types/bcrypt @types/jsonwebtoken
```
### 2. Configurar PostgreSQL
Ejecuta el script SQL en tu base de datos PostgreSQL:
```bash
psql -h 10.0.20.152 -U postgres -d CONTROLDESK -f database/schema.sql
```
O manualmente desde pgAdmin o DBeaver.
### 3. Generar hash de contraseña para admin
```bash
node scripts/generate-password-hash.js
```
Copia el hash generado y actualiza el script `database/schema.sql` con la contraseña real.
### 4. Configurar variables de entorno
Copia `.env.example` a `.env` y actualiza los valores:
```bash
cp .env.example .env
```
Asegúrate de cambiar el `JWT_SECRET` por un valor seguro y aleatorio.
## Uso
### Primer Login
1. Ve a `http://localhost:5173/login`
2. Usuario: `admin`
3. Contraseña: `Admin123!` (o la que hayas configurado)
### Gestión de Usuarios (Solo Admins)
1. Inicia sesión como admin
2. Ve a "Gestión de Usuarios" en el sidebar
3. Podrás:
- Crear nuevos usuarios
- Editar usuarios existentes
- Activar/desactivar usuarios
- Eliminar usuarios
- Asignar permisos de bases de datos
### Asignar Permisos
1. En la página de "Gestión de Usuarios", haz clic en el ícono de candado 🔒 del usuario
2. Verás la lista de todas las bases de datos disponibles
3. Haz clic en "Sin Acceso" para otorgar acceso a una base de datos
4. Haz clic en "Con Acceso" para revocar el acceso
**Nota:** Los usuarios con rol de Administrador tienen acceso a todas las bases de datos automáticamente.
### Permisos
Cada usuario puede tener los siguientes permisos por base de datos:
- **puede_ver**: Permite ver la base de datos en el panel
- **puede_descargar_backup**: Permite descargar archivos de backup (futuro)
- **puede_restaurar**: Permite ejecutar restauraciones (futuro)
Actualmente, el sistema filtra las bases de datos que el usuario puede ver en:
- Panel principal (dashboard)
- Alertas críticas
## Roles
### Administrador
- Acceso completo a todas las bases de datos
- Puede gestionar usuarios y permisos
- Puede ver la sección "Gestión de Usuarios"
### Usuario Normal
- Solo ve las bases de datos que le han sido asignadas
- No puede gestionar usuarios
- No tiene acceso a la sección de administración
## Seguridad
### Contraseñas
Las contraseñas deben cumplir con los siguientes requisitos:
- Mínimo 8 caracteres
- Al menos una mayúscula
- Al menos una minúscula
- Al menos un número
- Al menos un carácter especial (!@#$%^&*(),.?":{}|<>)
### Sesiones
- Las sesiones usan JWT (JSON Web Tokens)
- Los tokens se almacenan en cookies HTTP-only (no accesibles desde JavaScript)
- Los tokens expiran después de 7 días
- En producción, las cookies usan el flag `secure` (solo HTTPS)
## Estructura de Base de Datos
### Tabla: `usuarios`
```sql
id SERIAL PRIMARY KEY
username VARCHAR(50) UNIQUE NOT NULL
email VARCHAR(100) UNIQUE NOT NULL
password_hash VARCHAR(255) NOT NULL
nombre_completo VARCHAR(100)
activo BOOLEAN DEFAULT true
es_admin BOOLEAN DEFAULT false
fecha_creacion TIMESTAMP DEFAULT CURRENT_TIMESTAMP
ultimo_acceso TIMESTAMP
```
### Tabla: `usuario_base_datos`
```sql
id SERIAL PRIMARY KEY
usuario_id INTEGER NOT NULL (FK a usuarios)
base_datos_nombre VARCHAR(255) NOT NULL
puede_ver BOOLEAN DEFAULT true
puede_descargar_backup BOOLEAN DEFAULT false
puede_restaurar BOOLEAN DEFAULT false
fecha_asignacion TIMESTAMP DEFAULT CURRENT_TIMESTAMP
```
### Tabla: `sesiones`
```sql
id SERIAL PRIMARY KEY
usuario_id INTEGER NOT NULL (FK a usuarios)
token VARCHAR(255) UNIQUE NOT NULL
ip_address VARCHAR(50)
user_agent TEXT
fecha_creacion TIMESTAMP DEFAULT CURRENT_TIMESTAMP
fecha_expiracion TIMESTAMP NOT NULL
activa BOOLEAN DEFAULT true
```
## API
### Rutas de Autenticación
- `GET /login` - Página de inicio de sesión
- `POST /login?/login` - Procesar inicio de sesión
- `GET /logout` - Cerrar sesión
### Rutas de Usuarios (Solo Admins)
- `GET /usuarios` - Página de gestión de usuarios
- `POST /usuarios?/createUser` - Crear usuario
- `POST /usuarios?/updateUser` - Actualizar usuario
- `POST /usuarios?/deleteUser` - Eliminar usuario
- `POST /usuarios?/managePermissions` - Asignar/revocar permisos
- `GET /usuarios/api/permissions?userId=X` - Obtener permisos de un usuario
## Troubleshooting
### Error: "Could not connect to PostgreSQL"
Verifica que:
1. PostgreSQL esté corriendo en el servidor
2. Las credenciales en `.env` sean correctas
3. El firewall permita conexiones al puerto 5432
4. La base de datos `CONTROLDESK` exista
### Error: "Invalid token"
1. Cierra sesión y vuelve a iniciar sesión
2. Verifica que `JWT_SECRET` sea consistente
3. Limpia las cookies del navegador
### Error: "User not found"
1. Verifica que hayas ejecutado el script SQL
2. Revisa que el usuario admin se haya creado correctamente:
```sql
SELECT * FROM usuarios WHERE username = 'admin';
```
### No puedo ver algunas bases de datos
Si eres usuario normal (no admin):
1. Contacta al administrador para que te asigne permisos
2. Verifica en la base de datos:
```sql
SELECT * FROM usuario_base_datos WHERE usuario_id = TU_ID;
```
## Futuras Mejoras
- [ ] Recuperación de contraseña por email
- [ ] Autenticación de dos factores (2FA)
- [ ] Registro de auditoría de acciones
- [ ] Permisos más granulares (lectura, escritura, eliminación)
- [ ] Grupos de usuarios
- [ ] API REST para integraciones
- [ ] Notificaciones por email de cambios de permisos
## Soporte
Para reportar problemas o sugerencias, contacta al equipo de desarrollo.

1019
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,18 +13,27 @@
"@sveltejs/adapter-node": "^5.5.2",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0-next.0",
"@types/bcrypt": "^6.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/mssql": "^9.1.5",
"@types/node": "^22.0.0",
"@types/pg": "^8.16.0",
"autoprefixer": "^10.4.24",
"postcss": "^8.5.6",
"svelte": "^5.0.0-next.1",
"svelte-check": "^3.6.0",
"tailwindcss": "^3.4.19",
"tslib": "^2.4.1",
"typescript": "^5.0.0",
"vite": "^5.0.0"
},
"dependencies": {
"bcrypt": "^6.0.0",
"bootstrap": "^5.3.3",
"dotenv": "^16.4.5",
"mssql": "^10.0.2"
"jsonwebtoken": "^9.0.3",
"mssql": "^10.0.2",
"pg": "^8.18.0"
},
"type": "module"
}

6
postcss.config.cjs Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

View File

@@ -0,0 +1,18 @@
// Script para generar hash de contraseña para el usuario admin
import bcrypt from 'bcrypt';
const password = 'Admin123!'; // Cambiar esta contraseña
const saltRounds = 10;
bcrypt.hash(password, saltRounds, (err, hash) => {
if (err) {
console.error('Error generando hash:', err);
process.exit(1);
}
console.log('\n=== Hash de Contraseña Generado ===');
console.log('Contraseña:', password);
console.log('Hash:', hash);
console.log('\nUsa este hash en el archivo database/schema.sql');
console.log('para actualizar la contraseña del usuario admin.\n');
});

108
scripts/init-database.js Normal file
View File

@@ -0,0 +1,108 @@
// Script para inicializar la base de datos PostgreSQL
import { Pool } from 'pg';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Configuración de PostgreSQL (Docker local)
const pool = new Pool({
host: 'localhost',
port: 5432,
database: 'CONTROLDESK',
user: 'postgres',
password: 'Control.',
max: 1
});
async function initDatabase() {
console.log('=================================');
console.log('Inicializando Base de Datos');
console.log('=================================\n');
try {
// Leer el archivo SQL
const schemaPath = path.join(__dirname, '..', 'database', 'schema.sql');
console.log('1. Leyendo schema.sql...');
const sql = fs.readFileSync(schemaPath, 'utf8');
// Conectar a PostgreSQL
console.log('2. Conectando a PostgreSQL...');
const client = await pool.connect();
console.log(' ✓ Conectado exitosamente\n');
// Ejecutar el script SQL
console.log('3. Ejecutando script SQL...');
await client.query(sql);
console.log(' ✓ Tablas creadas exitosamente\n');
// Verificar las tablas creadas
console.log('4. Verificando tablas creadas...');
const result = await client.query(`
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name IN ('usuarios', 'usuario_base_datos', 'sesiones')
ORDER BY table_name;
`);
if (result.rows.length === 3) {
console.log(' ✓ Tablas verificadas:');
result.rows.forEach(row => {
console.log(` - ${row.table_name}`);
});
} else {
console.log(' ⚠ No todas las tablas fueron creadas');
}
// Verificar usuario admin
console.log('\n5. Verificando usuario admin...');
const userResult = await client.query('SELECT username, email, es_admin FROM usuarios WHERE username = $1', ['admin']);
if (userResult.rows.length > 0) {
console.log(' ✓ Usuario admin creado:');
console.log(` - Username: ${userResult.rows[0].username}`);
console.log(` - Email: ${userResult.rows[0].email}`);
console.log(` - Es Admin: ${userResult.rows[0].es_admin}`);
} else {
console.log(' ⚠ Usuario admin no encontrado');
}
client.release();
console.log('\n=================================');
console.log('✓ Inicialización Completa');
console.log('=================================\n');
console.log('IMPORTANTE: Ahora debes actualizar la contraseña del admin:');
console.log('1. Ejecuta: node scripts/generate-password-hash.js');
console.log('2. Copia el hash generado');
console.log('3. Ejecuta este SQL en PostgreSQL:');
console.log(' UPDATE usuarios SET password_hash = \'TU_HASH_AQUI\' WHERE username = \'admin\';');
console.log('\n4. Luego inicia el servidor: npm run dev');
console.log('5. Ve a: http://localhost:5173/login');
console.log(' Usuario: admin');
console.log(' Contraseña: Admin123!\n');
} catch (error) {
console.error('\n❌ Error durante la inicialización:');
console.error(error.message);
if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT') {
console.error('\n⚠ No se pudo conectar a PostgreSQL.');
console.error('Verifica que:');
console.error('1. PostgreSQL esté corriendo en Docker:');
console.error(' docker-compose -f docker-compose.postgres.yml up -d');
console.error('2. Verifica el estado: docker ps');
console.error('3. Ve los logs: docker-compose -f docker-compose.postgres.yml logs postgres');
}
process.exit(1);
} finally {
await pool.end();
}
}
// Ejecutar
initDatabase();

47
scripts/init-users.sh Normal file
View File

@@ -0,0 +1,47 @@
#!/bin/bash
# Script de inicialización del sistema de usuarios
echo "==================================="
echo "Inicialización del Sistema de Usuarios"
echo "==================================="
echo ""
# 1. Verificar si PostgreSQL está disponible
echo "1. Verificando conexión a PostgreSQL..."
if command -v psql &> /dev/null; then
echo " ✓ PostgreSQL CLI encontrado"
else
echo " ✗ PostgreSQL CLI no encontrado"
echo " Por favor, ejecuta el script SQL manualmente"
exit 1
fi
# 2. Generar hash de contraseña
echo ""
echo "2. Generando hash de contraseña para admin..."
node scripts/generate-password-hash.js
# 3. Instrucciones
echo ""
echo "==================================="
echo "Pasos siguientes:"
echo "==================================="
echo ""
echo "1. Copia el hash generado arriba"
echo ""
echo "2. Edita el archivo database/schema.sql y reemplaza el hash temporal"
echo ""
echo "3. Ejecuta el script SQL en PostgreSQL:"
echo " psql -h 10.0.20.152 -U postgres -d CONTROLDESK -f database/schema.sql"
echo ""
echo "4. O ejecuta el SQL manualmente desde pgAdmin/DBeaver"
echo ""
echo "5. Copia .env.example a .env y configura las variables"
echo ""
echo "6. Inicia el servidor: npm run dev"
echo ""
echo "7. Ve a http://localhost:5173/login"
echo " Usuario: admin"
echo " Contraseña: Admin123! (o la que configuraste)"
echo ""
echo "==================================="

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" />
<link rel="icon" type="image/png" href="/aduanasoft-icon.png" />
<meta name="viewport" content="width=device-width" />
<!-- Fuentes Premium -->
<!-- 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">
<!-- 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 -->
<!-- Iconos Material -->
<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>
%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>
<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 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>
<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>
<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>
<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>
<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>
<!-- 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 === 'backups' ? 'active' : ''}" onclick={() => setActive('backups')}>
<i class="bi bi-hdd-network md-nav-icon"></i>
<span>Respaldos Almacenados</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 === '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 === '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 === '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 === '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 === '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 === '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="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 === '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

@@ -3,7 +3,9 @@ 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';
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
@@ -19,11 +21,21 @@ async function getDiskSpace(drive: string) {
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');
// }
// 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[] = [];
@@ -38,6 +50,8 @@ export const load: PageServerLoad = async ({ cookies }) => {
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 = {
@@ -47,41 +61,35 @@ export const load: PageServerLoad = async ({ cookies }) => {
backups: null as string | null
};
// --- 1. Load Primary Data (Local SQL Express) ---
// --- 1. Load Primary Data (Servidor 152 - Bases Restauradas) ---
try {
const primary = await db.getPrimary();
// Usamos el servidor SECUNDARIO (152) como fuente del panel principal
const primary = await db.getSecondary();
// Main Database Rows
// Main Database Rows - Bases restauradas localmente
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,
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
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
mf.type = 0 AND d.name != 'tempdb'
d.name NOT IN ('master', 'tempdb', 'model', 'msdb')
GROUP BY
d.name, mf.physical_name
d.name
ORDER BY
d.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
@@ -97,7 +105,7 @@ export const load: PageServerLoad = async ({ cookies }) => {
const resSummary = await primary.request().query(querySummary);
summaryMain = resSummary.recordset[0];
// Alerts Data (also from Primary)
// Alerts Data (bases sin restore reciente)
const sqlAlerts = `
SELECT
d.name AS visible_name,
@@ -111,80 +119,138 @@ export const load: PageServerLoad = async ({ cookies }) => {
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 Principal: ${e.message}`;
errors.primary = `Error conectando al servidor Local (SQL Express): ${e.message}`;
}
// --- 2. Load Azure Data ---
// --- 2. Load Secondary Data (Servidor con TODAS las bases de clientes en 202) ---
try {
// Only try connecting if we are meant to
const azure = await db.getAzure();
// Aquí usamos el servidor PRIMARIO (202) para la segunda sección
const secondary = await db.getPrimary();
// Re-use queryMain logic for Azure
const queryMainAz = `
// Consultar TODAS las bases de datos en el servidor secundario
const queryMainSec = `
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
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
LEFT JOIN
msdb.dbo.restorehistory rh ON d.name = rh.destination_database_name
sys.master_files mf ON d.database_id = mf.database_id AND mf.type = 0
WHERE
mf.type = 0 AND d.name != 'tempdb'
d.name NOT IN ('master', 'tempdb', 'model', 'msdb')
GROUP BY
d.name, mf.physical_name
d.name, d.create_date, d.state_desc, d.recovery_model_desc
ORDER BY
d.name
`;
const resultAzure = await azure.request().query(queryMainAz);
databaseRowsAZ = resultAzure.recordset;
const resultSecondary = await secondary.request().query(queryMainSec);
databaseRowsAZ = resultSecondary.recordset;
const querySummaryAz = `
const querySummarySec = `
SELECT
COUNT(DISTINCT d.database_id) AS total_databases,
SUM(mf.size * 8 / 1024 / 1024) AS total_size_gb
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
sys.master_files mf ON d.database_id = mf.database_id AND mf.type = 0
WHERE
mf.type = 0 AND d.name != 'tempdb'
d.name NOT IN ('master', 'tempdb', 'model', 'msdb')
`;
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;
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 (ControlDesk): ${e.message}`;
errors.secondary = `Error conectando al servidor Secundario (Todas las Bases): ${e.message}`;
}
// --- 4. Process Backups & Hydrate Alerts (Requires Secondary DB) ---
// --- 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 {
@@ -206,15 +272,15 @@ export const load: PageServerLoad = async ({ cookies }) => {
const nodoName = path.parse(file).name;
let clientData: any = null;
// Try to fetch metadata if secondary DB is available
if (secondary) {
// Try to fetch metadata if azure DB is available
if (azure) {
try {
const clientQuery = `
SELECT ClienteAutoridad, Nombre, Usuario, BD_Shelter
FROM [CONTROLDESK].[dbo].[Usuarios]
WHERE Usuario = @nodoName
SELECT TOP 1 Nombre, NodoSubNodo, RFC
FROM [CONTROLDESK].[dbo].[BasesDeDatos]
WHERE NodoSubNodo = @nodoName OR BDName = @nodoName
`;
const req = secondary.request();
const req = azure.request();
req.input('nodoName', nodoName);
const res = await req.query(clientQuery);
clientData = res.recordset[0];
@@ -225,21 +291,28 @@ export const load: PageServerLoad = async ({ cookies }) => {
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',
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 (secondary && alertsData.length > 0) {
if (azure && alertsData.length > 0) {
const hydratedAlerts = [];
for (const alert of alertsData) {
const nodoName = alert.visible_name;
let cData: any = null;
try {
const req = secondary.request();
const req = azure.request();
req.input('nodoName', nodoName);
const res = await req.query(`
SELECT u.ClienteAutoridad, u.Nombre, u.Usuario, bd.CorreoNotificacion
@@ -255,10 +328,121 @@ export const load: PageServerLoad = async ({ cookies }) => {
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,
@@ -272,8 +456,193 @@ export const load: PageServerLoad = async ({ cookies }) => {
clientsData,
alertsData,
basesDeDatosList,
restoreHistory,
effectivenessByDb,
errors // Return the collected errors
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 });
}
};

8
tailwind.config.cjs Normal file
View File

@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{html,svelte,js,ts}'],
theme: {
extend: {}
},
plugins: []
};