From 7f185422a54298fe2f822eef8a60829e2c960aa7 Mon Sep 17 00:00:00 2001 From: lyen1688 Date: Wed, 6 May 2026 19:44:45 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E9=82=AE=E7=AE=B1?= =?UTF-8?q?=E5=BF=AB=E6=8D=B7=E7=99=BB=E5=BD=95=E5=89=8D=E7=AB=AF=E5=9B=9E?= =?UTF-8?q?=E8=B0=83=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/auth/EmailOAuthButtons.vue | 2 ++ .../auth/__tests__/EmailOAuthButtons.spec.ts | 1 + frontend/src/views/auth/OAuthCallbackView.vue | 34 +++++++++++++++++++ .../auth/__tests__/OAuthCallbackView.spec.ts | 33 +++++++++++++++++- 4 files changed, 69 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/auth/EmailOAuthButtons.vue b/frontend/src/components/auth/EmailOAuthButtons.vue index 974c2ab0..b5d874a5 100644 --- a/frontend/src/components/auth/EmailOAuthButtons.vue +++ b/frontend/src/components/auth/EmailOAuthButtons.vue @@ -34,6 +34,7 @@ import GoogleMark from './GoogleMark.vue' import { resolveAffiliateReferralCode, storeOAuthAffiliateCode } from '@/utils/oauthAffiliate' type EmailOAuthProvider = 'github' | 'google' +const EMAIL_OAUTH_PENDING_PROVIDER_KEY = 'email_oauth_pending_provider' const props = withDefaults(defineProps<{ disabled?: boolean @@ -73,6 +74,7 @@ function startLogin(provider: EmailOAuthProvider): void { const redirectTo = (route.query.redirect as string) || '/dashboard' const affiliateCode = resolveAffiliateReferralCode(props.affCode, route.query.aff, route.query.aff_code) storeOAuthAffiliateCode(affiliateCode) + window.sessionStorage.setItem(EMAIL_OAUTH_PENDING_PROVIDER_KEY, provider) const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1' const normalized = apiBase.replace(/\/$/, '') const params = new URLSearchParams({ redirect: redirectTo }) diff --git a/frontend/src/components/auth/__tests__/EmailOAuthButtons.spec.ts b/frontend/src/components/auth/__tests__/EmailOAuthButtons.spec.ts index dbda48e6..d8517808 100644 --- a/frontend/src/components/auth/__tests__/EmailOAuthButtons.spec.ts +++ b/frontend/src/components/auth/__tests__/EmailOAuthButtons.spec.ts @@ -57,6 +57,7 @@ describe('EmailOAuthButtons', () => { '/api/v1/auth/oauth/github/start?redirect=%2Fbilling%3Fplan%3Dpro&aff_code=AFF123' ) expect(window.sessionStorage.getItem('oauth_aff_code')).toBe('AFF123') + expect(window.sessionStorage.getItem('email_oauth_pending_provider')).toBe('github') }) it('uses a full-width descriptive button when only GitHub is enabled', () => { diff --git a/frontend/src/views/auth/OAuthCallbackView.vue b/frontend/src/views/auth/OAuthCallbackView.vue index 1bc617b7..92094436 100644 --- a/frontend/src/views/auth/OAuthCallbackView.vue +++ b/frontend/src/views/auth/OAuthCallbackView.vue @@ -140,6 +140,7 @@ const invitationError = ref('') const pendingProvider = ref<'github' | 'google'>('github') const redirectTo = ref('/dashboard') const invalidCallback = ref(false) +const EMAIL_OAUTH_PENDING_PROVIDER_KEY = 'email_oauth_pending_provider' type EmailOAuthPendingCompletion = Partial & { error?: string @@ -190,9 +191,37 @@ function sanitizeRedirectPath(path: string | null | undefined): string { return path } +function readPendingEmailOAuthProvider(): 'github' | 'google' | null { + if (typeof window === 'undefined') return null + const provider = window.sessionStorage.getItem(EMAIL_OAUTH_PENDING_PROVIDER_KEY) + if (provider === 'github' || provider === 'google') return provider + return null +} + +function redirectProviderCallbackToBackend(provider: 'github' | 'google'): void { + if (typeof window === 'undefined') return + const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1' + const normalized = apiBase.replace(/\/$/, '') + const params = new URLSearchParams() + for (const [key, value] of Object.entries(route.query)) { + if (Array.isArray(value)) { + value.forEach((item) => { + if (item != null) params.append(key, String(item)) + }) + } else if (value != null) { + params.set(key, String(value)) + } + } + const suffix = params.toString() ? `?${params.toString()}` : '' + window.location.href = `${normalized}/auth/oauth/${provider}/callback${suffix}` +} + async function finalizeTokenResponse(tokenResponse: OAuthTokenResponse, redirect: string) { persistOAuthTokenContext(tokenResponse) await authStore.setToken(tokenResponse.access_token) + if (typeof window !== 'undefined') { + window.sessionStorage.removeItem(EMAIL_OAUTH_PENDING_PROVIDER_KEY) + } clearAllAffiliateReferralCodes() appStore.showSuccess(t('auth.loginSuccess')) await router.replace(sanitizeRedirectPath(redirect)) @@ -274,6 +303,11 @@ onMounted(async () => { } if (!tokenResponse) { if (route.path === '/auth/oauth/callback') { + const pendingEmailOAuthProvider = readPendingEmailOAuthProvider() + if (pendingEmailOAuthProvider && code.value && state.value) { + redirectProviderCallbackToBackend(pendingEmailOAuthProvider) + return + } await resumePendingEmailOAuth() } return diff --git a/frontend/src/views/auth/__tests__/OAuthCallbackView.spec.ts b/frontend/src/views/auth/__tests__/OAuthCallbackView.spec.ts index 2b0a9ad8..589cb921 100644 --- a/frontend/src/views/auth/__tests__/OAuthCallbackView.spec.ts +++ b/frontend/src/views/auth/__tests__/OAuthCallbackView.spec.ts @@ -4,6 +4,7 @@ import OAuthCallbackView from '@/views/auth/OAuthCallbackView.vue' const { routeState, + locationState, routerReplaceMock, showErrorMock, showSuccessMock, @@ -16,6 +17,12 @@ const { path: '/auth/callback', query: {} as Record, }, + locationState: { + current: { + href: 'http://localhost/auth/callback', + hash: '', + } as { href: string; hash: string }, + }, routerReplaceMock: vi.fn(), showErrorMock: vi.fn(), showSuccessMock: vi.fn(), @@ -73,7 +80,14 @@ describe('OAuthCallbackView', () => { beforeEach(() => { routeState.path = '/auth/callback' routeState.query = {} - window.location.hash = '' + locationState.current = { + href: 'http://localhost/auth/callback', + hash: '', + } + Object.defineProperty(window, 'location', { + configurable: true, + value: locationState.current, + }) routerReplaceMock.mockReset() showErrorMock.mockReset() showSuccessMock.mockReset() @@ -124,6 +138,23 @@ describe('OAuthCallbackView', () => { expect(wrapper.find('input[readonly]').exists()).toBe(false) }) + it('forwards frontend email oauth provider callbacks back to the backend callback endpoint', async () => { + routeState.path = '/auth/oauth/callback' + routeState.query = { + code: 'provider-code', + state: 'provider-state', + } + window.sessionStorage.setItem('email_oauth_pending_provider', 'google') + + mount(OAuthCallbackView) + await vi.dynamicImportSettled() + + expect(locationState.current.href).toBe( + '/api/v1/auth/oauth/google/callback?code=provider-code&state=provider-state' + ) + expect(exchangePendingOAuthCompletionMock).not.toHaveBeenCalled() + }) + it('submits stored affiliate code when completing invited email oauth registration', async () => { routeState.path = '/auth/oauth/callback' exchangePendingOAuthCompletionMock.mockResolvedValue({