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:
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
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 { toast } from 'svelte-sonner';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
@@ -19,6 +20,13 @@
|
||||
let loading = $state(true);
|
||||
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
|
||||
const rolesByUser = $derived.by(() => {
|
||||
const m: Record<string, UserRole[]> = {};
|
||||
@@ -97,6 +105,44 @@
|
||||
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>
|
||||
|
||||
<div class="space-y-6">
|
||||
@@ -112,6 +158,41 @@
|
||||
{#if !companyId}
|
||||
<Card.Root><Card.Content class="pt-6 text-sm text-muted-foreground">Selecciona una compañía activa.</Card.Content></Card.Root>
|
||||
{: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.Header>
|
||||
<Card.Title>Usuarios ({users.length})</Card.Title>
|
||||
|
||||
Reference in New Issue
Block a user