Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions controller/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 6 additions & 1 deletion model/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
117 changes: 24 additions & 93 deletions web/default/src/components/persona-picker-host.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,110 +16,41 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.

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, unknown> | 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<string, unknown>,
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 (
<PersonaPickerDialog
open={shouldPrompt}
onOpenChange={() => {
/* dismissible={false}; ignore close attempts so it's a forced step */
}}
onPick={handlePick}
dismissible={false}
/>
)
return null
}
19 changes: 11 additions & 8 deletions web/default/src/features/welcome/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,16 +192,19 @@ export function Welcome() {
takeWelcomeHandoff<RegisterResponseData>()
)

// 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<Persona | null>(null)
const [brand, setBrand] = useState<BrandId>('')
Expand Down Expand Up @@ -294,7 +297,7 @@ export function Welcome() {
}
}

if (!handoff && !user) return null
if (!user) return null

return (
<div className='mx-auto max-w-3xl px-4 py-6 sm:py-10'>
Expand Down
Loading