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[];