fix(frontend): preserve callback recovery state
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -542,7 +542,7 @@ let authInitialized = false
|
||||
const navigationLoading = useNavigationLoadingState()
|
||||
// 延迟初始化预加载,传入 router 实例
|
||||
let routePrefetch: ReturnType<typeof useRoutePrefetch> | 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',
|
||||
|
||||
@@ -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<OAuthTokenResponse>('/auth/oauth/linuxdo/complete-registration', {
|
||||
await apiClient.post<LinuxDoPendingActionResponse>('/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 =
|
||||
|
||||
@@ -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<OAuthTokenResponse>('/auth/oauth/oidc/complete-registration', {
|
||||
await apiClient.post<PendingOidcCompletion>('/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 =
|
||||
|
||||
@@ -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<OAuthTokenResponse>('/auth/oauth/wechat/complete-registration', {
|
||||
await apiClient.post<PendingWeChatCompletion>('/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 =
|
||||
|
||||
@@ -85,6 +85,12 @@ function normalizeRedirectPath(path: string | null | undefined): string {
|
||||
return value
|
||||
}
|
||||
|
||||
function appendQueryParam(query: Record<string, string>, 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<string, string> = {
|
||||
...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({
|
||||
|
||||
@@ -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: '<div><slot /></div>' },
|
||||
Icon: true,
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
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,
|
||||
|
||||
@@ -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: '<div><slot /></div>' },
|
||||
Icon: true,
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
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',
|
||||
|
||||
@@ -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: '<div><slot /></div>' },
|
||||
Icon: true,
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user