From 3ca9a55c7135d785bc6089fa33dc6d8287fd3f59 Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Mon, 10 Aug 2026 22:53:35 +0200 Subject: [PATCH] feat: hide apps without launch url on My Apps page --- .../internal/controller/oidc_controller.go | 3 + backend/internal/service/e2etest_service.go | 1 + backend/internal/service/oidc_service.go | 29 +++ backend/internal/service/oidc_service_test.go | 54 +++++ frontend/messages/en.json | 3 + .../components/ui/empty/empty-content.svelte | 23 ++ .../ui/empty/empty-description.svelte | 23 ++ .../components/ui/empty/empty-header.svelte | 20 ++ .../components/ui/empty/empty-media.svelte | 41 ++++ .../components/ui/empty/empty-title.svelte | 20 ++ .../src/lib/components/ui/empty/empty.svelte | 23 ++ frontend/src/lib/components/ui/empty/index.ts | 22 ++ .../ui/tooltip/tooltip-content.svelte | 17 +- frontend/src/lib/services/oidc-service.ts | 6 + frontend/src/lib/types/oidc.type.ts | 6 + .../src/routes/settings/apps/+page.svelte | 228 +++++++++++++----- frontend/src/routes/settings/apps/+page.ts | 29 ++- .../apps/authorized-oidc-client-card.svelte | 30 +-- tests/data.ts | 3 +- tests/resources/export/database.json | 2 +- tests/specs/apps-dashboard.spec.ts | 72 +++--- 21 files changed, 547 insertions(+), 108 deletions(-) create mode 100644 frontend/src/lib/components/ui/empty/empty-content.svelte create mode 100644 frontend/src/lib/components/ui/empty/empty-description.svelte create mode 100644 frontend/src/lib/components/ui/empty/empty-header.svelte create mode 100644 frontend/src/lib/components/ui/empty/empty-media.svelte create mode 100644 frontend/src/lib/components/ui/empty/empty-title.svelte create mode 100644 frontend/src/lib/components/ui/empty/empty.svelte create mode 100644 frontend/src/lib/components/ui/empty/index.ts diff --git a/backend/internal/controller/oidc_controller.go b/backend/internal/controller/oidc_controller.go index 94d4afce..66a25569 100644 --- a/backend/internal/controller/oidc_controller.go +++ b/backend/internal/controller/oidc_controller.go @@ -394,6 +394,7 @@ func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) error { // @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[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] // @Router /api/oidc/users/me/authorized-clients [get] func (oc *OidcController) listOwnAuthorizedClientsHandler(c *gin.Context) error { @@ -410,6 +411,7 @@ func (oc *OidcController) listOwnAuthorizedClientsHandler(c *gin.Context) error // @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[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] // @Router /api/oidc/users/{id}/authorized-clients [get] func (oc *OidcController) listAuthorizedClientsHandler(c *gin.Context) error { @@ -467,6 +469,7 @@ func (oc *OidcController) revokeOwnClientAuthorizationHandler(c *gin.Context) er // @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[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] // @Router /api/oidc/users/me/clients [get] func (oc *OidcController) listOwnAccessibleClientsHandler(c *gin.Context) error { diff --git a/backend/internal/service/e2etest_service.go b/backend/internal/service/e2etest_service.go index 3bc1ba91..37e3cfa0 100644 --- a/backend/internal/service/e2etest_service.go +++ b/backend/internal/service/e2etest_service.go @@ -192,6 +192,7 @@ func (s *TestService) SeedDatabase(baseURL string) error { ID: "606c7782-f2b1-49e5-8ea9-26eb1b06d018", }, Name: "Immich", + LaunchURL: new("https://immich.local"), Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x CallbackURLs: datatype.StringList{"http://immich.localhost/auth/callback"}, CreatedByID: new(users[1].ID), diff --git a/backend/internal/service/oidc_service.go b/backend/internal/service/oidc_service.go index 9498ee16..2e5664da 100644 --- a/backend/internal/service/oidc_service.go +++ b/backend/internal/service/oidc_service.go @@ -592,6 +592,16 @@ func (s *OidcService) ListAuthorizedClients(ctx context.Context, userID string, Preload("Client"). Where("user_id = ?", userID) + // 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 { + query = query.Where("oidc_clients.launch_url IS NULL OR oidc_clients.launch_url = ''") + } + } + var authorizedClients []model.UserAuthorizedOidcClient response, err := utils.PaginateFilterAndSort(listRequestOptions, query, &authorizedClients) @@ -667,6 +677,15 @@ 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) + // Apply the launch URL filter before pagination so the app launcher never contains empty pages + if hasLaunchURL, ok := getHasLaunchURLFilter(listRequestOptions); ok { + if hasLaunchURL { + query = query.Where("oidc_clients.launch_url IS NOT NULL AND oidc_clients.launch_url <> ''") + } else { + query = query.Where("oidc_clients.launch_url IS NULL OR oidc_clients.launch_url = ''") + } + } + var clients []model.OidcClient // Handle custom sorting for lastUsedAt column @@ -705,6 +724,16 @@ func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID stri return dtos, response, err } +func getHasLaunchURLFilter(listRequestOptions utils.ListRequestOptions) (bool, bool) { + values := listRequestOptions.Filters["hasLaunchURL"] + if len(values) == 0 { + return false, false + } + + hasLaunchURL, ok := values[0].(bool) + return hasLaunchURL, ok +} + func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, userID string, scopes []string, authenticationMethod string) (*dto.OidcClientPreviewDto, error) { client, err := s.getClientInternal(ctx, clientID, s.db, false) if err != nil { diff --git a/backend/internal/service/oidc_service_test.go b/backend/internal/service/oidc_service_test.go index 73fdd0e2..e3acc047 100644 --- a/backend/internal/service/oidc_service_test.go +++ b/backend/internal/service/oidc_service_test.go @@ -858,6 +858,60 @@ func TestOidcService_ListAccessibleOidcClients_requiresExplicitGroupPermission(t assert.Equal(t, []string{"Unrestricted"}, accessibleClientNames(noGroupClients)) } +func TestOidcService_ListClientViewsFilterByLaunchURLPresence(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + s, err := NewOidcService(db, nil, nil, nil, nil, nil, nil) + require.NoError(t, err) + + user := model.User{Username: "launch-url-filter"} + require.NoError(t, db.Create(&user).Error) + + launchURL := "https://launchable.example.com" + emptyLaunchURL := "" + clients := []model.OidcClient{ + {Name: "Launchable", LaunchURL: &launchURL}, + {Name: "Missing launch URL"}, + {Name: "Empty launch URL", LaunchURL: &emptyLaunchURL}, + } + 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, + }).Error) + } + + withLaunchURL := utils.ListRequestOptions{ + Filters: map[string][]any{"hasLaunchURL": {true}}, + } + withoutLaunchURL := utils.ListRequestOptions{ + Filters: map[string][]any{"hasLaunchURL": {false}}, + } + + 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) + 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{}) + 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) + require.NoError(t, err) + assert.Equal(t, int64(2), hiddenPagination.TotalItems) + assert.ElementsMatch(t, []string{"Missing launch URL", "Empty launch URL"}, []string{ + hiddenAuthorizations[0].Client.Name, + hiddenAuthorizations[1].Client.Name, + }) +} + func accessibleClientNames(clients []dto.AccessibleOidcClientDto) []string { names := make([]string, len(clients)) for i := range clients { diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 8a368501..f4d46cf6 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -491,6 +491,9 @@ "client_name_description": "The name of the client that shows in the Pocket ID UI.", "client_description": "Description", "client_description_description": "An optional description of the client that shows in the Pocket ID UI.", + "show_all_apps": "Show all apps", + "show_hidden_apps": "Show hidden apps", + "hide_all_apps": "Hide all apps", "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.", diff --git a/frontend/src/lib/components/ui/empty/empty-content.svelte b/frontend/src/lib/components/ui/empty/empty-content.svelte new file mode 100644 index 00000000..15451b44 --- /dev/null +++ b/frontend/src/lib/components/ui/empty/empty-content.svelte @@ -0,0 +1,23 @@ + + +
+ {@render children?.()} +
diff --git a/frontend/src/lib/components/ui/empty/empty-description.svelte b/frontend/src/lib/components/ui/empty/empty-description.svelte new file mode 100644 index 00000000..f540551c --- /dev/null +++ b/frontend/src/lib/components/ui/empty/empty-description.svelte @@ -0,0 +1,23 @@ + + +
a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary', + className + )} + {...restProps} +> + {@render children?.()} +
diff --git a/frontend/src/lib/components/ui/empty/empty-header.svelte b/frontend/src/lib/components/ui/empty/empty-header.svelte new file mode 100644 index 00000000..09569974 --- /dev/null +++ b/frontend/src/lib/components/ui/empty/empty-header.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/frontend/src/lib/components/ui/empty/empty-media.svelte b/frontend/src/lib/components/ui/empty/empty-media.svelte new file mode 100644 index 00000000..db2d1033 --- /dev/null +++ b/frontend/src/lib/components/ui/empty/empty-media.svelte @@ -0,0 +1,41 @@ + + + + +
+ {@render children?.()} +
diff --git a/frontend/src/lib/components/ui/empty/empty-title.svelte b/frontend/src/lib/components/ui/empty/empty-title.svelte new file mode 100644 index 00000000..8d00c2b2 --- /dev/null +++ b/frontend/src/lib/components/ui/empty/empty-title.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/frontend/src/lib/components/ui/empty/empty.svelte b/frontend/src/lib/components/ui/empty/empty.svelte new file mode 100644 index 00000000..fcaa4328 --- /dev/null +++ b/frontend/src/lib/components/ui/empty/empty.svelte @@ -0,0 +1,23 @@ + + +
+ {@render children?.()} +
diff --git a/frontend/src/lib/components/ui/empty/index.ts b/frontend/src/lib/components/ui/empty/index.ts new file mode 100644 index 00000000..c74f8f31 --- /dev/null +++ b/frontend/src/lib/components/ui/empty/index.ts @@ -0,0 +1,22 @@ +import Content from './empty-content.svelte'; +import Description from './empty-description.svelte'; +import Header from './empty-header.svelte'; +import Media from './empty-media.svelte'; +import Title from './empty-title.svelte'; +import Root from './empty.svelte'; + +export { + Root, + Header, + Media, + Title, + Description, + Content, + // + Root as Empty, + Header as EmptyHeader, + Media as EmptyMedia, + Title as EmptyTitle, + Description as EmptyDescription, + Content as EmptyContent +}; diff --git a/frontend/src/lib/components/ui/tooltip/tooltip-content.svelte b/frontend/src/lib/components/ui/tooltip/tooltip-content.svelte index d3d2097d..35b95fef 100644 --- a/frontend/src/lib/components/ui/tooltip/tooltip-content.svelte +++ b/frontend/src/lib/components/ui/tooltip/tooltip-content.svelte @@ -27,17 +27,21 @@ {sideOffset} {side} class={cn( - 'data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 inline-flex items-center gap-1.5 rounded-xl px-3 py-1.5 text-xs has-data-[slot=kbd]:pr-1.5 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-lg bg-foreground text-background z-50 w-fit max-w-xs origin-(--bits-tooltip-content-transform-origin)', + 'data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/5 text-popover-foreground relative isolate inline-flex w-fit max-w-xs origin-(--bits-tooltip-content-transform-origin) items-center gap-1.5 rounded-xl px-3 py-1.5 text-xs shadow-lg ring-1 has-data-[slot=kbd]:pr-1.5 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-lg z-50', className )} {...restProps} > + {@render children?.()} {#snippet child({ props })}
+ + diff --git a/frontend/src/lib/services/oidc-service.ts b/frontend/src/lib/services/oidc-service.ts index 1fcf6abc..5234b85a 100644 --- a/frontend/src/lib/services/oidc-service.ts +++ b/frontend/src/lib/services/oidc-service.ts @@ -1,6 +1,7 @@ import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type'; import type { AccessibleOidcClient, + AuthorizedOidcClient, CompleteInteractionResponse, InteractionSession, InteractionStep, @@ -121,6 +122,11 @@ class OidcService extends APIService { return res.data as Paginated; }; + listOwnAuthorizedClients = async (options?: ListRequestOptions) => { + const res = await this.api.get('/oidc/users/me/authorized-clients', { params: options }); + return res.data as Paginated; + }; + revokeOwnAuthorizedClient = async (clientId: string) => { await this.api.delete(`/oidc/users/me/authorized-clients/${encodeClientIdParam(clientId)}`); }; diff --git a/frontend/src/lib/types/oidc.type.ts b/frontend/src/lib/types/oidc.type.ts index 8ed34ddf..f7463454 100644 --- a/frontend/src/lib/types/oidc.type.ts +++ b/frontend/src/lib/types/oidc.type.ts @@ -94,6 +94,12 @@ export type AccessibleOidcClient = OidcClientMetaData & { lastUsedAt: Date | null; }; +export type AuthorizedOidcClient = { + scope: string; + client: OidcClientMetaData; + lastUsedAt: Date; +}; + export type InteractionStep = 'authenticate' | 'select_account' | 'reauthenticate' | 'consent'; export type InteractionScopeInfo = { diff --git a/frontend/src/routes/settings/apps/+page.svelte b/frontend/src/routes/settings/apps/+page.svelte index 9ffb02dd..a8d01963 100644 --- a/frontend/src/routes/settings/apps/+page.svelte +++ b/frontend/src/routes/settings/apps/+page.svelte @@ -1,28 +1,65 @@ - +
{#if client.hasLogo} @@ -124,17 +125,18 @@ {:else}
{/if} - + {#if client.launchURL} + + {/if}
diff --git a/tests/data.ts b/tests/data.ts index f6e256ae..92e9c8f2 100644 --- a/tests/data.ts +++ b/tests/data.ts @@ -37,7 +37,8 @@ export const oidcClients = { id: '606c7782-f2b1-49e5-8ea9-26eb1b06d018', name: 'Immich', callbackUrl: 'http://immich.localhost/auth/callback', - secret: 'PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x' + secret: 'PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x', + launchURL: 'https://immich.local' }, tailscale: { id: '7c21a609-96b5-4011-9900-272b8d31a9d1', diff --git a/tests/resources/export/database.json b/tests/resources/export/database.json index bd47b034..b27915c8 100644 --- a/tests/resources/export/database.json +++ b/tests/resources/export/database.json @@ -124,7 +124,7 @@ "image_type": null, "is_group_restricted": true, "is_public": false, - "launch_url": null, + "launch_url": "https://immich.local", "logout_callback_urls": "bnVsbA==", "metadata_expires_at": null, "metadata_grant_types": "bnVsbA==", diff --git a/tests/specs/apps-dashboard.spec.ts b/tests/specs/apps-dashboard.spec.ts index 3473c8e4..0145774a 100644 --- a/tests/specs/apps-dashboard.spec.ts +++ b/tests/specs/apps-dashboard.spec.ts @@ -5,63 +5,71 @@ import { cleanupBackend } from '../utils/cleanup.util'; test.beforeEach(async () => await cleanupBackend()); -test('Dashboard shows all clients in the correct order', async ({ page }) => { - const client1 = oidcClients.tailscale; - const client2 = oidcClients.nextcloud; +test('Dashboard shows only clients with launch URLs in the correct order', async ({ page }) => { + const client1 = oidcClients.nextcloud; + const client2 = oidcClients.immich; await page.goto('/settings/apps'); - await expect(page.getByTestId('authorized-oidc-client-card')).toHaveCount(7); + const appCards = page.getByRole('article'); + await expect(appCards).toHaveCount(2); // Should be first - const card1 = page.getByTestId('authorized-oidc-client-card').first(); + const card1 = appCards.first(); await expect(card1.getByRole('heading')).toHaveText(client1.name); + await expect(card1.getByText(new URL(client1.launchURL).hostname)).toBeVisible(); - const card2 = page.getByTestId('authorized-oidc-client-card').nth(1); + const card2 = page.getByRole('article', { name: client2.name }); await expect(card2.getByRole('heading', { name: client2.name })).toBeVisible(); await expect(card2.getByText(new URL(client2.launchURL).hostname)).toBeVisible(); + + await expect(page.getByRole('article', { name: oidcClients.tailscale.name })).toHaveCount(0); }); test.describe('Dashboard shows only clients where user has access', () => { - test("User can't see one client", async ({ page }) => { + test("User can't see a restricted launchable client", async ({ page }) => { await authUtil.changeUser(page, 'craig'); - const notVisibleClient = oidcClients.immich; - await page.goto('/settings/apps'); - const cards = page.getByTestId('authorized-oidc-client-card'); - - await expect(cards).toHaveCount(6); - - const cardTexts = await cards.allTextContents(); - expect(cardTexts.some((text) => text.includes(notVisibleClient.name))).toBe(false); + await expect(page.getByRole('article')).toHaveCount(1); + await expect(page.getByRole('article', { name: oidcClients.nextcloud.name })).toBeVisible(); + await expect(page.getByRole('article', { name: oidcClients.immich.name })).toHaveCount(0); }); - test('User can see all clients', async ({ page }) => { + + test('User can see every accessible launchable client', async ({ page }) => { await page.goto('/settings/apps'); - const cards = page.getByTestId('authorized-oidc-client-card'); - await expect(cards).toHaveCount(7); + + await expect(page.getByRole('article')).toHaveCount(2); + await expect(page.getByRole('article', { name: oidcClients.nextcloud.name })).toBeVisible(); + await expect(page.getByRole('article', { name: oidcClients.immich.name })).toBeVisible(); }); }); -test('Revoke authorized client', async ({ page }) => { +test('Show and revoke a hidden authorized client in the app grid', async ({ page }) => { const client = oidcClients.tailscale; await page.goto('/settings/apps'); - const card = page.getByTestId('authorized-oidc-client-card').filter({ hasText: client.name }); + const appCards = page.getByRole('article'); + const clientCard = page.getByRole('article', { name: client.name }); + await expect(clientCard).toHaveCount(0); - card.getByRole('button', { name: 'Toggle menu' }).click(); + await page.getByRole('button', { name: /Show all apps/ }).click(); + await expect(appCards).toHaveCount(4); + await expect(clientCard).toBeVisible(); + await expect(page.getByRole('main').getByRole('separator')).toBeVisible(); + await expect(clientCard.getByRole('link', { name: 'Launch' })).toHaveCount(0); + await expect(clientCard.getByRole('button', { name: 'Revoke' })).toHaveCount(0); + await clientCard.getByRole('button', { name: 'Toggle menu' }).click(); await page.getByRole('menuitem', { name: 'Revoke' }).click(); - await page.getByRole('button', { name: 'Revoke' }).click(); + await page.getByRole('alertdialog').getByRole('button', { name: 'Revoke' }).click(); - await expect(page.locator('[data-type="success"]')).toHaveText( - `The access to ${client.name} has been successfully revoked.` - ); - - // The ... ago text should be gone as there is no last access anymore - await expect(card).not.toContainText('ago'); + await expect( + page.getByText(`The access to ${client.name} has been successfully revoked.`, { exact: true }) + ).toBeVisible(); + await expect(clientCard).toHaveCount(0); }); test('Launch authorized client', async ({ page }) => { @@ -69,11 +77,11 @@ test('Launch authorized client', async ({ page }) => { await page.goto('/settings/apps'); - const card1 = page.getByTestId('authorized-oidc-client-card').first(); - await expect(card1.getByRole('button', { name: 'Launch' })).toBeDisabled(); + const appCards = page.getByRole('article'); + await expect(appCards.getByRole('link', { name: 'Launch' })).toHaveCount(2); - const card2 = page.getByTestId('authorized-oidc-client-card').nth(1); - await expect(card2.getByRole('link', { name: 'Launch' })).toHaveAttribute( + const card = page.getByRole('article', { name: client.name }); + await expect(card.getByRole('link', { name: 'Launch' })).toHaveAttribute( 'href', client.launchURL );