From 7557dffc52ff1f7c1b20ef00dd2529bcf8146d93 Mon Sep 17 00:00:00 2001 From: Milton Gonzalez Date: Sat, 5 Sep 2026 23:28:46 -0500 Subject: [PATCH] feat: autorregistro con verificacion de correo + aprobacion, superadmin y SMTP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- AGENTS.md | 21 ++- API.md | 24 ++- BASE_DE_DATOS.md | 42 +++--- CHANGELOG.md | 17 +++ CONFIGURACION.md | 7 + ESTADO.md | 48 +++++- SPEC.md | 17 ++- VERSION | 2 +- backend/.env.example | 7 + .../migrations/002_registration_and_smtp.sql | 29 ++++ backend/package-lock.json | 16 +- backend/package.json | 5 +- backend/src/app.js | 7 +- backend/src/config/env.js | 6 +- backend/src/middleware/auth.js | 8 +- backend/src/modules/activities/service.js | 2 +- backend/src/modules/attachments/controller.js | 2 +- backend/src/modules/audit/routes.js | 2 +- backend/src/modules/auth/controller.js | 23 +++ backend/src/modules/auth/routes.js | 9 +- backend/src/modules/auth/service.js | 140 +++++++++++++++++- backend/src/modules/projects/service.js | 4 +- backend/src/modules/settings/controller.js | 8 + backend/src/modules/settings/repository.js | 18 +++ backend/src/modules/settings/routes.js | 11 ++ backend/src/modules/settings/service.js | 44 ++++++ backend/src/modules/users/controller.js | 10 ++ backend/src/modules/users/repository.js | 17 ++- backend/src/modules/users/routes.js | 4 +- backend/src/modules/users/service.js | 17 +++ backend/src/scripts/seed-db.js | 1 + backend/src/utils/crypto.js | 31 ++++ backend/src/utils/mailer.js | 31 ++++ frontend/package.json | 2 +- frontend/src/app/App.jsx | 8 + frontend/src/context/auth-context.jsx | 8 +- frontend/src/i18n/messages.js | 78 ++++++++++ .../pages/Configuracion/ConfiguracionPage.jsx | 11 +- .../pages/Configuracion/SmtpSettingsPanel.jsx | 56 +++++++ .../src/pages/Login/ForgotPasswordPage.jsx | 53 +++++++ frontend/src/pages/Login/LoginPage.jsx | 10 +- frontend/src/pages/Login/RegisterPage.jsx | 80 ++++++++++ .../src/pages/Login/ResetPasswordPage.jsx | 68 +++++++++ frontend/src/pages/Login/VerifyEmailPage.jsx | 44 ++++++ .../src/pages/Organizacion/UsersPanel.jsx | 83 +++++++++-- frontend/src/services/auth.js | 6 + frontend/src/services/settings.js | 4 + frontend/src/services/users.js | 2 + frontend/src/styles/app.css | 4 + 49 files changed, 1063 insertions(+), 84 deletions(-) create mode 100644 backend/database/migrations/002_registration_and_smtp.sql create mode 100644 backend/src/modules/settings/controller.js create mode 100644 backend/src/modules/settings/repository.js create mode 100644 backend/src/modules/settings/routes.js create mode 100644 backend/src/modules/settings/service.js create mode 100644 backend/src/utils/crypto.js create mode 100644 backend/src/utils/mailer.js create mode 100644 frontend/src/pages/Configuracion/SmtpSettingsPanel.jsx create mode 100644 frontend/src/pages/Login/ForgotPasswordPage.jsx create mode 100644 frontend/src/pages/Login/RegisterPage.jsx create mode 100644 frontend/src/pages/Login/ResetPasswordPage.jsx create mode 100644 frontend/src/pages/Login/VerifyEmailPage.jsx create mode 100644 frontend/src/services/auth.js create mode 100644 frontend/src/services/settings.js diff --git a/AGENTS.md b/AGENTS.md index 56a22a2..83fba19 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/API.md b/API.md index fb544e8..058724c 100644 --- a/API.md +++ b/API.md @@ -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 diff --git a/BASE_DE_DATOS.md b/BASE_DE_DATOS.md index d706492..8d20106 100644 --- a/BASE_DE_DATOS.md +++ b/BASE_DE_DATOS.md @@ -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 ``` --- diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b34939..6aa74ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/CONFIGURACION.md b/CONFIGURACION.md index 879f066..f798da7 100644 --- a/CONFIGURACION.md +++ b/CONFIGURACION.md @@ -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 diff --git a/ESTADO.md b/ESTADO.md index c7023b7..be419ab 100644 --- a/ESTADO.md +++ b/ESTADO.md @@ -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. diff --git a/SPEC.md b/SPEC.md index 64b45fb..ee3b435 100644 --- a/SPEC.md +++ b/SPEC.md @@ -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 diff --git a/VERSION b/VERSION index 3eefcb9..9084fa2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.0 +1.1.0 diff --git a/backend/.env.example b/backend/.env.example index 967050d..0074d7b 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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. diff --git a/backend/database/migrations/002_registration_and_smtp.sql b/backend/database/migrations/002_registration_and_smtp.sql new file mode 100644 index 0000000..bc0b856 --- /dev/null +++ b/backend/database/migrations/002_registration_and_smtp.sql @@ -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; diff --git a/backend/package-lock.json b/backend/package-lock.json index 0229b05..74d2f17 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index 47364c3..56dfa08 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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" } } diff --git a/backend/src/app.js b/backend/src/app.js index 7671621..1602286 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -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); diff --git a/backend/src/config/env.js b/backend/src/config/env.js index 3ab336d..1f8fe24 100644 --- a/backend/src/config/env.js +++ b/backend/src/config/env.js @@ -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', diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index a664dba..899a59f 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -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]; diff --git a/backend/src/modules/activities/service.js b/backend/src/modules/activities/service.js index 2ec49d8..96a1124 100644 --- a/backend/src/modules/activities/service.js +++ b/backend/src/modules/activities/service.js @@ -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; diff --git a/backend/src/modules/attachments/controller.js b/backend/src/modules/attachments/controller.js index 41c5e09..d2581f9 100644 --- a/backend/src/modules/attachments/controller.js +++ b/backend/src/modules/attachments/controller.js @@ -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); diff --git a/backend/src/modules/audit/routes.js b/backend/src/modules/audit/routes.js index 754eda6..46d5ead 100644 --- a/backend/src/modules/audit/routes.js +++ b/backend/src/modules/audit/routes.js @@ -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; diff --git a/backend/src/modules/auth/controller.js b/backend/src/modules/auth/controller.js index aa485ca..aa8715a 100644 --- a/backend/src/modules/auth/controller.js +++ b/backend/src/modules/auth/controller.js @@ -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 }); +}); diff --git a/backend/src/modules/auth/routes.js b/backend/src/modules/auth/routes.js index 161de59..e50f65f 100644 --- a/backend/src/modules/auth/routes.js +++ b/backend/src/modules/auth/routes.js @@ -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); diff --git a/backend/src/modules/auth/service.js b/backend/src/modules/auth/service.js index 20d547f..e3dcc1a 100644 --- a/backend/src/modules/auth/service.js +++ b/backend/src/modules/auth/service.js @@ -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: `

