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:
30
.env.example
Normal file
30
.env.example
Normal 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
322
INSTALACION_USUARIOS.md
Normal 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
66
database/schema.sql
Normal 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';
|
||||
52
docker-compose.postgres.yml
Normal file
52
docker-compose.postgres.yml
Normal 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
220
docs/USUARIOS.md
Normal 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
1019
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -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
6
postcss.config.cjs
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
18
scripts/generate-password-hash.js
Normal file
18
scripts/generate-password-hash.js
Normal 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
108
scripts/init-database.js
Normal 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
47
scripts/init-users.sh
Normal 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 "==================================="
|
||||
198
src/app.css
198
src/app.css
@@ -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;
|
||||
}
|
||||
|
||||
44
src/app.html
44
src/app.html
@@ -2,42 +2,16 @@
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<link rel="icon" type="image/png" href="/aduanasoft-icon.png" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
|
||||
<!-- Fuentes Premium -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- CSS Frameworks -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.7/css/dataTables.bootstrap5.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.datatables.net/buttons/2.4.2/css/buttons.bootstrap5.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.datatables.net/responsive/2.5.0/css/responsive.bootstrap5.min.css">
|
||||
|
||||
<!-- Iconos -->
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons+Outlined" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
||||
|
||||
<!-- Animaciones -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/aos@2.3.4/dist/aos.css">
|
||||
|
||||
<!-- Scripts (DataTables requires jQuery) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/aos@2.3.4/dist/aos.js"></script>
|
||||
|
||||
<!-- DataTables Scripts -->
|
||||
<script src="https://cdn.datatables.net/1.13.7/js/jquery.dataTables.min.js"></script>
|
||||
<script src="https://cdn.datatables.net/1.13.7/js/dataTables.bootstrap5.min.js"></script>
|
||||
<script src="https://cdn.datatables.net/buttons/2.4.2/js/dataTables.buttons.min.js"></script>
|
||||
<script src="https://cdn.datatables.net/buttons/2.4.2/js/buttons.bootstrap5.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
|
||||
<script src="https://cdn.datatables.net/buttons/2.4.2/js/buttons.html5.min.js"></script>
|
||||
<script src="https://cdn.datatables.net/buttons/2.4.2/js/buttons.print.min.js"></script>
|
||||
<script src="https://cdn.datatables.net/responsive/2.5.0/js/dataTables.responsive.min.js"></script>
|
||||
<script src="https://cdn.datatables.net/responsive/2.5.0/js/responsive.bootstrap5.min.js"></script>
|
||||
<!-- Fuentes -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Iconos Material -->
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons+Outlined" rel="stylesheet">
|
||||
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
BIN
src/lib/assets/icono.png
Normal file
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
BIN
src/lib/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -2,36 +2,42 @@
|
||||
import { page } from '$app/stores';
|
||||
</script>
|
||||
|
||||
<nav class="navbar navbar-expand-lg md-navbar position-fixed w-100 top-0">
|
||||
<div class="container-fluid px-4">
|
||||
<button class="btn sidebar-toggle d-lg-none me-3" type="button" onclick={() => document.getElementById('sidebar')?.classList.toggle('show')}>
|
||||
<i class="bi bi-list fs-4"></i>
|
||||
</button>
|
||||
|
||||
<a class="navbar-brand d-flex align-items-center" href="/">
|
||||
<div class="md-nav-icon me-3 brand-logo" style="width: 40px; height: 40px; font-size: 1.2rem; display: flex; align-items: center; justify-content: center;">
|
||||
<i class="material-icons-outlined">storage</i>
|
||||
<nav class="fixed inset-x-0 top-0 z-40 border-b border-slate-200 bg-white/90 backdrop-blur">
|
||||
<div class="mx-auto flex h-14 max-w-6xl items-center justify-between px-4 md:px-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center rounded-lg border border-slate-200 bg-white px-2 py-1 text-slate-700 shadow-sm hover:bg-slate-50 md:hidden"
|
||||
onclick={() => document.getElementById('sidebar')?.classList.toggle('hidden')}
|
||||
>
|
||||
<span class="material-icons-outlined text-base">menu</span>
|
||||
</button>
|
||||
|
||||
<a href="/" class="flex items-center gap-2">
|
||||
<div
|
||||
class="flex h-9 w-9 items-center justify-center rounded-lg bg-slate-900 text-slate-50 shadow-sm"
|
||||
>
|
||||
<span class="material-icons-outlined text-base">storage</span>
|
||||
</div>
|
||||
<div class="hidden flex-col md:flex">
|
||||
<span class="text-sm font-semibold text-slate-900">Database Management System</span>
|
||||
<span class="text-[0.7rem] text-slate-500">TransmitirAS</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<div class="hidden items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs text-slate-600 shadow-sm md:flex">
|
||||
<span class="material-icons-outlined text-xs">person</span>
|
||||
<span>Admin</span>
|
||||
</div>
|
||||
<span class="brand-text d-none d-sm-block">Database Management System</span>
|
||||
</a>
|
||||
|
||||
<div class="navbar-nav ms-auto d-flex align-items-center">
|
||||
<div class="nav-item dropdown me-3">
|
||||
<a class="nav-link dropdown-toggle px-3" href="#" id="userDropdown" role="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-person-circle me-2"></i>
|
||||
<span class="d-none d-md-inline">Admin</span>
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end md-card border-0 p-2">
|
||||
<li><a class="dropdown-item rounded" href="#"><i class="bi bi-person me-2"></i>Perfil</a></li>
|
||||
<li><a class="dropdown-item rounded" href="#"><i class="bi bi-gear me-2"></i>Configuración</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item text-danger rounded" href="/logout"><i class="bi bi-box-arrow-right me-2"></i>Cerrar Sesión</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<a href="/logout" class="btn btn-primary d-flex align-items-center">
|
||||
<i class="bi bi-box-arrow-right me-2"></i>
|
||||
<span class="d-none d-md-inline">Salir</span>
|
||||
|
||||
<a
|
||||
href="/logout"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-slate-900 px-3 py-1.5 text-xs font-medium text-slate-50 shadow-sm hover:bg-slate-800"
|
||||
>
|
||||
<span class="material-icons-outlined text-xs">logout</span>
|
||||
<span class="hidden md:inline">Salir</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,70 +10,106 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="md-sidebar" id="sidebar">
|
||||
<div class="px-3 py-4">
|
||||
<div class="text-center mb-4 d-flex align-items-center px-3">
|
||||
<div style="font-size: 2rem; color: var(--md-primary)" class="me-2">
|
||||
<i class="material-icons-outlined">dashboard</i>
|
||||
<aside
|
||||
id="sidebar"
|
||||
class="hidden md:flex md:flex-col md:w-64 fixed inset-y-0 left-0 bg-white border-r border-slate-200 z-30"
|
||||
>
|
||||
<div class="flex flex-col h-full px-4 py-4">
|
||||
<!-- Brand -->
|
||||
<div class="flex items-center gap-2 mb-6 px-1">
|
||||
<div
|
||||
class="inline-flex h-9 w-9 items-center justify-center rounded-lg bg-slate-900 text-slate-100 text-xl shadow-sm"
|
||||
>
|
||||
<span class="material-icons-outlined text-base">dashboard</span>
|
||||
</div>
|
||||
<div class="text-start">
|
||||
<h4 class="brand-text mb-0" style="font-size: 1.1rem; color: var(--md-text-primary)">TransmitirAS</h4>
|
||||
<p class="text-muted small mb-0" style="font-size: 0.75rem">Management System</p>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-semibold text-slate-900">TransmitirAS</span>
|
||||
<span class="text-[0.7rem] text-slate-500">Management System</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav flex-column">
|
||||
<button class="md-nav-link {activeSection === 'summary' ? 'active' : ''}" onclick={() => setActive('summary')}>
|
||||
<i class="bi bi-speedometer2 md-nav-icon"></i>
|
||||
<span>Panel Principal</span>
|
||||
</button>
|
||||
|
||||
<button class="md-nav-link {activeSection === 'backups' ? 'active' : ''}" onclick={() => setActive('backups')}>
|
||||
<i class="bi bi-hdd-network md-nav-icon"></i>
|
||||
<span>Respaldos Almacenados</span>
|
||||
|
||||
<!-- Nav -->
|
||||
<nav class="flex-1 space-y-1 text-sm">
|
||||
<button
|
||||
class={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left transition hover:bg-slate-100 ${
|
||||
activeSection === 'summary'
|
||||
? 'bg-slate-900 text-slate-50'
|
||||
: 'text-slate-600'
|
||||
}`}
|
||||
onclick={() => setActive('summary')}
|
||||
>
|
||||
<span class="material-icons-outlined text-base">dashboard</span>
|
||||
<span class="text-xs font-medium">Panel Principal</span>
|
||||
</button>
|
||||
|
||||
<button class="md-nav-link {activeSection === 'clients' ? 'active' : ''}" onclick={() => setActive('clients')}>
|
||||
<i class="bi bi-people md-nav-icon"></i>
|
||||
<span>Catálogo de Clientes</span>
|
||||
<button
|
||||
class={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left transition hover:bg-slate-100 ${
|
||||
activeSection === 'backups'
|
||||
? 'bg-slate-900 text-slate-50'
|
||||
: 'text-slate-600'
|
||||
}`}
|
||||
onclick={() => setActive('backups')}
|
||||
>
|
||||
<span class="material-icons-outlined text-base">backup</span>
|
||||
<span class="text-xs font-medium">Respaldos Almacenados</span>
|
||||
</button>
|
||||
|
||||
<button class="md-nav-link {activeSection === 'alerts' ? 'active' : ''}" onclick={() => setActive('alerts')}>
|
||||
<i class="bi bi-exclamation-triangle md-nav-icon"></i>
|
||||
<span>Alertas Críticas</span>
|
||||
|
||||
<button
|
||||
class={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left transition hover:bg-slate-100 ${
|
||||
activeSection === 'clients'
|
||||
? 'bg-slate-900 text-slate-50'
|
||||
: 'text-slate-600'
|
||||
}`}
|
||||
onclick={() => setActive('clients')}
|
||||
>
|
||||
<span class="material-icons-outlined text-base">people</span>
|
||||
<span class="text-xs font-medium">Catálogo de Clientes</span>
|
||||
</button>
|
||||
|
||||
<button class="md-nav-link {activeSection === 'azure' ? 'active' : ''}" onclick={() => setActive('azure')}>
|
||||
<i class="bi bi-cloud md-nav-icon"></i>
|
||||
<span>Azure Cloud</span>
|
||||
|
||||
<button
|
||||
class={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left transition hover:bg-slate-100 ${
|
||||
activeSection === 'alerts'
|
||||
? 'bg-slate-900 text-slate-50'
|
||||
: 'text-slate-600'
|
||||
}`}
|
||||
onclick={() => setActive('alerts')}
|
||||
>
|
||||
<span class="material-icons-outlined text-base">warning</span>
|
||||
<span class="text-xs font-medium">Alertas Críticas</span>
|
||||
</button>
|
||||
|
||||
<button class="md-nav-link {activeSection === 'databases' ? 'active' : ''}" onclick={() => setActive('databases')}>
|
||||
<i class="bi bi-database md-nav-icon"></i>
|
||||
<span>Gestión de Bases de Datos</span>
|
||||
|
||||
<button
|
||||
class={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left transition hover:bg-slate-100 ${
|
||||
activeSection === 'azure'
|
||||
? 'bg-slate-900 text-slate-50'
|
||||
: 'text-slate-600'
|
||||
}`}
|
||||
onclick={() => setActive('azure')}
|
||||
>
|
||||
<span class="material-icons-outlined text-base">cloud</span>
|
||||
<span class="text-xs font-medium">Azure Cloud</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left transition hover:bg-slate-100 ${
|
||||
activeSection === 'databases'
|
||||
? 'bg-slate-900 text-slate-50'
|
||||
: 'text-slate-600'
|
||||
}`}
|
||||
onclick={() => setActive('databases')}
|
||||
>
|
||||
<span class="material-icons-outlined text-base">dns</span>
|
||||
<span class="text-xs font-medium">Gestión de Bases de Datos</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="mt-5 px-3">
|
||||
<h6 class="text-uppercase text-muted small fw-bold mb-3 ms-2">Configuración</h6>
|
||||
<button class="md-nav-link" onclick={() => console.log('Config')}>
|
||||
<i class="bi bi-gear md-nav-icon"></i>
|
||||
<span>Configuración</span>
|
||||
</button>
|
||||
<a href="/api-docs" class="md-nav-link text-decoration-none">
|
||||
<i class="bi bi-code-square md-nav-icon"></i>
|
||||
<span>API</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto px-4 pt-4 text-center">
|
||||
<div class="d-flex align-items-center justify-content-center text-success mb-2">
|
||||
<i class="bi bi-shield-check me-2"></i>
|
||||
<small class="fw-bold">Sistema Seguro</small>
|
||||
</div>
|
||||
<div class="text-muted small" style="font-size: 0.7rem;">
|
||||
{new Date().toLocaleDateString()}
|
||||
<!-- Footer -->
|
||||
<div class="mt-6 border-t border-slate-200 pt-3 text-center text-[0.7rem] text-slate-500">
|
||||
<div class="flex items-center justify-center gap-1 mb-1 text-emerald-600">
|
||||
<span class="material-icons-outlined text-xs">shield</span>
|
||||
<span class="font-medium">Sistema Seguro</span>
|
||||
</div>
|
||||
<div>{new Date().toLocaleDateString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
87
src/lib/server/auth.ts
Normal file
87
src/lib/server/auth.ts
Normal 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 };
|
||||
}
|
||||
@@ -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
312
src/lib/server/users.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -1,279 +1,648 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
// Helper to check disk space (Simple Windows implementation)
|
||||
// Note: In production, consider a specialized library
|
||||
async function getDiskSpace(drive: string) {
|
||||
try {
|
||||
// Using fs.statfs if available (Node 18.15+) or just mock for now
|
||||
// Implementing proper disk check via Powershell is safer
|
||||
return { free: 0, total: 0 };
|
||||
} catch {
|
||||
return { free: 0, total: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies }) => {
|
||||
// 1. Auth Check (Commented out for dev)
|
||||
const sessionId = cookies.get('session_id');
|
||||
// if (!sessionId) {
|
||||
// throw redirect(303, '/login');
|
||||
// }
|
||||
|
||||
// Initialize result containers
|
||||
let databaseRows: any[] = [];
|
||||
let summaryMain: any = null;
|
||||
let restoredCount = 0;
|
||||
let notRestoredCount = 0;
|
||||
|
||||
let databaseRowsAZ: any[] = [];
|
||||
let summaryAZ: any = null;
|
||||
|
||||
let backupFiles: any[] = [];
|
||||
let clientsData: any[] = [];
|
||||
let alertsData: any[] = [];
|
||||
let basesDeDatosList: any[] = [];
|
||||
|
||||
// Connection errors to be passed to UI
|
||||
let errors = {
|
||||
primary: null as string | null,
|
||||
secondary: null as string | null,
|
||||
azure: null as string | null,
|
||||
backups: null as string | null
|
||||
};
|
||||
|
||||
// --- 1. Load Primary Data (Local SQL Express) ---
|
||||
try {
|
||||
const primary = await db.getPrimary();
|
||||
|
||||
// Main Database Rows
|
||||
const queryMain = `
|
||||
SELECT
|
||||
d.name AS visible_name,
|
||||
REVERSE(SUBSTRING(REVERSE(mf.physical_name), 1, CHARINDEX('\\', REVERSE(mf.physical_name)) - 1)) AS original_name,
|
||||
SUM(mf.size * 8 / 1024) AS size_mb,
|
||||
MAX(rh.restore_date) AS last_restore_date
|
||||
FROM
|
||||
sys.databases d
|
||||
LEFT JOIN
|
||||
sys.master_files mf ON d.database_id = mf.database_id
|
||||
LEFT JOIN
|
||||
msdb.dbo.restorehistory rh ON d.name = rh.destination_database_name
|
||||
WHERE
|
||||
mf.type = 0 AND d.name != 'tempdb'
|
||||
GROUP BY
|
||||
d.name, mf.physical_name
|
||||
`;
|
||||
const resultMain = await primary.request().query(queryMain);
|
||||
databaseRows = resultMain.recordset;
|
||||
|
||||
// Calculate restored/not restored
|
||||
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
for (const row of databaseRows) {
|
||||
if (row.last_restore_date && new Date(row.last_restore_date) > oneDayAgo) {
|
||||
restoredCount++;
|
||||
} else {
|
||||
notRestoredCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Summary Main
|
||||
const querySummary = `
|
||||
SELECT
|
||||
COUNT(DISTINCT d.database_id) AS total_databases,
|
||||
SUM(mf.size * 8 / 1024 / 1024) AS total_size_gb
|
||||
FROM
|
||||
sys.databases d
|
||||
LEFT JOIN
|
||||
sys.master_files mf ON d.database_id = mf.database_id
|
||||
WHERE
|
||||
mf.type = 0 AND d.name != 'tempdb'
|
||||
`;
|
||||
const resSummary = await primary.request().query(querySummary);
|
||||
summaryMain = resSummary.recordset[0];
|
||||
|
||||
// Alerts Data (also from Primary)
|
||||
const sqlAlerts = `
|
||||
SELECT
|
||||
d.name AS visible_name,
|
||||
MAX(rh.restore_date) AS last_restore_date
|
||||
FROM sys.databases d
|
||||
LEFT JOIN msdb.dbo.restorehistory rh ON d.name = rh.destination_database_name
|
||||
WHERE d.name != 'tempdb'
|
||||
GROUP BY d.name
|
||||
HAVING MAX(rh.restore_date) < DATEADD(DAY, -2, GETDATE()) OR MAX(rh.restore_date) IS NULL
|
||||
`;
|
||||
const resAlerts = await primary.request().query(sqlAlerts);
|
||||
alertsData = resAlerts.recordset;
|
||||
|
||||
} catch (e: any) {
|
||||
console.error("Error loading Primary DB data:", e);
|
||||
errors.primary = `Error conectando al servidor Principal: ${e.message}`;
|
||||
}
|
||||
|
||||
// --- 2. Load Azure Data ---
|
||||
try {
|
||||
// Only try connecting if we are meant to
|
||||
const azure = await db.getAzure();
|
||||
|
||||
// Re-use queryMain logic for Azure
|
||||
const queryMainAz = `
|
||||
SELECT
|
||||
d.name AS visible_name,
|
||||
REVERSE(SUBSTRING(REVERSE(mf.physical_name), 1, CHARINDEX('\\', REVERSE(mf.physical_name)) - 1)) AS original_name,
|
||||
SUM(mf.size * 8 / 1024) AS size_mb,
|
||||
MAX(rh.restore_date) AS last_restore_date
|
||||
FROM
|
||||
sys.databases d
|
||||
LEFT JOIN
|
||||
sys.master_files mf ON d.database_id = mf.database_id
|
||||
LEFT JOIN
|
||||
msdb.dbo.restorehistory rh ON d.name = rh.destination_database_name
|
||||
WHERE
|
||||
mf.type = 0 AND d.name != 'tempdb'
|
||||
GROUP BY
|
||||
d.name, mf.physical_name
|
||||
`;
|
||||
|
||||
const resultAzure = await azure.request().query(queryMainAz);
|
||||
databaseRowsAZ = resultAzure.recordset;
|
||||
|
||||
const querySummaryAz = `
|
||||
SELECT
|
||||
COUNT(DISTINCT d.database_id) AS total_databases,
|
||||
SUM(mf.size * 8 / 1024 / 1024) AS total_size_gb
|
||||
FROM
|
||||
sys.databases d
|
||||
LEFT JOIN
|
||||
sys.master_files mf ON d.database_id = mf.database_id
|
||||
WHERE
|
||||
mf.type = 0 AND d.name != 'tempdb'
|
||||
`;
|
||||
const resSummaryAz = await azure.request().query(querySummaryAz);
|
||||
summaryAZ = resSummaryAz.recordset[0];
|
||||
|
||||
} catch (e: any) {
|
||||
console.error("Error loading Azure DB data:", e);
|
||||
errors.azure = `Error conectando a Azure: ${e.message}`;
|
||||
}
|
||||
|
||||
// --- 3. Load Secondary Data (Clients & Management) ---
|
||||
// Note: We need this connection to hydrate backup info and alerts too
|
||||
let secondary: any = null;
|
||||
try {
|
||||
secondary = await db.getSecondary();
|
||||
|
||||
// Clients Catalog
|
||||
const clientsQuery = `SELECT ID, Nombre, NodoSubNodo, CorreoNotificacion, Activo, BDName FROM BasesdeDatos`;
|
||||
// Assuming 'CONTROLDESK' db context is handled in connection string or default db
|
||||
const resClients = await secondary.request().query(clientsQuery);
|
||||
clientsData = resClients.recordset;
|
||||
|
||||
// DB Management List
|
||||
const basesQueries = `SELECT ID, NodoSubNodo, Activo, RFC, Nombre, Sucursal, CorreoNotificacion, ServerName, BDName FROM BasesDeDatos`;
|
||||
const resBases = await secondary.request().query(basesQueries);
|
||||
basesDeDatosList = resBases.recordset;
|
||||
|
||||
} catch (e: any) {
|
||||
console.error("Error loading Secondary DB data:", e);
|
||||
errors.secondary = `Error conectando al servidor Secundario (ControlDesk): ${e.message}`;
|
||||
}
|
||||
|
||||
// --- 4. Process Backups & Hydrate Alerts (Requires Secondary DB) ---
|
||||
try {
|
||||
let files: string[] = [];
|
||||
try {
|
||||
files = await fs.readdir(env.BACKUP_PATH);
|
||||
} catch (e) {
|
||||
files = [];
|
||||
errors.backups = "No se pudo acceder a la carpeta de respaldos.";
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (file === '.' || file === '..') continue;
|
||||
|
||||
const filePath = path.join(env.BACKUP_PATH, file);
|
||||
let stats;
|
||||
try {
|
||||
stats = await fs.stat(filePath);
|
||||
} catch { continue; }
|
||||
|
||||
const nodoName = path.parse(file).name;
|
||||
let clientData: any = null;
|
||||
|
||||
// Try to fetch metadata if secondary DB is available
|
||||
if (secondary) {
|
||||
try {
|
||||
const clientQuery = `
|
||||
SELECT ClienteAutoridad, Nombre, Usuario, BD_Shelter
|
||||
FROM [CONTROLDESK].[dbo].[Usuarios]
|
||||
WHERE Usuario = @nodoName
|
||||
`;
|
||||
const req = secondary.request();
|
||||
req.input('nodoName', nodoName);
|
||||
const res = await req.query(clientQuery);
|
||||
clientData = res.recordset[0];
|
||||
} catch {}
|
||||
}
|
||||
|
||||
backupFiles.push({
|
||||
name: file,
|
||||
nodo_name: nodoName,
|
||||
client_name: clientData?.Nombre ?? 'Cliente no identificado',
|
||||
client_authority: clientData?.ClienteAutoridad ?? 'N/A',
|
||||
bd_shelter: clientData?.BD_Shelter ?? 'N/A',
|
||||
date: stats.mtime,
|
||||
size: (stats.size / 1024 / 1024).toFixed(2) + " MB"
|
||||
});
|
||||
}
|
||||
|
||||
// Hydrate Alerts if possible
|
||||
if (secondary && alertsData.length > 0) {
|
||||
const hydratedAlerts = [];
|
||||
for (const alert of alertsData) {
|
||||
const nodoName = alert.visible_name;
|
||||
let cData: any = null;
|
||||
try {
|
||||
const req = secondary.request();
|
||||
req.input('nodoName', nodoName);
|
||||
const res = await req.query(`
|
||||
SELECT u.ClienteAutoridad, u.Nombre, u.Usuario, bd.CorreoNotificacion
|
||||
FROM [CONTROLDESK].[dbo].[Usuarios] AS u
|
||||
LEFT JOIN [CONTROLDESK].[dbo].[BasesDeDatos] AS bd ON u.Usuario = bd.NodoSubNodo
|
||||
WHERE u.Usuario = @nodoName
|
||||
`);
|
||||
cData = res.recordset[0];
|
||||
} catch {}
|
||||
|
||||
hydratedAlerts.push({ ...alert, clientData: cData });
|
||||
}
|
||||
alertsData = hydratedAlerts;
|
||||
}
|
||||
|
||||
} catch (e: any) {
|
||||
console.error("Error processing backups/alerts hydration:", e);
|
||||
}
|
||||
|
||||
return {
|
||||
databaseRows,
|
||||
summaryMain,
|
||||
restoredCount,
|
||||
notRestoredCount,
|
||||
|
||||
databaseRowsAZ,
|
||||
summaryAZ,
|
||||
|
||||
backupFiles,
|
||||
clientsData,
|
||||
alertsData,
|
||||
basesDeDatosList,
|
||||
|
||||
errors // Return the collected errors
|
||||
};
|
||||
};
|
||||
|
||||
import { db } from '$lib/server/db';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { verifyToken } from '$lib/server/auth';
|
||||
import { getUserById, filterDatabasesByUserPermissions } from '$lib/server/users';
|
||||
|
||||
// Helper to check disk space (Simple Windows implementation)
|
||||
// Note: In production, consider a specialized library
|
||||
async function getDiskSpace(drive: string) {
|
||||
try {
|
||||
// Using fs.statfs if available (Node 18.15+) or just mock for now
|
||||
// Implementing proper disk check via Powershell is safer
|
||||
return { free: 0, total: 0 };
|
||||
} catch {
|
||||
return { free: 0, total: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies }) => {
|
||||
// 1. Auth Check - Verificar token JWT
|
||||
const token = cookies.get('session_token');
|
||||
if (!token) {
|
||||
throw redirect(303, '/login');
|
||||
}
|
||||
|
||||
const session = verifyToken(token);
|
||||
if (!session) {
|
||||
throw redirect(303, '/login');
|
||||
}
|
||||
|
||||
const currentUser = await getUserById(session.userId);
|
||||
if (!currentUser || !currentUser.activo) {
|
||||
throw redirect(303, '/login');
|
||||
}
|
||||
|
||||
// Initialize result containers
|
||||
let databaseRows: any[] = [];
|
||||
let summaryMain: any = null;
|
||||
let restoredCount = 0;
|
||||
let notRestoredCount = 0;
|
||||
|
||||
let databaseRowsAZ: any[] = [];
|
||||
let summaryAZ: any = null;
|
||||
|
||||
let backupFiles: any[] = [];
|
||||
let clientsData: any[] = [];
|
||||
let alertsData: any[] = [];
|
||||
let basesDeDatosList: any[] = [];
|
||||
let restoreHistory: Record<string, { restore_date: Date }[]> = {};
|
||||
let effectivenessByDb: Record<string, { month: string; effectiveness: number }[]> = {};
|
||||
|
||||
// Connection errors to be passed to UI
|
||||
let errors = {
|
||||
primary: null as string | null,
|
||||
secondary: null as string | null,
|
||||
azure: null as string | null,
|
||||
backups: null as string | null
|
||||
};
|
||||
|
||||
// --- 1. Load Primary Data (Servidor 152 - Bases Restauradas) ---
|
||||
try {
|
||||
// Usamos el servidor SECUNDARIO (152) como fuente del panel principal
|
||||
const primary = await db.getSecondary();
|
||||
|
||||
// Main Database Rows - Bases restauradas localmente
|
||||
const queryMain = `
|
||||
SELECT
|
||||
d.name AS visible_name,
|
||||
d.name AS original_name,
|
||||
CAST(SUM(mf.size * 8.0 / 1024) AS DECIMAL(10,2)) AS size_mb,
|
||||
CAST(SUM(mf.size * 8.0 / 1024 / 1024) AS DECIMAL(10,2)) AS total_size_gb,
|
||||
MAX(rh.restore_date) AS last_restore_date
|
||||
FROM
|
||||
sys.databases d
|
||||
LEFT JOIN
|
||||
sys.master_files mf ON d.database_id = mf.database_id AND mf.type = 0
|
||||
LEFT JOIN
|
||||
msdb.dbo.restorehistory rh ON d.name = rh.destination_database_name
|
||||
WHERE
|
||||
d.name NOT IN ('master', 'tempdb', 'model', 'msdb')
|
||||
GROUP BY
|
||||
d.name
|
||||
ORDER BY
|
||||
d.name
|
||||
`;
|
||||
const resultMain = await primary.request().query(queryMain);
|
||||
databaseRows = resultMain.recordset;
|
||||
|
||||
// Summary Main
|
||||
const querySummary = `
|
||||
SELECT
|
||||
COUNT(DISTINCT d.database_id) AS total_databases,
|
||||
SUM(mf.size * 8 / 1024 / 1024) AS total_size_gb
|
||||
FROM
|
||||
sys.databases d
|
||||
LEFT JOIN
|
||||
sys.master_files mf ON d.database_id = mf.database_id
|
||||
WHERE
|
||||
mf.type = 0 AND d.name != 'tempdb'
|
||||
`;
|
||||
const resSummary = await primary.request().query(querySummary);
|
||||
summaryMain = resSummary.recordset[0];
|
||||
|
||||
// Alerts Data (bases sin restore reciente)
|
||||
const sqlAlerts = `
|
||||
SELECT
|
||||
d.name AS visible_name,
|
||||
MAX(rh.restore_date) AS last_restore_date
|
||||
FROM sys.databases d
|
||||
LEFT JOIN msdb.dbo.restorehistory rh ON d.name = rh.destination_database_name
|
||||
WHERE d.name != 'tempdb'
|
||||
GROUP BY d.name
|
||||
HAVING MAX(rh.restore_date) < DATEADD(DAY, -2, GETDATE()) OR MAX(rh.restore_date) IS NULL
|
||||
`;
|
||||
const resAlerts = await primary.request().query(sqlAlerts);
|
||||
alertsData = resAlerts.recordset;
|
||||
|
||||
// Historial de restauraciones por base (últimos 60 días)
|
||||
const sqlHistory = `
|
||||
SELECT
|
||||
destination_database_name AS visible_name,
|
||||
restore_date
|
||||
FROM msdb.dbo.restorehistory
|
||||
WHERE restore_date >= DATEADD(DAY, -120, GETDATE())
|
||||
`;
|
||||
const resHistory = await primary.request().query(sqlHistory);
|
||||
for (const row of resHistory.recordset as any[]) {
|
||||
const name = String(row.visible_name ?? '').toLowerCase();
|
||||
if (!restoreHistory[name]) restoreHistory[name] = [];
|
||||
restoreHistory[name].push({ restore_date: row.restore_date });
|
||||
}
|
||||
|
||||
// Calcular efectividad mensual por base (1 restore esperado por día)
|
||||
const now = new Date();
|
||||
const currentYear = now.getFullYear();
|
||||
const currentMonth = now.getMonth(); // 0-11
|
||||
|
||||
const monthsToInclude = [currentMonth, currentMonth - 1, currentMonth - 2].filter(
|
||||
(m) => m >= 0
|
||||
);
|
||||
|
||||
for (const [dbName, history] of Object.entries(restoreHistory)) {
|
||||
const monthly: { [monthKey: string]: { daysWithRestore: Set<string>; totalDays: number } } = {};
|
||||
|
||||
// inicializar meses con días del mes
|
||||
for (const m of monthsToInclude) {
|
||||
const monthKey = `${currentYear}-${String(m + 1).padStart(2, '0')}`;
|
||||
monthly[monthKey] = {
|
||||
daysWithRestore: new Set<string>(),
|
||||
totalDays: new Date(currentYear, m + 1, 0).getDate()
|
||||
};
|
||||
}
|
||||
|
||||
for (const h of history) {
|
||||
const d = new Date(h.restore_date);
|
||||
const y = d.getFullYear();
|
||||
const m = d.getMonth();
|
||||
if (y !== currentYear || !monthsToInclude.includes(m)) continue;
|
||||
const monthKey = `${y}-${String(m + 1).padStart(2, '0')}`;
|
||||
const dayKey = d.toISOString().slice(0, 10);
|
||||
monthly[monthKey]?.daysWithRestore.add(dayKey);
|
||||
}
|
||||
|
||||
effectivenessByDb[dbName] = Object.entries(monthly).map(
|
||||
([month, data]) => {
|
||||
const eff =
|
||||
data.totalDays === 0
|
||||
? 0
|
||||
: (data.daysWithRestore.size / data.totalDays) * 100;
|
||||
return { month, effectiveness: Number(eff.toFixed(1)) };
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
} catch (e: any) {
|
||||
console.error("Error loading Primary DB data:", e);
|
||||
errors.primary = `Error conectando al servidor Local (SQL Express): ${e.message}`;
|
||||
}
|
||||
|
||||
// --- 2. Load Secondary Data (Servidor con TODAS las bases de clientes en 202) ---
|
||||
try {
|
||||
// Aquí usamos el servidor PRIMARIO (202) para la segunda sección
|
||||
const secondary = await db.getPrimary();
|
||||
|
||||
// Consultar TODAS las bases de datos en el servidor secundario
|
||||
const queryMainSec = `
|
||||
SELECT
|
||||
d.name AS visible_name,
|
||||
d.name AS original_name,
|
||||
CAST(SUM(mf.size * 8.0 / 1024) AS DECIMAL(10,2)) AS size_mb,
|
||||
d.create_date,
|
||||
d.state_desc,
|
||||
d.recovery_model_desc
|
||||
FROM
|
||||
sys.databases d
|
||||
LEFT JOIN
|
||||
sys.master_files mf ON d.database_id = mf.database_id AND mf.type = 0
|
||||
WHERE
|
||||
d.name NOT IN ('master', 'tempdb', 'model', 'msdb')
|
||||
GROUP BY
|
||||
d.name, d.create_date, d.state_desc, d.recovery_model_desc
|
||||
ORDER BY
|
||||
d.name
|
||||
`;
|
||||
|
||||
const resultSecondary = await secondary.request().query(queryMainSec);
|
||||
databaseRowsAZ = resultSecondary.recordset;
|
||||
|
||||
const querySummarySec = `
|
||||
SELECT
|
||||
COUNT(d.database_id) AS total_databases,
|
||||
CAST(SUM(mf.size * 8.0 / 1024 / 1024) AS DECIMAL(10,2)) AS total_size_gb
|
||||
FROM
|
||||
sys.databases d
|
||||
LEFT JOIN
|
||||
sys.master_files mf ON d.database_id = mf.database_id AND mf.type = 0
|
||||
WHERE
|
||||
d.name NOT IN ('master', 'tempdb', 'model', 'msdb')
|
||||
`;
|
||||
const resSummarySec = await secondary.request().query(querySummarySec);
|
||||
summaryAZ = resSummarySec.recordset[0];
|
||||
|
||||
} catch (e: any) {
|
||||
console.error("Error loading Secondary DB data:", e);
|
||||
errors.secondary = `Error conectando al servidor Secundario (Todas las Bases): ${e.message}`;
|
||||
}
|
||||
|
||||
// --- 3. Load Azure/ControlDesk Data (Catálogo de Clientes) ---
|
||||
// Note: We need this connection to hydrate backup info and alerts too
|
||||
let azure: any = null;
|
||||
try {
|
||||
azure = await db.getAzure();
|
||||
|
||||
// Clients Catalog - desde CONTROLDESK en Azure
|
||||
const clientsQuery = `SELECT ID, Nombre, NodoSubNodo, CorreoNotificacion, Activo, BDName FROM [CONTROLDESK].[dbo].[BasesdeDatos]`;
|
||||
const resClients = await azure.request().query(clientsQuery);
|
||||
clientsData = resClients.recordset;
|
||||
|
||||
// DB Management List
|
||||
const basesQueries = `SELECT ID, NodoSubNodo, Activo, RFC, Nombre, Sucursal, CorreoNotificacion, ServerName, BDName FROM [CONTROLDESK].[dbo].[BasesDeDatos]`;
|
||||
const resBases = await azure.request().query(basesQueries);
|
||||
basesDeDatosList = resBases.recordset;
|
||||
|
||||
} catch (e: any) {
|
||||
console.error("Error loading Azure/ControlDesk data:", e);
|
||||
errors.azure = `Error conectando a Azure/ControlDesk: ${e.message}`;
|
||||
}
|
||||
|
||||
// --- 4. Process Backups, Hydrate Alerts & Enriquecer databaseRows con datos de BasesDeDatos ---
|
||||
try {
|
||||
let files: string[] = [];
|
||||
try {
|
||||
files = await fs.readdir(env.BACKUP_PATH);
|
||||
} catch (e) {
|
||||
files = [];
|
||||
errors.backups = "No se pudo acceder a la carpeta de respaldos.";
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (file === '.' || file === '..') continue;
|
||||
|
||||
const filePath = path.join(env.BACKUP_PATH, file);
|
||||
let stats;
|
||||
try {
|
||||
stats = await fs.stat(filePath);
|
||||
} catch { continue; }
|
||||
|
||||
const nodoName = path.parse(file).name;
|
||||
let clientData: any = null;
|
||||
|
||||
// Try to fetch metadata if azure DB is available
|
||||
if (azure) {
|
||||
try {
|
||||
const clientQuery = `
|
||||
SELECT TOP 1 Nombre, NodoSubNodo, RFC
|
||||
FROM [CONTROLDESK].[dbo].[BasesDeDatos]
|
||||
WHERE NodoSubNodo = @nodoName OR BDName = @nodoName
|
||||
`;
|
||||
const req = azure.request();
|
||||
req.input('nodoName', nodoName);
|
||||
const res = await req.query(clientQuery);
|
||||
clientData = res.recordset[0];
|
||||
} catch {}
|
||||
}
|
||||
|
||||
backupFiles.push({
|
||||
name: file,
|
||||
nodo_name: nodoName,
|
||||
client_name: clientData?.Nombre ?? 'Cliente no identificado',
|
||||
client_authority: clientData?.RFC ?? 'N/A',
|
||||
bd_shelter: 'N/A',
|
||||
date: stats.mtime,
|
||||
size: (stats.size / 1024 / 1024).toFixed(2) + " MB"
|
||||
});
|
||||
}
|
||||
|
||||
// Ordenar respaldos de más reciente a más antiguo por fecha de modificación
|
||||
backupFiles.sort((a, b) => {
|
||||
const da = new Date(a.date).getTime();
|
||||
const db = new Date(b.date).getTime();
|
||||
return db - da;
|
||||
});
|
||||
|
||||
// Hydrate Alerts if possible
|
||||
if (azure && alertsData.length > 0) {
|
||||
const hydratedAlerts = [];
|
||||
for (const alert of alertsData) {
|
||||
const nodoName = alert.visible_name;
|
||||
let cData: any = null;
|
||||
try {
|
||||
const req = azure.request();
|
||||
req.input('nodoName', nodoName);
|
||||
const res = await req.query(`
|
||||
SELECT u.ClienteAutoridad, u.Nombre, u.Usuario, bd.CorreoNotificacion
|
||||
FROM [CONTROLDESK].[dbo].[Usuarios] AS u
|
||||
LEFT JOIN [CONTROLDESK].[dbo].[BasesDeDatos] AS bd ON u.Usuario = bd.NodoSubNodo
|
||||
WHERE u.Usuario = @nodoName
|
||||
`);
|
||||
cData = res.recordset[0];
|
||||
} catch {}
|
||||
|
||||
hydratedAlerts.push({ ...alert, clientData: cData });
|
||||
}
|
||||
alertsData = hydratedAlerts;
|
||||
}
|
||||
|
||||
// Enriquecer databaseRows (servidor local restaurado) con NodoSubNodo y Nombre desde BasesDeDatos
|
||||
if (azure && databaseRows.length > 0) {
|
||||
try {
|
||||
const req = azure.request();
|
||||
const res = await req.query(`
|
||||
SELECT ID, NodoSubNodo, Activo, RFC, Nombre, Sucursal, CorreoNotificacion, ServerName, BDName
|
||||
FROM [CONTROLDESK].[dbo].[BasesDeDatos]
|
||||
`);
|
||||
const bases = res.recordset as any[];
|
||||
|
||||
const mapByBdName = new Map<string, any>();
|
||||
const mapByNodo = new Map<string, any>();
|
||||
for (const bd of bases) {
|
||||
if (bd.BDName) {
|
||||
mapByBdName.set(String(bd.BDName).toLowerCase(), bd);
|
||||
}
|
||||
if (bd.NodoSubNodo) {
|
||||
mapByNodo.set(String(bd.NodoSubNodo).toLowerCase(), bd);
|
||||
}
|
||||
}
|
||||
|
||||
databaseRows = databaseRows.map((row) => {
|
||||
const key = String(row.visible_name ?? row.original_name ?? '').toLowerCase();
|
||||
// Intentar match por BDName primero, luego por NodoSubNodo
|
||||
let match = mapByBdName.get(key);
|
||||
if (!match) {
|
||||
match = mapByNodo.get(key);
|
||||
}
|
||||
if (!match) {
|
||||
console.log(`No match found for database: ${key}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
return {
|
||||
...row,
|
||||
NodoSubNodo: match.NodoSubNodo,
|
||||
client_name: match.Nombre,
|
||||
BDName: match.BDName
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Error enriching databaseRows with BasesDeDatos info:', e);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e: any) {
|
||||
console.error("Error processing backups/alerts hydration:", e);
|
||||
}
|
||||
|
||||
// CALCULAR MÉTRICAS DE RESTAURACIÓN basadas en backups reales (ANTES del filtro)
|
||||
restoredCount = 0;
|
||||
notRestoredCount = 0;
|
||||
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
|
||||
for (const db of databaseRows) {
|
||||
// Buscar backups de esta base de datos en las últimas 24 horas
|
||||
// Comparar usando NodoSubNodo (de la BD) con nodo_name (del archivo de backup)
|
||||
const recentBackups = backupFiles.filter(backup => {
|
||||
const backupNodo = (backup.nodo_name || '').toLowerCase();
|
||||
const dbNodo = (db.NodoSubNodo || '').toLowerCase();
|
||||
const backupDate = new Date(backup.date);
|
||||
|
||||
// Si no tiene NodoSubNodo, intentar con el nombre de la BD
|
||||
const hasMatch = dbNodo ? (backupNodo === dbNodo) :
|
||||
(backupNodo === (db.visible_name || '').toLowerCase());
|
||||
|
||||
return hasMatch && backupDate > oneDayAgo;
|
||||
});
|
||||
|
||||
if (recentBackups.length > 0) {
|
||||
restoredCount++;
|
||||
} else {
|
||||
notRestoredCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Aplicar filtro de permisos de usuario (si no es admin)
|
||||
if (!currentUser.es_admin) {
|
||||
databaseRows = await filterDatabasesByUserPermissions(currentUser.id, databaseRows);
|
||||
alertsData = await filterDatabasesByUserPermissions(currentUser.id, alertsData);
|
||||
|
||||
// Filtrar backups según las bases de datos permitidas (usar NodoSubNodo)
|
||||
const allowedNodos = new Set(databaseRows.map(db => (db.NodoSubNodo || db.visible_name).toLowerCase()));
|
||||
backupFiles = backupFiles.filter(backup => {
|
||||
const backupNodo = (backup.nodo_name || '').toLowerCase();
|
||||
return allowedNodos.has(backupNodo);
|
||||
});
|
||||
|
||||
// RECALCULAR MÉTRICAS basadas en las bases de datos filtradas
|
||||
summaryMain.total_size_gb = databaseRows.reduce((sum, db) => sum + (db.total_size_gb || 0), 0);
|
||||
summaryMain.total_size_gb = Math.round(summaryMain.total_size_gb * 100) / 100;
|
||||
|
||||
restoredCount = 0;
|
||||
notRestoredCount = 0;
|
||||
for (const db of databaseRows) {
|
||||
const recentBackups = backupFiles.filter(backup => {
|
||||
const backupNodo = (backup.nodo_name || '').toLowerCase();
|
||||
const dbNodo = (db.NodoSubNodo || '').toLowerCase();
|
||||
const backupDate = new Date(backup.date);
|
||||
|
||||
// Si no tiene NodoSubNodo, intentar con el nombre de la BD
|
||||
const hasMatch = dbNodo ? (backupNodo === dbNodo) :
|
||||
(backupNodo === (db.visible_name || '').toLowerCase());
|
||||
|
||||
return hasMatch && backupDate > oneDayAgo;
|
||||
});
|
||||
|
||||
if (recentBackups.length > 0) {
|
||||
restoredCount++;
|
||||
} else {
|
||||
notRestoredCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
databaseRows,
|
||||
summaryMain,
|
||||
restoredCount,
|
||||
notRestoredCount,
|
||||
|
||||
databaseRowsAZ,
|
||||
summaryAZ,
|
||||
|
||||
backupFiles,
|
||||
clientsData,
|
||||
alertsData,
|
||||
basesDeDatosList,
|
||||
restoreHistory,
|
||||
effectivenessByDb,
|
||||
|
||||
errors, // Return the collected errors
|
||||
currentUser // Añadir usuario actual para la UI
|
||||
};
|
||||
};
|
||||
|
||||
// Acciones para actualizar estado de clientes (activar/desactivar) y editar nombre de base de datos
|
||||
export const actions: Actions = {
|
||||
toggleClient: async ({ request }) => {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const idRaw = formData.get('id');
|
||||
const activoRaw = formData.get('activo');
|
||||
|
||||
if (!idRaw || !activoRaw) {
|
||||
return { success: false, message: 'Par | ||||