From 555134934914597109619ab7c7fedef82e844596 Mon Sep 17 00:00:00 2001
From: IanShaw027
Date: Wed, 22 Apr 2026 19:11:51 +0800
Subject: [PATCH] fix: clean up profile auth binding notes
---
backend/internal/handler/user_handler_test.go | 5 ++
backend/internal/server/api_contract_test.go | 3 +
backend/internal/service/user_service.go | 10 +++
.../ProfileIdentityBindingsSection.vue | 47 +++++++++++--
.../ProfileIdentityBindingsSection.spec.ts | 70 ++++++++++++++++++-
frontend/src/i18n/locales/en.ts | 5 ++
frontend/src/i18n/locales/zh.ts | 5 ++
frontend/src/types/index.ts | 1 +
8 files changed, 139 insertions(+), 7 deletions(-)
diff --git a/backend/internal/handler/user_handler_test.go b/backend/internal/handler/user_handler_test.go
index fa91bd2a..a655b81c 100644
--- a/backend/internal/handler/user_handler_test.go
+++ b/backend/internal/handler/user_handler_test.go
@@ -323,6 +323,11 @@ func TestUserHandlerGetProfileReturnsLegacyCompatibilityFields(t *testing.T) {
emailBinding, ok := identityBindings["email"].(map[string]any)
require.True(t, ok)
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)
require.True(t, ok)
diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go
index 30ddf0a2..d2b108f5 100644
--- a/backend/internal/server/api_contract_test.go
+++ b/backend/internal/server/api_contract_test.go
@@ -77,6 +77,7 @@ func TestAPIContracts(t *testing.T) {
"can_unbind": false,
"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."
},
"linuxdo": {
@@ -114,6 +115,7 @@ func TestAPIContracts(t *testing.T) {
"can_unbind": false,
"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."
},
"linuxdo": {
@@ -151,6 +153,7 @@ func TestAPIContracts(t *testing.T) {
"can_unbind": false,
"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."
},
"linuxdo": {
diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go
index 7ba401e7..a7279e6a 100644
--- a/backend/internal/service/user_service.go
+++ b/backend/internal/service/user_service.go
@@ -134,6 +134,7 @@ type UserIdentitySummary struct {
BindStartPath string `json:"bind_start_path,omitempty"`
CanBind bool `json:"can_bind"`
CanUnbind bool `json:"can_unbind"`
+ NoteKey string `json:"note_key,omitempty"`
Note string `json:"note,omitempty"`
}
@@ -156,6 +157,12 @@ type StartUserIdentityBindingResult struct {
UseBrowserRedirect bool `json:"use_browser_redirect"`
}
+const (
+ userIdentityNoteEmailManagedFromProfile = "profile.authBindings.notes.emailManagedFromProfile"
+ userIdentityNoteCanUnbind = "profile.authBindings.notes.canUnbind"
+ userIdentityNoteBindAnotherBeforeUnbind = "profile.authBindings.notes.bindAnotherBeforeUnbind"
+)
+
// UpdateProfileRequest 更新用户资料请求
type UpdateProfileRequest struct {
Email *string `json:"email"`
@@ -601,6 +608,7 @@ func (s *UserService) buildEmailIdentitySummary(user *User, records []UserAuthId
Provider: "email",
CanBind: false,
CanUnbind: false,
+ NoteKey: userIdentityNoteEmailManagedFromProfile,
Note: "Primary account email is managed from the profile form.",
}
if user == nil {
@@ -668,8 +676,10 @@ func (s *UserService) buildProviderIdentitySummary(provider string, user *User,
summary.VerifiedAt = primary.VerifiedAt
summary.CanUnbind = s.canUnbindProvider(provider, user, records)
if summary.CanUnbind {
+ summary.NoteKey = userIdentityNoteCanUnbind
summary.Note = "You can unbind this sign-in method."
} else {
+ summary.NoteKey = userIdentityNoteBindAnotherBeforeUnbind
summary.Note = "Bind another sign-in method before unbinding."
}
return summary
diff --git a/frontend/src/components/user/profile/ProfileIdentityBindingsSection.vue b/frontend/src/components/user/profile/ProfileIdentityBindingsSection.vue
index 626ab0ba..7843dace 100644
--- a/frontend/src/components/user/profile/ProfileIdentityBindingsSection.vue
+++ b/frontend/src/components/user/profile/ProfileIdentityBindingsSection.vue
@@ -67,23 +67,23 @@
{{ item.details.display_name }}
-
+
{{ item.details.subject_hint }}
{{ bindingCountLabel(item.details) }}
-
- {{ item.details.note }}
+
+ {{ bindingNote(item.details) }}
@@ -298,6 +298,13 @@ const emailSubmitActionLabel = computed(() =>
? t('profile.authBindings.confirmEmailReplaceAction')
: t('profile.authBindings.confirmEmailBindAction')
)
+const legacyBindingNoteKeys: Record = {
+ '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(
settings: WeChatOAuthPublicSettings | null | undefined
@@ -489,6 +496,36 @@ function bindingCountLabel(details: UserAuthBindingStatus | null): string {
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 {
isEmailFormExpanded.value = !isEmailFormExpanded.value
}
diff --git a/frontend/src/components/user/profile/__tests__/ProfileIdentityBindingsSection.spec.ts b/frontend/src/components/user/profile/__tests__/ProfileIdentityBindingsSection.spec.ts
index b54a1cce..3ad64861 100644
--- a/frontend/src/components/user/profile/__tests__/ProfileIdentityBindingsSection.spec.ts
+++ b/frontend/src/components/user/profile/__tests__/ProfileIdentityBindingsSection.spec.ts
@@ -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.bindSuccess') return 'Bind success'
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
},
}),
@@ -164,7 +170,7 @@ describe('ProfileIdentityBindingsSection', () => {
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('intent=bind_current_user')
expect(locationState.current.href).toContain('redirect=%2Fprofile')
@@ -219,7 +225,7 @@ describe('ProfileIdentityBindingsSection', () => {
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('intent=bind_current_user')
expect(locationState.current.href).toContain('redirect=%2Fprofile')
@@ -401,6 +407,36 @@ describe('ProfileIdentityBindingsSection', () => {
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 () => {
userApiMocks.sendEmailBindingCode.mockResolvedValue(undefined)
userApiMocks.bindEmailIdentity.mockResolvedValue(
@@ -541,6 +577,36 @@ describe('ProfileIdentityBindingsSection', () => {
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', () => {
const wrapper = mount(ProfileIdentityBindingsSection, {
global: {
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index 4a07f7b9..bbddfa35 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -1042,6 +1042,11 @@ export default {
oidc: '{providerName}',
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: {
avatar: 'Avatar is currently synced from {providerName}',
username: 'Nickname is currently synced from {providerName}',
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index 02eb84e6..e7cda148 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -1046,6 +1046,11 @@ export default {
oidc: '{providerName}',
wechat: '微信',
},
+ notes: {
+ emailManagedFromProfile: '主邮箱在资料表单中管理',
+ canUnbind: '你可以解绑这个登录方式。',
+ bindAnotherBeforeUnbind: '请先绑定其他登录方式,再解除当前绑定。',
+ },
source: {
avatar: '头像当前来自 {providerName}',
username: '昵称当前来自 {providerName}',
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 1d2f336d..4587b60a 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -51,6 +51,7 @@ export interface UserAuthBindingStatus {
bind_start_path?: string | null
can_bind?: boolean
can_unbind?: boolean
+ note_key?: string | null
note?: string | null
metadata?: Record
}