mirror of
https://github.com/langgenius/dify.git
synced 2026-09-03 06:53:26 +08:00
feat: app access point permission (#41631)
Co-authored-by: fatelei <fatelei@gmail.com>
This commit is contained in:
parent
183741b5f7
commit
82d86dcf98
@ -487,6 +487,8 @@ _LEGACY_APP_OWNER_KEYS: list[str] = [
|
||||
"app.acl.access_config",
|
||||
"app.acl.tracing_config",
|
||||
"app.acl.log_and_annotation",
|
||||
"app.acl.access_point_manage",
|
||||
"app.acl.access_point_view",
|
||||
]
|
||||
|
||||
_LEGACY_APP_ADMIN_KEYS: list[str] = [
|
||||
@ -502,6 +504,8 @@ _LEGACY_APP_ADMIN_KEYS: list[str] = [
|
||||
"app.acl.access_config",
|
||||
"app.acl.tracing_config",
|
||||
"app.acl.log_and_annotation",
|
||||
"app.acl.access_point_manage",
|
||||
"app.acl.access_point_view",
|
||||
]
|
||||
|
||||
_LEGACY_APP_EDITOR_KEYS: list[str] = [
|
||||
@ -515,10 +519,17 @@ _LEGACY_APP_EDITOR_KEYS: list[str] = [
|
||||
"app.acl.monitor",
|
||||
"app.acl.log_and_annotation",
|
||||
"app.acl.access_config",
|
||||
"app.acl.access_point_manage",
|
||||
"app.acl.access_point_view",
|
||||
]
|
||||
|
||||
_LEGACY_APP_NORMAL_KEYS: list[str] = [
|
||||
"app.acl.monitor",
|
||||
"app.acl.access_point_view",
|
||||
]
|
||||
|
||||
_LEGACY_APP_DATASET_OPERATOR_KEYS: list[str] = [
|
||||
"app.acl.access_point_view",
|
||||
]
|
||||
|
||||
_LEGACY_DATASET_OWNER_KEYS: list[str] = [
|
||||
@ -600,6 +611,7 @@ _LEGACY_MY_PERMISSIONS: dict[TenantAccountRole, dict[str, list[str]]] = {
|
||||
},
|
||||
TenantAccountRole.DATASET_OPERATOR: {
|
||||
"workspace": _LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS,
|
||||
"app": _LEGACY_APP_DATASET_OPERATOR_KEYS,
|
||||
"dataset": _LEGACY_DATASET_DATASET_OPERATOR_KEYS,
|
||||
},
|
||||
}
|
||||
|
||||
@ -777,7 +777,7 @@ class TestMyPermissions:
|
||||
(
|
||||
"dataset_operator",
|
||||
svc._LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS,
|
||||
[],
|
||||
svc._LEGACY_APP_DATASET_OPERATOR_KEYS,
|
||||
svc._LEGACY_DATASET_DATASET_OPERATOR_KEYS,
|
||||
),
|
||||
],
|
||||
|
||||
@ -238,9 +238,11 @@ describe('AppDetailLayout', () => {
|
||||
expect(useStore.getState().appDetail?.id).toBe('app-1')
|
||||
})
|
||||
|
||||
it('should allow access point pages without app deploy or app ACL permissions', async () => {
|
||||
it('should allow users with Access Point view permission to open the page directly', async () => {
|
||||
mockPathname = '/app/app-1/access-point'
|
||||
mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ permission_keys: [] }))
|
||||
mockFetchAppDetailDirect.mockResolvedValue(
|
||||
createAppDetail({ permission_keys: [AppACLPermission.AccessPointView] }),
|
||||
)
|
||||
|
||||
render(
|
||||
<AppDetailLayout appId="app-1">
|
||||
@ -254,6 +256,40 @@ describe('AppDetailLayout', () => {
|
||||
expect(useStore.getState().appDetail?.id).toBe('app-1')
|
||||
})
|
||||
|
||||
it('should redirect access point pages when view permission is missing', async () => {
|
||||
mockPathname = '/app/app-1/access-point'
|
||||
mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ permission_keys: [] }))
|
||||
|
||||
render(
|
||||
<AppDetailLayout appId="app-1">
|
||||
<div>App page content</div>
|
||||
</AppDetailLayout>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockReplace).toHaveBeenCalledWith('/apps')
|
||||
})
|
||||
expect(screen.queryByText('App page content')).not.toBeInTheDocument()
|
||||
expect(useStore.getState().appDetail).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should keep cached Access Point content hidden while redirecting without view permission', async () => {
|
||||
mockPathname = '/app/app-1/access-point'
|
||||
useStore.getState().setAppDetail(createAppDetail({ permission_keys: [] }))
|
||||
|
||||
render(
|
||||
<AppDetailLayout appId="app-1">
|
||||
<div>App page content</div>
|
||||
</AppDetailLayout>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('App page content')).not.toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(mockReplace).toHaveBeenCalledWith('/apps')
|
||||
})
|
||||
expect(mockFetchAppDetailDirect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should redirect deploy pages when app deploy ACL permission is missing', async () => {
|
||||
mockPathname = '/app/app-1/deploy'
|
||||
mockFetchAppDetailDirect.mockResolvedValue(
|
||||
@ -317,7 +353,7 @@ describe('AppDetailLayout', () => {
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockReplace).toHaveBeenCalledWith('/app/app-1/access-point')
|
||||
expect(mockReplace).toHaveBeenCalledWith('/apps')
|
||||
})
|
||||
expect(screen.queryByText('App page content')).not.toBeInTheDocument()
|
||||
expect(useStore.getState().appDetail).toBeUndefined()
|
||||
@ -478,7 +514,9 @@ describe('AppDetailLayout', () => {
|
||||
mockIsRbacEnabled = false
|
||||
mockPathname = '/app/app-1/access-config'
|
||||
mockFetchAppDetailDirect.mockResolvedValue(
|
||||
createAppDetail({ permission_keys: [AppACLPermission.AccessConfig] }),
|
||||
createAppDetail({
|
||||
permission_keys: [AppACLPermission.AccessConfig, AppACLPermission.AccessPointView],
|
||||
}),
|
||||
)
|
||||
|
||||
render(
|
||||
|
||||
@ -80,6 +80,20 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
const appName = routeAppDetail?.id === appId ? routeAppDetail.name : undefined
|
||||
const shouldBlockAgentResourceAccess =
|
||||
routeAppDetail?.mode === AppModeEnum.AGENT && pathname.endsWith('/access-config')
|
||||
const canViewAccessPoint =
|
||||
routeAppDetail?.id === appId &&
|
||||
currentWorkspace.id &&
|
||||
!isLoadingCurrentWorkspace &&
|
||||
!isLoadingWorkspacePermissionKeys &&
|
||||
!isLoadingAppDetail
|
||||
? getAppACLCapabilities(routeAppDetail.permission_keys, {
|
||||
currentUserId,
|
||||
resourceMaintainer: routeAppDetail.maintainer,
|
||||
workspacePermissionKeys,
|
||||
isRbacEnabled,
|
||||
}).canViewAccessPoint
|
||||
: false
|
||||
const shouldBlockAccessPointAccess = pathname.endsWith('/access-point') && !canViewAccessPoint
|
||||
|
||||
useDocumentTitle(`${pageTitle} · ${appName || t(($) => $['menus.appDetail'], { ns: 'common' })}`)
|
||||
|
||||
@ -141,6 +155,7 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
const isAnnotationsPath = pathname.endsWith('annotations')
|
||||
const isOverviewPath = pathname.endsWith('overview')
|
||||
const isAccessConfigPath = pathname.endsWith('access-config')
|
||||
const isAccessPointPath = pathname.endsWith('access-point')
|
||||
const isDeployPath = pathname.endsWith('deploy')
|
||||
if (
|
||||
(isLayoutPath && !appACLCapabilities.canAccessLayout) ||
|
||||
@ -149,6 +164,7 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
(isOverviewPath && !appACLCapabilities.canMonitor) ||
|
||||
(isAccessConfigPath &&
|
||||
(routeAppDetail.mode === AppModeEnum.AGENT || !appACLCapabilities.canAccessConfig)) ||
|
||||
(isAccessPointPath && !appACLCapabilities.canViewAccessPoint) ||
|
||||
(isDeployPath &&
|
||||
(routeAppDetail.mode !== AppModeEnum.WORKFLOW || !appACLCapabilities.canDeploy))
|
||||
) {
|
||||
@ -198,7 +214,7 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
|
||||
const isWorkflowPage = pathname.endsWith('/workflow')
|
||||
const content =
|
||||
!appDetail || shouldBlockAgentResourceAccess ? (
|
||||
!appDetail || shouldBlockAgentResourceAccess || shouldBlockAccessPointAccess ? (
|
||||
<div className="flex min-w-0 grow items-center justify-center bg-background-body">
|
||||
<Loading />
|
||||
</div>
|
||||
|
||||
@ -187,6 +187,8 @@ describe('AppDetailSection', () => {
|
||||
})
|
||||
|
||||
it('should render access point navigation using its app route', () => {
|
||||
mockAppPermissionKeys = [AppACLPermission.AccessPointView]
|
||||
|
||||
// Act
|
||||
render(<AppDetailSection />)
|
||||
|
||||
@ -200,6 +202,14 @@ describe('AppDetailSection', () => {
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide access point navigation without view permission', () => {
|
||||
render(<AppDetailSection />)
|
||||
|
||||
expect(
|
||||
screen.queryByRole('link', { name: 'common.appMenus.accessPoint' }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render deploy navigation with app deploy ACL regardless of the legacy workspace role', () => {
|
||||
// Arrange
|
||||
mockAppMode = 'workflow'
|
||||
|
||||
@ -120,12 +120,16 @@ const AppDetailSection = ({ expand = true }: AppDetailSectionProps) => {
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: t(($) => $['appMenus.accessPoint'], { ns: 'common' }),
|
||||
href: `/app/${appId}/access-point`,
|
||||
icon: accessPointNavIcon,
|
||||
selectedIcon: accessPointNavIcon,
|
||||
},
|
||||
...(appACLCapabilities.canViewAccessPoint
|
||||
? [
|
||||
{
|
||||
name: t(($) => $['appMenus.accessPoint'], { ns: 'common' }),
|
||||
href: `/app/${appId}/access-point`,
|
||||
icon: accessPointNavIcon,
|
||||
selectedIcon: accessPointNavIcon,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(supportsAppDeploy && appACLCapabilities.canDeploy
|
||||
? [
|
||||
{
|
||||
|
||||
@ -17,11 +17,6 @@ const mocks = vi.hoisted(() => ({
|
||||
},
|
||||
webCard: vi.fn(),
|
||||
apiCard: vi.fn(),
|
||||
capabilities: {
|
||||
canEdit: false,
|
||||
canDeploy: true,
|
||||
canReleaseAndVersion: false,
|
||||
},
|
||||
mcpCard: vi.fn(),
|
||||
triggerCard: vi.fn(),
|
||||
useAppWorkflow: vi.fn(),
|
||||
@ -44,14 +39,6 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('jotai', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('jotai')>()
|
||||
return {
|
||||
...actual,
|
||||
useAtomValue: () => undefined,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector: (state: Record<string, unknown>) => unknown) =>
|
||||
selector({ appDetail: mocks.appInfo }),
|
||||
@ -68,10 +55,6 @@ vi.mock('@/service/use-workflow', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/permission', () => ({
|
||||
getAppACLCapabilities: () => mocks.capabilities,
|
||||
}))
|
||||
|
||||
vi.mock('../shared/use-access-point-actions', () => ({
|
||||
useAccessPointActions: () => ({
|
||||
handleAppStateChanged: vi.fn(),
|
||||
@ -124,26 +107,32 @@ describe('BuiltInAccessPoints', () => {
|
||||
isError: false,
|
||||
isPending: false,
|
||||
}
|
||||
mocks.capabilities = {
|
||||
canEdit: false,
|
||||
canDeploy: true,
|
||||
canReleaseAndVersion: false,
|
||||
}
|
||||
})
|
||||
|
||||
it('renders the unpublished state across all access point cards', () => {
|
||||
render(<BuiltInAccessPoints appId="app-1" />)
|
||||
render(
|
||||
<BuiltInAccessPoints
|
||||
appId="app-1"
|
||||
canDeploy
|
||||
canManageAccessPoint={false}
|
||||
canReleaseAndVersion={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('deployments.studio.accessPoint.noPublishedTitle')).toBeInTheDocument()
|
||||
expect(mocks.webCard).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ availability: 'unavailable', canDeploy: true, canEdit: false }),
|
||||
expect.objectContaining({
|
||||
availability: 'unavailable',
|
||||
canDeploy: true,
|
||||
canManageAccessPoint: false,
|
||||
}),
|
||||
)
|
||||
expect(mocks.apiCard).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ availability: 'unavailable', canManage: false }),
|
||||
)
|
||||
expect(mocks.mcpCard).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.triggerCard).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ availability: 'unavailable', canEdit: false }),
|
||||
expect.objectContaining({ availability: 'unavailable', canManageAccessPoint: false }),
|
||||
)
|
||||
})
|
||||
|
||||
@ -158,7 +147,14 @@ describe('BuiltInAccessPoints', () => {
|
||||
isPending: false,
|
||||
}
|
||||
|
||||
render(<BuiltInAccessPoints appId="app-1" />)
|
||||
render(
|
||||
<BuiltInAccessPoints
|
||||
appId="app-1"
|
||||
canDeploy
|
||||
canManageAccessPoint={false}
|
||||
canReleaseAndVersion={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.queryByText('deployments.studio.accessPoint.noPublishedTitle'),
|
||||
@ -174,20 +170,38 @@ describe('BuiltInAccessPoints', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('does not use edit permission to manage the Service API', () => {
|
||||
mocks.capabilities = {
|
||||
canEdit: true,
|
||||
canDeploy: true,
|
||||
canReleaseAndVersion: false,
|
||||
}
|
||||
it('uses Access Point management for every requested built-in operation', () => {
|
||||
render(
|
||||
<BuiltInAccessPoints
|
||||
appId="app-1"
|
||||
canDeploy
|
||||
canManageAccessPoint
|
||||
canReleaseAndVersion={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
render(<BuiltInAccessPoints appId="app-1" />)
|
||||
|
||||
expect(mocks.apiCard).toHaveBeenCalledWith(expect.objectContaining({ canManage: false }))
|
||||
expect(mocks.webCard).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ canManageAccess: false, canManageAccessPoint: true }),
|
||||
)
|
||||
expect(mocks.apiCard).toHaveBeenCalledWith(expect.objectContaining({ canManage: true }))
|
||||
expect(mocks.mcpCard).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ canManageAccessPoint: true }),
|
||||
)
|
||||
expect(mocks.triggerCard).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ canManageAccessPoint: true }),
|
||||
)
|
||||
})
|
||||
|
||||
it('highlights only the targeted built-in access point card', () => {
|
||||
render(<BuiltInAccessPoints appId="app-1" highlightedAccessPoint="mcp" />)
|
||||
render(
|
||||
<BuiltInAccessPoints
|
||||
appId="app-1"
|
||||
canDeploy
|
||||
canManageAccessPoint
|
||||
canReleaseAndVersion
|
||||
highlightedAccessPoint="mcp"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mocks.webCard).toHaveBeenCalledWith(expect.objectContaining({ highlighted: false }))
|
||||
expect(mocks.apiCard).toHaveBeenCalledWith(expect.objectContaining({ highlighted: false }))
|
||||
@ -206,7 +220,9 @@ describe('BuiltInAccessPoints', () => {
|
||||
isPending: false,
|
||||
}
|
||||
|
||||
render(<BuiltInAccessPoints appId="app-1" />)
|
||||
render(
|
||||
<BuiltInAccessPoints appId="app-1" canDeploy canManageAccessPoint canReleaseAndVersion />,
|
||||
)
|
||||
|
||||
expect(mocks.webCard).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ availability: 'unavailable' }),
|
||||
@ -232,7 +248,9 @@ describe('BuiltInAccessPoints', () => {
|
||||
isPending: true,
|
||||
}
|
||||
|
||||
render(<BuiltInAccessPoints appId="app-1" />)
|
||||
render(
|
||||
<BuiltInAccessPoints appId="app-1" canDeploy canManageAccessPoint canReleaseAndVersion />,
|
||||
)
|
||||
|
||||
expect(mocks.webCard).toHaveBeenCalledWith(expect.objectContaining({ availability: 'loading' }))
|
||||
expect(mocks.apiCard).toHaveBeenCalledWith(expect.objectContaining({ availability: 'loading' }))
|
||||
@ -248,7 +266,9 @@ describe('BuiltInAccessPoints', () => {
|
||||
isPending: false,
|
||||
}
|
||||
|
||||
render(<BuiltInAccessPoints appId="app-1" />)
|
||||
render(
|
||||
<BuiltInAccessPoints appId="app-1" canDeploy canManageAccessPoint canReleaseAndVersion />,
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.queryByText('deployments.studio.accessPoint.noPublishedTitle'),
|
||||
@ -256,7 +276,9 @@ describe('BuiltInAccessPoints', () => {
|
||||
})
|
||||
|
||||
it('does not retry forbidden published workflow requests', () => {
|
||||
render(<BuiltInAccessPoints appId="app-1" />)
|
||||
render(
|
||||
<BuiltInAccessPoints appId="app-1" canDeploy canManageAccessPoint canReleaseAndVersion />,
|
||||
)
|
||||
|
||||
const options = mocks.useAppWorkflow.mock.calls.at(-1)?.[1] as {
|
||||
retry: (failureCount: number, error: unknown) => boolean
|
||||
|
||||
@ -39,8 +39,8 @@ describe('DeployedEnvironmentAccessPoints', () => {
|
||||
<DeployedEnvironmentAccessPoints
|
||||
appId="app-1"
|
||||
environmentId="staging"
|
||||
canEdit
|
||||
canManage
|
||||
canManageAccessPoint
|
||||
canReleaseAndVersion
|
||||
highlightedAccessPoint={highlightedAccessPoint}
|
||||
/>,
|
||||
)
|
||||
@ -56,7 +56,12 @@ describe('DeployedEnvironmentAccessPoints', () => {
|
||||
|
||||
it('renders MCP and Trigger as unsupported without a permanent loading state', () => {
|
||||
render(
|
||||
<DeployedEnvironmentAccessPoints appId="app-1" environmentId="staging" canEdit canManage />,
|
||||
<DeployedEnvironmentAccessPoints
|
||||
appId="app-1"
|
||||
environmentId="staging"
|
||||
canManageAccessPoint
|
||||
canReleaseAndVersion
|
||||
/>,
|
||||
)
|
||||
|
||||
const mcpCard = screen.getByRole('region', { name: /mcp\.server\.title/ })
|
||||
@ -76,4 +81,25 @@ describe('DeployedEnvironmentAccessPoints', () => {
|
||||
expect(card.querySelector('[aria-busy="true"]')).not.toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
it('passes the Built-in permission split to deployed environment cards', () => {
|
||||
render(
|
||||
<DeployedEnvironmentAccessPoints
|
||||
appId="app-1"
|
||||
environmentId="staging"
|
||||
canManageAccessPoint
|
||||
canReleaseAndVersion={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mocks.webAppCard).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
canManageAccessPoint: true,
|
||||
canReleaseAndVersion: false,
|
||||
}),
|
||||
)
|
||||
expect(mocks.serviceApiCard).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ canManageAccessPoint: true }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -228,7 +228,14 @@ describe('environment access point cards', () => {
|
||||
})
|
||||
|
||||
it('renders the real environment Web app URL and workflow actions without Embed', async () => {
|
||||
renderCard(<EnvironmentWebAppCard appId="app-1" environmentId="staging" canEdit canManage />)
|
||||
renderCard(
|
||||
<EnvironmentWebAppCard
|
||||
appId="app-1"
|
||||
environmentId="staging"
|
||||
canManageAccessPoint
|
||||
canReleaseAndVersion
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(await screen.findByText(/env\/workflow\/site-code/)).toHaveTextContent(
|
||||
'https://site.example.test/env/workflow/site-code',
|
||||
@ -249,7 +256,14 @@ describe('environment access point cards', () => {
|
||||
access_mode: 'sso_verified',
|
||||
})
|
||||
|
||||
renderCard(<EnvironmentWebAppCard appId="app-1" environmentId="staging" canEdit canManage />)
|
||||
renderCard(
|
||||
<EnvironmentWebAppCard
|
||||
appId="app-1"
|
||||
environmentId="staging"
|
||||
canManageAccessPoint
|
||||
canReleaseAndVersion
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', {
|
||||
@ -261,7 +275,14 @@ describe('environment access point cards', () => {
|
||||
it('shows the environment Web app query as loading instead of failed', () => {
|
||||
mocks.getSite.mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
renderCard(<EnvironmentWebAppCard appId="app-1" environmentId="staging" canEdit canManage />)
|
||||
renderCard(
|
||||
<EnvironmentWebAppCard
|
||||
appId="app-1"
|
||||
environmentId="staging"
|
||||
canManageAccessPoint
|
||||
canReleaseAndVersion
|
||||
/>,
|
||||
)
|
||||
|
||||
const card = screen.getByRole('region', { name: /webApp\.title/ })
|
||||
expect(card).toHaveAttribute('aria-busy', 'true')
|
||||
@ -273,7 +294,14 @@ describe('environment access point cards', () => {
|
||||
|
||||
it('uses environment Site mutations for status and URL reset, and opens its access container', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderCard(<EnvironmentWebAppCard appId="app-1" environmentId="staging" canEdit canManage />)
|
||||
renderCard(
|
||||
<EnvironmentWebAppCard
|
||||
appId="app-1"
|
||||
environmentId="staging"
|
||||
canManageAccessPoint
|
||||
canReleaseAndVersion
|
||||
/>,
|
||||
)
|
||||
|
||||
const accessModeButton = await screen.findByRole('button', {
|
||||
name: /accessControlDialog\.accessItems\.specific/,
|
||||
@ -313,7 +341,14 @@ describe('environment access point cards', () => {
|
||||
|
||||
it('opens Customize and Settings with environment endpoint data', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderCard(<EnvironmentWebAppCard appId="app-1" environmentId="staging" canEdit canManage />)
|
||||
renderCard(
|
||||
<EnvironmentWebAppCard
|
||||
appId="app-1"
|
||||
environmentId="staging"
|
||||
canManageAccessPoint
|
||||
canReleaseAndVersion={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
await screen.findByText(/env\/workflow\/site-code/)
|
||||
await user.click(screen.getByRole('button', { name: /customize\.entry/ }))
|
||||
@ -325,9 +360,67 @@ describe('environment access point cards', () => {
|
||||
expect(screen.getByRole('dialog', { name: 'environment settings' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps view actions available while disabling deployed Web App management', async () => {
|
||||
renderCard(
|
||||
<EnvironmentWebAppCard
|
||||
appId="app-1"
|
||||
environmentId="staging"
|
||||
canManageAccessPoint={false}
|
||||
canReleaseAndVersion={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(await screen.findByRole('switch')).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(screen.getByRole('link', { name: /studio\.accessPoint\.open/ })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: /regenerate/ })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: /customize\.entry/ })).toBeDisabled()
|
||||
expect(
|
||||
screen.getByRole('button', { name: /accessControlDialog\.accessItems\.specific/ }),
|
||||
).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: /settings\.settings/ })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('uses Access Point management for deployed Web App operations without access management', async () => {
|
||||
renderCard(
|
||||
<EnvironmentWebAppCard
|
||||
appId="app-1"
|
||||
environmentId="staging"
|
||||
canManageAccessPoint
|
||||
canReleaseAndVersion={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(await screen.findByRole('switch')).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: /regenerate/ })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: /customize\.entry/ })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: /settings\.settings/ })).toBeEnabled()
|
||||
expect(
|
||||
screen.getByRole('button', { name: /accessControlDialog\.accessItems\.specific/ }),
|
||||
).toBeDisabled()
|
||||
})
|
||||
|
||||
it('uses Web App access management independently from Access Point management', async () => {
|
||||
renderCard(
|
||||
<EnvironmentWebAppCard
|
||||
appId="app-1"
|
||||
environmentId="staging"
|
||||
canManageAccessPoint={false}
|
||||
canReleaseAndVersion
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(await screen.findByRole('switch')).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(screen.getByRole('button', { name: /regenerate/ })).toBeDisabled()
|
||||
expect(
|
||||
screen.getByRole('button', { name: /accessControlDialog\.accessItems\.specific/ }),
|
||||
).toBeEnabled()
|
||||
})
|
||||
|
||||
it('renders the real Service API endpoint, environment keys entry, docs entry, and API toggle', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderCard(<EnvironmentServiceApiCard appId="app-1" environmentId="staging" canManage />)
|
||||
renderCard(
|
||||
<EnvironmentServiceApiCard appId="app-1" environmentId="staging" canManageAccessPoint />,
|
||||
)
|
||||
|
||||
expect(await screen.findByText(api.base_url)).toBeInTheDocument()
|
||||
expect(mocks.apiKeyButtonProps).toHaveBeenLastCalledWith(
|
||||
@ -365,7 +458,9 @@ describe('environment access point cards', () => {
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
renderCard(<EnvironmentServiceApiCard appId="app-1" environmentId="staging" canManage />)
|
||||
renderCard(
|
||||
<EnvironmentServiceApiCard appId="app-1" environmentId="staging" canManageAccessPoint />,
|
||||
)
|
||||
|
||||
await screen.findByText(api.base_url)
|
||||
expect(await screen.findByRole('button', { name: 'environment-api-keys' })).toBeEnabled()
|
||||
@ -379,7 +474,9 @@ describe('environment access point cards', () => {
|
||||
it('distinguishes the Service API loading and failed query states', async () => {
|
||||
mocks.getApi.mockRejectedValue(new Error('API unavailable'))
|
||||
|
||||
renderCard(<EnvironmentServiceApiCard appId="app-1" environmentId="staging" canManage />)
|
||||
renderCard(
|
||||
<EnvironmentServiceApiCard appId="app-1" environmentId="staging" canManageAccessPoint />,
|
||||
)
|
||||
|
||||
const card = screen.getByRole('region', { name: /serviceApi\.title/ })
|
||||
expect(card).toHaveAttribute('aria-busy', 'true')
|
||||
@ -396,4 +493,22 @@ describe('environment access point cards', () => {
|
||||
expect(screen.getByRole('button', { name: 'environment-api-keys' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: /apiInfo\.doc/ })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('disables deployed Service API management without Access Point management', async () => {
|
||||
renderCard(
|
||||
<EnvironmentServiceApiCard
|
||||
appId="app-1"
|
||||
environmentId="staging"
|
||||
canManageAccessPoint={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(await screen.findByText(api.base_url)).toBeInTheDocument()
|
||||
expect(screen.getByRole('switch')).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(screen.getByRole('button', { name: 'environment-api-keys' })).toBeDisabled()
|
||||
expect(screen.getByRole('link', { name: /apiInfo\.doc/ })).toBeEnabled()
|
||||
expect(mocks.apiKeyButtonProps).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ canManage: false }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -14,7 +14,7 @@ import { AppACLPermission } from '@/utils/permission'
|
||||
import AccessPoint from '..'
|
||||
|
||||
let appMode = 'workflow'
|
||||
let appPermissionKeys: string[] = [AppACLPermission.Deploy]
|
||||
let appPermissionKeys: string[] = [AppACLPermission.AccessPointView]
|
||||
const accessPointMocks = vi.hoisted(() => ({
|
||||
builtIn: vi.fn(),
|
||||
deployed: vi.fn(),
|
||||
@ -49,7 +49,13 @@ vi.mock('@/context/permission-state', async () => {
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/app/access-point/built-in-access-points', () => ({
|
||||
BuiltInAccessPoints: (props: { appId: string; highlightedAccessPoint?: AccessPointType }) => {
|
||||
BuiltInAccessPoints: (props: {
|
||||
appId: string
|
||||
canDeploy: boolean
|
||||
canManageAccessPoint: boolean
|
||||
canReleaseAndVersion: boolean
|
||||
highlightedAccessPoint?: AccessPointType
|
||||
}) => {
|
||||
accessPointMocks.builtIn(props)
|
||||
return null
|
||||
},
|
||||
@ -58,8 +64,8 @@ vi.mock('@/app/components/app/access-point/built-in-access-points', () => ({
|
||||
vi.mock('@/app/components/app/access-point/deployed-environment-access-points', () => ({
|
||||
DeployedEnvironmentAccessPoints: (props: {
|
||||
appId: string
|
||||
canEdit: boolean
|
||||
canManage: boolean
|
||||
canManageAccessPoint: boolean
|
||||
canReleaseAndVersion: boolean
|
||||
environmentId: string
|
||||
highlightedAccessPoint?: AccessPointType
|
||||
}) => {
|
||||
@ -130,7 +136,7 @@ describe('AccessPoint', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
appMode = 'workflow'
|
||||
appPermissionKeys = [AppACLPermission.Deploy]
|
||||
appPermissionKeys = [AppACLPermission.AccessPointView]
|
||||
})
|
||||
|
||||
it('renders Built-in and only in-use environments from the API', () => {
|
||||
@ -210,7 +216,7 @@ describe('AccessPoint', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the selected deployed environment with deploy permissions', () => {
|
||||
it('shows the selected deployed environment with Access Point view permission', () => {
|
||||
renderAccessPoint({
|
||||
searchParams: '?environment=canary',
|
||||
})
|
||||
@ -218,8 +224,8 @@ describe('AccessPoint', () => {
|
||||
expect(accessPointMocks.deployed).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
appId: 'app-1',
|
||||
canEdit: false,
|
||||
canManage: true,
|
||||
canManageAccessPoint: false,
|
||||
canReleaseAndVersion: false,
|
||||
environmentId: 'canary',
|
||||
}),
|
||||
)
|
||||
@ -249,7 +255,7 @@ describe('AccessPoint', () => {
|
||||
expect(accessPointMocks.deployed).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to built-in access points without app deploy ACL permission', () => {
|
||||
it('hides environment tabs without Access Point view permission', () => {
|
||||
appPermissionKeys = []
|
||||
|
||||
renderAccessPoint({
|
||||
@ -260,4 +266,33 @@ describe('AccessPoint', () => {
|
||||
expect(accessPointMocks.builtIn).toHaveBeenCalledTimes(1)
|
||||
expect(accessPointMocks.deployed).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens deployed environments with Access Point management independently from deploy', () => {
|
||||
appPermissionKeys = [AppACLPermission.AccessPointManage]
|
||||
|
||||
renderAccessPoint({ searchParams: '?environment=canary' })
|
||||
|
||||
expect(screen.getByRole('tab', { name: 'Canary' })).toHaveAttribute('aria-selected', 'true')
|
||||
expect(accessPointMocks.deployed).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
canManageAccessPoint: true,
|
||||
canReleaseAndVersion: false,
|
||||
environmentId: 'canary',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('passes Web App access management independently from Access Point management', () => {
|
||||
appPermissionKeys = [AppACLPermission.AccessPointView, AppACLPermission.ReleaseAndVersion]
|
||||
|
||||
renderAccessPoint({ searchParams: '?environment=canary' })
|
||||
|
||||
expect(accessPointMocks.deployed).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
canManageAccessPoint: false,
|
||||
canReleaseAndVersion: true,
|
||||
environmentId: 'canary',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -94,7 +94,7 @@ describe('MCPAccessPointCard', () => {
|
||||
render(
|
||||
<MCPAccessPointCard
|
||||
appInfo={appInfo}
|
||||
canEdit
|
||||
canManageAccessPoint
|
||||
triggerModeDisabled={false}
|
||||
workflow={undefined}
|
||||
workflowLoading={false}
|
||||
@ -125,7 +125,7 @@ describe('MCPAccessPointCard', () => {
|
||||
render(
|
||||
<MCPAccessPointCard
|
||||
appInfo={workflowAppInfo}
|
||||
canEdit
|
||||
canManageAccessPoint
|
||||
triggerModeDisabled={false}
|
||||
workflow={publishedWorkflow}
|
||||
workflowLoading={false}
|
||||
@ -147,7 +147,7 @@ describe('MCPAccessPointCard', () => {
|
||||
render(
|
||||
<MCPAccessPointCard
|
||||
appInfo={workflowAppInfo}
|
||||
canEdit
|
||||
canManageAccessPoint
|
||||
triggerModeDisabled={false}
|
||||
workflow={publishedWorkflow}
|
||||
workflowLoading={false}
|
||||
|
||||
@ -156,7 +156,7 @@ describe('ServiceApiAccessPointCard', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('disables API management without release permission', () => {
|
||||
it('disables API management without Access Point management permission', () => {
|
||||
renderWithQueryClient(
|
||||
<ServiceApiAccessPointCard
|
||||
appInfo={createAppInfo(AppModeEnum.WORKFLOW)}
|
||||
|
||||
@ -68,7 +68,7 @@ function renderCard(availability: 'available' | 'loading' | 'unavailable') {
|
||||
<TriggerAccessPointCard
|
||||
appInfo={appInfo}
|
||||
availability={availability}
|
||||
canEdit
|
||||
canManageAccessPoint
|
||||
onToggleResult={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -69,9 +69,9 @@ const siteConfig = {
|
||||
use_icon_as_answer_icon: false,
|
||||
} satisfies ConfigParams
|
||||
|
||||
function renderActions(appId = 'app-1', canEdit = true) {
|
||||
function renderActions(appId = 'app-1', canManageAccessPoint = true) {
|
||||
const queryClient = createTestQueryClient()
|
||||
const rendered = renderHook(() => useAccessPointActions(appId, canEdit), {
|
||||
const rendered = renderHook(() => useAccessPointActions(appId, canManageAccessPoint), {
|
||||
wrapper: createQueryClientWrapper(queryClient),
|
||||
})
|
||||
|
||||
@ -135,7 +135,7 @@ describe('useAccessPointActions', () => {
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['apps', 'recent'] })
|
||||
})
|
||||
|
||||
it('keeps site configuration behind app editing permission', async () => {
|
||||
it('keeps site configuration behind Access Point management permission', async () => {
|
||||
const { result } = renderActions('app-1', false)
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@ -110,10 +110,10 @@ function renderCard(
|
||||
availability: 'available' | 'loading' | 'unavailable' = 'available',
|
||||
workflow?: PublishedWorkflow,
|
||||
{
|
||||
canEdit = true,
|
||||
canManageAccessPoint = true,
|
||||
onAppStateChanged = vi.fn().mockResolvedValue(undefined),
|
||||
}: {
|
||||
canEdit?: boolean
|
||||
canManageAccessPoint?: boolean
|
||||
onAppStateChanged?: () => Promise<void>
|
||||
} = {},
|
||||
) {
|
||||
@ -122,9 +122,9 @@ function renderCard(
|
||||
<WebAppAccessPointCard
|
||||
appInfo={createAppInfo(mode)}
|
||||
availability={availability}
|
||||
canEdit={canEdit}
|
||||
canDeploy
|
||||
canManageAccess
|
||||
canManageAccessPoint={canManageAccessPoint}
|
||||
showAccessControl
|
||||
onAppStateChanged={onAppStateChanged}
|
||||
onRefreshApp={vi.fn().mockResolvedValue(undefined)}
|
||||
@ -256,9 +256,9 @@ describe('WebAppAccessPointCard', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps site status changes behind app editing permission', async () => {
|
||||
it('keeps site status changes behind Access Point management permission', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderCard(AppModeEnum.CHAT, 'available', undefined, { canEdit: false })
|
||||
renderCard(AppModeEnum.CHAT, 'available', undefined, { canManageAccessPoint: false })
|
||||
|
||||
await user.click(screen.getByRole('switch'))
|
||||
|
||||
@ -332,4 +332,18 @@ describe('WebAppAccessPointCard', () => {
|
||||
screen.queryByText('deployments.health.ENVIRONMENT_STATUS_FAILED'),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables Web App management actions without Access Point management', () => {
|
||||
renderCard(AppModeEnum.CHAT, 'available', undefined, { canManageAccessPoint: false })
|
||||
|
||||
expect(screen.getByRole('switch')).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(screen.getByRole('button', { name: /embedIntoSite/ })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: /customize\.entry/ })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: /settings\.settings/ })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: /regenerate/ })).toBeDisabled()
|
||||
expect(screen.getByRole('link', { name: /studio\.accessPoint\.open/ })).toBeEnabled()
|
||||
expect(
|
||||
screen.getByRole('button', { name: /accessControlDialog\.accessItems\.anyone/ }),
|
||||
).toBeEnabled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -4,18 +4,13 @@ import type { AccessPoint } from '@/app/components/app/deploy/access-point'
|
||||
import { Button, buttonVariants } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { workspacePermissionKeysAtom } from '@/context/permission-state'
|
||||
import { userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import Link from '@/next/link'
|
||||
import { useAppWorkflow } from '@/service/use-workflow'
|
||||
import { getAppACLCapabilities } from '@/utils/permission'
|
||||
import { useAccessPointActions } from '../shared/use-access-point-actions'
|
||||
import { getPublishedWorkflowState, isAdvancedApp } from '../shared/utils'
|
||||
import { MCPAccessPointCard } from './mcp-card'
|
||||
@ -25,18 +20,22 @@ import { WebAppAccessPointCard } from './web-app-card'
|
||||
|
||||
type BuiltInAccessPointsProps = {
|
||||
appId: string
|
||||
canDeploy: boolean
|
||||
canManageAccessPoint: boolean
|
||||
canReleaseAndVersion: boolean
|
||||
highlightedAccessPoint?: AccessPoint | null
|
||||
}
|
||||
|
||||
export function BuiltInAccessPoints({ appId, highlightedAccessPoint }: BuiltInAccessPointsProps) {
|
||||
export function BuiltInAccessPoints({
|
||||
appId,
|
||||
canDeploy,
|
||||
canManageAccessPoint,
|
||||
canReleaseAndVersion,
|
||||
highlightedAccessPoint,
|
||||
}: BuiltInAccessPointsProps) {
|
||||
const { t } = useTranslation()
|
||||
const docLink = useDocLink()
|
||||
const appInfo = useAppStore((state) => state.appDetail)
|
||||
const { data: currentUserId } = useSuspenseQuery({
|
||||
...userProfileQueryOptions(),
|
||||
select: (data) => data.profile.id,
|
||||
})
|
||||
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const shouldFetchWorkflow = Boolean(appInfo && isAdvancedApp(appInfo))
|
||||
const {
|
||||
@ -50,16 +49,7 @@ export function BuiltInAccessPoints({ appId, highlightedAccessPoint }: BuiltInAc
|
||||
return failureCount < 3
|
||||
},
|
||||
})
|
||||
const capabilities = useMemo(
|
||||
() =>
|
||||
getAppACLCapabilities(appInfo?.permission_keys, {
|
||||
currentUserId,
|
||||
resourceMaintainer: appInfo?.maintainer,
|
||||
workspacePermissionKeys,
|
||||
}),
|
||||
[appInfo?.maintainer, appInfo?.permission_keys, currentUserId, workspacePermissionKeys],
|
||||
)
|
||||
const actions = useAccessPointActions(appId, capabilities.canEdit)
|
||||
const actions = useAccessPointActions(appId, canManageAccessPoint)
|
||||
|
||||
if (!appInfo) return <Loading />
|
||||
|
||||
@ -94,7 +84,7 @@ export function BuiltInAccessPoints({ appId, highlightedAccessPoint }: BuiltInAc
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{capabilities.canReleaseAndVersion ? (
|
||||
{canReleaseAndVersion ? (
|
||||
<Link
|
||||
href={`/app/${appId}/workflow`}
|
||||
className={cn(
|
||||
@ -118,9 +108,9 @@ export function BuiltInAccessPoints({ appId, highlightedAccessPoint }: BuiltInAc
|
||||
<WebAppAccessPointCard
|
||||
appInfo={appInfo}
|
||||
availability={appCardAvailability}
|
||||
canEdit={capabilities.canEdit}
|
||||
canDeploy={capabilities.canDeploy}
|
||||
canManageAccess={capabilities.canReleaseAndVersion}
|
||||
canDeploy={canDeploy}
|
||||
canManageAccess={canReleaseAndVersion}
|
||||
canManageAccessPoint={canManageAccessPoint}
|
||||
showAccessControl={systemFeatures.webapp_auth.enabled}
|
||||
onAppStateChanged={actions.handleAppStateChanged}
|
||||
onRefreshApp={actions.refreshAppDetail}
|
||||
@ -131,13 +121,13 @@ export function BuiltInAccessPoints({ appId, highlightedAccessPoint }: BuiltInAc
|
||||
<ServiceApiAccessPointCard
|
||||
appInfo={appInfo}
|
||||
availability={appCardAvailability}
|
||||
canManage={capabilities.canReleaseAndVersion}
|
||||
canManage={canManageAccessPoint}
|
||||
onAppStateChanged={actions.handleAppStateChanged}
|
||||
highlighted={highlightedAccessPoint === 'serviceApi'}
|
||||
/>
|
||||
<MCPAccessPointCard
|
||||
appInfo={appInfo}
|
||||
canEdit={capabilities.canEdit}
|
||||
canManageAccessPoint={canManageAccessPoint}
|
||||
workflow={workflow}
|
||||
workflowLoading={workflowLoading}
|
||||
triggerModeDisabled={workflowState.hasTriggerNode}
|
||||
@ -147,7 +137,7 @@ export function BuiltInAccessPoints({ appId, highlightedAccessPoint }: BuiltInAc
|
||||
<TriggerAccessPointCard
|
||||
appInfo={appInfo}
|
||||
availability={triggerAvailability}
|
||||
canEdit={capabilities.canEdit}
|
||||
canManageAccessPoint={canManageAccessPoint}
|
||||
onToggleResult={actions.handleResult}
|
||||
highlighted={highlightedAccessPoint === 'trigger'}
|
||||
/>
|
||||
|
||||
@ -28,7 +28,7 @@ import { getPublishedWorkflowNodes, isAdvancedApp } from '../shared/utils'
|
||||
|
||||
type MCPAccessPointCardProps = {
|
||||
appInfo: AccessPointAppInfo
|
||||
canEdit: boolean
|
||||
canManageAccessPoint: boolean
|
||||
highlighted?: boolean
|
||||
triggerModeDisabled: boolean
|
||||
workflow: PublishedWorkflow
|
||||
@ -37,7 +37,7 @@ type MCPAccessPointCardProps = {
|
||||
|
||||
export function MCPAccessPointCard({
|
||||
appInfo,
|
||||
canEdit,
|
||||
canManageAccessPoint,
|
||||
highlighted,
|
||||
triggerModeDisabled,
|
||||
workflow,
|
||||
@ -99,7 +99,7 @@ export function MCPAccessPointCard({
|
||||
}, [advancedApp, basicAppInputs, workflowNodes])
|
||||
|
||||
const handleStatusChange = async (enabled: boolean) => {
|
||||
if (!canEdit || loading || unavailable) return
|
||||
if (!canManageAccessPoint || loading || unavailable) return
|
||||
if (enabled && !serverPublished) {
|
||||
setShowServerModal(true)
|
||||
return
|
||||
@ -121,7 +121,7 @@ export function MCPAccessPointCard({
|
||||
}
|
||||
|
||||
const handleRegenerate = async () => {
|
||||
if (!canEdit || !detail?.id) return
|
||||
if (!canManageAccessPoint || !detail?.id) return
|
||||
await refreshServerCode(appInfo.id)
|
||||
invalidateServerDetail(appInfo.id)
|
||||
setShowRegenerate(false)
|
||||
@ -145,14 +145,14 @@ export function MCPAccessPointCard({
|
||||
icon="i-custom-vender-integrations-mcp"
|
||||
status={status}
|
||||
highlighted={highlighted}
|
||||
switchDisabled={!canEdit}
|
||||
switchDisabled={!canManageAccessPoint}
|
||||
switchLabel={t(($) => $['mcp.server.title'], { ns: 'tools' })}
|
||||
switchLoading={statusUpdating}
|
||||
onEnabledChange={loading || unavailable ? undefined : handleStatusChange}
|
||||
actions={
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={loading || unavailable || !canEdit}
|
||||
disabled={loading || unavailable || !canManageAccessPoint}
|
||||
onClick={() => setShowServerModal(true)}
|
||||
className="flex items-center gap-1 px-3"
|
||||
>
|
||||
@ -177,7 +177,7 @@ export function MCPAccessPointCard({
|
||||
regenerateLabel={t(($) => $['overview.appInfo.regenerate'], {
|
||||
ns: 'appOverview',
|
||||
})}
|
||||
regenerateDisabled={!canEdit || !serverPublished}
|
||||
regenerateDisabled={!canManageAccessPoint || !serverPublished}
|
||||
regenerating={regenerating}
|
||||
onRegenerate={() => setShowRegenerate(true)}
|
||||
/>
|
||||
|
||||
@ -55,7 +55,7 @@ function TriggerIcon({
|
||||
type TriggerAccessPointCardProps = {
|
||||
appInfo: AccessPointAppInfo
|
||||
availability: 'available' | 'loading' | 'unavailable'
|
||||
canEdit: boolean
|
||||
canManageAccessPoint: boolean
|
||||
highlighted?: boolean
|
||||
onToggleResult: (error: Error | null) => void
|
||||
}
|
||||
@ -63,7 +63,7 @@ type TriggerAccessPointCardProps = {
|
||||
export function TriggerAccessPointCard({
|
||||
appInfo,
|
||||
availability,
|
||||
canEdit,
|
||||
canManageAccessPoint,
|
||||
highlighted,
|
||||
onToggleResult,
|
||||
}: TriggerAccessPointCardProps) {
|
||||
@ -95,7 +95,7 @@ export function TriggerAccessPointCard({
|
||||
}, [setTriggerStatuses, triggers])
|
||||
|
||||
const toggleTrigger = async (trigger: AppTrigger, enabled: boolean) => {
|
||||
if (!canEdit) return
|
||||
if (!canManageAccessPoint) return
|
||||
const status = enabled ? 'enabled' : 'disabled'
|
||||
setTriggerStatus(trigger.node_id, status)
|
||||
|
||||
@ -192,7 +192,7 @@ export function TriggerAccessPointCard({
|
||||
</span>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
disabled={!canEdit || statusUpdating}
|
||||
disabled={!canManageAccessPoint || statusUpdating}
|
||||
aria-label={trigger.title}
|
||||
onCheckedChange={(nextEnabled) => void toggleTrigger(trigger, nextEnabled)}
|
||||
/>
|
||||
|
||||
@ -51,9 +51,9 @@ const ACCESS_MODE_LABEL_MAP: Record<AccessMode, SelectorParam<'app'>> = {
|
||||
type WebAppAccessPointCardProps = {
|
||||
appInfo: AccessPointAppInfo
|
||||
availability: AccessPointAvailability
|
||||
canEdit: boolean
|
||||
canDeploy: boolean
|
||||
canManageAccess: boolean
|
||||
canManageAccessPoint: boolean
|
||||
highlighted?: boolean
|
||||
showAccessControl: boolean
|
||||
onAppStateChanged: () => Promise<void>
|
||||
@ -65,9 +65,9 @@ type WebAppAccessPointCardProps = {
|
||||
export function WebAppAccessPointCard({
|
||||
appInfo,
|
||||
availability,
|
||||
canEdit,
|
||||
canDeploy,
|
||||
canManageAccess,
|
||||
canManageAccessPoint,
|
||||
highlighted,
|
||||
onAppStateChanged,
|
||||
onRefreshApp,
|
||||
@ -121,7 +121,7 @@ export function WebAppAccessPointCard({
|
||||
Boolean(accessSubjects?.groups?.length || accessSubjects?.members?.length)
|
||||
|
||||
const handleStatusChange = (enabled: boolean) => {
|
||||
if (!canEdit) return
|
||||
if (!canManageAccessPoint) return
|
||||
|
||||
updateSiteStatus.mutate({
|
||||
params: { app_id: appInfo.id },
|
||||
@ -130,7 +130,7 @@ export function WebAppAccessPointCard({
|
||||
}
|
||||
|
||||
const handleRegenerate = () => {
|
||||
if (!canEdit || resetSiteAccessToken.isPending) return
|
||||
if (!canManageAccessPoint || resetSiteAccessToken.isPending) return
|
||||
|
||||
resetSiteAccessToken.mutate({ params: { app_id: appInfo.id } })
|
||||
}
|
||||
@ -155,7 +155,7 @@ export function WebAppAccessPointCard({
|
||||
}
|
||||
status={status}
|
||||
highlighted={highlighted}
|
||||
switchDisabled={!canEdit}
|
||||
switchDisabled={!canManageAccessPoint}
|
||||
switchLabel={t(($) => $['overview.appInfo.title'], { ns: 'appOverview' })}
|
||||
switchLoading={updateSiteStatus.isPending}
|
||||
onEnabledChange={availability === 'available' ? handleStatusChange : undefined}
|
||||
@ -176,7 +176,7 @@ export function WebAppAccessPointCard({
|
||||
<Button
|
||||
className="flex items-center gap-1 px-3"
|
||||
variant="secondary"
|
||||
disabled={!running}
|
||||
disabled={!running || !canManageAccessPoint}
|
||||
onClick={() => setShowEmbedded(true)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-window-line size-4" />
|
||||
@ -186,7 +186,7 @@ export function WebAppAccessPointCard({
|
||||
<Button
|
||||
className="flex items-center gap-1 px-3"
|
||||
variant="secondary"
|
||||
disabled={!running}
|
||||
disabled={!running || !canManageAccessPoint}
|
||||
onClick={() => setShowCustomize(true)}
|
||||
>
|
||||
<span aria-hidden className="i-custom-vender-deploy-code-block size-4" />
|
||||
@ -197,7 +197,7 @@ export function WebAppAccessPointCard({
|
||||
<Button
|
||||
className="flex items-center gap-1 px-3"
|
||||
variant="secondary"
|
||||
disabled={availability !== 'available' || !canEdit}
|
||||
disabled={availability !== 'available' || !canManageAccessPoint}
|
||||
onClick={() => setShowSettings(true)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-equalizer-2-line size-4" />
|
||||
@ -223,7 +223,7 @@ export function WebAppAccessPointCard({
|
||||
regenerateLabel={t(($) => $['overview.appInfo.regenerate'], {
|
||||
ns: 'appOverview',
|
||||
})}
|
||||
regenerateDisabled={!canEdit}
|
||||
regenerateDisabled={!canManageAccessPoint}
|
||||
regenerating={resetSiteAccessToken.isPending}
|
||||
onRegenerate={() => setShowRegenerate(true)}
|
||||
/>
|
||||
|
||||
@ -10,14 +10,14 @@ import { ServiceApiCardView } from '../shared/service-api-card-view'
|
||||
type EnvironmentServiceApiCardProps = {
|
||||
appId: string
|
||||
environmentId: string
|
||||
canManage: boolean
|
||||
canManageAccessPoint: boolean
|
||||
highlighted?: boolean
|
||||
}
|
||||
|
||||
export function EnvironmentServiceApiCard({
|
||||
appId,
|
||||
environmentId,
|
||||
canManage,
|
||||
canManageAccessPoint,
|
||||
highlighted,
|
||||
}: EnvironmentServiceApiCardProps) {
|
||||
const { t } = useTranslation()
|
||||
@ -54,7 +54,7 @@ export function EnvironmentServiceApiCard({
|
||||
: 'disabled'
|
||||
|
||||
const handleEnabledChange = (enabled: boolean) => {
|
||||
if (!canManage) return
|
||||
if (!canManageAccessPoint) return
|
||||
|
||||
apiMutation.mutate({
|
||||
params,
|
||||
@ -68,7 +68,7 @@ export function EnvironmentServiceApiCard({
|
||||
appId,
|
||||
environmentId,
|
||||
apiKeyCount: api?.api_key_count,
|
||||
canManage,
|
||||
canManage: canManageAccessPoint,
|
||||
disabled: !apiQuery.isSuccess,
|
||||
}}
|
||||
apiUrl={api?.base_url ?? ''}
|
||||
@ -76,7 +76,7 @@ export function EnvironmentServiceApiCard({
|
||||
available={apiQuery.isSuccess}
|
||||
status={status}
|
||||
highlighted={highlighted}
|
||||
switchDisabled={!canManage}
|
||||
switchDisabled={!canManageAccessPoint}
|
||||
onEnabledChange={apiQuery.isSuccess ? handleEnabledChange : undefined}
|
||||
switchLoading={apiMutation.isPending}
|
||||
/>
|
||||
|
||||
@ -39,23 +39,23 @@ const ACCESS_MODE_ICON_MAP: Record<AccessMode, string> = {
|
||||
type EnvironmentWebAppCardProps = {
|
||||
appId: string
|
||||
environmentId: string
|
||||
canEdit: boolean
|
||||
canManage: boolean
|
||||
canManageAccessPoint: boolean
|
||||
canReleaseAndVersion: boolean
|
||||
highlighted?: boolean
|
||||
}
|
||||
|
||||
export function EnvironmentWebAppCard({
|
||||
appId,
|
||||
environmentId,
|
||||
canEdit,
|
||||
canManage,
|
||||
canManageAccessPoint,
|
||||
canReleaseAndVersion,
|
||||
highlighted,
|
||||
}: EnvironmentWebAppCardProps) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const appInfo = useAppStore((state) => state.appDetail) as AccessPointAppInfo | null
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const actions = useAccessPointActions(appId, canEdit)
|
||||
const actions = useAccessPointActions(appId, canManageAccessPoint)
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [showCustomize, setShowCustomize] = useState(false)
|
||||
const [showAccess, setShowAccess] = useState(false)
|
||||
@ -85,7 +85,7 @@ export function EnvironmentWebAppCard({
|
||||
...subjectsQueryOptions,
|
||||
enabled:
|
||||
siteQuery.isSuccess &&
|
||||
canManage &&
|
||||
canReleaseAndVersion &&
|
||||
(showAccess || accessMode === AccessMode.SPECIFIC_GROUPS_MEMBERS),
|
||||
})
|
||||
const accessConfigured =
|
||||
@ -135,7 +135,7 @@ export function EnvironmentWebAppCard({
|
||||
? t(($) => $['accessControlDialog.accessItems.external'], { ns: 'app' })
|
||||
: t(($) => $['accessControlDialog.accessItems.anyone'], { ns: 'app' })
|
||||
const handleEnabledChange = (enabled: boolean) => {
|
||||
if (!canManage) return
|
||||
if (!canManageAccessPoint) return
|
||||
|
||||
siteMutation.mutate({
|
||||
params,
|
||||
@ -144,7 +144,7 @@ export function EnvironmentWebAppCard({
|
||||
}
|
||||
|
||||
const handleRegenerate = () => {
|
||||
if (!canManage) return
|
||||
if (!canManageAccessPoint) return
|
||||
|
||||
resetAccessTokenMutation.mutate({ params })
|
||||
}
|
||||
@ -171,7 +171,7 @@ export function EnvironmentWebAppCard({
|
||||
}
|
||||
status={status}
|
||||
highlighted={highlighted}
|
||||
switchDisabled={!canManage}
|
||||
switchDisabled={!canManageAccessPoint}
|
||||
switchLabel={t(($) => $['overview.appInfo.title'], { ns: 'appOverview' })}
|
||||
onEnabledChange={siteQuery.isSuccess ? handleEnabledChange : undefined}
|
||||
switchLoading={siteMutation.isPending}
|
||||
@ -180,7 +180,7 @@ export function EnvironmentWebAppCard({
|
||||
<Button
|
||||
className="flex items-center gap-1 px-3"
|
||||
variant="secondary"
|
||||
disabled={!running || !apiQuery.isSuccess}
|
||||
disabled={!running || !apiQuery.isSuccess || !canManageAccessPoint}
|
||||
onClick={() => setShowCustomize(true)}
|
||||
>
|
||||
<span aria-hidden className="i-custom-vender-deploy-code-block size-4" />
|
||||
@ -191,7 +191,7 @@ export function EnvironmentWebAppCard({
|
||||
<Button
|
||||
className="flex items-center gap-1 px-3"
|
||||
variant="secondary"
|
||||
disabled={!appInfo || !siteQuery.isSuccess || !canEdit}
|
||||
disabled={!appInfo || !siteQuery.isSuccess || !canManageAccessPoint}
|
||||
onClick={() => setShowSettings(true)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-equalizer-2-line size-4" />
|
||||
@ -217,7 +217,7 @@ export function EnvironmentWebAppCard({
|
||||
regenerateLabel={t(($) => $['overview.appInfo.regenerate'], {
|
||||
ns: 'appOverview',
|
||||
})}
|
||||
regenerateDisabled={!canManage}
|
||||
regenerateDisabled={!canManageAccessPoint}
|
||||
regenerating={resetAccessTokenMutation.isPending}
|
||||
onRegenerate={() => setShowRegenerate(true)}
|
||||
/>
|
||||
@ -227,7 +227,7 @@ export function EnvironmentWebAppCard({
|
||||
accessIcon={ACCESS_MODE_ICON_MAP[accessMode]}
|
||||
accessLabel={accessLabel}
|
||||
available={siteQuery.isSuccess}
|
||||
disabled={!canManage}
|
||||
disabled={!canReleaseAndVersion}
|
||||
onClick={() => setShowAccess(true)}
|
||||
/>
|
||||
)}
|
||||
@ -236,7 +236,7 @@ export function EnvironmentWebAppCard({
|
||||
{appInfo && (
|
||||
<SettingsModal
|
||||
isChat={false}
|
||||
canDeploy={canManage}
|
||||
canDeploy
|
||||
appInfo={appInfo}
|
||||
isShow={showSettings}
|
||||
onClose={() => setShowSettings(false)}
|
||||
@ -255,7 +255,7 @@ export function EnvironmentWebAppCard({
|
||||
appId={appId}
|
||||
environmentId={environmentId}
|
||||
accessMode={accessMode}
|
||||
canManage={canManage}
|
||||
canManage={canReleaseAndVersion}
|
||||
onClose={() => setShowAccess(false)}
|
||||
onConfirm={() => setShowAccess(false)}
|
||||
/>
|
||||
|
||||
@ -31,16 +31,16 @@ const UNSUPPORTED_ACCESS_POINTS = ['mcp', 'trigger'] as const
|
||||
type DeployedEnvironmentAccessPointsProps = {
|
||||
appId: string
|
||||
environmentId: string
|
||||
canEdit: boolean
|
||||
canManage: boolean
|
||||
canManageAccessPoint: boolean
|
||||
canReleaseAndVersion: boolean
|
||||
highlightedAccessPoint?: AccessPoint | null
|
||||
}
|
||||
|
||||
export function DeployedEnvironmentAccessPoints({
|
||||
appId,
|
||||
environmentId,
|
||||
canEdit,
|
||||
canManage,
|
||||
canManageAccessPoint,
|
||||
canReleaseAndVersion,
|
||||
highlightedAccessPoint,
|
||||
}: DeployedEnvironmentAccessPointsProps) {
|
||||
const { t } = useTranslation()
|
||||
@ -67,14 +67,14 @@ export function DeployedEnvironmentAccessPoints({
|
||||
<EnvironmentWebAppCard
|
||||
appId={appId}
|
||||
environmentId={environmentId}
|
||||
canEdit={canEdit}
|
||||
canManage={canManage}
|
||||
canManageAccessPoint={canManageAccessPoint}
|
||||
canReleaseAndVersion={canReleaseAndVersion}
|
||||
highlighted={highlightedAccessPoint === 'webApp'}
|
||||
/>
|
||||
<EnvironmentServiceApiCard
|
||||
appId={appId}
|
||||
environmentId={environmentId}
|
||||
canManage={canManage}
|
||||
canManageAccessPoint={canManageAccessPoint}
|
||||
highlighted={highlightedAccessPoint === 'serviceApi'}
|
||||
/>
|
||||
{UNSUPPORTED_ACCESS_POINTS.map((accessPoint) => {
|
||||
|
||||
@ -33,15 +33,17 @@ type AccessPointProps = {
|
||||
}
|
||||
|
||||
type AccessPointContentProps = AccessPointProps & {
|
||||
canEdit: boolean
|
||||
canManage: boolean
|
||||
canDeploy: boolean
|
||||
canManageAccessPoint: boolean
|
||||
canReleaseAndVersion: boolean
|
||||
showEnvironmentTabs: boolean
|
||||
}
|
||||
|
||||
function AccessPointContent({
|
||||
appId,
|
||||
canEdit,
|
||||
canManage,
|
||||
canDeploy,
|
||||
canManageAccessPoint,
|
||||
canReleaseAndVersion,
|
||||
showEnvironmentTabs,
|
||||
}: AccessPointContentProps) {
|
||||
const { t } = useTranslation()
|
||||
@ -108,14 +110,17 @@ function AccessPointContent({
|
||||
{selectedEnvironment === BUILT_IN_ENVIRONMENT_ID ? (
|
||||
<BuiltInAccessPoints
|
||||
appId={appId}
|
||||
canDeploy={canDeploy}
|
||||
canManageAccessPoint={canManageAccessPoint}
|
||||
canReleaseAndVersion={canReleaseAndVersion}
|
||||
highlightedAccessPoint={selectedHighlightedAccessPoint}
|
||||
/>
|
||||
) : (
|
||||
<DeployedEnvironmentAccessPoints
|
||||
appId={appId}
|
||||
environmentId={selectedEnvironment}
|
||||
canEdit={canEdit}
|
||||
canManage={canManage}
|
||||
canManageAccessPoint={canManageAccessPoint}
|
||||
canReleaseAndVersion={canReleaseAndVersion}
|
||||
highlightedAccessPoint={selectedHighlightedAccessPoint}
|
||||
/>
|
||||
)}
|
||||
@ -136,14 +141,16 @@ export default function AccessPoint({ appId }: AccessPointProps) {
|
||||
resourceMaintainer: appDetail?.maintainer,
|
||||
workspacePermissionKeys,
|
||||
})
|
||||
const showEnvironmentTabs = appDetail?.mode === AppModeEnum.WORKFLOW && capabilities.canDeploy
|
||||
const showEnvironmentTabs =
|
||||
appDetail?.mode === AppModeEnum.WORKFLOW && capabilities.canViewAccessPoint
|
||||
|
||||
return (
|
||||
<AccessPointStateBoundary appId={appId} environmentQueryEnabled={showEnvironmentTabs}>
|
||||
<AccessPointContent
|
||||
appId={appId}
|
||||
canEdit={capabilities.canEdit}
|
||||
canManage={capabilities.canDeploy}
|
||||
canDeploy={capabilities.canDeploy}
|
||||
canManageAccessPoint={capabilities.canManageAccessPoint}
|
||||
canReleaseAndVersion={capabilities.canReleaseAndVersion}
|
||||
showEnvironmentTabs={showEnvironmentTabs}
|
||||
/>
|
||||
</AccessPointStateBoundary>
|
||||
|
||||
@ -14,7 +14,7 @@ import { fetchAppDetail, updateAppSiteConfig } from '@/service/apps'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { asyncRunSafe } from '@/utils'
|
||||
|
||||
export function useAccessPointActions(appId: string, canEdit: boolean) {
|
||||
export function useAccessPointActions(appId: string, canManageAccessPoint: boolean) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const setAppDetail = useAppStore((state) => state.setAppDetail)
|
||||
@ -64,7 +64,7 @@ export function useAccessPointActions(appId: string, canEdit: boolean) {
|
||||
|
||||
const saveSiteConfig = useCallback(
|
||||
async (params: ConfigParams) => {
|
||||
if (!canEdit) return
|
||||
if (!canManageAccessPoint) return
|
||||
const [error] = await asyncRunSafe<App>(
|
||||
updateAppSiteConfig({
|
||||
url: `/apps/${appId}/site`,
|
||||
@ -83,7 +83,7 @@ export function useAccessPointActions(appId: string, canEdit: boolean) {
|
||||
}
|
||||
handleResult(error)
|
||||
},
|
||||
[appId, canEdit, handleResult, queryClient],
|
||||
[appId, canManageAccessPoint, handleResult, queryClient],
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@ -288,13 +288,17 @@ function seedPublishedWorkflowQueries(queryClient: QueryClient) {
|
||||
|
||||
function renderFlow(
|
||||
deployment = createDeployment(),
|
||||
{ isDeploymentError = false }: { isDeploymentError?: boolean } = {},
|
||||
{
|
||||
canViewAccessPoint = true,
|
||||
isDeploymentError = false,
|
||||
}: { canViewAccessPoint?: boolean; isDeploymentError?: boolean } = {},
|
||||
) {
|
||||
const queryClient = createFlowQueryClient(deployment.environment.id)
|
||||
|
||||
return render(
|
||||
<PublisherEnvironmentFlow
|
||||
appId="app-1"
|
||||
canViewAccessPoint={canViewAccessPoint}
|
||||
deployment={deployment}
|
||||
environmentId={deployment.environment.id}
|
||||
environmentName={deployment.environment.display_name}
|
||||
@ -334,6 +338,7 @@ function renderFlowWithPolling(deployment = createDeployment()) {
|
||||
<PublisherPollingObserver />
|
||||
<PublisherEnvironmentFlow
|
||||
appId="app-1"
|
||||
canViewAccessPoint
|
||||
deployment={deployment}
|
||||
environmentId={deployment.environment.id}
|
||||
environmentName={deployment.environment.display_name}
|
||||
@ -478,6 +483,7 @@ describe('PublisherEnvironmentFlow', () => {
|
||||
render(
|
||||
<PublisherEnvironmentFlow
|
||||
appId="app-1"
|
||||
canViewAccessPoint
|
||||
environmentId="development"
|
||||
environmentName="Development"
|
||||
environmentTabs={<div>Environment tabs</div>}
|
||||
@ -508,6 +514,7 @@ describe('PublisherEnvironmentFlow', () => {
|
||||
render(
|
||||
<PublisherEnvironmentFlow
|
||||
appId="app-1"
|
||||
canViewAccessPoint
|
||||
environmentId="development"
|
||||
environmentName="Development"
|
||||
environmentTabs={<div>Environment tabs</div>}
|
||||
@ -577,6 +584,16 @@ describe('PublisherEnvironmentFlow', () => {
|
||||
},
|
||||
)
|
||||
|
||||
it('hides the Access Point environment entry without view permission', () => {
|
||||
renderFlow(createDeployment(), { canViewAccessPoint: false })
|
||||
|
||||
expect(screen.queryByRole('link', { name: 'Access Point' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'Deploy' })).toHaveAttribute(
|
||||
'href',
|
||||
'/app/app-1/deploy?environment=staging',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the deployment target and progress controls when a deploying status refresh fails', () => {
|
||||
const deployment = createDeployment({
|
||||
status: DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING,
|
||||
|
||||
@ -314,6 +314,7 @@ describe('app-publisher sections', () => {
|
||||
description: 'Workflow description',
|
||||
}}
|
||||
appURL="https://example.com/app"
|
||||
canViewAccessPoint
|
||||
disabledFunctionButton={false}
|
||||
disabledFunctionTooltip="disabled"
|
||||
handleOpenRunConfig={handleOpenRunConfig}
|
||||
@ -376,6 +377,7 @@ describe('app-publisher sections', () => {
|
||||
name: 'Workflow App',
|
||||
}}
|
||||
appURL="https://example.com/app"
|
||||
canViewAccessPoint
|
||||
disabledFunctionButton={false}
|
||||
hasHumanInputNode={false}
|
||||
hasTriggerNode={false}
|
||||
@ -408,6 +410,7 @@ describe('app-publisher sections', () => {
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
},
|
||||
appURL: 'https://example.com/app',
|
||||
canViewAccessPoint: true,
|
||||
disabledFunctionButton: false,
|
||||
hasHumanInputNode: false,
|
||||
hasTriggerNode: false,
|
||||
@ -449,6 +452,7 @@ describe('app-publisher sections', () => {
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
},
|
||||
appURL: 'https://example.com/app',
|
||||
canViewAccessPoint: true,
|
||||
disabledFunctionButton: false,
|
||||
hasHumanInputNode: false,
|
||||
hasTriggerNode: false,
|
||||
@ -494,6 +498,7 @@ describe('app-publisher sections', () => {
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
}}
|
||||
appURL="https://example.com/app"
|
||||
canViewAccessPoint
|
||||
disabledFunctionButton={false}
|
||||
hasHumanInputNode={false}
|
||||
hasTriggerNode
|
||||
@ -517,11 +522,36 @@ describe('app-publisher sections', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should hide the Access Point publisher entry without view permission', () => {
|
||||
render(
|
||||
<PublisherActionsSection
|
||||
appDetail={{ id: 'workflow-app', mode: AppModeEnum.WORKFLOW }}
|
||||
appURL="https://example.com/app"
|
||||
canViewAccessPoint={false}
|
||||
disabledFunctionButton={false}
|
||||
hasHumanInputNode={false}
|
||||
hasTriggerNode={false}
|
||||
publishedAt={Date.now()}
|
||||
showDeployAction
|
||||
workflowToolAvailable
|
||||
workflowToolIsLoading={false}
|
||||
onConfigureWorkflowTool={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('link', { name: /appMenus\.accessPoint\b/ })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /appMenus\.deploy\b/ })).toHaveAttribute(
|
||||
'href',
|
||||
'/app/workflow-app/deploy',
|
||||
)
|
||||
})
|
||||
|
||||
it('should expose unavailable quick links as disabled buttons before the first publish', () => {
|
||||
render(
|
||||
<PublisherActionsSection
|
||||
appDetail={{ id: 'workflow-app', mode: AppModeEnum.WORKFLOW }}
|
||||
appURL="https://example.com/app"
|
||||
canViewAccessPoint
|
||||
disabledFunctionButton
|
||||
hasHumanInputNode={false}
|
||||
hasTriggerNode={false}
|
||||
@ -550,6 +580,7 @@ describe('app-publisher sections', () => {
|
||||
<PublisherActionsSection
|
||||
appDetail={{ id: 'workflow-app', mode: AppModeEnum.WORKFLOW }}
|
||||
appURL="https://example.com/app"
|
||||
canViewAccessPoint
|
||||
disabledFunctionButton
|
||||
disabledFunctionTooltip="Open web app unavailable"
|
||||
hasHumanInputNode={false}
|
||||
@ -573,6 +604,7 @@ describe('app-publisher sections', () => {
|
||||
<PublisherActionsSection
|
||||
appDetail={{ id: 'workflow-app', mode: AppModeEnum.WORKFLOW }}
|
||||
appURL="https://example.com/app"
|
||||
canViewAccessPoint
|
||||
disabledFunctionButton
|
||||
disabledFunctionTooltip="Open web app unavailable"
|
||||
hasHumanInputNode={false}
|
||||
|
||||
@ -23,6 +23,7 @@ type PublisherActionsSectionProps = Pick<
|
||||
| null
|
||||
| undefined
|
||||
appURL: string
|
||||
canViewAccessPoint: boolean
|
||||
disabledFunctionButton: boolean
|
||||
disabledFunctionTooltip?: string
|
||||
handleOpenRunConfig?: (url: string) => void
|
||||
@ -41,6 +42,7 @@ type PublisherActionsSectionProps = Pick<
|
||||
export function PublisherActionsSection({
|
||||
appDetail,
|
||||
appURL,
|
||||
canViewAccessPoint,
|
||||
disabledFunctionButton,
|
||||
disabledFunctionTooltip,
|
||||
handleOpenRunConfig,
|
||||
@ -114,14 +116,16 @@ export function PublisherActionsSection({
|
||||
<TooltipContent role="tooltip">{disabledFunctionTooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<SuggestedAction
|
||||
disabled={navigationDisabled}
|
||||
description={t(($) => $['common.accessPointDescription'], { ns: 'workflow' })}
|
||||
link={appId ? `/app/${appId}/access-point` : undefined}
|
||||
icon={<span className="i-custom-vender-agent-v2-access-point size-4" />}
|
||||
>
|
||||
{t(($) => $['appMenus.accessPoint'], { ns: 'common' })}
|
||||
</SuggestedAction>
|
||||
{canViewAccessPoint && (
|
||||
<SuggestedAction
|
||||
disabled={navigationDisabled}
|
||||
description={t(($) => $['common.accessPointDescription'], { ns: 'workflow' })}
|
||||
link={appId ? `/app/${appId}/access-point` : undefined}
|
||||
icon={<span className="i-custom-vender-agent-v2-access-point size-4" />}
|
||||
>
|
||||
{t(($) => $['appMenus.accessPoint'], { ns: 'common' })}
|
||||
</SuggestedAction>
|
||||
)}
|
||||
{showDeploy && (
|
||||
<SuggestedAction
|
||||
disabled={navigationDisabled}
|
||||
|
||||
@ -8,10 +8,12 @@ function environmentHref(path: string, appId: string, environmentId: string) {
|
||||
|
||||
export function PublisherEnvironmentActionsSection({
|
||||
appId,
|
||||
canViewAccessPoint,
|
||||
deployment,
|
||||
environmentId,
|
||||
}: {
|
||||
appId?: string
|
||||
canViewAccessPoint: boolean
|
||||
deployment?: EnvironmentDeployment
|
||||
environmentId: string
|
||||
}) {
|
||||
@ -22,14 +24,16 @@ export function PublisherEnvironmentActionsSection({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col border-t-[0.5px] border-t-divider-regular p-3">
|
||||
<SuggestedAction
|
||||
disabled={actionsDisabled}
|
||||
description={t(($) => $['common.accessPointDescription'], { ns: 'workflow' })}
|
||||
link={accessPointHref}
|
||||
icon={<span className="i-custom-vender-agent-v2-access-point size-4" />}
|
||||
>
|
||||
{t(($) => $['appMenus.accessPoint'], { ns: 'common' })}
|
||||
</SuggestedAction>
|
||||
{canViewAccessPoint && (
|
||||
<SuggestedAction
|
||||
disabled={actionsDisabled}
|
||||
description={t(($) => $['common.accessPointDescription'], { ns: 'workflow' })}
|
||||
link={accessPointHref}
|
||||
icon={<span className="i-custom-vender-agent-v2-access-point size-4" />}
|
||||
>
|
||||
{t(($) => $['appMenus.accessPoint'], { ns: 'common' })}
|
||||
</SuggestedAction>
|
||||
)}
|
||||
<SuggestedAction
|
||||
disabled={actionsDisabled}
|
||||
description={t(($) => $['common.deployDescription'], { ns: 'workflow' })}
|
||||
|
||||
@ -15,6 +15,7 @@ import { PublisherEnvironmentSummarySection } from './summary-section'
|
||||
|
||||
type PublisherEnvironmentFlowProps = {
|
||||
appId?: string
|
||||
canViewAccessPoint: boolean
|
||||
deployment?: EnvironmentDeployment
|
||||
environmentId: string
|
||||
environmentName: string
|
||||
@ -28,6 +29,7 @@ type PublisherEnvironmentFlowProps = {
|
||||
|
||||
export function PublisherEnvironmentFlow({
|
||||
appId,
|
||||
canViewAccessPoint,
|
||||
deployment,
|
||||
environmentId,
|
||||
environmentName,
|
||||
@ -91,6 +93,7 @@ export function PublisherEnvironmentFlow({
|
||||
/>
|
||||
<PublisherEnvironmentActionsSection
|
||||
appId={appId}
|
||||
canViewAccessPoint={canViewAccessPoint}
|
||||
deployment={deployment}
|
||||
environmentId={environmentId}
|
||||
/>
|
||||
|
||||
@ -17,11 +17,11 @@ export function AppPublisher(props: AppPublisherProps) {
|
||||
select: (data) => data.profile.id,
|
||||
})
|
||||
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
|
||||
const canDeploy = getAppACLCapabilities(appDetail?.permission_keys, {
|
||||
const { canDeploy, canViewAccessPoint } = getAppACLCapabilities(appDetail?.permission_keys, {
|
||||
currentUserId,
|
||||
resourceMaintainer: appDetail?.maintainer,
|
||||
workspacePermissionKeys,
|
||||
}).canDeploy
|
||||
})
|
||||
const supportsMultiEnvironment = appDetail?.mode === AppModeEnum.WORKFLOW && canDeploy
|
||||
|
||||
return (
|
||||
@ -31,6 +31,7 @@ export function AppPublisher(props: AppPublisherProps) {
|
||||
>
|
||||
<PublisherContent
|
||||
{...props}
|
||||
canViewAccessPoint={canViewAccessPoint}
|
||||
open={open}
|
||||
supportsMultiEnvironment={supportsMultiEnvironment}
|
||||
onOpenStateChange={setOpen}
|
||||
|
||||
@ -33,12 +33,14 @@ import { useWorkflowLaunch } from './use-workflow-launch'
|
||||
import { useWorkflowTool } from './use-workflow-tool'
|
||||
|
||||
type PublisherContentProps = AppPublisherProps & {
|
||||
canViewAccessPoint: boolean
|
||||
open: boolean
|
||||
supportsMultiEnvironment: boolean
|
||||
onOpenStateChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function PublisherContent({
|
||||
canViewAccessPoint,
|
||||
crossAxisOffset = 0,
|
||||
debugWithMultipleModel = false,
|
||||
disabled = false,
|
||||
@ -212,6 +214,7 @@ export function PublisherContent({
|
||||
actions: {
|
||||
appDetail,
|
||||
appURL,
|
||||
canViewAccessPoint,
|
||||
disabledFunctionButton,
|
||||
disabledFunctionTooltip,
|
||||
handleOpenRunConfig: workflowLaunch.openDialog,
|
||||
@ -236,6 +239,7 @@ export function PublisherContent({
|
||||
disabled={disabled}
|
||||
environmentPublisher={{
|
||||
appId: appDetail?.id,
|
||||
canViewAccessPoint,
|
||||
deployment: selectedEnvironmentDeployment,
|
||||
environmentId: selectedEnvironmentId,
|
||||
environmentName:
|
||||
|
||||
@ -650,7 +650,7 @@ function render(
|
||||
return renderWithConsoleQuery(ui, { queryClient })
|
||||
}
|
||||
|
||||
let appPermissionKeys: string[] = [AppACLPermission.Deploy]
|
||||
let appPermissionKeys: string[] = [AppACLPermission.AccessPointView, AppACLPermission.Deploy]
|
||||
let appDetailAvailable = true
|
||||
const mockConsoleState = vi.hoisted(() => ({
|
||||
workspacePermissionKeys: [] as string[],
|
||||
@ -744,7 +744,7 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
describe('AppDeploy', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
appPermissionKeys = [AppACLPermission.Deploy]
|
||||
appPermissionKeys = [AppACLPermission.AccessPointView, AppACLPermission.Deploy]
|
||||
appDetailAvailable = true
|
||||
mockBuiltInEnvironment.appDetail.enable_api = false
|
||||
mockBuiltInEnvironment.appDetail.enable_site = true
|
||||
@ -847,6 +847,24 @@ describe('AppDeploy', () => {
|
||||
expect(builtInEnvironment.getByText('Updated at 03-09 16:03 by Bob')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps Access Point status visible but removes links without view permission', () => {
|
||||
appPermissionKeys = [AppACLPermission.Deploy]
|
||||
|
||||
render(<AppDeploy />)
|
||||
|
||||
const canaryRow = within(screen.getByRole('row', { name: /Canary/ }))
|
||||
expect(
|
||||
canaryRow.getByRole('button', {
|
||||
name: 'agentV2.agentDetail.access.webApp.title · agentV2.agentDetail.access.status.inService',
|
||||
}),
|
||||
).toBeDisabled()
|
||||
expect(
|
||||
canaryRow.queryByRole('link', {
|
||||
name: 'agentV2.agentDetail.access.webApp.title · agentV2.agentDetail.access.status.inService',
|
||||
}),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows only the trigger access point as active for a published trigger workflow', () => {
|
||||
mockBuiltInEnvironment.appDetail.enable_api = true
|
||||
mockBuiltInEnvironment.publishedWorkflow.graph.nodes = [
|
||||
@ -1561,7 +1579,7 @@ describe('AppDeploy', () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<AppDeployStateBoundary appId={APP_ID}>
|
||||
<EnvironmentTable appId={APP_ID} />
|
||||
<EnvironmentTable appId={APP_ID} canViewAccessPoint />
|
||||
</AppDeployStateBoundary>,
|
||||
{
|
||||
appEnvironments: APP_ENVIRONMENTS.map((environment) => ({
|
||||
@ -1625,7 +1643,7 @@ describe('AppDeploy', () => {
|
||||
|
||||
renderWithConsoleQuery(
|
||||
<AppDeployStateBoundary appId={APP_ID}>
|
||||
<EnvironmentTable appId={APP_ID} />
|
||||
<EnvironmentTable appId={APP_ID} canViewAccessPoint />
|
||||
</AppDeployStateBoundary>,
|
||||
{ queryClient },
|
||||
)
|
||||
@ -1747,7 +1765,7 @@ describe('AppDeploy', () => {
|
||||
const onUndeploy = vi.fn()
|
||||
render(
|
||||
<AppDeployStateBoundary appId={APP_ID}>
|
||||
<EnvironmentTable appId={APP_ID} onUndeploy={onUndeploy} />
|
||||
<EnvironmentTable appId={APP_ID} canViewAccessPoint onUndeploy={onUndeploy} />
|
||||
</AppDeployStateBoundary>,
|
||||
)
|
||||
|
||||
@ -1784,7 +1802,7 @@ describe('AppDeploy', () => {
|
||||
const onUndeploy = vi.fn()
|
||||
render(
|
||||
<AppDeployStateBoundary appId={APP_ID}>
|
||||
<EnvironmentTable appId={APP_ID} onUndeploy={onUndeploy} />
|
||||
<EnvironmentTable appId={APP_ID} canViewAccessPoint onUndeploy={onUndeploy} />
|
||||
</AppDeployStateBoundary>,
|
||||
)
|
||||
|
||||
|
||||
@ -19,7 +19,7 @@ function Divider() {
|
||||
return <div className="i-custom-vender-deploy-line-5 h-10 w-3" />
|
||||
}
|
||||
|
||||
export function BuiltInEnvironmentCard() {
|
||||
export function BuiltInEnvironmentCard({ canViewAccessPoint }: { canViewAccessPoint: boolean }) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const { formatTime } = useTimestamp()
|
||||
const appDetail = useAppStore((state) => state.appDetail)
|
||||
@ -90,7 +90,11 @@ export function BuiltInEnvironmentCard() {
|
||||
key={accessPoint}
|
||||
accessPoint={accessPoint}
|
||||
active={activeAccessPoints[accessPoint]}
|
||||
href={getAccessPointHref(appId, 'built-in', accessPoint)}
|
||||
href={
|
||||
canViewAccessPoint
|
||||
? getAccessPointHref(appId, 'built-in', accessPoint)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -23,6 +23,7 @@ import { EnvironmentRow } from './row'
|
||||
|
||||
type EnvironmentTableProps = {
|
||||
appId: string
|
||||
canViewAccessPoint: boolean
|
||||
onChangeVersion?: (deployment: EnvironmentDeployment) => void
|
||||
onDeployLatest?: (deployment: EnvironmentDeployment) => void
|
||||
onDeployToEnvironment?: (environment: AppEnvironment) => void
|
||||
@ -32,6 +33,7 @@ type EnvironmentTableProps = {
|
||||
|
||||
export function EnvironmentTable({
|
||||
appId,
|
||||
canViewAccessPoint,
|
||||
onChangeVersion,
|
||||
onDeployLatest,
|
||||
onDeployToEnvironment,
|
||||
@ -132,6 +134,7 @@ export function EnvironmentTable({
|
||||
<EnvironmentRow
|
||||
key={row.environment.id}
|
||||
appId={appId}
|
||||
canViewAccessPoint={canViewAccessPoint}
|
||||
row={row}
|
||||
onChangeVersion={onChangeVersion}
|
||||
onDeployLatest={onDeployLatest}
|
||||
|
||||
@ -10,6 +10,7 @@ import { EnvironmentRowActions } from './row-actions'
|
||||
|
||||
export function EnvironmentRow({
|
||||
appId,
|
||||
canViewAccessPoint,
|
||||
row,
|
||||
onChangeVersion,
|
||||
onDeployLatest,
|
||||
@ -17,6 +18,7 @@ export function EnvironmentRow({
|
||||
onUndeploy,
|
||||
}: {
|
||||
appId: string
|
||||
canViewAccessPoint: boolean
|
||||
row: EnvironmentDeployment
|
||||
onChangeVersion?: (deployment: EnvironmentDeployment) => void
|
||||
onDeployLatest?: (deployment: EnvironmentDeployment) => void
|
||||
@ -60,7 +62,11 @@ export function EnvironmentRow({
|
||||
key={accessPoint}
|
||||
accessPoint={accessPoint}
|
||||
active={isAccessPointActive(accessPoint)}
|
||||
href={getAccessPointHref(appId, row.environment.id, accessPoint)}
|
||||
href={
|
||||
canViewAccessPoint
|
||||
? getAccessPointHref(appId, row.environment.id, accessPoint)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -22,7 +22,13 @@ import { useRefreshAppEnvironmentsAfterDeploymentPolling } from './use-refresh-a
|
||||
import { useUndeployWorkflow } from './use-undeploy-workflow'
|
||||
import { toDeploymentVersion } from './version'
|
||||
|
||||
function AppDeployContent({ appId }: { appId: string }) {
|
||||
function AppDeployContent({
|
||||
appId,
|
||||
canViewAccessPoint,
|
||||
}: {
|
||||
appId: string
|
||||
canViewAccessPoint: boolean
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const { t: tWorkflow } = useTranslation('workflow')
|
||||
@ -86,9 +92,10 @@ function AppDeployContent({ appId }: { appId: string }) {
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 grow flex-col gap-4 px-6 py-2">
|
||||
<BuiltInEnvironmentCard />
|
||||
<BuiltInEnvironmentCard canViewAccessPoint={canViewAccessPoint} />
|
||||
<EnvironmentTable
|
||||
appId={appId}
|
||||
canViewAccessPoint={canViewAccessPoint}
|
||||
onDeployToEnvironment={(environment) =>
|
||||
setDeploymentRequest({
|
||||
environment: environment.display_name,
|
||||
@ -139,17 +146,17 @@ export default function AppDeploy() {
|
||||
|
||||
if (!appDetail) return <Loading type="app" />
|
||||
|
||||
const canDeploy = getAppACLCapabilities(appDetail.permission_keys, {
|
||||
const { canDeploy, canViewAccessPoint } = getAppACLCapabilities(appDetail.permission_keys, {
|
||||
currentUserId,
|
||||
resourceMaintainer: appDetail.maintainer,
|
||||
workspacePermissionKeys,
|
||||
}).canDeploy
|
||||
})
|
||||
|
||||
if (appDetail.mode !== AppModeEnum.WORKFLOW || !canDeploy) return null
|
||||
|
||||
return (
|
||||
<AppDeployStateBoundary appId={appDetail.id}>
|
||||
<AppDeployContent appId={appDetail.id} />
|
||||
<AppDeployContent appId={appDetail.id} canViewAccessPoint={canViewAccessPoint} />
|
||||
</AppDeployStateBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
@ -32,7 +32,7 @@ export function AccessPointIcon({
|
||||
}: {
|
||||
active: boolean
|
||||
accessPoint: AccessPoint
|
||||
href: string
|
||||
href?: string
|
||||
}) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const labels = useAccessPointLabels()
|
||||
@ -40,9 +40,12 @@ export function AccessPointIcon({
|
||||
? t(($) => $['agentDetail.access.status.inService'])
|
||||
: t(($) => $['agentDetail.access.status.outOfService'])
|
||||
const label = `${labels[accessPoint]} · ${status}`
|
||||
const linkHref = active ? href : undefined
|
||||
const navigable = Boolean(linkHref)
|
||||
const triggerClassName = cn(
|
||||
'flex size-5 shrink-0 items-center justify-center rounded-md border border-divider-regular text-text-secondary shadow-xs outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid',
|
||||
active ? 'cursor-pointer hover:bg-state-base-hover' : 'cursor-not-allowed opacity-30',
|
||||
active ? 'opacity-100' : 'opacity-30',
|
||||
navigable ? 'cursor-pointer hover:bg-state-base-hover' : 'cursor-not-allowed',
|
||||
)
|
||||
const icon = (
|
||||
<span aria-hidden className={cn(ACCESS_POINT_ICON_CLASS_NAMES[accessPoint], 'size-3')} />
|
||||
@ -52,8 +55,8 @@ export function AccessPointIcon({
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
active ? (
|
||||
<Link href={href} aria-label={label} className={triggerClassName}>
|
||||
linkHref ? (
|
||||
<Link href={linkHref} aria-label={label} className={triggerClassName}>
|
||||
{icon}
|
||||
</Link>
|
||||
) : (
|
||||
|
||||
@ -168,10 +168,15 @@ describe('ContinueWorkItem', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should fall back to access point when RBAC is disabled for an access-config-only app', () => {
|
||||
renderItem(createApp({ permission_keys: [AppACLPermission.AccessConfig] }), {
|
||||
rbac_enabled: false,
|
||||
})
|
||||
it('should fall back to access point when RBAC is disabled and Access Point is viewable', () => {
|
||||
renderItem(
|
||||
createApp({
|
||||
permission_keys: [AppACLPermission.AccessConfig, AppACLPermission.AccessPointView],
|
||||
}),
|
||||
{
|
||||
rbac_enabled: false,
|
||||
},
|
||||
)
|
||||
|
||||
expect(screen.getByRole('link', { name: /Continue App/ })).toHaveAttribute(
|
||||
'href',
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
"api_extension.manage": "إدارة إعدادات امتداد API",
|
||||
"app.access_config": "تكوين أذونات الوصول إلى التطبيق",
|
||||
"app.acl.access_config": "عرض أذونات الوصول وإدارتها",
|
||||
"app.acl.access_point_manage": "إدارة نقاط الوصول",
|
||||
"app.acl.access_point_view": "عرض نقاط الوصول",
|
||||
"app.acl.delete": "حذف التطبيق",
|
||||
"app.acl.deploy": "نشر التطبيق",
|
||||
"app.acl.edit": "تعديل معلومات التطبيق وتنسيقه",
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
"api_extension.manage": "API-Erweiterungskonfiguration verwalten",
|
||||
"app.access_config": "App-Zugriffsberechtigungen konfigurieren",
|
||||
"app.acl.access_config": "Zugriffsberechtigungen anzeigen und verwalten",
|
||||
"app.acl.access_point_manage": "Zugriffspunkte verwalten",
|
||||
"app.acl.access_point_view": "Zugriffspunkte anzeigen",
|
||||
"app.acl.delete": "App löschen",
|
||||
"app.acl.deploy": "App bereitstellen",
|
||||
"app.acl.edit": "App-Informationen bearbeiten und App orchestrieren",
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
"api_extension.manage": "Manage API extension configuration",
|
||||
"app.access_config": "Configure app access permissions",
|
||||
"app.acl.access_config": "View and manage access permissions",
|
||||
"app.acl.access_point_manage": "Manage access points",
|
||||
"app.acl.access_point_view": "View access points",
|
||||
"app.acl.delete": "Delete app",
|
||||
"app.acl.deploy": "Deploy app",
|
||||
"app.acl.edit": "Edit app information and orchestrate app",
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
"api_extension.manage": "Gestionar la configuración de la extensión de API",
|
||||
"app.access_config": "Configurar los permisos de acceso de la app",
|
||||
"app.acl.access_config": "Ver y gestionar los permisos de acceso",
|
||||
"app.acl.access_point_manage": "Gestionar puntos de acceso",
|
||||
"app.acl.access_point_view": "Ver puntos de acceso",
|
||||
"app.acl.delete": "Eliminar app",
|
||||
"app.acl.deploy": "Desplegar la app",
|
||||
"app.acl.edit": "Editar la información y orquestar la app",
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
"api_extension.manage": "مدیریت پیکربندی افزونه API",
|
||||
"app.access_config": "پیکربندی مجوزهای دسترسی برنامه",
|
||||
"app.acl.access_config": "مشاهده و مدیریت مجوزهای دسترسی",
|
||||
"app.acl.access_point_manage": "مدیریت نقاط دسترسی",
|
||||
"app.acl.access_point_view": "مشاهده نقاط دسترسی",
|
||||
"app.acl.delete": "حذف برنامه",
|
||||
"app.acl.deploy": "استقرار برنامه",
|
||||
"app.acl.edit": "ویرایش اطلاعات برنامه و هماهنگسازی برنامه",
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
"api_extension.manage": "Gérer la configuration de l'extension API",
|
||||
"app.access_config": "Configurer les autorisations d'accès à l'application",
|
||||
"app.acl.access_config": "Afficher et gérer les autorisations d'accès",
|
||||
"app.acl.access_point_manage": "Gérer les points d’accès",
|
||||
"app.acl.access_point_view": "Afficher les points d’accès",
|
||||
"app.acl.delete": "Supprimer l'application",
|
||||
"app.acl.deploy": "Déployer l'application",
|
||||
"app.acl.edit": "Modifier les informations et orchestrer l'application",
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
"api_extension.manage": "API एक्सटेंशन कॉन्फ़िगरेशन प्रबंधित करें",
|
||||
"app.access_config": "ऐप एक्सेस अनुमतियाँ कॉन्फ़िगर करें",
|
||||
"app.acl.access_config": "एक्सेस अनुमतियाँ देखें और प्रबंधित करें",
|
||||
"app.acl.access_point_manage": "एक्सेस पॉइंट प्रबंधित करें",
|
||||
"app.acl.access_point_view": "एक्सेस पॉइंट देखें",
|
||||
"app.acl.delete": "ऐप हटाएं",
|
||||
"app.acl.deploy": "ऐप डिप्लॉय करें",
|
||||
"app.acl.edit": "ऐप की जानकारी संपादित करें और ऐप को ऑर्केस्ट्रेट करें",
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
"api_extension.manage": "Kelola konfigurasi ekstensi API",
|
||||
"app.access_config": "Konfigurasikan izin akses aplikasi",
|
||||
"app.acl.access_config": "Lihat dan kelola izin akses",
|
||||
"app.acl.access_point_manage": "Kelola titik akses",
|
||||
"app.acl.access_point_view": "Lihat titik akses",
|
||||
"app.acl.delete": "Hapus aplikasi",
|
||||
"app.acl.deploy": "Deploy aplikasi",
|
||||
"app.acl.edit": "Edit informasi aplikasi dan orkestrasikan aplikasi",
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
"api_extension.manage": "Gestisci la configurazione delle estensioni API",
|
||||
"app.access_config": "Configura i permessi di accesso all'app",
|
||||
"app.acl.access_config": "Visualizza e gestisci i permessi di accesso",
|
||||
"app.acl.access_point_manage": "Gestisci i punti di accesso",
|
||||
"app.acl.access_point_view": "Visualizza i punti di accesso",
|
||||
"app.acl.delete": "Elimina app",
|
||||
"app.acl.deploy": "Distribuisci app",
|
||||
"app.acl.edit": "Modifica le informazioni e orchestra l'app",
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
"api_extension.manage": "API拡張設定を管理",
|
||||
"app.access_config": "アプリアクセス権限を設定",
|
||||
"app.acl.access_config": "アクセス権限の表示と管理",
|
||||
"app.acl.access_point_manage": "アクセスポイントを管理",
|
||||
"app.acl.access_point_view": "アクセスポイントを表示",
|
||||
"app.acl.delete": "アプリを削除",
|
||||
"app.acl.deploy": "アプリをデプロイ",
|
||||
"app.acl.edit": "アプリ情報の編集とアプリのオーケストレーション",
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
"api_extension.manage": "API 확장 구성 관리",
|
||||
"app.access_config": "앱 접근 권한 구성",
|
||||
"app.acl.access_config": "접근 권한 보기 및 관리",
|
||||
"app.acl.access_point_manage": "액세스 포인트 관리",
|
||||
"app.acl.access_point_view": "액세스 포인트 보기",
|
||||
"app.acl.delete": "앱 삭제",
|
||||
"app.acl.deploy": "앱 배포",
|
||||
"app.acl.edit": "앱 정보 편집 및 앱 오케스트레이션",
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
"api_extension.manage": "ຈັດການການຕັ້ງຄ່າ API extension",
|
||||
"app.access_config": "ຕັ້ງຄ່າສິດການເຂົ້າເຖິງແອັບ",
|
||||
"app.acl.access_config": "ເບິ່ງ ແລະ ຈັດການສິດການເຂົ້າເຖິງ",
|
||||
"app.acl.access_point_manage": "ຈັດການຈຸດເຂົ້າເຖິງ",
|
||||
"app.acl.access_point_view": "ເບິ່ງຈຸດເຂົ້າເຖິງ",
|
||||
"app.acl.delete": "ລຶບແອັບ",
|
||||
"app.acl.deploy": "ຕິດຕັ້ງແອັບ",
|
||||
"app.acl.edit": "ແກ້ໄຂຂໍ້ມູນແອັບ ແລະ ຈັດການລະບົບແອັບ",
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
"api_extension.manage": "API-extensieconfiguratie beheren",
|
||||
"app.access_config": "Toegangsrechten voor app configureren",
|
||||
"app.acl.access_config": "Toegangsrechten bekijken en beheren",
|
||||
"app.acl.access_point_manage": "Toegangspunten beheren",
|
||||
"app.acl.access_point_view": "Toegangspunten bekijken",
|
||||
"app.acl.delete": "App verwijderen",
|
||||
"app.acl.deploy": "App implementeren",
|
||||
"app.acl.edit": "App-informatie bewerken en app orkestreren",
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
"api_extension.manage": "Zarządzaj konfiguracją rozszerzenia API",
|
||||
"app.access_config": "Konfiguruj uprawnienia dostępu do aplikacji",
|
||||
"app.acl.access_config": "Wyświetlaj uprawnienia dostępu i zarządzaj nimi",
|
||||
"app.acl.access_point_manage": "Zarządzaj punktami dostępu",
|
||||
"app.acl.access_point_view": "Wyświetlaj punkty dostępu",
|
||||
"app.acl.delete": "Usuń aplikację",
|
||||
"app.acl.deploy": "Wdróż aplikację",
|
||||
"app.acl.edit": "Edytuj informacje o aplikacji i orkiestruj aplikację",
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
"api_extension.manage": "Gerenciar configuração de extensão de API",
|
||||
"app.access_config": "Configurar permissões de acesso ao aplicativo",
|
||||
"app.acl.access_config": "Visualizar e gerenciar permissões de acesso",
|
||||
"app.acl.access_point_manage": "Gerenciar pontos de acesso",
|
||||
"app.acl.access_point_view": "Visualizar pontos de acesso",
|
||||
"app.acl.delete": "Excluir aplicativo",
|
||||
"app.acl.deploy": "Implantar aplicativo",
|
||||
"app.acl.edit": "Editar informações e orquestrar o aplicativo",
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
"api_extension.manage": "Gestionează configurația extensiei API",
|
||||
"app.access_config": "Configurează permisiunile de acces ale aplicației",
|
||||
"app.acl.access_config": "Vizualizează și gestionează permisiunile de acces",
|
||||
"app.acl.access_point_manage": "Gestionează punctele de acces",
|
||||
"app.acl.access_point_view": "Vizualizează punctele de acces",
|
||||
"app.acl.delete": "Șterge aplicația",
|
||||
"app.acl.deploy": "Implementează aplicația",
|
||||
"app.acl.edit": "Editează informațiile aplicației și orchestrează aplicația",
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
"api_extension.manage": "Управление конфигурацией API-расширений",
|
||||
"app.access_config": "Настройка прав доступа к приложению",
|
||||
"app.acl.access_config": "Просмотр и управление правами доступа",
|
||||
"app.acl.access_point_manage": "Управление точками доступа",
|
||||
"app.acl.access_point_view": "Просмотр точек доступа",
|
||||
"app.acl.delete": "Удаление приложения",
|
||||
"app.acl.deploy": "Развертывание приложения",
|
||||
"app.acl.edit": "Редактирование информации о приложении и оркестрация приложения",
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
"api_extension.manage": "Upravljanje konfiguracije razširitve API",
|
||||
"app.access_config": "Konfiguracija dovoljenj za dostop do aplikacije",
|
||||
"app.acl.access_config": "Ogled in upravljanje dovoljenj za dostop",
|
||||
"app.acl.access_point_manage": "Upravljanje dostopnih točk",
|
||||
"app.acl.access_point_view": "Ogled dostopnih točk",
|
||||
"app.acl.delete": "Izbriši aplikacijo",
|
||||
"app.acl.deploy": "Uvedi aplikacijo",
|
||||
"app.acl.edit": "Uredi podatke o aplikaciji in orkestriraj aplikacijo",
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
"api_extension.manage": "จัดการการกําหนดค่าส่วนขยาย API",
|
||||
"app.access_config": "กําหนดค่าสิทธิ์การเข้าถึงแอป",
|
||||
"app.acl.access_config": "ดูและจัดการสิทธิ์การเข้าถึง",
|
||||
"app.acl.access_point_manage": "จัดการจุดเข้าถึง",
|
||||
"app.acl.access_point_view": "ดูจุดเข้าถึง",
|
||||
"app.acl.delete": "ลบแอป",
|
||||
"app.acl.deploy": "ปรับใช้แอป",
|
||||
"app.acl.edit": "แก้ไขข้อมูลแอปและจัดวางแอป",
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
"api_extension.manage": "API uzantısı yapılandırmasını yönet",
|
||||
"app.access_config": "Uygulama erişim izinlerini yapılandır",
|
||||
"app.acl.access_config": "Erişim izinlerini görüntüle ve yönet",
|
||||
"app.acl.access_point_manage": "Erişim noktalarını yönet",
|
||||
"app.acl.access_point_view": "Erişim noktalarını görüntüle",
|
||||
"app.acl.delete": "Uygulamayı sil",
|
||||
"app.acl.deploy": "Uygulamayı dağıt",
|
||||
"app.acl.edit": "Uygulama bilgilerini düzenle ve uygulamayı orkestre et",
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
"api_extension.manage": "Керування конфігурацією розширення API",
|
||||
"app.access_config": "Налаштування дозволів доступу до застосунку",
|
||||
"app.acl.access_config": "Переглядати дозволи доступу та керувати ними",
|
||||
"app.acl.access_point_manage": "Керувати точками доступу",
|
||||
"app.acl.access_point_view": "Переглядати точки доступу",
|
||||
"app.acl.delete": "Видалити застосунок",
|
||||
"app.acl.deploy": "Розгорнути застосунок",
|
||||
"app.acl.edit": "Редагувати інформацію про застосунок та оркеструвати застосунок",
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
"api_extension.manage": "Quản lý cấu hình phần mở rộng API",
|
||||
"app.access_config": "Cấu hình quyền truy cập ứng dụng",
|
||||
"app.acl.access_config": "Xem và quản lý quyền truy cập",
|
||||
"app.acl.access_point_manage": "Quản lý điểm truy cập",
|
||||
"app.acl.access_point_view": "Xem điểm truy cập",
|
||||
"app.acl.delete": "Xóa ứng dụng",
|
||||
"app.acl.deploy": "Triển khai ứng dụng",
|
||||
"app.acl.edit": "Chỉnh sửa thông tin và điều phối ứng dụng",
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
"api_extension.manage": "管理API扩展",
|
||||
"app.access_config": "配置应用访问权限",
|
||||
"app.acl.access_config": "查看与管理访问权限",
|
||||
"app.acl.access_point_manage": "管理访问点",
|
||||
"app.acl.access_point_view": "查看访问点",
|
||||
"app.acl.delete": "删除应用",
|
||||
"app.acl.deploy": "部署应用",
|
||||
"app.acl.edit": "编辑应用信息与编排应用",
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
"api_extension.manage": "管理API擴充配置",
|
||||
"app.access_config": "配置應用訪問權限",
|
||||
"app.acl.access_config": "檢視與管理存取權限",
|
||||
"app.acl.access_point_manage": "管理存取點",
|
||||
"app.acl.access_point_view": "檢視存取點",
|
||||
"app.acl.delete": "刪除應用",
|
||||
"app.acl.deploy": "部署應用",
|
||||
"app.acl.edit": "編輯應用資訊與編排應用",
|
||||
|
||||
@ -14,12 +14,32 @@ describe('app-redirection', () => {
|
||||
* - App mode (workflow, advanced-chat, chat, completion, agent-chat)
|
||||
*/
|
||||
describe('getRedirectionPath', () => {
|
||||
it('returns access point path when app ACL cannot access guarded pages', () => {
|
||||
const app = { id: 'app-123', mode: AppModeEnum.CHAT, permission_keys: [] }
|
||||
it('returns access point path when it is the only accessible app page', () => {
|
||||
const app = {
|
||||
id: 'app-123',
|
||||
mode: AppModeEnum.CHAT,
|
||||
permission_keys: [AppACLPermission.AccessPointView],
|
||||
}
|
||||
const result = getRedirectionPath(app)
|
||||
expect(result).toBe('/app/app-123/access-point')
|
||||
})
|
||||
|
||||
it('returns the app list when no app page is accessible', () => {
|
||||
const app = { id: 'app-123', mode: AppModeEnum.CHAT, permission_keys: [] }
|
||||
|
||||
expect(getRedirectionPath(app)).toBe('/apps')
|
||||
})
|
||||
|
||||
it('returns the access point path for Access Point managers', () => {
|
||||
const app = {
|
||||
id: 'app-123',
|
||||
mode: AppModeEnum.CHAT,
|
||||
permission_keys: [AppACLPermission.AccessPointManage],
|
||||
}
|
||||
|
||||
expect(getRedirectionPath(app)).toBe('/app/app-123/access-point')
|
||||
})
|
||||
|
||||
it('returns workflow path for workflow mode when app ACL can access layout', () => {
|
||||
const app = {
|
||||
id: 'app-123',
|
||||
@ -92,7 +112,11 @@ describe('app-redirection', () => {
|
||||
})
|
||||
|
||||
it('handles different app IDs', () => {
|
||||
const app1 = { id: 'abc-123', mode: AppModeEnum.CHAT, permission_keys: [] }
|
||||
const app1 = {
|
||||
id: 'abc-123',
|
||||
mode: AppModeEnum.CHAT,
|
||||
permission_keys: [AppACLPermission.AccessPointView],
|
||||
}
|
||||
const app2 = {
|
||||
id: 'xyz-789',
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
@ -125,11 +149,11 @@ describe('app-redirection', () => {
|
||||
expect(getRedirectionPath(app, { isRbacEnabled: true })).toBe('/app/app-123/access-config')
|
||||
})
|
||||
|
||||
it('returns access point path for access config only apps when RBAC is disabled', () => {
|
||||
it('returns access point path when RBAC is disabled and Access Point remains accessible', () => {
|
||||
const app = {
|
||||
id: 'app-123',
|
||||
mode: AppModeEnum.CHAT,
|
||||
permission_keys: [AppACLPermission.AccessConfig],
|
||||
permission_keys: [AppACLPermission.AccessConfig, AppACLPermission.AccessPointView],
|
||||
}
|
||||
|
||||
expect(getRedirectionPath(app, { isRbacEnabled: false })).toBe('/app/app-123/access-point')
|
||||
@ -173,8 +197,12 @@ describe('app-redirection', () => {
|
||||
/**
|
||||
* Tests that the redirection function is called with the correct path
|
||||
*/
|
||||
it('calls redirection function with access point path when app ACL cannot access guarded pages', () => {
|
||||
const app = { id: 'app-123', mode: AppModeEnum.CHAT, permission_keys: [] }
|
||||
it('calls redirection function with access point path when it is accessible', () => {
|
||||
const app = {
|
||||
id: 'app-123',
|
||||
mode: AppModeEnum.CHAT,
|
||||
permission_keys: [AppACLPermission.AccessPointView],
|
||||
}
|
||||
const mockRedirect = vi.fn()
|
||||
|
||||
getRedirection(app, mockRedirect)
|
||||
|
||||
@ -34,7 +34,9 @@ export const getRedirectionPath = (
|
||||
if (app.mode === AppModeEnum.WORKFLOW && appACLCapabilities.canDeploy)
|
||||
return `/app/${app.id}/deploy`
|
||||
|
||||
return `/app/${app.id}/access-point`
|
||||
if (appACLCapabilities.canViewAccessPoint) return `/app/${app.id}/access-point`
|
||||
|
||||
return '/apps'
|
||||
}
|
||||
|
||||
export const getRedirection = (
|
||||
|
||||
@ -50,6 +50,16 @@ describe('permission', () => {
|
||||
expect(releaseCapabilities.canDeploy).toBe(false)
|
||||
})
|
||||
|
||||
it('allows Access Point managers to view the page without granting management to viewers', () => {
|
||||
const viewCapabilities = getAppACLCapabilities([AppACLPermission.AccessPointView])
|
||||
const manageCapabilities = getAppACLCapabilities([AppACLPermission.AccessPointManage])
|
||||
|
||||
expect(viewCapabilities.canViewAccessPoint).toBe(true)
|
||||
expect(viewCapabilities.canManageAccessPoint).toBe(false)
|
||||
expect(manageCapabilities.canViewAccessPoint).toBe(true)
|
||||
expect(manageCapabilities.canManageAccessPoint).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps monitor, tracing config, and log/annotation permissions independent', () => {
|
||||
const monitorCapabilities = getAppACLCapabilities([AppACLPermission.Monitor])
|
||||
const tracingCapabilities = getAppACLCapabilities([AppACLPermission.TracingConfig])
|
||||
@ -109,6 +119,8 @@ describe('permission', () => {
|
||||
})
|
||||
|
||||
expect(capabilities.canViewLayout).toBe(true)
|
||||
expect(capabilities.canViewAccessPoint).toBe(true)
|
||||
expect(capabilities.canManageAccessPoint).toBe(true)
|
||||
expect(capabilities.canTestAndRun).toBe(true)
|
||||
expect(capabilities.canEdit).toBe(true)
|
||||
expect(capabilities.canImportExportDSL).toBe(true)
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import type { PermissionKey } from '@/models/access-control'
|
||||
|
||||
export const AppACLPermission = {
|
||||
AccessPointManage: 'app.acl.access_point_manage',
|
||||
AccessPointView: 'app.acl.access_point_view',
|
||||
Preview: 'app.acl.preview',
|
||||
ViewLayout: 'app.acl.view_layout',
|
||||
TestAndRun: 'app.acl.test_and_run',
|
||||
@ -38,6 +40,8 @@ export type ResourceMaintainerPermissionOptions = {
|
||||
}
|
||||
|
||||
type AppACLCapabilities = {
|
||||
canManageAccessPoint: boolean
|
||||
canViewAccessPoint: boolean
|
||||
canViewLayout: boolean
|
||||
canTestAndRun: boolean
|
||||
canEdit: boolean
|
||||
@ -133,8 +137,21 @@ export const getAppACLCapabilities = (
|
||||
AppACLPermission.Edit,
|
||||
hasMaintainerPermissions,
|
||||
)
|
||||
const canManageAccessPoint = hasResourcePermission(
|
||||
permissionKeys,
|
||||
AppACLPermission.AccessPointManage,
|
||||
hasMaintainerPermissions,
|
||||
)
|
||||
|
||||
return {
|
||||
canManageAccessPoint,
|
||||
canViewAccessPoint:
|
||||
canManageAccessPoint ||
|
||||
hasResourcePermission(
|
||||
permissionKeys,
|
||||
AppACLPermission.AccessPointView,
|
||||
hasMaintainerPermissions,
|
||||
),
|
||||
canViewLayout,
|
||||
canTestAndRun,
|
||||
canEdit,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user