open-webui/src/lib/components/admin/Users/UserList.svelte

522 lines
15 KiB
Svelte
Raw Normal View History

2024-11-13 05:51:42 +00:00
<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';
2025-02-03 21:37:29 +00:00
import localizedFormat from 'dayjs/plugin/localizedFormat';
2024-11-13 05:51:42 +00:00
dayjs.extend(relativeTime);
2025-02-03 21:37:29 +00:00
dayjs.extend(localizedFormat);
2024-11-13 05:51:42 +00:00
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';
2025-05-05 12:10:36 +00:00
import RoleUpdateConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
2024-11-13 05:51:42 +00:00
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';
2025-03-04 10:45:05 +00:00
import Banner from '$lib/components/common/Banner.svelte';
2025-04-13 04:44:15 +00:00
import Markdown from '$lib/components/chat/Messages/Markdown.svelte';
2025-05-10 13:23:17 +00:00
import Spinner from '$lib/components/common/Spinner.svelte';
2024-11-13 05:51:42 +00:00
const i18n = getContext('i18n');
2025-04-30 12:49:41 +00:00
let page = 1;
2024-11-13 05:51:42 +00:00
2025-05-10 13:23:17 +00:00
let users = null;
let total = null;
2024-11-13 05:51:42 +00:00
2025-04-30 12:49:41 +00:00
let query = '';
let orderBy = 'created_at'; // default sort key
let direction = 'asc'; // default sort order
let selectedUser = null;
2024-11-13 05:51:42 +00:00
let showDeleteConfirmDialog = false;
let showAddUserModal = false;
let showUserChatsModal = false;
let showEditUserModal = false;
const deleteUserHandler = async (id) => {
const res = await deleteUserById(localStorage.token, id).catch((error) => {
2025-01-21 06:41:32 +00:00
toast.error(`${error}`);
2024-11-13 05:51:42 +00:00
return null;
});
2025-05-05 12:12:41 +00:00
// if the user is deleted and the current page has only one user, go back to the previous page
if (users.length === 1 && page > 1) {
page -= 1;
}
2024-11-13 05:51:42 +00:00
if (res) {
2025-04-30 12:49:41 +00:00
getUserList();
}
};
2024-11-13 05:51:42 +00:00
2025-04-30 12:49:41 +00:00
const setSortKey = (key) => {
if (orderBy === key) {
direction = direction === 'asc' ? 'desc' : 'asc';
2024-11-13 05:51:42 +00:00
} else {
2025-04-30 12:49:41 +00:00
orderBy = key;
direction = 'asc';
2024-11-13 05:51:42 +00:00
}
2025-04-30 12:49:41 +00:00
};
2024-11-13 05:51:42 +00:00
2025-04-30 12:49:41 +00:00
const getUserList = async () => {
try {
2025-04-30 12:49:41 +00:00
const res = await getUsers(localStorage.token, query, orderBy, direction, page).catch(
(error) => {
toast.error(`${error}`);
return null;
}
);
if (res) {
users = res.users;
total = res.total;
}
} catch (err) {
console.error(err);
}
};
2024-11-13 05:51:42 +00:00
2025-04-30 12:49:41 +00:00
$: if (page) {
getUserList();
}
$: if (query !== null && orderBy && direction) {
getUserList();
}
2024-11-13 05:51:42 +00:00
</script>
<ConfirmDialog
bind:show={showDeleteConfirmDialog}
on:confirm={() => {
deleteUserHandler(selectedUser.id);
}}
/>
{#key selectedUser}
<EditUserModal
bind:show={showEditUserModal}
{selectedUser}
sessionUser={$user}
on:save={async () => {
2025-04-30 12:49:41 +00:00
getUserList();
2024-11-13 05:51:42 +00:00
}}
/>
{/key}
<AddUserModal
bind:show={showAddUserModal}
on:save={async () => {
2025-04-30 12:49:41 +00:00
getUserList();
2024-11-13 05:51:42 +00:00
}}
/>
2025-05-24 21:44:53 +00:00
{#if selectedUser}
<UserChatsModal bind:show={showUserChatsModal} user={selectedUser} />
{/if}
2024-11-13 05:51:42 +00:00
2025-05-11 21:08:49 +00:00
{#if ($config?.license_metadata?.seats ?? null) !== null && total && total > $config?.license_metadata?.seats}
2025-03-04 10:45:05 +00:00
<div class=" mt-1 mb-2 text-xs text-red-500">
<Banner
className="mx-0"
banner={{
type: 'error',
title: 'License Error',
content:
2025-07-30 10:09:33 +00:00
'Exceeded the number of seats in your license. Please contact support to increase the number of seats.'
2025-03-04 10:45:05 +00:00
}}
/>
</div>
{/if}
2025-05-10 13:23:17 +00:00
{#if users === null || total === null}
<div class="my-10">
2025-06-27 12:15:16 +00:00
<Spinner className="size-5" />
2024-11-14 10:20:34 +00:00
</div>
2025-05-10 13:23:17 +00:00
{:else}
2025-08-04 13:28:26 +00:00
<div
2025-09-16 20:47:43 +00:00
class="pt-0.5 pb-1 gap-1 flex flex-col md:flex-row justify-between sticky top-0 z-10 bg-white dark:bg-gray-900"
2025-08-04 13:28:26 +00:00
>
2025-05-10 13:23:17 +00:00
<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" />
2024-11-13 05:51:42 +00:00
2025-05-10 13:23:17 +00:00
{#if ($config?.license_metadata?.seats ?? null) !== null}
{#if total > $config?.license_metadata?.seats}
<span class="text-lg font-medium text-red-500"
>{total} of {$config?.license_metadata?.seats}
2025-08-19 18:39:17 +00:00
<span class="text-sm font-normal">{$i18n.t('available users')}</span></span
2025-05-10 13:23:17 +00:00
>
{:else}
<span class="text-lg font-medium text-gray-500 dark:text-gray-300"
>{total} of {$config?.license_metadata?.seats}
2025-08-19 18:39:17 +00:00
<span class="text-sm font-normal">{$i18n.t('available users')}</span></span
2024-11-14 10:20:34 +00:00
>
2025-05-10 13:23:17 +00:00
{/if}
{:else}
<span class="text-lg font-medium text-gray-500 dark:text-gray-300">{total}</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={query}
placeholder={$i18n.t('Search')}
/>
2024-11-13 05:51:42 +00:00
</div>
2025-05-10 13:23:17 +00:00
<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>
2024-11-13 05:51:42 +00:00
</div>
</div>
</div>
2025-09-16 20:47:43 +00:00
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full">
2025-05-10 13:23:17 +00:00
<table
class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full rounded-sm"
2024-11-13 05:51:42 +00:00
>
2025-09-16 20:47:43 +00:00
<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
<tr class=" border-b-2 border-gray-100 dark:border-gray-800">
2025-05-10 13:23:17 +00:00
<th
scope="col"
2025-09-16 20:47:43 +00:00
class="px-2.5 py-2 cursor-pointer select-none"
2025-05-10 13:23:17 +00:00
on:click={() => setSortKey('role')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('Role')}
{#if orderBy === 'role'}
<span class="font-normal"
>{#if direction === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
2024-11-13 05:51:42 +00:00
<ChevronUp className="size-2" />
2025-05-10 13:23:17 +00:00
</span>
{/if}
</div>
</th>
<th
scope="col"
2025-09-16 20:47:43 +00:00
class="px-2.5 py-2 cursor-pointer select-none"
2025-05-10 13:23:17 +00:00
on:click={() => setSortKey('name')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('Name')}
{#if orderBy === 'name'}
<span class="font-normal"
>{#if direction === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
2024-11-13 05:51:42 +00:00
<ChevronUp className="size-2" />
2025-05-10 13:23:17 +00:00
</span>
{/if}
</div>
</th>
<th
scope="col"
2025-09-16 20:47:43 +00:00
class="px-2.5 py-2 cursor-pointer select-none"
2025-05-10 13:23:17 +00:00
on:click={() => setSortKey('email')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('Email')}
{#if orderBy === 'email'}
<span class="font-normal"
>{#if direction === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
2024-11-13 05:51:42 +00:00
<ChevronUp className="size-2" />
2025-05-10 13:23:17 +00:00
</span>
{/if}
</div>
</th>
<th
scope="col"
2025-09-16 20:47:43 +00:00
class="px-2.5 py-2 cursor-pointer select-none"
2025-05-10 13:23:17 +00:00
on:click={() => setSortKey('last_active_at')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('Last Active')}
{#if orderBy === 'last_active_at'}
<span class="font-normal"
>{#if direction === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
2024-11-13 05:51:42 +00:00
<ChevronUp className="size-2" />
2025-05-10 13:23:17 +00:00
</span>
{/if}
</div>
</th>
<th
scope="col"
2025-09-16 20:47:43 +00:00
class="px-2.5 py-2 cursor-pointer select-none"
2025-05-10 13:23:17 +00:00
on:click={() => setSortKey('created_at')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('Created at')}
{#if orderBy === 'created_at'}
<span class="font-normal"
>{#if direction === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
2024-11-13 05:51:42 +00:00
<ChevronUp className="size-2" />
2025-05-10 13:23:17 +00:00
</span>
{/if}
2024-11-13 05:51:42 +00:00
</div>
2025-05-10 13:23:17 +00:00
</th>
2024-11-13 05:51:42 +00:00
2025-05-10 13:23:17 +00:00
<th
scope="col"
2025-09-16 20:47:43 +00:00
class="px-2.5 py-2 cursor-pointer select-none"
2025-05-10 13:23:17 +00:00
on:click={() => setSortKey('oauth_sub')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('OAuth ID')}
{#if orderBy === 'oauth_sub'}
<span class="font-normal"
>{#if direction === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-2" />
</span>
2024-11-14 10:20:34 +00:00
{/if}
2025-05-10 13:23:17 +00:00
</div>
</th>
2024-11-14 10:20:34 +00:00
2025-09-16 20:47:43 +00:00
<th scope="col" class="px-2.5 py-2 text-right" />
2025-05-10 13:23:17 +00:00
</tr>
</thead>
<tbody class="">
{#each users 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={() => {
selectedUser = user;
2025-05-31 11:00:27 +00:00
showEditUserModal = !showEditUserModal;
2025-05-10 13:23:17 +00:00
}}
>
<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"
2025-07-19 19:16:44 +00:00
src={user?.profile_image_url?.startsWith(WEBUI_BASE_URL) ||
2025-05-10 13:23:17 +00:00
user.profile_image_url.startsWith('https://www.gravatar.com/avatar/') ||
user.profile_image_url.startsWith('data:')
? user.profile_image_url
: `${WEBUI_BASE_URL}/user.png`}
2025-05-10 13:23:17 +00:00
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')}>
2024-11-13 05:51:42 +00:00
<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 () => {
2025-05-10 13:23:17 +00:00
showEditUserModal = !showEditUserModal;
2024-11-13 05:51:42 +00:00
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"
2025-05-10 13:23:17 +00:00
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"
2024-11-13 05:51:42 +00:00
/>
</svg>
</button>
</Tooltip>
2025-05-10 13:23:17 +00:00
{#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>
2024-11-13 05:51:42 +00:00
{#if total > 30}
2025-08-04 11:04:16 +00:00
<Pagination bind:page count={total} perPage={30} />
{/if}
2025-05-10 13:23:17 +00:00
{/if}
2025-04-13 04:44:15 +00:00
2025-04-13 04:47:46 +00:00
{#if !$config?.license_metadata}
2025-04-30 12:49:41 +00:00
{#if total > 50}
2025-04-13 04:47:46 +00:00
<div class="text-sm">
<Markdown
content={`
2025-04-13 04:44:15 +00:00
> [!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, youre not only helping us stay independent, youre 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)*
`}
2025-04-13 04:47:46 +00:00
/>
</div>
{/if}
2025-04-13 04:44:15 +00:00
{/if}