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 = `
+
+
+
+
+
+
+
+ | Fecha |
+ Operador |
+ Acción |
+ Ruta |
+ Resumen |
+ |
+
+
+
+
+
+
+
+ `;
+ }
+
+ 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 {
0
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 = `
+
+
+ `;
+ }
+
+ 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.email)}${isMe ? 'Vos' : ""}${!u.active ? 'Inactivo' : ""}
+
${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 = `
+
+ `;
+
+ 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");
+ });
+});