From 47de1efe863ee135d8933e9c4a4627df3a17eba6 Mon Sep 17 00:00:00 2001 From: Lucas Tettamanti Date: Sat, 2 May 2026 19:02:37 -0300 Subject: [PATCH] Login + ABM de operadores + audit log con UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - 3 migrations: system_users (citext email único, password_hash, active), system_sessions (UUID + expires_at + revoked_at), ALTER audit_log con actor_user_id/actor_email/actor_ip/action_path/summary y entity_id NULL. - src/modules/auth/: usersRepo, sessionsRepo, passwords (bcrypt cost 10), auth (login/logout), bootstrap (crea admin desde ADMIN_EMAIL/PASSWORD si la tabla está vacía). 4 tests passwords (hash distinto cada vez, verify rechaza, longitud mínima 8). - middleware/requireAuth: lee cookie bot_session, busca sesión activa, popula req.user. Whitelist: /styles, /components, /lib, /login, /, /home y SPA paths (HTML carga sin auth, el JS gatea con /api/auth/me). - middleware/auditWriter: registra cada POST/PUT/DELETE 2xx en audit_log con req.user, IP, body redactado (passwords/tokens/secrets). Handlers pueden enriquecer summary via res.locals.audit. - routes: /api/auth/{login,logout,me} (cookie httpOnly + DB session), /api/system-users (ABM con guards: cant_delete_self, cant_deactivate_self, email único, password ≥ 8), /api/audit-log + /api/audit-log/actors. - src/app.js: orden estricto — webhooks (sin auth) → auth routes (sin auth) → /login HTML → static → SPA HTML → requireAuth + auditWriter → API admin. Bootstrap del primer admin se ejecuta en index.js antes de listen. Usa ADMIN_EMAIL/ADMIN_PASSWORD/ADMIN_NAME del .env. Si no están seteados y la tabla está vacía, warn y exit (nadie puede loguearse). Frontend: - /login.html + /login.js: form simple, POST a /api/auth/login con credentials:include, redirect a ?next=... o /home. Si ya hay sesión activa, va directo a /home. - public/app.js gate: chequea /api/auth/me antes de initRouter; sin sesión redirige a /login?next=. window.__USER__ disponible para shell. - ops-shell: nav agrega "Operadores" + "Actividad". Header derecha muestra email del user + botón Salir (POST /api/auth/logout + redirect /login). - system-users-crud: CRUD lista/form (estilo settings). Crear/editar/ cambiar password/eliminar. UI muestra badge "Vos" + bloquea eliminarse ni desactivarse a uno mismo. - audit-log: tabla read-only con filtros (actor, entity_type, since, search), paginación 50, badges por acción, modal de detalles con changes JSON. /api/audit-log/actors pobla el dropdown de operadores. Smoke E2E: login OK + cookie set, /me 200; logout → /me 401; settings POST genera fila en audit_log con actor_email + action_path; ABM crea/borra operadores con guards; intentar borrarse devuelve 400 cant_delete_self. 161/161 tests verde (pre-existentes 157 + 4 passwords nuevos). Co-Authored-By: Claude Opus 4.7 (1M context) --- ...260502214940_system_users_and_sessions.sql | 48 ++++ .../20260502214941_audit_log_extend.sql | 24 ++ ...260502215456_audit_log_relax_entity_id.sql | 7 + env.example | 6 + index.js | 2 + package-lock.json | 55 ++++ package.json | 2 + public/app.js | 31 ++- public/components/audit-log.js | 202 ++++++++++++++ public/components/ops-shell.js | 35 +++ public/components/system-users-crud.js | 259 ++++++++++++++++++ public/lib/api.js | 50 +++- public/lib/router.js | 4 + public/login.html | 107 ++++++++ public/login.js | 58 ++++ src/app.js | 64 +++-- src/modules/auth/controllers/auditRoutes.js | 67 +++++ src/modules/auth/controllers/authRoutes.js | 113 ++++++++ src/modules/auth/controllers/usersRoutes.js | 123 +++++++++ src/modules/auth/db/sessionsRepo.js | 60 ++++ src/modules/auth/db/usersRepo.js | 62 +++++ src/modules/auth/middleware/auditWriter.js | 86 ++++++ src/modules/auth/middleware/requireAuth.js | 79 ++++++ src/modules/auth/services/auth.js | 42 +++ src/modules/auth/services/bootstrap.js | 29 ++ src/modules/auth/services/passwords.js | 19 ++ src/modules/auth/services/passwords.test.js | 26 ++ 27 files changed, 1628 insertions(+), 32 deletions(-) create mode 100644 db/migrations/20260502214940_system_users_and_sessions.sql create mode 100644 db/migrations/20260502214941_audit_log_extend.sql create mode 100644 db/migrations/20260502215456_audit_log_relax_entity_id.sql create mode 100644 public/components/audit-log.js create mode 100644 public/components/system-users-crud.js create mode 100644 public/login.html create mode 100644 public/login.js create mode 100644 src/modules/auth/controllers/auditRoutes.js create mode 100644 src/modules/auth/controllers/authRoutes.js create mode 100644 src/modules/auth/controllers/usersRoutes.js create mode 100644 src/modules/auth/db/sessionsRepo.js create mode 100644 src/modules/auth/db/usersRepo.js create mode 100644 src/modules/auth/middleware/auditWriter.js create mode 100644 src/modules/auth/middleware/requireAuth.js create mode 100644 src/modules/auth/services/auth.js create mode 100644 src/modules/auth/services/bootstrap.js create mode 100644 src/modules/auth/services/passwords.js create mode 100644 src/modules/auth/services/passwords.test.js diff --git a/db/migrations/20260502214940_system_users_and_sessions.sql b/db/migrations/20260502214940_system_users_and_sessions.sql new file mode 100644 index 0000000..923120c --- /dev/null +++ b/db/migrations/20260502214940_system_users_and_sessions.sql @@ -0,0 +1,48 @@ +-- migrate:up +-- Operadores del sistema (admin del bot) + sesiones server-side. +-- No tienen tenant_id porque el sistema es mono-tenant; si en algún momento se +-- vuelve multi-tenant, se agrega y listo. + +CREATE EXTENSION IF NOT EXISTS citext; + +CREATE TABLE system_users ( + id BIGSERIAL PRIMARY KEY, + email CITEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + password_hash TEXT NOT NULL, + active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_login_at TIMESTAMPTZ +); + +CREATE TABLE system_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id BIGINT NOT NULL REFERENCES system_users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + ip INET, + user_agent TEXT, + revoked_at TIMESTAMPTZ +); +CREATE INDEX system_sessions_user_id_idx ON system_sessions (user_id); +CREATE INDEX system_sessions_expires_at_idx ON system_sessions (expires_at); + +-- Trigger para mantener updated_at en system_users. +CREATE OR REPLACE FUNCTION system_users_touch_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at := NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER system_users_updated_trigger +BEFORE UPDATE ON system_users +FOR EACH ROW EXECUTE FUNCTION system_users_touch_updated_at(); + +-- migrate:down +DROP TRIGGER IF EXISTS system_users_updated_trigger ON system_users; +DROP FUNCTION IF EXISTS system_users_touch_updated_at(); +DROP TABLE IF EXISTS system_sessions; +DROP TABLE IF EXISTS system_users; diff --git a/db/migrations/20260502214941_audit_log_extend.sql b/db/migrations/20260502214941_audit_log_extend.sql new file mode 100644 index 0000000..2ae3a3b --- /dev/null +++ b/db/migrations/20260502214941_audit_log_extend.sql @@ -0,0 +1,24 @@ +-- migrate:up +-- Extiende audit_log para trazar acciones de operadores con contexto rico. +-- La columna 'actor' (text) preexistente queda como fallback para escrituras +-- del bot/sistema sin user (ej. webhook woo). + +ALTER TABLE audit_log + ADD COLUMN IF NOT EXISTS actor_user_id BIGINT REFERENCES system_users(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS actor_email TEXT, + ADD COLUMN IF NOT EXISTS actor_ip INET, + ADD COLUMN IF NOT EXISTS action_path TEXT, + ADD COLUMN IF NOT EXISTS summary TEXT; + +CREATE INDEX IF NOT EXISTS audit_log_created_at_idx ON audit_log (created_at DESC); +CREATE INDEX IF NOT EXISTS audit_log_actor_user_id_idx ON audit_log (actor_user_id); + +-- migrate:down +DROP INDEX IF EXISTS audit_log_actor_user_id_idx; +DROP INDEX IF EXISTS audit_log_created_at_idx; +ALTER TABLE audit_log + DROP COLUMN IF EXISTS summary, + DROP COLUMN IF EXISTS action_path, + DROP COLUMN IF EXISTS actor_ip, + DROP COLUMN IF EXISTS actor_email, + DROP COLUMN IF EXISTS actor_user_id; diff --git a/db/migrations/20260502215456_audit_log_relax_entity_id.sql b/db/migrations/20260502215456_audit_log_relax_entity_id.sql new file mode 100644 index 0000000..c08e885 --- /dev/null +++ b/db/migrations/20260502215456_audit_log_relax_entity_id.sql @@ -0,0 +1,7 @@ +-- migrate:up +-- entity_id era NOT NULL pero los nuevos eventos (login/logout, settings, +-- auth-related) no tienen una entity natural. Relajamos a NULL. +ALTER TABLE audit_log ALTER COLUMN entity_id DROP NOT NULL; + +-- migrate:down +ALTER TABLE audit_log ALTER COLUMN entity_id SET NOT NULL; diff --git a/env.example b/env.example index 4a84755..a4a3671 100644 --- a/env.example +++ b/env.example @@ -12,6 +12,12 @@ PG_IDLE_TIMEOUT_MS=30000 PG_CONN_TIMEOUT_MS=5000 APP_ENCRYPTION_KEY=your-32-char-encryption-key-here +# Bootstrap del primer operador (solo se usa si la tabla system_users está vacía). +# Después podés crear/editar operadores desde la UI en /operadores. +ADMIN_EMAIL=admin@local +ADMIN_PASSWORD=change-this-please +ADMIN_NAME=Admin + # =================== # LLM (OpenAI-compatible: DeepSeek, OpenAI, Anthropic via gateway, etc.) # =================== diff --git a/index.js b/index.js index 44cd325..140c86f 100644 --- a/index.js +++ b/index.js @@ -2,6 +2,7 @@ import "dotenv/config"; import { ensureTenant } from "./src/modules/2-identity/db/repo.js"; import { setTenant } from "./src/modules/shared/tenant.js"; import { createApp } from "./src/app.js"; +import { ensureBootstrapAdmin } from "./src/modules/auth/services/bootstrap.js"; async function configureUndiciDispatcher() { // Node 18+ usa undici debajo de fetch. Esto suele arreglar timeouts "fantasma" por keep-alive/pooling. @@ -28,6 +29,7 @@ const port = process.env.PORT || 3000; await configureUndiciDispatcher(); const tenantId = await ensureTenant({ key: TENANT_KEY, name: TENANT_KEY.toUpperCase() }); setTenant({ id: tenantId, key: TENANT_KEY }); + await ensureBootstrapAdmin().catch((err) => console.error("[auth] bootstrap failed:", err)); const app = createApp({ tenantId }); app.listen(port, () => console.log(`UI: http://localhost:${port} (tenant=${TENANT_KEY})`)); })().catch((err) => { diff --git a/package-lock.json b/package-lock.json index d021cf2..e0bf70c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,8 @@ "license": "MIT", "dependencies": { "ajv": "^8.17.1", + "bcrypt": "^6.0.0", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "csv-parse": "^6.1.0", "dbmate": "^2.0.0", @@ -1260,6 +1262,20 @@ "dev": true, "license": "MIT" }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -1431,6 +1447,25 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/cookie-signature": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", @@ -2323,6 +2358,26 @@ "node": ">= 0.6" } }, + "node_modules/node-addon-api": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", + "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/nodemon": { "version": "3.1.11", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz", diff --git a/package.json b/package.json index 1daaf74..06a4cfd 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ "license": "MIT", "dependencies": { "ajv": "^8.17.1", + "bcrypt": "^6.0.0", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "csv-parse": "^6.1.0", "dbmate": "^2.0.0", diff --git a/public/app.js b/public/app.js index 56f768b..fee3607 100644 --- a/public/app.js +++ b/public/app.js @@ -13,13 +13,32 @@ import "./components/orders-crud.js"; import "./components/takeovers-crud.js"; import "./components/zone-map-editor.js"; import "./components/settings-crud.js"; +import "./components/system-users-crud.js"; +import "./components/audit-log.js"; import { connectSSE } from "./lib/sse.js"; import { initRouter } from "./lib/router.js"; -connectSSE(); +(async function bootstrapShell() { + // Gate de sesión: si no hay cookie válida, redirigimos a /login. + // El HTML del shell carga sin auth, pero la data API exige cookie, así que + // sin sesión todo el SPA quedaría con 401s vacíos. + let user = null; + try { + const res = await fetch("/api/auth/me", { credentials: "include" }); + if (res.ok) { + const data = await res.json(); + if (data?.ok) user = data.user; + } + } catch {} -// Inicializar router después de que los componentes estén registrados -// Usa setTimeout para asegurar que el DOM esté listo -setTimeout(() => { - initRouter(); -}, 0); + if (!user) { + const next = encodeURIComponent(window.location.pathname + window.location.search); + window.location.replace(`/login?next=${next}`); + return; + } + + window.__USER__ = user; + + connectSSE(); + setTimeout(() => initRouter(), 0); +})(); diff --git a/public/components/audit-log.js b/public/components/audit-log.js new file mode 100644 index 0000000..e3a61b7 --- /dev/null +++ b/public/components/audit-log.js @@ -0,0 +1,202 @@ +import { api } from "../lib/api.js"; +import { modal } from "../lib/modal.js"; + +class AuditLog extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: "open" }); + this.items = []; + this.actors = []; + this.loading = false; + this.filters = { actor_id: "", entity_type: "", since: "", q: "" }; + this.limit = 50; + + this.shadowRoot.innerHTML = ` + +
+
+

Actividad

+
+ + + + + + +
+
+
+ + + + + + + + + + + + +
FechaOperadorAcciónRutaResumen
+ +
+
+ `; + } + + connectedCallback() { + this.shadowRoot.getElementById("btnApply").addEventListener("click", () => this.applyFilters()); + this.shadowRoot.getElementById("btnRefresh").addEventListener("click", () => this.load()); + this.shadowRoot.getElementById("fSearch").addEventListener("keydown", (e) => { + if (e.key === "Enter") this.applyFilters(); + }); + this.loadActors(); + this.load(); + } + + applyFilters() { + this.filters.actor_id = this.shadowRoot.getElementById("fActor").value || ""; + this.filters.entity_type = this.shadowRoot.getElementById("fEntity").value || ""; + this.filters.since = this.shadowRoot.getElementById("fSince").value || ""; + this.filters.q = this.shadowRoot.getElementById("fSearch").value.trim(); + this.load(); + } + + async loadActors() { + try { + const data = await api.auditActors(); + this.actors = data.items || []; + const sel = this.shadowRoot.getElementById("fActor"); + const current = sel.value; + sel.innerHTML = `` + + this.actors.map((a) => ``).join(""); + sel.value = current; + } catch {} + } + + async load() { + this.loading = true; + this.renderRows(); + try { + const params = { limit: this.limit }; + if (this.filters.actor_id) params.actor_id = this.filters.actor_id; + if (this.filters.entity_type) params.entity_type = this.filters.entity_type; + if (this.filters.since) params.since = new Date(this.filters.since).toISOString(); + if (this.filters.q) params.q = this.filters.q; + const data = await api.auditLog(params); + this.items = data.items || []; + this.loading = false; + this.renderRows(); + } catch (e) { + this.loading = false; + this.items = []; + this.renderRows(); + } + } + + renderRows() { + const rows = this.shadowRoot.getElementById("rows"); + const footer = this.shadowRoot.getElementById("footer"); + if (this.loading) { + rows.innerHTML = `Cargando...`; + footer.textContent = "—"; + return; + } + if (!this.items.length) { + rows.innerHTML = `Sin actividad para los filtros aplicados.`; + footer.textContent = "0 eventos"; + return; + } + rows.innerHTML = this.items.map((r) => { + const ts = new Date(r.created_at); + const tsStr = ts.toLocaleString("es-AR", { day: "2-digit", month: "2-digit", year: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" }); + const actor = r.actor_email || r.actor || "system"; + const actorClass = r.actor_user_id ? "" : "system"; + const actionClass = String(r.action || "").replace(/[^a-z_]/g, ""); + return ` + + ${this.escapeHtml(tsStr)} + ${this.escapeHtml(actor)} + ${this.escapeHtml(r.action || "—")} + ${this.escapeHtml(r.action_path || "")} + ${this.escapeHtml(r.summary || `${r.entity_type || ""}${r.entity_id ? "#"+r.entity_id : ""}`)} + + + `; + }).join(""); + rows.querySelectorAll('button[data-action="details"]').forEach((btn) => { + btn.addEventListener("click", () => this.showDetails(btn.dataset.id)); + }); + footer.textContent = `${this.items.length} evento${this.items.length === 1 ? "" : "s"}${this.items.length >= this.limit ? " (mostrando últimos)" : ""}`; + } + + showDetails(id) { + const r = this.items.find((x) => String(x.id) === String(id)); + if (!r) return; + const lines = []; + lines.push(`Fecha: ${new Date(r.created_at).toLocaleString("es-AR")}`); + lines.push(`Operador: ${r.actor_email || r.actor || "system"}`); + if (r.actor_ip) lines.push(`IP: ${r.actor_ip}`); + lines.push(`Acción: ${r.action}`); + lines.push(`Entidad: ${r.entity_type || "-"}${r.entity_id ? "#"+r.entity_id : ""}`); + if (r.action_path) lines.push(`Ruta: ${r.action_path}`); + if (r.summary) lines.push(`Resumen: ${r.summary}`); + if (r.changes) lines.push("\nCambios:\n" + JSON.stringify(r.changes, null, 2)); + modal.info(lines.join("\n")); + } + + escapeHtml(s) { + return String(s ?? "").replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } +} + +customElements.define("audit-log", AuditLog); diff --git a/public/components/ops-shell.js b/public/components/ops-shell.js index 4579315..ed0115c 100644 --- a/public/components/ops-shell.js +++ b/public/components/ops-shell.js @@ -56,6 +56,11 @@ class OpsShell extends HTMLElement { @keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:.4; } } /* Notification bell */ + .user-menu { display:flex; align-items:center; gap:8px; padding:4px 4px 4px 10px; border-radius: var(--r-sm); border:1px solid var(--border); } + .user-email { font: var(--fw-medium) 12px/1.2 var(--font-sans); color: var(--text); max-width:160px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } + .logout-btn { background: transparent; border: 1px solid transparent; color: var(--text-muted); padding: 4px 8px; border-radius: var(--r-sm); cursor:pointer; font:var(--fw-medium) 11px/1 var(--font-sans); } + .logout-btn:hover { color: var(--err); border-color: var(--err-soft); background: var(--err-soft); } + .notification-bell { position:relative; cursor:pointer; padding: 8px; border-radius: var(--r-sm); transition: background .15s; } .notification-bell:hover { background: var(--panel-2); } .notification-bell svg { width:18px; height:18px; fill: var(--text-muted); transition:fill .15s; display:block; } @@ -98,6 +103,8 @@ class OpsShell extends HTMLElement { Cantidades Pedidos Config + Operadores + Actividad
@@ -105,6 +112,10 @@ class OpsShell extends HTMLElement {
Conectando…
+
+ + +
@@ -174,6 +185,18 @@ class OpsShell extends HTMLElement {
+ +
+
+ +
+
+ +
+
+ +
+
`; } @@ -187,6 +210,18 @@ class OpsShell extends HTMLElement { if (label) label.textContent = s.ok ? "En vivo" : "Reconectando…"; }); + // User session badge + logout. + const user = window.__USER__ || null; + const emailEl = this.shadowRoot.getElementById("userEmail"); + if (emailEl) emailEl.textContent = user?.email || "—"; + this.shadowRoot.getElementById("logoutBtn")?.addEventListener("click", async () => { + try { + await fetch("/api/auth/logout", { method: "POST", credentials: "include" }); + } finally { + window.location.replace("/login"); + } + }); + // Listen for view switch requests from other components this._unsubSwitch = on("ui:switchView", ({ view }) => { if (view) this.setView(view, {}, { updateUrl: true }); diff --git a/public/components/system-users-crud.js b/public/components/system-users-crud.js new file mode 100644 index 0000000..0a1384f --- /dev/null +++ b/public/components/system-users-crud.js @@ -0,0 +1,259 @@ +import { api } from "../lib/api.js"; +import { modal } from "../lib/modal.js"; +import { toast } from "../lib/toast.js"; + +class SystemUsersCrud extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: "open" }); + this.users = []; + this.selected = null; // user seleccionado (objeto) + this.editing = null; // copia editable de selected + this.creating = false; // si true, editing es un user nuevo + this.loading = false; + this.saving = false; + + this.shadowRoot.innerHTML = ` + +
+
+

