feat: Add the setting for app's visibility permission

pull/19011/head^2
安泽芃 1 year ago
parent 5471f125ee
commit ba965ecbda

@ -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()

@ -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),
}

@ -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"))

@ -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

@ -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

@ -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 && (

@ -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<void>
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<string[]>(appSelectedMemberIDs || [])
const [isPermissionSelectorOpen, setIsPermissionSelectorOpen] = useState(false)
const [keywords, setKeywords] = useState('')
const [searchKeywords, setSearchKeywords] = useState('')
const [memberList, setMemberList] = useState<Member[]>([])
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 = ({
<p className='body-xs-regular text-text-tertiary'>{t('app.answerIcon.descriptionInExplore')}</p>
</div>
)}
{/* permissions */}
{isEditModal && (
<div className='pt-2'>
<div className='py-2 text-sm font-medium leading-[20px] text-text-primary'>{t('app.permissions')}</div>
<PortalToFollowElem
open={isPermissionSelectorOpen}
onOpenChange={setIsPermissionSelectorOpen}
placement='bottom-start'
offset={4}
>
<PortalToFollowElemTrigger
onClick={() => setIsPermissionSelectorOpen(v => !v)}
className='block'
>
<div className={cn('flex cursor-pointer items-center rounded-lg bg-components-input-bg-normal px-3 py-[6px] hover:bg-state-base-hover-alt',
isPermissionSelectorOpen && 'bg-state-base-hover-alt',
)}>
{permission === 'only_me' && (
<>
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} className='mr-2 shrink-0' size={24} />
<div className='mr-2 grow text-sm leading-5 text-components-input-text-filled'>{t('app.permissionsOnlyMe')}</div>
</>
)}
{permission === 'all_team_members' && (
<>
<div className='mr-2 flex h-6 w-6 items-center justify-center rounded-lg bg-[#EEF4FF]'>
<Users01 className='h-3.5 w-3.5 text-[#444CE7]' />
</div>
<div className='mr-2 grow text-sm leading-5 text-components-input-text-filled'>{t('app.permissionsAllMember')}</div>
</>
)}
{permission === 'partial_members' && (
<>
<div className='mr-2 flex h-6 w-6 items-center justify-center rounded-lg bg-[#EEF4FF]'>
<Users01 className='h-3.5 w-3.5 text-[#444CE7]' />
</div>
<div title={selectedMembersDisplay} className='mr-2 grow truncate text-sm leading-5 text-components-input-text-filled'>{selectedMembersDisplay}</div>
</>
)}
<RiArrowDownSLine className={cn('h-4 w-4 shrink-0 text-text-secondary')} />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[1002]'>
<div className='w-[480px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm'>
<div className='p-1'>
<div className='cursor-pointer rounded-lg py-1 pl-3 pr-2 hover:bg-state-base-hover' onClick={() => { setPermission('only_me'); setIsPermissionSelectorOpen(false); }}>
<div className='flex items-center gap-2'>
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} className='mr-2 shrink-0' size={24} />
<div className='mr-2 grow text-sm leading-5 text-text-primary'>{t('app.permissionsOnlyMe')}</div>
{permission === 'only_me' && <Check className='h-4 w-4 text-primary-600' />}
</div>
</div>
<div className='cursor-pointer rounded-lg py-1 pl-3 pr-2 hover:bg-state-base-hover' onClick={() => { setPermission('all_team_members'); setIsPermissionSelectorOpen(false); }}>
<div className='flex items-center gap-2'>
<div className='mr-2 flex h-6 w-6 items-center justify-center rounded-lg bg-[#EEF4FF]'>
<Users01 className='h-3.5 w-3.5 text-[#444CE7]' />
</div>
<div className='mr-2 grow text-sm leading-5 text-text-primary'>{t('app.permissionsAllMember')}</div>
{permission === 'all_team_members' && <Check className='h-4 w-4 text-primary-600' />}
</div>
</div>
<div className='cursor-pointer rounded-lg py-1 pl-3 pr-2 hover:bg-state-base-hover' onClick={() => { setPermission('partial_members'); setSelectedMemberIDs([userProfile.id]); }}>
<div className='flex items-center gap-2'>
<div className={cn('mr-2 flex h-6 w-6 items-center justify-center rounded-lg bg-[#FFF6ED]', permission === 'partial_members' && '!bg-[#EEF4FF]')}>
<UsersPlus className={cn('h-3.5 w-3.5 text-[#FB6514]', permission === 'partial_members' && '!text-[#444CE7]')} />
</div>
<div className='mr-2 grow text-sm leading-5 text-text-primary'>{t('app.permissionsInvitedMembers')}</div>
{permission === 'partial_members' && <Check className='h-4 w-4 text-primary-600' />}
</div>
</div>
</div>
{permission === 'partial_members' && (
<div className='max-h-[360px] overflow-y-auto border-t-[1px] border-divider-regular pb-1 pl-1 pr-1'>
<div className='sticky left-0 top-0 z-10 bg-white p-2 pb-1'>
<Input
showLeftIcon
showClearIcon
value={keywords}
onChange={e => handleKeywordsChange(e.target.value)}
onClear={() => handleKeywordsChange('')}
placeholder={t('common.operation.search') || ''}
/>
</div>
{showMe && (
<div className='flex items-center gap-2 rounded-lg py-1 pl-3 pr-[10px]'>
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} className='shrink-0' size={24} />
<div className='grow'>
<div className='truncate text-[13px] font-medium leading-[18px] text-text-secondary'>
{userProfile.name}
<span className='text-xs font-normal text-text-tertiary'>{` (${t('datasetSettings.form.me')})`}</span>
</div>
<div className='truncate text-xs leading-[18px] text-text-tertiary'>{userProfile.email}</div>
</div>
<Check className='h-4 w-4 shrink-0 text-text-accent opacity-30' />
</div>
)}
{filteredMemberList.map(member => (
<div key={member.id} className='flex cursor-pointer items-center gap-2 rounded-lg py-1 pl-3 pr-[10px] hover:bg-state-base-hover' onClick={() => selectMember(member)}>
<Avatar avatar={member.avatar_url} name={member.name} className='shrink-0' size={24} />
<div className='grow'>
<div className='truncate text-[13px] font-medium leading-[18px] text-text-secondary'>{member.name}</div>
<div className='truncate text-xs leading-[18px] text-text-tertiary'>{member.email}</div>
</div>
{selectedMemberIDs.includes(member.id) && <Check className='h-4 w-4 shrink-0 text-text-accent' />}
</div>
))}
</div>
)}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</div>
)}
{!isEditModal && isAppsFull && <AppsFull className='mt-4' loc='app-explore-create' />}
</div>
<div className='flex flex-row-reverse'>

