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 {
+7
View File
@@ -497,6 +497,13 @@
"show_all_apps": "Show all apps",
"show_hidden_apps": "Show hidden apps",
"hide_all_apps": "Hide all apps",
"sort_by": "Sort by",
"recently_used": "Recently used",
"name_a_to_z": "Name (A-Z)",
"name_z_to_a": "Name (Z-A)",
"search_apps": "Search apps",
"no_apps_match_your_search": "No apps match your search",
"try_a_different_search_term": "Try a different search term.",
"revoke_access": "Revoke Access",
"revoke_access_description": "Revoke access to {#b}{clientName}{/b}. {#b}{clientName}{/b} will no longer be able to access your account information.",
"revoke_access_successful": "The access to {clientName} has been successfully revoked.",
@@ -0,0 +1,82 @@
<script lang="ts">
import * as Pagination from '#lib/components/ui/pagination/index.ts';
import * as Select from '#lib/components/ui/select/index.ts';
import { m } from '#lib/paraglide/messages.js';
import type { PaginationResponse } from '#lib/types/list-request.type.ts';
import { cn } from '#lib/utils/style.ts';
let {
pagination,
onPageChange,
onPageSizeChange,
pageSizes = [20, 50, 100],
hideWhenSinglePage = false,
class: className
}: {
pagination?: PaginationResponse;
onPageChange: (page: number) => void;
onPageSizeChange: (size: number) => void;
pageSizes?: number[];
hideWhenSinglePage?: boolean;
class?: string;
} = $props();
// The controls are useless when every item already fits on a page of the smallest size
const hidden = $derived(
hideWhenSinglePage && (pagination?.totalItems ?? 0) <= Math.min(...pageSizes)
);
</script>
{#if !hidden}
<div
class={cn(
'mt-5 flex flex-col-reverse items-center justify-between gap-3 sm:flex-row',
className
)}
>
<div class="flex items-center space-x-2">
<p class="text-sm font-medium">{m.items_per_page()}</p>
<Select.Root
type="single"
value={pagination?.itemsPerPage.toString()}
onValueChange={(v) => onPageSizeChange(Number(v))}
>
<Select.Trigger class="w-20" aria-label={m.items_per_page()}>
{pagination?.itemsPerPage}
</Select.Trigger>
<Select.Content>
{#each pageSizes as size (size)}
<Select.Item value={size.toString()}>{size}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<Pagination.Root
class="mx-0 w-auto"
count={pagination?.totalItems || 0}
perPage={pagination?.itemsPerPage}
{onPageChange}
page={pagination?.currentPage}
>
{#snippet children({ pages })}
<Pagination.Content class="flex justify-end">
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as page (page.key)}
{#if page.type !== 'ellipsis' && page.value != 0}
<Pagination.Item>
<Pagination.Link {page} isActive={pagination?.currentPage === page.value}>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
{/snippet}
</Pagination.Root>
</div>
{/if}
@@ -2,9 +2,8 @@
import { goto } from '$app/navigation';
import { page as currentPage } from '$app/state';
import ActionsMenu from '#lib/components/actions-menu.svelte';
import ListPagination from '#lib/components/list-pagination.svelte';
import Checkbox from '#lib/components/ui/checkbox/checkbox.svelte';
import * as Pagination from '#lib/components/ui/pagination/index.ts';
import * as Select from '#lib/components/ui/select/index.ts';
import * as Table from '#lib/components/ui/table/index.js';
import Empty from '#lib/icons/empty.svelte';
import { m } from '#lib/paraglide/messages.js';
@@ -317,50 +316,10 @@
</div>
{/if}
<div class="mt-5 flex flex-col-reverse items-center justify-between gap-3 sm:flex-row">
<div class="flex items-center space-x-2">
<p class="text-sm font-medium">{m.items_per_page()}</p>
<Select.Root
type="single"
value={items?.pagination.itemsPerPage.toString()}
onValueChange={(v) => onPageSizeChange(Number(v))}
>
<Select.Trigger class="w-20">
{items?.pagination.itemsPerPage}
</Select.Trigger>
<Select.Content>
{#each availablePageSizes as size (size)}
<Select.Item value={size.toString()}>{size}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<Pagination.Root
class="mx-0 w-auto"
count={items?.pagination.totalItems || 0}
perPage={items?.pagination.itemsPerPage}
{onPageChange}
page={items?.pagination.currentPage}
>
{#snippet children({ pages })}
<Pagination.Content class="flex justify-end">
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as page (page.key)}
{#if page.type !== 'ellipsis' && page.value != 0}
<Pagination.Item>
<Pagination.Link {page} isActive={items?.pagination.currentPage === page.value}>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
{/snippet}
</Pagination.Root>
</div>
<ListPagination
pagination={items?.pagination}
pageSizes={availablePageSizes}
{onPageChange}
{onPageSizeChange}
/>
{/if}
+171 -105
View File
@@ -1,23 +1,35 @@
<script lang="ts">
import { openConfirmDialog } from '#lib/components/confirm-dialog/index.ts';
import { Button } from '#lib/components/ui/button/index.ts';
import ListPagination from '#lib/components/list-pagination.svelte';
import { Button, buttonVariants } from '#lib/components/ui/button/index.ts';
import * as DropdownMenu from '#lib/components/ui/dropdown-menu/index.js';
import * as Empty from '#lib/components/ui/empty/index.ts';
import * as Pagination from '#lib/components/ui/pagination/index.ts';
import * as InputGroup from '#lib/components/ui/input-group/index.js';
import { Separator } from '#lib/components/ui/separator/index.ts';
import { m } from '#lib/paraglide/messages.js';
import OIDCService from '#lib/services/oidc-service.ts';
import appConfigStore from '#lib/stores/application-configuration-store.ts';
import type { ListRequestOptions, Paginated } from '#lib/types/list-request.type.ts';
import type {
AccessibleOidcClient,
AuthorizedOidcClient,
OidcClientMetaData
} from '#lib/types/oidc.type.ts';
import { debounced } from '#lib/utils/debounce-util.ts';
import { axiosErrorToast } from '#lib/utils/error-util.ts';
import { cn } from '#lib/utils/style.ts';
import { ChevronDown, LayoutDashboard } from '@lucide/svelte';
import { ArrowUpDown, ChevronDown, LayoutDashboard, Search, SearchX } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition';
import { prefersReducedMotion } from 'svelte/motion';
import { fade, slide } from 'svelte/transition';
import AuthorizedOidcClientCard from './authorized-oidc-client-card.svelte';
import {
getMyAppsPreferences,
myAppsPageSizes,
myAppsPreferences,
myAppsSortOptions,
type MyAppsSort
} from './my-apps-preferences.svelte.ts';
let { data } = $props();
let clients: Paginated<AccessibleOidcClient> = $state(data.clients);
@@ -29,6 +41,8 @@
data.authorizedClientRequestOptions
);
let showAllApps = $state(false);
let searchValue = $state('');
let sort: MyAppsSort = $state(getMyAppsPreferences().sort);
const hiddenAuthorizedClients = $derived(
authorizedClientsWithoutLaunchURL.data.map(({ client, lastUsedAt }) => ({
...client,
@@ -37,16 +51,70 @@
);
const oidcService = new OIDCService();
const animationDuration = $derived(
$appConfigStore.disableAnimations || prefersReducedMotion.current ? 0 : 200
);
const sortLabels: Record<MyAppsSort, () => string> = {
recentlyUsed: m.recently_used,
nameAsc: m.name_a_to_z,
nameDesc: m.name_z_to_a
};
// Only unfiltered results tell whether the user has any apps, so the toolbar never unmounts while a search is being typed or cleared
let hasAnyApps = $state(
data.clients.pagination.totalItems +
data.authorizedClientsWithoutLaunchURL.pagination.totalItems >
0
);
async function refreshClients() {
[clients, authorizedClientsWithoutLaunchURL] = await Promise.all([
oidcService.listOwnAccessibleClients(requestOptions),
oidcService.listOwnAuthorizedClients(authorizedClientRequestOptions)
]);
if (!requestOptions.search) {
hasAnyApps =
clients.pagination.totalItems + authorizedClientsWithoutLaunchURL.pagination.totalItems > 0;
}
if (authorizedClientsWithoutLaunchURL.pagination.totalItems === 0) {
showAllApps = false;
}
}
// Both grids share the search, sort and page size, so any change reloads both from their first page
async function reloadFromFirstPage({
search = searchValue,
limit = getMyAppsPreferences().paginationLimit
}: { search?: string; limit?: number } = {}) {
for (const options of [requestOptions, authorizedClientRequestOptions]) {
options.search = search || undefined;
options.sort = { ...myAppsSortOptions[sort] };
options.pagination = { page: 1, limit };
}
try {
await refreshClients();
// The search is applied together with its results so the empty state never describes stale results
searchValue = search;
} catch (e) {
axiosErrorToast(e);
}
}
const onSearch = debounced((search: string) => reloadFromFirstPage({ search }), 300);
async function onSortChange(value: string) {
sort = value as MyAppsSort;
myAppsPreferences.current.sort = sort;
await reloadFromFirstPage();
}
async function onPageSizeChange(size: number) {
myAppsPreferences.current.paginationLimit = size;
await reloadFromFirstPage({ limit: size });
}
async function onPageChange(page: number) {
requestOptions.pagination = { limit: clients.pagination.itemsPerPage, page };
clients = await oidcService.listOwnAccessibleClients(requestOptions);
@@ -94,23 +162,66 @@
<title>{m.my_apps()}</title>
</svelte:head>
<div>
<div>
<h1 class="mb-5 flex items-center gap-2 text-2xl font-bold">
<div class="mb-5 flex flex-wrap items-center justify-between gap-3">
<h1 class="flex items-center gap-2 text-2xl font-bold">
<LayoutDashboard class="text-primary/80 size-6" />
{m.my_apps()}
</h1>
{#if hasAnyApps}
<div class="flex w-full items-center gap-2 sm:w-auto">
<InputGroup.Root class="w-full sm:w-64">
<InputGroup.Input
value={searchValue}
placeholder={m.search()}
aria-label={m.search_apps()}
type="search"
oninput={(e: Event) => onSearch((e.currentTarget as HTMLInputElement).value)}
/>
<InputGroup.Addon>
<Search />
</InputGroup.Addon>
</InputGroup.Root>
<DropdownMenu.Root>
<DropdownMenu.Trigger
class={buttonVariants({ variant: 'outline', size: 'icon', class: 'shrink-0' })}
aria-label={m.sort_by()}
title={m.sort_by()}
>
<ArrowUpDown />
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="w-48">
<DropdownMenu.Label>{m.sort_by()}</DropdownMenu.Label>
<DropdownMenu.RadioGroup value={sort} onValueChange={onSortChange}>
{#each Object.keys(myAppsSortOptions) as MyAppsSort[] as option (option)}
<DropdownMenu.RadioItem value={option}
>{sortLabels[option]()}</DropdownMenu.RadioItem
>
{/each}
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
{/if}
</div>
{#if clients.data.length === 0 && !showAllApps}
<Empty.Root class="mt-20">
<Empty.Header>
<Empty.Media variant="icon">
<LayoutDashboard />
</Empty.Media>
<Empty.Title>{m.no_apps_available()}</Empty.Title>
<Empty.Description>
{m.contact_your_administrator_for_app_access()}
</Empty.Description>
{#if searchValue}
<Empty.Media variant="icon">
<SearchX />
</Empty.Media>
<Empty.Title>{m.no_apps_match_your_search()}</Empty.Title>
<Empty.Description>{m.try_a_different_search_term()}</Empty.Description>
{:else}
<Empty.Media variant="icon">
<LayoutDashboard />
</Empty.Media>
<Empty.Title>{m.no_apps_available()}</Empty.Title>
<Empty.Description>
{m.contact_your_administrator_for_app_access()}
</Empty.Description>
{/if}
</Empty.Header>
{#if authorizedClientsWithoutLaunchURL.pagination.totalItems > 0}
<Empty.Content>
@@ -122,108 +233,63 @@
</Empty.Root>
{:else}
{#if clients.data.length > 0}
<div
class="grid gap-3"
style="grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr));"
>
{#each clients.data as client (client.id)}
<AuthorizedOidcClientCard {client} onRevoke={revokeAuthorizedClient} />
{/each}
<!-- Gap fix if two elements are present-->
{#if clients.data.length === 2}
<div></div>
{/if}
<div class="grid">
{#key clients}
<div
class="col-start-1 row-start-1 grid gap-3 self-start"
style="grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr));"
transition:fade={{ duration: animationDuration }}
>
{#each clients.data as client (client.id)}
<AuthorizedOidcClientCard {client} onRevoke={revokeAuthorizedClient} />
{/each}
<!-- Gap fix if two elements are present-->
{#if clients.data.length === 2}
<div></div>
{/if}
</div>
{/key}
</div>
{/if}
{#if clients.pagination.totalPages > 1}
<div class="mt-5 flex items-center justify-center">
<Pagination.Root
class="mx-0 w-auto"
count={clients.pagination.totalItems}
perPage={clients.pagination.itemsPerPage}
{onPageChange}
page={clients.pagination.currentPage}
>
{#snippet children({ pages })}
<Pagination.Content class="flex justify-center">
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as page (page.key)}
{#if page.type !== 'ellipsis' && page.value != 0}
<Pagination.Item>
<Pagination.Link
{page}
isActive={clients.pagination.currentPage === page.value}
>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
{/snippet}
</Pagination.Root>
</div>
{/if}
<ListPagination
pagination={clients.pagination}
pageSizes={myAppsPageSizes}
{onPageChange}
{onPageSizeChange}
hideWhenSinglePage
/>
{#if showAllApps}
<div transition:slide={{ duration: 200 }}>
<div transition:slide={{ duration: animationDuration }}>
{#if clients.data.length > 0}
<Separator class="my-8" />
{/if}
<div
class="grid gap-3"
style="grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr));"
>
{#each hiddenAuthorizedClients as client (client.id)}
<AuthorizedOidcClientCard {client} onRevoke={revokeAuthorizedClient} />
{/each}
<!-- Gap fix if two elements are present-->
{#if hiddenAuthorizedClients.length === 2}
<div></div>
{/if}
<div class="grid">
{#key authorizedClientsWithoutLaunchURL}
<div
class="col-start-1 row-start-1 grid gap-3 self-start"
style="grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr));"
transition:fade={{ duration: animationDuration }}
>
{#each hiddenAuthorizedClients as client (client.id)}
<AuthorizedOidcClientCard {client} onRevoke={revokeAuthorizedClient} />
{/each}
<!-- Gap fix if two elements are present-->
{#if hiddenAuthorizedClients.length === 2}
<div></div>
{/if}
</div>
{/key}
</div>
{#if authorizedClientsWithoutLaunchURL.pagination.totalPages > 1}
<div class="mt-5 flex items-center justify-center">
<Pagination.Root
class="mx-0 w-auto"
count={authorizedClientsWithoutLaunchURL.pagination.totalItems}
perPage={authorizedClientsWithoutLaunchURL.pagination.itemsPerPage}
onPageChange={onAuthorizedClientPageChange}
page={authorizedClientsWithoutLaunchURL.pagination.currentPage}
>
{#snippet children({ pages })}
<Pagination.Content class="flex justify-center">
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as page (page.key)}
{#if page.type !== 'ellipsis' && page.value != 0}
<Pagination.Item>
<Pagination.Link
{page}
isActive={authorizedClientsWithoutLaunchURL.pagination.currentPage ===
page.value}
>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
{/snippet}
</Pagination.Root>
</div>
{/if}
<ListPagination
pagination={authorizedClientsWithoutLaunchURL.pagination}
pageSizes={myAppsPageSizes}
onPageChange={onAuthorizedClientPageChange}
{onPageSizeChange}
hideWhenSinglePage
/>
</div>
{/if}
{/if}
+6 -10
View File
@@ -1,19 +1,18 @@
import OIDCService from '#lib/services/oidc-service.ts';
import type { ListRequestOptions } from '#lib/types/list-request.type.ts';
import type { PageLoad } from './$types';
import { getMyAppsPreferences, myAppsSortOptions } from './my-apps-preferences.svelte.ts';
export const load: PageLoad = async () => {
const oidcService = new OIDCService();
const preferences = getMyAppsPreferences();
const appRequestOptions: ListRequestOptions = {
pagination: {
page: 1,
limit: 20
},
sort: {
column: 'lastUsedAt',
direction: 'desc'
limit: preferences.paginationLimit
},
sort: { ...myAppsSortOptions[preferences.sort] },
filters: {
hasLaunchURL: [true]
}
@@ -22,12 +21,9 @@ export const load: PageLoad = async () => {
const authorizedClientRequestOptions: ListRequestOptions = {
pagination: {
page: 1,
limit: 20
},
sort: {
column: 'lastUsedAt',
direction: 'desc'
limit: preferences.paginationLimit
},
sort: { ...myAppsSortOptions[preferences.sort] },
filters: {
hasLaunchURL: [false]
}
@@ -0,0 +1,38 @@
import type { SortRequest } from '#lib/types/list-request.type.ts';
import { PersistedState } from 'runed';
export const myAppsSortOptions = {
recentlyUsed: { column: 'lastUsedAt', direction: 'desc' },
nameAsc: { column: 'name', direction: 'asc' },
nameDesc: { column: 'name', direction: 'desc' }
} satisfies Record<string, SortRequest>;
export type MyAppsSort = keyof typeof myAppsSortOptions;
export const myAppsPageSizes = [20, 50, 100];
type MyAppsPreferences = {
sort: MyAppsSort;
paginationLimit: number;
};
const defaultPreferences: MyAppsPreferences = {
sort: 'recentlyUsed',
paginationLimit: myAppsPageSizes[0]
};
export const myAppsPreferences = new PersistedState<MyAppsPreferences>(
'my-apps-preferences',
defaultPreferences
);
// Stored values can be stale or edited by hand, so unknown ones fall back to the defaults
export function getMyAppsPreferences(): MyAppsPreferences {
const { sort, paginationLimit } = myAppsPreferences.current;
return {
sort: sort in myAppsSortOptions ? sort : defaultPreferences.sort,
paginationLimit: myAppsPageSizes.includes(paginationLimit)
? paginationLimit
: defaultPreferences.paginationLimit
};
}
+52
View File
@@ -86,3 +86,55 @@ test('Launch authorized client', async ({ page }) => {
client.launchURL
);
});
test('Sort apps by name and remember the choice', async ({ page }) => {
await page.goto('/settings/apps');
const appCards = page.getByRole('article');
const sortButton = page.getByRole('button', { name: 'Sort by' });
await expect(appCards.first().getByRole('heading')).toHaveText(oidcClients.nextcloud.name);
await sortButton.click();
await page.getByRole('menuitemradio', { name: 'Name (A-Z)' }).click();
await expect(appCards.first().getByRole('heading')).toHaveText(oidcClients.immich.name);
await page.reload();
await expect(appCards.first().getByRole('heading')).toHaveText(oidcClients.immich.name);
await sortButton.click();
await expect(page.getByRole('menuitemradio', { name: 'Name (A-Z)' })).toHaveAttribute(
'aria-checked',
'true'
);
await page.getByRole('menuitemradio', { name: 'Name (Z-A)' }).click();
await expect(appCards.first().getByRole('heading')).toHaveText(oidcClients.nextcloud.name);
});
test('Search apps by name', async ({ page }) => {
await page.goto('/settings/apps');
const appCards = page.getByRole('article');
const searchInput = page.getByRole('searchbox', { name: 'Search apps' });
await searchInput.fill('NEXT');
await expect(appCards).toHaveCount(1);
await expect(page.getByRole('article', { name: oidcClients.nextcloud.name })).toBeVisible();
await searchInput.fill('does-not-exist');
await expect(page.getByText('No apps match your search')).toBeVisible();
await expect(appCards).toHaveCount(0);
await searchInput.fill('');
await expect(appCards).toHaveCount(2);
});
test('Search also filters hidden apps', async ({ page }) => {
await page.goto('/settings/apps');
await page.getByRole('button', { name: /Show all apps/ }).click();
await expect(page.getByRole('article')).toHaveCount(4);
await page.getByRole('searchbox', { name: 'Search apps' }).fill(oidcClients.tailscale.name);
await expect(page.getByRole('article')).toHaveCount(1);
await expect(page.getByRole('article', { name: oidcClients.tailscale.name })).toBeVisible();
});