Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
65efef1e
Commit
65efef1e
authored
Apr 21, 2026
by
IanShaw027
Browse files
feat: support replacing bound primary email
parent
12f1e19d
Changes
8
Show whitespace changes
Inline
Side-by-side
backend/internal/handler/user_handler.go
View file @
65efef1e
...
@@ -167,7 +167,7 @@ type StartIdentityBindingRequest struct {
...
@@ -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
{
...
...
backend/internal/handler/user_handler_test.go
View file @
65efef1e
...
@@ -422,6 +422,59 @@ func TestUserHandlerBindEmailIdentityReturnsProfileResponse(t *testing.T) {
...
@@ -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
)
...
...
backend/internal/service/auth_email_binding.go
View file @
65efef1e
...
@@ -13,7 +13,8 @@ import (
...
@@ -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(
...
@@ -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(
...
@@ -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 {
...
@@ -137,14 +144,15 @@ func hasBindableEmailIdentitySubject(email string) bool {
return
normalized
!=
""
&&
!
isReservedEmail
(
normalized
)
return
normalized
!=
""
&&
!
isReservedEmail
(
normalized
)
}
}
func
(
s
*
AuthService
)
bi
ndEmailIdentity
WithDefaults
Tx
(
func
(
s
*
AuthService
)
updateBou
ndEmailIdentityTx
(
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
.
bi
ndEmailIdentityWith
Defaults
(
ctx
,
tx
.
Client
(),
currentUser
,
email
,
hashedPassword
)
return
s
.
updateBou
ndEmailIdentityWith
Client
(
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(
...
@@ -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
.
bi
ndEmailIdentityWith
Defaults
(
txCtx
,
tx
.
Client
(),
currentUser
,
email
,
hashedPassword
);
err
!=
nil
{
if
err
:=
s
.
updateBou
ndEmailIdentityWith
Client
(
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(
...
@@ -163,12 +171,13 @@ func (s *AuthService) bindEmailIdentityWithDefaultsTx(
return
nil
return
nil
}
}
func
(
s
*
AuthService
)
bi
ndEmailIdentityWith
Defaults
(
func
(
s
*
AuthService
)
updateBou
ndEmailIdentityWith
Client
(
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,9 +201,11 @@ func (s *AuthService) bindEmailIdentityWithDefaults(
...
@@ -192,9 +201,11 @@ func (s *AuthService) bindEmailIdentityWithDefaults(
return
ErrServiceUnavailable
return
ErrServiceUnavailable
}
}
if
applyFirstBindDefaults
{
if
err
:=
s
.
ApplyProviderDefaultSettingsOnFirstBind
(
ctx
,
currentUser
.
ID
,
"email"
);
err
!=
nil
{
if
err
:=
s
.
ApplyProviderDefaultSettingsOnFirstBind
(
ctx
,
currentUser
.
ID
,
"email"
);
err
!=
nil
{
return
fmt
.
Errorf
(
"apply email first bind defaults: %w"
,
err
)
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
)
if
err
!=
nil
{
if
err
!=
nil
{
...
...
backend/internal/service/auth_service_email_bind_test.go
View file @
65efef1e
...
@@ -285,6 +285,148 @@ func TestAuthServiceBindEmailIdentity_RejectsReservedEmail(t *testing.T) {
...
@@ -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
}
}
...
...
frontend/src/components/user/profile/ProfileIdentityBindingsSection.vue
View file @
65efef1e
...
@@ -34,7 +34,7 @@
...
@@ -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 @@
...
@@ -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.p
asswordPlaceholder
')
"
:placeholder=
"
emailP
asswordPlaceholder"
:disabled=
"isBindingEmail"
:disabled=
"isBindingEmail"
/>
/>
<button
<button
...
@@ -86,7 +86,7 @@
...
@@ -86,7 +86,7 @@
{{
{{
isBindingEmail
isBindingEmail
?
t
(
'
common.loading
'
)
?
t
(
'
common.loading
'
)
:
t
(
'
profile.authBindings.confirmEmailBind
Action
'
)
:
emailSubmit
Action
Label
}}
}}
</button>
</button>
</div>
</div>
...
@@ -160,7 +160,7 @@ watch(
...
@@ -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(
...
@@ -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 {
...
@@ -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> {
...
@@ -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
{
...
...
frontend/src/components/user/profile/__tests__/ProfileIdentityBindingsSection.spec.ts
View file @
65efef1e
...
@@ -51,10 +51,14 @@ vi.mock('vue-i18n', async (importOriginal) => {
...
@@ -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', () => {
...
@@ -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
'
)
})
})
})
frontend/src/i18n/locales/en.ts
View file @
65efef1e
...
@@ -967,9 +967,12 @@ export default {
...
@@ -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
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
65efef1e
...
@@ -971,9 +971,12 @@ export default {
...
@@ -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
:
'
未绑定
'
,
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment