From c0e120353824be00a2ef63cbde8be5d625bd6fd0 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 25 Nov 2025 04:38:07 -0500 Subject: [PATCH] feat: user list in channels --- backend/open_webui/models/users.py | 34 +++ backend/open_webui/routers/channels.py | 77 +++++- backend/open_webui/routers/users.py | 4 +- backend/open_webui/utils/access_control.py | 43 +++- src/lib/apis/channels/index.ts | 54 ++++ .../channel/ChannelInfoModal.svelte | 81 ++++++ .../channel/ChannelInfoModal/UserList.svelte | 230 ++++++++++++++++++ .../Messages/Message/ProfilePreview.svelte | 6 +- .../Message/UserStatusLinkPreview.svelte | 2 +- src/lib/components/channel/Navbar.svelte | 8 + 10 files changed, 521 insertions(+), 18 deletions(-) create mode 100644 src/lib/components/channel/ChannelInfoModal.svelte create mode 100644 src/lib/components/channel/ChannelInfoModal/UserList.svelte diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index 9779731a47..7df3f588cf 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -99,7 +99,16 @@ class UserGroupIdsModel(UserModel): group_ids: list[str] = [] +class UserModelResponse(UserModel): + model_config = ConfigDict(extra="allow") + + class UserListResponse(BaseModel): + users: list[UserModelResponse] + total: int + + +class UserGroupIdsListResponse(BaseModel): users: list[UserGroupIdsModel] total: int @@ -239,6 +248,31 @@ class UsersTable: ) ) + user_ids = filter.get("user_ids") + if user_ids: + query = query.filter(User.id.in_(user_ids)) + + group_ids = filter.get("group_ids") + if group_ids: + query = query.filter( + exists( + select(GroupMember.id).where( + GroupMember.user_id == User.id, + GroupMember.group_id.in_(group_ids), + ) + ) + ) + + roles = filter.get("roles") + if roles: + include_roles = [role for role in roles if not role.startswith("!")] + exclude_roles = [role[1:] for role in roles if role.startswith("!")] + + if include_roles: + query = query.filter(User.role.in_(include_roles)) + if exclude_roles: + query = query.filter(~User.role.in_(exclude_roles)) + order_by = filter.get("order_by") direction = filter.get("direction") diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py index bd688bf5d9..37b6d23dfd 100644 --- a/backend/open_webui/routers/channels.py +++ b/backend/open_webui/routers/channels.py @@ -7,8 +7,17 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status, Backgrou from pydantic import BaseModel -from open_webui.socket.main import sio, get_user_ids_from_room -from open_webui.models.users import Users, UserNameResponse +from open_webui.socket.main import ( + sio, + get_user_ids_from_room, + get_active_status_by_user_id, +) +from open_webui.models.users import ( + UserListResponse, + UserModelResponse, + Users, + UserNameResponse, +) from open_webui.models.groups import Groups from open_webui.models.channels import ( @@ -38,7 +47,11 @@ from open_webui.utils.chat import generate_chat_completion from open_webui.utils.auth import get_admin_user, get_verified_user -from open_webui.utils.access_control import has_access, get_users_with_access +from open_webui.utils.access_control import ( + has_access, + get_users_with_access, + get_permitted_group_and_user_ids, +) from open_webui.utils.webhook import post_webhook from open_webui.utils.channels import extract_mentions, replace_mentions @@ -116,6 +129,64 @@ async def get_channel_by_id(id: str, user=Depends(get_verified_user)): ) +PAGE_ITEM_COUNT = 30 + + +@router.get("/{id}/users", response_model=UserListResponse) +async def get_channel_users_by_id( + id: str, + query: Optional[str] = None, + order_by: Optional[str] = None, + direction: Optional[str] = None, + page: Optional[int] = 1, + user=Depends(get_verified_user), +): + + channel = Channels.get_channel_by_id(id) + if not channel: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND + ) + + limit = PAGE_ITEM_COUNT + + page = max(1, page) + skip = (page - 1) * limit + + filter = { + "roles": ["!pending"], + } + + if query: + filter["query"] = query + if order_by: + filter["order_by"] = order_by + if direction: + filter["direction"] = direction + + permitted_ids = get_permitted_group_and_user_ids("read", channel.access_control) + if permitted_ids: + if permitted_ids.get("user_ids"): + filter["user_ids"] = permitted_ids.get("user_ids") + if permitted_ids.get("group_ids"): + filter["group_ids"] = permitted_ids.get("group_ids") + + result = Users.get_users(filter=filter, skip=skip, limit=limit) + + users = result["users"] + total = result["total"] + + return { + "users": [ + UserModelResponse( + **user.model_dump(), is_active=get_active_status_by_user_id(user.id) + ) + for user in users + ], + "total": total, + } + + ############################ # UpdateChannelById ############################ diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index f53b0e2749..0b44e4319a 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -17,7 +17,7 @@ from open_webui.models.chats import Chats from open_webui.models.users import ( UserModel, UserGroupIdsModel, - UserListResponse, + UserGroupIdsListResponse, UserInfoListResponse, UserIdNameListResponse, UserRoleUpdateForm, @@ -76,7 +76,7 @@ async def get_active_users( PAGE_ITEM_COUNT = 30 -@router.get("/", response_model=UserListResponse) +@router.get("/", response_model=UserGroupIdsListResponse) async def get_users( query: Optional[str] = None, order_by: Optional[str] = None, diff --git a/backend/open_webui/utils/access_control.py b/backend/open_webui/utils/access_control.py index af48bebfb4..97d0b41491 100644 --- a/backend/open_webui/utils/access_control.py +++ b/backend/open_webui/utils/access_control.py @@ -105,6 +105,22 @@ def has_permission( return get_permission(default_permissions, permission_hierarchy) +def get_permitted_group_and_user_ids( + type: str = "write", access_control: Optional[dict] = None +) -> Union[Dict[str, List[str]], None]: + if access_control is None: + return None + + permission_access = access_control.get(type, {}) + permitted_group_ids = permission_access.get("group_ids", []) + permitted_user_ids = permission_access.get("user_ids", []) + + return { + "group_ids": permitted_group_ids, + "user_ids": permitted_user_ids, + } + + def has_access( user_id: str, type: str = "write", @@ -122,9 +138,12 @@ def has_access( user_groups = Groups.get_groups_by_member_id(user_id) user_group_ids = {group.id for group in user_groups} - permission_access = access_control.get(type, {}) - permitted_group_ids = permission_access.get("group_ids", []) - permitted_user_ids = permission_access.get("user_ids", []) + permitted_ids = get_permitted_group_and_user_ids(type, access_control) + if permitted_ids is None: + return False + + permitted_group_ids = permitted_ids.get("group_ids", []) + permitted_user_ids = permitted_ids.get("user_ids", []) return user_id in permitted_user_ids or any( group_id in permitted_group_ids for group_id in user_group_ids @@ -136,18 +155,20 @@ def get_users_with_access( type: str = "write", access_control: Optional[dict] = None ) -> list[UserModel]: if access_control is None: - result = Users.get_users() + result = Users.get_users(filter={"roles": ["!pending"]}) return result.get("users", []) - permission_access = access_control.get(type, {}) - permitted_group_ids = permission_access.get("group_ids", []) - permitted_user_ids = permission_access.get("user_ids", []) + permitted_ids = get_permitted_group_and_user_ids(type, access_control) + if permitted_ids is None: + return [] + + permitted_group_ids = permitted_ids.get("group_ids", []) + permitted_user_ids = permitted_ids.get("user_ids", []) user_ids_with_access = set(permitted_user_ids) - for group_id in permitted_group_ids: - group_user_ids = Groups.get_group_user_ids_by_id(group_id) - if group_user_ids: - user_ids_with_access.update(group_user_ids) + group_user_ids_map = Groups.get_group_user_ids_by_ids(permitted_group_ids) + for user_ids in group_user_ids_map.values(): + user_ids_with_access.update(user_ids) return Users.get_users_by_user_ids(list(user_ids_with_access)) diff --git a/src/lib/apis/channels/index.ts b/src/lib/apis/channels/index.ts index ac51e5a5d0..2872bd89f8 100644 --- a/src/lib/apis/channels/index.ts +++ b/src/lib/apis/channels/index.ts @@ -101,6 +101,60 @@ export const getChannelById = async (token: string = '', channel_id: string) => return res; }; +export const getChannelUsersById = async ( + token: string, + channel_id: string, + query?: string, + orderBy?: string, + direction?: string, + page = 1 +) => { + let error = null; + let res = null; + + const searchParams = new URLSearchParams(); + + searchParams.set('page', `${page}`); + + if (query) { + searchParams.set('query', query); + } + + if (orderBy) { + searchParams.set('order_by', orderBy); + } + + if (direction) { + searchParams.set('direction', direction); + } + + res = await fetch( + `${WEBUI_API_BASE_URL}/channels/${channel_id}/users?${searchParams.toString()}`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + } + ) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const updateChannelById = async ( token: string = '', channel_id: string, diff --git a/src/lib/components/channel/ChannelInfoModal.svelte b/src/lib/components/channel/ChannelInfoModal.svelte new file mode 100644 index 0000000000..76b0b0fcf0 --- /dev/null +++ b/src/lib/components/channel/ChannelInfoModal.svelte @@ -0,0 +1,81 @@ + + +{#if channel} + +
+
+
+
+
+ {#if channel?.access_control === null} + + {:else} + + {/if} +
+ +
+ {channel.name} +
+
+
+ +
+ +
+
+
{ + e.preventDefault(); + submitHandler(); + }} + > +
+ +
+
+
+
+
+
+{/if} diff --git a/src/lib/components/channel/ChannelInfoModal/UserList.svelte b/src/lib/components/channel/ChannelInfoModal/UserList.svelte new file mode 100644 index 0000000000..1df7c3b30f --- /dev/null +++ b/src/lib/components/channel/ChannelInfoModal/UserList.svelte @@ -0,0 +1,230 @@ + + +
+ {#if users === null || total === null} +
+ +
+ {:else} +
+
+
+
+ + + +
+ +
+
+
+ +
+
+
+
+ + + +
+
+
+ {#each users as user, userIdx} +
+
+
+ + user + + +
{user.name}
+
+ + {#if user?.is_active} +
+ + + + +
+ {/if} +
+
+ +
+
+ +
+
+
+ {/each} +
+
+
+ + {#if total > 30} + + {/if} + {/if} +
diff --git a/src/lib/components/channel/Messages/Message/ProfilePreview.svelte b/src/lib/components/channel/Messages/Message/ProfilePreview.svelte index 620905e5ff..8ed95e63e8 100644 --- a/src/lib/components/channel/Messages/Message/ProfilePreview.svelte +++ b/src/lib/components/channel/Messages/Message/ProfilePreview.svelte @@ -7,6 +7,10 @@ import UserStatusLinkPreview from './UserStatusLinkPreview.svelte'; export let user = null; + + export let align = 'center'; + export let side = 'right'; + export let sideOffset = 8; @@ -14,5 +18,5 @@ - + diff --git a/src/lib/components/channel/Messages/Message/UserStatusLinkPreview.svelte b/src/lib/components/channel/Messages/Message/UserStatusLinkPreview.svelte index 0660548891..93472226ed 100644 --- a/src/lib/components/channel/Messages/Message/UserStatusLinkPreview.svelte +++ b/src/lib/components/channel/Messages/Message/UserStatusLinkPreview.svelte @@ -27,7 +27,7 @@ {#if user} +