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
|
||||
|
||||
# app - app
|
||||
APP_LOCAL_PORT=3030
|
||||
APP_DOCKER_PORT=3030
|
||||
APP_PORT=3030
|
||||
|
||||
# auth - app
|
||||
AUTH_LOCAL_PORT=4040
|
||||
AUTH_DOCKER_PORT=4040
|
||||
AUTH_PORT=4040
|
||||
|
||||
# tenants - postgres
|
||||
TENANTS_DB_NAME=dev-postgres
|
||||
TENANTS_DB_USER=dev-user-postgres
|
||||
TENANTS_DB_PASS=dev-pass-postgres
|
||||
|
||||
TENANTS_DB_LOCAL_PORT=54321
|
||||
TENANTS_DB_DOCKER_PORT=5432
|
||||
|
||||
# db primaria - postgres
|
||||
DB_NAME=dev-suitecoffee
|
||||
DB_USER=dev-user-suitecoffee
|
||||
DB_PASS=dev-pass-suitecoffee
|
||||
|
||||
DB_LOCAL_PORT=54322
|
||||
DB_DOCKER_PORT=5432
|
||||
|
||||
# --- secretos para Authentik
|
||||
AUTHENTIK_SECRET_KEY=poné_un_valor_largo_y_unico
|
||||
AUTHENTIK_DB_PASS=cambia_esto
|
||||
AUTHENTIK_BOOTSTRAP_PASSWORD=cambia_esto
|
||||
AUTHENTIK_BOOTSTRAP_EMAIL=info.suitecoffee@gmail.com
|
||||
AK_SECRET_KEY=Timothy-Yearning-Unzip-Playmate3-Snowiness-Desecrate
|
||||
AK_DB_PASS=Doable8
|
||||
AK_BOOTSTRAP_PASSWORD=Succulent-Sanded7
|
||||
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
|
||||
|
||||
# app - app
|
||||
APP_LOCAL_PORT=3000
|
||||
APP_DOCKER_PORT=3000
|
||||
APP_PORT=3000
|
||||
|
||||
# auth - app
|
||||
AUTH_LOCAL_PORT=4000
|
||||
AUTH_DOCKER_PORT=4000
|
||||
AUTH_PORT=4000
|
||||
|
||||
# tenants - 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:
|
||||
NODE_ENV: development # <- fuerza el entorno para que el loader tome .env.development
|
||||
expose:
|
||||
- ${APP_LOCAL_PORT}
|
||||
- ${APP_PORT}
|
||||
networks:
|
||||
net:
|
||||
aliases: [dev-app]
|
||||
@ -33,7 +33,7 @@ services:
|
||||
environment:
|
||||
NODE_ENV: development # <- fuerza el entorno para que el loader tome .env.development
|
||||
expose:
|
||||
- ${AUTH_LOCAL_PORT}
|
||||
- ${AUTH_PORT}
|
||||
networks:
|
||||
net:
|
||||
aliases: [dev-auth]
|
||||
@ -67,71 +67,84 @@ services:
|
||||
### Authentik ###
|
||||
#################
|
||||
|
||||
authentik-db:
|
||||
image: postgres:16
|
||||
ak-db:
|
||||
image: docker.io/library/postgres:16-alpine
|
||||
env_file:
|
||||
- .env.development
|
||||
environment:
|
||||
POSTGRES_DB: authentik
|
||||
POSTGRES_USER: authentik
|
||||
POSTGRES_PASSWORD: ${AUTHENTIK_DB_PASS}
|
||||
POSTGRES_DB: ${PG_DB:-authentik}
|
||||
POSTGRES_PASSWORD: ${PG_PASS:?database password required}
|
||||
POSTGRES_USER: ${PG_USER:-authentik}
|
||||
volumes:
|
||||
- authentik-db:/var/lib/postgresql/data
|
||||
networks:
|
||||
net:
|
||||
aliases: [ak-db]
|
||||
aliases: [dev-ak-db]
|
||||
|
||||
authentik-redis:
|
||||
image: redis:7-alpine
|
||||
command: ["redis-server", "--save", "", "--appendonly", "no"]
|
||||
ak-redis:
|
||||
image: docker.io/library/redis:alpine
|
||||
command: --save 60 1 --loglevel warning
|
||||
networks:
|
||||
net:
|
||||
aliases: [ak-redis]
|
||||
aliases: [dev-ak-redis]
|
||||
volumes:
|
||||
- ak-redis:/data
|
||||
|
||||
authentik:
|
||||
ak:
|
||||
image: ghcr.io/goauthentik/server:latest
|
||||
env_file:
|
||||
- .env.development
|
||||
command: server
|
||||
environment:
|
||||
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
|
||||
AUTHENTIK_DEBUG: "false"
|
||||
AUTHENTIK_POSTGRESQL__HOST: authentik-db
|
||||
AUTHENTIK_POSTGRESQL__USER: authentik
|
||||
AUTHENTIK_POSTGRESQL__NAME: authentik
|
||||
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_DB_PASS}
|
||||
AUTHENTIK_REDIS__HOST: authentik-redis
|
||||
AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD}
|
||||
AUTHENTIK_BOOTSTRAP_EMAIL: ${AUTHENTIK_BOOTSTRAP_EMAIL}
|
||||
AUTHENTIK_SECRET_KEY: "Timothy-Yearning-Unzip-Playmate3-Snowiness-Desecrate"
|
||||
AUTHENTIK_DEBUG: false
|
||||
AUTHENTIK_POSTGRESQL__HOST: ak-db
|
||||
AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}
|
||||
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
|
||||
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
|
||||
AUTHENTIK_REDIS__HOST: ak-redis
|
||||
|
||||
AUTHENTIK_BOOTSTRAP_PASSWORD: Succulent-Sanded7
|
||||
AUTHENTIK_BOOTSTRAP_EMAIL: info.suitecoffee@gmail.com
|
||||
AUTHENTIK_HTTP__TRUSTED_PROXY__CIDRS: "0.0.0.0/0,::/0"
|
||||
AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS: "https://authentik.suitecoffee.mateosaldain.uy,https://suitecoffee.mateosaldain.uy"
|
||||
AUTHENTIK_COOKIE__DOMAIN: "authentik.suitecoffee.mateosaldain.uy"
|
||||
AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS: "https://authentik.suitecoffee.mateosaldain.uy,https://suitecoffee.mateosaldain.uy,https://sso.suitecoffee.uy,https://suitecoffee.uy"
|
||||
AUTHENTIK_COOKIE__DOMAIN: sso.suitecoffee.uy
|
||||
networks:
|
||||
net:
|
||||
aliases: [authentik]
|
||||
aliases: [dev-authentik]
|
||||
volumes:
|
||||
- ./media:/media
|
||||
- ./custom-templates:/templates
|
||||
|
||||
authentik-worker:
|
||||
ak-worker:
|
||||
image: ghcr.io/goauthentik/server:latest
|
||||
command: worker
|
||||
depends_on:
|
||||
authentik-db:
|
||||
condition: service_started
|
||||
authentik-redis:
|
||||
condition: service_started
|
||||
environment:
|
||||
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
|
||||
AUTHENTIK_POSTGRESQL__HOST: authentik-db
|
||||
AUTHENTIK_POSTGRESQL__USER: authentik
|
||||
AUTHENTIK_POSTGRESQL__NAME: authentik
|
||||
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_DB_PASS}
|
||||
AUTHENTIK_REDIS__HOST: authentik-redis
|
||||
AUTHENTIK_POSTGRESQL__HOST: ak-db
|
||||
AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}
|
||||
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
|
||||
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
|
||||
AUTHENTIK_REDIS__HOST: ak-redis
|
||||
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
|
||||
|
||||
AUTHENTIK_HTTP__TRUSTED_PROXY__CIDRS: "0.0.0.0/0,::/0"
|
||||
AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS: "https://authentik.suitecoffee.mateosaldain.uy,https://suitecoffee.mateosaldain.uy"
|
||||
AUTHENTIK_COOKIE__DOMAIN: "authentik.suitecoffee.mateosaldain.uy"
|
||||
AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS: "https://authentik.suitecoffee.mateosaldain.uy,https://suitecoffee.mateosaldain.uy,https://sso.suitecoffee.uy,https://suitecoffee.uy"
|
||||
AUTHENTIK_COOKIE__DOMAIN: "sso.suitecoffee.uy"
|
||||
networks:
|
||||
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:
|
||||
tenants-db:
|
||||
suitecoffee-db:
|
||||
authentik-db:
|
||||
ak-redis:
|
||||
|
||||
networks:
|
||||
net:
|
||||
|
||||
@ -9,7 +9,7 @@ services:
|
||||
context: ./services/app
|
||||
dockerfile: Dockerfile.production
|
||||
expose:
|
||||
- ${APP_LOCAL_PORT}
|
||||
- ${APP_PORT}
|
||||
volumes:
|
||||
- ./services/app:/app
|
||||
env_file:
|
||||
@ -26,7 +26,7 @@ services:
|
||||
context: ./services/auth
|
||||
dockerfile: Dockerfile.production
|
||||
expose:
|
||||
- ${AUTH_LOCAL_PORT}
|
||||
- ${AUTH_PORT}
|
||||
volumes:
|
||||
- ./services/auth:/app
|
||||
env_file:
|
||||
|
||||
46
compose.yaml
46
compose.yaml
@ -3,7 +3,6 @@
|
||||
name: ${COMPOSE_PROJECT_NAME:-suitecoffee}
|
||||
|
||||
services:
|
||||
|
||||
app:
|
||||
depends_on:
|
||||
db:
|
||||
@ -11,7 +10,7 @@ services:
|
||||
tenants:
|
||||
condition: service_healthy
|
||||
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
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
@ -22,10 +21,10 @@ services:
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
authentik:
|
||||
ak:
|
||||
condition: service_started
|
||||
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
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
@ -56,32 +55,45 @@ services:
|
||||
start_period: 10s
|
||||
restart: unless-stopped
|
||||
|
||||
authentik-db:
|
||||
ak-db:
|
||||
image: postgres:16-alpine
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U authentik -d authentik"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
interval: 30s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}
|
||||
timeout: 5s
|
||||
restart: unless-stopped
|
||||
|
||||
authentik-redis:
|
||||
ak-redis:
|
||||
image: redis:7-alpine
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
interval: 30s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- redis-cli ping | grep PONG
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
restart: unless-stopped
|
||||
|
||||
authentik:
|
||||
ak:
|
||||
image: ghcr.io/goauthentik/server:latest
|
||||
depends_on:
|
||||
authentik-db: { condition: service_healthy }
|
||||
authentik-redis: { condition: service_healthy }
|
||||
ak-db:
|
||||
condition: service_healthy
|
||||
ak-redis:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
authentik-worker:
|
||||
ak-worker:
|
||||
image: ghcr.io/goauthentik/server:latest
|
||||
depends_on:
|
||||
ak-db:
|
||||
condition: service_started
|
||||
ak-redis:
|
||||
condition: service_started
|
||||
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 =====
|
||||
NODE_ENV=development
|
||||
PORT=3030
|
||||
APP_LOCAL_PORT=3030
|
||||
|
||||
# ===== Session (usa el Redis del stack) =====
|
||||
# Para DEV podemos reutilizar el Redis de Authentik. En prod conviene uno separado.
|
||||
SESSION_SECRET=pon-una-clave-larga-y-unica
|
||||
REDIS_URL=redis://authentik-redis:6379
|
||||
SESSION_SECRET=Neon*Mammal*Boaster*Ludicrous*Fender8*Crablike
|
||||
REDIS_URL=redis://ak-redis:6379
|
||||
|
||||
# ===== DB principal (metadatos de SuiteCoffee) =====
|
||||
DB_HOST=dev-tenants
|
||||
# # ===== DB principal (metadatos de SuiteCoffee) =====
|
||||
DB_HOST=dev-db
|
||||
DB_NAME=dev-suitecoffee
|
||||
DB_PORT=5432
|
||||
DB_NAME=dev-postgres
|
||||
DB_USER=dev-user-postgres
|
||||
DB_PASS=dev-pass-postgres
|
||||
DB_USER=dev-user-suitecoffee
|
||||
DB_PASS=dev-pass-suitecoffee
|
||||
|
||||
# ===== DB tenants (Tenants de SuiteCoffee) =====
|
||||
# # ===== DB tenants (Tenants de SuiteCoffee) =====
|
||||
TENANTS_HOST=dev-tenants
|
||||
TENANTS_DB=dev-postgres
|
||||
TENANTS_PORT=5432
|
||||
TENANTS_USER=dev-user-postgres
|
||||
TENANTS_PASS=dev-pass-postgres
|
||||
TENANTS_PORT=5432
|
||||
|
||||
# ===== (Opcional) Colores UI, si alguna vista los lee =====
|
||||
COL_PRI=452D19 # Marrón oscuro
|
||||
COL_SEC=D7A666 # Crema / Café
|
||||
COL_BG=FFA500 # Naranja
|
||||
|
||||
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 =====
|
||||
NODE_ENV=development
|
||||
PORT=4040
|
||||
AUTH_LOCAL_PORT=4040 # coincide con 'expose' del servicio auth
|
||||
|
||||
# ===== Session (usa el Redis del stack) =====
|
||||
# Para DEV podemos reutilizar el Redis de Authentik. En prod conviene uno separado.
|
||||
SESSION_SECRET=pon-una-clave-larga-y-unica
|
||||
REDIS_URL=redis://authentik-redis:6379
|
||||
SESSION_SECRET=Neon*Mammal*Boaster*Ludicrous*Fender8*Crablike
|
||||
REDIS_URL=redis://ak-redis:6379
|
||||
|
||||
# ===== DB principal (metadatos de SuiteCoffee) =====
|
||||
# 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_PORT=5432
|
||||
|
||||
TENANT_INIT_SQL=/home/mateo/SuiteCoffee/services/auth/src/db/initTenant.sql
|
||||
# TENANT_INIT_SQL=~/SuiteCoffee/services/app/src/db/01_init.sql
|
||||
TENANT_INIT_SQL=/app/src/db/initTenant.sql
|
||||
|
||||
# ===== (Opcional) Colores UI, si alguna vista los lee =====
|
||||
COL_PRI=452D19 # Marrón oscuro
|
||||
@ -33,7 +31,7 @@ COL_BG=FFA500 # Naranja
|
||||
|
||||
# ===== Authentik — Admin API (server-to-server dentro de la red) =====
|
||||
# Usa el alias de red del servicio 'authentik' y su puerto interno 9000
|
||||
AUTHENTIK_BASE_URL=http://authentik:9000
|
||||
AUTHENTIK_BASE_URL=http://dev-authentik:9000
|
||||
AUTHENTIK_TOKEN=eE3bFTLd4Rpt3ZkcidTC1EppDYMIr023ev3SXt4ImHynOfAGRVtAZVBXSNxj
|
||||
AUTHENTIK_DEFAULT_GROUP_NAME=suitecoffee-users
|
||||
|
||||
@ -41,13 +39,15 @@ AUTHENTIK_DEFAULT_GROUP_NAME=suitecoffee-users
|
||||
# DEV (todo dentro de la red de Docker):
|
||||
# - El auth service redirige al navegador a este issuer. Si NO tenés reverse proxy hacia Authentik,
|
||||
# esta URL interna NO será accesible desde el navegador del host. En ese caso, ver nota más abajo.
|
||||
OIDC_ISSUER=https://authentik.suitecoffee.mateosaldain.uy/application/o/suitecoffee/
|
||||
OIDC_CLIENT_ID=ydnp9s9I7G4p9Pwt5OsNlcpk1VKB9auN7AxqqNjC
|
||||
OIDC_CLIENT_SECRET=yqdI00kYMeQF8VdmhwN5QWUzPLUzRBYeeAH193FynuVD19mo1nBRf5c5IRojzPrxDS0Hk33guUwHFzaj8vjTbTRetwK528uNJ6BfrYGUN2vzxgdMHFLQOHSTR0gR1LtG
|
||||
# OIDC_ISSUER=https://authentik.suitecoffee.mateosaldain.uy/application/o/suitecoffee/
|
||||
OIDC_ISSUER=https://sso.suitecoffee.uy/application/o/suitecoffee/
|
||||
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)
|
||||
# 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:
|
||||
# - 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
|
||||
// ------------------------------------------------------------
|
||||
// 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)
|
||||
// - 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
|
||||
|
||||
/**
|
||||
* Lee configuración desde process.env en cada llamada (para evitar problemas
|
||||
* de orden de imports con dotenv). No falla en import-time.
|
||||
* @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]
|
||||
*/
|
||||
|
||||
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 || '';
|
||||
if (!BASE) throw new Error('AK_CONFIG: Falta AUTHENTIK_BASE_URL');
|
||||
if (!TOKEN) throw new Error('AK_CONFIG: Falta AUTHENTIK_TOKEN');
|
||||
if (!BASE) throw new Error('[AK_CONFIG] Falta variable AUTHENTIK_BASE_URL');
|
||||
if (!TOKEN) throw new Error('[AK_CONFIG] Falta variable AUTHENTIK_TOKEN');
|
||||
return { BASE, TOKEN };
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Utilidades
|
||||
// ------------------------------------------------------------
|
||||
|
||||
// Espera
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Helpers de sincronización
|
||||
// ------------------------------------------------------------
|
||||
|
||||
// -- util GET contra la API admin (ajusta si ya tenés un helper igual)
|
||||
async function akGET(path) {
|
||||
const base = (process.env.AUTHENTIK_BASE_URL || '').replace(/\/+$/, '');
|
||||
const url = `${base}${path}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${process.env.AUTHENTIK_TOKEN}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`AK GET ${path} -> ${res.status}: ${body}`);
|
||||
}
|
||||
return res.json();
|
||||
export function createAkClient(cfg = getConfig()) {
|
||||
return {
|
||||
request: (method, path, opts = {}) => request(method, path, opts, cfg),
|
||||
akGET: (path, opts) => request("GET", path, opts, cfg),
|
||||
akPOST: (path, opts) => request("POST", path, opts, cfg),
|
||||
akPUT: (path, opts) => request("PUT", path, opts, cfg),
|
||||
akPATCH: (path, opts) => request("PATCH", path, opts, cfg),
|
||||
akDELETE:(path, opts) => request("DELETE", path, opts, cfg),
|
||||
};
|
||||
}
|
||||
|
||||
// -- listar grupos con búsqueda por nombre/slug
|
||||
export async function akListGroups(search = '') {
|
||||
const q = search ? `?search=${encodeURIComponent(search)}` : '';
|
||||
const data = await akGET(`/api/v3/core/groups/${q}`);
|
||||
// algunas versiones devuelven {results:[]}, otras un array directo
|
||||
return Array.isArray(data) ? data : (data.results || []);
|
||||
// Listar grupos con búsqueda por nombre/slug
|
||||
export async function akListGroups(search = "") {
|
||||
const { akGET } = createAkClient();
|
||||
const term = String(search ?? "").trim();
|
||||
|
||||
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 = {}) {
|
||||
// PATCH del usuario para asegurar attributes.tenant_uuid
|
||||
return akRequest('patch', `/api/v3/core/users/${userPk}/`, {
|
||||
data: { attributes: partialAttrs },
|
||||
|
||||
const id = String(userPk ?? "").trim();
|
||||
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) {
|
||||
const groupName = `tenant_${tenantHex}`;
|
||||
const { akGET, akPOST } = createAkClient();
|
||||
|
||||
// buscar por nombre
|
||||
const data = await akRequest('get', '/api/v3/core/groups/', { params: { name: groupName }});
|
||||
const g = (data?.results || [])[0];
|
||||
if (g) return g.pk;
|
||||
const hex = String(tenantHex ?? "").trim();
|
||||
if (!hex) throw new TypeError("akEnsureGroupForTenant: `tenantHex` is required");
|
||||
|
||||
// crear si no existe
|
||||
const created = await akRequest('post', '/api/v3/core/groups/', {
|
||||
data: { name: groupName, attributes: { tenant_uuid: tenantHex } },
|
||||
});
|
||||
return created.pk;
|
||||
const groupName = `tenant_${hex}`;
|
||||
|
||||
// 1) Buscar existente (normaliza {results:[]}/[])
|
||||
const data = await akGET("/core/groups/", { qs: { search: groupName } });
|
||||
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) {
|
||||
// Endpoint de membership (en versiones recientes, POST users/<pk>/groups/)
|
||||
return akRequest('post', `/api/v3/core/users/${userPk}/groups/`, { data: { group: groupPk } });
|
||||
const { akPOST } = createAkClient();
|
||||
|
||||
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.
|
||||
* @param {('GET'|'POST'|'PUT'|'PATCH'|'DELETE')} method
|
||||
* @param {string} path - Ruta a partir de /api/v3 (por ej. "/core/users/")
|
||||
* @param {{qs?:Record<string,string|number|boolean>, body?:any, timeoutMs?:number, retries?:number}} [opts]
|
||||
* @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>}
|
||||
*/
|
||||
async function request(method, path, opts = {}) {
|
||||
const { BASE, TOKEN } = getConfig();
|
||||
|
||||
export async function request(method, path, opts = {}, cfg) {
|
||||
const { BASE, TOKEN } = cfg ?? getConfig();
|
||||
const {
|
||||
qs = undefined,
|
||||
body = undefined,
|
||||
timeoutMs = 10000,
|
||||
qs,
|
||||
body,
|
||||
timeoutMs = 10_000,
|
||||
retries = 0,
|
||||
headers = {},
|
||||
} = opts;
|
||||
|
||||
const url = new URL(`${BASE}/api/v3${path}`);
|
||||
if (qs) Object.entries(qs).forEach(([k, v]) => url.searchParams.set(k, String(v)));
|
||||
// Construcción segura de URL + QS
|
||||
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;
|
||||
for (let attempt = 1; attempt <= Math.max(1, retries + 1); attempt++) {
|
||||
const controller = new AbortController();
|
||||
const t = setTimeout(() => controller.abort(new Error('AK_TIMEOUT')), timeoutMs);
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(new Error("AK_TIMEOUT")), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
const init = {
|
||||
method,
|
||||
signal: controller.signal,
|
||||
signal: ctrl.signal,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
Authorization: `Bearer ${TOKEN}`,
|
||||
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);
|
||||
|
||||
if (res.status === 204) return null; // sin contenido
|
||||
|
||||
// intenta parsear JSON; si no es JSON, devuelve texto
|
||||
const ctype = res.headers.get('content-type') || '';
|
||||
const payload = ctype.includes('application/json') ? await res.json().catch(() => ({})) : await res.text();
|
||||
if (res.status === 204 || res.status === 205) return null;
|
||||
const ctype = res.headers.get("content-type") || "";
|
||||
const payload = /\bapplication\/json\b/i.test(ctype) ? await res.json().catch(() => ({})) : await res.text();
|
||||
|
||||
if (!res.ok) {
|
||||
const detail = typeof payload === 'string' ? payload : payload?.detail || payload?.error || JSON.stringify(payload);
|
||||
const err = new Error(`AK ${method} ${url.pathname} → HTTP ${res.status}: ${detail}`);
|
||||
const detail = typeof payload === "string" ? payload : payload?.detail || payload?.error || JSON.stringify(payload);
|
||||
const err = new Error(`AK ${method} ${url.pathname}${url.search} → ${res.status}: ${detail}`);
|
||||
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;
|
||||
}
|
||||
|
||||
@ -128,11 +246,10 @@ async function request(method, path, opts = {}) {
|
||||
} catch (e) {
|
||||
clearTimeout(t);
|
||||
lastErr = e;
|
||||
// Reintentos sólo en ECONNREFUSED/timeout/5xx
|
||||
const msg = String(e?.message || e);
|
||||
const retriable = msg.includes('ECONNREFUSED') || msg.includes('AK_TIMEOUT') || /\b5\d\d\b/.test(e?.status?.toString?.() || '');
|
||||
if (!retriable || attempt > retries) throw e;
|
||||
await sleep(500 * attempt); // backoff lineal suave
|
||||
const msg = String(e?.message || "");
|
||||
const retriable = msg.includes("AK_TIMEOUT") || msg.includes("ECONNREFUSED") || msg.includes("fetch failed");
|
||||
if (!retriable || attempt >= maxAttempts) throw e;
|
||||
await new Promise(r => setTimeout(r, 500 * 2 ** (attempt - 1)));
|
||||
}
|
||||
}
|
||||
throw lastErr;
|
||||
@ -142,124 +259,178 @@ async function request(method, path, opts = {}) {
|
||||
// Funciones públicas
|
||||
// ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Busca un usuario por email en Authentik (case-insensitive) usando ?search=
|
||||
* Devuelve el usuario exacto o null si no existe.
|
||||
*/
|
||||
export async function akFindUserByEmail(email) {
|
||||
if (!email) throw new Error('akFindUserByEmail: email requerido');
|
||||
const data = await request('GET', '/core/users/', { qs: { search: email, page_size: 50 }, retries: 3 });
|
||||
const list = Array.isArray(data?.results) ? data.results : [];
|
||||
const lower = String(email).toLowerCase();
|
||||
return list.find((u) => (u.email || '').toLowerCase() === lower) || null;
|
||||
const { akGET } = createAkClient();
|
||||
|
||||
const needle = String(email ?? "").trim().toLowerCase();
|
||||
if (!needle) throw new TypeError("akFindUserByEmail: `email` is required");
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea un usuario en Authentik con atributos de tenant y opcionalmente lo
|
||||
* agrega a un grupo existente.
|
||||
* @param {{email:string, displayName?:string, tenantUuid?:string, addToGroupId?: number|string, isActive?: boolean}} p
|
||||
* @returns {Promise<any>} el objeto usuario creado
|
||||
*/
|
||||
export async function akCreateUser(p) {
|
||||
const email = p?.email;
|
||||
if (!email) throw new Error('akCreateUser: email requerido');
|
||||
const name = p?.displayName || email;
|
||||
const tenantUuid = (p?.tenantUuid || '').replace(/-/g, '');
|
||||
const isActive = p?.isActive ?? true;
|
||||
export async function akCreateUser(p = {}) {
|
||||
const { akPOST } = createAkClient();
|
||||
|
||||
// 1) crear usuario
|
||||
const user = await request('POST', '/core/users/', {
|
||||
body: {
|
||||
username: email,
|
||||
name,
|
||||
email,
|
||||
is_active: isActive,
|
||||
attributes: tenantUuid ? { tenant_uuid: tenantUuid } : {},
|
||||
},
|
||||
retries: 3,
|
||||
});
|
||||
const email = String(p.email ?? "").trim().toLowerCase();
|
||||
if (!email) throw new TypeError("akCreateUser: `email` is required");
|
||||
|
||||
// 2) agregar a grupo (opcional)
|
||||
if (p?.addToGroupId) {
|
||||
const name = String(p.displayName ?? email).trim() || email;
|
||||
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 {
|
||||
await request('POST', `/core/users/${user.pk}/groups/`, { body: { group: p.addToGroupId }, retries: 2 });
|
||||
} catch (e) {
|
||||
// No rompas todo por el grupo; deja registro del error para que el caller decida.
|
||||
console.warn(`akCreateUser: no se pudo agregar al grupo ${p.addToGroupId}:`, e?.message || e);
|
||||
const userPk = encodeURIComponent(user.pk ?? user.id);
|
||||
await akPOST(`/core/users/${userPk}/groups/`, {
|
||||
body: { group: groupId },
|
||||
retries: 2,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`akCreateUser: could not add user ${user.pk ?? user.id} to group ${groupId}:`,
|
||||
err?.message || err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Establece/forza una contraseña a un usuario (si tu política lo permite).
|
||||
* @param {number|string} userPk
|
||||
* @param {string} password
|
||||
* @param {boolean} requireChange - si el usuario debe cambiarla al siguiente login
|
||||
*/
|
||||
export async function akSetPassword(userPk, password, requireChange = true) {
|
||||
if (!userPk) throw new Error('akSetPassword: userPk requerido');
|
||||
if (!password) throw new Error('akSetPassword: password requerida');
|
||||
const { akPOST } = createAkClient();
|
||||
|
||||
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 {
|
||||
await request('POST', `/core/users/${userPk}/set_password/`, {
|
||||
body: { password, require_change: !!requireChange },
|
||||
await akPOST(`/core/users/${encodeURIComponent(id)}/set_password/`, {
|
||||
body: { password: pwd, require_change: !!requireChange },
|
||||
retries: 1,
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
// Algunas instalaciones no permiten setear password por API (políticas). Propaga un error legible.
|
||||
const err = new Error(`akSetPassword: no se pudo establecer la contraseña: ${e?.message || e}`);
|
||||
const status = e?.status ? `HTTP ${e.status}: ` : "";
|
||||
const err = new Error(`akSetPassword: failed to set password (${status}${e?.message || e})`);
|
||||
err.cause = e;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper opcional para obtener grupos por nombre/slug si en el futuro lo necesitas
|
||||
* (no usado por index.js; se deja por conveniencia).
|
||||
*/
|
||||
|
||||
export async function akResolveGroupIdByName(name) {
|
||||
const data = await akListGroups(name);
|
||||
const lower = String(name || '').toLowerCase();
|
||||
const found = data.find(g =>
|
||||
String(g.name || '').toLowerCase() === lower ||
|
||||
String(g.slug || '').toLowerCase() === lower
|
||||
);
|
||||
return found?.pk ?? null;
|
||||
const term = String(name ?? "").trim();
|
||||
if (!term) throw new TypeError("akResolveGroupIdByName: `name` is required");
|
||||
|
||||
const needle = term.toLowerCase();
|
||||
const groups = await akListGroups(term);
|
||||
if (!Array.isArray(groups) || groups.length === 0) return 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 } = {}) {
|
||||
// si te pasan pk/id directo, devolvelo
|
||||
if (pk != null) return Number(pk);
|
||||
if (id != null) return Number(id);
|
||||
const toPk = (v) => {
|
||||
if (v == null || v === "") return null;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : String(v);
|
||||
};
|
||||
|
||||
// por UUID (devuelve objeto con pk)
|
||||
if (uuid) {
|
||||
// 1) Direct pk/id
|
||||
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 {
|
||||
const g = await akGET(`/api/v3/core/groups/${encodeURIComponent(uuid)}/`);
|
||||
if (g?.pk != null) return Number(g.pk);
|
||||
} catch (e) {
|
||||
// sigue intentando por nombre/slug
|
||||
}
|
||||
const g = await akGET(`/core/groups/${encodeURIComponent(uuidStr)}/`, { retries: 1 });
|
||||
const fromDetail = toPk(g?.pk ?? g?.id);
|
||||
if (fromDetail != null) return fromDetail;
|
||||
} catch { /* continue with name/slug */ }
|
||||
}
|
||||
|
||||
// por nombre/slug
|
||||
if (name || slug) {
|
||||
const needle = (name || slug);
|
||||
const list = await akListGroups(needle);
|
||||
const lower = String(needle || '').toLowerCase();
|
||||
const found = list.find(g =>
|
||||
String(g.name || '').toLowerCase() === lower ||
|
||||
String(g.slug || '').toLowerCase() === lower
|
||||
);
|
||||
if (found?.pk != null) return Number(found.pk);
|
||||
// 3) By exact name/slug
|
||||
const needle = String(name ?? slug ?? "").trim();
|
||||
if (needle) {
|
||||
const lower = needle.toLowerCase();
|
||||
const list = await akListGroups(needle); // expects [] or {results:[]}, handled in akListGroups
|
||||
const found =
|
||||
list.find(g => String(g?.slug ?? "").toLowerCase() === lower) ||
|
||||
list.find(g => String(g?.name ?? "").toLowerCase() === lower);
|
||||
const fromList = toPk(found?.pk ?? found?.id);
|
||||
if (fromList != null) return fromList;
|
||||
}
|
||||
|
||||
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)
|
||||
// - Vistas EJS (login)
|
||||
// - 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';
|
||||
@ -13,23 +13,28 @@ import chalk from 'chalk';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
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 { Pool } from 'pg';
|
||||
import session from 'express-session';
|
||||
import { createClient as createRedisClient } from 'redis';
|
||||
import * as connectRedis from 'connect-redis';
|
||||
import { createClient } from 'redis';
|
||||
import expressLayouts from 'express-ejs-layouts';
|
||||
import { Issuer, generators } from 'openid-client';
|
||||
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
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
const SESSION_COOKIE_NAME = 'sc.sid';
|
||||
|
||||
// Normaliza UUID (acepta con/sin guiones) → "hex" sin guiones
|
||||
const cleanUuid = (u) => (u ? String(u).toLowerCase().replace(/[^0-9a-f]/g, '') : '');
|
||||
|
||||
@ -37,14 +42,6 @@ const cleanUuid = (u) => (u ? String(u).toLowerCase().replace(/[^0-9a-f]/g, '')
|
||||
const schemaNameFor = (uuidHex) => `schema_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)
|
||||
// Identificador SQL (schema, role, table, …)
|
||||
const qi = (ident) => `"${String(ident).replace(/"/g, '""')}"`;
|
||||
@ -282,13 +279,13 @@ let _cachedInitSql = null;
|
||||
async function loadInitSql() {
|
||||
if (_cachedInitSql !== null) return _cachedInitSql;
|
||||
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, '..', 'src', 'db', 'initTenant.sql'),
|
||||
].filter(Boolean);
|
||||
for (const p of candidates) {
|
||||
try {
|
||||
await fs.promises.access(p, fs.constants.R_OK);
|
||||
await access(p, fsConstants.R_OK);
|
||||
const txt = await readFile(p, 'utf8');
|
||||
_cachedInitSql = String(txt || '');
|
||||
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 __dirname = path.dirname(__filename);
|
||||
const RedisStore = connectRedis.default || connectRedis.RedisStore;
|
||||
|
||||
function requiredEnv(keys) {
|
||||
const missing = keys.filter((k) => !process.env[k]);
|
||||
@ -375,7 +371,7 @@ function genTempPassword(len = 12) {
|
||||
// Configuración 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(express.json());
|
||||
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('view engine', 'ejs');
|
||||
|
||||
|
||||
// Archivos estáticos opcionales (ajusta si tu estructura difiere)
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
app.use('/pages', express.static(path.join(__dirname, 'pages')));
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Sesión (Redis)
|
||||
// Sesión (Redis) — misma cookie que APP
|
||||
// -----------------------------------------------------------------------------
|
||||
requiredEnv(['SESSION_SECRET', 'REDIS_URL']);
|
||||
const redis = createRedisClient({ url: process.env.REDIS_URL || 'redis://sessions-redis:6379' });
|
||||
await redis.connect().catch((e) => onFatal(e, 'No se pudo conectar a Redis (sesiones)'));
|
||||
const SESSION_COOKIE_NAME = process.env.SESSION_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";
|
||||
|
||||
app.use(
|
||||
session({
|
||||
name: SESSION_COOKIE_NAME,
|
||||
store: new RedisStore({ client: redis, prefix: 'sess:' }),
|
||||
secret: process.env.SESSION_SECRET || 'change-me',
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
},
|
||||
})
|
||||
);
|
||||
// 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/*
|
||||
},
|
||||
}));
|
||||
|
||||
// Exponer usuario a las vistas (no tocar req.session)
|
||||
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
|
||||
// -----------------------------------------------------------------------------
|
||||
@ -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.');
|
||||
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
|
||||
const {
|
||||
email,
|
||||
@ -830,7 +948,7 @@ app.post('/auth/password/set', async (req, res, next) => {
|
||||
|
||||
|
||||
// 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 || {};
|
||||
// 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
|
||||
// -----------------------------------------------------------------------------
|
||||
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
|
||||
@ -916,6 +1034,8 @@ const PORT = Number(process.env.PORT || 4040);
|
||||
console.log(`Activando entorno de -> ${env === 'PRODUCTION' ? chalk.red('PRODUCTION') : chalk.green('DEVELOPMENT')}`);
|
||||
await verificarConexion();
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Servidor de autenticación de SuiteCoffee corriendo en ${chalk.yellow(`http://localhost:${PORT}`)}`);
|
||||
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