feat(auth): add access tier system for phased app releases

Introduces a tiered access control system so apps can be released
gradually (founder → alpha → beta → public) without extra infrastructure.
Users are gated at the AuthGate level based on their tier vs the app's
requiredTier. All apps remain deployed and reachable, but only users
with sufficient tier can enter.

- Add accessTier enum + column to users schema (default: 'public')
- Add tier claim to JWT payload in better-auth config
- Add requiredTier field to ManaApp interface + all 25 apps
- Add hasAppAccess(), getAccessibleManaApps(), ACCESS_TIER_LABELS
- Update AuthGate with tier check + access denied screen
- Update getPillAppItems + Home page to filter by user tier
- Update all 22 app layouts to pass user tier to PillNav
- Add admin API: GET/PUT /api/v1/admin/users/:id/tier
- Document access tier system in CLAUDE.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-30 21:50:06 +02:00
parent 4f68215e68
commit b737240ec1
33 changed files with 494 additions and 39 deletions

View file

@ -53,7 +53,7 @@
}
}
const appItems = getPillAppItems('calc');
let appItems = $derived(getPillAppItems('calc', undefined, undefined, authStore.user?.tier));
let { children } = $props();

View file

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

View file

@ -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();

View file

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

View file

@ -59,7 +59,7 @@
}
// App switcher items
const appItems = getPillAppItems('clock');
let appItems = $derived(getPillAppItems('clock', undefined, undefined, authStore.user?.tier));
let { children } = $props();

View file

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

View file

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

View file

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

View file

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

View file

@ -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();

View file

@ -78,7 +78,7 @@
}
// App switcher items
const appItems = getPillAppItems('matrix');
let appItems = $derived(getPillAppItems('matrix'));
interface Props {
children: Snippet;

View file

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

View file

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

View file

@ -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[] = [

View file

@ -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();

View file

@ -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();

View file

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

View file

@ -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();

View file

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

View file

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

View file

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

View file

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