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
4d10ba42
Commit
4d10ba42
authored
Apr 20, 2026
by
IanShaw027
Browse files
fix: complete wechat pending auth callback flows
parent
bffcc204
Changes
2
Hide whitespace changes
Inline
Side-by-side
frontend/src/views/auth/WechatCallbackView.vue
View file @
4d10ba42
...
@@ -15,7 +15,16 @@
...
@@ -15,7 +15,16 @@
<
/div
>
<
/div
>
<
transition
name
=
"
fade
"
>
<
transition
name
=
"
fade
"
>
<
div
v
-
if
=
"
needsInvitation || needsAdoptionConfirmation
"
class
=
"
space-y-4
"
>
<
div
v
-
if
=
"
needsInvitation ||
needsAdoptionConfirmation ||
needsCreateAccount ||
needsBindLogin ||
needsTotpChallenge
"
class
=
"
space-y-4
"
>
<
div
<
div
v
-
if
=
"
adoptionRequired && (suggestedDisplayName || suggestedAvatarUrl)
"
v
-
if
=
"
adoptionRequired && (suggestedDisplayName || suggestedAvatarUrl)
"
class
=
"
rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-800/60
"
class
=
"
rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-800/60
"
...
@@ -141,6 +150,124 @@
...
@@ -141,6 +150,124 @@
{{
isSubmitting
?
t
(
'
common.processing
'
)
:
'
Continue
'
}}
{{
isSubmitting
?
t
(
'
common.processing
'
)
:
'
Continue
'
}}
<
/button
>
<
/button
>
<
/template
>
<
/template
>
<
template
v
-
else
-
if
=
"
needsCreateAccount
"
>
<
p
class
=
"
text-sm text-gray-700 dark:text-gray-300
"
>
Enter
an
email
address
to
create
your
account
and
continue
.
<
/p
>
<
div
class
=
"
space-y-3
"
>
<
input
v
-
model
=
"
pendingAccountEmail
"
data
-
testid
=
"
wechat-create-account-email
"
type
=
"
email
"
class
=
"
input w-full
"
placeholder
=
"
you@example.com
"
:
disabled
=
"
isSubmitting
"
@
keyup
.
enter
=
"
handleCreateAccount
"
/>
<
button
data
-
testid
=
"
wechat-create-account-submit
"
class
=
"
btn btn-primary w-full
"
:
disabled
=
"
isSubmitting || !pendingAccountEmail.trim()
"
@
click
=
"
handleCreateAccount
"
>
{{
isSubmitting
?
t
(
'
common.processing
'
)
:
'
Create account
'
}}
<
/button
>
<
button
class
=
"
btn btn-secondary w-full
"
:
disabled
=
"
isSubmitting
"
@
click
=
"
switchToBindLoginMode
"
>
I
already
have
an
account
<
/button
>
<
/div
>
<
transition
name
=
"
fade
"
>
<
p
v
-
if
=
"
accountActionError
"
class
=
"
text-sm text-red-600 dark:text-red-400
"
>
{{
accountActionError
}}
<
/p
>
<
/transition
>
<
/template
>
<
template
v
-
else
-
if
=
"
needsBindLogin
"
>
<
p
class
=
"
text-sm text-gray-700 dark:text-gray-300
"
>
Log
in
to
an
existing
account
to
bind
this
{{
providerName
}}
sign
-
in
.
<
/p
>
<
div
class
=
"
space-y-3
"
>
<
input
v
-
model
=
"
bindLoginEmail
"
data
-
testid
=
"
wechat-bind-login-email
"
type
=
"
email
"
class
=
"
input w-full
"
placeholder
=
"
you@example.com
"
:
disabled
=
"
isSubmitting
"
@
keyup
.
enter
=
"
handleBindLogin
"
/>
<
input
v
-
model
=
"
bindLoginPassword
"
data
-
testid
=
"
wechat-bind-login-password
"
type
=
"
password
"
class
=
"
input w-full
"
placeholder
=
"
Password
"
:
disabled
=
"
isSubmitting
"
@
keyup
.
enter
=
"
handleBindLogin
"
/>
<
button
data
-
testid
=
"
wechat-bind-login-submit
"
class
=
"
btn btn-primary w-full
"
:
disabled
=
"
isSubmitting || !bindLoginEmail.trim() || !bindLoginPassword
"
@
click
=
"
handleBindLogin
"
>
{{
isSubmitting
?
t
(
'
common.processing
'
)
:
'
Log in and bind
'
}}
<
/button
>
<
button
v
-
if
=
"
canReturnToCreateAccount
"
class
=
"
btn btn-secondary w-full
"
:
disabled
=
"
isSubmitting
"
@
click
=
"
switchToCreateAccountMode
"
>
Use
a
different
email
<
/button
>
<
/div
>
<
transition
name
=
"
fade
"
>
<
p
v
-
if
=
"
accountActionError
"
class
=
"
text-sm text-red-600 dark:text-red-400
"
>
{{
accountActionError
}}
<
/p
>
<
/transition
>
<
/template
>
<
template
v
-
else
-
if
=
"
needsTotpChallenge
"
>
<
p
class
=
"
text-sm text-gray-700 dark:text-gray-300
"
>
Enter
the
6
-
digit
verification
code
for
<
span
class
=
"
font-medium
"
>
{{
totpUserEmailMasked
||
'
your account
'
}}
<
/span
>
to
finish
binding
this
{{
providerName
}}
sign
-
in
.
<
/p
>
<
div
class
=
"
space-y-3
"
>
<
input
v
-
model
=
"
totpCode
"
data
-
testid
=
"
wechat-bind-login-totp
"
type
=
"
text
"
inputmode
=
"
numeric
"
maxlength
=
"
6
"
class
=
"
input w-full
"
placeholder
=
"
123456
"
:
disabled
=
"
isSubmitting
"
@
keyup
.
enter
=
"
handleSubmitTotpChallenge
"
/>
<
button
data
-
testid
=
"
wechat-bind-login-totp-submit
"
class
=
"
btn btn-primary w-full
"
:
disabled
=
"
isSubmitting || totpCode.trim().length !== 6
"
@
click
=
"
handleSubmitTotpChallenge
"
>
{{
isSubmitting
?
t
(
'
common.processing
'
)
:
'
Verify and continue
'
}}
<
/button
>
<
/div
>
<
transition
name
=
"
fade
"
>
<
p
v
-
if
=
"
totpError
"
class
=
"
text-sm text-red-600 dark:text-red-400
"
>
{{
totpError
}}
<
/p
>
<
/transition
>
<
/template
>
<
/div
>
<
/div
>
<
/transition
>
<
/transition
>
...
@@ -169,11 +296,12 @@
...
@@ -169,11 +296,12 @@
<
/template
>
<
/template
>
<
script
setup
lang
=
"
ts
"
>
<
script
setup
lang
=
"
ts
"
>
import
{
onMounted
,
ref
}
from
'
vue
'
import
{
computed
,
onMounted
,
ref
}
from
'
vue
'
import
{
useRoute
,
useRouter
}
from
'
vue-router
'
import
{
useRoute
,
useRouter
}
from
'
vue-router
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
AuthLayout
}
from
'
@/components/layout
'
import
{
AuthLayout
}
from
'
@/components/layout
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
{
apiClient
}
from
'
@/api/client
'
import
{
useAuthStore
,
useAppStore
}
from
'
@/stores
'
import
{
useAuthStore
,
useAppStore
}
from
'
@/stores
'
import
{
import
{
completeWeChatOAuthRegistration
,
completeWeChatOAuthRegistration
,
...
@@ -181,6 +309,7 @@ import {
...
@@ -181,6 +309,7 @@ import {
getAuthToken
,
getAuthToken
,
getOAuthCompletionKind
,
getOAuthCompletionKind
,
isOAuthLoginCompletion
,
isOAuthLoginCompletion
,
login2FA
,
prepareOAuthBindAccessTokenCookie
,
prepareOAuthBindAccessTokenCookie
,
persistOAuthTokenContext
,
persistOAuthTokenContext
,
type
OAuthAdoptionDecision
,
type
OAuthAdoptionDecision
,
...
@@ -208,9 +337,34 @@ const existingAccountEmail = ref('')
...
@@ -208,9 +337,34 @@ const existingAccountEmail = ref('')
const
adoptDisplayName
=
ref
(
true
)
const
adoptDisplayName
=
ref
(
true
)
const
adoptAvatar
=
ref
(
true
)
const
adoptAvatar
=
ref
(
true
)
const
needsAdoptionConfirmation
=
ref
(
false
)
const
needsAdoptionConfirmation
=
ref
(
false
)
const
pendingAccountAction
=
ref
<
'
none
'
|
'
create_account
'
|
'
bind_login
'
>
(
'
none
'
)
const
pendingAccountEmail
=
ref
(
''
)
const
bindLoginEmail
=
ref
(
''
)
const
bindLoginPassword
=
ref
(
''
)
const
accountActionError
=
ref
(
''
)
const
canReturnToCreateAccount
=
ref
(
false
)
const
needsTotpChallenge
=
ref
(
false
)
const
totpTempToken
=
ref
(
''
)
const
totpCode
=
ref
(
''
)
const
totpError
=
ref
(
''
)
const
totpUserEmailMasked
=
ref
(
''
)
const
bindSuccessMessage
=
t
(
'
profile.authBindings.bindSuccess
'
)
const
bindSuccessMessage
=
t
(
'
profile.authBindings.bindSuccess
'
)
const
providerName
=
'
WeChat
'
const
providerName
=
'
WeChat
'
const
needsCreateAccount
=
computed
(()
=>
pendingAccountAction
.
value
===
'
create_account
'
)
const
needsBindLogin
=
computed
(()
=>
pendingAccountAction
.
value
===
'
bind_login
'
)
type
PendingWeChatCompletion
=
PendingOAuthExchangeResponse
&
{
step
?:
string
pending_email
?:
string
resolved_email
?:
string
existing_account_email
?:
string
email
?:
string
intent
?:
string
requires_2fa
?:
boolean
temp_token
?:
string
user_email_masked
?:
string
}
function
parseFragmentParams
():
URLSearchParams
{
function
parseFragmentParams
():
URLSearchParams
{
const
raw
=
typeof
window
!==
'
undefined
'
?
window
.
location
.
hash
:
''
const
raw
=
typeof
window
!==
'
undefined
'
?
window
.
location
.
hash
:
''
...
@@ -278,6 +432,21 @@ function currentAdoptionDecision(): OAuthAdoptionDecision {
...
@@ -278,6 +432,21 @@ function currentAdoptionDecision(): OAuthAdoptionDecision {
}
}
}
}
function
resolveResumeEmail
():
string
{
return
typeof
route
.
query
.
email
===
'
string
'
?
route
.
query
.
email
.
trim
()
:
''
}
function
serializeAdoptionDecision
(
decision
:
OAuthAdoptionDecision
):
Record
<
string
,
boolean
>
{
const
payload
:
Record
<
string
,
boolean
>
=
{
}
if
(
typeof
decision
.
adoptDisplayName
===
'
boolean
'
)
{
payload
.
adopt_display_name
=
decision
.
adoptDisplayName
}
if
(
typeof
decision
.
adoptAvatar
===
'
boolean
'
)
{
payload
.
adopt_avatar
=
decision
.
adoptAvatar
}
return
payload
}
async
function
handleExistingAccountBinding
()
{
async
function
handleExistingAccountBinding
()
{
if
(
getAuthToken
())
{
if
(
getAuthToken
())
{
prepareOAuthBindAccessTokenCookie
()
prepareOAuthBindAccessTokenCookie
()
...
@@ -312,6 +481,103 @@ function hasSuggestedProfile(completion: PendingOAuthExchangeResponse): boolean
...
@@ -312,6 +481,103 @@ function hasSuggestedProfile(completion: PendingOAuthExchangeResponse): boolean
return
Boolean
(
completion
.
suggested_display_name
||
completion
.
suggested_avatar_url
)
return
Boolean
(
completion
.
suggested_display_name
||
completion
.
suggested_avatar_url
)
}
}
function
normalizedPendingState
(
value
:
string
|
null
|
undefined
):
string
{
return
value
?.
trim
().
toLowerCase
()
||
''
}
function
extractPendingAccountEmail
(
completion
:
PendingWeChatCompletion
):
string
{
return
(
completion
.
pending_email
||
completion
.
existing_account_email
||
completion
.
resolved_email
||
completion
.
email
||
resolveResumeEmail
()
||
''
).
trim
()
}
function
resolvePendingAccountAction
(
completion
:
PendingWeChatCompletion
):
'
none
'
|
'
create_account
'
|
'
bind_login
'
{
const
raw
=
normalizedPendingState
(
completion
.
step
||
completion
.
error
||
completion
.
intent
)
if
(
raw
===
'
email_required
'
||
raw
===
'
create_account_required
'
||
raw
===
'
create_account
'
)
{
return
'
create_account
'
}
if
(
raw
===
'
bind_login_required
'
||
raw
===
'
bind_login
'
||
raw
===
'
existing_account_binding_required
'
||
raw
===
'
existing_account_required
'
||
raw
===
'
adopt_existing_user_by_email
'
)
{
return
'
bind_login
'
}
return
'
none
'
}
function
applyPendingAccountAction
(
completion
:
PendingWeChatCompletion
)
{
const
action
=
resolvePendingAccountAction
(
completion
)
pendingAccountAction
.
value
=
action
accountActionError
.
value
=
''
needsTotpChallenge
.
value
=
false
totpTempToken
.
value
=
''
totpCode
.
value
=
''
totpError
.
value
=
''
totpUserEmailMasked
.
value
=
''
const
email
=
extractPendingAccountEmail
(
completion
)
if
(
action
===
'
create_account
'
)
{
pendingAccountEmail
.
value
=
email
canReturnToCreateAccount
.
value
=
true
return
}
if
(
action
===
'
bind_login
'
)
{
bindLoginEmail
.
value
=
email
bindLoginPassword
.
value
=
''
canReturnToCreateAccount
.
value
=
true
return
}
canReturnToCreateAccount
.
value
=
false
}
function
applyTotpChallenge
(
completion
:
PendingWeChatCompletion
):
boolean
{
if
(
completion
.
requires_2fa
!==
true
||
!
completion
.
temp_token
)
{
return
false
}
pendingAccountAction
.
value
=
'
none
'
needsInvitation
.
value
=
false
needsAdoptionConfirmation
.
value
=
false
needsTotpChallenge
.
value
=
true
totpTempToken
.
value
=
completion
.
temp_token
totpCode
.
value
=
''
totpError
.
value
=
''
totpUserEmailMasked
.
value
=
completion
.
user_email_masked
||
''
isProcessing
.
value
=
false
return
true
}
function
switchToBindLoginMode
()
{
pendingAccountAction
.
value
=
'
bind_login
'
bindLoginEmail
.
value
=
bindLoginEmail
.
value
.
trim
()
||
pendingAccountEmail
.
value
.
trim
()
bindLoginPassword
.
value
=
''
accountActionError
.
value
=
''
canReturnToCreateAccount
.
value
=
true
}
function
switchToCreateAccountMode
()
{
pendingAccountAction
.
value
=
'
create_account
'
pendingAccountEmail
.
value
=
pendingAccountEmail
.
value
.
trim
()
||
bindLoginEmail
.
value
.
trim
()
accountActionError
.
value
=
''
}
function
getRequestErrorMessage
(
error
:
unknown
,
fallback
:
string
):
string
{
const
err
=
error
as
{
message
?:
string
;
response
?:
{
data
?:
{
detail
?:
string
;
message
?:
string
}
}
}
return
err
.
response
?.
data
?.
detail
||
err
.
response
?.
data
?.
message
||
err
.
message
||
fallback
}
async
function
finalizeCompletion
(
completion
:
PendingOAuthExchangeResponse
,
redirect
:
string
)
{
async
function
finalizeCompletion
(
completion
:
PendingOAuthExchangeResponse
,
redirect
:
string
)
{
if
(
getOAuthCompletionKind
(
completion
)
===
'
bind
'
)
{
if
(
getOAuthCompletionKind
(
completion
)
===
'
bind
'
)
{
const
bindRedirect
=
sanitizeRedirectPath
(
completion
.
redirect
||
'
/profile
'
)
const
bindRedirect
=
sanitizeRedirectPath
(
completion
.
redirect
||
'
/profile
'
)
...
@@ -330,6 +596,33 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
...
@@ -330,6 +596,33 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
await
router
.
replace
(
redirect
)
await
router
.
replace
(
redirect
)
}
}
async
function
finalizePendingAccountResponse
(
completion
:
PendingWeChatCompletion
)
{
applyAdoptionSuggestionState
(
completion
)
if
(
completion
.
error
===
'
invitation_required
'
)
{
pendingAccountAction
.
value
=
'
none
'
needsInvitation
.
value
=
true
needsAdoptionConfirmation
.
value
=
false
isProcessing
.
value
=
false
return
}
if
(
applyTotpChallenge
(
completion
))
{
return
}
applyPendingAccountAction
(
completion
)
if
(
pendingAccountAction
.
value
!==
'
none
'
)
{
needsInvitation
.
value
=
false
needsAdoptionConfirmation
.
value
=
false
isProcessing
.
value
=
false
return
}
const
redirect
=
sanitizeRedirectPath
(
completion
.
redirect
||
redirectTo
.
value
)
await
finalizeCompletion
(
completion
,
redirect
)
}
async
function
handleSubmitInvitation
()
{
async
function
handleSubmitInvitation
()
{
invitationError
.
value
=
''
invitationError
.
value
=
''
if
(
!
invitationCode
.
value
.
trim
())
return
if
(
!
invitationCode
.
value
.
trim
())
return
...
@@ -356,15 +649,10 @@ async function handleSubmitInvitation() {
...
@@ -356,15 +649,10 @@ async function handleSubmitInvitation() {
async
function
handleContinueLogin
()
{
async
function
handleContinueLogin
()
{
isSubmitting
.
value
=
true
isSubmitting
.
value
=
true
try
{
try
{
const
completion
=
await
exchangePendingOAuthCompletion
(
currentAdoptionDecision
())
const
completion
=
await
exchangePendingOAuthCompletion
(
currentAdoptionDecision
())
as
PendingWeChatCompletion
await
finalize
Completion
(
completion
,
redirectTo
.
value
)
await
finalize
PendingAccountResponse
(
completion
)
}
catch
(
e
:
unknown
)
{
}
catch
(
e
:
unknown
)
{
const
err
=
e
as
{
message
?:
string
;
response
?:
{
data
?:
{
detail
?:
string
;
message
?:
string
}
}
}
errorMessage
.
value
=
getRequestErrorMessage
(
e
,
t
(
'
auth.loginFailed
'
))
errorMessage
.
value
=
err
.
response
?.
data
?.
detail
||
err
.
response
?.
data
?.
message
||
err
.
message
||
t
(
'
auth.loginFailed
'
)
appStore
.
showError
(
errorMessage
.
value
)
appStore
.
showError
(
errorMessage
.
value
)
needsAdoptionConfirmation
.
value
=
false
needsAdoptionConfirmation
.
value
=
false
}
finally
{
}
finally
{
...
@@ -372,6 +660,68 @@ async function handleContinueLogin() {
...
@@ -372,6 +660,68 @@ async function handleContinueLogin() {
}
}
}
}
async
function
handleCreateAccount
()
{
accountActionError
.
value
=
''
const
email
=
pendingAccountEmail
.
value
.
trim
()
if
(
!
email
)
return
isSubmitting
.
value
=
true
try
{
const
{
data
}
=
await
apiClient
.
post
<
PendingWeChatCompletion
>
(
'
/auth/oauth/pending/create-account
'
,
{
email
,
...
serializeAdoptionDecision
(
currentAdoptionDecision
())
}
)
await
finalizePendingAccountResponse
(
data
)
}
catch
(
e
:
unknown
)
{
accountActionError
.
value
=
getRequestErrorMessage
(
e
,
t
(
'
auth.loginFailed
'
))
}
finally
{
isSubmitting
.
value
=
false
}
}
async
function
handleBindLogin
()
{
accountActionError
.
value
=
''
const
email
=
bindLoginEmail
.
value
.
trim
()
const
password
=
bindLoginPassword
.
value
if
(
!
email
||
!
password
)
return
isSubmitting
.
value
=
true
try
{
const
{
data
}
=
await
apiClient
.
post
<
PendingWeChatCompletion
>
(
'
/auth/oauth/pending/bind-login
'
,
{
email
,
password
,
...
serializeAdoptionDecision
(
currentAdoptionDecision
())
}
)
await
finalizePendingAccountResponse
(
data
)
}
catch
(
e
:
unknown
)
{
accountActionError
.
value
=
getRequestErrorMessage
(
e
,
t
(
'
auth.loginFailed
'
))
}
finally
{
isSubmitting
.
value
=
false
}
}
async
function
handleSubmitTotpChallenge
()
{
totpError
.
value
=
''
const
code
=
totpCode
.
value
.
trim
()
if
(
!
totpTempToken
.
value
||
code
.
length
!==
6
)
return
isSubmitting
.
value
=
true
try
{
const
completion
=
await
login2FA
({
temp_token
:
totpTempToken
.
value
,
totp_code
:
code
}
)
persistOAuthTokenContext
(
completion
)
await
authStore
.
setToken
(
completion
.
access_token
)
appStore
.
showSuccess
(
t
(
'
auth.loginSuccess
'
))
await
router
.
replace
(
redirectTo
.
value
)
}
catch
(
e
:
unknown
)
{
totpError
.
value
=
getRequestErrorMessage
(
e
,
t
(
'
auth.loginFailed
'
))
}
finally
{
isSubmitting
.
value
=
false
}
}
onMounted
(
async
()
=>
{
onMounted
(
async
()
=>
{
if
(
typeof
route
.
query
.
email
===
'
string
'
)
{
if
(
typeof
route
.
query
.
email
===
'
string
'
)
{
existingAccountEmail
.
value
=
route
.
query
.
email
existingAccountEmail
.
value
=
route
.
query
.
email
...
@@ -395,7 +745,7 @@ onMounted(async () => {
...
@@ -395,7 +745,7 @@ onMounted(async () => {
}
}
try
{
try
{
const
completion
=
await
exchangePendingOAuthCompletion
()
const
completion
=
await
exchangePendingOAuthCompletion
()
as
PendingWeChatCompletion
const
redirect
=
sanitizeRedirectPath
(
const
redirect
=
sanitizeRedirectPath
(
completion
.
redirect
||
(
route
.
query
.
redirect
as
string
|
undefined
)
||
'
/dashboard
'
completion
.
redirect
||
(
route
.
query
.
redirect
as
string
|
undefined
)
||
'
/dashboard
'
)
)
...
@@ -408,6 +758,16 @@ onMounted(async () => {
...
@@ -408,6 +758,16 @@ onMounted(async () => {
return
return
}
}
if
(
applyTotpChallenge
(
completion
))
{
return
}
applyPendingAccountAction
(
completion
)
if
(
pendingAccountAction
.
value
!==
'
none
'
)
{
isProcessing
.
value
=
false
return
}
if
(
adoptionRequired
.
value
&&
hasSuggestedProfile
(
completion
))
{
if
(
adoptionRequired
.
value
&&
hasSuggestedProfile
(
completion
))
{
needsAdoptionConfirmation
.
value
=
true
needsAdoptionConfirmation
.
value
=
true
isProcessing
.
value
=
false
isProcessing
.
value
=
false
...
@@ -416,12 +776,7 @@ onMounted(async () => {
...
@@ -416,12 +776,7 @@ onMounted(async () => {
await
finalizeCompletion
(
completion
,
redirect
)
await
finalizeCompletion
(
completion
,
redirect
)
}
catch
(
e
:
unknown
)
{
}
catch
(
e
:
unknown
)
{
const
err
=
e
as
{
message
?:
string
;
response
?:
{
data
?:
{
detail
?:
string
;
message
?:
string
}
}
}
errorMessage
.
value
=
getRequestErrorMessage
(
e
,
t
(
'
auth.loginFailed
'
))
errorMessage
.
value
=
err
.
response
?.
data
?.
detail
||
err
.
response
?.
data
?.
message
||
err
.
message
||
t
(
'
auth.loginFailed
'
)
appStore
.
showError
(
errorMessage
.
value
)
appStore
.
showError
(
errorMessage
.
value
)
isProcessing
.
value
=
false
isProcessing
.
value
=
false
}
}
...
...
frontend/src/views/auth/__tests__/WechatCallbackView.spec.ts
View file @
4d10ba42
...
@@ -5,6 +5,8 @@ import WechatCallbackView from '@/views/auth/WechatCallbackView.vue'
...
@@ -5,6 +5,8 @@ import WechatCallbackView from '@/views/auth/WechatCallbackView.vue'
const
{
const
{
exchangePendingOAuthCompletionMock
,
exchangePendingOAuthCompletionMock
,
completeWeChatOAuthRegistrationMock
,
completeWeChatOAuthRegistrationMock
,
login2FAMock
,
apiClientPostMock
,
prepareOAuthBindAccessTokenCookieMock
,
prepareOAuthBindAccessTokenCookieMock
,
getAuthTokenMock
,
getAuthTokenMock
,
replaceMock
,
replaceMock
,
...
@@ -16,6 +18,8 @@ const {
...
@@ -16,6 +18,8 @@ const {
}
=
vi
.
hoisted
(()
=>
({
}
=
vi
.
hoisted
(()
=>
({
exchangePendingOAuthCompletionMock
:
vi
.
fn
(),
exchangePendingOAuthCompletionMock
:
vi
.
fn
(),
completeWeChatOAuthRegistrationMock
:
vi
.
fn
(),
completeWeChatOAuthRegistrationMock
:
vi
.
fn
(),
login2FAMock
:
vi
.
fn
(),
apiClientPostMock
:
vi
.
fn
(),
prepareOAuthBindAccessTokenCookieMock
:
vi
.
fn
(),
prepareOAuthBindAccessTokenCookieMock
:
vi
.
fn
(),
getAuthTokenMock
:
vi
.
fn
(),
getAuthTokenMock
:
vi
.
fn
(),
replaceMock
:
vi
.
fn
(),
replaceMock
:
vi
.
fn
(),
...
@@ -101,12 +105,19 @@ vi.mock('@/stores', () => ({
...
@@ -101,12 +105,19 @@ vi.mock('@/stores', () => ({
}),
}),
}))
}))
vi
.
mock
(
'
@/api/client
'
,
()
=>
({
apiClient
:
{
post
:
(...
args
:
any
[])
=>
apiClientPostMock
(...
args
),
},
}))
vi
.
mock
(
'
@/api/auth
'
,
async
()
=>
{
vi
.
mock
(
'
@/api/auth
'
,
async
()
=>
{
const
actual
=
await
vi
.
importActual
<
typeof
import
(
'
@/api/auth
'
)
>
(
'
@/api/auth
'
)
const
actual
=
await
vi
.
importActual
<
typeof
import
(
'
@/api/auth
'
)
>
(
'
@/api/auth
'
)
return
{
return
{
...
actual
,
...
actual
,
exchangePendingOAuthCompletion
:
(...
args
:
any
[])
=>
exchangePendingOAuthCompletionMock
(...
args
),
exchangePendingOAuthCompletion
:
(...
args
:
any
[])
=>
exchangePendingOAuthCompletionMock
(...
args
),
completeWeChatOAuthRegistration
:
(...
args
:
any
[])
=>
completeWeChatOAuthRegistrationMock
(...
args
),
completeWeChatOAuthRegistration
:
(...
args
:
any
[])
=>
completeWeChatOAuthRegistrationMock
(...
args
),
login2FA
:
(...
args
:
any
[])
=>
login2FAMock
(...
args
),
prepareOAuthBindAccessTokenCookie
:
(...
args
:
any
[])
=>
prepareOAuthBindAccessTokenCookieMock
(...
args
),
prepareOAuthBindAccessTokenCookie
:
(...
args
:
any
[])
=>
prepareOAuthBindAccessTokenCookieMock
(...
args
),
getAuthToken
:
(...
args
:
any
[])
=>
getAuthTokenMock
(...
args
),
getAuthToken
:
(...
args
:
any
[])
=>
getAuthTokenMock
(...
args
),
}
}
...
@@ -116,6 +127,8 @@ describe('WechatCallbackView', () => {
...
@@ -116,6 +127,8 @@ describe('WechatCallbackView', () => {
beforeEach
(()
=>
{
beforeEach
(()
=>
{
exchangePendingOAuthCompletionMock
.
mockReset
()
exchangePendingOAuthCompletionMock
.
mockReset
()
completeWeChatOAuthRegistrationMock
.
mockReset
()
completeWeChatOAuthRegistrationMock
.
mockReset
()
login2FAMock
.
mockReset
()
apiClientPostMock
.
mockReset
()
replaceMock
.
mockReset
()
replaceMock
.
mockReset
()
setTokenMock
.
mockReset
()
setTokenMock
.
mockReset
()
showSuccessMock
.
mockReset
()
showSuccessMock
.
mockReset
()
...
@@ -331,6 +344,251 @@ describe('WechatCallbackView', () => {
...
@@ -331,6 +344,251 @@ describe('WechatCallbackView', () => {
expect
(
replaceMock
.
mock
.
calls
[
0
]?.[
0
]).
toContain
(
'
email=user%40example.com
'
)
expect
(
replaceMock
.
mock
.
calls
[
0
]?.[
0
]).
toContain
(
'
email=user%40example.com
'
)
})
})
it
(
'
collects email for pending oauth account creation and submits adoption decisions
'
,
async
()
=>
{
exchangePendingOAuthCompletionMock
.
mockResolvedValue
({
error
:
'
email_required
'
,
redirect
:
'
/welcome
'
,
adoption_required
:
true
,
suggested_display_name
:
'
WeChat Nick
'
,
suggested_avatar_url
:
'
https://cdn.example/wechat.png
'
,
})
apiClientPostMock
.
mockResolvedValue
({
data
:
{
access_token
:
'
new-access-token
'
,
refresh_token
:
'
new-refresh-token
'
,
expires_in
:
3600
,
token_type
:
'
Bearer
'
,
},
})
setTokenMock
.
mockResolvedValue
({})
const
wrapper
=
mount
(
WechatCallbackView
,
{
global
:
{
stubs
:
{
AuthLayout
:
{
template
:
'
<div><slot /></div>
'
},
Icon
:
true
,
RouterLink
:
{
template
:
'
<a><slot /></a>
'
},
transition
:
false
,
},
},
})
await
flushPromises
()
const
checkboxes
=
wrapper
.
findAll
(
'
input[type="checkbox"]
'
)
expect
(
checkboxes
).
toHaveLength
(
2
)
await
checkboxes
[
1
].
setValue
(
false
)
await
wrapper
.
get
(
'
[data-testid="wechat-create-account-email"]
'
).
setValue
(
'
new@example.com
'
)
await
wrapper
.
get
(
'
[data-testid="wechat-create-account-submit"]
'
).
trigger
(
'
click
'
)
await
flushPromises
()
expect
(
apiClientPostMock
).
toHaveBeenCalledWith
(
'
/auth/oauth/pending/create-account
'
,
{
email
:
'
new@example.com
'
,
adopt_display_name
:
true
,
adopt_avatar
:
false
,
})
expect
(
setTokenMock
).
toHaveBeenCalledWith
(
'
new-access-token
'
)
expect
(
replaceMock
).
toHaveBeenCalledWith
(
'
/welcome
'
)
})
it
(
'
shows bind-login form for existing account binding and submits credentials with adoption decisions
'
,
async
()
=>
{
exchangePendingOAuthCompletionMock
.
mockResolvedValue
({
step
:
'
bind_login_required
'
,
redirect
:
'
/profile/security
'
,
email
:
'
existing@example.com
'
,
adoption_required
:
true
,
suggested_display_name
:
'
WeChat Nick
'
,
suggested_avatar_url
:
'
https://cdn.example/wechat.png
'
,
})
apiClientPostMock
.
mockResolvedValue
({
data
:
{
access_token
:
'
bind-access-token
'
,
refresh_token
:
'
bind-refresh-token
'
,
expires_in
:
3600
,
token_type
:
'
Bearer
'
,
},
})
setTokenMock
.
mockResolvedValue
({})
const
wrapper
=
mount
(
WechatCallbackView
,
{
global
:
{
stubs
:
{
AuthLayout
:
{
template
:
'
<div><slot /></div>
'
},
Icon
:
true
,
RouterLink
:
{
template
:
'
<a><slot /></a>
'
},
transition
:
false
,
},
},
})
await
flushPromises
()
const
checkboxes
=
wrapper
.
findAll
(
'
input[type="checkbox"]
'
)
expect
(
checkboxes
).
toHaveLength
(
2
)
await
checkboxes
[
0
].
setValue
(
false
)
await
wrapper
.
get
(
'
[data-testid="wechat-bind-login-email"]
'
).
setValue
(
'
existing@example.com
'
)
await
wrapper
.
get
(
'
[data-testid="wechat-bind-login-password"]
'
).
setValue
(
'
secret-password
'
)
await
wrapper
.
get
(
'
[data-testid="wechat-bind-login-submit"]
'
).
trigger
(
'
click
'
)
await
flushPromises
()
expect
(
apiClientPostMock
).
toHaveBeenCalledWith
(
'
/auth/oauth/pending/bind-login
'
,
{
email
:
'
existing@example.com
'
,
password
:
'
secret-password
'
,
adopt_display_name
:
false
,
adopt_avatar
:
true
,
})
expect
(
setTokenMock
).
toHaveBeenCalledWith
(
'
bind-access-token
'
)
expect
(
replaceMock
).
toHaveBeenCalledWith
(
'
/profile/security
'
)
})
it
(
'
allows switching from server-driven bind-login to create-account mode
'
,
async
()
=>
{
exchangePendingOAuthCompletionMock
.
mockResolvedValue
({
step
:
'
bind_login_required
'
,
redirect
:
'
/welcome
'
,
email
:
'
existing@example.com
'
,
})
const
wrapper
=
mount
(
WechatCallbackView
,
{
global
:
{
stubs
:
{
AuthLayout
:
{
template
:
'
<div><slot /></div>
'
},
Icon
:
true
,
RouterLink
:
{
template
:
'
<a><slot /></a>
'
},
transition
:
false
,
},
},
})
await
flushPromises
()
await
wrapper
.
get
(
'
button.btn-secondary
'
).
trigger
(
'
click
'
)
await
flushPromises
()
const
createAccountEmail
=
wrapper
.
get
(
'
[data-testid="wechat-create-account-email"]
'
)
expect
((
createAccountEmail
.
element
as
HTMLInputElement
).
value
).
toBe
(
'
existing@example.com
'
)
})
it
(
'
reuses query email for bind-login when backend does not echo it back
'
,
async
()
=>
{
routeState
.
query
=
{
email
:
'
resume@example.com
'
,
}
exchangePendingOAuthCompletionMock
.
mockResolvedValue
({
step
:
'
bind_login_required
'
,
redirect
:
'
/profile
'
,
})
const
wrapper
=
mount
(
WechatCallbackView
,
{
global
:
{
stubs
:
{
AuthLayout
:
{
template
:
'
<div><slot /></div>
'
},
Icon
:
true
,
RouterLink
:
{
template
:
'
<a><slot /></a>
'
},
transition
:
false
,
},
},
})
await
flushPromises
()
const
bindEmail
=
wrapper
.
get
(
'
[data-testid="wechat-bind-login-email"]
'
)
expect
((
bindEmail
.
element
as
HTMLInputElement
).
value
).
toBe
(
'
resume@example.com
'
)
})
it
(
'
keeps rendering pending bind-login UI when adoption confirmation leads to another pending step
'
,
async
()
=>
{
exchangePendingOAuthCompletionMock
.
mockResolvedValueOnce
({
redirect
:
'
/profile
'
,
adoption_required
:
true
,
suggested_display_name
:
'
WeChat Nick
'
,
suggested_avatar_url
:
'
https://cdn.example/wechat.png
'
,
})
.
mockResolvedValueOnce
({
step
:
'
bind_login_required
'
,
redirect
:
'
/profile
'
,
email
:
'
existing@example.com
'
,
adoption_required
:
true
,
suggested_display_name
:
'
WeChat Nick
'
,
suggested_avatar_url
:
'
https://cdn.example/wechat.png
'
,
})
const
wrapper
=
mount
(
WechatCallbackView
,
{
global
:
{
stubs
:
{
AuthLayout
:
{
template
:
'
<div><slot /></div>
'
},
Icon
:
true
,
RouterLink
:
{
template
:
'
<a><slot /></a>
'
},
transition
:
false
,
},
},
})
await
flushPromises
()
await
wrapper
.
findAll
(
'
button
'
)[
0
].
trigger
(
'
click
'
)
await
flushPromises
()
expect
(
showSuccessMock
).
not
.
toHaveBeenCalled
()
expect
(
replaceMock
).
not
.
toHaveBeenCalled
()
expect
((
wrapper
.
get
(
'
[data-testid="wechat-bind-login-email"]
'
).
element
as
HTMLInputElement
).
value
).
toBe
(
'
existing@example.com
'
)
})
it
(
'
handles bind-login 2FA challenge before redirecting
'
,
async
()
=>
{
exchangePendingOAuthCompletionMock
.
mockResolvedValue
({
error
:
'
adopt_existing_user_by_email
'
,
redirect
:
'
/profile
'
,
email
:
'
existing@example.com
'
,
adoption_required
:
true
,
suggested_display_name
:
'
WeChat Nick
'
,
suggested_avatar_url
:
'
https://cdn.example/wechat.png
'
,
})
apiClientPostMock
.
mockResolvedValue
({
data
:
{
requires_2fa
:
true
,
temp_token
:
'
temp-123
'
,
user_email_masked
:
'
o***g@example.com
'
,
},
})
login2FAMock
.
mockResolvedValue
({
access_token
:
'
2fa-access-token
'
,
refresh_token
:
'
2fa-refresh-token
'
,
expires_in
:
3600
,
})
setTokenMock
.
mockResolvedValue
({})
const
wrapper
=
mount
(
WechatCallbackView
,
{
global
:
{
stubs
:
{
AuthLayout
:
{
template
:
'
<div><slot /></div>
'
},
Icon
:
true
,
RouterLink
:
{
template
:
'
<a><slot /></a>
'
},
transition
:
false
,
},
},
})
await
flushPromises
()
await
wrapper
.
get
(
'
[data-testid="wechat-bind-login-password"]
'
).
setValue
(
'
secret-password
'
)
await
wrapper
.
get
(
'
[data-testid="wechat-bind-login-submit"]
'
).
trigger
(
'
click
'
)
await
flushPromises
()
expect
(
wrapper
.
text
()).
toContain
(
'
o***g@example.com
'
)
expect
(
login2FAMock
).
not
.
toHaveBeenCalled
()
await
wrapper
.
get
(
'
[data-testid="wechat-bind-login-totp"]
'
).
setValue
(
'
123456
'
)
await
wrapper
.
get
(
'
[data-testid="wechat-bind-login-totp-submit"]
'
).
trigger
(
'
click
'
)
await
flushPromises
()
expect
(
login2FAMock
).
toHaveBeenCalledWith
({
temp_token
:
'
temp-123
'
,
totp_code
:
'
123456
'
,
})
expect
(
setTokenMock
).
toHaveBeenCalledWith
(
'
2fa-access-token
'
)
expect
(
replaceMock
).
toHaveBeenCalledWith
(
'
/profile
'
)
expect
(
localStorage
.
getItem
(
'
refresh_token
'
)).
toBe
(
'
2fa-refresh-token
'
)
})
it
(
'
restarts the current-user bind flow after returning from login
'
,
async
()
=>
{
it
(
'
restarts the current-user bind flow after returning from login
'
,
async
()
=>
{
routeState
.
query
=
{
routeState
.
query
=
{
wechat_bind_existing
:
'
1
'
,
wechat_bind_existing
:
'
1
'
,
...
...
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