mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-09-29 14:29:04 +02:00
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:
co-authored by
Alessandro Segala
parent
0b2706a488
commit
09d196f7c5
@@ -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}
|
||||
|
||||
@@ -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?.()}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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[];
|
||||
};
|
||||
@@ -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[];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user