Merge branch 'development' into feature/catalogos-generales-permisos
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user