diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 79cf3c99..d52ae35f 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -411,8 +411,6 @@ "the_jwks_does_not_contain_any_key": "The JWKS does not contain any key", "jwks_key_is_not_a_jwk": "Key {number} of the JWKS is not a JWK", "oidc_allowed_group_count": "Allowed Group Count", - "show_advanced_options": "Show Advanced Options", - "hide_advanced_options": "Hide Advanced Options", "oidc_data_preview": "OIDC Data Preview", "preview_the_oidc_data_that_would_be_sent_for_different_users": "Preview the OIDC data that would be sent for different users", "id_token": "ID Token", @@ -431,13 +429,8 @@ "hours": "Hours", "days": "Days", "userinfo": "Userinfo", - "id_token_payload": "ID Token Payload", - "access_token_payload": "Access Token Payload", - "userinfo_endpoint_response": "Userinfo Endpoint Response", "copy": "Copy", "copy_all": "Copy All", - "preview_for_user": "Preview for {name}", - "preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user", "show": "Show", "select_an_option": "Select an option", "select_user": "Select User", @@ -499,7 +492,6 @@ "client_launch_url_description": "The URL that will be opened when a user launches the app from the My Apps page.", "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", @@ -663,5 +655,25 @@ "discard": "Discard", "discard_unsaved_changes": "Discard unsaved changes?", "discard_unsaved_changes_confirm": "You have unsaved changes on this page. Are you sure you want to discard them?", - "please_fix_the_errors_before_saving": "Please fix the highlighted errors before saving" + "please_fix_the_errors_before_saving": "Please fix the highlighted errors before saving", + "application": "Application", + "oidc_client_application_description": "How the client is shown to users on the consent screen and the My Apps page.", + "redirects": "Redirects", + "oidc_client_redirects_description": "Where users can be sent back to after signing in and signing out.", + "security": "Security", + "oidc_client_security_description": "How the client authenticates and how users authorize it.", + "show_less_details": "Show less details", + "all_users_can_sign_in_to_this_client": "Any user can sign in to this client.", + "selected_user_groups": "Selected user groups", + "only_members_of_the_selected_groups_can_sign_in": "Only members of the selected groups can sign in.", + "add_callback_url": "Add callback URL", + "add_logout_url": "Add logout URL", + "remove_logo": "Remove logo", + "use_image_url": "Use an image URL", + "claims": "Claims", + "set_custom_client_id": "Set custom client ID", + "client_type": "Client type", + "confidential_client": "Confidential Client", + "confidential_client_description": "Authenticates with a client secret. For apps with a backend that can keep the secret safe.", + "public_client_type_description": "Has no client secret and uses PKCE instead. For single-page, mobile and desktop apps." } diff --git a/frontend/src/lib/components/form/form-input.svelte b/frontend/src/lib/components/form/form-input.svelte index e24c11c1..15c5beec 100644 --- a/frontend/src/lib/components/form/form-input.svelte +++ b/frontend/src/lib/components/form/form-input.svelte @@ -33,6 +33,7 @@ labelFor, readonly = false, inputClass, + class: className, ...restProps }: HTMLAttributes & (WithChildren | WithoutChildren) & { @@ -52,7 +53,7 @@
diff --git a/frontend/src/lib/components/form/multi-select.svelte b/frontend/src/lib/components/form/multi-select.svelte index 4328e684..0f7f9353 100644 --- a/frontend/src/lib/components/form/multi-select.svelte +++ b/frontend/src/lib/components/form/multi-select.svelte @@ -1,5 +1,7 @@ - -
- (e.target.value = '')} - > - - - - - - - - (url = e.currentTarget.value)} - onfocusout={handleUrlChange} - aria-invalid={hasError} - type="url" - /> - {#if hasError} -

{m.invalid_url()}

- {/if} - -

- -

-
-
-
diff --git a/frontend/src/lib/components/form/url-list-input.svelte b/frontend/src/lib/components/form/url-list-input.svelte index 6add418c..59020475 100644 --- a/frontend/src/lib/components/form/url-list-input.svelte +++ b/frontend/src/lib/components/form/url-list-input.svelte @@ -2,20 +2,23 @@ import { Button } from '$lib/components/ui/button'; import { Input } from '$lib/components/ui/input'; import { m } from '$lib/paraglide/messages'; - import { LucideMinus, LucidePlus } from '@lucide/svelte'; + import { LucidePlus, LucideX } from '@lucide/svelte'; let { urls = $bindable(), error = null, testIdPrefix = 'url', disabled = false, - keepAtLeastOne = false + keepAtLeastOne = false, + addLabel }: { urls: string[]; error?: string | null; testIdPrefix?: string; disabled?: boolean; keepAtLeastOne?: boolean; + // Names what the button adds, instead of the generic "Add" and "Add another" + addLabel?: string; } = $props(); function removeUrl(index: number) { @@ -33,6 +36,7 @@ {#each urls as url, i (i)}
{/each}
diff --git a/frontend/src/lib/components/ui/alert/alert.svelte b/frontend/src/lib/components/ui/alert/alert.svelte index bc7b9c9a..26c0ce28 100644 --- a/frontend/src/lib/components/ui/alert/alert.svelte +++ b/frontend/src/lib/components/ui/alert/alert.svelte @@ -55,7 +55,10 @@ onDismiss?.(); if (dismissibleId) { const dismissedAlerts = JSON.parse(localStorage?.getItem('dismissed-alerts') || '[]'); - localStorage?.setItem('dismissed-alerts', JSON.stringify([...dismissedAlerts, dismissibleId])); + localStorage?.setItem( + 'dismissed-alerts', + JSON.stringify([...dismissedAlerts, dismissibleId]) + ); isVisible = false; } } diff --git a/frontend/src/lib/components/ui/tabs/tabs.svelte b/frontend/src/lib/components/ui/tabs/tabs.svelte index b80d4d1b..d706eb57 100644 --- a/frontend/src/lib/components/ui/tabs/tabs.svelte +++ b/frontend/src/lib/components/ui/tabs/tabs.svelte @@ -3,7 +3,6 @@ import { page } from '$app/state'; import { cn } from '$lib/utils/style.js'; import { Tabs as TabsPrimitive } from 'bits-ui'; - import { onMount } from 'svelte'; let { ref = $bindable(null), @@ -15,7 +14,8 @@ useHash?: boolean; } = $props(); - onMount(() => { + // Follows the hash on load and on later hash changes, so that links like `#credentials` elsewhere on the page can switch the tab + $effect(() => { if (useHash && page.url.hash) { value = page.url.hash.substring(1); } diff --git a/frontend/src/lib/utils/form-util.ts b/frontend/src/lib/utils/form-util.ts index a745bb7d..5b176af9 100644 --- a/frontend/src/lib/utils/form-util.ts +++ b/frontend/src/lib/utils/form-util.ts @@ -150,9 +150,10 @@ export function createForm>(schema: T, initialValu } // Handle unions + // A union that accepts an empty string, like `z.url().or(z.literal(''))`, can be left blank as well if (fieldSchema instanceof z.ZodUnion) { return !fieldSchema.def.options.some((o: any) => { - return o.def.type == 'optional'; + return o.def.type == 'optional' || (o instanceof z.ZodLiteral && o.def.values.includes('')); }); } diff --git a/frontend/src/routes/settings/admin/oidc-clients/+page.svelte b/frontend/src/routes/settings/admin/oidc-clients/+page.svelte index 4ef555f7..83ee3309 100644 --- a/frontend/src/routes/settings/admin/oidc-clients/+page.svelte +++ b/frontend/src/routes/settings/admin/oidc-clients/+page.svelte @@ -6,19 +6,19 @@ import OIDCService from '$lib/services/oidc-service'; import appConfigStore from '$lib/stores/application-configuration-store'; import clientSecretStore from '$lib/stores/client-secret-store'; - import type { OidcClientCreateWithLogo } from '$lib/types/oidc.type'; + import type { OidcClientCreate } from '$lib/types/oidc.type'; import { encodeClientIdParam } from '$lib/utils/client-id-util'; import { LucideMinus, ShieldCheck, ShieldPlus } from '@lucide/svelte'; import { toast } from 'svelte-sonner'; import { slide } from 'svelte/transition'; - import OIDCClientForm from './oidc-client-form.svelte'; + import OidcClientCreateForm from './oidc-client-create-form.svelte'; import OIDCClientList from './oidc-client-list.svelte'; let expandAddClient = $state(false); const oidcService = new OIDCService(); - async function createOIDCClient(client: OidcClientCreateWithLogo) { + async function createOIDCClient(client: OidcClientCreate) { clientSecretStore.clear(); const createdClient = await oidcService.createClient(client); if (createdClient.createdSecret) { @@ -28,14 +28,6 @@ ); } - const logoPromise = client.logo - ? oidcService.updateClientLogo(createdClient, client.logo, true) - : Promise.resolve(); - const darkLogoPromise = client.darkLogo - ? oidcService.updateClientLogo(createdClient, client.darkLogo, false) - : Promise.resolve(); - await Promise.all([logoPromise, darkLogoPromise]); - goto(`/settings/admin/oidc-clients/${encodeClientIdParam(createdClient.id)}`); toast.success(m.oidc_client_created_successfully()); } @@ -74,7 +66,7 @@ {#if expandAddClient}
- +
{/if} diff --git a/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.svelte b/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.svelte index 95311aaf..4540cd99 100644 --- a/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.svelte +++ b/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.svelte @@ -1,17 +1,14 @@ + + + + {m.allowed_user_groups()} + {m.allowed_user_groups_description()} + + + (client.isGroupRestricted ? 'restricted' : 'all'), + (value) => setAccessMode(value as AccessMode) + } + > + + + + + {m.all_users()} + {m.all_users_can_sign_in_to_this_client()} + + + + + + + + {m.selected_user_groups()} + + {m.only_members_of_the_selected_groups_can_sign_in()} + + + + + + + {#if client.isGroupRestricted} +
+ +
+ {/if} +
+
diff --git a/frontend/src/routes/settings/admin/oidc-clients/[id]/oidc-client-connection-details-card.svelte b/frontend/src/routes/settings/admin/oidc-clients/[id]/oidc-client-connection-details-card.svelte new file mode 100644 index 00000000..f83ac95b --- /dev/null +++ b/frontend/src/routes/settings/admin/oidc-clients/[id]/oidc-client-connection-details-card.svelte @@ -0,0 +1,102 @@ + + +{#snippet detail(label: string, value: string, testId?: string)} +
+ {label} + + {value} + +
+{/snippet} + + + + + + {client.name} + + + + + +
+ {@render detail(m.client_id(), client.id, 'client-id')} + {#if createdSecret} + {@render detail(m.client_secret(), createdSecret, 'created-client-secret')} + {/if} + {@render detail( + m.oidc_discovery_url(), + `${oidcConfiguration.issuer}/.well-known/openid-configuration` + )} +
+ {#if showAllEndpoints} +
+ {#each endpoints as endpoint (endpoint.label)} + {@render detail(endpoint.label, endpoint.value)} + {/each} +
+ {/if} +
+
diff --git a/frontend/src/routes/settings/admin/oidc-clients/oidc-callback-url-input.svelte b/frontend/src/routes/settings/admin/oidc-clients/oidc-callback-url-input.svelte index c78c4a45..226588f9 100644 --- a/frontend/src/routes/settings/admin/oidc-clients/oidc-callback-url-input.svelte +++ b/frontend/src/routes/settings/admin/oidc-clients/oidc-callback-url-input.svelte @@ -11,6 +11,7 @@ callbackURLs = $bindable(), error = $bindable(null), disabled = false, + addLabel, ...restProps }: HTMLAttributes & { label: string; @@ -18,13 +19,20 @@ callbackURLs: string[]; error?: string | null; disabled?: boolean; + addLabel?: string; children?: Snippet; } = $props();
- + {#if error} {error} diff --git a/frontend/src/routes/settings/admin/oidc-clients/oidc-client-create-form.svelte b/frontend/src/routes/settings/admin/oidc-clients/oidc-client-create-form.svelte new file mode 100644 index 00000000..25a86a37 --- /dev/null +++ b/frontend/src/routes/settings/admin/oidc-clients/oidc-client-create-form.svelte @@ -0,0 +1,153 @@ + + +{#snippet callbackUrlDescription()} + +{/snippet} + +{#snippet clientTypeOption(value: string, title: string, description: string)} + + + + + {title} + {description} + + + +{/snippet} + +
+
+ + {#if showCustomClientId} + + {/if} +
+ + + {m.client_type()} + ($inputs.isPublic.value = value === 'public')} + > + {@render clientTypeOption( + 'confidential', + m.confidential_client(), + m.confidential_client_description() + )} + {@render clientTypeOption('public', m.public_client(), m.public_client_type_description())} + + + + + +
+ + {#if !showCustomClientId} + + {/if} + +
+ diff --git a/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte b/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte index 1d7b0d80..5e0514b0 100644 --- a/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte +++ b/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte @@ -1,86 +1,62 @@ -{m.logo()} -
-
- {#if tabTriggers} - {@render tabTriggers()} - {/if} -
- -
-
- {#if logoDataURL} -
-
+
+
{ + e.preventDefault(); + isDraggingOver = true; + }} + ondragleave={() => (isDraggingOver = false)} + ondrop={onDrop} + > + (e.target.value = '')} + class={cn( + 'text-muted-foreground hover:bg-muted/50 flex size-28 cursor-pointer flex-col items-center justify-center gap-1.5 overflow-hidden rounded-2xl border border-dashed text-xs transition-colors', + logoDataURL && (light ? 'bg-[#F5F5F5]' : 'bg-[#262626]'), + isDraggingOver && 'border-primary bg-muted/50' + )} + > + {#if logoDataURL} - -
-
- {/if} + {m.upload_logo()} + {:else} + + {m.logo()} + {/if} + + {#if logoDataURL} + + {/if} +
+ +
+ {@render tabTriggers?.()} + + + + + + + (url = e.currentTarget.value)} + onfocusout={onUrlChange} + aria-invalid={hasUrlError} + type="url" + /> + {#if hasUrlError} +

{m.invalid_url()}

+ {/if} +

+ +

+
+
+
diff --git a/frontend/src/routes/settings/admin/oidc-clients/oidc-client-preview-modal.svelte b/frontend/src/routes/settings/admin/oidc-clients/oidc-client-preview-modal.svelte index 582bc9a5..fa9d36c2 100644 --- a/frontend/src/routes/settings/admin/oidc-clients/oidc-client-preview-modal.svelte +++ b/frontend/src/routes/settings/admin/oidc-clients/oidc-client-preview-modal.svelte @@ -2,7 +2,7 @@ import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte'; import MultiSelect from '$lib/components/form/multi-select.svelte'; import SearchableSelect from '$lib/components/form/searchable-select.svelte'; - import * as Alert from '$lib/components/ui/alert'; + import { Badge } from '$lib/components/ui/badge'; import { Button } from '$lib/components/ui/button'; import * as Dialog from '$lib/components/ui/dialog'; import * as Field from '$lib/components/ui/field'; @@ -14,8 +14,10 @@ import type { User } from '$lib/types/user.type'; import { debounced } from '$lib/utils/debounce-util'; import { getAxiosErrorMessage } from '$lib/utils/error-util'; - import { LucideAlertTriangle } from '@lucide/svelte'; - import { onMount } from 'svelte'; + import { cn } from '$lib/utils/style'; + import { LucideBraces, LucideCopy, LucideList } from '@lucide/svelte'; + + type Claims = Record; let { open = $bindable(), @@ -28,29 +30,40 @@ const oidcService = new OidcService(); const userService = new UserService(); - let previewData = $state<{ - idToken?: any; - accessToken?: any; - userInfo?: any; - } | null>(null); - let loadingPreview = $state(false); + // Claims holding a Unix timestamp, which are shown with a readable date next to them + const TIMESTAMP_CLAIMS = new Set(['exp', 'iat', 'nbf', 'auth_time', 'updated_at']); + + let previewData = $state<{ idToken?: Claims; accessToken?: Claims; userInfo?: Claims } | null>( + null + ); + let isLoading = $state(false); let isUserSearchLoading = $state(false); let user: User | null = $state(null); let users: User[] = $state([]); let scopes: string[] = $state(['openid', 'email', 'profile']); let errorMessage: string | null = $state(null); + let view = $state<'claims' | 'json'>('claims'); + let activeTab = $state<'idToken' | 'accessToken' | 'userInfo'>('idToken'); - async function loadPreviewData() { + // Each request gets a number so that responses arriving out of order don't overwrite newer ones + let latestRequest = 0; + + const activeData = $derived(previewData?.[activeTab] ?? {}); + + async function loadPreviewData(userId: string, scope: string) { + const request = ++latestRequest; + isLoading = true; errorMessage = null; try { - previewData = await oidcService.getClientPreview(clientId, user!.id, scopes.join(' ')); + const data = await oidcService.getClientPreview(clientId, userId, scope); + if (request === latestRequest) previewData = data; } catch (e) { - const error = getAxiosErrorMessage(e); - errorMessage = error; + if (request !== latestRequest) return; + errorMessage = getAxiosErrorMessage(e); previewData = null; } finally { - loadingPreview = false; + if (request === latestRequest) isLoading = false; } } @@ -62,19 +75,7 @@ }) ).data; if (!user) { - user = users[0]; - } - } - - async function onOpenChange(open: boolean) { - if (!open) { - previewData = null; - errorMessage = null; - } else { - loadingPreview = true; - await loadPreviewData().finally(() => { - loadingPreview = false; - }); + user = users[0] ?? null; } } @@ -84,126 +85,168 @@ (loading) => (isUserSearchLoading = loading) ); + // Timestamps arrive either as Unix seconds or as ISO strings depending on the claim, so both are accepted + function formatTimestamp(value: unknown) { + const date = + typeof value === 'number' + ? new Date(value * 1000) + : typeof value === 'string' + ? new Date(value) + : null; + return date && !isNaN(date.getTime()) ? date.toLocaleString() : null; + } + + // The user list is only needed once the dialog is opened $effect(() => { - if (open) { - loadPreviewData(); + if (open && users.length === 0) { + loadUsers(); } }); - onMount(() => { - loadUsers(); + // Reload the preview whenever the selected user or the scopes change + $effect(() => { + if (open && user) { + loadPreviewData(user.id, scopes.join(' ')); + } }); - - +{#snippet claimValue(key: string, value: unknown)} + {#if Array.isArray(value) && value.every((item) => typeof item !== 'object')} +
+ {#each value as item, i (i)} + {String(item)} + {:else} + [] + {/each} +
+ {:else if value !== null && typeof value === 'object'} +
{JSON.stringify(
+				value,
+				null,
+				2
+			)}
+ {:else if value === '' || value === null} + {value === null ? 'null' : '""'} + {:else} + {@const formattedDate = TIMESTAMP_CLAIMS.has(key) ? formatTimestamp(value) : null} + + {String(value)} + + {#if formattedDate} + {formattedDate} + {/if} + {/if} +{/snippet} + + + {m.oidc_data_preview()} - {#if user} - {m.preview_for_user({ name: user.displayName })} - {:else} - {m.preview_the_oidc_data_that_would_be_sent_for_this_user()} - {/if} + {m.preview_the_oidc_data_that_would_be_sent_for_different_users()} -
- {#if loadingPreview} -
- -
- {/if} +
+ + {m.user()} + ({ + value: user.id, + label: user.username + }))} + value={user?.id || ''} + oninput={(e) => onUserSearch(e.currentTarget.value)} + onSelect={(value) => (user = users.find((u) => u.id === value) || null)} + /> + + + {m.scopes()} + + +
-
- - {m.users()} - ({ - value: user.id, - label: user.username - }))} - value={user?.id || ''} - oninput={(e) => onUserSearch(e.currentTarget.value)} - onSelect={(value) => { - user = users.find((u) => u.id === value) || null; - loadPreviewData(); - }} - /> - - - {m.scopes()} - - +
+
+ + + + {m.id_token()} + {m.access_token()} + {m.userinfo()} + + +
+ + + + + + + + + + + + + +
- {#if errorMessage && !loadingPreview} - - - {m.error()} - - {errorMessage} - - - {/if} - - {#if previewData && !loadingPreview} - - - {m.id_token()} - {m.access_token()} - {m.userinfo()} - - - {@render tabContent(previewData.idToken, m.id_token_payload())} - - - - {@render tabContent(previewData.accessToken, m.access_token_payload())} - - - - {@render tabContent(previewData.userInfo, m.userinfo_endpoint_response())} - - - {/if} + +
+ {#if errorMessage && !isLoading} +
+

{m.error()}

+

{errorMessage}

+
+ {:else if !previewData} +
+ +
+ {:else if view === 'json'} +
{JSON.stringify(
+							activeData,
+							null,
+							2
+						)}
+ {:else} +
+ {#each Object.entries(activeData) as [key, value] (key)} +
+
{key}
+
{@render claimValue(key, value)}
+
+ {/each} +
+ {/if} +
- -{#snippet tabContent(data: any, title: string)} -
-
- {title} - - - -
-
- {#each Object.entries(data || {}) as [key, value] (key)} -
- {key} -
- -
- {typeof value === 'object' ? JSON.stringify(value, null, 2) : value} -
-
-
-
- {/each} -
-
-{/snippet} diff --git a/tests/specs/api.spec.ts b/tests/specs/api.spec.ts index 8f90f0a1..0fcea653 100644 --- a/tests/specs/api.spec.ts +++ b/tests/specs/api.spec.ts @@ -105,8 +105,8 @@ test('Grant a client user-delegated and client access to API permissions', async // Nextcloud has no API access granted by default await page.goto(`/settings/admin/oidc-clients/${oidcClients.nextcloud.id}`); - // Open the API access tab, where no API is listed yet, and add the Orders API - await page.getByRole('tab', { name: 'API access' }).click(); + // Open the Access tab, where no API is listed yet, and add the Orders API + await page.getByRole('tab', { name: 'Access', exact: true }).click(); await expect( page.getByText('This client has not been granted access to any API yet.') ).toBeVisible(); @@ -180,7 +180,7 @@ test('Grant a client access from the API details page', async ({ page }) => { // The same grant shows up on the client's side of the relation await page.goto(`/settings/admin/oidc-clients/${oidcClients.nextcloud.id}`); - await page.getByRole('tab', { name: 'API access' }).click(); + await page.getByRole('tab', { name: 'Access', exact: true }).click(); await expect(page.getByRole('row', { name: apis.orders.name })).toContainText('1 / 2'); }); diff --git a/tests/specs/oidc-backchannel-logout.spec.ts b/tests/specs/oidc-backchannel-logout.spec.ts index db134362..02377624 100644 --- a/tests/specs/oidc-backchannel-logout.spec.ts +++ b/tests/specs/oidc-backchannel-logout.spec.ts @@ -109,11 +109,9 @@ test('Saving a logout URL and revoking an app sends a verifiable logout token', const client = oidcClients.nextcloud; const logoutURL = `${receiver.url}/logout?tenant=test`; await page.goto(`/settings/admin/oidc-clients/${client.id}`); - await page.getByRole('button', { name: 'Show Advanced Options' }).click(); await page.getByLabel('Back-Channel Logout URL', { exact: true }).fill(logoutURL); await saveUnsavedChanges(page); await page.reload(); - await page.getByRole('button', { name: 'Show Advanced Options' }).click(); await expect(page.getByLabel('Back-Channel Logout URL', { exact: true })).toHaveValue(logoutURL); await page.goto('/settings/apps'); diff --git a/tests/specs/oidc-client-settings.spec.ts b/tests/specs/oidc-client-settings.spec.ts index b507e850..f281cd6e 100644 --- a/tests/specs/oidc-client-settings.spec.ts +++ b/tests/specs/oidc-client-settings.spec.ts @@ -14,38 +14,26 @@ test.describe('Create OIDC client', () => { await page.getByRole('button', { name: 'Add OIDC Client' }).click(); await page.getByLabel('Name').fill(oidcClient.name); - await page.getByLabel('Description').fill(oidcClient.description); - await page.getByLabel('Client Launch URL').fill(oidcClient.launchURL); - await page.getByRole('button', { name: 'Add' }).first().click(); + await page.getByRole('button', { name: 'Add callback URL' }).click(); await page.getByTestId('callback-url-1').fill(oidcClient.callbackUrl); - await page.getByRole('button', { name: 'Add another' }).click(); + await page.getByRole('button', { name: 'Add callback URL' }).click(); await page.getByTestId('callback-url-2').fill(oidcClient.secondCallbackUrl); - await page.locator('[role="tab"][data-value="light-logo"]').first().click(); - await page.setInputFiles('#oidc-client-logo-light', 'resources/images/pingvin-share-logo.png'); - await page.locator('[role="tab"][data-value="dark-logo"]').first().click(); - await page.setInputFiles('#oidc-client-logo-dark', 'resources/images/pingvin-share-logo.png'); - if (clientId) { - await page.getByRole('button', { name: 'Show Advanced Options' }).click(); + await page.getByRole('button', { name: 'Set custom client ID' }).click(); await page.getByLabel('Client ID').fill(clientId); } - await page.getByRole('button', { name: 'Save' }).click(); + await page.getByRole('button', { name: 'Create', exact: true }).click(); await expect(page.locator('[data-type="success"]')).toHaveText( 'OIDC client created successfully' ); const resolvedClientId = (await page.getByTestId('client-id').innerText()).trim(); - const createdSecret = ( - await page - .getByRole('tabpanel', { name: 'General', exact: true }) - .getByTestId('client-secret') - .innerText() - ).trim(); + const createdSecret = (await page.getByTestId('created-client-secret').innerText()).trim(); expect(createdSecret).toMatch(/^\w{32}$/); if (clientId) { @@ -55,13 +43,8 @@ test.describe('Create OIDC client', () => { } await expect(page.getByLabel('Name')).toHaveValue(oidcClient.name); - await expect(page.getByLabel('Description')).toHaveValue(oidcClient.description); await expect(page.getByTestId('callback-url-1')).toHaveValue(oidcClient.callbackUrl); await expect(page.getByTestId('callback-url-2')).toHaveValue(oidcClient.secondCallbackUrl); - await expect(page.getByRole('img', { name: `${oidcClient.name} logo` }).first()).toBeVisible(); - - const res = await page.request.get(`/api/oidc/clients/${resolvedClientId}/logo`); - expect(res.ok()).toBeTruthy(); // The generated value is available on the creation page and is forgotten after a reload await page.reload(); @@ -495,9 +478,9 @@ test('Filter OIDC clients by PAR requirement', async ({ page, request }) => { test('Update OIDC client allowed user groups', async ({ page }) => { await page.goto(`/settings/admin/oidc-clients/${oidcClients.nextcloud.id}`); - await page.getByRole('tab', { name: 'Allowed user groups' }).click(); + await page.getByRole('tab', { name: 'Access', exact: true }).click(); - await page.getByRole('button', { name: 'Restrict' }).click(); + await page.getByRole('radio', { name: 'Selected user groups' }).click(); await page.getByRole('row', { name: userGroups.designers.name }).getByRole('checkbox').click(); await page.getByRole('row', { name: userGroups.developers.name }).getByRole('checkbox').click(); diff --git a/tests/specs/oidc.spec.ts b/tests/specs/oidc.spec.ts index 18ecf519..1d1c71e8 100644 --- a/tests/specs/oidc.spec.ts +++ b/tests/specs/oidc.spec.ts @@ -1576,8 +1576,6 @@ test.describe('Pushed Authorization Requests (PAR)', () => { test('Admin UI: PAR toggle persists after save', async ({ page }) => { await page.goto(`/settings/admin/oidc-clients/${client.id}`); - await page.getByRole('button', { name: 'Show Advanced Options' }).click(); - // Enable the PAR toggle const parToggle = page.getByRole('switch', { name: 'Requires Pushed Authorization' }); if (!(await parToggle.isChecked())) { @@ -1587,7 +1585,6 @@ test.describe('Pushed Authorization Requests (PAR)', () => { await saveUnsavedChanges(page); await page.reload(); - await page.getByRole('button', { name: 'Show Advanced Options' }).click(); const savedToggle = page.getByRole('switch', { name: 'Requires Pushed Authorization' }); await expect(savedToggle).toBeChecked(); }); diff --git a/tests/specs/scim.spec.ts b/tests/specs/scim.spec.ts index f4abf751..add190b8 100644 --- a/tests/specs/scim.spec.ts +++ b/tests/specs/scim.spec.ts @@ -129,7 +129,7 @@ test.describe('SCIM Sync', () => { test('Remove allowed group and sync', async ({ page }) => { await syncScimServiceProvider(page); - await page.getByRole('tab', { name: 'Allowed user groups' }).click(); + await page.getByRole('tab', { name: 'Access', exact: true }).click(); const developersCheckbox = page .getByRole('row', { name: userGroups.developers.name }) @@ -155,10 +155,10 @@ test.describe('SCIM Sync', () => { test('Remove group restrictions and sync', async ({ page }) => { await syncScimServiceProvider(page); - await page.getByRole('tab', { name: 'Allowed user groups' }).click(); + await page.getByRole('tab', { name: 'Access', exact: true }).click(); - await page.getByRole('button', { name: 'Unrestrict' }).click(); - await page.getByRole('button', { name: 'Unrestrict' }).nth(1).click(); + await page.getByRole('radio', { name: 'All users' }).click(); + await page.getByRole('alertdialog').getByRole('button', { name: 'Unrestrict' }).click(); await expect( page.getByText('User groups restriction updated successfully', { exact: true }) ).toBeVisible();