dauabi / src /lib /components /workspace /common /AccessControl.svelte
github-actions[bot]
GitHub deploy: 7e224e4a536b07ec008613f06592e34050e7067c
f1f8449
<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;
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} 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="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 !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 -->
{#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>