fix(frontend): preserve callback recovery state

This commit is contained in:
IanShaw027
2026-04-22 13:19:41 +08:00
parent 81c827ee51
commit 6696e61c7b
10 changed files with 229 additions and 23 deletions

View File

@@ -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,

View File

@@ -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',

View File

@@ -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 =

View File

@@ -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 =

View File

@@ -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 =

View File

@@ -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({

View File

@@ -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,

View File

@@ -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',

View File

@@ -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',

View File

@@ -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'