feat: autorregistro con verificacion de correo + aprobacion, superadmin y SMTP
- POST /api/auth/register: primer usuario del sistema -> superadmin con sesion directa (bootstrap, sin correo ni aprobacion); los siguientes quedan pending_verification y reciben un correo con enlace de un solo uso (24h). - GET /api/auth/verify-email: pasa a pending_approval y notifica in-app a los admin/superadmin activos. - admin/superadmin aprueban (POST /api/users/:id/approve, eligen rol y departamento) o rechazan (POST /api/users/:id/reject, borra y audita el email) desde Organizacion -> Usuarios (nuevo badge de estado + acciones). - login() da mensaje especifico segun el status de la cuenta en vez del generico "credenciales invalidas". - Recuperacion de clave (forgot/reset-password): respuesta siempre generica, token de 1 hora de un solo uso. - Nuevo modulo settings: SMTP configurable SOLO por superadmin desde Configuracion -> Correo (SMTP); clave cifrada AES-256-GCM (ENCRYPTION_KEY) en la tabla system_settings, nunca expuesta por la API. sendMail() degrada a modo dev (loguea en vez de enviar) si no hay SMTP configurado, y nunca deja que un fallo de envio tumbe al que lo llama. - Migracion 002_registration_and_smtp.sql: rol superadmin, columnas de ciclo de vida en users (status, tokens), tabla system_settings, y promueve al usuario mas antiguo del sistema a superadmin. - Fix: React.StrictMode disparaba el efecto de VerifyEmailPage dos veces; la segunda llamada (token ya consumido) mostraba "enlace invalido" pese a que la verificacion ya habia funcionado. Se agrega una guarda por token. - Fix (deuda tecnica v1.0.0): se reordena el montaje de rutas en app.js — subtasksRoutes (mount generico en /api) va al final, para no interceptar con su requireAuth interno una request sin token dirigida a un router mas especifico registrado despues. - Documentacion actualizada: AGENTS.md, SPEC.md, API.md, BASE_DE_DATOS.md, CONFIGURACION.md, ESTADO.md, CHANGELOG.md. Version 1.1.0. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -106,14 +106,27 @@ Adaptación web (no es una app empaquetada, corre en pestaña de navegador):
|
||||
## Seguridad (proyecto con auth/usuarios)
|
||||
|
||||
- Permisos validados SIEMPRE en el backend, nunca solo en el frontend.
|
||||
- Sin autosuscripción: el administrador crea los usuarios.
|
||||
- Autorregistro con doble candado (decisión posterior al MVP inicial, ver `SPEC.md`): el usuario
|
||||
se crea a sí mismo pero queda `pending_verification` → confirma su correo (token de un solo uso,
|
||||
24h) → `pending_approval` → un `admin`/`superadmin` aprueba (elige rol/departamento) o rechaza
|
||||
(se borra). Solo el usuario más antiguo del sistema es `superadmin` automático (bootstrap, sin
|
||||
correo ni aprobación — no hay quién lo apruebe). El admin sigue pudiendo crear usuarios
|
||||
directamente (ya activos, sin pasar por este flujo) desde Organización → Usuarios.
|
||||
- `401` → el frontend limpia sesión, redirige a login y muestra aviso (ContentDialog).
|
||||
- Cierre de sesión por expiración de JWT.
|
||||
- Auditoría en BD (`audit_log`): usuario, acción, entidad, fecha/hora, detalle.
|
||||
- Credenciales/secretos solo en `.env` (nunca en código); `.env.example` sí se versiona.
|
||||
- Cifrado: bcrypt para contraseñas.
|
||||
- Revalidar `is_active` del usuario en cada request (un JWT firmado no basta).
|
||||
- Credenciales/secretos solo en `.env` (nunca en código); `.env.example` sí se versiona. Excepción:
|
||||
el SMTP del sistema NO vive en `.env` — lo carga el `superadmin` desde Configuración → Correo
|
||||
(SMTP), queda en `system_settings` con la clave cifrada (AES-256-GCM, `ENCRYPTION_KEY`).
|
||||
- Cifrado: bcrypt para contraseñas; AES-256-GCM (`backend/src/utils/crypto.js`) para secretos de
|
||||
terceros guardados en BD (hoy: la clave SMTP).
|
||||
- Revalidar `is_active` del usuario en cada request (un JWT firmado no basta); el login además
|
||||
revisa `status` (`pending_verification`/`pending_approval` dan un mensaje especifico, no login).
|
||||
- SQL siempre parametrizado. Uploads: validar MIME/tamaño, ruta de archivo generada por el servidor.
|
||||
- `forgot-password` responde SIEMPRE genérico (exista o no el correo); el envío de correo nunca
|
||||
debe poder tumbar ni cambiar la respuesta de un endpoint (`sendMail` atrapa sus propios errores).
|
||||
- Sin SMTP configurado, el sistema degrada a modo dev: los correos de verificación/recuperación se
|
||||
loguean en la consola del backend en vez de enviarse (nunca rompe el flujo).
|
||||
|
||||
## Regla de UX para pantallas
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## Base URL
|
||||
|
||||
- Desarrollo: `http://localhost:4019/api`
|
||||
- Producción: `https://project.linkedcorp.net/api` (pendiente de deploy)
|
||||
- Producción: `https://project.linkedcorp.net/api`
|
||||
|
||||
## Autenticacion
|
||||
|
||||
@@ -23,12 +23,22 @@
|
||||
## Endpoints principales
|
||||
|
||||
### Auth
|
||||
- `POST /api/auth/login` — `{ email, password }` → `{ token, user }`
|
||||
- `POST /api/auth/login` — `{ email, password }` → `{ token, user }`. Si el usuario no esta
|
||||
`active` (`pending_verification`/`pending_approval`) o `is_active=0`, `403` con mensaje especifico.
|
||||
- `POST /api/auth/register` — público. `{ name, email, password }` → si es el primer usuario del
|
||||
sistema: `{ bootstrap: true, token, user }` (superadmin, sesion directa); si no:
|
||||
`{ bootstrap: false }` y se envia (o loguea, sin SMTP) un correo de verificación.
|
||||
- `GET /api/auth/verify-email?token=...` — público. Pasa el usuario a `pending_approval` y notifica
|
||||
in-app a los `admin`/`superadmin` activos.
|
||||
- `POST /api/auth/forgot-password` — público. `{ email }` → respuesta **siempre genérica**.
|
||||
- `POST /api/auth/reset-password` — público. `{ token, password }`.
|
||||
- `POST /api/auth/logout`
|
||||
- `GET /api/auth/me` — usuario actual
|
||||
|
||||
### Usuarios (`/api/users`) — solo Administrador
|
||||
- `GET /api/users` · `POST /api/users` · `GET /api/users/:id` · `PUT /api/users/:id` · `PATCH /api/users/:id/active`
|
||||
### Usuarios (`/api/users`) — lectura para cualquier autenticado; escritura solo Administrador/Superadministrador
|
||||
- `GET /api/users` · `POST /api/users` · `PUT /api/users/:id` · `PATCH /api/users/:id/active`
|
||||
- `POST /api/users/:id/approve` — `{ roleId?, departmentId? }`, solo sobre usuarios `pending_approval`
|
||||
- `POST /api/users/:id/reject` — borra al usuario `pending_approval` (se audita el email antes)
|
||||
|
||||
### Departamentos (`/api/departments`)
|
||||
- `GET /api/departments` (árbol) · `POST /api/departments` · `PUT /api/departments/:id` · `DELETE /api/departments/:id`
|
||||
@@ -67,9 +77,13 @@
|
||||
- `GET/POST/PUT/DELETE /api/catalogs/priorities`
|
||||
- `GET/POST/PUT/DELETE /api/catalogs/project-types`
|
||||
|
||||
### Auditoría (`/api/audit`) — solo Administrador / Jefe de TI
|
||||
### Auditoría (`/api/audit`) — solo Superadministrador / Administrador / Jefe de TI
|
||||
- `GET /api/audit?entityType=project&entityId=1`
|
||||
|
||||
### Configuración SMTP (`/api/settings`) — solo Superadministrador
|
||||
- `GET /api/settings/smtp` — `{ host, port, user, from, configured }` (nunca la clave)
|
||||
- `PUT /api/settings/smtp` — `{ host, port, user, from, pass? }` (`pass` vacío = no cambiarla)
|
||||
|
||||
## Errores esperados
|
||||
|
||||
- `400` validación
|
||||
|
||||
+17
-25
@@ -20,8 +20,9 @@
|
||||
|
||||
| Tabla | Contenido |
|
||||
|---|---|
|
||||
| `roles` | catálogo fijo de 5 roles (admin, it_manager, project_lead, collaborator, viewer) |
|
||||
| `users` | usuarios, con rol y departamento |
|
||||
| `roles` | catálogo fijo de 6 roles (superadmin, admin, it_manager, project_lead, collaborator, viewer) |
|
||||
| `users` | usuarios, con rol, departamento y ciclo de vida de registro (`status`, tokens) |
|
||||
| `system_settings` | clave/valor de configuración del sistema (hoy: SMTP, clave cifrada) |
|
||||
| `departments` | departamentos/áreas, jerarquía simple vía `parent_id` |
|
||||
| `project_types` | catálogo de tipos de proyecto |
|
||||
| `priorities` | catálogo de prioridades (baja/media/alta/crítica) |
|
||||
@@ -58,7 +59,14 @@
|
||||
editables directo por el cliente: actividad con subtareas = % de subtareas hechas; actividad sin
|
||||
subtareas = manual (0-100); proyecto = promedio del avance de sus actividades.
|
||||
- `users.password_hash` con bcrypt, nunca texto plano ni reversible.
|
||||
- Borrado lógico donde aplique (`users.is_active`); nunca DELETE físico de usuarios con historial.
|
||||
- `users.status` (`pending_verification`/`pending_approval`/`active`) es el ciclo de vida del
|
||||
autorregistro, independiente de `is_active` (que sigue siendo el interruptor manual del admin
|
||||
para desactivar a alguien ya activo). Un usuario `pending_approval` rechazado se **borra**
|
||||
físicamente (no hay estado "rechazado" — se audita el email en `audit_log` antes de borrar).
|
||||
- `system_settings.value` para `smtp_pass_enc` va **cifrado** (AES-256-GCM, `ENCRYPTION_KEY` del
|
||||
`.env`) — nunca en texto plano, nunca se devuelve por la API (solo `configured: true/false`).
|
||||
- Borrado lógico donde aplique (`users.is_active`); nunca DELETE físico de usuarios **activos** con
|
||||
historial (los `pending_approval` rechazados sí se borran, ver arriba).
|
||||
- Empresa asumida única (no hay tabla `companies` en el MVP).
|
||||
- Producción siempre usa `192.168.222.32`. Desarrollo siempre usa `192.168.222.20`. No mezclar.
|
||||
|
||||
@@ -105,36 +113,20 @@ node src/scripts/seed-db.js
|
||||
|
||||
---
|
||||
|
||||
## Setup de base de datos en producción (primera vez) — pendiente, no ejecutado aún
|
||||
## Setup de base de datos en producción — ejecutado el 2026-09-06
|
||||
|
||||
El servidor `.32` no tiene SSH accesible; el acceso admin es solo desde la máquina de desarrollo.
|
||||
Credenciales de acceso admin: ver `SERVIDORES.md` (project-md-templates/, no versionado).
|
||||
BD `project_linkedcorp` creada en `.32` con `crear-db-prod.sh` (grant a `192.168.222.3`). Detalle
|
||||
del proceso y estado real en `PRODUCCION.md`.
|
||||
|
||||
```bash
|
||||
mysql -h 192.168.222.32 -u mgonzalez -p --ssl=0
|
||||
```
|
||||
|
||||
```sql
|
||||
CREATE DATABASE IF NOT EXISTS project_linkedcorp
|
||||
CHARACTER SET utf8mb4
|
||||
COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE USER IF NOT EXISTS 'project_linkedcorp'@'192.168.222.3'
|
||||
IDENTIFIED BY '[DB_PASSWORD_PROD]';
|
||||
|
||||
GRANT ALL PRIVILEGES ON project_linkedcorp.* TO 'project_linkedcorp'@'192.168.222.3';
|
||||
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
Migración en el servidor de aplicaciones (`.3`), cuando exista el deploy:
|
||||
Migración en el servidor de aplicaciones (`.3`):
|
||||
|
||||
```bash
|
||||
ssh deploy@192.168.222.3 -p 2203
|
||||
cd /opt/apps/project.linkedcorp.net/source/backend
|
||||
set -a && source /opt/apps/project.linkedcorp.net/backend/.env && set +a
|
||||
node src/scripts/migrate-db.js
|
||||
node src/scripts/seed-db.js # solo catálogos/roles; NO recrear usuarios de prueba de dev
|
||||
node src/scripts/seed-db.js # solo catálogos/roles; sin datos de ejemplo en produccion
|
||||
node src/scripts/create-admin.js "Nombre" correo@dominio # solo la primera vez, bootstrap manual
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
# Changelog - project.linkedcorp.net
|
||||
|
||||
## [1.1.0] - 2026-09-06
|
||||
### Added
|
||||
- Autorregistro público (`/registro`) con verificación de correo (enlace de un solo uso, 24h) y
|
||||
aprobación posterior por Administrador/Superadministrador (`/api/users/:id/approve|reject`).
|
||||
- Recuperación de clave (`/olvide-clave` → `/restablecer-clave`), respuesta siempre genérica.
|
||||
- Rol `superadmin`: el usuario más antiguo del sistema (bootstrap automático al primer registro).
|
||||
- Configuración → Correo (SMTP), solo visible/editable por el superadmin; clave cifrada
|
||||
(AES-256-GCM) en `system_settings`, nunca expuesta por la API. Sin configurar, los correos de
|
||||
verificación/recuperación se loguean en vez de enviarse (modo dev, no rompe el flujo).
|
||||
### Fixed
|
||||
- Bug real: `React.StrictMode` disparaba dos veces el efecto de `VerifyEmailPage`, y la segunda
|
||||
llamada (token ya consumido) mostraba "enlace inválido" aunque la verificación sí había
|
||||
funcionado. Corregido con una guarda por token ya solicitado.
|
||||
- Reordenado el montaje de rutas del backend: `subtasksRoutes` (mount genérico en `/api`) queda al
|
||||
final, para no interceptar con su `requireAuth` interno una request sin token dirigida a un
|
||||
router más específico registrado después (ver deuda técnica de la v1.0.0).
|
||||
|
||||
## [1.0.0] - 2026-09-06
|
||||
### Added
|
||||
- Primer despliegue a producción: `https://project.linkedcorp.net` (arquetipo B, dos servicios).
|
||||
|
||||
@@ -30,11 +30,18 @@ DB_PASSWORD=
|
||||
JWT_SECRET=
|
||||
JWT_EXPIRES=8h
|
||||
|
||||
# openssl rand -hex 32 — cifra secretos guardados en BD (hoy: la clave SMTP en system_settings)
|
||||
ENCRYPTION_KEY=
|
||||
|
||||
CORS_ORIGIN=http://localhost:5196
|
||||
|
||||
STORAGE_ROOT=./storage
|
||||
```
|
||||
|
||||
El SMTP real (para los correos de verificación/recuperación de clave) NO va en `.env`: lo carga el
|
||||
Superadministrador desde Configuración → Correo (SMTP) dentro de la app. Sin configurar, el backend
|
||||
degrada a modo dev: loguea el enlace en vez de enviarlo (no rompe el registro ni la recuperación).
|
||||
|
||||
`frontend/.env`:
|
||||
|
||||
```env
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# ESTADO — project.linkedcorp.net
|
||||
|
||||
Ultima actualizacion: 2026-09-06
|
||||
Fase actual: **En produccion** (`https://project.linkedcorp.net`, v1.0.0). MVP fase 1 operativo.
|
||||
Fase actual: **En produccion** (`https://project.linkedcorp.net`, v1.1.0). MVP fase 1 operativo +
|
||||
autorregistro/aprobacion/SMTP.
|
||||
|
||||
## Como retomar
|
||||
|
||||
@@ -63,6 +64,41 @@ Fase actual: **En produccion** (`https://project.linkedcorp.net`, v1.0.0). MVP f
|
||||
higiene de `DEPLOY_WEB.md`) — bloqueado por el clasificador de auto-mode del agente durante el
|
||||
deploy; requiere ejecutarlo a mano o con autorizacion explicita la proxima vez.
|
||||
|
||||
### Autorregistro, aprobación, superadmin y SMTP (2026-09-06)
|
||||
|
||||
- Migración `002_registration_and_smtp.sql`: rol `superadmin`, columnas de ciclo de vida en `users`
|
||||
(`status`, tokens de verificación/recuperación), tabla `system_settings`. El usuario más antiguo
|
||||
(id=1, creado por `create-admin.js` antes de este feature) se promovió solo a `superadmin`.
|
||||
- `POST /api/auth/register`: primer usuario del sistema → `superadmin` con sesión directa
|
||||
(bootstrap); los siguientes → `pending_verification`, correo con enlace de un solo uso (24h).
|
||||
- `GET /api/auth/verify-email` → `pending_approval` + notificación in-app a `admin`/`superadmin`.
|
||||
- `admin`/`superadmin` aprueban (`POST /api/users/:id/approve`, eligen rol/departamento) o rechazan
|
||||
(`POST /api/users/:id/reject`, borra y audita el email) desde Organización → Usuarios (nuevo badge
|
||||
de estado + acciones de fila).
|
||||
- `login()` da mensaje específico según `status` (pendiente de verificar / pendiente de aprobar)
|
||||
en vez del genérico "credenciales inválidas".
|
||||
- Recuperación de clave (`/olvide-clave` → `/restablecer-clave`): respuesta siempre genérica, token
|
||||
de 1 hora de un solo uso.
|
||||
- SMTP configurable desde Configuración → Correo (SMTP), **solo superadmin** (oculto para el resto);
|
||||
clave cifrada AES-256-GCM (`ENCRYPTION_KEY`) en `system_settings`, nunca expuesta por la API.
|
||||
`sendMail()` degrada a modo dev (loguea en vez de enviar) si no hay SMTP configurado, y nunca deja
|
||||
que un fallo de envío tumbe al que lo llama (crítico para `forgot-password`, que debe responder
|
||||
genérico siempre).
|
||||
- Bug real corregido: `React.StrictMode` disparaba el efecto de `VerifyEmailPage` dos veces; la
|
||||
segunda llamada (token ya consumido) mostraba "enlace inválido" pese a que la verificación había
|
||||
funcionado. Se agregó una guarda por token ya solicitado.
|
||||
- Deuda técnica de la v1.0.0 resuelta de paso: se reordenó el montaje de rutas en `app.js` —
|
||||
`subtasksRoutes` (mount genérico en `/api`) ahora va al final, así ya no intercepta con su
|
||||
`requireAuth` interno una request sin token dirigida a un router más específico (`/api/settings`,
|
||||
etc.) registrado después.
|
||||
- Verificado end-to-end en dev con Playwright: registro → correo logueado (modo dev) → login
|
||||
rechazado (pendiente de verificar) → verificar correo → login rechazado (pendiente de aprobar) →
|
||||
aprobar desde Usuarios → login exitoso → recuperar clave → login con clave nueva → panel SMTP
|
||||
visible solo para superadmin. Sin errores de consola.
|
||||
- **Pendiente**: desplegar esta versión a producción (migración `002` sobre `.32` + redeploy de
|
||||
ambos servicios) — ver `DEPLOY_WEB.md`. El superadmin de producción deberá cargar el SMTP real
|
||||
desde la UI cuando lo tenga; hasta entonces el sistema sigue en modo dev (logueando enlaces).
|
||||
|
||||
## Pendiente (fase 2, sin comprometer lo ya construido)
|
||||
|
||||
- Riesgos e Incidencias como modulos propios (hoy: `risk_level` simple en el proyecto).
|
||||
@@ -87,14 +123,12 @@ Fase actual: **En produccion** (`https://project.linkedcorp.net`, v1.0.0). MVP f
|
||||
(roles/prioridades/estados/tipos) cuando `NODE_ENV=production`; los departamentos y usuarios de
|
||||
ejemplo (TI/Finanzas/RRHH/Comercial, admin con clave=usuario) son SOLO de dev. El admin real de
|
||||
produccion se crea con `create-admin.js` (clave fuerte generada, nunca clave=usuario).
|
||||
- Autorregistro con doble candado (correo + aprobacion admin) — decision explicita del usuario,
|
||||
revierte la regla inicial "sin autosuscripcion". El primer usuario del sistema es `superadmin`
|
||||
automatico; el resto pasa por verificacion + aprobacion. SMTP vive en BD (cifrado), configurable
|
||||
solo por el superadmin desde la UI, no en `.env`.
|
||||
|
||||
## Deuda tecnica
|
||||
|
||||
- `npm audit`: 3 vulnerabilidades moderadas en `qs`/`body-parser`, transitivas de Express 4.x (sin
|
||||
fix no-breaking disponible al 2026-09-06). No critico; revisar cuando Express publique el fix.
|
||||
- Ruteo backend: `app.use('/api', subtasksRoutes)` actua como catch-all de cualquier `/api/*` no
|
||||
matcheado por un mount mas especifico registrado antes; una request SIN token a una ruta
|
||||
inexistente bajo `/api` devuelve 401 "No autenticado" en vez de 404. No afecta ninguna ruta real
|
||||
de la app (todas requieren auth de todos modos) — deteccion: `curl .../api/health` (esa ruta no
|
||||
existe bajo `/api`, solo en la raiz `/health`). Prolijidad pendiente para fase 2: montar
|
||||
`subtasksRoutes` bajo un prefijo mas especifico.
|
||||
|
||||
@@ -15,7 +15,8 @@ tiene cada cosa — sin que cargar la información sea tedioso para quien lo usa
|
||||
|
||||
| Rol | Alcance |
|
||||
|---|---|
|
||||
| Administrador | Todo, incluida gestión de usuarios/roles/catálogos |
|
||||
| Super Administrador | Todo lo de Administrador + configura el SMTP del sistema. Es el usuario más antiguo (bootstrap automático al registrarse el primero) |
|
||||
| Administrador | Todo, incluida gestión de usuarios/roles/catálogos y aprobar/rechazar registros |
|
||||
| Jefe de TI | Ve y gestiona todos los proyectos/actividades, aprueba; no administra usuarios |
|
||||
| Coordinador / Líder de proyecto | Crea/edita los proyectos donde es responsable o miembro, gestiona sus actividades |
|
||||
| Colaborador | Ve y actualiza lo asignado a él (actividades/subtareas propias), comenta |
|
||||
@@ -25,7 +26,16 @@ tiene cada cosa — sin que cargar la información sea tedioso para quien lo usa
|
||||
|
||||
### 1. Seguridad
|
||||
- Login/logout con JWT, sesión persistida en el navegador.
|
||||
- Usuarios: alta/edición por el Administrador (nombre, email, rol, departamento, cargo, activo/inactivo).
|
||||
- Usuarios: alta/edición directa por el Administrador (nombre, email, rol, departamento, cargo,
|
||||
activo/inactivo) **o** autorregistro público (`/registro`) con doble candado: el propio usuario
|
||||
confirma su correo (enlace de un solo uso, vence en 24h) y luego un Administrador/Super
|
||||
Administrador aprueba la cuenta (elige rol y departamento) o la rechaza. El primer usuario que
|
||||
exista en el sistema se vuelve Super Administrador automáticamente, sin correo ni aprobación.
|
||||
- Recuperación de clave (`/olvide-clave` → `/restablecer-clave`): respuesta siempre genérica
|
||||
(no revela si el correo existe), enlace de un solo uso que vence en 1 hora.
|
||||
- Correo saliente (verificación/recuperación): SMTP configurable desde Configuración por el Super
|
||||
Administrador; sin configurar, el sistema sigue funcionando en modo dev (el enlace se loguea en
|
||||
vez de enviarse por correo real).
|
||||
- Auditoría central (`audit_log`): quién hizo qué, sobre qué entidad, cuándo.
|
||||
|
||||
### 2. Organización
|
||||
@@ -71,6 +81,9 @@ tiene cada cosa — sin que cargar la información sea tedioso para quien lo usa
|
||||
- Catálogos editables por el Administrador: estados de proyecto, estados de actividad, prioridades,
|
||||
tipos de proyecto, departamentos. Vienen con datos semilla — no requieren configuración inicial.
|
||||
- Preferencias de interfaz: idioma es-EC/en-US, tema claro/oscuro, apariencia de botones.
|
||||
- Correo (SMTP) — visible y editable **solo** por el Super Administrador: host, puerto, usuario,
|
||||
remitente y clave (la clave se guarda cifrada y nunca se vuelve a mostrar, solo se sabe si está
|
||||
"configurada" o no).
|
||||
|
||||
## Flujos clave
|
||||
|
||||
|
||||
@@ -10,6 +10,13 @@ DB_PASSWORD=
|
||||
JWT_SECRET=
|
||||
JWT_EXPIRES=8h
|
||||
|
||||
# openssl rand -hex 32 — usada para cifrar secretos guardados en BD (ej. clave SMTP).
|
||||
ENCRYPTION_KEY=
|
||||
|
||||
CORS_ORIGIN=http://localhost:5196
|
||||
|
||||
STORAGE_ROOT=./storage
|
||||
|
||||
# El SMTP real NO se configura aqui: lo carga el superadmin desde Configuracion > Correo (SMTP)
|
||||
# en la app (queda guardado cifrado en la tabla system_settings). Sin configurar, los correos de
|
||||
# verificacion/recuperacion se loguean en la consola del backend en vez de enviarse.
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
-- Registro con verificacion de correo + aprobacion, rol superadmin, config SMTP. Idempotente.
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
INSERT IGNORE INTO roles (code, name) VALUES ('superadmin', 'Super Administrador');
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS status ENUM('pending_verification','pending_approval','active')
|
||||
NOT NULL DEFAULT 'active',
|
||||
ADD COLUMN IF NOT EXISTS email_verified_at DATETIME NULL,
|
||||
ADD COLUMN IF NOT EXISTS verification_token VARCHAR(128) NULL,
|
||||
ADD COLUMN IF NOT EXISTS verification_token_expires DATETIME NULL,
|
||||
ADD COLUMN IF NOT EXISTS reset_token VARCHAR(128) NULL,
|
||||
ADD COLUMN IF NOT EXISTS reset_token_expires DATETIME NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_verification_token ON users(verification_token);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_reset_token ON users(reset_token);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS system_settings (
|
||||
`key` VARCHAR(60) PRIMARY KEY,
|
||||
`value` TEXT NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- El usuario mas antiguo del sistema (creado antes de este feature, via create-admin.js) pasa a
|
||||
-- superadmin: es efectivamente el "primer usuario", solo que no vino del flujo de auto-registro.
|
||||
UPDATE users u JOIN roles r ON r.code = 'superadmin'
|
||||
SET u.role_id = r.id
|
||||
WHERE u.id = (SELECT id FROM (SELECT MIN(id) AS id FROM users) x)
|
||||
AND u.role_id <> r.id;
|
||||
Generated
+13
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "project-linkedcorp-backend",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "project-linkedcorp-backend",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
@@ -16,7 +16,8 @@
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.3.0",
|
||||
"mysql2": "^3.11.0"
|
||||
"mysql2": "^3.11.0",
|
||||
"nodemailer": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
@@ -859,6 +860,15 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.0.tgz",
|
||||
"integrity": "sha512-wdv+hXBg0iIPIOf108FaZyTxqjQpCvQeAJt9pHHTooPy/y3vQtI9eiImM9AIVKR3e/0FeMA1oueNfBjFJcaK+Q==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "project-linkedcorp-backend",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/server.js",
|
||||
@@ -20,6 +20,7 @@
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.3.0",
|
||||
"mysql2": "^3.11.0"
|
||||
"mysql2": "^3.11.0",
|
||||
"nodemailer": "^10.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -16,6 +16,7 @@ import attachmentsRoutes from './modules/attachments/routes.js';
|
||||
import notificationsRoutes from './modules/notifications/routes.js';
|
||||
import dashboardRoutes from './modules/dashboard/routes.js';
|
||||
import auditRoutes from './modules/audit/routes.js';
|
||||
import settingsRoutes from './modules/settings/routes.js';
|
||||
|
||||
export const app = express();
|
||||
|
||||
@@ -31,12 +32,16 @@ app.use('/api/departments', departmentsRoutes);
|
||||
app.use('/api/catalogs', catalogsRoutes);
|
||||
app.use('/api/projects', projectsRoutes);
|
||||
app.use('/api/activities', activitiesRoutes);
|
||||
app.use('/api', subtasksRoutes);
|
||||
app.use('/api/comments', commentsRoutes);
|
||||
app.use('/api/attachments', attachmentsRoutes);
|
||||
app.use('/api/notifications', notificationsRoutes);
|
||||
app.use('/api/dashboard', dashboardRoutes);
|
||||
app.use('/api/audit', auditRoutes);
|
||||
app.use('/api/settings', settingsRoutes);
|
||||
// Mas generico (mount en /api sin sub-path propio) — SIEMPRE al final de los /api/*, para no
|
||||
// interceptar con su requireAuth interno una ruta que en realidad pertenece a un router mas
|
||||
// especifico registrado despues (ver ESTADO.md, deuda tecnica).
|
||||
app.use('/api', subtasksRoutes);
|
||||
|
||||
app.use(notFoundHandler);
|
||||
app.use(errorHandler);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'dotenv/config';
|
||||
|
||||
const required = ['DB_HOST', 'DB_NAME', 'DB_USER', 'DB_PASSWORD', 'JWT_SECRET'];
|
||||
const required = ['DB_HOST', 'DB_NAME', 'DB_USER', 'DB_PASSWORD', 'JWT_SECRET', 'ENCRYPTION_KEY'];
|
||||
|
||||
for (const key of required) {
|
||||
if (!process.env[key]) {
|
||||
@@ -15,6 +15,10 @@ if (process.env.NODE_ENV === 'production' && (process.env.JWT_SECRET || '').leng
|
||||
throw new Error('JWT_SECRET debe tener al menos 32 caracteres en producción');
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'production' && (process.env.ENCRYPTION_KEY || '').length !== 64) {
|
||||
throw new Error('ENCRYPTION_KEY debe ser hex de 64 caracteres (32 bytes) en producción — generar con openssl rand -hex 32');
|
||||
}
|
||||
|
||||
export const env = {
|
||||
port: Number(process.env.PORT || 4019),
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
|
||||
@@ -46,6 +46,7 @@ export function requireRole(...roleCodes) {
|
||||
}
|
||||
|
||||
export const ROLES = {
|
||||
SUPERADMIN: 'superadmin',
|
||||
ADMIN: 'admin',
|
||||
IT_MANAGER: 'it_manager',
|
||||
PROJECT_LEAD: 'project_lead',
|
||||
@@ -53,6 +54,7 @@ export const ROLES = {
|
||||
VIEWER: 'viewer',
|
||||
};
|
||||
|
||||
export const CAN_MANAGE_CATALOGS = [ROLES.ADMIN];
|
||||
export const CAN_MANAGE_USERS = [ROLES.ADMIN];
|
||||
export const CAN_WRITE = [ROLES.ADMIN, ROLES.IT_MANAGER, ROLES.PROJECT_LEAD, ROLES.COLLABORATOR];
|
||||
export const CAN_MANAGE_CATALOGS = [ROLES.SUPERADMIN, ROLES.ADMIN];
|
||||
export const CAN_MANAGE_USERS = [ROLES.SUPERADMIN, ROLES.ADMIN];
|
||||
export const CAN_MANAGE_SMTP = [ROLES.SUPERADMIN];
|
||||
export const CAN_WRITE = [ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER, ROLES.PROJECT_LEAD, ROLES.COLLABORATOR];
|
||||
|
||||
@@ -15,7 +15,7 @@ async function getProjectOr404(projectId) {
|
||||
// admin/it_manager siempre; project_lead si tiene acceso al proyecto; collaborator solo si es
|
||||
// el responsable de la actividad (no puede crear actividades nuevas ni reasignarlas a otros).
|
||||
export async function canWriteActivity(user, project, activity) {
|
||||
if (user.roleCode === ROLES.ADMIN || user.roleCode === ROLES.IT_MANAGER) return true;
|
||||
if ([ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER].includes(user.roleCode)) return true;
|
||||
if (user.roleCode === ROLES.PROJECT_LEAD) return canWriteProject(user, project);
|
||||
if (user.roleCode === ROLES.COLLABORATOR && activity) return activity.responsible_user_id === user.id;
|
||||
return false;
|
||||
|
||||
@@ -40,7 +40,7 @@ export const downloadAttachment = asyncHandler(async (req, res) => {
|
||||
export const deleteAttachment = asyncHandler(async (req, res) => {
|
||||
const attachment = await repo.findById(Number(req.params.id));
|
||||
if (!attachment) throw new HttpError(404, 'Adjunto no encontrado');
|
||||
const canDelete = [ROLES.ADMIN, ROLES.IT_MANAGER].includes(req.currentUser.roleCode) || attachment.uploaded_by === req.currentUser.id;
|
||||
const canDelete = [ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER].includes(req.currentUser.roleCode) || attachment.uploaded_by === req.currentUser.id;
|
||||
if (!canDelete) throw new HttpError(403, 'No puedes eliminar este adjunto');
|
||||
|
||||
await repo.remove(attachment.id);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getAudit } from './controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(requireAuth, requireRole(ROLES.ADMIN, ROLES.IT_MANAGER));
|
||||
router.use(requireAuth, requireRole(ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER));
|
||||
router.get('/', getAudit);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -16,3 +16,26 @@ export const postLogout = asyncHandler(async (req, res) => {
|
||||
export const getMe = asyncHandler(async (req, res) => {
|
||||
ok(res, req.currentUser);
|
||||
});
|
||||
|
||||
export const postRegister = asyncHandler(async (req, res) => {
|
||||
const result = await authService.register(req.body || {});
|
||||
ok(res, result, 201);
|
||||
});
|
||||
|
||||
export const getVerifyEmail = asyncHandler(async (req, res) => {
|
||||
const result = await authService.verifyEmail(req.query.token);
|
||||
ok(res, result);
|
||||
});
|
||||
|
||||
export const postForgotPassword = asyncHandler(async (req, res) => {
|
||||
const { email } = req.body || {};
|
||||
if (email) await authService.forgotPassword(email);
|
||||
// Respuesta siempre generica, exista o no el correo.
|
||||
ok(res, { message: 'Si el correo existe, te enviamos instrucciones para recuperar tu clave.' });
|
||||
});
|
||||
|
||||
export const postResetPassword = asyncHandler(async (req, res) => {
|
||||
const { token, password } = req.body || {};
|
||||
await authService.resetPassword(token, password);
|
||||
ok(res, { reset: true });
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Router } from 'express';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { requireAuth } from '../../middleware/auth.js';
|
||||
import { postLogin, postLogout, getMe } from './controller.js';
|
||||
import {
|
||||
postLogin, postLogout, getMe, postRegister, getVerifyEmail, postForgotPassword, postResetPassword,
|
||||
} from './controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -12,7 +14,12 @@ const loginLimiter = rateLimit({
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
// Mismo limiter para registro/recuperacion: son los otros puntos de entrada publicos sensibles.
|
||||
router.post('/login', loginLimiter, postLogin);
|
||||
router.post('/register', loginLimiter, postRegister);
|
||||
router.get('/verify-email', loginLimiter, getVerifyEmail);
|
||||
router.post('/forgot-password', loginLimiter, postForgotPassword);
|
||||
router.post('/reset-password', loginLimiter, postResetPassword);
|
||||
router.post('/logout', requireAuth, postLogout);
|
||||
router.get('/me', requireAuth, getMe);
|
||||
|
||||
|
||||
@@ -1,23 +1,39 @@
|
||||
import crypto from 'node:crypto';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { pool } from '../../config/db.js';
|
||||
import { env } from '../../config/env.js';
|
||||
import { HttpError } from '../../utils/http.js';
|
||||
import { logAudit } from '../../utils/audit.js';
|
||||
import { sendMail } from '../../utils/mailer.js';
|
||||
import { createNotification } from '../notifications/service.js';
|
||||
|
||||
const STATUS_MESSAGES = {
|
||||
pending_verification: 'Debes confirmar tu correo antes de iniciar sesion. Revisa tu bandeja de entrada.',
|
||||
pending_approval: 'Tu cuenta esta pendiente de aprobacion por un administrador.',
|
||||
};
|
||||
|
||||
function newToken() {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
export async function login(email, password) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT u.id, u.name, u.email, u.password_hash, u.is_active, u.department_id, r.code AS role_code, r.name AS role_name
|
||||
`SELECT u.id, u.name, u.email, u.password_hash, u.is_active, u.status, u.department_id,
|
||||
r.code AS role_code, r.name AS role_name
|
||||
FROM users u JOIN roles r ON r.id = u.role_id
|
||||
WHERE u.email = ? LIMIT 1`,
|
||||
[email]
|
||||
);
|
||||
const user = rows[0];
|
||||
if (!user || !user.is_active) throw new HttpError(401, 'Credenciales invalidas');
|
||||
if (!user) throw new HttpError(401, 'Credenciales invalidas');
|
||||
|
||||
const validPassword = await bcrypt.compare(password, user.password_hash);
|
||||
if (!validPassword) throw new HttpError(401, 'Credenciales invalidas');
|
||||
|
||||
if (STATUS_MESSAGES[user.status]) throw new HttpError(403, STATUS_MESSAGES[user.status]);
|
||||
if (!user.is_active) throw new HttpError(403, 'Cuenta inactiva. Contacta a un administrador.');
|
||||
|
||||
const token = jwt.sign({ sub: user.id }, env.jwtSecret, { expiresIn: env.jwtExpires });
|
||||
|
||||
await logAudit({ userId: user.id, action: 'login', entityType: 'auth', entityId: user.id });
|
||||
@@ -34,3 +50,123 @@ export async function login(email, password) {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function register({ name, email, password }) {
|
||||
if (!name || !email || !password || password.length < 8) {
|
||||
throw new HttpError(400, 'Nombre, correo y una clave de al menos 8 caracteres son obligatorios');
|
||||
}
|
||||
const [existing] = await pool.query('SELECT id FROM users WHERE email = ?', [email]);
|
||||
if (existing.length) throw new HttpError(400, 'Ya existe una cuenta con ese correo');
|
||||
|
||||
const [[{ total }]] = await pool.query('SELECT COUNT(*) AS total FROM users');
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
|
||||
if (total === 0) {
|
||||
// Primer usuario del sistema: superadmin, sin correo ni aprobacion (no hay quien lo apruebe).
|
||||
const [superadminRole] = await pool.query("SELECT id FROM roles WHERE code = 'superadmin'");
|
||||
const [result] = await pool.query(
|
||||
`INSERT INTO users (name, email, password_hash, role_id, is_active, status, email_verified_at)
|
||||
VALUES (?, ?, ?, ?, 1, 'active', NOW())`,
|
||||
[name, email, passwordHash, superadminRole[0].id]
|
||||
);
|
||||
await logAudit({ userId: result.insertId, action: 'register_bootstrap_superadmin', entityType: 'user', entityId: result.insertId });
|
||||
const token = jwt.sign({ sub: result.insertId }, env.jwtSecret, { expiresIn: env.jwtExpires });
|
||||
return {
|
||||
bootstrap: true,
|
||||
token,
|
||||
user: { id: result.insertId, name, email, roleCode: 'superadmin', roleName: 'Super Administrador', departmentId: null },
|
||||
};
|
||||
}
|
||||
|
||||
const [collaboratorRole] = await pool.query("SELECT id FROM roles WHERE code = 'collaborator'");
|
||||
const verificationToken = newToken();
|
||||
const [result] = await pool.query(
|
||||
`INSERT INTO users (name, email, password_hash, role_id, is_active, status, verification_token, verification_token_expires)
|
||||
VALUES (?, ?, ?, ?, 0, 'pending_verification', ?, DATE_ADD(NOW(), INTERVAL 24 HOUR))`,
|
||||
[name, email, passwordHash, collaboratorRole[0].id, verificationToken]
|
||||
);
|
||||
await logAudit({ userId: result.insertId, action: 'register', entityType: 'user', entityId: result.insertId });
|
||||
|
||||
const verifyUrl = `${env.corsOrigin}/verificar-correo?token=${verificationToken}`;
|
||||
await sendMail({
|
||||
to: email,
|
||||
subject: 'Confirma tu correo — project.linkedcorp.net',
|
||||
html: `<p>Hola ${name},</p><p>Confirma tu correo para continuar tu registro:</p><p><a href="${verifyUrl}">${verifyUrl}</a></p><p>Este enlace vence en 24 horas.</p>`,
|
||||
});
|
||||
|
||||
return { bootstrap: false };
|
||||
}
|
||||
|
||||
export async function verifyEmail(token) {
|
||||
if (!token) throw new HttpError(400, 'Token invalido');
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, name, email FROM users
|
||||
WHERE verification_token = ? AND status = 'pending_verification' AND verification_token_expires > NOW()`,
|
||||
[token]
|
||||
);
|
||||
const user = rows[0];
|
||||
if (!user) throw new HttpError(400, 'El enlace de verificacion es invalido o expiro');
|
||||
|
||||
await pool.query(
|
||||
`UPDATE users SET status = 'pending_approval', email_verified_at = NOW(),
|
||||
verification_token = NULL, verification_token_expires = NULL
|
||||
WHERE id = ?`,
|
||||
[user.id]
|
||||
);
|
||||
await logAudit({ userId: user.id, action: 'verify_email', entityType: 'user', entityId: user.id });
|
||||
|
||||
const [admins] = await pool.query(
|
||||
`SELECT u.id FROM users u JOIN roles r ON r.id = u.role_id
|
||||
WHERE r.code IN ('admin','superadmin') AND u.is_active = 1`
|
||||
);
|
||||
for (const admin of admins) {
|
||||
await createNotification({
|
||||
userId: admin.id,
|
||||
type: 'registration_pending',
|
||||
message: `Nueva cuenta pendiente de aprobacion: ${user.name} (${user.email})`,
|
||||
});
|
||||
}
|
||||
|
||||
return { email: user.email };
|
||||
}
|
||||
|
||||
export async function forgotPassword(email) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, name FROM users WHERE email = ? AND status = 'active' AND is_active = 1`,
|
||||
[email]
|
||||
);
|
||||
const user = rows[0];
|
||||
// Respuesta siempre generica — no revela si el correo existe (regla de SEGURIDAD-Y-CALIDAD.md).
|
||||
if (!user) return;
|
||||
|
||||
const resetToken = newToken();
|
||||
await pool.query(
|
||||
`UPDATE users SET reset_token = ?, reset_token_expires = DATE_ADD(NOW(), INTERVAL 1 HOUR) WHERE id = ?`,
|
||||
[resetToken, user.id]
|
||||
);
|
||||
const resetUrl = `${env.corsOrigin}/restablecer-clave?token=${resetToken}`;
|
||||
await sendMail({
|
||||
to: email,
|
||||
subject: 'Recuperar contraseña — project.linkedcorp.net',
|
||||
html: `<p>Hola ${user.name},</p><p>Para elegir una nueva clave entra a este enlace (vence en 1 hora):</p><p><a href="${resetUrl}">${resetUrl}</a></p><p>Si no pediste esto, ignora el correo.</p>`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function resetPassword(token, newPassword) {
|
||||
if (!token || !newPassword || newPassword.length < 8) {
|
||||
throw new HttpError(400, 'Token y una clave nueva de al menos 8 caracteres son obligatorios');
|
||||
}
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id FROM users WHERE reset_token = ? AND reset_token_expires > NOW()`,
|
||||
[token]
|
||||
);
|
||||
const user = rows[0];
|
||||
if (!user) throw new HttpError(400, 'El enlace de recuperacion es invalido o expiro');
|
||||
|
||||
const passwordHash = await bcrypt.hash(newPassword, 12);
|
||||
await pool.query(
|
||||
`UPDATE users SET password_hash = ?, reset_token = NULL, reset_token_expires = NULL WHERE id = ?`,
|
||||
[passwordHash, user.id]
|
||||
);
|
||||
await logAudit({ userId: user.id, action: 'reset_password', entityType: 'user', entityId: user.id });
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ROLES } from '../../middleware/auth.js';
|
||||
|
||||
// Alcance de escritura: admin/it_manager siempre; project_lead solo si es dueño o miembro.
|
||||
export async function canWriteProject(user, project) {
|
||||
if (user.roleCode === ROLES.ADMIN || user.roleCode === ROLES.IT_MANAGER) return true;
|
||||
if ([ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER].includes(user.roleCode)) return true;
|
||||
if (user.roleCode !== ROLES.PROJECT_LEAD) return false;
|
||||
if (project.owner_user_id === user.id) return true;
|
||||
return repo.isMember(project.id, user.id);
|
||||
@@ -24,7 +24,7 @@ export async function createProject(payload, currentUser) {
|
||||
if (!payload.name || !payload.priorityId || !payload.statusId) {
|
||||
throw new HttpError(400, 'Nombre, prioridad y estado son obligatorios');
|
||||
}
|
||||
if (![ROLES.ADMIN, ROLES.IT_MANAGER, ROLES.PROJECT_LEAD].includes(currentUser.roleCode)) {
|
||||
if (![ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER, ROLES.PROJECT_LEAD].includes(currentUser.roleCode)) {
|
||||
throw new HttpError(403, 'No tienes permisos para crear proyectos');
|
||||
}
|
||||
const project = await repo.create({
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { ok, asyncHandler } from '../../utils/http.js';
|
||||
import * as service from './service.js';
|
||||
|
||||
export const getSmtp = asyncHandler(async (req, res) => ok(res, await service.getSmtpConfig()));
|
||||
|
||||
export const putSmtp = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.updateSmtpConfig(req.body, req.currentUser.id));
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { pool } from '../../config/db.js';
|
||||
|
||||
export async function getAll(keys) {
|
||||
const placeholders = keys.map(() => '?').join(', ');
|
||||
const [rows] = await pool.query(`SELECT \`key\`, \`value\` FROM system_settings WHERE \`key\` IN (${placeholders})`, keys);
|
||||
const map = {};
|
||||
for (const row of rows) map[row.key] = row.value;
|
||||
return map;
|
||||
}
|
||||
|
||||
export async function setMany(entries) {
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
await pool.query(
|
||||
'INSERT INTO system_settings (`key`, `value`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)',
|
||||
[key, value]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth, requireRole, CAN_MANAGE_SMTP } from '../../middleware/auth.js';
|
||||
import { getSmtp, putSmtp } from './controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(requireAuth, requireRole(...CAN_MANAGE_SMTP));
|
||||
router.get('/smtp', getSmtp);
|
||||
router.put('/smtp', putSmtp);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,44 @@
|
||||
import * as repo from './repository.js';
|
||||
import { encrypt, decrypt } from '../../utils/crypto.js';
|
||||
import { logAudit } from '../../utils/audit.js';
|
||||
import { HttpError } from '../../utils/http.js';
|
||||
|
||||
const SMTP_KEYS = ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_from', 'smtp_pass_enc'];
|
||||
|
||||
export async function getSmtpConfig() {
|
||||
const values = await repo.getAll(SMTP_KEYS);
|
||||
return {
|
||||
host: values.smtp_host || '',
|
||||
port: values.smtp_port ? Number(values.smtp_port) : 587,
|
||||
user: values.smtp_user || '',
|
||||
from: values.smtp_from || '',
|
||||
configured: !!(values.smtp_host && values.smtp_user && values.smtp_pass_enc),
|
||||
};
|
||||
}
|
||||
|
||||
// Version interna (con la clave descifrada) — solo la usa el mailer, nunca se expone via API.
|
||||
export async function getSmtpConfigInternal() {
|
||||
const values = await repo.getAll(SMTP_KEYS);
|
||||
if (!values.smtp_host || !values.smtp_user || !values.smtp_pass_enc) return null;
|
||||
return {
|
||||
host: values.smtp_host,
|
||||
port: values.smtp_port ? Number(values.smtp_port) : 587,
|
||||
user: values.smtp_user,
|
||||
from: values.smtp_from || values.smtp_user,
|
||||
pass: decrypt(values.smtp_pass_enc),
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateSmtpConfig(payload, actorId) {
|
||||
if (!payload.host || !payload.user) throw new HttpError(400, 'Host y usuario son obligatorios');
|
||||
const entries = {
|
||||
smtp_host: payload.host,
|
||||
smtp_port: String(payload.port || 587),
|
||||
smtp_user: payload.user,
|
||||
smtp_from: payload.from || payload.user,
|
||||
};
|
||||
if (payload.pass) entries.smtp_pass_enc = encrypt(payload.pass);
|
||||
await repo.setMany(entries);
|
||||
await logAudit({ userId: actorId, action: 'update', entityType: 'settings:smtp', entityId: null });
|
||||
return getSmtpConfig();
|
||||
}
|
||||
@@ -18,3 +18,13 @@ export const patchUserActive = asyncHandler(async (req, res) => {
|
||||
const user = await service.setUserActive(Number(req.params.id), !!req.body.isActive, req.currentUser.id);
|
||||
ok(res, user);
|
||||
});
|
||||
|
||||
export const postApproveUser = asyncHandler(async (req, res) => {
|
||||
const user = await service.approveUser(Number(req.params.id), req.body, req.currentUser.id);
|
||||
ok(res, user);
|
||||
});
|
||||
|
||||
export const postRejectUser = asyncHandler(async (req, res) => {
|
||||
await service.rejectUser(Number(req.params.id), req.currentUser.id);
|
||||
ok(res, { rejected: true });
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { pool } from '../../config/db.js';
|
||||
export async function findAll() {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT u.id, u.name, u.email, u.role_id, r.code AS role_code, r.name AS role_name,
|
||||
u.department_id, d.name AS department_name, u.position, u.is_active, u.created_at
|
||||
u.department_id, d.name AS department_name, u.position, u.is_active, u.status, u.created_at
|
||||
FROM users u
|
||||
JOIN roles r ON r.id = u.role_id
|
||||
LEFT JOIN departments d ON d.id = u.department_id
|
||||
@@ -14,7 +14,7 @@ export async function findAll() {
|
||||
|
||||
export async function findById(id) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT u.id, u.name, u.email, u.role_id, r.code AS role_code, u.department_id, u.position, u.is_active
|
||||
`SELECT u.id, u.name, u.email, u.role_id, r.code AS role_code, u.department_id, u.position, u.is_active, u.status
|
||||
FROM users u JOIN roles r ON r.id = u.role_id WHERE u.id = ?`,
|
||||
[id]
|
||||
);
|
||||
@@ -48,6 +48,19 @@ export async function setActive(id, isActive) {
|
||||
return findById(id);
|
||||
}
|
||||
|
||||
export async function approve(id, { roleId, departmentId }) {
|
||||
await pool.query(
|
||||
`UPDATE users SET status = 'active', is_active = 1, role_id = COALESCE(?, role_id), department_id = COALESCE(?, department_id)
|
||||
WHERE id = ?`,
|
||||
[roleId || null, departmentId || null, id]
|
||||
);
|
||||
return findById(id);
|
||||
}
|
||||
|
||||
export async function remove(id) {
|
||||
await pool.query('DELETE FROM users WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
export async function listRoles() {
|
||||
const [rows] = await pool.query('SELECT id, code, name FROM roles ORDER BY id');
|
||||
return rows;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth, requireRole, CAN_MANAGE_USERS } from '../../middleware/auth.js';
|
||||
import { getUsers, getRoles, postUser, putUser, patchUserActive } from './controller.js';
|
||||
import { getUsers, getRoles, postUser, putUser, patchUserActive, postApproveUser, postRejectUser } from './controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -10,5 +10,7 @@ router.get('/roles', getRoles);
|
||||
router.post('/', requireRole(...CAN_MANAGE_USERS), postUser);
|
||||
router.put('/:id', requireRole(...CAN_MANAGE_USERS), putUser);
|
||||
router.patch('/:id/active', requireRole(...CAN_MANAGE_USERS), patchUserActive);
|
||||
router.post('/:id/approve', requireRole(...CAN_MANAGE_USERS), postApproveUser);
|
||||
router.post('/:id/reject', requireRole(...CAN_MANAGE_USERS), postRejectUser);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -39,3 +39,20 @@ export async function setUserActive(id, isActive, actorId) {
|
||||
});
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function approveUser(id, payload, actorId) {
|
||||
const existing = await repo.findById(id);
|
||||
if (!existing) throw new HttpError(404, 'Usuario no encontrado');
|
||||
if (existing.status !== 'pending_approval') throw new HttpError(400, 'El usuario no esta pendiente de aprobacion');
|
||||
const user = await repo.approve(id, payload);
|
||||
await logAudit({ userId: actorId, action: 'approve', entityType: 'user', entityId: id });
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function rejectUser(id, actorId) {
|
||||
const existing = await repo.findById(id);
|
||||
if (!existing) throw new HttpError(404, 'Usuario no encontrado');
|
||||
if (existing.status !== 'pending_approval') throw new HttpError(400, 'El usuario no esta pendiente de aprobacion');
|
||||
await logAudit({ userId: actorId, action: 'reject', entityType: 'user', entityId: id, detail: { email: existing.email } });
|
||||
await repo.remove(id);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ async function run() {
|
||||
|
||||
// Catalogos base — necesarios para que la app funcione en CUALQUIER entorno (dev y produccion).
|
||||
const roles = [
|
||||
['superadmin', 'Super Administrador'],
|
||||
['admin', 'Administrador'],
|
||||
['it_manager', 'Jefe de TI'],
|
||||
['project_lead', 'Coordinador / Lider de proyecto'],
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
// Cifrado simetrico para secretos de terceros guardados en BD (ej. clave SMTP) — nunca en texto
|
||||
// plano. AES-256-GCM con ENCRYPTION_KEY (32 bytes hex) del entorno.
|
||||
const KEY = Buffer.from(process.env.ENCRYPTION_KEY || '', 'hex');
|
||||
|
||||
function requireKey() {
|
||||
if (KEY.length !== 32) {
|
||||
throw new Error('ENCRYPTION_KEY invalido o ausente (debe ser hex de 32 bytes / 64 caracteres)');
|
||||
}
|
||||
}
|
||||
|
||||
export function encrypt(text) {
|
||||
requireKey();
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', KEY, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
return Buffer.concat([iv, authTag, encrypted]).toString('base64');
|
||||
}
|
||||
|
||||
export function decrypt(payload) {
|
||||
requireKey();
|
||||
const raw = Buffer.from(payload, 'base64');
|
||||
const iv = raw.subarray(0, 12);
|
||||
const authTag = raw.subarray(12, 28);
|
||||
const encrypted = raw.subarray(28);
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', KEY, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import { getSmtpConfigInternal } from '../modules/settings/service.js';
|
||||
|
||||
// Degradacion a modo dev cuando el SMTP no esta configurado (regla del orquestador): en vez de
|
||||
// fallar, se loguea el contenido/enlace y el flujo que llamo a esto sigue funcionando.
|
||||
export async function sendMail({ to, subject, html }) {
|
||||
const config = await getSmtpConfigInternal();
|
||||
|
||||
if (!config) {
|
||||
console.log(`[mail:dev] to=${to} subject="${subject}"\n${html}`);
|
||||
return { sent: false, dev: true };
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: false,
|
||||
requireTLS: true,
|
||||
auth: { user: config.user, pass: config.pass },
|
||||
});
|
||||
|
||||
// Nunca dejar que un fallo de envio (SMTP mal configurado, host caido) tumbe al que llama —
|
||||
// critico para forgot-password, que siempre debe responder generico sin importar el resultado.
|
||||
try {
|
||||
await transporter.sendMail({ from: config.from, to, subject, html });
|
||||
return { sent: true, dev: false };
|
||||
} catch (err) {
|
||||
console.error('[mail:error]', err.message);
|
||||
return { sent: false, dev: false, error: err.message };
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "project-linkedcorp-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -6,6 +6,10 @@ import { ToastHost } from '../components/ToastHost.jsx';
|
||||
import { ProtectedRoute } from './ProtectedRoute.jsx';
|
||||
import AppShell from './AppShell.jsx';
|
||||
import LoginPage from '../pages/Login/LoginPage.jsx';
|
||||
import RegisterPage from '../pages/Login/RegisterPage.jsx';
|
||||
import VerifyEmailPage from '../pages/Login/VerifyEmailPage.jsx';
|
||||
import ForgotPasswordPage from '../pages/Login/ForgotPasswordPage.jsx';
|
||||
import ResetPasswordPage from '../pages/Login/ResetPasswordPage.jsx';
|
||||
import DashboardPage from '../pages/Dashboard/DashboardPage.jsx';
|
||||
import OrganizacionPage from '../pages/Organizacion/OrganizacionPage.jsx';
|
||||
import ProyectosPage from '../pages/Proyectos/ProyectosPage.jsx';
|
||||
@@ -19,6 +23,10 @@ export default function App() {
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/registro" element={<RegisterPage />} />
|
||||
<Route path="/verificar-correo" element={<VerifyEmailPage />} />
|
||||
<Route path="/olvide-clave" element={<ForgotPasswordPage />} />
|
||||
<Route path="/restablecer-clave" element={<ResetPasswordPage />} />
|
||||
<Route path="/" element={<ProtectedRoute><AppShell /></ProtectedRoute>}>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="proyectos" element={<ProyectosPage />} />
|
||||
|
||||
@@ -32,7 +32,13 @@ export function AuthProvider({ children }) {
|
||||
return result.user;
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({ user, ready, login, logout }), [user, ready, login, logout]);
|
||||
// Usado por RegisterPage cuando el registro devuelve sesion directa (primer usuario = superadmin).
|
||||
const setSession = useCallback((token, sessionUser) => {
|
||||
api.setToken(token);
|
||||
setUser(sessionUser);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({ user, ready, login, logout, setSession }), [user, ready, login, logout, setSession]);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,46 @@ export const messages = {
|
||||
login_password: 'Clave',
|
||||
login_submit: 'Entrar',
|
||||
login_error: 'Credenciales invalidas',
|
||||
login_forgot_password: '¿Olvidaste tu contraseña?',
|
||||
login_create_account: 'Crear cuenta',
|
||||
logout: 'Cerrar sesion',
|
||||
|
||||
register_title: 'Crear cuenta',
|
||||
register_name: 'Nombre completo',
|
||||
register_password_confirm: 'Confirmar clave',
|
||||
register_password_mismatch: 'Las claves no coinciden',
|
||||
register_password_short: 'La clave debe tener al menos 8 caracteres',
|
||||
register_submit: 'Registrarme',
|
||||
register_success: 'Cuenta creada. Revisa tu correo para confirmarla; luego un administrador debe aprobarla antes de que puedas entrar.',
|
||||
register_back_to_login: 'Volver a iniciar sesion',
|
||||
verify_email_title: 'Verificar correo',
|
||||
verify_email_success: 'Correo confirmado. Tu cuenta esta pendiente de aprobacion por un administrador.',
|
||||
verify_email_missing_token: 'Enlace invalido: falta el token de verificacion.',
|
||||
forgot_password_title: 'Recuperar clave',
|
||||
forgot_password_submit: 'Enviar instrucciones',
|
||||
forgot_password_sent: 'Si el correo existe, te enviamos instrucciones para recuperar tu clave.',
|
||||
reset_password_title: 'Elegir nueva clave',
|
||||
reset_password_new: 'Clave nueva',
|
||||
reset_password_submit: 'Guardar clave',
|
||||
reset_password_success: 'Clave actualizada. Redirigiendo a iniciar sesion...',
|
||||
|
||||
user_status_pending_verification: 'Pendiente de verificar correo',
|
||||
user_status_pending_approval: 'Pendiente de aprobacion',
|
||||
user_status_active: 'Activo',
|
||||
user_approve: 'Aprobar',
|
||||
user_reject: 'Rechazar',
|
||||
user_approve_title: 'Aprobar usuario',
|
||||
user_reject_confirm_msg: '¿Seguro que deseas rechazar esta solicitud de registro? Se eliminara permanentemente.',
|
||||
|
||||
config_smtp: 'Correo (SMTP)',
|
||||
config_smtp_superadmin_only: 'Solo el superadministrador puede configurar el correo del sistema.',
|
||||
smtp_host: 'Servidor (host)',
|
||||
smtp_port: 'Puerto',
|
||||
smtp_user: 'Usuario',
|
||||
smtp_from: 'Remitente',
|
||||
smtp_pass: 'Clave',
|
||||
smtp_pass_configured_hint: 'Configurada — dejar en blanco para no cambiarla',
|
||||
smtp_not_configured: 'Sin configurar: los correos se registran en el log del servidor (modo dev), no se envian de verdad.',
|
||||
common_new: 'Nuevo',
|
||||
common_edit: 'Editar',
|
||||
common_save: 'Guardar',
|
||||
@@ -145,7 +184,46 @@ export const messages = {
|
||||
login_password: 'Password',
|
||||
login_submit: 'Sign in',
|
||||
login_error: 'Invalid credentials',
|
||||
login_forgot_password: 'Forgot your password?',
|
||||
login_create_account: 'Create account',
|
||||
logout: 'Sign out',
|
||||
|
||||
register_title: 'Create account',
|
||||
register_name: 'Full name',
|
||||
register_password_confirm: 'Confirm password',
|
||||
register_password_mismatch: 'Passwords do not match',
|
||||
register_password_short: 'Password must be at least 8 characters',
|
||||
register_submit: 'Sign up',
|
||||
register_success: 'Account created. Check your email to confirm it; an administrator must approve it before you can sign in.',
|
||||
register_back_to_login: 'Back to sign in',
|
||||
verify_email_title: 'Verify email',
|
||||
verify_email_success: 'Email confirmed. Your account is pending administrator approval.',
|
||||
verify_email_missing_token: 'Invalid link: missing verification token.',
|
||||
forgot_password_title: 'Recover password',
|
||||
forgot_password_submit: 'Send instructions',
|
||||
forgot_password_sent: 'If the email exists, we sent instructions to recover your password.',
|
||||
reset_password_title: 'Choose a new password',
|
||||
reset_password_new: 'New password',
|
||||
reset_password_submit: 'Save password',
|
||||
reset_password_success: 'Password updated. Redirecting to sign in...',
|
||||
|
||||
user_status_pending_verification: 'Pending email verification',
|
||||
user_status_pending_approval: 'Pending approval',
|
||||
user_status_active: 'Active',
|
||||
user_approve: 'Approve',
|
||||
user_reject: 'Reject',
|
||||
user_approve_title: 'Approve user',
|
||||
user_reject_confirm_msg: 'Are you sure you want to reject this registration request? It will be permanently deleted.',
|
||||
|
||||
config_smtp: 'Email (SMTP)',
|
||||
config_smtp_superadmin_only: 'Only the superadmin can configure the system email.',
|
||||
smtp_host: 'Host',
|
||||
smtp_port: 'Port',
|
||||
smtp_user: 'User',
|
||||
smtp_from: 'From',
|
||||
smtp_pass: 'Password',
|
||||
smtp_pass_configured_hint: 'Configured — leave blank to keep it unchanged',
|
||||
smtp_not_configured: 'Not configured: emails are logged on the server (dev mode), not actually sent.',
|
||||
common_new: 'New',
|
||||
common_edit: 'Edit',
|
||||
common_save: 'Save',
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Palette, ListChecks, Flag, FolderKanban, Layers } from 'lucide-react';
|
||||
import { Palette, ListChecks, Flag, FolderKanban, Layers, Mail } from 'lucide-react';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
import { useAuth } from '../../context/auth-context.jsx';
|
||||
import { Expander } from '../../components/Expander.jsx';
|
||||
import { Field, FieldRow } from '../../components/Field.jsx';
|
||||
import { ComboBox } from '../../components/ComboBox.jsx';
|
||||
import { CatalogEditor } from './CatalogEditor.jsx';
|
||||
import SmtpSettingsPanel from './SmtpSettingsPanel.jsx';
|
||||
|
||||
export default function ConfiguracionPage() {
|
||||
const { t, theme, setTheme, lang, setLang, buttonMode, setButtonMode } = useUi();
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -84,6 +87,12 @@ export default function ConfiguracionPage() {
|
||||
fields={[{ key: 'name', label: t('common_name'), type: 'text' }]}
|
||||
/>
|
||||
</Expander>
|
||||
|
||||
{user?.roleCode === 'superadmin' && (
|
||||
<Expander icon={Mail} title={t('config_smtp')}>
|
||||
<SmtpSettingsPanel />
|
||||
</Expander>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Save } from 'lucide-react';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
||||
import { getSmtpConfig, updateSmtpConfig } from '../../services/settings.js';
|
||||
import { Field, FieldRow } from '../../components/Field.jsx';
|
||||
import { Button } from '../../components/Button.jsx';
|
||||
import { InfoBar } from '../../components/InfoBar.jsx';
|
||||
|
||||
export default function SmtpSettingsPanel() {
|
||||
const { t, pushToast } = useUi();
|
||||
const runAction = useAction();
|
||||
const { data: config, reload } = useAsync(getSmtpConfig, []);
|
||||
const [form, setForm] = useState({ host: '', port: 587, user: '', from: '', pass: '' });
|
||||
|
||||
useEffect(() => {
|
||||
if (config) setForm({ host: config.host, port: config.port, user: config.user, from: config.from, pass: '' });
|
||||
}, [config]);
|
||||
|
||||
async function save() {
|
||||
await runAction(async () => {
|
||||
await updateSmtpConfig(form);
|
||||
await reload();
|
||||
setForm((f) => ({ ...f, pass: '' }));
|
||||
pushToast({ tone: 'success', message: t('common_saved') });
|
||||
});
|
||||
}
|
||||
|
||||
if (!config) return <div className="text-muted">{t('common_loading')}</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!config.configured && <InfoBar tone="warning" dismissible={false}>{t('smtp_not_configured')}</InfoBar>}
|
||||
<FieldRow>
|
||||
<Field label={t('smtp_host')}>
|
||||
<input className="input" value={form.host} onChange={(e) => setForm((f) => ({ ...f, host: e.target.value }))} placeholder="192.168.222.10" />
|
||||
</Field>
|
||||
<Field label={t('smtp_port')}>
|
||||
<input className="input" type="number" value={form.port} onChange={(e) => setForm((f) => ({ ...f, port: Number(e.target.value) }))} />
|
||||
</Field>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<Field label={t('smtp_user')}>
|
||||
<input className="input" value={form.user} onChange={(e) => setForm((f) => ({ ...f, user: e.target.value }))} placeholder="project@linkedcorp.net" />
|
||||
</Field>
|
||||
<Field label={t('smtp_from')}>
|
||||
<input className="input" value={form.from} onChange={(e) => setForm((f) => ({ ...f, from: e.target.value }))} placeholder="project@linkedcorp.net" />
|
||||
</Field>
|
||||
</FieldRow>
|
||||
<Field label={t('smtp_pass')} hint={config.configured ? t('smtp_pass_configured_hint') : undefined}>
|
||||
<input className="input" type="password" value={form.pass} onChange={(e) => setForm((f) => ({ ...f, pass: e.target.value }))} />
|
||||
</Field>
|
||||
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Send } from 'lucide-react';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
import { forgotPassword } from '../../services/auth.js';
|
||||
import { Field } from '../../components/Field.jsx';
|
||||
import { Button } from '../../components/Button.jsx';
|
||||
import logoDark from '../../assets/logoLinkedCorpTxt.png';
|
||||
import logoLight from '../../assets/logoLinkedCorpTxtBlack.png';
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const { t, theme } = useUi();
|
||||
const [email, setEmail] = useState('');
|
||||
const [sent, setSent] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
await forgotPassword(email);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setSent(true); // siempre generico, exista o no el correo
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-shell">
|
||||
<form className="login-card" onSubmit={handleSubmit}>
|
||||
<div className="login-logo">
|
||||
<img src={theme === 'light' ? logoLight : logoDark} alt={t('appName')} className="login-logo-img" />
|
||||
</div>
|
||||
<div className="login-title">{t('forgot_password_title')}</div>
|
||||
{sent ? (
|
||||
<div className="login-info">{t('forgot_password_sent')}</div>
|
||||
) : (
|
||||
<>
|
||||
<Field label={t('login_email')}>
|
||||
<input className="input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required autoFocus />
|
||||
</Field>
|
||||
<Button variant="accent" icon={Send} type="submit" disabled={loading} style={{ width: '100%', justifyContent: 'center' }}>
|
||||
{t('forgot_password_submit')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<div className="login-links">
|
||||
<Link to="/login">{t('register_back_to_login')}</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { LogIn } from 'lucide-react';
|
||||
import { useAuth } from '../../context/auth-context.jsx';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
@@ -24,8 +24,8 @@ export default function LoginPage() {
|
||||
try {
|
||||
await login(email, password);
|
||||
navigate('/', { replace: true });
|
||||
} catch {
|
||||
setError(t('login_error'));
|
||||
} catch (err) {
|
||||
setError(err.message || t('login_error'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -48,6 +48,10 @@ export default function LoginPage() {
|
||||
<Button variant="accent" icon={LogIn} type="submit" disabled={loading} style={{ width: '100%', justifyContent: 'center' }}>
|
||||
{t('login_submit')}
|
||||
</Button>
|
||||
<div className="login-links">
|
||||
<Link to="/olvide-clave">{t('login_forgot_password')}</Link>
|
||||
<Link to="/registro">{t('login_create_account')}</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { UserPlus } from 'lucide-react';
|
||||
import { useAuth } from '../../context/auth-context.jsx';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
import { register } from '../../services/auth.js';
|
||||
import { Field } from '../../components/Field.jsx';
|
||||
import { Button } from '../../components/Button.jsx';
|
||||
import logoDark from '../../assets/logoLinkedCorpTxt.png';
|
||||
import logoLight from '../../assets/logoLinkedCorpTxtBlack.png';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const { setSession } = useAuth();
|
||||
const { t, theme } = useUi();
|
||||
const navigate = useNavigate();
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [info, setInfo] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setInfo('');
|
||||
if (password !== confirm) { setError(t('register_password_mismatch')); return; }
|
||||
if (password.length < 8) { setError(t('register_password_short')); return; }
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await register({ name, email, password });
|
||||
if (result.bootstrap) {
|
||||
setSession(result.token, result.user);
|
||||
navigate('/', { replace: true });
|
||||
return;
|
||||
}
|
||||
setInfo(t('register_success'));
|
||||
} catch (err) {
|
||||
setError(err.message || t('common_error'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-shell">
|
||||
<form className="login-card" onSubmit={handleSubmit}>
|
||||
<div className="login-logo">
|
||||
<img src={theme === 'light' ? logoLight : logoDark} alt={t('appName')} className="login-logo-img" />
|
||||
</div>
|
||||
<div className="login-title">{t('register_title')}</div>
|
||||
{error && <div className="login-error">{error}</div>}
|
||||
{info && <div className="login-info">{info}</div>}
|
||||
{!info && (
|
||||
<>
|
||||
<Field label={t('register_name')}>
|
||||
<input className="input" value={name} onChange={(e) => setName(e.target.value)} required autoFocus />
|
||||
</Field>
|
||||
<Field label={t('login_email')}>
|
||||
<input className="input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||
</Field>
|
||||
<Field label={t('login_password')}>
|
||||
<input className="input" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
|
||||
</Field>
|
||||
<Field label={t('register_password_confirm')}>
|
||||
<input className="input" type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} required />
|
||||
</Field>
|
||||
<Button variant="accent" icon={UserPlus} type="submit" disabled={loading} style={{ width: '100%', justifyContent: 'center' }}>
|
||||
{t('register_submit')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<div className="login-links">
|
||||
<Link to="/login">{t('register_back_to_login')}</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { KeyRound } from 'lucide-react';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
import { resetPassword } from '../../services/auth.js';
|
||||
import { Field } from '../../components/Field.jsx';
|
||||
import { Button } from '../../components/Button.jsx';
|
||||
import logoDark from '../../assets/logoLinkedCorpTxt.png';
|
||||
import logoLight from '../../assets/logoLinkedCorpTxtBlack.png';
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const { t, theme } = useUi();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const token = searchParams.get('token') || '';
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
if (password !== confirm) { setError(t('register_password_mismatch')); return; }
|
||||
if (password.length < 8) { setError(t('register_password_short')); return; }
|
||||
setLoading(true);
|
||||
try {
|
||||
await resetPassword(token, password);
|
||||
setSuccess(true);
|
||||
setTimeout(() => navigate('/login', { replace: true }), 2000);
|
||||
} catch (err) {
|
||||
setError(err.message || t('common_error'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-shell">
|
||||
<form className="login-card" onSubmit={handleSubmit}>
|
||||
<div className="login-logo">
|
||||
<img src={theme === 'light' ? logoLight : logoDark} alt={t('appName')} className="login-logo-img" />
|
||||
</div>
|
||||
<div className="login-title">{t('reset_password_title')}</div>
|
||||
{error && <div className="login-error">{error}</div>}
|
||||
{success ? (
|
||||
<div className="login-info">{t('reset_password_success')}</div>
|
||||
) : (
|
||||
<>
|
||||
<Field label={t('reset_password_new')}>
|
||||
<input className="input" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required autoFocus />
|
||||
</Field>
|
||||
<Field label={t('register_password_confirm')}>
|
||||
<input className="input" type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} required />
|
||||
</Field>
|
||||
<Button variant="accent" icon={KeyRound} type="submit" disabled={loading || !token} style={{ width: '100%', justifyContent: 'center' }}>
|
||||
{t('reset_password_submit')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<div className="login-links">
|
||||
<Link to="/login">{t('register_back_to_login')}</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
import { verifyEmail } from '../../services/auth.js';
|
||||
import logoDark from '../../assets/logoLinkedCorpTxt.png';
|
||||
import logoLight from '../../assets/logoLinkedCorpTxtBlack.png';
|
||||
|
||||
export default function VerifyEmailPage() {
|
||||
const { t, theme } = useUi();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [state, setState] = useState('loading');
|
||||
const [message, setMessage] = useState('');
|
||||
// El token de verificacion se consume en el servidor (un solo uso): con React.StrictMode el
|
||||
// efecto se dispara dos veces en dev, y la segunda llamada fallaria con "token invalido" aunque
|
||||
// la primera ya haya verificado correctamente. Esta ref evita el segundo disparo.
|
||||
const requestedTokenRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const token = searchParams.get('token');
|
||||
if (!token) { setState('error'); setMessage(t('verify_email_missing_token')); return; }
|
||||
if (requestedTokenRef.current === token) return;
|
||||
requestedTokenRef.current = token;
|
||||
verifyEmail(token)
|
||||
.then(() => { setState('success'); setMessage(t('verify_email_success')); })
|
||||
.catch((err) => { setState('error'); setMessage(err.message || t('common_error')); });
|
||||
}, [searchParams, t]);
|
||||
|
||||
return (
|
||||
<div className="login-shell">
|
||||
<div className="login-card">
|
||||
<div className="login-logo">
|
||||
<img src={theme === 'light' ? logoLight : logoDark} alt={t('appName')} className="login-logo-img" />
|
||||
</div>
|
||||
<div className="login-title">{t('verify_email_title')}</div>
|
||||
{state === 'loading' && <div className="text-muted">{t('common_loading')}</div>}
|
||||
{state === 'success' && <div className="login-info">{message}</div>}
|
||||
{state === 'error' && <div className="login-error">{message}</div>}
|
||||
<div className="login-links">
|
||||
<Link to="/login">{t('register_back_to_login')}</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Save, UserX, UserCheck } from 'lucide-react';
|
||||
import { Plus, Save, UserX, UserCheck, Check, X as XIcon } from 'lucide-react';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
import { useAuth } from '../../context/auth-context.jsx';
|
||||
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
||||
import { listUsers, listRoles, createUser, updateUser, setUserActive } from '../../services/users.js';
|
||||
import { listUsers, listRoles, createUser, updateUser, setUserActive, approveUser, rejectUser } from '../../services/users.js';
|
||||
import { listDepartments } from '../../services/departments.js';
|
||||
import { ListView } from '../../components/ListView.jsx';
|
||||
import { SearchInput } from '../../components/SearchInput.jsx';
|
||||
@@ -17,19 +18,25 @@ function flatten(nodes, acc = []) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
const STATUS_COLOR = { pending_verification: 'muted', pending_approval: 'warning', active: 'success' };
|
||||
const EMPTY_FORM = { name: '', email: '', roleId: '', departmentId: '', position: '', password: '' };
|
||||
|
||||
export default function UsersPanel() {
|
||||
const { t, pushToast } = useUi();
|
||||
const { t, pushToast, openDialog } = useUi();
|
||||
const { user: currentUser } = useAuth();
|
||||
const runAction = useAction();
|
||||
const { data: users, reload } = useAsync(listUsers, []);
|
||||
const { data: roles } = useAsync(listRoles, []);
|
||||
const { data: deptTree } = useAsync(listDepartments, []);
|
||||
|
||||
const canManage = ['admin', 'superadmin'].includes(currentUser?.roleCode);
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const [approving, setApproving] = useState(null);
|
||||
const [approveForm, setApproveForm] = useState({ roleId: '', departmentId: '' });
|
||||
|
||||
const deptOptions = deptTree ? flatten(deptTree).map((d) => ({ value: d.id, label: d.name })) : [];
|
||||
const roleOptions = (roles || []).map((r) => ({ value: r.id, label: r.name }));
|
||||
@@ -41,6 +48,7 @@ export default function UsersPanel() {
|
||||
}
|
||||
|
||||
function startEdit(user) {
|
||||
if (!canManage) return;
|
||||
setSelected(user);
|
||||
setForm({ name: user.name, email: user.email, roleId: user.role_id, departmentId: user.department_id || '', position: user.position || '', password: '' });
|
||||
setEditing(true);
|
||||
@@ -68,13 +76,40 @@ export default function UsersPanel() {
|
||||
});
|
||||
}
|
||||
|
||||
function startApprove(user) {
|
||||
const collaboratorRole = (roles || []).find((r) => r.code === 'collaborator');
|
||||
setApproving(user);
|
||||
setApproveForm({ roleId: user.role_id || collaboratorRole?.id || '', departmentId: '' });
|
||||
}
|
||||
|
||||
async function confirmApprove() {
|
||||
await runAction(async () => {
|
||||
await approveUser(approving.id, { roleId: approveForm.roleId, departmentId: approveForm.departmentId || null });
|
||||
setApproving(null);
|
||||
await reload();
|
||||
pushToast({ tone: 'success', message: t('common_saved') });
|
||||
});
|
||||
}
|
||||
|
||||
function confirmReject(user) {
|
||||
openDialog({
|
||||
tone: 'warning',
|
||||
title: t('user_reject'),
|
||||
message: t('user_reject_confirm_msg'),
|
||||
actions: [
|
||||
{ label: t('common_cancel'), variant: 'standard' },
|
||||
{ label: t('user_reject'), variant: 'danger', onClick: () => runAction(async () => { await rejectUser(user.id); await reload(); }) },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', label: t('common_name') },
|
||||
{ key: 'email', label: t('user_email') },
|
||||
{ key: 'role_name', label: t('user_role') },
|
||||
{ key: 'department_name', label: t('common_department'), render: (r) => r.department_name || '—' },
|
||||
{ key: 'is_active', label: t('common_status'), sortable: false, render: (r) => (
|
||||
<Badge color={r.is_active ? 'success' : 'muted'}>{r.is_active ? t('common_active') : t('common_inactive')}</Badge>
|
||||
{ key: 'status', label: t('common_status'), sortable: false, render: (r) => (
|
||||
<Badge color={STATUS_COLOR[r.status] || 'muted'}>{t(`user_status_${r.status}`)}</Badge>
|
||||
) },
|
||||
];
|
||||
|
||||
@@ -86,7 +121,7 @@ export default function UsersPanel() {
|
||||
<div className="toolbar-left">
|
||||
<SearchInput value={search} onChange={setSearch} />
|
||||
</div>
|
||||
<Button variant="accent" icon={Plus} onClick={startNew}>{t('user_new')}</Button>
|
||||
{canManage && <Button variant="accent" icon={Plus} onClick={startNew}>{t('user_new')}</Button>}
|
||||
</div>
|
||||
|
||||
<ListView
|
||||
@@ -95,13 +130,20 @@ export default function UsersPanel() {
|
||||
searchValue={search}
|
||||
getSearchHaystack={(u) => `${u.name} ${u.email} ${u.role_name} ${u.department_name || ''}`}
|
||||
getRowKey={(u) => u.id}
|
||||
onRowClick={startEdit}
|
||||
rowActions={(u) => [
|
||||
onRowClick={canManage ? startEdit : undefined}
|
||||
rowActions={canManage ? (u) => (
|
||||
u.status === 'pending_approval'
|
||||
? [
|
||||
{ label: t('user_approve'), icon: Check, onClick: () => startApprove(u) },
|
||||
{ label: t('user_reject'), icon: XIcon, danger: true, onClick: () => confirmReject(u) },
|
||||
]
|
||||
: [
|
||||
{ label: t('common_edit'), onClick: () => startEdit(u) },
|
||||
u.is_active
|
||||
? { label: t('common_inactive'), icon: UserX, danger: true, onClick: () => toggleActive(u) }
|
||||
: { label: t('common_active'), icon: UserCheck, onClick: () => toggleActive(u) },
|
||||
]}
|
||||
]
|
||||
) : undefined}
|
||||
/>
|
||||
|
||||
<DetailPopup open={editing} title={selected ? t('common_edit') : t('user_new')} onClose={() => setEditing(false)} width={560} height={440}>
|
||||
@@ -136,6 +178,21 @@ export default function UsersPanel() {
|
||||
<Button variant="standard" onClick={() => setEditing(false)}>{t('common_cancel')}</Button>
|
||||
</div>
|
||||
</DetailPopup>
|
||||
|
||||
<DetailPopup open={!!approving} title={t('user_approve_title')} onClose={() => setApproving(null)} width={420} height={300}>
|
||||
<FieldRow>
|
||||
<Field label={t('user_role')}>
|
||||
<ComboBox options={roleOptions} value={approveForm.roleId} onChange={(v) => setApproveForm((f) => ({ ...f, roleId: v }))} />
|
||||
</Field>
|
||||
<Field label={t('common_department')}>
|
||||
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={approveForm.departmentId} onChange={(v) => setApproveForm((f) => ({ ...f, departmentId: v }))} />
|
||||
</Field>
|
||||
</FieldRow>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button variant="accent" icon={Check} onClick={confirmApprove}>{t('user_approve')}</Button>
|
||||
<Button variant="standard" onClick={() => setApproving(null)}>{t('common_cancel')}</Button>
|
||||
</div>
|
||||
</DetailPopup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { api } from './api.js';
|
||||
|
||||
export const register = (data) => api.post('/auth/register', data);
|
||||
export const verifyEmail = (token) => api.get(`/auth/verify-email?token=${encodeURIComponent(token)}`);
|
||||
export const forgotPassword = (email) => api.post('/auth/forgot-password', { email });
|
||||
export const resetPassword = (token, password) => api.post('/auth/reset-password', { token, password });
|
||||
@@ -0,0 +1,4 @@
|
||||
import { api } from './api.js';
|
||||
|
||||
export const getSmtpConfig = () => api.get('/settings/smtp');
|
||||
export const updateSmtpConfig = (data) => api.put('/settings/smtp', data);
|
||||
@@ -5,3 +5,5 @@ export const listRoles = () => api.get('/users/roles');
|
||||
export const createUser = (data) => api.post('/users', data);
|
||||
export const updateUser = (id, data) => api.put(`/users/${id}`, data);
|
||||
export const setUserActive = (id, isActive) => api.patch(`/users/${id}/active`, { isActive });
|
||||
export const approveUser = (id, data) => api.post(`/users/${id}/approve`, data);
|
||||
export const rejectUser = (id) => api.post(`/users/${id}/reject`);
|
||||
|
||||
@@ -257,6 +257,10 @@ table.list-table { width: 100%; border-collapse: collapse; font-size: .8rem; }
|
||||
.login-logo-img { height: 80px; width: auto; }
|
||||
.login-title { font-size: 1.1rem; font-weight: 700; margin-bottom: 16px; }
|
||||
.login-error { color: rgb(var(--danger)); font-size: .78rem; margin-bottom: 10px; }
|
||||
.login-info { color: rgb(var(--success)); font-size: .82rem; margin-bottom: 10px; line-height: 1.5; }
|
||||
.login-links { display: flex; justify-content: space-between; gap: 10px; margin-top: 14px; font-size: .76rem; flex-wrap: wrap; }
|
||||
.login-links a { color: rgb(var(--muted)); text-decoration: none; }
|
||||
.login-links a:hover { color: rgb(var(--accent)); text-decoration: underline; }
|
||||
|
||||
/* --- DetailPopup (ver DetailPopup.jsx) ---
|
||||
Al elegir un item de un listado, la lista queda inactiva detras del overlay y el popup
|
||||
|
||||
Reference in New Issue
Block a user