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
ca4e38aa
Commit
ca4e38aa
authored
Apr 22, 2026
by
IanShaw027
Browse files
fix(profile): stabilize binding compatibility and frontend checks
parent
1aab084e
Changes
30
Show whitespace changes
Inline
Side-by-side
frontend/src/components/user/profile/ProfileIdentityBindingsSection.vue
View file @
ca4e38aa
...
...
@@ -299,20 +299,42 @@ const emailSubmitActionLabel = computed(() =>
:
t
(
'
profile.authBindings.confirmEmailBindAction
'
)
)
const
wechatOAuthSettings
=
computed
<
WeChatOAuthPublicSettings
|
null
>
(()
=>
{
if
(
hasExplicitWeChatOAuthCapabilities
(
appStore
.
cachedPublicSettings
))
{
return
appStore
.
cachedPublicSettings
function
resolveLegacyCompatibleWeChatSettings
(
settings
:
WeChatOAuthPublicSettings
|
null
|
undefined
):
(
WeChatOAuthPublicSettings
&
{
wechat_oauth_open_enabled
:
boolean
wechat_oauth_mp_enabled
:
boolean
}
)
|
null
{
if
(
!
settings
)
{
return
null
}
if
(
hasExplicitWeChatOAuthCapabilities
(
settings
))
{
return
settings
}
if
(
typeof
settings
.
wechat_oauth_enabled
!==
'
boolean
'
)
{
return
null
}
if
(
typeof
props
.
wechatOpenEnabled
===
'
boolean
'
&&
typeof
props
.
wechatMpEnabled
===
'
boolean
'
)
{
return
{
wechat_oauth_enabled
:
props
.
wechatEnabled
,
wechat_oauth_open_enabled
:
prop
s
.
wechat
OpenE
nabled
,
wechat_oauth_mp_enabled
:
prop
s
.
wechat
MpE
nabled
,
...
settings
,
wechat_oauth_open_enabled
:
setting
s
.
wechat
_oauth_e
nabled
,
wechat_oauth_mp_enabled
:
setting
s
.
wechat
_oauth_e
nabled
,
}
}
const
wechatOAuthSettings
=
computed
<
WeChatOAuthPublicSettings
|
null
>
(()
=>
{
const
cachedSettings
=
resolveLegacyCompatibleWeChatSettings
(
appStore
.
cachedPublicSettings
)
if
(
cachedSettings
)
{
return
cachedSettings
}
return
null
return
resolveLegacyCompatibleWeChatSettings
({
wechat_oauth_enabled
:
props
.
wechatEnabled
,
wechat_oauth_open_enabled
:
props
.
wechatOpenEnabled
,
wechat_oauth_mp_enabled
:
props
.
wechatMpEnabled
,
}
)
}
)
const
resolvedWeChatBinding
=
computed
(()
=>
resolveWeChatOAuthStartStrict
(
wechatOAuthSettings
.
value
))
...
...
@@ -362,6 +384,17 @@ function getBindingDetails(provider: UserAuthProvider): UserAuthBindingStatus |
return
binding
}
function
getDisplayableEmail
(
user
:
User
|
null
|
undefined
):
string
{
const
email
=
user
?.
email
?.
trim
()
||
''
if
(
!
email
)
{
return
''
}
if
(
email
.
endsWith
(
'
.invalid
'
)
&&
!
getBindingStatusForUser
(
user
,
'
email
'
))
{
return
''
}
return
email
}
function
isProviderEnabledForBinding
(
provider
:
BindableProvider
):
boolean
{
if
(
provider
===
'
linuxdo
'
)
{
return
props
.
linuxdoEnabled
...
...
@@ -444,14 +477,7 @@ function providerIconClass(provider: UserAuthProvider): string {
function
providerSummary
(
provider
:
UserAuthProvider
):
string
{
if
(
provider
===
'
email
'
)
{
const
email
=
currentUser
.
value
?.
email
?.
trim
()
||
''
if
(
!
email
)
{
return
''
}
if
(
currentUser
.
value
?.
email_bound
===
false
&&
email
.
endsWith
(
'
.invalid
'
))
{
return
''
}
return
email
return
getDisplayableEmail
(
currentUser
.
value
)
}
return
''
}
...
...
frontend/src/components/user/profile/ProfileInfoCard.vue
View file @
ca4e38aa
...
...
@@ -185,7 +185,7 @@ import Icon from '@/components/icons/Icon.vue'
import
ProfileAvatarCard
from
'
@/components/user/profile/ProfileAvatarCard.vue
'
import
ProfileEditForm
from
'
@/components/user/profile/ProfileEditForm.vue
'
import
ProfileIdentityBindingsSection
from
'
@/components/user/profile/ProfileIdentityBindingsSection.vue
'
import
type
{
User
,
UserAuthProvider
,
UserProfileSourceContext
}
from
'
@/types
'
import
type
{
User
,
UserAuthBindingStatus
,
UserAuthProvider
,
UserProfileSourceContext
}
from
'
@/types
'
const
props
=
withDefaults
(
defineProps
<
{
user
:
User
|
null
...
...
@@ -206,6 +206,29 @@ const props = withDefaults(defineProps<{
const
{
t
}
=
useI18n
()
function
normalizeBindingStatus
(
binding
:
boolean
|
UserAuthBindingStatus
|
undefined
):
boolean
|
null
{
if
(
typeof
binding
===
'
boolean
'
)
{
return
binding
}
if
(
!
binding
)
{
return
null
}
if
(
typeof
binding
.
bound
===
'
boolean
'
)
{
return
binding
.
bound
}
return
Boolean
(
binding
.
provider_subject
||
binding
.
issuer
||
binding
.
provider_key
)
}
function
isEmailBound
(
user
:
User
|
null
|
undefined
):
boolean
{
if
(
typeof
user
?.
email_bound
===
'
boolean
'
)
{
return
user
.
email_bound
}
const
nested
=
user
?.
auth_bindings
?.
email
??
user
?.
identity_bindings
?.
email
const
normalized
=
normalizeBindingStatus
(
nested
)
return
normalized
??
false
}
const
avatarUrl
=
computed
(()
=>
props
.
user
?.
avatar_url
?.
trim
()
||
''
)
const
displayName
=
computed
(()
=>
props
.
user
?.
username
?.
trim
()
||
props
.
user
?.
email
?.
trim
()
||
t
(
'
profile.user
'
))
const
primaryEmailDisplay
=
computed
(()
=>
{
...
...
@@ -213,7 +236,7 @@ const primaryEmailDisplay = computed(() => {
if
(
!
email
)
{
return
''
}
if
(
props
.
user
?.
email_bound
===
false
&&
email
.
endsWith
(
'
.invalid
'
))
{
if
(
email
.
endsWith
(
'
.invalid
'
)
&&
!
isEmailBound
(
props
.
user
))
{
return
''
}
return
email
...
...
frontend/src/components/user/profile/__tests__/ProfileIdentityBindingsSection.spec.ts
View file @
ca4e38aa
...
...
@@ -188,7 +188,7 @@ describe('ProfileIdentityBindingsSection', () => {
expect
(
wrapper
.
find
(
'
[data-testid="profile-binding-wechat-action"]
'
).
exists
()).
toBe
(
false
)
})
it
(
'
hide
s the WeChat bind action when only the legacy aggregate setting is present
'
,
()
=>
{
it
(
'
keep
s the WeChat bind action
visible
when only the legacy aggregate setting is present
'
,
()
=>
{
const
wrapper
=
mount
(
ProfileIdentityBindingsSection
,
{
global
:
{
plugins
:
[
pinia
],
...
...
@@ -201,7 +201,28 @@ describe('ProfileIdentityBindingsSection', () => {
},
})
expect
(
wrapper
.
find
(
'
[data-testid="profile-binding-wechat-action"]
'
).
exists
()).
toBe
(
false
)
expect
(
wrapper
.
find
(
'
[data-testid="profile-binding-wechat-action"]
'
).
exists
()).
toBe
(
true
)
})
it
(
'
starts the WeChat bind flow when only the legacy aggregate setting is present
'
,
async
()
=>
{
const
wrapper
=
mount
(
ProfileIdentityBindingsSection
,
{
global
:
{
plugins
:
[
pinia
],
},
props
:
{
user
:
createUser
(),
linuxdoEnabled
:
false
,
oidcEnabled
:
false
,
wechatEnabled
:
true
,
},
})
await
wrapper
.
get
(
'
[data-testid="profile-binding-wechat-action"]
'
).
trigger
(
'
click
'
)
expect
(
locationState
.
current
.
href
).
toContain
(
'
/api/v1/auth/oauth/wechat/start?
'
)
expect
(
locationState
.
current
.
href
).
toContain
(
'
mode=open
'
)
expect
(
locationState
.
current
.
href
).
toContain
(
'
intent=bind_current_user
'
)
expect
(
locationState
.
current
.
href
).
toContain
(
'
redirect=%2Fprofile
'
)
})
it
(
'
uses explicit cached WeChat capabilities and ignores legacy prop fallbacks
'
,
()
=>
{
...
...
@@ -358,6 +379,28 @@ describe('ProfileIdentityBindingsSection', () => {
expect
(
wrapper
.
get
(
'
[data-testid="profile-binding-email-status"]
'
).
text
()).
toBe
(
'
Not bound
'
)
})
it
(
'
does not show a synthetic oauth-only email when only fallback auth bindings mark email as unbound
'
,
()
=>
{
const
wrapper
=
mount
(
ProfileIdentityBindingsSection
,
{
global
:
{
plugins
:
[
pinia
],
},
props
:
{
user
:
createUser
({
email
:
'
legacy-user@wechat-connect.invalid
'
,
auth_bindings
:
{
email
:
{
bound
:
false
},
},
}),
linuxdoEnabled
:
false
,
oidcEnabled
:
false
,
wechatEnabled
:
false
,
},
})
expect
(
wrapper
.
text
()).
not
.
toContain
(
'
legacy-user@wechat-connect.invalid
'
)
expect
(
wrapper
.
get
(
'
[data-testid="profile-binding-email-status"]
'
).
text
()).
toBe
(
'
Not bound
'
)
})
it
(
'
keeps the email form available for replacing a bound primary email
'
,
async
()
=>
{
userApiMocks
.
sendEmailBindingCode
.
mockResolvedValue
(
undefined
)
userApiMocks
.
bindEmailIdentity
.
mockResolvedValue
(
...
...
frontend/src/components/user/profile/__tests__/ProfileInfoCard.spec.ts
View file @
ca4e38aa
...
...
@@ -152,6 +152,26 @@ describe('ProfileInfoCard', () => {
expect
(
wrapper
.
text
()).
not
.
toContain
(
'
legacy-user@oidc-connect.invalid
'
)
})
it
(
'
does not display synthetic oauth-only emails when only legacy identity bindings mark email as unbound
'
,
()
=>
{
const
wrapper
=
mount
(
ProfileInfoCard
,
{
props
:
{
user
:
createUser
({
email
:
'
legacy-user@wechat-connect.invalid
'
,
identity_bindings
:
{
email
:
{
bound
:
false
}
}
})
},
global
:
{
stubs
:
{
Icon
:
true
}
}
})
expect
(
wrapper
.
text
()).
not
.
toContain
(
'
legacy-user@wechat-connect.invalid
'
)
})
it
(
'
renders the approved overview hero and two-column content shell
'
,
()
=>
{
const
wrapper
=
mount
(
ProfileInfoCard
,
{
props
:
{
...
...
frontend/src/views/admin/SettingsView.vue
View file @
ca4e38aa
...
...
@@ -3763,11 +3763,7 @@
<
p
class
=
"
mt-1 text-sm text-gray-500 dark:text-gray-400
"
>
{{
t
(
"
admin.settings.payment.description
"
)
}}
<
a
:
href
=
"
locale === 'zh'
? 'https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT_CN.md'
: 'https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT.md'
"
:
href
=
"
paymentGuideHref
"
target
=
"
_blank
"
rel
=
"
noopener noreferrer
"
class
=
"
ml-2 inline-flex items-center text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300
"
...
...
@@ -4140,11 +4136,7 @@
<
p
class
=
"
mt-2 text-xs text-gray-400 dark:text-gray-500
"
>
{{
t
(
"
admin.settings.payment.enabledPaymentTypesHint
"
)
}}
<
a
:
href
=
"
locale === 'zh'
? 'https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT_CN.md#%E6%94%AF%E6%8C%81%E7%9A%84%E6%94%AF%E4%BB%98%E6%96%B9%E5%BC%8F'
: 'https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT.md#supported-payment-methods'
"
:
href
=
"
paymentGuideHref
"
target
=
"
_blank
"
rel
=
"
noopener noreferrer
"
class
=
"
ml-1 text-primary-500 hover:text-primary-600 dark:text-primary-400 dark:hover:text-primary-300
"
...
...
@@ -4729,6 +4721,12 @@ function localText(zh: string, en: string): string {
return
locale
.
value
.
startsWith
(
"
zh
"
)
?
zh
:
en
;
}
const
paymentGuideHref
=
computed
(()
=>
locale
.
value
.
startsWith
(
"
zh
"
)
?
"
https://github.com/Wei-Shaw/sub2api/blob/main/README_CN.md#%E6%94%AF%E4%BB%98
"
:
"
https://github.com/Wei-Shaw/sub2api/blob/main/README.md#payment
"
,
);
type
SettingsTab
=
|
"
general
"
|
"
security
"
...
...
frontend/src/views/admin/__tests__/SettingsView.spec.ts
View file @
ca4e38aa
...
...
@@ -46,6 +46,8 @@ const {
showSuccess
:
vi
.
fn
(),
}));
const
localeRef
=
vi
.
hoisted
(()
=>
({
value
:
"
zh-CN
"
}));
vi
.
mock
(
"
@/api
"
,
()
=>
({
adminAPI
:
{
settings
:
{
...
...
@@ -149,6 +151,8 @@ vi.mock("vue-i18n", async () => {
"
admin.settings.paymentVisibleMethods.sourceLabel
"
:
"
支付来源
"
,
"
admin.settings.paymentVisibleMethods.sourceHint
"
:
"
启用后必须明确选择一个来源;未配置状态不会对外展示该支付方式。
"
,
"
admin.settings.paymentVisibleMethods.sourceRequiredError
"
:
"
{title} 已启用,请先选择支付来源。
"
,
"
admin.settings.payment.configGuide
"
:
"
查看支付配置说明
"
,
"
admin.settings.payment.findProvider
"
:
"
查看支持的支付方式
"
,
"
admin.settings.openaiExperimentalScheduler.title
"
:
"
OpenAI 实验调度策略
"
,
"
admin.settings.openaiExperimentalScheduler.description
"
:
"
默认关闭。开启后仅影响本网关在 OpenAI 账号间的实验性调度选择逻辑,不代表上游 OpenAI 官方能力。
"
,
};
...
...
@@ -157,7 +161,7 @@ vi.mock("vue-i18n", async () => {
useI18n
:
()
=>
({
t
:
(
key
:
string
,
params
?:
Record
<
string
,
string
>
)
=>
(
translations
[
key
]
??
key
).
replace
(
/
\{(\w
+
)\}
/g
,
(
_
,
token
)
=>
params
?.[
token
]
??
`{
${
token
}
}`
),
locale
:
ref
(
"
zh-CN
"
)
,
locale
:
localeRef
,
}),
};
});
...
...
@@ -429,6 +433,7 @@ describe("admin SettingsView payment visible method controls", () => {
adminSettingsFetch
.
mockReset
();
showError
.
mockReset
();
showSuccess
.
mockReset
();
localeRef
.
value
=
"
zh-CN
"
;
getSettings
.
mockResolvedValue
({
...
baseSettingsResponse
});
updateSettings
.
mockImplementation
(
async
(
payload
)
=>
({
...
...
@@ -489,6 +494,30 @@ describe("admin SettingsView payment visible method controls", () => {
expect
(
wrapper
.
text
()).
not
.
toContain
(
"
支付来源
"
);
});
it
(
"
links payment guidance to README sections instead of removed payment docs
"
,
async
()
=>
{
const
wrapper
=
mountView
();
await
flushPromises
();
await
openPaymentTab
(
wrapper
);
const
paymentLinks
=
wrapper
.
findAll
(
"
a
"
)
.
filter
((
node
)
=>
[
"
查看支付配置说明
"
,
"
查看支持的支付方式
"
].
includes
(
node
.
text
()),
);
expect
(
paymentLinks
).
toHaveLength
(
2
);
expect
(
paymentLinks
[
0
]?.
attributes
(
"
href
"
)).
toBe
(
"
https://github.com/Wei-Shaw/sub2api/blob/main/README_CN.md#%E6%94%AF%E4%BB%98
"
,
);
expect
(
paymentLinks
[
1
]?.
attributes
(
"
href
"
)).
toBe
(
"
https://github.com/Wei-Shaw/sub2api/blob/main/README_CN.md#%E6%94%AF%E4%BB%98
"
,
);
for
(
const
link
of
paymentLinks
)
{
expect
(
link
.
attributes
(
"
href
"
)).
not
.
toContain
(
"
docs/PAYMENT
"
);
}
});
it
(
"
does not submit legacy visible payment method settings
"
,
async
()
=>
{
const
wrapper
=
mountView
();
...
...
frontend/src/views/auth/LinuxDoCallbackView.vue
View file @
ca4e38aa
...
...
@@ -456,7 +456,14 @@ function resolvePendingAccountAction(
if
(
raw
===
'
email_required
'
||
raw
===
'
create_account_required
'
||
raw
===
'
create_account
'
)
{
return
'
create_account
'
}
if
(
raw
===
'
bind_login_required
'
||
raw
===
'
bind_login
'
)
{
if
(
raw
===
'
bind_login_required
'
||
raw
===
'
bind_login
'
||
raw
===
'
existing_account
'
||
raw
===
'
existing_account_required
'
||
raw
===
'
existing_account_binding_required
'
||
raw
===
'
adopt_existing_user_by_email
'
)
{
return
'
bind_login
'
}
return
'
none
'
...
...
frontend/src/views/auth/WechatCallbackView.vue
View file @
ca4e38aa
...
...
@@ -613,8 +613,12 @@ async function handleBindCurrentAccount() {
return
}
try
{
await
prepareOAuthBindAccessTokenCookie
()
window
.
location
.
href
=
startURL
}
catch
(
e
:
unknown
)
{
errorMessage
.
value
=
getRequestErrorMessage
(
e
,
t
(
'
auth.loginFailed
'
))
}
}
async
function
handleExistingAccountBinding
()
{
...
...
frontend/src/views/auth/__tests__/LinuxDoCallbackView.spec.ts
View file @
ca4e38aa
...
...
@@ -336,6 +336,33 @@ describe('LinuxDoCallbackView', () => {
)
})
it
(
'
keeps rendering bind-login UI for legacy pending bind responses instead of treating them as success
'
,
async
()
=>
{
exchangePendingOAuthCompletion
.
mockResolvedValue
({
error
:
'
adopt_existing_user_by_email
'
,
redirect
:
'
/profile/security
'
,
email
:
'
existing@example.com
'
})
const
wrapper
=
mount
(
LinuxDoCallbackView
,
{
global
:
{
stubs
:
{
AuthLayout
:
{
template
:
'
<div><slot /></div>
'
},
Icon
:
true
,
RouterLink
:
{
template
:
'
<a><slot /></a>
'
},
transition
:
false
}
}
})
await
flushPromises
()
expect
(
showSuccess
).
not
.
toHaveBeenCalled
()
expect
(
replace
).
not
.
toHaveBeenCalled
()
expect
((
wrapper
.
get
(
'
[data-testid="linuxdo-bind-login-email"]
'
).
element
as
HTMLInputElement
).
value
).
toBe
(
'
existing@example.com
'
)
})
it
(
'
persists a pending auth session when the oauth flow still needs account creation
'
,
async
()
=>
{
exchangePendingOAuthCompletion
.
mockResolvedValue
({
error
:
'
email_required
'
,
...
...
frontend/src/views/auth/__tests__/WechatCallbackView.spec.ts
View file @
ca4e38aa
...
...
@@ -621,6 +621,34 @@ describe('WechatCallbackView', () => {
expect
(
locationState
.
current
.
href
).
toContain
(
'
mode=open
'
)
})
it
(
'
shows an error and stays on the page when preparing bind-token for the current account fails
'
,
async
()
=>
{
exchangePendingOAuthCompletionMock
.
mockResolvedValue
({
error
:
'
invitation_required
'
,
redirect
:
'
/usage
'
,
})
getAuthTokenMock
.
mockReturnValue
(
'
current-auth-token
'
)
prepareOAuthBindAccessTokenCookieMock
.
mockRejectedValue
(
new
Error
(
'
bind token failed
'
))
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="existing-account-submit"]
'
).
trigger
(
'
click
'
).
catch
(()
=>
undefined
)
await
flushPromises
()
expect
(
showErrorMock
).
toHaveBeenCalledWith
(
'
bind token failed
'
)
expect
(
locationState
.
current
.
href
).
toBe
(
'
http://localhost/auth/wechat/callback
'
)
})
it
(
'
collects email, password, and verify code for pending oauth account creation and submits adoption decisions
'
,
async
()
=>
{
getPublicSettingsMock
.
mockResolvedValue
({
invitation_code_enabled
:
true
,
...
...
Prev
1
2
Next
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