Mucha cosa y es muy tarde.
- Anda parte del registro
This commit is contained in:
+197
-29
@@ -1,46 +1,214 @@
|
||||
// services/auth/src/ak.js
|
||||
import axios from 'axios';
|
||||
// ------------------------------------------------------------
|
||||
// Cliente mínimo y robusto 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
|
||||
// ------------------------------------------------------------
|
||||
|
||||
const AK = axios.create({
|
||||
baseURL: `${process.env.AUTHENTIK_BASE_URL}/api/v3`,
|
||||
headers: { Authorization: `Bearer ${process.env.AUTHENTIK_TOKEN}` },
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Busca usuario por email (case-insensitive)
|
||||
export async function akFindUserByEmail(email) {
|
||||
const { data } = await AK.get('/core/users/', { params: { search: email }});
|
||||
// filtra exacto por email si querés evitar colisiones de 'search'
|
||||
return data.results?.find(u => (u.email || '').toLowerCase() === email.toLowerCase()) || null;
|
||||
/**
|
||||
* Lee configuración desde process.env en cada llamada (para evitar problemas
|
||||
* de orden de imports con dotenv). No falla en import-time.
|
||||
*/
|
||||
function getConfig() {
|
||||
const BASE = (process.env.AUTHENTIK_BASE_URL || '').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');
|
||||
return { BASE, TOKEN };
|
||||
}
|
||||
|
||||
// Crea usuario en Authentik con atributo tenant_uuid y lo agrega a un grupo (opcional)
|
||||
export async function akCreateUser({ email, displayName, tenantUuid, addToGroupId }) {
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Helpers de sincronización
|
||||
// ------------------------------------------------------------
|
||||
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 },
|
||||
});
|
||||
}
|
||||
|
||||
export async function akEnsureGroupForTenant(tenantHex) {
|
||||
const groupName = `tenant_${tenantHex}`;
|
||||
|
||||
// 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;
|
||||
|
||||
// crear si no existe
|
||||
const created = await akRequest('post', '/api/v3/core/groups/', {
|
||||
data: { name: groupName, attributes: { tenant_uuid: tenantHex } },
|
||||
});
|
||||
return created.pk;
|
||||
}
|
||||
|
||||
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 } });
|
||||
}
|
||||
|
||||
// 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]
|
||||
*/
|
||||
async function request(method, path, opts = {}) {
|
||||
const { BASE, TOKEN } = getConfig();
|
||||
const {
|
||||
qs = undefined,
|
||||
body = undefined,
|
||||
timeoutMs = 10000,
|
||||
retries = 0,
|
||||
} = opts;
|
||||
|
||||
const url = new URL(`${BASE}/api/v3${path}`);
|
||||
if (qs) Object.entries(qs).forEach(([k, v]) => url.searchParams.set(k, String(v)));
|
||||
|
||||
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);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
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.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}`);
|
||||
err.status = res.status; // @ts-ignore
|
||||
throw err;
|
||||
}
|
||||
|
||||
return payload;
|
||||
} 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
|
||||
}
|
||||
}
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// 1) crear usuario
|
||||
const { data: user } = await AK.post('/core/users/', {
|
||||
username: email, // en Authentik el username puede ser el email
|
||||
name: displayName || email,
|
||||
email,
|
||||
is_active: true,
|
||||
attributes: { tenant_uuid: tenantUuid }, // <-- para tu claim custom
|
||||
const user = await request('POST', '/core/users/', {
|
||||
body: {
|
||||
username: email,
|
||||
name,
|
||||
email,
|
||||
is_active: isActive,
|
||||
attributes: tenantUuid ? { tenant_uuid: tenantUuid } : {},
|
||||
},
|
||||
retries: 3,
|
||||
});
|
||||
|
||||
// 2) agregar a grupo por defecto (opcional)
|
||||
if (addToGroupId) {
|
||||
await AK.post(`/core/users/${user.pk}/groups/`, { group: addToGroupId });
|
||||
// 2) agregar a grupo (opcional)
|
||||
if (p?.addToGroupId) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return user; // contiene pk y uuid
|
||||
return user;
|
||||
}
|
||||
|
||||
// Opcional: setear/forzar password inicial (si querés flujo con password local en Authentik)
|
||||
/**
|
||||
* 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');
|
||||
try {
|
||||
await AK.post(`/core/users/${userPk}/set_password/`, {
|
||||
password, require_change: requireChange,
|
||||
await request('POST', `/core/users/${userPk}/set_password/`, {
|
||||
body: { password, require_change: !!requireChange },
|
||||
retries: 1,
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
// Si tu instancia no permite setear password por API, capturá y usá un flow de "reset password"
|
||||
throw new Error('No se pudo establecer la contraseña en Authentik por API');
|
||||
// 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}`);
|
||||
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 akListGroups(search) {
|
||||
const data = await request('GET', '/core/groups/', { qs: { search, page_size: 50 }, retries: 2 });
|
||||
return Array.isArray(data?.results) ? data.results : [];
|
||||
}
|
||||
|
||||
export async function akResolveGroupIdByName(name) {
|
||||
const data = await akListGroups(name);
|
||||
const lower = name.toLowerCase();
|
||||
const found = data.find(g => (g.name || '').toLowerCase() === lower || (g.slug || '').toLowerCase() === lower);
|
||||
return found?.pk || null;
|
||||
}
|
||||
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Fin
|
||||
// ------------------------------------------------------------
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+510
-404
File diff suppressed because it is too large
Load Diff
@@ -1,25 +1,164 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>Iniciar sesión | SuiteCoffee</title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
||||
<link rel="stylesheet" href="/css/main.css"/>
|
||||
</head>
|
||||
<body class="container">
|
||||
<header class="my-4">
|
||||
<h1>SuiteCoffee — Acceso</h1>
|
||||
</header>
|
||||
<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>
|
||||
|
||||
<% if (user) { %>
|
||||
<p>Ya iniciaste sesión como <strong><%= user.email %></strong>.</p>
|
||||
<p>Continuar a <a href="/">la aplicación</a></p>
|
||||
<% } else { %>
|
||||
<div class="card p-4">
|
||||
<p>Usamos inicio de sesión único (SSO) con nuestro Identity Provider.</p>
|
||||
<!-- Esta URL dispara el flujo OIDC hacia Authentik -->
|
||||
<a class="btn btn-primary btn-lg" href="/auth/login">Iniciar sesión con SuiteCoffee SSO</a>
|
||||
<!-- 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>
|
||||
<% } %>
|
||||
</body>
|
||||
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user