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
ed01c599
Commit
ed01c599
authored
Apr 21, 2026
by
IanShaw027
Browse files
feat: track authenticated user activity
parent
422f3449
Changes
10
Hide whitespace changes
Inline
Side-by-side
backend/internal/server/middleware/admin_auth_test.go
View file @
ed01c599
...
...
@@ -7,6 +7,7 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
...
...
@@ -153,6 +154,18 @@ func (s *stubUserRepo) Delete(ctx context.Context, id int64) error {
panic
(
"unexpected Delete call"
)
}
func
(
s
*
stubUserRepo
)
GetUserAvatar
(
ctx
context
.
Context
,
userID
int64
)
(
*
service
.
UserAvatar
,
error
)
{
return
nil
,
nil
}
func
(
s
*
stubUserRepo
)
UpsertUserAvatar
(
ctx
context
.
Context
,
userID
int64
,
input
service
.
UpsertUserAvatarInput
)
(
*
service
.
UserAvatar
,
error
)
{
panic
(
"unexpected UpsertUserAvatar call"
)
}
func
(
s
*
stubUserRepo
)
DeleteUserAvatar
(
ctx
context
.
Context
,
userID
int64
)
error
{
panic
(
"unexpected DeleteUserAvatar call"
)
}
func
(
s
*
stubUserRepo
)
List
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
)
([]
service
.
User
,
*
pagination
.
PaginationResult
,
error
)
{
panic
(
"unexpected List call"
)
}
...
...
@@ -161,6 +174,18 @@ func (s *stubUserRepo) ListWithFilters(ctx context.Context, params pagination.Pa
panic
(
"unexpected ListWithFilters call"
)
}
func
(
s
*
stubUserRepo
)
GetLatestUsedAtByUserIDs
(
ctx
context
.
Context
,
userIDs
[]
int64
)
(
map
[
int64
]
*
time
.
Time
,
error
)
{
panic
(
"unexpected GetLatestUsedAtByUserIDs call"
)
}
func
(
s
*
stubUserRepo
)
GetLatestUsedAtByUserID
(
ctx
context
.
Context
,
userID
int64
)
(
*
time
.
Time
,
error
)
{
panic
(
"unexpected GetLatestUsedAtByUserID call"
)
}
func
(
s
*
stubUserRepo
)
UpdateUserLastActiveAt
(
ctx
context
.
Context
,
userID
int64
,
activeAt
time
.
Time
)
error
{
panic
(
"unexpected UpdateUserLastActiveAt call"
)
}
func
(
s
*
stubUserRepo
)
UpdateBalance
(
ctx
context
.
Context
,
id
int64
,
amount
float64
)
error
{
panic
(
"unexpected UpdateBalance call"
)
}
...
...
@@ -189,6 +214,10 @@ func (s *stubUserRepo) AddGroupToAllowedGroups(ctx context.Context, userID int64
panic
(
"unexpected AddGroupToAllowedGroups call"
)
}
func
(
s
*
stubUserRepo
)
ListUserAuthIdentities
(
ctx
context
.
Context
,
userID
int64
)
([]
service
.
UserAuthIdentityRecord
,
error
)
{
panic
(
"unexpected ListUserAuthIdentities call"
)
}
func
(
s
*
stubUserRepo
)
UpdateTotpSecret
(
ctx
context
.
Context
,
userID
int64
,
encryptedSecret
*
string
)
error
{
panic
(
"unexpected UpdateTotpSecret call"
)
}
...
...
backend/internal/server/middleware/jwt_auth.go
View file @
ed01c599
package
middleware
import
(
"context"
"errors"
"strings"
...
...
@@ -11,11 +12,19 @@ import (
// NewJWTAuthMiddleware 创建 JWT 认证中间件
func
NewJWTAuthMiddleware
(
authService
*
service
.
AuthService
,
userService
*
service
.
UserService
)
JWTAuthMiddleware
{
return
JWTAuthMiddleware
(
jwtAuth
(
authService
,
userService
))
return
JWTAuthMiddleware
(
jwtAuth
(
authService
,
userService
,
userService
))
}
type
jwtUserReader
interface
{
GetByID
(
ctx
context
.
Context
,
id
int64
)
(
*
service
.
User
,
error
)
}
type
userActivityToucher
interface
{
TouchLastActiveForUser
(
ctx
context
.
Context
,
user
*
service
.
User
)
}
// jwtAuth JWT认证中间件实现
func
jwtAuth
(
authService
*
service
.
AuthService
,
userService
*
service
.
UserService
)
gin
.
HandlerFunc
{
func
jwtAuth
(
authService
*
service
.
AuthService
,
userService
jwtUserReader
,
activityToucher
userActivityToucher
)
gin
.
HandlerFunc
{
return
func
(
c
*
gin
.
Context
)
{
// 从Authorization header中提取token
authHeader
:=
c
.
GetHeader
(
"Authorization"
)
...
...
@@ -73,6 +82,9 @@ func jwtAuth(authService *service.AuthService, userService *service.UserService)
Concurrency
:
user
.
Concurrency
,
})
c
.
Set
(
string
(
ContextKeyUserRole
),
user
.
Role
)
if
activityToucher
!=
nil
{
activityToucher
.
TouchLastActiveForUser
(
c
.
Request
.
Context
(),
user
)
}
c
.
Next
()
}
...
...
backend/internal/server/middleware/jwt_auth_test.go
View file @
ed01c599
...
...
@@ -9,6 +9,7 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/service"
...
...
@@ -30,6 +31,25 @@ func (r *stubJWTUserRepo) GetByID(_ context.Context, id int64) (*service.User, e
return
u
,
nil
}
func
(
r
*
stubJWTUserRepo
)
GetUserAvatar
(
_
context
.
Context
,
_
int64
)
(
*
service
.
UserAvatar
,
error
)
{
return
nil
,
nil
}
func
(
r
*
stubJWTUserRepo
)
UpdateUserLastActiveAt
(
_
context
.
Context
,
_
int64
,
_
time
.
Time
)
error
{
return
nil
}
type
recordingActivityToucher
struct
{
userIDs
[]
int64
}
func
(
r
*
recordingActivityToucher
)
TouchLastActiveForUser
(
_
context
.
Context
,
user
*
service
.
User
)
{
if
user
==
nil
{
return
}
r
.
userIDs
=
append
(
r
.
userIDs
,
user
.
ID
)
}
// newJWTTestEnv 创建 JWT 认证中间件测试环境。
// 返回 gin.Engine(已注册 JWT 中间件)和 AuthService(用于生成 Token)。
func
newJWTTestEnv
(
users
map
[
int64
]
*
service
.
User
)
(
*
gin
.
Engine
,
*
service
.
AuthService
)
{
...
...
@@ -106,6 +126,45 @@ func TestJWTAuth_ValidToken_LowercaseBearer(t *testing.T) {
require
.
Equal
(
t
,
http
.
StatusOK
,
w
.
Code
)
}
func
TestJWTAuth_ValidToken_TouchesLastActive
(
t
*
testing
.
T
)
{
user
:=
&
service
.
User
{
ID
:
1
,
Email
:
"test@example.com"
,
Role
:
"user"
,
Status
:
service
.
StatusActive
,
Concurrency
:
5
,
TokenVersion
:
1
,
}
gin
.
SetMode
(
gin
.
TestMode
)
cfg
:=
&
config
.
Config
{}
cfg
.
JWT
.
Secret
=
"test-jwt-secret-32bytes-long!!!"
cfg
.
JWT
.
AccessTokenExpireMinutes
=
60
userRepo
:=
&
stubJWTUserRepo
{
users
:
map
[
int64
]
*
service
.
User
{
1
:
user
}}
authSvc
:=
service
.
NewAuthService
(
nil
,
userRepo
,
nil
,
nil
,
cfg
,
nil
,
nil
,
nil
,
nil
,
nil
,
nil
)
userSvc
:=
service
.
NewUserService
(
userRepo
,
nil
,
nil
,
nil
)
toucher
:=
&
recordingActivityToucher
{}
r
:=
gin
.
New
()
r
.
Use
(
jwtAuth
(
authSvc
,
userSvc
,
toucher
))
r
.
GET
(
"/protected"
,
func
(
c
*
gin
.
Context
)
{
c
.
Status
(
http
.
StatusOK
)
})
token
,
err
:=
authSvc
.
GenerateToken
(
user
)
require
.
NoError
(
t
,
err
)
w
:=
httptest
.
NewRecorder
()
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/protected"
,
nil
)
req
.
Header
.
Set
(
"Authorization"
,
"Bearer "
+
token
)
r
.
ServeHTTP
(
w
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
w
.
Code
)
require
.
Equal
(
t
,
[]
int64
{
1
},
toucher
.
userIDs
)
}
func
TestJWTAuth_MissingAuthorizationHeader
(
t
*
testing
.
T
)
{
router
,
_
:=
newJWTTestEnv
(
nil
)
...
...
backend/internal/service/admin_service_apikey_test.go
View file @
ed01c599
...
...
@@ -88,6 +88,9 @@ func (s *userRepoStubForGroupUpdate) GetLatestUsedAtByUserIDs(context.Context, [
func
(
s
*
userRepoStubForGroupUpdate
)
GetLatestUsedAtByUserID
(
context
.
Context
,
int64
)
(
*
time
.
Time
,
error
)
{
panic
(
"unexpected"
)
}
func
(
s
*
userRepoStubForGroupUpdate
)
UpdateUserLastActiveAt
(
context
.
Context
,
int64
,
time
.
Time
)
error
{
panic
(
"unexpected"
)
}
func
(
s
*
userRepoStubForGroupUpdate
)
RemoveGroupFromUserAllowedGroups
(
context
.
Context
,
int64
,
int64
)
error
{
panic
(
"unexpected"
)
}
...
...
backend/internal/service/admin_service_delete_test.go
View file @
ed01c599
...
...
@@ -107,6 +107,18 @@ func (s *userRepoStub) ListWithFilters(ctx context.Context, params pagination.Pa
panic
(
"unexpected ListWithFilters call"
)
}
func
(
s
*
userRepoStub
)
GetLatestUsedAtByUserIDs
(
ctx
context
.
Context
,
userIDs
[]
int64
)
(
map
[
int64
]
*
time
.
Time
,
error
)
{
panic
(
"unexpected GetLatestUsedAtByUserIDs call"
)
}
func
(
s
*
userRepoStub
)
GetLatestUsedAtByUserID
(
ctx
context
.
Context
,
userID
int64
)
(
*
time
.
Time
,
error
)
{
panic
(
"unexpected GetLatestUsedAtByUserID call"
)
}
func
(
s
*
userRepoStub
)
UpdateUserLastActiveAt
(
ctx
context
.
Context
,
userID
int64
,
activeAt
time
.
Time
)
error
{
panic
(
"unexpected UpdateUserLastActiveAt call"
)
}
func
(
s
*
userRepoStub
)
UpdateBalance
(
ctx
context
.
Context
,
id
int64
,
amount
float64
)
error
{
panic
(
"unexpected UpdateBalance call"
)
}
...
...
backend/internal/service/admin_service_email_identity_sync_test.go
View file @
ed01c599
...
...
@@ -97,6 +97,10 @@ func (s *emailSyncRepoStub) GetLatestUsedAtByUserID(context.Context, int64) (*ti
return
nil
,
nil
}
func
(
s
*
emailSyncRepoStub
)
UpdateUserLastActiveAt
(
context
.
Context
,
int64
,
time
.
Time
)
error
{
return
nil
}
func
(
s
*
emailSyncRepoStub
)
UpdateBalance
(
context
.
Context
,
int64
,
float64
)
error
{
return
nil
}
func
(
s
*
emailSyncRepoStub
)
DeductBalance
(
context
.
Context
,
int64
,
float64
)
error
{
return
nil
}
...
...
backend/internal/service/user_service.go
View file @
ed01c599
...
...
@@ -19,10 +19,13 @@ import (
"log/slog"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"time"
xdraw
"golang.org/x/image/draw"
"golang.org/x/sync/singleflight"
)
var
(
...
...
@@ -47,6 +50,8 @@ const (
notifyCodeUserRateWindow
=
10
*
time
.
Minute
defaultUserIdentityRedirect
=
"/settings/profile"
userLastActiveMinTouch
=
10
*
time
.
Minute
userLastActiveFailBackoff
=
30
*
time
.
Second
)
var
(
...
...
@@ -82,6 +87,7 @@ type UserRepository interface {
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
)
UpdateUserLastActiveAt
(
ctx
context
.
Context
,
userID
int64
,
activeAt
time
.
Time
)
error
UpdateBalance
(
ctx
context
.
Context
,
id
int64
,
amount
float64
)
error
DeductBalance
(
ctx
context
.
Context
,
id
int64
,
amount
float64
)
error
...
...
@@ -192,6 +198,8 @@ type UserService struct {
settingRepo
SettingRepository
authCacheInvalidator
APIKeyAuthCacheInvalidator
billingCache
BillingCache
lastActiveTouchL1
sync
.
Map
lastActiveTouchSF
singleflight
.
Group
}
// NewUserService 创建用户服务实例
...
...
@@ -788,6 +796,66 @@ func (s *UserService) GetByID(ctx context.Context, id int64) (*User, error) {
return
user
,
nil
}
// TouchLastActive 通过防抖更新 users.last_active_at,减少鉴权热路径写放大。
// 该操作为尽力而为,不应中断正常请求。
func
(
s
*
UserService
)
TouchLastActive
(
ctx
context
.
Context
,
userID
int64
)
{
if
s
==
nil
||
s
.
userRepo
==
nil
||
userID
<=
0
{
return
}
user
,
err
:=
s
.
userRepo
.
GetByID
(
ctx
,
userID
)
if
err
!=
nil
{
slog
.
Debug
(
"skip touch user last active after load failure"
,
"user_id"
,
userID
,
"error"
,
err
)
return
}
s
.
TouchLastActiveForUser
(
ctx
,
user
)
}
// TouchLastActiveForUser 使用已加载的用户信息更新 last_active_at,避免重复读取数据库。
func
(
s
*
UserService
)
TouchLastActiveForUser
(
ctx
context
.
Context
,
user
*
User
)
{
if
s
==
nil
||
s
.
userRepo
==
nil
||
user
==
nil
||
user
.
ID
<=
0
{
return
}
now
:=
time
.
Now
()
if
userLastActiveFresh
(
user
.
LastActiveAt
,
now
)
{
return
}
if
v
,
ok
:=
s
.
lastActiveTouchL1
.
Load
(
user
.
ID
);
ok
{
if
nextAllowedAt
,
ok
:=
v
.
(
time
.
Time
);
ok
&&
now
.
Before
(
nextAllowedAt
)
{
return
}
}
_
,
err
,
_
:=
s
.
lastActiveTouchSF
.
Do
(
strconv
.
FormatInt
(
user
.
ID
,
10
),
func
()
(
any
,
error
)
{
latest
:=
time
.
Now
()
if
v
,
ok
:=
s
.
lastActiveTouchL1
.
Load
(
user
.
ID
);
ok
{
if
nextAllowedAt
,
ok
:=
v
.
(
time
.
Time
);
ok
&&
latest
.
Before
(
nextAllowedAt
)
{
return
nil
,
nil
}
}
if
userLastActiveFresh
(
user
.
LastActiveAt
,
latest
)
{
return
nil
,
nil
}
if
err
:=
s
.
userRepo
.
UpdateUserLastActiveAt
(
ctx
,
user
.
ID
,
latest
);
err
!=
nil
{
s
.
lastActiveTouchL1
.
Store
(
user
.
ID
,
latest
.
Add
(
userLastActiveFailBackoff
))
return
nil
,
fmt
.
Errorf
(
"touch user last active: %w"
,
err
)
}
s
.
lastActiveTouchL1
.
Store
(
user
.
ID
,
latest
.
Add
(
userLastActiveMinTouch
))
return
nil
,
nil
})
if
err
!=
nil
{
slog
.
Warn
(
"touch user last active failed"
,
"user_id"
,
user
.
ID
,
"error"
,
err
)
}
}
func
userLastActiveFresh
(
lastActiveAt
*
time
.
Time
,
now
time
.
Time
)
bool
{
if
lastActiveAt
==
nil
{
return
false
}
return
now
.
Before
(
lastActiveAt
.
Add
(
userLastActiveMinTouch
))
}
func
(
s
*
UserService
)
hydrateUserAvatar
(
ctx
context
.
Context
,
user
*
User
)
error
{
if
s
==
nil
||
s
.
userRepo
==
nil
||
user
==
nil
||
user
.
ID
==
0
{
return
nil
...
...
backend/internal/service/user_service_test.go
View file @
ed01c599
...
...
@@ -23,18 +23,21 @@ import (
// --- mock: UserRepository ---
type
mockUserRepo
struct
{
updateBalanceErr
error
updateBalanceFn
func
(
ctx
context
.
Context
,
id
int64
,
amount
float64
)
error
getByIDUser
*
User
getByIDErr
error
updateFn
func
(
ctx
context
.
Context
,
user
*
User
)
error
updateCalls
int
upsertAvatarFn
func
(
ctx
context
.
Context
,
userID
int64
,
input
UpsertUserAvatarInput
)
(
*
UserAvatar
,
error
)
upsertAvatarArgs
[]
UpsertUserAvatarInput
deleteAvatarFn
func
(
ctx
context
.
Context
,
userID
int64
)
error
deleteAvatarIDs
[]
int64
getAvatarFn
func
(
ctx
context
.
Context
,
userID
int64
)
(
*
UserAvatar
,
error
)
txCalls
int
updateBalanceErr
error
updateBalanceFn
func
(
ctx
context
.
Context
,
id
int64
,
amount
float64
)
error
getByIDUser
*
User
getByIDErr
error
updateLastActiveErr
error
updateLastActiveUserIDs
[]
int64
updateLastActiveAt
[]
time
.
Time
updateFn
func
(
ctx
context
.
Context
,
user
*
User
)
error
updateCalls
int
upsertAvatarFn
func
(
ctx
context
.
Context
,
userID
int64
,
input
UpsertUserAvatarInput
)
(
*
UserAvatar
,
error
)
upsertAvatarArgs
[]
UpsertUserAvatarInput
deleteAvatarFn
func
(
ctx
context
.
Context
,
userID
int64
)
error
deleteAvatarIDs
[]
int64
getAvatarFn
func
(
ctx
context
.
Context
,
userID
int64
)
(
*
UserAvatar
,
error
)
txCalls
int
}
type
mockUserRepoTxKey
struct
{}
...
...
@@ -144,6 +147,11 @@ func (m *mockUserRepo) UpdateBalance(ctx context.Context, id int64, amount float
}
return
m
.
updateBalanceErr
}
func
(
m
*
mockUserRepo
)
UpdateUserLastActiveAt
(
_
context
.
Context
,
userID
int64
,
activeAt
time
.
Time
)
error
{
m
.
updateLastActiveUserIDs
=
append
(
m
.
updateLastActiveUserIDs
,
userID
)
m
.
updateLastActiveAt
=
append
(
m
.
updateLastActiveAt
,
activeAt
)
return
m
.
updateLastActiveErr
}
func
(
m
*
mockUserRepo
)
DeductBalance
(
context
.
Context
,
int64
,
float64
)
error
{
return
nil
}
func
(
m
*
mockUserRepo
)
UpdateConcurrency
(
context
.
Context
,
int64
,
int
)
error
{
return
nil
}
func
(
m
*
mockUserRepo
)
ExistsByEmail
(
context
.
Context
,
string
)
(
bool
,
error
)
{
return
false
,
nil
}
...
...
@@ -288,6 +296,39 @@ func TestUpdateBalance_CacheFailure_DoesNotAffectReturn(t *testing.T) {
},
2
*
time
.
Second
,
10
*
time
.
Millisecond
,
"即使失败也应调用 InvalidateUserBalance"
)
}
func
TestTouchLastActive_UpdatesWhenStale
(
t
*
testing
.
T
)
{
stale
:=
time
.
Now
()
.
Add
(
-
11
*
time
.
Minute
)
repo
:=
&
mockUserRepo
{
getByIDUser
:
&
User
{
ID
:
42
,
LastActiveAt
:
&
stale
,
},
}
svc
:=
NewUserService
(
repo
,
nil
,
nil
,
nil
)
svc
.
TouchLastActive
(
context
.
Background
(),
42
)
require
.
Equal
(
t
,
[]
int64
{
42
},
repo
.
updateLastActiveUserIDs
)
require
.
Len
(
t
,
repo
.
updateLastActiveAt
,
1
)
require
.
WithinDuration
(
t
,
time
.
Now
(),
repo
.
updateLastActiveAt
[
0
],
2
*
time
.
Second
)
}
func
TestTouchLastActive_SkipsWhenRecent
(
t
*
testing
.
T
)
{
recent
:=
time
.
Now
()
.
Add
(
-
time
.
Minute
)
repo
:=
&
mockUserRepo
{
getByIDUser
:
&
User
{
ID
:
42
,
LastActiveAt
:
&
recent
,
},
}
svc
:=
NewUserService
(
repo
,
nil
,
nil
,
nil
)
svc
.
TouchLastActive
(
context
.
Background
(),
42
)
require
.
Empty
(
t
,
repo
.
updateLastActiveUserIDs
)
require
.
Empty
(
t
,
repo
.
updateLastActiveAt
)
}
func
TestUpdateBalance_RepoError_ReturnsError
(
t
*
testing
.
T
)
{
repo
:=
&
mockUserRepo
{
updateBalanceErr
:
errors
.
New
(
"database error"
)}
cache
:=
&
mockBillingCache
{}
...
...
frontend/src/views/admin/UsersView.vue
View file @
ed01c599
...
...
@@ -455,12 +455,6 @@
<span
class=
"text-sm text-gray-500 dark:text-dark-400"
>
{{
formatDateTime
(
value
)
}}
</span>
</
template
>
<
template
#cell-last_login_at=
"{ value }"
>
<span
class=
"text-sm text-gray-500 dark:text-dark-400"
>
{{
value
?
formatDateTime
(
value
)
:
'
-
'
}}
</span>
</
template
>
<
template
#cell-last_used_at=
"{ value }"
>
<span
class=
"text-sm text-gray-500 dark:text-dark-400"
>
{{
value
?
formatDateTime
(
value
)
:
'
-
'
}}
...
...
@@ -718,7 +712,6 @@ const allColumns = computed<Column[]>(() => [
{
key
:
'
usage
'
,
label
:
t
(
'
admin.users.columns.usage
'
),
sortable
:
false
},
{
key
:
'
concurrency
'
,
label
:
t
(
'
admin.users.columns.concurrency
'
),
sortable
:
true
},
{
key
:
'
status
'
,
label
:
t
(
'
admin.users.columns.status
'
),
sortable
:
true
},
{
key
:
'
last_login_at
'
,
label
:
t
(
'
admin.users.columns.lastLogin
'
),
sortable
:
true
},
{
key
:
'
last_used_at
'
,
label
:
t
(
'
admin.users.columns.lastUsed
'
),
sortable
:
true
},
{
key
:
'
last_active_at
'
,
label
:
t
(
'
admin.users.columns.lastActive
'
),
sortable
:
true
},
{
key
:
'
created_at
'
,
label
:
t
(
'
admin.users.columns.created
'
),
sortable
:
true
},
...
...
@@ -735,7 +728,9 @@ const toggleableColumns = computed(() =>
const
hiddenColumns
=
reactive
<
Set
<
string
>>
(
new
Set
())
// Default hidden columns (columns hidden by default on first load)
const
DEFAULT_HIDDEN_COLUMNS
=
[
'
notes
'
,
'
groups
'
,
'
subscriptions
'
,
'
usage
'
,
'
concurrency
'
,
'
last_login_at
'
,
'
last_active_at
'
]
const
DEFAULT_HIDDEN_COLUMNS
=
[
'
notes
'
,
'
groups
'
,
'
subscriptions
'
,
'
usage
'
,
'
concurrency
'
]
const
REMOVED_COLUMNS
=
new
Set
([
'
last_login_at
'
])
const
FORCED_VISIBLE_COLUMNS
=
new
Set
([
'
last_active_at
'
])
// localStorage key for column settings
const
HIDDEN_COLUMNS_KEY
=
'
user-hidden-columns
'
...
...
@@ -746,7 +741,9 @@ const loadSavedColumns = () => {
const
saved
=
localStorage
.
getItem
(
HIDDEN_COLUMNS_KEY
)
if
(
saved
)
{
const
parsed
=
JSON
.
parse
(
saved
)
as
string
[]
parsed
.
forEach
(
key
=>
hiddenColumns
.
add
(
key
))
parsed
.
filter
(
key
=>
!
REMOVED_COLUMNS
.
has
(
key
)
&&
!
FORCED_VISIBLE_COLUMNS
.
has
(
key
))
.
forEach
(
key
=>
hiddenColumns
.
add
(
key
))
}
else
{
// Use default hidden columns on first load
DEFAULT_HIDDEN_COLUMNS
.
forEach
(
key
=>
hiddenColumns
.
add
(
key
))
...
...
@@ -808,7 +805,7 @@ const searchQuery = ref('')
const
USER_SORT_STORAGE_KEY
=
'
admin-users-table-sort
'
const
loadInitialSortState
=
():
{
sort_by
:
string
;
sort_order
:
'
asc
'
|
'
desc
'
}
=>
{
const
fallback
=
{
sort_by
:
'
created_at
'
,
sort_order
:
'
desc
'
as
'
asc
'
|
'
desc
'
}
const
sortable
=
new
Set
([
'
email
'
,
'
id
'
,
'
username
'
,
'
role
'
,
'
balance
'
,
'
concurrency
'
,
'
status
'
,
'
last_login_at
'
,
'
last_used_at
'
,
'
last_active_at
'
,
'
created_at
'
])
const
sortable
=
new
Set
([
'
email
'
,
'
id
'
,
'
username
'
,
'
role
'
,
'
balance
'
,
'
concurrency
'
,
'
status
'
,
'
last_used_at
'
,
'
last_active_at
'
,
'
created_at
'
])
try
{
const
raw
=
localStorage
.
getItem
(
USER_SORT_STORAGE_KEY
)
if
(
!
raw
)
return
fallback
...
...
frontend/src/views/admin/__tests__/UsersView.spec.ts
View file @
ed01c599
...
...
@@ -113,7 +113,7 @@ describe('admin UsersView', () => {
getBatchUserAttributes
.
mockResolvedValue
({
values
:
{}
})
})
it
(
'
shows
last_used_at column
and requests last_used_at sort
'
,
async
()
=>
{
it
(
'
shows
active and used activity columns, hides last_login_at,
and requests last_used_at sort
'
,
async
()
=>
{
const
wrapper
=
mount
(
UsersView
,
{
global
:
{
stubs
:
{
...
...
@@ -144,7 +144,10 @@ describe('admin UsersView', () => {
await
flushPromises
()
expect
(
wrapper
.
get
(
'
[data-test="columns"]
'
).
text
()).
toContain
(
'
last_used_at
'
)
const
columns
=
wrapper
.
get
(
'
[data-test="columns"]
'
).
text
()
expect
(
columns
).
toContain
(
'
last_used_at
'
)
expect
(
columns
).
toContain
(
'
last_active_at
'
)
expect
(
columns
).
not
.
toContain
(
'
last_login_at
'
)
await
wrapper
.
get
(
'
[data-test="sort-last-used"]
'
).
trigger
(
'
click
'
)
await
flushPromises
()
...
...
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