feat: add tab bar navigation for crowded pages

This commit is contained in:
Elias Schneider
2026-07-10 14:57:43 +02:00
parent b2711ced99
commit 28a553f63b
20 changed files with 578 additions and 332 deletions

View File

@@ -28,7 +28,7 @@
>
<div
class="{!isAuthPage
? 'max-w-410'
? 'max-w-[1720px]'
: ''} mx-auto flex w-full items-center justify-between px-4 md:px-10"
>
<div class="flex h-16 items-center">

View File

@@ -12,6 +12,9 @@
<TabsPrimitive.Content
bind:ref
data-slot="tabs-content"
class={cn('text-sm flex-1 outline-none', className)}
class={cn(
'text-sm flex-1 outline-none data-[state=active]:animate-in data-[state=active]:fade-in-0 data-[state=active]:slide-in-from-bottom-1 data-[state=active]:duration-200 motion-reduce:data-[state=active]:animate-none',
className
)}
{...restProps}
/>

View File

@@ -19,22 +19,105 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from 'bits-ui';
import { onMount } from 'svelte';
import { cn } from '$lib/utils/style.js';
let {
ref = $bindable(null),
variant = 'default',
class: className,
children,
...restProps
}: TabsPrimitive.ListProps & {
variant?: TabsListVariant;
} = $props();
let indicatorOffset = $state(0);
let indicatorSize = $state(0);
let indicatorVisible = $state(false);
let indicatorOrientation = $state<'horizontal' | 'vertical'>('horizontal');
function updateIndicator() {
if (!ref || variant !== 'line') {
indicatorVisible = false;
return;
}
const activeTrigger = ref.querySelector<HTMLElement>(
'[data-slot="tabs-trigger"][data-state="active"]'
);
if (!activeTrigger) {
indicatorVisible = false;
return;
}
const listRect = ref.getBoundingClientRect();
const triggerRect = activeTrigger.getBoundingClientRect();
indicatorOrientation = ref.dataset.orientation === 'vertical' ? 'vertical' : 'horizontal';
if (indicatorOrientation === 'vertical') {
indicatorOffset = triggerRect.top - listRect.top;
indicatorSize = triggerRect.height;
} else {
const triggerStyle = getComputedStyle(activeTrigger);
const paddingLeft = Number.parseFloat(triggerStyle.paddingLeft);
const paddingRight = Number.parseFloat(triggerStyle.paddingRight);
indicatorOffset = triggerRect.left - listRect.left + paddingLeft;
indicatorSize = triggerRect.width - paddingLeft - paddingRight;
}
indicatorVisible = true;
}
onMount(() => {
if (!ref || variant !== 'line') return;
const resizeObserver = new ResizeObserver(updateIndicator);
const observeTriggers = () => {
ref
?.querySelectorAll<HTMLElement>('[data-slot="tabs-trigger"]')
.forEach((trigger) => resizeObserver.observe(trigger));
updateIndicator();
};
const mutationObserver = new MutationObserver(observeTriggers);
resizeObserver.observe(ref);
observeTriggers();
mutationObserver.observe(ref, {
attributes: true,
attributeFilter: ['data-state'],
childList: true,
subtree: true
});
return () => {
mutationObserver.disconnect();
resizeObserver.disconnect();
};
});
</script>
<TabsPrimitive.List
bind:ref
data-slot="tabs-list"
data-variant={variant}
class={cn(tabsListVariants({ variant }), className)}
class={cn(tabsListVariants({ variant }), variant === 'line' && 'relative', className)}
{...restProps}
/>
>
{@render children?.()}
{#if variant === 'line'}
<span
aria-hidden="true"
data-slot="tabs-indicator"
class={cn(
'bg-foreground pointer-events-none absolute rounded-full opacity-0 transition-[transform,width,height,opacity] duration-300 ease-out motion-reduce:transition-none',
indicatorOrientation === 'horizontal' ? 'bottom-0 left-0 h-0.5' : 'top-0 right-0 w-0.5',
indicatorVisible && 'opacity-100'
)}
style:width={indicatorOrientation === 'horizontal' ? `${indicatorSize}px` : undefined}
style:height={indicatorOrientation === 'vertical' ? `${indicatorSize}px` : undefined}
style:transform={indicatorOrientation === 'horizontal'
? `translate3d(${indicatorOffset}px, 0, 0)`
: `translate3d(0, ${indicatorOffset}px, 0)`}
></span>
{/if}
</TabsPrimitive.List>

View File

@@ -16,7 +16,6 @@
"gap-2 rounded-full border border-transparent! px-3 py-1 text-sm font-medium group-data-vertical/tabs:rounded-2xl group-data-vertical/tabs:px-3 group-data-vertical/tabs:py-1.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-4 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
'group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent',
'data-active:bg-background dark:data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 data-active:text-foreground',
'after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100',
className
)}
{...restProps}

