mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
Merge pull request #3140 from DaydreamCoding/feat/admin-users-apikey-group-filter
feat(admin): /admin/users 新增按用户 API Key 所在分组过滤
This commit is contained in:
@@ -264,6 +264,10 @@ func (s *stubAdminService) GetAllGroupsByPlatform(ctx context.Context, platform
|
||||
return s.groups, nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) GetAllGroupsIncludingInactive(ctx context.Context) ([]service.Group, error) {
|
||||
return s.groups, nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) GetGroup(ctx context.Context, id int64) (*service.Group, error) {
|
||||
group := service.Group{ID: id, Name: "group", Status: service.StatusActive}
|
||||
return &group, nil
|
||||
|
||||
@@ -196,15 +196,21 @@ func (h *GroupHandler) List(c *gin.Context) {
|
||||
response.Paginated(c, outGroups, total, page, pageSize)
|
||||
}
|
||||
|
||||
// GetAll handles getting all active groups without pagination
|
||||
// GetAll handles getting all active groups without pagination.
|
||||
// Pass ?include_inactive=true to also include disabled groups (used by the
|
||||
// API Key group filter, which needs to surface groups that still have API keys
|
||||
// bound to them even after the group is disabled).
|
||||
// GET /api/v1/admin/groups/all
|
||||
func (h *GroupHandler) GetAll(c *gin.Context) {
|
||||
platform := c.Query("platform")
|
||||
includeInactive := c.Query("include_inactive") == "true"
|
||||
|
||||
var groups []service.Group
|
||||
var err error
|
||||
|
||||
if platform != "" {
|
||||
if includeInactive {
|
||||
groups, err = h.adminService.GetAllGroupsIncludingInactive(c.Request.Context())
|
||||
} else if platform != "" {
|
||||
groups, err = h.adminService.GetAllGroupsByPlatform(c.Request.Context(), platform)
|
||||
} else {
|
||||
groups, err = h.adminService.GetAllGroups(c.Request.Context())
|
||||
|
||||
@@ -106,6 +106,7 @@ type BindUserAuthIdentityChannelRequest struct {
|
||||
// - search: search in email, username
|
||||
// - attr[{id}]: filter by custom attribute value, e.g. attr[1]=company
|
||||
// - group_name: fuzzy filter by allowed group name
|
||||
// - api_key_group_id: filter by the exact group bound to the user's API keys
|
||||
func (h *UserHandler) List(c *gin.Context) {
|
||||
page, pageSize := response.ParsePagination(c)
|
||||
|
||||
@@ -123,6 +124,11 @@ func (h *UserHandler) List(c *gin.Context) {
|
||||
GroupName: strings.TrimSpace(c.Query("group_name")),
|
||||
Attributes: parseAttributeFilters(c),
|
||||
}
|
||||
if raw := strings.TrimSpace(c.Query("api_key_group_id")); raw != "" {
|
||||
if id, parseErr := strconv.ParseInt(raw, 10, 64); parseErr == nil && id > 0 {
|
||||
filters.APIKeyGroupID = id
|
||||
}
|
||||
}
|
||||
sortBy := c.DefaultQuery("sort_by", "created_at")
|
||||
sortOrder := c.DefaultQuery("sort_order", "desc")
|
||||
if raw, ok := c.GetQuery("include_subscriptions"); ok {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// listUsersFilterStub 捕获传入 ListUsers 的 filters,其余 AdminService 方法走 baseline stub。
|
||||
type listUsersFilterStub struct {
|
||||
service.AdminService
|
||||
captured service.UserListFilters
|
||||
}
|
||||
|
||||
func (s *listUsersFilterStub) ListUsers(_ context.Context, _, _ int, filters service.UserListFilters, _, _ string) ([]service.User, int64, error) {
|
||||
s.captured = filters
|
||||
return []service.User{}, 0, nil
|
||||
}
|
||||
|
||||
func TestAdminUserList_ParsesAPIKeyGroupID(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
cases := []struct {
|
||||
name string
|
||||
query string
|
||||
want int64
|
||||
}{
|
||||
{"valid id", "?api_key_group_id=42", 42},
|
||||
{"missing", "", 0},
|
||||
{"zero ignored", "?api_key_group_id=0", 0},
|
||||
{"negative ignored", "?api_key_group_id=-3", 0},
|
||||
{"non-numeric ignored", "?api_key_group_id=abc", 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
stub := &listUsersFilterStub{AdminService: newStubAdminService()}
|
||||
r := gin.New()
|
||||
h := NewUserHandler(stub, nil, nil, nil)
|
||||
r.GET("/admin/users", h.List)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, "/admin/users"+tc.query, nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
require.Equal(t, tc.want, stub.captured.APIKeyGroupID)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -462,6 +462,17 @@ func (r *userRepository) ListWithFilters(ctx context.Context, params pagination.
|
||||
))
|
||||
}
|
||||
|
||||
if filters.APIKeyGroupID > 0 {
|
||||
// 按"API Key 实际绑定的分组"过滤:用户只要有任意一个未软删除的 API Key
|
||||
// 绑定到该分组即命中(EXISTS 语义)。
|
||||
// 注意:SoftDeleteMixin 的拦截器不会自动下沉到 HasAPIKeysWith 子查询,
|
||||
// 必须显式加 apikey.DeletedAtIsNil(),否则已软删除的 key 会污染过滤结果。
|
||||
q = q.Where(dbuser.HasAPIKeysWith(
|
||||
apikey.GroupIDEQ(filters.APIKeyGroupID),
|
||||
apikey.DeletedAtIsNil(),
|
||||
))
|
||||
}
|
||||
|
||||
// If attribute filters are specified, we need to filter by user IDs first
|
||||
var allowedUserIDs []int64
|
||||
if len(filters.Attributes) > 0 {
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
//go:build integration
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type UserRepoAPIKeyGroupFilterSuite struct {
|
||||
suite.Suite
|
||||
ctx context.Context
|
||||
client *dbent.Client
|
||||
repo *userRepository
|
||||
}
|
||||
|
||||
func (s *UserRepoAPIKeyGroupFilterSuite) SetupTest() {
|
||||
s.ctx = context.Background()
|
||||
s.client = testEntClient(s.T())
|
||||
s.repo = newUserRepositoryWithSQL(s.client, integrationDB)
|
||||
// api_keys 必须先于 users 清理(外键);groups 也清理避免跨用例串扰。
|
||||
_, _ = integrationDB.ExecContext(s.ctx, "DELETE FROM api_keys")
|
||||
_, _ = integrationDB.ExecContext(s.ctx, "DELETE FROM user_allowed_groups")
|
||||
_, _ = integrationDB.ExecContext(s.ctx, "DELETE FROM user_subscriptions")
|
||||
_, _ = integrationDB.ExecContext(s.ctx, "DELETE FROM users")
|
||||
_, _ = integrationDB.ExecContext(s.ctx, "DELETE FROM groups")
|
||||
}
|
||||
|
||||
func TestUserRepoAPIKeyGroupFilterSuite(t *testing.T) {
|
||||
suite.Run(t, new(UserRepoAPIKeyGroupFilterSuite))
|
||||
}
|
||||
|
||||
func (s *UserRepoAPIKeyGroupFilterSuite) mustCreateUser(email string) *service.User {
|
||||
s.T().Helper()
|
||||
u := &service.User{
|
||||
Email: email,
|
||||
PasswordHash: "test-password-hash",
|
||||
Role: service.RoleUser,
|
||||
Status: service.StatusActive,
|
||||
Concurrency: 5,
|
||||
}
|
||||
s.Require().NoError(s.repo.Create(s.ctx, u), "create user")
|
||||
return u
|
||||
}
|
||||
|
||||
func (s *UserRepoAPIKeyGroupFilterSuite) mustCreateGroup(name string) *dbent.Group {
|
||||
s.T().Helper()
|
||||
g, err := s.client.Group.Create().
|
||||
SetName(name).
|
||||
SetStatus(service.StatusActive).
|
||||
Save(s.ctx)
|
||||
s.Require().NoError(err, "create group")
|
||||
return g
|
||||
}
|
||||
|
||||
func (s *UserRepoAPIKeyGroupFilterSuite) mustCreateAPIKey(userID int64, key, name string, groupID *int64) *dbent.APIKey {
|
||||
s.T().Helper()
|
||||
create := s.client.APIKey.Create().
|
||||
SetUserID(userID).
|
||||
SetKey(key).
|
||||
SetName(name)
|
||||
if groupID != nil {
|
||||
create = create.SetGroupID(*groupID)
|
||||
}
|
||||
ak, err := create.Save(s.ctx)
|
||||
s.Require().NoError(err, "create api key")
|
||||
return ak
|
||||
}
|
||||
|
||||
func (s *UserRepoAPIKeyGroupFilterSuite) ids(users []service.User) []int64 {
|
||||
out := make([]int64, len(users))
|
||||
for i := range users {
|
||||
out[i] = users[i].ID
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *UserRepoAPIKeyGroupFilterSuite) listByAPIKeyGroup(groupID int64) []service.User {
|
||||
s.T().Helper()
|
||||
users, _, err := s.repo.ListWithFilters(
|
||||
s.ctx,
|
||||
pagination.PaginationParams{Page: 1, PageSize: 50},
|
||||
service.UserListFilters{APIKeyGroupID: groupID},
|
||||
)
|
||||
s.Require().NoError(err, "ListWithFilters")
|
||||
return users
|
||||
}
|
||||
|
||||
// 命中:拥有绑定到该分组 API Key 的用户出现,绑定到其它分组的不出现。
|
||||
func (s *UserRepoAPIKeyGroupFilterSuite) TestFiltersUsersByAPIKeyGroup() {
|
||||
g := s.mustCreateGroup("grp-target")
|
||||
other := s.mustCreateGroup("grp-other")
|
||||
hit := s.mustCreateUser("hit@test.com")
|
||||
miss := s.mustCreateUser("miss@test.com")
|
||||
s.mustCreateAPIKey(hit.ID, "sk-hit", "K", &g.ID)
|
||||
s.mustCreateAPIKey(miss.ID, "sk-miss", "K", &other.ID)
|
||||
|
||||
s.Require().Equal([]int64{hit.ID}, s.ids(s.listByAPIKeyGroup(g.ID)))
|
||||
}
|
||||
|
||||
// 软删除的 API Key 不应命中(核心:软删除不会自动下沉到子查询,靠 DeletedAtIsNil 排除)。
|
||||
func (s *UserRepoAPIKeyGroupFilterSuite) TestSoftDeletedAPIKeyExcluded() {
|
||||
g := s.mustCreateGroup("grp-soft")
|
||||
u := s.mustCreateUser("soft@test.com")
|
||||
ak := s.mustCreateAPIKey(u.ID, "sk-soft", "K", &g.ID)
|
||||
// 软删除该 key:SoftDeleteMixin 的 Hook 把 Delete 转为 UPDATE deleted_at。
|
||||
s.Require().NoError(s.client.APIKey.DeleteOne(ak).Exec(s.ctx), "soft delete api key")
|
||||
|
||||
s.Require().Empty(s.listByAPIKeyGroup(g.ID), "user with only a soft-deleted key must not match")
|
||||
}
|
||||
|
||||
// 多 Key:用户有多个 key,仅一个绑该分组 → 命中且只返回一条(EXISTS/去重)。
|
||||
func (s *UserRepoAPIKeyGroupFilterSuite) TestMultipleKeysAnyMatchDedup() {
|
||||
g := s.mustCreateGroup("grp-multi")
|
||||
other := s.mustCreateGroup("grp-multi-other")
|
||||
u := s.mustCreateUser("multi@test.com")
|
||||
s.mustCreateAPIKey(u.ID, "sk-m1", "K1", &other.ID)
|
||||
s.mustCreateAPIKey(u.ID, "sk-m2", "K2", &g.ID)
|
||||
s.mustCreateAPIKey(u.ID, "sk-m3", "K3", nil) // 无分组
|
||||
|
||||
s.Require().Equal([]int64{u.ID}, s.ids(s.listByAPIKeyGroup(g.ID)))
|
||||
}
|
||||
|
||||
// 叠加过滤:api_key_group_id 与 status 同时指定时取交集——只返回同时满足两者的用户。
|
||||
func (s *UserRepoAPIKeyGroupFilterSuite) TestAPIKeyGroupAndStatusFilter() {
|
||||
g := s.mustCreateGroup("grp-combined")
|
||||
|
||||
// active 用户,key 绑 target 分组 → 应命中
|
||||
active := s.mustCreateUser("active-hit@test.com")
|
||||
s.mustCreateAPIKey(active.ID, "sk-active", "K", &g.ID)
|
||||
|
||||
// disabled 用户,key 也绑 target 分组 → 只用 group 过滤会命中,但 status=active 后排除
|
||||
disabled := s.mustCreateUser("disabled-hit@test.com")
|
||||
s.mustCreateAPIKey(disabled.ID, "sk-disabled", "K2", &g.ID)
|
||||
_, err := s.client.User.UpdateOneID(disabled.ID).SetStatus(service.StatusDisabled).Save(s.ctx)
|
||||
s.Require().NoError(err, "disable user")
|
||||
|
||||
// active 用户,key 绑其它分组 → group 过滤排除
|
||||
other := s.mustCreateGroup("grp-combined-other")
|
||||
miss := s.mustCreateUser("active-miss@test.com")
|
||||
s.mustCreateAPIKey(miss.ID, "sk-miss", "K3", &other.ID)
|
||||
|
||||
users, _, err := s.repo.ListWithFilters(
|
||||
s.ctx,
|
||||
pagination.PaginationParams{Page: 1, PageSize: 50},
|
||||
service.UserListFilters{
|
||||
APIKeyGroupID: g.ID,
|
||||
Status: service.StatusActive,
|
||||
},
|
||||
)
|
||||
s.Require().NoError(err)
|
||||
s.Require().Equal([]int64{active.ID}, s.ids(users), "only active user with matching key group should match")
|
||||
}
|
||||
|
||||
// 缺省(APIKeyGroupID=0)不过滤:所有用户都返回。
|
||||
func (s *UserRepoAPIKeyGroupFilterSuite) TestZeroGroupIDNoFilter() {
|
||||
g := s.mustCreateGroup("grp-zero")
|
||||
u1 := s.mustCreateUser("z1@test.com")
|
||||
u2 := s.mustCreateUser("z2@test.com")
|
||||
s.mustCreateAPIKey(u1.ID, "sk-z1", "K", &g.ID)
|
||||
|
||||
users, _, err := s.repo.ListWithFilters(
|
||||
s.ctx,
|
||||
pagination.PaginationParams{Page: 1, PageSize: 50},
|
||||
service.UserListFilters{APIKeyGroupID: 0},
|
||||
)
|
||||
s.Require().NoError(err)
|
||||
s.Require().ElementsMatch([]int64{u1.ID, u2.ID}, s.ids(users))
|
||||
}
|
||||
@@ -52,6 +52,9 @@ type AdminService interface {
|
||||
ListGroups(ctx context.Context, page, pageSize int, platform, status, search string, isExclusive *bool, sortBy, sortOrder string) ([]Group, int64, error)
|
||||
GetAllGroups(ctx context.Context) ([]Group, error)
|
||||
GetAllGroupsByPlatform(ctx context.Context, platform string) ([]Group, error)
|
||||
// GetAllGroupsIncludingInactive returns all groups regardless of status (active + disabled),
|
||||
// ordered by sort_order then id. Used by the API Key group filter dropdown.
|
||||
GetAllGroupsIncludingInactive(ctx context.Context) ([]Group, error)
|
||||
GetGroup(ctx context.Context, id int64) (*Group, error)
|
||||
GetGroupModelsListCandidates(ctx context.Context, id int64, platform string) ([]string, error)
|
||||
CreateGroup(ctx context.Context, input *CreateGroupInput) (*Group, error)
|
||||
@@ -1701,6 +1704,13 @@ func (s *adminServiceImpl) GetAllGroupsByPlatform(ctx context.Context, platform
|
||||
return s.groupRepo.ListActiveByPlatform(ctx, platform)
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) GetAllGroupsIncludingInactive(ctx context.Context) ([]Group, error) {
|
||||
// ListWithFilters with empty status = no status filter, so active + disabled groups are returned.
|
||||
// PageSize 10000 is intentionally large; group count is O(dozens) in practice.
|
||||
groups, _, err := s.groupRepo.ListWithFilters(ctx, pagination.PaginationParams{Page: 1, PageSize: 10000}, "", "", "", nil)
|
||||
return groups, err
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) GetGroup(ctx context.Context, id int64) (*Group, error) {
|
||||
return s.groupRepo.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
@@ -65,11 +65,15 @@ var (
|
||||
|
||||
// UserListFilters contains all filter options for listing users
|
||||
type UserListFilters struct {
|
||||
Status string // User status filter
|
||||
Role string // User role filter
|
||||
Search string // Search in email, username
|
||||
GroupName string // Filter by allowed group name (fuzzy match)
|
||||
Attributes map[int64]string // Custom attribute filters: attributeID -> value
|
||||
Status string // User status filter
|
||||
Role string // User role filter
|
||||
Search string // Search in email, username
|
||||
GroupName string // Filter by allowed group name (fuzzy match)
|
||||
// APIKeyGroupID filters users who own at least one non-soft-deleted API key
|
||||
// bound to this group (api_keys.group_id). 0 = no filter. Covers all three
|
||||
// group types since it matches the key's group directly, not allowed_groups.
|
||||
APIKeyGroupID int64
|
||||
Attributes map[int64]string // Custom attribute filters: attributeID -> value
|
||||
// IncludeSubscriptions controls whether ListWithFilters should load active subscriptions.
|
||||
// For large datasets this can be expensive; admin list pages should enable it on demand.
|
||||
// nil means not specified (default: load subscriptions for backward compatibility).
|
||||
|
||||
@@ -57,6 +57,17 @@ export async function getAll(platform?: GroupPlatform): Promise<AdminGroup[]> {
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ALL groups including disabled ones — used by the API Key group filter so
|
||||
* that admins can filter users whose keys are still bound to a now-disabled group.
|
||||
*/
|
||||
export async function getAllIncludingInactive(): Promise<AdminGroup[]> {
|
||||
const { data } = await apiClient.get<AdminGroup[]>('/admin/groups/all', {
|
||||
params: { include_inactive: true }
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active groups by platform
|
||||
* @param platform - Platform to filter by
|
||||
@@ -322,6 +333,7 @@ export const groupsAPI = {
|
||||
list,
|
||||
getAll,
|
||||
getByPlatform,
|
||||
getAllIncludingInactive,
|
||||
getById,
|
||||
getModelsListCandidates,
|
||||
create,
|
||||
|
||||
@@ -60,6 +60,7 @@ export async function list(
|
||||
role?: 'admin' | 'user'
|
||||
search?: string
|
||||
group_name?: string // fuzzy filter by allowed group name
|
||||
api_key_group_id?: number // filter users by the group their API keys are bound to
|
||||
attributes?: Record<number, string> // attributeId -> value
|
||||
include_subscriptions?: boolean
|
||||
sort_by?: string
|
||||
@@ -77,6 +78,7 @@ export async function list(
|
||||
role: filters?.role,
|
||||
search: filters?.search,
|
||||
group_name: filters?.group_name,
|
||||
api_key_group_id: filters?.api_key_group_id,
|
||||
include_subscriptions: filters?.include_subscriptions,
|
||||
sort_by: filters?.sort_by,
|
||||
sort_order: filters?.sort_order
|
||||
|
||||
@@ -1796,6 +1796,16 @@ export default {
|
||||
allGroups: 'All Groups',
|
||||
searchGroups: 'Search groups...',
|
||||
fuzzySearch: 'Fuzzy search',
|
||||
apiKeyGroupFilter: 'API Key Group',
|
||||
apiKeyGroupExclusive: 'Exclusive Groups',
|
||||
apiKeyGroupPublic: 'Public Groups',
|
||||
apiKeyGroupSubscription: 'Subscription Groups',
|
||||
apiKeyGroupDisabled: 'Disabled Groups',
|
||||
authorizedGroupFilter: 'Authorized Group',
|
||||
allAuthorizedGroups: 'All Authorized Groups',
|
||||
searchAuthorizedGroups: 'Search authorized groups...',
|
||||
allApiKeyGroups: 'All API Key Groups',
|
||||
searchApiKeyGroups: 'Search API Key groups...',
|
||||
admin: 'Admin',
|
||||
user: 'User',
|
||||
disabled: 'Disabled',
|
||||
|
||||
@@ -1820,6 +1820,16 @@ export default {
|
||||
allGroups: '全部分组',
|
||||
searchGroups: '搜索分组...',
|
||||
fuzzySearch: '模糊搜索',
|
||||
apiKeyGroupFilter: 'API Key 分组',
|
||||
apiKeyGroupExclusive: '专用分组',
|
||||
apiKeyGroupPublic: '公开分组',
|
||||
apiKeyGroupSubscription: '订阅分组',
|
||||
apiKeyGroupDisabled: '已禁用分组',
|
||||
authorizedGroupFilter: '授权分组',
|
||||
allAuthorizedGroups: '全部授权分组',
|
||||
searchAuthorizedGroups: '搜索授权分组...',
|
||||
allApiKeyGroups: '全部 API Key 分组',
|
||||
searchApiKeyGroups: '搜索 API Key 分组...',
|
||||
statusFilter: '状态筛选',
|
||||
allStatuses: '全部状态',
|
||||
admin: '管理员',
|
||||
|
||||
@@ -56,7 +56,18 @@
|
||||
searchable
|
||||
creatable
|
||||
:creatable-prefix="t('admin.users.fuzzySearch')"
|
||||
:search-placeholder="t('admin.users.searchGroups')"
|
||||
:search-placeholder="t('admin.users.searchAuthorizedGroups')"
|
||||
@change="applyFilter"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- API Key Group Filter (visible when enabled) -->
|
||||
<div v-if="visibleFilters.has('apiKeyGroup')" class="w-full sm:w-44">
|
||||
<Select
|
||||
v-model="filters.apiKeyGroup"
|
||||
:options="apiKeyGroupFilterOptions"
|
||||
searchable
|
||||
:search-placeholder="t('admin.users.searchApiKeyGroups')"
|
||||
@change="applyFilter"
|
||||
/>
|
||||
</div>
|
||||
@@ -751,6 +762,7 @@ import type { AdminUser, AdminGroup, UserAttributeDefinition } from '@/types'
|
||||
import type { BatchUserUsageStats } from '@/api/admin/dashboard'
|
||||
import type { PlatformQuotaItem } from '@/api/admin/users'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import type { SelectOption } from '@/components/common/Select.vue'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
@@ -759,6 +771,7 @@ import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import GroupBadge from '@/components/common/GroupBadge.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import { buildApiKeyGroupFilterOptions } from './apiKeyGroupFilterOptions'
|
||||
import UserAttributesConfigModal from '@/components/user/UserAttributesConfigModal.vue'
|
||||
import UserConcurrencyCell from '@/components/user/UserConcurrencyCell.vue'
|
||||
import PlatformUsageBreakdown from '@/components/user/PlatformUsageBreakdown.vue'
|
||||
@@ -1004,7 +1017,7 @@ const loadInitialSortState = (): { sort_by: string; sort_order: 'asc' | 'desc' }
|
||||
}
|
||||
const sortState = reactive(loadInitialSortState())
|
||||
|
||||
// Groups data for the groups column
|
||||
// Groups data for the groups column and the existing "authorised group" filter (active only)
|
||||
const allGroups = ref<AdminGroup[]>([])
|
||||
const loadAllGroups = async () => {
|
||||
if (allGroups.value.length > 0) return
|
||||
@@ -1014,6 +1027,18 @@ const loadAllGroups = async () => {
|
||||
console.error('Failed to load groups:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Groups for the API Key group filter — includes disabled groups so admins can
|
||||
// filter users whose keys are still bound to a now-disabled group.
|
||||
const allGroupsForApiKeyFilter = ref<AdminGroup[]>([])
|
||||
const loadAllGroupsForApiKeyFilter = async () => {
|
||||
if (allGroupsForApiKeyFilter.value.length > 0) return
|
||||
try {
|
||||
allGroupsForApiKeyFilter.value = await adminAPI.groups.getAllIncludingInactive()
|
||||
} catch (e) {
|
||||
console.error('Failed to load groups for API key filter:', e)
|
||||
}
|
||||
}
|
||||
// Resolve user's accessible groups: exclusive groups first, then public groups
|
||||
const getUserGroups = (user: AdminUser) => {
|
||||
const exclusive: AdminGroup[] = []
|
||||
@@ -1034,7 +1059,7 @@ const getUserGroups = (user: AdminUser) => {
|
||||
// Group filter options: "All Groups" + active exclusive groups (value = group name for fuzzy match)
|
||||
const groupFilterOptions = computed(() => {
|
||||
const options: { value: string; label: string }[] = [
|
||||
{ value: '', label: t('admin.users.allGroups') }
|
||||
{ value: '', label: t('admin.users.allAuthorizedGroups') }
|
||||
]
|
||||
for (const g of allGroups.value) {
|
||||
if (g.status !== 'active' || !g.is_exclusive || g.subscription_type !== 'standard') continue
|
||||
@@ -1043,11 +1068,24 @@ const groupFilterOptions = computed(() => {
|
||||
return options
|
||||
})
|
||||
|
||||
// API Key group filter options: "All" + groups partitioned by type (value = group id).
|
||||
// Uses allGroupsForApiKeyFilter which includes disabled groups.
|
||||
const apiKeyGroupFilterOptions = computed(() =>
|
||||
buildApiKeyGroupFilterOptions(allGroupsForApiKeyFilter.value, {
|
||||
all: t('admin.users.allApiKeyGroups'),
|
||||
exclusive: t('admin.users.apiKeyGroupExclusive'),
|
||||
public: t('admin.users.apiKeyGroupPublic'),
|
||||
subscription: t('admin.users.apiKeyGroupSubscription'),
|
||||
disabled: t('admin.users.apiKeyGroupDisabled'),
|
||||
}) as SelectOption[]
|
||||
)
|
||||
|
||||
// Filter values (role, status, and custom attributes)
|
||||
const filters = reactive({
|
||||
role: '',
|
||||
status: '',
|
||||
group: '' // group name for fuzzy match, '' = all
|
||||
group: '', // group name for fuzzy match, '' = all
|
||||
apiKeyGroup: null as number | null // group id bound to the user's API keys, null = all
|
||||
})
|
||||
const activeAttributeFilters = reactive<Record<number, string>>({})
|
||||
|
||||
@@ -1076,7 +1114,8 @@ const filterableAttributes = computed(() =>
|
||||
const builtInFilters = computed(() => [
|
||||
{ key: 'role', name: t('admin.users.columns.role'), type: 'select' as const },
|
||||
{ key: 'status', name: t('admin.users.columns.status'), type: 'select' as const },
|
||||
{ key: 'group', name: t('admin.users.columns.groups'), type: 'select' as const }
|
||||
{ key: 'group', name: t('admin.users.authorizedGroupFilter'), type: 'select' as const },
|
||||
{ key: 'apiKeyGroup', name: t('admin.users.apiKeyGroupFilter'), type: 'select' as const }
|
||||
])
|
||||
|
||||
// Load saved filters from localStorage
|
||||
@@ -1095,6 +1134,7 @@ const loadSavedFilters = () => {
|
||||
if (parsed.role) filters.role = parsed.role
|
||||
if (parsed.status) filters.status = parsed.status
|
||||
if (parsed.group) filters.group = parsed.group
|
||||
if (typeof parsed.apiKeyGroup === 'number') filters.apiKeyGroup = parsed.apiKeyGroup
|
||||
if (parsed.attributes) {
|
||||
Object.assign(activeAttributeFilters, parsed.attributes)
|
||||
}
|
||||
@@ -1114,6 +1154,7 @@ const saveFiltersToStorage = () => {
|
||||
role: filters.role,
|
||||
status: filters.status,
|
||||
group: filters.group,
|
||||
apiKeyGroup: filters.apiKeyGroup,
|
||||
attributes: activeAttributeFilters
|
||||
}
|
||||
localStorage.setItem(FILTER_VALUES_KEY, JSON.stringify(values))
|
||||
@@ -1492,6 +1533,7 @@ const loadUsers = async () => {
|
||||
status: filters.status as any,
|
||||
search: searchQuery.value || undefined,
|
||||
group_name: filters.group || undefined,
|
||||
api_key_group_id: filters.apiKeyGroup ?? undefined,
|
||||
attributes: Object.keys(attrFilters).length > 0 ? attrFilters : undefined,
|
||||
// 始终请求 subscriptions:列隐藏时仍需用于 UserPlatformQuotaModal 的 active-subscription 警示 banner
|
||||
include_subscriptions: true,
|
||||
@@ -1576,9 +1618,11 @@ const toggleBuiltInFilter = (key: string) => {
|
||||
if (key === 'role') filters.role = ''
|
||||
if (key === 'status') filters.status = ''
|
||||
if (key === 'group') filters.group = ''
|
||||
if (key === 'apiKeyGroup') filters.apiKeyGroup = null
|
||||
} else {
|
||||
visibleFilters.add(key)
|
||||
if (key === 'group') loadAllGroups()
|
||||
if (key === 'apiKeyGroup') loadAllGroupsForApiKeyFilter()
|
||||
}
|
||||
saveFiltersToStorage()
|
||||
pagination.page = 1
|
||||
@@ -1740,6 +1784,9 @@ onMounted(async () => {
|
||||
if (hasVisibleGroupsColumn.value || visibleFilters.has('group')) {
|
||||
loadAllGroups()
|
||||
}
|
||||
if (visibleFilters.has('apiKeyGroup')) {
|
||||
loadAllGroupsForApiKeyFilter()
|
||||
}
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
window.addEventListener('scroll', handleScroll, true)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildApiKeyGroupFilterOptions } from '../apiKeyGroupFilterOptions'
|
||||
import type { AdminGroup } from '@/types'
|
||||
|
||||
const labels = {
|
||||
all: 'All',
|
||||
exclusive: 'Exclusive',
|
||||
public: 'Public',
|
||||
subscription: 'Subscription',
|
||||
disabled: 'Disabled',
|
||||
}
|
||||
|
||||
function g(partial: Partial<AdminGroup>): AdminGroup {
|
||||
return {
|
||||
id: 0,
|
||||
name: '',
|
||||
status: 'active',
|
||||
is_exclusive: false,
|
||||
subscription_type: 'standard',
|
||||
...partial,
|
||||
} as AdminGroup
|
||||
}
|
||||
|
||||
describe('buildApiKeyGroupFilterOptions', () => {
|
||||
it('partitions active groups into exclusive/public/subscription with headers', () => {
|
||||
const groups = [
|
||||
g({ id: 1, name: 'Excl', is_exclusive: true, subscription_type: 'standard' }),
|
||||
g({ id: 2, name: 'Pub', is_exclusive: false, subscription_type: 'standard' }),
|
||||
g({ id: 3, name: 'Sub', is_exclusive: false, subscription_type: 'subscription' }),
|
||||
]
|
||||
expect(buildApiKeyGroupFilterOptions(groups, labels)).toEqual([
|
||||
{ value: null, label: 'All' },
|
||||
{ value: -1, label: 'Exclusive', kind: 'group', disabled: true },
|
||||
{ value: 1, label: 'Excl' },
|
||||
{ value: -2, label: 'Public', kind: 'group', disabled: true },
|
||||
{ value: 2, label: 'Pub' },
|
||||
{ value: -3, label: 'Subscription', kind: 'group', disabled: true },
|
||||
{ value: 3, label: 'Sub' },
|
||||
])
|
||||
})
|
||||
|
||||
it('treats subscription_type=subscription as subscription even if is_exclusive', () => {
|
||||
const groups = [g({ id: 9, name: 'X', is_exclusive: true, subscription_type: 'subscription' })]
|
||||
const opts = buildApiKeyGroupFilterOptions(groups, labels)
|
||||
expect(opts).toContainEqual({ value: 9, label: 'X' })
|
||||
expect(opts.find((o) => o.label === 'Subscription')).toBeDefined()
|
||||
expect(opts.find((o) => o.label === 'Exclusive')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('skips empty section headers', () => {
|
||||
const groups = [g({ id: 2, name: 'Pub', is_exclusive: false, subscription_type: 'standard' })]
|
||||
const opts = buildApiKeyGroupFilterOptions(groups, labels)
|
||||
expect(opts.find((o) => o.label === 'Exclusive')).toBeUndefined()
|
||||
expect(opts.find((o) => o.label === 'Subscription')).toBeUndefined()
|
||||
expect(opts).toContainEqual({ value: -2, label: 'Public', kind: 'group', disabled: true })
|
||||
})
|
||||
|
||||
it('places non-active groups in a separate disabled section (not omitted)', () => {
|
||||
const groups = [
|
||||
g({ id: 1, name: 'Active', is_exclusive: true }),
|
||||
g({ id: 2, name: 'Inactive', is_exclusive: true, status: 'inactive' }),
|
||||
]
|
||||
const opts = buildApiKeyGroupFilterOptions(groups, labels)
|
||||
// Active exclusive group appears in Exclusive section
|
||||
expect(opts).toContainEqual({ value: 1, label: 'Active' })
|
||||
// Disabled group appears in Disabled section
|
||||
expect(opts).toContainEqual({ value: 2, label: 'Inactive' })
|
||||
// Disabled section header present
|
||||
expect(opts).toContainEqual({ value: -4, label: 'Disabled', kind: 'group', disabled: true })
|
||||
// Not in Exclusive section
|
||||
const exclIdx = opts.findIndex((o) => o.value === -1)
|
||||
const disabledItemIdx = opts.findIndex((o) => o.value === 2)
|
||||
expect(exclIdx).toBeLessThan(disabledItemIdx)
|
||||
})
|
||||
|
||||
it('section headers use distinct negative values (no duplicate Vue :key)', () => {
|
||||
const groups = [
|
||||
g({ id: 1, name: 'E', is_exclusive: true }),
|
||||
g({ id: 2, name: 'P', is_exclusive: false }),
|
||||
g({ id: 3, name: 'S', subscription_type: 'subscription' }),
|
||||
g({ id: 4, name: 'D', status: 'inactive' }),
|
||||
]
|
||||
const opts = buildApiKeyGroupFilterOptions(groups, labels)
|
||||
const headerValues = opts.filter((o) => o.kind === 'group').map((o) => o.value)
|
||||
const unique = new Set(headerValues)
|
||||
expect(unique.size).toBe(headerValues.length) // all distinct
|
||||
headerValues.forEach((v) => expect(v).toBeLessThan(0)) // all negative
|
||||
})
|
||||
|
||||
it('returns only the all-option when there are no groups', () => {
|
||||
expect(buildApiKeyGroupFilterOptions([], labels)).toEqual([{ value: null, label: 'All' }])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { AdminGroup } from '@/types'
|
||||
|
||||
export interface ApiKeyGroupFilterOption {
|
||||
value: number | null
|
||||
label: string
|
||||
kind?: 'group'
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface ApiKeyGroupFilterLabels {
|
||||
all: string
|
||||
exclusive: string
|
||||
public: string
|
||||
subscription: string
|
||||
disabled: string
|
||||
}
|
||||
|
||||
// Sentinel values for section-header rows (negative so they never collide with real group ids).
|
||||
// Select.vue generates :key from `${typeof value}:${String(value ?? '')}` — using distinct
|
||||
// numbers avoids the duplicate "object:" keys that null-valued headers would produce.
|
||||
const HEADER_EXCLUSIVE = -1
|
||||
const HEADER_PUBLIC = -2
|
||||
const HEADER_SUBSCRIPTION = -3
|
||||
const HEADER_DISABLED = -4
|
||||
|
||||
/**
|
||||
* Build options for the "API Key group" filter Select.
|
||||
*
|
||||
* Active groups are partitioned into exclusive / public / subscription sections,
|
||||
* each preceded by a disabled section-header row. Disabled groups are collected
|
||||
* into a final "disabled" section so admins can filter users whose keys are still
|
||||
* bound to a now-disabled group. Empty sections render no header. The leading
|
||||
* "all" option (value null) clears the filter.
|
||||
*
|
||||
* Section-header rows use negative sentinel values (-1 … -4) instead of null so
|
||||
* that Vue's v-for :key expression produces distinct strings and avoids duplicate-
|
||||
* key warnings (fixes F2).
|
||||
*/
|
||||
export function buildApiKeyGroupFilterOptions(
|
||||
groups: AdminGroup[],
|
||||
labels: ApiKeyGroupFilterLabels
|
||||
): ApiKeyGroupFilterOption[] {
|
||||
const exclusive: ApiKeyGroupFilterOption[] = []
|
||||
const publicGroups: ApiKeyGroupFilterOption[] = []
|
||||
const subscription: ApiKeyGroupFilterOption[] = []
|
||||
const disabledGroups: ApiKeyGroupFilterOption[] = []
|
||||
|
||||
for (const grp of groups) {
|
||||
const item: ApiKeyGroupFilterOption = { value: grp.id, label: grp.name }
|
||||
if (grp.status !== 'active') {
|
||||
disabledGroups.push(item)
|
||||
} else if (grp.subscription_type === 'subscription') {
|
||||
subscription.push(item)
|
||||
} else if (grp.is_exclusive) {
|
||||
exclusive.push(item)
|
||||
} else {
|
||||
publicGroups.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
const options: ApiKeyGroupFilterOption[] = [{ value: null, label: labels.all }]
|
||||
|
||||
const sections: Array<[string, number, ApiKeyGroupFilterOption[]]> = [
|
||||
[labels.exclusive, HEADER_EXCLUSIVE, exclusive],
|
||||
[labels.public, HEADER_PUBLIC, publicGroups],
|
||||
[labels.subscription, HEADER_SUBSCRIPTION, subscription],
|
||||
[labels.disabled, HEADER_DISABLED, disabledGroups],
|
||||
]
|
||||
for (const [label, headerValue, items] of sections) {
|
||||
if (items.length === 0) continue
|
||||
options.push({ value: headerValue, label, kind: 'group', disabled: true })
|
||||
options.push(...items)
|
||||
}
|
||||
return options
|
||||
}
|
||||
Reference in New Issue
Block a user