Merge branch 'development' into feature/catalogos-generales-permisos

This commit is contained in:
2026-05-06 11:13:40 -06:00
12 changed files with 432 additions and 264 deletions

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { getContext, onMount } from "svelte";
import { onMount } from "svelte";
import { page } from "$app/state";
import { useSidebar } from "$lib/components/ui/sidebar/context.svelte.js";
import { getSidebarData } from "$lib/components/sidebar/modules";
import NavMain from "./nav-main.svelte";
@@ -15,9 +16,8 @@
...restProps
}: ComponentProps<typeof Sidebar.Root> = $props();
// Obtener datos del usuario desde el contexto (viene de Keycloak vía +layout.server.ts)
const userData = getContext<any>('user');
const userTenants = getContext<{ id: number; name: string; slug: string }[]>('userTenants') ?? [];
// Leer siempre de page.data para que el sidebar reaccione tras invalidateAll()
const userTenants = $derived((page.data.userTenants as { id: number; name: string; slug: string }[]) ?? []);
// Obtener datos del sidebar con traducciones
const sidebarData = getSidebarData();
@@ -25,15 +25,21 @@
// Combinar los datos estáticos del sidebar con los datos del usuario de Keycloak
const data = $derived({
...sidebarData,
user: userData
user: page.data.user
? {
name: userData.name || userData.preferred_username || "",
email: userData.email || "",
avatar: userData.avatar_url || "/avatars/default.jpg",
name: _displayName(page.data.user),
email: page.data.user.email || "",
avatar: page.data.user.avatar_url || "/avatars/default.jpg",
}
: sidebarData.user,
});
function _displayName(u: any): string {
const first = u.first_name || u.given_name || "";
const last = u.last_name || u.family_name || "";
return (first + " " + last).trim() || u.name || u.preferred_username || "";
}
const sidebar = useSidebar();
onMount(() => {

View File

@@ -285,20 +285,34 @@ export async function validateAuth(
if (profileResponse.ok) {
const profileData = await profileResponse.json();
// Combinar datos de Keycloak con datos del perfil
// Combinar datos de Keycloak con datos del perfil.
// Prioridad para nombre: caché local del perfil > JWT claims.
return {
...keycloakData,
avatar_url: profileData.avatar_url,
phone: profileData.phone,
bio: profileData.bio,
preferences: profileData.preferences
id: profileData.id || keycloakData.id || keycloakData.sub,
username: profileData.username || keycloakData.username || keycloakData.preferred_username || '',
email: profileData.email || keycloakData.email || '',
first_name: profileData.first_name || keycloakData.first_name || keycloakData.given_name || '',
last_name: profileData.last_name || keycloakData.last_name || keycloakData.family_name || '',
avatar_url: profileData.avatar_url || null,
phone: profileData.phone || null,
bio: profileData.bio || null,
preferences: profileData.preferences || {}
};
}
} catch (profileError) {
console.warn('⚠️ [API] No se pudo cargar el perfil del usuario, usando solo datos de Keycloak');
}
return keycloakData;
// Fallback: map raw JWT claim names to the expected field names
const nameParts = (keycloakData.name || '').split(' ');
return {
...keycloakData,
id: keycloakData.id || keycloakData.sub,
username: keycloakData.username || keycloakData.preferred_username || '',
first_name: keycloakData.first_name || keycloakData.given_name || nameParts[0] || '',
last_name: keycloakData.last_name || keycloakData.family_name || nameParts.slice(1).join(' ') || '',
};
} catch (error) {
// Si es un redirect, re-lanzarlo
if (error && typeof error === 'object' && 'status' in error && 'location' in error) {

View File

@@ -7,6 +7,11 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { setAccessTokenCookies } from '$lib/server/access-token-cookie';
import { getAccessTokenFromCookies } from '$lib/server/access-token-cookie';
// Disable client-side rendering to prevent SvelteKit from making a second
// __data.json request that would consume the one-time relay token twice.
export const csr = false;
export const load: PageServerLoad = async ({ url, cookies }) => {
const relayToken = url.searchParams.get('relay');
@@ -18,21 +23,22 @@ export const load: PageServerLoad = async ({ url, cookies }) => {
// If there is already a valid session, skip the exchange to avoid
// re-using a one-time relay token (e.g. browser tab reload or prefetch).
const existingToken = cookies.get('access_token') || cookies.get('access_token_0');
const existingToken = getAccessTokenFromCookies(cookies);
if (existingToken) {
console.log('[SSO] sesión existente detectada, redirigiendo sin exchange');
throw redirect(303, '/dashboard');
}
// 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}/`;
// SSO exchange must call the Hub that GENERATED the relay token.
// HUB_URL is the canonical public Hub (workspace.aduanasoft.com) — where the
// App Launcher runs and where relay tokens are stored.
// INTERNAL_HUB_URL is a local mirror only used for token validation in the backend.
const hubUrl = (
process.env.HUB_URL ||
process.env.VITE_HUB_URL ||
'http://localhost:8001'
).replace(/\/+$/, '');
const baseUrl = `${hubUrl}/`;
let response: Response;
try {
@@ -47,7 +53,22 @@ export const load: PageServerLoad = async ({ url, cookies }) => {
if (!response.ok) {
const body = await response.json().catch(() => ({}));
const detail = body?.detail || 'sso_exchange_failed';
const detail: string = body?.detail || 'sso_exchange_failed';
console.error('[SSO] exchange falló:', response.status, detail);
// If the token is "invalid/used", a concurrent request may have already
// succeeded and set cookies. Redirect to /dashboard — if the session is
// valid it will load; if not, the dashboard layout will redirect to /login.
const tokenAlreadyUsed =
detail.toLowerCase().includes('inválido') ||
detail.toLowerCase().includes('invalido') ||
detail.toLowerCase().includes('invalid') ||
detail.toLowerCase().includes('used') ||
detail.toLowerCase().includes('expired');
if (tokenAlreadyUsed) {
throw redirect(303, '/dashboard');
}
throw redirect(303, `/login?error=${encodeURIComponent(detail)}`);
}

View File

@@ -54,9 +54,12 @@
updateCsvImportBanner();
});
// Hacer disponible el usuario en el contexto para los componentes hijos
// Hacer disponible el usuario en el contexto para los componentes hijos.
// El sidebar ya lee de page.data directamente; este contexto lo usan otros componentes.
setContext('user', data.user);
setContext('userTenants', data.userTenants ?? []);
// Actualizar el contexto reactive al cambiar data.user (post invalidateAll)
$effect(() => { setContext('user', data.user); });
// ── Manejar expiración de sesión ────────────────────────────────────────
function handleSessionExpired(e: Event) {

View File

@@ -1,42 +1,18 @@
/**
* Server-side load y actions para gestión de perfil de usuario
*/
import { fail, redirect } from '@sveltejs/kit';
import { fail } from '@sveltejs/kit';
import { authenticatedFetch } from '$lib/server/api';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies, fetch }) => {
try {
const response = await authenticatedFetch(
'v1/core/users/me/profile',
{
method: 'GET',
},
cookies,
fetch,
'/login' // Redirigir a login si no está autenticado
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return {
profile: null,
error: errorData.detail || 'Error al cargar el perfil'
};
}
const profile = await response.json();
return {
profile,
error: null
};
} catch (err: any) {
console.error('Error loading profile:', err);
return {
profile: null,
error: 'Error al cargar el perfil'
};
}
export const load: PageServerLoad = async ({ parent }) => {
// El layout /dashboard ya cargó los datos del usuario via validateAuth.
// Reutilizamos esos datos en lugar de hacer una llamada duplicada.
const { user } = await parent();
return {
profile: user ?? null,
error: user ? null : 'Error al cargar el perfil'
};
};
export const actions: Actions = {
@@ -86,10 +62,9 @@ export const actions: Actions = {
}
}
// Agregar avatar_url si se subió exitosamente
if (avatarUrl) {
updateData.avatar_url = avatarUrl;
}
// No incluir avatar_url en el PUT /me/profile: el POST /me/avatar ya persistió
// el valor correcto (clave S3 o ruta local) en UserTenant. Enviar aquí la URL
// pública sobrescribiría esa clave y rompería el endpoint de servicio de imágenes.
try {
const response = await authenticatedFetch(

View File

@@ -3,7 +3,6 @@
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Textarea } from '$lib/components/ui/textarea';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Avatar, AvatarFallback, AvatarImage } from '$lib/components/ui/avatar';
import { getBackendAssetUrl } from '$lib/utils';
@@ -120,7 +119,9 @@
error = '';
success = '';
return async ({ update }) => {
await update();
// reset:false evita que SvelteKit limpie el formulario nativo.
// update() ya llama a invalidateAll internamente.
await update({ reset: false });
saving = false;
};
}}
@@ -200,22 +201,6 @@
</div>
</div>
<div class="space-y-2.5">
<Label for="email" class="text-sm font-medium">Email</Label>
<Input
id="email"
name="email"
type="email"
value={profile.email || ''}
placeholder="tu@email.com"
class="transition-colors"
required
/>
<p class="text-xs text-muted-foreground">
Cambiar el email puede requerir verificación
</p>
</div>
<div class="space-y-2.5">
<Label for="phone" class="text-sm font-medium">Teléfono</Label>
<Input
@@ -227,21 +212,6 @@
class="transition-colors"
/>
</div>
<div class="space-y-2.5">
<Label for="bio" class="text-sm font-medium">Biografía</Label>
<Textarea
id="bio"
name="bio"
value={profile.bio || ''}
placeholder="Cuéntanos algo sobre ti..."
rows={4}
class="resize-none transition-colors"
/>
<p class="text-xs text-muted-foreground">
Máximo 500 caracteres
</p>
</div>
</CardContent>
</Card>
@@ -263,6 +233,13 @@
<Input value={profile.id} disabled class="font-mono text-xs bg-muted/50" />
</div>
</div>
<div class="space-y-2.5">
<Label class="text-sm font-medium">Email</Label>
<Input value={profile.email || ''} disabled class="bg-muted/50" />
<p class="text-xs text-muted-foreground">
Para cambiar tu email accede a tu perfil en el portal de Aduanasoft.
</p>
</div>
</CardContent>
</Card>
</div>