From 16b698b54d58630cd36be7d56bce449f514605a6 Mon Sep 17 00:00:00 2001 From: Stephen Zhou Date: Fri, 26 Jun 2026 12:10:56 +0800 Subject: [PATCH] refactor: simplify deployment access ownership (#37994) --- .../skills/how-to-write-component/SKILL.md | 9 +- .../[appInstanceId]/access/page.tsx | 7 +- .../[appInstanceId]/api-tokens/page.tsx | 9 +- .../deployments/detail/access-tab.tsx | 31 --- .../__tests__/state.spec.ts | 2 +- .../channels/__tests__/section.spec.tsx} | 40 ++- .../channels/section.tsx} | 42 ++-- .../access => access-tab/channels}/url.ts | 0 .../components/endpoint.tsx} | 0 .../__tests__/api-key-generate-menu.spec.tsx | 30 ++- .../developer-api}/api-key-generate-menu.tsx | 9 +- .../developer-api}/api-key-list.tsx | 4 +- .../developer-api}/api-token-name.ts | 0 .../developer-api/created-token-dialog.tsx} | 2 +- .../developer-api/docs-drawer.tsx} | 9 +- .../detail/access-tab/developer-api/index.tsx | 11 + .../developer-api/section.tsx} | 90 +++---- .../developer-api/skeleton.tsx} | 4 +- .../deployments/detail/access-tab/index.tsx | 13 + .../__tests__/access-control-dialog.spec.tsx} | 42 +++- .../__tests__/access-policy.spec.ts | 0 .../__tests__/permissions.spec.tsx | 47 ++-- .../permissions/access-control-dialog.tsx | 232 ++++++++++++++++++ .../permissions}/access-policy.ts | 0 .../__tests__/add-button.spec.tsx | 78 ++++++ .../access-subject-selector/add-button.tsx | 41 ++-- .../selection-list.tsx | 0 .../subject-options.tsx | 0 .../access-subject-selector/types.ts | 0 .../access-subject-selector/utils.ts | 0 .../environment-permission-row.tsx} | 97 ++------ .../permission-summary-button.tsx} | 0 .../permissions/section.tsx} | 27 +- .../access => access-tab}/state.ts | 2 +- .../detail-table-styles.ts} | 0 .../detail-table.tsx} | 0 .../{common.tsx => components/section.tsx} | 0 .../deployment-environment-list.tsx | 4 +- .../deployment-row-actions-menu.tsx | 2 +- .../{deploy-tab.tsx => deploy-tab/index.tsx} | 14 +- .../deployments/detail/developer-api-tab.tsx | 13 - web/features/deployments/detail/index.tsx | 4 +- .../index.tsx} | 12 +- .../access/access-control-dialog-content.tsx | 110 --------- .../access/access-control-dialog.tsx | 35 --- .../access/access-control-item.tsx | 26 -- .../access/add-member-or-group-pop.tsx | 31 --- .../deployment-access-control-dialog.tsx | 98 -------- .../access/specific-groups-or-members.tsx | 72 ------ .../settings-tab/access/store-provider.tsx | 34 --- .../detail/settings-tab/access/store.ts | 52 ---- .../versions-tab/deploy-release-menu.tsx | 2 +- .../index.tsx} | 2 +- .../versions-tab/release-history-rows.tsx | 12 +- .../release-history-table-skeleton.tsx | 4 +- 55 files changed, 619 insertions(+), 786 deletions(-) delete mode 100644 web/features/deployments/detail/access-tab.tsx rename web/features/deployments/detail/{settings-tab/access => access-tab}/__tests__/state.spec.ts (97%) rename web/features/deployments/detail/{settings-tab/access/__tests__/channels-section.spec.tsx => access-tab/channels/__tests__/section.spec.tsx} (61%) rename web/features/deployments/detail/{settings-tab/access/channels-section.tsx => access-tab/channels/section.tsx} (89%) rename web/features/deployments/detail/{settings-tab/access => access-tab/channels}/url.ts (100%) rename web/features/deployments/detail/{settings-tab/access/common.tsx => access-tab/components/endpoint.tsx} (100%) rename web/features/deployments/detail/{settings-tab/access => access-tab/developer-api}/__tests__/api-key-generate-menu.spec.tsx (73%) rename web/features/deployments/detail/{settings-tab/access => access-tab/developer-api}/api-key-generate-menu.tsx (96%) rename web/features/deployments/detail/{settings-tab/access => access-tab/developer-api}/api-key-list.tsx (98%) rename web/features/deployments/detail/{settings-tab/access => access-tab/developer-api}/api-token-name.ts (100%) rename web/features/deployments/detail/{settings-tab/access/developer-api-created-token-dialog.tsx => access-tab/developer-api/created-token-dialog.tsx} (98%) rename web/features/deployments/detail/{settings-tab/access/api-docs-drawer.tsx => access-tab/developer-api/docs-drawer.tsx} (94%) create mode 100644 web/features/deployments/detail/access-tab/developer-api/index.tsx rename web/features/deployments/detail/{settings-tab/access/developer-api-section.tsx => access-tab/developer-api/section.tsx} (80%) rename web/features/deployments/detail/{settings-tab/access/developer-api-skeleton.tsx => access-tab/developer-api/skeleton.tsx} (96%) create mode 100644 web/features/deployments/detail/access-tab/index.tsx rename web/features/deployments/detail/{settings-tab/access/__tests__/deployment-access-control-dialog.spec.tsx => access-tab/permissions/__tests__/access-control-dialog.spec.tsx} (50%) rename web/features/deployments/detail/{settings-tab/access => access-tab/permissions}/__tests__/access-policy.spec.ts (100%) rename web/features/deployments/detail/{settings-tab/access => access-tab/permissions}/__tests__/permissions.spec.tsx (86%) create mode 100644 web/features/deployments/detail/access-tab/permissions/access-control-dialog.tsx rename web/features/deployments/detail/{settings-tab/access => access-tab/permissions}/access-policy.ts (100%) create mode 100644 web/features/deployments/detail/access-tab/permissions/access-subject-selector/__tests__/add-button.spec.tsx rename web/features/deployments/detail/{settings-tab/access => access-tab/permissions}/access-subject-selector/add-button.tsx (87%) rename web/features/deployments/detail/{settings-tab/access => access-tab/permissions}/access-subject-selector/selection-list.tsx (100%) rename web/features/deployments/detail/{settings-tab/access => access-tab/permissions}/access-subject-selector/subject-options.tsx (100%) rename web/features/deployments/detail/{settings-tab/access => access-tab/permissions}/access-subject-selector/types.ts (100%) rename web/features/deployments/detail/{settings-tab/access => access-tab/permissions}/access-subject-selector/utils.ts (100%) rename web/features/deployments/detail/{settings-tab/access/permissions.tsx => access-tab/permissions/environment-permission-row.tsx} (64%) rename web/features/deployments/detail/{settings-tab/access/permission-row-components.tsx => access-tab/permissions/permission-summary-button.tsx} (100%) rename web/features/deployments/detail/{settings-tab/access/permissions-section.tsx => access-tab/permissions/section.tsx} (75%) rename web/features/deployments/detail/{settings-tab/access => access-tab}/state.ts (92%) rename web/features/deployments/detail/{table-styles.ts => components/detail-table-styles.ts} (100%) rename web/features/deployments/detail/{table.tsx => components/detail-table.tsx} (100%) rename web/features/deployments/detail/{common.tsx => components/section.tsx} (100%) rename web/features/deployments/detail/{deploy-tab.tsx => deploy-tab/index.tsx} (92%) delete mode 100644 web/features/deployments/detail/developer-api-tab.tsx rename web/features/deployments/detail/{overview-tab.tsx => overview-tab/index.tsx} (88%) delete mode 100644 web/features/deployments/detail/settings-tab/access/access-control-dialog-content.tsx delete mode 100644 web/features/deployments/detail/settings-tab/access/access-control-dialog.tsx delete mode 100644 web/features/deployments/detail/settings-tab/access/access-control-item.tsx delete mode 100644 web/features/deployments/detail/settings-tab/access/add-member-or-group-pop.tsx delete mode 100644 web/features/deployments/detail/settings-tab/access/deployment-access-control-dialog.tsx delete mode 100644 web/features/deployments/detail/settings-tab/access/specific-groups-or-members.tsx delete mode 100644 web/features/deployments/detail/settings-tab/access/store-provider.tsx delete mode 100644 web/features/deployments/detail/settings-tab/access/store.ts rename web/features/deployments/detail/{versions-tab.tsx => versions-tab/index.tsx} (76%) diff --git a/.agents/skills/how-to-write-component/SKILL.md b/.agents/skills/how-to-write-component/SKILL.md index 4a5a73088ec..ee5c0d80887 100644 --- a/.agents/skills/how-to-write-component/SKILL.md +++ b/.agents/skills/how-to-write-component/SKILL.md @@ -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. diff --git a/web/app/(commonLayout)/deployments/[appInstanceId]/access/page.tsx b/web/app/(commonLayout)/deployments/[appInstanceId]/access/page.tsx index 5fa2ff42250..f8db21c90d1 100644 --- a/web/app/(commonLayout)/deployments/[appInstanceId]/access/page.tsx +++ b/web/app/(commonLayout)/deployments/[appInstanceId]/access/page.tsx @@ -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 +export default function InstanceDetailAccessPage() { + return } diff --git a/web/app/(commonLayout)/deployments/[appInstanceId]/api-tokens/page.tsx b/web/app/(commonLayout)/deployments/[appInstanceId]/api-tokens/page.tsx index 6fb643008ad..55d4812aa4c 100644 --- a/web/app/(commonLayout)/deployments/[appInstanceId]/api-tokens/page.tsx +++ b/web/app/(commonLayout)/deployments/[appInstanceId]/api-tokens/page.tsx @@ -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 +export default function InstanceDetailApiTokensPage() { + return } diff --git a/web/features/deployments/detail/access-tab.tsx b/web/features/deployments/detail/access-tab.tsx deleted file mode 100644 index 92b463e3970..00000000000 --- a/web/features/deployments/detail/access-tab.tsx +++ /dev/null @@ -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 ( -
- - -
- ) -} diff --git a/web/features/deployments/detail/settings-tab/access/__tests__/state.spec.ts b/web/features/deployments/detail/access-tab/__tests__/state.spec.ts similarity index 97% rename from web/features/deployments/detail/settings-tab/access/__tests__/state.spec.ts rename to web/features/deployments/detail/access-tab/__tests__/state.spec.ts index 77e3d9b2c22..65ec27d8773 100644 --- a/web/features/deployments/detail/settings-tab/access/__tests__/state.spec.ts +++ b/web/features/deployments/detail/access-tab/__tests__/state.spec.ts @@ -48,7 +48,7 @@ async function loadState() { function setDeploymentRoute(store: ReturnType, appInstanceId = 'app-instance-1') { store.set(setNextRouteStateAtom, { - pathname: `/deployments/${appInstanceId}/settings/access`, + pathname: `/deployments/${appInstanceId}/access`, params: { appInstanceId }, }) } diff --git a/web/features/deployments/detail/settings-tab/access/__tests__/channels-section.spec.tsx b/web/features/deployments/detail/access-tab/channels/__tests__/section.spec.tsx similarity index 61% rename from web/features/deployments/detail/settings-tab/access/__tests__/channels-section.spec.tsx rename to web/features/deployments/detail/access-tab/channels/__tests__/section.spec.tsx index fb4195115a7..488ae1a7732 100644 --- a/web/features/deployments/detail/settings-tab/access/__tests__/channels-section.spec.tsx +++ b/web/features/deployments/detail/access-tab/channels/__tests__/section.spec.tsx @@ -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() + 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( - , - ) + render() expect(screen.getByText('deployments.access.runAccess.webappDesc')).toBeInTheDocument() expect(screen.getByText('deployments.access.cli.description')).toBeInTheDocument() diff --git a/web/features/deployments/detail/settings-tab/access/channels-section.tsx b/web/features/deployments/detail/access-tab/channels/section.tsx similarity index 89% rename from web/features/deployments/detail/settings-tab/access/channels-section.tsx rename to web/features/deployments/detail/access-tab/channels/section.tsx index d3dde7936a4..5f08cc032c3 100644 --- a/web/features/deployments/detail/settings-tab/access/channels-section.tsx +++ b/web/features/deployments/detail/access-tab/channels/section.tsx @@ -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 ( { + 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')} {isLoading ? - : isError + : isError || !appInstanceId ? {t('common.loadFailed')} : runEnabled ? ( diff --git a/web/features/deployments/detail/settings-tab/access/url.ts b/web/features/deployments/detail/access-tab/channels/url.ts similarity index 100% rename from web/features/deployments/detail/settings-tab/access/url.ts rename to web/features/deployments/detail/access-tab/channels/url.ts diff --git a/web/features/deployments/detail/settings-tab/access/common.tsx b/web/features/deployments/detail/access-tab/components/endpoint.tsx similarity index 100% rename from web/features/deployments/detail/settings-tab/access/common.tsx rename to web/features/deployments/detail/access-tab/components/endpoint.tsx diff --git a/web/features/deployments/detail/settings-tab/access/__tests__/api-key-generate-menu.spec.tsx b/web/features/deployments/detail/access-tab/developer-api/__tests__/api-key-generate-menu.spec.tsx similarity index 73% rename from web/features/deployments/detail/settings-tab/access/__tests__/api-key-generate-menu.spec.tsx rename to web/features/deployments/detail/access-tab/developer-api/__tests__/api-key-generate-menu.spec.tsx index a83974fba6f..900836db3cd 100644 --- a/web/features/deployments/detail/settings-tab/access/__tests__/api-key-generate-menu.spec.tsx +++ b/web/features/deployments/detail/access-tab/developer-api/__tests__/api-key-generate-menu.spec.tsx @@ -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() + 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( , @@ -58,7 +72,6 @@ describe('ApiKeyGenerateMenu', () => { it('should clear the required name error when typing a valid name', () => { render( , @@ -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( + , + ) + + expect(screen.getByRole('button', { name: 'deployments.access.api.newKey' })).toBeDisabled() + }) }) diff --git a/web/features/deployments/detail/settings-tab/access/api-key-generate-menu.tsx b/web/features/deployments/detail/access-tab/developer-api/api-key-generate-menu.tsx similarity index 96% rename from web/features/deployments/detail/settings-tab/access/api-key-generate-menu.tsx rename to web/features/deployments/detail/access-tab/developer-api/api-key-generate-menu.tsx index bde6eb5d667..b79ddd5b9f8 100644 --- a/web/features/deployments/detail/settings-tab/access/api-key-generate-menu.tsx +++ b/web/features/deployments/detail/access-tab/developer-api/api-key-generate-menu.tsx @@ -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(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 } diff --git a/web/features/deployments/detail/settings-tab/access/api-key-list.tsx b/web/features/deployments/detail/access-tab/developer-api/api-key-list.tsx similarity index 98% rename from web/features/deployments/detail/settings-tab/access/api-key-list.tsx rename to web/features/deployments/detail/access-tab/developer-api/api-key-list.tsx index cf2a8553aee..4bcbc9152c1 100644 --- a/web/features/deployments/detail/settings-tab/access/api-key-list.tsx +++ b/web/features/deployments/detail/access-tab/developer-api/api-key-list.tsx @@ -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 diff --git a/web/features/deployments/detail/settings-tab/access/api-token-name.ts b/web/features/deployments/detail/access-tab/developer-api/api-token-name.ts similarity index 100% rename from web/features/deployments/detail/settings-tab/access/api-token-name.ts rename to web/features/deployments/detail/access-tab/developer-api/api-token-name.ts diff --git a/web/features/deployments/detail/settings-tab/access/developer-api-created-token-dialog.tsx b/web/features/deployments/detail/access-tab/developer-api/created-token-dialog.tsx similarity index 98% rename from web/features/deployments/detail/settings-tab/access/developer-api-created-token-dialog.tsx rename to web/features/deployments/detail/access-tab/developer-api/created-token-dialog.tsx index 3582e53bdcc..16778ec84fc 100644 --- a/web/features/deployments/detail/settings-tab/access/developer-api-created-token-dialog.tsx +++ b/web/features/deployments/detail/access-tab/developer-api/created-token-dialog.tsx @@ -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}' \\ diff --git a/web/features/deployments/detail/settings-tab/access/api-docs-drawer.tsx b/web/features/deployments/detail/access-tab/developer-api/docs-drawer.tsx similarity index 94% rename from web/features/deployments/detail/settings-tab/access/api-docs-drawer.tsx rename to web/features/deployments/detail/access-tab/developer-api/docs-drawer.tsx index fb88bf0c9cf..013b5872878 100644 --- a/web/features/deployments/detail/settings-tab/access/api-docs-drawer.tsx +++ b/web/features/deployments/detail/access-tab/developer-api/docs-drawer.tsx @@ -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 @@ -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, diff --git a/web/features/deployments/detail/access-tab/developer-api/index.tsx b/web/features/deployments/detail/access-tab/developer-api/index.tsx new file mode 100644 index 00000000000..a223de437d5 --- /dev/null +++ b/web/features/deployments/detail/access-tab/developer-api/index.tsx @@ -0,0 +1,11 @@ +'use client' + +import { DeveloperApiSection } from './section' + +export function DeveloperApiTab() { + return ( +
+ +
+ ) +} diff --git a/web/features/deployments/detail/settings-tab/access/developer-api-section.tsx b/web/features/deployments/detail/access-tab/developer-api/section.tsx similarity index 80% rename from web/features/deployments/detail/settings-tab/access/developer-api-section.tsx rename to web/features/deployments/detail/access-tab/developer-api/section.tsx index 3d9ca287cf7..503ca1dadf6 100644 --- a/web/features/deployments/detail/settings-tab/access/developer-api-section.tsx +++ b/web/features/deployments/detail/access-tab/developer-api/section.tsx @@ -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 ( { + 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 return ( @@ -95,10 +75,9 @@ export function DeveloperApiHeaderSwitch({ appInstanceId }: { {apiEnabled ? t('overview.enabled') : t('overview.disabled')} ) @@ -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 }: { @@ -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() - 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 - if (isError) + if (developerApiSettingsQuery.isError || !appInstanceId) return {t('common.loadFailed')} if (!apiEnabled) { @@ -205,14 +177,12 @@ export function DeveloperApiSection({
{apiUrl && ( )} {hasSelectableEnvironment ? ( setCreatedApiToken({ appInstanceId, token })} diff --git a/web/features/deployments/detail/settings-tab/access/developer-api-skeleton.tsx b/web/features/deployments/detail/access-tab/developer-api/skeleton.tsx similarity index 96% rename from web/features/deployments/detail/settings-tab/access/developer-api-skeleton.tsx rename to web/features/deployments/detail/access-tab/developer-api/skeleton.tsx index ab1b92ff63f..d37d26719b8 100644 --- a/web/features/deployments/detail/settings-tab/access/developer-api-skeleton.tsx +++ b/web/features/deployments/detail/access-tab/developer-api/skeleton.tsx @@ -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'] diff --git a/web/features/deployments/detail/access-tab/index.tsx b/web/features/deployments/detail/access-tab/index.tsx new file mode 100644 index 00000000000..88f28cf6a8b --- /dev/null +++ b/web/features/deployments/detail/access-tab/index.tsx @@ -0,0 +1,13 @@ +'use client' + +import { AccessChannelsSection } from './channels/section' +import { AccessPermissionsSection } from './permissions/section' + +export function AccessTab() { + return ( +
+ + +
+ ) +} diff --git a/web/features/deployments/detail/settings-tab/access/__tests__/deployment-access-control-dialog.spec.tsx b/web/features/deployments/detail/access-tab/permissions/__tests__/access-control-dialog.spec.tsx similarity index 50% rename from web/features/deployments/detail/settings-tab/access/__tests__/deployment-access-control-dialog.spec.tsx rename to web/features/deployments/detail/access-tab/permissions/__tests__/access-control-dialog.spec.tsx index 4b923f33449..81d33131291 100644 --- a/web/features/deployments/detail/settings-tab/access/__tests__/deployment-access-control-dialog.spec.tsx +++ b/web/features/deployments/detail/access-tab/permissions/__tests__/access-control-dialog.spec.tsx @@ -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( , @@ -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', + }, + ]) }) }) diff --git a/web/features/deployments/detail/settings-tab/access/__tests__/access-policy.spec.ts b/web/features/deployments/detail/access-tab/permissions/__tests__/access-policy.spec.ts similarity index 100% rename from web/features/deployments/detail/settings-tab/access/__tests__/access-policy.spec.ts rename to web/features/deployments/detail/access-tab/permissions/__tests__/access-policy.spec.ts diff --git a/web/features/deployments/detail/settings-tab/access/__tests__/permissions.spec.tsx b/web/features/deployments/detail/access-tab/permissions/__tests__/permissions.spec.tsx similarity index 86% rename from web/features/deployments/detail/settings-tab/access/__tests__/permissions.spec.tsx rename to web/features/deployments/detail/access-tab/permissions/__tests__/permissions.spec.tsx index 9c21ac5083d..281c5f89bdd 100644 --- a/web/features/deployments/detail/settings-tab/access/__tests__/permissions.spec.tsx +++ b/web/features/deployments/detail/access-tab/permissions/__tests__/permissions.spec.tsx @@ -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() + 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( , @@ -123,7 +138,6 @@ describe('EnvironmentPermissionRow', () => { renderWithAtomStore( , @@ -161,7 +175,6 @@ describe('EnvironmentPermissionRow', () => { renderWithAtomStore( , @@ -202,7 +215,6 @@ describe('EnvironmentPermissionRow', () => { it('should show specific subject counts in the access summary', () => { renderWithAtomStore( , @@ -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( - , - ) + renderWithAtomStore() expect(screen.getByText('Production')).toBeInTheDocument() expect(screen.queryByText('deployments.access.permissions.col.environment')).not.toBeInTheDocument() diff --git a/web/features/deployments/detail/access-tab/permissions/access-control-dialog.tsx b/web/features/deployments/detail/access-tab/permissions/access-control-dialog.tsx new file mode 100644 index 00000000000..310bbc6d374 --- /dev/null +++ b/web/features/deployments/detail/access-tab/permissions/access-control-dialog.tsx @@ -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 ( + !open && onClose()}> + + + {open && ( + + )} + + + ) +} + +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(() => + 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 ( +
+
+ + {t('access.permissions.editTitle')} + + + {t('access.permissions.editDescription')} + +
+ + 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} + > +
+

+ {t('accessControlDialog.accessLabel', { ns: 'app' })} +

+
+ +
+
+
+
+
+ + + + +
+
+
+ +
+ + +
+
+ ) +} + +function AccessControlItem({ type, children }: PropsWithChildren<{ + type: AppAccessMode +}>) { + return ( + + value={type} + variant="unstyled" + render={
} + 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} + + ) +} + +function SpecificGroupsOrMembersOption({ + selected, + selection, + onSelectionChange, +}: { + selected: boolean + selection: AccessSubjectSelectionValue + onSelectionChange: (selection: AccessSubjectSelectionValue) => void +}) { + const { t } = useTranslation() + + if (!selected) { + return ( +
+
+
+
+ ) + } + + return ( +
+
+
+
+
+ +
+
+
+ +
+
+ ) +} diff --git a/web/features/deployments/detail/settings-tab/access/access-policy.ts b/web/features/deployments/detail/access-tab/permissions/access-policy.ts similarity index 100% rename from web/features/deployments/detail/settings-tab/access/access-policy.ts rename to web/features/deployments/detail/access-tab/permissions/access-policy.ts diff --git a/web/features/deployments/detail/access-tab/permissions/access-subject-selector/__tests__/add-button.spec.tsx b/web/features/deployments/detail/access-tab/permissions/access-subject-selector/__tests__/add-button.spec.tsx new file mode 100644 index 00000000000..47254b9dc21 --- /dev/null +++ b/web/features/deployments/detail/access-tab/permissions/access-subject-selector/__tests__/add-button.spec.tsx @@ -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( + , + ) + + 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() + }) + }) +}) diff --git a/web/features/deployments/detail/settings-tab/access/access-subject-selector/add-button.tsx b/web/features/deployments/detail/access-tab/permissions/access-subject-selector/add-button.tsx similarity index 87% rename from web/features/deployments/detail/settings-tab/access/access-subject-selector/add-button.tsx rename to web/features/deployments/detail/access-tab/permissions/access-subject-selector/add-button.tsx index ccd68de5b6d..c4bb8b72b20 100644 --- a/web/features/deployments/detail/settings-tab/access/access-subject-selector/add-button.tsx +++ b/web/features/deployments/detail/access-tab/permissions/access-subject-selector/add-button.tsx @@ -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([]) + const [breadcrumbGroups, setBreadcrumbGroups] = useState([]) const scrollRootRef = useRef(null) const anchorRef = useRef(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" > @@ -156,8 +159,8 @@ export function AccessSubjectAddButton({ {shouldShowBreadcrumb && (
)} @@ -171,7 +174,7 @@ export function AccessSubjectAddButton({ subject={subject} selectedGroups={selectedGroups} selectedMembers={selectedMembers} - onExpandGroup={group => setSelectedGroupsForBreadcrumb([...selectedGroupsForBreadcrumb, group])} + onExpandGroup={group => setBreadcrumbGroups([...breadcrumbGroups, group])} /> )} diff --git a/web/features/deployments/detail/settings-tab/access/access-subject-selector/selection-list.tsx b/web/features/deployments/detail/access-tab/permissions/access-subject-selector/selection-list.tsx similarity index 100% rename from web/features/deployments/detail/settings-tab/access/access-subject-selector/selection-list.tsx rename to web/features/deployments/detail/access-tab/permissions/access-subject-selector/selection-list.tsx diff --git a/web/features/deployments/detail/settings-tab/access/access-subject-selector/subject-options.tsx b/web/features/deployments/detail/access-tab/permissions/access-subject-selector/subject-options.tsx similarity index 100% rename from web/features/deployments/detail/settings-tab/access/access-subject-selector/subject-options.tsx rename to web/features/deployments/detail/access-tab/permissions/access-subject-selector/subject-options.tsx diff --git a/web/features/deployments/detail/settings-tab/access/access-subject-selector/types.ts b/web/features/deployments/detail/access-tab/permissions/access-subject-selector/types.ts similarity index 100% rename from web/features/deployments/detail/settings-tab/access/access-subject-selector/types.ts rename to web/features/deployments/detail/access-tab/permissions/access-subject-selector/types.ts diff --git a/web/features/deployments/detail/settings-tab/access/access-subject-selector/utils.ts b/web/features/deployments/detail/access-tab/permissions/access-subject-selector/utils.ts similarity index 100% rename from web/features/deployments/detail/settings-tab/access/access-subject-selector/utils.ts rename to web/features/deployments/detail/access-tab/permissions/access-subject-selector/utils.ts diff --git a/web/features/deployments/detail/settings-tab/access/permissions.tsx b/web/features/deployments/detail/access-tab/permissions/environment-permission-row.tsx similarity index 64% rename from web/features/deployments/detail/settings-tab/access/permissions.tsx rename to web/features/deployments/detail/access-tab/permissions/environment-permission-row.tsx index 79e51f58c43..8e62fd3c0ef 100644 --- a/web/features/deployments/detail/settings-tab/access/permissions.tsx +++ b/web/features/deployments/detail/access-tab/permissions/environment-permission-row.tsx @@ -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() + 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}
- setDialogOpen(true)} + /> + setDialogOpen(false)} onSubmit={handlePermissionSubmit} />
) } - -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 ( - <> - setDialogOpen(true)} - /> - {dialogOpen && ( - setDialogOpen(false)} - onSubmit={handleSubmit} - /> - )} - - ) -} diff --git a/web/features/deployments/detail/settings-tab/access/permission-row-components.tsx b/web/features/deployments/detail/access-tab/permissions/permission-summary-button.tsx similarity index 100% rename from web/features/deployments/detail/settings-tab/access/permission-row-components.tsx rename to web/features/deployments/detail/access-tab/permissions/permission-summary-button.tsx diff --git a/web/features/deployments/detail/settings-tab/access/permissions-section.tsx b/web/features/deployments/detail/access-tab/permissions/section.tsx similarity index 75% rename from web/features/deployments/detail/settings-tab/access/permissions-section.tsx rename to web/features/deployments/detail/access-tab/permissions/section.tsx index 52d83c55e63..4243ec6db45 100644 --- a/web/features/deployments/detail/settings-tab/access/permissions-section.tsx +++ b/web/features/deployments/detail/access-tab/permissions/section.tsx @@ -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 ? - : isError + : isError || !appInstanceId ? {t('common.loadFailed')} : policyRows.length === 0 ? ( @@ -61,7 +59,6 @@ export function AccessPermissionsSection({ return ( { const appInstanceId = get(deploymentRouteAppInstanceIdAtom) diff --git a/web/features/deployments/detail/table-styles.ts b/web/features/deployments/detail/components/detail-table-styles.ts similarity index 100% rename from web/features/deployments/detail/table-styles.ts rename to web/features/deployments/detail/components/detail-table-styles.ts diff --git a/web/features/deployments/detail/table.tsx b/web/features/deployments/detail/components/detail-table.tsx similarity index 100% rename from web/features/deployments/detail/table.tsx rename to web/features/deployments/detail/components/detail-table.tsx diff --git a/web/features/deployments/detail/common.tsx b/web/features/deployments/detail/components/section.tsx similarity index 100% rename from web/features/deployments/detail/common.tsx rename to web/features/deployments/detail/components/section.tsx diff --git a/web/features/deployments/detail/deploy-tab/deployment-environment-list.tsx b/web/features/deployments/detail/deploy-tab/deployment-environment-list.tsx index d01d908aaaa..fb45e113cb7 100644 --- a/web/features/deployments/detail/deploy-tab/deployment-environment-list.tsx +++ b/web/features/deployments/detail/deploy-tab/deployment-environment-list.tsx @@ -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' diff --git a/web/features/deployments/detail/deploy-tab/deployment-row-actions-menu.tsx b/web/features/deployments/detail/deploy-tab/deployment-row-actions-menu.tsx index 5561d2a3b2e..29dc75222c2 100644 --- a/web/features/deployments/detail/deploy-tab/deployment-row-actions-menu.tsx +++ b/web/features/deployments/detail/deploy-tab/deployment-row-actions-menu.tsx @@ -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, diff --git a/web/features/deployments/detail/deploy-tab.tsx b/web/features/deployments/detail/deploy-tab/index.tsx similarity index 92% rename from web/features/deployments/detail/deploy-tab.tsx rename to web/features/deployments/detail/deploy-tab/index.tsx index 874881f31f5..db5c7b656c9 100644 --- a/web/features/deployments/detail/deploy-tab.tsx +++ b/web/features/deployments/detail/deploy-tab/index.tsx @@ -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'] diff --git a/web/features/deployments/detail/developer-api-tab.tsx b/web/features/deployments/detail/developer-api-tab.tsx deleted file mode 100644 index 6ee0e0a88da..00000000000 --- a/web/features/deployments/detail/developer-api-tab.tsx +++ /dev/null @@ -1,13 +0,0 @@ -'use client' - -import { DeveloperApiSection } from './settings-tab/access/developer-api-section' - -export function DeveloperApiTab({ appInstanceId }: { - appInstanceId: string -}) { - return ( -
- -
- ) -} diff --git a/web/features/deployments/detail/index.tsx b/web/features/deployments/detail/index.tsx index 5d188ce347e..42b1d18c41c 100644 --- a/web/features/deployments/detail/index.tsx +++ b/web/features/deployments/detail/index.tsx @@ -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 }: {
{t(`tabs.${activeTab}.name`)}
{activeTab === 'api-tokens' && (
- +
)} diff --git a/web/features/deployments/detail/overview-tab.tsx b/web/features/deployments/detail/overview-tab/index.tsx similarity index 88% rename from web/features/deployments/detail/overview-tab.tsx rename to web/features/deployments/detail/overview-tab/index.tsx index 4a742c1e115..771474c3ff1 100644 --- a/web/features/deployments/detail/overview-tab.tsx +++ b/web/features/deployments/detail/overview-tab/index.tsx @@ -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 ( diff --git a/web/features/deployments/detail/settings-tab/access/access-control-dialog-content.tsx b/web/features/deployments/detail/settings-tab/access/access-control-dialog-content.tsx deleted file mode 100644 index fb1f324a0ea..00000000000 --- a/web/features/deployments/detail/settings-tab/access/access-control-dialog-content.tsx +++ /dev/null @@ -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 ( -
-
- - {title ?? t('accessControlDialog.title', { ns: 'app' })} - - - {description ?? t('accessControlDialog.description', { ns: 'app' })} - -
- - 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} - > -
-

- {accessLabel ?? t('accessControlDialog.accessLabel', { ns: 'app' })} -

-
- -
-
-
-
-
- - - - {!hideExternal && ( - -
-
-
- {!hideExternalTip && } -
-
- )} - -
-
-
- -
- - -
-
- ) -} diff --git a/web/features/deployments/detail/settings-tab/access/access-control-dialog.tsx b/web/features/deployments/detail/settings-tab/access/access-control-dialog.tsx deleted file mode 100644 index c29e73aa9a4..00000000000 --- a/web/features/deployments/detail/settings-tab/access/access-control-dialog.tsx +++ /dev/null @@ -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 ( - !open && onClose?.()}> - - - {children} - - - ) -} diff --git a/web/features/deployments/detail/settings-tab/access/access-control-item.tsx b/web/features/deployments/detail/settings-tab/access/access-control-item.tsx deleted file mode 100644 index 5913a7e47f8..00000000000 --- a/web/features/deployments/detail/settings-tab/access/access-control-item.tsx +++ /dev/null @@ -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 ( - - value={type} - variant="unstyled" - render={
} - 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} - - ) -} diff --git a/web/features/deployments/detail/settings-tab/access/add-member-or-group-pop.tsx b/web/features/deployments/detail/settings-tab/access/add-member-or-group-pop.tsx deleted file mode 100644 index c1df04d257c..00000000000 --- a/web/features/deployments/detail/settings-tab/access/add-member-or-group-pop.tsx +++ /dev/null @@ -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 ( - { - setSpecificGroups(groups) - setSpecificMembers(members) - }} - /> - ) -} diff --git a/web/features/deployments/detail/settings-tab/access/deployment-access-control-dialog.tsx b/web/features/deployments/detail/settings-tab/access/deployment-access-control-dialog.tsx deleted file mode 100644 index 544ddcacd99..00000000000 --- a/web/features/deployments/detail/settings-tab/access/deployment-access-control-dialog.tsx +++ /dev/null @@ -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 ( - - - - ) -} - -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 ( - - - - ) -} diff --git a/web/features/deployments/detail/settings-tab/access/specific-groups-or-members.tsx b/web/features/deployments/detail/settings-tab/access/specific-groups-or-members.tsx deleted file mode 100644 index 7d536e0f1f1..00000000000 --- a/web/features/deployments/detail/settings-tab/access/specific-groups-or-members.tsx +++ /dev/null @@ -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 ( -
-
-
-
- ) - } - - return ( -
-
-
-
-
- -
-
-
- { - setSpecificGroups(groups) - setSpecificMembers(members) - }} - /> -
-
- ) -} - -export function WebAppSSONotEnabledTip() { - const { t } = useTranslation() - const tip = t('accessControlDialog.webAppSSONotEnabledTip', { ns: 'app' }) - - return ( - - {tip} - - ) -} diff --git a/web/features/deployments/detail/settings-tab/access/store-provider.tsx b/web/features/deployments/detail/settings-tab/access/store-provider.tsx deleted file mode 100644 index 7b8563289b1..00000000000 --- a/web/features/deployments/detail/settings-tab/access/store-provider.tsx +++ /dev/null @@ -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 ( - - {children} - - ) -} diff --git a/web/features/deployments/detail/settings-tab/access/store.ts b/web/features/deployments/detail/settings-tab/access/store.ts deleted file mode 100644 index 7b863c2f93c..00000000000 --- a/web/features/deployments/detail/settings-tab/access/store.ts +++ /dev/null @@ -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 - -export function createAccessControlStore(initialDraft: AccessControlDraft) { - return createStore(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(undefined) - -export function useAccessControlStore(selector: (state: AccessControlStore) => T) { - const store = use(AccessControlStoreContext) - if (!store) - throw new Error('useAccessControlStore must be used inside AccessControlDraftProvider') - - return useStore(store, selector) -} diff --git a/web/features/deployments/detail/versions-tab/deploy-release-menu.tsx b/web/features/deployments/detail/versions-tab/deploy-release-menu.tsx index 01f9b354447..0ae31c3e9e8 100644 --- a/web/features/deployments/detail/versions-tab/deploy-release-menu.tsx +++ b/web/features/deployments/detail/versions-tab/deploy-release-menu.tsx @@ -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, diff --git a/web/features/deployments/detail/versions-tab.tsx b/web/features/deployments/detail/versions-tab/index.tsx similarity index 76% rename from web/features/deployments/detail/versions-tab.tsx rename to web/features/deployments/detail/versions-tab/index.tsx index b58752711c4..d26a2506045 100644 --- a/web/features/deployments/detail/versions-tab.tsx +++ b/web/features/deployments/detail/versions-tab/index.tsx @@ -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 diff --git a/web/features/deployments/detail/versions-tab/release-history-rows.tsx b/web/features/deployments/detail/versions-tab/release-history-rows.tsx index 60a55301fd0..1e984c2e0eb 100644 --- a/web/features/deployments/detail/versions-tab/release-history-rows.tsx +++ b/web/features/deployments/detail/versions-tab/release-history-rows.tsx @@ -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, diff --git a/web/features/deployments/detail/versions-tab/release-history-table-skeleton.tsx b/web/features/deployments/detail/versions-tab/release-history-table-skeleton.tsx index b2c11bcae22..40e7a6fca3e 100644 --- a/web/features/deployments/detail/versions-tab/release-history-table-skeleton.tsx +++ b/web/features/deployments/detail/versions-tab/release-history-table-skeleton.tsx @@ -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']