From 5aecf8b90d5bcd2b249cb68eca918443df78d6d1 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 23 Apr 2026 22:49:52 +0200 Subject: [PATCH] =?UTF-8?q?feat(onboarding):=20M2=20=E2=80=94=20route=20gu?= =?UTF-8?q?ard=20+=20shell=20+=20Screen=201=20(name)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PATCH /api/v1/me/profile in mana-auth (name, image with 1–80 char validation) — powers the Screen-1 save - (app)/+layout.svelte: * isOnboarding derived from pathname * handleAuthReady loads onboardingStatus, redirects brand-new users to /onboarding/name (fire-and-forget so sync/data-layer init keeps running in parallel) * chrome (PillNav, wallpaper, bottom-stack) hidden in onboarding mode; AuthGate still wraps so the flow enforces authentication - /onboarding/+layout.svelte: full-viewport shell with progress dots (1/3, 2/3, 3/3) and a skip-all that marks the flow complete and sends the user home - /onboarding/+page.svelte: redirects bare entry to /onboarding/name - /onboarding/name/+page.svelte: text input (1–40 chars), Enter = Weiter, skip falls back to email local-part so Screen 2's greeting is never empty Co-Authored-By: Claude Opus 4.7 (1M context) --- .../apps/web/src/routes/(app)/+layout.svelte | 497 ++++++++++-------- .../routes/(app)/onboarding/+layout.svelte | 140 +++++ .../src/routes/(app)/onboarding/+page.svelte | 12 + .../routes/(app)/onboarding/name/+page.svelte | 221 ++++++++ services/mana-auth/src/index.ts | 2 +- services/mana-auth/src/routes/me.ts | 46 +- 6 files changed, 689 insertions(+), 229 deletions(-) create mode 100644 apps/mana/apps/web/src/routes/(app)/onboarding/+layout.svelte create mode 100644 apps/mana/apps/web/src/routes/(app)/onboarding/+page.svelte create mode 100644 apps/mana/apps/web/src/routes/(app)/onboarding/name/+page.svelte diff --git a/apps/mana/apps/web/src/routes/(app)/+layout.svelte b/apps/mana/apps/web/src/routes/(app)/+layout.svelte index 13aebd204..47057503c 100644 --- a/apps/mana/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/mana/apps/web/src/routes/(app)/+layout.svelte @@ -82,6 +82,7 @@ import { authStore } from '$lib/stores/auth.svelte'; import { userSettings } from '$lib/stores/user-settings.svelte'; import { isNavCollapsed as collapsedStore } from '$lib/stores/navigation'; + import { onboardingStatus } from '$lib/stores/onboarding-status.svelte'; import { getPillAppItems } from '@mana/shared-branding'; import { STORAGE_KEYS } from '$lib/config/storage-keys'; import { SearchRegistry } from '$lib/search/registry'; @@ -109,6 +110,14 @@ else setTimeout(cb, 0); } + // ── Onboarding mode ───────────────────────────────────── + // When the user is on any /onboarding/* route the main chrome + // (PillNav, wallpaper, bottom-stack) is hidden so the three + // onboarding screens get the full viewport. The route guard + // below also reads this flag to avoid redirecting a user who + // is already inside the flow. + let isOnboarding = $derived($page.url.pathname.startsWith('/onboarding')); + // ── App switcher ──────────────────────────────────────── // Prefer the active Space's tier for gating — falls back to the user // tier only during the bootstrap window where no space has loaded. @@ -311,15 +320,25 @@ // apps now — no standalone routes. The user-menu dropdown links via // `spiralHref` / `creditsHref` / `profileHref` etc., all pointing to // `/?app=` deep-links. + let isOnWorkbench = $derived($page.url.pathname === '/'); let baseNavItems = $derived([ - { - href: '/', - label: 'Workbench-Tabs', - icon: 'tabs', - iconOnly: true, - onClick: handleBottomBarToggle, - active: isBottomBarVisible, - }, + isOnWorkbench + ? { + href: '/', + label: 'Workbench-Tabs', + icon: 'tabs', + iconOnly: true, + onClick: handleBottomBarToggle, + active: isBottomBarVisible, + } + : { + href: '/', + label: 'Home', + icon: 'home', + iconOnly: true, + onClick: () => goto('/'), + active: false, + }, { href: '/', label: 'Suche', @@ -626,6 +645,22 @@ // value (0 on a fresh tab) until a sync actually runs. refreshPendingCount(); + // Onboarding guard: brand-new users land on `/` after signup + // but have `onboardingCompletedAt === null`. Redirect them + // into the 3-screen flow. Fired non-blocking because the + // earlier Phase A already initialized the data layer, so + // leaving sync running in parallel during onboarding is + // harmless (and useful — templates/+page.svelte writes a + // scene at the end). Self-skips when already inside the + // flow so the screens don't bounce each other. + if (!isOnboarding) { + void onboardingStatus.load().then(() => { + if (onboardingStatus.needsOnboarding) { + goto('/onboarding/name', { replaceState: true }); + } + }); + } + // Phase B-idle: settings + return-visit telemetry. Non-gating. idle(async () => { trackReturnVisit(); @@ -769,270 +804,278 @@ appName="Mana" locale={($locale || 'de') === 'de' ? 'de' : 'en'} > -
- + {#if isOnboarding} + + {@render children()} + {:else} +
+ - - {#if !isFullscreen} -
- - {#if isBottomBarVisible && bottomBarStore.component} - {@const BarComponent = bottomBarStore.component} - - {/if} + {#if isBottomBarVisible && bottomBarStore.component} + {@const BarComponent = bottomBarStore.component} + + {/if} - - {#if EncryptionIntroBannerC} - {@const EncryptionIntroBanner = EncryptionIntroBannerC} -
- -
- {/if} + {#if EncryptionIntroBannerC} + {@const EncryptionIntroBanner = EncryptionIntroBannerC} +
+ +
+ {/if} - - {#if syncBilling.paused} -
-
- Cloud Sync pausiert — Credits reichen nicht aus. -
- - Credits aufladen - - - Sync-Einstellungen - + + {#if syncBilling.paused} +
+
+ Cloud Sync pausiert — Credits reichen nicht aus. +
-
- {/if} + {/if} - - {#if (guestMode && guestMode.notifications.length > 0) || guestPrompt.notifications.length > 0} -
- -
- {/if} + {#if (guestMode && guestMode.notifications.length > 0) || guestPrompt.notifications.length > 0} +
+ +
+ {/if} - - {#if authStore.isAuthenticated && SessionWarningC} - {@const SessionWarning = SessionWarningC} -
- -
- {/if} + {#if authStore.isAuthenticated && SessionWarningC} + {@const SessionWarning = SessionWarningC} +
+ +
+ {/if} - - {#if SuggestionToastC} - {@const SuggestionToast = SuggestionToastC} -
- -
- {/if} + {#if SuggestionToastC} + {@const SuggestionToast = SuggestionToastC} +
+ +
+ {/if} - - {#if NudgeToastC} - {@const NudgeToast = NudgeToastC} -
- -
- {/if} + {#if NudgeToastC} + {@const NudgeToast = NudgeToastC} +
+ +
+ {/if} - - {#if isQuickInputVisible} - - {#snippet leftAction()} - - {/snippet} - - {/if} + + {#if isQuickInputVisible} + + {#snippet leftAction()} + + {/snippet} + + {/if} - - {#if isTagStripVisible} - ({ - id: t.id, - name: t.name, - color: t.color || '#3b82f6', - }))} - selectedIds={[]} - onToggle={() => {}} - onClear={() => {}} - onTagDrop={tagDropHandler ?? undefined} - managementHref="/tags" - loading={allTags.loading} - positioning="static" - /> - {/if} + + {#if isTagStripVisible} + ({ + id: t.id, + name: t.name, + color: t.color || '#3b82f6', + }))} + selectedIds={[]} + onToggle={() => {}} + onClear={() => {}} + onTagDrop={tagDropHandler ?? undefined} + managementHref="/tags" + loading={allTags.loading} + positioning="static" + /> + {/if} - - {#if activeBar} - + {/if} + + + - {/if} + > + {#snippet startSlot()} + {#if authStore.isAuthenticated} + + {/if} + {/snippet} + +
+ {/if} - - - {#snippet startSlot()} - {#if authStore.isAuthenticated} - - {/if} - {/snippet} - -
- {/if} + + - - - - -
-
- {#if routeBlocked && routeAppId} - - {:else} - {@render children()} - {/if} -
-
+
+
+ {#if routeBlocked && routeAppId} + + {:else} + {@render children()} + {/if} +
+
- - - {#if KeyboardShortcutsModalC} - {@const KeyboardShortcutsModal = KeyboardShortcutsModalC} - (showShortcuts = false)} /> - {/if} -
+ + {#if KeyboardShortcutsModalC} + {@const KeyboardShortcutsModal = KeyboardShortcutsModalC} + (showShortcuts = false)} /> + {/if} +
+ {/if} + + +
+
+
+ {#each [0, 1, 2] as step (step)} + + {/each} +
+ +
+ +
+ {@render children()} +
+
+ + diff --git a/apps/mana/apps/web/src/routes/(app)/onboarding/+page.svelte b/apps/mana/apps/web/src/routes/(app)/onboarding/+page.svelte new file mode 100644 index 000000000..601b2dfe2 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/onboarding/+page.svelte @@ -0,0 +1,12 @@ + + diff --git a/apps/mana/apps/web/src/routes/(app)/onboarding/name/+page.svelte b/apps/mana/apps/web/src/routes/(app)/onboarding/name/+page.svelte new file mode 100644 index 000000000..4626b4287 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/onboarding/name/+page.svelte @@ -0,0 +1,221 @@ + + + +
+
+

Wie willst du genannt werden?

+

Dein Name erscheint oben in der Navigation und in Nachrichten von Mana.

+
+ +
+ + + {#if error} + + {/if} +
+ +
+ + +
+
+ + diff --git a/services/mana-auth/src/index.ts b/services/mana-auth/src/index.ts index cdcbfb9ee..ec185ec21 100644 --- a/services/mana-auth/src/index.ts +++ b/services/mana-auth/src/index.ts @@ -99,7 +99,7 @@ app.route('/api/v1/api-keys', createApiKeyValidationRoute(apiKeysService)); // ─── Me (GDPR) ────────────────────────────────────────────── app.use('/api/v1/me/*', jwtAuth(config.baseUrl)); -app.route('/api/v1/me', createMeRoutes(userDataService)); +app.route('/api/v1/me', createMeRoutes(userDataService, db)); // ─── Encryption vault (per-user master key custody) ──────── // Mounted under /me so it inherits the JWT middleware above and shows diff --git a/services/mana-auth/src/routes/me.ts b/services/mana-auth/src/routes/me.ts index 1601d85fc..207a59e7b 100644 --- a/services/mana-auth/src/routes/me.ts +++ b/services/mana-auth/src/routes/me.ts @@ -7,11 +7,14 @@ */ import { Hono } from 'hono'; +import { eq } from 'drizzle-orm'; import type { AuthUser } from '../middleware/jwt-auth'; import type { UserDataService } from '../services/user-data'; +import type { Database } from '../db/connection'; +import { users } from '../db/schema/auth'; import { sendAccountDeletionEmail } from '../email/send'; -export function createMeRoutes(userDataService: UserDataService) { +export function createMeRoutes(userDataService: UserDataService, db: Database) { return ( new Hono<{ Variables: { user: AuthUser } }>() @@ -57,5 +60,46 @@ export function createMeRoutes(userDataService: UserDataService) { return c.json(result); }) + + // ─── Update profile (name, avatar) ────────────────────── + // Minimal patch endpoint used by the onboarding flow and + // Settings → Profile. JWT-based like the rest of /me/*; the + // updated name only lands in the user's JWT on next mint, so + // the caller is responsible for refreshing its in-memory + // representation of authStore.user. See docs/plans/onboarding-flow.md. + .patch('/profile', async (c) => { + const user = c.get('user'); + const body = (await c.req.json().catch(() => ({}))) as { + name?: unknown; + image?: unknown; + }; + + const patch: { name?: string; image?: string; updatedAt: Date } = { + updatedAt: new Date(), + }; + if (typeof body.name === 'string') { + const trimmed = body.name.trim(); + if (trimmed.length < 1 || trimmed.length > 80) { + return c.json({ error: 'name must be 1–80 characters' }, 400); + } + patch.name = trimmed; + } + if (typeof body.image === 'string') { + patch.image = body.image; + } + + if (!('name' in patch) && !('image' in patch)) { + return c.json({ error: 'no fields to update' }, 400); + } + + const [updated] = await db + .update(users) + .set(patch) + .where(eq(users.id, user.userId)) + .returning({ id: users.id, name: users.name, image: users.image }); + + if (!updated) return c.json({ error: 'User not found' }, 404); + return c.json({ name: updated.name, image: updated.image }); + }) ); }