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
92041457
Commit
92041457
authored
Apr 21, 2026
by
IanShaw027
Browse files
Close profile identity and avatar loop
parent
f73117f9
Changes
14
Hide whitespace changes
Inline
Side-by-side
backend/internal/handler/auth_handler.go
View file @
92041457
...
...
@@ -296,6 +296,7 @@ func (h *AuthHandler) Login2FA(c *gin.Context) {
c
.
Request
.
Context
(),
h
.
entClient
(),
h
.
authService
,
h
.
userService
,
pendingSession
,
decision
,
&
user
.
ID
,
...
...
backend/internal/handler/auth_linuxdo_oauth.go
View file @
92041457
...
...
@@ -495,7 +495,7 @@ func (h *AuthHandler) CompleteLinuxDoOAuthRegistration(c *gin.Context) {
response
.
ErrorFrom
(
c
,
err
)
return
}
if
err
:=
applyPendingOAuthAdoption
(
c
.
Request
.
Context
(),
h
.
entClient
(),
h
.
authService
,
session
,
decision
,
&
user
.
ID
);
err
!=
nil
{
if
err
:=
applyPendingOAuthAdoption
(
c
.
Request
.
Context
(),
h
.
entClient
(),
h
.
authService
,
h
.
userService
,
session
,
decision
,
&
user
.
ID
);
err
!=
nil
{
response
.
ErrorFrom
(
c
,
infraerrors
.
InternalServer
(
"PENDING_AUTH_ADOPTION_APPLY_FAILED"
,
"failed to apply oauth profile adoption"
)
.
WithCause
(
err
))
return
}
...
...
backend/internal/handler/auth_oauth_pending_flow.go
View file @
92041457
...
...
@@ -852,6 +852,7 @@ func applyPendingOAuthBinding(
ctx
context
.
Context
,
client
*
dbent
.
Client
,
authService
*
service
.
AuthService
,
userService
*
service
.
UserService
,
session
*
dbent
.
PendingAuthSession
,
decision
*
dbent
.
IdentityAdoptionDecision
,
overrideUserID
*
int64
,
...
...
@@ -938,6 +939,12 @@ func applyPendingOAuthBinding(
}
}
if
decision
!=
nil
&&
decision
.
AdoptAvatar
&&
adoptedAvatarURL
!=
""
&&
userService
!=
nil
{
if
_
,
err
:=
userService
.
SetAvatar
(
txCtx
,
targetUserID
,
adoptedAvatarURL
);
err
!=
nil
{
return
err
}
}
return
tx
.
Commit
()
}
...
...
@@ -945,6 +952,7 @@ func applyPendingOAuthAdoption(
ctx
context
.
Context
,
client
*
dbent
.
Client
,
authService
*
service
.
AuthService
,
userService
*
service
.
UserService
,
session
*
dbent
.
PendingAuthSession
,
decision
*
dbent
.
IdentityAdoptionDecision
,
overrideUserID
*
int64
,
...
...
@@ -953,6 +961,7 @@ func applyPendingOAuthAdoption(
ctx
,
client
,
authService
,
userService
,
session
,
decision
,
overrideUserID
,
...
...
@@ -1092,7 +1101,7 @@ func (h *AuthHandler) bindPendingOAuthLogin(c *gin.Context, provider string) {
})
return
}
if
err
:=
applyPendingOAuthBinding
(
c
.
Request
.
Context
(),
h
.
entClient
(),
h
.
authService
,
session
,
decision
,
&
user
.
ID
,
true
,
true
);
err
!=
nil
{
if
err
:=
applyPendingOAuthBinding
(
c
.
Request
.
Context
(),
h
.
entClient
(),
h
.
authService
,
h
.
userService
,
session
,
decision
,
&
user
.
ID
,
true
,
true
);
err
!=
nil
{
response
.
ErrorFrom
(
c
,
infraerrors
.
InternalServer
(
"PENDING_AUTH_BIND_APPLY_FAILED"
,
"failed to bind pending oauth identity"
)
.
WithCause
(
err
))
return
}
...
...
@@ -1188,7 +1197,7 @@ func (h *AuthHandler) createPendingOAuthAccount(c *gin.Context, provider string)
response
.
ErrorFrom
(
c
,
err
)
return
}
if
err
:=
applyPendingOAuthBinding
(
c
.
Request
.
Context
(),
client
,
h
.
authService
,
session
,
decision
,
&
user
.
ID
,
true
,
false
);
err
!=
nil
{
if
err
:=
applyPendingOAuthBinding
(
c
.
Request
.
Context
(),
client
,
h
.
authService
,
h
.
userService
,
session
,
decision
,
&
user
.
ID
,
true
,
false
);
err
!=
nil
{
response
.
ErrorFrom
(
c
,
infraerrors
.
InternalServer
(
"PENDING_AUTH_BIND_APPLY_FAILED"
,
"failed to bind pending oauth identity"
)
.
WithCause
(
err
))
return
}
...
...
@@ -1278,7 +1287,7 @@ func (h *AuthHandler) ExchangePendingOAuthCompletion(c *gin.Context) {
response
.
ErrorFrom
(
c
,
err
)
return
}
if
err
:=
applyPendingOAuthAdoption
(
c
.
Request
.
Context
(),
h
.
entClient
(),
h
.
authService
,
session
,
decision
,
session
.
TargetUserID
);
err
!=
nil
{
if
err
:=
applyPendingOAuthAdoption
(
c
.
Request
.
Context
(),
h
.
entClient
(),
h
.
authService
,
h
.
userService
,
session
,
decision
,
session
.
TargetUserID
);
err
!=
nil
{
response
.
ErrorFrom
(
c
,
infraerrors
.
InternalServer
(
"PENDING_AUTH_ADOPTION_APPLY_FAILED"
,
"failed to apply oauth profile adoption"
)
.
WithCause
(
err
))
return
}
...
...
backend/internal/handler/auth_oauth_pending_flow_test.go
View file @
92041457
...
...
@@ -152,6 +152,11 @@ func TestExchangePendingOAuthCompletionPreviewThenFinalizeAppliesAdoptionDecisio
require
.
Equal
(
t
,
"Alice Example"
,
identity
.
Metadata
[
"display_name"
])
require
.
Equal
(
t
,
"https://cdn.example/alice.png"
,
identity
.
Metadata
[
"avatar_url"
])
avatar
:=
loadUserAvatarRecord
(
t
,
client
,
userEntity
.
ID
)
require
.
NotNil
(
t
,
avatar
)
require
.
Equal
(
t
,
"remote_url"
,
avatar
.
StorageProvider
)
require
.
Equal
(
t
,
"https://cdn.example/alice.png"
,
avatar
.
URL
)
decision
,
err
:=
client
.
IdentityAdoptionDecision
.
Query
()
.
Where
(
identityadoptiondecision
.
PendingAuthSessionIDEQ
(
session
.
ID
))
.
Only
(
ctx
)
...
...
@@ -1242,6 +1247,18 @@ CREATE TABLE IF NOT EXISTS user_provider_default_grants (
UNIQUE(user_id, provider_type, grant_reason)
)`
)
require
.
NoError
(
t
,
err
)
_
,
err
=
db
.
Exec
(
`
CREATE TABLE IF NOT EXISTS user_avatars (
user_id INTEGER PRIMARY KEY,
storage_provider TEXT NOT NULL,
storage_key TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL,
content_type TEXT NOT NULL DEFAULT '',
byte_size INTEGER NOT NULL DEFAULT 0,
sha256 TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)`
)
require
.
NoError
(
t
,
err
)
drv
:=
entsql
.
OpenDB
(
dialect
.
SQLite
,
db
)
client
:=
enttest
.
NewClient
(
t
,
enttest
.
WithOptions
(
dbent
.
Driver
(
drv
)))
...
...
@@ -1492,6 +1509,35 @@ func decodeJSONBody(t *testing.T, recorder *httptest.ResponseRecorder) map[strin
return
payload
}
type
oauthPendingFlowAvatarRecord
struct
{
StorageProvider
string
URL
string
}
func
loadUserAvatarRecord
(
t
*
testing
.
T
,
client
*
dbent
.
Client
,
userID
int64
)
*
oauthPendingFlowAvatarRecord
{
t
.
Helper
()
var
rows
entsql
.
Rows
err
:=
client
.
Driver
()
.
Query
(
context
.
Background
(),
`SELECT storage_provider, url FROM user_avatars WHERE user_id = ?`
,
[]
any
{
userID
},
&
rows
,
)
require
.
NoError
(
t
,
err
)
defer
rows
.
Close
()
if
!
rows
.
Next
()
{
require
.
NoError
(
t
,
rows
.
Err
())
return
nil
}
var
record
oauthPendingFlowAvatarRecord
require
.
NoError
(
t
,
rows
.
Scan
(
&
record
.
StorageProvider
,
&
record
.
URL
))
require
.
NoError
(
t
,
rows
.
Err
())
return
&
record
}
func
countProviderGrantRecords
(
t
*
testing
.
T
,
client
*
dbent
.
Client
,
...
...
@@ -1604,16 +1650,95 @@ func (r *oauthPendingFlowUserRepo) Delete(ctx context.Context, id int64) error {
return
r
.
client
.
User
.
DeleteOneID
(
id
)
.
Exec
(
ctx
)
}
func
(
r
*
oauthPendingFlowUserRepo
)
GetUserAvatar
(
context
.
Context
,
int64
)
(
*
service
.
UserAvatar
,
error
)
{
return
nil
,
nil
func
(
r
*
oauthPendingFlowUserRepo
)
GetUserAvatar
(
ctx
context
.
Context
,
userID
int64
)
(
*
service
.
UserAvatar
,
error
)
{
driver
:=
r
.
client
.
Driver
()
if
tx
:=
dbent
.
TxFromContext
(
ctx
);
tx
!=
nil
{
driver
=
tx
.
Client
()
.
Driver
()
}
var
rows
entsql
.
Rows
if
err
:=
driver
.
Query
(
ctx
,
`SELECT storage_provider, storage_key, url, content_type, byte_size, sha256 FROM user_avatars WHERE user_id = ?`
,
[]
any
{
userID
},
&
rows
,
);
err
!=
nil
{
return
nil
,
err
}
defer
rows
.
Close
()
if
!
rows
.
Next
()
{
return
nil
,
rows
.
Err
()
}
var
avatar
service
.
UserAvatar
if
err
:=
rows
.
Scan
(
&
avatar
.
StorageProvider
,
&
avatar
.
StorageKey
,
&
avatar
.
URL
,
&
avatar
.
ContentType
,
&
avatar
.
ByteSize
,
&
avatar
.
SHA256
,
);
err
!=
nil
{
return
nil
,
err
}
if
err
:=
rows
.
Err
();
err
!=
nil
{
return
nil
,
err
}
return
&
avatar
,
nil
}
func
(
r
*
oauthPendingFlowUserRepo
)
UpsertUserAvatar
(
context
.
Context
,
int64
,
service
.
UpsertUserAvatarInput
)
(
*
service
.
UserAvatar
,
error
)
{
panic
(
"unexpected UpsertUserAvatar call"
)
func
(
r
*
oauthPendingFlowUserRepo
)
UpsertUserAvatar
(
ctx
context
.
Context
,
userID
int64
,
input
service
.
UpsertUserAvatarInput
)
(
*
service
.
UserAvatar
,
error
)
{
driver
:=
r
.
client
.
Driver
()
if
tx
:=
dbent
.
TxFromContext
(
ctx
);
tx
!=
nil
{
driver
=
tx
.
Client
()
.
Driver
()
}
var
result
entsql
.
Result
if
err
:=
driver
.
Exec
(
ctx
,
`INSERT INTO user_avatars (user_id, storage_provider, storage_key, url, content_type, byte_size, sha256, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(user_id) DO UPDATE SET
storage_provider = excluded.storage_provider,
storage_key = excluded.storage_key,
url = excluded.url,
content_type = excluded.content_type,
byte_size = excluded.byte_size,
sha256 = excluded.sha256,
updated_at = CURRENT_TIMESTAMP`
,
[]
any
{
userID
,
input
.
StorageProvider
,
input
.
StorageKey
,
input
.
URL
,
input
.
ContentType
,
input
.
ByteSize
,
input
.
SHA256
,
},
&
result
,
);
err
!=
nil
{
return
nil
,
err
}
return
&
service
.
UserAvatar
{
StorageProvider
:
input
.
StorageProvider
,
StorageKey
:
input
.
StorageKey
,
URL
:
input
.
URL
,
ContentType
:
input
.
ContentType
,
ByteSize
:
input
.
ByteSize
,
SHA256
:
input
.
SHA256
,
},
nil
}
func
(
r
*
oauthPendingFlowUserRepo
)
DeleteUserAvatar
(
context
.
Context
,
int64
)
error
{
return
nil
func
(
r
*
oauthPendingFlowUserRepo
)
DeleteUserAvatar
(
ctx
context
.
Context
,
userID
int64
)
error
{
driver
:=
r
.
client
.
Driver
()
if
tx
:=
dbent
.
TxFromContext
(
ctx
);
tx
!=
nil
{
driver
=
tx
.
Client
()
.
Driver
()
}
var
result
entsql
.
Result
return
driver
.
Exec
(
ctx
,
`DELETE FROM user_avatars WHERE user_id = ?`
,
[]
any
{
userID
},
&
result
)
}
func
(
r
*
oauthPendingFlowUserRepo
)
List
(
context
.
Context
,
pagination
.
PaginationParams
)
([]
service
.
User
,
*
pagination
.
PaginationResult
,
error
)
{
...
...
@@ -1636,6 +1761,14 @@ func (r *oauthPendingFlowUserRepo) UpdateConcurrency(context.Context, int64, int
panic
(
"unexpected UpdateConcurrency call"
)
}
func
(
r
*
oauthPendingFlowUserRepo
)
GetLatestUsedAtByUserIDs
(
context
.
Context
,
[]
int64
)
(
map
[
int64
]
*
time
.
Time
,
error
)
{
return
map
[
int64
]
*
time
.
Time
{},
nil
}
func
(
r
*
oauthPendingFlowUserRepo
)
GetLatestUsedAtByUserID
(
context
.
Context
,
int64
)
(
*
time
.
Time
,
error
)
{
return
nil
,
nil
}
func
(
r
*
oauthPendingFlowUserRepo
)
ExistsByEmail
(
ctx
context
.
Context
,
email
string
)
(
bool
,
error
)
{
count
,
err
:=
r
.
client
.
User
.
Query
()
.
Where
(
dbuser
.
EmailEQ
(
email
))
.
Count
(
ctx
)
return
count
>
0
,
err
...
...
backend/internal/handler/auth_oidc_oauth.go
View file @
92041457
...
...
@@ -537,7 +537,7 @@ func (h *AuthHandler) CompleteOIDCOAuthRegistration(c *gin.Context) {
response
.
ErrorFrom
(
c
,
err
)
return
}
if
err
:=
applyPendingOAuthAdoption
(
c
.
Request
.
Context
(),
h
.
entClient
(),
h
.
authService
,
session
,
decision
,
&
user
.
ID
);
err
!=
nil
{
if
err
:=
applyPendingOAuthAdoption
(
c
.
Request
.
Context
(),
h
.
entClient
(),
h
.
authService
,
h
.
userService
,
session
,
decision
,
&
user
.
ID
);
err
!=
nil
{
response
.
ErrorFrom
(
c
,
infraerrors
.
InternalServer
(
"PENDING_AUTH_ADOPTION_APPLY_FAILED"
,
"failed to apply oauth profile adoption"
)
.
WithCause
(
err
))
return
}
...
...
backend/internal/handler/auth_wechat_oauth.go
View file @
92041457
...
...
@@ -517,7 +517,7 @@ func (h *AuthHandler) CompleteWeChatOAuthRegistration(c *gin.Context) {
response
.
ErrorFrom
(
c
,
err
)
return
}
if
err
:=
applyPendingOAuthAdoption
(
c
.
Request
.
Context
(),
h
.
entClient
(),
h
.
authService
,
session
,
decision
,
&
user
.
ID
);
err
!=
nil
{
if
err
:=
applyPendingOAuthAdoption
(
c
.
Request
.
Context
(),
h
.
entClient
(),
h
.
authService
,
h
.
userService
,
session
,
decision
,
&
user
.
ID
);
err
!=
nil
{
response
.
ErrorFrom
(
c
,
infraerrors
.
InternalServer
(
"PENDING_AUTH_ADOPTION_APPLY_FAILED"
,
"failed to apply oauth profile adoption"
)
.
WithCause
(
err
))
return
}
...
...
backend/internal/handler/user_handler.go
View file @
92041457
...
...
@@ -2,6 +2,7 @@ package handler
import
(
"context"
"strings"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
...
...
@@ -43,8 +44,24 @@ type UpdateProfileRequest struct {
type
userProfileResponse
struct
{
dto
.
User
AvatarURL
string
`json:"avatar_url,omitempty"`
Identities
service
.
UserIdentitySummarySet
`json:"identities"`
AvatarURL
string
`json:"avatar_url,omitempty"`
AvatarSource
*
userProfileSourceContext
`json:"avatar_source,omitempty"`
UsernameSource
*
userProfileSourceContext
`json:"username_source,omitempty"`
DisplayNameSource
*
userProfileSourceContext
`json:"display_name_source,omitempty"`
NicknameSource
*
userProfileSourceContext
`json:"nickname_source,omitempty"`
ProfileSources
map
[
string
]
*
userProfileSourceContext
`json:"profile_sources,omitempty"`
Identities
service
.
UserIdentitySummarySet
`json:"identities"`
AuthBindings
map
[
string
]
service
.
UserIdentitySummary
`json:"auth_bindings"`
IdentityBindings
map
[
string
]
service
.
UserIdentitySummary
`json:"identity_bindings"`
EmailBound
bool
`json:"email_bound"`
LinuxDoBound
bool
`json:"linuxdo_bound"`
OIDCBound
bool
`json:"oidc_bound"`
WeChatBound
bool
`json:"wechat_bound"`
}
type
userProfileSourceContext
struct
{
Provider
string
`json:"provider,omitempty"`
Source
string
`json:"source,omitempty"`
}
// GetProfile handles getting user profile
...
...
@@ -335,9 +352,94 @@ func userProfileResponseFromService(user *service.User, identities service.UserI
if
base
==
nil
{
return
userProfileResponse
{}
}
bindings
:=
userProfileBindingMap
(
identities
)
profileSources
,
avatarSource
,
usernameSource
:=
inferUserProfileSources
(
user
,
identities
)
return
userProfileResponse
{
User
:
*
base
,
AvatarURL
:
user
.
AvatarURL
,
Identities
:
identities
,
User
:
*
base
,
AvatarURL
:
user
.
AvatarURL
,
AvatarSource
:
avatarSource
,
UsernameSource
:
usernameSource
,
DisplayNameSource
:
usernameSource
,
NicknameSource
:
usernameSource
,
ProfileSources
:
profileSources
,
Identities
:
identities
,
AuthBindings
:
bindings
,
IdentityBindings
:
bindings
,
EmailBound
:
identities
.
Email
.
Bound
,
LinuxDoBound
:
identities
.
LinuxDo
.
Bound
,
OIDCBound
:
identities
.
OIDC
.
Bound
,
WeChatBound
:
identities
.
WeChat
.
Bound
,
}
}
func
userProfileBindingMap
(
identities
service
.
UserIdentitySummarySet
)
map
[
string
]
service
.
UserIdentitySummary
{
return
map
[
string
]
service
.
UserIdentitySummary
{
"email"
:
identities
.
Email
,
"linuxdo"
:
identities
.
LinuxDo
,
"oidc"
:
identities
.
OIDC
,
"wechat"
:
identities
.
WeChat
,
}
}
func
inferUserProfileSources
(
user
*
service
.
User
,
identities
service
.
UserIdentitySummarySet
)
(
map
[
string
]
*
userProfileSourceContext
,
*
userProfileSourceContext
,
*
userProfileSourceContext
,
)
{
if
user
==
nil
{
return
nil
,
nil
,
nil
}
thirdParty
:=
thirdPartyIdentityProviders
(
identities
)
var
avatarSource
*
userProfileSourceContext
if
strings
.
TrimSpace
(
user
.
AvatarURL
)
!=
""
&&
len
(
thirdParty
)
==
1
{
avatarSource
=
buildUserProfileSourceContext
(
thirdParty
[
0
]
.
Provider
)
}
usernameValue
:=
strings
.
TrimSpace
(
user
.
Username
)
var
usernameSource
*
userProfileSourceContext
for
_
,
summary
:=
range
thirdParty
{
if
usernameValue
!=
""
&&
usernameValue
==
strings
.
TrimSpace
(
summary
.
DisplayName
)
{
usernameSource
=
buildUserProfileSourceContext
(
summary
.
Provider
)
break
}
}
if
usernameSource
==
nil
&&
usernameValue
!=
""
&&
len
(
thirdParty
)
==
1
{
usernameSource
=
buildUserProfileSourceContext
(
thirdParty
[
0
]
.
Provider
)
}
profileSources
:=
map
[
string
]
*
userProfileSourceContext
{}
if
avatarSource
!=
nil
{
profileSources
[
"avatar"
]
=
avatarSource
}
if
usernameSource
!=
nil
{
profileSources
[
"username"
]
=
usernameSource
profileSources
[
"display_name"
]
=
usernameSource
profileSources
[
"nickname"
]
=
usernameSource
}
if
len
(
profileSources
)
==
0
{
return
nil
,
avatarSource
,
usernameSource
}
return
profileSources
,
avatarSource
,
usernameSource
}
func
thirdPartyIdentityProviders
(
identities
service
.
UserIdentitySummarySet
)
[]
service
.
UserIdentitySummary
{
out
:=
make
([]
service
.
UserIdentitySummary
,
0
,
3
)
for
_
,
summary
:=
range
[]
service
.
UserIdentitySummary
{
identities
.
LinuxDo
,
identities
.
OIDC
,
identities
.
WeChat
}
{
if
summary
.
Bound
{
out
=
append
(
out
,
summary
)
}
}
return
out
}
func
buildUserProfileSourceContext
(
provider
string
)
*
userProfileSourceContext
{
provider
=
strings
.
TrimSpace
(
provider
)
if
provider
==
""
{
return
nil
}
return
&
userProfileSourceContext
{
Provider
:
provider
,
Source
:
provider
,
}
}
backend/internal/handler/user_handler_test.go
View file @
92041457
...
...
@@ -92,6 +92,12 @@ func (s *userHandlerRepoStub) RemoveGroupFromAllowedGroups(context.Context, int6
func
(
s
*
userHandlerRepoStub
)
AddGroupToAllowedGroups
(
context
.
Context
,
int64
,
int64
)
error
{
return
nil
}
func
(
s
*
userHandlerRepoStub
)
GetLatestUsedAtByUserIDs
(
context
.
Context
,
[]
int64
)
(
map
[
int64
]
*
time
.
Time
,
error
)
{
return
map
[
int64
]
*
time
.
Time
{},
nil
}
func
(
s
*
userHandlerRepoStub
)
GetLatestUsedAtByUserID
(
context
.
Context
,
int64
)
(
*
time
.
Time
,
error
)
{
return
nil
,
nil
}
func
(
s
*
userHandlerRepoStub
)
RemoveGroupFromUserAllowedGroups
(
context
.
Context
,
int64
,
int64
)
error
{
return
nil
}
...
...
@@ -230,6 +236,79 @@ func TestUserHandlerGetProfileReturnsIdentitySummaries(t *testing.T) {
require
.
Contains
(
t
,
resp
.
Data
.
Identities
.
WeChat
.
BindStartPath
,
"/api/v1/auth/oauth/wechat/start"
)
}
func
TestUserHandlerGetProfileReturnsLegacyCompatibilityFields
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
verifiedAt
:=
time
.
Date
(
2026
,
4
,
20
,
8
,
30
,
0
,
0
,
time
.
UTC
)
repo
:=
&
userHandlerRepoStub
{
user
:
&
service
.
User
{
ID
:
21
,
Email
:
"legacy-profile@example.com"
,
Username
:
"linuxdo-handle"
,
Role
:
service
.
RoleUser
,
Status
:
service
.
StatusActive
,
AvatarURL
:
"https://cdn.example.com/linuxdo.png"
,
AvatarSource
:
"remote_url"
,
},
identities
:
[]
service
.
UserAuthIdentityRecord
{
{
ProviderType
:
"linuxdo"
,
ProviderKey
:
"linuxdo"
,
ProviderSubject
:
"linuxdo-subject-21"
,
VerifiedAt
:
&
verifiedAt
,
Metadata
:
map
[
string
]
any
{
"username"
:
"linuxdo-handle"
,
},
},
},
}
handler
:=
NewUserHandler
(
service
.
NewUserService
(
repo
,
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
:
21
})
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
.
Equal
(
t
,
true
,
resp
.
Data
[
"email_bound"
])
require
.
Equal
(
t
,
true
,
resp
.
Data
[
"linuxdo_bound"
])
require
.
Equal
(
t
,
false
,
resp
.
Data
[
"oidc_bound"
])
require
.
Equal
(
t
,
false
,
resp
.
Data
[
"wechat_bound"
])
require
.
Equal
(
t
,
"https://cdn.example.com/linuxdo.png"
,
resp
.
Data
[
"avatar_url"
])
authBindings
,
ok
:=
resp
.
Data
[
"auth_bindings"
]
.
(
map
[
string
]
any
)
require
.
True
(
t
,
ok
)
linuxdoBinding
,
ok
:=
authBindings
[
"linuxdo"
]
.
(
map
[
string
]
any
)
require
.
True
(
t
,
ok
)
require
.
Equal
(
t
,
true
,
linuxdoBinding
[
"bound"
])
require
.
Equal
(
t
,
"linuxdo"
,
linuxdoBinding
[
"provider"
])
identityBindings
,
ok
:=
resp
.
Data
[
"identity_bindings"
]
.
(
map
[
string
]
any
)
require
.
True
(
t
,
ok
)
emailBinding
,
ok
:=
identityBindings
[
"email"
]
.
(
map
[
string
]
any
)
require
.
True
(
t
,
ok
)
require
.
Equal
(
t
,
true
,
emailBinding
[
"bound"
])
avatarSource
,
ok
:=
resp
.
Data
[
"avatar_source"
]
.
(
map
[
string
]
any
)
require
.
True
(
t
,
ok
)
require
.
Equal
(
t
,
"linuxdo"
,
avatarSource
[
"provider"
])
profileSources
,
ok
:=
resp
.
Data
[
"profile_sources"
]
.
(
map
[
string
]
any
)
require
.
True
(
t
,
ok
)
usernameSource
,
ok
:=
profileSources
[
"username"
]
.
(
map
[
string
]
any
)
require
.
True
(
t
,
ok
)
require
.
Equal
(
t
,
"linuxdo"
,
usernameSource
[
"provider"
])
}
func
TestUserHandlerStartIdentityBindingReturnsAuthorizeURL
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
...
...
backend/internal/service/user_service.go
View file @
92041457
...
...
@@ -65,6 +65,8 @@ type UserRepository interface {
List
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
)
([]
User
,
*
pagination
.
PaginationResult
,
error
)
ListWithFilters
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
,
filters
UserListFilters
)
([]
User
,
*
pagination
.
PaginationResult
,
error
)
GetLatestUsedAtByUserIDs
(
ctx
context
.
Context
,
userIDs
[]
int64
)
(
map
[
int64
]
*
time
.
Time
,
error
)
GetLatestUsedAtByUserID
(
ctx
context
.
Context
,
userID
int64
)
(
*
time
.
Time
,
error
)
UpdateBalance
(
ctx
context
.
Context
,
id
int64
,
amount
float64
)
error
DeductBalance
(
ctx
context
.
Context
,
id
int64
,
amount
float64
)
error
...
...
@@ -159,6 +161,33 @@ type userAuthIdentityReader interface {
ListUserAuthIdentities
(
ctx
context
.
Context
,
userID
int64
)
([]
UserAuthIdentityRecord
,
error
)
}
type
emailAuthIdentitySynchronizer
interface
{
EnsureEmailAuthIdentity
(
ctx
context
.
Context
,
userID
int64
,
email
string
)
error
ReplaceEmailAuthIdentity
(
ctx
context
.
Context
,
userID
int64
,
oldEmail
,
newEmail
string
)
error
}
func
ensureEmailAuthIdentitySync
(
ctx
context
.
Context
,
repo
UserRepository
,
userID
int64
,
email
string
)
error
{
syncer
,
ok
:=
repo
.
(
emailAuthIdentitySynchronizer
)
if
!
ok
{
return
nil
}
return
syncer
.
EnsureEmailAuthIdentity
(
ctx
,
userID
,
email
)
}
func
replaceEmailAuthIdentitySync
(
ctx
context
.
Context
,
repo
UserRepository
,
userID
int64
,
oldEmail
,
newEmail
string
)
error
{
oldNormalized
:=
strings
.
ToLower
(
strings
.
TrimSpace
(
oldEmail
))
newNormalized
:=
strings
.
ToLower
(
strings
.
TrimSpace
(
newEmail
))
if
oldNormalized
==
newNormalized
{
return
nil
}
syncer
,
ok
:=
repo
.
(
emailAuthIdentitySynchronizer
)
if
!
ok
{
return
nil
}
return
syncer
.
ReplaceEmailAuthIdentity
(
ctx
,
userID
,
oldEmail
,
newEmail
)
}
// ChangePasswordRequest 修改密码请求
type
ChangePasswordRequest
struct
{
CurrentPassword
string
`json:"current_password"`
...
...
@@ -252,6 +281,7 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req Updat
return
nil
,
fmt
.
Errorf
(
"get user: %w"
,
err
)
}
oldConcurrency
:=
user
.
Concurrency
oldEmail
:=
user
.
Email
// 更新字段
if
req
.
Email
!=
nil
{
...
...
@@ -271,24 +301,11 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req Updat
}
if
req
.
AvatarURL
!=
nil
{
avatarValue
:=
strings
.
TrimSpace
(
*
req
.
AvatarURL
)
switch
{
case
avatarValue
==
""
:
if
err
:=
s
.
userRepo
.
DeleteUserAvatar
(
ctx
,
userID
);
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"delete avatar: %w"
,
err
)
}
applyUserAvatar
(
user
,
nil
)
default
:
avatarInput
,
err
:=
normalizeUserAvatarInput
(
avatarValue
)
if
err
!=
nil
{
return
nil
,
err
}
avatar
,
err
:=
s
.
userRepo
.
UpsertUserAvatar
(
ctx
,
userID
,
avatarInput
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"upsert avatar: %w"
,
err
)
}
applyUserAvatar
(
user
,
avatar
)
avatar
,
err
:=
s
.
SetAvatar
(
ctx
,
userID
,
*
req
.
AvatarURL
)
if
err
!=
nil
{
return
nil
,
err
}
applyUserAvatar
(
user
,
avatar
)
}
if
req
.
Concurrency
!=
nil
{
...
...
@@ -309,6 +326,9 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req Updat
if
err
:=
s
.
userRepo
.
Update
(
ctx
,
user
);
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"update user: %w"
,
err
)
}
if
err
:=
replaceEmailAuthIdentitySync
(
ctx
,
s
.
userRepo
,
user
.
ID
,
oldEmail
,
user
.
Email
);
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"sync email auth identity: %w"
,
err
)
}
if
s
.
authCacheInvalidator
!=
nil
&&
user
.
Concurrency
!=
oldConcurrency
{
s
.
authCacheInvalidator
.
InvalidateAuthCacheByUserID
(
ctx
,
userID
)
}
...
...
@@ -316,6 +336,27 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req Updat
return
user
,
nil
}
func
(
s
*
UserService
)
SetAvatar
(
ctx
context
.
Context
,
userID
int64
,
raw
string
)
(
*
UserAvatar
,
error
)
{
avatarValue
:=
strings
.
TrimSpace
(
raw
)
if
avatarValue
==
""
{
if
err
:=
s
.
userRepo
.
DeleteUserAvatar
(
ctx
,
userID
);
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"delete avatar: %w"
,
err
)
}
return
nil
,
nil
}
avatarInput
,
err
:=
normalizeUserAvatarInput
(
avatarValue
)
if
err
!=
nil
{
return
nil
,
err
}
avatar
,
err
:=
s
.
userRepo
.
UpsertUserAvatar
(
ctx
,
userID
,
avatarInput
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"upsert avatar: %w"
,
err
)
}
return
avatar
,
nil
}
func
applyUserAvatar
(
user
*
User
,
avatar
*
UserAvatar
)
{
if
user
==
nil
{
return
...
...
frontend/src/api/user.ts
View file @
92041457
...
...
@@ -23,6 +23,7 @@ export async function getProfile(): Promise<User> {
*/
export
async
function
updateProfile
(
profile
:
{
username
?:
string
avatar_url
?:
string
|
null
balance_notify_enabled
?:
boolean
balance_notify_threshold
?:
number
|
null
balance_notify_extra_emails
?:
NotifyEmailEntry
[]
...
...
frontend/src/components/user/profile/ProfileInfoCard.vue
View file @
92041457
...
...
@@ -61,6 +61,71 @@
</div>
</div>
<div
class=
"mt-4 rounded-2xl border border-gray-100 bg-white/90 p-4 dark:border-dark-700 dark:bg-dark-900/30"
>
<div
class=
"flex flex-wrap items-start justify-between gap-3"
>
<div>
<h3
class=
"text-sm font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
profile.avatar.title
'
)
}}
</h3>
<p
class=
"mt-1 text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
profile.avatar.description
'
)
}}
</p>
</div>
<button
data-testid=
"profile-avatar-delete"
type=
"button"
class=
"btn btn-secondary btn-sm"
:disabled=
"avatarSaving"
@
click=
"handleAvatarDelete"
>
{{
t
(
'
common.delete
'
)
}}
</button>
</div>
<div
class=
"mt-3 space-y-3"
>
<label
for=
"profile-avatar-input"
class=
"text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400"
>
{{
t
(
'
profile.avatar.inputLabel
'
)
}}
</label>
<textarea
id=
"profile-avatar-input"
data-testid=
"profile-avatar-input"
v-model=
"avatarDraft"
rows=
"3"
class=
"input min-h-[88px]"
:placeholder=
"t('profile.avatar.inputPlaceholder')"
/>
<div
class=
"flex flex-wrap items-center gap-2"
>
<label
class=
"btn btn-secondary btn-sm cursor-pointer"
>
<input
data-testid=
"profile-avatar-file-input"
type=
"file"
accept=
"image/*"
class=
"hidden"
@
change=
"handleAvatarFileChange"
>
{{
t
(
'
profile.avatar.uploadAction
'
)
}}
</label>
<button
data-testid=
"profile-avatar-save"
type=
"button"
class=
"btn btn-primary btn-sm"
:disabled=
"avatarSaving"
@
click=
"handleAvatarSave"
>
{{
t
(
'
common.save
'
)
}}
</button>
<span
class=
"text-xs text-gray-400 dark:text-gray-500"
>
{{
t
(
'
profile.avatar.uploadHint
'
)
}}
</span>
</div>
</div>
</div>
<ProfileIdentityBindingsSection
class=
"mt-4"
:user=
"user"
...
...
@@ -74,11 +139,15 @@
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
}
from
'
vue
'
import
{
computed
,
ref
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
userAPI
}
from
'
@/api
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
ProfileIdentityBindingsSection
from
'
@/components/user/profile/ProfileIdentityBindingsSection.vue
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
useAuthStore
}
from
'
@/stores/auth
'
import
type
{
User
,
UserAuthProvider
,
UserProfileSourceContext
}
from
'
@/types
'
import
{
extractApiErrorMessage
}
from
'
@/utils/apiError
'
const
props
=
withDefaults
(
defineProps
<
{
...
...
@@ -97,6 +166,12 @@ const props = withDefaults(
)
const
{
t
}
=
useI18n
()
const
authStore
=
useAuthStore
()
const
appStore
=
useAppStore
()
const
maxAvatarBytes
=
100
*
1024
const
avatarDraft
=
ref
(
props
.
user
?.
avatar_url
?.
trim
()
||
''
)
const
avatarSaving
=
ref
(
false
)
const
providerLabels
=
computed
<
Record
<
UserAuthProvider
,
string
>>
(()
=>
({
email
:
t
(
'
profile.authBindings.providers.email
'
),
...
...
@@ -109,6 +184,13 @@ const avatarUrl = computed(() => props.user?.avatar_url?.trim() || '')
const
displayName
=
computed
(()
=>
props
.
user
?.
username
?.
trim
()
||
props
.
user
?.
email
?.
trim
()
||
'
User
'
)
const
avatarInitial
=
computed
(()
=>
displayName
.
value
.
charAt
(
0
).
toUpperCase
()
||
'
U
'
)
watch
(
()
=>
props
.
user
?.
avatar_url
,
(
value
)
=>
{
avatarDraft
.
value
=
value
?.
trim
()
||
''
}
)
function
normalizeProvider
(
value
:
string
):
UserAuthProvider
|
null
{
const
normalized
=
value
.
trim
().
toLowerCase
()
if
(
normalized
===
'
email
'
||
normalized
===
'
linuxdo
'
||
normalized
===
'
wechat
'
)
{
...
...
@@ -205,4 +287,122 @@ const sourceHints = computed(() => {
return
hints
})
function
estimateDataURLByteSize
(
value
:
string
):
number
{
const
[,
encoded
=
''
]
=
value
.
split
(
'
,
'
,
2
)
const
sanitized
=
encoded
.
replace
(
/
\s
+/g
,
''
)
const
padding
=
sanitized
.
endsWith
(
'
==
'
)
?
2
:
sanitized
.
endsWith
(
'
=
'
)
?
1
:
0
return
Math
.
max
(
0
,
Math
.
floor
((
sanitized
.
length
*
3
)
/
4
)
-
padding
)
}
function
validateAvatarInput
(
value
:
string
):
string
|
null
{
const
normalized
=
value
.
trim
()
if
(
!
normalized
)
{
return
null
}
if
(
normalized
.
startsWith
(
'
data:
'
))
{
if
(
!
/^data:image
\/[
a-zA-Z0-9.+-
]
+;base64,/i
.
test
(
normalized
))
{
appStore
.
showError
(
t
(
'
profile.avatar.invalidValue
'
))
return
null
}
if
(
estimateDataURLByteSize
(
normalized
)
>
maxAvatarBytes
)
{
appStore
.
showError
(
t
(
'
profile.avatar.fileTooLarge
'
))
return
null
}
return
normalized
}
try
{
const
parsed
=
new
URL
(
normalized
)
if
(
parsed
.
protocol
===
'
http:
'
||
parsed
.
protocol
===
'
https:
'
)
{
return
normalized
}
}
catch
{
// Invalid URL is handled below.
}
appStore
.
showError
(
t
(
'
profile.avatar.invalidValue
'
))
return
null
}
function
readFileAsDataURL
(
file
:
File
):
Promise
<
string
>
{
return
new
Promise
((
resolve
,
reject
)
=>
{
const
reader
=
new
FileReader
()
reader
.
onload
=
()
=>
resolve
(
typeof
reader
.
result
===
'
string
'
?
reader
.
result
:
''
)
reader
.
onerror
=
()
=>
reject
(
reader
.
error
??
new
Error
(
'
avatar_read_failed
'
))
reader
.
readAsDataURL
(
file
)
})
}
async
function
handleAvatarFileChange
(
event
:
Event
)
{
const
input
=
event
.
target
as
HTMLInputElement
|
null
const
file
=
input
?.
files
?.[
0
]
if
(
input
)
{
input
.
value
=
''
}
if
(
!
file
)
{
return
}
if
(
!
file
.
type
.
startsWith
(
'
image/
'
))
{
appStore
.
showError
(
t
(
'
profile.avatar.invalidType
'
))
return
}
if
(
file
.
size
>
maxAvatarBytes
)
{
appStore
.
showError
(
t
(
'
profile.avatar.fileTooLarge
'
))
return
}
try
{
const
dataURL
=
await
readFileAsDataURL
(
file
)
const
normalized
=
validateAvatarInput
(
dataURL
)
if
(
!
normalized
)
{
return
}
avatarDraft
.
value
=
normalized
}
catch
(
error
:
unknown
)
{
appStore
.
showError
(
extractApiErrorMessage
(
error
,
t
(
'
common.error
'
)))
}
}
async
function
handleAvatarSave
()
{
const
normalized
=
validateAvatarInput
(
avatarDraft
.
value
)
if
(
!
normalized
)
{
return
}
avatarSaving
.
value
=
true
try
{
const
updated
=
await
userAPI
.
updateProfile
({
avatar_url
:
normalized
})
authStore
.
user
=
updated
avatarDraft
.
value
=
updated
.
avatar_url
?.
trim
()
||
''
appStore
.
showSuccess
(
t
(
'
profile.avatar.saveSuccess
'
))
}
catch
(
error
:
unknown
)
{
appStore
.
showError
(
extractApiErrorMessage
(
error
,
t
(
'
common.error
'
)))
}
finally
{
avatarSaving
.
value
=
false
}
}
async
function
handleAvatarDelete
()
{
if
(
avatarSaving
.
value
)
{
return
}
if
(
!
avatarDraft
.
value
.
trim
()
&&
!
props
.
user
?.
avatar_url
?.
trim
())
{
appStore
.
showError
(
t
(
'
profile.avatar.emptyDeleteHint
'
))
return
}
avatarSaving
.
value
=
true
try
{
const
updated
=
await
userAPI
.
updateProfile
({
avatar_url
:
''
})
authStore
.
user
=
updated
avatarDraft
.
value
=
''
appStore
.
showSuccess
(
t
(
'
profile.avatar.deleteSuccess
'
))
}
catch
(
error
:
unknown
)
{
appStore
.
showError
(
extractApiErrorMessage
(
error
,
t
(
'
common.error
'
)))
}
finally
{
avatarSaving
.
value
=
false
}
}
</
script
>
frontend/src/components/user/profile/__tests__/ProfileInfoCard.spec.ts
0 → 100644
View file @
92041457
import
{
mount
}
from
'
@vue/test-utils
'
import
{
beforeEach
,
describe
,
expect
,
it
,
vi
}
from
'
vitest
'
import
ProfileInfoCard
from
'
@/components/user/profile/ProfileInfoCard.vue
'
import
type
{
User
}
from
'
@/types
'
const
{
updateProfileMock
,
showSuccessMock
,
showErrorMock
,
authStoreState
}
=
vi
.
hoisted
(()
=>
({
updateProfileMock
:
vi
.
fn
(),
showSuccessMock
:
vi
.
fn
(),
showErrorMock
:
vi
.
fn
(),
authStoreState
:
{
user
:
null
as
User
|
null
}
}))
vi
.
mock
(
'
@/api
'
,
()
=>
({
userAPI
:
{
updateProfile
:
updateProfileMock
}
}))
vi
.
mock
(
'
@/stores/auth
'
,
()
=>
({
useAuthStore
:
()
=>
authStoreState
}))
vi
.
mock
(
'
@/stores/app
'
,
()
=>
({
useAppStore
:
()
=>
({
showSuccess
:
showSuccessMock
,
showError
:
showErrorMock
})
}))
vi
.
mock
(
'
@/utils/apiError
'
,
()
=>
({
extractApiErrorMessage
:
(
error
:
unknown
)
=>
(
error
as
Error
).
message
||
'
request failed
'
}))
vi
.
mock
(
'
vue-i18n
'
,
async
(
importOriginal
)
=>
{
const
actual
=
await
importOriginal
<
typeof
import
(
'
vue-i18n
'
)
>
()
return
{
...
actual
,
useI18n
:
()
=>
({
t
:
(
key
:
string
,
params
?:
Record
<
string
,
string
>
)
=>
{
if
(
key
===
'
profile.administrator
'
)
return
'
Administrator
'
if
(
key
===
'
profile.user
'
)
return
'
User
'
if
(
key
===
'
profile.avatar.title
'
)
return
'
Profile avatar
'
if
(
key
===
'
profile.avatar.description
'
)
return
'
Set avatar by image URL or upload
'
if
(
key
===
'
profile.avatar.inputLabel
'
)
return
'
Avatar URL or data URL
'
if
(
key
===
'
profile.avatar.inputPlaceholder
'
)
return
'
https://cdn.example.com/avatar.png
'
if
(
key
===
'
profile.avatar.uploadAction
'
)
return
'
Upload image
'
if
(
key
===
'
profile.avatar.uploadHint
'
)
return
'
Images must be 100KB or smaller
'
if
(
key
===
'
profile.avatar.saveSuccess
'
)
return
'
Avatar updated
'
if
(
key
===
'
profile.avatar.deleteSuccess
'
)
return
'
Avatar removed
'
if
(
key
===
'
profile.avatar.invalidType
'
)
return
'
Please choose an image file
'
if
(
key
===
'
profile.avatar.fileTooLarge
'
)
return
'
Avatar image must be 100KB or smaller
'
if
(
key
===
'
profile.avatar.invalidValue
'
)
return
'
Enter a valid avatar URL or image data URL
'
if
(
key
===
'
profile.avatar.emptyDeleteHint
'
)
return
'
Avatar already removed
'
if
(
key
===
'
profile.authBindings.providers.email
'
)
return
'
Email
'
if
(
key
===
'
profile.authBindings.providers.linuxdo
'
)
return
'
LinuxDo
'
if
(
key
===
'
profile.authBindings.providers.wechat
'
)
return
'
WeChat
'
if
(
key
===
'
profile.authBindings.providers.oidc
'
)
return
params
?.
providerName
||
'
OIDC
'
if
(
key
===
'
common.save
'
)
return
'
Save
'
if
(
key
===
'
common.delete
'
)
return
'
Delete
'
return
key
}
})
}
})
function
createUser
(
overrides
:
Partial
<
User
>
=
{}):
User
{
return
{
id
:
5
,
username
:
'
alice
'
,
email
:
'
alice@example.com
'
,
avatar_url
:
null
,
role
:
'
user
'
,
balance
:
10
,
concurrency
:
2
,
status
:
'
active
'
,
allowed_groups
:
null
,
balance_notify_enabled
:
true
,
balance_notify_threshold
:
null
,
balance_notify_extra_emails
:
[],
created_at
:
'
2026-04-20T00:00:00Z
'
,
updated_at
:
'
2026-04-20T00:00:00Z
'
,
...
overrides
}
}
describe
(
'
ProfileInfoCard
'
,
()
=>
{
beforeEach
(()
=>
{
updateProfileMock
.
mockReset
()
showSuccessMock
.
mockReset
()
showErrorMock
.
mockReset
()
authStoreState
.
user
=
null
})
it
(
'
saves a remote avatar URL and updates the auth store
'
,
async
()
=>
{
const
updatedUser
=
createUser
({
avatar_url
:
'
https://cdn.example.com/new.png
'
})
updateProfileMock
.
mockResolvedValue
(
updatedUser
)
authStoreState
.
user
=
createUser
()
const
wrapper
=
mount
(
ProfileInfoCard
,
{
props
:
{
user
:
authStoreState
.
user
},
global
:
{
stubs
:
{
Icon
:
true
,
ProfileIdentityBindingsSection
:
true
}
}
})
await
wrapper
.
get
(
'
[data-testid="profile-avatar-input"]
'
).
setValue
(
'
https://cdn.example.com/new.png
'
)
await
wrapper
.
get
(
'
[data-testid="profile-avatar-save"]
'
).
trigger
(
'
click
'
)
expect
(
updateProfileMock
).
toHaveBeenCalledWith
({
avatar_url
:
'
https://cdn.example.com/new.png
'
})
expect
(
authStoreState
.
user
?.
avatar_url
).
toBe
(
'
https://cdn.example.com/new.png
'
)
expect
(
showSuccessMock
).
toHaveBeenCalledWith
(
'
Avatar updated
'
)
})
it
(
'
rejects an oversized data URL before sending the request
'
,
async
()
=>
{
authStoreState
.
user
=
createUser
()
const
oversized
=
`data:image/png;base64,
${
Buffer
.
from
(
new
Uint8Array
(
102401
)).
toString
(
'
base64
'
)}
`
const
wrapper
=
mount
(
ProfileInfoCard
,
{
props
:
{
user
:
authStoreState
.
user
},
global
:
{
stubs
:
{
Icon
:
true
,
ProfileIdentityBindingsSection
:
true
}
}
})
await
wrapper
.
get
(
'
[data-testid="profile-avatar-input"]
'
).
setValue
(
oversized
)
await
wrapper
.
get
(
'
[data-testid="profile-avatar-save"]
'
).
trigger
(
'
click
'
)
expect
(
updateProfileMock
).
not
.
toHaveBeenCalled
()
expect
(
showErrorMock
).
toHaveBeenCalledWith
(
'
Avatar image must be 100KB or smaller
'
)
})
it
(
'
deletes the current avatar
'
,
async
()
=>
{
const
updatedUser
=
createUser
({
avatar_url
:
null
})
updateProfileMock
.
mockResolvedValue
(
updatedUser
)
authStoreState
.
user
=
createUser
({
avatar_url
:
'
https://cdn.example.com/old.png
'
})
const
wrapper
=
mount
(
ProfileInfoCard
,
{
props
:
{
user
:
authStoreState
.
user
},
global
:
{
stubs
:
{
Icon
:
true
,
ProfileIdentityBindingsSection
:
true
}
}
})
await
wrapper
.
get
(
'
[data-testid="profile-avatar-delete"]
'
).
trigger
(
'
click
'
)
expect
(
updateProfileMock
).
toHaveBeenCalledWith
({
avatar_url
:
''
})
expect
(
authStoreState
.
user
?.
avatar_url
).
toBeNull
()
expect
(
showSuccessMock
).
toHaveBeenCalledWith
(
'
Avatar removed
'
)
})
})
frontend/src/i18n/locales/en.ts
View file @
92041457
...
...
@@ -941,6 +941,20 @@ export default {
unverified
:
'
Unverified
'
,
verified
:
'
Verified
'
,
},
avatar
:
{
title
:
'
Profile Avatar
'
,
description
:
'
Set your avatar with a remote image URL or upload a small image.
'
,
inputLabel
:
'
Avatar URL or data URL
'
,
inputPlaceholder
:
'
https://cdn.example.com/avatar.png
'
,
uploadAction
:
'
Upload image
'
,
uploadHint
:
'
Images must be 100KB or smaller
'
,
saveSuccess
:
'
Avatar updated
'
,
deleteSuccess
:
'
Avatar removed
'
,
invalidType
:
'
Please choose an image file
'
,
fileTooLarge
:
'
Avatar image must be 100KB or smaller
'
,
invalidValue
:
'
Enter a valid avatar URL or image data URL
'
,
emptyDeleteHint
:
'
Avatar is already empty
'
,
},
authBindings
:
{
title
:
'
Connected Sign-In Methods
'
,
description
:
'
View current bindings and connect another provider to this account.
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
92041457
...
...
@@ -945,6 +945,20 @@ export default {
unverified
:
'
未验证
'
,
verified
:
'
已验证
'
,
},
avatar
:
{
title
:
'
资料头像
'
,
description
:
'
支持填写远程图片 URL,或上传不超过 100KB 的头像图片。
'
,
inputLabel
:
'
头像 URL 或 data URL
'
,
inputPlaceholder
:
'
https://cdn.example.com/avatar.png
'
,
uploadAction
:
'
上传图片
'
,
uploadHint
:
'
图片大小需不超过 100KB
'
,
saveSuccess
:
'
头像已更新
'
,
deleteSuccess
:
'
头像已删除
'
,
invalidType
:
'
请选择图片文件
'
,
fileTooLarge
:
'
头像图片必须不超过 100KB
'
,
invalidValue
:
'
请输入有效的头像 URL 或图片 data URL
'
,
emptyDeleteHint
:
'
当前没有可删除的头像
'
,
},
authBindings
:
{
title
:
'
登录方式绑定
'
,
description
:
'
查看当前绑定状态,并将更多第三方登录方式关联到这个账号。
'
,
...
...
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