mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-02 15:59:04 +02:00
feat: redesign OIDC client details page
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
+119
@@ -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>
|
||||
+102
@@ -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>
|
||||
|
||||
+174
-131
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user