Operadores

+
+
+
+
+

Lista

+
+
+
+

Detalle

+
+
+
+
+ `; + } + + connectedCallback() { + this.shadowRoot.getElementById("btnNew").addEventListener("click", () => this.startCreate()); + this.load(); + } + + meId() { + const u = window.__USER__; + return u ? Number(u.id) : null; + } + + async load() { + this.loading = true; + this.renderList(); + try { + const data = await api.listSystemUsers(); + this.users = data.items || []; + this.loading = false; + // Si hay seleccionado, refrescar referencia. + if (this.selected) { + this.selected = this.users.find((u) => Number(u.id) === Number(this.selected.id)) || null; + } + this.renderList(); + this.renderForm(); + } catch (e) { + this.loading = false; + this.renderList(); + } + } + + startCreate() { + this.creating = true; + this.selected = null; + this.editing = { email: "", name: "", password: "", active: true }; + this.renderList(); + this.renderForm(); + } + + selectUser(user) { + this.creating = false; + this.selected = user; + this.editing = { ...user, password: "" }; + this.renderList(); + this.renderForm(); + } + + renderList() { + const list = this.shadowRoot.getElementById("list"); + if (this.loading) { list.innerHTML = `
Cargando...
`; return; } + if (!this.users.length) { list.innerHTML = `
No hay operadores
`; return; } + const meId = this.meId(); + list.innerHTML = this.users.map((u) => { + const isMe = Number(u.id) === meId; + const active = !this.creating && this.selected?.id === u.id ? "active" : ""; + const inactive = !u.active ? "inactive" : ""; + return ` +
+ +
${this.escapeHtml(u.name)}
+
+ `; + }).join(""); + list.querySelectorAll(".item[data-id]").forEach((el) => { + el.addEventListener("click", () => { + const u = this.users.find((x) => String(x.id) === el.dataset.id); + if (u) this.selectUser(u); + }); + }); + } + + renderForm() { + const title = this.shadowRoot.getElementById("formTitle"); + const slot = this.shadowRoot.getElementById("formSlot"); + if (!this.editing) { + title.textContent = "Detalle"; + slot.innerHTML = `
Seleccioná un operador o creá uno nuevo.
`; + return; + } + const isCreate = this.creating; + const meId = this.meId(); + const isSelf = !isCreate && Number(this.selected?.id) === meId; + title.textContent = isCreate ? "Nuevo operador" : `Editar — ${this.selected?.email || ""}`; + + slot.innerHTML = ` +
+
+ + + ${!isCreate ? '
El email no se puede cambiar (volvete a crear el operador si hace falta).
' : ""} +
+
+ + +
+
+ + + ${isCreate ? '
Mínimo 8 caracteres.
' : ""} +
+ ${isCreate ? "" : ` +
+ + + ${isSelf ? '
No podés desactivarte a vos mismo.
' : ""} +
+ `} +
+ + + ${isCreate || isSelf ? "" : ''} +
+
+ `; + + this.shadowRoot.getElementById("btnCancel").addEventListener("click", () => { + this.creating = false; + this.editing = this.selected ? { ...this.selected, password: "" } : null; + this.renderForm(); + }); + this.shadowRoot.getElementById("btnSave").addEventListener("click", () => this.save()); + this.shadowRoot.getElementById("btnDelete")?.addEventListener("click", () => this.remove()); + } + + async save() { + const f = this.shadowRoot; + const email = f.getElementById("fEmail").value.trim(); + const name = f.getElementById("fName").value.trim(); + const password = f.getElementById("fPassword").value; + const activeEl = f.getElementById("fActive"); + const active = activeEl ? activeEl.value === "true" : true; + + if (this.creating) { + if (!email) return toast({ kind: "error", text: "Email requerido" }); + if (!name) return toast({ kind: "error", text: "Nombre requerido" }); + if (!password || password.length < 8) return toast({ kind: "error", text: "Contraseña mínimo 8 caracteres" }); + } else { + if (!name) return toast({ kind: "error", text: "Nombre requerido" }); + if (password && password.length < 8) return toast({ kind: "error", text: "Contraseña mínimo 8 caracteres" }); + } + + this.saving = true; + this.renderForm(); + try { + if (this.creating) { + await api.createSystemUser({ email, name, password, active }); + toast({ kind: "ok", text: "Operador creado" }); + } else { + const payload = { name, active }; + if (password) payload.password = password; + await api.updateSystemUser(this.selected.id, payload); + toast({ kind: "ok", text: "Cambios guardados" }); + } + this.creating = false; + await this.load(); + } catch (e) { + // safeFetch ya muestra toast + } finally { + this.saving = false; + this.renderForm(); + } + } + + async remove() { + if (!this.selected) return; + const ok = await modal.confirm(`¿Eliminar al operador ${this.selected.email}? Pierde acceso al sistema.`, { confirmText: "Eliminar", cancelText: "Cancelar" }); + if (!ok) return; + try { + await api.deleteSystemUser(this.selected.id); + toast({ kind: "ok", text: "Operador eliminado" }); + this.selected = null; + this.editing = null; + await this.load(); + } catch (e) {} + } + + escapeHtml(s) { + return String(s ?? "").replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } +} + +customElements.define("system-users-crud", SystemUsersCrud); diff --git a/public/lib/api.js b/public/lib/api.js index a8bd68f..e400874 100644 --- a/public/lib/api.js +++ b/public/lib/api.js @@ -8,7 +8,7 @@ import { toast } from "./toast.js"; export async function safeFetch(url, opts = {}, { silent = false, label = null } = {}) { let res; try { - res = await fetch(url, opts); + res = await fetch(url, { credentials: "include", ...opts }); } catch (err) { if (!silent) toast({ kind: "error", text: `${label || "Red"}: ${err?.message || "sin conexión"}` }); throw err; @@ -313,4 +313,52 @@ export const api = { } return data; }, + + // --- Auth --- + async me() { + const res = await fetch("/api/auth/me", { credentials: "include" }); + if (!res.ok) return null; + const data = await res.json(); + return data?.user || null; + }, + async logout() { + return fetch("/api/auth/logout", { method: "POST", credentials: "include" }).then(r => r.json()); + }, + + // --- System users (operadores) --- + async listSystemUsers() { + return safeFetch("/api/system-users", {}, { label: "Operadores" }); + }, + async createSystemUser({ email, name, password, active = true }) { + return safeFetch("/api/system-users", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, name, password, active }), + }, { label: "Crear operador" }); + }, + async updateSystemUser(id, payload) { + return safeFetch(`/api/system-users/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }, { label: "Editar operador" }); + }, + async deleteSystemUser(id) { + return safeFetch(`/api/system-users/${id}`, { method: "DELETE" }, { label: "Eliminar operador" }); + }, + + // --- Audit log --- + async auditLog({ limit = 50, before, since, actor_id, entity_type, q } = {}) { + const qs = new URLSearchParams(); + qs.set("limit", String(limit)); + if (before) qs.set("before", before); + if (since) qs.set("since", since); + if (actor_id) qs.set("actor_id", String(actor_id)); + if (entity_type) qs.set("entity_type", entity_type); + if (q) qs.set("q", q); + return safeFetch(`/api/audit-log?${qs.toString()}`, {}, { silent: true }); + }, + async auditActors() { + return safeFetch("/api/audit-log/actors", {}, { silent: true }); + }, }; diff --git a/public/lib/router.js b/public/lib/router.js index 42f25bf..af2dd83 100644 --- a/public/lib/router.js +++ b/public/lib/router.js @@ -19,6 +19,8 @@ const ROUTES = [ { pattern: /^\/config-prompts$/, view: "prompts", params: [] }, { pattern: /^\/atencion-humana$/, view: "takeovers", params: [] }, { pattern: /^\/configuracion$/, view: "settings", params: [] }, + { pattern: /^\/operadores$/, view: "operadores", params: [] }, + { pattern: /^\/actividad$/, view: "actividad", params: [] }, ]; // Mapeo de vistas a rutas base (para navegación sin parámetros) @@ -35,6 +37,8 @@ const VIEW_TO_PATH = { prompts: "/config-prompts", takeovers: "/atencion-humana", settings: "/configuracion", + operadores: "/operadores", + actividad: "/actividad", }; /** diff --git a/public/login.html b/public/login.html new file mode 100644 index 0000000..25a16df --- /dev/null +++ b/public/login.html @@ -0,0 +1,107 @@ + + + + + + Piaf Console — Login + + + + + + + + diff --git a/public/login.js b/public/login.js new file mode 100644 index 0000000..f9c2d05 --- /dev/null +++ b/public/login.js @@ -0,0 +1,58 @@ +const form = document.getElementById("loginForm"); +const errorBox = document.getElementById("errorBox"); +const submitBtn = document.getElementById("submitBtn"); + +const ERROR_MESSAGES = { + missing_credentials: "Completá email y contraseña.", + invalid_credentials: "Email o contraseña incorrectos.", + user_inactive: "El usuario está deshabilitado.", +}; + +function showError(code) { + errorBox.textContent = ERROR_MESSAGES[code] || "No pudimos iniciarte sesión."; + errorBox.classList.add("visible"); +} + +function clearError() { + errorBox.classList.remove("visible"); + errorBox.textContent = ""; +} + +form.addEventListener("submit", async (e) => { + e.preventDefault(); + clearError(); + const email = form.email.value.trim(); + const password = form.password.value; + + submitBtn.disabled = true; + submitBtn.textContent = "Entrando..."; + + try { + const res = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ email, password }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || !data.ok) { + showError(data.error || "login_failed"); + return; + } + const next = new URLSearchParams(window.location.search).get("next") || "/home"; + window.location.replace(next); + } catch (err) { + showError("network"); + } finally { + submitBtn.disabled = false; + submitBtn.textContent = "Entrar"; + } +}); + +// Si ya hay sesión activa, ir directo al home. +fetch("/api/auth/me", { credentials: "include" }) + .then((r) => (r.ok ? r.json() : null)) + .then((d) => { + if (d?.ok) window.location.replace("/home"); + }) + .catch(() => {}); diff --git a/src/app.js b/src/app.js index 5afd115..2bd9ccc 100644 --- a/src/app.js +++ b/src/app.js @@ -1,57 +1,71 @@ import express from "express"; import cors from "cors"; +import cookieParser from "cookie-parser"; import path from "path"; import { fileURLToPath } from "url"; import { createSimulatorRouter } from "./modules/1-intake/routes/simulator.js"; import { createEvolutionRouter } from "./modules/1-intake/routes/evolution.js"; import { createWooWebhooksRouter } from "./modules/2-identity/routes/wooWebhooks.js"; +import { createAuthRouter } from "./modules/auth/controllers/authRoutes.js"; +import { createSystemUsersRouter } from "./modules/auth/controllers/usersRoutes.js"; +import { createAuditLogRouter } from "./modules/auth/controllers/auditRoutes.js"; +import { requireAuth } from "./modules/auth/middleware/requireAuth.js"; +import { auditWriter } from "./modules/auth/middleware/auditWriter.js"; export function createApp({ tenantId }) { const app = express(); + app.set("trust proxy", true); - app.use(cors()); + app.use(cors({ origin: true, credentials: true })); app.use(express.json({ limit: "1mb" })); + app.use(cookieParser()); - // Serve /public as static (UI + webcomponents) const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const publicDir = path.join(__dirname, "..", "public"); - app.use(express.static(publicDir)); - // --- Integraciones / UI --- - app.use(createSimulatorRouter({ tenantId })); + // Webhooks externos (Evolution, Woo) NO llevan auth ni se trazan en el log + // de operadores: se montan antes del requireAuth. app.use(createEvolutionRouter()); app.use(createWooWebhooksRouter()); - // Home (UI) + // Auth endpoints (login/logout/me) van también antes del requireAuth. + app.use(createAuthRouter()); + + // Login HTML (sin auth). + app.get("/login", (req, res) => { + res.sendFile(path.join(publicDir, "login.html")); + }); + + // Static assets — SIN auth (assets del shell, login, fonts, etc.) + app.use(express.static(publicDir)); + + // SPA shell HTML — sin auth en el HTML; el JS gatea con /api/auth/me. app.get("/", (req, res) => { res.sendFile(path.join(publicDir, "index.html")); }); - - // SPA catch-all - sirve index.html para todas las rutas del frontend const spaRoutes = [ - '/home', '/chat', '/conversaciones', '/usuarios', '/productos', - '/equivalencias', '/crosssell', '/cantidades', '/pedidos', - '/config-prompts', '/atencion-humana', '/configuracion', + "/home", "/chat", "/conversaciones", "/usuarios", "/productos", + "/equivalencias", "/crosssell", "/cantidades", "/pedidos", + "/config-prompts", "/atencion-humana", "/configuracion", + "/operadores", "/actividad", ]; app.get(spaRoutes, (req, res) => { res.sendFile(path.join(publicDir, "index.html")); }); - // Rutas con parámetros - app.get('/usuarios/:id', (req, res) => { - res.sendFile(path.join(publicDir, "index.html")); - }); - app.get('/productos/:id', (req, res) => { - res.sendFile(path.join(publicDir, "index.html")); - }); - app.get('/crosssell/:id', (req, res) => { - res.sendFile(path.join(publicDir, "index.html")); - }); - app.get('/pedidos/:id', (req, res) => { - res.sendFile(path.join(publicDir, "index.html")); - }); + app.get("/usuarios/:id", (req, res) => res.sendFile(path.join(publicDir, "index.html"))); + app.get("/productos/:id", (req, res) => res.sendFile(path.join(publicDir, "index.html"))); + app.get("/crosssell/:id", (req, res) => res.sendFile(path.join(publicDir, "index.html"))); + app.get("/pedidos/:id", (req, res) => res.sendFile(path.join(publicDir, "index.html"))); + + // Todas las rutas de admin (data API) requieren login + se trazan. + app.use(requireAuth); + app.use(auditWriter); + + app.use(createSimulatorRouter({ tenantId })); + app.use(createSystemUsersRouter()); + app.use(createAuditLogRouter()); return app; } - diff --git a/src/modules/auth/controllers/auditRoutes.js b/src/modules/auth/controllers/auditRoutes.js new file mode 100644 index 0000000..38c7b68 --- /dev/null +++ b/src/modules/auth/controllers/auditRoutes.js @@ -0,0 +1,67 @@ +/** + * Lectura del audit_log con filtros. + */ + +import { Router } from "express"; +import { pool } from "../../shared/db/pool.js"; +import { getTenantId } from "../../shared/tenant.js"; + +export function createAuditLogRouter() { + const router = Router(); + + router.get("/api/audit-log", async (req, res) => { + const tenantId = getTenantId(); + const limit = Math.min(parseInt(req.query.limit) || 50, 200); + const before = req.query.before ? new Date(req.query.before) : null; + const since = req.query.since ? new Date(req.query.since) : null; + const actorId = req.query.actor_id ? Number(req.query.actor_id) : null; + const entityType = req.query.entity_type ? String(req.query.entity_type) : null; + const search = req.query.q ? String(req.query.q).trim() : null; + + const wheres = ["tenant_id = $1"]; + const params = [tenantId]; + + if (before instanceof Date && !isNaN(before)) { + params.push(before.toISOString()); wheres.push(`created_at < $${params.length}`); + } + if (since instanceof Date && !isNaN(since)) { + params.push(since.toISOString()); wheres.push(`created_at >= $${params.length}`); + } + if (actorId) { + params.push(actorId); wheres.push(`actor_user_id = $${params.length}`); + } + if (entityType) { + params.push(entityType); wheres.push(`entity_type = $${params.length}`); + } + if (search) { + params.push(`%${search}%`); wheres.push(`(summary ILIKE $${params.length} OR action_path ILIKE $${params.length})`); + } + params.push(limit); + + const sql = ` + SELECT + id, created_at, entity_type, entity_id, action, + actor, actor_user_id, actor_email, actor_ip, + action_path, summary, changes + FROM audit_log + WHERE ${wheres.join(" AND ")} + ORDER BY created_at DESC + LIMIT $${params.length} + `; + const { rows } = await pool.query(sql, params); + res.json({ ok: true, items: rows }); + }); + + router.get("/api/audit-log/actors", async (req, res) => { + const sql = ` + SELECT u.id, u.email, u.name + FROM system_users u + WHERE EXISTS (SELECT 1 FROM audit_log a WHERE a.actor_user_id = u.id) + ORDER BY u.email + `; + const { rows } = await pool.query(sql); + res.json({ ok: true, items: rows }); + }); + + return router; +} diff --git a/src/modules/auth/controllers/authRoutes.js b/src/modules/auth/controllers/authRoutes.js new file mode 100644 index 0000000..83311bd --- /dev/null +++ b/src/modules/auth/controllers/authRoutes.js @@ -0,0 +1,113 @@ +import { Router } from "express"; +import { login, logout } from "../services/auth.js"; +import { findUserById } from "../db/usersRepo.js"; +import { findActiveSessionWithUser } from "../db/sessionsRepo.js"; +import { pool } from "../../shared/db/pool.js"; +import { getTenantId } from "../../shared/tenant.js"; +import { SESSION_COOKIE } from "../middleware/requireAuth.js"; + +const COOKIE_DEFAULTS = { + httpOnly: true, + sameSite: "lax", + path: "/", + secure: process.env.NODE_ENV === "production", +}; + +async function logAudit({ user, action, summary, ip, action_path }) { + const sql = ` + INSERT INTO audit_log + (tenant_id, entity_type, action, actor, actor_user_id, actor_email, actor_ip, action_path, summary) + VALUES ($1, 'auth', $2, 'ui', $3, $4, $5, $6, $7) + `; + try { + await pool.query(sql, [ + getTenantId(), + action, + user?.id ?? null, + user?.email ?? null, + ip || null, + action_path, + summary, + ]); + } catch (err) { + console.error("[auth] audit insert failed:", err.message); + } +} + +export function createAuthRouter() { + const router = Router(); + + router.post("/api/auth/login", async (req, res) => { + const { email, password } = req.body || {}; + const result = await login({ + email, + password, + ip: req.ip || null, + user_agent: req.headers["user-agent"] || null, + }); + + if (!result.ok) { + await logAudit({ + action: "login_failed", + summary: `Login fallido (${result.error}) para ${email || "(sin email)"}`, + ip: req.ip, + action_path: "POST /api/auth/login", + }); + return res.status(401).json({ ok: false, error: result.error }); + } + + const ttlMs = new Date(result.session.expires_at).getTime() - Date.now(); + res.cookie(SESSION_COOKIE, result.session.id, { + ...COOKIE_DEFAULTS, + maxAge: Math.max(ttlMs, 0), + }); + + await logAudit({ + user: result.user, + action: "login", + summary: `Login OK ${result.user.email}`, + ip: req.ip, + action_path: "POST /api/auth/login", + }); + + res.json({ ok: true, user: result.user }); + }); + + router.post("/api/auth/logout", async (req, res) => { + const sessionId = req.cookies?.[SESSION_COOKIE]; + let user = null; + if (sessionId) { + const found = await findActiveSessionWithUser(sessionId); + if (found) user = { id: found.user_id, email: found.email }; + } + await logout(sessionId); + res.clearCookie(SESSION_COOKIE, COOKIE_DEFAULTS); + + if (user) { + await logAudit({ + user, + action: "logout", + summary: `Logout ${user.email}`, + ip: req.ip, + action_path: "POST /api/auth/logout", + }); + } + + res.json({ ok: true }); + }); + + router.get("/api/auth/me", async (req, res) => { + const sessionId = req.cookies?.[SESSION_COOKIE]; + if (!sessionId) return res.status(401).json({ ok: false, error: "unauthenticated" }); + const found = await findActiveSessionWithUser(sessionId); + if (!found) { + res.clearCookie(SESSION_COOKIE, COOKIE_DEFAULTS); + return res.status(401).json({ ok: false, error: "session_expired_or_invalid" }); + } + const user = await findUserById(found.user_id); + if (!user) return res.status(401).json({ ok: false, error: "user_not_found" }); + res.json({ ok: true, user }); + }); + + return router; +} diff --git a/src/modules/auth/controllers/usersRoutes.js b/src/modules/auth/controllers/usersRoutes.js new file mode 100644 index 0000000..f30a58b --- /dev/null +++ b/src/modules/auth/controllers/usersRoutes.js @@ -0,0 +1,123 @@ +/** + * ABM de operadores (system_users). + * + * Reglas: + * - Email único, requerido al crear. + * - Password mínimo 8 chars al crear o cambiar. + * - Un user no puede borrarse a sí mismo ni desactivarse a sí mismo. + * - Borrar un user borra sus sesiones por cascade. + */ + +import { Router } from "express"; +import { + listUsers, + findUserByEmail, + findUserById, + createUser, + updateUser, + updatePassword, + deleteUser, +} from "../db/usersRepo.js"; +import { hashPassword } from "../services/passwords.js"; + +// Permite emails internos sin TLD (admin@local) y emails completos. +const EMAIL_RE = /^[^\s@]+@[^\s@]+$/; + +function asPublic(u) { + if (!u) return null; + return { + id: String(u.id), + email: u.email, + name: u.name, + active: u.active, + created_at: u.created_at, + updated_at: u.updated_at, + last_login_at: u.last_login_at, + }; +} + +export function createSystemUsersRouter() { + const router = Router(); + + router.get("/api/system-users", async (req, res) => { + const users = await listUsers({ limit: 200 }); + res.json({ ok: true, items: users.map(asPublic) }); + }); + + router.post("/api/system-users", async (req, res) => { + const { email, name, password, active } = req.body || {}; + if (!email || !EMAIL_RE.test(email)) { + return res.status(400).json({ ok: false, error: "invalid_email" }); + } + if (!name || !String(name).trim()) { + return res.status(400).json({ ok: false, error: "name_required" }); + } + if (!password || password.length < 8) { + return res.status(400).json({ ok: false, error: "password_too_short" }); + } + const dup = await findUserByEmail(email.trim().toLowerCase()); + if (dup) return res.status(409).json({ ok: false, error: "email_taken" }); + try { + const password_hash = await hashPassword(password); + const created = await createUser({ + email: email.trim().toLowerCase(), + name: String(name).trim(), + password_hash, + active: active !== false, + }); + res.locals.audit = { + entity_type: "system_user", + entity_id: created.id, + summary: `Creó operador ${created.email}`, + }; + res.json({ ok: true, user: asPublic(created) }); + } catch (err) { + res.status(500).json({ ok: false, error: err.message }); + } + }); + + router.put("/api/system-users/:id", async (req, res) => { + const id = Number(req.params.id); + if (!Number.isFinite(id)) return res.status(400).json({ ok: false, error: "invalid_id" }); + const target = await findUserById(id); + if (!target) return res.status(404).json({ ok: false, error: "not_found" }); + + const { name, active, password } = req.body || {}; + + if (active === false && req.user.id === id) { + return res.status(400).json({ ok: false, error: "cant_deactivate_self" }); + } + + let updated = await updateUser(id, { name, active }); + if (password) { + if (password.length < 8) return res.status(400).json({ ok: false, error: "password_too_short" }); + const hash = await hashPassword(password); + updated = await updatePassword(id, hash); + } + res.locals.audit = { + entity_type: "system_user", + entity_id: id, + summary: `Actualizó operador ${target.email}`, + }; + res.json({ ok: true, user: asPublic(updated) }); + }); + + router.delete("/api/system-users/:id", async (req, res) => { + const id = Number(req.params.id); + if (!Number.isFinite(id)) return res.status(400).json({ ok: false, error: "invalid_id" }); + if (req.user.id === id) { + return res.status(400).json({ ok: false, error: "cant_delete_self" }); + } + const target = await findUserById(id); + if (!target) return res.status(404).json({ ok: false, error: "not_found" }); + await deleteUser(id); + res.locals.audit = { + entity_type: "system_user", + entity_id: id, + summary: `Eliminó operador ${target.email}`, + }; + res.json({ ok: true }); + }); + + return router; +} diff --git a/src/modules/auth/db/sessionsRepo.js b/src/modules/auth/db/sessionsRepo.js new file mode 100644 index 0000000..3bdc0e6 --- /dev/null +++ b/src/modules/auth/db/sessionsRepo.js @@ -0,0 +1,60 @@ +import { pool } from "../../shared/db/pool.js"; + +const SESSION_TTL_DAYS = 14; + +export async function createSession({ user_id, ip = null, user_agent = null }) { + const sql = ` + INSERT INTO system_sessions (user_id, ip, user_agent, expires_at) + VALUES ($1, $2, $3, NOW() + ($4 || ' days')::interval) + RETURNING id, user_id, expires_at, created_at + `; + const { rows } = await pool.query(sql, [user_id, ip, user_agent, String(SESSION_TTL_DAYS)]); + return rows[0]; +} + +/** + * Devuelve user + session si la sesión existe, no está revocada y no expiró. + */ +export async function findActiveSessionWithUser(sessionId) { + if (!sessionId) return null; + const sql = ` + SELECT + s.id AS session_id, + s.expires_at, + s.user_id, + u.email, + u.name, + u.active + FROM system_sessions s + JOIN system_users u ON u.id = s.user_id + WHERE s.id = $1 + AND s.revoked_at IS NULL + AND s.expires_at > NOW() + AND u.active = true + LIMIT 1 + `; + try { + const { rows } = await pool.query(sql, [sessionId]); + return rows[0] || null; + } catch { + // sessionId inválido (no es UUID) → no es una sesión válida. + return null; + } +} + +export async function revokeSession(sessionId) { + if (!sessionId) return false; + try { + const { rowCount } = await pool.query( + `UPDATE system_sessions SET revoked_at = NOW() WHERE id = $1 AND revoked_at IS NULL`, + [sessionId], + ); + return rowCount > 0; + } catch { + return false; + } +} + +export async function purgeExpiredSessions() { + await pool.query(`DELETE FROM system_sessions WHERE expires_at < NOW() - INTERVAL '7 days'`); +} diff --git a/src/modules/auth/db/usersRepo.js b/src/modules/auth/db/usersRepo.js new file mode 100644 index 0000000..f131724 --- /dev/null +++ b/src/modules/auth/db/usersRepo.js @@ -0,0 +1,62 @@ +import { pool } from "../../shared/db/pool.js"; + +const COLUMNS = `id, email, name, active, created_at, updated_at, last_login_at`; + +export async function findUserByEmail(email) { + const sql = `SELECT ${COLUMNS}, password_hash FROM system_users WHERE email = $1 LIMIT 1`; + const { rows } = await pool.query(sql, [email]); + return rows[0] || null; +} + +export async function findUserById(id) { + const sql = `SELECT ${COLUMNS} FROM system_users WHERE id = $1 LIMIT 1`; + const { rows } = await pool.query(sql, [id]); + return rows[0] || null; +} + +export async function listUsers({ limit = 100 } = {}) { + const sql = `SELECT ${COLUMNS} FROM system_users ORDER BY created_at DESC LIMIT $1`; + const { rows } = await pool.query(sql, [limit]); + return rows; +} + +export async function countUsers() { + const { rows } = await pool.query(`SELECT COUNT(*)::int AS n FROM system_users`); + return rows[0]?.n || 0; +} + +export async function createUser({ email, name, password_hash, active = true }) { + const sql = ` + INSERT INTO system_users (email, name, password_hash, active) + VALUES ($1, $2, $3, $4) + RETURNING ${COLUMNS} + `; + const { rows } = await pool.query(sql, [email, name, password_hash, active]); + return rows[0]; +} + +export async function updateUser(id, { name, active }) { + const sets = []; + const params = [id]; + if (name !== undefined) { params.push(name); sets.push(`name = $${params.length}`); } + if (active !== undefined) { params.push(active); sets.push(`active = $${params.length}`); } + if (!sets.length) return findUserById(id); + const sql = `UPDATE system_users SET ${sets.join(", ")} WHERE id = $1 RETURNING ${COLUMNS}`; + const { rows } = await pool.query(sql, params); + return rows[0] || null; +} + +export async function updatePassword(id, password_hash) { + const sql = `UPDATE system_users SET password_hash = $2 WHERE id = $1 RETURNING ${COLUMNS}`; + const { rows } = await pool.query(sql, [id, password_hash]); + return rows[0] || null; +} + +export async function deleteUser(id) { + const { rowCount } = await pool.query(`DELETE FROM system_users WHERE id = $1`, [id]); + return rowCount > 0; +} + +export async function touchLastLogin(id) { + await pool.query(`UPDATE system_users SET last_login_at = NOW() WHERE id = $1`, [id]); +} diff --git a/src/modules/auth/middleware/auditWriter.js b/src/modules/auth/middleware/auditWriter.js new file mode 100644 index 0000000..a64efbf --- /dev/null +++ b/src/modules/auth/middleware/auditWriter.js @@ -0,0 +1,86 @@ +/** + * Middleware que registra en audit_log cada mutación HTTP del admin. + * Se ejecuta DESPUÉS de requireAuth, así que req.user existe. + * + * Captura: user, IP, UA, método+path, body redactado, status, summary + * (opt-in via res.locals.audit = { summary, entity_type, entity_id, changes }). + * + * Sólo loggea respuestas 2xx — 4xx/5xx ya quedan en logs de express. + */ + +import { pool } from "../../shared/db/pool.js"; +import { getTenantId } from "../../shared/tenant.js"; + +const SENSITIVE_KEYS = /pass(word)?|token|secret|api_?key/i; + +function redact(obj, depth = 0) { + if (depth > 4) return "[...]"; + if (obj == null) return obj; + if (Array.isArray(obj)) return obj.map((v) => redact(v, depth + 1)); + if (typeof obj === "object") { + const out = {}; + for (const [k, v] of Object.entries(obj)) { + if (SENSITIVE_KEYS.test(k)) { + out[k] = "[REDACTED]"; + } else { + out[k] = redact(v, depth + 1); + } + } + return out; + } + return obj; +} + +export function auditWriter(req, res, next) { + if (req.method === "GET" || req.method === "OPTIONS" || req.method === "HEAD") { + return next(); + } + + res.on("finish", () => { + if (res.statusCode < 200 || res.statusCode >= 300) return; + if (!req.user) return; + + const audit = res.locals?.audit || {}; + const entity_type = audit.entity_type || guessEntity(req.path); + const entity_id = audit.entity_id ?? null; + const action = audit.action || mapAction(req.method); + const summary = audit.summary || `${req.method} ${req.path}`; + const changes = audit.changes || (req.body ? redact(req.body) : null); + + const sql = ` + INSERT INTO audit_log + (tenant_id, entity_type, entity_id, action, changes, actor, + actor_user_id, actor_email, actor_ip, action_path, summary) + VALUES ($1, $2, $3, $4, $5, 'ui', $6, $7, $8, $9, $10) + `; + pool.query(sql, [ + getTenantId(), + entity_type, + entity_id != null ? String(entity_id) : null, + action, + changes ? JSON.stringify(changes) : null, + req.user.id, + req.user.email, + req.ip || null, + `${req.method} ${req.path}`, + summary, + ]).catch((err) => { + console.error("[auditWriter] insert failed:", err.message); + }); + }); + + next(); +} + +function mapAction(method) { + if (method === "POST") return "create"; + if (method === "PUT" || method === "PATCH") return "update"; + if (method === "DELETE") return "delete"; + return method.toLowerCase(); +} + +function guessEntity(path) { + // Devuelve el primer segmento significativo de la ruta. + const seg = path.split("/").filter(Boolean)[0] || "unknown"; + return seg.replace(/^api$/, "api").slice(0, 50); +} diff --git a/src/modules/auth/middleware/requireAuth.js b/src/modules/auth/middleware/requireAuth.js new file mode 100644 index 0000000..8e9fa06 --- /dev/null +++ b/src/modules/auth/middleware/requireAuth.js @@ -0,0 +1,79 @@ +/** + * Middleware que exige cookie bot_session válida. + * + * Whitelist de paths sin auth: + * - assets estáticos del SPA (index.html, login.html, app.js, /styles, /components, /lib) + * - rutas de auth (login/logout/me) + * + * El SPA shell carga sin auth; cuando el JS llama /api/auth/me y devuelve 401, + * el frontend redirige a /login. + * + * Webhooks externos (/webhook/*, /woo-webhook/*) se montan ANTES de este + * middleware en src/app.js, así que no llegan acá. + */ + +import { findActiveSessionWithUser } from "../db/sessionsRepo.js"; + +export const SESSION_COOKIE = "bot_session"; + +const PUBLIC_PREFIXES = [ + "/styles/", + "/components/", + "/lib/", +]; + +const PUBLIC_EXACT = new Set([ + "/", + "/login", + "/login.html", + "/login.js", + "/index.html", + "/app.js", + "/api/auth/login", + "/api/auth/logout", + "/api/auth/me", +]); + +function isPublic(req) { + if (req.method !== "GET" && !req.path.startsWith("/api/auth/")) return false; + if (PUBLIC_EXACT.has(req.path)) return true; + for (const prefix of PUBLIC_PREFIXES) { + if (req.path.startsWith(prefix)) return true; + } + // SPA catch-all routes (HTML del shell) — entran sin sesión y el JS redirige. + if (req.method === "GET" && SPA_PATHS.has(req.path)) return true; + return false; +} + +const SPA_PATHS = new Set([ + "/home", "/chat", "/conversaciones", "/usuarios", "/productos", + "/equivalencias", "/crosssell", "/cantidades", "/pedidos", + "/config-prompts", "/atencion-humana", "/configuracion", + "/operadores", "/actividad", +]); + +export async function requireAuth(req, res, next) { + if (isPublic(req)) return next(); + + const sessionId = req.cookies?.[SESSION_COOKIE] || null; + if (!sessionId) { + return res.status(401).json({ ok: false, error: "unauthenticated" }); + } + + const found = await findActiveSessionWithUser(sessionId); + if (!found) { + res.clearCookie(SESSION_COOKIE); + return res.status(401).json({ ok: false, error: "session_expired_or_invalid" }); + } + + req.user = { + id: Number(found.user_id), + email: found.email, + name: found.name, + }; + req.session = { + id: found.session_id, + expires_at: found.expires_at, + }; + next(); +} diff --git a/src/modules/auth/services/auth.js b/src/modules/auth/services/auth.js new file mode 100644 index 0000000..687ae0c --- /dev/null +++ b/src/modules/auth/services/auth.js @@ -0,0 +1,42 @@ +/** + * Login / logout services. La cookie y el set-cookie los maneja el controller; + * acá vive la lógica. + */ + +import { findUserByEmail, touchLastLogin } from "../db/usersRepo.js"; +import { createSession, revokeSession } from "../db/sessionsRepo.js"; +import { verifyPassword } from "./passwords.js"; + +export async function login({ email, password, ip, user_agent }) { + const e = String(email || "").trim().toLowerCase(); + if (!e || !password) return { ok: false, error: "missing_credentials" }; + + const user = await findUserByEmail(e); + if (!user) return { ok: false, error: "invalid_credentials" }; + if (!user.active) return { ok: false, error: "user_inactive" }; + + const ok = await verifyPassword(password, user.password_hash); + if (!ok) return { ok: false, error: "invalid_credentials" }; + + const session = await createSession({ user_id: user.id, ip, user_agent }); + await touchLastLogin(user.id); + + return { + ok: true, + user: { + id: user.id, + email: user.email, + name: user.name, + }, + session: { + id: session.id, + expires_at: session.expires_at, + }, + }; +} + +export async function logout(sessionId) { + if (!sessionId) return { ok: true, revoked: false }; + const revoked = await revokeSession(sessionId); + return { ok: true, revoked }; +} diff --git a/src/modules/auth/services/bootstrap.js b/src/modules/auth/services/bootstrap.js new file mode 100644 index 0000000..1547e44 --- /dev/null +++ b/src/modules/auth/services/bootstrap.js @@ -0,0 +1,29 @@ +/** + * Bootstrap del primer operador a partir de ADMIN_EMAIL/ADMIN_PASSWORD. + * Se ejecuta una vez en startup. Si la tabla ya tiene users, es noop. + */ + +import { countUsers, createUser } from "../db/usersRepo.js"; +import { hashPassword } from "./passwords.js"; + +export async function ensureBootstrapAdmin() { + const existing = await countUsers(); + if (existing > 0) return { skipped: true, reason: "users_exist", count: existing }; + + const email = process.env.ADMIN_EMAIL; + const password = process.env.ADMIN_PASSWORD; + const name = process.env.ADMIN_NAME || "Admin"; + + if (!email || !password) { + console.warn( + "[auth] system_users vacía y ADMIN_EMAIL/ADMIN_PASSWORD no seteados — " + + "no podrás loguearte hasta que crees un usuario." + ); + return { skipped: true, reason: "no_env" }; + } + + const password_hash = await hashPassword(password); + const user = await createUser({ email: email.trim().toLowerCase(), name, password_hash }); + console.log(`[auth] Bootstrap admin creado: ${user.email}`); + return { created: true, user }; +} diff --git a/src/modules/auth/services/passwords.js b/src/modules/auth/services/passwords.js new file mode 100644 index 0000000..933d092 --- /dev/null +++ b/src/modules/auth/services/passwords.js @@ -0,0 +1,19 @@ +/** + * Wrapper fino sobre bcrypt para que el resto del código no importe la lib. + * Cost 10 (~100ms en hardware moderno) — suficiente para un admin interno. + */ + +import bcrypt from "bcrypt"; + +const COST = 10; + +export async function hashPassword(plain) { + if (!plain || typeof plain !== "string") throw new Error("password_required"); + if (plain.length < 8) throw new Error("password_too_short"); + return bcrypt.hash(plain, COST); +} + +export async function verifyPassword(plain, hash) { + if (!plain || !hash) return false; + return bcrypt.compare(plain, hash); +} diff --git a/src/modules/auth/services/passwords.test.js b/src/modules/auth/services/passwords.test.js new file mode 100644 index 0000000..009c321 --- /dev/null +++ b/src/modules/auth/services/passwords.test.js @@ -0,0 +1,26 @@ +import { hashPassword, verifyPassword } from "./passwords.js"; + +describe("passwords", () => { + it("hashea distinto cada vez para misma password (salt)", async () => { + const a = await hashPassword("supersecret123"); + const b = await hashPassword("supersecret123"); + expect(a).not.toBe(b); + expect(a.length).toBeGreaterThan(20); + }); + + it("verify acepta la password correcta y rechaza otras", async () => { + const h = await hashPassword("supersecret123"); + expect(await verifyPassword("supersecret123", h)).toBe(true); + expect(await verifyPassword("wrong", h)).toBe(false); + expect(await verifyPassword("", h)).toBe(false); + }); + + it("rechaza passwords < 8 chars", async () => { + await expect(hashPassword("short")).rejects.toThrow("password_too_short"); + }); + + it("rechaza inputs vacíos", async () => { + await expect(hashPassword("")).rejects.toThrow("password_required"); + await expect(hashPassword(null)).rejects.toThrow("password_required"); + }); +});