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

@ -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 12 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<PendingPrompt | null>(null);
open(options: RequireAuthOptions): Promise<boolean> {
// 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<boolean>((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=<current path>` 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<boolean> {
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<void> {
const here = page.url?.pathname ?? '/';
const next = here === '/login' ? '/' : here;
await goto(`/login?next=${encodeURIComponent(next)}`);
authGateState.resolve(false);
}

View file

@ -0,0 +1,161 @@
<!--
Global "this feature needs an account" modal.
Mounted ONCE in the root layout. Subscribes to `authGateState.pending`,
which any module can fill via `requireAuth({ feature, reason })`. The
modal stays out of the DOM tree completely while there's no pending
prompt — no portals, no z-index gymnastics.
Why a single global modal?
- Every module would otherwise need its own copy of the same dialog
with the same buttons and the same login navigation logic.
- Two modules can't both prompt at once: the gate state replaces any
existing pending prompt with the new one and resolves the old one
as cancelled. That's the right behaviour anyway — if the user
triggers two auth-gated actions in the same tick, only the most
recent one is what they actually meant.
-->
<script lang="ts">
import { authGateState, navigateToLogin } from '$lib/auth/require-auth.svelte';
import { ManaEvents } from '@mana/shared-utils/analytics';
function cancel() {
const feature = authGateState.pending?.options.feature;
authGateState.resolve(false);
if (feature) {
ManaEvents.featureBlockedByAuth({ feature, action: 'cancel' });
}
}
async function confirm() {
const feature = authGateState.pending?.options.feature;
if (feature) {
ManaEvents.featureBlockedByAuth({ feature, action: 'login' });
}
await navigateToLogin();
}
function handleBackdrop(event: MouseEvent) {
if (event.target === event.currentTarget) cancel();
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') cancel();
}
</script>
{#if authGateState.pending}
{@const opts = authGateState.pending.options}
<div
class="backdrop"
role="dialog"
aria-modal="true"
aria-labelledby="auth-required-title"
onclick={handleBackdrop}
onkeydown={handleKeydown}
tabindex="-1"
>
<div class="modal">
<header>
<h2 id="auth-required-title">{opts.title ?? 'Konto erforderlich'}</h2>
</header>
<p>{opts.reason}</p>
<p class="hint">
Du kannst dich kostenlos registrieren oder mit einem bestehenden Mana-Konto anmelden. Nach
dem Login landest du wieder hier.
</p>
<div class="actions">
<button type="button" class="btn btn-secondary" onclick={cancel}>
{opts.cancelLabel ?? 'Abbrechen'}
</button>
<!-- svelte-ignore a11y_autofocus -->
<button type="button" class="btn btn-primary" onclick={confirm} autofocus>
{opts.loginLabel ?? 'Anmelden'}
</button>
</div>
</div>
</div>
{/if}
<style>
.backdrop {
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
backdrop-filter: blur(4px);
}
.modal {
max-width: 30rem;
width: 100%;
background: var(--surface, #fff);
border: 1px solid var(--border, #e5e7eb);
border-radius: 0.75rem;
padding: 1.5rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.4);
}
header {
margin-bottom: 0.5rem;
}
h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--foreground, #111);
}
p {
margin: 0.75rem 0;
font-size: 0.95rem;
line-height: 1.5;
color: var(--foreground, #222);
}
.hint {
font-size: 0.85rem;
color: var(--muted-foreground, #666);
}
.actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 1.25rem;
}
.btn {
padding: 0.55rem 1.25rem;
border-radius: 0.5rem;
border: 1px solid var(--border, #e5e7eb);
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
background: var(--surface, #fff);
color: var(--foreground, #111);
transition: background 0.15s ease;
}
.btn:hover {
background: var(--surface-hover, #f3f4f6);
}
.btn-primary {
background: var(--primary, #6366f1);
color: #fff;
border-color: var(--primary, #6366f1);
}
.btn-primary:hover {
background: var(--primary-hover, #4f46e5);
}
</style>

View file

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

View file

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

View file

@ -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 @@
<OfflineIndicator />
<PwaUpdatePrompt />
<EncryptionIntroBanner />
<AuthRequiredModal />
{#if needsRecoveryCode}
<RecoveryCodeUnlockModal onUnlocked={() => (needsRecoveryCode = false)} />

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