mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-02-04 11:36:46 +00:00
feat: restrict oidc clients by user groups per default (#1164)
This commit is contained in:
@@ -301,13 +301,17 @@
|
||||
"are_you_sure_you_want_to_create_a_new_client_secret": "Are you sure you want to create a new client secret? The old one will be invalidated.",
|
||||
"generate": "Generate",
|
||||
"new_client_secret_created_successfully": "New client secret created successfully",
|
||||
"allowed_user_groups_updated_successfully": "Allowed user groups updated successfully",
|
||||
"oidc_client_name": "OIDC Client {name}",
|
||||
"client_id": "Client ID",
|
||||
"client_secret": "Client secret",
|
||||
"show_more_details": "Show more details",
|
||||
"allowed_user_groups": "Allowed User Groups",
|
||||
"add_user_groups_to_this_client_to_restrict_access_to_users_in_these_groups": "Add user groups to this client to restrict access to users in these groups. If no user groups are selected, all users will have access to this client.",
|
||||
"allowed_user_groups_description": "Select the user groups whose members are allowed to sign in to this client.",
|
||||
"allowed_user_groups_status_unrestricted_description": "No user group restrictions are applied. Any user can sign in to this client.",
|
||||
"unrestrict": "Unrestrict",
|
||||
"restrict": "Restrict",
|
||||
"user_groups_restriction_updated_successfully": "User groups restriction updated successfully",
|
||||
"allowed_user_groups_updated_successfully": "Allowed user groups updated successfully",
|
||||
"favicon": "Favicon",
|
||||
"light_mode_logo": "Light Mode Logo",
|
||||
"dark_mode_logo": "Dark Mode Logo",
|
||||
@@ -471,5 +475,10 @@
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"system": "System",
|
||||
"signup_token_user_groups_description": "Automatically assign these groups to users who sign up using this token."
|
||||
"signup_token_user_groups_description": "Automatically assign these groups to users who sign up using this token.",
|
||||
"allowed_oidc_clients": "Allowed OIDC Clients",
|
||||
"allowed_oidc_clients_description": "Select the OIDC clients that members of this user group are allowed to sign in to.",
|
||||
"unrestrict_oidc_client": "Unrestrict {clientName}",
|
||||
"confirm_unrestrict_oidc_client_description": "Are you sure you want to unrestrict the OIDC client <b>{clientName}</b>? This will remove all group assignments for this client and any user will be able to sign in.",
|
||||
"allowed_oidc_clients_updated_successfully": "Allowed OIDC clients updated successfully"
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
title,
|
||||
description,
|
||||
defaultExpanded = false,
|
||||
forcedExpanded,
|
||||
button,
|
||||
icon,
|
||||
children
|
||||
}: {
|
||||
@@ -19,7 +21,9 @@
|
||||
title: string;
|
||||
description?: string;
|
||||
defaultExpanded?: boolean;
|
||||
forcedExpanded?: boolean;
|
||||
icon?: typeof IconType;
|
||||
button?: Snippet;
|
||||
children: Snippet;
|
||||
} = $props();
|
||||
|
||||
@@ -47,6 +51,12 @@
|
||||
}
|
||||
loadExpandedState();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (forcedExpanded !== undefined) {
|
||||
expanded = forcedExpanded;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Card.Root>
|
||||
@@ -63,11 +73,18 @@
|
||||
<Card.Description>{description}</Card.Description>
|
||||
{/if}
|
||||
</div>
|
||||
<Button class="ml-10 h-8 p-3" variant="ghost" aria-label={m.expand_card()}>
|
||||
<LucideChevronDown
|
||||
class={cn('size-5 transition-transform duration-200', expanded && 'rotate-180 transform')}
|
||||
/>
|
||||
</Button>
|
||||
{#if button}
|
||||
{@render button()}
|
||||
{:else}
|
||||
<Button class="ml-10 h-8 p-3" variant="ghost" aria-label={m.expand_card()}>
|
||||
<LucideChevronDown
|
||||
class={cn(
|
||||
'size-5 transition-transform duration-200',
|
||||
expanded && 'rotate-180 transform'
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</Card.Header>
|
||||
{#if expanded}
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
selectedIds = $bindable(),
|
||||
withoutSearch = false,
|
||||
selectionDisabled = false,
|
||||
rowSelectionDisabled,
|
||||
fetchCallback,
|
||||
defaultSort,
|
||||
columns,
|
||||
@@ -34,6 +35,7 @@
|
||||
selectedIds?: string[];
|
||||
withoutSearch?: boolean;
|
||||
selectionDisabled?: boolean;
|
||||
rowSelectionDisabled?: (item: T) => boolean;
|
||||
fetchCallback: (requestOptions: ListRequestOptions) => Promise<Paginated<T>>;
|
||||
defaultSort?: SortRequest;
|
||||
columns: AdvancedTableColumn<T>[];
|
||||
@@ -91,7 +93,9 @@
|
||||
});
|
||||
|
||||
async function onAllCheck(checked: boolean) {
|
||||
const pageIds = items!.data.map((item) => item.id);
|
||||
const pageIds = items!.data
|
||||
.filter((item) => !rowSelectionDisabled?.(item))
|
||||
.map((item) => item.id);
|
||||
const current = selectedIds ?? [];
|
||||
|
||||
if (checked) {
|
||||
@@ -264,7 +268,7 @@
|
||||
{#if selectedIds}
|
||||
<Table.Cell class="w-12">
|
||||
<Checkbox
|
||||
disabled={selectionDisabled}
|
||||
disabled={selectionDisabled || rowSelectionDisabled?.(item)}
|
||||
checked={selectedIds.includes(item.id)}
|
||||
onCheckedChange={(c: boolean) => onCheck(c, item.id)}
|
||||
/>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import UserGroupService from '$lib/services/user-group-service';
|
||||
import type { AdvancedTableColumn } from '$lib/types/advanced-table.type';
|
||||
import type { UserGroupWithUserCount } from '$lib/types/user-group.type';
|
||||
import type { UserGroupMinimal } from '$lib/types/user-group.type';
|
||||
|
||||
let {
|
||||
selectionDisabled = false,
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
const userGroupService = new UserGroupService();
|
||||
|
||||
const columns: AdvancedTableColumn<UserGroupWithUserCount>[] = [
|
||||
const columns: AdvancedTableColumn<UserGroupMinimal>[] = [
|
||||
{ label: 'ID', column: 'id', hidden: true },
|
||||
{ label: m.friendly_name(), column: 'friendlyName', sortable: true },
|
||||
{ label: m.name(), column: 'name', sortable: true },
|
||||
|
||||
@@ -1,30 +1,26 @@
|
||||
import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type';
|
||||
import type {
|
||||
UserGroupCreate,
|
||||
UserGroupWithUserCount,
|
||||
UserGroupWithUsers
|
||||
} from '$lib/types/user-group.type';
|
||||
import type { UserGroup, UserGroupCreate, UserGroupMinimal } from '$lib/types/user-group.type';
|
||||
import APIService from './api-service';
|
||||
|
||||
export default class UserGroupService extends APIService {
|
||||
list = async (options?: ListRequestOptions) => {
|
||||
const res = await this.api.get('/user-groups', { params: options });
|
||||
return res.data as Paginated<UserGroupWithUserCount>;
|
||||
return res.data as Paginated<UserGroupMinimal>;
|
||||
};
|
||||
|
||||
get = async (id: string) => {
|
||||
const res = await this.api.get(`/user-groups/${id}`);
|
||||
return res.data as UserGroupWithUsers;
|
||||
return res.data as UserGroup;
|
||||
};
|
||||
|
||||
create = async (user: UserGroupCreate) => {
|
||||
const res = await this.api.post('/user-groups', user);
|
||||
return res.data as UserGroupWithUsers;
|
||||
return res.data as UserGroup;
|
||||
};
|
||||
|
||||
update = async (id: string, user: UserGroupCreate) => {
|
||||
const res = await this.api.put(`/user-groups/${id}`, user);
|
||||
return res.data as UserGroupWithUsers;
|
||||
return res.data as UserGroup;
|
||||
};
|
||||
|
||||
remove = async (id: string) => {
|
||||
@@ -33,6 +29,11 @@ export default class UserGroupService extends APIService {
|
||||
|
||||
updateUsers = async (id: string, userIds: string[]) => {
|
||||
const res = await this.api.put(`/user-groups/${id}/users`, { userIds });
|
||||
return res.data as UserGroupWithUsers;
|
||||
return res.data as UserGroup;
|
||||
};
|
||||
|
||||
updateAllowedOidcClients = async (id: string, oidcClientIds: string[]) => {
|
||||
const res = await this.api.put(`/user-groups/${id}/allowed-oidc-clients`, { oidcClientIds });
|
||||
return res.data as UserGroup;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ export type OidcClient = OidcClientMetaData & {
|
||||
requiresReauthentication: boolean;
|
||||
credentials?: OidcClientCredentials;
|
||||
launchURL?: string;
|
||||
isGroupRestricted: boolean;
|
||||
};
|
||||
|
||||
export type OidcClientWithAllowedUserGroups = OidcClient & {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CustomClaim } from './custom-claim.type';
|
||||
import type { OidcClientMetaData } from './oidc.type';
|
||||
import type { User } from './user.type';
|
||||
|
||||
export type UserGroup = {
|
||||
@@ -8,13 +9,11 @@ export type UserGroup = {
|
||||
createdAt: string;
|
||||
customClaims: CustomClaim[];
|
||||
ldapId?: string;
|
||||
};
|
||||
|
||||
export type UserGroupWithUsers = UserGroup & {
|
||||
users: User[];
|
||||
allowedOidcClients: OidcClientMetaData[];
|
||||
};
|
||||
|
||||
export type UserGroupWithUserCount = UserGroup & {
|
||||
export type UserGroupMinimal = Omit<UserGroup, 'users' | 'allowedOidcClients'> & {
|
||||
userCount: number;
|
||||
};
|
||||
|
||||
|
||||
@@ -80,6 +80,44 @@
|
||||
return success;
|
||||
}
|
||||
|
||||
async function enableGroupRestriction() {
|
||||
client.isGroupRestricted = true;
|
||||
await oidcService
|
||||
.updateClient(client.id, {
|
||||
...client,
|
||||
isGroupRestricted: true
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(m.user_groups_restriction_updated_successfully());
|
||||
client.isGroupRestricted = true;
|
||||
})
|
||||
.catch(axiosErrorToast);
|
||||
}
|
||||
|
||||
function disableGroupRestriction() {
|
||||
openConfirmDialog({
|
||||
title: m.unrestrict_oidc_client({ clientName: client.name }),
|
||||
message: m.confirm_unrestrict_oidc_client_description({ clientName: client.name }),
|
||||
confirm: {
|
||||
label: m.unrestrict(),
|
||||
destructive: true,
|
||||
action: async () => {
|
||||
await oidcService
|
||||
.updateClient(client.id, {
|
||||
...client,
|
||||
isGroupRestricted: false
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(m.user_groups_restriction_updated_successfully());
|
||||
client.allowedUserGroupIds = [];
|
||||
client.isGroupRestricted = false;
|
||||
})
|
||||
.catch(axiosErrorToast);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function createClientSecret() {
|
||||
openConfirmDialog({
|
||||
title: m.create_new_client_secret(),
|
||||
@@ -120,6 +158,13 @@
|
||||
<title>{m.oidc_client_name({ name: client.name })}</title>
|
||||
</svelte:head>
|
||||
|
||||
{#snippet UnrestrictButton()}
|
||||
<Button
|
||||
onclick={enableGroupRestriction}
|
||||
variant={client.isGroupRestricted ? 'secondary' : 'default'}>{m.restrict()}</Button
|
||||
>
|
||||
{/snippet}
|
||||
|
||||
<div>
|
||||
<button type="button" class="text-muted-foreground flex text-sm" onclick={backNavigation.go}
|
||||
><LucideChevronLeft class="size-5" /> {m.back()}</button
|
||||
@@ -193,10 +238,19 @@
|
||||
<CollapsibleCard
|
||||
id="allowed-user-groups"
|
||||
title={m.allowed_user_groups()}
|
||||
description={m.add_user_groups_to_this_client_to_restrict_access_to_users_in_these_groups()}
|
||||
button={!client.isGroupRestricted ? UnrestrictButton : undefined}
|
||||
forcedExpanded={client.isGroupRestricted ? undefined : false}
|
||||
description={client.isGroupRestricted
|
||||
? m.allowed_user_groups_description()
|
||||
: m.allowed_user_groups_status_unrestricted_description()}
|
||||
>
|
||||
<UserGroupSelection bind:selectedGroupIds={client.allowedUserGroupIds} />
|
||||
<div class="mt-5 flex justify-end">
|
||||
<UserGroupSelection
|
||||
bind:selectedGroupIds={client.allowedUserGroupIds}
|
||||
selectionDisabled={!client.isGroupRestricted}
|
||||
/>
|
||||
<div class="mt-5 flex justify-end gap-3">
|
||||
<Button onclick={disableGroupRestriction} variant="secondary">{m.unrestrict()}</Button>
|
||||
|
||||
<Button onclick={() => updateUserGroupClients(client.allowedUserGroupIds)}>{m.save()}</Button>
|
||||
</div>
|
||||
</CollapsibleCard>
|
||||
|
||||
@@ -102,7 +102,8 @@
|
||||
logo: $inputs.logoUrl?.value ? undefined : logo,
|
||||
logoUrl: $inputs.logoUrl?.value,
|
||||
darkLogo: $inputs.darkLogoUrl?.value ? undefined : darkLogo,
|
||||
darkLogoUrl: $inputs.darkLogoUrl?.value
|
||||
darkLogoUrl: $inputs.darkLogoUrl?.value,
|
||||
isGroupRestricted: existingClient?.isGroupRestricted ?? true
|
||||
});
|
||||
|
||||
const hasLogo = logo != null || !!$inputs.logoUrl?.value;
|
||||
|
||||
@@ -15,11 +15,13 @@
|
||||
import { backNavigate } from '../../users/navigate-back-util';
|
||||
import UserGroupForm from '../user-group-form.svelte';
|
||||
import UserSelection from '../user-selection.svelte';
|
||||
import OidcClientSelection from './oidc-client-selection.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
let userGroup = $state({
|
||||
...data.userGroup,
|
||||
userIds: data.userGroup.users.map((u) => u.id)
|
||||
userIds: data.userGroup.users.map((u) => u.id),
|
||||
allowedOidcClientIds: data.userGroup.allowedOidcClients.map((c) => c.id)
|
||||
});
|
||||
|
||||
const userGroupService = new UserGroupService();
|
||||
@@ -56,6 +58,17 @@
|
||||
axiosErrorToast(e);
|
||||
});
|
||||
}
|
||||
|
||||
async function updateAllowedOidcClients(allowedClients: string[]) {
|
||||
await userGroupService
|
||||
.updateAllowedOidcClients(userGroup.id, allowedClients)
|
||||
.then(() => {
|
||||
toast.success(m.allowed_oidc_clients_updated_successfully());
|
||||
})
|
||||
.catch((e) => {
|
||||
axiosErrorToast(e);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -110,3 +123,16 @@
|
||||
<Button onclick={updateCustomClaims} type="submit">{m.save()}</Button>
|
||||
</div>
|
||||
</CollapsibleCard>
|
||||
|
||||
<CollapsibleCard
|
||||
id="user-group-oidc-clients"
|
||||
title={m.allowed_oidc_clients()}
|
||||
description={m.allowed_oidc_clients_description()}
|
||||
>
|
||||
<OidcClientSelection bind:selectedGroupIds={userGroup.allowedOidcClientIds} />
|
||||
<div class="mt-5 flex justify-end gap-3">
|
||||
<Button onclick={() => updateAllowedOidcClients(userGroup.allowedOidcClientIds)}
|
||||
>{m.save()}</Button
|
||||
>
|
||||
</div>
|
||||
</CollapsibleCard>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import ImageBox from '$lib/components/image-box.svelte';
|
||||
import AdvancedTable from '$lib/components/table/advanced-table.svelte';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import OidcService from '$lib/services/oidc-service';
|
||||
import type { AdvancedTableColumn } from '$lib/types/advanced-table.type';
|
||||
import type { ListRequestOptions } from '$lib/types/list-request.type';
|
||||
import type { OidcClient } from '$lib/types/oidc.type';
|
||||
import { cachedOidcClientLogo } from '$lib/utils/cached-image-util';
|
||||
import { mode } from 'mode-watcher';
|
||||
|
||||
let {
|
||||
selectedGroupIds = $bindable()
|
||||
}: {
|
||||
selectedGroupIds: string[];
|
||||
} = $props();
|
||||
|
||||
const oidcClientService = new OidcService();
|
||||
|
||||
const isLightMode = $derived(mode.current === 'light');
|
||||
|
||||
const columns: AdvancedTableColumn<OidcClient>[] = [
|
||||
{ label: 'ID', column: 'id', hidden: true },
|
||||
{ label: m.logo(), key: 'logo', cell: LogoCell },
|
||||
{ label: m.name(), column: 'name', sortable: true },
|
||||
{
|
||||
label: m.client_launch_url(),
|
||||
column: 'launchURL',
|
||||
hidden: true
|
||||
},
|
||||
{
|
||||
label: m.public_client(),
|
||||
column: 'isPublic',
|
||||
sortable: true,
|
||||
hidden: true
|
||||
}
|
||||
];
|
||||
|
||||
async function fetchCallback(requestOptions: ListRequestOptions) {
|
||||
const clients = await oidcClientService.listClients(requestOptions);
|
||||
const unrestrictedClientIds = clients.data.filter((c) => !c.isGroupRestricted).map((c) => c.id);
|
||||
selectedGroupIds = [...new Set([...selectedGroupIds, ...unrestrictedClientIds])];
|
||||
|
||||
return clients;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet LogoCell({ item }: { item: OidcClient })}
|
||||
{#if item.hasLogo}
|
||||
<ImageBox
|
||||
class="size-12 rounded-lg"
|
||||
src={cachedOidcClientLogo.getUrl(item.id, isLightMode)}
|
||||
alt={m.name_logo({ name: item.name })}
|
||||
/>
|
||||
{:else}
|
||||
<div class="bg-muted flex size-12 items-center justify-center rounded-lg text-lg font-bold">
|
||||
{item.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<AdvancedTable
|
||||
id="oidc-client-selection"
|
||||
{fetchCallback}
|
||||
defaultSort={{ column: 'name', direction: 'asc' }}
|
||||
bind:selectedIds={selectedGroupIds}
|
||||
rowSelectionDisabled={(item) => !item.isGroupRestricted}
|
||||
{columns}
|
||||
/>
|
||||
@@ -10,19 +10,19 @@
|
||||
AdvancedTableColumn,
|
||||
CreateAdvancedTableActions
|
||||
} from '$lib/types/advanced-table.type';
|
||||
import type { UserGroup, UserGroupWithUserCount } from '$lib/types/user-group.type';
|
||||
import type { UserGroupMinimal } from '$lib/types/user-group.type';
|
||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||
import { LucidePencil, LucideTrash } from '@lucide/svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const userGroupService = new UserGroupService();
|
||||
let tableRef: AdvancedTable<UserGroupWithUserCount>;
|
||||
let tableRef: AdvancedTable<UserGroupMinimal>;
|
||||
|
||||
export function refresh() {
|
||||
return tableRef?.refresh();
|
||||
}
|
||||
|
||||
const columns: AdvancedTableColumn<UserGroupWithUserCount>[] = [
|
||||
const columns: AdvancedTableColumn<UserGroupMinimal>[] = [
|
||||
{ label: 'ID', column: 'id', hidden: true },
|
||||
{ label: m.friendly_name(), column: 'friendlyName', sortable: true },
|
||||
{ label: m.name(), column: 'name', sortable: true },
|
||||
@@ -38,7 +38,7 @@
|
||||
{ label: m.source(), key: 'source', hidden: !$appConfigStore.ldapEnabled, cell: SourceCell }
|
||||
];
|
||||
|
||||
const actions: CreateAdvancedTableActions<UserGroupWithUserCount> = (group) => [
|
||||
const actions: CreateAdvancedTableActions<UserGroupMinimal> = (group) => [
|
||||
{
|
||||
label: m.edit(),
|
||||
primary: true,
|
||||
@@ -55,7 +55,7 @@
|
||||
}
|
||||
];
|
||||
|
||||
async function deleteUserGroup(userGroup: UserGroup) {
|
||||
async function deleteUserGroup(userGroup: UserGroupMinimal) {
|
||||
openConfirmDialog({
|
||||
title: m.delete_name({ name: userGroup.name }),
|
||||
message: m.are_you_sure_you_want_to_delete_this_user_group(),
|
||||
@@ -76,7 +76,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet SourceCell({ item }: { item: UserGroupWithUserCount })}
|
||||
{#snippet SourceCell({ item }: { item: UserGroupMinimal })}
|
||||
<Badge class="rounded-full" variant={item.ldapId ? 'default' : 'outline'}>
|
||||
{item.ldapId ? m.ldap() : m.local()}
|
||||
</Badge>
|
||||
|
||||
@@ -7,7 +7,13 @@ const config = {
|
||||
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
|
||||
// for more information about preprocessors
|
||||
preprocess: vitePreprocess(),
|
||||
|
||||
compilerOptions: {
|
||||
warningFilter: (warning) => {
|
||||
// Ignore "state_referenced_locally" warnings
|
||||
if (warning.code === 'state_referenced_locally') return false;
|
||||
return true;
|
||||
}
|
||||
},
|
||||
kit: {
|
||||
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
|
||||
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
||||
|
||||
Reference in New Issue
Block a user