From f589b7c1895a6a77166c047891acfa21bc0936c4 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 30 Nov 2025 08:24:27 -0500 Subject: [PATCH] feat/enh: group channel --- backend/open_webui/config.py | 7 +- ...pdate_channel_and_channel_members_table.py | 81 ++++++ backend/open_webui/models/channels.py | 164 +++++++++-- backend/open_webui/routers/channels.py | 130 +++++++-- backend/open_webui/routers/users.py | 20 +- src/lib/apis/channels/index.ts | 1 + src/lib/apis/users/index.ts | 30 +- .../admin/Users/Groups/EditGroupModal.svelte | 3 +- .../admin/Users/Groups/Permissions.svelte | 67 +++-- .../channel/ChannelInfoModal.svelte | 2 +- src/lib/components/channel/Navbar.svelte | 6 +- src/lib/components/layout/Sidebar.svelte | 19 +- .../layout/Sidebar/ChannelItem.svelte | 13 +- .../layout/Sidebar/ChannelModal.svelte | 136 +++++++-- .../workspace/common/MemberSelector.svelte | 269 ++++++++++++++++++ .../workspace/common/UserListSelector.svelte | 247 ---------------- .../workspace/common/Visibility.svelte | 4 +- 17 files changed, 830 insertions(+), 369 deletions(-) create mode 100644 backend/open_webui/migrations/versions/90ef40d4714e_update_channel_and_channel_members_table.py create mode 100644 src/lib/components/workspace/common/MemberSelector.svelte delete mode 100644 src/lib/components/workspace/common/UserListSelector.svelte diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 54ca0218d7..a3a9050f78 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -1455,6 +1455,10 @@ USER_PERMISSIONS_FEATURES_NOTES = ( os.environ.get("USER_PERMISSIONS_FEATURES_NOTES", "True").lower() == "true" ) +USER_PERMISSIONS_FEATURES_CHANNELS = ( + os.environ.get("USER_PERMISSIONS_FEATURES_CHANNELS", "True").lower() == "true" +) + USER_PERMISSIONS_FEATURES_API_KEYS = ( os.environ.get("USER_PERMISSIONS_FEATURES_API_KEYS", "False").lower() == "true" ) @@ -1509,8 +1513,9 @@ DEFAULT_USER_PERMISSIONS = { "features": { # General features "api_keys": USER_PERMISSIONS_FEATURES_API_KEYS, - "folders": USER_PERMISSIONS_FEATURES_FOLDERS, "notes": USER_PERMISSIONS_FEATURES_NOTES, + "folders": USER_PERMISSIONS_FEATURES_FOLDERS, + "channels": USER_PERMISSIONS_FEATURES_CHANNELS, "direct_tool_servers": USER_PERMISSIONS_FEATURES_DIRECT_TOOL_SERVERS, # Chat features "web_search": USER_PERMISSIONS_FEATURES_WEB_SEARCH, diff --git a/backend/open_webui/migrations/versions/90ef40d4714e_update_channel_and_channel_members_table.py b/backend/open_webui/migrations/versions/90ef40d4714e_update_channel_and_channel_members_table.py new file mode 100644 index 0000000000..8c52a4b22a --- /dev/null +++ b/backend/open_webui/migrations/versions/90ef40d4714e_update_channel_and_channel_members_table.py @@ -0,0 +1,81 @@ +"""Update channel and channel members table + +Revision ID: 90ef40d4714e +Revises: b10670c03dd5 +Create Date: 2025-11-30 06:33:38.790341 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import open_webui.internal.db + + +# revision identifiers, used by Alembic. +revision: str = "90ef40d4714e" +down_revision: Union[str, None] = "b10670c03dd5" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Update 'channel' table + op.add_column("channel", sa.Column("is_private", sa.Boolean(), nullable=True)) + + op.add_column("channel", sa.Column("archived_at", sa.BigInteger(), nullable=True)) + op.add_column("channel", sa.Column("archived_by", sa.Text(), nullable=True)) + + op.add_column("channel", sa.Column("deleted_at", sa.BigInteger(), nullable=True)) + op.add_column("channel", sa.Column("deleted_by", sa.Text(), nullable=True)) + + op.add_column("channel", sa.Column("updated_by", sa.Text(), nullable=True)) + + # Update 'channel_member' table + op.add_column("channel_member", sa.Column("role", sa.Text(), nullable=True)) + op.add_column("channel_member", sa.Column("invited_by", sa.Text(), nullable=True)) + op.add_column( + "channel_member", sa.Column("invited_at", sa.BigInteger(), nullable=True) + ) + + # Create 'channel_webhook' table + op.create_table( + "channel_webhook", + sa.Column("id", sa.Text(), primary_key=True, unique=True, nullable=False), + sa.Column("user_id", sa.Text(), nullable=False), + sa.Column( + "channel_id", + sa.Text(), + sa.ForeignKey("channel.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("name", sa.Text(), nullable=False), + sa.Column("profile_image_url", sa.Text(), nullable=True), + sa.Column("token", sa.Text(), nullable=False), + sa.Column("last_used_at", sa.BigInteger(), nullable=True), + sa.Column("created_at", sa.BigInteger(), nullable=False), + sa.Column("updated_at", sa.BigInteger(), nullable=False), + ) + + pass + + +def downgrade() -> None: + # Downgrade 'channel' table + op.drop_column("channel", "is_private") + op.drop_column("channel", "archived_at") + op.drop_column("channel", "archived_by") + op.drop_column("channel", "deleted_at") + op.drop_column("channel", "deleted_by") + op.drop_column("channel", "updated_by") + + # Downgrade 'channel_member' table + op.drop_column("channel_member", "role") + op.drop_column("channel_member", "invited_by") + op.drop_column("channel_member", "invited_at") + + # Drop 'channel_webhook' table + op.drop_table("channel_webhook") + + pass diff --git a/backend/open_webui/models/channels.py b/backend/open_webui/models/channels.py index 5d452b0216..23d8c407d9 100644 --- a/backend/open_webui/models/channels.py +++ b/backend/open_webui/models/channels.py @@ -4,6 +4,7 @@ import uuid from typing import Optional from open_webui.internal.db import Base, get_db +from open_webui.models.groups import Groups from open_webui.utils.access_control import has_access from pydantic import BaseModel, ConfigDict @@ -26,12 +27,23 @@ class Channel(Base): name = Column(Text) description = Column(Text, nullable=True) + # Used to indicate if the channel is private (for 'group' type channels) + is_private = Column(Boolean, nullable=True) + data = Column(JSON, nullable=True) meta = Column(JSON, nullable=True) access_control = Column(JSON, nullable=True) created_at = Column(BigInteger) + updated_at = Column(BigInteger) + updated_by = Column(Text, nullable=True) + + archived_at = Column(BigInteger, nullable=True) + archived_by = Column(Text, nullable=True) + + deleted_at = Column(BigInteger, nullable=True) + deleted_by = Column(Text, nullable=True) class ChannelModel(BaseModel): @@ -39,17 +51,28 @@ class ChannelModel(BaseModel): id: str user_id: str + type: Optional[str] = None name: str description: Optional[str] = None + is_private: Optional[bool] = None + data: Optional[dict] = None meta: Optional[dict] = None access_control: Optional[dict] = None created_at: int # timestamp in epoch (time_ns) + updated_at: int # timestamp in epoch (time_ns) + updated_by: Optional[str] = None + + archived_at: Optional[int] = None # timestamp in epoch (time_ns) + archived_by: Optional[str] = None + + deleted_at: Optional[int] = None # timestamp in epoch (time_ns) + deleted_by: Optional[str] = None class ChannelMember(Base): @@ -59,7 +82,9 @@ class ChannelMember(Base): channel_id = Column(Text, nullable=False) user_id = Column(Text, nullable=False) + role = Column(Text, nullable=True) status = Column(Text, nullable=True) + is_active = Column(Boolean, nullable=False, default=True) is_channel_muted = Column(Boolean, nullable=False, default=False) @@ -68,6 +93,9 @@ class ChannelMember(Base): data = Column(JSON, nullable=True) meta = Column(JSON, nullable=True) + invited_at = Column(BigInteger, nullable=True) + invited_by = Column(Text, nullable=True) + joined_at = Column(BigInteger) left_at = Column(BigInteger, nullable=True) @@ -84,7 +112,9 @@ class ChannelMemberModel(BaseModel): channel_id: str user_id: str + role: Optional[str] = None status: Optional[str] = None + is_active: bool = True is_channel_muted: bool = False @@ -93,6 +123,9 @@ class ChannelMemberModel(BaseModel): data: Optional[dict] = None meta: Optional[dict] = None + invited_at: Optional[int] = None # timestamp in epoch (time_ns) + invited_by: Optional[str] = None + joined_at: Optional[int] = None # timestamp in epoch (time_ns) left_at: Optional[int] = None # timestamp in epoch (time_ns) @@ -102,6 +135,40 @@ class ChannelMemberModel(BaseModel): updated_at: Optional[int] = None # timestamp in epoch (time_ns) +class ChannelWebhook(Base): + __tablename__ = "channel_webhook" + + id = Column(Text, primary_key=True, unique=True) + channel_id = Column(Text, nullable=False) + user_id = Column(Text, nullable=False) + + name = Column(Text, nullable=False) + profile_image_url = Column(Text, nullable=True) + + token = Column(Text, nullable=False) + last_used_at = Column(BigInteger, nullable=True) + + created_at = Column(BigInteger, nullable=False) + updated_at = Column(BigInteger, nullable=False) + + +class ChannelWebhookModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + channel_id: str + user_id: str + + name: str + profile_image_url: Optional[str] = None + + token: str + last_used_at: Optional[int] = None # timestamp in epoch (time_ns) + + created_at: int # timestamp in epoch (time_ns) + updated_at: int # timestamp in epoch (time_ns) + + #################### # Forms #################### @@ -113,18 +180,72 @@ class ChannelResponse(ChannelModel): class ChannelForm(BaseModel): - type: Optional[str] = None - name: str + name: str = "" description: Optional[str] = None + is_private: Optional[bool] = None data: Optional[dict] = None meta: Optional[dict] = None access_control: Optional[dict] = None + group_ids: Optional[list[str]] = None user_ids: Optional[list[str]] = None +class CreateChannelForm(ChannelForm): + type: Optional[str] = None + + class ChannelTable: + + def _create_memberships_by_user_ids_and_group_ids( + self, + channel_id: str, + invited_by: str, + user_ids: Optional[list[str]] = None, + group_ids: Optional[list[str]] = None, + ) -> list[ChannelMemberModel]: + # For group and direct message channels, automatically add the specified users as members + user_ids = user_ids or [] + if invited_by not in user_ids: + user_ids.append(invited_by) # Ensure the creator is also a member + + # Add users from specified groups + group_ids = group_ids or [] + for group_id in group_ids: + group_user_ids = Groups.get_group_user_ids_by_id(group_id) + for uid in group_user_ids: + if uid not in user_ids: + user_ids.append(uid) + + # Ensure uniqueness + user_ids = list(set(user_ids)) + + memberships = [] + for uid in user_ids: + channel_member = ChannelMemberModel( + **{ + "id": str(uuid.uuid4()), + "channel_id": channel_id, + "user_id": uid, + "status": "joined", + "is_active": True, + "is_channel_muted": False, + "is_channel_pinned": False, + "invited_at": int(time.time_ns()), + "invited_by": invited_by, + "joined_at": int(time.time_ns()), + "left_at": None, + "last_read_at": int(time.time_ns()), + "created_at": int(time.time_ns()), + "updated_at": int(time.time_ns()), + } + ) + + memberships.append(ChannelMember(**channel_member.model_dump())) + + return memberships + def insert_new_channel( - self, form_data: ChannelForm, user_id: str + self, form_data: CreateChannelForm, user_id: str ) -> Optional[ChannelModel]: with get_db() as db: channel = ChannelModel( @@ -140,31 +261,14 @@ class ChannelTable: ) new_channel = Channel(**channel.model_dump()) - if form_data.type == "dm": - # For direct message channels, automatically add the specified users as members - user_ids = form_data.user_ids or [] - if user_id not in user_ids: - user_ids.append(user_id) # Ensure the creator is also a member - - for uid in user_ids: - channel_member = ChannelMemberModel( - **{ - "id": str(uuid.uuid4()), - "channel_id": channel.id, - "user_id": uid, - "status": "joined", - "is_active": True, - "is_channel_muted": False, - "is_channel_pinned": False, - "joined_at": int(time.time_ns()), - "left_at": None, - "last_read_at": int(time.time_ns()), - "created_at": int(time.time_ns()), - "updated_at": int(time.time_ns()), - } - ) - new_membership = ChannelMember(**channel_member.model_dump()) - db.add(new_membership) + if form_data.type in ["group", "dm"]: + memberships = self._create_memberships_by_user_ids_and_group_ids( + channel.id, + user_id, + form_data.user_ids, + form_data.group_ids, + ) + db.add_all(memberships) db.add(new_channel) db.commit() @@ -398,8 +502,12 @@ class ChannelTable: return None channel.name = form_data.name + channel.description = form_data.description + channel.is_private = form_data.is_private + channel.data = form_data.data channel.meta = form_data.meta + channel.access_control = form_data.access_control channel.updated_at = int(time.time_ns()) diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py index d492176a00..fa71698f90 100644 --- a/backend/open_webui/routers/channels.py +++ b/backend/open_webui/routers/channels.py @@ -26,6 +26,7 @@ from open_webui.models.channels import ( ChannelModel, ChannelForm, ChannelResponse, + CreateChannelForm, ) from open_webui.models.messages import ( Messages, @@ -53,6 +54,7 @@ from open_webui.utils.access_control import ( has_access, get_users_with_access, get_permitted_group_and_user_ids, + has_permission, ) from open_webui.utils.webhook import post_webhook from open_webui.utils.channels import extract_mentions, replace_mentions @@ -76,10 +78,16 @@ class ChannelListItemResponse(ChannelModel): @router.get("/", response_model=list[ChannelListItemResponse]) -async def get_channels(user=Depends(get_verified_user)): +async def get_channels(request: Request, user=Depends(get_verified_user)): + if user.role != "admin" and not has_permission( + user.id, "features.channels", request.app.state.config.USER_PERMISSIONS + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) channels = Channels.get_channels_by_user_id(user.id) - channel_list = [] for channel in channels: last_message = Messages.get_last_message_by_channel_id(channel.id) @@ -124,13 +132,69 @@ async def get_all_channels(user=Depends(get_verified_user)): return Channels.get_channels_by_user_id(user.id) +############################ +# GetDMChannelByUserId +############################ + + +@router.get("/dm/{user_id}", response_model=Optional[ChannelModel]) +async def get_dm_channel_by_user_id( + request: Request, user_id: str, user=Depends(get_verified_user) +): + if user.role != "admin" and not has_permission( + user.id, "features.channels", request.app.state.config.USER_PERMISSIONS + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + try: + existing_channel = Channels.get_dm_channel_by_user_ids([user.id, user_id]) + if existing_channel: + Channels.update_member_active_status(existing_channel.id, user.id, True) + return ChannelModel(**existing_channel.model_dump()) + + channel = Channels.insert_new_channel( + CreateChannelForm( + type="dm", + name="", + user_ids=[user_id], + ), + user.id, + ) + return ChannelModel(**channel.model_dump()) + except Exception as e: + log.exception(e) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() + ) + + ############################ # CreateNewChannel ############################ @router.post("/create", response_model=Optional[ChannelModel]) -async def create_new_channel(form_data: ChannelForm, user=Depends(get_admin_user)): +async def create_new_channel( + request: Request, form_data: CreateChannelForm, user=Depends(get_verified_user) +): + if user.role != "admin" and not has_permission( + user.id, "features.channels", request.app.state.config.USER_PERMISSIONS + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + if form_data.type not in ["group", "dm"] and user.role != "admin": + # Only admins can create standard channels (joined by default) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + try: if form_data.type == "dm": existing_channel = Channels.get_dm_channel_by_user_ids( @@ -173,7 +237,7 @@ async def get_channel_by_id(id: str, user=Depends(get_verified_user)): user_ids = None users = None - if channel.type == "dm": + if channel.type in ["group", "dm"]: if not Channels.is_user_channel_member(channel.id, user.id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() @@ -203,7 +267,6 @@ async def get_channel_by_id(id: str, user=Depends(get_verified_user)): "unread_count": unread_count, } ) - else: if user.role != "admin" and not has_access( user.id, type="read", access_control=channel.access_control @@ -265,7 +328,7 @@ async def get_channel_members_by_id( page = max(1, page) skip = (page - 1) * limit - if channel.type == "dm": + if channel.type in ["group", "dm"]: if not Channels.is_user_channel_member(channel.id, user.id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() @@ -275,7 +338,6 @@ async def get_channel_members_by_id( member.user_id for member in Channels.get_members_by_channel_id(channel.id) ] users = Users.get_users_by_user_ids(user_ids) - total = len(users) return { @@ -358,14 +420,27 @@ async def update_is_active_member_by_id_and_user_id( @router.post("/{id}/update", response_model=Optional[ChannelModel]) async def update_channel_by_id( - id: str, form_data: ChannelForm, user=Depends(get_admin_user) + request: Request, id: str, form_data: ChannelForm, user=Depends(get_verified_user) ): + if user.role != "admin" and not has_permission( + user.id, "features.channels", request.app.state.config.USER_PERMISSIONS + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + channel = Channels.get_channel_by_id(id) if not channel: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND ) + if channel.user_id != user.id and user.role != "admin": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() + ) + try: channel = Channels.update_channel_by_id(id, form_data) return ChannelModel(**channel.model_dump()) @@ -382,13 +457,28 @@ async def update_channel_by_id( @router.delete("/{id}/delete", response_model=bool) -async def delete_channel_by_id(id: str, user=Depends(get_admin_user)): +async def delete_channel_by_id( + request: Request, id: str, user=Depends(get_verified_user) +): + if user.role != "admin" and not has_permission( + user.id, "features.channels", request.app.state.config.USER_PERMISSIONS + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + channel = Channels.get_channel_by_id(id) if not channel: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND ) + if channel.user_id != user.id and user.role != "admin": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() + ) + try: Channels.delete_channel_by_id(id) return True @@ -418,7 +508,7 @@ async def get_channel_messages( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND ) - if channel.type == "dm": + if channel.type in ["group", "dm"]: if not Channels.is_user_channel_member(channel.id, user.id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() @@ -481,7 +571,7 @@ async def get_pinned_channel_messages( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND ) - if channel.type == "dm": + if channel.type in ["group", "dm"]: if not Channels.is_user_channel_member(channel.id, user.id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() @@ -735,7 +825,7 @@ async def new_message_handler( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND ) - if channel.type == "dm": + if channel.type in ["group", "dm"]: if not Channels.is_user_channel_member(channel.id, user.id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() @@ -751,7 +841,7 @@ async def new_message_handler( try: message = Messages.insert_new_message(form_data, channel.id, user.id) if message: - if channel.type == "dm": + if channel.type in ["group", "dm"]: members = Channels.get_members_by_channel_id(channel.id) for member in members: if not member.is_active: @@ -857,7 +947,7 @@ async def get_channel_message( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND ) - if channel.type == "dm": + if channel.type in ["group", "dm"]: if not Channels.is_user_channel_member(channel.id, user.id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() @@ -912,7 +1002,7 @@ async def pin_channel_message( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND ) - if channel.type == "dm": + if channel.type in ["group", "dm"]: if not Channels.is_user_channel_member(channel.id, user.id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() @@ -975,7 +1065,7 @@ async def get_channel_thread_messages( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND ) - if channel.type == "dm": + if channel.type in ["group", "dm"]: if not Channels.is_user_channel_member(channel.id, user.id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() @@ -1040,7 +1130,7 @@ async def update_message_by_id( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() ) - if channel.type == "dm": + if channel.type in ["group", "dm"]: if not Channels.is_user_channel_member(channel.id, user.id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() @@ -1104,7 +1194,7 @@ async def add_reaction_to_message( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND ) - if channel.type == "dm": + if channel.type in ["group", "dm"]: if not Channels.is_user_channel_member(channel.id, user.id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() @@ -1173,7 +1263,7 @@ async def remove_reaction_by_id_and_user_id_and_name( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND ) - if channel.type == "dm": + if channel.type in ["group", "dm"]: if not Channels.is_user_channel_member(channel.id, user.id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() @@ -1256,7 +1346,7 @@ async def delete_message_by_id( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() ) - if channel.type == "dm": + if channel.type in ["group", "dm"]: if not Channels.is_user_channel_member(channel.id, user.id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index 7c4b801f4d..c51916422f 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -19,7 +19,7 @@ from open_webui.models.users import ( UserGroupIdsModel, UserGroupIdsListResponse, UserInfoListResponse, - UserIdNameListResponse, + UserInfoListResponse, UserRoleUpdateForm, Users, UserSettings, @@ -102,20 +102,31 @@ async def get_all_users( return Users.get_users() -@router.get("/search", response_model=UserIdNameListResponse) +@router.get("/search", response_model=UserInfoListResponse) async def search_users( query: Optional[str] = None, + order_by: Optional[str] = None, + direction: Optional[str] = None, + page: Optional[int] = 1, user=Depends(get_verified_user), ): limit = PAGE_ITEM_COUNT - page = 1 # Always return the first page for search + page = max(1, page) skip = (page - 1) * limit filter = {} if query: filter["query"] = query + filter = {} + if query: + filter["query"] = query + if order_by: + filter["order_by"] = order_by + if direction: + filter["direction"] = direction + return Users.get_users(filter=filter, skip=skip, limit=limit) @@ -196,8 +207,9 @@ class ChatPermissions(BaseModel): class FeaturesPermissions(BaseModel): api_keys: bool = False - folders: bool = True notes: bool = True + channels: bool = True + folders: bool = True direct_tool_servers: bool = False web_search: bool = True diff --git a/src/lib/apis/channels/index.ts b/src/lib/apis/channels/index.ts index e7c6b61cf8..4b444bb6ba 100644 --- a/src/lib/apis/channels/index.ts +++ b/src/lib/apis/channels/index.ts @@ -3,6 +3,7 @@ import { WEBUI_API_BASE_URL } from '$lib/constants'; type ChannelForm = { type?: string; name: string; + is_private?: boolean; data?: object; meta?: object; access_control?: object; diff --git a/src/lib/apis/users/index.ts b/src/lib/apis/users/index.ts index ac057359a5..89e2daa104 100644 --- a/src/lib/apis/users/index.ts +++ b/src/lib/apis/users/index.ts @@ -166,11 +166,33 @@ export const getUsers = async ( return res; }; -export const getAllUsers = async (token: string) => { +export const searchUsers = async ( + token: string, + query?: string, + orderBy?: string, + direction?: string, + page = 1 +) => { let error = null; let res = null; - res = await fetch(`${WEBUI_API_BASE_URL}/users/all`, { + 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}/users/search?${searchParams.toString()}`, { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -194,11 +216,11 @@ export const getAllUsers = async (token: string) => { return res; }; -export const searchUsers = async (token: string, query: string) => { +export const getAllUsers = async (token: string) => { let error = null; let res = null; - res = await fetch(`${WEBUI_API_BASE_URL}/users/search?query=${encodeURIComponent(query)}`, { + res = await fetch(`${WEBUI_API_BASE_URL}/users/all`, { method: 'GET', headers: { 'Content-Type': 'application/json', diff --git a/src/lib/components/admin/Users/Groups/EditGroupModal.svelte b/src/lib/components/admin/Users/Groups/EditGroupModal.svelte index d105c75d50..c0896a17b9 100644 --- a/src/lib/components/admin/Users/Groups/EditGroupModal.svelte +++ b/src/lib/components/admin/Users/Groups/EditGroupModal.svelte @@ -84,8 +84,9 @@ }, features: { api_keys: false, - folders: true, notes: true, + channels: true, + folders: true, direct_tool_servers: false, web_search: true, image_generation: true, diff --git a/src/lib/components/admin/Users/Groups/Permissions.svelte b/src/lib/components/admin/Users/Groups/Permissions.svelte index f663df9d96..58c3547d33 100644 --- a/src/lib/components/admin/Users/Groups/Permissions.svelte +++ b/src/lib/components/admin/Users/Groups/Permissions.svelte @@ -54,8 +54,9 @@ }, features: { api_keys: false, - folders: true, notes: true, + channels: true, + folders: true, direct_tool_servers: false, web_search: true, image_generation: true, @@ -726,6 +727,54 @@ {/if} +
+
+
+ {$i18n.t('Notes')} +
+ +
+ {#if defaultPermissions?.features?.notes && !permissions.features.notes} +
+
+ {$i18n.t('This is a default user permission and will remain enabled.')} +
+
+ {/if} +
+ +
+
+
+ {$i18n.t('Channels')} +
+ +
+ {#if defaultPermissions?.features?.channels && !permissions.features.channels} +
+
+ {$i18n.t('This is a default user permission and will remain enabled.')} +
+
+ {/if} +
+ +
+
+
+ {$i18n.t('Folders')} +
+ +
+ {#if defaultPermissions?.features?.folders && !permissions.features.folders} +
+
+ {$i18n.t('This is a default user permission and will remain enabled.')} +
+
+ {/if} +
+
@@ -789,21 +838,5 @@
{/if}
- -
-
-
- {$i18n.t('Notes')} -
- -
- {#if defaultPermissions?.features?.notes && !permissions.features.notes} -
-
- {$i18n.t('This is a default user permission and will remain enabled.')} -
-
- {/if} -
diff --git a/src/lib/components/channel/ChannelInfoModal.svelte b/src/lib/components/channel/ChannelInfoModal.svelte index 132b2c5229..4ed79828c0 100644 --- a/src/lib/components/channel/ChannelInfoModal.svelte +++ b/src/lib/components/channel/ChannelInfoModal.svelte @@ -44,7 +44,7 @@ {:else}
- {#if channel?.access_control === null} + {#if channel?.type === 'group' ? !channel?.is_private : channel?.access_control === null} {:else} diff --git a/src/lib/components/channel/Navbar.svelte b/src/lib/components/channel/Navbar.svelte index 936718b200..67f4d92610 100644 --- a/src/lib/components/channel/Navbar.svelte +++ b/src/lib/components/channel/Navbar.svelte @@ -92,7 +92,7 @@ {/if} {:else}
- {#if channel?.access_control === null} + {#if channel?.type === 'group' ? !channel?.is_private : channel?.access_control === null} {:else} @@ -100,9 +100,7 @@
{/if} -
+
{#if channel?.name} {channel.name} {:else} diff --git a/src/lib/components/layout/Sidebar.svelte b/src/lib/components/layout/Sidebar.svelte index bb02765596..1e031112e6 100644 --- a/src/lib/components/layout/Sidebar.svelte +++ b/src/lib/components/layout/Sidebar.svelte @@ -181,9 +181,18 @@ }; const initChannels = async () => { + // default (none), group, dm type await channels.set( (await getChannels(localStorage.token)).sort((a, b) => - a.type === b.type ? 0 : a.type === 'dm' ? 1 : -1 + a.type === b.type + ? 0 + : a.type === 'dm' + ? 1 + : a.type === 'group' + ? b.type === 'dm' + ? -1 + : 0 + : -1 ) ); }; @@ -486,7 +495,7 @@ { + onSubmit={async ({ type, name, is_private, access_control, group_ids, user_ids }) => { name = name?.trim(); if (type === 'dm') { @@ -504,7 +513,9 @@ const res = await createNewChannel(localStorage.token, { type: type, name: name, + is_private: is_private, access_control: access_control, + group_ids: group_ids, user_ids: user_ids }).catch((error) => { toast.error(`${error}`); @@ -940,14 +951,14 @@ {/if} - {#if $config?.features?.enable_channels && ($user?.role === 'admin' || $channels.length > 0)} + {#if $config?.features?.enable_channels && ($user?.role === 'admin' || ($user?.permissions?.features?.channels ?? true))} { await tick(); diff --git a/src/lib/components/layout/Sidebar/ChannelItem.svelte b/src/lib/components/layout/Sidebar/ChannelItem.svelte index cf148658b8..524724c9a1 100644 --- a/src/lib/components/layout/Sidebar/ChannelItem.svelte +++ b/src/lib/components/layout/Sidebar/ChannelItem.svelte @@ -31,10 +31,13 @@ {channel} edit={true} {onUpdate} - onSubmit={async ({ name, access_control }) => { + onSubmit={async ({ name, is_private, access_control, group_ids, user_ids }) => { const res = await updateChannelById(localStorage.token, channel.id, { name, - access_control + is_private, + access_control, + group_ids, + user_ids }).catch((error) => { toast.error(error.message); }); @@ -119,7 +122,7 @@ {/if} {:else}
- {#if channel?.access_control === null} + {#if channel?.type === 'group' ? !channel?.is_private : channel?.access_control === null} {:else} @@ -154,7 +157,7 @@
- {#if channel?.type === 'dm'} + {#if ['dm'].includes(channel?.type)} - {:else if $user?.role === 'admin'} + {:else if $user?.role === 'admin' || channel.user_id === $user?.id}