refactor: simplify deployment access ownership (#37994)

This commit is contained in:
Stephen Zhou 2026-06-26 12:10:56 +08:00 committed by GitHub
parent 4cd8b8c733
commit 16b698b54d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
55 changed files with 619 additions and 786 deletions

View File

@ -12,6 +12,7 @@ Use this as the component decision guide for Dify web. Existing code is referenc
| Question | Default | Promote or extract only when |
| --- | --- | --- |
| Where should code live? | Keep it local to the feature workflow, route, or owner. | Multiple verticals need the same stable primitive. |
| How should route/tab folders be named? | Match the current route segment, tab name, or user-visible surface. | Keep a historical or broader parent only when it still owns multiple surfaces. |
| Who owns state, data, and handlers? | The lowest component that uses them. | A parent coordinates shared loading, errors, empty UI, selection, submission, navigation, or one consistent snapshot. |
| Should this become Jotai state? | Keep synchronous UI/form state in component or DOM state. | Siblings need one source of truth, the value drives atoms, or scoped workflow state must survive hidden/unmounted steps. |
| Should URL state enter Jotai? | Let Next.js route params and `nuqs` own URL state and updates. | Query atoms or shared derived atoms need a read-only bridge hydrated at the route/surface boundary. |
@ -23,7 +24,7 @@ Use this as the component decision guide for Dify web. Existing code is referenc
- Search before adding UI, hooks, helpers, query utilities, or styling patterns. Reuse existing base components, feature components, hooks, utilities, and design styles when they fit.
- Follow Dify's CSS-first Tailwind v4 contract from `packages/dify-ui/README.md` and `packages/dify-ui/AGENTS.md`. Prefer design-system tokens, utilities, and radius mappings over generic Tailwind choices.
- Group feature code by workflow, route, or ownership area: components, hooks, local types, query helpers, atoms, constants, and small utilities should live near the code that changes with them.
- Group feature code by workflow, route, or ownership area with route-aligned names: components, hooks, local types, query helpers, atoms, constants, tests, and small utilities should live near the code that changes with them.
- Keep source/default selection, validation, dirty checks, and payload shaping close to the workflow that owns submit behavior. Do not hide flow-specific priority order, fallback behavior, or submit semantics in generic utilities.
- Prefer direct conditionals for small branch-specific decisions, especially form source selection and request payload assembly.
- Loading states for page sections, cards, lists, tables, forms, and drawers should be skeletons scoped to the content being loaded. Use spinners only for small inline busy indicators.
@ -32,6 +33,8 @@ Use this as the component decision guide for Dify web. Existing code is referenc
- State-heavy wizards, drawers, modals, and secondary workflows can be a small feature surface: an entry file, one feature-local state file when Jotai is actually needed, and shallow `ui/` owners that match real visual regions.
- The entry file handles route integration, provider wiring, close behavior, and surface mounting. The composition owner handles high-level workflow branching. The closest visual owner handles section branching.
- When a page or tab maps to a route segment, name its feature folder after that route/tab surface instead of a stale parent grouping. Remove misleading intermediate folders when only one surface remains.
- When a tab folder grows into several independent sections or action areas, split the first level by product/visual owners. Keep the root for the entry component and cross-owner state, colocate tests with the owner folder, and put truly shared local UI under a specifically named `components/` file.
- Repeated TanStack query calls in sibling components are acceptable when each component independently consumes the data; TanStack Query deduplicates and shares cache.
- Pass stable domain identity across boundaries. Do not forward derived presentation state when the receiver can derive it from its own data source.
- A component that owns a visual surface should also own data access, loading, empty, and error states for content rendered inside it unless a parent truly coordinates that state.
@ -60,8 +63,10 @@ Use this as the component decision guide for Dify web. Existing code is referenc
- Type component signatures directly; do not use `FC` or `React.FC`.
- Prefer `function` for top-level components and module helpers. Use arrow functions for local callbacks, handlers, and lambda-style APIs.
- Prefer named exports. Use default exports only where the framework requires them, such as Next.js route files.
- Avoid barrel files that only re-export secondary owners. `index.tsx` is acceptable for a route/tab entry component; import header controls, switches, sections, and row owners from their concrete owner files.
- Type simple one-off props inline. Use a named `Props` type only when reused, exported, complex, or clearer.
- Use API-generated or API-returned types at component boundaries. Keep small UI conversion helpers and one-off UI extensions beside the component that needs them.
- Avoid `common.tsx` buckets for shared UI. Use a feature-local `components/` folder with concrete filenames that describe the shared role.
- Do not create type aliases that only rename another type. Use aliases only for real UI concepts, refinements, or reusable local contracts.
- Name values by their domain role and backend API contract, especially persistent IDs and route params. Normalize framework or route params at the boundary.
- Put fallback and invariant checks in the lowest component that already handles that state. Do not extract helpers whose only behavior is hiding missing display data.
@ -96,7 +101,7 @@ Use this as the component decision guide for Dify web. Existing code is referenc
## Boundaries And Overlays
- Use the first level below a page or tab to organize independent page sections when it adds structure. This layer is layout/semantic first, not automatically the data owner.
- Use the first level below a page or tab to organize independent page sections when it adds structure or the root folder becomes noisy. This layer is layout/semantic first, not automatically the data owner.
- Treat component names, semantic roles, and user- or design-marked visual regions as boundary constraints. Keep adjacent UI as a sibling owner or introduce a correctly named broader owner.
- Keep cohesive forms, menu bodies, and one-off helpers local unless they need their own state, reuse, or semantic boundary.
- Separate hidden secondary surfaces from the trigger's main flow. For dialogs, dropdowns, popovers, and similar branches, extract a small local component when hidden content would obscure the parent.

View File

@ -1,8 +1,5 @@
import { AccessTab } from '@/features/deployments/detail/access-tab'
export default async function InstanceDetailAccessPage({ params }: {
params: Promise<{ appInstanceId: string }>
}) {
const { appInstanceId } = await params
return <AccessTab appInstanceId={appInstanceId} />
export default function InstanceDetailAccessPage() {
return <AccessTab />
}

View File

@ -1,8 +1,5 @@
import { DeveloperApiTab } from '@/features/deployments/detail/developer-api-tab'
import { DeveloperApiTab } from '@/features/deployments/detail/access-tab/developer-api'
export default async function InstanceDetailApiTokensPage({ params }: {
params: Promise<{ appInstanceId: string }>
}) {
const { appInstanceId } = await params
return <DeveloperApiTab appInstanceId={appInstanceId} />
export default function InstanceDetailApiTokensPage() {
return <DeveloperApiTab />
}

View File

@ -1,31 +0,0 @@
'use client'
import { useAtomValue } from 'jotai'
import { AccessChannelsSection } from './settings-tab/access/channels-section'
import { AccessPermissionsSection } from './settings-tab/access/permissions-section'
import { accessSettingsQueryAtom } from './settings-tab/access/state'
export function AccessTab({ appInstanceId }: {
appInstanceId: string
}) {
const accessSettingsQuery = useAtomValue(accessSettingsQueryAtom)
return (
<div className="flex w-full max-w-[960px] min-w-0 flex-col gap-y-4 px-6 py-6 sm:px-20 sm:py-8">
<AccessChannelsSection
appInstanceId={appInstanceId}
accessChannels={accessSettingsQuery.data?.accessChannels}
webAppEndpoints={accessSettingsQuery.data?.webAppEndpoints}
cliEndpoint={accessSettingsQuery.data?.cliEndpoint}
isLoading={accessSettingsQuery.isLoading}
isError={accessSettingsQuery.isError}
/>
<AccessPermissionsSection
appInstanceId={appInstanceId}
environmentPolicies={accessSettingsQuery.data?.environmentPolicies}
isLoading={accessSettingsQuery.isLoading}
isError={accessSettingsQuery.isError}
/>
</div>
)
}

View File

@ -48,7 +48,7 @@ async function loadState() {
function setDeploymentRoute(store: ReturnType<typeof createStore>, appInstanceId = 'app-instance-1') {
store.set(setNextRouteStateAtom, {
pathname: `/deployments/${appInstanceId}/settings/access`,
pathname: `/deployments/${appInstanceId}/access`,
params: { appInstanceId },
})
}

View File

@ -1,9 +1,20 @@
import type { AccessChannels, AccessEndpoint } from '@dify/contracts/enterprise/types.gen'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AccessChannelsSection } from '../channels-section'
import { deploymentRouteAppInstanceIdAtom } from '../../../../route-state'
import { accessSettingsQueryAtom } from '../../state'
import { AccessChannelsSection } from '../section'
const mockToggleAccessChannel = vi.hoisted(() => vi.fn())
const mockUseAtomValue = vi.hoisted(() => vi.fn())
vi.mock('jotai', async (importOriginal) => {
const actual = await importOriginal<typeof import('jotai')>()
return {
...actual,
useAtomValue: mockUseAtomValue,
}
})
vi.mock('@tanstack/react-query', () => ({
useMutation: () => ({
@ -49,19 +60,26 @@ function createEndpoint(endpointUrl: string): AccessEndpoint {
describe('AccessChannelsSection', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseAtomValue.mockImplementation((atom) => {
if (atom === deploymentRouteAppInstanceIdAtom)
return 'app-instance-1'
if (atom === accessSettingsQueryAtom) {
return {
data: {
accessChannels: createAccessChannels(),
webAppEndpoints: [createEndpoint('https://app.example.com/webapp')],
cliEndpoint: createEndpoint('https://cli.example.com/entry'),
},
isLoading: false,
isError: false,
}
}
return undefined
})
})
it('should render channel descriptions when access channels are enabled', () => {
render(
<AccessChannelsSection
appInstanceId="app-instance-1"
accessChannels={createAccessChannels()}
webAppEndpoints={[createEndpoint('https://app.example.com/webapp')]}
cliEndpoint={createEndpoint('https://cli.example.com/entry')}
isLoading={false}
isError={false}
/>,
)
render(<AccessChannelsSection />)
expect(screen.getByText('deployments.access.runAccess.webappDesc')).toBeInTheDocument()
expect(screen.getByText('deployments.access.cli.description')).toBeInTheDocument()

View File

@ -4,12 +4,15 @@ import type { AccessChannels, AccessEndpoint } from '@dify/contracts/enterprise/
import type { ReactNode } from 'react'
import { Switch, SwitchSkeleton } from '@langgenius/dify-ui/switch'
import { useMutation } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { consoleQuery } from '@/service/client'
import { DeploymentEmptyState, DeploymentNoticeState, DeploymentStateMessage } from '../../../components/empty-state'
import { Section } from '../../common'
import { CopyPill, EndpointRow } from './common'
import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
import { Section } from '../../components/section'
import { CopyPill, EndpointRow } from '../components/endpoint'
import { accessSettingsQueryAtom } from '../state'
import { getUrlOrigin } from './url'
const ACCESS_CHANNEL_SKELETON_SECTIONS = [
@ -17,22 +20,25 @@ const ACCESS_CHANNEL_SKELETON_SECTIONS = [
{ key: 'cli' },
]
function AccessChannelsSwitch({ appInstanceId, checked, accessChannels, disabled }: {
appInstanceId: string
function AccessChannelsSwitch({ checked, accessChannels, disabled }: {
checked: boolean
accessChannels?: AccessChannels
disabled?: boolean
}) {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const toggleAccessChannel = useMutation(consoleQuery.enterprise.accessService.updateAccessChannels.mutationOptions())
return (
<Switch
aria-label={t('access.channels.title')}
checked={checked}
disabled={disabled}
disabled={disabled || !appInstanceId}
loading={toggleAccessChannel.isPending}
onCheckedChange={(enabled) => {
if (!appInstanceId)
return
toggleAccessChannel.mutate({
params: { appInstanceId },
body: {
@ -106,22 +112,15 @@ function ChannelRow({ info, children }: {
)
}
export function AccessChannelsSection({
appInstanceId,
accessChannels,
webAppEndpoints,
cliEndpoint,
isLoading,
isError,
}: {
appInstanceId: string
accessChannels?: AccessChannels
webAppEndpoints?: AccessEndpoint[]
cliEndpoint?: AccessEndpoint
isLoading: boolean
isError: boolean
}) {
export function AccessChannelsSection() {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const accessSettingsQuery = useAtomValue(accessSettingsQueryAtom)
const accessChannels = accessSettingsQuery.data?.accessChannels
const webAppEndpoints: AccessEndpoint[] | undefined = accessSettingsQuery.data?.webAppEndpoints
const cliEndpoint: AccessEndpoint | undefined = accessSettingsQuery.data?.cliEndpoint
const isLoading = accessSettingsQuery.isLoading
const isError = accessSettingsQuery.isError
const runEnabled = accessChannels?.webAppEnabled ?? false
const webappRows = webAppEndpoints?.flatMap((endpoint) => {
const endpointUrl = endpoint.endpointUrl
@ -148,7 +147,6 @@ export function AccessChannelsSection({
{runEnabled ? t('overview.enabled') : t('overview.disabled')}
</span>
<AccessChannelsSwitch
appInstanceId={appInstanceId}
checked={runEnabled}
accessChannels={accessChannels}
disabled={isError}
@ -159,7 +157,7 @@ export function AccessChannelsSection({
>
{isLoading
? <AccessChannelsSkeleton />
: isError
: isError || !appInstanceId
? <DeploymentStateMessage variant="section">{t('common.loadFailed')}</DeploymentStateMessage>
: runEnabled
? (

View File

@ -1,9 +1,19 @@
import type { Environment } from '@dify/contracts/enterprise/types.gen'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { deploymentRouteAppInstanceIdAtom } from '../../../../route-state'
import { ApiKeyGenerateMenu } from '../api-key-generate-menu'
const mockMutate = vi.hoisted(() => vi.fn())
const mockUseAtomValue = vi.hoisted(() => vi.fn())
vi.mock('jotai', async (importOriginal) => {
const actual = await importOriginal<typeof import('jotai')>()
return {
...actual,
useAtomValue: mockUseAtomValue,
}
})
vi.mock('@tanstack/react-query', () => ({
useMutation: () => ({
@ -34,12 +44,16 @@ function createEnvironment(): Environment {
describe('ApiKeyGenerateMenu', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseAtomValue.mockImplementation((atom) => {
if (atom === deploymentRouteAppInstanceIdAtom)
return 'app-instance-1'
return undefined
})
})
it('should show the required name error when submitting an empty name', () => {
render(
<ApiKeyGenerateMenu
appInstanceId="app-instance-1"
environments={[createEnvironment()]}
onCreatedToken={vi.fn()}
/>,
@ -58,7 +72,6 @@ describe('ApiKeyGenerateMenu', () => {
it('should clear the required name error when typing a valid name', () => {
render(
<ApiKeyGenerateMenu
appInstanceId="app-instance-1"
environments={[createEnvironment()]}
onCreatedToken={vi.fn()}
/>,
@ -79,4 +92,17 @@ describe('ApiKeyGenerateMenu', () => {
expect(screen.queryByText('deployments.access.api.nameRequired')).not.toBeInTheDocument()
})
it('should disable the trigger when route app instance is missing', () => {
mockUseAtomValue.mockReturnValue(undefined)
render(
<ApiKeyGenerateMenu
environments={[createEnvironment()]}
onCreatedToken={vi.fn()}
/>,
)
expect(screen.getByRole('button', { name: 'deployments.access.api.newKey' })).toBeDisabled()
})
})

View File

@ -24,20 +24,20 @@ import {
} from '@langgenius/dify-ui/select'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useEffect, useId, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
import { generateApiTokenName } from './api-token-name'
export function ApiKeyGenerateMenu({
appInstanceId,
environments,
onCreatedToken,
triggerVariant = 'secondary',
triggerClassName,
children,
}: {
appInstanceId: string
environments: Environment[]
onCreatedToken: (token: string) => void
triggerVariant?: ButtonProps['variant']
@ -45,6 +45,7 @@ export function ApiKeyGenerateMenu({
children?: (props: { trigger: ReactNode }) => ReactNode
}) {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const nameInputId = useId()
const nameInputRef = useRef<HTMLInputElement>(null)
const [createDialogOpen, setCreateDialogOpen] = useState(false)
@ -56,7 +57,7 @@ export function ApiKeyGenerateMenu({
const selectedEnvironment = selectedEnvironmentId
? selectableEnvironments.find(env => env.id === selectedEnvironmentId)
: undefined
const disabled = selectableEnvironments.length === 0
const disabled = !appInstanceId || selectableEnvironments.length === 0
const isCreating = generateApiKey.isPending
useEffect(() => {
@ -105,7 +106,7 @@ export function ApiKeyGenerateMenu({
const name = draftName.trim()
if (!selectedEnvironmentId || !name) {
if (!appInstanceId || !selectedEnvironmentId || !name) {
setNameError(true)
return
}

View File

@ -28,10 +28,10 @@ import {
DetailTableHead,
DetailTableHeader,
DetailTableRow,
} from '../../table'
} from '../../components/detail-table'
import {
API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES,
} from '../../table-styles'
} from '../../components/detail-table-styles'
function ApiKeyName({ apiKey }: {
apiKey: ApiKey

View File

@ -6,7 +6,7 @@ import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitl
import { toast } from '@langgenius/dify-ui/toast'
import { useClipboard } from 'foxact/use-clipboard'
import { useTranslation } from 'react-i18next'
import { CopyPill } from './common'
import { CopyPill } from '../components/endpoint'
function buildCurlExample(apiUrl: string, token: string) {
return `curl -X POST '${apiUrl}' \\

View File

@ -13,6 +13,7 @@ import {
DrawerTitle,
DrawerViewport,
} from '@langgenius/dify-ui/drawer'
import { useAtomValue } from 'jotai'
import { useTranslation } from 'react-i18next'
import TemplateWorkflowEn from '@/app/components/develop/template/template_workflow.en.mdx'
import TemplateWorkflowJa from '@/app/components/develop/template/template_workflow.ja.mdx'
@ -21,6 +22,7 @@ import { useLocale } from '@/context/i18n'
import useTheme from '@/hooks/use-theme'
import { getDocLanguage } from '@/i18n-config/language'
import { AppModeEnum, Theme } from '@/types/app'
import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
type PromptVariable = { key: string, name: string }
type WorkflowApiDocAppDetail = Pick<App, 'id' | 'mode' | 'api_base_url'>
@ -67,19 +69,22 @@ function WorkflowDocTemplate({ docLanguage, appDetail, variables, inputs }: Work
export function DeveloperApiDocsDrawer({
open,
appInstanceId,
apiBaseUrl,
onOpenChange,
}: {
open: boolean
appInstanceId: string
apiBaseUrl: string
onOpenChange: (open: boolean) => void
}) {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const locale = useLocale()
const { theme } = useTheme()
const docLanguage = getDocLanguage(locale)
if (!appInstanceId)
return null
const appDetail: WorkflowApiDocAppDetail = {
id: appInstanceId,
mode: AppModeEnum.WORKFLOW,

View File

@ -0,0 +1,11 @@
'use client'
import { DeveloperApiSection } from './section'
export function DeveloperApiTab() {
return (
<div className="flex w-full max-w-[960px] min-w-0 flex-col gap-y-4 px-6 py-6 sm:px-20 sm:py-8">
<DeveloperApiSection />
</div>
)
}

View File

@ -14,54 +14,39 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { DeploymentEmptyState, DeploymentStateMessage } from '../../../components/empty-state'
import { DeveloperApiDocsDrawer } from './api-docs-drawer'
import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
import { CopyPill } from '../components/endpoint'
import { developerApiSettingsQueryAtom } from '../state'
import { ApiKeyGenerateMenu } from './api-key-generate-menu'
import { ApiKeyList } from './api-key-list'
import { CopyPill } from './common'
import { CreatedApiTokenDialog } from './developer-api-created-token-dialog'
import { DeveloperApiSkeleton } from './developer-api-skeleton'
import { developerApiSettingsQueryAtom } from './state'
import { CreatedApiTokenDialog } from './created-token-dialog'
import { DeveloperApiDocsDrawer } from './docs-drawer'
import { DeveloperApiSkeleton } from './skeleton'
type CreatedApiToken = {
appInstanceId: string
token: string
}
function useDeveloperApiSettings() {
const developerApiSettingsQuery = useAtomValue(developerApiSettingsQueryAtom)
const accessChannels = developerApiSettingsQuery.data?.accessChannels
const apiEnabled = accessChannels?.developerApiEnabled ?? false
const environments = developerApiSettingsQuery.data?.environments ?? []
const apiKeys: ApiKey[] = developerApiSettingsQuery.data?.apiKeys ?? []
const apiUrl = developerApiSettingsQuery.data?.developerApiUrl.apiUrl
return {
apiEnabled,
accessChannels,
apiUrl,
environments,
apiKeys,
isLoading: developerApiSettingsQuery.isLoading,
isError: developerApiSettingsQuery.isError,
}
}
function DeveloperApiSwitch({ appInstanceId, checked, accessChannels, disabled }: {
appInstanceId: string
function DeveloperApiSwitch({ checked, accessChannels, disabled }: {
checked: boolean
accessChannels?: AccessChannels
disabled?: boolean
}) {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const toggleDeveloperAPI = useMutation(consoleQuery.enterprise.accessService.updateAccessChannels.mutationOptions())
return (
<Switch
aria-label={t('access.api.developerTitle')}
checked={checked}
disabled={disabled}
disabled={disabled || !appInstanceId}
loading={toggleDeveloperAPI.isPending}
onCheckedChange={(enabled) => {
if (!appInstanceId)
return
toggleDeveloperAPI.mutate({
params: { appInstanceId },
body: {
@ -75,18 +60,13 @@ function DeveloperApiSwitch({ appInstanceId, checked, accessChannels, disabled }
)
}
export function DeveloperApiHeaderSwitch({ appInstanceId }: {
appInstanceId: string
}) {
export function DeveloperApiHeaderSwitch() {
const { t } = useTranslation('deployments')
const {
apiEnabled,
accessChannels,
isLoading,
isError,
} = useDeveloperApiSettings()
const developerApiSettingsQuery = useAtomValue(developerApiSettingsQueryAtom)
const accessChannels = developerApiSettingsQuery.data?.accessChannels
const apiEnabled = accessChannels?.developerApiEnabled ?? false
if (isLoading)
if (developerApiSettingsQuery.isLoading)
return <SwitchSkeleton />
return (
@ -95,10 +75,9 @@ export function DeveloperApiHeaderSwitch({ appInstanceId }: {
{apiEnabled ? t('overview.enabled') : t('overview.disabled')}
</span>
<DeveloperApiSwitch
appInstanceId={appInstanceId}
checked={apiEnabled}
accessChannels={accessChannels}
disabled={isError}
disabled={developerApiSettingsQuery.isError}
/>
</div>
)
@ -132,8 +111,7 @@ function ApiKeyListSection({ apiKeys, environments, action }: {
)
}
function DeveloperApiEndpoint({ appInstanceId, apiUrl }: {
appInstanceId: string
function DeveloperApiEndpoint({ apiUrl }: {
apiUrl: string
}) {
const { t } = useTranslation('deployments')
@ -156,7 +134,6 @@ function DeveloperApiEndpoint({ appInstanceId, apiUrl }: {
</Button>
<DeveloperApiDocsDrawer
open={apiDocsOpen}
appInstanceId={appInstanceId}
apiBaseUrl={apiUrl}
onOpenChange={setApiDocsOpen}
/>
@ -164,30 +141,25 @@ function DeveloperApiEndpoint({ appInstanceId, apiUrl }: {
)
}
export function DeveloperApiSection({
appInstanceId,
}: {
appInstanceId: string
}) {
export function DeveloperApiSection() {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const [createdApiToken, setCreatedApiToken] = useState<CreatedApiToken>()
const {
apiEnabled,
apiUrl,
apiKeys,
environments,
isLoading,
isError,
} = useDeveloperApiSettings()
const visibleCreatedApiToken = createdApiToken?.appInstanceId === appInstanceId
const developerApiSettingsQuery = useAtomValue(developerApiSettingsQueryAtom)
const accessChannels = developerApiSettingsQuery.data?.accessChannels
const apiEnabled = accessChannels?.developerApiEnabled ?? false
const apiUrl = developerApiSettingsQuery.data?.developerApiUrl.apiUrl
const apiKeys: ApiKey[] = developerApiSettingsQuery.data?.apiKeys ?? []
const environments = developerApiSettingsQuery.data?.environments ?? []
const visibleCreatedApiToken = createdApiToken && createdApiToken.appInstanceId === appInstanceId
? createdApiToken.token
: undefined
const hasSelectableEnvironment = environments.some(environment => Boolean(environment.id))
if (isLoading)
if (developerApiSettingsQuery.isLoading)
return <DeveloperApiSkeleton />
if (isError)
if (developerApiSettingsQuery.isError || !appInstanceId)
return <DeploymentStateMessage variant="section">{t('common.loadFailed')}</DeploymentStateMessage>
if (!apiEnabled) {
@ -205,14 +177,12 @@ export function DeveloperApiSection({
<div className="flex flex-col gap-4">
{apiUrl && (
<DeveloperApiEndpoint
appInstanceId={appInstanceId}
apiUrl={apiUrl}
/>
)}
{hasSelectableEnvironment
? (
<ApiKeyGenerateMenu
appInstanceId={appInstanceId}
environments={environments}
triggerVariant="primary"
onCreatedToken={token => setCreatedApiToken({ appInstanceId, token })}

View File

@ -11,8 +11,8 @@ import {
DetailTableHead,
DetailTableHeader,
DetailTableRow,
} from '../../table'
import { API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES } from '../../table-styles'
} from '../../components/detail-table'
import { API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES } from '../../components/detail-table-styles'
const DEVELOPER_API_KEY_SKELETON_KEYS = ['primary-key', 'secondary-key']

View File

@ -0,0 +1,13 @@
'use client'
import { AccessChannelsSection } from './channels/section'
import { AccessPermissionsSection } from './permissions/section'
export function AccessTab() {
return (
<div className="flex w-full max-w-[960px] min-w-0 flex-col gap-y-4 px-6 py-6 sm:px-20 sm:py-8">
<AccessChannelsSection />
<AccessPermissionsSection />
</div>
)
}

View File

@ -1,7 +1,7 @@
import { AccessSubjectType } from '@dify/contracts/enterprise/types.gen'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { AccessMode } from '@/models/access-control'
import { DeploymentAccessControlDialog } from '../deployment-access-control-dialog'
import { DeploymentAccessControlDialog } from '../access-control-dialog'
const mockUseSearchAccessSubjects = vi.hoisted(() => vi.fn())
@ -25,12 +25,21 @@ describe('DeploymentAccessControlDialog', () => {
render(
<DeploymentAccessControlDialog
initialDraft={{
currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS,
specificGroups: [{ id: 'group-1', name: 'Group One', groupSize: 2 }],
specificMembers: [{ id: 'member-1', name: 'Member One', email: 'member@example.com', avatar: '', avatarUrl: '' }],
selectedGroupsForBreadcrumb: [],
}}
open
initialKind="specific"
initialSubjects={[
{
id: 'group-1',
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP,
name: 'Group One',
memberCount: 2,
},
{
id: 'member-1',
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT,
name: 'Member One',
},
]}
onClose={vi.fn()}
onSubmit={handleSubmit}
/>,
@ -38,9 +47,18 @@ describe('DeploymentAccessControlDialog', () => {
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
expect(handleSubmit).toHaveBeenCalledWith('specific', {
groups: [{ id: 'group-1', name: 'Group One', groupSize: 2 }],
members: [{ id: 'member-1', name: 'Member One', email: 'member@example.com', avatar: '', avatarUrl: '' }],
})
expect(handleSubmit).toHaveBeenCalledWith('specific', [
{
id: 'group-1',
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP,
name: 'Group One',
memberCount: 2,
},
{
id: 'member-1',
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT,
name: 'Member One',
},
])
})
})

View File

@ -4,10 +4,21 @@ import { AccessMode, AccessSubjectType } from '@dify/contracts/enterprise/types.
import { fireEvent, render, screen } from '@testing-library/react'
import { createStore, Provider as JotaiProvider } from 'jotai'
import { describe, expect, it, vi } from 'vitest'
import { EnvironmentPermissionRow } from '../permissions'
import { AccessPermissionsSection } from '../permissions-section'
import { deploymentRouteAppInstanceIdAtom } from '../../../../route-state'
import { accessSettingsQueryAtom } from '../../state'
import { EnvironmentPermissionRow } from '../environment-permission-row'
import { AccessPermissionsSection } from '../section'
const mockMutate = vi.hoisted(() => vi.fn())
const mockUseAtomValue = vi.hoisted(() => vi.fn())
vi.mock('jotai', async (importOriginal) => {
const actual = await importOriginal<typeof import('jotai')>()
return {
...actual,
useAtomValue: mockUseAtomValue,
}
})
vi.mock('@tanstack/react-query', () => ({
useInfiniteQuery: () => ({
@ -94,6 +105,11 @@ function createEnvironmentAccessPolicy(): EnvironmentAccessPolicy {
describe('EnvironmentPermissionRow', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseAtomValue.mockImplementation((atom) => {
if (atom === deploymentRouteAppInstanceIdAtom)
return 'app-instance-1'
return undefined
})
mockMutate.mockImplementation((_variables: unknown, options?: { onError?: () => void }) => {
options?.onError?.()
})
@ -102,7 +118,6 @@ describe('EnvironmentPermissionRow', () => {
it('should keep the previous permission visible when updating the policy fails', () => {
renderWithAtomStore(
<EnvironmentPermissionRow
appInstanceId="app-instance-1"
environment={createEnvironment()}
summaryPolicy={createAccessPolicy()}
/>,
@ -123,7 +138,6 @@ describe('EnvironmentPermissionRow', () => {
renderWithAtomStore(
<EnvironmentPermissionRow
appInstanceId="app-instance-1"
environment={createEnvironment()}
summaryPolicy={createAccessPolicy()}
/>,
@ -161,7 +175,6 @@ describe('EnvironmentPermissionRow', () => {
renderWithAtomStore(
<EnvironmentPermissionRow
appInstanceId="app-instance-1"
environment={createEnvironment()}
summaryPolicy={createSpecificAccessPolicy()}
/>,
@ -202,7 +215,6 @@ describe('EnvironmentPermissionRow', () => {
it('should show specific subject counts in the access summary', () => {
renderWithAtomStore(
<EnvironmentPermissionRow
appInstanceId="app-instance-1"
environment={createEnvironment()}
summaryPolicy={createSpecificAccessPolicy()}
/>,
@ -220,17 +232,24 @@ describe('EnvironmentPermissionRow', () => {
describe('AccessPermissionsSection', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseAtomValue.mockImplementation((atom) => {
if (atom === deploymentRouteAppInstanceIdAtom)
return 'app-instance-1'
if (atom === accessSettingsQueryAtom) {
return {
data: {
environmentPolicies: [createEnvironmentAccessPolicy()],
},
isLoading: false,
isError: false,
}
}
return undefined
})
})
it('should render permission rows without column headers', () => {
renderWithAtomStore(
<AccessPermissionsSection
appInstanceId="app-instance-1"
environmentPolicies={[createEnvironmentAccessPolicy()]}
isLoading={false}
isError={false}
/>,
)
renderWithAtomStore(<AccessPermissionsSection />)
expect(screen.getByText('Production')).toBeInTheDocument()
expect(screen.queryByText('deployments.access.permissions.col.environment')).not.toBeInTheDocument()

View File

@ -0,0 +1,232 @@
'use client'
import type { PropsWithChildren } from 'react'
import type { AccessPermissionKind, SelectableAccessSubject } from './access-policy'
import type { AccessSubjectSelectionValue } from './access-subject-selector/types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
Dialog,
DialogCloseButton,
DialogContent,
DialogDescription,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import { RadioRoot } from '@langgenius/dify-ui/radio'
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { AccessMode as AppAccessMode } from '@/models/access-control'
import {
accessControlSelectionFromSubjects,
appAccessModeToPermissionKey,
permissionKeyToAppAccessMode,
subjectsFromAccessControlSelection,
} from './access-policy'
import { AccessSubjectAddButton } from './access-subject-selector/add-button'
import { AccessSubjectSelectionList } from './access-subject-selector/selection-list'
export function DeploymentAccessControlDialog({
open,
initialKind,
initialSubjects,
saving,
onClose,
onSubmit,
}: {
open: boolean
initialKind: AccessPermissionKind
initialSubjects: SelectableAccessSubject[]
saving?: boolean
onClose: () => void
onSubmit: (kind: AccessPermissionKind, subjects: SelectableAccessSubject[]) => void
}) {
const draftKey = [
initialKind,
initialSubjects.map(subject => `${subject.subjectType}:${subject.id}`).join(','),
].join(':')
return (
<Dialog open={open} disablePointerDismissal onOpenChange={open => !open && onClose()}>
<DialogContent
className={cn(
'h-auto max-h-[calc(100dvh-2rem)] min-h-[323px] w-[600px] max-w-none overflow-y-auto rounded-2xl border-none bg-components-panel-bg p-0 shadow-xl transition-shadow',
)}
>
<DialogCloseButton className="top-5 right-5 size-8" />
{open && (
<DeploymentAccessControlDialogBody
key={draftKey}
initialKind={initialKind}
initialSubjects={initialSubjects}
saving={saving}
onClose={onClose}
onSubmit={onSubmit}
/>
)}
</DialogContent>
</Dialog>
)
}
function DeploymentAccessControlDialogBody({
initialKind,
initialSubjects,
saving,
onClose,
onSubmit,
}: {
initialKind: AccessPermissionKind
initialSubjects: SelectableAccessSubject[]
saving?: boolean
onClose: () => void
onSubmit: (kind: AccessPermissionKind, subjects: SelectableAccessSubject[]) => void
}) {
const { t } = useTranslation('deployments')
const [currentMenu, setCurrentMenu] = useState(() => permissionKeyToAppAccessMode(initialKind))
const [specificSelection, setSpecificSelection] = useState<AccessSubjectSelectionValue>(() =>
accessControlSelectionFromSubjects(initialSubjects),
)
const specificSelected = currentMenu === AppAccessMode.SPECIFIC_GROUPS_MEMBERS
const selectedSubjectCount = specificSelection.groups.length + specificSelection.members.length
const specificEmpty = specificSelected && selectedSubjectCount === 0
const confirmDisabled = saving || (specificSelected && specificEmpty)
const handleConfirm = () => {
if (confirmDisabled)
return
onSubmit(
appAccessModeToPermissionKey(currentMenu),
specificSelected
? subjectsFromAccessControlSelection(specificSelection)
: [],
)
}
return (
<div className="flex flex-col gap-y-3">
<div className="pt-6 pr-14 pb-3 pl-6">
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{t('access.permissions.editTitle')}
</DialogTitle>
<DialogDescription className="mt-1 system-xs-regular text-text-tertiary">
{t('access.permissions.editDescription')}
</DialogDescription>
</div>
<RadioGroup<AppAccessMode>
value={currentMenu}
onValueChange={setCurrentMenu}
className="flex flex-col items-stretch gap-y-1 px-6 pb-3"
aria-labelledby="access-control-options-label"
disabled={saving}
>
<div className="leading-6">
<p id="access-control-options-label" className="system-sm-medium text-text-tertiary">
{t('accessControlDialog.accessLabel', { ns: 'app' })}
</p>
</div>
<AccessControlItem type={AppAccessMode.ORGANIZATION}>
<div className="flex items-center p-3">
<div className="flex grow items-center gap-x-2">
<span className="i-ri-building-line size-4 text-text-primary" aria-hidden="true" />
<p className="system-sm-medium text-text-primary">
{t('accessControlDialog.accessItems.organization', { ns: 'app' })}
</p>
</div>
</div>
</AccessControlItem>
<AccessControlItem type={AppAccessMode.SPECIFIC_GROUPS_MEMBERS}>
<SpecificGroupsOrMembersOption
selected={specificSelected}
selection={specificSelection}
onSelectionChange={setSpecificSelection}
/>
</AccessControlItem>
<AccessControlItem type={AppAccessMode.PUBLIC}>
<div className="flex items-center gap-x-2 p-3">
<span className="i-ri-global-line size-4 text-text-primary" aria-hidden="true" />
<p className="system-sm-medium text-text-primary">
{t('accessControlDialog.accessItems.anyone', { ns: 'app' })}
</p>
</div>
</AccessControlItem>
</RadioGroup>
<div className="flex items-center justify-end gap-x-2 p-6 pt-5">
<Button disabled={saving} onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button disabled={confirmDisabled || saving} loading={saving} variant="primary" onClick={handleConfirm}>
{t('operation.confirm', { ns: 'common' })}
</Button>
</div>
</div>
)
}
function AccessControlItem({ type, children }: PropsWithChildren<{
type: AppAccessMode
}>) {
return (
<RadioRoot<AppAccessMode>
value={type}
variant="unstyled"
render={<div />}
className={cn(
'cursor-pointer rounded-[10px] border-[0.5px] border-components-option-card-option-border bg-components-option-card-option-bg shadow-xs transition-colors',
'hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover',
'focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
'data-checked:border-components-option-card-option-selected-border data-checked:bg-components-option-card-option-selected-bg data-checked:ring-[0.5px] data-checked:ring-components-option-card-option-selected-border data-checked:ring-inset',
'data-disabled:cursor-not-allowed data-disabled:opacity-60 data-disabled:hover:border-components-option-card-option-border data-disabled:hover:bg-components-option-card-option-bg',
)}
>
{children}
</RadioRoot>
)
}
function SpecificGroupsOrMembersOption({
selected,
selection,
onSelectionChange,
}: {
selected: boolean
selection: AccessSubjectSelectionValue
onSelectionChange: (selection: AccessSubjectSelectionValue) => void
}) {
const { t } = useTranslation()
if (!selected) {
return (
<div className="flex items-center p-3">
<div className="flex grow items-center gap-x-2">
<span className="i-ri-lock-line size-4 text-text-primary" aria-hidden="true" />
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.specific', { ns: 'app' })}</p>
</div>
</div>
)
}
return (
<div>
<div className="flex items-center gap-x-1 p-3">
<div className="flex grow items-center gap-x-1">
<span className="i-ri-lock-line size-4 text-text-primary" aria-hidden="true" />
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.specific', { ns: 'app' })}</p>
</div>
<div className="flex items-center gap-x-1">
<AccessSubjectAddButton
selectedGroups={selection.groups}
selectedMembers={selection.members}
onChange={onSelectionChange}
/>
</div>
</div>
<div className="px-1 pb-1">
<AccessSubjectSelectionList
selectedGroups={selection.groups}
selectedMembers={selection.members}
onChange={onSelectionChange}
/>
</div>
</div>
)
}

View File

@ -0,0 +1,78 @@
import type { Subject } from '@/models/access-control'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SubjectType } from '@/models/access-control'
import { AccessSubjectAddButton } from '../add-button'
const mockUseSearchAccessSubjects = vi.hoisted(() => vi.fn())
vi.mock('@/service/access-control/use-access-subjects', () => ({
useSearchAccessSubjects: (...args: unknown[]) => mockUseSearchAccessSubjects(...args),
}))
const groupSubject: Subject = {
subjectId: 'group-1',
subjectType: SubjectType.GROUP,
groupData: {
id: 'group-1',
name: 'Group One',
groupSize: 2,
},
}
function lastSearchParams() {
return mockUseSearchAccessSubjects.mock.calls.at(-1)?.[0] as { groupId?: string } | undefined
}
// The add menu owns transient browsing state; reopening should start from the root group list.
describe('AccessSubjectAddButton', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseSearchAccessSubjects.mockReturnValue({
data: {
pages: [
{
subjects: [groupSubject],
hasMore: false,
},
],
},
fetchNextPage: vi.fn(),
isFetchingNextPage: false,
isLoading: false,
})
})
it('should reset expanded group browsing when the add menu reopens', async () => {
const user = userEvent.setup()
render(
<AccessSubjectAddButton
selectedGroups={[]}
selectedMembers={[]}
onChange={vi.fn()}
/>,
)
const addButton = screen.getByRole('combobox', { name: 'common.operation.add' })
await user.click(addButton)
await user.click(await screen.findByRole('button', { name: 'app.accessControlDialog.operateGroupAndMember.expand' }))
await waitFor(() => {
expect(lastSearchParams()).toMatchObject({ groupId: 'group-1' })
})
await user.click(addButton)
await waitFor(() => {
expect(addButton).toHaveAttribute('aria-expanded', 'false')
})
await user.click(addButton)
await waitFor(() => {
expect(lastSearchParams()?.groupId).toBeUndefined()
})
})
})

View File

@ -2,10 +2,7 @@
import type { ComboboxRootChangeEventDetails } from '@langgenius/dify-ui/combobox'
import type { AccessSubjectSelectionProps } from './types'
import type {
AccessControlGroup,
Subject,
} from '@/models/access-control'
import type { AccessControlGroup, Subject } from '@/models/access-control'
import {
Combobox,
ComboboxContent,
@ -33,8 +30,6 @@ import {
type AccessSubjectAddButtonProps = AccessSubjectSelectionProps & {
disabled?: boolean
breadcrumbGroups?: AccessControlGroup[]
onBreadcrumbGroupsChange?: (groups: AccessControlGroup[]) => void
}
export function AccessSubjectAddButton({
@ -42,20 +37,16 @@ export function AccessSubjectAddButton({
selectedMembers,
onChange,
disabled,
breadcrumbGroups,
onBreadcrumbGroupsChange,
}: AccessSubjectAddButtonProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [keyword, setKeyword] = useState('')
const [internalBreadcrumbGroups, setInternalBreadcrumbGroups] = useState<AccessControlGroup[]>([])
const [breadcrumbGroups, setBreadcrumbGroups] = useState<AccessControlGroup[]>([])
const scrollRootRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const selectedGroupsForBreadcrumb = breadcrumbGroups ?? internalBreadcrumbGroups
const setSelectedGroupsForBreadcrumb = onBreadcrumbGroupsChange ?? setInternalBreadcrumbGroups
const debouncedKeyword = useDebounce(keyword, { wait: 500 })
const lastAvailableGroup = selectedGroupsForBreadcrumb[selectedGroupsForBreadcrumb.length - 1]
const lastAvailableGroup = breadcrumbGroups[breadcrumbGroups.length - 1]
const { isLoading, isFetchingNextPage, fetchNextPage, data } = useSearchAccessSubjects({
keyword: debouncedKeyword,
groupId: lastAvailableGroup?.id,
@ -68,7 +59,7 @@ export function AccessSubjectAddButton({
members: selectedMembers,
})
const hasResults = pages.length > 0 && subjects.length > 0
const shouldShowBreadcrumb = hasResults || selectedGroupsForBreadcrumb.length > 0
const shouldShowBreadcrumb = hasResults || breadcrumbGroups.length > 0
const hasMore = pages[pages.length - 1]?.hasMore ?? false
useEffect(() => {
@ -83,13 +74,21 @@ export function AccessSubjectAddButton({
return () => observer?.disconnect()
}, [fetchNextPage, hasMore, isFetchingNextPage, isLoading])
const closeMenu = () => {
setKeyword('')
setBreadcrumbGroups([])
setOpen(false)
}
const handleOpenChange = (nextOpen: boolean) => {
if (nextOpen && disabled)
return
if (!nextOpen)
setKeyword('')
if (!nextOpen) {
closeMenu()
return
}
setOpen(nextOpen)
setOpen(true)
}
const handleInputValueChange = (inputValue: string, details: ComboboxRootChangeEventDetails) => {
@ -122,6 +121,10 @@ export function AccessSubjectAddButton({
icon={false}
size="small"
disabled={disabled}
onClick={() => {
if (open)
closeMenu()
}}
className="h-6 w-auto min-w-[52px] shrink-0 rounded-md border-0 bg-transparent px-2 py-0 text-xs font-medium text-components-button-secondary-accent-text hover:bg-state-accent-hover focus-visible:bg-state-accent-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-accent-hover"
>
<span className="inline-flex min-w-0 items-center justify-center gap-x-0.5 whitespace-nowrap">
@ -156,8 +159,8 @@ export function AccessSubjectAddButton({
{shouldShowBreadcrumb && (
<div className="flex h-7 items-center px-2 py-0.5">
<SelectedGroupsBreadCrumb
selectedGroupsForBreadcrumb={selectedGroupsForBreadcrumb}
onChange={setSelectedGroupsForBreadcrumb}
selectedGroupsForBreadcrumb={breadcrumbGroups}
onChange={setBreadcrumbGroups}
/>
</div>
)}
@ -171,7 +174,7 @@ export function AccessSubjectAddButton({
subject={subject}
selectedGroups={selectedGroups}
selectedMembers={selectedMembers}
onExpandGroup={group => setSelectedGroupsForBreadcrumb([...selectedGroupsForBreadcrumb, group])}
onExpandGroup={group => setBreadcrumbGroups([...breadcrumbGroups, group])}
/>
)}
</ComboboxList>

View File

@ -9,24 +9,22 @@ import type {
AccessPermissionKind,
SelectableAccessSubject,
} from './access-policy'
import type { AccessSubjectSelectionValue } from './access-subject-selector/types'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
import { DeploymentAccessControlDialog } from './access-control-dialog'
import {
accessControlSelectionFromSubjects,
accessModeToPermissionKey,
normalizeResolvedSubject,
permissionKeyToAccessMode,
permissionKeyToAppAccessMode,
policySubjects,
selectedSubjectsFromPolicy,
subjectsFromAccessControlSelection,
} from './access-policy'
import { DeploymentAccessControlDialog } from './deployment-access-control-dialog'
import { PermissionSummaryButton } from './permission-row-components'
import { PermissionSummaryButton } from './permission-summary-button'
type AccessPermissionDraft = {
fingerprint: string
@ -35,7 +33,6 @@ type AccessPermissionDraft = {
}
type EnvironmentPermissionRowProps = {
appInstanceId: string
disabled?: boolean
environment: Environment
summaryPolicy?: AccessPolicy
@ -43,13 +40,13 @@ type EnvironmentPermissionRowProps = {
}
export function EnvironmentPermissionRow({
appInstanceId,
disabled,
environment,
summaryPolicy,
resolvedSubjects = [],
}: EnvironmentPermissionRowProps) {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const environmentId = environment.id
const setEnvironmentAccessPolicy = useMutation(consoleQuery.enterprise.accessService.updateAccessPolicy.mutationOptions())
const policy = summaryPolicy
@ -58,6 +55,7 @@ export function EnvironmentPermissionRow({
? `${policy.mode}:${policy.subjects.map(subject => `${subject.subjectType}:${subject.subjectId}`).join(',')}`
: 'no-policy'
const [draft, setDraft] = useState<AccessPermissionDraft>()
const [dialogOpen, setDialogOpen] = useState(false)
const subjectLabelCandidates = [
...(draft?.subjects ?? []),
...resolvedSubjects
@ -70,9 +68,8 @@ export function EnvironmentPermissionRow({
const permissionKind = hasDraft && draft ? draft.kind : policyKind
const policySelectedSubjects = policyKind === 'specific' ? selectedSubjectsFromPolicy(policy, subjectLabelCandidates) : []
const subjects = hasDraft && draft ? draft.subjects : policySelectedSubjects
const subjectSelection = accessControlSelectionFromSubjects(subjects)
const isSaving = setEnvironmentAccessPolicy.isPending
const controlsDisabled = disabled || isSaving
const controlsDisabled = disabled || isSaving || !appInstanceId
const envName = environment.displayName
const persistPolicy = (
@ -82,6 +79,9 @@ export function EnvironmentPermissionRow({
onSuccess?: () => void
},
) => {
if (!appInstanceId)
return false
if (nextKind === 'specific' && nextSubjects.length === 0)
return false
@ -110,12 +110,8 @@ export function EnvironmentPermissionRow({
const handlePermissionSubmit = (
nextKind: AccessPermissionKind,
nextSelection: AccessSubjectSelectionValue,
options?: {
onSuccess?: () => void
},
normalizedSubjects: SelectableAccessSubject[],
) => {
const normalizedSubjects = nextKind === 'specific' ? subjectsFromAccessControlSelection(nextSelection) : []
persistPolicy(nextKind, normalizedSubjects, {
onSuccess: () => {
setDraft({
@ -123,7 +119,7 @@ export function EnvironmentPermissionRow({
kind: nextKind,
subjects: normalizedSubjects,
})
options?.onSuccess?.()
setDialogOpen(false)
},
})
}
@ -135,71 +131,22 @@ export function EnvironmentPermissionRow({
{envName}
</span>
</div>
<EnvironmentPermissionEditor
permissionKind={permissionKind}
subjectSelection={subjectSelection}
<PermissionSummaryButton
value={permissionKind}
subjects={subjects}
disabled={controlsDisabled}
loading={isSaving}
environmentLabel={envName}
onClick={() => setDialogOpen(true)}
/>
<DeploymentAccessControlDialog
open={dialogOpen}
initialKind={permissionKind}
initialSubjects={subjects}
saving={isSaving}
onClose={() => setDialogOpen(false)}
onSubmit={handlePermissionSubmit}
/>
</div>
)
}
function EnvironmentPermissionEditor({
permissionKind,
subjectSelection,
subjects,
disabled,
environmentLabel,
saving,
onSubmit,
}: {
permissionKind: AccessPermissionKind
subjectSelection: AccessSubjectSelectionValue
subjects: SelectableAccessSubject[]
disabled?: boolean
environmentLabel: string
saving?: boolean
onSubmit: (
nextKind: AccessPermissionKind,
nextSelection: AccessSubjectSelectionValue,
options?: { onSuccess?: () => void },
) => void
}) {
const [dialogOpen, setDialogOpen] = useState(false)
const handleSubmit = (nextKind: AccessPermissionKind, nextSelection: AccessSubjectSelectionValue) => {
onSubmit(nextKind, nextSelection, {
onSuccess: () => setDialogOpen(false),
})
}
return (
<>
<PermissionSummaryButton
value={permissionKind}
subjects={subjects}
disabled={disabled}
loading={saving}
environmentLabel={environmentLabel}
onClick={() => setDialogOpen(true)}
/>
{dialogOpen && (
<DeploymentAccessControlDialog
initialDraft={{
currentMenu: permissionKeyToAppAccessMode(permissionKind),
specificGroups: subjectSelection.groups,
specificMembers: subjectSelection.members,
selectedGroupsForBreadcrumb: [],
}}
saving={saving}
onClose={() => setDialogOpen(false)}
onSubmit={handleSubmit}
/>
)}
</>
)
}

View File

@ -1,11 +1,14 @@
'use client'
import type { EnvironmentAccessPolicy } from '@dify/contracts/enterprise/types.gen'
import { useAtomValue } from 'jotai'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle } from '@/app/components/base/skeleton'
import { DeploymentEmptyState, DeploymentStateMessage } from '../../../components/empty-state'
import { Section } from '../../common'
import { EnvironmentPermissionRow } from './permissions'
import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
import { Section } from '../../components/section'
import { accessSettingsQueryAtom } from '../state'
import { EnvironmentPermissionRow } from './environment-permission-row'
const ACCESS_PERMISSIONS_SKELETON_KEYS = ['production', 'staging', 'development']
@ -22,18 +25,13 @@ function AccessPermissionsSkeleton() {
)
}
export function AccessPermissionsSection({
appInstanceId,
environmentPolicies,
isLoading,
isError,
}: {
appInstanceId: string
environmentPolicies?: EnvironmentAccessPolicy[]
isLoading: boolean
isError: boolean
}) {
export function AccessPermissionsSection() {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const accessSettingsQuery = useAtomValue(accessSettingsQueryAtom)
const environmentPolicies: EnvironmentAccessPolicy[] | undefined = accessSettingsQuery.data?.environmentPolicies
const isLoading = accessSettingsQuery.isLoading
const isError = accessSettingsQuery.isError
const policyRows = environmentPolicies ?? []
return (
@ -43,7 +41,7 @@ export function AccessPermissionsSection({
>
{isLoading
? <AccessPermissionsSkeleton />
: isError
: isError || !appInstanceId
? <DeploymentStateMessage variant="section">{t('common.loadFailed')}</DeploymentStateMessage>
: policyRows.length === 0
? (
@ -61,7 +59,6 @@ export function AccessPermissionsSection({
return (
<EnvironmentPermissionRow
key={environment.id}
appInstanceId={appInstanceId}
environment={environment}
summaryPolicy={environmentPolicy.policy}
resolvedSubjects={environmentPolicy.resolvedSubjects}

View File

@ -3,7 +3,7 @@
import { skipToken } from '@tanstack/react-query'
import { atomWithQuery } from 'jotai-tanstack-query'
import { consoleQuery } from '@/service/client'
import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
export const accessSettingsQueryAtom = atomWithQuery((get) => {
const appInstanceId = get(deploymentRouteAppInstanceIdAtom)

View File

@ -13,10 +13,10 @@ import {
DetailTableHead,
DetailTableHeader,
DetailTableRow,
} from '../table'
} from '../components/detail-table'
import {
DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES,
} from '../table-styles'
} from '../components/detail-table-styles'
import { DeploymentRowActions } from './deployment-row-actions'
import { DeploymentStatusSummary } from './deployment-status-summary'

View File

@ -10,7 +10,7 @@ import {
} from '@langgenius/dify-ui/dropdown-menu'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME } from '../table-styles'
import { DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME } from '../components/detail-table-styles'
export function DeploymentActionsDropdown({
currentReleaseId,

View File

@ -2,11 +2,8 @@
import { useAtomValue } from 'jotai'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { DeploymentEmptyState, DeploymentStateMessage } from '../components/empty-state'
import { hasRuntimeInstanceDeployment } from '../shared/domain/runtime-status'
import { DeploymentEnvironmentList } from './deploy-tab/deployment-environment-list'
import { NewDeploymentButton } from './deploy-tab/new-deployment-button'
import { deploymentEnvironmentDeploymentsQueryAtom } from './state'
import { DeploymentEmptyState, DeploymentStateMessage } from '../../components/empty-state'
import { hasRuntimeInstanceDeployment } from '../../shared/domain/runtime-status'
import {
DetailTable,
DetailTableBody,
@ -16,10 +13,13 @@ import {
DetailTableHead,
DetailTableHeader,
DetailTableRow,
} from './table'
} from '../components/detail-table'
import {
DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES,
} from './table-styles'
} from '../components/detail-table-styles'
import { deploymentEnvironmentDeploymentsQueryAtom } from '../state'
import { DeploymentEnvironmentList } from './deployment-environment-list'
import { NewDeploymentButton } from './new-deployment-button'
const DEPLOYMENT_TABLE_ROW_SKELETON_KEYS = ['production', 'staging']

View File

@ -1,13 +0,0 @@
'use client'
import { DeveloperApiSection } from './settings-tab/access/developer-api-section'
export function DeveloperApiTab({ appInstanceId }: {
appInstanceId: string
}) {
return (
<div className="flex w-full max-w-[960px] min-w-0 flex-col gap-y-4 px-6 py-6 sm:px-20 sm:py-8">
<DeveloperApiSection appInstanceId={appInstanceId} />
</div>
)
}

View File

@ -8,8 +8,8 @@ import useDocumentTitle from '@/hooks/use-document-title'
import Link from '@/next/link'
import { useSelectedLayoutSegment } from '@/next/navigation'
import { CreateReleaseControl } from '../create-release'
import { DeveloperApiHeaderSwitch } from './access-tab/developer-api/section'
import { NewDeploymentHeaderAction } from './deploy-tab/new-deployment-button'
import { DeveloperApiHeaderSwitch } from './settings-tab/access/developer-api-section'
import { INSTANCE_DETAIL_TAB_KEYS, isInstanceDetailTabKey } from './tabs'
import { versionsTabLocalAtoms } from './versions-tab/state'
@ -73,7 +73,7 @@ export function InstanceDetail({ appInstanceId, children }: {
<div className="system-xl-semibold text-text-primary">{t(`tabs.${activeTab}.name`)}</div>
{activeTab === 'api-tokens' && (
<div className="shrink-0">
<DeveloperApiHeaderSwitch appInstanceId={appInstanceId} />
<DeveloperApiHeaderSwitch />
</div>
)}
</div>

View File

@ -3,12 +3,12 @@
import { useAtomValue } from 'jotai'
import { useTranslation } from 'react-i18next'
import Link from '@/next/link'
import { DeploymentStateMessage } from '../components/empty-state'
import { hasRuntimeInstanceDeployment } from '../shared/domain/runtime-status'
import { AccessStatusSection, AccessStatusSectionSkeleton, ApiTokenSummarySection, ApiTokenSummarySectionSkeleton } from './overview-tab/access-status-section'
import { EnvironmentStrip, EnvironmentStripSkeleton } from './overview-tab/environment-strip'
import { ReleaseHero, ReleaseHeroSkeleton } from './overview-tab/release-hero'
import { deploymentDetailOverviewQueryAtom } from './state'
import { DeploymentStateMessage } from '../../components/empty-state'
import { hasRuntimeInstanceDeployment } from '../../shared/domain/runtime-status'
import { deploymentDetailOverviewQueryAtom } from '../state'
import { AccessStatusSection, AccessStatusSectionSkeleton, ApiTokenSummarySection, ApiTokenSummarySectionSkeleton } from './access-status-section'
import { EnvironmentStrip, EnvironmentStripSkeleton } from './environment-strip'
import { ReleaseHero, ReleaseHeroSkeleton } from './release-hero'
function OverviewLayout({ children }: { children: React.ReactNode }) {
return (

View File

@ -1,110 +0,0 @@
'use client'
import type { ReactNode } from 'react'
import type { SpecificGroupsOrMembersProps } from './specific-groups-or-members'
import { Button } from '@langgenius/dify-ui/button'
import { DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import { useTranslation } from 'react-i18next'
import { AccessMode } from '@/models/access-control'
import { AccessControlItem } from './access-control-item'
import { SpecificGroupsOrMembers, WebAppSSONotEnabledTip } from './specific-groups-or-members'
import { useAccessControlStore } from './store'
type AccessControlDialogContentProps = {
title?: ReactNode
description?: ReactNode
accessLabel?: ReactNode
hideExternal?: boolean
hideExternalTip?: boolean
saving?: boolean
controlsDisabled?: boolean
confirmDisabled?: boolean
specificGroupsOrMembersProps?: SpecificGroupsOrMembersProps
onClose: () => void
onConfirm: () => void
}
export function AccessControlDialogContent({
title,
description,
accessLabel,
hideExternal = false,
hideExternalTip = false,
saving = false,
controlsDisabled = false,
confirmDisabled = false,
specificGroupsOrMembersProps,
onClose,
onConfirm,
}: AccessControlDialogContentProps) {
const { t } = useTranslation()
const currentMenu = useAccessControlStore(s => s.currentMenu)
const setCurrentMenu = useAccessControlStore(s => s.setCurrentMenu)
return (
<div className="flex flex-col gap-y-3">
<div className="pt-6 pr-14 pb-3 pl-6">
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{title ?? t('accessControlDialog.title', { ns: 'app' })}
</DialogTitle>
<DialogDescription className="mt-1 system-xs-regular text-text-tertiary">
{description ?? t('accessControlDialog.description', { ns: 'app' })}
</DialogDescription>
</div>
<RadioGroup<AccessMode>
value={currentMenu}
onValueChange={setCurrentMenu}
className="flex flex-col items-stretch gap-y-1 px-6 pb-3"
aria-labelledby="access-control-options-label"
disabled={controlsDisabled}
>
<div className="leading-6">
<p id="access-control-options-label" className="system-sm-medium text-text-tertiary">
{accessLabel ?? t('accessControlDialog.accessLabel', { ns: 'app' })}
</p>
</div>
<AccessControlItem type={AccessMode.ORGANIZATION}>
<div className="flex items-center p-3">
<div className="flex grow items-center gap-x-2">
<span className="i-ri-building-line size-4 text-text-primary" aria-hidden="true" />
<p className="system-sm-medium text-text-primary">
{t('accessControlDialog.accessItems.organization', { ns: 'app' })}
</p>
</div>
</div>
</AccessControlItem>
<AccessControlItem type={AccessMode.SPECIFIC_GROUPS_MEMBERS}>
<SpecificGroupsOrMembers {...specificGroupsOrMembersProps} />
</AccessControlItem>
{!hideExternal && (
<AccessControlItem type={AccessMode.EXTERNAL_MEMBERS}>
<div className="flex items-center p-3">
<div className="flex grow items-center gap-x-2">
<span className="i-ri-verified-badge-line size-4 text-text-primary" aria-hidden="true" />
<p className="system-sm-medium text-text-primary">
{t('accessControlDialog.accessItems.external', { ns: 'app' })}
</p>
</div>
{!hideExternalTip && <WebAppSSONotEnabledTip />}
</div>
</AccessControlItem>
)}
<AccessControlItem type={AccessMode.PUBLIC}>
<div className="flex items-center gap-x-2 p-3">
<span className="i-ri-global-line size-4 text-text-primary" aria-hidden="true" />
<p className="system-sm-medium text-text-primary">
{t('accessControlDialog.accessItems.anyone', { ns: 'app' })}
</p>
</div>
</AccessControlItem>
</RadioGroup>
<div className="flex items-center justify-end gap-x-2 p-6 pt-5">
<Button disabled={saving} onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button disabled={confirmDisabled || saving} loading={saving} variant="primary" onClick={onConfirm}>
{t('operation.confirm', { ns: 'common' })}
</Button>
</div>
</div>
)
}

View File

@ -1,35 +0,0 @@
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import {
Dialog,
DialogCloseButton,
DialogContent,
} from '@langgenius/dify-ui/dialog'
type DialogProps = {
className?: string
children: ReactNode
show: boolean
onClose?: () => void
}
export function AccessControlDialog({
className,
children,
show,
onClose,
}: DialogProps) {
return (
<Dialog open={show} disablePointerDismissal onOpenChange={open => !open && onClose?.()}>
<DialogContent
className={cn(
'h-auto max-h-[calc(100dvh-2rem)] min-h-[323px] w-[600px] max-w-none overflow-y-auto rounded-2xl border-none bg-components-panel-bg p-0 shadow-xl transition-shadow',
className,
)}
>
<DialogCloseButton className="top-5 right-5 size-8" />
{children}
</DialogContent>
</Dialog>
)
}

View File

@ -1,26 +0,0 @@
'use client'
import type { PropsWithChildren } from 'react'
import type { AccessMode } from '@/models/access-control'
import { cn } from '@langgenius/dify-ui/cn'
import { RadioRoot } from '@langgenius/dify-ui/radio'
export function AccessControlItem({ type, children }: PropsWithChildren<{
type: AccessMode
}>) {
return (
<RadioRoot<AccessMode>
value={type}
variant="unstyled"
render={<div />}
className={cn(
'cursor-pointer rounded-[10px] border-[0.5px] border-components-option-card-option-border bg-components-option-card-option-bg shadow-xs transition-colors',
'hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover',
'focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
'data-checked:border-components-option-card-option-selected-border data-checked:bg-components-option-card-option-selected-bg data-checked:ring-[0.5px] data-checked:ring-components-option-card-option-selected-border data-checked:ring-inset',
'data-disabled:cursor-not-allowed data-disabled:opacity-60 data-disabled:hover:border-components-option-card-option-border data-disabled:hover:bg-components-option-card-option-bg',
)}
>
{children}
</RadioRoot>
)
}

View File

@ -1,31 +0,0 @@
'use client'
import {
AccessSubjectAddButton,
} from './access-subject-selector/add-button'
import { useAccessControlStore } from './store'
export function AddMemberOrGroupDialog({ disabled = false }: {
disabled?: boolean
}) {
const specificGroups = useAccessControlStore(s => s.specificGroups)
const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups)
const specificMembers = useAccessControlStore(s => s.specificMembers)
const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers)
const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb)
const setSelectedGroupsForBreadcrumb = useAccessControlStore(s => s.setSelectedGroupsForBreadcrumb)
return (
<AccessSubjectAddButton
selectedGroups={specificGroups}
selectedMembers={specificMembers}
disabled={disabled}
breadcrumbGroups={selectedGroupsForBreadcrumb}
onBreadcrumbGroupsChange={setSelectedGroupsForBreadcrumb}
onChange={({ groups, members }) => {
setSpecificGroups(groups)
setSpecificMembers(members)
}}
/>
)
}

View File

@ -1,98 +0,0 @@
'use client'
import type {
AccessPermissionKind,
} from './access-policy'
import type { AccessSubjectSelectionValue } from './access-subject-selector/types'
import type { AccessControlDraft } from './store'
import { useTranslation } from 'react-i18next'
import { AccessMode as AppAccessMode } from '@/models/access-control'
import { AccessControlDialog } from './access-control-dialog'
import { AccessControlDialogContent } from './access-control-dialog-content'
import {
appAccessModeToPermissionKey,
} from './access-policy'
import { useAccessControlStore } from './store'
import { AccessControlDraftProvider } from './store-provider'
export function DeploymentAccessControlDialog({
initialDraft,
subjectsLoading,
saving,
onClose,
onSubmit,
}: {
initialDraft: AccessControlDraft
subjectsLoading?: boolean
saving?: boolean
onClose: () => void
onSubmit: (kind: AccessPermissionKind, subjects: AccessSubjectSelectionValue) => void
}) {
const draftKey = [
initialDraft.currentMenu,
initialDraft.specificGroups ? initialDraft.specificGroups.map(group => group.id).join(',') : 'no-groups',
initialDraft.specificMembers ? initialDraft.specificMembers.map(member => member.id).join(',') : 'no-members',
].join(':')
return (
<AccessControlDraftProvider draftKey={draftKey} initialDraft={initialDraft}>
<DeploymentAccessControlDialogBody
subjectsLoading={subjectsLoading}
saving={saving}
onClose={onClose}
onSubmit={onSubmit}
/>
</AccessControlDraftProvider>
)
}
function DeploymentAccessControlDialogBody({
subjectsLoading,
saving,
onClose,
onSubmit,
}: {
subjectsLoading?: boolean
saving?: boolean
onClose: () => void
onSubmit: (kind: AccessPermissionKind, subjects: AccessSubjectSelectionValue) => void
}) {
const { t } = useTranslation('deployments')
const currentMenu = useAccessControlStore(s => s.currentMenu)
const specificGroups = useAccessControlStore(s => s.specificGroups)
const specificMembers = useAccessControlStore(s => s.specificMembers)
const specificSelected = currentMenu === AppAccessMode.SPECIFIC_GROUPS_MEMBERS
const selectedSubjectCount = specificGroups.length + specificMembers.length
const specificEmpty = specificSelected && selectedSubjectCount === 0
const confirmDisabled = saving || (specificSelected && (subjectsLoading || specificEmpty))
const handleConfirm = () => {
if (confirmDisabled)
return
onSubmit(
appAccessModeToPermissionKey(currentMenu),
specificSelected
? { groups: specificGroups, members: specificMembers }
: { groups: [], members: [] },
)
}
return (
<AccessControlDialog show onClose={onClose}>
<AccessControlDialogContent
title={t('access.permissions.editTitle')}
description={t('access.permissions.editDescription')}
hideExternal
saving={saving}
controlsDisabled={saving || subjectsLoading}
confirmDisabled={confirmDisabled}
specificGroupsOrMembersProps={{
loading: subjectsLoading,
}}
onClose={onClose}
onConfirm={handleConfirm}
/>
</AccessControlDialog>
)
}

View File

@ -1,72 +0,0 @@
'use client'
import { useTranslation } from 'react-i18next'
import { Infotip } from '@/app/components/base/infotip'
import { AccessMode } from '@/models/access-control'
import { AccessSubjectSelectionList } from './access-subject-selector/selection-list'
import { AddMemberOrGroupDialog } from './add-member-or-group-pop'
import { useAccessControlStore } from './store'
export type SpecificGroupsOrMembersProps = {
loading?: boolean
}
export function SpecificGroupsOrMembers({
loading = false,
}: SpecificGroupsOrMembersProps) {
const currentMenu = useAccessControlStore(s => s.currentMenu)
const specificGroups = useAccessControlStore(s => s.specificGroups)
const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups)
const specificMembers = useAccessControlStore(s => s.specificMembers)
const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers)
const { t } = useTranslation()
if (currentMenu !== AccessMode.SPECIFIC_GROUPS_MEMBERS) {
return (
<div className="flex items-center p-3">
<div className="flex grow items-center gap-x-2">
<span className="i-ri-lock-line size-4 text-text-primary" aria-hidden="true" />
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.specific', { ns: 'app' })}</p>
</div>
</div>
)
}
return (
<div>
<div className="flex items-center gap-x-1 p-3">
<div className="flex grow items-center gap-x-1">
<span className="i-ri-lock-line size-4 text-text-primary" aria-hidden="true" />
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.specific', { ns: 'app' })}</p>
</div>
<div className="flex items-center gap-x-1">
<AddMemberOrGroupDialog disabled={loading} />
</div>
</div>
<div className="px-1 pb-1">
<AccessSubjectSelectionList
selectedGroups={specificGroups}
selectedMembers={specificMembers}
loading={loading}
onChange={({ groups, members }) => {
setSpecificGroups(groups)
setSpecificMembers(members)
}}
/>
</div>
</div>
)
}
export function WebAppSSONotEnabledTip() {
const { t } = useTranslation()
const tip = t('accessControlDialog.webAppSSONotEnabledTip', { ns: 'app' })
return (
<Infotip
aria-label={tip}
iconClassName="h-4 w-4 shrink-0 text-text-warning-secondary hover:text-text-warning-secondary"
>
{tip}
</Infotip>
)
}

View File

@ -1,34 +0,0 @@
'use client'
import type { ReactNode } from 'react'
import type { AccessControlDraft, AccessControlStoreApi } from './store'
import { useRef } from 'react'
import { AccessControlStoreContext, createAccessControlStore } from './store'
export function AccessControlDraftProvider({
children,
draftKey,
initialDraft,
}: {
children?: ReactNode
draftKey: string
initialDraft: AccessControlDraft
}) {
const storeRef = useRef<{
draftKey: string
store: AccessControlStoreApi
} | undefined>(undefined)
if (!storeRef.current || storeRef.current.draftKey !== draftKey) {
storeRef.current = {
draftKey,
store: createAccessControlStore(initialDraft),
}
}
return (
<AccessControlStoreContext value={storeRef.current.store}>
{children}
</AccessControlStoreContext>
)
}

View File

@ -1,52 +0,0 @@
import type { StoreApi } from 'zustand'
import type { AccessControlAccount, AccessControlGroup, AccessMode } from '@/models/access-control'
import type { App } from '@/types/app'
import { createContext, use } from 'react'
import { useStore } from 'zustand'
import { createStore } from 'zustand/vanilla'
export type AccessControlDraft = {
appId?: App['id']
currentMenu: AccessMode
specificGroups?: AccessControlGroup[]
specificMembers?: AccessControlAccount[]
selectedGroupsForBreadcrumb?: AccessControlGroup[]
}
export type AccessControlStore = {
appId: App['id']
specificGroups: AccessControlGroup[]
setSpecificGroups: (specificGroups: AccessControlGroup[]) => void
specificMembers: AccessControlAccount[]
setSpecificMembers: (specificMembers: AccessControlAccount[]) => void
currentMenu: AccessMode
setCurrentMenu: (currentMenu: AccessMode) => void
selectedGroupsForBreadcrumb: AccessControlGroup[]
setSelectedGroupsForBreadcrumb: (selectedGroupsForBreadcrumb: AccessControlGroup[]) => void
}
export type AccessControlStoreApi = StoreApi<AccessControlStore>
export function createAccessControlStore(initialDraft: AccessControlDraft) {
return createStore<AccessControlStore>(set => ({
appId: initialDraft.appId ?? '',
specificGroups: initialDraft.specificGroups ?? [],
setSpecificGroups: specificGroups => set({ specificGroups }),
specificMembers: initialDraft.specificMembers ?? [],
setSpecificMembers: specificMembers => set({ specificMembers }),
currentMenu: initialDraft.currentMenu,
setCurrentMenu: currentMenu => set({ currentMenu }),
selectedGroupsForBreadcrumb: initialDraft.selectedGroupsForBreadcrumb ?? [],
setSelectedGroupsForBreadcrumb: selectedGroupsForBreadcrumb => set({ selectedGroupsForBreadcrumb }),
}))
}
export const AccessControlStoreContext = createContext<AccessControlStoreApi | undefined>(undefined)
export function useAccessControlStore<T>(selector: (state: AccessControlStore) => T) {
const store = use(AccessControlStoreContext)
if (!store)
throw new Error('useAccessControlStore must be used inside AccessControlDraftProvider')
return useStore(store, selector)
}

View File

@ -17,7 +17,7 @@ import { consoleQuery } from '@/service/client'
import { TitleTooltip } from '../../components/title-tooltip'
import { openDeployDrawerAtom } from '../../deploy-drawer/state'
import { isUndeployedDeploymentRow } from '../../shared/domain/runtime-status'
import { DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME } from '../table-styles'
import { DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME } from '../components/detail-table-styles'
import { DeleteReleaseDialog } from './delete-release-dialog'
import {
buildDeployMenuSections,

View File

@ -1,5 +1,5 @@
'use client'
import { ReleaseHistoryTable } from './versions-tab/release-history-table'
import { ReleaseHistoryTable } from './release-history-table'
export function VersionsTab({ appInstanceId }: {
appInstanceId: string

View File

@ -14,10 +14,6 @@ import {
formatDate,
releaseCommit,
} from '../../shared/domain/release'
import {
deploymentSourceAppIdAtom,
deploymentSourceAppQueryAtom,
} from '../state'
import {
DetailTable,
DetailTableBody,
@ -27,8 +23,12 @@ import {
DetailTableHead,
DetailTableHeader,
DetailTableRow,
} from '../table'
import { RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES } from '../table-styles'
} from '../components/detail-table'
import { RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES } from '../components/detail-table-styles'
import {
deploymentSourceAppIdAtom,
deploymentSourceAppQueryAtom,
} from '../state'
import { DeployReleaseMenu } from './deploy-release-menu'
import {
ReleaseDeploymentsContent,

View File

@ -11,8 +11,8 @@ import {
DetailTableHead,
DetailTableHeader,
DetailTableRow,
} from '../table'
import { RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES } from '../table-styles'
} from '../components/detail-table'
import { RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES } from '../components/detail-table-styles'
const RELEASE_TABLE_ROW_SKELETON_KEYS = ['latest', 'previous', 'older', 'archived', 'initial']