feat: add access configuration pages and editor components for app and dataset management

This commit is contained in:
twwu 2026-04-27 18:29:38 +08:00
parent 7b97789fd2
commit 34f1ed0ab7
8 changed files with 325 additions and 69 deletions

View File

@ -0,0 +1,16 @@
import type { Locale } from '@/i18n-config'
import AppAccessConfigPage from '@/app/components/app/access-config'
export type AccessConfigPageProps = {
params: Promise<{ locale: Locale, appId: string }>
}
const AccessConfig = async (props: AccessConfigPageProps) => {
const params = await props.params
const { appId } = params
return <AppAccessConfigPage appId={appId} />
}
export default AccessConfig

View File

@ -12,6 +12,8 @@ import {
RiTerminalBoxLine,
RiTerminalWindowFill,
RiTerminalWindowLine,
RiUserSettingsFill,
RiUserSettingsLine,
} from '@remixicon/react'
import { useUnmount } from 'ahooks'
import * as React from 'react'
@ -100,6 +102,15 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
icon: RiDashboard2Line,
selectedIcon: RiDashboard2Fill,
},
...(isCurrentWorkspaceEditor
? [{
name: 'Access Config',
href: `/app/${appId}/access-config`,
icon: RiUserSettingsLine,
selectedIcon: RiUserSettingsFill,
}]
: []
),
]
return navConfig
}, [t])

View File

@ -0,0 +1,15 @@
import DatasetAccessConfigPage from '@/app/components/datasets/access-config'
type Props = {
params: Promise<{ datasetId: string }>
}
const AccessConfig = async (props: Props) => {
const params = await props.params
const { datasetId } = params
return <DatasetAccessConfigPage datasetId={datasetId} />
}
export default AccessConfig

View File

@ -9,6 +9,8 @@ import {
RiFileTextLine,
RiFocus2Fill,
RiFocus2Line,
RiUserSettingsFill,
RiUserSettingsLine,
} from '@remixicon/react'
import * as React from 'react'
import { useEffect, useMemo, useState } from 'react'
@ -83,6 +85,13 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
selectedIcon: RiEqualizer2Fill,
disabled: false,
},
{
name: 'Access Config',
href: `/datasets/${datasetId}/access-config`,
icon: RiUserSettingsLine,
selectedIcon: RiUserSettingsFill,
disabled: false,
},
]
if (datasetRes?.provider !== 'external') {

View File

@ -1,9 +1,6 @@
'use client'
import type {
AccessRule,
AssignedRole,
} from '@/app/components/header/account-setting/access-rules-page/access-rule-row'
import type { AccessRule } from '@/app/components/header/account-setting/access-rules-page/access-rule-row'
import { Button } from '@langgenius/dify-ui/button'
import {
Dialog,
@ -14,8 +11,7 @@ import {
} 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'
import AccessRulesEditor from '@/app/components/access-rules-editor'
export type AccessConfigModalProps = {
open: boolean
@ -46,38 +42,6 @@ const AccessConfigModalBody = ({
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)
@ -105,17 +69,7 @@ const AccessConfigModalBody = ({
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>
<AccessRulesEditor rules={rules} onRulesChange={setRules} />
</ScrollArea>
<div className="flex shrink-0 items-center justify-end gap-2 border-t border-divider-subtle px-6 py-4">
@ -126,17 +80,6 @@ const AccessConfigModalBody = ({
{saveLabel}
</Button>
</div>
{addingRule && (
<AddRuleTargetsModal
open
ruleName={addingRule.name}
initialRoleIds={addingRule.assignedRoles.map(role => role.id)}
initialMemberIds={[]}
onClose={handleCloseAddModal}
onSubmit={handleAddSubmit}
/>
)}
</DialogContent>
)
}
@ -159,15 +102,17 @@ const AccessConfigModal = ({
onClose()
}}
>
<AccessConfigModalBody
title={title}
description={description}
initialRules={initialRules}
saveLabel={saveLabel}
cancelLabel={cancelLabel}
onClose={onClose}
onSave={onSave}
/>
{open && (
<AccessConfigModalBody
title={title}
description={description}
initialRules={initialRules}
saveLabel={saveLabel}
cancelLabel={cancelLabel}
onClose={onClose}
onSave={onSave}
/>
)}
</Dialog>
)
}

View File

@ -0,0 +1,103 @@
'use client'
import type {
AccessRule,
AssignedRole,
} from '@/app/components/header/account-setting/access-rules-page/access-rule-row'
import { cn } from '@langgenius/dify-ui/cn'
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 AccessRulesEditorProps = {
rules: AccessRule[]
/**
* Called whenever assigned roles/members are mutated. The editor is
* controlled when this callback is provided, uncontrolled (with internal
* state seeded from `rules`) otherwise.
*/
onRulesChange?: (rules: AccessRule[]) => void
className?: string
}
const AccessRulesEditor = ({
rules: rulesProp,
onRulesChange,
className,
}: AccessRulesEditorProps) => {
const isControlled = typeof onRulesChange === 'function'
const [internalRules, setInternalRules] = useState<AccessRule[]>(rulesProp)
const rules = isControlled ? rulesProp : internalRules
const updateRules = useCallback(
(updater: (prev: AccessRule[]) => AccessRule[]) => {
if (isControlled) {
onRulesChange(updater(rulesProp))
return
}
setInternalRules(prev => updater(prev))
},
[isControlled, onRulesChange, rulesProp],
)
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) => {
updateRules(prev =>
prev.map(rule =>
rule.id === target.id
? {
...rule,
assignedRoles: rule.assignedRoles.filter(r => r.id !== role.id),
}
: rule,
),
)
},
[updateRules],
)
return (
<div className={cn('flex flex-col', className)}>
{rules.map((rule, index) => (
<AccessRuleRow
key={rule.id}
rule={rule}
showMenu={false}
onAddRole={handleAddRole}
onRemoveRole={handleRemoveRole}
className={cn(index > 0 && 'border-t border-divider-subtle')}
/>
))}
{addingRule && (
<AddRuleTargetsModal
open
ruleName={addingRule.name}
initialRoleIds={addingRule.assignedRoles.map(role => role.id)}
initialMemberIds={[]}
onClose={handleCloseAddModal}
onSubmit={handleAddSubmit}
/>
)}
</div>
)
}
export default AccessRulesEditor

View File

@ -0,0 +1,74 @@
'use client'
import type { AccessRule } from '@/app/components/header/account-setting/access-rules-page/access-rule-row'
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
import AccessRulesEditor from '@/app/components/access-rules-editor'
// TODO: replace with the per-app access rules fetched from the access-rules
// API once available. Mirrors the workspace-level App access rules catalog.
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: '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: 'app-editor', name: 'App Editor' },
{ id: 'it-staff', name: 'IT Staff' },
],
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: 'tester', name: 'Tester' },
{ id: 'ops-staff', name: 'Ops Staff' },
{ id: 'member', name: 'Member' },
],
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: 'partner', name: 'Partner' },
],
permissions: [],
},
]
type AppAccessConfigPageProps = {
appId: string
}
const AppAccessConfigPage = ({ appId: _appId }: AppAccessConfigPageProps) => {
return (
<ScrollArea
className="h-full bg-components-panel-bg"
slotClassNames={{ viewport: 'overscroll-contain' }}
>
<div className="w-full px-16 py-8">
<h1 className="title-2xl-semi-bold text-text-primary">Access Config</h1>
<div className="mt-6">
<AccessRulesEditor rules={DEFAULT_APP_ACCESS_RULES} />
</div>
</div>
</ScrollArea>
)
}
export default AppAccessConfigPage

