refactor: update SSO handling and remove deprecated components
- Changed the hub-net network configuration to external in docker-compose. - Removed the Single Sign-On (SSO) service implementation and associated login form component. - Enhanced authentication callback logic to improve error handling and redirect management. - Updated various routes to streamline login and authentication processes, ensuring proper redirection to workspace login. - Cleaned up unused code and improved overall structure for better maintainability.
This commit is contained in:
@@ -313,5 +313,5 @@ networks:
|
|||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
hub-net:
|
hub-net:
|
||||||
external: false
|
external: true
|
||||||
name: aduanasoft-hub_default
|
name: aduanasoft-hub_default
|
||||||
|
|||||||
@@ -1,383 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import * as Card from "$lib/components/ui/card/index";
|
|
||||||
import {
|
|
||||||
FieldGroup,
|
|
||||||
Field,
|
|
||||||
FieldLabel,
|
|
||||||
FieldDescription,
|
|
||||||
} from "$lib/components/ui/field/index";
|
|
||||||
import { Input } from "$lib/components/ui/input/index";
|
|
||||||
import { Button } from "$lib/components/ui/button/index";
|
|
||||||
import { cn } from "$lib/utils";
|
|
||||||
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 { onMount, tick } from 'svelte';
|
|
||||||
import { clearAccessTokenOnDocument } from '$lib/access-token-cookie-browser';
|
|
||||||
|
|
||||||
let { class: className, ...restProps }: HTMLAttributes<HTMLDivElement> = $props();
|
|
||||||
|
|
||||||
const id = $props.id();
|
|
||||||
|
|
||||||
let username = $state('');
|
|
||||||
let password = $state('');
|
|
||||||
let tenantSlug = $state('');
|
|
||||||
let loading = $state(false);
|
|
||||||
// step 1 = credenciales, step 2 = selección de organización
|
|
||||||
let step = $state<1 | 2>(1);
|
|
||||||
let readyToSubmit = $state(false);
|
|
||||||
let formEl: HTMLFormElement | undefined = $state();
|
|
||||||
|
|
||||||
// Descubrimiento de tenants
|
|
||||||
type TenantInfo = { id: number; name: string; slug: string };
|
|
||||||
let tenants = $state<TenantInfo[]>([]);
|
|
||||||
let discoveryError = $state('');
|
|
||||||
|
|
||||||
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
|
|
||||||
function clearClientCookies() {
|
|
||||||
if (typeof document !== 'undefined') {
|
|
||||||
const isSecure = window.location.protocol === 'https:';
|
|
||||||
const secureFlag = isSecure ? '; Secure' : '';
|
|
||||||
clearAccessTokenOnDocument();
|
|
||||||
document.cookie = `refresh_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; SameSite=Lax${secureFlag}`;
|
|
||||||
document.cookie = `active_company_id=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; SameSite=Lax${secureFlag}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearAllData() {
|
|
||||||
if (typeof localStorage !== 'undefined') {
|
|
||||||
localStorage.removeItem('access_token');
|
|
||||||
localStorage.removeItem('refresh_token');
|
|
||||||
localStorage.removeItem('activeCompanyId');
|
|
||||||
}
|
|
||||||
clearClientCookies();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchTenants(): Promise<TenantInfo[]> {
|
|
||||||
discoveryError = '';
|
|
||||||
try {
|
|
||||||
const apiBase = (import.meta.env.VITE_API_URL || '').replace(/\/+$/, '');
|
|
||||||
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) {
|
|
||||||
// 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 {
|
|
||||||
discoveryError = 'Error de conexión con el servidor';
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Llama al backend real desde el paso 2
|
|
||||||
function confirmTenant() {
|
|
||||||
if (!tenantSlug) return;
|
|
||||||
readyToSubmit = true;
|
|
||||||
formEl?.requestSubmit();
|
|
||||||
}
|
|
||||||
|
|
||||||
function goBack() {
|
|
||||||
step = 1;
|
|
||||||
tenantSlug = '';
|
|
||||||
tenants = [];
|
|
||||||
readyToSubmit = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleMicrosoftLogin() {
|
|
||||||
clearAllData();
|
|
||||||
if (tenantSlug) localStorage.setItem('pending_tenant_slug', tenantSlug);
|
|
||||||
loginWithProvider('microsoft');
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleGoogleLogin() {
|
|
||||||
clearAllData();
|
|
||||||
if (tenantSlug) localStorage.setItem('pending_tenant_slug', tenantSlug);
|
|
||||||
loginWithProvider('google');
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class={cn("flex flex-col gap-6", className)} {...restProps}>
|
|
||||||
<Card.Root class="overflow-hidden p-0 shadow-2xl border-0">
|
|
||||||
<Card.Content class="grid p-0 md:grid-cols-2">
|
|
||||||
<!-- Formulario -->
|
|
||||||
<form
|
|
||||||
bind:this={formEl}
|
|
||||||
class="p-8 md:p-10 flex flex-col justify-center"
|
|
||||||
method="POST"
|
|
||||||
use:enhance={async ({ cancel }) => {
|
|
||||||
// Interceptar solo en el paso 1 antes de hacer el submit real
|
|
||||||
if (!readyToSubmit) {
|
|
||||||
cancel();
|
|
||||||
loading = true;
|
|
||||||
tenants = await fetchTenants();
|
|
||||||
loading = false;
|
|
||||||
if (tenants.length === 1) {
|
|
||||||
// 1 sola org: login directo
|
|
||||||
tenantSlug = tenants[0].slug;
|
|
||||||
readyToSubmit = true;
|
|
||||||
await tick(); // esperar a que el DOM refleje tenantSlug antes de enviar
|
|
||||||
formEl?.requestSubmit();
|
|
||||||
} 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 sin error: enviar igual, el backend rechazará
|
|
||||||
readyToSubmit = true;
|
|
||||||
await tick();
|
|
||||||
formEl?.requestSubmit();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
loading = true;
|
|
||||||
return async ({ update, result }) => {
|
|
||||||
await update({ reset: false });
|
|
||||||
loading = false;
|
|
||||||
readyToSubmit = false;
|
|
||||||
if (result.type === 'failure') {
|
|
||||||
clearClientCookies();
|
|
||||||
step = 1;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FieldGroup>
|
|
||||||
<!-- Logo / Branding -->
|
|
||||||
<div class="flex flex-col items-center gap-3 text-center mb-2">
|
|
||||||
<img src={faviconUrl} alt="Anexo 76" class="w-14 h-14 rounded-2xl shadow-lg shadow-blue-200 dark:shadow-blue-900/50" />
|
|
||||||
<div>
|
|
||||||
<h1 class="text-2xl font-bold tracking-tight text-slate-900 dark:text-slate-100">Anexo 76</h1>
|
|
||||||
<p class="text-muted-foreground text-sm mt-0.5">
|
|
||||||
Sistema de Cumplimiento Fiscal y Aduanal
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if error}
|
|
||||||
<div class="flex items-start gap-3 rounded-xl bg-red-50 dark:bg-red-950/40 border border-red-200 dark:border-red-800 p-4 text-sm text-red-700 dark:text-red-400">
|
|
||||||
<svg class="w-4 h-4 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
||||||
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
|
|
||||||
</svg>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Campos ocultos siempre presentes para el submit en paso 2 -->
|
|
||||||
<input type="hidden" name="tenant_slug" value={tenantSlug} />
|
|
||||||
{#if step === 2}
|
|
||||||
<input type="hidden" name="username" value={username} />
|
|
||||||
<input type="hidden" name="password" value={password} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if step === 1}
|
|
||||||
<!-- PASO 1: Credenciales -->
|
|
||||||
<Field>
|
|
||||||
<FieldLabel for="username-{id}" class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">Usuario</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="username-{id}"
|
|
||||||
name="username"
|
|
||||||
type="text"
|
|
||||||
placeholder="usuario@empresa.com"
|
|
||||||
bind:value={username}
|
|
||||||
required
|
|
||||||
disabled={loading}
|
|
||||||
class="h-11"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<FieldLabel for="password-{id}" class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">Contraseña</FieldLabel>
|
|
||||||
<a href="##" class="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400 font-medium hover:underline underline-offset-2">
|
|
||||||
¿Olvidaste tu contraseña?
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
id="password-{id}"
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
bind:value={password}
|
|
||||||
required
|
|
||||||
disabled={loading}
|
|
||||||
class="h-11"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
class="h-11 w-full bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 font-semibold shadow-md shadow-blue-200 dark:shadow-blue-900/40 transition-all duration-200"
|
|
||||||
>
|
|
||||||
{#if loading}
|
|
||||||
<svg class="animate-spin w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
|
||||||
</svg>
|
|
||||||
Verificando...
|
|
||||||
{:else}
|
|
||||||
Iniciar sesión
|
|
||||||
{/if}
|
|
||||||
</Button>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<div class="relative flex items-center gap-3 my-1">
|
|
||||||
<div class="flex-1 h-px bg-border"></div>
|
|
||||||
<span class="text-xs text-muted-foreground font-medium px-1">o continúa con</span>
|
|
||||||
<div class="flex-1 h-px bg-border"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Field class="grid grid-cols-2 gap-3">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
type="button"
|
|
||||||
onclick={handleGoogleLogin}
|
|
||||||
disabled={loading}
|
|
||||||
class="h-11 border hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors"
|
|
||||||
>
|
|
||||||
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
|
||||||
<path d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z" fill="currentColor"/>
|
|
||||||
</svg>
|
|
||||||
<span class="sr-only">Google</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
type="button"
|
|
||||||
onclick={handleMicrosoftLogin}
|
|
||||||
disabled={loading}
|
|
||||||
class="h-11 border hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors"
|
|
||||||
>
|
|
||||||
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
|
||||||
<path d="M11.4 24H0V12.6h11.4V24zM24 24H12.6V12.6H24V24zM11.4 11.4H0V0h11.4v11.4zm12.6 0H12.6V0H24v11.4z" fill="currentColor"/>
|
|
||||||
</svg>
|
|
||||||
<span class="sr-only">Microsoft</span>
|
|
||||||
</Button>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<p class="text-center text-sm text-muted-foreground">
|
|
||||||
¿No tienes cuenta?{' '}
|
|
||||||
<a href="/register" class="font-semibold text-blue-600 hover:text-blue-700 dark:text-blue-400 hover:underline underline-offset-2">
|
|
||||||
Regístrate
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
{:else}
|
|
||||||
<!-- PASO 2: Selección de organización -->
|
|
||||||
<div class="flex flex-col gap-1 text-center">
|
|
||||||
<p class="text-sm font-medium text-slate-700 dark:text-slate-300">Selecciona tu organización</p>
|
|
||||||
<p class="text-xs text-muted-foreground">Tu cuenta tiene acceso a varias organizaciones</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
{#each tenants as t}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => (tenantSlug = t.slug)}
|
|
||||||
class="flex items-center gap-3 rounded-xl border-2 px-4 py-3 text-left transition-all duration-150 hover:border-blue-400 hover:bg-blue-50 dark:hover:bg-blue-950/40
|
|
||||||
{tenantSlug === t.slug
|
|
||||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-950/40'
|
|
||||||
: 'border-input bg-background'}"
|
|
||||||
>
|
|
||||||
<span class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900/50 text-blue-600 dark:text-blue-400 font-bold text-sm">
|
|
||||||
{t.name.charAt(0).toUpperCase()}
|
|
||||||
</span>
|
|
||||||
<span class="flex-1 text-sm font-medium text-slate-800 dark:text-slate-200">{t.name}</span>
|
|
||||||
{#if tenantSlug === t.slug}
|
|
||||||
<svg class="w-4 h-4 text-blue-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Field>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onclick={confirmTenant}
|
|
||||||
disabled={!tenantSlug || loading}
|
|
||||||
class="h-11 w-full bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 font-semibold shadow-md shadow-blue-200 dark:shadow-blue-900/40 transition-all duration-200"
|
|
||||||
>
|
|
||||||
{#if loading}
|
|
||||||
<svg class="animate-spin w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
|
||||||
</svg>
|
|
||||||
Iniciando sesión...
|
|
||||||
{:else}
|
|
||||||
Continuar
|
|
||||||
{/if}
|
|
||||||
</Button>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={goBack}
|
|
||||||
class="flex items-center justify-center gap-1.5 text-xs text-muted-foreground hover:text-slate-700 dark:hover:text-slate-300 transition-colors mx-auto"
|
|
||||||
>
|
|
||||||
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/>
|
|
||||||
</svg>
|
|
||||||
Volver al inicio de sesión
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</FieldGroup>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<!-- Panel imagen -->
|
|
||||||
<div class="relative hidden md:block overflow-hidden">
|
|
||||||
<!-- Fotografía de fondo -->
|
|
||||||
<img
|
|
||||||
src="/login-bg.jpg"
|
|
||||||
alt=""
|
|
||||||
class="absolute inset-0 w-full h-full object-cover object-center"
|
|
||||||
/>
|
|
||||||
<!-- Overlay degradado -->
|
|
||||||
<div class="absolute inset-0 bg-gradient-to-t from-slate-900/90 via-slate-900/40 to-slate-900/10"></div>
|
|
||||||
|
|
||||||
<!-- Contenido sobre la imagen -->
|
|
||||||
<div class="relative z-10 h-full flex flex-col justify-end p-10">
|
|
||||||
<div class="flex items-center gap-3 mb-4">
|
|
||||||
<img src={faviconUrl} alt="" class="w-9 h-9 rounded-xl" />
|
|
||||||
<span class="text-white font-bold text-lg tracking-tight">Anexo 76</span>
|
|
||||||
</div>
|
|
||||||
<blockquote class="space-y-2">
|
|
||||||
<p class="text-white text-xl font-semibold leading-snug">
|
|
||||||
"Cumplimiento fiscal simplificado para empresas que operan con el SAT."
|
|
||||||
</p>
|
|
||||||
<footer class="text-slate-300 text-sm">Sistema de Cumplimiento Fiscal y Aduanal</footer>
|
|
||||||
</blockquote>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card.Content>
|
|
||||||
</Card.Root>
|
|
||||||
<p class="px-6 text-center text-xs text-muted-foreground">
|
|
||||||
Al continuar, aceptas nuestros
|
|
||||||
<a href="##" class="underline hover:text-blue-600 underline-offset-2">Términos de Servicio</a>
|
|
||||||
y
|
|
||||||
<a href="##" class="underline hover:text-blue-600 underline-offset-2">Política de Privacidad</a>.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
157
frontend/src/lib/server/workspace-auth.ts
Normal file
157
frontend/src/lib/server/workspace-auth.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
import { redirect, type Cookies } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
const DEFAULT_WORKSPACE_BASE_URL = 'https://workspace.aduanasoft.com';
|
||||||
|
const RETURN_PATH_COOKIE = 'workspace_return_path';
|
||||||
|
|
||||||
|
function stripTrailingSlashes(value: string): string {
|
||||||
|
return value.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInternalOnlyHost(rawUrl: string): boolean {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(rawUrl);
|
||||||
|
const host = parsed.hostname.toLowerCase();
|
||||||
|
return host === 'host.docker.internal' || host === 'backend' || host === 'hub-keycloak';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWorkspaceBaseUrl(): string {
|
||||||
|
const candidates = [
|
||||||
|
(env.VITE_HUB_URL || '').trim(),
|
||||||
|
(env.HUB_URL || '').trim(),
|
||||||
|
DEFAULT_WORKSPACE_BASE_URL
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (!isInternalOnlyHost(candidate)) {
|
||||||
|
return stripTrailingSlashes(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_WORKSPACE_BASE_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkspaceLoginUrlOptions = {
|
||||||
|
/**
|
||||||
|
* URL del login del Hub sin `return_to`. Usar en `post_logout_redirect_uri` para que,
|
||||||
|
* tras logout en KC, el Hub aplique myApps() (launcher si el usuario tiene varias apps).
|
||||||
|
* Con `return_to` a Anexo76, el re-login siempre rebotaba a esa app aunque hubiera más.
|
||||||
|
*/
|
||||||
|
forPostLogout?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getWorkspaceLoginUrl(
|
||||||
|
systemBaseUrl: string,
|
||||||
|
options?: WorkspaceLoginUrlOptions
|
||||||
|
): string {
|
||||||
|
const workspaceBaseUrl = getWorkspaceBaseUrl();
|
||||||
|
if (options?.forPostLogout) {
|
||||||
|
return `${workspaceBaseUrl}/login`;
|
||||||
|
}
|
||||||
|
// return_to points to /login so that after Workspace auth the browser lands on
|
||||||
|
// /login, which immediately attempts a prompt=none KC auth.
|
||||||
|
const loginUrl = `${systemBaseUrl}/login`;
|
||||||
|
return `${workspaceBaseUrl}/login?return_to=${encodeURIComponent(loginUrl)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storeReturnPath(cookies: Cookies, path: string): void {
|
||||||
|
if (!path || !path.startsWith('/')) return;
|
||||||
|
cookies.set(RETURN_PATH_COOKIE, path, {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: env.NODE_ENV === 'production',
|
||||||
|
maxAge: 60 * 10
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPublicKeycloakBaseUrl(): string {
|
||||||
|
const configuredKeycloakUrl = (env.VITE_KEYCLOAK_URL || '').trim();
|
||||||
|
if (configuredKeycloakUrl) {
|
||||||
|
return stripTrailingSlashes(configuredKeycloakUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${getWorkspaceBaseUrl()}/kcauth`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getKeycloakRealm(): string {
|
||||||
|
return (env.KEYCLOAK_REALM || env.VITE_KEYCLOAK_REALM || 'master').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getKeycloakClientId(): string {
|
||||||
|
return (env.KEYCLOAK_CLIENT_ID || env.VITE_KEYCLOAK_CLIENT_ID || 'anexo76-frontend').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCleanReturnPath(url: URL): string {
|
||||||
|
const cleanParams = new URLSearchParams(url.searchParams);
|
||||||
|
cleanParams.delete('sso_verified');
|
||||||
|
|
||||||
|
const queryString = cleanParams.toString();
|
||||||
|
return queryString ? `${url.pathname}?${queryString}` : url.pathname;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storeWorkspaceReturnPath(cookies: Cookies, url: URL): string {
|
||||||
|
const returnPath = getCleanReturnPath(url);
|
||||||
|
|
||||||
|
cookies.set(RETURN_PATH_COOKIE, returnPath, {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: env.NODE_ENV === 'production',
|
||||||
|
maxAge: 60 * 10
|
||||||
|
});
|
||||||
|
|
||||||
|
return returnPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readWorkspaceReturnPath(cookies: Cookies, fallbackPath: string): string {
|
||||||
|
const storedReturnPath = cookies.get(RETURN_PATH_COOKIE);
|
||||||
|
if (storedReturnPath && storedReturnPath.startsWith('/')) {
|
||||||
|
return storedReturnPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallbackPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearWorkspaceReturnPath(cookies: Cookies): void {
|
||||||
|
cookies.delete(RETURN_PATH_COOKIE, { path: '/' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildKeycloakAuthorizationUrl(systemBaseUrl: string, redirectPath: string): string {
|
||||||
|
const keycloakBaseUrl = getPublicKeycloakBaseUrl();
|
||||||
|
const redirectUri = `${systemBaseUrl}/auth/callback`;
|
||||||
|
const state = JSON.stringify({ redirect_url: redirectPath });
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: getKeycloakClientId(),
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
response_type: 'code',
|
||||||
|
scope: 'openid',
|
||||||
|
prompt: 'none',
|
||||||
|
state
|
||||||
|
});
|
||||||
|
|
||||||
|
return `${keycloakBaseUrl}/realms/${getKeycloakRealm()}/protocol/openid-connect/auth?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redirectToWorkspaceLogin(cookies: Cookies, url: URL): never {
|
||||||
|
storeWorkspaceReturnPath(cookies, url);
|
||||||
|
throw redirect(303, getWorkspaceLoginUrl(url.origin));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redirectToKeycloakAuthorization(systemBaseUrl: string, redirectPath: string): never {
|
||||||
|
throw redirect(303, buildKeycloakAuthorizationUrl(systemBaseUrl, redirectPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildKeycloakLogoutUrl(systemBaseUrl: string): string {
|
||||||
|
const keycloakBaseUrl = getPublicKeycloakBaseUrl();
|
||||||
|
const workspaceLoginUrl = getWorkspaceLoginUrl(systemBaseUrl, { forPostLogout: true });
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: getKeycloakClientId(),
|
||||||
|
post_logout_redirect_uri: workspaceLoginUrl
|
||||||
|
});
|
||||||
|
|
||||||
|
return `${keycloakBaseUrl}/realms/${getKeycloakRealm()}/protocol/openid-connect/logout?${params.toString()}`;
|
||||||
|
}
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
/**
|
|
||||||
* Servicio de Single Sign-On (SSO) con proveedores externos
|
|
||||||
*/
|
|
||||||
import { browser } from '$app/environment';
|
|
||||||
|
|
||||||
// Tipos de proveedores SSO soportados
|
|
||||||
export type SSOProvider = 'microsoft' | 'google' | 'github';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Inicia el flujo de autenticación con un proveedor SSO
|
|
||||||
* @param provider - El proveedor SSO a utilizar
|
|
||||||
*/
|
|
||||||
export const loginWithProvider = async (provider: SSOProvider): Promise<void> => {
|
|
||||||
if (!browser) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Construir la URL de redirección al proveedor SSO
|
|
||||||
const keycloakUrl = import.meta.env.VITE_KEYCLOAK_URL;
|
|
||||||
const realm = import.meta.env.VITE_KEYCLOAK_REALM;
|
|
||||||
const clientId = import.meta.env.VITE_KEYCLOAK_CLIENT_ID;
|
|
||||||
|
|
||||||
// Validar que las variables de entorno estén configuradas
|
|
||||||
if (!keycloakUrl || !realm || !clientId) {
|
|
||||||
const missing = [];
|
|
||||||
if (!keycloakUrl) missing.push('VITE_KEYCLOAK_URL');
|
|
||||||
if (!realm) missing.push('VITE_KEYCLOAK_REALM');
|
|
||||||
if (!clientId) missing.push('VITE_KEYCLOAK_CLIENT_ID');
|
|
||||||
|
|
||||||
const errorMsg = `Configuración de Keycloak incompleta. Faltan las siguientes variables de entorno: ${missing.join(', ')}. Por favor, verifica tu archivo .env y reinicia el servidor de desarrollo.`;
|
|
||||||
console.error(errorMsg);
|
|
||||||
alert(errorMsg);
|
|
||||||
throw new Error(errorMsg);
|
|
||||||
}
|
|
||||||
|
|
||||||
const redirectUri = encodeURIComponent(window.location.origin + '/auth/callback');
|
|
||||||
|
|
||||||
// URL de login de Keycloak con el provider específico
|
|
||||||
const loginUrl = `${keycloakUrl}/realms/${realm}/protocol/openid-connect/auth?client_id=${clientId}&redirect_uri=${redirectUri}&response_type=code&scope=openid&kc_idp_hint=${provider}`;
|
|
||||||
|
|
||||||
// Redirigir al usuario al proveedor SSO
|
|
||||||
window.location.href = loginUrl;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error al iniciar login con ${provider}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Obtiene la lista de proveedores SSO disponibles
|
|
||||||
* Esta función podría consultar a Keycloak para obtener los providers configurados
|
|
||||||
*/
|
|
||||||
export const getAvailableProviders = async (): Promise<SSOProvider[]> => {
|
|
||||||
// Por ahora retornamos una lista estática
|
|
||||||
// En producción, esto debería consultarse desde Keycloak
|
|
||||||
return ['microsoft', 'google', 'github'];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Obtiene la configuración de visualización para un proveedor
|
|
||||||
*/
|
|
||||||
export const getProviderConfig = (provider: SSOProvider) => {
|
|
||||||
const configs = {
|
|
||||||
microsoft: {
|
|
||||||
name: 'Microsoft',
|
|
||||||
icon: '🪟',
|
|
||||||
color: 'bg-blue-600 hover:bg-blue-700'
|
|
||||||
},
|
|
||||||
google: {
|
|
||||||
name: 'Google',
|
|
||||||
icon: '🔍',
|
|
||||||
color: 'bg-red-600 hover:bg-red-700'
|
|
||||||
},
|
|
||||||
github: {
|
|
||||||
name: 'GitHub',
|
|
||||||
icon: '🐙',
|
|
||||||
color: 'bg-gray-800 hover:bg-gray-900'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return configs[provider];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Intercambia el código de autorización por tokens
|
|
||||||
*/
|
|
||||||
export const exchangeCodeForTokens = async (
|
|
||||||
code: string,
|
|
||||||
redirectUri: string
|
|
||||||
): Promise<{ access_token: string; refresh_token: string; id_token?: string }> => {
|
|
||||||
try {
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000/api/';
|
|
||||||
const baseUrl = API_BASE_URL.endsWith('/') ? API_BASE_URL : `${API_BASE_URL}/`;
|
|
||||||
|
|
||||||
const response = await fetch(`${baseUrl}v1/auth/exchange-code`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
code,
|
|
||||||
redirect_uri: redirectUri
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.detail || 'Error intercambiando código por tokens');
|
|
||||||
}
|
|
||||||
|
|
||||||
return await response.json();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error en exchangeCodeForTokens:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decodifica un JWT (sin verificar la firma)
|
|
||||||
* NOTA: Esta es una decodificación simple para obtener los claims.
|
|
||||||
* La verificación de la firma debe hacerse en el backend.
|
|
||||||
*/
|
|
||||||
export const decodeJWT = (token: string): any => {
|
|
||||||
try {
|
|
||||||
const parts = token.split('.');
|
|
||||||
if (parts.length !== 3) {
|
|
||||||
throw new Error('Token JWT inválido');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decodificar la parte del payload (segunda parte)
|
|
||||||
const payload = parts[1];
|
|
||||||
const decodedPayload = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
|
|
||||||
return JSON.parse(decodedPayload);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error decodificando JWT:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -32,7 +32,7 @@ export const load: PageServerLoad = async ({ cookies, fetch }) => {
|
|||||||
clearAuthTokens(cookies);
|
clearAuthTokens(cookies);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si no está autenticado, mostrar la página principal pública
|
// Si no está autenticado, mostrar la página principal pública
|
||||||
return {
|
return {
|
||||||
isAuthenticated: false
|
isAuthenticated: false
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect, isRedirect } from '@sveltejs/kit';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { setAccessTokenCookies } from '$lib/server/access-token-cookie';
|
import { setAccessTokenCookies } from '$lib/server/access-token-cookie';
|
||||||
|
import {
|
||||||
|
clearWorkspaceReturnPath,
|
||||||
|
getWorkspaceLoginUrl,
|
||||||
|
readWorkspaceReturnPath,
|
||||||
|
storeReturnPath,
|
||||||
|
} from '$lib/server/workspace-auth';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ url, cookies, fetch }) => {
|
export const load: PageServerLoad = async ({ url, cookies, fetch }) => {
|
||||||
// Obtener el código y state de los query params
|
// Obtener el código y state de los query params
|
||||||
@@ -10,13 +16,24 @@ export const load: PageServerLoad = async ({ url, cookies, fetch }) => {
|
|||||||
const errorDescription = url.searchParams.get('error_description');
|
const errorDescription = url.searchParams.get('error_description');
|
||||||
|
|
||||||
if (errorParam) {
|
if (errorParam) {
|
||||||
console.error('❌ [Callback Server] Error en autenticación:', errorParam, errorDescription);
|
console.error('❌ [Callback Server] KC auth error:', errorParam, errorDescription);
|
||||||
throw redirect(303, `/login?error=${encodeURIComponent(errorDescription || errorParam)}`);
|
// login_required means no KC session exists yet → send to Workspace login.
|
||||||
|
// Preserve the intended destination through the detour so /login can pick it up.
|
||||||
|
if (state) {
|
||||||
|
try {
|
||||||
|
const stateObj = JSON.parse(state);
|
||||||
|
const returnPath = stateObj.redirect_url;
|
||||||
|
if (returnPath && returnPath.startsWith('/') && returnPath !== '/login') {
|
||||||
|
storeReturnPath(cookies, returnPath);
|
||||||
|
}
|
||||||
|
} catch { /* ignore malformed state */ }
|
||||||
|
}
|
||||||
|
throw redirect(303, getWorkspaceLoginUrl(url.origin));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!code) {
|
if (!code) {
|
||||||
console.error('❌ [Callback Server] No se recibió código de autorización');
|
console.error('❌ [Callback Server] No se recibió código de autorización');
|
||||||
throw redirect(303, '/login?error=No se recibió código de autorización');
|
throw redirect(303, getWorkspaceLoginUrl(url.origin));
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -78,7 +95,7 @@ export const load: PageServerLoad = async ({ url, cookies, fetch }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Obtener la URL de redirección del state o ir al dashboard
|
// Obtener la URL de redirección del state o ir al dashboard
|
||||||
let redirectTo = '/dashboard';
|
let redirectTo = readWorkspaceReturnPath(cookies, '/dashboard');
|
||||||
if (state) {
|
if (state) {
|
||||||
try {
|
try {
|
||||||
const stateObj = JSON.parse(state);
|
const stateObj = JSON.parse(state);
|
||||||
@@ -87,12 +104,15 @@ export const load: PageServerLoad = async ({ url, cookies, fetch }) => {
|
|||||||
console.warn('⚠️ [Callback Server] No se pudo obtener redirect_url del state');
|
console.warn('⚠️ [Callback Server] No se pudo obtener redirect_url del state');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearWorkspaceReturnPath(cookies);
|
||||||
|
|
||||||
// Redirigir a la página de destino
|
// Redirigir a la página de destino
|
||||||
throw redirect(303, redirectTo);
|
throw redirect(303, redirectTo);
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
if (isRedirect(err)) throw err;
|
||||||
console.error('❌ [Callback Server] Error procesando autenticación:', err);
|
console.error('❌ [Callback Server] Error procesando autenticación:', err);
|
||||||
throw redirect(303, `/login?error=${encodeURIComponent(err.message || 'Error procesando autenticación')}`);
|
throw redirect(303, getWorkspaceLoginUrl(url.origin));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { setAccessTokenCookies } from '$lib/server/access-token-cookie';
|
import { setAccessTokenCookies } from '$lib/server/access-token-cookie';
|
||||||
|
import { redirectToWorkspaceLogin } from '$lib/server/workspace-auth';
|
||||||
|
|
||||||
// Disable client-side rendering to prevent SvelteKit from making a second
|
// Disable client-side rendering to prevent SvelteKit from making a second
|
||||||
// __data.json request that would consume the one-time relay token twice.
|
// __data.json request that would consume the one-time relay token twice.
|
||||||
@@ -17,7 +18,7 @@ export const load: PageServerLoad = async ({ url, cookies }) => {
|
|||||||
console.log('[SSO] relay token presente:', !!relayToken);
|
console.log('[SSO] relay token presente:', !!relayToken);
|
||||||
|
|
||||||
if (!relayToken) {
|
if (!relayToken) {
|
||||||
throw redirect(303, '/login?error=sso_missing_token');
|
redirectToWorkspaceLogin(cookies, url);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Limpiar sesión anterior para que el nuevo usuario reciba sus propias cookies.
|
// Limpiar sesión anterior para que el nuevo usuario reciba sus propias cookies.
|
||||||
@@ -51,7 +52,7 @@ export const load: PageServerLoad = async ({ url, cookies }) => {
|
|||||||
body: JSON.stringify({ relay_token: relayToken }),
|
body: JSON.stringify({ relay_token: relayToken }),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw redirect(303, '/login?error=sso_hub_unreachable');
|
redirectToWorkspaceLogin(cookies, url);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -72,10 +73,10 @@ export const load: PageServerLoad = async ({ url, cookies }) => {
|
|||||||
throw redirect(303, '/dashboard');
|
throw redirect(303, '/dashboard');
|
||||||
}
|
}
|
||||||
|
|
||||||
throw redirect(303, `/login?error=${encodeURIComponent(detail)}`);
|
redirectToWorkspaceLogin(cookies, url);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokens = await response.json();
|
let tokens = await response.json();
|
||||||
console.log('[SSO] exchange exitoso, tokens recibidos:', {
|
console.log('[SSO] exchange exitoso, tokens recibidos:', {
|
||||||
hasAccessToken: !!tokens.access_token,
|
hasAccessToken: !!tokens.access_token,
|
||||||
accessTokenLen: tokens.access_token?.length,
|
accessTokenLen: tokens.access_token?.length,
|
||||||
@@ -84,6 +85,36 @@ export const load: PageServerLoad = async ({ url, cookies }) => {
|
|||||||
tenant_slug: tokens.tenant_slug,
|
tenant_slug: tokens.tenant_slug,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Refresh proactivo ────────────────────────────────────────────────────
|
||||||
|
// Los tokens del relay fueron emitidos por KC via el browser (iss=IP:8085).
|
||||||
|
// El Hub backend valida contra KC interno (hub-keycloak:8080) → issuer mismatch → 401.
|
||||||
|
// Refrescando aquí: Anexo76 backend → Hub → KC interno → iss=hub-keycloak:8080 → válido.
|
||||||
|
if (tokens.refresh_token) {
|
||||||
|
try {
|
||||||
|
const internalApiUrl = (
|
||||||
|
process.env.INTERNAL_API_URL ||
|
||||||
|
process.env.VITE_API_URL ||
|
||||||
|
'http://backend:8000/api/'
|
||||||
|
).replace(/\/+$/, '');
|
||||||
|
const refreshRes = await fetch(`${internalApiUrl}/v1/auth/refresh`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ refresh_token: tokens.refresh_token }),
|
||||||
|
});
|
||||||
|
if (refreshRes.ok) {
|
||||||
|
const refreshed = await refreshRes.json();
|
||||||
|
if (refreshed.access_token && refreshed.refresh_token) {
|
||||||
|
tokens = { ...tokens, ...refreshed };
|
||||||
|
console.log('[SSO] tokens refrescados exitosamente (iss normalizado)');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('[SSO] refresh proactivo falló (status', refreshRes.status, ') — usando tokens originales del relay');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[SSO] refresh proactivo error (non-blocking):', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
console.log('[SSO] NODE_ENV:', process.env.NODE_ENV, '→ isProduction:', isProduction);
|
console.log('[SSO] NODE_ENV:', process.env.NODE_ENV, '→ isProduction:', isProduction);
|
||||||
|
|
||||||
|
|||||||
@@ -7,21 +7,22 @@ import {
|
|||||||
getUserCompanies,
|
getUserCompanies,
|
||||||
clearAuthTokens
|
clearAuthTokens
|
||||||
} from '$lib/server/api';
|
} from '$lib/server/api';
|
||||||
|
import {
|
||||||
|
redirectToWorkspaceLogin
|
||||||
|
} from '$lib/server/workspace-auth';
|
||||||
|
|
||||||
export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
|
export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
|
||||||
// Verificar si existe el token en las cookies
|
// Verificar si existe el token en las cookies
|
||||||
const { accessToken } = getAuthTokens(cookies);
|
const { accessToken } = getAuthTokens(cookies);
|
||||||
console.log('[dashboard layout] access_token presente:', !!accessToken, '| url:', url.pathname);
|
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) {
|
||||||
if (!accessToken && url.pathname !== '/login') {
|
redirectToWorkspaceLogin(cookies, url);
|
||||||
const redirectUrl = `/login?redirect=${encodeURIComponent(url.pathname)}`;
|
|
||||||
throw redirect(303, redirectUrl);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validar el token con el backend y obtener datos del usuario
|
// Validar el token con el backend y obtener datos del usuario
|
||||||
// La función validateAuth maneja automáticamente el refresh de tokens
|
// La función validateAuth maneja automáticamente el refresh de tokens
|
||||||
const redirectOnFail = `/login?redirect=${encodeURIComponent(url.pathname)}`;
|
const redirectOnFail = undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Primero my-companies: ejecuta get_current_user y puede crear tenant/empresa/usuario
|
// Primero my-companies: ejecuta get_current_user y puede crear tenant/empresa/usuario
|
||||||
@@ -75,15 +76,8 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si estamos ya en la página de login, no intentar redirigir de nuevo
|
|
||||||
if (url.pathname === '/login') {
|
|
||||||
console.error('🔐 [Dashboard] Error validando token en login page, limpiando cookies.');
|
|
||||||
clearAuthTokens(cookies);
|
|
||||||
return { authenticated: false, error: error };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Para cualquier otro error (conexión, etc), limpiar token y redirigir
|
// Para cualquier otro error (conexión, etc), limpiar token y redirigir
|
||||||
clearAuthTokens(cookies);
|
clearAuthTokens(cookies);
|
||||||
throw redirect(303, redirectOnFail);
|
redirectToWorkspaceLogin(cookies, url);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export const load: PageServerLoad = async ({ fetch, cookies, url, parent }) => {
|
|||||||
const accessToken = tokens.accessToken;
|
const accessToken = tokens.accessToken;
|
||||||
|
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
throw redirect(302, '/auth/login');
|
throw redirect(302, '/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -75,14 +75,14 @@ export const load: PageServerLoad = async ({ fetch, cookies, url, parent }) => {
|
|||||||
{},
|
{},
|
||||||
cookies,
|
cookies,
|
||||||
fetch,
|
fetch,
|
||||||
'/auth/login'
|
'/login'
|
||||||
),
|
),
|
||||||
authenticatedFetch(
|
authenticatedFetch(
|
||||||
'v1/public/reference_data/invoice-types?page=1&page_size=100',
|
'v1/public/reference_data/invoice-types?page=1&page_size=100',
|
||||||
{},
|
{},
|
||||||
cookies,
|
cookies,
|
||||||
fetch,
|
fetch,
|
||||||
'/auth/login'
|
'/login'
|
||||||
)
|
)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,85 +1,37 @@
|
|||||||
import { redirect, fail } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { clearAuthTokens, setAuthTokens, getServerApiUrl } from '$lib/server/api';
|
import { clearAuthTokens } from '$lib/server/api';
|
||||||
|
import {
|
||||||
|
getWorkspaceLoginUrl,
|
||||||
|
readWorkspaceReturnPath,
|
||||||
|
storeReturnPath,
|
||||||
|
redirectToKeycloakAuthorization
|
||||||
|
} from '$lib/server/workspace-auth';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||||
// Si hay un parámetro 'logout' en la URL, limpiar las cookies
|
|
||||||
if (url.searchParams.has('logout')) {
|
|
||||||
clearAuthTokens(cookies);
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Limpiar siempre las cookies de sesión anterior al cargar login
|
|
||||||
// Esto evita que se queden datos del tenant anterior
|
|
||||||
clearAuthTokens(cookies);
|
clearAuthTokens(cookies);
|
||||||
|
|
||||||
// Permitir acceso al login sin redirigir automáticamente
|
// Workspace redirige de vuelta aquí con ?sso_verified=1 después de que el usuario
|
||||||
// Esto evita bucles de redirección cuando el token existe pero puede estar expirado
|
// se autenticó en Workspace (que usa el mismo Keycloak central).
|
||||||
return {};
|
// En ese momento la sesión KC ya existe en el browser → prompt=none funciona sin
|
||||||
};
|
// mostrar ninguna pantalla de login.
|
||||||
|
if (url.searchParams.get('sso_verified') === '1') {
|
||||||
|
const existingReturnPath = readWorkspaceReturnPath(cookies, '');
|
||||||
|
const intendedPath =
|
||||||
|
existingReturnPath && existingReturnPath !== '/login'
|
||||||
|
? existingReturnPath
|
||||||
|
: (url.searchParams.get('redirect') || '/dashboard');
|
||||||
|
|
||||||
export const actions = {
|
storeReturnPath(cookies, intendedPath);
|
||||||
default: async ({ request, cookies, url, fetch }) => {
|
redirectToKeycloakAuthorization(url.origin, intendedPath);
|
||||||
const data = await request.formData();
|
|
||||||
const username = data.get('username')?.toString();
|
|
||||||
const password = data.get('password')?.toString();
|
|
||||||
const tenant_slug = data.get('tenant_slug')?.toString();
|
|
||||||
|
|
||||||
if (!username || !password || !tenant_slug) {
|
|
||||||
return fail(400, { error: 'Credenciales incorrectas' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const apiUrl = getServerApiUrl();
|
|
||||||
const loginUrl = `${apiUrl}v1/auth/login`;
|
|
||||||
|
|
||||||
const requestBody = {
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
tenant_slug
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await fetch(loginUrl, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(requestBody)
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return fail(response.status, {
|
|
||||||
error: result.detail || 'Error de autenticación',
|
|
||||||
username,
|
|
||||||
tenant_slug
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.access_token) {
|
|
||||||
// Establecer tokens usando la función centralizada
|
|
||||||
setAuthTokens(cookies, result.access_token, result.refresh_token);
|
|
||||||
|
|
||||||
// Redirigir al dashboard o a la URL original
|
|
||||||
const redirectUrl = url.searchParams.get('redirect') || '/dashboard';
|
|
||||||
throw redirect(303, redirectUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
return fail(500, { error: 'No se recibió token de autenticación' });
|
|
||||||
} catch (error) {
|
|
||||||
// Si es un redirect de SvelteKit, re-lanzarlo
|
|
||||||
if (error && typeof error === 'object' && 'status' in error && 'location' in error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return fail(500, {
|
|
||||||
error: 'Error de conexión con el servidor: ' + (error instanceof Error ? error.message : String(error)),
|
|
||||||
username,
|
|
||||||
tenant_slug
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} satisfies Actions;
|
|
||||||
|
// Sin sso_verified → primera visita o sesión expirada.
|
||||||
|
// Guardar la ruta deseada y mandar al Workspace a autenticar.
|
||||||
|
const intendedPath = url.searchParams.get('redirect') || '/dashboard';
|
||||||
|
if (intendedPath !== '/dashboard') {
|
||||||
|
storeReturnPath(cookies, intendedPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw redirect(303, getWorkspaceLoginUrl(url.origin));
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,9 +1 @@
|
|||||||
<script lang="ts">
|
<!-- Esta página nunca se renderiza: el load SSR siempre redirige al workspace. -->
|
||||||
import LoginForm from "$lib/components/login-form.svelte";
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="bg-gradient-to-br from-slate-100 via-blue-50 to-slate-200 dark:from-slate-950 dark:via-blue-950/30 dark:to-slate-900 flex min-h-svh flex-col items-center justify-center p-6 md:p-10">
|
|
||||||
<div class="w-full max-w-sm md:max-w-3xl">
|
|
||||||
<LoginForm />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -1,16 +1,10 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import { env } from '$env/dynamic/private';
|
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { clearAccessTokenCookies } from '$lib/server/access-token-cookie';
|
import { clearAccessTokenCookies } from '$lib/server/access-token-cookie';
|
||||||
|
import { buildKeycloakLogoutUrl, clearWorkspaceReturnPath } from '$lib/server/workspace-auth';
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ cookies, request }) => {
|
export const POST: RequestHandler = async ({ cookies, request }) => {
|
||||||
const refreshToken = cookies.get('refresh_token');
|
const systemBaseUrl = new URL(request.url).origin;
|
||||||
|
|
||||||
// Redirigir al workspace (Hub) — es el sistema central de autenticación.
|
|
||||||
const hubPublicUrl = (env.HUB_URL || '').replace(/\/+$/, '');
|
|
||||||
const postLogoutUrl = hubPublicUrl
|
|
||||||
? `${hubPublicUrl}/login`
|
|
||||||
: `${new URL(request.url).origin}/login`;
|
|
||||||
|
|
||||||
// Eliminar todas las cookies de autenticación (access_token puede estar fragmentado)
|
// Eliminar todas las cookies de autenticación (access_token puede estar fragmentado)
|
||||||
clearAccessTokenCookies(cookies);
|
clearAccessTokenCookies(cookies);
|
||||||
@@ -18,23 +12,7 @@ export const POST: RequestHandler = async ({ cookies, request }) => {
|
|||||||
cookies.delete('active_company_id', { path: '/' });
|
cookies.delete('active_company_id', { path: '/' });
|
||||||
cookies.delete('sso_tenant_id', { path: '/' });
|
cookies.delete('sso_tenant_id', { path: '/' });
|
||||||
cookies.delete('sso_tenant_pub', { path: '/' });
|
cookies.delete('sso_tenant_pub', { path: '/' });
|
||||||
|
clearWorkspaceReturnPath(cookies);
|
||||||
|
|
||||||
// Llamar al Hub para revocar el refresh token (best-effort).
|
throw redirect(303, buildKeycloakLogoutUrl(systemBaseUrl));
|
||||||
if (refreshToken) {
|
|
||||||
try {
|
|
||||||
const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, '');
|
|
||||||
await fetch(`${hubUrl}/api/v1/auth/logout`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
refresh_token: refreshToken,
|
|
||||||
post_logout_redirect_uri: postLogoutUrl,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Si falla la llamada al Hub, continuar de todos modos
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw redirect(303, postLogoutUrl);
|
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user