Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
6696e61c
Commit
6696e61c
authored
Apr 22, 2026
by
IanShaw027
Browse files
fix(frontend): preserve callback recovery state
parent
81c827ee
Changes
10
Show whitespace changes
Inline
Side-by-side
frontend/src/router/__tests__/guards.spec.ts
View file @
6696e61c
...
...
@@ -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
,
...
...
frontend/src/router/index.ts
View file @
6696e61c
...
...
@@ -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
'
,
...
...
frontend/src/views/auth/LinuxDoCallbackView.vue
View file @
6696e61c
...
...
@@ -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
<
OAuthToke
nResponse
>
(
'
/auth/oauth/linuxdo/complete-registration
'
,
{
await
apiClient
.
post
<
LinuxDoPendingActio
nResponse
>
(
'
/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
=
...
...
frontend/src/views/auth/OidcCallbackView.vue
View file @
6696e61c
...
...
@@ -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
=
...
...
frontend/src/views/auth/WechatCallbackView.vue
View file @
6696e61c
...
...
@@ -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
=
...
...
frontend/src/views/auth/WechatPaymentCallbackView.vue
View file @
6696e61c
...
...
@@ -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
({
...
...
frontend/src/views/auth/__tests__/LinuxDoCallbackView.spec.ts
View file @
6696e61c
...
...
@@ -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
,
...
...
frontend/src/views/auth/__tests__/OidcCallbackView.spec.ts
View file @
6696e61c
...
...
@@ -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
'
,
...
...
frontend/src/views/auth/__tests__/WechatCallbackView.spec.ts
View file @
6696e61c
...
...
@@ -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
'
,
...
...
frontend/src/views/auth/__tests__/WechatPaymentCallbackView.spec.ts
View file @
6696e61c
...
...
@@ -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
'
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment