Nueva sección "Workspace" (Organizaciones + Usuarios) que orquesta el Hub reenviando el token del usuario autenticado. Sin secretos en el CRM ni bypass: la autorización la impone el Hub (hub_admin para tenants; hub_admin o admin del tenant para invitaciones). - Organizaciones: listar (GET /api/v1/hub/tenants) y crear (POST /api/v1/hub/tenants → tenant + realm Keycloak). - Usuarios: invitación de un solo uso (POST /api/v1/hub/invites) con enlace copiable si el correo no llega. - Se descarta /auth/provision-user (PROVISION_SECRET, machine-to-machine) en favor del flujo de invitación con token de admin. - Helpers puros (slug, validación, extracción de errores del Hub) con tests unitarios; estado "requiere permisos" ante 403 y fallback a slug manual para admin de tenant. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
127 lines
3.8 KiB
TypeScript
127 lines
3.8 KiB
TypeScript
/**
|
|
* Orquestación del Hub (Workspace) para dar de alta ORGANIZACIONES (tenants) y
|
|
* USUARIOS (invitaciones) desde el CRM.
|
|
*
|
|
* Principio de seguridad: SIEMPRE se llama server-side reenviando el token del
|
|
* usuario autenticado (Bearer). Es el Hub quien valida el permiso —
|
|
* `hub_admin`/superadmin para tenants, `hub_admin` o `admin` del tenant para
|
|
* invitaciones. El CRM NO guarda secretos ni hace bypass de autorización.
|
|
*
|
|
* Endpoints del Hub (base = getHubBackendUrl()):
|
|
* GET /api/v1/hub/tenants → lista de organizaciones (solo hub_admin)
|
|
* POST /api/v1/hub/tenants → crea tenant + realm Keycloak (solo hub_admin)
|
|
* POST /api/v1/hub/invites → invitación de un solo uso + email (hub_admin | admin del tenant)
|
|
*/
|
|
|
|
import { getHubBackendUrl } from '$lib/server/workspace-auth';
|
|
import { hubErrorMessage, isForbiddenStatus } from '$lib/server/workspace-provision.shared';
|
|
|
|
const HUB_API_PREFIX = '/api/v1/hub';
|
|
|
|
function hubUrl(path: string): string {
|
|
return `${getHubBackendUrl()}${HUB_API_PREFIX}${path}`;
|
|
}
|
|
|
|
export type WorkspaceTenant = {
|
|
id: number;
|
|
name: string;
|
|
slug: string;
|
|
display_name?: string | null;
|
|
contact_name?: string | null;
|
|
contact_email: string;
|
|
status: string;
|
|
is_self_hosted: boolean;
|
|
has_license: boolean;
|
|
created_at: string;
|
|
};
|
|
|
|
export type WorkspaceInvite = {
|
|
id: number;
|
|
email: string;
|
|
tenant_slug: string;
|
|
role: string;
|
|
invite_url: string;
|
|
expires_at: string;
|
|
created_at: string;
|
|
};
|
|
|
|
export type HubResult<T> =
|
|
| { ok: true; data: T }
|
|
| { ok: false; status: number; forbidden: boolean; error: string };
|
|
|
|
/** Construye el resultado de error a partir de una respuesta no-2xx del Hub. */
|
|
async function toErrorResult<T>(res: Response): Promise<HubResult<T>> {
|
|
const body = await res.json().catch(() => null);
|
|
return {
|
|
ok: false,
|
|
status: res.status,
|
|
forbidden: isForbiddenStatus(res.status),
|
|
error: hubErrorMessage(body, res.status)
|
|
};
|
|
}
|
|
|
|
const jsonHeaders = (accessToken: string) => ({
|
|
Authorization: `Bearer ${accessToken}`,
|
|
'Content-Type': 'application/json'
|
|
});
|
|
|
|
/** Lista las organizaciones del workspace. Requiere hub_admin (403 si no). */
|
|
export async function listWorkspaceTenants(
|
|
accessToken: string,
|
|
fetch: typeof globalThis.fetch
|
|
): Promise<HubResult<WorkspaceTenant[]>> {
|
|
const res = await fetch(hubUrl('/tenants'), {
|
|
headers: { Authorization: `Bearer ${accessToken}` }
|
|
});
|
|
if (!res.ok) return toErrorResult<WorkspaceTenant[]>(res);
|
|
const data = (await res.json()) as { tenants?: WorkspaceTenant[] };
|
|
return { ok: true, data: Array.isArray(data.tenants) ? data.tenants : [] };
|
|
}
|
|
|
|
export type CreateTenantInput = {
|
|
name: string;
|
|
slug: string;
|
|
contact_email: string;
|
|
contact_name?: string;
|
|
contact_phone?: string;
|
|
display_name?: string;
|
|
is_self_hosted: boolean;
|
|
app_url?: string;
|
|
};
|
|
|
|
/** Crea una organización (tenant) y provisiona su realm en Keycloak. */
|
|
export async function createWorkspaceTenant(
|
|
accessToken: string,
|
|
fetch: typeof globalThis.fetch,
|
|
input: CreateTenantInput
|
|
): Promise<HubResult<WorkspaceTenant>> {
|
|
const res = await fetch(hubUrl('/tenants'), {
|
|
method: 'POST',
|
|
headers: jsonHeaders(accessToken),
|
|
body: JSON.stringify(input)
|
|
});
|
|
if (!res.ok) return toErrorResult<WorkspaceTenant>(res);
|
|
return { ok: true, data: (await res.json()) as WorkspaceTenant };
|
|
}
|
|
|
|
export type CreateInviteInput = {
|
|
email: string;
|
|
tenant_slug: string;
|
|
role: string;
|
|
};
|
|
|
|
/** Genera una invitación de un solo uso para un usuario en un tenant. */
|
|
export async function createWorkspaceInvite(
|
|
accessToken: string,
|
|
fetch: typeof globalThis.fetch,
|
|
input: CreateInviteInput
|
|
): Promise<HubResult<WorkspaceInvite>> {
|
|
const res = await fetch(hubUrl('/invites'), {
|
|
method: 'POST',
|
|
headers: jsonHeaders(accessToken),
|
|
body: JSON.stringify(input)
|
|
});
|
|
if (!res.ok) return toErrorResult<WorkspaceInvite>(res);
|
|
return { ok: true, data: (await res.json()) as WorkspaceInvite };
|
|
}
|