diff --git a/web/app/components/access-config-modal/index.tsx b/web/app/components/access-config-modal/index.tsx new file mode 100644 index 0000000000..8514eb9e06 --- /dev/null +++ b/web/app/components/access-config-modal/index.tsx @@ -0,0 +1,175 @@ +'use client' + +import type { + AccessRule, + AssignedRole, +} from '@/app/components/header/account-setting/access-rules-page/access-rule-row' +import { Button } from '@langgenius/dify-ui/button' +import { + Dialog, + DialogCloseButton, + DialogContent, + DialogDescription, + DialogTitle, +} from '@langgenius/dify-ui/dialog' +import { ScrollArea } from '@langgenius/dify-ui/scroll-area' +import { useCallback, useState } from 'react' +import AccessRuleRow from '@/app/components/header/account-setting/access-rules-page/access-rule-row' +import AddRuleTargetsModal from '@/app/components/header/account-setting/access-rules-page/add-rule-targets-modal' + +export type AccessConfigModalProps = { + open: boolean + title: string + description: string + initialRules: AccessRule[] + /** + * Optional override label for the primary action. Defaults to "Save". + */ + saveLabel?: string + /** + * Optional override label for the cancel action. Defaults to "Cancel". + */ + cancelLabel?: string + onClose: () => void + onSave?: (rules: AccessRule[]) => void +} + +type AccessConfigModalBodyProps = Omit + +const AccessConfigModalBody = ({ + title, + description, + initialRules, + saveLabel = 'Save', + cancelLabel = 'Cancel', + onClose, + onSave, +}: AccessConfigModalBodyProps) => { + const [rules, setRules] = useState(initialRules) + const [addingRule, setAddingRule] = useState(null) + + const handleAddRole = useCallback((rule: AccessRule) => { + setAddingRule(rule) + }, []) + + const handleCloseAddModal = useCallback(() => { + setAddingRule(null) + }, []) + + const handleAddSubmit = useCallback( + (_selection: { roleIds: string[], memberIds: string[] }) => { + // TODO: wire up to API when backend is ready. + }, + [], + ) + + const handleRemoveRole = useCallback( + (target: AccessRule, role: AssignedRole) => { + setRules(prev => + prev.map(rule => + rule.id === target.id + ? { + ...rule, + assignedRoles: rule.assignedRoles.filter(r => r.id !== role.id), + } + : rule, + ), + ) + }, + [], + ) + + const handleSave = useCallback(() => { + onSave?.(rules) + onClose() + }, [onClose, onSave, rules]) + + return ( + +
+ +
+ + {title} + + + {description} + +
+
+ + +
+ {rules.map(rule => ( + + ))} +
+
+ +
+ + +
+ + {addingRule && ( + role.id)} + initialMemberIds={[]} + onClose={handleCloseAddModal} + onSubmit={handleAddSubmit} + /> + )} +
+ ) +} + +const AccessConfigModal = ({ + open, + title, + description, + initialRules, + saveLabel, + cancelLabel, + onClose, + onSave, +}: AccessConfigModalProps) => { + return ( + { + if (!nextOpen) + onClose() + }} + > + + + ) +} + +export default AccessConfigModal diff --git a/web/app/components/apps/app-access-config-modal/index.tsx b/web/app/components/apps/app-access-config-modal/index.tsx new file mode 100644 index 0000000000..528a7f4be6 --- /dev/null +++ b/web/app/components/apps/app-access-config-modal/index.tsx @@ -0,0 +1,108 @@ +'use client' + +import type { AccessRule } from '@/app/components/header/account-setting/access-rules-page/access-rule-row' +import type { App } from '@/types/app' +import AccessConfigModal from '@/app/components/access-config-modal' + +// TODO: replace with the per-app access rules fetched from the access-rules API +// once available. The catalog mirrors the workspace-level App access rules and +// adds app-specific rules that can only be assigned per-app. +const DEFAULT_APP_ACCESS_RULES: AccessRule[] = [ + { + id: 'app-full-access', + name: 'Full access', + description: 'Highest level. Can edit, publish, delete apps, and manage access for this app.', + assignedRoles: [ + { id: 'owner', name: 'Owner' }, + { id: 'admin', name: 'Admin' }, + { id: 'marketing-lead', name: 'Marketing Lead' }, + { id: 'kb-admin', name: 'KB Admin' }, + { id: 'app-admin', name: 'App Admin' }, + { id: 'executive', name: 'Executive' }, + ], + permissions: [], + }, + { + id: 'app-can-edit', + name: 'Can edit', + description: 'Modify Prompts, adjust workflows, change variables. Test and publish updates.', + assignedRoles: [ + { id: 'owner', name: 'Owner' }, + { id: 'admin', name: 'Admin' }, + { id: 'marketing-lead', name: 'Marketing Lead' }, + { id: 'kb-admin', name: 'KB Admin' }, + { id: 'app-admin', name: 'App Admin' }, + { id: 'executive', name: 'Executive' }, + ], + permissions: [], + }, + { + id: 'app-can-view-and-use', + name: 'Can view & use', + description: 'View and use the app. Access Prompt and workflow logs. Cannot modify.', + assignedRoles: [ + { id: 'owner', name: 'Owner' }, + { id: 'admin', name: 'Admin' }, + { id: 'marketing-lead', name: 'Marketing Lead' }, + { id: 'kb-admin', name: 'KB Admin' }, + { id: 'app-admin', name: 'App Admin' }, + { id: 'executive', name: 'Executive' }, + ], + permissions: [], + }, + { + id: 'app-can-preview', + name: 'Can preview', + description: 'View the app in the list only. Cannot open the editor or use the app.', + assignedRoles: [ + { id: 'owner', name: 'Owner' }, + { id: 'admin', name: 'Admin' }, + { id: 'marketing-lead', name: 'Marketing Lead' }, + { id: 'kb-admin', name: 'KB Admin' }, + { id: 'app-admin', name: 'App Admin' }, + { id: 'executive', name: 'Executive' }, + ], + permissions: [], + }, + { + id: 'app-can-optimize-prompt', + name: 'Can optimize prompt', + description: 'Dedicated prompt optimization access.', + assignedRoles: [ + { id: 'owner', name: 'Owner' }, + { id: 'admin', name: 'Admin' }, + { id: 'marketing-lead', name: 'Marketing Lead' }, + { id: 'kb-admin', name: 'KB Admin' }, + { id: 'app-admin', name: 'App Admin' }, + { id: 'executive', name: 'Executive' }, + ], + permissions: [], + }, +] + +export type AppAccessConfigModalProps = { + open: boolean + app: Pick + onClose: () => void + onSave?: (rules: AccessRule[]) => void +} + +const AppAccessConfigModal = ({ + open, + app: _app, + onClose, + onSave, +}: AppAccessConfigModalProps) => { + return ( + + ) +} + +export default AppAccessConfigModal diff --git a/web/app/components/apps/app-card.tsx b/web/app/components/apps/app-card.tsx index 80aab3ce4d..305d7d339d 100644 --- a/web/app/components/apps/app-card.tsx +++ b/web/app/components/apps/app-card.tsx @@ -68,6 +68,9 @@ const DSLExportConfirmModal = dynamic(() => import('@/app/components/workflow/ds const AccessControl = dynamic(() => import('@/app/components/app/app-access-control'), { ssr: false, }) +const AppAccessConfigModal = dynamic(() => import('@/app/components/apps/app-access-config-modal'), { + ssr: false, +}) type AppCardProps = { app: App @@ -86,6 +89,7 @@ type AppCardOperationsMenuProps = { onSwitch: () => void onDelete: () => void onAccessControl: () => void + onAccessConfig: () => void } const AppCardOperationsMenu: React.FC = ({ @@ -99,6 +103,7 @@ const AppCardOperationsMenu: React.FC = ({ onSwitch, onDelete, onAccessControl, + onAccessConfig, }) => { const { t } = useTranslation() const openAsyncWindow = useAsyncWindowOpen() @@ -167,6 +172,10 @@ const AppCardOperationsMenu: React.FC = ({ )} + handleMenuAction(e, onAccessConfig)}> + Access Config + + { const [showConfirmDelete, setShowConfirmDelete] = useState(false) const [confirmDeleteInput, setConfirmDeleteInput] = useState('') const [showAccessControl, setShowAccessControl] = useState(false) + const [showAccessConfig, setShowAccessConfig] = useState(false) const [isOperationsMenuOpen, setIsOperationsMenuOpen] = useState(false) const [secretEnvList, setSecretEnvList] = useState([]) const { mutateAsync: mutateDeleteApp, isPending: isDeleting } = useDeleteAppMutation() @@ -288,6 +298,13 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => { }) }, []) + const handleShowAccessConfig = useCallback(() => { + setIsOperationsMenuOpen(false) + queueMicrotask(() => { + setShowAccessConfig(true) + }) + }, []) + const onEdit: CreateAppModalProps['onConfirm'] = useCallback(async ({ name, icon_type, @@ -550,6 +567,7 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => { onSwitch={handleShowSwitchModal} onDelete={handleShowDeleteConfirm} onAccessControl={handleShowAccessControl} + onAccessConfig={handleShowAccessConfig} /> ) : ( @@ -564,6 +582,7 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => { onSwitch={handleShowSwitchModal} onDelete={handleShowDeleteConfirm} onAccessControl={handleShowAccessControl} + onAccessConfig={handleShowAccessConfig} /> )} @@ -670,6 +689,13 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => { {showAccessControl && ( setShowAccessControl(false)} /> )} + {showAccessConfig && ( + setShowAccessConfig(false)} + /> + )} ) } diff --git a/web/app/components/datasets/list/dataset-card/__tests__/operations.spec.tsx b/web/app/components/datasets/list/dataset-card/__tests__/operations.spec.tsx index 41c0b2749b..67d4996f44 100644 --- a/web/app/components/datasets/list/dataset-card/__tests__/operations.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/__tests__/operations.spec.tsx @@ -18,6 +18,7 @@ describe('Operations', () => { openRenameModal: vi.fn(), handleExportPipeline: vi.fn(), detectIsUsedByApp: vi.fn(), + openAccessConfig: vi.fn(), } beforeEach(() => { @@ -80,6 +81,14 @@ describe('Operations', () => { fireEvent.click(screen.getByText(/operation\.delete/)) expect(detectIsUsedByApp).toHaveBeenCalledTimes(1) }) + + it('should call openAccessConfig when access config is clicked', () => { + const openAccessConfig = vi.fn() + renderInMenu() + + fireEvent.click(screen.getByText('Access Config')) + expect(openAccessConfig).toHaveBeenCalledTimes(1) + }) }) describe('Edge Cases', () => { diff --git a/web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-modals.spec.tsx b/web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-modals.spec.tsx index 8cc10ae5ae..fd6ac86eed 100644 --- a/web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-modals.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-modals.spec.tsx @@ -71,10 +71,12 @@ describe('DatasetCardModals', () => { modalState: { showRenameModal: false, showConfirmDelete: false, + showAccessConfig: false, confirmMessage: '', }, onCloseRename: vi.fn(), onCloseConfirm: vi.fn(), + onCloseAccessConfig: vi.fn(), onConfirmDelete: vi.fn(), onSuccess: vi.fn(), } @@ -209,6 +211,7 @@ describe('DatasetCardModals', () => { modalState={{ showRenameModal: true, showConfirmDelete: true, + showAccessConfig: false, confirmMessage: 'Delete this dataset?', }} />, diff --git a/web/app/components/datasets/list/dataset-card/components/__tests__/operations-dropdown.spec.tsx b/web/app/components/datasets/list/dataset-card/components/__tests__/operations-dropdown.spec.tsx index 2bb138e6dc..a013a66079 100644 --- a/web/app/components/datasets/list/dataset-card/components/__tests__/operations-dropdown.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/components/__tests__/operations-dropdown.spec.tsx @@ -34,6 +34,7 @@ describe('OperationsDropdown', () => { openRenameModal: vi.fn(), handleExportPipeline: vi.fn(), detectIsUsedByApp: vi.fn(), + openAccessConfig: vi.fn(), } beforeEach(() => { diff --git a/web/app/components/datasets/list/dataset-card/components/dataset-card-modals.tsx b/web/app/components/datasets/list/dataset-card/components/dataset-card-modals.tsx index 47a195943b..332f430ed7 100644 --- a/web/app/components/datasets/list/dataset-card/components/dataset-card-modals.tsx +++ b/web/app/components/datasets/list/dataset-card/components/dataset-card-modals.tsx @@ -10,11 +10,17 @@ import { } from '@langgenius/dify-ui/alert-dialog' import * as React from 'react' import { useTranslation } from 'react-i18next' +import dynamic from '@/next/dynamic' import RenameDatasetModal from '../../../rename-modal' +const DatasetAccessConfigModal = dynamic(() => import('../dataset-access-config-modal'), { + ssr: false, +}) + type ModalState = { showRenameModal: boolean showConfirmDelete: boolean + showAccessConfig: boolean confirmMessage: string } @@ -23,6 +29,7 @@ type DatasetCardModalsProps = { modalState: ModalState onCloseRename: () => void onCloseConfirm: () => void + onCloseAccessConfig: () => void onConfirmDelete: () => void onSuccess?: () => void } @@ -32,6 +39,7 @@ const DatasetCardModals = ({ modalState, onCloseRename, onCloseConfirm, + onCloseAccessConfig, onConfirmDelete, onSuccess, }: DatasetCardModalsProps) => { @@ -47,6 +55,13 @@ const DatasetCardModals = ({ onSuccess={onSuccess} /> )} + {modalState.showAccessConfig && ( + + )} !open && onCloseConfirm()}>
diff --git a/web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx b/web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx index a7918ef033..d17bbb3f40 100644 --- a/web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx +++ b/web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx @@ -14,6 +14,7 @@ type OperationsDropdownProps = { openRenameModal: () => void handleExportPipeline: (include?: boolean) => void detectIsUsedByApp: () => void + openAccessConfig: () => void } const OperationsDropdown = ({ @@ -22,6 +23,7 @@ const OperationsDropdown = ({ openRenameModal, handleExportPipeline, detectIsUsedByApp, + openAccessConfig, }: OperationsDropdownProps) => { const [open, setOpen] = React.useState(false) @@ -58,6 +60,7 @@ const OperationsDropdown = ({ openRenameModal={openRenameModal} handleExportPipeline={handleExportPipeline} detectIsUsedByApp={detectIsUsedByApp} + openAccessConfig={openAccessConfig} /> diff --git a/web/app/components/datasets/list/dataset-card/dataset-access-config-modal/index.tsx b/web/app/components/datasets/list/dataset-card/dataset-access-config-modal/index.tsx new file mode 100644 index 0000000000..a2337c6632 --- /dev/null +++ b/web/app/components/datasets/list/dataset-card/dataset-access-config-modal/index.tsx @@ -0,0 +1,108 @@ +'use client' + +import type { AccessRule } from '@/app/components/header/account-setting/access-rules-page/access-rule-row' +import type { DataSet } from '@/models/datasets' +import AccessConfigModal from '@/app/components/access-config-modal' + +// TODO: replace with the per-knowledge-base access rules fetched from the +// access-rules API once available. The catalog mirrors the workspace-level +// Knowledge Base access rules. +const DEFAULT_KB_ACCESS_RULES: AccessRule[] = [ + { + id: 'kb-full-access', + name: 'Full access', + description: 'Highest level. Can edit, publish, delete, and manage access for this knowledge base.', + assignedRoles: [ + { id: 'owner', name: 'Owner' }, + { id: 'admin', name: 'Admin' }, + { id: 'marketing-lead', name: 'Marketing Lead' }, + { id: 'kb-admin', name: 'KB Admin' }, + { id: 'app-admin', name: 'App Admin' }, + { id: 'executive', name: 'Executive' }, + ], + permissions: [], + }, + { + id: 'kb-can-edit', + name: 'Can edit', + description: 'Edit knowledge base content, modify settings, and run tests.', + assignedRoles: [ + { id: 'owner', name: 'Owner' }, + { id: 'admin', name: 'Admin' }, + { id: 'marketing-lead', name: 'Marketing Lead' }, + { id: 'kb-admin', name: 'KB Admin' }, + { id: 'app-admin', name: 'App Admin' }, + { id: 'executive', name: 'Executive' }, + ], + permissions: [], + }, + { + id: 'kb-can-view-and-use', + name: 'Can view & use', + description: 'View knowledge base sources, configs, and logs. Cannot modify content.', + assignedRoles: [ + { id: 'owner', name: 'Owner' }, + { id: 'admin', name: 'Admin' }, + { id: 'marketing-lead', name: 'Marketing Lead' }, + { id: 'kb-admin', name: 'KB Admin' }, + { id: 'app-admin', name: 'App Admin' }, + { id: 'executive', name: 'Executive' }, + ], + permissions: [], + }, + { + id: 'kb-can-preview', + name: 'Can preview', + description: 'View in the list only. Cannot access the detail page.', + assignedRoles: [ + { id: 'owner', name: 'Owner' }, + { id: 'admin', name: 'Admin' }, + { id: 'marketing-lead', name: 'Marketing Lead' }, + { id: 'kb-admin', name: 'KB Admin' }, + { id: 'app-admin', name: 'App Admin' }, + { id: 'executive', name: 'Executive' }, + ], + permissions: [], + }, + { + id: 'kb-can-test', + name: 'Can test', + description: 'Test knowledge base retrieval efficiency in sandbox.', + assignedRoles: [ + { id: 'owner', name: 'Owner' }, + { id: 'admin', name: 'Admin' }, + { id: 'marketing-lead', name: 'Marketing Lead' }, + { id: 'kb-admin', name: 'KB Admin' }, + { id: 'app-admin', name: 'App Admin' }, + { id: 'executive', name: 'Executive' }, + ], + permissions: [], + }, +] + +export type DatasetAccessConfigModalProps = { + open: boolean + dataset: Pick + onClose: () => void + onSave?: (rules: AccessRule[]) => void +} + +const DatasetAccessConfigModal = ({ + open, + dataset: _dataset, + onClose, + onSave, +}: DatasetAccessConfigModalProps) => { + return ( + + ) +} + +export default DatasetAccessConfigModal diff --git a/web/app/components/datasets/list/dataset-card/hooks/use-dataset-card-state.ts b/web/app/components/datasets/list/dataset-card/hooks/use-dataset-card-state.ts index 6cffbb6828..2449e029bb 100644 --- a/web/app/components/datasets/list/dataset-card/hooks/use-dataset-card-state.ts +++ b/web/app/components/datasets/list/dataset-card/hooks/use-dataset-card-state.ts @@ -10,6 +10,7 @@ import { downloadBlob } from '@/utils/download' type ModalState = { showRenameModal: boolean showConfirmDelete: boolean + showAccessConfig: boolean confirmMessage: string } @@ -30,6 +31,7 @@ export const useDatasetCardState = ({ dataset, onSuccess }: UseDatasetCardStateO const [modalState, setModalState] = useState({ showRenameModal: false, showConfirmDelete: false, + showAccessConfig: false, confirmMessage: '', }) @@ -49,6 +51,14 @@ export const useDatasetCardState = ({ dataset, onSuccess }: UseDatasetCardStateO setModalState(prev => ({ ...prev, showConfirmDelete: false })) }, []) + const openAccessConfig = useCallback(() => { + setModalState(prev => ({ ...prev, showAccessConfig: true })) + }, []) + + const closeAccessConfig = useCallback(() => { + setModalState(prev => ({ ...prev, showAccessConfig: false })) + }, []) + // API mutations const { mutateAsync: checkUsage } = useCheckDatasetUsage() const { mutateAsync: deleteDatasetMutation } = useDeleteDataset() @@ -122,6 +132,8 @@ export const useDatasetCardState = ({ dataset, onSuccess }: UseDatasetCardStateO openRenameModal, closeRenameModal, closeConfirmDelete, + openAccessConfig, + closeAccessConfig, // Export state exporting, diff --git a/web/app/components/datasets/list/dataset-card/index.tsx b/web/app/components/datasets/list/dataset-card/index.tsx index 5bd032d151..d16592bcb3 100644 --- a/web/app/components/datasets/list/dataset-card/index.tsx +++ b/web/app/components/datasets/list/dataset-card/index.tsx @@ -37,6 +37,8 @@ const DatasetCard = ({ openRenameModal, closeRenameModal, closeConfirmDelete, + openAccessConfig, + closeAccessConfig, handleExportPipeline, detectIsUsedByApp, onConfirmDelete, @@ -88,6 +90,7 @@ const DatasetCard = ({ openRenameModal={openRenameModal} handleExportPipeline={handleExportPipeline} detectIsUsedByApp={detectIsUsedByApp} + openAccessConfig={openAccessConfig} />
diff --git a/web/app/components/datasets/list/dataset-card/operations.tsx b/web/app/components/datasets/list/dataset-card/operations.tsx index 1349ae05e7..443e98b2ea 100644 --- a/web/app/components/datasets/list/dataset-card/operations.tsx +++ b/web/app/components/datasets/list/dataset-card/operations.tsx @@ -11,6 +11,7 @@ type OperationsProps = { openRenameModal: () => void handleExportPipeline: () => void detectIsUsedByApp: () => void + openAccessConfig: () => void onClose?: () => void } @@ -20,6 +21,7 @@ const Operations = ({ openRenameModal, handleExportPipeline, detectIsUsedByApp, + openAccessConfig, onClose, }: OperationsProps) => { const { t } = useTranslation() @@ -39,23 +41,32 @@ const Operations = ({ detectIsUsedByApp() } + const handleAccessConfig = () => { + onClose?.() + openAccessConfig() + } + return ( <> - + {t('operation.edit', { ns: 'common' })} {showExportPipeline && ( - + {t('operations.exportPipeline', { ns: 'datasetPipeline' })} )} + + + Access Config + {showDelete && ( <> - + {t('operation.delete', { ns: 'common' })} diff --git a/web/app/components/header/account-setting/access-rules-page/access-rule-row.tsx b/web/app/components/header/account-setting/access-rules-page/access-rule-row.tsx index a652c38b57..bbd2201bd9 100644 --- a/web/app/components/header/account-setting/access-rules-page/access-rule-row.tsx +++ b/web/app/components/header/account-setting/access-rules-page/access-rule-row.tsx @@ -21,6 +21,7 @@ export type AccessRule = { export type AccessRuleRowProps = { rule: AccessRule className?: string + showMenu?: boolean onEdit?: (rule: AccessRule) => void onCopy?: (rule: AccessRule) => void onDelete?: (rule: AccessRule) => void @@ -31,6 +32,7 @@ export type AccessRuleRowProps = { const AccessRuleRow = ({ rule, className, + showMenu = true, onEdit, onCopy, onDelete, @@ -70,11 +72,13 @@ const AccessRuleRow = ({ - + {showMenu && ( + + )} ) }