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
1da4bd72
Unverified
Commit
1da4bd72
authored
Apr 22, 2026
by
Wesley Liddick
Committed by
GitHub
Apr 22, 2026
Browse files
Merge pull request #1802 from IanShaw027/fix/profile-auth-bindings-i18n
fix(profile): 修正邮箱重复显示问题并添加国际化语言支持
parents
c6d25f69
55513493
Changes
8
Hide whitespace changes
Inline
Side-by-side
backend/internal/handler/user_handler_test.go
View file @
1da4bd72
...
@@ -323,6 +323,11 @@ func TestUserHandlerGetProfileReturnsLegacyCompatibilityFields(t *testing.T) {
...
@@ -323,6 +323,11 @@ func TestUserHandlerGetProfileReturnsLegacyCompatibilityFields(t *testing.T) {
emailBinding
,
ok
:=
identityBindings
[
"email"
]
.
(
map
[
string
]
any
)
emailBinding
,
ok
:=
identityBindings
[
"email"
]
.
(
map
[
string
]
any
)
require
.
True
(
t
,
ok
)
require
.
True
(
t
,
ok
)
require
.
Equal
(
t
,
true
,
emailBinding
[
"bound"
])
require
.
Equal
(
t
,
true
,
emailBinding
[
"bound"
])
require
.
Equal
(
t
,
"profile.authBindings.notes.emailManagedFromProfile"
,
emailBinding
[
"note_key"
])
linuxdoCompatBinding
,
ok
:=
identityBindings
[
"linuxdo"
]
.
(
map
[
string
]
any
)
require
.
True
(
t
,
ok
)
require
.
Equal
(
t
,
"profile.authBindings.notes.canUnbind"
,
linuxdoCompatBinding
[
"note_key"
])
profileSources
,
ok
:=
resp
.
Data
[
"profile_sources"
]
.
(
map
[
string
]
any
)
profileSources
,
ok
:=
resp
.
Data
[
"profile_sources"
]
.
(
map
[
string
]
any
)
require
.
True
(
t
,
ok
)
require
.
True
(
t
,
ok
)
...
...
backend/internal/server/api_contract_test.go
View file @
1da4bd72
...
@@ -77,6 +77,7 @@ func TestAPIContracts(t *testing.T) {
...
@@ -77,6 +77,7 @@ func TestAPIContracts(t *testing.T) {
"can_unbind": false,
"can_unbind": false,
"display_name": "alice@example.com",
"display_name": "alice@example.com",
"subject_hint": "a***e@example.com",
"subject_hint": "a***e@example.com",
"note_key": "profile.authBindings.notes.emailManagedFromProfile",
"note": "Primary account email is managed from the profile form."
"note": "Primary account email is managed from the profile form."
},
},
"linuxdo": {
"linuxdo": {
...
@@ -114,6 +115,7 @@ func TestAPIContracts(t *testing.T) {
...
@@ -114,6 +115,7 @@ func TestAPIContracts(t *testing.T) {
"can_unbind": false,
"can_unbind": false,
"display_name": "alice@example.com",
"display_name": "alice@example.com",
"subject_hint": "a***e@example.com",
"subject_hint": "a***e@example.com",
"note_key": "profile.authBindings.notes.emailManagedFromProfile",
"note": "Primary account email is managed from the profile form."
"note": "Primary account email is managed from the profile form."
},
},
"linuxdo": {
"linuxdo": {
...
@@ -151,6 +153,7 @@ func TestAPIContracts(t *testing.T) {
...
@@ -151,6 +153,7 @@ func TestAPIContracts(t *testing.T) {
"can_unbind": false,
"can_unbind": false,
"display_name": "alice@example.com",
"display_name": "alice@example.com",
"subject_hint": "a***e@example.com",
"subject_hint": "a***e@example.com",
"note_key": "profile.authBindings.notes.emailManagedFromProfile",
"note": "Primary account email is managed from the profile form."
"note": "Primary account email is managed from the profile form."
},
},
"linuxdo": {
"linuxdo": {
...
...
backend/internal/service/user_service.go
View file @
1da4bd72
...
@@ -134,6 +134,7 @@ type UserIdentitySummary struct {
...
@@ -134,6 +134,7 @@ type UserIdentitySummary struct {
BindStartPath
string
`json:"bind_start_path,omitempty"`
BindStartPath
string
`json:"bind_start_path,omitempty"`
CanBind
bool
`json:"can_bind"`
CanBind
bool
`json:"can_bind"`
CanUnbind
bool
`json:"can_unbind"`
CanUnbind
bool
`json:"can_unbind"`
NoteKey
string
`json:"note_key,omitempty"`
Note
string
`json:"note,omitempty"`
Note
string
`json:"note,omitempty"`
}
}
...
@@ -156,6 +157,12 @@ type StartUserIdentityBindingResult struct {
...
@@ -156,6 +157,12 @@ type StartUserIdentityBindingResult struct {
UseBrowserRedirect
bool
`json:"use_browser_redirect"`
UseBrowserRedirect
bool
`json:"use_browser_redirect"`
}
}
const
(
userIdentityNoteEmailManagedFromProfile
=
"profile.authBindings.notes.emailManagedFromProfile"
userIdentityNoteCanUnbind
=
"profile.authBindings.notes.canUnbind"
userIdentityNoteBindAnotherBeforeUnbind
=
"profile.authBindings.notes.bindAnotherBeforeUnbind"
)
// UpdateProfileRequest 更新用户资料请求
// UpdateProfileRequest 更新用户资料请求
type
UpdateProfileRequest
struct
{
type
UpdateProfileRequest
struct
{
Email
*
string
`json:"email"`
Email
*
string
`json:"email"`
...
@@ -601,6 +608,7 @@ func (s *UserService) buildEmailIdentitySummary(user *User, records []UserAuthId
...
@@ -601,6 +608,7 @@ func (s *UserService) buildEmailIdentitySummary(user *User, records []UserAuthId
Provider
:
"email"
,
Provider
:
"email"
,
CanBind
:
false
,
CanBind
:
false
,
CanUnbind
:
false
,
CanUnbind
:
false
,
NoteKey
:
userIdentityNoteEmailManagedFromProfile
,
Note
:
"Primary account email is managed from the profile form."
,
Note
:
"Primary account email is managed from the profile form."
,
}
}
if
user
==
nil
{
if
user
==
nil
{
...
@@ -668,8 +676,10 @@ func (s *UserService) buildProviderIdentitySummary(provider string, user *User,
...
@@ -668,8 +676,10 @@ func (s *UserService) buildProviderIdentitySummary(provider string, user *User,
summary
.
VerifiedAt
=
primary
.
VerifiedAt
summary
.
VerifiedAt
=
primary
.
VerifiedAt
summary
.
CanUnbind
=
s
.
canUnbindProvider
(
provider
,
user
,
records
)
summary
.
CanUnbind
=
s
.
canUnbindProvider
(
provider
,
user
,
records
)
if
summary
.
CanUnbind
{
if
summary
.
CanUnbind
{
summary
.
NoteKey
=
userIdentityNoteCanUnbind
summary
.
Note
=
"You can unbind this sign-in method."
summary
.
Note
=
"You can unbind this sign-in method."
}
else
{
}
else
{
summary
.
NoteKey
=
userIdentityNoteBindAnotherBeforeUnbind
summary
.
Note
=
"Bind another sign-in method before unbinding."
summary
.
Note
=
"Bind another sign-in method before unbinding."
}
}
return
summary
return
summary
...
...
frontend/src/components/user/profile/ProfileIdentityBindingsSection.vue
View file @
1da4bd72
...
@@ -67,23 +67,23 @@
...
@@ -67,23 +67,23 @@
</p>
</p>
<div
<div
v-if=
"
item.details && (item.details.display_name || item.details.subject_hint || bindingCountLabel(item.details) ||
item.details
.note
)"
v-if=
"
hasBindingDetails(item.provider,
item.details)"
class=
"grid gap-1 text-sm text-gray-500 dark:text-gray-400"
class=
"grid gap-1 text-sm text-gray-500 dark:text-gray-400"
>
>
<p
<p
v-if=
"item.details.display_name"
v-if=
"
item.provider !== 'email' &&
item.details
?
.display_name"
class=
"font-medium text-gray-700 dark:text-gray-200"
class=
"font-medium text-gray-700 dark:text-gray-200"
>
>
{{
item
.
details
.
display_name
}}
{{
item
.
details
.
display_name
}}
</p>
</p>
<p
v-if=
"item.details.subject_hint"
>
<p
v-if=
"
item.provider !== 'email' &&
item.details
?
.subject_hint"
>
{{
item
.
details
.
subject_hint
}}
{{
item
.
details
.
subject_hint
}}
</p>
</p>
<p
v-if=
"bindingCountLabel(item.details)"
>
<p
v-if=
"bindingCountLabel(item.details)"
>
{{
bindingCountLabel
(
item
.
details
)
}}
{{
bindingCountLabel
(
item
.
details
)
}}
</p>
</p>
<p
v-if=
"item.details
.note
"
>
<p
v-if=
"
bindingNote(
item.details
)
"
>
{{
item
.
details
.
note
}}
{{
bindingNote
(
item
.
details
)
}}
</p>
</p>
</div>
</div>
...
@@ -298,6 +298,13 @@ const emailSubmitActionLabel = computed(() =>
...
@@ -298,6 +298,13 @@ const emailSubmitActionLabel = computed(() =>
?
t
(
'
profile.authBindings.confirmEmailReplaceAction
'
)
?
t
(
'
profile.authBindings.confirmEmailReplaceAction
'
)
:
t
(
'
profile.authBindings.confirmEmailBindAction
'
)
:
t
(
'
profile.authBindings.confirmEmailBindAction
'
)
)
)
const
legacyBindingNoteKeys
:
Record
<
string
,
string
>
=
{
'
Primary account email is managed from the profile form.
'
:
'
profile.authBindings.notes.emailManagedFromProfile
'
,
'
You can unbind this sign-in method.
'
:
'
profile.authBindings.notes.canUnbind
'
,
'
Bind another sign-in method before unbinding.
'
:
'
profile.authBindings.notes.bindAnotherBeforeUnbind
'
,
}
function
resolveLegacyCompatibleWeChatSettings
(
function
resolveLegacyCompatibleWeChatSettings
(
settings
:
WeChatOAuthPublicSettings
|
null
|
undefined
settings
:
WeChatOAuthPublicSettings
|
null
|
undefined
...
@@ -489,6 +496,36 @@ function bindingCountLabel(details: UserAuthBindingStatus | null): string {
...
@@ -489,6 +496,36 @@ function bindingCountLabel(details: UserAuthBindingStatus | null): string {
return
t
(
'
profile.authBindings.boundCount
'
,
{
count
:
details
.
bound_count
}
)
return
t
(
'
profile.authBindings.boundCount
'
,
{
count
:
details
.
bound_count
}
)
}
}
function
bindingNote
(
details
:
UserAuthBindingStatus
|
null
):
string
{
if
(
!
details
)
{
return
''
}
const
noteKey
=
details
.
note_key
?.
trim
()
||
legacyBindingNoteKeys
[
details
.
note
?.
trim
()
||
''
]
||
''
if
(
noteKey
)
{
const
translated
=
t
(
noteKey
)
if
(
translated
!==
noteKey
)
{
return
translated
}
}
return
details
.
note
?.
trim
()
||
''
}
function
hasBindingDetails
(
provider
:
UserAuthProvider
,
details
:
UserAuthBindingStatus
|
null
):
boolean
{
if
(
!
details
)
{
return
false
}
const
showsProviderIdentityDetails
=
provider
!==
'
email
'
&&
Boolean
(
details
.
display_name
||
details
.
subject_hint
)
return
Boolean
(
showsProviderIdentityDetails
||
bindingCountLabel
(
details
)
||
bindingNote
(
details
))
}
function
toggleEmailForm
():
void
{
function
toggleEmailForm
():
void
{
isEmailFormExpanded
.
value
=
!
isEmailFormExpanded
.
value
isEmailFormExpanded
.
value
=
!
isEmailFormExpanded
.
value
}
}
...
...
frontend/src/components/user/profile/__tests__/ProfileIdentityBindingsSection.spec.ts
View file @
1da4bd72
...
@@ -64,6 +64,12 @@ vi.mock('vue-i18n', async (importOriginal) => {
...
@@ -64,6 +64,12 @@ vi.mock('vue-i18n', async (importOriginal) => {
if
(
key
===
'
profile.authBindings.codeSentTo
'
)
return
`Code sent to
${
params
?.
email
||
''
}
`
.
trim
()
if
(
key
===
'
profile.authBindings.codeSentTo
'
)
return
`Code sent to
${
params
?.
email
||
''
}
`
.
trim
()
if
(
key
===
'
profile.authBindings.bindSuccess
'
)
return
'
Bind success
'
if
(
key
===
'
profile.authBindings.bindSuccess
'
)
return
'
Bind success
'
if
(
key
===
'
profile.authBindings.replaceSuccess
'
)
return
'
Primary email updated
'
if
(
key
===
'
profile.authBindings.replaceSuccess
'
)
return
'
Primary email updated
'
if
(
key
===
'
profile.authBindings.notes.emailManagedFromProfile
'
)
return
'
Primary email is managed in the profile form
'
if
(
key
===
'
profile.authBindings.notes.canUnbind
'
)
return
'
You can unbind this sign-in method
'
if
(
key
===
'
profile.authBindings.notes.bindAnotherBeforeUnbind
'
)
return
'
Bind another sign-in method before unbinding
'
return
key
return
key
},
},
}),
}),
...
@@ -164,7 +170,7 @@ describe('ProfileIdentityBindingsSection', () => {
...
@@ -164,7 +170,7 @@ describe('ProfileIdentityBindingsSection', () => {
await
wrapper
.
get
(
'
[data-testid="profile-binding-wechat-action"]
'
).
trigger
(
'
click
'
)
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
(
'
/api/v1/auth/oauth/wechat/
bind/
start?
'
)
expect
(
locationState
.
current
.
href
).
toContain
(
'
mode=open
'
)
expect
(
locationState
.
current
.
href
).
toContain
(
'
mode=open
'
)
expect
(
locationState
.
current
.
href
).
toContain
(
'
intent=bind_current_user
'
)
expect
(
locationState
.
current
.
href
).
toContain
(
'
intent=bind_current_user
'
)
expect
(
locationState
.
current
.
href
).
toContain
(
'
redirect=%2Fprofile
'
)
expect
(
locationState
.
current
.
href
).
toContain
(
'
redirect=%2Fprofile
'
)
...
@@ -219,7 +225,7 @@ describe('ProfileIdentityBindingsSection', () => {
...
@@ -219,7 +225,7 @@ describe('ProfileIdentityBindingsSection', () => {
await
wrapper
.
get
(
'
[data-testid="profile-binding-wechat-action"]
'
).
trigger
(
'
click
'
)
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
(
'
/api/v1/auth/oauth/wechat/
bind/
start?
'
)
expect
(
locationState
.
current
.
href
).
toContain
(
'
mode=open
'
)
expect
(
locationState
.
current
.
href
).
toContain
(
'
mode=open
'
)
expect
(
locationState
.
current
.
href
).
toContain
(
'
intent=bind_current_user
'
)
expect
(
locationState
.
current
.
href
).
toContain
(
'
intent=bind_current_user
'
)
expect
(
locationState
.
current
.
href
).
toContain
(
'
redirect=%2Fprofile
'
)
expect
(
locationState
.
current
.
href
).
toContain
(
'
redirect=%2Fprofile
'
)
...
@@ -401,6 +407,36 @@ describe('ProfileIdentityBindingsSection', () => {
...
@@ -401,6 +407,36 @@ describe('ProfileIdentityBindingsSection', () => {
expect
(
wrapper
.
get
(
'
[data-testid="profile-binding-email-status"]
'
).
text
()).
toBe
(
'
Not bound
'
)
expect
(
wrapper
.
get
(
'
[data-testid="profile-binding-email-status"]
'
).
text
()).
toBe
(
'
Not bound
'
)
})
})
it
(
'
shows the bound email only once and localizes the email management note
'
,
()
=>
{
const
wrapper
=
mount
(
ProfileIdentityBindingsSection
,
{
global
:
{
plugins
:
[
pinia
],
},
props
:
{
user
:
createUser
({
email
:
'
alice@example.com
'
,
email_bound
:
true
,
auth_bindings
:
{
email
:
{
bound
:
true
,
display_name
:
'
alice@example.com
'
,
subject_hint
:
'
a***e@example.com
'
,
note_key
:
'
profile.authBindings.notes.emailManagedFromProfile
'
,
note
:
'
Primary account email is managed from the profile form.
'
,
}
as
any
,
},
}),
linuxdoEnabled
:
false
,
oidcEnabled
:
false
,
wechatEnabled
:
false
,
},
})
expect
(
wrapper
.
text
().
match
(
/alice@example
\.
com/g
)).
toHaveLength
(
1
)
expect
(
wrapper
.
text
()).
not
.
toContain
(
'
a***e@example.com
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
Primary email is managed in the profile form
'
)
})
it
(
'
keeps the email form available for replacing a bound primary email
'
,
async
()
=>
{
it
(
'
keeps the email form available for replacing a bound primary email
'
,
async
()
=>
{
userApiMocks
.
sendEmailBindingCode
.
mockResolvedValue
(
undefined
)
userApiMocks
.
sendEmailBindingCode
.
mockResolvedValue
(
undefined
)
userApiMocks
.
bindEmailIdentity
.
mockResolvedValue
(
userApiMocks
.
bindEmailIdentity
.
mockResolvedValue
(
...
@@ -541,6 +577,36 @@ describe('ProfileIdentityBindingsSection', () => {
...
@@ -541,6 +577,36 @@ describe('ProfileIdentityBindingsSection', () => {
expect
(
wrapper
.
get
(
'
[data-testid="profile-binding-linuxdo-status"]
'
).
text
()).
toBe
(
'
Not bound
'
)
expect
(
wrapper
.
get
(
'
[data-testid="profile-binding-linuxdo-status"]
'
).
text
()).
toBe
(
'
Not bound
'
)
})
})
it
(
'
localizes third-party unbind guidance from note_key
'
,
()
=>
{
const
wrapper
=
mount
(
ProfileIdentityBindingsSection
,
{
global
:
{
plugins
:
[
pinia
],
},
props
:
{
user
:
createUser
({
email_bound
:
true
,
linuxdo_bound
:
true
,
auth_bindings
:
{
email
:
{
bound
:
true
},
linuxdo
:
{
bound
:
true
,
display_name
:
'
linuxdo-handle
'
,
note_key
:
'
profile.authBindings.notes.canUnbind
'
,
note
:
'
You can unbind this sign-in method.
'
,
can_unbind
:
true
,
}
as
any
,
},
}),
linuxdoEnabled
:
true
,
oidcEnabled
:
false
,
wechatEnabled
:
false
,
},
})
expect
(
wrapper
.
text
()).
toContain
(
'
You can unbind this sign-in method
'
)
expect
(
wrapper
.
text
()).
not
.
toContain
(
'
You can unbind this sign-in method.
'
)
})
it
(
'
hides bind actions when provider details say bindable but the provider is disabled
'
,
()
=>
{
it
(
'
hides bind actions when provider details say bindable but the provider is disabled
'
,
()
=>
{
const
wrapper
=
mount
(
ProfileIdentityBindingsSection
,
{
const
wrapper
=
mount
(
ProfileIdentityBindingsSection
,
{
global
:
{
global
:
{
...
...
frontend/src/i18n/locales/en.ts
View file @
1da4bd72
...
@@ -1042,6 +1042,11 @@ export default {
...
@@ -1042,6 +1042,11 @@ export default {
oidc
:
'
{providerName}
'
,
oidc
:
'
{providerName}
'
,
wechat
:
'
WeChat
'
,
wechat
:
'
WeChat
'
,
},
},
notes
:
{
emailManagedFromProfile
:
'
Primary email is managed in the profile form
'
,
canUnbind
:
'
You can unbind this sign-in method
'
,
bindAnotherBeforeUnbind
:
'
Bind another sign-in method before unbinding
'
,
},
source
:
{
source
:
{
avatar
:
'
Avatar is currently synced from {providerName}
'
,
avatar
:
'
Avatar is currently synced from {providerName}
'
,
username
:
'
Nickname is currently synced from {providerName}
'
,
username
:
'
Nickname is currently synced from {providerName}
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
1da4bd72
...
@@ -1046,6 +1046,11 @@ export default {
...
@@ -1046,6 +1046,11 @@ export default {
oidc
:
'
{providerName}
'
,
oidc
:
'
{providerName}
'
,
wechat
:
'
微信
'
,
wechat
:
'
微信
'
,
},
},
notes
:
{
emailManagedFromProfile
:
'
主邮箱在资料表单中管理
'
,
canUnbind
:
'
你可以解绑这个登录方式。
'
,
bindAnotherBeforeUnbind
:
'
请先绑定其他登录方式,再解除当前绑定。
'
,
},
source
:
{
source
:
{
avatar
:
'
头像当前来自 {providerName}
'
,
avatar
:
'
头像当前来自 {providerName}
'
,
username
:
'
昵称当前来自 {providerName}
'
,
username
:
'
昵称当前来自 {providerName}
'
,
...
...
frontend/src/types/index.ts
View file @
1da4bd72
...
@@ -51,6 +51,7 @@ export interface UserAuthBindingStatus {
...
@@ -51,6 +51,7 @@ export interface UserAuthBindingStatus {
bind_start_path
?:
string
|
null
bind_start_path
?:
string
|
null
can_bind
?:
boolean
can_bind
?:
boolean
can_unbind
?:
boolean
can_unbind
?:
boolean
note_key
?:
string
|
null
note
?:
string
|
null
note
?:
string
|
null
metadata
?:
Record
<
string
,
unknown
>
metadata
?:
Record
<
string
,
unknown
>
}
}
...
...
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