mirror of https://github.com/open-webui/open-webui
622 lines
18 KiB
Svelte
622 lines
18 KiB
Svelte
<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';
|
|
|
|
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 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 () => {
|
|
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>
|
|
</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>
|
|
|
|
<!-- 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>
|
|
|
|
<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}
|
|
|
|
<!-- Users -->
|
|
{#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>
|