From 94d7dd4831e044fa85a62c320c26a4acf8704901 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 9 Apr 2026 12:08:49 +0200 Subject: [PATCH] feat(mana/web): tier-gate workbench picker, openApps, and per-route navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guests and under-tier users could see and use every module in the workbench because tier-filtering only existed in @mana/shared-branding's MANA_APPS list — never in the workbench app-registry that the picker and the page-level routes actually consume. Three leaks closed: ──── 1. Workbench AppPagePicker ──── The picker was calling getAllApps() and only filtering by "already open in this scene". Result: a guest opening "Add page" saw all 32 modules including founder-only ones like dreams, finance, memoro. Fix: new getAccessibleApps(userTier) helper in app-registry/registry.ts joins the workbench in-memory map with MANA_APPS by id, looks up each app's requiredTier, and filters via hasAppAccess. Apps that exist in the workbench registry but NOT in MANA_APPS (`automations`, `playground`, the `inventar` ↔ `inventory` id mismatch) default to visible — hiding them would silently break internal tools for founders/devs. AppPagePicker now takes a `userTier` prop and calls getAccessibleApps(userTier) instead of getAllApps(). (app)/+page.svelte threads authStore.user?.tier into it. ──── 2. openApps soft-filter ──── The default Home scene seeds [todo, calendar, notes] — `notes` is founder-tier, so a brand-new guest device would still try to render the notes view in their workbench tab strip even though they can't actually use it. Same risk for any cross-device synced scene that contains gated apps (e.g. founder logs in on a public-tier secondary account). Fix: (app)/+page.svelte derives `openApps` through a soft filter (isAppAccessible) instead of using workbenchScenesStore.openApps directly. The store keeps the full list — we don't destructively delete on tier downgrades — so the tabs reappear when the user upgrades or signs in. Internal-only apps (no MANA_APPS entry) stay visible by the same default-visible rule. ──── 3. Per-route tier gate in (app)/+layout.svelte ──── The wrapping in (app)/+layout.svelte: - only runs onMount, so it doesn't react to client-side navigation - skips the tier check entirely when !authStore.isAuthenticated - has no per-route requiredTier — it's set once on the outer wrapper So a guest typing /dreams or /cycles in the URL bar slipped past silently and rendered the gated module. Same for a public-tier user clicking through to /finance. Fix: reactive `routeBlocked` derivation in the (app) layout: - Extract first path segment from $page.url.pathname - Look it up in MANA_APPS by id - If found and user (or 'guest') doesn't satisfy requiredTier, render an inline tier-denied panel instead of {@render children()} The panel mirrors AuthGate's tier-denied design (same locked icon + tier comparison + "Zur Übersicht" / "Anmelden" buttons) but works reactively for any subsequent navigation. Routes that don't map to a MANA_APPS id (settings, profile, admin, help, observatory, …) fall through with routeAppId=null and are never blocked. ──── New helpers in app-registry ──── getAccessibleApps(userTier?) — filtered AppDescriptor[] isAppAccessible(appId, userTier?) — boolean for single-app lookup Both treat `userTier === undefined | null` as 'guest' (the lowest tier in @mana/shared-branding). Both default-visible for apps not in MANA_APPS so the workbench-internal tools keep working. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../apps/web/src/lib/app-registry/index.ts | 2 + .../apps/web/src/lib/app-registry/registry.ts | 51 +++++++++ .../components/workbench/AppPagePicker.svelte | 14 ++- .../apps/web/src/routes/(app)/+layout.svelte | 104 +++++++++++++++++- .../apps/web/src/routes/(app)/+page.svelte | 15 ++- 5 files changed, 180 insertions(+), 6 deletions(-) diff --git a/apps/mana/apps/web/src/lib/app-registry/index.ts b/apps/mana/apps/web/src/lib/app-registry/index.ts index 21d21808c..ae0f94404 100644 --- a/apps/mana/apps/web/src/lib/app-registry/index.ts +++ b/apps/mana/apps/web/src/lib/app-registry/index.ts @@ -9,6 +9,8 @@ export { canDrop, executeDrop, getAllApps, + getAccessibleApps, + isAppAccessible, } from './registry'; // Register all apps eagerly — descriptors are lightweight with lazy imports diff --git a/apps/mana/apps/web/src/lib/app-registry/registry.ts b/apps/mana/apps/web/src/lib/app-registry/registry.ts index 9cb2f22fd..acafcceb5 100644 --- a/apps/mana/apps/web/src/lib/app-registry/registry.ts +++ b/apps/mana/apps/web/src/lib/app-registry/registry.ts @@ -4,6 +4,7 @@ import type { DragType } from '@mana/shared-ui/dnd'; import { linkMutations, buildCachedData } from '@mana/shared-links'; +import { MANA_APPS, hasAppAccess, type AccessTier } from '@mana/shared-branding'; import type { AppDescriptor, DropResult } from './types'; const apps = new Map(); @@ -81,3 +82,53 @@ export async function executeDrop( export function getAllApps(): AppDescriptor[] { return Array.from(apps.values()); } + +/** + * Looks up the access tier for a workbench-registry app via the canonical + * @mana/shared-branding MANA_APPS list. The two registries are intentionally + * separate (workbench needs createItem/dragType/views; MANA_APPS holds tier + * + branding metadata), so the join happens by `id`. + * + * Returns null when there is no matching MANA_APPS entry. Internal-only + * tools that exist in the workbench but not in MANA_APPS (`automations`, + * `playground`, the `inventar` ↔ `inventory` id mismatch) fall into this + * case — `getAccessibleApps` then treats them as visible by default + * rather than hiding them for everyone, since the alternative is a + * silent regression for founders/devs. + */ +function getAppRequiredTier(appId: string): AccessTier | null { + const branding = MANA_APPS.find((a) => a.id === appId); + return branding?.requiredTier ?? null; +} + +/** + * Returns workbench apps the given user tier may access. Used by the + * AppPagePicker to hide gated modules from the "add page" picker, and + * by the workbench layout to soft-filter `openApps` so seeded / migrated + * scenes don't render gated content for downgraded users or guests. + * + * Tier semantics (from @mana/shared-branding): + * guest(0) < public(1) < beta(2) < alpha(3) < founder(4) + * + * Pass `undefined` (no signed-in user) → treated as `'guest'`. The + * default behaviour for apps with NO MANA_APPS entry is "visible" (see + * `getAppRequiredTier` rationale above). + */ +export function getAccessibleApps(userTier?: string | null): AppDescriptor[] { + const tier = userTier ?? 'guest'; + return Array.from(apps.values()).filter((app) => { + const required = getAppRequiredTier(app.id); + if (!required) return true; + return hasAppAccess(tier, required); + }); +} + +/** Single-app version of `getAccessibleApps`. Returns true when the + * given user tier may use this app. Used by the (app) layout's + * per-route tier check so direct URL navigation to a gated module + * is blocked for users without access. */ +export function isAppAccessible(appId: string, userTier?: string | null): boolean { + const required = getAppRequiredTier(appId); + if (!required) return true; + return hasAppAccess(userTier ?? 'guest', required); +} diff --git a/apps/mana/apps/web/src/lib/components/workbench/AppPagePicker.svelte b/apps/mana/apps/web/src/lib/components/workbench/AppPagePicker.svelte index e11a18e44..c252460a2 100644 --- a/apps/mana/apps/web/src/lib/components/workbench/AppPagePicker.svelte +++ b/apps/mana/apps/web/src/lib/components/workbench/AppPagePicker.svelte @@ -4,7 +4,7 @@ { + const seg = $page.url.pathname.split('/')[1] ?? ''; + if (!seg) return null; + return MANA_APPS.find((a) => a.id === seg) ?? null; + }); + let routeBlocked = $derived.by(() => { + if (!routeAppId) return false; + const tier = authStore.user?.tier ?? 'guest'; + return !hasAppAccess(tier, routeAppId.requiredTier); + }); + let routeTierLabels = $derived.by(() => { + const labels = ACCESS_TIER_LABELS[($locale || 'de') === 'de' ? 'de' : 'en']; + const userTier = (authStore.user?.tier ?? 'guest') as AccessTier; + const required = routeAppId?.requiredTier ?? ('public' as AccessTier); + return { + user: labels[userTier] ?? userTier, + required: labels[required] ?? required, + }; + }); + // ── UI State ──────────────────────────────────────────── let isCollapsed = $state(false); let showShortcuts = $state(false); @@ -610,7 +645,74 @@
- {@render children()} + {#if routeBlocked && routeAppId} + +
+
+

+ {routeAppId.name} +

+
🔒
+

+ {($locale || 'de') === 'de' + ? 'Diese App ist aktuell in der geschlossenen ' + : 'This app is currently in closed '}{routeTierLabels.required}{($locale || 'de') === 'de' ? '-Phase.' : ' phase.'} +

+
+
+ {($locale || 'de') === 'de' ? 'Dein Zugang:' : 'Your access:'} + {routeTierLabels.user} +
+
+ {($locale || 'de') === 'de' ? 'Benötigt:' : 'Required:'} + {routeTierLabels.required} +
+
+
+ + {#if !authStore.isAuthenticated} + + {/if} +
+
+
+ {:else} + {@render children()} + {/if}
diff --git a/apps/mana/apps/web/src/routes/(app)/+page.svelte b/apps/mana/apps/web/src/routes/(app)/+page.svelte index 2a9ba27bc..2a028a875 100644 --- a/apps/mana/apps/web/src/routes/(app)/+page.svelte +++ b/apps/mana/apps/web/src/routes/(app)/+page.svelte @@ -5,9 +5,10 @@ import SceneRenameDialog from '$lib/components/workbench/scenes/SceneRenameDialog.svelte'; import ConfirmDialog from '$lib/components/workbench/scenes/ConfirmDialog.svelte'; import { PageCarousel, type CarouselPage } from '$lib/components/page-carousel'; - import { getApp, getAppByDragType } from '$lib/app-registry'; + import { getApp, getAppByDragType, isAppAccessible } from '$lib/app-registry'; import { onMount, onDestroy } from 'svelte'; import { workbenchScenesStore } from '$lib/stores/workbench-scenes.svelte'; + import { authStore } from '$lib/stores/auth.svelte'; import { DragPreview } from '@mana/shared-ui/dnd'; import type { DragType } from '@mana/shared-ui/dnd'; import { ContextMenu } from '@mana/shared-ui'; @@ -49,7 +50,16 @@ let scenes = $derived(workbenchScenesStore.scenes); let activeSceneId = $derived(workbenchScenesStore.activeSceneId); - let openApps = $derived(workbenchScenesStore.openApps); + + // Soft-filter the scene's open apps so gated modules don't render + // for users who aren't allowed to use them. The store still holds + // the full list — that way a founder-tier scene migrated to a + // guest device just hides the gated tabs locally instead of + // destructively deleting them from sync. When the user upgrades + // (or signs in) the apps reappear automatically. + let openApps = $derived( + workbenchScenesStore.openApps.filter((a) => isAppAccessible(a.appId, authStore.user?.tier)) + ); // ── Map openApps → CarouselPage[] ─────────────────────── let carouselPages = $derived( @@ -229,6 +239,7 @@ onSelect={handleAddApp} onClose={() => (showPicker = false)} activeAppIds={openApps.map((a) => a.appId)} + userTier={authStore.user?.tier} /> {/snippet}