feat(core): botón "dar de alta usuario" (invitación) en Usuarios + fix hub_admin

- Frontend Usuarios: formulario para dar de alta (email + rol) → invitación; muestra
  el enlace copiable por si el correo no llega.
- Backend invites: resuelve tenant_slug desde la compañía cuando el token no lo trae
  (hub_admin) y usa el token KC de la sesión (valkey) para crear el invite en el Hub.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ernesto Herrera
2026-07-17 16:12:11 -06:00
parent 868724d1f2
commit de9e35c501
2 changed files with 103 additions and 2 deletions

View File

@@ -37,13 +37,33 @@ async def create_invite(
required_permissions=["user.create"], required_permissions=["user.create"],
) )
# tenant_slug: del token si viene; si el usuario es hub_admin (sin tenant en el
# token), se resuelve desde la compañía destino (a76.company → core.tenants).
tenant_slug: str = current_user.get("tenant_slug") or "" tenant_slug: str = current_user.get("tenant_slug") or ""
if not tenant_slug:
from sqlalchemy import text as _text
row = db.execute(
_text(
"SELECT t.slug FROM a76.company c "
"JOIN core.tenants t ON t.id = c.tenant_id WHERE c.id = :c"
),
{"c": data.company_id},
).first()
if row and row[0]:
tenant_slug = row[0]
created_by: str = current_user.get("sub") or "" created_by: str = current_user.get("sub") or ""
# El invite se crea en el Hub: se necesita el token KC (la sesión local no la
# acepta el Hub). Se toma de la sesión (valkey) y se refresca si hace falta.
from core.hub_token import get_hub_access_token
kc_token = await get_hub_access_token(request)
service = InviteService(db) service = InviteService(db)
return await service.create_invite( return await service.create_invite(
data=data, data=data,
created_by=created_by, created_by=created_by,
tenant_slug=tenant_slug, tenant_slug=tenant_slug,
user_access_token=credentials.credentials, user_access_token=kc_token or credentials.credentials,
) )

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { Users, X } from '@lucide/svelte'; import { Users, X, UserPlus, Copy, Check } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { companyStore } from '$lib/stores/company.svelte'; import { companyStore } from '$lib/stores/company.svelte';
@@ -19,6 +20,13 @@
let loading = $state(true); let loading = $state(true);
let busy = $state<string | null>(null); let busy = $state<string | null>(null);
// Alta de usuario por invitación
let inviteEmail = $state('');
let inviteRoleId = $state<number | null>(null);
let inviting = $state(false);
let inviteUrl = $state<string | null>(null);
let copied = $state(false);
// user_id → asignaciones de rol // user_id → asignaciones de rol
const rolesByUser = $derived.by(() => { const rolesByUser = $derived.by(() => {
const m: Record<string, UserRole[]> = {}; const m: Record<string, UserRole[]> = {};
@@ -97,6 +105,44 @@
busy = null; busy = null;
} }
} }
async function inviteUser() {
if (!companyId) return;
const email = inviteEmail.trim();
if (!email || !email.includes('@')) {
toast.error('Ingresa un email válido');
return;
}
if (!inviteRoleId) {
toast.error('Selecciona el rol del usuario');
return;
}
inviting = true;
inviteUrl = null;
try {
const res = await usersAPI.invite({ email, company_id: companyId, role_id: inviteRoleId });
toast.success('Invitación creada');
inviteUrl = res.invite_url ?? null;
inviteEmail = '';
await load();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo dar de alta el usuario');
} finally {
inviting = false;
}
}
async function copyInvite() {
if (!inviteUrl) return;
try {
await navigator.clipboard.writeText(inviteUrl);
copied = true;
toast.success('Enlace copiado');
setTimeout(() => (copied = false), 2000);
} catch {
toast.error('No se pudo copiar');
}
}
</script> </script>
<div class="space-y-6"> <div class="space-y-6">
@@ -112,6 +158,41 @@
{#if !companyId} {#if !companyId}
<Card.Root><Card.Content class="pt-6 text-sm text-muted-foreground">Selecciona una compañía activa.</Card.Content></Card.Root> <Card.Root><Card.Content class="pt-6 text-sm text-muted-foreground">Selecciona una compañía activa.</Card.Content></Card.Root>
{:else} {:else}
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2"><UserPlus class="h-4 w-4" /> Dar de alta usuario</Card.Title>
<Card.Description>Se envía una invitación por email; el usuario crea su contraseña y queda en la compañía con el rol elegido.</Card.Description>
</Card.Header>
<Card.Content>
<div class="grid gap-4 sm:grid-cols-3">
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
<span class="font-medium">Email *</span>
<input type="email" class={inputCls} bind:value={inviteEmail} placeholder="persona@empresa.com" />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Rol *</span>
<select class={inputCls} bind:value={inviteRoleId}>
<option value={null} disabled>Selecciona…</option>
{#each roles as r (r.id)}<option value={r.id}>{r.name}</option>{/each}
</select>
</label>
</div>
<div class="mt-4 flex items-center justify-between gap-2">
{#if roles.length === 0}
<span class="text-xs text-muted-foreground">Primero crea roles en "Roles y permisos".</span>
{:else}<span></span>{/if}
<Button onclick={inviteUser} disabled={inviting || roles.length === 0}>{inviting ? 'Enviando…' : 'Invitar / dar de alta'}</Button>
</div>
{#if inviteUrl}
<div class="mt-3 flex items-center gap-2 rounded-md border bg-muted/40 p-2">
<span class="whitespace-nowrap text-xs text-muted-foreground">Si el correo no llega, comparte:</span>
<input class="flex-1 font-mono text-xs {inputCls}" readonly value={inviteUrl} />
<Button variant="outline" size="sm" onclick={copyInvite}>{#if copied}<Check class="h-4 w-4" />{:else}<Copy class="h-4 w-4" />{/if}</Button>
</div>
{/if}
</Card.Content>
</Card.Root>
<Card.Root> <Card.Root>
<Card.Header> <Card.Header>
<Card.Title>Usuarios ({users.length})</Card.Title> <Card.Title>Usuarios ({users.length})</Card.Title>