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:
+327
-156
@@ -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);
|
||||
+162
-42
@@ -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;
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user