Compare commits
2 Commits
c6f18013b3
...
a613a7a6aa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a613a7a6aa | ||
|
|
45f128a551 |
@@ -49,7 +49,8 @@ FROM node:22-alpine AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache wget
|
||||
# wget de busybox ya viene en alpine; el apk es best-effort (redes restringidas sin CDN de Alpine)
|
||||
RUN apk add --no-cache wget || true
|
||||
|
||||
RUN npm config set strict-ssl false && \
|
||||
npm install -g pnpm
|
||||
|
||||
@@ -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',
|
||||
|
||||
80
frontend/src/lib/server/workspace-provision.shared.test.ts
Normal file
80
frontend/src/lib/server/workspace-provision.shared.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
85
frontend/src/lib/server/workspace-provision.shared.ts
Normal file
85
frontend/src/lib/server/workspace-provision.shared.ts
Normal file
@@ -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;
|
||||
}
|
||||
126
frontend/src/lib/server/workspace-provision.ts
Normal file
126
frontend/src/lib/server/workspace-provision.ts
Normal file
@@ -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<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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
<script lang="ts">
|
||||
import { Building2, Plus, ShieldAlert, RefreshCw } from '@lucide/svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
let submitting = $state(false);
|
||||
|
||||
const inputCls =
|
||||
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
||||
|
||||
// Valores repoblados tras un fallo de validación del servidor.
|
||||
const v = $derived((form && 'values' in form ? form.values : null) as Record<string, unknown> | null);
|
||||
function prev(field: string): string {
|
||||
const val = v?.[field];
|
||||
return typeof val === 'string' ? val : '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||
<Building2 class="h-6 w-6" /> Organizaciones del workspace
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Da de alta un cliente nuevo (tenant). Se crea su realm en Keycloak y su licencia base.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if data.forbidden}
|
||||
<Card.Root>
|
||||
<Card.Content class="flex items-start gap-3 pt-6">
|
||||
<ShieldAlert class="mt-0.5 h-5 w-5 text-amber-500" />
|
||||
<div class="text-sm">
|
||||
<p class="font-medium">Requiere permisos de administrador del workspace</p>
|
||||
<p class="mt-1 text-muted-foreground">
|
||||
El alta de organizaciones solo está disponible para administradores del Hub
|
||||
(<code>hub_admin</code>). Tu usuario actual no tiene ese rol.
|
||||
</p>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{:else}
|
||||
{#if data.loadError}
|
||||
<div class="rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
No se pudo consultar el workspace: {data.loadError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Alta de organización -->
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="flex items-center gap-2"><Plus class="h-4 w-4" /> Nueva organización</Card.Title>
|
||||
<Card.Description>El slug identifica al tenant; no se puede cambiar después.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/create"
|
||||
use:enhance={() => {
|
||||
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();
|
||||
}
|
||||
};
|
||||
}}
|
||||
>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
|
||||
<span class="font-medium">Nombre / Razón social *</span>
|
||||
<input class={inputCls} name="name" required minlength="2" value={prev('name')} />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Slug *</span>
|
||||
<input
|
||||
class="font-mono {inputCls}"
|
||||
name="slug"
|
||||
required
|
||||
pattern="[a-z0-9-]+"
|
||||
placeholder="empresa-abc"
|
||||
title="Solo minúsculas, dígitos y guiones"
|
||||
value={prev('slug')}
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Nombre para mostrar</span>
|
||||
<input class={inputCls} name="display_name" value={prev('display_name')} />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Contacto (nombre)</span>
|
||||
<input class={inputCls} name="contact_name" value={prev('contact_name')} />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Email de contacto *</span>
|
||||
<input type="email" class={inputCls} name="contact_email" required value={prev('contact_email')} />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Teléfono de contacto</span>
|
||||
<input class={inputCls} name="contact_phone" value={prev('contact_phone')} />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">URL de la app (opcional)</span>
|
||||
<input class={inputCls} name="app_url" placeholder="https://app.empresa-abc.com" value={prev('app_url')} />
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm sm:col-span-2">
|
||||
<input type="checkbox" name="is_self_hosted" class="h-4 w-4 rounded border" />
|
||||
<span>Cliente self-hosted (instalación propia)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end border-t pt-4">
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? 'Creando…' : 'Crear organización'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<!-- Organizaciones existentes -->
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Organizaciones ({data.tenants.length})</Card.Title>
|
||||
<Card.Description>Tenants activos en el workspace.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if data.tenants.length === 0}
|
||||
<p class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<RefreshCw class="h-4 w-4" /> Aún no hay organizaciones registradas.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-left text-muted-foreground">
|
||||
<tr class="border-b">
|
||||
<th class="py-2 pr-4 font-medium">Nombre</th>
|
||||
<th class="py-2 pr-4 font-medium">Slug</th>
|
||||
<th class="py-2 pr-4 font-medium">Contacto</th>
|
||||
<th class="py-2 pr-4 font-medium">Estatus</th>
|
||||
<th class="py-2 pr-4 font-medium">Licencia</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.tenants as t (t.id)}
|
||||
<tr class="border-b last:border-0">
|
||||
<td class="py-2 pr-4">{t.display_name || t.name}</td>
|
||||
<td class="py-2 pr-4 font-mono text-xs">{t.slug}</td>
|
||||
<td class="py-2 pr-4">{t.contact_email}</td>
|
||||
<td class="py-2 pr-4">
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs {t.status === 'active'
|
||||
? 'bg-emerald-500/15 text-emerald-600'
|
||||
: 'bg-muted text-muted-foreground'}"
|
||||
>
|
||||
{t.status}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-2 pr-4">{t.has_license ? 'Sí' : '—'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
};
|
||||
152
frontend/src/routes/dashboard/workspace/usuarios/+page.svelte
Normal file
152
frontend/src/routes/dashboard/workspace/usuarios/+page.svelte
Normal file
@@ -0,0 +1,152 @@
|
||||
<script lang="ts">
|
||||
import { UserPlus, Copy, Check, MailCheck } from '@lucide/svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
let submitting = $state(false);
|
||||
let copied = $state(false);
|
||||
|
||||
const inputCls =
|
||||
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
||||
|
||||
const v = $derived((form && 'values' in form ? form.values : null) as Record<string, unknown> | null);
|
||||
function prev(field: string): string {
|
||||
const val = v?.[field];
|
||||
return typeof val === 'string' ? val : '';
|
||||
}
|
||||
|
||||
// Invitación recién creada (para copiar/compartir el enlace).
|
||||
const created = $derived(
|
||||
form && 'success' in form && form.success
|
||||
? { email: form.email, inviteUrl: form.inviteUrl }
|
||||
: null
|
||||
);
|
||||
|
||||
async function copyInvite(url: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
copied = true;
|
||||
toast.success('Enlace de invitación copiado');
|
||||
setTimeout(() => (copied = false), 2000);
|
||||
} catch {
|
||||
toast.error('No se pudo copiar el enlace');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||
<UserPlus class="h-6 w-6" /> Alta de usuarios (invitaciones)
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Invita a un usuario a una organización del workspace. Recibe un enlace de un solo uso para
|
||||
fijar su contraseña y activarse.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if created}
|
||||
<Card.Root class="border-emerald-500/40">
|
||||
<Card.Content class="pt-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<MailCheck class="mt-0.5 h-5 w-5 text-emerald-600" />
|
||||
<div class="min-w-0 flex-1 text-sm">
|
||||
<p class="font-medium">Invitación enviada a {created.email}</p>
|
||||
<p class="mt-1 text-muted-foreground">
|
||||
Si el correo no llega, comparte este enlace directamente:
|
||||
</p>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<input class="flex-1 font-mono text-xs {inputCls}" readonly value={created.inviteUrl} />
|
||||
<Button variant="outline" size="sm" onclick={() => copyInvite(String(created.inviteUrl))}>
|
||||
{#if copied}<Check class="h-4 w-4" />{:else}<Copy class="h-4 w-4" />{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Nueva invitación</Card.Title>
|
||||
<Card.Description>
|
||||
{#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}
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/invite"
|
||||
use:enhance={() => {
|
||||
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();
|
||||
}
|
||||
};
|
||||
}}
|
||||
>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
|
||||
<span class="font-medium">Email del usuario *</span>
|
||||
<input type="email" class={inputCls} name="email" required value={prev('email')} />
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Organización *</span>
|
||||
{#if data.canListTenants}
|
||||
<select class={inputCls} name="tenant_slug" required value={prev('tenant_slug')}>
|
||||
<option value="" disabled>Selecciona…</option>
|
||||
{#each data.tenants as t (t.slug)}
|
||||
<option value={t.slug}>{t.name} ({t.slug})</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else}
|
||||
<input
|
||||
class="font-mono {inputCls}"
|
||||
name="tenant_slug"
|
||||
required
|
||||
pattern="[a-z0-9-]+"
|
||||
placeholder="empresa-abc"
|
||||
title="Slug del tenant (minúsculas, dígitos y guiones)"
|
||||
value={prev('tenant_slug')}
|
||||
/>
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Rol *</span>
|
||||
<select class={inputCls} name="role" required value={prev('role') || 'user'}>
|
||||
{#each data.roles as r (r)}
|
||||
<option value={r}>{r}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end border-t pt-4">
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? 'Enviando…' : 'Enviar invitación'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
Reference in New Issue
Block a user