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/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 }) => ( + + + )}
{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', - }) + +
+ + +
+
) : ( <>
@@ -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-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: (
- - - - - - handleOpenCreateVariableModal()} icon={}> - Variable - - handleOpenCreateVariableModal(true)} - icon={} - > - Variable as file - - - + + + @@ -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)} + /> + +
+ )} )} - {_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 - +
+ + +
@@ -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) { )} /> -
- } - /> -
-
- - - _onCreateStandaloneVariable()} icon={}> - Variable - - _onCreateStandaloneVariable(true)} - icon={} - > - Variable as file - - - + -
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({ - - - - - - _onCreateVariable()} icon={}> - Variable - - _onCreateVariable(true)} - icon={} - > - Variable as file - - - + + +
) } 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)