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:
@@ -37,13 +37,33 @@ async def create_invite(
|
||||
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 ""
|
||||
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 ""
|
||||
|
||||
# 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)
|
||||
return await service.create_invite(
|
||||
data=data,
|
||||
created_by=created_by,
|
||||
tenant_slug=tenant_slug,
|
||||
user_access_token=credentials.credentials,
|
||||
user_access_token=kc_token or credentials.credentials,
|
||||
)
|
||||
|
||||
@@ -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