feat: support replacing bound primary email

This commit is contained in:
IanShaw027
2026-04-21 13:47:15 +08:00
parent 12f1e19d68
commit 65efef1eee
8 changed files with 313 additions and 17 deletions

View File

@@ -34,7 +34,7 @@
</div>
<div
v-if="item.provider === 'email' && !item.bound"
v-if="item.provider === 'email'"
class="mt-3 grid gap-2 sm:grid-cols-[minmax(0,1.4fr)_auto]"
>
<input
@@ -73,7 +73,7 @@
data-testid="profile-binding-email-password-input"
type="password"
class="input"
:placeholder="t('profile.authBindings.passwordPlaceholder')"
:placeholder="emailPasswordPlaceholder"
:disabled="isBindingEmail"
/>
<button
@@ -86,7 +86,7 @@
{{
isBindingEmail
? t('common.loading')
: t('profile.authBindings.confirmEmailBindAction')
: emailSubmitActionLabel
}}
</button>
</div>
@@ -160,7 +160,7 @@ watch(
() => props.user,
(user) => {
localUser.value = null
if (!user || getBindingStatusForUser(user, 'email')) {
if (!user) {
return
}
if (typeof user.email === 'string' && !user.email.endsWith('.invalid')) {
@@ -171,6 +171,17 @@ watch(
)
const currentUser = computed(() => localUser.value ?? props.user)
const emailBound = computed(() => getBindingStatus('email'))
const emailPasswordPlaceholder = computed(() =>
emailBound.value
? t('profile.authBindings.replaceEmailPasswordPlaceholder')
: t('profile.authBindings.passwordPlaceholder')
)
const emailSubmitActionLabel = computed(() =>
emailBound.value
? t('profile.authBindings.confirmEmailReplaceAction')
: t('profile.authBindings.confirmEmailBindAction')
)
const wechatOAuthSettings = computed<WeChatOAuthPublicSettings | null>(() => {
if (hasExplicitWeChatOAuthCapabilities(appStore.cachedPublicSettings)) {
@@ -286,7 +297,7 @@ function validateEmailBindingForm(requireCode: boolean): boolean {
appStore.showError(t('auth.passwordRequired'))
return false
}
if (requireCode && emailBindingForm.password.length < 6) {
if (requireCode && !emailBound.value && emailBindingForm.password.length < 6) {
appStore.showError(t('auth.passwordMinLength'))
return false
}
@@ -321,10 +332,15 @@ async function bindEmail(): Promise<void> {
verify_code: emailBindingForm.verifyCode,
password: emailBindingForm.password,
})
const replacingBoundEmail = emailBound.value
applyUpdatedUser(user)
emailBindingForm.verifyCode = ''
emailBindingForm.password = ''
appStore.showSuccess(t('profile.authBindings.bindSuccess'))
appStore.showSuccess(
replacingBoundEmail
? t('profile.authBindings.replaceSuccess')
: t('profile.authBindings.bindSuccess')
)
} catch (error) {
appStore.showError((error as { message?: string }).message || t('common.tryAgain'))
} finally {

View File

@@ -51,10 +51,14 @@ vi.mock('vue-i18n', async (importOriginal) => {
if (key === 'profile.authBindings.emailPlaceholder') return 'Email address'
if (key === 'profile.authBindings.codePlaceholder') return 'Verification code'
if (key === 'profile.authBindings.passwordPlaceholder') return 'Set password'
if (key === 'profile.authBindings.replaceEmailPasswordPlaceholder')
return 'Current password'
if (key === 'profile.authBindings.sendCodeAction') return 'Send code'
if (key === 'profile.authBindings.confirmEmailBindAction') return 'Bind email'
if (key === 'profile.authBindings.confirmEmailReplaceAction') return 'Replace primary email'
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'
return key
},
}),
@@ -324,4 +328,68 @@ describe('ProfileIdentityBindingsSection', () => {
expect(wrapper.get('[data-testid="profile-binding-email-status"]').text()).toBe('Not bound')
expect(wrapper.get('[data-testid="profile-binding-email-input"]').exists()).toBe(true)
})
it('keeps the email form available for replacing a bound primary email', async () => {
userApiMocks.sendEmailBindingCode.mockResolvedValue(undefined)
userApiMocks.bindEmailIdentity.mockResolvedValue(
createUser({
email: 'new@example.com',
email_bound: true,
auth_bindings: {
email: { bound: true },
},
})
)
const appStore = useAppStore()
const authStore = useAuthStore()
authStore.user = createUser({
email: 'current@example.com',
email_bound: true,
auth_bindings: {
email: { bound: true },
},
})
const showSuccessSpy = vi.spyOn(appStore, 'showSuccess')
const wrapper = mount(ProfileIdentityBindingsSection, {
global: {
plugins: [pinia],
},
props: {
user: authStore.user,
linuxdoEnabled: false,
oidcEnabled: false,
wechatEnabled: false,
},
})
expect(wrapper.get('[data-testid="profile-binding-email-status"]').text()).toBe('Bound')
expect(wrapper.get('[data-testid="profile-binding-email-input"]').exists()).toBe(true)
expect(wrapper.get('[data-testid="profile-binding-email-submit"]').text()).toBe(
'Replace primary email'
)
expect(
(wrapper.get('[data-testid="profile-binding-email-password-input"]').element as HTMLInputElement)
.placeholder
).toBe('Current password')
await wrapper.get('[data-testid="profile-binding-email-input"]').setValue('new@example.com')
await wrapper.get('[data-testid="profile-binding-email-send-code"]').trigger('click')
expect(userApiMocks.sendEmailBindingCode).toHaveBeenCalledWith('new@example.com')
await wrapper.get('[data-testid="profile-binding-email-code-input"]').setValue('123456')
await wrapper.get('[data-testid="profile-binding-email-password-input"]').setValue(
'current-password'
)
await wrapper.get('[data-testid="profile-binding-email-submit"]').trigger('click')
expect(userApiMocks.bindEmailIdentity).toHaveBeenCalledWith({
email: 'new@example.com',
verify_code: '123456',
password: 'current-password',
})
expect(authStore.user?.email).toBe('new@example.com')
expect(showSuccessSpy).toHaveBeenCalledWith('Primary email updated')
})
})