diff --git a/CLAUDE.md b/CLAUDE.md index 9cd9bf3bc..fcc509a05 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -418,6 +418,82 @@ When adding a new app that should participate in cross-app SSO, update **all thr Missing any of these will silently break SSO for that app. +### Access Tier System (Phased Release) + +Apps can be gated behind access tiers for phased rollouts (e.g., founder-only alpha, then beta, then public). + +#### Tier Hierarchy + +| Tier | Level | Who | +|------|-------|-----| +| `guest` | 0 | Unauthenticated visitors (local-only) | +| `public` | 1 | Any registered user (default for new signups) | +| `beta` | 2 | Beta testers | +| `alpha` | 3 | Alpha testers / internal | +| `founder` | 4 | Founding members | + +A user can access an app if their tier level >= the app's `requiredTier` level. + +#### How It Works + +1. **`mana-apps.ts`** defines `requiredTier` per app (e.g., `requiredTier: 'founder'`) +2. **Users table** stores `accessTier` column (default: `'public'`) +3. **JWT** includes a `tier` claim, set during token creation in better-auth config +4. **AuthGate** checks the tier client-side and shows an "access restricted" state if insufficient + +#### Key Files + +| File | Purpose | +|------|---------| +| `packages/shared-branding/src/mana-apps.ts` | App registry with `requiredTier` | +| `services/mana-auth/src/db/schema/auth.ts` | `accessTier` column on users | +| `services/mana-auth/src/auth/better-auth.config.ts` | Adds `tier` to JWT claims | +| `packages/shared-auth-ui/src/components/AuthGate.svelte` | Client-side tier gating | +| `services/mana-auth/src/routes/admin.ts` | Admin API for tier management | + +#### Gating an App + +Pass `requiredTier` to AuthGate in the app's layout: + +```svelte + + + +``` + +The tier value comes from the app's entry in `mana-apps.ts`. Apps without `requiredTier` default to `'public'` (accessible to all registered users). + +#### Admin API + +```bash +# Set a user's tier +PUT /api/v1/admin/users/:id/tier +{ "tier": "beta" } + +# Get a user's tier +GET /api/v1/admin/users/:id/tier + +# List users (includes tier) +GET /api/v1/admin/users +``` + +#### Releasing an App + +To widen access, change `requiredTier` in `mana-apps.ts`: + +```typescript +// Founder-only alpha +{ id: 'myapp', requiredTier: 'founder' } + +// Open to beta testers +{ id: 'myapp', requiredTier: 'beta' } + +// Public release +{ id: 'myapp', requiredTier: 'public' } +``` + +No database migration needed -- just update the config and redeploy the app. + ### Search Architecture Projects requiring web search and content extraction use **mana-search** as the central search service: diff --git a/apps/calc/apps/web/src/routes/(app)/+layout.svelte b/apps/calc/apps/web/src/routes/(app)/+layout.svelte index d99169539..26be54c01 100644 --- a/apps/calc/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/calc/apps/web/src/routes/(app)/+layout.svelte @@ -53,7 +53,7 @@ } } - const appItems = getPillAppItems('calc'); + let appItems = $derived(getPillAppItems('calc', undefined, undefined, authStore.user?.tier)); let { children } = $props(); diff --git a/apps/calendar/apps/web/src/routes/(app)/+layout.svelte b/apps/calendar/apps/web/src/routes/(app)/+layout.svelte index 80b56adae..1fa15cd80 100644 --- a/apps/calendar/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/calendar/apps/web/src/routes/(app)/+layout.svelte @@ -70,7 +70,7 @@ let showGuestWelcome = $state(false); // App switcher items - const appItems = getPillAppItems('calendar'); + let appItems = $derived(getPillAppItems('calendar', undefined, undefined, authStore.user?.tier)); // Split-Panel Store fΓΌr Split-Screen Feature const splitPanel = setSplitPanelContext('calendar', DEFAULT_APPS); diff --git a/apps/chat/apps/web/src/routes/(protected)/+layout.svelte b/apps/chat/apps/web/src/routes/(protected)/+layout.svelte index 1c2f66c94..eb8118b6d 100644 --- a/apps/chat/apps/web/src/routes/(protected)/+layout.svelte +++ b/apps/chat/apps/web/src/routes/(protected)/+layout.svelte @@ -44,7 +44,7 @@ setContext('tags', allTags); // App switcher items - const appItems = getPillAppItems('chat'); + let appItems = $derived(getPillAppItems('chat', undefined, undefined, authStore.user?.tier)); let { children, data }: { children: any; data: LayoutData } = $props(); diff --git a/apps/citycorners/apps/web/src/routes/(app)/+layout.svelte b/apps/citycorners/apps/web/src/routes/(app)/+layout.svelte index f4adc1941..fdbdbde66 100644 --- a/apps/citycorners/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/citycorners/apps/web/src/routes/(app)/+layout.svelte @@ -24,7 +24,9 @@ import { setLocale, supportedLocales } from '$lib/i18n'; import { api } from '$lib/api'; - const appItems = getPillAppItems('citycorners'); + let appItems = $derived( + getPillAppItems('citycorners', undefined, undefined, authStore.user?.tier) + ); const allTags = useAllSharedTags(); setContext('tags', allTags); diff --git a/apps/clock/apps/web/src/routes/(app)/+layout.svelte b/apps/clock/apps/web/src/routes/(app)/+layout.svelte index 6bd014917..59ed789d6 100644 --- a/apps/clock/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/clock/apps/web/src/routes/(app)/+layout.svelte @@ -59,7 +59,7 @@ } // App switcher items - const appItems = getPillAppItems('clock'); + let appItems = $derived(getPillAppItems('clock', undefined, undefined, authStore.user?.tier)); let { children } = $props(); diff --git a/apps/contacts/apps/web/src/routes/(app)/+layout.svelte b/apps/contacts/apps/web/src/routes/(app)/+layout.svelte index 81a9dfd99..cb5173cc7 100644 --- a/apps/contacts/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/contacts/apps/web/src/routes/(app)/+layout.svelte @@ -82,7 +82,7 @@ const modalContactId = $derived(contactDetailMatch?.[1] || null); // App switcher items - const appItems = getPillAppItems('contacts'); + let appItems = $derived(getPillAppItems('contacts', undefined, undefined, authStore.user?.tier)); // Split-Panel Store fΓΌr Split-Screen Feature const splitPanel = setSplitPanelContext('contacts', DEFAULT_APPS); diff --git a/apps/context/apps/web/src/routes/(app)/+layout.svelte b/apps/context/apps/web/src/routes/(app)/+layout.svelte index a3097d448..c472e8d9d 100644 --- a/apps/context/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/context/apps/web/src/routes/(app)/+layout.svelte @@ -39,7 +39,7 @@ useAllTags as useAllSharedTags, } from '@manacore/shared-stores'; - const appItems = getPillAppItems('context'); + let appItems = $derived(getPillAppItems('context', undefined, undefined, authStore.user?.tier)); const allTags = useAllSharedTags(); setContext('tags', allTags); diff --git a/apps/manacore/apps/web/src/routes/(app)/+layout.svelte b/apps/manacore/apps/web/src/routes/(app)/+layout.svelte index 745a8df75..1f8e006c8 100644 --- a/apps/manacore/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/manacore/apps/web/src/routes/(app)/+layout.svelte @@ -46,8 +46,8 @@ let { children }: { children: Snippet } = $props(); - // App switcher items - const appItems = getPillAppItems('manacore'); + // App switcher items (filtered by user's access tier) + let appItems = $derived(getPillAppItems('manacore', undefined, undefined, authStore.user?.tier)); let loading = $state(true); let isCollapsed = $state(false); diff --git a/apps/manacore/apps/web/src/routes/(app)/home/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/home/+page.svelte index de90a661f..b0692ba25 100644 --- a/apps/manacore/apps/web/src/routes/(app)/home/+page.svelte +++ b/apps/manacore/apps/web/src/routes/(app)/home/+page.svelte @@ -5,6 +5,7 @@ MANA_APPS, APP_URLS, APP_STATUS_LABELS, + getAccessibleManaApps, type ManaApp, type AppIconId, } from '@manacore/shared-branding'; @@ -20,8 +21,9 @@ let currentLocale = $derived(($locale as 'de' | 'en') || 'de'); let statusLabels = $derived(APP_STATUS_LABELS[currentLocale] || APP_STATUS_LABELS['de']); - // Filter active (non-archived) apps - const activeApps = MANA_APPS.filter((app) => !app.archived); + // Filter apps by user's access tier + let userTier = $derived(authStore.user?.tier || 'public'); + let activeApps = $derived(getAccessibleManaApps(userTier)); // Group apps by category interface AppCategory { @@ -38,13 +40,13 @@ const productivityIds: AppIconId[] = ['todo', 'calendar', 'contacts', 'manadeck', 'inventory']; const utilityIds: AppIconId[] = ['clock', 'zitare', 'storage', 'moodlit', 'matrix']; - function getAppsForCategory(ids: AppIconId[]): ManaApp[] { + function getAppsForCategory(ids: AppIconId[], apps: ManaApp[]): ManaApp[] { return ids - .map((id) => activeApps.find((app) => app.id === id)) + .map((id) => apps.find((app) => app.id === id)) .filter((app): app is ManaApp => !!app); } - const categories: AppCategory[] = [ + let categories = $derived([ { id: 'ai', titleDe: 'KI & Kreativ', @@ -52,7 +54,7 @@ descDe: 'Intelligente Assistenten und kreative Werkzeuge', descEn: 'Intelligent assistants and creative tools', icon: 'πŸ€–', - apps: getAppsForCategory(aiAppIds), + apps: getAppsForCategory(aiAppIds, activeApps), }, { id: 'productivity', @@ -61,7 +63,7 @@ descDe: 'Organisiere deinen Alltag', descEn: 'Organize your daily life', icon: 'πŸ“‹', - apps: getAppsForCategory(productivityIds), + apps: getAppsForCategory(productivityIds, activeApps), }, { id: 'utility', @@ -70,9 +72,9 @@ descDe: 'Praktische Helferlein', descEn: 'Handy helpers', icon: 'πŸ”§', - apps: getAppsForCategory(utilityIds), + apps: getAppsForCategory(utilityIds, activeApps), }, - ]; + ] satisfies AppCategory[]); function getStatusColor(status: ManaApp['status']): string { const colors = { diff --git a/apps/manadeck/apps/web/src/routes/(app)/+layout.svelte b/apps/manadeck/apps/web/src/routes/(app)/+layout.svelte index 0be91fd81..ef1e3a883 100644 --- a/apps/manadeck/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/manadeck/apps/web/src/routes/(app)/+layout.svelte @@ -33,7 +33,7 @@ import { manadeckStore } from '$lib/data/local-store'; // App switcher items - const appItems = getPillAppItems('manadeck'); + let appItems = $derived(getPillAppItems('manadeck', undefined, undefined, authStore.user?.tier)); // Live queries β€” auto-update when IndexedDB changes (local writes, sync, other tabs) const allDecks = useAllDecks(); diff --git a/apps/matrix/apps/web/src/routes/(app)/+layout.svelte b/apps/matrix/apps/web/src/routes/(app)/+layout.svelte index f2c82fa73..df77def56 100644 --- a/apps/matrix/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/matrix/apps/web/src/routes/(app)/+layout.svelte @@ -78,7 +78,7 @@ } // App switcher items - const appItems = getPillAppItems('matrix'); + let appItems = $derived(getPillAppItems('matrix')); interface Props { children: Snippet; diff --git a/apps/moodlit/apps/web/src/routes/(app)/+layout.svelte b/apps/moodlit/apps/web/src/routes/(app)/+layout.svelte index 80e4b6259..c962f5bb0 100644 --- a/apps/moodlit/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/moodlit/apps/web/src/routes/(app)/+layout.svelte @@ -10,7 +10,7 @@ import { moodlitStore } from '$lib/data/local-store'; let { children } = $props(); - const appItems = getPillAppItems(); + let appItems = $derived(getPillAppItems(undefined, undefined, undefined, authStore.user?.tier)); let userEmail = $derived(authStore.isAuthenticated ? (authStore.user?.email ?? '') : ''); let showGuestWelcome = $state(false); let isCollapsed = $state(false); diff --git a/apps/mukke/apps/web/src/routes/(app)/+layout.svelte b/apps/mukke/apps/web/src/routes/(app)/+layout.svelte index e18ba634f..12590d4b2 100644 --- a/apps/mukke/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/mukke/apps/web/src/routes/(app)/+layout.svelte @@ -47,7 +47,9 @@ setContext('tags', allTags); // App switcher items - const appItems = getPillAppItems('mukke' as any); + let appItems = $derived( + getPillAppItems('mukke' as any, undefined, undefined, authStore.user?.tier) + ); // Split-Panel Store const splitPanel = setSplitPanelContext('mukke' as any, DEFAULT_APPS); diff --git a/apps/news/apps/web/src/routes/(protected)/+layout.svelte b/apps/news/apps/web/src/routes/(protected)/+layout.svelte index b7c398d83..13daffd7e 100644 --- a/apps/news/apps/web/src/routes/(protected)/+layout.svelte +++ b/apps/news/apps/web/src/routes/(protected)/+layout.svelte @@ -12,7 +12,7 @@ let { children } = $props(); - const appItems = getPillAppItems(); + let appItems = $derived(getPillAppItems(undefined, undefined, undefined, authStore.user?.tier)); let userEmail = $derived(authStore.isAuthenticated ? (authStore.user?.email ?? '') : ''); const navItems: PillNavItem[] = [ diff --git a/apps/picture/apps/web/src/routes/app/+layout.svelte b/apps/picture/apps/web/src/routes/app/+layout.svelte index 53b4d705f..2c819606b 100644 --- a/apps/picture/apps/web/src/routes/app/+layout.svelte +++ b/apps/picture/apps/web/src/routes/app/+layout.svelte @@ -41,7 +41,7 @@ import { browser } from '$app/environment'; // App switcher items - const appItems = getPillAppItems('picture'); + let appItems = $derived(getPillAppItems('picture', undefined, undefined, authStore.user?.tier)); // Live query for shared tags (local-first) const allTags = useAllSharedTags(); diff --git a/apps/presi/apps/web/src/routes/(app)/+layout.svelte b/apps/presi/apps/web/src/routes/(app)/+layout.svelte index d07d46caa..543aa73d1 100644 --- a/apps/presi/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/presi/apps/web/src/routes/(app)/+layout.svelte @@ -28,7 +28,7 @@ import { presiStore } from '$lib/data/local-store'; // App switcher items - const appItems = getPillAppItems('presi'); + let appItems = $derived(getPillAppItems('presi', undefined, undefined, auth.user?.tier)); // Shared tag store (local-first) const allTags = useAllSharedTags(); diff --git a/apps/questions/apps/web/src/routes/(app)/+layout.svelte b/apps/questions/apps/web/src/routes/(app)/+layout.svelte index 1870f27de..ba84ffe33 100644 --- a/apps/questions/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/questions/apps/web/src/routes/(app)/+layout.svelte @@ -40,7 +40,7 @@ const allQuestions = useAllQuestions(); // App switcher items - const appItems = getPillAppItems('questions'); + let appItems = $derived(getPillAppItems('questions', undefined, undefined, authStore.user?.tier)); // Mobile detection let isMobile = $state(false); diff --git a/apps/storage/apps/web/src/routes/+layout.svelte b/apps/storage/apps/web/src/routes/+layout.svelte index 924c46b14..5ae730a3f 100644 --- a/apps/storage/apps/web/src/routes/+layout.svelte +++ b/apps/storage/apps/web/src/routes/+layout.svelte @@ -31,7 +31,7 @@ import '../app.css'; // App switcher items - const appItems = getPillAppItems('storage'); + let appItems = $derived(getPillAppItems('storage', undefined, undefined, authStore.user?.tier)); // Live query for shared tags (local-first) const allTags = useAllSharedTags(); diff --git a/apps/todo/apps/web/src/routes/(app)/+layout.svelte b/apps/todo/apps/web/src/routes/(app)/+layout.svelte index e10e08a9d..8aeefbf73 100644 --- a/apps/todo/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/todo/apps/web/src/routes/(app)/+layout.svelte @@ -77,8 +77,8 @@ } } - // App switcher items - const appItems = getPillAppItems('todo'); + // App switcher items (filtered by user's access tier) + let appItems = $derived(getPillAppItems('todo', undefined, undefined, authStore.user?.tier)); // Split-Panel Store fΓΌr Split-Screen Feature const splitPanel = setSplitPanelContext('todo', DEFAULT_APPS); diff --git a/apps/uload/apps/web/src/routes/(app)/+layout.svelte b/apps/uload/apps/web/src/routes/(app)/+layout.svelte index 64dfdb96d..01263380b 100644 --- a/apps/uload/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/uload/apps/web/src/routes/(app)/+layout.svelte @@ -12,7 +12,7 @@ let { children } = $props(); - const appItems = getPillAppItems(); + let appItems = $derived(getPillAppItems(undefined, undefined, undefined, authStore.user?.tier)); let userEmail = $derived(authStore.isAuthenticated ? (authStore.user?.email ?? '') : ''); diff --git a/apps/wisekeep/apps/web/src/routes/(protected)/+layout.svelte b/apps/wisekeep/apps/web/src/routes/(protected)/+layout.svelte index a2e60652b..86657aeab 100644 --- a/apps/wisekeep/apps/web/src/routes/(protected)/+layout.svelte +++ b/apps/wisekeep/apps/web/src/routes/(protected)/+layout.svelte @@ -10,7 +10,7 @@ import { wisekeepStore } from '$lib/data/local-store'; let { children } = $props(); - const appItems = getPillAppItems(); + let appItems = $derived(getPillAppItems(undefined, undefined, undefined, authStore.user?.tier)); let userEmail = $derived(authStore.isAuthenticated ? (authStore.user?.email ?? '') : ''); let showGuestWelcome = $state(false); let isCollapsed = $state(false); diff --git a/apps/zitare/apps/web/src/routes/(app)/+layout.svelte b/apps/zitare/apps/web/src/routes/(app)/+layout.svelte index c3e2c23cc..b07bc2f03 100644 --- a/apps/zitare/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/zitare/apps/web/src/routes/(app)/+layout.svelte @@ -54,7 +54,7 @@ let showGuestWelcome = $state(false); // App switcher items - const appItems = getPillAppItems('zitare'); + let appItems = $derived(getPillAppItems('zitare', undefined, undefined, authStore.user?.tier)); // User settings for nav visibility const userSettings = createUserSettingsStore({ diff --git a/games/arcade/apps/web/src/routes/(app)/+layout.svelte b/games/arcade/apps/web/src/routes/(app)/+layout.svelte index 184dc6684..431c5e2e5 100644 --- a/games/arcade/apps/web/src/routes/(app)/+layout.svelte +++ b/games/arcade/apps/web/src/routes/(app)/+layout.svelte @@ -45,7 +45,7 @@ } } - const appItems = getPillAppItems('arcade'); + let appItems = $derived(getPillAppItems('arcade', undefined, undefined, authStore.user?.tier)); let { children } = $props(); diff --git a/packages/shared-auth-ui/src/components/AuthGate.svelte b/packages/shared-auth-ui/src/components/AuthGate.svelte index bf7104ce5..fe923963c 100644 --- a/packages/shared-auth-ui/src/components/AuthGate.svelte +++ b/packages/shared-auth-ui/src/components/AuthGate.svelte @@ -5,17 +5,19 @@ - Auth store initialization - Loading spinner while checking auth - Redirect to login if not authenticated (unless allowGuest) + - Access tier check (blocks apps the user doesn't have access to) - Calling onReady callback after auth is confirmed - Rendering children only when ready Usage: - + --> -{#if !ready} +{#if tierDenied} +
+
+ {#if appName} +

{appName}

+ {/if} +
πŸ”’
+

+ Diese App ist aktuell in der geschlossenen {requiredTierLabel}-Phase. +

+
+
+ Dein Zugang: + {userTierLabel} +
+
+ BenΓΆtigt: + {requiredTierLabel} +
+
+
+ +
+
+
+{:else if !ready}
{:else} {@render children()} {/if} + + diff --git a/packages/shared-auth/src/core/jwtUtils.ts b/packages/shared-auth/src/core/jwtUtils.ts index 0eb39c9c9..ce1ca2cf7 100644 --- a/packages/shared-auth/src/core/jwtUtils.ts +++ b/packages/shared-auth/src/core/jwtUtils.ts @@ -78,6 +78,7 @@ export function getUserFromToken(token: string, storedEmail?: string): UserData id: payload.sub, email: email || 'user@example.com', role: payload.role || 'user', + tier: payload.tier || 'public', }; } catch (error) { console.error('Error extracting user from token:', error); diff --git a/packages/shared-auth/src/types/index.ts b/packages/shared-auth/src/types/index.ts index 2f8f9f467..026951119 100644 --- a/packages/shared-auth/src/types/index.ts +++ b/packages/shared-auth/src/types/index.ts @@ -24,6 +24,7 @@ export interface DecodedToken { sub: string; email?: string; role?: string; + tier?: string; exp: number; iat: number; aud?: string; @@ -50,6 +51,7 @@ export interface UserData { id: string; email: string; role: string; + tier: string; } /** diff --git a/packages/shared-branding/src/index.ts b/packages/shared-branding/src/index.ts index 9212348bc..31c158140 100644 --- a/packages/shared-branding/src/index.ts +++ b/packages/shared-branding/src/index.ts @@ -54,12 +54,17 @@ export { getManaAppsByStatus, getAvailableManaApps, getActiveManaApps, + getAccessibleManaApps, + hasAppAccess, + getTierLevel, APP_STATUS_LABELS, APP_SLIDER_LABELS, APP_URLS, + ACCESS_TIER_LABELS, getPillAppItems, type ManaApp, type AppStatus, + type AccessTier, type PillAppItemConfig, } from './mana-apps'; diff --git a/packages/shared-branding/src/mana-apps.ts b/packages/shared-branding/src/mana-apps.ts index dc3aa75d8..f4efba5a9 100644 --- a/packages/shared-branding/src/mana-apps.ts +++ b/packages/shared-branding/src/mana-apps.ts @@ -8,6 +8,56 @@ import type { AppIconId } from './app-icons'; export type AppStatus = 'published' | 'beta' | 'development' | 'planning'; +/** + * Access tier hierarchy (higher number = more access): + * guest(0) < public(1) < beta(2) < alpha(3) < founder(4) + */ +export type AccessTier = 'guest' | 'public' | 'beta' | 'alpha' | 'founder'; + +const TIER_LEVELS: Record = { + guest: 0, + public: 1, + beta: 2, + alpha: 3, + founder: 4, +}; + +/** + * Check if a user's tier meets the required tier for an app + */ +export function hasAppAccess(userTier: string, requiredTier: AccessTier): boolean { + const userLevel = TIER_LEVELS[userTier as AccessTier] ?? 0; + const requiredLevel = TIER_LEVELS[requiredTier] ?? 0; + return userLevel >= requiredLevel; +} + +/** + * Get the numeric level for a tier (for comparisons) + */ +export function getTierLevel(tier: string): number { + return TIER_LEVELS[tier as AccessTier] ?? 0; +} + +/** + * Tier display labels + */ +export const ACCESS_TIER_LABELS = { + de: { + guest: 'Gast', + public: 'Standard', + beta: 'Beta', + alpha: 'Alpha', + founder: 'Founder', + }, + en: { + guest: 'Guest', + public: 'Standard', + beta: 'Beta', + alpha: 'Alpha', + founder: 'Founder', + }, +} as const; + export interface ManaApp { id: AppIconId; name: string; @@ -23,6 +73,8 @@ export interface ManaApp { color: string; comingSoon: boolean; status: AppStatus; + /** Minimum access tier required to use this app */ + requiredTier: AccessTier; url?: string; // Optional URL for the app /** Whether this app is archived (in apps-archived folder) */ archived?: boolean; @@ -48,6 +100,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#0ea5e9', comingSoon: false, status: 'beta', + requiredTier: 'alpha', }, { id: 'memoro', @@ -64,6 +117,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#f8d62b', comingSoon: false, status: 'published', + requiredTier: 'founder', archived: true, }, { @@ -81,6 +135,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#f97316', comingSoon: false, status: 'development', + requiredTier: 'alpha', }, { id: 'manadeck', @@ -97,6 +152,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#8b5cf6', comingSoon: true, status: 'development', + requiredTier: 'alpha', }, { id: 'picture', @@ -113,6 +169,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#22c55e', comingSoon: true, status: 'development', + requiredTier: 'alpha', }, { id: 'zitare', @@ -129,6 +186,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#f59e0b', comingSoon: true, status: 'development', + requiredTier: 'beta', }, { id: 'wisekeep', @@ -145,6 +203,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#6366f1', comingSoon: true, status: 'planning', + requiredTier: 'founder', archived: true, }, { @@ -162,6 +221,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#10b981', comingSoon: false, status: 'development', + requiredTier: 'founder', archived: true, }, { @@ -179,6 +239,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#3b82f6', comingSoon: false, status: 'development', + requiredTier: 'beta', }, { id: 'calendar', @@ -195,6 +256,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#0ea5e9', comingSoon: false, status: 'development', + requiredTier: 'beta', }, { id: 'storage', @@ -211,6 +273,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#3b82f6', comingSoon: false, status: 'development', + requiredTier: 'alpha', }, { id: 'clock', @@ -227,6 +290,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#f59e0b', comingSoon: false, status: 'development', + requiredTier: 'beta', }, { id: 'todo', @@ -243,6 +307,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#8b5cf6', comingSoon: false, status: 'development', + requiredTier: 'beta', }, { id: 'mail', @@ -259,6 +324,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#6366f1', comingSoon: false, status: 'development', + requiredTier: 'founder', }, { id: 'moodlit', @@ -275,6 +341,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#8b5cf6', comingSoon: false, status: 'development', + requiredTier: 'alpha', }, { id: 'inventory', @@ -291,6 +358,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#14b8a6', comingSoon: false, status: 'development', + requiredTier: 'alpha', }, { id: 'questions', @@ -307,6 +375,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#8b5cf6', comingSoon: false, status: 'development', + requiredTier: 'alpha', }, { id: 'matrix', @@ -323,6 +392,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#8b5cf6', comingSoon: false, status: 'development', + requiredTier: 'alpha', }, { id: 'context', @@ -339,6 +409,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#0ea5e9', comingSoon: false, status: 'development', + requiredTier: 'alpha', }, { id: 'times', @@ -355,6 +426,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#f59e0b', comingSoon: false, status: 'development', + requiredTier: 'alpha', }, { id: 'citycorners', @@ -371,6 +443,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#2563eb', comingSoon: false, status: 'development', + requiredTier: 'alpha', }, { id: 'uload', @@ -387,6 +460,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#6366f1', comingSoon: false, status: 'development', + requiredTier: 'alpha', }, { id: 'reader', @@ -403,6 +477,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#f97316', comingSoon: false, status: 'development', + requiredTier: 'founder', }, { id: 'news', @@ -419,6 +494,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#10b981', comingSoon: false, status: 'development', + requiredTier: 'founder', }, { id: 'calc', @@ -435,6 +511,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#ec4899', comingSoon: false, status: 'development', + requiredTier: 'beta', }, ]; @@ -466,6 +543,14 @@ export function getActiveManaApps(): ManaApp[] { return MANA_APPS.filter((app) => !app.archived); } +/** + * Get apps accessible to a user based on their tier + * Only returns active (non-archived) apps the user has access to + */ +export function getAccessibleManaApps(userTier: string): ManaApp[] { + return MANA_APPS.filter((app) => !app.archived && hasAppAccess(userTier, app.requiredTier)); +} + /** * Status labels in German and English */ @@ -548,21 +633,24 @@ export interface PillAppItemConfig { /** * Get app items for PillNavigation app switcher - * Only returns active (non-archived) apps + * Only returns apps the user has access to (non-archived, tier-gated) * @param currentAppId - The ID of the current app to mark as active * @param isDev - Whether to use development URLs (default: auto-detect) * @param customUrls - Optional custom URL overrides per app + * @param userTier - The user's access tier (default: 'public') */ export function getPillAppItems( currentAppId?: AppIconId, isDev?: boolean, - customUrls?: Partial> + customUrls?: Partial>, + userTier?: string ): PillAppItemConfig[] { const isDevMode = isDev ?? (typeof window !== 'undefined' && window.location.hostname === 'localhost'); - // Only show active (non-archived) apps - return getActiveManaApps().map((app) => ({ + const tier = userTier || 'public'; + // Only show apps the user has access to + return getAccessibleManaApps(tier).map((app) => ({ id: app.id, name: app.name, url: customUrls?.[app.id] || (isDevMode ? APP_URLS[app.id].dev : APP_URLS[app.id].prod), diff --git a/services/mana-auth/src/auth/better-auth.config.ts b/services/mana-auth/src/auth/better-auth.config.ts index e4f4471fb..006e7a953 100644 --- a/services/mana-auth/src/auth/better-auth.config.ts +++ b/services/mana-auth/src/auth/better-auth.config.ts @@ -68,6 +68,9 @@ export interface JWTCustomPayload { /** Session ID for reference */ sid: string; + + /** Access tier for app-level gating (guest, public, beta, alpha, founder) */ + tier: string; } /** @@ -368,6 +371,7 @@ export function createBetterAuth(databaseUrl: string) { email: user.email, role: (user as { role?: string }).role || 'user', sid: session.id, + tier: (user as { accessTier?: string }).accessTier || 'public', }; }, }, diff --git a/services/mana-auth/src/db/schema/auth.ts b/services/mana-auth/src/db/schema/auth.ts index 355f86f71..cdcaf5b24 100644 --- a/services/mana-auth/src/db/schema/auth.ts +++ b/services/mana-auth/src/db/schema/auth.ts @@ -15,6 +15,16 @@ export const authSchema = pgSchema('auth'); // Enum for user roles export const userRoleEnum = pgEnum('user_role', ['user', 'admin', 'service']); +// Enum for access tiers (controls which apps a user can access) +// Hierarchy: founder > alpha > beta > public > guest +export const accessTierEnum = pgEnum('access_tier', [ + 'guest', + 'public', + 'beta', + 'alpha', + 'founder', +]); + // Users table (Better Auth schema) export const users = authSchema.table('users', { id: text('id').primaryKey(), // Better Auth generates nanoid @@ -26,6 +36,7 @@ export const users = authSchema.table('users', { updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), // Custom fields (not required by Better Auth) role: userRoleEnum('role').default('user').notNull(), + accessTier: accessTierEnum('access_tier').default('public').notNull(), twoFactorEnabled: boolean('two_factor_enabled').default(false), deletedAt: timestamp('deleted_at', { withTimezone: true }), }); diff --git a/services/mana-auth/src/index.ts b/services/mana-auth/src/index.ts index 3384d138f..5a28169d5 100644 --- a/services/mana-auth/src/index.ts +++ b/services/mana-auth/src/index.ts @@ -21,6 +21,7 @@ import { createAuthRoutes } from './routes/auth'; import { createGuildRoutes } from './routes/guilds'; import { createApiKeyRoutes, createApiKeyValidationRoute } from './routes/api-keys'; import { createMeRoutes } from './routes/me'; +import { createAdminRoutes } from './routes/admin'; // ─── Bootstrap ────────────────────────────────────────────── @@ -81,6 +82,11 @@ app.route('/api/v1/api-keys', createApiKeyValidationRoute(apiKeysService)); app.use('/api/v1/me/*', jwtAuth(config.baseUrl)); app.route('/api/v1/me', createMeRoutes(db)); +// ─── Admin ────────────────────────────────────────────────── + +app.use('/api/v1/admin/*', jwtAuth(config.baseUrl)); +app.route('/api/v1/admin', createAdminRoutes(db)); + // ─── Internal API ─────────────────────────────────────────── app.use('/api/v1/internal/*', serviceAuth(config.serviceKey)); diff --git a/services/mana-auth/src/routes/admin.ts b/services/mana-auth/src/routes/admin.ts new file mode 100644 index 000000000..4fb78a8ae --- /dev/null +++ b/services/mana-auth/src/routes/admin.ts @@ -0,0 +1,108 @@ +/** + * Admin routes β€” User tier management + * + * Protected by JWT auth + admin role check. + * Only users with role 'admin' can manage tiers. + */ + +import { Hono } from 'hono'; +import { eq } from 'drizzle-orm'; +import type { AuthUser } from '../middleware/jwt-auth'; +import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; +import { users } from '../db/schema/auth'; + +const VALID_TIERS = ['guest', 'public', 'beta', 'alpha', 'founder'] as const; +type AccessTier = (typeof VALID_TIERS)[number]; + +export function createAdminRoutes(db: PostgresJsDatabase) { + const app = new Hono<{ Variables: { user: AuthUser } }>(); + + // Admin role check middleware + app.use('*', async (c, next) => { + const user = c.get('user'); + if (user.role !== 'admin') { + return c.json({ error: 'Forbidden', message: 'Admin access required' }, 403); + } + await next(); + }); + + // ─── Update user's access tier ───────────────────────────── + + app.put('/users/:userId/tier', async (c) => { + const { userId } = c.req.param(); + const body = await c.req.json(); + const { tier } = body as { tier: string }; + + if (!tier || !VALID_TIERS.includes(tier as AccessTier)) { + return c.json( + { + error: 'Invalid tier', + message: `Tier must be one of: ${VALID_TIERS.join(', ')}`, + }, + 400 + ); + } + + const [updated] = await db + .update(users) + .set({ accessTier: tier as AccessTier, updatedAt: new Date() }) + .where(eq(users.id, userId)) + .returning({ id: users.id, email: users.email, accessTier: users.accessTier }); + + if (!updated) { + return c.json({ error: 'Not found', message: 'User not found' }, 404); + } + + return c.json({ + success: true, + user: updated, + }); + }); + + // ─── Get user's current tier ─────────────────────────────── + + app.get('/users/:userId/tier', async (c) => { + const { userId } = c.req.param(); + + const [user] = await db + .select({ id: users.id, email: users.email, accessTier: users.accessTier }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + if (!user) { + return c.json({ error: 'Not found', message: 'User not found' }, 404); + } + + return c.json(user); + }); + + // ─── List all users with their tiers ─────────────────────── + + app.get('/users', async (c) => { + const tier = c.req.query('tier'); + const limit = parseInt(c.req.query('limit') || '50', 10); + const offset = parseInt(c.req.query('offset') || '0', 10); + + let query = db + .select({ + id: users.id, + email: users.email, + name: users.name, + role: users.role, + accessTier: users.accessTier, + createdAt: users.createdAt, + }) + .from(users); + + if (tier && VALID_TIERS.includes(tier as AccessTier)) { + query = query.where(eq(users.accessTier, tier as AccessTier)) as typeof query; + } + + const result = await query.limit(limit).offset(offset); + + return c.json({ users: result, count: result.length }); + }); + + return app; +}