feat/refac: group members db table (#19239)

* refac: group members table db migration

* refac: group members backend

* refac: group members frontend

* refac: group members frontend integration

* refac: styling
This commit is contained in:
Tim Baek 2025-11-18 03:59:56 -05:00 committed by GitHub
commit 34684e7e58
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 600 additions and 207 deletions

View file

@ -0,0 +1,146 @@
"""add_group_member_table
Revision ID: 37f288994c47
Revises: a5c220713937
Create Date: 2025-11-17 03:45:25.123939
"""
import uuid
import time
import json
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "37f288994c47"
down_revision: Union[str, None] = "a5c220713937"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 1. Create new table
op.create_table(
"group_member",
sa.Column("id", sa.Text(), primary_key=True, unique=True, nullable=False),
sa.Column(
"group_id",
sa.Text(),
sa.ForeignKey("group.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"user_id",
sa.Text(),
sa.ForeignKey("user.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("created_at", sa.BigInteger(), nullable=True),
sa.Column("updated_at", sa.BigInteger(), nullable=True),
sa.UniqueConstraint("group_id", "user_id", name="uq_group_member_group_user"),
)
connection = op.get_bind()
# 2. Read existing group with user_ids JSON column
group_table = sa.Table(
"group",
sa.MetaData(),
sa.Column("id", sa.Text()),
sa.Column("user_ids", sa.JSON()), # JSON stored as text in SQLite + PG
)
results = connection.execute(
sa.select(group_table.c.id, group_table.c.user_ids)
).fetchall()
print(results)
# 3. Insert members into group_member table
gm_table = sa.Table(
"group_member",
sa.MetaData(),
sa.Column("id", sa.Text()),
sa.Column("group_id", sa.Text()),
sa.Column("user_id", sa.Text()),
sa.Column("created_at", sa.BigInteger()),
sa.Column("updated_at", sa.BigInteger()),
)
now = int(time.time())
for group_id, user_ids in results:
if not user_ids:
continue
if isinstance(user_ids, str):
try:
user_ids = json.loads(user_ids)
except Exception:
continue # skip invalid JSON
if not isinstance(user_ids, list):
continue
rows = [
{
"id": str(uuid.uuid4()),
"group_id": group_id,
"user_id": uid,
"created_at": now,
"updated_at": now,
}
for uid in user_ids
]
if rows:
connection.execute(gm_table.insert(), rows)
# 4. Optionally drop the old column
with op.batch_alter_table("group") as batch:
batch.drop_column("user_ids")
def downgrade():
# Reverse: restore user_ids column
with op.batch_alter_table("group") as batch:
batch.add_column(sa.Column("user_ids", sa.JSON()))
connection = op.get_bind()
gm_table = sa.Table(
"group_member",
sa.MetaData(),
sa.Column("group_id", sa.Text()),
sa.Column("user_id", sa.Text()),
sa.Column("created_at", sa.BigInteger()),
sa.Column("updated_at", sa.BigInteger()),
)
group_table = sa.Table(
"group",
sa.MetaData(),
sa.Column("id", sa.Text()),
sa.Column("user_ids", sa.JSON()),
)
# Build JSON arrays again
results = connection.execute(sa.select(group_table.c.id)).fetchall()
for (group_id,) in results:
members = connection.execute(
sa.select(gm_table.c.user_id).where(gm_table.c.group_id == group_id)
).fetchall()
member_ids = [m[0] for m in members]
connection.execute(
group_table.update()
.where(group_table.c.id == group_id)
.values(user_ids=member_ids)
)
# Drop the new table
op.drop_table("group_member")

View file

@ -11,7 +11,7 @@ from open_webui.models.files import FileMetadataResponse
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, String, Text, JSON, func from sqlalchemy import BigInteger, Column, String, Text, JSON, func, ForeignKey
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -35,7 +35,6 @@ class Group(Base):
meta = Column(JSON, nullable=True) meta = Column(JSON, nullable=True)
permissions = Column(JSON, nullable=True) permissions = Column(JSON, nullable=True)
user_ids = Column(JSON, nullable=True)
created_at = Column(BigInteger) created_at = Column(BigInteger)
updated_at = Column(BigInteger) updated_at = Column(BigInteger)
@ -53,12 +52,33 @@ class GroupModel(BaseModel):
meta: Optional[dict] = None meta: Optional[dict] = None
permissions: Optional[dict] = None permissions: Optional[dict] = None
user_ids: list[str] = []
created_at: int # timestamp in epoch created_at: int # timestamp in epoch
updated_at: int # timestamp in epoch updated_at: int # timestamp in epoch
class GroupMember(Base):
__tablename__ = "group_member"
id = Column(Text, unique=True, primary_key=True)
group_id = Column(
Text,
ForeignKey("group.id", ondelete="CASCADE"),
nullable=False,
)
user_id = Column(Text, nullable=False)
created_at = Column(BigInteger, nullable=True)
updated_at = Column(BigInteger, nullable=True)
class GroupMemberModel(BaseModel):
id: str
group_id: str
user_id: str
created_at: Optional[int] = None # timestamp in epoch
updated_at: Optional[int] = None # timestamp in epoch
#################### ####################
# Forms # Forms
#################### ####################
@ -72,7 +92,7 @@ class GroupResponse(BaseModel):
permissions: Optional[dict] = None permissions: Optional[dict] = None
data: Optional[dict] = None data: Optional[dict] = None
meta: Optional[dict] = None meta: Optional[dict] = None
user_ids: list[str] = [] member_count: Optional[int] = None
created_at: int # timestamp in epoch created_at: int # timestamp in epoch
updated_at: int # timestamp in epoch updated_at: int # timestamp in epoch
@ -87,7 +107,7 @@ class UserIdsForm(BaseModel):
user_ids: Optional[list[str]] = None user_ids: Optional[list[str]] = None
class GroupUpdateForm(GroupForm, UserIdsForm): class GroupUpdateForm(GroupForm):
pass pass
@ -131,12 +151,8 @@ class GroupTable:
return [ return [
GroupModel.model_validate(group) GroupModel.model_validate(group)
for group in db.query(Group) for group in db.query(Group)
.filter( .join(GroupMember, GroupMember.group_id == Group.id)
func.json_array_length(Group.user_ids) > 0 .filter(GroupMember.user_id == user_id)
) # Ensure array exists
.filter(
Group.user_ids.cast(String).like(f'%"{user_id}"%')
) # String-based check
.order_by(Group.updated_at.desc()) .order_by(Group.updated_at.desc())
.all() .all()
] ]
@ -149,12 +165,46 @@ class GroupTable:
except Exception: except Exception:
return None return None
def get_group_user_ids_by_id(self, id: str) -> Optional[str]: def get_group_user_ids_by_id(self, id: str) -> Optional[list[str]]:
group = self.get_group_by_id(id) with get_db() as db:
if group: members = (
return group.user_ids db.query(GroupMember.user_id).filter(GroupMember.group_id == id).all()
else: )
return None
if not members:
return None
return [m[0] for m in members]
def set_group_user_ids_by_id(self, group_id: str, user_ids: list[str]) -> None:
with get_db() as db:
# Delete existing members
db.query(GroupMember).filter(GroupMember.group_id == group_id).delete()
# Insert new members
now = int(time.time())
new_members = [
GroupMember(
id=str(uuid.uuid4()),
group_id=group_id,
user_id=user_id,
created_at=now,
updated_at=now,
)
for user_id in user_ids
]
db.add_all(new_members)
db.commit()
def get_group_member_count_by_id(self, id: str) -> int:
with get_db() as db:
count = (
db.query(func.count(GroupMember.user_id))
.filter(GroupMember.group_id == id)
.scalar()
)
return count if count else 0
def update_group_by_id( def update_group_by_id(
self, id: str, form_data: GroupUpdateForm, overwrite: bool = False self, id: str, form_data: GroupUpdateForm, overwrite: bool = False
@ -195,20 +245,29 @@ class GroupTable:
def remove_user_from_all_groups(self, user_id: str) -> bool: def remove_user_from_all_groups(self, user_id: str) -> bool:
with get_db() as db: with get_db() as db:
try: try:
groups = self.get_groups_by_member_id(user_id) # Find all groups the user belongs to
groups = (
db.query(Group)
.join(GroupMember, GroupMember.group_id == Group.id)
.filter(GroupMember.user_id == user_id)
.all()
)
# Remove the user from each group
for group in groups: for group in groups:
group.user_ids.remove(user_id) db.query(GroupMember).filter(
db.query(Group).filter_by(id=group.id).update( GroupMember.group_id == group.id, GroupMember.user_id == user_id
{ ).delete()
"user_ids": group.user_ids,
"updated_at": int(time.time()),
}
)
db.commit()
db.query(Group).filter_by(id=group.id).update(
{"updated_at": int(time.time())}
)
db.commit()
return True return True
except Exception: except Exception:
db.rollback()
return False return False
def create_groups_by_group_names( def create_groups_by_group_names(
@ -246,37 +305,61 @@ class GroupTable:
def sync_groups_by_group_names(self, user_id: str, group_names: list[str]) -> bool: def sync_groups_by_group_names(self, user_id: str, group_names: list[str]) -> bool:
with get_db() as db: with get_db() as db:
try: try:
groups = db.query(Group).filter(Group.name.in_(group_names)).all() now = int(time.time())
group_ids = [group.id for group in groups]
# Remove user from groups not in the new list # 1. Groups that SHOULD contain the user
existing_groups = self.get_groups_by_member_id(user_id) target_groups = (
db.query(Group).filter(Group.name.in_(group_names)).all()
)
target_group_ids = {g.id for g in target_groups}
for group in existing_groups: # 2. Groups the user is CURRENTLY in
if group.id not in group_ids: existing_group_ids = {
group.user_ids.remove(user_id) g.id
db.query(Group).filter_by(id=group.id).update( for g in db.query(Group)
{ .join(GroupMember, GroupMember.group_id == Group.id)
"user_ids": group.user_ids, .filter(GroupMember.user_id == user_id)
"updated_at": int(time.time()), .all()
} }
# 3. Determine adds + removals
groups_to_add = target_group_ids - existing_group_ids
groups_to_remove = existing_group_ids - target_group_ids
# 4. Remove in one bulk delete
if groups_to_remove:
db.query(GroupMember).filter(
GroupMember.user_id == user_id,
GroupMember.group_id.in_(groups_to_remove),
).delete(synchronize_session=False)
db.query(Group).filter(Group.id.in_(groups_to_remove)).update(
{"updated_at": now}, synchronize_session=False
)
# 5. Bulk insert missing memberships
for group_id in groups_to_add:
db.add(
GroupMember(
id=str(uuid.uuid4()),
group_id=group_id,
user_id=user_id,
created_at=now,
updated_at=now,
) )
)
# Add user to new groups if groups_to_add:
for group in groups: db.query(Group).filter(Group.id.in_(groups_to_add)).update(
if user_id not in group.user_ids: {"updated_at": now}, synchronize_session=False
group.user_ids.append(user_id) )
db.query(Group).filter_by(id=group.id).update(
{
"user_ids": group.user_ids,
"updated_at": int(time.time()),
}
)
db.commit() db.commit()
return True return True
except Exception as e: except Exception as e:
log.exception(e) log.exception(e)
db.rollback()
return False return False
def add_users_to_group( def add_users_to_group(
@ -288,21 +371,31 @@ class GroupTable:
if not group: if not group:
return None return None
group_user_ids = group.user_ids now = int(time.time())
if not group_user_ids or not isinstance(group_user_ids, list):
group_user_ids = []
group_user_ids = list(set(group_user_ids)) # Deduplicate for user_id in user_ids or []:
try:
db.add(
GroupMember(
id=str(uuid.uuid4()),
group_id=id,
user_id=user_id,
created_at=now,
updated_at=now,
)
)
db.flush() # Detect unique constraint violation early
except Exception:
db.rollback() # Clear failed INSERT
db.begin() # Start a new transaction
continue # Duplicate → ignore
for user_id in user_ids: group.updated_at = now
if user_id not in group_user_ids:
group_user_ids.append(user_id)
group.user_ids = group_user_ids
group.updated_at = int(time.time())
db.commit() db.commit()
db.refresh(group) db.refresh(group)
return GroupModel.model_validate(group) return GroupModel.model_validate(group)
except Exception as e: except Exception as e:
log.exception(e) log.exception(e)
return None return None
@ -316,23 +409,22 @@ class GroupTable:
if not group: if not group:
return None return None
group_user_ids = group.user_ids if not user_ids:
if not group_user_ids or not isinstance(group_user_ids, list):
return GroupModel.model_validate(group) return GroupModel.model_validate(group)
group_user_ids = list(set(group_user_ids)) # Deduplicate # Remove each user from group_member
for user_id in user_ids: for user_id in user_ids:
if user_id in group_user_ids: db.query(GroupMember).filter(
group_user_ids.remove(user_id) GroupMember.group_id == id, GroupMember.user_id == user_id
).delete()
group.user_ids = group_user_ids # Update group timestamp
group.updated_at = int(time.time()) group.updated_at = int(time.time())
db.commit() db.commit()
db.refresh(group) db.refresh(group)
return GroupModel.model_validate(group) return GroupModel.model_validate(group)
except Exception as e: except Exception as e:
log.exception(e) log.exception(e)
return None return None

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

@ -33,9 +33,18 @@ router = APIRouter()
@router.get("/", response_model=list[GroupResponse]) @router.get("/", response_model=list[GroupResponse])
async def get_groups(user=Depends(get_verified_user)): async def get_groups(user=Depends(get_verified_user)):
if user.role == "admin": if user.role == "admin":
return Groups.get_groups() groups = Groups.get_groups()
else: else:
return Groups.get_groups_by_member_id(user.id) groups = Groups.get_groups_by_member_id(user.id)
return [
GroupResponse(
**group.model_dump(),
member_count=Groups.get_group_member_count_by_id(group.id),
)
for group in groups
if group
]
############################ ############################
@ -48,7 +57,10 @@ async def create_new_group(form_data: GroupForm, user=Depends(get_admin_user)):
try: try:
group = Groups.insert_new_group(user.id, form_data) group = Groups.insert_new_group(user.id, form_data)
if group: if group:
return group return GroupResponse(
**group.model_dump(),
member_count=Groups.get_group_member_count_by_id(group.id),
)
else: else:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
@ -71,7 +83,10 @@ async def create_new_group(form_data: GroupForm, user=Depends(get_admin_user)):
async def get_group_by_id(id: str, user=Depends(get_admin_user)): async def get_group_by_id(id: str, user=Depends(get_admin_user)):
group = Groups.get_group_by_id(id) group = Groups.get_group_by_id(id)
if group: if group:
return group return GroupResponse(
**group.model_dump(),
member_count=Groups.get_group_member_count_by_id(group.id),
)
else: else:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
@ -89,12 +104,12 @@ async def update_group_by_id(
id: str, form_data: GroupUpdateForm, user=Depends(get_admin_user) id: str, form_data: GroupUpdateForm, user=Depends(get_admin_user)
): ):
try: try:
if form_data.user_ids:
form_data.user_ids = Users.get_valid_user_ids(form_data.user_ids)
group = Groups.update_group_by_id(id, form_data) group = Groups.update_group_by_id(id, form_data)
if group: if group:
return group return GroupResponse(
**group.model_dump(),
member_count=Groups.get_group_member_count_by_id(group.id),
)
else: else:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
@ -123,7 +138,10 @@ async def add_user_to_group(
group = Groups.add_users_to_group(id, form_data.user_ids) group = Groups.add_users_to_group(id, form_data.user_ids)
if group: if group:
return group return GroupResponse(
**group.model_dump(),
member_count=Groups.get_group_member_count_by_id(group.id),
)
else: else:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
@ -144,7 +162,10 @@ async def remove_users_from_group(
try: try:
group = Groups.remove_users_from_group(id, form_data.user_ids) group = Groups.remove_users_from_group(id, form_data.user_ids)
if group: if group:
return group return GroupResponse(
**group.model_dump(),
member_count=Groups.get_group_member_count_by_id(group.id),
)
else: else:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,

View file

@ -349,8 +349,10 @@ def user_to_scim(user: UserModel, request: Request) -> SCIMUser:
def group_to_scim(group: GroupModel, request: Request) -> SCIMGroup: def group_to_scim(group: GroupModel, request: Request) -> SCIMGroup:
"""Convert internal Group model to SCIM Group""" """Convert internal Group model to SCIM Group"""
member_ids = Groups.get_group_user_ids_by_id(group.id)
members = [] members = []
for user_id in group.user_ids:
for user_id in member_ids:
user = Users.get_user_by_id(user_id) user = Users.get_user_by_id(user_id)
if user: if user:
members.append( members.append(
@ -796,9 +798,11 @@ async def create_group(
update_form = GroupUpdateForm( update_form = GroupUpdateForm(
name=new_group.name, name=new_group.name,
description=new_group.description, description=new_group.description,
user_ids=member_ids,
) )
Groups.update_group_by_id(new_group.id, update_form) Groups.update_group_by_id(new_group.id, update_form)
Groups.set_group_user_ids_by_id(new_group.id, member_ids)
new_group = Groups.get_group_by_id(new_group.id) new_group = Groups.get_group_by_id(new_group.id)
return group_to_scim(new_group, request) return group_to_scim(new_group, request)
@ -830,7 +834,7 @@ async def update_group(
# Handle members if provided # Handle members if provided
if group_data.members is not None: if group_data.members is not None:
member_ids = [member.value for member in group_data.members] member_ids = [member.value for member in group_data.members]
update_form.user_ids = member_ids Groups.set_group_user_ids_by_id(group_id, member_ids)
# Update group # Update group
updated_group = Groups.update_group_by_id(group_id, update_form) updated_group = Groups.update_group_by_id(group_id, update_form)
@ -863,7 +867,6 @@ async def patch_group(
update_form = GroupUpdateForm( update_form = GroupUpdateForm(
name=group.name, name=group.name,
description=group.description, description=group.description,
user_ids=group.user_ids.copy() if group.user_ids else [],
) )
for operation in patch_data.Operations: for operation in patch_data.Operations:
@ -876,21 +879,22 @@ async def patch_group(
update_form.name = value update_form.name = value
elif path == "members": elif path == "members":
# Replace all members # Replace all members
update_form.user_ids = [member["value"] for member in value] Groups.set_group_user_ids_by_id(
group_id, [member["value"] for member in value]
)
elif op == "add": elif op == "add":
if path == "members": if path == "members":
# Add members # Add members
if isinstance(value, list): if isinstance(value, list):
for member in value: for member in value:
if isinstance(member, dict) and "value" in member: if isinstance(member, dict) and "value" in member:
if member["value"] not in update_form.user_ids: Groups.add_users_to_group(group_id, [member["value"]])
update_form.user_ids.append(member["value"])
elif op == "remove": elif op == "remove":
if path and path.startswith("members[value eq"): if path and path.startswith("members[value eq"):
# Remove specific member # Remove specific member
member_id = path.split('"')[1] member_id = path.split('"')[1]
if member_id in update_form.user_ids: Groups.remove_users_from_group(group_id, [member_id])
update_form.user_ids.remove(member_id)
# Update group # Update group
updated_group = Groups.update_group_by_id(group_id, update_form) updated_group = Groups.update_group_by_id(group_id, update_form)

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

@ -1137,22 +1137,21 @@ class OAuthManager:
f"Removing user from group {group_model.name} as it is no longer in their oauth groups" f"Removing user from group {group_model.name} as it is no longer in their oauth groups"
) )
user_ids = group_model.user_ids Groups.remove_users_from_group(group_model.id, [user.id])
user_ids = [i for i in user_ids if i != user.id]
# In case a group is created, but perms are never assigned to the group by hitting "save" # In case a group is created, but perms are never assigned to the group by hitting "save"
group_permissions = group_model.permissions group_permissions = group_model.permissions
if not group_permissions: if not group_permissions:
group_permissions = default_permissions group_permissions = default_permissions
update_form = GroupUpdateForm(
name=group_model.name,
description=group_model.description,
permissions=group_permissions,
user_ids=user_ids,
)
Groups.update_group_by_id( Groups.update_group_by_id(
id=group_model.id, form_data=update_form, overwrite=False id=group_model.id,
form_data=GroupUpdateForm(
name=group_model.name,
description=group_model.description,
permissions=group_permissions,
),
overwrite=False,
) )
# Add user to new groups # Add user to new groups
@ -1168,22 +1167,21 @@ class OAuthManager:
f"Adding user to group {group_model.name} as it was found in their oauth groups" f"Adding user to group {group_model.name} as it was found in their oauth groups"
) )
user_ids = group_model.user_ids Groups.add_users_to_group(group_model.id, [user.id])
user_ids.append(user.id)
# In case a group is created, but perms are never assigned to the group by hitting "save" # In case a group is created, but perms are never assigned to the group by hitting "save"
group_permissions = group_model.permissions group_permissions = group_model.permissions
if not group_permissions: if not group_permissions:
group_permissions = default_permissions group_permissions = default_permissions
update_form = GroupUpdateForm(
name=group_model.name,
description=group_model.description,
permissions=group_permissions,
user_ids=user_ids,
)
Groups.update_group_by_id( Groups.update_group_by_id(
id=group_model.id, form_data=update_form, overwrite=False id=group_model.id,
form_data=GroupUpdateForm(
name=group_model.name,
description=group_model.description,
permissions=group_permissions,
),
overwrite=False,
) )
async def _process_picture_url( async def _process_picture_url(

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

@ -33,9 +33,6 @@
let loaded = false; let loaded = false;
let users = [];
let total = 0;
let groups = []; let groups = [];
let filteredGroups; let filteredGroups;
@ -93,16 +90,6 @@
return; return;
} }
const res = await getAllUsers(localStorage.token).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
users = res.users;
total = res.total;
}
defaultPermissions = await getUserDefaultPermissions(localStorage.token); defaultPermissions = await getUserDefaultPermissions(localStorage.token);
await setGroups(); await setGroups();
loaded = true; loaded = true;
@ -189,7 +176,7 @@
{#each filteredGroups as group} {#each filteredGroups as group}
<div class="my-2"> <div class="my-2">
<GroupItem {group} {users} {setGroups} {defaultPermissions} /> <GroupItem {group} {setGroups} {defaultPermissions} />
</div> </div>
{/each} {/each}
</div> </div>

View file

@ -8,6 +8,9 @@
export let name = ''; export let name = '';
export let color = ''; export let color = '';
export let description = ''; export let description = '';
export let edit = false;
export let onDelete: Function = () => {};
</script> </script>
<div class="flex gap-2"> <div class="flex gap-2">
@ -59,3 +62,18 @@
/> />
</div> </div>
</div> </div>
{#if edit}
<div class="flex flex-col w-full mt-2">
<div class=" mb-0.5 text-xs text-gray-500">{$i18n.t('Actions')}</div>
<div class="flex-1">
<button
class="text-xs bg-transparent hover:underline cursor-pointer"
on:click={() => onDelete()}
>
{$i18n.t('Delete')}
</button>
</div>
</div>
{/if}

View file

@ -19,7 +19,6 @@
export let show = false; export let show = false;
export let edit = false; export let edit = false;
export let users = [];
export let group = null; export let group = null;
export let defaultPermissions = {}; export let defaultPermissions = {};
@ -31,6 +30,8 @@
let loading = false; let loading = false;
let showDeleteConfirmDialog = false; let showDeleteConfirmDialog = false;
let userCount = 0;
export let name = ''; export let name = '';
export let description = ''; export let description = '';
@ -83,7 +84,6 @@
notes: true notes: true
} }
}; };
export let userIds = [];
const submitHandler = async () => { const submitHandler = async () => {
loading = true; loading = true;
@ -91,8 +91,7 @@
const group = { const group = {
name, name,
description, description,
permissions, permissions
user_ids: userIds
}; };
await onSubmit(group); await onSubmit(group);
@ -107,7 +106,7 @@
description = group.description; description = group.description;
permissions = group?.permissions ?? {}; permissions = group?.permissions ?? {};
userIds = group?.user_ids ?? []; userCount = group?.member_count ?? 0;
} }
}; };
@ -129,7 +128,7 @@
}} }}
/> />
<Modal size="md" bind:show> <Modal size="lg" bind:show>
<div> <div>
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 mb-1.5"> <div class=" flex justify-between dark:text-gray-100 px-5 pt-4 mb-1.5">
<div class=" text-lg font-medium self-center font-primary"> <div class=" text-lg font-medium self-center font-primary">
@ -228,20 +227,27 @@
<div class=" self-center mr-2"> <div class=" self-center mr-2">
<UserPlusSolid /> <UserPlusSolid />
</div> </div>
<div class=" self-center">{$i18n.t('Users')} ({userIds.length})</div> <div class=" self-center">{$i18n.t('Users')}</div>
</button> </button>
{/if} {/if}
</div> </div>
<div <div
class="flex-1 mt-1 lg:mt-1 lg:h-[22rem] lg:max-h-[22rem] overflow-y-auto scrollbar-hidden" class="flex-1 mt-1 lg:mt-1 lg:h-[30rem] lg:max-h-[30rem] overflow-y-auto scrollbar-hidden"
> >
{#if selectedTab == 'general'} {#if selectedTab == 'general'}
<Display bind:name bind:description /> <Display
bind:name
bind:description
{edit}
onDelete={() => {
showDeleteConfirmDialog = true;
}}
/>
{: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:userIds {users} /> <Users bind:userCount groupId={group?.id} />
{/if} {/if}
</div> </div>
</div> </div>
@ -295,37 +301,25 @@
{/if} {/if}
</div> --> </div> -->
<div class="flex justify-between pt-3 text-sm font-medium gap-1.5"> {#if ['general', 'permissions'].includes(selectedTab)}
{#if edit} <div class="flex justify-end pt-3 text-sm font-medium gap-1.5">
<button <button
class="px-3.5 py-1.5 text-sm font-medium dark:bg-black dark:hover:bg-gray-900 dark:text-white bg-white text-black hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center" class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center {loading
type="button" ? ' cursor-not-allowed'
on:click={() => { : ''}"
showDeleteConfirmDialog = true; type="submit"
}} disabled={loading}
> >
{$i18n.t('Delete')} {$i18n.t('Save')}
{#if loading}
<div class="ml-2 self-center">
<Spinner />
</div>
{/if}
</button> </button>
{:else} </div>
<div></div> {/if}
{/if}
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center {loading
? ' cursor-not-allowed'
: ''}"
type="submit"
disabled={loading}
>
{$i18n.t('Save')}
{#if loading}
<div class="ml-2 self-center">
<Spinner />
</div>
{/if}
</button>
</div>
</form> </form>
</div> </div>
</div> </div>

View file

@ -12,7 +12,6 @@
import UserCircleSolid from '$lib/components/icons/UserCircleSolid.svelte'; import UserCircleSolid from '$lib/components/icons/UserCircleSolid.svelte';
import GroupModal from './EditGroupModal.svelte'; import GroupModal from './EditGroupModal.svelte';
export let users = [];
export let group = { export let group = {
name: 'Admins', name: 'Admins',
user_ids: [1, 2, 3] user_ids: [1, 2, 3]
@ -58,7 +57,6 @@
<GroupModal <GroupModal
bind:show={showEdit} bind:show={showEdit}
edit edit
{users}
{group} {group}
{defaultPermissions} {defaultPermissions}
onSubmit={updateHandler} onSubmit={updateHandler}
@ -81,7 +79,7 @@
</div> </div>
<div class="flex items-center gap-1.5 w-fit font-medium text-right justify-end"> <div class="flex items-center gap-1.5 w-fit font-medium text-right justify-end">
{group.user_ids.length} {group?.member_count}
<div> <div>
<User className="size-3.5" /> <User className="size-3.5" />

View file

@ -2,50 +2,80 @@
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 Pagination from '$lib/components/common/Pagination.svelte';
import { addUserToGroup, removeUserFromGroup } from '$lib/apis/groups';
export let users = []; export let groupId: string;
export let userIds = []; export let userCount = 0;
let filteredUsers = []; let users = [];
let total = 0;
$: filteredUsers = users
.filter((user) => {
if (query === '') {
return true;
}
return (
user.name.toLowerCase().includes(query.toLowerCase()) ||
user.email.toLowerCase().includes(query.toLowerCase())
);
})
.sort((a, b) => {
const aUserIndex = userIds.indexOf(a.id);
const bUserIndex = userIds.indexOf(b.id);
// Compare based on userIds or fall back to alphabetical order
if (aUserIndex !== -1 && bUserIndex === -1) return -1; // 'a' has valid userId -> prioritize
if (bUserIndex !== -1 && aUserIndex === -1) return 1; // 'b' has valid userId -> prioritize
// Both a and b are either in the userIds array or not, so we'll sort them by their indices
if (aUserIndex !== -1 && bUserIndex !== -1) return aUserIndex - bUserIndex;
// If both are not in the userIds, fallback to alphabetical sorting by name
return a.name.localeCompare(b.name);
});
let query = ''; let query = '';
let page = 1;
const getUserList = async () => {
try {
const res = await getUsers(
localStorage.token,
query,
`group_id:${groupId}`,
null,
page
).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
users = res.users;
total = res.total;
}
} catch (err) {
console.error(err);
}
};
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) {
getUserList();
}
$: if (query !== null) {
getUserList();
}
$: if (query) {
page = 1;
}
</script> </script>
<div> <div class=" max-h-full h-full w-full flex flex-col overflow-y-hidden">
<div class="flex w-full"> <div class="w-full h-fit mb-1.5">
<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>
@ -57,20 +87,16 @@
</div> </div>
</div> </div>
<div class="mt-3 scrollbar-hidden"> <div class="flex-1 overflow-y-auto scrollbar-hidden">
<div class="flex flex-col gap-2.5"> <div class="flex flex-col gap-2.5">
{#if filteredUsers.length > 0} {#if users.length > 0}
{#each filteredUsers 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>
@ -82,7 +108,7 @@
</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>
@ -95,4 +121,8 @@
{/if} {/if}
</div> </div>
</div> </div>
{#if total > 30}
<Pagination bind:page count={total} perPage={30} />
{/if}
</div> </div>