mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-16 09:13:20 +03:00
feat: oidc client data preview (#624)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
56
frontend/src/lib/components/form/multi-select.svelte
Normal file
56
frontend/src/lib/components/form/multi-select.svelte
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user