diff --git a/frontend/src/lib/components/sidebar/app-launcher.svelte b/frontend/src/lib/components/sidebar/app-launcher.svelte index 1bdaae82..c74f05aa 100644 --- a/frontend/src/lib/components/sidebar/app-launcher.svelte +++ b/frontend/src/lib/components/sidebar/app-launcher.svelte @@ -1,21 +1,35 @@ @@ -44,27 +58,21 @@ >

Tus aplicaciones

- {#each systemStore.allowedSystems as sys (sys)} - {@const label = SYSTEM_LABELS[sys]} - {@const Icon = ICONS[sys]} - {@const active = systemStore.activeSystem === sys} - {@const canSwitch = systemStore.canSwitch} + {#each workspaceAppsStore.apps as app (app.id)} {/each} diff --git a/frontend/src/lib/server/workspace-apps.ts b/frontend/src/lib/server/workspace-apps.ts new file mode 100644 index 00000000..0bdb7cae --- /dev/null +++ b/frontend/src/lib/server/workspace-apps.ts @@ -0,0 +1,105 @@ +/** + * Consulta al Hub las aplicaciones del Workspace accesibles al usuario autenticado. + * + * Endpoint: GET /api/v1/auth/my-apps + * Respuesta (top-level): { routing: string, apps: [...] } + * - routing: decisión de routing del Hub (no se usa para redirigir; siempre se muestra el + * launcher para que el usuario elija). + * - apps: productos del Workspace accesibles, cada uno con su URL. + * + * Mismo patrón que la llamada a /api/v1/auth/my-tenants en +layout.server.ts. + */ + +import { dev } from '$app/environment'; +import { env } from '$env/dynamic/private'; +import type { WorkspaceApp } from '$lib/stores/workspace-apps.svelte'; + +export type MyAppsResponse = { + routing: string; + apps: WorkspaceApp[]; +}; + +const EMPTY_RESPONSE: MyAppsResponse = { routing: '', apps: [] }; + +function resolveHubUrl(): string { + return (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, ''); +} + +/** Item crudo de `apps` tal como lo devuelve el Hub en /api/v1/auth/my-apps. */ +type RawWorkspaceApp = { + id?: number | string; + name?: string; + slug?: string; + login_url?: string; + sso_url?: string; + image_url?: string | null; + description?: string | null; +}; + +/** Primer valor string no vacío. */ +function firstNonEmpty(...values: (string | null | undefined)[]): string | null { + for (const value of values) { + if (typeof value === 'string' && value.trim()) return value; + } + return null; +} + +/** Normaliza un item crudo de `apps` (shape del Hub) a WorkspaceApp para el launcher. */ +function normalizeWorkspaceApp(raw: unknown): WorkspaceApp | null { + if (!raw || typeof raw !== 'object') return null; + const app = raw as RawWorkspaceApp; + + // URL de entrada de la app (login_url). Para apps del mismo origin trae el `active_system` + // que usa el launcher para el cambio de sistema local; sso_url solo como respaldo. + const url = firstNonEmpty(app.login_url, app.sso_url); + if (!url) return null; // sin URL la app no es accionable en el launcher + + const id = app.id != null ? String(app.id) : (firstNonEmpty(app.slug) ?? url); + + return { + id, + name: firstNonEmpty(app.name, app.slug) ?? id, + slug: firstNonEmpty(app.slug), + url, + iconUrl: firstNonEmpty(app.image_url) + }; +} + +/** + * Obtiene las apps del Workspace usando el token del usuario. + * Nunca lanza: ante cualquier fallo degrada a respuesta vacía para no romper el dashboard + * (mismo criterio que la carga de tenants). + */ +export async function fetchMyApps( + accessToken: string, + fetch: typeof globalThis.fetch, + tenantOverride?: string | null +): Promise { + try { + const hubUrl = resolveHubUrl(); + const res = await fetch(`${hubUrl}/api/v1/auth/my-apps`, { + headers: { + Authorization: `Bearer ${accessToken}`, + ...(tenantOverride ? { 'X-Tenant-Override': tenantOverride } : {}) + } + }); + if (!res.ok) { + if (dev) console.log(`[my-apps] Hub respondió status ${res.status} en ${hubUrl} → sin apps`); + return EMPTY_RESPONSE; + } + + const data = (await res.json()) as { routing?: unknown; apps?: unknown }; + + const apps = Array.isArray(data.apps) + ? data.apps.map(normalizeWorkspaceApp).filter((app): app is WorkspaceApp => app !== null) + : []; + + return { + routing: typeof data.routing === 'string' ? data.routing : '', + apps + }; + } catch (error) { + if (dev) console.log('[my-apps] error llamando al Hub:', error); + return EMPTY_RESPONSE; + } +} diff --git a/frontend/src/lib/stores/workspace-apps.svelte.ts b/frontend/src/lib/stores/workspace-apps.svelte.ts new file mode 100644 index 00000000..3962b329 --- /dev/null +++ b/frontend/src/lib/stores/workspace-apps.svelte.ts @@ -0,0 +1,42 @@ +/** App del Workspace normalizada para el launcher. */ +export type WorkspaceApp = { + id: string; + name: string; + /** Identificador legible (subtítulo en la tarjeta del launcher). */ + slug: string | null; + /** URL de entrada de la app (login_url); para apps del mismo origin trae `?active_system`. */ + url: string; + iconUrl: string | null; +}; + +/** + * Store de las apps del Workspace/Hub accesibles al usuario (launcher de productos). + * Es independiente de `systemStore` (SCAF/SCAII): aquí viven los productos del Workspace, + * allá el sistema interno de Anexo76. + */ +class WorkspaceAppsStore { + _apps = $state([]); + _routing = $state(''); + + get apps() { + return this._apps; + } + get routing() { + return this._routing; + } + get hasApps() { + return this._apps.length > 0; + } + + initialize(apps: WorkspaceApp[], routing: string | null) { + this._apps = Array.isArray(apps) ? apps : []; + this._routing = routing ?? ''; + } + + clear() { + this._apps = []; + this._routing = ''; + } +} + +export const workspaceAppsStore = new WorkspaceAppsStore(); diff --git a/frontend/src/routes/dashboard/+layout.server.ts b/frontend/src/routes/dashboard/+layout.server.ts index 1669f3dc..7e09f726 100644 --- a/frontend/src/routes/dashboard/+layout.server.ts +++ b/frontend/src/routes/dashboard/+layout.server.ts @@ -18,6 +18,7 @@ import { setActiveSystemCookie, redirectToWorkspaceBase } from '$lib/server/system-gate'; +import { fetchMyApps } from '$lib/server/workspace-apps'; export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => { // Verificar si existe el token en las cookies @@ -74,6 +75,13 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => { // No bloquear el dashboard si falla la carga de tenants } + // Cargar las apps del Workspace accesibles al usuario (Hub, fuente de verdad). + // Releer el token de la cookie: validateAuth pudo haberlo refrescado y la variable + // `accessToken` capturada al inicio quedaría vencida → el Hub respondería 401. + // fetchMyApps nunca lanza: degrada a lista vacía si el Hub falla. + const freshAccessToken = getAuthTokens(cookies).accessToken ?? accessToken; + const myApps = await fetchMyApps(freshAccessToken, fetch, cookies.get('sso_tenant_id')); + return { authenticated: true, user: { ...userData, token: accessToken, allowedSystems }, @@ -82,6 +90,8 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => { activeSystem: gate.activeSystem, allowedSystems, userTenants, + workspaceApps: myApps.apps, + appRouting: myApps.routing, error: undefined }; } catch (error) { diff --git a/frontend/src/routes/dashboard/+layout.svelte b/frontend/src/routes/dashboard/+layout.svelte index d5984901..aa667b23 100644 --- a/frontend/src/routes/dashboard/+layout.svelte +++ b/frontend/src/routes/dashboard/+layout.svelte @@ -23,6 +23,7 @@ import { logout, getKeycloakInstance } from '$lib/auth'; import LicenseErrorScreen from '$lib/components/license-error-screen.svelte'; import { systemStore, type SystemType } from '$lib/stores/system.svelte'; + import { workspaceAppsStore } from '$lib/stores/workspace-apps.svelte'; type LicenseError = { type: string; message: string; status: number }; let { data, children }: { data: LayoutData & { licenseError?: LicenseError }; children: any } = $props(); @@ -102,6 +103,7 @@ (data.allowedSystems ?? []) as SystemType[], (data.activeSystem ?? null) as string | null ); + workspaceAppsStore.initialize(data.workspaceApps ?? [], data.appRouting ?? null); // Re-sincronizar si data.user cambia (por ejemplo, tras invalidateAll o cambio de compañía) $effect(() => { @@ -112,6 +114,7 @@ (data.allowedSystems ?? []) as SystemType[], (data.activeSystem ?? null) as string | null ); + workspaceAppsStore.initialize(data.workspaceApps ?? [], data.appRouting ?? null); }); // ── Manejar expiración de sesión ──────────────────────────────────────── @@ -193,7 +196,7 @@
- {#if systemStore.allowedSystems.length > 0} + {#if workspaceAppsStore.hasApps}
diff --git a/frontend/src/routes/dashboard/+layout.ts b/frontend/src/routes/dashboard/+layout.ts index 1bd80c42..c20252f6 100644 --- a/frontend/src/routes/dashboard/+layout.ts +++ b/frontend/src/routes/dashboard/+layout.ts @@ -9,6 +9,8 @@ export const load: LayoutLoad = async ({ data }) => { userTenants: data.userTenants ?? [], activeCompanyId: data.activeCompanyId, activeSystem: data.activeSystem ?? null, - allowedSystems: data.allowedSystems ?? [] + allowedSystems: data.allowedSystems ?? [], + workspaceApps: data.workspaceApps ?? [], + appRouting: data.appRouting ?? '' }; };