feature/app-selector-menu
This commit is contained in:
@@ -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}
|
||||
|
||||
105
frontend/src/lib/server/workspace-apps.ts
Normal file
105
frontend/src/lib/server/workspace-apps.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
42
frontend/src/lib/stores/workspace-apps.svelte.ts
Normal file
42
frontend/src/lib/stores/workspace-apps.svelte.ts
Normal 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();
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ?? ''
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user