diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index a66bb457879..ab52ca5ae5c 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -23,6 +23,9 @@ jobs: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} s3-bucket-name: 'console.qovery.com' + # CloudFront migration (QOV-2108): dual-target until the DNS cutover + s3-bucket-name-with-cloudfront: 'qovery-console-prod' + cloudfront-distribution-id: 'E28PPG1VH7VZ9R' cloudflare-zone: ${{ secrets.CLOUDFLARE_ZONE }} cloudflare-token: ${{ secrets.CLOUDFLARE_TOKEN }} nx-cloud-access-token: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }} diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index b855827ce2c..5174c8b6567 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -23,6 +23,9 @@ jobs: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} s3-bucket-name: 'console-staging.qovery.com' + # CloudFront migration (QOV-2108): dual-target until the DNS cutover + s3-bucket-name-with-cloudfront: 'qovery-console-staging' + cloudfront-distribution-id: 'EN3DKJ81R0NBQ' cloudflare-zone: ${{ secrets.CLOUDFLARE_ZONE }} cloudflare-token: ${{ secrets.CLOUDFLARE_TOKEN }} nx-cloud-access-token: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }} diff --git a/.github/workflows/test-build-and-deploy.yml b/.github/workflows/test-build-and-deploy.yml index 6991fdac216..25e201a8e7c 100644 --- a/.github/workflows/test-build-and-deploy.yml +++ b/.github/workflows/test-build-and-deploy.yml @@ -35,6 +35,12 @@ on: required: false s3-bucket-name: required: false + # CloudFront migration (QOV-2108): origin bucket behind CloudFront. + # When set, builds are also deployed there and index.html is invalidated. + s3-bucket-name-with-cloudfront: + required: false + cloudfront-distribution-id: + required: false cloudflare-zone: required: false cloudflare-token: @@ -208,6 +214,9 @@ jobs: runs-on: ubuntu-latest needs: [nx-main] if: ${{ inputs.flow != 'pull-request' }} + env: + S3_BUCKET_NAME_WITH_CLOUDFRONT: ${{ secrets.s3-bucket-name-with-cloudfront }} + CLOUDFRONT_DISTRIBUTION_ID: ${{ secrets.cloudfront-distribution-id }} steps: - name: Git clone the repository uses: actions/checkout@v4 @@ -222,6 +231,22 @@ jobs: - name: Copy index.html to S3 last run: | AWS_REGION=${{ secrets.aws-region }} AWS_ACCESS_KEY_ID=${{ secrets.aws-access-key-id }} AWS_SECRET_ACCESS_KEY=${{ secrets.aws-secret-access-key }} aws s3 cp ./dist/dist/apps/console/index.html s3://${{ secrets.s3-bucket-name }}/ + # Dual-target during the CloudFront migration (QOV-2108): the bucket + # below is the OAC origin behind CloudFront; the legacy bucket above + # keeps serving the live site until the DNS cutover. Remove the legacy + # steps once the migration is complete. + - name: Copy assets to CloudFront origin bucket + if: env.S3_BUCKET_NAME_WITH_CLOUDFRONT != '' + run: | + AWS_REGION=${{ secrets.aws-region }} AWS_ACCESS_KEY_ID=${{ secrets.aws-access-key-id }} AWS_SECRET_ACCESS_KEY=${{ secrets.aws-secret-access-key }} aws s3 cp ./dist/dist/apps/console/ s3://$S3_BUCKET_NAME_WITH_CLOUDFRONT/ --recursive --exclude "index.html" + - name: Copy index.html to CloudFront origin bucket last + if: env.S3_BUCKET_NAME_WITH_CLOUDFRONT != '' + run: | + AWS_REGION=${{ secrets.aws-region }} AWS_ACCESS_KEY_ID=${{ secrets.aws-access-key-id }} AWS_SECRET_ACCESS_KEY=${{ secrets.aws-secret-access-key }} aws s3 cp ./dist/dist/apps/console/index.html s3://$S3_BUCKET_NAME_WITH_CLOUDFRONT/ + - name: Invalidate CloudFront cached index.html + if: env.CLOUDFRONT_DISTRIBUTION_ID != '' + run: | + AWS_REGION=${{ secrets.aws-region }} AWS_ACCESS_KEY_ID=${{ secrets.aws-access-key-id }} AWS_SECRET_ACCESS_KEY=${{ secrets.aws-secret-access-key }} aws cloudfront create-invalidation --distribution-id $CLOUDFRONT_DISTRIBUTION_ID --paths "/index.html" "/" staging-smoke-test: name: Staging smoke test diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/overview/route.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/overview/route.tsx index d99c028b8b6..57a2a628f12 100644 --- a/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/overview/route.tsx +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/overview/route.tsx @@ -2,6 +2,7 @@ import { type IconName } from '@fortawesome/fontawesome-common-types' import { Outlet, createFileRoute, useMatchRoute } from '@tanstack/react-router' import { Link as RouterLink } from '@tanstack/react-router' import posthog from 'posthog-js' +import { useFeatureFlagEnabled } from 'posthog-js/react' import { useEffect, useMemo } from 'react' import { ClusterAvatar, @@ -22,7 +23,7 @@ import { } from '@qovery/domains/environments/feature' import { useEnvironmentsOverview } from '@qovery/domains/projects/feature' import { isArgoCd, isEditableService } from '@qovery/domains/services/data-access' -import { ArgoCdServiceList, useServices } from '@qovery/domains/services/feature' +import { AgenticWorkflowServiceList, ArgoCdServiceList, useServices } from '@qovery/domains/services/feature' import { Heading, Icon, Link, Navbar, Section, Tooltip } from '@qovery/shared/ui' export const Route = createFileRoute( @@ -44,6 +45,7 @@ function RouteComponent() { const { data: deploymentStatus } = useDeploymentStatus({ environmentId }) const { data: cluster } = useCluster({ organizationId, clusterId: environment?.cluster_id, suspense: true }) const { data: services = [] } = useServices({ environmentId, suspense: true }) + const isAgenticWorkflowEnabled = Boolean(useFeatureFlagEnabled('argentic-workflow')) useClusterRunningStatusSocket({ organizationId, @@ -195,6 +197,9 @@ function RouteComponent() { )} {shouldDisplayArgoCdServicesBelowQovery && } + {isServicesListTab && isAgenticWorkflowEnabled && ( + + )} diff --git a/libs/domains/onboarding/feature/src/lib/step-personalize/personal-email-domains.ts b/libs/domains/onboarding/feature/src/lib/step-personalize/personal-email-domains.ts index f8a6f8cb544..e71c03043a4 100644 --- a/libs/domains/onboarding/feature/src/lib/step-personalize/personal-email-domains.ts +++ b/libs/domains/onboarding/feature/src/lib/step-personalize/personal-email-domains.ts @@ -1235,6 +1235,7 @@ export const PERSONAL_EMAIL_DOMAINS = new Set([ 'globalfree.it', 'globalpagan.com', 'globalsite.com.br', + 'gmail.co', 'gmail.com', 'gmail.com.br', 'gmail.ru', @@ -3722,6 +3723,7 @@ export const PERSONAL_EMAIL_DOMAINS = new Set([ 'promessage.com', 'prontomail.com', 'protestant.com', + 'proton.me', 'protonmail.com', 'prydirect.info', 'psv-supporter.com', diff --git a/libs/domains/onboarding/feature/src/lib/step-personalize/step-personalize.spec.tsx b/libs/domains/onboarding/feature/src/lib/step-personalize/step-personalize.spec.tsx index 492e10cf497..a07301569d1 100644 --- a/libs/domains/onboarding/feature/src/lib/step-personalize/step-personalize.spec.tsx +++ b/libs/domains/onboarding/feature/src/lib/step-personalize/step-personalize.spec.tsx @@ -46,7 +46,7 @@ describe('StepPersonalize', () => { await waitFor(() => expect(continueButton).toBeEnabled()) }) - it('should reject personal email addresses', async () => { + it.each(['gmail.com', 'gmail.co', 'proton.me'])('should reject personal email addresses from %s', async (domain) => { const { userEvent } = renderWithProviders( wrapWithReactHookForm(, { defaultValues: { @@ -61,7 +61,7 @@ describe('StepPersonalize', () => { const continueButton = screen.getByRole('button', { name: 'Continue' }) const emailInput = screen.getByLabelText('Professional email address') - await userEvent.type(emailInput, 'john.doe@gmail.com') + await userEvent.type(emailInput, `john.doe@${domain}`) await userEvent.tab() expect(await screen.findByText('Please enter your professional email address.')).toBeInTheDocument() diff --git a/libs/domains/service-job/feature/src/lib/job-creation-flow/step-variables/step-variables.tsx b/libs/domains/service-job/feature/src/lib/job-creation-flow/step-variables/step-variables.tsx index 3ad84d0f57a..1ac9b10a4b1 100644 --- a/libs/domains/service-job/feature/src/lib/job-creation-flow/step-variables/step-variables.tsx +++ b/libs/domains/service-job/feature/src/lib/job-creation-flow/step-variables/step-variables.tsx @@ -47,10 +47,10 @@ export function StepVariables() { const [variables, setVariables] = useState(methods.getValues().variables) - const onAddPort = () => { + const onAddPort = (isSecret = false) => { const newVariableRow: VariableData = { variable: '', - isSecret: false, + isSecret, value: '', scope: APIVariableScopeEnum.JOB, } diff --git a/libs/domains/services/data-access/src/lib/domains-services-data-access.ts b/libs/domains/services/data-access/src/lib/domains-services-data-access.ts index de59538e01e..f35a54b415a 100644 --- a/libs/domains/services/data-access/src/lib/domains-services-data-access.ts +++ b/libs/domains/services/data-access/src/lib/domains-services-data-access.ts @@ -1,5 +1,7 @@ import { createQueryKeys, type inferQueryKeys } from '@lukemorales/query-key-factory' import { + type AgenticWorkflowResponse, + AgenticWorkflowsApi, ApplicationActionsApi, type ApplicationAdvancedSettings, ApplicationConfigurationApi, @@ -104,6 +106,7 @@ const helmsApi = new HelmsApi() const terraformsApi = new TerraformsApi() const blueprintApi = new BlueprintMainCallsApi() const blueprintCatalogApi = new BlueprintCatalogApi() +const agenticWorkflowsApi = new AgenticWorkflowsApi() const applicationMainCallsApi = new ApplicationMainCallsApi() const containerMainCallsApi = new ContainerMainCallsApi() @@ -238,7 +241,7 @@ export function isArgoCd(service?: AnyService): service is ArgoCd { } export function isEditableService(service: AnyService): service is EditableService { - return !isArgoCd(service) + return !isArgoCd(service) && (service.service_type as string) !== 'AGENTIC_WORKFLOW' } export function isEditableServiceType(serviceType?: ServiceType): serviceType is EditableServiceType { @@ -365,6 +368,13 @@ export const services = createQueryKeys('services', { ) }, }), + listAgenticWorkflows: (environmentId: string) => ({ + queryKey: [environmentId], + async queryFn(): Promise { + const response = await agenticWorkflowsApi.listAgenticWorkflows(environmentId) + return response.data.results ?? [] + }, + }), argocdManifest: (serviceId: string) => ({ queryKey: [serviceId], async queryFn() { diff --git a/libs/domains/services/feature/src/index.ts b/libs/domains/services/feature/src/index.ts index f798adcc252..744892f77ba 100644 --- a/libs/domains/services/feature/src/index.ts +++ b/libs/domains/services/feature/src/index.ts @@ -67,6 +67,8 @@ export * from './lib/hooks/use-recent-services/use-recent-services' export * from './lib/hooks/use-favorite-services/use-favorite-services' export * from './lib/service-actions/service-actions' export * from './lib/argocd-service-list/argocd-service-list' +export * from './lib/agentic-workflow-service-list/agentic-workflow-service-list' +export * from './lib/hooks/use-agentic-workflow-services/use-agentic-workflow-services' export * from './lib/argocd-manifest/argocd-manifest' export * from './lib/service-deployment-status-label/service-deployment-status-label' export * from './lib/service-overview/service-header/service-header' diff --git a/libs/domains/services/feature/src/lib/agentic-workflow-service-list/agentic-workflow-service-list.spec.tsx b/libs/domains/services/feature/src/lib/agentic-workflow-service-list/agentic-workflow-service-list.spec.tsx new file mode 100644 index 00000000000..3d2bf230e10 --- /dev/null +++ b/libs/domains/services/feature/src/lib/agentic-workflow-service-list/agentic-workflow-service-list.spec.tsx @@ -0,0 +1,33 @@ +import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import { AgenticWorkflowServiceList } from './agentic-workflow-service-list' + +const mockUseAgenticWorkflowServices = jest.fn() + +jest.mock('../hooks/use-agentic-workflow-services/use-agentic-workflow-services', () => ({ + useAgenticWorkflowServices: () => mockUseAgenticWorkflowServices(), +})) + +describe('AgenticWorkflowServiceList', () => { + it('should not render an empty section', () => { + mockUseAgenticWorkflowServices.mockReturnValue({ data: [] }) + const { container } = renderWithProviders() + + expect(container).toBeEmptyDOMElement() + }) + + it('should render agentic workflows in their own section', () => { + mockUseAgenticWorkflowServices.mockReturnValue({ + data: [ + { id: 'workflow-1', name: 'Review pull requests', enabled: true, model: { type: 'CLAUDE' } }, + { id: 'workflow-2', name: 'Triage incidents', enabled: false, model: { type: 'OPENAI' } }, + ], + }) + renderWithProviders() + + expect(screen.getByRole('heading', { name: 'Agentic workflows' })).toBeInTheDocument() + expect(screen.getByText('Review pull requests')).toBeInTheDocument() + expect(screen.getByText('Triage incidents')).toBeInTheDocument() + expect(screen.getByText('1 enabled')).toBeInTheDocument() + expect(screen.getByText('CLAUDE')).toBeInTheDocument() + }) +}) diff --git a/libs/domains/services/feature/src/lib/agentic-workflow-service-list/agentic-workflow-service-list.tsx b/libs/domains/services/feature/src/lib/agentic-workflow-service-list/agentic-workflow-service-list.tsx new file mode 100644 index 00000000000..5277d0b36fa --- /dev/null +++ b/libs/domains/services/feature/src/lib/agentic-workflow-service-list/agentic-workflow-service-list.tsx @@ -0,0 +1,80 @@ +import { type AgenticWorkflowResponse } from 'qovery-typescript-axios' +import { Badge, Heading, Icon, Section, TablePrimitives, Tooltip } from '@qovery/shared/ui' +import { useAgenticWorkflowServices } from '../hooks/use-agentic-workflow-services/use-agentic-workflow-services' + +const { Table } = TablePrimitives + +const tableGridLayoutClassName = 'grid w-full grid-cols-[minmax(350px,1fr)_minmax(180px,260px)_140px]' + +export interface AgenticWorkflowServiceListProps { + environmentId: string +} + +export function AgenticWorkflowServiceList({ environmentId }: AgenticWorkflowServiceListProps) { + const { data: services = [] } = useAgenticWorkflowServices({ environmentId, suspense: true }) + + if (services.length === 0) return null + + const enabledServicesCount = services.filter(({ enabled }) => enabled).length + + return ( + + + + Agentic workflows + + AI workflows triggered through webhooks and connected services. + + + + + + {enabledServicesCount} enabled + + + + + + + Service + + + Model + + + Status + + + + + {services.map((service: AgenticWorkflowResponse) => ( + + + + + + {service.name} + + + + + {service.model?.type ?? '-'} + + + + {service.enabled ? 'Enabled' : 'Disabled'} + + + + ))} + + + + + ) +} + +export default AgenticWorkflowServiceList diff --git a/libs/domains/services/feature/src/lib/hooks/use-agentic-workflow-services/use-agentic-workflow-services.ts b/libs/domains/services/feature/src/lib/hooks/use-agentic-workflow-services/use-agentic-workflow-services.ts new file mode 100644 index 00000000000..f712ad971e8 --- /dev/null +++ b/libs/domains/services/feature/src/lib/hooks/use-agentic-workflow-services/use-agentic-workflow-services.ts @@ -0,0 +1,16 @@ +import { useQuery } from '@tanstack/react-query' +import { queries } from '@qovery/state/util-queries' + +export interface UseAgenticWorkflowServicesProps { + environmentId: string + suspense?: boolean +} + +export function useAgenticWorkflowServices({ environmentId, suspense = false }: UseAgenticWorkflowServicesProps) { + return useQuery({ + ...queries.services.listAgenticWorkflows(environmentId), + suspense, + }) +} + +export default useAgenticWorkflowServices diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-variables/step-variables/step-variables.spec.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-variables/step-variables/step-variables.spec.tsx index ced31df16d4..1df02555b3b 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-variables/step-variables/step-variables.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-variables/step-variables/step-variables.spec.tsx @@ -40,7 +40,7 @@ jest.mock('@qovery/domains/variables/feature', () => ({ VariableFormModal: ({ onSubmit, scope, - isFile, + isSecret, }: { onSubmit?: (data: { key: string @@ -51,18 +51,17 @@ jest.mock('@qovery/domains/variables/feature', () => ({ mountPath?: string }) => void scope: 'APPLICATION' | 'CONTAINER' - isFile?: boolean + isSecret?: boolean }) => ( onSubmit?.({ - key: isFile ? 'CONFIG_FILE' : 'NODE_ENV', - value: isFile ? '{"key":"value"}' : 'production', + key: 'NODE_ENV', + value: 'production', scope, - isSecret: false, - isFile: !!isFile, - mountPath: isFile ? '/vault/secrets/config-file' : undefined, + isSecret: !!isSecret, + isFile: false, }) } > @@ -113,6 +112,28 @@ describe('ApplicationContainerStepVariables', () => { }) }) + it('adds a secret with application scope by default', async () => { + const { userEvent } = renderWithProviders( + + <> + + + > + + ) + + await userEvent.click(screen.getByRole('button', { name: /^add secret$/i })) + await userEvent.click(screen.getByRole('button', { name: /confirm variable modal/i })) + + await waitFor(() => { + expect(screen.getByTestId('variables-state')).toHaveTextContent('"scope":"APPLICATION"') + expect(screen.getByTestId('variables-state')).toHaveTextContent('"isSecret":true') + }) + }) + it('calls onBack when going back', async () => { const { userEvent } = renderWithProviders( { ) expect(screen.getByText('No secret manager linked on your cluster')).toBeInTheDocument() - expect(screen.queryByRole('button', { name: /^add secret$/i })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: /^add secret as file$/i })).not.toBeInTheDocument() }) }) diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-variables/step-variables/step-variables.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-variables/step-variables/step-variables.tsx index b486bf1217d..a9a5649d818 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-variables/step-variables/step-variables.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-variables/step-variables/step-variables.tsx @@ -175,7 +175,13 @@ export function ApplicationContainerStepVariables({ onBack, onSubmit }: Applicat setCurrentStep(5) }, [setCurrentStep]) - const openVariableModal = ({ isFile, index }: { isFile: boolean; index?: number }) => { + const openVariableModal = ({ + isSecret = false, + index, + }: { + isSecret?: boolean + index?: number + } = {}) => { const currentVariable = typeof index === 'number' ? variables[index] : undefined const mode = typeof index === 'number' ? 'UPDATE' : 'CREATE' @@ -185,7 +191,6 @@ export function ApplicationContainerStepVariables({ onBack, onSubmit }: Applicat closeModal={closeModal} mode={mode} type={currentVariable?.file ? APIVariableTypeEnum.FILE : APIVariableTypeEnum.VALUE} - isFile={isFile} variable={ typeof index === 'number' && currentVariable ? mapVariableToModalVariable(currentVariable, index, serviceScope) @@ -200,6 +205,7 @@ export function ApplicationContainerStepVariables({ onBack, onSubmit }: Applicat } }} scope={serviceScope} + isSecret={typeof index === 'number' ? currentVariable?.isSecret : isSecret} projectId={projectId} environmentId={environmentId} /> @@ -258,26 +264,68 @@ export function ApplicationContainerStepVariables({ onBack, onSubmit }: Applicat Custom variables - {variables.length > 0 && - cardHeaderActions({ - onAddDefault: () => openVariableModal({ isFile: false }), - onAddAsFile: () => openVariableModal({ isFile: true }), - defaultLabel: 'Add variable', - asFileLabel: 'Add variable as file', - })} + {variables.length > 0 && ( + + openVariableModal({ isSecret: true })} + > + + Add secret + + openVariableModal()} + > + + Add variable + + + )} {variables.length === 0 ? ( - emptyState({ - title: 'No custom variables added yet', - onAddDefault: () => openVariableModal({ isFile: false }), - onAddAsFile: () => openVariableModal({ isFile: true }), - defaultLabel: 'Add variable', - asFileLabel: 'Add variable as file', - }) + + + openVariableModal()} + > + + Add variable + + openVariableModal({ isSecret: true })} + > + + Add secret + + + ) : ( <> @@ -342,7 +390,7 @@ export function ApplicationContainerStepVariables({ onBack, onSubmit }: Applicat size="xs" iconOnly disabled={variable.isReadOnly} - onClick={() => openVariableModal({ isFile: !!variable.file, index })} + onClick={() => openVariableModal({ index })} > diff --git a/libs/domains/services/feature/src/lib/service-list/service-list.spec.tsx b/libs/domains/services/feature/src/lib/service-list/service-list.spec.tsx index 58e52241fe8..f67bda65b57 100644 --- a/libs/domains/services/feature/src/lib/service-list/service-list.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-list/service-list.spec.tsx @@ -469,6 +469,24 @@ describe('ServiceList', () => { expect(screen.queryByText('ARGOCD APP')).not.toBeInTheDocument() }) + it('should not pass agentic workflows to the standard service table', () => { + mockServicesData = [ + { + id: 'agentic-workflow-id', + name: 'Review pull requests', + service_type: 'AGENTIC_WORKFLOW', + serviceType: 'AGENTIC_WORKFLOW', + enabled: true, + model: { type: 'CLAUDE' }, + }, + ] + + renderWithProviders() + + expect(screen.getByText('No service found')).toBeInTheDocument() + expect(screen.queryByText('Review pull requests')).not.toBeInTheDocument() + }) + it('should navigate to service on row click', async () => { const { userEvent } = renderWithProviders() const rows = screen.getAllByRole('row') diff --git a/libs/domains/services/feature/src/lib/service-variables-tabs/service-variables-custom-tab.tsx b/libs/domains/services/feature/src/lib/service-variables-tabs/service-variables-custom-tab.tsx index fa061ad9453..d2fce29e1a6 100644 --- a/libs/domains/services/feature/src/lib/service-variables-tabs/service-variables-custom-tab.tsx +++ b/libs/domains/services/feature/src/lib/service-variables-tabs/service-variables-custom-tab.tsx @@ -42,7 +42,7 @@ export function ServiceVariablesCustomTab() { return null } - const handleOpenCreateVariableModal = (isFile = false) => + const handleOpenCreateVariableModal = (isSecret = false) => openModal({ content: ( - - - - - Add variable - - - - - handleOpenCreateVariableModal()} icon={}> - Variable - - handleOpenCreateVariableModal(true)} - icon={} - > - Variable as file - - - + handleOpenCreateVariableModal()}> + + Add variable + + + handleOpenCreateVariableModal(true)}> + + Add secret + @@ -122,12 +111,6 @@ export function ServiceVariablesCustomTab() { }> Import from .env file - window.open('https://dashboard.doppler.com', '_blank')} - icon={} - > - Import from Doppler - diff --git a/libs/domains/variables/feature/src/lib/create-update-variable-modal/create-update-variable-modal.spec.tsx b/libs/domains/variables/feature/src/lib/create-update-variable-modal/create-update-variable-modal.spec.tsx index 48210cb1606..9f23681d17c 100644 --- a/libs/domains/variables/feature/src/lib/create-update-variable-modal/create-update-variable-modal.spec.tsx +++ b/libs/domains/variables/feature/src/lib/create-update-variable-modal/create-update-variable-modal.spec.tsx @@ -1,7 +1,7 @@ import { type VariableResponse } from 'qovery-typescript-axios' import { type ReactNode } from 'react' -import { renderWithProviders, screen } from '@qovery/shared/util-tests' -import { CreateUpdateVariableModal } from './create-update-variable-modal' +import { renderWithProviders, screen, waitFor } from '@qovery/shared/util-tests' +import { CreateUpdateVariableModal, VariableFormModal } from './create-update-variable-modal' jest.mock('@qovery/shared/ui', () => { const actual = jest.requireActual('@qovery/shared/ui') @@ -97,6 +97,89 @@ describe('CreateUpdateVariableModal', () => { expect(descriptionField).not.toBeInstanceOf(HTMLTextAreaElement) }) + it('should render secret creation without the secret toggle and with hidden value control', () => { + renderWithProviders() + + expect(screen.getByText('New secret')).toBeInTheDocument() + expect(screen.queryByText('Secret variable')).not.toBeInTheDocument() + expect(screen.getByRole('checkbox', { name: /show value/i })).toBeInTheDocument() + }) + + it('should default to value format and show file inputs when selecting as file', async () => { + const { userEvent } = renderWithProviders() + + expect(screen.getByRole('radio', { name: /value/i })).toHaveAttribute('data-state', 'on') + expect(screen.queryByLabelText('Path')).not.toBeInTheDocument() + + await userEvent.click(screen.getByRole('radio', { name: /as file/i })) + + const pathField = screen.getByLabelText('Path') + + expect(screen.getByText('New variable file')).toBeInTheDocument() + expect(pathField).not.toHaveAttribute('placeholder') + expect(pathField).toHaveValue('') + expect(screen.getByText('Variable interpolation')).toBeInTheDocument() + + await userEvent.click(pathField) + + expect(pathField).toHaveValue('/') + }) + + it('should render secret file edit with hidden value control', async () => { + const { userEvent } = renderWithProviders( + + ) + + const valueField = screen.getByLabelText('Value') + + expect(screen.getByText('Edit secret file')).toBeInTheDocument() + expect(screen.getByRole('checkbox', { name: /show value/i })).toBeInTheDocument() + expect(valueField).toHaveClass('[-webkit-text-security:disc]') + + await userEvent.click(screen.getByRole('checkbox', { name: /show value/i })) + + expect(valueField).not.toHaveClass('[-webkit-text-security:disc]') + }) + + it('should submit a secret created as a file with the selected format', async () => { + const onSubmit = jest.fn() + const { userEvent } = renderWithProviders( + + ) + + await userEvent.click(screen.getByRole('radio', { name: /as file/i })) + await userEvent.type(screen.getByLabelText('Variable'), 'MY_SECRET') + await userEvent.click(screen.getByLabelText('Path')) + await userEvent.type(screen.getByLabelText('Path'), 'vault/secrets/my-secret') + await userEvent.type(screen.getByLabelText('Value'), 'secret-value') + await userEvent.click(screen.getByRole('button', { name: /confirm/i })) + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + key: 'MY_SECRET', + value: 'secret-value', + scope: 'ENVIRONMENT', + isSecret: true, + isFile: true, + mountPath: '/vault/secrets/my-secret', + }) + ) + }) + }) + it('should not render the open editor button for aliases', () => { renderWithProviders() diff --git a/libs/domains/variables/feature/src/lib/create-update-variable-modal/create-update-variable-modal.tsx b/libs/domains/variables/feature/src/lib/create-update-variable-modal/create-update-variable-modal.tsx index f9f335a1ab5..e98889bc127 100644 --- a/libs/domains/variables/feature/src/lib/create-update-variable-modal/create-update-variable-modal.tsx +++ b/libs/domains/variables/feature/src/lib/create-update-variable-modal/create-update-variable-modal.tsx @@ -1,16 +1,18 @@ import { type APIVariableScopeEnum, type APIVariableTypeEnum, type VariableResponse } from 'qovery-typescript-axios' -import { useRef, useState } from 'react' +import { useId, useRef, useState } from 'react' import { Controller, FormProvider, useForm } from 'react-hook-form' import { match } from 'ts-pattern' import { Button, Callout, + Checkbox, Icon, InputSelect, InputText, InputTextArea, InputToggle, ModalCrud, + SegmentedControl, Tooltip, useModal, } from '@qovery/shared/ui' @@ -87,7 +89,7 @@ export type VariableFormModalProps = { variable?: VariableResponse mode: 'CREATE' | 'UPDATE' type: keyof typeof APIVariableTypeEnum - isFile?: boolean + isSecret?: boolean hasClusterSecretManagerConfigured?: boolean scope: Scope projectId?: string @@ -104,12 +106,18 @@ export function VariableFormModal(props: VariableFormModalProps) { variable, mode, type, - isFile, + isSecret, hasClusterSecretManagerConfigured = false, } = props - const _isFile = (variable && environmentVariableFile(variable)) || (isFile ?? false) + const isCreateValue = mode === 'CREATE' && type === 'VALUE' + const [isFileVariable, setIsFileVariable] = useState(() => + Boolean(type === 'FILE' || (variable && environmentVariableFile(variable))) + ) const { enableAlertClickOutside } = useModal() const [isValueEditorOpen, setIsValueEditorOpen] = useState(false) + const [showSecretValue, setShowSecretValue] = useState(false) + const showSecretValueId = useId() + const isSecretVariable = isCreateValue ? Boolean(isSecret) : Boolean(variable?.is_secret) const textareaRef = useRef(null) @@ -140,7 +148,7 @@ export function VariableFormModal(props: VariableFormModalProps) { const defaultScope = // Check if it's a file and the scope is one of services and assign the default scope to 'ENVIRONMENT' - isFile && ['APPLICATION', 'CONTAINER', 'JOB', 'HELM'].includes(scope) + isFileVariable && ['APPLICATION', 'CONTAINER', 'JOB', 'HELM'].includes(scope) ? 'ENVIRONMENT' : variable?.scope === 'BUILT_IN' ? undefined @@ -165,19 +173,36 @@ export function VariableFormModal(props: VariableFormModalProps) { key: variable?.key, scope: defaultScope, value: variable?.value, - isSecret: variable?.is_secret, + isSecret: isSecretVariable, description: variable?.description, - enable_interpolation_in_file: _isFile ? variable?.enable_interpolation_in_file ?? true : undefined, + enable_interpolation_in_file: isFileVariable ? variable?.enable_interpolation_in_file ?? true : undefined, mountPath, }, mode: 'onChange', }) + const handleVariableFormatChange = (isFileSelected: boolean) => { + setIsFileVariable(isFileSelected) + + if (!isFileSelected) { + return + } + + if (methods.getValues('enable_interpolation_in_file') === undefined) { + methods.setValue('enable_interpolation_in_file', true) + } + + const currentScope = methods.getValues('scope') + + if (currentScope && ['APPLICATION', 'CONTAINER', 'JOB', 'HELM'].includes(currentScope)) { + methods.setValue('scope', 'ENVIRONMENT', { shouldValidate: true, shouldDirty: true }) + } + } + methods.watch(() => enableAlertClickOutside(methods.formState.isDirty)) const watchScope = methods.watch('scope') - const watchIsSecret = methods.watch('isSecret') const watchMountPath = methods.watch('mountPath') - const valueEditorLanguage = getValueEditorLanguage({ isFile: _isFile, mountPath: watchMountPath }) + const valueEditorLanguage = getValueEditorLanguage({ isFile: isFileVariable, mountPath: watchMountPath }) const valueEditorServiceId = isValueEditorScope(watchScope) ? props.serviceId : undefined const valueEditorScope = isValueEditorScope(watchScope) ? watchScope : undefined @@ -187,14 +212,17 @@ export function VariableFormModal(props: VariableFormModalProps) { // allow empty variable value if (!cloneData.value) cloneData.value = '' - if (!_isFile) { + if (!isFileVariable) { delete cloneData.mountPath + delete cloneData.enable_interpolation_in_file + } else if (cloneData.enable_interpolation_in_file === undefined) { + cloneData.enable_interpolation_in_file = true } try { await onSubmit({ ...cloneData, - isFile: _isFile, + isFile: isFileVariable, }) closeModal() } catch (e) { @@ -211,19 +239,28 @@ export function VariableFormModal(props: VariableFormModalProps) { title = 'Create ' + (type === 'ALIAS' ? 'alias' : type === 'OVERRIDE' ? 'override' : '') } - title += ' variable' + (_isFile ? ' file' : '') + if (isCreateValue) { + title += isSecretVariable ? ' secret' : ' variable' + } else if (mode === 'UPDATE' && type !== 'ALIAS' && type !== 'OVERRIDE') { + title += isSecretVariable ? ' secret' : ' variable' + } else { + title += ' variable' + } + + title += isFileVariable ? ' file' : '' - const description = match({ type, _isFile }) + const description = match({ type, isFileVariable }) .with({ type: 'ALIAS' }, () => 'Aliases allow you to specify a different name for a variable on a specific scope.') .with({ type: 'OVERRIDE' }, () => 'Overrides allow you to define a different env var value on a specific scope.') .with( - { _isFile: true }, + { isFileVariable: true }, () => 'The content of the Value field will be mounted as a file in the specified "Path". Accessing the environment variable at runtime will return the "Path" of the file.' ) - .otherwise( - () => - "Environment variables can be accessed at both build and run time. Set them as ARGS in your Dockerfile to use them during build processes. At runtime, they're available to your application automatically. Secrets value can only be accessed by your application." + .otherwise(() => + isSecretVariable + ? 'Secrets can only be accessed by your application at runtime. Use them for sensitive values such as tokens, credentials, and private configuration.' + : "Environment variables can be accessed at both build and run time. Set them as ARGS in your Dockerfile to use them during build processes. At runtime, they're available to your application automatically." ) return ( @@ -236,6 +273,18 @@ export function VariableFormModal(props: VariableFormModalProps) { onSubmit={_onSubmit} loading={loading} > + {isCreateValue && ( + handleVariableFormatChange(value === 'FILE')} + > + Value + As file + + )} + {type === 'ALIAS' || type === 'OVERRIDE' ? ( ) : ( @@ -258,7 +307,7 @@ export function VariableFormModal(props: VariableFormModalProps) { /> )} - {_isFile && + {isFileVariable && (type === 'ALIAS' || type === 'OVERRIDE' || mode === 'UPDATE' ? ( ) : ( @@ -269,14 +318,22 @@ export function VariableFormModal(props: VariableFormModalProps) { required: 'Please enter a mount path.', }} render={({ field, fieldState: { error } }) => ( - + { + if (!field.value) { + field.onChange('/') + } + }} + > + + )} /> ))} @@ -342,12 +399,13 @@ export function VariableFormModal(props: VariableFormModalProps) { {props.environmentId && ( )} + {isSecretVariable && ( + + setShowSecretValue(checked === true)} + /> + + Show value + + + )} )} - {_isFile && ( + {isFileVariable && ( - {mode === 'CREATE' && type === 'VALUE' && ( - - ( - - )} - /> - - )} - - {mode === 'CREATE' && type === 'VALUE' && watchIsSecret && hasClusterSecretManagerConfigured && ( + {isCreateValue && isSecretVariable && hasClusterSecretManagerConfigured && ( @@ -506,7 +559,7 @@ export type CreateUpdateVariableModalProps = { variable?: VariableResponse mode: 'CREATE' | 'UPDATE' type: keyof typeof APIVariableTypeEnum - isFile?: boolean + isSecret?: boolean hasClusterSecretManagerConfigured?: boolean } & CreateUpdateVariableModalScopeProps diff --git a/libs/domains/variables/feature/src/lib/flow-create-variable/flow-create-variable.tsx b/libs/domains/variables/feature/src/lib/flow-create-variable/flow-create-variable.tsx index 2d9d43be2e6..32224dd3d5d 100644 --- a/libs/domains/variables/feature/src/lib/flow-create-variable/flow-create-variable.tsx +++ b/libs/domains/variables/feature/src/lib/flow-create-variable/flow-create-variable.tsx @@ -10,7 +10,7 @@ import VariableRow from './variable-row/variable-row' export interface FlowCreateVariableProps { onBack: () => void onSubmit: FormEventHandler - onAdd: () => void + onAdd: (isSecret?: boolean) => void onRemove: (index: number) => void variables: VariableData[] availableScopes: APIVariableScopeEnum[] @@ -27,16 +27,22 @@ export function FlowCreateVariable({ availableScopes, }: FlowCreateVariableProps) { const { formState } = useFormContext() - const gridTemplateColumns = '1fr 1fr 1fr 56px 32px' + const gridTemplateColumns = '1fr 1fr 1fr 32px' return ( Environment variables - - Add Variable - - + + onAdd(true)}> + + Add secret + + onAdd(false)}> + + Add variable + + @@ -71,7 +77,6 @@ export function FlowCreateVariable({ Variable Value Scope - Secret )} diff --git a/libs/domains/variables/feature/src/lib/flow-create-variable/variable-row/variable-row.tsx b/libs/domains/variables/feature/src/lib/flow-create-variable/variable-row/variable-row.tsx index 062aa5f0b8e..142ac84c9a0 100644 --- a/libs/domains/variables/feature/src/lib/flow-create-variable/variable-row/variable-row.tsx +++ b/libs/domains/variables/feature/src/lib/flow-create-variable/variable-row/variable-row.tsx @@ -3,7 +3,7 @@ import { type APIVariableScopeEnum } from 'qovery-typescript-axios' import { useState } from 'react' import { Controller, useFormContext } from 'react-hook-form' import { type FlowVariableData } from '@qovery/shared/interfaces' -import { BlockContent, Button, Icon, InputSelectSmall, InputTextSmall, InputToggle, Tooltip } from '@qovery/shared/ui' +import { BlockContent, Button, Icon, InputSelectSmall, InputTextSmall, Tooltip } from '@qovery/shared/ui' import { generateScopeLabel } from '@qovery/shared/util-js' import { CodeEditorVariable } from '../../code-editor-variable/code-editor-variable' import { FieldVariableSuggestion } from '../../field-variable-suggestion/field-variable-suggestion' @@ -142,14 +142,6 @@ export function VariableRow(props: VariableRowProps) { )} /> - - } - /> - - props.onDelete(index)}> diff --git a/libs/domains/variables/feature/src/lib/variable-list/variable-list.tsx b/libs/domains/variables/feature/src/lib/variable-list/variable-list.tsx index 69494c61bfa..c31616410be 100644 --- a/libs/domains/variables/feature/src/lib/variable-list/variable-list.tsx +++ b/libs/domains/variables/feature/src/lib/variable-list/variable-list.tsx @@ -172,7 +172,7 @@ export function VariableList({ }) } - const _onCreateStandaloneVariable = (isFile = false) => + const _onCreateStandaloneVariable = (isSecret = false) => openModal({ content: ( ), @@ -859,36 +859,14 @@ export function VariableList({ className="rounded-none border-0 bg-transparent py-12" > - - - - - Add variable - - - - - _onCreateStandaloneVariable()} icon={}> - Variable - - _onCreateStandaloneVariable(true)} - icon={} - > - Variable as file - - - + _onCreateStandaloneVariable()}> + + Add variable + - window.open('https://dashboard.doppler.com', '_blank')} - > - - Import from Doppler + _onCreateStandaloneVariable(true)}> + + Add secret diff --git a/libs/domains/variables/feature/src/lib/variables-action-toolbar/variables-action-toolbar.tsx b/libs/domains/variables/feature/src/lib/variables-action-toolbar/variables-action-toolbar.tsx index 4ab6dfd583a..d2dc95baee9 100644 --- a/libs/domains/variables/feature/src/lib/variables-action-toolbar/variables-action-toolbar.tsx +++ b/libs/domains/variables/feature/src/lib/variables-action-toolbar/variables-action-toolbar.tsx @@ -40,7 +40,7 @@ export function VariablesActionToolbar({ const hasImportEnvFile = Boolean(onImportEnvFile) const showImportButton = hasImportEnvFile && importEnvFileAccess === 'button' - const _onCreateVariable = (isFile?: boolean) => + const _onCreateVariable = (isSecret = false) => openModal({ content: ( @@ -106,25 +106,15 @@ export function VariablesActionToolbar({ - - - - - New variable - - - - _onCreateVariable()} icon={}> - Variable - - _onCreateVariable(true)} - icon={} - > - Variable as file - - - + _onCreateVariable(true)}> + + Add secret + + + _onCreateVariable()}> + + Add variable + ) } diff --git a/libs/shared/ui/src/lib/components/inputs/input-text-area/input-text-area.tsx b/libs/shared/ui/src/lib/components/inputs/input-text-area/input-text-area.tsx index e292acac426..1173ae8805e 100644 --- a/libs/shared/ui/src/lib/components/inputs/input-text-area/input-text-area.tsx +++ b/libs/shared/ui/src/lib/components/inputs/input-text-area/input-text-area.tsx @@ -11,10 +11,11 @@ export interface InputTextAreaProps { hint?: ReactNode error?: string dataTestId?: string + maskValue?: boolean } export const InputTextArea = forwardRef(function InputTextArea(props, ref) { - const { label, value = '', name, onChange, className, hint, error, dataTestId = 'input-textarea' } = props + const { label, value = '', name, onChange, className, hint, error, dataTestId = 'input-textarea', maskValue } = props const [currentValue, setCurrentValue] = useState(value) const previousValueRef = useRef(value) @@ -71,7 +72,10 @@ export const InputTextArea = forwardRef ref={ref} name={name} id={label} - className="mt-5 min-h-[52px] w-full appearance-none bg-transparent pr-3 text-sm text-neutral outline-0" + className={twMerge( + 'mt-5 min-h-[52px] w-full appearance-none bg-transparent pr-3 text-sm text-neutral outline-0', + maskValue && '[-webkit-text-security:disc]' + )} value={!currentValue ? undefined : currentValue} onChange={(e) => { if (onChange) onChange(e)
AI workflows triggered through webhooks and connected services.
Custom variables