feat: add OAuth APIs with scoped permissions (#1542)

Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
This commit is contained in:
Elias Schneider
2026-07-06 12:25:02 -07:00
committed by GitHub
co-authored by Alessandro Segala
parent 0b2706a488
commit 09d196f7c5
68 changed files with 3945 additions and 157 deletions
@@ -32,6 +32,7 @@
children,
onInput,
labelFor,
readonly = false,
inputClass,
...restProps
}: HTMLAttributes<HTMLDivElement> &
@@ -43,6 +44,7 @@
disabled?: boolean;
inputClass?: string;
type?: 'text' | 'password' | 'email' | 'number' | 'checkbox' | 'date' | 'url';
readonly?: boolean;
onInput?: (e: FormInputEvent) => void;
} = $props();
@@ -64,7 +66,7 @@
<FormattedMessage m={description} />
{#if docsLink}
<a
class="relative text-black after:absolute after:bottom-0 after:left-0 after:h-px after:w-full after:translate-y-[-1px] after:bg-white dark:text-white"
class="relative text-black after:absolute after:bottom-0 after:left-0 after:h-px after:w-full after:-translate-y-px after:bg-white dark:text-white"
href={docsLink}
target="_blank"
>
@@ -91,6 +93,7 @@
bind:value={input.value}
{disabled}
oninput={(e) => onInput?.(e)}
{readonly}
/>
{/if}
{/if}
+16 -3
View File
@@ -1,13 +1,19 @@
<script lang="ts">
import * as Item from '$lib/components/ui/item/index.js';
import { m } from '$lib/paraglide/messages';
import { LucideMail, LucideUser, LucideUsers } from '@lucide/svelte';
import type { InteractionScopeInfo } from '$lib/types/oidc.type';
import { LucideKeyRound, LucideMail, LucideUser, LucideUsers } from '@lucide/svelte';
import ScopeItem from './scope-item.svelte';
let { scopes }: { scopes: string[] } = $props();
let { scopes, scopeInfo = [] }: { scopes: string[]; scopeInfo?: InteractionScopeInfo[] } =
$props();
const standardScopes = ['openid', 'profile', 'email', 'groups', 'offline_access'];
const infoByKey = $derived(new Map(scopeInfo.map((info) => [info.key, info])));
const customScopes = $derived(scopes.filter((scope) => !standardScopes.includes(scope)));
</script>
<Item.Group data-testid="scopes">
<Item.Group data-testid="scopes" class="gap-1">
{#if scopes.includes('email')}
<ScopeItem icon={LucideMail} name={m.email()} description={m.view_your_email_address()} />
{/if}
@@ -25,4 +31,11 @@
description={m.view_the_groups_you_are_a_member_of()}
/>
{/if}
{#each customScopes as scope}
<ScopeItem
icon={LucideKeyRound}
name={infoByKey.get(scope)?.name ?? scope}
description={infoByKey.get(scope)?.description || m.access_an_api_on_your_behalf()}
/>
{/each}
</Item.Group>
@@ -13,7 +13,7 @@
<p
bind:this={ref}
data-slot="card-description"
class={cn('text-muted-foreground text-sm', className)}
class={cn('text-muted-foreground text-sm mt-3', className)}
{...restProps}
>
{@render children?.()}
+56
View File
@@ -0,0 +1,56 @@
import type {
Api,
ApiCreate,
ApiListItem,
ApiPermissionInput,
ApiUpdate,
ClientApiAccess
} from '$lib/types/api.type';
import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type';
import APIService from './api-service';
export default class ApisService extends APIService {
list = async (options?: ListRequestOptions) => {
const res = await this.api.get('/apis', { params: options });
return res.data as Paginated<ApiListItem>;
};
listAll = async () => {
const res = await this.api.get('/apis', { params: { pagination: { page: 1, limit: 1000 } } });
return (res.data as Paginated<ApiListItem>).data;
};
get = async (id: string) => {
const res = await this.api.get(`/apis/${id}`);
return res.data as Api;
};
create = async (api: ApiCreate) => {
const res = await this.api.post('/apis', api);
return res.data as Api;
};
update = async (id: string, api: ApiUpdate) => {
const res = await this.api.put(`/apis/${id}`, api);
return res.data as Api;
};
remove = async (id: string) => {
await this.api.delete(`/apis/${id}`);
};
updatePermissions = async (id: string, permissions: ApiPermissionInput[]) => {
const res = await this.api.put(`/apis/${id}/permissions`, { permissions });
return res.data as Api;
};
getClientAccess = async (clientId: string) => {
const res = await this.api.get(`/api-access/${clientId}`);
return res.data as ClientApiAccess;
};
updateClientAccess = async (clientId: string, access: ClientApiAccess) => {
const res = await this.api.put(`/api-access/${clientId}`, access);
return res.data as ClientApiAccess;
};
}
+38
View File
@@ -0,0 +1,38 @@
export type ApiPermission = {
id: string;
key: string;
name: string;
description?: string;
};
export type Api = {
id: string;
name: string;
resource: string;
createdAt: string;
permissions: ApiPermission[];
};
export type ApiListItem = Omit<Api, 'permissions'> & {
permissionCount: number;
};
export type ApiCreate = {
name: string;
resource: string;
};
export type ApiUpdate = {
name: string;
};
export type ApiPermissionInput = {
key: string;
name: string;
description: string;
};
export type ClientApiAccess = {
userDelegatedPermissionIds: string[];
clientPermissionIds: string[];
};
+12 -1
View File
@@ -43,7 +43,10 @@ export type OidcClientWithAllowedUserGroupsCount = OidcClient & {
allowedUserGroupsCount: number;
};
export type OidcClientUpdate = Omit<OidcClient, 'id' | 'logoURL' | 'hasLogo' | 'hasDarkLogo' | 'pkceSupported'>;
export type OidcClientUpdate = Omit<
OidcClient,
'id' | 'logoURL' | 'hasLogo' | 'hasDarkLogo' | 'pkceSupported'
>;
export type OidcClientCreate = OidcClientUpdate & {
id?: string;
};
@@ -61,6 +64,7 @@ export type OidcClientCreateWithLogo = OidcClientCreate & {
export type OidcDeviceCodeInfo = {
scope: string[];
scopeInfo: InteractionScopeInfo[];
authorizationRequired: boolean;
reauthenticationRequired: boolean;
client: OidcClientMetaData;
@@ -72,9 +76,16 @@ export type AccessibleOidcClient = OidcClientMetaData & {
export type InteractionStep = 'authenticate' | 'select_account' | 'reauthenticate' | 'consent';
export type InteractionScopeInfo = {
key: string;
name: string;
description?: string;
};
export type InteractionSession = {
id: string;
scopes: string[];
scopeInfo: InteractionScopeInfo[];
client: OidcClientMetaData;
currentStep?: InteractionStep;
requiredSteps: InteractionStep[];
+3 -3
View File
@@ -121,8 +121,8 @@
</p>
{:else if authorizationRequired}
<div class="w-full max-w-[450px]" transition:slide={{ duration: 300 }}>
<Card.Root class="mt-6">
<Card.Header class="pb-5">
<Card.Root class="mt-6 gap-2">
<Card.Header>
<p class="text-muted-foreground text-start">
<FormattedMessage
m={m.client_wants_to_access_the_following_information({
@@ -132,7 +132,7 @@
</p>
</Card.Header>
<Card.Content data-testid="scopes">
<ScopeList scopes={deviceInfo!.scope} />
<ScopeList scopes={deviceInfo!.scope} scopeInfo={deviceInfo!.scopeInfo} />
</Card.Content>
</Card.Root>
</div>
+2 -2
View File
@@ -170,7 +170,7 @@
</div>
{:else if currentStep === 'consent'}
<div class="w-full max-w-md" transition:slide={{ duration: 300 }}>
<Card.Root class="mb-10">
<Card.Root class="mb-10 gap-3">
<Card.Header>
<p class="text-muted-foreground text-start">
<FormattedMessage
@@ -181,7 +181,7 @@
</p>
</Card.Header>
<Card.Content>
<ScopeList scopes={interactionSession.scopes} />
<ScopeList scopes={interactionSession.scopes} scopeInfo={interactionSession.scopeInfo} />
</Card.Content>
</Card.Root>
</div>
@@ -34,6 +34,7 @@
{ href: '/settings/admin/users', label: m.users() },
{ href: '/settings/admin/user-groups', label: m.user_groups() },
{ href: '/settings/admin/oidc-clients', label: m.oidc_clients() },
{ href: '/settings/admin/apis', label: m.apis() },
{ href: '/settings/admin/api-keys', label: m.api_keys() },
{ href: '/settings/admin/application-configuration', label: m.application_configuration() }
];
@@ -0,0 +1,83 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import ApisService from '$lib/services/apis-service';
import type { ApiCreate } from '$lib/types/api.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideMinus, LucidePlus, LucideServer } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition';
import ApiForm from './api-form.svelte';
import ApiList from './api-list.svelte';
let expandAddApi = $state(false);
const apisService = new ApisService();
async function createApi(api: ApiCreate) {
let success = true;
await apisService
.create(api)
.then((createdApi) => {
toast.success(m.api_created_successfully());
goto(`/settings/admin/apis/${createdApi.id}`);
})
.catch((e) => {
axiosErrorToast(e);
success = false;
});
return success;
}
</script>
<svelte:head>
<title>{m.apis()}</title>
</svelte:head>
<div>
<Card.Root>
<Card.Header>
<div class="flex flex-wrap items-center justify-between gap-4 md:flex-nowrap">
<div>
<Card.Title>
<LucidePlus class="text-primary/80 size-5" />
{m.create_api()}
</Card.Title>
<Card.Description>{m.create_a_new_api_description()}</Card.Description>
</div>
{#if !expandAddApi}
<Button class="w-full md:w-auto" onclick={() => (expandAddApi = true)}
>{m.add_api()}</Button
>
{:else}
<Button class="h-8 p-3" variant="ghost" onclick={() => (expandAddApi = false)}>
<LucideMinus class="size-5" />
</Button>
{/if}
</div>
</Card.Header>
{#if expandAddApi}
<div transition:slide>
<Card.Content>
<ApiForm callback={createApi} />
</Card.Content>
</div>
{/if}
</Card.Root>
</div>
<div>
<Card.Root>
<Card.Header>
<Card.Title>
<LucideServer class="text-primary/80 size-5" />
{m.manage_apis()}
</Card.Title>
</Card.Header>
<Card.Content>
<ApiList />
</Card.Content>
</Card.Root>
</div>
@@ -0,0 +1,88 @@
<script lang="ts">
import CollapsibleCard from '$lib/components/collapsible-card.svelte';
import { Button } from '$lib/components/ui/button';
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 { 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 ApiPermissionsInput from './api-permissions-input.svelte';
let { data } = $props();
let api = $state(data.api);
let permissions = $state<ApiPermissionInput[]>(
data.api.permissions.map((p) => ({
key: p.key,
name: p.name,
description: p.description ?? ''
}))
);
const apisService = new ApisService();
const backNavigation = backNavigate('/settings/admin/apis');
async function updateApi(updated: ApiCreate) {
let success = true;
await apisService
.update(api.id, { name: updated.name })
.then((res) => {
api = { ...api, ...res };
toast.success(m.api_updated_successfully());
})
.catch((e) => {
axiosErrorToast(e);
success = false;
});
return success;
}
async function updatePermissions() {
await apisService
.updatePermissions(api.id, permissions)
.then((res) => {
permissions = res.permissions.map((p) => ({
key: p.key,
name: p.name,
description: p.description ?? ''
}));
toast.success(m.api_permissions_updated_successfully());
})
.catch(axiosErrorToast);
}
</script>
<svelte:head>
<title>{api.name}</title>
</svelte:head>
<div>
<button type="button" class="text-muted-foreground flex text-sm" onclick={backNavigation.go}>
<LucideChevronLeft class="size-5" />
{m.back()}
</button>
</div>
<Card.Root>
<Card.Header>
<Card.Title>{m.general()}</Card.Title>
</Card.Header>
<Card.Content>
<ApiForm existingApi={api} callback={updateApi} />
</Card.Content>
</Card.Root>
<CollapsibleCard
id="api-permissions"
title={m.api_permissions()}
description={m.api_permissions_description()}
defaultExpanded={true}
>
<ApiPermissionsInput bind:permissions />
<div class="mt-5 flex justify-end">
<Button usePromiseLoading onclick={updatePermissions}>{m.save()}</Button>
</div>
</CollapsibleCard>
@@ -0,0 +1,7 @@
import ApisService from '$lib/services/apis-service';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ params }) => {
const api = await new ApisService().get(params.id);
return { api };
};
@@ -0,0 +1,44 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { m } from '$lib/paraglide/messages';
import type { ApiPermissionInput } from '$lib/types/api.type';
import { LucideMinus, LucidePlus } from '@lucide/svelte';
let { permissions = $bindable() }: { permissions: ApiPermissionInput[] } = $props();
const limit = 100;
</script>
<div class="flex flex-col gap-y-3">
{#each permissions as _, i}
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
<Input
class="font-mono sm:w-1/3"
placeholder={m.api_permission_key()}
bind:value={permissions[i].key}
/>
<Input class="sm:w-1/4" placeholder={m.name()} bind:value={permissions[i].name} />
<Input placeholder={m.description()} bind:value={permissions[i].description} />
<Button
variant="outline"
size="sm"
aria-label={m.delete()}
onclick={() => (permissions = permissions.filter((_, index) => index !== i))}
>
<LucideMinus class="size-4" />
</Button>
</div>
{/each}
</div>
{#if permissions.length < limit}
<Button
class="mt-3"
variant="secondary"
size="sm"
onclick={() => (permissions = [...permissions, { key: '', name: '', description: '' }])}
>
<LucidePlus class="mr-1 size-4" />
{permissions.length === 0 ? m.add_permission() : m.add_another()}
</Button>
{/if}
@@ -0,0 +1,65 @@
<script lang="ts">
import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import type { Api, ApiCreate } 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 {
callback,
existingApi
}: {
existingApi?: Api;
callback: (api: ApiCreate) => Promise<boolean>;
} = $props();
let isLoading = $state(false);
const isEdit = !!existingApi;
const api = {
name: existingApi?.name || '',
resource: existingApi?.resource || ''
};
const formSchema = z.object({
name: z.string().min(1).max(50),
resource: z
.url()
.min(1)
.max(350)
.refine((value) => !/[#\s]/.test(value), {
message: 'Resource must not include whitespace or a fragment'
})
});
type FormSchema = typeof formSchema;
const { inputs, ...form } = createForm<FormSchema>(formSchema, api);
async function onSubmit() {
const data = form.validate();
if (!data) return;
isLoading = true;
const success = await callback(data);
if (success && !existingApi) {
form.reset();
}
isLoading = false;
}
</script>
<form onsubmit={preventDefault(onSubmit)}>
<div class="flex flex-col gap-3">
<FormInput label={m.name()} bind:input={$inputs.name} />
<FormInput
label={m.api_resource()}
description={m.api_resource_description()}
bind:input={$inputs.resource}
readonly={isEdit}
/>
</div>
<div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">{m.save()}</Button>
</div>
</form>
@@ -0,0 +1,74 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { openConfirmDialog } from '$lib/components/confirm-dialog/';
import AdvancedTable from '$lib/components/table/advanced-table.svelte';
import { m } from '$lib/paraglide/messages';
import ApisService from '$lib/services/apis-service';
import type {
AdvancedTableColumn,
CreateAdvancedTableActions
} from '$lib/types/advanced-table.type';
import type { ApiListItem } from '$lib/types/api.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucidePencil, LucideTrash } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
const apisService = new ApisService();
let tableRef: AdvancedTable<ApiListItem>;
export function refresh() {
return tableRef?.refresh();
}
const columns: AdvancedTableColumn<ApiListItem>[] = [
{ 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: 'permissionCount', value: (item) => item.permissionCount }
];
const actions: CreateAdvancedTableActions<ApiListItem> = (api) => [
{
label: m.edit(),
primary: true,
icon: LucidePencil,
variant: 'ghost',
onClick: (api) => goto(`/settings/admin/apis/${api.id}`)
},
{
label: m.delete(),
icon: LucideTrash,
variant: 'danger',
onClick: (api) => deleteApi(api)
}
];
async function deleteApi(api: ApiListItem) {
openConfirmDialog({
title: m.delete_name({ name: api.name }),
message: m.are_you_sure_you_want_to_delete_this_api(),
confirm: {
label: m.delete(),
destructive: true,
action: async () => {
try {
await apisService.remove(api.id);
await refresh();
toast.success(m.api_deleted_successfully());
} catch (e) {
axiosErrorToast(e);
}
}
}
});
}
</script>
<AdvancedTable
id="api-list"
bind:this={tableRef}
fetchCallback={apisService.list}
defaultSort={{ column: 'name', direction: 'asc' }}
{columns}
{actions}
/>
@@ -23,6 +23,7 @@
import { backNavigate } from '../../users/navigate-back-util';
import OidcForm from '../oidc-client-form.svelte';
import OidcClientPreviewModal from '../oidc-client-preview-modal.svelte';
import ApiAccessCard from './api-access-card.svelte';
import ScimResourceProviderForm from './scim-resource-provider-form.svelte';
let { data } = $props();
@@ -315,6 +316,9 @@
>
</div>
</CollapsibleCard>
<CollapsibleCard id="api-access" title={m.api_access()} description={m.api_access_description()}>
<ApiAccessCard clientId={client.id} isPublicClient={client.isPublic} />
</CollapsibleCard>
<CollapsibleCard
id="scim-provisioning"
title={m.scim_provisioning()}
@@ -0,0 +1,147 @@
<script lang="ts">
import { goto } from '$app/navigation';
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
import { Button } from '$lib/components/ui/button';
import { Spinner } from '$lib/components/ui/spinner';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import ApisService from '$lib/services/apis-service';
import type { Api } from '$lib/types/api.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import ApiPermissionsModal from './api-permissions-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 loading = $state(true);
let editingApi = $state<Api | null>(null);
let modalOpen = $state(false);
onMount(async () => {
try {
const [list, access] = await Promise.all([
apisService.listAll(),
apisService.getClientAccess(clientId)
]);
apis = await Promise.all(list.map((a) => apisService.get(a.id)));
userSelected = new Set(access.userDelegatedPermissionIds);
clientSelected = new Set(access.clientPermissionIds);
} 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;
modalOpen = true;
}
function allowedIdsFor(api: Api, selected: Set<string>) {
return api.permissions.filter((p) => selected.has(p.id)).map((p) => p.id);
}
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]
});
userSelected = new Set(res.userDelegatedPermissionIds);
clientSelected = new Set(res.clientPermissionIds);
toast.success(m.api_access_updated_successfully());
}
</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}
<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
>
</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}
{#if editingApi}
<ApiPermissionsModal
bind:open={modalOpen}
api={editingApi}
userAllowedIds={allowedIdsFor(editingApi, userSelected)}
clientAllowedIds={allowedIdsFor(editingApi, clientSelected)}
showClientAccess={!isPublicClient}
onSave={(userIds, clientIds) => saveApi(editingApi!, userIds, clientIds)}
/>
{/if}
@@ -0,0 +1,153 @@
<script lang="ts">
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 { ListRequestOptions, Paginated } from '$lib/types/list-request.type';
import { axiosErrorToast } from '$lib/utils/error-util';
let {
open = $bindable(),
api,
userAllowedIds,
clientAllowedIds,
showClientAccess,
onSave
}: {
open: boolean;
api: Api;
userAllowedIds: string[];
clientAllowedIds: string[];
showClientAccess: boolean;
onSave: (userPermissionIds: string[], clientPermissionIds: string[]) => Promise<void>;
} = $props();
let workingUser = $state<string[]>([]);
let workingClient = $state<string[]>([]);
let saving = $state(false);
$effect(() => {
if (open) {
workingUser = [...userAllowedIds];
workingClient = [...clientAllowedIds];
}
});
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);
}
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(workingUser, 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 })}
<Checkbox
aria-label={`${m.user_delegated_access()}: ${item.name}`}
checked={workingUser.includes(item.id)}
onCheckedChange={(checked: boolean) => (workingUser = toggle(workingUser, 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) =>
(workingClient = toggle(workingClient, 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.Description>
{m.select_the_permissions_this_client_may_request()}
{#if !showClientAccess}
{m.client_access_unavailable_for_public_clients()}
{/if}
</Dialog.Description>
</Dialog.Header>
<AdvancedTable
id={`api-access-grants-${api.id}`}
{columns}
{fetchCallback}
defaultSort={{ column: 'name', direction: 'asc' }}
/>
<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>