mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 01:01:09 +02:00
feat(mana/web): tier-gate workbench picker, openApps, and per-route navigation
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 <AuthGate> 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) <noreply@anthropic.com>
This commit is contained in:
parent
237516749a
commit
94d7dd4831
5 changed files with 180 additions and 6 deletions
|
|
@ -9,6 +9,8 @@ export {
|
|||
canDrop,
|
||||
executeDrop,
|
||||
getAllApps,
|
||||
getAccessibleApps,
|
||||
isAppAccessible,
|
||||
} from './registry';
|
||||
|
||||
// Register all apps eagerly — descriptors are lightweight with lazy imports
|
||||
|
|
|
|||
|
|
@ -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<string, AppDescriptor>();
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import PickerOverlay from '$lib/components/PickerOverlay.svelte';
|
||||
import { getAllApps } from '$lib/app-registry';
|
||||
import { getAccessibleApps } from '$lib/app-registry';
|
||||
|
||||
function appName(id: string, fallback: string): string {
|
||||
const key = `apps.${id}`;
|
||||
|
|
@ -16,11 +16,19 @@
|
|||
onSelect: (appId: string) => void;
|
||||
onClose: () => void;
|
||||
activeAppIds?: string[];
|
||||
/** User access tier from authStore.user?.tier — `undefined` falls
|
||||
* through to 'guest' inside getAccessibleApps. */
|
||||
userTier?: string | null;
|
||||
}
|
||||
|
||||
let { onSelect, onClose, activeAppIds = [] }: Props = $props();
|
||||
let { onSelect, onClose, activeAppIds = [], userTier = null }: Props = $props();
|
||||
|
||||
let availableApps = $derived(getAllApps().filter((app) => !activeAppIds.includes(app.id)));
|
||||
// Filter twice: tier-gate first (so guests + public users don't see
|
||||
// founder/alpha/beta apps at all), then drop apps that are already
|
||||
// open in the current scene.
|
||||
let availableApps = $derived(
|
||||
getAccessibleApps(userTier).filter((app) => !activeAppIds.includes(app.id))
|
||||
);
|
||||
</script>
|
||||
|
||||
<PickerOverlay
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@
|
|||
import { getAdapterLoader } from '$lib/quick-input/registry';
|
||||
import { createFallbackAdapter } from '$lib/quick-input/fallback-adapter';
|
||||
import { AuthGate, GuestWelcomeModal } from '@mana/shared-auth-ui';
|
||||
import { MANA_APPS, hasAppAccess, ACCESS_TIER_LABELS } from '@mana/shared-branding';
|
||||
import type { AccessTier } from '@mana/shared-branding';
|
||||
import { createGuestMode, type GuestMode } from '$lib/stores/guest-mode.svelte';
|
||||
import { guestPrompt, setGuestPromptNavigator } from '$lib/stores/guest-prompt.svelte';
|
||||
import { NotificationBar } from '@mana/shared-ui';
|
||||
|
|
@ -74,6 +76,39 @@
|
|||
// ── App switcher ────────────────────────────────────────
|
||||
let appItems = $derived(getPillAppItems('mana', undefined, undefined, authStore.user?.tier));
|
||||
|
||||
// ── Per-route tier gate ─────────────────────────────────
|
||||
// AuthGate (the wrapping component) only checks tiers onMount and only
|
||||
// for authenticated users — so a guest typing /dreams into the URL bar
|
||||
// or a public-tier user navigating into a founder module would slip
|
||||
// past silently. This reactive check looks up the first path segment
|
||||
// in MANA_APPS, and if that app has a requiredTier the current user
|
||||
// (or guest) doesn't meet, we render a denial panel instead of the
|
||||
// routed view.
|
||||
//
|
||||
// Routes that don't map to a MANA_APPS id (settings, profile, admin,
|
||||
// help, …) fall through with `routeAppId === null` and are never
|
||||
// blocked here. Workbench `/` (empty first segment) likewise passes
|
||||
// through — soft-filtering of openApps happens in (app)/+page.svelte.
|
||||
let routeAppId = $derived.by(() => {
|
||||
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 @@
|
|||
<!-- Main content -->
|
||||
<main style="padding-bottom: {bottomChromeHeight + 32}px">
|
||||
<div class="mx-auto max-w-7xl px-3 py-4 sm:px-6 sm:py-8 lg:px-8">
|
||||
{@render children()}
|
||||
{#if routeBlocked && routeAppId}
|
||||
<!-- Per-route tier gate. The wrapping AuthGate only fires
|
||||
onMount + only for authenticated users, so this is the
|
||||
only place that catches direct URL navigation into a
|
||||
gated module by a guest or under-tier user. -->
|
||||
<div class="flex min-h-[60vh] items-center justify-center p-6">
|
||||
<div
|
||||
class="w-full max-w-96 rounded-2xl border px-8 py-10 text-center shadow-sm"
|
||||
style:border-color="hsl(var(--border, 0 0% 90%))"
|
||||
style:background-color="hsl(var(--card, 0 0% 100%))"
|
||||
>
|
||||
<h1 class="mb-4 text-xl font-bold" style:color="hsl(var(--foreground, 0 0% 9%))">
|
||||
{routeAppId.name}
|
||||
</h1>
|
||||
<div class="mb-4 text-5xl">🔒</div>
|
||||
<p
|
||||
class="mb-6 text-[0.9375rem] leading-relaxed"
|
||||
style:color="hsl(var(--muted-foreground, 0 0% 45%))"
|
||||
>
|
||||
{($locale || 'de') === 'de'
|
||||
? 'Diese App ist aktuell in der geschlossenen '
|
||||
: 'This app is currently in closed '}<strong>{routeTierLabels.required}</strong
|
||||
>{($locale || 'de') === 'de' ? '-Phase.' : ' phase.'}
|
||||
</p>
|
||||
<div
|
||||
class="mb-6 flex flex-col gap-2 rounded-xl p-4"
|
||||
style:background-color="hsl(var(--muted, 0 0% 96%))"
|
||||
>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span style:color="hsl(var(--muted-foreground, 0 0% 45%))"
|
||||
>{($locale || 'de') === 'de' ? 'Dein Zugang:' : 'Your access:'}</span
|
||||
>
|
||||
<span class="font-semibold" style:color="hsl(var(--foreground, 0 0% 9%))"
|
||||
>{routeTierLabels.user}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span style:color="hsl(var(--muted-foreground, 0 0% 45%))"
|
||||
>{($locale || 'de') === 'de' ? 'Benötigt:' : 'Required:'}</span
|
||||
>
|
||||
<span class="font-semibold text-violet-500">{routeTierLabels.required}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<button
|
||||
class="w-full cursor-pointer rounded-lg border-none px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-90"
|
||||
style:background-color="hsl(var(--primary, 239 84% 67%))"
|
||||
style:color="hsl(var(--primary-foreground, 0 0% 100%))"
|
||||
onclick={() => goto('/')}
|
||||
>
|
||||
{($locale || 'de') === 'de' ? 'Zur Übersicht' : 'Back to overview'}
|
||||
</button>
|
||||
{#if !authStore.isAuthenticated}
|
||||
<button
|
||||
class="w-full cursor-pointer rounded-lg border px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-90"
|
||||
style:border-color="hsl(var(--border, 0 0% 90%))"
|
||||
style:color="hsl(var(--foreground, 0 0% 9%))"
|
||||
onclick={() => goto('/login')}
|
||||
>
|
||||
{($locale || 'de') === 'de' ? 'Anmelden' : 'Sign in'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{@render children()}
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
|
|
|||
|
|
@ -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<CarouselPage[]>(
|
||||
|
|
@ -229,6 +239,7 @@
|
|||
onSelect={handleAddApp}
|
||||
onClose={() => (showPicker = false)}
|
||||
activeAppIds={openApps.map((a) => a.appId)}
|
||||
userTier={authStore.user?.tier}
|
||||
/>
|
||||
{/snippet}
|
||||
</PageCarousel>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue