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,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>