diff --git a/api/controllers/console/workspace/members.py b/api/controllers/console/workspace/members.py index c2d3d93d7b..3d36598d27 100644 --- a/api/controllers/console/workspace/members.py +++ b/api/controllers/console/workspace/members.py @@ -83,6 +83,11 @@ def _normalize_enum_value(value: object) -> str: normalized = getattr(value, "value", value) return str(normalized) if normalized is not None else "" +def _is_role_enabled(role: TenantAccountRole | str, tenant_id: str) -> bool: + if role != TenantAccountRole.DATASET_OPERATOR: + return True + return FeatureService.get_features(tenant_id=tenant_id).dataset_operator_enabled + @console_ns.route("/workspaces/current/members") class MemberListApi(Resource): @@ -154,6 +159,8 @@ class MemberInviteEmailApi(Resource): inviter = current_user if not inviter.current_tenant: raise ValueError("No current tenant") + if not _is_role_enabled(invitee_role, inviter.current_tenant.id): + return {"code": "invalid-role", "message": "Invalid role"}, 400 # Check workspace permission for member invitations from libs.workspace_permission import check_workspace_member_invite_permission @@ -252,6 +259,8 @@ class MemberUpdateRoleApi(Resource): current_user, _ = current_account_with_tenant() if not current_user.current_tenant: raise ValueError("No current tenant") + if not _is_role_enabled(new_role, current_user.current_tenant.id): + return {"code": "invalid-role", "message": "Invalid role"}, 400 member = db.session.get(Account, str(member_id)) if not member: abort(404) @@ -259,11 +268,17 @@ class MemberUpdateRoleApi(Resource): try: assert member is not None, "Member not found" TenantService.update_member_role(current_user.current_tenant, member, new_role, current_user) + except services.errors.account.CannotOperateSelfError as e: + return {"code": "cannot-operate-self", "message": str(e)}, 400 + except services.errors.account.NoPermissionError as e: + return {"code": "forbidden", "message": str(e)}, 403 + except services.errors.account.MemberNotInTenantError as e: + return {"code": "member-not-found", "message": str(e)}, 404 + except services.errors.account.RoleAlreadyAssignedError as e: + return {"code": "role-already-assigned", "message": str(e)}, 400 except Exception as e: raise ValueError(str(e)) - # todo: 403 - return {"result": "success"} diff --git a/api/core/prompt/advanced_prompt_transform.py b/api/core/prompt/advanced_prompt_transform.py index 24e05ef865..5a9914e6e4 100644 --- a/api/core/prompt/advanced_prompt_transform.py +++ b/api/core/prompt/advanced_prompt_transform.py @@ -161,35 +161,39 @@ class AdvancedPromptTransform(PromptTransform): prompt_messages: list[PromptMessage] = [] for prompt_item in prompt_template: raw_prompt = prompt_item.text - - if prompt_item.edition_type == "basic" or not prompt_item.edition_type: - if self.with_variable_tmpl: - vp = VariablePool.empty() - for k, v in inputs.items(): - if k.startswith("#"): - vp.add(k[1:-1].split("."), v) - raw_prompt = raw_prompt.replace("{{#context#}}", context or "") - prompt = vp.convert_template(raw_prompt).text - else: - parser = PromptTemplateParser(template=raw_prompt, with_variable_tmpl=self.with_variable_tmpl) - prompt_inputs: Mapping[str, str] = {k: inputs[k] for k in parser.variable_keys if k in inputs} - prompt_inputs = self._set_context_variable( - context=context, parser=parser, prompt_inputs=prompt_inputs - ) - prompt = parser.format(prompt_inputs) - elif prompt_item.edition_type == "jinja2": - prompt = raw_prompt - prompt_inputs = inputs - prompt = Jinja2Formatter.format(template=prompt, inputs=prompt_inputs) - else: - raise ValueError(f"Invalid edition type: {prompt_item.edition_type}") - - if prompt_item.role == PromptMessageRole.USER: - prompt_messages.append(UserPromptMessage(content=prompt)) - elif prompt_item.role == PromptMessageRole.SYSTEM and prompt: - prompt_messages.append(SystemPromptMessage(content=prompt)) - elif prompt_item.role == PromptMessageRole.ASSISTANT: - prompt_messages.append(AssistantPromptMessage(content=prompt)) + edition_type = prompt_item.edition_type or "basic" + match edition_type: + case "basic": + if self.with_variable_tmpl: + vp = VariablePool.empty() + for k, v in inputs.items(): + if k.startswith("#"): + vp.add(k[1:-1].split("."), v) + raw_prompt = raw_prompt.replace("{{#context#}}", context or "") + prompt = vp.convert_template(raw_prompt).text + else: + parser = PromptTemplateParser(template=raw_prompt, with_variable_tmpl=self.with_variable_tmpl) + prompt_inputs: Mapping[str, str] = {k: inputs[k] for k in parser.variable_keys if k in inputs} + prompt_inputs = self._set_context_variable( + context=context, parser=parser, prompt_inputs=prompt_inputs + ) + prompt = parser.format(prompt_inputs) + case "jinja2": + prompt = raw_prompt + prompt_inputs = inputs + prompt = Jinja2Formatter.format(template=prompt, inputs=prompt_inputs) + case _: + raise ValueError(f"Invalid edition type: {prompt_item.edition_type}") + match prompt_item.role: + case PromptMessageRole.USER: + prompt_messages.append(UserPromptMessage(content=prompt)) + case PromptMessageRole.SYSTEM: + if prompt: + prompt_messages.append(SystemPromptMessage(content=prompt)) + case PromptMessageRole.ASSISTANT: + prompt_messages.append(AssistantPromptMessage(content=prompt)) + case PromptMessageRole.TOOL: + pass if query and memory_config and memory_config.query_prompt_template: parser = PromptTemplateParser( diff --git a/api/core/rag/extractor/extract_processor.py b/api/core/rag/extractor/extract_processor.py index b679edab36..e49e814149 100644 --- a/api/core/rag/extractor/extract_processor.py +++ b/api/core/rag/extractor/extract_processor.py @@ -183,34 +183,35 @@ class ExtractProcessor: return extractor.extract() elif extract_setting.datasource_type == DatasourceType.WEBSITE: assert extract_setting.website_info is not None, "website_info is required" - if extract_setting.website_info.provider == "firecrawl": - extractor = FirecrawlWebExtractor( - url=extract_setting.website_info.url, - job_id=extract_setting.website_info.job_id, - tenant_id=extract_setting.website_info.tenant_id, - mode=extract_setting.website_info.mode, - only_main_content=extract_setting.website_info.only_main_content, - ) - return extractor.extract() - elif extract_setting.website_info.provider == "watercrawl": - extractor = WaterCrawlWebExtractor( - url=extract_setting.website_info.url, - job_id=extract_setting.website_info.job_id, - tenant_id=extract_setting.website_info.tenant_id, - mode=extract_setting.website_info.mode, - only_main_content=extract_setting.website_info.only_main_content, - ) - return extractor.extract() - elif extract_setting.website_info.provider == "jinareader": - extractor = JinaReaderWebExtractor( - url=extract_setting.website_info.url, - job_id=extract_setting.website_info.job_id, - tenant_id=extract_setting.website_info.tenant_id, - mode=extract_setting.website_info.mode, - only_main_content=extract_setting.website_info.only_main_content, - ) - return extractor.extract() - else: - raise ValueError(f"Unsupported website provider: {extract_setting.website_info.provider}") + match extract_setting.website_info.provider: + case "firecrawl": + extractor = FirecrawlWebExtractor( + url=extract_setting.website_info.url, + job_id=extract_setting.website_info.job_id, + tenant_id=extract_setting.website_info.tenant_id, + mode=extract_setting.website_info.mode, + only_main_content=extract_setting.website_info.only_main_content, + ) + return extractor.extract() + case "watercrawl": + extractor = WaterCrawlWebExtractor( + url=extract_setting.website_info.url, + job_id=extract_setting.website_info.job_id, + tenant_id=extract_setting.website_info.tenant_id, + mode=extract_setting.website_info.mode, + only_main_content=extract_setting.website_info.only_main_content, + ) + return extractor.extract() + case "jinareader": + extractor = JinaReaderWebExtractor( + url=extract_setting.website_info.url, + job_id=extract_setting.website_info.job_id, + tenant_id=extract_setting.website_info.tenant_id, + mode=extract_setting.website_info.mode, + only_main_content=extract_setting.website_info.only_main_content, + ) + return extractor.extract() + case _: + raise ValueError(f"Unsupported website provider: {extract_setting.website_info.provider}") else: raise ValueError(f"Unsupported datasource type: {extract_setting.datasource_type}") diff --git a/api/services/account_service.py b/api/services/account_service.py index cda71b5c1d..09a0eb494d 100644 --- a/api/services/account_service.py +++ b/api/services/account_service.py @@ -1281,8 +1281,8 @@ class TenantService: """Check member permission""" perms = { "add": [TenantAccountRole.OWNER, TenantAccountRole.ADMIN], - "remove": [TenantAccountRole.OWNER], - "update": [TenantAccountRole.OWNER], + "remove": [TenantAccountRole.OWNER, TenantAccountRole.ADMIN], + "update": [TenantAccountRole.OWNER, TenantAccountRole.ADMIN], } if action not in {"add", "remove", "update"}: raise InvalidActionError("Invalid action.") @@ -1300,6 +1300,15 @@ class TenantService: if not ta_operator or ta_operator.role not in perms[action]: raise NoPermissionError(f"No permission to {action} member.") + if action == "remove" and ta_operator.role == TenantAccountRole.ADMIN and member: + ta_member = db.session.scalar( + select(TenantAccountJoin) + .where(TenantAccountJoin.tenant_id == tenant.id, TenantAccountJoin.account_id == member.id) + .limit(1) + ) + if ta_member and ta_member.role == TenantAccountRole.OWNER: + raise NoPermissionError(f"No permission to {action} member.") + @staticmethod def remove_member_from_tenant(tenant: Tenant, account: Account, operator: Account): """Remove member from tenant. @@ -1371,6 +1380,7 @@ class TenantService: def update_member_role(tenant: Tenant, member: Account, new_role: str, operator: Account): """Update member role""" TenantService.check_member_permission(tenant, operator, member, "update") + new_tenant_role = TenantAccountRole(new_role) target_member_join = db.session.scalar( select(TenantAccountJoin) @@ -1381,6 +1391,11 @@ class TenantService: if not target_member_join: raise MemberNotInTenantError("Member not in tenant.") + operator_role = TenantService.get_user_role(operator, tenant) + target_role = TenantAccountRole(target_member_join.role) + if operator_role == TenantAccountRole.ADMIN and (TenantAccountRole.OWNER in {target_role, new_tenant_role}): + raise NoPermissionError("No permission to update member.") + if target_member_join.role == new_role: raise RoleAlreadyAssignedError("The provided role is already assigned to the member.") @@ -1395,7 +1410,7 @@ class TenantService: current_owner_join.role = TenantAccountRole.ADMIN # Update the role of the target member - target_member_join.role = TenantAccountRole(new_role) + target_member_join.role = new_tenant_role db.session.commit() @staticmethod diff --git a/api/tests/unit_tests/services/test_account_service.py b/api/tests/unit_tests/services/test_account_service.py index 9e5b936f96..6376e2e3a1 100644 --- a/api/tests/unit_tests/services/test_account_service.py +++ b/api/tests/unit_tests/services/test_account_service.py @@ -13,6 +13,7 @@ from services.errors.account import ( AccountPasswordError, AccountRegisterError, CurrentPasswordIncorrectError, + NoPermissionError, ) @@ -817,8 +818,8 @@ class TestTenantService: # Mock the database queries in update_member_role method with patch("services.account_service.db") as mock_db: - # scalar calls: permission check, target member lookup - mock_db.session.scalar.side_effect = [mock_operator_join, mock_target_join] + # scalar calls: permission check, target member lookup, operator role lookup + mock_db.session.scalar.side_effect = [mock_operator_join, mock_target_join, mock_operator_join] # Execute test TenantService.update_member_role(mock_tenant, mock_member, "admin", mock_operator) @@ -827,6 +828,65 @@ class TestTenantService: assert mock_target_join.role == "admin" self._assert_database_operations_called(mock_db) + def test_admin_can_update_admin_member_role(self): + """Test admin can update another non-owner member, including an admin.""" + mock_tenant = MagicMock() + mock_tenant.id = "tenant-456" + mock_member = TestAccountAssociatedDataFactory.create_account_mock(account_id="member-789") + mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123") + mock_target_join = TestAccountAssociatedDataFactory.create_tenant_join_mock( + tenant_id="tenant-456", account_id="member-789", role="admin" + ) + mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock( + tenant_id="tenant-456", account_id="operator-123", role="admin" + ) + + with patch("services.account_service.db") as mock_db: + mock_db.session.scalar.side_effect = [mock_operator_join, mock_target_join, mock_operator_join] + + TenantService.update_member_role(mock_tenant, mock_member, "editor", mock_operator) + + assert mock_target_join.role == "editor" + self._assert_database_operations_called(mock_db) + + def test_admin_cannot_update_owner_member_role(self): + """Test admin cannot update an owner member.""" + mock_tenant = MagicMock() + mock_tenant.id = "tenant-456" + mock_member = TestAccountAssociatedDataFactory.create_account_mock(account_id="member-789") + mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123") + mock_target_join = TestAccountAssociatedDataFactory.create_tenant_join_mock( + tenant_id="tenant-456", account_id="member-789", role="owner" + ) + mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock( + tenant_id="tenant-456", account_id="operator-123", role="admin" + ) + + with patch("services.account_service.db") as mock_db: + mock_db.session.scalar.side_effect = [mock_operator_join, mock_target_join, mock_operator_join] + + with pytest.raises(NoPermissionError): + TenantService.update_member_role(mock_tenant, mock_member, "editor", mock_operator) + + def test_admin_cannot_promote_member_to_owner(self): + """Test admin cannot promote a non-owner member to owner.""" + mock_tenant = MagicMock() + mock_tenant.id = "tenant-456" + mock_member = TestAccountAssociatedDataFactory.create_account_mock(account_id="member-789") + mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123") + mock_target_join = TestAccountAssociatedDataFactory.create_tenant_join_mock( + tenant_id="tenant-456", account_id="member-789", role="admin" + ) + mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock( + tenant_id="tenant-456", account_id="operator-123", role="admin" + ) + + with patch("services.account_service.db") as mock_db: + mock_db.session.scalar.side_effect = [mock_operator_join, mock_target_join, mock_operator_join] + + with pytest.raises(NoPermissionError): + TenantService.update_member_role(mock_tenant, mock_member, "owner", mock_operator) + # ==================== Permission Check Tests ==================== def test_check_member_permission_success(self, mock_db_dependencies): @@ -864,6 +924,39 @@ class TestTenantService: "add", ) + def test_admin_can_remove_non_owner_member(self, mock_db_dependencies): + """Test admin can remove a non-owner member.""" + mock_tenant = MagicMock() + mock_tenant.id = "tenant-456" + mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123") + mock_member = TestAccountAssociatedDataFactory.create_account_mock(account_id="member-789") + mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock( + tenant_id="tenant-456", account_id="operator-123", role="admin" + ) + mock_member_join = TestAccountAssociatedDataFactory.create_tenant_join_mock( + tenant_id="tenant-456", account_id="member-789", role="admin" + ) + mock_db_dependencies["db"].session.scalar.side_effect = [mock_operator_join, mock_member_join] + + TenantService.check_member_permission(mock_tenant, mock_operator, mock_member, "remove") + + def test_admin_cannot_remove_owner_member(self, mock_db_dependencies): + """Test admin cannot remove an owner member.""" + mock_tenant = MagicMock() + mock_tenant.id = "tenant-456" + mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123") + mock_member = TestAccountAssociatedDataFactory.create_account_mock(account_id="member-789") + mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock( + tenant_id="tenant-456", account_id="operator-123", role="admin" + ) + mock_member_join = TestAccountAssociatedDataFactory.create_tenant_join_mock( + tenant_id="tenant-456", account_id="member-789", role="owner" + ) + mock_db_dependencies["db"].session.scalar.side_effect = [mock_operator_join, mock_member_join] + + with pytest.raises(NoPermissionError): + TenantService.check_member_permission(mock_tenant, mock_operator, mock_member, "remove") + class TestRegisterService: """ diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 46277d3349..849595d3c7 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -4699,9 +4699,6 @@ }, "ts/no-explicit-any": { "count": 3 - }, - "ts/no-non-null-asserted-optional-chain": { - "count": 1 } }, "web/service/use-tools.ts": { diff --git a/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx index 781f6f9088..7f31ba832f 100644 --- a/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx @@ -314,6 +314,37 @@ describe('MembersPage', () => { expect(screen.queryByRole('button', { name: /transfer ownership/i })).not.toBeInTheDocument() }) + it('should allow admins to operate other non-owner members only', () => { + vi.mocked(useAppContext).mockReturnValue({ + userProfile: { email: 'admin@example.com' }, + currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace, + isCurrentWorkspaceOwner: false, + isCurrentWorkspaceManager: true, + } as unknown as AppContextValue) + vi.mocked(useMembers).mockReturnValue({ + data: { + accounts: [ + mockAccounts[0], + mockAccounts[1], + { ...mockAccounts[1]!, id: '3', email: 'editor@example.com', name: 'Editor User', role: 'editor' }, + { ...mockAccounts[1]!, id: '4', email: 'normal@example.com', name: 'Normal User', role: 'normal' }, + { ...mockAccounts[1]!, id: '5', email: 'dataset@example.com', name: 'Dataset User', role: 'dataset_operator' }, + { ...mockAccounts[1]!, id: '6', email: 'other-admin@example.com', name: 'Other Admin User', role: 'admin' }, + ], + }, + refetch: mockRefetch, + } as unknown as ReturnType) + + renderMembersPage() + + expect(screen.getByText('Member Operation editor'))!.toBeInTheDocument() + expect(screen.getByText('Member Operation normal'))!.toBeInTheDocument() + expect(screen.getByText('Member Operation dataset_operator'))!.toBeInTheDocument() + expect(screen.getByText('Member Operation admin'))!.toBeInTheDocument() + expect(screen.getAllByText('common.members.admin')).toHaveLength(1) + expect(screen.queryByText('Member Operation owner')).not.toBeInTheDocument() + }) + it('should use created_at as fallback when last_active_at is empty', () => { const memberNoLastActive: Member = { ...mockAccounts[1]!, diff --git a/web/app/components/header/account-setting/members-page/operation/__tests__/index.spec.tsx b/web/app/components/header/account-setting/members-page/operation/__tests__/index.spec.tsx index 8c9081833d..728ccf82e1 100644 --- a/web/app/components/header/account-setting/members-page/operation/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/members-page/operation/__tests__/index.spec.tsx @@ -8,8 +8,8 @@ const mockUpdateMemberRole = vi.fn() const mockDeleteMemberOrCancelInvitation = vi.fn() vi.mock('@/service/common', () => ({ - deleteMemberOrCancelInvitation: () => mockDeleteMemberOrCancelInvitation(), - updateMemberRole: () => mockUpdateMemberRole(), + deleteMemberOrCancelInvitation: (args: unknown) => mockDeleteMemberOrCancelInvitation(args), + updateMemberRole: (args: unknown) => mockUpdateMemberRole(args), })) const mockUseProviderContext = vi.fn(() => ({ @@ -65,18 +65,21 @@ describe('Operation', () => { expect(await screen.findByText('common.members.datasetOperator')).toBeInTheDocument() }) - it('should show owner-allowed role options when operator role is admin', async () => { + it('should show admin-allowed role options when operator role is admin', async () => { const user = userEvent.setup() renderOperation({}, 'admin') await user.click(screen.getByText('common.members.editor')) - expect(screen.queryByText('common.members.admin')).not.toBeInTheDocument() + expect(screen.getByText('common.members.admin')).toBeInTheDocument() + expect(screen.getAllByText('common.members.editor')).toHaveLength(2) expect(screen.getByText('common.members.normal')).toBeInTheDocument() + expect(screen.queryByText('common.members.datasetOperator')).not.toBeInTheDocument() + expect(screen.getByText('common.members.removeFromTeam')).toBeInTheDocument() }) - it('should not show role options when operator role is unsupported', async () => { + it('should not show role options or remove action when operator role is unsupported', async () => { const user = userEvent.setup() renderOperation({}, 'normal') @@ -84,7 +87,7 @@ describe('Operation', () => { await user.click(screen.getByText('common.members.editor')) expect(screen.queryByText('common.members.normal')).not.toBeInTheDocument() - expect(screen.getByText('common.members.removeFromTeam')).toBeInTheDocument() + expect(screen.queryByText('common.members.removeFromTeam')).not.toBeInTheDocument() }) it('should call updateMemberRole and onOperate when selecting another role', async () => { @@ -96,7 +99,10 @@ describe('Operation', () => { await user.click(await screen.findByText('common.members.normal')) await waitFor(() => { - expect(mockUpdateMemberRole).toHaveBeenCalled() + expect(mockUpdateMemberRole).toHaveBeenCalledWith({ + url: '/workspaces/current/members/member-id/update-role', + body: { role: 'normal' }, + }) expect(onOperate).toHaveBeenCalled() }) }) @@ -109,7 +115,7 @@ describe('Operation', () => { await user.click(screen.getByText('common.members.editor')) expect(await screen.findByText('common.members.datasetOperator')).toBeInTheDocument() - expect(screen.queryByText('common.members.admin')).not.toBeInTheDocument() + expect(screen.getByText('common.members.admin')).toBeInTheDocument() }) it('should fall back to normal role label when member role is unknown', () => { @@ -127,7 +133,9 @@ describe('Operation', () => { await user.click(await screen.findByText('common.members.removeFromTeam')) await waitFor(() => { - expect(mockDeleteMemberOrCancelInvitation).toHaveBeenCalled() + expect(mockDeleteMemberOrCancelInvitation).toHaveBeenCalledWith({ + url: '/workspaces/current/members/member-id', + }) expect(onOperate).toHaveBeenCalled() }) }) diff --git a/web/app/components/header/account-setting/members-page/operation/index.tsx b/web/app/components/header/account-setting/members-page/operation/index.tsx index b67fb26bcd..7d2f609177 100644 --- a/web/app/components/header/account-setting/members-page/operation/index.tsx +++ b/web/app/components/header/account-setting/members-page/operation/index.tsx @@ -26,6 +26,9 @@ const roleI18nKeyMap = { dataset_operator: { label: 'members.datasetOperator', tip: 'members.datasetOperatorTip' }, } as const type OperationRoleKey = keyof typeof roleI18nKeyMap +const nonOwnerRoles = ['admin', 'editor', 'normal'] as const +const isNonOwnerRole = (role: Member['role']) => role !== 'owner' + const Operation = ({ member, operatorRole, onOperate }: IOperationProps) => { const [open, setOpen] = useState(false) const { t } = useTranslation() @@ -48,13 +51,13 @@ const Operation = ({ member, operatorRole, onOperate }: IOperationProps) => { } if (operatorRole === 'admin') { return [ - 'editor', - 'normal', + ...nonOwnerRoles, ...(datasetOperatorEnabled ? ['dataset_operator'] as const : []), ] } return [] }, [operatorRole, datasetOperatorEnabled]) + const canRemoveMember = operatorRole === 'owner' || (operatorRole === 'admin' && isNonOwnerRole(member.role)) const handleDeleteMemberOrCancelInvitation = async () => { setOpen(false) try { @@ -81,7 +84,7 @@ const Operation = ({ member, operatorRole, onOperate }: IOperationProps) => { return ( } + render={ - )} - /> - + - + {t('common.publish', { ns: 'workflow' })} + + + )} /> - - + + + + + {showPublishAsKnowledgePipelineModal && ( + + )} + ) } diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx index 1c2f7177a5..ba18f2d963 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx @@ -1,4 +1,3 @@ -import type { IconInfo } from '@/models/datasets' import type { PublishWorkflowParams } from '@/types/workflow' import { AlertDialog, @@ -25,7 +24,6 @@ import ShortcutsName from '@/app/components/workflow/shortcuts-name' import { useStore, useWorkflowStore } from '@/app/components/workflow/store' import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' -import { useDocLink } from '@/context/i18n' import { useModalContextSelector } from '@/context/modal-context' import { useProviderContextSelector } from '@/context/provider-context' import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url' @@ -34,9 +32,8 @@ import Link from '@/next/link' import { useParams, useRouter } from '@/next/navigation' import { useInvalidDatasetList } from '@/service/knowledge/use-dataset' import { useInvalid } from '@/service/use-base' -import { publishedPipelineInfoQueryKeyPrefix, useInvalidCustomizedTemplateList, usePublishAsCustomizedPipeline } from '@/service/use-pipeline' +import { publishedPipelineInfoQueryKeyPrefix } from '@/service/use-pipeline' import { usePublishWorkflow } from '@/service/use-workflow' -import PublishAsKnowledgePipelineModal from '../../publish-as-knowledge-pipeline-modal' const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P'] type PopupProps = { @@ -44,6 +41,8 @@ type PopupProps = { confirmVisible?: boolean onShowConfirm?: () => void onHideConfirm?: () => void + isPublishingAsCustomizedPipeline?: boolean + onShowPublishAsKnowledgePipelineModal?: () => void } const Popup = ({ @@ -51,11 +50,12 @@ const Popup = ({ confirmVisible: controlledConfirmVisible, onShowConfirm, onHideConfirm, + isPublishingAsCustomizedPipeline = false, + onShowPublishAsKnowledgePipelineModal, }: PopupProps) => { const { t } = useTranslation() const { datasetId } = useParams() const { push } = useRouter() - const docLink = useDocLink() const publishedAt = useStore(s => s.publishedAt) const draftUpdatedAt = useStore(s => s.draftUpdatedAt) const pipelineId = useStore(s => s.pipelineId) @@ -73,9 +73,6 @@ const Popup = ({ const showConfirm = onShowConfirm ?? showLocalConfirm const hideConfirm = onHideConfirm ?? hideLocalConfirm const [publishing, { setFalse: hidePublishing, setTrue: showPublishing }] = useBoolean(false) - const { mutateAsync: publishAsCustomizedPipeline } = usePublishAsCustomizedPipeline() - const [showPublishAsKnowledgePipelineModal, { setFalse: hidePublishAsKnowledgePipelineModal, setTrue: setShowPublishAsKnowledgePipelineModal }] = useBoolean(false) - const [isPublishingAsCustomizedPipeline, { setFalse: hidePublishingAsCustomizedPipeline, setTrue: showPublishingAsCustomizedPipeline }] = useBoolean(false) const invalidPublishedPipelineInfo = useInvalid([...publishedPipelineInfoQueryKeyPrefix, pipelineId]) const invalidDatasetList = useInvalidDatasetList() const handleHideConfirm = useCallback(() => { @@ -145,47 +142,15 @@ const Popup = ({ const goToAddDocuments = useCallback(() => { push(`/datasets/${datasetId}/documents/create-from-pipeline`) }, [datasetId, push]) - const invalidCustomizedTemplateList = useInvalidCustomizedTemplateList() - const handlePublishAsKnowledgePipeline = useCallback(async (name: string, icon: IconInfo, description?: string) => { - try { - showPublishingAsCustomizedPipeline() - await publishAsCustomizedPipeline({ - pipelineId: pipelineId || '', - name, - icon_info: icon, - description, - }) - toast.success(t('publishTemplate.success.message', { ns: 'datasetPipeline' }), { - description: ( -
- - {t('publishTemplate.success.tip', { ns: 'datasetPipeline' })} - - - {t('publishTemplate.success.learnMore', { ns: 'datasetPipeline' })} - -
- ), - }) - invalidCustomizedTemplateList() - } - catch { - toast.error(t('publishTemplate.error.message', { ns: 'datasetPipeline' })) - } - finally { - hidePublishingAsCustomizedPipeline() - hidePublishAsKnowledgePipelineModal() - } - }, [showPublishingAsCustomizedPipeline, publishAsCustomizedPipeline, pipelineId, t, invalidCustomizedTemplateList, hidePublishingAsCustomizedPipeline, hidePublishAsKnowledgePipelineModal, docLink]) const handleClickPublishAsKnowledgePipeline = useCallback(() => { onRequestClose?.() if (!isAllowPublishAsCustomKnowledgePipelineTemplate) { setShowPricingModal() } else { - setShowPublishAsKnowledgePipelineModal() + onShowPublishAsKnowledgePipelineModal?.() } - }, [isAllowPublishAsCustomKnowledgePipelineTemplate, onRequestClose, setShowPublishAsKnowledgePipelineModal, setShowPricingModal]) + }, [isAllowPublishAsCustomKnowledgePipelineTemplate, onRequestClose, onShowPublishAsKnowledgePipelineModal, setShowPricingModal]) return (
@@ -279,7 +244,6 @@ const Popup = ({ - {showPublishAsKnowledgePipelineModal && ()}
) } diff --git a/web/service/use-plugins.ts b/web/service/use-plugins.ts index 6004ecde56..36397c7f64 100644 --- a/web/service/use-plugins.ts +++ b/web/service/use-plugins.ts @@ -336,7 +336,7 @@ export const useInstallOrUpdate = ({ } if (item.type === 'marketplace') { const data = item as GitHubItemAndMarketPlaceDependency - uniqueIdentifier = data.value.marketplace_plugin_unique_identifier! || plugin[i]?.plugin_id! + uniqueIdentifier = data.value.marketplace_plugin_unique_identifier! || (plugin[i]?.latest_package_identifier ?? '') || (plugin[i]?.plugin_id ?? '') if (uniqueIdentifier === installedPayload?.uniqueIdentifier) { return { status: TaskStatus.success,