Kaynağa Gözat

feat: new editor user permission profile (#4435)

Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com>
Co-authored-by: crazywoola <427733928@qq.com>
Charles Zhou 9 ay önce
ebeveyn
işleme
8bcc5a36bb
49 değiştirilmiş dosya ile 246 ekleme ve 126 silme
  1. 13 7
      api/controllers/console/app/app.py
  2. 13 1
      api/controllers/console/app/conversation.py
  3. 2 2
      api/controllers/console/app/site.py
  4. 6 6
      api/controllers/console/datasets/datasets.py
  5. 10 10
      api/controllers/console/datasets/datasets_document.py
  6. 4 4
      api/controllers/console/datasets/datasets_segments.py
  7. 10 10
      api/controllers/console/tag/tags.py
  8. 2 2
      api/controllers/console/workspace/members.py
  9. 5 3
      api/controllers/console/workspace/models.py
  10. 17 1
      api/models/account.py
  11. 2 0
      api/tests/unit_tests/models/test_account.py
  12. 16 13
      web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout.tsx
  13. 4 4
      web/app/(commonLayout)/apps/AppCard.tsx
  14. 2 2
      web/app/(commonLayout)/apps/Apps.tsx
  15. 2 2
      web/app/(commonLayout)/datasets/Datasets.tsx
  16. 3 3
      web/app/components/app/create-app-modal/index.tsx
  17. 2 2
      web/app/components/app/create-from-dsl-modal/index.tsx
  18. 5 4
      web/app/components/app/overview/appCard.tsx
  19. 2 2
      web/app/components/app/switch-app-modal/index.tsx
  20. 1 1
      web/app/components/develop/secret-key/secret-key-modal.tsx
  21. 2 2
      web/app/components/explore/app-list/index.tsx
  22. 1 0
      web/app/components/header/account-setting/members-page/index.tsx
  23. 6 2
      web/app/components/header/account-setting/members-page/invite-modal/index.tsx
  24. 2 1
      web/app/components/header/account-setting/members-page/operation/index.tsx
  25. 4 1
      web/app/components/header/account-setting/model-provider-page/model-modal/index.tsx
  26. 3 0
      web/app/components/header/account-setting/model-provider-page/system-model-selector/index.tsx
  27. 5 5
      web/app/components/header/app-nav/index.tsx
  28. 3 3
      web/app/components/header/app-selector/index.tsx
  29. 3 3
      web/app/components/header/index.tsx
  30. 2 2
      web/app/components/header/nav/nav-selector/index.tsx
  31. 18 14
      web/app/components/tools/provider/custom-create-card.tsx
  32. 4 0
      web/app/components/tools/provider/detail.tsx
  33. 2 0
      web/app/components/tools/setting/build-in/config-credentials.tsx
  34. 23 10
      web/app/components/tools/workflow-tool/configure-button.tsx
  35. 4 0
      web/context/app-context.tsx
  36. 3 0
      web/i18n/de-DE/common.ts
  37. 6 0
      web/i18n/en-US/common.ts
  38. 3 0
      web/i18n/fr-FR/common.ts
  39. 3 0
      web/i18n/ja-JP/common.ts
  40. 3 0
      web/i18n/ko-KR/common.ts
  41. 3 0
      web/i18n/pl-PL/common.ts
  42. 3 0
      web/i18n/pt-BR/common.ts
  43. 3 0
      web/i18n/ro-RO/common.ts
  44. 3 0
      web/i18n/uk-UA/common.ts
  45. 3 0
      web/i18n/vi-VN/common.ts
  46. 3 0
      web/i18n/zh-Hans/common.ts
  47. 3 0
      web/i18n/zh-Hant/common.ts
  48. 2 2
      web/models/common.ts
  49. 2 2
      web/utils/app-redirection.ts

+ 13 - 7
api/controllers/console/app/app.py

@@ -68,8 +68,8 @@ class AppListApi(Resource):
         parser.add_argument('icon_background', type=str, location='json')
         args = parser.parse_args()
 
-        # The role of the current user in the ta table must be admin or owner
-        if not current_user.is_admin_or_owner:
+        # The role of the current user in the ta table must be admin, owner, or editor
+        if not current_user.is_editor:
             raise Forbidden()
 
         if 'mode' not in args or args['mode'] is None:
@@ -89,8 +89,8 @@ class AppImportApi(Resource):
     @cloud_edition_billing_resource_check('apps')
     def post(self):
         """Import app"""
-        # The role of the current user in the ta table must be admin or owner
-        if not current_user.is_admin_or_owner:
+        # The role of the current user in the ta table must be admin, owner, or editor
+        if not current_user.is_editor:
             raise Forbidden()
 
         parser = reqparse.RequestParser()
@@ -147,7 +147,7 @@ class AppApi(Resource):
     @get_app_model
     def delete(self, app_model):
         """Delete app"""
-        if not current_user.is_admin_or_owner:
+        if not current_user.is_editor:
             raise Forbidden()
 
         app_service = AppService()
@@ -164,8 +164,8 @@ class AppCopyApi(Resource):
     @marshal_with(app_detail_fields_with_site)
     def post(self, app_model):
         """Copy app"""
-        # The role of the current user in the ta table must be admin or owner
-        if not current_user.is_admin_or_owner:
+        # The role of the current user in the ta table must be admin, owner, or editor
+        if not current_user.is_editor:
             raise Forbidden()
 
         parser = reqparse.RequestParser()
@@ -238,6 +238,9 @@ class AppSiteStatus(Resource):
     @get_app_model
     @marshal_with(app_detail_fields)
     def post(self, app_model):
+        # The role of the current user in the ta table must be admin, owner, or editor
+        if not current_user.is_editor:
+            raise Forbidden()
         parser = reqparse.RequestParser()
         parser.add_argument('enable_site', type=bool, required=True, location='json')
         args = parser.parse_args()
@@ -255,6 +258,9 @@ class AppApiStatus(Resource):
     @get_app_model
     @marshal_with(app_detail_fields)
     def post(self, app_model):
+        # The role of the current user in the ta table must be admin or owner
+        if not current_user.is_admin_or_owner:
+            raise Forbidden()
         parser = reqparse.RequestParser()
         parser.add_argument('enable_api', type=bool, required=True, location='json')
         args = parser.parse_args()

+ 13 - 1
api/controllers/console/app/conversation.py

@@ -6,7 +6,7 @@ from flask_restful import Resource, marshal_with, reqparse
 from flask_restful.inputs import int_range
 from sqlalchemy import func, or_
 from sqlalchemy.orm import joinedload
-from werkzeug.exceptions import NotFound
+from werkzeug.exceptions import Forbidden, NotFound
 
 from controllers.console import api
 from controllers.console.app.wraps import get_app_model
@@ -33,6 +33,8 @@ class CompletionConversationApi(Resource):
     @get_app_model(mode=AppMode.COMPLETION)
     @marshal_with(conversation_pagination_fields)
     def get(self, app_model):
+        if not current_user.is_admin_or_owner:
+            raise Forbidden()
         parser = reqparse.RequestParser()
         parser.add_argument('keyword', type=str, location='args')
         parser.add_argument('start', type=datetime_string('%Y-%m-%d %H:%M'), location='args')
@@ -106,6 +108,8 @@ class CompletionConversationDetailApi(Resource):
     @get_app_model(mode=AppMode.COMPLETION)
     @marshal_with(conversation_message_detail_fields)
     def get(self, app_model, conversation_id):
+        if not current_user.is_admin_or_owner:
+            raise Forbidden()
         conversation_id = str(conversation_id)
 
         return _get_conversation(app_model, conversation_id)
@@ -115,6 +119,8 @@ class CompletionConversationDetailApi(Resource):
     @account_initialization_required
     @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
     def delete(self, app_model, conversation_id):
+        if not current_user.is_admin_or_owner:
+            raise Forbidden()
         conversation_id = str(conversation_id)
 
         conversation = db.session.query(Conversation) \
@@ -137,6 +143,8 @@ class ChatConversationApi(Resource):
     @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
     @marshal_with(conversation_with_summary_pagination_fields)
     def get(self, app_model):
+        if not current_user.is_admin_or_owner:
+            raise Forbidden()
         parser = reqparse.RequestParser()
         parser.add_argument('keyword', type=str, location='args')
         parser.add_argument('start', type=datetime_string('%Y-%m-%d %H:%M'), location='args')
@@ -225,6 +233,8 @@ class ChatConversationDetailApi(Resource):
     @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
     @marshal_with(conversation_detail_fields)
     def get(self, app_model, conversation_id):
+        if not current_user.is_admin_or_owner:
+            raise Forbidden()
         conversation_id = str(conversation_id)
 
         return _get_conversation(app_model, conversation_id)
@@ -234,6 +244,8 @@ class ChatConversationDetailApi(Resource):
     @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
     @account_initialization_required
     def delete(self, app_model, conversation_id):
+        if not current_user.is_admin_or_owner:
+            raise Forbidden()
         conversation_id = str(conversation_id)
 
         conversation = db.session.query(Conversation) \

+ 2 - 2
api/controllers/console/app/site.py

@@ -40,8 +40,8 @@ class AppSite(Resource):
     def post(self, app_model):
         args = parse_app_site_args()
 
-        # The role of the current user in the ta table must be admin or owner
-        if not current_user.is_admin_or_owner:
+        # The role of the current user in the ta table must be editor, admin, or owner
+        if not current_user.is_editor:
             raise Forbidden()
 
         site = db.session.query(Site). \

+ 6 - 6
api/controllers/console/datasets/datasets.py

@@ -107,8 +107,8 @@ class DatasetListApi(Resource):
                             help='Invalid indexing technique.')
         args = parser.parse_args()
 
