This commit is contained in:
Timothy Jaeryang Baek
2026-02-09 13:28:21 -06:00
parent f7406ff576
commit 3c7f9aa6a4
27 changed files with 789 additions and 253 deletions
+3 -2
View File
@@ -3,10 +3,11 @@ import { WEBUI_API_BASE_URL } from '$lib/constants';
type ChannelForm = {
type?: string;
name: string;
is_private?: boolean;
is_private?: boolean | null;
data?: object;
meta?: object;
access_control?: object;
access_grants?: object[];
group_ids?: string[];
user_ids?: string[];
};
+32
View File
@@ -99,6 +99,38 @@ export const getGroupById = async (token: string, id: string) => {
return res;
};
export const getGroupInfoById = async (token: string, id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/groups/id/${id}/info`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateGroupById = async (token: string, id: string, group: object) => {
let error = null;
+4 -4
View File
@@ -4,7 +4,7 @@ export const createNewKnowledge = async (
token: string,
name: string,
description: string,
accessControl: null | object
accessGrants: object[]
) => {
let error = null;
@@ -18,7 +18,7 @@ export const createNewKnowledge = async (
body: JSON.stringify({
name: name,
description: description,
access_control: accessControl
access_grants: accessGrants
})
})
.then(async (res) => {
@@ -248,7 +248,7 @@ type KnowledgeUpdateForm = {
name?: string;
description?: string;
data?: object;
access_control?: null | object;
access_grants?: object[];
};
export const updateKnowledgeById = async (token: string, id: string, form: KnowledgeUpdateForm) => {
@@ -265,7 +265,7 @@ export const updateKnowledgeById = async (token: string, id: string, form: Knowl
name: form?.name ? form.name : undefined,
description: form?.description ? form.description : undefined,
data: form?.data ? form.data : undefined,
access_control: form.access_control
access_grants: form.access_grants
})
})
.then(async (res) => {
+1 -1
View File
@@ -5,7 +5,7 @@ type NoteItem = {
title: string;
data: object;
meta?: null | object;
access_control?: null | object;
access_grants?: object[];
};
export const createNewNote = async (token: string, note: NoteItem) => {
+3 -4
View File
@@ -7,7 +7,7 @@ type PromptItem = {
content: string;
data?: object | null;
meta?: object | null;
access_control?: null | object;
access_grants?: object[];
version_id?: string | null; // Active version
commit_message?: string | null; // For history tracking
is_production?: boolean; // Whether to set new version as production
@@ -23,7 +23,7 @@ type PromptHistoryItem = {
command: string;
data: object;
meta: object;
access_control: object | null;
access_grants: object[];
};
user_id: string;
commit_message: string | null;
@@ -42,7 +42,7 @@ type PromptDiff = {
to_snapshot: object;
content_diff: string[];
name_changed: boolean;
access_control_changed: boolean;
access_grants_changed: boolean;
};
export const createNewPrompt = async (token: string, prompt: PromptItem) => {
@@ -611,4 +611,3 @@ export const getPromptDiff = async (
return res;
};
+27
View File
@@ -327,6 +327,33 @@ export const getUserById = async (token: string, userId: string) => {
return res;
};
export const getUserInfoById = async (token: string, userId: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/users/${userId}/info`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.error(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateUserStatus = async (token: string, formData: object) => {
let error = null;
@@ -15,13 +15,32 @@
import AddMembersModal from './ChannelInfoModal/AddMembersModal.svelte';
export let show = false;
export let channel = null;
export let channel: any = null;
export let onUpdate = () => {};
let showAddMembersModal = false;
const submitHandler = async () => {};
const hasPublicReadGrant = (grants: any) =>
Array.isArray(grants) &&
grants.some(
(grant) =>
grant?.principal_type === 'user' &&
grant?.principal_id === '*' &&
grant?.permission === 'read'
);
const isPublicChannel = (channel: any): boolean => {
if (channel?.type === 'group') {
if (typeof channel?.is_private === 'boolean') {
return !channel.is_private;
}
return hasPublicReadGrant(channel?.access_grants);
}
return hasPublicReadGrant(channel?.access_grants);
};
const removeMemberHandler = async (userId) => {
const res = await removeMembersById(localStorage.token, channel.id, {
user_ids: [userId]
@@ -62,7 +81,7 @@
</div>
{:else}
<div class=" size-4 justify-center flex items-center">
{#if channel?.type === 'group' ? !channel?.is_private : channel?.access_control === null}
{#if isPublicChannel(channel)}
<Hashtag className="size-3.5" strokeWidth="2.5" />
{:else}
<Lock className="size-5.5" strokeWidth="2" />
@@ -129,6 +129,25 @@
onDestroy(() => {
window.removeEventListener('keydown', keydownListener);
});
const hasPublicReadGrant = (grants: any) =>
Array.isArray(grants) &&
grants.some(
(grant) =>
grant?.principal_type === 'user' &&
grant?.principal_id === '*' &&
grant?.permission === 'read'
);
const isPublicChannel = (channel: any): boolean => {
if (channel?.type === 'group') {
if (typeof channel?.is_private === 'boolean') {
return !channel.is_private;
}
return hasPublicReadGrant(channel?.access_grants);
}
return hasPublicReadGrant(channel?.access_grants);
};
</script>
{#if filteredItems.length}
@@ -165,7 +184,7 @@
>
{#if item.type === 'channel'}
<div class=" size-4 justify-center flex items-center mr-0.5">
{#if item?.data?.access_control === null}
{#if isPublicChannel(item?.data)}
<Hashtag className="size-3" strokeWidth="2.5" />
{:else}
<Lock className="size-[15px]" strokeWidth="2" />
+20 -1
View File
@@ -26,6 +26,25 @@
let showChannelPinnedMessagesModal = false;
let showChannelInfoModal = false;
const hasPublicReadGrant = (grants: any) =>
Array.isArray(grants) &&
grants.some(
(grant) =>
grant?.principal_type === 'user' &&
grant?.principal_id === '*' &&
grant?.permission === 'read'
);
const isPublicChannel = (channel: any): boolean => {
if (channel?.type === 'group') {
if (typeof channel?.is_private === 'boolean') {
return !channel.is_private;
}
return hasPublicReadGrant(channel?.access_grants);
}
return hasPublicReadGrant(channel?.access_grants);
};
export let channel;
export let onPin = (messageId, pinned) => {};
@@ -112,7 +131,7 @@
{/if}
{:else}
<div class=" size-4.5 justify-center flex items-center">
{#if channel?.type === 'group' ? !channel?.is_private : channel?.access_control === null}
{#if isPublicChannel(channel)}
<Hashtag className="size-3.5" strokeWidth="2.5" />
{:else}
<Lock className="size-5" strokeWidth="2" />
+1 -1
View File
@@ -151,7 +151,7 @@
return {
...file,
user: undefined,
access_control: undefined
access_grants: undefined
};
}),
selectedToolIds,
+3 -2
View File
@@ -560,7 +560,8 @@
<ChannelModal
bind:show={showCreateChannel}
onSubmit={async ({ type, name, is_private, access_control, group_ids, user_ids }) => {
onSubmit={async (payload: any) => {
let { type, name, is_private, access_grants, group_ids, user_ids } = payload ?? {};
name = name?.trim();
if (type === 'dm') {
@@ -579,7 +580,7 @@
type: type,
name: name,
is_private: is_private,
access_control: access_control,
access_grants: access_grants,
group_ids: group_ids,
user_ids: user_ids
}).catch((error) => {
@@ -25,6 +25,25 @@
let showEditChannelModal = false;
let itemElement;
const hasPublicReadGrant = (grants: any) =>
Array.isArray(grants) &&
grants.some(
(grant) =>
grant?.principal_type === 'user' &&
grant?.principal_id === '*' &&
grant?.permission === 'read'
);
const isPublicChannel = (channel: any): boolean => {
if (channel?.type === 'group') {
if (typeof channel?.is_private === 'boolean') {
return !channel.is_private;
}
return hasPublicReadGrant(channel?.access_grants);
}
return hasPublicReadGrant(channel?.access_grants);
};
</script>
<ChannelModal
@@ -32,11 +51,12 @@
{channel}
edit={true}
{onUpdate}
onSubmit={async ({ name, is_private, access_control, group_ids, user_ids }) => {
onSubmit={async (payload: any) => {
const { name, is_private, access_grants, group_ids, user_ids } = payload ?? {};
const res = await updateChannelById(localStorage.token, channel.id, {
name,
is_private,
access_control,
access_grants,
group_ids,
user_ids
}).catch((error) => {
@@ -123,7 +143,7 @@
{/if}
{:else}
<div class=" size-4 justify-center flex items-center ml-1">
{#if channel?.type === 'group' ? !channel?.is_private : channel?.access_control === null}
{#if isPublicChannel(channel)}
<Hashtag className="size-3.5" strokeWidth="2.5" />
{:else}
<Lock className="size-[15px]" strokeWidth="2" />
@@ -23,7 +23,7 @@
export let onSubmit: Function = () => {};
export let onUpdate: Function = () => {};
export let channel = null;
export let channel: any = null;
export let edit = false;
let channelTypes = ['group', 'dm'];
@@ -31,7 +31,7 @@
let name = '';
let isPrivate = null;
let accessControl = {};
let accessGrants = [];
let groupIds = [];
let userIds = [];
@@ -65,8 +65,8 @@
await onSubmit({
type: type,
name: name.replace(/\s/g, '-'),
is_private: type === 'group' ? isPrivate : null,
access_control: type === '' ? accessControl : {},
is_private: type === 'group' ? (isPrivate ?? true) : null,
access_grants: type === '' ? accessGrants : [],
group_ids: groupIds,
user_ids: userIds
});
@@ -85,8 +85,12 @@
if (channel) {
name = channel?.name ?? '';
isPrivate = channel?.is_private ?? null;
accessControl = channel.access_control;
if (type === 'group') {
isPrivate = typeof channel?.is_private === 'boolean' ? channel.is_private : true;
} else {
isPrivate = null;
}
accessGrants = channel?.access_grants ?? [];
userIds = channel?.user_ids ?? [];
}
};
@@ -102,8 +106,14 @@
const deleteHandler = async () => {
showDeleteConfirmDialog = false;
if (!channel?.id) {
show = false;
return;
}
const res = await deleteChannelById(localStorage.token, channel.id).catch((error) => {
const channelId = channel.id;
const res = await deleteChannelById(localStorage.token, channelId).catch((error) => {
toast.error(error.message);
});
@@ -111,7 +121,7 @@
toast.success($i18n.t('Channel deleted successfully'));
onUpdate();
if ($page.url.pathname === `/channels/${channel.id}`) {
if ($page.url.pathname === `/channels/${channelId}`) {
goto('/');
}
}
@@ -122,7 +132,7 @@
const resetHandler = () => {
type = '';
name = '';
accessControl = {};
accessGrants = [];
userIds = [];
loading = false;
};
@@ -226,11 +236,11 @@
{#if type !== 'dm'}
<div class="-mx-2 mb-1 mt-2.5 px-2">
{#if type === ''}
<AccessControl bind:accessControl accessRoles={['read', 'write']} />
<AccessControl bind:accessGrants accessRoles={['read', 'write']} />
{:else if type === 'group'}
<Visibility
state={isPrivate ? 'private' : 'public'}
onChange={(value) => {
onChange={(value: string) => {
if (value === 'private') {
isPrivate = true;
} else {
+22 -6
View File
@@ -108,9 +108,18 @@
},
// pages: [], // TODO: Implement pages for notes to allow users to create multiple pages in a note
meta: null,
access_control: {}
access_grants: []
};
const hasPublicReadGrant = (grants) =>
Array.isArray(grants) &&
grants.some(
(grant) =>
grant?.principal_type === 'user' &&
grant?.principal_id === '*' &&
grant?.permission === 'read'
);
let files = [];
let messages = [];
@@ -161,6 +170,9 @@
if (res) {
note = res;
if (!Array.isArray(note?.access_grants)) {
note.access_grants = [];
}
files = res.data.files || [];
if (note?.write_access) {
@@ -193,7 +205,7 @@
data: {
files: files
},
access_control: note?.access_control
access_grants: note?.access_grants ?? []
}).catch((e) => {
toast.error(`${e}`);
});
@@ -765,8 +777,8 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
console.log('noteEventHandler', _note);
if (_note.id !== id) return;
if (_note.access_control && _note.access_control !== note.access_control) {
note.access_control = _note.access_control;
if (_note.access_grants && _note.access_grants !== note.access_grants) {
note.access_grants = _note.access_grants;
}
if (_note.data && _note.data.files) {
@@ -851,7 +863,7 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
{#if note}
<AccessControlModal
bind:show={showAccessControlModal}
bind:accessControl={note.access_control}
bind:accessGrants={note.access_grants}
accessRoles={['read', 'write']}
onChange={() => {
changeDebounceHandler();
@@ -1114,7 +1126,11 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
}}
disabled={note?.user_id !== $user?.id && $user?.role !== 'admin'}
>
<span> {note?.access_control ? $i18n.t('Private') : $i18n.t('Everyone')} </span>
<span>
{hasPublicReadGrant(note?.access_grants)
? $i18n.t('Everyone')
: $i18n.t('Private')}
</span>
</button>
{:else}
<div>
+1 -1
View File
@@ -129,7 +129,7 @@
}
},
meta: null,
access_control: {}
access_grants: []
}).catch((error) => {
toast.error(`${error}`);
return null;
+1 -1
View File
@@ -120,7 +120,7 @@ export const createNoteHandler = async (title: string, md?: string, html?: strin
}
},
meta: null,
access_control: {}
access_grants: []
}).catch((error) => {
toast.error(`${error}`);
return null;
@@ -15,7 +15,7 @@
let name = '';
let description = '';
let accessControl = {};
let accessGrants = [];
const submitHandler = async () => {
loading = true;
@@ -32,7 +32,7 @@
localStorage.token,
name,
description,
accessControl
accessGrants
).catch((e) => {
toast.error(`${e}`);
});
@@ -114,7 +114,7 @@
<div class="mt-2">
<AccessControl
bind:accessControl
bind:accessGrants
accessRoles={['read', 'write']}
share={$user?.permissions?.sharing?.knowledge || $user?.role === 'admin'}
sharePublic={$user?.permissions?.sharing?.public_knowledge || $user?.role === 'admin'}
@@ -75,6 +75,8 @@
file_ids: string[];
};
files: any[];
access_grants?: any[];
write_access?: boolean;
};
let id = null;
@@ -614,7 +616,7 @@
...knowledge,
name: knowledge.name,
description: knowledge.description,
access_control: knowledge.access_control
access_grants: knowledge.access_grants ?? []
}).catch((e) => {
toast.error(`${e}`);
});
@@ -745,6 +747,9 @@
if (res) {
knowledge = res;
if (!Array.isArray(knowledge?.access_grants)) {
knowledge.access_grants = [];
}
knowledgeId = knowledge?.id;
} else {
goto('/workspace/knowledge');
@@ -828,9 +833,11 @@
{#if id && knowledge}
<AccessControlModal
bind:show={showAccessControlModal}
bind:accessControl={knowledge.access_control}
bind:accessGrants={knowledge.access_grants}
share={$user?.permissions?.sharing?.knowledge || $user?.role === 'admin'}
sharePublic={$user?.permissions?.sharing?.public_knowledge || $user?.role === 'admin'}
sharePublic={$user?.permissions?.sharing?.public_knowledge ||
$user?.role === 'admin' ||
knowledge?.write_access}
onChange={() => {
changeDebounceHandler();
}}
@@ -108,7 +108,7 @@
let builtinTools = {};
let actionIds = [];
let accessControl = {};
let accessGrants = [];
let tts = { voice: '' };
const submitHandler = async () => {
@@ -140,7 +140,7 @@
info.params = { ...info.params, ...params };
info.access_control = accessControl;
info.access_grants = accessGrants;
info.meta.capabilities = capabilities;
if (enableDescription) {
@@ -301,14 +301,7 @@
builtinTools = model?.meta?.builtinTools ?? {};
tts = { voice: model?.meta?.tts?.voice ?? '' };
if ('access_control' in model) {
accessControl = model.access_control;
} else {
accessControl = {};
}
console.log(model?.access_control);
console.log(accessControl);
accessGrants = model?.access_grants ?? [];
info = {
...info,
@@ -334,10 +327,10 @@
{#if loaded}
<AccessControlModal
bind:show={showAccessControlModal}
bind:accessControl
bind:accessGrants
accessRoles={['read', 'write']}
share={$user?.permissions?.sharing?.models || $user?.role === 'admin'}
sharePublic={$user?.permissions?.sharing?.public_models || $user?.role === 'admin'}
sharePublic={$user?.permissions?.sharing?.public_models || $user?.role === 'admin' || edit}
/>
{#if onBack}
@@ -46,7 +46,7 @@
let commitMessage = '';
let isProduction = true;
let accessControl = {};
let accessGrants = [];
let showAccessControlModal = false;
let hasManualEdit = false;
@@ -87,7 +87,7 @@
command,
content,
tags: tags.map((tag) => tag.name),
access_control: accessControl,
access_grants: accessGrants,
commit_message: commitMessage || undefined,
is_production: isProduction
});
@@ -259,7 +259,7 @@
command = prompt.command.at(0) === '/' ? prompt.command.slice(1) : prompt.command;
content = prompt.content;
tags = (prompt.tags || []).map((tag) => ({ name: tag }));
accessControl = prompt?.access_control === undefined ? {} : prompt?.access_control;
accessGrants = prompt?.access_grants === undefined ? [] : prompt?.access_grants;
// Store originals for revert on collision
originalName = name;
@@ -286,10 +286,10 @@
<AccessControlModal
bind:show={showAccessControlModal}
bind:accessControl
bind:accessGrants
accessRoles={['read', 'write']}
share={$user?.permissions?.sharing?.prompts || $user?.role === 'admin'}
sharePublic={$user?.permissions?.sharing?.public_prompts || $user?.role === 'admin'}
sharePublic={$user?.permissions?.sharing?.public_prompts || $user?.role === 'admin' || edit}
/>
<!-- Edit Modal -->
@@ -30,7 +30,7 @@
description: ''
};
export let content = '';
export let accessControl = {};
export let accessGrants = [];
let _content = '';
@@ -161,7 +161,7 @@ class Tools:
name,
meta,
content,
access_control: accessControl
access_grants: accessGrants
});
};
@@ -187,10 +187,10 @@ class Tools:
<AccessControlModal
bind:show={showAccessControlModal}
bind:accessControl
bind:accessGrants
accessRoles={['read', 'write']}
share={$user?.permissions?.sharing?.tools || $user?.role === 'admin'}
sharePublic={$user?.permissions?.sharing?.public_tools || $user?.role === 'admin'}
sharePublic={$user?.permissions?.sharing?.public_tools || $user?.role === 'admin' || edit}
/>
<div class=" flex flex-col justify-between w-full overflow-y-auto h-full">
@@ -3,75 +3,430 @@
const i18n = getContext('i18n');
import { getGroups } from '$lib/apis/groups';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import UserCircleSolid from '$lib/components/icons/UserCircleSolid.svelte';
import { getGroups, getGroupById, getGroupInfoById } from '$lib/apis/groups';
import { getUserById, getUserInfoById } from '$lib/apis/users';
import { WEBUI_API_BASE_URL } from '$lib/constants';
import XMark from '$lib/components/icons/XMark.svelte';
import Badge from '$lib/components/common/Badge.svelte';
import GlobeAlt from '$lib/components/icons/GlobeAlt.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import AddAccessModal from './AddAccessModal.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
type AccessGrant = {
id?: string;
principal_type: 'user' | 'group';
principal_id: string;
permission: 'read' | 'write';
};
type LegacyAccessControl = {
read: { group_ids: string[]; user_ids: string[] };
write: { group_ids: string[]; user_ids: string[] };
};
export let onChange: Function = () => {};
export let accessRoles = ['read'];
export let accessControl = {};
export let accessGrants: AccessGrant[] | any = [];
export let accessControl: any = undefined;
export let share = true;
export let sharePublic = true;
let selectedGroupId = '';
let groups = [];
let groups: any[] = [];
const resolvingGroupIds = new Set<string>();
let userById: Record<string, any> = {};
const resolvingUserIds = new Set<string>();
$: if (!sharePublic && accessControl === null) {
initPublicAccess();
}
let showAddAccessModal = false;
const initPublicAccess = () => {
if (!sharePublic && accessControl === null) {
accessControl = {
read: {
group_ids: [],
user_ids: []
},
write: {
group_ids: [],
user_ids: []
const dedupeAccessGrants = (grants: AccessGrant[] | null | undefined): AccessGrant[] => {
if (!Array.isArray(grants)) return [];
const map = new Map<string, AccessGrant>();
for (const grant of grants) {
if (!grant) continue;
const key = `${grant.principal_type}:${grant.principal_id}:${grant.permission}`;
if (!grant.principal_type || !grant.principal_id || !grant.permission) continue;
map.set(key, {
id: grant.id,
principal_type: grant.principal_type,
principal_id: grant.principal_id,
permission: grant.permission
});
}
return Array.from(map.values());
};
const legacyAccessControlToGrants = (accessControl: any): AccessGrant[] => {
if (accessControl === null) {
return [
{
principal_type: 'user',
principal_id: '*',
permission: 'read'
}
};
onChange(accessControl);
];
}
if (!accessControl || typeof accessControl !== 'object') {
return [];
}
const grants: AccessGrant[] = [];
for (const permission of ['read', 'write'] as const) {
const entry = accessControl?.[permission] ?? {};
for (const groupId of entry?.group_ids ?? []) {
grants.push({
principal_type: 'group',
principal_id: groupId,
permission
});
}
for (const userId of entry?.user_ids ?? []) {
grants.push({
principal_type: 'user',
principal_id: userId,
permission
});
}
}
return dedupeAccessGrants(grants);
};
const grantsToLegacyAccessControl = (grants: AccessGrant[]): null | LegacyAccessControl => {
const normalized = dedupeAccessGrants(grants);
if (hasPublicReadGrant(normalized)) {
return null;
}
const result: LegacyAccessControl = {
read: { group_ids: [], user_ids: [] },
write: { group_ids: [], user_ids: [] }
};
for (const grant of normalized) {
if (!['read', 'write'].includes(grant.permission)) {
continue;
}
if (grant.principal_type === 'group') {
if (!result[grant.permission].group_ids.includes(grant.principal_id)) {
result[grant.permission].group_ids = [
...result[grant.permission].group_ids,
grant.principal_id
];
}
} else if (grant.principal_type === 'user' && grant.principal_id !== '*') {
if (!result[grant.permission].user_ids.includes(grant.principal_id)) {
result[grant.permission].user_ids = [
...result[grant.permission].user_ids,
grant.principal_id
];
}
}
}
return result;
};
const normalizeInputToGrants = (value: any): AccessGrant[] => {
if (value === null) {
return legacyAccessControlToGrants(null);
}
if (Array.isArray(value)) {
return dedupeAccessGrants(value);
}
if (value && typeof value === 'object' && ('read' in value || 'write' in value)) {
return legacyAccessControlToGrants(value);
}
return [];
};
const stableStringify = (value: any): string => {
try {
return JSON.stringify(value ?? null);
} catch {
return '';
}
};
const hasPublicReadGrant = (grants: AccessGrant[]): boolean =>
grants.some(
(grant) =>
grant.principal_type === 'user' && grant.principal_id === '*' && grant.permission === 'read'
);
const currentGrants = (): AccessGrant[] =>
Array.isArray(accessGrants) ? (accessGrants as AccessGrant[]) : [];
const getPrincipalIdsByPermission = (
principalType: 'user' | 'group',
permission: 'read' | 'write'
): string[] =>
Array.from(
new Set(
currentGrants()
.filter(
(grant) => grant.principal_type === principalType && grant.permission === permission
)
.map((grant) => grant.principal_id)
)
);
const hasPrincipalGrant = (
principalType: 'user' | 'group',
principalId: string,
permission: 'read' | 'write'
): boolean =>
currentGrants().some(
(grant) =>
grant.principal_type === principalType &&
grant.principal_id === principalId &&
grant.permission === permission
);
const commitAccessGrants = (nextGrants: AccessGrant[]) => {
accessGrants = dedupeAccessGrants(nextGrants);
onChange(accessGrants);
};
const setPublic = (isPublic: boolean) => {
const filtered = currentGrants().filter(
(grant) =>
!(
grant.principal_type === 'user' &&
grant.principal_id === '*' &&
grant.permission === 'read'
)
);
if (isPublic) {
filtered.push({
principal_type: 'user',
principal_id: '*',
permission: 'read'
});
}
commitAccessGrants(filtered);
};
const upsertPrincipalGrant = (
principalType: 'user' | 'group',
principalId: string,
permission: 'read' | 'write',
grants: AccessGrant[]
): AccessGrant[] => {
if (
grants.some(
(grant) =>
grant.principal_type === principalType &&
grant.principal_id === principalId &&
grant.permission === permission
)
) {
return grants;
}
return [
...grants,
{
principal_type: principalType,
principal_id: principalId,
permission
}
];
};
const removePrincipalGrant = (
principalType: 'user' | 'group',
principalId: string,
permission: 'read' | 'write',
grants: AccessGrant[]
): AccessGrant[] =>
grants.filter(
(grant) =>
!(
grant.principal_type === principalType &&
grant.principal_id === principalId &&
grant.permission === permission
)
);
const removePrincipal = (principalType: 'user' | 'group', principalId: string) => {
let next = [...currentGrants()];
next = removePrincipalGrant(principalType, principalId, 'read', next);
next = removePrincipalGrant(principalType, principalId, 'write', next);
commitAccessGrants(next);
};
const togglePrincipalWrite = (principalType: 'user' | 'group', principalId: string) => {
let next = [...currentGrants()];
const hasWrite = hasPrincipalGrant(principalType, principalId, 'write');
if (hasWrite) {
next = removePrincipalGrant(principalType, principalId, 'write', next);
} else {
next = upsertPrincipalGrant(principalType, principalId, 'read', next);
next = upsertPrincipalGrant(principalType, principalId, 'write', next);
}
commitAccessGrants(next);
};
const ensureUsersByIds = async (userIds: string[]) => {
const pendingIds = userIds.filter((id) => !userById[id] && !resolvingUserIds.has(id));
if (!pendingIds.length) return;
for (const id of pendingIds) {
resolvingUserIds.add(id);
}
const fetched = await Promise.all(
pendingIds.map(async (id) => {
const user = await getUserInfoById(localStorage.token, id).catch((error) => {
console.error(error);
return null;
});
return { id, user };
})
);
const nextUserById = { ...userById };
for (const item of fetched) {
if (item.user?.id) {
nextUserById[item.id] = item.user;
}
resolvingUserIds.delete(item.id);
}
userById = nextUserById;
};
const handleAddAccess = ({ userIds, groupIds }: { userIds: string[]; groupIds: string[] }) => {
let next = [...currentGrants()];
for (const groupId of groupIds) {
next = upsertPrincipalGrant('group', groupId, 'read', next);
}
for (const userId of userIds) {
next = upsertPrincipalGrant('user', userId, 'read', next);
}
commitAccessGrants(next);
};
// NOTE: We must reference `accessGrants` directly in each reactive
// expression so Svelte tracks the dependency.
const ensureGroupsByIds = async (groupIds: string[]) => {
const pendingIds = groupIds.filter(
(id) => !groups.find((g) => g.id === id) && !resolvingGroupIds.has(id)
);
if (!pendingIds.length) return;
for (const id of pendingIds) {
resolvingGroupIds.add(id);
}
const fetched = await Promise.all(
pendingIds.map(async (id) => {
const group = await getGroupInfoById(localStorage.token, id).catch((error) => {
console.error(error);
return null;
});
return group;
})
);
const newGroups = fetched.filter((g) => g);
if (newGroups.length > 0) {
groups = [...groups, ...newGroups].filter(
(g, index, self) => index === self.findIndex((t) => t.id === g.id)
);
}
for (const id of pendingIds) {
resolvingGroupIds.delete(id);
}
};
$: if (readGroupIds.length > 0 || writeGroupIds.length > 0) {
void ensureGroupsByIds([...readGroupIds, ...writeGroupIds]);
}
$: readGroupIds = (accessGrants, getPrincipalIdsByPermission('group', 'read'));
$: writeGroupIds = (accessGrants, getPrincipalIdsByPermission('group', 'write'));
$: readUserIds =
(accessGrants, getPrincipalIdsByPermission('user', 'read').filter((id) => id !== '*'));
$: writeUserIds =
(accessGrants, getPrincipalIdsByPermission('user', 'write').filter((id) => id !== '*'));
$: selectedUserIds = Array.from(new Set([...readUserIds, ...writeUserIds]));
$: selectedUsers = selectedUserIds
.map((id) => {
return userById[id] ?? { id, name: id, email: '' };
})
.sort((a, b) => a.name.localeCompare(b.name));
$: accessGroups = groups
.filter((group) => readGroupIds.includes(group.id) || writeGroupIds.includes(group.id))
.sort((a, b) => a.name.localeCompare(b.name));
$: if (selectedUserIds.length > 0) {
void ensureUsersByIds(selectedUserIds);
}
$: {
if (accessControl !== undefined) {
const normalizedGrants = normalizeInputToGrants(accessControl);
if (stableStringify(normalizedGrants) !== stableStringify(accessGrants)) {
accessGrants = normalizedGrants;
}
}
}
$: {
const normalizedGrants = normalizeInputToGrants(accessGrants);
if (stableStringify(normalizedGrants) !== stableStringify(accessGrants)) {
accessGrants = normalizedGrants;
}
if (accessControl !== undefined) {
const nextAccessControl = grantsToLegacyAccessControl(normalizedGrants);
if (stableStringify(nextAccessControl) !== stableStringify(accessControl)) {
accessControl = nextAccessControl;
}
}
}
onMount(async () => {
groups = await getGroups(localStorage.token, true).catch((error) => {
console.log('AccessControl mounted', { accessGrants, accessControl });
const res = await getGroups(localStorage.token, true).catch((error) => {
console.error(error);
return [];
});
if (accessControl === null) {
initPublicAccess();
} else {
accessControl = {
read: {
group_ids: accessControl?.read?.group_ids ?? [],
user_ids: accessControl?.read?.user_ids ?? []
},
write: {
group_ids: accessControl?.write?.group_ids ?? [],
user_ids: accessControl?.write?.user_ids ?? []
}
};
}
console.log('getGroups res', res);
groups = [...groups, ...res].filter(
(g, index, self) => index === self.findIndex((t) => t.id === g.id)
);
});
$: console.log('AccessControl state', {
accessGrants,
readGroupIds,
writeGroupIds,
selectedUserIds,
groups,
accessGroups,
selectedUsers
});
</script>
<div class=" rounded-lg flex flex-col gap-2">
<div class="">
<div class=" text-xs font-medium mb-2.5 text-gray-500">{$i18n.t('Visibility')}</div>
<AddAccessModal bind:show={showAddAccessModal} onAdd={handleAddAccess} />
<div class="flex gap-2.5 items-center mb-1">
<div class=" rounded-lg flex flex-col gap-1">
<div class="py-2">
<div class="flex gap-2.5 items-center">
<div>
<div class=" p-2 bg-black/5 dark:bg-white/5 rounded-full">
{#if accessControl !== null}
{#if !hasPublicReadGrant(accessGrants ?? [])}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
@@ -106,36 +461,28 @@
</div>
<div>
<select
id="models"
class="dark:bg-gray-900 outline-hidden bg-transparent text-sm font-medium block w-fit pr-10 max-w-full placeholder-gray-400"
value={accessControl !== null ? 'private' : 'public'}
on:change={(e) => {
if (e.target.value === 'public') {
accessControl = null;
} else {
accessControl = {
read: {
group_ids: [],
user_ids: []
},
write: {
group_ids: [],
user_ids: []
}
};
}
onChange(accessControl);
}}
<Tooltip
content={!(share && sharePublic) && !hasPublicReadGrant(accessGrants ?? [])
? $i18n.t('You do not have permission to make this public')
: ''}
>
<option class=" text-gray-700" value="private" selected>{$i18n.t('Private')}</option>
{#if share && sharePublic}
<option class=" text-gray-700" value="public" selected>{$i18n.t('Public')}</option>
{/if}
</select>
<select
id="models"
class="dark:bg-gray-900 outline-none bg-transparent text-sm font-medium block w-fit pr-10 max-w-full placeholder-gray-400"
value={!hasPublicReadGrant(accessGrants ?? []) ? 'private' : 'public'}
on:change={(e) => {
setPublic((e.target as HTMLSelectElement).value === 'public');
}}
>
<option class=" text-gray-700" value="private">{$i18n.t('Private')}</option>
{#if (share && sharePublic) || hasPublicReadGrant(accessGrants ?? [])}
<option class=" text-gray-700" value="public">{$i18n.t('Public')}</option>
{/if}
</select>
</Tooltip>
<div class=" text-xs text-gray-400 font-medium">
{#if accessControl !== null}
{#if !hasPublicReadGrant(accessGrants ?? [])}
{$i18n.t('Only select users and groups with permission can access')}
{:else}
{$i18n.t('Accessible to all users')}
@@ -146,116 +493,126 @@
</div>
{#if share}
{#if accessControl !== null}
{@const accessGroups = groups.filter((group) =>
(accessControl?.read?.group_ids ?? []).includes(group.id)
)}
<div class="flex items-center justify-between text-xs font-medium text-gray-500 my-1">
<div>
<div class="">
<div class="flex justify-between mb-2.5">
<div class="text-xs font-medium text-gray-500">
{$i18n.t('Groups')}
{$i18n.t('Access List')}
</div>
<div class="flex gap-1">
<button
class="px-2 py-1 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition text-xs font-medium flex items-center gap-1"
type="button"
on:click={() => {
showAddAccessModal = true;
}}
>
<Plus className="size-3" />
{$i18n.t('Add Access')}
</button>
</div>
</div>
<!-- List -->
<div class="flex flex-col gap-2">
<!-- Groups -->
{#each accessGroups as group}
<div class="flex items-center gap-3 justify-between text-sm w-full transition pb-1">
<div class="flex items-center gap-2 w-full flex-1">
<!-- Placeholder for group icon vs user icon -->
<div
class="size-5 rounded-full bg-gray-100 dark:bg-gray-850 flex items-center justify-center text-xs"
>
{group.name.charAt(0).toUpperCase()}
</div>
<div class="truncate text-sm flex items-center gap-2">
{group.name}
<span class="text-xs text-gray-400 font-normal"
>{group?.member_count} {$i18n.t('members')}</span
>
</div>
</div>
{#if accessGroups.length > 0}
<div class="flex flex-col gap-1.5 mb-2 px-0.5 mx-0.5">
{#each accessGroups as group}
<div class="flex items-center gap-3 justify-between text-sm w-full transition">
<div class="flex items-center gap-1.5 w-full">
<div>
{group.name} <span class="text-xs text-gray-500">{group?.member_count}</span>
</div>
</div>
<div class="w-full flex justify-end items-center gap-2">
<button
type="button"
on:click={() => {
if (accessRoles.includes('write')) {
togglePrincipalWrite('group', group.id);
}
}}
>
{#if writeGroupIds.includes(group.id)}
<Badge type={'success'} content={$i18n.t('Write')} />
{:else}
<Badge type={'info'} content={$i18n.t('Read')} />
{/if}
</button>
<div class="w-full flex justify-end items-center gap-0.5">
<button
class=""
type="button"
on:click={() => {
if (accessRoles.includes('write')) {
if ((accessControl?.write?.group_ids ?? []).includes(group.id)) {
accessControl.write.group_ids = (
accessControl?.write?.group_ids ?? []
).filter((group_id) => group_id !== group.id);
} else {
accessControl.write.group_ids = [
...(accessControl?.write?.group_ids ?? []),
group.id
];
}
onChange(accessControl);
}
}}
>
{#if (accessControl?.write?.group_ids ?? []).includes(group.id)}
<Badge type={'success'} content={$i18n.t('Write')} />
{:else}
<Badge type={'info'} content={$i18n.t('Read')} />
{/if}
</button>
<button
class=" rounded-full p-1 hover:bg-gray-100 dark:hover:bg-gray-850 transition"
type="button"
on:click={() => {
accessControl.read.group_ids = (
accessControl?.read?.group_ids ?? []
).filter((id) => id !== group.id);
accessControl.write.group_ids = (
accessControl?.write?.group_ids ?? []
).filter((id) => id !== group.id);
onChange(accessControl);
}}
>
<XMark />
</button>
</div>
</div>
{/each}
</div>
{/if}
<!-- <div class="flex items-center justify-center">
<div class="text-gray-500 text-xs text-center py-2 px-10">
{$i18n.t('No groups with access, add a group to grant access')}
</div>
</div> -->
<div class="mb-1">
<div class="flex w-full">
<div class="flex flex-1 items-center">
<div class="w-full px-0.5">
<select
class=" outline-hidden bg-transparent text-sm block w-full pr-10 max-w-full
{selectedGroupId ? '' : 'text-gray-500'}
dark:placeholder-gray-500"
bind:value={selectedGroupId}
on:change={() => {
if (selectedGroupId !== '') {
accessControl.read.group_ids = [
...(accessControl?.read?.group_ids ?? []),
selectedGroupId
];
selectedGroupId = '';
onChange(accessControl);
}
}}
>
<option class=" text-gray-700" value="" disabled selected
>{$i18n.t('Select a group')}</option
>
{#each groups.filter((group) => !(accessControl?.read?.group_ids ?? []).includes(group.id)) as group}
<option class=" text-gray-700" value={group.id}>{group.name}</option>
{/each}
</select>
</div>
</div>
</div>
<button
class=" rounded-full p-1 hover:bg-gray-100 dark:hover:bg-gray-850 transition"
type="button"
on:click={() => {
removePrincipal('group', group.id);
}}
>
<XMark className="size-4" />
</button>
</div>
</div>
</div>
{/if}
{/each}
<!-- Users -->
{#each selectedUsers as user}
<div
class="flex items-center gap-3 justify-between text-sm w-full transition border-b border-gray-50 dark:border-gray-850 pb-2 last:border-0"
>
<div class="flex items-center gap-2 w-full flex-1">
<img
class="rounded-full size-5 object-cover"
src={`${WEBUI_API_BASE_URL}/users/${user.id}/profile/image`}
alt={user.name ?? user.id}
/>
<div class="w-full">
<Tooltip content={user.email} placement="top-start">
<div class="truncate text-sm">{user.name ?? user.id}</div>
</Tooltip>
</div>
</div>
<div class="w-full flex justify-end items-center gap-2">
<button
type="button"
on:click={() => {
if (accessRoles.includes('write')) {
togglePrincipalWrite('user', user.id);
}
}}
>
{#if writeUserIds.includes(user.id)}
<Badge type={'success'} content={$i18n.t('Write')} />
{:else}
<Badge type={'info'} content={$i18n.t('Read')} />
{/if}
</button>
<button
class=" rounded-full p-1 hover:bg-gray-100 dark:hover:bg-gray-850 transition"
type="button"
on:click={() => {
removePrincipal('user', user.id);
}}
>
<XMark className="size-4" />
</button>
</div>
</div>
{/each}
{#if !hasPublicReadGrant(accessGrants ?? []) && accessGroups.length === 0 && selectedUsers.length === 0}
<div class="text-xs text-gray-500 text-center py-4">
{$i18n.t('No access grants. Private to you.')}
</div>
{/if}
</div>
{/if}
</div>
@@ -1,4 +1,4 @@
<script>
<script lang="ts">
import { getContext } from 'svelte';
const i18n = getContext('i18n');
@@ -6,8 +6,16 @@
import AccessControl from './AccessControl.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
type AccessGrant = {
id?: string;
principal_type: 'user' | 'group';
principal_id: string;
permission: 'read' | 'write';
};
export let show = false;
export let accessControl = {};
export let accessGrants: AccessGrant[] = [];
export let accessControl: any = undefined;
export let accessRoles = ['read'];
export let share = true;
@@ -33,7 +41,14 @@
</div>
<div class="w-full px-5 pb-4 dark:text-white">
<AccessControl bind:accessControl {onChange} {accessRoles} {share} {sharePublic} />
<AccessControl
bind:accessGrants
bind:accessControl
{onChange}
{accessRoles}
{share}
{sharePublic}
/>
</div>
</div>
</Modal>
@@ -35,7 +35,7 @@
content: updatedPrompt.content,
version_id: updatedPrompt.version_id,
tags: updatedPrompt.tags,
access_control: updatedPrompt?.access_control === undefined ? {} : updatedPrompt?.access_control
access_grants: updatedPrompt?.access_grants === undefined ? [] : updatedPrompt?.access_grants
};
}
};
@@ -59,7 +59,7 @@
content: _prompt.content,
version_id: _prompt.version_id,
tags: _prompt.tags,
access_control: _prompt?.access_control === undefined ? {} : _prompt?.access_control
access_grants: _prompt?.access_grants === undefined ? [] : _prompt?.access_grants
};
} else {
goto('/workspace/prompts');
@@ -10,10 +10,11 @@
import PromptEditor from '$lib/components/workspace/Prompts/PromptEditor.svelte';
let prompt: {
title: string;
name: string;
command: string;
content: string;
access_control: any | null;
tags: string[];
access_grants: any[];
} | null = null;
let clone = false;
@@ -50,7 +51,7 @@
command: _prompt.command,
content: _prompt.content,
tags: _prompt.tags || [],
access_control: _prompt.access_control !== undefined ? _prompt.access_control : {}
access_grants: _prompt.access_grants !== undefined ? _prompt.access_grants : []
};
});
@@ -70,7 +71,7 @@
command: _prompt.command,
content: _prompt.content,
tags: _prompt.tags || [],
access_control: _prompt.access_control !== undefined ? _prompt.access_control : {}
access_grants: _prompt.access_grants !== undefined ? _prompt.access_grants : []
};
}
});
@@ -37,7 +37,7 @@
name: data.name,
meta: data.meta,
content: data.content,
access_control: data.access_control
access_grants: data.access_grants
}).catch((error) => {
toast.error(`${error}`);
return null;
@@ -87,7 +87,7 @@
name={tool?.name ?? ''}
meta={tool?.meta ?? { description: '' }}
content={tool?.content ?? ''}
accessControl={tool?.access_control !== undefined ? tool.access_control : {}}
accessGrants={tool?.access_grants !== undefined ? tool.access_grants : []}
{clone}
onSave={(value) => {
saveHandler(value);
@@ -37,7 +37,7 @@
name: data.name,
meta: data.meta,
content: data.content,
access_control: data.access_control
access_grants: data.access_grants
}).catch((error) => {
toast.error(`${error}`);
return null;
@@ -83,7 +83,7 @@
name={tool.name}
meta={tool.meta}
content={tool.content}
accessControl={tool.access_control}
accessGrants={tool.access_grants ?? []}
onSave={(value) => {
saveHandler(value);
}}