refac/fix: group member user list

This commit is contained in:
Timothy Jaeryang Baek 2025-11-25 01:37:33 -05:00
parent 38c6b0bff6
commit b1c1e68e56
2 changed files with 185 additions and 167 deletions

View file

@ -11,8 +11,8 @@ from open_webui.utils.misc import throttle
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, String, Text, Date from sqlalchemy import BigInteger, Column, String, Text, Date, exists, select
from sqlalchemy import or_ from sqlalchemy import or_, case
import datetime import datetime
@ -243,20 +243,30 @@ class UsersTable:
direction = filter.get("direction") direction = filter.get("direction")
if order_by and order_by.startswith("group_id:"): if order_by and order_by.startswith("group_id:"):
query = query.outerjoin(GroupMember, GroupMember.user_id == User.id)
group_id = order_by.split(":", 1)[1] group_id = order_by.split(":", 1)[1]
if direction == "asc": # Subquery that checks if the user belongs to the group
query = query.order_by((GroupMember.group_id == group_id).asc()) membership_exists = exists(
else: select(GroupMember.id).where(
query = query.order_by( GroupMember.user_id == User.id,
(GroupMember.group_id == group_id).desc() GroupMember.group_id == group_id,
) )
)
# CASE: user in group → 1, user not in group → 0
group_sort = case((membership_exists, 1), else_=0)
if direction == "asc":
query = query.order_by(group_sort.asc(), User.name.asc())
else:
query = query.order_by(group_sort.desc(), User.name.asc())
elif order_by == "name": elif order_by == "name":
if direction == "asc": if direction == "asc":
query = query.order_by(User.name.asc()) query = query.order_by(User.name.asc())
else: else:
query = query.order_by(User.name.desc()) query = query.order_by(User.name.desc())
elif order_by == "email": elif order_by == "email":
if direction == "asc": if direction == "asc":
query = query.order_by(User.email.asc()) query = query.order_by(User.email.asc())

View file

@ -11,21 +11,23 @@
import { getUsers } from '$lib/apis/users'; import { getUsers } from '$lib/apis/users';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { addUserToGroup, removeUserFromGroup } from '$lib/apis/groups';
import { WEBUI_API_BASE_URL } from '$lib/constants';
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import Checkbox from '$lib/components/common/Checkbox.svelte'; import Checkbox from '$lib/components/common/Checkbox.svelte';
import Badge from '$lib/components/common/Badge.svelte'; import Badge from '$lib/components/common/Badge.svelte';
import Search from '$lib/components/icons/Search.svelte'; import Search from '$lib/components/icons/Search.svelte';
import Pagination from '$lib/components/common/Pagination.svelte'; import Pagination from '$lib/components/common/Pagination.svelte';
import { addUserToGroup, removeUserFromGroup } from '$lib/apis/groups';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte'; import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte'; import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import { WEBUI_API_BASE_URL } from '$lib/constants'; import Spinner from '$lib/components/common/Spinner.svelte';
export let groupId: string; export let groupId: string;
export let userCount = 0; export let userCount = 0;
let users = []; let users = null;
let total = 0; let total = null;
let query = ''; let query = '';
let orderBy = `group_id:${groupId}`; // default sort key let orderBy = `group_id:${groupId}`; // default sort key
@ -100,163 +102,169 @@
</div> </div>
</div> </div>
{#if users.length > 0} {#if users === null || total === null}
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full"> <div class="my-10">
<table <Spinner className="size-5" />
class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full"
>
<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
<tr class=" border-b-[1.5px] border-gray-50 dark:border-gray-850">
<th
scope="col"
class="px-2.5 py-2 cursor-pointer text-left w-8"
on:click={() => setSortKey(`group_id:${groupId}`)}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('MBR')}
{#if orderBy === `group_id:${groupId}`}
<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>
{/if}
</div>
</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none"
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">
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none"
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">
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none"
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">
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</th>
</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 w-8">
<div class="flex w-full justify-center">
<Checkbox
state={(user?.group_ids ?? []).includes(groupId) ? 'checked' : 'unchecked'}
on:change={(e) => {
toggleMember(user.id, e.detail);
}}
/>
</div>
</td>
<td class="px-3 py-1 min-w-[7rem] w-28">
<div class=" translate-y-0.5">
<Badge
type={user.role === 'admin'
? 'info'
: user.role === 'user'
? 'success'
: 'muted'}
content={$i18n.t(user.role)}
/>
</div>
</td>
<td class="px-3 py-1 font-medium text-gray-900 dark:text-white max-w-48">
<Tooltip content={user.email} placement="top-start">
<div class="flex items-center">
<img
class="rounded-full w-6 h-6 object-cover mr-2.5 flex-shrink-0"
src={`${WEBUI_API_BASE_URL}/users/${user.id}/profile/image`}
alt="user"
/>
<div class="font-medium truncate">{user.name}</div>
</div>
</Tooltip>
</td>
<td class=" px-3 py-1">
{dayjs(user.last_active_at * 1000).fromNow()}
</td>
</tr>
{/each}
</tbody>
</table>
</div> </div>
{:else} {:else}
<div class="text-gray-500 text-xs text-center py-2 px-10"> {#if users.length > 0}
{$i18n.t('No users were found.')} <div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full">
</div> <table
{/if} class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full"
>
<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
<tr class=" border-b-[1.5px] border-gray-50 dark:border-gray-850">
<th
scope="col"
class="px-2.5 py-2 cursor-pointer text-left w-8"
on:click={() => setSortKey(`group_id:${groupId}`)}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('MBR')}
{#if total > 30} {#if orderBy === `group_id:${groupId}`}
<Pagination bind:page count={total} perPage={30} /> <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>
{/if}
</div>
</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none"
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">
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none"
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">
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none"
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">
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</th>
</tr>
</thead>
<tbody class="">
{#each users as user, userIdx (user?.id ?? userIdx)}
<tr class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs">
<td class=" px-3 py-1 w-8">
<div class="flex w-full justify-center">
<Checkbox
state={(user?.group_ids ?? []).includes(groupId) ? 'checked' : 'unchecked'}
on:change={(e) => {
toggleMember(user.id, e.detail);
}}
/>
</div>
</td>
<td class="px-3 py-1 min-w-[7rem] w-28">
<div class=" translate-y-0.5">
<Badge
type={user.role === 'admin'
? 'info'
: user.role === 'user'
? 'success'
: 'muted'}
content={$i18n.t(user.role)}
/>
</div>
</td>
<td class="px-3 py-1 font-medium text-gray-900 dark:text-white max-w-48">
<Tooltip content={user.email} placement="top-start">
<div class="flex items-center">
<img
class="rounded-full w-6 h-6 object-cover mr-2.5 flex-shrink-0"
src={`${WEBUI_API_BASE_URL}/users/${user.id}/profile/image`}
alt="user"
/>
<div class="font-medium truncate">{user.name}</div>
</div>
</Tooltip>
</td>
<td class=" px-3 py-1">
{dayjs(user.last_active_at * 1000).fromNow()}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<div class="text-gray-500 text-xs text-center py-2 px-10">
{$i18n.t('No users were found.')}
</div>
{/if}
{#if total > 30}
<Pagination bind:page count={total} perPage={30} />
{/if}
{/if} {/if}
</div> </div>