-
+
+ {#if app.iconUrl}
+

+ {:else}
+
+ {/if}
-
{label.name}
-
{label.code}
- {#if active}
-
+
{app.name}
+ {#if app.slug}
+
{app.slug}
{/if}
{/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}