View File

@@ -1,18 +1,36 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from 'bits-ui';
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),
value = $bindable(''),
useHash = false,
class: className,
...restProps
}: TabsPrimitive.RootProps = $props();
}: TabsPrimitive.RootProps & {
useHash?: boolean;
} = $props();
onMount(() => {
if (useHash && page.url.hash) {
value = page.url.hash.substring(1);
}
});
function onTabChange(newValue: string) {
if (useHash && page.url.hash !== newValue) {
window.location.hash = newValue;
}
}
</script>
<TabsPrimitive.Root
bind:ref
bind:value
onValueChange={onTabChange}
data-slot="tabs"
class={cn('gap-2 group/tabs flex data-[orientation=horizontal]:flex-col', className)}
{...restProps}

View File

@@ -50,7 +50,7 @@
>
<main
in:fade={{ duration: 200 }}
class="mx-auto flex w-full max-w-[1640px] flex-col gap-x-8 gap-y-8 p-4 md:p-8 lg:flex-row"
class="mx-auto flex w-full max-w-[1720px] flex-col gap-x-8 gap-y-8 p-4 md:p-8 lg:flex-row"
>
<div class="min-w-[200px] xl:min-w-[250px]">
<div in:fly={{ x: -15, duration: 200 }} class="sticky top-6">

View File

