diff --git a/apps/mana/apps/web/src/lib/api/base-client.ts b/apps/mana/apps/web/src/lib/api/base-client.ts index 686b47990..d6c26ccbf 100644 --- a/apps/mana/apps/web/src/lib/api/base-client.ts +++ b/apps/mana/apps/web/src/lib/api/base-client.ts @@ -5,6 +5,7 @@ */ import { authStore } from '$lib/stores/auth.svelte'; +import { guestPrompt } from '$lib/stores/guest-prompt.svelte'; /** * Retry configuration @@ -87,6 +88,19 @@ export async function fetchWithRetry( const config = { ...DEFAULT_RETRY_CONFIG, ...retryConfig }; let lastError: string | null = null; + // Short-circuit for guest mode: skip the entire fetch ladder, surface + // the unified bottom-bar prompt instead. Without this, every server- + // only feature (LLM, media upload, search, …) would still hit the + // network and return a 401 — costing latency, log noise, and a worse + // error message ("Sitzung abgelaufen") than what we can show locally. + if (!authStore.isAuthenticated) { + guestPrompt.requireAccount( + 'api', + 'Diese Funktion braucht ein Konto. Melde dich an, um sie zu nutzen.' + ); + return { data: null, error: 'Anmeldung erforderlich' }; + } + for (let attempt = 0; attempt <= config.maxRetries; attempt++) { try { // Get fresh token for each attempt @@ -104,6 +118,14 @@ export async function fetchWithRetry( if (!response.ok) { // Don't retry on auth errors if (response.status === 401) { + // Token has expired or been revoked server-side. Surface + // the unified prompt so the user has a one-click path + // back into the auth flow instead of a silent failure. + guestPrompt.requireAccount( + 'api:401', + 'Sitzung abgelaufen — bitte neu anmelden, um fortzufahren.', + 'Neu anmelden' + ); return { data: null, error: 'Sitzung abgelaufen — bitte neu anmelden', diff --git a/apps/mana/apps/web/src/lib/stores/guest-prompt.svelte.ts b/apps/mana/apps/web/src/lib/stores/guest-prompt.svelte.ts new file mode 100644 index 000000000..2fd43fcec --- /dev/null +++ b/apps/mana/apps/web/src/lib/stores/guest-prompt.svelte.ts @@ -0,0 +1,102 @@ +/** + * Unified guest-mode prompt singleton. + * + * The (app) layout already renders a NotificationBar in the bottom stack + * for the time-based guest nudge from `createGuestMode` (shared-stores). + * This singleton lets the rest of the app push *event-driven* prompts + * into the same visual slot — e.g. when an API call returns 401 or a + * server-only feature is invoked while signed out. + * + * Why a separate store from `createGuestMode`? + * - The shared-stores GuestMode is cross-app and only knows about its + * own internal nudge timer. Adding a "push notification from anywhere" + * mutator there would force every host app to wire it up. + * - This file is mana-web-local and reads from authStore directly, so + * no cross-package surface change is needed. + * + * Dedup: each prompt is keyed by `featureKey`. Calling `requireAccount` + * twice with the same key while the first prompt is still visible is a + * no-op — we don't want a stack of "you need an account" toasts after + * three failed LLM calls in a row. + * + * Auto-clear: when authStore flips to authenticated, all pending guest + * prompts vanish. The (app) layout's auth effect calls `clear()` on + * sign-in. + */ + +import type { BottomNotification } from '@mana/shared-ui'; + +let prompts = $state([]); + +/** Default action target — the login page already exposes both login + * and register flows so we point at one URL and let the user pick. */ +const DEFAULT_LOGIN_HREF = '/login'; + +/** Navigates the browser. Kept as a small wrapper so unit tests can + * swap it out without pulling SvelteKit's `goto`. */ +let navigate: (href: string) => void = (href) => { + if (typeof window !== 'undefined') window.location.assign(href); +}; + +/** Test / boot hook: lets the layout swap in SvelteKit's `goto` so + * the prompt action does a client-side transition instead of a full + * page reload. */ +export function setGuestPromptNavigator(fn: (href: string) => void): void { + navigate = fn; +} + +export const guestPrompt = { + get notifications(): BottomNotification[] { + return prompts; + }, + + /** + * Push a "this needs an account" notification into the bottom stack. + * Deduped by `featureKey` — repeat calls while the same prompt is + * still on screen are a no-op so the bar doesn't grow. + * + * @param featureKey Stable id (`'llm'`, `'api:401'`, `'media-upload'`, …) + * @param message User-facing copy. Defaults to a generic message + * — pass a specific one when you can ("KI-Antworten + * brauchen ein Konto.") for better UX. + * @param actionLabel Button label. Defaults to "Anmelden". + */ + requireAccount( + featureKey: string, + message: string = 'Diese Funktion braucht ein Konto. Melde dich an oder registriere dich, um sie zu nutzen.', + actionLabel: string = 'Anmelden' + ): void { + const id = `guest-prompt:${featureKey}`; + if (prompts.some((p) => p.id === id)) return; + + prompts = [ + ...prompts, + { + id, + message, + type: 'warning', + dismissible: true, + action: { + label: actionLabel, + onClick: () => { + navigate(DEFAULT_LOGIN_HREF); + guestPrompt.dismiss(id); + }, + }, + onDismiss: () => guestPrompt.dismiss(id), + }, + ]; + }, + + /** Drop a single prompt by id. Safe to call for unknown ids. */ + dismiss(id: string): void { + prompts = prompts.filter((p) => p.id !== id); + }, + + /** Drop every prompt. Called by the (app) layout on sign-in so the + * bar doesn't carry stale "you need an account" warnings into the + * authenticated session. */ + clear(): void { + prompts = []; + }, +}; diff --git a/apps/mana/apps/web/src/routes/(app)/+layout.svelte b/apps/mana/apps/web/src/routes/(app)/+layout.svelte index fd24b1f6a..2bb7aa9cf 100644 --- a/apps/mana/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/mana/apps/web/src/routes/(app)/+layout.svelte @@ -31,6 +31,7 @@ import { createFallbackAdapter } from '$lib/quick-input/fallback-adapter'; import { AuthGate, GuestWelcomeModal } from '@mana/shared-auth-ui'; import { createGuestMode, type GuestMode } from '$lib/stores/guest-mode.svelte'; + import { guestPrompt, setGuestPromptNavigator } from '$lib/stores/guest-prompt.svelte'; import { NotificationBar } from '@mana/shared-ui'; import { tagLocalStore, tagMutations, useAllTags } from '@mana/shared-stores'; import { linkLocalStore, linkMutations } from '@mana/shared-links'; @@ -286,6 +287,15 @@ // ── Auth Ready (replaces monolith onMount) ────────────── async function handleAuthReady() { + // Wire the unified guest-prompt singleton to SvelteKit's `goto` + // so the "Anmelden" button does a client-side transition instead + // of the default full-page reload. Idempotent — safe to call on + // every auth-ready cycle. If the user signs in successfully, + // drop any leftover guest prompts so the bottom bar starts the + // authenticated session clean. + setGuestPromptNavigator((href) => goto(href)); + if (authStore.isAuthenticated) guestPrompt.clear(); + // Phase A: Auth-independent — guests + authenticated await Promise.all([ manaStore.initialize(), @@ -454,10 +464,17 @@ - - {#if guestMode && guestMode.notifications.length > 0} + + {#if (guestMode && guestMode.notifications.length > 0) || guestPrompt.notifications.length > 0}
- +
{/if}