-        # The role of the current user in the ta table must be admin or owner
-        if not current_user.is_admin_or_owner:
+        # The role of the current user in the ta table must be admin, owner, or editor
+        if not current_user.is_editor:
             raise Forbidden()
 
         try:
@@ -195,8 +195,8 @@ class DatasetApi(Resource):
         parser.add_argument('retrieval_model', type=dict, location='json', help='Invalid retrieval model.')
         args = parser.parse_args()
 
-        # The role of the current user in the ta table must be admin or owner
-        if not current_user.is_admin_or_owner:
+        # The role of the current user in the ta table must be admin, owner, or editor
+        if not current_user.is_editor:
             raise Forbidden()
 
         dataset = DatasetService.update_dataset(
@@ -213,8 +213,8 @@ class DatasetApi(Resource):
     def delete(self, dataset_id):
         dataset_id_str = str(dataset_id)
 
-        # The role of the current user in the ta table must be admin or owner
-        if not current_user.is_admin_or_owner:
+        # The role of the current user in the ta table must be admin, owner, or editor
+        if not current_user.is_editor:
             raise Forbidden()
 
         try:

+ 10 - 10
api/controllers/console/datasets/datasets_document.py

@@ -226,8 +226,8 @@ class DatasetDocumentListApi(Resource):
         if not dataset:
             raise NotFound('Dataset not found.')
 
-        # The role of the current user in the ta table must be admin or owner
-        if not current_user.is_admin_or_owner:
+        # The role of the current user in the ta table must be admin, owner, or editor
+        if not current_user.is_editor:
             raise Forbidden()
 
         try:
@@ -278,8 +278,8 @@ class DatasetInitApi(Resource):
     @marshal_with(dataset_and_document_fields)
     @cloud_edition_billing_resource_check('vector_space')
     def post(self):
-        # The role of the current user in the ta table must be admin or owner
-        if not current_user.is_admin_or_owner:
+        # The role of the current user in the ta table must be admin, owner, or editor
+        if not current_user.is_editor:
             raise Forbidden()
 
         parser = reqparse.RequestParser()
@@ -632,8 +632,8 @@ class DocumentProcessingApi(DocumentResource):
         document_id = str(document_id)
         document = self.get_document(dataset_id, document_id)
 
-        # The role of the current user in the ta table must be admin or owner
-        if not current_user.is_admin_or_owner:
+        # The role of the current user in the ta table must be admin, owner, or editor
+        if not current_user.is_editor:
             raise Forbidden()
 
         if action == "pause":
@@ -696,8 +696,8 @@ class DocumentMetadataApi(DocumentResource):
         doc_type = req_data.get('doc_type')
         doc_metadata = req_data.get('doc_metadata')
 
-        # The role of the current user in the ta table must be admin or owner
-        if not current_user.is_admin_or_owner:
+        # The role of the current user in the ta table must be admin, owner, or editor
+        if not current_user.is_editor:
             raise Forbidden()
 
         if doc_type is None or doc_metadata is None:
@@ -743,8 +743,8 @@ class DocumentStatusApi(DocumentResource):
 
         document = self.get_document(dataset_id, document_id)
 
-        # The role of the current user in the ta table must be admin or owner
-        if not current_user.is_admin_or_owner:
+        # The role of the current user in the ta table must be admin, owner, or editor
+        if not current_user.is_editor:
             raise Forbidden()
 
         indexing_cache_key = 'document_{}_indexing'.format(document.id)

+ 4 - 4
api/controllers/console/datasets/datasets_segments.py

@@ -126,8 +126,8 @@ class DatasetDocumentSegmentApi(Resource):
             raise NotFound('Dataset not found.')
         # check user's model setting
         DatasetService.check_dataset_model_setting(dataset)
-        # The role of the current user in the ta table must be admin or owner
-        if not current_user.is_admin_or_owner:
+        # The role of the current user in the ta table must be admin, owner, or editor
+        if not current_user.is_editor:
             raise Forbidden()
 
         try:
@@ -302,8 +302,8 @@ class DatasetDocumentSegmentUpdateApi(Resource):
         ).first()
         if not segment:
             raise NotFound('Segment not found.')
-        # The role of the current user in the ta table must be admin or owner
-        if not current_user.is_admin_or_owner:
+        # The role of the current user in the ta table must be admin, owner, or editor
+        if not current_user.is_editor:
             raise Forbidden()
         try:
             DatasetService.check_dataset_permission(dataset, current_user)

+ 10 - 10
api/controllers/console/tag/tags.py

@@ -35,8 +35,8 @@ class TagListApi(Resource):
     @login_required
     @account_initialization_required
     def post(self):
-        # The role of the current user in the ta table must be admin or owner
-        if not current_user.is_admin_or_owner:
+        # The role of the current user in the ta table must be admin, owner, or editor
+        if not current_user.is_editor:
             raise Forbidden()
 
         parser = reqparse.RequestParser()
@@ -67,8 +67,8 @@ class TagUpdateDeleteApi(Resource):
     @account_initialization_required
     def patch(self, tag_id):
         tag_id = str(tag_id)
-        # The role of the current user in the ta table must be admin or owner
-        if not current_user.is_admin_or_owner:
+        # The role of the current user in the ta table must be admin, owner, or editor
+        if not current_user.is_editor:
             raise Forbidden()
 
         parser = reqparse.RequestParser()
@@ -94,8 +94,8 @@ class TagUpdateDeleteApi(Resource):
     @account_initialization_required
     def delete(self, tag_id):
         tag_id = str(tag_id)
-        # The role of the current user in the ta table must be admin or owner
-        if not current_user.is_admin_or_owner:
+        # The role of the current user in the ta table must be admin, owner, or editor
+        if not current_user.is_editor:
             raise Forbidden()
 
         TagService.delete_tag(tag_id)
@@ -109,8 +109,8 @@ class TagBindingCreateApi(Resource):
     @login_required
     @account_initialization_required
     def post(self):
-        # The role of the current user in the ta table must be admin or owner
-        if not current_user.is_admin_or_owner:
+        # The role of the current user in the ta table must be admin, owner, or editor
+        if not current_user.is_editor:
             raise Forbidden()
 
         parser = reqparse.RequestParser()
@@ -134,8 +134,8 @@ class TagBindingDeleteApi(Resource):
     @login_required
     @account_initialization_required
     def post(self):
-        # The role of the current user in the ta table must be admin or owner
-        if not current_user.is_admin_or_owner:
+        # The role of the current user in the ta table must be admin, owner, or editor
+        if not current_user.is_editor:
             raise Forbidden()
 
         parser = reqparse.RequestParser()

+ 2 - 2
api/controllers/console/workspace/members.py

@@ -43,7 +43,7 @@ class MemberInviteEmailApi(Resource):
         invitee_emails = args['emails']
         invitee_role = args['role']
         interface_language = args['language']
-        if invitee_role not in [TenantAccountRole.ADMIN, TenantAccountRole.NORMAL]:
+        if not TenantAccountRole.is_non_owner_role(invitee_role):
             return {'code': 'invalid-role', 'message': 'Invalid role'}, 400
 
         inviter = current_user
@@ -114,7 +114,7 @@ class MemberUpdateRoleApi(Resource):
         args = parser.parse_args()
         new_role = args['role']
 
-        if new_role not in ['admin', 'normal', 'owner']:
+        if not TenantAccountRole.is_valid_role(new_role):
             return {'code': 'invalid-role', 'message': 'Invalid role'}, 400
 
         member = Account.query.get(str(member_id))

+ 5 - 3
api/controllers/console/workspace/models.py

@@ -11,7 +11,6 @@ from core.model_runtime.entities.model_entities import ModelType
 from core.model_runtime.errors.validate import CredentialsValidateFailedError
 from core.model_runtime.utils.encoders import jsonable_encoder
 from libs.login import login_required
-from models.account import TenantAccountRole
 from services.model_load_balancing_service import ModelLoadBalancingService
 from services.model_provider_service import ModelProviderService
 
@@ -43,6 +42,9 @@ class DefaultModelApi(Resource):
     @login_required
     @account_initialization_required
     def post(self):
