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:
2026-05-11 17:17:37 -05:00
parent af415eb604
commit 52577b9e66
12 changed files with 266 additions and 664 deletions

View File

@@ -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>

View 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()}`;
}

View File

@@ -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;
}
};