diff --git a/src/lib/apis/channels/index.ts b/src/lib/apis/channels/index.ts index 225d8cd7c..5715c64e8 100644 --- a/src/lib/apis/channels/index.ts +++ b/src/lib/apis/channels/index.ts @@ -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[]; }; diff --git a/src/lib/apis/groups/index.ts b/src/lib/apis/groups/index.ts index a74c61b83..6089a6023 100644 --- a/src/lib/apis/groups/index.ts +++ b/src/lib/apis/groups/index.ts @@ -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; diff --git a/src/lib/apis/knowledge/index.ts b/src/lib/apis/knowledge/index.ts index dc9dd8b88..4c7c90484 100644 --- a/src/lib/apis/knowledge/index.ts +++ b/src/lib/apis/knowledge/index.ts @@ -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) => { diff --git a/src/lib/apis/notes/index.ts b/src/lib/apis/notes/index.ts index 55f9427e0..341ced57e 100644 --- a/src/lib/apis/notes/index.ts +++ b/src/lib/apis/notes/index.ts @@ -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) => { diff --git a/src/lib/apis/prompts/index.ts b/src/lib/apis/prompts/index.ts index e9cd6e848..c227c9f71 100644 --- a/src/lib/apis/prompts/index.ts +++ b/src/lib/apis/prompts/index.ts @@ -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; }; - diff --git a/src/lib/apis/users/index.ts b/src/lib/apis/users/index.ts index d6da54bbf..cd3b40adc 100644 --- a/src/lib/apis/users/index.ts +++ b/src/lib/apis/users/index.ts @@ -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; diff --git a/src/lib/components/channel/ChannelInfoModal.svelte b/src/lib/components/channel/ChannelInfoModal.svelte index 44094f780..cd1ee3524 100644 --- a/src/lib/components/channel/ChannelInfoModal.svelte +++ b/src/lib/components/channel/ChannelInfoModal.svelte @@ -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 @@ {:else}
- {#if channel?.type === 'group' ? !channel?.is_private : channel?.access_control === null} + {#if isPublicChannel(channel)} {:else} diff --git a/src/lib/components/channel/MessageInput/MentionList.svelte b/src/lib/components/channel/MessageInput/MentionList.svelte index 4272a5650..7daf69204 100644 --- a/src/lib/components/channel/MessageInput/MentionList.svelte +++ b/src/lib/components/channel/MessageInput/MentionList.svelte @@ -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); + }; {#if filteredItems.length} @@ -165,7 +184,7 @@ > {#if item.type === 'channel'}
- {#if item?.data?.access_control === null} + {#if isPublicChannel(item?.data)} {:else} diff --git a/src/lib/components/channel/Navbar.svelte b/src/lib/components/channel/Navbar.svelte index 6b5d7c97c..b02193b46 100644 --- a/src/lib/components/channel/Navbar.svelte +++ b/src/lib/components/channel/Navbar.svelte @@ -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}
- {#if channel?.type === 'group' ? !channel?.is_private : channel?.access_control === null} + {#if isPublicChannel(channel)} {:else} diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte index 5914e7afc..968f56c2f 100644 --- a/src/lib/components/chat/MessageInput.svelte +++ b/src/lib/components/chat/MessageInput.svelte @@ -151,7 +151,7 @@ return { ...file, user: undefined, - access_control: undefined + access_grants: undefined }; }), selectedToolIds, diff --git a/src/lib/components/layout/Sidebar.svelte b/src/lib/components/layout/Sidebar.svelte index 03591d2b7..2a1c551d4 100644 --- a/src/lib/components/layout/Sidebar.svelte +++ b/src/lib/components/layout/Sidebar.svelte @@ -560,7 +560,8 @@ { + 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) => { diff --git a/src/lib/components/layout/Sidebar/ChannelItem.svelte b/src/lib/components/layout/Sidebar/ChannelItem.svelte index 95be5abbf..e8cf1ff75 100644 --- a/src/lib/components/layout/Sidebar/ChannelItem.svelte +++ b/src/lib/components/layout/Sidebar/ChannelItem.svelte @@ -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); + }; { + 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}
- {#if channel?.type === 'group' ? !channel?.is_private : channel?.access_control === null} + {#if isPublicChannel(channel)} {:else} diff --git a/src/lib/components/layout/Sidebar/ChannelModal.svelte b/src/lib/components/layout/Sidebar/ChannelModal.svelte index 65404c056..efb62bd01 100644 --- a/src/lib/components/layout/Sidebar/ChannelModal.svelte +++ b/src/lib/components/layout/Sidebar/ChannelModal.svelte @@ -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'}
{#if type === ''} - + {:else if type === 'group'} { + onChange={(value: string) => { if (value === 'private') { isPrivate = true; } else { diff --git a/src/lib/components/notes/NoteEditor.svelte b/src/lib/components/notes/NoteEditor.svelte index 977d00ef7..708e7e852 100644 --- a/src/lib/components/notes/NoteEditor.svelte +++ b/src/lib/components/notes/NoteEditor.svelte @@ -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} { 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'} > - {note?.access_control ? $i18n.t('Private') : $i18n.t('Everyone')} + + {hasPublicReadGrant(note?.access_grants) + ? $i18n.t('Everyone') + : $i18n.t('Private')} + {:else}
diff --git a/src/lib/components/notes/Notes.svelte b/src/lib/components/notes/Notes.svelte index 3ca43e6ee..504bd3021 100644 --- a/src/lib/components/notes/Notes.svelte +++ b/src/lib/components/notes/Notes.svelte @@ -129,7 +129,7 @@ } }, meta: null, - access_control: {} + access_grants: [] }).catch((error) => { toast.error(`${error}`); return null; diff --git a/src/lib/components/notes/utils.ts b/src/lib/components/notes/utils.ts index 052c48a44..8c6bd3524 100644 --- a/src/lib/components/notes/utils.ts +++ b/src/lib/components/notes/utils.ts @@ -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; diff --git a/src/lib/components/workspace/Knowledge/CreateKnowledgeBase.svelte b/src/lib/components/workspace/Knowledge/CreateKnowledgeBase.svelte index 3373e5a66..14d70106e 100644 --- a/src/lib/components/workspace/Knowledge/CreateKnowledgeBase.svelte +++ b/src/lib/components/workspace/Knowledge/CreateKnowledgeBase.svelte @@ -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 @@
{ 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} { changeDebounceHandler(); }} diff --git a/src/lib/components/workspace/Models/ModelEditor.svelte b/src/lib/components/workspace/Models/ModelEditor.svelte index 18e26963a..9aee1a37c 100644 --- a/src/lib/components/workspace/Models/ModelEditor.svelte +++ b/src/lib/components/workspace/Models/ModelEditor.svelte @@ -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} {#if onBack} diff --git a/src/lib/components/workspace/Prompts/PromptEditor.svelte b/src/lib/components/workspace/Prompts/PromptEditor.svelte index 6f251f36c..a9a97bd5c 100644 --- a/src/lib/components/workspace/Prompts/PromptEditor.svelte +++ b/src/lib/components/workspace/Prompts/PromptEditor.svelte @@ -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 @@ diff --git a/src/lib/components/workspace/Tools/ToolkitEditor.svelte b/src/lib/components/workspace/Tools/ToolkitEditor.svelte index dd0d4a4a1..70533099c 100644 --- a/src/lib/components/workspace/Tools/ToolkitEditor.svelte +++ b/src/lib/components/workspace/Tools/ToolkitEditor.svelte @@ -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:
diff --git a/src/lib/components/workspace/common/AccessControl.svelte b/src/lib/components/workspace/common/AccessControl.svelte index 1bf4bbc1f..7e691d0e3 100644 --- a/src/lib/components/workspace/common/AccessControl.svelte +++ b/src/lib/components/workspace/common/AccessControl.svelte @@ -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(); + let userById: Record = {}; + const resolvingUserIds = new Set(); - $: 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(); + 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 }); -
-
-
{$i18n.t('Visibility')}
+ -
+
+
+
- {#if accessControl !== null} + {#if !hasPublicReadGrant(accessGrants ?? [])}
- + +
- {#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 @@
{#if share} - {#if accessControl !== null} - {@const accessGroups = groups.filter((group) => - (accessControl?.read?.group_ids ?? []).includes(group.id) - )} +
-
-
-
- {$i18n.t('Groups')} + {$i18n.t('Access List')} +
+
+ +
+
+ + +
+ + {#each accessGroups as group} +
+
+ +
+ {group.name.charAt(0).toUpperCase()} +
+ +
+ {group.name} + {group?.member_count} {$i18n.t('members')}
- {#if accessGroups.length > 0} -
- {#each accessGroups as group} -
-
-
- {group.name} {group?.member_count} -
-
+
+ -
- - - -
-
- {/each} -
- {/if} - - - -
-
-
-
- -
-
-
+
-
- {/if} + {/each} + + + {#each selectedUsers as user} +
+
+ {user.name +
+ +
{user.name ?? user.id}
+
+
+
+ +
+ + + +
+
+ {/each} + + {#if !hasPublicReadGrant(accessGrants ?? []) && accessGroups.length === 0 && selectedUsers.length === 0} +
+ {$i18n.t('No access grants. Private to you.')} +
+ {/if} +
{/if}
diff --git a/src/lib/components/workspace/common/AccessControlModal.svelte b/src/lib/components/workspace/common/AccessControlModal.svelte index 3e53c8bd0..145695282 100644 --- a/src/lib/components/workspace/common/AccessControlModal.svelte +++ b/src/lib/components/workspace/common/AccessControlModal.svelte @@ -1,4 +1,4 @@ -