feat: enhance permission handling and UI updates across various components

This commit is contained in:
twwu
2026-05-18 13:34:51 +08:00
parent 363fb7e368
commit a5c59a02ae
15 changed files with 253 additions and 169 deletions

View File

@ -31,6 +31,7 @@ import useDocumentTitle from '@/hooks/use-document-title'
import { usePathname, useRouter } from '@/next/navigation'
import { fetchAppDetailDirect } from '@/service/apps'
import { AppModeEnum } from '@/types/app'
import { hasPermission } from '@/utils/permission'
import s from './style.module.css'
type IAppDetailLayoutProps = {
@ -48,7 +49,7 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
const pathname = usePathname()
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const { isCurrentWorkspaceEditor, isLoadingCurrentWorkspace, currentWorkspace } = useAppContext()
const { isCurrentWorkspaceEditor, isLoadingCurrentWorkspace, currentWorkspace, workspacePermissionKeys } = useAppContext()
const appInfoActions = useAppInfoActions({ resetKey: appId })
const { appDetail, setAppDetail, setAppSidebarExpand } = useStore(useShallow(state => ({
appDetail: state.appDetail,
@ -64,6 +65,9 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
selectedIcon: NavIcon
}>>([])
const canAccessMonitor = hasPermission(workspacePermissionKeys, 'app.monitor.access')
const canAccessLog = hasPermission(workspacePermissionKeys, 'app.log.access')
const getNavigationConfig = useCallback((appId: string, isCurrentWorkspaceEditor: boolean, mode: AppModeEnum) => {
const navConfig = [
...(isCurrentWorkspaceEditor
@ -81,7 +85,7 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
icon: RiTerminalBoxLine,
selectedIcon: RiTerminalBoxFill,
},
...(isCurrentWorkspaceEditor
...(canAccessLog
? [{
name: mode !== AppModeEnum.WORKFLOW
? t('appMenus.logAndAnn', { ns: 'common' })
@ -92,12 +96,14 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
}]
: []
),
{
name: t('appMenus.overview', { ns: 'common' }),
href: `/app/${appId}/overview`,
icon: RiDashboard2Line,
selectedIcon: RiDashboard2Fill,
},
...(canAccessMonitor
? [{
name: t('appMenus.overview', { ns: 'common' }),
href: `/app/${appId}/overview`,
icon: RiDashboard2Line,
selectedIcon: RiDashboard2Fill,
}]
: []),
...(isCurrentWorkspaceEditor
? [{
name: 'Access Config',

View File

@ -4,21 +4,29 @@ import type { AliyunConfig, ArizeConfig, DatabricksConfig, LangFuseConfig, LangS
import type { TracingStatus } from '@/models/app'
import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import {
RiArrowDownDoubleLine,
RiEqualizer2Line,
} from '@remixicon/react'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import { AliyunIcon, ArizeIcon, DatabricksIcon, LangfuseIcon, LangsmithIcon, MlflowIcon, OpikIcon, PhoenixIcon, TencentIcon, WeaveIcon } from '@/app/components/base/icons/src/public/tracing'
import {
AliyunIcon,
ArizeIcon,
DatabricksIcon,
LangfuseIcon,
LangsmithIcon,
MlflowIcon,
OpikIcon,
PhoenixIcon,
TencentIcon,
WeaveIcon,
} from '@/app/components/base/icons/src/public/tracing'
import Loading from '@/app/components/base/loading'
import Indicator from '@/app/components/header/indicator'
import { useAppContext } from '@/context/app-context'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { usePathname } from '@/next/navigation'
import { fetchTracingConfig as doFetchTracingConfig, fetchTracingStatus, updateTracingStatus } from '@/service/apps'
import { hasPermission } from '@/utils/permission'
import ConfigButton from './config-button'
import TracingIcon from './tracing-icon'
import { TracingProvider } from './type'
@ -30,8 +38,9 @@ const Panel: FC = () => {
const pathname = usePathname()
const matched = /\/app\/([^/]+)/.exec(pathname)
const appId = (matched?.length && matched[1]) ? matched[1] : ''
const { isCurrentWorkspaceEditor } = useAppContext()
const readOnly = !isCurrentWorkspaceEditor
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
const canConfigTracing = hasPermission(workspacePermissionKeys, 'app.monitor.tracking_config')
const readOnly = !canConfigTracing
const [isLoaded, {
setTrue: setLoaded,
@ -253,11 +262,11 @@ const Panel: FC = () => {
<TracingIcon size="md" />
<div className="mx-2 system-sm-semibold text-text-secondary">{t(`${I18N_PREFIX}.title`, { ns: 'app' })}</div>
<div className="rounded-md p-1">
<RiEqualizer2Line className="h-4 w-4 text-text-tertiary" />
<span className="i-ri-equalizer-2-line h-4 w-4 text-text-tertiary" />
</div>
<Divider type="vertical" className="h-3.5" />
<div className="rounded-md p-1">
<RiArrowDownDoubleLine className="h-4 w-4 text-text-tertiary" />
<span className="i-ri-arrow-down-double-line h-4 w-4 text-text-tertiary" />
</div>
</div>
</ConfigButton>
@ -297,7 +306,7 @@ const Panel: FC = () => {
</div>
{InUseProviderIcon && <InUseProviderIcon className="ml-1 h-4" />}
<div className="ml-2 rounded-md p-1">
<RiEqualizer2Line className="h-4 w-4 text-text-tertiary" />
<span className="i-ri-equalizer-2-line h-4 w-4 text-text-tertiary" />
</div>
<Divider type="vertical" className="h-3.5" />
</div>

View File

@ -37,7 +37,7 @@ import AppIcon from '@/app/components/base/app-icon'
import Input from '@/app/components/base/input'
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useAppContext, useSelector } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { AppCardTags } from '@/features/tag-management/components/app-card-tags'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
@ -53,6 +53,7 @@ import { fetchWorkflowDraft } from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
import { downloadBlob } from '@/utils/download'
import { hasPermission } from '@/utils/permission'
import { formatTime } from '@/utils/time'
import { basePath } from '@/utils/var'
@ -107,6 +108,8 @@ const AppCardOperationsMenu: React.FC<AppCardOperationsMenuProps> = ({
const { t } = useTranslation()
const openAsyncWindow = useAsyncWindowOpen()
const { push } = useRouter()
const workspacePermissionKeys = useSelector(state => state.workspacePermissionKeys)
const canManageAccessConfig = hasPermission(workspacePermissionKeys, 'app.access_config')
const handleMenuAction = useCallback((e: React.MouseEvent<HTMLElement>, action: () => void) => {
e.stopPropagation()
@ -177,9 +180,11 @@ const AppCardOperationsMenu: React.FC<AppCardOperationsMenuProps> = ({
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem className="gap-2 px-3" onClick={handleOpenAccessConfig}>
<span className="text-sm leading-5 text-text-secondary">Access Config</span>
</DropdownMenuItem>
{canManageAccessConfig && (
<DropdownMenuItem className="gap-2 px-3" onClick={handleOpenAccessConfig}>
<span className="text-sm leading-5 text-text-secondary">Access Config</span>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
@ -435,9 +440,9 @@ const AppCard = ({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () =>
e.preventDefault()
getRedirection(isCurrentWorkspaceEditor, app, push)
}}
className="group relative col-span-1 inline-flex h-[160px] cursor-pointer flex-col rounded-xl border border-solid border-components-card-border bg-components-card-bg shadow-sm transition-shadow duration-200 ease-in-out hover:shadow-lg"
className="group relative col-span-1 inline-flex h-40 cursor-pointer flex-col rounded-xl border border-solid border-components-card-border bg-components-card-bg shadow-sm transition-shadow duration-200 ease-in-out hover:shadow-lg"
>
<div className="flex h-[66px] shrink-0 grow-0 items-center gap-3 px-[14px] pt-[14px] pb-3">
<div className="flex h-16.5 shrink-0 grow-0 items-center gap-3 px-3.5 pt-3.5 pb-3">
<div className="relative shrink-0">
<AppIcon
size="large"
@ -452,7 +457,7 @@ const AppCard = ({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () =>
<div className="flex items-center text-sm leading-5 font-semibold text-text-secondary">
<div className="truncate" title={app.name}>{app.name}</div>
</div>
<div className="flex items-center gap-1 text-[10px] leading-[18px] font-medium text-text-tertiary">
<div className="flex items-center gap-1 text-2xs leading-4.5 font-medium text-text-tertiary">
<div className="truncate" title={app.author_name}>{app.author_name}</div>
<div>·</div>
<div className="truncate" title={EditTimeText}>{EditTimeText}</div>
@ -502,7 +507,7 @@ const AppCard = ({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () =>
</div>
</div>
</div>
<div className="h-[90px] px-[14px] text-xs leading-normal text-text-tertiary">
<div className="h-22.5 px-3.5 text-xs leading-normal text-text-tertiary">
<div
className="line-clamp-2"
title={app.description}
@ -510,88 +515,86 @@ const AppCard = ({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () =>
{app.description}
</div>
</div>
<div className="absolute right-0 bottom-1 left-0 flex h-[42px] shrink-0 items-center pt-1 pr-[6px] pb-[6px] pl-[14px]">
<div className="absolute right-0 bottom-1 left-0 flex h-10.5 shrink-0 items-center pt-1 pr-1.5 pb-1.5 pl-3.5">
<div
className={cn('flex w-0 grow items-center gap-1')}
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
}}
>
<div className="mr-10.25 min-w-0 grow overflow-hidden">
<AppCardTags
appId={app.id}
tags={app.tags}
onOpenTagManagement={onOpenTagManagement}
onTagsChange={onRefresh}
/>
</div>
</div>
{isCurrentWorkspaceEditor && (
<>
<div
className={cn('flex w-0 grow items-center gap-1')}
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
}}
>
<div className="mr-[41px] min-w-0 grow overflow-hidden">
<AppCardTags
appId={app.id}
tags={app.tags}
onOpenTagManagement={onOpenTagManagement}
onTagsChange={onRefresh}
/>
</div>
</div>
<div
className={cn(
'absolute top-1/2 right-[6px] flex -translate-y-1/2 items-center transition-opacity',
isOperationsMenuOpen
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100',
)}
>
<div className="mx-1 h-[14px] w-px shrink-0 bg-divider-regular" />
<DropdownMenu modal={false} open={isOperationsMenuOpen} onOpenChange={setIsOperationsMenuOpen}>
<DropdownMenuTrigger
aria-label={t('operation.more', { ns: 'common' })}
className={cn(
isOperationsMenuOpen ? 'bg-state-base-hover shadow-none' : 'bg-transparent',
'flex h-8 w-8 items-center justify-center rounded-md border-none p-2 hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:ring-inset',
)}
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
}}
>
<div className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md">
<span className="sr-only">{t('operation.more', { ns: 'common' })}</span>
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName={operationsMenuWidthClassName}
>
{systemFeatures.webapp_auth.enabled
? (
<AppCardOperationsMenuContent
app={app}
shouldShowSwitchOption={shouldShowSwitchOption}
shouldShowAccessControlOption={shouldShowAccessControlOption}
onEdit={handleShowEditModal}
onDuplicate={handleShowDuplicateModal}
onExport={exportCheck}
onSwitch={handleShowSwitchModal}
onDelete={handleShowDeleteConfirm}
onAccessControl={handleShowAccessControl}
/>
)
: (
<AppCardOperationsMenu
app={app}
shouldShowSwitchOption={shouldShowSwitchOption}
shouldShowOpenInExploreOption={!app.has_draft_trigger}
shouldShowAccessControlOption={shouldShowAccessControlOption}
onEdit={handleShowEditModal}
onDuplicate={handleShowDuplicateModal}
onExport={exportCheck}
onSwitch={handleShowSwitchModal}
onDelete={handleShowDeleteConfirm}
onAccessControl={handleShowAccessControl}
/>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
<div
className={cn(
'absolute top-1/2 right-1.5 flex -translate-y-1/2 items-center transition-opacity',
isOperationsMenuOpen
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100',
)}
>
<div className="mx-1 h-3.5 w-px shrink-0 bg-divider-regular" />
<DropdownMenu modal={false} open={isOperationsMenuOpen} onOpenChange={setIsOperationsMenuOpen}>
<DropdownMenuTrigger
aria-label={t('operation.more', { ns: 'common' })}
className={cn(
isOperationsMenuOpen ? 'bg-state-base-hover shadow-none' : 'bg-transparent',
'flex h-8 w-8 items-center justify-center rounded-md border-none p-2 hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:ring-inset',
)}
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
}}
>
<div className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md">
<span className="sr-only">{t('operation.more', { ns: 'common' })}</span>
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName={operationsMenuWidthClassName}
>
{systemFeatures.webapp_auth.enabled
? (
<AppCardOperationsMenuContent
app={app}
shouldShowSwitchOption={shouldShowSwitchOption}
shouldShowAccessControlOption={shouldShowAccessControlOption}
onEdit={handleShowEditModal}
onDuplicate={handleShowDuplicateModal}
onExport={exportCheck}
onSwitch={handleShowSwitchModal}
onDelete={handleShowDeleteConfirm}
onAccessControl={handleShowAccessControl}
/>
)
: (
<AppCardOperationsMenu
app={app}
shouldShowSwitchOption={shouldShowSwitchOption}
shouldShowOpenInExploreOption={!app.has_draft_trigger}
shouldShowAccessControlOption={shouldShowAccessControlOption}
onEdit={handleShowEditModal}
onDuplicate={handleShowDuplicateModal}
onExport={exportCheck}
onSwitch={handleShowSwitchModal}
onDelete={handleShowDeleteConfirm}
onAccessControl={handleShowAccessControl}
/>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
</div>

View File

@ -18,6 +18,7 @@ import dynamic from '@/next/dynamic'
import { consoleQuery } from '@/service/client'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { AppModeEnum } from '@/types/app'
import { hasPermission } from '@/utils/permission'
import AppCard from './app-card'
import { AppCardSkeleton } from './app-card-skeleton'
import { APP_LIST_SEARCH_DEBOUNCE_MS } from './constants'
@ -43,7 +44,9 @@ const List: FC<Props> = ({
}) => {
const { t } = useTranslation()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
const { isLoadingCurrentWorkspace, workspacePermissionKeys } = useAppContext()
const canCreateApp = hasPermission(workspacePermissionKeys, 'app.create')
const canAccessAppList = hasPermission(workspacePermissionKeys, 'app_library.access')
// eslint-disable-next-line react/use-state -- custom URL query hook, not React.useState
const {
@ -68,7 +71,7 @@ const List: FC<Props> = ({
const { dragging } = useDSLDragDrop({
onDSLFileDropped: handleDSLFileDropped,
containerRef,
enabled: isCurrentWorkspaceEditor,
enabled: canCreateApp,
})
const appListQuery = useMemo<AppListQuery>(() => ({
@ -101,7 +104,7 @@ const List: FC<Props> = ({
initialPageParam: 1,
placeholderData: keepPreviousData,
}),
enabled: !isCurrentWorkspaceDatasetOperator,
enabled: canAccessAppList,
refetchInterval: systemFeatures.enable_collaboration_mode ? 10000 : false,
})
@ -113,12 +116,12 @@ const List: FC<Props> = ({
const anchorRef = useRef<HTMLDivElement>(null)
const options = [
{ value: 'all', text: t('types.all', { ns: 'app' }), icon: <span className="mr-1 i-ri-apps-2-line h-[14px] w-[14px]" /> },
{ value: AppModeEnum.WORKFLOW, text: t('types.workflow', { ns: 'app' }), icon: <span className="mr-1 i-ri-exchange-2-line h-[14px] w-[14px]" /> },
{ value: AppModeEnum.ADVANCED_CHAT, text: t('types.advanced', { ns: 'app' }), icon: <span className="mr-1 i-ri-message-3-line h-[14px] w-[14px]" /> },
{ value: AppModeEnum.CHAT, text: t('types.chatbot', { ns: 'app' }), icon: <span className="mr-1 i-ri-message-3-line h-[14px] w-[14px]" /> },
{ value: AppModeEnum.AGENT_CHAT, text: t('types.agent', { ns: 'app' }), icon: <span className="mr-1 i-ri-robot-3-line h-[14px] w-[14px]" /> },
{ value: AppModeEnum.COMPLETION, text: t('types.completion', { ns: 'app' }), icon: <span className="mr-1 i-ri-file-4-line h-[14px] w-[14px]" /> },
{ value: 'all', text: t('types.all', { ns: 'app' }), icon: <span className="mr-1 i-ri-apps-2-line size-3.5" /> },
{ value: AppModeEnum.WORKFLOW, text: t('types.workflow', { ns: 'app' }), icon: <span className="mr-1 i-ri-exchange-2-line size-3.5" /> },
{ value: AppModeEnum.ADVANCED_CHAT, text: t('types.advanced', { ns: 'app' }), icon: <span className="mr-1 i-ri-message-3-line size-3.5" /> },
{ value: AppModeEnum.CHAT, text: t('types.chatbot', { ns: 'app' }), icon: <span className="mr-1 i-ri-message-3-line size-3.5" /> },
{ value: AppModeEnum.AGENT_CHAT, text: t('types.agent', { ns: 'app' }), icon: <span className="mr-1 i-ri-robot-3-line size-3.5" /> },
{ value: AppModeEnum.COMPLETION, text: t('types.completion', { ns: 'app' }), icon: <span className="mr-1 i-ri-file-4-line size-3.5" /> },
]
useEffect(() => {
@ -129,7 +132,7 @@ const List: FC<Props> = ({
}, [refetch])
useEffect(() => {
if (isCurrentWorkspaceDatasetOperator)
if (!canAccessAppList)
return
const hasMore = hasNextPage ?? true
let observer: IntersectionObserver | undefined
@ -156,7 +159,7 @@ const List: FC<Props> = ({
observer.observe(anchorRef.current)
}
return () => observer?.disconnect()
}, [isLoading, isFetchingNextPage, fetchNextPage, error, hasNextPage, isCurrentWorkspaceDatasetOperator])
}, [isLoading, isFetchingNextPage, fetchNextPage, error, hasNextPage, canAccessAppList])
const handleCreatedByMeChange = useCallback(() => {
setIsCreatedByMe(!isCreatedByMe)
@ -225,7 +228,7 @@ const List: FC<Props> = ({
!hasAnyApp && 'overflow-hidden',
)}
>
{(isCurrentWorkspaceEditor || isLoadingCurrentWorkspace) && (
{(canCreateApp || isLoadingCurrentWorkspace) && (
<NewAppCard
ref={newAppCardRef}
isLoading={isLoadingCurrentWorkspace}
@ -252,7 +255,7 @@ const List: FC<Props> = ({
)}
</div>
{isCurrentWorkspaceEditor && (
{canCreateApp && (
<div
className={`flex items-center justify-center gap-2 py-4 ${dragging ? 'text-text-accent' : 'text-text-quaternary'}`}
role="region"

View File

@ -6,7 +6,6 @@ import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContextSelector } from 'use-context-selector'
import { CreateFromDSLModalTab } from '@/app/components/app/create-from-dsl-modal'
import { FileArrow01, FilePlus01, FilePlus02 } from '@/app/components/base/icons/src/vender/line/files'
import AppListContext from '@/context/app-list-context'
import { useProviderContext } from '@/context/provider-context'
import dynamic from '@/next/dynamic'
@ -60,7 +59,6 @@ const CreateAppCard = ({
const controlHideCreateFromTemplatePanel = useContextSelector(AppListContext, ctx => ctx.controlHideCreateFromTemplatePanel)
useEffect(() => {
if (controlHideCreateFromTemplatePanel > 0)
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
setShowNewAppTemplateDialog(false)
}, [controlHideCreateFromTemplatePanel])
@ -68,27 +66,27 @@ const CreateAppCard = ({
<div
ref={ref}
className={cn(
'relative col-span-1 inline-flex h-[160px] flex-col justify-between rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg transition-opacity',
'relative col-span-1 inline-flex h-40 flex-col justify-between rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg transition-opacity',
isLoading && 'pointer-events-none opacity-50',
className,
)}
>
<div className="grow rounded-t-xl p-2">
<div className="px-6 pt-2 pb-1 text-xs leading-[18px] font-medium text-text-tertiary">{t('createApp', { ns: 'app' })}</div>
<button type="button" className="mb-1 flex w-full cursor-pointer items-center rounded-lg px-6 py-[7px] text-[13px] leading-[18px] font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary" onClick={() => setShowNewAppModal(true)}>
<FilePlus01 className="mr-2 h-4 w-4 shrink-0" />
<div className="px-6 pt-2 pb-1 text-xs leading-4.5 font-medium text-text-tertiary">{t('createApp', { ns: 'app' })}</div>
<button type="button" className="mb-1 flex w-full cursor-pointer items-center rounded-lg px-6 py-1.75 text-[13px] leading-4.5 font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary" onClick={() => setShowNewAppModal(true)}>
<span className="mr-2 i-custom-vender-line-files-file-plus-01 h-4 w-4 shrink-0" />
{t('newApp.startFromBlank', { ns: 'app' })}
</button>
<button type="button" className="flex w-full cursor-pointer items-center rounded-lg px-6 py-[7px] text-[13px] leading-[18px] font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary" onClick={() => setShowNewAppTemplateDialog(true)}>
<FilePlus02 className="mr-2 h-4 w-4 shrink-0" />
<button type="button" className="flex w-full cursor-pointer items-center rounded-lg px-6 py-1.75 text-[13px] leading-4.5 font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary" onClick={() => setShowNewAppTemplateDialog(true)}>
<span className="mr-2 i-custom-vender-line-files-file-plus-02 h-4 w-4 shrink-0" />
{t('newApp.startFromTemplate', { ns: 'app' })}
</button>
<button
type="button"
onClick={() => setShowCreateFromDSLModal(true)}
className="flex w-full cursor-pointer items-center rounded-lg px-6 py-[7px] text-[13px] leading-[18px] font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
className="flex w-full cursor-pointer items-center rounded-lg px-6 py-1.75 text-[13px] leading-4.5 font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
>
<FileArrow01 className="mr-2 h-4 w-4 shrink-0" />
<span className="mr-2 i-custom-vender-line-files-file-arrow-01 h-4 w-4 shrink-0" />
{t('importDSL', { ns: 'app' })}
</button>
</div>

View File

@ -6,6 +6,8 @@ import {
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import * as React from 'react'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { hasPermission } from '@/utils/permission'
import Operations from '../operations'
type OperationsDropdownProps = {
@ -26,6 +28,8 @@ const OperationsDropdown = ({
openAccessConfig,
}: OperationsDropdownProps) => {
const [open, setOpen] = React.useState(false)
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
const canManageAccessConfig = hasPermission(workspacePermissionKeys, 'dataset.access_config')
return (
<div
@ -57,6 +61,7 @@ const OperationsDropdown = ({
<Operations
showDelete={!isCurrentWorkspaceDatasetOperator}
showExportPipeline={dataset.runtime_mode === 'rag_pipeline'}
showAccessConfig={canManageAccessConfig}
openRenameModal={openRenameModal}
handleExportPipeline={handleExportPipeline}
detectIsUsedByApp={detectIsUsedByApp}

View File

@ -65,7 +65,7 @@ const DatasetCard = ({
return (
<>
<div
className="group relative col-span-1 flex h-[190px] cursor-pointer flex-col rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-xs shadow-shadow-shadow-3 transition-all duration-200 ease-in-out hover:bg-components-card-bg-alt hover:shadow-md hover:shadow-shadow-shadow-5"
className="group relative col-span-1 flex h-47.5 cursor-pointer flex-col rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-xs shadow-shadow-shadow-3 transition-all duration-200 ease-in-out hover:bg-components-card-bg-alt hover:shadow-md hover:shadow-shadow-shadow-5"
data-disable-nprogress={true}
onClick={handleCardClick}
>

View File

@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next'
type OperationsProps = {
showDelete: boolean
showExportPipeline: boolean
showAccessConfig?: boolean
openRenameModal: () => void
handleExportPipeline: () => void
detectIsUsedByApp: () => void
@ -18,6 +19,7 @@ type OperationsProps = {
const Operations = ({
showDelete,
showExportPipeline,
showAccessConfig = false,
openRenameModal,
handleExportPipeline,
detectIsUsedByApp,
@ -58,10 +60,12 @@ const Operations = ({
{t('operations.exportPipeline', { ns: 'datasetPipeline' })}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={handleAccessConfig}>
<span aria-hidden className="mr-1 i-ri-user-settings-line size-4 text-text-tertiary" />
Access Config
</DropdownMenuItem>
{showAccessConfig && (
<DropdownMenuItem onClick={handleAccessConfig}>
<span aria-hidden className="mr-1 i-ri-user-settings-line size-4 text-text-tertiary" />
Access Config
</DropdownMenuItem>
)}
{showDelete && (
<>
<DropdownMenuSeparator />

View File

@ -3,7 +3,6 @@
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { useDatasetList, useInvalidDatasetList } from '@/service/knowledge/use-dataset'
import DatasetCard from './dataset-card'
import NewDatasetCard from './new-dataset-card'
@ -22,7 +21,6 @@ const Datasets = ({
onOpenTagManagement = () => {},
}: Props) => {
const { t } = useTranslation()
const isCurrentWorkspaceEditor = useAppContextWithSelector(state => state.isCurrentWorkspaceEditor)
const {
data: datasetList,
fetchNextPage,
@ -60,7 +58,7 @@ const Datasets = ({
return (
<>
<nav className="grid grow grid-cols-1 content-start gap-3 px-12 pt-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{isCurrentWorkspaceEditor && <NewDatasetCard />}
<NewDatasetCard />
{datasetList?.pages.map(({ data: datasets }) => datasets.map(dataset => (
<DatasetCard key={dataset.id} dataset={dataset} onSuccess={invalidDatasetList} onOpenTagManagement={onOpenTagManagement} />),
))}

View File

@ -16,6 +16,7 @@ import { TagManagementModal } from '@/features/tag-management/components/tag-man
import useDocumentTitle from '@/hooks/use-document-title'
import { useDatasetApiBaseUrl, useInvalidDatasetList } from '@/service/knowledge/use-dataset'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { hasPermission } from '@/utils/permission'
// Components
import ExternalAPIPanel from '../external-api/external-api-panel'
import ServiceApi from '../extra-info/service-api'
@ -52,7 +53,9 @@ const List = () => {
}
const isCurrentWorkspaceManager = useAppContextSelector(state => state.isCurrentWorkspaceManager)
const workspacePermissionKeys = useAppContextSelector(state => state.workspacePermissionKeys)
const { data: apiBaseInfo } = useDatasetApiBaseUrl()
const canConnectExternalDataset = hasPermission(workspacePermissionKeys, 'dataset.external.connect')
return (
<div className="relative flex grow flex-col overflow-y-auto bg-background-body">
@ -82,14 +85,18 @@ const List = () => {
<ServiceApi apiBaseUrl={apiBaseInfo?.api_base_url ?? ''} />
)
}
<div className="h-4 w-px bg-divider-regular" />
<Button
className="gap-0.5 shadow-xs"
onClick={() => setShowExternalApiPanel(true)}
>
<span className="i-custom-vender-solid-development-api-connection-mod h-4 w-4 text-components-button-secondary-text" />
<span className="flex items-center justify-center gap-1 px-0.5 system-sm-medium text-components-button-secondary-text">{t('externalAPIPanelTitle', { ns: 'dataset' })}</span>
</Button>
{canConnectExternalDataset && (
<>
<div className="h-4 w-px bg-divider-regular" />
<Button
className="gap-0.5 shadow-xs"
onClick={() => setShowExternalApiPanel(true)}
>
<span className="i-custom-vender-solid-development-api-connection-mod h-4 w-4 text-components-button-secondary-text" />
<span className="flex items-center justify-center gap-1 px-0.5 system-sm-medium text-components-button-secondary-text">{t('externalAPIPanelTitle', { ns: 'dataset' })}</span>
</Button>
</>
)}
</div>
</div>
<Datasets tags={tagIDs} keywords={searchKeywords} includeAll={includeAll} onOpenTagManagement={() => setShowTagManagementModal(true)} />

View File

@ -6,20 +6,27 @@ import {
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { ApiConnectionMod } from '@/app/components/base/icons/src/vender/solid/development'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { hasPermission } from '@/utils/permission'
import Option from './option'
const CreateAppCard = () => {
const { t } = useTranslation()
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
const canAddDataset = hasPermission(workspacePermissionKeys, 'dataset.create')
const canConnectExternalDataset = hasPermission(workspacePermissionKeys, 'dataset.external.connect')
return (
<div className="flex h-[190px] flex-col gap-y-0.5 rounded-xl bg-background-default-dimmed">
<div className="flex h-47.5 flex-col gap-y-0.5 rounded-xl bg-background-default-dimmed">
<div className="flex grow flex-col items-center justify-center p-2">
<Option
disabled={!canAddDataset}
href="/datasets/create"
Icon={RiAddLine}
text={t('createDataset', { ns: 'dataset' })}
/>
<Option
disabled={!canAddDataset}
href="/datasets/create-from-pipeline"
Icon={RiFunctionAddLine}
text={t('createFromPipeline', { ns: 'dataset' })}
@ -27,6 +34,7 @@ const CreateAppCard = () => {
</div>
<div className="border-t-[0.5px] border-divider-subtle p-2">
<Option
disabled={!canConnectExternalDataset}
href="/datasets/connect"
Icon={ApiConnectionMod}
text={t('connectDataset', { ns: 'dataset' })}

View File

@ -1,3 +1,4 @@
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import Link from '@/next/link'
@ -5,13 +6,28 @@ type OptionProps = {
Icon: React.ComponentType<{ className?: string }>
text: string
href: string
disabled?: boolean
}
const Option = ({
Icon,
text,
href,
disabled = false,
}: OptionProps) => {
if (disabled) {
return (
<div
className={cn(
'flex w-full cursor-not-allowed items-center gap-x-2 rounded-lg bg-transparent px-4 py-2 text-text-tertiary opacity-50 shadow-shadow-shadow-3',
)}
>
<Icon className="h-4 w-4 shrink-0" />
<span className="grow text-left system-sm-medium">{text}</span>
</div>
)
}
return (
<Link
type="button"

View File

@ -16,11 +16,14 @@ import LabelFilter from '@/app/components/tools/labels/filter'
import CustomCreateCard from '@/app/components/tools/provider/custom-create-card'
import ProviderDetail from '@/app/components/tools/provider/detail'
import WorkflowToolEmpty from '@/app/components/tools/provider/empty'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useCheckInstalled, useInvalidateInstalledPluginList } from '@/service/use-plugins'
import { useAllToolProviders } from '@/service/use-tools'
import { hasPermission } from '@/utils/permission'
import Marketplace from './marketplace'
import { useMarketplace } from './marketplace/hooks'
import MCPList from './mcp'
import { getToolType } from './utils'
@ -44,14 +47,21 @@ const ProviderList = () => {
...systemFeaturesQueryOptions(),
select: s => s.enable_marketplace,
})
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
const containerRef = useRef<HTMLDivElement>(null)
const [activeTab, setActiveTab] = useQueryState('category', parseAsToolProviderCategory)
const canManageCustomAndWorkflow = hasPermission(workspacePermissionKeys, 'tool.manage')
const canManageMCP = hasPermission(workspacePermissionKeys, 'mcp.manage')
const options = [
{ value: 'builtin', text: t('type.builtIn', { ns: 'tools' }) },
{ value: 'api', text: t('type.custom', { ns: 'tools' }) },
{ value: 'workflow', text: t('type.workflow', { ns: 'tools' }) },
{ value: 'mcp', text: 'MCP' },
...(canManageCustomAndWorkflow
? [
{ value: 'api', text: t('type.custom', { ns: 'tools' }) },
{ value: 'workflow', text: t('type.workflow', { ns: 'tools' }) },
]
: []),
...(canManageMCP ? [{ value: 'mcp', text: 'MCP' }] : []),
]
const [tagFilterValue, setTagFilterValue] = useState<string[]>([])
const handleTagsChange = (value: string[]) => {
@ -130,7 +140,7 @@ const ProviderList = () => {
className="relative flex grow flex-col overflow-y-auto bg-background-body"
>
<div className={cn(
'sticky top-0 z-10 flex flex-wrap items-center justify-between gap-y-2 bg-background-body px-12 pt-4 pb-2 leading-[56px]',
'sticky top-0 z-10 flex flex-wrap items-center justify-between gap-y-2 bg-background-body px-12 pt-4 pb-2 leading-14',
currentProviderId && 'pr-6',
)}
>

View File

@ -3,6 +3,8 @@ import type { TagType } from '@/contract/console/tags'
import { ComboboxInput, ComboboxInputGroup, ComboboxItem, ComboboxItemIndicator, ComboboxItemText, ComboboxList, ComboboxSeparator, useComboboxFilteredItems } from '@langgenius/dify-ui/combobox'
import { Fragment } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { hasPermission } from '@/utils/permission'
import { isCreateTagOption } from './tag-combobox-item'
type TagPanelProps = {
@ -26,6 +28,12 @@ export const TagPanel = ({
const hasCreateOption = filteredItems.some(isCreateTagOption)
const placeholder = t('tag.selectorPlaceholder', { ns: 'common' }) || ''
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
const canManageTags
= type === 'app'
? hasPermission(workspacePermissionKeys, 'app.tag.manage')
: hasPermission(workspacePermissionKeys, 'dataset.tag.manage')
return (
<div className="relative w-full">
<div className="p-2 pb-1">
@ -53,7 +61,7 @@ export const TagPanel = ({
{filteredItems.length > 0 && (
<ComboboxList className="max-h-58">
{(tag: TagComboboxItem) => {
if (isCreateTagOption(tag)) {
if (isCreateTagOption(tag) && canManageTags) {
return (
<Fragment key={tag.id}>
<ComboboxItem
@ -89,22 +97,26 @@ export const TagPanel = ({
</div>
</div>
)}
<ComboboxSeparator />
<div className="p-1">
<button
type="button"
className="flex w-full cursor-pointer touch-manipulation items-center gap-x-1 rounded-lg px-2 py-1.5 text-left outline-hidden hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active"
onClick={() => {
onOpenTagManagement?.()
onClose?.()
}}
>
<span aria-hidden="true" className="i-ri-price-tag-3-line h-4 w-4 text-text-tertiary" />
<span className="min-w-0 grow truncate px-1 system-md-regular text-text-secondary">
{t('tag.manageTags', { ns: 'common' })}
</span>
</button>
</div>
{canManageTags && (
<>
<ComboboxSeparator />
<div className="p-1">
<button
type="button"
className="flex w-full cursor-pointer touch-manipulation items-center gap-x-1 rounded-lg px-2 py-1.5 text-left outline-hidden hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active"
onClick={() => {
onOpenTagManagement?.()
onClose?.()
}}
>
<span aria-hidden="true" className="i-ri-price-tag-3-line h-4 w-4 text-text-tertiary" />
<span className="min-w-0 grow truncate px-1 system-md-regular text-text-secondary">
{t('tag.manageTags', { ns: 'common' })}
</span>
</button>
</div>
</>
)}
</div>
)
}

View File

@ -8,7 +8,9 @@ import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { consoleQuery } from '@/service/client'
import { hasPermission } from '@/utils/permission'
import { useApplyTagBindingsMutation } from '../hooks/use-tag-mutations'
import { isCreateTagOption } from './tag-combobox-item'
import { TagPanel } from './tag-panel'
@ -66,6 +68,8 @@ export const TagSelector = ({
const [open, setOpen] = useState(false)
const [draftTags, setDraftTags] = useState<Tag[]>(value)
const [inputValue, setInputValue] = useState('')
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
const canManageTags = type === 'app' ? hasPermission(workspacePermissionKeys, 'app.tag.manage') : hasPermission(workspacePermissionKeys, 'dataset.tag.manage')
const applyTagBindingsMutation = useApplyTagBindingsMutation()
const {
isPending: isCreatingTag,
@ -212,6 +216,7 @@ export const TagSelector = ({
isItemEqualToValue={isSameTag}
>
<ComboboxTrigger
disabled={!canManageTags}
aria-label={triggerLabel}
className={cn(
open ? 'bg-state-base-hover' : 'bg-transparent',