Mucha cosa y es muy tarde.
- Anda parte del registro
This commit is contained in:
parent
80778c0ed9
commit
237a5427dd
@ -1,13 +1,10 @@
|
||||
# docker-compose.overrride.yml
|
||||
# Docker Comose para entorno de desarrollo o development.
|
||||
|
||||
# compose.dev.yaml
|
||||
# Docker Compose para entorno de desarrollo.
|
||||
|
||||
services:
|
||||
|
||||
app:
|
||||
image: node:20-bookworm
|
||||
expose:
|
||||
- ${APP_LOCAL_PORT}
|
||||
working_dir: /app
|
||||
user: "${UID:-1000}:${GID:-1000}"
|
||||
volumes:
|
||||
@ -16,7 +13,9 @@ services:
|
||||
env_file:
|
||||
- ./services/app/.env.development
|
||||
environment:
|
||||
- NODE_ENV=${NODE_ENV}
|
||||
NODE_ENV: development # <- fuerza el entorno para que el loader tome .env.development
|
||||
expose:
|
||||
- ${APP_LOCAL_PORT}
|
||||
networks:
|
||||
net:
|
||||
aliases: [dev-app]
|
||||
@ -24,8 +23,6 @@ services:
|
||||
|
||||
auth:
|
||||
image: node:20-bookworm
|
||||
expose:
|
||||
- ${AUTH_LOCAL_PORT}
|
||||
working_dir: /app
|
||||
user: "${UID:-1000}:${GID:-1000}"
|
||||
volumes:
|
||||
@ -34,11 +31,13 @@ services:
|
||||
env_file:
|
||||
- ./services/auth/.env.development
|
||||
environment:
|
||||
- NODE_ENV=${NODE_ENV}
|
||||
command: npm run dev
|
||||
NODE_ENV: development # <- fuerza el entorno para que el loader tome .env.development
|
||||
expose:
|
||||
- ${AUTH_LOCAL_PORT}
|
||||
networks:
|
||||
net:
|
||||
aliases: [dev-auth]
|
||||
command: npm run dev
|
||||
|
||||
db:
|
||||
image: postgres:16
|
||||
@ -67,8 +66,9 @@ services:
|
||||
#################
|
||||
### Authentik ###
|
||||
#################
|
||||
# --- Authentik db (solo interno)
|
||||
|
||||
authentik-db:
|
||||
image: postgres:16
|
||||
environment:
|
||||
POSTGRES_DB: authentik
|
||||
POSTGRES_USER: authentik
|
||||
@ -79,15 +79,15 @@ services:
|
||||
net:
|
||||
aliases: [ak-db]
|
||||
|
||||
# --- Authentik Redis (solo interno)
|
||||
authentik-redis:
|
||||
image: redis:7-alpine
|
||||
command: ["redis-server", "--save", "", "--appendonly", "no"]
|
||||
networks:
|
||||
net:
|
||||
aliases: [ak-redis]
|
||||
|
||||
# --- Authentik Server (sin puertos públicos)
|
||||
authentik:
|
||||
image: ghcr.io/goauthentik/server:latest
|
||||
command: server
|
||||
environment:
|
||||
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
|
||||
@ -97,19 +97,23 @@ services:
|
||||
AUTHENTIK_POSTGRESQL__NAME: authentik
|
||||
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_DB_PASS}
|
||||
AUTHENTIK_REDIS__HOST: authentik-redis
|
||||
# Opcional: bootstrap automático del admin
|
||||
AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD}
|
||||
AUTHENTIK_BOOTSTRAP_EMAIL: ${AUTHENTIK_BOOTSTRAP_EMAIL}
|
||||
AUTHENTIK_HTTP__TRUSTED_PROXY__CIDRS: "0.0.0.0/0,::/0"
|
||||
AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS: "https://authentik.suitecoffee.mateosaldain.uy,https://suitecoffee.mateosaldain.uy"
|
||||
AUTHENTIK_COOKIE__DOMAIN: "authentik.suitecoffee.mateosaldain.uy"
|
||||
networks:
|
||||
net:
|
||||
aliases: [authentik]
|
||||
|
||||
# --- Authentik Worker
|
||||
authentik-worker:
|
||||
image: ghcr.io/goauthentik/server:latest
|
||||
command: worker
|
||||
depends_on:
|
||||
authentik-db: { condition: service_healthy }
|
||||
authentik-redis: { condition: service_started }
|
||||
authentik-db:
|
||||
condition: service_started
|
||||
authentik-redis:
|
||||
condition: service_started
|
||||
environment:
|
||||
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
|
||||
AUTHENTIK_POSTGRESQL__HOST: authentik-db
|
||||
@ -117,6 +121,9 @@ services:
|
||||
AUTHENTIK_POSTGRESQL__NAME: authentik
|
||||
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_DB_PASS}
|
||||
AUTHENTIK_REDIS__HOST: authentik-redis
|
||||
AUTHENTIK_HTTP__TRUSTED_PROXY__CIDRS: "0.0.0.0/0,::/0"
|
||||
AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS: "https://authentik.suitecoffee.mateosaldain.uy,https://suitecoffee.mateosaldain.uy"
|
||||
AUTHENTIK_COOKIE__DOMAIN: "authentik.suitecoffee.mateosaldain.uy"
|
||||
networks:
|
||||
net:
|
||||
aliases: [ak-work]
|
||||
|
||||
13
compose.yaml
13
compose.yaml
@ -22,6 +22,8 @@ services:
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
authentik:
|
||||
condition: service_started
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://localhost:${AUTH_DOCKER_PORT}/health || exit 1"]
|
||||
interval: 10s
|
||||
@ -58,20 +60,25 @@ services:
|
||||
image: postgres:16-alpine
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U authentik -d authentik"]
|
||||
interval: 10s
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
retries: 20
|
||||
restart: unless-stopped
|
||||
|
||||
authentik-redis:
|
||||
image: redis:7-alpine
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
restart: unless-stopped
|
||||
|
||||
authentik:
|
||||
image: ghcr.io/goauthentik/server:latest
|
||||
depends_on:
|
||||
authentik-db: { condition: service_healthy }
|
||||
authentik-redis: { condition: service_started }
|
||||
authentik-redis: { condition: service_healthy }
|
||||
restart: unless-stopped
|
||||
|
||||
authentik-worker:
|
||||
|
||||
35
services/app/package-lock.json
generated
35
services/app/package-lock.json
generated
@ -9,6 +9,7 @@
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"bcrypt": "^6.0.0",
|
||||
"chalk": "^5.6.0",
|
||||
"connect-redis": "^9.0.0",
|
||||
"cors": "^2.8.5",
|
||||
@ -130,6 +131,20 @@
|
||||
"version": "1.0.2",
|
||||
"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",
|
||||
"dev": true,
|
||||
@ -898,6 +913,26 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "8.5.0",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz",
|
||||
"integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==",
|
||||
"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.10",
|
||||
"dev": true,
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
"nodemon": "^3.1.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcrypt": "^6.0.0",
|
||||
"chalk": "^5.6.0",
|
||||
"connect-redis": "^9.0.0",
|
||||
"cors": "^2.8.5",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
337
services/app/src/routes.legacy.js
Normal file
337
services/app/src/routes.legacy.js
Normal file
@ -0,0 +1,337 @@
|
||||
// services/app/src/routes.legacy.js
|
||||
// -----------------------------------------------------------------------------
|
||||
// Endpoints legacy de SuiteCoffee extraídos del index original y montados
|
||||
// como módulo. No elimina nada; sólo organiza y robustece.
|
||||
//
|
||||
// Cómo se usa: el nuevo services/app/src/index.js hace
|
||||
// const legacy = await import('./routes.legacy.js')
|
||||
// legacy.default(app, { requireAuth, withTenant, done, mainPool, tenantsPool, express })
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export default function mount(app, ctx) {
|
||||
const { requireAuth, withTenant, done, mainPool, tenantsPool, express } = ctx;
|
||||
|
||||
// Aliases de compatibilidad con el archivo original
|
||||
const pool = mainPool; // el original usaba `pool` (DB principal)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers y seguridad (copiados/adaptados del archivo original)
|
||||
// ---------------------------------------------------------------------------
|
||||
const ALLOWED_TABLES = [
|
||||
'roles','usuarios','usua_roles',
|
||||
'categorias','productos',
|
||||
'clientes','mesas',
|
||||
'comandas','deta_comandas',
|
||||
'proveedores','compras','deta_comp_producto',
|
||||
'mate_primas','deta_comp_materias',
|
||||
'prov_producto','prov_mate_prima',
|
||||
'receta_producto', 'asistencia_resumen_diario',
|
||||
'asistencia_intervalo', 'vw_compras'
|
||||
];
|
||||
const VALID_IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
const q = (s) => `"${String(s).replace(/"/g, '""')}"`; // quote ident simple
|
||||
function ensureTable(name) {
|
||||
const t = String(name || '').toLowerCase();
|
||||
if (!ALLOWED_TABLES.includes(t)) throw new Error('Tabla no permitida');
|
||||
return t;
|
||||
}
|
||||
|
||||
async function getClient() { // el original devolvía pool.connect()
|
||||
const client = await pool.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
// Columnas de una tabla
|
||||
async function loadColumns(client, table) {
|
||||
const sql = `
|
||||
SELECT
|
||||
c.column_name,
|
||||
c.data_type,
|
||||
c.is_nullable = 'YES' AS is_nullable,
|
||||
c.column_default,
|
||||
EXISTS (
|
||||
SELECT 1 FROM pg_attribute a
|
||||
JOIN pg_class t ON t.oid = a.attrelid
|
||||
JOIN pg_index i ON i.indrelid = t.oid AND a.attnum = ANY(i.indkey)
|
||||
WHERE t.relname = $1 AND i.indisprimary AND a.attname = c.column_name
|
||||
) AS is_primary,
|
||||
(
|
||||
SELECT a.attgenerated = 's' OR a.attidentity IN ('a','d')
|
||||
FROM pg_attribute a
|
||||
JOIN pg_class t2 ON t2.oid = a.attrelid
|
||||
WHERE t2.relname = $1 AND a.attname = c.column_name
|
||||
) AS is_generated
|
||||
FROM information_schema.columns c
|
||||
WHERE c.table_schema = 'public' AND c.table_name = $1
|
||||
ORDER BY c.ordinal_position`;
|
||||
const { rows } = await client.query(sql, [table]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// PKs de una tabla
|
||||
async function loadPrimaryKey(client, table) {
|
||||
const sql = `
|
||||
SELECT a.attname AS column_name
|
||||
FROM pg_index i
|
||||
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
||||
JOIN pg_class t ON t.oid = i.indrelid
|
||||
WHERE t.relname = $1 AND i.indisprimary`;
|
||||
const { rows } = await client.query(sql, [table]);
|
||||
return rows.map(r => r.column_name);
|
||||
}
|
||||
|
||||
// FKs salientes de una tabla → { [column]: { foreign_table, foreign_column } }
|
||||
async function loadForeignKeys(client, table) {
|
||||
const sql = `
|
||||
SELECT
|
||||
kcu.column_name AS column_name,
|
||||
ccu.table_name AS foreign_table,
|
||||
ccu.column_name AS foreign_column
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_name = kcu.constraint_name
|
||||
AND tc.table_schema = kcu.table_schema
|
||||
JOIN information_schema.constraint_column_usage ccu
|
||||
ON ccu.constraint_name = tc.constraint_name
|
||||
AND ccu.table_schema = tc.table_schema
|
||||
WHERE tc.constraint_type = 'FOREIGN KEY'
|
||||
AND tc.table_schema = 'public'
|
||||
AND tc.table_name = $1`;
|
||||
const { rows } = await client.query(sql, [table]);
|
||||
const map = {};
|
||||
for (const r of rows) map[r.column_name] = { foreign_table: r.foreign_table, foreign_column: r.foreign_column };
|
||||
return map;
|
||||
}
|
||||
|
||||
// Heurística para elegir una columna "label" en tablas referenciadas
|
||||
async function pickLabelColumn(client, refTable) {
|
||||
const preferred = ['nombre','raz_social','apodo','documento','correo','telefono','descripcion','detalle'];
|
||||
const { rows } = await client.query(
|
||||
`SELECT column_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema='public' AND table_name=$1
|
||||
ORDER BY ordinal_position`, [refTable]
|
||||
);
|
||||
for (const cand of preferred) if (rows.find(r => r.column_name === cand)) return cand;
|
||||
const textish = rows.find(r => /text|character varying|varchar/i.test(r.data_type));
|
||||
if (textish) return textish.column_name;
|
||||
return rows[0]?.column_name || 'id';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RUTAS DE UI (vistas)
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get('/', (req, res) => {
|
||||
res.locals.pageTitle = 'Dashboard';
|
||||
res.locals.pageId = 'home';
|
||||
res.render('dashboard');
|
||||
});
|
||||
|
||||
app.get('/dashboard', (req, res) => {
|
||||
res.locals.pageTitle = 'Dashboard';
|
||||
res.locals.pageId = 'dashboard';
|
||||
res.render('dashboard');
|
||||
});
|
||||
|
||||
app.get('/comandas', (req, res) => {
|
||||
res.locals.pageTitle = 'Comandas';
|
||||
res.locals.pageId = 'comandas';
|
||||
res.render('comandas');
|
||||
});
|
||||
|
||||
app.get('/estadoComandas', (req, res) => {
|
||||
res.locals.pageTitle = 'Estado de Comandas';
|
||||
res.locals.pageId = 'estadoComandas';
|
||||
res.render('estadoComandas');
|
||||
});
|
||||
|
||||
app.get('/productos', (req, res) => {
|
||||
res.locals.pageTitle = 'Productos';
|
||||
res.locals.pageId = 'productos';
|
||||
res.render('productos');
|
||||
});
|
||||
|
||||
app.get('/usuarios', (req, res) => {
|
||||
res.locals.pageTitle = 'Usuarios';
|
||||
res.locals.pageId = 'usuarios';
|
||||
res.render('usuarios');
|
||||
});
|
||||
|
||||
app.get('/reportes', (req, res) => {
|
||||
res.locals.pageTitle = 'Reportes';
|
||||
res.locals.pageId = 'reportes';
|
||||
res.render('reportes');
|
||||
});
|
||||
|
||||
app.get('/compras', (req, res) => {
|
||||
res.locals.pageTitle = 'Compras';
|
||||
res.locals.pageId = 'compras';
|
||||
res.render('compras');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API: ejemplos por-tenant y utilitarios (introspección)
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ejemplo conservado del original (usar search_path via withTenant)
|
||||
app.get('/api/productos', requireAuth, withTenant, async (req, res, next) => {
|
||||
const { rows } = await req.pg.query('SELECT * FROM productos ORDER BY id');
|
||||
res.json(rows);
|
||||
}, done);
|
||||
|
||||
// Listado de tablas permitidas
|
||||
app.get('/api/tables', async (_req, res) => {
|
||||
res.json(ALLOWED_TABLES);
|
||||
});
|
||||
|
||||
// Esquema de una tabla (columnas + FKs)
|
||||
app.get('/api/schema/:table', async (req, res) => {
|
||||
try {
|
||||
const table = ensureTable(req.params.table);
|
||||
const client = await getClient();
|
||||
try {
|
||||
const columns = await loadColumns(client, table);
|
||||
const fks = await loadForeignKeys(client, table);
|
||||
const enriched = columns.map(c => ({ ...c, foreign: fks[c.column_name] || null }));
|
||||
res.json({ table, columns: enriched });
|
||||
} finally { client.release(); }
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Opciones para una columna con FK (id/label)
|
||||
app.get('/api/options/:table/:column', async (req, res) => {
|
||||
try {
|
||||
const table = ensureTable(req.params.table);
|
||||
const column = req.params.column;
|
||||
if (!VALID_IDENT.test(column)) throw new Error('Columna inválida');
|
||||
|
||||
const client = await getClient();
|
||||
try {
|
||||
const fks = await loadForeignKeys(client, table);
|
||||
const fk = fks[column];
|
||||
if (!fk) return res.json([]);
|
||||
const refTable = fk.foreign_table;
|
||||
const refId = fk.foreign_column;
|
||||
const labelCol = await pickLabelColumn(client, refTable);
|
||||
const sql = `SELECT ${q(refId)} AS id, ${q(labelCol)} AS label FROM ${q(refTable)} ORDER BY ${q(labelCol)} LIMIT 1000`;
|
||||
const result = await client.query(sql);
|
||||
res.json(result.rows);
|
||||
} finally { client.release(); }
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Datos de una tabla (limitados) — vista rápida
|
||||
app.get('/api/table/:table', async (req, res) => {
|
||||
try {
|
||||
const table = ensureTable(req.params.table);
|
||||
const limit = Math.min(parseInt(req.query.limit || '100', 10), 1000);
|
||||
const client = await getClient();
|
||||
try {
|
||||
const pks = await loadPrimaryKey(client, table);
|
||||
const order = pks[0] ? q(pks[0]) : '1';
|
||||
const sql = `SELECT * FROM ${q(table)} ORDER BY ${order} LIMIT $1`;
|
||||
const { rows } = await client.query(sql, [limit]);
|
||||
res.json(rows);
|
||||
} finally { client.release(); }
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: e.message, code: e.code, detail: e.detail });
|
||||
}
|
||||
});
|
||||
|
||||
// Crear/actualizar registros genéricos (placeholder: pega aquí tu lógica original)
|
||||
app.post('/api/table/:table', async (req, res) => {
|
||||
// TODO: Pegar implementación original (insert/update genérico) aquí.
|
||||
// Sugerencia: validar payload contra loadColumns(client, table),
|
||||
// construir INSERT/UPDATE dinámico ignorando columnas generadas y PKs cuando corresponda.
|
||||
res.status(501).json({ error: 'not-implemented', detail: 'Pegar lógica original de POST /api/table/:table' });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Endpoints de negocio (conservados tal cual cuando fue posible)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Detalle de una comanda
|
||||
app.get('/api/comandas/:id/detalle', (req, res, next) =>
|
||||
pool.query(
|
||||
`SELECT id_det_comanda, id_producto, producto_nombre,
|
||||
cantidad, pre_unitario, subtotal, observaciones
|
||||
FROM public.v_comandas_detalle_items
|
||||
WHERE id_comanda = $1::int
|
||||
ORDER BY id_det_comanda`,
|
||||
[req.params.id]
|
||||
)
|
||||
.then(r => res.json(r.rows))
|
||||
.catch(next)
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RPC / Reportes / Procedimientos (stubs con TODO si no se extrajo el SQL)
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post('/api/rpc/find_usuarios_por_documentos', async (req, res) => {
|
||||
// TODO: Pegar el SQL original. Ejemplo:
|
||||
// const { documentos } = req.body || {};
|
||||
// const { rows } = await pool.query('SELECT * FROM public.find_usuarios_por_documentos($1::jsonb)', [JSON.stringify(documentos||[])])
|
||||
// res.json(rows);
|
||||
res.status(501).json({ error: 'not-implemented' });
|
||||
});
|
||||
|
||||
app.post('/api/rpc/import_asistencia', async (req, res) => {
|
||||
// TODO: pegar lógica original
|
||||
res.status(501).json({ error: 'not-implemented' });
|
||||
});
|
||||
|
||||
app.post('/api/rpc/asistencia_get', async (req, res) => {
|
||||
// TODO
|
||||
res.status(501).json({ error: 'not-implemented' });
|
||||
});
|
||||
|
||||
app.post('/api/rpc/asistencia_update_raw', async (req, res) => {
|
||||
// TODO
|
||||
res.status(501).json({ error: 'not-implemented' });
|
||||
});
|
||||
|
||||
app.post('/api/rpc/asistencia_delete_raw', async (req, res) => {
|
||||
// TODO
|
||||
res.status(501).json({ error: 'not-implemented' });
|
||||
});
|
||||
|
||||
app.post('/api/rpc/report_tickets', async (req, res) => {
|
||||
// TODO: posiblemente public.report_tickets_year(year int)
|
||||
res.status(501).json({ error: 'not-implemented' });
|
||||
});
|
||||
|
||||
app.post('/api/rpc/report_asistencia', async (req, res) => {
|
||||
// TODO: posiblemente public.report_asistencia(desde date, hasta date)
|
||||
res.status(501).json({ error: 'not-implemented' });
|
||||
});
|
||||
|
||||
app.get('/api/rpc/report_gastos', async (req, res) => {
|
||||
// TODO: pegar la SELECT/función original
|
||||
res.status(501).json({ error: 'not-implemented' });
|
||||
});
|
||||
|
||||
app.post('/api/rpc/report_gastos', async (req, res) => {
|
||||
try {
|
||||
// Ejemplo de carcasa robusta en base a nombres vistos
|
||||
const { desde, hasta } = req.body || {};
|
||||
if (!desde || !hasta) return res.status(400).json({ error: 'desde y hasta son requeridos' });
|
||||
// TODO: reemplazar por tu SQL real; esto es un placeholder ilutrativo
|
||||
const sql = 'SELECT * FROM public.report_gastos($1::date, $2::date)';
|
||||
try {
|
||||
const { rows } = await pool.query(sql, [desde, hasta]);
|
||||
res.json(rows);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'report_gastos failed', message: e.message, detail: e.detail, code: e.code });
|
||||
}
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'report_gastos failed', message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/rpc/save_compra', async (req, res) => { res.status(501).json({ error: 'not-implemented' }); });
|
||||
app.post('/api/rpc/get_compra', async (req, res) => { res.status(501).json({ error: 'not-implemented' }); });
|
||||
app.post('/api/rpc/delete_compra', async (req, res) => { res.status(501).json({ error: 'not-implemented' }); });
|
||||
}
|
||||
75
services/auth/package-lock.json
generated
75
services/auth/package-lock.json
generated
@ -16,6 +16,7 @@
|
||||
"cookie-session": "^2.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.1",
|
||||
"ejs": "^3.1.10",
|
||||
"express": "^5.1.0",
|
||||
"express-ejs-layouts": "^2.5.1",
|
||||
"express-session": "^1.18.2",
|
||||
@ -197,6 +198,12 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/async": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
@ -642,6 +649,21 @@
|
||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ejs": {
|
||||
"version": "3.1.10",
|
||||
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
|
||||
"integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"jake": "^10.8.5"
|
||||
},
|
||||
"bin": {
|
||||
"ejs": "bin/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
@ -804,6 +826,36 @@
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/filelist": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
|
||||
"integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"minimatch": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/filelist/node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/filelist/node_modules/minimatch": {
|
||||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
|
||||
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
@ -1297,6 +1349,23 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/jake": {
|
||||
"version": "10.9.4",
|
||||
"resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
|
||||
"integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"async": "^3.2.6",
|
||||
"filelist": "^1.0.4",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"jake": "bin/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "4.15.9",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
|
||||
@ -1802,6 +1871,12 @@
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
|
||||
@ -22,6 +22,7 @@
|
||||
"cookie-session": "^2.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.1",
|
||||
"ejs": "^3.1.10",
|
||||
"express": "^5.1.0",
|
||||
"express-ejs-layouts": "^2.5.1",
|
||||
"express-session": "^1.18.2",
|
||||
|
||||
@ -1,46 +1,214 @@
|
||||
// services/auth/src/ak.js
|
||||
import axios from 'axios';
|
||||
// ------------------------------------------------------------
|
||||
// Cliente mínimo y robusto para la API Admin de Authentik (v3)
|
||||
// - Sin dependencias externas (usa fetch nativo de Node >=18)
|
||||
// - ESM compatible
|
||||
// - Timeouts, reintentos opcionales y mensajes de error claros
|
||||
// - Compatible con services/auth/src/index.js actual
|
||||
// ------------------------------------------------------------
|
||||
|
||||
const AK = axios.create({
|
||||
baseURL: `${process.env.AUTHENTIK_BASE_URL}/api/v3`,
|
||||
headers: { Authorization: `Bearer ${process.env.AUTHENTIK_TOKEN}` },
|
||||
timeout: 10000,
|
||||
/**
|
||||
* Lee configuración desde process.env en cada llamada (para evitar problemas
|
||||
* de orden de imports con dotenv). No falla en import-time.
|
||||
*/
|
||||
function getConfig() {
|
||||
const BASE = (process.env.AUTHENTIK_BASE_URL || '').replace(/\/+$/, '');
|
||||
const TOKEN = process.env.AUTHENTIK_TOKEN || '';
|
||||
if (!BASE) throw new Error('AK_CONFIG: Falta AUTHENTIK_BASE_URL');
|
||||
if (!TOKEN) throw new Error('AK_CONFIG: Falta AUTHENTIK_TOKEN');
|
||||
return { BASE, TOKEN };
|
||||
}
|
||||
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Helpers de sincronización
|
||||
// ------------------------------------------------------------
|
||||
export async function akPatchUserAttributes(userPk, partialAttrs = {}) {
|
||||
// PATCH del usuario para asegurar attributes.tenant_uuid
|
||||
return akRequest('patch', `/api/v3/core/users/${userPk}/`, {
|
||||
data: { attributes: partialAttrs },
|
||||
});
|
||||
|
||||
// Busca usuario por email (case-insensitive)
|
||||
export async function akFindUserByEmail(email) {
|
||||
const { data } = await AK.get('/core/users/', { params: { search: email }});
|
||||
// filtra exacto por email si querés evitar colisiones de 'search'
|
||||
return data.results?.find(u => (u.email || '').toLowerCase() === email.toLowerCase()) || null;
|
||||
}
|
||||
|
||||
// Crea usuario en Authentik con atributo tenant_uuid y lo agrega a un grupo (opcional)
|
||||
export async function akCreateUser({ email, displayName, tenantUuid, addToGroupId }) {
|
||||
// 1) crear usuario
|
||||
const { data: user } = await AK.post('/core/users/', {
|
||||
username: email, // en Authentik el username puede ser el email
|
||||
name: displayName || email,
|
||||
email,
|
||||
is_active: true,
|
||||
attributes: { tenant_uuid: tenantUuid }, // <-- para tu claim custom
|
||||
export async function akEnsureGroupForTenant(tenantHex) {
|
||||
const groupName = `tenant_${tenantHex}`;
|
||||
|
||||
// buscar por nombre
|
||||
const data = await akRequest('get', '/api/v3/core/groups/', { params: { name: groupName }});
|
||||
const g = (data?.results || [])[0];
|
||||
if (g) return g.pk;
|
||||
|
||||
// crear si no existe
|
||||
const created = await akRequest('post', '/api/v3/core/groups/', {
|
||||
data: { name: groupName, attributes: { tenant_uuid: tenantHex } },
|
||||
});
|
||||
|
||||
// 2) agregar a grupo por defecto (opcional)
|
||||
if (addToGroupId) {
|
||||
await AK.post(`/core/users/${user.pk}/groups/`, { group: addToGroupId });
|
||||
return created.pk;
|
||||
}
|
||||
|
||||
return user; // contiene pk y uuid
|
||||
export async function akAddUserToGroup(userPk, groupPk) {
|
||||
// Endpoint de membership (en versiones recientes, POST users/<pk>/groups/)
|
||||
return akRequest('post', `/api/v3/core/users/${userPk}/groups/`, { data: { group: groupPk } });
|
||||
}
|
||||
|
||||
// Opcional: setear/forzar password inicial (si querés flujo con password local en Authentik)
|
||||
export async function akSetPassword(userPk, password, requireChange = true) {
|
||||
// Utilidad de espera
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/**
|
||||
* Llamada HTTP genérica con fetch + timeout + manejo de errores.
|
||||
* @param {('GET'|'POST'|'PUT'|'PATCH'|'DELETE')} method
|
||||
* @param {string} path - Ruta a partir de /api/v3 (por ej. "/core/users/")
|
||||
* @param {{qs?:Record<string,string|number|boolean>, body?:any, timeoutMs?:number, retries?:number}} [opts]
|
||||
*/
|
||||
async function request(method, path, opts = {}) {
|
||||
const { BASE, TOKEN } = getConfig();
|
||||
const {
|
||||
qs = undefined,
|
||||
body = undefined,
|
||||
timeoutMs = 10000,
|
||||
retries = 0,
|
||||
} = opts;
|
||||
|
||||
const url = new URL(`${BASE}/api/v3${path}`);
|
||||
if (qs) Object.entries(qs).forEach(([k, v]) => url.searchParams.set(k, String(v)));
|
||||
|
||||
let lastErr;
|
||||
for (let attempt = 1; attempt <= Math.max(1, retries + 1); attempt++) {
|
||||
const controller = new AbortController();
|
||||
const t = setTimeout(() => controller.abort(new Error('AK_TIMEOUT')), timeoutMs);
|
||||
try {
|
||||
await AK.post(`/core/users/${userPk}/set_password/`, {
|
||||
password, require_change: requireChange,
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
clearTimeout(t);
|
||||
|
||||
if (res.status === 204) return null; // sin contenido
|
||||
|
||||
// intenta parsear JSON; si no es JSON, devuelve texto
|
||||
const ctype = res.headers.get('content-type') || '';
|
||||
const payload = ctype.includes('application/json') ? await res.json().catch(() => ({})) : await res.text();
|
||||
|
||||
if (!res.ok) {
|
||||
const detail = typeof payload === 'string' ? payload : payload?.detail || payload?.error || JSON.stringify(payload);
|
||||
const err = new Error(`AK ${method} ${url.pathname} → HTTP ${res.status}: ${detail}`);
|
||||
err.status = res.status; // @ts-ignore
|
||||
throw err;
|
||||
}
|
||||
|
||||
return payload;
|
||||
} catch (e) {
|
||||
// Si tu instancia no permite setear password por API, capturá y usá un flow de "reset password"
|
||||
throw new Error('No se pudo establecer la contraseña en Authentik por API');
|
||||
clearTimeout(t);
|
||||
lastErr = e;
|
||||
// Reintentos sólo en ECONNREFUSED/timeout/5xx
|
||||
const msg = String(e?.message || e);
|
||||
const retriable = msg.includes('ECONNREFUSED') || msg.includes('AK_TIMEOUT') || /\b5\d\d\b/.test(e?.status?.toString?.() || '');
|
||||
if (!retriable || attempt > retries) throw e;
|
||||
await sleep(500 * attempt); // backoff lineal suave
|
||||
}
|
||||
}
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Funciones públicas
|
||||
// ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Busca un usuario por email en Authentik (case-insensitive) usando ?search=
|
||||
* Devuelve el usuario exacto o null si no existe.
|
||||
*/
|
||||
export async function akFindUserByEmail(email) {
|
||||
if (!email) throw new Error('akFindUserByEmail: email requerido');
|
||||
const data = await request('GET', '/core/users/', { qs: { search: email, page_size: 50 }, retries: 3 });
|
||||
const list = Array.isArray(data?.results) ? data.results : [];
|
||||
const lower = String(email).toLowerCase();
|
||||
return list.find((u) => (u.email || '').toLowerCase() === lower) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea un usuario en Authentik con atributos de tenant y opcionalmente lo
|
||||
* agrega a un grupo existente.
|
||||
* @param {{email:string, displayName?:string, tenantUuid?:string, addToGroupId?: number|string, isActive?: boolean}} p
|
||||
* @returns {Promise<any>} el objeto usuario creado
|
||||
*/
|
||||
export async function akCreateUser(p) {
|
||||
const email = p?.email;
|
||||
if (!email) throw new Error('akCreateUser: email requerido');
|
||||
const name = p?.displayName || email;
|
||||
const tenantUuid = (p?.tenantUuid || '').replace(/-/g, '');
|
||||
const isActive = p?.isActive ?? true;
|
||||
|
||||
// 1) crear usuario
|
||||
const user = await request('POST', '/core/users/', {
|
||||
body: {
|
||||
username: email,
|
||||
name,
|
||||
email,
|
||||
is_active: isActive,
|
||||
attributes: tenantUuid ? { tenant_uuid: tenantUuid } : {},
|
||||
},
|
||||
retries: 3,
|
||||
});
|
||||
|
||||
// 2) agregar a grupo (opcional)
|
||||
if (p?.addToGroupId) {
|
||||
try {
|
||||
await request('POST', `/core/users/${user.pk}/groups/`, { body: { group: p.addToGroupId }, retries: 2 });
|
||||
} catch (e) {
|
||||
// No rompas todo por el grupo; deja registro del error para que el caller decida.
|
||||
console.warn(`akCreateUser: no se pudo agregar al grupo ${p.addToGroupId}:`, e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Establece/forza una contraseña a un usuario (si tu política lo permite).
|
||||
* @param {number|string} userPk
|
||||
* @param {string} password
|
||||
* @param {boolean} requireChange - si el usuario debe cambiarla al siguiente login
|
||||
*/
|
||||
export async function akSetPassword(userPk, password, requireChange = true) {
|
||||
if (!userPk) throw new Error('akSetPassword: userPk requerido');
|
||||
if (!password) throw new Error('akSetPassword: password requerida');
|
||||
try {
|
||||
await request('POST', `/core/users/${userPk}/set_password/`, {
|
||||
body: { password, require_change: !!requireChange },
|
||||
retries: 1,
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
// Algunas instalaciones no permiten setear password por API (políticas). Propaga un error legible.
|
||||
const err = new Error(`akSetPassword: no se pudo establecer la contraseña: ${e?.message || e}`);
|
||||
err.cause = e;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Fin
|
||||
// ------------------------------------------------------------
|
||||
|
||||
3071
services/auth/src/db/dumpl_manso_250905.sql
Normal file
3071
services/auth/src/db/dumpl_manso_250905.sql
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,80 +1,222 @@
|
||||
// auth/src/index.js
|
||||
// services/auth/src/index.js
|
||||
// ------------------------------------------------------------
|
||||
// SuiteCoffee — Servicio de Autenticación (Express + OIDC)
|
||||
// - ESM compatible (Node >=18)
|
||||
// - Sesiones con Redis (compartibles con otros servicios)
|
||||
// - Vistas EJS (login)
|
||||
// - Rutas OIDC: /auth/login, /auth/callback, /auth/logout
|
||||
// - Registro de usuario: /api/users/register (DB + Authentik)
|
||||
// ------------------------------------------------------------
|
||||
|
||||
import 'dotenv/config';
|
||||
import chalk from 'chalk';
|
||||
import express from 'express';
|
||||
import expressLayouts from 'express-ejs-layouts';
|
||||
import cors from 'cors';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Pool } from 'pg';
|
||||
import bcrypt from'bcrypt';
|
||||
import session from 'express-session';
|
||||
import { createClient as createRedisClient } from 'redis';
|
||||
import * as connectRedis from 'connect-redis';
|
||||
import expressLayouts from 'express-ejs-layouts';
|
||||
import { Issuer, generators } from 'openid-client';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import session from 'express-session';
|
||||
import { createClient } from 'redis';
|
||||
import * as connectRedis from 'connect-redis';
|
||||
const RedisStore = connectRedis.default || connectRedis.RedisStore;
|
||||
|
||||
const redis = createClient({ url: process.env.REDIS_URL || 'redis://authentik-redis:6379' });
|
||||
await redis.connect();
|
||||
|
||||
import { Issuer, generators } from 'openid-client';
|
||||
import cookieSession from 'cookie-session';
|
||||
|
||||
import { akFindUserByEmail, akCreateUser, akSetPassword } from './ak.js';
|
||||
|
||||
|
||||
// Rutas
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
// -----------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// Normaliza UUID (acepta con/sin guiones) → "hex" sin guiones
|
||||
const cleanUuid = (u) => (u ? String(u).toLowerCase().replace(/[^0-9a-f]/g, '') : '');
|
||||
|
||||
// Nombre de schema/rol a partir de uuid limpio
|
||||
const schemaNameFor = (uuidHex) => `schema_tenant_${uuidHex}`;
|
||||
const roleNameFor = (uuidHex) => `tenant_${uuidHex}`;
|
||||
|
||||
// Helpers de Authentik (admin API)
|
||||
const { akFindUserByEmail, akCreateUser, akSetPassword } = await import('./ak.js');
|
||||
|
||||
// Quoter seguro de identificadores SQL (roles, schemas, tablas)
|
||||
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;
|
||||
|
||||
// --- Resolver y cachear el grupo por ID/UUID/NOMBRE una sola vez ---
|
||||
let DEFAULT_GROUP_ID = process.env.AUTHENTIK_DEFAULT_GROUP_ID
|
||||
? Number(process.env.AUTHENTIK_DEFAULT_GROUP_ID)
|
||||
: null;
|
||||
|
||||
if (!DEFAULT_GROUP_ID) {
|
||||
(async () => {
|
||||
try {
|
||||
// Si tenés akResolveGroupIdByName, usalo:
|
||||
// DEFAULT_GROUP_ID = await akResolveGroupIdByName(process.env.AUTHENTIK_DEFAULT_GROUP_NAME);
|
||||
|
||||
// Con el helper genérico que te dejé en ak.js:
|
||||
DEFAULT_GROUP_ID = await akResolveGroupId({
|
||||
uuid: process.env.AUTHENTIK_DEFAULT_GROUP_UUID,
|
||||
name: process.env.AUTHENTIK_DEFAULT_GROUP_NAME,
|
||||
});
|
||||
console.log('[AK] DEFAULT_GROUP_ID resuelto:', DEFAULT_GROUP_ID);
|
||||
} catch (e) {
|
||||
console.warn('[AK] No se pudo resolver DEFAULT_GROUP_ID:', e?.message || e);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// Verificar existencia del tenant sin crear (en la DB de tenants)
|
||||
async function tenantExists(uuidHex) {
|
||||
if (!uuidHex) return false;
|
||||
const schema = schemaNameFor(uuidHex);
|
||||
const client = await tenantsPool.connect();
|
||||
try {
|
||||
const q = await client.query(
|
||||
'SELECT 1 FROM information_schema.schemata WHERE schema_name=$1',
|
||||
[schema]
|
||||
);
|
||||
return q.rowCount > 0;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Intenta obtener el tenant por orden:
|
||||
// 1) DB principal (app_user por email)
|
||||
// 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();
|
||||
|
||||
// 1) DB principal
|
||||
const dbRes = await pool.query(
|
||||
'SELECT tenant_uuid FROM app_user WHERE LOWER(email)=LOWER($1) LIMIT 1',
|
||||
[emailLower]
|
||||
);
|
||||
if (dbRes.rowCount) {
|
||||
const fromDb = cleanUuid(dbRes.rows[0].tenant_uuid);
|
||||
if (fromDb) return fromDb;
|
||||
}
|
||||
|
||||
// 2) Authentik
|
||||
const akUser = await akFindUserByEmail(emailLower).catch(() => null);
|
||||
const fromAk = cleanUuid(akUser?.attributes?.tenant_uuid);
|
||||
if (fromAk) return fromAk;
|
||||
|
||||
// 3) Pedido del request
|
||||
const fromReq = cleanUuid(requestedTenantUuid);
|
||||
if (fromReq) return fromReq;
|
||||
|
||||
return null; // no hay tenant conocido
|
||||
}
|
||||
|
||||
// Helper para crear tenant si falta
|
||||
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');
|
||||
|
||||
return { tenant_uuid: uuid, schema, role, role_password: pwd };
|
||||
} catch (e) {
|
||||
try { await client.query('ROLLBACK'); } catch {}
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Utilidades
|
||||
// -----------------------------------------------------------------------------
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const RedisStore = connectRedis.default || connectRedis.RedisStore;
|
||||
|
||||
// Variables de Entorno
|
||||
import dotenv, { config } from 'dotenv';
|
||||
|
||||
// Obtención de la ruta de la variable de entorno correspondiente a NODE_ENV
|
||||
try {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
dotenv.config({ path: path.resolve(__dirname, '../.env.development' )});
|
||||
console.log(`Activando entorno de ->${chalk.green(` DEVELOPMENT `)}`);
|
||||
} else if (process.env.NODE_ENV === 'stage') {
|
||||
dotenv.config({ path: path.resolve(__dirname, '../.env.test' )});
|
||||
console.log(`Activando entorno de ->${chalk.yellow(` TESTING `)}`);
|
||||
} else if (process.env.NODE_ENV === 'production') {
|
||||
dotenv.config({ path: path.resolve(__dirname, '../.env.production' )});
|
||||
console.log(`Activando entorno de ->${chalk.red(` PRODUCTION `)}`);
|
||||
function requiredEnv(keys) {
|
||||
const missing = keys.filter((k) => !process.env[k]);
|
||||
if (missing.length) {
|
||||
console.warn(chalk.yellow(`⚠ Falta configurar variables de entorno: ${missing.join(', ')}`));
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("A ocurrido un error al seleccionar el entorno. \nError: " + error);
|
||||
}
|
||||
|
||||
// Configuración de renderizado
|
||||
function onFatal(err, msg = 'Error fatal') {
|
||||
console.error(chalk.red(`\n${msg}:`));
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Configuración Express
|
||||
// -----------------------------------------------------------------------------
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.set('trust proxy', 1);
|
||||
app.use(cors({ origin: true, credentials: true }));
|
||||
app.use(express.json());
|
||||
app.set('trust proxy', true);
|
||||
app.use(express.static(path.join(__dirname, 'pages')));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
/* 1) Motor de vistas apuntando a /auth/src/views */
|
||||
// Vistas EJS
|
||||
app.set('views', path.join(__dirname, 'views'));
|
||||
app.set('view engine', 'ejs');
|
||||
|
||||
/* 2) Estáticos si usás /css/main.css dentro de /auth/src/public */
|
||||
// Archivos estáticos opcionales (ajusta si tu estructura difiere)
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
app.use('/pages', express.static(path.join(__dirname, 'pages')));
|
||||
|
||||
/* 3) Exponer user a las vistas (opcional, cómodo) */
|
||||
app.use((req, res, next) => {
|
||||
res.locals.user = req.session?.user || null;
|
||||
next();
|
||||
});
|
||||
// -----------------------------------------------------------------------------
|
||||
// Sesión (Redis)
|
||||
// -----------------------------------------------------------------------------
|
||||
requiredEnv(['SESSION_SECRET', 'REDIS_URL']);
|
||||
const redis = createRedisClient({ url: process.env.REDIS_URL || 'redis://sessions-redis:6379' });
|
||||
await redis.connect().catch((e) => onFatal(e, 'No se pudo conectar a Redis (sesiones)'));
|
||||
|
||||
|
||||
/* 4) Página de login (renderiza el EJS de arriba)
|
||||
- Mantén /auth/login para iniciar OIDC (redirección a Authentik)
|
||||
- Usa /login para mostrar la página con el botón */
|
||||
app.get('/login', (req, res) => {
|
||||
res.render('login'); // -> /auth/src/views/login.ejs
|
||||
});
|
||||
|
||||
app.use(session({
|
||||
app.use(
|
||||
session({
|
||||
name: 'sc.sid',
|
||||
store: new RedisStore({ client: redis, prefix: 'sess:' }),
|
||||
secret: process.env.SESSION_SECRET || 'change-me',
|
||||
@ -85,420 +227,384 @@ app.use(session({
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
},
|
||||
}));
|
||||
})
|
||||
);
|
||||
|
||||
app.use(cookieSession({
|
||||
name: 'sid',
|
||||
secret: process.env.SESSION_SECRET,
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: false // en prod detrás de https: true
|
||||
}));
|
||||
|
||||
// Configuración de conexión PostgreSQL
|
||||
|
||||
const poolMeta = {
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASS,
|
||||
database: process.env.DB_NAME,
|
||||
port: process.env.DB_LOCAL_PORT
|
||||
};
|
||||
|
||||
const pool = new Pool(poolMeta);
|
||||
|
||||
const poolTenants = new Pool({ // apunta al servidor/base multi-tenant
|
||||
host: process.env.TENANTS_HOST, // dev-tenants
|
||||
user: process.env.TENANTS_USER,
|
||||
password: process.env.TENANTS_PASS,
|
||||
database: process.env.TENANTS_DB, // dev-postgres
|
||||
port: process.env.TENANTS_PORT,
|
||||
// Exponer usuario a las vistas (no tocar req.session)
|
||||
app.use((req, res, next) => {
|
||||
res.locals.user = req.session?.user || null;
|
||||
next();
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// PostgreSQL — DB tenants (usuarios de suitecoffee)
|
||||
// -----------------------------------------------------------------------------
|
||||
const tenantsPool = new Pool({
|
||||
host: process.env.TENANTS_HOST, // dev-tenants
|
||||
user: process.env.TENANTS_USER,
|
||||
password: process.env.TENANTS_PASS,
|
||||
database: process.env.TENANTS_DB, // dev-postgres
|
||||
port: process.env.TENANTS_PORT
|
||||
host: process.env.TENANTS_HOST || 'dev-tenants',
|
||||
port: Number(process.env.TENANTS_PORT || 5432),
|
||||
user: process.env.TENANTS_USER || 'dev-user-postgres',
|
||||
password: process.env.TENANTS_PASS || 'dev-pass-postgres',
|
||||
database: process.env.TENANTS_DB || 'dev-postgres',
|
||||
max: 10,
|
||||
});
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// PostgreSQL — DB principal (metadatos de negocio)
|
||||
// -----------------------------------------------------------------------------
|
||||
requiredEnv(['DB_HOST', 'DB_USER', 'DB_PASS', 'DB_NAME']);
|
||||
const pool = new Pool({
|
||||
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',
|
||||
max: 10,
|
||||
idleTimeoutMillis: 30_000,
|
||||
});
|
||||
|
||||
async function verificarConexion() {
|
||||
try {
|
||||
const client = await pool.connect();
|
||||
const res = await client.query('SELECT NOW() AS hora');
|
||||
console.log(`\nConexión con la base de datos ${chalk.green(process.env.DB_NAME)} fue exitosa.`);
|
||||
console.log('Fecha y hora actual de la base de datos:', res.rows[0].hora);
|
||||
client.release(); // liberar el cliente de nuevo al pool
|
||||
const { rows } = await client.query('SELECT NOW() AS ahora');
|
||||
console.log(`\nConexión con ${chalk.green(process.env.DB_NAME)} OK. Hora DB:`, rows[0].ahora);
|
||||
client.release();
|
||||
} catch (error) {
|
||||
console.error('Error al conectar con la base de datos al iniciar:', error.message);
|
||||
console.error(`Troubleshooting:\n1. Compruebe que las bases de datos se iniciaron correctamente.\n2. Verifique las credenciales y puertos de acceso a la base de datos.\n3. Si está conectandose a una base de datos externa a localhost, verifique las reglas del firewal de entrada y salida de ambos dispositivos.`);
|
||||
console.error('Revisar DB_HOST/USER/PASS/NAME, accesos de red y firewall.');
|
||||
}
|
||||
}
|
||||
|
||||
// Descubrimiento OIDC (una sola vez)
|
||||
// -----------------------------------------------------------------------------
|
||||
// OIDC (Authentik) — discovery + cliente
|
||||
// -----------------------------------------------------------------------------
|
||||
requiredEnv(['OIDC_ISSUER', 'OIDC_CLIENT_ID', 'OIDC_CLIENT_SECRET', 'OIDC_REDIRECT_URI']);
|
||||
|
||||
|
||||
async function discoverOIDCWithRetry(issuerUrl, { retries = 30, delayMs = 2000 } = {}) {
|
||||
let lastErr;
|
||||
for (let i = 1; i <= retries; i++) {
|
||||
try {
|
||||
const issuer = await Issuer.discover(issuerUrl);
|
||||
console.log(`[OIDC] issuer OK en intento ${i}:`, issuer.metadata.issuer);
|
||||
return issuer;
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
console.warn(`[OIDC] intento ${i}/${retries} falló: ${err.code || err.message}`);
|
||||
await sleep(delayMs);
|
||||
}
|
||||
}
|
||||
// No abortamos el proceso; dejamos el servidor vivo y seguimos reintentando en background
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
let oidcClient;
|
||||
(async () => {
|
||||
const issuer = await Issuer.discover(process.env.OIDC_ISSUER); // debe coincidir EXACTO
|
||||
try {
|
||||
const issuer = await discoverOIDCWithRetry(process.env.OIDC_ISSUER, { retries: 60, delayMs: 2000 });
|
||||
oidcClient = new issuer.Client({
|
||||
client_id: process.env.OIDC_CLIENT_ID,
|
||||
client_secret: process.env.OIDC_CLIENT_SECRET,
|
||||
redirect_uris: [process.env.OIDC_REDIRECT_URI],
|
||||
response_types: ['code'],
|
||||
});
|
||||
})().catch(err => {
|
||||
console.error('Error inicializando OIDC:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
|
||||
// util para resolver tenant si aún no usás claim tenant_uuid
|
||||
async function lookupTenantByEmail(email) {
|
||||
const { rows } = await poolMeta.query(
|
||||
`SELECT tenant_uuid FROM app_user WHERE email = $1 ORDER BY id LIMIT 1`,
|
||||
[email.toLowerCase()]
|
||||
);
|
||||
return rows[0]?.tenant_uuid || null;
|
||||
}
|
||||
|
||||
// === Servir páginas estáticas ===
|
||||
|
||||
app.get('/',(req, res) => res.sendFile(path.join(__dirname, 'pages', 'index.html')));
|
||||
|
||||
app.get('/planes', async (req, res) => {
|
||||
} catch (e) {
|
||||
console.error('⚠ No se pudo inicializar OIDC aún. Seguirá reintentando cada 10s en background.');
|
||||
// reintento en background cada 10s sin tumbar el proceso
|
||||
(async function loop() {
|
||||
try {
|
||||
const { rows: [row] } = await pool.query(
|
||||
'SELECT api.get_planes_json($1) AS data;',
|
||||
[true]
|
||||
const issuer = await Issuer.discover(process.env.OIDC_ISSUER);
|
||||
oidcClient = new issuer.Client({
|
||||
client_id: process.env.OIDC_CLIENT_ID,
|
||||
client_secret: process.env.OIDC_CLIENT_SECRET,
|
||||
redirect_uris: [process.env.OIDC_REDIRECT_URI],
|
||||
response_types: ['code'],
|
||||
});
|
||||
console.log('[OIDC] inicializado correctamente en reintento tardío');
|
||||
} catch {
|
||||
setTimeout(loop, 10000);
|
||||
}
|
||||
})();
|
||||
}
|
||||
})();
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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;
|
||||
|
||||
// guarda todo lo necesario para el callback
|
||||
req.session.code_verifier = code_verifier;
|
||||
req.session.state = state;
|
||||
|
||||
// log de depuración
|
||||
console.log('[OIDC] start login sid=%s state=%s', req.sessionID, state)
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
app.get('/auth/callback', async (req, res, next) => {
|
||||
try {
|
||||
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 }
|
||||
);
|
||||
res.type('application/json').send(row.data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Error al cargar planes' });
|
||||
|
||||
delete req.session.code_verifier;
|
||||
delete req.session.state;
|
||||
|
||||
const claims = tokenSet.claims();
|
||||
const email = (claims.email || '').toLowerCase();
|
||||
const tenantUuid = (claims.tenant_uuid || '').replace(/-/g, '');
|
||||
|
||||
let tenantHex = cleanUuid(claims.tenant_uuid);
|
||||
if (!tenantHex) {
|
||||
// intenta Authentik
|
||||
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]);
|
||||
tenantHex = cleanUuid(q.rows?.[0]?.tenant_uuid);
|
||||
}
|
||||
}
|
||||
|
||||
// Regenerar sesión para evitar fijación
|
||||
req.session.regenerate((err) => {
|
||||
if (err) return next(err);
|
||||
req.session.user = {
|
||||
sub: claims.sub,
|
||||
email,
|
||||
tenant_uuid: tenantUuid || null,
|
||||
};
|
||||
req.session.save((e2) => (e2 ? next(e2) : res.redirect('/')));
|
||||
});
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/auth/logout', (req, res) => {
|
||||
req.session.destroy(() => {
|
||||
res.clearCookie('sc.sid');
|
||||
res.status(204).end();
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/auth/me', (req, res) => {
|
||||
if (!req.session?.user) return res.status(401).json({ error: 'no-auth' });
|
||||
res.json({ user: req.session.user });
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Registro de usuario (DB principal + Authentik)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
app.post('/api/users/register', async (req, res, next) => {
|
||||
const { email, display_name, tenant_uuid, role, password } = req.body;
|
||||
const { email, display_name, tenant_uuid: requestedTenant, role } = req.body || {};
|
||||
if (!email) return res.status(400).json({ error: 'email es obligatorio' });
|
||||
|
||||
if (!email || !tenant_uuid) {
|
||||
return res.status(400).json({ error: 'email y tenant_uuid son obligatorios' });
|
||||
const emailLower = String(email).toLowerCase();
|
||||
|
||||
// 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 });
|
||||
}
|
||||
}
|
||||
|
||||
const client = await poolMeta.connect();
|
||||
// 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
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// 0) idempotencia: si ya existe en tu DB, devolvés 409 o retornás el existente
|
||||
const { rows: existing } = await client.query(
|
||||
`SELECT id, email, ak_sub FROM app_user WHERE email = $1`, [email]
|
||||
// Evitar duplicar usuario por email + tenant (ajusta según tu constraint)
|
||||
const dup = await client.query(
|
||||
'SELECT id FROM app_user WHERE LOWER(email)=LOWER($1) AND tenant_uuid=$2',
|
||||
[emailLower, tenantHex]
|
||||
);
|
||||
if (existing.length) {
|
||||
if (dup.rowCount) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(409).json({ error: 'El usuario ya existe en SuiteCoffee' });
|
||||
return res.status(409).json({
|
||||
error: 'user-exists',
|
||||
message: 'Ya existe un usuario con este email en este tenant.',
|
||||
next: '/auth/login',
|
||||
});
|
||||
}
|
||||
|
||||
// 1) crear/obtener usuario en Authentik
|
||||
let akUser = await akFindUserByEmail(email);
|
||||
// Authentik: crear si no existe; si existe, reusar y (opcional) asegurar attributes.tenant_uuid
|
||||
let akUser = await akFindUserByEmail(emailLower);
|
||||
if (!akUser) {
|
||||
akUser = await akCreateUser({
|
||||
email,
|
||||
email: emailLower,
|
||||
displayName: display_name,
|
||||
tenantUuid: tenant_uuid.replace(/-/g, ''),
|
||||
addToGroupId: process.env.AUTHENTIK_DEFAULT_GROUP_ID || null,
|
||||
tenantUuid: tenantHex, // se guarda en attributes
|
||||
addToGroupId: DEFAULT_GROUP_ID || null,
|
||||
isActive: true,
|
||||
});
|
||||
// Si querés asignar una clave inicial (no recomendado en prod), descomentá:
|
||||
// if (password) await akSetPassword(akUser.pk, password, 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 */ }
|
||||
}
|
||||
}
|
||||
|
||||
// el 'sub' lo tendrás recién tras login OIDC; guardamos el uuid interno si te sirve
|
||||
const _role = role || 'owner';
|
||||
|
||||
// 2) crear usuario local (sin password, dependemos del SSO)
|
||||
await client.query(
|
||||
`INSERT INTO app_user (email, display_name, tenant_uuid, ak_user_uuid, role)
|
||||
VALUES ($1,$2,$3,$4,$5)`,
|
||||
[email, display_name || null, tenant_uuid.replace(/-/g, ''), akUser.uuid, _role]
|
||||
[emailLower, display_name || null, tenantHex, akUser.uuid, _role]
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
return res.status(201).json({
|
||||
message: 'Usuario registrado',
|
||||
email, tenant_uuid, role: _role,
|
||||
email: emailLower,
|
||||
tenant_uuid: tenantHex, // devolvés el mismo
|
||||
role: _role,
|
||||
authentik_user_uuid: akUser.uuid,
|
||||
next: '/auth/login' // redirigí a OIDC
|
||||
next: '/auth/login',
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
try { await client.query('ROLLBACK'); } catch {}
|
||||
if (err?.code === '23505') { // unique_violation
|
||||
return res.status(409).json({ error: 'user-exists' });
|
||||
}
|
||||
next(err);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
// Espera: { email, display_name?, tenant_uuid }
|
||||
// app.post('/api/users/register', async (req, res, next) => {
|
||||
|
||||
// app.post('/api/registro', async (req, res) => {
|
||||
// const {
|
||||
// nombre_empresa, rut, correo, telefono, direccion, logo,
|
||||
// clave_acceso, plan_id
|
||||
// } = req.body;
|
||||
|
||||
// const clientMeta = await poolMeta.connect();
|
||||
// const clientTen = await poolTenants.connect();
|
||||
// const { email, display_name, tenant_uuid: rawTenant, role } = req.body || {};
|
||||
// if (!email) return res.status(400).json({ error: 'email es obligatorio' });
|
||||
// // Si no vino tenant: lo creamos
|
||||
// const { tenant_uuid, schema, role: dbRole } = await ensureTenant({ tenant_uuid: rawTenant });
|
||||
|
||||
// const client = await pool.connect();
|
||||
// try {
|
||||
// await clientMeta.query('BEGIN');
|
||||
// await client.query('BEGIN');
|
||||
|
||||
// // 1) Generar UUID sin guiones
|
||||
// const uuid = crypto.randomUUID().replace(/-/g, '');
|
||||
// const hash = await bcrypt.hash(clave_acceso, 10);
|
||||
// // ¿ya existe en tu DB?
|
||||
// const { rows: dup } = await client.query(
|
||||
// 'SELECT id FROM app_user WHERE email=$1 AND tenant_uuid=$2',
|
||||
// [email.toLowerCase(), tenant_uuid.replace(/-/g, '')]
|
||||
// );
|
||||
// if (dup.length) {
|
||||
// await client.query('ROLLBACK');
|
||||
// return res.status(409).json({ error: 'El usuario ya existe en SuiteCoffee' });
|
||||
// }
|
||||
|
||||
// // 2) Provisionar en PG (dev-tenants/dev-postgres)
|
||||
// const { rows: [prov] } = await clientTen.query(
|
||||
// `SELECT public.f_tenant_provision($1::text, $2::text) AS data`,
|
||||
// [uuid, `tenant_${uuid}`]
|
||||
// // Authentik: crear si no existe
|
||||
// let akUser = await akFindUserByEmail(email);
|
||||
// if (!akUser) {
|
||||
// akUser = await akCreateUser({
|
||||
// email,
|
||||
// displayName: display_name,
|
||||
// tenantUuid: tenant_uuid, // se normaliza dentro de ak.js
|
||||
// addToGroupId: DEFAULT_GROUP_ID || null,
|
||||
// isActive: true,
|
||||
// });
|
||||
// // Si querés forzar clave inicial (opcional; depende de tus políticas):
|
||||
// // await akSetPassword(akUser.pk, 'ClaveTemporal123!', true);
|
||||
// }
|
||||
|
||||
// const _role = role || 'owner';
|
||||
// await client.query(
|
||||
// `INSERT INTO app_user (email, display_name, tenant_uuid, ak_user_uuid, role)
|
||||
// VALUES ($1,$2,$3,$4,$5)`,
|
||||
// [email.toLowerCase(), display_name || null, tenant_uuid.replace(/-/g, ''), akUser.uuid, _role]
|
||||
// );
|
||||
|
||||
// const info = prov.data; // { tenant_uuid, schema, role, user, password, ... }
|
||||
|
||||
// // 3) Guardar metadatos en suitecoffee_db (tu tabla 'tenant')
|
||||
// await clientMeta.query(`
|
||||
// INSERT INTO tenant (
|
||||
// uuid, nombre_empresa, rut, correo, telefono, direccion, logo,
|
||||
// clave_acceso, plan_id,
|
||||
// schema_name, role_name, user_name
|
||||
// ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
||||
// `, [
|
||||
// uuid, nombre_empresa, rut, correo, telefono, direccion, logo,
|
||||
// hash, plan_id,
|
||||
// info.schema, info.role, info.user
|
||||
// ]);
|
||||
|
||||
// await clientMeta.query('COMMIT');
|
||||
|
||||
// // 4) Devolver credenciales del usuario de DB si las necesitás (mejor *no* persistir la password)
|
||||
// await client.query('COMMIT');
|
||||
// return res.status(201).json({
|
||||
// message: 'Tenant registrado correctamente',
|
||||
// uuid,
|
||||
// schema: info.schema,
|
||||
// db_user: info.user,
|
||||
// db_password: info.password // muéstrala *una vez* y recomendación: NO guardarla
|
||||
// message: 'Usuario registrado',
|
||||
// email,
|
||||
// tenant_uuid,
|
||||
// role: _role,
|
||||
// authentik_user_uuid: akUser.uuid,
|
||||
// next: '/auth/login'
|
||||
// });
|
||||
|
||||
// } catch (err) {
|
||||
// await clientMeta.query('ROLLBACK');
|
||||
// console.error(err);
|
||||
// return res.status(500).json({ error: 'Error al registrar tenant' });
|
||||
// try { await client.query('ROLLBACK'); } catch {}
|
||||
// next(err);
|
||||
// } finally {
|
||||
// clientMeta.release();
|
||||
// clientTen.release();
|
||||
// client.release();
|
||||
// }
|
||||
// });
|
||||
|
||||
app.post('/api/login', async (req, res) => {
|
||||
const { correo, clave_acceso } = req.body;
|
||||
|
||||
try {
|
||||
const client = await pool.connect();
|
||||
// -----------------------------------------------------------------------------
|
||||
// Healthcheck
|
||||
// -----------------------------------------------------------------------------
|
||||
app.get('/health', (_req, res) => res.status(200).json({ status: 'ok' }));
|
||||
|
||||
const result = await client.query(`
|
||||
SELECT uuid, clave_acceso, nombre_empresa, nombre_base_datos
|
||||
FROM tenant
|
||||
WHERE correo = $1 AND estado = true
|
||||
`, [correo]);
|
||||
// -----------------------------------------------------------------------------
|
||||
// 404 + Manejo de errores
|
||||
// -----------------------------------------------------------------------------
|
||||
app.use((req, res) => res.status(404).json({ error: 'not-found', path: req.originalUrl }));
|
||||
|
||||
client.release();
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(401).json({ error: 'Correo no registrado o inactivo' });
|
||||
}
|
||||
|
||||
const tenant = result.rows[0];
|
||||
const coincide = await bcrypt.compare(clave_acceso, tenant.clave_acceso);
|
||||
|
||||
if (!coincide) {
|
||||
return res.status(401).json({ error: 'Clave incorrecta' });
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
message: 'Login correcto',
|
||||
uuid: tenant.uuid,
|
||||
nombre_empresa: tenant.nombre_empresa,
|
||||
base_datos: tenant.nombre_base_datos
|
||||
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) });
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Error al validar login' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/auth/login', (req, res) => {
|
||||
const code_verifier = generators.codeVerifier();
|
||||
const code_challenge = generators.codeChallenge(code_verifier);
|
||||
req.session.code_verifier = code_verifier;
|
||||
|
||||
const url = oidcClient.authorizationUrl({
|
||||
scope: 'openid profile email offline_access tenant', // incluye tu scope custom “tenant”
|
||||
code_challenge: code_challenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
res.redirect(url);
|
||||
});
|
||||
|
||||
|
||||
// ------------- Middleware ----------------
|
||||
function getTenantUuid(req) {
|
||||
// Ejemplo 1: header
|
||||
if (req.headers['x-tenant-uuid']) return String(req.headers['x-tenant-uuid']);
|
||||
// Ejemplo 2: si más adelante usás JWT:
|
||||
// return req.user?.tenantUuid;
|
||||
throw new Error('Tenant no especificado');
|
||||
}
|
||||
|
||||
async function withTenant(req, res, next) {
|
||||
const client = await tenantsPool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const uuid = getTenantUuid(req).replace(/-/g, '');
|
||||
const schema = `schema_tenant_${uuid}`;
|
||||
await client.query(`SELECT public.f_set_search_path($1)`, [schema]);
|
||||
|
||||
// guardamos el cliente en req para reutilizar en los handlers
|
||||
req.pg = client;
|
||||
req.pgSchema = schema;
|
||||
next();
|
||||
} catch (e) {
|
||||
if (req.pg) await req.pg.query('ROLLBACK');
|
||||
if (req.pg) req.pg.release();
|
||||
return res.status(400).json({ error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Al final de cada handler, hacé COMMIT y release
|
||||
async function done(req, res, next) {
|
||||
try { if (req.pg) await req.pg.query('COMMIT'); }
|
||||
finally { if (req.pg) req.pg.release(); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// --- login: redirige a Authentik con PKCE
|
||||
app.get('/auth/login', async (req, res) => {
|
||||
const client = await getClient();
|
||||
const state = generators.state();
|
||||
const code_verifier = generators.codeVerifier();
|
||||
const code_challenge = generators.codeChallenge(code_verifier);
|
||||
|
||||
req.session.state = state;
|
||||
req.session.code_verifier = code_verifier;
|
||||
|
||||
const authUrl = client.authorizationUrl({
|
||||
scope: 'openid profile email',
|
||||
state,
|
||||
code_challenge,
|
||||
code_challenge_method: 'S256'
|
||||
});
|
||||
|
||||
res.redirect(authUrl);
|
||||
});
|
||||
|
||||
app.use((req,res,next)=>{ res.locals.user = req.session?.user || null; next(); });
|
||||
|
||||
|
||||
// --- callback: intercambia code por tokens y guarda sesión mínima
|
||||
// --- RUTA: callback (enlaza la sesión con tu usuario local)
|
||||
app.get('/auth/callback', async (req, res, next) => {
|
||||
try {
|
||||
const params = oidcClient.callbackParams(req);
|
||||
const tokenSet = await oidcClient.callback(
|
||||
process.env.OIDC_REDIRECT_URI,
|
||||
params,
|
||||
{ code_verifier: req.session.code_verifier }
|
||||
);
|
||||
const claims = tokenSet.claims(); // { sub, email, tenant_uuid?, ... }
|
||||
|
||||
const email = (claims.email || '').toLowerCase();
|
||||
const sub = claims.sub;
|
||||
const tenantUuid = (claims.tenant_uuid || await lookupTenantByEmail(email))?.replace(/-/g, '');
|
||||
|
||||
if (!tenantUuid) {
|
||||
return res.status(403).send('No se pudo determinar el tenant del usuario.');
|
||||
}
|
||||
|
||||
// Asegurar presencia del usuario en tu DB y enlazar el sub de OIDC
|
||||
const { rows } = await poolMeta.query(
|
||||
`SELECT id, ak_sub FROM app_user WHERE email=$1 AND tenant_uuid=$2`,
|
||||
[email, tenantUuid]
|
||||
);
|
||||
let userId;
|
||||
if (rows.length) {
|
||||
userId = rows[0].id;
|
||||
if (!rows[0].ak_sub) {
|
||||
await poolMeta.query(`UPDATE app_user SET ak_sub=$1 WHERE id=$2`, [sub, userId]);
|
||||
}
|
||||
} else {
|
||||
// “just in time” create (opcional): lo das de alta si no existe aún en tu app
|
||||
const ins = await poolMeta.query(
|
||||
`INSERT INTO app_user (email, tenant_uuid, ak_sub, role)
|
||||
VALUES ($1,$2,$3,'staff') RETURNING id`,
|
||||
[email, tenantUuid, sub]
|
||||
);
|
||||
userId = ins.rows[0].id;
|
||||
}
|
||||
|
||||
// Sesión de aplicación (lo que el resto del backend necesita)
|
||||
req.session.user = { id: userId, email, tenant_uuid: tenantUuid, sub };
|
||||
|
||||
req.session.regenerate(err => {
|
||||
if (err) return next(err);
|
||||
req.session.user = { id: userId, email, tenant_uuid: tenantUuid, sub };
|
||||
req.session.save(err2 => {
|
||||
if (err2) return next(err2);
|
||||
return res.redirect('/');
|
||||
});
|
||||
});
|
||||
|
||||
// redirige a la app (home o dashboard)
|
||||
res.redirect('/');
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// (Opcional) logout “local”
|
||||
app.post('/auth/logout', (req, res) => {
|
||||
req.session.destroy(() => res.clearCookie('sc.sid').status(204).end());
|
||||
});
|
||||
|
||||
// --- ver quién soy (para probar)
|
||||
app.get('/auth/me', (req, res) => {
|
||||
if (!req.session?.user) return res.status(401).json({ error: 'no autenticado' });
|
||||
res.json({ user: req.session.user });
|
||||
});
|
||||
|
||||
// --- logout simple (borra cookie)
|
||||
app.post('/auth/logout', (req, res) => {
|
||||
req.session = null;
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// Colores personalizados
|
||||
let primaryColor = chalk.hex('#'+`${process.env.COL_PRI}`);
|
||||
let secondaryColor = chalk.hex('#'+`${process.env.COL_SEC}`);
|
||||
// let backgroundColor = chalk.hex('#'+`${process.env.COL_BG}`);
|
||||
|
||||
|
||||
app.use(expressLayouts);
|
||||
// Iniciar servidor
|
||||
app.listen( process.env.PORT, () => {
|
||||
console.log(`Servidor de ${chalk.yellow('autenticación')} de ${secondaryColor('SuiteCoffee')} corriendo en ${chalk.yellow(`http://localhost:${process.env.PORT}\n`)}` );
|
||||
console.log(chalk.grey(`Comprobando accesibilidad a la db ${chalk.green(process.env.DB_NAME)} del host ${chalk.white(`${process.env.DB_HOST}`)} ...`));
|
||||
verificarConexion();
|
||||
});
|
||||
|
||||
app.get("/health", async (req, res) => {
|
||||
// Podés chequear DB aquí. 200 = healthy; 503 = not ready.
|
||||
res.status(200).json({ status: "ok" });
|
||||
// -----------------------------------------------------------------------------
|
||||
// Arranque
|
||||
// -----------------------------------------------------------------------------
|
||||
const PORT = Number(process.env.PORT || 4040);
|
||||
|
||||
(async () => {
|
||||
const env = (process.env.NODE_ENV || 'development').toUpperCase();
|
||||
console.log(`Activando entorno de -> ${env === 'PRODUCTION' ? chalk.red('PRODUCTION') : chalk.green('DEVELOPMENT')}`);
|
||||
await verificarConexion();
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Servidor de autenticación de SuiteCoffee corriendo en ${chalk.yellow(`http://localhost:${PORT}`)}`);
|
||||
});
|
||||
})();
|
||||
|
||||
@ -2,24 +2,163 @@
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Iniciar sesión | SuiteCoffee</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="/css/main.css"/>
|
||||
</head>
|
||||
<body class="container">
|
||||
<header class="my-4">
|
||||
<h1>SuiteCoffee — Acceso</h1>
|
||||
</header>
|
||||
<title><%= typeof pageTitle !== 'undefined' ? pageTitle : 'Iniciar sesión · SuiteCoffee' %></title>
|
||||
|
||||
<% if (user) { %>
|
||||
<p>Ya iniciaste sesión como <strong><%= user.email %></strong>.</p>
|
||||
<p>Continuar a <a href="/">la aplicación</a></p>
|
||||
<% } else { %>
|
||||
<div class="card p-4">
|
||||
<p>Usamos inicio de sesión único (SSO) con nuestro Identity Provider.</p>
|
||||
<!-- Esta URL dispara el flujo OIDC hacia Authentik -->
|
||||
<a class="btn btn-primary btn-lg" href="/auth/login">Iniciar sesión con SuiteCoffee SSO</a>
|
||||
<!-- Bootstrap 5 (minimal) -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--col-pri: #<%= (process.env.COL_PRI || '452D19') %>;
|
||||
--col-sec: #<%= (process.env.COL_SEC || 'D7A666') %>;
|
||||
--col-bg: #<%= (process.env.COL_BG || 'FFA500') %>33; /* con alpha */
|
||||
}
|
||||
body { background: radial-gradient(1200px 600px at 10% -10%, var(--col-bg), transparent), #f8f9fa; }
|
||||
.brand { color: var(--col-pri); }
|
||||
.btn-sso { background: var(--col-pri); color: #fff; border-color: var(--col-pri); }
|
||||
.btn-sso:hover { filter: brightness(1.05); color: #fff; }
|
||||
.card { border-radius: 14px; }
|
||||
.form-hint { font-size: .875rem; color: #6c757d; }
|
||||
.divider { display:flex; align-items:center; text-transform:uppercase; font-size:.8rem; color:#6c757d; }
|
||||
.divider::before, .divider::after { content:""; height:1px; background:#dee2e6; flex:1; }
|
||||
.divider:not(:empty)::before { margin-right:.75rem; }
|
||||
.divider:not(:empty)::after { margin-left:.75rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container py-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-sm-10 col-md-8 col-lg-6 col-xl-5">
|
||||
<div class="text-center mb-4">
|
||||
<h1 class="brand fw-bold">SuiteCoffee</h1>
|
||||
<p class="text-secondary mb-0">Accedé a tu cuenta</p>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<!-- Mensajes (query ?msg= / ?error=) -->
|
||||
<div id="flash" class="mb-3" style="display:none"></div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-4 p-md-5">
|
||||
|
||||
<!-- SSO con Authentik -->
|
||||
<div class="d-grid gap-2 mb-3">
|
||||
<a href="/auth/login" class="btn btn-sso btn-lg" id="btn-sso">
|
||||
Ingresar con SSO (Authentik)
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="divider my-3">o</div>
|
||||
|
||||
<!-- Registro mínimo (usa POST /api/users/register) -->
|
||||
<form id="form-register" class="needs-validation" novalidate>
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input type="email" class="form-control" id="email" name="email" placeholder="tu@correo.com" required>
|
||||
<div class="invalid-feedback">Ingresá un email válido.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="display_name" class="form-label">Nombre a mostrar</label>
|
||||
<input type="text" class="form-control" id="display_name" name="display_name" placeholder="Ej.: Juan Pérez" required>
|
||||
<div class="invalid-feedback">Ingresá tu nombre.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="tenant_uuid" class="form-label">Código de organización (tenant UUID)</label>
|
||||
<input type="text" class="form-control" id="tenant_uuid" name="tenant_uuid" placeholder="Ej.: 4b8d0f6a-...">
|
||||
<div class="form-hint">Si te invitaron a una organización existente, pegá aquí su UUID. Si sos el primero de tu empresa, dejalo vacío y el equipo te asignará uno.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="role" class="form-label">Rol</label>
|
||||
<select id="role" name="role" class="form-select">
|
||||
<option value="owner">Owner</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="staff">Staff</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<button type="submit" class="btn btn-outline-dark">Crear cuenta</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p class="text-center text-muted mt-3 mb-0" style="font-size:.9rem;">
|
||||
Al continuar aceptás nuestros términos y políticas.
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-secondary mt-3" style="font-size:.9rem;">
|
||||
¿Ya tenés cuenta? <a href="/auth/login" class="link-dark">Iniciá sesión con SSO</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Mostrar mensajes por querystring (?msg=... / ?error=...)
|
||||
(function() {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const el = document.getElementById('flash');
|
||||
const msg = params.get('msg');
|
||||
const err = params.get('error');
|
||||
if (msg) {
|
||||
el.innerHTML = `<div class="alert alert-success mb-0" role="alert">${decodeURIComponent(msg)}</div>`;
|
||||
el.style.display = '';
|
||||
} else if (err) {
|
||||
el.innerHTML = `<div class="alert alert-danger mb-0" role="alert">${decodeURIComponent(err)}</div>`;
|
||||
el.style.display = '';
|
||||
}
|
||||
})();
|
||||
|
||||
// Validación Bootstrap + envío del registro contra /api/users/register
|
||||
(function() {
|
||||
const form = document.getElementById('form-register');
|
||||
form.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
form.classList.add('was-validated');
|
||||
if (!form.checkValidity()) return;
|
||||
|
||||
const btn = form.querySelector('button[type="submit"]');
|
||||
btn.disabled = true; btn.innerText = 'Creando...';
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
email: document.getElementById('email').value.trim(),
|
||||
display_name: document.getElementById('display_name').value.trim(),
|
||||
tenant_uuid: document.getElementById('tenant_uuid').value.trim() || undefined,
|
||||
role: document.getElementById('role').value
|
||||
};
|
||||
|
||||
const res = await fetch('/api/users/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(data?.error || data?.message || 'No se pudo registrar');
|
||||
}
|
||||
|
||||
// Registro OK → redirigimos a login SSO
|
||||
const redir = '/auth/login';
|
||||
location.href = redir + '?msg=' + encodeURIComponent('Registro exitoso. Iniciá sesión con SSO.');
|
||||
} catch (err) {
|
||||
alert(err.message || String(err));
|
||||
} finally {
|
||||
btn.disabled = false; btn.innerText = 'Crear cuenta';
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user