Spaces:
Build error
Build error
<script> | |
import { WEBUI_BASE_URL } from '$lib/constants'; | |
import { WEBUI_NAME, config, user, showSidebar } from '$lib/stores'; | |
import { goto } from '$app/navigation'; | |
import { onMount, getContext } from 'svelte'; | |
import dayjs from 'dayjs'; | |
import relativeTime from 'dayjs/plugin/relativeTime'; | |
import localizedFormat from 'dayjs/plugin/localizedFormat'; | |
dayjs.extend(relativeTime); | |
dayjs.extend(localizedFormat); | |
import { toast } from 'svelte-sonner'; | |
import { updateUserRole, getUsers, deleteUserById } from '$lib/apis/users'; | |
import Pagination from '$lib/components/common/Pagination.svelte'; | |
import ChatBubbles from '$lib/components/icons/ChatBubbles.svelte'; | |
import Tooltip from '$lib/components/common/Tooltip.svelte'; | |
import EditUserModal from '$lib/components/admin/Users/UserList/EditUserModal.svelte'; | |
import UserChatsModal from '$lib/components/admin/Users/UserList/UserChatsModal.svelte'; | |
import AddUserModal from '$lib/components/admin/Users/UserList/AddUserModal.svelte'; | |
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; | |
import Badge from '$lib/components/common/Badge.svelte'; | |
import Plus from '$lib/components/icons/Plus.svelte'; | |
import ChevronUp from '$lib/components/icons/ChevronUp.svelte'; | |
import ChevronDown from '$lib/components/icons/ChevronDown.svelte'; | |
import About from '$lib/components/chat/Settings/About.svelte'; | |
import Banner from '$lib/components/common/Banner.svelte'; | |
import Markdown from '$lib/components/chat/Messages/Markdown.svelte'; | |
const i18n = getContext('i18n'); | |
export let users = []; | |
let search = ''; | |
let selectedUser = null; | |
let page = 1; | |
let showDeleteConfirmDialog = false; | |
let showAddUserModal = false; | |
let showUserChatsModal = false; | |
let showEditUserModal = false; | |
const updateRoleHandler = async (id, role) => { | |
const res = await updateUserRole(localStorage.token, id, role).catch((error) => { | |
toast.error(`${error}`); | |
return null; | |
}); | |
if (res) { | |
users = await getUsers(localStorage.token); | |
} | |
}; | |
const deleteUserHandler = async (id) => { | |
const res = await deleteUserById(localStorage.token, id).catch((error) => { | |
toast.error(`${error}`); | |
return null; | |
}); | |
if (res) { | |
users = await getUsers(localStorage.token); | |
} | |
}; | |
let sortKey = 'created_at'; // default sort key | |
let sortOrder = 'asc'; // default sort order | |
function setSortKey(key) { | |
if (sortKey === key) { | |
sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; | |
} else { | |
sortKey = key; | |
sortOrder = 'asc'; | |
} | |
} | |
let filteredUsers; | |
$: filteredUsers = users | |
.filter((user) => { | |
if (search === '') { | |
return true; | |
} else { | |
let name = user.name.toLowerCase(); | |
let email = user.email.toLowerCase(); | |
const query = search.toLowerCase(); | |
return name.includes(query) || email.includes(query); | |
} | |
}) | |
.sort((a, b) => { | |
if (a[sortKey] < b[sortKey]) return sortOrder === 'asc' ? -1 : 1; | |
if (a[sortKey] > b[sortKey]) return sortOrder === 'asc' ? 1 : -1; | |
return 0; | |
}) | |
.slice((page - 1) * 20, page * 20); | |
</script> | |
<ConfirmDialog | |
bind:show={showDeleteConfirmDialog} | |
on:confirm={() => { | |
deleteUserHandler(selectedUser.id); | |
}} | |
/> | |
{#key selectedUser} | |
<EditUserModal | |
bind:show={showEditUserModal} | |
{selectedUser} | |
sessionUser={$user} | |
on:save={async () => { | |
users = await getUsers(localStorage.token); | |
}} | |
/> | |
{/key} | |
<AddUserModal | |
bind:show={showAddUserModal} | |
on:save={async () => { | |
users = await getUsers(localStorage.token); | |
}} | |
/> | |
<UserChatsModal bind:show={showUserChatsModal} user={selectedUser} /> | |
{#if ($config?.license_metadata?.seats ?? null) !== null && users.length > $config?.license_metadata?.seats} | |
<div class=" mt-1 mb-2 text-xs text-red-500"> | |
<Banner | |
className="mx-0" | |
banner={{ | |
type: 'error', | |
title: 'License Error', | |
content: | |
'Exceeded the number of seats in your license. Please contact support to increase the number of seats.', | |
dismissable: true | |
}} | |
/> | |
</div> | |
{/if} | |
<div class="mt-0.5 mb-2 gap-1 flex flex-col md:flex-row justify-between"> | |
<div class="flex md:self-center text-lg font-medium px-0.5"> | |
<div class="flex-shrink-0"> | |
{$i18n.t('Users')} | |
</div> | |
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50 dark:bg-gray-850" /> | |
{#if ($config?.license_metadata?.seats ?? null) !== null} | |
{#if users.length > $config?.license_metadata?.seats} | |
<span class="text-lg font-medium text-red-500" | |
>{users.length} of {$config?.license_metadata?.seats} | |
<span class="text-sm font-normal">available users</span></span | |
> | |
{:else} | |
<span class="text-lg font-medium text-gray-500 dark:text-gray-300" | |
>{users.length} of {$config?.license_metadata?.seats} | |
<span class="text-sm font-normal">available users</span></span | |
> | |
{/if} | |
{:else} | |
<span class="text-lg font-medium text-gray-500 dark:text-gray-300">{users.length}</span> | |
{/if} | |
</div> | |
<div class="flex gap-1"> | |
<div class=" flex w-full space-x-2"> | |
<div class="flex flex-1"> | |
<div class=" self-center ml-1 mr-3"> | |
<svg | |
xmlns="http://www.w3.org/2000/svg" | |
viewBox="0 0 20 20" | |
fill="currentColor" | |
class="w-4 h-4" | |
> | |
<path | |
fill-rule="evenodd" | |
d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" | |
clip-rule="evenodd" | |
/> | |
</svg> | |
</div> | |
<input | |
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent" | |
bind:value={search} | |
placeholder={$i18n.t('Search')} | |
/> | |
</div> | |
<div> | |
<Tooltip content={$i18n.t('Add User')}> | |
<button | |
class=" p-2 rounded-xl hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 transition font-medium text-sm flex items-center space-x-1" | |
on:click={() => { | |
showAddUserModal = !showAddUserModal; | |
}} | |
> | |
<Plus className="size-3.5" /> | |
</button> | |
</Tooltip> | |
</div> | |
</div> | |
</div> | |
</div> | |
<div | |
class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded-sm pt-0.5" | |
> | |
<table | |
class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full rounded-sm" | |
> | |
<thead | |
class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-850 dark:text-gray-400 -translate-y-0.5" | |
> | |
<tr class=""> | |
<th | |
scope="col" | |
class="px-3 py-1.5 cursor-pointer select-none" | |
on:click={() => setSortKey('role')} | |
> | |
<div class="flex gap-1.5 items-center"> | |
{$i18n.t('Role')} | |
{#if sortKey === 'role'} | |
<span class="font-normal" | |
>{#if sortOrder === 'asc'} | |
<ChevronUp className="size-2" /> | |
{:else} | |
<ChevronDown className="size-2" /> | |
{/if} | |
</span> | |
{:else} | |
<span class="invisible"> | |
<ChevronUp className="size-2" /> | |
</span> | |
{/if} | |
</div> | |
</th> | |
<th | |
scope="col" | |
class="px-3 py-1.5 cursor-pointer select-none" | |
on:click={() => setSortKey('name')} | |
> | |
<div class="flex gap-1.5 items-center"> | |
{$i18n.t('Name')} | |
{#if sortKey === 'name'} | |
<span class="font-normal" | |
>{#if sortOrder === 'asc'} | |
<ChevronUp className="size-2" /> | |
{:else} | |
<ChevronDown className="size-2" /> | |
{/if} | |
</span> | |
{:else} | |
<span class="invisible"> | |
<ChevronUp className="size-2" /> | |
</span> | |
{/if} | |
</div> | |
</th> | |
<th | |
scope="col" | |
class="px-3 py-1.5 cursor-pointer select-none" | |
on:click={() => setSortKey('email')} | |
> | |
<div class="flex gap-1.5 items-center"> | |
{$i18n.t('Email')} | |
{#if sortKey === 'email'} | |
<span class="font-normal" | |
>{#if sortOrder === 'asc'} | |
<ChevronUp className="size-2" /> | |
{:else} | |
<ChevronDown className="size-2" /> | |
{/if} | |
</span> | |
{:else} | |
<span class="invisible"> | |
<ChevronUp className="size-2" /> | |
</span> | |
{/if} | |
</div> | |
</th> | |
<th | |
scope="col" | |
class="px-3 py-1.5 cursor-pointer select-none" | |
on:click={() => setSortKey('last_active_at')} | |
> | |
<div class="flex gap-1.5 items-center"> | |
{$i18n.t('Last Active')} | |
{#if sortKey === 'last_active_at'} | |
<span class="font-normal" | |
>{#if sortOrder === 'asc'} | |
<ChevronUp className="size-2" /> | |
{:else} | |
<ChevronDown className="size-2" /> | |
{/if} | |
</span> | |
{:else} | |
<span class="invisible"> | |
<ChevronUp className="size-2" /> | |
</span> | |
{/if} | |
</div> | |
</th> | |
<th | |
scope="col" | |
class="px-3 py-1.5 cursor-pointer select-none" | |
on:click={() => setSortKey('created_at')} | |
> | |
<div class="flex gap-1.5 items-center"> | |
{$i18n.t('Created at')} | |
{#if sortKey === 'created_at'} | |
<span class="font-normal" | |
>{#if sortOrder === 'asc'} | |
<ChevronUp className="size-2" /> | |
{:else} | |
<ChevronDown className="size-2" /> | |
{/if} | |
</span> | |
{:else} | |
<span class="invisible"> | |
<ChevronUp className="size-2" /> | |
</span> | |
{/if} | |
</div> | |
</th> | |
<th | |
scope="col" | |
class="px-3 py-1.5 cursor-pointer select-none" | |
on:click={() => setSortKey('oauth_sub')} | |
> | |
<div class="flex gap-1.5 items-center"> | |
{$i18n.t('OAuth ID')} | |
{#if sortKey === 'oauth_sub'} | |
<span class="font-normal" | |
>{#if sortOrder === 'asc'} | |
<ChevronUp className="size-2" /> | |
{:else} | |
<ChevronDown className="size-2" /> | |
{/if} | |
</span> | |
{:else} | |
<span class="invisible"> | |
<ChevronUp className="size-2" /> | |
</span> | |
{/if} | |
</div> | |
</th> | |
<th scope="col" class="px-3 py-2 text-right" /> | |
</tr> | |
</thead> | |
<tbody class=""> | |
{#each filteredUsers as user, userIdx} | |
<tr class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs"> | |
<td class="px-3 py-1 min-w-[7rem] w-28"> | |
<button | |
class=" translate-y-0.5" | |
on:click={() => { | |
if (user.role === 'user') { | |
updateRoleHandler(user.id, 'admin'); | |
} else if (user.role === 'pending') { | |
updateRoleHandler(user.id, 'user'); | |
} else { | |
updateRoleHandler(user.id, 'pending'); | |
} | |
}} | |
> | |
<Badge | |
type={user.role === 'admin' ? 'info' : user.role === 'user' ? 'success' : 'muted'} | |
content={$i18n.t(user.role)} | |
/> | |
</button> | |
</td> | |
<td class="px-3 py-1 font-medium text-gray-900 dark:text-white w-max"> | |
<div class="flex flex-row w-max"> | |
<img | |
class=" rounded-full w-6 h-6 object-cover mr-2.5" | |
src={user.profile_image_url.startsWith(WEBUI_BASE_URL) || | |
user.profile_image_url.startsWith('https://www.gravatar.com/avatar/') || | |
user.profile_image_url.startsWith('data:') | |
? user.profile_image_url | |
: `/user.png`} | |
alt="user" | |
/> | |
<div class=" font-medium self-center">{user.name}</div> | |
</div> | |
</td> | |
<td class=" px-3 py-1"> {user.email} </td> | |
<td class=" px-3 py-1"> | |
{dayjs(user.last_active_at * 1000).fromNow()} | |
</td> | |
<td class=" px-3 py-1"> | |
{dayjs(user.created_at * 1000).format('LL')} | |
</td> | |
<td class=" px-3 py-1"> {user.oauth_sub ?? ''} </td> | |
<td class="px-3 py-1 text-right"> | |
<div class="flex justify-end w-full"> | |
{#if $config.features.enable_admin_chat_access && user.role !== 'admin'} | |
<Tooltip content={$i18n.t('Chats')}> | |
<button | |
class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl" | |
on:click={async () => { | |
showUserChatsModal = !showUserChatsModal; | |
selectedUser = user; | |
}} | |
> | |
<ChatBubbles /> | |
</button> | |
</Tooltip> | |
{/if} | |
<Tooltip content={$i18n.t('Edit User')}> | |
<button | |
class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl" | |
on:click={async () => { | |
showEditUserModal = !showEditUserModal; | |
selectedUser = user; | |
}} | |
> | |
<svg | |
xmlns="http://www.w3.org/2000/svg" | |
fill="none" | |
viewBox="0 0 24 24" | |
stroke-width="1.5" | |
stroke="currentColor" | |
class="w-4 h-4" | |
> | |
<path | |
stroke-linecap="round" | |
stroke-linejoin="round" | |
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" | |
/> | |
</svg> | |
</button> | |
</Tooltip> | |
{#if user.role !== 'admin'} | |
<Tooltip content={$i18n.t('Delete User')}> | |
<button | |
class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl" | |
on:click={async () => { | |
showDeleteConfirmDialog = true; | |
selectedUser = user; | |
}} | |
> | |
<svg | |
xmlns="http://www.w3.org/2000/svg" | |
fill="none" | |
viewBox="0 0 24 24" | |
stroke-width="1.5" | |
stroke="currentColor" | |
class="w-4 h-4" | |
> | |
<path | |
stroke-linecap="round" | |
stroke-linejoin="round" | |
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" | |
/> | |
</svg> | |
</button> | |
</Tooltip> | |
{/if} | |
</div> | |
</td> | |
</tr> | |
{/each} | |
</tbody> | |
</table> | |
</div> | |
<div class=" text-gray-500 text-xs mt-1.5 text-right"> | |
โ {$i18n.t("Click on the user role button to change a user's role.")} | |
</div> | |
<Pagination bind:page count={users.length} /> | |
{#if !$config?.license_metadata} | |
{#if users.length > 50} | |
<div class="text-sm"> | |
<Markdown | |
content={` | |
> [!NOTE] | |
> # **Hey there! ๐** | |
> | |
> It looks like you have over 50 users โ that usually falls under organizational usage. | |
> | |
> Open WebUI is proudly open source and completely free, with no hidden limits โ and we'd love to keep it that way. ๐ฑ | |
> | |
> By supporting the project through sponsorship or an enterprise license, youโre not only helping us stay independent, youโre also helping us ship new features faster, improve stability, and grow the project for the long haul. With an *enterprise license*, you also get additional perks like dedicated support, customization options, and more โ all at a fraction of what it would cost to build and maintain internally. | |
> | |
> Your support helps us stay independent and continue building great tools for everyone. ๐ | |
> | |
> - ๐ **[Click here to learn more about enterprise licensing](https://docs.openwebui.com/enterprise)** | |
> - ๐ *[Click here to sponsor the project on GitHub](https://github.com/sponsors/tjbck)* | |
`} | |
/> | |
</div> | |
{/if} | |
{/if} | |