Browse Source

fix: frontend permission check (#784)

Matri 1 year ago
parent
commit
2c77a74c40

+ 14 - 3
api/services/workspace_service.py

@@ -1,11 +1,14 @@
+from flask_login import current_user
 from extensions.ext_database import db
-from models.account import Tenant
+from models.account import Tenant, TenantAccountJoin
 from models.provider import Provider
 
 
 class WorkspaceService:
     @classmethod
     def get_tenant_info(cls, tenant: Tenant):
+        if not tenant:
+            return None
         tenant_info = {
             'id': tenant.id,
             'name': tenant.name,
@@ -13,10 +16,18 @@ class WorkspaceService:
             'status': tenant.status,
             'created_at': tenant.created_at,
             'providers': [],
-            'in_trial': True,
-            'trial_end_reason': None
+            'in_trail': True,
+            'trial_end_reason': None,
+            'role': 'normal',
         }
 
+        # Get role of user
+        tenant_account_join = db.session.query(TenantAccountJoin).filter(
+            TenantAccountJoin.tenant_id == tenant.id,
+            TenantAccountJoin.account_id == current_user.id
+        ).first()
+        tenant_info['role'] = tenant_account_join.role
+
         # Get providers
         providers = db.session.query(Provider).filter(
             Provider.tenant_id == tenant.id

+ 14 - 7
web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout.tsx

@@ -1,6 +1,6 @@
 'use client'
 import type { FC } from 'react'
-import React, { useEffect } from 'react'
+import React, { useEffect, useMemo } from 'react'
 import cn from 'classnames'
 import useSWR from 'swr'
 import { useTranslation } from 'react-i18next'
@@ -19,6 +19,7 @@ import {
 import s from './style.module.css'
 import AppSideBar from '@/app/components/app-sidebar'
 import { fetchAppDetail } from '@/service/apps'
+import { useAppContext } from '@/context/app-context'
 
 export type IAppDetailLayoutProps = {
   children: React.ReactNode
@@ -31,15 +32,21 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
     params: { appId }, // get appId in path
   } = props
   const { t } = useTranslation()
+  const { isCurrentWorkspaceManager } = useAppContext()
   const detailParams = { url: '/apps', id: appId }
   const { data: response } = useSWR(detailParams, fetchAppDetail)
 
-  const navigation = [
-    { name: t('common.appMenus.overview'), href: `/app/${appId}/overview`, icon: ChartBarSquareIcon, selectedIcon: ChartBarSquareSolidIcon },
-    { name: t('common.appMenus.promptEng'), href: `/app/${appId}/configuration`, icon: Cog8ToothIcon, selectedIcon: Cog8ToothSolidIcon },
-    { name: t('common.appMenus.apiAccess'), href: `/app/${appId}/develop`, icon: CommandLineIcon, selectedIcon: CommandLineSolidIcon },
-    { name: t('common.appMenus.logAndAnn'), href: `/app/${appId}/logs`, icon: DocumentTextIcon, selectedIcon: DocumentTextSolidIcon },
-  ]
+  const navigation = useMemo(() => {
+    const navs = [
+      { name: t('common.appMenus.overview'), href: `/app/${appId}/overview`, icon: ChartBarSquareIcon, selectedIcon: ChartBarSquareSolidIcon },
+      { name: t('common.appMenus.apiAccess'), href: `/app/${appId}/develop`, icon: CommandLineIcon, selectedIcon: CommandLineSolidIcon },
+      { name: t('common.appMenus.logAndAnn'), href: `/app/${appId}/logs`, icon: DocumentTextIcon, selectedIcon: DocumentTextSolidIcon },
+    ]
+    if (isCurrentWorkspaceManager)
+      navs.push({ name: t('common.appMenus.promptEng'), href: `/app/${appId}/configuration`, icon: Cog8ToothIcon, selectedIcon: Cog8ToothSolidIcon })
+    return navs
+  }, [appId, isCurrentWorkspaceManager, t])
+
   const appModeName = response?.mode?.toUpperCase() === 'COMPLETION' ? t('common.appModes.completionApp') : t('common.appModes.chatApp')
   useEffect(() => {
     if (response?.name)

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

@@ -12,7 +12,7 @@ import Confirm from '@/app/components/base/confirm'
 import { ToastContext } from '@/app/components/base/toast'
 import { deleteApp } from '@/service/apps'
 import AppIcon from '@/app/components/base/app-icon'
-import AppsContext from '@/context/app-context'
+import AppsContext, { useAppContext } from '@/context/app-context'
 
 export type AppCardProps = {
   app: App
@@ -25,6 +25,7 @@ const AppCard = ({
 }: AppCardProps) => {
   const { t } = useTranslation()
   const { notify } = useContext(ToastContext)
+  const { isCurrentWorkspaceManager } = useAppContext()
 
   const mutateApps = useContextSelector(AppsContext, state => state.mutateApps)
 
@@ -55,7 +56,8 @@ const AppCard = ({
           <div className={style.listItemHeading}>
             <div className={style.listItemHeadingContent}>{app.name}</div>
           </div>
-          <span className={style.deleteAppIcon} onClick={onDeleteClick} />
+          { isCurrentWorkspaceManager
+          && <span className={style.deleteAppIcon} onClick={onDeleteClick} />}
         </div>
         <div className={style.listItemDescription}>{app.model_config?.pre_prompt}</div>
         <div className={style.listItemFooter}>

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

@@ -8,7 +8,7 @@ import AppCard from './AppCard'
 import NewAppCard from './NewAppCard'
 import type { AppListResponse } from '@/models/app'
 import { fetchAppList } from '@/service/apps'
-import { useSelector } from '@/context/app-context'
+import { useAppContext, useSelector } from '@/context/app-context'
 import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
 
 const getKey = (pageIndex: number, previousPageData: AppListResponse) => {
@@ -19,6 +19,7 @@ const getKey = (pageIndex: number, previousPageData: AppListResponse) => {
 
 const Apps = () => {
   const { t } = useTranslation()
+  const { isCurrentWorkspaceManager } = useAppContext()
   const { data, isLoading, setSize, mutate } = useSWRInfinite(getKey, fetchAppList, { revalidateFirstPage: false })
   const loadingStateRef = useRef(false)
   const pageContainerRef = useSelector(state => state.pageContainerRef)
@@ -55,7 +56,8 @@ const Apps = () => {
       {data?.map(({ data: apps }) => apps.map(app => (
         <AppCard key={app.id} app={app} onDelete={mutate} />
       )))}
-      <NewAppCard ref={anchorRef} onSuccess={mutate} />
+      { isCurrentWorkspaceManager
+      && <NewAppCard ref={anchorRef} onSuccess={mutate} />}
     </nav>
   )
 }

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

@@ -7,7 +7,7 @@ import NewDatasetCard from './NewDatasetCard'
 import DatasetCard from './DatasetCard'
 import type { DataSetListResponse } from '@/models/datasets'
 import { fetchDatasets } from '@/service/datasets'
-import { useSelector } from '@/context/app-context'
+import { useAppContext, useSelector } from '@/context/app-context'
 
 const getKey = (pageIndex: number, previousPageData: DataSetListResponse) => {
   if (!pageIndex || previousPageData.has_more)
@@ -16,6 +16,7 @@ const getKey = (pageIndex: number, previousPageData: DataSetListResponse) => {
 }
 
 const Datasets = () => {
+  const { isCurrentWorkspaceManager } = useAppContext()
   const { data, isLoading, setSize, mutate } = useSWRInfinite(getKey, fetchDatasets, { revalidateFirstPage: false })
   const loadingStateRef = useRef(false)
   const pageContainerRef = useSelector(state => state.pageContainerRef)
@@ -44,7 +45,7 @@ const Datasets = () => {
       {data?.map(({ data: datasets }) => datasets.map(dataset => (
         <DatasetCard key={dataset.id} dataset={dataset} onDelete={mutate} />),
       ))}
-      <NewDatasetCard ref={anchorRef} />
+      { isCurrentWorkspaceManager && <NewDatasetCard ref={anchorRef} /> }
     </nav>
   )
 }

+ 22 - 12
web/app/components/app/overview/appCard.tsx

@@ -1,6 +1,6 @@
 'use client'
 import type { FC } from 'react'
-import React, { useState } from 'react'
+import React, { useMemo, useState } from 'react'
 import {
   Cog8ToothIcon,
   DocumentTextIcon,
@@ -22,6 +22,7 @@ import Switch from '@/app/components/base/switch'
 import type { AppDetailResponse } from '@/models/app'
 import './style.css'
 import { AppType } from '@/types/app'
+import { useAppContext } from '@/context/app-context'
 
 export type IAppCardProps = {
   className?: string
@@ -48,22 +49,30 @@ function AppCard({
 }: IAppCardProps) {
   const router = useRouter()
   const pathname = usePathname()
+  const { currentWorkspace, isCurrentWorkspaceManager } = useAppContext()
   const [showSettingsModal, setShowSettingsModal] = useState(false)
   const [showShareModal, setShowShareModal] = useState(false)
   const [showEmbedded, setShowEmbedded] = useState(false)
   const [showCustomizeModal, setShowCustomizeModal] = useState(false)
   const { t } = useTranslation()
 
-  const OPERATIONS_MAP = {
-    webapp: [
-      { opName: t('appOverview.overview.appInfo.preview'), opIcon: RocketLaunchIcon },
-      { opName: t('appOverview.overview.appInfo.share.entry'), opIcon: ShareIcon },
-      appInfo.mode === AppType.chat ? { opName: t('appOverview.overview.appInfo.embedded.entry'), opIcon: EmbedIcon } : false,
-      { opName: t('appOverview.overview.appInfo.settings.entry'), opIcon: Cog8ToothIcon },
-    ].filter(item => !!item),
-    api: [{ opName: t('appOverview.overview.apiInfo.doc'), opIcon: DocumentTextIcon }],
-    app: [],
-  }
+  const OPERATIONS_MAP = useMemo(() => {
+    const operationsMap = {
+      webapp: [
+        { opName: t('appOverview.overview.appInfo.preview'), opIcon: RocketLaunchIcon },
+        { opName: t('appOverview.overview.appInfo.share.entry'), opIcon: ShareIcon },
+      ] as { opName: string; opIcon: any }[],
+      api: [{ opName: t('appOverview.overview.apiInfo.doc'), opIcon: DocumentTextIcon }],
+      app: [],
+    }
+    if (appInfo.mode === AppType.chat)
+      operationsMap.webapp.push({ opName: t('appOverview.overview.appInfo.embedded.entry'), opIcon: EmbedIcon })
+
+    if (isCurrentWorkspaceManager)
+      operationsMap.webapp.push({ opName: t('appOverview.overview.appInfo.settings.entry'), opIcon: Cog8ToothIcon })
+
+    return operationsMap
+  }, [isCurrentWorkspaceManager, appInfo, t])
 
   const isApp = cardType === 'app' || cardType === 'webapp'
   const basicName = isApp ? appInfo?.site?.title : t('appOverview.overview.apiInfo.title')
@@ -129,7 +138,7 @@ function AppCard({
             <Tag className="mr-2" color={runningStatus ? 'green' : 'yellow'}>
               {runningStatus ? t('appOverview.overview.status.running') : t('appOverview.overview.status.disable')}
             </Tag>
-            <Switch defaultValue={runningStatus} onChange={onChangeStatus} />
+            <Switch defaultValue={runningStatus} onChange={onChangeStatus} disabled={currentWorkspace?.role === 'normal'} />
           </div>
         </div>
         <div className="flex flex-col justify-center py-2">
@@ -200,6 +209,7 @@ function AppCard({
               onClose={() => setShowShareModal(false)}
               linkUrl={appUrl}
               onGenerateCode={onGenerateCode}
+              regeneratable={isCurrentWorkspaceManager}
             />
             <SettingsModal
               appInfo={appInfo}

+ 4 - 2
web/app/components/app/overview/share-link.tsx

@@ -17,6 +17,7 @@ type IShareLinkProps = {
   onClose: () => void
   onGenerateCode: () => Promise<void>
   linkUrl: string
+  regeneratable?: boolean
 }
 
 const prefixShare = 'appOverview.overview.appInfo.share'
@@ -26,6 +27,7 @@ const ShareLinkModal: FC<IShareLinkProps> = ({
   isShow,
   onClose,
   onGenerateCode,
+  regeneratable,
 }) => {
   const [genLoading, setGenLoading] = useState(false)
   const [isCopied, setIsCopied] = useState(false)
@@ -51,7 +53,7 @@ const ShareLinkModal: FC<IShareLinkProps> = ({
         <LinkIcon className='w-4 h-4 mr-2' />
         { t(`${prefixShare}.${isCopied ? 'linkCopied' : 'copyLink'}`) }
       </Button>
-      <Button className='w-32 !px-0' onClick={async () => {
+      {regeneratable && <Button className='w-32 !px-0' onClick={async () => {
         setGenLoading(true)
         await onGenerateCode()
         setGenLoading(false)
@@ -59,7 +61,7 @@ const ShareLinkModal: FC<IShareLinkProps> = ({
       }}>
         <ArrowPathIcon className={`w-4 h-4 mr-2 ${genLoading ? 'generateLogo' : ''}`} />
         {t(`${prefixShare}.regenerate`)}
-      </Button>
+      </Button>}
     </div>
   </Modal>
 }

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

@@ -18,6 +18,7 @@ import Tooltip from '@/app/components/base/tooltip'
 import Loading from '@/app/components/base/loading'
 import Confirm from '@/app/components/base/confirm'
 import I18n from '@/context/i18n'
+import { useAppContext } from '@/context/app-context'
 
 type ISecretKeyModalProps = {
   isShow: boolean
@@ -31,6 +32,7 @@ const SecretKeyModal = ({
   onClose,
 }: ISecretKeyModalProps) => {
   const { t } = useTranslation()
+  const { currentWorkspace, isCurrentWorkspaceManager } = useAppContext()
   const [showConfirmDelete, setShowConfirmDelete] = useState(false)
   const [isVisible, setVisible] = useState(false)
   const [newKey, setNewKey] = useState<CreateApiKeyResponse | undefined>(undefined)
@@ -118,11 +120,13 @@ const SecretKeyModal = ({
                         setCopyValue(api.token)
                       }}></div>
                     </Tooltip>
-                    <div className={`flex items-center justify-center flex-shrink-0 w-6 h-6 rounded-lg cursor-pointer ${s.trashIcon}`} onClick={() => {
-                      setDelKeyId(api.id)
-                      setShowConfirmDelete(true)
-                    }}>
-                    </div>
+                    { isCurrentWorkspaceManager
+                      && <div className={`flex items-center justify-center flex-shrink-0 w-6 h-6 rounded-lg cursor-pointer ${s.trashIcon}`} onClick={() => {
+                        setDelKeyId(api.id)
+                        setShowConfirmDelete(true)
+                      }}>
+                      </div>
+                    }
                   </div>
                 </div>
               ))}
@@ -131,9 +135,7 @@ const SecretKeyModal = ({
         )
       }
       <div className='flex'>
-        <Button type='default' className={`flex flex-shrink-0 mt-4 ${s.autoWidth}`} onClick={() =>
-          onCreate()
-        }>
+        <Button type='default' className={`flex flex-shrink-0 mt-4 ${s.autoWidth}`} onClick={onCreate} disabled={ !currentWorkspace || currentWorkspace.role === 'normal'}>
           <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>

+ 15 - 5
web/app/components/header/account-setting/data-source-page/data-source-notion/index.tsx

@@ -8,6 +8,7 @@ import s from './style.module.css'
 import NotionIcon from '@/app/components/base/notion-icon'
 import { apiPrefix } from '@/config'
 import type { DataSourceNotion as TDataSourceNotion } from '@/models/common'
+import { useAppContext } from '@/context/app-context'
 
 type DataSourceNotionProps = {
   workspaces: TDataSourceNotion[]
@@ -16,6 +17,8 @@ const DataSourceNotion = ({
   workspaces,
 }: DataSourceNotionProps) => {
   const { t } = useTranslation()
+  const { isCurrentWorkspaceManager } = useAppContext()
+
   const connected = !!workspaces.length
 
   return (
@@ -35,18 +38,25 @@ const DataSourceNotion = ({
           }
         </div>
         {
-          !connected
+          connected
             ? (
               <Link
-                className='flex items-center ml-3 px-3 h-7 bg-white border border-gray-200 rounded-md text-xs font-medium text-gray-700 cursor-pointer'
-                href={`${apiPrefix}/oauth/data-source/notion`}>
+                className={
+                  `flex items-center ml-3 px-3 h-7 bg-white border border-gray-200
+                  rounded-md text-xs font-medium text-gray-700
+                  ${isCurrentWorkspaceManager ? 'cursor-pointer' : 'grayscale opacity-50 cursor-default'}`
+                }
+                href={isCurrentWorkspaceManager ? `${apiPrefix}/oauth/data-source/notion` : '/'}>
                 {t('common.dataSource.connect')}
               </Link>
             )
             : (
               <Link
-                href={`${apiPrefix}/oauth/data-source/notion`}
-                className='flex items-center px-3 h-7 bg-white border-[0.5px] border-gray-200 text-xs font-medium text-primary-600 rounded-md cursor-pointer'>
+                href={isCurrentWorkspaceManager ? `${apiPrefix}/oauth/data-source/notion` : '/' }
+                className={
+                  `flex items-center px-3 h-7 bg-white border-[0.5px] border-gray-200 text-xs font-medium text-primary-600 rounded-md
+                  ${isCurrentWorkspaceManager ? 'cursor-pointer' : 'grayscale opacity-50 cursor-default'}`
+                }>
                 <PlusIcon className='w-[14px] h-[14px] mr-[5px]' />
                 {t('common.dataSource.notion.addWorkspace')}
               </Link>

+ 10 - 8
web/app/components/header/account-setting/key-validator/Operate.tsx

@@ -5,6 +5,7 @@ import type { Status } from './declarations'
 type OperateProps = {
   isOpen: boolean
   status: Status
+  disabled?: boolean
   onCancel: () => void
   onSave: () => void
   onAdd: () => void
@@ -14,6 +15,7 @@ type OperateProps = {
 const Operate = ({
   isOpen,
   status,
+  disabled,
   onCancel,
   onSave,
   onAdd,
@@ -44,10 +46,10 @@ const Operate = ({
 
   if (status === 'add') {
     return (
-      <div className='
-        px-3 h-[28px] bg-white border border-gray-200 rounded-md cursor-pointer
-        text-xs font-medium text-gray-700 flex items-center
-      ' onClick={onAdd}>
+      <div className={
+        `px-3 h-[28px] bg-white border border-gray-200 rounded-md cursor-pointer
+        text-xs font-medium text-gray-700 flex items-center ${disabled && 'opacity-50 cursor-default'}}`
+      } onClick={() => !disabled && onAdd()}>
         {t('common.provider.addKey')}
       </div>
     )
@@ -69,10 +71,10 @@ const Operate = ({
             <Indicator color='green' className='mr-4' />
           )
         }
-        <div className='
-          px-3 h-[28px] bg-white border border-gray-200 rounded-md cursor-pointer
-          text-xs font-medium text-gray-700 flex items-center
-        ' onClick={onEdit}>
+        <div className={
+          `px-3 h-[28px] bg-white border border-gray-200 rounded-md cursor-pointer
+          text-xs font-medium text-gray-700 flex items-center ${disabled && 'opacity-50 cursor-default'}}`
+        } onClick={() => !disabled && onEdit()}>
           {t('common.provider.editKey')}
         </div>
       </div>

+ 4 - 1
web/app/components/header/account-setting/key-validator/index.tsx

@@ -13,6 +13,7 @@ export type KeyValidatorProps = {
   forms: Form[]
   keyFrom: KeyFrom
   onSave: (v: ValidateValue) => Promise<boolean | undefined>
+  disabled?: boolean
 }
 
 const KeyValidator = ({
@@ -22,6 +23,7 @@ const KeyValidator = ({
   forms,
   keyFrom,
   onSave,
+  disabled,
 }: KeyValidatorProps) => {
   const triggerKey = `plugins/${type}`
   const { eventEmitter } = useEventEmitterContextContext()
@@ -85,10 +87,11 @@ const KeyValidator = ({
           onSave={handleSave}
           onAdd={handleAdd}
           onEdit={handleEdit}
+          disabled={disabled}
         />
       </div>
       {
-        isOpen && (
+        isOpen && !disabled && (
           <div className='px-4 py-3'>
             {
               forms.map(form => (

+ 7 - 9
web/app/components/header/account-setting/members-page/index.tsx

@@ -16,9 +16,9 @@ import { fetchMembers } from '@/service/common'
 import I18n from '@/context/i18n'
 import { useAppContext } from '@/context/app-context'
 import Avatar from '@/app/components/base/avatar'
-import { useWorkspacesContext } from '@/context/workspace-context'
 
 dayjs.extend(relativeTime)
+
 const MembersPage = () => {
   const { t } = useTranslation()
   const RoleMap = {
@@ -27,15 +27,13 @@ const MembersPage = () => {
     normal: t('common.members.normal'),
   }
   const { locale } = useContext(I18n)
-  const { userProfile } = useAppContext()
+  const { userProfile, currentWorkspace, isCurrentWorkspaceManager } = useAppContext()
   const { data, mutate } = useSWR({ url: '/workspaces/current/members' }, fetchMembers)
   const [inviteModalVisible, setInviteModalVisible] = useState(false)
   const [invitationLink, setInvitationLink] = useState('')
   const [invitedModalVisible, setInvitedModalVisible] = useState(false)
   const accounts = data?.accounts || []
   const owner = accounts.filter(account => account.role === 'owner')?.[0]?.email === userProfile.email
-  const { workspaces } = useWorkspacesContext()
-  const currentWrokspace = workspaces.filter(item => item.current)?.[0]
 
   return (
     <>
@@ -43,14 +41,14 @@ const MembersPage = () => {
         <div className='flex items-center mb-4 p-3 bg-gray-50 rounded-2xl'>
           <div className={cn(s['logo-icon'], 'shrink-0')}></div>
           <div className='grow mx-2'>
-            <div className='text-sm font-medium text-gray-900'>{currentWrokspace?.name}</div>
+            <div className='text-sm font-medium text-gray-900'>{currentWorkspace?.name}</div>
             <div className='text-xs text-gray-500'>{t('common.userProfile.workspace')}</div>
           </div>
-          <div className='
-            shrink-0 flex items-center py-[7px] px-3 border-[0.5px] border-gray-200
+          <div className={
+            `shrink-0 flex items-center py-[7px] px-3 border-[0.5px] border-gray-200
             text-[13px] font-medium text-primary-600 bg-white
-            shadow-xs rounded-lg cursor-pointer
-          ' onClick={() => setInviteModalVisible(true)}>
+            shadow-xs rounded-lg ${isCurrentWorkspaceManager ? 'cursor-pointer' : 'grayscale opacity-50 cursor-default'}`
+          } onClick={() => isCurrentWorkspaceManager && setInviteModalVisible(true)}>
             <UserPlusIcon className='w-4 h-4 mr-2 ' />
             {t('common.members.invite')}
           </div>

+ 3 - 0
web/app/components/header/account-setting/plugin-page/SerpapiPlugin.tsx

@@ -6,6 +6,7 @@ import type { Form, ValidateValue } from '../key-validator/declarations'
 import { updatePluginKey, validatePluginKey } from './utils'
 import { useToastContext } from '@/app/components/base/toast'
 import type { PluginProvider } from '@/models/common'
+import { useAppContext } from '@/context/app-context'
 
 type SerpapiPluginProps = {
   plugin: PluginProvider
@@ -16,6 +17,7 @@ const SerpapiPlugin = ({
   onUpdate,
 }: SerpapiPluginProps) => {
   const { t } = useTranslation()
+  const { isCurrentWorkspaceManager } = useAppContext()
   const { notify } = useToastContext()
 
   const forms: Form[] = [{
@@ -70,6 +72,7 @@ const SerpapiPlugin = ({
         link: 'https://serpapi.com/manage-api-key',
       }}
       onSave={handleSave}
+      disabled={!isCurrentWorkspaceManager}
     />
   )
 }

+ 4 - 1
web/app/components/header/app-selector/index.tsx

@@ -8,6 +8,7 @@ import Indicator from '../indicator'
 import type { AppDetailResponse } from '@/models/app'
 import NewAppDialog from '@/app/(commonLayout)/apps/NewAppDialog'
 import AppIcon from '@/app/components/base/app-icon'
+import { useAppContext } from '@/context/app-context'
 
 type IAppSelectorProps = {
   appItems: AppDetailResponse[]
@@ -16,6 +17,7 @@ type IAppSelectorProps = {
 
 export default function AppSelector({ appItems, curApp }: IAppSelectorProps) {
   const router = useRouter()
+  const { isCurrentWorkspaceManager } = useAppContext()
   const [showNewAppDialog, setShowNewAppDialog] = useState(false)
   const { t } = useTranslation()
 
@@ -77,7 +79,7 @@ export default function AppSelector({ appItems, curApp }: IAppSelectorProps) {
                 ))
               }
             </div>)}
-            <Menu.Item>
+            {isCurrentWorkspaceManager && <Menu.Item>
               <div className='p-1' onClick={() => setShowNewAppDialog(true)}>
                 <div
                   className='flex items-center h-12 rounded-lg cursor-pointer hover:bg-gray-100'
@@ -95,6 +97,7 @@ export default function AppSelector({ appItems, curApp }: IAppSelectorProps) {
                 </div>
               </div>
             </Menu.Item>
+            }
           </Menu.Items>
         </Transition>
       </Menu>

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

@@ -1,3 +1,5 @@
+'use client'
+
 import Link from 'next/link'
 import AccountDropdown from './account-dropdown'
 import AppNav from './app-nav'
@@ -8,6 +10,7 @@ import GithubStar from './github-star'
 import PluginNav from './plugin-nav'
 import s from './index.module.css'
 import { WorkspaceProvider } from '@/context/workspace-context'
+import { useAppContext } from '@/context/app-context'
 
 const navClassName = `
   flex items-center relative mr-3 px-3 h-8 rounded-xl
@@ -16,6 +19,7 @@ const navClassName = `
 `
 
 const Header = () => {
+  const { isCurrentWorkspaceManager } = useAppContext()
   return (
     <>
       <div className='flex items-center'>
@@ -29,7 +33,7 @@ const Header = () => {
         <ExploreNav className={navClassName} />
         <AppNav />
         <PluginNav className={navClassName} />
-        <DatasetNav />
+        {isCurrentWorkspaceManager && <DatasetNav />}
       </div>
       <div className='flex items-center flex-shrink-0'>
         <EnvNav />

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

@@ -6,6 +6,7 @@ import { useRouter } from 'next/navigation'
 import { debounce } from 'lodash-es'
 import Indicator from '../../indicator'
 import AppIcon from '@/app/components/base/app-icon'
+import { useAppContext } from '@/context/app-context'
 
 type NavItem = {
   id: string
@@ -29,6 +30,7 @@ const itemClassName = `
 
 const NavSelector = ({ curNav, navs, createText, onCreate, onLoadmore }: INavSelectorProps) => {
   const router = useRouter()
+  const { isCurrentWorkspaceManager } = useAppContext()
 
   const handleScroll = useCallback(debounce((e) => {
     if (typeof onLoadmore === 'function') {
@@ -81,7 +83,7 @@ const NavSelector = ({ curNav, navs, createText, onCreate, onLoadmore }: INavSel
               ))
             }
           </div>
-          <Menu.Item>
+          {isCurrentWorkspaceManager && <Menu.Item>
             <div className='p-1' onClick={onCreate}>
               <div
                 className='flex items-center h-12 rounded-lg cursor-pointer hover:bg-gray-100'
@@ -98,7 +100,7 @@ const NavSelector = ({ curNav, navs, createText, onCreate, onLoadmore }: INavSel
                 <div className='font-normal text-[14px] text-gray-700'>{createText}</div>
               </div>
             </div>
-          </Menu.Item>
+          </Menu.Item>}
         </Menu.Items>
       </Menu>
     </div>

+ 47 - 9
web/context/app-context.tsx

@@ -1,20 +1,23 @@
 'use client'
 
-import { createRef, useEffect, useRef, useState } from 'react'
+import { createRef, useCallback, useEffect, useMemo, useRef, useState } from 'react'
 import useSWR from 'swr'
 import { createContext, useContext, useContextSelector } from 'use-context-selector'
 import type { FC, ReactNode } from 'react'
 import { fetchAppList } from '@/service/apps'
 import Loading from '@/app/components/base/loading'
-import { fetchLanggeniusVersion, fetchUserProfile } from '@/service/common'
+import { fetchCurrentWorkspace, fetchLanggeniusVersion, fetchUserProfile } from '@/service/common'
 import type { App } from '@/types/app'
-import type { LangGeniusVersionResponse, UserProfileResponse } from '@/models/common'
+import type { ICurrentWorkspace, LangGeniusVersionResponse, UserProfileResponse } from '@/models/common'
 
 export type AppContextValue = {
   apps: App[]
-  mutateApps: () => void
+  mutateApps: VoidFunction
   userProfile: UserProfileResponse
-  mutateUserProfile: () => void
+  mutateUserProfile: VoidFunction
+  currentWorkspace: ICurrentWorkspace
+  isCurrentWorkspaceManager: boolean
+  mutateCurrentWorkspace: VoidFunction
   pageContainerRef: React.RefObject<HTMLDivElement>
   langeniusVersionInfo: LangGeniusVersionResponse
   useSelector: typeof useSelector
@@ -30,6 +33,17 @@ const initialLangeniusVersionInfo = {
   can_auto_update: false,
 }
 
+const initialWorkspaceInfo: ICurrentWorkspace = {
+  id: '',
+  name: '',
+  plan: '',
+  status: '',
+  created_at: 0,
+  role: 'normal',
+  providers: [],
+  in_trail: true,
+}
+
 const AppContext = createContext<AppContextValue>({
   apps: [],
   mutateApps: () => { },
@@ -40,7 +54,10 @@ const AppContext = createContext<AppContextValue>({
     avatar: '',
     is_password_set: false,
   },
+  currentWorkspace: initialWorkspaceInfo,
+  isCurrentWorkspaceManager: false,
   mutateUserProfile: () => { },
+  mutateCurrentWorkspace: () => { },
   pageContainerRef: createRef(),
   langeniusVersionInfo: initialLangeniusVersionInfo,
   useSelector,
@@ -59,10 +76,14 @@ export const AppContextProvider: FC<AppContextProviderProps> = ({ children }) =>
 
   const { data: appList, mutate: mutateApps } = useSWR({ url: '/apps', params: { page: 1 } }, fetchAppList)
   const { data: userProfileResponse, mutate: mutateUserProfile } = useSWR({ url: '/account/profile', params: {} }, fetchUserProfile)
+  const { data: currentWorkspaceResponse, mutate: mutateCurrentWorkspace } = useSWR({ url: '/workspaces/current', params: {} }, fetchCurrentWorkspace)
 
   const [userProfile, setUserProfile] = useState<UserProfileResponse>()
   const [langeniusVersionInfo, setLangeniusVersionInfo] = useState<LangGeniusVersionResponse>(initialLangeniusVersionInfo)
-  const updateUserProfileAndVersion = async () => {
+  const [currentWorkspace, setCurrentWorkspace] = useState<ICurrentWorkspace>(initialWorkspaceInfo)
+  const isCurrentWorkspaceManager = useMemo(() => ['owner', 'admin'].includes(currentWorkspace.role), [currentWorkspace.role])
+
+  const updateUserProfileAndVersion = useCallback(async () => {
     if (userProfileResponse && !userProfileResponse.bodyUsed) {
       const result = await userProfileResponse.json()
       setUserProfile(result)
@@ -71,16 +92,33 @@ export const AppContextProvider: FC<AppContextProviderProps> = ({ children }) =>
       const versionData = await fetchLanggeniusVersion({ url: '/version', params: { current_version } })
       setLangeniusVersionInfo({ ...versionData, current_version, latest_version: versionData.version, current_env })
     }
-  }
+  }, [userProfileResponse])
+
   useEffect(() => {
     updateUserProfileAndVersion()
-  }, [userProfileResponse])
+  }, [updateUserProfileAndVersion, userProfileResponse])
+
+  useEffect(() => {
+    if (currentWorkspaceResponse)
+      setCurrentWorkspace(currentWorkspaceResponse)
+  }, [currentWorkspaceResponse])
 
   if (!appList || !userProfile)
     return <Loading type='app' />
 
   return (
-    <AppContext.Provider value={{ apps: appList.data, mutateApps, userProfile, mutateUserProfile, pageContainerRef, langeniusVersionInfo, useSelector }}>
+    <AppContext.Provider value={{
+      apps: appList.data,
+      mutateApps,
+      userProfile,
+      mutateUserProfile,
+      pageContainerRef,
+      langeniusVersionInfo,
+      useSelector,
+      currentWorkspace,
+      isCurrentWorkspaceManager,
+      mutateCurrentWorkspace,
+    }}>
       <div ref={pageContainerRef} className='relative flex flex-col h-full overflow-auto bg-gray-100'>
         {children}
       </div>

+ 7 - 0
web/models/common.ts

@@ -118,6 +118,13 @@ export type IWorkspace = {
   current: boolean
 }
 
+export type ICurrentWorkspace = Omit<IWorkspace, 'current'> & {
+  role: 'normal' | 'admin' | 'owner'
+  providers: Provider[]
+  in_trail: boolean
+  trial_end_reason?: string
+}
+
 export type DataSourceNotionPage = {
   page_icon: null | {
     type: string | null

+ 5 - 0
web/service/common.ts

@@ -2,6 +2,7 @@ import type { Fetcher } from 'swr'
 import { del, get, patch, post, put } from './base'
 import type {
   AccountIntegrate, CommonResponse, DataSourceNotion,
+  ICurrentWorkspace,
   IWorkspace, LangGeniusVersionResponse, Member,
   OauthResponse, PluginProvider, Provider, ProviderAnthropicToken, ProviderAzureToken,
   SetupStatusResponse, TenantInfoResponse, UserProfileOriginResponse,
@@ -87,6 +88,10 @@ export const fetchFilePreview: Fetcher<{ content: string }, { fileID: string }>
   return get(`/files/${fileID}/preview`) as Promise<{ content: string }>
 }
 
+export const fetchCurrentWorkspace: Fetcher<ICurrentWorkspace, { url: string; params: Record<string, any> }> = ({ url, params }) => {
+  return get(url, { params }) as Promise<ICurrentWorkspace>
+}
+
 export const fetchWorkspaces: Fetcher<{ workspaces: IWorkspace[] }, { url: string; params: Record<string, any> }> = ({ url, params }) => {
   return get(url, { params }) as Promise<{ workspaces: IWorkspace[] }>
 }