Merge branch 'development' into feature/reporte-bak
This commit is contained in:
8
frontend/src/app.d.ts
vendored
8
frontend/src/app.d.ts
vendored
@@ -7,7 +7,13 @@ declare global {
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
// interface PageData {}
|
||||
interface PageData {
|
||||
licenseError?: {
|
||||
type: string;
|
||||
message: string;
|
||||
status: number;
|
||||
};
|
||||
}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
|
||||
@@ -229,6 +229,18 @@ async function fetchApi<T = any>(
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// Incluir tenant override para flujo SSO multi-tenant.
|
||||
// sso_tenant_pub es una cookie no-HttpOnly que el servidor setea junto con sso_tenant_id.
|
||||
if (browser) {
|
||||
const tenantPub = document.cookie
|
||||
.split('; ')
|
||||
.find((c) => c.startsWith('sso_tenant_pub='))
|
||||
?.split('=')[1];
|
||||
if (tenantPub) {
|
||||
headers['X-Tenant-Override'] = tenantPub;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
...options,
|
||||
@@ -236,24 +248,32 @@ async function fetchApi<T = any>(
|
||||
credentials: 'include' // Importante: envía cookies con cada request
|
||||
});
|
||||
|
||||
// Si recibimos 401 o 403 y no es el endpoint de refresh, intentar refrescar el token
|
||||
if ((response.status === 401 || response.status === 403) && !endpoint.includes('/auth/refresh') && retryCount === 0) {
|
||||
// Si es 403 (Forbidden), mostrar toast de permisos insuficientes
|
||||
if (response.status === 403) {
|
||||
if (browser) {
|
||||
toast.error('No tienes permisos para realizar esta acción', {
|
||||
duration: 4000,
|
||||
description: 'Contacta a tu administrador si crees que esto es un error'
|
||||
});
|
||||
}
|
||||
// Retornar el error 403 sin intentar refresh
|
||||
const data = await response.json();
|
||||
return {
|
||||
error: data.detail || 'No tienes permisos para realizar esta acción',
|
||||
status: 403
|
||||
};
|
||||
// 403 = permisos, no autenticación: nunca intentar refresh.
|
||||
if (response.status === 403 && !endpoint.includes('/auth/refresh') && retryCount === 0) {
|
||||
if (browser) {
|
||||
toast.error('No tienes permisos para realizar esta acción', {
|
||||
duration: 4000,
|
||||
description: 'Contacta a tu administrador si crees que esto es un error'
|
||||
});
|
||||
}
|
||||
const data = await response.json();
|
||||
return {
|
||||
error: data.detail || 'No tienes permisos para realizar esta acción',
|
||||
status: 403
|
||||
};
|
||||
}
|
||||
|
||||
// 402 = licencia inválida/expirada: no intentar refresh.
|
||||
if (response.status === 402 && !endpoint.includes('/auth/refresh') && retryCount === 0) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
return {
|
||||
error: data.message || data.detail || 'Licencia inválida o expirada',
|
||||
status: 402
|
||||
};
|
||||
}
|
||||
|
||||
// Solo 401 dispara silent refresh.
|
||||
if (response.status === 401 && !endpoint.includes('/auth/refresh') && retryCount === 0) {
|
||||
// Si es 401, intentar refrescar el token
|
||||
isRefreshing = true;
|
||||
|
||||
@@ -682,7 +702,16 @@ export const api = {
|
||||
api.post('/v1/auth/refresh/', { refresh_token: refreshToken }),
|
||||
logout: (data: { refresh_token: string, username?: string }) => api.post('/v1/auth/logout', data, { keepalive: true }),
|
||||
me: () => api.get('/v1/auth/me/'),
|
||||
health: () => api.get('/health')
|
||||
health: () => api.get('/health'),
|
||||
register: (data: {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
tenant_slug: string;
|
||||
invite_token?: string;
|
||||
}) => api.post('/v1/auth/register', data),
|
||||
},
|
||||
|
||||
tenants: {
|
||||
|
||||
@@ -405,18 +405,14 @@ export const logout = async () => {
|
||||
deleteCookie('access_token');
|
||||
// La cookie HttpOnly del refresh_token la limpia el servidor
|
||||
|
||||
// Logout de Keycloak JS si estaba autenticado con SSO
|
||||
if (keycloakInstance?.authenticated) {
|
||||
// Logout unificado (SSO y password): POST al logout route del servidor.
|
||||
// Evita redirección visible al endpoint de Keycloak.
|
||||
if (keycloakInstance) {
|
||||
try {
|
||||
await fetch('/logout', { method: 'POST' });
|
||||
} catch { }
|
||||
await keycloakInstance.logout({
|
||||
redirectUri: window.location.origin + '/login'
|
||||
});
|
||||
return;
|
||||
keycloakInstance.clearToken();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Para login con password: POST al logout route del servidor
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/logout';
|
||||
|
||||
@@ -124,11 +124,16 @@
|
||||
</script>
|
||||
|
||||
<Sheet.Root bind:open={helpStore.isOpen}>
|
||||
<Sheet.Trigger
|
||||
class="fixed right-6 bottom-6 z-50 flex h-14 w-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-transform hover:scale-110 active:scale-95"
|
||||
aria-label="Ayuda"
|
||||
>
|
||||
<HelpCircle size={28} />
|
||||
<Sheet.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<button
|
||||
{...props}
|
||||
class="fixed right-6 bottom-6 z-50 flex h-14 w-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-transform hover:scale-110 active:scale-95"
|
||||
aria-label="Ayuda"
|
||||
>
|
||||
<HelpCircle size={28} />
|
||||
</button>
|
||||
{/snippet}
|
||||
</Sheet.Trigger>
|
||||
<Sheet.Content side="right" class="w-[400px] sm:w-[540px]">
|
||||
<Sheet.Header>
|
||||
|
||||
100
frontend/src/lib/components/license-error-screen.svelte
Normal file
100
frontend/src/lib/components/license-error-screen.svelte
Normal file
@@ -0,0 +1,100 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { logout } from '$lib/auth';
|
||||
|
||||
interface LicenseError {
|
||||
type: string;
|
||||
message: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
let { error }: { error: LicenseError } = $props();
|
||||
|
||||
const isHubOffline = error.type === 'HUB_OFFLINE' || error.type === 'HUB_ERROR';
|
||||
|
||||
function handleLogout() {
|
||||
void logout();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-[calc(100vh-4rem)] flex-col items-center justify-center p-8">
|
||||
<div class="flex max-w-md flex-col items-center gap-6 text-center">
|
||||
<!-- Icon -->
|
||||
{#if isHubOffline}
|
||||
<div class="flex h-20 w-20 items-center justify-center rounded-full bg-yellow-100 dark:bg-yellow-900/30">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-10 w-10 text-yellow-600 dark:text-yellow-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex h-20 w-20 items-center justify-center rounded-full bg-destructive/10">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-10 w-10 text-destructive"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Heading -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-foreground">
|
||||
{#if isHubOffline}
|
||||
Servicio de licencias no disponible
|
||||
{:else}
|
||||
Acceso suspendido
|
||||
{/if}
|
||||
</h1>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{error.message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Help text -->
|
||||
<div class="rounded-lg border bg-muted/50 px-4 py-3 text-sm text-muted-foreground">
|
||||
{#if isHubOffline}
|
||||
El servidor de licencias no está disponible en este momento. Por favor, inténtalo de nuevo
|
||||
en unos minutos o contacta a soporte si el problema persiste.
|
||||
{:else if error.type === 'LICENSE_ERROR'}
|
||||
Tu organización no cuenta con una licencia activa para acceder al sistema. Contacta a tu
|
||||
administrador o al equipo de soporte para regularizar tu suscripción.
|
||||
{:else}
|
||||
No tienes permisos para acceder al sistema. Contacta a tu administrador.
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3">
|
||||
{#if isHubOffline}
|
||||
<Button variant="outline" onclick={() => window.location.reload()}>
|
||||
Reintentar
|
||||
</Button>
|
||||
{/if}
|
||||
<Button variant="destructive" onclick={handleLogout}>
|
||||
Cerrar sesión
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,27 +1,27 @@
|
||||
<script lang="ts">
|
||||
import * as Card from "$lib/components/ui/card/index.js";
|
||||
import * as Card from "$lib/components/ui/card/index.ts";
|
||||
import {
|
||||
FieldGroup,
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldDescription,
|
||||
} from "$lib/components/ui/field/index.js";
|
||||
import { Input } from "$lib/components/ui/input/index.js";
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
} from "$lib/components/ui/field/index.ts";
|
||||
import { Input } from "$lib/components/ui/input/index.ts";
|
||||
import { Button } from "$lib/components/ui/button/index.ts";
|
||||
import { cn } from "$lib/utils.ts";
|
||||
import faviconUrl from '$lib/assets/favicon.svg';
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { page } from '$app/state';
|
||||
import { enhance } from '$app/forms';
|
||||
import { loginWithProvider } from '$lib/sso';
|
||||
import { loginWithProvider } from '$lib/sso.ts';
|
||||
import { onMount, tick } from 'svelte';
|
||||
|
||||
let { class: className, ...restProps }: HTMLAttributes<HTMLDivElement> = $props();
|
||||
|
||||
const id = $props.id();
|
||||
|
||||
let username = $state('demo');
|
||||
let password = $state('demo123');
|
||||
let username = $state('');
|
||||
let password = $state('');
|
||||
let tenantSlug = $state('');
|
||||
let loading = $state(false);
|
||||
// step 1 = credenciales, step 2 = selección de organización
|
||||
@@ -32,12 +32,18 @@
|
||||
// Descubrimiento de tenants
|
||||
type TenantInfo = { id: number; name: string; slug: string };
|
||||
let tenants = $state<TenantInfo[]>([]);
|
||||
let discoveryError = $state('');
|
||||
|
||||
const error = $derived(page.form?.error || '');
|
||||
const error = $derived(discoveryError || page.form?.error || '');
|
||||
|
||||
// Limpiar todo el localStorage y cookies al montar el componente de login
|
||||
onMount(() => {
|
||||
clearAllData();
|
||||
// Si viene ?tenant= en la URL (ej: después del registro), pre-seleccionar
|
||||
const urlTenant = new URL(window.location.href).searchParams.get('tenant');
|
||||
if (urlTenant) {
|
||||
tenantSlug = urlTenant;
|
||||
}
|
||||
});
|
||||
|
||||
// Función para limpiar cookies del cliente
|
||||
@@ -61,22 +67,25 @@
|
||||
}
|
||||
|
||||
async function fetchTenants(): Promise<TenantInfo[]> {
|
||||
discoveryError = '';
|
||||
try {
|
||||
const apiBase = (import.meta.env.VITE_API_URL || '').replace(/\/+$/, '');
|
||||
// Llama a /login SIN tenant_slug: el backend verifica credenciales primero,
|
||||
// luego devuelve las orgs. Sin contraseña válida no se revela nada.
|
||||
const res = await fetch(`${apiBase}/v1/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
// { status: "choose_tenant", tenants: [...] }
|
||||
// Múltiples tenants: { status: "choose_tenant", tenants: [...] }
|
||||
if (data.tenants) return data.tenants;
|
||||
// Un solo tenant: Hub devuelve token directo con data.tenant
|
||||
if (data.access_token && data.tenant) return [data.tenant];
|
||||
} else {
|
||||
discoveryError = data.detail || 'Error de autenticación';
|
||||
}
|
||||
} catch {
|
||||
// ignore — el server action mostrará el error de autenticación
|
||||
discoveryError = 'Error de conexión con el servidor';
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -132,8 +141,11 @@
|
||||
} else if (tenants.length > 1) {
|
||||
// Varias orgs: mostrar selector
|
||||
step = 2;
|
||||
} else if (discoveryError) {
|
||||
// Error claro del Hub (sin licencia, credenciales inválidas, etc.)
|
||||
// No hacer submit — el error ya se muestra en discoveryError
|
||||
} else {
|
||||
// 0 orgs: enviar igual, el backend rechazará
|
||||
// 0 orgs sin error: enviar igual, el backend rechazará
|
||||
readyToSubmit = true;
|
||||
await tick();
|
||||
formEl?.requestSubmit();
|
||||
@@ -142,11 +154,12 @@
|
||||
}
|
||||
loading = true;
|
||||
return async ({ update, result }) => {
|
||||
await update();
|
||||
await update({ reset: false });
|
||||
loading = false;
|
||||
readyToSubmit = false;
|
||||
if (result.type === 'failure') {
|
||||
clearClientCookies();
|
||||
step = 1;
|
||||
}
|
||||
};
|
||||
}}
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
|
||||
<Sidebar.Root {collapsible} {...restProps}>
|
||||
<Sidebar.Header>
|
||||
<TeamSwitcher />
|
||||
<TeamSwitcher {userTenants} />
|
||||
</Sidebar.Header>
|
||||
<Sidebar.Content>
|
||||
<NavMain items={data.navMain} />
|
||||
|
||||
@@ -7,9 +7,20 @@
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { getBackendAssetUrl } from '$lib/utils';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
|
||||
interface Tenant {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
let { userTenants = [] }: { userTenants: Tenant[] } = $props();
|
||||
|
||||
const sidebar = useSidebar();
|
||||
|
||||
let switchingTenant = $state(false);
|
||||
|
||||
// Derivar la URL del logo usando el endpoint específico
|
||||
let activeCompanyLogoUrl = $derived(
|
||||
companyStore.activeCompany?.logo
|
||||
@@ -24,11 +35,70 @@
|
||||
companyStore.activeCompany?.name?.slice(0, 2).toUpperCase() || 'CO'
|
||||
);
|
||||
|
||||
// Fallback en degradé cuando no hay logo cargado
|
||||
const fallbackBg =
|
||||
'radial-gradient(circle at 30% 30%, rgba(0,0,0,0.08), rgba(0,0,0,0.12)), linear-gradient(135deg, rgba(99,102,241,0.12), rgba(14,165,233,0.18))';
|
||||
function readCookie(name: string): string | null {
|
||||
if (typeof document === 'undefined') return null;
|
||||
const value = `; ${document.cookie}`;
|
||||
const parts = value.split(`; ${name}=`);
|
||||
if (parts.length === 2) return parts.pop()?.split(';').shift() ?? null;
|
||||
return null;
|
||||
}
|
||||
|
||||
const logoBg = $derived(activeCompanyLogoUrl ? `url(${activeCompanyLogoUrl})` : fallbackBg);
|
||||
let activeTenantPubId = $derived.by<number | null>(() => {
|
||||
const fromCookie = readCookie('sso_tenant_pub');
|
||||
if (fromCookie && !Number.isNaN(Number(fromCookie))) return Number(fromCookie);
|
||||
return null;
|
||||
});
|
||||
|
||||
async function switchTenant(tenant: Tenant) {
|
||||
if (switchingTenant) return;
|
||||
switchingTenant = true;
|
||||
try {
|
||||
const res = await fetch('/api-sveltekit/auth/switch-tenant', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tenant_id: tenant.id }),
|
||||
credentials: 'include',
|
||||
});
|
||||
if (res.ok) {
|
||||
companyStore.clear();
|
||||
await invalidateAll();
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
console.error('[team-switcher] switch-tenant error:', err);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[team-switcher] fetch error:', e);
|
||||
} finally {
|
||||
switchingTenant = false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeIdentity(value: string | undefined | null): string {
|
||||
return (value ?? '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
let tenantIdentitySet = $derived.by(() => {
|
||||
const set = new Set<string>();
|
||||
for (const tenant of userTenants) {
|
||||
set.add(normalizeIdentity(tenant.name));
|
||||
set.add(normalizeIdentity(tenant.slug));
|
||||
}
|
||||
set.delete('');
|
||||
return set;
|
||||
});
|
||||
|
||||
// Excluir del listado de companias cualquier registro que realmente represente al tenant.
|
||||
let myCompanies = $derived(
|
||||
companyStore.companies.filter((company) => !tenantIdentitySet.has(normalizeIdentity(company.name)))
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
const active = companyStore.activeCompany;
|
||||
if (!active) return;
|
||||
if (!tenantIdentitySet.has(normalizeIdentity(active.name))) return;
|
||||
if (myCompanies.length === 0) return;
|
||||
void companyStore.setActiveCompany(myCompanies[0], true);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Sidebar.Menu>
|
||||
@@ -99,18 +169,42 @@
|
||||
side={sidebar.isMobile ? 'bottom' : 'right'}
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenu.Label class="text-xs text-muted-foreground">Mis Compañías</DropdownMenu.Label>
|
||||
<DropdownMenu.Label class="text-xs text-muted-foreground">Tenant</DropdownMenu.Label>
|
||||
{#if userTenants.length === 0}
|
||||
<DropdownMenu.Item disabled class="gap-2 p-2">
|
||||
<span class="text-muted-foreground">Sin tenant asignado</span>
|
||||
</DropdownMenu.Item>
|
||||
{:else}
|
||||
{#each userTenants as tenant (tenant.id)}
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => switchTenant(tenant)}
|
||||
class="cursor-pointer gap-2 p-2"
|
||||
disabled={switchingTenant}
|
||||
>
|
||||
<div class="flex size-6 items-center justify-center rounded-md border bg-muted">
|
||||
<BuildingIcon class="size-3.5" />
|
||||
</div>
|
||||
<span class="truncate font-medium">{tenant.name}</span>
|
||||
{#if activeTenantPubId === tenant.id}
|
||||
<CheckIcon class="ml-auto size-4 text-primary" />
|
||||
{/if}
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
{/if}
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<DropdownMenu.Label class="text-xs text-muted-foreground">Mis compañías</DropdownMenu.Label>
|
||||
|
||||
{#if companyStore.loading}
|
||||
<DropdownMenu.Item disabled class="gap-2 p-2">
|
||||
<span class="text-muted-foreground">Cargando...</span>
|
||||
</DropdownMenu.Item>
|
||||
{:else if companyStore.companies.length === 0}
|
||||
{:else if myCompanies.length === 0}
|
||||
<DropdownMenu.Item disabled class="gap-2 p-2">
|
||||
<span class="text-muted-foreground">No hay compañías disponibles</span>
|
||||
<span class="text-muted-foreground">No tienes compañías disponibles</span>
|
||||
</DropdownMenu.Item>
|
||||
{:else}
|
||||
{#each companyStore.companies as company, index (company.id)}
|
||||
{#each myCompanies as company, index (company.id)}
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => companyStore.setActiveCompany(company)}
|
||||
class="cursor-pointer gap-2 p-2"
|
||||
|
||||
@@ -82,10 +82,11 @@ export function clearAuthTokens(cookies: Cookies) {
|
||||
/**
|
||||
* Crea headers de autorización con el token Bearer
|
||||
*/
|
||||
export function createAuthHeaders(token: string, additionalHeaders?: Record<string, string>) {
|
||||
export function createAuthHeaders(token: string, additionalHeaders?: Record<string, string>, tenantOverride?: string) {
|
||||
return {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
...(tenantOverride ? { 'X-Tenant-Override': tenantOverride } : {}),
|
||||
...additionalHeaders
|
||||
};
|
||||
}
|
||||
@@ -162,6 +163,9 @@ export async function authenticatedFetch(
|
||||
// Construir URL completa
|
||||
const url = endpoint.startsWith('http') ? endpoint : `${baseUrl}${endpoint}`;
|
||||
|
||||
// Leer tenant override de cookie SSO (flujo multi-tenant relay)
|
||||
const tenantOverride = cookies.get('sso_tenant_id');
|
||||
|
||||
// Crear AbortController para timeout
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => {
|
||||
@@ -174,7 +178,7 @@ export async function authenticatedFetch(
|
||||
const isFormData = options.body instanceof FormData;
|
||||
const headers = isFormData
|
||||
? { 'Authorization': `Bearer ${accessToken}`, ...(options.headers as Record<string, string> || {}) }
|
||||
: createAuthHeaders(accessToken, options.headers as Record<string, string>);
|
||||
: createAuthHeaders(accessToken, options.headers as Record<string, string>, tenantOverride);
|
||||
|
||||
let response = await fetch(url, {
|
||||
...options,
|
||||
@@ -205,7 +209,7 @@ export async function authenticatedFetch(
|
||||
// Si el body es FormData, no incluir Content-Type
|
||||
const newHeaders = isFormData
|
||||
? { 'Authorization': `Bearer ${newToken}`, ...(options.headers as Record<string, string> || {}) }
|
||||
: createAuthHeaders(newToken, options.headers as Record<string, string>);
|
||||
: createAuthHeaders(newToken, options.headers as Record<string, string>, tenantOverride);
|
||||
|
||||
response = await fetch(url, {
|
||||
...options,
|
||||
|
||||
@@ -112,8 +112,14 @@ class CompanyStore {
|
||||
}
|
||||
} else {
|
||||
console.error('Error loading companies:', response.error);
|
||||
// Si falla la carga (ej: 401), limpiar el store
|
||||
if (response.status === 401) {
|
||||
if (response.status === 402) {
|
||||
const { toast } = await import('svelte-sonner');
|
||||
toast.error('Licencia inactiva', {
|
||||
duration: 8000,
|
||||
description: response.error || 'Tu licencia no está activa para este tenant. Contacta al administrador.'
|
||||
});
|
||||
this.clear();
|
||||
} else if (response.status === 401) {
|
||||
this.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +1,82 @@
|
||||
/**
|
||||
* Endpoint server-side para cambiar de tenant sin exponer el refresh_token al cliente.
|
||||
*
|
||||
* Flujo:
|
||||
* 1. Cliente llama POST /api-sveltekit/auth/switch-tenant con { tenant_slug }
|
||||
* 2. Este servidor lee access_token y refresh_token de las cookies (HttpOnly).
|
||||
* 3. Llama al backend /v1/auth/switch-tenant con ambos tokens.
|
||||
* 4. Si es exitoso, actualiza las cookies con los nuevos tokens.
|
||||
* 5. Retorna ok al cliente para que recargue la página.
|
||||
* Dos modos:
|
||||
* - { tenant_id } → flujo SSO relay: solo actualiza cookie sso_tenant_id (override de tenant)
|
||||
* - { tenant_slug } → flujo login clásico: re-emite tokens KC para el nuevo tenant
|
||||
*/
|
||||
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import { getServerApiUrl, getAuthTokens, setAuthTokens } from '$lib/server/api';
|
||||
|
||||
export const POST = async ({ request, cookies, fetch }: RequestEvent) => {
|
||||
const { tenant_slug } = await request.json();
|
||||
const body = await request.json();
|
||||
const { tenant_id, tenant_slug } = body as { tenant_id?: number; tenant_slug?: string };
|
||||
|
||||
if (!tenant_slug) {
|
||||
return json({ error: 'tenant_slug is required' }, { status: 400 });
|
||||
if (!tenant_id && !tenant_slug) {
|
||||
return json({ error: 'tenant_id or tenant_slug is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken } = getAuthTokens(cookies);
|
||||
|
||||
if (!accessToken || !refreshToken) {
|
||||
if (!accessToken) {
|
||||
return json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Modo SSO relay: validar acceso vía Hub y actualizar cookie de override
|
||||
if (tenant_id) {
|
||||
try {
|
||||
const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, '');
|
||||
const tenantsRes = await fetch(`${hubUrl}/api/v1/auth/my-tenants`, {
|
||||
headers: { 'Authorization': `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!tenantsRes.ok) {
|
||||
return json({ error: 'Could not validate tenant access' }, { status: 403 });
|
||||
}
|
||||
const tenants: { id: number }[] = await tenantsRes.json();
|
||||
const hasAccess = tenants.some((t) => t.id === tenant_id);
|
||||
if (!hasAccess) {
|
||||
return json({ error: 'Access denied to tenant' }, { status: 403 });
|
||||
}
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
cookies.set('sso_tenant_id', String(tenant_id), {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
});
|
||||
cookies.set('sso_tenant_pub', String(tenant_id), {
|
||||
path: '/',
|
||||
httpOnly: false,
|
||||
secure: isProduction,
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
});
|
||||
cookies.delete('active_company_id', { path: '/' });
|
||||
return json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error('[switch-tenant] SSO mode error:', error);
|
||||
return json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Modo login clásico: re-emitir tokens KC para el nuevo tenant
|
||||
if (!refreshToken) {
|
||||
return json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const baseUrl = getServerApiUrl();
|
||||
|
||||
const response = await fetch(`${baseUrl}v1/auth/switch-tenant`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${accessToken}`
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({ tenant_slug, refresh_token: refreshToken })
|
||||
body: JSON.stringify({ tenant_slug, refresh_token: refreshToken }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -44,15 +85,13 @@ export const POST = async ({ request, cookies, fetch }: RequestEvent) => {
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Actualizar cookies con los nuevos tokens del nuevo tenant
|
||||
setAuthTokens(cookies, data.access_token, data.refresh_token);
|
||||
// Limpiar la compañía activa para que el dashboard recargue con el nuevo tenant
|
||||
cookies.delete('active_company_id', { path: '/' });
|
||||
|
||||
cookies.delete('sso_tenant_id', { path: '/' });
|
||||
cookies.delete('sso_tenant_pub', { path: '/' });
|
||||
return json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error('[switch-tenant] Error:', error);
|
||||
console.error('[switch-tenant] Classic mode error:', error);
|
||||
return json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
101
frontend/src/routes/auth/sso/+page.server.ts
Normal file
101
frontend/src/routes/auth/sso/+page.server.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* SSO auto-login page for Anexo76.
|
||||
* The Hub App Launcher redirects here with ?relay=<token> after generating a relay token.
|
||||
* This server-side load function exchanges the relay token for KC tokens via the
|
||||
* Hub backend, sets HttpOnly cookies, and redirects to /dashboard.
|
||||
*/
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ url, cookies }) => {
|
||||
const relayToken = url.searchParams.get('relay');
|
||||
console.log('[SSO] relay token presente:', !!relayToken);
|
||||
|
||||
if (!relayToken) {
|
||||
throw redirect(303, '/login?error=sso_missing_token');
|
||||
}
|
||||
|
||||
// SSO exchange must call Hub backend, not Anexo76 backend.
|
||||
// Use INTERNAL_HUB_URL for server-to-server communication.
|
||||
let hubUrl = process.env.INTERNAL_HUB_URL;
|
||||
if (!hubUrl) {
|
||||
hubUrl = process.env.VITE_HUB_URL;
|
||||
// Fallback: replace localhost with hub-backend for Docker
|
||||
hubUrl = hubUrl?.replace('localhost', 'host.docker.internal').replace('127.0.0.1', 'host.docker.internal');
|
||||
}
|
||||
const baseUrl = hubUrl?.endsWith('/') ? hubUrl : `${hubUrl}/`;
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${baseUrl}api/v1/auth/sso-exchange`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ relay_token: relayToken }),
|
||||
});
|
||||
} catch (err) {
|
||||
throw redirect(303, '/login?error=sso_hub_unreachable');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
const detail = body?.detail || 'sso_exchange_failed';
|
||||
throw redirect(303, `/login?error=${encodeURIComponent(detail)}`);
|
||||
}
|
||||
|
||||
const tokens = await response.json();
|
||||
console.log('[SSO] exchange exitoso, tokens recibidos:', {
|
||||
hasAccessToken: !!tokens.access_token,
|
||||
accessTokenLen: tokens.access_token?.length,
|
||||
hasRefreshToken: !!tokens.refresh_token,
|
||||
tenant_id: tokens.tenant_id,
|
||||
tenant_slug: tokens.tenant_slug,
|
||||
});
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
console.log('[SSO] NODE_ENV:', process.env.NODE_ENV, '→ isProduction:', isProduction);
|
||||
|
||||
// access_token — NO HttpOnly (client JS reads it for Bearer headers)
|
||||
cookies.set('access_token', tokens.access_token, {
|
||||
path: '/',
|
||||
httpOnly: false,
|
||||
secure: isProduction,
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
});
|
||||
|
||||
// refresh_token — HttpOnly (never exposed to JS)
|
||||
if (tokens.refresh_token) {
|
||||
cookies.set('refresh_token', tokens.refresh_token, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
});
|
||||
}
|
||||
|
||||
// sso_tenant_id — HttpOnly cookie con el tenant seleccionado.
|
||||
// El backend lo pasa como X-Tenant-Override en Hub /auth/me para que
|
||||
// devuelva el tenant correcto aunque el KC token tenga otro tenant baked in.
|
||||
if (tokens.tenant_id) {
|
||||
cookies.set('sso_tenant_id', String(tokens.tenant_id), {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
});
|
||||
// sso_tenant_pub — companion no-HttpOnly para que el cliente JS pueda
|
||||
// leer el tenant override e incluirlo como X-Tenant-Override en fetch directo al backend.
|
||||
// No es un secreto (solo un ID numérico; Hub valida UserTenant en cada request).
|
||||
cookies.set('sso_tenant_pub', String(tokens.tenant_id), {
|
||||
path: '/',
|
||||
httpOnly: false,
|
||||
secure: isProduction,
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
});
|
||||
}
|
||||
console.log('[SSO] cookies configuradas, redirigiendo a /dashboard');
|
||||
throw redirect(303, '/dashboard');
|
||||
};
|
||||
11
frontend/src/routes/auth/sso/+page.svelte
Normal file
11
frontend/src/routes/auth/sso/+page.svelte
Normal file
@@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
// This page is never rendered — the server-side load always redirects.
|
||||
// It exists only to satisfy SvelteKit's file-based routing requirement.
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen items-center justify-center">
|
||||
<div class="flex flex-col items-center gap-4 text-center">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-2 border-blue-500 border-t-transparent"></div>
|
||||
<p class="text-sm text-slate-500">Iniciando sesión automáticamente…</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,16 +1,17 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
import {
|
||||
validateAuth,
|
||||
getUserCompanies,
|
||||
getAuthTokens,
|
||||
clearAuthTokens,
|
||||
authenticatedFetch
|
||||
getUserCompanies,
|
||||
clearAuthTokens
|
||||
} from '$lib/server/api';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
|
||||
// Verificar si existe el token en las cookies
|
||||
const { accessToken } = getAuthTokens(cookies);
|
||||
console.log('[dashboard layout] access_token presente:', !!accessToken, '| url:', url.pathname);
|
||||
|
||||
// Si no hay token, redirigir al login, pero excluir la ruta /login para evitar bucle
|
||||
if (!accessToken && url.pathname !== '/login') {
|
||||
@@ -28,18 +29,29 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
|
||||
// Cargar las compañías del usuario en el servidor (SSR)
|
||||
const companies = await getUserCompanies(cookies, fetch);
|
||||
|
||||
// Cargar los tenants del usuario para el selector de organización
|
||||
let userTenants: { id: number; name: string; slug: string; is_active: boolean }[] = [];
|
||||
// Si la cookie active_company_id apunta a una compañía que ya no existe, limpiarla
|
||||
const cookieCompanyId = cookies.get('active_company_id');
|
||||
if (cookieCompanyId) {
|
||||
const cookieId = parseInt(cookieCompanyId);
|
||||
const stillExists = companies.some((c) => c.id === cookieId);
|
||||
if (!stillExists) {
|
||||
cookies.delete('active_company_id', { path: '/' });
|
||||
}
|
||||
}
|
||||
|
||||
// Cargar los tenants del usuario desde Hub (fuente de verdad multi-tenant)
|
||||
let userTenants: { id: number; name: string; slug: string }[] = [];
|
||||
try {
|
||||
const tenantsRes = await authenticatedFetch(
|
||||
`v1/core/user-tenants/${userData.sub}`,
|
||||
{},
|
||||
cookies,
|
||||
fetch
|
||||
);
|
||||
const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, '');
|
||||
const tenantOverride = cookies.get('sso_tenant_id');
|
||||
const tenantsRes = await fetch(`${hubUrl}/api/v1/auth/my-tenants`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
...(tenantOverride ? { 'X-Tenant-Override': tenantOverride } : {}),
|
||||
},
|
||||
});
|
||||
if (tenantsRes.ok) {
|
||||
const tenantsData = await tenantsRes.json();
|
||||
userTenants = tenantsData.tenants ?? [];
|
||||
userTenants = await tenantsRes.json();
|
||||
}
|
||||
} catch {
|
||||
// No bloquear el dashboard si falla la carga de tenants
|
||||
@@ -70,7 +82,6 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
|
||||
}
|
||||
|
||||
// Para cualquier otro error (conexión, etc), limpiar token y redirigir
|
||||
console.error('🔐 [Dashboard] Error validando token:', error);
|
||||
clearAuthTokens(cookies);
|
||||
throw redirect(303, redirectOnFail);
|
||||
}
|
||||
|
||||
@@ -20,8 +20,10 @@
|
||||
import type { SessionExpiredDetail } from '$lib/session-manager';
|
||||
import { authStore } from '$lib/auth';
|
||||
import { logout, getKeycloakInstance } from '$lib/auth';
|
||||
import LicenseErrorScreen from '$lib/components/license-error-screen.svelte';
|
||||
|
||||
let { data, children }: { data: LayoutData; children: any } = $props();
|
||||
type LicenseError = { type: string; message: string; status: number };
|
||||
let { data, children }: { data: LayoutData & { licenseError?: LicenseError }; children: any } = $props();
|
||||
|
||||
let csvImportBanner = $state(false);
|
||||
let csvImportBannerLabel = $state<string | null>(null);
|
||||
@@ -132,6 +134,9 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if data.licenseError}
|
||||
<LicenseErrorScreen error={data.licenseError} />
|
||||
{:else}
|
||||
<Sidebar.Provider>
|
||||
<AppSidebar />
|
||||
<Sidebar.Inset class="overflow-x-hidden">
|
||||
@@ -187,3 +192,4 @@
|
||||
|
||||
<!-- Diálogo de advertencia de sesión por inactividad -->
|
||||
<SessionTimeoutWarning />
|
||||
{/if}
|
||||
|
||||
@@ -30,8 +30,8 @@ export const actions = {
|
||||
}
|
||||
|
||||
try {
|
||||
const baseUrl = getServerApiUrl();
|
||||
const loginUrl = `${baseUrl}v1/auth/login`;
|
||||
const hubUrl = process.env.INTERNAL_HUB_URL || 'http://host.docker.internal:8001';
|
||||
const loginUrl = `${hubUrl}/api/v1/auth/login`;
|
||||
|
||||
const requestBody = {
|
||||
username,
|
||||
|
||||
@@ -1,14 +1,49 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ cookies }) => {
|
||||
export const POST: RequestHandler = async ({ cookies, request }) => {
|
||||
const refreshToken = cookies.get('refresh_token');
|
||||
|
||||
// Post-logout siempre va al workspace login, no al login local de Anexo76.
|
||||
// Desde el workspace el usuario puede volver a autenticarse con Microsoft
|
||||
// y el relay lo traerá de vuelta automáticamente.
|
||||
// HUB_URL es la URL pública del workspace (ej: https://workspace.aduanasoft.com)
|
||||
const hubPublicUrl = (env.HUB_URL || '').replace(/\/+$/, '');
|
||||
const workspaceLoginUrl = hubPublicUrl
|
||||
? `${hubPublicUrl}/login`
|
||||
: `${new URL(request.url).origin}/login`;
|
||||
|
||||
// Eliminar todas las cookies de autenticación
|
||||
cookies.delete('access_token', { path: '/' });
|
||||
cookies.delete('refresh_token', { path: '/' });
|
||||
|
||||
// Eliminar la cookie de la compañía activa
|
||||
cookies.delete('active_company_id', { path: '/' });
|
||||
cookies.delete('sso_tenant_id', { path: '/' });
|
||||
cookies.delete('sso_tenant_pub', { path: '/' });
|
||||
|
||||
// Redirigir al login
|
||||
throw redirect(303, '/login');
|
||||
// Llamar al Hub para revocar el refresh token.
|
||||
// La navegación final siempre debe volver al login local de anexo76
|
||||
// sin redirigir al endpoint de logout de Keycloak.
|
||||
if (refreshToken) {
|
||||
try {
|
||||
const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, '');
|
||||
|
||||
const res = await fetch(`${hubUrl}/api/v1/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
refresh_token: refreshToken,
|
||||
post_logout_redirect_uri: workspaceLoginUrl,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
await res.json().catch(() => ({}));
|
||||
}
|
||||
} catch (err: any) {
|
||||
// Si falla la llamada al Hub, caer al workspace login de todos modos
|
||||
}
|
||||
}
|
||||
|
||||
throw redirect(303, workspaceLoginUrl);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { api } from '$lib/api';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
// Parámetros del URL — se rellenan desde el link de invitación
|
||||
let inviteToken = $state('');
|
||||
let inviteTenantSlug = $state('');
|
||||
let inviteEmail = $state('');
|
||||
let isInviteFlow = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
const params = new URL(window.location.href).searchParams;
|
||||
inviteToken = params.get('invite_token') ?? '';
|
||||
inviteTenantSlug = params.get('tenant') ?? '';
|
||||
inviteEmail = params.get('email') ?? '';
|
||||
isInviteFlow = Boolean(inviteToken && inviteTenantSlug);
|
||||
|
||||
if (isInviteFlow) {
|
||||
// Pre-rellenar campos bloqueados desde la invitación
|
||||
formData.tenant_slug = inviteTenantSlug;
|
||||
if (inviteEmail) formData.email = inviteEmail;
|
||||
}
|
||||
});
|
||||
|
||||
let formData = $state({
|
||||
username: '',
|
||||
@@ -9,25 +31,24 @@
|
||||
confirmPassword: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
tenant_slug: 'aduanasoft' // Por defecto
|
||||
tenant_slug: 'aduanasoft'
|
||||
});
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state('');
|
||||
let passwordError = $state('');
|
||||
let success = $state(false);
|
||||
|
||||
async function handleRegister(e: Event) {
|
||||
e.preventDefault();
|
||||
error = '';
|
||||
passwordError = '';
|
||||
|
||||
// Validar que las contraseñas coincidan
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
passwordError = 'Las contraseñas no coinciden';
|
||||
return;
|
||||
}
|
||||
|
||||
// Validar longitud de contraseña
|
||||
if (formData.password.length < 8) {
|
||||
passwordError = 'La contraseña debe tener al menos 8 caracteres';
|
||||
return;
|
||||
@@ -36,22 +57,30 @@
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
const response = await api.auth.register({
|
||||
const payload: Record<string, string> = {
|
||||
username: formData.username,
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
first_name: formData.first_name,
|
||||
last_name: formData.last_name,
|
||||
tenant_slug: formData.tenant_slug
|
||||
});
|
||||
tenant_slug: formData.tenant_slug,
|
||||
};
|
||||
|
||||
if (inviteToken) {
|
||||
payload.invite_token = inviteToken;
|
||||
}
|
||||
|
||||
const response = await api.auth.register(payload as any);
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
|
||||
// Registro exitoso, redirigir al login
|
||||
alert(`¡Registro exitoso! Bienvenido ${response.data.username}`);
|
||||
goto('/login');
|
||||
|
||||
success = true;
|
||||
// Redirigir al login con el tenant pre-seleccionado tras unos segundos
|
||||
setTimeout(() => {
|
||||
goto(`/login?tenant=${encodeURIComponent(formData.tenant_slug)}`);
|
||||
}, 2000);
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Error al registrar usuario';
|
||||
} finally {
|
||||
@@ -77,6 +106,45 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Banner de invitación -->
|
||||
{#if isInviteFlow}
|
||||
<div class="mt-4 rounded-md bg-blue-50 border border-blue-200 p-4">
|
||||
<div class="flex">
|
||||
<svg class="h-5 w-5 text-blue-400 mt-0.5 mr-3 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-blue-800">Invitación válida</p>
|
||||
<p class="text-sm text-blue-700 mt-1">
|
||||
Estás registrándote en <strong>{inviteTenantSlug}</strong>.
|
||||
El enlace caduca en 48 horas y es de un solo uso.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Pantalla de éxito -->
|
||||
{#if success}
|
||||
<div class="mt-8 rounded-lg bg-white px-6 py-8 shadow text-center">
|
||||
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
|
||||
<svg class="h-6 w-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">¡Cuenta creada!</h3>
|
||||
<p class="mt-2 text-sm text-gray-600">
|
||||
Te hemos enviado un correo de verificación a <strong>{formData.email}</strong>.
|
||||
Confirma tu email antes de iniciar sesión.
|
||||
</p>
|
||||
<a
|
||||
href="/login"
|
||||
class="mt-6 inline-block rounded-md bg-blue-600 px-5 py-2 text-sm font-semibold text-white hover:bg-blue-500"
|
||||
>
|
||||
Ir al login
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Formulario de registro -->
|
||||
<div class="mt-8">
|
||||
<div class="rounded-lg bg-white px-6 py-8 shadow">
|
||||
@@ -108,9 +176,13 @@
|
||||
id="email"
|
||||
bind:value={formData.email}
|
||||
required
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500"
|
||||
readonly={isInviteFlow && Boolean(inviteEmail)}
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 {isInviteFlow && inviteEmail ? 'bg-gray-50 text-gray-500 cursor-not-allowed' : ''}"
|
||||
placeholder="usuario@ejemplo.com"
|
||||
/>
|
||||
{#if isInviteFlow && inviteEmail}
|
||||
<p class="mt-1 text-xs text-gray-500">El email está fijado por la invitación.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Nombre -->
|
||||
@@ -187,15 +259,25 @@
|
||||
<label for="tenant_slug" class="block text-sm font-medium text-gray-700">
|
||||
Empresa
|
||||
</label>
|
||||
<select
|
||||
id="tenant_slug"
|
||||
bind:value={formData.tenant_slug}
|
||||
required
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500"
|
||||
>
|
||||
<option value="aduanasoft">AduanaSoft</option>
|
||||
<!-- Agregar más tenants aquí -->
|
||||
</select>
|
||||
{#if isInviteFlow}
|
||||
<input
|
||||
type="text"
|
||||
id="tenant_slug"
|
||||
value={formData.tenant_slug}
|
||||
readonly
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm bg-gray-50 text-gray-500 cursor-not-allowed"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">Fijado por la invitación.</p>
|
||||
{:else}
|
||||
<select
|
||||
id="tenant_slug"
|
||||
bind:value={formData.tenant_slug}
|
||||
required
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500"
|
||||
>
|
||||
<option value="aduanasoft">AduanaSoft</option>
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Errores -->
|
||||
@@ -233,5 +315,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user