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 }