Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions controller/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
94 changes: 94 additions & 0 deletions controller/token_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -30,6 +31,7 @@ type tokenAPIResponse struct {

type tokenPageResponse struct {
Items []tokenResponseItem `json:"items"`
Total int `json:"total"`
}

type tokenResponseItem struct {
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
74 changes: 74 additions & 0 deletions docs/superpowers/specs/2026-07-19-api-keys-group-filter-design.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 14 additions & 6 deletions model/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 搜索模式。
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 != "" {
Expand Down
66 changes: 46 additions & 20 deletions web/default/src/components/data-table/toolbar/faceted-filter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.

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'
Expand Down Expand Up @@ -52,13 +52,19 @@ type DataTableFacetedFilterProps<TData, TValue> = {
}[]
/** 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<TData, TValue>({
column,
title,
options,
singleSelect = false,
allOptionValue,
showCounts = true,
}: DataTableFacetedFilterProps<TData, TValue>) {
const { t } = useTranslation()
const facets = column?.getFacetedUniqueValues()
Expand All @@ -69,7 +75,8 @@ function DataTableFacetedFilterInner<TData, TValue>({
const nextSelectedValues = getNextSelectedValues(
selectedValues,
optionValue,
singleSelect
singleSelect,
allOptionValue
)

column?.setFilterValue(
Expand Down Expand Up @@ -128,6 +135,34 @@ function DataTableFacetedFilterInner<TData, TValue>({
<CommandGroup>
{options.map((option) => {
const isSelected = selectedValues.has(option.value)
let optionIcon: React.ReactNode = null
if (option.iconNode) {
optionIcon = (
<span className='text-muted-foreground flex size-4 items-center justify-center'>
{option.iconNode}
</span>
)
} else if (option.icon) {
optionIcon = (
<option.icon className='text-muted-foreground size-4' />
)
}

let optionCount: React.ReactNode = null
if (showCounts && typeof option.count === 'number') {
optionCount = (
<span className='text-muted-foreground ms-auto flex h-4 min-w-4 items-center justify-center font-mono text-xs'>
{option.count}
</span>
)
} else if (showCounts && facets?.get(option.value)) {
optionCount = (
<span className='ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs'>
{facets.get(option.value)}
</span>
)
}

return (
<CommandItem
key={option.value}
Expand All @@ -143,28 +178,14 @@ function DataTableFacetedFilterInner<TData, TValue>({
>
<CheckIcon className={cn('text-background h-4 w-4')} />
</div>
{option.iconNode ? (
<span className='text-muted-foreground flex size-4 items-center justify-center'>
{option.iconNode}
</span>
) : option.icon ? (
<option.icon className='text-muted-foreground size-4' />
) : null}
{optionIcon}
<span
className='min-w-0 flex-1 truncate'
title={t(option.label)}
>
{t(option.label)}
</span>
{typeof option.count === 'number' ? (
<span className='text-muted-foreground ms-auto flex h-4 min-w-4 items-center justify-center font-mono text-xs'>
{option.count}
</span>
) : facets?.get(option.value) ? (
<span className='ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs'>
{facets.get(option.value)}
</span>
) : null}
{optionCount}
</CommandItem>
)
})}
Expand Down Expand Up @@ -196,8 +217,13 @@ export const DataTableFacetedFilter = React.memo(
function getNextSelectedValues(
selectedValues: Set<string>,
optionValue: string,
singleSelect: boolean
singleSelect: boolean,
allOptionValue?: string
): string[] {
if (optionValue === allOptionValue) {
return []
}

if (singleSelect) {
return selectedValues.has(optionValue) ? [] : [optionValue]
}
Expand All @@ -209,5 +235,5 @@ function getNextSelectedValues(
nextSelectedValues.add(optionValue)
}

return Array.from(nextSelectedValues)
return [...nextSelectedValues]
}
Loading