feat: redesign OIDC client details page

This commit is contained in:
Elias Schneider
2026-10-01 19:48:57 +02:00
parent 642ccc80d3
commit e588e3fc5d
22 changed files with 1058 additions and 787 deletions
+21 -9
View File
@@ -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."
}
@@ -33,6 +33,7 @@
labelFor,
readonly = false,
inputClass,
class: className,
...restProps
}: HTMLAttributes<HTMLDivElement> &
(WithChildren | WithoutChildren) & {
@@ -52,7 +53,7 @@
<Field.Field
data-disabled={disabled}
class={cn('flex flex-col justify-between', restProps.class)}
class={cn('flex flex-col justify-between', className)}
{...restProps}
>
<div>
@@ -1,5 +1,7 @@
<script lang="ts">
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { m } from '$lib/paraglide/messages';
import { cn } from '$lib/utils/style';
import { LucideChevronDown } from '@lucide/svelte';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
@@ -8,7 +10,9 @@
items,
selectedItems = $bindable(),
onSelect,
autoClose = false
autoClose = false,
placeholder = m.select_an_option(),
class: className
}: {
items: {
value: string;
@@ -17,8 +21,12 @@
selectedItems: string[];
onSelect?: (value: string) => void;
autoClose?: boolean;
placeholder?: string;
class?: string;
} = $props();
const selected = $derived(items.filter((item) => selectedItems.includes(item.value)));
function handleItemSelect(value: string) {
if (selectedItems.includes(value)) {
selectedItems = selectedItems.filter((item) => item !== value);
@@ -32,13 +40,19 @@
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline">
{#each items.filter((item) => selectedItems.includes(item.value)) as item (item.value)}
<Badge variant="secondary">
{item.label}
</Badge>
{/each}
<LucideChevronDown class="text-muted-foreground ml-2 size-4" />
<Button {...props} variant="outline" class={cn('w-full px-3 font-normal', className)}>
<!-- Button centers its content in an inner span, so this one spreads the badges and the chevron apart -->
<span class="flex w-full min-w-0 items-center justify-between gap-2">
<!-- The badges stay on one line and are clipped so the trigger keeps the height of the other inputs -->
<span class="flex min-w-0 gap-1 overflow-hidden">
{#each selected as item (item.value)}
<Badge variant="secondary">{item.label}</Badge>
{:else}
<span class="text-muted-foreground">{placeholder}</span>
{/each}
</span>
<LucideChevronDown class="size-4 shrink-0 opacity-50" />
</span>
</Button>
{/snippet}
</DropdownMenu.Trigger>
@@ -1,88 +0,0 @@
<script lang="ts">
import FileInput from '$lib/components/form/file-input.svelte';
import FormattedMessage from '$lib/components/formatted-message.svelte';
import { Button, buttonVariants } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Popover from '$lib/components/ui/popover';
import { m } from '$lib/paraglide/messages';
import { cn } from '$lib/utils/style';
import { LucideChevronDown } from '@lucide/svelte';
let {
label,
accept,
onchange,
id = 'file-input'
}: {
label: string;
accept?: string;
onchange: (file: File | string | null) => void;
id?: string;
} = $props();
let url = $state('');
let hasError = $state(false);
async function handleFileChange(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0] || null;
url = '';
hasError = false;
onchange(file);
}
async function handleUrlChange(e: Event) {
const url = (e.target as HTMLInputElement).value.trim();
if (!url) return;
try {
new URL(url);
hasError = false;
} catch {
hasError = true;
return;
}
onchange(url);
}
</script>
<div class="flex">
<FileInput
{id}
variant="secondary"
{accept}
onchange={handleFileChange}
onclick={(e: any) => (e.target.value = '')}
>
<Button variant="secondary" class="rounded-r-none">
{label}
</Button>
</FileInput>
<Popover.Root>
<Popover.Trigger
class={cn(buttonVariants({ variant: 'secondary' }), 'rounded-l-none border-l')}
>
<LucideChevronDown class="size-4" /></Popover.Trigger
>
<Popover.Content class="w-80">
<Label for="{id}-url" class="text-xs">URL</Label>
<Input
id="{id}-url"
placeholder=""
value={url}
oninput={(e) => (url = e.currentTarget.value)}
onfocusout={handleUrlChange}
aria-invalid={hasError}
type="url"
/>
{#if hasError}
<p class="text-destructive mt-1 text-start text-xs">{m.invalid_url()}</p>
{/if}
<p class="text-muted-foreground mt-2 text-xs">
<FormattedMessage message={m.logo_from_url_description} />
</p>
</Popover.Content>
</Popover.Root>
</div>
@@ -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)}
<div class="flex gap-x-2">
<Input
class="font-mono text-[13px] md:text-[13px]"
aria-invalid={!!error}
data-testid={`${testIdPrefix}-${i + 1}`}
type="text"
@@ -42,25 +46,26 @@
{disabled}
/>
<Button
variant="outline"
size="sm"
variant="ghost"
size="icon"
class="text-muted-foreground size-9"
aria-label={m.remove_url({ identifier: url || i + 1 })}
onclick={() => removeUrl(i)}
{disabled}
>
<LucideMinus class="size-4" />
<LucideX class="size-4" />
</Button>
</div>
{/each}
</div>
<Button
class="mt-2"
variant="secondary"
class="text-muted-foreground mt-1 -ml-2.5"
variant="ghost"
size="sm"
onclick={() => (urls = [...urls, ''])}
{disabled}
>
<LucidePlus class="mr-1 size-4" />
{urls.length === 0 ? m.add() : m.add_another()}
{addLabel ?? (urls.length === 0 ? m.add() : m.add_another())}
</Button>
</div>
@@ -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;
}
}
@@ -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);
}
+2 -1
View File
@@ -150,9 +150,10 @@ export function createForm<T extends z.ZodType<any, any>>(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(''));
});
}
@@ -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}
<div transition:slide>
<Card.Content>
<OIDCClientForm mode="create" callback={createOIDCClient} />
<OidcClientCreateForm callback={createOIDCClient} />
</Card.Content>
</div>
{/if}
@@ -1,17 +1,14 @@
<script lang="ts">
import { openConfirmDialog } from '$lib/components/confirm-dialog';
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
import FormattedMessage from '$lib/components/formatted-message.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 Card from '$lib/components/ui/card';
import * as Field from '$lib/components/ui/field';
import * as Tabs from '$lib/components/ui/tabs';
import UserGroupSelection from '$lib/components/user-group-selection.svelte';
import { m } from '$lib/paraglide/messages';
import OidcService from '$lib/services/oidc-service';
import ScimService from '$lib/services/scim-service';
import clientSecretStore, { autoCreatedSecretId } from '$lib/stores/client-secret-store';
import clientSecretStore from '$lib/stores/client-secret-store';
import type {
OidcClientCreateWithLogo,
OidcClientCredentials,
@@ -21,16 +18,14 @@
} from '$lib/types/oidc.type';
import type { ScimServiceProviderCreate } from '$lib/types/scim.type';
import { cachedOidcClientLogo } from '$lib/utils/cached-image-util';
import { axiosErrorToast } from '$lib/utils/error-util';
import { trackUnsavedValue } from '$lib/utils/unsaved-changes-util.svelte';
import { LucideChevronLeft, LucideInfo, LucideTriangleAlert } from '@lucide/svelte';
import { LucideChevronLeft, LucideEye, LucideInfo } from '@lucide/svelte';
import { onDestroy } from 'svelte';
import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition';
import { backNavigate } from '../../users/navigate-back-util';
import OidcForm from '../oidc-client-form.svelte';
import OidcClientPreviewModal from '../oidc-client-preview-modal.svelte';
import ApiAccessCard from './api-access-card.svelte';
import OidcClientAllowedUserGroupsCard from './oidc-client-allowed-user-groups-card.svelte';
import OidcClientConnectionDetailsCard from './oidc-client-connection-details-card.svelte';
import OidcClientFederatedCredentialsCard from './oidc-client-federated-credentials-card.svelte';
import OidcClientSecretsCard from './oidc-client-secrets-card.svelte';
import OidcClientTokenLifetimesCard from './oidc-client-token-lifetimes-card.svelte';
@@ -45,32 +40,16 @@
let clientSecrets = $state<OidcClientSecret[]>(data.client.credentials?.secrets ?? []);
let scimServiceProvider = $state(data.scimServiceProvider);
let showAllDetails = $state(false);
let showPreview = $state(false);
const credentialCount = $derived(
clientSecrets.length + (client.credentials?.federatedIdentities?.length ?? 0)
);
const oidcService = new OidcService();
const scimService = new ScimService();
const backNavigation = backNavigate('/settings/admin/oidc-clients');
const allowedUserGroups = trackUnsavedValue(
() => client.allowedUserGroupIds,
(allowedUserGroupIds) => {
client.allowedUserGroupIds = allowedUserGroupIds;
},
(allowedUserGroupIds) => oidcService.updateAllowedUserGroups(client.id, allowedUserGroupIds)
);
const setupDetails = $state({
[m.issuer_url()]: data.oidcConfiguration.issuer,
[m.authorization_url()]: data.oidcConfiguration.authorization_endpoint,
[m.oidc_discovery_url()]: `${data.oidcConfiguration.issuer}/.well-known/openid-configuration`,
[m.token_url()]: data.oidcConfiguration.token_endpoint,
[m.userinfo_url()]: data.oidcConfiguration.userinfo_endpoint,
[m.logout_url()]: data.oidcConfiguration.end_session_endpoint,
[m.certificate_url()]: data.oidcConfiguration.jwks_uri,
[m.pkce()]: client.pkceEnabled ? m.enabled() : m.disabled()
});
async function updateClient(updatedClient: OidcClientCreateWithLogo) {
const dataPromise = oidcService.updateClient(client.id, updatedClient);
const imagePromise =
@@ -84,16 +63,10 @@
: Promise.resolve();
client.isPublic = updatedClient.isPublic;
setupDetails[m.pkce()] = updatedClient.pkceEnabled ? m.enabled() : m.disabled();
setupDetails[m.requires_reauthentication()] = updatedClient.requiresReauthentication
? m.enabled()
: m.disabled();
const [savedClient] = await Promise.all([dataPromise, imagePromise, darkImagePromise]);
Object.assign(client, savedClient);
setupDetails[m.requires_pushed_authorization_requests()] =
updatedClient.requiresPushedAuthorizationRequests ? m.enabled() : m.disabled();
if (updatedClient.logoUrl) {
cachedOidcClientLogo.bustCache(client.id, true);
}
@@ -126,48 +99,6 @@
client.credentials = credentials;
}
async function enableGroupRestriction() {
client.isGroupRestricted = true;
await oidcService
.updateClient(client.id, {
...client,
isGroupRestricted: true
})
.then(() => {
toast.success(m.user_groups_restriction_updated_successfully());
client.isGroupRestricted = true;
})
.catch(axiosErrorToast);
}
function disableGroupRestriction() {
openConfirmDialog({
title: m.unrestrict_oidc_client({ clientName: client.name }),
message: {
message: m.confirm_unrestrict_oidc_client_description,
inputs: { clientName: client.name }
},
confirm: {
label: m.unrestrict(),
destructive: true,
action: async () => {
await oidcService
.updateClient(client.id, {
...client,
isGroupRestricted: false
})
.then(() => {
toast.success(m.user_groups_restriction_updated_successfully());
client.allowedUserGroupIds = [];
allowedUserGroups.markSaved();
client.isGroupRestricted = false;
})
.catch(axiosErrorToast);
}
}
});
}
async function saveScimServiceProvider(provider: ScimServiceProviderCreate | null) {
if (!provider) {
await scimService.deleteServiceProvider(scimServiceProvider!.id);
@@ -186,13 +117,6 @@
<title>{m.oidc_client_name({ name: client.name })}</title>
</svelte:head>
{#snippet UnrestrictButton()}
<Button
onclick={enableGroupRestriction}
variant={client.isGroupRestricted ? 'secondary' : 'default'}>{m.restrict()}</Button
>
{/snippet}
{#if client.pkceSupported && !client.pkceEnabled}
<Alert.Root variant="info">
<LucideInfo class="size-4" />
@@ -213,126 +137,57 @@
</Alert.Root>
{/if}
<div>
<div class="flex items-center justify-between gap-4">
<button type="button" class="text-muted-foreground flex text-sm" onclick={backNavigation.go}
><LucideChevronLeft class="size-5" /> {m.back()}</button
>
<Button variant="outline" size="sm" onclick={() => (showPreview = true)}>
<LucideEye class="mr-2 size-4" />
{m.oidc_data_preview()}
</Button>
</div>
<Tabs.Root value="general" useHash class="gap-4">
<div class="overflow-x-auto pb-1">
<Tabs.Root value="general" useHash class="gap-6">
<div class="overflow-x-auto border-b [scrollbar-width:none]">
<Tabs.List variant="line" class="min-w-max">
<Tabs.Trigger value="general">{m.general()}</Tabs.Trigger>
<Tabs.Trigger value="user-groups">
{m.allowed_user_groups()}
<Tabs.Trigger value="access">
{m.access()}
{#if client.isGroupRestricted && client.allowedUserGroupIds.length === 0}
<LucideTriangleAlert class="ml-0.5 size-4 text-yellow-600 dark:text-yellow-400" />
{/if}</Tabs.Trigger
>
<Tabs.Trigger value="credentials">{m.credentials()}</Tabs.Trigger>
<Tabs.Trigger value="api-access">{m.api_access()}</Tabs.Trigger>
<span class="size-1.5 rounded-full bg-yellow-500"></span>
{/if}
</Tabs.Trigger>
<Tabs.Trigger value="credentials">
{m.credentials()}
<Badge variant="secondary" class="text-muted-foreground h-4.5 px-1.5 text-[11px]">
{credentialCount}
</Badge>
</Tabs.Trigger>
<Tabs.Trigger value="scim">{m.scim_provisioning()}</Tabs.Trigger>
<Tabs.Trigger value="preview">{m.oidc_data_preview()}</Tabs.Trigger>
</Tabs.List>
</div>
<Tabs.Content value="general" class="flex flex-col gap-4">
<Card.Root>
<Card.Header>
<Card.Title>{client.name}</Card.Title>
</Card.Header>
<Card.Content>
<div class="flex flex-col">
<div class="mb-2 flex flex-col sm:flex-row sm:items-center">
<Field.Label class="w-52">{m.client_id()}</Field.Label>
<CopyToClipboard value={client.id}>
<span class="text-muted-foreground text-sm" data-testid="client-id">
{client.id}
</span>
</CopyToClipboard>
</div>
{#if $autoCreatedSecretId && clientSecrets.some((secret) => secret.id === $autoCreatedSecretId) && $clientSecretStore[$autoCreatedSecretId]}
<div class="mb-2 flex flex-col sm:flex-row sm:items-center">
<Field.Label class="w-52">{m.client_secret()}</Field.Label>
<CopyToClipboard value={$clientSecretStore[$autoCreatedSecretId]}>
<span class="text-muted-foreground text-sm break-all" data-testid="client-secret">
{$clientSecretStore[$autoCreatedSecretId]}
</span>
</CopyToClipboard>
</div>
{/if}
{#if showAllDetails}
<div transition:slide>
{#each Object.entries(setupDetails) as [key, value] (key)}
<div class="mb-2 flex flex-col sm:flex-row sm:items-center">
<Field.Label class="w-52">{key}</Field.Label>
<CopyToClipboard {value}>
<span class="text-muted-foreground text-sm">{value}</span>
</CopyToClipboard>
</div>
{/each}
</div>
{/if}
{#if !showAllDetails}
<div class="mt-4 flex justify-center">
<Button onclick={() => (showAllDetails = true)} size="sm" variant="ghost"
>{m.show_more_details()}</Button
>
</div>
{/if}
</div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Content>
<OidcForm mode="update" existingClient={client} callback={updateClient} />
</Card.Content>
</Card.Root>
<Tabs.Content value="general" class="flex flex-col gap-6">
<OidcClientConnectionDetailsCard
{client}
secrets={clientSecrets}
oidcConfiguration={data.oidcConfiguration}
/>
<OidcForm existingClient={client} callback={updateClient} />
<OidcClientTokenLifetimesCard {client} callback={updateTokenLifetimes} />
</Tabs.Content>
<Tabs.Content value="credentials" id="credentials" class="flex flex-col gap-4">
<OidcClientSecretsCard {client} bind:secrets={clientSecrets} />
<OidcClientFederatedCredentialsCard {client} callback={updateFederatedCredentials} />
</Tabs.Content>
<Tabs.Content value="user-groups" id="allowed-user-groups">
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between gap-4">
<div>
<Card.Title>{m.allowed_user_groups()}</Card.Title>
<Card.Description>
{client.isGroupRestricted
? m.allowed_user_groups_description()
: m.allowed_user_groups_status_unrestricted_description()}
</Card.Description>
</div>
{#if !client.isGroupRestricted}
{@render UnrestrictButton()}
{/if}
</div>
</Card.Header>
{#if client.isGroupRestricted}
<Card.Content>
<UserGroupSelection bind:selectedGroupIds={client.allowedUserGroupIds} />
<div class="mt-5 flex justify-end gap-3">
<Button onclick={disableGroupRestriction} variant="secondary">{m.unrestrict()}</Button>
</div>
</Card.Content>
{/if}
</Card.Root>
</Tabs.Content>
<Tabs.Content value="api-access" id="api-access">
<Tabs.Content value="access" class="flex flex-col gap-6">
<OidcClientAllowedUserGroupsCard bind:client />
<ApiAccessCard clientId={client.id} isPublicClient={client.isPublic} />
</Tabs.Content>
<Tabs.Content value="scim" id="scim-provisioning">
<Tabs.Content value="credentials" class="flex flex-col gap-6">
<OidcClientSecretsCard {client} bind:secrets={clientSecrets} />
<OidcClientFederatedCredentialsCard {client} callback={updateFederatedCredentials} />
</Tabs.Content>
<Tabs.Content value="scim">
<Card.Root>
<Card.Header>
<Card.Title>{m.scim_provisioning()}</Card.Title>
@@ -349,26 +204,6 @@
</Card.Content>
</Card.Root>
</Tabs.Content>
<Tabs.Content value="preview">
<Card.Root>
<Card.Header>
<div class="flex flex-col items-start justify-between gap-3 sm:flex-row sm:items-center">
<div>
<Card.Title>
{m.oidc_data_preview()}
</Card.Title>
<Card.Description>
{m.preview_the_oidc_data_that_would_be_sent_for_different_users()}
</Card.Description>
</div>
<Button variant="outline" onclick={() => (showPreview = true)}>
{m.show()}
</Button>
</div>
</Card.Header>
</Card.Root>
</Tabs.Content>
</Tabs.Root>
<OidcClientPreviewModal bind:open={showPreview} clientId={client.id} />
@@ -0,0 +1,119 @@
<script lang="ts">
import { openConfirmDialog } from '$lib/components/confirm-dialog';
import * as Card from '$lib/components/ui/card';
import * as Field from '$lib/components/ui/field';
import * as RadioGroup from '$lib/components/ui/radio-group';
import UserGroupSelection from '$lib/components/user-group-selection.svelte';
import { m } from '$lib/paraglide/messages';
import OidcService from '$lib/services/oidc-service';
import type { OidcClient } from '$lib/types/oidc.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { trackUnsavedValue } from '$lib/utils/unsaved-changes-util.svelte';
import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition';
type AccessMode = 'all' | 'restricted';
let {
client = $bindable()
}: {
client: OidcClient & { allowedUserGroupIds: string[] };
} = $props();
const oidcService = new OidcService();
const allowedUserGroups = trackUnsavedValue(
() => client.allowedUserGroupIds,
(allowedUserGroupIds) => {
client.allowedUserGroupIds = allowedUserGroupIds;
},
(allowedUserGroupIds) => oidcService.updateAllowedUserGroups(client.id, allowedUserGroupIds)
);
async function restrict() {
try {
await oidcService.updateClient(client.id, { ...client, isGroupRestricted: true });
client.isGroupRestricted = true;
toast.success(m.user_groups_restriction_updated_successfully());
} catch (e) {
axiosErrorToast(e);
}
}
function unrestrict() {
openConfirmDialog({
title: m.unrestrict_oidc_client({ clientName: client.name }),
message: {
message: m.confirm_unrestrict_oidc_client_description,
inputs: { clientName: client.name }
},
confirm: {
label: m.unrestrict(),
destructive: true,
action: async () => {
try {
await oidcService.updateClient(client.id, { ...client, isGroupRestricted: false });
client.allowedUserGroupIds = [];
allowedUserGroups.markSaved();
client.isGroupRestricted = false;
toast.success(m.user_groups_restriction_updated_successfully());
} catch (e) {
axiosErrorToast(e);
}
}
}
});
}
// The selection only follows the saved restriction, so a cancelled confirmation leaves it where it was
function setAccessMode(accessMode: AccessMode) {
if (accessMode === 'restricted' && !client.isGroupRestricted) {
restrict();
} else if (accessMode === 'all' && client.isGroupRestricted) {
unrestrict();
}
}
</script>
<Card.Root>
<Card.Header>
<Card.Title>{m.allowed_user_groups()}</Card.Title>
<Card.Description>{m.allowed_user_groups_description()}</Card.Description>
</Card.Header>
<Card.Content class="flex flex-col gap-6">
<RadioGroup.Root
class="grid gap-3 sm:grid-cols-2"
bind:value={
() => (client.isGroupRestricted ? 'restricted' : 'all'),
(value) => setAccessMode(value as AccessMode)
}
>
<Field.Label for="access-mode-all">
<Field.Field orientation="horizontal">
<RadioGroup.Item value="all" id="access-mode-all" />
<Field.Content>
<Field.Title>{m.all_users()}</Field.Title>
<Field.Description>{m.all_users_can_sign_in_to_this_client()}</Field.Description>
</Field.Content>
</Field.Field>
</Field.Label>
<Field.Label for="access-mode-restricted">
<Field.Field orientation="horizontal">
<RadioGroup.Item value="restricted" id="access-mode-restricted" />
<Field.Content>
<Field.Title>{m.selected_user_groups()}</Field.Title>
<Field.Description>
{m.only_members_of_the_selected_groups_can_sign_in()}
</Field.Description>
</Field.Content>
</Field.Field>
</Field.Label>
</RadioGroup.Root>
{#if client.isGroupRestricted}
<div transition:slide={{ duration: 200 }}>
<UserGroupSelection bind:selectedGroupIds={client.allowedUserGroupIds} />
</div>
{/if}
</Card.Content>
</Card.Root>
@@ -0,0 +1,102 @@
<script lang="ts">
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import clientSecretStore, { autoCreatedSecretId } from '$lib/stores/client-secret-store';
import type {
OidcClient,
OidcClientSecret,
OidcDiscoveryConfiguration
} from '$lib/types/oidc.type';
import { cn } from '$lib/utils/style';
import { LucideChevronDown } from '@lucide/svelte';
import { slide } from 'svelte/transition';
let {
client,
secrets,
oidcConfiguration
}: {
client: OidcClient;
secrets: OidcClientSecret[];
oidcConfiguration: OidcDiscoveryConfiguration;
} = $props();
let showAllEndpoints = $state(false);
// The secret that was generated together with the client is only known until the page is left
const createdSecret = $derived(
$autoCreatedSecretId && secrets.some((secret) => secret.id === $autoCreatedSecretId)
? $clientSecretStore[$autoCreatedSecretId]
: undefined
);
const endpoints = $derived([
{ label: m.issuer_url(), value: oidcConfiguration.issuer },
{ label: m.authorization_url(), value: oidcConfiguration.authorization_endpoint },
{ label: m.token_url(), value: oidcConfiguration.token_endpoint },
{ label: m.userinfo_url(), value: oidcConfiguration.userinfo_endpoint },
{ label: m.logout_url(), value: oidcConfiguration.end_session_endpoint },
{ label: m.certificate_url(), value: oidcConfiguration.jwks_uri }
]);
</script>
{#snippet detail(label: string, value: string, testId?: string)}
<div class="flex min-w-0 flex-col gap-1">
<span class="text-muted-foreground text-xs">{label}</span>
<CopyToClipboard {value}>
<span class="font-mono text-xs break-all" data-testid={testId}>{value}</span>
</CopyToClipboard>
</div>
{/snippet}
<!-- The main values sit next to each other in one row, so they don't leave the full-width card mostly empty -->
<!-- The endpoints use a grid instead, so that their columns line up across rows -->
<Card.Root>
<Card.Header>
<Card.Title class="min-w-0 truncate">{client.name}</Card.Title>
<Card.Action>
<Button
variant="ghost"
size="sm"
class="text-muted-foreground"
aria-label={showAllEndpoints ? m.show_less_details() : m.show_more_details()}
onclick={() => (showAllEndpoints = !showAllEndpoints)}
>
<!-- Only the chevron is shown on small screens so the title doesn't have to wrap -->
<span class="hidden sm:inline">
{showAllEndpoints ? m.show_less_details() : m.show_more_details()}
</span>
<LucideChevronDown
class={cn(
'size-4 opacity-60 transition-transform duration-200 sm:ml-1.5',
showAllEndpoints && 'rotate-180'
)}
/>
</Button>
</Card.Action>
</Card.Header>
<Card.Content class="flex flex-col gap-4">
<div class="flex flex-wrap gap-x-12 gap-y-4">
{@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`
)}
</div>
{#if showAllEndpoints}
<div
class="grid gap-x-6 gap-y-4 border-t pt-4 md:grid-cols-2 xl:grid-cols-3"
transition:slide={{ duration: 200 }}
>
{#each endpoints as endpoint (endpoint.label)}
{@render detail(endpoint.label, endpoint.value)}
{/each}
</div>
{/if}
</Card.Content>
</Card.Root>
@@ -11,6 +11,7 @@
callbackURLs = $bindable(),
error = $bindable(null),
disabled = false,
addLabel,
...restProps
}: HTMLAttributes<HTMLDivElement> & {
label: string;
@@ -18,13 +19,20 @@
callbackURLs: string[];
error?: string | null;
disabled?: boolean;
addLabel?: string;
children?: Snippet;
} = $props();
</script>
<div {...restProps}>
<FormInput {label} {description} {disabled}>
<UrlListInput bind:urls={callbackURLs} {error} {disabled} testIdPrefix="callback-url" />
<UrlListInput
bind:urls={callbackURLs}
{error}
{disabled}
{addLabel}
testIdPrefix="callback-url"
/>
</FormInput>
{#if error}
<Field.Error>{error}</Field.Error>
@@ -0,0 +1,153 @@
<script lang="ts">
import FormInput from '$lib/components/form/form-input.svelte';
import FormattedMessage from '$lib/components/formatted-message.svelte';
import { Button } from '$lib/components/ui/button';
import * as Field from '$lib/components/ui/field';
import * as RadioGroup from '$lib/components/ui/radio-group';
import { m } from '$lib/paraglide/messages';
import type { OidcClientCreate } from '$lib/types/oidc.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { callbackUrlSchema, emptyToUndefined } from '$lib/utils/zod-util';
import { z } from 'zod/v4';
import OidcCallbackUrlInput from './oidc-callback-url-input.svelte';
let {
callback
}: {
callback: (client: OidcClientCreate) => Promise<void>;
} = $props();
// New clients start with these lifetimes, they can be changed on the details page afterwards
const DEFAULT_ACCESS_TOKEN_DURATION_MINUTES = 60;
const DEFAULT_REFRESH_TOKEN_DURATION_MINUTES = 30 * 24 * 60;
let isLoading = $state(false);
let showCustomClientId = $state(false);
const formSchema = z.object({
id: emptyToUndefined(
z
.string()
.min(2)
.max(128)
.regex(/^[a-zA-Z0-9_-]+$/, {
message: m.invalid_client_id()
})
.optional()
),
name: z.string().min(2).max(50),
callbackURLs: z.array(callbackUrlSchema).default([]),
isPublic: z.boolean()
});
const formStore = createForm(formSchema, {
id: '',
name: '',
callbackURLs: [],
isPublic: false
});
const { inputs } = formStore;
async function onSubmit() {
const data = formStore.validate();
if (!data) return;
// Only the essentials are asked for here, everything else starts with its default and is configured on the details page
isLoading = true;
try {
await callback({
...data,
description: '',
logoutCallbackURLs: [],
backchannelLogoutURL: '',
pkceEnabled: data.isPublic,
requiresReauthentication: false,
requiresPushedAuthorizationRequests: false,
skipConsent: false,
isGroupRestricted: true,
credentials: { federatedIdentities: [], secrets: [] },
accessTokenDurationMinutes: DEFAULT_ACCESS_TOKEN_DURATION_MINUTES,
refreshTokenDurationMinutes: DEFAULT_REFRESH_TOKEN_DURATION_MINUTES
});
} catch (e) {
axiosErrorToast(e);
} finally {
isLoading = false;
}
}
</script>
{#snippet callbackUrlDescription()}
<FormattedMessage message={m.callback_url_description} />
{/snippet}
{#snippet clientTypeOption(value: string, title: string, description: string)}
<Field.Label for="client-type-{value}">
<Field.Field orientation="horizontal">
<RadioGroup.Item {value} id="client-type-{value}" />
<Field.Content>
<Field.Title>{title}</Field.Title>
<Field.Description>{description}</Field.Description>
</Field.Content>
</Field.Field>
</Field.Label>
{/snippet}
<form onsubmit={preventDefault(onSubmit)} class="flex flex-col gap-6">
<div class="grid grid-cols-1 gap-x-3 gap-y-6 md:grid-cols-2">
<FormInput
label={m.name()}
description={m.client_name_description()}
bind:input={$inputs.name}
/>
{#if showCustomClientId}
<FormInput
label={m.client_id()}
placeholder={m.generated()}
description={m.custom_client_id_description()}
bind:input={$inputs.id}
/>
{/if}
</div>
<Field.Set>
<Field.Legend variant="label">{m.client_type()}</Field.Legend>
<RadioGroup.Root
class="grid gap-3 sm:grid-cols-2"
value={$inputs.isPublic.value ? 'public' : 'confidential'}
onValueChange={(value) => ($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())}
</RadioGroup.Root>
</Field.Set>
<OidcCallbackUrlInput
label={m.callback_urls()}
description={callbackUrlDescription}
addLabel={m.add_callback_url()}
bind:callbackURLs={$inputs.callbackURLs.value}
bind:error={$inputs.callbackURLs.error}
/>
<div class="flex items-center justify-between gap-3">
<!-- The client ID can't be changed later, so this is the only place to set a custom one -->
{#if !showCustomClientId}
<Button
variant="ghost"
size="sm"
class="text-muted-foreground -ml-2.5"
onclick={() => (showCustomClientId = true)}
>
{m.set_custom_client_id()}
</Button>
{/if}
<Button {isLoading} type="submit" class="ml-auto">{m.create()}</Button>
</div>
</form>
@@ -1,86 +1,62 @@
<script lang="ts">
import FormInput from '$lib/components/form/form-input.svelte';
import FormattedMessage from '$lib/components/formatted-message.svelte';
import SwitchWithLabel from '$lib/components/form/switch-with-label.svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import * as Field from '$lib/components/ui/field';
import { Switch } from '$lib/components/ui/switch';
import * as Tabs from '$lib/components/ui/tabs';
import { m } from '$lib/paraglide/messages';
import type {
OidcClient,
OidcClientCreateWithLogo,
OidcClientUpdateWithLogo
} from '$lib/types/oidc.type';
import type { OidcClient, OidcClientCreateWithLogo } from '$lib/types/oidc.type';
import { cachedOidcClientLogo } from '$lib/utils/cached-image-util';
import { axiosErrorToast } from '$lib/utils/error-util';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { createForm, type FormInput as FormInputState } from '$lib/utils/form-util';
import { trackFormChanges } from '$lib/utils/unsaved-changes-util.svelte';
import { cn } from '$lib/utils/style';
import { callbackUrlSchema, emptyToUndefined, optionalUrl } from '$lib/utils/zod-util';
import { LucideChevronDown, LucideMoon, LucideSun } from '@lucide/svelte';
import { slide } from 'svelte/transition';
import { callbackUrlSchema, optionalUrl } from '$lib/utils/zod-util';
import { LucideMoon, LucideSun } from '@lucide/svelte';
import { z } from 'zod/v4';
import OidcCallbackUrlInput from './oidc-callback-url-input.svelte';
import OidcClientImageInput from './oidc-client-image-input.svelte';
let {
callback,
existingClient,
mode
existingClient
}: {
existingClient?: OidcClient;
callback: (client: OidcClientCreateWithLogo | OidcClientUpdateWithLogo) => Promise<void>;
mode: 'create' | 'update';
existingClient: OidcClient;
callback: (client: OidcClientCreateWithLogo) => Promise<void>;
} = $props();
let isLoading = $state(false);
let showAdvancedOptions = $state(false);
let logo = $state<File | null | undefined>();
let darkLogo = $state<File | null | undefined>();
// What discarding restores the previews to; moves forward whenever a logo is saved.
let savedLogoDataURL = existingClient?.hasLogo
? cachedOidcClientLogo.getUrl(existingClient!.id)
let savedLogoDataURL = existingClient.hasLogo
? cachedOidcClientLogo.getUrl(existingClient.id)
: null;
let savedDarkLogoDataURL = existingClient?.hasDarkLogo
? cachedOidcClientLogo.getUrl(existingClient!.id, false)
let savedDarkLogoDataURL = existingClient.hasDarkLogo
? cachedOidcClientLogo.getUrl(existingClient.id, false)
: null;
let logoDataURL: string | null = $state(savedLogoDataURL);
let darkLogoDataURL: string | null = $state(savedDarkLogoDataURL);
const isCIMDClient = $derived(existingClient?.clientType === 'cimd');
// Defaults for new clients; existing clients keep the lifetimes edited in their own card.
const DEFAULT_ACCESS_TOKEN_DURATION_MINUTES = 60;
const DEFAULT_REFRESH_TOKEN_DURATION_MINUTES = 30 * 24 * 60;
const isCIMDClient = $derived(existingClient.clientType === 'cimd');
const client = {
id: '',
name: existingClient?.name || '',
description: existingClient?.description || '',
callbackURLs: existingClient?.callbackURLs || [],
logoutCallbackURLs: existingClient?.logoutCallbackURLs || [],
backchannelLogoutURL: existingClient?.backchannelLogoutURL || '',
isPublic: existingClient?.isPublic || false,
pkceEnabled: existingClient?.pkceEnabled || false,
requiresReauthentication: existingClient?.requiresReauthentication || false,
name: existingClient.name || '',
description: existingClient.description || '',
callbackURLs: existingClient.callbackURLs || [],
logoutCallbackURLs: existingClient.logoutCallbackURLs || [],
backchannelLogoutURL: existingClient.backchannelLogoutURL || '',
isPublic: existingClient.isPublic || false,
pkceEnabled: existingClient.pkceEnabled || false,
requiresReauthentication: existingClient.requiresReauthentication || false,
requiresPushedAuthorizationRequests:
existingClient?.requiresPushedAuthorizationRequests || false,
skipConsent: existingClient?.skipConsent || false,
launchURL: existingClient?.launchURL || '',
existingClient.requiresPushedAuthorizationRequests || false,
skipConsent: existingClient.skipConsent || false,
launchURL: existingClient.launchURL || '',
logoUrl: '',
darkLogoUrl: '',
pkceSupported: existingClient?.pkceSupported || false
pkceSupported: existingClient.pkceSupported || false
};
const formSchema = z.object({
id: emptyToUndefined(
z
.string()
.min(2)
.max(128)
.regex(/^[a-zA-Z0-9_-]+$/, {
message: m.invalid_client_id()
})
.optional()
),
name: z.string().min(2).max(50),
description: z.string().max(150),
callbackURLs: z.array(callbackUrlSchema).default([]),
@@ -105,51 +81,37 @@
async function saveClient(data: z.infer<FormSchema>) {
await callback({
...data,
credentials: existingClient?.credentials ?? { federatedIdentities: [], secrets: [] },
credentials: existingClient.credentials ?? { federatedIdentities: [], secrets: [] },
logo: $inputs.logoUrl?.value ? undefined : logo,
logoUrl: $inputs.logoUrl?.value,
darkLogo: $inputs.darkLogoUrl?.value ? undefined : darkLogo,
darkLogoUrl: $inputs.darkLogoUrl?.value,
isGroupRestricted: existingClient?.isGroupRestricted ?? true,
// The token lifetimes are edited in their own card. The current values are sent along
// because the backend falls back to the defaults for missing ones.
accessTokenDurationMinutes:
existingClient?.accessTokenDurationMinutes ?? DEFAULT_ACCESS_TOKEN_DURATION_MINUTES,
refreshTokenDurationMinutes:
existingClient?.refreshTokenDurationMinutes ?? DEFAULT_REFRESH_TOKEN_DURATION_MINUTES
isGroupRestricted: existingClient.isGroupRestricted,
// The token lifetimes are edited in their own card, but the current values are sent along because the backend falls back to the defaults for missing ones
accessTokenDurationMinutes: existingClient.accessTokenDurationMinutes,
refreshTokenDurationMinutes: existingClient.refreshTokenDurationMinutes
});
const hasLogo = logo != null || !!$inputs.logoUrl?.value;
const hasDarkLogo = darkLogo != null || !!$inputs.darkLogoUrl?.value;
if (existingClient) {
if (hasLogo) {
logoDataURL = cachedOidcClientLogo.getUrl(existingClient.id);
}
if (hasDarkLogo) {
darkLogoDataURL = cachedOidcClientLogo.getUrl(existingClient.id, false);
}
savedLogoDataURL = logoDataURL;
savedDarkLogoDataURL = darkLogoDataURL;
// The uploaded file has been persisted, so it's no longer "pending" for dirty-tracking.
logo = undefined;
darkLogo = undefined;
} else {
formStore.reset();
if (hasLogo) {
logoDataURL = cachedOidcClientLogo.getUrl(existingClient.id);
}
if (hasDarkLogo) {
darkLogoDataURL = cachedOidcClientLogo.getUrl(existingClient.id, false);
}
savedLogoDataURL = logoDataURL;
savedDarkLogoDataURL = darkLogoDataURL;
// The uploaded file has been persisted, so it's no longer "pending" for dirty-tracking.
logo = undefined;
darkLogo = undefined;
}
// Create mode has its own Save button rather than going through the unsaved-changes bar.
// Submitting with the Enter key saves right away instead of going through the unsaved-changes bar
async function onSubmit() {
const data = formStore.validate();
if (!data) return;
isLoading = true;
try {
await saveClient(data);
} catch (e) {
axiosErrorToast(e);
} finally {
isLoading = false;
}
await saveClient(data).catch(axiosErrorToast);
}
function discardLogoChanges() {
@@ -159,12 +121,10 @@
darkLogoDataURL = savedDarkLogoDataURL;
}
if (mode === 'update') {
trackFormChanges(() => formStore, saveClient, {
dirty: () => logo !== undefined || darkLogo !== undefined,
discard: discardLogoChanges
});
}
trackFormChanges(() => formStore, saveClient, {
dirty: () => logo !== undefined || darkLogo !== undefined,
discard: discardLogoChanges
});
function onLogoChange(input: File | string | null, light: boolean = true) {
if (input == null) return;
@@ -217,175 +177,218 @@
<FormattedMessage message={m.logout_callback_url_description} />
{/snippet}
<form onsubmit={preventDefault(onSubmit)}>
<div class="grid grid-cols-1 gap-x-3 gap-y-7 sm:flex-row md:grid-cols-2">
<FormInput
label={m.name()}
class="w-full"
description={m.client_name_description()}
bind:input={$inputs.name}
disabled={isCIMDClient}
/>
<FormInput
label={m.client_description()}
class="w-full"
description={m.client_description_description()}
bind:input={$inputs.description}
/>
<FormInput
label={m.client_launch_url()}
description={m.client_launch_url_description()}
class="w-full"
type="url"
bind:input={$inputs.launchURL}
/>
<OidcCallbackUrlInput
label={m.callback_urls()}
description={callbackUrlDescription}
class="w-full"
bind:callbackURLs={$inputs.callbackURLs.value}
bind:error={$inputs.callbackURLs.error}
disabled={isCIMDClient}
/>
<OidcCallbackUrlInput
label={m.logout_callback_urls()}
description={logoutCallbackUrlDescription}
class="w-full"
bind:callbackURLs={$inputs.logoutCallbackURLs.value}
bind:error={$inputs.logoutCallbackURLs.error}
disabled={isCIMDClient}
/>
<div>
<SwitchWithLabel
id="public-client"
label={m.public_client()}
description={m.public_clients_description()}
onCheckedChange={(v) => {
if (v) {
$inputs.pkceEnabled.value = true;
}
}}
bind:checked={$inputs.isPublic.value}
disabled={isCIMDClient}
/>
</div>
<div
class="rounded-lg transition-all duration-200"
class:[&_[data-switch-root]]:ring-2={pkcePromptNeeded}
class:[&_[data-switch-root]]:ring-blue-500={pkcePromptNeeded}
>
<SwitchWithLabel
id="pkce"
label={m.pkce()}
description={m.proof_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks()}
disabled={isCIMDClient || $inputs.isPublic.value}
bind:checked={$inputs.pkceEnabled.value}
/>
</div>
<SwitchWithLabel
id="requires-reauthentication"
label={m.requires_reauthentication()}
description={m.requires_users_to_authenticate_again_on_each_authorization()}
bind:checked={$inputs.requiresReauthentication.value}
/>
<SwitchWithLabel
id="skip-consent"
label={m.skip_consent()}
description={m.skip_consent_description()}
bind:checked={$inputs.skipConsent.value}
/>
</div>
<div class="mt-7 w-full md:w-1/2">
<Tabs.Root value="light-logo">
<Tabs.Content value="light-logo">
<OidcClientImageInput
{logoDataURL}
resetLogo={() => resetLogo(true)}
clientName={$inputs.name.value}
light={true}
onLogoChange={(input) => onLogoChange(input, true)}
>
{#snippet tabTriggers()}
<Tabs.List class="grid h-8 w-full grid-cols-2">
<Tabs.Trigger value="light-logo" class="px-3">
<LucideSun class="size-4" />
</Tabs.Trigger>
<Tabs.Trigger value="dark-logo" class="px-3">
<LucideMoon class="size-4" />
</Tabs.Trigger>
</Tabs.List>
{/snippet}
</OidcClientImageInput>
</Tabs.Content>
<Tabs.Content value="dark-logo">
<OidcClientImageInput
light={false}
logoDataURL={darkLogoDataURL}
resetLogo={() => resetLogo(false)}
clientName={$inputs.name.value}
onLogoChange={(input) => onLogoChange(input, false)}
>
{#snippet tabTriggers()}
<Tabs.List class="grid h-8 w-full grid-cols-2">
<Tabs.Trigger value="light-logo" class="px-3">
<LucideSun class="size-4" />
</Tabs.Trigger>
<Tabs.Trigger value="dark-logo" class="px-3">
<LucideMoon class="size-4" />
</Tabs.Trigger>
</Tabs.List>
{/snippet}
</OidcClientImageInput>
</Tabs.Content>
</Tabs.Root>
</div>
{#snippet switchField(
id: string,
label: string,
description: string,
input: FormInputState<boolean>,
disabled: boolean = false,
onCheckedChange?: (checked: boolean) => void
)}
<Field.Field orientation="horizontal" data-disabled={disabled}>
<Field.Content>
<Field.Label for={id}>{label}</Field.Label>
<Field.Description>{description}</Field.Description>
</Field.Content>
<Switch {id} {disabled} {onCheckedChange} bind:checked={input.value} />
</Field.Field>
{/snippet}
{#if showAdvancedOptions}
<div class="mt-7 flex flex-col gap-y-7 md:col-span-2" transition:slide={{ duration: 200 }}>
<SwitchWithLabel
id="requires-par"
label={m.requires_pushed_authorization_requests()}
description={m.requires_pushed_authorization_requests_description()}
bind:checked={$inputs.requiresPushedAuthorizationRequests.value}
{#snippet publicClientField()}
{@render switchField(
'public-client',
m.public_client(),
m.public_clients_description(),
$inputs.isPublic,
isCIMDClient,
(checked) => {
if (checked) {
$inputs.pkceEnabled.value = true;
}
}
)}
{/snippet}
{#snippet pkceField()}
<div
class="rounded-lg transition-all duration-200"
class:[&_[data-switch-root]]:ring-2={pkcePromptNeeded}
class:[&_[data-switch-root]]:ring-blue-500={pkcePromptNeeded}
>
{@render switchField(
'pkce',
m.pkce(),
m.proof_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks(),
$inputs.pkceEnabled,
isCIMDClient || $inputs.isPublic.value
)}
</div>
{/snippet}
{#snippet reauthenticationField()}
{@render switchField(
'requires-reauthentication',
m.requires_reauthentication(),
m.requires_users_to_authenticate_again_on_each_authorization(),
$inputs.requiresReauthentication
)}
{/snippet}
{#snippet skipConsentField()}
{@render switchField(
'skip-consent',
m.skip_consent(),
m.skip_consent_description(),
$inputs.skipConsent
)}
{/snippet}
{#snippet parField()}
{@render switchField(
'requires-par',
m.requires_pushed_authorization_requests(),
m.requires_pushed_authorization_requests_description(),
$inputs.requiresPushedAuthorizationRequests
)}
{/snippet}
{#snippet callbackUrlsInput()}
<OidcCallbackUrlInput
label={m.callback_urls()}
description={callbackUrlDescription}
addLabel={m.add_callback_url()}
class="w-full"
bind:callbackURLs={$inputs.callbackURLs.value}
bind:error={$inputs.callbackURLs.error}
disabled={isCIMDClient}
/>
{/snippet}
{#snippet logoutCallbackUrlsInput()}
<OidcCallbackUrlInput
label={m.logout_callback_urls()}
description={logoutCallbackUrlDescription}
addLabel={m.add_logout_url()}
class="w-full"
bind:callbackURLs={$inputs.logoutCallbackURLs.value}
bind:error={$inputs.logoutCallbackURLs.error}
disabled={isCIMDClient}
/>
{/snippet}
{#snippet backchannelLogoutUrlInput()}
<FormInput
label={m.backchannel_logout_url()}
description={m.backchannel_logout_url_description()}
class="w-full"
type="url"
bind:input={$inputs.backchannelLogoutURL}
disabled={isCIMDClient}
/>
{/snippet}
{#snippet logoTabTriggers()}
<Tabs.List class="grid h-8 grid-cols-2">
<Tabs.Trigger value="light-logo" class="px-2.5" aria-label={m.light()}>
<LucideSun class="size-3.5" />
</Tabs.Trigger>
<Tabs.Trigger value="dark-logo" class="px-2.5" aria-label={m.dark()}>
<LucideMoon class="size-3.5" />
</Tabs.Trigger>
</Tabs.List>
{/snippet}
{#snippet logoInput()}
<Tabs.Root value="light-logo">
<Tabs.Content value="light-logo">
<OidcClientImageInput
{logoDataURL}
resetLogo={() => resetLogo(true)}
clientName={$inputs.name.value}
light={true}
onLogoChange={(input) => onLogoChange(input, true)}
tabTriggers={logoTabTriggers}
/>
<div class="grid grid-cols-1 gap-x-3 gap-y-7 md:grid-cols-2">
<FormInput
label={m.backchannel_logout_url()}
description={m.backchannel_logout_url_description()}
class="w-full"
type="url"
bind:input={$inputs.backchannelLogoutURL}
disabled={isCIMDClient}
/>
{#if mode == 'create'}
</Tabs.Content>
<Tabs.Content value="dark-logo">
<OidcClientImageInput
light={false}
logoDataURL={darkLogoDataURL}
resetLogo={() => resetLogo(false)}
clientName={$inputs.name.value}
onLogoChange={(input) => onLogoChange(input, false)}
tabTriggers={logoTabTriggers}
/>
</Tabs.Content>
</Tabs.Root>
{/snippet}
<!-- The form is split into cards so that related settings are grouped together -->
<form onsubmit={preventDefault(onSubmit)} class="flex flex-col gap-6">
<Card.Root>
<Card.Header>
<Card.Title>{m.application()}</Card.Title>
<Card.Description>{m.oidc_client_application_description()}</Card.Description>
</Card.Header>
<Card.Content class="flex flex-col gap-7">
<!-- The logo, name and description form one block because together they are what users see on the consent screen -->
<div class="flex flex-col gap-7 sm:flex-row sm:gap-6">
<div class="shrink-0">
{@render logoInput()}
</div>
<div class="flex flex-1 flex-col gap-6">
<FormInput
label={m.client_id()}
placeholder={m.generated()}
label={m.name()}
class="w-full"
description={m.custom_client_id_description()}
bind:input={$inputs.id}
bind:input={$inputs.name}
disabled={isCIMDClient}
/>
{/if}
<FormInput
label={m.client_description()}
class="w-full"
bind:input={$inputs.description}
/>
</div>
</div>
</div>
{/if}
<div class="relative mt-5 flex justify-center">
<Button
variant="ghost"
class="text-muted-foreground"
onclick={() => (showAdvancedOptions = !showAdvancedOptions)}
>
{showAdvancedOptions ? m.hide_advanced_options() : m.show_advanced_options()}
<LucideChevronDown
class={cn(
'size-5 transition-transform duration-200',
showAdvancedOptions && 'rotate-180 transform'
)}
<FormInput
label={m.client_launch_url()}
description={m.client_launch_url_description()}
class="w-full"
type="url"
bind:input={$inputs.launchURL}
/>
</Button>
{#if mode === 'create'}
<Button {isLoading} type="submit" class="absolute right-0">{m.save()}</Button>
{/if}
</div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>{m.redirects()}</Card.Title>
<Card.Description>{m.oidc_client_redirects_description()}</Card.Description>
</Card.Header>
<Card.Content class="flex flex-col gap-7">
{@render callbackUrlsInput()}
{@render logoutCallbackUrlsInput()}
{@render backchannelLogoutUrlInput()}
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>{m.security()}</Card.Title>
<Card.Description>{m.oidc_client_security_description()}</Card.Description>
</Card.Header>
<Card.Content>
<Field.Group class="gap-6">
{@render publicClientField()}
<Field.Separator />
{@render pkceField()}
<Field.Separator />
{@render reauthenticationField()}
<Field.Separator />
{@render skipConsentField()}
<Field.Separator />
{@render parField()}
</Field.Group>
</Card.Content>
</Card.Root>
</form>
@@ -1,10 +1,14 @@
<script lang="ts">
import UrlFileInput from '$lib/components/form/url-file-input.svelte';
import FileInput from '$lib/components/form/file-input.svelte';
import FormattedMessage from '$lib/components/formatted-message.svelte';
import ImageBox from '$lib/components/image-box.svelte';
import { Button } from '$lib/components/ui/button';
import * as Field from '$lib/components/ui/field';
import { Button, buttonVariants } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Popover from '$lib/components/ui/popover';
import { m } from '$lib/paraglide/messages';
import { LucideX } from '@lucide/svelte';
import { cn } from '$lib/utils/style';
import { LucideLink, LucideUpload, LucideX } from '@lucide/svelte';
import type { Snippet } from 'svelte';
let {
@@ -23,35 +27,121 @@
light: boolean;
} = $props();
let id = `oidc-client-logo-${light ? 'light' : 'dark'}`;
const id = `oidc-client-logo-${light ? 'light' : 'dark'}`;
let url = $state('');
let hasUrlError = $state(false);
let isDraggingOver = $state(false);
function selectFile(file: File | null) {
url = '';
hasUrlError = false;
onLogoChange(file);
}
function onFileChange(e: Event) {
selectFile((e.target as HTMLInputElement).files?.[0] || null);
}
// Dropping an image on the preview behaves like picking it in the file dialog
function onDrop(e: DragEvent) {
e.preventDefault();
isDraggingOver = false;
const file = e.dataTransfer?.files[0];
if (file?.type.startsWith('image/')) {
selectFile(file);
}
}
function onUrlChange(e: Event) {
const value = (e.target as HTMLInputElement).value.trim();
if (!value) return;
try {
new URL(value);
hasUrlError = false;
} catch {
hasUrlError = true;
return;
}
onLogoChange(value);
}
</script>
<Field.Label for={id}>{m.logo()}</Field.Label>
<div class="flex h-24 items-end gap-4">
<div class="flex flex-col gap-2">
{#if tabTriggers}
{@render tabTriggers()}
{/if}
<div class="flex flex-wrap items-center gap-2">
<UrlFileInput {id} label={m.upload_logo()} accept="image/*" onchange={onLogoChange} />
</div>
</div>
{#if logoDataURL}
<div class="flex items-start gap-4">
<div class="relative shrink-0">
<div class="flex w-28 flex-col gap-2">
<div
class="relative"
role="group"
aria-label={m.logo()}
ondragover={(e) => {
e.preventDefault();
isDraggingOver = true;
}}
ondragleave={() => (isDraggingOver = false)}
ondrop={onDrop}
>
<FileInput
{id}
accept="image/*"
onchange={onFileChange}
onclick={(e: any) => (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}
<ImageBox
class="size-24 {light ? 'bg-[#F5F5F5]' : 'bg-[#262626]'}"
class="size-full bg-transparent"
src={logoDataURL}
alt={m.name_logo({ name: clientName })}
/>
<Button
size="icon"
onclick={resetLogo}
class="absolute -top-2 -right-2 size-6 rounded-full shadow-md "
>
<LucideX class="size-3" />
</Button>
</div>
</div>
{/if}
<span class="sr-only">{m.upload_logo()}</span>
{:else}
<LucideUpload class="size-5" />
{m.logo()}
{/if}
</FileInput>
{#if logoDataURL}
<Button
size="icon"
onclick={resetLogo}
aria-label={m.remove_logo()}
class="absolute -top-2 -right-2 size-6 rounded-full shadow-md"
>
<LucideX class="size-3" />
</Button>
{/if}
</div>
<div class="flex items-center justify-between gap-1">
{@render tabTriggers?.()}
<Popover.Root>
<Popover.Trigger
class={cn(buttonVariants({ variant: 'ghost', size: 'icon-sm' }), 'text-muted-foreground')}
aria-label={m.use_image_url()}
>
<LucideLink class="size-4" />
</Popover.Trigger>
<Popover.Content class="w-80">
<Label for="{id}-url" class="text-xs">URL</Label>
<Input
id="{id}-url"
value={url}
oninput={(e) => (url = e.currentTarget.value)}
onfocusout={onUrlChange}
aria-invalid={hasUrlError}
type="url"
/>
{#if hasUrlError}
<p class="text-destructive mt-1 text-start text-xs">{m.invalid_url()}</p>
{/if}
<p class="text-muted-foreground mt-2 text-xs">
<FormattedMessage message={m.logo_from_url_description} />
</p>
</Popover.Content>
</Popover.Root>
</div>
</div>
@@ -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<string, unknown>;
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(' '));
}
});
</script>
<Dialog.Root bind:open {onOpenChange}>
<Dialog.Content class="sm-min-w[500px] max-h-[90vh] min-w-[90vw] overflow-auto lg:min-w-[1000px]">
{#snippet claimValue(key: string, value: unknown)}
{#if Array.isArray(value) && value.every((item) => typeof item !== 'object')}
<div class="flex flex-wrap gap-1.5">
{#each value as item, i (i)}
<Badge variant="secondary" class="h-auto max-w-full font-mono break-all whitespace-normal"
>{String(item)}</Badge
>
{:else}
<span class="text-muted-foreground font-mono text-xs">[]</span>
{/each}
</div>
{:else if value !== null && typeof value === 'object'}
<pre class="font-mono text-xs whitespace-pre-wrap break-all">{JSON.stringify(
value,
null,
2
)}</pre>
{:else if value === '' || value === null}
<span class="text-muted-foreground font-mono text-xs">{value === null ? 'null' : '""'}</span>
{:else}
{@const formattedDate = TIMESTAMP_CLAIMS.has(key) ? formatTimestamp(value) : null}
<CopyToClipboard value={String(value)}>
<span class="font-mono text-xs break-all">{String(value)}</span>
</CopyToClipboard>
{#if formattedDate}
<span class="text-muted-foreground ml-2 text-xs">{formattedDate}</span>
{/if}
{/if}
{/snippet}
<Dialog.Root bind:open>
<Dialog.Content class="flex h-[min(90vh,46rem)] flex-col sm:max-w-4xl">
<Dialog.Header>
<Dialog.Title>{m.oidc_data_preview()}</Dialog.Title>
<Dialog.Description>
{#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()}
</Dialog.Description>
</Dialog.Header>
<div class="overflow-auto px-4">
{#if loadingPreview}
<div class="flex items-center justify-center py-12">
<Spinner class="size-8" />
</div>
{/if}
<div class="grid gap-3 sm:grid-cols-2">
<Field.Field>
<Field.Label>{m.user()}</Field.Label>
<SearchableSelect
class="w-full"
selectText={m.select_user()}
isLoading={isUserSearchLoading}
items={users.map((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)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{m.scopes()}</Field.Label>
<MultiSelect
items={[
{ value: 'openid', label: 'openid' },
{ value: 'email', label: 'email' },
{ value: 'profile', label: 'profile' },
{ value: 'groups', label: 'groups' }
]}
bind:selectedItems={scopes}
/>
</Field.Field>
</div>
<div class="flex flex-col sm:flex-row justify-start gap-3">
<Field.Field class="w-auto min-w-48">
<Field.Label>{m.users()}</Field.Label>
<SearchableSelect
selectText={m.select_user()}
isLoading={isUserSearchLoading}
items={Object.values(users).map((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;
loadPreviewData();
}}
/>
</Field.Field>
<Field.Field class="w-auto">
<Field.Label>{m.scopes()}</Field.Label>
<MultiSelect
items={[
{ value: 'openid', label: 'openid' },
{ value: 'email', label: 'email' },
{ value: 'profile', label: 'profile' },
{ value: 'groups', label: 'groups' }
]}
bind:selectedItems={scopes}
/>
</Field.Field>
<div class="flex min-h-0 flex-1 flex-col gap-3">
<div class="flex items-center justify-between gap-3 border-b">
<!-- The token tabs scroll on narrow screens so the view toggle and copy button stay inside the dialog -->
<Tabs.Root bind:value={activeTab} class="min-w-0 overflow-x-auto [scrollbar-width:none]">
<Tabs.List variant="line">
<Tabs.Trigger value="idToken">{m.id_token()}</Tabs.Trigger>
<Tabs.Trigger value="accessToken">{m.access_token()}</Tabs.Trigger>
<Tabs.Trigger value="userInfo">{m.userinfo()}</Tabs.Trigger>
</Tabs.List>
</Tabs.Root>
<div class="flex shrink-0 items-center gap-1 pb-1">
<Tabs.Root bind:value={view}>
<Tabs.List class="h-8">
<Tabs.Trigger value="claims" class="px-2" aria-label={m.claims()}>
<LucideList class="size-3.5" />
</Tabs.Trigger>
<Tabs.Trigger value="json" class="px-2" aria-label="JSON">
<LucideBraces class="size-3.5" />
</Tabs.Trigger>
</Tabs.List>
</Tabs.Root>
<CopyToClipboard value={JSON.stringify(activeData, null, 2)}>
<Button
size="icon-sm"
variant="ghost"
aria-label={m.copy_all()}
disabled={!previewData}
>
<LucideCopy class="size-3.5" />
</Button>
</CopyToClipboard>
</div>
</div>
{#if errorMessage && !loadingPreview}
<Alert.Root variant="destructive" class="mt-5 mb-6">
<LucideAlertTriangle class="h-4 w-4" />
<Alert.Title>{m.error()}</Alert.Title>
<Alert.Description>
{errorMessage}
</Alert.Description>
</Alert.Root>
{/if}
{#if previewData && !loadingPreview}
<Tabs.Root value="id-token" class="mt-5 w-full">
<Tabs.List class="mb-6 grid w-full grid-cols-3">
<Tabs.Trigger value="id-token">{m.id_token()}</Tabs.Trigger>
<Tabs.Trigger value="access-token">{m.access_token()}</Tabs.Trigger>
<Tabs.Trigger value="userinfo">{m.userinfo()}</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="id-token">
{@render tabContent(previewData.idToken, m.id_token_payload())}
</Tabs.Content>
<Tabs.Content value="access-token" class="mt-4">
{@render tabContent(previewData.accessToken, m.access_token_payload())}
</Tabs.Content>
<Tabs.Content value="userinfo" class="mt-4">
{@render tabContent(previewData.userInfo, m.userinfo_endpoint_response())}
</Tabs.Content>
</Tabs.Root>
{/if}
<!-- The frame has a fixed size so the dialog doesn't jump in height between tabs, views and reloads -->
<div
class={cn(
'min-h-0 flex-1 overflow-y-auto rounded-2xl border transition-opacity',
isLoading && previewData && 'opacity-50'
)}
>
{#if errorMessage && !isLoading}
<div class="flex h-full flex-col items-center justify-center p-6 text-center">
<p class="font-medium">{m.error()}</p>
<p class="text-muted-foreground max-w-sm">{errorMessage}</p>
</div>
{:else if !previewData}
<div class="flex h-full items-center justify-center">
<Spinner class="size-6" />
</div>
{:else if view === 'json'}
<pre class="p-4 font-mono text-xs whitespace-pre-wrap break-all">{JSON.stringify(
activeData,
null,
2
)}</pre>
{:else}
<dl class="divide-y" data-testid="preview-claims">
{#each Object.entries(activeData) as [key, value] (key)}
<div class="grid gap-1 px-4 py-2.5 sm:grid-cols-[11rem_minmax(0,1fr)] sm:gap-4">
<dt class="text-muted-foreground font-mono text-xs leading-5">{key}</dt>
<dd class="min-w-0 leading-5">{@render claimValue(key, value)}</dd>
</div>
{/each}
</dl>
{/if}
</div>
</div>
</Dialog.Content>
</Dialog.Root>
{#snippet tabContent(data: any, title: string)}
<div class="space-y-4">
<div class="mb-6 flex items-center justify-between">
<span class="text-lg font-semibold">{title}</span>
<CopyToClipboard value={JSON.stringify(data, null, 2)}>
<Button size="sm" variant="outline">{m.copy_all()}</Button>
</CopyToClipboard>
</div>
<div class="space-y-3">
{#each Object.entries(data || {}) as [key, value] (key)}
<div class="grid grid-cols-1 items-start gap-4 border-b pb-3 md:grid-cols-[200px_1fr]">
<Field.Label class="pt-1">{key}</Field.Label>
<div class="min-w-0">
<CopyToClipboard value={typeof value === 'string' ? value : JSON.stringify(value)}>
<div
class="text-muted-foreground bg-muted/30 hover:bg-muted/50 cursor-pointer rounded px-3 py-2 font-mono text-sm"
>
{typeof value === 'object' ? JSON.stringify(value, null, 2) : value}
</div>
</CopyToClipboard>
</div>
</div>
{/each}
</div>
</div>
{/snippet}
+3 -3
View File
@@ -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');
});
@@ -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');
+7 -24
View File
@@ -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();
-3
View File
@@ -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();
});
+4 -4
View File
@@ -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();