| <script lang="ts"> |
| import { getContext, onMount } from 'svelte'; |
|
|
| const i18n = getContext('i18n'); |
|
|
| import { getGroups, getGroupById, getGroupInfoById } from '$lib/apis/groups'; |
| import { 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'; |
| import Switch from '$lib/components/common/Switch.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 accessGrants: AccessGrant[] | any = []; |
| export let accessControl: any = undefined; |
|
|
| export let share = true; |
| export let sharePublic = true; |
| export let shareUsers = true; |
|
|
| let groups: any[] = []; |
| const resolvingGroupIds = new Set<string>(); |
| let userById: Record<string, any> = {}; |
| const resolvingUserIds = new Set<string>(); |
|
|
| let showAddAccessModal = false; |
|
|
| 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' |
| } |
| ]; |
| } |
|
|
| 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 hasPublicWriteGrant = (grants: AccessGrant[]): boolean => |
| grants.some( |
| (grant) => |
| grant.principal_type === 'user' && |
| grant.principal_id === '*' && |
| grant.permission === 'write' |
| ); |
|
|
| 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 === '*') |
| ); |
| if (isPublic) { |
| filtered.push({ |
| principal_type: 'user', |
| principal_id: '*', |
| permission: 'read' |
| }); |
| } |
| commitAccessGrants(filtered); |
| }; |
|
|
| const togglePublicWrite = () => { |
| let next = [...currentGrants()]; |
| if (hasPublicWriteGrant(next)) { |
| next = next.filter( |
| (grant) => |
| !( |
| grant.principal_type === 'user' && |
| grant.principal_id === '*' && |
| grant.permission === 'write' |
| ) |
| ); |
| } else { |
| next = upsertPrincipalGrant('user', '*', 'write', next); |
| } |
| commitAccessGrants(next); |
| }; |
|
|
| 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); |
| }; |
|
|
| |
| |
| 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 () => { |
| console.log('AccessControl mounted', { accessGrants, accessControl }); |
| const res = await getGroups(localStorage.token, true).catch((error) => { |
| console.error(error); |
| return []; |
| }); |
|
|
| 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> |
|
|
| <AddAccessModal bind:show={showAddAccessModal} {shareUsers} onAdd={handleAddAccess} /> |
|
|
| <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 !hasPublicReadGrant(accessGrants ?? [])} |
| <svg |
| xmlns="http://www.w3.org/2000/svg" |
| fill="none" |
| viewBox="0 0 24 24" |
| stroke-width="1.5" |
| stroke="currentColor" |
| class="w-5 h-5" |
| > |
| <path |
| stroke-linecap="round" |
| stroke-linejoin="round" |
| d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" |
| /> |
| </svg> |
| {:else} |
| <svg |
| xmlns="http://www.w3.org/2000/svg" |
| fill="none" |
| viewBox="0 0 24 24" |
| stroke-width="1.5" |
| stroke="currentColor" |
| class="w-5 h-5" |
| > |
| <path |
| stroke-linecap="round" |
| stroke-linejoin="round" |
| d="M6.115 5.19l.319 1.913A6 6 0 008.11 10.36L9.75 12l-.387.775c-.217.433-.132.956.21 1.298l1.348 1.348c.21.21.329.497.329.795v1.089c0 .426.24.815.622 1.006l.153.076c.433.217.956.132 1.298-.21l.723-.723a8.7 8.7 0 002.288-4.042 1.087 1.087 0 00-.358-1.099l-1.33-1.108c-.251-.21-.582-.299-.905-.245l-1.17.195a1.125 1.125 0 01-.98-.314l-.295-.295a1.125 1.125 0 010-1.591l.13-.132a1.125 1.125 0 011.3-.21l.603.302a.809.809 0 001.086-1.086L14.25 7.5l1.256-.837a4.5 4.5 0 001.528-1.732l.146-.292M6.115 5.19A9 9 0 1017.18 4.64M6.115 5.19A8.965 8.965 0 0112 3c1.929 0 3.716.607 5.18 1.64" |
| /> |
| </svg> |
| {/if} |
| </div> |
| </div> |
| |
| <div> |
| <Tooltip |
| content={!(share && sharePublic) && !hasPublicReadGrant(accessGrants ?? []) |
| ? $i18n.t('You do not have permission to make this public') |
| : ''} |
| > |
| <select |
| id="models" |
| class="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 !hasPublicReadGrant(accessGrants ?? [])} |
| {$i18n.t('Only select users and groups with permission can access')} |
| {:else} |
| {$i18n.t('Accessible to all users')} |
| {/if} |
| </div> |
| </div> |
| </div> |
| |
| {#if hasPublicReadGrant(accessGrants ?? []) && accessRoles.includes('write')} |
| <div class="flex w-full justify-between mt-2 ml-0.5"> |
| <div class="self-center text-xs"> |
| {$i18n.t('Allow public write access')} |
| </div> |
| <Switch |
| state={hasPublicWriteGrant(accessGrants ?? [])} |
| on:change={() => { |
| togglePublicWrite(); |
| }} |
| /> |
| </div> |
| {/if} |
| </div> |
| |
| {#if share} |
| <div class="flex items-center justify-between text-xs font-medium text-gray-500 my-1"> |
| <div> |
| {$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> |
| |
| |
| <div class="flex flex-col gap-2"> |
| |
| {#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"> |
| |
| <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> |
| |
| <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> |
| |
| <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> |
| {/each} |
| |
| |
| {#if shareUsers} |
| {#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} |
| |
| {#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> |
| |