feat: allow admins to auto grant APIs to CIMD clients (#1692)

This commit is contained in:
Elias Schneider
2026-08-18 20:02:14 +02:00
committed by GitHub
parent 22354581df
commit 915a6bcf08
38 changed files with 2605 additions and 393 deletions
@@ -0,0 +1,21 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages';
let {
hasAccess,
granted,
total
}: {
hasAccess: boolean;
granted: number;
total: number;
} = $props();
</script>
{#if !hasAccess}
<span class="text-muted-foreground text-sm">{m.no_access()}</span>
{:else}
<span class="text-sm">
{m.permissions_granted_count({ granted: String(granted), total: String(total) })}
</span>
{/if}
@@ -1,38 +1,47 @@
<script lang="ts">
import SwitchWithLabel from '$lib/components/form/switch-with-label.svelte';
import AdvancedTable from '$lib/components/table/advanced-table.svelte';
import { Button } from '$lib/components/ui/button';
import Checkbox from '$lib/components/ui/checkbox/checkbox.svelte';
import * as Dialog from '$lib/components/ui/dialog';
import { m } from '$lib/paraglide/messages';
import type { AdvancedTableColumn } from '$lib/types/advanced-table.type';
import type { Api, ApiPermission } from '$lib/types/api.type';
import type { Api, ApiClientGrant, ApiPermission } from '$lib/types/api.type';
import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type';
import { axiosErrorToast } from '$lib/utils/error-util';
let {
open = $bindable(),
api,
userAllowedIds,
clientAllowedIds,
grant,
implicitUserAccess = false,
implicitUserIds = [],
showClientAccess,
title,
onSave
}: {
open: boolean;
api: Api;
userAllowedIds: string[];
clientAllowedIds: string[];
grant: ApiClientGrant;
implicitUserAccess?: boolean;
implicitUserIds?: string[];
showClientAccess: boolean;
onSave: (userPermissionIds: string[], clientPermissionIds: string[]) => Promise<void>;
title?: string;
onSave: (grant: ApiClientGrant) => Promise<void>;
} = $props();
let workingUserAccess = $state(false);
let workingClientAccess = $state(false);
let workingUser = $state<string[]>([]);
let workingClient = $state<string[]>([]);
let saving = $state(false);
$effect(() => {
if (open) {
workingUser = [...userAllowedIds];
workingClient = [...clientAllowedIds];
workingUserAccess = grant.userDelegatedAccess;
workingClientAccess = grant.clientAccess;
workingUser = [...grant.userDelegatedPermissionIds];
workingClient = [...grant.clientPermissionIds];
}
});
@@ -53,6 +62,21 @@
return ids.filter((existing) => existing !== id);
}
// A permission only makes sense together with access to the API, so checking one turns the access on and turning access off drops the selection
function toggleUserPermission(id: string, checked: boolean) {
workingUser = toggle(workingUser, id, checked);
if (checked) {
workingUserAccess = true;
}
}
function toggleClientPermission(id: string, checked: boolean) {
workingClient = toggle(workingClient, id, checked);
if (checked) {
workingClientAccess = true;
}
}
function fetchCallback(options: ListRequestOptions): Promise<Paginated<ApiPermission>> {
let data = api.permissions;
@@ -95,7 +119,12 @@
async function save() {
saving = true;
try {
await onSave(workingUser, workingClient);
await onSave({
userDelegatedAccess: workingUserAccess,
clientAccess: showClientAccess && workingClientAccess,
userDelegatedPermissionIds: workingUserAccess ? workingUser : [],
clientPermissionIds: showClientAccess && workingClientAccess ? workingClient : []
});
open = false;
} catch (e) {
axiosErrorToast(e);
@@ -110,10 +139,13 @@
{/snippet}
{#snippet UserDelegatedCell({ item }: { item: ApiPermission })}
{@const implicit = implicitUserIds.includes(item.id)}
<Checkbox
aria-label={`${m.user_delegated_access()}: ${item.name}`}
checked={workingUser.includes(item.id)}
onCheckedChange={(checked: boolean) => (workingUser = toggle(workingUser, item.id, checked))}
checked={implicit || workingUser.includes(item.id)}
disabled={implicit}
title={implicit ? m.granted_through_cimd_access() : undefined}
onCheckedChange={(checked: boolean) => toggleUserPermission(item.id, checked)}
/>
{/snippet}
@@ -121,29 +153,64 @@
<Checkbox
aria-label={`${m.client_access()}: ${item.name}`}
checked={workingClient.includes(item.id)}
onCheckedChange={(checked: boolean) =>
(workingClient = toggle(workingClient, item.id, checked))}
onCheckedChange={(checked: boolean) => toggleClientPermission(item.id, checked)}
/>
{/snippet}
<Dialog.Root bind:open>
<Dialog.Content class="max-h-[90vh] min-w-[90vw] overflow-auto lg:min-w-250">
<Dialog.Header>
<Dialog.Title>{api.name}</Dialog.Title>
<Dialog.Title>{title ?? api.name}</Dialog.Title>
<Dialog.Description>
{m.select_the_permissions_this_client_may_request()}
{m.select_the_access_this_client_may_request()}
{#if !showClientAccess}
{m.client_access_unavailable_for_public_clients()}
{/if}
{#if implicitUserAccess}
{m.access_granted_through_cimd_access()}
{/if}
</Dialog.Description>
</Dialog.Header>
<AdvancedTable
id={`api-access-grants-${api.id}`}
{columns}
{fetchCallback}
defaultSort={{ column: 'name', direction: 'asc' }}
/>
<div class="flex flex-col gap-4 sm:flex-row sm:gap-10">
<SwitchWithLabel
id={`api-user-access-${api.id}`}
label={m.user_delegated_access()}
description={m.user_delegated_access_description()}
checked={implicitUserAccess || workingUserAccess}
disabled={implicitUserAccess}
onCheckedChange={(checked) => {
workingUserAccess = checked;
if (!checked) {
workingUser = [];
}
}}
/>
{#if showClientAccess}
<SwitchWithLabel
id={`api-client-access-${api.id}`}
label={m.client_access()}
description={m.client_access_description()}
bind:checked={workingClientAccess}
onCheckedChange={(checked) => {
if (!checked) {
workingClient = [];
}
}}
/>
{/if}
</div>
{#if api.permissions.length > 0}
<div class="overflow-auto">
<AdvancedTable
id={`api-access-grants-${api.id}`}
{columns}
{fetchCallback}
defaultSort={{ column: 'name', direction: 'asc' }}
/>
</div>
{/if}
<div class="mt-4 flex justify-end gap-2">
<Button variant="secondary" onclick={() => (open = false)}>{m.cancel()}</Button>
@@ -1,6 +1,7 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch/index.js';
import { cn } from '$lib/utils/style';
let {
id,
@@ -8,6 +9,7 @@
label,
description,
disabled = false,
class: className,
onCheckedChange
}: {
id: string;
@@ -15,11 +17,12 @@
label: string;
description?: string;
disabled?: boolean;
class?: string;
onCheckedChange?: (checked: boolean) => void;
} = $props();
</script>
<div class="items-top flex space-x-2">
<div class={cn('items-top flex space-x-2', className)}>
<Switch
{id}
{disabled}
@@ -0,0 +1,35 @@
<script lang="ts">
import ImageBox from '$lib/components/image-box.svelte';
import { m } from '$lib/paraglide/messages';
import { cachedOidcClientLogo } from '$lib/utils/cached-image-util';
import { cn } from '$lib/utils/style';
import { mode } from 'mode-watcher';
let {
id,
name,
hasLogo,
class: className = 'size-9'
}: {
id: string;
name: string;
hasLogo: boolean;
class?: string;
} = $props();
const isLightMode = $derived(mode.current === 'light');
</script>
{#if hasLogo}
<ImageBox
class={cn('rounded-lg', className)}
src={cachedOidcClientLogo.getUrl(id, isLightMode)}
alt={m.name_logo({ name })}
/>
{:else}
<div
class={cn('bg-muted flex shrink-0 items-center justify-center rounded-lg font-bold', className)}
>
{name.charAt(0).toUpperCase()}
</div>
{/if}
@@ -27,6 +27,7 @@
<DropdownMenu.Label>{m.toggle_columns()}</DropdownMenu.Label>
<DropdownMenu.Separator />
{#each columns as column (column)}
{#if column.label}
<DropdownMenu.CheckboxItem
closeOnSelect={false}
checked={selectedColumns.includes(column.column ?? column.key!)}
@@ -41,6 +42,7 @@
>
{column.label}
</DropdownMenu.CheckboxItem>
{/if}
{/each}
</DropdownMenu.Group>
</DropdownMenu.Content>
@@ -25,6 +25,7 @@
selectedIds = $bindable(),
withoutSearch = false,
selectionDisabled = false,
onRowClick,
rowSelectionDisabled,
fetchCallback,
defaultSort,
@@ -39,6 +40,7 @@
fetchCallback: (requestOptions: ListRequestOptions) => Promise<Paginated<T>>;
defaultSort?: SortRequest;
columns: AdvancedTableColumn<T>[];
onRowClick?: (item: T) => void;
actions?: CreateAdvancedTableActions<T>;
} = $props();
@@ -270,9 +272,9 @@
<Table.Row
class={{
'bg-muted/20': selectedIds?.includes(item.id),
'cursor-pointer': getPrimaryAction(item)
'cursor-pointer': getPrimaryAction(item) || onRowClick
}}
onclick={getPrimaryAction(item)}
onclick={onRowClick ? () => onRowClick(item) : getPrimaryAction(item)}
>
{#if selectedIds}
<Table.Cell class="w-12">
+37 -7
View File
@@ -1,9 +1,13 @@
import type {
Api,
ApiCimdAccessUpdate,
ApiClient,
ApiClientAccess,
ApiClientGrant,
ApiCreate,
ApiPermissionInput,
ApiUpdate,
ClientApiAccess
ClientApiGrant
} from '$lib/types/api.type';
import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type';
import { encodeClientIdParam } from '$lib/utils/client-id-util';
@@ -44,13 +48,39 @@ export default class ApisService extends APIService {
return res.data as Api;
};
getClientAccess = async (clientId: string) => {
const res = await this.api.get(`/api-access/${encodeClientIdParam(clientId)}`);
return res.data as ClientApiAccess;
updateCimdAccess = async (id: string, access: ApiCimdAccessUpdate) => {
const res = await this.api.put(`/apis/${id}/cimd-access`, access);
return res.data as Api;
};
updateClientAccess = async (clientId: string, access: ClientApiAccess) => {
const res = await this.api.put(`/api-access/${encodeClientIdParam(clientId)}`, access);
return res.data as ClientApiAccess;
listClients = async (id: string, options?: ListRequestOptions) => {
const res = await this.api.get(`/apis/${id}/clients`, { params: options });
return res.data as Paginated<ApiClientAccess>;
};
listAssignableClients = async (id: string, options?: ListRequestOptions) => {
const res = await this.api.get(`/apis/${id}/assignable-clients`, { params: options });
return res.data as Paginated<ApiClient>;
};
updateClientAccessForApi = async (id: string, clientId: string, grant: ApiClientGrant) => {
const res = await this.api.put(`/apis/${id}/clients/${encodeClientIdParam(clientId)}`, grant);
return res.data as ApiClientGrant;
};
removeClientAccessForApi = async (id: string, clientId: string) => {
await this.api.delete(`/apis/${id}/clients/${encodeClientIdParam(clientId)}`);
};
listClientApis = async (clientId: string) => {
const res = await this.api.get(`/api-access/${encodeClientIdParam(clientId)}/apis`);
return res.data as ClientApiGrant[];
};
listAssignableApis = async (clientId: string, options?: ListRequestOptions) => {
const res = await this.api.get(`/api-access/${encodeClientIdParam(clientId)}/assignable-apis`, {
params: options
});
return res.data as Paginated<Api>;
};
}
+31 -1
View File
@@ -3,6 +3,7 @@ export type ApiPermission = {
key: string;
name: string;
description?: string;
allowedForCimdClients: boolean;
};
export type Api = {
@@ -11,6 +12,7 @@ export type Api = {
resource: string;
createdAt: string;
permissions: ApiPermission[];
allowCimdClients: boolean;
};
export type ApiCreate = {
@@ -28,7 +30,35 @@ export type ApiPermissionInput = {
description: string;
};
export type ClientApiAccess = {
export type ApiClientGrant = {
userDelegatedAccess: boolean;
clientAccess: boolean;
userDelegatedPermissionIds: string[];
clientPermissionIds: string[];
};
export type ApiCimdAccessUpdate = {
enabled: boolean;
permissionIds: string[];
};
export type ApiClient = {
id: string;
name: string;
clientType: string;
isPublic: boolean;
hasLogo: boolean;
hasDarkLogo: boolean;
};
export type ApiClientAccess = ApiClientGrant & {
client: ApiClient;
cimdGrantedAccess: boolean;
cimdGrantedPermissionIds: string[];
};
export type ClientApiGrant = ApiClientGrant & {
api: Api;
cimdGrantedAccess: boolean;
cimdGrantedPermissionIds: string[];
};
@@ -4,27 +4,38 @@
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import ApisService from '$lib/services/apis-service';
import type { ApiCreate, ApiPermissionInput } from '$lib/types/api.type';
import type { ApiCimdAccessUpdate, ApiCreate, ApiPermissionInput } from '$lib/types/api.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideChevronLeft } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import { backNavigate } from '../../users/navigate-back-util';
import ApiForm from '../api-form.svelte';
import ApiAccessCard from './api-access-card.svelte';
import ApiPermissionsInput from './api-permissions-input.svelte';
let { data } = $props();
let api = $state(data.api);
let permissions = $state<ApiPermissionInput[]>(
data.api.permissions.map((p) => ({
let permissions = $state<ApiPermissionInput[]>(toPermissionInputs(data.api.permissions));
function toPermissionInputs(apiPermissions: typeof data.api.permissions): ApiPermissionInput[] {
const inputs = apiPermissions.map((p) => ({
key: p.key,
name: p.name,
description: p.description ?? ''
}))
);
}));
// Show an empty row so the user doesn't have to add one first
return inputs.length > 0 ? inputs : [{ key: '', name: '', description: '' }];
}
function isEmptyPermission(p: ApiPermissionInput) {
return !p.key.trim() && !p.name.trim() && !p.description.trim();
}
const apisService = new ApisService();
const backNavigation = backNavigate('/settings/admin/apis');
let accessCard = $state<ApiAccessCard>();
async function updateApi(updated: ApiCreate) {
let success = true;
await apisService
@@ -42,16 +53,31 @@
async function updatePermissions() {
await apisService
.updatePermissions(api.id, permissions)
.updatePermissions(
api.id,
permissions.filter((p) => !isEmptyPermission(p))
)
.then((res) => {
permissions = res.permissions.map((p) => ({
key: p.key,
name: p.name,
description: p.description ?? ''
}));
api = res;
permissions = toPermissionInputs(res.permissions);
toast.success(m.api_permissions_updated_successfully());
})
.catch(axiosErrorToast);
// A removed permission takes the client grants that referenced it with it
await accessCard?.refresh();
}
async function updateCimdAccess(update: ApiCimdAccessUpdate) {
await apisService
.updateCimdAccess(api.id, update)
.then((res) => {
api = res;
toast.success(m.api_access_updated_successfully());
})
.catch(axiosErrorToast);
await accessCard?.refresh();
}
</script>
@@ -86,3 +112,5 @@
<Button usePromiseLoading onclick={updatePermissions}>{m.save()}</Button>
</div>
</CollapsibleCard>
<ApiAccessCard bind:this={accessCard} {api} onCimdAccessSave={updateCimdAccess} />
@@ -0,0 +1,52 @@
<script lang="ts">
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 type { Api, ApiCimdAccessUpdate } from '$lib/types/api.type';
import ApiCimdAccessTab from './api-cimd-access-tab.svelte';
import ApiClientsTab from './api-clients-tab.svelte';
let {
api,
onCimdAccessSave
}: { api: Api; onCimdAccessSave: (update: ApiCimdAccessUpdate) => Promise<void> } = $props();
let clientsTab = $state<ApiClientsTab>();
let tab = $state('clients');
export async function refresh() {
await clientsTab?.refresh();
}
</script>
<Card.Root>
<Card.Header>
<Card.Title>{m.access()}</Card.Title>
<Card.Description>{m.api_access_card_description()}</Card.Description>
</Card.Header>
<Card.Content>
<Tabs.Root bind:value={tab} class="gap-4">
<div class="flex flex-col items-start justify-between gap-3 sm:flex-row sm:items-center">
<Tabs.List>
<Tabs.Trigger value="clients">{m.oidc_clients()}</Tabs.Trigger>
<Tabs.Trigger value="metadata-document-clients">
{m.metadata_document_client_access()}
</Tabs.Trigger>
</Tabs.List>
{#if tab === 'clients'}
<Button variant="outline" onclick={() => clientsTab?.openPicker()}>
{m.add_client()}
</Button>
{/if}
</div>
<Tabs.Content value="clients">
<ApiClientsTab bind:this={clientsTab} {api} />
</Tabs.Content>
<Tabs.Content value="metadata-document-clients">
<ApiCimdAccessTab {api} onSave={onCimdAccessSave} />
</Tabs.Content>
</Tabs.Root>
</Card.Content>
</Card.Root>
@@ -0,0 +1,78 @@
<script lang="ts">
import FormInput from '$lib/components/form/form-input.svelte';
import SwitchWithLabel from '$lib/components/form/switch-with-label.svelte';
import { Button } from '$lib/components/ui/button';
import Checkbox from '$lib/components/ui/checkbox/checkbox.svelte';
import { Label } from '$lib/components/ui/label';
import { m } from '$lib/paraglide/messages';
import type { Api, ApiCimdAccessUpdate } from '$lib/types/api.type';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { z } from 'zod/v4';
let { api, onSave }: { api: Api; onSave: (update: ApiCimdAccessUpdate) => Promise<void> } =
$props();
const formSchema = z.object({ enabled: z.boolean(), permissionIds: z.array(z.string()) });
const { inputs, ...form } = createForm(formSchema, {
enabled: api.allowCimdClients,
permissionIds: api.permissions.filter((p) => p.allowedForCimdClients).map((p) => p.id)
});
async function save() {
const data = form.validate();
if (data) await onSave(data);
}
</script>
<form novalidate onsubmit={preventDefault(save)}>
<FormInput bind:input={$inputs.enabled} class="my-5">
<SwitchWithLabel
id="allow-cimd-clients"
label={m.allow_all_metadata_document_clients()}
description={m.allow_all_metadata_document_clients_description()}
bind:checked={$inputs.enabled.value}
/>
</FormInput>
{#if $inputs.enabled.value}
<div class="mt-6">
{#if api.permissions.length > 0}
<FormInput bind:input={$inputs.permissionIds}>
<Label class="mb-3">{m.granted_permissions()}</Label>
<div class="flex flex-col gap-3">
{#each api.permissions as permission (permission.id)}
<div class="flex items-start gap-2">
<Checkbox
id={`cimd-permission-${permission.id}`}
class="mt-0.5"
checked={$inputs.permissionIds.value.includes(permission.id)}
onCheckedChange={(checked: boolean) =>
form.setValue(
'permissionIds',
checked
? [...$inputs.permissionIds.value, permission.id]
: $inputs.permissionIds.value.filter((id) => id !== permission.id)
)}
/>
<div class="grid gap-1 leading-none">
<Label
for={`cimd-permission-${permission.id}`}
class="mb-0 text-sm leading-none font-medium"
>
{permission.name}
</Label>
<p class="text-muted-foreground font-mono text-xs">{permission.key}</p>
</div>
</div>
{/each}
</div>
</FormInput>
{/if}
</div>
{/if}
<div class="mt-5 flex justify-end">
<Button type="submit" usePromiseLoading>{m.save()}</Button>
</div>
</form>
@@ -0,0 +1,171 @@
<script lang="ts">
import ApiAccessCell from '$lib/components/api-access-cell.svelte';
import ApiPermissionsModal from '$lib/components/api-permissions-modal.svelte';
import { openConfirmDialog } from '$lib/components/confirm-dialog';
import OidcClientAvatar from '$lib/components/oidc-client-avatar.svelte';
import AdvancedTable from '$lib/components/table/advanced-table.svelte';
import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import ApisService from '$lib/services/apis-service';
import type { AdvancedTableColumn } from '$lib/types/advanced-table.type';
import type { Api, ApiClient, ApiClientAccess, ApiClientGrant } from '$lib/types/api.type';
import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type';
import { encodeClientIdParam } from '$lib/utils/client-id-util';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucidePencil, LucideTrash } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import ClientSelectionModal from './client-selection-modal.svelte';
let { api }: { api: Api } = $props();
const apisService = new ApisService();
type ClientRow = ApiClientAccess & { id: string };
let tableRef: AdvancedTable<ClientRow>;
let editing = $state<ApiClientAccess | null>(null);
let modalOpen = $state(false);
let pickerOpen = $state(false);
const columns: AdvancedTableColumn<ClientRow>[] = [
{ label: m.client(), key: 'client', cell: ClientCell },
{ label: m.user_delegated_access(), key: 'user-access', cell: UserAccessCell },
{ label: m.client_access(), key: 'client-access', cell: ClientAccessCell },
{ label: '', key: 'actions', cell: ActionsCell }
];
async function fetchCallback(options: ListRequestOptions): Promise<Paginated<ClientRow>> {
const res = await apisService.listClients(api.id, options);
return { ...res, data: res.data.map((entry) => ({ ...entry, id: entry.client.id })) };
}
export async function refresh() {
await tableRef?.refresh();
}
export function openPicker() {
pickerOpen = true;
}
function openEdit(entry: ApiClientAccess) {
editing = entry;
modalOpen = true;
}
function addClient(client: ApiClient) {
editing = {
client,
userDelegatedAccess: true,
clientAccess: false,
userDelegatedPermissionIds: [],
clientPermissionIds: [],
cimdGrantedAccess: false,
cimdGrantedPermissionIds: []
};
pickerOpen = false;
modalOpen = true;
}
async function save(entry: ApiClientAccess, grant: ApiClientGrant) {
await apisService.updateClientAccessForApi(api.id, entry.client.id, {
...grant,
clientAccess: entry.client.isPublic ? false : grant.clientAccess,
clientPermissionIds: entry.client.isPublic ? [] : grant.clientPermissionIds
});
await tableRef?.refresh();
toast.success(m.api_access_updated_successfully());
}
function userGrantedCount(entry: ApiClientAccess) {
return new Set([...entry.userDelegatedPermissionIds, ...entry.cimdGrantedPermissionIds]).size;
}
function removeClient(entry: ApiClientAccess) {
openConfirmDialog({
title: m.revoke_access_for_name({ name: entry.client.name }),
message: m.are_you_sure_you_want_to_revoke_the_api_access_of_this_client(),
confirm: {
label: m.revoke(),
destructive: true,
action: async () => {
try {
await apisService.removeClientAccessForApi(api.id, entry.client.id);
await tableRef?.refresh();
toast.success(m.api_access_updated_successfully());
} catch (e) {
axiosErrorToast(e);
}
}
}
});
}
</script>
{#snippet ClientCell({ item }: { item: ClientRow })}
<div class="flex items-center gap-3">
<OidcClientAvatar id={item.client.id} name={item.client.name} hasLogo={item.client.hasLogo} />
<div class="flex flex-col gap-0.5">
<a
class="font-medium hover:underline"
href={`/settings/admin/oidc-clients/${encodeClientIdParam(item.client.id)}`}
>
{item.client.name}
</a>
{#if item.client.clientType === 'cimd'}
<span class="text-muted-foreground text-xs">{m.client_type_metadata_document()}</span>
{/if}
</div>
</div>
{/snippet}
{#snippet UserAccessCell({ item }: { item: ClientRow })}
<ApiAccessCell
hasAccess={item.userDelegatedAccess || item.cimdGrantedAccess}
granted={userGrantedCount(item)}
total={api.permissions.length}
/>
{/snippet}
{#snippet ClientAccessCell({ item }: { item: ClientRow })}
<ApiAccessCell
hasAccess={item.clientAccess}
granted={item.clientPermissionIds.length}
total={api.permissions.length}
/>
{/snippet}
{#snippet ActionsCell({ item }: { item: ClientRow })}
<div class="flex justify-end gap-1">
<Button variant="ghost" size="sm" aria-label={m.edit()} onclick={() => openEdit(item)}>
<LucidePencil class="size-4" />
</Button>
<Button
variant="ghost"
size="sm"
aria-label={m.revoke()}
disabled={!item.userDelegatedAccess && !item.clientAccess}
onclick={() => removeClient(item)}
>
<LucideTrash class="size-4" />
</Button>
</div>
{/snippet}
<AdvancedTable id={`api-clients-${api.id}`} bind:this={tableRef} {columns} {fetchCallback} />
<ClientSelectionModal bind:open={pickerOpen} apiId={api.id} onSelect={addClient} />
{#if editing}
<ApiPermissionsModal
bind:open={modalOpen}
{api}
grant={editing}
implicitUserAccess={editing.cimdGrantedAccess}
implicitUserIds={editing.cimdGrantedPermissionIds}
showClientAccess={!editing.client.isPublic}
title={editing.client.name}
onSave={(grant) => save(editing!, grant)}
/>
{/if}
@@ -0,0 +1,59 @@
<script lang="ts">
import OidcClientAvatar from '$lib/components/oidc-client-avatar.svelte';
import AdvancedTable from '$lib/components/table/advanced-table.svelte';
import * as Dialog from '$lib/components/ui/dialog';
import { m } from '$lib/paraglide/messages';
import ApisService from '$lib/services/apis-service';
import type { AdvancedTableColumn } from '$lib/types/advanced-table.type';
import type { ApiClient } from '$lib/types/api.type';
import type { ListRequestOptions } from '$lib/types/list-request.type';
let {
open = $bindable(),
apiId,
onSelect
}: {
open: boolean;
apiId: string;
onSelect: (client: ApiClient) => void;
} = $props();
const apisService = new ApisService();
const columns: AdvancedTableColumn<ApiClient>[] = [
{ label: m.logo(), key: 'logo', cell: LogoCell },
{ label: m.name(), column: 'name', sortable: true },
{
label: m.client_type(),
column: 'clientType',
sortable: true,
value: (item) =>
item.clientType === 'cimd' ? m.client_type_metadata_document() : m.client_type_standard()
}
];
function fetchCallback(options: ListRequestOptions) {
return apisService.listAssignableClients(apiId, options);
}
</script>
{#snippet LogoCell({ item }: { item: ApiClient })}
<OidcClientAvatar id={item.id} name={item.name} hasLogo={item.hasLogo} />
{/snippet}
<Dialog.Root bind:open>
<Dialog.Content class="max-h-[90vh] min-w-[90vw] overflow-auto lg:min-w-250">
<Dialog.Header>
<Dialog.Title>{m.add_client()}</Dialog.Title>
<Dialog.Description>{m.select_a_client_to_grant_access_to_this_api()}</Dialog.Description>
</Dialog.Header>
<AdvancedTable
id="api-client-selection"
onRowClick={(item) => onSelect(item)}
{fetchCallback}
defaultSort={{ column: 'name', direction: 'asc' }}
{columns}
/>
</Dialog.Content>
</Dialog.Root>
@@ -24,7 +24,12 @@
{ label: 'ID', column: 'id', hidden: true },
{ label: m.name(), column: 'name', sortable: true },
{ label: m.api_resource(), column: 'resource', sortable: true },
{ label: m.api_permissions(), key: 'permissions', value: (item) => item.permissions.length }
{ label: m.api_permissions(), key: 'permissions', value: (item) => item.permissions.length },
{
label: m.metadata_document_client_access(),
column: 'allowCimdClients',
value: (item) => (item.allowCimdClients ? m.enabled() : m.disabled())
}
];
const actions: CreateAdvancedTableActions<Api> = () => [
@@ -359,15 +359,7 @@
</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>
<ApiAccessCard clientId={client.id} isPublicClient={client.isPublic} />
</Tabs.Content>
<Tabs.Content value="scim" id="scim-provisioning">
@@ -1,147 +1,212 @@
<script lang="ts">
import { goto } from '$app/navigation';
import ApiAccessCell from '$lib/components/api-access-cell.svelte';
import ApiPermissionsModal from '$lib/components/api-permissions-modal.svelte';
import { openConfirmDialog } from '$lib/components/confirm-dialog';
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 { Spinner } from '$lib/components/ui/spinner';
import * as Table from '$lib/components/ui/table';
import Empty from '$lib/icons/empty.svelte';
import { m } from '$lib/paraglide/messages';
import ApisService from '$lib/services/apis-service';
import type { Api } from '$lib/types/api.type';
import type { Api, ApiClientGrant, ClientApiGrant } from '$lib/types/api.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucidePencil, LucideTrash } from '@lucide/svelte';
import { onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import ApiPermissionsModal from './api-permissions-modal.svelte';
import ApiSelectionModal from './api-selection-modal.svelte';
let { clientId, isPublicClient }: { clientId: string; isPublicClient: boolean } = $props();
const apisService = new ApisService();
let apis = $state<Api[]>([]);
let userSelected = $state<Set<string>>(new Set());
let clientSelected = $state<Set<string>>(new Set());
let grants = $state<ClientApiGrant[]>([]);
let loading = $state(true);
let editingApi = $state<Api | null>(null);
let editing = $state<ClientApiGrant | null>(null);
let modalOpen = $state(false);
let pickerOpen = $state(false);
onMount(async () => {
onMount(load);
async function load() {
try {
const [list, access] = await Promise.all([
apisService.listAll(),
apisService.getClientAccess(clientId)
]);
apis = list;
userSelected = new Set(access.userDelegatedPermissionIds);
clientSelected = new Set(access.clientPermissionIds);
grants = await apisService.listClientApis(clientId);
} catch (e) {
axiosErrorToast(e);
} finally {
loading = false;
}
});
function grantedCount(api: Api, selected: Set<string>) {
return api.permissions.filter((p) => selected.has(p.id)).length;
}
function openEdit(api: Api) {
editingApi = api;
function openEdit(grant: ClientApiGrant) {
editing = grant;
modalOpen = true;
}
function allowedIdsFor(api: Api, selected: Set<string>) {
return api.permissions.filter((p) => selected.has(p.id)).map((p) => p.id);
function addApi(api: Api) {
editing = {
api,
userDelegatedAccess: true,
clientAccess: false,
userDelegatedPermissionIds: [],
clientPermissionIds: [],
cimdGrantedAccess: false,
cimdGrantedPermissionIds: []
};
pickerOpen = false;
modalOpen = true;
}
async function saveApi(api: Api, userIds: string[], clientIds: string[]) {
// Grants of other APIs stay untouched, and for public clients the (never editable) client grants are sent back unchanged
const otherUser = [...userSelected].filter((id) => !api.permissions.some((p) => p.id === id));
const otherClient = [...clientSelected].filter(
(id) => !api.permissions.some((p) => p.id === id)
);
const res = await apisService.updateClientAccess(clientId, {
userDelegatedPermissionIds: [...otherUser, ...userIds],
clientPermissionIds: isPublicClient ? [...clientSelected] : [...otherClient, ...clientIds]
async function save(entry: ClientApiGrant, grant: ApiClientGrant) {
await apisService.updateClientAccessForApi(entry.api.id, clientId, {
...grant,
clientAccess: isPublicClient ? false : grant.clientAccess,
clientPermissionIds: isPublicClient ? [] : grant.clientPermissionIds
});
userSelected = new Set(res.userDelegatedPermissionIds);
clientSelected = new Set(res.clientPermissionIds);
await load();
toast.success(m.api_access_updated_successfully());
}
function removeApi(entry: ClientApiGrant) {
openConfirmDialog({
title: m.revoke_access_to_name({ name: entry.api.name }),
message: m.are_you_sure_you_want_to_revoke_the_access_of_this_client_to_the_api(),
confirm: {
label: m.revoke(),
destructive: true,
action: async () => {
try {
await apisService.removeClientAccessForApi(entry.api.id, clientId);
await load();
toast.success(m.api_access_updated_successfully());
} catch (e) {
axiosErrorToast(e);
}
}
}
});
}
function userGrantedCount(entry: ClientApiGrant) {
const granted = new Set([
...entry.userDelegatedPermissionIds,
...entry.cimdGrantedPermissionIds
]);
return granted.size;
}
</script>
{#if loading}
<div class="flex justify-center py-6">
<Spinner class="size-6" />
</div>
{:else if apis.length === 0}
<div class="flex flex-col items-center justify-center gap-2 py-6">
<p class="text-muted-foreground text-sm">{m.no_apis_defined_yet()}</p>
<Button variant="outline" size="sm" onclick={() => goto('/settings/admin/apis')}>
{m.create_api()}
</Button>
</div>
{:else}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>{m.api_name()}</Table.Head>
<Table.Head>{m.user_delegated_access()}</Table.Head>
{#if !isPublicClient}
<Table.Head>{m.client_access()}</Table.Head>
{/if}
<Table.Head class="w-20"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each apis as api (api.id)}
<Table.Row>
<Table.Cell>
<div class="flex flex-col gap-1">
<span class="font-medium">{api.name}</span>
<div>
<CopyToClipboard value={api.resource}>
<span class="text-muted-foreground font-mono text-xs break-all"
>{api.resource}</span
<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.api_access()}</Card.Title>
<Card.Description>{m.api_access_description()}</Card.Description>
</div>
<Button onclick={() => (pickerOpen = true)}>{m.add_api()}</Button>
</div>
</Card.Header>
<Card.Content>
{#if loading}
<div class="flex justify-center py-6">
<Spinner class="size-6" />
</div>
{:else if grants.length === 0}
<div class="my-5 flex flex-col items-center">
<Empty class="text-muted-foreground h-20" />
<p class="text-muted-foreground mt-3 text-sm">{m.this_client_has_no_api_access_yet()}</p>
</div>
{:else}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>{m.api_name()}</Table.Head>
<Table.Head>{m.user_delegated_access()}</Table.Head>
{#if !isPublicClient}
<Table.Head>{m.client_access()}</Table.Head>
{/if}
<Table.Head class="w-20"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each grants as entry (entry.api.id)}
<Table.Row>
<Table.Cell>
<div class="flex flex-col gap-1">
<span class="font-medium">{entry.api.name}</span>
<div>
<CopyToClipboard value={entry.api.resource}>
<span class="text-muted-foreground font-mono text-xs break-all"
>{entry.api.resource}</span
>
</CopyToClipboard>
</div>
{#if entry.cimdGrantedAccess}
<span class="text-muted-foreground text-xs"
>{m.granted_to_all_cimd_clients()}</span
>
{/if}
</div>
</Table.Cell>
<Table.Cell>
<ApiAccessCell
hasAccess={entry.userDelegatedAccess || entry.cimdGrantedAccess}
granted={userGrantedCount(entry)}
total={entry.api.permissions.length}
/>
</Table.Cell>
{#if !isPublicClient}
<Table.Cell>
<ApiAccessCell
hasAccess={entry.clientAccess}
granted={entry.clientPermissionIds.length}
total={entry.api.permissions.length}
/>
</Table.Cell>
{/if}
<Table.Cell class="text-right">
<div class="flex justify-end gap-1">
<Button
variant="ghost"
size="sm"
aria-label={m.edit()}
onclick={() => openEdit(entry)}
>
</CopyToClipboard>
</div>
</div>
</Table.Cell>
<Table.Cell class="text-muted-foreground text-sm">
{m.permissions_granted_count({
granted: String(grantedCount(api, userSelected)),
total: String(api.permissions.length)
})}
</Table.Cell>
{#if !isPublicClient}
<Table.Cell class="text-muted-foreground text-sm">
{m.permissions_granted_count({
granted: String(grantedCount(api, clientSelected)),
total: String(api.permissions.length)
})}
</Table.Cell>
{/if}
<Table.Cell class="text-right">
<Button
variant="outline"
size="sm"
disabled={api.permissions.length === 0}
onclick={() => openEdit(api)}>{m.edit()}</Button
>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
<LucidePencil class="size-4" />
</Button>
<Button
variant="ghost"
size="sm"
aria-label={m.revoke()}
disabled={!entry.userDelegatedAccess && !entry.clientAccess}
onclick={() => removeApi(entry)}
>
<LucideTrash class="size-4" />
</Button>
</div>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
</Card.Content>
</Card.Root>
{#if editingApi}
<ApiSelectionModal bind:open={pickerOpen} {clientId} onSelect={addApi} />
{#if editing}
<ApiPermissionsModal
bind:open={modalOpen}
api={editingApi}
userAllowedIds={allowedIdsFor(editingApi, userSelected)}
clientAllowedIds={allowedIdsFor(editingApi, clientSelected)}
api={editing.api}
grant={editing}
implicitUserAccess={editing.cimdGrantedAccess}
implicitUserIds={editing.cimdGrantedPermissionIds}
showClientAccess={!isPublicClient}
onSave={(userIds, clientIds) => saveApi(editingApi!, userIds, clientIds)}
onSave={(grant) => save(editing!, grant)}
/>
{/if}
@@ -0,0 +1,48 @@
<script lang="ts">
import AdvancedTable from '$lib/components/table/advanced-table.svelte';
import * as Dialog from '$lib/components/ui/dialog';
import { m } from '$lib/paraglide/messages';
import ApisService from '$lib/services/apis-service';
import type { AdvancedTableColumn } from '$lib/types/advanced-table.type';
import type { Api } from '$lib/types/api.type';
import type { ListRequestOptions } from '$lib/types/list-request.type';
let {
open = $bindable(),
clientId,
onSelect
}: {
open: boolean;
clientId: string;
onSelect: (api: Api) => void;
} = $props();
const apisService = new ApisService();
const columns: AdvancedTableColumn<Api>[] = [
{ label: m.name(), column: 'name', sortable: true },
{ label: m.api_resource(), column: 'resource', sortable: true },
{ label: m.api_permissions(), key: 'permissions', value: (item) => item.permissions.length }
];
function fetchCallback(options: ListRequestOptions) {
return apisService.listAssignableApis(clientId, options);
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-h-[90vh] min-w-[90vw] overflow-auto lg:min-w-250">
<Dialog.Header>
<Dialog.Title>{m.add_api()}</Dialog.Title>
<Dialog.Description>{m.select_an_api_this_client_should_access()}</Dialog.Description>
</Dialog.Header>
<AdvancedTable
id="client-api-selection"
{fetchCallback}
onRowClick={(item) => onSelect(item)}
defaultSort={{ column: 'name', direction: 'asc' }}
{columns}
/>
</Dialog.Content>
</Dialog.Root>