feat: add app and dataset access configuration modals for managing access rules

This commit is contained in:
twwu 2026-04-27 17:58:52 +08:00
parent 5f4b086e39
commit 7b97789fd2
13 changed files with 487 additions and 8 deletions

View File

@ -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<AccessConfigModalProps, 'open'>
const AccessConfigModalBody = ({
title,
description,
initialRules,
saveLabel = 'Save',
cancelLabel = 'Cancel',
onClose,
onSave,
}: AccessConfigModalBodyProps) => {
const [rules, setRules] = useState<AccessRule[]>(initialRules)
const [addingRule, setAddingRule] = useState<AccessRule | null>(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 (
<DialogContent
className="flex max-h-[85vh] w-[520px] flex-col overflow-hidden p-0"
backdropProps={{ forceRender: true }}
>
<div className="relative shrink-0 px-6 pt-6 pb-4">
<DialogCloseButton />
<div className="pr-8">
<DialogTitle className="system-xl-semibold text-text-primary">
{title}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{description}
</DialogDescription>
</div>
</div>
<ScrollArea
className="min-h-0 flex-1"
slotClassNames={{ viewport: 'px-6 overscroll-contain' }}
>
<div className="flex flex-col">
{rules.map(rule => (
<AccessRuleRow
key={rule.id}
rule={rule}
showMenu={false}
onAddRole={handleAddRole}
onRemoveRole={handleRemoveRole}
/>
))}
</div>
</ScrollArea>
<div className="flex shrink-0 items-center justify-end gap-2 border-t border-divider-subtle px-6 py-4">
<Button variant="secondary" onClick={onClose}>
{cancelLabel}
</Button>
<Button variant="primary" onClick={handleSave}>
{saveLabel}
</Button>
</div>
{addingRule && (
<AddRuleTargetsModal
open
ruleName={addingRule.name}
initialRoleIds={addingRule.assignedRoles.map(role => role.id)}
initialMemberIds={[]}
onClose={handleCloseAddModal}
onSubmit={handleAddSubmit}
/>
)}
</DialogContent>
)
}
const AccessConfigModal = ({
open,
title,
description,
initialRules,
saveLabel,
cancelLabel,
onClose,
onSave,
}: AccessConfigModalProps) => {
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen)
onClose()
}}
>
<AccessConfigModalBody
title={title}
description={description}
initialRules={initialRules}
saveLabel={saveLabel}
cancelLabel={cancelLabel}
onClose={onClose}
onSave={onSave}
/>
</Dialog>
)
}
export default AccessConfigModal

View File

@ -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<App, 'id' | 'name'>
onClose: () => void
onSave?: (rules: AccessRule[]) => void
}
const AppAccessConfigModal = ({
open,
app: _app,
onClose,
onSave,
}: AppAccessConfigModalProps) => {
return (
<AccessConfigModal
open={open}
title="App Access Config"
description="Configure access levels for this specific app."
initialRules={DEFAULT_APP_ACCESS_RULES}
onClose={onClose}
onSave={onSave}
/>
)
}
export default AppAccessConfigModal

View File

@ -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<AppCardOperationsMenuProps> = ({
@ -99,6 +103,7 @@ const AppCardOperationsMenu: React.FC<AppCardOperationsMenuProps> = ({
onSwitch,
onDelete,
onAccessControl,
onAccessConfig,
}) => {
const { t } = useTranslation()
const openAsyncWindow = useAsyncWindowOpen()
@ -167,6 +172,10 @@ const AppCardOperationsMenu: React.FC<AppCardOperationsMenuProps> = ({
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem className="gap-2 px-3" onClick={e => handleMenuAction(e, onAccessConfig)}>
<span className="text-sm leading-5 text-text-secondary">Access Config</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
className="gap-2 px-3"
@ -217,6 +226,7 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => {
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<EnvironmentVariable[]>([])
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}
/>
)}
</DropdownMenuContent>
@ -670,6 +689,13 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => {
{showAccessControl && (
<AccessControl app={app} onConfirm={onUpdateAccessControl} onClose={() => setShowAccessControl(false)} />
)}
{showAccessConfig && (
<AppAccessConfigModal
open
app={app}
onClose={() => setShowAccessConfig(false)}
/>
)}
</>
)
}

View File

@ -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(<Operations {...defaultProps} openAccessConfig={openAccessConfig} />)
fireEvent.click(screen.getByText('Access Config'))
expect(openAccessConfig).toHaveBeenCalledTimes(1)
})
})
describe('Edge Cases', () => {

View File

@ -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?',
}}
/>,

View File

@ -34,6 +34,7 @@ describe('OperationsDropdown', () => {
openRenameModal: vi.fn(),
handleExportPipeline: vi.fn(),
detectIsUsedByApp: vi.fn(),
openAccessConfig: vi.fn(),
}
beforeEach(() => {

View File

@ -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 && (
<DatasetAccessConfigModal
open
dataset={dataset}
onClose={onCloseAccessConfig}
/>
)}
<AlertDialog open={modalState.showConfirmDelete} onOpenChange={open => !open && onCloseConfirm()}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">

View File

@ -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}
/>
</DropdownMenuContent>
</DropdownMenu>

View File

@ -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<DataSet, 'id' | 'name'>
onClose: () => void
onSave?: (rules: AccessRule[]) => void
}
const DatasetAccessConfigModal = ({
open,
dataset: _dataset,
onClose,
onSave,
}: DatasetAccessConfigModalProps) => {
return (
<AccessConfigModal
open={open}
title="Knowledge Base Access Config"
description="Configure access levels for this specific knowledge base."
initialRules={DEFAULT_KB_ACCESS_RULES}
onClose={onClose}
onSave={onSave}
/>
)
}
export default DatasetAccessConfigModal

View File

@ -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<ModalState>({
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,

View File

@ -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}
/>
</div>
<DatasetCardModals
@ -95,6 +98,7 @@ const DatasetCard = ({
modalState={modalState}
onCloseRename={closeRenameModal}
onCloseConfirm={closeConfirmDelete}
onCloseAccessConfig={closeAccessConfig}
onConfirmDelete={onConfirmDelete}
onSuccess={onSuccess}
/>

View File

@ -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 (
<>
<DropdownMenuItem onClick={handleRename}>
<span aria-hidden className="i-ri-edit-line size-4 text-text-tertiary" />
<span aria-hidden className="mr-1 i-ri-edit-line size-4 text-text-tertiary" />
{t('operation.edit', { ns: 'common' })}
</DropdownMenuItem>
{showExportPipeline && (
<DropdownMenuItem onClick={handleExport}>
<span aria-hidden className="i-ri-file-download-line size-4 text-text-tertiary" />
<span aria-hidden className="mr-1 i-ri-file-download-line size-4 text-text-tertiary" />
{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>
{showDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
<span aria-hidden className="i-ri-delete-bin-line size-4" />
<span aria-hidden className="mr-1 i-ri-delete-bin-line size-4" />
{t('operation.delete', { ns: 'common' })}
</DropdownMenuItem>
</>

View File

@ -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 = ({
</button>
</div>
</div>
<AccessRuleRowMenu
onEdit={handleEdit}
onCopy={handleCopy}
onDelete={handleDelete}
/>
{showMenu && (
<AccessRuleRowMenu
onEdit={handleEdit}
onCopy={handleCopy}
onDelete={handleDelete}
/>
)}
</div>
)
}