@@ -1,18 +1,14 @@
<script lang="ts">
import CollapsibleCard from '$lib/components/collapsible-card.svelte';
import * as Alert from '$lib/components/ui/alert';
import * as Card from '$lib/components/ui/card';
import * as Tabs from '$lib/components/ui/tabs';
import { m } from '$lib/paraglide/messages';
import AppConfigService from '$lib/services/app-config-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { AllAppConfig } from '$lib/types/application-configuration.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import {
LucideImage,
LucideInfo,
Mail,
SlidersHorizontal,
UserSearch,
Users
LucideInfo
} from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import AppConfigEmailForm from './forms/app-config-email-form.svelte';
@@ -101,57 +97,85 @@
</Alert.Description>
</Alert.Root>
{/if}
<div>
<CollapsibleCard
id="application-configuration-general"
icon={SlidersHorizontal}
title={m.general()}
defaultExpanded
>
<AppConfigGeneralForm {appConfig} callback={updateAppConfig} />
</CollapsibleCard>
</div>
<Tabs.Root value="general" useHash class="gap-4">
<div class="overflow-x-auto pb-1">
<Tabs.List variant="line" class="min-w-max">
<Tabs.Trigger value="general">
{m.general()}
</Tabs.Trigger>
<Tabs.Trigger value="user-creation">
{m.user_creation()}
</Tabs.Trigger>
<Tabs.Trigger value="email">
{m.email()}
</Tabs.Trigger>
<Tabs.Trigger value="ldap">
{m.ldap()}
</Tabs.Trigger>
<Tabs.Trigger value="images">
{m.images()}
</Tabs.Trigger>
</Tabs.List>
</div>
<div>
<CollapsibleCard
id="application-configuration-signup-defaults"
icon={Users}
title={m.user_creation()}
description={m.configure_user_creation()}
>
<AppConfigSignupDefaultsForm {appConfig} callback={updateAppConfig} />
</CollapsibleCard>
</div>
<Tabs.Content value="general" id="application-configuration-general">
<Card.Root>
<Card.Header>
<Card.Title>{m.general()}</Card.Title>
</Card.Header>
<Card.Content>
<AppConfigGeneralForm {appConfig} callback={updateAppConfig} />
</Card.Content>
</Card.Root>
</Tabs.Content>
<div>
<CollapsibleCard
id="application-configuration-email"
icon={Mail}
title={m.email()}
description={m.configure_smtp_to_send_emails()}
>
<AppConfigEmailForm {appConfig} callback={updateAppConfig} />
</CollapsibleCard>
</div>
<Tabs.Content value="user-creation" id="application-configuration-signup-defaults">
<Card.Root>
<Card.Header>
<Card.Title>{m.user_creation()}</Card.Title>
<Card.Description>{m.configure_user_creation()}</Card.Description>
</Card.Header>
<Card.Content>
<AppConfigSignupDefaultsForm {appConfig} callback={updateAppConfig} />
</Card.Content>
</Card.Root>
</Tabs.Content>
<div>
<CollapsibleCard
id="application-configuration-ldap"
icon={UserSearch}
title={m.ldap()}
description={m.configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server()}
>
<AppConfigLdapForm {appConfig} callback={updateAppConfig} />
</CollapsibleCard>
</div>
<Tabs.Content value="email" id="application-configuration-email">
<Card.Root>
<Card.Header>
<Card.Title>{m.email()}</Card.Title>
<Card.Description>{m.configure_smtp_to_send_emails()}</Card.Description>
</Card.Header>
<Card.Content>
<AppConfigEmailForm {appConfig} callback={updateAppConfig} />
</Card.Content>
</Card.Root>
</Tabs.Content>
<div>
<CollapsibleCard
id="application-configuration-images"
icon={LucideImage}
title={m.images()}
description={m.configure_application_images()}
>
<UpdateApplicationImages callback={updateImages} />
</CollapsibleCard>
</div>
<Tabs.Content value="ldap" id="application-configuration-ldap">
<Card.Root>
<Card.Header>
<Card.Title>{m.ldap()}</Card.Title>
<Card.Description>
{m.configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server()}
</Card.Description>
</Card.Header>
<Card.Content>
<AppConfigLdapForm {appConfig} callback={updateAppConfig} />
</Card.Content>
</Card.Root>
</Tabs.Content>
<Tabs.Content value="images" id="application-configuration-images">
<Card.Root>
<Card.Header>
<Card.Title>{m.images()}</Card.Title>
<Card.Description>{m.configure_application_images()}</Card.Description>
</Card.Header>
<Card.Content>
<UpdateApplicationImages callback={updateImages} />
</Card.Content>
</Card.Root>
</Tabs.Content>
</Tabs.Root>

View File

