From ba965ecbda6cc5a24671e4b996f6d219509520bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=89=E6=B3=BD=E8=8A=83?= <> Date: Mon, 28 Apr 2025 17:20:29 +0800 Subject: [PATCH] feat: Add the setting for app's visibility permission --- api/controllers/console/app/app.py | 3 + api/fields/app_fields.py | 2 + api/models/model.py | 18 ++ api/services/app_permission_service.py | 191 ++++++++++++++++++ api/services/app_service.py | 60 +++++- web/app/(commonLayout)/apps/AppCard.tsx | 6 + .../explore/create-app-modal/index.tsx | 191 +++++++++++++++++- web/i18n/zh-Hans/app.ts | 4 + web/service/apps.ts | 4 +- web/types/app.ts | 4 + 10 files changed, 476 insertions(+), 7 deletions(-) create mode 100644 api/services/app_permission_service.py diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index 3e908b76a7..2a6874221c 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -1,4 +1,5 @@ import uuid +import json from typing import cast from flask_login import current_user # type: ignore @@ -139,6 +140,8 @@ class AppApi(Resource): parser.add_argument("icon", type=str, location="json") parser.add_argument("icon_background", type=str, location="json") parser.add_argument("use_icon_as_answer_icon", type=bool, location="json") + parser.add_argument("permission", type=str, location="json") + parser.add_argument("partial_member_list", type=list, location="json") args = parser.parse_args() app_service = AppService() diff --git a/api/fields/app_fields.py b/api/fields/app_fields.py index f42364f110..ea11bfd09f 100644 --- a/api/fields/app_fields.py +++ b/api/fields/app_fields.py @@ -98,6 +98,8 @@ app_partial_fields = { "updated_by": fields.String, "updated_at": TimestampField, "tags": fields.List(fields.Nested(tag_fields)), + "permission": fields.String, + "permission_account_ids": fields.List(fields.String), } diff --git a/api/models/model.py b/api/models/model.py index d1490d75c8..012785d494 100644 --- a/api/models/model.py +++ b/api/models/model.py @@ -101,6 +101,7 @@ class App(Base): updated_by = db.Column(StringUUID, nullable=True) updated_at = db.Column(db.DateTime, nullable=False, server_default=func.current_timestamp()) use_icon_as_answer_icon = db.Column(db.Boolean, nullable=False, server_default=db.text("false")) + permission = db.Column(db.String(255)) @property def desc_or_prompt(self): @@ -295,6 +296,23 @@ class App(Base): return tags or [] +class AppPermission(Base): + __tablename__ = "app_permissions" + __table_args__ = ( + db.PrimaryKeyConstraint("id", name="app_permission_pkey"), + db.Index("idx_app_permissions_app_id", "app_id"), + db.Index("idx_app_permissions_account_id", "account_id"), + db.Index("idx_app_permissions_tenant_id", "tenant_id"), + ) + + id = db.Column(StringUUID, server_default=db.text("uuid_generate_v4()")) + app_id = db.Column(StringUUID, nullable=False) + account_id = db.Column(StringUUID, nullable=False) + has_permission = db.Column(db.Boolean, nullable=False, server_default=db.text("true")) + created_at = db.Column(db.DateTime, nullable=False, server_default=func.current_timestamp()) + tenant_id = db.Column(StringUUID, nullable=False) + + class AppModelConfig(Base): __tablename__ = "app_model_configs" __table_args__ = (db.PrimaryKeyConstraint("id", name="app_model_config_pkey"), db.Index("app_app_id_idx", "app_id")) diff --git a/api/services/app_permission_service.py b/api/services/app_permission_service.py new file mode 100644 index 0000000000..44a89b88b6 --- /dev/null +++ b/api/services/app_permission_service.py @@ -0,0 +1,191 @@ +from sqlalchemy.orm import Session + +from extensions.ext_database import db +from models.model import AppPermission + + +class AppPermissionService: + @classmethod + def get_app_permissions_by_app_id(cls, app_id: str): + """ + Get a list of account IDs that have permission for an app. + + Args: + app_id (str): The ID of the app. + + Returns: + list: A list of account IDs with permissions for the app. + """ + with Session(db.engine) as session: + permissions = session.query(AppPermission).filter( + AppPermission.app_id == app_id, + AppPermission.has_permission == True + ).all() + + return [permission.account_id for permission in permissions] + + @classmethod + def get_app_permissions_by_account_id(cls, account_id: str): + """ + Get a list of app IDs that an account has permission for. + + Args: + account_id (str): The ID of the account. + + Returns: + list: A list of app IDs the account has permission for. + """ + with Session(db.engine) as session: + permissions = session.query(AppPermission).filter( + AppPermission.account_id == account_id, + AppPermission.has_permission == True + ).all() + + return [permission.app_id for permission in permissions] + + @classmethod + def update_app_permissions(cls, tenant_id: str, app_id: str, account_ids: list): + """ + Update the permissions for an app by replacing all existing permissions. + + Args: + tenant_id (str): The ID of the tenant. + app_id (str): The ID of the app. + account_ids (list): A list of account IDs to grant permission to. + + Returns: + bool: True if the operation succeeds. + """ + try: + with Session(db.engine) as session: + # Delete existing permissions for the app + session.query(AppPermission).filter( + AppPermission.app_id == app_id + ).delete() + + # Create new permissions + permissions = [] + for account_id in account_ids: + permission = AppPermission( + tenant_id=tenant_id, + app_id=app_id, + account_id=account_id, + has_permission=True + ) + permissions.append(permission) + + session.add_all(permissions) + session.commit() + + return True + except Exception as e: + db.session.rollback() + raise e + + @classmethod + def add_app_permission(cls, tenant_id: str, app_id: str, account_id: str): + """ + Add permission for an account to access an app. + + Args: + tenant_id (str): The ID of the tenant. + app_id (str): The ID of the app. + account_id (str): The ID of the account to grant permission to. + + Returns: + AppPermission: The created permission object. + """ + with Session(db.engine) as session: + # Check if permission already exists + existing_permission = session.query(AppPermission).filter( + AppPermission.app_id == app_id, + AppPermission.account_id == account_id + ).first() + + if existing_permission: + existing_permission.has_permission = True + session.commit() + return existing_permission + + # Create new permission + permission = AppPermission( + tenant_id=tenant_id, + app_id=app_id, + account_id=account_id, + has_permission=True + ) + + session.add(permission) + session.commit() + + return permission + + @classmethod + def remove_app_permission(cls, app_id: str, account_id: str): + """ + Remove permission for an account to access an app. + + Args: + app_id (str): The ID of the app. + account_id (str): The ID of the account to remove permission from. + + Returns: + bool: True if the permission was removed. + """ + with Session(db.engine) as session: + permission = session.query(AppPermission).filter( + AppPermission.app_id == app_id, + AppPermission.account_id == account_id + ).first() + + if permission: + session.delete(permission) + session.commit() + return True + + return False + + @classmethod + def check_app_permission(cls, app_id: str, account_id: str): + """ + Check if an account has permission to access an app. + + Args: + app_id (str): The ID of the app. + account_id (str): The ID of the account. + + Returns: + bool: True if the account has permission. + """ + with Session(db.engine) as session: + permission = session.query(AppPermission).filter( + AppPermission.app_id == app_id, + AppPermission.account_id == account_id, + AppPermission.has_permission == True + ).first() + + return permission is not None + + @classmethod + def clear_app_permissions(cls, app_id: str): + """ + Clear all permissions for an app. + + Args: + app_id (str): The ID of the app. + + Returns: + bool: True if the operation succeeds. + """ + try: + with Session(db.engine) as session: + session.query(AppPermission).filter( + AppPermission.app_id == app_id + ).delete() + + session.commit() + + return True + except Exception as e: + db.session.rollback() + raise e \ No newline at end of file diff --git a/api/services/app_service.py b/api/services/app_service.py index e87a1c7931..e6c1909882 100644 --- a/api/services/app_service.py +++ b/api/services/app_service.py @@ -20,6 +20,7 @@ from extensions.ext_database import db from models.account import Account from models.model import App, AppMode, AppModelConfig from models.tools import ApiToolProvider +from services.app_permission_service import AppPermissionService from services.tag_service import TagService from tasks.remove_app_and_related_data_task import remove_app_and_related_data_task @@ -66,7 +67,8 @@ class AppService: per_page=args["limit"], error_out=False, ) - + for app in app_models.items: + app.permission_account_ids = AppPermissionService.get_app_permissions_by_app_id(app.id) return app_models def create_app(self, tenant_id: str, args: dict, account: Account) -> App: @@ -231,7 +233,16 @@ class AppService: app.use_icon_as_answer_icon = args.get("use_icon_as_answer_icon", False) app.updated_by = current_user.id app.updated_at = datetime.now(UTC).replace(tzinfo=None) + app.permission = args.get("permission") db.session.commit() + tenant_id = current_user.current_tenant_id + # Handle app permissions + if args.get("permission") == "partial_members" and args.get("partial_member_list"): + account_ids = args.get("partial_member_list") + AppPermissionService.update_app_permissions(tenant_id, app.id, account_ids) + # Clear permissions if permission is not partial_members + elif args.get("permission") != "partial_members": + AppPermissionService.clear_app_permissions(app.id) return app @@ -304,6 +315,9 @@ class AppService: Delete app :param app: App instance """ + # Clear app permissions + AppPermissionService.clear_app_permissions(app.id) + db.session.delete(app) db.session.commit() @@ -373,3 +387,47 @@ class AppService: meta["tool_icons"][tool_name] = {"background": "#252525", "content": "\ud83d\ude01"} return meta + + def get_app_permission_member_list(self, app_id: str) -> list: + """ + Get the list of account IDs that have permission to access the app + + Args: + app_id (str): The ID of the app + + Returns: + list: List of account IDs with permission to access the app + """ + return AppPermissionService.get_app_permissions_by_app_id(app_id) + + def check_app_permission(self, app: App, user: Account) -> bool: + """ + Check if a user has permission to access an app + + Args: + app (App): The app to check permission for + user (Account): The user account to check + + Returns: + bool: True if the user has permission, False otherwise + """ + # App owner always has permission + if app.created_by == user.id: + return True + + # Tenant owner/admin always has permission + if user.is_owner or user.is_admin: + return True + + # Check app permission setting + if not app.permission or app.permission == "all_team_members": + # All team members have permission + return True + elif app.permission == "only_me": + # Only the app creator has permission + return app.created_by == user.id + elif app.permission == "partial_members": + # Check if the user is in the partial member list + return AppPermissionService.check_app_permission(app.id, user.id) + + return False diff --git a/web/app/(commonLayout)/apps/AppCard.tsx b/web/app/(commonLayout)/apps/AppCard.tsx index 3f8c180c1a..49ac1aec15 100644 --- a/web/app/(commonLayout)/apps/AppCard.tsx +++ b/web/app/(commonLayout)/apps/AppCard.tsx @@ -81,6 +81,8 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { icon_background, description, use_icon_as_answer_icon, + permission, + partial_member_list }) => { try { await updateAppInfo({ @@ -91,6 +93,8 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { icon_background, description, use_icon_as_answer_icon, + permission, + partial_member_list }) setShowEditModal(false) notify({ @@ -381,6 +385,8 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { show={showEditModal} onConfirm={onEdit} onHide={() => setShowEditModal(false)} + appPermission={app.permission} + appSelectedMemberIDs={app.permission_account_ids} /> )} {showDuplicateModal && ( diff --git a/web/app/components/explore/create-app-modal/index.tsx b/web/app/components/explore/create-app-modal/index.tsx index d6d521833a..83b6fa6bb8 100644 --- a/web/app/components/explore/create-app-modal/index.tsx +++ b/web/app/components/explore/create-app-modal/index.tsx @@ -1,8 +1,12 @@ 'use client' -import React, { useCallback, useState } from 'react' +import React, { useCallback, useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' -import { RiCloseLine, RiCommandLine, RiCornerDownLeftLine } from '@remixicon/react' +import { RiCloseLine, RiCommandLine, RiCornerDownLeftLine, RiArrowDownSLine } from '@remixicon/react' import { useDebounceFn, useKeyPress } from 'ahooks' +import { RiCloseLine as RemixIconCloseLine } from '@remixicon/react' +import cn from 'classnames' +import { Users01, UsersPlus } from '@/app/components/base/icons/src/vender/solid/users' +import { Check } from '@/app/components/base/icons/src/vender/line/general' import AppIconPicker from '../../base/app-icon-picker' import Modal from '@/app/components/base/modal' import Button from '@/app/components/base/button' @@ -11,10 +15,15 @@ import Textarea from '@/app/components/base/textarea' import Switch from '@/app/components/base/switch' import Toast from '@/app/components/base/toast' import AppIcon from '@/app/components/base/app-icon' +import Avatar from '@/app/components/base/avatar' +import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem' +import { useAppContext } from '@/context/app-context' import { useProviderContext } from '@/context/provider-context' import AppsFull from '@/app/components/billing/apps-full-in-dialog' import type { AppIconType } from '@/types/app' +import { fetchMembers } from '@/service/common' import { noop } from 'lodash-es' +import type { Member } from '@/models/common' export type CreateAppModalProps = { show: boolean @@ -27,6 +36,8 @@ export type CreateAppModalProps = { appIconUrl?: string | null appMode?: string appUseIconAsAnswerIcon?: boolean + appPermission?: string + appSelectedMemberIDs?: string[] onConfirm: (info: { name: string icon_type: AppIconType @@ -34,6 +45,8 @@ export type CreateAppModalProps = { icon_background?: string description: string use_icon_as_answer_icon?: boolean + permission: 'only_me' | 'all_team_members' | 'partial_members' + partial_member_list?: string[] }) => Promise onHide: () => void } @@ -49,6 +62,8 @@ const CreateAppModal = ({ appDescription, appMode, appUseIconAsAnswerIcon, + appPermission = 'only_me', + appSelectedMemberIDs, onConfirm, onHide, }: CreateAppModalProps) => { @@ -63,7 +78,15 @@ const CreateAppModal = ({ const [showAppIconPicker, setShowAppIconPicker] = useState(false) const [description, setDescription] = useState(appDescription || '') const [useIconAsAnswerIcon, setUseIconAsAnswerIcon] = useState(appUseIconAsAnswerIcon || false) + const [permission, setPermission] = useState<'only_me' | 'all_team_members' | 'partial_members'>(appPermission) + const [selectedMemberIDs, setSelectedMemberIDs] = useState(appSelectedMemberIDs || []) + const [isPermissionSelectorOpen, setIsPermissionSelectorOpen] = useState(false) + const [keywords, setKeywords] = useState('') + const [searchKeywords, setSearchKeywords] = useState('') + const [memberList, setMemberList] = useState([]) + + const { userProfile, currentWorkspace } = useAppContext() const { plan, enableBilling } = useProviderContext() const isAppsFull = (enableBilling && plan.usage.buildApps >= plan.total.buildApps) @@ -79,9 +102,56 @@ const CreateAppModal = ({ icon_background: appIcon.type === 'emoji' ? appIcon.background! : undefined, description, use_icon_as_answer_icon: useIconAsAnswerIcon, + permission, + partial_member_list: permission === 'partial_members' ? selectedMemberIDs : [], }) onHide() - }, [name, appIcon, description, useIconAsAnswerIcon, onConfirm, onHide, t]) + }, [name, appIcon, description, useIconAsAnswerIcon, permission, selectedMemberIDs, onConfirm, onHide, t]) + + const { run: handleSearch } = useDebounceFn(() => { + setSearchKeywords(keywords) + }, { wait: 500 }) + + const handleKeywordsChange = (value: string) => { + setKeywords(value) + handleSearch() + } + + const selectMember = (member: Member) => { + if (selectedMemberIDs.includes(member.id)) + setSelectedMemberIDs(selectedMemberIDs.filter(v => v !== member.id)) + else + setSelectedMemberIDs([...selectedMemberIDs, member.id]) + } + + const selectedMembersDisplay = React.useMemo(() => { + return [ + userProfile, + ...memberList.filter(member => member.id !== userProfile.id).filter(member => selectedMemberIDs.includes(member.id)), + ].map(member => member.name).join(', ') + }, [userProfile, selectedMemberIDs, memberList]) + + const showMe = React.useMemo(() => { + return userProfile.name.includes(searchKeywords) || userProfile.email.includes(searchKeywords) + }, [searchKeywords, userProfile]) + + const filteredMemberList = React.useMemo(() => { + return memberList.filter(member => (member.name.includes(searchKeywords) || member.email.includes(searchKeywords)) && member.id !== userProfile.id) + }, [memberList, searchKeywords, userProfile]) + + useEffect(() => { + if (isPermissionSelectorOpen && permission === 'partial_members') { + (async () => { + try { + const { accounts } = await fetchMembers({ url: '/workspaces/current/members', params: {} }) + setMemberList(accounts || []) + } catch (e) { + console.error('Failed to fetch members', e) + setMemberList([]) + } + })() + } + }, [isPermissionSelectorOpen, permission]) const { run: handleSubmit } = useDebounceFn(submit, { wait: 300 }) @@ -156,6 +226,119 @@ const CreateAppModal = ({

