diff --git a/frontend/src/views/auth/WechatCallbackView.vue b/frontend/src/views/auth/WechatCallbackView.vue index 36e3140c..a3efcf9a 100644 --- a/frontend/src/views/auth/WechatCallbackView.vue +++ b/frontend/src/views/auth/WechatCallbackView.vue @@ -300,6 +300,7 @@ import { persistOAuthTokenContext, resolveWeChatOAuthStartStrict, type OAuthAdoptionDecision, + type OAuthTokenResponse, type PendingOAuthExchangeResponse } from '@/api/auth' @@ -328,6 +329,7 @@ const pendingAccountAction = ref<'none' | 'create_account' | 'bind_login'>('none const pendingAccountEmail = ref('') const bindLoginEmail = ref('') const bindLoginPassword = ref('') +const legacyPendingOAuthToken = ref('') const accountActionError = ref('') const canReturnToCreateAccount = ref(false) const needsTotpChallenge = ref(false) @@ -354,12 +356,49 @@ type PendingWeChatCompletion = PendingOAuthExchangeResponse & { user_email_masked?: string } +function persistPendingAuthSession(redirect?: string) { + authStore.setPendingAuthSession({ + token: '', + token_field: 'pending_oauth_token', + provider: 'wechat', + redirect: sanitizeRedirectPath(redirect || redirectTo.value) + }) +} + +function clearPendingAuthSession() { + authStore.clearPendingAuthSession() +} + function parseFragmentParams(): URLSearchParams { const raw = typeof window !== 'undefined' ? window.location.hash : '' const hash = raw.startsWith('#') ? raw.slice(1) : raw return new URLSearchParams(hash) } +function readLegacyFragmentLogin(params: URLSearchParams): OAuthTokenResponse | null { + const accessToken = params.get('access_token')?.trim() || '' + if (!accessToken) { + return null + } + + const completion: OAuthTokenResponse = { + access_token: accessToken + } + const refreshToken = params.get('refresh_token')?.trim() || '' + if (refreshToken) { + completion.refresh_token = refreshToken + } + const expiresIn = Number.parseInt(params.get('expires_in')?.trim() || '', 10) + if (Number.isFinite(expiresIn) && expiresIn > 0) { + completion.expires_in = expiresIn + } + const tokenType = params.get('token_type')?.trim() || '' + if (tokenType) { + completion.token_type = tokenType + } + return completion +} + function sanitizeRedirectPath(path: string | null | undefined): string { if (!path) return '/dashboard' if (!path.startsWith('/')) return '/dashboard' @@ -672,6 +711,7 @@ function isCreateAccountRecoveryError(error: unknown): boolean { async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redirect: string) { if (getOAuthCompletionKind(completion) === 'bind') { const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile') + clearPendingAuthSession() appStore.showSuccess(bindSuccessMessage) await router.replace(bindRedirect) return @@ -689,16 +729,19 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi async function finalizePendingAccountResponse(completion: PendingWeChatCompletion) { applyAdoptionSuggestionState(completion) + const redirect = sanitizeRedirectPath(completion.redirect || redirectTo.value) if (completion.error === 'invitation_required') { pendingAccountAction.value = 'none' needsInvitation.value = true needsAdoptionConfirmation.value = false isProcessing.value = false + persistPendingAuthSession(redirect) return } if (applyTotpChallenge(completion)) { + persistPendingAuthSession(redirect) return } @@ -707,10 +750,10 @@ async function finalizePendingAccountResponse(completion: PendingWeChatCompletio needsInvitation.value = false needsAdoptionConfirmation.value = false isProcessing.value = false + persistPendingAuthSession(redirect) return } - const redirect = sanitizeRedirectPath(completion.redirect || redirectTo.value) await finalizeCompletion(completion, redirect) } @@ -720,10 +763,18 @@ async function handleSubmitInvitation() { isSubmitting.value = true try { - const tokenData = await completeWeChatOAuthRegistration( - invitationCode.value.trim(), - currentAdoptionDecision() - ) + const tokenData = legacyPendingOAuthToken.value + ? ( + await apiClient.post('/auth/oauth/wechat/complete-registration', { + pending_oauth_token: legacyPendingOAuthToken.value, + invitation_code: invitationCode.value.trim(), + ...serializeAdoptionDecision(currentAdoptionDecision()) + }) + ).data + : await completeWeChatOAuthRegistration( + invitationCode.value.trim(), + currentAdoptionDecision() + ) persistOAuthTokenContext(tokenData) await authStore.setToken(tokenData.access_token) appStore.showSuccess(t('auth.loginSuccess')) @@ -864,48 +915,74 @@ onMounted(async () => { } const params = parseFragmentParams() + const legacyLogin = readLegacyFragmentLogin(params) + const legacyPendingToken = params.get('pending_oauth_token')?.trim() || '' const error = params.get('error') const errorDesc = params.get('error_description') || params.get('error_message') || '' - - if (error) { - errorMessage.value = errorDesc || error - appStore.showError(errorMessage.value) - isProcessing.value = false - return - } + const redirect = sanitizeRedirectPath( + params.get('redirect') || (route.query.redirect as string | undefined) || '/dashboard' + ) try { - const completion = await exchangePendingOAuthCompletion() as PendingWeChatCompletion - const redirect = sanitizeRedirectPath( - completion.redirect || (route.query.redirect as string | undefined) || '/dashboard' - ) - applyAdoptionSuggestionState(completion) - redirectTo.value = redirect + if (legacyLogin) { + persistOAuthTokenContext(legacyLogin) + await authStore.setToken(legacyLogin.access_token) + appStore.showSuccess(t('auth.loginSuccess')) + await router.replace(redirect) + return + } - if (completion.error === 'invitation_required') { + if (error === 'invitation_required' && legacyPendingToken) { + legacyPendingOAuthToken.value = legacyPendingToken + redirectTo.value = redirect needsInvitation.value = true isProcessing.value = false return } + if (error) { + errorMessage.value = errorDesc || error + appStore.showError(errorMessage.value) + isProcessing.value = false + return + } + + const completion = await exchangePendingOAuthCompletion() as PendingWeChatCompletion + const completionRedirect = sanitizeRedirectPath( + completion.redirect || (route.query.redirect as string | undefined) || '/dashboard' + ) + applyAdoptionSuggestionState(completion) + redirectTo.value = completionRedirect + + if (completion.error === 'invitation_required') { + needsInvitation.value = true + isProcessing.value = false + persistPendingAuthSession(completionRedirect) + return + } + if (applyTotpChallenge(completion)) { + persistPendingAuthSession(completionRedirect) return } applyPendingAccountAction(completion) if (pendingAccountAction.value !== 'none') { isProcessing.value = false + persistPendingAuthSession(completionRedirect) return } if (adoptionRequired.value && hasSuggestedProfile(completion)) { needsAdoptionConfirmation.value = true isProcessing.value = false + persistPendingAuthSession(completionRedirect) return } - await finalizeCompletion(completion, redirect) + await finalizeCompletion(completion, completionRedirect) } catch (e: unknown) { + clearPendingAuthSession() errorMessage.value = getRequestErrorMessage(e, t('auth.loginFailed')) appStore.showError(errorMessage.value) isProcessing.value = false diff --git a/frontend/src/views/auth/__tests__/WechatCallbackView.spec.ts b/frontend/src/views/auth/__tests__/WechatCallbackView.spec.ts index e02060f6..98a0268d 100644 --- a/frontend/src/views/auth/__tests__/WechatCallbackView.spec.ts +++ b/frontend/src/views/auth/__tests__/WechatCallbackView.spec.ts @@ -14,6 +14,8 @@ const { getAuthTokenMock, replaceMock, setTokenMock, + setPendingAuthSessionMock, + clearPendingAuthSessionMock, showSuccessMock, showErrorMock, fetchPublicSettingsMock, @@ -32,6 +34,8 @@ const { getAuthTokenMock: vi.fn(), replaceMock: vi.fn(), setTokenMock: vi.fn(), + setPendingAuthSessionMock: vi.fn(), + clearPendingAuthSessionMock: vi.fn(), showSuccessMock: vi.fn(), showErrorMock: vi.fn(), fetchPublicSettingsMock: vi.fn(), @@ -111,6 +115,8 @@ vi.mock('vue-i18n', () => ({ vi.mock('@/stores', () => ({ useAuthStore: () => ({ setToken: setTokenMock, + setPendingAuthSession: setPendingAuthSessionMock, + clearPendingAuthSession: clearPendingAuthSessionMock, }), useAppStore: () => ({ ...appStoreState, @@ -152,6 +158,8 @@ describe('WechatCallbackView', () => { getPublicSettingsMock.mockReset() replaceMock.mockReset() setTokenMock.mockReset() + setPendingAuthSessionMock.mockReset() + clearPendingAuthSessionMock.mockReset() showSuccessMock.mockReset() showErrorMock.mockReset() prepareOAuthBindAccessTokenCookieMock.mockReset() @@ -269,6 +277,81 @@ describe('WechatCallbackView', () => { expect(locationState.current.href).toContain('mode=open') }) + it('accepts the legacy fragment token success callback without pending-session exchange', async () => { + locationState.current.hash = + '#access_token=legacy-access-token&refresh_token=legacy-refresh-token&expires_in=3600&token_type=Bearer&redirect=%2Flegacy-dashboard' + Object.defineProperty(window, 'location', { + configurable: true, + value: locationState.current, + }) + setTokenMock.mockResolvedValue({}) + + mount(WechatCallbackView, { + global: { + stubs: { + AuthLayout: { template: '
' }, + Icon: true, + RouterLink: { template: '' }, + transition: false, + }, + }, + }) + + await flushPromises() + + expect(exchangePendingOAuthCompletionMock).not.toHaveBeenCalled() + expect(setTokenMock).toHaveBeenCalledWith('legacy-access-token') + expect(localStorage.getItem('refresh_token')).toBe('legacy-refresh-token') + expect(localStorage.getItem('token_expires_at')).not.toBeNull() + expect(showSuccessMock).toHaveBeenCalledWith('Login success') + expect(replaceMock).toHaveBeenCalledWith('/legacy-dashboard') + }) + + it('accepts the legacy pending oauth invitation fragment without pending-session exchange', async () => { + locationState.current.hash = + '#error=invitation_required&pending_oauth_token=legacy-pending-token&redirect=%2Flegacy-invite' + Object.defineProperty(window, 'location', { + configurable: true, + value: locationState.current, + }) + apiClientPostMock.mockResolvedValue({ + data: { + access_token: 'legacy-access-token', + refresh_token: 'legacy-refresh-token', + expires_in: 3600, + token_type: 'Bearer', + }, + }) + setTokenMock.mockResolvedValue({}) + + const wrapper = mount(WechatCallbackView, { + global: { + stubs: { + AuthLayout: { template: '
' }, + Icon: true, + RouterLink: { template: '' }, + transition: false, + }, + }, + }) + + await flushPromises() + + expect(exchangePendingOAuthCompletionMock).not.toHaveBeenCalled() + await wrapper.find('input[type="text"]').setValue('invite-code') + await wrapper.find('button').trigger('click') + await flushPromises() + + expect(apiClientPostMock).toHaveBeenCalledWith('/auth/oauth/wechat/complete-registration', { + pending_oauth_token: 'legacy-pending-token', + invitation_code: 'invite-code', + adopt_display_name: true, + adopt_avatar: true, + }) + expect(setTokenMock).toHaveBeenCalledWith('legacy-access-token') + expect(replaceMock).toHaveBeenCalledWith('/legacy-invite') + }) + it('does not send adoption decisions during the initial exchange', async () => { exchangePendingOAuthCompletionMock.mockResolvedValue({ access_token: 'access-token', @@ -382,6 +465,7 @@ describe('WechatCallbackView', () => { adoptAvatar: true, }) expect(setTokenMock).not.toHaveBeenCalled() + expect(clearPendingAuthSessionMock).toHaveBeenCalledTimes(1) expect(showSuccessMock).toHaveBeenCalledWith('profile.authBindings.bindSuccess') expect(replaceMock).toHaveBeenCalledWith('/profile/connections') }) @@ -548,6 +632,33 @@ describe('WechatCallbackView', () => { expect(replaceMock).toHaveBeenCalledWith('/welcome') }) + it('persists a pending auth session when the oauth flow still needs account creation', async () => { + exchangePendingOAuthCompletionMock.mockResolvedValue({ + error: 'email_required', + redirect: '/welcome', + }) + + mount(WechatCallbackView, { + global: { + stubs: { + AuthLayout: { template: '
' }, + Icon: true, + RouterLink: { template: '' }, + transition: false, + }, + }, + }) + + await flushPromises() + + expect(setPendingAuthSessionMock).toHaveBeenCalledWith({ + token: '', + token_field: 'pending_oauth_token', + provider: 'wechat', + redirect: '/welcome', + }) + }) + it('switches to bind-login when create-account returns EMAIL_EXISTS', async () => { exchangePendingOAuthCompletionMock.mockResolvedValue({ error: 'email_required',