diff --git a/controller/user.go b/controller/user.go index b7bbdada2e3f..39ab038f2dc7 100644 --- a/controller/user.go +++ b/controller/user.go @@ -234,8 +234,7 @@ func Register(c *gin.Context) { defaultSetting.OnboardingCompletedAt = time.Now().UTC().Format(time.RFC3339) } if settingBytes, mErr := common.Marshal(defaultSetting); mErr == nil { - settingStr := string(settingBytes) - cleanUser.Setting = &settingStr + cleanUser.Setting = string(settingBytes) } if err := cleanUser.Insert(inviterId); err != nil { common.ApiError(c, err) diff --git a/model/user.go b/model/user.go index ad248bf35092..c7051fb10dd3 100644 --- a/model/user.go +++ b/model/user.go @@ -413,7 +413,12 @@ func (user *User) Insert(inviterId int) error { // 初始化用户设置,包括默认的边栏配置 if user.Setting == "" { - defaultSetting := dto.UserSetting{} + // Seed persona="unset" so OAuth signups (github/wechat/oidc/ + // discord/linuxdo/telegram) flow through PersonaPickerHost -> + // /welcome wizard just like the email Register path does. + // Email Register pre-populates Setting before calling Insert, + // so this branch only fires for OAuth-created users. + defaultSetting := dto.UserSetting{Persona: "unset"} // 这里暂时不设置SidebarModules,因为需要在用户创建后根据角色设置 user.SetSetting(defaultSetting) } diff --git a/web/default/src/components/persona-picker-host.tsx b/web/default/src/components/persona-picker-host.tsx index e6d5946358dc..06976478febc 100644 --- a/web/default/src/components/persona-picker-host.tsx +++ b/web/default/src/components/persona-picker-host.tsx @@ -16,110 +16,41 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { useState } from 'react' +import { useEffect } from 'react' import { useNavigate } from '@tanstack/react-router' -import { useTranslation } from 'react-i18next' -import { toast } from 'sonner' import { useShouldPromptPersona } from '@/hooks/use-persona' import { useAuthStore } from '@/stores/auth-store' -import { updateUserSettings } from '@/features/profile/api' -import { PERSONA_PRESETS } from '@/features/profile/lib/persona-presets' -import type { Persona, UserSettings } from '@/features/profile/types' -import { PersonaPickerDialog } from './persona-picker-dialog' - -function parseSetting( - setting: Record | string | undefined -): UserSettings { - if (!setting) return {} - if (typeof setting === 'string') { - try { - return JSON.parse(setting) as UserSettings - } catch { - return {} - } - } - return setting as UserSettings -} /** - * Renders the one-shot persona picker when the authenticated user's - * setting JSON contains the 'unset' sentinel placed by backend Register. - * Mounted once inside AuthenticatedLayout — feature components don't need - * to know about it. + * Universal onboarding-routing host. Mounted once inside + * AuthenticatedLayout. When the authenticated user's setting JSON + * contains the 'unset' persona sentinel (placed by backend Register + * OR seeded for new OAuth signups), redirect them to /welcome — the + * full-page 3-step wizard handles persona/brand/client capture. + * + * Why a redirect instead of a modal: + * - Single funnel: email/password Register, OAuth callbacks, and + * legacy users who haven't picked persona all land at /welcome + * - More space than a modal — wizard cards + welcome banner fit + * - Easier to test, link to, A/B + * - PersonaPickerDialog modal still exists (used by /welcome and + * /profile preset switcher) — just not as a blocking layout-level + * modal anymore * - * On pick: - * 1. PUT /api/user/setting with persona + matching sidebar_modules preset - * 2. Update authStore so all consumers see the new persona immediately - * 3. Navigate to the persona's default route (only if currently on /dashboard*) + * Loop prevention: skip the redirect when already on /welcome. */ export function PersonaPickerHost() { - const { t } = useTranslation() const shouldPrompt = useShouldPromptPersona() const user = useAuthStore((s) => s.auth.user) - const setUser = useAuthStore((s) => s.auth.setUser) const navigate = useNavigate() - const [submitting, setSubmitting] = useState(false) - - if (!user || !shouldPrompt) return null - - const handlePick = async (persona: Persona) => { - if (submitting) return - setSubmitting(true) - try { - const preset = PERSONA_PRESETS[persona] - const currentSetting = parseSetting(user.setting) - const nextSetting: UserSettings = { - ...currentSetting, - persona, - } - - const res = await updateUserSettings({ - ...nextSetting, - persona, - sidebar_modules: preset.sidebarModules, - }) - - if (!res.success) { - toast.error(res.message || t('Could not save your selection.')) - return - } - - // Reflect new persona + sidebar in auth store so the rest of the - // app re-renders immediately (sidebar config hook depends on these). - setUser({ - ...user, - setting: nextSetting as unknown as Record, - sidebar_modules: preset.sidebarModules, - }) - - // Navigate to the persona's home only if we're on a generic landing - // (the dashboard/index/keys empty state). Avoid hijacking deep links. - const path = - typeof window !== 'undefined' ? window.location.pathname : '' - const landingPaths = ['/', '/dashboard', '/dashboard/overview', '/keys'] - if (landingPaths.some((p) => path === p || path.startsWith(p + '/'))) { - // TanStack Router's typed routes don't always include - // /dashboard/overview; persona presets store a path string so we - // bypass the type-narrowed `to` here. - navigate({ to: preset.defaultRoute as never, replace: true }) - } - toast.success(t('Welcome aboard!')) - } catch (_e) { - toast.error(t('Could not save your selection.')) - } finally { - setSubmitting(false) - } - } + useEffect(() => { + if (!user || !shouldPrompt) return + const path = + typeof window !== 'undefined' ? window.location.pathname : '' + if (path === '/welcome') return + navigate({ to: '/welcome', replace: true }) + }, [user, shouldPrompt, navigate]) - return ( - { - /* dismissible={false}; ignore close attempts so it's a forced step */ - }} - onPick={handlePick} - dismissible={false} - /> - ) + return null } diff --git a/web/default/src/features/welcome/index.tsx b/web/default/src/features/welcome/index.tsx index 5954ebe11602..d621a18f57bb 100644 --- a/web/default/src/features/welcome/index.tsx +++ b/web/default/src/features/welcome/index.tsx @@ -192,16 +192,19 @@ export function Welcome() { takeWelcomeHandoff() ) - // If a user navigates here directly (no handoff), gracefully redirect - // to dashboard. The wizard makes no sense without the just-signed-up - // context, and we don't want to expose a stale Welcome state. + // Unauthenticated visitors get bounced to /sign-in. Otherwise stay on + // the wizard even without a handoff — covers: + // * Old backend (didn't return data) — user is logged in, just no + // default-token banner + // * OAuth signups landing here via PersonaPickerHost redirect + // * Privacy-mode browsers where sessionStorage write failed + // * User navigating to /welcome on their own to redo the picker + // The default-token banner is conditionally rendered against `handoff`. useEffect(() => { - if (!handoff && !user) { + if (!user) { navigate({ to: '/sign-in', replace: true }) - } else if (!handoff && user) { - navigate({ to: '/dashboard/overview' as never, replace: true }) } - }, [handoff, user, navigate]) + }, [user, navigate]) const [persona, setPersona] = useState(null) const [brand, setBrand] = useState('') @@ -294,7 +297,7 @@ export function Welcome() { } } - if (!handoff && !user) return null + if (!user) return null return (