fix: clean up profile auth binding notes

This commit is contained in:
IanShaw027
2026-04-22 19:11:51 +08:00
parent c6d25f69d5
commit 5551349349
8 changed files with 139 additions and 7 deletions

View File

@ -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)

View File

@ -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) {
"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) {
"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": {

View File

@ -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 {
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
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,
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

View File

@ -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(() =>
? 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 {
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
} }

View File

@ -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', () => {
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', () => {
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', () => {
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', () => {
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: {

View File

@ -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}',

View File

@ -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}',

View File

@ -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>
} }