diff --git a/frontend/src/router/__tests__/guards.spec.ts b/frontend/src/router/__tests__/guards.spec.ts index bdf07b18..076b943d 100644 --- a/frontend/src/router/__tests__/guards.spec.ts +++ b/frontend/src/router/__tests__/guards.spec.ts @@ -78,7 +78,7 @@ function simulateGuard( return authState.isAdmin ? '/admin/dashboard' : '/dashboard' } if (authState.backendModeEnabled && !authState.isAuthenticated) { - const allowed = ['/login', '/key-usage', '/setup'] + const allowed = ['/login', '/key-usage', '/setup', '/payment/result'] const callbackPaths = [ '/auth/callback', '/auth/linuxdo/callback', @@ -127,7 +127,7 @@ function simulateGuard( if (authState.isAuthenticated && authState.isAdmin) { return null } - const allowed = ['/login', '/key-usage', '/setup'] + const allowed = ['/login', '/key-usage', '/setup', '/payment/result'] const callbackPaths = [ '/auth/callback', '/auth/linuxdo/callback', @@ -462,6 +462,18 @@ describe('路由守卫逻辑', () => { expect(redirect).toBeNull() }) + it('unauthenticated: /payment/result is allowed', () => { + const authState: MockAuthState = { + isAuthenticated: false, + isAdmin: false, + isSimpleMode: false, + backendModeEnabled: true, + hasPendingAuthSession: false, + } + const redirect = simulateGuard('/payment/result', { requiresAuth: false }, authState) + expect(redirect).toBeNull() + }) + it('unauthenticated: /register is allowed when a pending auth session exists', () => { const authState: MockAuthState = { isAuthenticated: false, diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index b7fcf475..b97ccb5d 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -542,7 +542,7 @@ let authInitialized = false const navigationLoading = useNavigationLoadingState() // 延迟初始化预加载,传入 router 实例 let routePrefetch: ReturnType | null = null -const BACKEND_MODE_ALLOWED_PATHS = ['/login', '/key-usage', '/setup'] +const BACKEND_MODE_ALLOWED_PATHS = ['/login', '/key-usage', '/setup', '/payment/result'] const BACKEND_MODE_CALLBACK_PATHS = [ '/auth/callback', '/auth/linuxdo/callback', diff --git a/frontend/src/views/auth/LinuxDoCallbackView.vue b/frontend/src/views/auth/LinuxDoCallbackView.vue index 4009454c..2cf4e694 100644 --- a/frontend/src/views/auth/LinuxDoCallbackView.vue +++ b/frontend/src/views/auth/LinuxDoCallbackView.vue @@ -603,6 +603,14 @@ async function finalizePendingAccountResponse(completion: LinuxDoPendingActionRe return } + if (completion.auth_result === 'pending_session') { + needsInvitation.value = false + needsAdoptionConfirmation.value = false + isProcessing.value = false + persistPendingAuthSession(redirect) + return + } + await finalizeCompletion(completion, redirect) } @@ -612,9 +620,9 @@ async function handleSubmitInvitation() { isSubmitting.value = true try { - const tokenData = legacyPendingOAuthToken.value + const completion: LinuxDoPendingActionResponse = legacyPendingOAuthToken.value ? ( - await apiClient.post('/auth/oauth/linuxdo/complete-registration', { + await apiClient.post('/auth/oauth/linuxdo/complete-registration', { pending_oauth_token: legacyPendingOAuthToken.value, invitation_code: invitationCode.value.trim(), ...serializeAdoptionDecision(currentAdoptionDecision()) @@ -624,10 +632,7 @@ async function handleSubmitInvitation() { invitationCode.value.trim(), currentAdoptionDecision() ) - persistOAuthTokenContext(tokenData) - await authStore.setToken(tokenData.access_token) - appStore.showSuccess(t('auth.loginSuccess')) - await router.replace(redirectTo.value) + await finalizePendingAccountResponse(completion) } catch (e: unknown) { const err = e as { message?: string; response?: { data?: { message?: string } } } invitationError.value = diff --git a/frontend/src/views/auth/OidcCallbackView.vue b/frontend/src/views/auth/OidcCallbackView.vue index d03c70fb..873022e1 100644 --- a/frontend/src/views/auth/OidcCallbackView.vue +++ b/frontend/src/views/auth/OidcCallbackView.vue @@ -632,6 +632,14 @@ async function finalizePendingAccountResponse(completion: PendingOidcCompletion) return } + if (completion.auth_result === 'pending_session') { + needsInvitation.value = false + needsAdoptionConfirmation.value = false + isProcessing.value = false + persistPendingAuthSession(redirect) + return + } + await finalizeCompletion(completion, redirect) } @@ -641,9 +649,9 @@ async function handleSubmitInvitation() { isSubmitting.value = true try { - const tokenData = legacyPendingOAuthToken.value + const completion: PendingOidcCompletion = legacyPendingOAuthToken.value ? ( - await apiClient.post('/auth/oauth/oidc/complete-registration', { + await apiClient.post('/auth/oauth/oidc/complete-registration', { pending_oauth_token: legacyPendingOAuthToken.value, invitation_code: invitationCode.value.trim(), ...serializeAdoptionDecision(currentAdoptionDecision()) @@ -653,10 +661,7 @@ async function handleSubmitInvitation() { invitationCode.value.trim(), currentAdoptionDecision() ) - persistOAuthTokenContext(tokenData) - await authStore.setToken(tokenData.access_token) - appStore.showSuccess(t('auth.loginSuccess')) - await router.replace(redirectTo.value) + await finalizePendingAccountResponse(completion) } catch (e: unknown) { const err = e as { message?: string; response?: { data?: { message?: string } } } invitationError.value = diff --git a/frontend/src/views/auth/WechatCallbackView.vue b/frontend/src/views/auth/WechatCallbackView.vue index 9a71f62b..bae20df8 100644 --- a/frontend/src/views/auth/WechatCallbackView.vue +++ b/frontend/src/views/auth/WechatCallbackView.vue @@ -840,6 +840,14 @@ async function finalizePendingAccountResponse(completion: PendingWeChatCompletio return } + if (completion.auth_result === 'pending_session') { + needsInvitation.value = false + needsAdoptionConfirmation.value = false + isProcessing.value = false + persistPendingAuthSession(redirect) + return + } + await finalizeCompletion(completion, redirect) } @@ -849,9 +857,9 @@ async function handleSubmitInvitation() { isSubmitting.value = true try { - const tokenData = legacyPendingOAuthToken.value + const completion: PendingWeChatCompletion = legacyPendingOAuthToken.value ? ( - await apiClient.post('/auth/oauth/wechat/complete-registration', { + await apiClient.post('/auth/oauth/wechat/complete-registration', { pending_oauth_token: legacyPendingOAuthToken.value, invitation_code: invitationCode.value.trim(), ...serializeAdoptionDecision(currentAdoptionDecision()) @@ -861,10 +869,7 @@ async function handleSubmitInvitation() { invitationCode.value.trim(), currentAdoptionDecision() ) - persistOAuthTokenContext(tokenData) - await authStore.setToken(tokenData.access_token) - appStore.showSuccess(t('auth.loginSuccess')) - await router.replace(redirectTo.value) + await finalizePendingAccountResponse(completion) } catch (e: unknown) { const err = e as { message?: string; response?: { data?: { message?: string } } } invitationError.value = diff --git a/frontend/src/views/auth/WechatPaymentCallbackView.vue b/frontend/src/views/auth/WechatPaymentCallbackView.vue index 53599ec3..225c84e1 100644 --- a/frontend/src/views/auth/WechatPaymentCallbackView.vue +++ b/frontend/src/views/auth/WechatPaymentCallbackView.vue @@ -85,6 +85,12 @@ function normalizeRedirectPath(path: string | null | undefined): string { return value } +function appendQueryParam(query: Record, key: string, value: string) { + if (value) { + query[key] = value + } +} + function goBackToPayment() { void router.replace('/purchase') } @@ -102,12 +108,19 @@ onMounted(async () => { } const resumeToken = readParam('wechat_resume_token') + const openid = readParam('openid') + const state = readParam('state') + const scope = readParam('scope') + const paymentType = readParam('payment_type') + const amount = readParam('amount') + const orderType = readParam('order_type') + const planId = readParam('plan_id') const redirectURL = new URL( normalizeRedirectPath(readParam('redirect')), window.location.origin, ) - if (!resumeToken) { + if (!resumeToken && !openid) { errorMessage.value = t('auth.wechatPayment.callbackMissingResumeToken') return } @@ -115,7 +128,18 @@ onMounted(async () => { const query: Record = { ...Object.fromEntries(redirectURL.searchParams.entries()), wechat_resume: '1', - wechat_resume_token: resumeToken, + } + + if (resumeToken) { + query.wechat_resume_token = resumeToken + } else { + query.openid = openid + appendQueryParam(query, 'state', state) + appendQueryParam(query, 'scope', scope) + appendQueryParam(query, 'payment_type', paymentType) + appendQueryParam(query, 'amount', amount) + appendQueryParam(query, 'order_type', orderType) + appendQueryParam(query, 'plan_id', planId) } await router.replace({ diff --git a/frontend/src/views/auth/__tests__/LinuxDoCallbackView.spec.ts b/frontend/src/views/auth/__tests__/LinuxDoCallbackView.spec.ts index 29aef613..3fee2c27 100644 --- a/frontend/src/views/auth/__tests__/LinuxDoCallbackView.spec.ts +++ b/frontend/src/views/auth/__tests__/LinuxDoCallbackView.spec.ts @@ -409,6 +409,50 @@ describe('LinuxDoCallbackView', () => { }) }) + it('keeps the oauth flow active when complete-registration returns another pending step', async () => { + exchangePendingOAuthCompletion.mockResolvedValue({ + error: 'invitation_required', + redirect: '/dashboard', + adoption_required: true, + suggested_display_name: 'LinuxDo Nick', + suggested_avatar_url: 'https://cdn.example/linuxdo.png' + }) + completeLinuxDoOAuthRegistration.mockResolvedValue({ + auth_result: 'pending_session', + step: 'choose_account_action_required', + redirect: '/dashboard', + email: 'fresh@example.com', + resolved_email: 'fresh@example.com', + force_email_on_signup: true, + adoption_required: true + }) + + const wrapper = mount(LinuxDoCallbackView, { + global: { + stubs: { + AuthLayout: { template: '
' }, + Icon: true, + RouterLink: { template: '' }, + transition: false + } + } + }) + + await flushPromises() + await wrapper.find('input[type="text"]').setValue('invite-code') + await wrapper.find('button').trigger('click') + await flushPromises() + + expect(completeLinuxDoOAuthRegistration).toHaveBeenCalledWith('invite-code', { + adoptDisplayName: true, + adoptAvatar: true + }) + expect(setToken).not.toHaveBeenCalled() + expect(replace).not.toHaveBeenCalled() + expect(wrapper.text()).toContain('auth.oauthFlow.bindExistingAccount') + expect(wrapper.text()).toContain('auth.oauthFlow.createNewAccount') + }) + it('collects email, password, and verify code for pending oauth account creation and submits adoption decisions', async () => { getPublicSettings.mockResolvedValue({ invitation_code_enabled: true, diff --git a/frontend/src/views/auth/__tests__/OidcCallbackView.spec.ts b/frontend/src/views/auth/__tests__/OidcCallbackView.spec.ts index 0167604c..ec89512b 100644 --- a/frontend/src/views/auth/__tests__/OidcCallbackView.spec.ts +++ b/frontend/src/views/auth/__tests__/OidcCallbackView.spec.ts @@ -385,6 +385,50 @@ describe('OidcCallbackView', () => { }) }) + it('keeps the oauth flow active when complete-registration returns another pending step', async () => { + exchangePendingOAuthCompletion.mockResolvedValue({ + error: 'invitation_required', + redirect: '/dashboard', + adoption_required: true, + suggested_display_name: 'OIDC Nick', + suggested_avatar_url: 'https://cdn.example/oidc.png' + }) + completeOIDCOAuthRegistration.mockResolvedValue({ + auth_result: 'pending_session', + step: 'choose_account_action_required', + redirect: '/dashboard', + email: 'fresh@example.com', + resolved_email: 'fresh@example.com', + force_email_on_signup: true, + adoption_required: true + }) + + const wrapper = mount(OidcCallbackView, { + global: { + stubs: { + AuthLayout: { template: '
' }, + Icon: true, + RouterLink: { template: '' }, + transition: false + } + } + }) + + await flushPromises() + await wrapper.find('input[type="text"]').setValue('invite-code') + await wrapper.find('button').trigger('click') + await flushPromises() + + expect(completeOIDCOAuthRegistration).toHaveBeenCalledWith('invite-code', { + adoptDisplayName: true, + adoptAvatar: true + }) + expect(setToken).not.toHaveBeenCalled() + expect(replace).not.toHaveBeenCalled() + expect(wrapper.text()).toContain('auth.oauthFlow.bindExistingAccount') + expect(wrapper.text()).toContain('auth.oauthFlow.createNewAccount') + }) + it('collects email, password, and verify code for pending oauth account creation and submits adoption decisions', async () => { getPublicSettings.mockResolvedValue({ oidc_oauth_provider_name: 'ExampleID', diff --git a/frontend/src/views/auth/__tests__/WechatCallbackView.spec.ts b/frontend/src/views/auth/__tests__/WechatCallbackView.spec.ts index cc72107d..da41c987 100644 --- a/frontend/src/views/auth/__tests__/WechatCallbackView.spec.ts +++ b/frontend/src/views/auth/__tests__/WechatCallbackView.spec.ts @@ -517,6 +517,50 @@ describe('WechatCallbackView', () => { expect(replaceMock).toHaveBeenCalledWith('/subscriptions') }) + it('keeps the oauth flow active when complete-registration returns another pending step', async () => { + exchangePendingOAuthCompletionMock.mockResolvedValue({ + error: 'invitation_required', + redirect: '/dashboard', + adoption_required: true, + suggested_display_name: 'WeChat Nick', + suggested_avatar_url: 'https://cdn.example/wechat.png', + }) + completeWeChatOAuthRegistrationMock.mockResolvedValue({ + auth_result: 'pending_session', + step: 'choose_account_action_required', + redirect: '/dashboard', + email: 'fresh@example.com', + resolved_email: 'fresh@example.com', + force_email_on_signup: true, + adoption_required: true, + }) + + const wrapper = mount(WechatCallbackView, { + global: { + stubs: { + AuthLayout: { template: '
' }, + Icon: true, + RouterLink: { template: '' }, + transition: false, + }, + }, + }) + + await flushPromises() + await wrapper.find('input[type="text"]').setValue('invite-code') + await wrapper.find('button').trigger('click') + await flushPromises() + + expect(completeWeChatOAuthRegistrationMock).toHaveBeenCalledWith('invite-code', { + adoptDisplayName: true, + adoptAvatar: true, + }) + expect(setTokenMock).not.toHaveBeenCalled() + expect(replaceMock).not.toHaveBeenCalled() + expect(wrapper.get('[data-testid="wechat-choice-bind-existing"]').exists()).toBe(true) + expect(wrapper.get('[data-testid="wechat-choice-create-account"]').exists()).toBe(true) + }) + it('offers existing-account email collection during invitation flow', async () => { exchangePendingOAuthCompletionMock.mockResolvedValue({ error: 'invitation_required', diff --git a/frontend/src/views/auth/__tests__/WechatPaymentCallbackView.spec.ts b/frontend/src/views/auth/__tests__/WechatPaymentCallbackView.spec.ts index 93cd0e94..822a083b 100644 --- a/frontend/src/views/auth/__tests__/WechatPaymentCallbackView.spec.ts +++ b/frontend/src/views/auth/__tests__/WechatPaymentCallbackView.spec.ts @@ -79,6 +79,29 @@ describe('WechatPaymentCallbackView', () => { }) }) + it('redirects legacy openid callback payloads back to purchase while preserving resume context', async () => { + locationState.current.hash = + '#openid=openid-123&state=oauth-state&scope=snsapi_base&payment_type=wxpay_direct&amount=128&order_type=subscription&plan_id=7&redirect=%2Fpayment%3Ffrom%3Dwechat' + + mount(WechatPaymentCallbackView) + await flushPromises() + + expect(replaceMock).toHaveBeenCalledWith({ + path: '/purchase', + query: { + from: 'wechat', + wechat_resume: '1', + openid: 'openid-123', + state: 'oauth-state', + scope: 'snsapi_base', + payment_type: 'wxpay_direct', + amount: '128', + order_type: 'subscription', + plan_id: '7', + }, + }) + }) + it('shows an error when the callback payload is missing the resume token', async () => { locationState.current.hash = '#payment_type=wxpay'