mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-19 06:21:23 +02:00
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:
parent
2b4494628e
commit
45958ad885
6 changed files with 356 additions and 2 deletions
158
apps/mana/apps/web/src/lib/auth/require-auth.svelte.ts
Normal file
158
apps/mana/apps/web/src/lib/auth/require-auth.svelte.ts
Normal 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 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<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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)} />
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue