From 2fa4ed63ca96d865dfbae4ecd8de5f396926378c Mon Sep 17 00:00:00 2001 From: RemiBonnet Date: Tue, 15 Sep 2026 18:30:23 +0200 Subject: [PATCH 1/7] feat(agent-settings): manage LLM provider tokens --- .../domains-organizations-data-access.spec.ts | 66 ++++++- .../lib/domains-organizations-data-access.ts | 35 ++++ .../organizations/feature/src/index.ts | 4 + .../use-create-llm-provider.ts | 17 ++ .../use-delete-llm-provider.ts | 17 ++ .../use-edit-llm-provider.ts | 17 ++ .../use-llm-providers/use-llm-providers.ts | 14 ++ .../llm-provider-create-edit-modal.spec.tsx | 93 ++++++++++ .../llm-provider-create-edit-modal.tsx | 170 +++++++++++++++++ .../settings-agent-personalization.spec.tsx | 65 ++++++- .../settings-agent-personalization.tsx | 175 +++++++++++++++++- package.json | 2 +- yarn.lock | 10 +- 13 files changed, 670 insertions(+), 15 deletions(-) create mode 100644 libs/domains/organizations/feature/src/lib/hooks/use-create-llm-provider/use-create-llm-provider.ts create mode 100644 libs/domains/organizations/feature/src/lib/hooks/use-delete-llm-provider/use-delete-llm-provider.ts create mode 100644 libs/domains/organizations/feature/src/lib/hooks/use-edit-llm-provider/use-edit-llm-provider.ts create mode 100644 libs/domains/organizations/feature/src/lib/hooks/use-llm-providers/use-llm-providers.ts create mode 100644 libs/domains/organizations/feature/src/lib/llm-provider-create-edit-modal/llm-provider-create-edit-modal.spec.tsx create mode 100644 libs/domains/organizations/feature/src/lib/llm-provider-create-edit-modal/llm-provider-create-edit-modal.tsx diff --git a/libs/domains/organizations/data-access/src/lib/domains-organizations-data-access.spec.ts b/libs/domains/organizations/data-access/src/lib/domains-organizations-data-access.spec.ts index 5806761a976..aa8b642fcab 100644 --- a/libs/domains/organizations/data-access/src/lib/domains-organizations-data-access.spec.ts +++ b/libs/domains/organizations/data-access/src/lib/domains-organizations-data-access.spec.ts @@ -1,4 +1,11 @@ -import { MCPServersApi, OrganizationMainCallsApi, OrganizationOnboardingStatusEnum } from 'qovery-typescript-axios' +import { + LLMProvidersApi, + LlmProviderScope, + LlmProviderType, + MCPServersApi, + OrganizationMainCallsApi, + OrganizationOnboardingStatusEnum, +} from 'qovery-typescript-axios' import { mutations, organizations } from './domains-organizations-data-access' describe('organizations.onboarding', () => { @@ -104,3 +111,60 @@ describe('MCP server mutations', () => { expect(MCPServersApi.prototype.deleteMcpServer).toHaveBeenCalledWith('mcp-1') }) }) + +describe('organizations.llmProviders', () => { + it('should return organization LLM providers', async () => { + const mockData = [{ id: 'provider-1', name: 'Claude' }] + jest + .spyOn(LLMProvidersApi.prototype, 'listLlmProviders') + .mockResolvedValue({ data: { results: mockData } } as never) + + const query = organizations.llmProviders({ organizationId: 'org-1' }) + const result = await query.queryFn({} as never) + + expect(LLMProvidersApi.prototype.listLlmProviders).toHaveBeenCalledWith('org-1') + expect(result).toEqual(mockData) + expect(query.queryKey).toContain('org-1') + }) +}) + +describe('LLM provider mutations', () => { + const llmProviderRequest = { + name: 'Claude', + type: LlmProviderType.CLAUDE, + credential: 'secret', + scope: LlmProviderScope.USER, + } + + it('should create an LLM provider', async () => { + const mockData = { id: 'provider-1', ...llmProviderRequest } + jest.spyOn(LLMProvidersApi.prototype, 'createLlmProvider').mockResolvedValue({ data: mockData } as never) + + const result = await mutations.createLlmProvider({ organizationId: 'org-1', llmProviderRequest }) + + expect(LLMProvidersApi.prototype.createLlmProvider).toHaveBeenCalledWith('org-1', llmProviderRequest) + expect(result).toEqual(mockData) + }) + + it('should edit an LLM provider', async () => { + const mockData = { id: 'provider-1', ...llmProviderRequest } + jest.spyOn(LLMProvidersApi.prototype, 'editLlmProvider').mockResolvedValue({ data: mockData } as never) + + const result = await mutations.editLlmProvider({ + organizationId: 'org-1', + llmProviderId: 'provider-1', + llmProviderRequest, + }) + + expect(LLMProvidersApi.prototype.editLlmProvider).toHaveBeenCalledWith('provider-1', llmProviderRequest) + expect(result).toEqual(mockData) + }) + + it('should delete an LLM provider', async () => { + jest.spyOn(LLMProvidersApi.prototype, 'deleteLlmProvider').mockResolvedValue({ data: undefined } as never) + + await mutations.deleteLlmProvider({ organizationId: 'org-1', llmProviderId: 'provider-1' }) + + expect(LLMProvidersApi.prototype.deleteLlmProvider).toHaveBeenCalledWith('provider-1') + }) +}) diff --git a/libs/domains/organizations/data-access/src/lib/domains-organizations-data-access.ts b/libs/domains/organizations/data-access/src/lib/domains-organizations-data-access.ts index 7cce133a153..4ff5b72b9b1 100644 --- a/libs/domains/organizations/data-access/src/lib/domains-organizations-data-access.ts +++ b/libs/domains/organizations/data-access/src/lib/domains-organizations-data-access.ts @@ -14,6 +14,8 @@ import { HelmRepositoriesApi, type HelmRepositoryRequest, type InviteMemberRequest, + LLMProvidersApi, + type LlmProviderRequest, MCPServersApi, type McpServerRequest, type MemberRoleUpdateRequest, @@ -62,6 +64,7 @@ const billingApi = new BillingApi() const customRolesApi = new OrganizationCustomRoleApi() const membersApi = new MembersApi() const mcpServersApi = new MCPServersApi() +const llmProvidersApi = new LLMProvidersApi() const githubAppApi = new GithubAppApi() const argoCdApi = new ArgoCDApi() @@ -122,6 +125,13 @@ export const organizations = createQueryKeys('organizations', { return response.data.results }, }), + llmProviders: ({ organizationId }: { organizationId: string }) => ({ + queryKey: [organizationId], + async queryFn() { + const response = await llmProvidersApi.listLlmProviders(organizationId) + return response.data.results + }, + }), containerRegistry: ({ organizationId, containerRegistryId, @@ -674,6 +684,31 @@ export const mutations = { const response = await mcpServersApi.deleteMcpServer(mcpServerId) return response.data }, + async createLlmProvider({ + organizationId, + llmProviderRequest, + }: { + organizationId: string + llmProviderRequest: LlmProviderRequest + }) { + const response = await llmProvidersApi.createLlmProvider(organizationId, llmProviderRequest) + return response.data + }, + async editLlmProvider({ + llmProviderId, + llmProviderRequest, + }: { + organizationId: string + llmProviderId: string + llmProviderRequest: LlmProviderRequest + }) { + const response = await llmProvidersApi.editLlmProvider(llmProviderId, llmProviderRequest) + return response.data + }, + async deleteLlmProvider({ llmProviderId }: { organizationId: string; llmProviderId: string }) { + const response = await llmProvidersApi.deleteLlmProvider(llmProviderId) + return response.data + }, async deleteGitToken({ organizationId, gitTokenId }: { organizationId: string; gitTokenId: string }) { const response = await organizationApi.deleteGitToken(organizationId, gitTokenId) return response.data diff --git a/libs/domains/organizations/feature/src/index.ts b/libs/domains/organizations/feature/src/index.ts index 651d5151a5d..cb06a5b810e 100644 --- a/libs/domains/organizations/feature/src/index.ts +++ b/libs/domains/organizations/feature/src/index.ts @@ -102,6 +102,10 @@ export * from './lib/hooks/use-create-mcp-server/use-create-mcp-server' export * from './lib/hooks/use-create-qovery-mcp-server/use-create-qovery-mcp-server' export * from './lib/hooks/use-edit-mcp-server/use-edit-mcp-server' export * from './lib/hooks/use-delete-mcp-server/use-delete-mcp-server' +export * from './lib/hooks/use-llm-providers/use-llm-providers' +export * from './lib/hooks/use-create-llm-provider/use-create-llm-provider' +export * from './lib/hooks/use-edit-llm-provider/use-edit-llm-provider' +export * from './lib/hooks/use-delete-llm-provider/use-delete-llm-provider' export * from './lib/invoice-banner/invoice-banner' export * from './lib/settings-general/settings-general' export * from './lib/settings-labels-annotations/settings-labels-annotations' diff --git a/libs/domains/organizations/feature/src/lib/hooks/use-create-llm-provider/use-create-llm-provider.ts b/libs/domains/organizations/feature/src/lib/hooks/use-create-llm-provider/use-create-llm-provider.ts new file mode 100644 index 00000000000..9b0114bbdeb --- /dev/null +++ b/libs/domains/organizations/feature/src/lib/hooks/use-create-llm-provider/use-create-llm-provider.ts @@ -0,0 +1,17 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { mutations } from '@qovery/domains/organizations/data-access' +import { queries } from '@qovery/state/util-queries' + +export function useCreateLlmProvider() { + const queryClient = useQueryClient() + + return useMutation(mutations.createLlmProvider, { + onSuccess(_, { organizationId }) { + queryClient.invalidateQueries({ queryKey: queries.organizations.llmProviders({ organizationId }).queryKey }) + }, + meta: { + notifyOnSuccess: { title: 'Your token has been created' }, + notifyOnError: true, + }, + }) +} diff --git a/libs/domains/organizations/feature/src/lib/hooks/use-delete-llm-provider/use-delete-llm-provider.ts b/libs/domains/organizations/feature/src/lib/hooks/use-delete-llm-provider/use-delete-llm-provider.ts new file mode 100644 index 00000000000..4f8ae76a8c3 --- /dev/null +++ b/libs/domains/organizations/feature/src/lib/hooks/use-delete-llm-provider/use-delete-llm-provider.ts @@ -0,0 +1,17 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { mutations } from '@qovery/domains/organizations/data-access' +import { queries } from '@qovery/state/util-queries' + +export function useDeleteLlmProvider() { + const queryClient = useQueryClient() + + return useMutation(mutations.deleteLlmProvider, { + onSuccess(_, { organizationId }) { + queryClient.invalidateQueries({ queryKey: queries.organizations.llmProviders({ organizationId }).queryKey }) + }, + meta: { + notifyOnSuccess: { title: 'Your token has been deleted' }, + notifyOnError: true, + }, + }) +} diff --git a/libs/domains/organizations/feature/src/lib/hooks/use-edit-llm-provider/use-edit-llm-provider.ts b/libs/domains/organizations/feature/src/lib/hooks/use-edit-llm-provider/use-edit-llm-provider.ts new file mode 100644 index 00000000000..e2ada667af4 --- /dev/null +++ b/libs/domains/organizations/feature/src/lib/hooks/use-edit-llm-provider/use-edit-llm-provider.ts @@ -0,0 +1,17 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { mutations } from '@qovery/domains/organizations/data-access' +import { queries } from '@qovery/state/util-queries' + +export function useEditLlmProvider() { + const queryClient = useQueryClient() + + return useMutation(mutations.editLlmProvider, { + onSuccess(_, { organizationId }) { + queryClient.invalidateQueries({ queryKey: queries.organizations.llmProviders({ organizationId }).queryKey }) + }, + meta: { + notifyOnSuccess: { title: 'Your token has been updated' }, + notifyOnError: true, + }, + }) +} diff --git a/libs/domains/organizations/feature/src/lib/hooks/use-llm-providers/use-llm-providers.ts b/libs/domains/organizations/feature/src/lib/hooks/use-llm-providers/use-llm-providers.ts new file mode 100644 index 00000000000..28e15ac8ff9 --- /dev/null +++ b/libs/domains/organizations/feature/src/lib/hooks/use-llm-providers/use-llm-providers.ts @@ -0,0 +1,14 @@ +import { useQuery } from '@tanstack/react-query' +import { queries } from '@qovery/state/util-queries' + +export interface UseLlmProvidersProps { + organizationId: string + suspense?: boolean +} + +export function useLlmProviders({ organizationId, suspense = false }: UseLlmProvidersProps) { + return useQuery({ + ...queries.organizations.llmProviders({ organizationId }), + suspense, + }) +} diff --git a/libs/domains/organizations/feature/src/lib/llm-provider-create-edit-modal/llm-provider-create-edit-modal.spec.tsx b/libs/domains/organizations/feature/src/lib/llm-provider-create-edit-modal/llm-provider-create-edit-modal.spec.tsx new file mode 100644 index 00000000000..0e053f40858 --- /dev/null +++ b/libs/domains/organizations/feature/src/lib/llm-provider-create-edit-modal/llm-provider-create-edit-modal.spec.tsx @@ -0,0 +1,93 @@ +import { LlmProviderScope, LlmProviderType } from 'qovery-typescript-axios' +import { renderWithProviders, screen, waitFor } from '@qovery/shared/util-tests' +import * as useCreateLlmProviderHook from '../hooks/use-create-llm-provider/use-create-llm-provider' +import * as useEditLlmProviderHook from '../hooks/use-edit-llm-provider/use-edit-llm-provider' +import { LlmProviderCreateEditModal } from './llm-provider-create-edit-modal' + +const useCreateLlmProviderMock = jest.spyOn(useCreateLlmProviderHook, 'useCreateLlmProvider') as jest.Mock +const useEditLlmProviderMock = jest.spyOn(useEditLlmProviderHook, 'useEditLlmProvider') as jest.Mock +const createLlmProvider = jest.fn() +const editLlmProvider = jest.fn() + +jest.mock('@tanstack/react-router', () => ({ + ...jest.requireActual('@tanstack/react-router'), + useParams: () => ({ organizationId: 'org-1' }), +})) + +describe('LlmProviderCreateEditModal', () => { + beforeEach(() => { + jest.clearAllMocks() + createLlmProvider.mockResolvedValue({ id: 'provider-1' }) + editLlmProvider.mockResolvedValue({ id: 'provider-1' }) + useCreateLlmProviderMock.mockReturnValue({ mutateAsync: createLlmProvider, isLoading: false }) + useEditLlmProviderMock.mockReturnValue({ mutateAsync: editLlmProvider, isLoading: false }) + }) + + it('should create a personal Claude token', async () => { + const onClose = jest.fn() + const { userEvent } = renderWithProviders() + + await userEvent.type(screen.getByLabelText('Name'), ' My Claude ') + await userEvent.type(screen.getByLabelText('Token'), ' sk-ant-secret ') + await userEvent.click(screen.getByRole('button', { name: 'Add token' })) + + await waitFor(() => + expect(createLlmProvider).toHaveBeenCalledWith({ + organizationId: 'org-1', + llmProviderRequest: { + name: 'My Claude', + description: undefined, + type: LlmProviderType.CLAUDE, + credential: 'sk-ant-secret', + scope: LlmProviderScope.USER, + }, + }) + ) + expect(onClose).toHaveBeenCalledWith({ id: 'provider-1' }) + }) + + it('should keep the stored token when editing with a blank value', async () => { + const { userEvent } = renderWithProviders( + + ) + + expect(screen.queryByLabelText('Scope')).not.toBeInTheDocument() + expect(screen.getByText('Leave blank to keep the current token.')).toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: 'Save token' })) + + await waitFor(() => + expect(editLlmProvider).toHaveBeenCalledWith({ + organizationId: 'org-1', + llmProviderId: 'provider-1', + llmProviderRequest: { + name: 'Claude', + description: undefined, + type: LlmProviderType.CLAUDE, + credential: undefined, + scope: undefined, + }, + }) + ) + }) + + it('should expose Bedrock as unavailable', async () => { + const { userEvent } = renderWithProviders() + + await userEvent.click(screen.getByLabelText('Provider')) + + expect(screen.getByRole('option', { name: /AWS Bedrock/ })).toHaveAttribute('aria-disabled', 'true') + expect(screen.getByText('Coming soon')).toBeInTheDocument() + }) +}) diff --git a/libs/domains/organizations/feature/src/lib/llm-provider-create-edit-modal/llm-provider-create-edit-modal.tsx b/libs/domains/organizations/feature/src/lib/llm-provider-create-edit-modal/llm-provider-create-edit-modal.tsx new file mode 100644 index 00000000000..e33480126a7 --- /dev/null +++ b/libs/domains/organizations/feature/src/lib/llm-provider-create-edit-modal/llm-provider-create-edit-modal.tsx @@ -0,0 +1,170 @@ +import { useParams } from '@tanstack/react-router' +import { + type LlmProviderRequest, + type LlmProviderResponse, + LlmProviderScope, + LlmProviderType, +} from 'qovery-typescript-axios' +import { Controller, FormProvider, useForm } from 'react-hook-form' +import { InputSelect, InputText, InputTextArea, ModalCrud, useModal } from '@qovery/shared/ui' +import { useCreateLlmProvider } from '../hooks/use-create-llm-provider/use-create-llm-provider' +import { useEditLlmProvider } from '../hooks/use-edit-llm-provider/use-edit-llm-provider' + +interface LlmProviderFormValues { + name: string + description: string + type: LlmProviderType + credential: string + scope: LlmProviderScope +} + +export interface LlmProviderCreateEditModalProps { + onClose: (response?: LlmProviderResponse) => void + llmProvider?: LlmProviderResponse +} + +const PROVIDER_OPTIONS = [ + { label: 'Anthropic Claude', value: LlmProviderType.CLAUDE }, + { + label: 'AWS Bedrock', + value: LlmProviderType.BEDROCK, + description: 'Coming soon', + isDisabled: true, + }, +] + +const SCOPE_OPTIONS = [ + { label: 'Personal', value: LlmProviderScope.USER }, + { label: 'Organization', value: LlmProviderScope.ORGANIZATION }, +] + +export function LlmProviderCreateEditModal({ onClose, llmProvider }: LlmProviderCreateEditModalProps) { + const { organizationId = '' } = useParams({ strict: false }) + const isEdit = llmProvider !== undefined + const { enableAlertClickOutside } = useModal() + const methods = useForm({ + mode: 'onChange', + defaultValues: { + name: llmProvider?.name ?? '', + description: llmProvider?.description ?? '', + type: llmProvider?.type ?? LlmProviderType.CLAUDE, + credential: '', + scope: llmProvider?.scope ?? LlmProviderScope.USER, + }, + }) + methods.watch(() => enableAlertClickOutside(methods.formState.isDirty)) + + const { mutateAsync: createLlmProvider, isLoading: isCreating } = useCreateLlmProvider() + const { mutateAsync: editLlmProvider, isLoading: isEditing } = useEditLlmProvider() + + const onSubmit = methods.handleSubmit(async (data) => { + const credential = data.credential.trim() + const llmProviderRequest: LlmProviderRequest = { + name: data.name.trim(), + description: data.description.trim() || undefined, + type: data.type, + credential: credential || undefined, + scope: isEdit ? undefined : data.scope, + } + + try { + const response = isEdit + ? await editLlmProvider({ organizationId, llmProviderId: llmProvider.id, llmProviderRequest }) + : await createLlmProvider({ organizationId, llmProviderRequest }) + onClose(response) + } catch (error) { + console.error(error) + } + }) + + return ( + + +
+ {!isEdit ? ( + ( + + )} + /> + ) : null} + Boolean(value.trim()) || 'Please enter a token name.', + }} + render={({ field, fieldState: { error } }) => ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> +
+
+
+ ) +} diff --git a/libs/domains/organizations/feature/src/lib/settings-agent-personalization/settings-agent-personalization.spec.tsx b/libs/domains/organizations/feature/src/lib/settings-agent-personalization/settings-agent-personalization.spec.tsx index 1dbc7758f3a..a2d20fb5696 100644 --- a/libs/domains/organizations/feature/src/lib/settings-agent-personalization/settings-agent-personalization.spec.tsx +++ b/libs/domains/organizations/feature/src/lib/settings-agent-personalization/settings-agent-personalization.spec.tsx @@ -1,20 +1,31 @@ -import { type McpServerResponse, McpServerScope } from 'qovery-typescript-axios' +import { + type LlmProviderResponse, + LlmProviderScope, + LlmProviderType, + type McpServerResponse, + McpServerScope, +} from 'qovery-typescript-axios' import { type ReactElement } from 'react' import * as sharedUi from '@qovery/shared/ui' import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import * as useDeleteLlmProviderHook from '../hooks/use-delete-llm-provider/use-delete-llm-provider' import * as useDeleteMcpServerHook from '../hooks/use-delete-mcp-server/use-delete-mcp-server' +import * as useLlmProvidersHook from '../hooks/use-llm-providers/use-llm-providers' import * as useMcpServersHook from '../hooks/use-mcp-servers/use-mcp-servers' import { type McpServerCreateEditModalProps } from '../mcp-server-create-edit-modal/mcp-server-create-edit-modal' import { SettingsAgentPersonalization } from './settings-agent-personalization' const useMcpServersMock = jest.spyOn(useMcpServersHook, 'useMcpServers') as jest.Mock const useDeleteMcpServerMock = jest.spyOn(useDeleteMcpServerHook, 'useDeleteMcpServer') as jest.Mock +const useLlmProvidersMock = jest.spyOn(useLlmProvidersHook, 'useLlmProviders') as jest.Mock +const useDeleteLlmProviderMock = jest.spyOn(useDeleteLlmProviderHook, 'useDeleteLlmProvider') as jest.Mock const useModalMock = jest.spyOn(sharedUi, 'useModal') as jest.Mock const useModalConfirmationMock = jest.spyOn(sharedUi, 'useModalConfirmation') as jest.Mock const openModal = jest.fn() const closeModal = jest.fn() const openModalConfirmation = jest.fn() const deleteMcpServer = jest.fn() +const deleteLlmProvider = jest.fn() jest.mock('@tanstack/react-router', () => ({ ...jest.requireActual('@tanstack/react-router'), @@ -59,6 +70,30 @@ const mcpServers: McpServerResponse[] = [ }, ] +const llmProviders: LlmProviderResponse[] = [ + { + id: 'provider-claude', + name: 'Claude production', + description: '', + type: LlmProviderType.CLAUDE, + has_credential: true, + scope: LlmProviderScope.ORGANIZATION, + created_at: '2026-09-15T10:00:00Z', + updated_at: '2026-09-15T10:00:00Z', + }, + { + id: 'provider-personal', + name: 'My Claude', + description: '', + type: LlmProviderType.CLAUDE, + has_credential: false, + scope: LlmProviderScope.USER, + owner_name: 'Rémi Bonnet', + created_at: '2026-09-15T10:00:00Z', + updated_at: '2026-09-15T10:00:00Z', + }, +] + describe('SettingsAgentPersonalization', () => { beforeEach(() => { jest.useFakeTimers() @@ -66,6 +101,8 @@ describe('SettingsAgentPersonalization', () => { useModalMock.mockReturnValue({ openModal, closeModal }) useModalConfirmationMock.mockReturnValue({ openModalConfirmation }) useDeleteMcpServerMock.mockReturnValue({ mutateAsync: deleteMcpServer }) + useDeleteLlmProviderMock.mockReturnValue({ mutateAsync: deleteLlmProvider }) + useLlmProvidersMock.mockReturnValue({ data: [] }) }) afterEach(() => { @@ -83,6 +120,7 @@ describe('SettingsAgentPersonalization', () => { screen.queryByText('Personal MCPs belong to one member. Organization MCPs are shared with the organization.') ).not.toBeInTheDocument() expect(screen.getByText('No MCPs')).toBeInTheDocument() + expect(screen.getByText('No tokens')).toBeInTheDocument() }) it('should render MCPs alphabetically with their URL, details tooltip, and actions', async () => { @@ -133,6 +171,31 @@ describe('SettingsAgentPersonalization', () => { expect(openModal).toHaveBeenNthCalledWith(2, expect.objectContaining({ options: { fakeModal: true, width: 680 } })) }) + it('should render and manage provider tokens', async () => { + useMcpServersMock.mockReturnValue({ data: [] }) + useLlmProvidersMock.mockReturnValue({ data: llmProviders }) + const { userEvent } = renderWithProviders() + + expect(screen.getByText('Organization tokens')).toBeInTheDocument() + expect(screen.getByText('Personal tokens')).toBeInTheDocument() + expect(screen.getAllByText('Claude')).toHaveLength(2) + expect(screen.getByText('Configured')).toBeInTheDocument() + expect(screen.getByText('No token')).toBeInTheDocument() + + await userEvent.click(screen.getByRole('button', { name: 'Add token' })) + await userEvent.click(screen.getByRole('button', { name: 'Edit Claude production' })) + expect(openModal).toHaveBeenCalledTimes(2) + + await userEvent.click(screen.getByRole('button', { name: 'Delete Claude production' })) + const confirmation = openModalConfirmation.mock.calls[0][0] + await confirmation.action() + expect(confirmation).toEqual(expect.objectContaining({ title: 'Delete token', name: 'Claude production' })) + expect(deleteLlmProvider).toHaveBeenCalledWith({ + organizationId: 'org-1', + llmProviderId: 'provider-claude', + }) + }) + it('should confirm deletion with the connector name and organization scope', async () => { useMcpServersMock.mockReturnValue({ data: mcpServers }) const { userEvent } = renderWithProviders() diff --git a/libs/domains/organizations/feature/src/lib/settings-agent-personalization/settings-agent-personalization.tsx b/libs/domains/organizations/feature/src/lib/settings-agent-personalization/settings-agent-personalization.tsx index 1b4e3ce9f98..a65ffd978a6 100644 --- a/libs/domains/organizations/feature/src/lib/settings-agent-personalization/settings-agent-personalization.tsx +++ b/libs/domains/organizations/feature/src/lib/settings-agent-personalization/settings-agent-personalization.tsx @@ -1,8 +1,15 @@ import { useParams } from '@tanstack/react-router' -import { type McpServerResponse, McpServerScope } from 'qovery-typescript-axios' +import { + type LlmProviderResponse, + LlmProviderScope, + LlmProviderType, + type McpServerResponse, + McpServerScope, +} from 'qovery-typescript-axios' import { Suspense, useMemo } from 'react' import { SettingsHeading } from '@qovery/shared/console-shared' import { + Badge, BlockContent, Button, EmptyState, @@ -16,8 +23,11 @@ import { useModalConfirmation, } from '@qovery/shared/ui' import { useDocumentTitle } from '@qovery/shared/util-hooks' +import { useDeleteLlmProvider } from '../hooks/use-delete-llm-provider/use-delete-llm-provider' import { useDeleteMcpServer } from '../hooks/use-delete-mcp-server/use-delete-mcp-server' +import { useLlmProviders } from '../hooks/use-llm-providers/use-llm-providers' import { useMcpServers } from '../hooks/use-mcp-servers/use-mcp-servers' +import { LlmProviderCreateEditModal } from '../llm-provider-create-edit-modal/llm-provider-create-edit-modal' import { McpServerCreateEditModal } from '../mcp-server-create-edit-modal/mcp-server-create-edit-modal' interface McpServerRowProps { @@ -143,6 +153,141 @@ function McpServersSkeleton() { ) } +interface LlmProviderRowProps { + organizationId: string + llmProvider: LlmProviderResponse +} + +function LlmProviderRow({ organizationId, llmProvider }: LlmProviderRowProps) { + const { openModal, closeModal } = useModal() + const { openModalConfirmation } = useModalConfirmation() + const { mutateAsync: deleteLlmProvider } = useDeleteLlmProvider() + const owner = + llmProvider.scope === LlmProviderScope.USER ? `Owner: ${llmProvider.owner_name ?? 'You'}` : 'Organization' + + const onEdit = () => { + openModal({ + content: , + options: { fakeModal: true, width: 680 }, + }) + } + + const onDelete = () => { + openModalConfirmation({ + title: 'Delete token', + confirmationMethod: 'action', + name: llmProvider.name, + action: async () => { + try { + await deleteLlmProvider({ organizationId, llmProviderId: llmProvider.id }) + } catch (error) { + console.error(error) + } + }, + }) + } + + return ( +
  • +
    +
    + + + + {llmProvider.type === LlmProviderType.CLAUDE ? 'Claude' : 'Bedrock'} + {llmProvider.has_credential ? ( + Configured + ) : ( + No token + )} +
    +

    {owner}

    +
    +
    + + +
    +
  • + ) +} + +function LlmProvidersSkeleton() { + return ( + + {[0, 1].map((index) => ( +
    +
    + + +
    +
    + + +
    +
    + ))} +
    + ) +} + +function LlmProvidersList({ organizationId }: { organizationId: string }) { + const { data: llmProviders = [] } = useLlmProviders({ organizationId, suspense: true }) + const sortedLlmProviders = useMemo( + () => [...llmProviders].sort((first, second) => first.name.localeCompare(second.name)), + [llmProviders] + ) + + if (sortedLlmProviders.length === 0) { + return ( + + ) + } + + const personalProviders = sortedLlmProviders.filter(({ scope }) => scope === LlmProviderScope.USER) + const organizationProviders = sortedLlmProviders.filter(({ scope }) => scope === LlmProviderScope.ORGANIZATION) + const providerGroup = (title: string, providers: LlmProviderResponse[], emptyMessage: string) => ( + + {providers.length > 0 ? ( +
      + {providers.map((llmProvider) => ( + + ))} +
    + ) : ( +

    {emptyMessage}

    + )} +
    + ) + + return ( +
    + {providerGroup('Organization tokens', organizationProviders, 'No organization tokens.')} + {personalProviders.length > 0 ? providerGroup('Personal tokens', personalProviders, '') : null} +
    + ) +} + interface McpServersListProps { organizationId: string } @@ -194,7 +339,7 @@ export function SettingsAgentPersonalization() { const { organizationId = '' } = useParams({ strict: false }) const { openModal, closeModal } = useModal() - const onAdd = () => { + const onAddMcp = () => { openModal({ content: , options: { @@ -204,18 +349,34 @@ export function SettingsAgentPersonalization() { }) } + const onAddToken = () => { + openModal({ + content: , + options: { fakeModal: true, width: 680 }, + }) + } + return (
    - +
    + + +
    -
    +
    + }> + + }> diff --git a/package.json b/package.json index 1f86db38337..bb3c6d72dd5 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "mermaid": "11.6.0", "monaco-editor": "0.53.0", "posthog-js": "1.345.1", - "qovery-typescript-axios": "1.1.976", + "qovery-typescript-axios": "1.1.978", "react": "18.3.1", "react-country-flag": "3.0.2", "react-datepicker": "4.12.0", diff --git a/yarn.lock b/yarn.lock index fbe34380eb9..641fe2ebadd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6438,7 +6438,7 @@ __metadata: prettier: 3.2.5 prettier-plugin-tailwindcss: 0.5.14 pretty-quick: 4.0.0 - qovery-typescript-axios: 1.1.976 + qovery-typescript-axios: 1.1.978 qovery-ws-typescript-axios: 0.1.646 react: 18.3.1 react-country-flag: 3.0.2 @@ -25964,12 +25964,12 @@ __metadata: languageName: node linkType: hard -"qovery-typescript-axios@npm:1.1.976": - version: 1.1.976 - resolution: "qovery-typescript-axios@npm:1.1.976" +"qovery-typescript-axios@npm:1.1.978": + version: 1.1.978 + resolution: "qovery-typescript-axios@npm:1.1.978" dependencies: axios: 1.18.1 - checksum: 110b239dad9a0b333e2ef20aaacc9a7bec811b95ecffeded1e68adea59553df619a870ede4cca987eb6926d4c0ea64e347b8841c9d4fa388dd4cad438f95e8f4 + checksum: b0ed13fe2c988869ec5f6d115d00a28db700571f6b44dc3c4941cea673fdb6653942f72acc13eed593a1b75ca72febe3e8f4e1ee49d0d8c3e52c5464f2a5f04d languageName: node linkType: hard From 262664b880182ead7e6e4c4636067ffe488ea61c Mon Sep 17 00:00:00 2001 From: RemiBonnet Date: Tue, 15 Sep 2026 19:04:00 +0200 Subject: [PATCH 2/7] fix(agent-settings): harden token management --- .../llm-provider-create-edit-modal.spec.tsx | 11 ++ .../llm-provider-create-edit-modal.tsx | 9 +- .../settings-agent-personalization.spec.tsx | 39 +++- .../settings-agent-personalization.tsx | 184 +++++++++++------- 4 files changed, 164 insertions(+), 79 deletions(-) diff --git a/libs/domains/organizations/feature/src/lib/llm-provider-create-edit-modal/llm-provider-create-edit-modal.spec.tsx b/libs/domains/organizations/feature/src/lib/llm-provider-create-edit-modal/llm-provider-create-edit-modal.spec.tsx index 0e053f40858..01351414a35 100644 --- a/libs/domains/organizations/feature/src/lib/llm-provider-create-edit-modal/llm-provider-create-edit-modal.spec.tsx +++ b/libs/domains/organizations/feature/src/lib/llm-provider-create-edit-modal/llm-provider-create-edit-modal.spec.tsx @@ -46,6 +46,17 @@ describe('LlmProviderCreateEditModal', () => { expect(onClose).toHaveBeenCalledWith({ id: 'provider-1' }) }) + it('should reject a whitespace-only token', async () => { + const { userEvent } = renderWithProviders() + + await userEvent.type(screen.getByLabelText('Name'), 'My Claude') + await userEvent.type(screen.getByLabelText('Token'), ' ') + await userEvent.click(screen.getByRole('button', { name: 'Add token' })) + + expect(await screen.findByText('Please enter a token.')).toBeInTheDocument() + expect(createLlmProvider).not.toHaveBeenCalled() + }) + it('should keep the stored token when editing with a blank value', async () => { const { userEvent } = renderWithProviders( Boolean(value.trim()) || 'Please enter a token.', + } + } render={({ field, fieldState: { error } }) => ( ({ useParams: () => ({ organizationId: 'org-1' }), })) +jest.mock('@auth0/auth0-react', () => ({ + Auth0Provider: ({ children }: { children: ReactNode }) => children, + useAuth0: () => ({ user: { sub: 'auth0|current-user' } }), +})) + const mcpServers: McpServerResponse[] = [ { id: 'mcp-zulu', @@ -89,6 +94,19 @@ const llmProviders: LlmProviderResponse[] = [ has_credential: false, scope: LlmProviderScope.USER, owner_name: 'Rémi Bonnet', + owner_user_sub: 'auth0|current-user', + created_at: '2026-09-15T10:00:00Z', + updated_at: '2026-09-15T10:00:00Z', + }, + { + id: 'provider-other-user', + name: 'Other Claude', + description: '', + type: LlmProviderType.CLAUDE, + has_credential: true, + scope: LlmProviderScope.USER, + owner_name: 'Another member', + owner_user_sub: 'auth0|other-user', created_at: '2026-09-15T10:00:00Z', updated_at: '2026-09-15T10:00:00Z', }, @@ -171,6 +189,16 @@ describe('SettingsAgentPersonalization', () => { expect(openModal).toHaveBeenNthCalledWith(2, expect.objectContaining({ options: { fakeModal: true, width: 680 } })) }) + it('should keep page actions in normal flow below the large breakpoint', () => { + useMcpServersMock.mockReturnValue({ data: [] }) + + renderWithProviders() + + const actions = screen.getByRole('button', { name: 'Add token' }).parentElement + expect(actions).not.toHaveClass('absolute') + expect(actions).toHaveClass('-mt-4', 'mb-8', 'flex', 'flex-wrap', 'lg:absolute', 'lg:m-0') + }) + it('should render and manage provider tokens', async () => { useMcpServersMock.mockReturnValue({ data: [] }) useLlmProvidersMock.mockReturnValue({ data: llmProviders }) @@ -178,9 +206,14 @@ describe('SettingsAgentPersonalization', () => { expect(screen.getByText('Organization tokens')).toBeInTheDocument() expect(screen.getByText('Personal tokens')).toBeInTheDocument() - expect(screen.getAllByText('Claude')).toHaveLength(2) - expect(screen.getByText('Configured')).toBeInTheDocument() + expect(screen.getAllByText('Claude')).toHaveLength(3) + expect(screen.getAllByText('Configured')).toHaveLength(2) expect(screen.getByText('No token')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Edit My Claude' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Delete My Claude' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Edit Other Claude' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Delete Other Claude' })).not.toBeInTheDocument() + expect(screen.getByText('Owner: Another member')).toBeInTheDocument() await userEvent.click(screen.getByRole('button', { name: 'Add token' })) await userEvent.click(screen.getByRole('button', { name: 'Edit Claude production' })) diff --git a/libs/domains/organizations/feature/src/lib/settings-agent-personalization/settings-agent-personalization.tsx b/libs/domains/organizations/feature/src/lib/settings-agent-personalization/settings-agent-personalization.tsx index a65ffd978a6..c909d980c27 100644 --- a/libs/domains/organizations/feature/src/lib/settings-agent-personalization/settings-agent-personalization.tsx +++ b/libs/domains/organizations/feature/src/lib/settings-agent-personalization/settings-agent-personalization.tsx @@ -1,3 +1,4 @@ +import { useAuth0 } from '@auth0/auth0-react' import { useParams } from '@tanstack/react-router' import { type LlmProviderResponse, @@ -6,7 +7,7 @@ import { type McpServerResponse, McpServerScope, } from 'qovery-typescript-axios' -import { Suspense, useMemo } from 'react' +import { type ReactNode, Suspense, useMemo } from 'react' import { SettingsHeading } from '@qovery/shared/console-shared' import { Badge, @@ -156,14 +157,20 @@ function McpServersSkeleton() { interface LlmProviderRowProps { organizationId: string llmProvider: LlmProviderResponse + currentUserSub?: string } -function LlmProviderRow({ organizationId, llmProvider }: LlmProviderRowProps) { +function LlmProviderRow({ organizationId, llmProvider, currentUserSub }: LlmProviderRowProps) { const { openModal, closeModal } = useModal() const { openModalConfirmation } = useModalConfirmation() const { mutateAsync: deleteLlmProvider } = useDeleteLlmProvider() const owner = - llmProvider.scope === LlmProviderScope.USER ? `Owner: ${llmProvider.owner_name ?? 'You'}` : 'Organization' + llmProvider.scope === LlmProviderScope.USER + ? `Owner: ${llmProvider.owner_name ?? 'Unknown member'}` + : 'Organization' + const canManage = + llmProvider.scope === LlmProviderScope.ORGANIZATION || + Boolean(currentUserSub && llmProvider.owner_user_sub === currentUserSub) const onEdit = () => { openModal({ @@ -206,28 +213,30 @@ function LlmProviderRow({ organizationId, llmProvider }: LlmProviderRowProps) {

    {owner}

    -
    - - -
    + {canManage ? ( +
    + + +
    + ) : null} ) } @@ -251,29 +260,38 @@ function LlmProvidersSkeleton() { ) } -function LlmProvidersList({ organizationId }: { organizationId: string }) { - const { data: llmProviders = [] } = useLlmProviders({ organizationId, suspense: true }) - const sortedLlmProviders = useMemo( - () => [...llmProviders].sort((first, second) => first.name.localeCompare(second.name)), - [llmProviders] - ) +interface ScopedItem { + id: string + name: string + scope: string +} - if (sortedLlmProviders.length === 0) { - return ( - - ) - } +interface ScopedItemsListProps { + items: T[] + organizationScope: string + userScope: string + organizationTitle: string + organizationEmptyMessage: string + personalTitle: string + renderItem: (item: T) => ReactNode +} - const personalProviders = sortedLlmProviders.filter(({ scope }) => scope === LlmProviderScope.USER) - const organizationProviders = sortedLlmProviders.filter(({ scope }) => scope === LlmProviderScope.ORGANIZATION) - const providerGroup = (title: string, providers: LlmProviderResponse[], emptyMessage: string) => ( +function ScopedItemsList({ + items, + organizationScope, + userScope, + organizationTitle, + organizationEmptyMessage, + personalTitle, + renderItem, +}: ScopedItemsListProps) { + const sortedItems = useMemo(() => [...items].sort((first, second) => first.name.localeCompare(second.name)), [items]) + const personalItems = sortedItems.filter(({ scope }) => scope === userScope) + const organizationItems = sortedItems.filter(({ scope }) => scope === organizationScope) + const itemGroup = (title: string, groupedItems: T[], emptyMessage: string) => ( - {providers.length > 0 ? ( -
      - {providers.map((llmProvider) => ( - - ))} -
    + {groupedItems.length > 0 ? ( +
      {groupedItems.map(renderItem)}
    ) : (

    {emptyMessage}

    )} @@ -282,24 +300,50 @@ function LlmProvidersList({ organizationId }: { organizationId: string }) { return (
    - {providerGroup('Organization tokens', organizationProviders, 'No organization tokens.')} - {personalProviders.length > 0 ? providerGroup('Personal tokens', personalProviders, '') : null} + {itemGroup(organizationTitle, organizationItems, organizationEmptyMessage)} + {personalItems.length > 0 ? itemGroup(personalTitle, personalItems, '') : null}
    ) } +function LlmProvidersList({ organizationId }: { organizationId: string }) { + const { data: llmProviders = [] } = useLlmProviders({ organizationId, suspense: true }) + const { user } = useAuth0() + + if (llmProviders.length === 0) { + return ( + + ) + } + + return ( + ( + + )} + /> + ) +} + interface McpServersListProps { organizationId: string } function McpServersList({ organizationId }: McpServersListProps) { const { data: mcpServers = [] } = useMcpServers({ organizationId, suspense: true }) - const sortedMcpServers = useMemo( - () => [...mcpServers].sort((first, second) => first.name.localeCompare(second.name)), - [mcpServers] - ) - if (sortedMcpServers.length === 0) { + if (mcpServers.length === 0) { return ( scope === McpServerScope.USER) - const organizationMcpServers = sortedMcpServers.filter(({ scope }) => scope === McpServerScope.ORGANIZATION) - - const mcpServerGroup = (title: string, servers: McpServerResponse[], emptyMessage: string) => ( - - {servers.length > 0 ? ( -
      - {servers.map((mcpServer) => ( - - ))} -
    - ) : ( -

    {emptyMessage}

    - )} -
    - ) - return ( -
    - {mcpServerGroup('Organization MCPs', organizationMcpServers, 'No organization MCPs.')} - {personalMcpServers.length > 0 ? mcpServerGroup('Personal MCPs', personalMcpServers, '') : null} -
    + ( + + )} + /> ) } @@ -359,9 +393,9 @@ export function SettingsAgentPersonalization() { return (
    -
    +
    -
    +
    -
    -

    {mcpServer.url}

    {owner ? ( - <> - - - {owner} - - + + {owner} + ) : null}
    +

    {mcpServer.url}

    {mcpServer.scope === McpServerScope.ORGANIZATION || mcpServer.attachable ? ( @@ -167,7 +152,7 @@ function LlmProviderRow({ organizationId, llmProvider, currentUserSub }: LlmProv const owner = llmProvider.scope === LlmProviderScope.USER ? `Owner: ${llmProvider.owner_name ?? 'Unknown member'}` - : 'Organization' + : undefined const canManage = llmProvider.scope === LlmProviderScope.ORGANIZATION || Boolean(currentUserSub && llmProvider.owner_user_sub === currentUserSub) @@ -201,17 +186,29 @@ function LlmProviderRow({ organizationId, llmProvider, currentUserSub }: LlmProv >
    + - {llmProvider.type === LlmProviderType.CLAUDE ? 'Claude' : 'Bedrock'} - {llmProvider.has_credential ? ( - Configured - ) : ( - No token - )} + {llmProvider.description ? ( + + + + + + ) : null} + {!llmProvider.has_credential ? No token : null}
    -

    {owner}

    + {owner ?

    {owner}

    : null}
    {canManage ? (
    @@ -271,7 +268,6 @@ interface ScopedItemsListProps { organizationScope: string userScope: string organizationTitle: string - organizationEmptyMessage: string personalTitle: string renderItem: (item: T) => ReactNode } @@ -281,27 +277,22 @@ function ScopedItemsList({ organizationScope, userScope, organizationTitle, - organizationEmptyMessage, personalTitle, renderItem, }: ScopedItemsListProps) { const sortedItems = useMemo(() => [...items].sort((first, second) => first.name.localeCompare(second.name)), [items]) const personalItems = sortedItems.filter(({ scope }) => scope === userScope) const organizationItems = sortedItems.filter(({ scope }) => scope === organizationScope) - const itemGroup = (title: string, groupedItems: T[], emptyMessage: string) => ( + const itemGroup = (title: string, groupedItems: T[]) => ( - {groupedItems.length > 0 ? ( -
      {groupedItems.map(renderItem)}
    - ) : ( -

    {emptyMessage}

    - )} +
      {groupedItems.map(renderItem)}
    ) return (
    - {itemGroup(organizationTitle, organizationItems, organizationEmptyMessage)} - {personalItems.length > 0 ? itemGroup(personalTitle, personalItems, '') : null} + {organizationItems.length > 0 ? itemGroup(organizationTitle, organizationItems) : null} + {personalItems.length > 0 ? itemGroup(personalTitle, personalItems) : null}
    ) } @@ -322,8 +313,7 @@ function LlmProvidersList({ organizationId }: { organizationId: string }) { organizationScope={LlmProviderScope.ORGANIZATION} userScope={LlmProviderScope.USER} organizationTitle="Organization tokens" - organizationEmptyMessage="No organization tokens." - personalTitle="Personal tokens" + personalTitle="Personal token" renderItem={(llmProvider) => ( ( @@ -368,21 +357,11 @@ function McpServersList({ organizationId }: McpServersListProps) { ) } -export function SettingsAgentPersonalization() { - useDocumentTitle('Agent personalization - Organization settings') +export function SettingsAgentTokens() { + useDocumentTitle('Agent tokens - Organization settings') const { organizationId = '' } = useParams({ strict: false }) const { openModal, closeModal } = useModal() - const onAddMcp = () => { - openModal({ - content: , - options: { - fakeModal: true, - width: 680, - }, - }) - } - const onAddToken = () => { openModal({ content: , @@ -391,31 +370,54 @@ export function SettingsAgentPersonalization() { } return ( -
    -
    -
    - -
    - - -
    -
    - -
    +
    +
    + + + +
    }> +
    +
    +
    + ) +} + +export function SettingsAgentMcps() { + useDocumentTitle('Agent MCPs - Organization settings') + const { organizationId = '' } = useParams({ strict: false }) + const { openModal, closeModal } = useModal() + + const onAddMcp = () => { + openModal({ + content: , + options: { + fakeModal: true, + width: 680, + }, + }) + } + + return ( +
    +
    + + + +
    }>
    -
    -
    +
    + ) } diff --git a/libs/domains/service-settings/feature/src/lib/agentic-workflow-settings/agentic-workflow-ai-configuration-settings/agentic-workflow-ai-configuration-settings.spec.tsx b/libs/domains/service-settings/feature/src/lib/agentic-workflow-settings/agentic-workflow-ai-configuration-settings/agentic-workflow-ai-configuration-settings.spec.tsx index 24043a4e817..eab5d06659f 100644 --- a/libs/domains/service-settings/feature/src/lib/agentic-workflow-settings/agentic-workflow-ai-configuration-settings/agentic-workflow-ai-configuration-settings.spec.tsx +++ b/libs/domains/service-settings/feature/src/lib/agentic-workflow-settings/agentic-workflow-ai-configuration-settings/agentic-workflow-ai-configuration-settings.spec.tsx @@ -3,14 +3,22 @@ import { AgenticWorkflowSettingsFormHarness } from '../agentic-workflow-settings import { AgenticWorkflowAiConfigurationSettings } from './agentic-workflow-ai-configuration-settings' describe('AgenticWorkflowAiConfigurationSettings', () => { - it('renders the write-only API key, model settings, and instructions', () => { + it('renders the token, model settings, and instructions', () => { renderWithProviders( - {(form) => } + {(form) => ( + + )} ) - expect(screen.getByLabelText('API key')).toHaveValue('') + expect(screen.getByLabelText('Token')).toBeInTheDocument() + expect(screen.queryByLabelText('API key')).not.toBeInTheDocument() expect(screen.getByText('Cloud settings JSON')).toBeInTheDocument() expect(screen.queryByRole('heading', { name: 'Instructions' })).not.toBeInTheDocument() const instructions = screen.getByRole('textbox', { name: 'Instructions' }) @@ -21,7 +29,14 @@ describe('AgenticWorkflowAiConfigurationSettings', () => { it('does not show an instructions error before the field is modified', () => { renderWithProviders( - {(form) => } + {(form) => ( + + )} ) diff --git a/libs/domains/service-settings/feature/src/lib/agentic-workflow-settings/agentic-workflow-ai-configuration-settings/agentic-workflow-ai-configuration-settings.tsx b/libs/domains/service-settings/feature/src/lib/agentic-workflow-settings/agentic-workflow-ai-configuration-settings/agentic-workflow-ai-configuration-settings.tsx index ae516255cad..cc144791ae4 100644 --- a/libs/domains/service-settings/feature/src/lib/agentic-workflow-settings/agentic-workflow-ai-configuration-settings/agentic-workflow-ai-configuration-settings.tsx +++ b/libs/domains/service-settings/feature/src/lib/agentic-workflow-settings/agentic-workflow-ai-configuration-settings/agentic-workflow-ai-configuration-settings.tsx @@ -1,6 +1,7 @@ +import { type LlmProviderResponse } from 'qovery-typescript-axios' import { Controller, type UseFormReturn } from 'react-hook-form' +import { LlmProviderSetting } from '@qovery/domains/organizations/feature' import { AgenticWorkflowCodeEditorField, AgenticWorkflowPromptEditor } from '@qovery/domains/services/feature' -import { InputText } from '@qovery/shared/ui' import { type AgenticWorkflowSettingsFormValues } from '../agentic-workflow-settings' import { AgenticWorkflowSettingsCard } from '../agentic-workflow-settings-card' @@ -16,9 +17,13 @@ function getJsonError(value: string) { export function AgenticWorkflowAiConfigurationSettings({ environmentId, form, + llmProviders, + organizationId, }: { environmentId: string form: UseFormReturn + llmProviders: LlmProviderResponse[] + organizationId: string }) { return ( <> @@ -27,10 +32,15 @@ export function AgenticWorkflowAiConfigurationSettings({ description="Configure the Anthropic credentials and cloud settings." > ( - + )} /> { editService.mockReset() useServiceSpy.mockReturnValue({ data: service }) useGitTokensSpy.mockReturnValue({ data: [{ id: 'token-1', type: 'GITHUB' }], isLoading: false }) + useLlmProvidersSpy.mockReturnValue({ data: [] }) useMcpServersSpy.mockReturnValue({ data: [{ id: 'mcp-1', name: 'Documentation', url: 'https://docs.example.com' }], isLoading: false, @@ -186,7 +189,11 @@ describe('AgenticWorkflowSettings views', () => { payload: expect.objectContaining({ description: 'Updated description', execution_mode: AgenticWorkflowExecutionMode.IN_PLACE, - model: { type: AgenticWorkflowModelType.BEDROCK, settings: '{"temperature":0.2}' }, + model: { + type: AgenticWorkflowModelType.BEDROCK, + llm_provider_id: 'provider-1', + settings: '{"temperature":0.2}', + }, mcp: '{"mcpServers":{}}', outputs: [{ name: 'Audit log', url: null }], mcp_servers: [{ id: 'mcp-1', required: true }], @@ -215,11 +222,12 @@ describe('AgenticWorkflowSettings views', () => { ) }) - it('renders AI configuration without exposing the write-only API key', () => { + it('renders AI configuration with a stored token only', () => { renderWithProviders() expect(screen.getByRole('heading', { name: 'AI configuration' })).toBeInTheDocument() - expect(screen.getByLabelText('API key')).toHaveValue('') + expect(screen.getByLabelText('Token')).toBeInTheDocument() + expect(screen.queryByLabelText('API key')).not.toBeInTheDocument() expect(screen.getByRole('textbox', { name: 'Cloud settings JSON' })).toHaveValue('{"temperature":0.2}') expect(screen.getByRole('textbox', { name: 'Instructions' })).toBeInTheDocument() }) diff --git a/libs/domains/service-settings/feature/src/lib/agentic-workflow-settings/agentic-workflow-settings.tsx b/libs/domains/service-settings/feature/src/lib/agentic-workflow-settings/agentic-workflow-settings.tsx index c85d9b73a45..63a7cb4be40 100644 --- a/libs/domains/service-settings/feature/src/lib/agentic-workflow-settings/agentic-workflow-settings.tsx +++ b/libs/domains/service-settings/feature/src/lib/agentic-workflow-settings/agentic-workflow-settings.tsx @@ -3,9 +3,10 @@ import { AgenticWorkflowExecutionMode, type AgenticWorkflowRequest, type GitTokenResponse, + LlmProviderType, } from 'qovery-typescript-axios' import { useForm } from 'react-hook-form' -import { useGitTokens } from '@qovery/domains/organizations/feature' +import { useGitTokens, useLlmProviders } from '@qovery/domains/organizations/feature' import { isAgenticWorkflow } from '@qovery/domains/services/data-access' import { type AgenticWorkflowAutomation, @@ -34,7 +35,7 @@ export interface AgenticWorkflowSettingsFormValues { description: string enabled: boolean executionMode: AgenticWorkflowExecutionMode - modelApiKey: string + llmProviderId: string modelSettings: string agentPrompt: string repositories: AgenticWorkflowGitRepository[] @@ -127,6 +128,7 @@ export function AgenticWorkflowSettings({ page }: AgenticWorkflowSettingsProps) isLoading: contextServicesLoading, } = useAgenticWorkflowContextServices(environmentId) const { mutate: editService, isLoading } = useEditService({ organizationId, projectId, environmentId }) + const { data: llmProviders = [] } = useLlmProviders({ organizationId, enabled: page === 'ai-configuration' }) const content = PAGE_CONTENT[page] useDocumentTitle(`${content.title} - Service settings`) const workflow = service && isAgenticWorkflow(service) ? service : undefined @@ -142,7 +144,7 @@ export function AgenticWorkflowSettings({ page }: AgenticWorkflowSettingsProps) description: workflow.description, enabled: workflow.enabled, executionMode: workflow.execution_mode ?? AgenticWorkflowExecutionMode.IN_PLACE, - modelApiKey: '', + llmProviderId: workflow.model.llm_provider_id ?? '', modelSettings: workflow.model.settings, agentPrompt: workflow.agent_prompt, repositories: workflow.project_repositories.map(({ url, branch, git_token_id }) => { @@ -182,13 +184,15 @@ export function AgenticWorkflowSettings({ page }: AgenticWorkflowSettingsProps) const pageValid = Boolean(values.name.trim()) && (page !== 'ai-configuration' || - (Boolean(values.agentPrompt.trim()) && agenticWorkflowJsonValidation(values.modelSettings) === true)) && + (Boolean(values.llmProviderId) && + Boolean(values.agentPrompt.trim()) && + agenticWorkflowJsonValidation(values.modelSettings) === true)) && (page !== 'connections' || values.repositories.every(isGitRepositoryComplete)) const submit = form.handleSubmit((data) => { const model: AgenticWorkflowRequest['model'] = { type: workflow.model.type, settings: data.modelSettings, - ...(data.modelApiKey.trim() ? { api_key: data.modelApiKey.trim() } : {}), + llm_provider_id: data.llmProviderId, } const selectedContextServices = contextServices.filter(({ id }) => data.contextServiceIds.includes(id)) @@ -241,7 +245,14 @@ export function AgenticWorkflowSettings({ page }: AgenticWorkflowSettingsProps)
    {page === 'general' ? : null} {page === 'ai-configuration' ? ( - + type === LlmProviderType.CLAUDE && has_credential + )} + organizationId={organizationId} + /> ) : null} {page === 'connections' ? ( ({ GitBranchSettings: () =>
    Git branch
    , GitProviderSetting: () =>
    Git provider
    , GitRepositorySetting: () =>
    Git repository
    , + LlmProviderSetting: ({ onChange }: { onChange: (value: string) => void }) => ( + + ), McpServerCreateEditModal: () =>
    Create MCP server
    , McpServerSetting: () =>
    Organization MCP connectors
    , useCreateQoveryMcpServer: () => ({ isLoading: mockCreateQoveryMcpServerLoading, mutateAsync: mockCreateQoveryMcpServer, }), + useLlmProviders: () => ({ data: [], isLoading: false }), useMcpServers: () => ({ data: mockMcpServers, isError: mockMcpServersError, @@ -133,7 +139,7 @@ function renderConfiguration({ const validSeed: Partial = { name: 'review-agent', agentPrompt: 'Review incoming payloads.', - modelApiKey: 'sk-ant-test', + llmProviderId: 'provider-1', automations: [{ id: 'automation-1', triggers: [{ id: 'webhook-1', type: 'webhook' }], outputs: [] }], } @@ -294,7 +300,8 @@ describe('AgenticWorkflowConfiguration', () => { await userEvent.click(screen.getByRole('button', { name: 'Anthropic' })) expect(screen.getByRole('heading', { name: 'Configure provider' })).toBeInTheDocument() - expect(screen.getByLabelText('API key')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Select stored token' })).toBeInTheDocument() + expect(screen.queryByLabelText('API key')).not.toBeInTheDocument() expect(screen.getByText('Cloud settings JSON')).toBeInTheDocument() await userEvent.click(screen.getByRole('button', { name: 'Save provider' })) @@ -639,7 +646,7 @@ describe('AgenticWorkflowConfiguration', () => { await userEvent.type(screen.getByRole('textbox', { name: /Instructions/ }), 'Review incoming payloads.') await userEvent.click(screen.getByRole('button', { name: 'Anthropic' })) - await userEvent.type(screen.getByLabelText('API key'), 'sk-ant-test') + await userEvent.click(screen.getByRole('button', { name: 'Select stored token' })) await userEvent.click(screen.getByRole('button', { name: 'Save provider' })) expect(createButton).toBeEnabled() @@ -731,7 +738,7 @@ describe('AgenticWorkflowConfiguration', () => { await userEvent.type(screen.getByRole('textbox', { name: 'Name' }), 'review-agent') await userEvent.type(screen.getByRole('textbox', { name: 'Instructions' }), 'Review incoming payloads.') await userEvent.click(screen.getByRole('button', { name: 'Anthropic' })) - await userEvent.type(screen.getByLabelText('API key'), 'sk-ant-test') + await userEvent.click(screen.getByRole('button', { name: 'Select stored token' })) await userEvent.click(screen.getByRole('button', { name: 'Save provider' })) await userEvent.click(screen.getByRole('button', { name: 'Add trigger' })) await userEvent.click(screen.getByRole('button', { name: 'Add' })) diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx index 8a10aed70fa..0a36750d507 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx @@ -1,10 +1,15 @@ import { useNavigate, useParams } from '@tanstack/react-router' import clsx from 'clsx' import posthog from 'posthog-js' -import { APIVariableScopeEnum, type McpServerResponse } from 'qovery-typescript-axios' +import { APIVariableScopeEnum, LlmProviderType, type McpServerResponse } from 'qovery-typescript-axios' import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react' import { Controller, FormProvider, useFieldArray } from 'react-hook-form' -import { useCreateQoveryMcpServer, useMcpServers } from '@qovery/domains/organizations/feature' +import { + LlmProviderSetting, + useCreateQoveryMcpServer, + useLlmProviders, + useMcpServers, +} from '@qovery/domains/organizations/feature' import { VariableRow, useImportVariables } from '@qovery/domains/variables/feature' import { IconEnum } from '@qovery/shared/enums' import { type VariableData } from '@qovery/shared/interfaces' @@ -15,6 +20,7 @@ import { DropdownMenu, Heading, Icon, + InputSelect, InputText, InputTextArea, Modal, @@ -282,6 +288,7 @@ export function AgenticWorkflowConfiguration() { isLoading: areMcpServersLoading, refetch: refetchMcpServers, } = useMcpServers({ organizationId }) + const { data: llmProviders = [], isLoading: areLlmProvidersLoading } = useLlmProviders({ organizationId }) const { data: contextServices = [], isLoading: areContextServicesLoading } = useAgenticWorkflowContextServices(environmentId) const navigate = useNavigate() @@ -314,7 +321,6 @@ export function AgenticWorkflowConfiguration() { const [dockerModalOpen, setDockerModalOpen] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false) const [showValidationErrors, setShowValidationErrors] = useState(false) - const modelApiKeyInputRef = useRef(null) const headerRef = useRef(null) const promptEditorRef = useRef(null) const createdServiceIdRef = useRef() @@ -329,8 +335,12 @@ export function AgenticWorkflowConfiguration() { const variablesValid = areVariablesValid(variableValues) const showNameError = (showValidationErrors || Boolean(dirtyFields.name)) && !values.name.trim() const showPromptError = (showValidationErrors || Boolean(dirtyFields.agentPrompt)) && !values.agentPrompt.trim() - const showModelApiKeyError = (showValidationErrors || Boolean(dirtyFields.modelApiKey)) && !values.modelApiKey.trim() - const providerConfigurationInvalid = !values.modelApiKey.trim() || Boolean(modelSettingsJsonError) + const hasModelCredential = Boolean(values.llmProviderId) + const showLlmProviderError = (showValidationErrors || Boolean(dirtyFields.llmProviderId)) && !hasModelCredential + const providerConfigurationInvalid = !hasModelCredential || Boolean(modelSettingsJsonError) + const availableLlmProviders = llmProviders.filter( + ({ type, has_credential }) => type === LlmProviderType.CLAUDE && has_credential + ) const settingsGroupsInvalid: Record = { general: false, resources: false, @@ -506,9 +516,6 @@ export function AgenticWorkflowConfiguration() { if (providerConfigurationInvalid) { setProviderModalOpen(true) - window.requestAnimationFrame(() => { - window.requestAnimationFrame(() => modelApiKeyInputRef.current?.focus()) - }) return false } @@ -925,11 +932,11 @@ export function AgenticWorkflowConfiguration() { Anthropic - {!values.modelApiKey.trim() ? ( + {!hasModelCredential ? ( - API key required + Token required ) : null} @@ -1072,16 +1079,15 @@ export function AgenticWorkflowConfiguration() { setOpen={setProviderModalOpen} > ( - )} diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-context.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-context.tsx index a9b1a468f44..078c4f74e2f 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-context.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-context.tsx @@ -101,7 +101,7 @@ export interface AgenticWorkflowFormData { mcpJson: string gitRepositories: AgenticWorkflowGitRepository[] contextServices: AgenticWorkflowContextService[] - modelApiKey: string + llmProviderId: string modelSettingsJson: string whitelistHosts: string dockerFragment: string @@ -141,7 +141,7 @@ export function getAgenticWorkflowDefaults(): AgenticWorkflowFormData { mcpJson: '', gitRepositories: [], contextServices: [], - modelApiKey: '', + llmProviderId: '', modelSettingsJson: DEFAULT_MODEL_SETTINGS, whitelistHosts: '*', dockerFragment: '', diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-request.spec.ts b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-request.spec.ts index eb010eb6640..15f89f61b6b 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-request.spec.ts +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-request.spec.ts @@ -14,7 +14,7 @@ const values: AgenticWorkflowFormData = { mcpJson: '', gitRepositories: [], contextServices: [], - modelApiKey: 'api-key', + llmProviderId: 'provider-1', modelSettingsJson: '{}', whitelistHosts: '*', dockerFragment: '', @@ -27,6 +27,14 @@ describe('formatAgenticWorkflowRequest', () => { expect(formatAgenticWorkflowRequest(values).enabled).toBe(true) }) + it('sends the selected LLM provider', () => { + expect(formatAgenticWorkflowRequest(values).model).toEqual({ + type: AgenticWorkflowModelType.CLAUDE, + llm_provider_id: 'provider-1', + settings: '{}', + }) + }) + it('sends selected MCP servers and their creation requirements', () => { expect(formatAgenticWorkflowRequest(values, ['mcp-1']).mcp_servers).toEqual([ { id: 'mcp-1', required: true }, diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-request.ts b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-request.ts index 0227bbac811..e91ebba07c3 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-request.ts +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-request.ts @@ -68,7 +68,7 @@ export function formatAgenticWorkflowRequest( })), model: { type: values.aiModel, - api_key: values.modelApiKey, + llm_provider_id: values.llmProviderId, settings: values.modelSettingsJson, }, project_repositories: values.gitRepositories.map((repository) => ({ diff --git a/libs/domains/services/feature/src/lib/service-new/service-new.spec.tsx b/libs/domains/services/feature/src/lib/service-new/service-new.spec.tsx index 6422e5f3271..ae675f04ed6 100644 --- a/libs/domains/services/feature/src/lib/service-new/service-new.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-new/service-new.spec.tsx @@ -171,8 +171,9 @@ describe('ServiceNew', () => { ) - expect(screen.getByRole('heading', { name: 'Agent use cases' })).toBeInTheDocument() - expect(screen.getByText('BETA')).toBeInTheDocument() + const agentUseCasesHeading = screen.getByRole('heading', { name: 'Agent use cases' }) + expect(agentUseCasesHeading).toBeInTheDocument() + expect(within(agentUseCasesHeading.parentElement as HTMLElement).getByText('NEW')).toBeInTheDocument() expect(screen.getByRole('link', { name: /Incident Analyzer with incident.io/i })).toHaveAttribute( 'href', '/organization/org-1/project/project-1/environment/env-1/service/create/agentic-workflow?template=incident-io-analyzer' diff --git a/libs/domains/services/feature/src/lib/service-new/service-new.tsx b/libs/domains/services/feature/src/lib/service-new/service-new.tsx index c25a3e0daae..687b94b79dc 100644 --- a/libs/domains/services/feature/src/lib/service-new/service-new.tsx +++ b/libs/domains/services/feature/src/lib/service-new/service-new.tsx @@ -386,7 +386,7 @@ export function ServiceNew({ size="sm" className="h-4 border-transparent bg-surface-brand-solid px-1 pt-[1px] text-[8px] font-semibold text-neutralInvert" > - BETA + NEW

    diff --git a/package.json b/package.json index bb3c6d72dd5..b8b6166df49 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "mermaid": "11.6.0", "monaco-editor": "0.53.0", "posthog-js": "1.345.1", - "qovery-typescript-axios": "1.1.978", + "qovery-typescript-axios": "1.1.979", "react": "18.3.1", "react-country-flag": "3.0.2", "react-datepicker": "4.12.0", diff --git a/yarn.lock b/yarn.lock index 641fe2ebadd..b95894ed7ef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6438,7 +6438,7 @@ __metadata: prettier: 3.2.5 prettier-plugin-tailwindcss: 0.5.14 pretty-quick: 4.0.0 - qovery-typescript-axios: 1.1.978 + qovery-typescript-axios: 1.1.979 qovery-ws-typescript-axios: 0.1.646 react: 18.3.1 react-country-flag: 3.0.2 @@ -25964,12 +25964,12 @@ __metadata: languageName: node linkType: hard -"qovery-typescript-axios@npm:1.1.978": - version: 1.1.978 - resolution: "qovery-typescript-axios@npm:1.1.978" +"qovery-typescript-axios@npm:1.1.979": + version: 1.1.979 + resolution: "qovery-typescript-axios@npm:1.1.979" dependencies: axios: 1.18.1 - checksum: b0ed13fe2c988869ec5f6d115d00a28db700571f6b44dc3c4941cea673fdb6653942f72acc13eed593a1b75ca72febe3e8f4e1ee49d0d8c3e52c5464f2a5f04d + checksum: 412bf2ca0adea62b3e4bd20461052c37f8b179a8cb84d327d4f119180377db7d73a2cc1b8ba7bce92b0ee69ba0afbf96a75cc29d9fcb23f48604719849f2e84e languageName: node linkType: hard From c692aaf9b007a57a21864a91a1856a90c6c53bed Mon Sep 17 00:00:00 2001 From: RemiBonnet Date: Wed, 16 Sep 2026 14:17:20 +0200 Subject: [PATCH 4/7] fix(agent-workflow): toggle service selection action --- .../llm-provider-setting.spec.tsx | 2 +- .../settings-agent-personalization.tsx | 4 +--- .../qovery-service-context-modal.spec.tsx | 17 +++++++++++------ .../context/qovery-service-context-modal.tsx | 11 ++++------- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/libs/domains/organizations/feature/src/lib/llm-provider-setting/llm-provider-setting.spec.tsx b/libs/domains/organizations/feature/src/lib/llm-provider-setting/llm-provider-setting.spec.tsx index 146ec1a8660..2b101669f36 100644 --- a/libs/domains/organizations/feature/src/lib/llm-provider-setting/llm-provider-setting.spec.tsx +++ b/libs/domains/organizations/feature/src/lib/llm-provider-setting/llm-provider-setting.spec.tsx @@ -1,7 +1,7 @@ +import { act } from '@testing-library/react' import { type LlmProviderResponse, LlmProviderScope, LlmProviderType } from 'qovery-typescript-axios' import { type ReactElement } from 'react' import selectEvent from 'react-select-event' -import { act } from '@testing-library/react' import * as sharedUi from '@qovery/shared/ui' import { renderWithProviders, screen } from '@qovery/shared/util-tests' import { type LlmProviderCreateEditModalProps } from '../llm-provider-create-edit-modal/llm-provider-create-edit-modal' diff --git a/libs/domains/organizations/feature/src/lib/settings-agent-personalization/settings-agent-personalization.tsx b/libs/domains/organizations/feature/src/lib/settings-agent-personalization/settings-agent-personalization.tsx index 0be9be389c3..11c5a05af45 100644 --- a/libs/domains/organizations/feature/src/lib/settings-agent-personalization/settings-agent-personalization.tsx +++ b/libs/domains/organizations/feature/src/lib/settings-agent-personalization/settings-agent-personalization.tsx @@ -150,9 +150,7 @@ function LlmProviderRow({ organizationId, llmProvider, currentUserSub }: LlmProv const { openModalConfirmation } = useModalConfirmation() const { mutateAsync: deleteLlmProvider } = useDeleteLlmProvider() const owner = - llmProvider.scope === LlmProviderScope.USER - ? `Owner: ${llmProvider.owner_name ?? 'Unknown member'}` - : undefined + llmProvider.scope === LlmProviderScope.USER ? `Owner: ${llmProvider.owner_name ?? 'Unknown member'}` : undefined const canManage = llmProvider.scope === LlmProviderScope.ORGANIZATION || Boolean(currentUserSub && llmProvider.owner_user_sub === currentUserSub) diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.spec.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.spec.tsx index 7a99d3213aa..aea06d09bd9 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.spec.tsx @@ -24,8 +24,9 @@ describe('QoveryServiceContextModal', () => { ) await userEvent.click(screen.getByRole('checkbox', { name: 'api' })) + expect(screen.getByRole('button', { name: 'Unselect all' })).toBeInTheDocument() await userEvent.click(screen.getByRole('checkbox', { name: 'postgres' })) - expect(screen.queryByRole('button', { name: 'Select all' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Unselect all' })).toBeInTheDocument() await userEvent.click(screen.getByRole('button', { name: 'Confirm' })) expect(onSave).toHaveBeenCalledWith(services) @@ -43,7 +44,7 @@ describe('QoveryServiceContextModal', () => { ) expect(screen.getByRole('checkbox', { name: 'api' })).toBeChecked() - expect(screen.getByRole('button', { name: 'Select all' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Unselect all' })).toBeInTheDocument() }) it('keeps Select all available when only a stale service ID is selected', () => { @@ -60,12 +61,16 @@ describe('QoveryServiceContextModal', () => { expect(screen.getByRole('button', { name: 'Select all' })).toBeInTheDocument() }) - it('removes the hidden Select all action from keyboard interaction', () => { - renderWithProviders( + it('unselects all services when at least one service is selected', async () => { + const { userEvent } = renderWithProviders( ) - expect(screen.getByText('Select all').closest('button')).toBeDisabled() + await userEvent.click(screen.getByRole('button', { name: 'Unselect all' })) + + expect(screen.getByRole('checkbox', { name: 'api' })).not.toBeChecked() + expect(screen.getByRole('checkbox', { name: 'postgres' })).not.toBeChecked() + expect(screen.getByRole('button', { name: 'Select all' })).toBeInTheDocument() }) it('prevents closing while services are being saved', async () => { @@ -84,7 +89,7 @@ describe('QoveryServiceContextModal', () => { await userEvent.click(screen.getByRole('button', { name: 'Confirm' })) expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled() - expect(screen.getByRole('button', { name: 'Select all' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'Unselect all' })).toBeDisabled() expect(screen.getByRole('checkbox', { name: 'api' })).toBeDisabled() expect(screen.getByRole('checkbox', { name: 'postgres' })).toBeDisabled() expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument() diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.tsx index 4b757bf43f0..3d3d6775839 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/context/qovery-service-context-modal.tsx @@ -1,4 +1,3 @@ -import clsx from 'clsx' import { useState } from 'react' import { Button, Checkbox, Heading, Icon, Section } from '@qovery/shared/ui' import { type AgenticWorkflowContextService } from '../../agentic-workflow-context' @@ -19,7 +18,7 @@ export function QoveryServiceContextModal({ const [selectedIds, setSelectedIds] = useState(value.map(({ id }) => id)) const [isSaving, setIsSaving] = useState(false) const [saveError, setSaveError] = useState() - const hideSelectAll = isLoading || services.every(({ id }) => selectedIds.includes(id)) + const hasSelectedService = services.some(({ id }) => selectedIds.includes(id)) return (

    @@ -39,12 +38,10 @@ export function QoveryServiceContextModal({ variant="plain" color="neutral" size="xs" - aria-hidden={hideSelectAll} - disabled={hideSelectAll || isSaving} - className={clsx(hideSelectAll && 'pointer-events-none opacity-0')} - onClick={() => setSelectedIds(services.map(({ id }) => id))} + disabled={isLoading || isSaving} + onClick={() => setSelectedIds(hasSelectedService ? [] : services.map(({ id }) => id))} > - Select all + {hasSelectedService ? 'Unselect all' : 'Select all'}
    From 945626fa9e6e3a5920f409162532ba5445f2989d Mon Sep 17 00:00:00 2001 From: RemiBonnet Date: Wed, 16 Sep 2026 14:27:40 +0200 Subject: [PATCH 5/7] fix(agent-settings): use singular navigation labels --- .../organization/$organizationId/settings/route.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/settings/route.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/settings/route.tsx index af0d0d8176e..f87e765ba56 100644 --- a/apps/console/src/routes/_authenticated/organization/$organizationId/settings/route.tsx +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/settings/route.tsx @@ -110,11 +110,11 @@ function RouteComponent() { const agentsLink = { type: 'group', - title: 'Agents', + title: 'Agent', icon: 'robot' as const, children: [ - { title: 'Tokens', to: `${pathSettings}/agents/tokens` }, - { title: 'MCPs', to: `${pathSettings}/agents/mcps` }, + { title: 'Token', to: `${pathSettings}/agents/tokens` }, + { title: 'MCP', to: `${pathSettings}/agents/mcps` }, ], } From a6f6fc03e53bffc1040f1cc5fd471928fe50380a Mon Sep 17 00:00:00 2001 From: RemiBonnet Date: Wed, 16 Sep 2026 14:42:14 +0200 Subject: [PATCH 6/7] refactor(agent-workflow): read route ids from params --- .../llm-provider-setting.spec.tsx | 8 ++++--- .../llm-provider-setting.tsx | 12 +++------- ...orkflow-ai-configuration-settings.spec.tsx | 22 +++++-------------- ...tic-workflow-ai-configuration-settings.tsx | 12 +--------- .../agentic-workflow-settings.tsx | 2 -- .../agentic-workflow-configuration.tsx | 2 -- .../agentic-workflow-prompt-editor.tsx | 8 +++---- 7 files changed, 18 insertions(+), 48 deletions(-) diff --git a/libs/domains/organizations/feature/src/lib/llm-provider-setting/llm-provider-setting.spec.tsx b/libs/domains/organizations/feature/src/lib/llm-provider-setting/llm-provider-setting.spec.tsx index 2b101669f36..71276a79e66 100644 --- a/libs/domains/organizations/feature/src/lib/llm-provider-setting/llm-provider-setting.spec.tsx +++ b/libs/domains/organizations/feature/src/lib/llm-provider-setting/llm-provider-setting.spec.tsx @@ -11,6 +11,10 @@ const useModalMock = jest.spyOn(sharedUi, 'useModal') as jest.Mock const openModal = jest.fn() const closeModal = jest.fn() +jest.mock('@tanstack/react-router', () => ({ + useParams: () => ({ organizationId: 'organization-1' }), +})) + const llmProvider: LlmProviderResponse = { id: 'provider-1', name: 'Claude token', @@ -30,9 +34,7 @@ describe('LlmProviderSetting', () => { it('should create a token and select it', () => { const onChange = jest.fn() - renderWithProviders( - - ) + renderWithProviders() expect(screen.getByRole('link', { name: 'Agents → Tokens' })).toHaveAttribute( 'href', diff --git a/libs/domains/organizations/feature/src/lib/llm-provider-setting/llm-provider-setting.tsx b/libs/domains/organizations/feature/src/lib/llm-provider-setting/llm-provider-setting.tsx index a615630919e..7d05f6324b0 100644 --- a/libs/domains/organizations/feature/src/lib/llm-provider-setting/llm-provider-setting.tsx +++ b/libs/domains/organizations/feature/src/lib/llm-provider-setting/llm-provider-setting.tsx @@ -1,3 +1,4 @@ +import { useParams } from '@tanstack/react-router' import { type LlmProviderResponse } from 'qovery-typescript-axios' import { useState } from 'react' import { InputSelect, useModal } from '@qovery/shared/ui' @@ -7,19 +8,12 @@ export interface LlmProviderSettingProps { error?: string isLoading?: boolean llmProviders: LlmProviderResponse[] - organizationId: string value: string onChange: (value: string) => void } -export function LlmProviderSetting({ - error, - isLoading, - llmProviders, - organizationId, - value, - onChange, -}: LlmProviderSettingProps) { +export function LlmProviderSetting({ error, isLoading, llmProviders, value, onChange }: LlmProviderSettingProps) { + const { organizationId = '' } = useParams({ strict: false }) const { openModal, closeModal } = useModal() const [createdLlmProvider, setCreatedLlmProvider] = useState() const availableLlmProviders = diff --git a/libs/domains/service-settings/feature/src/lib/agentic-workflow-settings/agentic-workflow-ai-configuration-settings/agentic-workflow-ai-configuration-settings.spec.tsx b/libs/domains/service-settings/feature/src/lib/agentic-workflow-settings/agentic-workflow-ai-configuration-settings/agentic-workflow-ai-configuration-settings.spec.tsx index eab5d06659f..21f3566b991 100644 --- a/libs/domains/service-settings/feature/src/lib/agentic-workflow-settings/agentic-workflow-ai-configuration-settings/agentic-workflow-ai-configuration-settings.spec.tsx +++ b/libs/domains/service-settings/feature/src/lib/agentic-workflow-settings/agentic-workflow-ai-configuration-settings/agentic-workflow-ai-configuration-settings.spec.tsx @@ -2,18 +2,15 @@ import { renderWithProviders, screen } from '@qovery/shared/util-tests' import { AgenticWorkflowSettingsFormHarness } from '../agentic-workflow-settings-test-utils' import { AgenticWorkflowAiConfigurationSettings } from './agentic-workflow-ai-configuration-settings' +jest.mock('@tanstack/react-router', () => ({ + useParams: () => ({ environmentId: 'environment-1', organizationId: 'organization-1' }), +})) + describe('AgenticWorkflowAiConfigurationSettings', () => { it('renders the token, model settings, and instructions', () => { renderWithProviders( - {(form) => ( - - )} + {(form) => } ) @@ -29,14 +26,7 @@ describe('AgenticWorkflowAiConfigurationSettings', () => { it('does not show an instructions error before the field is modified', () => { renderWithProviders( - {(form) => ( - - )} + {(form) => } ) diff --git a/libs/domains/service-settings/feature/src/lib/agentic-workflow-settings/agentic-workflow-ai-configuration-settings/agentic-workflow-ai-configuration-settings.tsx b/libs/domains/service-settings/feature/src/lib/agentic-workflow-settings/agentic-workflow-ai-configuration-settings/agentic-workflow-ai-configuration-settings.tsx index cc144791ae4..1be7f47eecf 100644 --- a/libs/domains/service-settings/feature/src/lib/agentic-workflow-settings/agentic-workflow-ai-configuration-settings/agentic-workflow-ai-configuration-settings.tsx +++ b/libs/domains/service-settings/feature/src/lib/agentic-workflow-settings/agentic-workflow-ai-configuration-settings/agentic-workflow-ai-configuration-settings.tsx @@ -15,15 +15,11 @@ function getJsonError(value: string) { } export function AgenticWorkflowAiConfigurationSettings({ - environmentId, form, llmProviders, - organizationId, }: { - environmentId: string form: UseFormReturn llmProviders: LlmProviderResponse[] - organizationId: string }) { return ( <> @@ -35,12 +31,7 @@ export function AgenticWorkflowAiConfigurationSettings({ name="llmProviderId" control={form.control} render={({ field }) => ( - + )} /> ( : null} {page === 'ai-configuration' ? ( type === LlmProviderType.CLAUDE && has_credential )} - organizationId={organizationId} /> ) : null} {page === 'connections' ? ( diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx index 0a36750d507..60598cb620d 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx @@ -1052,7 +1052,6 @@ export function AgenticWorkflowConfiguration() {
    variable.variable ?? '').filter(Boolean)} @@ -1083,7 +1082,6 @@ export function AgenticWorkflowConfiguration() { control={form.control} render={({ field }) => ( void prompt: string promptError?: string @@ -18,10 +18,8 @@ export interface AgenticWorkflowPromptEditorProps { export const AgenticWorkflowPromptEditor = forwardRef< AgenticWorkflowPromptEditorHandle, AgenticWorkflowPromptEditorProps ->(function AgenticWorkflowPromptEditor( - { compact = false, environmentId, onPromptChange, prompt, promptError, variableKeys }, - ref -) { +>(function AgenticWorkflowPromptEditor({ compact = false, onPromptChange, prompt, promptError, variableKeys }, ref) { + const { environmentId = '' } = useParams({ strict: false }) const promptRef = useRef(null) const { data: environmentVariables = [] } = useVariables({ parentId: environmentId, scope: 'ENVIRONMENT' }) const suggestions = Array.from(new Set([...variableKeys, ...environmentVariables.map(({ key }) => key)])).map( From b0c35f551c35eff1fc56fcc82b8a6713fb434a96 Mon Sep 17 00:00:00 2001 From: RemiBonnet Date: Wed, 16 Sep 2026 14:47:13 +0200 Subject: [PATCH 7/7] chore(ci): retry pull request checks