mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
feat: app deployment v2 (#39829)
Co-authored-by: zhangx1n <zhangxin@dify.ai> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
542 files changed
+29450
-13824
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import type { SecretKeyScope } from '../secret-key-modal'
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import SecretKeyButton from '../secret-key-button'
|
||||
@@ -6,17 +7,19 @@ vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({
|
||||
default: ({
|
||||
isShow,
|
||||
onClose,
|
||||
appId,
|
||||
canManage,
|
||||
scope,
|
||||
}: {
|
||||
isShow: boolean
|
||||
onClose: () => void
|
||||
appId?: string
|
||||
canManage: boolean
|
||||
scope: SecretKeyScope
|
||||
}) =>
|
||||
isShow ? (
|
||||
<div data-testid="secret-key-modal">
|
||||
<span data-testid="modal-app-id">{`Modal for ${appId || 'no-app'}`}</span>
|
||||
<span data-testid="modal-app-id">
|
||||
{`Modal for ${scope.type === 'dataset' ? 'no-app' : scope.appId}`}
|
||||
</span>
|
||||
<span data-testid="modal-can-manage">{String(canManage)}</span>
|
||||
<button onClick={onClose} data-testid="close-modal">
|
||||
Close
|
||||
|
||||
@@ -1,14 +1,112 @@
|
||||
import type { ApiKeyList as AppApiKeyList } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { ApiKeyList as DatasetApiKeyList } from '@dify/contracts/api/console/datasets/types.gen'
|
||||
import type { EnvironmentApiKey } from '@dify/contracts/enterprise-app-deploy/types.gen'
|
||||
import type { SecretKeyScope } from '../secret-key-modal'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach } from 'vitest'
|
||||
import { render } from '@/test/console/render'
|
||||
import { createTestQueryClient } from '@/test/query-client'
|
||||
import SecretKeyModal from '../secret-key-modal'
|
||||
|
||||
type MutationCallbacks<TData> = {
|
||||
onSuccess?: (data: TData) => void
|
||||
}
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
appKeys: [] as AppApiKeyList['data'],
|
||||
datasetKeys: [] as DatasetApiKeyList['data'],
|
||||
environmentKeys: [] as EnvironmentApiKey[],
|
||||
listApp: vi.fn(),
|
||||
createApp: vi.fn(),
|
||||
deleteApp: vi.fn(),
|
||||
listDataset: vi.fn(),
|
||||
createDataset: vi.fn(),
|
||||
deleteDataset: vi.fn(),
|
||||
listEnvironment: vi.fn(),
|
||||
createEnvironment: vi.fn(),
|
||||
deleteEnvironment: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
apps: {
|
||||
byResourceId: {
|
||||
apiKeys: {
|
||||
get: {
|
||||
queryOptions: ({ input }: { input: unknown }) => ({
|
||||
queryKey: ['apps', 'api-keys', input],
|
||||
queryFn: () => {
|
||||
apiMocks.listApp(input)
|
||||
return Promise.resolve({ data: apiMocks.appKeys })
|
||||
},
|
||||
}),
|
||||
},
|
||||
post: {
|
||||
mutationOptions: () => ({
|
||||
mutationFn: (variables: unknown) => apiMocks.createApp(variables),
|
||||
}),
|
||||
},
|
||||
byApiKeyId: {
|
||||
delete: {
|
||||
mutationOptions: () => ({
|
||||
mutationFn: (variables: unknown) => apiMocks.deleteApp(variables),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
datasets: {
|
||||
apiKeys: {
|
||||
get: {
|
||||
queryOptions: () => ({
|
||||
queryKey: ['datasets', 'api-keys'],
|
||||
queryFn: () => {
|
||||
apiMocks.listDataset()
|
||||
return Promise.resolve({ data: apiMocks.datasetKeys })
|
||||
},
|
||||
}),
|
||||
},
|
||||
post: {
|
||||
mutationOptions: () => ({
|
||||
mutationFn: () => apiMocks.createDataset(),
|
||||
}),
|
||||
},
|
||||
byApiKeyId: {
|
||||
delete: {
|
||||
mutationOptions: () => ({
|
||||
mutationFn: (variables: unknown) => apiMocks.deleteDataset(variables),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
enterprise: {
|
||||
appDeploy: {
|
||||
accessService: {
|
||||
listEnvironmentApiKeys: {
|
||||
queryOptions: ({ input, enabled }: { input: unknown; enabled?: boolean }) => ({
|
||||
queryKey: ['environment', 'api-keys', input],
|
||||
queryFn: () => {
|
||||
apiMocks.listEnvironment(input)
|
||||
return Promise.resolve({ data: apiMocks.environmentKeys })
|
||||
},
|
||||
enabled,
|
||||
}),
|
||||
},
|
||||
createEnvironmentApiKey: {
|
||||
mutationOptions: () => ({
|
||||
mutationFn: (variables: unknown) => apiMocks.createEnvironment(variables),
|
||||
}),
|
||||
},
|
||||
deleteEnvironmentApiKey: {
|
||||
mutationOptions: () => ({
|
||||
mutationFn: (variables: unknown) => apiMocks.deleteEnvironment(variables),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const mockCurrentWorkspace = vi.fn().mockReturnValue({
|
||||
id: 'workspace-1',
|
||||
@@ -30,45 +128,27 @@ vi.mock('@/hooks/use-timestamp', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
let appApiKeys: AppApiKeyList = { data: [] }
|
||||
let datasetApiKeys: DatasetApiKeyList = { data: [] }
|
||||
let appApiKeysLoading = false
|
||||
let datasetApiKeysLoading = false
|
||||
const appScope = { type: 'app', appId: 'app-123' } as const
|
||||
const datasetScope = { type: 'dataset' } as const
|
||||
const environmentScope = {
|
||||
type: 'environment',
|
||||
appId: 'app-123',
|
||||
environmentId: 'staging',
|
||||
} as const
|
||||
|
||||
const createAppApiKey = vi.fn(
|
||||
(_variables: unknown, callbacks?: MutationCallbacks<{ token: string }>) =>
|
||||
callbacks?.onSuccess?.({ token: 'new-app-token-123' }),
|
||||
)
|
||||
const deleteAppApiKey = vi.fn()
|
||||
const createDatasetApiKey = vi.fn(
|
||||
(_variables: unknown, callbacks?: MutationCallbacks<{ token: string }>) =>
|
||||
callbacks?.onSuccess?.({ token: 'new-dataset-token-123' }),
|
||||
)
|
||||
const deleteDatasetApiKey = vi.fn()
|
||||
|
||||
let mutationHookIndex = 0
|
||||
vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
|
||||
return {
|
||||
...actual,
|
||||
useMutation: () => {
|
||||
const mutations = [createAppApiKey, deleteAppApiKey, createDatasetApiKey, deleteDatasetApiKey]
|
||||
const mutate = mutations[mutationHookIndex % mutations.length]
|
||||
mutationHookIndex += 1
|
||||
return { mutate }
|
||||
},
|
||||
useQuery: (options: { queryKey: readonly unknown[] }) => {
|
||||
const isAppQuery = JSON.stringify(options.queryKey).includes('"apps"')
|
||||
return isAppQuery
|
||||
? { data: appApiKeys, isLoading: appApiKeysLoading }
|
||||
: { data: datasetApiKeys, isLoading: datasetApiKeysLoading }
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
async function renderModal(appId?: string) {
|
||||
async function renderModal(scope: SecretKeyScope, overrides: { canManage?: boolean } = {}) {
|
||||
const queryClient = createTestQueryClient()
|
||||
const onClose = vi.fn()
|
||||
const result = render(<SecretKeyModal isShow appId={appId} canManage onClose={onClose} />)
|
||||
const result = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SecretKeyModal
|
||||
isShow
|
||||
canManage={overrides.canManage ?? true}
|
||||
scope={scope}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
await act(async () => {
|
||||
vi.runAllTimers()
|
||||
})
|
||||
@@ -89,11 +169,21 @@ describe('SecretKeyModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
mutationHookIndex = 0
|
||||
appApiKeys = { data: [] }
|
||||
datasetApiKeys = { data: [] }
|
||||
appApiKeysLoading = false
|
||||
datasetApiKeysLoading = false
|
||||
mockCurrentWorkspace.mockReturnValue({ id: 'workspace-1', name: 'Test Workspace' })
|
||||
apiMocks.appKeys = []
|
||||
apiMocks.datasetKeys = []
|
||||
apiMocks.environmentKeys = []
|
||||
apiMocks.createApp.mockResolvedValue({ token: 'new-app-token-123' })
|
||||
apiMocks.deleteApp.mockResolvedValue(undefined)
|
||||
apiMocks.createDataset.mockResolvedValue({ token: 'new-dataset-token-123' })
|
||||
apiMocks.deleteDataset.mockResolvedValue(undefined)
|
||||
apiMocks.createEnvironment.mockResolvedValue({
|
||||
id: 'environment-key-2',
|
||||
token: 'env-created-secret-key-abcdefghijklmnopqrst',
|
||||
type: 'api',
|
||||
created_at: 1,
|
||||
})
|
||||
apiMocks.deleteEnvironment.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -101,119 +191,202 @@ describe('SecretKeyModal', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('renders the app API key dataset selected by appId', async () => {
|
||||
appApiKeys = {
|
||||
data: [
|
||||
{
|
||||
id: 'app-key-1',
|
||||
token: 'app-secret-token-123456789',
|
||||
type: 'app',
|
||||
created_at: 1,
|
||||
},
|
||||
],
|
||||
}
|
||||
it('loads and renders app API keys through the generated query input', async () => {
|
||||
apiMocks.appKeys = [
|
||||
{
|
||||
id: 'app-key-1',
|
||||
token: 'app-secret-token-123456789',
|
||||
type: 'app',
|
||||
created_at: 1,
|
||||
},
|
||||
]
|
||||
|
||||
await renderModal('app-123')
|
||||
await renderModal(appScope)
|
||||
|
||||
expect(screen.getByText('app...cret-token-123456789')).toBeInTheDocument()
|
||||
expect(await screen.findByText('app...cret-token-123456789')).toBeInTheDocument()
|
||||
expect(apiMocks.listApp).toHaveBeenCalledWith({
|
||||
params: { resource_id: 'app-123' },
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the workspace dataset API keys without an appId', async () => {
|
||||
datasetApiKeys = {
|
||||
data: [
|
||||
{
|
||||
id: 'dataset-key-1',
|
||||
token: 'dataset-secret-token-123456789',
|
||||
type: 'dataset',
|
||||
created_at: 1,
|
||||
},
|
||||
],
|
||||
}
|
||||
it('loads and renders workspace dataset API keys', async () => {
|
||||
apiMocks.datasetKeys = [
|
||||
{
|
||||
id: 'dataset-key-1',
|
||||
token: 'dataset-secret-token-123456789',
|
||||
type: 'dataset',
|
||||
created_at: 1,
|
||||
},
|
||||
]
|
||||
|
||||
await renderModal()
|
||||
await renderModal(datasetScope)
|
||||
|
||||
expect(screen.getByText('dat...cret-token-123456789')).toBeInTheDocument()
|
||||
expect(await screen.findByText('dat...cret-token-123456789')).toBeInTheDocument()
|
||||
expect(apiMocks.listDataset).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates an app API key through the generated mutation input', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
await renderModal('app-123')
|
||||
await renderModal(appScope)
|
||||
|
||||
await user.click(screen.getByText('appApi.apiKeyModal.createNewSecretKey'))
|
||||
|
||||
expect(createAppApiKey).toHaveBeenCalledWith(
|
||||
{ params: { resource_id: 'app-123' } },
|
||||
expect.objectContaining({ onSuccess: expect.any(Function) }),
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(apiMocks.createApp).toHaveBeenCalledWith({
|
||||
params: { resource_id: 'app-123' },
|
||||
})
|
||||
})
|
||||
expect(await screen.findByText('new-app-token-123')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('creates a dataset API key through the generated mutation', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
await renderModal()
|
||||
await renderModal(datasetScope)
|
||||
|
||||
await user.click(screen.getByText('appApi.apiKeyModal.createNewSecretKey'))
|
||||
|
||||
expect(createDatasetApiKey).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
expect.objectContaining({ onSuccess: expect.any(Function) }),
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(apiMocks.createDataset).toHaveBeenCalledWith()
|
||||
})
|
||||
expect(await screen.findByText('new-dataset-token-123')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('deletes an app API key through the generated mutation input', async () => {
|
||||
appApiKeys = {
|
||||
data: [
|
||||
{
|
||||
id: 'app-key-0',
|
||||
token: 'other-app-secret-token-987654321',
|
||||
type: 'app',
|
||||
created_at: 1,
|
||||
},
|
||||
{
|
||||
id: 'app-key-1',
|
||||
token: 'app-secret-token-123456789',
|
||||
type: 'app',
|
||||
created_at: 1,
|
||||
},
|
||||
],
|
||||
}
|
||||
await renderModal('app-123')
|
||||
apiMocks.appKeys = [
|
||||
{
|
||||
id: 'app-key-0',
|
||||
token: 'other-app-secret-token-987654321',
|
||||
type: 'app',
|
||||
created_at: 1,
|
||||
},
|
||||
{
|
||||
id: 'app-key-1',
|
||||
token: 'app-secret-token-123456789',
|
||||
type: 'app',
|
||||
created_at: 1,
|
||||
},
|
||||
]
|
||||
await renderModal(appScope)
|
||||
await screen.findByText('app...cret-token-123456789')
|
||||
|
||||
await confirmKeyDeletion('common.operation.delete app...cret-token-123456789')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteAppApiKey).toHaveBeenCalledWith({
|
||||
expect(apiMocks.deleteApp).toHaveBeenCalledWith({
|
||||
params: { resource_id: 'app-123', api_key_id: 'app-key-1' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes a dataset API key through the generated mutation input', async () => {
|
||||
datasetApiKeys = {
|
||||
data: [
|
||||
{
|
||||
id: 'dataset-key-0',
|
||||
token: 'other-dataset-secret-token-987654321',
|
||||
type: 'dataset',
|
||||
created_at: 1,
|
||||
},
|
||||
{
|
||||
id: 'dataset-key-1',
|
||||
token: 'dataset-secret-token-123456789',
|
||||
type: 'dataset',
|
||||
created_at: 1,
|
||||
},
|
||||
],
|
||||
}
|
||||
await renderModal()
|
||||
apiMocks.datasetKeys = [
|
||||
{
|
||||
id: 'dataset-key-0',
|
||||
token: 'other-dataset-secret-token-987654321',
|
||||
type: 'dataset',
|
||||
created_at: 1,
|
||||
},
|
||||
{
|
||||
id: 'dataset-key-1',
|
||||
token: 'dataset-secret-token-123456789',
|
||||
type: 'dataset',
|
||||
created_at: 1,
|
||||
},
|
||||
]
|
||||
await renderModal(datasetScope)
|
||||
await screen.findByText('dat...cret-token-123456789')
|
||||
|
||||
await confirmKeyDeletion('common.operation.delete dat...cret-token-123456789')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteDatasetApiKey).toHaveBeenCalledWith({
|
||||
expect(apiMocks.deleteDataset).toHaveBeenCalledWith({
|
||||
params: { api_key_id: 'dataset-key-1' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('loads environment-scoped keys without requesting built-in app keys', async () => {
|
||||
apiMocks.environmentKeys = [
|
||||
{
|
||||
id: 'environment-key-1',
|
||||
token: 'env-existing-secret-key-abcdefghijklmnopqrst',
|
||||
type: 'api',
|
||||
created_at: 1,
|
||||
},
|
||||
]
|
||||
|
||||
await renderModal(environmentScope)
|
||||
|
||||
expect(await screen.findByText(/^env\.\.\./)).toBeInTheDocument()
|
||||
expect(apiMocks.listEnvironment).toHaveBeenCalledWith({
|
||||
params: {
|
||||
app_id: 'app-123',
|
||||
environment_id: 'staging',
|
||||
},
|
||||
})
|
||||
expect(apiMocks.listApp).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates an environment-scoped API key', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
await renderModal(environmentScope)
|
||||
|
||||
await user.click(screen.getByText('appApi.apiKeyModal.createNewSecretKey'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiMocks.createEnvironment).toHaveBeenCalledWith({
|
||||
params: {
|
||||
app_id: 'app-123',
|
||||
environment_id: 'staging',
|
||||
},
|
||||
})
|
||||
})
|
||||
expect(
|
||||
await screen.findByText('env-created-secret-key-abcdefghijklmnopqrst'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('deletes an environment-scoped API key', async () => {
|
||||
apiMocks.environmentKeys = [
|
||||
{
|
||||
id: 'environment-key-1',
|
||||
token: 'env-existing-secret-key-abcdefghijklmnopqrst',
|
||||
type: 'api',
|
||||
created_at: 1,
|
||||
},
|
||||
]
|
||||
await renderModal(environmentScope)
|
||||
|
||||
await screen.findByText(/^env\.\.\./)
|
||||
await confirmKeyDeletion('common.operation.delete env...abcdefghijklmnopqrst')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiMocks.deleteEnvironment).toHaveBeenCalledWith({
|
||||
params: {
|
||||
api_key_id: 'environment-key-1',
|
||||
app_id: 'app-123',
|
||||
environment_id: 'staging',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('disables creation when the caller cannot manage keys', async () => {
|
||||
await renderModal(datasetScope, { canManage: false })
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: 'appApi.apiKeyModal.createNewSecretKey',
|
||||
}),
|
||||
).toBeDisabled()
|
||||
})
|
||||
|
||||
it('exposes an accessible close button', async () => {
|
||||
const { onClose } = await renderModal(datasetScope)
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.close' }))
|
||||
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -39,8 +39,8 @@ const SecretKeyButton = ({
|
||||
<SecretKeyModal
|
||||
isShow={isVisible}
|
||||
onClose={() => setIsVisible(false)}
|
||||
appId={appId}
|
||||
canManage={canManage}
|
||||
scope={appId ? { type: 'app', appId } : { type: 'dataset' }}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
'use client'
|
||||
|
||||
import { skipToken, useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { currentWorkspaceAtom } from '@/context/workspace-state'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { SecretKeyModalView } from './view'
|
||||
|
||||
export type SecretKeyScope =
|
||||
| { type: 'app'; appId: string }
|
||||
| { type: 'dataset' }
|
||||
| { type: 'environment'; appId: string; environmentId: string }
|
||||
|
||||
export type SecretKeyModalProps = {
|
||||
isShow: boolean
|
||||
canManage: boolean
|
||||
scope: SecretKeyScope
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
type SecretKeyControllerProps = Omit<SecretKeyModalProps, 'scope'> & {
|
||||
createDisabled: boolean
|
||||
}
|
||||
|
||||
export default function SecretKeyModal({
|
||||
isShow = false,
|
||||
canManage,
|
||||
scope,
|
||||
onClose,
|
||||
}: SecretKeyModalProps) {
|
||||
const currentWorkspace = useAtomValue(currentWorkspaceAtom)
|
||||
const controllerProps = {
|
||||
canManage,
|
||||
createDisabled: !currentWorkspace.id || !canManage,
|
||||
isShow,
|
||||
onClose,
|
||||
}
|
||||
|
||||
switch (scope.type) {
|
||||
case 'app':
|
||||
return <AppSecretKeyController {...controllerProps} appId={scope.appId} />
|
||||
case 'dataset':
|
||||
return <DatasetSecretKeyController {...controllerProps} />
|
||||
case 'environment':
|
||||
return (
|
||||
<EnvironmentSecretKeyController
|
||||
{...controllerProps}
|
||||
appId={scope.appId}
|
||||
environmentId={scope.environmentId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function AppSecretKeyController({
|
||||
appId,
|
||||
...viewProps
|
||||
}: SecretKeyControllerProps & { appId: string }) {
|
||||
const apiKeysQuery = useQuery(
|
||||
consoleQuery.apps.byResourceId.apiKeys.get.queryOptions({
|
||||
input: viewProps.isShow ? { params: { resource_id: appId } } : skipToken,
|
||||
}),
|
||||
)
|
||||
const createMutation = useMutation(consoleQuery.apps.byResourceId.apiKeys.post.mutationOptions())
|
||||
const deleteMutation = useMutation(
|
||||
consoleQuery.apps.byResourceId.apiKeys.byApiKeyId.delete.mutationOptions(),
|
||||
)
|
||||
|
||||
return (
|
||||
<SecretKeyModalView
|
||||
{...viewProps}
|
||||
apiKeys={apiKeysQuery.data?.data}
|
||||
isCreating={createMutation.isPending}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
isLoading={apiKeysQuery.isLoading}
|
||||
onCreate={(onSuccess) =>
|
||||
createMutation.mutate({ params: { resource_id: appId } }, { onSuccess })
|
||||
}
|
||||
onDelete={(keyId, onSuccess) =>
|
||||
deleteMutation.mutate({ params: { resource_id: appId, api_key_id: keyId } }, { onSuccess })
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DatasetSecretKeyController(viewProps: SecretKeyControllerProps) {
|
||||
const apiKeysQuery = useQuery({
|
||||
...consoleQuery.datasets.apiKeys.get.queryOptions(),
|
||||
enabled: viewProps.isShow,
|
||||
})
|
||||
const createMutation = useMutation(consoleQuery.datasets.apiKeys.post.mutationOptions())
|
||||
const deleteMutation = useMutation(
|
||||
consoleQuery.datasets.apiKeys.byApiKeyId.delete.mutationOptions(),
|
||||
)
|
||||
|
||||
return (
|
||||
<SecretKeyModalView
|
||||
{...viewProps}
|
||||
apiKeys={apiKeysQuery.data?.data}
|
||||
isCreating={createMutation.isPending}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
isLoading={apiKeysQuery.isLoading}
|
||||
onCreate={(onSuccess) => createMutation.mutate(undefined, { onSuccess })}
|
||||
onDelete={(keyId, onSuccess) =>
|
||||
deleteMutation.mutate({ params: { api_key_id: keyId } }, { onSuccess })
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EnvironmentSecretKeyController({
|
||||
appId,
|
||||
environmentId,
|
||||
...viewProps
|
||||
}: SecretKeyControllerProps & { appId: string; environmentId: string }) {
|
||||
const params = {
|
||||
app_id: appId,
|
||||
environment_id: environmentId,
|
||||
}
|
||||
const apiKeysQuery = useQuery(
|
||||
consoleQuery.enterprise.appDeploy.accessService.listEnvironmentApiKeys.queryOptions({
|
||||
input: { params },
|
||||
enabled: viewProps.isShow,
|
||||
}),
|
||||
)
|
||||
const createMutation = useMutation(
|
||||
consoleQuery.enterprise.appDeploy.accessService.createEnvironmentApiKey.mutationOptions(),
|
||||
)
|
||||
const deleteMutation = useMutation(
|
||||
consoleQuery.enterprise.appDeploy.accessService.deleteEnvironmentApiKey.mutationOptions(),
|
||||
)
|
||||
|
||||
return (
|
||||
<SecretKeyModalView
|
||||
{...viewProps}
|
||||
apiKeys={apiKeysQuery.data?.data}
|
||||
isCreating={createMutation.isPending}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
isLoading={apiKeysQuery.isLoading}
|
||||
onCreate={(onSuccess) => createMutation.mutate({ params }, { onSuccess })}
|
||||
onDelete={(keyId, onSuccess) =>
|
||||
deleteMutation.mutate(
|
||||
{
|
||||
params: {
|
||||
...params,
|
||||
api_key_id: keyId,
|
||||
},
|
||||
},
|
||||
{ onSuccess },
|
||||
)
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+64
-115
@@ -1,5 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import type { ApiKeyItem } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { EnvironmentApiKey } from '@dify/contracts/enterprise-app-deploy/types.gen'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
@@ -12,120 +14,68 @@ import {
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { skipToken, useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import CopyFeedback from '@/app/components/base/copy-feedback'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { currentWorkspaceAtom } from '@/context/workspace-state'
|
||||
import useTimestamp from '@/hooks/use-timestamp'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import SecretKeyGenerateModal from './secret-key-generate'
|
||||
import s from './style.module.css'
|
||||
import SecretKeyGenerateModal from '../secret-key-generate'
|
||||
import s from '../style.module.css'
|
||||
|
||||
type ISecretKeyModalProps = {
|
||||
isShow: boolean
|
||||
appId?: string
|
||||
type SecretKeyItem = ApiKeyItem | EnvironmentApiKey
|
||||
type CreatedSecretKey = Pick<ApiKeyItem, 'token'>
|
||||
|
||||
export type SecretKeyModalViewProps = {
|
||||
apiKeys?: SecretKeyItem[]
|
||||
canManage: boolean
|
||||
createDisabled: boolean
|
||||
isCreating: boolean
|
||||
isDeleting: boolean
|
||||
isLoading: boolean
|
||||
isShow: boolean
|
||||
onClose: () => void
|
||||
onCreate: (onSuccess: (createdKey: CreatedSecretKey) => void) => void
|
||||
onDelete: (keyId: string, onSuccess: () => void) => void
|
||||
}
|
||||
|
||||
const SecretKeyModal = ({ isShow = false, appId, canManage, onClose }: ISecretKeyModalProps) => {
|
||||
export function SecretKeyModalView({
|
||||
apiKeys,
|
||||
canManage,
|
||||
createDisabled,
|
||||
isCreating,
|
||||
isDeleting,
|
||||
isLoading,
|
||||
isShow,
|
||||
onClose,
|
||||
onCreate,
|
||||
onDelete,
|
||||
}: SecretKeyModalViewProps) {
|
||||
const { t } = useTranslation()
|
||||
const { formatTime } = useTimestamp()
|
||||
const currentWorkspace = useAtomValue(currentWorkspaceAtom)
|
||||
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [newKey, setNewKey] = useState<Pick<ApiKeyItem, 'token'> | undefined>(undefined)
|
||||
const createAppApiKey = useMutation(consoleQuery.apps.byResourceId.apiKeys.post.mutationOptions())
|
||||
const deleteAppApiKey = useMutation(
|
||||
consoleQuery.apps.byResourceId.apiKeys.byApiKeyId.delete.mutationOptions(),
|
||||
)
|
||||
const createDatasetApiKey = useMutation(consoleQuery.datasets.apiKeys.post.mutationOptions())
|
||||
const deleteDatasetApiKey = useMutation(
|
||||
consoleQuery.datasets.apiKeys.byApiKeyId.delete.mutationOptions(),
|
||||
)
|
||||
const { data: appApiKeys, isLoading: isAppApiKeysLoading } = useQuery(
|
||||
consoleQuery.apps.byResourceId.apiKeys.get.queryOptions({
|
||||
input: appId && isShow ? { params: { resource_id: appId } } : skipToken,
|
||||
}),
|
||||
)
|
||||
const { data: datasetApiKeys, isLoading: isDatasetApiKeysLoading } = useQuery({
|
||||
...consoleQuery.datasets.apiKeys.get.queryOptions(),
|
||||
enabled: !appId && isShow,
|
||||
})
|
||||
const apiKeysList = appId ? appApiKeys : datasetApiKeys
|
||||
const isApiKeysLoading = appId ? isAppApiKeysLoading : isDatasetApiKeysLoading
|
||||
const [deleteKeyId, setDeleteKeyId] = useState<string>()
|
||||
const [newKey, setNewKey] = useState<CreatedSecretKey>()
|
||||
|
||||
const [delKeyID, setDelKeyId] = useState('')
|
||||
|
||||
const onDel = async () => {
|
||||
setShowConfirmDelete(false)
|
||||
if (!canManage) return
|
||||
if (!delKeyID) return
|
||||
|
||||
try {
|
||||
if (appId) {
|
||||
deleteAppApiKey.mutate({
|
||||
params: { resource_id: appId, api_key_id: delKeyID },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
deleteDatasetApiKey.mutate({
|
||||
params: { api_key_id: delKeyID },
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
const handleClose = () => {
|
||||
setDeleteKeyId(undefined)
|
||||
setNewKey(undefined)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const onCreate = async () => {
|
||||
if (!currentWorkspace.id || !canManage) return
|
||||
const handleCreate = () => {
|
||||
if (createDisabled || isCreating) return
|
||||
onCreate(setNewKey)
|
||||
}
|
||||
|
||||
try {
|
||||
if (appId) {
|
||||
createAppApiKey.mutate(
|
||||
{ params: { resource_id: appId } },
|
||||
{
|
||||
onSuccess: (apiKey) => {
|
||||
setIsVisible(true)
|
||||
setNewKey(apiKey)
|
||||
},
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
createDatasetApiKey.mutate(undefined, {
|
||||
onSuccess: (apiKey) => {
|
||||
setIsVisible(true)
|
||||
setNewKey(apiKey)
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
const handleDelete = () => {
|
||||
if (!deleteKeyId || isDeleting) return
|
||||
onDelete(deleteKeyId, () => setDeleteKeyId(undefined))
|
||||
}
|
||||
|
||||
const generateToken = (token: string) => {
|
||||
return `${token.slice(0, 3)}...${token.slice(-20)}`
|
||||
}
|
||||
|
||||
const handleDeleteConfirmOpenChange = (open: boolean) => {
|
||||
if (open) return
|
||||
|
||||
setDelKeyId('')
|
||||
setShowConfirmDelete(false)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setIsVisible(false)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
@@ -141,7 +91,7 @@ const SecretKeyModal = ({ isShow = false, appId, canManage, onClose }: ISecretKe
|
||||
)}
|
||||
>
|
||||
<DialogTitle className="title-2xl-semi-bold text-text-primary">
|
||||
{`${t(($) => $['apiKeyModal.apiSecretKey'], { ns: 'appApi' })}`}
|
||||
{t(($) => $['apiKeyModal.apiSecretKey'], { ns: 'appApi' })}
|
||||
</DialogTitle>
|
||||
|
||||
<div className="-mt-6 -mr-2 mb-4 flex justify-end">
|
||||
@@ -160,12 +110,12 @@ const SecretKeyModal = ({ isShow = false, appId, canManage, onClose }: ISecretKe
|
||||
<p className="mt-1 shrink-0 text-[13px] leading-5 font-normal text-text-tertiary">
|
||||
{t(($) => $['apiKeyModal.apiSecretKeyTips'], { ns: 'appApi' })}
|
||||
</p>
|
||||
{isApiKeysLoading && (
|
||||
{isLoading && (
|
||||
<div className="mt-4">
|
||||
<Loading />
|
||||
</div>
|
||||
)}
|
||||
{!!apiKeysList?.data?.length && (
|
||||
{!!apiKeys?.length && (
|
||||
<div className="mt-4 flex grow flex-col overflow-hidden">
|
||||
<div className="flex h-9 shrink-0 items-center border-b border-divider-regular text-xs font-semibold text-text-tertiary">
|
||||
<div className="w-64 shrink-0 px-3">
|
||||
@@ -177,10 +127,10 @@ const SecretKeyModal = ({ isShow = false, appId, canManage, onClose }: ISecretKe
|
||||
<div className="w-50 shrink-0 px-3">
|
||||
{t(($) => $['apiKeyModal.lastUsed'], { ns: 'appApi' })}
|
||||
</div>
|
||||
<div className="grow px-3"></div>
|
||||
<div className="grow px-3" />
|
||||
</div>
|
||||
<div className="grow overflow-auto">
|
||||
{apiKeysList.data.map((api) => (
|
||||
{apiKeys.map((api) => (
|
||||
<div
|
||||
className="flex h-9 items-center border-b border-divider-regular text-sm font-normal text-text-secondary"
|
||||
key={api.id}
|
||||
@@ -207,10 +157,7 @@ const SecretKeyModal = ({ isShow = false, appId, canManage, onClose }: ISecretKe
|
||||
{canManage && (
|
||||
<ActionButton
|
||||
aria-label={`${t(($) => $['operation.delete'], { ns: 'common' })} ${generateToken(api.token)}`}
|
||||
onClick={() => {
|
||||
setDelKeyId(api.id)
|
||||
setShowConfirmDelete(true)
|
||||
}}
|
||||
onClick={() => setDeleteKeyId(api.id)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-delete-bin-line size-4" />
|
||||
</ActionButton>
|
||||
@@ -224,8 +171,9 @@ const SecretKeyModal = ({ isShow = false, appId, canManage, onClose }: ISecretKe
|
||||
<div className="flex">
|
||||
<Button
|
||||
className={`mt-4 flex shrink-0 ${s.autoWidth}`}
|
||||
onClick={onCreate}
|
||||
disabled={!currentWorkspace.id || !canManage}
|
||||
onClick={handleCreate}
|
||||
disabled={createDisabled || isCreating}
|
||||
loading={isCreating}
|
||||
>
|
||||
<span className="i-heroicons-plus-20-solid flex size-4 shrink-0" />
|
||||
<div className="text-xs font-medium text-text-secondary">
|
||||
@@ -233,7 +181,12 @@ const SecretKeyModal = ({ isShow = false, appId, canManage, onClose }: ISecretKe
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
<AlertDialog open={showConfirmDelete} onOpenChange={handleDeleteConfirmOpenChange}>
|
||||
<AlertDialog
|
||||
open={deleteKeyId !== undefined}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !isDeleting) setDeleteKeyId(undefined)
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
|
||||
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
|
||||
@@ -244,10 +197,10 @@ const SecretKeyModal = ({ isShow = false, appId, canManage, onClose }: ISecretKe
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton>
|
||||
<AlertDialogCancelButton disabled={isDeleting}>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton onClick={onDel}>
|
||||
<AlertDialogConfirmButton loading={isDeleting} onClick={handleDelete}>
|
||||
{t(($) => $['operation.confirm'], { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
@@ -255,16 +208,12 @@ const SecretKeyModal = ({ isShow = false, appId, canManage, onClose }: ISecretKe
|
||||
</AlertDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{isShow && (
|
||||
<SecretKeyGenerateModal
|
||||
className="shrink-0"
|
||||
isShow={isVisible}
|
||||
onClose={() => setIsVisible(false)}
|
||||
newKey={newKey}
|
||||
/>
|
||||
)}
|
||||
<SecretKeyGenerateModal
|
||||
className="shrink-0"
|
||||
isShow={isShow && newKey !== undefined}
|
||||
onClose={() => setNewKey(undefined)}
|
||||
newKey={newKey}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SecretKeyModal
|
||||
Reference in new issue
Block a user