@@ -1,13 +1,14 @@
<script lang="ts">
import { beforeNavigate } from '$app/navigation';
import { page } from '$app/state';
import CollapsibleCard from '$lib/components/collapsible-card.svelte';
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 { 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';
@@ -17,7 +18,7 @@
import type { ScimServiceProviderCreate } from '$lib/types/scim.type';
import { cachedOidcClientLogo } from '$lib/utils/cached-image-util';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideChevronLeft, LucideInfo, LucideRefreshCcw } from '@lucide/svelte';
import { LucideChevronLeft, LucideInfo, LucideRefreshCcw, LucideTriangleAlert } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition';
import { backNavigate } from '../../users/navigate-back-util';
@@ -230,122 +231,176 @@
>
</div>
<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 !client.isPublic}
<div class="mt-1 mb-2 flex flex-col sm:flex-row sm:items-center">
<Field.Label class="w-52">{m.client_secret()}</Field.Label>
{#if $clientSecretStore}
<CopyToClipboard value={$clientSecretStore}>
<span class="text-muted-foreground text-sm" data-testid="client-secret">
{$clientSecretStore}
<Tabs.Root value="general" useHash class="gap-4">
<div class="overflow-x-auto pb-1">
<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()}
{#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="api-access">{m.api_access()}</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>
{:else}
<div>
<span class="text-muted-foreground text-sm" data-testid="client-secret"
>••••••••••••••••••••••••••••••••</span
>
<Button
class="ml-2"
onclick={createClientSecret}
size="sm"
variant="ghost"
aria-label="Create new client secret"><LucideRefreshCcw class="size-3" /></Button
</div>
{#if !client.isPublic}
<div class="mt-1 mb-2 flex flex-col sm:flex-row sm:items-center">
<Field.Label class="w-52">{m.client_secret()}</Field.Label>
{#if $clientSecretStore}
<CopyToClipboard value={$clientSecretStore}>
<span class="text-muted-foreground text-sm" data-testid="client-secret">
{$clientSecretStore}
</span>
</CopyToClipboard>
{:else}
<div>
<span class="text-muted-foreground text-sm" data-testid="client-secret"
>••••••••••••••••••••••••••••••••</span
>
<Button
class="ml-2"
onclick={createClientSecret}
size="sm"
variant="ghost"
aria-label="Create new client secret"
><LucideRefreshCcw class="size-3" /></Button
>
</div>
{/if}
</div>
{/if}
{#if showAllDetails}
<div transition:slide>
{#each Object.entries(setupDetails) as [key, value]}
<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>
{/if}
{#if showAllDetails}
<div transition:slide>
{#each Object.entries(setupDetails) as [key, value]}
<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}
</Card.Content>
</Card.Root>
{#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>
<CollapsibleCard
id="allowed-user-groups"
title={m.allowed_user_groups()}
button={!client.isGroupRestricted ? UnrestrictButton : undefined}
forcedExpanded={client.isGroupRestricted ? undefined : false}
description={client.isGroupRestricted
? m.allowed_user_groups_description()
: m.allowed_user_groups_status_unrestricted_description()}
>
<UserGroupSelection
bind:selectedGroupIds={client.allowedUserGroupIds}
selectionDisabled={!client.isGroupRestricted}
/>
<div class="mt-5 flex justify-end gap-3">
<Button onclick={disableGroupRestriction} variant="secondary">{m.unrestrict()}</Button>
<Card.Root>
<Card.Content>
<OidcForm mode="update" existingClient={client} callback={updateClient} />
</Card.Content>
</Card.Root>
</Tabs.Content>
<Button usePromiseLoading onclick={() => updateUserGroupClients(client.allowedUserGroupIds)}
>{m.save()}</Button
>
</div>
</CollapsibleCard>
<CollapsibleCard id="api-access" title={m.api_access()} description={m.api_access_description()}>
<ApiAccessCard clientId={client.id} isPublicClient={client.isPublic} />
</CollapsibleCard>
<CollapsibleCard
id="scim-provisioning"
title={m.scim_provisioning()}
description={m.scim_provisioning_description()}
>
<ScimResourceProviderForm
oidcClientId={client.id}
existingProvider={scimServiceProvider}
onSave={saveScimServiceProvider}
/>
</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>
<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>
<Button
usePromiseLoading
onclick={() => updateUserGroupClients(client.allowedUserGroupIds)}>{m.save()}</Button
>
</div>
</Card.Content>
{/if}
</Card.Root>
</Tabs.Content>
<Tabs.Content value="api-access" id="api-access">
<Card.Root>
<Card.Header>
<Card.Title>{m.api_access()}</Card.Title>
<Card.Description>{m.api_access_description()}</Card.Description>
</Card.Header>
<Card.Content>
<ApiAccessCard clientId={client.id} isPublicClient={client.isPublic} />
</Card.Content>
</Card.Root>
</Tabs.Content>
<Tabs.Content value="scim" id="scim-provisioning">
<Card.Root>
<Card.Header>
<Card.Title>{m.scim_provisioning()}</Card.Title>
<Card.Description>
{m.preview_the_oidc_data_that_would_be_sent_for_different_users()}
<FormattedMessage m={m.scim_provisioning_description()} />
</Card.Description>
</div>
</Card.Header>
<Card.Content>
<ScimResourceProviderForm
oidcClientId={client.id}
existingProvider={scimServiceProvider}
onSave={saveScimServiceProvider}
/>
</Card.Content>
</Card.Root>
</Tabs.Content>
<Button variant="outline" onclick={() => (showPreview = true)}>
{m.show()}
</Button>
</div>
</Card.Header>
</Card.Root>
<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} />

View File

@@ -1,9 +1,9 @@
<script lang="ts">
import CollapsibleCard from '$lib/components/collapsible-card.svelte';
import CustomClaimsInput from '$lib/components/form/custom-claims-input.svelte';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import * as Tabs from '$lib/components/ui/tabs';
import { m } from '$lib/paraglide/messages';
import CustomClaimService from '$lib/services/custom-claim-service';
import UserGroupService from '$lib/services/user-group-service';
@@ -86,59 +86,84 @@
<Badge class="rounded-full" variant="default">{m.ldap()}</Badge>
{/if}
</div>
<Card.Root>
<Card.Header>
<Card.Title>{m.general()}</Card.Title>
</Card.Header>
<Card.Content>
<UserGroupForm existingUserGroup={userGroup} callback={updateUserGroup} />
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>{m.users()}</Card.Title>
<Card.Description>{m.assign_users_to_this_group()}</Card.Description>
</Card.Header>
<Card.Content>
<UserSelection
bind:selectedUserIds={userGroup.userIds}
selectionDisabled={!!userGroup.ldapId && $appConfigStore.ldapEnabled}
/>
<div class="mt-5 flex justify-end">
<Button
disabled={!!userGroup.ldapId && $appConfigStore.ldapEnabled}
onclick={() => updateUserGroupUsers(userGroup.userIds)}>{m.save()}</Button
>
</div>
</Card.Content>
</Card.Root>
<CollapsibleCard
id="user-group-custom-claims"
title={m.custom_claims()}
description={m.custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized()}
>
<CustomClaimsInput bind:customClaims={userGroup.customClaims} />
<div class="mt-5 flex justify-end">
<Button onclick={updateCustomClaims} type="submit">{m.save()}</Button>
<Tabs.Root value="general" useHash class="gap-4">
<div class="overflow-x-auto pb-1">
<Tabs.List variant="line" class="min-w-max">
<Tabs.Trigger value="general">{m.general()}</Tabs.Trigger>
<Tabs.Trigger value="users">{m.users()}</Tabs.Trigger>
<Tabs.Trigger value="clients">{m.allowed_oidc_clients()}</Tabs.Trigger>
<Tabs.Trigger value="custom-claims">{m.custom_claims()}</Tabs.Trigger>
</Tabs.List>
</div>
</CollapsibleCard>
<CollapsibleCard
id="user-group-oidc-clients"
title={m.allowed_oidc_clients()}
description={m.allowed_oidc_clients_description()}
>
<OidcClientSelection
bind:this={oidcClientSelectionRef}
bind:selectedGroupIds={userGroup.allowedOidcClientIds}
/>
<div class="mt-5 flex justify-end gap-3">
<Button onclick={() => updateAllowedOidcClients(userGroup.allowedOidcClientIds)}
>{m.save()}</Button
>
</div>
</CollapsibleCard>
<Tabs.Content value="general">
<Card.Root>
<Card.Header>
<Card.Title>{m.general()}</Card.Title>
</Card.Header>
<Card.Content>
<UserGroupForm existingUserGroup={userGroup} callback={updateUserGroup} />
</Card.Content>
</Card.Root>
</Tabs.Content>
<Tabs.Content value="users">
<Card.Root>
<Card.Header>
<Card.Title>{m.users()}</Card.Title>
<Card.Description>{m.assign_users_to_this_group()}</Card.Description>
</Card.Header>
<Card.Content>
<UserSelection
bind:selectedUserIds={userGroup.userIds}
selectionDisabled={!!userGroup.ldapId && $appConfigStore.ldapEnabled}
/>
<div class="mt-5 flex justify-end">
<Button
disabled={!!userGroup.ldapId && $appConfigStore.ldapEnabled}
onclick={() => updateUserGroupUsers(userGroup.userIds)}>{m.save()}</Button
>
</div>
</Card.Content>
</Card.Root>
</Tabs.Content>
<Tabs.Content value="clients" id="user-group-oidc-clients">
<Card.Root>
<Card.Header>
<Card.Title>{m.allowed_oidc_clients()}</Card.Title>
<Card.Description>{m.allowed_oidc_clients_description()}</Card.Description>
</Card.Header>
<Card.Content>
<OidcClientSelection
bind:this={oidcClientSelectionRef}
bind:selectedGroupIds={userGroup.allowedOidcClientIds}
/>
<div class="mt-5 flex justify-end gap-3">
<Button onclick={() => updateAllowedOidcClients(userGroup.allowedOidcClientIds)}
>{m.save()}</Button
>
</div>
</Card.Content>
</Card.Root>
</Tabs.Content>
<Tabs.Content value="custom-claims" id="user-group-custom-claims">
<Card.Root>
<Card.Header>
<Card.Title>{m.custom_claims()}</Card.Title>
<Card.Description>
{m.custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized()}
</Card.Description>
</Card.Header>
<Card.Content>
<CustomClaimsInput bind:customClaims={userGroup.customClaims} />
<div class="mt-5 flex justify-end">
<Button onclick={updateCustomClaims} type="submit">{m.save()}</Button>
</div>
</Card.Content>
</Card.Root>
</Tabs.Content>
</Tabs.Root>

View File

@@ -1,11 +1,11 @@
<script lang="ts">
import CollapsibleCard from '$lib/components/collapsible-card.svelte';
import CustomClaimsInput from '$lib/components/form/custom-claims-input.svelte';
import ProfilePictureSettings from '$lib/components/form/profile-picture-settings.svelte';
import Badge from '$lib/components/ui/badge/badge.svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import * as Item from '$lib/components/ui/item/index.js';
import * as Tabs from '$lib/components/ui/tabs';
import UserGroupSelection from '$lib/components/user-group-selection.svelte';
import { m } from '$lib/paraglide/messages';
import CustomClaimService from '$lib/services/custom-claim-service';
@@ -94,70 +94,95 @@
<Badge class="rounded-full" variant="default">{m.ldap()}</Badge>
{/if}
</div>
<Card.Root>
<Card.Header>
<Card.Title>{m.general()}</Card.Title>
</Card.Header>
<Card.Content>
<UserForm existingUser={user} callback={updateUser} />
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Content>
<ProfilePictureSettings
userId={user.id}
isLdapUser={!!user.ldapId}
updateCallback={updateProfilePicture}
resetCallback={resetProfilePicture}
/>
</Card.Content>
</Card.Root>
<CollapsibleCard
id="user-groups"
title={m.user_groups()}
description={m.manage_which_groups_this_user_belongs_to()}
>
<UserGroupSelection
bind:selectedGroupIds={user.userGroupIds}
selectionDisabled={!!user.ldapId && $appConfigStore.ldapEnabled}
/>
<div class="mt-5 flex justify-end">
<Button
onclick={() => updateUserGroups(user.userGroupIds)}
disabled={!!user.ldapId && $appConfigStore.ldapEnabled}
type="submit">{m.save()}</Button
>
<Tabs.Root value="general" useHash class="gap-4">
<div class="overflow-x-auto pb-1">
<Tabs.List variant="line" class="min-w-max">
<Tabs.Trigger value="general">{m.general()}</Tabs.Trigger>
<Tabs.Trigger value="groups">{m.user_groups()}</Tabs.Trigger>
<Tabs.Trigger value="passkeys">{m.passkeys()}</Tabs.Trigger>
<Tabs.Trigger value="custom-claims">{m.custom_claims()}</Tabs.Trigger>
</Tabs.List>
</div>
</CollapsibleCard>
<Item.Group class="bg-card border shadow-sm rounded-4xl p-5">
<Item.Root class="border-none bg-transparent p-0">
<Item.Media class="text-primary/80">
<KeyRound class="size-5" />
</Item.Media>
<Item.Content class="min-w-52">
<Item.Title class="text-xl font-semibold">{m.passkeys()}</Item.Title>
<Item.Description
>{passkeys.length > 0
? m.manage_this_users_passkeys()
: m.user_has_no_passkeys_yet()}</Item.Description
>
</Item.Content>
</Item.Root>
{#if passkeys.length > 0}
<AdminPasskeyList userId={user.id} bind:passkeys />
{/if}
</Item.Group>
<Tabs.Content value="general" class="flex flex-col gap-4">
<Card.Root>
<Card.Header>
<Card.Title>{m.general()}</Card.Title>
</Card.Header>
<Card.Content>
<UserForm existingUser={user} callback={updateUser} />
</Card.Content>
</Card.Root>
<CollapsibleCard
id="user-custom-claims"
title={m.custom_claims()}
description={m.custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user()}
>
<CustomClaimsInput bind:customClaims={user.customClaims} />
<div class="mt-5 flex justify-end">
<Button onclick={updateCustomClaims} type="submit">{m.save()}</Button>
</div>
</CollapsibleCard>
<Card.Root>
<Card.Content>
<ProfilePictureSettings
userId={user.id}
isLdapUser={!!user.ldapId}
updateCallback={updateProfilePicture}
resetCallback={resetProfilePicture}
/>
</Card.Content>
</Card.Root>
</Tabs.Content>
<Tabs.Content value="groups" id="user-groups">
<Card.Root>
<Card.Header>
<Card.Title>{m.user_groups()}</Card.Title>
<Card.Description>{m.manage_which_groups_this_user_belongs_to()}</Card.Description>
</Card.Header>
<Card.Content>
<UserGroupSelection
bind:selectedGroupIds={user.userGroupIds}
selectionDisabled={!!user.ldapId && $appConfigStore.ldapEnabled}
/>
<div class="mt-5 flex justify-end">
<Button
onclick={() => updateUserGroups(user.userGroupIds)}
disabled={!!user.ldapId && $appConfigStore.ldapEnabled}
type="submit">{m.save()}</Button
>
</div>
</Card.Content>
</Card.Root>
</Tabs.Content>
<Tabs.Content value="passkeys">
<Item.Group class="bg-card rounded-4xl border p-5 shadow-sm">
<Item.Root class="border-none bg-transparent p-0">
<Item.Media class="text-primary/80">
<KeyRound class="size-5" />
</Item.Media>
<Item.Content class="min-w-52">
<Item.Title class="text-xl font-semibold">{m.passkeys()}</Item.Title>
<Item.Description
>{passkeys.length > 0
? m.manage_this_users_passkeys()
: m.user_has_no_passkeys_yet()}</Item.Description
>
</Item.Content>
</Item.Root>
{#if passkeys.length > 0}
<AdminPasskeyList userId={user.id} bind:passkeys />
{/if}
</Item.Group>
</Tabs.Content>
<Tabs.Content value="custom-claims" id="user-custom-claims">
<Card.Root>
<Card.Header>
<Card.Title>{m.custom_claims()}</Card.Title>
<Card.Description>
{m.custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user()}
</Card.Description>
</Card.Header>
<Card.Content>
<CustomClaimsInput bind:customClaims={user.customClaims} />
<div class="mt-5 flex justify-end">
<Button onclick={updateCustomClaims} type="submit">{m.save()}</Button>
</div>
</Card.Content>
</Card.Root>
</Tabs.Content>
</Tabs.Root>

View File

@@ -31,7 +31,7 @@
</script>
<Card.Root
class="border-muted group relative h-[160px] p-5 transition-all duration-200 hover:shadow-md sm:max-w-[50vw] md:max-w-[400px]"
class="border-muted group relative h-[160px] p-5 transition-all duration-200 hover:shadow-md sm:max-w-[50vw] md:max-w-[430px]"
data-testid="authorized-oidc-client-card"
>
<Card.Content class=" p-0">