+        if not current_user.is_admin_or_owner:
+            raise Forbidden()
+        
         parser = reqparse.RequestParser()
         parser.add_argument('model_settings', type=list, required=True, nullable=False, location='json')
         args = parser.parse_args()
@@ -96,7 +98,7 @@ class ModelProviderModelApi(Resource):
     @login_required
     @account_initialization_required
     def post(self, provider: str):
-        if not TenantAccountRole.is_privileged_role(current_user.current_tenant.current_role):
+        if not current_user.is_admin_or_owner:
             raise Forbidden()
 
         tenant_id = current_user.current_tenant_id
@@ -162,7 +164,7 @@ class ModelProviderModelApi(Resource):
     @login_required
     @account_initialization_required
     def delete(self, provider: str):
-        if not TenantAccountRole.is_privileged_role(current_user.current_tenant.current_role):
+        if not current_user.is_admin_or_owner:
             raise Forbidden()
 
         tenant_id = current_user.current_tenant_id

+ 17 - 1
api/models/account.py

@@ -106,6 +106,9 @@ class Account(UserMixin, db.Model):
     def is_admin_or_owner(self):
         return TenantAccountRole.is_privileged_role(self._current_tenant.current_role)
 
+    @property
+    def is_editor(self):
+        return TenantAccountRole.is_editing_role(self._current_tenant.current_role)
 
 class TenantStatus(str, enum.Enum):
     NORMAL = 'normal'
@@ -115,11 +118,24 @@ class TenantStatus(str, enum.Enum):
 class TenantAccountRole(str, enum.Enum):
     OWNER = 'owner'
     ADMIN = 'admin'
+    EDITOR = 'editor'
     NORMAL = 'normal'
 
+    @staticmethod
+    def is_valid_role(role: str) -> bool:
+        return role and role in {TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.EDITOR, TenantAccountRole.NORMAL}
+
     @staticmethod
     def is_privileged_role(role: str) -> bool:
-        return role and role in {TenantAccountRole.ADMIN, TenantAccountRole.OWNER}
+        return role and role in {TenantAccountRole.OWNER, TenantAccountRole.ADMIN}
+    
+    @staticmethod
+    def is_non_owner_role(role: str) -> bool:
+        return role and role in {TenantAccountRole.ADMIN, TenantAccountRole.EDITOR, TenantAccountRole.NORMAL}
+    
+    @staticmethod
+    def is_editing_role(role: str) -> bool:
+        return role and role in {TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.EDITOR}
 
 
 class Tenant(db.Model):

+ 2 - 0
api/tests/unit_tests/models/test_account.py

@@ -4,9 +4,11 @@ from models.account import TenantAccountRole
 def test_account_is_privileged_role() -> None:
     assert TenantAccountRole.ADMIN == 'admin'
     assert TenantAccountRole.OWNER == 'owner'
+    assert TenantAccountRole.EDITOR == 'editor'
     assert TenantAccountRole.NORMAL == 'normal'
 
     assert TenantAccountRole.is_privileged_role(TenantAccountRole.ADMIN)
     assert TenantAccountRole.is_privileged_role(TenantAccountRole.OWNER)
     assert not TenantAccountRole.is_privileged_role(TenantAccountRole.NORMAL)
+    assert not TenantAccountRole.is_privileged_role(TenantAccountRole.EDITOR)
     assert not TenantAccountRole.is_privileged_role('')

+ 16 - 13
web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout.tsx

@@ -32,7 +32,7 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
   const pathname = usePathname()
   const media = useBreakpoints()
   const isMobile = media === MediaType.mobile
