feat(mana/web): global requireAuth() gate for guest-blocked features

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=<current path>` 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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-08 15:36:38 +02:00
parent 2b4494628e
commit 45958ad885
6 changed files with 356 additions and 2 deletions

View file

@ -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),
};
/**