Carga completa

This commit is contained in:
2025-09-06 11:19:42 +00:00
parent 237a5427dd
commit 5d078f3932
17 changed files with 3278 additions and 298 deletions
+29
View File
@@ -0,0 +1,29 @@
# ===== Runtime =====
NODE_ENV=development
PORT=3030
APP_LOCAL_PORT=3030
# ===== Session (usa el Redis del stack) =====
# Para DEV podemos reutilizar el Redis de Authentik. En prod conviene uno separado.
SESSION_SECRET=pon-una-clave-larga-y-unica
REDIS_URL=redis://authentik-redis:6379
# ===== DB principal (metadatos de SuiteCoffee) =====
DB_HOST=dev-tenants
DB_PORT=5432
DB_NAME=dev-postgres
DB_USER=dev-user-postgres
DB_PASS=dev-pass-postgres
# ===== DB tenants (Tenants de SuiteCoffee) =====
TENANTS_HOST=dev-tenants
TENANTS_DB=dev-postgres
TENANTS_USER=dev-user-postgres
TENANTS_PASS=dev-pass-postgres
TENANTS_PORT=5432
# ===== (Opcional) Colores UI, si alguna vista los lee =====
COL_PRI=452D19 # Marrón oscuro
COL_SEC=D7A666 # Crema / Café
COL_BG=FFA500 # Naranja
+22
View File
@@ -0,0 +1,22 @@
NODE_ENV=production # Entorno de desarrollo
PORT=3000 # Variables del servicio -> suitecoffee-app
# Variables del servicio -> suitecoffee-db de suitecoffee-app
DB_HOST=prod-tenants
# Nombre de la base de datos
DB_NAME=postgres
# Usuario y contraseña
DB_USER=postgres
DB_PASS=postgres
# Puertos del servicio de db
DB_LOCAL_PORT=5432
DB_DOCKER_PORT=5432
# Colores personalizados
COL_PRI=452D19 # Marrón oscuro
COL_SEC=D7A666 # Crema / Café
COL_BG=FFA500 # Naranja
+62
View File
@@ -19,6 +19,7 @@
"express-ejs-layouts": "^2.5.1",
"express-session": "^1.18.2",
"ioredis": "^5.7.0",
"morgan": "^1.10.1",
"pg": "^8.16.3",
"pg-format": "^1.0.4",
"redis": "^5.8.2",
@@ -131,6 +132,24 @@
"version": "1.0.2",
"license": "MIT"
},
"node_modules/basic-auth": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz",
"integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.1.2"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/basic-auth/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
@@ -902,6 +921,49 @@
"node": "*"
}
},
"node_modules/morgan": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz",
"integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==",
"license": "MIT",
"dependencies": {
"basic-auth": "~2.0.1",
"debug": "2.6.9",
"depd": "~2.0.0",
"on-finished": "~2.3.0",
"on-headers": "~1.1.0"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/morgan/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/morgan/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/morgan/node_modules/on-finished": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
"integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/ms": {
"version": "2.1.3",
"license": "MIT"
+1
View File
@@ -25,6 +25,7 @@
"express-ejs-layouts": "^2.5.1",
"express-session": "^1.18.2",
"ioredis": "^5.7.0",
"morgan": "^1.10.1",
"pg": "^8.16.3",
"pg-format": "^1.0.4",
"redis": "^5.8.2",
+197 -154
View File
@@ -1,216 +1,259 @@
// services/app/src/index.js
// -----------------------------------------------------------------------------
// SuiteCoffee — Servicio APP (Express)
// - Carga de entorno robusta (compatible con Docker Compose env_file)
// - Sesiones compartidas via Redis (mismo cookie que AUTH)
// - Middlewares: CORS, JSON, estáticos, EJS opcional
// - Multitenant por esquema: requireAuth + withTenant + done
// - Montaje automático de ENDPOINTS LEGACY sin perder nada
// - Healthcheck, 404 y manejador de errores
// - Conserva y expone pools para que tus endpoints los usen
// -----------------------------------------------------------------------------
// ------------------------------------------------------------
// SuiteCoffee — Servicio APP (UI + APIs negocio)
// - ESM (Node >=18)
// - Vistas EJS en ./views (dashboard.ejs, comandas.ejs, etc.)
// - Sesión compartida con AUTH (cookie: sc.sid, Redis)
// - Monta routes.legacy.js con requireAuth + withTenant
// ------------------------------------------------------------
// === 0) CARGA DE ENTORNO ROBUSTA (no pisa variables ya definidas por Compose)
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import dotenv from 'dotenv';
import chalk from 'chalk';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const ENV = (process.env.NODE_ENV || 'development').toLowerCase();
const envMap = { development: '.env.development', stage: '.env.test', production: '.env.production' };
const envFile = envMap[ENV] || '.env.development';
const candidates = [
path.resolve(process.cwd(), envFile), // /app/.env.development (dentro del contenedor)
path.resolve(__dirname, '..', envFile), // por si queda un nivel arriba
path.resolve(__dirname, envFile), // por si lo ponen junto al src
];
const found = candidates.find((p) => fs.existsSync(p));
if (found) {
dotenv.config({ path: found, override: false });
console.log(`Activando entorno de -> ${ENV.toUpperCase()} ${chalk.gray(`(${found})`)}`);
} else {
console.log(`Activando entorno de -> ${ENV.toUpperCase()} (sin archivo .env; usando variables del proceso)`);
}
// === 1) IMPORTS PRINCIPALES
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import expressLayouts from 'express-ejs-layouts';
import morgan from 'morgan';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import session from 'express-session';
import expressLayouts from 'express-ejs-layouts';
import { createClient as createRedisClient } from 'redis';
import * as connectRedis from 'connect-redis';
import { Pool } from 'pg';
import bcrypt from 'bcrypt'; // <- lo conservamos si ya lo usabas
import crypto from 'node:crypto'; // <- idem
// Tolerante a cambios de export en connect-redis
// -----------------------------------------------------------------------------
// Utilidades base
// -----------------------------------------------------------------------------
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const RedisStore = connectRedis.default || connectRedis.RedisStore;
// === 2) APP y CONFIG BASICA
const qi = (ident) => `"${String(ident).replace(/"/g, '""')}"`;
const CLEAN_HEX = (s) => (String(s || '').toLowerCase().replace(/[^0-9a-f]/g, '') || null);
const REQUIRED = (...keys) => {
const miss = keys.filter((k) => !process.env[k]);
if (miss.length) {
console.warn(`⚠ Faltan variables de entorno: ${miss.join(', ')}`);
}
};
// -----------------------------------------------------------------------------
// Validación de entorno mínimo (ajusta nombres si difieren)
// -----------------------------------------------------------------------------
REQUIRED(
// Sesión
'SESSION_SECRET', 'REDIS_URL',
// DB principal
'DB_HOST', 'DB_NAME', 'DB_USER', 'DB_PASS',
// DB de tenants
'TENANTS_HOST', 'TENANTS_DB', 'TENANTS_USER', 'TENANTS_PASS'
);
// -----------------------------------------------------------------------------
// Pools de PostgreSQL
// -----------------------------------------------------------------------------
const mainPool = new Pool({
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT || 5432),
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASS,
max: 10,
idleTimeoutMillis: 30_000,
});
const tenantsPool = new Pool({
host: process.env.TENANTS_HOST,
port: Number(process.env.TENANTS_PORT || 5432),
database: process.env.TENANTS_DB,
user: process.env.TENANTS_USER,
password: process.env.TENANTS_PASS,
max: 10,
idleTimeoutMillis: 30_000,
});
// Autotest (no rompe si falla; sólo loguea)
(async () => {
try {
const c = await mainPool.connect();
const r = await c.query('SELECT NOW() now');
console.log('[APP] DB principal OK. Hora:', r.rows[0].now);
c.release();
} catch (e) {
console.error('[APP] Error al conectar DB principal:', e.message);
}
try {
const c = await tenantsPool.connect();
const r = await c.query('SELECT NOW() now');
console.log('[APP] DB tenants OK. Hora:', r.rows[0].now);
c.release();
} catch (e) {
console.error('[APP] Error al conectar DB tenants:', e.message);
}
})();
// -----------------------------------------------------------------------------
// Express + EJS
// -----------------------------------------------------------------------------
const app = express();
app.set('trust proxy', 1);
app.use(cors({ origin: true, credentials: true }));
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true }));
// Vistas EJS (si no usás vistas, puedes dejarlo; no rompe)
// Views EJS en ./views
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
app.use(expressLayouts);
app.set('layout', 'layout');
app.set("layout", "layouts/main");
// Estáticos opcionales (ajusta si tu estructura difiere)
app.use(express.static(path.join(__dirname, 'public')));
// Estáticos (si tenés carpeta public/, assets, etc.)
app.use('/public', express.static(path.join(__dirname, 'public')));
// === 3) SESIONES COMPARTIDAS (mismo cookie que AUTH)
const SESSION_SECRET = process.env.SESSION_SECRET || 'change-me-in-dev';
const REDIS_URL = process.env.REDIS_URL || 'redis://authentik-redis:6379';
// Middlewares básicos
app.use(morgan('dev'));
app.use(cors({ origin: true, credentials: true }));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
const redis = createRedisClient({ url: REDIS_URL });
await redis.connect().catch((e) => {
console.warn('⚠ No se pudo conectar a Redis de sesiones:', e?.message || e);
// ----------------------------------------------------------
// Middleware para datos globales
// ----------------------------------------------------------
app.use((req, res, next) => {
res.locals.currentPath = req.path;
res.locals.pageTitle = "SuiteCoffee";
res.locals.pageId = "";
next();
});
// -----------------------------------------------------------------------------
// Sesión (Redis) — misma cookie que AUTH
// -----------------------------------------------------------------------------
const SESSION_COOKIE_NAME = 'sc.sid';
const redis = createRedisClient({ url: process.env.REDIS_URL });
await redis.connect().catch((e) => console.error('[APP] Redis session error:', e.message));
app.use(
session({
name: 'sc.sid', // <- igual que en AUTH
name: SESSION_COOKIE_NAME,
store: new RedisStore({ client: redis, prefix: 'sess:' }),
secret: SESSION_SECRET,
secret: process.env.SESSION_SECRET || 'change-me',
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
// domain: 'suitecoffee.mateosaldain.uy', // (opcional) si lo necesitás
},
})
);
// Exponer el usuario a las vistas (no tocar req.session)
// Exponer usuario a las vistas
app.use((req, res, next) => {
res.locals.user = req.session?.user || null;
next();
});
// === 4) POOLS A BASES DE DATOS ===
// 4.1) Base principal (si la usás en APP). Conservamos variables usadas en el repo.
const DB = {
host: process.env.DB_HOST || 'dev-db',
port: Number(process.env.DB_PORT || 5432),
user: process.env.DB_USER || 'dev-user-suitecoffee',
password: process.env.DB_PASS || 'dev-pass-suitecoffee',
database: process.env.DB_NAME || 'dev-suitecoffee',
};
export const mainPool = new Pool({ ...DB, max: 10, idleTimeoutMillis: 30_000 });
async function verificarConexion() {
try {
const c = await mainPool.connect();
const { rows } = await c.query('SELECT NOW() AS now');
console.log(`DB principal OK @ ${rows[0].now}`);
c.release();
} catch (e) {
console.warn('⚠ No se pudo verificar DB principal:', e?.message || e);
// -----------------------------------------------------------------------------
// Middlewares de Auth/Tenant para routes.legacy.js
// -----------------------------------------------------------------------------
function requireAuth(req, res, next) {
if (!req.session?.user) {
// Si querés devolver 401 en lugar de redirigir, cambia esta línea
return res.redirect('/auth/login');
}
next();
}
// 4.2) Base multi-tenant (un solo DB con esquemas por tenant)
const TENANTS = {
host: process.env.TENANTS_HOST || 'dev-tenants',
port: Number(process.env.TENANTS_PORT || 5432),
user: process.env.TENANTS_USER || 'postgres',
password: process.env.TENANTS_PASS || 'postgres',
database: process.env.TENANTS_DB || 'dev-postgres',
};
export const tenantsPool = new Pool({ ...TENANTS, max: 20, idleTimeoutMillis: 30_000 });
// === 5) MIDDLEWARES DE AUTENTICACIÓN Y TENANT ===
export function requireAuth(req, res, next) {
if (req.session?.user) return next();
// Fallback DEV: permitir si el front envía explícitamente el tenant (para pruebas)
if (req.get('x-tenant-uuid')) return next();
return res.status(401).json({ error: 'no-auth' });
}
function getTenantUuid(req) {
const h = req.get('x-tenant-uuid');
if (h) return String(h).replace(/-/g, '');
const s = req.session?.user?.tenant_uuid;
if (s) return String(s).replace(/-/g, '');
throw new Error('Tenant no especificado');
}
export async function withTenant(req, res, next) {
const client = await tenantsPool.connect();
// Abre un client al DB de tenants y fija search_path al esquema del usuario
async function withTenant(req, res, next) {
try {
await client.query('BEGIN');
const uuid = getTenantUuid(req);
const schema = `schema_tenant_${uuid}`;
const hex = CLEAN_HEX(req.session?.user?.tenant_uuid);
if (!hex) return res.status(400).json({ error: 'tenant-missing' });
// Si creaste la función en DB: SELECT public.f_set_search_path($1)
// await client.query('SELECT public.f_set_search_path($1)', [schema]);
await client.query(`SET LOCAL search_path TO ${schema.replace(/"/g, '')}`);
const schema = `schema_tenant_${hex}`;
const client = await tenantsPool.connect();
// Fijar search_path para que las consultas apunten al esquema del tenant
await client.query(`SET SESSION search_path TO ${qi(schema)}, public`);
// Hacemos el client accesible para los handlers de routes.legacy.js
req.pg = client;
req.pgSchema = schema;
// Liberar el client al finalizar la respuesta
const release = () => {
try { client.release(); } catch {}
};
res.on('finish', release);
res.on('close', release);
next();
} catch (e) {
try { await client.query('ROLLBACK'); } catch {}
client.release();
return res.status(400).json({ error: e.message });
next(e);
}
}
export async function done(req, res, next) {
try {
if (req.pg) await req.pg.query('COMMIT');
} catch (e) {
try { if (req.pg) await req.pg.query('ROLLBACK'); } catch {}
} finally {
if (req.pg) req.pg.release();
// No-op (compatibilidad con el archivo legacy si lo pasa al final)
function done(_req, _res, next) { return next && next(); }
// -----------------------------------------------------------------------------
// Home / Landing
// -----------------------------------------------------------------------------
app.get('/', (req, res) => {
if (req.session?.user) {
return res.redirect('/comandas'); // ya logueado → dashboard
}
next?.();
}
return res.render('login', { pageTitle: 'Iniciar sesión' });
});
// === 6) RUTAS BÁSICAS / HEALTH ===
app.get('/health', (_req, res) => res.status(200).json({ status: 'ok' }));
app.get('/api/health', (_req, res) => res.status(200).json({ status: 'ok' }));
// Página para definir contraseña (el form envía al servicio AUTH)
app.get('/set-password', (req, res) => {
const pp = req.session?.pendingPassword;
if (!pp) return req.session?.user ? res.redirect('/comandas') : res.redirect('/auth/login');
// === 7) MONTAJE AUTOMÁTICO DE ENDPOINTS LEGACY ===
// Para NO PERDER NADA de tu archivo original:
// 1) Crea services/app/src/routes.legacy.js y exporta por defecto una función:
// export default function mount(app, ctx) { /* pega aquí TODOS tus app.get/post/... */ }
// // ctx trae: { requireAuth, withTenant, done, mainPool, tenantsPool, express }
// 2) O exporta un Router en routes.legacy.js como: export const router = Router();
// 3) Este bloque intentará montarlo si existe.
try {
const legacy = await import('./routes.legacy.js');
if (legacy?.default) {
legacy.default(app, { requireAuth, withTenant, done, mainPool, tenantsPool, express });
console.log('✔ Endpoints legacy montados (función default)');
} else if (legacy?.router) {
app.use(legacy.router);
console.log('✔ Endpoints legacy montados (router)');
}
} catch {
console.log(' No se encontró routes.legacy.js; continúa sólo con las rutas nuevas');
}
res.type('html').send(`
<!doctype html><meta charset="utf-8">
<title>SuiteCoffee · Definir contraseña</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<div class="container py-5" style="max-width:520px;">
<h2 class="mb-4">Definir contraseña</h2>
<form method="post" action="/auth/password/set" class="vstack gap-3">
<input class="form-control" type="password" name="password" placeholder="Nueva contraseña" minlength="8" required>
<input class="form-control" type="password" name="password2" placeholder="Repetí la contraseña" minlength="8" required>
<button class="btn btn-primary" type="submit">Guardar y continuar</button>
<small class="text-muted">Luego te redirigiremos a iniciar sesión por SSO.</small>
</form>
</div>
`);
});
// -----------------------------------------------------------------------------
// Montar rutas legacy (render de EJS y APIs de negocio)
// -----------------------------------------------------------------------------
const legacy = await import('./routes.legacy.js');
legacy.default(app, {
requireAuth,
withTenant,
done,
mainPool,
tenantsPool,
express,
});
// -----------------------------------------------------------------------------
// Health + 404 + errores
// -----------------------------------------------------------------------------
app.get('/health', (_req, res) => res.status(200).json({ status: 'ok', service: 'app' }));
// === 8) 404 + MANEJO DE ERRORES ===
app.use((req, res) => res.status(404).json({ error: 'not-found', path: req.originalUrl }));
app.use((err, _req, res, _next) => {
console.error(' Error APP:', err);
console.error('[APP] Error:', err);
if (res.headersSent) return;
res.status(500).json({ error: 'internal-error', detail: err?.message || String(err) });
});
// === 9) ARRANQUE ===
const PORT = Number(process.env.APP_LOCAL_PORT || process.env.PORT || 4000);
(async () => {
console.log(`Entorno -> ${ENV.toUpperCase()} | Puerto -> ${PORT}`);
await verificarConexion();
app.listen(PORT, () => console.log(`SuiteCoffee APP escuchando en ${chalk.yellow(`http://localhost:${PORT}`)}`));
})();
// -----------------------------------------------------------------------------
// Arranque
// -----------------------------------------------------------------------------
const PORT = Number(process.env.PORT || process.env.APP_LOCAL_PORT || 3030);
app.listen(PORT, () => {
console.log(`[APP] SuiteCoffee corriendo en http://localhost:${PORT}`);
});
+164
View 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 /api/users/register) -->
<form id="form-register" class="needs-validation" novalidate>
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" class="form-control" id="email" name="email" placeholder="tu@correo.com" required>
<div class="invalid-feedback">Ingresá un email válido.</div>
</div>
<div class="mb-3">
<label for="display_name" class="form-label">Nombre a mostrar</label>
<input type="text" class="form-control" id="display_name" name="display_name" placeholder="Ej.: Juan Pérez" required>
<div class="invalid-feedback">Ingresá tu nombre.</div>
</div>
<div class="mb-3">
<label for="tenant_uuid" class="form-label">Código de organización (tenant UUID)</label>
<input type="text" class="form-control" id="tenant_uuid" name="tenant_uuid" placeholder="Ej.: 4b8d0f6a-...">
<div class="form-hint">Si te invitaron a una organización existente, pegá aquí su UUID. Si sos el primero de tu empresa, dejalo vacío y el equipo te asignará uno.</div>
</div>
<div class="mb-3">
<label for="role" class="form-label">Rol</label>
<select id="role" name="role" class="form-select">
<option value="owner">Owner</option>
<option value="admin">Admin</option>
<option value="staff">Staff</option>
</select>
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-outline-dark">Crear cuenta</button>
</div>
</form>
<p class="text-center text-muted mt-3 mb-0" style="font-size:.9rem;">
Al continuar aceptás nuestros términos y políticas.
</p>
</div>
</div>
<p class="text-center text-secondary mt-3" style="font-size:.9rem;">
¿Ya tenés cuenta? <a href="/auth/login" class="link-dark">Iniciá sesión con SSO</a>
</p>
</div>
</div>
</div>
<script>
// Mostrar mensajes por querystring (?msg=... / ?error=...)
(function() {
const params = new URLSearchParams(location.search);
const el = document.getElementById('flash');
const msg = params.get('msg');
const err = params.get('error');
if (msg) {
el.innerHTML = `<div class="alert alert-success mb-0" role="alert">${decodeURIComponent(msg)}</div>`;
el.style.display = '';
} else if (err) {
el.innerHTML = `<div class="alert alert-danger mb-0" role="alert">${decodeURIComponent(err)}</div>`;
el.style.display = '';
}
})();
// Validación Bootstrap + envío del registro contra /api/users/register
(function() {
const form = document.getElementById('form-register');
form.addEventListener('submit', async function(e) {
e.preventDefault();
e.stopPropagation();
form.classList.add('was-validated');
if (!form.checkValidity()) return;
const btn = form.querySelector('button[type="submit"]');
btn.disabled = true; btn.innerText = 'Creando...';
try {
const payload = {
email: document.getElementById('email').value.trim(),
display_name: document.getElementById('display_name').value.trim(),
tenant_uuid: document.getElementById('tenant_uuid').value.trim() || undefined,
role: document.getElementById('role').value
};
const res = await fetch('/api/users/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
credentials: 'include'
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data?.error || data?.message || 'No se pudo registrar');
}
// Registro OK → redirigimos a login SSO
const redir = '/auth/login';
location.href = redir + '?msg=' + encodeURIComponent('Registro exitoso. Iniciá sesión con SSO.');
} catch (err) {
alert(err.message || String(err));
} finally {
btn.disabled = false; btn.innerText = 'Crear cuenta';
}
});
})();
</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
</body>
</html>