feat: list allowed groups when hovering over allowed group count (#1671)

This commit is contained in:
Kyle Mendell
2026-08-25 11:30:16 -05:00
committed by GitHub
parent 29a6fd6c29
commit d75523fed0
11 changed files with 153 additions and 60 deletions

View File

@@ -118,7 +118,7 @@ func (oc *OidcController) getClientHandler(c *gin.Context) error {
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.OidcClientWithAllowedGroupsCountDto]
// @Success 200 {object} dto.Paginated[dto.OidcClientWithAllowedGroupsDto]
// @Failure default {object} dto.ErrorDto "Error"
// @Router /api/oidc/clients [get]
func (oc *OidcController) listClientsHandler(c *gin.Context) error {
@@ -131,22 +131,17 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) error {
}
// Map the user groups to DTOs
var clientsDto = make([]dto.OidcClientWithAllowedGroupsCountDto, len(clients))
var clientsDto = make([]dto.OidcClientWithAllowedGroupsDto, len(clients))
for i, client := range clients {
var clientDto dto.OidcClientWithAllowedGroupsCountDto
var clientDto dto.OidcClientWithAllowedGroupsDto
if err := dto.MapStruct(client, &clientDto); err != nil {
return err
}
clientDto.HasDarkLogo = client.HasDarkLogo()
clientDto.AllowedUserGroupsCount, err = oc.oidcService.GetAllowedGroupsCountOfClient(c, client.ID)
if err != nil {
return err
}
clientsDto[i] = clientDto
}
c.JSON(http.StatusOK, dto.Paginated[dto.OidcClientWithAllowedGroupsCountDto]{
c.JSON(http.StatusOK, dto.Paginated[dto.OidcClientWithAllowedGroupsDto]{
Data: clientsDto,
Pagination: pagination,
})

View File

@@ -33,9 +33,9 @@ type OidcClientWithAllowedUserGroupsDto struct {
AllowedUserGroups []UserGroupMinimalDto `json:"allowedUserGroups"`
}
type OidcClientWithAllowedGroupsCountDto struct {
type OidcClientWithAllowedGroupsDto struct {
OidcClientDto
AllowedUserGroupsCount int64 `json:"allowedUserGroupsCount"`
AllowedUserGroups []UserGroupMinimalDto `json:"allowedUserGroups"`
}
type OidcClientUpdateDto struct {

View File

@@ -124,14 +124,15 @@ func (s *OidcService) ListClients(ctx context.Context, name string, listRequestO
query := s.db.
WithContext(ctx).
Preload("CreatedBy").
Preload("AllowedUserGroups").
Model(&model.OidcClient{})
if name != "" {
query = query.Where("name LIKE ?", "%"+name+"%")
}
// As allowedUserGroupsCount is not a column, we need to manually sort it
if listRequestOptions.Sort.Column == "allowedUserGroupsCount" && utils.IsValidSortDirection(listRequestOptions.Sort.Direction) {
// Sort the allowed user groups relation by its row count because it is not an OIDC client column
if listRequestOptions.Sort.Column == "allowedUserGroups" && utils.IsValidSortDirection(listRequestOptions.Sort.Direction) {
query = query.Select("oidc_clients.*, COUNT(oidc_clients_allowed_user_groups.oidc_client_id)").
Joins("LEFT JOIN oidc_clients_allowed_user_groups ON oidc_clients.id = oidc_clients_allowed_user_groups.oidc_client_id").
Group("oidc_clients.id").
@@ -629,22 +630,6 @@ func (s *OidcService) UpdateAllowedUserGroups(ctx context.Context, id string, in
return client, nil
}
func (s *OidcService) GetAllowedGroupsCountOfClient(ctx context.Context, id string) (int64, error) {
// We only perform select queries here, so we can rollback in all cases
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
client, err := s.getClientInternal(ctx, id, tx, false)
if err != nil {
return 0, err
}
count := tx.WithContext(ctx).Model(&client).Association("AllowedUserGroups").Count()
return count, nil
}
func (s *OidcService) ListAuthorizedClients(ctx context.Context, userID string, listRequestOptions utils.ListRequestOptions) ([]model.UserAuthorizedOidcClient, utils.PaginationResponse, error) {
tx := s.db.Begin()
defer func() {

View File

@@ -28,20 +28,20 @@
<DropdownMenu.Separator />
{#each columns as column (column)}
{#if column.label}
<DropdownMenu.CheckboxItem
closeOnSelect={false}
checked={selectedColumns.includes(column.column ?? column.key!)}
onCheckedChange={(v) => {
const key = column.column ?? column.key!;
if (v) {
selectedColumns = [...selectedColumns, key];
} else {
selectedColumns = selectedColumns.filter((c) => c !== key);
}
}}
>
{column.label}
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
closeOnSelect={false}
checked={selectedColumns.includes(column.column ?? column.key!)}
onCheckedChange={(v) => {
const key = column.column ?? column.key!;
if (v) {
selectedColumns = [...selectedColumns, key];
} else {
selectedColumns = selectedColumns.filter((c) => c !== key);
}
}}
>
{column.label}
</DropdownMenu.CheckboxItem>
{/if}
{/each}
</DropdownMenu.Group>

View File

@@ -0,0 +1,10 @@
import Scrollbar from './scroll-area-scrollbar.svelte';
import Root from './scroll-area.svelte';
export {
Root,
Scrollbar,
//,
Root as ScrollArea,
Scrollbar as ScrollAreaScrollbar
};

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import { ScrollArea as ScrollAreaPrimitive } from 'bits-ui';
import { cn, type WithoutChild } from '$lib/utils/style.js';
let {
ref = $bindable(null),
class: className,
orientation = 'vertical',
children,
...restProps
}: WithoutChild<ScrollAreaPrimitive.ScrollbarProps> = $props();
</script>
<ScrollAreaPrimitive.Scrollbar
bind:ref
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
{orientation}
class={cn(
'data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent flex touch-none p-px transition-colors select-none',
className
)}
{...restProps}
>
{@render children?.()}
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
class="rounded-full relative flex-1 bg-border"
/>
</ScrollAreaPrimitive.Scrollbar>

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import { ScrollArea as ScrollAreaPrimitive } from 'bits-ui';
import { cn, type WithoutChild } from '$lib/utils/style.js';
import { Scrollbar } from './index.js';
let {
ref = $bindable(null),
viewportRef = $bindable(null),
class: className,
orientation = 'vertical',
scrollbarXClasses = '',
scrollbarYClasses = '',
children,
...restProps
}: WithoutChild<ScrollAreaPrimitive.RootProps> & {
orientation?: 'vertical' | 'horizontal' | 'both' | undefined;
scrollbarXClasses?: string | undefined;
scrollbarYClasses?: string | undefined;
viewportRef?: HTMLElement | null;
} = $props();
</script>
<ScrollAreaPrimitive.Root
bind:ref
data-slot="scroll-area"
class={cn('relative', className)}
{...restProps}
>
<ScrollAreaPrimitive.Viewport
bind:ref={viewportRef}
data-slot="scroll-area-viewport"
class="cn-scroll-area-viewport size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{@render children?.()}
</ScrollAreaPrimitive.Viewport>
{#if orientation === 'vertical' || orientation === 'both'}
<Scrollbar orientation="vertical" class={scrollbarYClasses} />
{/if}
{#if orientation === 'horizontal' || orientation === 'both'}
<Scrollbar orientation="horizontal" class={scrollbarXClasses} />
{/if}
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>

View File

@@ -11,8 +11,8 @@ import type {
OidcClientSecret,
OidcClientSecretCreated,
OidcClientUpdate,
OidcClientWithAllowedGroups,
OidcClientWithAllowedUserGroups,
OidcClientWithAllowedUserGroupsCount,
OidcDeviceCodeInfo
} from '$lib/types/oidc.type';
import type { ScimServiceProvider } from '$lib/types/scim.type';
@@ -38,7 +38,7 @@ class OidcService extends APIService {
const res = await this.api.get('/oidc/clients', {
params: options
});
return res.data as Paginated<OidcClientWithAllowedUserGroupsCount>;
return res.data as Paginated<OidcClientWithAllowedGroups>;
};
createClient = async (client: OidcClientCreate) =>

View File

@@ -1,4 +1,4 @@
import type { UserGroup } from './user-group.type';
import type { UserGroup, UserGroupMinimal } from './user-group.type';
export type OidcClientType = 'standard' | 'cimd';
@@ -74,8 +74,8 @@ export type OidcClientWithAllowedUserGroups = OidcClient & {
allowedUserGroups: UserGroup[];
};
export type OidcClientWithAllowedUserGroupsCount = OidcClient & {
allowedUserGroupsCount: number;
export type OidcClientWithAllowedGroups = OidcClient & {
allowedUserGroups: UserGroupMinimal[];
};
export type OidcClientUpdate = Omit<

View File

@@ -3,13 +3,15 @@
import { openConfirmDialog } from '$lib/components/confirm-dialog/';
import ImageBox from '$lib/components/image-box.svelte';
import AdvancedTable from '$lib/components/table/advanced-table.svelte';
import { ScrollArea } from '$lib/components/ui/scroll-area';
import * as Tooltip from '$lib/components/ui/tooltip';
import { m } from '$lib/paraglide/messages';
import OIDCService from '$lib/services/oidc-service';
import type {
AdvancedTableColumn,
CreateAdvancedTableActions
} from '$lib/types/advanced-table.type';
import type { OidcClient, OidcClientWithAllowedUserGroupsCount } from '$lib/types/oidc.type';
import type { OidcClient, OidcClientWithAllowedGroups } from '$lib/types/oidc.type';
import { cachedOidcClientLogo } from '$lib/utils/cached-image-util';
import { encodeClientIdParam } from '$lib/utils/client-id-util';
import { axiosErrorToast } from '$lib/utils/error-util';
@@ -18,7 +20,7 @@
import { toast } from 'svelte-sonner';
const oidcService = new OIDCService();
let tableRef: AdvancedTable<OidcClientWithAllowedUserGroupsCount>;
let tableRef: AdvancedTable<OidcClientWithAllowedGroups>;
export function refresh() {
return tableRef?.refresh();
@@ -36,15 +38,15 @@
{ label: m.client_type_metadata_document(), value: 'cimd' }
];
const columns: AdvancedTableColumn<OidcClientWithAllowedUserGroupsCount>[] = [
const columns: AdvancedTableColumn<OidcClientWithAllowedGroups>[] = [
{ label: 'ID', column: 'id', hidden: true },
{ label: m.logo(), key: 'logo', cell: LogoCell },
{ label: m.name(), column: 'name', sortable: true },
{
label: m.oidc_allowed_group_count(),
column: 'allowedUserGroupsCount',
column: 'allowedUserGroups',
sortable: true,
value: (item) => (item.isGroupRestricted ? item.allowedUserGroupsCount : '-')
cell: AllowedGroupCountCell
},
{
label: m.restricted(),
@@ -93,7 +95,7 @@
}
];
const actions: CreateAdvancedTableActions<OidcClientWithAllowedUserGroupsCount> = (client) => [
const actions: CreateAdvancedTableActions<OidcClientWithAllowedGroups> = (client) => [
{
label: m.edit(),
primary: true,
@@ -145,7 +147,35 @@
}
</script>
{#snippet LogoCell({ item }: { item: OidcClientWithAllowedUserGroupsCount })}
{#snippet AllowedGroupCountCell({ item }: { item: OidcClientWithAllowedGroups })}
{#if !item.isGroupRestricted}
-
{:else if item.allowedUserGroups.length === 0}
{item.allowedUserGroups.length}
{:else}
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger class="cursor-default underline decoration-dotted underline-offset-4">
{item.allowedUserGroups.length}
</Tooltip.Trigger>
<Tooltip.Content side="right" class="flex-col items-start">
<ScrollArea
class="[&>[data-slot=scroll-area-viewport]]:max-h-48"
scrollbarYClasses="[&>[data-slot=scroll-area-thumb]]:bg-background/40"
>
<div class="flex flex-col gap-0.5 pr-3">
{#each item.allowedUserGroups as group (group.id)}
<span>{group.friendlyName}</span>
{/each}
</div>
</ScrollArea>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
{/if}
{/snippet}
{#snippet LogoCell({ item }: { item: OidcClientWithAllowedGroups })}
{#if item.hasLogo}
<ImageBox
class="size-12 rounded-lg"

View File

@@ -5,7 +5,7 @@
import OidcService from '$lib/services/oidc-service';
import type { AdvancedTableColumn } from '$lib/types/advanced-table.type';
import type { ListRequestOptions } from '$lib/types/list-request.type';
import type { OidcClient, OidcClientWithAllowedUserGroupsCount } from '$lib/types/oidc.type';
import type { OidcClient, OidcClientWithAllowedGroups } from '$lib/types/oidc.type';
import { cachedOidcClientLogo } from '$lib/utils/cached-image-util';
import { mode } from 'mode-watcher';
@@ -17,7 +17,7 @@
const oidcClientService = new OidcService();
let tableRef: AdvancedTable<OidcClientWithAllowedUserGroupsCount>;
let tableRef: AdvancedTable<OidcClientWithAllowedGroups>;
export function refresh() {
return tableRef?.refresh();
@@ -25,16 +25,16 @@
const isLightMode = $derived(mode.current === 'light');
const columns: AdvancedTableColumn<OidcClientWithAllowedUserGroupsCount>[] = [
const columns: AdvancedTableColumn<OidcClientWithAllowedGroups>[] = [
{ label: 'ID', column: 'id', hidden: true },
{ label: m.logo(), key: 'logo', cell: LogoCell },
{ label: m.name(), column: 'name', sortable: true },
{
label: m.oidc_allowed_group_count(),
column: 'allowedUserGroupsCount',
column: 'allowedUserGroups',
sortable: true,
value: (item) => (item.isGroupRestricted ? item.allowedUserGroupsCount : '-')
value: (item) => (item.isGroupRestricted ? item.allowedUserGroups.length : '-')
},
{
label: m.restricted(),