Carga completa
This commit is contained in:
parent
237a5427dd
commit
5d078f3932
35
.env.development
Normal file
35
.env.development
Normal file
@ -0,0 +1,35 @@
|
||||
# Archivo de variables de entorno para docker-compose.yml
|
||||
COMPOSE_PROJECT_NAME=suitecoffee_dev
|
||||
|
||||
# Entorno de desarrollo
|
||||
NODE_ENV=development
|
||||
|
||||
# app - app
|
||||
APP_LOCAL_PORT=3030
|
||||
APP_DOCKER_PORT=3030
|
||||
|
||||
# auth - app
|
||||
AUTH_LOCAL_PORT=4040
|
||||
AUTH_DOCKER_PORT=4040
|
||||
|
||||
# tenants - postgres
|
||||
TENANTS_DB_NAME=dev-postgres
|
||||
TENANTS_DB_USER=dev-user-postgres
|
||||
TENANTS_DB_PASS=dev-pass-postgres
|
||||
|
||||
TENANTS_DB_LOCAL_PORT=54321
|
||||
TENANTS_DB_DOCKER_PORT=5432
|
||||
|
||||
# db primaria - postgres
|
||||
DB_NAME=dev-suitecoffee
|
||||
DB_USER=dev-user-suitecoffee
|
||||
DB_PASS=dev-pass-suitecoffee
|
||||
|
||||
DB_LOCAL_PORT=54322
|
||||
DB_DOCKER_PORT=5432
|
||||
|
||||
# --- secretos para Authentik
|
||||
AUTHENTIK_SECRET_KEY=poné_un_valor_largo_y_unico
|
||||
AUTHENTIK_DB_PASS=cambia_esto
|
||||
AUTHENTIK_BOOTSTRAP_PASSWORD=cambia_esto
|
||||
AUTHENTIK_BOOTSTRAP_EMAIL=info.suitecoffee@gmail.com
|
||||
23
.env.production
Normal file
23
.env.production
Normal file
@ -0,0 +1,23 @@
|
||||
# Archivo de variables de entorno para docker-compose.yml
|
||||
COMPOSE_PROJECT_NAME=suitecoffee_prod
|
||||
|
||||
# Entorno de desarrollo
|
||||
NODE_ENV=production
|
||||
|
||||
# app - app
|
||||
APP_LOCAL_PORT=3000
|
||||
APP_DOCKER_PORT=3000
|
||||
|
||||
# auth - app
|
||||
AUTH_LOCAL_PORT=4000
|
||||
AUTH_DOCKER_PORT=4000
|
||||
|
||||
# tenants - postgres
|
||||
TENANTS_DB_NAME=postgres
|
||||
TENANTS_DB_USER=postgres
|
||||
TENANTS_DB_PASS=postgres
|
||||
|
||||
# db primaria - postgres
|
||||
DB_NAME=suitecoffee
|
||||
DB_USER=suitecoffee
|
||||
DB_PASS=suitecoffee
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -33,6 +33,6 @@ tests/
|
||||
.gitmodules
|
||||
|
||||
# Ignorar archivos personales o privados (si existen)
|
||||
.env.*
|
||||
# .env.*
|
||||
*.pem
|
||||
*.key
|
||||
0
docs/db's.md
Normal file
0
docs/db's.md
Normal file
29
services/app/.env.development
Normal file
29
services/app/.env.development
Normal file
@ -0,0 +1,29 @@
|
||||
# ===== Runtime =====
|
||||
NODE_ENV=development
|
||||
PORT=3030
|
||||
APP_LOCAL_PORT=3030
|
||||
|
||||
# ===== Session (usa el Redis del stack) =====
|
||||
# Para DEV podemos reutilizar el Redis de Authentik. En prod conviene uno separado.
|
||||
SESSION_SECRET=pon-una-clave-larga-y-unica
|
||||
REDIS_URL=redis://authentik-redis:6379
|
||||
|
||||
# ===== DB principal (metadatos de SuiteCoffee) =====
|
||||
DB_HOST=dev-tenants
|
||||
DB_PORT=5432
|
||||
DB_NAME=dev-postgres
|
||||
DB_USER=dev-user-postgres
|
||||
DB_PASS=dev-pass-postgres
|
||||
|
||||
# ===== DB tenants (Tenants de SuiteCoffee) =====
|
||||
TENANTS_HOST=dev-tenants
|
||||
TENANTS_DB=dev-postgres
|
||||
TENANTS_USER=dev-user-postgres
|
||||
TENANTS_PASS=dev-pass-postgres
|
||||
TENANTS_PORT=5432
|
||||
|
||||
# ===== (Opcional) Colores UI, si alguna vista los lee =====
|
||||
COL_PRI=452D19 # Marrón oscuro
|
||||
COL_SEC=D7A666 # Crema / Café
|
||||
COL_BG=FFA500 # Naranja
|
||||
|
||||
22
services/app/.env.production
Normal file
22
services/app/.env.production
Normal file
@ -0,0 +1,22 @@
|
||||
NODE_ENV=production # Entorno de desarrollo
|
||||
|
||||
PORT=3000 # Variables del servicio -> suitecoffee-app
|
||||
|
||||
# Variables del servicio -> suitecoffee-db de suitecoffee-app
|
||||
|
||||
DB_HOST=prod-tenants
|
||||
# Nombre de la base de datos
|
||||
DB_NAME=postgres
|
||||
|
||||
# Usuario y contraseña
|
||||
DB_USER=postgres
|
||||
DB_PASS=postgres
|
||||
|
||||
# Puertos del servicio de db
|
||||
DB_LOCAL_PORT=5432
|
||||
DB_DOCKER_PORT=5432
|
||||
|
||||
# Colores personalizados
|
||||
COL_PRI=452D19 # Marrón oscuro
|
||||
COL_SEC=D7A666 # Crema / Café
|
||||
COL_BG=FFA500 # Naranja
|
||||
62
services/app/package-lock.json
generated
62
services/app/package-lock.json
generated
@ -19,6 +19,7 @@
|
||||
"express-ejs-layouts": "^2.5.1",
|
||||
"express-session": "^1.18.2",
|
||||
"ioredis": "^5.7.0",
|
||||
"morgan": "^1.10.1",
|
||||
"pg": "^8.16.3",
|
||||
"pg-format": "^1.0.4",
|
||||
"redis": "^5.8.2",
|
||||
@ -131,6 +132,24 @@
|
||||
"version": "1.0.2",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/basic-auth": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz",
|
||||
"integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/basic-auth/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bcrypt": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
|
||||
@ -902,6 +921,49 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/morgan": {
|
||||
"version": "1.10.1",
|
||||
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz",
|
||||
"integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"basic-auth": "~2.0.1",
|
||||
"debug": "2.6.9",
|
||||
"depd": "~2.0.0",
|
||||
"on-finished": "~2.3.0",
|
||||
"on-headers": "~1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/morgan/node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/morgan/node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/morgan/node_modules/on-finished": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
|
||||
"integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ee-first": "1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"license": "MIT"
|
||||
|
||||
@ -25,6 +25,7 @@
|
||||
"express-ejs-layouts": "^2.5.1",
|
||||
"express-session": "^1.18.2",
|
||||
"ioredis": "^5.7.0",
|
||||
"morgan": "^1.10.1",
|
||||
"pg": "^8.16.3",
|
||||
"pg-format": "^1.0.4",
|
||||
"redis": "^5.8.2",
|
||||
|
||||
@ -1,216 +1,259 @@
|
||||
// services/app/src/index.js
|
||||
// -----------------------------------------------------------------------------
|
||||
// SuiteCoffee — Servicio APP (Express)
|
||||
// - Carga de entorno robusta (compatible con Docker Compose env_file)
|
||||
// - Sesiones compartidas via Redis (mismo cookie que AUTH)
|
||||
// - Middlewares: CORS, JSON, estáticos, EJS opcional
|
||||
// - Multitenant por esquema: requireAuth + withTenant + done
|
||||
// - Montaje automático de ENDPOINTS LEGACY sin perder nada
|
||||
// - Healthcheck, 404 y manejador de errores
|
||||
// - Conserva y expone pools para que tus endpoints los usen
|
||||
// -----------------------------------------------------------------------------
|
||||
// ------------------------------------------------------------
|
||||
// SuiteCoffee — Servicio APP (UI + APIs negocio)
|
||||
// - ESM (Node >=18)
|
||||
// - Vistas EJS en ./views (dashboard.ejs, comandas.ejs, etc.)
|
||||
// - Sesión compartida con AUTH (cookie: sc.sid, Redis)
|
||||
// - Monta routes.legacy.js con requireAuth + withTenant
|
||||
// ------------------------------------------------------------
|
||||
|
||||
// === 0) CARGA DE ENTORNO ROBUSTA (no pisa variables ya definidas por Compose)
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import dotenv from 'dotenv';
|
||||
import chalk from 'chalk';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const ENV = (process.env.NODE_ENV || 'development').toLowerCase();
|
||||
const envMap = { development: '.env.development', stage: '.env.test', production: '.env.production' };
|
||||
const envFile = envMap[ENV] || '.env.development';
|
||||
const candidates = [
|
||||
path.resolve(process.cwd(), envFile), // /app/.env.development (dentro del contenedor)
|
||||
path.resolve(__dirname, '..', envFile), // por si queda un nivel arriba
|
||||
path.resolve(__dirname, envFile), // por si lo ponen junto al src
|
||||
];
|
||||
const found = candidates.find((p) => fs.existsSync(p));
|
||||
if (found) {
|
||||
dotenv.config({ path: found, override: false });
|
||||
console.log(`Activando entorno de -> ${ENV.toUpperCase()} ${chalk.gray(`(${found})`)}`);
|
||||
} else {
|
||||
console.log(`Activando entorno de -> ${ENV.toUpperCase()} (sin archivo .env; usando variables del proceso)`);
|
||||
}
|
||||
|
||||
// === 1) IMPORTS PRINCIPALES
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import expressLayouts from 'express-ejs-layouts';
|
||||
import morgan from 'morgan';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import session from 'express-session';
|
||||
import expressLayouts from 'express-ejs-layouts';
|
||||
import { createClient as createRedisClient } from 'redis';
|
||||
import * as connectRedis from 'connect-redis';
|
||||
import { Pool } from 'pg';
|
||||
import bcrypt from 'bcrypt'; // <- lo conservamos si ya lo usabas
|
||||
import crypto from 'node:crypto'; // <- idem
|
||||
|
||||
// Tolerante a cambios de export en connect-redis
|
||||
// -----------------------------------------------------------------------------
|
||||
// Utilidades base
|
||||
// -----------------------------------------------------------------------------
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const RedisStore = connectRedis.default || connectRedis.RedisStore;
|
||||
|
||||
// === 2) APP y CONFIG BASICA
|
||||
const qi = (ident) => `"${String(ident).replace(/"/g, '""')}"`;
|
||||
const CLEAN_HEX = (s) => (String(s || '').toLowerCase().replace(/[^0-9a-f]/g, '') || null);
|
||||
const REQUIRED = (...keys) => {
|
||||
const miss = keys.filter((k) => !process.env[k]);
|
||||
if (miss.length) {
|
||||
console.warn(`⚠ Faltan variables de entorno: ${miss.join(', ')}`);
|
||||
}
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Validación de entorno mínimo (ajusta nombres si difieren)
|
||||
// -----------------------------------------------------------------------------
|
||||
REQUIRED(
|
||||
// Sesión
|
||||
'SESSION_SECRET', 'REDIS_URL',
|
||||
// DB principal
|
||||
'DB_HOST', 'DB_NAME', 'DB_USER', 'DB_PASS',
|
||||
// DB de tenants
|
||||
'TENANTS_HOST', 'TENANTS_DB', 'TENANTS_USER', 'TENANTS_PASS'
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Pools de PostgreSQL
|
||||
// -----------------------------------------------------------------------------
|
||||
const mainPool = new Pool({
|
||||
host: process.env.DB_HOST,
|
||||
port: Number(process.env.DB_PORT || 5432),
|
||||
database: process.env.DB_NAME,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASS,
|
||||
max: 10,
|
||||
idleTimeoutMillis: 30_000,
|
||||
});
|
||||
|
||||
const tenantsPool = new Pool({
|
||||
host: process.env.TENANTS_HOST,
|
||||
port: Number(process.env.TENANTS_PORT || 5432),
|
||||
database: process.env.TENANTS_DB,
|
||||
user: process.env.TENANTS_USER,
|
||||
password: process.env.TENANTS_PASS,
|
||||
max: 10,
|
||||
idleTimeoutMillis: 30_000,
|
||||
});
|
||||
|
||||
// Autotest (no rompe si falla; sólo loguea)
|
||||
(async () => {
|
||||
try {
|
||||
const c = await mainPool.connect();
|
||||
const r = await c.query('SELECT NOW() now');
|
||||
console.log('[APP] DB principal OK. Hora:', r.rows[0].now);
|
||||
c.release();
|
||||
} catch (e) {
|
||||
console.error('[APP] Error al conectar DB principal:', e.message);
|
||||
}
|
||||
try {
|
||||
const c = await tenantsPool.connect();
|
||||
const r = await c.query('SELECT NOW() now');
|
||||
console.log('[APP] DB tenants OK. Hora:', r.rows[0].now);
|
||||
c.release();
|
||||
} catch (e) {
|
||||
console.error('[APP] Error al conectar DB tenants:', e.message);
|
||||
}
|
||||
})();
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Express + EJS
|
||||
// -----------------------------------------------------------------------------
|
||||
const app = express();
|
||||
app.set('trust proxy', 1);
|
||||
app.use(cors({ origin: true, credentials: true }));
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Vistas EJS (si no usás vistas, puedes dejarlo; no rompe)
|
||||
// Views EJS en ./views
|
||||
app.set('views', path.join(__dirname, 'views'));
|
||||
app.set('view engine', 'ejs');
|
||||
app.use(expressLayouts);
|
||||
app.set('layout', 'layout');
|
||||
app.set("layout", "layouts/main");
|
||||
|
||||
// Estáticos opcionales (ajusta si tu estructura difiere)
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
// Estáticos (si tenés carpeta public/, assets, etc.)
|
||||
app.use('/public', express.static(path.join(__dirname, 'public')));
|
||||
|
||||
// === 3) SESIONES COMPARTIDAS (mismo cookie que AUTH)
|
||||
const SESSION_SECRET = process.env.SESSION_SECRET || 'change-me-in-dev';
|
||||
const REDIS_URL = process.env.REDIS_URL || 'redis://authentik-redis:6379';
|
||||
// Middlewares básicos
|
||||
app.use(morgan('dev'));
|
||||
app.use(cors({ origin: true, credentials: true }));
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
const redis = createRedisClient({ url: REDIS_URL });
|
||||
await redis.connect().catch((e) => {
|
||||
console.warn('⚠ No se pudo conectar a Redis de sesiones:', e?.message || e);
|
||||
// ----------------------------------------------------------
|
||||
// Middleware para datos globales
|
||||
// ----------------------------------------------------------
|
||||
app.use((req, res, next) => {
|
||||
res.locals.currentPath = req.path;
|
||||
res.locals.pageTitle = "SuiteCoffee";
|
||||
res.locals.pageId = "";
|
||||
next();
|
||||
});
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Sesión (Redis) — misma cookie que AUTH
|
||||
// -----------------------------------------------------------------------------
|
||||
const SESSION_COOKIE_NAME = 'sc.sid';
|
||||
const redis = createRedisClient({ url: process.env.REDIS_URL });
|
||||
await redis.connect().catch((e) => console.error('[APP] Redis session error:', e.message));
|
||||
|
||||
app.use(
|
||||
session({
|
||||
name: 'sc.sid', // <- igual que en AUTH
|
||||
name: SESSION_COOKIE_NAME,
|
||||
store: new RedisStore({ client: redis, prefix: 'sess:' }),
|
||||
secret: SESSION_SECRET,
|
||||
secret: process.env.SESSION_SECRET || 'change-me',
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
// domain: 'suitecoffee.mateosaldain.uy', // (opcional) si lo necesitás
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Exponer el usuario a las vistas (no tocar req.session)
|
||||
// Exponer usuario a las vistas
|
||||
app.use((req, res, next) => {
|
||||
res.locals.user = req.session?.user || null;
|
||||
next();
|
||||
});
|
||||
|
||||
// === 4) POOLS A BASES DE DATOS ===
|
||||
// 4.1) Base principal (si la usás en APP). Conservamos variables usadas en el repo.
|
||||
const DB = {
|
||||
host: process.env.DB_HOST || 'dev-db',
|
||||
port: Number(process.env.DB_PORT || 5432),
|
||||
user: process.env.DB_USER || 'dev-user-suitecoffee',
|
||||
password: process.env.DB_PASS || 'dev-pass-suitecoffee',
|
||||
database: process.env.DB_NAME || 'dev-suitecoffee',
|
||||
};
|
||||
export const mainPool = new Pool({ ...DB, max: 10, idleTimeoutMillis: 30_000 });
|
||||
|
||||
async function verificarConexion() {
|
||||
try {
|
||||
const c = await mainPool.connect();
|
||||
const { rows } = await c.query('SELECT NOW() AS now');
|
||||
console.log(`DB principal OK @ ${rows[0].now}`);
|
||||
c.release();
|
||||
} catch (e) {
|
||||
console.warn('⚠ No se pudo verificar DB principal:', e?.message || e);
|
||||
// -----------------------------------------------------------------------------
|
||||
// Middlewares de Auth/Tenant para routes.legacy.js
|
||||
// -----------------------------------------------------------------------------
|
||||
function requireAuth(req, res, next) {
|
||||
if (!req.session?.user) {
|
||||
// Si querés devolver 401 en lugar de redirigir, cambia esta línea
|
||||
return res.redirect('/auth/login');
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
// 4.2) Base multi-tenant (un solo DB con esquemas por tenant)
|
||||
const TENANTS = {
|
||||
host: process.env.TENANTS_HOST || 'dev-tenants',
|
||||
port: Number(process.env.TENANTS_PORT || 5432),
|
||||
user: process.env.TENANTS_USER || 'postgres',
|
||||
password: process.env.TENANTS_PASS || 'postgres',
|
||||
database: process.env.TENANTS_DB || 'dev-postgres',
|
||||
};
|
||||
export const tenantsPool = new Pool({ ...TENANTS, max: 20, idleTimeoutMillis: 30_000 });
|
||||
|
||||
// === 5) MIDDLEWARES DE AUTENTICACIÓN Y TENANT ===
|
||||
export function requireAuth(req, res, next) {
|
||||
if (req.session?.user) return next();
|
||||
// Fallback DEV: permitir si el front envía explícitamente el tenant (para pruebas)
|
||||
if (req.get('x-tenant-uuid')) return next();
|
||||
return res.status(401).json({ error: 'no-auth' });
|
||||
}
|
||||
|
||||
function getTenantUuid(req) {
|
||||
const h = req.get('x-tenant-uuid');
|
||||
if (h) return String(h).replace(/-/g, '');
|
||||
const s = req.session?.user?.tenant_uuid;
|
||||
if (s) return String(s).replace(/-/g, '');
|
||||
throw new Error('Tenant no especificado');
|
||||
}
|
||||
|
||||
export async function withTenant(req, res, next) {
|
||||
const client = await tenantsPool.connect();
|
||||
// Abre un client al DB de tenants y fija search_path al esquema del usuario
|
||||
async function withTenant(req, res, next) {
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const uuid = getTenantUuid(req);
|
||||
const schema = `schema_tenant_${uuid}`;
|
||||
const hex = CLEAN_HEX(req.session?.user?.tenant_uuid);
|
||||
if (!hex) return res.status(400).json({ error: 'tenant-missing' });
|
||||
|
||||
// Si creaste la función en DB: SELECT public.f_set_search_path($1)
|
||||
// await client.query('SELECT public.f_set_search_path($1)', [schema]);
|
||||
await client.query(`SET LOCAL search_path TO ${schema.replace(/"/g, '')}`);
|
||||
const schema = `schema_tenant_${hex}`;
|
||||
const client = await tenantsPool.connect();
|
||||
|
||||
// Fijar search_path para que las consultas apunten al esquema del tenant
|
||||
await client.query(`SET SESSION search_path TO ${qi(schema)}, public`);
|
||||
|
||||
// Hacemos el client accesible para los handlers de routes.legacy.js
|
||||
req.pg = client;
|
||||
req.pgSchema = schema;
|
||||
|
||||
// Liberar el client al finalizar la respuesta
|
||||
const release = () => {
|
||||
try { client.release(); } catch {}
|
||||
};
|
||||
res.on('finish', release);
|
||||
res.on('close', release);
|
||||
|
||||
next();
|
||||
} catch (e) {
|
||||
try { await client.query('ROLLBACK'); } catch {}
|
||||
client.release();
|
||||
return res.status(400).json({ error: e.message });
|
||||
next(e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function done(req, res, next) {
|
||||
try {
|
||||
if (req.pg) await req.pg.query('COMMIT');
|
||||
} catch (e) {
|
||||
try { if (req.pg) await req.pg.query('ROLLBACK'); } catch {}
|
||||
} finally {
|
||||
if (req.pg) req.pg.release();
|
||||
// No-op (compatibilidad con el archivo legacy si lo pasa al final)
|
||||
function done(_req, _res, next) { return next && next(); }
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Home / Landing
|
||||
// -----------------------------------------------------------------------------
|
||||
app.get('/', (req, res) => {
|
||||
if (req.session?.user) {
|
||||
return res.redirect('/comandas'); // ya logueado → dashboard
|
||||
}
|
||||
next?.();
|
||||
}
|
||||
return res.render('login', { pageTitle: 'Iniciar sesión' });
|
||||
});
|
||||
|
||||
// === 6) RUTAS BÁSICAS / HEALTH ===
|
||||
app.get('/health', (_req, res) => res.status(200).json({ status: 'ok' }));
|
||||
app.get('/api/health', (_req, res) => res.status(200).json({ status: 'ok' }));
|
||||
// Página para definir contraseña (el form envía al servicio AUTH)
|
||||
app.get('/set-password', (req, res) => {
|
||||
const pp = req.session?.pendingPassword;
|
||||
if (!pp) return req.session?.user ? res.redirect('/comandas') : res.redirect('/auth/login');
|
||||
|
||||
// === 7) MONTAJE AUTOMÁTICO DE ENDPOINTS LEGACY ===
|
||||
// Para NO PERDER NADA de tu archivo original:
|
||||
// 1) Crea services/app/src/routes.legacy.js y exporta por defecto una función:
|
||||
// export default function mount(app, ctx) { /* pega aquí TODOS tus app.get/post/... */ }
|
||||
// // ctx trae: { requireAuth, withTenant, done, mainPool, tenantsPool, express }
|
||||
// 2) O exporta un Router en routes.legacy.js como: export const router = Router();
|
||||
// 3) Este bloque intentará montarlo si existe.
|
||||
try {
|
||||
const legacy = await import('./routes.legacy.js');
|
||||
if (legacy?.default) {
|
||||
legacy.default(app, { requireAuth, withTenant, done, mainPool, tenantsPool, express });
|
||||
console.log('✔ Endpoints legacy montados (función default)');
|
||||
} else if (legacy?.router) {
|
||||
app.use(legacy.router);
|
||||
console.log('✔ Endpoints legacy montados (router)');
|
||||
}
|
||||
} catch {
|
||||
console.log('ℹ No se encontró routes.legacy.js; continúa sólo con las rutas nuevas');
|
||||
}
|
||||
res.type('html').send(`
|
||||
<!doctype html><meta charset="utf-8">
|
||||
<title>SuiteCoffee · Definir contraseña</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||
<div class="container py-5" style="max-width:520px;">
|
||||
<h2 class="mb-4">Definir contraseña</h2>
|
||||
<form method="post" action="/auth/password/set" class="vstack gap-3">
|
||||
<input class="form-control" type="password" name="password" placeholder="Nueva contraseña" minlength="8" required>
|
||||
<input class="form-control" type="password" name="password2" placeholder="Repetí la contraseña" minlength="8" required>
|
||||
<button class="btn btn-primary" type="submit">Guardar y continuar</button>
|
||||
<small class="text-muted">Luego te redirigiremos a iniciar sesión por SSO.</small>
|
||||
</form>
|
||||
</div>
|
||||
`);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Montar rutas legacy (render de EJS y APIs de negocio)
|
||||
// -----------------------------------------------------------------------------
|
||||
const legacy = await import('./routes.legacy.js');
|
||||
legacy.default(app, {
|
||||
requireAuth,
|
||||
withTenant,
|
||||
done,
|
||||
mainPool,
|
||||
tenantsPool,
|
||||
express,
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Health + 404 + errores
|
||||
// -----------------------------------------------------------------------------
|
||||
app.get('/health', (_req, res) => res.status(200).json({ status: 'ok', service: 'app' }));
|
||||
|
||||
// === 8) 404 + MANEJO DE ERRORES ===
|
||||
app.use((req, res) => res.status(404).json({ error: 'not-found', path: req.originalUrl }));
|
||||
|
||||
app.use((err, _req, res, _next) => {
|
||||
console.error('❌ Error APP:', err);
|
||||
console.error('[APP] Error:', err);
|
||||
if (res.headersSent) return;
|
||||
res.status(500).json({ error: 'internal-error', detail: err?.message || String(err) });
|
||||
});
|
||||
|
||||
// === 9) ARRANQUE ===
|
||||
const PORT = Number(process.env.APP_LOCAL_PORT || process.env.PORT || 4000);
|
||||
(async () => {
|
||||
console.log(`Entorno -> ${ENV.toUpperCase()} | Puerto -> ${PORT}`);
|
||||
await verificarConexion();
|
||||
app.listen(PORT, () => console.log(`SuiteCoffee APP escuchando en ${chalk.yellow(`http://localhost:${PORT}`)}`));
|
||||
})();
|
||||
// -----------------------------------------------------------------------------
|
||||
// Arranque
|
||||
// -----------------------------------------------------------------------------
|
||||
const PORT = Number(process.env.PORT || process.env.APP_LOCAL_PORT || 3030);
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[APP] SuiteCoffee corriendo en http://localhost:${PORT}`);
|
||||
});
|
||||
|
||||
103
services/auth/.env.development
Normal file
103
services/auth/.env.development
Normal file
@ -0,0 +1,103 @@
|
||||
# ===== Runtime =====
|
||||
NODE_ENV=development
|
||||
PORT=4040
|
||||
AUTH_LOCAL_PORT=4040 # coincide con 'expose' del servicio auth
|
||||
|
||||
# ===== Session (usa el Redis del stack) =====
|
||||
# Para DEV podemos reutilizar el Redis de Authentik. En prod conviene uno separado.
|
||||
SESSION_SECRET=pon-una-clave-larga-y-unica
|
||||
REDIS_URL=redis://authentik-redis:6379
|
||||
|
||||
# ===== DB principal (metadatos de SuiteCoffee) =====
|
||||
# Usa el alias de red del servicio 'db' (compose: aliases [dev-db])
|
||||
DB_HOST=dev-db
|
||||
DB_PORT=5432
|
||||
DB_NAME=dev-suitecoffee
|
||||
DB_USER=dev-user-suitecoffee
|
||||
DB_PASS=dev-pass-suitecoffee
|
||||
|
||||
# ===== DB tenants (Tenants de SuiteCoffee) =====
|
||||
TENANTS_HOST=dev-tenants
|
||||
TENANTS_DB=dev-postgres
|
||||
TENANTS_USER=dev-user-postgres
|
||||
TENANTS_PASS=dev-pass-postgres
|
||||
TENANTS_PORT=5432
|
||||
|
||||
TENANT_INIT_SQL=/home/mateo/SuiteCoffee/services/auth/src/db/initTenant.sql
|
||||
# TENANT_INIT_SQL=~/SuiteCoffee/services/app/src/db/01_init.sql
|
||||
|
||||
# ===== (Opcional) Colores UI, si alguna vista los lee =====
|
||||
COL_PRI=452D19 # Marrón oscuro
|
||||
COL_SEC=D7A666 # Crema / Café
|
||||
COL_BG=FFA500 # Naranja
|
||||
|
||||
# ===== Authentik — Admin API (server-to-server dentro de la red) =====
|
||||
# Usa el alias de red del servicio 'authentik' y su puerto interno 9000
|
||||
AUTHENTIK_BASE_URL=http://authentik:9000
|
||||
AUTHENTIK_TOKEN=eE3bFTLd4Rpt3ZkcidTC1EppDYMIr023ev3SXt4ImHynOfAGRVtAZVBXSNxj
|
||||
AUTHENTIK_DEFAULT_GROUP_NAME=suitecoffee-users
|
||||
|
||||
# ===== OIDC (DEBE coincidir con el Provider) =====
|
||||
# DEV (todo dentro de la red de Docker):
|
||||
# - El auth service redirige al navegador a este issuer. Si NO tenés reverse proxy hacia Authentik,
|
||||
# esta URL interna NO será accesible desde el navegador del host. En ese caso, ver nota más abajo.
|
||||
OIDC_ISSUER=https://authentik.suitecoffee.mateosaldain.uy/application/o/suitecoffee/
|
||||
OIDC_CLIENT_ID=ydnp9s9I7G4p9Pwt5OsNlcpk1VKB9auN7AxqqNjC
|
||||
OIDC_CLIENT_SECRET=yqdI00kYMeQF8VdmhwN5QWUzPLUzRBYeeAH193FynuVD19mo1nBRf5c5IRojzPrxDS0Hk33guUwHFzaj8vjTbTRetwK528uNJ6BfrYGUN2vzxgdMHFLQOHSTR0gR1LtG
|
||||
|
||||
# Redirect URI que definiste en el Provider. Usa el alias de red del servicio 'auth' (dev-auth)
|
||||
# Si accedés desde el host sin proxy, usa mejor http://localhost:4040/auth/callback y añadilo al Provider.
|
||||
OIDC_REDIRECT_URI=https://suitecoffee.mateosaldain.uy/auth/callback
|
||||
|
||||
# Cómo querés que maneje la contraseña Authentik para usuarios NUEVOS creados por tu backend:
|
||||
# - TEMP_FORCE_CHANGE: crea un password temporal y obliga a cambiar en el primer login (recomendado si usás login con usuario/clave)
|
||||
# - INVITE_LINK: envías/entregás un link de “establecer contraseña” (necesita flow de Enrollment/Recovery y SMTP configurado)
|
||||
# - SSO_ONLY: no setea password local; login solo por Google/Microsoft/WebAuthn
|
||||
AK_PASSWORD_MODE=TEMP_FORCE_CHANGE
|
||||
|
||||
# (Opcional) longitud del password temporal
|
||||
AK_TEMP_PW_LENGTH=12
|
||||
|
||||
|
||||
# 3) Configuración en Authentik (por modo)
|
||||
# A) TEMP_FORCE_CHANGE (password temporal + cambio obligado)
|
||||
# Flow de Autenticación
|
||||
# Entra al Admin de Authentik → Flows → tu Authentication Flow (el que usa tu Provider OIDC).
|
||||
# Asegurate de que tenga:
|
||||
# Identification Stage (identifica por email/username),
|
||||
# Password Stage (para escribir contraseña).
|
||||
# Con eso, cuando el usuario entre con la clave temporal, Authentik le pedirá cambiarla.
|
||||
# Provider OIDC (suitecoffee)
|
||||
# Admin → Applications → Providers → tu provider de SuiteCoffee → Flow settings
|
||||
# Authentication flow: seleccioná el de arriba.
|
||||
# (Opcional) Email SMTP
|
||||
# Si querés notificar o enviar contraseñas temporales/enlaces desde Authentik, configura SMTP en Admin → System → Email.
|
||||
# Resultado: el usuario se registra en tu app → lo redirigís a /auth/login → Authentik pide email+clave → entra con la temporal → obliga a cambiarla → vuelve a tu app.
|
||||
|
||||
# B) INVITE_LINK (enlace de “establecer contraseña”)
|
||||
# SMTP
|
||||
# Admin → System → Email: configura SMTP (host, puerto, credenciales, remitente).
|
||||
# Flow de Enrollment/Recovery
|
||||
# Admin → Flows → clona/crea un flow de Enrollment/Recovery con:
|
||||
# Identification Stage (email/username),
|
||||
# Email Stage (envía el link con token),
|
||||
# Password Stage (para que defina su clave),
|
||||
# (opcional) Prompt/ User Write para confirmar.
|
||||
# Guardalo con un Slug fácil (ej. enroll-set-password).
|
||||
# Cómo usarlo
|
||||
# Caminos:
|
||||
# Manual desde UI: Admin → Directory → Invitations → crear invitación, elegir Flow enroll-set-password, seleccionar usuario, copiar link y enviar.
|
||||
# Automático (más adelante): podemos automatizar por API la creación de una Invitation y envío de mail. (Si querés, te armo el helper akCreateInvitation(userUUID, flowSlug).)
|
||||
# Resultado: el registro en tu app no pone password; el usuario recibe un link para establecer la clave y desde ahí inicia normalmente.
|
||||
|
||||
# C) SSO_ONLY (sin contraseñas locales)
|
||||
# Configura un Source (Google Workspace / Microsoft Entra / WebAuthn):
|
||||
# Admin → Directory → Sources: crea el Source (por ejemplo, Google OAuth o Entra ID).
|
||||
# Activa Create users (para que se creen en Authentik si no existen).
|
||||
# Mapea email y name.
|
||||
# Authentication Flow
|
||||
# Agrega una Source Stage del proveedor (Google/Microsoft/WebAuthn) en tu Authentication Flow.
|
||||
# (Podés dejar Password Stage deshabilitado si querés solo SSO.)
|
||||
# Provider OIDC
|
||||
# En tu Provider suitecoffee, seleccioná ese Authentication Flow.
|
||||
# Resultado: el usuario se registra en tu app → al entrar a /auth/login ve botón Iniciar con Google/Microsoft → hace click, vuelve con sesión, tu backend setea sc.sid.
|
||||
22
services/auth/.env.production
Normal file
22
services/auth/.env.production
Normal file
@ -0,0 +1,22 @@
|
||||
NODE_ENV=production # Entorno de desarrollo
|
||||
|
||||
PORT=4000 # Variables del servicio -> suitecoffee-app
|
||||
|
||||
# AUTH_HOST=prod-auth
|
||||
|
||||
DB_HOST=prod-db
|
||||
# Nombre de la base de datos
|
||||
DB_NAME=suitecoffee
|
||||
|
||||
# Usuario y contraseña
|
||||
DB_USER=suitecoffee
|
||||
DB_PASS=suitecoffee
|
||||
|
||||
# Puertos del servicio de db
|
||||
DB_LOCAL_PORT=5432
|
||||
DB_DOCKER_PORT=5432
|
||||
|
||||
# Colores personalizados
|
||||
COL_PRI=452D19 # Marrón oscuro
|
||||
COL_SEC=D7A666 # Crema / Café
|
||||
COL_BG=FFA500 # Naranja
|
||||
@ -23,6 +23,29 @@ function getConfig() {
|
||||
// ------------------------------------------------------------
|
||||
// Helpers de sincronización
|
||||
// ------------------------------------------------------------
|
||||
|
||||
// -- util GET contra la API admin (ajusta si ya tenés un helper igual)
|
||||
async function akGET(path) {
|
||||
const base = (process.env.AUTHENTIK_BASE_URL || '').replace(/\/+$/, '');
|
||||
const url = `${base}${path}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${process.env.AUTHENTIK_TOKEN}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`AK GET ${path} -> ${res.status}: ${body}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// -- listar grupos con búsqueda por nombre/slug
|
||||
export async function akListGroups(search = '') {
|
||||
const q = search ? `?search=${encodeURIComponent(search)}` : '';
|
||||
const data = await akGET(`/api/v3/core/groups/${q}`);
|
||||
// algunas versiones devuelven {results:[]}, otras un array directo
|
||||
return Array.isArray(data) ? data : (data.results || []);
|
||||
}
|
||||
|
||||
export async function akPatchUserAttributes(userPk, partialAttrs = {}) {
|
||||
// PATCH del usuario para asegurar attributes.tenant_uuid
|
||||
return akRequest('patch', `/api/v3/core/users/${userPk}/`, {
|
||||
@ -196,18 +219,46 @@ export async function akSetPassword(userPk, password, requireChange = true) {
|
||||
* Helper opcional para obtener grupos por nombre/slug si en el futuro lo necesitas
|
||||
* (no usado por index.js; se deja por conveniencia).
|
||||
*/
|
||||
export async function akListGroups(search) {
|
||||
const data = await request('GET', '/core/groups/', { qs: { search, page_size: 50 }, retries: 2 });
|
||||
return Array.isArray(data?.results) ? data.results : [];
|
||||
}
|
||||
|
||||
export async function akResolveGroupIdByName(name) {
|
||||
const data = await akListGroups(name);
|
||||
const lower = name.toLowerCase();
|
||||
const found = data.find(g => (g.name || '').toLowerCase() === lower || (g.slug || '').toLowerCase() === lower);
|
||||
return found?.pk || null;
|
||||
const lower = String(name || '').toLowerCase();
|
||||
const found = data.find(g =>
|
||||
String(g.name || '').toLowerCase() === lower ||
|
||||
String(g.slug || '').toLowerCase() === lower
|
||||
);
|
||||
return found?.pk ?? null;
|
||||
}
|
||||
|
||||
export async function akResolveGroupId({ id, pk, uuid, name, slug } = {}) {
|
||||
// si te pasan pk/id directo, devolvelo
|
||||
if (pk != null) return Number(pk);
|
||||
if (id != null) return Number(id);
|
||||
|
||||
// por UUID (devuelve objeto con pk)
|
||||
if (uuid) {
|
||||
try {
|
||||
const g = await akGET(`/api/v3/core/groups/${encodeURIComponent(uuid)}/`);
|
||||
if (g?.pk != null) return Number(g.pk);
|
||||
} catch (e) {
|
||||
// sigue intentando por nombre/slug
|
||||
}
|
||||
}
|
||||
|
||||
// por nombre/slug
|
||||
if (name || slug) {
|
||||
const needle = (name || slug);
|
||||
const list = await akListGroups(needle);
|
||||
const lower = String(needle || '').toLowerCase();
|
||||
const found = list.find(g =>
|
||||
String(g.name || '').toLowerCase() === lower ||
|
||||
String(g.slug || '').toLowerCase() === lower
|
||||
);
|
||||
if (found?.pk != null) return Number(found.pk);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Fin
|
||||
|
||||
2239
services/auth/src/db/initTenant.sql
Normal file
2239
services/auth/src/db/initTenant.sql
Normal file
File diff suppressed because it is too large
Load Diff
@ -13,6 +13,7 @@ import chalk from 'chalk';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Pool } from 'pg';
|
||||
import session from 'express-session';
|
||||
@ -21,13 +22,14 @@ import * as connectRedis from 'connect-redis';
|
||||
import expressLayouts from 'express-ejs-layouts';
|
||||
import { Issuer, generators } from 'openid-client';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
const SESSION_COOKIE_NAME = 'sc.sid';
|
||||
|
||||
// Normaliza UUID (acepta con/sin guiones) → "hex" sin guiones
|
||||
const cleanUuid = (u) => (u ? String(u).toLowerCase().replace(/[^0-9a-f]/g, '') : '');
|
||||
|
||||
@ -36,12 +38,22 @@ const schemaNameFor = (uuidHex) => `schema_tenant_${uuidHex}`;
|
||||
const roleNameFor = (uuidHex) => `tenant_${uuidHex}`;
|
||||
|
||||
// Helpers de Authentik (admin API)
|
||||
const { akFindUserByEmail, akCreateUser, akSetPassword } = await import('./ak.js');
|
||||
const {
|
||||
akFindUserByEmail,
|
||||
akCreateUser,
|
||||
akSetPassword,
|
||||
akResolveGroupId
|
||||
} = await import('./ak.js');
|
||||
|
||||
// Quoter seguro de identificadores SQL (roles, schemas, tablas)
|
||||
// Identificador SQL (schema, role, table, …)
|
||||
const qi = (ident) => `"${String(ident).replace(/"/g, '""')}"`;
|
||||
const qs = (str) => `'${String(str).replace(/'/g, "''")}'`; // quote string literal seguro
|
||||
const VALID_IDENT = /^[a-z_][a-z0-9_]*$/i;
|
||||
|
||||
// Literal de texto SQL (valores: contraseñas, strings, …)
|
||||
const qs = (val) => `'${String(val).replace(/'/g, "''")}'`;
|
||||
|
||||
|
||||
const VALID_IDENT = /^[a-zA-Z_][a-zA-Z0-9_:$-]*$/;
|
||||
|
||||
// --- Resolver y cachear el grupo por ID/UUID/NOMBRE una sola vez ---
|
||||
let DEFAULT_GROUP_ID = process.env.AUTHENTIK_DEFAULT_GROUP_ID
|
||||
@ -66,6 +78,26 @@ if (!DEFAULT_GROUP_ID) {
|
||||
})();
|
||||
}
|
||||
|
||||
function nukeSession(req, res, redirectTo = '/auth/login', reason = 'reset') {
|
||||
try {
|
||||
// Destruye la sesión en el store (Redis)
|
||||
req.session?.destroy(() => {
|
||||
// Limpia la cookie en el navegador
|
||||
res.clearCookie(SESSION_COOKIE_NAME, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
});
|
||||
// Reinicia el flujo
|
||||
return res.redirect(303, `${redirectTo}?reason=${encodeURIComponent(reason)}`);
|
||||
});
|
||||
} catch {
|
||||
// Si algo falla, al menos intentamos redirigir
|
||||
return res.redirect(303, `${redirectTo}?reason=${encodeURIComponent(reason)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Verificar existencia del tenant sin crear (en la DB de tenants)
|
||||
async function tenantExists(uuidHex) {
|
||||
if (!uuidHex) return false;
|
||||
@ -87,12 +119,12 @@ async function tenantExists(uuidHex) {
|
||||
// 2) Authentik (attributes.tenant_uuid del usuario)
|
||||
// 3) valor provisto en el request (si viene)
|
||||
async function resolveExistingTenantUuid({ email, requestedTenantUuid }) {
|
||||
const emailLower = String(email).toLowerCase();
|
||||
const normEmail = String(email).trim().toLowerCase();
|
||||
|
||||
// 1) DB principal
|
||||
const dbRes = await pool.query(
|
||||
'SELECT tenant_uuid FROM app_user WHERE LOWER(email)=LOWER($1) LIMIT 1',
|
||||
[emailLower]
|
||||
[normEmail]
|
||||
);
|
||||
if (dbRes.rowCount) {
|
||||
const fromDb = cleanUuid(dbRes.rows[0].tenant_uuid);
|
||||
@ -100,7 +132,7 @@ async function resolveExistingTenantUuid({ email, requestedTenantUuid }) {
|
||||
}
|
||||
|
||||
// 2) Authentik
|
||||
const akUser = await akFindUserByEmail(emailLower).catch(() => null);
|
||||
const akUser = await akFindUserByEmail(normEmail).catch(() => null);
|
||||
const fromAk = cleanUuid(akUser?.attributes?.tenant_uuid);
|
||||
if (fromAk) return fromAk;
|
||||
|
||||
@ -113,57 +145,198 @@ async function resolveExistingTenantUuid({ email, requestedTenantUuid }) {
|
||||
|
||||
// Helper para crear tenant si falta
|
||||
async function ensureTenant({ tenant_uuid }) {
|
||||
const client = await tenantsPool.connect();
|
||||
const admin = await tenantsPool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await admin.query('BEGIN');
|
||||
|
||||
// Si no vino UUID, generamos uno
|
||||
let uuid = (tenant_uuid || crypto.randomUUID()).toLowerCase();
|
||||
const uuidNoHyphen = uuid.replace(/-/g, '');
|
||||
// uuid y nombres
|
||||
const uuid = (tenant_uuid || crypto.randomUUID()).toLowerCase();
|
||||
const hex = uuid.replace(/-/g, '');
|
||||
if (!/^[a-f0-9]{32}$/.test(hex)) throw new Error('tenant_uuid inválido');
|
||||
|
||||
const schema = `schema_tenant_${uuidNoHyphen}`;
|
||||
const role = `tenant_${uuidNoHyphen}`;
|
||||
const pwd = crypto.randomBytes(18).toString('base64url'); // password del rol
|
||||
const schema = `schema_tenant_${hex}`;
|
||||
const role = `tenant_${hex}`;
|
||||
const pwd = crypto.randomBytes(18).toString('base64url');
|
||||
|
||||
if (!VALID_IDENT.test(schema) || !VALID_IDENT.test(role)) {
|
||||
throw new Error('Identificador de schema/rol inválido');
|
||||
}
|
||||
|
||||
// 1) Crear ROL si no existe
|
||||
const { rowCount: hasRole } = await client.query(
|
||||
'SELECT 1 FROM pg_roles WHERE rolname=$1',
|
||||
[role]
|
||||
);
|
||||
if (!hasRole) {
|
||||
// Para el identificador usamos qi(); el password sí va parametrizado
|
||||
await client.query(`CREATE ROLE ${qi(role)} LOGIN PASSWORD ${qs(pwd)}`);
|
||||
// 1) Crear ROL si no existe (PASSWORD debe ser LITERAL, no parámetro)
|
||||
const r = await admin.query('SELECT 1 FROM pg_roles WHERE rolname=$1', [role]);
|
||||
if (!r.rowCount) {
|
||||
await admin.query(`CREATE ROLE ${qi(role)} LOGIN PASSWORD ${qs(pwd)}`);
|
||||
// Si quisieras rotarla luego:
|
||||
// await admin.query(`ALTER ROLE ${qi(role)} PASSWORD ${qs(pwd)}`);
|
||||
}
|
||||
|
||||
// 2) Crear SCHEMA si no existe y asignar owner al rol del tenant
|
||||
const { rowCount: hasSchema } = await client.query(
|
||||
// 2) Crear SCHEMA si no existe y asignar owner
|
||||
const s = await admin.query(
|
||||
'SELECT 1 FROM information_schema.schemata WHERE schema_name=$1',
|
||||
[schema]
|
||||
);
|
||||
if (!hasSchema) {
|
||||
await client.query(`CREATE SCHEMA ${qi(schema)} AUTHORIZATION ${qi(role)}`);
|
||||
if (!s.rowCount) {
|
||||
await admin.query(`CREATE SCHEMA ${qi(schema)} AUTHORIZATION ${qi(role)}`);
|
||||
} else {
|
||||
await admin.query(`ALTER SCHEMA ${qi(schema)} OWNER TO ${qi(role)}`);
|
||||
}
|
||||
|
||||
// 3) Permisos mínimos para el rol del tenant en su schema
|
||||
await client.query(`GRANT USAGE ON SCHEMA ${qi(schema)} TO ${qi(role)}`);
|
||||
await client.query(
|
||||
// 3) Permisos por defecto
|
||||
await admin.query(`GRANT USAGE ON SCHEMA ${qi(schema)} TO ${qi(role)}`);
|
||||
await admin.query(
|
||||
`ALTER DEFAULT PRIVILEGES IN SCHEMA ${qi(schema)}
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${qi(role)}`
|
||||
);
|
||||
await client.query(
|
||||
await admin.query(
|
||||
`ALTER DEFAULT PRIVILEGES IN SCHEMA ${qi(schema)}
|
||||
GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO ${qi(role)}`
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
// 4) Aplicar 01_init.sql en la misma transacción
|
||||
const initSql = await loadInitSql(); // tu caché/loader actual
|
||||
if (initSql && initSql.trim()) {
|
||||
await admin.query(`SET LOCAL search_path TO ${qi(schema)}, public`);
|
||||
await admin.query(initSql);
|
||||
}
|
||||
|
||||
await admin.query('COMMIT');
|
||||
return { tenant_uuid: uuid, schema, role, role_password: pwd };
|
||||
} catch (e) {
|
||||
try { await admin.query('ROLLBACK'); } catch {}
|
||||
throw e;
|
||||
} finally {
|
||||
admin.release();
|
||||
}
|
||||
}
|
||||
|
||||
// async function ensureTenant({ tenant_uuid }) {
|
||||
// const client = await tenantsPool.connect();
|
||||
// try {
|
||||
// await client.query('BEGIN');
|
||||
|
||||
// // Si no vino UUID, generamos uno
|
||||
// let uuid = (tenant_uuid || crypto.randomUUID()).toLowerCase();
|
||||
// const uuidNoHyphen = uuid.replace(/-/g, '');
|
||||
|
||||
// const schema = `schema_tenant_${uuidNoHyphen}`;
|
||||
// const role = `tenant_${uuidNoHyphen}`;
|
||||
// const pwd = crypto.randomBytes(18).toString('base64url'); // password del rol
|
||||
|
||||
// if (!VALID_IDENT.test(schema) || !VALID_IDENT.test(role)) {
|
||||
// throw new Error('Identificador de schema/rol inválido');
|
||||
// }
|
||||
|
||||
// // 1) Crear ROL si no existe
|
||||
// const { rowCount: hasRole } = await client.query(
|
||||
// 'SELECT 1 FROM pg_roles WHERE rolname=$1',
|
||||
// [role]
|
||||
// );
|
||||
// if (!hasRole) {
|
||||
// // Para el identificador usamos qi(); el password sí va parametrizado
|
||||
// await client.query(`CREATE ROLE ${qi(role)} LOGIN PASSWORD ${qs(pwd)}`);
|
||||
// }
|
||||
|
||||
// // 2) Crear SCHEMA si no existe y asignar owner al rol del tenant
|
||||
// const { rowCount: hasSchema } = await client.query(
|
||||
// 'SELECT 1 FROM information_schema.schemata WHERE schema_name=$1',
|
||||
// [schema]
|
||||
// );
|
||||
// if (!hasSchema) {
|
||||
// await client.query(`CREATE SCHEMA ${qi(schema)} AUTHORIZATION ${qi(role)}`);
|
||||
// }
|
||||
|
||||
// // 3) Permisos mínimos para el rol del tenant en su schema
|
||||
// await client.query(`GRANT USAGE ON SCHEMA ${qi(schema)} TO ${qi(role)}`);
|
||||
// await client.query(
|
||||
// `ALTER DEFAULT PRIVILEGES IN SCHEMA ${qi(schema)}
|
||||
// GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${qi(role)}`
|
||||
// );
|
||||
// await client.query(
|
||||
// `ALTER DEFAULT PRIVILEGES IN SCHEMA ${qi(schema)}
|
||||
// GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO ${qi(role)}`
|
||||
// );
|
||||
|
||||
// await client.query('COMMIT');
|
||||
|
||||
// // 4) Inicialización del esquema con 01_init.sql (solo si está vacío)
|
||||
// try {
|
||||
// await initializeTenantSchemaIfEmpty(client, schema);
|
||||
// } catch (e) {
|
||||
// // Podés decidir si esto es fatal o "best-effort".
|
||||
// // Si querés cortar el alta cuando falla la init, usa: throw e;
|
||||
// console.warn(`[TENANT INIT] Falló inicialización de ${schema}:`, e?.message || e);
|
||||
// }
|
||||
|
||||
// return { tenant_uuid: uuid, schema, role, role_password: pwd };
|
||||
// } catch (e) {
|
||||
// try { await client.query('ROLLBACK'); } catch {}
|
||||
// throw e;
|
||||
// } finally {
|
||||
// client.release();
|
||||
// }
|
||||
// }
|
||||
|
||||
// Carga el 01_init.sql del disco, elimina BEGIN/COMMIT y sustituye el schema.
|
||||
|
||||
let _cachedInitSql = null;
|
||||
async function loadInitSql() {
|
||||
if (_cachedInitSql !== null) return _cachedInitSql;
|
||||
const candidates = [
|
||||
process.env.TENANT_INIT_SQL, // recomendado via .env
|
||||
path.resolve(__dirname, 'db', 'initTenant.sql'),
|
||||
path.resolve(__dirname, '..', 'src', 'db', 'initTenant.sql'),
|
||||
].filter(Boolean);
|
||||
for (const p of candidates) {
|
||||
try {
|
||||
await fs.promises.access(p, fs.constants.R_OK);
|
||||
const txt = await readFile(p, 'utf8');
|
||||
_cachedInitSql = String(txt || '');
|
||||
console.log(`[TENANT INIT] initTenant.sql: ${p} (${_cachedInitSql.length} bytes)`);
|
||||
return _cachedInitSql;
|
||||
} catch {}
|
||||
}
|
||||
console.warn('[TENANT INIT] initTenant.sql no encontrado (se omitirá).');
|
||||
_cachedInitSql = '';
|
||||
return _cachedInitSql;
|
||||
}
|
||||
|
||||
async function isSchemaEmpty(client, schema) {
|
||||
const { rows } = await client.query(
|
||||
`SELECT COUNT(*)::int AS c
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = $1`,
|
||||
[schema]
|
||||
);
|
||||
return rows[0].c === 0;
|
||||
}
|
||||
|
||||
/** Ejecuta 01_init.sql para un tenant (solo si el esquema está vacío). */
|
||||
async function initializeTenantSchemaIfEmpty(schema) {
|
||||
const sql = await loadInitSql();
|
||||
if (!sql || !sql.trim()) {
|
||||
console.warn(`[TENANT INIT] Esquema ${schema}: 01_init.sql vacío/no disponible. Salteando.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await tenantsPool.connect();
|
||||
try {
|
||||
// No usamos LOCAL: queremos que el search_path persista en esta conexión mientras dura el script
|
||||
await client.query('BEGIN');
|
||||
await client.query(`SET search_path TO ${qi(schema)}, public`);
|
||||
|
||||
const empty = await isSchemaEmpty(client, schema);
|
||||
if (!empty) {
|
||||
await client.query('ROLLBACK');
|
||||
console.log(`[TENANT INIT] Esquema ${schema}: ya tiene tablas. No se aplica 01_init.sql.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await client.query(sql); // acepta múltiples sentencias separadas por ';'
|
||||
await client.query('COMMIT');
|
||||
console.log(`[TENANT INIT] Esquema ${schema}: 01_init.sql aplicado.`);
|
||||
} catch (e) {
|
||||
try { await client.query('ROLLBACK'); } catch {}
|
||||
console.error(`[TENANT INIT] Error aplicando 01_init.sql sobre ${schema}:`, e.message);
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
@ -191,6 +364,13 @@ function onFatal(err, msg = 'Error fatal') {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function genTempPassword(len = 12) {
|
||||
const base = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@$%';
|
||||
let out = '';
|
||||
for (let i = 0; i < len; i++) out += base[Math.floor(Math.random() * base.length)];
|
||||
return out;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Configuración Express
|
||||
// -----------------------------------------------------------------------------
|
||||
@ -217,7 +397,7 @@ await redis.connect().catch((e) => onFatal(e, 'No se pudo conectar a Redis (sesi
|
||||
|
||||
app.use(
|
||||
session({
|
||||
name: 'sc.sid',
|
||||
name: SESSION_COOKIE_NAME,
|
||||
store: new RedisStore({ client: redis, prefix: 'sess:' }),
|
||||
secret: process.env.SESSION_SECRET || 'change-me',
|
||||
resave: false,
|
||||
@ -248,7 +428,6 @@ const tenantsPool = new Pool({
|
||||
max: 10,
|
||||
});
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// PostgreSQL — DB principal (metadatos de negocio)
|
||||
// -----------------------------------------------------------------------------
|
||||
@ -328,85 +507,132 @@ let oidcClient;
|
||||
}
|
||||
})();
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Vistas
|
||||
// -----------------------------------------------------------------------------
|
||||
app.get('/', (req, res) => res.render('login', { pageTitle: 'Iniciar sesión' }));
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Rutas OIDC
|
||||
// -----------------------------------------------------------------------------
|
||||
app.get('/auth/login', (req, res) => {
|
||||
const code_verifier = generators.codeVerifier();
|
||||
const code_challenge = generators.codeChallenge(code_verifier);
|
||||
const state = generators.state();
|
||||
// req.session.code_verifier = code_verifier;
|
||||
app.get('/auth/login', (req, res, next) => {
|
||||
try {
|
||||
|
||||
// guarda todo lo necesario para el callback
|
||||
req.session.code_verifier = code_verifier;
|
||||
req.session.state = state;
|
||||
if (req.session?.oidc) {
|
||||
return nukeSession(req, res, '/auth/login', 'stale_oidc');
|
||||
}
|
||||
|
||||
// log de depuración
|
||||
console.log('[OIDC] start login sid=%s state=%s', req.sessionID, state)
|
||||
const code_verifier = generators.codeVerifier();
|
||||
const code_challenge = generators.codeChallenge(code_verifier);
|
||||
|
||||
const url = oidcClient.authorizationUrl({
|
||||
scope: 'openid email profile',
|
||||
code_challenge,
|
||||
code_challenge_method: 'S256',
|
||||
state,
|
||||
});
|
||||
console.log('[OIDC] auth URL has state? %s', url.includes(`state=${state}`));
|
||||
return res.redirect(url);
|
||||
// Podés usar generators.state() y generators.nonce(); ambas son válidas
|
||||
const state = generators.state(); // crypto.randomBytes(24).toString('base64url') también sirve
|
||||
const nonce = generators.nonce();
|
||||
|
||||
|
||||
|
||||
// Guardamos TODO dentro de un objeto para evitar claves sueltas
|
||||
req.session.oidc = { code_verifier, state, nonce };
|
||||
|
||||
// Guardar sesión ANTES de redirigir
|
||||
req.session.save((err) => {
|
||||
if (err) return next(err);
|
||||
|
||||
const url = oidcClient.authorizationUrl({
|
||||
scope: 'openid profile email offline_access',
|
||||
code_challenge,
|
||||
code_challenge_method: 'S256',
|
||||
state,
|
||||
nonce,
|
||||
});
|
||||
|
||||
return res.redirect(url); // importantísimo: return
|
||||
});
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/auth/callback', async (req, res, next) => {
|
||||
try {
|
||||
// Log útil para debug
|
||||
console.log('[OIDC] cb sid=%s query=%j', req.sessionID, req.query);
|
||||
const params = oidcClient.callbackParams(req);
|
||||
const tokenSet = await oidcClient.callback(
|
||||
process.env.OIDC_REDIRECT_URI,
|
||||
params,
|
||||
{ code_verifier: req.session.code_verifier, state: req.session.state }
|
||||
);
|
||||
|
||||
delete req.session.code_verifier;
|
||||
delete req.session.state;
|
||||
// Recuperar lo que guardamos en /auth/login
|
||||
const { oidc } = req.session || {};
|
||||
const code_verifier = oidc?.code_verifier;
|
||||
const stateStored = oidc?.state;
|
||||
const nonceStored = oidc?.nonce;
|
||||
|
||||
// Si por algún motivo no está la info (sesión perdida/expirada), reiniciamos el flujo
|
||||
if (!code_verifier || !stateStored) {
|
||||
console.warn('[OIDC] Falta code_verifier/state en sesión; reiniciando login');
|
||||
return res.redirect(303, '/auth/login');
|
||||
}
|
||||
|
||||
const params = oidcClient.callbackParams(req);
|
||||
|
||||
// openid-client validará que el "state" recibido coincida con el que pasamos aquí
|
||||
let tokenSet;
|
||||
try {
|
||||
tokenSet = await oidcClient.callback(
|
||||
process.env.OIDC_REDIRECT_URI,
|
||||
params,
|
||||
{ code_verifier, state: stateStored, nonce: nonceStored }
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn('[OIDC] callback error, resetting session:', err?.message || err);
|
||||
return nukeSession(req, res, '/auth/login', 'callback_error');
|
||||
}
|
||||
|
||||
// Limpiar datos OIDC de la sesión
|
||||
delete req.session.oidc;
|
||||
|
||||
const claims = tokenSet.claims();
|
||||
const email = (claims.email || '').toLowerCase();
|
||||
const tenantUuid = (claims.tenant_uuid || '').replace(/-/g, '');
|
||||
|
||||
|
||||
// tenant desde claim, Authentik o fallback a tu DB
|
||||
let tenantHex = cleanUuid(claims.tenant_uuid);
|
||||
if (!tenantHex) {
|
||||
// intenta Authentik
|
||||
const akUser = await akFindUserByEmail(email).catch(()=>null);
|
||||
const akUser = await akFindUserByEmail(email).catch(() => null);
|
||||
tenantHex = cleanUuid(akUser?.attributes?.tenant_uuid);
|
||||
|
||||
// último recurso: tu DB
|
||||
if (!tenantHex) {
|
||||
const q = await pool.query('SELECT tenant_uuid FROM app_user WHERE LOWER(email)=LOWER($1) LIMIT 1', [email]);
|
||||
const q = await pool.query(
|
||||
'SELECT tenant_uuid FROM app_user WHERE LOWER(email)=LOWER($1) LIMIT 1',
|
||||
[email]
|
||||
);
|
||||
tenantHex = cleanUuid(q.rows?.[0]?.tenant_uuid);
|
||||
}
|
||||
}
|
||||
|
||||
// Regenerar sesión para evitar fijación
|
||||
// Regenerar sesión para evitar fijación y guardar el usuario
|
||||
req.session.regenerate((err) => {
|
||||
if (err) return next(err);
|
||||
if (err) {
|
||||
if (!res.headersSent) res.status(500).send('No se pudo crear la sesión.');
|
||||
return;
|
||||
}
|
||||
req.session.user = {
|
||||
sub: claims.sub,
|
||||
email,
|
||||
tenant_uuid: tenantUuid || null,
|
||||
tenant_uuid: tenantHex || null,
|
||||
};
|
||||
req.session.save((e2) => (e2 ? next(e2) : res.redirect('/')));
|
||||
req.session.save((e2) => {
|
||||
if (e2) {
|
||||
if (!res.headersSent) res.status(500).send('No se pudo guardar la sesión.');
|
||||
return;
|
||||
}
|
||||
if (!res.headersSent) return res.redirect('/'); // te llevará a /comandas si ya implementaste ese redirect
|
||||
});
|
||||
});
|
||||
|
||||
return res.redirect('/');
|
||||
|
||||
} catch (e) {
|
||||
next(e);
|
||||
console.error('[OIDC] callback error:', e);
|
||||
if (!res.headersSent) return next(e);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
app.post('/auth/logout', (req, res) => {
|
||||
req.session.destroy(() => {
|
||||
res.clearCookie('sc.sid');
|
||||
res.clearCookie(SESSION_COOKIE_NAME);
|
||||
res.status(204).end();
|
||||
});
|
||||
});
|
||||
@ -420,104 +646,189 @@ app.get('/auth/me', (req, res) => {
|
||||
// Registro de usuario (DB principal + Authentik)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// Helpers defensivos (si ya los tenés, podés omitir estas definiciones)
|
||||
const extractAkUserUuid = (u) =>
|
||||
(u && (u.uuid || u?.user?.uuid || (Array.isArray(u.results) && u.results[0]?.uuid))) || null;
|
||||
const extractAkUserPk = (u) =>
|
||||
(u && (u.pk ?? u?.user?.pk ?? null));
|
||||
|
||||
async function akDeleteUser(pkOrUuid) {
|
||||
try {
|
||||
if (!pkOrUuid || !globalThis.fetch) return;
|
||||
const base = process.env.AUTHENTIK_BASE_URL?.replace(/\/+$/, '') || '';
|
||||
const id = String(pkOrUuid);
|
||||
const url = `${base}/api/v3/core/users/${encodeURIComponent(id)}/`;
|
||||
await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${process.env.AUTHENTIK_TOKEN}` }
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[AK] No se pudo borrar usuario (compensación):', e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// POST /api/users/register
|
||||
// ==============================
|
||||
app.post('/api/users/register', async (req, res, next) => {
|
||||
const { email, display_name, tenant_uuid: requestedTenant, role } = req.body || {};
|
||||
if (!email) return res.status(400).json({ error: 'email es obligatorio' });
|
||||
// 0) input
|
||||
const {
|
||||
email,
|
||||
display_name,
|
||||
role,
|
||||
tenant_uuid: requestedTenantUuid, // opcional
|
||||
} = req.body || {};
|
||||
|
||||
const emailLower = String(email).toLowerCase();
|
||||
const normEmail = String(email || '').trim().toLowerCase();
|
||||
if (!normEmail) return res.status(400).json({ error: 'email requerido' });
|
||||
|
||||
// 1) ¿Ya hay tenant conocido (DB o Authentik o request)?
|
||||
let tenantHex = await resolveExistingTenantUuid({
|
||||
email: emailLower,
|
||||
requestedTenantUuid: requestedTenant,
|
||||
});
|
||||
|
||||
// 2) Si viene por request, asegurate que exista (o créalo a demanda)
|
||||
if (tenantHex) {
|
||||
const exists = await tenantExists(tenantHex);
|
||||
if (!exists) {
|
||||
// Si tu política es NO crear si lo traen y no existe, devolvé 400.
|
||||
// return res.status(400).json({ error: 'tenant-invalido', detail: 'El tenant indicado no existe' });
|
||||
|
||||
// Si preferís crearlo para "reparar", lo creamos sin generar un UUID nuevo:
|
||||
await ensureTenant({ tenant_uuid: tenantHex });
|
||||
// 1) Resolver tenant uuid (existente o nuevo)
|
||||
let tenantHex = null;
|
||||
try {
|
||||
if (typeof resolveExistingTenantUuid === 'function') {
|
||||
tenantHex = await resolveExistingTenantUuid({
|
||||
email: normEmail,
|
||||
requestedTenantUuid,
|
||||
});
|
||||
} else {
|
||||
tenantHex = cleanUuid(requestedTenantUuid);
|
||||
}
|
||||
|
||||
// Crear/asegurar tenant en una transacción (ahí adentro corre 01_init.sql)
|
||||
if (tenantHex) {
|
||||
// si no existe, ensureTenant lo crea
|
||||
await ensureTenant({ tenant_uuid: tenantHex });
|
||||
} else {
|
||||
const created = await ensureTenant({ tenant_uuid: null }); // genera uuid
|
||||
tenantHex = cleanUuid(created?.tenant_uuid);
|
||||
}
|
||||
} catch (e) {
|
||||
return next(new Error(`No se pudo preparar el tenant: ${e.message}`));
|
||||
}
|
||||
|
||||
// 3) Si todavía no hay tenant → primer alta de org → crear uno nuevo
|
||||
if (!tenantHex) {
|
||||
const created = await ensureTenant({ tenant_uuid: null }); // genera uuid nuevo
|
||||
tenantHex = cleanUuid(created.tenant_uuid);
|
||||
}
|
||||
|
||||
// 4) Alta transaccional del usuario en TU DB
|
||||
// 2) Transacción DB principal + Authentik con compensación
|
||||
const client = await pool.connect();
|
||||
let createdAkUser = null; // para compensación
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Evitar duplicar usuario por email + tenant (ajusta según tu constraint)
|
||||
// Duplicados (ajusta a tu constraint real: por email o por (email,tenant))
|
||||
const dup = await client.query(
|
||||
'SELECT id FROM app_user WHERE LOWER(email)=LOWER($1) AND tenant_uuid=$2',
|
||||
[emailLower, tenantHex]
|
||||
'SELECT id FROM app_user WHERE LOWER(email)=LOWER($1)',
|
||||
[normEmail]
|
||||
);
|
||||
if (dup.rowCount) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(409).json({
|
||||
error: 'user-exists',
|
||||
message: 'Ya existe un usuario con este email en este tenant.',
|
||||
message: 'Ya existe un usuario con este email.',
|
||||
next: '/auth/login',
|
||||
});
|
||||
}
|
||||
|
||||
// Authentik: crear si no existe; si existe, reusar y (opcional) asegurar attributes.tenant_uuid
|
||||
let akUser = await akFindUserByEmail(emailLower);
|
||||
// Authentik: buscar o crear
|
||||
let akUser = await akFindUserByEmail(normEmail).catch(() => null);
|
||||
if (!akUser) {
|
||||
akUser = await akCreateUser({
|
||||
email: emailLower,
|
||||
displayName: display_name,
|
||||
tenantUuid: tenantHex, // se guarda en attributes
|
||||
email: normEmail,
|
||||
displayName: display_name || null,
|
||||
tenantUuid: tenantHex, // attributes.tenant_uuid
|
||||
addToGroupId: DEFAULT_GROUP_ID || null,
|
||||
isActive: true,
|
||||
});
|
||||
} else {
|
||||
// si existe y no tiene attribute tenant_uuid, lo “reparamos” (opcional):
|
||||
const akAttrHex = cleanUuid(akUser?.attributes?.tenant_uuid);
|
||||
if (!akAttrHex) {
|
||||
try {
|
||||
// parchea attributes del user en AK (si tu versión permite PATCH)
|
||||
await akPatchUserAttributes(akUser.pk, { tenant_uuid: tenantHex });
|
||||
} catch { /* opcional, no crítico */ }
|
||||
}
|
||||
createdAkUser = akUser; // marcar que lo creamos nosotros
|
||||
}
|
||||
|
||||
const _role = role || 'owner';
|
||||
// Asegurar uuid/pk
|
||||
let akUserUuid = extractAkUserUuid(akUser);
|
||||
let akUserPk = extractAkUserPk(akUser);
|
||||
if (!akUserUuid || akUserPk == null) {
|
||||
const ref = await akFindUserByEmail(normEmail).catch(() => null);
|
||||
akUserUuid = akUserUuid || extractAkUserUuid(ref);
|
||||
akUserPk = akUserPk ?? extractAkUserPk(ref);
|
||||
}
|
||||
if (!akUserUuid) throw new Error('No se pudo obtener uuid del usuario en Authentik');
|
||||
|
||||
// Insert en tu DB principal
|
||||
const finalRole = role || 'owner';
|
||||
await client.query(
|
||||
`INSERT INTO app_user (email, display_name, tenant_uuid, ak_user_uuid, role)
|
||||
VALUES ($1,$2,$3,$4,$5)`,
|
||||
[emailLower, display_name || null, tenantHex, akUser.uuid, _role]
|
||||
[normEmail, display_name || null, tenantHex, akUserUuid, finalRole]
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
return res.status(201).json({
|
||||
message: 'Usuario registrado',
|
||||
email: emailLower,
|
||||
tenant_uuid: tenantHex, // devolvés el mismo
|
||||
role: _role,
|
||||
authentik_user_uuid: akUser.uuid,
|
||||
next: '/auth/login',
|
||||
// 3) Marcar sesión para set-password (si usás este flujo)
|
||||
req.session.pendingPassword = {
|
||||
email: normEmail,
|
||||
ak_user_uuid: akUserUuid,
|
||||
ak_user_pk: akUserPk,
|
||||
exp: Date.now() + 10 * 60 * 1000,
|
||||
};
|
||||
|
||||
return req.session.save(() => {
|
||||
const accept = String(req.headers['accept'] || '');
|
||||
if (accept.includes('text/html')) {
|
||||
return res.redirect(303, '/set-password');
|
||||
}
|
||||
return res.status(201).json({
|
||||
message: 'Usuario registrado',
|
||||
email: normEmail,
|
||||
tenant_uuid: tenantHex,
|
||||
role: finalRole,
|
||||
authentik_user_uuid: akUserUuid,
|
||||
next: '/set-password',
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
// Rollbacks + Compensaciones
|
||||
try { await client.query('ROLLBACK'); } catch {}
|
||||
if (err?.code === '23505') { // unique_violation
|
||||
return res.status(409).json({ error: 'user-exists' });
|
||||
}
|
||||
next(err);
|
||||
try {
|
||||
// Si creamos el usuario en Authentik y luego falló algo → borrar
|
||||
if (createdAkUser) {
|
||||
const id = extractAkUserPk(createdAkUser) ?? extractAkUserUuid(createdAkUser);
|
||||
if (id) await akDeleteUser(id);
|
||||
}
|
||||
} catch {}
|
||||
return next(err);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
// Definir contraseña
|
||||
app.post('/auth/password/set', async (req, res, next) => {
|
||||
try {
|
||||
const pp = req.session?.pendingPassword;
|
||||
if (!pp || (pp.exp && Date.now() > pp.exp)) {
|
||||
// token de sesión vencido o ausente
|
||||
if (!res.headersSent) return res.redirect(303, '/set-password');
|
||||
return;
|
||||
}
|
||||
|
||||
const { password, password2 } = req.body || {};
|
||||
if (!password || password.length < 8 || password !== password2) {
|
||||
return res.status(400).send('Contraseña inválida o no coincide.');
|
||||
}
|
||||
|
||||
// Buscar el usuario en Authentik y setear la clave
|
||||
const u = await akFindUserByEmail(pp.email);
|
||||
if (!u) return res.status(404).send('No se encontró el usuario en Authentik.');
|
||||
|
||||
await akSetPassword(u.pk, password, true); // true = force change handled; ajusta a tu helper
|
||||
|
||||
// Limpiar marcador y continuar al SSO
|
||||
delete req.session.pendingPassword;
|
||||
return req.session.save(() => res.redirect(303, '/auth/login'));
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Espera: { email, display_name?, tenant_uuid }
|
||||
// app.post('/api/users/register', async (req, res, next) => {
|
||||
|
||||
@ -587,12 +898,12 @@ app.get('/health', (_req, res) => res.status(200).json({ status: 'ok' }));
|
||||
// -----------------------------------------------------------------------------
|
||||
// 404 + Manejo de errores
|
||||
// -----------------------------------------------------------------------------
|
||||
app.use((req, res) => res.status(404).json({ error: 'not-found', path: req.originalUrl }));
|
||||
app.use((req, res) => res.status(404).json({ error: 'Error 404, No se encontró la página', path: req.originalUrl }));
|
||||
|
||||
app.use((err, _req, res, _next) => {
|
||||
console.error('❌ Error:', err);
|
||||
if (res.headersSent) return;
|
||||
res.status(500).json({ error: 'internal-error', detail: err?.message || String(err) });
|
||||
res.status(500).json({ error: '¡Oh! A ocurrido un error en el servidor auth.', detail: err.stack || String(err) });
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
19
services/manso/.env.development
Normal file
19
services/manso/.env.development
Normal file
@ -0,0 +1,19 @@
|
||||
NODE_ENV=development
|
||||
|
||||
PORT=3030
|
||||
|
||||
DB_HOST=dev-tenants
|
||||
DB_NAME=manso
|
||||
|
||||
# Usuario y contraseña
|
||||
DB_USER=manso
|
||||
DB_PASS=manso
|
||||
|
||||
# Puertos del servicio de db
|
||||
DB_LOCAL_PORT=5432
|
||||
DB_DOCKER_PORT=5432
|
||||
|
||||
# Colores personalizados
|
||||
COL_PRI=452D19 # Marrón oscuro
|
||||
COL_SEC=D7A666 # Crema / Café
|
||||
COL_BG=FFA500 # Naranja
|
||||
20
services/manso/.env.production
Normal file
20
services/manso/.env.production
Normal file
@ -0,0 +1,20 @@
|
||||
NODE_ENV=production # Entorno de desarrollo
|
||||
|
||||
PORT=3000 # Variables del servicio -> suitecoffee-app
|
||||
|
||||
# Variables del servicio -> suitecoffee-db de suitecoffee-app
|
||||
|
||||
DB_HOST=dev-tenants
|
||||
DB_NAME=manso
|
||||
|
||||
# Usuario y contraseña
|
||||
DB_USER=manso
|
||||
DB_PASS=manso
|
||||
# Puertos del servicio de db
|
||||
DB_LOCAL_PORT=5432
|
||||
DB_DOCKER_PORT=5432
|
||||
|
||||
# Colores personalizados
|
||||
COL_PRI=452D19 # Marrón oscuro
|
||||
COL_SEC=D7A666 # Crema / Café
|
||||
COL_BG=FFA500 # Naranja
|
||||
Loading…
x
Reference in New Issue
Block a user