Mucha cosa y es muy tarde.

- Anda parte del registro
This commit is contained in:
Mateo Saldain 2025-09-05 08:13:09 +00:00
parent 80778c0ed9
commit 237a5427dd
12 changed files with 4584 additions and 1348 deletions

View File

@ -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
@ -63,12 +62,13 @@ services:
networks:
net:
aliases: [dev-tenants]
#################
### Authentik ###
#################
# --- Authentik db (solo interno)
authentik-db:
image: postgres:16
environment:
POSTGRES_DB: authentik
POSTGRES_USER: authentik
@ -77,17 +77,17 @@ services:
- authentik-db:/var/lib/postgresql/data
networks:
net:
aliases: [ak-db]
aliases: [ak-db]
# --- Authentik Redis (solo interno)
authentik-redis:
image: redis:7-alpine
command: ["redis-server", "--save", "", "--appendonly", "no"]
networks:
net:
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}
networks:
net:
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,8 +121,11 @@ services:
AUTHENTIK_POSTGRESQL__NAME: authentik
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_DB_PASS}
AUTHENTIK_REDIS__HOST: authentik-redis
networks:
net:
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]
volumes:
@ -128,4 +135,4 @@ volumes:
networks:
net:
driver: bridge
driver: bridge

View File

@ -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-db: { condition: service_healthy }
authentik-redis: { condition: service_healthy }
restart: unless-stopped
authentik-worker:

View File

@ -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,

View File

@ -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

View 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' }); });
}

View File

@ -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",

View File

@ -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",

View File

@ -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,
});
// 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;
/**
* 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 };
}
// Crea usuario en Authentik con atributo tenant_uuid y lo agrega a un grupo (opcional)
export async function akCreateUser({ email, displayName, tenantUuid, addToGroupId }) {
// ------------------------------------------------------------
// 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 },
});
}
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 } },
});
return created.pk;
}
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 } });
}
// 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 {
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) {
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 { 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
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 por defecto (opcional)
if (addToGroupId) {
await AK.post(`/core/users/${user.pk}/groups/`, { group: addToGroupId });
// 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; // contiene pk y uuid
return user;
}
// Opcional: setear/forzar password inicial (si querés flujo con password local en Authentik)
/**
* 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 AK.post(`/core/users/${userPk}/set_password/`, {
password, require_change: requireChange,
await request('POST', `/core/users/${userPk}/set_password/`, {
body: { password, require_change: !!requireChange },
retries: 1,
});
return true;
} 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');
// 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
// ------------------------------------------------------------

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,25 +1,164 @@
<!doctype html>
<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>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<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>
<% } %>
</body>
<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>