From a613a7a6aa9271ba14966a2553816848b9b96f6e Mon Sep 17 00:00:00 2001 From: Ernesto Herrera Date: Thu, 16 Jul 2026 13:19:21 -0600 Subject: [PATCH] =?UTF-8?q?feat(crm,workspace):=20alta=20de=20organizacion?= =?UTF-8?q?es=20y=20usuarios=20v=C3=ADa=20Hub?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/lib/components/sidebar/modules.ts | 10 + .../server/workspace-provision.shared.test.ts | 80 ++++++++ .../lib/server/workspace-provision.shared.ts | 85 +++++++++ .../src/lib/server/workspace-provision.ts | 126 ++++++++++++ .../workspace/organizaciones/+page.server.ts | 78 ++++++++ .../workspace/organizaciones/+page.svelte | 180 ++++++++++++++++++ .../workspace/usuarios/+page.server.ts | 63 ++++++ .../dashboard/workspace/usuarios/+page.svelte | 152 +++++++++++++++ 8 files changed, 774 insertions(+) create mode 100644 frontend/src/lib/server/workspace-provision.shared.test.ts create mode 100644 frontend/src/lib/server/workspace-provision.shared.ts create mode 100644 frontend/src/lib/server/workspace-provision.ts create mode 100644 frontend/src/routes/dashboard/workspace/organizaciones/+page.server.ts create mode 100644 frontend/src/routes/dashboard/workspace/organizaciones/+page.svelte create mode 100644 frontend/src/routes/dashboard/workspace/usuarios/+page.server.ts create mode 100644 frontend/src/routes/dashboard/workspace/usuarios/+page.svelte diff --git a/frontend/src/lib/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts index dbd39e0..6792c2b 100644 --- a/frontend/src/lib/components/sidebar/modules.ts +++ b/frontend/src/lib/components/sidebar/modules.ts @@ -6,6 +6,7 @@ import { Briefcase, Ship, Receipt, + Building2, } from '@lucide/svelte'; export type SystemContext = 'fixed_asset' | 'inventory'; @@ -69,6 +70,15 @@ export function getNavMain(): NavMainItem[] { { title: 'Facturas y cobranza', url: '/dashboard/fin/facturas' }, ], }, + { + title: 'Workspace', + url: '/dashboard/workspace/organizaciones', + icon: Building2, + items: [ + { title: 'Organizaciones', url: '/dashboard/workspace/organizaciones' }, + { title: 'Usuarios (invitaciones)', url: '/dashboard/workspace/usuarios' }, + ], + }, { title: 'Usuarios', url: '/dashboard/users', diff --git a/frontend/src/lib/server/workspace-provision.shared.test.ts b/frontend/src/lib/server/workspace-provision.shared.test.ts new file mode 100644 index 0000000..e9b3ab7 --- /dev/null +++ b/frontend/src/lib/server/workspace-provision.shared.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest'; +import { + slugifyTenantName, + validateTenantForm, + hubErrorMessage, + isForbiddenStatus, + TENANT_SLUG_RE +} from './workspace-provision.shared'; + +describe('slugifyTenantName', () => { + it('convierte nombre con acentos y espacios a slug válido', () => { + const slug = slugifyTenantName('Logística Peña & Cía S.A. de C.V.'); + expect(slug).toBe('logistica-pena-cia-s-a-de-c-v'); + expect(TENANT_SLUG_RE.test(slug)).toBe(true); + }); + + it('quita guiones al inicio y al final', () => { + expect(slugifyTenantName(' --Hola-- ')).toBe('hola'); + }); + + it('cadena sin caracteres válidos da string vacío', () => { + expect(slugifyTenantName('!!!')).toBe(''); + }); +}); + +describe('validateTenantForm', () => { + it('acepta datos válidos', () => { + expect( + validateTenantForm({ name: 'Empresa ABC', slug: 'empresa-abc', contact_email: 'a@b.com' }) + ).toBeNull(); + }); + + it('rechaza nombre demasiado corto', () => { + expect( + validateTenantForm({ name: 'A', slug: 'a-b', contact_email: 'a@b.com' }) + ).toMatch(/al menos 2/); + }); + + it('rechaza slug con mayúsculas o espacios', () => { + expect( + validateTenantForm({ name: 'Empresa ABC', slug: 'Empresa ABC', contact_email: 'a@b.com' }) + ).toMatch(/slug/i); + }); + + it('rechaza email inválido', () => { + expect( + validateTenantForm({ name: 'Empresa ABC', slug: 'empresa-abc', contact_email: 'no-email' }) + ).toMatch(/email/i); + }); +}); + +describe('hubErrorMessage', () => { + it('devuelve el detail string tal cual', () => { + expect(hubErrorMessage({ detail: 'Slug ya existe' }, 409)).toBe('Slug ya existe'); + }); + + it('formatea el primer error de validación de Pydantic', () => { + const body = { detail: [{ loc: ['body', 'contact_email'], msg: 'value is not a valid email' }] }; + expect(hubErrorMessage(body, 422)).toBe('contact_email: value is not a valid email'); + }); + + it('mensaje de permisos ante 403 sin detail', () => { + expect(hubErrorMessage(null, 403)).toMatch(/permisos/i); + }); + + it('mensaje genérico con status ante cuerpo desconocido', () => { + expect(hubErrorMessage(null, 500)).toMatch(/500/); + }); +}); + +describe('isForbiddenStatus', () => { + it('true para 401 y 403', () => { + expect(isForbiddenStatus(401)).toBe(true); + expect(isForbiddenStatus(403)).toBe(true); + }); + it('false para otros', () => { + expect(isForbiddenStatus(422)).toBe(false); + expect(isForbiddenStatus(200)).toBe(false); + }); +}); diff --git a/frontend/src/lib/server/workspace-provision.shared.ts b/frontend/src/lib/server/workspace-provision.shared.ts new file mode 100644 index 0000000..e71b283 --- /dev/null +++ b/frontend/src/lib/server/workspace-provision.shared.ts @@ -0,0 +1,85 @@ +/** + * Helpers puros para el alta de organizaciones/usuarios en el Workspace (Hub). + * Sin dependencias de entorno para poder testearse en aislamiento (Vitest). + */ + +// Slug del tenant: mismas reglas que el Hub (TenantCreateDTO.slug → ^[a-z0-9-]+$). +export const TENANT_SLUG_RE = /^[a-z0-9-]+$/; + +// Roles válidos para invitar un usuario. Son los que el Hub reconoce en su +// flujo de alta (ver ProvisionUserRequestDTO / create_invite). No inventar otros. +export const WORKSPACE_INVITE_ROLES = ['user', 'admin', 'supervisor', 'operador', 'visor'] as const; +export type WorkspaceInviteRole = (typeof WORKSPACE_INVITE_ROLES)[number]; + +/** + * Deriva un slug candidato a partir del nombre de la organización: + * minúsculas, sin acentos, espacios y símbolos → guiones. + */ +export function slugifyTenantName(name: string): string { + return name + .normalize('NFD') + .replace(/[̀-ͯ]/g, '') // quita acentos (marcas combinantes Unicode) + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 100); +} + +export type TenantFormValues = { + name: string; + slug: string; + contact_email: string; +}; + +/** + * Valida los campos obligatorios del alta de organización antes de llamar al Hub, + * para dar feedback inmediato sin gastar un round-trip. + * Devuelve un mensaje de error o null si es válido. + */ +export function validateTenantForm(values: TenantFormValues): string | null { + if (!values.name || values.name.trim().length < 2) { + return 'El nombre de la organización debe tener al menos 2 caracteres.'; + } + if (!TENANT_SLUG_RE.test(values.slug)) { + return 'El slug solo admite minúsculas, dígitos y guiones (sin espacios ni acentos).'; + } + if (!values.contact_email || !values.contact_email.includes('@')) { + return 'El email de contacto es obligatorio y debe ser válido.'; + } + return null; +} + +/** + * Extrae un mensaje legible del cuerpo de error de FastAPI (Hub). + * - 401/403 → mensaje de permisos. + * - detail string → tal cual. + * - detail array (422 Pydantic) → "campo: msg" del primer error. + * - cualquier otro → mensaje genérico con el status. + */ +export function hubErrorMessage(body: unknown, status: number): string { + const detail = + body && typeof body === 'object' ? (body as { detail?: unknown }).detail : null; + + if (typeof detail === 'string' && detail.trim()) { + return detail; + } + + if (Array.isArray(detail) && detail.length > 0) { + const first = detail[0] as { loc?: unknown[]; msg?: string }; + const loc = Array.isArray(first.loc) ? first.loc : []; + const field = loc.length ? String(loc[loc.length - 1]) : ''; + const msg = first.msg ?? 'dato inválido'; + return field ? `${field}: ${msg}` : msg; + } + + if (status === 401 || status === 403) { + return 'No tienes permisos de administrador en el workspace para esta acción.'; + } + + return `El workspace respondió con un error (${status}).`; +} + +/** True si el status del Hub indica falta de permisos/sesión. */ +export function isForbiddenStatus(status: number): boolean { + return status === 401 || status === 403; +} diff --git a/frontend/src/lib/server/workspace-provision.ts b/frontend/src/lib/server/workspace-provision.ts new file mode 100644 index 0000000..4bc5af1 --- /dev/null +++ b/frontend/src/lib/server/workspace-provision.ts @@ -0,0 +1,126 @@ +/** + * 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 = + | { 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(res: Response): Promise> { + 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> { + const res = await fetch(hubUrl('/tenants'), { + headers: { Authorization: `Bearer ${accessToken}` } + }); + if (!res.ok) return toErrorResult(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> { + const res = await fetch(hubUrl('/tenants'), { + method: 'POST', + headers: jsonHeaders(accessToken), + body: JSON.stringify(input) + }); + if (!res.ok) return toErrorResult(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> { + const res = await fetch(hubUrl('/invites'), { + method: 'POST', + headers: jsonHeaders(accessToken), + body: JSON.stringify(input) + }); + if (!res.ok) return toErrorResult(res); + return { ok: true, data: (await res.json()) as WorkspaceInvite }; +} diff --git a/frontend/src/routes/dashboard/workspace/organizaciones/+page.server.ts b/frontend/src/routes/dashboard/workspace/organizaciones/+page.server.ts new file mode 100644 index 0000000..aab020a --- /dev/null +++ b/frontend/src/routes/dashboard/workspace/organizaciones/+page.server.ts @@ -0,0 +1,78 @@ +import { fail } from '@sveltejs/kit'; +import type { PageServerLoad, Actions } from './$types'; +import { getAuthTokens } from '$lib/server/api'; +import { + listWorkspaceTenants, + createWorkspaceTenant, + type CreateTenantInput +} from '$lib/server/workspace-provision'; +import { validateTenantForm } from '$lib/server/workspace-provision.shared'; + +export const load: PageServerLoad = async ({ cookies, fetch }) => { + const { accessToken } = getAuthTokens(cookies); + if (!accessToken) { + return { tenants: [], forbidden: true, loadError: null }; + } + + const res = await listWorkspaceTenants(accessToken, fetch); + if (!res.ok) { + // 401/403 → el usuario no es admin del workspace: se muestra estado informativo, + // no un error. Otros status sí se reportan como error de carga. + return { tenants: [], forbidden: res.forbidden, loadError: res.forbidden ? null : res.error }; + } + + return { tenants: res.data, forbidden: false, loadError: null }; +}; + +export const actions: Actions = { + create: async ({ request, cookies, fetch }) => { + const { accessToken } = getAuthTokens(cookies); + if (!accessToken) return fail(401, { error: 'Tu sesión expiró. Vuelve a entrar al CRM.' }); + + const data = await request.formData(); + const name = String(data.get('name') ?? '').trim(); + const slug = String(data.get('slug') ?? '') + .trim() + .toLowerCase(); + const contact_email = String(data.get('contact_email') ?? '').trim(); + const contact_name = String(data.get('contact_name') ?? '').trim(); + const contact_phone = String(data.get('contact_phone') ?? '').trim(); + const display_name = String(data.get('display_name') ?? '').trim(); + const app_url = String(data.get('app_url') ?? '').trim(); + const is_self_hosted = data.get('is_self_hosted') === 'on'; + + // Valores para repoblar el formulario si algo falla. + const values = { + name, + slug, + contact_email, + contact_name, + contact_phone, + display_name, + app_url, + is_self_hosted + }; + + const validationError = validateTenantForm({ name, slug, contact_email }); + if (validationError) return fail(422, { error: validationError, values }); + + // Solo se envían los campos opcionales con valor, para no mandar strings vacíos. + const payload: CreateTenantInput = { + name, + slug, + contact_email, + is_self_hosted, + ...(contact_name ? { contact_name } : {}), + ...(contact_phone ? { contact_phone } : {}), + ...(display_name ? { display_name } : {}), + ...(app_url ? { app_url } : {}) + }; + + const res = await createWorkspaceTenant(accessToken, fetch, payload); + if (!res.ok) { + return fail(res.forbidden ? 403 : 422, { error: res.error, values }); + } + + return { success: true, tenantName: res.data.name, tenantSlug: res.data.slug }; + } +}; diff --git a/frontend/src/routes/dashboard/workspace/organizaciones/+page.svelte b/frontend/src/routes/dashboard/workspace/organizaciones/+page.svelte new file mode 100644 index 0000000..99c3399 --- /dev/null +++ b/frontend/src/routes/dashboard/workspace/organizaciones/+page.svelte @@ -0,0 +1,180 @@ + + +
+
+

+ Organizaciones del workspace +

+

+ Da de alta un cliente nuevo (tenant). Se crea su realm en Keycloak y su licencia base. +

+
+ + {#if data.forbidden} + + + +
+

Requiere permisos de administrador del workspace

+

+ El alta de organizaciones solo está disponible para administradores del Hub + (hub_admin). Tu usuario actual no tiene ese rol. +

+
+
+
+ {:else} + {#if data.loadError} +
+ No se pudo consultar el workspace: {data.loadError} +
+ {/if} + + + + + Nueva organización + El slug identifica al tenant; no se puede cambiar después. + + +
{ + submitting = true; + return async ({ result, update }) => { + submitting = false; + if (result.type === 'success') { + toast.success(`Organización creada: ${result.data?.tenantName ?? ''}`); + await update({ reset: true }); + } else if (result.type === 'failure') { + toast.error(String(result.data?.error ?? 'No se pudo crear la organización')); + await update({ reset: false }); + } else { + await update(); + } + }; + }} + > +
+ + + + + + + + +
+ +
+ +
+
+
+
+ + + + + Organizaciones ({data.tenants.length}) + Tenants activos en el workspace. + + + {#if data.tenants.length === 0} +

+ Aún no hay organizaciones registradas. +

+ {:else} +
+ + + + + + + + + + + + {#each data.tenants as t (t.id)} + + + + + + + + {/each} + +
NombreSlugContactoEstatusLicencia
{t.display_name || t.name}{t.slug}{t.contact_email} + + {t.status} + + {t.has_license ? 'Sí' : '—'}
+
+ {/if} +
+
+ {/if} +
diff --git a/frontend/src/routes/dashboard/workspace/usuarios/+page.server.ts b/frontend/src/routes/dashboard/workspace/usuarios/+page.server.ts new file mode 100644 index 0000000..f3bc5a1 --- /dev/null +++ b/frontend/src/routes/dashboard/workspace/usuarios/+page.server.ts @@ -0,0 +1,63 @@ +import { fail } from '@sveltejs/kit'; +import type { PageServerLoad, Actions } from './$types'; +import { getAuthTokens } from '$lib/server/api'; +import { listWorkspaceTenants, createWorkspaceInvite } from '$lib/server/workspace-provision'; +import { WORKSPACE_INVITE_ROLES } from '$lib/server/workspace-provision.shared'; + +export const load: PageServerLoad = async ({ cookies, fetch }) => { + const { accessToken } = getAuthTokens(cookies); + if (!accessToken) { + return { tenants: [], canListTenants: false, roles: WORKSPACE_INVITE_ROLES }; + } + + // Los hub_admin pueden listar todos los tenants (dropdown). Un admin de tenant + // no puede listarlos (403) pero sí puede invitar a SU tenant escribiendo el slug. + const res = await listWorkspaceTenants(accessToken, fetch); + if (!res.ok) { + return { tenants: [], canListTenants: false, roles: WORKSPACE_INVITE_ROLES }; + } + + return { + tenants: res.data.map((t) => ({ slug: t.slug, name: t.display_name || t.name })), + canListTenants: true, + roles: WORKSPACE_INVITE_ROLES + }; +}; + +export const actions: Actions = { + invite: async ({ request, cookies, fetch }) => { + const { accessToken } = getAuthTokens(cookies); + if (!accessToken) return fail(401, { error: 'Tu sesión expiró. Vuelve a entrar al CRM.' }); + + const data = await request.formData(); + const email = String(data.get('email') ?? '').trim(); + const tenant_slug = String(data.get('tenant_slug') ?? '') + .trim() + .toLowerCase(); + const role = String(data.get('role') ?? 'user').trim(); + + const values = { email, tenant_slug, role }; + + if (!email || !email.includes('@')) { + return fail(422, { error: 'Ingresa un email válido para invitar.', values }); + } + if (!tenant_slug) { + return fail(422, { error: 'Selecciona (o escribe) el slug de la organización destino.', values }); + } + if (!WORKSPACE_INVITE_ROLES.includes(role as (typeof WORKSPACE_INVITE_ROLES)[number])) { + return fail(422, { error: 'Rol inválido.', values }); + } + + const res = await createWorkspaceInvite(accessToken, fetch, { email, tenant_slug, role }); + if (!res.ok) { + return fail(res.forbidden ? 403 : 422, { error: res.error, values }); + } + + return { + success: true, + email: res.data.email, + inviteUrl: res.data.invite_url, + expiresAt: res.data.expires_at + }; + } +}; diff --git a/frontend/src/routes/dashboard/workspace/usuarios/+page.svelte b/frontend/src/routes/dashboard/workspace/usuarios/+page.svelte new file mode 100644 index 0000000..4b156fd --- /dev/null +++ b/frontend/src/routes/dashboard/workspace/usuarios/+page.svelte @@ -0,0 +1,152 @@ + + +
+
+

+ Alta de usuarios (invitaciones) +

+

+ Invita a un usuario a una organización del workspace. Recibe un enlace de un solo uso para + fijar su contraseña y activarse. +

+
+ + {#if created} + + +
+ +
+

Invitación enviada a {created.email}

+

+ Si el correo no llega, comparte este enlace directamente: +

+
+ + +
+
+
+
+
+ {/if} + + + + Nueva invitación + + {#if data.canListTenants} + Elige la organización destino y el rol del usuario. + {:else} + Escribe el slug de tu organización y el rol del usuario. + {/if} + + + +
{ + submitting = true; + return async ({ result, update }) => { + submitting = false; + if (result.type === 'success') { + toast.success('Invitación creada'); + await update({ reset: true }); + } else if (result.type === 'failure') { + toast.error(String(result.data?.error ?? 'No se pudo crear la invitación')); + await update({ reset: false }); + } else { + await update(); + } + }; + }} + > +
+ + + + + +
+ +
+ +
+
+
+
+