diff --git a/controller/token.go b/controller/token.go index e65ae0ea0..24f8eb2d0 100644 --- a/controller/token.go +++ b/controller/token.go @@ -33,13 +33,13 @@ func buildMaskedTokenResponses(tokens []*model.Token) []*model.Token { func GetAllTokens(c *gin.Context) { userId := c.GetInt("id") + groups := c.QueryArray("group") pageInfo := common.GetPageQuery(c) - tokens, err := model.GetAllUserTokens(userId, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + tokens, total, err := model.GetAllUserTokens(userId, groups, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) if err != nil { common.ApiError(c, err) return } - total, _ := model.CountUserTokens(userId) pageInfo.SetTotal(int(total)) pageInfo.SetItems(buildMaskedTokenResponses(tokens)) common.ApiSuccess(c, pageInfo) @@ -49,10 +49,11 @@ func SearchTokens(c *gin.Context) { userId := c.GetInt("id") keyword := c.Query("keyword") token := c.Query("token") + groups := c.QueryArray("group") pageInfo := common.GetPageQuery(c) - tokens, total, err := model.SearchUserTokens(userId, keyword, token, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + tokens, total, err := model.SearchUserTokens(userId, keyword, token, groups, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) if err != nil { common.ApiError(c, err) return diff --git a/controller/token_test.go b/controller/token_test.go index 1d566eaa0..953b63dc6 100644 --- a/controller/token_test.go +++ b/controller/token_test.go @@ -16,6 +16,7 @@ import ( "github.com/QuantumNous/new-api/model" "github.com/gin-gonic/gin" "github.com/glebarez/sqlite" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/driver/mysql" "gorm.io/driver/postgres" @@ -30,6 +31,7 @@ type tokenAPIResponse struct { type tokenPageResponse struct { Items []tokenResponseItem `json:"items"` + Total int `json:"total"` } type tokenResponseItem struct { @@ -423,6 +425,55 @@ func TestGetAllTokensMasksKeyInResponse(t *testing.T) { } } +func TestGetAllTokensFiltersByGroup(t *testing.T) { + db := setupTokenControllerTestDB(t) + premium := seedToken(t, db, 1, "premium-token", "abcd1234efgh5678") + require.NoError(t, db.Model(premium).Update("group", "premium").Error) + seedToken(t, db, 1, "default-token", "mnop1234qrst5678") + otherUser := seedToken(t, db, 2, "other-user-premium", "uvwx1234yzab5678") + require.NoError(t, db.Model(otherUser).Update("group", "premium").Error) + + ctx, recorder := newAuthenticatedContext(t, http.MethodGet, "/api/token/?group=premium&p=1&size=10", nil, 1) + GetAllTokens(ctx) + + response := decodeAPIResponse(t, recorder) + require.True(t, response.Success, "expected success response, got message: %s", response.Message) + + var page tokenPageResponse + require.NoError(t, common.Unmarshal(response.Data, &page)) + require.Equal(t, 1, page.Total) + require.Len(t, page.Items, 1) + require.Equal(t, "premium-token", page.Items[0].Name) +} + +func TestGetAllTokensFiltersByMultipleGroups(t *testing.T) { + db := setupTokenControllerTestDB(t) + premium := seedToken(t, db, 1, "premium-token", "abcd1234efgh5678") + require.NoError(t, db.Model(premium).Update("group", "premium").Error) + standard := seedToken(t, db, 1, "standard-token", "mnop1234qrst5678") + require.NoError(t, db.Model(standard).Update("group", "standard").Error) + defaultToken := seedToken(t, db, 1, "default-token", "ijkl1234mnop5678") + otherUser := seedToken(t, db, 2, "other-user-premium", "uvwx1234yzab5678") + require.NoError(t, db.Model(otherUser).Update("group", "premium").Error) + + ctx, recorder := newAuthenticatedContext(t, http.MethodGet, "/api/token/?group=premium&group=standard&p=1&size=10", nil, 1) + GetAllTokens(ctx) + + response := decodeAPIResponse(t, recorder) + require.True(t, response.Success, "expected success response, got message: %s", response.Message) + + var page tokenPageResponse + require.NoError(t, common.Unmarshal(response.Data, &page)) + require.Equal(t, 2, page.Total) + require.Len(t, page.Items, 2) + assert.ElementsMatch(t, []string{"premium-token", "standard-token"}, []string{ + page.Items[0].Name, + page.Items[1].Name, + }) + assert.NotEqual(t, defaultToken.Name, page.Items[0].Name) + assert.NotEqual(t, defaultToken.Name, page.Items[1].Name) +} + func TestSearchTokensMasksKeyInResponse(t *testing.T) { db := setupTokenControllerTestDB(t) token := seedToken(t, db, 1, "searchable-token", "ijkl1234mnop5678") @@ -467,6 +518,49 @@ func TestSearchTokensKeywordMatchesNameSubstring(t *testing.T) { require.Equal(t, "north-beijing-token", page.Items[0].Name) } +func TestSearchTokensCombinesGroupAndNameSubstring(t *testing.T) { + db := setupTokenControllerTestDB(t) + premium := seedToken(t, db, 1, "north-beijing-premium", "abcd1234efgh5678") + require.NoError(t, db.Model(premium).Update("group", "premium").Error) + seedToken(t, db, 1, "north-beijing-default", "mnop1234qrst5678") + + ctx, recorder := newAuthenticatedContext(t, http.MethodGet, "/api/token/search?keyword=beijing&group=premium&p=1&size=10", nil, 1) + SearchTokens(ctx) + + response := decodeAPIResponse(t, recorder) + require.True(t, response.Success, "expected success response, got message: %s", response.Message) + + var page tokenPageResponse + require.NoError(t, common.Unmarshal(response.Data, &page)) + require.Equal(t, 1, page.Total) + require.Len(t, page.Items, 1) + require.Equal(t, "north-beijing-premium", page.Items[0].Name) +} + +func TestSearchTokensCombinesMultipleGroupsAndNameSubstring(t *testing.T) { + db := setupTokenControllerTestDB(t) + premium := seedToken(t, db, 1, "north-beijing-premium", "abcd1234efgh5678") + require.NoError(t, db.Model(premium).Update("group", "premium").Error) + standard := seedToken(t, db, 1, "north-beijing-standard", "mnop1234qrst5678") + require.NoError(t, db.Model(standard).Update("group", "standard").Error) + seedToken(t, db, 1, "north-beijing-default", "ijkl1234mnop5678") + + ctx, recorder := newAuthenticatedContext(t, http.MethodGet, "/api/token/search?keyword=beijing&group=premium&group=standard&p=1&size=10", nil, 1) + SearchTokens(ctx) + + response := decodeAPIResponse(t, recorder) + require.True(t, response.Success, "expected success response, got message: %s", response.Message) + + var page tokenPageResponse + require.NoError(t, common.Unmarshal(response.Data, &page)) + require.Equal(t, 2, page.Total) + require.Len(t, page.Items, 2) + assert.ElementsMatch(t, []string{"north-beijing-premium", "north-beijing-standard"}, []string{ + page.Items[0].Name, + page.Items[1].Name, + }) +} + func TestSearchTokensTokenMatchesKeySubstring(t *testing.T) { db := setupTokenControllerTestDB(t) seedToken(t, db, 1, "matching-key-token", "abcd1234efgh5678") diff --git a/docs/superpowers/specs/2026-07-19-api-keys-group-filter-design.md b/docs/superpowers/specs/2026-07-19-api-keys-group-filter-design.md new file mode 100644 index 000000000..65638cc5b --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-api-keys-group-filter-design.md @@ -0,0 +1,74 @@ +# API Keys Group Filter Design + +## Goal + +Add a group filter to the `/keys` page that matches the existing `/channels` +group filter in appearance and behavior. Users can type to narrow the available +groups and select one group to filter the paginated API key list. + +## User Experience + +- Add a `Group` faceted filter to the `/keys` table toolbar beside the existing + status filter. +- Reuse `DataTableFacetedFilter`, including its searchable command list, + checkbox-style option indicator, selected badge, clear action, and responsive + toolbar behavior. +- Match `/channels` by setting `singleSelect: true`. The control looks like a + checkbox list but permits one selected group at a time. +- Include `All Groups` as the unfiltered option. +- Store the selected value in the route search state under `group`, using the + same array serialization pattern as `/channels`, so refresh and navigation + preserve the filter. +- Fetch options from the current-user groups endpoint because `/keys` is + available to ordinary users, while the channel group endpoint is admin-only. +- Allow the group filter to combine with name, API key, and status filters. + +## Data Flow + +1. The `/keys` route validates `group` as an optional string array. +2. `ApiKeysTable` reads the selected group from table URL state. +3. The table fetches current-user group metadata and maps group names to filter + options. +4. Selecting a group resets pagination through the existing table URL-state + behavior and adds the selected group to the API request. +5. Both regular listing and text-search requests use the same server-side group + predicate, so pagination totals and page contents remain correct. + +## Backend Contract + +- Add an optional `group` query parameter to both: + - `GET /api/token/` + - `GET /api/token/search` +- Apply an exact group match together with the authenticated user ID. +- Preserve the current behavior when `group` is empty or omitted. +- Keep name and key substring matching unchanged. +- Implement filtering with GORM so SQLite, MySQL, and PostgreSQL remain + supported. + +## Error And Loading Behavior + +- A failure to load group options must not prevent the API key list from + loading; the filter simply has no selectable group options. +- Existing list/search API error handling and loading indicators remain in use. +- An unknown group value may return an empty page; it must never broaden the + query beyond the authenticated user's keys. + +## Verification + +- Backend tests verify group-only filtering, group combined with substring + search, correct totals, and isolation between users. +- Frontend tests verify request parameter construction and URL-backed selected + state where the existing test harness provides a stable behavior-level seam. +- Run focused Go tests, frontend type checking, linting for changed files, and a + production frontend build. +- Start the local service with fixture keys in multiple groups and use the + `computer-use` plugin to verify that typing narrows group options, selecting a + group updates the list, combined search remains correct, and clearing restores + all groups. +- Build a `linux/amd64` Docker image tagged `newapi:amd64`, inspect its platform, + and run a container-level health/status check before delivery. + +## Scope + +This change does not add true multi-group OR filtering, alter API key creation +or editing, change channel filtering, or introduce a new selector component. diff --git a/model/token.go b/model/token.go index 1c2930d59..f29cf81e9 100644 --- a/model/token.go +++ b/model/token.go @@ -128,11 +128,16 @@ func (token *Token) NormalizeMacLimits() error { return nil } -func GetAllUserTokens(userId int, startIdx int, num int) ([]*Token, error) { - var tokens []*Token - var err error - err = DB.Where("user_id = ?", userId).Order("id desc").Limit(num).Offset(startIdx).Find(&tokens).Error - return tokens, err +func GetAllUserTokens(userId int, groups []string, startIdx int, num int) (tokens []*Token, total int64, err error) { + query := DB.Model(&Token{}).Where("user_id = ?", userId) + if len(groups) > 0 { + query = query.Where(map[string]interface{}{"group": groups}) + } + if err = query.Count(&total).Error; err != nil { + return nil, 0, err + } + err = query.Order("id desc").Limit(num).Offset(startIdx).Find(&tokens).Error + return tokens, total, err } // sanitizeLikePattern 校验并清洗用户输入的 LIKE 搜索模式。 @@ -192,7 +197,7 @@ func sanitizeContainsLikePattern(input string) (string, error) { const searchHardLimit = 100 -func SearchUserTokens(userId int, keyword string, token string, offset int, limit int) (tokens []*Token, total int64, err error) { +func SearchUserTokens(userId int, keyword string, token string, groups []string, offset int, limit int) (tokens []*Token, total int64, err error) { // model 层强制截断 if limit <= 0 || limit > searchHardLimit { limit = searchHardLimit @@ -220,6 +225,9 @@ func SearchUserTokens(userId int, keyword string, token string, offset int, limi } baseQuery := DB.Model(&Token{}).Where("user_id = ?", userId) + if len(groups) > 0 { + baseQuery = baseQuery.Where(map[string]interface{}{"group": groups}) + } // 非空才加 LIKE 条件,空则跳过(不过滤该字段) if keyword != "" { diff --git a/web/default/src/components/data-table/toolbar/faceted-filter.tsx b/web/default/src/components/data-table/toolbar/faceted-filter.tsx index 9dc31b0cc..67a4b33b5 100644 --- a/web/default/src/components/data-table/toolbar/faceted-filter.tsx +++ b/web/default/src/components/data-table/toolbar/faceted-filter.tsx @@ -16,7 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { type Column } from '@tanstack/react-table' +import type { Column } from '@tanstack/react-table' import { Check as CheckIcon, PlusCircle as PlusCircledIcon } from 'lucide-react' import * as React from 'react' import { useTranslation } from 'react-i18next' @@ -52,6 +52,10 @@ type DataTableFacetedFilterProps = { }[] /** Enable single select mode (only one option can be selected at a time) */ singleSelect?: boolean + /** Option value that clears the filter when selected. */ + allOptionValue?: string + /** Show explicit or faceted option counts. */ + showCounts?: boolean } function DataTableFacetedFilterInner({ @@ -59,6 +63,8 @@ function DataTableFacetedFilterInner({ title, options, singleSelect = false, + allOptionValue, + showCounts = true, }: DataTableFacetedFilterProps) { const { t } = useTranslation() const facets = column?.getFacetedUniqueValues() @@ -69,7 +75,8 @@ function DataTableFacetedFilterInner({ const nextSelectedValues = getNextSelectedValues( selectedValues, optionValue, - singleSelect + singleSelect, + allOptionValue ) column?.setFilterValue( @@ -128,6 +135,34 @@ function DataTableFacetedFilterInner({ {options.map((option) => { const isSelected = selectedValues.has(option.value) + let optionIcon: React.ReactNode = null + if (option.iconNode) { + optionIcon = ( + + {option.iconNode} + + ) + } else if (option.icon) { + optionIcon = ( + + ) + } + + let optionCount: React.ReactNode = null + if (showCounts && typeof option.count === 'number') { + optionCount = ( + + {option.count} + + ) + } else if (showCounts && facets?.get(option.value)) { + optionCount = ( + + {facets.get(option.value)} + + ) + } + return ( ({ > - {option.iconNode ? ( - - {option.iconNode} - - ) : option.icon ? ( - - ) : null} + {optionIcon} {t(option.label)} - {typeof option.count === 'number' ? ( - - {option.count} - - ) : facets?.get(option.value) ? ( - - {facets.get(option.value)} - - ) : null} + {optionCount} ) })} @@ -196,8 +217,13 @@ export const DataTableFacetedFilter = React.memo( function getNextSelectedValues( selectedValues: Set, optionValue: string, - singleSelect: boolean + singleSelect: boolean, + allOptionValue?: string ): string[] { + if (optionValue === allOptionValue) { + return [] + } + if (singleSelect) { return selectedValues.has(optionValue) ? [] : [optionValue] } @@ -209,5 +235,5 @@ function getNextSelectedValues( nextSelectedValues.add(optionValue) } - return Array.from(nextSelectedValues) + return [...nextSelectedValues] } diff --git a/web/default/src/components/data-table/toolbar/toolbar.tsx b/web/default/src/components/data-table/toolbar/toolbar.tsx index 6d1881a07..c0a932216 100644 --- a/web/default/src/components/data-table/toolbar/toolbar.tsx +++ b/web/default/src/components/data-table/toolbar/toolbar.tsx @@ -41,6 +41,8 @@ type FilterDef = { count?: number }[] singleSelect?: boolean + allOptionValue?: string + showCounts?: boolean } type SearchDraft = { @@ -266,6 +268,8 @@ export function DataTableToolbar(props: DataTableToolbarProps) { title={filter.title} options={filter.options} singleSelect={filter.singleSelect} + allOptionValue={filter.allOptionValue} + showCounts={filter.showCounts} /> ) }), diff --git a/web/default/src/features/keys/api.ts b/web/default/src/features/keys/api.ts index df3cc5ff7..2c032811f 100644 --- a/web/default/src/features/keys/api.ts +++ b/web/default/src/features/keys/api.ts @@ -35,8 +35,12 @@ import type { export async function getApiKeys( params: GetApiKeysParams = {} ): Promise { - const { p = 1, size = 10 } = params - const res = await api.get(`/api/token/?p=${p}&size=${size}`) + const { groups = [], p = 1, size = 10 } = params + const queryParams = new URLSearchParams() + groups.forEach((group) => queryParams.append('group', group)) + queryParams.set('p', String(p)) + queryParams.set('size', String(size)) + const res = await api.get(`/api/token/?${queryParams.toString()}`) return res.data } @@ -44,10 +48,11 @@ export async function getApiKeys( export async function searchApiKeys( params: SearchApiKeysParams ): Promise { - const { keyword = '', token = '', p, size } = params + const { keyword = '', token = '', groups = [], p, size } = params const queryParams = new URLSearchParams() if (keyword) queryParams.set('keyword', keyword) if (token) queryParams.set('token', token) + groups.forEach((group) => queryParams.append('group', group)) if (p != null) queryParams.set('p', String(p)) if (size != null) queryParams.set('size', String(size)) const res = await api.get(`/api/token/search?${queryParams.toString()}`) diff --git a/web/default/src/features/keys/components/api-keys-columns.tsx b/web/default/src/features/keys/components/api-keys-columns.tsx index ad7a8212c..b8a34c612 100644 --- a/web/default/src/features/keys/components/api-keys-columns.tsx +++ b/web/default/src/features/keys/components/api-keys-columns.tsx @@ -239,6 +239,7 @@ export function useApiKeysColumns(now: number): ColumnDef[] { ) }, + filterFn: (row, id, value) => value.includes(String(row.getValue(id))), size: 160, meta: { mobileHidden: true }, }, diff --git a/web/default/src/features/keys/components/api-keys-table.tsx b/web/default/src/features/keys/components/api-keys-table.tsx index 079505b20..d1324da77 100644 --- a/web/default/src/features/keys/components/api-keys-table.tsx +++ b/web/default/src/features/keys/components/api-keys-table.tsx @@ -20,7 +20,7 @@ import { useQuery } from '@tanstack/react-query' import { getRouteApi } from '@tanstack/react-router' import type { Table as TanstackTable } from '@tanstack/react-table' import { Database } from 'lucide-react' -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' @@ -42,6 +42,7 @@ import { import { Input } from '@/components/ui/input' import { Skeleton } from '@/components/ui/skeleton' import { useTableUrlState } from '@/hooks/use-table-url-state' +import { getUserGroups } from '@/lib/api' import { formatQuota } from '@/lib/format' import { cn } from '@/lib/utils' @@ -215,6 +216,13 @@ export function ApiKeysTable() { globalFilter: { enabled: true, key: 'filter' }, columnFilters: [ { columnId: 'status', searchKey: 'status', type: 'array' }, + { + columnId: 'group', + searchKey: 'group', + type: 'array', + deserialize: (value) => + Array.isArray(value) ? value.filter((group) => group !== 'all') : [], + }, { columnId: '_tokenSearch', searchKey: 'token', type: 'string' }, ], }) @@ -228,8 +236,31 @@ export function ApiKeysTable() { columnId: '_tokenSearch', onColumnFiltersChange, }) + const selectedGroups = useMemo( + () => + ( + (columnFilters.find((filter) => filter.id === 'group') + ?.value as string[]) || [] + ).filter((group) => group !== 'all'), + [columnFilters] + ) const shouldSearch = Boolean(globalFilter?.trim() || tokenFilter.trim()) + const { data: groupsData } = useQuery({ + queryKey: ['user-groups'], + queryFn: getUserGroups, + }) + const groupFilterOptions = useMemo( + () => [ + { label: t('All Groups'), value: 'all' }, + ...Object.keys(groupsData?.data || {}).map((group) => ({ + label: group, + value: group, + })), + ], + [groupsData, t] + ) + // Fetch data with React Query // eslint-disable-next-line @tanstack/query/exhaustive-deps const { data, isLoading, isFetching } = useQuery({ @@ -239,6 +270,7 @@ export function ApiKeysTable() { pagination.pageSize, globalFilter, tokenFilter, + selectedGroups, refreshTrigger, ], queryFn: async () => { @@ -246,10 +278,12 @@ export function ApiKeysTable() { ? await searchApiKeys({ keyword: globalFilter, token: tokenFilter, + groups: selectedGroups, p: pagination.pageIndex + 1, size: pagination.pageSize, }) : await getApiKeys({ + groups: selectedGroups, p: pagination.pageIndex + 1, size: pagination.pageSize, }) @@ -323,6 +357,13 @@ export function ApiKeysTable() { options: API_KEY_STATUS_OPTIONS, singleSelect: true, }, + { + columnId: 'group', + title: t('Group'), + options: groupFilterOptions, + allOptionValue: 'all', + showCounts: false, + }, ], }} mobile={} diff --git a/web/default/src/features/keys/types.ts b/web/default/src/features/keys/types.ts index ff4b2ea30..ba48af34c 100644 --- a/web/default/src/features/keys/types.ts +++ b/web/default/src/features/keys/types.ts @@ -62,6 +62,7 @@ export interface ApiResponse { } export interface GetApiKeysParams { + groups?: string[] p?: number size?: number } @@ -80,6 +81,7 @@ export interface GetApiKeysResponse { export interface SearchApiKeysParams { keyword?: string token?: string + groups?: string[] p?: number size?: number } diff --git a/web/default/src/routes/_authenticated/keys/index.tsx b/web/default/src/routes/_authenticated/keys/index.tsx index 14d885cd7..3deb63265 100644 --- a/web/default/src/routes/_authenticated/keys/index.tsx +++ b/web/default/src/routes/_authenticated/keys/index.tsx @@ -29,6 +29,7 @@ const apiKeySearchSchema = z.object({ .array(z.enum(API_KEY_STATUS_OPTIONS.map((s) => s.value as `${number}`))) .optional() .catch([]), + group: z.array(z.string()).optional().catch([]), filter: z.string().optional().catch(''), token: z.string().optional().catch(''), })