refac: group members frontend integration

This commit is contained in:
Timothy Jaeryang Baek 2025-11-18 03:44:26 -05:00
parent 0a72d047ef
commit 73734b186b
5 changed files with 152 additions and 32 deletions

View file

@ -6,7 +6,7 @@ from open_webui.internal.db import Base, JSONField, get_db
from open_webui.env import DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL from open_webui.env import DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL
from open_webui.models.chats import Chats from open_webui.models.chats import Chats
from open_webui.models.groups import Groups from open_webui.models.groups import Groups, GroupMember
from open_webui.utils.misc import throttle from open_webui.utils.misc import throttle
@ -95,8 +95,12 @@ class UpdateProfileForm(BaseModel):
date_of_birth: Optional[datetime.date] = None date_of_birth: Optional[datetime.date] = None
class UserGroupIdsModel(UserModel):
group_ids: list[str] = []
class UserListResponse(BaseModel): class UserListResponse(BaseModel):
users: list[UserModel] users: list[UserGroupIdsModel]
total: int total: int
@ -222,7 +226,10 @@ class UsersTable:
limit: Optional[int] = None, limit: Optional[int] = None,
) -> dict: ) -> dict:
with get_db() as db: with get_db() as db:
query = db.query(User) # Join GroupMember so we can order by group_id when requested
query = db.query(User).outerjoin(
GroupMember, GroupMember.user_id == User.id
)
if filter: if filter:
query_key = filter.get("query") query_key = filter.get("query")
@ -237,7 +244,16 @@ class UsersTable:
order_by = filter.get("order_by") order_by = filter.get("order_by")
direction = filter.get("direction") direction = filter.get("direction")
if order_by == "name": if order_by and order_by.startswith("group_id:"):
group_id = order_by.split(":", 1)[1]
if direction == "asc":
query = query.order_by((GroupMember.group_id == group_id).asc())
else:
query = query.order_by(
(GroupMember.group_id == group_id).desc()
)
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:

View file

