feat: oidc client data preview (#624)

Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
Kyle Mendell
2025-06-09 10:46:03 -05:00
committed by GitHub
parent 61bf14225b
commit c111b79147
12 changed files with 626 additions and 113 deletions

View File

@@ -353,5 +353,23 @@
"oidc_allowed_group_count": "Allowed Group Count",
"unrestricted": "Unrestricted",
"show_advanced_options": "Show Advanced Options",
"hide_advanced_options": "Hide 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",
"access_token": "Access Token",
"userinfo": "Userinfo",
"id_token_payload": "ID Token Payload",
"access_token_payload": "Access Token Payload",
"userinfo_endpoint_response": "Userinfo Endpoint Response",
"copy": "Copy",
"no_preview_data_available": "No preview data available",
"copy_all": "Copy All",
"preview": "Preview",
"preview_for_user": "Preview for {name} ({email})",
"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",
"error": "Error"
}

View File

@@ -0,0 +1,56 @@
<script lang="ts">
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { LucideChevronDown } from '@lucide/svelte';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
let {
items,
selectedItems = $bindable(),
onSelect,
autoClose = false
}: {
items: {
value: string;
label: string;
}[];
selectedItems: string[];
onSelect?: (value: string) => void;
autoClose?: boolean;
} = $props();
function handleItemSelect(value: string) {
if (selectedItems.includes(value)) {
selectedItems = selectedItems.filter((item) => item !== value);
} else {
selectedItems = [...selectedItems, value];
}
onSelect?.(value);
}
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline">
{#each items.filter((item) => selectedItems.includes(item.value)) as item}
<Badge variant="secondary">
{item.label}
</Badge>
{/each}
<LucideChevronDown class="text-muted-foreground ml-2 size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start" class="w-[var(--bits-dropdown-menu-anchor-width)]">
{#each items as item}
<DropdownMenu.CheckboxItem
checked={selectedItems.includes(item.value)}
onCheckedChange={() => handleItemSelect(item.value)}
closeOnSelect={autoClose}
>
{item.label}
</DropdownMenu.CheckboxItem>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>

View File

@@ -2,15 +2,19 @@
import { Button } from '$lib/components/ui/button';
import * as Command from '$lib/components/ui/command';
import * as Popover from '$lib/components/ui/popover';
import { m } from '$lib/paraglide/messages';
import { cn } from '$lib/utils/style';
import { LucideCheck, LucideChevronDown } from '@lucide/svelte';
import { LoaderCircle, LucideCheck, LucideChevronDown } from '@lucide/svelte';
import { tick } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import type { FormEventHandler, HTMLAttributes } from 'svelte/elements';
let {
items,
value = $bindable(),
onSelect,
oninput,
isLoading,
selectText = m.select_an_option(),
...restProps
}: HTMLAttributes<HTMLButtonElement> & {
items: {
@@ -18,7 +22,10 @@
label: string;
}[];
value: string;
oninput?: FormEventHandler<HTMLInputElement>;
onSelect?: (value: string) => void;
isLoading?: boolean;
selectText?: string;
} = $props();
let open = $state(false);
@@ -53,21 +60,35 @@
</script>
<Popover.Root bind:open {...restProps}>
<Popover.Trigger class="w-full">
<Popover.Trigger>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
class={cn('justify-between', restProps.class)}
>
{items.find((item) => item.value === value)?.label || 'Select an option'}
{items.find((item) => item.value === value)?.label || selectText}
<LucideChevronDown class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</Popover.Trigger>
<Popover.Content class="p-0">
<Popover.Content class="p-0" sameWidth>
<Command.Root shouldFilter={false}>
<Command.Input placeholder="Search..." oninput={(e: any) => filterItems(e.target.value)} />
<Command.Empty>No results found.</Command.Empty>
<Command.Input
placeholder={m.search()}
oninput={(e) => {
filterItems(e.currentTarget.value);
oninput?.(e);
}}
/>
<Command.Empty>
{#if isLoading}
<div class="flex w-full justify-center">
<LoaderCircle class="size-4 animate-spin" />
</div>
{:else}
{m.no_items_found()}
{/if}
</Command.Empty>
<Command.Group>
{#each filteredItems as item}
<Command.Item

View File

@@ -103,6 +103,13 @@ class OidcService extends APIService {
const response = await this.api.get(`/oidc/device/info?code=${userCode}`);
return response.data;
}
async getClientPreview(id: string, userId: string, scopes: string) {
const response = await this.api.get(`/oidc/clients/${id}/preview/${userId}`, {
params: { scopes }
});
return response.data;
}
}
export default OidcService;

View File

@@ -1,4 +1,8 @@
export function debounced<T extends (...args: any[]) => void>(func: T, delay: number) {
export function debounced<T extends (...args: any[]) => any>(
func: T,
delay: number,
onLoadingChange?: (loading: boolean) => void
) {
let debounceTimeout: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
@@ -6,8 +10,14 @@ export function debounced<T extends (...args: any[]) => void>(func: T, delay: nu
clearTimeout(debounceTimeout);
}
debounceTimeout = setTimeout(() => {
func(...args);
onLoadingChange?.(true);
debounceTimeout = setTimeout(async () => {
try {
await func(...args);
} finally {
onLoadingChange?.(false);
}
}, delay);
};
}

View File

@@ -13,10 +13,11 @@
import clientSecretStore from '$lib/stores/client-secret-store';
import type { OidcClientCreateWithLogo } from '$lib/types/oidc.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideChevronLeft, LucideRefreshCcw } from '@lucide/svelte';
import { LucideChevronLeft, LucideRefreshCcw, RectangleEllipsis } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition';
import OidcForm from '../oidc-client-form.svelte';
import OidcClientPreviewModal from '../oidc-client-preview-modal.svelte';
let { data } = $props();
let client = $state({
@@ -24,6 +25,7 @@
allowedUserGroupIds: data.allowedUserGroups.map((g) => g.id)
});
let showAllDetails = $state(false);
let showPreview = $state(false);
const oidcService = new OidcService();
@@ -91,6 +93,12 @@
});
}
let previewUserId = $state<string | null>(null);
function handlePreview(userId: string) {
previewUserId = userId;
}
beforeNavigate(() => {
clientSecretStore.clear();
});
@@ -180,3 +188,22 @@
<Button onclick={() => updateUserGroupClients(client.allowedUserGroupIds)}>{m.save()}</Button>
</div>
</CollapsibleCard>
<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>
<OidcClientPreviewModal bind:open={showPreview} clientId={client.id} />

View File

@@ -0,0 +1,211 @@
<script lang="ts">
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 { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import Label from '$lib/components/ui/label/label.svelte';
import * as Tabs from '$lib/components/ui/tabs';
import { m } from '$lib/paraglide/messages';
import OidcService from '$lib/services/oidc-service';
import UserService from '$lib/services/user-service';
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';
let {
open = $bindable(),
clientId
}: {
open: boolean;
clientId: string;
} = $props();
const oidcService = new OidcService();
const userService = new UserService();
let previewData = $state<{
idToken?: any;
accessToken?: any;
userInfo?: any;
} | null>(null);
let loadingPreview = $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);
async function loadPreviewData() {
errorMessage = null;
try {
previewData = await oidcService.getClientPreview(clientId, user!.id, scopes.join(' '));
} catch (e) {
const error = getAxiosErrorMessage(e);
errorMessage = error;
previewData = null;
} finally {
loadingPreview = false;
}
}
async function loadUsers(search?: string) {
users = (
await userService.list({
search,
pagination: { limit: 10, page: 1 }
})
).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;
});
}
}
const onUserSearch = debounced(
async (search: string) => await loadUsers(search),
300,
(loading) => (isUserSearchLoading = loading)
);
$effect(() => {
if (open) {
loadPreviewData();
}
});
onMount(() => {
loadUsers();
});
</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]">
<Dialog.Header>
<Dialog.Title>{m.oidc_data_preview()}</Dialog.Title>
<Dialog.Description>
{#if user}
{m.preview_for_user({ name: user.firstName + ' ' + user.lastName, email: user.email })}
{:else}
{m.preview_the_oidc_data_that_would_be_sent_for_this_user()}
{/if}
</Dialog.Description>
</Dialog.Header>
<div class="overflow-auto px-4">
{#if loadingPreview}
<div class="flex items-center justify-center py-12">
<div class="h-8 w-8 animate-spin rounded-full border-b-2 border-gray-900"></div>
</div>
{/if}
<div class="flex justify-start gap-3">
<div>
<Label class="text-sm font-medium">{m.users()}</Label>
<div>
<SearchableSelect
class="w-48"
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();
}}
/>
</div>
</div>
<div>
<Label class="text-sm font-medium">Scopes</Label>
<MultiSelect
items={[
{ value: 'openid', label: 'openid' },
{ value: 'email', label: 'email' },
{ value: 'profile', label: 'profile' },
{ value: 'groups', label: 'groups' }
]}
bind:selectedItems={scopes}
/>
</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}
</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">
<Label class="text-lg font-semibold">{title}</Label>
<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]}
<div class="grid grid-cols-1 items-start gap-4 border-b pb-3 md:grid-cols-[200px_1fr]">
<Label class="pt-1 text-sm font-medium">{key}</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}