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
81c827ee
Commit
81c827ee
authored
Apr 22, 2026
by
IanShaw027
Browse files
fix(profile): stabilize identity binding management
parent
83cad63c
Changes
13
Show whitespace changes
Inline
Side-by-side
backend/internal/handler/auth_current_user_test.go
View file @
81c827ee
...
@@ -37,6 +37,7 @@ func TestAuthHandlerGetCurrentUserReturnsProfileCompatibilityFields(t *testing.T
...
@@ -37,6 +37,7 @@ func TestAuthHandlerGetCurrentUserReturnsProfileCompatibilityFields(t *testing.T
VerifiedAt
:
&
verifiedAt
,
VerifiedAt
:
&
verifiedAt
,
Metadata
:
map
[
string
]
any
{
Metadata
:
map
[
string
]
any
{
"username"
:
"linuxdo-handle"
,
"username"
:
"linuxdo-handle"
,
"avatar_url"
:
"https://cdn.example.com/linuxdo.png"
,
},
},
},
},
},
},
...
...
backend/internal/handler/user_handler.go
View file @
81c827ee
...
@@ -258,6 +258,12 @@ func (h *UserHandler) UnbindIdentity(c *gin.Context) {
...
@@ -258,6 +258,12 @@ func (h *UserHandler) UnbindIdentity(c *gin.Context) {
response
.
ErrorFrom
(
c
,
err
)
response
.
ErrorFrom
(
c
,
err
)
return
return
}
}
if
h
.
authService
!=
nil
{
if
err
:=
h
.
authService
.
RevokeAllUserSessions
(
c
.
Request
.
Context
(),
subject
.
UserID
);
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
}
profileResp
,
err
:=
h
.
buildUserProfileResponse
(
c
.
Request
.
Context
(),
subject
.
UserID
,
updatedUser
)
profileResp
,
err
:=
h
.
buildUserProfileResponse
(
c
.
Request
.
Context
(),
subject
.
UserID
,
updatedUser
)
if
err
!=
nil
{
if
err
!=
nil
{
...
@@ -504,8 +510,12 @@ func inferUserProfileSources(user *service.User, identities service.UserIdentity
...
@@ -504,8 +510,12 @@ func inferUserProfileSources(user *service.User, identities service.UserIdentity
thirdParty
:=
thirdPartyIdentityProviders
(
identities
)
thirdParty
:=
thirdPartyIdentityProviders
(
identities
)
var
avatarSource
*
userProfileSourceContext
var
avatarSource
*
userProfileSourceContext
if
strings
.
TrimSpace
(
user
.
AvatarURL
)
!=
""
&&
len
(
thirdParty
)
==
1
{
avatarValue
:=
strings
.
TrimSpace
(
user
.
AvatarURL
)
avatarSource
=
buildUserProfileSourceContext
(
thirdParty
[
0
]
.
Provider
)
for
_
,
summary
:=
range
thirdParty
{
if
avatarValue
!=
""
&&
avatarValue
==
strings
.
TrimSpace
(
summary
.
DisplayName
)
{
avatarSource
=
buildUserProfileSourceContext
(
summary
.
Provider
)
break
}
}
}
usernameValue
:=
strings
.
TrimSpace
(
user
.
Username
)
usernameValue
:=
strings
.
TrimSpace
(
user
.
Username
)
...
@@ -516,9 +526,6 @@ func inferUserProfileSources(user *service.User, identities service.UserIdentity
...
@@ -516,9 +526,6 @@ func inferUserProfileSources(user *service.User, identities service.UserIdentity
break
break
}
}
}
}
if
usernameSource
==
nil
&&
usernameValue
!=
""
&&
len
(
thirdParty
)
==
1
{
usernameSource
=
buildUserProfileSourceContext
(
thirdParty
[
0
]
.
Provider
)
}
profileSources
:=
map
[
string
]
*
userProfileSourceContext
{}
profileSources
:=
map
[
string
]
*
userProfileSourceContext
{}
if
avatarSource
!=
nil
{
if
avatarSource
!=
nil
{
...
...
backend/internal/handler/user_handler_test.go
View file @
81c827ee
...
@@ -278,6 +278,7 @@ func TestUserHandlerGetProfileReturnsLegacyCompatibilityFields(t *testing.T) {
...
@@ -278,6 +278,7 @@ func TestUserHandlerGetProfileReturnsLegacyCompatibilityFields(t *testing.T) {
VerifiedAt
:
&
verifiedAt
,
VerifiedAt
:
&
verifiedAt
,
Metadata
:
map
[
string
]
any
{
Metadata
:
map
[
string
]
any
{
"username"
:
"linuxdo-handle"
,
"username"
:
"linuxdo-handle"
,
"avatar_url"
:
"https://cdn.example.com/linuxdo.png"
,
},
},
},
},
},
},
...
@@ -331,10 +332,102 @@ func TestUserHandlerGetProfileReturnsLegacyCompatibilityFields(t *testing.T) {
...
@@ -331,10 +332,102 @@ func TestUserHandlerGetProfileReturnsLegacyCompatibilityFields(t *testing.T) {
require
.
Equal
(
t
,
"linuxdo"
,
usernameSource
[
"source"
])
require
.
Equal
(
t
,
"linuxdo"
,
usernameSource
[
"source"
])
}
}
func
TestUserHandlerGetProfileDoesNotInferEditedProfileSourcesWithoutMatchingIdentityMetadata
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
repo
:=
&
userHandlerRepoStub
{
user
:
&
service
.
User
{
ID
:
22
,
Email
:
"edited-profile@example.com"
,
Username
:
"custom-name"
,
Role
:
service
.
RoleUser
,
Status
:
service
.
StatusActive
,
AvatarURL
:
"https://cdn.example.com/custom.png"
,
AvatarSource
:
"remote_url"
,
},
identities
:
[]
service
.
UserAuthIdentityRecord
{
{
ProviderType
:
"linuxdo"
,
ProviderKey
:
"linuxdo"
,
ProviderSubject
:
"linuxdo-subject-22"
,
Metadata
:
map
[
string
]
any
{
"username"
:
"linuxdo-handle"
,
"avatar_url"
:
"https://cdn.example.com/linuxdo.png"
,
},
},
},
}
handler
:=
NewUserHandler
(
service
.
NewUserService
(
repo
,
nil
,
nil
,
nil
),
nil
,
nil
,
nil
)
recorder
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
recorder
)
c
.
Request
=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/user/profile"
,
nil
)
c
.
Set
(
string
(
middleware2
.
ContextKeyUser
),
middleware2
.
AuthSubject
{
UserID
:
22
})
handler
.
GetProfile
(
c
)
require
.
Equal
(
t
,
http
.
StatusOK
,
recorder
.
Code
)
var
resp
struct
{
Code
int
`json:"code"`
Data
map
[
string
]
any
`json:"data"`
}
require
.
NoError
(
t
,
json
.
Unmarshal
(
recorder
.
Body
.
Bytes
(),
&
resp
))
require
.
Equal
(
t
,
0
,
resp
.
Code
)
require
.
NotContains
(
t
,
resp
.
Data
,
"avatar_source"
)
require
.
NotContains
(
t
,
resp
.
Data
,
"username_source"
)
require
.
NotContains
(
t
,
resp
.
Data
,
"profile_sources"
)
}
type
userHandlerEmailCacheStub
struct
{
type
userHandlerEmailCacheStub
struct
{
data
*
service
.
VerificationCodeData
data
*
service
.
VerificationCodeData
}
}
type
userHandlerRefreshTokenCacheStub
struct
{
revokedUserIDs
[]
int64
}
func
(
s
*
userHandlerRefreshTokenCacheStub
)
StoreRefreshToken
(
context
.
Context
,
string
,
*
service
.
RefreshTokenData
,
time
.
Duration
)
error
{
return
nil
}
func
(
s
*
userHandlerRefreshTokenCacheStub
)
GetRefreshToken
(
context
.
Context
,
string
)
(
*
service
.
RefreshTokenData
,
error
)
{
return
nil
,
service
.
ErrRefreshTokenNotFound
}
func
(
s
*
userHandlerRefreshTokenCacheStub
)
DeleteRefreshToken
(
context
.
Context
,
string
)
error
{
return
nil
}
func
(
s
*
userHandlerRefreshTokenCacheStub
)
DeleteUserRefreshTokens
(
_
context
.
Context
,
userID
int64
)
error
{
s
.
revokedUserIDs
=
append
(
s
.
revokedUserIDs
,
userID
)
return
nil
}
func
(
s
*
userHandlerRefreshTokenCacheStub
)
DeleteTokenFamily
(
context
.
Context
,
string
)
error
{
return
nil
}
func
(
s
*
userHandlerRefreshTokenCacheStub
)
AddToUserTokenSet
(
context
.
Context
,
int64
,
string
,
time
.
Duration
)
error
{
return
nil
}
func
(
s
*
userHandlerRefreshTokenCacheStub
)
AddToFamilyTokenSet
(
context
.
Context
,
string
,
string
,
time
.
Duration
)
error
{
return
nil
}
func
(
s
*
userHandlerRefreshTokenCacheStub
)
GetUserTokenHashes
(
context
.
Context
,
int64
)
([]
string
,
error
)
{
return
nil
,
nil
}
func
(
s
*
userHandlerRefreshTokenCacheStub
)
GetFamilyTokenHashes
(
context
.
Context
,
string
)
([]
string
,
error
)
{
return
nil
,
nil
}
func
(
s
*
userHandlerRefreshTokenCacheStub
)
IsTokenInFamily
(
context
.
Context
,
string
,
string
)
(
bool
,
error
)
{
return
false
,
nil
}
func
(
s
*
userHandlerEmailCacheStub
)
GetVerificationCode
(
context
.
Context
,
string
)
(
*
service
.
VerificationCodeData
,
error
)
{
func
(
s
*
userHandlerEmailCacheStub
)
GetVerificationCode
(
context
.
Context
,
string
)
(
*
service
.
VerificationCodeData
,
error
)
{
return
s
.
data
,
nil
return
s
.
data
,
nil
}
}
...
@@ -495,6 +588,52 @@ func TestUserHandlerUnbindIdentityReturnsUpdatedProfile(t *testing.T) {
...
@@ -495,6 +588,52 @@ func TestUserHandlerUnbindIdentityReturnsUpdatedProfile(t *testing.T) {
require
.
Equal
(
t
,
false
,
linuxdoBinding
[
"bound"
])
require
.
Equal
(
t
,
false
,
linuxdoBinding
[
"bound"
])
}
}
func
TestUserHandlerUnbindIdentityRevokesAllUserSessionsWhenAuthServiceConfigured
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
repo
:=
&
userHandlerRepoStub
{
user
:
&
service
.
User
{
ID
:
23
,
Email
:
"identity@example.com"
,
Username
:
"identity-user"
,
Role
:
service
.
RoleUser
,
Status
:
service
.
StatusActive
,
},
identities
:
[]
service
.
UserAuthIdentityRecord
{
{
ProviderType
:
"email"
,
ProviderKey
:
"email"
,
ProviderSubject
:
"identity@example.com"
,
},
{
ProviderType
:
"linuxdo"
,
ProviderKey
:
"linuxdo"
,
ProviderSubject
:
"linuxdo-subject-23"
,
},
},
}
refreshTokenCache
:=
&
userHandlerRefreshTokenCacheStub
{}
cfg
:=
&
config
.
Config
{
JWT
:
config
.
JWTConfig
{
Secret
:
"test-secret"
,
ExpireHour
:
1
,
},
}
authService
:=
service
.
NewAuthService
(
nil
,
repo
,
nil
,
refreshTokenCache
,
cfg
,
nil
,
nil
,
nil
,
nil
,
nil
,
nil
)
handler
:=
NewUserHandler
(
service
.
NewUserService
(
repo
,
nil
,
nil
,
nil
),
authService
,
nil
,
nil
)
recorder
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
recorder
)
c
.
Request
=
httptest
.
NewRequest
(
http
.
MethodDelete
,
"/api/v1/user/account-bindings/linuxdo"
,
nil
)
c
.
Set
(
string
(
middleware2
.
ContextKeyUser
),
middleware2
.
AuthSubject
{
UserID
:
23
})
c
.
Params
=
gin
.
Params
{{
Key
:
"provider"
,
Value
:
"linuxdo"
}}
handler
.
UnbindIdentity
(
c
)
require
.
Equal
(
t
,
http
.
StatusOK
,
recorder
.
Code
)
require
.
Equal
(
t
,
[]
int64
{
23
},
refreshTokenCache
.
revokedUserIDs
)
}
func
TestUserHandlerBindEmailIdentityRejectsWrongCurrentPasswordForBoundEmail
(
t
*
testing
.
T
)
{
func
TestUserHandlerBindEmailIdentityRejectsWrongCurrentPasswordForBoundEmail
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
gin
.
SetMode
(
gin
.
TestMode
)
...
...
backend/internal/repository/user_profile_identity_repo.go
View file @
81c827ee
...
@@ -301,17 +301,18 @@ func (r *userRepository) BindAuthIdentityToUser(ctx context.Context, input BindA
...
@@ -301,17 +301,18 @@ func (r *userRepository) BindAuthIdentityToUser(ctx context.Context, input BindA
client
:=
clientFromContext
(
txCtx
,
r
.
client
)
client
:=
clientFromContext
(
txCtx
,
r
.
client
)
canonical
:=
input
.
Canonical
canonical
:=
input
.
Canonical
identity
,
err
:=
client
.
AuthIdentity
.
Query
()
.
identity
Records
,
err
:=
client
.
AuthIdentity
.
Query
()
.
Where
(
Where
(
authidentity
.
ProviderTypeEQ
(
strings
.
TrimSpace
(
canonical
.
ProviderType
)),
authidentity
.
ProviderTypeEQ
(
strings
.
TrimSpace
(
canonical
.
ProviderType
)),
authidentity
.
ProviderKey
EQ
(
strings
.
TrimSpace
(
canonical
.
ProviderKey
)),
authidentity
.
ProviderKey
In
(
compatibleIdentityProviderKeys
(
canonical
.
ProviderType
,
canonical
.
ProviderKey
)
...
),
authidentity
.
ProviderSubjectEQ
(
strings
.
TrimSpace
(
canonical
.
ProviderSubject
)),
authidentity
.
ProviderSubjectEQ
(
strings
.
TrimSpace
(
canonical
.
ProviderSubject
)),
)
.
)
.
Only
(
txCtx
)
All
(
txCtx
)
if
err
!=
nil
&&
!
dbent
.
IsNotFound
(
err
)
{
if
err
!=
nil
{
return
err
return
err
}
}
if
identity
!=
nil
&&
identity
.
UserID
!=
input
.
UserID
{
identity
:=
selectOwnedCompatibleIdentity
(
identityRecords
,
input
.
UserID
)
if
identity
==
nil
&&
hasCompatibleIdentityConflict
(
identityRecords
,
input
.
UserID
)
{
return
ErrAuthIdentityOwnershipConflict
return
ErrAuthIdentityOwnershipConflict
}
}
if
identity
==
nil
{
if
identity
==
nil
{
...
@@ -346,20 +347,21 @@ func (r *userRepository) BindAuthIdentityToUser(ctx context.Context, input BindA
...
@@ -346,20 +347,21 @@ func (r *userRepository) BindAuthIdentityToUser(ctx context.Context, input BindA
var
channel
*
dbent
.
AuthIdentityChannel
var
channel
*
dbent
.
AuthIdentityChannel
if
input
.
Channel
!=
nil
{
if
input
.
Channel
!=
nil
{
channel
,
err
=
client
.
AuthIdentityChannel
.
Query
()
.
channel
Records
,
err
:
=
client
.
AuthIdentityChannel
.
Query
()
.
Where
(
Where
(
authidentitychannel
.
ProviderTypeEQ
(
strings
.
TrimSpace
(
input
.
Channel
.
ProviderType
)),
authidentitychannel
.
ProviderTypeEQ
(
strings
.
TrimSpace
(
input
.
Channel
.
ProviderType
)),
authidentitychannel
.
ProviderKey
EQ
(
strings
.
TrimSpace
(
input
.
Channel
.
ProviderKey
)),
authidentitychannel
.
ProviderKey
In
(
compatibleIdentityProviderKeys
(
input
.
Channel
.
ProviderType
,
input
.
Channel
.
ProviderKey
)
...
),
authidentitychannel
.
ChannelEQ
(
strings
.
TrimSpace
(
input
.
Channel
.
Channel
)),
authidentitychannel
.
ChannelEQ
(
strings
.
TrimSpace
(
input
.
Channel
.
Channel
)),
authidentitychannel
.
ChannelAppIDEQ
(
strings
.
TrimSpace
(
input
.
Channel
.
ChannelAppID
)),
authidentitychannel
.
ChannelAppIDEQ
(
strings
.
TrimSpace
(
input
.
Channel
.
ChannelAppID
)),
authidentitychannel
.
ChannelSubjectEQ
(
strings
.
TrimSpace
(
input
.
Channel
.
ChannelSubject
)),
authidentitychannel
.
ChannelSubjectEQ
(
strings
.
TrimSpace
(
input
.
Channel
.
ChannelSubject
)),
)
.
)
.
WithIdentity
()
.
WithIdentity
()
.
Only
(
txCtx
)
All
(
txCtx
)
if
err
!=
nil
&&
!
dbent
.
IsNotFound
(
err
)
{
if
err
!=
nil
{
return
err
return
err
}
}
if
channel
!=
nil
&&
channel
.
Edges
.
Identity
!=
nil
&&
channel
.
Edges
.
Identity
.
UserID
!=
input
.
UserID
{
channel
=
selectOwnedCompatibleChannel
(
channelRecords
,
input
.
UserID
)
if
channel
==
nil
&&
hasCompatibleChannelConflict
(
channelRecords
,
input
.
UserID
)
{
return
ErrAuthIdentityChannelOwnershipConflict
return
ErrAuthIdentityChannelOwnershipConflict
}
}
if
channel
==
nil
{
if
channel
==
nil
{
...
@@ -397,6 +399,61 @@ func (r *userRepository) BindAuthIdentityToUser(ctx context.Context, input BindA
...
@@ -397,6 +399,61 @@ func (r *userRepository) BindAuthIdentityToUser(ctx context.Context, input BindA
return
result
,
nil
return
result
,
nil
}
}
func
compatibleIdentityProviderKeys
(
providerType
,
providerKey
string
)
[]
string
{
providerType
=
strings
.
TrimSpace
(
strings
.
ToLower
(
providerType
))
providerKey
=
strings
.
TrimSpace
(
providerKey
)
if
providerKey
==
""
{
return
[]
string
{
providerKey
}
}
if
providerType
!=
"wechat"
{
return
[]
string
{
providerKey
}
}
keys
:=
[]
string
{
providerKey
}
if
!
strings
.
EqualFold
(
providerKey
,
"wechat-main"
)
{
keys
=
append
(
keys
,
"wechat-main"
)
}
if
!
strings
.
EqualFold
(
providerKey
,
"wechat"
)
{
keys
=
append
(
keys
,
"wechat"
)
}
return
keys
}
func
selectOwnedCompatibleIdentity
(
records
[]
*
dbent
.
AuthIdentity
,
userID
int64
)
*
dbent
.
AuthIdentity
{
for
_
,
record
:=
range
records
{
if
record
.
UserID
==
userID
{
return
record
}
}
return
nil
}
func
hasCompatibleIdentityConflict
(
records
[]
*
dbent
.
AuthIdentity
,
userID
int64
)
bool
{
for
_
,
record
:=
range
records
{
if
record
.
UserID
!=
userID
{
return
true
}
}
return
false
}
func
selectOwnedCompatibleChannel
(
records
[]
*
dbent
.
AuthIdentityChannel
,
userID
int64
)
*
dbent
.
AuthIdentityChannel
{
for
_
,
record
:=
range
records
{
if
record
.
Edges
.
Identity
!=
nil
&&
record
.
Edges
.
Identity
.
UserID
==
userID
{
return
record
}
}
return
nil
}
func
hasCompatibleChannelConflict
(
records
[]
*
dbent
.
AuthIdentityChannel
,
userID
int64
)
bool
{
for
_
,
record
:=
range
records
{
if
record
.
Edges
.
Identity
!=
nil
&&
record
.
Edges
.
Identity
.
UserID
!=
userID
{
return
true
}
}
return
false
}
func
(
r
*
userRepository
)
RecordProviderGrant
(
ctx
context
.
Context
,
input
ProviderGrantRecordInput
)
(
bool
,
error
)
{
func
(
r
*
userRepository
)
RecordProviderGrant
(
ctx
context
.
Context
,
input
ProviderGrantRecordInput
)
(
bool
,
error
)
{
exec
:=
txAwareSQLExecutor
(
ctx
,
r
.
sql
,
r
.
client
)
exec
:=
txAwareSQLExecutor
(
ctx
,
r
.
sql
,
r
.
client
)
if
exec
==
nil
{
if
exec
==
nil
{
...
...
backend/internal/repository/user_profile_identity_repo_contract_test.go
View file @
81c827ee
...
@@ -186,6 +186,79 @@ func (s *UserProfileIdentityRepoSuite) TestBindAuthIdentityToUser_IsIdempotentAn
...
@@ -186,6 +186,79 @@ func (s *UserProfileIdentityRepoSuite) TestBindAuthIdentityToUser_IsIdempotentAn
s
.
Require
()
.
ErrorIs
(
err
,
ErrAuthIdentityChannelOwnershipConflict
)
s
.
Require
()
.
ErrorIs
(
err
,
ErrAuthIdentityChannelOwnershipConflict
)
}
}
func
(
s
*
UserProfileIdentityRepoSuite
)
TestBindAuthIdentityToUser_ReusesLegacyWeChatAliasRecords
()
{
user
:=
s
.
mustCreateUser
(
"wechat-legacy-alias"
)
legacyIdentity
,
err
:=
s
.
client
.
AuthIdentity
.
Create
()
.
SetUserID
(
user
.
ID
)
.
SetProviderType
(
"wechat"
)
.
SetProviderKey
(
"wechat"
)
.
SetProviderSubject
(
"union-legacy-123"
)
.
SetMetadata
(
map
[
string
]
any
{
"source"
:
"legacy-alias"
})
.
Save
(
s
.
ctx
)
s
.
Require
()
.
NoError
(
err
)
legacyChannel
,
err
:=
s
.
client
.
AuthIdentityChannel
.
Create
()
.
SetIdentityID
(
legacyIdentity
.
ID
)
.
SetProviderType
(
"wechat"
)
.
SetProviderKey
(
"wechat"
)
.
SetChannel
(
"oa"
)
.
SetChannelAppID
(
"wx-app-legacy"
)
.
SetChannelSubject
(
"openid-legacy-123"
)
.
SetMetadata
(
map
[
string
]
any
{
"scene"
:
"legacy-alias"
})
.
Save
(
s
.
ctx
)
s
.
Require
()
.
NoError
(
err
)
bound
,
err
:=
s
.
repo
.
BindAuthIdentityToUser
(
s
.
ctx
,
BindAuthIdentityInput
{
UserID
:
user
.
ID
,
Canonical
:
AuthIdentityKey
{
ProviderType
:
"wechat"
,
ProviderKey
:
"wechat-main"
,
ProviderSubject
:
"union-legacy-123"
,
},
Channel
:
&
AuthIdentityChannelKey
{
ProviderType
:
"wechat"
,
ProviderKey
:
"wechat-main"
,
Channel
:
"oa"
,
ChannelAppID
:
"wx-app-legacy"
,
ChannelSubject
:
"openid-legacy-123"
,
},
Metadata
:
map
[
string
]
any
{
"source"
:
"canonical-bind"
},
ChannelMetadata
:
map
[
string
]
any
{
"scene"
:
"canonical-bind"
},
})
s
.
Require
()
.
NoError
(
err
)
s
.
Require
()
.
NotNil
(
bound
)
s
.
Require
()
.
NotNil
(
bound
.
Identity
)
s
.
Require
()
.
NotNil
(
bound
.
Channel
)
s
.
Require
()
.
Equal
(
legacyIdentity
.
ID
,
bound
.
Identity
.
ID
)
s
.
Require
()
.
Equal
(
legacyChannel
.
ID
,
bound
.
Channel
.
ID
)
s
.
Require
()
.
Equal
(
"wechat-main"
,
bound
.
Identity
.
ProviderKey
)
s
.
Require
()
.
Equal
(
"wechat-main"
,
bound
.
Channel
.
ProviderKey
)
s
.
Require
()
.
Equal
(
"canonical-bind"
,
bound
.
Identity
.
Metadata
[
"source"
])
s
.
Require
()
.
Equal
(
"canonical-bind"
,
bound
.
Channel
.
Metadata
[
"scene"
])
identityCount
,
err
:=
s
.
client
.
AuthIdentity
.
Query
()
.
Where
(
authidentity
.
UserIDEQ
(
user
.
ID
),
authidentity
.
ProviderTypeEQ
(
"wechat"
),
authidentity
.
ProviderSubjectEQ
(
"union-legacy-123"
),
)
.
Count
(
s
.
ctx
)
s
.
Require
()
.
NoError
(
err
)
s
.
Require
()
.
Equal
(
1
,
identityCount
)
channelCount
,
err
:=
s
.
client
.
AuthIdentityChannel
.
Query
()
.
Where
(
authidentitychannel
.
ProviderTypeEQ
(
"wechat"
),
authidentitychannel
.
ChannelEQ
(
"oa"
),
authidentitychannel
.
ChannelAppIDEQ
(
"wx-app-legacy"
),
authidentitychannel
.
ChannelSubjectEQ
(
"openid-legacy-123"
),
)
.
Count
(
s
.
ctx
)
s
.
Require
()
.
NoError
(
err
)
s
.
Require
()
.
Equal
(
1
,
channelCount
)
}
func
(
s
*
UserProfileIdentityRepoSuite
)
TestCreateAuthIdentity_RejectsChannelProviderMismatch
()
{
func
(
s
*
UserProfileIdentityRepoSuite
)
TestCreateAuthIdentity_RejectsChannelProviderMismatch
()
{
user
:=
s
.
mustCreateUser
(
"provider-mismatch-create"
)
user
:=
s
.
mustCreateUser
(
"provider-mismatch-create"
)
...
...
backend/internal/repository/user_repo.go
View file @
81c827ee
...
@@ -43,6 +43,9 @@ func (r *userRepository) Create(ctx context.Context, userIn *service.User) error
...
@@ -43,6 +43,9 @@ func (r *userRepository) Create(ctx context.Context, userIn *service.User) error
if
userIn
==
nil
{
if
userIn
==
nil
{
return
nil
return
nil
}
}
if
err
:=
r
.
ensureNormalizedEmailAvailable
(
ctx
,
0
,
userIn
.
Email
);
err
!=
nil
{
return
err
}
// 统一使用 ent 的事务:保证用户与允许分组的更新原子化,
// 统一使用 ent 的事务:保证用户与允许分组的更新原子化,
// 并避免基于 *sql.Tx 手动构造 ent client 导致的 ExecQuerier 断言错误。
// 并避免基于 *sql.Tx 手动构造 ent client 导致的 ExecQuerier 断言错误。
...
@@ -146,6 +149,9 @@ func (r *userRepository) Update(ctx context.Context, userIn *service.User) error
...
@@ -146,6 +149,9 @@ func (r *userRepository) Update(ctx context.Context, userIn *service.User) error
if
userIn
==
nil
{
if
userIn
==
nil
{
return
nil
return
nil
}
}
if
err
:=
r
.
ensureNormalizedEmailAvailable
(
ctx
,
userIn
.
ID
,
userIn
.
Email
);
err
!=
nil
{
return
err
}
// 使用 ent 事务包裹用户更新与 allowed_groups 同步,避免跨层事务不一致。
// 使用 ent 事务包裹用户更新与 allowed_groups 同步,避免跨层事务不一致。
tx
,
err
:=
r
.
client
.
Tx
(
ctx
)
tx
,
err
:=
r
.
client
.
Tx
(
ctx
)
...
@@ -704,6 +710,21 @@ func (r *userRepository) ExistsByEmail(ctx context.Context, email string) (bool,
...
@@ -704,6 +710,21 @@ func (r *userRepository) ExistsByEmail(ctx context.Context, email string) (bool,
return
r
.
client
.
User
.
Query
()
.
Where
(
userEmailLookupPredicate
(
email
))
.
Exist
(
ctx
)
return
r
.
client
.
User
.
Query
()
.
Where
(
userEmailLookupPredicate
(
email
))
.
Exist
(
ctx
)
}
}
func
(
r
*
userRepository
)
ensureNormalizedEmailAvailable
(
ctx
context
.
Context
,
userID
int64
,
email
string
)
error
{
matches
,
err
:=
r
.
client
.
User
.
Query
()
.
Where
(
userEmailLookupPredicate
(
email
))
.
All
(
ctx
)
if
err
!=
nil
{
return
err
}
for
_
,
match
:=
range
matches
{
if
match
.
ID
!=
userID
{
return
service
.
ErrEmailExists
}
}
return
nil
}
func
userEmailLookupPredicate
(
email
string
)
predicate
.
User
{
func
userEmailLookupPredicate
(
email
string
)
predicate
.
User
{
normalized
:=
strings
.
ToLower
(
strings
.
TrimSpace
(
email
))
normalized
:=
strings
.
ToLower
(
strings
.
TrimSpace
(
email
))
if
normalized
==
""
{
if
normalized
==
""
{
...
...
backend/internal/repository/user_repo_email_lookup_unit_test.go
View file @
81c827ee
...
@@ -67,3 +67,80 @@ func TestUserRepositoryExistsByEmailNormalizesLegacySpacingAndCase(t *testing.T)
...
@@ -67,3 +67,80 @@ func TestUserRepositoryExistsByEmailNormalizesLegacySpacingAndCase(t *testing.T)
require
.
NoError
(
t
,
err
)
require
.
NoError
(
t
,
err
)
require
.
True
(
t
,
exists
)
require
.
True
(
t
,
exists
)
}
}
func
TestUserRepositoryCreateRejectsNormalizedEmailDuplicate
(
t
*
testing
.
T
)
{
repo
,
_
:=
newUserEntRepo
(
t
)
ctx
:=
context
.
Background
()
err
:=
repo
.
Create
(
ctx
,
&
service
.
User
{
Email
:
" Existing@Example.com "
,
Username
:
"existing-user"
,
PasswordHash
:
"hash"
,
Role
:
service
.
RoleUser
,
Status
:
service
.
StatusActive
,
})
require
.
NoError
(
t
,
err
)
err
=
repo
.
Create
(
ctx
,
&
service
.
User
{
Email
:
"existing@example.com"
,
Username
:
"duplicate-user"
,
PasswordHash
:
"hash"
,
Role
:
service
.
RoleUser
,
Status
:
service
.
StatusActive
,
})
require
.
ErrorIs
(
t
,
err
,
service
.
ErrEmailExists
)
}
func
TestUserRepositoryUpdateRejectsNormalizedEmailDuplicate
(
t
*
testing
.
T
)
{
repo
,
_
:=
newUserEntRepo
(
t
)
ctx
:=
context
.
Background
()
first
:=
&
service
.
User
{
Email
:
" Existing@Example.com "
,
Username
:
"existing-user"
,
PasswordHash
:
"hash"
,
Role
:
service
.
RoleUser
,
Status
:
service
.
StatusActive
,
}
require
.
NoError
(
t
,
repo
.
Create
(
ctx
,
first
))
second
:=
&
service
.
User
{
Email
:
"second@example.com"
,
Username
:
"second-user"
,
PasswordHash
:
"hash"
,
Role
:
service
.
RoleUser
,
Status
:
service
.
StatusActive
,
}
require
.
NoError
(
t
,
repo
.
Create
(
ctx
,
second
))
second
.
Email
=
" existing@example.com "
err
:=
repo
.
Update
(
ctx
,
second
)
require
.
ErrorIs
(
t
,
err
,
service
.
ErrEmailExists
)
}
func
TestUserRepositoryGetByEmailReportsNormalizedEmailConflict
(
t
*
testing
.
T
)
{
repo
,
client
:=
newUserEntRepo
(
t
)
ctx
:=
context
.
Background
()
_
,
err
:=
client
.
User
.
Create
()
.
SetEmail
(
"Conflict@Example.com"
)
.
SetUsername
(
"conflict-user-1"
)
.
SetPasswordHash
(
"hash"
)
.
SetRole
(
service
.
RoleUser
)
.
SetStatus
(
service
.
StatusActive
)
.
Save
(
ctx
)
require
.
NoError
(
t
,
err
)
_
,
err
=
client
.
User
.
Create
()
.
SetEmail
(
" conflict@example.com "
)
.
SetUsername
(
"conflict-user-2"
)
.
SetPasswordHash
(
"hash"
)
.
SetRole
(
service
.
RoleUser
)
.
SetStatus
(
service
.
StatusActive
)
.
Save
(
ctx
)
require
.
NoError
(
t
,
err
)
_
,
err
=
repo
.
GetByEmail
(
ctx
,
"conflict@example.com"
)
require
.
Error
(
t
,
err
)
require
.
ErrorContains
(
t
,
err
,
"normalized email lookup matched multiple users"
)
}
backend/internal/service/admin_service_auth_identity_binding_test.go
View file @
81c827ee
...
@@ -188,6 +188,93 @@ func TestAdminServiceBindUserAuthIdentityIsIdempotentForSameUser(t *testing.T) {
...
@@ -188,6 +188,93 @@ func TestAdminServiceBindUserAuthIdentityIsIdempotentForSameUser(t *testing.T) {
require
.
Equal
(
t
,
"second"
,
identities
[
0
]
.
Metadata
[
"source"
])
require
.
Equal
(
t
,
"second"
,
identities
[
0
]
.
Metadata
[
"source"
])
}
}
func
TestAdminServiceBindUserAuthIdentityReusesLegacyWeChatAliasRecords
(
t
*
testing
.
T
)
{
client
:=
newAdminServiceAuthIdentityBindingTestClient
(
t
)
ctx
:=
context
.
Background
()
user
,
err
:=
client
.
User
.
Create
()
.
SetEmail
(
"wechat-alias@example.com"
)
.
SetPasswordHash
(
"hash"
)
.
SetRole
(
RoleUser
)
.
SetStatus
(
StatusActive
)
.
Save
(
ctx
)
require
.
NoError
(
t
,
err
)
legacyIdentity
,
err
:=
client
.
AuthIdentity
.
Create
()
.
SetUserID
(
user
.
ID
)
.
SetProviderType
(
"wechat"
)
.
SetProviderKey
(
"wechat"
)
.
SetProviderSubject
(
"union-legacy-123"
)
.
SetMetadata
(
map
[
string
]
any
{
"source"
:
"legacy"
})
.
Save
(
ctx
)
require
.
NoError
(
t
,
err
)
legacyChannel
,
err
:=
client
.
AuthIdentityChannel
.
Create
()
.
SetIdentityID
(
legacyIdentity
.
ID
)
.
SetProviderType
(
"wechat"
)
.
SetProviderKey
(
"wechat"
)
.
SetChannel
(
"open"
)
.
SetChannelAppID
(
"wx-open"
)
.
SetChannelSubject
(
"openid-legacy-123"
)
.
SetMetadata
(
map
[
string
]
any
{
"scene"
:
"legacy"
})
.
Save
(
ctx
)
require
.
NoError
(
t
,
err
)
svc
:=
&
adminServiceImpl
{
userRepo
:
&
userRepoStub
{
user
:
&
User
{
ID
:
user
.
ID
,
Email
:
user
.
Email
,
Status
:
StatusActive
}},
entClient
:
client
,
}
result
,
err
:=
svc
.
BindUserAuthIdentity
(
ctx
,
user
.
ID
,
AdminBindAuthIdentityInput
{
ProviderType
:
"wechat"
,
ProviderKey
:
"wechat-main"
,
ProviderSubject
:
"union-legacy-123"
,
Metadata
:
map
[
string
]
any
{
"source"
:
"admin-repair"
},
Channel
:
&
AdminBindAuthIdentityChannelInput
{
Channel
:
"open"
,
ChannelAppID
:
"wx-open"
,
ChannelSubject
:
"openid-legacy-123"
,
Metadata
:
map
[
string
]
any
{
"scene"
:
"admin-repair"
},
},
})
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
result
)
require
.
Equal
(
t
,
"wechat-main"
,
result
.
ProviderKey
)
require
.
NotNil
(
t
,
result
.
Channel
)
require
.
Equal
(
t
,
"open"
,
result
.
Channel
.
Channel
)
identity
,
err
:=
client
.
AuthIdentity
.
Get
(
ctx
,
legacyIdentity
.
ID
)
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
"wechat-main"
,
identity
.
ProviderKey
)
require
.
Equal
(
t
,
"admin-repair"
,
identity
.
Metadata
[
"source"
])
channel
,
err
:=
client
.
AuthIdentityChannel
.
Get
(
ctx
,
legacyChannel
.
ID
)
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
"wechat-main"
,
channel
.
ProviderKey
)
require
.
Equal
(
t
,
legacyIdentity
.
ID
,
channel
.
IdentityID
)
require
.
Equal
(
t
,
"admin-repair"
,
channel
.
Metadata
[
"scene"
])
identityCount
,
err
:=
client
.
AuthIdentity
.
Query
()
.
Where
(
authidentity
.
ProviderTypeEQ
(
"wechat"
),
authidentity
.
ProviderSubjectEQ
(
"union-legacy-123"
),
)
.
Count
(
ctx
)
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
1
,
identityCount
)
channelCount
,
err
:=
client
.
AuthIdentityChannel
.
Query
()
.
Where
(
authidentitychannel
.
ProviderTypeEQ
(
"wechat"
),
authidentitychannel
.
ChannelEQ
(
"open"
),
authidentitychannel
.
ChannelAppIDEQ
(
"wx-open"
),
authidentitychannel
.
ChannelSubjectEQ
(
"openid-legacy-123"
),
)
.
Count
(
ctx
)
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
1
,
channelCount
)
}
func
TestAdminServiceBindUserAuthIdentityRejectsInvalidProviderType
(
t
*
testing
.
T
)
{
func
TestAdminServiceBindUserAuthIdentityRejectsInvalidProviderType
(
t
*
testing
.
T
)
{
client
:=
newAdminServiceAuthIdentityBindingTestClient
(
t
)
client
:=
newAdminServiceAuthIdentityBindingTestClient
(
t
)
ctx
:=
context
.
Background
()
ctx
:=
context
.
Background
()
...
...
backend/internal/service/user_service_test.go
View file @
81c827ee
...
@@ -406,13 +406,15 @@ func TestUnbindUserAuthProviderRemovesProviderAndReturnsUpdatedProfile(t *testin
...
@@ -406,13 +406,15 @@ func TestUnbindUserAuthProviderRemovesProviderAndReturnsUpdatedProfile(t *testin
},
},
},
},
}
}
svc
:=
NewUserService
(
repo
,
nil
,
nil
,
nil
)
invalidator
:=
&
mockAuthCacheInvalidator
{}
svc
:=
NewUserService
(
repo
,
nil
,
invalidator
,
nil
)
user
,
err
:=
svc
.
UnbindUserAuthProvider
(
context
.
Background
(),
12
,
"linuxdo"
)
user
,
err
:=
svc
.
UnbindUserAuthProvider
(
context
.
Background
(),
12
,
"linuxdo"
)
require
.
NoError
(
t
,
err
)
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
[]
string
{
"linuxdo"
},
repo
.
unboundProviders
)
require
.
Equal
(
t
,
[]
string
{
"linuxdo"
},
repo
.
unboundProviders
)
require
.
Equal
(
t
,
int64
(
12
),
user
.
ID
)
require
.
Equal
(
t
,
int64
(
12
),
user
.
ID
)
require
.
Equal
(
t
,
[]
int64
{
12
},
invalidator
.
invalidatedUserIDs
)
summaries
,
err
:=
svc
.
GetProfileIdentitySummaries
(
context
.
Background
(),
12
,
user
)
summaries
,
err
:=
svc
.
GetProfileIdentitySummaries
(
context
.
Background
(),
12
,
user
)
require
.
NoError
(
t
,
err
)
require
.
NoError
(
t
,
err
)
...
...
frontend/src/components/user/profile/ProfileIdentityBindingsSection.vue
View file @
81c827ee
...
@@ -444,7 +444,14 @@ function providerIconClass(provider: UserAuthProvider): string {
...
@@ -444,7 +444,14 @@ function providerIconClass(provider: UserAuthProvider): string {
function
providerSummary
(
provider
:
UserAuthProvider
):
string
{
function
providerSummary
(
provider
:
UserAuthProvider
):
string
{
if
(
provider
===
'
email
'
)
{
if
(
provider
===
'
email
'
)
{
return
currentUser
.
value
?.
email
||
''
const
email
=
currentUser
.
value
?.
email
?.
trim
()
||
''
if
(
!
email
)
{
return
''
}
if
(
currentUser
.
value
?.
email_bound
===
false
&&
email
.
endsWith
(
'
.invalid
'
))
{
return
''
}
return
email
}
}
return
''
return
''
}
}
...
...
frontend/src/components/user/profile/ProfileInfoCard.vue
View file @
81c827ee
...
@@ -40,7 +40,7 @@
...
@@ -40,7 +40,7 @@
<div
class=
"space-y-1"
>
<div
class=
"space-y-1"
>
<p
class=
"truncate text-sm text-gray-600 dark:text-gray-300"
>
<p
class=
"truncate text-sm text-gray-600 dark:text-gray-300"
>
{{
user
?.
email
}}
{{
primaryEmailDisplay
}}
</p>
</p>
<div
<div
v-if=
"sourceHints.length"
v-if=
"sourceHints.length"
...
@@ -208,6 +208,16 @@ const { t } = useI18n()
...
@@ -208,6 +208,16 @@ const { t } = useI18n()
const
avatarUrl
=
computed
(()
=>
props
.
user
?.
avatar_url
?.
trim
()
||
''
)
const
avatarUrl
=
computed
(()
=>
props
.
user
?.
avatar_url
?.
trim
()
||
''
)
const
displayName
=
computed
(()
=>
props
.
user
?.
username
?.
trim
()
||
props
.
user
?.
email
?.
trim
()
||
t
(
'
profile.user
'
))
const
displayName
=
computed
(()
=>
props
.
user
?.
username
?.
trim
()
||
props
.
user
?.
email
?.
trim
()
||
t
(
'
profile.user
'
))
const
primaryEmailDisplay
=
computed
(()
=>
{
const
email
=
props
.
user
?.
email
?.
trim
()
||
''
if
(
!
email
)
{
return
''
}
if
(
props
.
user
?.
email_bound
===
false
&&
email
.
endsWith
(
'
.invalid
'
))
{
return
''
}
return
email
})
const
avatarInitial
=
computed
(()
=>
displayName
.
value
.
charAt
(
0
).
toUpperCase
()
||
'
U
'
)
const
avatarInitial
=
computed
(()
=>
displayName
.
value
.
charAt
(
0
).
toUpperCase
()
||
'
U
'
)
const
memberSinceLabel
=
computed
(()
=>
{
const
memberSinceLabel
=
computed
(()
=>
{
const
raw
=
props
.
user
?.
created_at
?.
trim
()
const
raw
=
props
.
user
?.
created_at
?.
trim
()
...
@@ -229,7 +239,7 @@ const memberSinceLabel = computed(() => {
...
@@ -229,7 +239,7 @@ const memberSinceLabel = computed(() => {
const
providerLabels
=
computed
<
Record
<
UserAuthProvider
,
string
>>
(()
=>
({
const
providerLabels
=
computed
<
Record
<
UserAuthProvider
,
string
>>
(()
=>
({
email
:
t
(
'
profile.authBindings.providers.email
'
),
email
:
t
(
'
profile.authBindings.providers.email
'
),
linuxdo
:
t
(
'
profile.authBindings.providers.linuxdo
'
),
linuxdo
:
t
(
'
profile.authBindings.providers.linuxdo
'
),
oidc
:
t
(
'
profile.authBindings.providers.oidc
'
,
{
providerName
:
'
OIDC
'
}),
oidc
:
t
(
'
profile.authBindings.providers.oidc
'
,
{
providerName
:
props
.
oidcProviderName
}),
wechat
:
t
(
'
profile.authBindings.providers.wechat
'
)
wechat
:
t
(
'
profile.authBindings.providers.wechat
'
)
}))
}))
...
...
frontend/src/components/user/profile/__tests__/ProfileIdentityBindingsSection.spec.ts
View file @
81c827ee
...
@@ -335,6 +335,29 @@ describe('ProfileIdentityBindingsSection', () => {
...
@@ -335,6 +335,29 @@ describe('ProfileIdentityBindingsSection', () => {
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
(
'
does not show a synthetic oauth-only email as the bound email summary
'
,
()
=>
{
const
wrapper
=
mount
(
ProfileIdentityBindingsSection
,
{
global
:
{
plugins
:
[
pinia
],
},
props
:
{
user
:
createUser
({
email
:
'
legacy-user@linuxdo-connect.invalid
'
,
email_bound
:
false
,
auth_bindings
:
{
email
:
{
bound
:
false
},
},
}),
linuxdoEnabled
:
false
,
oidcEnabled
:
false
,
wechatEnabled
:
false
,
},
})
expect
(
wrapper
.
text
()).
not
.
toContain
(
'
legacy-user@linuxdo-connect.invalid
'
)
expect
(
wrapper
.
get
(
'
[data-testid="profile-binding-email-status"]
'
).
text
()).
toBe
(
'
Not bound
'
)
})
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
(
...
...
frontend/src/components/user/profile/__tests__/ProfileInfoCard.spec.ts
View file @
81c827ee
...
@@ -111,6 +111,47 @@ describe('ProfileInfoCard', () => {
...
@@ -111,6 +111,47 @@ describe('ProfileInfoCard', () => {
expect
(
wrapper
.
text
()).
toContain
(
'
Username synced from LinuxDo
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
Username synced from LinuxDo
'
)
})
})
it
(
'
uses the configured OIDC provider name in source hints
'
,
()
=>
{
const
wrapper
=
mount
(
ProfileInfoCard
,
{
props
:
{
user
:
createUser
({
profile_sources
:
{
username
:
{
provider
:
'
oidc
'
,
source
:
'
oidc
'
}
}
}),
oidcProviderName
:
'
ExampleID
'
},
global
:
{
stubs
:
{
Icon
:
true
}
}
})
expect
(
wrapper
.
text
()).
toContain
(
'
Username synced from ExampleID
'
)
})
it
(
'
does not display synthetic oauth-only emails as a real bound email
'
,
()
=>
{
const
wrapper
=
mount
(
ProfileInfoCard
,
{
props
:
{
user
:
createUser
({
email
:
'
legacy-user@oidc-connect.invalid
'
,
email_bound
:
false
,
auth_bindings
:
{
email
:
{
bound
:
false
}
}
})
},
global
:
{
stubs
:
{
Icon
:
true
}
}
})
expect
(
wrapper
.
text
()).
not
.
toContain
(
'
legacy-user@oidc-connect.invalid
'
)
})
it
(
'
renders the approved overview hero and two-column content shell
'
,
()
=>
{
it
(
'
renders the approved overview hero and two-column content shell
'
,
()
=>
{
const
wrapper
=
mount
(
ProfileInfoCard
,
{
const
wrapper
=
mount
(
ProfileInfoCard
,
{
props
:
{
props
:
{
...
...
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