@ -117,6 +117,10 @@ const translation = {
description: '是否使用 WebApp 图标替换分享的应用界面中的 🤖',
descriptionInExplore: '是否使用 WebApp 图标替换 Explore 界面中的 🤖',
},
permissions: '可见权限',
permissionsOnlyMe: '只有我',
permissionsAllMember: '所有团队成员',
permissionsInvitedMembers: '部分团队成员',
switch: '迁移为工作流编排',
switchTipStart: '将为您创建一个使用工作流编排的新应用。新应用将',
switchTip: '不能够',

@ -28,8 +28,8 @@ export const createApp: Fetcher<AppDetailResponse, { name: string; icon_type?: A
return post<AppDetailResponse>('apps', { body: { name, icon_type, icon, icon_background, mode, description, model_config: config } })
}
export const updateAppInfo: Fetcher<AppDetailResponse, { appID: string; name: string; icon_type: AppIconType; icon: string; icon_background?: string; description: string; use_icon_as_answer_icon?: boolean }> = ({ appID, name, icon_type, icon, icon_background, description, use_icon_as_answer_icon }) => {
return put<AppDetailResponse>(`apps/${appID}`, { body: { name, icon_type, icon, icon_background, description, use_icon_as_answer_icon } })
export const updateAppInfo: Fetcher<AppDetailResponse, { appID: string; name: string; icon_type: AppIconType; icon: string; icon_background?: string; description: string; use_icon_as_answer_icon?: boolean; permission?: string; partial_member_list?: string[] }> = ({ appID, name, icon_type, icon, icon_background, description, use_icon_as_answer_icon, permission, partial_member_list }) => {
return put<AppDetailResponse>(`apps/${appID}`, { body: { name, icon_type, icon, icon_background, description, use_icon_as_answer_icon, permission, partial_member_list } })
}
export const copyApp: Fetcher<AppDetailResponse, { appID: string; name: string; icon_type: AppIconType; icon: string; icon_background?: string | null; mode: AppMode; description?: string }> = ({ appID, name, icon_type, icon, icon_background, mode, description }) => {

@ -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 = {

Loading…
Cancel
Save