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
d5819181
Commit
d5819181
authored
Apr 21, 2026
by
IanShaw027
Browse files
feat(auth): reclaim stale identities and refresh profile UI
parent
c0371e91
Changes
16
Show whitespace changes
Inline
Side-by-side
backend/internal/handler/auth_oauth_pending_flow.go
View file @
d5819181
...
...
@@ -644,15 +644,17 @@ func resolvePendingOAuthTargetUserID(ctx context.Context, client *dbent.Client,
}
func
userNormalizedEmailPredicate
(
email
string
)
predicate
.
User
{
normalized
:=
strings
.
TrimSpace
(
email
)
normalized
:=
strings
.
ToLower
(
strings
.
TrimSpace
(
email
)
)
if
normalized
==
""
{
return
dbuser
.
EmailEQ
(
email
)
}
return
predicate
.
User
(
func
(
s
*
entsql
.
Selector
)
{
s
.
Where
(
entsql
.
ExprP
(
fmt
.
Sprintf
(
"LOWER(TRIM(%s)) = LOWER(TRIM(?))"
,
s
.
C
(
dbuser
.
FieldEmail
)),
normalized
,
))
s
.
Where
(
entsql
.
P
(
func
(
b
*
entsql
.
Builder
)
{
b
.
WriteString
(
"LOWER(TRIM("
)
.
Ident
(
s
.
C
(
dbuser
.
FieldEmail
))
.
WriteString
(
")) = "
)
.
Arg
(
normalized
)
}))
})
}
...
...
@@ -718,8 +720,17 @@ func ensurePendingOAuthIdentityForUser(ctx context.Context, tx *dbent.Tx, sessio
}
if
identity
!=
nil
{
if
identity
.
UserID
!=
userID
{
activeOwner
,
err
:=
findActiveUserByID
(
ctx
,
client
,
identity
.
UserID
)
if
err
!=
nil
{
return
nil
,
err
}
if
activeOwner
!=
nil
{
return
nil
,
infraerrors
.
Conflict
(
"AUTH_IDENTITY_OWNERSHIP_CONFLICT"
,
"auth identity already belongs to another user"
)
}
return
client
.
AuthIdentity
.
UpdateOneID
(
identity
.
ID
)
.
SetUserID
(
userID
)
.
Save
(
ctx
)
}
return
identity
,
nil
}
...
...
@@ -756,7 +767,7 @@ func ensurePendingWeChatOAuthIdentityForUser(ctx context.Context, tx *dbent.Tx,
if
err
!=
nil
{
return
nil
,
err
}
identity
,
hasCanonicalKey
,
err
:=
chooseWeChatIdentityForUser
(
identityRecords
,
userID
,
providerKey
)
identity
,
hasCanonicalKey
,
err
:=
chooseWeChatIdentityForUser
(
ctx
,
client
,
identityRecords
,
userID
,
providerKey
)
if
err
!=
nil
{
return
nil
,
err
}
...
...
@@ -773,7 +784,7 @@ func ensurePendingWeChatOAuthIdentityForUser(ctx context.Context, tx *dbent.Tx,
if
err
!=
nil
{
return
nil
,
err
}
legacyOpenIDIdentity
,
_
,
err
=
chooseWeChatIdentityForUser
(
legacyOpenIDRecords
,
userID
,
providerKey
)
legacyOpenIDIdentity
,
_
,
err
=
chooseWeChatIdentityForUser
(
ctx
,
client
,
legacyOpenIDRecords
,
userID
,
providerKey
)
if
err
!=
nil
{
return
nil
,
err
}
...
...
@@ -783,6 +794,9 @@ func ensurePendingWeChatOAuthIdentityForUser(ctx context.Context, tx *dbent.Tx,
case
identity
!=
nil
:
update
:=
client
.
AuthIdentity
.
UpdateOneID
(
identity
.
ID
)
.
SetMetadata
(
mergeOAuthMetadata
(
identity
.
Metadata
,
metadata
))
if
identity
.
UserID
!=
userID
{
update
=
update
.
SetUserID
(
userID
)
}
if
!
strings
.
EqualFold
(
strings
.
TrimSpace
(
identity
.
ProviderKey
),
providerKey
)
&&
!
hasCanonicalKey
{
update
=
update
.
SetProviderKey
(
providerKey
)
}
...
...
@@ -838,7 +852,7 @@ func ensurePendingWeChatOAuthIdentityForUser(ctx context.Context, tx *dbent.Tx,
if
err
!=
nil
{
return
nil
,
err
}
channelRecord
,
hasCanonicalChannelKey
,
err
:=
chooseWeChatChannelForUser
(
channelRecords
,
userID
,
providerKey
)
channelRecord
,
hasCanonicalChannelKey
,
err
:=
chooseWeChatChannelForUser
(
ctx
,
client
,
channelRecords
,
userID
,
providerKey
)
if
err
!=
nil
{
return
nil
,
err
}
...
...
@@ -872,7 +886,7 @@ func ensurePendingWeChatOAuthIdentityForUser(ctx context.Context, tx *dbent.Tx,
return
identity
,
nil
}
func
chooseWeChatIdentityForUser
(
records
[]
*
dbent
.
AuthIdentity
,
userID
int64
,
preferredProviderKey
string
)
(
*
dbent
.
AuthIdentity
,
bool
,
error
)
{
func
chooseWeChatIdentityForUser
(
ctx
context
.
Context
,
client
*
dbent
.
Client
,
records
[]
*
dbent
.
AuthIdentity
,
userID
int64
,
preferredProviderKey
string
)
(
*
dbent
.
AuthIdentity
,
bool
,
error
)
{
var
preferred
*
dbent
.
AuthIdentity
var
fallback
*
dbent
.
AuthIdentity
hasCanonicalKey
:=
false
...
...
@@ -881,8 +895,14 @@ func chooseWeChatIdentityForUser(records []*dbent.AuthIdentity, userID int64, pr
continue
}
if
record
.
UserID
!=
userID
{
activeOwner
,
err
:=
findActiveUserByID
(
ctx
,
client
,
record
.
UserID
)
if
err
!=
nil
{
return
nil
,
false
,
err
}
if
activeOwner
!=
nil
{
return
nil
,
false
,
infraerrors
.
Conflict
(
"AUTH_IDENTITY_OWNERSHIP_CONFLICT"
,
"auth identity already belongs to another user"
)
}
}
if
strings
.
EqualFold
(
strings
.
TrimSpace
(
record
.
ProviderKey
),
preferredProviderKey
)
{
hasCanonicalKey
=
true
if
preferred
==
nil
{
...
...
@@ -900,7 +920,7 @@ func chooseWeChatIdentityForUser(records []*dbent.AuthIdentity, userID int64, pr
return
fallback
,
hasCanonicalKey
,
nil
}
func
chooseWeChatChannelForUser
(
records
[]
*
dbent
.
AuthIdentityChannel
,
userID
int64
,
preferredProviderKey
string
)
(
*
dbent
.
AuthIdentityChannel
,
bool
,
error
)
{
func
chooseWeChatChannelForUser
(
ctx
context
.
Context
,
client
*
dbent
.
Client
,
records
[]
*
dbent
.
AuthIdentityChannel
,
userID
int64
,
preferredProviderKey
string
)
(
*
dbent
.
AuthIdentityChannel
,
bool
,
error
)
{
var
preferred
*
dbent
.
AuthIdentityChannel
var
fallback
*
dbent
.
AuthIdentityChannel
hasCanonicalKey
:=
false
...
...
@@ -909,8 +929,14 @@ func chooseWeChatChannelForUser(records []*dbent.AuthIdentityChannel, userID int
continue
}
if
record
.
Edges
.
Identity
!=
nil
&&
record
.
Edges
.
Identity
.
UserID
!=
userID
{
activeOwner
,
err
:=
findActiveUserByID
(
ctx
,
client
,
record
.
Edges
.
Identity
.
UserID
)
if
err
!=
nil
{
return
nil
,
false
,
err
}
if
activeOwner
!=
nil
{
return
nil
,
false
,
infraerrors
.
Conflict
(
"AUTH_IDENTITY_CHANNEL_OWNERSHIP_CONFLICT"
,
"auth identity channel already belongs to another user"
)
}
}
if
strings
.
EqualFold
(
strings
.
TrimSpace
(
record
.
ProviderKey
),
preferredProviderKey
)
{
hasCanonicalKey
=
true
if
preferred
==
nil
{
...
...
@@ -928,6 +954,20 @@ func chooseWeChatChannelForUser(records []*dbent.AuthIdentityChannel, userID int
return
fallback
,
hasCanonicalKey
,
nil
}
func
findActiveUserByID
(
ctx
context
.
Context
,
client
*
dbent
.
Client
,
userID
int64
)
(
*
dbent
.
User
,
error
)
{
if
client
==
nil
||
userID
<=
0
{
return
nil
,
nil
}
userEntity
,
err
:=
client
.
User
.
Get
(
ctx
,
userID
)
if
err
!=
nil
{
if
dbent
.
IsNotFound
(
err
)
{
return
nil
,
nil
}
return
nil
,
infraerrors
.
InternalServer
(
"AUTH_IDENTITY_USER_LOOKUP_FAILED"
,
"failed to load auth identity user"
)
.
WithCause
(
err
)
}
return
userEntity
,
nil
}
func
channelRecordMetadata
(
channel
*
dbent
.
AuthIdentityChannel
)
map
[
string
]
any
{
if
channel
==
nil
{
return
map
[
string
]
any
{}
...
...
@@ -1343,7 +1383,7 @@ func (h *AuthHandler) bindPendingOAuthLogin(c *gin.Context, provider string) {
return
}
if
err
:=
applyPendingOAuthBinding
(
c
.
Request
.
Context
(),
h
.
entClient
(),
h
.
authService
,
h
.
userService
,
session
,
decision
,
&
user
.
ID
,
true
,
true
);
err
!=
nil
{
respon
se
.
ErrorFrom
(
c
,
infraerrors
.
InternalServer
(
"PENDING_AUTH_BIND_APPLY_FAILED"
,
"failed to bind pending oauth identity"
)
.
WithCause
(
err
)
)
respon
dPendingOAuthBindingApplyError
(
c
,
err
)
return
}
...
...
@@ -1363,6 +1403,14 @@ func (h *AuthHandler) bindPendingOAuthLogin(c *gin.Context, provider string) {
writeOAuthTokenPairResponse
(
c
,
tokenPair
)
}
func
respondPendingOAuthBindingApplyError
(
c
*
gin
.
Context
,
err
error
)
{
if
code
:=
infraerrors
.
Code
(
err
);
code
>=
http
.
StatusBadRequest
&&
code
<
http
.
StatusInternalServerError
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
ErrorFrom
(
c
,
infraerrors
.
InternalServer
(
"PENDING_AUTH_BIND_APPLY_FAILED"
,
"failed to bind pending oauth identity"
)
.
WithCause
(
err
))
}
func
(
h
*
AuthHandler
)
createPendingOAuthAccount
(
c
*
gin
.
Context
,
provider
string
)
{
var
req
createPendingOAuthAccountRequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
...
...
@@ -1480,7 +1528,7 @@ func (h *AuthHandler) createPendingOAuthAccount(c *gin.Context, provider string)
if
rollbackCreatedUser
(
err
)
{
return
}
respon
se
.
ErrorFrom
(
c
,
infraerrors
.
InternalServer
(
"PENDING_AUTH_BIND_APPLY_FAILED"
,
"failed to bind pending oauth identity"
)
.
WithCause
(
err
)
)
respon
dPendingOAuthBindingApplyError
(
c
,
err
)
return
}
...
...
@@ -1514,7 +1562,7 @@ func (h *AuthHandler) createPendingOAuthAccount(c *gin.Context, provider string)
if
rollbackCreatedUser
(
err
)
{
return
}
respon
se
.
ErrorFrom
(
c
,
infraerrors
.
InternalServer
(
"PENDING_AUTH_BIND_APPLY_FAILED"
,
"failed to bind pending oauth identity"
)
.
WithCause
(
err
)
)
respon
dPendingOAuthBindingApplyError
(
c
,
err
)
return
}
}
...
...
backend/internal/handler/auth_oauth_pending_flow_test.go
View file @
d5819181
...
...
@@ -1358,6 +1358,80 @@ func TestBindOIDCOAuthLoginRejectsInvalidPasswordWithoutConsumingSession(t *test
require
.
Nil
(
t
,
storedSession
.
ConsumedAt
)
}
func
TestBindOIDCOAuthLoginReclaimsIdentityOwnedBySoftDeletedUser
(
t
*
testing
.
T
)
{
handler
,
client
:=
newOAuthPendingFlowTestHandler
(
t
,
false
)
ctx
:=
context
.
Background
()
oldOwnerHash
,
err
:=
handler
.
authService
.
HashPassword
(
"old-secret"
)
require
.
NoError
(
t
,
err
)
oldOwner
,
err
:=
client
.
User
.
Create
()
.
SetEmail
(
"old-owner@example.com"
)
.
SetUsername
(
"old-owner"
)
.
SetPasswordHash
(
oldOwnerHash
)
.
SetRole
(
service
.
RoleUser
)
.
SetStatus
(
service
.
StatusActive
)
.
Save
(
ctx
)
require
.
NoError
(
t
,
err
)
identity
,
err
:=
client
.
AuthIdentity
.
Create
()
.
SetUserID
(
oldOwner
.
ID
)
.
SetProviderType
(
"oidc"
)
.
SetProviderKey
(
"https://issuer.example"
)
.
SetProviderSubject
(
"oidc-bind-soft-deleted-123"
)
.
SetMetadata
(
map
[
string
]
any
{
"username"
:
"old-owner"
})
.
Save
(
ctx
)
require
.
NoError
(
t
,
err
)
_
,
err
=
client
.
User
.
Delete
()
.
Where
(
dbuser
.
IDEQ
(
oldOwner
.
ID
))
.
Exec
(
ctx
)
require
.
NoError
(
t
,
err
)
newOwnerHash
,
err
:=
handler
.
authService
.
HashPassword
(
"secret-123"
)
require
.
NoError
(
t
,
err
)
newOwner
,
err
:=
client
.
User
.
Create
()
.
SetEmail
(
"owner@example.com"
)
.
SetUsername
(
"owner-user"
)
.
SetPasswordHash
(
newOwnerHash
)
.
SetRole
(
service
.
RoleUser
)
.
SetStatus
(
service
.
StatusActive
)
.
Save
(
ctx
)
require
.
NoError
(
t
,
err
)
session
,
err
:=
client
.
PendingAuthSession
.
Create
()
.
SetSessionToken
(
"bind-login-soft-deleted-owner-session-token"
)
.
SetIntent
(
"adopt_existing_user_by_email"
)
.
SetProviderType
(
"oidc"
)
.
SetProviderKey
(
"https://issuer.example"
)
.
SetProviderSubject
(
"oidc-bind-soft-deleted-123"
)
.
SetTargetUserID
(
newOwner
.
ID
)
.
SetResolvedEmail
(
newOwner
.
Email
)
.
SetBrowserSessionKey
(
"bind-login-soft-deleted-owner-browser-session-key"
)
.
SetUpstreamIdentityClaims
(
map
[
string
]
any
{
"username"
:
"oidc_user"
,
"suggested_display_name"
:
"Recovered OIDC User"
,
})
.
SetRedirectTo
(
"/profile"
)
.
SetExpiresAt
(
time
.
Now
()
.
UTC
()
.
Add
(
10
*
time
.
Minute
))
.
Save
(
ctx
)
require
.
NoError
(
t
,
err
)
body
:=
bytes
.
NewBufferString
(
`{"email":"owner@example.com","password":"secret-123","adopt_display_name":false,"adopt_avatar":false}`
)
recorder
:=
httptest
.
NewRecorder
()
ginCtx
,
_
:=
gin
.
CreateTestContext
(
recorder
)
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/auth/oauth/oidc/bind-login"
,
body
)
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
req
.
AddCookie
(
&
http
.
Cookie
{
Name
:
oauthPendingSessionCookieName
,
Value
:
encodeCookieValue
(
session
.
SessionToken
)})
req
.
AddCookie
(
&
http
.
Cookie
{
Name
:
oauthPendingBrowserCookieName
,
Value
:
encodeCookieValue
(
"bind-login-soft-deleted-owner-browser-session-key"
)})
ginCtx
.
Request
=
req
handler
.
BindOIDCOAuthLogin
(
ginCtx
)
require
.
Equal
(
t
,
http
.
StatusOK
,
recorder
.
Code
)
identity
,
err
=
client
.
AuthIdentity
.
Get
(
ctx
,
identity
.
ID
)
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
newOwner
.
ID
,
identity
.
UserID
)
}
func
TestBindOIDCOAuthLoginAppliesFirstBindGrantOnce
(
t
*
testing
.
T
)
{
defaultSubAssigner
:=
&
oauthPendingFlowDefaultSubAssignerStub
{}
handler
,
client
:=
newOAuthPendingFlowTestHandlerWithDependencies
(
t
,
oauthPendingFlowTestHandlerOptions
{
...
...
backend/internal/repository/user_repo.go
View file @
d5819181
...
...
@@ -12,7 +12,9 @@ import (
dbent
"github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/apikey"
"github.com/Wei-Shaw/sub2api/ent/authidentity"
"github.com/Wei-Shaw/sub2api/ent/authidentitychannel"
dbgroup
"github.com/Wei-Shaw/sub2api/ent/group"
"github.com/Wei-Shaw/sub2api/ent/identityadoptiondecision"
"github.com/Wei-Shaw/sub2api/ent/predicate"
dbuser
"github.com/Wei-Shaw/sub2api/ent/user"
"github.com/Wei-Shaw/sub2api/ent/userallowedgroup"
...
...
@@ -292,13 +294,57 @@ func normalizeEmailAuthIdentitySubject(email string) string {
}
func
(
r
*
userRepository
)
Delete
(
ctx
context
.
Context
,
id
int64
)
error
{
affected
,
err
:=
r
.
client
.
User
.
Delete
()
.
Where
(
dbuser
.
IDEQ
(
id
))
.
Exec
(
ctx
)
tx
,
err
:=
r
.
client
.
Tx
(
ctx
)
if
err
!=
nil
&&
!
errors
.
Is
(
err
,
dbent
.
ErrTxStarted
)
{
return
translatePersistenceError
(
err
,
service
.
ErrUserNotFound
,
nil
)
}
var
txClient
*
dbent
.
Client
if
err
==
nil
{
defer
func
()
{
_
=
tx
.
Rollback
()
}()
txClient
=
tx
.
Client
()
}
else
{
txClient
=
r
.
client
}
identityIDs
,
err
:=
txClient
.
AuthIdentity
.
Query
()
.
Where
(
authidentity
.
UserIDEQ
(
id
))
.
IDs
(
ctx
)
if
err
!=
nil
{
return
translatePersistenceError
(
err
,
service
.
ErrUserNotFound
,
nil
)
}
if
len
(
identityIDs
)
>
0
{
if
_
,
err
:=
txClient
.
IdentityAdoptionDecision
.
Update
()
.
Where
(
identityadoptiondecision
.
IdentityIDIn
(
identityIDs
...
))
.
ClearIdentityID
()
.
Save
(
ctx
);
err
!=
nil
{
return
translatePersistenceError
(
err
,
service
.
ErrUserNotFound
,
nil
)
}
if
_
,
err
:=
txClient
.
AuthIdentityChannel
.
Delete
()
.
Where
(
authidentitychannel
.
IdentityIDIn
(
identityIDs
...
))
.
Exec
(
ctx
);
err
!=
nil
{
return
translatePersistenceError
(
err
,
service
.
ErrUserNotFound
,
nil
)
}
if
_
,
err
:=
txClient
.
AuthIdentity
.
Delete
()
.
Where
(
authidentity
.
UserIDEQ
(
id
))
.
Exec
(
ctx
);
err
!=
nil
{
return
translatePersistenceError
(
err
,
service
.
ErrUserNotFound
,
nil
)
}
}
affected
,
err
:=
txClient
.
User
.
Delete
()
.
Where
(
dbuser
.
IDEQ
(
id
))
.
Exec
(
ctx
)
if
err
!=
nil
{
return
translatePersistenceError
(
err
,
service
.
ErrUserNotFound
,
nil
)
}
if
affected
==
0
{
return
service
.
ErrUserNotFound
}
if
tx
!=
nil
{
if
err
:=
tx
.
Commit
();
err
!=
nil
{
return
translatePersistenceError
(
err
,
service
.
ErrUserNotFound
,
nil
)
}
}
return
nil
}
...
...
@@ -645,15 +691,17 @@ func (r *userRepository) ExistsByEmail(ctx context.Context, email string) (bool,
}
func
userEmailLookupPredicate
(
email
string
)
predicate
.
User
{
normalized
:=
strings
.
TrimSpace
(
email
)
normalized
:=
strings
.
ToLower
(
strings
.
TrimSpace
(
email
)
)
if
normalized
==
""
{
return
dbuser
.
EmailEQ
(
email
)
}
return
predicate
.
User
(
func
(
s
*
entsql
.
Selector
)
{
s
.
Where
(
entsql
.
ExprP
(
fmt
.
Sprintf
(
"LOWER(TRIM(%s)) = LOWER(TRIM(?))"
,
s
.
C
(
dbuser
.
FieldEmail
)),
normalized
,
))
s
.
Where
(
entsql
.
P
(
func
(
b
*
entsql
.
Builder
)
{
b
.
WriteString
(
"LOWER(TRIM("
)
.
Ident
(
s
.
C
(
dbuser
.
FieldEmail
))
.
WriteString
(
")) = "
)
.
Arg
(
normalized
)
}))
})
}
...
...
backend/internal/repository/user_repo_integration_test.go
View file @
d5819181
...
...
@@ -8,6 +8,8 @@ import (
"time"
dbent
"github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/authidentity"
"github.com/Wei-Shaw/sub2api/ent/authidentitychannel"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/suite"
...
...
@@ -124,11 +126,27 @@ func (s *UserRepoSuite) TestGetByEmail() {
s
.
Require
()
.
Equal
(
user
.
ID
,
got
.
ID
)
}
func
(
s
*
UserRepoSuite
)
TestGetByEmail_NormalizesSpacingAndCaseOnPostgres
()
{
user
:=
s
.
mustCreateUser
(
&
service
.
User
{
Email
:
" Legacy@Example.com "
})
got
,
err
:=
s
.
repo
.
GetByEmail
(
s
.
ctx
,
" legacy@example.com "
)
s
.
Require
()
.
NoError
(
err
,
"GetByEmail normalized lookup"
)
s
.
Require
()
.
Equal
(
user
.
ID
,
got
.
ID
)
}
func
(
s
*
UserRepoSuite
)
TestGetByEmail_NotFound
()
{
_
,
err
:=
s
.
repo
.
GetByEmail
(
s
.
ctx
,
"nonexistent@test.com"
)
s
.
Require
()
.
Error
(
err
,
"expected error for non-existent email"
)
}
func
(
s
*
UserRepoSuite
)
TestExistsByEmail_NormalizesSpacingAndCaseOnPostgres
()
{
s
.
mustCreateUser
(
&
service
.
User
{
Email
:
" Legacy@Example.com "
})
exists
,
err
:=
s
.
repo
.
ExistsByEmail
(
s
.
ctx
,
" LEGACY@example.com "
)
s
.
Require
()
.
NoError
(
err
,
"ExistsByEmail normalized lookup"
)
s
.
Require
()
.
True
(
exists
)
}
func
(
s
*
UserRepoSuite
)
TestUpdate
()
{
user
:=
s
.
mustCreateUser
(
&
service
.
User
{
Email
:
"update@test.com"
,
Username
:
"original"
})
...
...
@@ -152,6 +170,39 @@ func (s *UserRepoSuite) TestDelete() {
s
.
Require
()
.
Error
(
err
,
"expected error after delete"
)
}
func
(
s
*
UserRepoSuite
)
TestDeleteRemovesAuthIdentitiesAndChannels
()
{
user
:=
s
.
mustCreateUser
(
&
service
.
User
{
Email
:
"delete-oauth@test.com"
})
identity
,
err
:=
s
.
client
.
AuthIdentity
.
Create
()
.
SetUserID
(
user
.
ID
)
.
SetProviderType
(
"linuxdo"
)
.
SetProviderKey
(
"linuxdo"
)
.
SetProviderSubject
(
"delete-oauth-subject"
)
.
Save
(
s
.
ctx
)
s
.
Require
()
.
NoError
(
err
)
_
,
err
=
s
.
client
.
AuthIdentityChannel
.
Create
()
.
SetIdentityID
(
identity
.
ID
)
.
SetProviderType
(
"wechat"
)
.
SetProviderKey
(
"wechat"
)
.
SetChannel
(
"open"
)
.
SetChannelAppID
(
"app-id"
)
.
SetChannelSubject
(
"openid-123"
)
.
Save
(
s
.
ctx
)
s
.
Require
()
.
NoError
(
err
)
err
=
s
.
repo
.
Delete
(
s
.
ctx
,
user
.
ID
)
s
.
Require
()
.
NoError
(
err
)
identityCount
,
err
:=
s
.
client
.
AuthIdentity
.
Query
()
.
Where
(
authidentity
.
UserIDEQ
(
user
.
ID
))
.
Count
(
s
.
ctx
)
s
.
Require
()
.
NoError
(
err
)
s
.
Require
()
.
Zero
(
identityCount
)
channelCount
,
err
:=
s
.
client
.
AuthIdentityChannel
.
Query
()
.
Where
(
authidentitychannel
.
IdentityIDEQ
(
identity
.
ID
))
.
Count
(
s
.
ctx
)
s
.
Require
()
.
NoError
(
err
)
s
.
Require
()
.
Zero
(
channelCount
)
}
// --- List / ListWithFilters ---
func
(
s
*
UserRepoSuite
)
TestList
()
{
...
...
backend/internal/service/auth_pending_identity_service.go
View file @
d5819181
...
...
@@ -11,8 +11,11 @@ import (
dbent
"github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/identityadoptiondecision"
dbpredicate
"github.com/Wei-Shaw/sub2api/ent/predicate"
"github.com/Wei-Shaw/sub2api/ent/pendingauthsession"
infraerrors
"github.com/Wei-Shaw/sub2api/internal/pkg/errors"
entsql
"entgo.io/ent/dialect/sql"
)
var
(
...
...
@@ -271,6 +274,24 @@ func (s *AuthPendingIdentityService) UpsertAdoptionDecision(ctx context.Context,
return
nil
,
fmt
.
Errorf
(
"pending auth ent client is not configured"
)
}
if
input
.
IdentityID
!=
nil
&&
*
input
.
IdentityID
>
0
{
if
_
,
err
:=
s
.
entClient
.
IdentityAdoptionDecision
.
Update
()
.
Where
(
identityadoptiondecision
.
IdentityIDEQ
(
*
input
.
IdentityID
),
dbpredicate
.
IdentityAdoptionDecision
(
func
(
s
*
entsql
.
Selector
)
{
col
:=
s
.
C
(
identityadoptiondecision
.
FieldPendingAuthSessionID
)
s
.
Where
(
entsql
.
Or
(
entsql
.
IsNull
(
col
),
entsql
.
NEQ
(
col
,
input
.
PendingAuthSessionID
),
))
}),
)
.
ClearIdentityID
()
.
Save
(
ctx
);
err
!=
nil
{
return
nil
,
err
}
}
existing
,
err
:=
s
.
entClient
.
IdentityAdoptionDecision
.
Query
()
.
Where
(
identityadoptiondecision
.
PendingAuthSessionIDEQ
(
input
.
PendingAuthSessionID
))
.
Only
(
ctx
)
...
...
backend/internal/service/auth_pending_identity_service_test.go
View file @
d5819181
...
...
@@ -10,6 +10,7 @@ import (
dbent
"github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/enttest"
"github.com/Wei-Shaw/sub2api/ent/identityadoptiondecision"
"github.com/stretchr/testify/require"
"entgo.io/ent/dialect"
...
...
@@ -192,6 +193,139 @@ func TestAuthPendingIdentityService_UpsertAdoptionDecision(t *testing.T) {
require
.
True
(
t
,
second
.
AdoptAvatar
)
}
func
TestAuthPendingIdentityService_UpsertAdoptionDecision_ReassignsExistingIdentityReference
(
t
*
testing
.
T
)
{
svc
,
client
:=
newAuthPendingIdentityServiceTestClient
(
t
)
ctx
:=
context
.
Background
()
user
,
err
:=
client
.
User
.
Create
()
.
SetEmail
(
"adoption-reassign@example.com"
)
.
SetPasswordHash
(
"hash"
)
.
SetRole
(
RoleUser
)
.
SetStatus
(
StatusActive
)
.
Save
(
ctx
)
require
.
NoError
(
t
,
err
)
identity
,
err
:=
client
.
AuthIdentity
.
Create
()
.
SetUserID
(
user
.
ID
)
.
SetProviderType
(
"wechat"
)
.
SetProviderKey
(
"wechat-open"
)
.
SetProviderSubject
(
"union-reassign"
)
.
SetMetadata
(
map
[
string
]
any
{})
.
Save
(
ctx
)
require
.
NoError
(
t
,
err
)
firstSession
,
err
:=
svc
.
CreatePendingSession
(
ctx
,
CreatePendingAuthSessionInput
{
Intent
:
"bind_current_user"
,
Identity
:
PendingAuthIdentityKey
{
ProviderType
:
"wechat"
,
ProviderKey
:
"wechat-open"
,
ProviderSubject
:
"union-reassign"
,
},
})
require
.
NoError
(
t
,
err
)
firstDecision
,
err
:=
svc
.
UpsertAdoptionDecision
(
ctx
,
PendingIdentityAdoptionDecisionInput
{
PendingAuthSessionID
:
firstSession
.
ID
,
IdentityID
:
&
identity
.
ID
,
AdoptDisplayName
:
true
,
AdoptAvatar
:
false
,
})
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
firstDecision
.
IdentityID
)
require
.
Equal
(
t
,
identity
.
ID
,
*
firstDecision
.
IdentityID
)
secondSession
,
err
:=
svc
.
CreatePendingSession
(
ctx
,
CreatePendingAuthSessionInput
{
Intent
:
"bind_current_user"
,
Identity
:
PendingAuthIdentityKey
{
ProviderType
:
"wechat"
,
ProviderKey
:
"wechat-open"
,
ProviderSubject
:
"union-reassign"
,
},
})
require
.
NoError
(
t
,
err
)
secondDecision
,
err
:=
svc
.
UpsertAdoptionDecision
(
ctx
,
PendingIdentityAdoptionDecisionInput
{
PendingAuthSessionID
:
secondSession
.
ID
,
IdentityID
:
&
identity
.
ID
,
AdoptDisplayName
:
false
,
AdoptAvatar
:
true
,
})
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
secondDecision
.
IdentityID
)
require
.
Equal
(
t
,
identity
.
ID
,
*
secondDecision
.
IdentityID
)
reloadedFirst
,
err
:=
client
.
IdentityAdoptionDecision
.
Get
(
ctx
,
firstDecision
.
ID
)
require
.
NoError
(
t
,
err
)
require
.
Nil
(
t
,
reloadedFirst
.
IdentityID
)
}
func
TestAuthPendingIdentityService_UpsertAdoptionDecision_ClearsLegacyNullSessionReference
(
t
*
testing
.
T
)
{
t
.
Skip
(
"legacy NULL pending_auth_session_id rows only exist in production PostgreSQL history; sqlite unit schema rejects NULL"
)
svc
,
client
:=
newAuthPendingIdentityServiceTestClient
(
t
)
ctx
:=
context
.
Background
()
user
,
err
:=
client
.
User
.
Create
()
.
SetEmail
(
"legacy-null-session@example.com"
)
.
SetPasswordHash
(
"hash"
)
.
SetRole
(
RoleUser
)
.
SetStatus
(
StatusActive
)
.
Save
(
ctx
)
require
.
NoError
(
t
,
err
)
identity
,
err
:=
client
.
AuthIdentity
.
Create
()
.
SetUserID
(
user
.
ID
)
.
SetProviderType
(
"wechat"
)
.
SetProviderKey
(
"wechat-main"
)
.
SetProviderSubject
(
"legacy-null-session"
)
.
SetMetadata
(
map
[
string
]
any
{})
.
Save
(
ctx
)
require
.
NoError
(
t
,
err
)
_
,
err
=
client
.
ExecContext
(
ctx
,
`INSERT INTO identity_adoption_decisions
(identity_id, adopt_display_name, adopt_avatar, decided_at, created_at, updated_at, pending_auth_session_id)
VALUES (?, ?, ?, ?, ?, ?, NULL)`
,
identity
.
ID
,
true
,
false
,
time
.
Now
()
.
UTC
(),
time
.
Now
()
.
UTC
(),
time
.
Now
()
.
UTC
(),
)
require
.
NoError
(
t
,
err
)
legacyDecision
,
err
:=
client
.
IdentityAdoptionDecision
.
Query
()
.
Where
(
identityadoptiondecision
.
IdentityIDEQ
(
identity
.
ID
))
.
Only
(
ctx
)
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
legacyDecision
.
IdentityID
)
session
,
err
:=
svc
.
CreatePendingSession
(
ctx
,
CreatePendingAuthSessionInput
{
Intent
:
"bind_current_user"
,
Identity
:
PendingAuthIdentityKey
{
ProviderType
:
"wechat"
,
ProviderKey
:
"wechat-main"
,
ProviderSubject
:
"legacy-null-session"
,
},
})
require
.
NoError
(
t
,
err
)
decision
,
err
:=
svc
.
UpsertAdoptionDecision
(
ctx
,
PendingIdentityAdoptionDecisionInput
{
PendingAuthSessionID
:
session
.
ID
,
IdentityID
:
&
identity
.
ID
,
AdoptDisplayName
:
false
,
AdoptAvatar
:
true
,
})
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
decision
.
IdentityID
)
require
.
Equal
(
t
,
identity
.
ID
,
*
decision
.
IdentityID
)
reloadedLegacy
,
err
:=
client
.
IdentityAdoptionDecision
.
Get
(
ctx
,
legacyDecision
.
ID
)
require
.
NoError
(
t
,
err
)
require
.
Nil
(
t
,
reloadedLegacy
.
IdentityID
)
}
func
TestAuthPendingIdentityService_ConsumeBrowserSession
(
t
*
testing
.
T
)
{
svc
,
_
:=
newAuthPendingIdentityServiceTestClient
(
t
)
ctx
:=
context
.
Background
()
...
...
frontend/src/api/auth.ts
View file @
d5819181
...
...
@@ -361,11 +361,13 @@ export type WeChatOAuthUnavailableReason =
|
'
capability_unknown
'
|
'
external_browser_required
'
|
'
wechat_browser_required
'
|
'
native_app_required
'
export
interface
ResolvedWeChatOAuthStart
{
mode
:
WeChatOAuthMode
|
null
openEnabled
:
boolean
mpEnabled
:
boolean
mobileEnabled
:
boolean
isWeChatBrowser
:
boolean
unavailableReason
:
WeChatOAuthUnavailableReason
|
null
}
...
...
@@ -374,6 +376,22 @@ export type WeChatOAuthPublicSettings = {
wechat_oauth_enabled
?:
boolean
wechat_oauth_open_enabled
?:
boolean
wechat_oauth_mp_enabled
?:
boolean
wechat_oauth_mobile_enabled
?:
boolean
}
export
function
isWeChatWebOAuthEnabled
(
settings
:
WeChatOAuthPublicSettings
|
null
|
undefined
,
):
boolean
{
const
legacyEnabled
=
settings
?.
wechat_oauth_enabled
??
false
const
hasExplicitCapabilities
=
typeof
settings
?.
wechat_oauth_open_enabled
===
'
boolean
'
||
typeof
settings
?.
wechat_oauth_mp_enabled
===
'
boolean
'
if
(
!
hasExplicitCapabilities
)
{
return
legacyEnabled
}
return
settings
?.
wechat_oauth_open_enabled
===
true
||
settings
?.
wechat_oauth_mp_enabled
===
true
}
export
function
hasExplicitWeChatOAuthCapabilities
(
...
...
@@ -401,24 +419,27 @@ export function resolveWeChatOAuthStart(
const
mpEnabled
=
typeof
settings
?.
wechat_oauth_mp_enabled
===
'
boolean
'
?
settings
.
wechat_oauth_mp_enabled
:
legacyEnabled
const
mobileEnabled
=
typeof
settings
?.
wechat_oauth_mobile_enabled
===
'
boolean
'
?
settings
.
wechat_oauth_mobile_enabled
:
false
if
(
isWeChatBrowser
)
{
if
(
mpEnabled
)
{
return
{
mode
:
'
mp
'
,
openEnabled
,
mpEnabled
,
isWeChatBrowser
,
unavailableReason
:
null
}
return
{
mode
:
'
mp
'
,
openEnabled
,
mpEnabled
,
mobileEnabled
,
isWeChatBrowser
,
unavailableReason
:
null
}
}
if
(
openEnabled
)
{
return
{
mode
:
null
,
openEnabled
,
mpEnabled
,
isWeChatBrowser
,
unavailableReason
:
'
external_browser_required
'
}
return
{
mode
:
null
,
openEnabled
,
mpEnabled
,
mobileEnabled
,
isWeChatBrowser
,
unavailableReason
:
'
external_browser_required
'
}
}
return
{
mode
:
null
,
openEnabled
,
mpEnabled
,
isWeChatBrowser
,
unavailableReason
:
'
not_configured
'
}
return
{
mode
:
null
,
openEnabled
,
mpEnabled
,
mobileEnabled
,
isWeChatBrowser
,
unavailableReason
:
'
not_configured
'
}
}
if
(
openEnabled
)
{
return
{
mode
:
'
open
'
,
openEnabled
,
mpEnabled
,
isWeChatBrowser
,
unavailableReason
:
null
}
return
{
mode
:
'
open
'
,
openEnabled
,
mpEnabled
,
mobileEnabled
,
isWeChatBrowser
,
unavailableReason
:
null
}
}
if
(
mpEnabled
)
{
return
{
mode
:
null
,
openEnabled
,
mpEnabled
,
isWeChatBrowser
,
unavailableReason
:
'
wechat_browser_required
'
}
return
{
mode
:
null
,
openEnabled
,
mpEnabled
,
mobileEnabled
,
isWeChatBrowser
,
unavailableReason
:
'
wechat_browser_required
'
}
}
return
{
mode
:
null
,
openEnabled
,
mpEnabled
,
isWeChatBrowser
,
unavailableReason
:
'
not_configured
'
}
return
{
mode
:
null
,
openEnabled
,
mpEnabled
,
mobileEnabled
,
isWeChatBrowser
,
unavailableReason
:
'
not_configured
'
}
}
export
function
resolveWeChatOAuthStartStrict
(
...
...
@@ -435,6 +456,7 @@ export function resolveWeChatOAuthStartStrict(
mode
:
null
,
openEnabled
:
false
,
mpEnabled
:
false
,
mobileEnabled
:
false
,
isWeChatBrowser
,
unavailableReason
:
'
capability_unknown
'
,
}
...
...
frontend/src/components/layout/AppHeader.vue
View file @
d5819181
...
...
@@ -74,10 +74,14 @@
class=
"flex items-center gap-2 rounded-xl p-1.5 transition-colors hover:bg-gray-100 dark:hover:bg-dark-800"
aria-label=
"User Menu"
>
<div
class=
"flex h-8 w-8 items-center justify-center rounded-xl bg-gradient-to-br from-primary-500 to-primary-600 text-sm font-medium text-white shadow-sm"
<div
class=
"flex h-8 w-8 items-center justify-center overflow-hidden rounded-xl bg-gradient-to-br from-primary-500 to-primary-600 text-sm font-medium text-white shadow-sm"
>
<img
v-if=
"avatarUrl"
:src=
"avatarUrl"
:alt=
"displayName"
class=
"h-full w-full object-cover"
>
{{
userInitials
}}
<span
v-else
>
{{
userInitials
}}
</span>
</div>
<div
class=
"hidden text-left md:block"
>
<div
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
...
...
@@ -232,6 +236,7 @@ const dropdownOpen = ref(false)
const
dropdownRef
=
ref
<
HTMLElement
|
null
>
(
null
)
const
contactInfo
=
computed
(()
=>
appStore
.
contactInfo
)
const
docUrl
=
computed
(()
=>
appStore
.
docUrl
)
const
avatarUrl
=
computed
(()
=>
user
.
value
?.
avatar_url
?.
trim
()
||
''
)
// 只在标准模式的管理员下显示新手引导按钮
const
showOnboardingButton
=
computed
(()
=>
{
...
...
frontend/src/components/user/profile/ProfileAvatarCard.vue
View file @
d5819181
<
template
>
<div
class=
"card"
>
<div
class=
"border-b border-gray-100 px-6 py-4 dark:border-dark-700"
>
<div
:class=
"props.embedded ? 'space-y-4' : 'card'"
>
<div
v-if=
"!props.embedded"
class=
"border-b border-gray-100 px-6 py-4 dark:border-dark-700"
>
<h2
class=
"text-lg font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
profile.avatar.title
'
)
}}
</h2>
...
...
@@ -9,8 +12,9 @@
</p>
</div>
<div
class=
"flex flex-col gap-5 px-6 py-6 sm:flex-row sm:items-start"
>
<div
:
class=
"
props.embedded ? 'space-y-3' : '
flex flex-col gap-5 px-6 py-6 sm:flex-row sm:items-start
'
"
>
<div
v-if=
"!props.embedded"
class=
"flex h-24 w-24 shrink-0 items-center justify-center overflow-hidden rounded-2xl bg-gradient-to-br from-primary-500 to-primary-600 text-3xl font-bold text-white shadow-lg shadow-primary-500/20"
>
<img
...
...
@@ -22,9 +26,12 @@
<span
v-else
>
{{
avatarInitial
}}
</span>
</div>
<div
class=
"min-w-0 flex-1 space-y-4"
>
<div
:
class=
"
props.embedded ? 'space-y-3' : '
min-w-0 flex-1 space-y-4
'
"
>
<div
class=
"space-y-1"
>
<p
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
<p
v-if=
"props.embedded"
class=
"text-sm font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
profile.avatar.title
'
)
}}
</p>
<p
v-else
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
{{
displayName
}}
</p>
<p
class=
"text-sm text-gray-500 dark:text-gray-400"
>
...
...
@@ -78,9 +85,12 @@ import { useAuthStore } from '@/stores/auth'
import
type
{
User
}
from
'
@/types
'
import
{
extractApiErrorMessage
}
from
'
@/utils/apiError
'
const
props
=
defineProps
<
{
const
props
=
withDefaults
(
defineProps
<
{
user
:
User
|
null
}
>
()
embedded
?:
boolean
}
>
(),
{
embedded
:
false
,
})
const
{
t
}
=
useI18n
()
const
authStore
=
useAuthStore
()
...
...
frontend/src/components/user/profile/ProfileEditForm.vue
View file @
d5819181
<
template
>
<div
class=
"card"
>
<div
class=
"border-b border-gray-100 px-6 py-4 dark:border-dark-700"
>
<div
:class=
"props.embedded ? 'space-y-4' : 'card'"
>
<div
v-if=
"!props.embedded"
class=
"border-b border-gray-100 px-6 py-4 dark:border-dark-700"
>
<h2
class=
"text-lg font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
profile.editProfile
'
)
}}
</h2>
</div>
<div
class=
"px-6 py-6"
>
<div
:
class=
"
props.embedded ? '' : '
px-6 py-6
'
"
>
<form
@
submit.prevent=
"handleUpdateProfile"
class=
"space-y-4"
>
<div
v-if=
"props.embedded"
>
<p
class=
"text-sm font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
profile.editProfile
'
)
}}
</p>
</div>
<div>
<label
for=
"username"
class=
"input-label"
>
{{
t
(
'
profile.username
'
)
}}
...
...
@@ -37,9 +45,12 @@ import { useAuthStore } from '@/stores/auth'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
userAPI
}
from
'
@/api
'
const
props
=
defineProps
<
{
const
props
=
withDefaults
(
defineProps
<
{
initialUsername
:
string
}
>
()
embedded
?:
boolean
}
>
(),
{
embedded
:
false
,
})
const
{
t
}
=
useI18n
()
const
authStore
=
useAuthStore
()
...
...
frontend/src/components/user/profile/ProfileIdentityBindingsSection.vue
View file @
d5819181
<
template
>
<div
class=
"card overflow-hidden"
>
<div
class=
"border-b border-gray-100 px-6 py-4 dark:border-dark-700"
>
<div
:class=
"props.embedded ? 'space-y-4' : 'card overflow-hidden'"
>
<div
v-if=
"!props.embedded"
class=
"border-b border-gray-100 px-6 py-4 dark:border-dark-700"
>
<h2
class=
"text-lg font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
profile.authBindings.title
'
)
}}
</h2>
...
...
@@ -9,11 +12,23 @@
</p>
</div>
<div
class=
"divide-y divide-gray-100 dark:divide-dark-700"
>
<div
:class=
"props.embedded ? 'space-y-4' : 'divide-y divide-gray-100 dark:divide-dark-700'"
>
<div
v-if=
"props.embedded"
>
<p
class=
"text-sm font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
profile.authBindings.title
'
)
}}
</p>
<p
class=
"mt-1 text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
profile.authBindings.description
'
)
}}
</p>
</div>
<div
v-for=
"item in providerItems"
:key=
"item.provider"
class=
"px-6 py-5"
:class=
"
props.embedded
? 'rounded-2xl border border-gray-100 bg-gray-50/70 p-4 dark:border-dark-700 dark:bg-dark-900/30'
: 'px-6 py-5'
"
>
<div
class=
"flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div
class=
"flex min-w-0 flex-1 items-start gap-4"
>
...
...
@@ -154,6 +169,7 @@ const props = withDefaults(
wechatEnabled
?:
boolean
wechatOpenEnabled
?:
boolean
wechatMpEnabled
?:
boolean
embedded
?:
boolean
}
>
(),
{
linuxdoEnabled
:
false
,
...
...
@@ -162,6 +178,7 @@ const props = withDefaults(
wechatEnabled
:
false
,
wechatOpenEnabled
:
undefined
,
wechatMpEnabled
:
undefined
,
embedded
:
false
,
}
)
...
...
frontend/src/components/user/profile/ProfileInfoCard.vue
View file @
d5819181
...
...
@@ -3,9 +3,9 @@
<div
class=
"border-b border-gray-100 bg-gradient-to-r from-primary-500/10 to-primary-600/5 px-6 py-5 dark:border-dark-700 dark:from-primary-500/20 dark:to-primary-600/10"
>
<div
class=
"flex
items-center gap-4
"
>
<div
class=
"flex
flex-col gap-5 lg:flex-row lg:items-start
"
>
<div
class=
"flex h-
16
w-
16
items-center justify-center overflow-hidden rounded-
2xl
bg-gradient-to-br from-primary-500 to-primary-600 text-2xl font-bold text-white shadow-lg shadow-primary-500/20"
class=
"flex h-
20
w-
20 shrink-0
items-center justify-center overflow-hidden rounded-
[1.5rem]
bg-gradient-to-br from-primary-500 to-primary-600 text-2xl font-bold text-white shadow-lg shadow-primary-500/20"
>
<img
v-if=
"avatarUrl"
...
...
@@ -15,14 +15,12 @@
>
<span
v-else
>
{{
avatarInitial
}}
</span>
</div>
<div
class=
"min-w-0 flex-1"
>
<h2
class=
"truncate text-lg font-semibold text-gray-900 dark:text-white"
>
<div
class=
"min-w-0 flex-1 space-y-5"
>
<div
class=
"space-y-2"
>
<div
class=
"flex flex-wrap items-center gap-2"
>
<h2
class=
"truncate text-xl font-semibold text-gray-900 dark:text-white"
>
{{
displayName
}}
</h2>
<p
class=
"mt-1 truncate text-sm text-gray-500 dark:text-gray-400"
>
{{
user
?.
email
}}
</p>
<div
class=
"mt-1 flex items-center gap-2"
>
<span
:class=
"['badge', user?.role === 'admin' ? 'badge-primary' : 'badge-gray']"
>
{{
user
?.
role
===
'
admin
'
?
t
(
'
profile.administrator
'
)
:
t
(
'
profile.user
'
)
}}
</span>
...
...
@@ -36,27 +34,59 @@
}}
</span>
</div>
<p
class=
"truncate text-sm text-gray-500 dark:text-gray-400"
>
{{
user
?.
email
}}
</p>
</div>
<div
class=
"grid gap-3 sm:grid-cols-2 xl:grid-cols-4"
>
<div
class=
"rounded-2xl bg-white/75 px-4 py-3 shadow-sm ring-1 ring-white/70 dark:bg-dark-900/40 dark:ring-dark-700"
>
<p
class=
"text-xs font-medium uppercase tracking-[0.16em] text-gray-400 dark:text-gray-500"
>
{{
t
(
'
profile.username
'
)
}}
</p>
<p
class=
"mt-1 truncate text-sm font-medium text-gray-900 dark:text-white"
>
{{
user
?.
username
||
displayName
}}
</p>
</div>
<div
class=
"rounded-2xl bg-white/75 px-4 py-3 shadow-sm ring-1 ring-white/70 dark:bg-dark-900/40 dark:ring-dark-700"
>
<p
class=
"text-xs font-medium uppercase tracking-[0.16em] text-gray-400 dark:text-gray-500"
>
{{
t
(
'
profile.email
'
)
}}
</p>
<p
class=
"mt-1 truncate text-sm font-medium text-gray-900 dark:text-white"
>
{{
user
?.
email
||
'
-
'
}}
</p>
</div>
<div
class=
"rounded-2xl bg-white/75 px-4 py-3 shadow-sm ring-1 ring-white/70 dark:bg-dark-900/40 dark:ring-dark-700"
>
<p
class=
"text-xs font-medium uppercase tracking-[0.16em] text-gray-400 dark:text-gray-500"
>
{{
t
(
'
profile.status
'
)
}}
</p>
<p
class=
"mt-1 text-sm font-medium text-gray-900 dark:text-white"
>
{{
user
?.
status
===
'
active
'
?
t
(
'
common.active
'
)
:
user
?.
status
?
t
(
'
common.disabled
'
)
:
'
-
'
}}
</p>
</div>
<div
class=
"rounded-2xl bg-white/75 px-4 py-3 shadow-sm ring-1 ring-white/70 dark:bg-dark-900/40 dark:ring-dark-700"
>
<p
class=
"text-xs font-medium uppercase tracking-[0.16em] text-gray-400 dark:text-gray-500"
>
{{
t
(
'
profile.role
'
)
}}
</p>
<p
class=
"mt-1 text-sm font-medium text-gray-900 dark:text-white"
>
{{
user
?.
role
===
'
admin
'
?
t
(
'
profile.administrator
'
)
:
t
(
'
profile.user
'
)
}}
</p>
</div>
</div>
<div
class=
"px-6 py-4"
>
<div
class=
"space-y-3"
>
<div
class=
"flex items-center gap-3 text-sm text-gray-600 dark:text-gray-400"
>
<Icon
name=
"mail"
size=
"sm"
class=
"text-gray-400 dark:text-gray-500"
/>
<span
class=
"truncate"
>
{{
user
?.
email
}}
</span>
</div>
<div
v-if=
"user?.username"
class=
"flex items-center gap-3 text-sm text-gray-600 dark:text-gray-400"
>
<Icon
name=
"user"
size=
"sm"
class=
"text-gray-400 dark:text-gray-500"
/>
<span
class=
"truncate"
>
{{
user
.
username
}}
</span>
</div>
</div>
<div
class=
"space-y-6 px-6 py-6"
>
<div
v-if=
"sourceHints.length"
class=
"
mt-4
grid gap-2 rounded-2xl border border-gray-100 bg-gray-50/80 p-
3
text-xs text-gray-500 dark:border-dark-700 dark:bg-dark-900/30 dark:text-gray-400"
class=
"grid gap-2 rounded-2xl border border-gray-100 bg-gray-50/80 p-
4
text-xs text-gray-500 dark:border-dark-700 dark:bg-dark-900/30 dark:text-gray-400"
>
<div
v-for=
"hint in sourceHints"
...
...
@@ -67,6 +97,39 @@
<span>
{{
hint
.
text
}}
</span>
</div>
</div>
<div
class=
"grid gap-6 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]"
>
<div
class=
"rounded-3xl border border-gray-100 bg-gray-50/70 p-5 dark:border-dark-700 dark:bg-dark-900/30"
>
<ProfileAvatarCard
:user=
"user"
embedded
/>
</div>
<div
class=
"rounded-3xl border border-gray-100 bg-gray-50/70 p-5 dark:border-dark-700 dark:bg-dark-900/30"
>
<ProfileEditForm
:initial-username=
"user?.username || ''"
embedded
/>
</div>
</div>
<div
class=
"rounded-3xl border border-gray-100 bg-gray-50/70 p-5 dark:border-dark-700 dark:bg-dark-900/30"
>
<ProfileIdentityBindingsSection
:user=
"user"
:linuxdo-enabled=
"linuxdoEnabled"
:oidc-enabled=
"oidcEnabled"
:oidc-provider-name=
"oidcProviderName"
:wechat-enabled=
"wechatEnabled"
:wechat-open-enabled=
"wechatOpenEnabled"
:wechat-mp-enabled=
"wechatMpEnabled"
embedded
/>
</div>
<div
class=
"rounded-3xl border border-gray-100 bg-gray-50/70 p-5 dark:border-dark-700 dark:bg-dark-900/30"
>
<ProfilePasswordForm
embedded
/>
</div>
</div>
</div>
</
template
>
...
...
@@ -75,11 +138,28 @@
import
{
computed
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
ProfileAvatarCard
from
'
@/components/user/profile/ProfileAvatarCard.vue
'
import
ProfileEditForm
from
'
@/components/user/profile/ProfileEditForm.vue
'
import
ProfileIdentityBindingsSection
from
'
@/components/user/profile/ProfileIdentityBindingsSection.vue
'
import
ProfilePasswordForm
from
'
@/components/user/profile/ProfilePasswordForm.vue
'
import
type
{
User
,
UserAuthProvider
,
UserProfileSourceContext
}
from
'
@/types
'
const
props
=
defineProps
<
{
const
props
=
withDefaults
(
defineProps
<
{
user
:
User
|
null
}
>
()
linuxdoEnabled
?:
boolean
oidcEnabled
?:
boolean
oidcProviderName
?:
string
wechatEnabled
?:
boolean
wechatOpenEnabled
?:
boolean
wechatMpEnabled
?:
boolean
}
>
(),
{
linuxdoEnabled
:
false
,
oidcEnabled
:
false
,
oidcProviderName
:
'
OIDC
'
,
wechatEnabled
:
false
,
wechatOpenEnabled
:
undefined
,
wechatMpEnabled
:
undefined
,
})
const
{
t
}
=
useI18n
()
...
...
frontend/src/components/user/profile/ProfilePasswordForm.vue
View file @
d5819181
<
template
>
<div
class=
"card"
>
<div
class=
"border-b border-gray-100 px-6 py-4 dark:border-dark-700"
>
<div
:class=
"props.embedded ? 'space-y-4' : 'card'"
>
<div
v-if=
"!props.embedded"
class=
"border-b border-gray-100 px-6 py-4 dark:border-dark-700"
>
<h2
class=
"text-lg font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
profile.changePassword
'
)
}}
</h2>
</div>
<div
class=
"px-6 py-6"
>
<div
:
class=
"
props.embedded ? '' : '
px-6 py-6
'
"
>
<form
@
submit.prevent=
"handleChangePassword"
class=
"space-y-4"
>
<div
v-if=
"props.embedded"
>
<p
class=
"text-sm font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
profile.changePassword
'
)
}}
</p>
</div>
<div>
<label
for=
"old_password"
class=
"input-label"
>
{{
t
(
'
profile.currentPassword
'
)
}}
...
...
@@ -70,6 +78,11 @@ import { userAPI } from '@/api'
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
const
props
=
withDefaults
(
defineProps
<
{
embedded
?:
boolean
}
>
(),
{
embedded
:
false
,
})
const
loading
=
ref
(
false
)
const
form
=
ref
({
...
...
frontend/src/i18n/locales/en.ts
View file @
d5819181
...
...
@@ -898,6 +898,9 @@ export default {
administrator
:
'
Administrator
'
,
user
:
'
User
'
,
username
:
'
Username
'
,
email
:
'
Email
'
,
status
:
'
Status
'
,
role
:
'
Role
'
,
enterUsername
:
'
Enter username
'
,
editProfile
:
'
Edit Profile
'
,
updateProfile
:
'
Update Profile
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
d5819181
...
...
@@ -902,6 +902,9 @@ export default {
administrator
:
'
管理员
'
,
user
:
'
用户
'
,
username
:
'
用户名
'
,
email
:
'
邮箱
'
,
status
:
'
状态
'
,
role
:
'
角色
'
,
enterUsername
:
'
输入用户名
'
,
editProfile
:
'
编辑个人资料
'
,
updateProfile
:
'
更新资料
'
,
...
...
frontend/src/views/user/ProfileView.vue
View file @
d5819181
...
...
@@ -22,11 +22,7 @@
/>
</div>
<ProfileInfoCard
:user=
"user"
/>
<ProfileAvatarCard
:user=
"user"
/>
<ProfileAccountBindingsCard
<ProfileInfoCard
:user=
"user"
:linuxdo-enabled=
"linuxdoOAuthEnabled"
:oidc-enabled=
"oidcOAuthEnabled"
...
...
@@ -52,9 +48,6 @@
</div>
</div>
</div>
<ProfileEditForm
:initial-username=
"user?.username || ''"
/>
<ProfileBalanceNotifyCard
v-if=
"user && balanceLowNotifyEnabled"
:enabled=
"user.balance_notify_enabled ?? true"
...
...
@@ -63,8 +56,6 @@
:system-default-threshold=
"systemDefaultThreshold"
:user-email=
"user.email"
/>
<ProfilePasswordForm
/>
<ProfileTotpCard
/>
</div>
</AppLayout>
...
...
@@ -77,12 +68,9 @@ import { Icon } from '@/components/icons'
import
StatCard
from
'
@/components/common/StatCard.vue
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
ProfileBalanceNotifyCard
from
'
@/components/user/profile/ProfileBalanceNotifyCard.vue
'
import
ProfileAccountBindingsCard
from
'
@/components/user/profile/ProfileAccountBindingsCard.vue
'
import
ProfileAvatarCard
from
'
@/components/user/profile/ProfileAvatarCard.vue
'
import
ProfileEditForm
from
'
@/components/user/profile/ProfileEditForm.vue
'
import
ProfileInfoCard
from
'
@/components/user/profile/ProfileInfoCard.vue
'
import
ProfilePasswordForm
from
'
@/components/user/profile/ProfilePasswordForm.vue
'
import
ProfileTotpCard
from
'
@/components/user/profile/ProfileTotpCard.vue
'
import
{
isWeChatWebOAuthEnabled
}
from
'
@/api/auth
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
useAuthStore
}
from
'
@/stores/auth
'
import
{
formatDate
}
from
'
@/utils/format
'
...
...
@@ -141,7 +129,7 @@ onMounted(async () => {
balanceLowNotifyEnabled
.
value
=
settings
.
balance_low_notify_enabled
??
false
systemDefaultThreshold
.
value
=
settings
.
balance_low_notify_threshold
??
0
linuxdoOAuthEnabled
.
value
=
settings
.
linuxdo_oauth_enabled
??
false
wechatOAuthEnabled
.
value
=
settings
.
wechat_oa
uth
_e
nabled
??
false
wechatOAuthEnabled
.
value
=
isWeChatWebOA
uth
E
nabled
(
settings
)
wechatOAuthOpenEnabled
.
value
=
typeof
settings
.
wechat_oauth_open_enabled
===
'
boolean
'
?
settings
.
wechat_oauth_open_enabled
:
undefined
...
...
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