feat: app access point permission (#41631)

Co-authored-by: fatelei <fatelei@gmail.com>
This commit is contained in:
Wu Tianwei 2026-09-02 06:34:33 +00:00 committed by GitHub
parent 183741b5f7
commit 82d86dcf98
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
66 changed files with 728 additions and 221 deletions

View File

@ -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,
},
}

View File

@ -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,
),
],

View File

@ -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(

View File

@ -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>

View File

@ -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'

View File

@ -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
? [
{

View File

@ -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

View File

@ -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 }),
)
})
})

View File

@ -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 }),
)
})
})

View File

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

View File

@ -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}

View File

@ -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)}

View File

@ -68,7 +68,7 @@ function renderCard(availability: 'available' | 'loading' | 'unavailable') {
<TriggerAccessPointCard
appInfo={appInfo}
availability={availability}
canEdit
canManageAccessPoint
onToggleResult={vi.fn()}
/>,
)

View File

@ -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 () => {

View File

@ -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()
})
})

View File

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

View File

@ -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)}
/>

View File

@ -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)}
/>

View File

@ -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)}
/>

View File

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

View File

@ -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)}
/>

View File

@ -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) => {

View File

@ -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>

View File

@ -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 {

View File

@ -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,

View File

@ -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}

View File

@ -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}

View File

@ -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' })}

View File

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

View File

@ -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}

View File

@ -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:

View File

@ -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>,
)

View File

@ -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>

View File

@ -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}

View File

@ -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>

View File

@ -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>
)
}

View File

@ -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>
) : (

View File

@ -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',

View File

@ -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": "تعديل معلومات التطبيق وتنسيقه",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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": "ویرایش اطلاعات برنامه و هماهنگ‌سازی برنامه",

View File

@ -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 daccès",
"app.acl.access_point_view": "Afficher les points daccès",
"app.acl.delete": "Supprimer l'application",
"app.acl.deploy": "Déployer l'application",
"app.acl.edit": "Modifier les informations et orchestrer l'application",

View File

@ -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": "ऐप की जानकारी संपादित करें और ऐप को ऑर्केस्ट्रेट करें",

View File

@ -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",

View File

@ -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",

View File

@ -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": "アプリ情報の編集とアプリのオーケストレーション",

View File

@ -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": "앱 정보 편집 및 앱 오케스트레이션",

View File

@ -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": "ແກ້ໄຂຂໍ້ມູນແອັບ ແລະ ຈັດການລະບົບແອັບ",

View File

@ -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",

View File

@ -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ę",

View File

@ -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",

View File

@ -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",

View File

@ -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": "Редактирование информации о приложении и оркестрация приложения",

View File

@ -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",

View File

@ -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": "แก้ไขข้อมูลแอปและจัดวางแอป",

View File

@ -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",

View File

@ -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": "Редагувати інформацію про застосунок та оркеструвати застосунок",

View File

@ -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",

View File

@ -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": "编辑应用信息与编排应用",

View File

@ -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": "編輯應用資訊與編排應用",

View File

@ -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)

View File

@ -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 = (

View File

@ -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)

View File

@ -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,