Inclusión del dominio suitecoffee.uy al NPM.
Se ajustaron los problemas de renderizado y redirección mayores de https://suitecoffee.uy/ Se re-creó el archivo ~/SuiteCoffee/services/app/src/index.js para mantener un orden adecuado Las rutas exigen una cookie de seción para cargarse, de o contrario redireccionan a https://suitecoffee.uy/auth/login para iniciar o crear sesión de usuario, excepto https://suitecoffee.uy/inicio que se mantene de esta manera con motivos de desarrollo
This commit is contained in:
parent
5d078f3932
commit
69f5860b7f
@ -5,31 +5,42 @@ COMPOSE_PROJECT_NAME=suitecoffee_dev
|
|||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
|
||||||
# app - app
|
# app - app
|
||||||
APP_LOCAL_PORT=3030
|
APP_PORT=3030
|
||||||
APP_DOCKER_PORT=3030
|
|
||||||
|
|
||||||
# auth - app
|
# auth - app
|
||||||
AUTH_LOCAL_PORT=4040
|
AUTH_PORT=4040
|
||||||
AUTH_DOCKER_PORT=4040
|
|
||||||
|
|
||||||
# tenants - postgres
|
# tenants - postgres
|
||||||
TENANTS_DB_NAME=dev-postgres
|
TENANTS_DB_NAME=dev-postgres
|
||||||
TENANTS_DB_USER=dev-user-postgres
|
TENANTS_DB_USER=dev-user-postgres
|
||||||
TENANTS_DB_PASS=dev-pass-postgres
|
TENANTS_DB_PASS=dev-pass-postgres
|
||||||
|
|
||||||
TENANTS_DB_LOCAL_PORT=54321
|
|
||||||
TENANTS_DB_DOCKER_PORT=5432
|
|
||||||
|
|
||||||
# db primaria - postgres
|
# db primaria - postgres
|
||||||
DB_NAME=dev-suitecoffee
|
DB_NAME=dev-suitecoffee
|
||||||
DB_USER=dev-user-suitecoffee
|
DB_USER=dev-user-suitecoffee
|
||||||
DB_PASS=dev-pass-suitecoffee
|
DB_PASS=dev-pass-suitecoffee
|
||||||
|
|
||||||
DB_LOCAL_PORT=54322
|
|
||||||
DB_DOCKER_PORT=5432
|
|
||||||
|
|
||||||
# --- secretos para Authentik
|
# --- secretos para Authentik
|
||||||
AUTHENTIK_SECRET_KEY=poné_un_valor_largo_y_unico
|
AK_SECRET_KEY=Timothy-Yearning-Unzip-Playmate3-Snowiness-Desecrate
|
||||||
AUTHENTIK_DB_PASS=cambia_esto
|
AK_DB_PASS=Doable8
|
||||||
AUTHENTIK_BOOTSTRAP_PASSWORD=cambia_esto
|
AK_BOOTSTRAP_PASSWORD=Succulent-Sanded7
|
||||||
AUTHENTIK_BOOTSTRAP_EMAIL=info.suitecoffee@gmail.com
|
AK_BOOTSTRAP_EMAIL=info.suitecoffee@gmail.com
|
||||||
|
|
||||||
|
PG_PASS=gOWjL8V564vyh1aXUcqh4o/xo7eObraaCVZezPi3iw2LzPlU
|
||||||
|
AUTHENTIK_SECRET_KEY=11zMsUL57beO+okjeGh7OB3lQdGUWII+VaATHs/zsw1+6KMSTyGfAY0yHpq3C442+3CwrZ/KtjgHBfbv
|
||||||
|
AUTHENTIK_ERROR_REPORTING__ENABLED=true
|
||||||
|
|
||||||
|
# SMTP Host Emails are sent to
|
||||||
|
AUTHENTIK_EMAIL__HOST=localhost
|
||||||
|
AUTHENTIK_EMAIL__PORT=25
|
||||||
|
# Optionally authenticate (don't add quotation marks to your password)
|
||||||
|
AUTHENTIK_EMAIL__USERNAME=info.suitecoffee@gmail.com
|
||||||
|
AUTHENTIK_EMAIL__PASSWORD=Succulent-Sanded7
|
||||||
|
# Use StartTLS
|
||||||
|
AUTHENTIK_EMAIL__USE_TLS=false
|
||||||
|
# Use SSL
|
||||||
|
AUTHENTIK_EMAIL__USE_SSL=false
|
||||||
|
AUTHENTIK_EMAIL__TIMEOUT=10
|
||||||
|
# Email address authentik will send from, should have a correct @domain
|
||||||
|
AUTHENTIK_EMAIL__FROM=info.suitecoffee@gmail.com
|
||||||
@ -5,12 +5,10 @@ COMPOSE_PROJECT_NAME=suitecoffee_prod
|
|||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
|
|
||||||
# app - app
|
# app - app
|
||||||
APP_LOCAL_PORT=3000
|
APP_PORT=3000
|
||||||
APP_DOCKER_PORT=3000
|
|
||||||
|
|
||||||
# auth - app
|
# auth - app
|
||||||
AUTH_LOCAL_PORT=4000
|
AUTH_PORT=4000
|
||||||
AUTH_DOCKER_PORT=4000
|
|
||||||
|
|
||||||
# tenants - postgres
|
# tenants - postgres
|
||||||
TENANTS_DB_NAME=postgres
|
TENANTS_DB_NAME=postgres
|
||||||
|
|||||||
344
app.index.js
Normal file
344
app.index.js
Normal file
@ -0,0 +1,344 @@
|
|||||||
|
// services/app/src/index.js
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// SuiteCoffee — Servicio APP (UI + APIs negocio)
|
||||||
|
// - ESM (Node >=18)
|
||||||
|
// - Vistas EJS en ./views (dashboard.ejs, comandas.ejs, etc.)
|
||||||
|
// - Sesión compartida con AUTH (cookie: sc.sid, Redis)
|
||||||
|
// - Monta routes.legacy.js con requireAuth + withTenant
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
import 'dotenv/config';
|
||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import morgan from 'morgan';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import session from 'express-session';
|
||||||
|
import expressLayouts from 'express-ejs-layouts';
|
||||||
|
// import RedisStore from "connect-redis";
|
||||||
|
import { createClient } from 'redis';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Utilidades base
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
const qi = (ident) => `"${String(ident).replace(/"/g, '""')}"`;
|
||||||
|
const CLEAN_HEX = (s) => (String(s || '').toLowerCase().replace(/[^0-9a-f]/g, '') || null);
|
||||||
|
const REQUIRED = (...keys) => {
|
||||||
|
const miss = keys.filter((k) => !process.env[k]);
|
||||||
|
if (miss.length) {
|
||||||
|
console.warn(`⚠ Faltan variables de entorno: ${miss.join(', ')}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Validación de entorno mínimo (ajusta nombres si difieren)
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
REQUIRED(
|
||||||
|
// Sesión
|
||||||
|
'SESSION_SECRET', 'REDIS_URL',
|
||||||
|
// DB principal
|
||||||
|
'DB_HOST', 'DB_NAME', 'DB_USER', 'DB_PASS',
|
||||||
|
// DB de tenants
|
||||||
|
'TENANTS_HOST', 'TENANTS_DB', 'TENANTS_USER', 'TENANTS_PASS'
|
||||||
|
);
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Pools de PostgreSQL
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
const mainPool = new Pool({
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
port: Number(process.env.DB_PORT || 5432),
|
||||||
|
database: process.env.DB_NAME,
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
password: process.env.DB_PASS,
|
||||||
|
max: 10,
|
||||||
|
idleTimeoutMillis: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tenantsPool = new Pool({
|
||||||
|
host: process.env.TENANTS_HOST,
|
||||||
|
port: Number(process.env.TENANTS_PORT || 5432),
|
||||||
|
database: process.env.TENANTS_DB,
|
||||||
|
user: process.env.TENANTS_USER,
|
||||||
|
password: process.env.TENANTS_PASS,
|
||||||
|
max: 10,
|
||||||
|
idleTimeoutMillis: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Autotest (no rompe si falla; sólo loguea)
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const c = await mainPool.connect();
|
||||||
|
const r = await c.query('SELECT NOW() now');
|
||||||
|
console.log('[APP] DB principal OK. Hora:', r.rows[0].now);
|
||||||
|
c.release();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[APP] Error al conectar DB principal:', e.message);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const c = await tenantsPool.connect();
|
||||||
|
const r = await c.query('SELECT NOW() now');
|
||||||
|
console.log('[APP] DB tenants OK. Hora:', r.rows[0].now);
|
||||||
|
c.release();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[APP] Error al conectar DB tenants:', e.message);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Express + EJS
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
const app = express();
|
||||||
|
app.set('trust proxy', Number(process.env.TRUST_PROXY_HOPS || 2));
|
||||||
|
app.use(cors({ origin: true, credentials: true }));
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// Motor de vistas EJS
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
|
||||||
|
// Views EJS en ./views
|
||||||
|
app.set('views', path.join(__dirname, 'views'));
|
||||||
|
app.set('view engine', 'ejs');
|
||||||
|
app.use(expressLayouts);
|
||||||
|
app.set("layout", "layouts/main");
|
||||||
|
|
||||||
|
// Estáticos (si tenés carpeta public/, assets, etc.)
|
||||||
|
app.use('/public', express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
|
// Middlewares básicos
|
||||||
|
app.use(morgan('dev'));
|
||||||
|
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// Middleware para datos globales
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
res.locals.currentPath = req.path;
|
||||||
|
res.locals.pageTitle = "SuiteCoffee";
|
||||||
|
res.locals.pageId = "";
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// Rutas de UI
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
app.get("/", (req, res) => {
|
||||||
|
res.locals.pageTitle = "Inicio";
|
||||||
|
res.locals.pageId = "inicio";
|
||||||
|
res.render("inicio");
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Sesión (Redis) — misma cookie que AUTH
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const SESSION_COOKIE_NAME = process.env.SESSION_COOKIE_NAME || "sc.sid";
|
||||||
|
const SESSION_SECRET = process.env.SESSION_SECRET || "pon-una-clave-larga-y-unica";
|
||||||
|
const REDIS_URL = process.env.REDIS_URL || "redis://authentik-redis:6379";
|
||||||
|
|
||||||
|
// 1) Redis client
|
||||||
|
const redis = createClient({ url: REDIS_URL /*, legacyMode: true */ });
|
||||||
|
redis.on("error", (err) => console.error("[Redis] Client Error:", err));
|
||||||
|
await redis.connect();
|
||||||
|
console.log("[Redis] connected");
|
||||||
|
|
||||||
|
// 2) Resolver RedisStore (soporta:
|
||||||
|
// - v5: factory CJS -> connectRedis(session)
|
||||||
|
// - v6/v7: export { RedisStore } ó export default class RedisStore)
|
||||||
|
async function resolveRedisStore(session) {
|
||||||
|
const mod = await import("connect-redis"); // ESM/CJS agnóstico
|
||||||
|
// named export (v6/v7)
|
||||||
|
if (typeof mod.RedisStore === "function") return mod.RedisStore;
|
||||||
|
// default export (class ó factory)
|
||||||
|
if (typeof mod.default === "function") {
|
||||||
|
// ¿es clase neweable?
|
||||||
|
if (mod.default.prototype && (mod.default.prototype.get || mod.default.prototype.set)) {
|
||||||
|
return mod.default; // class RedisStore
|
||||||
|
}
|
||||||
|
// si no, asumimos factory antigua
|
||||||
|
const Store = mod.default(session); // connectRedis(session)
|
||||||
|
if (typeof Store === "function") return Store; // class devuelta por factory
|
||||||
|
}
|
||||||
|
// algunos builds CJS exponen la factory bajo mod (poco común)
|
||||||
|
if (typeof mod === "function") {
|
||||||
|
const Store = mod(session);
|
||||||
|
if (typeof Store === "function") return Store;
|
||||||
|
}
|
||||||
|
throw new Error("connect-redis: no se pudo resolver RedisStore (API desconocida).");
|
||||||
|
}
|
||||||
|
|
||||||
|
const RedisStore = await resolveRedisStore(session);
|
||||||
|
|
||||||
|
// 3) Session middleware
|
||||||
|
app.use(session({
|
||||||
|
name: SESSION_COOKIE_NAME,
|
||||||
|
secret: SESSION_SECRET,
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
store: new RedisStore({
|
||||||
|
client: redis,
|
||||||
|
prefix: "sc:", // opcional
|
||||||
|
}),
|
||||||
|
proxy: true,
|
||||||
|
cookie: {
|
||||||
|
secure: "auto",
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/", // ¡crítico! visible en / y /auth/*
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Middlewares de Auth/Tenant para routes.legacy.js
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
function requireAuth(req, res, next) {
|
||||||
|
if (!req.session?.user) return res.redirect(303, "/auth/login");
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abre un client al DB de tenants y fija search_path al esquema del usuario
|
||||||
|
async function withTenant(req, res, next) {
|
||||||
|
try {
|
||||||
|
const hex = CLEAN_HEX(req.session?.user?.tenant_uuid);
|
||||||
|
if (!hex) return res.status(400).json({ error: 'tenant-missing' });
|
||||||
|
|
||||||
|
const schema = `schema_tenant_${hex}`;
|
||||||
|
const client = await tenantsPool.connect();
|
||||||
|
|
||||||
|
// Fijar search_path para que las consultas apunten al esquema del tenant
|
||||||
|
await client.query(`SET SESSION search_path TO ${qi(schema)}, public`);
|
||||||
|
|
||||||
|
// Hacemos el client accesible para los handlers de routes.legacy.js
|
||||||
|
req.pg = client;
|
||||||
|
|
||||||
|
// Liberar el client al finalizar la respuesta
|
||||||
|
const release = () => {
|
||||||
|
try { client.release(); } catch {}
|
||||||
|
};
|
||||||
|
res.on('finish', release);
|
||||||
|
res.on('close', release);
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (e) {
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No-op (compatibilidad con el archivo legacy si lo pasa al final)
|
||||||
|
function done(_req, _res, next) { return next && next(); }
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Home / Landing
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// app.get('/', (req, res) => {
|
||||||
|
// if (req.session?.user) return res.redirect(303, "/inicio");
|
||||||
|
// return res.redirect(303, "/auth/login");
|
||||||
|
// });
|
||||||
|
|
||||||
|
// Página de login
|
||||||
|
app.get("/auth/login", (_req, res) => {
|
||||||
|
return res.render("login", { pageTitle: "Iniciar sesión" });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/', (_req, res) => {
|
||||||
|
return res.render("inicio", { pageTitle: "Bienvenido" });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/", (_req, res) => res.redirect(303, "/auth/login"));
|
||||||
|
|
||||||
|
app.use([
|
||||||
|
"/dashboard",
|
||||||
|
"/comandas",
|
||||||
|
"/estadoComandas",
|
||||||
|
"/productos",
|
||||||
|
"/usuarios",
|
||||||
|
"/reportes",
|
||||||
|
"/compras",
|
||||||
|
], requireAuth);
|
||||||
|
|
||||||
|
|
||||||
|
// Página para definir contraseña (el form envía al servicio AUTH)
|
||||||
|
app.get('/set-password', (req, res) => {
|
||||||
|
const pp = req.session?.pendingPassword;
|
||||||
|
if (!pp) return req.session?.user ? res.redirect('/comandas') : res.redirect('/auth/login');
|
||||||
|
|
||||||
|
res.type('html').send(`
|
||||||
|
<!doctype html><meta charset="utf-8">
|
||||||
|
<title>SuiteCoffee · Definir contraseña</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||||
|
<div class="container py-5" style="max-width:520px;">
|
||||||
|
<h2 class="mb-4">Definir contraseña</h2>
|
||||||
|
<form method="post" action="/auth/password/set" class="vstack gap-3">
|
||||||
|
<input class="form-control" type="password" name="password" placeholder="Nueva contraseña" minlength="8" required>
|
||||||
|
<input class="form-control" type="password" name="password2" placeholder="Repetí la contraseña" minlength="8" required>
|
||||||
|
<button class="btn btn-primary" type="submit">Guardar y continuar</button>
|
||||||
|
<small class="text-muted">Luego te redirigiremos a iniciar sesión por SSO.</small>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Montar rutas legacy (render de EJS y APIs de negocio)
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
const legacy = await import('./routes.legacy.js');
|
||||||
|
legacy.default(app, {
|
||||||
|
requireAuth,
|
||||||
|
withTenant,
|
||||||
|
done,
|
||||||
|
mainPool,
|
||||||
|
tenantsPool,
|
||||||
|
express,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// Verificación de conexión
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
async function verificarConexion() {
|
||||||
|
try {
|
||||||
|
const client = await pool.connect();
|
||||||
|
const res = await client.query('SELECT NOW() AS hora');
|
||||||
|
console.log(`Conexión con la base de datos ${process.env.DB_NAME} fue exitosa.`);
|
||||||
|
console.log('Fecha y hora actual de la base de datos:', res.rows[0].hora);
|
||||||
|
client.release();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error al conectar con la base de datos al iniciar:', error.message);
|
||||||
|
console.error('Revisar credenciales y accesos de red.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Health + 404 + errores
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
app.get('/health', (_req, res) => res.status(200).json({ status: 'ok', service: 'app' }));
|
||||||
|
|
||||||
|
app.use((req, res) => res.status(404).json({ error: 'not-found', path: req.originalUrl }));
|
||||||
|
|
||||||
|
app.use((err, _req, res, _next) => {
|
||||||
|
console.error('[APP] Error:', err);
|
||||||
|
if (res.headersSent) return;
|
||||||
|
res.status(500).json({ error: 'internal-error', detail: err?.message || String(err) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Arranque
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
const PORT = Number(process.env.PORT || process.env.APP_LOCAL_PORT || 3030);
|
||||||
|
// app.listen(PORT, () => {
|
||||||
|
// console.log(`[APP] SuiteCoffee corriendo en http://localhost:${PORT}`);
|
||||||
|
// });
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
await verificarConexion();
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`[APP] SuiteCoffee corriendo en http://localhost:${PORT}`);
|
||||||
|
});
|
||||||
|
})();
|
||||||
@ -15,7 +15,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
NODE_ENV: development # <- fuerza el entorno para que el loader tome .env.development
|
NODE_ENV: development # <- fuerza el entorno para que el loader tome .env.development
|
||||||
expose:
|
expose:
|
||||||
- ${APP_LOCAL_PORT}
|
- ${APP_PORT}
|
||||||
networks:
|
networks:
|
||||||
net:
|
net:
|
||||||
aliases: [dev-app]
|
aliases: [dev-app]
|
||||||
@ -33,7 +33,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
NODE_ENV: development # <- fuerza el entorno para que el loader tome .env.development
|
NODE_ENV: development # <- fuerza el entorno para que el loader tome .env.development
|
||||||
expose:
|
expose:
|
||||||
- ${AUTH_LOCAL_PORT}
|
- ${AUTH_PORT}
|
||||||
networks:
|
networks:
|
||||||
net:
|
net:
|
||||||
aliases: [dev-auth]
|
aliases: [dev-auth]
|
||||||
@ -67,71 +67,84 @@ services:
|
|||||||
### Authentik ###
|
### Authentik ###
|
||||||
#################
|
#################
|
||||||
|
|
||||||
authentik-db:
|
ak-db:
|
||||||
image: postgres:16
|
image: docker.io/library/postgres:16-alpine
|
||||||
|
env_file:
|
||||||
|
- .env.development
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: authentik
|
POSTGRES_DB: ${PG_DB:-authentik}
|
||||||
POSTGRES_USER: authentik
|
POSTGRES_PASSWORD: ${PG_PASS:?database password required}
|
||||||
POSTGRES_PASSWORD: ${AUTHENTIK_DB_PASS}
|
POSTGRES_USER: ${PG_USER:-authentik}
|
||||||
volumes:
|
volumes:
|
||||||
- authentik-db:/var/lib/postgresql/data
|
- authentik-db:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
net:
|
net:
|
||||||
aliases: [ak-db]
|
aliases: [dev-ak-db]
|
||||||
|
|
||||||
authentik-redis:
|
ak-redis:
|
||||||
image: redis:7-alpine
|
image: docker.io/library/redis:alpine
|
||||||
command: ["redis-server", "--save", "", "--appendonly", "no"]
|
command: --save 60 1 --loglevel warning
|
||||||
networks:
|
networks:
|
||||||
net:
|
net:
|
||||||
aliases: [ak-redis]
|
aliases: [dev-ak-redis]
|
||||||
|
volumes:
|
||||||
|
- ak-redis:/data
|
||||||
|
|
||||||
authentik:
|
ak:
|
||||||
image: ghcr.io/goauthentik/server:latest
|
image: ghcr.io/goauthentik/server:latest
|
||||||
|
env_file:
|
||||||
|
- .env.development
|
||||||
command: server
|
command: server
|
||||||
environment:
|
environment:
|
||||||
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
|
AUTHENTIK_SECRET_KEY: "Timothy-Yearning-Unzip-Playmate3-Snowiness-Desecrate"
|
||||||
AUTHENTIK_DEBUG: "false"
|
AUTHENTIK_DEBUG: false
|
||||||
AUTHENTIK_POSTGRESQL__HOST: authentik-db
|
AUTHENTIK_POSTGRESQL__HOST: ak-db
|
||||||
AUTHENTIK_POSTGRESQL__USER: authentik
|
AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}
|
||||||
AUTHENTIK_POSTGRESQL__NAME: authentik
|
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
|
||||||
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_DB_PASS}
|
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
|
||||||
AUTHENTIK_REDIS__HOST: authentik-redis
|
AUTHENTIK_REDIS__HOST: ak-redis
|
||||||
AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD}
|
|
||||||
AUTHENTIK_BOOTSTRAP_EMAIL: ${AUTHENTIK_BOOTSTRAP_EMAIL}
|
AUTHENTIK_BOOTSTRAP_PASSWORD: Succulent-Sanded7
|
||||||
|
AUTHENTIK_BOOTSTRAP_EMAIL: info.suitecoffee@gmail.com
|
||||||
AUTHENTIK_HTTP__TRUSTED_PROXY__CIDRS: "0.0.0.0/0,::/0"
|
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_SECURITY__CSRF_TRUSTED_ORIGINS: "https://authentik.suitecoffee.mateosaldain.uy,https://suitecoffee.mateosaldain.uy,https://sso.suitecoffee.uy,https://suitecoffee.uy"
|
||||||
AUTHENTIK_COOKIE__DOMAIN: "authentik.suitecoffee.mateosaldain.uy"
|
AUTHENTIK_COOKIE__DOMAIN: sso.suitecoffee.uy
|
||||||
networks:
|
networks:
|
||||||
net:
|
net:
|
||||||
aliases: [authentik]
|
aliases: [dev-authentik]
|
||||||
|
volumes:
|
||||||
|
- ./media:/media
|
||||||
|
- ./custom-templates:/templates
|
||||||
|
|
||||||
authentik-worker:
|
ak-worker:
|
||||||
image: ghcr.io/goauthentik/server:latest
|
image: ghcr.io/goauthentik/server:latest
|
||||||
command: worker
|
command: worker
|
||||||
depends_on:
|
|
||||||
authentik-db:
|
|
||||||
condition: service_started
|
|
||||||
authentik-redis:
|
|
||||||
condition: service_started
|
|
||||||
environment:
|
environment:
|
||||||
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
|
AUTHENTIK_POSTGRESQL__HOST: ak-db
|
||||||
AUTHENTIK_POSTGRESQL__HOST: authentik-db
|
AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}
|
||||||
AUTHENTIK_POSTGRESQL__USER: authentik
|
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
|
||||||
AUTHENTIK_POSTGRESQL__NAME: authentik
|
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
|
||||||
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_DB_PASS}
|
AUTHENTIK_REDIS__HOST: ak-redis
|
||||||
AUTHENTIK_REDIS__HOST: authentik-redis
|
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
|
||||||
|
|
||||||
AUTHENTIK_HTTP__TRUSTED_PROXY__CIDRS: "0.0.0.0/0,::/0"
|
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_SECURITY__CSRF_TRUSTED_ORIGINS: "https://authentik.suitecoffee.mateosaldain.uy,https://suitecoffee.mateosaldain.uy,https://sso.suitecoffee.uy,https://suitecoffee.uy"
|
||||||
AUTHENTIK_COOKIE__DOMAIN: "authentik.suitecoffee.mateosaldain.uy"
|
AUTHENTIK_COOKIE__DOMAIN: "sso.suitecoffee.uy"
|
||||||
networks:
|
networks:
|
||||||
net:
|
net:
|
||||||
aliases: [ak-work]
|
aliases: [dev-ak-work]
|
||||||
|
user: root
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- ./media:/media
|
||||||
|
- ./certs:/certs
|
||||||
|
- ./custom-templates:/templates
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
tenants-db:
|
tenants-db:
|
||||||
suitecoffee-db:
|
suitecoffee-db:
|
||||||
authentik-db:
|
authentik-db:
|
||||||
|
ak-redis:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
net:
|
net:
|
||||||
|
|||||||
@ -9,7 +9,7 @@ services:
|
|||||||
context: ./services/app
|
context: ./services/app
|
||||||
dockerfile: Dockerfile.production
|
dockerfile: Dockerfile.production
|
||||||
expose:
|
expose:
|
||||||
- ${APP_LOCAL_PORT}
|
- ${APP_PORT}
|
||||||
volumes:
|
volumes:
|
||||||
- ./services/app:/app
|
- ./services/app:/app
|
||||||
env_file:
|
env_file:
|
||||||
@ -26,7 +26,7 @@ services:
|
|||||||
context: ./services/auth
|
context: ./services/auth
|
||||||
dockerfile: Dockerfile.production
|
dockerfile: Dockerfile.production
|
||||||
expose:
|
expose:
|
||||||
- ${AUTH_LOCAL_PORT}
|
- ${AUTH_PORT}
|
||||||
volumes:
|
volumes:
|
||||||
- ./services/auth:/app
|
- ./services/auth:/app
|
||||||
env_file:
|
env_file:
|
||||||
|
|||||||
46
compose.yaml
46
compose.yaml
@ -3,7 +3,6 @@
|
|||||||
name: ${COMPOSE_PROJECT_NAME:-suitecoffee}
|
name: ${COMPOSE_PROJECT_NAME:-suitecoffee}
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|
||||||
app:
|
app:
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@ -11,7 +10,7 @@ services:
|
|||||||
tenants:
|
tenants:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "curl -fsS http://localhost:${APP_DOCKER_PORT}/health || exit 1"]
|
test: ["CMD-SHELL", "curl -fsS http://localhost:${APP_PORT}/health || exit 1"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 10
|
retries: 10
|
||||||
@ -22,10 +21,10 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
authentik:
|
ak:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "curl -fsS http://localhost:${AUTH_DOCKER_PORT}/health || exit 1"]
|
test: ["CMD-SHELL", "curl -fsS http://localhost:${AUTH_PORT}/health || exit 1"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 10
|
retries: 10
|
||||||
@ -56,32 +55,45 @@ services:
|
|||||||
start_period: 10s
|
start_period: 10s
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
authentik-db:
|
ak-db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U authentik -d authentik"]
|
interval: 30s
|
||||||
interval: 5s
|
retries: 5
|
||||||
timeout: 3s
|
start_period: 20s
|
||||||
retries: 20
|
test:
|
||||||
|
- CMD-SHELL
|
||||||
|
- pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}
|
||||||
|
timeout: 5s
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
authentik-redis:
|
ak-redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
interval: 30s
|
||||||
interval: 5s
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
|
test:
|
||||||
|
- CMD-SHELL
|
||||||
|
- redis-cli ping | grep PONG
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 20
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
authentik:
|
ak:
|
||||||
image: ghcr.io/goauthentik/server:latest
|
image: ghcr.io/goauthentik/server:latest
|
||||||
depends_on:
|
depends_on:
|
||||||
authentik-db: { condition: service_healthy }
|
ak-db:
|
||||||
authentik-redis: { condition: service_healthy }
|
condition: service_healthy
|
||||||
|
ak-redis:
|
||||||
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
authentik-worker:
|
ak-worker:
|
||||||
image: ghcr.io/goauthentik/server:latest
|
image: ghcr.io/goauthentik/server:latest
|
||||||
|
depends_on:
|
||||||
|
ak-db:
|
||||||
|
condition: service_started
|
||||||
|
ak-redis:
|
||||||
|
condition: service_started
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
316
docs/ak.md
Normal file
316
docs/ak.md
Normal file
@ -0,0 +1,316 @@
|
|||||||
|
# Documentación detallada de funciones: `ak.js`
|
||||||
|
|
||||||
|
Este documento fue generado automáticamente a partir del archivo `ak.js` proporcionado. Incluye una sección por función detectada, con firma, ubicación, descripción, parámetros, valores de retorno, posibles errores y un ejemplo de uso.
|
||||||
|
|
||||||
|
> **Nota:** Las descripciones y tipos se infieren heurísticamente a partir de los nombres, comentarios y cuerpo de cada función. Revise y ajuste donde corresponda.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `getConfig`
|
||||||
|
|
||||||
|
**Firma:** `function getConfig()`
|
||||||
|
**Ubicación:** línea 28
|
||||||
|
|
||||||
|
**Comentario previo en el código:**
|
||||||
|
```js
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// Cliente 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
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
// Utiliza AUTHENTIK_BASE_URL y AUTHENTIK_TOKEN para validar y devuelve la configuración (base URL y token) desde variables de entorno.
|
||||||
|
// Devuelve la URL base y el Token que se leyó desde .env
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} AkCfg
|
||||||
|
* @property {string} BASE // p.ej. "https://idp.example.com"
|
||||||
|
* @property {string} TOKEN // bearer
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} AkOpts
|
||||||
|
* @property {Record<string, string|number|boolean|Array<string|number|boolean>>} [qs]
|
||||||
|
* @property {any} [body]
|
||||||
|
* @property {number} [timeoutMs=10000]
|
||||||
|
* @property {number} [retries=0]
|
||||||
|
* @property {Record<string,string>} [headers]
|
||||||
|
*/
|
||||||
|
```
|
||||||
|
**Descripción:** Obtiene Config.
|
||||||
|
|
||||||
|
**Parámetros:** *(sin parámetros)*
|
||||||
|
|
||||||
|
**Retorna (aprox.):** `{ BASE, TOKEN }`
|
||||||
|
|
||||||
|
**Errores/excepciones:**
|
||||||
|
- Puede lanzar `Error('[AK_CONFIG] Falta variable AUTHENTIK_BASE_URL')`.
|
||||||
|
- Puede lanzar `Error('[AK_CONFIG] Falta variable AUTHENTIK_TOKEN')`.
|
||||||
|
|
||||||
|
**Ejemplo de uso:**
|
||||||
|
```js
|
||||||
|
const result = getConfig();
|
||||||
|
console.log(result);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
### `akListGroups`
|
||||||
|
|
||||||
|
**Firma:** `export async function akListGroups(search = "")`
|
||||||
|
**Ubicación:** línea 60
|
||||||
|
|
||||||
|
**Comentario previo en el código:**
|
||||||
|
```js
|
||||||
|
// Listar grupos con búsqueda por nombre/slug
|
||||||
|
```
|
||||||
|
**Descripción:** Función `akListGroups`. Interactúa con una API HTTP.
|
||||||
|
|
||||||
|
**Parámetros:**
|
||||||
|
- `search` (opcional, por defecto = `""`): descripción.
|
||||||
|
|
||||||
|
**Retorna (aprox.):** `[]`
|
||||||
|
|
||||||
|
**Ejemplo de uso:**
|
||||||
|
```js
|
||||||
|
const result = await akListGroups(search);
|
||||||
|
console.log(result);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
### `akPatchUserAttributes`
|
||||||
|
|
||||||
|
**Firma:** `export async function akPatchUserAttributes(userPk, partialAttrs = {})`
|
||||||
|
**Ubicación:** línea 73
|
||||||
|
|
||||||
|
**Descripción:** Función `akPatchUserAttributes`.
|
||||||
|
|
||||||
|
**Parámetros:**
|
||||||
|
- `userPk`: descripción.
|
||||||
|
- `partialAttrs` (opcional, por defecto = `{}`): descripción.
|
||||||
|
|
||||||
|
**Retorna:** Puede no retornar valor explícito (`void`) o retorna según la rama de ejecución.
|
||||||
|
|
||||||
|
**Ejemplo de uso:**
|
||||||
|
```js
|
||||||
|
const result = await akPatchUserAttributes(userPk, partialAttrs);
|
||||||
|
console.log(result);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
### `akEnsureGroupForTenant`
|
||||||
|
|
||||||
|
**Firma:** `export async function akEnsureGroupForTenant(tenantHex)`
|
||||||
|
**Ubicación:** línea 97
|
||||||
|
|
||||||
|
**Descripción:** Función `akEnsureGroupForTenant`. Interactúa con una API HTTP. Maneja errores con bloques try/catch.
|
||||||
|
|
||||||
|
**Parámetros:**
|
||||||
|
- `tenantHex`: descripción.
|
||||||
|
|
||||||
|
**Retorna (aprox.):** `found.pk ?? found.id`
|
||||||
|
|
||||||
|
**Errores/excepciones:**
|
||||||
|
- Puede lanzar `TypeError("akEnsureGroupForTenant: `tenantHex` is required")`.
|
||||||
|
|
||||||
|
**Ejemplo de uso:**
|
||||||
|
```js
|
||||||
|
const result = await akEnsureGroupForTenant(tenantHex);
|
||||||
|
console.log(result);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
### `akAddUserToGroup`
|
||||||
|
|
||||||
|
**Firma:** `export async function akAddUserToGroup(userPk, groupPk)`
|
||||||
|
**Ubicación:** línea 130
|
||||||
|
|
||||||
|
**Descripción:** Función `akAddUserToGroup`. Interactúa con una API HTTP. Maneja errores con bloques try/catch.
|
||||||
|
|
||||||
|
**Parámetros:**
|
||||||
|
- `userPk`: descripción.
|
||||||
|
- `groupPk`: descripción.
|
||||||
|
|
||||||
|
**Retorna (aprox.):** `await akPOST("/core/group_memberships/", { body: { user, group } })`
|
||||||
|
|
||||||
|
**Errores/excepciones:**
|
||||||
|
- Puede lanzar `TypeError("akAddUserToGroup: `userPk` is required")`.
|
||||||
|
- Puede lanzar `TypeError("akAddUserToGroup: `groupPk` is required")`.
|
||||||
|
|
||||||
|
**Ejemplo de uso:**
|
||||||
|
```js
|
||||||
|
const result = await akAddUserToGroup(userPk, groupPk);
|
||||||
|
console.log(result);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
### `request`
|
||||||
|
|
||||||
|
**Firma:** `export async function request(method, path, opts = {}, cfg)`
|
||||||
|
**Ubicación:** línea 167
|
||||||
|
|
||||||
|
**Comentario previo en el código:**
|
||||||
|
```js
|
||||||
|
/**
|
||||||
|
* Llamada HTTP genérica con fetch + timeout + manejo de errores.
|
||||||
|
* @param {'GET'|'POST'|'PUT'|'PATCH'|'DELETE'} method
|
||||||
|
* @param {string} path Ruta relativa (ej. "/core/users/") o absoluta; si es relativa se antepone "/api/v3".
|
||||||
|
* @param {AkOpts} [opts]
|
||||||
|
* @param {AkCfg} [cfg] Config inyectada; si se omite se usa getConfig()
|
||||||
|
* @returns {Promise<any|null>}
|
||||||
|
*/
|
||||||
|
```
|
||||||
|
**Descripción:** Función `request`.
|
||||||
|
|
||||||
|
**Parámetros:**
|
||||||
|
- `method`: descripción.
|
||||||
|
- `path`: descripción.
|
||||||
|
- `opts` (opcional, por defecto = `{}`): descripción.
|
||||||
|
- `cfg`: descripción.
|
||||||
|
|
||||||
|
**Retorna:** Puede no retornar valor explícito (`void`) o retorna según la rama de ejecución.
|
||||||
|
|
||||||
|
**Ejemplo de uso:**
|
||||||
|
```js
|
||||||
|
const result = await request(method, path, opts, cfg);
|
||||||
|
console.log(result);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
### `akFindUserByEmail`
|
||||||
|
|
||||||
|
**Firma:** `export async function akFindUserByEmail(email)`
|
||||||
|
**Ubicación:** línea 262
|
||||||
|
|
||||||
|
**Comentario previo en el código:**
|
||||||
|
```js
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// Funciones públicas
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
```
|
||||||
|
**Descripción:** Función `akFindUserByEmail`. Interactúa con una API HTTP.
|
||||||
|
|
||||||
|
**Parámetros:**
|
||||||
|
- `email`: descripción.
|
||||||
|
|
||||||
|
**Retorna (aprox.):** `null`
|
||||||
|
|
||||||
|
**Errores/excepciones:**
|
||||||
|
- Puede lanzar `TypeError("akFindUserByEmail: `email` is required")`.
|
||||||
|
|
||||||
|
**Ejemplo de uso:**
|
||||||
|
```js
|
||||||
|
const result = await akFindUserByEmail(email);
|
||||||
|
console.log(result);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
### `akCreateUser`
|
||||||
|
|
||||||
|
**Firma:** `export async function akCreateUser(p = {})`
|
||||||
|
**Ubicación:** línea 298
|
||||||
|
|
||||||
|
**Descripción:** Función `akCreateUser`.
|
||||||
|
|
||||||
|
**Parámetros:**
|
||||||
|
- `p` (opcional, por defecto = `{}`): descripción.
|
||||||
|
|
||||||
|
**Retorna:** Puede no retornar valor explícito (`void`) o retorna según la rama de ejecución.
|
||||||
|
|
||||||
|
**Ejemplo de uso:**
|
||||||
|
```js
|
||||||
|
const result = await akCreateUser(p);
|
||||||
|
console.log(result);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
### `akSetPassword`
|
||||||
|
|
||||||
|
**Firma:** `export async function akSetPassword(userPk, password, requireChange = true)`
|
||||||
|
**Ubicación:** línea 349
|
||||||
|
|
||||||
|
**Descripción:** Función `akSetPassword`. Interactúa con una API HTTP. Maneja errores con bloques try/catch.
|
||||||
|
|
||||||
|
**Parámetros:**
|
||||||
|
- `userPk`: descripción.
|
||||||
|
- `password`: descripción.
|
||||||
|
- `requireChange` (opcional, por defecto = `true`): descripción.
|
||||||
|
|
||||||
|
**Retorna (aprox.):** `true`
|
||||||
|
|
||||||
|
**Errores/excepciones:**
|
||||||
|
- Puede lanzar `TypeError("akSetPassword: `userPk` is required")`.
|
||||||
|
- Puede lanzar `TypeError("akSetPassword: `password` is required")`.
|
||||||
|
|
||||||
|
**Ejemplo de uso:**
|
||||||
|
```js
|
||||||
|
const result = await akSetPassword(userPk, password, requireChange);
|
||||||
|
console.log(result);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
### `akResolveGroupIdByName`
|
||||||
|
|
||||||
|
**Firma:** `export async function akResolveGroupIdByName(name)`
|
||||||
|
**Ubicación:** línea 373
|
||||||
|
|
||||||
|
**Descripción:** Función `akResolveGroupIdByName`.
|
||||||
|
|
||||||
|
**Parámetros:**
|
||||||
|
- `name`: descripción.
|
||||||
|
|
||||||
|
**Retorna (aprox.):** `byName?.pk ?? byName?.id ?? null`
|
||||||
|
|
||||||
|
**Errores/excepciones:**
|
||||||
|
- Puede lanzar `TypeError("akResolveGroupIdByName: `name` is required")`.
|
||||||
|
|
||||||
|
**Ejemplo de uso:**
|
||||||
|
```js
|
||||||
|
const result = await akResolveGroupIdByName(name);
|
||||||
|
console.log(result);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
### `akResolveGroupId`
|
||||||
|
|
||||||
|
**Firma:** `export async function akResolveGroupId({ id, pk, uuid, name, slug } = {})`
|
||||||
|
**Ubicación:** línea 389
|
||||||
|
|
||||||
|
**Descripción:** Función `akResolveGroupId`.
|
||||||
|
|
||||||
|
**Parámetros:**
|
||||||
|
- `{ id`: descripción.
|
||||||
|
- `pk`: descripción.
|
||||||
|
- `uuid`: descripción.
|
||||||
|
- `name`: descripción.
|
||||||
|
- `slug }` (opcional, por defecto = `{}`): descripción.
|
||||||
|
|
||||||
|
**Retorna:** Puede no retornar valor explícito (`void`) o retorna según la rama de ejecución.
|
||||||
|
|
||||||
|
**Ejemplo de uso:**
|
||||||
|
```js
|
||||||
|
const result = await akResolveGroupId({ id, pk, uuid, name, slug });
|
||||||
|
console.log(result);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
### `toPk`
|
||||||
|
|
||||||
|
**Firma:** `const => toPk(v)`
|
||||||
|
**Ubicación:** línea 390
|
||||||
|
|
||||||
|
**Descripción:** Función `toPk`.
|
||||||
|
|
||||||
|
**Parámetros:**
|
||||||
|
- `v`: descripción.
|
||||||
|
|
||||||
|
**Retorna (aprox.):** `Number.isFinite(n) ? n : String(v)`
|
||||||
|
|
||||||
|
**Ejemplo de uso:**
|
||||||
|
```js
|
||||||
|
const result = toPk(v);
|
||||||
|
console.log(result);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
@ -1,29 +1,26 @@
|
|||||||
# ===== Runtime =====
|
# ===== Runtime =====
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
PORT=3030
|
PORT=3030
|
||||||
APP_LOCAL_PORT=3030
|
|
||||||
|
|
||||||
# ===== Session (usa el Redis del stack) =====
|
# ===== Session (usa el Redis del stack) =====
|
||||||
# Para DEV podemos reutilizar el Redis de Authentik. En prod conviene uno separado.
|
# Para DEV podemos reutilizar el Redis de Authentik. En prod conviene uno separado.
|
||||||
SESSION_SECRET=pon-una-clave-larga-y-unica
|
SESSION_SECRET=Neon*Mammal*Boaster*Ludicrous*Fender8*Crablike
|
||||||
REDIS_URL=redis://authentik-redis:6379
|
REDIS_URL=redis://ak-redis:6379
|
||||||
|
|
||||||
# ===== DB principal (metadatos de SuiteCoffee) =====
|
# # ===== DB principal (metadatos de SuiteCoffee) =====
|
||||||
DB_HOST=dev-tenants
|
DB_HOST=dev-db
|
||||||
|
DB_NAME=dev-suitecoffee
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
DB_NAME=dev-postgres
|
DB_USER=dev-user-suitecoffee
|
||||||
DB_USER=dev-user-postgres
|
DB_PASS=dev-pass-suitecoffee
|
||||||
DB_PASS=dev-pass-postgres
|
|
||||||
|
|
||||||
# ===== DB tenants (Tenants de SuiteCoffee) =====
|
# # ===== DB tenants (Tenants de SuiteCoffee) =====
|
||||||
TENANTS_HOST=dev-tenants
|
TENANTS_HOST=dev-tenants
|
||||||
TENANTS_DB=dev-postgres
|
TENANTS_DB=dev-postgres
|
||||||
|
TENANTS_PORT=5432
|
||||||
TENANTS_USER=dev-user-postgres
|
TENANTS_USER=dev-user-postgres
|
||||||
TENANTS_PASS=dev-pass-postgres
|
TENANTS_PASS=dev-pass-postgres
|
||||||
TENANTS_PORT=5432
|
|
||||||
|
|
||||||
# ===== (Opcional) Colores UI, si alguna vista los lee =====
|
|
||||||
COL_PRI=452D19 # Marrón oscuro
|
|
||||||
COL_SEC=D7A666 # Crema / Café
|
|
||||||
COL_BG=FFA500 # Naranja
|
|
||||||
|
|
||||||
|
SESSION_COOKIE_NAME=sc.sid
|
||||||
|
SESSION_SECRET=pon-una-clave-larga-y-unica
|
||||||
|
REDIS_URL=redis://authentik-redis:6379
|
||||||
402
services/app/src/bak/reportes.ejs.bak
Normal file
402
services/app/src/bak/reportes.ejs.bak
Normal file
@ -0,0 +1,402 @@
|
|||||||
|
<% /* Reportes - Asistencias y Tickets (Comandas) */ %>
|
||||||
|
<div class="container-fluid py-3">
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
<h3 class="mb-0">Reportes</h3>
|
||||||
|
<span class="ms-auto small text-muted" id="repStatus">—</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filtros -->
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3 align-items-end">
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<label class="form-label mb-1">Asistencias · Rango</label>
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-6 col-md-4">
|
||||||
|
<input id="asistDesde" type="date" class="form-control">
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-4">
|
||||||
|
<input id="asistHasta" type="date" class="form-control">
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-4 d-grid d-md-block">
|
||||||
|
<button id="btnAsistCargar" class="btn btn-primary me-2">Cargar</button>
|
||||||
|
<button id="btnAsistExcel" class="btn btn-outline-success me-2">Excel</button>
|
||||||
|
<button id="btnAsistPDF" class="btn btn-outline-secondary">PDF</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<label class="form-label mb-1">Tickets (Comandas) · Año</label>
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-6 col-md-4">
|
||||||
|
<input id="ticketsYear" type="number" min="2000" step="1" class="form-control">
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-8 d-grid d-md-block">
|
||||||
|
<button id="btnTicketsCargar" class="btn btn-primary me-2">Cargar</button>
|
||||||
|
<button id="btnTicketsExcel" class="btn btn-outline-success me-2">Excel</button>
|
||||||
|
<button id="btnTicketsPDF" class="btn btn-outline-secondary">PDF</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="small text-muted mt-2">
|
||||||
|
Los archivos Excel se generan como CSV (compatibles). Los PDF se generan con “Imprimir área” del navegador.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reporte Asistencias -->
|
||||||
|
<div class="card shadow-sm mb-3" id="PRINT_ASIST">
|
||||||
|
<div class="card-header d-flex align-items-center">
|
||||||
|
<strong>Asistencias</strong>
|
||||||
|
<span class="ms-auto small text-muted" id="asistInfo">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm align-middle">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Documento</th>
|
||||||
|
<th>Nombre</th>
|
||||||
|
<th>Apellido</th>
|
||||||
|
<th>Fecha</th>
|
||||||
|
<th class="text-end">Desde</th>
|
||||||
|
<th class="text-end">Hasta</th>
|
||||||
|
<th class="text-end">Duración</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="tbAsist">
|
||||||
|
<tr><td colspan="7" class="p-3 text-muted">Sin datos</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reporte Tickets -->
|
||||||
|
<div class="card shadow-sm" id="PRINT_TICKETS">
|
||||||
|
<div class="card-header d-flex align-items-center">
|
||||||
|
<strong>Tickets</strong>
|
||||||
|
<span class="ms-auto small text-muted" id="ticketsInfo">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<div class="emp-card p-3 border rounded">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<div class="fw-semibold">Resumen del año</div>
|
||||||
|
<div class="small text-muted" id="ticketsYearTitle">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="row text-center">
|
||||||
|
<div class="col-4">
|
||||||
|
<div class="small text-muted">Tickets YTD</div>
|
||||||
|
<div class="fs-5 fw-semibold" id="tYtd">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
<div class="small text-muted">Promedio</div>
|
||||||
|
<div class="fs-5 fw-semibold" id="tAvg">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
<div class="small text-muted">Hasta la fecha</div>
|
||||||
|
<div class="fs-5 fw-semibold" id="tToDate">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<div class="emp-card p-3 border rounded">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<div class="fw-semibold">Tickets por mes</div>
|
||||||
|
<div class="small text-muted">Cantidad</div>
|
||||||
|
</div>
|
||||||
|
<div class="spark-wrap" id="ticketsChart" style="height:140px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm align-middle">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Mes</th>
|
||||||
|
<th class="text-end">Tickets</th>
|
||||||
|
<th class="text-end">Importe</th>
|
||||||
|
<th class="text-end">Ticket promedio</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="tbTickets">
|
||||||
|
<tr><td colspan="4" class="p-3 text-muted">Sin datos</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.spark rect:hover { filter: brightness(0.9); }
|
||||||
|
@media print {
|
||||||
|
body * { visibility: hidden !important; }
|
||||||
|
#PRINT_ASIST, #PRINT_ASIST *,
|
||||||
|
#PRINT_TICKETS, #PRINT_TICKETS * { visibility: visible !important; }
|
||||||
|
#PRINT_ASIST, #PRINT_TICKETS { position: absolute; left:0; top:0; width:100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/* =========================
|
||||||
|
Helpers reutilizables
|
||||||
|
========================= */
|
||||||
|
const $ = s => document.querySelector(s);
|
||||||
|
const z2 = n => String(n).padStart(2,'0');
|
||||||
|
const fmtMoney = v => (v==null? '—' : new Intl.NumberFormat('es-UY',{style:'currency',currency:'UYU'}).format(Number(v)));
|
||||||
|
const fmtHM = mins => { const h = Math.floor(mins/60); const m = Math.round(mins%60); return `${z2(h)}:${z2(m)}`; };
|
||||||
|
|
||||||
|
// GET JSON simple
|
||||||
|
async function jget(url){
|
||||||
|
const r = await fetch(url);
|
||||||
|
const j = await r.json().catch(()=>null);
|
||||||
|
if (!r.ok) throw new Error(j?.error || `${r.status} ${r.statusText}`);
|
||||||
|
return j;
|
||||||
|
}
|
||||||
|
// POST JSON simple
|
||||||
|
async function jpost(url, body){
|
||||||
|
const r = await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body||{})});
|
||||||
|
const j = await r.json().catch(()=>null);
|
||||||
|
if (!r.ok) throw new Error(j?.error || `${r.status} ${r.statusText}`);
|
||||||
|
return j;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSV (Excel-friendly)
|
||||||
|
|
||||||
|
function toCSV(rows, headers){
|
||||||
|
const esc = v => {
|
||||||
|
if (v == null) return '';
|
||||||
|
if (typeof v === 'number') return String(v); // números sin comillas
|
||||||
|
const s = String(v);
|
||||||
|
return /[",\n]/.test(s) ? `"${s.replace(/"/g,'""')}"` : s;
|
||||||
|
};
|
||||||
|
const cols = headers && headers.length ? headers : Object.keys(rows?.[0] || {});
|
||||||
|
const lines = [];
|
||||||
|
if (headers) lines.push(cols.join(','));
|
||||||
|
for (const r of (rows || [])) lines.push(cols.map(c => esc(r[c])).join(','));
|
||||||
|
return lines.join('\r\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadText(filename, text){
|
||||||
|
const blob = new Blob([text], {type:'text/csv;charset=utf-8;'});
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(a.href);
|
||||||
|
}
|
||||||
|
// Print solo área
|
||||||
|
function printArea(id){
|
||||||
|
// cambiamos el hash para que @media print muestre el área; luego invocamos print
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
window.print();
|
||||||
|
}
|
||||||
|
|
||||||
|
// SVG barras simple (sin librerías)
|
||||||
|
function barsSVG(series /* [{label:'Ene', value:Number}] */){
|
||||||
|
const W=560, H=120, PAD=10, GAP=6;
|
||||||
|
const n = series.length||1;
|
||||||
|
const max = Math.max(1, ...series.map(d=>d.value||0));
|
||||||
|
const bw = Math.max(6, Math.floor((W-PAD*2-GAP*(n-1))/n));
|
||||||
|
let x = PAD;
|
||||||
|
let bars = '';
|
||||||
|
series.forEach((d,i)=>{
|
||||||
|
const vh = Math.round((d.value/max)*(H-PAD-26)); // 26px para etiquetas
|
||||||
|
const y = H-20 - vh;
|
||||||
|
const title = `${d.label} · ${d.value}`;
|
||||||
|
bars += `<g>
|
||||||
|
<rect x="${x}" y="${y}" width="${bw}" height="${vh}" rx="3" ry="3" class="bar">
|
||||||
|
<title>${title}</title>
|
||||||
|
</rect>
|
||||||
|
<text x="${x + bw/2}" y="${H-6}" text-anchor="middle">${d.label}</text>
|
||||||
|
</g>`;
|
||||||
|
x += bw + GAP;
|
||||||
|
});
|
||||||
|
const css = `.bar{fill:#0d6efd}`;
|
||||||
|
const axis = `<line x1="${PAD}" y1="${H-20}" x2="${W-PAD}" y2="${H-20}" stroke="#adb5bd" stroke-width="1"/>`;
|
||||||
|
return `<svg viewBox="0 0 ${W} ${H}" class="spark" preserveAspectRatio="none">
|
||||||
|
<style>${css}</style>
|
||||||
|
${axis}
|
||||||
|
${bars}
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
Data access (enchufable)
|
||||||
|
=========================
|
||||||
|
Estas funciones llaman RPCs del server, que a su vez deben
|
||||||
|
delegar en funciones SQL. Si aún no existen, ver más abajo
|
||||||
|
el bloque "Sugerencia de funciones SQL".
|
||||||
|
*/
|
||||||
|
async function fetchAsistencias(desde, hasta){
|
||||||
|
// endpoint recomendado (RPC):
|
||||||
|
// POST /api/rpc/report_asistencia { desde, hasta }
|
||||||
|
// Retorna [{documento,nombre,apellido,fecha,desde_hora,hasta_hora,dur_min}]
|
||||||
|
try {
|
||||||
|
return await jpost('/api/rpc/report_asistencia', { desde, hasta });
|
||||||
|
} catch {
|
||||||
|
// fallback (si aún no tienes RPC): lee la vista "asistencia_detalle" hipotética
|
||||||
|
const url = `/api/table/asistencia_detalle?desde=${encodeURIComponent(desde)}&hasta=${encodeURIComponent(hasta)}&limit=10000`;
|
||||||
|
return await jget(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTickets(year){
|
||||||
|
// endpoint recomendado (RPC):
|
||||||
|
// POST /api/rpc/report_tickets { year }
|
||||||
|
// Retorna: { year, total_ytd, avg_ticket, to_date, months:[{mes:1..12, nombre:'Ene', cant, importe, avg}] }
|
||||||
|
return await jpost('/api/rpc/report_tickets', { year });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
Render Asistencias
|
||||||
|
========================= */
|
||||||
|
let cacheAsist = [];
|
||||||
|
function renderAsistTabla(rows){
|
||||||
|
const tb = $('#tbAsist');
|
||||||
|
if (!rows?.length){ tb.innerHTML = '<tr><td colspan="7" class="p-3 text-muted">Sin datos</td></tr>'; return; }
|
||||||
|
tb.innerHTML = '';
|
||||||
|
rows.forEach(r=>{
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td>${r.documento||'—'}</td>
|
||||||
|
<td>${r.nombre||'—'}</td>
|
||||||
|
<td>${r.apellido||'—'}</td>
|
||||||
|
<td>${r.fecha||'—'}</td>
|
||||||
|
<td class="text-end">${r.desde_hora||'—'}</td>
|
||||||
|
<td class="text-end">${r.hasta_hora||'—'}</td>
|
||||||
|
<td class="text-end">${fmtHM(Number(r.dur_min||0))}</td>
|
||||||
|
`;
|
||||||
|
tb.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAsist(){
|
||||||
|
const d = $('#asistDesde').value;
|
||||||
|
const h = $('#asistHasta').value;
|
||||||
|
$('#repStatus').textContent = 'Cargando asistencias…';
|
||||||
|
const rows = await fetchAsistencias(d,h);
|
||||||
|
cacheAsist = rows||[];
|
||||||
|
renderAsistTabla(cacheAsist);
|
||||||
|
const minsTot = cacheAsist.reduce((s,r)=> s + Number(r.dur_min||0), 0);
|
||||||
|
$('#asistInfo').textContent = `${cacheAsist.length} registros · ${fmtHM(minsTot)}`;
|
||||||
|
$('#repStatus').textContent = 'Listo';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
Render Tickets
|
||||||
|
========================= */
|
||||||
|
let cacheTickets = null;
|
||||||
|
function renderTickets(data){
|
||||||
|
const months = data?.months||[];
|
||||||
|
$('#ticketsYearTitle').textContent = data?.year || '—';
|
||||||
|
$('#tYtd').textContent = months.reduce((s,m)=> s + Number(m.cant||0), 0);
|
||||||
|
$('#tAvg').textContent = fmtMoney(data?.avg_ticket ?? 0);
|
||||||
|
$('#tToDate').textContent = data?.to_date != null ? fmtMoney(data.to_date) : '—';
|
||||||
|
|
||||||
|
const series = months.map(m=>({ label:m.nombre||m.mes, value:Number(m.cant||0) }));
|
||||||
|
$('#ticketsChart').innerHTML = barsSVG(series);
|
||||||
|
|
||||||
|
const tb = $('#tbTickets');
|
||||||
|
if (!months.length){ tb.innerHTML = '<tr><td colspan="4" class="p-3 text-muted">Sin datos</td></tr>'; return; }
|
||||||
|
tb.innerHTML = '';
|
||||||
|
months.forEach(m=>{
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td>${m.nombre||m.mes}</td>
|
||||||
|
<td class="text-end">${m.cant||0}</td>
|
||||||
|
<td class="text-end">${fmtMoney(m.importe||0)}</td>
|
||||||
|
<td class="text-end">${fmtMoney(m.avg||0)}</td>
|
||||||
|
`;
|
||||||
|
tb.appendChild(tr);
|
||||||
|
});
|
||||||
|
$('#ticketsInfo').textContent = `${months.length} meses`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTickets(){
|
||||||
|
const y = Number($('#ticketsYear').value);
|
||||||
|
$('#repStatus').textContent = 'Cargando tickets…';
|
||||||
|
const data = await fetchTickets(y);
|
||||||
|
cacheTickets = data;
|
||||||
|
renderTickets(cacheTickets);
|
||||||
|
$('#repStatus').textContent = 'Listo';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
Excel (CSV) & PDF
|
||||||
|
========================= */
|
||||||
|
function exportAsistCSV(){
|
||||||
|
if (!cacheAsist?.length) return;
|
||||||
|
const headers = ['Documento','Nombre','Apellido','Fecha','Desde','Hasta','Duración(min)'];
|
||||||
|
const rows = cacheAsist.map(r=>({
|
||||||
|
Documento:r.documento||'',
|
||||||
|
Nombre:r.nombre||'',
|
||||||
|
Apellido:r.apellido||'',
|
||||||
|
Fecha:r.fecha||'',
|
||||||
|
Desde:r.desde_hora||'',
|
||||||
|
Hasta:r.hasta_hora||'',
|
||||||
|
'Duración(min)':Number(r.dur_min||0)
|
||||||
|
}));
|
||||||
|
const csv = toCSV(rows, headers);
|
||||||
|
downloadText(`asistencias_${$('#asistDesde').value}_${$('#asistHasta').value}.csv`, csv);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportTicketsCSV(){
|
||||||
|
if (!cacheTickets?.months?.length) return;
|
||||||
|
const toInt = v => Math.round(Number(v || 0)); // sin decimales
|
||||||
|
const headers = ['Año','Mes','Tickets','Importe','Ticket promedio'];
|
||||||
|
const rows = cacheTickets.months.map(m => ({
|
||||||
|
'Año': cacheTickets.year,
|
||||||
|
'Mes': m.nombre || m.mes,
|
||||||
|
'Tickets': Number(m.cant || 0),
|
||||||
|
'Importe': toInt(m.importe), // ← entero
|
||||||
|
'Ticket promedio': toInt(m.avg) // ← entero
|
||||||
|
}));
|
||||||
|
const csv = toCSV(rows, headers);
|
||||||
|
downloadText(`tickets_${cacheTickets.year}.csv`, csv);
|
||||||
|
}
|
||||||
|
// PDF vía print-area del navegador
|
||||||
|
const onPDFAsist = () => printArea('PRINT_ASIST');
|
||||||
|
const onPDFTicket = () => printArea('PRINT_TICKETS');
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
Eventos + defaults
|
||||||
|
========================= */
|
||||||
|
document.getElementById('btnAsistCargar').addEventListener('click', loadAsist);
|
||||||
|
document.getElementById('btnTicketsCargar').addEventListener('click', loadTickets);
|
||||||
|
document.getElementById('btnAsistExcel').addEventListener('click', exportAsistCSV);
|
||||||
|
document.getElementById('btnTicketsExcel').addEventListener('click', exportTicketsCSV);
|
||||||
|
document.getElementById('btnAsistPDF').addEventListener('click', onPDFAsist);
|
||||||
|
document.getElementById('btnTicketsPDF').addEventListener('click', onPDFTicket);
|
||||||
|
|
||||||
|
// Defaults: último mes y año actual
|
||||||
|
(function initDefaults(){
|
||||||
|
const today = new Date();
|
||||||
|
const y = today.getFullYear();
|
||||||
|
const hasta = today.toISOString().slice(0,10);
|
||||||
|
const d = new Date(today); d.setMonth(d.getMonth()-1);
|
||||||
|
const desde = d.toISOString().slice(0,10);
|
||||||
|
$('#asistDesde').value = desde;
|
||||||
|
$('#asistHasta').value = hasta;
|
||||||
|
$('#ticketsYear').value = y;
|
||||||
|
// carga inicial
|
||||||
|
loadAsist().catch(()=>{});
|
||||||
|
loadTickets().catch(()=>{});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,337 +0,0 @@
|
|||||||
// 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' }); });
|
|
||||||
}
|
|
||||||
130
services/app/src/views/inicio.ejs
Normal file
130
services/app/src/views/inicio.ejs
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
<!-- views/inicio.ejs -->
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta http-equiv="x-ua-compatible" content="IE=edge" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>Inicio • SuiteCoffee</title>
|
||||||
|
<style>
|
||||||
|
:root { --bg:#0b0b0c; --card:#141519; --text:#e7e9ee; --muted:#a5acb8; --accent:#6ee7b7; --border:#232733; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin:0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica Neue, Arial, "Apple Color Emoji","Segoe UI Emoji"; background: var(--bg); color: var(--text); }
|
||||||
|
.wrap { max-width: 960px; margin: 48px auto; padding: 0 20px; }
|
||||||
|
.card { background: var(--card); border: 1px solid var(--border); border-radius: 16px; padding: 20px; box-shadow: 0 10px 30px rgba(0,0,0,.25); }
|
||||||
|
h1 { font-size: 26px; margin: 0 0 8px; }
|
||||||
|
p.lead { color: var(--muted); margin: 0 0 20px; }
|
||||||
|
h2 { margin: 24px 0 10px; font-size: 18px; color: var(--accent); }
|
||||||
|
table { width: 100%; border-collapse: collapse; overflow: hidden; border-radius: 12px; border: 1px solid var(--border); }
|
||||||
|
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--border); vertical-align: top; }
|
||||||
|
th { color: var(--muted); font-weight: 600; letter-spacing: .02em; }
|
||||||
|
tbody tr:last-child td { border-bottom: 0; }
|
||||||
|
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
|
||||||
|
.grid { display: grid; gap: 18px; grid-template-columns: 1fr; }
|
||||||
|
@media (min-width: 900px) { .grid { grid-template-columns: 1fr 1fr; } }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.pill { display:inline-block; padding:2px 8px; border:1px solid var(--border); border-radius:999px; color:var(--muted); font-size:12px; }
|
||||||
|
.k { color:#93c5fd; }
|
||||||
|
.v { color:#fca5a5; word-break: break-all; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="card">
|
||||||
|
<%
|
||||||
|
const hasUser = typeof user !== 'undefined' && user;
|
||||||
|
const hasCookies = typeof cookies !== 'undefined' && cookies && Object.keys(cookies).length;
|
||||||
|
const displayName =
|
||||||
|
(hasUser && (user.name || user.displayName || user.email)) ||
|
||||||
|
(hasCookies && (cookies.user_name || cookies.displayName || cookies.email)) ||
|
||||||
|
'usuario';
|
||||||
|
%>
|
||||||
|
<h1>Hola, <span><%= displayName %></span> 👋</h1>
|
||||||
|
<p class="lead">Bienvenido a SuiteCoffee. Este es tu inicio.</p>
|
||||||
|
|
||||||
|
<% if (hasUser) { %>
|
||||||
|
<h2>Sesión</h2>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<% for (const [k,v] of Object.entries(user)) { %>
|
||||||
|
<tr>
|
||||||
|
<th><code class="k"><%= k %></code></th>
|
||||||
|
<td><code class="v"><%= typeof v === 'object' ? JSON.stringify(v) : String(v) %></code></td>
|
||||||
|
</tr>
|
||||||
|
<% } %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<div class="grid" style="margin-top:18px;">
|
||||||
|
<section class="card">
|
||||||
|
<h2>Cookies (servidor)</h2>
|
||||||
|
<% if (hasCookies) { %>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Nombre</th><th>Valor</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% for (const [name, value] of Object.entries(cookies)) { %>
|
||||||
|
<tr>
|
||||||
|
<td><code class="k"><%= name %></code></td>
|
||||||
|
<td><code class="v"><%= typeof value === 'object' ? JSON.stringify(value) : String(value) %></code></td>
|
||||||
|
</tr>
|
||||||
|
<% } %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<% } else { %>
|
||||||
|
<p class="muted">No se recibieron cookies desde el servidor (req.cookies). ¿Estás usando <code>cookie-parser</code> o pasando <code>cookies</code> al render?</p>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Cookies (navegador)</h2>
|
||||||
|
<table id="client-cookies">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Nombre</th><th>Valor</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td colspan="2" class="muted">Cargando…</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="muted" style="margin-top:10px;">Raw <code>document.cookie</code>:</p>
|
||||||
|
<pre id="cookie-raw" class="muted" style="white-space: pre-wrap; word-break: break-all;"></pre>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
const tbody = document.querySelector('#client-cookies tbody');
|
||||||
|
const raw = document.cookie || '';
|
||||||
|
document.getElementById('cookie-raw').textContent = raw || '(sin cookies)';
|
||||||
|
const pairs = raw ? raw.split(/;\s*/) : [];
|
||||||
|
if (!pairs.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="2"><em class="muted">sin cookies</em></td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
for (const kv of pairs) {
|
||||||
|
const i = kv.indexOf('=');
|
||||||
|
const name = i >= 0 ? kv.slice(0, i) : kv;
|
||||||
|
const value = i >= 0 ? kv.slice(i + 1) : '';
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
const td1 = document.createElement('td');
|
||||||
|
const td2 = document.createElement('td');
|
||||||
|
td1.innerHTML = '<code class="k"></code>';
|
||||||
|
td2.innerHTML = '<code class="v"></code>';
|
||||||
|
td1.querySelector('code').textContent = decodeURIComponent(name);
|
||||||
|
td2.querySelector('code').textContent = decodeURIComponent(value);
|
||||||
|
tr.append(td1, td2);
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('cookie render error:', err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -1,12 +1,11 @@
|
|||||||
# ===== Runtime =====
|
# ===== Runtime =====
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
PORT=4040
|
PORT=4040
|
||||||
AUTH_LOCAL_PORT=4040 # coincide con 'expose' del servicio auth
|
|
||||||
|
|
||||||
# ===== Session (usa el Redis del stack) =====
|
# ===== Session (usa el Redis del stack) =====
|
||||||
# Para DEV podemos reutilizar el Redis de Authentik. En prod conviene uno separado.
|
# Para DEV podemos reutilizar el Redis de Authentik. En prod conviene uno separado.
|
||||||
SESSION_SECRET=pon-una-clave-larga-y-unica
|
SESSION_SECRET=Neon*Mammal*Boaster*Ludicrous*Fender8*Crablike
|
||||||
REDIS_URL=redis://authentik-redis:6379
|
REDIS_URL=redis://ak-redis:6379
|
||||||
|
|
||||||
# ===== DB principal (metadatos de SuiteCoffee) =====
|
# ===== DB principal (metadatos de SuiteCoffee) =====
|
||||||
# Usa el alias de red del servicio 'db' (compose: aliases [dev-db])
|
# Usa el alias de red del servicio 'db' (compose: aliases [dev-db])
|
||||||
@ -23,8 +22,7 @@ TENANTS_USER=dev-user-postgres
|
|||||||
TENANTS_PASS=dev-pass-postgres
|
TENANTS_PASS=dev-pass-postgres
|
||||||
TENANTS_PORT=5432
|
TENANTS_PORT=5432
|
||||||
|
|
||||||
TENANT_INIT_SQL=/home/mateo/SuiteCoffee/services/auth/src/db/initTenant.sql
|
TENANT_INIT_SQL=/app/src/db/initTenant.sql
|
||||||
# TENANT_INIT_SQL=~/SuiteCoffee/services/app/src/db/01_init.sql
|
|
||||||
|
|
||||||
# ===== (Opcional) Colores UI, si alguna vista los lee =====
|
# ===== (Opcional) Colores UI, si alguna vista los lee =====
|
||||||
COL_PRI=452D19 # Marrón oscuro
|
COL_PRI=452D19 # Marrón oscuro
|
||||||
@ -33,7 +31,7 @@ COL_BG=FFA500 # Naranja
|
|||||||
|
|
||||||
# ===== Authentik — Admin API (server-to-server dentro de la red) =====
|
# ===== Authentik — Admin API (server-to-server dentro de la red) =====
|
||||||
# Usa el alias de red del servicio 'authentik' y su puerto interno 9000
|
# Usa el alias de red del servicio 'authentik' y su puerto interno 9000
|
||||||
AUTHENTIK_BASE_URL=http://authentik:9000
|
AUTHENTIK_BASE_URL=http://dev-authentik:9000
|
||||||
AUTHENTIK_TOKEN=eE3bFTLd4Rpt3ZkcidTC1EppDYMIr023ev3SXt4ImHynOfAGRVtAZVBXSNxj
|
AUTHENTIK_TOKEN=eE3bFTLd4Rpt3ZkcidTC1EppDYMIr023ev3SXt4ImHynOfAGRVtAZVBXSNxj
|
||||||
AUTHENTIK_DEFAULT_GROUP_NAME=suitecoffee-users
|
AUTHENTIK_DEFAULT_GROUP_NAME=suitecoffee-users
|
||||||
|
|
||||||
@ -41,13 +39,15 @@ AUTHENTIK_DEFAULT_GROUP_NAME=suitecoffee-users
|
|||||||
# DEV (todo dentro de la red de Docker):
|
# DEV (todo dentro de la red de Docker):
|
||||||
# - El auth service redirige al navegador a este issuer. Si NO tenés reverse proxy hacia Authentik,
|
# - El auth service redirige al navegador a este issuer. Si NO tenés reverse proxy hacia Authentik,
|
||||||
# esta URL interna NO será accesible desde el navegador del host. En ese caso, ver nota más abajo.
|
# esta URL interna NO será accesible desde el navegador del host. En ese caso, ver nota más abajo.
|
||||||
OIDC_ISSUER=https://authentik.suitecoffee.mateosaldain.uy/application/o/suitecoffee/
|
# OIDC_ISSUER=https://authentik.suitecoffee.mateosaldain.uy/application/o/suitecoffee/
|
||||||
OIDC_CLIENT_ID=ydnp9s9I7G4p9Pwt5OsNlcpk1VKB9auN7AxqqNjC
|
OIDC_ISSUER=https://sso.suitecoffee.uy/application/o/suitecoffee/
|
||||||
OIDC_CLIENT_SECRET=yqdI00kYMeQF8VdmhwN5QWUzPLUzRBYeeAH193FynuVD19mo1nBRf5c5IRojzPrxDS0Hk33guUwHFzaj8vjTbTRetwK528uNJ6BfrYGUN2vzxgdMHFLQOHSTR0gR1LtG
|
OIDC_CLIENT_ID=1orMM8vOvf3WkN2FejXYvUFpPtONG0Lx1eMlwIpW
|
||||||
|
OIDC_CLIENT_SECRET=t5wx13qBcM0EFQ3cGnUIAmLzvbdsQrUVPv1OGWjszWkEp35pJQ55t7vZeeShqG49kuRAaiXv6PSGJLhRfGaponGaJl8gH1uCL7KIxdmm7UihgYoAXB2dFhZV4zRxfze2
|
||||||
|
|
||||||
# Redirect URI que definiste en el Provider. Usa el alias de red del servicio 'auth' (dev-auth)
|
# Redirect URI que definiste en el Provider. Usa el alias de red del servicio 'auth' (dev-auth)
|
||||||
# Si accedés desde el host sin proxy, usa mejor http://localhost:4040/auth/callback y añadilo al Provider.
|
# Si accedés desde el host sin proxy, usa mejor http://localhost:4040/auth/callback y añadilo al Provider.
|
||||||
OIDC_REDIRECT_URI=https://suitecoffee.mateosaldain.uy/auth/callback
|
# OIDC_REDIRECT_URI=https://suitecoffee.mateosaldain.uy/auth/callback
|
||||||
|
OIDC_REDIRECT_URI=https://suitecoffee.uy/auth/callback
|
||||||
|
|
||||||
# Cómo querés que maneje la contraseña Authentik para usuarios NUEVOS creados por tu backend:
|
# Cómo querés que maneje la contraseña Authentik para usuarios NUEVOS creados por tu backend:
|
||||||
# - TEMP_FORCE_CHANGE: crea un password temporal y obliga a cambiar en el primer login (recomendado si usás login con usuario/clave)
|
# - TEMP_FORCE_CHANGE: crea un password temporal y obliga a cambiar en el primer login (recomendado si usás login con usuario/clave)
|
||||||
|
|||||||
@ -1,126 +1,244 @@
|
|||||||
// services/auth/src/ak.js
|
// services/auth/src/ak.js
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
// Cliente mínimo y robusto para la API Admin de Authentik (v3)
|
// Cliente para la API Admin de Authentik (v3)
|
||||||
// - Sin dependencias externas (usa fetch nativo de Node >=18)
|
// - Sin dependencias externas (usa fetch nativo de Node >=18)
|
||||||
// - ESM compatible
|
// - ESM compatible
|
||||||
// - Timeouts, reintentos opcionales y mensajes de error claros
|
// - Timeouts, reintentos opcionales y mensajes de error claros
|
||||||
// - Compatible con services/auth/src/index.js actual
|
// - Compatible con services/auth/src/index.js actual
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
// Utiliza AUTHENTIK_BASE_URL y AUTHENTIK_TOKEN para validar y devuelve la configuración (base URL y token) desde variables de entorno.
|
||||||
|
// Devuelve la URL base y el Token que se leyó desde .env
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lee configuración desde process.env en cada llamada (para evitar problemas
|
* @typedef {Object} AkCfg
|
||||||
* de orden de imports con dotenv). No falla en import-time.
|
* @property {string} BASE // p.ej. "https://idp.example.com"
|
||||||
|
* @property {string} TOKEN // bearer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} AkOpts
|
||||||
|
* @property {Record<string, string|number|boolean|Array<string|number|boolean>>} [qs]
|
||||||
|
* @property {any} [body]
|
||||||
|
* @property {number} [timeoutMs=10000]
|
||||||
|
* @property {number} [retries=0]
|
||||||
|
* @property {Record<string,string>} [headers]
|
||||||
|
*/
|
||||||
|
|
||||||
function getConfig() {
|
function getConfig() {
|
||||||
const BASE = (process.env.AUTHENTIK_BASE_URL || '').replace(/\/+$/, '');
|
const BASE = (process.env.AUTHENTIK_BASE_URL || "").trim().replace(/\/+$/, "");
|
||||||
const TOKEN = process.env.AUTHENTIK_TOKEN || '';
|
const TOKEN = process.env.AUTHENTIK_TOKEN || '';
|
||||||
if (!BASE) throw new Error('AK_CONFIG: Falta AUTHENTIK_BASE_URL');
|
if (!BASE) throw new Error('[AK_CONFIG] Falta variable AUTHENTIK_BASE_URL');
|
||||||
if (!TOKEN) throw new Error('AK_CONFIG: Falta AUTHENTIK_TOKEN');
|
if (!TOKEN) throw new Error('[AK_CONFIG] Falta variable AUTHENTIK_TOKEN');
|
||||||
return { BASE, TOKEN };
|
return { BASE, TOKEN };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// Utilidades
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
// Espera
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
// Helpers de sincronización
|
// Helpers de sincronización
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
// -- util GET contra la API admin (ajusta si ya tenés un helper igual)
|
export function createAkClient(cfg = getConfig()) {
|
||||||
async function akGET(path) {
|
return {
|
||||||
const base = (process.env.AUTHENTIK_BASE_URL || '').replace(/\/+$/, '');
|
request: (method, path, opts = {}) => request(method, path, opts, cfg),
|
||||||
const url = `${base}${path}`;
|
akGET: (path, opts) => request("GET", path, opts, cfg),
|
||||||
const res = await fetch(url, {
|
akPOST: (path, opts) => request("POST", path, opts, cfg),
|
||||||
headers: { Authorization: `Bearer ${process.env.AUTHENTIK_TOKEN}` },
|
akPUT: (path, opts) => request("PUT", path, opts, cfg),
|
||||||
});
|
akPATCH: (path, opts) => request("PATCH", path, opts, cfg),
|
||||||
if (!res.ok) {
|
akDELETE:(path, opts) => request("DELETE", path, opts, cfg),
|
||||||
const body = await res.text().catch(() => '');
|
};
|
||||||
throw new Error(`AK GET ${path} -> ${res.status}: ${body}`);
|
|
||||||
}
|
|
||||||
return res.json();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- listar grupos con búsqueda por nombre/slug
|
// Listar grupos con búsqueda por nombre/slug
|
||||||
export async function akListGroups(search = '') {
|
export async function akListGroups(search = "") {
|
||||||
const q = search ? `?search=${encodeURIComponent(search)}` : '';
|
const { akGET } = createAkClient();
|
||||||
const data = await akGET(`/api/v3/core/groups/${q}`);
|
const term = String(search ?? "").trim();
|
||||||
// algunas versiones devuelven {results:[]}, otras un array directo
|
|
||||||
return Array.isArray(data) ? data : (data.results || []);
|
const data = await akGET("/core/groups/", {
|
||||||
|
qs: term ? { search: term } : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Array.isArray(data)) return data;
|
||||||
|
if (data && Array.isArray(data.results)) return data.results;
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function akPatchUserAttributes(userPk, partialAttrs = {}) {
|
export async function akPatchUserAttributes(userPk, partialAttrs = {}) {
|
||||||
// PATCH del usuario para asegurar attributes.tenant_uuid
|
|
||||||
return akRequest('patch', `/api/v3/core/users/${userPk}/`, {
|
const id = String(userPk ?? "").trim();
|
||||||
data: { attributes: partialAttrs },
|
if (!id) throw new TypeError("[AK_PATH_USER_ATRIBUTES] atribute `userPk` is required");
|
||||||
|
|
||||||
|
if (partialAttrs == null || typeof partialAttrs !== "object" || Array.isArray(partialAttrs)) {
|
||||||
|
throw new TypeError("[AK_PATH_USER_ATRIBUTES] atribute `partialAttrs` must be a plain object");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove undefineds to avoid unintentionally nulling keys server-side
|
||||||
|
const cleaned = Object.fromEntries(
|
||||||
|
Object.entries(partialAttrs).filter(([, v]) => v !== undefined)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Object.keys(cleaned).length === 0) {
|
||||||
|
throw new TypeError("[AK_PATH_USER_ATRIBUTES] atribute `partialAttrs` is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: pass path WITHOUT /api/v3; the client prefixes it
|
||||||
|
return akPATCH(`/core/users/${encodeURIComponent(id)}/`, {
|
||||||
|
body: { attributes: cleaned },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function akEnsureGroupForTenant(tenantHex) {
|
export async function akEnsureGroupForTenant(tenantHex) {
|
||||||
const groupName = `tenant_${tenantHex}`;
|
const { akGET, akPOST } = createAkClient();
|
||||||
|
|
||||||
// buscar por nombre
|
const hex = String(tenantHex ?? "").trim();
|
||||||
const data = await akRequest('get', '/api/v3/core/groups/', { params: { name: groupName }});
|
if (!hex) throw new TypeError("akEnsureGroupForTenant: `tenantHex` is required");
|
||||||
const g = (data?.results || [])[0];
|
|
||||||
if (g) return g.pk;
|
|
||||||
|
|
||||||
// crear si no existe
|
const groupName = `tenant_${hex}`;
|
||||||
const created = await akRequest('post', '/api/v3/core/groups/', {
|
|
||||||
data: { name: groupName, attributes: { tenant_uuid: tenantHex } },
|
// 1) Buscar existente (normaliza {results:[]}/[])
|
||||||
});
|
const data = await akGET("/core/groups/", { qs: { search: groupName } });
|
||||||
return created.pk;
|
const list = Array.isArray(data) ? data : (Array.isArray(data?.results) ? data.results : []);
|
||||||
|
const existing = list.find(g => g?.name === groupName);
|
||||||
|
if (existing?.pk ?? existing?.id) return existing.pk ?? existing.id;
|
||||||
|
|
||||||
|
// 2) Crear si no existe
|
||||||
|
try {
|
||||||
|
const created = await akPOST("/core/groups/", {
|
||||||
|
body: { name: groupName, attributes: { tenant_uuid: hex } },
|
||||||
|
});
|
||||||
|
return created?.pk ?? created?.id;
|
||||||
|
} catch (e) {
|
||||||
|
// 3) Condición de carrera (otro proceso lo creó): reconsulta y devuelve
|
||||||
|
const msg = String(e?.message || "");
|
||||||
|
if (/already exists|unique|duplicate|409/i.test(msg)) {
|
||||||
|
const data2 = await akGET("/core/groups/", { qs: { search: groupName } });
|
||||||
|
const list2 = Array.isArray(data2) ? data2 : (Array.isArray(data2?.results) ? data2.results : []);
|
||||||
|
const found = list2.find(g => g?.name === groupName);
|
||||||
|
if (found?.pk ?? found?.id) return found.pk ?? found.id;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function akAddUserToGroup(userPk, groupPk) {
|
export async function akAddUserToGroup(userPk, groupPk) {
|
||||||
// Endpoint de membership (en versiones recientes, POST users/<pk>/groups/)
|
const { akPOST } = createAkClient();
|
||||||
return akRequest('post', `/api/v3/core/users/${userPk}/groups/`, { data: { group: groupPk } });
|
|
||||||
|
const user = String(userPk ?? "").trim();
|
||||||
|
const group = String(groupPk ?? "").trim();
|
||||||
|
if (!user) throw new TypeError("akAddUserToGroup: `userPk` is required");
|
||||||
|
if (!group) throw new TypeError("akAddUserToGroup: `groupPk` is required");
|
||||||
|
|
||||||
|
// API reciente: POST /core/users/<pk>/groups/ { group: <pk> }
|
||||||
|
const path = `/core/users/${encodeURIComponent(user)}/groups/`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await akPOST(path, { body: { group } });
|
||||||
|
} catch (e) {
|
||||||
|
const msg = String(e?.message || "");
|
||||||
|
// Si ya es miembro, tratamos como éxito idempotente
|
||||||
|
if (/already.*member|exists|duplicate|409/i.test(msg)) {
|
||||||
|
return { ok: true, alreadyMember: true, userPk: user, groupPk: group };
|
||||||
|
}
|
||||||
|
// Fallback para instancias viejas: /core/group_memberships/ { user, group }
|
||||||
|
if (/404|not\s*found/i.test(msg)) {
|
||||||
|
return await akPOST("/core/group_memberships/", { body: { user, group } });
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Utilidad de espera
|
|
||||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Llamada HTTP genérica con fetch + timeout + manejo de errores.
|
* Llamada HTTP genérica con fetch + timeout + manejo de errores.
|
||||||
* @param {('GET'|'POST'|'PUT'|'PATCH'|'DELETE')} method
|
* @param {'GET'|'POST'|'PUT'|'PATCH'|'DELETE'} method
|
||||||
* @param {string} path - Ruta a partir de /api/v3 (por ej. "/core/users/")
|
* @param {string} path Ruta relativa (ej. "/core/users/") o absoluta; si es relativa se antepone "/api/v3".
|
||||||
* @param {{qs?:Record<string,string|number|boolean>, body?:any, timeoutMs?:number, retries?:number}} [opts]
|
* @param {AkOpts} [opts]
|
||||||
|
* @param {AkCfg} [cfg] Config inyectada; si se omite se usa getConfig()
|
||||||
|
* @returns {Promise<any|null>}
|
||||||
*/
|
*/
|
||||||
async function request(method, path, opts = {}) {
|
|
||||||
const { BASE, TOKEN } = getConfig();
|
export async function request(method, path, opts = {}, cfg) {
|
||||||
|
const { BASE, TOKEN } = cfg ?? getConfig();
|
||||||
const {
|
const {
|
||||||
qs = undefined,
|
qs,
|
||||||
body = undefined,
|
body,
|
||||||
timeoutMs = 10000,
|
timeoutMs = 10_000,
|
||||||
retries = 0,
|
retries = 0,
|
||||||
|
headers = {},
|
||||||
} = opts;
|
} = opts;
|
||||||
|
|
||||||
const url = new URL(`${BASE}/api/v3${path}`);
|
// Construcción segura de URL + QS
|
||||||
if (qs) Object.entries(qs).forEach(([k, v]) => url.searchParams.set(k, String(v)));
|
const base = BASE.endsWith("/") ? BASE : `${BASE}/`;
|
||||||
|
let p = /^https?:\/\//i.test(path) ? path : (path.startsWith("/") ? path : `/${path}`);
|
||||||
|
if (!/^https?:\/\//i.test(p) && !p.startsWith("/api/")) p = `/api/v3${p}`;
|
||||||
|
const url = new URL(p, base);
|
||||||
|
if (qs && typeof qs === "object") {
|
||||||
|
for (const [k, v] of Object.entries(qs)) {
|
||||||
|
if (v == null) continue;
|
||||||
|
if (Array.isArray(v)) v.forEach((x) => url.searchParams.append(k, String(x)));
|
||||||
|
else url.searchParams.set(k, String(v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reintentos + timeout
|
||||||
|
const maxAttempts = Math.max(1, retries + 1);
|
||||||
let lastErr;
|
let lastErr;
|
||||||
for (let attempt = 1; attempt <= Math.max(1, retries + 1); attempt++) {
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
const controller = new AbortController();
|
const ctrl = new AbortController();
|
||||||
const t = setTimeout(() => controller.abort(new Error('AK_TIMEOUT')), timeoutMs);
|
const t = setTimeout(() => ctrl.abort(new Error("AK_TIMEOUT")), timeoutMs);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, {
|
const init = {
|
||||||
method,
|
method,
|
||||||
signal: controller.signal,
|
signal: ctrl.signal,
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${TOKEN}`,
|
Authorization: `Bearer ${TOKEN}`,
|
||||||
'Content-Type': 'application/json',
|
Accept: "application/json",
|
||||||
'Accept': 'application/json',
|
...headers,
|
||||||
},
|
},
|
||||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
};
|
||||||
});
|
if (body !== undefined) {
|
||||||
|
// Sólo forzar JSON si es objeto plano
|
||||||
|
const isPlainObj = body && typeof body === "object" &&
|
||||||
|
!(body instanceof FormData) &&
|
||||||
|
!(body instanceof URLSearchParams) &&
|
||||||
|
!(typeof Blob !== "undefined" && body instanceof Blob);
|
||||||
|
if (isPlainObj) {
|
||||||
|
init.headers["Content-Type"] = init.headers["Content-Type"] || "application/json";
|
||||||
|
init.body = JSON.stringify(body);
|
||||||
|
} else {
|
||||||
|
init.body = body; // deja que fetch maneje el Content-Type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url, init);
|
||||||
clearTimeout(t);
|
clearTimeout(t);
|
||||||
|
|
||||||
if (res.status === 204) return null; // sin contenido
|
if (res.status === 204 || res.status === 205) return null;
|
||||||
|
const ctype = res.headers.get("content-type") || "";
|
||||||
// intenta parsear JSON; si no es JSON, devuelve texto
|
const payload = /\bapplication\/json\b/i.test(ctype) ? await res.json().catch(() => ({})) : await res.text();
|
||||||
const ctype = res.headers.get('content-type') || '';
|
|
||||||
const payload = ctype.includes('application/json') ? await res.json().catch(() => ({})) : await res.text();
|
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const detail = typeof payload === 'string' ? payload : payload?.detail || payload?.error || JSON.stringify(payload);
|
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}`);
|
const err = new Error(`AK ${method} ${url.pathname}${url.search} → ${res.status}: ${detail}`);
|
||||||
err.status = res.status; // @ts-ignore
|
err.status = res.status; // @ts-ignore
|
||||||
|
// Reintenta 5xx y 429
|
||||||
|
if ((res.status >= 500 && res.status <= 599) || res.status === 429) {
|
||||||
|
lastErr = err;
|
||||||
|
if (attempt < maxAttempts) {
|
||||||
|
let delay = 500 * 2 ** (attempt - 1);
|
||||||
|
const ra = parseInt(res.headers.get("retry-after") || "", 10);
|
||||||
|
if (!Number.isNaN(ra)) delay = Math.max(delay, ra * 1000);
|
||||||
|
await new Promise(r => setTimeout(r, delay));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -128,11 +246,10 @@ async function request(method, path, opts = {}) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
clearTimeout(t);
|
clearTimeout(t);
|
||||||
lastErr = e;
|
lastErr = e;
|
||||||
// Reintentos sólo en ECONNREFUSED/timeout/5xx
|
const msg = String(e?.message || "");
|
||||||
const msg = String(e?.message || e);
|
const retriable = msg.includes("AK_TIMEOUT") || msg.includes("ECONNREFUSED") || msg.includes("fetch failed");
|
||||||
const retriable = msg.includes('ECONNREFUSED') || msg.includes('AK_TIMEOUT') || /\b5\d\d\b/.test(e?.status?.toString?.() || '');
|
if (!retriable || attempt >= maxAttempts) throw e;
|
||||||
if (!retriable || attempt > retries) throw e;
|
await new Promise(r => setTimeout(r, 500 * 2 ** (attempt - 1)));
|
||||||
await sleep(500 * attempt); // backoff lineal suave
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw lastErr;
|
throw lastErr;
|
||||||
@ -142,124 +259,178 @@ async function request(method, path, opts = {}) {
|
|||||||
// Funciones públicas
|
// 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) {
|
export async function akFindUserByEmail(email) {
|
||||||
if (!email) throw new Error('akFindUserByEmail: email requerido');
|
const { akGET } = createAkClient();
|
||||||
const data = await request('GET', '/core/users/', { qs: { search: email, page_size: 50 }, retries: 3 });
|
|
||||||
const list = Array.isArray(data?.results) ? data.results : [];
|
const needle = String(email ?? "").trim().toLowerCase();
|
||||||
const lower = String(email).toLowerCase();
|
if (!needle) throw new TypeError("akFindUserByEmail: `email` is required");
|
||||||
return list.find((u) => (u.email || '').toLowerCase() === lower) || null;
|
|
||||||
|
const PAGE_SIZE = 100;
|
||||||
|
let page = 1;
|
||||||
|
const MAX_PAGES = 10;
|
||||||
|
|
||||||
|
while (page <= MAX_PAGES) {
|
||||||
|
const data = await akGET("/core/users/", {
|
||||||
|
qs: { search: needle, page_size: PAGE_SIZE, page },
|
||||||
|
retries: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const list = Array.isArray(data)
|
||||||
|
? data
|
||||||
|
: (Array.isArray(data?.results) ? data.results : []);
|
||||||
|
|
||||||
|
const found = list.find(u => String(u?.email || "").toLowerCase() === needle);
|
||||||
|
if (found) return found || null;
|
||||||
|
|
||||||
|
// Continuar paginando sólo si hay más resultados
|
||||||
|
const hasNext =
|
||||||
|
Array.isArray(data)
|
||||||
|
? list.length === PAGE_SIZE // array plano: inferimos por tamaño
|
||||||
|
: Boolean(data?.next); // DRF: link "next"
|
||||||
|
if (!hasNext) break;
|
||||||
|
|
||||||
|
page += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function akCreateUser(p = {}) {
|
||||||
* Crea un usuario en Authentik con atributos de tenant y opcionalmente lo
|
const { akPOST } = createAkClient();
|
||||||
* 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 email = String(p.email ?? "").trim().toLowerCase();
|
||||||
const user = await request('POST', '/core/users/', {
|
if (!email) throw new TypeError("akCreateUser: `email` is required");
|
||||||
body: {
|
|
||||||
username: email,
|
|
||||||
name,
|
|
||||||
email,
|
|
||||||
is_active: isActive,
|
|
||||||
attributes: tenantUuid ? { tenant_uuid: tenantUuid } : {},
|
|
||||||
},
|
|
||||||
retries: 3,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2) agregar a grupo (opcional)
|
const name = String(p.displayName ?? email).trim() || email;
|
||||||
if (p?.addToGroupId) {
|
const tenantUuid = String(p.tenantUuid ?? "").replace(/-/g, "").trim();
|
||||||
|
const isActive = p.isActive ?? true;
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
username: email,
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
is_active: !!isActive,
|
||||||
|
attributes: tenantUuid ? { tenant_uuid: tenantUuid } : {},
|
||||||
|
};
|
||||||
|
|
||||||
|
let user;
|
||||||
|
try {
|
||||||
|
user = await akPOST("/core/users/", { body, retries: 2 });
|
||||||
|
} catch (e) {
|
||||||
|
const msg = String(e?.message || "");
|
||||||
|
if (/409|already\s*exists|unique|duplicate/i.test(msg)) {
|
||||||
|
// Idempotencia: si ya existe, lo buscamos por email y lo devolvemos
|
||||||
|
const existing = await akFindUserByEmail(email);
|
||||||
|
if (existing) return existing;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agregar a grupo (opcional, no rompe el flujo si falla)
|
||||||
|
const groupId = p.addToGroupId != null ? String(p.addToGroupId).trim() : "";
|
||||||
|
if (groupId) {
|
||||||
try {
|
try {
|
||||||
await request('POST', `/core/users/${user.pk}/groups/`, { body: { group: p.addToGroupId }, retries: 2 });
|
const userPk = encodeURIComponent(user.pk ?? user.id);
|
||||||
} catch (e) {
|
await akPOST(`/core/users/${userPk}/groups/`, {
|
||||||
// No rompas todo por el grupo; deja registro del error para que el caller decida.
|
body: { group: groupId },
|
||||||
console.warn(`akCreateUser: no se pudo agregar al grupo ${p.addToGroupId}:`, e?.message || e);
|
retries: 2,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
`akCreateUser: could not add user ${user.pk ?? user.id} to group ${groupId}:`,
|
||||||
|
err?.message || err
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return user;
|
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) {
|
export async function akSetPassword(userPk, password, requireChange = true) {
|
||||||
if (!userPk) throw new Error('akSetPassword: userPk requerido');
|
const { akPOST } = createAkClient();
|
||||||
if (!password) throw new Error('akSetPassword: password requerida');
|
|
||||||
|
const id = String(userPk ?? "").trim();
|
||||||
|
if (!id) throw new TypeError("akSetPassword: `userPk` is required");
|
||||||
|
|
||||||
|
const pwd = String(password ?? "");
|
||||||
|
if (!pwd) throw new TypeError("akSetPassword: `password` is required");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await request('POST', `/core/users/${userPk}/set_password/`, {
|
await akPOST(`/core/users/${encodeURIComponent(id)}/set_password/`, {
|
||||||
body: { password, require_change: !!requireChange },
|
body: { password: pwd, require_change: !!requireChange },
|
||||||
retries: 1,
|
retries: 1,
|
||||||
|
timeoutMs: 15_000,
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Algunas instalaciones no permiten setear password por API (políticas). Propaga un error legible.
|
const status = e?.status ? `HTTP ${e.status}: ` : "";
|
||||||
const err = new Error(`akSetPassword: no se pudo establecer la contraseña: ${e?.message || e}`);
|
const err = new Error(`akSetPassword: failed to set password (${status}${e?.message || e})`);
|
||||||
err.cause = e;
|
err.cause = e;
|
||||||
throw err;
|
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 akResolveGroupIdByName(name) {
|
export async function akResolveGroupIdByName(name) {
|
||||||
const data = await akListGroups(name);
|
const term = String(name ?? "").trim();
|
||||||
const lower = String(name || '').toLowerCase();
|
if (!term) throw new TypeError("akResolveGroupIdByName: `name` is required");
|
||||||
const found = data.find(g =>
|
|
||||||
String(g.name || '').toLowerCase() === lower ||
|
const needle = term.toLowerCase();
|
||||||
String(g.slug || '').toLowerCase() === lower
|
const groups = await akListGroups(term);
|
||||||
);
|
if (!Array.isArray(groups) || groups.length === 0) return null;
|
||||||
return found?.pk ?? null;
|
|
||||||
|
// Prefer exact slug match, then exact name match
|
||||||
|
const bySlug = groups.find(g => String(g?.slug ?? "").toLowerCase() === needle);
|
||||||
|
if (bySlug) return bySlug.pk ?? bySlug.id ?? null;
|
||||||
|
|
||||||
|
const byName = groups.find(g => String(g?.name ?? "").toLowerCase() === needle);
|
||||||
|
return byName?.pk ?? byName?.id ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function akResolveGroupId({ id, pk, uuid, name, slug } = {}) {
|
export async function akResolveGroupId({ id, pk, uuid, name, slug } = {}) {
|
||||||
// si te pasan pk/id directo, devolvelo
|
const toPk = (v) => {
|
||||||
if (pk != null) return Number(pk);
|
if (v == null || v === "") return null;
|
||||||
if (id != null) return Number(id);
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? n : String(v);
|
||||||
|
};
|
||||||
|
|
||||||
// por UUID (devuelve objeto con pk)
|
// 1) Direct pk/id
|
||||||
if (uuid) {
|
const direct = pk ?? id;
|
||||||
|
const directPk = toPk(direct);
|
||||||
|
if (directPk != null) return directPk;
|
||||||
|
|
||||||
|
const { akGET } = createAkClient();
|
||||||
|
|
||||||
|
// 2) By UUID (detail endpoint)
|
||||||
|
const uuidStr = String(uuid ?? "").trim();
|
||||||
|
if (uuidStr) {
|
||||||
try {
|
try {
|
||||||
const g = await akGET(`/api/v3/core/groups/${encodeURIComponent(uuid)}/`);
|
const g = await akGET(`/core/groups/${encodeURIComponent(uuidStr)}/`, { retries: 1 });
|
||||||
if (g?.pk != null) return Number(g.pk);
|
const fromDetail = toPk(g?.pk ?? g?.id);
|
||||||
} catch (e) {
|
if (fromDetail != null) return fromDetail;
|
||||||
// sigue intentando por nombre/slug
|
} catch { /* continue with name/slug */ }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// por nombre/slug
|
// 3) By exact name/slug
|
||||||
if (name || slug) {
|
const needle = String(name ?? slug ?? "").trim();
|
||||||
const needle = (name || slug);
|
if (needle) {
|
||||||
const list = await akListGroups(needle);
|
const lower = needle.toLowerCase();
|
||||||
const lower = String(needle || '').toLowerCase();
|
const list = await akListGroups(needle); // expects [] or {results:[]}, handled in akListGroups
|
||||||
const found = list.find(g =>
|
const found =
|
||||||
String(g.name || '').toLowerCase() === lower ||
|
list.find(g => String(g?.slug ?? "").toLowerCase() === lower) ||
|
||||||
String(g.slug || '').toLowerCase() === lower
|
list.find(g => String(g?.name ?? "").toLowerCase() === lower);
|
||||||
);
|
const fromList = toPk(found?.pk ?? found?.id);
|
||||||
if (found?.pk != null) return Number(found.pk);
|
if (fromList != null) return fromList;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
// Fin
|
// Exportación de constantes
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
export const akGET = (path, opts) => request("GET", path, opts);
|
||||||
|
export const akPOST = (path, opts) => request("POST", path, opts);
|
||||||
|
export const akPUT = (path, opts) => request("PUT", path, opts);
|
||||||
|
export const akPATCH = (path, opts) => request("PATCH", path, opts);
|
||||||
|
export const akDELETE = (path, opts) => request("DELETE", path, opts);
|
||||||
@ -5,7 +5,7 @@
|
|||||||
// - Sesiones con Redis (compartibles con otros servicios)
|
// - Sesiones con Redis (compartibles con otros servicios)
|
||||||
// - Vistas EJS (login)
|
// - Vistas EJS (login)
|
||||||
// - Rutas OIDC: /auth/login, /auth/callback, /auth/logout
|
// - Rutas OIDC: /auth/login, /auth/callback, /auth/logout
|
||||||
// - Registro de usuario: /api/users/register (DB + Authentik)
|
// - Registro de usuario: /auth/api/users/register (DB + Authentik)
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
@ -13,23 +13,28 @@ import chalk from 'chalk';
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import fs from 'node:fs/promises';
|
import { access, readFile } from 'node:fs/promises';
|
||||||
|
import { constants as fsConstants } from 'node:fs';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { Pool } from 'pg';
|
import { Pool } from 'pg';
|
||||||
import session from 'express-session';
|
import session from 'express-session';
|
||||||
import { createClient as createRedisClient } from 'redis';
|
import { createClient } from 'redis';
|
||||||
import * as connectRedis from 'connect-redis';
|
|
||||||
import expressLayouts from 'express-ejs-layouts';
|
import expressLayouts from 'express-ejs-layouts';
|
||||||
import { Issuer, generators } from 'openid-client';
|
import { Issuer, generators } from 'openid-client';
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import { readFile } from 'node:fs/promises';
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Importaciones desde archivos
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Helpers de Authentik (admin API)
|
||||||
|
import { akFindUserByEmail, akCreateUser,
|
||||||
|
akSetPassword, akResolveGroupId } from './ak.js';
|
||||||
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
const SESSION_COOKIE_NAME = 'sc.sid';
|
|
||||||
|
|
||||||
// Normaliza UUID (acepta con/sin guiones) → "hex" sin guiones
|
// Normaliza UUID (acepta con/sin guiones) → "hex" sin guiones
|
||||||
const cleanUuid = (u) => (u ? String(u).toLowerCase().replace(/[^0-9a-f]/g, '') : '');
|
const cleanUuid = (u) => (u ? String(u).toLowerCase().replace(/[^0-9a-f]/g, '') : '');
|
||||||
|
|
||||||
@ -37,14 +42,6 @@ const cleanUuid = (u) => (u ? String(u).toLowerCase().replace(/[^0-9a-f]/g, '')
|
|||||||
const schemaNameFor = (uuidHex) => `schema_tenant_${uuidHex}`;
|
const schemaNameFor = (uuidHex) => `schema_tenant_${uuidHex}`;
|
||||||
const roleNameFor = (uuidHex) => `tenant_${uuidHex}`;
|
const roleNameFor = (uuidHex) => `tenant_${uuidHex}`;
|
||||||
|
|
||||||
// Helpers de Authentik (admin API)
|
|
||||||
const {
|
|
||||||
akFindUserByEmail,
|
|
||||||
akCreateUser,
|
|
||||||
akSetPassword,
|
|
||||||
akResolveGroupId
|
|
||||||
} = await import('./ak.js');
|
|
||||||
|
|
||||||
// Quoter seguro de identificadores SQL (roles, schemas, tablas)
|
// Quoter seguro de identificadores SQL (roles, schemas, tablas)
|
||||||
// Identificador SQL (schema, role, table, …)
|
// Identificador SQL (schema, role, table, …)
|
||||||
const qi = (ident) => `"${String(ident).replace(/"/g, '""')}"`;
|
const qi = (ident) => `"${String(ident).replace(/"/g, '""')}"`;
|
||||||
@ -282,13 +279,13 @@ let _cachedInitSql = null;
|
|||||||
async function loadInitSql() {
|
async function loadInitSql() {
|
||||||
if (_cachedInitSql !== null) return _cachedInitSql;
|
if (_cachedInitSql !== null) return _cachedInitSql;
|
||||||
const candidates = [
|
const candidates = [
|
||||||
process.env.TENANT_INIT_SQL, // recomendado via .env
|
process.env.TENANT_INIT_SQL, // opcional
|
||||||
path.resolve(__dirname, 'db', 'initTenant.sql'),
|
path.resolve(__dirname, 'db', 'initTenant.sql'),
|
||||||
path.resolve(__dirname, '..', 'src', 'db', 'initTenant.sql'),
|
path.resolve(__dirname, '..', 'src', 'db', 'initTenant.sql'),
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
for (const p of candidates) {
|
for (const p of candidates) {
|
||||||
try {
|
try {
|
||||||
await fs.promises.access(p, fs.constants.R_OK);
|
await access(p, fsConstants.R_OK);
|
||||||
const txt = await readFile(p, 'utf8');
|
const txt = await readFile(p, 'utf8');
|
||||||
_cachedInitSql = String(txt || '');
|
_cachedInitSql = String(txt || '');
|
||||||
console.log(`[TENANT INIT] initTenant.sql: ${p} (${_cachedInitSql.length} bytes)`);
|
console.log(`[TENANT INIT] initTenant.sql: ${p} (${_cachedInitSql.length} bytes)`);
|
||||||
@ -349,7 +346,6 @@ async function initializeTenantSchemaIfEmpty(schema) {
|
|||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
const RedisStore = connectRedis.default || connectRedis.RedisStore;
|
|
||||||
|
|
||||||
function requiredEnv(keys) {
|
function requiredEnv(keys) {
|
||||||
const missing = keys.filter((k) => !process.env[k]);
|
const missing = keys.filter((k) => !process.env[k]);
|
||||||
@ -375,7 +371,7 @@ function genTempPassword(len = 12) {
|
|||||||
// Configuración Express
|
// Configuración Express
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
const app = express();
|
const app = express();
|
||||||
app.set('trust proxy', 1);
|
app.set('trust proxy', Number(process.env.TRUST_PROXY_HOPS || 2));
|
||||||
app.use(cors({ origin: true, credentials: true }));
|
app.use(cors({ origin: true, credentials: true }));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.urlencoded({ extended: true }));
|
app.use(express.urlencoded({ extended: true }));
|
||||||
@ -384,31 +380,70 @@ app.use(express.urlencoded({ extended: true }));
|
|||||||
app.set('views', path.join(__dirname, 'views'));
|
app.set('views', path.join(__dirname, 'views'));
|
||||||
app.set('view engine', 'ejs');
|
app.set('view engine', 'ejs');
|
||||||
|
|
||||||
|
|
||||||
// Archivos estáticos opcionales (ajusta si tu estructura difiere)
|
// Archivos estáticos opcionales (ajusta si tu estructura difiere)
|
||||||
app.use(express.static(path.join(__dirname, 'public')));
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
app.use('/pages', express.static(path.join(__dirname, 'pages')));
|
app.use('/pages', express.static(path.join(__dirname, 'pages')));
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Sesión (Redis)
|
// Sesión (Redis) — misma cookie que APP
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
requiredEnv(['SESSION_SECRET', 'REDIS_URL']);
|
requiredEnv(['SESSION_SECRET', 'REDIS_URL']);
|
||||||
const redis = createRedisClient({ url: process.env.REDIS_URL || 'redis://sessions-redis:6379' });
|
const SESSION_COOKIE_NAME = process.env.SESSION_NAME || "sc.sid";
|
||||||
await redis.connect().catch((e) => onFatal(e, 'No se pudo conectar a Redis (sesiones)'));
|
const SESSION_SECRET = process.env.SESSION_SECRET || "pon-una-clave-larga-y-unica";
|
||||||
|
const REDIS_URL = process.env.REDIS_URL || "redis://authentik-redis:6379";
|
||||||
|
|
||||||
app.use(
|
// 1) Redis client
|
||||||
session({
|
const redis = createClient({ url: REDIS_URL /*, legacyMode: true */ });
|
||||||
name: SESSION_COOKIE_NAME,
|
redis.on("error", (err) => console.error("[Redis] Client Error:", err));
|
||||||
store: new RedisStore({ client: redis, prefix: 'sess:' }),
|
await redis.connect();
|
||||||
secret: process.env.SESSION_SECRET || 'change-me',
|
console.log("[Redis] connected");
|
||||||
resave: false,
|
|
||||||
saveUninitialized: false,
|
// 2) Resolver RedisStore (soporta:
|
||||||
cookie: {
|
// - v5: factory CJS -> connectRedis(session)
|
||||||
httpOnly: true,
|
// - v6/v7: export { RedisStore } ó export default class RedisStore)
|
||||||
sameSite: 'lax',
|
async function resolveRedisStore(session) {
|
||||||
secure: process.env.NODE_ENV === 'production',
|
const mod = await import("connect-redis"); // ESM/CJS agnóstico
|
||||||
},
|
// named export (v6/v7)
|
||||||
})
|
if (typeof mod.RedisStore === "function") return mod.RedisStore;
|
||||||
);
|
// default export (class ó factory)
|
||||||
|
if (typeof mod.default === "function") {
|
||||||
|
// ¿es clase neweable?
|
||||||
|
if (mod.default.prototype && (mod.default.prototype.get || mod.default.prototype.set)) {
|
||||||
|
return mod.default; // class RedisStore
|
||||||
|
}
|
||||||
|
// si no, asumimos factory antigua
|
||||||
|
const Store = mod.default(session); // connectRedis(session)
|
||||||
|
if (typeof Store === "function") return Store; // class devuelta por factory
|
||||||
|
}
|
||||||
|
// algunos builds CJS exponen la factory bajo mod (poco común)
|
||||||
|
if (typeof mod === "function") {
|
||||||
|
const Store = mod(session);
|
||||||
|
if (typeof Store === "function") return Store;
|
||||||
|
}
|
||||||
|
throw new Error("connect-redis: no se pudo resolver RedisStore (API desconocida).");
|
||||||
|
}
|
||||||
|
|
||||||
|
const RedisStore = await resolveRedisStore(session);
|
||||||
|
|
||||||
|
// 3) Session middleware
|
||||||
|
app.use(session({
|
||||||
|
name: SESSION_COOKIE_NAME,
|
||||||
|
secret: SESSION_SECRET,
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
store: new RedisStore({
|
||||||
|
client: redis,
|
||||||
|
prefix: "sc:", // opcional
|
||||||
|
}),
|
||||||
|
proxy: true,
|
||||||
|
cookie: {
|
||||||
|
secure: "auto",
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/", // ¡crítico! visible en / y /auth/*
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
// Exponer usuario a las vistas (no tocar req.session)
|
// Exponer usuario a las vistas (no tocar req.session)
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
@ -507,6 +542,89 @@ let oidcClient;
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Vistas
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Página de login
|
||||||
|
app.get("/auth/login", (_req, res) => {
|
||||||
|
return res.render("login", { pageTitle: "Iniciar sesión" });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/auth/login", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const email = String(req.body.email || "").trim().toLowerCase();
|
||||||
|
const password = String(req.body.password || "");
|
||||||
|
const remember = req.body.remember === "on" || req.body.remember === true;
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
return res.status(400).render("login", { pageTitle: "Iniciar sesión", error: "Completa email y contraseña." });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tabla/columnas por defecto; ajustables por env si tu esquema difiere
|
||||||
|
const USERS_TABLE = process.env.TENANTS_USERS_TABLE || "users";
|
||||||
|
const COL_ID = process.env.TENANTS_COL_ID || "id";
|
||||||
|
const COL_EMAIL = process.env.TENANTS_COL_EMAIL || "email";
|
||||||
|
const COL_HASH = process.env.TENANTS_COL_HASH || "password_hash";
|
||||||
|
const COL_ROLE = process.env.TENANTS_COL_ROLE || "role";
|
||||||
|
const COL_TENANT = process.env.TENANTS_COL_TENANT || "tenant_id";
|
||||||
|
|
||||||
|
const { rows } = await tenantsPool.query(
|
||||||
|
`SELECT ${COL_ID} AS id, ${COL_EMAIL} AS email, ${COL_HASH} AS password_hash,
|
||||||
|
${COL_ROLE} AS role, ${COL_TENANT} AS tenant_id
|
||||||
|
FROM ${USERS_TABLE}
|
||||||
|
WHERE ${COL_EMAIL} = $1
|
||||||
|
LIMIT 1`,
|
||||||
|
[email]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return res.status(401).render("login", { pageTitle: "Iniciar sesión", error: "Credenciales inválidas." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = rows[0];
|
||||||
|
const ok = await bcrypt.compare(password, user.password_hash || "");
|
||||||
|
if (!ok) {
|
||||||
|
return res.status(401).render("login", { pageTitle: "Iniciar sesión", error: "Credenciales inválidas." });
|
||||||
|
}
|
||||||
|
|
||||||
|
// (Opcional) registro de acceso en DB principal
|
||||||
|
try {
|
||||||
|
await pool.query(
|
||||||
|
"INSERT INTO auth_audit_log(email, tenant_id, action, at) VALUES ($1, $2, $3, NOW())",
|
||||||
|
[user.email, user.tenant_id, "login_success"]
|
||||||
|
);
|
||||||
|
} catch { /* noop si no existe la tabla */ }
|
||||||
|
|
||||||
|
// Sesión compartida
|
||||||
|
req.session.regenerate((err) => {
|
||||||
|
if (err) return next(err);
|
||||||
|
|
||||||
|
req.session.user = {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
tenant_id: user.tenant_id,
|
||||||
|
loggedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (remember) {
|
||||||
|
req.session.cookie.maxAge = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
} else {
|
||||||
|
req.session.cookie.expires = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
req.session.save((err2) => {
|
||||||
|
if (err2) return next(err2);
|
||||||
|
return res.redirect(303, "/"); // "/" → app decide /dashboard o /auth/login
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Rutas OIDC
|
// Rutas OIDC
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
@ -617,7 +735,7 @@ app.get('/auth/callback', async (req, res, next) => {
|
|||||||
if (!res.headersSent) res.status(500).send('No se pudo guardar la sesión.');
|
if (!res.headersSent) res.status(500).send('No se pudo guardar la sesión.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!res.headersSent) return res.redirect('/'); // te llevará a /comandas si ya implementaste ese redirect
|
if (!res.headersSent) return res.redirect('/');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -668,9 +786,9 @@ async function akDeleteUser(pkOrUuid) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ==============================
|
// ==============================
|
||||||
// POST /api/users/register
|
// POST /auth/api/users/register
|
||||||
// ==============================
|
// ==============================
|
||||||
app.post('/api/users/register', async (req, res, next) => {
|
app.post('/auth/api/users/register', async (req, res, next) => {
|
||||||
// 0) input
|
// 0) input
|
||||||
const {
|
const {
|
||||||
email,
|
email,
|
||||||
@ -830,7 +948,7 @@ app.post('/auth/password/set', async (req, res, next) => {
|
|||||||
|
|
||||||
|
|
||||||
// Espera: { email, display_name?, tenant_uuid }
|
// Espera: { email, display_name?, tenant_uuid }
|
||||||
// app.post('/api/users/register', async (req, res, next) => {
|
// app.post('/auth/auth/api/users/register', async (req, res, next) => {
|
||||||
|
|
||||||
// const { email, display_name, tenant_uuid: rawTenant, role } = req.body || {};
|
// const { email, display_name, tenant_uuid: rawTenant, role } = req.body || {};
|
||||||
// if (!email) return res.status(400).json({ error: 'email es obligatorio' });
|
// if (!email) return res.status(400).json({ error: 'email es obligatorio' });
|
||||||
@ -893,7 +1011,7 @@ app.post('/auth/password/set', async (req, res, next) => {
|
|||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Healthcheck
|
// Healthcheck
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
app.get('/health', (_req, res) => res.status(200).json({ status: 'ok' }));
|
app.get('/health', (_req, res) => res.status(200).json({ status: 'ok', service: 'auth' }));
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// 404 + Manejo de errores
|
// 404 + Manejo de errores
|
||||||
@ -916,6 +1034,8 @@ const PORT = Number(process.env.PORT || 4040);
|
|||||||
console.log(`Activando entorno de -> ${env === 'PRODUCTION' ? chalk.red('PRODUCTION') : chalk.green('DEVELOPMENT')}`);
|
console.log(`Activando entorno de -> ${env === 'PRODUCTION' ? chalk.red('PRODUCTION') : chalk.green('DEVELOPMENT')}`);
|
||||||
await verificarConexion();
|
await verificarConexion();
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Servidor de autenticación de SuiteCoffee corriendo en ${chalk.yellow(`http://localhost:${PORT}`)}`);
|
console.log(`[AUTH] SuiteCoffee corriendo en http://localhost:${PORT}`);
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
export default app;
|
||||||
164
services/auth/src/views/login.ejs
Normal file
164
services/auth/src/views/login.ejs
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="es">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- 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 /auth/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 /auth/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('/auth/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