feature/app-selector-menu

This commit is contained in:
2026-05-27 08:38:14 -06:00
parent accc78eb2e
commit 2e3d0a55fd
6 changed files with 203 additions and 33 deletions

View File

@@ -1,21 +1,35 @@
<script lang="ts">
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import CheckIcon from '@lucide/svelte/icons/check';
import LayoutGridIcon from '@lucide/svelte/icons/layout-grid';
import PackageIcon from '@lucide/svelte/icons/package';
import BarChart3Icon from '@lucide/svelte/icons/bar-chart-3';
import { systemStore, SYSTEM_LABELS, type SystemType } from '$lib/stores/system.svelte';
import { invalidateAll } from '$app/navigation';
import { workspaceAppsStore, type WorkspaceApp } from '$lib/stores/workspace-apps.svelte';
const ICONS: Record<SystemType, any> = {
fixed_asset: PackageIcon,
inventory: BarChart3Icon,
};
// Abrir la app seleccionada:
// - Mismo origin (es esta misma app): si trae `active_system`, cambio de sistema local
// recargando la página actual con ese parámetro (sin rebotar al Workspace). Si no, ir al dashboard.
// - Otro origin: navegar a su entrada (login_url); su propio login resuelve el SSO vía Workspace
// y aterriza en la app.
function openApp(app: WorkspaceApp) {
let target: URL;
try {
target = new URL(app.url);
} catch {
window.location.assign(app.url);
return;
}
async function switchSystem(sys: SystemType) {
if (sys === systemStore.activeSystem || systemStore.switching) return;
const ok = await systemStore.setActiveSystem(sys);
if (ok) await invalidateAll();
if (target.origin !== window.location.origin) {
window.location.assign(app.url);
return;
}
const system = target.searchParams.get('active_system');
if (system) {
const current = new URL(window.location.href);
current.searchParams.set('active_system', system);
window.location.assign(current.pathname + current.search);
} else {
window.location.assign('/dashboard');
}
}
</script>
@@ -44,27 +58,21 @@
>
<p class="mb-3 px-1 text-xs font-medium text-muted-foreground">Tus aplicaciones</p>
<div class="grid grid-cols-2 gap-2">
{#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)}
<button
onclick={() => switchSystem(sys)}
disabled={!canSwitch || systemStore.switching}
class="flex flex-col items-center gap-1.5 rounded-lg p-3 text-center transition-colors
disabled:cursor-default
{canSwitch ? 'hover:bg-accent' : ''}
{active ? 'ring-2 ring-primary bg-accent/50' : ''}
{systemStore.switching ? 'opacity-50' : ''}"
onclick={() => openApp(app)}
class="flex flex-col items-center gap-1.5 rounded-lg p-3 text-center transition-colors hover:bg-accent"
>
<div class="flex size-10 items-center justify-center rounded-xl bg-primary/10 text-primary">
<Icon class="size-5" />
<div class="flex size-10 items-center justify-center overflow-hidden rounded-xl bg-primary/10 text-primary">
{#if app.iconUrl}
<img src={app.iconUrl} alt={app.name} class="size-full object-cover" />
{:else}
<LayoutGridIcon class="size-5" />
{/if}
</div>
<span class="text-sm font-medium leading-tight">{label.name}</span>
<span class="text-xs text-muted-foreground">{label.code}</span>
{#if active}
<CheckIcon class="size-3 text-primary" />
<span class="text-sm font-medium leading-tight">{app.name}</span>
{#if app.slug}
<span class="text-xs text-muted-foreground">{app.slug}</span>
{/if}
</button>
{/each}

View File

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

View File

@@ -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<WorkspaceApp[]>([]);
_routing = $state<string>('');
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();

View File

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

View File

@@ -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 @@
<Sidebar.Trigger class="-ml-1" />
<Separator orientation="vertical" class="mr-2 data-[orientation=vertical]:h-4" />
</div>
{#if systemStore.allowedSystems.length > 0}
{#if workspaceAppsStore.hasApps}
<div class="ml-auto px-4">
<AppLauncher />
</div>

View File

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