mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-08 18:59:10 +02:00
feat: allow admins to auto grant APIs to CIMD clients (#1692)
This commit is contained in:
@@ -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}
|
||||
+88
-21
@@ -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">
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user