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(''),
})