@ -16,6 +16,7 @@ from open_webui.models.groups import Groups
from open_webui.models.chats import Chats from open_webui.models.chats import Chats
from open_webui.models.users import ( from open_webui.models.users import (
UserModel, UserModel,
UserGroupIdsModel,
UserListResponse, UserListResponse,
UserInfoListResponse, UserInfoListResponse,
UserIdNameListResponse, UserIdNameListResponse,
@ -91,7 +92,25 @@ async def get_users(
if direction: if direction:
filter["direction"] = direction filter["direction"] = direction
return Users.get_users(filter=filter, skip=skip, limit=limit) result = Users.get_users(filter=filter, skip=skip, limit=limit)
users = result["users"]
total = result["total"]
return {
"users": [
UserGroupIdsModel(
**{
**user.model_dump(),
"group_ids": [
group.id for group in Groups.get_groups_by_member_id(user.id)
],
}
)
for user in users
],
"total": total,
}
@router.get("/all", response_model=UserInfoListResponse) @router.get("/all", response_model=UserInfoListResponse)

View file

@ -160,3 +160,73 @@ export const deleteGroupById = async (token: string, id: string) => {
return res; return res;
}; };
export const addUserToGroup = async (token: string, id: string, userIds: string[]) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/groups/id/${id}/users/add`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({
user_ids: userIds
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const removeUserFromGroup = async (token: string, id: string, userIds: string[]) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/groups/id/${id}/users/remove`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({
user_ids: userIds
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};

View file

@ -219,7 +219,7 @@
<div class=" self-center mr-2"> <div class=" self-center mr-2">
<UserPlusSolid /> <UserPlusSolid />
</div> </div>
<div class=" self-center">{$i18n.t('Users')} ({userCount})</div> <div class=" self-center">{$i18n.t('Users')}</div>
</button> </button>
{/if} {/if}
</div> </div>
@ -232,7 +232,7 @@
{:else if selectedTab == 'permissions'} {:else if selectedTab == 'permissions'}
<Permissions bind:permissions {defaultPermissions} /> <Permissions bind:permissions {defaultPermissions} />
{:else if selectedTab == 'users'} {:else if selectedTab == 'users'}
<Users bind:userCount /> <Users bind:userCount groupId={group?.id} />
{/if} {/if}
</div> </div>
</div> </div>

View file

@ -2,18 +2,18 @@
import { getContext } from 'svelte'; import { getContext } from 'svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
import { getUsers } from '$lib/apis/users';
import { toast } from 'svelte-sonner';
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import { WEBUI_BASE_URL } from '$lib/constants';
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 { getUsers } from '$lib/apis/users';
import { toast } from 'svelte-sonner';
import Pagination from '$lib/components/common/Pagination.svelte'; import Pagination from '$lib/components/common/Pagination.svelte';
import { addUserToGroup, removeUserFromGroup } from '$lib/apis/groups';
export let groupId: string;
export let userCount = 0; export let userCount = 0;
let userIds = [];
let users = []; let users = [];
let total = 0; let total = 0;
@ -23,7 +23,13 @@
const getUserList = async () => { const getUserList = async () => {
try { try {
const res = await getUsers(localStorage.token, query, null, null, page).catch((error) => { const res = await getUsers(
localStorage.token,
query,
`group_id:${groupId}`,
null,
page
).catch((error) => {
toast.error(`${error}`); toast.error(`${error}`);
return null; return null;
}); });
@ -37,6 +43,23 @@
} }
}; };
const toggleMember = async (userId, state) => {
if (state === 'checked') {
await addUserToGroup(localStorage.token, groupId, [userId]).catch((error) => {
toast.error(`${error}`);
return null;
});
} else {
await removeUserFromGroup(localStorage.token, groupId, [userId]).catch((error) => {
toast.error(`${error}`);
return null;
});
}
page = 1;
getUserList();
};
$: if (page) { $: if (page) {
getUserList(); getUserList();
} }
@ -50,9 +73,9 @@
} }
</script> </script>
<div class=" max-h-full"> <div class=" max-h-full h-full w-full flex flex-col">
<div class="flex w-full"> <div class="w-full h-fit">
<div class="flex flex-1"> <div class="flex flex-1 h-fit">
<div class=" self-center mr-3"> <div class=" self-center mr-3">
<Search /> <Search />
</div> </div>
@ -64,20 +87,16 @@
</div> </div>
</div> </div>
<div class="mt-3 overflow-y-auto"> <div class="mt-3 overflow-y-auto flex-1">
<div class="flex flex-col gap-2.5"> <div class="flex flex-col gap-2.5">
{#if users.length > 0} {#if users.length > 0}
{#each users as user, userIdx (user.id)} {#each users as user, userIdx (user.id)}
<div class="flex flex-row items-center gap-3 w-full text-sm"> <div class="flex flex-row items-center gap-3 w-full text-sm">
<div class="flex items-center"> <div class="flex items-center">
<Checkbox <Checkbox
state={userIds.includes(user.id) ? 'checked' : 'unchecked'} state={(user?.group_ids ?? []).includes(groupId) ? 'checked' : 'unchecked'}
on:change={(e) => { on:change={(e) => {
if (e.detail === 'checked') { toggleMember(user.id, e.detail);
userIds = [...userIds, user.id];
} else {
userIds = userIds.filter((id) => id !== user.id);
}
}} }}
/> />
</div> </div>
@ -89,20 +108,12 @@
</div> </div>
</Tooltip> </Tooltip>
{#if userIds.includes(user.id)} {#if (user?.group_ids ?? []).includes(groupId)}
<Badge type="success" content="member" /> <Badge type="success" content="member" />
{/if} {/if}
</div> </div>
</div> </div>
{/each} {/each}
{page}
{total}
{#if total > 30}
<Pagination bind:page count={total} perPage={30} />
{/if}
{:else} {:else}
<div class="text-gray-500 text-xs text-center py-2 px-10"> <div class="text-gray-500 text-xs text-center py-2 px-10">
{$i18n.t('No users were found.')} {$i18n.t('No users were found.')}
@ -110,4 +121,8 @@
{/if} {/if}
</div> </div>
</div> </div>
{#if total > 30}
<Pagination bind:page count={total} perPage={30} />
{/if}
</div> </div>