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
436 lines
15 KiB
JavaScript
436 lines
15 KiB
JavaScript
// services/auth/src/ak.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]
|
|
*/
|
|
|
|
function getConfig() {
|
|
const BASE = (process.env.AUTHENTIK_BASE_URL || "").trim().replace(/\/+$/, "");
|
|
const TOKEN = process.env.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
|
|
// ------------------------------------------------------------
|
|
|
|
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 { 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 = {}) {
|
|
|
|
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 { akGET, akPOST } = createAkClient();
|
|
|
|
const hex = String(tenantHex ?? "").trim();
|
|
if (!hex) throw new TypeError("akEnsureGroupForTenant: `tenantHex` is required");
|
|
|
|
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) {
|
|
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;
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* 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>}
|
|
*/
|
|
|
|
export async function request(method, path, opts = {}, cfg) {
|
|
const { BASE, TOKEN } = cfg ?? getConfig();
|
|
const {
|
|
qs,
|
|
body,
|
|
timeoutMs = 10_000,
|
|
retries = 0,
|
|
headers = {},
|
|
} = opts;
|
|
|
|
// 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 <= maxAttempts; attempt++) {
|
|
const ctrl = new AbortController();
|
|
const t = setTimeout(() => ctrl.abort(new Error("AK_TIMEOUT")), timeoutMs);
|
|
try {
|
|
const init = {
|
|
method,
|
|
signal: ctrl.signal,
|
|
headers: {
|
|
Authorization: `Bearer ${TOKEN}`,
|
|
Accept: "application/json",
|
|
...headers,
|
|
},
|
|
};
|
|
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 || 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}${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;
|
|
}
|
|
|
|
return payload;
|
|
} catch (e) {
|
|
clearTimeout(t);
|
|
lastErr = e;
|
|
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;
|
|
}
|
|
|
|
// ------------------------------------------------------------
|
|
// Funciones públicas
|
|
// ------------------------------------------------------------
|
|
|
|
export async function akFindUserByEmail(email) {
|
|
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;
|
|
}
|
|
|
|
export async function akCreateUser(p = {}) {
|
|
const { akPOST } = createAkClient();
|
|
|
|
const email = String(p.email ?? "").trim().toLowerCase();
|
|
if (!email) throw new TypeError("akCreateUser: `email` is required");
|
|
|
|
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 {
|
|
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;
|
|
}
|
|
|
|
export async function akSetPassword(userPk, password, requireChange = true) {
|
|
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 akPOST(`/core/users/${encodeURIComponent(id)}/set_password/`, {
|
|
body: { password: pwd, require_change: !!requireChange },
|
|
retries: 1,
|
|
timeoutMs: 15_000,
|
|
});
|
|
return true;
|
|
} catch (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;
|
|
}
|
|
}
|
|
|
|
export async function akResolveGroupIdByName(name) {
|
|
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 } = {}) {
|
|
const toPk = (v) => {
|
|
if (v == null || v === "") return null;
|
|
const n = Number(v);
|
|
return Number.isFinite(n) ? n : String(v);
|
|
};
|
|
|
|
// 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(`/core/groups/${encodeURIComponent(uuidStr)}/`, { retries: 1 });
|
|
const fromDetail = toPk(g?.pk ?? g?.id);
|
|
if (fromDetail != null) return fromDetail;
|
|
} catch { /* continue with name/slug */ }
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
// ------------------------------------------------------------
|
|
// 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); |