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 (