mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-07 02:58:21 +00:00
refac
This commit is contained in:
parent
8f4f29d834
commit
d4c561d9f2
49 changed files with 1243 additions and 466 deletions
|
|
@ -855,7 +855,7 @@ async def event_target_matches(
|
|||
if user_group_ids is None:
|
||||
from open_webui.models.groups import Groups
|
||||
|
||||
groups_by_user = await Groups.get_groups_by_member_ids(list(user_ids))
|
||||
groups_by_user = await Groups.get_groups_by_member_ids(list(user_ids), include_inherited=True)
|
||||
user_group_ids = {user_id: {group.id for group in groups} for user_id, groups in groups_by_user.items()}
|
||||
|
||||
return any(group_ids.intersection(target_group_ids) for group_ids in user_group_ids.values())
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
"""Add single-parent group hierarchy.
|
||||
|
||||
Revision ID: b8e4f0a3c752
|
||||
Revises: a7d3e9f2b641
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = 'b8e4f0a3c752'
|
||||
down_revision = 'a7d3e9f2b641'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
with op.batch_alter_table('group') as batch:
|
||||
batch.add_column(
|
||||
sa.Column(
|
||||
'parent_group_id',
|
||||
sa.Text(),
|
||||
sa.ForeignKey('group.id', name='fk_group_parent', ondelete='SET NULL'),
|
||||
nullable=True,
|
||||
)
|
||||
)
|
||||
batch.create_index('ix_group_parent_group_id', ['parent_group_id'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
with op.batch_alter_table('group') as batch:
|
||||
batch.drop_index('ix_group_parent_group_id')
|
||||
batch.drop_constraint('fk_group_parent', type_='foreignkey')
|
||||
batch.drop_column('parent_group_id')
|
||||
|
|
@ -595,7 +595,7 @@ class AccessGrantsTable:
|
|||
if user_group_ids is None:
|
||||
from open_webui.models.groups import Groups
|
||||
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db, include_inherited=True)
|
||||
user_group_ids = {group.id for group in user_groups}
|
||||
|
||||
if user_group_ids:
|
||||
|
|
@ -651,7 +651,7 @@ class AccessGrantsTable:
|
|||
if user_group_ids is None:
|
||||
from open_webui.models.groups import Groups
|
||||
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db, include_inherited=True)
|
||||
user_group_ids = {group.id for group in user_groups}
|
||||
|
||||
if user_group_ids:
|
||||
|
|
@ -730,7 +730,7 @@ class AccessGrantsTable:
|
|||
group_ids.append(grant.principal_id)
|
||||
|
||||
if group_ids:
|
||||
group_user_ids = await Groups.get_group_user_ids_by_ids(group_ids, db=db)
|
||||
group_user_ids = await Groups.get_group_user_ids_by_ids(group_ids, db=db, include_inherited=True)
|
||||
for members in group_user_ids.values():
|
||||
user_ids.update(members)
|
||||
return user_ids
|
||||
|
|
|
|||
|
|
@ -291,7 +291,7 @@ class CalendarTable:
|
|||
async def get_calendars_by_user(self, user_id: str, db: Optional[AsyncSession] = None) -> list[CalendarModel]:
|
||||
"""Owned + shared calendars."""
|
||||
async with get_async_db_context(db) as db:
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db, include_inherited=True)
|
||||
user_group_ids = [g.id for g in user_groups]
|
||||
|
||||
stmt = select(Calendar)
|
||||
|
|
@ -497,7 +497,7 @@ class CalendarEventTable:
|
|||
Recurring events are fetched if they have any rrule (expansion in Python).
|
||||
"""
|
||||
async with get_async_db_context(db) as db:
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db, include_inherited=True)
|
||||
user_group_ids = [g.id for g in user_groups]
|
||||
|
||||
# Get calendar IDs accessible to user
|
||||
|
|
@ -599,7 +599,7 @@ class CalendarEventTable:
|
|||
db: Optional[AsyncSession] = None,
|
||||
) -> CalendarEventListResponse:
|
||||
async with get_async_db_context(db) as db:
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db, include_inherited=True)
|
||||
user_group_ids = [g.id for g in user_groups]
|
||||
|
||||
# Get accessible calendar IDs
|
||||
|
|
|
|||
|
|
@ -289,7 +289,7 @@ class ChannelTable:
|
|||
users.add(invited_by)
|
||||
|
||||
for group_id in group_ids or []:
|
||||
group_user_ids = await Groups.get_group_user_ids_by_id(group_id)
|
||||
group_user_ids = await Groups.get_group_user_ids_by_id(group_id, include_inherited=True)
|
||||
users.update(group_user_ids)
|
||||
|
||||
return users
|
||||
|
|
@ -393,7 +393,9 @@ class ChannelTable:
|
|||
|
||||
async def get_channels_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> list[ChannelModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id, db=db)]
|
||||
user_group_ids = [
|
||||
group.id for group in await Groups.get_groups_by_member_id(user_id, db=db, include_inherited=True)
|
||||
]
|
||||
|
||||
result = await db.execute(
|
||||
select(Channel)
|
||||
|
|
@ -737,7 +739,9 @@ class ChannelTable:
|
|||
return []
|
||||
|
||||
# Preload user's group membership
|
||||
user_group_ids = [g.id for g in await Groups.get_groups_by_member_id(user_id, db=db)]
|
||||
user_group_ids = [
|
||||
g.id for g in await Groups.get_groups_by_member_id(user_id, db=db, include_inherited=True)
|
||||
]
|
||||
|
||||
allowed_channels = []
|
||||
|
||||
|
|
@ -813,7 +817,9 @@ class ChannelTable:
|
|||
stmt = select(Channel).filter(Channel.id == id)
|
||||
|
||||
# Determine user groups
|
||||
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id, db=db)]
|
||||
user_group_ids = [
|
||||
group.id for group in await Groups.get_groups_by_member_id(user_id, db=db, include_inherited=True)
|
||||
]
|
||||
|
||||
# Apply ACL rules
|
||||
stmt = self._has_permission(
|
||||
|
|
|
|||
|
|
@ -527,7 +527,7 @@ class ChatMessageTable:
|
|||
db: Optional[AsyncSession] = None,
|
||||
) -> dict[str, int]:
|
||||
async with get_async_db_context(db) as db:
|
||||
from open_webui.models.groups import GroupMember
|
||||
from open_webui.models.groups import group_user_memberships
|
||||
|
||||
stmt = select(ChatMessage.model_id, func.count(ChatMessage.id).label('count')).filter(
|
||||
ChatMessage.role == 'assistant',
|
||||
|
|
@ -539,7 +539,7 @@ class ChatMessageTable:
|
|||
if end_date:
|
||||
stmt = stmt.filter(ChatMessage.created_at <= end_date)
|
||||
if group_id:
|
||||
group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery()
|
||||
group_users = select(group_user_memberships([group_id], True).c.user_id).scalar_subquery()
|
||||
stmt = stmt.filter(ChatMessage.user_id.in_(group_users))
|
||||
|
||||
stmt = stmt.group_by(ChatMessage.model_id)
|
||||
|
|
@ -555,7 +555,7 @@ class ChatMessageTable:
|
|||
) -> dict[str, dict]:
|
||||
"""Count distinct users and chats per model."""
|
||||
async with get_async_db_context(db) as db:
|
||||
from open_webui.models.groups import GroupMember
|
||||
from open_webui.models.groups import group_user_memberships
|
||||
|
||||
stmt = select(
|
||||
ChatMessage.model_id,
|
||||
|
|
@ -571,7 +571,7 @@ class ChatMessageTable:
|
|||
if end_date:
|
||||
stmt = stmt.filter(ChatMessage.created_at <= end_date)
|
||||
if group_id:
|
||||
group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery()
|
||||
group_users = select(group_user_memberships([group_id], True).c.user_id).scalar_subquery()
|
||||
stmt = stmt.filter(ChatMessage.user_id.in_(group_users))
|
||||
|
||||
stmt = stmt.group_by(ChatMessage.model_id)
|
||||
|
|
@ -593,7 +593,7 @@ class ChatMessageTable:
|
|||
) -> dict[str, dict]:
|
||||
"""Aggregate token usage by model using database-level aggregation."""
|
||||
async with get_async_db_context(db) as db:
|
||||
from open_webui.models.groups import GroupMember
|
||||
from open_webui.models.groups import group_user_memberships
|
||||
|
||||
# We need the dialect to determine JSON extraction syntax
|
||||
# For async sessions, access via get_bind()
|
||||
|
|
@ -618,7 +618,7 @@ class ChatMessageTable:
|
|||
if end_date:
|
||||
stmt = stmt.filter(ChatMessage.created_at <= end_date)
|
||||
if group_id:
|
||||
group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery()
|
||||
group_users = select(group_user_memberships([group_id], True).c.user_id).scalar_subquery()
|
||||
stmt = stmt.filter(ChatMessage.user_id.in_(group_users))
|
||||
|
||||
stmt = stmt.group_by(ChatMessage.model_id)
|
||||
|
|
@ -643,7 +643,7 @@ class ChatMessageTable:
|
|||
) -> dict[str, dict]:
|
||||
"""Aggregate token usage by user using database-level aggregation."""
|
||||
async with get_async_db_context(db) as db:
|
||||
from open_webui.models.groups import GroupMember
|
||||
from open_webui.models.groups import group_user_memberships
|
||||
|
||||
bind = await db.connection()
|
||||
dialect = bind.dialect.name
|
||||
|
|
@ -666,7 +666,7 @@ class ChatMessageTable:
|
|||
if end_date:
|
||||
stmt = stmt.filter(ChatMessage.created_at <= end_date)
|
||||
if group_id:
|
||||
group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery()
|
||||
group_users = select(group_user_memberships([group_id], True).c.user_id).scalar_subquery()
|
||||
stmt = stmt.filter(ChatMessage.user_id.in_(group_users))
|
||||
|
||||
stmt = stmt.group_by(ChatMessage.user_id)
|
||||
|
|
@ -917,7 +917,7 @@ class ChatMessageTable:
|
|||
db: Optional[AsyncSession] = None,
|
||||
) -> dict[str, int]:
|
||||
async with get_async_db_context(db) as db:
|
||||
from open_webui.models.groups import GroupMember
|
||||
from open_webui.models.groups import group_user_memberships
|
||||
|
||||
stmt = select(ChatMessage.user_id, func.count(ChatMessage.id).label('count')).filter(
|
||||
ChatMessage.role == 'assistant',
|
||||
|
|
@ -928,7 +928,7 @@ class ChatMessageTable:
|
|||
if end_date:
|
||||
stmt = stmt.filter(ChatMessage.created_at <= end_date)
|
||||
if group_id:
|
||||
group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery()
|
||||
group_users = select(group_user_memberships([group_id], True).c.user_id).scalar_subquery()
|
||||
stmt = stmt.filter(ChatMessage.user_id.in_(group_users))
|
||||
|
||||
stmt = stmt.group_by(ChatMessage.user_id)
|
||||
|
|
@ -943,7 +943,7 @@ class ChatMessageTable:
|
|||
db: Optional[AsyncSession] = None,
|
||||
) -> dict[str, int]:
|
||||
async with get_async_db_context(db) as db:
|
||||
from open_webui.models.groups import GroupMember
|
||||
from open_webui.models.groups import group_user_memberships
|
||||
|
||||
stmt = select(ChatMessage.chat_id, func.count(ChatMessage.id).label('count')).filter(
|
||||
ChatMessage.role == 'assistant',
|
||||
|
|
@ -954,7 +954,7 @@ class ChatMessageTable:
|
|||
if end_date:
|
||||
stmt = stmt.filter(ChatMessage.created_at <= end_date)
|
||||
if group_id:
|
||||
group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery()
|
||||
group_users = select(group_user_memberships([group_id], True).c.user_id).scalar_subquery()
|
||||
stmt = stmt.filter(ChatMessage.user_id.in_(group_users))
|
||||
|
||||
stmt = stmt.group_by(ChatMessage.chat_id)
|
||||
|
|
@ -972,7 +972,7 @@ class ChatMessageTable:
|
|||
async with get_async_db_context(db) as db:
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from open_webui.models.groups import GroupMember
|
||||
from open_webui.models.groups import group_user_memberships
|
||||
|
||||
stmt = select(ChatMessage.created_at, ChatMessage.model_id).filter(
|
||||
ChatMessage.role == 'assistant',
|
||||
|
|
@ -984,7 +984,7 @@ class ChatMessageTable:
|
|||
if end_date:
|
||||
stmt = stmt.filter(ChatMessage.created_at <= end_date)
|
||||
if group_id:
|
||||
group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery()
|
||||
group_users = select(group_user_memberships([group_id], True).c.user_id).scalar_subquery()
|
||||
stmt = stmt.filter(ChatMessage.user_id.in_(group_users))
|
||||
|
||||
result = await db.execute(stmt)
|
||||
|
|
@ -1021,7 +1021,7 @@ class ChatMessageTable:
|
|||
async with get_async_db_context(db) as db:
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from open_webui.models.groups import GroupMember
|
||||
from open_webui.models.groups import group_user_memberships
|
||||
|
||||
stmt = select(ChatMessage.created_at, ChatMessage.model_id).filter(
|
||||
ChatMessage.role == 'assistant',
|
||||
|
|
@ -1033,7 +1033,7 @@ class ChatMessageTable:
|
|||
if end_date:
|
||||
stmt = stmt.filter(ChatMessage.created_at <= end_date)
|
||||
if group_id:
|
||||
group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery()
|
||||
group_users = select(group_user_memberships([group_id], True).c.user_id).scalar_subquery()
|
||||
stmt = stmt.filter(ChatMessage.user_id.in_(group_users))
|
||||
|
||||
result = await db.execute(stmt)
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@ import logging
|
|||
import time
|
||||
import uuid
|
||||
from typing import Optional
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from open_webui.env import DEFAULT_GROUP_SHARE_PERMISSION
|
||||
from open_webui.internal.db import Base, JSONField, get_async_db_context
|
||||
from open_webui.internal.db import Base, JSONField, get_async_db_context, get_async_db
|
||||
from open_webui.models.access_grants import AccessGrant
|
||||
from open_webui.models.files import FileMetadataResponse
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
|
@ -23,6 +24,7 @@ from sqlalchemy import (
|
|||
or_,
|
||||
select,
|
||||
update,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
|
@ -38,6 +40,10 @@ log = logging.getLogger(__name__)
|
|||
class Group(Base):
|
||||
__tablename__ = 'group'
|
||||
|
||||
parent_group_id = Column(
|
||||
Text, ForeignKey('group.id', name='fk_group_parent', ondelete='SET NULL'), nullable=True, index=True
|
||||
)
|
||||
|
||||
id = Column(Text, unique=True, primary_key=True)
|
||||
user_id = Column(Text)
|
||||
|
||||
|
|
@ -54,6 +60,7 @@ class Group(Base):
|
|||
|
||||
|
||||
class GroupModel(BaseModel):
|
||||
parent_group_id: Optional[str] = None
|
||||
id: str
|
||||
user_id: str
|
||||
|
||||
|
|
@ -105,6 +112,7 @@ class GroupResponse(GroupModel):
|
|||
|
||||
|
||||
class GroupInfoResponse(BaseModel):
|
||||
parent_group_id: Optional[str] = None
|
||||
id: str
|
||||
user_id: str
|
||||
name: str
|
||||
|
|
@ -115,6 +123,7 @@ class GroupInfoResponse(BaseModel):
|
|||
|
||||
|
||||
class GroupForm(BaseModel):
|
||||
parent_group_id: Optional[str] = None
|
||||
name: str
|
||||
description: str
|
||||
permissions: Optional[dict] = None
|
||||
|
|
@ -134,6 +143,95 @@ class GroupListResponse(BaseModel):
|
|||
total: int = 0
|
||||
|
||||
|
||||
class GroupHierarchyError(ValueError):
|
||||
def __init__(self, message: str, status_code: int = 400):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
def ancestor_groups(group_ids):
|
||||
"""Identifier-only recursion: UNION also terminates on externally introduced cycles."""
|
||||
chain = select(Group.id.label('group_id')).where(Group.id.in_(group_ids)).cte(recursive=True)
|
||||
return chain.union(
|
||||
select(Group.parent_group_id).join(chain, Group.id == chain.c.group_id).where(Group.parent_group_id.isnot(None))
|
||||
)
|
||||
|
||||
|
||||
def descendant_groups(group_ids):
|
||||
chain = (
|
||||
select(Group.id.label('root_id'), Group.id.label('group_id')).where(Group.id.in_(group_ids)).cte(recursive=True)
|
||||
)
|
||||
return chain.union(select(chain.c.root_id, Group.id).join(chain, Group.parent_group_id == chain.c.group_id))
|
||||
|
||||
|
||||
def user_group_memberships(user_ids, include_inherited=False):
|
||||
direct = select(GroupMember.user_id, GroupMember.group_id).where(GroupMember.user_id.in_(user_ids))
|
||||
if not include_inherited:
|
||||
return direct.subquery()
|
||||
chain = direct.cte(recursive=True)
|
||||
return chain.union(
|
||||
select(chain.c.user_id, Group.parent_group_id)
|
||||
.join(Group, Group.id == chain.c.group_id)
|
||||
.where(Group.parent_group_id.isnot(None))
|
||||
)
|
||||
|
||||
|
||||
def group_user_memberships(group_ids, include_inherited=False):
|
||||
if not include_inherited:
|
||||
return select(GroupMember.group_id, GroupMember.user_id).where(GroupMember.group_id.in_(group_ids)).subquery()
|
||||
descendants = descendant_groups(group_ids)
|
||||
return (
|
||||
select(descendants.c.root_id.label('group_id'), GroupMember.user_id)
|
||||
.join(GroupMember, GroupMember.group_id == descendants.c.group_id)
|
||||
.distinct()
|
||||
.subquery()
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def hierarchy_transaction():
|
||||
# Own the session: callers may already have an unrelated read transaction.
|
||||
async with get_async_db() as db:
|
||||
try:
|
||||
if db.bind.dialect.name == 'sqlite':
|
||||
await db.execute(text('BEGIN IMMEDIATE'))
|
||||
elif db.bind.dialect.name == 'postgresql':
|
||||
await db.execute(text('SELECT pg_advisory_xact_lock(731947205)'))
|
||||
else:
|
||||
raise RuntimeError('Group hierarchy requires SQLite or PostgreSQL')
|
||||
yield db
|
||||
await db.commit()
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def refresh_group_sessions(user_ids):
|
||||
if not user_ids:
|
||||
return
|
||||
# Import lazily to avoid models/socket import cycles. A committed write must not
|
||||
# be reported as failed just because a client has already disconnected.
|
||||
from open_webui.socket.main import disconnect_user_sessions
|
||||
|
||||
for user_id in set(user_ids):
|
||||
try:
|
||||
await disconnect_user_sessions(user_id, refresh_access=True)
|
||||
except Exception:
|
||||
log.exception('Unable to refresh group access for user %s', user_id)
|
||||
|
||||
|
||||
async def validate_parent(db, group_id, parent_id):
|
||||
if parent_id is None:
|
||||
return
|
||||
if not parent_id:
|
||||
raise GroupHierarchyError('Parent group must be a group ID or null.')
|
||||
if not await db.get(Group, parent_id):
|
||||
raise GroupHierarchyError('Parent group not found.', 404)
|
||||
ancestors = ancestor_groups([parent_id])
|
||||
if (await db.execute(select(ancestors.c.group_id).where(ancestors.c.group_id == group_id))).first():
|
||||
raise GroupHierarchyError('A group cannot be its own parent or a descendant of itself.')
|
||||
|
||||
|
||||
class GroupTable:
|
||||
def _ensure_default_share_config(self, group_data: dict) -> dict:
|
||||
"""Ensure the group data dict has a default share config if not already set."""
|
||||
|
|
@ -148,30 +246,20 @@ class GroupTable:
|
|||
async def insert_new_group(
|
||||
self, user_id: str, form_data: GroupForm, db: Optional[AsyncSession] = None
|
||||
) -> Optional[GroupModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
async with hierarchy_transaction() as session:
|
||||
await validate_parent(session, None, form_data.parent_group_id)
|
||||
group_data = self._ensure_default_share_config(form_data.model_dump(exclude_none=True))
|
||||
group = GroupModel(
|
||||
**{
|
||||
**group_data,
|
||||
'id': str(uuid.uuid4()),
|
||||
'user_id': user_id,
|
||||
'created_at': int(time.time()),
|
||||
'updated_at': int(time.time()),
|
||||
}
|
||||
group = Group(
|
||||
**group_data,
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user_id,
|
||||
created_at=int(time.time()),
|
||||
updated_at=int(time.time()),
|
||||
)
|
||||
|
||||
try:
|
||||
result = Group(**group.model_dump())
|
||||
db.add(result)
|
||||
await db.commit()
|
||||
await db.refresh(result)
|
||||
if result:
|
||||
return GroupModel.model_validate(result)
|
||||
else:
|
||||
return None
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
session.add(group)
|
||||
await session.flush()
|
||||
result = GroupModel.model_validate(group)
|
||||
return result
|
||||
|
||||
async def get_all_groups(self, db: Optional[AsyncSession] = None) -> list[GroupModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
|
|
@ -217,7 +305,7 @@ class GroupTable:
|
|||
)
|
||||
|
||||
if member_id:
|
||||
member_groups_select = select(GroupMember.group_id).where(GroupMember.user_id == member_id)
|
||||
member_groups_select = select(user_group_memberships([member_id], True).c.group_id)
|
||||
members_only_and_is_member = and_(
|
||||
json_share_lower == 'members',
|
||||
Group.id.in_(member_groups_select),
|
||||
|
|
@ -232,7 +320,7 @@ class GroupTable:
|
|||
# Only apply member_id filter when share filter is NOT present
|
||||
if 'member_id' in filter:
|
||||
stmt = stmt.filter(
|
||||
Group.id.in_(select(GroupMember.group_id).where(GroupMember.user_id == filter['member_id']))
|
||||
Group.id.in_(select(user_group_memberships([filter['member_id']], True).c.group_id))
|
||||
)
|
||||
|
||||
result = await db.execute(stmt.order_by(Group.updated_at.desc()))
|
||||
|
|
@ -263,7 +351,7 @@ class GroupTable:
|
|||
stmt = stmt.filter(Group.name.ilike(f'%{filter["query"]}%'))
|
||||
if 'member_id' in filter:
|
||||
stmt = stmt.filter(
|
||||
Group.id.in_(select(GroupMember.group_id).where(GroupMember.user_id == filter['member_id']))
|
||||
Group.id.in_(select(user_group_memberships([filter['member_id']], True).c.group_id))
|
||||
)
|
||||
|
||||
if 'share' in filter:
|
||||
|
|
@ -303,112 +391,100 @@ class GroupTable:
|
|||
'total': total,
|
||||
}
|
||||
|
||||
async def get_groups_by_member_id(self, user_id: str, db: Optional[AsyncSession] = None) -> list[GroupModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(
|
||||
select(Group)
|
||||
.join(GroupMember, GroupMember.group_id == Group.id)
|
||||
.filter(GroupMember.user_id == user_id)
|
||||
.order_by(Group.updated_at.desc())
|
||||
)
|
||||
return [GroupModel.model_validate(group) for group in result.scalars().all()]
|
||||
async def get_groups_by_member_id(
|
||||
self, user_id: str, db: Optional[AsyncSession] = None, *, include_inherited=False
|
||||
) -> list[GroupModel]:
|
||||
return (await self.get_groups_by_member_ids([user_id], db=db, include_inherited=include_inherited))[user_id]
|
||||
|
||||
async def get_groups_by_member_ids(
|
||||
self, user_ids: list[str], db: Optional[AsyncSession] = None
|
||||
self, user_ids: list[str], db: Optional[AsyncSession] = None, *, include_inherited=False
|
||||
) -> dict[str, list[GroupModel]]:
|
||||
"""Fetch groups for multiple users in a single query to avoid N+1."""
|
||||
groups = {uid: [] for uid in user_ids}
|
||||
if not user_ids:
|
||||
return groups
|
||||
memberships = user_group_memberships(user_ids, include_inherited)
|
||||
async with get_async_db_context(db) as db:
|
||||
# Query GroupMember joined with Group, filtering by user_ids
|
||||
result = await db.execute(
|
||||
select(GroupMember.user_id, Group)
|
||||
.join(Group, Group.id == GroupMember.group_id)
|
||||
.filter(GroupMember.user_id.in_(user_ids))
|
||||
.order_by(Group.updated_at.desc())
|
||||
rows = await db.execute(
|
||||
select(memberships.c.user_id, Group)
|
||||
.join(Group, Group.id == memberships.c.group_id)
|
||||
.order_by(Group.updated_at.desc(), Group.id)
|
||||
)
|
||||
rows = result.all()
|
||||
for uid, group in rows:
|
||||
groups[uid].append(GroupModel.model_validate(group))
|
||||
return groups
|
||||
|
||||
# Group groups by user_id
|
||||
user_groups: dict[str, list[GroupModel]] = {uid: [] for uid in user_ids}
|
||||
for user_id, group in rows:
|
||||
user_groups[user_id].append(GroupModel.model_validate(group))
|
||||
|
||||
return user_groups
|
||||
async def get_ancestor_ids(self, group_id: str, db: Optional[AsyncSession] = None) -> set[str]:
|
||||
chain = ancestor_groups([group_id])
|
||||
async with get_async_db_context(db) as db:
|
||||
return set((await db.execute(select(chain.c.group_id))).scalars())
|
||||
|
||||
async def get_group_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[GroupModel]:
|
||||
try:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(Group).filter_by(id=id))
|
||||
result = await db.execute(select(Group).filter_by(id=id).execution_options(populate_existing=True))
|
||||
group = result.scalars().first()
|
||||
return GroupModel.model_validate(group) if group else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def get_group_user_ids_by_id(self, id: str, db: Optional[AsyncSession] = None) -> list[str]:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(GroupMember.user_id).filter(GroupMember.group_id == id))
|
||||
members = result.all()
|
||||
|
||||
if not members:
|
||||
return []
|
||||
|
||||
return [m[0] for m in members]
|
||||
async def get_group_user_ids_by_id(
|
||||
self, id: str, db: Optional[AsyncSession] = None, *, include_inherited=False
|
||||
) -> list[str]:
|
||||
return (await self.get_group_user_ids_by_ids([id], db=db, include_inherited=include_inherited))[id]
|
||||
|
||||
async def get_group_user_ids_by_ids(
|
||||
self, group_ids: list[str], db: Optional[AsyncSession] = None
|
||||
self, group_ids: list[str], db: Optional[AsyncSession] = None, *, include_inherited=False
|
||||
) -> dict[str, list[str]]:
|
||||
users = {gid: [] for gid in group_ids}
|
||||
if not group_ids:
|
||||
return users
|
||||
memberships = group_user_memberships(group_ids, include_inherited)
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(
|
||||
select(GroupMember.group_id, GroupMember.user_id).filter(GroupMember.group_id.in_(group_ids))
|
||||
)
|
||||
members = result.all()
|
||||
|
||||
group_user_ids: dict[str, list[str]] = {group_id: [] for group_id in group_ids}
|
||||
|
||||
for group_id, user_id in members:
|
||||
group_user_ids[group_id].append(user_id)
|
||||
|
||||
return group_user_ids
|
||||
for gid, uid in await db.execute(select(memberships)):
|
||||
users[gid].append(uid)
|
||||
return users
|
||||
|
||||
async def set_group_user_ids_by_id(
|
||||
self, group_id: str, user_ids: list[str], db: Optional[AsyncSession] = None
|
||||
) -> None:
|
||||
async with get_async_db_context(db) as db:
|
||||
# Delete existing members
|
||||
await db.execute(delete(GroupMember).filter(GroupMember.group_id == group_id))
|
||||
|
||||
# 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,
|
||||
async with hierarchy_transaction() as session:
|
||||
if not await session.get(Group, group_id):
|
||||
raise GroupHierarchyError('Group not found.', 404)
|
||||
previous = set(
|
||||
(await session.execute(select(GroupMember.user_id).where(GroupMember.group_id == group_id))).scalars()
|
||||
)
|
||||
requested = set(user_ids)
|
||||
await session.execute(
|
||||
delete(GroupMember).where(
|
||||
GroupMember.group_id == group_id, GroupMember.user_id.in_(previous - requested)
|
||||
)
|
||||
for user_id in user_ids
|
||||
]
|
||||
)
|
||||
now = int(time.time())
|
||||
session.add_all(
|
||||
[
|
||||
GroupMember(id=str(uuid.uuid4()), group_id=group_id, user_id=uid, created_at=now, updated_at=now)
|
||||
for uid in requested - previous
|
||||
]
|
||||
)
|
||||
await session.execute(update(Group).where(Group.id == group_id).values(updated_at=now))
|
||||
await refresh_group_sessions(previous ^ requested)
|
||||
|
||||
db.add_all(new_members)
|
||||
await db.commit()
|
||||
async def get_group_member_count_by_id(
|
||||
self, id: str, db: Optional[AsyncSession] = None, *, include_inherited=False
|
||||
) -> int:
|
||||
return (await self.get_group_member_counts_by_ids([id], db=db, include_inherited=include_inherited)).get(id, 0)
|
||||
|
||||
async def get_group_member_count_by_id(self, id: str, db: Optional[AsyncSession] = None) -> int:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(func.count(GroupMember.user_id)).filter(GroupMember.group_id == id))
|
||||
count = result.scalar()
|
||||
return count if count else 0
|
||||
|
||||
async def get_group_member_counts_by_ids(self, ids: list[str], db: Optional[AsyncSession] = None) -> dict[str, int]:
|
||||
async def get_group_member_counts_by_ids(
|
||||
self, ids: list[str], db: Optional[AsyncSession] = None, *, include_inherited=False
|
||||
) -> dict[str, int]:
|
||||
if not ids:
|
||||
return {}
|
||||
memberships = group_user_memberships(ids, include_inherited)
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(
|
||||
select(GroupMember.group_id, func.count(GroupMember.user_id))
|
||||
.filter(GroupMember.group_id.in_(ids))
|
||||
.group_by(GroupMember.group_id)
|
||||
rows = await db.execute(
|
||||
select(memberships.c.group_id, func.count(memberships.c.user_id)).group_by(memberships.c.group_id)
|
||||
)
|
||||
rows = result.all()
|
||||
return {group_id: count for group_id, count in rows}
|
||||
return dict(rows.all())
|
||||
|
||||
async def update_group_by_id(
|
||||
self,
|
||||
|
|
@ -416,71 +492,75 @@ class GroupTable:
|
|||
form_data: GroupUpdateForm,
|
||||
overwrite: bool = False,
|
||||
db: Optional[AsyncSession] = None,
|
||||
*,
|
||||
changes: Optional[dict] = None,
|
||||
) -> Optional[GroupModel]:
|
||||
try:
|
||||
async with get_async_db_context(db) as db:
|
||||
await db.execute(
|
||||
update(Group)
|
||||
.filter_by(id=id)
|
||||
.values(
|
||||
**form_data.model_dump(exclude_none=True),
|
||||
updated_at=int(time.time()),
|
||||
)
|
||||
affected = []
|
||||
async with hierarchy_transaction() as session:
|
||||
group = await session.get(Group, id)
|
||||
if group is None:
|
||||
raise GroupHierarchyError('Group not found.', 404)
|
||||
values = form_data.model_dump(exclude_none=True)
|
||||
if 'parent_group_id' in form_data.model_fields_set:
|
||||
await validate_parent(session, id, form_data.parent_group_id)
|
||||
values['parent_group_id'] = form_data.parent_group_id
|
||||
parent_changed = values.get('parent_group_id', group.parent_group_id) != group.parent_group_id
|
||||
if changes is not None:
|
||||
changes.update(
|
||||
old_parent_group_id=group.parent_group_id,
|
||||
parent_group_id=values.get('parent_group_id', group.parent_group_id),
|
||||
)
|
||||
await db.commit()
|
||||
return await self.get_group_by_id(id=id, db=db)
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
return None
|
||||
if parent_changed or ('permissions' in values and values['permissions'] != group.permissions):
|
||||
members = group_user_memberships([id], True)
|
||||
affected = list((await session.execute(select(members.c.user_id))).scalars())
|
||||
for key, value in values.items():
|
||||
setattr(group, key, value)
|
||||
group.updated_at = int(time.time())
|
||||
await session.flush()
|
||||
result = GroupModel.model_validate(group)
|
||||
await refresh_group_sessions(affected)
|
||||
return result
|
||||
|
||||
async def delete_group_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool:
|
||||
async with get_async_db_context(db) as db:
|
||||
try:
|
||||
await db.execute(delete(Group).filter_by(id=id))
|
||||
await db.execute(delete(AccessGrant).filter_by(principal_type='group', principal_id=id))
|
||||
await db.commit()
|
||||
return True
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
return False
|
||||
async def delete_group_by_id(
|
||||
self, id: str, db: Optional[AsyncSession] = None, *, changes: Optional[dict] = None
|
||||
) -> bool:
|
||||
async with hierarchy_transaction() as session:
|
||||
group = await session.get(Group, id)
|
||||
if group is None:
|
||||
raise GroupHierarchyError('Group not found.', 404)
|
||||
members = group_user_memberships([id], True)
|
||||
affected = list((await session.execute(select(members.c.user_id))).scalars())
|
||||
children = list((await session.execute(select(Group.id).where(Group.parent_group_id == id))).scalars())
|
||||
if changes is not None:
|
||||
changes.update(parent_group_id=group.parent_group_id, promoted_child_ids=children)
|
||||
await session.execute(
|
||||
update(Group)
|
||||
.where(Group.parent_group_id == id)
|
||||
.values(parent_group_id=group.parent_group_id, updated_at=int(time.time()))
|
||||
)
|
||||
await session.execute(delete(GroupMember).where(GroupMember.group_id == id))
|
||||
await session.execute(delete(AccessGrant).filter_by(principal_type='group', principal_id=id))
|
||||
await session.execute(delete(Group).where(Group.id == id))
|
||||
await refresh_group_sessions(affected)
|
||||
return True
|
||||
|
||||
async def delete_all_groups(self, db: Optional[AsyncSession] = None) -> bool:
|
||||
async with get_async_db_context(db) as db:
|
||||
try:
|
||||
await db.execute(delete(Group))
|
||||
await db.execute(delete(AccessGrant).filter_by(principal_type='group'))
|
||||
await db.commit()
|
||||
|
||||
return True
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
return False
|
||||
async with hierarchy_transaction() as session:
|
||||
affected = list((await session.execute(select(GroupMember.user_id).distinct())).scalars())
|
||||
await session.execute(update(Group).values(parent_group_id=None))
|
||||
await session.execute(delete(GroupMember))
|
||||
await session.execute(delete(AccessGrant).filter_by(principal_type='group'))
|
||||
await session.execute(delete(Group))
|
||||
await refresh_group_sessions(affected)
|
||||
return True
|
||||
|
||||
async def remove_user_from_all_groups(self, user_id: str, db: Optional[AsyncSession] = None) -> bool:
|
||||
async with get_async_db_context(db) as db:
|
||||
try:
|
||||
# Find all groups the user belongs to
|
||||
result = await db.execute(
|
||||
select(Group)
|
||||
.join(GroupMember, GroupMember.group_id == Group.id)
|
||||
.filter(GroupMember.user_id == user_id)
|
||||
)
|
||||
groups = result.scalars().all()
|
||||
|
||||
# Remove the user from each group
|
||||
for group in groups:
|
||||
await db.execute(
|
||||
delete(GroupMember).filter(GroupMember.group_id == group.id, GroupMember.user_id == user_id)
|
||||
)
|
||||
|
||||
await db.execute(update(Group).filter_by(id=group.id).values(updated_at=int(time.time())))
|
||||
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
return False
|
||||
async with hierarchy_transaction() as session:
|
||||
ids = select(GroupMember.group_id).where(GroupMember.user_id == user_id)
|
||||
await session.execute(update(Group).where(Group.id.in_(ids)).values(updated_at=int(time.time())))
|
||||
await session.execute(delete(GroupMember).where(GroupMember.user_id == user_id))
|
||||
await refresh_group_sessions([user_id])
|
||||
return True
|
||||
|
||||
async def create_groups_by_group_names(
|
||||
self, user_id: str, group_names: list[str], db: Optional[AsyncSession] = None
|
||||
|
|
@ -521,133 +601,74 @@ class GroupTable:
|
|||
async def sync_groups_by_group_names(
|
||||
self, user_id: str, group_names: list[str], db: Optional[AsyncSession] = None
|
||||
) -> bool:
|
||||
async with get_async_db_context(db) as db:
|
||||
try:
|
||||
now = int(time.time())
|
||||
|
||||
# 1. Groups that SHOULD contain the user
|
||||
result = await db.execute(select(Group).filter(Group.name.in_(group_names)))
|
||||
target_groups = result.scalars().all()
|
||||
target_group_ids = {g.id for g in target_groups}
|
||||
|
||||
# 2. Groups the user is CURRENTLY in
|
||||
result = await db.execute(
|
||||
select(Group)
|
||||
.join(GroupMember, GroupMember.group_id == Group.id)
|
||||
.filter(GroupMember.user_id == user_id)
|
||||
)
|
||||
existing_group_ids = {g.id for g in result.scalars().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:
|
||||
await db.execute(
|
||||
delete(GroupMember).filter(
|
||||
GroupMember.user_id == user_id,
|
||||
GroupMember.group_id.in_(groups_to_remove),
|
||||
)
|
||||
)
|
||||
|
||||
await db.execute(update(Group).filter(Group.id.in_(groups_to_remove)).values(updated_at=now))
|
||||
|
||||
# 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,
|
||||
)
|
||||
)
|
||||
|
||||
if groups_to_add:
|
||||
await db.execute(update(Group).filter(Group.id.in_(groups_to_add)).values(updated_at=now))
|
||||
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
await db.rollback()
|
||||
return False
|
||||
async with hierarchy_transaction() as session:
|
||||
target = set((await session.execute(select(Group.id).where(Group.name.in_(group_names)))).scalars())
|
||||
previous = set(
|
||||
(await session.execute(select(GroupMember.group_id).where(GroupMember.user_id == user_id))).scalars()
|
||||
)
|
||||
await session.execute(
|
||||
delete(GroupMember).where(GroupMember.user_id == user_id, GroupMember.group_id.in_(previous - target))
|
||||
)
|
||||
now = int(time.time())
|
||||
session.add_all(
|
||||
[
|
||||
GroupMember(id=str(uuid.uuid4()), group_id=gid, user_id=user_id, created_at=now, updated_at=now)
|
||||
for gid in target - previous
|
||||
]
|
||||
)
|
||||
await session.execute(update(Group).where(Group.id.in_(previous ^ target)).values(updated_at=now))
|
||||
if previous != target:
|
||||
await refresh_group_sessions([user_id])
|
||||
return True
|
||||
|
||||
async def add_users_to_group(
|
||||
self,
|
||||
id: str,
|
||||
user_ids: Optional[list[str]] = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
self, id: str, user_ids: Optional[list[str]] = None, db: Optional[AsyncSession] = None
|
||||
) -> Optional[GroupModel]:
|
||||
try:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(Group).filter_by(id=id))
|
||||
group = result.scalars().first()
|
||||
if not group:
|
||||
return None
|
||||
|
||||
now = int(time.time())
|
||||
|
||||
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,
|
||||
)
|
||||
)
|
||||
await db.flush() # Detect unique constraint violation early
|
||||
except Exception:
|
||||
await db.rollback() # Clear failed INSERT
|
||||
continue # Duplicate → ignore
|
||||
|
||||
group.updated_at = now
|
||||
await db.commit()
|
||||
await db.refresh(group)
|
||||
|
||||
return GroupModel.model_validate(group)
|
||||
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
return None
|
||||
async with hierarchy_transaction() as session:
|
||||
group = await session.get(Group, id)
|
||||
if group is None:
|
||||
raise GroupHierarchyError('Group not found.', 404)
|
||||
previous = set(
|
||||
(await session.execute(select(GroupMember.user_id).where(GroupMember.group_id == id))).scalars()
|
||||
)
|
||||
added = set(user_ids or []) - previous
|
||||
now = int(time.time())
|
||||
session.add_all(
|
||||
[
|
||||
GroupMember(id=str(uuid.uuid4()), group_id=id, user_id=uid, created_at=now, updated_at=now)
|
||||
for uid in added
|
||||
]
|
||||
)
|
||||
group.updated_at = now
|
||||
await session.flush()
|
||||
result = GroupModel.model_validate(group)
|
||||
await refresh_group_sessions(added)
|
||||
return result
|
||||
|
||||
async def remove_users_from_group(
|
||||
self,
|
||||
id: str,
|
||||
user_ids: Optional[list[str]] = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
self, id: str, user_ids: Optional[list[str]] = None, db: Optional[AsyncSession] = None
|
||||
) -> Optional[GroupModel]:
|
||||
try:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(Group).filter_by(id=id))
|
||||
group = result.scalars().first()
|
||||
if not group:
|
||||
return None
|
||||
|
||||
if not user_ids:
|
||||
return GroupModel.model_validate(group)
|
||||
|
||||
# Remove users from group_member in batch
|
||||
await db.execute(
|
||||
delete(GroupMember).filter(GroupMember.group_id == id, GroupMember.user_id.in_(user_ids))
|
||||
)
|
||||
|
||||
# Update group timestamp
|
||||
group.updated_at = int(time.time())
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(group)
|
||||
return GroupModel.model_validate(group)
|
||||
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
return None
|
||||
async with hierarchy_transaction() as session:
|
||||
group = await session.get(Group, id)
|
||||
if group is None:
|
||||
raise GroupHierarchyError('Group not found.', 404)
|
||||
removed = list(
|
||||
(
|
||||
await session.execute(
|
||||
select(GroupMember.user_id).where(
|
||||
GroupMember.group_id == id, GroupMember.user_id.in_(user_ids or [])
|
||||
)
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
await session.execute(
|
||||
delete(GroupMember).where(GroupMember.group_id == id, GroupMember.user_id.in_(removed))
|
||||
)
|
||||
group.updated_at = int(time.time())
|
||||
await session.flush()
|
||||
result = GroupModel.model_validate(group)
|
||||
await refresh_group_sessions(removed)
|
||||
return result
|
||||
|
||||
|
||||
Groups = GroupTable()
|
||||
|
|
|
|||
|
|
@ -481,7 +481,7 @@ class KnowledgeTable:
|
|||
if knowledge.user_id == user_id:
|
||||
return True
|
||||
if user_group_ids is None:
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db, include_inherited=True)
|
||||
user_group_ids = {group.id for group in user_groups}
|
||||
return await AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
|
|
|
|||
|
|
@ -252,7 +252,10 @@ class ModelsTable:
|
|||
|
||||
if writable_by_user_id:
|
||||
user_group_ids = {
|
||||
group.id for group in await Groups.get_groups_by_member_id(writable_by_user_id, db=db)
|
||||
group.id
|
||||
for group in await Groups.get_groups_by_member_id(
|
||||
writable_by_user_id, db=db, include_inherited=True
|
||||
)
|
||||
}
|
||||
stmt = self._has_permission(
|
||||
db, stmt, {'user_id': writable_by_user_id, 'group_ids': user_group_ids}, permission='write'
|
||||
|
|
@ -475,7 +478,7 @@ class ModelsTable:
|
|||
)
|
||||
|
||||
if not is_admin:
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db, include_inherited=True)
|
||||
user_group_ids = [group.id for group in user_groups]
|
||||
|
||||
filter_dict = {'user_id': user_id}
|
||||
|
|
|
|||
|
|
@ -313,7 +313,7 @@ class NoteTable:
|
|||
db: Optional[AsyncSession] = None,
|
||||
) -> list[NoteModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db, include_inherited=True)
|
||||
user_group_ids = [group.id for group in user_groups]
|
||||
|
||||
stmt = select(Note).order_by(Note.updated_at.desc())
|
||||
|
|
@ -400,7 +400,7 @@ class NoteTable:
|
|||
db: Optional[AsyncSession] = None,
|
||||
) -> list[NoteModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db, include_inherited=True)
|
||||
user_group_ids = [group.id for group in user_groups]
|
||||
|
||||
stmt = (
|
||||
|
|
|
|||
|
|
@ -241,7 +241,7 @@ class PromptsTable:
|
|||
self, user_id: str, permission: str = 'write', db: AsyncSession | None = None
|
||||
) -> list[PromptUserResponse]:
|
||||
async with get_async_db_context(db) as session:
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=session)
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=session, include_inherited=True)
|
||||
user_group_ids = [group.id for group in user_groups]
|
||||
|
||||
query = select(Prompt).filter(Prompt.is_active == True).order_by(Prompt.updated_at.desc())
|
||||
|
|
@ -699,7 +699,7 @@ class PromptsTable:
|
|||
async def get_tags_by_user_id(self, user_id: str, db: AsyncSession | None = None) -> list[str]:
|
||||
try:
|
||||
async with get_async_db_context(db) as session:
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=session)
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=session, include_inherited=True)
|
||||
user_group_ids = [group.id for group in user_groups]
|
||||
|
||||
query = select(Prompt.tags).filter(Prompt.is_active == True)
|
||||
|
|
|
|||
|
|
@ -177,7 +177,9 @@ class SkillsTable:
|
|||
stmt = stmt.filter(Skill.id.in_(ids))
|
||||
|
||||
if user_id is not None:
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user_id, db=db)}
|
||||
user_group_ids = {
|
||||
group.id for group in await Groups.get_groups_by_member_id(user_id, db=db, include_inherited=True)
|
||||
}
|
||||
stmt = AccessGrants.has_permission_filter(
|
||||
db=db,
|
||||
query=stmt,
|
||||
|
|
|
|||
|
|
@ -189,7 +189,10 @@ class ToolsTable:
|
|||
|
||||
if user_id is not None:
|
||||
if user_group_ids is None:
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user_id, db=db)}
|
||||
user_group_ids = {
|
||||
group.id
|
||||
for group in await Groups.get_groups_by_member_id(user_id, db=db, include_inherited=True)
|
||||
}
|
||||
stmt = AccessGrants.has_permission_filter(
|
||||
db=db,
|
||||
query=stmt,
|
||||
|
|
@ -235,7 +238,7 @@ class ToolsTable:
|
|||
defer_content: bool = False,
|
||||
db: AsyncSession | None = None,
|
||||
) -> list[ToolUserModel]:
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db, include_inherited=True)
|
||||
user_group_ids = {group.id for group in user_groups}
|
||||
return await self.get_tools(
|
||||
defer_content=defer_content,
|
||||
|
|
|
|||
|
|
@ -549,7 +549,7 @@ class UsersTable:
|
|||
async with get_async_db_context(db) as session:
|
||||
# Deferred imports to avoid circular dependencies
|
||||
from open_webui.models.channels import ChannelMember
|
||||
from open_webui.models.groups import GroupMember
|
||||
from open_webui.models.groups import GroupMember, group_user_memberships
|
||||
|
||||
# Join GroupMember so we can order by group_id when requested
|
||||
stmt = select(User)
|
||||
|
|
@ -587,14 +587,8 @@ class UsersTable:
|
|||
stmt = stmt.filter(User.id.in_(user_ids))
|
||||
|
||||
if group_ids:
|
||||
stmt = stmt.filter(
|
||||
exists(
|
||||
select(GroupMember.id).where(
|
||||
GroupMember.user_id == User.id,
|
||||
GroupMember.group_id.in_(group_ids),
|
||||
)
|
||||
)
|
||||
)
|
||||
memberships = group_user_memberships(group_ids, True)
|
||||
stmt = stmt.filter(User.id.in_(select(memberships.c.user_id)))
|
||||
|
||||
roles = filter.get('roles')
|
||||
if roles:
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ async def _check_calendar_access(calendar_id: str, user: UserModel, permission:
|
|||
raise HTTPException(status_code=404, detail='Calendar not found')
|
||||
if cal.user_id == user.id or user.role == 'admin':
|
||||
return cal
|
||||
user_groups = await Groups.get_groups_by_member_id(user.id)
|
||||
user_groups = await Groups.get_groups_by_member_id(user.id, include_inherited=True)
|
||||
user_group_ids = [g.id for g in user_groups]
|
||||
if await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ async def get_channel_member_user_ids(
|
|||
user_ids = permitted_ids.get('user_ids') or []
|
||||
group_ids = permitted_ids.get('group_ids') or []
|
||||
if group_ids:
|
||||
for member_ids in (await Groups.get_group_user_ids_by_ids(group_ids, db=db)).values():
|
||||
for member_ids in (await Groups.get_group_user_ids_by_ids(group_ids, db=db, include_inherited=True)).values():
|
||||
user_ids.extend(member_ids)
|
||||
|
||||
return list(dict.fromkeys([*user_ids, channel.user_id]))
|
||||
|
|
|
|||
|
|
@ -109,7 +109,9 @@ async def get_folders(
|
|||
|
||||
user_group_ids = None
|
||||
if user.role != 'admin' and any(folder.data and 'files' in folder.data for folder in folders):
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)}
|
||||
user_group_ids = {
|
||||
group.id for group in await Groups.get_groups_by_member_id(user.id, db=db, include_inherited=True)
|
||||
}
|
||||
|
||||
# Verify folder data integrity
|
||||
folder_list = []
|
||||
|
|
@ -244,7 +246,7 @@ async def get_shared_folders(
|
|||
):
|
||||
"""Get all folders shared with the current user (not owned by them)."""
|
||||
await check_folders_permission(request, user, db=db)
|
||||
groups = await Groups.get_groups_by_member_id(user.id, db=db)
|
||||
groups = await Groups.get_groups_by_member_id(user.id, db=db, include_inherited=True)
|
||||
group_ids = {g.id for g in groups}
|
||||
|
||||
folder_perms = await Folders.get_shared_folder_ids_for_user(user.id, group_ids, db=db)
|
||||
|
|
|
|||
|
|
@ -1,16 +1,22 @@
|
|||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Optional, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status, Query
|
||||
from open_webui.config import CACHE_DIR
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.events import EVENTS, publish_event
|
||||
from open_webui.internal.db import get_async_session
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.groups import (
|
||||
GroupForm,
|
||||
GroupHierarchyError,
|
||||
Group,
|
||||
GroupMember,
|
||||
group_user_memberships,
|
||||
descendant_groups,
|
||||
GroupInfoResponse,
|
||||
GroupResponse,
|
||||
Groups,
|
||||
|
|
@ -20,9 +26,13 @@ from open_webui.models.groups import (
|
|||
from open_webui.models.knowledge import Knowledges
|
||||
from open_webui.models.models import Models
|
||||
from open_webui.models.tools import Tools
|
||||
from open_webui.models.users import UserInfoResponse, Users
|
||||
from open_webui.models.users import UserInfoResponse, Users, User
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, or_
|
||||
from pydantic import BaseModel
|
||||
from open_webui.utils.access_control import combine_permissions
|
||||
from open_webui.utils.json_codec import JSONCodec
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -83,6 +93,8 @@ async def create_new_group(
|
|||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT('Error creating group'),
|
||||
)
|
||||
except GroupHierarchyError as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=str(e)) from e
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
|
@ -108,7 +120,7 @@ async def get_group_by_id(id: str, user=Depends(get_admin_user), db: AsyncSessio
|
|||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
|
|
@ -123,7 +135,7 @@ async def get_group_info_by_id(id: str, user=Depends(get_verified_user), db: Asy
|
|||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
|
|
@ -149,7 +161,7 @@ async def export_group_by_id(id: str, user=Depends(get_admin_user), db: AsyncSes
|
|||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
|
|
@ -186,14 +198,15 @@ async def update_group_by_id(
|
|||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
group = await Groups.update_group_by_id(id, form_data, db=db)
|
||||
changes = {}
|
||||
group = await Groups.update_group_by_id(id, form_data, db=db, changes=changes)
|
||||
if group:
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.GROUP_UPDATED,
|
||||
actor=user,
|
||||
subject_id=id,
|
||||
data={'name': group.name},
|
||||
data={'name': group.name, **changes},
|
||||
)
|
||||
return GroupResponse(
|
||||
**group.model_dump(),
|
||||
|
|
@ -204,6 +217,8 @@ async def update_group_by_id(
|
|||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT('Error updating group'),
|
||||
)
|
||||
except GroupHierarchyError as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=str(e)) from e
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
|
@ -249,6 +264,8 @@ async def add_user_to_group(
|
|||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT('Error adding users to group'),
|
||||
)
|
||||
except GroupHierarchyError as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=str(e)) from e
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
|
@ -286,6 +303,8 @@ async def remove_users_from_group(
|
|||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT('Error removing users from group'),
|
||||
)
|
||||
except GroupHierarchyError as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=str(e)) from e
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
|
@ -306,20 +325,32 @@ async def delete_group_by_id(
|
|||
request: Request, id: str, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
try:
|
||||
result = await Groups.delete_group_by_id(id, db=db)
|
||||
changes = {}
|
||||
result = await Groups.delete_group_by_id(id, db=db, changes=changes)
|
||||
if result:
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.GROUP_DELETED,
|
||||
actor=user,
|
||||
subject_id=id,
|
||||
data=changes,
|
||||
)
|
||||
for child_id in changes['promoted_child_ids']:
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.GROUP_UPDATED,
|
||||
actor=user,
|
||||
subject_id=child_id,
|
||||
data={'old_parent_group_id': id, 'parent_group_id': changes['parent_group_id']},
|
||||
)
|
||||
return result
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT('Error deleting group'),
|
||||
)
|
||||
except GroupHierarchyError as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=str(e)) from e
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
|
@ -349,7 +380,7 @@ async def preview_group_access(
|
|||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
group_ids = {group.id}
|
||||
group_ids = await Groups.get_ancestor_ids(group.id, db=db)
|
||||
|
||||
# Batch-check accessible resources using existing AccessGrants
|
||||
all_models = await Models.get_all_models(db=db)
|
||||
|
|
@ -384,6 +415,14 @@ async def preview_group_access(
|
|||
|
||||
active_models = [m for m in all_models if m.is_active]
|
||||
|
||||
ancestors = (await db.execute(select(Group).where(Group.id.in_(group_ids - {id})))).scalars().all()
|
||||
inherited_permissions = JSONCodec.loads(JSONCodec.dumps(await Config.get('user.permissions') or {}))
|
||||
for ancestor in ancestors:
|
||||
inherited_permissions = combine_permissions(inherited_permissions, ancestor.permissions or {})
|
||||
effective_permissions = combine_permissions(
|
||||
JSONCodec.loads(JSONCodec.dumps(inherited_permissions)), group.permissions or {}
|
||||
)
|
||||
|
||||
return {
|
||||
'group': {'id': group.id, 'name': group.name},
|
||||
'models': {
|
||||
|
|
@ -399,4 +438,72 @@ async def preview_group_access(
|
|||
'total': len(all_tools),
|
||||
},
|
||||
'permissions': group.permissions or {},
|
||||
'inherited_permissions': inherited_permissions,
|
||||
'effective_permissions': effective_permissions,
|
||||
}
|
||||
|
||||
|
||||
class GroupMemberInfo(UserInfoResponse):
|
||||
membership_type: Literal['direct', 'inherited']
|
||||
via_group_ids: list[str] = []
|
||||
|
||||
|
||||
class GroupMembersResponse(BaseModel):
|
||||
items: list[GroupMemberInfo]
|
||||
total: int
|
||||
counts: dict[str, int]
|
||||
|
||||
|
||||
@router.get('/id/{id}/members', response_model=GroupMembersResponse)
|
||||
async def inspect_group_members(
|
||||
id: str,
|
||||
membership: Literal['direct', 'inherited', 'effective'] = 'effective',
|
||||
query: str = '',
|
||||
page: int = Query(default=1, ge=1),
|
||||
user=Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if not await db.get(Group, id):
|
||||
raise HTTPException(status_code=404, detail='Group not found.')
|
||||
direct = select(GroupMember.user_id).where(GroupMember.group_id == id)
|
||||
effective = group_user_memberships([id], True)
|
||||
direct_count = (await db.execute(select(func.count()).select_from(direct.subquery()))).scalar_one()
|
||||
effective_count = (await db.execute(select(func.count()).select_from(effective))).scalar_one()
|
||||
stmt = select(User).where(User.id.in_(select(effective.c.user_id)))
|
||||
if membership == 'direct':
|
||||
stmt = stmt.where(User.id.in_(direct))
|
||||
elif membership == 'inherited':
|
||||
stmt = stmt.where(User.id.not_in(direct))
|
||||
if query:
|
||||
stmt = stmt.where(or_(User.name.ilike(f'%{query}%'), User.email.ilike(f'%{query}%')))
|
||||
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one()
|
||||
users = (await db.execute(stmt.order_by(User.name, User.id).offset((page - 1) * 30).limit(30))).scalars().all()
|
||||
descendant = descendant_groups([id])
|
||||
sources = {u.id: [] for u in users}
|
||||
if sources:
|
||||
rows = await db.execute(
|
||||
select(GroupMember.user_id, GroupMember.group_id).where(
|
||||
GroupMember.user_id.in_(sources), GroupMember.group_id.in_(select(descendant.c.group_id))
|
||||
)
|
||||
)
|
||||
for uid, gid in rows:
|
||||
sources[uid].append(gid)
|
||||
return GroupMembersResponse(
|
||||
items=[
|
||||
GroupMemberInfo(
|
||||
id=u.id,
|
||||
name=u.name,
|
||||
email=u.email,
|
||||
role=u.role,
|
||||
membership_type='direct' if id in sources[u.id] else 'inherited',
|
||||
via_group_ids=sorted(gid for gid in sources[u.id] if gid != id),
|
||||
)
|
||||
for u in users
|
||||
],
|
||||
total=total,
|
||||
counts={
|
||||
'direct': direct_count,
|
||||
'effective': effective_count,
|
||||
'inherited': effective_count - direct_count,
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -180,7 +180,7 @@ async def get_knowledge_bases(
|
|||
skip = (page - 1) * limit
|
||||
|
||||
filter = {}
|
||||
groups = await Groups.get_groups_by_member_id(user.id, db=db)
|
||||
groups = await Groups.get_groups_by_member_id(user.id, db=db, include_inherited=True)
|
||||
user_group_ids = {group.id for group in groups}
|
||||
|
||||
if not user.role == 'admin' or not BYPASS_ADMIN_ACCESS_CONTROL:
|
||||
|
|
@ -245,7 +245,7 @@ async def search_knowledge_bases(
|
|||
if direction in {'asc', 'desc'}:
|
||||
filter['direction'] = direction
|
||||
|
||||
groups = await Groups.get_groups_by_member_id(user.id, db=db)
|
||||
groups = await Groups.get_groups_by_member_id(user.id, db=db, include_inherited=True)
|
||||
user_group_ids = {group.id for group in groups}
|
||||
|
||||
if not user.role == 'admin' or not BYPASS_ADMIN_ACCESS_CONTROL:
|
||||
|
|
@ -301,7 +301,7 @@ async def search_knowledge_files(
|
|||
if include_content:
|
||||
filter['include_content'] = True
|
||||
|
||||
groups = await Groups.get_groups_by_member_id(user.id, db=db)
|
||||
groups = await Groups.get_groups_by_member_id(user.id, db=db, include_inherited=True)
|
||||
if groups:
|
||||
filter['group_ids'] = [group.id for group in groups]
|
||||
|
||||
|
|
|
|||
|
|
@ -192,7 +192,7 @@ async def get_models(
|
|||
filter['direction'] = direction
|
||||
|
||||
# Pre-fetch user group IDs once - used for both filter and write_access check
|
||||
groups = await Groups.get_groups_by_member_id(user.id, db=db)
|
||||
groups = await Groups.get_groups_by_member_id(user.id, db=db, include_inherited=True)
|
||||
user_group_ids = {group.id for group in groups}
|
||||
|
||||
if not user.role == 'admin' or not BYPASS_ADMIN_ACCESS_CONTROL:
|
||||
|
|
@ -472,7 +472,7 @@ async def import_models(
|
|||
# per-model has_access calls (N+1 avoidance).
|
||||
existing_model_ids = list(existing_models.keys())
|
||||
if user.role != 'admin' and existing_model_ids:
|
||||
groups = await Groups.get_groups_by_member_id(user.id, db=db)
|
||||
groups = await Groups.get_groups_by_member_id(user.id, db=db, include_inherited=True)
|
||||
user_group_ids = {group.id for group in groups}
|
||||
writable_model_ids = await AccessGrants.get_accessible_resource_ids(
|
||||
user_id=user.id,
|
||||
|
|
|
|||
|
|
@ -201,7 +201,7 @@ async def search_notes(
|
|||
filter['direction'] = direction
|
||||
|
||||
if not user.role == 'admin' or not BYPASS_ADMIN_ACCESS_CONTROL:
|
||||
groups = await Groups.get_groups_by_member_id(user.id, db=db)
|
||||
groups = await Groups.get_groups_by_member_id(user.id, db=db, include_inherited=True)
|
||||
if groups:
|
||||
filter['group_ids'] = [group.id for group in groups]
|
||||
|
||||
|
|
|
|||
|
|
@ -455,7 +455,7 @@ async def get_filtered_models(models, user, db=None):
|
|||
"""Return only the models the given *user* is allowed to access."""
|
||||
model_ids = [m['model'] for m in models.get('models', [])]
|
||||
model_infos = {mi.id: mi for mi in await Models.get_models_by_ids(model_ids, db=db)}
|
||||
user_group_ids = {g.id for g in await Groups.get_groups_by_member_id(user.id, db=db)}
|
||||
user_group_ids = {g.id for g in await Groups.get_groups_by_member_id(user.id, db=db, include_inherited=True)}
|
||||
|
||||
accessible_ids = await AccessGrants.get_accessible_resource_ids(
|
||||
user_id=user.id,
|
||||
|
|
@ -1548,7 +1548,7 @@ async def get_openai_models(
|
|||
if user.role == 'user' and not BYPASS_MODEL_ACCESS_CONTROL:
|
||||
model_ids = [m['id'] for m in models]
|
||||
model_infos = {mi.id: mi for mi in await Models.get_models_by_ids(model_ids, db=db)}
|
||||
user_group_ids = {g.id for g in await Groups.get_groups_by_member_id(user.id, db=db)}
|
||||
user_group_ids = {g.id for g in await Groups.get_groups_by_member_id(user.id, db=db, include_inherited=True)}
|
||||
accessible_ids = await AccessGrants.get_accessible_resource_ids(
|
||||
user_id=user.id,
|
||||
resource_type='model',
|
||||
|
|
|
|||
|
|
@ -683,7 +683,9 @@ async def get_filtered_models(models, user, db=None):
|
|||
# Filter models based on user access control
|
||||
model_ids = [model['id'] for model in models.get('data', [])]
|
||||
model_infos = {model_info.id: model_info for model_info in await Models.get_models_by_ids(model_ids, db=db)}
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)}
|
||||
user_group_ids = {
|
||||
group.id for group in await Groups.get_groups_by_member_id(user.id, db=db, include_inherited=True)
|
||||
}
|
||||
|
||||
# Batch-fetch accessible resource IDs in a single query instead of N has_access calls
|
||||
accessible_model_ids = await AccessGrants.get_accessible_resource_ids(
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ async def get_prompt_list(
|
|||
filter['direction'] = direction
|
||||
|
||||
# Pre-fetch user group IDs once - used for both filter and write_access check
|
||||
groups = await Groups.get_groups_by_member_id(user.id, db=db)
|
||||
groups = await Groups.get_groups_by_member_id(user.id, db=db, include_inherited=True)
|
||||
user_group_ids = {group.id for group in groups}
|
||||
|
||||
if not (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL):
|
||||
|
|
|
|||
|
|
@ -14,12 +14,13 @@ from typing import Any, Dict, List, Optional
|
|||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.routing import APIRoute
|
||||
from open_webui.config import OAUTH_PROVIDERS
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.events import EVENTS, publish_event
|
||||
from open_webui.env import SCIM_AUTH_PROVIDER
|
||||
from open_webui.internal.db import get_async_session
|
||||
from open_webui.models.groups import GroupModel, Groups
|
||||
from open_webui.models.groups import GroupModel, Groups, GroupHierarchyError
|
||||
from open_webui.models.users import UserModel, Users
|
||||
from open_webui.utils.auth import (
|
||||
decode_token,
|
||||
|
|
@ -32,7 +33,21 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class SCIMGroupRoute(APIRoute):
|
||||
def get_route_handler(self):
|
||||
handler = super().get_route_handler()
|
||||
|
||||
async def handle(request):
|
||||
try:
|
||||
return await handler(request)
|
||||
except GroupHierarchyError as error:
|
||||
return scim_error(error.status_code, str(error), 'invalidValue')
|
||||
|
||||
return handle
|
||||
|
||||
|
||||
router = APIRouter(route_class=SCIMGroupRoute)
|
||||
|
||||
# SCIM 2.0 Schema URIs
|
||||
SCIM_USER_SCHEMA = 'urn:ietf:params:scim:schemas:core:2.0:User'
|
||||
|
|
@ -937,6 +952,19 @@ async def get_group(
|
|||
return await group_to_scim(group, request, db=db)
|
||||
|
||||
|
||||
async def validate_user_members(members, db):
|
||||
ids = []
|
||||
for member in members or []:
|
||||
value = member if isinstance(member, dict) else member.model_dump(by_alias=True)
|
||||
if value.get('type') not in (None, 'User') or '/Groups/' in (value.get('$ref') or ''):
|
||||
raise GroupHierarchyError('Only direct User members are supported by SCIM.')
|
||||
if not value.get('value'):
|
||||
raise GroupHierarchyError('A member user ID is required.')
|
||||
ids.append(value['value'])
|
||||
if set(await Users.get_valid_user_ids(ids, db=db)) != set(ids):
|
||||
raise GroupHierarchyError('One or more member users were not found.')
|
||||
|
||||
|
||||
@router.post('/Groups', response_model=SCIMGroup, status_code=status.HTTP_201_CREATED)
|
||||
async def create_group(
|
||||
request: Request,
|
||||
|
|
@ -945,6 +973,7 @@ async def create_group(
|
|||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Create SCIM Group"""
|
||||
await validate_user_members(group_data.members, db)
|
||||
# Extract member IDs
|
||||
member_ids = []
|
||||
if group_data.members:
|
||||
|
|
@ -1016,6 +1045,7 @@ async def update_group(
|
|||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Update SCIM Group (full update)"""
|
||||
await validate_user_members(group_data.members, db)
|
||||
group = await Groups.get_group_by_id(group_id, db=db)
|
||||
if not group:
|
||||
raise HTTPException(
|
||||
|
|
@ -1102,6 +1132,13 @@ async def patch_group(
|
|||
added_member_ids = []
|
||||
removed_member_ids = []
|
||||
|
||||
# Validate all requested assignments before applying any patch operation.
|
||||
for operation in patch_data.Operations:
|
||||
if operation.path == 'members' and operation.op.lower() in ('add', 'replace'):
|
||||
if not isinstance(operation.value, list):
|
||||
raise GroupHierarchyError('Members must be a list of users.')
|
||||
await validate_user_members(operation.value, db)
|
||||
|
||||
for operation in patch_data.Operations:
|
||||
op = operation.op.lower()
|
||||
path = operation.path
|
||||
|
|
@ -1184,7 +1221,8 @@ async def delete_group(
|
|||
detail=f'Group {group_id} not found',
|
||||
)
|
||||
|
||||
success = await Groups.delete_group_by_id(group_id, db=db)
|
||||
changes = {}
|
||||
success = await Groups.delete_group_by_id(group_id, db=db, changes=changes)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
|
|
@ -1196,7 +1234,15 @@ async def delete_group(
|
|||
EVENTS.GROUP_DELETED,
|
||||
subject_id=group_id,
|
||||
source='scim',
|
||||
data={'name': group.name},
|
||||
data={'name': group.name, **changes},
|
||||
)
|
||||
|
||||
for child_id in changes['promoted_child_ids']:
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.GROUP_UPDATED,
|
||||
subject_id=child_id,
|
||||
source='scim',
|
||||
data={'old_parent_group_id': group_id, 'parent_group_id': changes['parent_group_id']},
|
||||
)
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -86,7 +86,9 @@ async def get_skill_list(
|
|||
filter['direction'] = direction
|
||||
|
||||
is_bypass_admin = user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)}
|
||||
user_group_ids = {
|
||||
group.id for group in await Groups.get_groups_by_member_id(user.id, db=db, include_inherited=True)
|
||||
}
|
||||
|
||||
if not is_bypass_admin:
|
||||
filter['group_ids'] = user_group_ids
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ async def list_terminal_servers(request: Request, user=Depends(get_verified_user
|
|||
return []
|
||||
|
||||
connections = await Config.get('terminal_server.connections', []) or []
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)}
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, include_inherited=True)}
|
||||
|
||||
return [
|
||||
{
|
||||
|
|
@ -129,7 +129,7 @@ async def proxy_terminal(
|
|||
if not connection.get('enabled', True):
|
||||
return JSONResponse({'error': 'Terminal server disabled'}, status_code=403)
|
||||
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)}
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, include_inherited=True)}
|
||||
if not await has_connection_access(user, connection, user_group_ids):
|
||||
return JSONResponse({'error': 'Access denied'}, status_code=403)
|
||||
|
||||
|
|
|
|||
|
|
@ -81,7 +81,9 @@ async def get_tools(
|
|||
tools = []
|
||||
bypass_access_control = user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL
|
||||
user_group_ids = (
|
||||
set() if bypass_access_control else {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)}
|
||||
set()
|
||||
if bypass_access_control
|
||||
else {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db, include_inherited=True)}
|
||||
)
|
||||
|
||||
# Local Tools
|
||||
|
|
@ -236,7 +238,9 @@ async def get_tool_list(user=Depends(get_verified_user), db: AsyncSession = Depe
|
|||
|
||||
bypass_access_control = user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL
|
||||
user_group_ids = (
|
||||
set() if bypass_access_control else {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)}
|
||||
set()
|
||||
if bypass_access_control
|
||||
else {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db, include_inherited=True)}
|
||||
)
|
||||
tools = await Tools.get_tools(
|
||||
defer_content=True,
|
||||
|
|
|
|||
|
|
@ -167,8 +167,10 @@ async def search_users(
|
|||
|
||||
|
||||
@router.get('/groups')
|
||||
async def get_user_groups(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
|
||||
return await Groups.get_groups_by_member_id(user.id, db=db)
|
||||
async def get_user_groups(
|
||||
include_inherited: bool = False, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
return await user_groups_response(user.id, include_inherited, db)
|
||||
|
||||
|
||||
############################
|
||||
|
|
@ -1108,9 +1110,12 @@ async def delete_user_by_id(
|
|||
|
||||
@router.get('/{user_id}/groups')
|
||||
async def get_user_groups_by_id(
|
||||
user_id: str, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)
|
||||
user_id: str,
|
||||
include_inherited: bool = False,
|
||||
user=Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
return await Groups.get_groups_by_member_id(user_id, db=db)
|
||||
return await user_groups_response(user_id, include_inherited, db)
|
||||
|
||||
|
||||
############################
|
||||
|
|
@ -1133,7 +1138,7 @@ async def get_user_preview(
|
|||
)
|
||||
|
||||
# Get all group IDs this user belongs to
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db, include_inherited=True)
|
||||
user_group_ids = {g.id for g in user_groups}
|
||||
|
||||
all_models = await Models.get_all_models(db=db)
|
||||
|
|
@ -1189,3 +1194,12 @@ async def get_user_preview(
|
|||
'total': len(all_tools),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def user_groups_response(user_id, include_inherited, db):
|
||||
direct = await Groups.get_groups_by_member_id(user_id, db=db)
|
||||
if not include_inherited:
|
||||
return direct
|
||||
direct_ids = {g.id for g in direct}
|
||||
effective = await Groups.get_groups_by_member_id(user_id, db=db, include_inherited=True)
|
||||
return [{**g.model_dump(), 'membership_type': 'direct' if g.id in direct_ids else 'inherited'} for g in effective]
|
||||
|
|
|
|||
|
|
@ -434,7 +434,7 @@ async def leave_room_for_users(room: str, user_ids: list[str]):
|
|||
log.debug('Failed to make session %s leave room %s: %s', sid, room, e)
|
||||
|
||||
|
||||
async def disconnect_user_sessions(user_id: str):
|
||||
async def disconnect_user_sessions(user_id: str, *, refresh_access: bool = False):
|
||||
"""Disconnect all Socket.IO sessions belonging to a user.
|
||||
|
||||
Call this when a user's role is changed or the user is deleted so that
|
||||
|
|
@ -444,6 +444,11 @@ async def disconnect_user_sessions(user_id: str):
|
|||
"""
|
||||
session_ids = get_session_ids_by_user_id(user_id)
|
||||
for sid in session_ids:
|
||||
if refresh_access:
|
||||
try:
|
||||
await sio.emit('access:updated', {}, to=sid)
|
||||
except Exception:
|
||||
log.exception('Failed to notify session %s about changed access', sid)
|
||||
try:
|
||||
await sio.disconnect(sid)
|
||||
except Exception:
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ async def _has_write_access_to_note(note, user_id: str) -> bool:
|
|||
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
|
||||
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)]
|
||||
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id, include_inherited=True)]
|
||||
return await AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type='note',
|
||||
|
|
@ -1149,7 +1149,7 @@ async def search_notes(
|
|||
|
||||
try:
|
||||
user_id = __user__.get('id')
|
||||
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)]
|
||||
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id, include_inherited=True)]
|
||||
|
||||
result = await Notes.search_notes(
|
||||
user_id=user_id,
|
||||
|
|
@ -1246,7 +1246,7 @@ async def view_note(
|
|||
|
||||
# Check access permission
|
||||
user_id = __user__.get('id')
|
||||
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)]
|
||||
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id, include_inherited=True)]
|
||||
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
|
||||
|
|
@ -2067,7 +2067,7 @@ async def list_knowledge_bases(
|
|||
from open_webui.models.knowledge import Knowledges
|
||||
|
||||
user_id = __user__.get('id')
|
||||
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)]
|
||||
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id, include_inherited=True)]
|
||||
|
||||
result = await Knowledges.search_knowledge_bases(
|
||||
user_id,
|
||||
|
|
@ -2127,7 +2127,7 @@ async def search_knowledge_bases(
|
|||
from open_webui.models.knowledge import Knowledges
|
||||
|
||||
user_id = __user__.get('id')
|
||||
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)]
|
||||
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id, include_inherited=True)]
|
||||
|
||||
result = await Knowledges.search_knowledge_bases(
|
||||
user_id,
|
||||
|
|
@ -2194,7 +2194,7 @@ async def search_knowledge_files(
|
|||
|
||||
user_id = __user__.get('id')
|
||||
user_role = __user__.get('role', 'user')
|
||||
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)]
|
||||
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id, include_inherited=True)]
|
||||
|
||||
# When model has attached knowledge, scope to attached KBs/files only
|
||||
if __model_knowledge__:
|
||||
|
|
@ -2663,7 +2663,7 @@ async def grep_knowledge_files(
|
|||
|
||||
user_id = __user__.get('id')
|
||||
user_role = __user__.get('role', 'user')
|
||||
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)]
|
||||
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id, include_inherited=True)]
|
||||
|
||||
# Collect files to search
|
||||
files_to_search = []
|
||||
|
|
@ -2910,7 +2910,7 @@ async def view_knowledge_file(
|
|||
|
||||
user_id = __user__.get('id')
|
||||
user_role = __user__.get('role', 'user')
|
||||
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)]
|
||||
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id, include_inherited=True)]
|
||||
|
||||
file = await Files.get_file_by_id(file_id)
|
||||
if not file:
|
||||
|
|
@ -3060,7 +3060,7 @@ async def list_knowledge(
|
|||
|
||||
user_id = __user__.get('id')
|
||||
user_role = __user__.get('role', 'user')
|
||||
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)]
|
||||
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id, include_inherited=True)]
|
||||
|
||||
knowledge_bases = []
|
||||
files = []
|
||||
|
|
@ -3201,7 +3201,7 @@ async def query_knowledge_files(
|
|||
|
||||
user_id = __user__.get('id')
|
||||
user_role = __user__.get('role', 'user')
|
||||
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)]
|
||||
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id, include_inherited=True)]
|
||||
|
||||
embedding_function = getattr(__request__.app.state, 'EMBEDDING_FUNCTION', None)
|
||||
if not embedding_function:
|
||||
|
|
@ -3398,7 +3398,7 @@ async def query_knowledge_bases(
|
|||
from open_webui.routers.knowledge import KNOWLEDGE_BASES_COLLECTION
|
||||
|
||||
user_id = __user__.get('id')
|
||||
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)]
|
||||
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id, include_inherited=True)]
|
||||
embedding_function = getattr(__request__.app.state, 'EMBEDDING_FUNCTION', None)
|
||||
if not embedding_function:
|
||||
return JSONCodec.dumps({'error': 'Embedding function not configured'})
|
||||
|
|
@ -3526,7 +3526,9 @@ async def view_skill(
|
|||
# Check user access
|
||||
user_role = __user__.get('role', 'user')
|
||||
if user_role != 'admin' and skill.user_id != user_id:
|
||||
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)]
|
||||
user_group_ids = [
|
||||
group.id for group in await Groups.get_groups_by_member_id(user_id, include_inherited=True)
|
||||
]
|
||||
if not await AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type='skill',
|
||||
|
|
@ -4297,7 +4299,7 @@ async def create_calendar_event(
|
|||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.groups import Groups
|
||||
|
||||
user_group_ids = [g.id for g in await Groups.get_groups_by_member_id(user_id)]
|
||||
user_group_ids = [g.id for g in await Groups.get_groups_by_member_id(user_id, include_inherited=True)]
|
||||
if not await AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type='calendar',
|
||||
|
|
@ -4417,7 +4419,7 @@ async def update_calendar_event(
|
|||
if not cal:
|
||||
return JSONCodec.dumps({'error': 'Access denied'})
|
||||
if cal.user_id != user_id and __user__.get('role') != 'admin':
|
||||
user_group_ids = [g.id for g in await Groups.get_groups_by_member_id(user_id)]
|
||||
user_group_ids = [g.id for g in await Groups.get_groups_by_member_id(user_id, include_inherited=True)]
|
||||
if not await AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type='calendar',
|
||||
|
|
@ -4521,7 +4523,7 @@ async def delete_calendar_event(
|
|||
if not cal:
|
||||
return JSONCodec.dumps({'error': 'Access denied'})
|
||||
if cal.user_id != user_id and __user__.get('role') != 'admin':
|
||||
user_group_ids = [g.id for g in await Groups.get_groups_by_member_id(user_id)]
|
||||
user_group_ids = [g.id for g in await Groups.get_groups_by_member_id(user_id, include_inherited=True)]
|
||||
if not await AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type='calendar',
|
||||
|
|
|
|||
|
|
@ -314,7 +314,7 @@ async def _get_accessible_kb_ids(
|
|||
|
||||
user_id = user.get('id')
|
||||
user_role = user.get('role', 'user')
|
||||
user_group_ids = [g.id for g in await Groups.get_groups_by_member_id(user_id)]
|
||||
user_group_ids = [g.id for g in await Groups.get_groups_by_member_id(user_id, include_inherited=True)]
|
||||
|
||||
async def _has_access(kb):
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -32,6 +32,21 @@ def fill_missing_permissions(permissions: dict[str, Any], default_permissions: d
|
|||
return permissions
|
||||
|
||||
|
||||
def combine_permissions(permissions: dict[str, Any], group_permissions: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Combine permissions from multiple groups by taking the most permissive value."""
|
||||
for key, value in group_permissions.items():
|
||||
if isinstance(value, dict):
|
||||
if key not in permissions:
|
||||
permissions[key] = {}
|
||||
permissions[key] = combine_permissions(permissions[key], value)
|
||||
else:
|
||||
if key not in permissions:
|
||||
permissions[key] = value
|
||||
else:
|
||||
permissions[key] = permissions[key] or value # Use the most permissive value (True > False)
|
||||
return permissions
|
||||
|
||||
|
||||
async def get_permissions(
|
||||
user_id: str,
|
||||
default_permissions: dict[str, Any],
|
||||
|
|
@ -43,21 +58,7 @@ async def get_permissions(
|
|||
Permissions are nested in a dict with the permission key as the key and a boolean as the value.
|
||||
"""
|
||||
|
||||
def combine_permissions(permissions: dict[str, Any], group_permissions: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Combine permissions from multiple groups by taking the most permissive value."""
|
||||
for key, value in group_permissions.items():
|
||||
if isinstance(value, dict):
|
||||
if key not in permissions:
|
||||
permissions[key] = {}
|
||||
permissions[key] = combine_permissions(permissions[key], value)
|
||||
else:
|
||||
if key not in permissions:
|
||||
permissions[key] = value
|
||||
else:
|
||||
permissions[key] = permissions[key] or value # Use the most permissive value (True > False)
|
||||
return permissions
|
||||
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db, include_inherited=True)
|
||||
|
||||
# Deep copy default permissions to avoid modifying the original dict
|
||||
permissions = JSONCodec.loads(JSONCodec.dumps(default_permissions))
|
||||
|
|
@ -97,7 +98,7 @@ async def has_permission(
|
|||
permission_hierarchy = permission_key.split('.')
|
||||
|
||||
# Retrieve user group permissions
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db, include_inherited=True)
|
||||
|
||||
for group in user_groups:
|
||||
if get_permission(group.permissions or {}, permission_hierarchy):
|
||||
|
|
@ -130,7 +131,7 @@ async def has_access(
|
|||
return False
|
||||
|
||||
if user_group_ids is None:
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db, include_inherited=True)
|
||||
user_group_ids = {group.id for group in user_groups}
|
||||
|
||||
for grant in access_grants:
|
||||
|
|
@ -174,7 +175,7 @@ async def has_connection_access(
|
|||
return user.role == 'admin'
|
||||
|
||||
if user_group_ids is None:
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)}
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, include_inherited=True)}
|
||||
|
||||
return await has_access(user.id, 'read', access_grants, user_group_ids)
|
||||
|
||||
|
|
@ -386,7 +387,9 @@ async def check_model_access(
|
|||
if user.role != 'admin':
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)}
|
||||
user_group_ids = {
|
||||
group.id for group in await Groups.get_groups_by_member_id(user.id, include_inherited=True)
|
||||
}
|
||||
if not (
|
||||
user.id == model_info.user_id
|
||||
or await AccessGrants.has_access(
|
||||
|
|
|
|||
|
|
@ -48,7 +48,9 @@ async def has_access_to_file(
|
|||
# the user controls would gain write/delete on it (CWE-863). Read access is unaffected.
|
||||
knowledge_bases = await Knowledges.get_knowledges_by_file_id(file_id, db=db)
|
||||
if user_group_ids is None:
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)}
|
||||
user_group_ids = {
|
||||
group.id for group in await Groups.get_groups_by_member_id(user.id, db=db, include_inherited=True)
|
||||
}
|
||||
for knowledge_base in knowledge_bases:
|
||||
if (
|
||||
knowledge_base.user_id == user.id
|
||||
|
|
@ -124,7 +126,9 @@ async def get_accessible_folder_files(
|
|||
return entries
|
||||
|
||||
if user_group_ids is None:
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)}
|
||||
user_group_ids = {
|
||||
group.id for group in await Groups.get_groups_by_member_id(user.id, db=db, include_inherited=True)
|
||||
}
|
||||
|
||||
accessible: list[dict] = []
|
||||
for entry in entries:
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ async def get_user_groups_for_custom_headers(
|
|||
return None
|
||||
|
||||
try:
|
||||
return await Groups.get_groups_by_member_id(user.id)
|
||||
return await Groups.get_groups_by_member_id(user.id, include_inherited=True)
|
||||
except Exception:
|
||||
log.exception('Failed to resolve user groups for custom headers')
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -507,7 +507,9 @@ async def check_model_access(user, model, model_info=None, db=None):
|
|||
# base-model hop; skipped when no check below needs it.
|
||||
user_group_ids = None
|
||||
if user.id != model_info.user_id or model_info.base_model_id:
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)}
|
||||
user_group_ids = {
|
||||
group.id for group in await Groups.get_groups_by_member_id(user.id, db=db, include_inherited=True)
|
||||
}
|
||||
|
||||
if not (
|
||||
user.id == model_info.user_id
|
||||
|
|
@ -547,7 +549,9 @@ async def get_filtered_models(models, user, db=None):
|
|||
if info:
|
||||
model_infos[model['id']] = info
|
||||
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)}
|
||||
user_group_ids = {
|
||||
group.id for group in await Groups.get_groups_by_member_id(user.id, db=db, include_inherited=True)
|
||||
}
|
||||
|
||||
# Batch-fetch accessible resource IDs in a single query instead of N has_access calls
|
||||
accessible_model_ids = await AccessGrants.get_accessible_resource_ids(
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ async def prompt_template(template: str, user: Optional[Any] = None) -> str:
|
|||
try:
|
||||
from open_webui.models.groups import Groups
|
||||
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id)
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, include_inherited=True)
|
||||
groups = ', '.join(g.name for g in user_groups)
|
||||
except Exception:
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -153,7 +153,9 @@ async def get_terminal_json(request, user, metadata: dict, path: str, extra_para
|
|||
or (config.get('context_id') in {'chat_id', 'automation_id'} and not context_id)
|
||||
):
|
||||
return None
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user_model.id)}
|
||||
user_group_ids = {
|
||||
group.id for group in await Groups.get_groups_by_member_id(user_model.id, include_inherited=True)
|
||||
}
|
||||
if not await has_connection_access(user_model, connection, user_group_ids):
|
||||
return None
|
||||
|
||||
|
|
|
|||
|
|
@ -352,7 +352,7 @@ async def get_tools(request: Request, tool_ids: list[str], user: UserModel, extr
|
|||
tools_dict = {}
|
||||
|
||||
# Get user's group memberships for access control checks
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)}
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, include_inherited=True)}
|
||||
|
||||
# Batch-fetch all DB tools in one query instead of one per tool_id
|
||||
local_tool_ids = [tool_id for tool_id in tool_ids if not tool_id.startswith('server:')]
|
||||
|
|
@ -1471,7 +1471,7 @@ async def get_terminal_tools(
|
|||
if not connection.get('enabled', True):
|
||||
raise RuntimeError(f"Terminal server '{terminal_id}' is disabled")
|
||||
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)}
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, include_inherited=True)}
|
||||
if not await has_connection_access(user, connection, user_group_ids):
|
||||
raise RuntimeError(f'Access denied to terminal {terminal_id}')
|
||||
|
||||
|
|
|
|||
|
|
@ -295,3 +295,18 @@ export const getGroupPreview = async (token: string, id: string) => {
|
|||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getGroupMembers = async (
|
||||
token: string,
|
||||
id: string,
|
||||
membership: 'direct' | 'inherited' | 'effective' = 'effective',
|
||||
query = '',
|
||||
page = 1
|
||||
) => {
|
||||
const params = new URLSearchParams({ membership, query, page: String(page) });
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/groups/id/${id}/members?${params}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (!res.ok) throw (await res.json()).detail;
|
||||
return res.json();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
<script context="module">
|
||||
/** @typedef {{ id: string, name: string, parent_group_id?: string | null, path: string, ancestor_ids: string[] }} GroupListItem */
|
||||
</script>
|
||||
|
||||
<script>
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { onMount, getContext } from 'svelte';
|
||||
|
|
@ -11,8 +15,9 @@
|
|||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
|
||||
import Check from '$lib/components/icons/Check.svelte';
|
||||
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
|
||||
import Select from '$lib/components/common/Select.svelte';
|
||||
import { createNewGroup, getGroups } from '$lib/apis/groups';
|
||||
import { createNewGroup, getGroups, updateGroupById } from '$lib/apis/groups';
|
||||
import { getUserDefaultPermissions, updateUserDefaultPermissions } from '$lib/apis/users';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
|
@ -23,33 +28,95 @@
|
|||
let groups = [];
|
||||
|
||||
let query = '';
|
||||
let sortBy = 'members';
|
||||
let sortBy = 'name';
|
||||
let collapsed = new Set();
|
||||
/** @type {any} */
|
||||
let draggedGroup = null;
|
||||
let dropTarget = '';
|
||||
let moving = false;
|
||||
|
||||
const sortItems = [
|
||||
{ value: 'members', label: $i18n.t('Members') },
|
||||
{ value: 'name', label: $i18n.t('Name') }
|
||||
];
|
||||
|
||||
$: filteredGroups = groups
|
||||
.filter((group) => {
|
||||
if (query === '') {
|
||||
return true;
|
||||
} else {
|
||||
let name = group.name.toLowerCase();
|
||||
const q = query.toLowerCase();
|
||||
return name.includes(q);
|
||||
/** @type {any[]} */
|
||||
let filteredGroups = [];
|
||||
/** @type {Map<string | null, any[]>} */
|
||||
let children = new Map();
|
||||
$: {
|
||||
children = new Map();
|
||||
for (const group of groups) {
|
||||
const parent = group.parent_group_id ?? null;
|
||||
children.set(parent, [...(children.get(parent) ?? []), group]);
|
||||
}
|
||||
for (const siblings of children.values()) {
|
||||
siblings.sort((a, b) =>
|
||||
sortBy === 'members'
|
||||
? (b.member_count ?? 0) - (a.member_count ?? 0) || a.name.localeCompare(b.name)
|
||||
: a.name.localeCompare(b.name)
|
||||
);
|
||||
}
|
||||
const matching = new Set();
|
||||
for (const group of groups) {
|
||||
if (group.path.toLowerCase().includes(query.toLowerCase())) {
|
||||
matching.add(group.id);
|
||||
for (const id of group.ancestor_ids) matching.add(id);
|
||||
}
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (sortBy === 'name') {
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
const visible = [];
|
||||
const pending = [...(children.get(null) ?? [])].reverse();
|
||||
while (pending.length) {
|
||||
const group = pending.pop();
|
||||
if (!matching.has(group.id)) continue;
|
||||
visible.push(group);
|
||||
if (query || !collapsed.has(group.id)) {
|
||||
pending.push(...[...(children.get(group.id) ?? [])].reverse());
|
||||
}
|
||||
}
|
||||
filteredGroups = visible;
|
||||
}
|
||||
|
||||
return (b.member_count ?? 0) - (a.member_count ?? 0) || a.name.localeCompare(b.name);
|
||||
});
|
||||
/** @param {string | null} parentId */
|
||||
const canDrop = (parentId) => {
|
||||
const parent = groups.find((group) => group.id === parentId);
|
||||
return (
|
||||
draggedGroup &&
|
||||
!moving &&
|
||||
(draggedGroup.parent_group_id ?? null) !== parentId &&
|
||||
draggedGroup.id !== parentId &&
|
||||
!parent?.ancestor_ids.includes(draggedGroup.id)
|
||||
);
|
||||
};
|
||||
|
||||
/** @param {string | null} parentId */
|
||||
const moveGroup = async (parentId) => {
|
||||
if (!canDrop(parentId)) return;
|
||||
const group = draggedGroup;
|
||||
draggedGroup = null;
|
||||
dropTarget = '';
|
||||
moving = true;
|
||||
try {
|
||||
await updateGroupById(localStorage.token, group.id, {
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
parent_group_id: parentId
|
||||
});
|
||||
collapsed.delete(parentId);
|
||||
collapsed = new Set(collapsed);
|
||||
await setGroups();
|
||||
toast.success($i18n.t('Group moved successfully'));
|
||||
} catch (error) {
|
||||
toast.error(String(error));
|
||||
} finally {
|
||||
moving = false;
|
||||
}
|
||||
};
|
||||
|
||||
$: if (loaded) {
|
||||
adminGroupCount.set(filteredGroups.length);
|
||||
adminGroupCount.set(
|
||||
groups.filter((group) => group.path.toLowerCase().includes(query.toLowerCase())).length
|
||||
);
|
||||
}
|
||||
|
||||
/** @type {any} */
|
||||
|
|
@ -59,12 +126,31 @@
|
|||
let showDefaultPermissionsModal = false;
|
||||
|
||||
const setGroups = async () => {
|
||||
groups = await getGroups(localStorage.token);
|
||||
/** @type {any[]} */
|
||||
const result = await getGroups(localStorage.token);
|
||||
const byId = new Map(result.map((group) => [group.id, group]));
|
||||
groups = result.map((group) => {
|
||||
const ancestors = [];
|
||||
const seen = new Set([group.id]);
|
||||
let parent = byId.get(group.parent_group_id);
|
||||
while (parent && !seen.has(parent.id)) {
|
||||
seen.add(parent.id);
|
||||
ancestors.unshift(parent);
|
||||
parent = byId.get(parent.parent_group_id);
|
||||
}
|
||||
return {
|
||||
...group,
|
||||
path: [...ancestors, group].map((item) => item.name).join(' / '),
|
||||
ancestor_ids: ancestors.map((item) => item.id)
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/** @param {any} updatedGroup */
|
||||
const updateGroup = (updatedGroup) => {
|
||||
groups = groups.map((group) => (group.id === updatedGroup.id ? updatedGroup : group));
|
||||
groups = groups.map((group) =>
|
||||
group.id === updatedGroup.id ? { ...group, ...updatedGroup } : group
|
||||
);
|
||||
};
|
||||
|
||||
/** @param {any} group */
|
||||
|
|
@ -78,6 +164,7 @@
|
|||
toast.success($i18n.t('Group created successfully'));
|
||||
await setGroups();
|
||||
}
|
||||
return !!res;
|
||||
};
|
||||
|
||||
/** @param {any} group */
|
||||
|
|
@ -95,6 +182,7 @@
|
|||
toast.success($i18n.t('Default permissions updated successfully'));
|
||||
defaultPermissions = await getUserDefaultPermissions(localStorage.token);
|
||||
}
|
||||
return !!res;
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
|
|
@ -116,6 +204,7 @@
|
|||
edit={false}
|
||||
tabs={['general', 'permissions']}
|
||||
permissions={defaultPermissions}
|
||||
{groups}
|
||||
onSubmit={addGroupHandler}
|
||||
/>
|
||||
{/if}
|
||||
|
|
@ -185,13 +274,105 @@
|
|||
</div>
|
||||
|
||||
{#if filteredGroups.length !== 0}
|
||||
<div class="mt-1 grid grid-cols-1">
|
||||
{#each filteredGroups as group, idx (group.id)}
|
||||
<GroupItem {group} {setGroups} {updateGroup} {defaultPermissions} />
|
||||
{#if idx < filteredGroups.length - 1}
|
||||
<hr class="border-gray-50 dark:border-gray-850/40" />
|
||||
{/if}
|
||||
<div class="mt-2" aria-label={$i18n.t('Group hierarchy')}>
|
||||
{#each filteredGroups as group (group.id)}
|
||||
<div
|
||||
role="group"
|
||||
aria-label={group.name}
|
||||
class="relative flex items-center rounded-xl transition {dropTarget === group.id
|
||||
? 'bg-gray-100/40 dark:bg-gray-800/30'
|
||||
: 'hover:bg-gray-50/60 dark:hover:bg-gray-900'} {draggedGroup?.id === group.id
|
||||
? 'opacity-40'
|
||||
: ''}"
|
||||
style:padding-left={`${Math.min(group.ancestor_ids.length, 8) * 20}px`}
|
||||
on:dragover={(event) => {
|
||||
if (canDrop(group.id)) {
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
|
||||
dropTarget = group.id;
|
||||
}
|
||||
}}
|
||||
on:dragleave={(event) => {
|
||||
if (
|
||||
!(event.relatedTarget instanceof Node) ||
|
||||
!event.currentTarget.contains(event.relatedTarget)
|
||||
)
|
||||
dropTarget = '';
|
||||
}}
|
||||
on:drop|preventDefault={() => moveGroup(group.id)}
|
||||
>
|
||||
{#each group.ancestor_ids.slice(0, 8) as ancestorId, depth}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute top-0 bottom-0 border-l border-gray-100 dark:border-gray-900"
|
||||
style:left={`${depth * 20 + 11}px`}
|
||||
></span>
|
||||
{/each}
|
||||
{#if children.has(group.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="z-10 flex size-6 shrink-0 items-center justify-center rounded-md text-gray-500 hover:text-gray-900 dark:hover:text-gray-100"
|
||||
aria-label={$i18n.t(
|
||||
collapsed.has(group.id) ? 'Expand {{name}}' : 'Collapse {{name}}',
|
||||
{ name: group.name }
|
||||
)}
|
||||
aria-expanded={!!query || !collapsed.has(group.id)}
|
||||
on:click={() => {
|
||||
collapsed.has(group.id) ? collapsed.delete(group.id) : collapsed.add(group.id);
|
||||
collapsed = new Set(collapsed);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="transition-transform"
|
||||
class:rotate-90={!!query || !collapsed.has(group.id)}
|
||||
>
|
||||
<ChevronRight className="size-3.5" />
|
||||
</div>
|
||||
</button>
|
||||
{:else}<span class="w-6 shrink-0"></span>{/if}
|
||||
<div class="min-w-0 flex-1">
|
||||
<GroupItem
|
||||
{group}
|
||||
{groups}
|
||||
{setGroups}
|
||||
{updateGroup}
|
||||
{defaultPermissions}
|
||||
draggable={!moving}
|
||||
onDragStart={(event) => {
|
||||
if (!event.dataTransfer) return;
|
||||
draggedGroup = group;
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData('text/plain', group.id);
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
draggedGroup = null;
|
||||
dropTarget = '';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{#if draggedGroup}
|
||||
<div
|
||||
role="region"
|
||||
aria-label={$i18n.t('Move to top level')}
|
||||
class="mt-1 rounded-lg px-3 py-2 text-xs transition {dropTarget === 'root'
|
||||
? 'bg-gray-100/40 text-gray-500 dark:bg-gray-800/30 dark:text-gray-400'
|
||||
: 'text-gray-400 dark:text-gray-500'}"
|
||||
on:dragover={(event) => {
|
||||
if (canDrop(null)) {
|
||||
event.preventDefault();
|
||||
dropTarget = 'root';
|
||||
}
|
||||
}}
|
||||
on:dragleave={() => {
|
||||
dropTarget = '';
|
||||
}}
|
||||
on:drop|preventDefault={() => moveGroup(null)}
|
||||
>
|
||||
{$i18n.t('Move to top level')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex w-full flex-col items-center justify-center py-16 pb-24">
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
<script lang="ts">
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { getContext, onMount } from 'svelte';
|
||||
const i18n = getContext('i18n');
|
||||
const i18n = getContext<import('svelte/store').Writable<import('i18next').i18n>>('i18n');
|
||||
|
||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||
import Modal from '$lib/components/common/Modal.svelte';
|
||||
import General from './General.svelte';
|
||||
import Permissions from './Permissions.svelte';
|
||||
import Users from './Users.svelte';
|
||||
import InheritedMembers from './InheritedMembers.svelte';
|
||||
import { getGroupPreview } from '$lib/apis/groups';
|
||||
import GroupPreviewPanel from './GroupPreviewPanel.svelte';
|
||||
import { DEFAULT_PERMISSIONS } from '$lib/constants/permissions';
|
||||
import { getUserDefaultPermissions, getUserDefaultPermissionsDefaults } from '$lib/apis/users';
|
||||
|
|
@ -24,8 +26,38 @@
|
|||
export let show = false;
|
||||
export let edit = false;
|
||||
|
||||
export let group = null;
|
||||
export let defaultPermissions = {};
|
||||
export let group: any = null;
|
||||
export let groups: import('../Groups.svelte').GroupListItem[] = [];
|
||||
let parent_group_id: string | null = null;
|
||||
let membershipTab = 'direct';
|
||||
let inheritedPermissions: Record<string, any> = {};
|
||||
let loadedParent: string | null | undefined = undefined;
|
||||
let inheritanceRequest = 0;
|
||||
async function loadInheritance(parentId: string | null) {
|
||||
loadedParent = parentId;
|
||||
const request = ++inheritanceRequest;
|
||||
inheritedPermissions = defaultPermissions;
|
||||
if (!parentId) return;
|
||||
try {
|
||||
const preview = await getGroupPreview(localStorage.token, parentId);
|
||||
if (request === inheritanceRequest) inheritedPermissions = preview.effective_permissions;
|
||||
} catch (e) {
|
||||
if (request === inheritanceRequest && show) toast.error(String(e));
|
||||
}
|
||||
}
|
||||
$: deleteMessage = group?.parent_group_id
|
||||
? $i18n.t(
|
||||
'Delete {{name}}? Its child groups will move to {{parent}}. Permissions and direct memberships belonging to this group will be removed.',
|
||||
{
|
||||
name: group?.name,
|
||||
parent: groups.find((item) => item.id === group.parent_group_id)?.path
|
||||
}
|
||||
)
|
||||
: $i18n.t(
|
||||
'Delete {{name}}? Its child groups will become top-level groups. Permissions and direct memberships belonging to this group will be removed.',
|
||||
{ name: group?.name }
|
||||
);
|
||||
export let defaultPermissions: Record<string, any> = {};
|
||||
|
||||
export let custom = true;
|
||||
|
||||
|
|
@ -40,7 +72,7 @@
|
|||
|
||||
export let name = '';
|
||||
export let description = '';
|
||||
export let data = {};
|
||||
export let data: Record<string, any> = {};
|
||||
|
||||
export let permissions = DEFAULT_PERMISSIONS;
|
||||
|
||||
|
|
@ -51,13 +83,17 @@
|
|||
name,
|
||||
description,
|
||||
data,
|
||||
permissions
|
||||
permissions,
|
||||
...(custom ? { parent_group_id } : {})
|
||||
};
|
||||
|
||||
await onSubmit(group);
|
||||
|
||||
loading = false;
|
||||
show = false;
|
||||
try {
|
||||
if (await onSubmit(group)) show = false;
|
||||
} catch (error) {
|
||||
toast.error(String(error));
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resetToDefaultsHandler = async () => {
|
||||
|
|
@ -86,8 +122,9 @@
|
|||
const init = () => {
|
||||
if (group) {
|
||||
name = group.name;
|
||||
parent_group_id = group.parent_group_id ?? null;
|
||||
description = group.description;
|
||||
const loadedPermissions = group?.permissions ?? {};
|
||||
const loadedPermissions: Record<string, any> = group?.permissions ?? {};
|
||||
// Create fresh object from defaults, then overlay loaded values
|
||||
permissions = {
|
||||
workspace: { ...DEFAULT_PERMISSIONS.workspace, ...loadedPermissions.workspace },
|
||||
|
|
@ -106,6 +143,13 @@
|
|||
$: if (show) {
|
||||
init();
|
||||
}
|
||||
$: if (!show) {
|
||||
loadedParent = undefined;
|
||||
inheritanceRequest++;
|
||||
membershipTab = 'direct';
|
||||
}
|
||||
|
||||
$: if (show && parent_group_id !== loadedParent) loadInheritance(parent_group_id);
|
||||
|
||||
onMount(() => {
|
||||
selectedTab = tabs[0];
|
||||
|
|
@ -115,9 +159,14 @@
|
|||
|
||||
<ConfirmDialog
|
||||
bind:show={showDeleteConfirmDialog}
|
||||
on:confirm={() => {
|
||||
onDelete();
|
||||
show = false;
|
||||
title={$i18n.t('Delete group')}
|
||||
message={deleteMessage}
|
||||
on:confirm={async () => {
|
||||
try {
|
||||
if (await onDelete()) show = false;
|
||||
} catch (error) {
|
||||
toast.error(String(error));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
|
|
@ -172,7 +221,7 @@
|
|||
>
|
||||
{#if tabs.includes('general')}
|
||||
<button
|
||||
class="px-0.5 py-1 max-w-fit w-fit rounded-lg flex-1 lg:flex-none flex text-right transition {selectedTab ===
|
||||
class="px-0.5 py-1 max-w-fit w-fit rounded-lg shrink-0 whitespace-nowrap flex text-right transition {selectedTab ===
|
||||
'general'
|
||||
? ''
|
||||
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
|
||||
|
|
@ -201,7 +250,7 @@
|
|||
|
||||
{#if tabs.includes('permissions')}
|
||||
<button
|
||||
class="px-0.5 py-1 max-w-fit w-fit rounded-lg flex-1 lg:flex-none flex text-right transition {selectedTab ===
|
||||
class="px-0.5 py-1 max-w-fit w-fit rounded-lg shrink-0 whitespace-nowrap flex text-right transition {selectedTab ===
|
||||
'permissions'
|
||||
? ''
|
||||
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
|
||||
|
|
@ -219,7 +268,7 @@
|
|||
|
||||
{#if tabs.includes('users')}
|
||||
<button
|
||||
class="px-0.5 py-1 max-w-fit w-fit rounded-lg flex-1 lg:flex-none flex text-right transition {selectedTab ===
|
||||
class="px-0.5 py-1 max-w-fit w-fit rounded-lg shrink-0 whitespace-nowrap flex text-right transition {selectedTab ===
|
||||
'users'
|
||||
? ''
|
||||
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
|
||||
|
|
@ -237,7 +286,7 @@
|
|||
|
||||
{#if tabs.includes('preview')}
|
||||
<button
|
||||
class="px-0.5 py-1 max-w-fit w-fit rounded-lg flex-1 lg:flex-none flex text-right transition {selectedTab ===
|
||||
class="px-0.5 py-1 max-w-fit w-fit rounded-lg shrink-0 whitespace-nowrap flex text-right transition {selectedTab ===
|
||||
'preview'
|
||||
? ''
|
||||
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
|
||||
|
|
@ -270,6 +319,9 @@
|
|||
<div class="w-full h-full overflow-y-auto scrollbar-hidden">
|
||||
{#if selectedTab == 'general'}
|
||||
<General
|
||||
{groups}
|
||||
groupId={group?.id}
|
||||
bind:parent_group_id
|
||||
bind:name
|
||||
bind:description
|
||||
bind:data
|
||||
|
|
@ -279,9 +331,37 @@
|
|||
}}
|
||||
/>
|
||||
{:else if selectedTab == 'permissions'}
|
||||
<Permissions bind:permissions {defaultPermissions} />
|
||||
{#if custom && parent_group_id}
|
||||
<p class="text-xs text-gray-500 mb-3">
|
||||
{$i18n.t(
|
||||
'Parent permissions are inherited. Disabling a local permission does not revoke inherited access.'
|
||||
)}
|
||||
</p>
|
||||
{/if}
|
||||
<Permissions
|
||||
bind:permissions
|
||||
defaultPermissions={custom ? inheritedPermissions : defaultPermissions}
|
||||
/>
|
||||
{:else if selectedTab == 'users'}
|
||||
<Users bind:userCount groupId={group?.id} {onMemberChange} />
|
||||
<div class="flex gap-3 mb-3 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
class:underline={membershipTab === 'direct'}
|
||||
on:click={() => (membershipTab = 'direct')}
|
||||
>{$i18n.t('Direct members')}</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class:underline={membershipTab === 'inherited'}
|
||||
on:click={() => (membershipTab = 'inherited')}
|
||||
>{$i18n.t('Inherited members')}</button
|
||||
>
|
||||
</div>
|
||||
{#if membershipTab === 'direct'}
|
||||
<Users bind:userCount groupId={group?.id} {onMemberChange} />
|
||||
{:else}
|
||||
<InheritedMembers groupId={group?.id} {groups} />
|
||||
{/if}
|
||||
{:else if selectedTab == 'preview'}
|
||||
<GroupPreviewPanel groupId={group?.id} />
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,36 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import Textarea from '$lib/components/common/Textarea.svelte';
|
||||
import Dropdown from '$lib/components/common/Dropdown.svelte';
|
||||
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
|
||||
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
|
||||
import Check from '$lib/components/icons/Check.svelte';
|
||||
import Search from '$lib/components/icons/Search.svelte';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
const i18n = getContext<import('svelte/store').Writable<import('i18next').i18n>>('i18n');
|
||||
|
||||
export let name = '';
|
||||
export let color = '';
|
||||
export let description = '';
|
||||
export let data = {};
|
||||
export let data: Record<string, any> = {};
|
||||
|
||||
export let edit = false;
|
||||
export let groupId: string | undefined = undefined;
|
||||
export let groups: import('../Groups.svelte').GroupListItem[] = [];
|
||||
export let parent_group_id: string | null = null;
|
||||
let parentSearch = '';
|
||||
let showParents = false;
|
||||
$: selectedParent = groups.find((group) => group.id === parent_group_id);
|
||||
$: candidates = groups
|
||||
.filter(
|
||||
(group) =>
|
||||
group.id !== groupId &&
|
||||
(!groupId || !group.ancestor_ids.includes(groupId)) &&
|
||||
(group.id === parent_group_id ||
|
||||
group.path.toLowerCase().includes(parentSearch.toLowerCase()))
|
||||
)
|
||||
.sort((a, b) => a.path.localeCompare(b.path));
|
||||
export let onDelete: Function = () => {};
|
||||
</script>
|
||||
|
||||
|
|
@ -31,6 +51,87 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="my-3 space-y-1">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<Tooltip content={$i18n.t('Members of this group inherit access from its parent groups.')}>
|
||||
<label for="group-parent" class="text-xs shrink-0 text-gray-500"
|
||||
>{$i18n.t('Parent group')}</label
|
||||
>
|
||||
</Tooltip>
|
||||
<Dropdown
|
||||
align="end"
|
||||
bind:show={showParents}
|
||||
contentClass="w-80 max-w-[calc(100vw-2rem)]"
|
||||
maxHeight="20rem"
|
||||
onOpenChange={(open) => {
|
||||
if (!open) parentSearch = '';
|
||||
}}
|
||||
>
|
||||
<button
|
||||
id="group-parent"
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={showParents}
|
||||
class="flex max-w-64 items-center gap-1.5 rounded-lg px-1.5 py-1 text-sm text-gray-700 transition hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-gray-900"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate">{selectedParent?.name ?? $i18n.t('No parent')}</span>
|
||||
<ChevronDown className="size-3.5 shrink-0 text-gray-400" />
|
||||
</button>
|
||||
<div slot="content">
|
||||
<DropdownMenu className="w-full">
|
||||
<div class="flex items-center gap-2 px-2 py-2">
|
||||
<Search className="size-3.5 shrink-0 text-gray-400" />
|
||||
<input
|
||||
aria-label={$i18n.t('Search parent groups')}
|
||||
placeholder={$i18n.t('Search parent groups')}
|
||||
class="min-w-0 w-full bg-transparent text-sm outline-hidden"
|
||||
bind:value={parentSearch}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={!parent_group_id}
|
||||
class="my-1 flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
on:click={() => {
|
||||
parent_group_id = null;
|
||||
showParents = false;
|
||||
}}
|
||||
>
|
||||
<span class="flex-1">{$i18n.t('No parent')}</span>
|
||||
{#if !parent_group_id}<Check className="size-4" />{/if}
|
||||
</button>
|
||||
{#each candidates as candidate (candidate.id)}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={candidate.id === parent_group_id}
|
||||
class="relative flex w-full items-center gap-2 rounded-lg py-2 pr-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
title={candidate.path}
|
||||
on:click={() => {
|
||||
parent_group_id = candidate.id;
|
||||
showParents = false;
|
||||
}}
|
||||
>
|
||||
{#if candidate.ancestor_ids.length}<span
|
||||
aria-hidden="true"
|
||||
class="absolute top-0 h-1/2 w-2 border-b border-l border-gray-200 dark:border-gray-700"
|
||||
style:left={`${Math.min(candidate.ancestor_ids.length, 6) * 16 - 4}px`}
|
||||
></span>{/if}
|
||||
<span
|
||||
class="flex-1 truncate"
|
||||
style:margin-left={`${Math.min(candidate.ancestor_ids.length, 6) * 16}px`}
|
||||
>{candidate.name}</span
|
||||
>
|
||||
{#if candidate.id === parent_group_id}<Check className="size-4 shrink-0" />{/if}
|
||||
</button>
|
||||
{:else}<p class="px-2 py-3 text-xs text-gray-500">{$i18n.t('No groups found')}</p>{/each}
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <div class="flex flex-col w-full mt-2">
|
||||
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Color')}</div>
|
||||
|
||||
|
|
@ -80,7 +181,7 @@
|
|||
class="text-sm bg-transparent outline-hidden rounded-lg pl-2 pr-5"
|
||||
value={data?.config?.share ?? 'members'}
|
||||
on:change={(e) => {
|
||||
const value = e.target.value;
|
||||
const value = e.currentTarget.value;
|
||||
let shareValue;
|
||||
if (value === 'false') {
|
||||
shareValue = false;
|
||||
|
|
|
|||
|
|
@ -7,11 +7,18 @@
|
|||
|
||||
import { deleteGroupById, updateGroupById } from '$lib/apis/groups';
|
||||
|
||||
/** @type {import('../Groups.svelte').GroupListItem[]} */
|
||||
export let groups = [];
|
||||
export let draggable = false;
|
||||
/** @type {(event: DragEvent) => void} */
|
||||
export let onDragStart = () => {};
|
||||
export let onDragEnd = () => {};
|
||||
import EditGroupModal from './EditGroupModal.svelte';
|
||||
|
||||
export let group = {
|
||||
id: '',
|
||||
name: 'Admins',
|
||||
path: 'Admins',
|
||||
description: '',
|
||||
permissions: {},
|
||||
user_ids: [1, 2, 3],
|
||||
|
|
@ -26,6 +33,7 @@
|
|||
let showEdit = false;
|
||||
$: hasCustomPermissions = Object.keys(group?.permissions ?? {}).length > 0;
|
||||
|
||||
/** @param {any} _group */
|
||||
const updateHandler = async (_group) => {
|
||||
const res = await updateGroupById(localStorage.token, group.id, _group).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
|
|
@ -34,8 +42,9 @@
|
|||
|
||||
if (res) {
|
||||
toast.success($i18n.t('Group updated successfully'));
|
||||
setGroups();
|
||||
await setGroups();
|
||||
}
|
||||
return !!res;
|
||||
};
|
||||
|
||||
const deleteHandler = async () => {
|
||||
|
|
@ -46,8 +55,9 @@
|
|||
|
||||
if (res) {
|
||||
toast.success($i18n.t('Group deleted successfully'));
|
||||
setGroups();
|
||||
await setGroups();
|
||||
}
|
||||
return !!res;
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
|
|
@ -62,6 +72,7 @@
|
|||
bind:show={showEdit}
|
||||
edit
|
||||
{group}
|
||||
{groups}
|
||||
{defaultPermissions}
|
||||
tabs={['general', 'permissions', 'users', 'preview']}
|
||||
onSubmit={updateHandler}
|
||||
|
|
@ -70,15 +81,19 @@
|
|||
/>
|
||||
|
||||
<button
|
||||
class="group flex cursor-pointer text-left w-full px-2.5 py-2"
|
||||
class="group flex cursor-pointer text-left w-full px-1.5 py-1.5"
|
||||
{draggable}
|
||||
on:dragstart={onDragStart}
|
||||
on:dragend={onDragEnd}
|
||||
title={group.path}
|
||||
on:click={() => {
|
||||
showEdit = true;
|
||||
}}
|
||||
>
|
||||
<div class="w-full">
|
||||
<div class="min-w-0 w-full">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-0.5 pl-1">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-3 pl-1">
|
||||
<div class="flex min-w-0 items-center gap-2 sm:shrink-0 sm:max-w-[55%]">
|
||||
<div
|
||||
class="text-sm font-normal line-clamp-1 text-gray-900 group-hover:underline dark:text-gray-100"
|
||||
>
|
||||
|
|
@ -88,11 +103,11 @@
|
|||
<div
|
||||
class="shrink-0 rounded-md bg-gray-500/10 px-1.5 py-0.5 text-[0.6875rem] font-normal leading-none text-gray-600 dark:text-gray-300"
|
||||
>
|
||||
{$i18n.t('{{COUNT}} members', { COUNT: group?.member_count ?? 0 })}
|
||||
{$i18n.t('{{COUNT}} direct members', { COUNT: group?.member_count ?? 0 })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 items-center gap-1.5 text-xs text-gray-500">
|
||||
<div class="hidden min-w-0 items-center gap-1.5 text-xs text-gray-500 sm:flex">
|
||||
<div class="line-clamp-1 min-w-0">
|
||||
{#if group?.description}
|
||||
{group.description}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { getGroupMembers } from '$lib/apis/groups';
|
||||
const i18n = getContext<any>('i18n');
|
||||
export let groupId: string;
|
||||
export let groups: import('../Groups.svelte').GroupListItem[] = [];
|
||||
let query = '';
|
||||
let page = 1;
|
||||
$: members = getGroupMembers(localStorage.token, groupId, 'inherited', query, page);
|
||||
</script>
|
||||
|
||||
<div class="space-y-3 text-sm">
|
||||
<p class="text-xs text-gray-500">
|
||||
{$i18n.t('Inherited members are managed in their directly assigned groups.')}
|
||||
</p>
|
||||
<input
|
||||
class="w-full rounded-lg border border-gray-200 dark:border-gray-700 bg-transparent px-2 py-1"
|
||||
aria-label={$i18n.t('Search inherited members')}
|
||||
placeholder={$i18n.t('Search inherited members')}
|
||||
bind:value={query}
|
||||
on:input={() => {
|
||||
page = 1;
|
||||
}}
|
||||
/>
|
||||
{#await members}
|
||||
<p>{$i18n.t('Loading...')}</p>
|
||||
{:then result}
|
||||
<p class="text-xs text-gray-500">
|
||||
{$i18n.t(
|
||||
'Direct: {{direct}} · Inherited: {{inherited}} · Total: {{effective}}',
|
||||
result.counts
|
||||
)}
|
||||
</p>
|
||||
{#each result.items as member (member.id)}
|
||||
<div class="border-b border-gray-100 dark:border-gray-800 py-2">
|
||||
<div>{member.name} <span class="text-gray-500">{member.email}</span></div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{$i18n.t('Inherited via')}
|
||||
{#each member.via_group_ids as id, index}
|
||||
{index ? ', ' : ''}<a class="underline" href={`/admin/users/groups?id=${id}`}
|
||||
>{groups.find((group) => group.id === id)?.path || id}</a
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}<p class="text-gray-500">{$i18n.t('No inherited members')}</p>{/each}
|
||||
<div class="flex items-center justify-between">
|
||||
<button type="button" disabled={page <= 1} class="disabled:opacity-40" on:click={() => page--}
|
||||
>{$i18n.t('Previous')}</button
|
||||
>
|
||||
<span>{page} / {Math.max(1, Math.ceil(result.total / 30))}</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page * 30 >= result.total}
|
||||
class="disabled:opacity-40"
|
||||
on:click={() => page++}>{$i18n.t('Next')}</button
|
||||
>
|
||||
</div>
|
||||
{:catch error}
|
||||
<p role="alert" class="text-red-500">{String(error)}</p>
|
||||
{/await}
|
||||
</div>
|
||||
|
|
@ -714,6 +714,11 @@
|
|||
}
|
||||
|
||||
const socketInstance = $socket;
|
||||
const scheduleChannelRefresh = () => {
|
||||
socketInstance?.off('connect', initChannels);
|
||||
socketInstance?.once('connect', initChannels);
|
||||
};
|
||||
socketInstance?.on('access:updated', scheduleChannelRefresh);
|
||||
socketInstance?.on('events', chatActiveEventHandler);
|
||||
socketInstance?.on('connect', refreshChatRows);
|
||||
|
||||
|
|
@ -753,6 +758,8 @@
|
|||
dropZone.removeEventListener('dragleave', onDragLeave);
|
||||
}
|
||||
|
||||
socketInstance?.off('access:updated', scheduleChannelRefresh);
|
||||
socketInstance?.off('connect', initChannels);
|
||||
socketInstance?.off('events', chatActiveEventHandler);
|
||||
socketInstance?.off('connect', refreshChatRows);
|
||||
|
||||
|
|
|
|||
|
|
@ -3538,5 +3538,26 @@
|
|||
"YouTube": "",
|
||||
"YouTube video player": "",
|
||||
"Zoom in": "",
|
||||
"Zoom out": ""
|
||||
"Zoom out": "",
|
||||
"Parent group": "Parent group",
|
||||
"Search parent groups": "Search parent groups",
|
||||
"No parent": "No parent",
|
||||
"Members of this group inherit access from its parent groups.": "Members of this group inherit access from its parent groups.",
|
||||
"{{COUNT}} direct members": "{{COUNT}} direct members",
|
||||
"Inherited members are managed in their directly assigned groups.": "Inherited members are managed in their directly assigned groups.",
|
||||
"Direct: {{direct}} · Inherited: {{inherited}} · Total: {{effective}}": "Direct: {{direct}} · Inherited: {{inherited}} · Total: {{effective}}",
|
||||
"Search inherited members": "Search inherited members",
|
||||
"Inherited via": "Inherited via",
|
||||
"No inherited members": "No inherited members",
|
||||
"Delete {{name}}? Its child groups will move to {{parent}}. Permissions and direct memberships belonging to this group will be removed.": "Delete {{name}}? Its child groups will move to {{parent}}. Permissions and direct memberships belonging to this group will be removed.",
|
||||
"Delete {{name}}? Its child groups will become top-level groups. Permissions and direct memberships belonging to this group will be removed.": "Delete {{name}}? Its child groups will become top-level groups. Permissions and direct memberships belonging to this group will be removed.",
|
||||
"Delete group": "Delete group",
|
||||
"Parent permissions are inherited. Disabling a local permission does not revoke inherited access.": "Parent permissions are inherited. Disabling a local permission does not revoke inherited access.",
|
||||
"Direct members": "Direct members",
|
||||
"Inherited members": "Inherited members",
|
||||
"Group hierarchy": "Group hierarchy",
|
||||
"Move to top level": "Move to top level",
|
||||
"Expand {{name}}": "Expand {{name}}",
|
||||
"Collapse {{name}}": "Collapse {{name}}",
|
||||
"Group moved successfully": "Group moved successfully"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@
|
|||
|
||||
import { getModels, getToolServersData, getVersionUpdates } from '$lib/apis';
|
||||
import { getTools } from '$lib/apis/tools';
|
||||
import { getSkills } from '$lib/apis/skills';
|
||||
import { getSessionUser } from '$lib/apis/auths';
|
||||
import { getBanners } from '$lib/apis/configs';
|
||||
import { getTerminalServers } from '$lib/apis/terminal';
|
||||
import { getUserSettings } from '$lib/apis/users';
|
||||
|
|
@ -23,6 +25,8 @@
|
|||
models,
|
||||
knowledge,
|
||||
tools,
|
||||
skills,
|
||||
socket,
|
||||
functions,
|
||||
tags,
|
||||
banners,
|
||||
|
|
@ -168,6 +172,37 @@
|
|||
tools.set(toolsData);
|
||||
};
|
||||
|
||||
const refreshAccess = async () => {
|
||||
if (!localStorage.token) return;
|
||||
try {
|
||||
user.set(await getSessionUser(localStorage.token));
|
||||
const results = await Promise.allSettled([
|
||||
setModels(),
|
||||
setTools(),
|
||||
setToolServers(),
|
||||
getSkills(localStorage.token).then((value) => skills.set(value))
|
||||
]);
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') console.error('Unable to refresh access', result.reason);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Unable to refresh permissions', error);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
const socketInstance = $socket;
|
||||
const scheduleAccessRefresh = () => {
|
||||
socketInstance?.off('connect', refreshAccess);
|
||||
socketInstance?.once('connect', refreshAccess);
|
||||
};
|
||||
socketInstance?.on('access:updated', scheduleAccessRefresh);
|
||||
return () => {
|
||||
socketInstance?.off('access:updated', scheduleAccessRefresh);
|
||||
socketInstance?.off('connect', refreshAccess);
|
||||
};
|
||||
});
|
||||
|
||||
const openSettingsFromUrl = async () => {
|
||||
const requestedSettings = $page.url.searchParams.get('settings');
|
||||
if (!requestedSettings) {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
import {
|
||||
config,
|
||||
user,
|
||||
models,
|
||||
settings,
|
||||
theme,
|
||||
WEBUI_NAME,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue