feat(crm,workspace): alta de organizaciones y usuarios vía Hub

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>
This commit is contained in:
Ernesto Herrera
2026-07-16 13:19:21 -06:00
parent 45f128a551
commit a613a7a6aa
8 changed files with 774 additions and 0 deletions

View File

@@ -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',

View 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);
});
});

View 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;
}

View 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 };
}

View File

@@ -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 };
}
};

View File

@@ -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>

View File

@@ -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
};
}
};

View 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>