From 31763c0b18a35c0efbcd2dbf95d4d375b1ff6350 Mon Sep 17 00:00:00 2001 From: cocowh Date: Thu, 14 May 2026 00:51:04 +0800 Subject: [PATCH] feat(kb): entity-based permissions with share service layer and extensions - Add entity-based permission system via ResourceMember with entity_type + entity_id - Implement knowledge share service with batch_add_members, add_member, remove_member, update_member_role, and list_members - Refactor _add_initial_kb_members to use batch_add_members for single-transaction safety - Relax InitialMemberCreate.entity_type from Literal[\"user\", \"namespace\"] to str to support custom entity types (e.g., org_department) via registerAuthSection - Fix summary counts: deduplicate total_count and include shared_into_group_kbs in group_count - Frontend: use backend summary data for sidebar counts (total, personal, group, organization) - Frontend: hide \"from\" column in all/groups list views, show only in specific group view - Fix hidden invalid character (U+00B7) in PermissionManagementTab.tsx import - Add comprehensive tests for batch_add_members and initial KB member creation Co-Authored-By: Claude Opus 4.7 --- AGENTS.md | 1 + ...b5c6_extend_resource_members_for_entity.py | 202 ++++ .../app/api/endpoints/adapter/attachments.py | 6 +- backend/app/api/endpoints/adapter/chat.py | 9 +- backend/app/api/endpoints/groups.py | 31 + backend/app/api/endpoints/knowledge.py | 57 ++ backend/app/api/endpoints/mcp_providers.py | 74 +- backend/app/api/endpoints/share.py | 38 +- backend/app/core/cache.py | 19 +- backend/app/models/resource_member.py | 137 ++- backend/app/models/user.py | 10 - backend/app/schemas/base_role.py | 29 + backend/app/schemas/knowledge.py | 53 + backend/app/schemas/mcp_providers.py | 22 + backend/app/schemas/share.py | 98 +- backend/app/schemas/shared_team.py | 3 +- backend/app/services/adapters/bot_kinds.py | 21 +- .../adapters/task_kinds/operations.py | 3 +- .../adapters/task_kinds/query_utils.py | 37 +- .../adapters/task_kinds/running_tasks.py | 5 +- backend/app/services/adapters/team_kinds.py | 56 +- .../app/services/chat/access/permissions.py | 3 +- .../app/services/chat/storage/task_manager.py | 3 +- backend/app/services/group_member_helper.py | 9 +- backend/app/services/group_service.py | 72 +- .../services/knowledge/knowledge_service.py | 899 ++++++++++++++--- .../knowledge/task_knowledge_base_service.py | 3 +- backend/app/services/readers/group_members.py | 3 +- backend/app/services/readers/shared_teams.py | 9 +- backend/app/services/share/__init__.py | 8 + backend/app/services/share/base_service.py | 909 +++++++++++++++--- .../share/external_entity_resolver.py | 183 ++++ .../services/share/knowledge_share_service.py | 643 +++++++++++-- .../share/namespace_entity_resolver.py | 140 +++ .../app/services/share/team_share_service.py | 3 +- backend/app/services/shared_task.py | 12 +- backend/app/services/shared_team.py | 20 +- backend/app/services/task_member_service.py | 12 +- .../test_group_namespace_permissions.py | 9 +- .../tests/api/endpoints/test_groups_api.py | 29 +- backend/tests/api/test_batch_add_members.py | 93 +- .../test_knowledge_base_initial_members.py | 204 ++++ backend/tests/api/test_skills_api.py | 3 +- .../services/adapters/test_team_kinds_copy.py | 3 +- .../test_external_entity_permissions.py | 754 +++++++++++++++ .../test_namespace_knowledge_permissions.py | 278 +++++- .../knowledge/test_permission_policy.py | 3 +- .../share/test_check_entity_permission.py | 195 ++++ .../share/test_namespace_entity_resolver.py | 194 ++++ .../test_task_member_service_group_chat.py | 4 + .../entity-permission-extension.md | 418 ++++++++ .../knowledge/knowledge-base-guide.md | 56 ++ ...026-05-13-frontend-message-queue-design.md | 318 ++++++ .../entity-permission-extension.md | 418 ++++++++ .../knowledge/knowledge-base-guide.md | 57 ++ .../document/document-detail-dialog.test.tsx | 2 +- .../ChatArea.queue-message-handler.test.tsx | 89 +- .../chat/useChatStreamHandlers.queue.test.tsx | 298 ++++++ .../chat/useMessageSendQueue.test.tsx | 256 +++++ .../input/ChatInputCard.layout.test.tsx | 91 +- .../components/input/chat-send-state.test.tsx | 191 +++- frontend/src/apis/groups.ts | 23 +- frontend/src/apis/knowledge-permission.ts | 97 +- frontend/src/apis/mcpProviders.ts | 21 + frontend/src/apis/team.ts | 1 + frontend/src/app/(tasks)/chat/page.tsx | 6 +- frontend/src/app/(tasks)/code/page.tsx | 6 +- frontend/src/app/tasks/page.tsx | 11 +- .../document/auth-section-registry.ts | 78 ++ .../document/components/CommandPalette.tsx | 5 +- .../components/CreateKnowledgeBaseDialog.tsx | 46 +- .../components/DeleteFolderDialog.tsx | 2 +- .../document/components/DocumentPanel.tsx | 5 +- .../components/KnowledgeBaseAuthSection.tsx | 418 ++++++++ .../document/components/KnowledgeBaseForm.tsx | 5 + .../components/KnowledgeDetailPanel.tsx | 2 +- .../KnowledgeDocumentPageDesktop.tsx | 542 +++++------ .../KnowledgeDocumentPageMobile.tsx | 1 + .../components/KnowledgeGroupListPage.tsx | 125 ++- .../KnowledgeSidebar/NavigationSection.tsx | 11 +- .../components/KnowledgeSidebar/index.tsx | 16 +- .../knowledge/document/extension-loader.ts | 26 + .../knowledge/document/hooks/useFolders.ts | 25 +- .../knowledge/document/hooks/useGroupKbs.ts | 11 +- .../document/hooks/useKnowledgeBaseDialogs.ts | 346 +++++++ .../document/hooks/useKnowledgeSidebar.ts | 42 +- .../document/hooks/useKnowledgeUrlSync.ts | 139 +++ .../pages/KnowledgeBaseClassicPageDesktop.tsx | 5 +- .../pages/KnowledgeBaseClassicPageMobile.tsx | 61 +- .../components/PermissionManagementTab.tsx | 823 ++++++++++++---- .../components/add-namespace-dialog.tsx | 376 ++++++++ .../permission/permission-tab-registry.ts | 72 ++ .../features/knowledge/utils/share-source.ts | 35 + .../components/McpConfigEditModal.tsx | 27 +- .../components/McpConfigImportModal.tsx | 10 + .../components/SingleMcpServerEditModal.tsx | 502 +++++++--- .../src/features/settings/utils/mcpConfig.ts | 10 +- .../tasks/components/chat/ChatArea.tsx | 24 +- .../components/chat/useChatStreamHandlers.tsx | 800 +++++++++------ .../components/chat/useMessageSendQueue.ts | 142 +++ .../tasks/components/input/ChatInputCard.tsx | 75 +- .../components/input/ChatInputControls.tsx | 123 +-- .../input/MobileChatInputControls.tsx | 116 ++- .../tasks/components/input/SendButton.tsx | 4 + .../tasks/components/input/chatSendState.ts | 101 ++ .../components/share/TeamShareHandler.tsx | 103 +- frontend/src/i18n/locales/en/chat.json | 4 + frontend/src/i18n/locales/en/common.json | 18 + frontend/src/i18n/locales/en/knowledge.json | 20 +- frontend/src/i18n/locales/zh-CN/chat.json | 4 + frontend/src/i18n/locales/zh-CN/common.json | 18 + .../src/i18n/locales/zh-CN/knowledge.json | 24 +- frontend/src/types/knowledge.ts | 52 +- 113 files changed, 11604 insertions(+), 1760 deletions(-) create mode 100644 backend/alembic/versions/20260506_d1e2f3a4b5c6_extend_resource_members_for_entity.py create mode 100644 backend/app/services/share/external_entity_resolver.py create mode 100644 backend/app/services/share/namespace_entity_resolver.py create mode 100644 backend/tests/api/test_knowledge_base_initial_members.py create mode 100644 backend/tests/services/knowledge/test_external_entity_permissions.py create mode 100644 backend/tests/services/share/test_check_entity_permission.py create mode 100644 backend/tests/services/share/test_namespace_entity_resolver.py create mode 100644 docs/en/developer-guide/entity-permission-extension.md create mode 100644 docs/superpowers/specs/2026-05-13-frontend-message-queue-design.md create mode 100644 docs/zh/developer-guide/entity-permission-extension.md create mode 100644 frontend/src/__tests__/features/tasks/components/chat/useChatStreamHandlers.queue.test.tsx create mode 100644 frontend/src/__tests__/features/tasks/components/chat/useMessageSendQueue.test.tsx create mode 100644 frontend/src/features/knowledge/document/auth-section-registry.ts create mode 100644 frontend/src/features/knowledge/document/components/KnowledgeBaseAuthSection.tsx create mode 100644 frontend/src/features/knowledge/document/extension-loader.ts create mode 100644 frontend/src/features/knowledge/document/hooks/useKnowledgeBaseDialogs.ts create mode 100644 frontend/src/features/knowledge/document/hooks/useKnowledgeUrlSync.ts create mode 100644 frontend/src/features/knowledge/permission/components/add-namespace-dialog.tsx create mode 100644 frontend/src/features/knowledge/permission/permission-tab-registry.ts create mode 100644 frontend/src/features/knowledge/utils/share-source.ts create mode 100644 frontend/src/features/tasks/components/chat/useMessageSendQueue.ts create mode 100644 frontend/src/features/tasks/components/input/chatSendState.ts diff --git a/AGENTS.md b/AGENTS.md index e42c683ab85..9f575b7b76e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -571,6 +571,7 @@ t('actions.save') // Ambiguous - which namespace? - Backend encrypts Git tokens and API keys (AES-256-CBC) - OIDC support for enterprise SSO - Role-based access control for admin operations +- Entity-based permission extension for internal org integration: [`docs/en/developer-guide/entity-permission-extension.md`](docs/en/developer-guide/entity-permission-extension.md) --- diff --git a/backend/alembic/versions/20260506_d1e2f3a4b5c6_extend_resource_members_for_entity.py b/backend/alembic/versions/20260506_d1e2f3a4b5c6_extend_resource_members_for_entity.py new file mode 100644 index 00000000000..28dd68f3f36 --- /dev/null +++ b/backend/alembic/versions/20260506_d1e2f3a4b5c6_extend_resource_members_for_entity.py @@ -0,0 +1,202 @@ +# SPDX-FileCopyrightText: 2025 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +"""extend_resource_members_for_entity + +Revision ID: d1e2f3a4b5c6 +Revises: c3d4e5f6a708 +Create Date: 2026-05-06 + +Add entity_type, entity_id columns to resource_members table. +Change unique constraint from (resource_type, resource_id, user_id) +to (resource_type, resource_id, entity_type, entity_id). +Keep user_id column for backward compatibility (nullable, auto-synced from entity_id +for entity_type='user' via SQLAlchemy events). +entity_id is backfilled from user_id then set to NOT NULL. +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "d1e2f3a4b5c6" +down_revision = "c3d4e5f6a708" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + columns = [col["name"] for col in inspector.get_columns("resource_members")] + + # Step 1: Add entity_type column + if "entity_type" not in columns: + op.add_column( + "resource_members", + sa.Column( + "entity_type", + sa.String(20), + nullable=False, + server_default="user", + comment="Entity type: user, namespace", + ), + ) + + # Step 2: Add entity_id column + if "entity_id" not in columns: + op.add_column( + "resource_members", + sa.Column( + "entity_id", + sa.String(100), + nullable=True, + comment="Entity identifier: user_id for 'user', external ID for others", + ), + ) + + # Step 3: Backfill existing rows - set entity_id = CAST(user_id AS CHAR), entity_type = 'user' + dialect = conn.dialect.name + if dialect == "mysql": + op.execute( + """ + UPDATE resource_members + SET entity_id = CAST(user_id AS CHAR), entity_type = 'user' + WHERE entity_id IS NULL + """ + ) + elif dialect == "sqlite": + op.execute( + """ + UPDATE resource_members + SET entity_id = CAST(user_id AS TEXT), entity_type = 'user' + WHERE entity_id IS NULL + """ + ) + + # Step 3.5: Make entity_id NOT NULL after backfill + if dialect == "mysql": + op.alter_column( + "resource_members", + "entity_id", + existing_type=sa.String(100), + nullable=False, + ) + elif dialect == "sqlite": + with op.batch_alter_table("resource_members") as batch_op: + batch_op.alter_column( + "entity_id", + existing_type=sa.String(100), + nullable=False, + ) + + # Step 4: Make user_id nullable (kept for backward compatibility) + # user_id is auto-synced from entity_id for entity_type='user' via SQLAlchemy events + if dialect == "mysql": + op.execute( + "ALTER TABLE resource_members MODIFY user_id INT NULL COMMENT 'Member user ID (kept for backward compatibility, use entity_type+entity_id for new code)'" + ) + elif dialect == "sqlite": + with op.batch_alter_table("resource_members") as batch_op: + batch_op.alter_column( + "user_id", + existing_type=sa.Integer(), + nullable=True, + ) + + # Step 6: Add entity_display_name for entity-type member snapshots + if "entity_display_name" not in columns: + op.add_column( + "resource_members", + sa.Column( + "entity_display_name", + sa.String(100), + nullable=True, + comment="Display name snapshot for entity-type members", + ), + ) + + # Step 5: Drop old unique constraint and create new one + if dialect == "mysql": + # Dynamically find the actual unique constraint/index name from MySQL + old_uc_name_result = conn.execute( + sa.text( + "SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS " + "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'resource_members' " + "AND CONSTRAINT_TYPE = 'UNIQUE'" + ) + ).fetchone() + old_uc_name = ( + old_uc_name_result[0] if old_uc_name_result else "uq_resource_members" + ) + + op.drop_constraint(old_uc_name, "resource_members", type_="unique") + op.create_unique_constraint( + "uq_resource_members_entity", + "resource_members", + ["resource_type", "resource_id", "entity_type", "entity_id"], + ) + else: + # SQLite: use batch operations + with op.batch_alter_table("resource_members") as batch_op: + batch_op.drop_constraint("uq_resource_members", type_="unique") + batch_op.create_unique_constraint( + "uq_resource_members_entity", + ["resource_type", "resource_id", "entity_type", "entity_id"], + ) + + # Step 7: Add index for entity-type queries + op.create_index( + "idx_resource_members_entity", + "resource_members", + ["entity_type", "entity_id", "resource_type", "status"], + ) + + +def downgrade() -> None: + conn = op.get_bind() + dialect = conn.dialect.name + + # Drop entity index + op.drop_index("idx_resource_members_entity", table_name="resource_members") + + # Drop new unique constraint, restore old one + if dialect == "mysql": + # Dynamically find the current unique constraint name + new_uc_name_result = conn.execute( + sa.text( + "SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS " + "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'resource_members' " + "AND CONSTRAINT_TYPE = 'UNIQUE'" + ) + ).fetchone() + new_uc_name = ( + new_uc_name_result[0] + if new_uc_name_result + else "uq_resource_members_entity" + ) + + op.drop_constraint(new_uc_name, "resource_members", type_="unique") + op.create_unique_constraint( + "uq_resource_members", + "resource_members", + ["resource_type", "resource_id", "user_id"], + ) + else: + with op.batch_alter_table("resource_members") as batch_op: + batch_op.drop_constraint("uq_resource_members_entity", type_="unique") + batch_op.create_unique_constraint( + "uq_resource_members", + ["resource_type", "resource_id", "user_id"], + ) + + # Drop added columns (check existence to tolerate partial rollback states) + inspector = sa.inspect(conn) + columns = [col["name"] for col in inspector.get_columns("resource_members")] + if "entity_display_name" in columns: + op.drop_column("resource_members", "entity_display_name") + if "entity_id" in columns: + op.drop_column("resource_members", "entity_id") + if "entity_type" in columns: + op.drop_column("resource_members", "entity_type") diff --git a/backend/app/api/endpoints/adapter/attachments.py b/backend/app/api/endpoints/adapter/attachments.py index a4ecbae5e0a..e1c3f562b36 100644 --- a/backend/app/api/endpoints/adapter/attachments.py +++ b/backend/app/api/endpoints/adapter/attachments.py @@ -211,7 +211,8 @@ def _check_task_access(db: Session, task_id: int, user_id: int) -> bool: .filter( ResourceMember.resource_type == ResourceType.TASK, ResourceMember.resource_id == task_id, - ResourceMember.user_id == user_id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), ResourceMember.status == MemberStatus.APPROVED, ) .first() @@ -821,7 +822,8 @@ async def get_all_task_attachments( .filter( ResourceMember.resource_type == ResourceType.TASK, ResourceMember.resource_id == task_id, - ResourceMember.user_id == current_user.id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(current_user.id), ResourceMember.status == MemberStatus.APPROVED, ) .first() diff --git a/backend/app/api/endpoints/adapter/chat.py b/backend/app/api/endpoints/adapter/chat.py index adfc7ef5d43..77192cd3f2e 100644 --- a/backend/app/api/endpoints/adapter/chat.py +++ b/backend/app/api/endpoints/adapter/chat.py @@ -228,7 +228,8 @@ async def correct_response( .filter( ResourceMember.resource_type == ResourceType.TASK, ResourceMember.resource_id == request.task_id, - ResourceMember.user_id == current_user.id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(current_user.id), ResourceMember.status == MemberStatus.APPROVED, ) .first() @@ -428,7 +429,8 @@ async def delete_correction( .filter( ResourceMember.resource_type == ResourceType.TASK, ResourceMember.resource_id == subtask.task_id, - ResourceMember.user_id == current_user.id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(current_user.id), ResourceMember.status == MemberStatus.APPROVED, ) .first() @@ -502,7 +504,8 @@ async def apply_correction( .filter( ResourceMember.resource_type == ResourceType.TASK, ResourceMember.resource_id == subtask.task_id, - ResourceMember.user_id == current_user.id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(current_user.id), ResourceMember.status == MemberStatus.APPROVED, ) .first() diff --git a/backend/app/api/endpoints/groups.py b/backend/app/api/endpoints/groups.py index 462777c0d97..0283871c795 100644 --- a/backend/app/api/endpoints/groups.py +++ b/backend/app/api/endpoints/groups.py @@ -69,6 +69,37 @@ def list_groups( return GroupListResponse(total=total, items=groups) +@router.get("/search", response_model=GroupListResponse) +def search_groups_endpoint( + q: str = Query( + "", + min_length=0, + max_length=100, + description="Search query for group name or display_name", + ), + page: int = Query(1, ge=1, description="Page number"), + limit: int = Query(20, ge=1, le=100, description="Items per page"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Search groups by name or display_name. + Only returns groups with level='group' (filters out organization-level groups). + Results are limited to groups where the current user is a member. + Returns paginated results. + """ + skip = (page - 1) * limit + groups, total = group_service.search_groups( + db=db, + q=q, + skip=skip, + limit=limit, + user_id=current_user.id, + user_role=current_user.role, + ) + return GroupListResponse(total=total, items=groups) + + @router.post("", response_model=GroupResponse, status_code=status.HTTP_201_CREATED) def create_group_endpoint( group_create: GroupCreate, diff --git a/backend/app/api/endpoints/knowledge.py b/backend/app/api/endpoints/knowledge.py index a897ca9d237..b0241ba9bc0 100644 --- a/backend/app/api/endpoints/knowledge.py +++ b/backend/app/api/endpoints/knowledge.py @@ -32,6 +32,7 @@ DocumentContentUpdate, DocumentDetailResponse, DocumentMoveRequest, + InitialMemberCreate, KnowledgeBaseCreate, KnowledgeBaseListResponse, KnowledgeBaseMigrateRequest, @@ -298,6 +299,56 @@ def get_organization_namespace( } +def _add_initial_kb_members( + db: Session, + kb_id: int, + current_user: User, + members: list[InitialMemberCreate], +) -> None: + """Add initial members to a knowledge base after creation.""" + from app.services.share import knowledge_share_service + + members_data: list[tuple[int, BaseRole, str | None, str | None, str | None]] = [] + for member in members: + target_user_id = 0 + entity_type = member.entity_type if member.entity_type else "user" + entity_id = member.entity_id + if entity_type == "user": + try: + target_user_id = int(entity_id) + except (ValueError, TypeError): + raise HTTPException( + status_code=400, + detail=f"Invalid user ID: {entity_id}", + ) + entity_id = None + members_data.append( + ( + target_user_id, + member.role, + entity_type, + entity_id, + member.entity_display_name, + ) + ) + + result = knowledge_share_service.batch_add_members( + db=db, + resource_id=kb_id, + current_user_id=current_user.id, + members_data=members_data, + ) + + if result.failed: + details = ", ".join( + f"{f.entity_id or f.user_id}: {f.error}" for f in result.failed + ) + raise HTTPException( + status_code=400, + detail=f"Failed to add some members: {details}", + ) + + @router.post( "", response_model=KnowledgeBaseResponse, @@ -314,6 +365,7 @@ def create_knowledge_base( - **namespace=default**: Personal knowledge base - **namespace=**: Team knowledge base (requires Maintainer+ permission) + - **members**: Optional initial members to add after creation """ try: # Use Orchestrator for unified business logic (REST API and MCP tools share the same logic) @@ -330,6 +382,11 @@ def create_knowledge_base( ), summary_model_ref=data.summary_model_ref, ) + + # Add initial members if provided + if data.members: + _add_initial_kb_members(db, result.id, current_user, data.members) + add_span_event( "knowledge.base.created", { diff --git a/backend/app/api/endpoints/mcp_providers.py b/backend/app/api/endpoints/mcp_providers.py index ef9207a7729..cf99489f963 100644 --- a/backend/app/api/endpoints/mcp_providers.py +++ b/backend/app/api/endpoints/mcp_providers.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: Apache-2.0 +import asyncio import json from typing import Optional @@ -15,11 +16,15 @@ MCPProviderKeysResponse, MCPProviderListResponse, MCPServerListResponse, + MCPTestRequest, + MCPTestResponse, + MCPToolInfo, ) from app.schemas.user import MCPProviderKeys, UserInDB, UserPreferences, UserUpdate from app.services.mcp_providers.security import encrypt_mcp_provider_keys from app.services.mcp_providers.service import MCPProviderService from app.services.user import user_service +from chat_shell.tools.mcp.client import MCPClient from shared.logger import setup_logger router = APIRouter(tags=["mcp-providers"]) @@ -37,6 +42,56 @@ async def list_mcp_providers( return MCPProviderListResponse(providers=providers) +def _parse_user_preferences(prefs_data: Optional[str]) -> UserPreferences: + """Parse user preferences from JSON string or return default""" + if not prefs_data: + return UserPreferences() + try: + if isinstance(prefs_data, str): + prefs_dict = json.loads(prefs_data) + return UserPreferences.model_validate(prefs_dict) + return UserPreferences.model_validate(prefs_data) + except Exception: + return UserPreferences() + + +@router.post("/test", response_model=MCPTestResponse) +async def test_mcp_connection( + request: MCPTestRequest, + current_user: UserInDB = Depends(security.get_current_user), +) -> MCPTestResponse: + """Test an MCP server connection and list its available tools. + + Returns success=False (HTTP 200) for all connection failures so callers + can display the error message without treating it as a server error. + """ + server_type = request.server_config.get("type", "") + if server_type == "stdio": + return MCPTestResponse(success=False, error="stdio type is not testable") + + # Normalize "http" to "streamable-http" — the frontend may store the type as "http" + # after saving, but MCPClient only understands "streamable-http" for HTTP transports + server_config = dict(request.server_config) + if server_type == "http": + server_config["type"] = "streamable-http" + + try: + config = {request.server_name: server_config} + raw_tools = await asyncio.wait_for(_connect_and_get_tools(config), timeout=15) + tools = [ + MCPToolInfo( + name=getattr(tool, "name", ""), + description=getattr(tool, "description", ""), + ) + for tool in raw_tools + ] + return MCPTestResponse(success=True, tools=tools) + except asyncio.TimeoutError: + return MCPTestResponse(success=False, error="Connection timed out (15s)") + except Exception as e: + return MCPTestResponse(success=False, error=str(e)) + + @router.post("/{provider_key}/servers", response_model=MCPServerListResponse) async def get_provider_servers( provider_key: str, @@ -74,19 +129,6 @@ async def get_provider_servers( ) -def _parse_user_preferences(prefs_data: Optional[str]) -> UserPreferences: - """Parse user preferences from JSON string or return default""" - if not prefs_data: - return UserPreferences() - try: - if isinstance(prefs_data, str): - prefs_dict = json.loads(prefs_data) - return UserPreferences.model_validate(prefs_dict) - return UserPreferences.model_validate(prefs_data) - except Exception: - return UserPreferences() - - @router.put("/keys", response_model=MCPProviderKeysResponse) async def update_mcp_provider_keys( keys: MCPProviderKeysRequest, @@ -134,3 +176,9 @@ async def update_mcp_provider_keys( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Failed to update MCP provider keys: {str(e)}", ) + + +async def _connect_and_get_tools(config: dict) -> list: + """Connect to MCP server and retrieve its tools list.""" + async with MCPClient(config) as client: + return client.get_tools() diff --git a/backend/app/api/endpoints/share.py b/backend/app/api/endpoints/share.py index aca25f700d6..7fa9a62d4e3 100644 --- a/backend/app/api/endpoints/share.py +++ b/backend/app/api/endpoints/share.py @@ -26,6 +26,7 @@ KBShareInfoResponse, MemberListResponse, MyKBPermissionResponse, + MyPermissionSourcesResponse, PendingRequestListResponse, PublicKnowledgeBaseResponse, ResourceMemberCreate, @@ -351,6 +352,9 @@ def add_member( current_user_id=current_user.id, target_user_id=body.user_id, role=body.role, + entity_type=body.entity_type, + entity_id=body.entity_id, + entity_display_name=body.entity_display_name, ) @@ -378,7 +382,10 @@ def batch_add_members( - **body**: List of members (user_id, role) """ service = _get_share_service(resource_type) - members_data = [(m.user_id, m.role) for m in body.members] + members_data = [ + (m.user_id, m.role, m.entity_type, m.entity_id, m.entity_display_name) + for m in body.members + ] return service.batch_add_members( db=db, resource_id=resource_id, @@ -574,3 +581,32 @@ def get_kb_share_info( ) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) from e + + +@router.get( + "/KnowledgeBase/{resource_id}/my-permission-sources", + response_model=MyPermissionSourcesResponse, + summary="Get my KnowledgeBase permission sources", +) +def get_my_kb_permission_sources( + resource_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(security.get_current_user), +) -> MyPermissionSourcesResponse: + """ + Get all permission sources for the current user on a knowledge base. + + Returns detailed information about how the current user has access: + - Creator + - Direct user-type permissions (via share link or direct add) + - Entity-type permissions (via external resolvers) + - Group membership (namespace) + - Group chat binding (for personal KBs) + + - **resource_id**: Knowledge base ID + """ + return knowledge_share_service.get_my_permission_sources( + db=db, + resource_id=resource_id, + user_id=current_user.id, + ) diff --git a/backend/app/core/cache.py b/backend/app/core/cache.py index deec2d5ba1c..f2ff42790ca 100644 --- a/backend/app/core/cache.py +++ b/backend/app/core/cache.py @@ -2,11 +2,11 @@ # # SPDX-License-Identifier: Apache-2.0 +import asyncio import logging from typing import Any, Dict, List, Optional import orjson -from redis import Redis as SyncRedis from redis.asyncio import Redis from app.core.config import settings @@ -116,6 +116,23 @@ def get_sync(self, key: str) -> Optional[Any]: logger.error(f"Error getting cache key {key} (sync): {str(e)}") return None + def set_from_sync( + self, + key: str, + value: Any, + expire: int | None = settings.REPO_CACHE_EXPIRED_TIME, + ) -> bool: + """Set value to cache synchronously (for background threads). + + Uses asyncio.run() to execute the async set() method. This avoids + duplicating Redis connection logic and follows the pattern from PR #1011. + """ + try: + return asyncio.run(self.set(key, value, expire=expire)) + except Exception as e: + logger.error(f"Error setting cache key {key} (sync): {str(e)}") + return False + def get_user_repositories_sync( self, user_id: int, git_domain: str ) -> Optional[list]: diff --git a/backend/app/models/resource_member.py b/backend/app/models/resource_member.py index 50c9b3bb9a4..5664dec177e 100644 --- a/backend/app/models/resource_member.py +++ b/backend/app/models/resource_member.py @@ -14,28 +14,22 @@ from datetime import datetime from enum import Enum as PyEnum -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional from sqlalchemy import ( Column, DateTime, - ForeignKey, Index, Integer, String, UniqueConstraint, ) -from sqlalchemy.orm import Mapped, relationship from sqlalchemy.sql import func from app.db.base import Base from app.models.share_link import ResourceType from app.schemas.base_role import BaseRole -if TYPE_CHECKING: - from app.models.user import User - - # Define epoch time for default datetime values EPOCH_TIME = datetime(1970, 1, 1, 0, 0, 0) @@ -85,18 +79,35 @@ class ResourceMember(Base): comment="Resource ID", ) - # Member info - user_id = Column( - Integer, - ForeignKey("users.id"), + # Entity info (polymorphic member identification) + # entity_type: "user" (default), "namespace" + # entity_id: user_id (for "user") or external identifier (e.g., department UUID) + entity_type = Column( + String(20), nullable=False, - index=True, - comment="Member user ID", + default="user", + server_default="user", + comment="Entity type: user, namespace", + ) + entity_id = Column( + String(100), + nullable=False, + comment="Entity identifier: user_id for 'user', external ID for others", + ) + + entity_display_name = Column( + String(100), + nullable=True, + comment="Display name snapshot for entity-type members (group name, department name, etc.)", ) - # Relationship to User - user: Mapped["User"] = relationship( - "User", foreign_keys=[user_id], back_populates="resource_members" + # Backward compatibility: kept as nullable column for old SQL queries + # For entity_type='user', auto-synced from entity_id via SQLAlchemy events + # For other entity types, remains NULL + user_id = Column( + Integer, + nullable=True, + comment="Member user ID (kept for backward compatibility, use entity_type+entity_id for new code)", ) # Role-based permission @@ -173,7 +184,11 @@ class ResourceMember(Base): __table_args__ = ( UniqueConstraint( - "resource_type", "resource_id", "user_id", name="uq_resource_members" + "resource_type", + "resource_id", + "entity_type", + "entity_id", + name="uq_resource_members_entity", ), Index("idx_resource_members_resource", "resource_type", "resource_id"), Index("idx_resource_members_status", "status"), @@ -193,6 +208,12 @@ class ResourceMember(Base): ) def __repr__(self) -> str: + if self.entity_type and self.entity_type != "user": + return ( + f"" + ) return ( f" None: role: The role to set (Owner, Maintainer, Developer, Reporter) """ self.role = role + + @classmethod + def create( + cls, + resource_type: str, + resource_id: int, + entity_type: str = "user", + entity_id: Optional[str] = None, + role: str = ResourceRole.Reporter.value, + status: str = MemberStatus.PENDING.value, + invited_by_user_id: int = 0, + share_link_id: int = 0, + reviewed_by_user_id: int = 0, + reviewed_at: Optional[datetime] = None, + copied_resource_id: int = 0, + entity_display_name: Optional[str] = None, + requested_at: Optional[datetime] = None, + ) -> "ResourceMember": + """Create a ResourceMember with consistent entity initialization. + + Automatically syncs user_id from entity_id for user-type members. + Prefer this factory over direct constructor to ensure invariant + consistency regardless of SQLAlchemy event listener availability + (e.g., bulk_insert_mappings bypasses before_insert). + """ + if not entity_id: + raise ValueError("entity_id is required") + if entity_type == "user": + try: + int(entity_id) + except (TypeError, ValueError) as exc: + raise ValueError( + "entity_id must be an integer string for user-type members" + ) from exc + + member = cls( + resource_type=resource_type, + resource_id=resource_id, + entity_type=entity_type, + entity_id=entity_id, + role=role, + status=status, + invited_by_user_id=invited_by_user_id, + share_link_id=share_link_id, + reviewed_by_user_id=reviewed_by_user_id, + reviewed_at=reviewed_at or EPOCH_TIME, + copied_resource_id=copied_resource_id, + entity_display_name=entity_display_name, + requested_at=requested_at or datetime.utcnow(), + ) + # Explicitly sync user_id so callers don't rely solely on event listeners + _sync_user_id_from_entity(member) + return member + + +# SQLAlchemy event listeners to keep user_id in sync with entity_id for user-type members +from sqlalchemy import event + + +def _sync_user_id_from_entity(target: ResourceMember) -> None: + """Sync user_id column from entity_id when entity_type is 'user'.""" + if target.entity_type and target.entity_type == "user" and target.entity_id: + try: + target.user_id = int(target.entity_id) + except (ValueError, TypeError): + target.user_id = None + else: + target.user_id = None + + +@event.listens_for(ResourceMember, "before_insert") +def _resource_member_before_insert( + _mapper, _connection, target: ResourceMember +) -> None: + _sync_user_id_from_entity(target) + + +@event.listens_for(ResourceMember, "before_update") +def _resource_member_before_update( + _mapper, _connection, target: ResourceMember +) -> None: + _sync_user_id_from_entity(target) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index a7d1b608fec..25ad37fd52a 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -9,16 +9,6 @@ The User model is extended here with Backend-specific relationships. """ -from sqlalchemy.orm import relationship - from shared.models.db import User -# Add Backend-specific relationships to User model -# Note: This modifies the shared User class to add relationships -# that only make sense in the Backend context -# Resource sharing is now handled via the unified ResourceMember table -User.resource_members = relationship( - "ResourceMember", foreign_keys="[ResourceMember.user_id]", back_populates="user" -) - __all__ = ["User"] diff --git a/backend/app/schemas/base_role.py b/backend/app/schemas/base_role.py index cdbf4d3200a..531b28ccbe2 100644 --- a/backend/app/schemas/base_role.py +++ b/backend/app/schemas/base_role.py @@ -76,6 +76,35 @@ def has_permission(user_role: str | BaseRole, required_role: str | BaseRole) -> return user_level <= required_level +def get_highest_role(roles: list[str | BaseRole]) -> str | None: + """ + Resolve the highest (most privileged) role from a list of roles. + + When a user has multiple permission sources, the highest role wins. + This matches the DingTalk Docs permission model behavior. + + Args: + roles: List of role values (strings or BaseRole enums) + + Returns: + The highest role string value, or None if the list is empty + """ + if not roles: + return None + + best_role = None + best_level = 999 + + for role in roles: + role_str = role.value if isinstance(role, BaseRole) else role + level = ROLE_HIERARCHY.get(role_str, 999) + if level < best_level: # Lower number = higher privilege + best_level = level + best_role = role_str + + return best_role + + # Backward compatibility aliases # These allow existing code to continue using their preferred names GroupRole = BaseRole diff --git a/backend/app/schemas/knowledge.py b/backend/app/schemas/knowledge.py index cdc7fe49505..55dfb316d38 100644 --- a/backend/app/schemas/knowledge.py +++ b/backend/app/schemas/knowledge.py @@ -12,6 +12,8 @@ from pydantic import BaseModel, Field, field_validator, model_validator +from app.schemas.base_role import BaseRole + # Import shared types from kind.py to avoid duplication from app.schemas.kind import ( EmbeddingModelRef, @@ -67,6 +69,28 @@ class ResourceScope(str, Enum): # are imported from app.schemas.kind to maintain single source of truth +class InitialMemberCreate(BaseModel): + """Schema for an initial member when creating a knowledge base.""" + + entity_type: str = Field( + default="user", + description="Entity type: 'user', 'namespace', or other registered types", + ) + entity_id: str = Field( + ..., + min_length=1, + description="Entity identifier (user ID or namespace ID)", + ) + role: BaseRole = Field( + default=BaseRole.Reporter, + description="Member role: Maintainer, Developer, Reporter, RestrictedAnalyst", + ) + entity_display_name: Optional[str] = Field( + default=None, + description="Display name snapshot for the entity (e.g., department name, group display name)", + ) + + class KnowledgeBaseCreate(BaseModel): """Schema for creating a knowledge base.""" @@ -93,6 +117,10 @@ class KnowledgeBaseCreate(BaseModel): max_length=3, description="Guided questions list (max 3) to show in notebook mode for quick user interaction", ) + members: Optional[List[InitialMemberCreate]] = Field( + None, + description="Initial members to add to the knowledge base after creation", + ) @field_validator("guided_questions") @classmethod @@ -549,6 +577,31 @@ class KnowledgeBaseWithGroupInfo(BaseModel): None, description="Current user's role for this KB: 'Owner' | 'Maintainer' | 'Developer' | 'Reporter' | 'RestrictedAnalyst' | None", ) + # Source group for entity-authorized shared KBs + source_group: Optional[str] = Field( + None, + description="Source group name for entity-authorized shared KBs, e.g., '来自 XX 群组'", + ) + # Share source user name for shared KBs + shared_from: Optional[str] = Field( + None, + description="Share source user name for shared KBs", + ) + # Multiple share source user names for multi-source shared KBs in All mode + shared_from_users: Optional[list[str]] = Field( + None, + description="Multiple share source user names for multi-source shared KBs in All mode", + ) + # Share via entity type: 'user', 'namespace', or other registered entity types + shared_via: Optional[str] = Field( + None, + description="Share via entity type: 'user', 'namespace', or other registered entity types", + ) + # Knowledge base creator name for display fallback + owner_name: Optional[str] = Field( + None, + description="Knowledge base creator's user name", + ) class AllGroupedPersonal(BaseModel): diff --git a/backend/app/schemas/mcp_providers.py b/backend/app/schemas/mcp_providers.py index 9b1461b4663..c3f5b376f79 100644 --- a/backend/app/schemas/mcp_providers.py +++ b/backend/app/schemas/mcp_providers.py @@ -74,3 +74,25 @@ class MCPProviderKeysResponse(BaseModel): success: bool message: str + + +class MCPToolInfo(BaseModel): + """Information about a single MCP tool""" + + name: str + description: str + + +class MCPTestRequest(BaseModel): + """Request to test an MCP server connection""" + + server_name: str + server_config: Dict[str, Any] # Raw server config: type/url/headers/command/args + + +class MCPTestResponse(BaseModel): + """Response for MCP server connection test""" + + success: bool + error: Optional[str] = None + tools: List[MCPToolInfo] = [] diff --git a/backend/app/schemas/share.py b/backend/app/schemas/share.py index 11bbb88d561..100f4c6e862 100644 --- a/backend/app/schemas/share.py +++ b/backend/app/schemas/share.py @@ -12,7 +12,7 @@ from enum import Enum from typing import List, Optional -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from app.models.share_link import ResourceType @@ -137,13 +137,34 @@ class ShareLinkInDB(BaseModel): class ResourceMemberCreate(BaseModel): - """Request body for adding a member directly.""" + """Request body for adding a member directly. - user_id: int = Field(description="User ID to add as member") + Supports both user and entity-type members: + - For user members: provide user_id, entity_type defaults to 'user' + - For entity members: provide entity_type, entity_id (user_id=0) + """ + + user_id: int = Field( + default=0, description="User ID to add as member (0 for entity members)" + ) role: MemberRole = Field( default=MemberRole.Reporter, description="Member role (Owner not allowed)", ) + entity_type: Optional[str] = Field( + default=None, + description="Entity type: 'user' (default), 'namespace'. " + "When set to non-user type, user_id can be 0.", + ) + entity_id: Optional[str] = Field( + default=None, + description="Entity identifier (required when entity_type is set and not 'user')", + ) + entity_display_name: Optional[str] = Field( + default=None, + description="Display name for entity-type members (e.g., group name, department name). " + "Optional; used as a snapshot when creating the member record.", + ) @field_validator("role") @classmethod @@ -153,6 +174,24 @@ def validate_role(cls, v: MemberRole) -> MemberRole: raise ValueError("Owner role cannot be assigned via API") return v + @field_validator("entity_type") + @classmethod + def validate_entity_type(cls, v: Optional[str]) -> Optional[str]: + """Normalize entity_type to lowercase.""" + if v is not None: + return v.lower() + return v + + @model_validator(mode="after") + def validate_member_identity(self) -> "ResourceMemberCreate": + """Ensure valid member identity combinations.""" + entity_type = self.entity_type or "user" + if entity_type != "user" and not self.entity_id: + raise ValueError("entity_id is required for non-user entity members") + if entity_type != "user" and self.user_id != 0: + raise ValueError("user_id must be 0 for non-user entity members") + return self + class BatchResourceMemberCreate(BaseModel): """Request body for batch adding members directly.""" @@ -186,11 +225,19 @@ class ResourceMemberResponse(BaseModel): id: int resource_type: str resource_id: int - user_id: int - user_name: Optional[str] = None # Populated from user lookup + user_id: Optional[int] = ( + None # Null for entity-type members (namespace, department, etc.) + ) + display_name: Optional[str] = None # Unified display name for all member types user_email: Optional[str] = None # Populated from user lookup role: str = Field(description="Member role: Owner, Maintainer, Developer, Reporter") status: str + entity_type: Optional[str] = None + entity_id: Optional[str] = None + source_type: str = Field( + default="direct", + description="Permission source: 'direct', 'entity_permission', 'share_link', 'group_membership'", + ) invited_by_user_id: int invited_by_user_name: Optional[str] = None # Populated from user lookup reviewed_by_user_id: Optional[int] = None @@ -207,6 +254,8 @@ class FailedMemberResponse(BaseModel): user_id: int error: str + entity_type: Optional[str] = None + entity_id: Optional[str] = None class BatchResourceMemberResponse(BaseModel): @@ -229,9 +278,14 @@ class ResourceMemberInDB(BaseModel): id: int resource_type: str resource_id: int - user_id: int + user_id: Optional[int] = ( + None # Null for entity-type members (namespace, department, etc.) + ) role: str = Field(description="Member role: Owner, Maintainer, Developer, Reporter") status: str + entity_type: Optional[str] = None + entity_id: Optional[str] = None + entity_display_name: Optional[str] = None invited_by_user_id: int share_link_id: Optional[int] = None reviewed_by_user_id: Optional[int] = None @@ -401,6 +455,38 @@ class MyKBPermissionResponse(BaseModel): ) +class PermissionSourceInfo(BaseModel): + """Schema describing a single permission source.""" + + source_type: str = Field( + ..., + description="Source type: 'direct', 'entity_permission', 'group_membership', 'share_link', 'creator'", + ) + display_name: Optional[str] = Field( + None, description="Display name of the source (e.g., group name)" + ) + role: str = Field(..., description="Role granted by this source") + entity_type: Optional[str] = Field( + None, description="Entity type if source is entity_permission" + ) + entity_id: Optional[str] = Field( + None, description="Entity ID if source is entity_permission" + ) + + +class MyPermissionSourcesResponse(BaseModel): + """Schema for current user's permission sources on a knowledge base.""" + + has_access: bool = Field(..., description="Whether user has access to the KB") + effective_role: Optional[str] = Field( + None, description="Highest role across all sources" + ) + is_creator: bool = Field(..., description="Whether user is the KB creator") + sources: List[PermissionSourceInfo] = Field( + default_factory=list, description="All permission sources" + ) + + class KBShareInfoResponse(BaseModel): """Schema for knowledge base share info response.""" diff --git a/backend/app/schemas/shared_team.py b/backend/app/schemas/shared_team.py index f3583efda86..07249d22148 100644 --- a/backend/app/schemas/shared_team.py +++ b/backend/app/schemas/shared_team.py @@ -3,7 +3,7 @@ # SPDX-License-Identifier: Apache-2.0 from datetime import datetime -from typing import Optional +from typing import List, Optional from pydantic import BaseModel @@ -51,6 +51,7 @@ class TeamShareInfo(BaseModel): user_name: str team_id: int team_name: str + bind_mode: Optional[List[str]] = None class JoinSharedTeamRequest(BaseModel): diff --git a/backend/app/services/adapters/bot_kinds.py b/backend/app/services/adapters/bot_kinds.py index 8417225afc7..904e37044a0 100644 --- a/backend/app/services/adapters/bot_kinds.py +++ b/backend/app/services/adapters/bot_kinds.py @@ -399,18 +399,26 @@ def create_with_user( name: ref.model_dump() for name, ref in preload_skill_refs.items() } + ghost_name = self._find_available_name( + db, + base_name=f"{obj_in.name}-ghost", + kind="Ghost", + user_id=user_id, + namespace=namespace, + ) + ghost_json = { "kind": "Ghost", "spec": ghost_spec, "status": {"state": "Available"}, - "metadata": {"name": f"{obj_in.name}-ghost", "namespace": namespace}, + "metadata": {"name": ghost_name, "namespace": namespace}, "apiVersion": "agent.wecode.io/v1", } ghost = Kind( user_id=user_id, kind="Ghost", - name=f"{obj_in.name}-ghost", + name=ghost_name, namespace=namespace, json=ghost_json, is_active=True, @@ -499,7 +507,7 @@ def create_with_user( bot_json = { "kind": "Bot", "spec": { - "ghostRef": {"name": f"{obj_in.name}-ghost", "namespace": namespace}, + "ghostRef": {"name": ghost_name, "namespace": namespace}, "shellRef": {"name": shell_ref_name, "namespace": shell_ref_namespace}, "modelRef": {"name": model_ref_name, "namespace": model_ref_namespace}, }, @@ -2058,21 +2066,22 @@ def _resolve_skill_refs( return resolved - def find_available_bot_name( + def _find_available_name( self, db: Session, *, base_name: str, + kind: str, user_id: int, namespace: str = "default", ) -> str: - """Find an available bot name by appending (2), (3), etc. if needed.""" + """Find an available name by appending (2), (3), etc. if needed.""" is_group_namespace = namespace != "default" candidate = base_name counter = 2 while True: query = db.query(Kind).filter( - Kind.kind == "Bot", + Kind.kind == kind, Kind.name == candidate, Kind.namespace == namespace, Kind.is_active == True, diff --git a/backend/app/services/adapters/task_kinds/operations.py b/backend/app/services/adapters/task_kinds/operations.py index 516a9bd2ec8..de14cc48c72 100644 --- a/backend/app/services/adapters/task_kinds/operations.py +++ b/backend/app/services/adapters/task_kinds/operations.py @@ -675,7 +675,8 @@ def _handle_member_leave( .filter( ResourceMember.resource_type == ResourceType.TASK, ResourceMember.resource_id == task_id, - ResourceMember.user_id == user_id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), ResourceMember.status == MemberStatus.APPROVED, ) .first() diff --git a/backend/app/services/adapters/task_kinds/query_utils.py b/backend/app/services/adapters/task_kinds/query_utils.py index bbdea70c640..2b027afd251 100644 --- a/backend/app/services/adapters/task_kinds/query_utils.py +++ b/backend/app/services/adapters/task_kinds/query_utils.py @@ -21,7 +21,8 @@ FROM tasks k LEFT JOIN resource_members tm ON k.id = tm.resource_id AND tm.resource_type = 'Task' - AND tm.user_id = :user_id + AND tm.entity_type = 'user' + AND tm.entity_id = :entity_id AND tm.status = 'approved' WHERE k.kind = 'Task' AND k.is_active = :is_active @@ -36,7 +37,8 @@ FROM tasks k LEFT JOIN resource_members tm ON k.id = tm.resource_id AND tm.resource_type = 'Task' - AND tm.user_id = :user_id + AND tm.entity_type = 'user' + AND tm.entity_id = :entity_id AND tm.status = 'approved' WHERE k.kind = 'Task' AND k.is_active = :is_active @@ -93,7 +95,8 @@ SELECT resource_id FROM resource_members WHERE resource_type = 'Task' - AND user_id = :user_id + AND entity_type = 'user' + AND entity_id = :entity_id AND status = 'approved' AND copied_resource_id = 0 """ @@ -132,7 +135,11 @@ def get_accessible_task_ids_and_total( total = _timed_scalar( db, _ACCESSIBLE_COUNT_SQL, - {"user_id": user_id, "is_active": TaskResource.STATE_ACTIVE}, + { + "user_id": user_id, + "entity_id": str(user_id), + "is_active": TaskResource.STATE_ACTIVE, + }, "accessible_total", ) rows = _timed_rows( @@ -140,6 +147,7 @@ def get_accessible_task_ids_and_total( _ACCESSIBLE_IDS_SQL, { "user_id": user_id, + "entity_id": str(user_id), "is_active": TaskResource.STATE_ACTIVE, "limit": limit + extra_limit, "skip": skip, @@ -157,7 +165,11 @@ def get_owned_task_ids_and_total( total = _timed_scalar( db, _OWNED_COUNT_SQL, - {"user_id": user_id, "is_active": TaskResource.STATE_ACTIVE}, + { + "user_id": user_id, + "entity_id": str(user_id), + "is_active": TaskResource.STATE_ACTIVE, + }, "owned_total", ) rows = _timed_rows( @@ -165,6 +177,7 @@ def get_owned_task_ids_and_total( _OWNED_IDS_SQL, { "user_id": user_id, + "entity_id": str(user_id), "is_active": TaskResource.STATE_ACTIVE, "limit": limit + extra_limit, "skip": skip, @@ -188,7 +201,11 @@ def get_group_task_ids_for_accessible_user(db: Session, *, user_id: int) -> Set[ owned_rows = _timed_rows( db, _OWNED_GROUP_CHAT_SQL, - {"user_id": user_id, "is_active": TaskResource.STATE_ACTIVE}, + { + "user_id": user_id, + "entity_id": str(user_id), + "is_active": TaskResource.STATE_ACTIVE, + }, "owned_group_chat", ) owned_ids = {row[0] for row in owned_rows} @@ -197,7 +214,7 @@ def get_group_task_ids_for_accessible_user(db: Session, *, user_id: int) -> Set[ member_rows = _timed_rows( db, _MEMBER_TASK_IDS_SQL, - {"user_id": user_id}, + {"entity_id": str(user_id)}, "member_task_ids", ) member_task_ids = {row[0] for row in member_rows} @@ -214,7 +231,11 @@ def get_group_task_ids_for_owned_tasks(db: Session, *, user_id: int) -> Set[int] rows = _timed_rows( db, _OWNED_GROUP_CHAT_SQL, - {"user_id": user_id, "is_active": TaskResource.STATE_ACTIVE}, + { + "user_id": user_id, + "entity_id": str(user_id), + "is_active": TaskResource.STATE_ACTIVE, + }, "owned_group_chat_only", ) return {row[0] for row in rows} diff --git a/backend/app/services/adapters/task_kinds/running_tasks.py b/backend/app/services/adapters/task_kinds/running_tasks.py index 683b657d85c..14b16d3394f 100644 --- a/backend/app/services/adapters/task_kinds/running_tasks.py +++ b/backend/app/services/adapters/task_kinds/running_tasks.py @@ -29,12 +29,13 @@ def _get_candidate_task_user_ids(db: Session, team: Kind) -> Optional[Set[int]]: """Return candidate task owners for a team, or None when narrowing is unsafe.""" if team.namespace == "default": shared_user_ids = { - row[0] + int(row[0]) for row in ( - db.query(ResourceMember.user_id) + db.query(ResourceMember.entity_id) .filter( ResourceMember.resource_type == ResourceType.TEAM, ResourceMember.resource_id == team.id, + ResourceMember.entity_type == "user", ResourceMember.status == MemberStatus.APPROVED, ) .all() diff --git a/backend/app/services/adapters/team_kinds.py b/backend/app/services/adapters/team_kinds.py index 62a2728c430..b59122cb184 100644 --- a/backend/app/services/adapters/team_kinds.py +++ b/backend/app/services/adapters/team_kinds.py @@ -312,7 +312,8 @@ def get_user_teams( & (ResourceMember.resource_type == ResourceType.TEAM), ) .filter( - ResourceMember.user_id == user_id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), ResourceMember.status == MemberStatus.APPROVED, Kind.is_active == True, Kind.kind == "Team", @@ -741,7 +742,8 @@ def get_team_detail( .filter( ResourceMember.resource_type == ResourceType.TEAM, ResourceMember.resource_id == team_id, - ResourceMember.user_id == user_id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), ResourceMember.status == MemberStatus.APPROVED, ) .first() @@ -1020,7 +1022,8 @@ def delete_with_user( .filter( ResourceMember.resource_type == ResourceType.TEAM, ResourceMember.resource_id == team_id, - ResourceMember.user_id == user_id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), ResourceMember.status == MemberStatus.APPROVED, ) .first() @@ -1127,7 +1130,8 @@ def count_user_teams( & (ResourceMember.resource_type == ResourceType.TEAM), ) .filter( - ResourceMember.user_id == user_id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), ResourceMember.status == MemberStatus.APPROVED, Kind.is_active == True, Kind.kind == "Team", @@ -1906,7 +1910,8 @@ def get_team_input_parameters( .filter( ResourceMember.resource_type == ResourceType.TEAM, ResourceMember.resource_id == team_id, - ResourceMember.user_id == user_id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), ResourceMember.status == MemberStatus.APPROVED, ) .first() @@ -2094,7 +2099,8 @@ def get_team_skills( .filter( ResourceMember.resource_type == ResourceType.TEAM, ResourceMember.resource_id == team_id, - ResourceMember.user_id == user_id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), ResourceMember.status == MemberStatus.APPROVED, ) .first() @@ -2152,36 +2158,6 @@ def get_team_skills( "preload_skills": sorted(all_preload_skills), } - def _find_available_name( - self, - db: Session, - *, - base_name: str, - kind: str, - user_id: int, - namespace: str, - ) -> str: - """Return the first available name: base_name → base_name (2) → base_name (3) ...""" - is_group_namespace = namespace != "default" - candidate = base_name - counter = 2 - while True: - query = db.query(Kind).filter( - Kind.kind == kind, - Kind.name == candidate, - Kind.namespace == namespace, - Kind.is_active == True, - ) - # For personal namespace, scope uniqueness check to the user - # For group namespaces, uniqueness is across all users in the namespace - if not is_group_namespace: - query = query.filter(Kind.user_id == user_id) - existing = query.first() - if not existing: - return candidate - candidate = f"{base_name} ({counter})" - counter += 1 - def copy_team( self, db: Session, @@ -2255,9 +2231,10 @@ def copy_team( raise HTTPException(status_code=400, detail="Leader bot not found") # Find available bot name in the destination namespace - bot_name = bot_kinds_service.find_available_bot_name( + bot_name = bot_kinds_service._find_available_name( db, base_name=f"Copy of {original_bot.name}", + kind="Bot", user_id=user_id, namespace=dest_namespace, ) @@ -2307,9 +2284,10 @@ def copy_team( if bot: if dest_namespace != original.namespace: # Clone the bot into the destination namespace - bot_name = bot_kinds_service.find_available_bot_name( + bot_name = bot_kinds_service._find_available_name( db, base_name=bot.name, + kind="Bot", user_id=user_id, namespace=dest_namespace, ) @@ -2348,7 +2326,7 @@ def copy_team( # Determine group_name for permission check group_name = dest_namespace if dest_namespace != "default" else None - team_name = self._find_available_name( + team_name = bot_kinds_service._find_available_name( db, base_name=f"Copy of {original.name}", kind="Team", diff --git a/backend/app/services/chat/access/permissions.py b/backend/app/services/chat/access/permissions.py index 13a59706ba7..bb58d4eeb84 100644 --- a/backend/app/services/chat/access/permissions.py +++ b/backend/app/services/chat/access/permissions.py @@ -75,7 +75,8 @@ def can_access_task(db: Session, user_id: int, task_id: int) -> bool: .filter( ResourceMember.resource_type == ResourceType.TASK, ResourceMember.resource_id == task_id, - ResourceMember.user_id == user_id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), ResourceMember.status == MemberStatus.APPROVED, ) .first() diff --git a/backend/app/services/chat/storage/task_manager.py b/backend/app/services/chat/storage/task_manager.py index aff915c6978..cda172d1684 100644 --- a/backend/app/services/chat/storage/task_manager.py +++ b/backend/app/services/chat/storage/task_manager.py @@ -164,7 +164,8 @@ def get_task_with_access_check( .filter( ResourceMember.resource_type == ResourceType.TASK, ResourceMember.resource_id == task_id, - ResourceMember.user_id == user_id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), ResourceMember.status == MemberStatus.APPROVED, ) .first() diff --git a/backend/app/services/group_member_helper.py b/backend/app/services/group_member_helper.py index 2c0d3e64552..7f4930cca55 100644 --- a/backend/app/services/group_member_helper.py +++ b/backend/app/services/group_member_helper.py @@ -61,7 +61,8 @@ def get_group_member( .filter( ResourceMember.resource_type == NAMESPACE_RESOURCE_TYPE, ResourceMember.resource_id == namespace_id, - ResourceMember.user_id == user_id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), ResourceMember.status == MemberStatus.APPROVED.value, ) .first() @@ -174,7 +175,8 @@ def get_user_groups_with_roles(db: Session, user_id: int) -> list[tuple[str, str db.query(ResourceMember) .filter( ResourceMember.resource_type == NAMESPACE_RESOURCE_TYPE, - ResourceMember.user_id == user_id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), ResourceMember.status == MemberStatus.APPROVED.value, ) .all() @@ -226,7 +228,8 @@ def create_group_member( member = ResourceMember( resource_type=NAMESPACE_RESOURCE_TYPE, resource_id=namespace_id, - user_id=user_id, + entity_type="user", + entity_id=str(user_id), role=role, status=MemberStatus.APPROVED.value, invited_by_user_id=invited_by_user_id, diff --git a/backend/app/services/group_service.py b/backend/app/services/group_service.py index a806eaa0a12..e60a7af389b 100644 --- a/backend/app/services/group_service.py +++ b/backend/app/services/group_service.py @@ -6,6 +6,7 @@ from typing import Optional from fastapi import HTTPException +from sqlalchemy import or_ from sqlalchemy.orm import Session from app.core.exceptions import CustomHTTPException @@ -145,7 +146,8 @@ def create_group( owner_member = ResourceMember( resource_type=NAMESPACE_RESOURCE_TYPE, resource_id=new_group.id, - user_id=owner_user_id, + entity_type="user", + entity_id=str(owner_user_id), role=GroupRole.Owner.value, status=MemberStatus.APPROVED.value, invited_by_user_id=owner_user_id, # Self-invited @@ -254,6 +256,68 @@ def list_user_groups( return result +def search_groups( + db: Session, + q: str, + skip: int = 0, + limit: int = 20, + user_id: int | None = None, + user_role: str | None = None, +) -> tuple[list[GroupResponse], int]: + """ + Search groups by name or display_name with level='group' filter. + + Args: + db: Database session + q: Search query string + skip: Number of records to skip + limit: Maximum number of records to return + user_id: Current user ID (optional — filters to user's groups if provided) + user_role: Current user role (optional) + Returns: + Tuple of (list of GroupResponse objects, total count) + """ + query = db.query(Namespace).filter( + Namespace.is_active.is_(True), + Namespace.level == GroupLevel.group.value, + ) + + if q: + search_pattern = f"%{q}%" + query = query.filter( + or_( + Namespace.name.ilike(search_pattern), + Namespace.display_name.ilike(search_pattern), + ) + ) + + # Filter to groups where user is a member + if user_id is not None: + member_data = get_user_groups_with_roles(db, user_id) + group_names = [name for name, _ in member_data] + if not group_names: + return [], 0 + query = query.filter(Namespace.name.in_(group_names)) + + total = query.count() + groups = query.order_by(Namespace.created_at.desc()).offset(skip).limit(limit).all() + + # Get member counts and user's role map + member_counts = {} + role_map = {name: role for name, role in member_data} if user_id is not None else {} + for group in groups: + member_counts[group.name] = get_group_member_count(db, group.name) + + result = [] + for group in groups: + group_response = GroupResponse.model_validate(group) + group_response.member_count = member_counts.get(group.name, 0) + group_response.my_role = role_map.get(group.name) + result.append(group_response) + + return result, total + + def update_group( db: Session, group_name: str, @@ -772,7 +836,8 @@ def update_member_roles_batch( .filter( ResourceMember.resource_type == NAMESPACE_RESOURCE_TYPE, ResourceMember.resource_id == namespace_id, - ResourceMember.user_id.in_(user_ids), + ResourceMember.entity_type == "user", + ResourceMember.entity_id.in_([str(uid) for uid in user_ids]), ResourceMember.status == MemberStatus.APPROVED.value, ) .all() @@ -986,7 +1051,8 @@ def invite_all_users( new_member = ResourceMember( resource_type=NAMESPACE_RESOURCE_TYPE, resource_id=group.id, - user_id=user.id, + entity_type="user", + entity_id=str(user.id), role=GroupRole.Reporter.value, status=MemberStatus.APPROVED.value, invited_by_user_id=invited_by_user_id, diff --git a/backend/app/services/knowledge/knowledge_service.py b/backend/app/services/knowledge/knowledge_service.py index d16aa43e613..e2103794c2a 100644 --- a/backend/app/services/knowledge/knowledge_service.py +++ b/backend/app/services/knowledge/knowledge_service.py @@ -7,7 +7,7 @@ """ import asyncio -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Optional from sqlalchemy import and_, case, func @@ -337,13 +337,22 @@ def list_knowledge_bases( db.query(ResourceMember.resource_id) .filter( ResourceMember.resource_type == ResourceType.KNOWLEDGE_BASE.value, - ResourceMember.user_id == user_id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), ResourceMember.status == MemberStatus.APPROVED.value, ) .all() ) shared_kb_ids = [p.resource_id for p in shared_permissions] + # Include KBs accessible via entity-type bindings (namespace / external resolvers) + accessible_groups = get_user_groups(db, user_id) + entity_result = KnowledgeService._collect_entity_authorized_kbs( + db, user_id, accessible_groups + ) + entity_kb_ids = {kb.id for kb in entity_result.entity_kbs} + shared_kb_ids = list(set(shared_kb_ids) | entity_kb_ids) + # Get knowledge bases bound to group chats where user is a member bound_kb_ids = KnowledgeService._get_bound_kb_ids_for_user(db, user_id) @@ -385,6 +394,8 @@ def list_knowledge_bases( if role is None: return [] + # KBs belonging to this group (native group KBs only) + # Entity-authorized KBs are shown in personal shared_with_me instead return ( db.query(Kind) .filter( @@ -424,13 +435,22 @@ def list_knowledge_bases( db.query(ResourceMember.resource_id) .filter( ResourceMember.resource_type == ResourceType.KNOWLEDGE_BASE.value, - ResourceMember.user_id == user_id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), ResourceMember.status == MemberStatus.APPROVED.value, ) .all() ) shared_kb_ids = [p.resource_id for p in shared_permissions] + # Collect all entity-authorized KBs (namespace + external resolvers) + # via the unified helper to avoid duplicating entity resolution logic. + entity_result = KnowledgeService._collect_entity_authorized_kbs( + db, user_id, accessible_groups + ) + entity_kb_ids = {kb.id for kb in entity_result.entity_kbs} + shared_kb_ids = list(set(shared_kb_ids) | entity_kb_ids) + # Get organization-level namespace names org_namespaces = ( db.query(Namespace.name) @@ -1535,6 +1555,8 @@ def get_accessible_knowledge( Returns: AccessibleKnowledgeResponse with personal, team, and organization knowledge bases """ + from app.models.resource_member import MemberStatus, ResourceMember + from app.models.share_link import ResourceType # Get personal knowledge bases (created by user) personal_kbs = ( @@ -1560,6 +1582,7 @@ def get_accessible_knowledge( ) for kb in personal_kbs ] + included_personal_ids = {kb.id for kb in personal_kbs} # Get personal knowledge bases that are bound to group chats where user is a member # These are shared via group chat binding, not by explicit sharing @@ -1580,6 +1603,9 @@ def get_accessible_knowledge( ) for kb in bound_kbs: + if kb.id in included_personal_ids: + continue + included_personal_ids.add(kb.id) personal.append( AccessibleKnowledgeBase( id=kb.id, @@ -1592,8 +1618,59 @@ def get_accessible_knowledge( ) ) + # Include KBs shared directly to the user via ResourceMember (entity_type=user) + direct_shared_rows = ( + db.query(ResourceMember.resource_id) + .filter( + ResourceMember.resource_type == ResourceType.KNOWLEDGE_BASE.value, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), + ResourceMember.status == MemberStatus.APPROVED.value, + ) + .all() + ) + direct_shared_ids = {row[0] for row in direct_shared_rows} + # Get team knowledge bases grouped by namespace accessible_groups = get_user_groups(db, user_id) + + # Include KBs shared via entity-type bindings (namespace / external resolvers) + entity_result = KnowledgeService._collect_entity_authorized_kbs( + db, user_id, accessible_groups + ) + entity_shared_ids = {kb.id for kb in entity_result.entity_shared_to_me_kbs} + + extra_shared_ids = ( + direct_shared_ids | entity_shared_ids + ) - included_personal_ids + if extra_shared_ids: + shared_kbs = ( + db.query(Kind) + .filter( + Kind.kind == "KnowledgeBase", + Kind.id.in_(extra_shared_ids), + Kind.is_active == True, + Kind.user_id != user_id, + ) + .order_by(Kind.updated_at.desc()) + .all() + ) + for kb in shared_kbs: + if kb.id in included_personal_ids: + continue + included_personal_ids.add(kb.id) + personal.append( + AccessibleKnowledgeBase( + id=kb.id, + name=kb.json.get("spec", {}).get("name", ""), + description=kb.json.get("spec", {}).get("description") or None, + document_count=KnowledgeService.get_active_document_count( + db, kb.id + ), + updated_at=kb.updated_at, + ) + ) + team_groups: list[TeamKnowledgeGroup] = [] for group_name in accessible_groups: @@ -1729,13 +1806,25 @@ def get_personal_knowledge_bases_grouped( db.query(ResourceMember.resource_id) .filter( ResourceMember.resource_type == ResourceType.KNOWLEDGE_BASE.value, - ResourceMember.user_id == user_id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), ResourceMember.status == MemberStatus.APPROVED.value, ) .all() ) shared_kb_ids = [p[0] for p in shared_kb_ids] + # Include KBs accessible via entity-type bindings (namespace / external resolvers). + # Restrict to personal-scope (namespace=default) to match this grouping's semantics. + accessible_groups = get_user_groups(db, user_id) + entity_result = KnowledgeService._collect_entity_authorized_kbs( + db, user_id, accessible_groups + ) + if entity_result.entity_personal_kb_ids: + shared_kb_ids = list( + set(shared_kb_ids) | entity_result.entity_personal_kb_ids + ) + # Query shared personal KBs (namespace=default, but not created by current user) shared_kbs = [] if shared_kb_ids: @@ -1774,6 +1863,551 @@ def get_personal_knowledge_bases_grouped( "shared_with_me": shared_with_me, } + @staticmethod + def _merge_roles(*roles: str | None) -> str | None: + """Return the highest privilege role from a list of roles.""" + highest: str | None = None + for role in roles: + if role is None: + continue + if highest is None or has_permission(role, highest): + highest = role + return highest + + @staticmethod + def _kb_to_response( + kb: Kind, + group_id: str, + group_name: str, + group_type: str, + document_counts: dict[int, int], + owner_user_map: dict[int, str], + my_role: str | None = None, + source_group: str | None = None, + shared_from: str | None = None, + shared_via: str | None = None, + shared_from_users: list[str] | None = None, + ) -> KnowledgeBaseWithGroupInfo: + """Convert a Kind KB to KnowledgeBaseWithGroupInfo response.""" + spec = kb.json.get("spec", {}) + return KnowledgeBaseWithGroupInfo( + id=kb.id, + name=spec.get("name", ""), + description=spec.get("description") or None, + kb_type=spec.get("kbType", "notebook"), + namespace=kb.namespace, + document_count=document_counts.get(kb.id, 0), + updated_at=kb.updated_at, + created_at=kb.created_at, + user_id=kb.user_id, + group_id=group_id, + group_name=group_name, + group_type=group_type, + my_role=my_role, + source_group=source_group, + shared_from=shared_from, + shared_via=shared_via, + shared_from_users=shared_from_users, + owner_name=owner_user_map.get(kb.user_id), + ) + + @dataclass(frozen=True) + class EntityAuthorizedKbsResult: + """Intermediate result for KBs accessible via entity-type bindings.""" + + entity_kbs: list[Kind] + entity_personal_kb_ids: set[int] + entity_shared_to_me_kbs: list[Kind] + shared_into_group_kbs: list[Kind] + member_group_map: dict[int, list[str]] + member_role_map: dict[int, list[str]] + member_inviter_map: dict[int, set[int]] + member_type_map: dict[int, list[str]] + member_entity_id_map: dict[int, list[str]] + + @staticmethod + def _collect_entity_authorized_kbs( + db: Session, + user_id: int, + accessible_groups: list[str], + ) -> "KnowledgeService.EntityAuthorizedKbsResult": + """Collect KBs accessible via entity-type ResourceMember bindings.""" + from app.models.resource_member import MemberStatus, ResourceMember + from app.models.share_link import ResourceType + from app.services.share.external_entity_resolver import ( + get_all_entity_types, + get_entity_resolver, + ) + + entity_members_kb_ids: set[int] = set() + entity_member_group_map: dict[int, list[str]] = {} + entity_member_role_map: dict[int, list[str]] = {} + entity_member_inviter_map: dict[int, set[int]] = {} + entity_member_type_map: dict[int, list[str]] = {} + entity_member_entity_id_map: dict[int, list[str]] = {} + + if accessible_groups: + group_namespaces = ( + db.query(Namespace) + .filter( + Namespace.name.in_(accessible_groups), + Namespace.is_active == True, + ) + .all() + ) + namespace_name_to_id = {ns.name: ns.id for ns in group_namespaces} + + if namespace_name_to_id: + entity_members = ( + db.query(ResourceMember) + .filter( + ResourceMember.resource_type + == ResourceType.KNOWLEDGE_BASE.value, + ResourceMember.entity_type == "namespace", + ResourceMember.entity_id.in_( + [str(ns_id) for ns_id in namespace_name_to_id.values()] + ), + ResourceMember.status == MemberStatus.APPROVED.value, + ) + .all() + ) + + for em in entity_members: + kb_id = em.resource_id + entity_members_kb_ids.add(kb_id) + ns_id = int(em.entity_id) if em.entity_id else 0 + for ns_name, nid in namespace_name_to_id.items(): + if nid == ns_id: + entity_member_group_map.setdefault(kb_id, []).append( + ns_name + ) + break + entity_member_role_map.setdefault(kb_id, []).append( + em.get_effective_role() + ) + entity_member_type_map.setdefault(kb_id, []).append("namespace") + entity_member_entity_id_map.setdefault(kb_id, []).append( + em.entity_id + ) + if em.invited_by_user_id: + entity_member_inviter_map.setdefault(kb_id, set()).add( + em.invited_by_user_id + ) + + for entity_type_str in get_all_entity_types(): + if entity_type_str in ("namespace", "user"): + continue + resolver = get_entity_resolver(entity_type_str) + if not resolver: + continue + resolved_kb_ids = resolver.get_resource_ids_by_entity( + db, user_id, entity_type_str + ) + if not resolved_kb_ids: + continue + entity_type_members = ( + db.query(ResourceMember) + .filter( + ResourceMember.resource_type == ResourceType.KNOWLEDGE_BASE.value, + ResourceMember.entity_type == entity_type_str, + ResourceMember.resource_id.in_(resolved_kb_ids), + ResourceMember.status == MemberStatus.APPROVED.value, + ) + .all() + ) + + # Filter to only entity_ids that actually matched the user + all_entity_ids = [m.entity_id for m in entity_type_members if m.entity_id] + matched_entity_ids = set( + resolver.match_entity_bindings( + db, user_id, entity_type_str, all_entity_ids + ) + ) + + for em in entity_type_members: + if em.entity_id not in matched_entity_ids: + continue + kb_id = em.resource_id + entity_members_kb_ids.add(kb_id) + entity_member_role_map.setdefault(kb_id, []).append( + em.get_effective_role() + ) + entity_member_type_map.setdefault(kb_id, []).append(entity_type_str) + entity_member_entity_id_map.setdefault(kb_id, []).append(em.entity_id) + if em.invited_by_user_id: + entity_member_inviter_map.setdefault(kb_id, set()).add( + em.invited_by_user_id + ) + + entity_shared_to_me_kbs: list[Kind] = [] + entity_personal_kb_ids: set[int] = set() + entity_kbs: list[Kind] = [] + if entity_members_kb_ids: + entity_kbs = ( + db.query(Kind) + .filter( + Kind.kind == "KnowledgeBase", + Kind.is_active == True, + Kind.id.in_(entity_members_kb_ids), + ) + .all() + ) + for ekb in entity_kbs: + if ekb.namespace == "default": + entity_personal_kb_ids.add(ekb.id) + if ekb.user_id == user_id: + continue + entity_shared_to_me_kbs.append(ekb) + + shared_into_group_kbs: list[Kind] = [] + for ekb in entity_kbs: + target_groups = entity_member_group_map.get(ekb.id, []) + for target_group in target_groups: + if target_group not in accessible_groups: + continue + shared_into_group_kbs.append(ekb) + break + + # Exclude KBs already in shared_into_group_kbs from entity_shared_to_me_kbs + shared_into_group_ids = {kb.id for kb in shared_into_group_kbs} + entity_shared_to_me_kbs = [ + kb for kb in entity_shared_to_me_kbs if kb.id not in shared_into_group_ids + ] + + return KnowledgeService.EntityAuthorizedKbsResult( + entity_kbs=entity_kbs, + entity_personal_kb_ids=entity_personal_kb_ids, + entity_shared_to_me_kbs=entity_shared_to_me_kbs, + shared_into_group_kbs=shared_into_group_kbs, + member_group_map=entity_member_group_map, + member_role_map=entity_member_role_map, + member_inviter_map=entity_member_inviter_map, + member_type_map=entity_member_type_map, + member_entity_id_map=entity_member_entity_id_map, + ) + + @staticmethod + def _batch_fetch_kb_metadata( + db: Session, + personal_created: list[Kind], + personal_shared: list[Kind], + entity_shared_to_me_kbs: list[Kind], + group_kbs: list[Kind], + shared_into_group_kbs: list[Kind], + org_kbs: list[Kind], + shared_kb_inviter_map: dict[int, int], + entity_member_inviter_map: dict[int, set[int]], + remaining_shared_group_kbs: list[Kind], + accessible_groups: list[str], + shared_group_names: list[str], + ) -> tuple[dict[int, int], dict[int, str], dict[str, str], dict[int, str]]: + """Batch-fetch document counts, owner names, namespace names, inviter names.""" + all_kb_ids = list( + dict.fromkeys( + [kb.id for kb in personal_created] + + [kb.id for kb in personal_shared] + + [kb.id for kb in entity_shared_to_me_kbs] + + [kb.id for kb in group_kbs] + + [kb.id for kb in shared_into_group_kbs] + + [kb.id for kb in org_kbs] + + [kb.id for kb in remaining_shared_group_kbs] + ) + ) + document_counts = KnowledgeService.get_active_document_counts(db, all_kb_ids) + + owner_user_ids: set[int] = set() + for kb in personal_created: + owner_user_ids.add(kb.user_id) + for kb in personal_shared: + owner_user_ids.add(kb.user_id) + for kb in entity_shared_to_me_kbs: + owner_user_ids.add(kb.user_id) + for kb in group_kbs: + owner_user_ids.add(kb.user_id) + for kb in shared_into_group_kbs: + owner_user_ids.add(kb.user_id) + for kb in org_kbs: + owner_user_ids.add(kb.user_id) + for kb in remaining_shared_group_kbs: + owner_user_ids.add(kb.user_id) + + owner_user_map: dict[int, str] = {} + if owner_user_ids: + users = ( + db.query(User.id, User.user_name) + .filter(User.id.in_(list(owner_user_ids))) + .all() + ) + owner_user_map = {u.id: u.user_name for u in users} + + namespace_display_names = {} + display_name_namespace_names = list( + dict.fromkeys(accessible_groups + shared_group_names) + ) + if display_name_namespace_names: + namespaces = ( + db.query(Namespace) + .filter( + Namespace.name.in_(display_name_namespace_names), + Namespace.is_active == True, + ) + .all() + ) + namespace_display_names = {ns.name: ns.display_name for ns in namespaces} + + all_inviter_ids: set[int] = set() + all_inviter_ids.update(shared_kb_inviter_map.values()) + for inviter_set in entity_member_inviter_map.values(): + all_inviter_ids.update(inviter_set) + for kb in remaining_shared_group_kbs: + inviter_id = shared_kb_inviter_map.get(kb.id) + if inviter_id: + all_inviter_ids.add(inviter_id) + + inviter_user_map: dict[int, str] = {} + if all_inviter_ids: + users = ( + db.query(User.id, User.user_name) + .filter(User.id.in_(list(all_inviter_ids))) + .all() + ) + inviter_user_map = {u.id: u.user_name for u in users} + + return ( + document_counts, + owner_user_map, + namespace_display_names, + inviter_user_map, + ) + + @staticmethod + def _build_shared_with_me( + personal_shared: list[Kind], + remaining_shared_group_kbs: list[Kind], + entity_shared_to_me_kbs: list[Kind], + shared_kb_roles: dict[int, str], + shared_kb_inviter_map: dict[int, int], + entity_member_role_map: dict[int, list[str]], + entity_member_inviter_map: dict[int, set[int]], + entity_member_type_map: dict[int, list[str]], + entity_member_group_map: dict[int, list[str]], + entity_member_entity_id_map: dict[int, list[str]], + namespace_display_names: dict[str, str], + inviter_user_map: dict[int, str], + document_counts: dict[int, int], + owner_user_map: dict[int, str], + db: Session, + ) -> list[KnowledgeBaseWithGroupInfo]: + """Aggregate multi-source permission info into shared_with_me responses.""" + from dataclasses import dataclass, field + + @dataclass + class KbSourceInfo: + kb: Kind + roles: list[str | None] = field(default_factory=list) + inviter_ids: set[int] = field(default_factory=set) + source_groups: set[str] = field(default_factory=set) + shared_vias: set[str] = field(default_factory=set) + + kb_sources: dict[int, KbSourceInfo] = {} + + def _ensure_source(kb: Kind) -> KbSourceInfo: + if kb.id not in kb_sources: + kb_sources[kb.id] = KbSourceInfo(kb=kb) + return kb_sources[kb.id] + + for kb in personal_shared: + info = _ensure_source(kb) + info.roles.append(shared_kb_roles.get(kb.id)) + inviter_id = shared_kb_inviter_map.get(kb.id) + if inviter_id: + info.inviter_ids.add(inviter_id) + info.shared_vias.add("user") + + for kb in remaining_shared_group_kbs: + info = _ensure_source(kb) + info.roles.append(shared_kb_roles.get(kb.id)) + inviter_id = shared_kb_inviter_map.get(kb.id) + if inviter_id: + info.inviter_ids.add(inviter_id) + info.source_groups.add( + namespace_display_names.get(kb.namespace, kb.namespace) + ) + info.shared_vias.add("user") + + for ekb in entity_shared_to_me_kbs: + info = _ensure_source(ekb) + for role in entity_member_role_map.get(ekb.id, []): + info.roles.append(role) + direct_role = shared_kb_roles.get(ekb.id) + info.roles.append(direct_role) + + for inviter_id in entity_member_inviter_map.get(ekb.id, set()): + info.inviter_ids.add(inviter_id) + + type_list = entity_member_type_map.get(ekb.id, []) + entity_id_list = entity_member_entity_id_map.get(ekb.id, []) + group_list = entity_member_group_map.get(ekb.id, []) + group_idx = 0 + for i, entity_type_str in enumerate(type_list): + if entity_type_str == "namespace": + if group_idx < len(group_list): + source_group_name = group_list[group_idx] + if source_group_name: + info.source_groups.add( + namespace_display_names.get( + source_group_name, source_group_name + ) + ) + info.shared_vias.add("namespace") + group_idx += 1 + elif entity_type_str: + entity_id = entity_id_list[i] if i < len(entity_id_list) else None + if entity_id: + from app.services.share import knowledge_share_service + + display_name = knowledge_share_service._get_entity_display_name( + db, entity_type_str, entity_id + ) + if display_name: + info.source_groups.add(display_name) + info.shared_vias.add(entity_type_str) + + shared_with_me = [] + for info in kb_sources.values(): + merged_role = KnowledgeService._merge_roles(*info.roles) + + sorted_inviter_ids = sorted(info.inviter_ids) + sorted_source_groups = sorted(info.source_groups) + sorted_shared_vias = sorted(info.shared_vias) + + source_names: list[str] = [] + for uid in sorted_inviter_ids: + name = inviter_user_map.get(uid) + if name and name not in source_names: + source_names.append(name) + for sg in sorted_source_groups: + if sg and sg not in source_names: + source_names.append(sg) + + primary_inviter = sorted_inviter_ids[0] if sorted_inviter_ids else None + shared_from = ( + inviter_user_map.get(primary_inviter) if primary_inviter else None + ) + primary_source_group = ( + sorted_source_groups[0] if sorted_source_groups else None + ) + primary_via = sorted_shared_vias[0] if sorted_shared_vias else None + + shared_with_me.append( + KnowledgeService._kb_to_response( + info.kb, + "default", + "personal-shared", + "personal-shared", + document_counts, + owner_user_map, + merged_role, + primary_source_group, + shared_from, + primary_via, + source_names if len(source_names) > 1 else None, + ) + ) + + return shared_with_me + + @staticmethod + def _build_groups_section( + grouped_namespace_names: list[str], + groups_map: dict[str, list[Kind]], + group_kbs: list[Kind], + shared_into_group_kbs: list[Kind], + entity_member_group_map: dict[int, list[str]], + entity_member_role_map: dict[int, list[str]], + entity_member_inviter_map: dict[int, set[int]], + shared_kb_roles: dict[int, str], + group_roles: dict[str, str], + namespace_display_names: dict[str, str], + owner_user_map: dict[int, str], + inviter_user_map: dict[int, str], + document_counts: dict[int, int], + ) -> list[AllGroupedTeamGroup]: + """Build groups section from native and shared-into-group KBs.""" + groups = [] + for ns_name in grouped_namespace_names: + display_name = namespace_display_names.get(ns_name, ns_name) + kbs = groups_map.get(ns_name, []) + user_group_role = group_roles.get(ns_name) + + kb_responses = [] + seen_kb_ids = set() + for kb in kbs: + if kb.id in seen_kb_ids: + continue + seen_kb_ids.add(kb.id) + + is_shared_into_group = ( + kb.id in entity_member_group_map + and ns_name in entity_member_group_map.get(kb.id, []) + and kb.namespace != ns_name + ) + if is_shared_into_group: + shared_roles = entity_member_role_map.get(kb.id, []) + merged_shared_role = ( + KnowledgeService._merge_roles(*shared_roles) + if shared_roles + else None + ) + direct_role = shared_kb_roles.get(kb.id) + merged_role = KnowledgeService._merge_roles( + merged_shared_role, direct_role + ) + shared_from_name = None + for inviter_id in entity_member_inviter_map.get(kb.id, set()): + name = inviter_user_map.get(inviter_id) + if name: + shared_from_name = name + break + kb_responses.append( + KnowledgeService._kb_to_response( + kb, + ns_name, + display_name or ns_name, + "group", + document_counts, + owner_user_map, + merged_role, + shared_from=shared_from_name, + shared_via="namespace", + ) + ) + else: + kb_responses.append( + KnowledgeService._kb_to_response( + kb, + ns_name, + display_name or ns_name, + "group", + document_counts, + owner_user_map, + KnowledgeService._merge_roles( + shared_kb_roles.get(kb.id), user_group_role + ), + ) + ) + + groups.append( + AllGroupedTeamGroup( + group_name=ns_name, + group_display_name=display_name or ns_name, + kb_count=len(kb_responses), + knowledge_bases=kb_responses, + ) + ) + + return groups + @staticmethod def get_all_knowledge_bases_grouped( db: Session, @@ -1791,8 +2425,6 @@ def get_all_knowledge_bases_grouped( db: Database session user_id: Current user ID - Returns: - AllGroupedKnowledgeResponse with personal, groups, organization, and summary sections Returns: AllGroupedKnowledgeResponse with personal, groups, organization, and summary sections """ @@ -1814,19 +2446,26 @@ def get_all_knowledge_bases_grouped( .all() ) - # 2. Get shared knowledge bases with their roles (single query) + # 2. Get shared knowledge bases with their roles and inviters (single query) shared_members = ( - db.query(ResourceMember.resource_id, ResourceMember.role) + db.query( + ResourceMember.resource_id, + ResourceMember.role, + ResourceMember.invited_by_user_id, + ) .filter( ResourceMember.resource_type == ResourceType.KNOWLEDGE_BASE.value, - ResourceMember.user_id == user_id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), ResourceMember.status == MemberStatus.APPROVED.value, ) .all() ) shared_kb_ids = [p[0] for p in shared_members] - # Build a map from kb_id to role for shared KBs shared_kb_roles: dict[int, str] = {p[0]: p[1] for p in shared_members} + shared_kb_inviter_map: dict[int, int] = { + p[0]: p[2] for p in shared_members if p[2] + } shared_kbs: list[Kind] = [] if shared_kb_ids: @@ -1874,7 +2513,6 @@ def get_all_knowledge_bases_grouped( accessible_groups_with_roles = get_user_groups_with_roles(db, user_id) accessible_group_names = [g[0] for g in accessible_groups_with_roles] - # Build a map from group_name to role group_roles: dict[str, str] = {g[0]: g[1] for g in accessible_groups_with_roles} accessible_group_namespaces = {} @@ -1899,9 +2537,7 @@ def get_all_knowledge_bases_grouped( shared_group_names = list( dict.fromkeys(kb.namespace for kb in shared_group_kbs) ) - grouped_namespace_names = list( - dict.fromkeys(accessible_groups + shared_group_names) - ) + grouped_namespace_names = list(dict.fromkeys(accessible_groups)) # 4. Get ALL group knowledge bases in ONE query (key optimization) group_kbs: list[Kind] = [] @@ -1917,9 +2553,30 @@ def get_all_knowledge_bases_grouped( .all() ) group_kb_map = {kb.id: kb for kb in group_kbs} + + remaining_shared_group_kbs: list[Kind] = [] for kb in shared_group_kbs: - group_kb_map[kb.id] = kb + if kb.namespace in accessible_groups: + group_kb_map[kb.id] = kb + else: + remaining_shared_group_kbs.append(kb) + + # 4b. Collect entity-authorized KBs via external resolver + entity_result = KnowledgeService._collect_entity_authorized_kbs( + db, user_id, accessible_groups + ) + entity_kbs = entity_result.entity_kbs + entity_personal_kb_ids = entity_result.entity_personal_kb_ids + entity_shared_to_me_kbs = entity_result.entity_shared_to_me_kbs + shared_into_group_kbs = entity_result.shared_into_group_kbs + entity_member_group_map = entity_result.member_group_map + entity_member_role_map = entity_result.member_role_map + entity_member_inviter_map = entity_result.member_inviter_map + entity_member_type_map = entity_result.member_type_map + entity_member_entity_id_map = entity_result.member_entity_id_map + group_kbs = list(group_kb_map.values()) + group_kbs = [kb for kb in group_kbs if kb.id not in entity_personal_kb_ids] # 5. Get organization knowledge bases (single query) org_kbs = ( @@ -1938,7 +2595,7 @@ def get_all_knowledge_bases_grouped( organization_namespaces = ( db.query(Namespace) .filter( - Namespace.level == GroupLevel.organization.value, + Namespace.name.in_({kb.namespace for kb in org_kbs}), Namespace.is_active == True, ) .all() @@ -1948,140 +2605,113 @@ def get_all_knowledge_bases_grouped( } org_namespace = organization_namespaces[0] if organization_namespaces else None - # 6. Batch fetch document counts (single query) - all_kb_ids = list( - dict.fromkeys( - [kb.id for kb in personal_created] - + [kb.id for kb in personal_shared] - + [kb.id for kb in group_kbs] - + [kb.id for kb in org_kbs] - ) + # 6. Batch fetch metadata + ( + document_counts, + owner_user_map, + namespace_display_names, + inviter_user_map, + ) = KnowledgeService._batch_fetch_kb_metadata( + db, + personal_created, + personal_shared, + entity_shared_to_me_kbs, + group_kbs, + shared_into_group_kbs, + org_kbs, + shared_kb_inviter_map, + entity_member_inviter_map, + remaining_shared_group_kbs, + accessible_groups, + shared_group_names, ) - document_counts = KnowledgeService.get_active_document_counts(db, all_kb_ids) - - # 7. Batch fetch namespace display names for groups - namespace_display_names = {} - if grouped_namespace_names: - namespaces = ( - db.query(Namespace) - .filter( - Namespace.name.in_(grouped_namespace_names), - Namespace.is_active == True, - ) - .all() - ) - namespace_display_names = {ns.name: ns.display_name for ns in namespaces} - - # Helper function to convert Kind to KnowledgeBaseWithGroupInfo - def kb_to_response( - kb: Kind, - group_id: str, - group_name: str, - group_type: str, - my_role: str | None = None, - ) -> KnowledgeBaseWithGroupInfo: - spec = kb.json.get("spec", {}) - return KnowledgeBaseWithGroupInfo( - id=kb.id, - name=spec.get("name", ""), - description=spec.get("description") or None, - kb_type=spec.get("kbType", "notebook"), - namespace=kb.namespace, - document_count=document_counts.get(kb.id, 0), - updated_at=kb.updated_at, - created_at=kb.created_at, - user_id=kb.user_id, - group_id=group_id, - group_name=group_name, - group_type=group_type, - my_role=my_role, - ) - - def merge_roles(*roles: str | None) -> str | None: - highest: str | None = None - for role in roles: - if role is None: - continue - if highest is None or has_permission(role, highest): - highest = role - return highest - - def get_namespace_view_role(namespace_name: str, namespace_level: str) -> str: - view_role = get_view_role_in_group( - db, - user_id, - namespace_name, - user_role=user.role, - group_level=namespace_level, - ) - return view_role.value if view_role is not None else BaseRole.Reporter.value # Build personal section - # Use stable English identifiers for group_name - frontend handles localization - # For personal created KBs, user is always Owner created_by_me = [ - kb_to_response(kb, "default", "personal", "personal", "Owner") - for kb in personal_created - ] - # For shared KBs, use the role from ResourceMember - shared_with_me = [ - kb_to_response( + KnowledgeService._kb_to_response( kb, "default", - "personal-shared", - "personal-shared", - shared_kb_roles.get(kb.id), + "personal", + "personal", + document_counts, + owner_user_map, + "Owner", ) - for kb in personal_shared + for kb in personal_created ] + + shared_with_me = KnowledgeService._build_shared_with_me( + personal_shared=personal_shared, + remaining_shared_group_kbs=remaining_shared_group_kbs, + entity_shared_to_me_kbs=entity_shared_to_me_kbs, + shared_kb_roles=shared_kb_roles, + shared_kb_inviter_map=shared_kb_inviter_map, + entity_member_role_map=entity_member_role_map, + entity_member_inviter_map=entity_member_inviter_map, + entity_member_type_map=entity_member_type_map, + entity_member_group_map=entity_member_group_map, + entity_member_entity_id_map=entity_member_entity_id_map, + namespace_display_names=namespace_display_names, + inviter_user_map=inviter_user_map, + document_counts=document_counts, + owner_user_map=owner_user_map, + db=db, + ) + # Build groups section - group KBs by namespace in memory groups_map: dict[str, list[Kind]] = {} for kb in group_kbs: groups_map.setdefault(kb.namespace, []).append(kb) - - # Build groups list - include ALL accessible groups, even those without KBs - # For group KBs, use the user's role in that group - groups = [] - for ns_name in grouped_namespace_names: - display_name = namespace_display_names.get(ns_name, ns_name) - kbs = groups_map.get(ns_name, []) - user_group_role = group_roles.get(ns_name) - groups.append( - AllGroupedTeamGroup( - group_name=ns_name, - group_display_name=display_name or ns_name, - kb_count=len(kbs), - knowledge_bases=[ - kb_to_response( - kb, - ns_name, - display_name or ns_name, - "group", - merge_roles(shared_kb_roles.get(kb.id), user_group_role), - ) - for kb in kbs - ], - ) - ) + for kb in shared_into_group_kbs: + target_groups = entity_member_group_map.get(kb.id, []) + for target_group in target_groups: + groups_map.setdefault(target_group, []).append(kb) + + groups = KnowledgeService._build_groups_section( + grouped_namespace_names=grouped_namespace_names, + groups_map=groups_map, + group_kbs=group_kbs, + shared_into_group_kbs=shared_into_group_kbs, + entity_member_group_map=entity_member_group_map, + entity_member_role_map=entity_member_role_map, + entity_member_inviter_map=entity_member_inviter_map, + shared_kb_roles=shared_kb_roles, + group_roles=group_roles, + namespace_display_names=namespace_display_names, + owner_user_map=owner_user_map, + inviter_user_map=inviter_user_map, + document_counts=document_counts, + ) # Build organization section - # Use stable English identifier for fallback - frontend handles localization - # For organization KBs, use the user's role in the organization namespace org_display_name = ( org_namespace.display_name if org_namespace else "organization" ) org_ns_name = org_namespace.name if org_namespace else None + + def get_namespace_view_role(namespace_name: str, namespace_level: str) -> str: + view_role = get_view_role_in_group( + db, + user_id, + namespace_name, + user_role=user.role, + group_level=namespace_level, + ) + return view_role.value if view_role is not None else BaseRole.Reporter.value + organization = AllGroupedOrganization( namespace=org_ns_name, display_name=org_display_name, kb_count=len(org_kbs), knowledge_bases=[ - kb_to_response( + KnowledgeService._kb_to_response( kb, org_ns_name or "organization", org_display_name, "organization", - merge_roles( + document_counts, + owner_user_map, + KnowledgeService._merge_roles( shared_kb_roles.get(kb.id), get_namespace_view_role( kb.namespace, @@ -2094,13 +2724,26 @@ def get_namespace_view_role(namespace_name: str, namespace_level: str) -> str: ) # Build summary + grouped_kb_ids = {kb.id for kb in group_kbs} | { + kb.id for kb in shared_into_group_kbs + } + + all_kb_ids = ( + {kb.id for kb in personal_created} + | {kb.id for kb in personal_shared} + | {kb.id for kb in entity_shared_to_me_kbs} + | grouped_kb_ids + | {kb.id for kb in org_kbs} + | {kb.id for kb in remaining_shared_group_kbs} + ) + summary = AllGroupedSummary( - total_count=len(personal_created) + total_count=len(all_kb_ids), + personal_count=len(personal_created) + len(personal_shared) - + len(group_kbs) - + len(org_kbs), - personal_count=len(personal_created) + len(personal_shared), - group_count=len(group_kbs), + + len(entity_shared_to_me_kbs) + + len(remaining_shared_group_kbs), + group_count=len(grouped_kb_ids), organization_count=len(org_kbs), ) diff --git a/backend/app/services/knowledge/task_knowledge_base_service.py b/backend/app/services/knowledge/task_knowledge_base_service.py index ec657fdd419..0e4a3535e6d 100644 --- a/backend/app/services/knowledge/task_knowledge_base_service.py +++ b/backend/app/services/knowledge/task_knowledge_base_service.py @@ -174,7 +174,8 @@ def _is_kb_bound_to_user_group_chat( TaskResource.kind == "Task", TaskResource.is_active == TaskResource.STATE_ACTIVE, ResourceMember.resource_type == ResourceType.TASK, - ResourceMember.user_id == user_id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), ResourceMember.status == MemberStatus.APPROVED, ) .all() diff --git a/backend/app/services/readers/group_members.py b/backend/app/services/readers/group_members.py index 9b2eb1e4400..bf25b523cce 100644 --- a/backend/app/services/readers/group_members.py +++ b/backend/app/services/readers/group_members.py @@ -97,7 +97,8 @@ def get_user_groups(self, db: Session, user_id: int) -> List[str]: db.query(ResourceMember) .filter( ResourceMember.resource_type == NAMESPACE_RESOURCE_TYPE, - ResourceMember.user_id == user_id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), ResourceMember.status == MemberStatus.APPROVED.value, ) .all() diff --git a/backend/app/services/readers/shared_teams.py b/backend/app/services/readers/shared_teams.py index e5cffdaf037..9a206b11233 100644 --- a/backend/app/services/readers/shared_teams.py +++ b/backend/app/services/readers/shared_teams.py @@ -74,7 +74,8 @@ def is_shared_to_user(self, db: Session, team_id: int, user_id: int) -> bool: .filter( ResourceMember.resource_type == ResourceType.TEAM, ResourceMember.resource_id == team_id, - ResourceMember.user_id == user_id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), ResourceMember.status == MemberStatus.APPROVED, ) .first() @@ -86,7 +87,8 @@ def get_shared_team_ids(self, db: Session, user_id: int) -> List[int]: db.query(ResourceMember.resource_id) .filter( ResourceMember.resource_type == ResourceType.TEAM, - ResourceMember.user_id == user_id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), ResourceMember.status == MemberStatus.APPROVED, ) .all() @@ -101,7 +103,8 @@ def get_by_team_and_user( .filter( ResourceMember.resource_type == ResourceType.TEAM, ResourceMember.resource_id == team_id, - ResourceMember.user_id == user_id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), ResourceMember.status == MemberStatus.APPROVED, ) .first() diff --git a/backend/app/services/share/__init__.py b/backend/app/services/share/__init__.py index ab3730d4857..fd68274529f 100644 --- a/backend/app/services/share/__init__.py +++ b/backend/app/services/share/__init__.py @@ -9,11 +9,16 @@ """ from app.services.share.base_service import UnifiedShareService +from app.services.share.external_entity_resolver import register_entity_resolver from app.services.share.knowledge_share_service import ( KnowledgeShareService, get_knowledge_base_tool_access_mode_by_ids, knowledge_share_service, ) +from app.services.share.namespace_entity_resolver import ( + NamespaceEntityResolver, + namespace_entity_resolver, +) from app.services.share.share_webhook import ( send_share_request_notification, send_share_review_notification, @@ -21,6 +26,9 @@ from app.services.share.task_share_service import TaskShareService, task_share_service from app.services.share.team_share_service import TeamShareService, team_share_service +# Register the namespace entity resolver for open-source group-level permissions +register_entity_resolver("namespace", NamespaceEntityResolver) + __all__ = [ "UnifiedShareService", "TeamShareService", diff --git a/backend/app/services/share/base_service.py b/backend/app/services/share/base_service.py index ede0438dac3..6b31e321482 100644 --- a/backend/app/services/share/base_service.py +++ b/backend/app/services/share/base_service.py @@ -23,10 +23,11 @@ from sqlalchemy.orm import Session from app.core.config import settings +from app.models.namespace import Namespace from app.models.resource_member import MemberStatus, ResourceMember, ResourceRole from app.models.share_link import ResourceType, ShareLink from app.models.user import User -from app.schemas.base_role import BaseRole, has_permission +from app.schemas.base_role import BaseRole, get_highest_role, has_permission from app.schemas.share import ( BatchResourceMemberResponse, FailedMemberResponse, @@ -35,14 +36,17 @@ ) from app.schemas.share import MemberStatus as SchemaMemberStatus from app.schemas.share import ( + MyPermissionSourcesResponse, PendingRequestListResponse, PendingRequestResponse, + PermissionSourceInfo, ResourceMemberResponse, ReviewRequestResponse, ShareInfoResponse, ShareLinkConfig, ShareLinkResponse, ) +from app.services.share.external_entity_resolver import get_entity_resolver from shared.telemetry.decorators import trace_sync # SchemaMemberRole is an alias to BaseRole for backward compatibility @@ -76,6 +80,22 @@ def __init__(self, resource_type: ResourceType): self.aes_key = settings.SHARE_TOKEN_AES_KEY.encode("utf-8") self.aes_iv = settings.SHARE_TOKEN_AES_IV.encode("utf-8") + @property + def _resource_type_variants(self) -> list[str]: + """Return resource_type values to query, handling legacy formats.""" + variants = [self.resource_type.value] + if self.resource_type.value == "KnowledgeBase": + variants.append("KNOWLEDGE_BASE") + return variants + + @property + def _approved_status_variants(self) -> list[str]: + """Return approved status values to query, handling legacy case formats.""" + return [ + MemberStatus.APPROVED.value, + MemberStatus.APPROVED.value.upper(), + ] + # ========================================================================= # Abstract methods (must be implemented by subclasses) # ========================================================================= @@ -280,11 +300,9 @@ def create_share_link( # Check for existing active share link # Note: Database may store resource_type in different formats (e.g., "KNOWLEDGE_BASE" vs "KnowledgeBase") # We need to check both formats to handle legacy data - resource_type_variants = [self.resource_type.value] + resource_type_variants = self._resource_type_variants - # Add underscore variant for KnowledgeBase -> KNOWLEDGE_BASE - if self.resource_type.value == "KnowledgeBase": - resource_type_variants.append("KNOWLEDGE_BASE") + # _resource_type_variants already handles KnowledgeBase -> KNOWLEDGE_BASE existing_link = ( db.query(ShareLink) @@ -299,8 +317,7 @@ def create_share_link( logger.info( f"[create_share_link] Existing link check: " f"found={existing_link is not None}, " - f"existing_link_id={existing_link.id if existing_link else None}, " - f"existing_token={existing_link.share_token[:50] + '...' if existing_link and existing_link.share_token else None}" + f"existing_link_id={existing_link.id if existing_link else None}" ) if existing_link: @@ -405,9 +422,7 @@ def get_share_link( raise HTTPException(status_code=404, detail="Resource not found") # Note: Database may store resource_type in different formats (e.g., "KNOWLEDGE_BASE" vs "KnowledgeBase") - resource_type_variants = [self.resource_type.value] - if self.resource_type.value == "KnowledgeBase": - resource_type_variants.append("KNOWLEDGE_BASE") + resource_type_variants = self._resource_type_variants share_link = ( db.query(ShareLink) @@ -438,9 +453,7 @@ def delete_share_link(self, db: Session, resource_id: int, user_id: int) -> bool ) # Note: Database may store resource_type in different formats - resource_type_variants = [self.resource_type.value] - if self.resource_type.value == "KnowledgeBase": - resource_type_variants.append("KNOWLEDGE_BASE") + resource_type_variants = self._resource_type_variants share_link = ( db.query(ShareLink) @@ -502,9 +515,7 @@ def get_share_info(self, db: Session, share_token: str) -> ShareInfoResponse: # Find share link by resource_id (not by token, since token encoding may vary) # Note: Database may store resource_type in different formats - resource_type_variants = [self.resource_type.value] - if self.resource_type.value == "KnowledgeBase": - resource_type_variants.append("KNOWLEDGE_BASE") + resource_type_variants = self._resource_type_variants share_link = ( db.query(ShareLink) @@ -583,9 +594,7 @@ def join_by_link( # Find share link by resource_id (not by token, since token encoding may vary) # Note: Database may store resource_type in different formats - resource_type_variants = [self.resource_type.value] - if self.resource_type.value == "KnowledgeBase": - resource_type_variants.append("KNOWLEDGE_BASE") + resource_type_variants = self._resource_type_variants share_link = ( db.query(ShareLink) @@ -618,7 +627,8 @@ def join_by_link( .filter( ResourceMember.resource_type.in_(resource_type_variants), ResourceMember.resource_id == resource_id, - ResourceMember.user_id == user_id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), ) .first() ) @@ -689,18 +699,18 @@ def join_by_link( # Create member record # For PENDING status, use 0 as placeholder for reviewed_by_user_id # For APPROVED (auto-approved), use owner_id - member = ResourceMember( + member = ResourceMember.create( resource_type=self.resource_type.value, resource_id=resource_id, - user_id=user_id, + entity_type="user", + entity_id=str(user_id), + role=role, status=initial_status, invited_by_user_id=0, # Via link share_link_id=share_link.id, reviewed_by_user_id=0 if is_pending else owner_id, reviewed_at=datetime.utcnow(), - requested_at=datetime.utcnow(), ) - member.set_role(role) db.add(member) @@ -743,17 +753,8 @@ def get_members( # Note: Database may store resource_type in different formats (e.g., "KNOWLEDGE_BASE" vs "KnowledgeBase") # We need to query both formats to handle legacy data - resource_type_variants = [self.resource_type.value] - - # Add underscore variant for KnowledgeBase -> KNOWLEDGE_BASE - if self.resource_type.value == "KnowledgeBase": - resource_type_variants.append("KNOWLEDGE_BASE") - - # Note: Database may store status in different formats (e.g., "APPROVED" vs "approved") - approved_status_variants = [ - MemberStatus.APPROVED.value, - MemberStatus.APPROVED.value.upper(), - ] + resource_type_variants = self._resource_type_variants + approved_status_variants = self._approved_status_variants all_members = ( db.query(ResourceMember) @@ -765,15 +766,21 @@ def get_members( .all() ) - # Deduplicate members by user_id (keep the first one found for each user) - # This handles legacy data where the same user may have multiple records - # with different resource_type formats (e.g., "KNOWLEDGE_BASE" vs "KnowledgeBase") - seen_user_ids = set() - members = [] + # Deduplicate members by user_id (for user-type) or (entity_type, entity_id) + # This handles legacy data where the same user may have multiple records. + # Approved records take precedence over pending ones. + member_by_key: dict = {} for member in all_members: - if member.user_id not in seen_user_ids: - seen_user_ids.add(member.user_id) - members.append(member) + if member.entity_type and member.entity_type != "user" and member.entity_id: + key = (member.entity_type, member.entity_id) + else: + key = member.user_id + + existing = member_by_key.get(key) + if not existing or member.status == MemberStatus.APPROVED.value: + member_by_key[key] = member + + members = list(member_by_key.values()) # LOGGING: Log query parameters and results to verify resource_type matching logger.info( @@ -789,6 +796,42 @@ def get_members( f"resource_id={member.resource_id}, user_id={member.user_id}" ) + # Batch-preload namespace display names for entity-type members + namespace_ids = set() + # Collect external entity IDs by type for batch resolution + external_entity_ids: dict[str, set[str]] = {} + for m in members: + if m.entity_type and m.entity_type != "user" and m.entity_id: + if m.entity_type == "namespace": + try: + namespace_ids.add(int(m.entity_id)) + except (ValueError, TypeError): + pass + else: + external_entity_ids.setdefault(m.entity_type, set()).add( + m.entity_id + ) + + namespace_map = {} + if namespace_ids: + namespaces = ( + db.query(Namespace.id, Namespace.display_name, Namespace.name) + .filter(Namespace.id.in_(namespace_ids)) + .all() + ) + namespace_map = {ns.id: (ns.display_name or ns.name) for ns in namespaces} + + # Batch resolve external entity display names + external_display_names: dict[str, dict[str, str]] = {} + if external_entity_ids: + for entity_type, entity_ids in external_entity_ids.items(): + resolver = get_entity_resolver(entity_type) + if not resolver: + continue + external_display_names[entity_type] = resolver.batch_get_display_names( + db, list(entity_ids) + ) + # Populate user names user_ids = set() for m in members: @@ -801,7 +844,16 @@ def get_members( users = db.query(User).filter(User.id.in_(user_ids)).all() user_map = {u.id: u for u in users} - member_responses = [self._member_to_response(m, user_map) for m in members] + member_responses = [ + self._member_to_response( + m, + user_map, + db=db, + namespace_map=namespace_map, + external_display_names=external_display_names, + ) + for m in members + ] return MemberListResponse(members=member_responses, total=len(member_responses)) @@ -812,6 +864,9 @@ def add_member( current_user_id: int, target_user_id: int, role: SchemaMemberRole, + entity_type: Optional[str] = None, + entity_id: Optional[str] = None, + entity_display_name: Optional[str] = None, ) -> ResourceMemberResponse: """Directly add a member to a resource.""" # Validate resource and ownership/manage permission @@ -828,37 +883,71 @@ def add_member( raise HTTPException(status_code=403, detail="No permission to add members") # Cannot add self - if target_user_id == current_user_id: + if target_user_id == current_user_id and not entity_type: raise HTTPException(status_code=400, detail="Cannot add yourself") - # Check target user exists - target_user = ( - db.query(User) - .filter(User.id == target_user_id, User.is_active.is_(True)) - .first() - ) - if not target_user: - raise HTTPException(status_code=404, detail="Target user not found") + # Determine effective entity type + eff_entity_type = entity_type if entity_type else "user" + eff_entity_id = entity_id if entity_id else str(target_user_id) + + # Validate entity_type if non-user and non-built-in + # Built-in types: 'user' (default) and 'namespace' (handled natively) + if eff_entity_type not in ("user", "namespace") and not get_entity_resolver( + eff_entity_type + ): + raise HTTPException( + status_code=400, detail=f"Unknown entity type: {eff_entity_type}" + ) + + # Suppress display-name snapshots for reliably resolvable entity types. + # User and namespace names can always be resolved from local DB tables; + # only external/unreliable types (registered via register_entity_resolver) + # need snapshots. + needs_snapshot = True + if eff_entity_type == "user": + needs_snapshot = False + else: + resolver = get_entity_resolver(eff_entity_type) + if resolver and not resolver.requires_display_name_snapshot: + needs_snapshot = False + if not needs_snapshot: + entity_display_name = None + elif not entity_display_name: + # Auto-fill snapshot for external types that require it + resolver = get_entity_resolver(eff_entity_type) + if resolver: + entity_display_name = resolver.get_display_name(db, eff_entity_id) + + # Check target user exists (only for user-type members) + if eff_entity_type == "user": + target_user = ( + db.query(User) + .filter(User.id == target_user_id, User.is_active.is_(True)) + .first() + ) + if not target_user: + raise HTTPException(status_code=404, detail="Target user not found") + else: + target_user = None # LOGGING: Log user information to verify email is available in User model - logger.info( - f"[add_member] Target user found: user_id={target_user_id}, " - f"user_name={target_user.user_name}, email={target_user.email}" - ) + if target_user: + logger.info( + f"[add_member] Target user found: user_id={target_user_id}, " + f"user_name={target_user.user_name}" + ) - # Check for existing member + # Check for existing member by entity # Note: Database may store resource_type in different formats (e.g., "KNOWLEDGE_BASE" vs "KnowledgeBase") - # We need to check both formats to handle legacy data and prevent duplicate records - resource_type_variants = [self.resource_type.value] - if self.resource_type.value == "KnowledgeBase": - resource_type_variants.append("KNOWLEDGE_BASE") + resource_type_variants = self._resource_type_variants existing = ( db.query(ResourceMember) .filter( ResourceMember.resource_type.in_(resource_type_variants), ResourceMember.resource_id == resource_id, - ResourceMember.user_id == target_user_id, + ResourceMember.entity_type == eff_entity_type, + ResourceMember.entity_id == eff_entity_id, ) .first() ) @@ -871,9 +960,7 @@ def add_member( # Ensure share_link_id is set (required by database) if not existing.share_link_id: # Note: Database may store resource_type in different formats - resource_type_variants = [self.resource_type.value] - if self.resource_type.value == "KnowledgeBase": - resource_type_variants.append("KNOWLEDGE_BASE") + resource_type_variants = self._resource_type_variants share_link = ( db.query(ShareLink) @@ -909,14 +996,14 @@ def add_member( existing.reviewed_by_user_id = current_user_id existing.reviewed_at = datetime.utcnow() existing.updated_at = datetime.utcnow() + if entity_display_name is not None: + existing.entity_display_name = entity_display_name member = existing else: # Get or create a share link for direct member addition # This is needed because share_link_id may be required in the database # Note: Database may store resource_type in different formats - resource_type_variants = [self.resource_type.value] - if self.resource_type.value == "KnowledgeBase": - resource_type_variants.append("KNOWLEDGE_BASE") + resource_type_variants = self._resource_type_variants share_link = ( db.query(ShareLink) @@ -945,18 +1032,19 @@ def add_member( db.flush() # Get the share_link.id # Create new member with approved status - member = ResourceMember( + member = ResourceMember.create( resource_type=self.resource_type.value, resource_id=resource_id, - user_id=target_user_id, + entity_type=eff_entity_type, + entity_id=eff_entity_id, + role=role.value, status=MemberStatus.APPROVED.value, invited_by_user_id=current_user_id, share_link_id=share_link.id, reviewed_by_user_id=current_user_id, reviewed_at=datetime.utcnow(), - requested_at=datetime.utcnow(), + entity_display_name=entity_display_name, ) - member.set_role(role.value) db.add(member) db.commit() @@ -976,7 +1064,7 @@ def add_member( user_map = {u.id: u for u in users} logger.info(f"[add_member] User map created with {len(user_map)} users") - return self._member_to_response(member, user_map) + return self._member_to_response(member, user_map, db=db) @trace_sync( span_name="share.batch_add_members", @@ -992,7 +1080,9 @@ def batch_add_members( db: Session, resource_id: int, current_user_id: int, - members_data: List[Tuple[int, SchemaMemberRole]], + members_data: List[ + Tuple[int, SchemaMemberRole, Optional[str], Optional[str], Optional[str]] + ], ) -> BatchResourceMemberResponse: """Batch add multiple members to a resource in a single transaction. @@ -1000,7 +1090,9 @@ def batch_add_members( db: Database session resource_id: Resource ID current_user_id: Current user performing the action - members_data: List of (target_user_id, role) tuples + members_data: List of (target_user_id, role, entity_type, entity_id, entity_display_name) tuples. + For user-type members: (target_user_id, role, None, None, None) + For entity-type members: (0, role, entity_type, entity_id, entity_display_name) """ # Validate resource and ownership/manage permission once resource = self._get_resource(db, resource_id, current_user_id) @@ -1015,9 +1107,7 @@ def batch_add_members( raise HTTPException(status_code=403, detail="No permission to add members") # Get or create share link once for all members - resource_type_variants = [self.resource_type.value] - if self.resource_type.value == "KnowledgeBase": - resource_type_variants.append("KNOWLEDGE_BASE") + resource_type_variants = self._resource_type_variants share_link = ( db.query(ShareLink) @@ -1043,32 +1133,107 @@ def batch_add_members( db.add(share_link) db.flush() - # Batch-query all target users and existing members - target_user_ids = [uid for uid, _ in members_data] + # Separate user-type and entity-type members + user_member_entries: List[Tuple[int, SchemaMemberRole]] = [] + entity_member_entries: List[ + Tuple[str, str, SchemaMemberRole, Optional[str]] + ] = [] + for entry in members_data: + target_user_id, role, entity_type, entity_id, entity_display_name = entry + if entity_type and entity_type != "user": + eff_entity_type = entity_type + eff_entity_id = entity_id if entity_id else str(target_user_id) + # Validate entity_type if non-user and non-built-in + resolver = get_entity_resolver(eff_entity_type) + if eff_entity_type not in ("user", "namespace") and not resolver: + failed.append( + FailedMemberResponse( + user_id=0, + entity_type=eff_entity_type, + entity_id=eff_entity_id, + error=f"Unknown entity type: {eff_entity_type}", + ) + ) + continue + # Suppress snapshots for reliably resolvable entity types + needs_snapshot = True + if not resolver.requires_display_name_snapshot: + needs_snapshot = False + if not needs_snapshot: + entity_display_name = None + elif not entity_display_name: + # Auto-fill snapshot for external types that require it + entity_display_name = resolver.get_display_name(db, eff_entity_id) + entity_member_entries.append( + (eff_entity_type, eff_entity_id, role, entity_display_name) + ) + else: + user_member_entries.append((target_user_id, role)) + + # Batch-query all target users for user-type members + target_user_ids = [uid for uid, _ in user_member_entries] target_users = ( - db.query(User) - .filter(User.id.in_(target_user_ids), User.is_active.is_(True)) - .all() + ( + db.query(User) + .filter(User.id.in_(target_user_ids), User.is_active.is_(True)) + .all() + ) + if target_user_ids + else [] ) valid_user_map = {u.id: u for u in target_users} - existing_members = ( - db.query(ResourceMember) - .filter( - ResourceMember.resource_type.in_(resource_type_variants), - ResourceMember.resource_id == resource_id, - ResourceMember.user_id.in_(target_user_ids), + # Query existing user-type members + user_existing_members = ( + ( + db.query(ResourceMember) + .filter( + ResourceMember.resource_type.in_(resource_type_variants), + ResourceMember.resource_id == resource_id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id.in_([str(uid) for uid in target_user_ids]), + ) + .all() ) - .all() + if target_user_ids + else [] ) - existing_map = {m.user_id: m for m in existing_members} + user_existing_map = {m.user_id: m for m in user_existing_members} + + # Query existing entity-type members + entity_keys = [(et, eid) for et, eid, _, _ in entity_member_entries] + entity_existing_members = [] + if entity_keys: + from sqlalchemy import and_, or_ + + entity_filters = [ + and_( + ResourceMember.entity_type == et, + ResourceMember.entity_id == eid, + ) + for et, eid in entity_keys + ] + entity_existing_members = ( + db.query(ResourceMember) + .filter( + ResourceMember.resource_type.in_(resource_type_variants), + ResourceMember.resource_id == resource_id, + or_(*entity_filters), + ) + .all() + ) + entity_existing_map = { + (m.entity_type, m.entity_id): m for m in entity_existing_members + } succeeded: List[ResourceMember] = [] failed: List[FailedMemberResponse] = [] - # Track processed IDs to guard against duplicates within members_data + # Track processed entity keys to guard against duplicates within members_data processed_user_ids: set = set() + processed_entity_keys: set = set() - for target_user_id, role in members_data: + # Process user-type members + for target_user_id, role in user_member_entries: # Skip self-add if target_user_id == current_user_id: failed.append( @@ -1097,7 +1262,7 @@ def batch_add_members( ) continue - existing = existing_map.get(target_user_id) + existing = user_existing_map.get(target_user_id) if existing: if existing.status.lower() == MemberStatus.APPROVED.value.lower(): failed.append( @@ -1118,28 +1283,93 @@ def batch_add_members( existing.reviewed_at = datetime.utcnow() existing.updated_at = datetime.utcnow() succeeded.append(existing) - # Mark as processed so later duplicates are caught processed_user_ids.add(target_user_id) else: # Create new member - member = ResourceMember( + member = ResourceMember.create( resource_type=self.resource_type.value, resource_id=resource_id, - user_id=target_user_id, + entity_type="user", + entity_id=str(target_user_id), + role=role.value, status=MemberStatus.APPROVED.value, invited_by_user_id=current_user_id, share_link_id=share_link.id, reviewed_by_user_id=current_user_id, reviewed_at=datetime.utcnow(), - requested_at=datetime.utcnow(), ) - member.set_role(role.value) db.add(member) succeeded.append(member) - # Update both maps so subsequent iterations see the new member - existing_map[target_user_id] = member + user_existing_map[target_user_id] = member processed_user_ids.add(target_user_id) + # Process entity-type members + for ( + entity_type_str, + entity_id_str, + role, + entity_display_name, + ) in entity_member_entries: + entity_key = (entity_type_str, entity_id_str) + + # Skip duplicate entries within the same batch request + if entity_key in processed_entity_keys: + failed.append( + FailedMemberResponse( + user_id=0, + entity_type=entity_type_str, + entity_id=entity_id_str, + error="Duplicate entry in request", + ) + ) + continue + + existing = entity_existing_map.get(entity_key) + if existing: + if existing.status.lower() == MemberStatus.APPROVED.value.lower(): + failed.append( + FailedMemberResponse( + user_id=0, + entity_type=entity_type_str, + entity_id=entity_id_str, + error="Entity already has access", + ) + ) + continue + + # Update existing pending/rejected record + if not existing.share_link_id: + existing.share_link_id = share_link.id + existing.set_role(role.value) + existing.status = MemberStatus.APPROVED.value + existing.invited_by_user_id = current_user_id + existing.reviewed_by_user_id = current_user_id + existing.reviewed_at = datetime.utcnow() + existing.updated_at = datetime.utcnow() + if entity_display_name is not None: + existing.entity_display_name = entity_display_name + succeeded.append(existing) + processed_entity_keys.add(entity_key) + else: + # Create new entity member + member = ResourceMember.create( + resource_type=self.resource_type.value, + resource_id=resource_id, + entity_type=entity_type_str, + entity_id=entity_id_str, + role=role.value, + status=MemberStatus.APPROVED.value, + invited_by_user_id=current_user_id, + share_link_id=share_link.id, + reviewed_by_user_id=current_user_id, + reviewed_at=datetime.utcnow(), + entity_display_name=entity_display_name, + ) + db.add(member) + succeeded.append(member) + entity_existing_map[entity_key] = member + processed_entity_keys.add(entity_key) + db.commit() # Refresh all succeeded members, then call approval hooks and collect @@ -1154,7 +1384,7 @@ def batch_add_members( member.copied_resource_id = copied_resource_id except Exception as exc: logger.error( - "Approval hook failed for member user_id=%s: %s", + "Approval hook failed for member %s: %s", member.user_id, exc, ) @@ -1162,13 +1392,13 @@ def batch_add_members( # Persist all copied_resource_id updates in one commit db.commit() - # Build user map for responses + # Build user map for responses (only for user-type members) all_user_ids = set(target_user_ids) | {current_user_id} all_users = db.query(User).filter(User.id.in_(all_user_ids)).all() user_map = {u.id: u for u in all_users} return BatchResourceMemberResponse( - succeeded=[self._member_to_response(m, user_map) for m in succeeded], + succeeded=[self._member_to_response(m, user_map, db=db) for m in succeeded], failed=failed, ) @@ -1197,9 +1427,7 @@ def update_member( ) # Note: Database may store resource_type in different formats (e.g., "KNOWLEDGE_BASE" vs "KnowledgeBase") - resource_type_variants = [self.resource_type.value] - if self.resource_type.value == "KnowledgeBase": - resource_type_variants.append("KNOWLEDGE_BASE") + resource_type_variants = self._resource_type_variants # Find member member = ( @@ -1231,7 +1459,7 @@ def update_member( users = db.query(User).filter(User.id.in_(user_ids)).all() user_map = {u.id: u for u in users} - return self._member_to_response(member, user_map) + return self._member_to_response(member, user_map, db=db) def remove_member( self, @@ -1257,9 +1485,7 @@ def remove_member( ) # Note: Database may store resource_type in different formats (e.g., "KNOWLEDGE_BASE" vs "KnowledgeBase") - resource_type_variants = [self.resource_type.value] - if self.resource_type.value == "KnowledgeBase": - resource_type_variants.append("KNOWLEDGE_BASE") + resource_type_variants = self._resource_type_variants # Find member member = ( @@ -1275,21 +1501,218 @@ def remove_member( if not member: raise HTTPException(status_code=404, detail="Member not found") + # Prevent non-owners from removing the owner + if member.get_effective_role() == ResourceRole.Owner.value: + if current_user_id != owner_id: + raise HTTPException(status_code=403, detail="Cannot remove the owner") + + # Prevent self-removal via this endpoint (should use leave) + if member.entity_type == "user" and member.entity_id == str(current_user_id): + raise HTTPException( + status_code=400, detail="Use leave instead of removing yourself" + ) + + # Prevent self-demotion: if removing this entity member would cause the + # current user to lose their management permission, reject. Owners are + # always exempt since their role is not revoked by member deletion. + if current_user_id != owner_id and self._remove_would_lose_manage( + db, resource_id, current_user_id, member + ): + raise HTTPException( + status_code=400, + detail=( + "Cannot remove this member: you would lose your own " + "management permission" + ), + ) + # Delete member record db.delete(member) db.commit() return True + def _remove_would_lose_manage( + self, + db: Session, + resource_id: int, + user_id: int, + member_to_remove: ResourceMember, + ) -> bool: + """Check whether removing a member would drop the given user below Maintainer. + + Looks at all remaining approved member records for the resource (excluding + the one being removed) and recomputes the user's effective role: direct + user-type record first, then highest entity-type role matched via resolvers. + Returns True if none of them grant Maintainer or higher. + """ + resource_type_variants = self._resource_type_variants + approved_status_variants = self._approved_status_variants + + # Direct user-type membership still grants manage? + direct_member = ( + db.query(ResourceMember) + .filter( + ResourceMember.resource_type.in_(resource_type_variants), + ResourceMember.resource_id == resource_id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), + ResourceMember.status.in_(approved_status_variants), + ResourceMember.id != member_to_remove.id, + ) + .first() + ) + if direct_member and has_permission( + direct_member.get_effective_role(), + SchemaMemberRole.Maintainer.value, + ): + return False + + # Any remaining entity-type binding that matches the user grants manage? + entity_rows = ( + db.query( + ResourceMember.entity_type, + ResourceMember.entity_id, + ResourceMember.role, + ) + .filter( + ResourceMember.resource_type.in_(resource_type_variants), + ResourceMember.resource_id == resource_id, + ResourceMember.entity_type != "user", + ResourceMember.entity_type != "", + ResourceMember.entity_id.isnot(None), + ResourceMember.status.in_(approved_status_variants), + ResourceMember.id != member_to_remove.id, + ) + .all() + ) + + from collections import defaultdict + + entity_groups: defaultdict[str, list[tuple[str, str]]] = defaultdict(list) + for et, eid, role in entity_rows: + if et and eid: + entity_groups[et].append((eid, role)) + + for entity_type_str, entries in entity_groups.items(): + resolver = get_entity_resolver(entity_type_str) + if not resolver: + continue + eids = [eid for eid, _ in entries] + matched = resolver.match_entity_bindings(db, user_id, entity_type_str, eids) + if not matched: + continue + role_by_eid = {eid: role for eid, role in entries} + for eid in matched: + role = role_by_eid.get(eid) + if role and has_permission(role, SchemaMemberRole.Maintainer.value): + return False + + return True + + def _resolve_source_type(self, member: ResourceMember) -> str: + """ + Determine the source type of a member permission. + + Returns: + 'direct' — user was directly added as a member + 'entity_permission' — group/entity was authorized + 'share_link' — user joined via share link + """ + if member.entity_type and member.entity_type != "user": + return "entity_permission" + if ( + member.share_link_id + and member.share_link_id > 0 + and member.invited_by_user_id == 0 + ): + return "share_link" + return "direct" + + def _get_entity_display_name( + self, + db: Session, + entity_type: str, + entity_id: str, + namespace_map: Optional[dict[int, str]] = None, + external_display_names: Optional[dict[str, dict[str, str]]] = None, + ) -> Optional[str]: + """ + Resolve display name for entity-type members. + + Built-in support for 'namespace' (group) entities with optional + batch-preloaded namespace_map for performance. + Subclasses can override to support additional entity types. + + Args: + db: Database session + entity_type: Entity type (e.g., 'namespace') + entity_id: Entity identifier + namespace_map: Optional preloaded dict of namespace_id -> display_name + external_display_names: Optional preloaded dict of + entity_type -> {entity_id -> display_name} + + Returns: + Display name string or None if not resolvable + """ + if entity_type == "namespace": + try: + ns_id = int(entity_id) + except (ValueError, TypeError): + return None + if namespace_map is not None: + return namespace_map.get(ns_id) + namespace = db.query(Namespace).filter(Namespace.id == ns_id).first() + if namespace: + return namespace.display_name or namespace.name + return None + + if external_display_names is not None: + cached = external_display_names.get(entity_type, {}).get(entity_id) + if cached: + return cached + + # Try external resolvers for display name + resolver = get_entity_resolver(entity_type) + if resolver: + display_name = resolver.get_display_name(db, entity_id) + if display_name: + return display_name + + return None + def _member_to_response( - self, member: ResourceMember, user_map: Dict[int, User] + self, + member: ResourceMember, + user_map: Dict[int, User], + db: Session = None, + namespace_map: Optional[dict[int, str]] = None, + external_display_names: Optional[dict[str, dict[str, str]]] = None, ) -> ResourceMemberResponse: """Convert ResourceMember model to response schema.""" # Get user name and email from map user = user_map.get(member.user_id) - user_name = user.user_name if user else None user_email = user.email if user else None + # Determine unified display_name based on member type + display_name = None + if member.entity_type and member.entity_type != "user": + # Entity-type member — prefer persisted snapshot for unreliable + # types, then live lookup via _get_entity_display_name. + if member.entity_display_name: + display_name = member.entity_display_name + elif member.entity_id and db: + display_name = self._get_entity_display_name( + db, + member.entity_type, + member.entity_id, + namespace_map, + external_display_names, + ) + elif user: + # User-type member — use user name + display_name = user.user_name + # Get invited by user name invited_by_user = user_map.get(member.invited_by_user_id) invited_by_user_name = invited_by_user.user_name if invited_by_user else None @@ -1304,16 +1727,21 @@ def _member_to_response( # Get effective role for response effective_role = member.get_effective_role() + source_type = self._resolve_source_type(member) return ResourceMemberResponse( id=member.id, resource_type=member.resource_type, resource_id=member.resource_id, user_id=member.user_id, - user_name=user_name, + display_name=display_name, user_email=user_email, role=effective_role, status=member.status, + entity_type=member.entity_type, + entity_id=member.entity_id, + entity_display_name=member.entity_display_name, + source_type=source_type, invited_by_user_id=member.invited_by_user_id, invited_by_user_name=invited_by_user_name, reviewed_by_user_id=member.reviewed_by_user_id, @@ -1350,9 +1778,7 @@ def get_pending_requests( # Note: Database may store resource_type in different formats (e.g., "KNOWLEDGE_BASE" vs "KnowledgeBase") # We need to query both formats to handle legacy data - resource_type_variants = [self.resource_type.value] - if self.resource_type.value == "KnowledgeBase": - resource_type_variants.append("KNOWLEDGE_BASE") + resource_type_variants = self._resource_type_variants # Note: Database may store status in different formats (e.g., "PENDING" vs "pending") pending_status_variants = [ @@ -1426,9 +1852,7 @@ def review_request( ) # Note: Database may store resource_type in different formats (e.g., "KNOWLEDGE_BASE" vs "KnowledgeBase") - resource_type_variants = [self.resource_type.value] - if self.resource_type.value == "KnowledgeBase": - resource_type_variants.append("KNOWLEDGE_BASE") + resource_type_variants = self._resource_type_variants # Note: Database may store status in different formats (e.g., "PENDING" vs "pending") pending_status_variants = [ @@ -1495,18 +1919,14 @@ def check_permission( """ Check if user has required permission role. + Checks both direct user-type permissions and entity-type memberships. Role hierarchy: Owner > Maintainer > Developer > Reporter > RestrictedAnalyst """ # Note: Database may store resource_type in different formats (e.g., "KNOWLEDGE_BASE" vs "KnowledgeBase") - resource_type_variants = [self.resource_type.value] - if self.resource_type.value == "KnowledgeBase": - resource_type_variants.append("KNOWLEDGE_BASE") + resource_type_variants = self._resource_type_variants # Note: Database may store status in different formats (e.g., "APPROVED" vs "approved") - approved_status_variants = [ - MemberStatus.APPROVED.value, - MemberStatus.APPROVED.value.upper(), - ] + approved_status_variants = self._approved_status_variants from sqlalchemy import case @@ -1515,7 +1935,8 @@ def check_permission( .filter( ResourceMember.resource_type.in_(resource_type_variants), ResourceMember.resource_id == resource_id, - ResourceMember.user_id == user_id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), ResourceMember.status.in_(approved_status_variants), ) .order_by( @@ -1529,27 +1950,23 @@ def check_permission( .first() ) - if not member: - return False + if member: + # Use effective role for permission check + effective_role = member.get_effective_role() + return has_permission(effective_role, required_role.value) - # Use effective role for permission check - effective_role = member.get_effective_role() - return has_permission(effective_role, required_role.value) + # Fallback: check entity-type permissions (e.g., namespace) + return self.check_entity_permission(db, resource_id, user_id, required_role) def get_user_role( self, db: Session, resource_id: int, user_id: int ) -> Optional[str]: """Get user's role for a resource.""" # Note: Database may store resource_type in different formats (e.g., "KNOWLEDGE_BASE" vs "KnowledgeBase") - resource_type_variants = [self.resource_type.value] - if self.resource_type.value == "KnowledgeBase": - resource_type_variants.append("KNOWLEDGE_BASE") + resource_type_variants = self._resource_type_variants # Note: Database may store status in different formats (e.g., "APPROVED" vs "approved") - approved_status_variants = [ - MemberStatus.APPROVED.value, - MemberStatus.APPROVED.value.upper(), - ] + approved_status_variants = self._approved_status_variants from sqlalchemy import case @@ -1558,7 +1975,8 @@ def get_user_role( .filter( ResourceMember.resource_type.in_(resource_type_variants), ResourceMember.resource_id == resource_id, - ResourceMember.user_id == user_id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), ResourceMember.status.in_(approved_status_variants), ) .order_by( @@ -1572,4 +1990,209 @@ def get_user_role( .first() ) - return member.get_effective_role() if member else None + direct_role = member.get_effective_role() if member else None + if direct_role == ResourceRole.Owner.value: + return direct_role + + entity_role = self._get_highest_entity_role(db, resource_id, user_id) + if direct_role and entity_role: + return ( + direct_role if has_permission(direct_role, entity_role) else entity_role + ) + return direct_role or entity_role + + def _get_highest_entity_role( + self, + db: Session, + resource_id: int, + user_id: int, + ) -> Optional[str]: + """Get the highest role granted via entity-type memberships. + + Queries all entity bindings for the resource, resolves which entities + match the user via registered resolvers, and returns the highest + privilege role among matched bindings. + """ + resource_type_variants = self._resource_type_variants + approved_status_variants = self._approved_status_variants + + entity_rows = ( + db.query( + ResourceMember.entity_type, + ResourceMember.entity_id, + ResourceMember.role, + ) + .filter( + ResourceMember.resource_type.in_(resource_type_variants), + ResourceMember.resource_id == resource_id, + ResourceMember.entity_type != "user", + ResourceMember.entity_type != "", + ResourceMember.entity_id.isnot(None), + ResourceMember.status.in_(approved_status_variants), + ) + .all() + ) + + if not entity_rows: + return None + + from collections import defaultdict + + entity_groups: defaultdict[str, list[tuple[str, str]]] = defaultdict(list) + for et, eid, role in entity_rows: + if et and eid: + entity_groups[et].append((eid, role)) + + highest: Optional[str] = None + for entity_type_str, entries in entity_groups.items(): + resolver = get_entity_resolver(entity_type_str) + if not resolver: + continue + eids = [eid for eid, _ in entries] + matched = resolver.match_entity_bindings(db, user_id, entity_type_str, eids) + if not matched: + continue + role_by_eid = {eid: role for eid, role in entries} + for eid in matched: + role = role_by_eid.get(eid) + if role and (highest is None or has_permission(role, highest)): + highest = role + return highest + + def check_entity_permission( + self, + db: Session, + resource_id: int, + user_id: int, + required_role: SchemaMemberRole, + ) -> bool: + """ + Check if user has required permission via entity-type memberships. + + Base implementation only checks entity_type != 'user' ResourceMember records + that match via external resolvers. Subclasses (e.g., KnowledgeShareService) + should override this with resource-specific logic. + + Args: + db: Database session + resource_id: Resource ID + user_id: User ID + required_role: Required role level + + Returns: + True if user has sufficient permission via entity membership + """ + resource_type_variants = self._resource_type_variants + + approved_status_variants = self._approved_status_variants + + # Query entity bindings with their stored roles for precise checks + entity_rows = ( + db.query( + ResourceMember.entity_type, + ResourceMember.entity_id, + ResourceMember.role, + ) + .filter( + ResourceMember.resource_type.in_(resource_type_variants), + ResourceMember.resource_id == resource_id, + ResourceMember.entity_type != "user", + ResourceMember.entity_type != "", + ResourceMember.entity_id.isnot(None), + ResourceMember.status.in_(approved_status_variants), + ) + .all() + ) + + if not entity_rows: + return False + + from collections import defaultdict + + entity_groups: defaultdict[str, list[tuple[str, str]]] = defaultdict(list) + for et, eid, role in entity_rows: + if et and eid: + entity_groups[et].append((eid, role)) + + for entity_type_str, entries in entity_groups.items(): + resolver = get_entity_resolver(entity_type_str) + if not resolver: + continue + eids = [eid for eid, _ in entries] + matched = resolver.match_entity_bindings(db, user_id, entity_type_str, eids) + if not matched: + continue + # Build eid -> role lookup to avoid iterating all entries + role_by_eid = {eid: role for eid, role in entries} + for eid in matched: + role = role_by_eid.get(eid) + if role and has_permission(role, required_role): + return True + + return False + + def get_my_permission_sources( + self, + db: Session, + resource_id: int, + user_id: int, + ) -> MyPermissionSourcesResponse: + """ + Get all permission sources for the current user on a resource. + + Base implementation returns direct ResourceMember permissions. + Subclasses should override to include resource-specific sources + (e.g., group membership, entity-type permissions). + + Args: + db: Database session + resource_id: Resource ID + user_id: User ID + + Returns: + MyPermissionSourcesResponse with all sources + """ + resource_type_variants = self._resource_type_variants + + approved_status_variants = self._approved_status_variants + + # Get user-type direct permission + user_member = ( + db.query(ResourceMember) + .filter( + ResourceMember.resource_type.in_(resource_type_variants), + ResourceMember.resource_id == resource_id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), + ResourceMember.status.in_(approved_status_variants), + ) + .first() + ) + + sources: list[PermissionSourceInfo] = [] + roles: list[str] = [] + + if user_member: + effective_role = user_member.get_effective_role() + source_type = self._resolve_source_type(user_member) + user = db.query(User).filter(User.id == user_id).first() + source_name = user.user_name if user else None + sources.append( + PermissionSourceInfo( + source_type=source_type, + display_name=source_name, + role=effective_role, + entity_type="user", + entity_id=str(user_id), + ) + ) + roles.append(effective_role) + + effective_role = get_highest_role(roles) if roles else None + + return MyPermissionSourcesResponse( + has_access=len(sources) > 0, + effective_role=effective_role, + is_creator=False, + sources=sources, + ) diff --git a/backend/app/services/share/external_entity_resolver.py b/backend/app/services/share/external_entity_resolver.py new file mode 100644 index 00000000000..9b758e697f8 --- /dev/null +++ b/backend/app/services/share/external_entity_resolver.py @@ -0,0 +1,183 @@ +# SPDX-FileCopyrightText: 2025 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +""" +External entity resolver interface for resource permissions. + +Defines the IExternalEntityResolver interface that handles entity_type +bindings not natively supported by the core system (e.g., 'namespace'). +Implementations are registered via register_entity_resolver(). + +The open-source core ships with no implementations. Internal deployments +(e.g., wecode) provide concrete resolvers via the registration function. +""" + +import logging +from abc import ABC, abstractmethod +from typing import Optional + +from sqlalchemy.orm import Session + +logger = logging.getLogger(__name__) + +# Module-level resolver registry +_external_entity_resolvers: dict[str, type["IExternalEntityResolver"]] = {} +_resolver_instances: dict[str, "IExternalEntityResolver"] = {} + + +def register_entity_resolver( + entity_type: str, resolver_cls: type["IExternalEntityResolver"] +) -> None: + """Register an external entity resolver for a specific entity type. + + Args: + entity_type: The entity type to handle (e.g., 'namespace') + resolver_cls: The resolver class implementing IExternalEntityResolver + """ + _external_entity_resolvers[entity_type] = resolver_cls + _resolver_instances.pop(entity_type, None) # Clear cached instance + logger.info( + f"Registered external entity resolver for type='{entity_type}': " + f"{resolver_cls.__name__}" + ) + + +def get_entity_resolver(entity_type: str) -> Optional["IExternalEntityResolver"]: + """Get a resolver instance for the given entity type. + + Args: + entity_type: The entity type to get a resolver for + + Returns: + An IExternalEntityResolver instance, or None if no resolver is registered + """ + if entity_type in _resolver_instances: + return _resolver_instances[entity_type] + + cls = _external_entity_resolvers.get(entity_type) + if cls: + instance = cls() + _resolver_instances[entity_type] = instance + return instance + return None + + +def get_all_entity_types() -> list[str]: + """Get all registered external entity types. + + Returns: + List of entity type strings + """ + return list(_external_entity_resolvers.keys()) + + +class IExternalEntityResolver(ABC): + """Interface for resolving external entity permissions. + + Handles entity_type bindings that are not natively supported by the + core permission system. The core system calls these resolvers when + it encounters resource_member records with matching entity types. + + Typical flow: + 1. Permission check finds ResourceMember with entity_type='namespace' + 2. Core calls resolver.match_entity_bindings() to check user membership + 3. If matched, the binding's role is applied + """ + + @abstractmethod + def match_entity_bindings( + self, + db: Session, + user_id: int, + entity_type: str, + entity_ids: list[str], + user_context: Optional[dict] = None, + ) -> list[str]: + """Determine which entity bindings match the user. + + Args: + db: Database session + user_id: User ID to check + entity_type: Type of entity (e.g., 'namespace') + entity_ids: List of entity IDs to check against + user_context: Optional user profile data to avoid re-fetching + + Returns: + List of entity IDs from entity_ids that the user matches. + Empty list if no bindings match. + The actual permission role is stored on the ResourceMember record. + """ + ... + + @property + def requires_display_name_snapshot(self) -> bool: + """Return True if display names should be persisted as snapshots. + + When False, the share service will prefer live resolver lookups over + persisted entity_display_name snapshots. Override to False for entity + types that are reliably resolvable from local data (e.g., namespace). + """ + return True + + def get_display_name(self, db: Session, entity_id: str) -> Optional[str]: + """Optional: resolve display name for entity-type members. + + Called by share services when rendering member lists to resolve + human-readable names for entity-type members (e.g., department names). + + Args: + db: Database session + entity_id: Entity identifier + + Returns: + Display name string or None if not resolvable + """ + return None + + def batch_get_display_names( + self, db: Session, entity_ids: list[str] + ) -> dict[str, str]: + """Batch resolve display names for multiple entity IDs. + + Default implementation falls back to individual get_display_name calls. + Subclasses should override this when the underlying API supports true + batch resolution to avoid N+1 queries. + + Args: + db: Database session + entity_ids: List of entity identifiers + + Returns: + Mapping of entity_id -> display name for resolvable IDs + """ + result: dict[str, str] = {} + for eid in entity_ids: + name = self.get_display_name(db, eid) + if name: + result[eid] = name + return result + + @abstractmethod + def get_resource_ids_by_entity( + self, + db: Session, + user_id: int, + entity_type: str, + user_context: Optional[dict] = None, + ) -> list[int]: + """Get resource IDs accessible to user via entity binding. + + Used for list operations: find all resources where any + resource_member record matches a user's entity affiliations. + + Args: + db: Database session + user_id: User ID + entity_type: Entity type to resolve + user_context: Optional user profile data + + Returns: + List of resource IDs accessible via this entity type + """ + ... diff --git a/backend/app/services/share/knowledge_share_service.py b/backend/app/services/share/knowledge_share_service.py index b1376548209..f108e7c1218 100644 --- a/backend/app/services/share/knowledge_share_service.py +++ b/backend/app/services/share/knowledge_share_service.py @@ -10,20 +10,28 @@ """ import logging -from typing import Optional, Tuple +from datetime import datetime +from typing import List, Optional, Tuple +from fastapi import HTTPException from sqlalchemy.orm import Session from app.core.config import settings from app.models.kind import Kind +from app.models.namespace import Namespace from app.models.resource_member import MemberStatus, ResourceMember, ResourceRole from app.models.share_link import ResourceType, ShareLink from app.models.user import User -from app.schemas.base_role import BaseRole, has_permission +from app.schemas.base_role import BaseRole, get_highest_role, has_permission from app.schemas.share import ( + BatchResourceMemberResponse, + FailedMemberResponse, KBShareInfoResponse, MyKBPermissionResponse, + MyPermissionSourcesResponse, PendingRequestInfo, + PermissionSourceInfo, + ResourceMemberResponse, ) # SchemaMemberRole is an alias to BaseRole for backward compatibility @@ -69,7 +77,8 @@ def get_knowledge_base_tool_access_mode_by_ids( .filter( ResourceMember.resource_type == ResourceType.KNOWLEDGE_BASE.value, ResourceMember.resource_id.in_([kb.id for kb in kbs]), - ResourceMember.user_id == user_id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), ResourceMember.status == MemberStatus.APPROVED.value, ResourceMember.role == ResourceRole.RestrictedAnalyst.value, ) @@ -167,18 +176,22 @@ def _get_resource( ) return kb - logger.warning( + logger.info( f"[_get_resource] User is NOT creator: user_id={user_id} != kb.user_id={kb.user_id}" ) # Check if user has explicit shared access + resource_type_variants = self._resource_type_variants + approved_status_variants = self._approved_status_variants + member = ( db.query(ResourceMember) .filter( - ResourceMember.resource_type == ResourceType.KNOWLEDGE_BASE.value, + ResourceMember.resource_type.in_(resource_type_variants), ResourceMember.resource_id == resource_id, - ResourceMember.user_id == user_id, - ResourceMember.status == MemberStatus.APPROVED.value, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), + ResourceMember.status.in_(approved_status_variants), ) .first() ) @@ -194,7 +207,46 @@ def _get_resource( ) return kb - logger.warning(f"[_get_resource] User has NO explicit shared access") + logger.info("[_get_resource] User has NO explicit shared access") + + # Check entity-type memberships (e.g., namespace) + entity_members = ( + db.query(ResourceMember) + .filter( + ResourceMember.resource_type.in_(resource_type_variants), + ResourceMember.resource_id == resource_id, + ResourceMember.entity_type != "user", + ResourceMember.entity_type != "", + ResourceMember.entity_id.isnot(None), + ResourceMember.status.in_(approved_status_variants), + ) + .all() + ) + if entity_members: + from app.services.share.external_entity_resolver import ( + get_entity_resolver, + ) + + entity_groups: dict[str, list[str]] = {} + for em in entity_members: + if em.entity_type and em.entity_id: + entity_groups.setdefault(em.entity_type, []).append(em.entity_id) + + for entity_type_str, entity_ids in entity_groups.items(): + resolver = get_entity_resolver(entity_type_str) + if resolver: + matched = resolver.match_entity_bindings( + db, + user_id, + entity_type_str, + entity_ids, + ) + if matched: + logger.info( + f"[_get_resource] User {user_id} matched entity " + f"type='{entity_type_str}' via resolver" + ) + return kb # For organization knowledge bases, all authenticated users have access if is_organization_namespace(db, kb.namespace): @@ -218,7 +270,7 @@ def _get_resource( return None logger.info(f"[_get_resource] User has team role: role={role}") return kb - logger.warning( + logger.info( f"[_get_resource] User has NO team role in namespace={kb.namespace}" ) else: @@ -278,103 +330,462 @@ def _on_member_approved( return None # ========================================================================= - # Permission Check Methods (integrated from KnowledgePermissionService) + # Member addition overrides with KB-specific validation # ========================================================================= - def get_user_kb_permission( + def add_member( self, db: Session, - knowledge_base_id: int, - user_id: int, - ) -> Tuple[bool, Optional[str], bool]: + resource_id: int, + current_user_id: int, + target_user_id: int, + role: SchemaMemberRole, + entity_type: Optional[str] = None, + entity_id: Optional[str] = None, + entity_display_name: Optional[str] = None, + ) -> ResourceMemberResponse: + """Add a member to a KnowledgeBase with group-native KB protection. + + Prevents sharing a group-native KB back to its own group via + entity-type membership, which would create a redundant record. """ - Get user's permission for a knowledge base. + if entity_type == "namespace" and entity_id: + kb = ( + db.query(Kind) + .filter( + Kind.id == resource_id, + Kind.kind == "KnowledgeBase", + Kind.is_active.is_(True), + ) + .first() + ) + if kb: + try: + ns_id = int(entity_id) + from app.models.namespace import Namespace - Priority: creator > explicit permission (ResourceMember) > group permission > task binding + target_ns = ( + db.query(Namespace).filter(Namespace.id == ns_id).first() + ) + if target_ns and kb.namespace == target_ns.name: + raise HTTPException( + status_code=400, + detail="Cannot share a knowledge base to its own group", + ) + except (ValueError, TypeError): + pass + + return super().add_member( + db=db, + resource_id=resource_id, + current_user_id=current_user_id, + target_user_id=target_user_id, + role=role, + entity_type=entity_type, + entity_id=entity_id, + entity_display_name=entity_display_name, + ) - Returns: - Tuple of (has_access, role, is_creator) + def batch_add_members( + self, + db: Session, + resource_id: int, + current_user_id: int, + members_data: List[ + Tuple[int, SchemaMemberRole, Optional[str], Optional[str], Optional[str]] + ], + ) -> BatchResourceMemberResponse: + """Batch add members to a KnowledgeBase with group-native KB protection. + + Filters out entries that would share a group-native KB back to its + own group; those entries are returned in the failed list. """ - # Get the knowledge base kb = ( db.query(Kind) .filter( - Kind.id == knowledge_base_id, + Kind.id == resource_id, Kind.kind == "KnowledgeBase", - Kind.is_active, + Kind.is_active.is_(True), ) .first() ) - if not kb: - return False, None, False + filtered_members = [] + failed: List[FailedMemberResponse] = [] + for entry in members_data: + _target_user_id, _role, ent_type, ent_id, _ent_display_name = entry + if ent_type == "namespace" and ent_id and kb: + try: + ns_id = int(ent_id) + from app.models.namespace import Namespace + + target_ns = ( + db.query(Namespace).filter(Namespace.id == ns_id).first() + ) + if target_ns and kb.namespace == target_ns.name: + failed.append( + FailedMemberResponse( + user_id=0, + entity_type=ent_type, + entity_id=ent_id, + error="Cannot share a knowledge base to its own group", + ) + ) + continue + except (ValueError, TypeError): + pass + filtered_members.append(entry) + + result = super().batch_add_members( + db=db, + resource_id=resource_id, + current_user_id=current_user_id, + members_data=filtered_members, + ) - # For group knowledge bases, check Restricted Analyst status FIRST - # This check must run before creator/explicit-share checks to prevent bypass - if kb.namespace != "default" and not is_organization_namespace( - db, kb.namespace - ): - if is_restricted_analyst(db, user_id, kb.namespace): - logger.warning( - f"[get_user_kb_permission] User {user_id} is Restricted Analyst in group " - f"'{kb.namespace}', denying access to KB {knowledge_base_id}" - ) - return False, None, False + result.failed.extend(failed) + return result - # Check if user is creator + # ========================================================================= + # Permission Check Methods (integrated from KnowledgePermissionService) + # ========================================================================= + + # ===================================================================== + # Permission source helpers — each returns (roles, sources) and may + # signal an early-return condition for Restricted Analyst. + # ===================================================================== + + def _source_creator( + self, + db: Session, + kb: Kind, + user_id: int, + user: Optional[User], + include_sources: bool, + ) -> tuple[list[str], list[PermissionSourceInfo]]: + """Creator permission source.""" + roles: list[str] = [] + sources: list[PermissionSourceInfo] = [] if kb.user_id == user_id: - return True, ResourceRole.Owner.value, True + if include_sources: + sources.append( + PermissionSourceInfo( + source_type="creator", + display_name=user.user_name if user else None, + role=BaseRole.Owner.value, + entity_type="user", + entity_id=str(user_id), + ) + ) + roles.append(BaseRole.Owner.value) + return roles, sources - # Check explicit permission in resource_members table - explicit_perm = ( + def _source_direct_member( + self, + db: Session, + kb: Kind, + user_id: int, + user: Optional[User], + include_sources: bool, + ) -> tuple[list[str], list[PermissionSourceInfo], bool]: + """Direct user-type ResourceMember permission source. + + Returns: + (roles, sources, is_restricted_analyst) + """ + roles: list[str] = [] + sources: list[PermissionSourceInfo] = [] + user_member = ( db.query(ResourceMember) .filter( - ResourceMember.resource_type == ResourceType.KNOWLEDGE_BASE.value, - ResourceMember.resource_id == knowledge_base_id, - ResourceMember.user_id == user_id, - ResourceMember.status == MemberStatus.APPROVED.value, + ResourceMember.resource_type.in_(self._resource_type_variants), + ResourceMember.resource_id == kb.id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), + ResourceMember.status.in_(self._approved_status_variants), ) .first() ) - - if explicit_perm: - effective_role = explicit_perm.get_effective_role() - # RestrictedAnalyst is not allowed to access knowledge base details + if user_member: + effective_role = user_member.get_effective_role() if effective_role == ResourceRole.RestrictedAnalyst.value: - return False, None, False - return True, effective_role, False + return [], [], True + if include_sources: + source_type = self._resolve_source_type(user_member) + sources.append( + PermissionSourceInfo( + source_type=source_type, + display_name=user.user_name if user else None, + role=effective_role, + entity_type="user", + entity_id=str(user_id), + ) + ) + roles.append(effective_role) + return roles, sources, False - # For organization knowledge bases, all authenticated users have VIEW access + def _source_organization( + self, + db: Session, + kb: Kind, + user: Optional[User], + include_sources: bool, + ) -> tuple[list[str], list[PermissionSourceInfo]]: + """Organization knowledge base permission source.""" + roles: list[str] = [] + sources: list[PermissionSourceInfo] = [] if is_organization_namespace(db, kb.namespace): - user = db.query(User).filter(User.id == user_id).first() - if user and user.role == "admin": - return True, ResourceRole.Owner.value, False - return True, ResourceRole.Reporter.value, False + org_role = ( + ResourceRole.Owner.value + if user and user.role == "admin" + else ResourceRole.Reporter.value + ) + if include_sources: + sources.append( + PermissionSourceInfo( + source_type="organization", + display_name="Organization", + role=org_role, + ) + ) + roles.append(org_role) + return roles, sources - # For team knowledge bases, check group permission - if kb.namespace != "default": - group_role = get_effective_role_in_group(db, user_id, kb.namespace) - if group_role is not None: - # RestrictedAnalyst is not allowed to access knowledge base details - if group_role == GroupRole.RestrictedAnalyst: - return False, None, False - # Map group role to resource role - role_mapping = { - GroupRole.Owner: ResourceRole.Owner.value, - GroupRole.Maintainer: ResourceRole.Maintainer.value, - GroupRole.Developer: ResourceRole.Developer.value, - GroupRole.Reporter: ResourceRole.Reporter.value, - } - role = role_mapping.get(group_role, ResourceRole.Reporter.value) - return True, role, False - - # For personal knowledge bases (namespace == "default"), check if bound to group chat - if kb.namespace == "default": - if self._is_kb_bound_to_user_group_chat(db, knowledge_base_id, user_id): - # User is member of a group chat that has this KB bound - return True, ResourceRole.Reporter.value, False - - return False, None, False + def _source_group_membership( + self, + db: Session, + kb: Kind, + user_id: int, + include_sources: bool, + ) -> tuple[list[str], list[PermissionSourceInfo], bool]: + """Group membership permission source for team knowledge bases. + + Returns: + (roles, sources, is_restricted_analyst) + """ + roles: list[str] = [] + sources: list[PermissionSourceInfo] = [] + if kb.namespace == "default" or is_organization_namespace(db, kb.namespace): + return roles, sources, False + + group_role = get_effective_role_in_group(db, user_id, kb.namespace) + if group_role is None: + return roles, sources, False + + if group_role == GroupRole.RestrictedAnalyst: + return [], [], True + + role_mapping = { + GroupRole.Owner: BaseRole.Owner.value, + GroupRole.Maintainer: BaseRole.Maintainer.value, + GroupRole.Developer: BaseRole.Developer.value, + GroupRole.Reporter: BaseRole.Reporter.value, + } + mapped_role = role_mapping.get(group_role, BaseRole.Reporter.value) + if include_sources: + group = db.query(Namespace).filter(Namespace.name == kb.namespace).first() + sources.append( + PermissionSourceInfo( + source_type="group_membership", + display_name=( + group.display_name or group.name if group else kb.namespace + ), + role=mapped_role, + ) + ) + roles.append(mapped_role) + return roles, sources, False + + def _source_entity_permission( + self, + db: Session, + kb: Kind, + user_id: int, + include_sources: bool, + ) -> tuple[list[str], list[PermissionSourceInfo]]: + """Entity-type ResourceMember permission source.""" + roles: list[str] = [] + sources: list[PermissionSourceInfo] = [] + entity_rows = ( + db.query( + ResourceMember.entity_type, + ResourceMember.entity_id, + ResourceMember.role, + ) + .filter( + ResourceMember.resource_type.in_(self._resource_type_variants), + ResourceMember.resource_id == kb.id, + ResourceMember.entity_type != "user", + ResourceMember.entity_type != "", + ResourceMember.entity_id.isnot(None), + ResourceMember.status.in_(self._approved_status_variants), + ) + .all() + ) + if not entity_rows: + return roles, sources + + from app.services.share.external_entity_resolver import get_entity_resolver + + entity_groups: dict[str, list[str]] = {} + entity_role_map: dict[str, str] = {} + for et, eid, role_str in entity_rows: + if et and eid: + entity_groups.setdefault(et, []).append(eid) + entity_role_map[f"{et}:{eid}"] = role_str + + for entity_type_str, entity_ids in entity_groups.items(): + resolver = get_entity_resolver(entity_type_str) + if not resolver: + continue + matched = resolver.match_entity_bindings( + db, user_id, entity_type_str, entity_ids + ) + if not matched: + continue + if include_sources: + display_names = resolver.batch_get_display_names(db, list(matched)) + for eid in matched: + key = f"{entity_type_str}:{eid}" + role_str = entity_role_map.get(key) + if not role_str: + continue + if include_sources: + sources.append( + PermissionSourceInfo( + source_type="entity_permission", + display_name=display_names.get(eid), + role=role_str, + entity_type=entity_type_str, + entity_id=eid, + ) + ) + roles.append(role_str) + return roles, sources + + def _source_group_chat_binding( + self, + db: Session, + kb: Kind, + user_id: int, + include_sources: bool, + ) -> tuple[list[str], list[PermissionSourceInfo]]: + """Group chat binding permission source for personal KBs.""" + roles: list[str] = [] + sources: list[PermissionSourceInfo] = [] + if kb.namespace == "default" and self._is_kb_bound_to_user_group_chat( + db, kb.id, user_id + ): + if include_sources: + sources.append( + PermissionSourceInfo( + source_type="group_chat_binding", + display_name="Group Chat", + role=ResourceRole.Reporter.value, + ) + ) + roles.append(ResourceRole.Reporter.value) + return roles, sources + + def _compute_kb_access_core( + self, + db: Session, + kb: Kind, + user_id: int, + include_sources: bool = True, + ) -> tuple[bool, bool, Optional[str], list[PermissionSourceInfo]]: + """ + Core permission computation shared by get_user_kb_permission and + get_my_permission_sources. Collects ALL permission sources and computes + the highest effective role via get_highest_role. + + Returns: + Tuple of (has_access, is_creator, effective_role, sources) + """ + sources: list[PermissionSourceInfo] = [] + roles: list[str] = [] + + # Pre-fetch user once to avoid repeated queries + user = db.query(User).filter(User.id == user_id).first() + + # 1. Creator + is_creator = kb.user_id == user_id + r, s = self._source_creator(db, kb, user_id, user, include_sources) + roles.extend(r) + sources.extend(s) + + # Creator bypasses Restricted Analyst check + if not is_creator and kb.namespace != "default": + if not is_organization_namespace(db, kb.namespace): + if is_restricted_analyst(db, user_id, kb.namespace): + return False, is_creator, None, sources + + # 2. Direct user-type member + r, s, restricted = self._source_direct_member( + db, kb, user_id, user, include_sources + ) + if restricted: + return False, is_creator, None, sources + roles.extend(r) + sources.extend(s) + + # 3. Organization + r, s = self._source_organization(db, kb, user, include_sources) + roles.extend(r) + sources.extend(s) + + # 4. Group membership + r, s, restricted = self._source_group_membership( + db, kb, user_id, include_sources + ) + if restricted: + return False, is_creator, None, sources + roles.extend(r) + sources.extend(s) + + # 5. Entity permission + r, s = self._source_entity_permission(db, kb, user_id, include_sources) + roles.extend(r) + sources.extend(s) + + # 6. Group chat binding + r, s = self._source_group_chat_binding(db, kb, user_id, include_sources) + roles.extend(r) + sources.extend(s) + + effective_role = get_highest_role(roles) if roles else None + has_access = len(roles) > 0 or is_creator + return has_access, is_creator, effective_role, sources + + def get_user_kb_permission( + self, + db: Session, + knowledge_base_id: int, + user_id: int, + ) -> Tuple[bool, Optional[str], bool]: + """ + Get user's permission for a knowledge base. + + Returns: + Tuple of (has_access, role, is_creator) + """ + kb = ( + db.query(Kind) + .filter( + Kind.id == knowledge_base_id, + Kind.kind == "KnowledgeBase", + Kind.is_active, + ) + .first() + ) + + if not kb: + return False, None, False + + has_access, is_creator, effective_role, _ = self._compute_kb_access_core( + db, kb, user_id, include_sources=False + ) + return has_access, effective_role, is_creator def check_permission( self, @@ -439,7 +850,8 @@ def _is_kb_bound_to_user_group_chat( TaskResource.kind == "Task", TaskResource.is_active == TaskResource.STATE_ACTIVE, ResourceMember.resource_type == ResourceType.TASK, - ResourceMember.user_id == user_id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), ResourceMember.status == MemberStatus.APPROVED, ) .all() @@ -475,9 +887,10 @@ def can_manage_permissions( ) if is_creator: return True - return has_access and role in ( - ResourceRole.Owner, - ResourceRole.Maintainer, + return ( + has_access + and role is not None + and has_permission(role, BaseRole.Maintainer) ) def get_my_permission( @@ -544,7 +957,8 @@ def get_my_permission( .filter( ResourceMember.resource_type == ResourceType.KNOWLEDGE_BASE.value, ResourceMember.resource_id == knowledge_base_id, - ResourceMember.user_id == user_id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), ) .first() ) @@ -566,6 +980,18 @@ def get_my_permission( db, knowledge_base_id, user_id, kb=kb ) + # If user already has access and the pending role is not higher than + # the current effective role, suppress the pending request to avoid + # UX confusion (user already has access via entity permission, etc.) + if pending_request and has_access and merged_role: + pending_role = ( + pending_request.role.value + if hasattr(pending_request.role, "value") + else str(pending_request.role) + ) + if has_permission(merged_role, pending_role): + pending_request = None + return MyKBPermissionResponse( has_access=has_access, role=( @@ -680,9 +1106,7 @@ def get_public_kb_info( # Find share link by resource_id (more reliable than token matching) # The token in DB is URL-encoded, but FastAPI may have decoded the input # Note: Database may store resource_type in different formats (e.g., "KNOWLEDGE_BASE" vs "KnowledgeBase") - resource_type_variants = [self.resource_type.value] - if self.resource_type.value == "KnowledgeBase": - resource_type_variants.append("KNOWLEDGE_BASE") + resource_type_variants = self._resource_type_variants share_link = ( db.query(ShareLink) @@ -759,9 +1183,7 @@ def get_share_token_by_kb_id( Share token if found, None otherwise """ # Note: Database may store resource_type in different formats (e.g., "KNOWLEDGE_BASE" vs "KnowledgeBase") - resource_type_variants = [self.resource_type.value] - if self.resource_type.value == "KnowledgeBase": - resource_type_variants.append("KNOWLEDGE_BASE") + resource_type_variants = self._resource_type_variants share_link = ( db.query(ShareLink) @@ -775,6 +1197,48 @@ def get_share_token_by_kb_id( return share_link.share_token if share_link else None + # ========================================================================= + # Permission Sources + # ========================================================================= + + def get_my_permission_sources( + self, + db: Session, + resource_id: int, + user_id: int, + ) -> MyPermissionSourcesResponse: + """ + Get all permission sources for the current user on a knowledge base. + + Delegates to _compute_kb_access_core for consistent permission logic. + """ + kb = ( + db.query(Kind) + .filter( + Kind.id == resource_id, + Kind.kind == "KnowledgeBase", + Kind.is_active, + ) + .first() + ) + if not kb: + return MyPermissionSourcesResponse( + has_access=False, + is_creator=False, + sources=[], + ) + + has_access, is_creator, effective_role, sources = self._compute_kb_access_core( + db, kb, user_id, include_sources=True + ) + + return MyPermissionSourcesResponse( + has_access=has_access, + is_creator=is_creator, + effective_role=effective_role, + sources=sources, + ) + # ========================================================================= # Cleanup Methods # ========================================================================= @@ -824,7 +1288,8 @@ def delete_members_for_user( db.query(ResourceMember) .filter( ResourceMember.resource_type == ResourceType.KNOWLEDGE_BASE.value, - ResourceMember.user_id == user_id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), ) .delete() ) diff --git a/backend/app/services/share/namespace_entity_resolver.py b/backend/app/services/share/namespace_entity_resolver.py new file mode 100644 index 00000000000..125387e43e5 --- /dev/null +++ b/backend/app/services/share/namespace_entity_resolver.py @@ -0,0 +1,140 @@ +# SPDX-FileCopyrightText: 2025 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +""" +Namespace entity resolver implementation. + +Concrete implementation of IExternalEntityResolver that handles +namespace entity type by checking group membership via ResourceMember. + +When a ResourceMember record has entity_type='namespace' and +entity_id='', it grants access to all members of that +namespace who have approved membership status. +""" + +import logging +from typing import Optional + +from sqlalchemy.orm import Session + +from app.models.namespace import Namespace +from app.models.resource_member import MemberStatus, ResourceMember +from app.services.group_member_helper import NAMESPACE_RESOURCE_TYPE +from app.services.share.external_entity_resolver import IExternalEntityResolver + +logger = logging.getLogger(__name__) + + +class NamespaceEntityResolver(IExternalEntityResolver): + """Resolves namespace entity bindings using ResourceMember membership. + + For a given user, this resolver checks if the user is a member of + the namespaces specified in entity_ids by querying ResourceMember + records with resource_type='Namespace' and entity_type='user'. + """ + + @property + def requires_display_name_snapshot(self) -> bool: + """Namespace names are always resolvable from the local DB.""" + return False + + def get_display_name(self, db: Session, entity_id: str) -> Optional[str]: + """Resolve namespace display name from the Namespace table.""" + if not entity_id: + return None + try: + ns_id = int(entity_id) + except (ValueError, TypeError): + return None + namespace = db.query(Namespace).filter(Namespace.id == ns_id).first() + if namespace: + return namespace.display_name or namespace.name + return None + + def match_entity_bindings( + self, + db: Session, + user_id: int, + entity_type: str, + entity_ids: list[str], + user_context: Optional[dict] = None, + ) -> list[str]: + if entity_type != "namespace": + return [] + + if not entity_ids: + return [] + + # Convert entity_ids to int for namespace ID comparison + namespace_ids = [] + for eid in entity_ids: + try: + namespace_ids.append(int(eid)) + except (ValueError, TypeError): + continue + + if not namespace_ids: + return [] + + matched_rows = ( + db.query(ResourceMember.resource_id) + .filter( + ResourceMember.resource_type == NAMESPACE_RESOURCE_TYPE, + ResourceMember.resource_id.in_(namespace_ids), + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), + ResourceMember.status == MemberStatus.APPROVED.value, + ) + .all() + ) + matched = [str(r.resource_id) for r in matched_rows] + + if matched: + logger.info( + f"User user_id={user_id} matched namespaces {matched} " + f"via NamespaceEntityResolver" + ) + + return matched + + def get_resource_ids_by_entity( + self, + db: Session, + user_id: int, + entity_type: str, + user_context: Optional[dict] = None, + ) -> list[int]: + if entity_type != "namespace": + return [] + + # Find all namespaces where user is an approved member + user_namespace_ids = ( + db.query(ResourceMember.resource_id) + .filter( + ResourceMember.resource_type == NAMESPACE_RESOURCE_TYPE, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), + ResourceMember.status == MemberStatus.APPROVED.value, + ) + .all() + ) + ns_ids = [r.resource_id for r in user_namespace_ids] + if not ns_ids: + return [] + + # Find all KnowledgeBase resource_members referencing those namespaces + results = ( + db.query(ResourceMember.resource_id) + .filter( + ResourceMember.resource_type.in_(["KnowledgeBase", "KNOWLEDGE_BASE"]), + ResourceMember.entity_type == "namespace", + ResourceMember.entity_id.in_([str(nid) for nid in ns_ids]), + ResourceMember.status == MemberStatus.APPROVED.value, + ) + .all() + ) + return list(set(r.resource_id for r in results)) + + +namespace_entity_resolver = NamespaceEntityResolver() diff --git a/backend/app/services/share/team_share_service.py b/backend/app/services/share/team_share_service.py index 3a96e5e98a1..84cfa66fe3d 100644 --- a/backend/app/services/share/team_share_service.py +++ b/backend/app/services/share/team_share_service.py @@ -64,7 +64,8 @@ def _get_resource( .filter( ResourceMember.resource_type == ResourceType.TEAM.value, ResourceMember.resource_id == resource_id, - ResourceMember.user_id == user_id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), ResourceMember.status == MemberStatus.APPROVED.value, ) .first() diff --git a/backend/app/services/shared_task.py b/backend/app/services/shared_task.py index e0f0f762b81..78e8df8715b 100644 --- a/backend/app/services/shared_task.py +++ b/backend/app/services/shared_task.py @@ -672,7 +672,8 @@ def join_shared_task( .filter( ResourceMember.resource_type == ResourceType.TASK, ResourceMember.resource_id == share_info.task_id, - ResourceMember.user_id == user_id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), ) .first() ) @@ -727,7 +728,8 @@ def join_shared_task( resource_member = ResourceMember( resource_type=ResourceType.TASK, resource_id=share_info.task_id, - user_id=user_id, + entity_type="user", + entity_id=str(user_id), role=ResourceRole.Maintainer.value, status=MemberStatus.APPROVED, invited_by_user_id=share_info.user_id, @@ -752,7 +754,8 @@ def get_user_shared_tasks(self, db: Session, user_id: int) -> List[SharedTaskInD db.query(ResourceMember) .filter( ResourceMember.resource_type == ResourceType.TASK, - ResourceMember.user_id == user_id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), ResourceMember.status == MemberStatus.APPROVED, ResourceMember.copied_resource_id > 0, # Only entries with copied tasks ) @@ -784,7 +787,8 @@ def remove_shared_task( .filter( ResourceMember.resource_type == ResourceType.TASK, ResourceMember.resource_id == original_task_id, - ResourceMember.user_id == user_id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), ResourceMember.status == MemberStatus.APPROVED, ) .first() diff --git a/backend/app/services/shared_team.py b/backend/app/services/shared_team.py index 233e212a94e..fc3ae429957 100644 --- a/backend/app/services/shared_team.py +++ b/backend/app/services/shared_team.py @@ -159,6 +159,11 @@ def decode_share_token( user_name=user.user_name, team_id=team_id, team_name=team.name, + bind_mode=( + team.json.get("spec", {}).get("bind_mode") + if isinstance(team.json, dict) + else None + ), ) else: # Without database session, return basic info with placeholder names @@ -185,7 +190,8 @@ def create_share_relationship( .filter( ResourceMember.resource_type == ResourceType.TEAM, ResourceMember.resource_id == team_id, - ResourceMember.user_id == user_id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), ResourceMember.status == MemberStatus.APPROVED, ) .first() @@ -202,7 +208,8 @@ def create_share_relationship( .filter( ResourceMember.resource_type == ResourceType.TEAM, ResourceMember.resource_id == team_id, - ResourceMember.user_id == user_id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), ) .first() ) @@ -230,7 +237,8 @@ def create_share_relationship( resource_member = ResourceMember( resource_type=ResourceType.TEAM, resource_id=team_id, - user_id=user_id, + entity_type="user", + entity_id=str(user_id), role=ResourceRole.Maintainer.value, status=MemberStatus.APPROVED, invited_by_user_id=original_user_id, @@ -261,7 +269,8 @@ def get_user_shared_teams(self, db: Session, user_id: int) -> List[SharedTeamInD db.query(ResourceMember) .filter( ResourceMember.resource_type == ResourceType.TEAM, - ResourceMember.user_id == user_id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), ResourceMember.status == MemberStatus.APPROVED, ) .all() @@ -312,7 +321,8 @@ def remove_shared_team(self, db: Session, user_id: int, team_id: int) -> bool: .filter( ResourceMember.resource_type == ResourceType.TEAM, ResourceMember.resource_id == team_id, - ResourceMember.user_id == user_id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), ResourceMember.status == MemberStatus.APPROVED, ) .first() diff --git a/backend/app/services/task_member_service.py b/backend/app/services/task_member_service.py index 60ca99abbad..e84e9012567 100644 --- a/backend/app/services/task_member_service.py +++ b/backend/app/services/task_member_service.py @@ -79,7 +79,8 @@ def is_member(self, db: Session, task_id: int, user_id: int) -> bool: .filter( ResourceMember.resource_type == ResourceType.TASK, ResourceMember.resource_id == task_id, - ResourceMember.user_id == user_id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), ResourceMember.status == MemberStatus.APPROVED, ResourceMember.copied_resource_id == 0, ) @@ -246,7 +247,8 @@ def add_member( .filter( ResourceMember.resource_type == ResourceType.TASK, ResourceMember.resource_id == task_id, - ResourceMember.user_id == user_id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), ) .first() ) @@ -288,7 +290,8 @@ def add_member( new_member = ResourceMember( resource_type=ResourceType.TASK, resource_id=task_id, - user_id=user_id, + entity_type="user", + entity_id=str(user_id), role=ResourceRole.Maintainer.value, # Group chat members get maintainer role status=MemberStatus.APPROVED, invited_by_user_id=invited_by, @@ -320,7 +323,8 @@ def remove_member( .filter( ResourceMember.resource_type == ResourceType.TASK, ResourceMember.resource_id == task_id, - ResourceMember.user_id == user_id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(user_id), ResourceMember.status == MemberStatus.APPROVED, ) .first() diff --git a/backend/tests/api/endpoints/test_group_namespace_permissions.py b/backend/tests/api/endpoints/test_group_namespace_permissions.py index 8df5d7631e9..0ca616bf763 100644 --- a/backend/tests/api/endpoints/test_group_namespace_permissions.py +++ b/backend/tests/api/endpoints/test_group_namespace_permissions.py @@ -54,7 +54,8 @@ def _add_member( member = ResourceMember( resource_type="Namespace", resource_id=group.id, - user_id=user.id, + entity_type="user", + entity_id=str(user.id), role=role, status=MemberStatus.APPROVED.value, invited_by_user_id=group.owner_user_id, @@ -182,7 +183,8 @@ def test_admin_transfer_ownership_demotes_previous_owner( .filter( ResourceMember.resource_type == "Namespace", ResourceMember.resource_id == group.id, - ResourceMember.user_id == owner.id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(owner.id), ResourceMember.status == MemberStatus.APPROVED.value, ) .first() @@ -195,7 +197,8 @@ def test_admin_transfer_ownership_demotes_previous_owner( .filter( ResourceMember.resource_type == "Namespace", ResourceMember.resource_id == group.id, - ResourceMember.user_id == maintainer.id, + ResourceMember.entity_type == "user", + ResourceMember.entity_id == str(maintainer.id), ResourceMember.status == MemberStatus.APPROVED.value, ) .first() diff --git a/backend/tests/api/endpoints/test_groups_api.py b/backend/tests/api/endpoints/test_groups_api.py index 4682e052431..9ce59d71cd2 100644 --- a/backend/tests/api/endpoints/test_groups_api.py +++ b/backend/tests/api/endpoints/test_groups_api.py @@ -51,7 +51,8 @@ def _add_member( member = ResourceMember( resource_type="Namespace", resource_id=group.id, - user_id=user.id, + entity_type="user", + entity_id=str(user.id), role=role, status=MemberStatus.APPROVED.value, invited_by_user_id=group.owner_user_id, @@ -95,17 +96,23 @@ def test_batch_update_group_member_roles_success( "Developer", ] - refreshed_developer = test_db.get(User, developer.id) - refreshed_reporter = test_db.get(User, reporter.id) - developer_member = next( - member - for member in refreshed_developer.resource_members - if member.resource_id == group.id + developer_member = ( + test_db.query(ResourceMember) + .filter( + ResourceMember.resource_type == "Namespace", + ResourceMember.resource_id == group.id, + ResourceMember.entity_id == str(developer.id), + ) + .first() ) - reporter_member = next( - member - for member in refreshed_reporter.resource_members - if member.resource_id == group.id + reporter_member = ( + test_db.query(ResourceMember) + .filter( + ResourceMember.resource_type == "Namespace", + ResourceMember.resource_id == group.id, + ResourceMember.entity_id == str(reporter.id), + ) + .first() ) assert developer_member.role == "Maintainer" assert reporter_member.role == "Developer" diff --git a/backend/tests/api/test_batch_add_members.py b/backend/tests/api/test_batch_add_members.py index 9ac009bd272..edffffbc271 100644 --- a/backend/tests/api/test_batch_add_members.py +++ b/backend/tests/api/test_batch_add_members.py @@ -288,7 +288,8 @@ def test_batch_add_requires_manage_permission( reporter_member = ResourceMember( resource_type="KnowledgeBase", resource_id=knowledge_base.id, - user_id=test_user.id, + entity_type="user", + entity_id=str(test_user.id), role="Reporter", status=MemberStatus.APPROVED.value, invited_by_user_id=knowledge_base.user_id, @@ -343,6 +344,96 @@ def test_batch_add_nonexistent_resource_returns_404( ) assert response.status_code == 404 + def test_batch_add_namespace_entity_member( + self, + test_client: TestClient, + kb_owner_token: str, + knowledge_base: Kind, + ): + """Batch add a namespace entity-type member should succeed.""" + response = test_client.post( + f"/api/share/KnowledgeBase/{knowledge_base.id}/members/batch", + json={ + "members": [ + { + "user_id": 0, + "role": "Reporter", + "entity_type": "namespace", + "entity_id": "42", + }, + ] + }, + headers={"Authorization": f"Bearer {kb_owner_token}"}, + ) + assert response.status_code == 200 + data = response.json() + assert len(data["succeeded"]) == 1 + assert len(data["failed"]) == 0 + member = data["succeeded"][0] + assert member["entity_type"] == "namespace" + assert member["entity_id"] == "42" + assert member["role"] == "Reporter" + + def test_batch_add_namespace_to_own_group_fails( + self, + test_client: TestClient, + test_db: Session, + kb_owner_token: str, + kb_owner: User, + ): + """Sharing a group-native KB back to its own group should fail.""" + from app.models.namespace import Namespace + + ns = Namespace( + name="test-group", + display_name="Test Group", + owner_user_id=kb_owner.id, + visibility="internal", + description="test", + level="group", + is_active=True, + ) + test_db.add(ns) + test_db.commit() + test_db.refresh(ns) + + group_kb = Kind( + user_id=kb_owner.id, + kind="KnowledgeBase", + name="group-kb", + namespace=ns.name, + json={ + "apiVersion": "agent.wecode.io/v1", + "kind": "KnowledgeBase", + "metadata": {"name": "group-kb", "namespace": ns.name}, + "spec": {"description": "Group KB"}, + }, + is_active=True, + ) + test_db.add(group_kb) + test_db.commit() + test_db.refresh(group_kb) + + response = test_client.post( + f"/api/share/KnowledgeBase/{group_kb.id}/members/batch", + json={ + "members": [ + { + "user_id": 0, + "role": "Reporter", + "entity_type": "namespace", + "entity_id": str(ns.id), + }, + ] + }, + headers={"Authorization": f"Bearer {kb_owner_token}"}, + ) + assert response.status_code == 200 + data = response.json() + assert len(data["succeeded"]) == 0 + assert len(data["failed"]) == 1 + assert "own group" in data["failed"][0]["error"].lower() + @pytest.mark.api class TestSingleAddMember: diff --git a/backend/tests/api/test_knowledge_base_initial_members.py b/backend/tests/api/test_knowledge_base_initial_members.py new file mode 100644 index 00000000000..1e0e2673ad1 --- /dev/null +++ b/backend/tests/api/test_knowledge_base_initial_members.py @@ -0,0 +1,204 @@ +# SPDX-FileCopyrightText: 2025 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Integration tests for knowledge base creation with initial members.""" + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.core.security import create_access_token, get_password_hash +from app.models.kind import Kind +from app.models.namespace import Namespace +from app.models.resource_member import ResourceMember +from app.models.user import User + + +@pytest.fixture +def kb_owner(test_db: Session) -> User: + """Create a knowledge base owner user.""" + user = User( + user_name="kb_owner", + password_hash=get_password_hash("ownerpass123"), + email="kb_owner@example.com", + is_active=True, + ) + test_db.add(user) + test_db.commit() + test_db.refresh(user) + return user + + +@pytest.fixture +def kb_owner_token(kb_owner: User) -> str: + """Create JWT token for the KB owner.""" + return create_access_token(data={"sub": kb_owner.user_name}) + + +@pytest.fixture +def target_user(test_db: Session) -> User: + """Create a target user to be added as a member.""" + user = User( + user_name="target_user", + password_hash=get_password_hash("targetpass"), + email="target@example.com", + is_active=True, + ) + test_db.add(user) + test_db.commit() + test_db.refresh(user) + return user + + +@pytest.fixture +def namespace(test_db: Session, kb_owner: User) -> Namespace: + """Create a namespace for entity-type member tests.""" + ns = Namespace( + name="test-ns", + display_name="Test Namespace", + owner_user_id=kb_owner.id, + level="group", + is_active=True, + ) + test_db.add(ns) + test_db.commit() + test_db.refresh(ns) + return ns + + +@pytest.mark.api +class TestKnowledgeBaseInitialMembers: + """Tests for creating a knowledge base with initial members.""" + + def test_create_kb_with_user_members_success( + self, + test_client: TestClient, + kb_owner_token: str, + target_user: User, + ): + """Creating a KB with valid user initial members should succeed.""" + response = test_client.post( + "/api/knowledge-bases", + json={ + "name": "kb-with-members", + "retrieval_config": {"retriever_name": "test"}, + "members": [ + { + "entity_type": "user", + "entity_id": str(target_user.id), + "role": "Developer", + } + ], + }, + headers={"Authorization": f"Bearer {kb_owner_token}"}, + ) + assert response.status_code == 201 + data = response.json() + kb_id = data["id"] + + # Verify member was added + members_resp = test_client.get( + f"/api/share/KnowledgeBase/{kb_id}/members", + headers={"Authorization": f"Bearer {kb_owner_token}"}, + ) + assert members_resp.status_code == 200 + members = members_resp.json()["members"] + assert any( + m["user_id"] == target_user.id and m["role"] == "Developer" for m in members + ) + + def test_create_kb_with_namespace_member_success( + self, + test_client: TestClient, + kb_owner_token: str, + namespace: Namespace, + ): + """Creating a KB with a namespace entity member should succeed.""" + response = test_client.post( + "/api/knowledge-bases", + json={ + "name": "kb-with-ns-member", + "retrieval_config": {"retriever_name": "test"}, + "members": [ + { + "entity_type": "namespace", + "entity_id": str(namespace.id), + "role": "Maintainer", + } + ], + }, + headers={"Authorization": f"Bearer {kb_owner_token}"}, + ) + assert response.status_code == 201 + data = response.json() + kb_id = data["id"] + + # Verify entity member was added + members_resp = test_client.get( + f"/api/share/KnowledgeBase/{kb_id}/members", + headers={"Authorization": f"Bearer {kb_owner_token}"}, + ) + assert members_resp.status_code == 200 + members = members_resp.json()["members"] + assert any( + m["entity_type"] == "namespace" + and m["entity_id"] == str(namespace.id) + and m["role"] == "Maintainer" + for m in members + ) + + def test_create_kb_with_invalid_user_id_fails( + self, + test_client: TestClient, + kb_owner_token: str, + ): + """Creating a KB with an invalid user ID should return 400.""" + response = test_client.post( + "/api/knowledge-bases", + json={ + "name": "kb-invalid-user", + "retrieval_config": {"retriever_name": "test"}, + "members": [ + { + "entity_type": "user", + "entity_id": "not_a_number", + "role": "Reporter", + } + ], + }, + headers={"Authorization": f"Bearer {kb_owner_token}"}, + ) + assert response.status_code == 400 + assert "Invalid user ID" in response.json()["detail"] + + def test_create_kb_with_duplicate_members_fails( + self, + test_client: TestClient, + kb_owner_token: str, + target_user: User, + ): + """Creating a KB with duplicate initial members should return 400.""" + response = test_client.post( + "/api/knowledge-bases", + json={ + "name": "kb-dup-members", + "retrieval_config": {"retriever_name": "test"}, + "members": [ + { + "entity_type": "user", + "entity_id": str(target_user.id), + "role": "Developer", + }, + { + "entity_type": "user", + "entity_id": str(target_user.id), + "role": "Reporter", + }, + ], + }, + headers={"Authorization": f"Bearer {kb_owner_token}"}, + ) + assert response.status_code == 400 + detail = response.json()["detail"] + assert "Failed to add some members" in detail diff --git a/backend/tests/api/test_skills_api.py b/backend/tests/api/test_skills_api.py index 9327783c66c..9292f849c7b 100644 --- a/backend/tests/api/test_skills_api.py +++ b/backend/tests/api/test_skills_api.py @@ -55,7 +55,8 @@ def _add_group_member( member = ResourceMember( resource_type="Namespace", resource_id=group.id, - user_id=user.id, + entity_type="user", + entity_id=str(user.id), role=role, status=MemberStatus.APPROVED.value, invited_by_user_id=group.owner_user_id, diff --git a/backend/tests/services/adapters/test_team_kinds_copy.py b/backend/tests/services/adapters/test_team_kinds_copy.py index 1e55d3f2c6e..170a6621105 100644 --- a/backend/tests/services/adapters/test_team_kinds_copy.py +++ b/backend/tests/services/adapters/test_team_kinds_copy.py @@ -49,7 +49,8 @@ def _add_group_member( member = ResourceMember( resource_type="Namespace", resource_id=group.id, - user_id=user.id, + entity_type="user", + entity_id=str(user.id), role=role, status=MemberStatus.APPROVED.value, invited_by_user_id=group.owner_user_id, diff --git a/backend/tests/services/knowledge/test_external_entity_permissions.py b/backend/tests/services/knowledge/test_external_entity_permissions.py new file mode 100644 index 00000000000..e40e9a5349d --- /dev/null +++ b/backend/tests/services/knowledge/test_external_entity_permissions.py @@ -0,0 +1,754 @@ +# SPDX-FileCopyrightText: 2026 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for external entity resolver integration in knowledge permissions. + +Covers: +- External entity resolver registration and resolution paths +- get_resource_ids_by_entity batch resolver behavior +- Entity display name snapshot persistence for external types +- ResourceMember factory method and event listener edge cases +- Role merging across user and external entity bindings +""" + +from typing import Optional +from unittest.mock import patch + +import pytest +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from app.core.security import get_password_hash +from app.models.kind import Kind +from app.models.namespace import Namespace +from app.models.resource_member import MemberStatus, ResourceMember, ResourceRole +from app.models.share_link import ResourceType +from app.models.user import User +from app.schemas.knowledge import KnowledgeBaseCreate, ResourceScope +from app.schemas.share import MemberRole +from app.services.group_permission import get_user_groups_with_roles +from app.services.knowledge.knowledge_service import KnowledgeService +from app.services.share import knowledge_share_service +from app.services.share.external_entity_resolver import ( + IExternalEntityResolver, + _external_entity_resolvers, + get_entity_resolver, + register_entity_resolver, +) + + +def _create_user(test_db: Session, username: str, role: str = "user") -> User: + user = User( + user_name=username, + password_hash=get_password_hash(f"{username}-secret"), + email=f"{username}@example.com", + is_active=True, + git_info=None, + role=role, + ) + test_db.add(user) + test_db.commit() + test_db.refresh(user) + return user + + +def _create_namespace( + test_db: Session, + owner: User, + name: str, + level: str = "group", +) -> Namespace: + namespace = Namespace( + name=name, + display_name=name, + owner_user_id=owner.id, + visibility="internal", + description="test namespace", + level=level, + is_active=True, + ) + test_db.add(namespace) + test_db.commit() + test_db.refresh(namespace) + return namespace + + +def _add_namespace_member( + test_db: Session, + namespace: Namespace, + user: User, + role: str, + invited_by_user_id: int, +) -> ResourceMember: + member = ResourceMember( + resource_type="Namespace", + resource_id=namespace.id, + entity_type="user", + entity_id=str(user.id), + role=role, + status=MemberStatus.APPROVED.value, + invited_by_user_id=invited_by_user_id, + share_link_id=0, + reviewed_by_user_id=invited_by_user_id, + copied_resource_id=0, + ) + test_db.add(member) + test_db.commit() + test_db.refresh(member) + return member + + +class MockDepartmentResolver(IExternalEntityResolver): + """Mock resolver for testing external entity permission paths.""" + + def __init__(self, user_dept_map: Optional[dict[int, set[str]]] = None): + self.user_dept_map = user_dept_map or {} + + @property + def requires_display_name_snapshot(self) -> bool: + return True + + def get_display_name(self, db: Session, entity_id: str) -> Optional[str]: + return f"Dept-{entity_id}" + + def match_entity_bindings( + self, + db: Session, + user_id: int, + entity_type: str, + entity_ids: list[str], + user_context: Optional[dict] = None, + ) -> list[str]: + if entity_type != "mock_department": + return [] + user_depts = self.user_dept_map.get(user_id, set()) + return list(user_depts & set(entity_ids)) + + def get_resource_ids_by_entity( + self, + db: Session, + user_id: int, + entity_type: str, + user_context: Optional[dict] = None, + ) -> list[int]: + if entity_type != "mock_department": + return [] + user_depts = self.user_dept_map.get(user_id, set()) + if not user_depts: + return [] + results = ( + db.query(ResourceMember.resource_id) + .filter( + ResourceMember.resource_type == ResourceType.KNOWLEDGE_BASE.value, + ResourceMember.entity_type == "mock_department", + ResourceMember.entity_id.in_(list(user_depts)), + ResourceMember.status == MemberStatus.APPROVED.value, + ) + .all() + ) + return list(set(r.resource_id for r in results)) + + +@pytest.fixture(autouse=True) +def cleanup_resolvers(): + """Clean up mock resolver registrations after each test.""" + original = dict(_external_entity_resolvers) + yield + _external_entity_resolvers.clear() + _external_entity_resolvers.update(original) + + +@pytest.mark.unit +def test_external_entity_resolver_registration() -> None: + """Resolver registry should store and retrieve implementations.""" + register_entity_resolver("mock_department", MockDepartmentResolver) + + resolver = get_entity_resolver("mock_department") + assert resolver is not None + assert isinstance(resolver, MockDepartmentResolver) + + unknown = get_entity_resolver("nonexistent") + assert unknown is None + + +@pytest.mark.unit +def test_external_entity_permission_check_via_share_service(test_db: Session) -> None: + """check_permission should invoke external resolver for entity-type members.""" + owner = _create_user(test_db, "ext-owner") + member = _create_user(test_db, "ext-member") + namespace = _create_namespace(test_db, owner, "ext-space") + _add_namespace_member(test_db, namespace, owner, "Owner", owner.id) + + kb_id = KnowledgeService.create_knowledge_base( + test_db, + owner.id, + KnowledgeBaseCreate(name="ext-kb", namespace="default"), + ) + + # Register resolver: member belongs to dept_1 + register_entity_resolver( + "mock_department", lambda: MockDepartmentResolver({member.id: {"dept_1"}}) + ) + + # Add mock_department entity binding + knowledge_share_service.add_member( + test_db, + resource_id=kb_id, + current_user_id=owner.id, + target_user_id=0, + role=MemberRole.Reporter, + entity_type="mock_department", + entity_id="dept_1", + entity_display_name="OldSnapshot", + ) + + # Member should have access via resolver + has_access = knowledge_share_service.check_permission( + test_db, kb_id, member.id, MemberRole.Reporter + ) + assert has_access is True + + +@pytest.mark.unit +def test_external_entity_permission_denied_when_not_in_dept( + test_db: Session, +) -> None: + """Resolver should deny access when user is not in the department.""" + owner = _create_user(test_db, "ext-deny-owner") + member = _create_user(test_db, "ext-deny-member") + namespace = _create_namespace(test_db, owner, "ext-deny-space") + _add_namespace_member(test_db, namespace, owner, "Owner", owner.id) + + kb_id = KnowledgeService.create_knowledge_base( + test_db, + owner.id, + KnowledgeBaseCreate(name="ext-deny-kb", namespace="default"), + ) + + # Member is NOT in dept_1 + register_entity_resolver( + "mock_department", lambda: MockDepartmentResolver({member.id: {"dept_2"}}) + ) + + knowledge_share_service.add_member( + test_db, + resource_id=kb_id, + current_user_id=owner.id, + target_user_id=0, + role=MemberRole.Reporter, + entity_type="mock_department", + entity_id="dept_1", + ) + + has_access = knowledge_share_service.check_permission( + test_db, kb_id, member.id, MemberRole.Reporter + ) + assert has_access is False + + +@pytest.mark.unit +def test_get_resource_ids_by_entity_batch_resolver_behavior( + test_db: Session, +) -> None: + """get_resource_ids_by_entity should resolve KBs across multiple resolver calls.""" + owner = _create_user(test_db, "batch-owner") + member = _create_user(test_db, "batch-member") + namespace = _create_namespace(test_db, owner, "batch-space") + _add_namespace_member(test_db, namespace, owner, "Owner", owner.id) + + kb_a = KnowledgeService.create_knowledge_base( + test_db, + owner.id, + KnowledgeBaseCreate(name="batch-kb-a", namespace="default"), + ) + kb_b = KnowledgeService.create_knowledge_base( + test_db, + owner.id, + KnowledgeBaseCreate(name="batch-kb-b", namespace="default"), + ) + + # Member belongs to dept_1 and dept_2 + register_entity_resolver( + "mock_department", + lambda: MockDepartmentResolver({member.id: {"dept_1", "dept_2"}}), + ) + + # Bind KBs to different departments + knowledge_share_service.add_member( + test_db, + resource_id=kb_a, + current_user_id=owner.id, + target_user_id=0, + role=MemberRole.Reporter, + entity_type="mock_department", + entity_id="dept_1", + ) + knowledge_share_service.add_member( + test_db, + resource_id=kb_b, + current_user_id=owner.id, + target_user_id=0, + role=MemberRole.Reporter, + entity_type="mock_department", + entity_id="dept_2", + ) + + grouped = KnowledgeService.get_all_knowledge_bases_grouped(test_db, member.id) + accessible_ids = {kb.id for kb in grouped.personal.shared_with_me} + assert kb_a in accessible_ids + assert kb_b in accessible_ids + + +@pytest.mark.unit +def test_entity_display_name_snapshot_persisted_for_external_type( + test_db: Session, +) -> None: + """External entity types should have display_name snapshot persisted.""" + owner = _create_user(test_db, "snapshot-owner") + namespace = _create_namespace(test_db, owner, "snapshot-space") + _add_namespace_member(test_db, namespace, owner, "Owner", owner.id) + + kb_id = KnowledgeService.create_knowledge_base( + test_db, + owner.id, + KnowledgeBaseCreate(name="snapshot-kb", namespace="default"), + ) + + register_entity_resolver("mock_department", MockDepartmentResolver) + + knowledge_share_service.add_member( + test_db, + resource_id=kb_id, + current_user_id=owner.id, + target_user_id=0, + role=MemberRole.Reporter, + entity_type="mock_department", + entity_id="dept_99", + entity_display_name="CustomDeptName", + ) + + member = ( + test_db.query(ResourceMember) + .filter( + ResourceMember.resource_type == "KnowledgeBase", + ResourceMember.resource_id == kb_id, + ResourceMember.entity_type == "mock_department", + ) + .first() + ) + assert member is not None + assert member.entity_display_name == "CustomDeptName" + + # get_members should use the snapshot for external types + members = knowledge_share_service.get_members(test_db, kb_id, owner.id) + dept_member = next( + (m for m in members.members if m.entity_type == "mock_department"), None + ) + assert dept_member is not None + assert dept_member.display_name == "CustomDeptName" + + +@pytest.mark.unit +def test_role_merge_user_and_external_entity(test_db: Session) -> None: + """get_user_kb_permission should return the highest role across user and entity.""" + owner = _create_user(test_db, "merge-ext-owner") + member = _create_user(test_db, "merge-ext-member") + namespace = _create_namespace(test_db, owner, "merge-ext-space") + _add_namespace_member(test_db, namespace, owner, "Owner", owner.id) + + kb_id = KnowledgeService.create_knowledge_base( + test_db, + owner.id, + KnowledgeBaseCreate(name="merge-ext-kb", namespace="default"), + ) + + # Direct user share: Maintainer + knowledge_share_service.add_member( + test_db, + resource_id=kb_id, + current_user_id=owner.id, + target_user_id=member.id, + role=MemberRole.Maintainer, + ) + + # External entity share: Reporter + register_entity_resolver( + "mock_department", lambda: MockDepartmentResolver({member.id: {"dept_1"}}) + ) + knowledge_share_service.add_member( + test_db, + resource_id=kb_id, + current_user_id=owner.id, + target_user_id=0, + role=MemberRole.Reporter, + entity_type="mock_department", + entity_id="dept_1", + ) + + has_access, role, is_creator = knowledge_share_service.get_user_kb_permission( + test_db, kb_id, member.id + ) + assert has_access is True + assert role == ResourceRole.Maintainer.value + assert is_creator is False + + +@pytest.mark.unit +def test_resource_member_factory_method_sets_user_id() -> None: + """ResourceMember.create should sync user_id for user-type members.""" + member = ResourceMember.create( + resource_type="KnowledgeBase", + resource_id=1, + entity_type="user", + entity_id="42", + role=ResourceRole.Developer.value, + status=MemberStatus.APPROVED.value, + ) + assert member.entity_type == "user" + assert member.entity_id == "42" + assert member.user_id == 42 + + +@pytest.mark.unit +def test_resource_member_factory_method_clears_user_id_for_entity() -> None: + """ResourceMember.create should set user_id=None for non-user entity types.""" + member = ResourceMember.create( + resource_type="KnowledgeBase", + resource_id=1, + entity_type="namespace", + entity_id="99", + role=ResourceRole.Reporter.value, + ) + assert member.entity_type == "namespace" + assert member.entity_id == "99" + assert member.user_id is None + + +@pytest.mark.unit +def test_resource_member_factory_requires_entity_id_for_user() -> None: + """ResourceMember.create should reject user-type members without entity_id.""" + with pytest.raises(ValueError, match="entity_id is required"): + ResourceMember.create( + resource_type="KnowledgeBase", + resource_id=1, + entity_type="user", + entity_id=None, + ) + + +@pytest.mark.unit +def test_event_listener_syncs_user_id_on_bulk_insert_bypass(test_db: Session) -> None: + """Factory method provides invariant when event listeners are bypassed. + + SQLAlchemy before_insert events do NOT fire for bulk_insert_mappings. + The factory method ensures user_id is set regardless. + """ + member = ResourceMember.create( + resource_type="KnowledgeBase", + resource_id=1, + entity_type="user", + entity_id="123", + ) + # Verify pre-insert state is consistent + assert member.user_id == 123 + + test_db.add(member) + test_db.commit() + test_db.refresh(member) + + # Verify post-insert state remains consistent + assert member.user_id == 123 + + +@pytest.mark.unit +def test_event_listener_handles_non_numeric_entity_id(test_db: Session) -> None: + """Event listener should gracefully handle entity_id that cannot be cast to int.""" + member = ResourceMember( + resource_type="KnowledgeBase", + resource_id=1, + entity_type="user", + entity_id="not-a-number", + role=ResourceRole.Reporter.value, + status=MemberStatus.APPROVED.value, + invited_by_user_id=0, + share_link_id=0, + reviewed_by_user_id=0, + copied_resource_id=0, + ) + test_db.add(member) + test_db.commit() + test_db.refresh(member) + + assert member.user_id is None + + +@pytest.mark.unit +def test_multiple_external_entity_types_isolated(test_db: Session) -> None: + """Different external entity types should not interfere with each other.""" + owner = _create_user(test_db, "multi-type-owner") + member = _create_user(test_db, "multi-type-member") + namespace = _create_namespace(test_db, owner, "multi-type-space") + _add_namespace_member(test_db, namespace, owner, "Owner", owner.id) + + kb_id = KnowledgeService.create_knowledge_base( + test_db, + owner.id, + KnowledgeBaseCreate(name="multi-type-kb", namespace="default"), + ) + + # Register two different resolvers + class MockTeamResolver(IExternalEntityResolver): + def match_entity_bindings( + self, db, user_id, entity_type, entity_ids, user_context=None + ): + if entity_type == "mock_team" and "team_a" in entity_ids: + return ["team_a"] + return [] + + def get_resource_ids_by_entity( + self, db, user_id, entity_type, user_context=None + ): + if entity_type != "mock_team": + return [] + return [kb_id] + + register_entity_resolver("mock_department", MockDepartmentResolver) + register_entity_resolver("mock_team", MockTeamResolver) + + # Add both entity types to the same KB + knowledge_share_service.add_member( + test_db, + resource_id=kb_id, + current_user_id=owner.id, + target_user_id=0, + role=MemberRole.Reporter, + entity_type="mock_department", + entity_id="dept_1", + ) + knowledge_share_service.add_member( + test_db, + resource_id=kb_id, + current_user_id=owner.id, + target_user_id=0, + role=MemberRole.Reporter, + entity_type="mock_team", + entity_id="team_a", + ) + + members = knowledge_share_service.get_members(test_db, kb_id, owner.id) + entity_members = [m for m in members.members if m.entity_type != "user"] + assert len(entity_members) == 2 + types = {m.entity_type for m in entity_members} + assert types == {"mock_department", "mock_team"} + + +@pytest.mark.unit +def test_entity_shared_kb_appears_in_personal_listings(test_db: Session) -> None: + """Entity-type shared KBs must surface in all three personal-scope list endpoints. + + Regression for Task #19: list_knowledge_bases(scope=PERSONAL), + get_personal_knowledge_bases_grouped, and get_accessible_knowledge all + previously filtered only entity_type='user' ResourceMember rows, so KBs + shared via external entity resolvers (e.g. department bindings) were + missing from chat context selectors and personal groupings. + """ + owner = _create_user(test_db, "entity-list-owner") + member = _create_user(test_db, "entity-list-member") + namespace = _create_namespace(test_db, owner, "entity-list-space") + _add_namespace_member(test_db, namespace, owner, "Owner", owner.id) + + kb_id = KnowledgeService.create_knowledge_base( + test_db, + owner.id, + KnowledgeBaseCreate(name="entity-list-kb", namespace="default"), + ) + + register_entity_resolver( + "mock_department", + lambda: MockDepartmentResolver({member.id: {"dept_1"}}), + ) + + knowledge_share_service.add_member( + test_db, + resource_id=kb_id, + current_user_id=owner.id, + target_user_id=0, + role=MemberRole.Reporter, + entity_type="mock_department", + entity_id="dept_1", + ) + + # 1. list_knowledge_bases(scope=PERSONAL) + personal_list = KnowledgeService.list_knowledge_bases( + test_db, member.id, scope=ResourceScope.PERSONAL + ) + assert kb_id in {kb.id for kb in personal_list} + + # 2. get_personal_knowledge_bases_grouped -> shared_with_me + grouped = KnowledgeService.get_personal_knowledge_bases_grouped(test_db, member.id) + shared_ids = {kb.id for kb in grouped["shared_with_me"]} + assert kb_id in shared_ids + + # 3. get_accessible_knowledge -> personal + accessible = KnowledgeService.get_accessible_knowledge(test_db, member.id) + accessible_ids = {kb.id for kb in accessible.personal} + assert kb_id in accessible_ids + + +@pytest.mark.unit +def test_entity_shared_kb_not_leaked_to_unmatched_user(test_db: Session) -> None: + """Users not matched by the resolver must not see the entity-shared KB.""" + owner = _create_user(test_db, "entity-leak-owner") + member = _create_user(test_db, "entity-leak-member") + outsider = _create_user(test_db, "entity-leak-outsider") + namespace = _create_namespace(test_db, owner, "entity-leak-space") + _add_namespace_member(test_db, namespace, owner, "Owner", owner.id) + + kb_id = KnowledgeService.create_knowledge_base( + test_db, + owner.id, + KnowledgeBaseCreate(name="entity-leak-kb", namespace="default"), + ) + + # Only `member` belongs to dept_1; `outsider` is unmapped. + register_entity_resolver( + "mock_department", + lambda: MockDepartmentResolver({member.id: {"dept_1"}}), + ) + + knowledge_share_service.add_member( + test_db, + resource_id=kb_id, + current_user_id=owner.id, + target_user_id=0, + role=MemberRole.Reporter, + entity_type="mock_department", + entity_id="dept_1", + ) + + personal_list = KnowledgeService.list_knowledge_bases( + test_db, outsider.id, scope=ResourceScope.PERSONAL + ) + assert kb_id not in {kb.id for kb in personal_list} + + grouped = KnowledgeService.get_personal_knowledge_bases_grouped( + test_db, outsider.id + ) + assert kb_id not in {kb.id for kb in grouped["shared_with_me"]} + + accessible = KnowledgeService.get_accessible_knowledge(test_db, outsider.id) + assert kb_id not in {kb.id for kb in accessible.personal} + + +@pytest.mark.unit +def test_unknown_entity_type_rejected(test_db: Session) -> None: + """add_member should reject unregistered entity types.""" + owner = _create_user(test_db, "unknown-type-owner") + kb_id = KnowledgeService.create_knowledge_base( + test_db, + owner.id, + KnowledgeBaseCreate(name="unknown-type-kb", namespace="default"), + ) + + with pytest.raises(HTTPException) as exc_info: + knowledge_share_service.add_member( + test_db, + resource_id=kb_id, + current_user_id=owner.id, + target_user_id=0, + role=MemberRole.Reporter, + entity_type="nonexistent_type", + entity_id="xxx", + ) + assert exc_info.value.status_code == 400 + assert "Unknown entity type" in str(exc_info.value.detail) + + +@pytest.mark.unit +def test_entity_member_removed_permission_downgrade( + test_db: Session, +) -> None: + """Removing an entity member should downgrade user permissions accordingly.""" + owner = _create_user(test_db, "downgrade-owner") + member = _create_user(test_db, "downgrade-member") + + kb_id = KnowledgeService.create_knowledge_base( + test_db, + owner.id, + KnowledgeBaseCreate(name="downgrade-kb", namespace="default"), + ) + + # Grant direct Reporter access + knowledge_share_service.add_member( + test_db, + resource_id=kb_id, + current_user_id=owner.id, + target_user_id=member.id, + role=MemberRole.Reporter, + ) + + # Also grant via mock_department Maintainer + register_entity_resolver( + "mock_department", + lambda: MockDepartmentResolver({member.id: {"dept_1"}}), + ) + knowledge_share_service.add_member( + test_db, + resource_id=kb_id, + current_user_id=owner.id, + target_user_id=0, + role=MemberRole.Maintainer, + entity_type="mock_department", + entity_id="dept_1", + ) + + # Effective role should be Maintainer + _, role, _ = knowledge_share_service.get_user_kb_permission( + test_db, kb_id, member.id + ) + assert role == ResourceRole.Maintainer.value + + # Remove the entity member + members = knowledge_share_service.get_members(test_db, kb_id, owner.id) + entity_member = next( + (m for m in members.members if m.entity_type == "mock_department"), None + ) + assert entity_member is not None + knowledge_share_service.remove_member(test_db, kb_id, entity_member.id, owner.id) + + # Effective role should downgrade to Reporter + _, role_after, _ = knowledge_share_service.get_user_kb_permission( + test_db, kb_id, member.id + ) + assert role_after == ResourceRole.Reporter.value + + +@pytest.mark.unit +def test_batch_add_members_duplicate_handling( + test_db: Session, +) -> None: + """Batch add should deduplicate entries within the same request.""" + owner = _create_user(test_db, "batch-dup-owner") + user_a = _create_user(test_db, "batch-dup-a") + + kb_id = KnowledgeService.create_knowledge_base( + test_db, + owner.id, + KnowledgeBaseCreate(name="batch-dup-kb", namespace="default"), + ) + + # Same user appears twice in the batch + result = knowledge_share_service.batch_add_members( + test_db, + resource_id=kb_id, + current_user_id=owner.id, + members_data=[ + (user_a.id, MemberRole.Developer, None, None, None), + (user_a.id, MemberRole.Reporter, None, None, None), + ], + ) + + assert len(result.succeeded) == 1 + assert len(result.failed) == 1 + assert result.failed[0].error == "Duplicate entry in request" diff --git a/backend/tests/services/knowledge/test_namespace_knowledge_permissions.py b/backend/tests/services/knowledge/test_namespace_knowledge_permissions.py index 3730673b1b9..a981082b775 100644 --- a/backend/tests/services/knowledge/test_namespace_knowledge_permissions.py +++ b/backend/tests/services/knowledge/test_namespace_knowledge_permissions.py @@ -71,7 +71,8 @@ def _add_member( member = ResourceMember( resource_type="Namespace", resource_id=namespace.id, - user_id=user.id, + entity_type="user", + entity_id=str(user.id), role=role.value, status=MemberStatus.APPROVED.value, invited_by_user_id=invited_by_user_id, @@ -106,7 +107,8 @@ def _add_kb_member( member = ResourceMember( resource_type="KnowledgeBase", resource_id=knowledge_base_id, - user_id=user.id, + entity_type="user", + entity_id=str(user.id), role=role.value, status=status, invited_by_user_id=invited_by_user_id, @@ -848,6 +850,278 @@ def test_owner_can_migrate_personal_knowledge_base_to_group( assert migrated_kb.namespace == namespace.name +@pytest.mark.unit +def test_namespace_display_name_syncs_after_rename(test_db: Session) -> None: + """KB permission tab should show the latest namespace name, not stale snapshot.""" + from app.schemas.share import MemberRole + + owner = _create_user(test_db, "ns-rename-owner") + namespace = _create_namespace(test_db, owner, "ns-rename-group") + _add_member(test_db, namespace, owner, GroupRole.Owner, owner.id) + + # Use a personal KB (namespace=default) so adding namespace permission + # does not trigger the "own group" protection. + knowledge_base_id = KnowledgeService.create_knowledge_base( + test_db, + owner.id, + KnowledgeBaseCreate(name="ns-rename-kb", namespace="default"), + ) + + # Add namespace permission with an old snapshot + knowledge_share_service.add_member( + test_db, + resource_id=knowledge_base_id, + current_user_id=owner.id, + target_user_id=0, + role=MemberRole.Reporter, + entity_type="namespace", + entity_id=str(namespace.id), + entity_display_name="OldSnapshotName", + ) + + # Rename the namespace + namespace.display_name = "NewRenamedName" + test_db.commit() + + # get_members should return the live name, not the snapshot + members = knowledge_share_service.get_members(test_db, knowledge_base_id, owner.id) + namespace_members = [m for m in members.members if m.entity_type == "namespace"] + assert len(namespace_members) == 1 + assert namespace_members[0].display_name == "NewRenamedName" + + +@pytest.mark.unit +def test_namespace_snapshot_is_suppressed(test_db: Session) -> None: + """add_member should ignore entity_display_name for namespace entries.""" + from app.schemas.share import MemberRole + + owner = _create_user(test_db, "ns-snapshot-owner") + namespace = _create_namespace(test_db, owner, "ns-snapshot-group") + _add_member(test_db, namespace, owner, GroupRole.Owner, owner.id) + + # Use a personal KB (namespace=default) so adding namespace permission + # does not trigger the "own group" protection. + knowledge_base_id = KnowledgeService.create_knowledge_base( + test_db, + owner.id, + KnowledgeBaseCreate(name="ns-snapshot-kb", namespace="default"), + ) + + knowledge_share_service.add_member( + test_db, + resource_id=knowledge_base_id, + current_user_id=owner.id, + target_user_id=0, + role=MemberRole.Reporter, + entity_type="namespace", + entity_id=str(namespace.id), + entity_display_name="ShouldBeIgnored", + ) + + member = ( + test_db.query(ResourceMember) + .filter( + ResourceMember.resource_type == "KnowledgeBase", + ResourceMember.resource_id == knowledge_base_id, + ResourceMember.entity_type == "namespace", + ResourceMember.entity_id == str(namespace.id), + ) + .first() + ) + assert member is not None + assert member.entity_display_name is None + + +@pytest.mark.unit +def test_kb_shown_in_all_target_groups_via_namespace_entity( + test_db: Session, +) -> None: + """KB shared to multiple groups via namespace entity should appear in all target groups.""" + owner = _create_user(test_db, "multi-group-owner") + group_a = _create_namespace(test_db, owner, "group-a") + group_b = _create_namespace(test_db, owner, "group-b") + member = _create_user(test_db, "multi-group-member") + + _add_member(test_db, group_a, owner, GroupRole.Owner, owner.id) + _add_member(test_db, group_b, owner, GroupRole.Owner, owner.id) + _add_member(test_db, group_a, member, GroupRole.Developer, owner.id) + _add_member(test_db, group_b, member, GroupRole.Developer, owner.id) + + # Create a personal KB and share it to both groups via namespace entity + knowledge_base_id = KnowledgeService.create_knowledge_base( + test_db, + owner.id, + KnowledgeBaseCreate(name="shared-to-both", namespace="default"), + ) + + from app.schemas.share import MemberRole + + knowledge_share_service.add_member( + test_db, + resource_id=knowledge_base_id, + current_user_id=owner.id, + target_user_id=0, + role=MemberRole.Reporter, + entity_type="namespace", + entity_id=str(group_a.id), + ) + knowledge_share_service.add_member( + test_db, + resource_id=knowledge_base_id, + current_user_id=owner.id, + target_user_id=0, + role=MemberRole.Reporter, + entity_type="namespace", + entity_id=str(group_b.id), + ) + + grouped = KnowledgeService.get_all_knowledge_bases_grouped(test_db, member.id) + + group_names_with_kb = { + g.group_name + for g in grouped.groups + if any(kb.id == knowledge_base_id for kb in g.knowledge_bases) + } + assert "group-a" in group_names_with_kb + assert "group-b" in group_names_with_kb + + +@pytest.mark.unit +def test_get_user_kb_permission_merges_multiple_entity_roles( + test_db: Session, +) -> None: + """get_user_kb_permission should return the highest role across all entity sources.""" + owner = _create_user(test_db, "merge-owner") + group_a = _create_namespace(test_db, owner, "merge-group-a") + member = _create_user(test_db, "merge-member") + + _add_member(test_db, group_a, owner, GroupRole.Owner, owner.id) + _add_member(test_db, group_a, member, GroupRole.Developer, owner.id) + + knowledge_base_id = KnowledgeService.create_knowledge_base( + test_db, + owner.id, + KnowledgeBaseCreate(name="merge-kb", namespace="default"), + ) + + from app.schemas.share import MemberRole + + # Share with Reporter role via namespace entity + knowledge_share_service.add_member( + test_db, + resource_id=knowledge_base_id, + current_user_id=owner.id, + target_user_id=0, + role=MemberRole.Reporter, + entity_type="namespace", + entity_id=str(group_a.id), + ) + # Also share with Maintainer role via direct user entity + knowledge_share_service.add_member( + test_db, + resource_id=knowledge_base_id, + current_user_id=owner.id, + target_user_id=member.id, + role=MemberRole.Maintainer, + ) + + has_access, role, is_creator = knowledge_share_service.get_user_kb_permission( + test_db, knowledge_base_id, member.id + ) + + assert has_access is True + assert role == ResourceRole.Maintainer.value + assert is_creator is False + + +@pytest.mark.unit +def test_permission_sources_consistent_with_user_kb_permission( + test_db: Session, +) -> None: + """get_my_permission_sources and get_user_kb_permission should return the same effective_role.""" + owner = _create_user(test_db, "consistent-owner") + group_a = _create_namespace(test_db, owner, "consistent-group-a") + member = _create_user(test_db, "consistent-member") + + _add_member(test_db, group_a, owner, GroupRole.Owner, owner.id) + _add_member(test_db, group_a, member, GroupRole.Developer, owner.id) + + knowledge_base_id = KnowledgeService.create_knowledge_base( + test_db, + owner.id, + KnowledgeBaseCreate(name="consistent-kb", namespace="default"), + ) + + from app.schemas.share import MemberRole + + knowledge_share_service.add_member( + test_db, + resource_id=knowledge_base_id, + current_user_id=owner.id, + target_user_id=0, + role=MemberRole.Reporter, + entity_type="namespace", + entity_id=str(group_a.id), + ) + + _, role_from_permission, _ = knowledge_share_service.get_user_kb_permission( + test_db, knowledge_base_id, member.id + ) + sources = knowledge_share_service.get_my_permission_sources( + test_db, knowledge_base_id, member.id + ) + + assert role_from_permission == sources.effective_role + + +@pytest.mark.unit +def test_get_resource_checks_all_entity_records( + test_db: Session, +) -> None: + """_get_resource should check all entity records, not just the first one.""" + owner = _create_user(test_db, "resource-owner") + group_a = _create_namespace(test_db, owner, "resource-group-a") + group_b = _create_namespace(test_db, owner, "resource-group-b") + member = _create_user(test_db, "resource-member") + + _add_member(test_db, group_a, owner, GroupRole.Owner, owner.id) + _add_member(test_db, group_b, owner, GroupRole.Owner, owner.id) + _add_member(test_db, group_b, member, GroupRole.Developer, owner.id) + + knowledge_base_id = KnowledgeService.create_knowledge_base( + test_db, + owner.id, + KnowledgeBaseCreate(name="resource-kb", namespace="default"), + ) + + from app.schemas.share import MemberRole + + # Member is NOT in group_a, but IS in group_b + knowledge_share_service.add_member( + test_db, + resource_id=knowledge_base_id, + current_user_id=owner.id, + target_user_id=0, + role=MemberRole.Reporter, + entity_type="namespace", + entity_id=str(group_a.id), + ) + knowledge_share_service.add_member( + test_db, + resource_id=knowledge_base_id, + current_user_id=owner.id, + target_user_id=0, + role=MemberRole.Reporter, + entity_type="namespace", + entity_id=str(group_b.id), + ) + + # _get_resource should find the group_b match even if group_a was queried first + kb = knowledge_share_service._get_resource(test_db, knowledge_base_id, member.id) + assert kb is not None + assert kb.id == knowledge_base_id + + @pytest.mark.unit def test_delete_knowledge_base_removes_orphaned_folders(test_db: Session) -> None: """Deleting a knowledge base must also delete all its folders to prevent orphaned records.""" diff --git a/backend/tests/services/knowledge/test_permission_policy.py b/backend/tests/services/knowledge/test_permission_policy.py index a5833c3a5ac..5c49c5ddf05 100644 --- a/backend/tests/services/knowledge/test_permission_policy.py +++ b/backend/tests/services/knowledge/test_permission_policy.py @@ -67,7 +67,8 @@ def _add_member( member = ResourceMember( resource_type="Namespace", resource_id=namespace.id, - user_id=user.id, + entity_type="user", + entity_id=str(user.id), role=role.value, status=MemberStatus.APPROVED.value, invited_by_user_id=invited_by_user_id, diff --git a/backend/tests/services/share/test_check_entity_permission.py b/backend/tests/services/share/test_check_entity_permission.py new file mode 100644 index 00000000000..346bd9c862c --- /dev/null +++ b/backend/tests/services/share/test_check_entity_permission.py @@ -0,0 +1,195 @@ +# SPDX-FileCopyrightText: 2025 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for check_entity_permission with bool resolver semantics.""" + +import pytest +from sqlalchemy.orm import Session + +from app.core.security import get_password_hash +from app.models.kind import Kind +from app.models.namespace import Namespace +from app.models.resource_member import MemberStatus, ResourceMember, ResourceRole +from app.models.share_link import ResourceType +from app.models.user import User +from app.schemas.base_role import BaseRole +from app.services.share.base_service import UnifiedShareService +from app.services.share.external_entity_resolver import register_entity_resolver +from app.services.share.namespace_entity_resolver import NamespaceEntityResolver + + +class DummyShareService(UnifiedShareService): + """Minimal subclass for testing base permission logic.""" + + def _get_resource(self, db, resource_id, user_id): + return db.query(Kind).filter(Kind.id == resource_id).first() + + def _get_resource_name(self, resource): + return resource.json.get("spec", {}).get("name", "") + + def _get_resource_owner_id(self, resource): + return resource.user_id + + def _get_share_url_base(self): + return "http://test" + + def _on_member_approved(self, db, member, resource): + return None + + +def _create_user(test_db: Session, username: str) -> User: + user = User( + user_name=username, + password_hash=get_password_hash(f"{username}-pass"), + email=f"{username}@example.com", + is_active=True, + role="user", + ) + test_db.add(user) + test_db.commit() + test_db.refresh(user) + return user + + +def _create_namespace(test_db: Session, owner: User, name: str) -> Namespace: + ns = Namespace( + name=name, + display_name=name, + owner_user_id=owner.id, + visibility="internal", + description="test", + level="group", + is_active=True, + ) + test_db.add(ns) + test_db.commit() + test_db.refresh(ns) + return ns + + +def _add_ns_member( + test_db: Session, namespace: Namespace, user: User, role: str +) -> ResourceMember: + member = ResourceMember( + resource_type="Namespace", + resource_id=namespace.id, + entity_type="user", + entity_id=str(user.id), + role=role, + status=MemberStatus.APPROVED.value, + invited_by_user_id=namespace.owner_user_id, + share_link_id=0, + reviewed_by_user_id=namespace.owner_user_id, + copied_resource_id=0, + ) + test_db.add(member) + test_db.commit() + test_db.refresh(member) + return member + + +def _create_kb(test_db: Session, owner: User, name: str, ns: str = "default") -> Kind: + kb = Kind( + name=name, + namespace=ns, + kind="KnowledgeBase", + user_id=owner.id, + is_active=True, + json={"spec": {"name": name}}, + ) + test_db.add(kb) + test_db.commit() + test_db.refresh(kb) + return kb + + +def _add_kb_entity_member( + test_db: Session, kb_id: int, entity_id: str, role: str +) -> ResourceMember: + member = ResourceMember( + resource_type=ResourceType.KNOWLEDGE_BASE.value, + resource_id=kb_id, + entity_type="namespace", + entity_id=entity_id, + role=role, + status=MemberStatus.APPROVED.value, + invited_by_user_id=0, + share_link_id=0, + reviewed_by_user_id=0, + copied_resource_id=0, + ) + test_db.add(member) + test_db.commit() + test_db.refresh(member) + return member + + +class TestCheckEntityPermission: + def test_entity_binding_with_sufficient_role(self, test_db: Session): + owner = _create_user(test_db, "owner") + user = _create_user(test_db, "user") + ns = _create_namespace(test_db, owner, "ns1") + _add_ns_member(test_db, ns, user, ResourceRole.Maintainer.value) + kb = _create_kb(test_db, owner, "kb1") + _add_kb_entity_member(test_db, kb.id, str(ns.id), ResourceRole.Maintainer.value) + + service = DummyShareService(ResourceType.KNOWLEDGE_BASE) + result = service.check_entity_permission( + test_db, kb.id, user.id, BaseRole.Maintainer + ) + assert result is True + + def test_entity_binding_with_insufficient_role(self, test_db: Session): + owner = _create_user(test_db, "owner") + user = _create_user(test_db, "user") + ns = _create_namespace(test_db, owner, "ns1") + _add_ns_member(test_db, ns, user, ResourceRole.Reporter.value) + kb = _create_kb(test_db, owner, "kb1") + _add_kb_entity_member(test_db, kb.id, str(ns.id), ResourceRole.Reporter.value) + + service = DummyShareService(ResourceType.KNOWLEDGE_BASE) + result = service.check_entity_permission( + test_db, kb.id, user.id, BaseRole.Maintainer + ) + assert result is False + + def test_no_matching_entity_binding(self, test_db: Session): + owner = _create_user(test_db, "owner") + user = _create_user(test_db, "user") + ns = _create_namespace(test_db, owner, "ns1") + # user is NOT a member of ns + kb = _create_kb(test_db, owner, "kb1") + _add_kb_entity_member(test_db, kb.id, str(ns.id), ResourceRole.Maintainer.value) + + service = DummyShareService(ResourceType.KNOWLEDGE_BASE) + result = service.check_entity_permission( + test_db, kb.id, user.id, BaseRole.Reporter + ) + assert result is False + + def test_multiple_bindings_only_one_matches(self, test_db: Session): + """User matches ns1 (Reporter) but not ns2 (Owner). Should get Reporter.""" + owner = _create_user(test_db, "owner") + user = _create_user(test_db, "user") + ns1 = _create_namespace(test_db, owner, "ns1") + ns2 = _create_namespace(test_db, owner, "ns2") + _add_ns_member(test_db, ns1, user, ResourceRole.Reporter.value) + # user is NOT a member of ns2 + kb = _create_kb(test_db, owner, "kb1") + _add_kb_entity_member(test_db, kb.id, str(ns1.id), ResourceRole.Reporter.value) + _add_kb_entity_member(test_db, kb.id, str(ns2.id), ResourceRole.Owner.value) + + service = DummyShareService(ResourceType.KNOWLEDGE_BASE) + # Reporter should NOT satisfy Maintainer requirement + assert ( + service.check_entity_permission( + test_db, kb.id, user.id, BaseRole.Maintainer + ) + is False + ) + # Reporter SHOULD satisfy Reporter requirement + assert ( + service.check_entity_permission(test_db, kb.id, user.id, BaseRole.Reporter) + is True + ) diff --git a/backend/tests/services/share/test_namespace_entity_resolver.py b/backend/tests/services/share/test_namespace_entity_resolver.py new file mode 100644 index 00000000000..4ead0b79760 --- /dev/null +++ b/backend/tests/services/share/test_namespace_entity_resolver.py @@ -0,0 +1,194 @@ +# SPDX-FileCopyrightText: 2025 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for NamespaceEntityResolver.""" + +import pytest +from sqlalchemy.orm import Session + +from app.core.security import get_password_hash +from app.models.kind import Kind +from app.models.namespace import Namespace +from app.models.resource_member import MemberStatus, ResourceMember, ResourceRole +from app.models.share_link import ResourceType +from app.models.user import User +from app.schemas.namespace import GroupRole +from app.services.share.namespace_entity_resolver import NamespaceEntityResolver + +resolver = NamespaceEntityResolver() + + +def _create_user(test_db: Session, username: str) -> User: + user = User( + user_name=username, + password_hash=get_password_hash(f"{username}-pass"), + email=f"{username}@example.com", + is_active=True, + role="user", + ) + test_db.add(user) + test_db.commit() + test_db.refresh(user) + return user + + +def _create_namespace(test_db: Session, owner: User, name: str) -> Namespace: + ns = Namespace( + name=name, + display_name=name, + owner_user_id=owner.id, + visibility="internal", + description="test", + level="group", + is_active=True, + ) + test_db.add(ns) + test_db.commit() + test_db.refresh(ns) + return ns + + +def _add_ns_member( + test_db: Session, + namespace: Namespace, + user: User, + role: str, +) -> ResourceMember: + member = ResourceMember( + resource_type="Namespace", + resource_id=namespace.id, + entity_type="user", + entity_id=str(user.id), + role=role, + status=MemberStatus.APPROVED.value, + invited_by_user_id=namespace.owner_user_id, + share_link_id=0, + reviewed_by_user_id=namespace.owner_user_id, + copied_resource_id=0, + ) + test_db.add(member) + test_db.commit() + test_db.refresh(member) + return member + + +def _add_kb_entity_member( + test_db: Session, + kb_id: int, + entity_id: str, + role: str = ResourceRole.Reporter.value, +) -> ResourceMember: + member = ResourceMember( + resource_type=ResourceType.KNOWLEDGE_BASE.value, + resource_id=kb_id, + entity_type="namespace", + entity_id=entity_id, + role=role, + status=MemberStatus.APPROVED.value, + invited_by_user_id=0, + share_link_id=0, + reviewed_by_user_id=0, + copied_resource_id=0, + ) + test_db.add(member) + test_db.commit() + test_db.refresh(member) + return member + + +class TestMatchEntityBindings: + def test_match_single_namespace_returns_matched_id(self, test_db: Session): + owner = _create_user(test_db, "owner1") + user = _create_user(test_db, "user1") + ns = _create_namespace(test_db, owner, "ns1") + _add_ns_member(test_db, ns, user, ResourceRole.Maintainer.value) + + result = resolver.match_entity_bindings( + test_db, user.id, "namespace", [str(ns.id)] + ) + assert result == [str(ns.id)] + + def test_match_multiple_namespaces_returns_matched_ids(self, test_db: Session): + owner = _create_user(test_db, "owner2") + user = _create_user(test_db, "user2") + ns1 = _create_namespace(test_db, owner, "ns1") + ns2 = _create_namespace(test_db, owner, "ns2") + _add_ns_member(test_db, ns1, user, ResourceRole.Reporter.value) + _add_ns_member(test_db, ns2, user, ResourceRole.Maintainer.value) + + result = resolver.match_entity_bindings( + test_db, user.id, "namespace", [str(ns1.id), str(ns2.id)] + ) + assert set(result) == {str(ns1.id), str(ns2.id)} + + def test_no_match_returns_empty_list(self, test_db: Session): + owner = _create_user(test_db, "owner3") + user = _create_user(test_db, "user3") + ns = _create_namespace(test_db, owner, "ns3") + # user is NOT a member of ns + + result = resolver.match_entity_bindings( + test_db, user.id, "namespace", [str(ns.id)] + ) + assert result == [] + + def test_non_namespace_entity_returns_empty_list(self, test_db: Session): + result = resolver.match_entity_bindings(test_db, 1, "org_department", ["dept1"]) + assert result == [] + + def test_empty_entity_ids_returns_empty_list(self, test_db: Session): + result = resolver.match_entity_bindings(test_db, 1, "namespace", []) + assert result == [] + + +class TestGetResourceIdsByEntity: + def test_returns_kb_ids_for_member_namespaces(self, test_db: Session): + owner = _create_user(test_db, "owner4") + user = _create_user(test_db, "user4") + ns = _create_namespace(test_db, owner, "ns4") + _add_ns_member(test_db, ns, user, ResourceRole.Reporter.value) + + kb = Kind( + name="kb1", + namespace="default", + kind="KnowledgeBase", + user_id=owner.id, + is_active=True, + json={}, + ) + test_db.add(kb) + test_db.commit() + test_db.refresh(kb) + + _add_kb_entity_member(test_db, kb.id, str(ns.id)) + + result = resolver.get_resource_ids_by_entity(test_db, user.id, "namespace") + assert result == [kb.id] + + def test_returns_empty_for_non_member(self, test_db: Session): + owner = _create_user(test_db, "owner5") + user = _create_user(test_db, "user5") + ns = _create_namespace(test_db, owner, "ns5") + # user is NOT a member + + kb = Kind( + name="kb2", + namespace="default", + kind="KnowledgeBase", + user_id=owner.id, + is_active=True, + json={}, + ) + test_db.add(kb) + test_db.commit() + test_db.refresh(kb) + + _add_kb_entity_member(test_db, kb.id, str(ns.id)) + + result = resolver.get_resource_ids_by_entity(test_db, user.id, "namespace") + assert result == [] + + def test_non_namespace_entity_returns_empty(self, test_db: Session): + result = resolver.get_resource_ids_by_entity(test_db, 1, "org_department") + assert result == [] diff --git a/backend/tests/services/test_task_member_service_group_chat.py b/backend/tests/services/test_task_member_service_group_chat.py index fc14419a378..0a62fbcf183 100644 --- a/backend/tests/services/test_task_member_service_group_chat.py +++ b/backend/tests/services/test_task_member_service_group_chat.py @@ -97,6 +97,8 @@ def test_is_member_includes_real_group_chat_members( mock_member.id = 1 mock_member.resource_type = ResourceType.TASK mock_member.resource_id = 100 + mock_member.entity_type = "user" + mock_member.entity_id = "2" mock_member.user_id = 2 mock_member.status = MemberStatus.APPROVED mock_member.copied_resource_id = 0 # Real group chat member @@ -168,6 +170,8 @@ def test_get_members_excludes_share_records(self, task_member_service, mock_db): # Mock a real group chat member (not a share record) mock_member = Mock(spec=ResourceMember) mock_member.id = 1 + mock_member.entity_type = "user" + mock_member.entity_id = "2" mock_member.user_id = 2 mock_member.invited_by_user_id = 1 mock_member.requested_at = "2025-01-02" diff --git a/docs/en/developer-guide/entity-permission-extension.md b/docs/en/developer-guide/entity-permission-extension.md new file mode 100644 index 00000000000..3ae03959cc1 --- /dev/null +++ b/docs/en/developer-guide/entity-permission-extension.md @@ -0,0 +1,418 @@ +--- +sidebar_position: 13 +--- + +# Entity Permission Extension + +This document describes Wegent's unified resource sharing permission architecture, covering the current authorization mechanism, permission processing logic, extension architecture design, and how to integrate internal customized authorization systems via `IExternalEntityResolver`. + +## Overview + +Wegent's permission system is implemented based on the `ResourceMember` model and supports two member binding modes: + +- **Direct User Binding** (`entity_type="user"`): Add a specific user as a resource member +- **Entity Binding** (`entity_type="namespace"` or custom types): Bind an external entity (e.g., group, department) to a resource, and members within the entity automatically inherit access permissions + +The core extension point for entity binding is the `IExternalEntityResolver` interface. The open-source version includes a built-in `namespace` type resolver, while internal deployments can register custom resolvers to integrate with enterprise organizational structures (e.g., departments, project teams). + +## Current Authorization Mechanism + +### Unified Role System + +The system uses `BaseRole` as the single source of truth for role definitions: + +| Role | Permission Level | Description | +|------|-----------------|-------------| +| **Owner** | Highest | Full control, can delete resources and transfer ownership | +| **Maintainer** | High | Manage members and settings | +| **Developer** | Medium | Modify content | +| **Reporter** | Low | Read-only access | +| **RestrictedAnalyst** | Lowest | Restricted read-only access | + +When role conflicts occur, the system automatically selects the **highest permission** role. This logic is implemented by `has_permission()` and `get_highest_role()`. + +### Member Status + +`ResourceMember.status` has three states: + +- `pending`: Awaiting approval +- `approved`: Approved (only approved members participate in permission calculations) +- `rejected`: Rejected + +### Resource Member Model + +``` +ResourceMember +├── resource_type: str # Resource type, e.g., "KnowledgeBase" +├── resource_id: int # Resource ID +├── entity_type: str # "user" | "namespace" | custom type +├── entity_id: str # Entity identifier +├── role: str # Role value +├── status: str # pending | approved | rejected +└── user_id: int | None # Compatibility field, auto-synced for user type +``` + +Unique constraint: `(resource_type, resource_id, entity_type, entity_id)` + +### Ownership vs Authorization + +The permission system has two core concepts that are easily confused and must be clearly distinguished: + +| Dimension | Ownership | Authorization | +|-----------|-----------|---------------| +| **Determination basis** | Resource's `user_id` field (e.g., `Kind.user_id`) | `role` field in `ResourceMember` records | +| **Typical example** | Creator of the knowledge base | A user added as a member and assigned the Owner role | +| **Database representation** | A field in the resource table row | A record in the `resource_members` table | +| **Permission source** | Naturally has full access without `ResourceMember` records | Must be obtained through member binding | + +**Key distinctions:** + +1. **Creator is not equal to Owner in ResourceMember** + - Creator is determined by `kb.user_id == user_id` + - `ResourceMember(role="Owner")` is an authorized member, not necessarily the creator + - The creator always has full permissions even without any `ResourceMember` records + +2. **Both are independent sources during permission computation** + - In `_compute_kb_access_core()`, `is_creator` and `roles` (from ResourceMember) are **computed separately** + - Final result: `has_access = len(roles) > 0 or is_creator` + - Even without any authorization records, the creator always has access + +3. **Ownership transfer only changes ownership, not authorization records** + - After transfer, the new owner's `user_id` is written to the resource table + - Old `ResourceMember` records are not automatically deleted + - If the old creator also has a `ResourceMember(role="Owner")` record, it remains valid after transfer + +4. **Distinction in frontend display** + - `PermissionSourceInfo.source_type="creator"` indicates ownership source + - `PermissionSourceInfo.source_type="direct"` indicates direct authorization source + - The creator also appears as one of the permission sources in the return value of `get_my_permission_sources()` + +## Permission Processing Logic + +### Direct Permission Check Flow + +The call chain of `UnifiedShareService.check_permission()`: + +``` +check_permission(resource_id, user_id, required_role) +├── 1. Query ResourceMember +│ resource_type = {resource_type} +│ entity_type = "user" +│ entity_id = str(user_id) +│ status = "approved" +│ → Found direct binding +│ → Check with has_permission(effective_role, required_role) +│ → If satisfied, return True +│ +└── 2. No direct binding found → Fallback to check_entity_permission() +``` + +### Entity Permission Fallback Flow + +The call chain of `check_entity_permission()`: + +``` +check_entity_permission(resource_id, user_id, required_role) +├── 1. Query all approved bindings with entity_type != "user" for this resource +│ Group by entity_type: {entity_type: [(entity_id, role), ...]} +│ +├── 2. Iterate each entity_type +│ resolver = get_entity_resolver(entity_type) +│ matched = resolver.match_entity_bindings(db, user_id, entity_type, entity_ids) +│ → If matched is not empty +│ → Check if the role corresponding to each matched entity_id satisfies required_role +│ → If satisfied, return True +│ +└── 3. No match → Return False +``` + +### Role Conflict Resolution + +When a user has both direct binding and entity binding, `get_user_role()` handles it as follows: + +``` +get_user_role(resource_id, user_id) +├── direct_role = Role from direct user binding +│ If direct_role == "Owner" → Return Owner directly +│ +├── entity_role = _get_highest_entity_role() +│ Iterate all entity bindings +│ Call match_entity_bindings() for each entity_type +│ Take the highest role among matched entities +│ +└── If both direct_role and entity_role exist + → Return the higher permission of the two +``` + +### Entity Permissions in List Queries + +When `KnowledgeService.get_all_knowledge_bases_grouped()` retrieves the knowledge base list, it also needs to get knowledge bases accessible via entity binding. This logic is implemented by `_collect_entity_authorized_kbs()`: + +``` +_collect_entity_authorized_kbs(user_id, accessible_groups) +├── Step 1: Handle namespace type (hardcoded optimization path) +│ Convert accessible_groups to namespace IDs +│ Query ResourceMember (entity_type="namespace", entity_id in namespace_ids) +│ Collect these resources and their group, role information +│ +└── Step 2: Handle external entity types (via resolvers) + Iterate all registered entity_types (excluding "namespace", "user") + resolver = get_entity_resolver(entity_type) + resolved_kb_ids = resolver.get_resource_ids_by_entity(db, user_id, entity_type) + → Query ResourceMember rows corresponding to these KBs + → Filter out entity_ids that the user actually matches using match_entity_bindings() + → Collect role and group information +``` + +## Extension Architecture Design + +### Share Service Layered Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ API Layer │ +│ - /api/v1/share/members (add/remove/get members) │ +│ - /api/v1/knowledge (list with permissions) │ +└─────────────────────────────────────────────────────────────┘ + │ +┌─────────────────────────────────────────────────────────────┐ +│ Service Layer │ +│ UnifiedShareService (base_service.py) │ +│ ├── check_permission() / check_entity_permission() │ +│ ├── get_user_role() / _get_highest_entity_role() │ +│ ├── add_member() / remove_member() / get_members() │ +│ └── get_my_permission_sources() │ +│ │ +│ KnowledgeShareService (knowledge_share_service.py) │ +│ └── Knowledge base specific permission logic │ +└─────────────────────────────────────────────────────────────┘ + │ +┌─────────────────────────────────────────────────────────────┐ +│ Resolver Layer │ +│ IExternalEntityResolver (external_entity_resolver.py) │ +│ ├── NamespaceEntityResolver (namespace_entity_resolver.py) │ +│ └── [Custom Resolvers] Registered via register_entity_resolver() │ +└─────────────────────────────────────────────────────────────┘ + │ +┌─────────────────────────────────────────────────────────────┐ +│ Model Layer │ +│ ResourceMember (resource_member.py) │ +│ └── Polymorphic: (entity_type, entity_id) │ +└─────────────────────────────────────────────────────────────┘ +``` + +### IExternalEntityResolver Interface + +```python +class IExternalEntityResolver(ABC): + @abstractmethod + def match_entity_bindings( + self, db, user_id, entity_type, entity_ids, user_context=None + ) -> list[str]: + """Return the list of entity IDs from entity_ids that the user actually matches""" + + @abstractmethod + def get_resource_ids_by_entity( + self, db, user_id, entity_type, user_context=None + ) -> list[int]: + """Return all resource IDs accessible by the user via this entity type""" + + @property + def requires_display_name_snapshot(self) -> bool: + """Whether to persist display name snapshots (default True)""" + return True + + def get_display_name(self, db, entity_id) -> Optional[str]: + """Resolve display name for a single entity""" + return None + + def batch_get_display_names(self, db, entity_ids) -> dict[str, str]: + """Batch resolve display names (defaults to individual get_display_name calls)""" +``` + +### Resolver Registration Mechanism + +Resolvers are managed through a module-level registry and registered at application startup: + +```python +# app/services/share/__init__.py +register_entity_resolver("namespace", NamespaceEntityResolver) +# register_entity_resolver("department", DepartmentResolver) # Custom +``` + +After registration: +- `get_entity_resolver("namespace")` returns a singleton instance +- `get_all_entity_types()` returns the list of all registered types +- Instances are cached; repeated calls return the same object + +## Extension Mechanism + +### Built-in Resolver: NamespaceEntityResolver + +`NamespaceEntityResolver` handles bindings with `entity_type="namespace"`. Core logic: + +**match_entity_bindings()**: +- Input: `entity_ids` = List of bound namespace IDs (strings) +- Query `ResourceMember` (resource_type="Namespace", entity_type="user", entity_id=str(user_id), status="approved") +- Return the subset of namespace IDs that the user actually belongs to + +**get_resource_ids_by_entity()**: +- First query which namespaces the user belongs to +- Then query which KnowledgeBases are bound to those namespaces +- Return deduplicated KB ID list + +**requires_display_name_snapshot**: `False` +- Namespace names can be queried in real-time from the local `Namespace` table, no need to persist snapshots + +### Custom Resolver Implementation Steps + +Implementing a custom resolver requires the following steps: + +**Step 1: Create the resolver class** + +```python +# app/services/share/department_resolver.py +from typing import Optional +from sqlalchemy.orm import Session +from app.services.share.external_entity_resolver import IExternalEntityResolver + +class DepartmentResolver(IExternalEntityResolver): + """Enterprise department permission resolver example""" + + @property + def requires_display_name_snapshot(self) -> bool: + # Department names can be queried from enterprise API in real-time + return False + + def match_entity_bindings( + self, + db: Session, + user_id: int, + entity_type: str, + entity_ids: list[str], + user_context: Optional[dict] = None, + ) -> list[str]: + if entity_type != "department": + return [] + + # Query user's departments (example: from enterprise system or local cache table) + user_dept_ids = self._get_user_department_ids(user_id) + + # Return intersection + return list(set(entity_ids) & set(user_dept_ids)) + + def get_resource_ids_by_entity( + self, + db: Session, + user_id: int, + entity_type: str, + user_context: Optional[dict] = None, + ) -> list[int]: + if entity_type != "department": + return [] + + # Get all departments the user belongs to + user_dept_ids = self._get_user_department_ids(user_id) + if not user_dept_ids: + return [] + + # Query knowledge bases bound to these departments + from app.models.resource_member import MemberStatus, ResourceMember + from app.models.share_link import ResourceType + + results = ( + db.query(ResourceMember.resource_id) + .filter( + ResourceMember.resource_type == ResourceType.KNOWLEDGE_BASE.value, + ResourceMember.entity_type == "department", + ResourceMember.entity_id.in_(user_dept_ids), + ResourceMember.status == MemberStatus.APPROVED.value, + ) + .all() + ) + return list(set(r.resource_id for r in results)) + + def get_display_name(self, db: Session, entity_id: str) -> Optional[str]: + # Query department name from enterprise system or cache table + return self._query_department_name(entity_id) + + def _get_user_department_ids(self, user_id: int) -> list[str]: + # Internal implementation: call enterprise org API or query local cache + pass + + def _query_department_name(self, entity_id: str) -> Optional[str]: + pass +``` + +**Step 2: Register the resolver** + +```python +# app/services/share/__init__.py +from app.services.share.department_resolver import DepartmentResolver + +register_entity_resolver("department", DepartmentResolver) +``` + +**Step 3: Add resource members** + +Bind the department to a knowledge base via API or Service layer: + +```python +knowledge_share_service.add_member( + db, + resource_id=kb_id, + current_user_id=owner_id, + target_user_id=0, # target_user_id is 0 for entity types + role=MemberRole.Reporter, + entity_type="department", + entity_id="dept_123", + entity_display_name="Technology Department", # Optional, persisted if requires_display_name_snapshot=True +) +``` + +## Usage Examples + +### Complete Scenario: Sharing Knowledge Base by Department + +**Scenario**: The company wants to share the "Product Specifications" knowledge base with all members of the "Product Department". + +**Implementation**: + +1. Implement `DepartmentResolver` (as shown in the code above) +2. In the knowledge base permission management, the Owner adds a department member: + - entity_type = "department" + - entity_id = "dept_product" + - role = "Developer" +3. When a product department member logs in: + - `get_all_knowledge_bases_grouped()` calls `DepartmentResolver.get_resource_ids_by_entity()` + - Discovers that the member belongs to "dept_product", returns KB ID + - The knowledge base appears in the member's "Shared with Me" list +4. When the member accesses the knowledge base: + - `check_permission()` finds no direct binding + - Falls back to `check_entity_permission()` + - `DepartmentResolver.match_entity_bindings()` confirms the member belongs to "dept_product" + - Permission check passes + +### Frontend Permission Source Display + +The frontend displays how users obtained their permissions via `PermissionSourceInfo`: + +- `direct`: Added directly as a member +- `entity`: Obtained through an entity (department, group) +- `link`: Obtained through a share link + +When the source is `entity`, the frontend calls `get_display_name()` or displays the `entity_display_name` snapshot to show the entity name. + +## Best Practices + +1. **Keep resolvers lightweight**: `match_entity_bindings()` may be called on every permission check. Avoid heavyweight queries. Introduce local cache tables or Redis caching when necessary. + +2. **Batch queries over individual queries**: If the underlying system supports batch APIs, override `batch_get_display_names()` to avoid N+1 problems. + +3. **Display name snapshot strategy**: + - If entity names come from **reliable local data sources** (e.g., Namespace table), set `requires_display_name_snapshot = False` + - If entity names come from **external systems** (e.g., enterprise APIs), set `requires_display_name_snapshot = True`, letting the system persist snapshots when adding members + +4. **Avoid circular dependencies**: Resolver implementations should not import upper-layer Services like `KnowledgeShareService`. Keep the Resolver Layer independent. + +5. **Reuse user_context**: In batch scenarios like list queries, the upper-layer Service can pass user profile data into `user_context`, preventing the resolver from redundantly querying user information internally. diff --git a/docs/en/user-guide/knowledge/knowledge-base-guide.md b/docs/en/user-guide/knowledge/knowledge-base-guide.md index 2f94bed68cd..6a54521ed6b 100644 --- a/docs/en/user-guide/knowledge/knowledge-base-guide.md +++ b/docs/en/user-guide/knowledge/knowledge-base-guide.md @@ -202,6 +202,61 @@ Sources: --- +## 🔐 Permission Management + +Knowledge bases support fine-grained access control through the Share Service architecture. You can manage members, assign roles, and authorize external entities (e.g., groups) to access your knowledge bases. + +### Member Roles + +| Role | Permissions | +|------|-------------| +| **Owner** | Full control, can transfer ownership and manage all members | +| **Maintainer** | Can manage documents, settings, and invite members | +| **Developer** | Can read and contribute documents | +| **Reporter** | Read-only access | + +### Managing Members + +1. Open a knowledge base and click **Permission Management** +2. Under the **Personal** tab, invite users by username or email +3. Assign a role from the dropdown (Owner, Maintainer, Developer, Reporter) +4. Click **Add** to send the invitation + +### Entity-Level Authorization + +In addition to individual users, you can authorize entire groups or namespaces: + +1. Switch to the **Group** tab in the permission dialog +2. Search for a group or namespace +3. Select the desired group and assign a role +4. All members of that group inherit the assigned permissions + +**Note:** Entity-authorized knowledge bases appear in the members' **Shared with Me** section rather than under the group's native knowledge bases. + +### Permission Source Visualization + +When viewing members, the system displays how each member gained access: + +- **Direct** — Added directly as a member +- **Entity** — Access granted through a group or namespace +- **Link** — Access obtained via a share link + +### Role Conflict Resolution + +When a user has multiple access paths to the same knowledge base (e.g., direct membership and group membership), the system automatically resolves conflicts by selecting the highest-privilege role. + +### Ownership Transfer + +Owners can transfer ownership to another member: + +1. In **Permission Management**, locate the target member +2. Click **Transfer Ownership** next to their name +3. Confirm the transfer in the dialog + +The previous owner is downgraded to Maintainer, and the new owner gains full control. + +--- + ## ❓ Troubleshooting ### Upload Issues @@ -247,3 +302,4 @@ Sources: - [Document Management](./document-management.md) - Adding and managing documents - [Chunking Strategies](./chunking-strategies.md) - Document chunking options - [Configuring Retrievers](./configuring-retrievers.md) - Retrieval configuration +- [Permission Management](#-permission-management) - Member roles and access control diff --git a/docs/superpowers/specs/2026-05-13-frontend-message-queue-design.md b/docs/superpowers/specs/2026-05-13-frontend-message-queue-design.md new file mode 100644 index 00000000000..e305febb480 --- /dev/null +++ b/docs/superpowers/specs/2026-05-13-frontend-message-queue-design.md @@ -0,0 +1,318 @@ +--- +sidebar_position: 3 +--- + +# 前端消息队列设计 + +## 背景 + +当前聊天页在模型输出过程中会把输入发送能力锁住。用户必须等当前回答结束,才能输入并发送下一个问题。 + +用户希望前端支持消息队列:模型输出时仍然可以输入下一个问题,点击发送后进入前端队列,并在当前回答结束后自动发送。 + +## 当前状态 + +现有前端消息链路集中在以下模块: + +- `ChatArea` 编排聊天页输入区、消息区和任务状态 +- `useChatStreamHandlers` 负责构建发送参数、调用 `ChatStreamContext.sendMessage`、维护 pending 和 streaming 状态 +- `ChatStreamContext.sendMessage` 负责写入本地用户消息、调用 WebSocket `chat:send`、迁移临时任务 ID +- `TaskStateMachine` 是消息展示的单一状态来源 +- `ChatInputControls` 和 `MobileChatInputControls` 根据 `isStreaming` 把发送按钮切换为停止按钮或禁用发送 + +现有阻塞点主要有两个: + +- `canSubmit` 在 `streamHandlers.isStreaming` 为 true 时变为 false +- 发送按钮在 streaming 期间优先展示停止动作,无法把新输入加入队列 + +## 目标 + +- 模型输出过程中,输入框仍可编辑 +- 用户在 streaming 期间点击发送时,消息进入前端队列 +- 当前 AI 回复结束后,队列按 FIFO 自动发送下一条 +- 入队时清空输入框,避免用户误以为没有响应 +- 队列消息在发送框上方展示,不进入聊天消息流 +- 队列展示至少能区分 queued、sending、failed +- 支持取消排队消息,取消后内容回到输入框继续编辑 +- 用户在已有排队消息时继续输入并发送,合并到现有排队消息中,一次发送 +- 保留停止当前生成的能力 +- 保持现有 WebSocket 协议和后端发送接口不变 + +## 非目标 + +- 本轮不做后端消息队列 +- 本轮不允许同一任务并发发送多个子任务 +- 本轮不改变 `TaskStateMachine` 作为消息展示单一来源的原则 +- 本轮不修改 group chat 的后端协作语义 +- 本轮不重做聊天页整体视觉设计 +- 本轮不支持跨页面持久化未发送队列 + +## 方案对比 + +### 方案 1:ChatArea 层顺序队列 + +做法: + +- 在 `ChatArea/useChatStreamHandlers` 附近维护当前任务的待发送队列 +- streaming 时发送动作只入队 +- 当前流结束后自动取队首并调用现有发送逻辑 + +优点: + +- 改动集中在前端发送编排层 +- 可以复用现有参数构建、附件、上下文、技能、模型选择逻辑 +- 不改变后端协议 +- 更容易用组件和 hook 测试覆盖 + +缺点: + +- 队列只在当前页面生命周期内存在 +- 多页面打开同一任务时,队列不会同步 + +### 方案 2:ChatStreamContext 全局队列 + +做法: + +- 把队列放进 `ChatStreamContext` +- 所有页面通过统一 context 入队和自动发送 + +优点: + +- 能覆盖更多入口 +- 更接近全局消息调度 + +缺点: + +- 会和临时 task ID、真实 task ID 迁移、不同页面状态同步耦合 +- 更容易让 context 承担业务参数快照职责 +- 首版风险更高 + +### 方案 3:后端顺序队列 + +做法: + +- 前端 streaming 时仍直接发送请求 +- 后端按任务维度排队执行 + +优点: + +- 队列语义最完整 +- 多端一致性最好 + +缺点: + +- 需要调整后端任务执行语义 +- 超出本轮“前端支持”的范围 +- 需要更多执行器和任务状态回归验证 + +## 选型 + +采用方案 1:ChatArea 层顺序队列。 + +原因: + +- 用户确认需要前端消息队列和自动发送 +- 现有后端同一任务串行上下文更适合 FIFO 顺序发送 +- 第一版应该避免引入后端执行语义变化 +- `useChatStreamHandlers` 已经掌握发送所需的完整上下文,适合做参数快照 + +## 总体设计 + +### 队列数据模型 + +新增一个前端队列项类型,放在聊天发送相关模块附近,例如 `useMessageSendQueue`: + +```ts +type QueuedMessageStatus = "queued" | "sending" | "failed"; + +interface QueuedMessage { + id: string; + taskId: number; + content: string; + createdAt: number; + status: QueuedMessageStatus; + error?: string; + requestSnapshot: SendMessageSnapshot; +} +``` + +`SendMessageSnapshot` 记录入队当刻需要发送的所有可变输入: + +- message +- attachments +- selected contexts +- selected model +- force override 配置 +- selected skills +- repository 和 branch +- generation params +- toggles such as deep thinking and clarification + +入队后即使用户继续修改输入框或切换模型,队列项也按入队时的快照发送,避免排队内容漂移。 + +### 入队行为 + +当满足以下条件时,点击发送执行入队: + +- 当前任务已有真实 `taskId` +- 当前任务正在 streaming,或正在等待响应开始 +- 输入内容非空 +- 附件已经上传完成 +- 当前必要选择项满足要求,例如 code 任务已选择仓库 + +入队成功后: + +- 清空输入框 +- 清空当前附件和上下文选择 +- 在发送框上方展示队列预览,状态显示为 queued +- 保持输入框可继续编辑 + +如果当前任务已有未发送的 queued 项,新的输入不会创建第二条可见队列项,而是合并到现有 queued 项中。合并后的队列项保留所有发送快照信息,并在当前 AI 回复结束后作为一次请求发送。 + +新任务第一条消息仍按现有流程发送。原因是第一条消息发送前没有真实任务 ID,继续入队会让后续队列难以稳定绑定任务。 + +### 自动发送行为 + +队列调度器监听当前任务 streaming 状态: + +- `isStreaming` 从 true 变为 false 后触发 +- `selectedTaskDetail.status` 不再是 `RUNNING` 后触发 +- 当前没有正在发送的队列项时触发 +- 队列非空时取队首发送 + +自动发送复用现有发送函数,但使用队列项保存的快照,而不是读取当前输入区状态。 + +发送成功后: + +- 从队列移除该项 +- 通过现有发送链路创建正常的本地用户消息和后续 AI 回复 +- 如果发送后又进入 streaming,等待下一轮完成后再发下一条 + +发送失败后: + +- 将该队列项标记为 failed +- 显示 toast +- 暂停后续队列自动发送 +- 保留失败项,后续可通过重试能力继续发送 + +### 队列展示 + +队列预览属于输入区状态,不属于聊天消息流: + +- 队列预览显示在发送框上方,而不是发送框内部 +- 每个队列项展示状态标签和最多两行消息内容 +- queued 和 failed 项显示取消按钮 +- sending 项不显示取消按钮,避免移除已经开始发送的请求 +- 点击取消按钮会从队列移除该项,并把消息内容恢复到输入框 + +这样可以避免把尚未发送的内容混入 `TaskStateMachine` 消息列表,也能让用户清楚地区分“已发送消息”和“等待发送的输入”。 + +### 输入区行为 + +Desktop 和 mobile 保持一致: + +- streaming 时输入框不禁用 +- streaming 且输入为空时,保留停止当前生成的入口 +- streaming 且输入非空时,显示入队发送入口,同时仍保留停止入口 +- 队列预览显示在输入卡片外侧上方 +- 非 streaming 时维持现有发送行为 + +为了控制改动范围,第一版只增加输入区上方的队列预览和发送按钮状态区分,不重做聊天页整体布局。 + +### 停止当前生成 + +停止动作只作用于当前正在生成的 AI 回复,不会清空队列。 + +停止成功后: + +- 当前流结束 +- 队列调度器按正常规则发送下一条 + +如果用户希望停止后不继续发送队列,后续可以增加“清空队列”或“暂停队列”操作,本轮不包含。 + +### 任务切换 + +队列按任务 ID 分组。 + +- 切换到其他任务时,只显示该任务自己的队列状态 +- 自动发送只处理当前选中任务的队列 +- 新建任务第一条消息完成并获得真实 task ID 后,才允许后续入队 + +本轮不做跨任务后台自动发送。这样能避免用户离开任务后仍有不可见消息被自动发出。 + +## 错误处理 + +### 入队前校验失败 + +沿用现有 toast: + +- 附件未上传完成 +- code 任务未选择仓库 +- 模型选择缺失 +- 输入为空 + +这些场景不创建队列项。 + +### 自动发送失败 + +自动发送失败时: + +- 队列项标记为 failed +- 队列预览展示错误状态 +- toast 展示错误原因 +- 后续队列暂停 + +失败项需要用户显式重试或取消。重试优先复用 toast 中的 retry 入口;取消会把该队列项内容恢复到输入框。 + +### 当前流异常结束 + +当当前流进入 error 或 cancelled 状态时,也视为“当前流结束”。如果队列非空,调度器继续发送下一条。 + +## 测试计划 + +### Hook 和发送状态测试 + +- streaming 时 `canSubmit` 不再因为 streaming 变 false +- streaming 时输入非空点击发送会创建队列项,不会立即调用 WebSocket send +- 当前流结束后自动发送队首 +- 已有 queued 项时再次发送会合并为一次 queued 请求 +- 取消 queued 项会从队列移除,并把内容恢复到输入框 +- 自动发送失败时暂停后续队列 + +### Desktop 控件测试 + +- streaming 且输入为空时仍可停止生成 +- streaming 且输入非空时可以加入队列 +- 队列预览显示在发送框上方,而不是聊天消息区或发送框内部 +- queued 和 failed 项可以取消,sending 项不能取消 +- 非 streaming 时发送按钮保持现有行为 + +### Mobile 控件测试 + +- mobile 控件与 desktop 发送状态一致 +- streaming 时更多菜单中会被安全禁用的动作仍保持现有约束 + +### 集成回归测试 + +- 新任务第一条消息发送流程不变 +- 已有任务 follow-up 发送流程不变 +- 附件、知识库、表格上下文入队后按快照发送 +- code 任务仓库和分支快照正确发送 +- group chat 不回归已有发送和已读标记逻辑 +- 取消排队后恢复的内容可继续编辑并重新发送 + +## 实施顺序 + +1. 为队列状态和调度器写失败测试 +2. 提取发送请求快照构建逻辑,保证普通发送和队列发送复用 +3. 新增 per-task 消息队列 hook +4. 调整 `ChatInputControls` 和 `MobileChatInputControls` 的 streaming 发送状态 +5. 在发送框上方接入 queued 消息预览、合并和取消恢复输入 +6. 补齐失败、重试和任务切换测试 + +## 风险 + +- 发送参数快照如果遗漏字段,队列消息可能和用户入队时看到的配置不一致 +- 现有 `TaskStateMachine` 的 pending 消息去重和后端恢复逻辑需要避免把 queued 消息误删 +- 停止当前生成后自动发送下一条可能不符合部分用户预期,但它符合本轮已确认的“队列自动发送”语义 +- 多页面打开同一任务时,前端本地队列不会同步;这是首版明确接受的限制 diff --git a/docs/zh/developer-guide/entity-permission-extension.md b/docs/zh/developer-guide/entity-permission-extension.md new file mode 100644 index 00000000000..e7163ad339a --- /dev/null +++ b/docs/zh/developer-guide/entity-permission-extension.md @@ -0,0 +1,418 @@ +--- +sidebar_position: 13 +--- + +# 实体权限扩展 + +本文档介绍 Wegent 的统一资源分享权限架构,涵盖当前授权机制、权限处理逻辑、扩展架构设计,以及如何通过 `IExternalEntityResolver` 接入内部定制化授权系统。 + +## 概述 + +Wegent 的权限系统基于 `ResourceMember` 模型实现,支持两种成员绑定方式: + +- **直接用户绑定** (`entity_type="user"`):将具体用户添加为资源成员 +- **实体绑定** (`entity_type="namespace"` 或自定义类型):将外部实体(如群组、部门)绑定到资源,实体内的成员自动继承访问权限 + +实体绑定的核心扩展点是 `IExternalEntityResolver` 接口。开源版本内置了 `namespace` 类型的解析器,内部部署可通过注册自定义解析器接入企业组织架构(如部门、项目团队)。 + +## 当前授权机制 + +### 统一角色体系 + +系统使用 `BaseRole` 作为唯一的角色定义源: + +| 角色 | 权限级别 | 说明 | +|------|---------|------| +| **Owner** | 最高 | 完全控制,可删除资源、转移所有权 | +| **Maintainer** | 高 | 管理成员和设置 | +| **Developer** | 中 | 修改内容 | +| **Reporter** | 低 | 只读访问 | +| **RestrictedAnalyst** | 最低 | 受限的只读访问 | + +角色冲突时,系统自动选择**最高权限**的角色。该逻辑由 `has_permission()` 和 `get_highest_role()` 实现。 + +### 成员状态 + +`ResourceMember.status` 有三种状态: + +- `pending`:待审批 +- `approved`:已批准(只有 approved 的成员才参与权限计算) +- `rejected`:已拒绝 + +### 资源成员模型 + +``` +ResourceMember +├── resource_type: str # 资源类型,如 "KnowledgeBase" +├── resource_id: int # 资源 ID +├── entity_type: str # "user" | "namespace" | 自定义类型 +├── entity_id: str # 实体标识符 +├── role: str # 角色值 +├── status: str # pending | approved | rejected +└── user_id: int | None # 兼容字段,user 类型时自动同步 +``` + +唯一约束:`(resource_type, resource_id, entity_type, entity_id)` + +### 归属与授权的分离 + +权限系统中存在两个容易混淆的核心概念,必须明确区分: + +| 维度 | 归属(Ownership) | 授权(Authorization) | +|------|-----------------|---------------------| +| **判定依据** | 资源的 `user_id` 字段(如 `Kind.user_id`) | `ResourceMember` 记录中的 `role` 字段 | +| **典型示例** | 知识库的创建者 | 被添加为成员并分配了 Owner 角色的用户 | +| **数据库体现** | 资源表的一行字段 | `resource_members` 表的一条记录 | +| **权限来源** | 天然拥有完全访问权限,无需 `ResourceMember` 记录 | 必须通过成员绑定获得 | + +**关键区别:** + +1. **创建者(Creator)不等于 ResourceMember 中的 Owner** + - 创建者由 `kb.user_id == user_id` 判定 + - `ResourceMember(role="Owner")` 是被授权的成员,不一定是创建者 + - 创建者可以没有任何 `ResourceMember` 记录仍然拥有完全权限 + +2. **权限计算时两者是独立来源** + - `_compute_kb_access_core()` 中,`is_creator` 和 `roles`(来自 ResourceMember)是**分别计算**的两条线 + - 最终 `has_access = len(roles) > 0 or is_creator` + - 即使没有任何授权记录,创建者始终有访问权限 + +3. **所有权转移只改变归属,不改变授权记录** + - 转移后,新归属者的 `user_id` 被写入资源表 + - 旧的 `ResourceMember` 记录不会被自动删除 + - 如果旧创建者同时有 `ResourceMember(role="Owner")` 记录,转移后该记录仍然有效 + +4. **前端展示中的区分** + - `PermissionSourceInfo.source_type="creator"` 表示归属来源 + - `PermissionSourceInfo.source_type="direct"` 表示直接授权来源 + - 创建者也会作为权限来源之一出现在 `get_my_permission_sources()` 的返回中 + +## 权限处理逻辑 + +### 直接权限检查流程 + +`UnifiedShareService.check_permission()` 的调用链路: + +``` +check_permission(resource_id, user_id, required_role) +├── 1. 查询 ResourceMember +│ resource_type = {resource_type} +│ entity_type = "user" +│ entity_id = str(user_id) +│ status = "approved" +│ → 找到直接绑定 +│ → 用 has_permission(effective_role, required_role) 判断 +│ → 满足则返回 True +│ +└── 2. 未找到直接绑定 → 回退到 check_entity_permission() +``` + +### 实体权限回退流程 + +`check_entity_permission()` 的调用链路: + +``` +check_entity_permission(resource_id, user_id, required_role) +├── 1. 查询该资源的所有 entity_type != "user" 的 approved 绑定 +│ 按 entity_type 分组为 {entity_type: [(entity_id, role), ...]} +│ +├── 2. 遍历每个 entity_type +│ resolver = get_entity_resolver(entity_type) +│ matched = resolver.match_entity_bindings(db, user_id, entity_type, entity_ids) +│ → 如果 matched 非空 +│ → 检查 matched 中每个 entity_id 对应的 role 是否满足 required_role +│ → 满足则返回 True +│ +└── 3. 无匹配 → 返回 False +``` + +### 角色冲突解决 + +当用户同时具有直接绑定和实体绑定时,`get_user_role()` 的处理逻辑: + +``` +get_user_role(resource_id, user_id) +├── direct_role = 直接用户绑定的角色 +│ 如果 direct_role == "Owner" → 直接返回 Owner +│ +├── entity_role = _get_highest_entity_role() +│ 遍历所有实体绑定 +│ 对每个 entity_type 调用 match_entity_bindings() +│ 取匹配实体中的最高角色 +│ +└── 如果同时存在 direct_role 和 entity_role + → 返回两者中的更高权限 +``` + +### 列表查询中的实体权限 + +`KnowledgeService.get_all_knowledge_bases_grouped()` 在获取知识库列表时,需要一并获取通过实体绑定可访问的知识库。该逻辑由 `_collect_entity_authorized_kbs()` 实现: + +``` +_collect_entity_authorized_kbs(user_id, accessible_groups) +├── Step 1: 处理 namespace 类型(硬编码优化路径) +│ 将 accessible_groups 转换为 namespace IDs +│ 查询 ResourceMember (entity_type="namespace", entity_id in namespace_ids) +│ 收集这些资源及其分组、角色信息 +│ +└── Step 2: 处理外部实体类型(通过解析器) + 遍历所有已注册的 entity_type(除 "namespace"、"user" 外) + resolver = get_entity_resolver(entity_type) + resolved_kb_ids = resolver.get_resource_ids_by_entity(db, user_id, entity_type) + → 查询这些 KB 对应的 ResourceMember 行 + → 用 match_entity_bindings() 过滤出用户实际匹配的 entity_id + → 收集角色和分组信息 +``` + +## 扩展架构设计 + +### Share Service 分层架构 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ API Layer │ +│ - /api/v1/share/members (add/remove/get members) │ +│ - /api/v1/knowledge (list with permissions) │ +└─────────────────────────────────────────────────────────────┘ + │ +┌─────────────────────────────────────────────────────────────┐ +│ Service Layer │ +│ UnifiedShareService (base_service.py) │ +│ ├── check_permission() / check_entity_permission() │ +│ ├── get_user_role() / _get_highest_entity_role() │ +│ ├── add_member() / remove_member() / get_members() │ +│ └── get_my_permission_sources() │ +│ │ +│ KnowledgeShareService (knowledge_share_service.py) │ +│ └── 知识库特定的权限逻辑 │ +└─────────────────────────────────────────────────────────────┘ + │ +┌─────────────────────────────────────────────────────────────┐ +│ Resolver Layer │ +│ IExternalEntityResolver (external_entity_resolver.py) │ +│ ├── NamespaceEntityResolver (namespace_entity_resolver.py) │ +│ └── [Custom Resolvers] 通过 register_entity_resolver() 注册 │ +└─────────────────────────────────────────────────────────────┘ + │ +┌─────────────────────────────────────────────────────────────┐ +│ Model Layer │ +│ ResourceMember (resource_member.py) │ +│ └── Polymorphic: (entity_type, entity_id) │ +└─────────────────────────────────────────────────────────────┘ +``` + +### IExternalEntityResolver 接口 + +```python +class IExternalEntityResolver(ABC): + @abstractmethod + def match_entity_bindings( + self, db, user_id, entity_type, entity_ids, user_context=None + ) -> list[str]: + """返回 entity_ids 中该用户实际匹配的 ID 列表""" + + @abstractmethod + def get_resource_ids_by_entity( + self, db, user_id, entity_type, user_context=None + ) -> list[int]: + """返回该用户通过此实体类型可访问的所有资源 ID""" + + @property + def requires_display_name_snapshot(self) -> bool: + """是否需要持久化显示名称快照(默认 True)""" + return True + + def get_display_name(self, db, entity_id) -> Optional[str]: + """解析单个实体的显示名称""" + return None + + def batch_get_display_names(self, db, entity_ids) -> dict[str, str]: + """批量解析显示名称(默认逐个调用 get_display_name)""" +``` + +### 解析器注册机制 + +解析器通过模块级注册表管理,在应用启动时注册: + +```python +# app/services/share/__init__.py +register_entity_resolver("namespace", NamespaceEntityResolver) +# register_entity_resolver("department", DepartmentResolver) # 自定义 +``` + +注册后: +- `get_entity_resolver("namespace")` 返回单例实例 +- `get_all_entity_types()` 返回所有已注册的类型列表 +- 实例被缓存,重复获取返回同一对象 + +## 拓展机制 + +### 内置解析器:NamespaceEntityResolver + +`NamespaceEntityResolver` 处理 `entity_type="namespace"` 的绑定。核心逻辑: + +**match_entity_bindings()**: +- 输入:`entity_ids` = 绑定的 namespace ID 列表(字符串) +- 查询 `ResourceMember` (resource_type="Namespace", entity_type="user", entity_id=str(user_id), status="approved") +- 返回用户实际属于的 namespace ID 子集 + +**get_resource_ids_by_entity()**: +- 先查用户属于哪些 namespace +- 再查这些 namespace 绑定了哪些 KnowledgeBase +- 返回去重后的 KB ID 列表 + +**requires_display_name_snapshot**:`False` +- namespace 名称可以从本地 `Namespace` 表实时查询,无需持久化快照 + +### 自定义解析器实现步骤 + +实现自定义解析器需要以下步骤: + +**Step 1:创建解析器类** + +```python +# app/services/share/department_resolver.py +from typing import Optional +from sqlalchemy.orm import Session +from app.services.share.external_entity_resolver import IExternalEntityResolver + +class DepartmentResolver(IExternalEntityResolver): + """企业部门权限解析器示例""" + + @property + def requires_display_name_snapshot(self) -> bool: + # 部门名称可从企业 API 实时查询,不持久化快照 + return False + + def match_entity_bindings( + self, + db: Session, + user_id: int, + entity_type: str, + entity_ids: list[str], + user_context: Optional[dict] = None, + ) -> list[str]: + if entity_type != "department": + return [] + + # 查询用户所属部门(示例:从企业系统或本地缓存表) + user_dept_ids = self._get_user_department_ids(user_id) + + # 返回交集 + return list(set(entity_ids) & set(user_dept_ids)) + + def get_resource_ids_by_entity( + self, + db: Session, + user_id: int, + entity_type: str, + user_context: Optional[dict] = None, + ) -> list[int]: + if entity_type != "department": + return [] + + # 获取用户所属的所有部门 + user_dept_ids = self._get_user_department_ids(user_id) + if not user_dept_ids: + return [] + + # 查询这些部门绑定的知识库 + from app.models.resource_member import MemberStatus, ResourceMember + from app.models.share_link import ResourceType + + results = ( + db.query(ResourceMember.resource_id) + .filter( + ResourceMember.resource_type == ResourceType.KNOWLEDGE_BASE.value, + ResourceMember.entity_type == "department", + ResourceMember.entity_id.in_(user_dept_ids), + ResourceMember.status == MemberStatus.APPROVED.value, + ) + .all() + ) + return list(set(r.resource_id for r in results)) + + def get_display_name(self, db: Session, entity_id: str) -> Optional[str]: + # 从企业系统或缓存表查询部门名称 + return self._query_department_name(entity_id) + + def _get_user_department_ids(self, user_id: int) -> list[str]: + # 内部实现:调用企业组织架构 API 或查询本地缓存 + pass + + def _query_department_name(self, entity_id: str) -> Optional[str]: + pass +``` + +**Step 2:注册解析器** + +```python +# app/services/share/__init__.py +from app.services.share.department_resolver import DepartmentResolver + +register_entity_resolver("department", DepartmentResolver) +``` + +**Step 3:添加资源成员** + +通过 API 或 Service 层将部门绑定到知识库: + +```python +knowledge_share_service.add_member( + db, + resource_id=kb_id, + current_user_id=owner_id, + target_user_id=0, # entity 类型时 target_user_id 为 0 + role=MemberRole.Reporter, + entity_type="department", + entity_id="dept_123", + entity_display_name="技术部", # 可选,若 requires_display_name_snapshot=True 则持久化 +) +``` + +## 使用示例 + +### 完整场景:按部门共享知识库 + +**场景**:公司希望将 "产品规范" 知识库共享给 "产品部" 的所有成员。 + +**实现**: + +1. 实现 `DepartmentResolver`(如上代码) +2. 在知识库权限管理中,Owner 添加部门成员: + - entity_type = "department" + - entity_id = "dept_product" + - role = "Developer" +3. 产品部成员登录后: + - `get_all_knowledge_bases_grouped()` 调用 `DepartmentResolver.get_resource_ids_by_entity()` + - 发现该成员属于 "dept_product",返回 KB ID + - 知识库出现在成员的 "与我共享" 列表中 +4. 成员访问知识库时: + - `check_permission()` 未找到直接绑定 + - 回退到 `check_entity_permission()` + - `DepartmentResolver.match_entity_bindings()` 确认成员属于 "dept_product" + - 权限检查通过 + +### 前端权限来源展示 + +前端通过 `PermissionSourceInfo` 展示用户如何获得权限: + +- `direct`:直接添加为成员 +- `entity`:通过实体(部门、群组)获得 +- `link`:通过分享链接获得 + +当来源为 `entity` 时,前端调用 `get_display_name()` 或显示 `entity_display_name` 快照来展示实体名称。 + +## 最佳实践 + +1. **解析器应尽量轻量化**:`match_entity_bindings()` 在每次权限检查时都可能被调用,避免重量级查询。必要时引入本地缓存表或 Redis 缓存 + +2. **批量查询优于逐条查询**:如果底层系统支持批量 API,应重写 `batch_get_display_names()` 以避免 N+1 问题 + +3. **display_name 快照策略**: + - 如果实体名称来自**可靠本地数据源**(如 Namespace 表),设置 `requires_display_name_snapshot = False` + - 如果实体名称来自**外部系统**(如企业 API),设置 `requires_display_name_snapshot = True`,让系统在添加成员时持久化快照 + +4. **避免循环依赖**:解析器实现中不要导入 `KnowledgeShareService` 等上层 Service,保持 Resolver Layer 的独立性 + +5. **user_context 复用**:在列表查询等批量场景中,上层 Service 可将用户资料数据传入 `user_context`,避免解析器内部重复查询用户信息 diff --git a/docs/zh/user-guide/knowledge/knowledge-base-guide.md b/docs/zh/user-guide/knowledge/knowledge-base-guide.md index 3d127a7a015..a65126b04de 100644 --- a/docs/zh/user-guide/knowledge/knowledge-base-guide.md +++ b/docs/zh/user-guide/knowledge/knowledge-base-guide.md @@ -22,6 +22,7 @@ sidebar_position: 6 - [与智能体集成](#-与智能体集成) - [引用机制](#-引用机制) - [摘要功能](#-摘要功能) +- [权限管理](#-权限管理) - 成员角色与访问控制 - [使用限制](#-使用限制) - [常见问题](#-常见问题) - [相关资源](#-相关资源) @@ -249,6 +250,61 @@ AI 响应包含链接到源文档的编号引用: --- +## 🔐 权限管理 + +知识库通过 Share Service 架构支持细粒度的访问控制。您可以管理成员、分配角色,并授权外部实体(如群组)访问您的知识库。 + +### 成员角色 + +| 角色 | 权限 | +|------|------| +| **Owner(所有者)** | 完全控制,可转移所有权并管理所有成员 | +| **Maintainer(维护者)** | 可管理文档、设置和邀请成员 | +| **Developer(开发者)** | 可阅读和贡献文档 | +| **Reporter(报告者)** | 只读访问 | + +### 管理成员 + +1. 打开知识库,点击 **权限管理** +2. 在 **个人** 标签页中,通过用户名或邮箱邀请用户 +3. 从下拉框中选择角色(所有者、维护者、开发者、报告者) +4. 点击 **添加** 发送邀请 + +### 实体级授权 + +除了单个用户,您还可以授权整个群组或命名空间: + +1. 在权限对话框中切换到 **群组** 标签页 +2. 搜索并选择目标群组或命名空间 +3. 为该群组分配角色 +4. 该群组的所有成员将继承所分配的权限 + +**注意:** 通过实体授权的知识库会显示在成员的 **与我共享** 区域中,而非该群组的本地知识库列表下。 + +### 权限来源可视化 + +在查看成员列表时,系统会显示每个成员的访问来源: + +- **直接** — 直接添加为成员 +- **实体** — 通过群组或命名空间获得访问权限 +- **链接** — 通过分享链接获得访问权限 + +### 角色冲突解决 + +当用户通过多种途径访问同一知识库(例如同时拥有直接成员身份和群组成员身份)时,系统会自动选择最高权限的角色进行冲突解决。 + +### 所有权转移 + +所有者可以将知识库所有权转移给其他成员: + +1. 在 **权限管理** 中找到目标成员 +2. 点击其名称旁的 **转移所有权** +3. 在确认对话框中确认转移 + +原所有者将被降级为维护者,新所有者获得完全控制权。 + +--- + ## ❓ 常见问题 ### 上传问题 @@ -275,6 +331,7 @@ AI 响应包含链接到源文档的编号引用: - [文档管理](./document-management.md) - 学习如何管理文档 - [分块策略](./chunking-strategies.md) - 优化文档分块 - [配置检索器](./configuring-retrievers.md) - 设置向量数据库 +- [权限管理](#-权限管理) - 成员角色与访问控制 - [智能体设置](../settings/agent-settings.md) - 配置具有知识库访问权限的智能体 --- diff --git a/frontend/src/__tests__/features/knowledge/document/document-detail-dialog.test.tsx b/frontend/src/__tests__/features/knowledge/document/document-detail-dialog.test.tsx index 1316f8d54f6..6141d4aaf69 100644 --- a/frontend/src/__tests__/features/knowledge/document/document-detail-dialog.test.tsx +++ b/frontend/src/__tests__/features/knowledge/document/document-detail-dialog.test.tsx @@ -179,7 +179,7 @@ describe('DocumentDetailDialog wiki-link routing', () => { is_active: true, index_status: 'success', index_generation: 1, - folder_id: 0, + folder_id: 0, source_type: 'text', source_config: {}, created_at: '2026-04-02T00:00:00Z', diff --git a/frontend/src/__tests__/features/tasks/components/chat/ChatArea.queue-message-handler.test.tsx b/frontend/src/__tests__/features/tasks/components/chat/ChatArea.queue-message-handler.test.tsx index db9442668f2..cfdf804396e 100644 --- a/frontend/src/__tests__/features/tasks/components/chat/ChatArea.queue-message-handler.test.tsx +++ b/frontend/src/__tests__/features/tasks/components/chat/ChatArea.queue-message-handler.test.tsx @@ -4,11 +4,24 @@ import type { TaskDetail } from '@/types/api' import { ChatArea } from '@/features/tasks/components/chat' +const mockChatInputCard = jest.fn((_props: Record) => ( +
+)) + const defaultStreamHandlers = { pendingTaskId: null, isStreaming: false, isAwaitingResponseStart: false, isSubtaskStreaming: false, + canQueueMessage: false, + queuedMessageCount: 0, + queuedMessages: [] as Array<{ + id: string + displayMessage: string + status: 'queued' | 'sending' | 'failed' + error?: string + }>, + cancelQueuedMessage: jest.fn(), isStopping: false, hasPendingUserMessage: false, localPendingMessage: null, @@ -121,7 +134,7 @@ jest.mock('@/features/tasks/components/chat/SloganDisplay', () => ({ SloganDisplay: () =>
, })) jest.mock('@/features/tasks/components/input/ChatInputCard', () => ({ - ChatInputCard: () =>
, + ChatInputCard: (props: Record) => mockChatInputCard(props), })) jest.mock( '@/features/tasks/components/chat/PipelineStageIndicator', @@ -214,6 +227,7 @@ describe('ChatArea queue message handler mounting', () => { beforeEach(() => { streamHandlersMock = { ...defaultStreamHandlers } selectedTaskDetailMock = null + mockChatInputCard.mockClear() }) it('mounts QueueMessageHandler for chat mode', () => { @@ -232,4 +246,77 @@ describe('ChatArea queue message handler mounting', () => { ) expect(screen.getByTestId('queue-message-handler')).toBeInTheDocument() }) + + it('passes queued send availability to ChatInputCard for a running task', () => { + streamHandlersMock = { + ...defaultStreamHandlers, + isStreaming: true, + canQueueMessage: true, + } + selectedTaskDetailMock = { + id: 42, + status: 'RUNNING', + is_group_chat: false, + subtasks: [], + } as unknown as TaskDetail + + render() + + expect(mockChatInputCard).toHaveBeenCalledWith( + expect.objectContaining({ canQueueMessage: true, canSubmit: true, queuedMessages: [] }) + ) + }) + + it('passes queued message cancellation to ChatInputCard', () => { + const cancelQueuedMessage = jest.fn() + const queuedMessages = [ + { + id: '42:local-user-1', + displayMessage: 'next question', + status: 'queued' as const, + }, + ] + streamHandlersMock = { + ...defaultStreamHandlers, + isStreaming: true, + canQueueMessage: true, + queuedMessages, + cancelQueuedMessage, + } + selectedTaskDetailMock = { + id: 42, + status: 'RUNNING', + is_group_chat: false, + subtasks: [], + } as unknown as TaskDetail + + render() + + expect(mockChatInputCard).toHaveBeenCalledWith( + expect.objectContaining({ + queuedMessages, + onCancelQueuedMessage: cancelQueuedMessage, + }) + ) + }) + + it('keeps submit disabled while streaming when queueing is unavailable', () => { + streamHandlersMock = { + ...defaultStreamHandlers, + isStreaming: true, + canQueueMessage: false, + } + selectedTaskDetailMock = { + id: 42, + status: 'RUNNING', + is_group_chat: false, + subtasks: [], + } as unknown as TaskDetail + + render() + + expect(mockChatInputCard).toHaveBeenCalledWith( + expect.objectContaining({ canQueueMessage: false, canSubmit: false }) + ) + }) }) diff --git a/frontend/src/__tests__/features/tasks/components/chat/useChatStreamHandlers.queue.test.tsx b/frontend/src/__tests__/features/tasks/components/chat/useChatStreamHandlers.queue.test.tsx new file mode 100644 index 00000000000..97d6a453213 --- /dev/null +++ b/frontend/src/__tests__/features/tasks/components/chat/useChatStreamHandlers.queue.test.tsx @@ -0,0 +1,298 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { useChatStreamHandlers } from '@/features/tasks/components/chat/useChatStreamHandlers' +import type { TaskDetail } from '@/types/api' +import type React from 'react' + +const mockContextSendMessage = jest.fn() +const mockSetTaskInputMessage = jest.fn() +const mockSetIsLoading = jest.fn() +const mockResetAttachment = jest.fn() +const mockResetContexts = jest.fn() +const mockScrollToBottom = jest.fn() +const mockAddUserMessage = jest.fn() +const mockUpdateUserMessage = jest.fn() +const mockToast = jest.fn() + +let isMachineStreamingMock = true +let taskInputMessageMock = 'next question' +let selectedTaskDetailMock = { + id: 42, + status: 'RUNNING', + is_group_chat: false, + subtasks: [], +} as unknown as TaskDetail + +jest.mock('next/navigation', () => ({ + useRouter: () => ({ push: jest.fn() }), + useSearchParams: () => new URLSearchParams(), + usePathname: () => '/chat', +})) + +jest.mock('@/features/tasks/contexts/taskContext', () => ({ + useTaskContext: () => ({ + selectedTaskDetail: selectedTaskDetailMock, + refreshTasks: jest.fn(), + refreshSelectedTaskDetail: jest.fn(), + markTaskAsViewed: jest.fn(), + }), +})) + +jest.mock('@/features/tasks/contexts/chatStreamContext', () => ({ + useChatStreamContext: () => ({ + sendMessage: mockContextSendMessage, + stopStream: jest.fn(), + clearVersion: 0, + }), +})) + +jest.mock('@/contexts/SocketContext', () => ({ + useSocket: () => ({ retryMessage: jest.fn() }), +})) + +jest.mock('@/contexts/DeviceContext', () => ({ + useDevices: () => ({ selectedDeviceId: null }), +})) + +jest.mock('@/hooks/use-toast', () => ({ + useToast: () => ({ toast: mockToast }), +})) + +jest.mock('@/hooks/useTranslation', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})) + +jest.mock('@/features/common/UserContext', () => ({ + useUser: () => ({ user: { id: 7 } }), +})) + +jest.mock('@/hooks/useTraceAction', () => ({ + useTraceAction: () => ({ + traceAction: (_name: string, _attrs: Record, fn: () => unknown) => fn(), + }), +})) + +jest.mock('@/features/tasks/hooks/useTaskStateMachine', () => ({ + useTaskStateMachine: () => ({ + state: { messages: new Map(), isStopping: false }, + isStreaming: isMachineStreamingMock, + }), +})) + +jest.mock('@/features/tasks/state', () => ({ + generateMessageId: () => 'local-user-1', + taskStateManager: { + getOrCreate: () => ({ + addUserMessage: mockAddUserMessage, + updateUserMessage: mockUpdateUserMessage, + }), + }, +})) + +function renderQueueableHook() { + return renderHook(() => + useChatStreamHandlers({ + selectedTeam: { id: 5, name: 'Team' } as never, + selectedModel: null, + forceOverride: false, + setSelectedModel: jest.fn(), + setForceOverride: jest.fn(), + selectedRepo: null, + selectedBranch: null, + showRepositorySelector: false, + effectiveRequiresWorkspace: false, + taskInputMessage: taskInputMessageMock, + setTaskInputMessage: mockSetTaskInputMessage, + setIsLoading: mockSetIsLoading, + enableDeepThinking: false, + enableClarification: false, + externalApiParams: {}, + attachments: [ + { + id: 11, + filename: 'ready.txt', + status: 'ready', + }, + ] as never, + resetAttachment: mockResetAttachment, + isAttachmentReadyToSend: true, + taskType: 'chat', + shouldHideChatInput: false, + scrollToBottom: mockScrollToBottom, + selectedContexts: [], + resetContexts: mockResetContexts, + additionalSkills: [], + }) + ) +} + +describe('useChatStreamHandlers queue integration', () => { + beforeEach(() => { + jest.clearAllMocks() + isMachineStreamingMock = true + taskInputMessageMock = 'next question' + selectedTaskDetailMock = { + id: 42, + status: 'RUNNING', + is_group_chat: false, + subtasks: [], + } as unknown as TaskDetail + }) + + it('queues a follow-up outside the chat message stream while the active task is streaming', async () => { + const { result } = renderQueueableHook() + + await act(async () => { + await result.current.handleSendMessage() + }) + + expect(mockContextSendMessage).not.toHaveBeenCalled() + expect(mockAddUserMessage).not.toHaveBeenCalled() + expect(result.current.queuedMessages).toEqual([ + expect.objectContaining({ + id: '42:local-user-1', + displayMessage: 'next question', + status: 'queued', + }), + ]) + expect(mockSetTaskInputMessage).toHaveBeenCalledWith('') + expect(mockResetAttachment).toHaveBeenCalled() + expect(mockResetContexts).toHaveBeenCalled() + }) + + it('returns a cancelled queued follow-up to the input before it is dispatched', async () => { + const { result, rerender } = renderQueueableHook() + + await act(async () => { + await result.current.handleSendMessage() + }) + + mockSetTaskInputMessage.mockClear() + taskInputMessageMock = '' + rerender() + + act(() => { + result.current.cancelQueuedMessage('42:local-user-1') + }) + + expect(result.current.queuedMessages).toEqual([]) + expect(mockSetTaskInputMessage).toHaveBeenCalledWith('next question') + + isMachineStreamingMock = false + selectedTaskDetailMock = { + id: 42, + status: 'COMPLETED', + is_group_chat: false, + subtasks: [], + } as unknown as TaskDetail + rerender() + + await Promise.resolve() + expect(mockContextSendMessage).not.toHaveBeenCalled() + }) + + it('merges a new queued input into the existing queued message and sends once', async () => { + taskInputMessageMock = 'first question' + const { result, rerender } = renderQueueableHook() + + await act(async () => { + await result.current.handleSendMessage() + }) + + taskInputMessageMock = 'second question' + rerender() + + await act(async () => { + await result.current.handleSendMessage() + }) + + expect(result.current.queuedMessages).toEqual([ + expect.objectContaining({ + displayMessage: 'first question\n\nsecond question', + status: 'queued', + }), + ]) + expect(mockContextSendMessage).not.toHaveBeenCalled() + + isMachineStreamingMock = false + selectedTaskDetailMock = { + id: 42, + status: 'COMPLETED', + is_group_chat: false, + subtasks: [], + } as unknown as TaskDetail + rerender() + + await waitFor(() => { + expect(mockContextSendMessage).toHaveBeenCalledTimes(1) + }) + expect(mockContextSendMessage.mock.calls[0][0]).toMatchObject({ + message: 'first question\n\nsecond question', + }) + expect(mockContextSendMessage.mock.calls[0][1]).toMatchObject({ + pendingUserMessage: 'first question\n\nsecond question', + }) + }) + + it('does not expose queue availability for a pending task unless it is streaming or awaiting response', () => { + isMachineStreamingMock = false + selectedTaskDetailMock = { + id: 42, + status: 'PENDING', + is_group_chat: false, + subtasks: [], + } as unknown as TaskDetail + + const { result } = renderQueueableHook() + + expect(result.current.canQueueMessage).toBe(false) + }) + + it('shows a retry action that resends a failed queued message', async () => { + mockContextSendMessage.mockImplementationOnce(async (_request, options) => { + options?.onError?.(new Error('network down')) + throw new Error('network down') + }) + const { result, rerender } = renderQueueableHook() + + await act(async () => { + await result.current.handleSendMessage() + }) + + isMachineStreamingMock = false + selectedTaskDetailMock = { + id: 42, + status: 'COMPLETED', + is_group_chat: false, + subtasks: [], + } as unknown as TaskDetail + rerender() + + await waitFor(() => { + expect(mockToast).toHaveBeenCalledWith( + expect.objectContaining({ + variant: 'destructive', + title: 'network down', + action: expect.anything(), + }) + ) + }) + expect(mockToast).toHaveBeenCalledTimes(1) + expect(result.current.queuedMessages[0]).toMatchObject({ + status: 'failed', + error: 'network down', + }) + + const toastAction = mockToast.mock.calls[0][0].action as React.ReactElement<{ + onClick: () => void + }> + await act(async () => { + toastAction.props.onClick() + await Promise.resolve() + }) + + await waitFor(() => { + expect(mockContextSendMessage).toHaveBeenCalledTimes(2) + }) + expect(mockUpdateUserMessage).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/__tests__/features/tasks/components/chat/useMessageSendQueue.test.tsx b/frontend/src/__tests__/features/tasks/components/chat/useMessageSendQueue.test.tsx new file mode 100644 index 00000000000..cc47a3caf99 --- /dev/null +++ b/frontend/src/__tests__/features/tasks/components/chat/useMessageSendQueue.test.tsx @@ -0,0 +1,256 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { useMessageSendQueue } from '@/features/tasks/components/chat/useMessageSendQueue' + +interface Snapshot { + message: string +} + +function queued(message: string, taskId = 42) { + return { + taskId, + localMessageId: `local-${message}`, + displayMessage: message, + snapshot: { message }, + } +} + +describe('useMessageSendQueue', () => { + it('dispatches queued messages in FIFO order when the task is unblocked', async () => { + const dispatchMessage = jest.fn().mockResolvedValue(undefined) + let isDispatchBlocked = true + + const { result, rerender } = renderHook(() => + useMessageSendQueue({ + taskId: 42, + isDispatchBlocked, + dispatchMessage, + }) + ) + + act(() => { + result.current.enqueueMessage(queued('first')) + result.current.enqueueMessage(queued('second')) + }) + + expect(dispatchMessage).not.toHaveBeenCalled() + + isDispatchBlocked = false + rerender() + + await waitFor(() => { + expect(dispatchMessage).toHaveBeenCalledTimes(2) + }) + expect(dispatchMessage.mock.calls.map(call => call[0].snapshot.message)).toEqual([ + 'first', + 'second', + ]) + expect(result.current.activeTaskQueue).toEqual([]) + }) + + it('pauses later queued messages when one dispatch fails', async () => { + const dispatchMessage = jest + .fn() + .mockRejectedValueOnce(new Error('network down')) + .mockResolvedValue(undefined) + + const { result } = renderHook(() => + useMessageSendQueue({ + taskId: 42, + isDispatchBlocked: false, + dispatchMessage, + }) + ) + + act(() => { + result.current.enqueueMessage(queued('first')) + result.current.enqueueMessage(queued('second')) + }) + + await waitFor(() => { + expect(result.current.activeTaskQueue[0]?.status).toBe('failed') + }) + + expect(dispatchMessage).toHaveBeenCalledTimes(1) + expect(result.current.activeTaskQueue.map(item => item.snapshot.message)).toEqual([ + 'first', + 'second', + ]) + }) + + it('dispatches only one message per unblocked window in one-per-unblock mode', async () => { + const dispatchMessage = jest.fn().mockResolvedValue(undefined) + let isDispatchBlocked = true + + const { result, rerender } = renderHook(() => + useMessageSendQueue({ + taskId: 42, + isDispatchBlocked, + dispatchMessage, + dispatchMode: 'one-per-unblock', + }) + ) + + act(() => { + result.current.enqueueMessage(queued('first')) + result.current.enqueueMessage(queued('second')) + }) + + isDispatchBlocked = false + rerender() + + await waitFor(() => { + expect(dispatchMessage).toHaveBeenCalledTimes(1) + }) + expect(dispatchMessage.mock.calls[0][0].snapshot.message).toBe('first') + await waitFor(() => { + expect(result.current.activeTaskQueue.map(item => item.snapshot.message)).toEqual(['second']) + }) + expect(dispatchMessage).toHaveBeenCalledTimes(1) + + isDispatchBlocked = true + rerender() + isDispatchBlocked = false + rerender() + + await waitFor(() => { + expect(dispatchMessage).toHaveBeenCalledTimes(2) + }) + expect(dispatchMessage.mock.calls.map(call => call[0].snapshot.message)).toEqual([ + 'first', + 'second', + ]) + }) + + it('retries a failed queued message before later queued messages continue', async () => { + const dispatchMessage = jest + .fn() + .mockRejectedValueOnce(new Error('network down')) + .mockResolvedValue(undefined) + + const { result } = renderHook(() => + useMessageSendQueue({ + taskId: 42, + isDispatchBlocked: false, + dispatchMessage, + }) + ) + + act(() => { + result.current.enqueueMessage(queued('first')) + result.current.enqueueMessage(queued('second')) + }) + + await waitFor(() => { + expect(result.current.activeTaskQueue[0]).toMatchObject({ + status: 'failed', + error: 'network down', + }) + }) + expect(dispatchMessage).toHaveBeenCalledTimes(1) + + const failedId = result.current.activeTaskQueue[0].id + act(() => { + result.current.retryMessage(failedId) + }) + + await waitFor(() => { + expect(dispatchMessage).toHaveBeenCalledTimes(3) + }) + expect(dispatchMessage.mock.calls.map(call => call[0].snapshot.message)).toEqual([ + 'first', + 'first', + 'second', + ]) + expect(result.current.activeTaskQueue).toEqual([]) + }) + + it('updates a queued message snapshot before it is dispatched', async () => { + const dispatchMessage = jest.fn().mockResolvedValue(undefined) + let isDispatchBlocked = true + + const { result, rerender } = renderHook(() => + useMessageSendQueue({ + taskId: 42, + isDispatchBlocked, + dispatchMessage, + }) + ) + + act(() => { + result.current.enqueueMessage(queued('first')) + }) + + const queuedId = result.current.activeTaskQueue[0].id + act(() => { + result.current.updateQueuedMessage(queuedId, message => ({ + ...message, + displayMessage: 'first\n\nsecond', + snapshot: { message: 'first\n\nsecond' }, + })) + }) + + isDispatchBlocked = false + rerender() + + await waitFor(() => { + expect(dispatchMessage).toHaveBeenCalledTimes(1) + }) + expect(dispatchMessage.mock.calls[0][0]).toMatchObject({ + displayMessage: 'first\n\nsecond', + snapshot: { message: 'first\n\nsecond' }, + }) + }) + + it('cancels a queued message before dispatch', async () => { + const dispatchMessage = jest.fn().mockResolvedValue(undefined) + let isDispatchBlocked = true + + const { result, rerender } = renderHook(() => + useMessageSendQueue({ + taskId: 42, + isDispatchBlocked, + dispatchMessage, + }) + ) + + act(() => { + result.current.enqueueMessage(queued('first')) + }) + + const queuedId = result.current.activeTaskQueue[0].id + act(() => { + result.current.cancelMessage(queuedId) + }) + + expect(result.current.activeTaskQueue).toEqual([]) + + isDispatchBlocked = false + rerender() + + await Promise.resolve() + expect(dispatchMessage).not.toHaveBeenCalled() + }) + + it('only dispatches messages for the active task', async () => { + const dispatchMessage = jest.fn().mockResolvedValue(undefined) + + const { result } = renderHook(() => + useMessageSendQueue({ + taskId: 42, + isDispatchBlocked: false, + dispatchMessage, + }) + ) + + act(() => { + result.current.enqueueMessage(queued('other-task', 100)) + result.current.enqueueMessage(queued('active-task', 42)) + }) + + await waitFor(() => { + expect(dispatchMessage).toHaveBeenCalledTimes(1) + }) + expect(dispatchMessage.mock.calls[0][0].snapshot.message).toBe('active-task') + expect(result.current.queuedMessages.map(item => item.snapshot.message)).toEqual(['other-task']) + }) +}) diff --git a/frontend/src/__tests__/features/tasks/components/input/ChatInputCard.layout.test.tsx b/frontend/src/__tests__/features/tasks/components/input/ChatInputCard.layout.test.tsx index d5aa63929aa..e52779a63f2 100644 --- a/frontend/src/__tests__/features/tasks/components/input/ChatInputCard.layout.test.tsx +++ b/frontend/src/__tests__/features/tasks/components/input/ChatInputCard.layout.test.tsx @@ -1,11 +1,15 @@ import '@testing-library/jest-dom' -import { render, screen } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import { ChatInputCard, type ChatInputCardProps, } from '@/features/tasks/components/input/ChatInputCard' +const mockChatInputControls = jest.fn((_props: Record) => ( +
+)) + jest.mock('@/hooks/useTranslation', () => ({ useTranslation: () => ({ t: (key: string) => key, @@ -35,7 +39,7 @@ jest.mock('@/features/tasks/components/selector/SelectedTeamBadge', () => ({ jest.mock('@/features/tasks/components/input/ChatInputControls', () => ({ __esModule: true, - default: () =>
, + default: (props: Record) => mockChatInputControls(props), })) jest.mock('@/features/tasks/components/input/DeviceSelectorTab', () => ({ @@ -161,6 +165,10 @@ const buildProps = (): ChatInputCardProps => ({ }) describe('ChatInputCard layout', () => { + beforeEach(() => { + mockChatInputControls.mockClear() + }) + it('keeps the badge area attached to the input content instead of distributing vertical gaps', () => { render() @@ -176,4 +184,83 @@ describe('ChatInputCard layout', () => { expect(chatInputWrapper).toHaveClass('pt-1.5') expect(chatInputWrapper).not.toHaveClass('pt-3') }) + + it('forwards queued and awaiting send state to input controls', () => { + render() + + expect(mockChatInputControls).toHaveBeenCalledWith( + expect.objectContaining({ + canQueueMessage: true, + isAwaitingResponseStart: true, + }) + ) + }) + + it('renders queued messages above the chat input', () => { + render( + + ) + + const queuedList = screen.getByTestId('queued-message-list') + const card = screen.getByTestId('chat-input-card') + const chatInput = screen.getByTestId('chat-input') + + expect(queuedList).toHaveTextContent('next question') + expect(queuedList).toHaveTextContent('messages.status_queued') + expect(queuedList.compareDocumentPosition(card) & Node.DOCUMENT_POSITION_FOLLOWING).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ) + expect(card).toContainElement(chatInput) + expect(card).not.toContainElement(queuedList) + }) + + it('cancels a queued message from the queue preview', () => { + const onCancelQueuedMessage = jest.fn() + + render( + + ) + + fireEvent.click(screen.getByTestId('cancel-queued-message-button')) + + expect(onCancelQueuedMessage).toHaveBeenCalledWith('42:local-user-1') + }) + + it('does not allow cancelling a queued message that is already sending', () => { + render( + + ) + + expect(screen.getByTestId('queued-message-list')).toHaveTextContent('messages.status_sending') + expect(screen.queryByTestId('cancel-queued-message-button')).not.toBeInTheDocument() + }) }) diff --git a/frontend/src/__tests__/features/tasks/components/input/chat-send-state.test.tsx b/frontend/src/__tests__/features/tasks/components/input/chat-send-state.test.tsx index 19bff2e04d1..a0ac556fcc0 100644 --- a/frontend/src/__tests__/features/tasks/components/input/chat-send-state.test.tsx +++ b/frontend/src/__tests__/features/tasks/components/input/chat-send-state.test.tsx @@ -6,6 +6,7 @@ import '@testing-library/jest-dom' import { render, screen } from '@testing-library/react' import type { ChatInputControlsProps } from '@/features/tasks/components/input/ChatInputControls' import { ChatInputControls } from '@/features/tasks/components/input/ChatInputControls' +import { getChatSendState } from '@/features/tasks/components/input/chatSendState' jest.mock('@/features/layout/hooks/useMediaQuery', () => ({ useIsMobile: () => false, @@ -52,7 +53,11 @@ jest.mock('@/features/tasks/components/AttachmentButton', () => ({ jest.mock('@/features/tasks/components/input/SendButton', () => ({ __esModule: true, - default: () => , + default: ({ ariaLabel }: { ariaLabel?: string }) => ( + + ), })) jest.mock('@/components/ui/action-button', () => ({ @@ -146,4 +151,188 @@ describe('ChatInputControls send state', () => { expect(screen.getByRole('button', { name: 'Stop generating' })).toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Send' })).not.toBeInTheDocument() }) + + it('labels queued send action separately from stop action', () => { + render() + + expect(screen.getByRole('button', { name: 'Stop generating' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Queue message' })).toBeInTheDocument() + }) +}) + +describe('getChatSendState', () => { + it('allows queue send while streaming when input has content and queuing is available', () => { + expect( + getChatSendState({ + isLoading: false, + isStreaming: true, + isAwaitingResponseStart: false, + isStopping: false, + isModelSelectionRequired: false, + isAttachmentReadyToSend: true, + hasNoTeams: false, + shouldHideChatInput: false, + taskInputMessage: 'next question', + selectedTaskStatus: 'RUNNING', + isSubtaskStreaming: true, + isGroupChat: false, + canQueueMessage: true, + }) + ).toEqual({ + primaryAction: 'queue', + isPrimaryDisabled: false, + showStopAction: true, + showPendingAction: false, + }) + }) + + it('keeps stop as the only action while streaming with empty input', () => { + expect( + getChatSendState({ + isLoading: false, + isStreaming: true, + isAwaitingResponseStart: false, + isStopping: false, + isModelSelectionRequired: false, + isAttachmentReadyToSend: true, + hasNoTeams: false, + shouldHideChatInput: false, + taskInputMessage: '', + selectedTaskStatus: 'RUNNING', + isSubtaskStreaming: true, + isGroupChat: false, + canQueueMessage: true, + }) + ).toEqual({ + primaryAction: 'stop', + isPrimaryDisabled: false, + showStopAction: true, + showPendingAction: false, + }) + }) + + it('keeps stop as the only action while streaming with hidden empty input', () => { + expect( + getChatSendState({ + isLoading: false, + isStreaming: true, + isAwaitingResponseStart: false, + isStopping: false, + isModelSelectionRequired: false, + isAttachmentReadyToSend: true, + hasNoTeams: false, + shouldHideChatInput: true, + taskInputMessage: '', + selectedTaskStatus: 'RUNNING', + isSubtaskStreaming: true, + isGroupChat: false, + canQueueMessage: true, + }) + ).toEqual({ + primaryAction: 'stop', + isPrimaryDisabled: false, + showStopAction: true, + showPendingAction: false, + }) + }) + + it('uses normal send outside streaming', () => { + expect( + getChatSendState({ + isLoading: false, + isStreaming: false, + isAwaitingResponseStart: false, + isStopping: false, + isModelSelectionRequired: false, + isAttachmentReadyToSend: true, + hasNoTeams: false, + shouldHideChatInput: false, + taskInputMessage: 'hello', + selectedTaskStatus: undefined, + isSubtaskStreaming: false, + isGroupChat: false, + canQueueMessage: false, + }) + ).toEqual({ + primaryAction: 'send', + isPrimaryDisabled: false, + showStopAction: false, + showPendingAction: false, + }) + }) + + it('shows pending loading for non-group pending task', () => { + expect( + getChatSendState({ + isLoading: false, + isStreaming: false, + isAwaitingResponseStart: false, + isStopping: false, + isModelSelectionRequired: false, + isAttachmentReadyToSend: true, + hasNoTeams: false, + shouldHideChatInput: false, + taskInputMessage: 'hello', + selectedTaskStatus: 'PENDING', + isSubtaskStreaming: false, + isGroupChat: false, + canQueueMessage: false, + }) + ).toEqual({ + primaryAction: 'loading', + isPrimaryDisabled: true, + showStopAction: false, + showPendingAction: true, + }) + }) + + it('allows normal send for idle pending group chat', () => { + expect( + getChatSendState({ + isLoading: false, + isStreaming: false, + isAwaitingResponseStart: false, + isStopping: false, + isModelSelectionRequired: false, + isAttachmentReadyToSend: true, + hasNoTeams: false, + shouldHideChatInput: false, + taskInputMessage: 'hello', + selectedTaskStatus: 'PENDING', + isSubtaskStreaming: false, + isGroupChat: true, + canQueueMessage: false, + }) + ).toEqual({ + primaryAction: 'send', + isPrimaryDisabled: false, + showStopAction: false, + showPendingAction: false, + }) + }) + + it('shows stop loading for cancelling task', () => { + expect( + getChatSendState({ + isLoading: false, + isStreaming: false, + isAwaitingResponseStart: false, + isStopping: false, + isModelSelectionRequired: false, + isAttachmentReadyToSend: true, + hasNoTeams: false, + shouldHideChatInput: false, + taskInputMessage: 'hello', + selectedTaskStatus: 'CANCELLING', + isSubtaskStreaming: false, + isGroupChat: false, + canQueueMessage: false, + }) + ).toEqual({ + primaryAction: 'loading', + isPrimaryDisabled: true, + showStopAction: true, + showPendingAction: false, + }) + }) }) diff --git a/frontend/src/apis/groups.ts b/frontend/src/apis/groups.ts index ff984257b7a..f3fb445d6db 100644 --- a/frontend/src/apis/groups.ts +++ b/frontend/src/apis/groups.ts @@ -21,6 +21,13 @@ import type { AddMemberResult, } from '@/types/group' +function buildQueryString(params: Record): string { + const entries = Object.entries(params) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => [k, String(v)] as [string, string]) + return `?${new URLSearchParams(entries).toString()}` +} + /** * List user's groups (created + joined) */ @@ -28,12 +35,22 @@ export const listGroups = async (params?: { page?: number limit?: number }): Promise => { - const queryString = params - ? `?${new URLSearchParams(params as Record).toString()}` - : '' + const queryString = params ? buildQueryString(params) : '' return await apiClient.get(`/groups${queryString}`) } +/** + * Search groups by name or display_name (level=group only) + */ +export const searchGroups = async (params: { + q: string + page?: number + limit?: number +}): Promise => { + const queryString = buildQueryString(params) + return await apiClient.get(`/groups/search${queryString}`) +} + /** * Create a new group */ diff --git a/frontend/src/apis/knowledge-permission.ts b/frontend/src/apis/knowledge-permission.ts index 975cd911e86..762604501d2 100644 --- a/frontend/src/apis/knowledge-permission.ts +++ b/frontend/src/apis/knowledge-permission.ts @@ -18,6 +18,7 @@ import type { PermissionReviewRequest, PermissionReviewResponse, PermissionUpdateRequest, + PermissionUserInfo, PublicKnowledgeBaseResponse, ShareLinkConfig, ShareLinkResponse, @@ -29,10 +30,14 @@ interface ResourceMemberResponse { resource_type: string resource_id: number user_id: number - user_name: string | null + display_name: string | null user_email: string | null role: string status: string + entity_type?: string | null + entity_id?: string | null + entity_display_name?: string | null + source_type?: string | null invited_by_user_id: number invited_by_user_name: string | null reviewed_by_user_id: number | null @@ -44,6 +49,7 @@ interface ResourceMemberResponse { updated_at: string } import client from './client' +import { ApiError } from './client' export const knowledgePermissionApi = { /** @@ -95,55 +101,65 @@ export const knowledgePermissionApi = { let pending: PermissionListResponse['pending'] = [] if (options?.includePendingRequests) { - const pendingResponse = await client.get<{ - requests: { - id: number - user_id: number - user_name: string | null - user_email: string | null - requested_role: string - requested_at: string - }[] - total: number - }>(`/share/KnowledgeBase/${kbId}/requests`) + try { + const pendingResponse = await client.get<{ + requests: { + id: number + user_id: number + user_name: string | null + user_email: string | null + requested_role: string + requested_at: string + }[] + total: number + }>(`/share/KnowledgeBase/${kbId}/requests`) - pending = pendingResponse.requests.map(r => { - return { - id: r.id, - user_id: r.user_id, - username: r.user_name || '', - email: r.user_email || '', - role: (r.requested_role as MemberRole) || 'Reporter', - requested_at: r.requested_at, + pending = pendingResponse.requests.map(r => { + return { + id: r.id, + user_id: r.user_id, + username: r.user_name || '', + email: r.user_email || '', + role: (r.requested_role as MemberRole) || 'Reporter', + requested_at: r.requested_at, + } + }) + } catch (err) { + // Viewer may have just lost manage permission (e.g. removed a group + // that granted them Maintainer). Treat as empty list instead of error. + if (!(err instanceof ApiError) || err.status !== 403) { + throw err } - }) + } } // Transform approved members - group by role - const approved = membersResponse.members.reduce( + const approved = membersResponse.members.reduce<{ + [K in MemberRole]: PermissionUserInfo[] + }>( (acc, m) => { const role = (m.role as MemberRole) || 'Reporter' if (!acc[role]) acc[role] = [] - const member = { + // For entity-type members, backend populates display_name via _get_entity_display_name + const isEntity = m.entity_type && m.entity_type !== 'user' + const member: PermissionUserInfo = { id: m.id, user_id: m.user_id, - username: m.user_name || '', - email: m.user_email || '', + display_name: + m.display_name || m.entity_display_name || (isEntity ? m.entity_id || '' : ''), + email: isEntity ? '' : m.user_email || '', role: role, requested_at: m.requested_at, reviewed_at: m.reviewed_at || undefined, reviewed_by: m.reviewed_by_user_id || undefined, + entity_type: m.entity_type || undefined, + entity_id: m.entity_id || undefined, + source_type: m.source_type || undefined, } acc[role].push(member) return acc }, - { Owner: [], Maintainer: [], Developer: [], Reporter: [], RestrictedAnalyst: [] } as { - Owner: typeof pending - Maintainer: typeof pending - Developer: typeof pending - Reporter: typeof pending - RestrictedAnalyst: typeof pending - } + { Owner: [], Maintainer: [], Developer: [], Reporter: [], RestrictedAnalyst: [] } ) return { pending, approved } }, @@ -175,6 +191,23 @@ export const knowledgePermissionApi = { return response }, + /** + * Add permission for a namespace (group-level permission) + */ + addNamespacePermission: async ( + kbId: number, + namespaceId: number, + role: string + ): Promise => { + const response = await client.post(`/share/KnowledgeBase/${kbId}/members`, { + user_id: 0, + role, + entity_type: 'namespace', + entity_id: String(namespaceId), + }) + return response + }, + /** * Batch add permissions for multiple users in a single request */ diff --git a/frontend/src/apis/mcpProviders.ts b/frontend/src/apis/mcpProviders.ts index 9be93371f78..ba5da257403 100644 --- a/frontend/src/apis/mcpProviders.ts +++ b/frontend/src/apis/mcpProviders.ts @@ -55,6 +55,22 @@ export interface MCPServerListResponse { error_details?: string } +export interface MCPToolInfo { + name: string + description: string +} + +export interface MCPTestRequest { + server_name: string + server_config: Record +} + +export interface MCPTestResponse { + success: boolean + error?: string + tools: MCPToolInfo[] +} + export const mcpProviderApis = { /** Get list of MCP providers */ getProviders: async (): Promise => { @@ -70,4 +86,9 @@ export const mcpProviderApis = { updateKeys: async (data: MCPProviderKeysRequest): Promise => { return apiClient.put('/mcp-providers/keys', data) }, + + /** Test MCP server connection */ + testServer: async (data: MCPTestRequest): Promise => { + return apiClient.post('/mcp-providers/test', data) + }, } diff --git a/frontend/src/apis/team.ts b/frontend/src/apis/team.ts index 48da01a7bfc..abc2ac783b1 100644 --- a/frontend/src/apis/team.ts +++ b/frontend/src/apis/team.ts @@ -37,6 +37,7 @@ export interface TeamShareInfoResponse { user_name: string team_id: number team_name: string + bind_mode?: string[] } // Team Share Join Request Type diff --git a/frontend/src/app/(tasks)/chat/page.tsx b/frontend/src/app/(tasks)/chat/page.tsx index 5e934b53c42..16cfd66105f 100644 --- a/frontend/src/app/(tasks)/chat/page.tsx +++ b/frontend/src/app/(tasks)/chat/page.tsx @@ -107,11 +107,7 @@ export default function ChatPage() { - {}} - onRefreshTeams={handleRefreshTeams} - /> + diff --git a/frontend/src/app/(tasks)/code/page.tsx b/frontend/src/app/(tasks)/code/page.tsx index 8a29a3879e0..4bbf69ddc61 100644 --- a/frontend/src/app/(tasks)/code/page.tsx +++ b/frontend/src/app/(tasks)/code/page.tsx @@ -71,11 +71,7 @@ export default function CodePage() { - {}} - onRefreshTeams={handleRefreshTeams} - /> + {/* Onboarding tour */} (null) - const handleRefreshTeams = async (): Promise => { return await refreshTeams() } @@ -50,11 +47,7 @@ function TasksPageContent() { - +
{/* Responsive sidebar */} @@ -77,7 +70,7 @@ function TasksPageContent() {
diff --git a/frontend/src/features/knowledge/document/auth-section-registry.ts b/frontend/src/features/knowledge/document/auth-section-registry.ts new file mode 100644 index 00000000000..a3e0b9be8f0 --- /dev/null +++ b/frontend/src/features/knowledge/document/auth-section-registry.ts @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +/** + * Auth section registry for extensible knowledge base authorization. + * + * Allows internal deployments (e.g., wecode) to register additional + * authorization sections for external entity types without modifying + * open-source components. + * + * Built-in sections (user, namespace) are registered by the OSS core. + * Internal extensions register via registerAuthSection() at module load time. + */ + +import type { ReactNode } from 'react' +import type { MemberRole } from '@/types/knowledge' + +/** Entry representing a selected authorization target */ +export interface AuthEntry { + id: string + label: string + entityType: string + entityId: string + role: MemberRole +} + +export interface AuthSectionConfig { + /** Entity type identifier, e.g. 'user', 'namespace', or other registered types */ + type: string + /** i18n key for the section label */ + labelKey: string + /** Render the search/select UI for this section */ + renderSearch: (props: { + role: MemberRole + onSelect: (entry: AuthEntry) => void + /** Optional entity ID to exclude from search results (e.g. the KB's owning namespace) */ + excludedEntityId?: string + }) => ReactNode +} + +const registry: AuthSectionConfig[] = [] +const listeners: Set<() => void> = new Set() + +function notify() { + listeners.forEach(l => l()) +} + +/** + * Subscribe to registry changes (e.g., when new sections are registered + * asynchronously via dynamic imports). + */ +export function subscribeAuthSections(listener: () => void): () => void { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +/** + * Register an auth section configuration. + * Call at module load time (top-level of a feature module). + */ +export function registerAuthSection(config: AuthSectionConfig): void { + // Prevent duplicate registrations + const existingIndex = registry.findIndex(r => r.type === config.type) + if (existingIndex >= 0) { + registry[existingIndex] = config + } else { + registry.push(config) + } + notify() +} + +/** Get all registered auth section configs */ +export function getAuthSections(): AuthSectionConfig[] { + return [...registry] +} diff --git a/frontend/src/features/knowledge/document/components/CommandPalette.tsx b/frontend/src/features/knowledge/document/components/CommandPalette.tsx index bddaac7fc7b..c030aed0b3a 100644 --- a/frontend/src/features/knowledge/document/components/CommandPalette.tsx +++ b/frontend/src/features/knowledge/document/components/CommandPalette.tsx @@ -12,7 +12,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react' import { Search, BookOpen, Database, Users } from 'lucide-react' -import { Dialog, DialogContent } from '@/components/ui/dialog' +import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { useTranslation } from '@/hooks/useTranslation' import { cn } from '@/lib/utils' @@ -152,6 +152,9 @@ export function CommandPalette({ return ( + + {t('document.sidebar.searchPlaceholder', '搜索知识库...')} + {/* Search input */}
diff --git a/frontend/src/features/knowledge/document/components/CreateKnowledgeBaseDialog.tsx b/frontend/src/features/knowledge/document/components/CreateKnowledgeBaseDialog.tsx index e1606531c2e..d2980965df5 100644 --- a/frontend/src/features/knowledge/document/components/CreateKnowledgeBaseDialog.tsx +++ b/frontend/src/features/knowledge/document/components/CreateKnowledgeBaseDialog.tsx @@ -23,8 +23,15 @@ import { SelectValue, } from '@/components/ui/select' import { useTranslation } from '@/hooks/useTranslation' -import type { SummaryModelRef, KnowledgeBaseType, RetrievalConfig } from '@/types/knowledge' +import type { + SummaryModelRef, + KnowledgeBaseType, + RetrievalConfig, + InitialMember, + MemberRole, +} from '@/types/knowledge' import { KnowledgeBaseForm } from './KnowledgeBaseForm' +import { KnowledgeBaseAuthSection } from './KnowledgeBaseAuthSection' /** Available group for selection */ export interface AvailableGroup { @@ -51,6 +58,8 @@ interface CreateKnowledgeBaseDialogProps { selectedGroupId?: string /** Knowledge base type selected by user */ kb_type: KnowledgeBaseType + /** Initial members (users/groups) to add after creation */ + members?: InitialMember[] }) => Promise loading?: boolean scope?: 'personal' | 'group' | 'organization' | 'all' @@ -123,6 +132,16 @@ export function CreateKnowledgeBaseDialog({ const [exemptCalls, setExemptCalls] = useState(5) // Selected group for creating KB (used when showGroupSelector is true) const [selectedGroupId, setSelectedGroupId] = useState(defaultGroupId || 'personal') + // Auth section: initial members to add + const [authEntries, setAuthEntries] = useState< + Array<{ + id: string + label: string + entityType: string + entityId: string + role: MemberRole + }> + >([]) // Reset selectedKbType and selectedGroupId when dialog opens useEffect(() => { @@ -172,6 +191,13 @@ export function CreateKnowledgeBaseDialog({ try { // Filter out empty guided questions const validGuidedQuestions = guidedQuestions.filter(q => q.trim().length > 0) + const members = authEntries.map(e => ({ + entity_type: e.entityType, + entity_id: e.entityId, + role: e.role, + entity_display_name: e.label, + })) as InitialMember[] + await onSubmit({ name: name.trim(), description: description.trim() || undefined, @@ -186,6 +212,7 @@ export function CreateKnowledgeBaseDialog({ exempt_calls_before_check: exemptCalls, selectedGroupId: showGroupSelector ? selectedGroupId : undefined, kb_type: selectedKbType, + members: members.length > 0 ? members : undefined, }) setName('') setDescription('') @@ -205,6 +232,7 @@ export function CreateKnowledgeBaseDialog({ }) setMaxCalls(10) setExemptCalls(5) + setAuthEntries([]) } catch (err) { setError(err instanceof Error ? err.message : t('common:error')) } @@ -234,6 +262,7 @@ export function CreateKnowledgeBaseDialog({ setError('') setAccordionValue('') setSelectedGroupId(defaultGroupId || 'personal') + setAuthEntries([]) } onOpenChange(newOpen) } @@ -317,7 +346,7 @@ export function CreateKnowledgeBaseDialog({ {/* Group selector - only show when showGroupSelector is true */} {showGroupSelector && availableGroups && availableGroups.length > 0 && (
- + setRoleForType(section.type, v as MemberRole)} + > + + {t(`document.permission.role.${role}`)} + + + +
+
+ {t('document.permission.role.Maintainer')} +
+
+ {t('document.permission.role.MaintainerDescription')} +
+
+
+ +
+
{t('document.permission.role.Developer')}
+
+ {t('document.permission.role.DeveloperDescription')} +
+
+
+ +
+
{t('document.permission.role.Reporter')}
+
+ {t('document.permission.role.ReporterDescription')} +
+
+
+ +
+
+ {t('document.permission.role.RestrictedAnalyst')} +
+
+ {t('document.permission.role.RestrictedAnalystDescription')} +
+
+
+
+ +
+
+ ) + })} + + + ) +} + +// ==================== Built-in auth section renderers ==================== + +function UserAuthSearch({ + role, + onSelect, +}: { + role: MemberRole + onSelect: (entry: AuthEntry) => void +}) { + const { t } = useTranslation('knowledge') + + const loc = (key: string, fallback: string) => { + const v = t(key) + return v && v !== key ? v : fallback + } + + return ( + { + if (users.length > 0) { + const user = users[users.length - 1] + onSelect({ + id: `user-${user.id}`, + label: user.user_name, + entityType: 'user', + entityId: String(user.id), + role, + }) + } + }} + placeholder={loc('document.permission.searchUserPlaceholder', '搜索用户...')} + multiple={false} + hideSelectedUsers + /> + ) +} + +function NamespaceAuthSearch({ + role, + onSelect, + excludedEntityId, +}: { + role: MemberRole + onSelect: (entry: AuthEntry) => void + excludedEntityId?: string +}) { + const { t } = useTranslation('knowledge') + const { user: currentUser } = useUser() + + const loc = (key: string, fallback: string) => { + const v = t(key) + return v && v !== key ? v : fallback + } + const [groups, setGroups] = useState([]) + const [searching, setSearching] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + const [showDropdown, setShowDropdown] = useState(false) + const inputRef = useRef(null) + const dropdownRef = useRef(null) + + const performSearch = useCallback(async (query: string) => { + setSearching(true) + try { + const result = await searchGroups({ q: query, limit: 20 }) + setGroups(result.items || []) + } catch (_err) { + // silently fail + } finally { + setSearching(false) + } + }, []) + + // Load initial groups on mount + useEffect(() => { + performSearch('') + }, [performSearch]) + + // Debounced search when query changes + useEffect(() => { + const timer = setTimeout(() => { + performSearch(searchQuery) + }, 300) + return () => clearTimeout(timer) + }, [searchQuery, performSearch]) + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) && + inputRef.current && + !inputRef.current.contains(event.target as Node) + ) { + setShowDropdown(false) + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, []) + + // Sort groups: owned by current user first, then by display_name + const filteredGroups = [...groups].sort((a, b) => { + const aOwned = currentUser ? a.owner_user_id === currentUser.id : false + const bOwned = currentUser ? b.owner_user_id === currentUser.id : false + if (aOwned && !bOwned) return -1 + if (!aOwned && bOwned) return 1 + return (a.display_name || a.name).localeCompare(b.display_name || b.name) + }) + + const handleSelect = (group: Group) => { + onSelect({ + id: `namespace-${group.id}`, + label: group.display_name || group.name, + entityType: 'namespace', + entityId: String(group.id), + role, + }) + setSearchQuery('') + setShowDropdown(false) + } + + return ( +
+
+ + { + setSearchQuery(e.target.value) + setShowDropdown(true) + }} + onFocus={() => { + if (!searching && groups.length === 0) performSearch(searchQuery) + setShowDropdown(true) + }} + placeholder={loc('document.permission.searchGroupPlaceholder', '搜索群组...')} + className="pl-9" + /> +
+ {showDropdown && ( +
+ {searching ? ( +
+ +
+ ) : filteredGroups.length === 0 ? ( +
+ {searchQuery.trim() + ? loc('document.permission.noGroupResults', '没有匹配的组') + : loc('document.permission.noGroups', '暂无可用群组')} +
+ ) : ( + filteredGroups.map(group => { + const isBound = excludedEntityId ? group.name === excludedEntityId : false + return ( + + ) + }) + )} +
+ )} +
+ ) +} + +// ==================== Register built-in auth sections ==================== + +registerAuthSection({ + type: 'user', + labelKey: 'document.permission.individual', + renderSearch: ({ role, onSelect }) => , +}) + +registerAuthSection({ + type: 'namespace', + labelKey: 'document.permission.namespace', + renderSearch: ({ role, onSelect, excludedEntityId }) => ( + + ), +}) diff --git a/frontend/src/features/knowledge/document/components/KnowledgeBaseForm.tsx b/frontend/src/features/knowledge/document/components/KnowledgeBaseForm.tsx index 68e8d51b6ec..cdf9f78001d 100644 --- a/frontend/src/features/knowledge/document/components/KnowledgeBaseForm.tsx +++ b/frontend/src/features/knowledge/document/components/KnowledgeBaseForm.tsx @@ -53,6 +53,8 @@ interface KnowledgeBaseFormProps { retrievalGroupName?: string retrievalReadOnly?: boolean retrievalPartialReadOnly?: boolean + /** Content to render before the auto-summary section */ + beforeSummarySection?: React.ReactNode /** Whether to show guided questions section (only for notebook type) */ showGuidedQuestions?: boolean /** Guided questions list (max 3) */ @@ -87,6 +89,7 @@ export function KnowledgeBaseForm({ retrievalGroupName, retrievalReadOnly, retrievalPartialReadOnly, + beforeSummarySection, showGuidedQuestions = false, guidedQuestions = [], onGuidedQuestionsChange, @@ -223,6 +226,8 @@ export function KnowledgeBaseForm({ />
+ {beforeSummarySection} +
diff --git a/frontend/src/features/knowledge/document/components/KnowledgeDetailPanel.tsx b/frontend/src/features/knowledge/document/components/KnowledgeDetailPanel.tsx index dc2264da875..173dbe65a5d 100644 --- a/frontend/src/features/knowledge/document/components/KnowledgeDetailPanel.tsx +++ b/frontend/src/features/knowledge/document/components/KnowledgeDetailPanel.tsx @@ -261,7 +261,7 @@ export function KnowledgeDetailPanel({
{headerActions}
- + )}
diff --git a/frontend/src/features/knowledge/document/components/KnowledgeDocumentPageDesktop.tsx b/frontend/src/features/knowledge/document/components/KnowledgeDocumentPageDesktop.tsx index 58530142c3a..85d1362adc2 100644 --- a/frontend/src/features/knowledge/document/components/KnowledgeDocumentPageDesktop.tsx +++ b/frontend/src/features/knowledge/document/components/KnowledgeDocumentPageDesktop.tsx @@ -8,47 +8,36 @@ * Hybrid navigation mode with: * - Left sidebar: Search, Favorites, Recent, Groups * - Right content area: KB detail or Group list - * - * Responsibilities kept here (thin orchestrator): - * - Sidebar collapse state & event wiring - * - URL sync on initial load - * - Navigation helpers (updateUrlParams, navigateToKb) - * - Selection handlers (handleSelectKb, handleSelectGroup, etc.) - * - Rendering main content area via renderMainContent() - * - Composing KnowledgeSidebar + dialogs from useKnowledgeDialogs - * - * Extracted sub-modules: - * - useGroupKbs (hooks/useGroupKbs.ts): group KB loading logic - * - useKnowledgeDialogs (components/KnowledgeDialogs.tsx): dialog state & CRUD handlers */ 'use client' import { useState, useEffect, useMemo, useCallback } from 'react' -import { useSearchParams, useRouter } from 'next/navigation' import { FolderOpen } from 'lucide-react' -import { Spinner } from '@/components/ui/spinner' import { userApis } from '@/apis/user' import { teamService } from '@/features/tasks/service/teamService' +import { saveGlobalModelPreference, type ModelPreference } from '@/utils/modelPreferences' import { getModelFromConfig } from '@/features/settings/services/bots' import { useTranslation } from '@/hooks/useTranslation' import { useUser } from '@/features/common/UserContext' import { useKnowledgeSidebar, type KnowledgeGroup } from '../hooks/useKnowledgeSidebar' import { useNamespaceRoleMap } from '../hooks/useNamespaceRoleMap' import { useGroupKbs } from '../hooks/useGroupKbs' -import { useKnowledgeDialogs } from './KnowledgeDialogs' +import { useKnowledgeUrlSync } from '../hooks/useKnowledgeUrlSync' +import { useKnowledgeBaseDialogs } from '../hooks/useKnowledgeBaseDialogs' import { KnowledgeSidebar } from './KnowledgeSidebar' import { KnowledgeDetailPanel } from './KnowledgeDetailPanel' import { KnowledgeGroupListPage, type KbDataItem } from './KnowledgeGroupListPage' import { DingtalkDocsPage } from './DingtalkDocs' -import type { AvailableGroup } from './CreateKnowledgeBaseDialog' -import type { MigrationTargetGroup } from './MigrateKnowledgeBaseDialog' +import { CreateKnowledgeBaseDialog, type AvailableGroup } from './CreateKnowledgeBaseDialog' +import { EditKnowledgeBaseDialog } from './EditKnowledgeBaseDialog' +import { DeleteKnowledgeBaseDialog } from './DeleteKnowledgeBaseDialog' +import { MigrateKnowledgeBaseDialog } from './MigrateKnowledgeBaseDialog' import { canCreateKnowledgeBaseInNamespace, canManageKnowledgeBase, } from '@/utils/namespace-permissions' -import { buildKbUrl } from '@/utils/knowledgeUrl' -import type { KnowledgeBase, KnowledgeBaseType } from '@/types/knowledge' +import type { KnowledgeBase, KnowledgeBaseWithGroupInfo, SummaryModelRef } from '@/types/knowledge' import type { DefaultTeamsResponse, Team } from '@/types/api' // Sidebar width constant @@ -69,19 +58,25 @@ export function KnowledgeDocumentPageDesktop({ initialDocPath, }: KnowledgeDocumentPageDesktopProps = {}) { const { t } = useTranslation('knowledge') - const router = useRouter() - const searchParams = useSearchParams() const { user } = useUser() // Knowledge sidebar hook const sidebar = useKnowledgeSidebar() const namespaceRoleMap = useNamespaceRoleMap() - // Destructure stable references from sidebar to satisfy exhaustive-deps - const { clearSelection, allKnowledgeBases } = sidebar + // Group KBs hook (extracted from inline logic) + const { + groupKbs, + isGroupKbsLoading, + reload: reloadGroupKbs, + } = useGroupKbs({ + selectedGroupId: sidebar.selectedGroupId, + groups: sidebar.groups, + personalCreatedByMe: sidebar.personalCreatedByMe, + personalSharedWithMe: sidebar.personalSharedWithMe, + }) // Sidebar collapse state - auto-collapse when notebook KB is selected - // Use localStorage to sync state with parent components (for TopNavigation expand button) const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => { if (typeof window !== 'undefined') { return localStorage.getItem('knowledge-sidebar-collapsed') === 'true' @@ -89,7 +84,6 @@ export function KnowledgeDocumentPageDesktop({ return false }) - // Sync collapse state to localStorage and dispatch custom event for parent components const updateSidebarCollapsed = useCallback((collapsed: boolean) => { setIsSidebarCollapsed(collapsed) if (typeof window !== 'undefined') { @@ -100,17 +94,14 @@ export function KnowledgeDocumentPageDesktop({ } }, []) - // Listen for collapse changes from parent components (e.g., TopNavigation expand button) useEffect(() => { const handleCollapseChange = (event: CustomEvent<{ collapsed: boolean }>) => { setIsSidebarCollapsed(event.detail.collapsed) } - window.addEventListener( 'knowledge-sidebar-collapse-change', handleCollapseChange as EventListener ) - return () => { window.removeEventListener( 'knowledge-sidebar-collapse-change', @@ -119,24 +110,10 @@ export function KnowledgeDocumentPageDesktop({ } }, []) - // Listen for clear selection event from TaskSidebar - useEffect(() => { - const handleClearSelection = () => { - clearSelection() - } - - window.addEventListener('knowledge-clear-selection', handleClearSelection) - - return () => { - window.removeEventListener('knowledge-clear-selection', handleClearSelection) - } - }, [clearSelection]) - // Default teams config for saving model preference const [defaultTeamsConfig, setDefaultTeamsConfig] = useState(null) const [teams, setTeams] = useState([]) - // Load default teams config and teams list on mount useEffect(() => { const loadDefaultTeamsAndTeams = async () => { try { @@ -153,7 +130,6 @@ export function KnowledgeDocumentPageDesktop({ loadDefaultTeamsAndTeams() }, []) - // Find knowledge mode default team and its bind_model const knowledgeTeamInfo = useMemo(() => { if (!defaultTeamsConfig?.knowledge || teams.length === 0) { return { id: null, bindModel: null as string | null } @@ -185,123 +161,61 @@ export function KnowledgeDocumentPageDesktop({ const knowledgeDefaultTeamId = knowledgeTeamInfo.id const knowledgeBindModel = knowledgeTeamInfo.bindModel - // Track if initial URL sync has been done - const [initialUrlSyncDone, setInitialUrlSyncDone] = useState(false) + const saveSummaryModelToPreference = useCallback( + (summaryModelRef: SummaryModelRef | null | undefined) => { + if (!knowledgeDefaultTeamId || !summaryModelRef?.name) return - // Sync selected KB or group from URL parameter on initial load only - useEffect(() => { - if (initialUrlSyncDone) return - if (sidebar.isGroupsLoading) return - - // Mode 1: Virtual URL - select KB by namespace + name - if (initialKbName) { - let found: (typeof allKnowledgeBases)[0] | undefined - - if (initialKbNamespace) { - found = allKnowledgeBases.find( - kb => - kb.name.toLowerCase() === initialKbName.toLowerCase() && - kb.namespace.toLowerCase() === initialKbNamespace.toLowerCase() - ) - } else { - found = allKnowledgeBases.find(kb => kb.name.toLowerCase() === initialKbName.toLowerCase()) + const preference: ModelPreference = { + modelName: summaryModelRef.name, + modelType: summaryModelRef.type, + forceOverride: true, + updatedAt: Date.now(), } - if (found) { - sidebar.selectKb(found) - } - setInitialUrlSyncDone(true) - return - } - - // Mode 2: Query params - select KB by id or group - const kbParam = searchParams.get('kb') - const groupParam = searchParams.get('group') + saveGlobalModelPreference(knowledgeDefaultTeamId, preference) + }, + [knowledgeDefaultTeamId] + ) - if (kbParam) { - const kbId = parseInt(kbParam, 10) - if (!isNaN(kbId)) { - const found = allKnowledgeBases.find(kb => kb.id === kbId) - if (found) { - sidebar.selectKb(found) - } - } - } else if (groupParam === 'dingtalk') { - // Restore DingTalk mode from URL - sidebar.selectDingtalk() - } else if (groupParam) { - sidebar.selectGroup(groupParam) - } - setInitialUrlSyncDone(true) - }, [ - searchParams, - allKnowledgeBases, - sidebar.isGroupsLoading, - sidebar.selectKb, - sidebar.selectGroup, - sidebar.selectDingtalk, + // URL sync and navigation + const { updateUrlParams, navigateToKb } = useKnowledgeUrlSync({ initialKbNamespace, initialKbName, - initialUrlSyncDone, - ]) - - // Helper function to update URL parameters for group/all navigation - const updateUrlParams = useCallback( - (params: { kb?: number | null; group?: string | null }) => { - if (params.kb !== undefined && params.kb !== null) return - - if (initialKbNamespace !== undefined) { - const newSearchParams = new URLSearchParams() - newSearchParams.set('type', 'document') - if (params.group !== undefined && params.group !== null) { - newSearchParams.set('group', params.group) - } - router.push(`/knowledge?${newSearchParams.toString()}`) - return - } - - const newSearchParams = new URLSearchParams(searchParams.toString()) - newSearchParams.set('type', 'document') - newSearchParams.delete('kb') - - if (params.group !== undefined) { - if (params.group === null) { - newSearchParams.delete('group') - } else { - newSearchParams.set('group', params.group) - } - } - - router.replace(`?${newSearchParams.toString()}`, { scroll: false }) - }, - [router, searchParams, initialKbNamespace] - ) + allKnowledgeBases: sidebar.allKnowledgeBases, + isGroupsLoading: sidebar.isGroupsLoading, + selectKb: sidebar.selectKb, + selectGroup: sidebar.selectGroup, + selectDingtalk: sidebar.selectDingtalk, + }) - // Navigate to KB detail page using canonical URL path - const navigateToKb = useCallback( - (kb: { name: string; namespace: string }) => { - const kbWithInfo = sidebar.allKnowledgeBasesWithGroupInfo.find( - k => k.name === kb.name && k.namespace === kb.namespace - ) - const isOrganization = kbWithInfo?.group_type === 'organization' - const kbPath = buildKbUrl(kb.namespace, kb.name, isOrganization) - router.push(kbPath) - }, - [router, sidebar.allKnowledgeBasesWithGroupInfo] - ) + // Handle clear selection event from sidebar + useEffect(() => { + const { clearSelection } = sidebar + const handleClearSelection = () => { + clearSelection() + updateUrlParams({ kb: null, group: null }) + } + window.addEventListener('knowledge-clear-selection', handleClearSelection) + return () => { + window.removeEventListener('knowledge-clear-selection', handleClearSelection) + } + }, [sidebar, updateUrlParams]) - // Group KBs loaded via extracted hook - const { groupKbs, isGroupKbsLoading } = useGroupKbs({ - selectedGroupId: sidebar.selectedGroupId, - groups: sidebar.groups, - personalCreatedByMe: sidebar.personalCreatedByMe, - personalSharedWithMe: sidebar.personalSharedWithMe, + // Dialog management + const dialogs = useKnowledgeBaseDialogs({ + sidebar, + saveSummaryModelToPreference, + reloadGroupKbs, }) // Auto-collapse sidebar when a notebook KB is selected useEffect(() => { if (sidebar.selectedKb) { - updateSidebarCollapsed(sidebar.selectedKb.kb_type === 'notebook') + if (sidebar.selectedKb.kb_type === 'notebook') { + updateSidebarCollapsed(true) + } else { + updateSidebarCollapsed(false) + } } else { updateSidebarCollapsed(false) } @@ -314,8 +228,14 @@ export function KnowledgeDocumentPageDesktop({ }, [sidebar.selectedGroupId, sidebar.groups]) const canCreateInSelectedGroup = useMemo(() => { - if (!selectedGroup) return false - if (selectedGroup.type === 'personal') return true + if (!selectedGroup) { + return false + } + + if (selectedGroup.type === 'personal') { + return true + } + return canCreateKnowledgeBaseInNamespace({ namespace: selectedGroup.name, namespaceRole: namespaceRoleMap.get(selectedGroup.name), @@ -334,104 +254,6 @@ export function KnowledgeDocumentPageDesktop({ [user?.id, namespaceRoleMap] ) - // Build available groups for filter dropdown - const availableGroups = useMemo(() => { - return sidebar.groups.map(g => ({ - id: g.id, - name: g.name, - displayName: g.displayName, - })) - }, [sidebar.groups]) - - // Build available groups for create dialog (with canCreate flag) - const availableGroupsForCreate = useMemo((): AvailableGroup[] => { - return sidebar.groups.map(g => ({ - id: g.id, - name: g.name, - displayName: g.displayName, - type: g.type, - canCreate: - g.type === 'personal' - ? true - : canCreateKnowledgeBaseInNamespace({ - namespace: g.name, - namespaceRole: namespaceRoleMap.get(g.name), - }), - })) - }, [sidebar.groups, namespaceRoleMap]) - - const hasCreatableTeamGroup = useMemo(() => { - return availableGroupsForCreate.some(group => group.type === 'group' && group.canCreate) - }, [availableGroupsForCreate]) - - // Build available target groups for migration - const availableMigrationGroups = useMemo((): MigrationTargetGroup[] => { - return sidebar.groups - .filter(g => g.type === 'group' || g.type === 'organization') - .map(g => ({ - id: g.id, - name: g.name, - displayName: g.displayName, - type: g.type as 'group' | 'organization', - })) - }, [sidebar.groups]) - - // Check if KB can be migrated (only personal KBs created by current user) - const canMigrateKb = useCallback( - (kb: { id: number; namespace: string; user_id: number }) => { - if (kb.namespace !== 'default') return false - return kb.user_id === sidebar.currentUser?.id - }, - [sidebar.currentUser] - ) - - // Check if KB is favorite - const isFavorite = useCallback( - (kbId: number) => sidebar.favorites.some(kb => kb.id === kbId), - [sidebar.favorites] - ) - - // Toggle favorite - const { addFavorite, removeFavorite } = sidebar - const handleToggleFavorite = useCallback( - async (kbId: number, shouldFavorite: boolean) => { - if (shouldFavorite) { - await addFavorite(kbId) - } else { - await removeFavorite(kbId) - } - }, - [addFavorite, removeFavorite] - ) - - // Dialog state and CRUD handlers via extracted hook - const { openCreate, openEdit, openDelete, openMigrate, dialogsElement } = useKnowledgeDialogs({ - groups: sidebar.groups, - selectedGroupId: sidebar.selectedGroupId, - availableGroupsForCreate, - availableMigrationGroups, - knowledgeDefaultTeamId, - knowledgeBindModel, - onCreated: sidebar.refreshAll, - onUpdated: sidebar.refreshAll, - onDeleted: async (deletedKbId: number) => { - await sidebar.refreshAll() - if (deletedKbId === sidebar.selectedKbId) { - sidebar.clearSelection() - } - }, - onMigrated: async (migratedKbId: number) => { - await sidebar.refreshAll() - if (migratedKbId === sidebar.selectedKbId) { - sidebar.clearSelection() - } - }, - onReloadGroupKbs: () => { - // groupKbs is managed by useGroupKbs hook which re-fetches on selectedGroupId change - }, - }) - - // Handle KB selection const handleSelectKb = useCallback( (kb: KnowledgeBase | { id: number; name: string; namespace: string }) => { const fullKb = sidebar.allKnowledgeBases.find(k => k.id === kb.id) @@ -442,19 +264,17 @@ export function KnowledgeDocumentPageDesktop({ if (initialKbName) { sidebar.selectKb(fullKb) } - navigateToKb(fullKb) + navigateToKb(fullKb, sidebar.allKnowledgeBasesWithGroupInfo) } }, [sidebar, navigateToKb, initialKbName] ) - // Handle "All" selection const handleSelectAll = useCallback(() => { sidebar.selectAll() updateUrlParams({ kb: null, group: null }) }, [sidebar, updateUrlParams]) - // Handle group selection const handleSelectGroup = useCallback( (groupId: string) => { sidebar.selectGroup(groupId) @@ -463,56 +283,81 @@ export function KnowledgeDocumentPageDesktop({ [sidebar, updateUrlParams] ) - // Handle back from group list const handleBackFromGroup = useCallback(() => { sidebar.clearSelection() updateUrlParams({ kb: null, group: null }) }, [sidebar, updateUrlParams]) - // Handle create KB from group list - const handleCreateKbFromGroup = useCallback( - (kbType: KnowledgeBaseType) => { - if (!selectedGroup) return - if (selectedGroup.type === 'personal') { - openCreate(kbType, 'personal') - } else if (selectedGroup.type === 'organization') { - openCreate(kbType, 'organization') - } else { - openCreate(kbType, 'group', selectedGroup.name) - } - }, - [selectedGroup, openCreate] - ) - - // Handle "Groups" selection const handleSelectGroups = useCallback(() => { sidebar.selectGroups() updateUrlParams({ kb: null, group: 'all-groups' }) }, [sidebar, updateUrlParams]) - // Handle "DingTalk" selection const handleSelectDingtalk = useCallback(() => { sidebar.selectDingtalk() updateUrlParams({ kb: null, group: 'dingtalk' }) }, [sidebar, updateUrlParams]) - // Handle create KB from "All" page - const handleCreateKbFromAll = useCallback( - (kbType: KnowledgeBaseType) => { - openCreate(kbType, 'personal', undefined, true) + const isFavorite = useCallback( + (kbId: number) => { + return sidebar.favorites.some(kb => kb.id === kbId) }, - [openCreate] + [sidebar.favorites] ) - // Handle create KB from "Groups" page - const handleCreateKbFromGroups = useCallback( - (kbType: KnowledgeBaseType) => { - openCreate(kbType, 'group', undefined, true) + const handleToggleFavorite = useCallback( + async (kbId: number, shouldFavorite: boolean) => { + if (shouldFavorite) { + await sidebar.addFavorite(kbId) + } else { + await sidebar.removeFavorite(kbId) + } }, - [openCreate] + [sidebar] ) - // Handle group click from KB detail panel + const availableGroups = useMemo(() => { + return sidebar.groups.map(g => ({ + id: g.id, + name: g.name, + displayName: g.displayName, + })) + }, [sidebar.groups]) + + const availableGroupsForCreate = useMemo((): AvailableGroup[] => { + return sidebar.groups.map(g => ({ + id: g.id, + name: g.name, + displayName: g.displayName, + type: g.type, + canCreate: + g.type === 'personal' + ? true + : canCreateKnowledgeBaseInNamespace({ + namespace: g.name, + namespaceRole: namespaceRoleMap.get(g.name), + }), + })) + }, [sidebar.groups, namespaceRoleMap]) + + const hasCreatableTeamGroup = useMemo(() => { + return availableGroupsForCreate.some(group => group.type === 'group' && group.canCreate) + }, [availableGroupsForCreate]) + + const allModeKbsWithInfo = useMemo((): KnowledgeBaseWithGroupInfo[] => { + const map = new Map() + for (const kb of sidebar.allKnowledgeBasesWithGroupInfo) { + if (!map.has(kb.id)) { + map.set(kb.id, { ...kb }) + } + } + return Array.from(map.values()) + }, [sidebar.allKnowledgeBasesWithGroupInfo]) + + const selectedKbGroupInfo = sidebar.selectedKb + ? sidebar.getKbGroupInfo(sidebar.selectedKb) + : undefined + const handleGroupClick = useCallback( (groupId: string, groupType?: string) => { let sidebarGroupId: string @@ -529,36 +374,14 @@ export function KnowledgeDocumentPageDesktop({ [sidebar, updateUrlParams] ) - // Get group info for the selected KB - const selectedKbGroupInfo = sidebar.selectedKb - ? sidebar.getKbGroupInfo(sidebar.selectedKb) - : undefined - const renderMainContent = () => { - if (sidebar.viewMode === 'dingtalk') { - // Wait for DingTalk status to finish loading before rendering - if (sidebar.isDingtalkLoading) { - return ( -
- -
- ) - } - return ( - sidebar.refreshAll()} - /> - ) - } - if (sidebar.selectedKb) { return ( updateSidebarCollapsed(false)} - onEditKb={openEdit} + onEditKb={dialogs.setEditingKb} groupInfo={selectedKbGroupInfo} onGroupClick={handleGroupClick} initialDocPath={initialDocPath} @@ -574,7 +397,7 @@ export function KnowledgeDocumentPageDesktop({ return ( ({ id: kb.id, name: kb.name, @@ -593,14 +416,14 @@ export function KnowledgeDocumentPageDesktop({ knowledgeBasesWithGroupInfo={teamGroupKbs} isLoading={sidebar.isGroupsLoading} onSelectKb={handleSelectKb} - onCreateKb={hasCreatableTeamGroup ? handleCreateKbFromGroups : undefined} + onCreateKb={hasCreatableTeamGroup ? dialogs.handleCreateKbFromGroups : undefined} onEditKb={kb => { const fullKb = sidebar.allKnowledgeBases.find(k => k.id === kb.id) - if (fullKb) openEdit(fullKb) + if (fullKb) dialogs.setEditingKb(fullKb) }} onDeleteKb={kb => { const fullKb = sidebar.allKnowledgeBases.find(k => k.id === kb.id) - if (fullKb) openDelete(fullKb) + if (fullKb) dialogs.setDeletingKb(fullKb) }} canManageKb={canManageKbInList} onToggleFavorite={handleToggleFavorite} @@ -614,23 +437,39 @@ export function KnowledgeDocumentPageDesktop({ ) } + if (sidebar.viewMode === 'dingtalk') { + if (sidebar.isDingtalkLoading) { + return ( +
+
+
+ ) + } + return ( + sidebar.refreshAll()} + /> + ) + } + if (sidebar.viewMode === 'all') { return ( { const fullKb = sidebar.allKnowledgeBases.find(k => k.id === kb.id) - if (fullKb) openEdit(fullKb) + if (fullKb) dialogs.setEditingKb(fullKb) }} onDeleteKb={kb => { const fullKb = sidebar.allKnowledgeBases.find(k => k.id === kb.id) - if (fullKb) openDelete(fullKb) + if (fullKb) dialogs.setDeletingKb(fullKb) }} canManageKb={canManageKbInList} onToggleFavorite={handleToggleFavorite} @@ -649,14 +488,19 @@ export function KnowledgeDocumentPageDesktop({ const groupKbsWithInfo = sidebar.allKnowledgeBasesWithGroupInfo.filter(kb => { if (selectedGroup.type === 'personal') { - return kb.group_type === 'personal' + return kb.group_type === 'personal' || kb.group_type === 'personal-shared' } else if (selectedGroup.type === 'organization') { return kb.group_type === 'organization' } else { - return kb.group_type === 'group' && kb.namespace === selectedGroup.name + return kb.group_type === 'group' && kb.group_id === selectedGroup.name } }) + const groupNativeKbs = + selectedGroup.type === 'group' ? groupKbsWithInfo.filter(kb => !kb.shared_from) : [] + const groupSharedKbs = + selectedGroup.type === 'group' ? groupKbsWithInfo.filter(kb => kb.shared_from) : [] + return ( { const fullKb = sidebar.allKnowledgeBases.find(k => k.id === kb.id) || groupKbs.find(k => k.id === kb.id) - if (fullKb) openEdit(fullKb) + if (fullKb) dialogs.setEditingKb(fullKb) }} onDeleteKb={kb => { const fullKb = sidebar.allKnowledgeBases.find(k => k.id === kb.id) || groupKbs.find(k => k.id === kb.id) - if (fullKb) openDelete(fullKb) + if (fullKb) dialogs.setDeletingKb(fullKb) }} canManageKb={canManageKbInList} onToggleFavorite={handleToggleFavorite} @@ -685,27 +529,28 @@ export function KnowledgeDocumentPageDesktop({ isPersonalMode={isPersonalMode} personalCreatedByMe={isPersonalMode ? sidebar.personalCreatedByMe : undefined} personalSharedWithMe={isPersonalMode ? sidebar.personalSharedWithMe : undefined} + groupNativeKbs={groupNativeKbs} + groupSharedKbs={groupSharedKbs} getKbGroupInfo={sidebar.getKbGroupInfo} onMigrateKb={kb => { const fullKb = sidebar.allKnowledgeBases.find(k => k.id === kb.id) || groupKbs.find(k => k.id === kb.id) - if (fullKb) openMigrate(fullKb) + if (fullKb) dialogs.setMigratingKb(fullKb) }} - canMigrate={canMigrateKb} + canMigrate={dialogs.canMigrateKb} /> ) } - // Empty state return (

- {t('document.tree.emptyState', '请从左侧选择一个知识库')} + {t('document.tree.emptyState', 'Please select a knowledge base from the left')}

- {t('document.tree.emptyStateHint', '浏览并选择一个知识库以查看详情')} + {t('document.tree.emptyStateHint', 'Browse and select a knowledge base to view details')}

) @@ -713,7 +558,6 @@ export function KnowledgeDocumentPageDesktop({ return (
- {/* Left sidebar */} {!isSidebarCollapsed && (
updateSidebarCollapsed(true)} dingtalkDocCount={sidebar.dingtalkDocCount} isDingtalkConfigured={sidebar.isDingtalkConfigured} + summary={sidebar.summary} + allKnowledgeBases={sidebar.allKnowledgeBases} + onCollapse={() => updateSidebarCollapsed(true)} />
)} - {/* Right content area */}
{renderMainContent()}
- {/* Dialogs */} - {dialogsElement} + { + if (!dialogs.isCreating) { + dialogs.setShowCreateDialog(open) + if (!open) { + dialogs.resetCreateDialogState() + } + } + }} + onSubmit={dialogs.handleCreate} + loading={dialogs.isCreating} + scope={dialogs.createScope} + groupName={dialogs.createGroupName} + kbType={dialogs.createKbType} + knowledgeDefaultTeamId={knowledgeDefaultTeamId} + bindModel={knowledgeBindModel} + showGroupSelector={dialogs.showGroupSelector} + availableGroups={availableGroupsForCreate} + defaultGroupId="personal" + /> + !dialogs.isUpdating && !open && dialogs.setEditingKb(null)} + knowledgeBase={dialogs.editingKb} + onSubmit={dialogs.handleUpdate} + loading={dialogs.isUpdating} + knowledgeDefaultTeamId={knowledgeDefaultTeamId} + bindModel={knowledgeBindModel} + /> + + !dialogs.isDeleting && !open && dialogs.setDeletingKb(null)} + knowledgeBase={dialogs.deletingKb} + onConfirm={dialogs.handleDelete} + loading={dialogs.isDeleting} + /> + + !dialogs.isMigrating && !open && dialogs.setMigratingKb(null)} + knowledgeBase={dialogs.migratingKb} + availableGroups={dialogs.availableMigrationGroups} + onMigrate={dialogs.handleMigrate} + loading={dialogs.isMigrating} + />
) } diff --git a/frontend/src/features/knowledge/document/components/KnowledgeDocumentPageMobile.tsx b/frontend/src/features/knowledge/document/components/KnowledgeDocumentPageMobile.tsx index 511ce497cdb..f36b640c1ad 100644 --- a/frontend/src/features/knowledge/document/components/KnowledgeDocumentPageMobile.tsx +++ b/frontend/src/features/knowledge/document/components/KnowledgeDocumentPageMobile.tsx @@ -181,6 +181,7 @@ export function KnowledgeDocumentPageMobile({ guided_questions: data.guided_questions, max_calls_per_conversation: data.max_calls_per_conversation, exempt_calls_before_check: data.exempt_calls_before_check, + members: data.members, }) // Save model preference when summary is enabled and model is selected diff --git a/frontend/src/features/knowledge/document/components/KnowledgeGroupListPage.tsx b/frontend/src/features/knowledge/document/components/KnowledgeGroupListPage.tsx index 108ec6cf323..1009a25982d 100644 --- a/frontend/src/features/knowledge/document/components/KnowledgeGroupListPage.tsx +++ b/frontend/src/features/knowledge/document/components/KnowledgeGroupListPage.tsx @@ -14,6 +14,7 @@ 'use client' import { useState, useMemo, useCallback } from 'react' +import { getKbShareSourceText } from '@/features/knowledge/utils/share-source' import { ArrowLeft, Plus, @@ -93,6 +94,10 @@ export interface KnowledgeGroupListPageProps { personalCreatedByMe?: KnowledgeBaseWithGroupInfo[] /** Knowledge bases shared with current user (for personal mode) */ personalSharedWithMe?: KnowledgeBaseWithGroupInfo[] + /** Native group knowledge bases (for group section mode) */ + groupNativeKbs?: KnowledgeBaseWithGroupInfo[] + /** Shared-into-group knowledge bases (for group section mode) */ + groupSharedKbs?: KnowledgeBaseWithGroupInfo[] /** Migrate a knowledge base to group */ onMigrateKb?: (kb: KbDataItem) => void /** Check if user can migrate a KB (only for personal KBs created by user) */ @@ -184,9 +189,12 @@ export function KnowledgeGroupListPage({ isPersonalMode = false, personalCreatedByMe = [], personalSharedWithMe = [], + groupNativeKbs = [], + groupSharedKbs = [], onMigrateKb, canMigrate, }: KnowledgeGroupListPageProps) { + const isGroupSectionMode = groupNativeKbs.length > 0 || groupSharedKbs.length > 0 const { t } = useTranslation('knowledge') const [sortBy, setSortBy] = useState('default') const [sortOrder, setSortOrder] = useState('asc') @@ -276,9 +284,20 @@ export function KnowledgeGroupListPage({ }) } + // Dedup by ID as a safety measure against duplicate data + const seen = new Set() + result = result.filter(kb => { + if (seen.has(kb.id)) return false + seen.add(kb.id) + return true + }) + return sortKbs(result) }, [dataSource, isAllMode, filterGroupId, getKbGroupInfo, sortKbs]) + // Determine whether to show the "from" column + const showFromColumn = !isPersonalMode && !isAllMode + // Sorted personal KBs for personal mode const sortedCreatedByMe = useMemo( () => sortKbs(personalCreatedByMe), @@ -288,6 +307,8 @@ export function KnowledgeGroupListPage({ () => sortKbs(personalSharedWithMe), [personalSharedWithMe, sortKbs] ) + const sortedNative = useMemo(() => sortKbs(groupNativeKbs), [groupNativeKbs, sortKbs]) + const sortedShared = useMemo(() => sortKbs(groupSharedKbs), [groupSharedKbs, sortKbs]) const handleToggleFavorite = useCallback( (e: React.MouseEvent, kb: KbDataItem) => { @@ -321,7 +342,11 @@ export function KnowledgeGroupListPage({ } // Render table header - const renderTableHeader = (showGroupColumn: boolean) => ( + const renderTableHeader = ( + showGroupColumn: boolean, + showFromColumn: boolean, + groupColumnTitle?: string + ) => ( @@ -339,11 +364,14 @@ export function KnowledgeGroupListPage({ className="flex items-center hover:text-text-primary transition-colors" onClick={() => handleSort('group')} > - {t('document.table.group', '归属小组')} + {groupColumnTitle || t('document.table.group', '归属')} )} + {showFromColumn && ( + {t('document.table.from', '来自')} + )} {t('document.table.permission', '权限')}
) diff --git a/frontend/src/features/knowledge/document/extension-loader.ts b/frontend/src/features/knowledge/document/extension-loader.ts new file mode 100644 index 00000000000..7e2eb3b54ff --- /dev/null +++ b/frontend/src/features/knowledge/document/extension-loader.ts @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +/** + * Knowledge base extension loader. + * + * The module path is injected at build time via the environment variable + * NEXT_PUBLIC_KB_EXTENSION_MODULE. In open-source builds this variable is + * unset and the loader is a no-op. Internal builds set it to + * @wecode/features/knowledge via webpack DefinePlugin or .env. + */ + +const EXTENSION_MODULE = + (typeof process !== 'undefined' && process.env.NEXT_PUBLIC_KB_EXTENSION_MODULE) || '' + +export async function loadKBExtensions(): Promise { + if (!EXTENSION_MODULE) { + return + } + try { + await import(/* webpackIgnore: true */ EXTENSION_MODULE) + } catch (error) { + console.warn(`Failed to load KB extension module "${EXTENSION_MODULE}"`, error) + } +} diff --git a/frontend/src/features/knowledge/document/hooks/useFolders.ts b/frontend/src/features/knowledge/document/hooks/useFolders.ts index 8bb8a131977..1635cf16f8a 100644 --- a/frontend/src/features/knowledge/document/hooks/useFolders.ts +++ b/frontend/src/features/knowledge/document/hooks/useFolders.ts @@ -105,19 +105,16 @@ export function useFolders(options: UseFoldersOptions) { [knowledgeBaseId, fetchFolders] ) - const move = useCallback( - async (documentId: number, folderId: number): Promise => { - try { - await moveDocument(documentId, folderId) - return true - } catch (err) { - const msg = err instanceof Error ? err.message : t('document.folder.moveDocumentFailed') - toast({ description: msg, variant: 'destructive' }) - return false - } - }, - [] - ) + const move = useCallback(async (documentId: number, folderId: number): Promise => { + try { + await moveDocument(documentId, folderId) + return true + } catch (err) { + const msg = err instanceof Error ? err.message : t('document.folder.moveDocumentFailed') + toast({ description: msg, variant: 'destructive' }) + return false + } + }, []) return { folders, @@ -129,4 +126,4 @@ export function useFolders(options: UseFoldersOptions) { deleteFolder: remove, moveDocument: move, } -} \ No newline at end of file +} diff --git a/frontend/src/features/knowledge/document/hooks/useGroupKbs.ts b/frontend/src/features/knowledge/document/hooks/useGroupKbs.ts index b486ddf53d0..ecac6fc09d4 100644 --- a/frontend/src/features/knowledge/document/hooks/useGroupKbs.ts +++ b/frontend/src/features/knowledge/document/hooks/useGroupKbs.ts @@ -53,6 +53,8 @@ export interface UseGroupKbsReturn { groupKbs: KnowledgeBase[] isGroupKbsLoading: boolean toKnowledgeBase: typeof toKnowledgeBase + /** Trigger a manual reload of group KBs */ + reload: () => void } export function useGroupKbs({ @@ -63,6 +65,11 @@ export function useGroupKbs({ }: UseGroupKbsProps): UseGroupKbsReturn { const [groupKbs, setGroupKbs] = useState([]) const [isGroupKbsLoading, setIsGroupKbsLoading] = useState(false) + const [reloadCounter, setReloadCounter] = useState(0) + + const reload = useCallback(() => { + setReloadCounter(c => c + 1) + }, []) useEffect(() => { if (!selectedGroupId) { @@ -113,7 +120,7 @@ export function useGroupKbs({ return () => { isCancelled = true } - }, [selectedGroupId, groups, personalCreatedByMe, personalSharedWithMe]) + }, [selectedGroupId, groups, personalCreatedByMe, personalSharedWithMe, reloadCounter]) - return { groupKbs, isGroupKbsLoading, toKnowledgeBase: useCallback(toKnowledgeBase, []) } + return { groupKbs, isGroupKbsLoading, toKnowledgeBase: useCallback(toKnowledgeBase, []), reload } } diff --git a/frontend/src/features/knowledge/document/hooks/useKnowledgeBaseDialogs.ts b/frontend/src/features/knowledge/document/hooks/useKnowledgeBaseDialogs.ts new file mode 100644 index 00000000000..605b109b4b0 --- /dev/null +++ b/frontend/src/features/knowledge/document/hooks/useKnowledgeBaseDialogs.ts @@ -0,0 +1,346 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +/** + * useKnowledgeBaseDialogs - Hook for managing knowledge base dialog states and handlers. + * + * Encapsulates create, edit, delete, and migrate dialog logic to keep the + * page component focused on layout and navigation. + */ + +import { useState, useCallback, useMemo } from 'react' +import { useRouter } from 'next/navigation' +import { migrateKnowledgeBaseToGroup } from '@/apis/knowledge' +import type { + KnowledgeBase, + KnowledgeBaseCreate, + KnowledgeBaseType, + KnowledgeBaseUpdate, + SummaryModelRef, +} from '@/types/knowledge' +import type { MigrationTargetGroup } from '../components/MigrateKnowledgeBaseDialog' + +interface SidebarLike { + groups: Array<{ id: string; name: string; displayName: string; type: string }> + selectedGroupId: string | null + currentUser: { id: number } | null + selectedKbId: number | null + refreshAll: () => Promise + clearSelection: () => void +} + +interface UseKnowledgeBaseDialogsParams { + sidebar: SidebarLike + saveSummaryModelToPreference: (summaryModelRef: SummaryModelRef | null | undefined) => void + reloadGroupKbs: () => void +} + +export interface UseKnowledgeBaseDialogsReturn { + // Create dialog + showCreateDialog: boolean + setShowCreateDialog: (show: boolean) => void + createScope: 'personal' | 'group' | 'organization' + setCreateScope: (scope: 'personal' | 'group' | 'organization') => void + createGroupName: string | undefined + setCreateGroupName: (name: string | undefined) => void + createKbType: KnowledgeBaseType + setCreateKbType: (type: KnowledgeBaseType) => void + showGroupSelector: boolean + setShowGroupSelector: (show: boolean) => void + isCreating: boolean + createError: string | null + + // Edit dialog + editingKb: KnowledgeBase | null + setEditingKb: (kb: KnowledgeBase | null) => void + isUpdating: boolean + + // Delete dialog + deletingKb: KnowledgeBase | null + setDeletingKb: (kb: KnowledgeBase | null) => void + isDeleting: boolean + + // Migrate dialog + migratingKb: KnowledgeBase | null + setMigratingKb: (kb: KnowledgeBase | null) => void + isMigrating: boolean + availableMigrationGroups: MigrationTargetGroup[] + + // Handlers + handleCreate: ( + data: Omit & { selectedGroupId?: string } + ) => Promise + handleUpdate: (data: KnowledgeBaseUpdate) => Promise + handleDelete: () => Promise + handleMigrate: (targetGroupName: string) => Promise + handleCreateKbFromAll: (kbType: KnowledgeBaseType) => void + handleCreateKbFromGroups: (kbType: KnowledgeBaseType) => void + handleCreateKbFromGroup: (kbType: KnowledgeBaseType) => void + canMigrateKb: (kb: { id: number; namespace: string; user_id: number }) => boolean + resetCreateDialogState: () => void +} + +export function useKnowledgeBaseDialogs({ + sidebar, + saveSummaryModelToPreference, + reloadGroupKbs, +}: UseKnowledgeBaseDialogsParams): UseKnowledgeBaseDialogsReturn { + const router = useRouter() + + // Create dialog state + const [showCreateDialog, setShowCreateDialog] = useState(false) + const [createScope, setCreateScope] = useState<'personal' | 'group' | 'organization'>('personal') + const [createGroupName, setCreateGroupName] = useState(undefined) + const [createKbType, setCreateKbType] = useState('notebook') + const [showGroupSelector, setShowGroupSelector] = useState(false) + const [isCreating, setIsCreating] = useState(false) + + // Edit dialog state + const [editingKb, setEditingKb] = useState(null) + const [isUpdating, setIsUpdating] = useState(false) + + // Delete dialog state + const [deletingKb, setDeletingKb] = useState(null) + const [isDeleting, setIsDeleting] = useState(false) + + // Migrate dialog state + const [migratingKb, setMigratingKb] = useState(null) + const [isMigrating, setIsMigrating] = useState(false) + + const resetCreateDialogState = useCallback(() => { + setCreateGroupName(undefined) + setCreateScope('personal') + setCreateKbType('notebook') + setShowGroupSelector(false) + }, []) + + const [createError, setCreateError] = useState(null) + + const handleCreate = useCallback( + async (data: Omit & { selectedGroupId?: string }) => { + setIsCreating(true) + setCreateError(null) + try { + let namespace = 'default' + + if (data.selectedGroupId) { + const selectedGroup = sidebar.groups.find(g => g.id === data.selectedGroupId) + if (!selectedGroup) { + throw new Error(`Selected group ${data.selectedGroupId} not found`) + } + if (selectedGroup.type === 'personal') { + namespace = 'default' + } else { + namespace = selectedGroup.name + } + } else if (createScope === 'organization') { + const orgGroup = sidebar.groups.find(g => g.type === 'organization') + if (!orgGroup) { + throw new Error('Organization group not found') + } + namespace = orgGroup.name + } else if (createGroupName) { + namespace = createGroupName + } + + const kbType = data.kb_type || createKbType + + const { createKnowledgeBase } = await import('@/apis/knowledge') + await createKnowledgeBase({ + name: data.name, + description: data.description, + namespace, + retrieval_config: data.retrieval_config, + summary_enabled: data.summary_enabled, + summary_model_ref: data.summary_model_ref, + kb_type: kbType, + guided_questions: data.guided_questions, + max_calls_per_conversation: data.max_calls_per_conversation, + exempt_calls_before_check: data.exempt_calls_before_check, + members: data.members, + }) + + if (data.summary_enabled && data.summary_model_ref) { + saveSummaryModelToPreference(data.summary_model_ref) + } + setShowCreateDialog(false) + resetCreateDialogState() + + await sidebar.refreshAll() + if (sidebar.selectedGroupId) { + reloadGroupKbs() + } + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to create knowledge base' + setCreateError(message) + throw err + } finally { + setIsCreating(false) + } + }, + [ + createScope, + createGroupName, + createKbType, + sidebar, + saveSummaryModelToPreference, + reloadGroupKbs, + resetCreateDialogState, + ] + ) + + const handleUpdate = useCallback( + async (data: KnowledgeBaseUpdate) => { + if (!editingKb) return + setIsUpdating(true) + try { + const { updateKnowledgeBase } = await import('@/apis/knowledge') + await updateKnowledgeBase(editingKb.id, data) + + if (data.summary_enabled && data.summary_model_ref) { + saveSummaryModelToPreference(data.summary_model_ref) + } + + await sidebar.refreshAll() + setEditingKb(null) + } finally { + setIsUpdating(false) + } + }, + [editingKb, sidebar, saveSummaryModelToPreference] + ) + + const handleDelete = useCallback(async () => { + if (!deletingKb) return + setIsDeleting(true) + try { + const { deleteKnowledgeBase } = await import('@/apis/knowledge') + await deleteKnowledgeBase(deletingKb.id) + + await sidebar.refreshAll() + + if (deletingKb.id === sidebar.selectedKbId) { + sidebar.clearSelection() + router.push('/knowledge?type=document') + } + + setDeletingKb(null) + } finally { + setIsDeleting(false) + } + }, [deletingKb, sidebar, router]) + + const handleMigrate = useCallback( + async (targetGroupName: string) => { + if (!migratingKb) return + setIsMigrating(true) + try { + await migrateKnowledgeBaseToGroup(migratingKb.id, targetGroupName) + await sidebar.refreshAll() + + if (migratingKb.id === sidebar.selectedKbId) { + sidebar.clearSelection() + router.push('/knowledge?type=document') + } + + setMigratingKb(null) + } catch (error) { + throw error + } finally { + setIsMigrating(false) + } + }, + [migratingKb, sidebar, router] + ) + + const handleCreateKbFromAll = useCallback((kbType: KnowledgeBaseType) => { + setCreateScope('personal') + setCreateGroupName(undefined) + setCreateKbType(kbType) + setShowGroupSelector(true) + setShowCreateDialog(true) + }, []) + + const handleCreateKbFromGroups = useCallback((kbType: KnowledgeBaseType) => { + setCreateScope('group') + setCreateGroupName(undefined) + setCreateKbType(kbType) + setShowGroupSelector(true) + setShowCreateDialog(true) + }, []) + + const handleCreateKbFromGroup = useCallback( + (kbType: KnowledgeBaseType) => { + const selectedGroup = sidebar.groups.find(g => g.id === sidebar.selectedGroupId) + if (!selectedGroup) return + + if (selectedGroup.type === 'personal') { + setCreateScope('personal') + setCreateGroupName(undefined) + } else if (selectedGroup.type === 'organization') { + setCreateScope('organization') + setCreateGroupName(undefined) + } else { + setCreateScope('group') + setCreateGroupName(selectedGroup.name) + } + setCreateKbType(kbType) + setShowCreateDialog(true) + }, + [sidebar.groups, sidebar.selectedGroupId] + ) + + const canMigrateKb = useCallback( + (kb: { id: number; namespace: string; user_id: number }) => { + if (kb.namespace !== 'default') return false + return kb.user_id === sidebar.currentUser?.id + }, + [sidebar.currentUser] + ) + + const availableMigrationGroups = useMemo((): MigrationTargetGroup[] => { + return sidebar.groups + .filter(g => g.type === 'group' || g.type === 'organization') + .map(g => ({ + id: g.id, + name: g.name, + displayName: g.displayName, + type: g.type as 'group' | 'organization', + })) + }, [sidebar.groups]) + + return { + showCreateDialog, + setShowCreateDialog, + createScope, + setCreateScope, + createGroupName, + setCreateGroupName, + createKbType, + setCreateKbType, + showGroupSelector, + setShowGroupSelector, + isCreating, + createError, + editingKb, + setEditingKb, + isUpdating, + deletingKb, + setDeletingKb, + isDeleting, + migratingKb, + setMigratingKb, + isMigrating, + availableMigrationGroups, + handleCreate, + handleUpdate, + handleDelete, + handleMigrate, + handleCreateKbFromAll, + handleCreateKbFromGroups, + handleCreateKbFromGroup, + canMigrateKb, + resetCreateDialogState, + } +} diff --git a/frontend/src/features/knowledge/document/hooks/useKnowledgeSidebar.ts b/frontend/src/features/knowledge/document/hooks/useKnowledgeSidebar.ts index ee2fc90e6af..3e0cab91a0c 100644 --- a/frontend/src/features/knowledge/document/hooks/useKnowledgeSidebar.ts +++ b/frontend/src/features/knowledge/document/hooks/useKnowledgeSidebar.ts @@ -109,6 +109,14 @@ export interface UseKnowledgeSidebarReturn { isDingtalkConfigured: boolean isDingtalkLoading: boolean + // Summary counts from backend + summary?: { + total_count: number + personal_count: number + group_count: number + organization_count: number + } + // Refresh refreshAll: () => Promise } @@ -239,18 +247,27 @@ export function useKnowledgeSidebar(): UseKnowledgeSidebarReturn { ...allGroupedData.organization.knowledge_bases, ] - // Remove duplicates by ID - const seen = new Set() + // Remove duplicates by (id, group_id) so the same KB can appear in different groups + const seen = new Set() return all.filter(kb => { - if (seen.has(kb.id)) return false - seen.add(kb.id) + const key = `${kb.id}-${kb.group_id}` + if (seen.has(key)) return false + seen.add(key) return true }) }, [allGroupedData]) // Build all knowledge bases list for search (without group info) const allKnowledgeBases = useMemo((): KnowledgeBase[] => { - return allKnowledgeBasesWithGroupInfo.map(toKnowledgeBase) + // Dedupe by id for search/recent items + const seen = new Set() + return allKnowledgeBasesWithGroupInfo + .filter(kb => { + if (seen.has(kb.id)) return false + seen.add(kb.id) + return true + }) + .map(toKnowledgeBase) }, [allKnowledgeBasesWithGroupInfo]) // Build a map from KB ID to group info for quick lookup @@ -269,6 +286,15 @@ export function useKnowledgeSidebar(): UseKnowledgeSidebarReturn { // Get group info for a KB (accepts both KnowledgeBase and KnowledgeBaseWithGroupInfo) const getKbGroupInfo = useCallback( (kb: KbDataItem): KbGroupInfo => { + // If kb has source_group (entity-authorized shared KB), show the source group + if ('source_group' in kb && kb.source_group) { + return { + groupId: 'shared', + groupName: kb.source_group, + groupType: 'personal-shared', + } + } + // If kb already has group info (KnowledgeBaseWithGroupInfo), use it directly if ('group_id' in kb && 'group_name' in kb && 'group_type' in kb) { return { @@ -307,6 +333,7 @@ export function useKnowledgeSidebar(): UseKnowledgeSidebarReturn { // Personal group const personalKbCount = + allGroupedData.summary?.personal_count ?? allGroupedData.personal.created_by_me.length + allGroupedData.personal.shared_with_me.length result.push({ id: 'personal', @@ -333,7 +360,7 @@ export function useKnowledgeSidebar(): UseKnowledgeSidebarReturn { type: 'organization', name: allGroupedData.organization.namespace || 'organization', displayName: allGroupedData.organization.display_name || '公司', - kbCount: allGroupedData.organization.kb_count, + kbCount: allGroupedData.summary?.organization_count ?? allGroupedData.organization.kb_count, }) return result @@ -554,6 +581,9 @@ export function useKnowledgeSidebar(): UseKnowledgeSidebarReturn { isDingtalkConfigured, isDingtalkLoading, + // Summary from backend + summary: allGroupedData?.summary, + // Refresh refreshAll, } diff --git a/frontend/src/features/knowledge/document/hooks/useKnowledgeUrlSync.ts b/frontend/src/features/knowledge/document/hooks/useKnowledgeUrlSync.ts new file mode 100644 index 00000000000..c23be61ae5e --- /dev/null +++ b/frontend/src/features/knowledge/document/hooks/useKnowledgeUrlSync.ts @@ -0,0 +1,139 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +/** + * useKnowledgeUrlSync - Hook for syncing knowledge page state with URL. + * + * Handles initial KB/group selection from URL params or virtual URL paths, + * and provides helpers to update the URL when navigation changes. + */ + +import { useState, useEffect, useCallback } from 'react' +import { useSearchParams, useRouter } from 'next/navigation' +import { buildKbUrl } from '@/utils/knowledgeUrl' +import type { KnowledgeBase } from '@/types/knowledge' + +interface UseKnowledgeUrlSyncParams { + initialKbNamespace?: string + initialKbName?: string + allKnowledgeBases: KnowledgeBase[] + isGroupsLoading: boolean + selectKb: (kb: KnowledgeBase) => void + selectGroup: (groupId: string) => void + selectDingtalk: () => void +} + +export function useKnowledgeUrlSync({ + initialKbNamespace, + initialKbName, + allKnowledgeBases, + isGroupsLoading, + selectKb, + selectGroup, + selectDingtalk, +}: UseKnowledgeUrlSyncParams) { + const router = useRouter() + const searchParams = useSearchParams() + const [initialUrlSyncDone, setInitialUrlSyncDone] = useState(false) + + // Sync selected KB or group from URL parameter on initial load only + useEffect(() => { + if (initialUrlSyncDone) return + if (isGroupsLoading) return + + if (initialKbName) { + let found: KnowledgeBase | undefined + if (initialKbNamespace) { + found = allKnowledgeBases.find( + kb => + kb.name.toLowerCase() === initialKbName.toLowerCase() && + kb.namespace.toLowerCase() === initialKbNamespace.toLowerCase() + ) + } else { + found = allKnowledgeBases.find(kb => kb.name.toLowerCase() === initialKbName.toLowerCase()) + } + if (found) { + selectKb(found) + } + setInitialUrlSyncDone(true) + return + } + + const kbParam = searchParams.get('kb') + const groupParam = searchParams.get('group') + + if (kbParam) { + const kbId = parseInt(kbParam, 10) + if (!isNaN(kbId)) { + const found = allKnowledgeBases.find(kb => kb.id === kbId) + if (found) { + selectKb(found) + } + } + } else if (groupParam === 'dingtalk') { + selectDingtalk() + } else if (groupParam) { + selectGroup(groupParam) + } + setInitialUrlSyncDone(true) + }, [ + searchParams, + allKnowledgeBases, + isGroupsLoading, + selectKb, + selectGroup, + selectDingtalk, + initialKbNamespace, + initialKbName, + initialUrlSyncDone, + ]) + + const updateUrlParams = useCallback( + (params: { kb?: number | null; group?: string | null }) => { + if (params.kb !== undefined && params.kb !== null) return + + if (initialKbName !== undefined) { + const newSearchParams = new URLSearchParams() + newSearchParams.set('type', 'document') + if (params.group !== undefined && params.group !== null) { + newSearchParams.set('group', params.group) + } + router.push(`/knowledge?${newSearchParams.toString()}`) + return + } + + const newSearchParams = new URLSearchParams(searchParams.toString()) + newSearchParams.set('type', 'document') + newSearchParams.delete('kb') + + if (params.group !== undefined) { + if (params.group === null) { + newSearchParams.delete('group') + } else { + newSearchParams.set('group', params.group) + } + } + + router.replace(`?${newSearchParams.toString()}`, { scroll: false }) + }, + [router, searchParams, initialKbName] + ) + + const navigateToKb = useCallback( + ( + kb: { name: string; namespace: string }, + allKbsWithGroupInfo: Array<{ name: string; namespace: string; group_type?: string }> + ) => { + const kbWithInfo = allKbsWithGroupInfo.find( + k => k.name === kb.name && k.namespace === kb.namespace + ) + const isOrganization = kbWithInfo?.group_type === 'organization' + const kbPath = buildKbUrl(kb.namespace, kb.name, isOrganization) + router.push(kbPath) + }, + [router] + ) + + return { initialUrlSyncDone, updateUrlParams, navigateToKb } +} diff --git a/frontend/src/features/knowledge/document/pages/KnowledgeBaseClassicPageDesktop.tsx b/frontend/src/features/knowledge/document/pages/KnowledgeBaseClassicPageDesktop.tsx index aa24f45a0f6..32d0f766195 100644 --- a/frontend/src/features/knowledge/document/pages/KnowledgeBaseClassicPageDesktop.tsx +++ b/frontend/src/features/knowledge/document/pages/KnowledgeBaseClassicPageDesktop.tsx @@ -261,7 +261,10 @@ export function KnowledgeBaseClassicPageDesktop({ knowledgeBaseId, initialDocPat /> - + ) : ( diff --git a/frontend/src/features/knowledge/document/pages/KnowledgeBaseClassicPageMobile.tsx b/frontend/src/features/knowledge/document/pages/KnowledgeBaseClassicPageMobile.tsx index 00d7d66b75b..e0cef552660 100644 --- a/frontend/src/features/knowledge/document/pages/KnowledgeBaseClassicPageMobile.tsx +++ b/frontend/src/features/knowledge/document/pages/KnowledgeBaseClassicPageMobile.tsx @@ -12,6 +12,7 @@ import { TaskSidebar, SearchDialog } from '@/features/tasks/components/sidebar' import { ThemeToggle } from '@/features/theme/ThemeToggle' import { Button } from '@/components/ui/button' import { Spinner } from '@/components/ui/spinner' +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' import { saveLastTab } from '@/utils/userPreferences' import { useUser } from '@/features/common/UserContext' import { useSearchShortcut } from '@/features/tasks/hooks/useSearchShortcut' @@ -20,9 +21,11 @@ import { useKnowledgeBaseDetail } from '@/features/knowledge/document/hooks' import { useNamespaceRoleMap } from '@/features/knowledge/document/hooks/useNamespaceRoleMap' import { useKnowledgePermissions } from '@/features/knowledge/permission/hooks/useKnowledgePermissions' import { DocumentList } from '@/features/knowledge/document/components' +import { PermissionManagementTab } from '@/features/knowledge/permission/components/PermissionManagementTab' import { canManageKnowledgeBase, canManageKnowledgeBaseDocuments, + canManageKnowledgeBasePermissions, } from '@/utils/namespace-permissions' /** * Mobile-specific implementation of Knowledge Base Classic Page @@ -71,6 +74,9 @@ export function KnowledgeBaseClassicPageMobile({ knowledgeBaseId, initialDocPath // Search dialog state const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(false) + // Tab state for permission management + const [activeTab, setActiveTab] = useState<'documents' | 'permissions'>('documents') + const namespaceRoleMap = useNamespaceRoleMap() // Toggle search dialog @@ -114,6 +120,17 @@ export function KnowledgeBaseClassicPageMobile({ knowledgeBaseId, initialDocPath }) }, [knowledgeBase, user, myPermission?.role, namespaceRoleMap]) + // Check if user can manage permissions (creator, namespace manager, or KB manager) + const canManagePermissions = useMemo(() => { + if (!knowledgeBase || !user) return false + return canManageKnowledgeBasePermissions({ + currentUserId: user.id, + knowledgeBase, + knowledgeRole: myPermission?.role, + namespaceRole: namespaceRoleMap.get(knowledgeBase.namespace), + }) + }, [knowledgeBase, user, myPermission?.role, namespaceRoleMap]) + // Loading state if (kbLoading) { return ( @@ -174,14 +191,44 @@ export function KnowledgeBaseClassicPageMobile({ knowledgeBaseId, initialDocPath - {/* Document List */} + {/* Document List / Permission Management */}
- + {canManagePermissions ? ( + setActiveTab(v as 'documents' | 'permissions')} + > + + + {t('knowledge:document.documents')} + + + {t('knowledge:document.permissions')} + + + + + + + + + + ) : ( + + )}
diff --git a/frontend/src/features/knowledge/permission/components/PermissionManagementTab.tsx b/frontend/src/features/knowledge/permission/components/PermissionManagementTab.tsx index e08509ad3c9..b90aeb82c29 100644 --- a/frontend/src/features/knowledge/permission/components/PermissionManagementTab.tsx +++ b/frontend/src/features/knowledge/permission/components/PermissionManagementTab.tsx @@ -5,7 +5,9 @@ 'use client' import { useEffect, useState } from 'react' -import { Check, X, UserPlus, Pencil, Trash2, Users, Clock, Shield } from 'lucide-react' +import { Check, X, UserPlus, Pencil, Trash2, User, Users, Clock } from 'lucide-react' + +import { getPermissionTabs, subscribePermissionTabs } from '../permission-tab-registry' import { Button } from '@/components/ui/button' import { Card } from '@/components/ui/card' import { Spinner } from '@/components/ui/spinner' @@ -19,18 +21,64 @@ import { import { useTranslation } from '@/hooks/useTranslation' import { useKnowledgePermissions } from '../hooks/useKnowledgePermissions' import { AddUserDialog } from './add-user-dialog' -import type { MemberRole, PendingPermissionInfo, PermissionUserInfo } from '@/types/knowledge' +import { AddNamespaceDialog } from './add-namespace-dialog' import { ASSIGNABLE_ROLES } from '@/types/base-role' +import type { + MemberRole, + PendingPermissionInfo, + PermissionUserInfo, + ApprovedPermissionsByRole, +} from '@/types/knowledge' + +/** + * Build role-grouped members by applying a filter to each role list. + */ +function buildRoleGroups( + approved: ApprovedPermissionsByRole, + filter: (user: PermissionUserInfo) => boolean +): ApprovedPermissionsByRole { + return { + Owner: approved.Owner.filter(filter), + Maintainer: approved.Maintainer.filter(filter), + Developer: approved.Developer.filter(filter), + Reporter: approved.Reporter.filter(filter), + RestrictedAnalyst: approved.RestrictedAnalyst.filter(filter), + } +} interface PermissionManagementTabProps { kbId: number + /** The namespace this KB belongs to; groups in this namespace are excluded from group authorization */ + kbNamespace?: string + /** + * Extension tabs rendered alongside standard tabs (Personal/Groups). + * Used by internal deployments to add entity-type permission panels. + */ + extensionTabs?: Array<{ + label: string + icon?: React.ReactNode + render: (props: { kbId: number }) => React.ReactNode + }> } -export function PermissionManagementTab({ kbId }: PermissionManagementTabProps) { +export function PermissionManagementTab({ + kbId, + kbNamespace, + extensionTabs, +}: PermissionManagementTabProps) { const { t } = useTranslation('knowledge') + + // Safe translation helper that falls back when key is not found + const loc = (key: string, fallback: string) => { + const v = t(key) + return v && v !== key ? v : fallback + } + const [showAddUser, setShowAddUser] = useState(false) + const [showAddNamespace, setShowAddNamespace] = useState(false) const [editingId, setEditingId] = useState(null) const [editingRole, setEditingRole] = useState('Reporter') + const [activeTab, setActiveTab] = useState('user') // Track selected approval roles for pending requests const [approvalRoles, setApprovalRoles] = useState>({}) @@ -50,6 +98,15 @@ export function PermissionManagementTab({ kbId }: PermissionManagementTabProps) fetchPermissions() }, [fetchPermissions]) + // Load optional KB extensions (e.g., org_department in internal builds) + useEffect(() => { + import('../../document/extension-loader').then(({ loadKBExtensions }) => { + loadKBExtensions().catch((err: unknown) => { + console.warn('Failed to load KB extensions:', err) + }) + }) + }, []) + // Initialize approval roles when pending requests change useEffect(() => { if (permissions?.pending) { @@ -113,6 +170,12 @@ export function PermissionManagementTab({ kbId }: PermissionManagementTabProps) setEditingId(null) } + // Track registered external permission tabs (must be before any early return) + const [externalTabs, setExternalTabs] = useState(getPermissionTabs) + useEffect(() => { + return subscribePermissionTabs(() => setExternalTabs(getPermissionTabs())) + }, []) + if (loading && !permissions) { return (
@@ -122,27 +185,68 @@ export function PermissionManagementTab({ kbId }: PermissionManagementTabProps) } const pendingCount = permissions?.pending.length || 0 + + // Separate namespace members from user members + const allApproved = permissions?.approved + + // Filter namespace members into role groups for group display — must be before namespaceGroupCount + const filterNamespaceIn = (users: PermissionUserInfo[]) => + users.filter(u => u.entity_type === 'namespace') + const namespaceRoleGroups = allApproved + ? { + Owner: filterNamespaceIn(allApproved.Owner), + Maintainer: filterNamespaceIn(allApproved.Maintainer), + Developer: filterNamespaceIn(allApproved.Developer), + Reporter: filterNamespaceIn(allApproved.Reporter), + RestrictedAnalyst: filterNamespaceIn(allApproved.RestrictedAnalyst), + } + : null + + const namespaceGroupCount = namespaceRoleGroups + ? Object.values(namespaceRoleGroups).reduce((sum, arr) => sum + arr.length, 0) + : 0 + + const boundNamespaceIds = namespaceRoleGroups + ? Array.from( + new Set( + Object.values(namespaceRoleGroups) + .flat() + .map(m => m.entity_id) + .filter((id): id is string => !!id) + ) + ) + : [] + + // Filter namespace members out of role groups for user display + const filterNamespaceOut = (users: PermissionUserInfo[]) => + users.filter(u => !u.entity_type || u.entity_type === 'user') + const userRoleGroups = allApproved + ? { + Owner: filterNamespaceOut(allApproved.Owner), + Maintainer: filterNamespaceOut(allApproved.Maintainer), + Developer: filterNamespaceOut(allApproved.Developer), + Reporter: filterNamespaceOut(allApproved.Reporter), + RestrictedAnalyst: filterNamespaceOut(allApproved.RestrictedAnalyst), + } + : null + const approvedCount = - (permissions?.approved.Owner?.length || 0) + - (permissions?.approved.Maintainer?.length || 0) + - (permissions?.approved.Developer?.length || 0) + - (permissions?.approved.Reporter?.length || 0) + - (permissions?.approved.RestrictedAnalyst?.length || 0) + (userRoleGroups?.Owner?.length || 0) + + (userRoleGroups?.Maintainer?.length || 0) + + (userRoleGroups?.Developer?.length || 0) + + (userRoleGroups?.Reporter?.length || 0) + + (userRoleGroups?.RestrictedAnalyst?.length || 0) + + // Compute role groups for registered external permission tabs + const externalRoleGroups: Record> = {} + if (allApproved) { + for (const tab of externalTabs) { + externalRoleGroups[tab.type] = buildRoleGroups(allApproved, tab.filter) + } + } return (
- {/* Header with Add User button */} -
-

- - {t('document.permission.management')} -

- -
- {/* Error Message */} {error && (
@@ -153,185 +257,496 @@ export function PermissionManagementTab({ kbId }: PermissionManagementTabProps)
)} - {/* Pending Requests Section */} - -
- - {t('document.permission.pendingRequests')} - {pendingCount > 0 && ( - - {pendingCount} - - )} -
- - {pendingCount === 0 ? ( -

- {t('document.permission.noPendingRequests')} -

- ) : ( -
- {permissions?.pending.map(permission => ( -
-
-
{permission.username}
-
- {permission.email || t('document.permission.noEmail')} -
-
- {t('document.permission.requesting')}:{' '} - {permission.role - ? t(`document.permission.role.${permission.role}`) - : t('document.permission.role.Reporter')} -
-
-
- - - -
-
- ))} -
- )} -
- - {/* Approved Users Section */} - -
- - {t('document.permission.approvedUsers')} + {/* Tabs */} +
+
+ + + {/* Extension tabs (prop) */} + {extensionTabs?.map((tab, index) => { + const tabValue = `ext-${index}` + return ( + + ) + })} + {/* Registered external permission tabs */} + {externalTabs.map(tab => { + const tabValue = `ext-reg-${tab.type}` + const tabGroups = externalRoleGroups[tab.type] + const tabCount = tabGroups + ? Object.values(tabGroups).reduce((sum, arr) => sum + arr.length, 0) + : 0 + return ( + + ) + })} +
- {approvedCount === 0 ? ( -

- {t('document.permission.noApprovedUsers')} -

- ) : ( -
- {/* Owner permissions */} - {(permissions?.approved.Owner?.length || 0) > 0 && ( - - )} - {/* Maintainer permissions */} - {(permissions?.approved.Maintainer?.length || 0) > 0 && ( - - )} - {/* Developer permissions */} - {(permissions?.approved.Developer?.length || 0) > 0 && ( - + {/* ===== User Tab ===== */} + {activeTab === 'user' && ( +
+ {/* Pending Requests Section */} + +
+ + {t('document.permission.pendingRequests')} + {pendingCount > 0 && ( + + {pendingCount} + + )} +
+ + {pendingCount === 0 ? ( +

+ {t('document.permission.noPendingRequests')} +

+ ) : ( +
+ {permissions?.pending.map(permission => ( +
+
+
{permission.username}
+
+ {permission.email || t('document.permission.noEmail')} +
+
+ {t('document.permission.requesting')}:{' '} + {permission.role + ? t(`document.permission.role.${permission.role}`) + : t('document.permission.role.Reporter')} +
+
+
+ + + +
+
+ ))} +
)} - {/* Reporter permissions */} - {(permissions?.approved.Reporter?.length || 0) > 0 && ( - +
+ + {/* Approved Users Section */} + +
+
+ + {t('document.permission.approvedUsers')} + {approvedCount > 0 && ( + + {approvedCount} + + )} +
+ +
+ + {approvedCount === 0 ? ( +

+ {t('document.permission.noApprovedUsers')} +

+ ) : ( +
+ {(userRoleGroups?.Owner?.length || 0) > 0 && ( + + )} + {(userRoleGroups?.Maintainer?.length || 0) > 0 && ( + + )} + {(userRoleGroups?.Developer?.length || 0) > 0 && ( + + )} + {(userRoleGroups?.Reporter?.length || 0) > 0 && ( + + )} + {(userRoleGroups?.RestrictedAnalyst?.length || 0) > 0 && ( + + )} +
)} - {/* RestrictedAnalyst permissions */} - {(permissions?.approved.RestrictedAnalyst?.length || 0) > 0 && ( - +
+
+ )} + + {/* ===== Group Tab ===== */} + {activeTab === 'namespace' && ( +
+ +
+
+ + {loc('document.permission.namespaceMembers', '群组')} + {namespaceGroupCount > 0 && ( + + {namespaceGroupCount} + + )} +
+ +
+ + {namespaceGroupCount === 0 ? ( +

+ {loc('document.permission.noNamespacePermissions', '暂无群组权限')} +

+ ) : ( +
+ {(namespaceRoleGroups?.Owner?.length || 0) > 0 && ( + + )} + {(namespaceRoleGroups?.Maintainer?.length || 0) > 0 && ( + + )} + {(namespaceRoleGroups?.Developer?.length || 0) > 0 && ( + + )} + {(namespaceRoleGroups?.Reporter?.length || 0) > 0 && ( + + )} + {(namespaceRoleGroups?.RestrictedAnalyst?.length || 0) > 0 && ( + + )} +
)} +
+
+ )} + + {/* ===== Extension Tab Content (prop) ===== */} + {extensionTabs?.map((tab, index) => { + const tabValue = `ext-${index}` + return activeTab === tabValue ?
{tab.render({ kbId })}
: null + })} + + {/* ===== Registered External Tab Content ===== */} + {externalTabs.map(tab => { + const tabValue = `ext-reg-${tab.type}` + const tabGroups = externalRoleGroups[tab.type] + const tabCount = tabGroups + ? Object.values(tabGroups).reduce((sum, arr) => sum + arr.length, 0) + : 0 + return activeTab === tabValue ? ( +
+ +
+
+ {tab.icon} + {loc(tab.labelKey, tab.label || tab.type)} +
+ {tab.renderAddButton && tab.renderAddButton({ kbId, onSuccess: fetchPermissions })} +
+ {tabCount === 0 ? ( +

+ {loc('document.permission.noApprovedUsers', '暂无已授权用户')} +

+ ) : ( +
+ {(tabGroups?.Owner?.length || 0) > 0 && ( + + )} + {(tabGroups?.Maintainer?.length || 0) > 0 && ( + + )} + {(tabGroups?.Developer?.length || 0) > 0 && ( + + )} + {(tabGroups?.Reporter?.length || 0) > 0 && ( + + )} + {(tabGroups?.RestrictedAnalyst?.length || 0) > 0 && ( + + )} +
+ )} +
- )} - + ) : null + })} {/* Add User Dialog */} + + {/* Add Namespace Dialog */} +
) } @@ -359,7 +784,7 @@ interface PermissionGroupProps { t: (key: string) => string } -function PermissionGroup({ +export function PermissionGroup({ title, users, editingId, @@ -382,10 +807,8 @@ function PermissionGroup({ className="group flex items-center justify-between p-2 rounded-lg hover:bg-muted transition-colors" >
-
{user.username}
-
- {user.email || t('document.permission.noEmail')} -
+
{user.display_name}
+ {user.email &&
{user.email}
}
{editingId === user.id ? (
@@ -404,7 +827,7 @@ function PermissionGroup({
) : ( -
+
+ ) + }) + )} +
+ )} +
+ {/* Selected group chips */} + {selectedGroups.length > 0 && ( +
+ {selectedGroups.map(group => ( + + + {group.display_name || group.name} + + + + ))} +
+ )} +
+ + {/* Role selector */} +
+ + +
+ + {/* Error message */} + {error && ( +
{error}
+ )} + + + + + + + + + ) +} diff --git a/frontend/src/features/knowledge/permission/permission-tab-registry.ts b/frontend/src/features/knowledge/permission/permission-tab-registry.ts new file mode 100644 index 00000000000..5a0ae99a6e1 --- /dev/null +++ b/frontend/src/features/knowledge/permission/permission-tab-registry.ts @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +/** + * Permission management tab registry. + * + * Provides an extension point for internal deployments to add + * additional entity-type permission tabs for external entity types + * to the knowledge base permission management panel. + */ + +import type { ReactNode } from 'react' +import type { PermissionUserInfo } from '@/types/knowledge' + +export interface PermissionTabConfig { + /** Unique entity type identifier */ + type: string + /** i18n translation key for tab label */ + labelKey: string + /** Fallback label if translation key is not found */ + label?: string + /** Optional icon rendered next to the label */ + icon?: ReactNode + /** + * Filter function to determine which approved members + * belong to this tab. + */ + filter: (user: PermissionUserInfo) => boolean + /** + * Optional render function for the add-permission button/dialog. + */ + renderAddButton?: (props: { kbId: number; onSuccess: () => void }) => ReactNode +} + +const registry: PermissionTabConfig[] = [] +const listeners: Set<() => void> = new Set() + +function notify() { + listeners.forEach(l => l()) +} + +/** + * Subscribe to registry changes (e.g., when new tabs are registered + * asynchronously via dynamic imports). + */ +export function subscribePermissionTabs(listener: () => void): () => void { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +/** + * Register a permission management tab for a specific entity type. + */ +export function registerPermissionTab(config: PermissionTabConfig): void { + const existingIndex = registry.findIndex(r => r.type === config.type) + if (existingIndex >= 0) { + registry[existingIndex] = config + } else { + registry.push(config) + } + notify() +} + +/** + * Get all registered permission tab configurations. + */ +export function getPermissionTabs(): PermissionTabConfig[] { + return [...registry] +} diff --git a/frontend/src/features/knowledge/utils/share-source.ts b/frontend/src/features/knowledge/utils/share-source.ts new file mode 100644 index 00000000000..62a1f863c9c --- /dev/null +++ b/frontend/src/features/knowledge/utils/share-source.ts @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +import type { KnowledgeBaseWithGroupInfo } from '@/types/knowledge' + +/** + * Build a list of share-source labels for a knowledge base. + * Each source is represented as a separate string so callers can + * decide how to render them (e.g. comma-separated, chips, etc.). + */ +export function getKbShareSourceLabels(kb: KnowledgeBaseWithGroupInfo): string[] { + const labels: string[] = [] + + if (kb.source_group) { + labels.push(kb.source_group) + } + + if (kb.shared_from_users && kb.shared_from_users.length > 0) { + labels.push(...kb.shared_from_users) + } else if (kb.shared_from) { + labels.push(kb.shared_from) + } + + return labels +} + +/** + * Return a single display string for all share sources. + * Falls back to '--' when there are no sources. + */ +export function getKbShareSourceText(kb: KnowledgeBaseWithGroupInfo): string { + const labels = getKbShareSourceLabels(kb) + return labels.join(', ') || '--' +} diff --git a/frontend/src/features/settings/components/McpConfigEditModal.tsx b/frontend/src/features/settings/components/McpConfigEditModal.tsx index f831158c979..754a9783c56 100644 --- a/frontend/src/features/settings/components/McpConfigEditModal.tsx +++ b/frontend/src/features/settings/components/McpConfigEditModal.tsx @@ -51,12 +51,29 @@ const McpConfigEditModal: React.FC = ({ onSave(normalized) setEditConfigError(false) onOpenChange(false) - } catch { + } catch (error) { setEditConfigError(true) - toast({ - variant: 'destructive', - title: t('bot.errors.mcp_config_json'), - }) + if (error instanceof SyntaxError) { + toast({ + variant: 'destructive', + title: t('bot.errors.mcp_config_json'), + }) + } else if (error instanceof Error && error.message === 'mcp_config_missing_server_name') { + toast({ + variant: 'destructive', + title: t('bot.errors.mcp_config_missing_server_name'), + }) + } else if (error instanceof Error && error.message.startsWith('mcp_server_name_invalid:')) { + toast({ + variant: 'destructive', + title: t('bot.errors.mcp_server_name_invalid'), + }) + } else { + toast({ + variant: 'destructive', + title: t('bot.errors.mcp_config_invalid'), + }) + } } }, [editConfig, agentType, onSave, onOpenChange, toast, t]) diff --git a/frontend/src/features/settings/components/McpConfigImportModal.tsx b/frontend/src/features/settings/components/McpConfigImportModal.tsx index 9aad5dc5c62..ea1a89c2451 100644 --- a/frontend/src/features/settings/components/McpConfigImportModal.tsx +++ b/frontend/src/features/settings/components/McpConfigImportModal.tsx @@ -71,6 +71,16 @@ const McpConfigImportModal: React.FC = ({ variant: 'destructive', title: t('bot.errors.mcp_config_json'), }) + } else if (error instanceof Error && error.message === 'mcp_config_missing_server_name') { + toast({ + variant: 'destructive', + title: t('bot.errors.mcp_config_missing_server_name'), + }) + } else if (error instanceof Error && error.message.startsWith('mcp_server_name_invalid:')) { + toast({ + variant: 'destructive', + title: t('bot.errors.mcp_server_name_invalid'), + }) } else { toast({ variant: 'destructive', diff --git a/frontend/src/features/settings/components/SingleMcpServerEditModal.tsx b/frontend/src/features/settings/components/SingleMcpServerEditModal.tsx index 3244700c249..cbff8c1ae71 100644 --- a/frontend/src/features/settings/components/SingleMcpServerEditModal.tsx +++ b/frontend/src/features/settings/components/SingleMcpServerEditModal.tsx @@ -24,6 +24,7 @@ import { import { useTranslation } from '@/hooks/useTranslation' import { CodeMirrorEditor } from '@/components/common/CodeMirrorEditor' import { useTheme } from '@/features/theme/ThemeProvider' +import { mcpProviderApis, type MCPTestResponse } from '@/apis/mcpProviders' interface SingleMcpServerEditModalProps { open: boolean @@ -41,6 +42,23 @@ const serverTypes = [ { value: 'http', label: 'HTTP' }, ] +// Fields handled explicitly by the form UI; everything else is preserved as-is +const KNOWN_FORM_KEYS = new Set([ + 'type', + 'command', + 'args', + 'url', + 'base_url', + 'headers', + 'transport', +]) + +type TestStatus = 'idle' | 'loading' | 'success' | 'error' + +function extractExtraFields(config: Record): Record { + return Object.fromEntries(Object.entries(config).filter(([k]) => !KNOWN_FORM_KEYS.has(k))) +} + const SingleMcpServerEditModal: React.FC = ({ open, onOpenChange, @@ -52,11 +70,14 @@ const SingleMcpServerEditModal: React.FC = ({ const { t } = useTranslation('common') const { theme } = useTheme() - // Form mode state + // Form field state const [type, setType] = useState('stdio') const [command, setCommand] = useState('') + const [args, setArgs] = useState('') const [url, setUrl] = useState('') const [headers, setHeaders] = useState('') + // Unknown fields not handled by the form UI — preserved and merged back on save + const [extraFields, setExtraFields] = useState>({}) // JSON mode state const [jsonContent, setJsonContent] = useState('') @@ -65,48 +86,147 @@ const SingleMcpServerEditModal: React.FC = ({ // Mode toggle: 'form' | 'json' const [editMode, setEditMode] = useState<'form' | 'json'>('form') - // Initialize from serverConfig + // Test connection state + const [testStatus, setTestStatus] = useState('idle') + const [testResult, setTestResult] = useState(null) + const [showTools, setShowTools] = useState(false) + + // Initialize from serverConfig when modal opens useEffect(() => { if (open && serverConfig) { const initialType = (serverConfig.type as string) || 'stdio' setType(initialType) setCommand((serverConfig.command as string) || '') + const initialArgs = Array.isArray(serverConfig.args) + ? (serverConfig.args as string[]).join('\n') + : '' + setArgs(initialArgs) setUrl((serverConfig.url as string) || (serverConfig.base_url as string) || '') setHeaders(serverConfig.headers ? JSON.stringify(serverConfig.headers, null, 2) : '') + setExtraFields(extractExtraFields(serverConfig)) setJsonContent(JSON.stringify(serverConfig, null, 2)) setJsonError(false) + setEditMode('form') + setTestStatus('idle') + setTestResult(null) + setShowTools(false) } }, [open, serverConfig]) - // Sync JSON when form changes + // Sync form fields → JSON when in form mode useEffect(() => { - if (editMode === 'form') { - const config: Record = { type } - if (type === 'stdio') { - if (command.trim()) config.command = command.trim() - } else { - if (url.trim()) config.url = url.trim() - } - if (headers.trim()) { - try { - const parsedHeaders = JSON.parse(headers) - if (Object.keys(parsedHeaders).length > 0) { - config.headers = parsedHeaders - } - } catch { - // Invalid JSON, skip headers + if (editMode !== 'form') return + const config: Record = { ...extraFields, type } + if (type === 'stdio') { + if (command.trim()) config.command = command.trim() + const parsedArgs = args + .split('\n') + .map(a => a.trim()) + .filter(Boolean) + if (parsedArgs.length > 0) config.args = parsedArgs + } else { + if (url.trim()) config.url = url.trim() + } + if (headers.trim()) { + try { + const parsedHeaders = JSON.parse(headers) + if (Object.keys(parsedHeaders).length > 0) { + config.headers = parsedHeaders } + } catch { + // Invalid JSON headers, skip } - setJsonContent(JSON.stringify(config, null, 2)) } - }, [editMode, type, command, url, headers]) + setJsonContent(JSON.stringify(config, null, 2)) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [type, command, args, url, headers, extraFields]) - // Sync form when JSON changes (only when switching to form mode) const handleJsonChange = useCallback((value: string) => { setJsonContent(value) setJsonError(false) }, []) + // When switching modes, sync state in the appropriate direction + const handleSetEditMode = useCallback( + (mode: 'form' | 'json') => { + if (mode === 'form' && editMode === 'json') { + // Sync JSON → form fields + try { + const parsed = JSON.parse(jsonContent) + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + setType((parsed.type as string) || 'stdio') + setCommand((parsed.command as string) || '') + const parsedArgs = Array.isArray(parsed.args) + ? (parsed.args as string[]).join('\n') + : '' + setArgs(parsedArgs) + setUrl((parsed.url as string) || (parsed.base_url as string) || '') + setHeaders(parsed.headers ? JSON.stringify(parsed.headers, null, 2) : '') + setExtraFields(extractExtraFields(parsed)) + } + } catch { + // Keep current form state if JSON is invalid + } + } + setEditMode(mode) + }, + [editMode, jsonContent] + ) + + // Build current config from either form fields or JSON content + const buildCurrentConfig = useCallback((): Record => { + if (editMode === 'json') { + try { + return JSON.parse(jsonContent) + } catch { + return {} + } + } + const config: Record = { ...extraFields, type } + if (type === 'stdio') { + if (command.trim()) config.command = command.trim() + const parsedArgs = args + .split('\n') + .map(a => a.trim()) + .filter(Boolean) + if (parsedArgs.length > 0) config.args = parsedArgs + } else { + if (url.trim()) config.url = url.trim() + } + if (headers.trim()) { + try { + const parsedHeaders = JSON.parse(headers) + if (Object.keys(parsedHeaders).length > 0) config.headers = parsedHeaders + } catch { + /* skip */ + } + } + return config + }, [editMode, jsonContent, type, command, args, url, headers, extraFields]) + + const handleTest = useCallback(async () => { + setTestStatus('loading') + setTestResult(null) + setShowTools(false) + try { + const currentConfig = buildCurrentConfig() + const result = await mcpProviderApis.testServer({ + server_name: serverName, + server_config: currentConfig, + }) + if (result.success) { + setTestStatus('success') + setTestResult(result) + } else { + setTestStatus('error') + setTestResult(result) + } + } catch { + setTestStatus('error') + setTestResult(null) + } + }, [buildCurrentConfig, serverName]) + const handleSave = useCallback(() => { if (editMode === 'json') { try { @@ -120,9 +240,14 @@ const SingleMcpServerEditModal: React.FC = ({ setJsonError(true) } } else { - const config: Record = { type } + const config: Record = { ...extraFields, type } if (type === 'stdio') { if (command.trim()) config.command = command.trim() + const parsedArgs = args + .split('\n') + .map(a => a.trim()) + .filter(Boolean) + if (parsedArgs.length > 0) config.args = parsedArgs } else { if (url.trim()) config.url = url.trim() } @@ -133,140 +258,178 @@ const SingleMcpServerEditModal: React.FC = ({ config.headers = parsedHeaders } } catch { - // Invalid JSON, ignore headers + // Invalid JSON headers, ignore } } onSave(config) } - }, [editMode, jsonContent, type, command, url, headers, onSave]) + }, [editMode, jsonContent, type, command, args, url, headers, extraFields, onSave]) const isHttpType = type === 'http' || type === 'streamable-http' || type === 'sse' return ( - - - - {t('common:bot.edit_mcp_server', 'Edit MCP Server')} - + + {/* Header */} + +
+ + {t('common:bot.edit_mcp_server', 'Edit MCP Server')} + + + {serverName} + +
+ + {/* Tab bar */} +
+ + +
- {/* Mode Toggle */} -
- - -
+ {/* Content */} +
+ {editMode === 'form' ? ( +
+ {/* Name + Type row */} +
+
+ + +
+
+ + +
+
- {editMode === 'form' ? ( -
- {/* Server Name (Read-only) */} -
- - -
+ {/* Command (stdio) */} + {!isHttpType && ( +
+ + setCommand(e.target.value)} + placeholder={t( + 'common:bot.mcp_command_placeholder', + 'e.g., npx -y @modelcontextprotocol/server-filesystem /path' + )} + className="text-sm font-mono" + /> +
+ )} - {/* Type Selection */} -
- - -
+ {/* Args (stdio) */} + {!isHttpType && ( +
+ +