feat: support replacing bound primary email
This commit is contained in:
@@ -167,7 +167,7 @@ type StartIdentityBindingRequest struct {
|
|||||||
type BindEmailIdentityRequest struct {
|
type BindEmailIdentityRequest struct {
|
||||||
Email string `json:"email" binding:"required,email"`
|
Email string `json:"email" binding:"required,email"`
|
||||||
VerifyCode string `json:"verify_code" binding:"required"`
|
VerifyCode string `json:"verify_code" binding:"required"`
|
||||||
Password string `json:"password" binding:"required,min=6"`
|
Password string `json:"password" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SendEmailBindingCodeRequest struct {
|
type SendEmailBindingCodeRequest struct {
|
||||||
|
|||||||
@@ -422,6 +422,59 @@ func TestUserHandlerBindEmailIdentityReturnsProfileResponse(t *testing.T) {
|
|||||||
require.True(t, resp.Data.EmailBound)
|
require.True(t, resp.Data.EmailBound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUserHandlerBindEmailIdentityRejectsWrongCurrentPasswordForBoundEmail(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
user := &service.User{
|
||||||
|
ID: 11,
|
||||||
|
Email: "current@example.com",
|
||||||
|
Username: "bound-user",
|
||||||
|
Role: service.RoleUser,
|
||||||
|
Status: service.StatusActive,
|
||||||
|
}
|
||||||
|
require.NoError(t, user.SetPassword("current-password"))
|
||||||
|
|
||||||
|
repo := &userHandlerRepoStub{user: user}
|
||||||
|
emailCache := &userHandlerEmailCacheStub{
|
||||||
|
data: &service.VerificationCodeData{
|
||||||
|
Code: "123456",
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
ExpiresAt: time.Now().UTC().Add(10 * time.Minute),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cfg := &config.Config{
|
||||||
|
JWT: config.JWTConfig{
|
||||||
|
Secret: "test-secret",
|
||||||
|
ExpireHour: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
emailService := service.NewEmailService(nil, emailCache)
|
||||||
|
authService := service.NewAuthService(nil, repo, nil, nil, cfg, nil, emailService, nil, nil, nil, nil)
|
||||||
|
handler := NewUserHandler(service.NewUserService(repo, nil, nil, nil), authService, nil, nil)
|
||||||
|
|
||||||
|
body := []byte(`{"email":"new@example.com","verify_code":"123456","password":"wrong-password"}`)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(recorder)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/user/account-bindings/email", bytes.NewReader(body))
|
||||||
|
c.Request.Header.Set("Content-Type", "application/json")
|
||||||
|
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 11})
|
||||||
|
|
||||||
|
handler.BindEmailIdentity(c)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
|
||||||
|
require.Equal(t, http.StatusBadRequest, resp.Code)
|
||||||
|
require.Equal(t, "PASSWORD_INCORRECT", resp.Reason)
|
||||||
|
require.Equal(t, "current password is incorrect", resp.Message)
|
||||||
|
require.Equal(t, "current@example.com", repo.user.Email)
|
||||||
|
}
|
||||||
|
|
||||||
func TestUserHandlerStartIdentityBindingReturnsAuthorizeURL(t *testing.T) {
|
func TestUserHandlerStartIdentityBindingReturnsAuthorizeURL(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import (
|
|||||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BindEmailIdentity verifies and binds a local email/password identity to the current user.
|
// BindEmailIdentity verifies and binds a local email/password identity to the
|
||||||
|
// current user, or replaces the existing bound primary email.
|
||||||
func (s *AuthService) BindEmailIdentity(
|
func (s *AuthService) BindEmailIdentity(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
userID int64,
|
userID int64,
|
||||||
@@ -43,6 +44,13 @@ func (s *AuthService) BindEmailIdentity(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
firstRealEmailBind := !hasBindableEmailIdentitySubject(currentUser.Email)
|
||||||
|
if firstRealEmailBind && len(password) < 6 {
|
||||||
|
return nil, infraerrors.BadRequest("PASSWORD_TOO_SHORT", "password must be at least 6 characters")
|
||||||
|
}
|
||||||
|
if !firstRealEmailBind && !s.CheckPassword(password, currentUser.PasswordHash) {
|
||||||
|
return nil, ErrPasswordIncorrect
|
||||||
|
}
|
||||||
|
|
||||||
existingUser, err := s.userRepo.GetByEmail(ctx, normalizedEmail)
|
existingUser, err := s.userRepo.GetByEmail(ctx, normalizedEmail)
|
||||||
switch {
|
switch {
|
||||||
@@ -57,9 +65,8 @@ func (s *AuthService) BindEmailIdentity(
|
|||||||
return nil, fmt.Errorf("hash password: %w", err)
|
return nil, fmt.Errorf("hash password: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
firstRealEmailBind := !hasBindableEmailIdentitySubject(currentUser.Email)
|
if s.entClient != nil {
|
||||||
if firstRealEmailBind && s.entClient != nil {
|
if err := s.updateBoundEmailIdentityTx(ctx, currentUser, normalizedEmail, hashedPassword, firstRealEmailBind); err != nil {
|
||||||
if err := s.bindEmailIdentityWithDefaultsTx(ctx, currentUser, normalizedEmail, hashedPassword); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return currentUser, nil
|
return currentUser, nil
|
||||||
@@ -137,14 +144,15 @@ func hasBindableEmailIdentitySubject(email string) bool {
|
|||||||
return normalized != "" && !isReservedEmail(normalized)
|
return normalized != "" && !isReservedEmail(normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AuthService) bindEmailIdentityWithDefaultsTx(
|
func (s *AuthService) updateBoundEmailIdentityTx(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
currentUser *User,
|
currentUser *User,
|
||||||
email string,
|
email string,
|
||||||
hashedPassword string,
|
hashedPassword string,
|
||||||
|
applyFirstBindDefaults bool,
|
||||||
) error {
|
) error {
|
||||||
if tx := dbent.TxFromContext(ctx); tx != nil {
|
if tx := dbent.TxFromContext(ctx); tx != nil {
|
||||||
return s.bindEmailIdentityWithDefaults(ctx, tx.Client(), currentUser, email, hashedPassword)
|
return s.updateBoundEmailIdentityWithClient(ctx, tx.Client(), currentUser, email, hashedPassword, applyFirstBindDefaults)
|
||||||
}
|
}
|
||||||
|
|
||||||
tx, err := s.entClient.Tx(ctx)
|
tx, err := s.entClient.Tx(ctx)
|
||||||
@@ -154,7 +162,7 @@ func (s *AuthService) bindEmailIdentityWithDefaultsTx(
|
|||||||
defer func() { _ = tx.Rollback() }()
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
txCtx := dbent.NewTxContext(ctx, tx)
|
txCtx := dbent.NewTxContext(ctx, tx)
|
||||||
if err := s.bindEmailIdentityWithDefaults(txCtx, tx.Client(), currentUser, email, hashedPassword); err != nil {
|
if err := s.updateBoundEmailIdentityWithClient(txCtx, tx.Client(), currentUser, email, hashedPassword, applyFirstBindDefaults); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
@@ -163,12 +171,13 @@ func (s *AuthService) bindEmailIdentityWithDefaultsTx(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AuthService) bindEmailIdentityWithDefaults(
|
func (s *AuthService) updateBoundEmailIdentityWithClient(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
client *dbent.Client,
|
client *dbent.Client,
|
||||||
currentUser *User,
|
currentUser *User,
|
||||||
email string,
|
email string,
|
||||||
hashedPassword string,
|
hashedPassword string,
|
||||||
|
applyFirstBindDefaults bool,
|
||||||
) error {
|
) error {
|
||||||
if client == nil || currentUser == nil || currentUser.ID <= 0 {
|
if client == nil || currentUser == nil || currentUser.ID <= 0 {
|
||||||
return ErrServiceUnavailable
|
return ErrServiceUnavailable
|
||||||
@@ -192,8 +201,10 @@ func (s *AuthService) bindEmailIdentityWithDefaults(
|
|||||||
return ErrServiceUnavailable
|
return ErrServiceUnavailable
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.ApplyProviderDefaultSettingsOnFirstBind(ctx, currentUser.ID, "email"); err != nil {
|
if applyFirstBindDefaults {
|
||||||
return fmt.Errorf("apply email first bind defaults: %w", err)
|
if err := s.ApplyProviderDefaultSettingsOnFirstBind(ctx, currentUser.ID, "email"); err != nil {
|
||||||
|
return fmt.Errorf("apply email first bind defaults: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updatedUser, err := client.User.Get(ctx, currentUser.ID)
|
updatedUser, err := client.User.Get(ctx, currentUser.ID)
|
||||||
|
|||||||
@@ -285,6 +285,148 @@ func TestAuthServiceBindEmailIdentity_RejectsReservedEmail(t *testing.T) {
|
|||||||
require.Nil(t, updatedUser)
|
require.Nil(t, updatedUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuthServiceBindEmailIdentity_ReplacesBoundEmailAndSkipsFirstBindDefaults(t *testing.T) {
|
||||||
|
assigner := &emailBindDefaultSubAssignerStub{}
|
||||||
|
cache := &emailBindCacheStub{
|
||||||
|
data: &service.VerificationCodeData{
|
||||||
|
Code: "123456",
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
ExpiresAt: time.Now().UTC().Add(10 * time.Minute),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc, _, client := newAuthServiceForEmailBind(t, map[string]string{
|
||||||
|
service.SettingKeyAuthSourceDefaultEmailBalance: "8.5",
|
||||||
|
service.SettingKeyAuthSourceDefaultEmailConcurrency: "4",
|
||||||
|
service.SettingKeyAuthSourceDefaultEmailSubscriptions: `[{"group_id":11,"validity_days":30}]`,
|
||||||
|
service.SettingKeyAuthSourceDefaultEmailGrantOnFirstBind: "true",
|
||||||
|
}, cache, assigner)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
hashedPassword, err := svc.HashPassword("current-password")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
user, err := client.User.Create().
|
||||||
|
SetEmail("current@example.com").
|
||||||
|
SetUsername("bound-user").
|
||||||
|
SetPasswordHash(hashedPassword).
|
||||||
|
SetBalance(7.5).
|
||||||
|
SetConcurrency(3).
|
||||||
|
SetRole(service.RoleUser).
|
||||||
|
SetStatus(service.StatusActive).
|
||||||
|
Save(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, client.AuthIdentity.Create().
|
||||||
|
SetUserID(user.ID).
|
||||||
|
SetProviderType("email").
|
||||||
|
SetProviderKey("email").
|
||||||
|
SetProviderSubject("current@example.com").
|
||||||
|
SetVerifiedAt(time.Now().UTC()).
|
||||||
|
SetMetadata(map[string]any{"source": "test"}).
|
||||||
|
Exec(ctx))
|
||||||
|
|
||||||
|
updatedUser, err := svc.BindEmailIdentity(ctx, user.ID, "new@example.com", "123456", "current-password")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, updatedUser)
|
||||||
|
require.Equal(t, "new@example.com", updatedUser.Email)
|
||||||
|
|
||||||
|
storedUser, err := client.User.Get(ctx, user.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "new@example.com", storedUser.Email)
|
||||||
|
require.Equal(t, 7.5, storedUser.Balance)
|
||||||
|
require.Equal(t, 3, storedUser.Concurrency)
|
||||||
|
require.True(t, svc.CheckPassword("current-password", storedUser.PasswordHash))
|
||||||
|
|
||||||
|
newIdentityCount, err := client.AuthIdentity.Query().
|
||||||
|
Where(
|
||||||
|
authidentity.UserIDEQ(user.ID),
|
||||||
|
authidentity.ProviderTypeEQ("email"),
|
||||||
|
authidentity.ProviderKeyEQ("email"),
|
||||||
|
authidentity.ProviderSubjectEQ("new@example.com"),
|
||||||
|
).
|
||||||
|
Count(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 1, newIdentityCount)
|
||||||
|
|
||||||
|
oldIdentityCount, err := client.AuthIdentity.Query().
|
||||||
|
Where(
|
||||||
|
authidentity.UserIDEQ(user.ID),
|
||||||
|
authidentity.ProviderTypeEQ("email"),
|
||||||
|
authidentity.ProviderKeyEQ("email"),
|
||||||
|
authidentity.ProviderSubjectEQ("current@example.com"),
|
||||||
|
).
|
||||||
|
Count(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 0, oldIdentityCount)
|
||||||
|
|
||||||
|
require.Empty(t, assigner.calls)
|
||||||
|
require.Equal(t, 0, countProviderGrantRecords(t, client, user.ID, "email", "first_bind"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthServiceBindEmailIdentity_RejectsWrongCurrentPasswordForBoundEmail(t *testing.T) {
|
||||||
|
cache := &emailBindCacheStub{
|
||||||
|
data: &service.VerificationCodeData{
|
||||||
|
Code: "123456",
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
ExpiresAt: time.Now().UTC().Add(10 * time.Minute),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc, _, client := newAuthServiceForEmailBind(t, nil, cache, nil)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
hashedPassword, err := svc.HashPassword("current-password")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
user, err := client.User.Create().
|
||||||
|
SetEmail("current@example.com").
|
||||||
|
SetUsername("bound-user").
|
||||||
|
SetPasswordHash(hashedPassword).
|
||||||
|
SetBalance(1).
|
||||||
|
SetConcurrency(1).
|
||||||
|
SetRole(service.RoleUser).
|
||||||
|
SetStatus(service.StatusActive).
|
||||||
|
Save(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, client.AuthIdentity.Create().
|
||||||
|
SetUserID(user.ID).
|
||||||
|
SetProviderType("email").
|
||||||
|
SetProviderKey("email").
|
||||||
|
SetProviderSubject("current@example.com").
|
||||||
|
SetVerifiedAt(time.Now().UTC()).
|
||||||
|
SetMetadata(map[string]any{"source": "test"}).
|
||||||
|
Exec(ctx))
|
||||||
|
|
||||||
|
updatedUser, err := svc.BindEmailIdentity(ctx, user.ID, "new@example.com", "123456", "wrong-password")
|
||||||
|
require.ErrorIs(t, err, service.ErrPasswordIncorrect)
|
||||||
|
require.Nil(t, updatedUser)
|
||||||
|
|
||||||
|
storedUser, err := client.User.Get(ctx, user.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "current@example.com", storedUser.Email)
|
||||||
|
require.True(t, svc.CheckPassword("current-password", storedUser.PasswordHash))
|
||||||
|
|
||||||
|
oldIdentityCount, err := client.AuthIdentity.Query().
|
||||||
|
Where(
|
||||||
|
authidentity.UserIDEQ(user.ID),
|
||||||
|
authidentity.ProviderTypeEQ("email"),
|
||||||
|
authidentity.ProviderKeyEQ("email"),
|
||||||
|
authidentity.ProviderSubjectEQ("current@example.com"),
|
||||||
|
).
|
||||||
|
Count(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 1, oldIdentityCount)
|
||||||
|
|
||||||
|
newIdentityCount, err := client.AuthIdentity.Query().
|
||||||
|
Where(
|
||||||
|
authidentity.UserIDEQ(user.ID),
|
||||||
|
authidentity.ProviderTypeEQ("email"),
|
||||||
|
authidentity.ProviderKeyEQ("email"),
|
||||||
|
authidentity.ProviderSubjectEQ("new@example.com"),
|
||||||
|
).
|
||||||
|
Count(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 0, newIdentityCount)
|
||||||
|
}
|
||||||
|
|
||||||
type emailBindSettingRepoStub struct {
|
type emailBindSettingRepoStub struct {
|
||||||
values map[string]string
|
values map[string]string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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]"
|
class="mt-3 grid gap-2 sm:grid-cols-[minmax(0,1.4fr)_auto]"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
data-testid="profile-binding-email-password-input"
|
data-testid="profile-binding-email-password-input"
|
||||||
type="password"
|
type="password"
|
||||||
class="input"
|
class="input"
|
||||||
:placeholder="t('profile.authBindings.passwordPlaceholder')"
|
:placeholder="emailPasswordPlaceholder"
|
||||||
:disabled="isBindingEmail"
|
:disabled="isBindingEmail"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -86,7 +86,7 @@
|
|||||||
{{
|
{{
|
||||||
isBindingEmail
|
isBindingEmail
|
||||||
? t('common.loading')
|
? t('common.loading')
|
||||||
: t('profile.authBindings.confirmEmailBindAction')
|
: emailSubmitActionLabel
|
||||||
}}
|
}}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -160,7 +160,7 @@ watch(
|
|||||||
() => props.user,
|
() => props.user,
|
||||||
(user) => {
|
(user) => {
|
||||||
localUser.value = null
|
localUser.value = null
|
||||||
if (!user || getBindingStatusForUser(user, 'email')) {
|
if (!user) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (typeof user.email === 'string' && !user.email.endsWith('.invalid')) {
|
if (typeof user.email === 'string' && !user.email.endsWith('.invalid')) {
|
||||||
@@ -171,6 +171,17 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const currentUser = computed(() => localUser.value ?? props.user)
|
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>(() => {
|
const wechatOAuthSettings = computed<WeChatOAuthPublicSettings | null>(() => {
|
||||||
if (hasExplicitWeChatOAuthCapabilities(appStore.cachedPublicSettings)) {
|
if (hasExplicitWeChatOAuthCapabilities(appStore.cachedPublicSettings)) {
|
||||||
@@ -286,7 +297,7 @@ function validateEmailBindingForm(requireCode: boolean): boolean {
|
|||||||
appStore.showError(t('auth.passwordRequired'))
|
appStore.showError(t('auth.passwordRequired'))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (requireCode && emailBindingForm.password.length < 6) {
|
if (requireCode && !emailBound.value && emailBindingForm.password.length < 6) {
|
||||||
appStore.showError(t('auth.passwordMinLength'))
|
appStore.showError(t('auth.passwordMinLength'))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -321,10 +332,15 @@ async function bindEmail(): Promise<void> {
|
|||||||
verify_code: emailBindingForm.verifyCode,
|
verify_code: emailBindingForm.verifyCode,
|
||||||
password: emailBindingForm.password,
|
password: emailBindingForm.password,
|
||||||
})
|
})
|
||||||
|
const replacingBoundEmail = emailBound.value
|
||||||
applyUpdatedUser(user)
|
applyUpdatedUser(user)
|
||||||
emailBindingForm.verifyCode = ''
|
emailBindingForm.verifyCode = ''
|
||||||
emailBindingForm.password = ''
|
emailBindingForm.password = ''
|
||||||
appStore.showSuccess(t('profile.authBindings.bindSuccess'))
|
appStore.showSuccess(
|
||||||
|
replacingBoundEmail
|
||||||
|
? t('profile.authBindings.replaceSuccess')
|
||||||
|
: t('profile.authBindings.bindSuccess')
|
||||||
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
appStore.showError((error as { message?: string }).message || t('common.tryAgain'))
|
appStore.showError((error as { message?: string }).message || t('common.tryAgain'))
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -51,10 +51,14 @@ vi.mock('vue-i18n', async (importOriginal) => {
|
|||||||
if (key === 'profile.authBindings.emailPlaceholder') return 'Email address'
|
if (key === 'profile.authBindings.emailPlaceholder') return 'Email address'
|
||||||
if (key === 'profile.authBindings.codePlaceholder') return 'Verification code'
|
if (key === 'profile.authBindings.codePlaceholder') return 'Verification code'
|
||||||
if (key === 'profile.authBindings.passwordPlaceholder') return 'Set password'
|
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.sendCodeAction') return 'Send code'
|
||||||
if (key === 'profile.authBindings.confirmEmailBindAction') return 'Bind email'
|
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.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'
|
||||||
return key
|
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-status"]').text()).toBe('Not bound')
|
||||||
expect(wrapper.get('[data-testid="profile-binding-email-input"]').exists()).toBe(true)
|
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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -967,9 +967,12 @@ export default {
|
|||||||
emailPlaceholder: 'Enter email address',
|
emailPlaceholder: 'Enter email address',
|
||||||
codePlaceholder: 'Enter verification code',
|
codePlaceholder: 'Enter verification code',
|
||||||
passwordPlaceholder: 'Set a login password',
|
passwordPlaceholder: 'Set a login password',
|
||||||
|
replaceEmailPasswordPlaceholder: 'Enter current password',
|
||||||
sendCodeAction: 'Send code',
|
sendCodeAction: 'Send code',
|
||||||
confirmEmailBindAction: 'Bind email',
|
confirmEmailBindAction: 'Bind email',
|
||||||
|
confirmEmailReplaceAction: 'Replace primary email',
|
||||||
codeSentTo: 'Code sent to {email}',
|
codeSentTo: 'Code sent to {email}',
|
||||||
|
replaceSuccess: 'Primary email updated',
|
||||||
status: {
|
status: {
|
||||||
bound: 'Bound',
|
bound: 'Bound',
|
||||||
notBound: 'Not bound',
|
notBound: 'Not bound',
|
||||||
|
|||||||
@@ -971,9 +971,12 @@ export default {
|
|||||||
emailPlaceholder: '输入邮箱地址',
|
emailPlaceholder: '输入邮箱地址',
|
||||||
codePlaceholder: '输入验证码',
|
codePlaceholder: '输入验证码',
|
||||||
passwordPlaceholder: '设置登录密码',
|
passwordPlaceholder: '设置登录密码',
|
||||||
|
replaceEmailPasswordPlaceholder: '输入当前密码',
|
||||||
sendCodeAction: '发送验证码',
|
sendCodeAction: '发送验证码',
|
||||||
confirmEmailBindAction: '绑定邮箱',
|
confirmEmailBindAction: '绑定邮箱',
|
||||||
|
confirmEmailReplaceAction: '更换主邮箱',
|
||||||
codeSentTo: '验证码已发送到 {email}',
|
codeSentTo: '验证码已发送到 {email}',
|
||||||
|
replaceSuccess: '主邮箱已更新',
|
||||||
status: {
|
status: {
|
||||||
bound: '已绑定',
|
bound: '已绑定',
|
||||||
notBound: '未绑定',
|
notBound: '未绑定',
|
||||||
|
|||||||
Reference in New Issue
Block a user