View File

@ -0,0 +1,83 @@
'use client'
import type { AccessRule } from '@/app/components/header/account-setting/access-rules-page/access-rule-row'
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
import AccessRulesEditor from '@/app/components/access-rules-editor'
// TODO: replace with the per-knowledge-base access rules fetched from the
// access-rules API once available. Mirrors the workspace-level Knowledge Base
// access rules catalog.
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: 'kb-admin', name: 'KB 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: 'kb-editor', name: 'KB Editor' },
{ id: 'ops-staff', name: 'Ops Staff' },
{ id: 'it-staff', name: 'IT Staff' },
],
permissions: [],
},
{
id: 'kb-can-view-and-use',
name: 'Can view & use',
description: 'View knowledge base sources, configs, and logs. Cannot modify content.',
assignedRoles: [
{ id: 'member', name: 'Member' },
],
permissions: [],
},
{
id: 'kb-can-preview',
name: 'Can preview',
description: 'View in the list only. Cannot access the detail page.',
assignedRoles: [
{ id: 'partner', name: 'Partner' },
],
permissions: [],
},
{
id: 'kb-can-test',
name: 'Can test',
description: 'Test knowledge base retrieval efficiency in sandbox.',
assignedRoles: [
{ id: 'tester', name: 'Tester' },
],
permissions: [],
},
]
type DatasetAccessConfigPageProps = {
datasetId: string
}
const DatasetAccessConfigPage = ({ datasetId: _datasetId }: DatasetAccessConfigPageProps) => {
return (
<ScrollArea
className="h-full bg-components-panel-bg"
slotClassNames={{ viewport: 'overscroll-contain' }}
>
<div className="px-12 py-8">
<h1 className="title-2xl-semi-bold text-text-primary">Access Config</h1>
<div className="mt-6">
<AccessRulesEditor rules={DEFAULT_KB_ACCESS_RULES} />
</div>
</div>
</ScrollArea>
)
}
export default DatasetAccessConfigPage