{t('app.answerIcon.descriptionInExplore')}

)} + {/* permissions */} + {isEditModal && ( +
+
{t('app.permissions')}
+ + setIsPermissionSelectorOpen(v => !v)} + className='block' + > +
+ {permission === 'only_me' && ( + <> + +
{t('app.permissionsOnlyMe')}
+ + )} + {permission === 'all_team_members' && ( + <> +
+ +
+
{t('app.permissionsAllMember')}
+ + )} + {permission === 'partial_members' && ( + <> +
+ +
+
{selectedMembersDisplay}
+ + )} + +
+
+ +
+
+
{ setPermission('only_me'); setIsPermissionSelectorOpen(false); }}> +
+ +
{t('app.permissionsOnlyMe')}
+ {permission === 'only_me' && } +
+
+
{ setPermission('all_team_members'); setIsPermissionSelectorOpen(false); }}> +
+
+ +
+
{t('app.permissionsAllMember')}
+ {permission === 'all_team_members' && } +
+
+
{ setPermission('partial_members'); setSelectedMemberIDs([userProfile.id]); }}> +
+
+ +
+
{t('app.permissionsInvitedMembers')}
+ {permission === 'partial_members' && } +
+
+
+ {permission === 'partial_members' && ( +
+
+ handleKeywordsChange(e.target.value)} + onClear={() => handleKeywordsChange('')} + placeholder={t('common.operation.search') || ''} + /> +
+ {showMe && ( +
+ +
+
+ {userProfile.name} + {` (${t('datasetSettings.form.me')})`} +
+
{userProfile.email}
+
+ +
+ )} + {filteredMemberList.map(member => ( +
selectMember(member)}> + +
+
{member.name}
+
{member.email}
+
+ {selectedMemberIDs.includes(member.id) && } +
+ ))} +
+ )} +
+
+
+
+ )} {!isEditModal && isAppsFull && }
@@ -190,4 +373,4 @@ const CreateAppModal = ({ ) } -export default CreateAppModal +export default CreateAppModal \ No newline at end of file diff --git a/web/i18n/zh-Hans/app.ts b/web/i18n/zh-Hans/app.ts index 7ef8c1b514..07a56568dd 100644 --- a/web/i18n/zh-Hans/app.ts +++ b/web/i18n/zh-Hans/app.ts @@ -117,6 +117,10 @@ const translation = { description: '是否使用 WebApp 图标替换分享的应用界面中的 🤖', descriptionInExplore: '是否使用 WebApp 图标替换 Explore 界面中的 🤖', }, + permissions: '可见权限', + permissionsOnlyMe: '只有我', + permissionsAllMember: '所有团队成员', + permissionsInvitedMembers: '部分团队成员', switch: '迁移为工作流编排', switchTipStart: '将为您创建一个使用工作流编排的新应用。新应用将', switchTip: '不能够', diff --git a/web/service/apps.ts b/web/service/apps.ts index 3f7ec7b548..c41a5ef8f5 100644 --- a/web/service/apps.ts +++ b/web/service/apps.ts @@ -28,8 +28,8 @@ export const createApp: Fetcher('apps', { body: { name, icon_type, icon, icon_background, mode, description, model_config: config } }) } -export const updateAppInfo: Fetcher = ({ appID, name, icon_type, icon, icon_background, description, use_icon_as_answer_icon }) => { - return put(`apps/${appID}`, { body: { name, icon_type, icon, icon_background, description, use_icon_as_answer_icon } }) +export const updateAppInfo: Fetcher = ({ appID, name, icon_type, icon, icon_background, description, use_icon_as_answer_icon, permission, partial_member_list }) => { + return put(`apps/${appID}`, { body: { name, icon_type, icon, icon_background, description, use_icon_as_answer_icon, permission, partial_member_list } }) } export const copyApp: Fetcher = ({ appID, name, icon_type, icon, icon_background, mode, description }) => { diff --git a/web/types/app.ts b/web/types/app.ts index 39f011dcaa..12acff2e06 100644 --- a/web/types/app.ts +++ b/web/types/app.ts @@ -359,6 +359,10 @@ export type App = { updated_at: number updated_by?: string } + /** Permission */ + permission: string + /** Permission Account IDs */ + permission_account_ids: string[] } export type AppSSO = {