Hola ${name},

Confirma tu correo para continuar tu registro:

${verifyUrl}

Este enlace vence en 24 horas.

`, + }); + + 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: `

Hola ${user.name},

Para elegir una nueva clave entra a este enlace (vence en 1 hora):

${resetUrl}

Si no pediste esto, ignora el correo.

`, + }); +} + +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 }); +} diff --git a/backend/src/modules/projects/service.js b/backend/src/modules/projects/service.js index 77c9478..7cd2c80 100644 --- a/backend/src/modules/projects/service.js +++ b/backend/src/modules/projects/service.js @@ -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({ diff --git a/backend/src/modules/settings/controller.js b/backend/src/modules/settings/controller.js new file mode 100644 index 0000000..6d12853 --- /dev/null +++ b/backend/src/modules/settings/controller.js @@ -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)); +}); diff --git a/backend/src/modules/settings/repository.js b/backend/src/modules/settings/repository.js new file mode 100644 index 0000000..be9b983 --- /dev/null +++ b/backend/src/modules/settings/repository.js @@ -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] + ); + } +} diff --git a/backend/src/modules/settings/routes.js b/backend/src/modules/settings/routes.js new file mode 100644 index 0000000..c119060 --- /dev/null +++ b/backend/src/modules/settings/routes.js @@ -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; diff --git a/backend/src/modules/settings/service.js b/backend/src/modules/settings/service.js new file mode 100644 index 0000000..87d1656 --- /dev/null +++ b/backend/src/modules/settings/service.js @@ -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(); +} diff --git a/backend/src/modules/users/controller.js b/backend/src/modules/users/controller.js index 50332b0..0b4a9a9 100644 --- a/backend/src/modules/users/controller.js +++ b/backend/src/modules/users/controller.js @@ -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 }); +}); diff --git a/backend/src/modules/users/repository.js b/backend/src/modules/users/repository.js index 29cdfbf..18e8b5f 100644 --- a/backend/src/modules/users/repository.js +++ b/backend/src/modules/users/repository.js @@ -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; diff --git a/backend/src/modules/users/routes.js b/backend/src/modules/users/routes.js index 300e3d1..39b419e 100644 --- a/backend/src/modules/users/routes.js +++ b/backend/src/modules/users/routes.js @@ -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; diff --git a/backend/src/modules/users/service.js b/backend/src/modules/users/service.js index 2354130..f5ec00c 100644 --- a/backend/src/modules/users/service.js +++ b/backend/src/modules/users/service.js @@ -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); +} diff --git a/backend/src/scripts/seed-db.js b/backend/src/scripts/seed-db.js index 566f33a..5cabd1a 100644 --- a/backend/src/scripts/seed-db.js +++ b/backend/src/scripts/seed-db.js @@ -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'], diff --git a/backend/src/utils/crypto.js b/backend/src/utils/crypto.js new file mode 100644 index 0000000..6ee129f --- /dev/null +++ b/backend/src/utils/crypto.js @@ -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'); +} diff --git a/backend/src/utils/mailer.js b/backend/src/utils/mailer.js new file mode 100644 index 0000000..de1479f --- /dev/null +++ b/backend/src/utils/mailer.js @@ -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 }; + } +} diff --git a/frontend/package.json b/frontend/package.json index ca03bfb..bee2b6a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "1.0.0", + "version": "1.1.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/app/App.jsx b/frontend/src/app/App.jsx index 7db995f..b22c361 100644 --- a/frontend/src/app/App.jsx +++ b/frontend/src/app/App.jsx @@ -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() { } /> + } /> + } /> + } /> + } /> }> } /> } /> diff --git a/frontend/src/context/auth-context.jsx b/frontend/src/context/auth-context.jsx index 8007ca8..47561f3 100644 --- a/frontend/src/context/auth-context.jsx +++ b/frontend/src/context/auth-context.jsx @@ -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 {children}; } diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index b094e27..a25f0a3 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -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', diff --git a/frontend/src/pages/Configuracion/ConfiguracionPage.jsx b/frontend/src/pages/Configuracion/ConfiguracionPage.jsx index caca1ae..8926fd9 100644 --- a/frontend/src/pages/Configuracion/ConfiguracionPage.jsx +++ b/frontend/src/pages/Configuracion/ConfiguracionPage.jsx @@ -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 (
@@ -84,6 +87,12 @@ export default function ConfiguracionPage() { fields={[{ key: 'name', label: t('common_name'), type: 'text' }]} /> + + {user?.roleCode === 'superadmin' && ( + + + + )}
); } diff --git a/frontend/src/pages/Configuracion/SmtpSettingsPanel.jsx b/frontend/src/pages/Configuracion/SmtpSettingsPanel.jsx new file mode 100644 index 0000000..b86b524 --- /dev/null +++ b/frontend/src/pages/Configuracion/SmtpSettingsPanel.jsx @@ -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
{t('common_loading')}
; + + return ( +
+ {!config.configured && {t('smtp_not_configured')}} + + + setForm((f) => ({ ...f, host: e.target.value }))} placeholder="192.168.222.10" /> + + + setForm((f) => ({ ...f, port: Number(e.target.value) }))} /> + + + + + setForm((f) => ({ ...f, user: e.target.value }))} placeholder="project@linkedcorp.net" /> + + + setForm((f) => ({ ...f, from: e.target.value }))} placeholder="project@linkedcorp.net" /> + + + + setForm((f) => ({ ...f, pass: e.target.value }))} /> + + +
+ ); +} diff --git a/frontend/src/pages/Login/ForgotPasswordPage.jsx b/frontend/src/pages/Login/ForgotPasswordPage.jsx new file mode 100644 index 0000000..f005457 --- /dev/null +++ b/frontend/src/pages/Login/ForgotPasswordPage.jsx @@ -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 ( +
+
+
+ {t('appName')} +
+
{t('forgot_password_title')}
+ {sent ? ( +
{t('forgot_password_sent')}
+ ) : ( + <> + + setEmail(e.target.value)} required autoFocus /> + + + + )} +
+ {t('register_back_to_login')} +
+
+
+ ); +} diff --git a/frontend/src/pages/Login/LoginPage.jsx b/frontend/src/pages/Login/LoginPage.jsx index d90cad7..64c7a41 100644 --- a/frontend/src/pages/Login/LoginPage.jsx +++ b/frontend/src/pages/Login/LoginPage.jsx @@ -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() { +
+ {t('login_forgot_password')} + {t('login_create_account')} +
); diff --git a/frontend/src/pages/Login/RegisterPage.jsx b/frontend/src/pages/Login/RegisterPage.jsx new file mode 100644 index 0000000..e821366 --- /dev/null +++ b/frontend/src/pages/Login/RegisterPage.jsx @@ -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 ( +
+
+
+ {t('appName')} +
+
{t('register_title')}
+ {error &&
{error}
} + {info &&
{info}
} + {!info && ( + <> + + setName(e.target.value)} required autoFocus /> + + + setEmail(e.target.value)} required /> + + + setPassword(e.target.value)} required /> + + + setConfirm(e.target.value)} required /> + + + + )} +
+ {t('register_back_to_login')} +
+
+
+ ); +} diff --git a/frontend/src/pages/Login/ResetPasswordPage.jsx b/frontend/src/pages/Login/ResetPasswordPage.jsx new file mode 100644 index 0000000..fdcf28c --- /dev/null +++ b/frontend/src/pages/Login/ResetPasswordPage.jsx @@ -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 ( +
+
+
+ {t('appName')} +
+
{t('reset_password_title')}
+ {error &&
{error}
} + {success ? ( +
{t('reset_password_success')}
+ ) : ( + <> + + setPassword(e.target.value)} required autoFocus /> + + + setConfirm(e.target.value)} required /> + + + + )} +
+ {t('register_back_to_login')} +
+
+
+ ); +} diff --git a/frontend/src/pages/Login/VerifyEmailPage.jsx b/frontend/src/pages/Login/VerifyEmailPage.jsx new file mode 100644 index 0000000..a7ad594 --- /dev/null +++ b/frontend/src/pages/Login/VerifyEmailPage.jsx @@ -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 ( +
+
+
+ {t('appName')} +
+
{t('verify_email_title')}
+ {state === 'loading' &&
{t('common_loading')}
} + {state === 'success' &&
{message}
} + {state === 'error' &&
{message}
} +
+ {t('register_back_to_login')} +
+
+
+ ); +} diff --git a/frontend/src/pages/Organizacion/UsersPanel.jsx b/frontend/src/pages/Organizacion/UsersPanel.jsx index e656771..43a305e 100644 --- a/frontend/src/pages/Organizacion/UsersPanel.jsx +++ b/frontend/src/pages/Organizacion/UsersPanel.jsx @@ -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) => ( - {r.is_active ? t('common_active') : t('common_inactive')} + { key: 'status', label: t('common_status'), sortable: false, render: (r) => ( + {t(`user_status_${r.status}`)} ) }, ]; @@ -86,7 +121,7 @@ export default function UsersPanel() {
- + {canManage && } `${u.name} ${u.email} ${u.role_name} ${u.department_name || ''}`} getRowKey={(u) => u.id} - onRowClick={startEdit} - rowActions={(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) }, - ]} + 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} /> setEditing(false)} width={560} height={440}> @@ -136,6 +178,21 @@ export default function UsersPanel() { + + setApproving(null)} width={420} height={300}> + + + setApproveForm((f) => ({ ...f, roleId: v }))} /> + + + setApproveForm((f) => ({ ...f, departmentId: v }))} /> + + +
+ + +
+
); } diff --git a/frontend/src/services/auth.js b/frontend/src/services/auth.js new file mode 100644 index 0000000..19f1afc --- /dev/null +++ b/frontend/src/services/auth.js @@ -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 }); diff --git a/frontend/src/services/settings.js b/frontend/src/services/settings.js new file mode 100644 index 0000000..7488285 --- /dev/null +++ b/frontend/src/services/settings.js @@ -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); diff --git a/frontend/src/services/users.js b/frontend/src/services/users.js index d9a6371..b0bb7d9 100644 --- a/frontend/src/services/users.js +++ b/frontend/src/services/users.js @@ -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`); diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index c344961..4629df7 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -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