feat: redesign OIDC client details page

This commit is contained in:
Elias Schneider
2026-10-01 19:48:57 +02:00
parent 642ccc80d3
commit e588e3fc5d
22 changed files with 1058 additions and 787 deletions
@@ -33,6 +33,7 @@
labelFor,
readonly = false,
inputClass,
class: className,
...restProps
}: HTMLAttributes<HTMLDivElement> &
(WithChildren | WithoutChildren) & {
@@ -52,7 +53,7 @@
<Field.Field
data-disabled={disabled}
class={cn('flex flex-col justify-between', restProps.class)}
class={cn('flex flex-col justify-between', className)}
{...restProps}
>
<div>
@@ -1,5 +1,7 @@
<script lang="ts">
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { m } from '$lib/paraglide/messages';
import { cn } from '$lib/utils/style';
import { LucideChevronDown } from '@lucide/svelte';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
@@ -8,7 +10,9 @@
items,
selectedItems = $bindable(),
onSelect,
autoClose = false
autoClose = false,
placeholder = m.select_an_option(),
class: className
}: {
items: {
value: string;
@@ -17,8 +21,12 @@
selectedItems: string[];
onSelect?: (value: string) => void;
autoClose?: boolean;
placeholder?: string;
class?: string;
} = $props();
const selected = $derived(items.filter((item) => selectedItems.includes(item.value)));
function handleItemSelect(value: string) {
if (selectedItems.includes(value)) {
selectedItems = selectedItems.filter((item) => item !== value);
@@ -32,13 +40,19 @@
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline">
{#each items.filter((item) => selectedItems.includes(item.value)) as item (item.value)}
<Badge variant="secondary">
{item.label}
</Badge>
{/each}
<LucideChevronDown class="text-muted-foreground ml-2 size-4" />
<Button {...props} variant="outline" class={cn('w-full px-3 font-normal', className)}>
<!-- Button centers its content in an inner span, so this one spreads the badges and the chevron apart -->
<span class="flex w-full min-w-0 items-center justify-between gap-2">
<!-- The badges stay on one line and are clipped so the trigger keeps the height of the other inputs -->
<span class="flex min-w-0 gap-1 overflow-hidden">
{#each selected as item (item.value)}
<Badge variant="secondary">{item.label}</Badge>
{:else}
<span class="text-muted-foreground">{placeholder}</span>
{/each}
</span>
<LucideChevronDown class="size-4 shrink-0 opacity-50" />
</span>
</Button>
{/snippet}
</DropdownMenu.Trigger>
@@ -1,88 +0,0 @@
<script lang="ts">
import FileInput from '$lib/components/form/file-input.svelte';
import FormattedMessage from '$lib/components/formatted-message.svelte';
import { Button, buttonVariants } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Popover from '$lib/components/ui/popover';
import { m } from '$lib/paraglide/messages';
import { cn } from '$lib/utils/style';
import { LucideChevronDown } from '@lucide/svelte';
let {
label,
accept,
onchange,
id = 'file-input'
}: {
label: string;
accept?: string;
onchange: (file: File | string | null) => void;
id?: string;
} = $props();
let url = $state('');
let hasError = $state(false);
async function handleFileChange(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0] || null;
url = '';
hasError = false;
onchange(file);
}
async function handleUrlChange(e: Event) {
const url = (e.target as HTMLInputElement).value.trim();
if (!url) return;
try {
new URL(url);
hasError = false;
} catch {
hasError = true;
return;
}
onchange(url);
}
</script>
<div class="flex">
<FileInput
{id}
variant="secondary"
{accept}
onchange={handleFileChange}
onclick={(e: any) => (e.target.value = '')}
>
<Button variant="secondary" class="rounded-r-none">
{label}
</Button>
</FileInput>
<Popover.Root>
<Popover.Trigger
class={cn(buttonVariants({ variant: 'secondary' }), 'rounded-l-none border-l')}
>
<LucideChevronDown class="size-4" /></Popover.Trigger
>
<Popover.Content class="w-80">
<Label for="{id}-url" class="text-xs">URL</Label>
<Input
id="{id}-url"
placeholder=""
value={url}
oninput={(e) => (url = e.currentTarget.value)}
onfocusout={handleUrlChange}
aria-invalid={hasError}
type="url"
/>
{#if hasError}
<p class="text-destructive mt-1 text-start text-xs">{m.invalid_url()}</p>
{/if}
<p class="text-muted-foreground mt-2 text-xs">
<FormattedMessage message={m.logo_from_url_description} />
</p>
</Popover.Content>
</Popover.Root>
</div>
@@ -2,20 +2,23 @@
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { m } from '$lib/paraglide/messages';
import { LucideMinus, LucidePlus } from '@lucide/svelte';
import { LucidePlus, LucideX } from '@lucide/svelte';
let {
urls = $bindable(),
error = null,
testIdPrefix = 'url',
disabled = false,
keepAtLeastOne = false
keepAtLeastOne = false,
addLabel
}: {
urls: string[];
error?: string | null;
testIdPrefix?: string;
disabled?: boolean;
keepAtLeastOne?: boolean;
// Names what the button adds, instead of the generic "Add" and "Add another"
addLabel?: string;
} = $props();
function removeUrl(index: number) {
@@ -33,6 +36,7 @@
{#each urls as url, i (i)}
<div class="flex gap-x-2">
<Input
class="font-mono text-[13px] md:text-[13px]"
aria-invalid={!!error}
data-testid={`${testIdPrefix}-${i + 1}`}
type="text"
@@ -42,25 +46,26 @@
{disabled}
/>
<Button
variant="outline"
size="sm"
variant="ghost"
size="icon"
class="text-muted-foreground size-9"
aria-label={m.remove_url({ identifier: url || i + 1 })}
onclick={() => removeUrl(i)}
{disabled}
>
<LucideMinus class="size-4" />
<LucideX class="size-4" />
</Button>
</div>
{/each}
</div>
<Button
class="mt-2"
variant="secondary"
class="text-muted-foreground mt-1 -ml-2.5"
variant="ghost"
size="sm"
onclick={() => (urls = [...urls, ''])}
{disabled}
>
<LucidePlus class="mr-1 size-4" />
{urls.length === 0 ? m.add() : m.add_another()}
{addLabel ?? (urls.length === 0 ? m.add() : m.add_another())}
</Button>
</div>
@@ -55,7 +55,10 @@
onDismiss?.();
if (dismissibleId) {
const dismissedAlerts = JSON.parse(localStorage?.getItem('dismissed-alerts') || '[]');
localStorage?.setItem('dismissed-alerts', JSON.stringify([...dismissedAlerts, dismissibleId]));
localStorage?.setItem(
'dismissed-alerts',
JSON.stringify([...dismissedAlerts, dismissibleId])
);
isVisible = false;
}
}
@@ -3,7 +3,6 @@
import { page } from '$app/state';
import { cn } from '$lib/utils/style.js';
import { Tabs as TabsPrimitive } from 'bits-ui';
import { onMount } from 'svelte';
let {
ref = $bindable(null),
@@ -15,7 +14,8 @@
useHash?: boolean;
} = $props();
onMount(() => {
// Follows the hash on load and on later hash changes, so that links like `#credentials` elsewhere on the page can switch the tab
$effect(() => {
if (useHash && page.url.hash) {
value = page.url.hash.substring(1);
}
+2 -1
View File
@@ -150,9 +150,10 @@ export function createForm<T extends z.ZodType<any, any>>(schema: T, initialValu
}
// Handle unions
// A union that accepts an empty string, like `z.url().or(z.literal(''))`, can be left blank as well
if (fieldSchema instanceof z.ZodUnion) {
return !fieldSchema.def.options.some((o: any) => {
return o.def.type == 'optional';
return o.def.type == 'optional' || (o instanceof z.ZodLiteral && o.def.values.includes(''));
});
}