mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-21 17:19:04 +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}
|
||||
@@ -0,0 +1,220 @@
|
||||
<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, 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,
|
||||
grant,
|
||||
implicitUserAccess = false,
|
||||
implicitUserIds = [],
|
||||
showClientAccess,
|
||||
title,
|
||||
onSave
|
||||
}: {
|
||||
open: boolean;
|
||||
api: Api;
|
||||
grant: ApiClientGrant;
|
||||
implicitUserAccess?: boolean;
|
||||
implicitUserIds?: string[];
|
||||
showClientAccess: boolean;
|
||||
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) {
|
||||
workingUserAccess = grant.userDelegatedAccess;
|
||||
workingClientAccess = grant.clientAccess;
|
||||
workingUser = [...grant.userDelegatedPermissionIds];
|
||||
workingClient = [...grant.clientPermissionIds];
|
||||
}
|
||||
});
|
||||
|
||||
const columns: AdvancedTableColumn<ApiPermission>[] = $derived([
|
||||
{ label: m.name(), column: 'name', sortable: true },
|
||||
{ label: m.key(), key: 'key', cell: KeyCell },
|
||||
{ label: m.description(), key: 'description', value: (p) => p.description ?? '' },
|
||||
{ label: m.user_delegated_access(), key: 'userDelegated', cell: UserDelegatedCell },
|
||||
...(showClientAccess
|
||||
? [{ label: m.client_access(), key: 'clientAccess', cell: ClientAccessCell }]
|
||||
: [])
|
||||
]);
|
||||
|
||||
function toggle(ids: string[], id: string, checked: boolean) {
|
||||
if (checked) {
|
||||
return ids.includes(id) ? ids : [...ids, id];
|
||||
}
|
||||
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;
|
||||
|
||||
const search = options.search?.toLowerCase();
|
||||
if (search) {
|
||||
data = data.filter(
|
||||
(p) =>
|
||||
p.key.toLowerCase().includes(search) ||
|
||||
p.name.toLowerCase().includes(search) ||
|
||||
(p.description ?? '').toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
const column = options.sort?.column;
|
||||
if (column) {
|
||||
const direction = options.sort?.direction === 'desc' ? -1 : 1;
|
||||
data = [...data].sort(
|
||||
(a, b) =>
|
||||
String((a as Record<string, unknown>)[column] ?? '').localeCompare(
|
||||
String((b as Record<string, unknown>)[column] ?? '')
|
||||
) * direction
|
||||
);
|
||||
}
|
||||
|
||||
const page = options.pagination?.page ?? 1;
|
||||
const limit = options.pagination?.limit ?? 20;
|
||||
const start = (page - 1) * limit;
|
||||
|
||||
return Promise.resolve({
|
||||
data: data.slice(start, start + limit),
|
||||
pagination: {
|
||||
totalPages: Math.max(1, Math.ceil(data.length / limit)),
|
||||
totalItems: data.length,
|
||||
currentPage: page,
|
||||
itemsPerPage: limit
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving = true;
|
||||
try {
|
||||
await onSave({
|
||||
userDelegatedAccess: workingUserAccess,
|
||||
clientAccess: showClientAccess && workingClientAccess,
|
||||
userDelegatedPermissionIds: workingUserAccess ? workingUser : [],
|
||||
clientPermissionIds: showClientAccess && workingClientAccess ? workingClient : []
|
||||
});
|
||||
open = false;
|
||||
} catch (e) {
|
||||
axiosErrorToast(e);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet KeyCell({ item }: { item: ApiPermission })}
|
||||
<span class="font-mono text-xs">{item.key}</span>
|
||||
{/snippet}
|
||||
|
||||
{#snippet UserDelegatedCell({ item }: { item: ApiPermission })}
|
||||
{@const implicit = implicitUserIds.includes(item.id)}
|
||||
<Checkbox
|
||||
aria-label={`${m.user_delegated_access()}: ${item.name}`}
|
||||
checked={implicit || workingUser.includes(item.id)}
|
||||
disabled={implicit}
|
||||
title={implicit ? m.granted_through_cimd_access() : undefined}
|
||||
onCheckedChange={(checked: boolean) => toggleUserPermission(item.id, checked)}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#snippet ClientAccessCell({ item }: { item: ApiPermission })}
|
||||
<Checkbox
|
||||
aria-label={`${m.client_access()}: ${item.name}`}
|
||||
checked={workingClient.includes(item.id)}
|
||||
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>{title ?? api.name}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{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>
|
||||
|
||||
<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>
|
||||
<Button isLoading={saving} onclick={save}>{m.save()}</Button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user