mirror of
https://github.com/langgenius/dify.git
synced 2026-07-21 18:58:35 +08:00
refactor: simplify deployment access ownership (#37994)
This commit is contained in:
parent
4cd8b8c733
commit
16b698b54d
@ -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.
|
||||
|
||||
@ -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 />
|
||||
}
|
||||
|
||||
@ -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 />
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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 },
|
||||
})
|
||||
}
|
||||
@ -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()
|
||||
@ -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
|
||||
? (
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
@ -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}' \\
|
||||
@ -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,
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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 })}
|
||||
@ -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']
|
||||
|
||||
13
web/features/deployments/detail/access-tab/index.tsx
Normal file
13
web/features/deployments/detail/access-tab/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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',
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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}
|
||||
@ -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)
|
||||
@ -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'
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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']
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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 (
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
@ -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,
|
||||
|
||||
@ -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']
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user