feat: make my apps searchable and sortable

This commit is contained in:
Elias Schneider
2026-10-07 21:25:22 +02:00
parent 27709be5cf
commit 9c0c304d59
10 changed files with 534 additions and 180 deletions
+10 -5
View File
@@ -470,9 +470,10 @@ func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) error {
// @Summary List authorized clients for current user
// @Description Get a paginated list of OIDC clients that the current user has authorized
// @Tags OIDC
// @Param search query string false "Search term to filter clients by name"
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[column] query string false "Column to sort by (name or lastUsedAt)"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Param filters[hasLaunchURL] query bool false "Filter clients by whether a launch URL is configured"
// @Success 200 {object} dto.Paginated[dto.AuthorizedOidcClientDto]
@@ -488,9 +489,10 @@ func (oc *OidcController) listOwnAuthorizedClientsHandler(c *gin.Context) error
// @Description Get a paginated list of OIDC clients that a specific user has authorized
// @Tags OIDC
// @Param id path string true "User ID"
// @Param search query string false "Search term to filter clients by name"
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[column] query string false "Column to sort by (name or lastUsedAt)"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Param filters[hasLaunchURL] query bool false "Filter clients by whether a launch URL is configured"
// @Success 200 {object} dto.Paginated[dto.AuthorizedOidcClientDto]
@@ -502,9 +504,10 @@ func (oc *OidcController) listAuthorizedClientsHandler(c *gin.Context) error {
}
func (oc *OidcController) listAuthorizedClients(c *gin.Context, userID string) error {
searchTerm := c.Query("search")
listRequestOptions := utils.ParseListRequestOptions(c)
authorizedClients, pagination, err := oc.oidcService.ListAuthorizedClients(c.Request.Context(), userID, listRequestOptions)
authorizedClients, pagination, err := oc.oidcService.ListAuthorizedClients(c.Request.Context(), userID, searchTerm, listRequestOptions)
if err != nil {
return err
}
@@ -548,20 +551,22 @@ func (oc *OidcController) revokeOwnClientAuthorizationHandler(c *gin.Context) er
// @Summary List accessible OIDC clients for current user
// @Description Get a list of OIDC clients that the current user can access
// @Tags OIDC
// @Param search query string false "Search term to filter clients by name"
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[column] query string false "Column to sort by (name or lastUsedAt)"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Param filters[hasLaunchURL] query bool false "Filter clients by whether a launch URL is configured"
// @Success 200 {object} dto.Paginated[dto.AccessibleOidcClientDto]
// @Failure default {object} dto.ErrorDto "Error"
// @Router /api/oidc/users/me/clients [get]
func (oc *OidcController) listOwnAccessibleClientsHandler(c *gin.Context) error {
searchTerm := c.Query("search")
listRequestOptions := utils.ParseListRequestOptions(c)
userID := c.GetString("userID")
clients, pagination, err := oc.oidcService.ListAccessibleOidcClients(c.Request.Context(), userID, listRequestOptions)
clients, pagination, err := oc.oidcService.ListAccessibleOidcClients(c.Request.Context(), userID, searchTerm, listRequestOptions)
if err != nil {
return err
}
+21 -5
View File
@@ -713,7 +713,7 @@ func (s *OidcService) UpdateAllowedUserGroups(ctx context.Context, id string, in
return client, nil
}
func (s *OidcService) ListAuthorizedClients(ctx context.Context, userID string, listRequestOptions utils.ListRequestOptions) ([]model.UserAuthorizedOidcClient, utils.PaginationResponse, error) {
func (s *OidcService) ListAuthorizedClients(ctx context.Context, userID string, search string, listRequestOptions utils.ListRequestOptions) ([]model.UserAuthorizedOidcClient, utils.PaginationResponse, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
@@ -732,15 +732,20 @@ func (s *OidcService) ListAuthorizedClients(ctx context.Context, userID string,
return nil, utils.PaginationResponse{}, err
}
// Join the clients so the search, the launch URL filter and the name sort can use their columns
query := tx.
WithContext(ctx).
Model(&model.UserAuthorizedOidcClient{}).
Preload("Client").
Where("user_id = ?", userID)
Joins("JOIN oidc_clients ON oidc_clients.id = user_authorized_oidc_clients.client_id").
Where("user_authorized_oidc_clients.user_id = ?", userID)
if search != "" {
query = query.Where("LOWER(oidc_clients.name) LIKE ?", "%"+strings.ToLower(search)+"%")
}
// Apply the launch URL filter before pagination so hidden authorizations have their own page count
if hasLaunchURL, ok := getHasLaunchURLFilter(listRequestOptions); ok {
query = query.Joins("JOIN oidc_clients ON oidc_clients.id = user_authorized_oidc_clients.client_id")
if hasLaunchURL {
query = query.Where("oidc_clients.launch_url IS NOT NULL AND oidc_clients.launch_url <> ''")
} else {
@@ -748,6 +753,11 @@ func (s *OidcService) ListAuthorizedClients(ctx context.Context, userID string,
}
}
// The name lives on the joined client, so the generic sorting of the authorization model cannot handle it
if listRequestOptions.Sort.Column == "name" && utils.IsValidSortDirection(listRequestOptions.Sort.Direction) {
query = query.Order("LOWER(oidc_clients.name) " + utils.NormalizeSortDirection(listRequestOptions.Sort.Direction))
}
var authorizedClients []model.UserAuthorizedOidcClient
response, err := utils.PaginateFilterAndSort(listRequestOptions, query, &authorizedClients)
@@ -802,7 +812,7 @@ func (s *OidcService) RevokeAuthorizedClient(ctx context.Context, userID string,
return nil
}
func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID string, listRequestOptions utils.ListRequestOptions) ([]dto.AccessibleOidcClientDto, utils.PaginationResponse, error) {
func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID string, search string, listRequestOptions utils.ListRequestOptions) ([]dto.AccessibleOidcClientDto, utils.PaginationResponse, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
@@ -836,6 +846,10 @@ func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID stri
WHERE oidc_clients_allowed_user_groups.oidc_client_id = oidc_clients.id
AND oidc_clients_allowed_user_groups.user_group_id IN (?))`, false, userGroupIDs)
if search != "" {
query = query.Where("LOWER(oidc_clients.name) LIKE ?", "%"+strings.ToLower(search)+"%")
}
// Apply the launch URL filter before pagination so the app launcher never contains empty pages
if hasLaunchURL, ok := getHasLaunchURLFilter(listRequestOptions); ok {
if hasLaunchURL {
@@ -850,9 +864,11 @@ func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID stri
// Handle custom sorting for lastUsedAt column
var response utils.PaginationResponse
if listRequestOptions.Sort.Column == "lastUsedAt" && utils.IsValidSortDirection(listRequestOptions.Sort.Direction) {
// Never used clients share a NULL timestamp, so they are ordered by name to keep pages stable
query = query.
Joins("LEFT JOIN user_authorized_oidc_clients ON oidc_clients.id = user_authorized_oidc_clients.client_id AND user_authorized_oidc_clients.user_id = ?", userID).
Order("user_authorized_oidc_clients.last_used_at " + listRequestOptions.Sort.Direction + " NULLS LAST")
Order("user_authorized_oidc_clients.last_used_at " + utils.NormalizeSortDirection(listRequestOptions.Sort.Direction) + " NULLS LAST").
Order("LOWER(oidc_clients.name) ASC")
}
response, err = utils.PaginateFilterAndSort(listRequestOptions, query, &clients)
+140 -7
View File
@@ -10,6 +10,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/common"
@@ -25,7 +26,7 @@ import (
func TestListAuthorizedClientsRejectsMissingUser(t *testing.T) {
service := &OidcService{db: testutils.NewDatabaseForTest(t)}
_, _, err := service.ListAuthorizedClients(t.Context(), "missing-user", utils.ListRequestOptions{})
_, _, err := service.ListAuthorizedClients(t.Context(), "missing-user", "", utils.ListRequestOptions{})
require.True(t, apperror.IsCode(err, apperror.CodeUserNotFound))
}
@@ -1040,11 +1041,11 @@ func TestOidcService_ListAccessibleOidcClients_requiresExplicitGroupPermission(t
require.NoError(t, db.Create(&clients[i]).Error)
}
groupClients, _, err := s.ListAccessibleOidcClients(t.Context(), userWithGroup.ID, utils.ListRequestOptions{})
groupClients, _, err := s.ListAccessibleOidcClients(t.Context(), userWithGroup.ID, "", utils.ListRequestOptions{})
require.NoError(t, err)
assert.ElementsMatch(t, []string{"Unrestricted", "Restricted to user group"}, accessibleClientNames(groupClients))
noGroupClients, _, err := s.ListAccessibleOidcClients(t.Context(), userWithoutGroup.ID, utils.ListRequestOptions{})
noGroupClients, _, err := s.ListAccessibleOidcClients(t.Context(), userWithoutGroup.ID, "", utils.ListRequestOptions{})
require.NoError(t, err)
assert.Equal(t, []string{"Unrestricted"}, accessibleClientNames(noGroupClients))
}
@@ -1079,22 +1080,22 @@ func TestOidcService_ListClientViewsFilterByLaunchURLPresence(t *testing.T) {
Filters: map[string][]any{"hasLaunchURL": {false}},
}
allClients, allClientsPagination, err := s.ListAccessibleOidcClients(t.Context(), user.ID, utils.ListRequestOptions{})
allClients, allClientsPagination, err := s.ListAccessibleOidcClients(t.Context(), user.ID, "", utils.ListRequestOptions{})
require.NoError(t, err)
assert.Equal(t, int64(3), allClientsPagination.TotalItems)
assert.ElementsMatch(t, []string{"Launchable", "Missing launch URL", "Empty launch URL"}, accessibleClientNames(allClients))
launchableClients, launchablePagination, err := s.ListAccessibleOidcClients(t.Context(), user.ID, withLaunchURL)
launchableClients, launchablePagination, err := s.ListAccessibleOidcClients(t.Context(), user.ID, "", withLaunchURL)
require.NoError(t, err)
assert.Equal(t, int64(1), launchablePagination.TotalItems)
assert.Equal(t, []string{"Launchable"}, accessibleClientNames(launchableClients))
allAuthorizations, allAuthorizationsPagination, err := s.ListAuthorizedClients(t.Context(), user.ID, utils.ListRequestOptions{})
allAuthorizations, allAuthorizationsPagination, err := s.ListAuthorizedClients(t.Context(), user.ID, "", utils.ListRequestOptions{})
require.NoError(t, err)
assert.Equal(t, int64(3), allAuthorizationsPagination.TotalItems)
assert.Len(t, allAuthorizations, 3)
hiddenAuthorizations, hiddenPagination, err := s.ListAuthorizedClients(t.Context(), user.ID, withoutLaunchURL)
hiddenAuthorizations, hiddenPagination, err := s.ListAuthorizedClients(t.Context(), user.ID, "", withoutLaunchURL)
require.NoError(t, err)
assert.Equal(t, int64(2), hiddenPagination.TotalItems)
assert.ElementsMatch(t, []string{"Missing launch URL", "Empty launch URL"}, []string{
@@ -1103,6 +1104,138 @@ func TestOidcService_ListClientViewsFilterByLaunchURLPresence(t *testing.T) {
})
}
func TestOidcService_ListAccessibleOidcClients_searchAndSort(t *testing.T) {
forEachTestDatabase(t, func(t *testing.T, db *gorm.DB) {
s, err := NewOidcService(db, nil, nil, nil, nil, nil, nil, nil)
require.NoError(t, err)
user := model.User{Username: "search-and-sort"}
require.NoError(t, db.Create(&user).Error)
clients := []model.OidcClient{
{Name: "alpha"},
{Name: "Bravo"},
{Name: "charlie Cloud"},
{Name: "Delta"},
}
for i := range clients {
require.NoError(t, db.Create(&clients[i]).Error)
}
// Only Bravo and Delta have been used, so the other two have no last used timestamp
now := time.Now()
require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{UserID: user.ID, ClientID: clients[1].ID, LastUsedAt: datatype.DateTime(now.Add(-time.Hour))}).Error)
require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{UserID: user.ID, ClientID: clients[3].ID, LastUsedAt: datatype.DateTime(now)}).Error)
list := func(search string, column string, direction string) []string {
options := utils.ListRequestOptions{}
options.Sort.Column = column
options.Sort.Direction = direction
result, _, err := s.ListAccessibleOidcClients(t.Context(), user.ID, search, options)
require.NoError(t, err)
return accessibleClientNames(result)
}
t.Run("sorts by last used with never used clients last in name order", func(t *testing.T) {
assert.Equal(t, []string{"Delta", "Bravo", "alpha", "charlie Cloud"}, list("", "lastUsedAt", "DESC"))
})
t.Run("sorts by name case-insensitively", func(t *testing.T) {
assert.Equal(t, []string{"alpha", "Bravo", "charlie Cloud", "Delta"}, list("", "name", "asc"))
assert.Equal(t, []string{"Delta", "charlie Cloud", "Bravo", "alpha"}, list("", "name", "desc"))
})
t.Run("searches the name case-insensitively", func(t *testing.T) {
assert.Equal(t, []string{"charlie Cloud"}, list("CLOUD", "name", "asc"))
assert.Equal(t, []string{"Delta"}, list("lTa", "name", "asc"))
assert.Empty(t, list("missing", "name", "asc"))
})
t.Run("combines search and sort", func(t *testing.T) {
assert.Equal(t, []string{"Bravo", "charlie Cloud"}, list("r", "lastUsedAt", "desc"))
})
})
}
func TestOidcService_ListAuthorizedClients_searchAndSort(t *testing.T) {
forEachTestDatabase(t, func(t *testing.T, db *gorm.DB) {
s, err := NewOidcService(db, nil, nil, nil, nil, nil, nil, nil)
require.NoError(t, err)
user := model.User{Username: "authorized-search-and-sort"}
require.NoError(t, db.Create(&user).Error)
launchURL := "https://launchable.example.com"
clients := []model.OidcClient{
{Name: "alpha"},
{Name: "Bravo"},
{Name: "charlie Cloud"},
{Name: "Launchable", LaunchURL: &launchURL},
}
lastUsedOffsets := []time.Duration{-3 * time.Hour, -time.Hour, -2 * time.Hour, 0}
now := time.Now()
for i := range clients {
require.NoError(t, db.Create(&clients[i]).Error)
require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
UserID: user.ID,
ClientID: clients[i].ID,
LastUsedAt: datatype.DateTime(now.Add(lastUsedOffsets[i])),
}).Error)
}
list := func(search string, hasLaunchURL *bool, column string, direction string) []string {
options := utils.ListRequestOptions{}
options.Sort.Column = column
options.Sort.Direction = direction
if hasLaunchURL != nil {
options.Filters = map[string][]any{"hasLaunchURL": {*hasLaunchURL}}
}
result, _, err := s.ListAuthorizedClients(t.Context(), user.ID, search, options)
require.NoError(t, err)
names := make([]string, len(result))
for i := range result {
names[i] = result[i].Client.Name
}
return names
}
hidden := false
t.Run("sorts hidden clients by name case-insensitively", func(t *testing.T) {
assert.Equal(t, []string{"alpha", "Bravo", "charlie Cloud"}, list("", &hidden, "name", "asc"))
assert.Equal(t, []string{"charlie Cloud", "Bravo", "alpha"}, list("", &hidden, "name", "desc"))
})
t.Run("sorts hidden clients by last used", func(t *testing.T) {
assert.Equal(t, []string{"Bravo", "charlie Cloud", "alpha"}, list("", &hidden, "lastUsedAt", "desc"))
})
t.Run("searches the client name case-insensitively", func(t *testing.T) {
assert.Equal(t, []string{"charlie Cloud"}, list("cloud", &hidden, "name", "asc"))
assert.Equal(t, []string{"Launchable"}, list("LAUNCH", nil, "name", "asc"))
assert.Empty(t, list("launch", &hidden, "name", "asc"))
})
})
}
// forEachTestDatabase runs the test on SQLite and Postgres because LIKE and NULL ordering behave differently on them
func forEachTestDatabase(t *testing.T, test func(t *testing.T, db *gorm.DB)) {
t.Run("sqlite", func(t *testing.T) {
test(t, testutils.NewDatabaseForTest(t))
})
t.Run("postgres", func(t *testing.T) {
db := testutils.NewPostgresDatabaseForTest(t)
// Column types such as DateTime pick their encoding from the configured provider
previousProvider := common.EnvConfig.DbProvider
common.EnvConfig.DbProvider = common.DbProviderPostgres
t.Cleanup(func() {
common.EnvConfig.DbProvider = previousProvider
})
test(t, db)
})
}
func accessibleClientNames(clients []dto.AccessibleOidcClientDto) []string {
names := make([]string, len(clients))
for i := range clients {