-  const { isCurrentWorkspaceManager } = useAppContext()
+  const { isCurrentWorkspaceManager, isCurrentWorkspaceEditor } = useAppContext()
   const { appDetail, setAppDetail, setAppSiderbarExpand } = useStore(useShallow(state => ({
     appDetail: state.appDetail,
     setAppDetail: state.setAppDetail,
@@ -45,9 +45,9 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
     selectedIcon: NavIcon
   }>>([])
 
-  const getNavigations = useCallback((appId: string, isCurrentWorkspaceManager: boolean, mode: string) => {
+  const getNavigations = useCallback((appId: string, isCurrentWorkspaceManager: boolean, isCurrentWorkspaceEditor: boolean, mode: string) => {
     const navs = [
-      ...(isCurrentWorkspaceManager
+      ...(isCurrentWorkspaceEditor
         ? [{
           name: t('common.appMenus.promptEng'),
           href: `/app/${appId}/${(mode === 'workflow' || mode === 'advanced-chat') ? 'workflow' : 'configuration'}`,
@@ -62,14 +62,17 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
         icon: TerminalSquare,
         selectedIcon: TerminalSquareSolid,
       },
-      {
-        name: mode !== 'workflow'
-          ? t('common.appMenus.logAndAnn')
-          : t('common.appMenus.logs'),
-        href: `/app/${appId}/logs`,
-        icon: FileHeart02,
-        selectedIcon: FileHeart02Solid,
-      },
+      ...(isCurrentWorkspaceManager
+        ? [{
+          name: mode !== 'workflow'
+            ? t('common.appMenus.logAndAnn')
+            : t('common.appMenus.logs'),
+          href: `/app/${appId}/logs`,
+          icon: FileHeart02,
+          selectedIcon: FileHeart02Solid,
+        }]
+        : []
+      ),
       {
         name: t('common.appMenus.overview'),
         href: `/app/${appId}/overview`,
@@ -104,10 +107,10 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
       }
       else {
         setAppDetail(res)
-        setNavigation(getNavigations(appId, isCurrentWorkspaceManager, res.mode))
+        setNavigation(getNavigations(appId, isCurrentWorkspaceManager, isCurrentWorkspaceEditor, res.mode))
       }
     })
-  }, [appId, isCurrentWorkspaceManager])
+  }, [appId, isCurrentWorkspaceManager, isCurrentWorkspaceEditor])
 
   useUnmount(() => {
     setAppDetail()

+ 4 - 4
web/app/(commonLayout)/apps/AppCard.tsx

@@ -37,7 +37,7 @@ export type AppCardProps = {
 const AppCard = ({ app, onRefresh }: AppCardProps) => {
   const { t } = useTranslation()
   const { notify } = useContext(ToastContext)
-  const { isCurrentWorkspaceManager } = useAppContext()
+  const { isCurrentWorkspaceEditor } = useAppContext()
   const { onPlanInfoChanged } = useProviderContext()
   const { push } = useRouter()
 
@@ -116,7 +116,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
         onRefresh()
       mutateApps()
       onPlanInfoChanged()
-      getRedirection(isCurrentWorkspaceManager, newApp, push)
+      getRedirection(isCurrentWorkspaceEditor, newApp, push)
     }
     catch (e) {
       notify({ type: 'error', message: t('app.newApp.appCreateFailed') })
@@ -224,7 +224,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
       <div
         onClick={(e) => {
           e.preventDefault()
-          getRedirection(isCurrentWorkspaceManager, app, push)
+          getRedirection(isCurrentWorkspaceEditor, app, push)
         }}
         className='group flex col-span-1 bg-white border-2 border-solid border-transparent rounded-xl shadow-sm min-h-[160px] flex flex-col transition-all duration-200 ease-in-out cursor-pointer hover:shadow-lg'
       >
@@ -298,7 +298,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
               />
             </div>
           </div>
-          {isCurrentWorkspaceManager && (
+          {isCurrentWorkspaceEditor && (
             <>
               <div className='!hidden group-hover:!flex shrink-0 mx-1 w-[1px] h-[14px] bg-gray-200'/>
               <div className='!hidden group-hover:!flex shrink-0'>

+ 2 - 2
web/app/(commonLayout)/apps/Apps.tsx

@@ -50,7 +50,7 @@ const getKey = (
 
 const Apps = () => {
   const { t } = useTranslation()
-  const { isCurrentWorkspaceManager } = useAppContext()
+  const { isCurrentWorkspaceEditor } = useAppContext()
   const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
   const [activeTab, setActiveTab] = useTabSearchParams({
     defaultTab: 'all',
@@ -130,7 +130,7 @@ const Apps = () => {
         </div>
       </div>
       <nav className='grid content-start grid-cols-1 gap-4 px-12 pt-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 grow shrink-0'>
-        {isCurrentWorkspaceManager
+        {isCurrentWorkspaceEditor
           && <NewAppCard onSuccess={mutate} />}
         {data?.map(({ data: apps }: any) => apps.map((app: any) => (
           <AppCard key={app.id} app={app} onRefresh={mutate} />

+ 2 - 2
web/app/(commonLayout)/datasets/Datasets.tsx

@@ -44,7 +44,7 @@ const Datasets = ({
   tags,
   keywords,
 }: Props) => {
-  const { isCurrentWorkspaceManager } = useAppContext()
+  const { isCurrentWorkspaceEditor } = useAppContext()
   const { data, isLoading, setSize, mutate } = useSWRInfinite(
     (pageIndex: number, previousPageData: DataSetListResponse) => getKey(pageIndex, previousPageData, tags, keywords),
     fetchDatasets,
@@ -76,7 +76,7 @@ const Datasets = ({
 
   return (
     <nav className='grid content-start grid-cols-1 gap-4 px-12 pt-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 grow shrink-0'>
-      { isCurrentWorkspaceManager && <NewDatasetCard ref={anchorRef} /> }
+      { isCurrentWorkspaceEditor && <NewDatasetCard ref={anchorRef} /> }
       {data?.map(({ data: datasets }) => datasets.map(dataset => (
         <DatasetCard key={dataset.id} dataset={dataset} onSuccess={mutate} />),
       ))}

+ 3 - 3
web/app/components/app/create-app-modal/index.tsx

@@ -44,7 +44,7 @@ const CreateAppModal = ({ show, onSuccess, onClose }: CreateAppDialogProps) => {
 
   const { plan, enableBilling } = useProviderContext()
   const isAppsFull = (enableBilling && plan.usage.buildApps >= plan.total.buildApps)
-  const { isCurrentWorkspaceManager } = useAppContext()
+  const { isCurrentWorkspaceEditor } = useAppContext()
 
   const isCreatingRef = useRef(false)
   const onCreate: MouseEventHandler = useCallback(async () => {
@@ -72,13 +72,13 @@ const CreateAppModal = ({ show, onSuccess, onClose }: CreateAppDialogProps) => {
       onClose()
       mutateApps()
       localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
-      getRedirection(isCurrentWorkspaceManager, app, push)
+      getRedirection(isCurrentWorkspaceEditor, app, push)
     }
     catch (e) {
       notify({ type: 'error', message: t('app.newApp.appCreateFailed') })
     }
     isCreatingRef.current = false
-  }, [name, notify, t, appMode, emoji.icon, emoji.icon_background, description, onSuccess, onClose, mutateApps, push, isCurrentWorkspaceManager])
+  }, [name, notify, t, appMode, emoji.icon, emoji.icon_background, description, onSuccess, onClose, mutateApps, push, isCurrentWorkspaceEditor])
 
   return (
     <Modal

+ 2 - 2
web/app/components/app/create-from-dsl-modal/index.tsx

@@ -47,7 +47,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose }: CreateFromDSLModalProp
       setFileContent('')
   }
 
-  const { isCurrentWorkspaceManager } = useAppContext()
+  const { isCurrentWorkspaceEditor } = useAppContext()
   const { plan, enableBilling } = useProviderContext()
   const isAppsFull = (enableBilling && plan.usage.buildApps >= plan.total.buildApps)
 
@@ -68,7 +68,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose }: CreateFromDSLModalProp
         onClose()
       notify({ type: 'success', message: t('app.newApp.appCreated') })
       localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
-      getRedirection(isCurrentWorkspaceManager, app, push)
+      getRedirection(isCurrentWorkspaceEditor, app, push)
     }
     catch (e) {
       notify({ type: 'error', message: t('app.newApp.appCreateFailed') })

+ 5 - 4
web/app/components/app/overview/appCard.tsx

@@ -53,7 +53,7 @@ function AppCard({
 }: IAppCardProps) {
   const router = useRouter()
   const pathname = usePathname()
-  const { currentWorkspace, isCurrentWorkspaceManager } = useAppContext()
+  const { currentWorkspace, isCurrentWorkspaceManager, isCurrentWorkspaceEditor } = useAppContext()
   const [showSettingsModal, setShowSettingsModal] = useState(false)
   const [showEmbedded, setShowEmbedded] = useState(false)
   const [showCustomizeModal, setShowCustomizeModal] = useState(false)
@@ -74,16 +74,17 @@ function AppCard({
     if (appInfo.mode !== 'completion' && appInfo.mode !== 'workflow')
       operationsMap.webapp.push({ opName: t('appOverview.overview.appInfo.embedded.entry'), opIcon: EmbedIcon })
 
-    if (isCurrentWorkspaceManager)
+    if (isCurrentWorkspaceEditor)
       operationsMap.webapp.push({ opName: t('appOverview.overview.appInfo.settings.entry'), opIcon: Cog8ToothIcon })
 
     return operationsMap
-  }, [isCurrentWorkspaceManager, appInfo, t])
+  }, [isCurrentWorkspaceEditor, appInfo, t])
 
   const isApp = cardType === 'webapp'
   const basicName = isApp
     ? appInfo?.site?.title
     : t('appOverview.overview.apiInfo.title')
+  const toggleDisabled = isApp ? !isCurrentWorkspaceEditor : !isCurrentWorkspaceManager
   const runningStatus = isApp ? appInfo.enable_site : appInfo.enable_api
   const { app_base_url, access_token } = appInfo.site ?? {}
   const appMode = (appInfo.mode !== 'completion' && appInfo.mode !== 'workflow') ? 'chat' : appInfo.mode
@@ -154,7 +155,7 @@ function AppCard({
                 ? t('appOverview.overview.status.running')
                 : t('appOverview.overview.status.disable')}
             </Tag>
-            <Switch defaultValue={runningStatus} onChange={onChangeStatus} disabled={currentWorkspace?.role === 'normal'} />
+            <Switch defaultValue={runningStatus} onChange={onChangeStatus} disabled={toggleDisabled} />
           </div>
         </div>
         <div className="flex flex-col justify-center py-2">

+ 2 - 2
web/app/components/app/switch-app-modal/index.tsx

@@ -37,7 +37,7 @@ const SwitchAppModal = ({ show, appDetail, inAppDetail = false, onSuccess, onClo
   const { notify } = useContext(ToastContext)
   const setAppDetail = useAppStore(s => s.setAppDetail)
 
-  const { isCurrentWorkspaceManager } = useAppContext()
+  const { isCurrentWorkspaceEditor } = useAppContext()
   const { plan, enableBilling } = useProviderContext()
   const isAppsFull = (enableBilling && plan.usage.buildApps >= plan.total.buildApps)
 
@@ -66,7 +66,7 @@ const SwitchAppModal = ({ show, appDetail, inAppDetail = false, onSuccess, onClo
         await deleteApp(appDetail.id)
       localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
       getRedirection(
-        isCurrentWorkspaceManager,
+        isCurrentWorkspaceEditor,
         {
           id: newAppID,
           mode: appDetail.mode === 'completion' ? 'workflow' : 'advanced-chat',

+ 1 - 1
web/app/components/develop/secret-key/secret-key-modal.tsx

@@ -143,7 +143,7 @@ const SecretKeyModal = ({
         )
       }
       <div className='flex'>
-        <Button type='default' className={`flex flex-shrink-0 mt-4 ${s.autoWidth}`} onClick={onCreate} disabled={ !currentWorkspace || currentWorkspace.role === 'normal'}>
+        <Button type='default' className={`flex flex-shrink-0 mt-4 ${s.autoWidth}`} onClick={onCreate} disabled={ !currentWorkspace || !isCurrentWorkspaceManager}>
           <PlusIcon className='flex flex-shrink-0 w-4 h-4' />
           <div className='text-xs font-medium text-gray-800'>{t('appApi.apiKeyModal.createNewSecretKey')}</div>
         </Button>

+ 2 - 2
web/app/components/explore/app-list/index.tsx

@@ -38,7 +38,7 @@ const Apps = ({
   onSuccess,
 }: AppsProps) => {
   const { t } = useTranslation()
-  const { isCurrentWorkspaceManager } = useAppContext()
+  const { isCurrentWorkspaceEditor } = useAppContext()
   const { push } = useRouter()
   const { hasEditPermission } = useContext(ExploreContext)
   const allCategoriesEn = t('explore.apps.allCategories', { lng: 'en' })
@@ -116,7 +116,7 @@ const Apps = ({
       if (onSuccess)
         onSuccess()
       localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
-      getRedirection(isCurrentWorkspaceManager, app, push)
+      getRedirection(isCurrentWorkspaceEditor, app, push)
     }
     catch (e) {
       Toast.notify({ type: 'error', message: t('app.newApp.appCreateFailed') })

+ 1 - 0
web/app/components/header/account-setting/members-page/index.tsx

@@ -28,6 +28,7 @@ const MembersPage = () => {
   const RoleMap = {
     owner: t('common.members.owner'),
     admin: t('common.members.admin'),
+    editor: t('common.members.editor'),
     normal: t('common.members.normal'),
   }
   const { locale } = useContext(I18n)

+ 6 - 2
web/app/components/header/account-setting/members-page/invite-modal/index.tsx

@@ -37,6 +37,10 @@ const InviteModal = ({
       name: 'normal',
       description: t('common.members.normalTip'),
     },
+    {
+      name: 'editor',
+      description: t('common.members.editorTip'),
+    },
     {
       name: 'admin',
       description: t('common.members.adminTip'),
@@ -120,7 +124,7 @@ const InviteModal = ({
                         <div className='flex flex-row'>
                           <span
                             className={cn(
-                              'text-indigo-600 w-8',
+                              'text-indigo-600 mr-2',
                               'flex items-center',
                             )}
                           >
@@ -130,7 +134,7 @@ const InviteModal = ({
                             <span className={`${selected ? 'font-medium' : 'font-normal'} capitalize block truncate`}>
                               {t(`common.members.${role.name}`)}
                             </span>
-                            <span className={`${selected ? 'font-medium' : 'font-normal'} capitalize block truncate`}>
+                            <span className={`${selected ? 'font-medium' : 'font-normal'} capitalize block text-gray-500`}>
                               {role.description}
                             </span>
                           </div>

+ 2 - 1
web/app/components/header/account-setting/members-page/operation/index.tsx

@@ -36,6 +36,7 @@ const Operation = ({
   const RoleMap = {
     owner: t('common.members.owner'),
     admin: t('common.members.admin'),
+    editor: t('common.members.editor'),
     normal: t('common.members.normal'),
   }
   const { notify } = useContext(ToastContext)
@@ -98,7 +99,7 @@ const Operation = ({
               >
                 <div className="px-1 py-1">
                   {
-                    ['admin', 'normal'].map(role => (
+                    ['admin', 'editor', 'normal'].map(role => (
                       <Menu.Item key={role}>
                         <div className={itemClassName} onClick={() => handleUpdateMemberRole(role)}>
                           {

+ 4 - 1
web/app/components/header/account-setting/model-provider-page/model-modal/index.tsx

@@ -47,6 +47,7 @@ import {
 } from '@/app/components/base/portal-to-follow-elem'
 import { useToastContext } from '@/app/components/base/toast'
 import ConfirmCommon from '@/app/components/base/confirm/common'
+import { useAppContext } from '@/context/app-context'
 
 type ModelModalProps = {
   provider: ModelProvider
@@ -74,7 +75,8 @@ const ModelModal: FC<ModelModalProps> = ({
     providerFormSchemaPredefined && provider.custom_configuration.status === CustomConfigurationStatusEnum.active,
     currentCustomConfigurationModelFixedFields,
   )
-  const isEditMode = !!formSchemasValue
+  const { isCurrentWorkspaceManager } = useAppContext()
+  const isEditMode = !!formSchemasValue && isCurrentWorkspaceManager
   const { t } = useTranslation()
   const { notify } = useToastContext()
   const language = useLanguage()
@@ -344,6 +346,7 @@ const ModelModal: FC<ModelModalProps> = ({
                       || filteredRequiredFormSchemas.some(item => value[item.variable] === undefined)
                       || (draftConfig?.enabled && (draftConfig?.configs.filter(config => config.enabled).length ?? 0) < 2)
                     }
+
                   >
                     {t('common.operation.save')}
                   </Button>

+ 3 - 0
web/app/components/header/account-setting/model-provider-page/system-model-selector/index.tsx

@@ -23,6 +23,7 @@ import Button from '@/app/components/base/button'
 import { useProviderContext } from '@/context/provider-context'
 import { updateDefaultModel } from '@/service/common'
 import { useToastContext } from '@/app/components/base/toast'
+import { useAppContext } from '@/context/app-context'
 
 type SystemModelSelectorProps = {
   textGenerationDefaultModel: DefaultModelResponse | undefined
@@ -40,6 +41,7 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
 }) => {
   const { t } = useTranslation()
   const { notify } = useToastContext()
+  const { isCurrentWorkspaceManager } = useAppContext()
   const { textGenerationModelList } = useProviderContext()
   const updateModelList = useUpdateModelList()
   const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding)
@@ -248,6 +250,7 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
               type='primary'
               className='!h-8 !text-[13px]'
               onClick={handleSave}
+              disabled={!isCurrentWorkspaceManager}
             >
               {t('common.operation.save')}
             </Button>

+ 5 - 5
web/app/components/header/app-nav/index.tsx

@@ -39,7 +39,7 @@ const getKey = (
 const AppNav = () => {
   const { t } = useTranslation()
   const { appId } = useParams()
-  const { isCurrentWorkspaceManager } = useAppContext()
+  const { isCurrentWorkspaceEditor } = useAppContext()
   const appDetail = useAppStore(state => state.appDetail)
   const [showNewAppDialog, setShowNewAppDialog] = useState(false)
   const [showNewAppTemplateDialog, setShowNewAppTemplateDialog] = useState(false)
@@ -71,8 +71,8 @@ const AppNav = () => {
     if (appsData) {
       const appItems = flatten(appsData?.map(appData => appData.data))
       const navItems = appItems.map((app) => {
-        const link = ((isCurrentWorkspaceManager, app) => {
-          if (!isCurrentWorkspaceManager) {
+        const link = ((isCurrentWorkspaceEditor, app) => {
+          if (!isCurrentWorkspaceEditor) {
             return `/app/${app.id}/overview`
           }
           else {
@@ -81,7 +81,7 @@ const AppNav = () => {
             else
               return `/app/${app.id}/configuration`
           }
-        })(isCurrentWorkspaceManager, app)
+        })(isCurrentWorkspaceEditor, app)
         return {
           id: app.id,
           icon: app.icon,
@@ -93,7 +93,7 @@ const AppNav = () => {
       })
       setNavItems(navItems)
     }
-  }, [appsData, isCurrentWorkspaceManager, setNavItems])
+  }, [appsData, isCurrentWorkspaceEditor, setNavItems])
 
   // update current app name
   useEffect(() => {

+ 3 - 3
web/app/components/header/app-selector/index.tsx

@@ -17,7 +17,7 @@ type IAppSelectorProps = {
 
 export default function AppSelector({ appItems, curApp }: IAppSelectorProps) {
   const router = useRouter()
-  const { isCurrentWorkspaceManager } = useAppContext()
+  const { isCurrentWorkspaceEditor } = useAppContext()
   const [showNewAppDialog, setShowNewAppDialog] = useState(false)
   const { t } = useTranslation()
 
@@ -65,7 +65,7 @@ export default function AppSelector({ appItems, curApp }: IAppSelectorProps) {
                 appItems.map((app: AppDetailResponse) => (
                   <Menu.Item key={app.id}>
                     <div className={itemClassName} onClick={() =>
-                      router.push(`/app/${app.id}/${isCurrentWorkspaceManager ? 'configuration' : 'overview'}`)
+                      router.push(`/app/${app.id}/${isCurrentWorkspaceEditor ? 'configuration' : 'overview'}`)
                     }>
                       <div className='relative w-6 h-6 mr-2 bg-[#D5F5F6] rounded-[6px]'>
                         <AppIcon size='tiny' />
@@ -79,7 +79,7 @@ export default function AppSelector({ appItems, curApp }: IAppSelectorProps) {
                 ))
               }
             </div>)}
-            {isCurrentWorkspaceManager && <Menu.Item>
+            {isCurrentWorkspaceEditor && <Menu.Item>
               <div className='p-1' onClick={() => setShowNewAppDialog(true)}>
                 <div
                   className='flex items-center h-12 rounded-lg cursor-pointer hover:bg-gray-100'

+ 3 - 3
web/app/components/header/index.tsx

@@ -26,7 +26,7 @@ const navClassName = `
 `
 
 const Header = () => {
-  const { isCurrentWorkspaceManager } = useAppContext()
+  const { isCurrentWorkspaceEditor } = useAppContext()
 
   const selectedSegment = useSelectedLayoutSegment()
   const media = useBreakpoints()
@@ -74,7 +74,7 @@ const Header = () => {
         <div className='flex items-center'>
           <ExploreNav className={navClassName} />
           <AppNav />
-          {isCurrentWorkspaceManager && <DatasetNav />}
+          {isCurrentWorkspaceEditor && <DatasetNav />}
           <ToolsNav className={navClassName} />
         </div>
       )}
@@ -93,7 +93,7 @@ const Header = () => {
         <div className='w-full flex flex-col p-2 gap-y-1'>
           <ExploreNav className={navClassName} />
           <AppNav />
-          {isCurrentWorkspaceManager && <DatasetNav />}
+          {isCurrentWorkspaceEditor && <DatasetNav />}
           <ToolsNav className={navClassName} />
         </div>
       )}

+ 2 - 2
web/app/components/header/nav/nav-selector/index.tsx

@@ -34,7 +34,7 @@ export type INavSelectorProps = {
 const NavSelector = ({ curNav, navs, createText, isApp, onCreate, onLoadmore }: INavSelectorProps) => {
   const { t } = useTranslation()
   const router = useRouter()
-  const { isCurrentWorkspaceManager } = useAppContext()
+  const { isCurrentWorkspaceEditor } = useAppContext()
   const setAppDetail = useAppStore(state => state.setAppDetail)
 
   const handleScroll = useCallback(debounce((e) => {
@@ -122,7 +122,7 @@ const NavSelector = ({ curNav, navs, createText, isApp, onCreate, onLoadmore }:
                   </div>
                 </Menu.Button>
               )}
-              {isApp && isCurrentWorkspaceManager && (
+              {isApp && isCurrentWorkspaceEditor && (
                 <Menu as="div" className="relative w-full h-full">
                   {({ open }) => (
                     <>

+ 18 - 14
web/app/components/tools/provider/custom-create-card.tsx

@@ -11,6 +11,7 @@ import { ArrowUpRight } from '@/app/components/base/icons/src/vender/line/arrows
 import EditCustomToolModal from '@/app/components/tools/edit-custom-collection-modal'
 import { createCustomCollection } from '@/service/tools'
 import Toast from '@/app/components/base/toast'
+import { useAppContext } from '@/context/app-context'
 
 type Props = {
   onRefreshData: () => void
@@ -20,6 +21,7 @@ const Contribute = ({ onRefreshData }: Props) => {
   const { t } = useTranslation()
   const { locale } = useContext(I18n)
   const language = getLanguage(locale)
+  const { isCurrentWorkspaceManager } = useAppContext()
 
   const linkUrl = useMemo(() => {
     if (language.startsWith('zh_'))
@@ -40,23 +42,25 @@ const Contribute = ({ onRefreshData }: Props) => {
 
   return (
     <>
-      <div className='flex flex-col col-span-1 bg-gray-200 border-[0.5px] border-black/5 rounded-xl min-h-[160px] transition-all duration-200 ease-in-out cursor-pointer hover:bg-gray-50 hover:shadow-lg'>
-        <div className='group grow rounded-t-xl hover:bg-white' onClick={() => setIsShowEditCustomCollectionModal(true)}>
-          <div className='shrink-0 flex items-center p-4 pb-3'>
-            <div className='w-10 h-10 flex items-center justify-center border border-gray-200 bg-gray-100 rounded-lg group-hover:border-primary-100 group-hover:bg-primary-50'>
-              <Plus className='w-4 h-4 text-gray-500 group-hover:text-primary-600'/>
+      {isCurrentWorkspaceManager && (
+        <div className='flex flex-col col-span-1 bg-gray-200 border-[0.5px] border-black/5 rounded-xl min-h-[160px] transition-all duration-200 ease-in-out cursor-pointer hover:bg-gray-50 hover:shadow-lg'>
+          <div className='group grow rounded-t-xl hover:bg-white' onClick={() => setIsShowEditCustomCollectionModal(true)}>
+            <div className='shrink-0 flex items-center p-4 pb-3'>
+              <div className='w-10 h-10 flex items-center justify-center border border-gray-200 bg-gray-100 rounded-lg group-hover:border-primary-100 group-hover:bg-primary-50'>
+                <Plus className='w-4 h-4 text-gray-500 group-hover:text-primary-600'/>
+              </div>
+              <div className='ml-3 text-sm font-semibold leading-5 text-gray-800 group-hover:text-primary-600'>{t('tools.createCustomTool')}</div>
             </div>
-            <div className='ml-3 text-sm font-semibold leading-5 text-gray-800 group-hover:text-primary-600'>{t('tools.createCustomTool')}</div>
+          </div>
+          <div className='px-4 py-3 rounded-b-xl border-t-[0.5px] border-black/5 text-gray-500 hover:text-[#155EEF] hover:bg-white'>
+            <a href={linkUrl} target='_blank' rel='noopener noreferrer' className='flex items-center space-x-1'>
+              <BookOpen01 className='shrink-0 w-3 h-3' />
+              <div className='grow leading-[18px] text-xs font-normal truncate' title={t('tools.customToolTip') || ''}>{t('tools.customToolTip')}</div>
+              <ArrowUpRight className='shrink-0 w-3 h-3' />
+            </a>
           </div>
         </div>
-        <div className='px-4 py-3 rounded-b-xl border-t-[0.5px] border-black/5 text-gray-500 hover:text-[#155EEF] hover:bg-white'>
-          <a href={linkUrl} target='_blank' rel='noopener noreferrer' className='flex items-center space-x-1'>
-            <BookOpen01 className='shrink-0 w-3 h-3' />
-            <div className='grow leading-[18px] text-xs font-normal truncate' title={t('tools.customToolTip') || ''}>{t('tools.customToolTip')}</div>
-            <ArrowUpRight className='shrink-0 w-3 h-3' />
-          </a>
-        </div>
-      </div>
+      )}
       {isShowEditCollectionToolModal && (
         <EditCustomToolModal
           payload={null}

+ 4 - 0
web/app/components/tools/provider/detail.tsx

@@ -33,6 +33,7 @@ import { useModalContext } from '@/context/modal-context'
 import { useProviderContext } from '@/context/provider-context'
 import { ConfigurationMethodEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
 import Loading from '@/app/components/base/loading'
+import { useAppContext } from '@/context/app-context'
 
 type Props = {
   collection: Collection
@@ -51,6 +52,7 @@ const ProviderDetail = ({
   const isAuthed = collection.is_team_authorization
   const isBuiltIn = collection.type === CollectionType.builtIn
   const isModel = collection.type === CollectionType.model
+  const { isCurrentWorkspaceManager } = useAppContext()
 
   const [isDetailLoading, setIsDetailLoading] = useState(false)
 
@@ -221,6 +223,7 @@ const ProviderDetail = ({
               if (collection.type === CollectionType.builtIn || collection.type === CollectionType.model)
                 showSettingAuthModal()
             }}
+            disabled={!isCurrentWorkspaceManager}
           >
             {isAuthed && <Indicator className='mr-2' color={'green'} />}
             <div className={cn('text-white leading-[18px] text-[13px] font-medium', isAuthed && '!text-gray-700')}>
@@ -251,6 +254,7 @@ const ProviderDetail = ({
             <Button
               className={cn('shrink-0 my-3 w-[183px] flex items-center bg-white')}
               onClick={() => setIsShowEditWorkflowToolModal(true)}
+              disabled={!isCurrentWorkspaceManager}
             >
               <div className='leading-5 text-sm font-medium text-gray-700'>{t('tools.createTool.editAction')}</div>
             </Button>

+ 2 - 0
web/app/components/tools/setting/build-in/config-credentials.tsx

@@ -11,6 +11,7 @@ import { fetchBuiltInToolCredential, fetchBuiltInToolCredentialSchema } from '@/
 import Loading from '@/app/components/base/loading'
 import Form from '@/app/components/header/account-setting/model-provider-page/model-modal/Form'
 import { LinkExternal02 } from '@/app/components/base/icons/src/vender/line/general'
+import { useAppContext } from '@/context/app-context'
 
 type Props = {
   collection: Collection
@@ -29,6 +30,7 @@ const ConfigCredential: FC<Props> = ({
 }) => {
   const { t } = useTranslation()
   const [credentialSchema, setCredentialSchema] = useState<any>(null)
+  const { isCurrentWorkspaceManager } = useAppContext()
   const { name: collectionName } = collection
   const [tempCredential, setTempCredential] = React.useState<any>({})
   useEffect(() => {

+ 23 - 10
web/app/components/tools/workflow-tool/configure-button.tsx

@@ -13,6 +13,7 @@ import Toast from '@/app/components/base/toast'
 import { createWorkflowToolProvider, fetchWorkflowToolDetailByAppID, saveWorkflowToolProvider } from '@/service/tools'
 import type { Emoji, WorkflowToolProviderParameter, WorkflowToolProviderRequest, WorkflowToolProviderResponse } from '@/app/components/tools/types'
 import type { InputVar } from '@/app/components/workflow/types'
+import { useAppContext } from '@/context/app-context'
 
 type Props = {
   disabled: boolean
@@ -44,6 +45,7 @@ const WorkflowToolConfigureButton = ({
   const [showModal, setShowModal] = useState(false)
   const [isLoading, setIsLoading] = useState(false)
   const [detail, setDetail] = useState<WorkflowToolProviderResponse>()
+  const { isCurrentWorkspaceManager } = useAppContext()
 
   const outdated = useMemo(() => {
     if (!detail)
@@ -175,22 +177,33 @@ const WorkflowToolConfigureButton = ({
             disabled ? 'shadow-xs opacity-30 cursor-not-allowed' : 'cursor-pointer',
             !published && 'hover:bg-primary-50',
           )}>
-            <div
-              className='flex justify-start items-center gap-2 px-2.5 py-2'
-              onClick={() => !published && setShowModal(true)}
-            >
-              <Tools className={cn('relative w-4 h-4', !published && 'group-hover:text-primary-600')}/>
-              <div title={t('workflow.common.workflowAsTool') || ''} className={cn('grow shrink basis-0 text-[13px] font-medium leading-[18px] truncate', !published && 'group-hover:text-primary-600')}>{t('workflow.common.workflowAsTool')}</div>
-              {!published && (
-                <span className='shrink-0 px-1 border border-black/8 rounded-[5px] bg-white text-[10px] font-medium leading-[18px] text-gray-500'>{t('workflow.common.configureRequired').toLocaleUpperCase()}</span>
+            {isCurrentWorkspaceManager
+              ? (
+                <div
+                  className='flex justify-start items-center gap-2 px-2.5 py-2'
+                  onClick={() => !published && setShowModal(true)}
+                >
+                  <Tools className={cn('relative w-4 h-4', !published && 'group-hover:text-primary-600')} />
+                  <div title={t('workflow.common.workflowAsTool') || ''} className={cn('grow shrink basis-0 text-[13px] font-medium leading-[18px] truncate', !published && 'group-hover:text-primary-600')}>{t('workflow.common.workflowAsTool')}</div>
+                  {!published && (
+                    <span className='shrink-0 px-1 border border-black/8 rounded-[5px] bg-white text-[10px] font-medium leading-[18px] text-gray-500'>{t('workflow.common.configureRequired').toLocaleUpperCase()}</span>
+                  )}
+                </div>)
+              : (
+                <div
+                  className='flex justify-start items-center gap-2 px-2.5 py-2'
+                >
+                  <Tools className='w-4 h-4 text-gray-500' />
+                  <div title={t('workflow.common.workflowAsTool') || ''} className='grow shrink basis-0 text-[13px] font-medium leading-[18px] truncate text-gray-500'>{t('workflow.common.workflowAsTool')}</div>
+                </div>
               )}
-            </div>
             {published && (
               <div className='px-2.5 py-2 border-t-[0.5px] border-black/5'>
                 <div className='flex justify-between'>
                   <Button
                     className='px-2 w-[140px] py-0 h-6 shadow-xs rounded-md text-xs font-medium text-gray-700 border-[0.5px] bg-white border-gray-200'
                     onClick={() => setShowModal(true)}
+                    disabled={!isCurrentWorkspaceManager}
                   >
                     {t('workflow.common.configure')}
                     {outdated && <Indicator className='ml-1' color={'yellow'} />}
@@ -208,7 +221,7 @@ const WorkflowToolConfigureButton = ({
             )}
           </div>
         )}
-        {published && isLoading && <div className='pt-2'><Loading type='app'/></div>}
+        {published && isLoading && <div className='pt-2'><Loading type='app' /></div>}
       </div>
       {showModal && (
         <WorkflowToolModal

+ 4 - 0
web/context/app-context.tsx

@@ -19,6 +19,7 @@ export type AppContextValue = {
   currentWorkspace: ICurrentWorkspace
   isCurrentWorkspaceManager: boolean
   isCurrentWorkspaceOwner: boolean
+  isCurrentWorkspaceEditor: boolean
   mutateCurrentWorkspace: VoidFunction
   pageContainerRef: React.RefObject<HTMLDivElement>
   langeniusVersionInfo: LangGeniusVersionResponse
@@ -59,6 +60,7 @@ const AppContext = createContext<AppContextValue>({
   currentWorkspace: initialWorkspaceInfo,
   isCurrentWorkspaceManager: false,
   isCurrentWorkspaceOwner: false,
+  isCurrentWorkspaceEditor: false,
   mutateUserProfile: () => { },
   mutateCurrentWorkspace: () => { },
   pageContainerRef: createRef(),
@@ -86,6 +88,7 @@ export const AppContextProvider: FC<AppContextProviderProps> = ({ children }) =>
   const [currentWorkspace, setCurrentWorkspace] = useState<ICurrentWorkspace>(initialWorkspaceInfo)
   const isCurrentWorkspaceManager = useMemo(() => ['owner', 'admin'].includes(currentWorkspace.role), [currentWorkspace.role])
   const isCurrentWorkspaceOwner = useMemo(() => currentWorkspace.role === 'owner', [currentWorkspace.role])
+  const isCurrentWorkspaceEditor = useMemo(() => ['owner', 'admin', 'editor'].includes(currentWorkspace.role), [currentWorkspace.role])
   const updateUserProfileAndVersion = useCallback(async () => {
     if (userProfileResponse && !userProfileResponse.bodyUsed) {
       const result = await userProfileResponse.json()
@@ -121,6 +124,7 @@ export const AppContextProvider: FC<AppContextProviderProps> = ({ children }) =>
       currentWorkspace,
       isCurrentWorkspaceManager,
       isCurrentWorkspaceOwner,
+      isCurrentWorkspaceEditor,
       mutateCurrentWorkspace,
     }}>
       <div className='flex flex-col h-full overflow-y-auto'>

+ 3 - 0
web/i18n/de-DE/common.ts

@@ -169,6 +169,8 @@ const translation = {
     adminTip: 'Kann Apps erstellen & Team-Einstellungen verwalten',
     normal: 'Normal',
     normalTip: 'Kann nur Apps verwenden, kann keine Apps erstellen',
+    editor: 'Editor',
+    editorTip: 'Kann Apps erstellen & bearbeiten',
     inviteTeamMember: 'Teammitglied hinzufügen',
     inviteTeamMemberTip: 'Sie können direkt nach der Anmeldung auf Ihre Teamdaten zugreifen.',
     email: 'E-Mail',
@@ -185,6 +187,7 @@ const translation = {
     removeFromTeamTip: 'Wird den Teamzugang entfernen',
     setAdmin: 'Als Administrator einstellen',
     setMember: 'Als normales Mitglied einstellen',
+    setEditor: 'Als Editor einstellen',
     disinvite: 'Einladung widerrufen',
     deleteMember: 'Mitglied löschen',
     you: '(Du)',

+ 6 - 0
web/i18n/en-US/common.ts

@@ -169,6 +169,10 @@ const translation = {
     adminTip: 'Can build apps & manage team settings',
     normal: 'Normal',
     normalTip: 'Only can use apps, can not build apps',
+    builder: 'Builder',
+    builderTip: 'Can build & edit own apps',
+    editor: 'Editor',
+    editorTip: 'Can build & edit apps',
     inviteTeamMember: 'Add team member',
     inviteTeamMemberTip: 'They can access your team data directly after signing in.',
     email: 'Email',
@@ -185,6 +189,8 @@ const translation = {
     removeFromTeamTip: 'Will remove team access',
     setAdmin: 'Set as administrator',
     setMember: 'Set to ordinary member',
+    setBuilder: 'Set as builder',
+    setEditor: 'Set as editor',
     disinvite: 'Cancel the invitation',
     deleteMember: 'Delete Member',
     you: '(You)',

+ 3 - 0
web/i18n/fr-FR/common.ts

@@ -169,6 +169,8 @@ const translation = {
     adminTip: 'Peut construire des applications & gérer les paramètres de l\'équipe',
     normal: 'Normal',
     normalTip: 'Peut seulement utiliser des applications, ne peut pas construire des applications',
+    editor: 'Éditeur',
+    editorTip: 'Peut construire des applications, mais ne peut pas gérer les paramètres de l\'équipe',
     inviteTeamMember: 'Ajouter un membre de l\'équipe',
     inviteTeamMemberTip: 'Ils peuvent accéder directement à vos données d\'équipe après s\'être connectés.',
     email: 'Courrier électronique',
@@ -185,6 +187,7 @@ const translation = {
     removeFromTeamTip: 'Supprimera l\'accès de l\'équipe',
     setAdmin: 'Définir comme administrateur',
     setMember: 'Définir en tant que membre ordinaire',
+    setEditor: 'Définir en tant qu\'éditeur',
     disinvite: 'Annuler l\'invitation',
     deleteMember: 'Supprimer Membre',
     you: '(Vous)',

+ 3 - 0
web/i18n/ja-JP/common.ts

@@ -169,6 +169,8 @@ const translation = {
     adminTip: 'アプリの構築およびチーム設定の管理ができます',
     normal: '通常',
     normalTip: 'アプリの使用のみが可能で、アプリの構築はできません',
+    editor: 'エディター',
+    editorTip: 'アプリの構築ができますが、チーム設定の管理はできません',
     inviteTeamMember: 'チームメンバーを招待する',
     inviteTeamMemberTip: '彼らはサインイン後、直接あなたのチームデータにアクセスできます。',
     email: 'メール',
@@ -185,6 +187,7 @@ const translation = {
     removeFromTeamTip: 'チームへのアクセスが削除されます',
     setAdmin: '管理者に設定',
     setMember: '通常のメンバーに設定',
+    setEditor: 'エディターに設定',
     disinvite: '招待をキャンセル',
     deleteMember: 'メンバーを削除',
     you: '(あなた)',

+ 3 - 0
web/i18n/ko-KR/common.ts

@@ -165,6 +165,8 @@ const translation = {
     adminTip: '앱 빌드 및 팀 설정 관리 가능',
     normal: '일반',
     normalTip: '앱 사용만 가능하고 앱 빌드는 불가능',
+    editor: '편집자',
+    editorTip: '앱 빌드만 가능하고 팀 설정 관리 불가능',
     inviteTeamMember: '팀 멤버 초대',
     inviteTeamMemberTip: '로그인 후에 바로 팀 데이터에 액세스할 수 있습니다.',
     email: '이메일',
@@ -181,6 +183,7 @@ const translation = {
     removeFromTeamTip: '팀 액세스가 제거됩니다',
     setAdmin: '관리자 설정',
     setMember: '일반 멤버 설정',
+    setEditor: '편집자 설정',
     disinvite: '초대 취소',
     deleteMember: '멤버 삭제',
     you: '(나)',

+ 3 - 0
web/i18n/pl-PL/common.ts

@@ -175,6 +175,8 @@ const translation = {
     adminTip: 'Może tworzyć aplikacje i zarządzać ustawieniami zespołu',
     normal: 'Normalny',
     normalTip: 'Może tylko korzystać z aplikacji, nie może tworzyć aplikacji',
+    editor: 'Edytor',
+    editorTip: 'Może tworzyć i edytować aplikacje, ale nie zarządzać ustawieniami zespołu',
     inviteTeamMember: 'Dodaj członka zespołu',
     inviteTeamMemberTip:
       'Mogą uzyskać bezpośredni dostęp do danych Twojego zespołu po zalogowaniu.',
@@ -193,6 +195,7 @@ const translation = {
     removeFromTeamTip: 'Usunie dostęp do zespołu',
     setAdmin: 'Ustaw jako administratora',
     setMember: 'Ustaw jako zwykłego członka',
+    setEditor: 'Ustaw jako edytora',
     disinvite: 'Anuluj zaproszenie',
     deleteMember: 'Usuń członka',
     you: '(Ty)',

+ 3 - 0
web/i18n/pt-BR/common.ts

@@ -169,6 +169,8 @@ const translation = {
     adminTip: 'Pode criar aplicativos e gerenciar configurações da equipe',
     normal: 'Normal',
     normalTip: 'Só pode usar aplicativos, não pode criar aplicativos',
+    editor: 'Editor',
+    editorTip: 'Pode editar aplicativos, mas não pode gerenciar configurações da equipe',
     inviteTeamMember: 'Adicionar membro da equipe',
     inviteTeamMemberTip: 'Eles podem acessar os dados da sua equipe diretamente após fazer login.',
     email: 'E-mail',
@@ -185,6 +187,7 @@ const translation = {
     removeFromTeamTip: 'Removerá o acesso da equipe',
     setAdmin: 'Definir como administrador',
     setMember: 'Definir como membro comum',
+    setEditor: 'Definir como editor',
     disinvite: 'Cancelar o convite',
     deleteMember: 'Excluir Membro',
     you: '(Você)',

+ 3 - 0
web/i18n/ro-RO/common.ts

@@ -168,6 +168,8 @@ const translation = {
     adminTip: 'Poate construi aplicații și gestiona setările echipei',
     normal: 'Normal',
     normalTip: 'Poate doar utiliza aplicații, nu poate construi aplicații',
+    editor: 'Editor',
+    editorTip: 'Poate construi aplicații, dar nu poate gestiona setările echipei',
     inviteTeamMember: 'Adaugă membru în echipă',
     inviteTeamMemberTip: 'Pot accesa direct datele echipei dvs. după autentificare.',
     email: 'Email',
@@ -184,6 +186,7 @@ const translation = {
     removeFromTeamTip: 'Va elimina accesul la echipă',
     setAdmin: 'Setează ca administrator',
     setMember: 'Setează ca membru obișnuit',
+    setEditor: 'Setează ca editor',
     disinvite: 'Anulează invitația',
     deleteMember: 'Șterge membru',
     you: '(Dvs.)',

+ 3 - 0
web/i18n/uk-UA/common.ts

@@ -169,6 +169,8 @@ const translation = {
     adminTip: 'Може створювати програми та керувати налаштуваннями команди',
     normal: 'Звичайний',
     normalTip: 'Може лише використовувати програми, не може створювати програми',
+    editor: 'Редактор',
+    editorTip: 'Може створювати програми, але не може керувати налаштуваннями команди',
     inviteTeamMember: 'Додати учасника команди',
     inviteTeamMemberTip: 'Вони зможуть отримати доступ до даних вашої команди безпосередньо після входу.',
     email: 'Електронна пошта',
@@ -185,6 +187,7 @@ const translation = {
     removeFromTeamTip: 'Буде видалено доступ до команди',
     setAdmin: 'Призначити адміністратором',
     setMember: 'Встановити як звичайного члена',
+    setEditor: 'Встановити як Редактор',
     disinvite: 'Скасувати запрошення',
     deleteMember: 'Видалити учасника',
     you: '(Ви)',

+ 3 - 0
web/i18n/vi-VN/common.ts

@@ -168,6 +168,8 @@ const translation = {
     adminTip: 'Có thể xây dựng ứng dụng và quản lý cài đặt nhóm',
     normal: 'Bình thường',
     normalTip: 'Chỉ có thể sử dụng ứng dụng, không thể xây dựng ứng dụng',
+    editor: 'Biên tập viên',
+    editorTip: 'Chỉ có thể xây dựng ứng dụng, không thể quản lý cài đặt nhóm',
     inviteTeamMember: 'Mời thành viên nhóm',
     inviteTeamMemberTip: 'Sau khi đăng nhập, họ có thể truy cập trực tiếp vào dữ liệu nhóm của bạn.',
     email: 'Email',
@@ -184,6 +186,7 @@ const translation = {
     removeFromTeamTip: 'Sẽ xóa quyền truy cập nhóm',
     setAdmin: 'Đặt làm quản trị viên',
     setMember: 'Đặt thành viên bình thường',
+    setEditor: 'Đặt làm biên tập viên',
     disinvite: 'Hủy lời mời',
     deleteMember: 'Xóa thành viên',
     you: '(Bạn)',

+ 3 - 0
web/i18n/zh-Hans/common.ts

@@ -169,6 +169,8 @@ const translation = {
     adminTip: '能够建立应用程序和管理团队设置',
     normal: '成员',
     normalTip: '只能使用应用程序,不能建立应用程序',
+    editor: '编辑',
+    editorTip: '能够建立并编辑应用程序,不能管理团队设置',
     inviteTeamMember: '添加团队成员',
     inviteTeamMemberTip: '对方在登录后可以访问你的团队数据。',
     email: '邮箱',
@@ -185,6 +187,7 @@ const translation = {
     removeFromTeamTip: '将取消团队访问',
     setAdmin: '设为管理员',
     setMember: '设为普通成员',
+    setEditor: '设为编辑',
     disinvite: '取消邀请',
     deleteMember: '删除成员',
     you: '(你)',

+ 3 - 0
web/i18n/zh-Hant/common.ts

@@ -169,6 +169,8 @@ const translation = {
     adminTip: '能夠建立應用程式和管理團隊設定',
     normal: '成員',
     normalTip: '只能使用應用程式,不能建立應用程式',
+    editor: '編輯',
+    editorTip: '能夠建立並編輯應用程式,不能管理團隊設定',
     inviteTeamMember: '新增團隊成員',
     inviteTeamMemberTip: '對方在登入後可以訪問你的團隊資料。',
     email: '郵箱',
@@ -185,6 +187,7 @@ const translation = {
     removeFromTeamTip: '將取消團隊訪問',
     setAdmin: '設為管理員',
     setMember: '設為普通成員',
+    setEditor: '設為編輯',
     disinvite: '取消邀請',
     deleteMember: '刪除成員',
     you: '(你)',

+ 2 - 2
web/models/common.ts

@@ -64,7 +64,7 @@ export type TenantInfoResponse = {
 export type Member = Pick<UserProfileResponse, 'id' | 'name' | 'email' | 'last_login_at' | 'created_at'> & {
   avatar: string
   status: 'pending' | 'active' | 'banned' | 'closed'
-  role: 'owner' | 'admin' | 'normal'
+  role: 'owner' | 'admin' | 'editor' | 'normal'
 }
 
 export enum ProviderName {
@@ -125,7 +125,7 @@ export type IWorkspace = {
 }
 
 export type ICurrentWorkspace = Omit<IWorkspace, 'current'> & {
-  role: 'normal' | 'admin' | 'owner'
+  role: 'owner' | 'admin' | 'editor' | 'normal'
   providers: Provider[]
   in_trail: boolean
   trial_end_reason?: string

+ 2 - 2
web/utils/app-redirection.ts

@@ -1,9 +1,9 @@
 export const getRedirection = (
-  isCurrentWorkspaceManager: boolean,
+  isCurrentWorkspaceEditor: boolean,
   app: any,
   redirectionFunc: (href: string) => void,
 ) => {
-  if (!isCurrentWorkspaceManager) {
+  if (!isCurrentWorkspaceEditor) {
     redirectionFunc(`/app/${app.id}/overview`)
   }
   else {