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
+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>