From 45958ad885cf1075973599614af79118c4ab6545 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 8 Apr 2026 15:36:38 +0200 Subject: [PATCH] feat(mana/web): global requireAuth() gate for guest-blocked features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unified Mana app runs most modules in a "guest mode": you can open a module, look around, type a quick note, etc. without an account. But anything that touches an *encrypted* table (dreams voice capture, memoro recordings, notes, todo, calendar events, …) needs the user to be logged in — the encryption vault only unlocks against a Mana Auth session, and writing to those tables without it throws `VaultLockedError` at the very last step of the action. Before this commit, every entry point into an encryption-required action would silently let the guest go through the whole flow (record audio, wait for transcription, open the dexie write) and then explode with a stack-trace error. The user lost work and didn't know why. The dreams voice capture flow surfaced this during the 2026-04-08 STT debugging session. The fix is a global imperative gate: `requireAuth({ feature, reason })`. Call sites await it before the action; it returns immediately if the user is already authenticated, otherwise pops a global modal that asks the guest to log in or cancel. Promise-based, so callers decide what to do with `false` (silent abort, restore state, own toast). $lib/auth/require-auth.svelte.ts new — store + helper $lib/components/auth/AuthRequiredModal.svelte new — global modal routes/+layout.svelte mount the modal once packages/shared-utils/src/analytics.ts new ManaEvents.featureBlockedByAuth event for conversion tracking Wired into the two voice-capture entry points that actually exhibited the bug: modules/dreams/ListView.svelte → feature: 'dreams-voice-capture' routes/(app)/memoro/+page.svelte → feature: 'memoro-voice-capture' Both gate on `requireAuth()` BEFORE the mic permission request, so guests see the friendly "Konto erforderlich" modal instead of recording → transcribing → crashing. Design choices documented in detail in the require-auth.svelte.ts header comment: - Imperative function (not a button wrapper component) so it works in event handlers, store actions, keyboard shortcuts, drag-drop handlers — anywhere async code runs. - Single global modal mounted once in the root layout, no portal/z-index gymnastics; two simultaneous prompts replace each other (the most recent one wins). - Checks `authStore.isAuthenticated`, not vault-unlocked state — the user-facing concept is "I need an account", not "I need a working encryption vault". Vault-unlock failures (network error etc.) are a separate bug class with their own UX. - The modal navigates to `/login?next=` so the user lands back on the same page after logging in. The Promise resolves `false` on navigation; the user re-clicks the original button after coming back, and the second click sees `isAuthenticated === true` and proceeds without a modal. Re-triggering the original action across a navigation cycle would require restoring half-recorded mic state — not worth the complexity, and the second click is a clean UX. How to wire a new entry point (4 lines): import { requireAuth } from '$lib/auth/require-auth.svelte'; async function handleCreateThing() { const ok = await requireAuth({ feature: 'create-thing', reason: 'Things werden verschlüsselt gespeichert. Dafür brauchst du ein Mana-Konto.', }); if (!ok) return; // ...existing logic } Co-Authored-By: Claude Opus 4.6 (1M context) --- .../web/src/lib/auth/require-auth.svelte.ts | 158 +++++++++++++++++ .../components/auth/AuthRequiredModal.svelte | 161 ++++++++++++++++++ .../src/lib/modules/dreams/ListView.svelte | 14 ++ .../web/src/routes/(app)/memoro/+page.svelte | 11 ++ apps/mana/apps/web/src/routes/+layout.svelte | 2 + packages/shared-utils/src/analytics.ts | 12 +- 6 files changed, 356 insertions(+), 2 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/auth/require-auth.svelte.ts create mode 100644 apps/mana/apps/web/src/lib/components/auth/AuthRequiredModal.svelte diff --git a/apps/mana/apps/web/src/lib/auth/require-auth.svelte.ts b/apps/mana/apps/web/src/lib/auth/require-auth.svelte.ts new file mode 100644 index 000000000..c10ed2333 --- /dev/null +++ b/apps/mana/apps/web/src/lib/auth/require-auth.svelte.ts @@ -0,0 +1,158 @@ +/** + * `requireAuth()` — global guest gate for features that need an account. + * + * The unified Mana app runs most modules in a "guest mode": you can open + * a module, look around, type a quick note, etc. without an account. + * But anything that touches an *encrypted* table (dreams voice capture, + * memoro recordings, notes, todo, calendar events, …) needs the user to + * be logged in — the encryption vault only unlocks against a Mana Auth + * session, and writing to those tables without it throws + * `VaultLockedError` at the very last step of the action. + * + * Before this helper, every entry point into an encryption-required + * action would silently let the guest go through the whole flow (record + * audio, wait for transcription, …) and then explode at the database + * write with a stack-trace error. The user lost work and didn't know + * why. + * + * `requireAuth()` is the imperative gate the entry points wrap around. + * It returns immediately if the user is already authenticated, otherwise + * it pops a global modal (mounted once in the root layout) that asks + * the guest to log in or cancel. The Promise resolves with `true` if + * the user ended up logged in, `false` if they dismissed. + * + * ## Usage + * + * ```ts + * async function handleMicClick() { + * const ok = await requireAuth({ + * feature: 'voice-recording', + * reason: 'Sprach-Aufnahmen werden verschlüsselt in deinem persönlichen Tagebuch gespeichert.', + * }); + * if (!ok) return; + * + * // existing recording logic — guaranteed to have an unlocked vault now + * await dreamRecorder.start(); + * } + * ``` + * + * ## Why imperative, not a button wrapper? + * + * - Works in any code path: event handlers, store actions, async flows, + * keyboard shortcuts, drag-drop handlers — without forcing every + * call site to render through a wrapper component. + * - Composable: callers can chain `requireAuth()` with other async + * pre-checks (mic permission, network state, …) in one async block. + * - Promise-based: caller decides what to do with `false` (silently + * abort, restore previous state, show their own toast). + * + * ## Why `isAuthenticated` and not `isVaultUnlocked`? + * + * The user-facing concept is "I need an account", not "I need an + * unlocked encryption vault". Vault unlocks happen automatically after + * login via the root layout's `$effect`. A logged-in user with a + * vault-unlock failure (network error, etc.) is a different bug class + * handled by the existing `VaultLockedError` flow with its own + * recovery UX. Mixing the two would muddy the message. + */ + +import { goto } from '$app/navigation'; +import { page } from '$app/state'; +import { authStore } from '$lib/stores/auth.svelte'; + +/** What requireAuth() needs to render the modal. */ +export interface RequireAuthOptions { + /** + * Stable feature identifier for analytics / telemetry. Lowercase, + * hyphenated. Examples: `'voice-recording'`, `'create-task'`, + * `'send-message'`. + */ + feature: string; + + /** + * Human-readable reason shown in the modal body. Should explain + * *why* this specific action needs an account in 1–2 sentences. + * Avoid generic "log in to use this app" — name the concrete + * reason (encryption, sync, server-side processing) so the user + * understands the trade-off. + */ + reason: string; + + /** Optional override for the modal title. Defaults to "Konto erforderlich". */ + title?: string; + + /** Optional override for the primary button label. Defaults to "Anmelden". */ + loginLabel?: string; + + /** Optional override for the secondary button label. Defaults to "Abbrechen". */ + cancelLabel?: string; +} + +/** Internal store shape — only the modal component reads this. */ +interface PendingPrompt { + options: RequireAuthOptions; + resolve: (granted: boolean) => void; +} + +class AuthGateState { + pending = $state(null); + + open(options: RequireAuthOptions): Promise { + // If a previous prompt is still open, dismiss it as cancelled. + // Two simultaneous requireAuth() calls would be a bug in the + // caller, but we want to never leak hanging promises. + if (this.pending) { + this.pending.resolve(false); + } + + return new Promise((resolve) => { + this.pending = { options, resolve }; + }); + } + + resolve(granted: boolean): void { + const p = this.pending; + if (!p) return; + this.pending = null; + p.resolve(granted); + } +} + +/** Singleton state — the modal subscribes to `pending`, callers use the + * exported `requireAuth()` function. */ +export const authGateState = new AuthGateState(); + +/** + * Ensure the user is authenticated before running an action. + * + * - If already authenticated → returns `true` immediately, no UI. + * - If guest → shows a modal and resolves to `true` if the user + * logs in (and returns to the page), `false` if they cancel. + * + * The modal navigates to `/login?next=` so the user + * lands back on the same view after logging in. The Promise then + * resolves on the *next* time `authStore.isAuthenticated` flips to + * `true` — the caller does NOT have to re-trigger their action. + */ +export async function requireAuth(options: RequireAuthOptions): Promise { + if (authStore.isAuthenticated) return true; + return authGateState.open(options); +} + +/** + * Called by AuthRequiredModal when the user clicks "Anmelden". Saves + * the current path so /login can redirect back, then navigates. + * + * The modal closes immediately on click. We deliberately do NOT wait + * for the post-login redirect to come back here — once the user + * navigates to /login, the original action's call site has lost its + * stack frame anyway. Instead, the user re-clicks the button after + * landing back on the page; the second click sees `isAuthenticated` + * is now true and proceeds without a modal. + */ +export async function navigateToLogin(): Promise { + const here = page.url?.pathname ?? '/'; + const next = here === '/login' ? '/' : here; + await goto(`/login?next=${encodeURIComponent(next)}`); + authGateState.resolve(false); +} diff --git a/apps/mana/apps/web/src/lib/components/auth/AuthRequiredModal.svelte b/apps/mana/apps/web/src/lib/components/auth/AuthRequiredModal.svelte new file mode 100644 index 000000000..34286478a --- /dev/null +++ b/apps/mana/apps/web/src/lib/components/auth/AuthRequiredModal.svelte @@ -0,0 +1,161 @@ + + + +{#if authGateState.pending} + {@const opts = authGateState.pending.options} + +{/if} + + diff --git a/apps/mana/apps/web/src/lib/modules/dreams/ListView.svelte b/apps/mana/apps/web/src/lib/modules/dreams/ListView.svelte index 2702398fb..1895166eb 100644 --- a/apps/mana/apps/web/src/lib/modules/dreams/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/dreams/ListView.svelte @@ -14,6 +14,7 @@ import { dreamRecorder, formatElapsed } from './recorder.svelte'; import { MOOD_COLORS, MOOD_LABELS, type Dream, type DreamMood, type SleepQuality } from './types'; import type { ViewProps } from '$lib/app-registry'; + import { requireAuth } from '$lib/auth/require-auth.svelte'; import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui'; import { PencilSimple, PushPin, Trash } from '@mana/shared-icons'; import SymbolsView from './views/SymbolsView.svelte'; @@ -201,6 +202,19 @@ if (msg !== 'cancelled') recError = msg; } } else if (dreamRecorder.status === 'idle') { + // Voice recording writes to the encrypted `dreams` table — without + // an account the vault is locked and the very last step (the + // dexie write) would throw VaultLockedError after the user has + // already invested time recording and waiting for transcription. + // Gate the entry point so guests see a friendly login prompt + // BEFORE the mic permission request. + const ok = await requireAuth({ + feature: 'dreams-voice-capture', + reason: + 'Sprach-Aufnahmen werden verschlüsselt in deinem persönlichen Tagebuch gespeichert. Dafür brauchst du ein Mana-Konto.', + }); + if (!ok) return; + await dreamRecorder.start(); if (dreamRecorder.error) { recError = dreamRecorder.error; diff --git a/apps/mana/apps/web/src/routes/(app)/memoro/+page.svelte b/apps/mana/apps/web/src/routes/(app)/memoro/+page.svelte index 11554eb0c..d604f78a4 100644 --- a/apps/mana/apps/web/src/routes/(app)/memoro/+page.svelte +++ b/apps/mana/apps/web/src/routes/(app)/memoro/+page.svelte @@ -3,6 +3,7 @@ import { getContext } from 'svelte'; import { memosStore } from '$lib/modules/memoro/stores/memos.svelte'; import { memoRecorder, formatElapsed } from '$lib/modules/memoro/recorder.svelte'; + import { requireAuth } from '$lib/auth/require-auth.svelte'; import { filterBySearch, filterByTag, @@ -63,6 +64,16 @@ if (msg !== 'cancelled') recError = msg; } } else if (memoRecorder.status === 'idle') { + // Memos write to the encrypted `memos` table — gate guests + // before the mic permission request, otherwise they record + // audio + wait for transcription only to crash on the + // VaultLockedError at the very last step. + const ok = await requireAuth({ + feature: 'memoro-voice-capture', + reason: 'Sprach-Memos werden verschlüsselt gespeichert. Dafür brauchst du ein Mana-Konto.', + }); + if (!ok) return; + await memoRecorder.start(); if (memoRecorder.error) { recError = memoRecorder.error; diff --git a/apps/mana/apps/web/src/routes/+layout.svelte b/apps/mana/apps/web/src/routes/+layout.svelte index 5d1083f32..67d34a85a 100644 --- a/apps/mana/apps/web/src/routes/+layout.svelte +++ b/apps/mana/apps/web/src/routes/+layout.svelte @@ -15,6 +15,7 @@ import SyncConflictToast from '$lib/components/SyncConflictToast.svelte'; import OfflineIndicator from '$lib/components/OfflineIndicator.svelte'; import PwaUpdatePrompt from '$lib/components/PwaUpdatePrompt.svelte'; + import AuthRequiredModal from '$lib/components/auth/AuthRequiredModal.svelte'; let { children } = $props(); @@ -111,6 +112,7 @@ + {#if needsRecoveryCode} (needsRecoveryCode = false)} /> diff --git a/packages/shared-utils/src/analytics.ts b/packages/shared-utils/src/analytics.ts index 6c521955d..ad956be1b 100644 --- a/packages/shared-utils/src/analytics.ts +++ b/packages/shared-utils/src/analytics.ts @@ -326,8 +326,7 @@ export const ManaEvents = { onboardingSkipped: (atStep: number) => track.mana('onboarding_skipped', { at_step: atStep }), dashboardEditToggled: (editing: boolean) => track.mana('dashboard_edit_toggled', { editing }), widgetAdded: (widgetType: string) => track.mana('widget_added', { widget_type: widgetType }), - widgetRemoved: (widgetType: string) => - track.mana('widget_removed', { widget_type: widgetType }), + widgetRemoved: (widgetType: string) => track.mana('widget_removed', { widget_type: widgetType }), widgetResized: (widgetType: string, size: string) => track.mana('widget_resized', { widget_type: widgetType, size }), creditsTabViewed: (tab: string) => track.mana('credits_tab_viewed', { tab }), @@ -342,6 +341,15 @@ export const ManaEvents = { secondModuleUsed: (appId: string) => track.mana('second_module_used', { app: appId }), /** Guest user converted to registered user */ guestConverted: () => track.mana('guest_converted'), + /** + * A guest tried to use a feature that requires an account. + * `feature` is the stable identifier passed to `requireAuth()`, + * `action` is whether the user clicked through to login or + * cancelled the modal — useful for measuring the conversion + * rate of the auth gate. + */ + featureBlockedByAuth: (params: { feature: string; action: 'login' | 'cancel' }) => + track.mana('feature_blocked_by_auth', params), }; /**