From 9575fea221d94b68b59e95b81f8b5c910f5e867d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Mon, 12 Oct 2026 14:57:06 +0200 Subject: [PATCH 01/17] feat(variables): add JSON external secret UI --- .../add-secret-modal.spec.tsx | 87 ++++++ .../add-secret-modal/add-secret-modal.tsx | 138 ++++++--- .../external-secret-json-preview.ts | 72 +++++ .../external-secrets-tab.spec.tsx | 157 +++++++++- .../external-secrets/external-secrets-tab.tsx | 274 ++++++++++++++---- .../lib/external-secrets/json-secret-keys.tsx | 26 ++ 6 files changed, 662 insertions(+), 92 deletions(-) create mode 100644 libs/domains/variables/feature/src/lib/external-secrets/external-secret-json-preview.ts create mode 100644 libs/domains/variables/feature/src/lib/external-secrets/json-secret-keys.tsx diff --git a/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.spec.tsx b/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.spec.tsx index b8448053369..1d2a27858f1 100644 --- a/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.spec.tsx +++ b/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.spec.tsx @@ -88,4 +88,91 @@ describe('AddSecretModal', () => { ) }) }) + it('submits a JSON display name separately from its reference', async () => { + const onSubmit = jest.fn() + const { userEvent } = renderWithProviders( + + ) + expect(screen.getByText('Add secret as JSON')).toBeInTheDocument() + expect(screen.getByText('Display name only. Use the JSON keys in your code.')).toBeInTheDocument() + expect(screen.queryByLabelText('Path')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Add secret' })).toBeDisabled() + await userEvent.type(screen.getByLabelText('Reference'), 'lucis/production/application') + expect(screen.getByRole('button', { name: 'Add secret' })).toBeDisabled() + await userEvent.type(screen.getByLabelText('Display name'), 'Application credentials') + await userEvent.click(screen.getByRole('button', { name: 'Add secret' })) + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Application credentials', + reference: 'lucis/production/application', + isJson: true, + keys: ['DATABASE_URL', 'API_KEY'], + isFile: false, + filePath: undefined, + }) + ) + ) + }) + + it('keeps the JSON edit form open if saving fails', async () => { + const onClose = jest.fn() + const onSubmit = jest.fn().mockRejectedValue(new Error('Could not save')) + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined) + const { userEvent } = renderWithProviders( + + ) + await userEvent.type(screen.getByLabelText('Reference'), '-updated') + await userEvent.click(screen.getByRole('button', { name: 'Edit secret' })) + await waitFor(() => expect(onSubmit).toHaveBeenCalled()) + expect(onClose).not.toHaveBeenCalled() + consoleError.mockRestore() + }) + it('blocks submission without JSON keys and clears the preview when the reference changes', async () => { + const onSubmit = jest.fn() + const { userEvent } = renderWithProviders( + + ) + await userEvent.type(screen.getByLabelText('Display name'), 'My credentials') + await userEvent.type(screen.getByLabelText('Reference'), 'not-json') + expect(screen.getByText('No JSON detected')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Add secret' })).toBeDisabled() + await userEvent.keyboard('{Enter}') + expect(onSubmit).not.toHaveBeenCalled() + await userEvent.clear(screen.getByLabelText('Reference')) + await userEvent.type(screen.getByLabelText('Reference'), 'valid') + expect(screen.getByRole('button', { name: 'Copy API_KEY' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Add secret' })).toBeEnabled() + await userEvent.type(screen.getByLabelText('Reference'), '-unknown') + expect(screen.queryByRole('button', { name: 'Copy API_KEY' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Add secret' })).toBeDisabled() + }) }) diff --git a/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.tsx b/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.tsx index 218069b6302..a59dcf55964 100644 --- a/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.tsx +++ b/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.tsx @@ -6,8 +6,18 @@ import { match } from 'ts-pattern' import { getSecretManagerProvider } from '@qovery/domains/clusters/data-access' import { useSecretManagerProviderSecrets } from '@qovery/domains/clusters/feature' import { type VariableScope } from '@qovery/domains/variables/data-access' -import { Checkbox, Icon, InputSelect, InputText, InputTextArea, ModalCrud, useModal } from '@qovery/shared/ui' +import { + Checkbox, + EmptyState, + Icon, + InputSelect, + InputText, + InputTextArea, + ModalCrud, + useModal, +} from '@qovery/shared/ui' import { useDebounce } from '@qovery/shared/util-hooks' +import { JsonSecretKeys } from '../json-secret-keys' export type SecretSourceOption = { value: string @@ -33,6 +43,8 @@ export type AddSecretModalSubmitData = { description?: string filePath?: string isFile: boolean + isJson?: boolean + keys?: string[] reference: string secretManagerAccessId: string } @@ -45,9 +57,11 @@ export type AddSecretModalInitialSecret = { } interface AddSecretModalProps { + jsonSecrets?: { reference: string; secretManagerAccessId?: string; keys?: string[] }[] secretSources: SecretSourceOption[] defaultSource?: SecretSourceOption isFile?: boolean + isJson?: boolean mode?: 'create' | 'edit' initialSecret?: AddSecretModalInitialSecret scope?: VariableScope @@ -66,8 +80,10 @@ type AddSecretFormValues = { export function AddSecretModal({ secretSources, + jsonSecrets, defaultSource, isFile = false, + isJson = false, mode = 'create', initialSecret, scope, @@ -95,6 +111,12 @@ export function AddSecretModal({ const secretNameValue = methods.watch('secretName') const selectedSourceId = methods.watch('source') + const selectedReference = methods.watch('reference') + // TODO(secret-manager-json): Replace fixture lookup with key-only API metadata; never fetch secret values. + const jsonKeys = jsonSecrets?.find( + (secret) => secret.secretManagerAccessId === selectedSourceId && secret.reference === selectedReference.trim() + )?.keys + const hasJsonKeys = Boolean(jsonKeys?.length) const { data: providerSecrets, @@ -128,16 +150,25 @@ export function AddSecretModal({ const referenceOptions = useMemo( () => - (providerSecrets?.results ?? []).map((secret) => ({ + [ + ...new Set([ + ...(providerSecrets?.results ?? []), + ...(isJson + ? (jsonSecrets ?? []) + .filter((secret) => secret.secretManagerAccessId === selectedSourceId) + .map((secret) => secret.reference) + : []), + ]), + ].map((secret) => ({ label: secret, value: secret, })), - [providerSecrets?.results] + [providerSecrets?.results, isJson, jsonSecrets, selectedSourceId] ) const handleSubmit = methods.handleSubmit(async (data) => { const finalReference = (data.reference || referenceInput).trim() - if (!finalReference) return + if (!finalReference || (isJson && !hasJsonKeys)) return const finalName = data.secretName.trim() || finalReference.split('/').pop()?.toUpperCase() || 'NEW_SECRET' const selectedSource = secretSources.find((option) => option.value === data.source) ?? secretSources[0] @@ -153,23 +184,26 @@ export function AddSecretModal({ description: descriptionValue ? descriptionValue : undefined, filePath: isFile ? finalPath : undefined, isFile, + ...(isJson ? { isJson: true, keys: jsonKeys } : {}), reference: finalReference, secretManagerAccessId: selectedSource.value, }) - posthog.capture('external-secret-form-submitted', { - mode, - scope, - is_file: isFile, - success: true, - }) + if (!isJson) + posthog.capture('external-secret-form-submitted', { + mode, + scope, + is_file: isFile, + success: true, + }) onClose() } catch (error) { - posthog.capture('external-secret-form-submitted', { - mode, - scope, - is_file: isFile, - success: false, - }) + if (!isJson) + posthog.capture('external-secret-form-submitted', { + mode, + scope, + is_file: isFile, + success: false, + }) console.error(error) } }) @@ -181,30 +215,31 @@ export function AddSecretModal({ return ( 'Edit secret as JSON') + .with({ isJson: true }, () => 'Add secret as JSON') + .with({ isFile: true, mode: 'edit' }, () => 'Edit secret as file') + .with({ isFile: true }, () => 'Add secret as file') + .with({ mode: 'edit' }, () => 'Edit secret') + .otherwise(() => 'Add secret')} + description={match({ isJson, isFile }) + .with( + { isJson: true }, + () => 'Qovery will inject all top-level JSON keys as environment variables at deployment.' + ) + .with( + { isFile: true }, + () => 'Qovery will resolve the external secret at deployment and mount it as a file inside your container.' + ) + .otherwise(() => 'Qovery will resolve the external secret at deployment.')} onSubmit={handleSubmit} onClose={onClose} submitLabel={mode === 'edit' ? 'Edit secret' : 'Add secret'} loading={methods.formState.isSubmitting} + submitDisabled={isJson && !hasJsonKeys} > <> +

Secret parameters

{ const selected = value as string field.onChange(selected) - if (!secretNameValue) { + if (!isJson && !secretNameValue) { const inferredName = selected.split('/').pop()?.toUpperCase() if (inferredName) { methods.setValue('secretName', inferredName, { shouldValidate: true }) @@ -287,15 +322,19 @@ export function AddSecretModal({ ( )} @@ -306,7 +345,7 @@ export function AddSecretModal({ control={methods.control} render={({ field }) => ( + {isJson && ( +
+

+ Variables to import{hasJsonKeys ? ` (${jsonKeys?.length})` : ''} +

+
+ {hasJsonKeys && jsonKeys ? ( + + ) : ( + + )} +
+
+ )} + {shouldDisplayEnvironmentAcknowledgement && (
diff --git a/libs/domains/variables/feature/src/lib/external-secrets/external-secret-json-preview.ts b/libs/domains/variables/feature/src/lib/external-secrets/external-secret-json-preview.ts new file mode 100644 index 00000000000..662d9903c8d --- /dev/null +++ b/libs/domains/variables/feature/src/lib/external-secrets/external-secret-json-preview.ts @@ -0,0 +1,72 @@ +import { type SecretManagerAccess } from 'qovery-typescript-axios' +import { getSecretManagerProvider } from '@qovery/domains/clusters/data-access' +import { type VariableScope } from '@qovery/domains/variables/data-access' +import { generateScopeLabel } from '@qovery/shared/util-js' + +// UI model only. Replace the local preview storage when the API contract is available. +// Keep only key names here: never fetch or store the JSON secret's values. +export interface ExternalSecretJSON { + id: string + name: string + reference: string + description?: string + isJson: true + keys?: string[] + source: string | null + sourceIcon?: 'AWS' | 'GCP' + secretManagerAccessId?: string + scope: string + previewParentId: string +} + +// TODO(secret-manager-json): Remove this temporary example after design review, before shipping. +export function createJsonSecretPreview( + parentId: string, + scope: VariableScope, + manager?: SecretManagerAccess +): ExternalSecretJSON[] { + if ( + window.location.hostname !== 'localhost' || + !['ddb7eba1-a074-485f-b8f2-a7c1c2ad5719', 'a40ee878-05a1-434c-adba-0c03ed54a4bd'].includes(parentId) + ) + return [] + + return [ + { + id: 'local-json-example', + name: 'Lucis application secrets', + reference: 'lucis/production/application', + description: 'Example JSON secret — keys only, no secret values.', + isJson: true, + keys: [ + 'DATABASE_URL', + 'REDIS_URL', + 'API_KEY', + 'JWT_SECRET', + 'SESSION_SECRET', + 'ENCRYPTION_KEY', + 'SMTP_HOST', + 'SMTP_PORT', + 'SMTP_USERNAME', + 'SMTP_PASSWORD', + 'STRIPE_SECRET_KEY', + 'STRIPE_WEBHOOK_SECRET', + 'S3_BUCKET', + 'S3_ACCESS_KEY_ID', + 'S3_SECRET_ACCESS_KEY', + 'OAUTH_CLIENT_ID', + 'OAUTH_CLIENT_SECRET', + 'SENTRY_DSN', + 'WEBHOOK_SIGNING_SECRET', + 'INTERNAL_API_TOKEN', + 'ANALYTICS_WRITE_KEY', + 'SEARCH_API_KEY', + ], + source: manager?.name ?? 'Example secret manager', + sourceIcon: getSecretManagerProvider(manager), + secretManagerAccessId: manager?.id, + scope: generateScopeLabel(scope), + previewParentId: parentId, + }, + ] +} diff --git a/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.spec.tsx b/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.spec.tsx index 7a39ef598d8..f0348b3b961 100644 --- a/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.spec.tsx +++ b/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.spec.tsx @@ -1,15 +1,19 @@ import { useParams } from '@tanstack/react-router' import { type ReactElement, type ReactNode } from 'react' import { useModal, useModalConfirmation } from '@qovery/shared/ui' -import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import * as utilHooks from '@qovery/shared/util-hooks' +import { act, renderWithProviders, screen } from '@qovery/shared/util-tests' import { useCreateVariable } from '../hooks/use-create-variable/use-create-variable' import { useDeleteVariable } from '../hooks/use-delete-variable/use-delete-variable' import { useEditVariable } from '../hooks/use-edit-variable/use-edit-variable' import { useVariablesSecretManagers } from '../hooks/use-variables-secret-managers/use-variables-secret-managers' import { useVariables } from '../hooks/use-variables/use-variables' import { type AddSecretModalSubmitData } from './add-secret-modal/add-secret-modal' +import { createJsonSecretPreview } from './external-secret-json-preview' import { ExternalSecretsTab } from './external-secrets-tab' +jest.mock('./external-secret-json-preview', () => ({ createJsonSecretPreview: jest.fn(() => []) })) + jest.mock('../hooks/use-variables/use-variables', () => ({ useVariables: jest.fn(), })) @@ -70,6 +74,7 @@ describe('ExternalSecretsTab', () => { beforeEach(() => { jest.clearAllMocks() + jest.mocked(createJsonSecretPreview).mockReturnValue([]) useParamsMock.mockReturnValue({ organizationId: 'organization-id', environmentId: 'environment-id', @@ -504,4 +509,154 @@ describe('ExternalSecretsTab', () => { '/organization/organization-id/cluster/cluster-id/settings/addons' ) }) + it('shows JSON keys under the reference and supports collapsing without exposing values', async () => { + jest.mocked(createJsonSecretPreview).mockReturnValue([ + { + id: 'local-json-example', + name: 'Application credentials', + reference: 'lucis/app', + isJson: true, + keys: ['DATABASE_URL', 'API_KEY'], + source: 'Example manager', + scope: 'Environment', + previewParentId: 'environment-id', + }, + ]) + useVariablesMock.mockReturnValue({ data: [] } as unknown as ReturnType) + const { userEvent } = renderWithProviders() + expect(screen.getByText('Application credentials')).toBeInTheDocument() + expect(screen.getByText('DATABASE_URL')).toBeInTheDocument() + expect(screen.getByText('API_KEY')).toBeInTheDocument() + await userEvent.click(screen.getByText('Application credentials')) + expect(screen.queryByText('DATABASE_URL')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Expand keys for lucis/app' })).not.toBeInTheDocument() + screen.getByRole('cell', { name: 'Expand keys for lucis/app' }).focus() + await userEvent.keyboard('{Enter}') + expect(screen.getByText('DATABASE_URL')).toBeInTheDocument() + const copyKey = jest.fn() + const copyHook = jest.spyOn(utilHooks, 'useCopyToClipboard').mockReturnValue([null, copyKey]) + await userEvent.click(screen.getByText('Application credentials')) + await userEvent.click(screen.getByText('Application credentials')) + await userEvent.click(screen.getByRole('button', { name: 'Copy DATABASE_URL' })) + expect(copyKey).toHaveBeenCalledWith('DATABASE_URL') + expect(screen.getByText('Copied')).toBeInTheDocument() + expect(screen.getByText('API_KEY')).toBeInTheDocument() + copyHook.mockRestore() + }) + + it('counts imported keys and searches inside collapsed JSON secrets', async () => { + jest.mocked(createJsonSecretPreview).mockReturnValue([ + { + id: 'json', + name: 'Application credentials', + reference: 'lucis/app', + isJson: true, + keys: ['DATABASE_URL', 'API_KEY'], + source: 'Example manager', + scope: 'Environment', + previewParentId: 'environment-id', + }, + ]) + useVariablesMock.mockReturnValue({ + data: [ + { + id: 'regular', + key: 'PASSWORD', + value: 'lucis/password', + scope: 'ENVIRONMENT', + variable_type: 'EXTERNAL_SECRET', + }, + ], + } as unknown as ReturnType) + const { userEvent } = renderWithProviders() + expect(screen.getByText('3 external secrets')).toBeInTheDocument() + await userEvent.click(screen.getByText('Application credentials')) + expect(screen.queryByText('API_KEY')).not.toBeInTheDocument() + await userEvent.click(screen.getByPlaceholderText('Search secrets')) + await userEvent.paste('api_key') + expect(screen.getByText('Application credentials')).toBeInTheDocument() + expect(screen.getByText('API_KEY')).toBeInTheDocument() + expect(screen.queryByText('DATABASE_URL')).not.toBeInTheDocument() + expect(screen.queryByText('PASSWORD')).not.toBeInTheDocument() + expect(screen.getByText('1/3 external secrets')).toBeInTheDocument() + await userEvent.clear(screen.getByPlaceholderText('Search secrets')) + expect(screen.getByText('DATABASE_URL')).toBeInTheDocument() + expect(screen.getByText('API_KEY')).toBeInTheDocument() + await userEvent.click(screen.getByPlaceholderText('Search secrets')) + await userEvent.paste('Application credentials') + expect(screen.getByText('Application credentials')).toBeInTheDocument() + expect(screen.getByText('No keys match your search.')).toBeInTheDocument() + expect(screen.getByText('0/3 external secrets')).toBeInTheDocument() + expect(screen.queryByText('DATABASE_URL')).not.toBeInTheDocument() + expect(screen.queryByText('API_KEY')).not.toBeInTheDocument() + await userEvent.clear(screen.getByPlaceholderText('Search secrets')) + await userEvent.click(screen.getByPlaceholderText('Search secrets')) + await userEvent.paste('lucis') + expect(screen.getByText('Application credentials')).toBeInTheDocument() + expect(screen.getByText('PASSWORD')).toBeInTheDocument() + expect(screen.getByText('1/3 external secrets')).toBeInTheDocument() + await userEvent.clear(screen.getByPlaceholderText('Search secrets')) + await userEvent.click(screen.getByPlaceholderText('Search secrets')) + await userEvent.paste('no-match') + expect(screen.getByText('0/3 external secrets')).toBeInTheDocument() + expect(screen.getByText('No secrets match your search or filters.')).toBeInTheDocument() + await userEvent.clear(screen.getByPlaceholderText('Search secrets')) + expect(screen.getByText('3 external secrets')).toBeInTheDocument() + }) + + it('creates, edits and deletes JSON previews without calling the variable API', async () => { + const createVariable = jest.fn() + const editVariable = jest.fn() + const deleteVariable = jest.fn() + const openModal = jest.fn() + const openModalConfirmation = jest.fn() + useCreateVariableMock.mockReturnValue({ mutateAsync: createVariable } as unknown as ReturnType< + typeof useCreateVariable + >) + useEditVariableMock.mockReturnValue({ mutateAsync: editVariable } as unknown as ReturnType) + useDeleteVariableMock.mockReturnValue({ mutateAsync: deleteVariable } as unknown as ReturnType< + typeof useDeleteVariable + >) + useModalMock.mockReturnValue({ openModal, closeModal: jest.fn() }) + useModalConfirmationMock.mockReturnValue({ openModalConfirmation }) + useVariablesMock.mockReturnValue({ data: [] } as unknown as ReturnType) + useVariablesSecretManagersMock.mockReturnValue({ + secretManagers: [], + hasClusterSecretManagerConfigured: true, + clusterId: 'cluster-id', + }) + const { userEvent } = renderWithProviders() + await userEvent.click(screen.getByRole('button', { name: 'Add secret' })) + await userEvent.click(screen.getByRole('menuitem', { name: 'Add secret as JSON' })) + const modal = openModal.mock.calls[0][0].content as ReactElement<{ + isJson: boolean + onSubmit: (data: AddSecretModalSubmitData) => Promise + }> + expect(modal.props.isJson).toBe(true) + const data = { + name: 'Application credentials', + reference: 'lucis/app', + isJson: true, + isFile: false, + secretManagerAccessId: 'manager-id', + } + await act(async () => modal.props.onSubmit(data)) + expect(screen.getByText('lucis/app')).toBeInTheDocument() + expect(screen.getByText(/Keys are not available yet/)).toBeInTheDocument() + expect(screen.getByText('0 external secrets · 1 JSON count unavailable')).toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: 'Edit' })) + const editModal = openModal.mock.calls[1][0].content as typeof modal + expect(editModal.props.isJson).toBe(true) + await act(async () => + editModal.props.onSubmit({ ...data, name: 'Updated credentials', reference: 'lucis/updated' }) + ) + expect(screen.getByText('lucis/updated')).toBeInTheDocument() + expect(screen.getByText('Updated credentials')).toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: 'Delete' })) + await act(async () => openModalConfirmation.mock.calls[0][0].action()) + expect(screen.queryByText('lucis/updated')).not.toBeInTheDocument() + expect(createVariable).not.toHaveBeenCalled() + expect(editVariable).not.toHaveBeenCalled() + expect(deleteVariable).not.toHaveBeenCalled() + }) }) diff --git a/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.tsx b/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.tsx index eb680add32e..8b1c64974fd 100644 --- a/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.tsx +++ b/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.tsx @@ -13,7 +13,7 @@ import { useReactTable, } from '@tanstack/react-table' import posthog from 'posthog-js' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { Fragment, type KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react' import { match } from 'ts-pattern' import { getSecretManagerProvider } from '@qovery/domains/clusters/data-access' import { type VariableScope } from '@qovery/domains/variables/data-access' @@ -43,9 +43,13 @@ import { type SecretSourceOption, mapSecretManagersToSources, } from './add-secret-modal/add-secret-modal' +import { type ExternalSecretJSON, createJsonSecretPreview } from './external-secret-json-preview' +import { JsonSecretKeys } from './json-secret-keys' const { Table } = TablePrimitives +type SecretFormat = 'variable' | 'file' | 'json' + const ADD_SECRET_OPTIONS = [ { value: 'variable', @@ -57,7 +61,12 @@ const ADD_SECRET_OPTIONS = [ label: 'Add secret as file', icon: 'file-lines' as const, }, -] + { + value: 'json', + label: 'Add secret as JSON', + icon: 'brackets-curly' as const, + }, +] satisfies { value: SecretFormat; label: string; icon: 'key' | 'file-lines' | 'brackets-curly' }[] const gridLayoutClassName = 'grid w-full grid-cols-[32px_minmax(0,1fr)_240px_220px_140px_104px]' @@ -67,6 +76,10 @@ type ExternalSecretRow = { description?: string filePath?: string isFile?: boolean + isJson?: boolean + keys?: string[] + previewParentId?: string + secretManagerAccessId?: string reference: string source: string | null sourceIcon?: 'AWS' | 'GCP' @@ -77,7 +90,7 @@ const columnHelper = createColumnHelper() type AddSecretDropdownProps = { color?: 'brand' | 'neutral' - onSelect: (isFile: boolean) => void + onSelect: (format: SecretFormat) => void } function AddSecretDropdown({ color = 'brand', onSelect }: AddSecretDropdownProps) { @@ -95,7 +108,7 @@ function AddSecretDropdown({ color = 'brand', onSelect }: AddSecretDropdownProps } - onSelect={() => onSelect(option.value === 'file')} + onSelect={() => onSelect(option.value)} > {option.label} @@ -129,9 +142,16 @@ export function ExternalSecretsTab({ suspense: true, }) - const secrets = useMemo( - () => - variables.filter(isExternalSecretVariable).map((variable) => { + // TODO(secret-manager-json): Replace local create/edit/delete state with the API when its contract is available. + // The seeded example and all JSON changes currently reset when this component remounts. + const [jsonSecrets, setJsonSecrets] = useState(() => + createJsonSecretPreview(parentId, scope, secretManagers[0]) + ) + const [collapsedJsonSecrets, setCollapsedJsonSecrets] = useState>({}) + + const secrets = useMemo( + () => [ + ...variables.filter(isExternalSecretVariable).map((variable) => { const secretManager = secretManagers.find((manager) => manager.id === variable.secret_manager_access_id) return { @@ -146,9 +166,12 @@ export function ExternalSecretsTab({ scope: generateScopeLabel(variable.scope), } }), - [secretManagers, variables] + ...jsonSecrets.filter((secret) => secret.previewParentId === parentId), + ], + [secretManagers, variables, jsonSecrets, parentId] ) const [search, setSearch] = useState('') + const searchQuery = search.trim().toLowerCase() const [sorting, setSorting] = useState([]) const [columnFilters, setColumnFilters] = useState([]) const [rowSelection, setRowSelection] = useState({}) @@ -165,7 +188,11 @@ export function ExternalSecretsTab({ if (!secret?.source) { return secretSources[0] } - return secretSources.find((option) => option.tableLabel === secret.source) ?? secretSources[0] + return ( + secretSources.find( + (option) => option.value === secret.secretManagerAccessId || option.tableLabel === secret.source + ) ?? secretSources[0] + ) }, [secretSources] ) @@ -176,16 +203,31 @@ export function ExternalSecretsTab({ description, filePath, isFile, + isJson, + keys, reference, secretManagerAccessId, - }: { - name: string - description?: string - filePath?: string - isFile: boolean - reference: string - secretManagerAccessId: string - }) => { + }: AddSecretModalSubmitData) => { + if (isJson) { + const manager = secretManagers.find((item) => item.id === secretManagerAccessId) + setJsonSecrets((current) => [ + ...current, + { + id: `local-json-${crypto.randomUUID()}`, + name, + reference, + description, + isJson: true, + keys, + source: manager?.name ?? null, + sourceIcon: getSecretManagerProvider(manager), + secretManagerAccessId, + scope: generateScopeLabel(scope), + previewParentId: parentId, + }, + ]) + return + } await createVariable({ variableRequest: { key: name, @@ -200,14 +242,34 @@ export function ExternalSecretsTab({ }) onCreateSecret?.() }, - [createVariable, onCreateSecret, parentId, scope] + [createVariable, onCreateSecret, parentId, scope, secretManagers] ) const handleEditSecret = useCallback( async ( secretId: string, - { name, description, filePath, isFile, reference, secretManagerAccessId }: AddSecretModalSubmitData + { name, description, filePath, isFile, isJson, keys, reference, secretManagerAccessId }: AddSecretModalSubmitData ) => { + if (isJson) { + const manager = secretManagers.find((item) => item.id === secretManagerAccessId) + setJsonSecrets((current) => + current.map((secret) => + secret.id === secretId + ? { + ...secret, + name, + reference, + description, + secretManagerAccessId, + source: manager?.name ?? null, + sourceIcon: getSecretManagerProvider(manager), + keys, + } + : secret + ) + ) + return + } const variableEditRequest = { key: name, value: reference, @@ -222,11 +284,12 @@ export function ExternalSecretsTab({ }) onEditSecret?.() }, - [editVariable, onEditSecret] + [editVariable, onEditSecret, secretManagers] ) const handleOpenAddSecret = useCallback( - (isFile: boolean) => { + (format: SecretFormat) => { + const isFile = format === 'file' posthog.capture('external-secret-add-clicked', { scope, is_file: isFile, @@ -235,8 +298,10 @@ export function ExternalSecretsTab({ content: ( handleEditSecret(secret.id, updated)} @@ -271,7 +338,7 @@ export function ExternalSecretsTab({ }, }) }, - [closeModal, getSourceOption, handleEditSecret, openModal, secretSources] + [closeModal, getSourceOption, handleEditSecret, openModal, secretSources, jsonSecrets] ) useEffect(() => { @@ -284,8 +351,11 @@ export function ExternalSecretsTab({ const handleDeleteSecrets = useCallback( async (secretIds: string[]) => { const idsToDelete = new Set(secretIds) - await Promise.all([...idsToDelete].map((variableId) => deleteVariable({ variableId }))) - onDeleteSecret?.() + const localIds = new Set(jsonSecrets.map((secret) => secret.id)) + const remoteIds = [...idsToDelete].filter((id) => !localIds.has(id)) + await Promise.all(remoteIds.map((variableId) => deleteVariable({ variableId }))) + setJsonSecrets((current) => current.filter((secret) => !idsToDelete.has(secret.id))) + if (remoteIds.length > 0) onDeleteSecret?.() setRowSelection((prev) => { const next = { ...prev } for (const secretId of secretIds) { @@ -294,7 +364,7 @@ export function ExternalSecretsTab({ return next }) }, - [deleteVariable, onDeleteSecret] + [deleteVariable, onDeleteSecret, jsonSecrets] ) const handleConfirmDeleteSecrets = useCallback( @@ -388,6 +458,27 @@ export function ExternalSecretsTab({ enableSorting: true, cell: (info) => { const secret = info.row.original + if (secret.isJson) { + const expanded = !collapsedJsonSecrets[secret.id] + return ( +
+ + + {secret.name} + + + + {secret.keys + ? `${secret.keys.length} ${pluralize(secret.keys.length, 'secret')} imported` + : 'Keys unavailable'} + + + + + +
+ ) + } const showFilePath = secret.isFile && secret.filePath return (
@@ -494,7 +585,7 @@ export function ExternalSecretsTab({ }, }), ], - [handleConfirmDeleteSecrets, handleOpenEditSecret] + [handleConfirmDeleteSecrets, handleOpenEditSecret, collapsedJsonSecrets] ) const table = useReactTable({ @@ -505,6 +596,14 @@ export function ExternalSecretsTab({ onRowSelectionChange: setRowSelection, onGlobalFilterChange: setSearch, onColumnFiltersChange: setColumnFilters, + globalFilterFn: (row, columnId, value: string) => { + const query = value.trim().toLowerCase() + return ( + String(row.getValue(columnId) ?? '') + .toLowerCase() + .includes(query) || Boolean(row.original.keys?.some((key) => key.toLowerCase().includes(query))) + ) + }, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), @@ -514,11 +613,22 @@ export function ExternalSecretsTab({ }) const shouldShowEmptyState = secrets.length === 0 && Boolean(emptyStateConfig) - const totalRows = table.getPreFilteredRowModel().rows.length - const isSearching = table.getRowCount() !== totalRows - const countText = isSearching - ? `${table.getRowCount()}/${totalRows} external ${pluralize(table.getRowCount(), 'secret')}` - : `${totalRows} external ${pluralize(totalRows, 'secret')}` + const countSecrets = (rows: ExternalSecretRow[], query = '') => + rows.reduce( + (count, secret) => + count + (secret.isJson ? secret.keys?.filter((key) => key.toLowerCase().includes(query)).length ?? 0 : 1), + 0 + ) + const totalSecrets = countSecrets(secrets) + const visibleSecrets = table.getRowModel().rows.map(({ original }) => original) + const visibleCount = countSecrets(visibleSecrets, searchQuery) + const unknownCount = visibleSecrets.filter((secret) => secret.isJson && !secret.keys).length + const isSearching = visibleCount !== totalSecrets || table.getRowCount() !== secrets.length + const countText = + (isSearching + ? `${visibleCount}/${totalSecrets} external ${totalSecrets === 1 ? 'secret' : 'secrets'}` + : `${totalSecrets} external ${totalSecrets === 1 ? 'secret' : 'secrets'}`) + + (unknownCount > 0 ? ` · ${unknownCount} JSON ${pluralize(unknownCount, 'count')} unavailable` : '') const selectedRows = table.getSelectedRowModel().rows.map(({ original }) => original) const selectedIds = selectedRows.map((secret) => secret.id) const hasSelection = selectedIds.length > 0 @@ -529,7 +639,7 @@ export function ExternalSecretsTab({
{countText} - + @@ -540,7 +650,22 @@ export function ExternalSecretsTab({ className="h-8 w-[180px]" placeholder="Search secrets" value={search} - onChange={(e) => setSearch(e.target.value)} + onChange={(e) => { + const value = e.target.value + setSearch(value) + const query = value.trim().toLowerCase() + if (query) { + setCollapsedJsonSecrets((current) => { + const next = { ...current } + for (const secret of secrets) { + if (secret.keys?.some((key) => key.toLowerCase().includes(query))) { + delete next[secret.id] + } + } + return next + }) + } + }} />
@@ -601,25 +726,70 @@ export function ExternalSecretsTab({ ))} - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} + {table.getRowModel().rows.length === 0 && ( + + + No secrets match your search or filters. + - ))} + )} + {table.getRowModel().rows.map((row) => { + const matchingKeys = row.original.keys?.filter((key) => key.toLowerCase().includes(searchQuery)) + return ( + + + {row.getVisibleCells().map((cell) => ( + + setCollapsedJsonSecrets((current) => ({ ...current, [row.id]: !current[row.id] })), + onKeyDown: (event: KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + setCollapsedJsonSecrets((current) => ({ ...current, [row.id]: !current[row.id] })) + } + }, + } + : {})} + className={twMerge( + 'flex h-full items-center first:relative', + cell.column.id === 'actions' && 'justify-end', + cell.column.id === 'name' && 'border-r border-neutral', + cell.column.id === 'name' && + row.original.isJson && + 'cursor-pointer focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-brand-strong' + )} + > + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + {row.original.isJson && !collapsedJsonSecrets[row.id] && ( + + + {matchingKeys ? ( + matchingKeys.length > 0 ? ( + + ) : ( +

No keys match your search.

+ ) + ) : ( +

Keys are not available yet.

+ )} +
+
+ )} +
+ ) + })}
)} diff --git a/libs/domains/variables/feature/src/lib/external-secrets/json-secret-keys.tsx b/libs/domains/variables/feature/src/lib/external-secrets/json-secret-keys.tsx new file mode 100644 index 00000000000..392d64535ef --- /dev/null +++ b/libs/domains/variables/feature/src/lib/external-secrets/json-secret-keys.tsx @@ -0,0 +1,26 @@ +import { Button, CopyToClipboard } from '@qovery/shared/ui' +import { twMerge } from '@qovery/shared/util-js' + +export function JsonSecretKeys({ keys, label, className }: { keys: string[]; label: string; className?: string }) { + return ( +
    + {keys.map((key) => ( +
  • + + + +
  • + ))} +
+ ) +} From 9d0fd7dbeb3a819023ce941f1274682699007466 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Mon, 12 Oct 2026 15:01:34 +0200 Subject: [PATCH 02/17] feat(variables): share secret dropdown with service creation --- .../step-variables/step-variables.spec.tsx | 35 +++++- .../step-variables/step-variables.tsx | 112 +++++------------- libs/domains/variables/feature/src/index.ts | 1 + .../external-secrets/add-secret-dropdown.tsx | 52 ++++++++ .../external-secrets/external-secrets-tab.tsx | 52 +------- 5 files changed, 116 insertions(+), 136 deletions(-) create mode 100644 libs/domains/variables/feature/src/lib/external-secrets/add-secret-dropdown.tsx 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 1df02555b3b..12a3958ee2f 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 @@ -36,7 +36,10 @@ jest.mock('@tanstack/react-router', () => ({ })) jest.mock('@qovery/domains/variables/feature', () => ({ - AddSecretModal: () => null, + AddSecretDropdown: jest.requireActual('@qovery/domains/variables/feature').AddSecretDropdown, + AddSecretModal: ({ isFile, isJson }: { isFile?: boolean; isJson?: boolean }) => ( +
{isJson ? 'JSON secret modal' : isFile ? 'File secret modal' : 'Variable secret modal'}
+ ), VariableFormModal: ({ onSubmit, scope, @@ -164,6 +167,36 @@ describe('ApplicationContainerStepVariables', () => { expect(onSubmit).toHaveBeenCalled() }) + it.each([ + ['Add secret as variable', 'Variable secret modal'], + ['Add secret as file', 'File secret modal'], + ['Add secret as JSON', 'JSON secret modal'], + ])('opens %s from the external secret dropdown', async (option, modal) => { + mockUseVariablesSecretManagers.mockReturnValue({ + secretManagers: [], + hasClusterSecretManagerConfigured: true, + clusterId: 'cluster-1', + }) + const { userEvent } = renderWithProviders( + + + + ) + expect(screen.queryByRole('button', { name: 'Add secret as file' })).not.toBeInTheDocument() + const addButtons = screen.getAllByRole('button', { name: 'Add secret' }) + await userEvent.click(addButtons[addButtons.length - 1]) + expect(screen.getAllByRole('menuitem').map((item) => item.textContent)).toEqual([ + 'Add secret as variable', + 'Add secret as file', + 'Add secret as JSON', + ]) + await userEvent.click(screen.getByRole('menuitem', { name: option })) + expect(screen.getByText(modal)).toBeInTheDocument() + }) + it('does not expose external secret creation when no secret manager is linked', () => { renderWithProviders( void | Promise } -function cardHeaderActions({ - onAddDefault, - onAddAsFile, - defaultLabel, - asFileLabel, -}: { - onAddDefault: () => void - onAddAsFile: () => void - defaultLabel: string - asFileLabel: string -}) { - return ( -
- - -
- ) -} - -function emptyState({ - title, - onAddDefault, - onAddAsFile, - defaultLabel, - asFileLabel, -}: { - title: string - onAddDefault: () => void - onAddAsFile: () => void - defaultLabel: string - asFileLabel: string -}) { - return ( - -
- - -
-
- ) -} - function mapModalDataToVariable(data: CreateUpdateVariableModalSubmitData, current?: VariableData): VariableData { return { variable: data.key, @@ -216,7 +149,15 @@ export function ApplicationContainerStepVariables({ onBack, onSubmit }: Applicat }) } - const openSecretModal = ({ isFile, index }: { isFile: boolean; index?: number }) => { + const openSecretModal = ({ + isFile = false, + isJson = false, + index, + }: { + isFile?: boolean + isJson?: boolean + index?: number + }) => { const currentSecret = typeof index === 'number' ? externalSecrets[index] : undefined const defaultSource = secretSources.find((source) => source.value === currentSecret?.secretManagerAccessId) ?? @@ -230,6 +171,7 @@ export function ApplicationContainerStepVariables({ onBack, onSubmit }: Applicat secretSources={secretSources} defaultSource={defaultSource} isFile={isFile} + isJson={isJson} initialSecret={currentSecret} onClose={closeModal} onSubmit={(secret) => { @@ -419,14 +361,13 @@ export function ApplicationContainerStepVariables({ onBack, onSubmit }: Applicat

External secrets

- {hasClusterSecretManagerConfigured && - externalSecrets.length > 0 && - cardHeaderActions({ - onAddDefault: () => openSecretModal({ isFile: false }), - onAddAsFile: () => openSecretModal({ isFile: true }), - defaultLabel: 'Add secret', - asFileLabel: 'Add secret as file', - })} + {hasClusterSecretManagerConfigured && externalSecrets.length > 0 && ( + openSecretModal({ isFile: format === 'file', isJson: format === 'json' })} + /> + )}
@@ -440,13 +381,16 @@ export function ApplicationContainerStepVariables({ onBack, onSubmit }: Applicat className="h-auto rounded-none border-0 bg-transparent px-8 py-8" /> ) : externalSecrets.length === 0 ? ( - emptyState({ - title: 'No external secrets added yet', - onAddDefault: () => openSecretModal({ isFile: false }), - onAddAsFile: () => openSecretModal({ isFile: true }), - defaultLabel: 'Add secret', - asFileLabel: 'Add secret as file', - }) + + openSecretModal({ isFile: format === 'file', isJson: format === 'json' })} + /> + ) : ( <>
diff --git a/libs/domains/variables/feature/src/index.ts b/libs/domains/variables/feature/src/index.ts index 1fa39cefaf4..183616421d4 100644 --- a/libs/domains/variables/feature/src/index.ts +++ b/libs/domains/variables/feature/src/index.ts @@ -20,3 +20,4 @@ export * from './lib/import-environment-variable-modal/import-environment-variab export * from './lib/external-secrets/external-secrets-tab' export * from './lib/external-secrets/secret-manager-feature-flag-entry-point' export * from './lib/external-secrets/add-secret-modal/add-secret-modal' +export * from './lib/external-secrets/add-secret-dropdown' diff --git a/libs/domains/variables/feature/src/lib/external-secrets/add-secret-dropdown.tsx b/libs/domains/variables/feature/src/lib/external-secrets/add-secret-dropdown.tsx new file mode 100644 index 00000000000..af0ecabe19d --- /dev/null +++ b/libs/domains/variables/feature/src/lib/external-secrets/add-secret-dropdown.tsx @@ -0,0 +1,52 @@ +import { Button, DropdownMenu, Icon } from '@qovery/shared/ui' + +export type SecretFormat = 'variable' | 'file' | 'json' + +const ADD_SECRET_OPTIONS = [ + { + value: 'variable', + label: 'Add secret as variable', + icon: 'key' as const, + }, + { + value: 'file', + label: 'Add secret as file', + icon: 'file-lines' as const, + }, + { + value: 'json', + label: 'Add secret as JSON', + icon: 'brackets-curly' as const, + }, +] satisfies { value: SecretFormat; label: string; icon: 'key' | 'file-lines' | 'brackets-curly' }[] + +type AddSecretDropdownProps = { + color?: 'brand' | 'neutral' + size?: 'sm' | 'md' + onSelect: (format: SecretFormat) => void +} + +export function AddSecretDropdown({ color = 'brand', size = 'md', onSelect }: AddSecretDropdownProps) { + return ( + + + + + + {ADD_SECRET_OPTIONS.map((option) => ( + } + onSelect={() => onSelect(option.value)} + > + {option.label} + + ))} + + + ) +} diff --git a/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.tsx b/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.tsx index 8b1c64974fd..1902a224fa2 100644 --- a/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.tsx +++ b/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.tsx @@ -21,7 +21,6 @@ import { isExternalSecretVariable } from '@qovery/domains/variables/util' import { Button, Checkbox, - DropdownMenu, EmptyState, Icon, TableFilter, @@ -37,6 +36,7 @@ import { useDeleteVariable } from '../hooks/use-delete-variable/use-delete-varia import { useEditVariable } from '../hooks/use-edit-variable/use-edit-variable' import { useVariablesSecretManagers } from '../hooks/use-variables-secret-managers/use-variables-secret-managers' import { useVariables } from '../hooks/use-variables/use-variables' +import { AddSecretDropdown, type SecretFormat } from './add-secret-dropdown' import { AddSecretModal, type AddSecretModalSubmitData, @@ -48,26 +48,6 @@ import { JsonSecretKeys } from './json-secret-keys' const { Table } = TablePrimitives -type SecretFormat = 'variable' | 'file' | 'json' - -const ADD_SECRET_OPTIONS = [ - { - value: 'variable', - label: 'Add secret as variable', - icon: 'key' as const, - }, - { - value: 'file', - label: 'Add secret as file', - icon: 'file-lines' as const, - }, - { - value: 'json', - label: 'Add secret as JSON', - icon: 'brackets-curly' as const, - }, -] satisfies { value: SecretFormat; label: string; icon: 'key' | 'file-lines' | 'brackets-curly' }[] - const gridLayoutClassName = 'grid w-full grid-cols-[32px_minmax(0,1fr)_240px_220px_140px_104px]' type ExternalSecretRow = { @@ -88,36 +68,6 @@ type ExternalSecretRow = { const columnHelper = createColumnHelper() -type AddSecretDropdownProps = { - color?: 'brand' | 'neutral' - onSelect: (format: SecretFormat) => void -} - -function AddSecretDropdown({ color = 'brand', onSelect }: AddSecretDropdownProps) { - return ( - - - - - - {ADD_SECRET_OPTIONS.map((option) => ( - } - onSelect={() => onSelect(option.value)} - > - {option.label} - - ))} - - - ) -} - export type ExternalSecretsTabProps = { scope: VariableScope parentId: string From b6cfe0947c70f0d86029bd1e6ac32dca6183e0ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Mon, 12 Oct 2026 15:45:07 +0200 Subject: [PATCH 03/17] feat(variables): enable JSON preview during service creation --- .../step-summary/step-summary.spec.tsx | 9 +++++++ .../step-summary/step-summary.tsx | 3 +++ .../step-variables/step-variables.spec.tsx | 25 +++++++++++++++++-- .../step-variables/step-variables.tsx | 21 +++++++++++++++- libs/domains/variables/feature/src/index.ts | 3 +++ .../external-secret-json-preview.ts | 2 +- .../common/flow-variable-data.interface.ts | 3 +++ 7 files changed, 62 insertions(+), 4 deletions(-) diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-summary/step-summary/step-summary.spec.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-summary/step-summary/step-summary.spec.tsx index b588580a006..50e436ffd1c 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-summary/step-summary/step-summary.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-summary/step-summary/step-summary.spec.tsx @@ -249,6 +249,15 @@ describe('ApplicationContainerStepSummary', () => { }) }) + it('does not send JSON previews to the external variable API', async () => { + const { userEvent } = renderComponent({ + externalSecrets: [{ ...externalSecretsData[0], isJson: true, keys: ['DATABASE_URL'] }], + }) + await userEvent.click(screen.getByTestId('button-create')) + await waitFor(() => expect(mockNavigate).toHaveBeenCalled()) + expect(mockCreateVariable).not.toHaveBeenCalled() + }) + it('continues service creation when external secret creation fails', async () => { mockCreateVariable.mockRejectedValueOnce(new Error('External secret creation failed')) const { userEvent } = renderComponent() diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-summary/step-summary/step-summary.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-summary/step-summary/step-summary.tsx index a4ddc3b04ab..d7cdc237b7f 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-summary/step-summary/step-summary.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-summary/step-summary/step-summary.tsx @@ -115,6 +115,9 @@ export function ApplicationContainerStepSummary({ // External secret creation errors are surfaced by the mutation notifications but should not block the service creation flow. await Promise.allSettled( externalSecretsData.map(async (externalSecret) => { + // TODO(secret-manager-json): Replace this preview-only guard with JSON API integration before production. + // Never send fake JSON references through the real variable API. + if (externalSecret.isJson) return const scope = externalSecret.scope ?? createPayload.serviceType return createVariable({ variableRequest: { 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 12a3958ee2f..f33d0d5513e 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 @@ -36,9 +36,27 @@ jest.mock('@tanstack/react-router', () => ({ })) jest.mock('@qovery/domains/variables/feature', () => ({ + createJsonSecretPreview: () => [{ reference: 'lucis/production/application', keys: ['DATABASE_URL'] }], + JsonSecretKeys: jest.requireActual('@qovery/domains/variables/feature').JsonSecretKeys, AddSecretDropdown: jest.requireActual('@qovery/domains/variables/feature').AddSecretDropdown, - AddSecretModal: ({ isFile, isJson }: { isFile?: boolean; isJson?: boolean }) => ( -
{isJson ? 'JSON secret modal' : isFile ? 'File secret modal' : 'Variable secret modal'}
+ AddSecretModal: ({ + isFile, + isJson, + jsonSecrets, + }: { + isFile?: boolean + isJson?: boolean + jsonSecrets?: { reference: string; keys?: string[] }[] + }) => ( +
+ {isJson ? 'JSON secret modal' : isFile ? 'File secret modal' : 'Variable secret modal'} + {isJson && + jsonSecrets?.map((secret) => ( + + {secret.reference}: {secret.keys?.join(', ')} + + ))} +
), VariableFormModal: ({ onSubmit, @@ -195,6 +213,9 @@ describe('ApplicationContainerStepVariables', () => { ]) await userEvent.click(screen.getByRole('menuitem', { name: option })) expect(screen.getByText(modal)).toBeInTheDocument() + if (option === 'Add secret as JSON') { + expect(screen.getByText('lucis/production/application: DATABASE_URL')).toBeInTheDocument() + } }) it('does not expose external secret creation when no secret manager is linked', () => { 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 06423ac6662..db181190c0c 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 @@ -7,7 +7,9 @@ import { AddSecretModal, type AddSecretModalSubmitData, type CreateUpdateVariableModalSubmitData, + JsonSecretKeys, VariableFormModal, + createJsonSecretPreview, mapSecretManagersToSources, useVariablesSecretManagers, } from '@qovery/domains/variables/feature' @@ -66,6 +68,7 @@ function mapSecretForForm( description: secret.description, filePath: secret.filePath, isFile: secret.isFile, + ...(secret.isJson ? { isJson: true, keys: secret.keys } : {}), reference: secret.reference, secretManagerAccessId: secret.secretManagerAccessId, source: source?.tableLabel ?? null, @@ -82,6 +85,11 @@ export function ApplicationContainerStepVariables({ onBack, onSubmit }: Applicat const serviceScope = generalForm.getValues('serviceType') === 'APPLICATION' ? 'APPLICATION' : 'CONTAINER' const secretSources = useMemo(() => mapSecretManagersToSources(secretManagers), [secretManagers]) + // TODO(secret-manager-json): Remove fixture metadata before production and load key-only metadata from the API. + const jsonSecrets = useMemo( + () => createJsonSecretPreview(environmentId, serviceScope, secretManagers[0]), + [environmentId, serviceScope, secretManagers] + ) const sourceById = useMemo(() => new Map(secretSources.map((source) => [source.value, source])), [secretSources]) const { @@ -169,6 +177,7 @@ export function ApplicationContainerStepVariables({ onBack, onSubmit }: Applicat )}
+ {secret.isJson && ( + + {secret.keys?.length ?? 0} secrets imported + + )} {secret.filePath && (
@@ -439,7 +453,7 @@ export function ApplicationContainerStepVariables({ onBack, onSubmit }: Applicat variant="outline" size="xs" iconOnly - onClick={() => openSecretModal({ isFile: !!secret.isFile, index })} + onClick={() => openSecretModal({ isFile: !!secret.isFile, isJson: !!secret.isJson, index })} > @@ -455,6 +469,11 @@ export function ApplicationContainerStepVariables({ onBack, onSubmit }: Applicat
+ {secret.isJson && secret.keys && ( +
+ +
+ )}
))} diff --git a/libs/domains/variables/feature/src/index.ts b/libs/domains/variables/feature/src/index.ts index 183616421d4..e0381d81fe5 100644 --- a/libs/domains/variables/feature/src/index.ts +++ b/libs/domains/variables/feature/src/index.ts @@ -21,3 +21,6 @@ export * from './lib/external-secrets/external-secrets-tab' export * from './lib/external-secrets/secret-manager-feature-flag-entry-point' export * from './lib/external-secrets/add-secret-modal/add-secret-modal' export * from './lib/external-secrets/add-secret-dropdown' +// TODO(secret-manager-json): Remove the fixture export and its creation-flow usage before production. +export { createJsonSecretPreview } from './lib/external-secrets/external-secret-json-preview' +export { JsonSecretKeys } from './lib/external-secrets/json-secret-keys' diff --git a/libs/domains/variables/feature/src/lib/external-secrets/external-secret-json-preview.ts b/libs/domains/variables/feature/src/lib/external-secrets/external-secret-json-preview.ts index 662d9903c8d..362ae6ac31a 100644 --- a/libs/domains/variables/feature/src/lib/external-secrets/external-secret-json-preview.ts +++ b/libs/domains/variables/feature/src/lib/external-secrets/external-secret-json-preview.ts @@ -19,7 +19,7 @@ export interface ExternalSecretJSON { previewParentId: string } -// TODO(secret-manager-json): Remove this temporary example after design review, before shipping. +// TODO(secret-manager-json): REMOVE BEFORE PRODUCTION: this entire fixture, its export, and its usages in the variables tab and service creation. export function createJsonSecretPreview( parentId: string, scope: VariableScope, diff --git a/libs/shared/interfaces/src/lib/common/flow-variable-data.interface.ts b/libs/shared/interfaces/src/lib/common/flow-variable-data.interface.ts index 8bf821b2318..ea3aefc26c2 100644 --- a/libs/shared/interfaces/src/lib/common/flow-variable-data.interface.ts +++ b/libs/shared/interfaces/src/lib/common/flow-variable-data.interface.ts @@ -16,6 +16,9 @@ export interface ExternalSecretData { description?: string filePath?: string isFile?: boolean + // TODO(secret-manager-json): Replace these preview fields with the API model before production. + isJson?: boolean + keys?: string[] source?: string | null sourceIcon?: 'aws' | 'gcp' secretManagerAccessId?: string From 8c4952205f16f8e606f5836bcae7b7401e1a024a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Mon, 12 Oct 2026 15:45:41 +0200 Subject: [PATCH 04/17] test(variables): use unconditional preview assertion --- .../step-variables/step-variables.spec.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 f33d0d5513e..772b460cb7d 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 @@ -213,9 +213,9 @@ describe('ApplicationContainerStepVariables', () => { ]) await userEvent.click(screen.getByRole('menuitem', { name: option })) expect(screen.getByText(modal)).toBeInTheDocument() - if (option === 'Add secret as JSON') { - expect(screen.getByText('lucis/production/application: DATABASE_URL')).toBeInTheDocument() - } + expect(screen.queryByText('lucis/production/application: DATABASE_URL') !== null).toBe( + option === 'Add secret as JSON' + ) }) it('does not expose external secret creation when no secret manager is linked', () => { From 7c78b76ac825349cea1db0e9acedd5c8993176f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Mon, 12 Oct 2026 15:51:28 +0200 Subject: [PATCH 05/17] feat(variables): toggle JSON keys during service creation --- .../step-variables/step-variables.spec.tsx | 43 ++++++++++++++++++- .../step-variables/step-variables.tsx | 42 ++++++++++++++++-- 2 files changed, 80 insertions(+), 5 deletions(-) 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 772b460cb7d..2a9aad1e9a0 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 @@ -1,4 +1,4 @@ -import { type ReactNode } from 'react' +import { type ReactNode, useEffect } from 'react' import { renderWithProviders, screen, waitFor } from '@qovery/shared/util-tests' import { ApplicationContainerCreationFlow, @@ -98,6 +98,21 @@ function VariablesState() { return
{JSON.stringify(variablesForm.watch())}
} +function JsonSecretStep() { + const { variablesForm } = useApplicationContainerCreateContext() + useEffect(() => { + variablesForm.setValue('externalSecrets', [ + { + name: 'Application credentials', + reference: 'lucis/production/application', + isJson: true, + keys: ['DATABASE_URL'], + }, + ]) + }, [variablesForm]) + return +} + describe('ApplicationContainerStepVariables', () => { const onBack = jest.fn() const onSubmit = jest.fn() @@ -218,6 +233,32 @@ describe('ApplicationContainerStepVariables', () => { ) }) + it('toggles JSON keys from the name cell using pointer and keyboard', async () => { + mockUseVariablesSecretManagers.mockReturnValue({ + secretManagers: [], + hasClusterSecretManagerConfigured: true, + clusterId: 'cluster-1', + }) + const { userEvent } = renderWithProviders( + + + + ) + expect(screen.getByRole('button', { name: 'Copy DATABASE_URL' })).toBeInTheDocument() + await userEvent.click(screen.getByText('Application credentials')) + expect(screen.queryByRole('button', { name: 'Copy DATABASE_URL' })).not.toBeInTheDocument() + const cell = screen.getByRole('button', { name: 'Expand keys for lucis/production/application' }) + expect(cell).toHaveAttribute('aria-expanded', 'false') + cell.focus() + await userEvent.keyboard('{Enter}') + expect(screen.getByRole('button', { name: 'Copy DATABASE_URL' })).toBeInTheDocument() + await userEvent.keyboard(' ') + expect(screen.queryByRole('button', { name: 'Copy DATABASE_URL' })).not.toBeInTheDocument() + }) + it('does not expose external secret creation when no secret manager is linked', () => { renderWithProviders( >({}) + const toggleJsonSecret = (id: string) => setCollapsedJsonSecrets((current) => ({ ...current, [id]: !current[id] })) const { secretManagers, hasClusterSecretManagerConfigured } = useVariablesSecretManagers() const serviceScope = generalForm.getValues('serviceType') === 'APPLICATION' ? 'APPLICATION' : 'CONTAINER' @@ -417,7 +420,29 @@ export function ApplicationContainerStepVariables({ onBack, onSubmit }: Applicat key={secret.id} className="grid min-h-[60px] grid-cols-[minmax(0,1fr)_minmax(0,1fr)_88px] border-b border-neutral last:border-b-0" > -
+
toggleJsonSecret(secret.id), + onKeyDown: (event: KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + toggleJsonSecret(secret.id) + } + }, + } + : {})} + className={twMerge( + 'relative flex min-w-0 flex-col justify-center gap-1 px-4 py-2', + secret.isJson && + 'cursor-pointer pr-10 focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-brand-strong' + )} + >
{secret.name} {secret.description && ( @@ -433,6 +458,12 @@ export function ApplicationContainerStepVariables({ onBack, onSubmit }: Applicat {secret.keys?.length ?? 0} secrets imported )} + {secret.isJson && ( + + )} {secret.filePath && (
@@ -469,8 +500,11 @@ export function ApplicationContainerStepVariables({ onBack, onSubmit }: Applicat
- {secret.isJson && secret.keys && ( -
+ {secret.isJson && secret.keys && !collapsedJsonSecrets[secret.id] && ( +
)} From 6eb7a1ffe0bed811d84d7bd17dbc2609ea4419f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Tue, 13 Oct 2026 10:06:31 +0200 Subject: [PATCH 06/17] fix(variables): offer JSON fixture for every creation source --- .../step-variables/step-variables.spec.tsx | 13 +++++++------ .../step-variables/step-variables.tsx | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) 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 2a9aad1e9a0..9777ffab28b 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 @@ -1,3 +1,4 @@ +import { type SecretManagerAccess } from 'qovery-typescript-axios' import { type ReactNode, useEffect } from 'react' import { renderWithProviders, screen, waitFor } from '@qovery/shared/util-tests' import { @@ -8,7 +9,7 @@ import { ApplicationContainerStepVariables } from './step-variables' const mockNavigate = jest.fn() const mockUseVariablesSecretManagers = jest.fn(() => ({ - secretManagers: [], + secretManagers: [] as SecretManagerAccess[], hasClusterSecretManagerConfigured: false, clusterId: 'cluster-1', })) @@ -51,8 +52,8 @@ jest.mock('@qovery/domains/variables/feature', () => ({
{isJson ? 'JSON secret modal' : isFile ? 'File secret modal' : 'Variable secret modal'} {isJson && - jsonSecrets?.map((secret) => ( - + jsonSecrets?.map((secret, index) => ( + {secret.reference}: {secret.keys?.join(', ')} ))} @@ -206,7 +207,7 @@ describe('ApplicationContainerStepVariables', () => { ['Add secret as JSON', 'JSON secret modal'], ])('opens %s from the external secret dropdown', async (option, modal) => { mockUseVariablesSecretManagers.mockReturnValue({ - secretManagers: [], + secretManagers: [{ id: 'first-source' }, { id: 'second-source' }] as SecretManagerAccess[], hasClusterSecretManagerConfigured: true, clusterId: 'cluster-1', }) @@ -228,8 +229,8 @@ describe('ApplicationContainerStepVariables', () => { ]) await userEvent.click(screen.getByRole('menuitem', { name: option })) expect(screen.getByText(modal)).toBeInTheDocument() - expect(screen.queryByText('lucis/production/application: DATABASE_URL') !== null).toBe( - option === 'Add secret as JSON' + expect(screen.queryAllByText('lucis/production/application: DATABASE_URL')).toHaveLength( + option === 'Add secret as JSON' ? 2 : 0 ) }) 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 aab606d423c..0ac99956f50 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 @@ -90,7 +90,7 @@ export function ApplicationContainerStepVariables({ onBack, onSubmit }: Applicat const secretSources = useMemo(() => mapSecretManagersToSources(secretManagers), [secretManagers]) // TODO(secret-manager-json): Remove fixture metadata before production and load key-only metadata from the API. const jsonSecrets = useMemo( - () => createJsonSecretPreview(environmentId, serviceScope, secretManagers[0]), + () => secretManagers.flatMap((manager) => createJsonSecretPreview(environmentId, serviceScope, manager)), [environmentId, serviceScope, secretManagers] ) const sourceById = useMemo(() => new Map(secretSources.map((source) => [source.value, source])), [secretSources]) From 2e9fdb0f57da3d3a7e8704e0494f10cc2bc98c0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Tue, 13 Oct 2026 11:23:33 +0200 Subject: [PATCH 07/17] refactor(variables): manage JSON secrets by reference only --- .../step-summary/step-summary.spec.tsx | 2 +- .../step-variables/step-variables.spec.tsx | 34 ++-- .../step-variables/step-variables.tsx | 53 +----- libs/domains/variables/feature/src/index.ts | 1 - .../add-secret-modal.spec.tsx | 46 +++--- .../add-secret-modal/add-secret-modal.tsx | 48 +----- .../external-secret-json-preview.ts | 29 +--- .../external-secrets-tab.spec.tsx | 80 ++------- .../external-secrets/external-secrets-tab.tsx | 155 ++++-------------- .../lib/external-secrets/json-secret-keys.tsx | 26 --- .../common/flow-variable-data.interface.ts | 3 +- 11 files changed, 83 insertions(+), 394 deletions(-) delete mode 100644 libs/domains/variables/feature/src/lib/external-secrets/json-secret-keys.tsx diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-summary/step-summary/step-summary.spec.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-summary/step-summary/step-summary.spec.tsx index 50e436ffd1c..c125776ecac 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-summary/step-summary/step-summary.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-summary/step-summary/step-summary.spec.tsx @@ -251,7 +251,7 @@ describe('ApplicationContainerStepSummary', () => { it('does not send JSON previews to the external variable API', async () => { const { userEvent } = renderComponent({ - externalSecrets: [{ ...externalSecretsData[0], isJson: true, keys: ['DATABASE_URL'] }], + externalSecrets: [{ ...externalSecretsData[0], isJson: true }], }) await userEvent.click(screen.getByTestId('button-create')) await waitFor(() => expect(mockNavigate).toHaveBeenCalled()) 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 9777ffab28b..fb7f9f8722b 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 @@ -37,8 +37,7 @@ jest.mock('@tanstack/react-router', () => ({ })) jest.mock('@qovery/domains/variables/feature', () => ({ - createJsonSecretPreview: () => [{ reference: 'lucis/production/application', keys: ['DATABASE_URL'] }], - JsonSecretKeys: jest.requireActual('@qovery/domains/variables/feature').JsonSecretKeys, + createJsonSecretPreview: () => [{ reference: 'lucis/production/application' }], AddSecretDropdown: jest.requireActual('@qovery/domains/variables/feature').AddSecretDropdown, AddSecretModal: ({ isFile, @@ -47,16 +46,12 @@ jest.mock('@qovery/domains/variables/feature', () => ({ }: { isFile?: boolean isJson?: boolean - jsonSecrets?: { reference: string; keys?: string[] }[] + jsonSecrets?: { reference: string }[] }) => (
{isJson ? 'JSON secret modal' : isFile ? 'File secret modal' : 'Variable secret modal'} {isJson && - jsonSecrets?.map((secret, index) => ( - - {secret.reference}: {secret.keys?.join(', ')} - - ))} + jsonSecrets?.map((secret, index) => {secret.reference})}
), VariableFormModal: ({ @@ -107,7 +102,6 @@ function JsonSecretStep() { name: 'Application credentials', reference: 'lucis/production/application', isJson: true, - keys: ['DATABASE_URL'], }, ]) }, [variablesForm]) @@ -229,18 +223,16 @@ describe('ApplicationContainerStepVariables', () => { ]) await userEvent.click(screen.getByRole('menuitem', { name: option })) expect(screen.getByText(modal)).toBeInTheDocument() - expect(screen.queryAllByText('lucis/production/application: DATABASE_URL')).toHaveLength( - option === 'Add secret as JSON' ? 2 : 0 - ) + expect(screen.queryAllByText('lucis/production/application')).toHaveLength(option === 'Add secret as JSON' ? 2 : 0) }) - it('toggles JSON keys from the name cell using pointer and keyboard', async () => { + it('renders JSON references without expandable key details', async () => { mockUseVariablesSecretManagers.mockReturnValue({ secretManagers: [], hasClusterSecretManagerConfigured: true, clusterId: 'cluster-1', }) - const { userEvent } = renderWithProviders( + renderWithProviders( { ) - expect(screen.getByRole('button', { name: 'Copy DATABASE_URL' })).toBeInTheDocument() - await userEvent.click(screen.getByText('Application credentials')) - expect(screen.queryByRole('button', { name: 'Copy DATABASE_URL' })).not.toBeInTheDocument() - const cell = screen.getByRole('button', { name: 'Expand keys for lucis/production/application' }) - expect(cell).toHaveAttribute('aria-expanded', 'false') - cell.focus() - await userEvent.keyboard('{Enter}') - expect(screen.getByRole('button', { name: 'Copy DATABASE_URL' })).toBeInTheDocument() - await userEvent.keyboard(' ') - expect(screen.queryByRole('button', { name: 'Copy DATABASE_URL' })).not.toBeInTheDocument() + expect(screen.getByText('Application credentials')).toBeInTheDocument() + expect(screen.getByText('lucis/production/application')).toBeInTheDocument() + expect(screen.queryByLabelText(/Expand keys|Collapse keys|Copy DATABASE_URL/)).not.toBeInTheDocument() + expect(screen.queryByText(/secrets imported/)).not.toBeInTheDocument() }) it('does not expose external secret creation when no secret manager is linked', () => { 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 0ac99956f50..1721379cd41 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 @@ -1,13 +1,12 @@ import { useParams } from '@tanstack/react-router' import { APIVariableTypeEnum, type VariableResponse } from 'qovery-typescript-axios' -import { type KeyboardEvent, useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo } from 'react' import { useFieldArray } from 'react-hook-form' import { AddSecretDropdown, AddSecretModal, type AddSecretModalSubmitData, type CreateUpdateVariableModalSubmitData, - JsonSecretKeys, VariableFormModal, createJsonSecretPreview, mapSecretManagersToSources, @@ -15,7 +14,6 @@ import { } from '@qovery/domains/variables/feature' import { type ExternalSecretData, type VariableData } from '@qovery/shared/interfaces' import { Button, EmptyState, FunnelFlowBody, Heading, Icon, Section, Tooltip, useModal } from '@qovery/shared/ui' -import { twMerge } from '@qovery/shared/util-js' import { useApplicationContainerCreateContext } from '../../application-container-creation-flow' export interface ApplicationContainerStepVariablesProps { @@ -69,7 +67,7 @@ function mapSecretForForm( description: secret.description, filePath: secret.filePath, isFile: secret.isFile, - ...(secret.isJson ? { isJson: true, keys: secret.keys } : {}), + ...(secret.isJson ? { isJson: true } : {}), reference: secret.reference, secretManagerAccessId: secret.secretManagerAccessId, source: source?.tableLabel ?? null, @@ -82,13 +80,11 @@ export function ApplicationContainerStepVariables({ onBack, onSubmit }: Applicat const { setCurrentStep, variablesForm, generalForm } = useApplicationContainerCreateContext() const { projectId = '', environmentId = '' } = useParams({ strict: false }) const { openModal, closeModal } = useModal() - const [collapsedJsonSecrets, setCollapsedJsonSecrets] = useState>({}) - const toggleJsonSecret = (id: string) => setCollapsedJsonSecrets((current) => ({ ...current, [id]: !current[id] })) const { secretManagers, hasClusterSecretManagerConfigured } = useVariablesSecretManagers() const serviceScope = generalForm.getValues('serviceType') === 'APPLICATION' ? 'APPLICATION' : 'CONTAINER' const secretSources = useMemo(() => mapSecretManagersToSources(secretManagers), [secretManagers]) - // TODO(secret-manager-json): Remove fixture metadata before production and load key-only metadata from the API. + // TODO(secret-manager-json): Remove fixture references before production and use the provider reference list. const jsonSecrets = useMemo( () => secretManagers.flatMap((manager) => createJsonSecretPreview(environmentId, serviceScope, manager)), [environmentId, serviceScope, secretManagers] @@ -420,29 +416,7 @@ export function ApplicationContainerStepVariables({ onBack, onSubmit }: Applicat key={secret.id} className="grid min-h-[60px] grid-cols-[minmax(0,1fr)_minmax(0,1fr)_88px] border-b border-neutral last:border-b-0" > -
toggleJsonSecret(secret.id), - onKeyDown: (event: KeyboardEvent) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault() - toggleJsonSecret(secret.id) - } - }, - } - : {})} - className={twMerge( - 'relative flex min-w-0 flex-col justify-center gap-1 px-4 py-2', - secret.isJson && - 'cursor-pointer pr-10 focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-brand-strong' - )} - > +
{secret.name} {secret.description && ( @@ -453,17 +427,6 @@ export function ApplicationContainerStepVariables({ onBack, onSubmit }: Applicat )}
- {secret.isJson && ( - - {secret.keys?.length ?? 0} secrets imported - - )} - {secret.isJson && ( - - )} {secret.filePath && (
@@ -500,14 +463,6 @@ export function ApplicationContainerStepVariables({ onBack, onSubmit }: Applicat
- {secret.isJson && secret.keys && !collapsedJsonSecrets[secret.id] && ( -
- -
- )}
))} diff --git a/libs/domains/variables/feature/src/index.ts b/libs/domains/variables/feature/src/index.ts index e0381d81fe5..67199719a81 100644 --- a/libs/domains/variables/feature/src/index.ts +++ b/libs/domains/variables/feature/src/index.ts @@ -23,4 +23,3 @@ export * from './lib/external-secrets/add-secret-modal/add-secret-modal' export * from './lib/external-secrets/add-secret-dropdown' // TODO(secret-manager-json): Remove the fixture export and its creation-flow usage before production. export { createJsonSecretPreview } from './lib/external-secrets/external-secret-json-preview' -export { JsonSecretKeys } from './lib/external-secrets/json-secret-keys' diff --git a/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.spec.tsx b/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.spec.tsx index 1d2a27858f1..0c2ccaa35d5 100644 --- a/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.spec.tsx +++ b/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.spec.tsx @@ -97,7 +97,6 @@ describe('AddSecretModal', () => { { reference: 'lucis/production/application', secretManagerAccessId: 'secret-manager-access-id', - keys: ['DATABASE_URL', 'API_KEY'], }, ]} secretSources={secretSources} @@ -119,7 +118,6 @@ describe('AddSecretModal', () => { name: 'Application credentials', reference: 'lucis/production/application', isJson: true, - keys: ['DATABASE_URL', 'API_KEY'], isFile: false, filePath: undefined, }) @@ -134,9 +132,7 @@ describe('AddSecretModal', () => { const { userEvent } = renderWithProviders( { expect(onClose).not.toHaveBeenCalled() consoleError.mockRestore() }) - it('blocks submission without JSON keys and clears the preview when the reference changes', async () => { + it('accepts a JSON reference without fetching its keys', async () => { const onSubmit = jest.fn() const { userEvent } = renderWithProviders( - + ) - await userEvent.type(screen.getByLabelText('Display name'), 'My credentials') - await userEvent.type(screen.getByLabelText('Reference'), 'not-json') - expect(screen.getByText('No JSON detected')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Add secret' })).toBeDisabled() - await userEvent.keyboard('{Enter}') - expect(onSubmit).not.toHaveBeenCalled() - await userEvent.clear(screen.getByLabelText('Reference')) - await userEvent.type(screen.getByLabelText('Reference'), 'valid') - expect(screen.getByRole('button', { name: 'Copy API_KEY' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Add secret' })).toBeEnabled() - await userEvent.type(screen.getByLabelText('Reference'), '-unknown') - expect(screen.queryByRole('button', { name: 'Copy API_KEY' })).not.toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Add secret' })).toBeDisabled() + expect(screen.queryByText('Variables to import')).not.toBeInTheDocument() + expect(screen.queryByText('No JSON detected')).not.toBeInTheDocument() + await userEvent.click(screen.getByLabelText('Display name')) + await userEvent.paste('My credentials') + await userEvent.click(screen.getByLabelText('Reference')) + await userEvent.paste('new/json/reference') + await userEvent.click(screen.getByRole('button', { name: 'Add secret' })) + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'My credentials', + reference: 'new/json/reference', + isJson: true, + }) + ) + ) + expect(onSubmit.mock.calls[0][0]).not.toHaveProperty('keys') }) }) diff --git a/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.tsx b/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.tsx index a59dcf55964..d2b242d2c93 100644 --- a/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.tsx +++ b/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.tsx @@ -6,18 +6,8 @@ import { match } from 'ts-pattern' import { getSecretManagerProvider } from '@qovery/domains/clusters/data-access' import { useSecretManagerProviderSecrets } from '@qovery/domains/clusters/feature' import { type VariableScope } from '@qovery/domains/variables/data-access' -import { - Checkbox, - EmptyState, - Icon, - InputSelect, - InputText, - InputTextArea, - ModalCrud, - useModal, -} from '@qovery/shared/ui' +import { Checkbox, Icon, InputSelect, InputText, InputTextArea, ModalCrud, useModal } from '@qovery/shared/ui' import { useDebounce } from '@qovery/shared/util-hooks' -import { JsonSecretKeys } from '../json-secret-keys' export type SecretSourceOption = { value: string @@ -44,7 +34,6 @@ export type AddSecretModalSubmitData = { filePath?: string isFile: boolean isJson?: boolean - keys?: string[] reference: string secretManagerAccessId: string } @@ -57,7 +46,7 @@ export type AddSecretModalInitialSecret = { } interface AddSecretModalProps { - jsonSecrets?: { reference: string; secretManagerAccessId?: string; keys?: string[] }[] + jsonSecrets?: { reference: string; secretManagerAccessId?: string }[] secretSources: SecretSourceOption[] defaultSource?: SecretSourceOption isFile?: boolean @@ -111,13 +100,6 @@ export function AddSecretModal({ const secretNameValue = methods.watch('secretName') const selectedSourceId = methods.watch('source') - const selectedReference = methods.watch('reference') - // TODO(secret-manager-json): Replace fixture lookup with key-only API metadata; never fetch secret values. - const jsonKeys = jsonSecrets?.find( - (secret) => secret.secretManagerAccessId === selectedSourceId && secret.reference === selectedReference.trim() - )?.keys - const hasJsonKeys = Boolean(jsonKeys?.length) - const { data: providerSecrets, isError: isProviderSecretsError, @@ -168,7 +150,7 @@ export function AddSecretModal({ const handleSubmit = methods.handleSubmit(async (data) => { const finalReference = (data.reference || referenceInput).trim() - if (!finalReference || (isJson && !hasJsonKeys)) return + if (!finalReference) return const finalName = data.secretName.trim() || finalReference.split('/').pop()?.toUpperCase() || 'NEW_SECRET' const selectedSource = secretSources.find((option) => option.value === data.source) ?? secretSources[0] @@ -184,7 +166,7 @@ export function AddSecretModal({ description: descriptionValue ? descriptionValue : undefined, filePath: isFile ? finalPath : undefined, isFile, - ...(isJson ? { isJson: true, keys: jsonKeys } : {}), + ...(isJson ? { isJson: true } : {}), reference: finalReference, secretManagerAccessId: selectedSource.value, }) @@ -236,7 +218,6 @@ export function AddSecretModal({ onClose={onClose} submitLabel={mode === 'edit' ? 'Edit secret' : 'Add secret'} loading={methods.formState.isSubmitting} - submitDisabled={isJson && !hasJsonKeys} > <>

Secret parameters

@@ -354,27 +335,6 @@ export function AddSecretModal({ )} /> - {isJson && ( -
-

- Variables to import{hasJsonKeys ? ` (${jsonKeys?.length})` : ''} -

-
- {hasJsonKeys && jsonKeys ? ( - - ) : ( - - )} -
-
- )} - {shouldDisplayEnvironmentAcknowledgement && (
diff --git a/libs/domains/variables/feature/src/lib/external-secrets/external-secret-json-preview.ts b/libs/domains/variables/feature/src/lib/external-secrets/external-secret-json-preview.ts index 362ae6ac31a..eb542ac81b4 100644 --- a/libs/domains/variables/feature/src/lib/external-secrets/external-secret-json-preview.ts +++ b/libs/domains/variables/feature/src/lib/external-secrets/external-secret-json-preview.ts @@ -4,14 +4,13 @@ import { type VariableScope } from '@qovery/domains/variables/data-access' import { generateScopeLabel } from '@qovery/shared/util-js' // UI model only. Replace the local preview storage when the API contract is available. -// Keep only key names here: never fetch or store the JSON secret's values. +// Reference metadata only: never fetch or store the JSON secret's contents. export interface ExternalSecretJSON { id: string name: string reference: string description?: string isJson: true - keys?: string[] source: string | null sourceIcon?: 'AWS' | 'GCP' secretManagerAccessId?: string @@ -36,32 +35,8 @@ export function createJsonSecretPreview( id: 'local-json-example', name: 'Lucis application secrets', reference: 'lucis/production/application', - description: 'Example JSON secret — keys only, no secret values.', + description: 'Example JSON secret reference.', isJson: true, - keys: [ - 'DATABASE_URL', - 'REDIS_URL', - 'API_KEY', - 'JWT_SECRET', - 'SESSION_SECRET', - 'ENCRYPTION_KEY', - 'SMTP_HOST', - 'SMTP_PORT', - 'SMTP_USERNAME', - 'SMTP_PASSWORD', - 'STRIPE_SECRET_KEY', - 'STRIPE_WEBHOOK_SECRET', - 'S3_BUCKET', - 'S3_ACCESS_KEY_ID', - 'S3_SECRET_ACCESS_KEY', - 'OAUTH_CLIENT_ID', - 'OAUTH_CLIENT_SECRET', - 'SENTRY_DSN', - 'WEBHOOK_SIGNING_SECRET', - 'INTERNAL_API_TOKEN', - 'ANALYTICS_WRITE_KEY', - 'SEARCH_API_KEY', - ], source: manager?.name ?? 'Example secret manager', sourceIcon: getSecretManagerProvider(manager), secretManagerAccessId: manager?.id, diff --git a/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.spec.tsx b/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.spec.tsx index f0348b3b961..4952d228d70 100644 --- a/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.spec.tsx +++ b/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.spec.tsx @@ -1,7 +1,6 @@ import { useParams } from '@tanstack/react-router' import { type ReactElement, type ReactNode } from 'react' import { useModal, useModalConfirmation } from '@qovery/shared/ui' -import * as utilHooks from '@qovery/shared/util-hooks' import { act, renderWithProviders, screen } from '@qovery/shared/util-tests' import { useCreateVariable } from '../hooks/use-create-variable/use-create-variable' import { useDeleteVariable } from '../hooks/use-delete-variable/use-delete-variable' @@ -509,49 +508,13 @@ describe('ExternalSecretsTab', () => { '/organization/organization-id/cluster/cluster-id/settings/addons' ) }) - it('shows JSON keys under the reference and supports collapsing without exposing values', async () => { - jest.mocked(createJsonSecretPreview).mockReturnValue([ - { - id: 'local-json-example', - name: 'Application credentials', - reference: 'lucis/app', - isJson: true, - keys: ['DATABASE_URL', 'API_KEY'], - source: 'Example manager', - scope: 'Environment', - previewParentId: 'environment-id', - }, - ]) - useVariablesMock.mockReturnValue({ data: [] } as unknown as ReturnType) - const { userEvent } = renderWithProviders() - expect(screen.getByText('Application credentials')).toBeInTheDocument() - expect(screen.getByText('DATABASE_URL')).toBeInTheDocument() - expect(screen.getByText('API_KEY')).toBeInTheDocument() - await userEvent.click(screen.getByText('Application credentials')) - expect(screen.queryByText('DATABASE_URL')).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'Expand keys for lucis/app' })).not.toBeInTheDocument() - screen.getByRole('cell', { name: 'Expand keys for lucis/app' }).focus() - await userEvent.keyboard('{Enter}') - expect(screen.getByText('DATABASE_URL')).toBeInTheDocument() - const copyKey = jest.fn() - const copyHook = jest.spyOn(utilHooks, 'useCopyToClipboard').mockReturnValue([null, copyKey]) - await userEvent.click(screen.getByText('Application credentials')) - await userEvent.click(screen.getByText('Application credentials')) - await userEvent.click(screen.getByRole('button', { name: 'Copy DATABASE_URL' })) - expect(copyKey).toHaveBeenCalledWith('DATABASE_URL') - expect(screen.getByText('Copied')).toBeInTheDocument() - expect(screen.getByText('API_KEY')).toBeInTheDocument() - copyHook.mockRestore() - }) - - it('counts imported keys and searches inside collapsed JSON secrets', async () => { + it('counts JSON references as one secret and searches reference metadata only', async () => { jest.mocked(createJsonSecretPreview).mockReturnValue([ { id: 'json', name: 'Application credentials', reference: 'lucis/app', isJson: true, - keys: ['DATABASE_URL', 'API_KEY'], source: 'Example manager', scope: 'Environment', previewParentId: 'environment-id', @@ -569,39 +532,23 @@ describe('ExternalSecretsTab', () => { ], } as unknown as ReturnType) const { userEvent } = renderWithProviders() - expect(screen.getByText('3 external secrets')).toBeInTheDocument() + expect(screen.getByText('2 external secrets')).toBeInTheDocument() await userEvent.click(screen.getByText('Application credentials')) - expect(screen.queryByText('API_KEY')).not.toBeInTheDocument() + expect(screen.queryByLabelText(/Collapse keys|Expand keys/)).not.toBeInTheDocument() + expect(screen.queryByText(/secrets imported/)).not.toBeInTheDocument() await userEvent.click(screen.getByPlaceholderText('Search secrets')) - await userEvent.paste('api_key') - expect(screen.getByText('Application credentials')).toBeInTheDocument() - expect(screen.getByText('API_KEY')).toBeInTheDocument() - expect(screen.queryByText('DATABASE_URL')).not.toBeInTheDocument() - expect(screen.queryByText('PASSWORD')).not.toBeInTheDocument() - expect(screen.getByText('1/3 external secrets')).toBeInTheDocument() + await userEvent.paste('API_KEY') + expect(screen.getByText('0/2 external secrets')).toBeInTheDocument() + expect(screen.queryByText('Application credentials')).not.toBeInTheDocument() await userEvent.clear(screen.getByPlaceholderText('Search secrets')) - expect(screen.getByText('DATABASE_URL')).toBeInTheDocument() - expect(screen.getByText('API_KEY')).toBeInTheDocument() - await userEvent.click(screen.getByPlaceholderText('Search secrets')) - await userEvent.paste('Application credentials') + await userEvent.paste('lucis/app') expect(screen.getByText('Application credentials')).toBeInTheDocument() - expect(screen.getByText('No keys match your search.')).toBeInTheDocument() - expect(screen.getByText('0/3 external secrets')).toBeInTheDocument() - expect(screen.queryByText('DATABASE_URL')).not.toBeInTheDocument() - expect(screen.queryByText('API_KEY')).not.toBeInTheDocument() + expect(screen.getByText('1/2 external secrets')).toBeInTheDocument() await userEvent.clear(screen.getByPlaceholderText('Search secrets')) - await userEvent.click(screen.getByPlaceholderText('Search secrets')) - await userEvent.paste('lucis') - expect(screen.getByText('Application credentials')).toBeInTheDocument() - expect(screen.getByText('PASSWORD')).toBeInTheDocument() - expect(screen.getByText('1/3 external secrets')).toBeInTheDocument() - await userEvent.clear(screen.getByPlaceholderText('Search secrets')) - await userEvent.click(screen.getByPlaceholderText('Search secrets')) - await userEvent.paste('no-match') - expect(screen.getByText('0/3 external secrets')).toBeInTheDocument() - expect(screen.getByText('No secrets match your search or filters.')).toBeInTheDocument() + await userEvent.paste('Application credentials') + expect(screen.getByText('1/2 external secrets')).toBeInTheDocument() await userEvent.clear(screen.getByPlaceholderText('Search secrets')) - expect(screen.getByText('3 external secrets')).toBeInTheDocument() + expect(screen.getByText('2 external secrets')).toBeInTheDocument() }) it('creates, edits and deletes JSON previews without calling the variable API', async () => { @@ -642,8 +589,7 @@ describe('ExternalSecretsTab', () => { } await act(async () => modal.props.onSubmit(data)) expect(screen.getByText('lucis/app')).toBeInTheDocument() - expect(screen.getByText(/Keys are not available yet/)).toBeInTheDocument() - expect(screen.getByText('0 external secrets · 1 JSON count unavailable')).toBeInTheDocument() + expect(screen.getByText('1 external secret')).toBeInTheDocument() await userEvent.click(screen.getByRole('button', { name: 'Edit' })) const editModal = openModal.mock.calls[1][0].content as typeof modal expect(editModal.props.isJson).toBe(true) diff --git a/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.tsx b/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.tsx index 1902a224fa2..609ee731303 100644 --- a/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.tsx +++ b/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.tsx @@ -13,7 +13,7 @@ import { useReactTable, } from '@tanstack/react-table' import posthog from 'posthog-js' -import { Fragment, type KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { match } from 'ts-pattern' import { getSecretManagerProvider } from '@qovery/domains/clusters/data-access' import { type VariableScope } from '@qovery/domains/variables/data-access' @@ -44,7 +44,6 @@ import { mapSecretManagersToSources, } from './add-secret-modal/add-secret-modal' import { type ExternalSecretJSON, createJsonSecretPreview } from './external-secret-json-preview' -import { JsonSecretKeys } from './json-secret-keys' const { Table } = TablePrimitives @@ -57,7 +56,6 @@ type ExternalSecretRow = { filePath?: string isFile?: boolean isJson?: boolean - keys?: string[] previewParentId?: string secretManagerAccessId?: string reference: string @@ -97,7 +95,6 @@ export function ExternalSecretsTab({ const [jsonSecrets, setJsonSecrets] = useState(() => createJsonSecretPreview(parentId, scope, secretManagers[0]) ) - const [collapsedJsonSecrets, setCollapsedJsonSecrets] = useState>({}) const secrets = useMemo( () => [ @@ -121,7 +118,6 @@ export function ExternalSecretsTab({ [secretManagers, variables, jsonSecrets, parentId] ) const [search, setSearch] = useState('') - const searchQuery = search.trim().toLowerCase() const [sorting, setSorting] = useState([]) const [columnFilters, setColumnFilters] = useState([]) const [rowSelection, setRowSelection] = useState({}) @@ -154,7 +150,6 @@ export function ExternalSecretsTab({ filePath, isFile, isJson, - keys, reference, secretManagerAccessId, }: AddSecretModalSubmitData) => { @@ -168,7 +163,6 @@ export function ExternalSecretsTab({ reference, description, isJson: true, - keys, source: manager?.name ?? null, sourceIcon: getSecretManagerProvider(manager), secretManagerAccessId, @@ -198,7 +192,7 @@ export function ExternalSecretsTab({ const handleEditSecret = useCallback( async ( secretId: string, - { name, description, filePath, isFile, isJson, keys, reference, secretManagerAccessId }: AddSecretModalSubmitData + { name, description, filePath, isFile, isJson, reference, secretManagerAccessId }: AddSecretModalSubmitData ) => { if (isJson) { const manager = secretManagers.find((item) => item.id === secretManagerAccessId) @@ -213,7 +207,6 @@ export function ExternalSecretsTab({ secretManagerAccessId, source: manager?.name ?? null, sourceIcon: getSecretManagerProvider(manager), - keys, } : secret ) @@ -408,27 +401,6 @@ export function ExternalSecretsTab({ enableSorting: true, cell: (info) => { const secret = info.row.original - if (secret.isJson) { - const expanded = !collapsedJsonSecrets[secret.id] - return ( -
- - - {secret.name} - - - - {secret.keys - ? `${secret.keys.length} ${pluralize(secret.keys.length, 'secret')} imported` - : 'Keys unavailable'} - - - - - -
- ) - } const showFilePath = secret.isFile && secret.filePath return (
@@ -535,7 +507,7 @@ export function ExternalSecretsTab({ }, }), ], - [handleConfirmDeleteSecrets, handleOpenEditSecret, collapsedJsonSecrets] + [handleConfirmDeleteSecrets, handleOpenEditSecret] ) const table = useReactTable({ @@ -546,14 +518,6 @@ export function ExternalSecretsTab({ onRowSelectionChange: setRowSelection, onGlobalFilterChange: setSearch, onColumnFiltersChange: setColumnFilters, - globalFilterFn: (row, columnId, value: string) => { - const query = value.trim().toLowerCase() - return ( - String(row.getValue(columnId) ?? '') - .toLowerCase() - .includes(query) || Boolean(row.original.keys?.some((key) => key.toLowerCase().includes(query))) - ) - }, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), @@ -563,22 +527,12 @@ export function ExternalSecretsTab({ }) const shouldShowEmptyState = secrets.length === 0 && Boolean(emptyStateConfig) - const countSecrets = (rows: ExternalSecretRow[], query = '') => - rows.reduce( - (count, secret) => - count + (secret.isJson ? secret.keys?.filter((key) => key.toLowerCase().includes(query)).length ?? 0 : 1), - 0 - ) - const totalSecrets = countSecrets(secrets) - const visibleSecrets = table.getRowModel().rows.map(({ original }) => original) - const visibleCount = countSecrets(visibleSecrets, searchQuery) - const unknownCount = visibleSecrets.filter((secret) => secret.isJson && !secret.keys).length - const isSearching = visibleCount !== totalSecrets || table.getRowCount() !== secrets.length + const totalRows = table.getPreFilteredRowModel().rows.length + const visibleRows = table.getRowCount() const countText = - (isSearching - ? `${visibleCount}/${totalSecrets} external ${totalSecrets === 1 ? 'secret' : 'secrets'}` - : `${totalSecrets} external ${totalSecrets === 1 ? 'secret' : 'secrets'}`) + - (unknownCount > 0 ? ` · ${unknownCount} JSON ${pluralize(unknownCount, 'count')} unavailable` : '') + visibleRows !== totalRows + ? `${visibleRows}/${totalRows} external ${pluralize(totalRows, 'secret')}` + : `${totalRows} external ${pluralize(totalRows, 'secret')}` const selectedRows = table.getSelectedRowModel().rows.map(({ original }) => original) const selectedIds = selectedRows.map((secret) => secret.id) const hasSelection = selectedIds.length > 0 @@ -589,7 +543,7 @@ export function ExternalSecretsTab({
{countText} - + @@ -600,22 +554,7 @@ export function ExternalSecretsTab({ className="h-8 w-[180px]" placeholder="Search secrets" value={search} - onChange={(e) => { - const value = e.target.value - setSearch(value) - const query = value.trim().toLowerCase() - if (query) { - setCollapsedJsonSecrets((current) => { - const next = { ...current } - for (const secret of secrets) { - if (secret.keys?.some((key) => key.toLowerCase().includes(query))) { - delete next[secret.id] - } - } - return next - }) - } - }} + onChange={(e) => setSearch(e.target.value)} />
@@ -683,63 +622,25 @@ export function ExternalSecretsTab({ )} - {table.getRowModel().rows.map((row) => { - const matchingKeys = row.original.keys?.filter((key) => key.toLowerCase().includes(searchQuery)) - return ( - - ( + + {row.getVisibleCells().map((cell) => ( + - {row.getVisibleCells().map((cell) => ( - - setCollapsedJsonSecrets((current) => ({ ...current, [row.id]: !current[row.id] })), - onKeyDown: (event: KeyboardEvent) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault() - setCollapsedJsonSecrets((current) => ({ ...current, [row.id]: !current[row.id] })) - } - }, - } - : {})} - className={twMerge( - 'flex h-full items-center first:relative', - cell.column.id === 'actions' && 'justify-end', - cell.column.id === 'name' && 'border-r border-neutral', - cell.column.id === 'name' && - row.original.isJson && - 'cursor-pointer focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-brand-strong' - )} - > - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - {row.original.isJson && !collapsedJsonSecrets[row.id] && ( - - - {matchingKeys ? ( - matchingKeys.length > 0 ? ( - - ) : ( -

No keys match your search.

- ) - ) : ( -

Keys are not available yet.

- )} -
-
- )} -
- ) - })} + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ))} )} diff --git a/libs/domains/variables/feature/src/lib/external-secrets/json-secret-keys.tsx b/libs/domains/variables/feature/src/lib/external-secrets/json-secret-keys.tsx deleted file mode 100644 index 392d64535ef..00000000000 --- a/libs/domains/variables/feature/src/lib/external-secrets/json-secret-keys.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { Button, CopyToClipboard } from '@qovery/shared/ui' -import { twMerge } from '@qovery/shared/util-js' - -export function JsonSecretKeys({ keys, label, className }: { keys: string[]; label: string; className?: string }) { - return ( -
    - {keys.map((key) => ( -
  • - - - -
  • - ))} -
- ) -} diff --git a/libs/shared/interfaces/src/lib/common/flow-variable-data.interface.ts b/libs/shared/interfaces/src/lib/common/flow-variable-data.interface.ts index ea3aefc26c2..b806f76d889 100644 --- a/libs/shared/interfaces/src/lib/common/flow-variable-data.interface.ts +++ b/libs/shared/interfaces/src/lib/common/flow-variable-data.interface.ts @@ -16,9 +16,8 @@ export interface ExternalSecretData { description?: string filePath?: string isFile?: boolean - // TODO(secret-manager-json): Replace these preview fields with the API model before production. + // TODO(secret-manager-json): Replace this preview field with the API model before production. isJson?: boolean - keys?: string[] source?: string | null sourceIcon?: 'aws' | 'gcp' secretManagerAccessId?: string From 8c99a9e29aff9cf1ac9cb55db7b8c2544dff781b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Tue, 13 Oct 2026 11:28:56 +0200 Subject: [PATCH 08/17] feat(variables): add optional JSON secret key prefix --- .../step-variables/step-variables.tsx | 2 +- .../add-secret-modal.spec.tsx | 42 ++++++++++++++++++- .../add-secret-modal/add-secret-modal.tsx | 25 ++++++++++- .../external-secret-json-preview.ts | 1 + .../external-secrets-tab.spec.tsx | 3 ++ .../external-secrets/external-secrets-tab.tsx | 15 ++++++- .../common/flow-variable-data.interface.ts | 3 +- 7 files changed, 85 insertions(+), 6 deletions(-) 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 1721379cd41..62739696e5f 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 @@ -67,7 +67,7 @@ function mapSecretForForm( description: secret.description, filePath: secret.filePath, isFile: secret.isFile, - ...(secret.isJson ? { isJson: true } : {}), + ...(secret.isJson ? { isJson: true, keyPrefix: secret.keyPrefix } : {}), reference: secret.reference, secretManagerAccessId: secret.secretManagerAccessId, source: source?.tableLabel ?? null, diff --git a/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.spec.tsx b/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.spec.tsx index 0c2ccaa35d5..3285262fde7 100644 --- a/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.spec.tsx +++ b/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.spec.tsx @@ -105,7 +105,7 @@ describe('AddSecretModal', () => { /> ) expect(screen.getByText('Add secret as JSON')).toBeInTheDocument() - expect(screen.getByText('Display name only. Use the JSON keys in your code.')).toBeInTheDocument() + expect(screen.getByText('For identification only. Not used in your code.')).toBeInTheDocument() expect(screen.queryByLabelText('Path')).not.toBeInTheDocument() expect(screen.getByRole('button', { name: 'Add secret' })).toBeDisabled() await userEvent.type(screen.getByLabelText('Reference'), 'lucis/production/application') @@ -169,4 +169,44 @@ describe('AddSecretModal', () => { ) expect(onSubmit.mock.calls[0][0]).not.toHaveProperty('keys') }) + it.each(['', 'APP_'])('submits a JSON reference with optional prefix "%s"', async (prefix) => { + const onSubmit = jest.fn() + const { userEvent } = renderWithProviders( + + ) + expect(screen.getByLabelText('Key prefix (optional)')).toHaveValue('') + await userEvent.click(screen.getByLabelText('Display name')) + await userEvent.paste('Application credentials') + await userEvent.click(screen.getByLabelText('Reference')) + await userEvent.paste('lucis/app') + await userEvent.click(screen.getByLabelText('Key prefix (optional)')) + await userEvent.paste(prefix) + await userEvent.click(screen.getByRole('button', { name: 'Add secret' })) + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + isJson: true, + keyPrefix: prefix || undefined, + }) + ) + ) + }) + + it('restores and clears the JSON prefix when editing', async () => { + const onSubmit = jest.fn() + const { userEvent } = renderWithProviders( + + ) + expect(screen.getByLabelText('Key prefix (optional)')).toHaveValue('APP_') + await userEvent.clear(screen.getByLabelText('Key prefix (optional)')) + await userEvent.click(screen.getByRole('button', { name: 'Edit secret' })) + await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ keyPrefix: undefined }))) + }) }) diff --git a/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.tsx b/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.tsx index d2b242d2c93..0d5e8fd3be8 100644 --- a/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.tsx +++ b/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.tsx @@ -32,6 +32,7 @@ export type AddSecretModalSubmitData = { name: string description?: string filePath?: string + keyPrefix?: string isFile: boolean isJson?: boolean reference: string @@ -43,6 +44,7 @@ export type AddSecretModalInitialSecret = { reference: string description?: string filePath?: string + keyPrefix?: string } interface AddSecretModalProps { @@ -63,6 +65,7 @@ type AddSecretFormValues = { reference: string path: string secretName: string + keyPrefix: string description: string acknowledgeEnvironmentSecretCost: boolean } @@ -87,6 +90,7 @@ export function AddSecretModal({ reference: initialSecret?.reference ?? '', path: initialSecret?.filePath ?? '', secretName: initialSecret?.name ?? '', + keyPrefix: initialSecret?.keyPrefix ?? '', description: initialSecret?.description ?? '', acknowledgeEnvironmentSecretCost: false, }, @@ -166,7 +170,7 @@ export function AddSecretModal({ description: descriptionValue ? descriptionValue : undefined, filePath: isFile ? finalPath : undefined, isFile, - ...(isJson ? { isJson: true } : {}), + ...(isJson ? { isJson: true, keyPrefix: data.keyPrefix.trim() || undefined } : {}), reference: finalReference, secretManagerAccessId: selectedSource.value, }) @@ -313,7 +317,7 @@ export function AddSecretModal({ onChange={field.onChange} hint={ isJson - ? 'Display name only. Use the JSON keys in your code.' + ? 'For identification only. Not used in your code.' : 'Name that other Qovery services will use' } error={error?.message} @@ -321,6 +325,23 @@ export function AddSecretModal({ )} /> + {isJson && ( + ( + + )} + /> + )} + { await userEvent.click(screen.getByRole('menuitem', { name: 'Add secret as JSON' })) const modal = openModal.mock.calls[0][0].content as ReactElement<{ isJson: boolean + initialSecret?: { keyPrefix?: string } onSubmit: (data: AddSecretModalSubmitData) => Promise }> expect(modal.props.isJson).toBe(true) @@ -586,6 +587,7 @@ describe('ExternalSecretsTab', () => { isJson: true, isFile: false, secretManagerAccessId: 'manager-id', + keyPrefix: 'APP_', } await act(async () => modal.props.onSubmit(data)) expect(screen.getByText('lucis/app')).toBeInTheDocument() @@ -593,6 +595,7 @@ describe('ExternalSecretsTab', () => { await userEvent.click(screen.getByRole('button', { name: 'Edit' })) const editModal = openModal.mock.calls[1][0].content as typeof modal expect(editModal.props.isJson).toBe(true) + expect(editModal.props.initialSecret?.keyPrefix).toBe('APP_') await act(async () => editModal.props.onSubmit({ ...data, name: 'Updated credentials', reference: 'lucis/updated' }) ) diff --git a/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.tsx b/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.tsx index 609ee731303..671daa8cb3d 100644 --- a/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.tsx +++ b/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.tsx @@ -56,6 +56,7 @@ type ExternalSecretRow = { filePath?: string isFile?: boolean isJson?: boolean + keyPrefix?: string previewParentId?: string secretManagerAccessId?: string reference: string @@ -150,6 +151,7 @@ export function ExternalSecretsTab({ filePath, isFile, isJson, + keyPrefix, reference, secretManagerAccessId, }: AddSecretModalSubmitData) => { @@ -163,6 +165,7 @@ export function ExternalSecretsTab({ reference, description, isJson: true, + keyPrefix, source: manager?.name ?? null, sourceIcon: getSecretManagerProvider(manager), secretManagerAccessId, @@ -192,7 +195,16 @@ export function ExternalSecretsTab({ const handleEditSecret = useCallback( async ( secretId: string, - { name, description, filePath, isFile, isJson, reference, secretManagerAccessId }: AddSecretModalSubmitData + { + name, + description, + filePath, + isFile, + isJson, + keyPrefix, + reference, + secretManagerAccessId, + }: AddSecretModalSubmitData ) => { if (isJson) { const manager = secretManagers.find((item) => item.id === secretManagerAccessId) @@ -204,6 +216,7 @@ export function ExternalSecretsTab({ name, reference, description, + keyPrefix, secretManagerAccessId, source: manager?.name ?? null, sourceIcon: getSecretManagerProvider(manager), diff --git a/libs/shared/interfaces/src/lib/common/flow-variable-data.interface.ts b/libs/shared/interfaces/src/lib/common/flow-variable-data.interface.ts index b806f76d889..c1d450aca52 100644 --- a/libs/shared/interfaces/src/lib/common/flow-variable-data.interface.ts +++ b/libs/shared/interfaces/src/lib/common/flow-variable-data.interface.ts @@ -16,8 +16,9 @@ export interface ExternalSecretData { description?: string filePath?: string isFile?: boolean - // TODO(secret-manager-json): Replace this preview field with the API model before production. + // TODO(secret-manager-json): Replace these preview fields with the API model before production. isJson?: boolean + keyPrefix?: string source?: string | null sourceIcon?: 'aws' | 'gcp' secretManagerAccessId?: string From 7a1b57cc2e2c5bb9f255ece6b978368a87c615ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Tue, 13 Oct 2026 11:33:30 +0200 Subject: [PATCH 09/17] feat(variables): show JSON secret prefixes in lists --- .../step-variables/step-variables.spec.tsx | 10 ++++++---- .../step-variables/step-variables.tsx | 6 ++++++ .../lib/external-secrets/external-secrets-tab.spec.tsx | 2 ++ .../src/lib/external-secrets/external-secrets-tab.tsx | 6 ++++++ 4 files changed, 20 insertions(+), 4 deletions(-) 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 fb7f9f8722b..8e00986b0b9 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 @@ -94,7 +94,7 @@ function VariablesState() { return
{JSON.stringify(variablesForm.watch())}
} -function JsonSecretStep() { +function JsonSecretStep({ keyPrefix }: { keyPrefix?: string }) { const { variablesForm } = useApplicationContainerCreateContext() useEffect(() => { variablesForm.setValue('externalSecrets', [ @@ -102,9 +102,10 @@ function JsonSecretStep() { name: 'Application credentials', reference: 'lucis/production/application', isJson: true, + keyPrefix, }, ]) - }, [variablesForm]) + }, [variablesForm, keyPrefix]) return } @@ -226,7 +227,7 @@ describe('ApplicationContainerStepVariables', () => { expect(screen.queryAllByText('lucis/production/application')).toHaveLength(option === 'Add secret as JSON' ? 2 : 0) }) - it('renders JSON references without expandable key details', async () => { + it.each([undefined, 'APP_'])('renders JSON references with prefix %s without expandable key details', (keyPrefix) => { mockUseVariablesSecretManagers.mockReturnValue({ secretManagers: [], hasClusterSecretManagerConfigured: true, @@ -237,11 +238,12 @@ describe('ApplicationContainerStepVariables', () => { creationFlowUrl="/organization/org-1/project/proj-1/environment/env-1/service/create/application" defaultServiceType="APPLICATION" > - + ) expect(screen.getByText('Application credentials')).toBeInTheDocument() expect(screen.getByText('lucis/production/application')).toBeInTheDocument() + expect(screen.getByText(keyPrefix || 'No prefix')).toBeInTheDocument() expect(screen.queryByLabelText(/Expand keys|Collapse keys|Copy DATABASE_URL/)).not.toBeInTheDocument() expect(screen.queryByText(/secrets imported/)).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 62739696e5f..24600cdd83d 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 @@ -427,6 +427,12 @@ export function ApplicationContainerStepVariables({ onBack, onSubmit }: Applicat )}
+ {secret.isJson && ( +
+ + {secret.keyPrefix || 'No prefix'} +
+ )} {secret.filePath && (
diff --git a/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.spec.tsx b/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.spec.tsx index 10a32a400b5..35e4c56b71f 100644 --- a/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.spec.tsx +++ b/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.spec.tsx @@ -533,6 +533,7 @@ describe('ExternalSecretsTab', () => { } as unknown as ReturnType) const { userEvent } = renderWithProviders() expect(screen.getByText('2 external secrets')).toBeInTheDocument() + expect(screen.getByText('No prefix')).toBeInTheDocument() await userEvent.click(screen.getByText('Application credentials')) expect(screen.queryByLabelText(/Collapse keys|Expand keys/)).not.toBeInTheDocument() expect(screen.queryByText(/secrets imported/)).not.toBeInTheDocument() @@ -596,6 +597,7 @@ describe('ExternalSecretsTab', () => { const editModal = openModal.mock.calls[1][0].content as typeof modal expect(editModal.props.isJson).toBe(true) expect(editModal.props.initialSecret?.keyPrefix).toBe('APP_') + expect(screen.getByText('APP_')).toBeInTheDocument() await act(async () => editModal.props.onSubmit({ ...data, name: 'Updated credentials', reference: 'lucis/updated' }) ) diff --git a/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.tsx b/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.tsx index 671daa8cb3d..d0a038d4825 100644 --- a/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.tsx +++ b/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.tsx @@ -429,6 +429,12 @@ export function ExternalSecretsTab({ )}
+ {secret.isJson && ( +
+ + {secret.keyPrefix || 'No prefix'} +
+ )} {showFilePath && (
From 91550395a5140c524aa494d5cc5275eb2e2d958e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Tue, 13 Oct 2026 11:35:41 +0200 Subject: [PATCH 10/17] fix(variables): clarify automatic JSON prefix separator --- .../step-variables/step-variables.spec.tsx | 43 ++++++++++--------- .../step-variables/step-variables.tsx | 4 +- .../add-secret-modal.spec.tsx | 6 +-- .../add-secret-modal/add-secret-modal.tsx | 2 +- .../external-secrets-tab.spec.tsx | 4 +- .../external-secrets/external-secrets-tab.tsx | 2 +- 6 files changed, 33 insertions(+), 28 deletions(-) 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 8e00986b0b9..2d13d2e7428 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 @@ -227,26 +227,29 @@ describe('ApplicationContainerStepVariables', () => { expect(screen.queryAllByText('lucis/production/application')).toHaveLength(option === 'Add secret as JSON' ? 2 : 0) }) - it.each([undefined, 'APP_'])('renders JSON references with prefix %s without expandable key details', (keyPrefix) => { - mockUseVariablesSecretManagers.mockReturnValue({ - secretManagers: [], - hasClusterSecretManagerConfigured: true, - clusterId: 'cluster-1', - }) - renderWithProviders( - - - - ) - expect(screen.getByText('Application credentials')).toBeInTheDocument() - expect(screen.getByText('lucis/production/application')).toBeInTheDocument() - expect(screen.getByText(keyPrefix || 'No prefix')).toBeInTheDocument() - expect(screen.queryByLabelText(/Expand keys|Collapse keys|Copy DATABASE_URL/)).not.toBeInTheDocument() - expect(screen.queryByText(/secrets imported/)).not.toBeInTheDocument() - }) + it.each([undefined, 'APP', 'APP_'])( + 'renders JSON references with prefix %s without expandable key details', + (keyPrefix) => { + mockUseVariablesSecretManagers.mockReturnValue({ + secretManagers: [], + hasClusterSecretManagerConfigured: true, + clusterId: 'cluster-1', + }) + renderWithProviders( + + + + ) + expect(screen.getByText('Application credentials')).toBeInTheDocument() + expect(screen.getByText('lucis/production/application')).toBeInTheDocument() + expect(screen.getByText(keyPrefix ? 'APP_' : 'No prefix')).toBeInTheDocument() + expect(screen.queryByLabelText(/Expand keys|Collapse keys|Copy DATABASE_URL/)).not.toBeInTheDocument() + expect(screen.queryByText(/secrets imported/)).not.toBeInTheDocument() + } + ) it('does not expose external secret creation when no secret manager is linked', () => { renderWithProviders( 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 24600cdd83d..ae71ea8b28f 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 @@ -430,7 +430,9 @@ export function ApplicationContainerStepVariables({ onBack, onSubmit }: Applicat {secret.isJson && (
- {secret.keyPrefix || 'No prefix'} + + {secret.keyPrefix ? `${secret.keyPrefix.replace(/_+$/, '')}_` : 'No prefix'} +
)} {secret.filePath && ( diff --git a/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.spec.tsx b/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.spec.tsx index 3285262fde7..cbb7f373f20 100644 --- a/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.spec.tsx +++ b/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.spec.tsx @@ -169,7 +169,7 @@ describe('AddSecretModal', () => { ) expect(onSubmit.mock.calls[0][0]).not.toHaveProperty('keys') }) - it.each(['', 'APP_'])('submits a JSON reference with optional prefix "%s"', async (prefix) => { + it.each(['', 'APP'])('submits a JSON reference with optional prefix "%s"', async (prefix) => { const onSubmit = jest.fn() const { userEvent } = renderWithProviders( @@ -198,13 +198,13 @@ describe('AddSecretModal', () => { ) - expect(screen.getByLabelText('Key prefix (optional)')).toHaveValue('APP_') + expect(screen.getByLabelText('Key prefix (optional)')).toHaveValue('APP') await userEvent.clear(screen.getByLabelText('Key prefix (optional)')) await userEvent.click(screen.getByRole('button', { name: 'Edit secret' })) await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ keyPrefix: undefined }))) diff --git a/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.tsx b/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.tsx index 0d5e8fd3be8..93a64f74313 100644 --- a/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.tsx +++ b/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.tsx @@ -336,7 +336,7 @@ export function AddSecretModal({ label="Key prefix (optional)" value={field.value} onChange={field.onChange} - hint="Prepended to every JSON key to avoid conflicts (e.g. APP_)." + hint="Avoid conflicts with a prefix. We automatically add _ after it: APP becomes APP_KEY." /> )} /> diff --git a/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.spec.tsx b/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.spec.tsx index 35e4c56b71f..f41544b1ccd 100644 --- a/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.spec.tsx +++ b/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.spec.tsx @@ -588,7 +588,7 @@ describe('ExternalSecretsTab', () => { isJson: true, isFile: false, secretManagerAccessId: 'manager-id', - keyPrefix: 'APP_', + keyPrefix: 'APP', } await act(async () => modal.props.onSubmit(data)) expect(screen.getByText('lucis/app')).toBeInTheDocument() @@ -596,7 +596,7 @@ describe('ExternalSecretsTab', () => { await userEvent.click(screen.getByRole('button', { name: 'Edit' })) const editModal = openModal.mock.calls[1][0].content as typeof modal expect(editModal.props.isJson).toBe(true) - expect(editModal.props.initialSecret?.keyPrefix).toBe('APP_') + expect(editModal.props.initialSecret?.keyPrefix).toBe('APP') expect(screen.getByText('APP_')).toBeInTheDocument() await act(async () => editModal.props.onSubmit({ ...data, name: 'Updated credentials', reference: 'lucis/updated' }) diff --git a/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.tsx b/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.tsx index d0a038d4825..aaf2dbec4ca 100644 --- a/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.tsx +++ b/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.tsx @@ -432,7 +432,7 @@ export function ExternalSecretsTab({ {secret.isJson && (
- {secret.keyPrefix || 'No prefix'} + {secret.keyPrefix ? `${secret.keyPrefix.replace(/_+$/, '')}_` : 'No prefix'}
)} {showFilePath && ( From fa4d0c792b4f9751e0cfb95e1a861e9a6eca14e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Tue, 13 Oct 2026 11:38:45 +0200 Subject: [PATCH 11/17] docs(variables): clarify JSON prefix hint --- .../lib/external-secrets/add-secret-modal/add-secret-modal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.tsx b/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.tsx index 93a64f74313..82123ca9bb5 100644 --- a/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.tsx +++ b/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.tsx @@ -336,7 +336,7 @@ export function AddSecretModal({ label="Key prefix (optional)" value={field.value} onChange={field.onChange} - hint="Avoid conflicts with a prefix. We automatically add _ after it: APP becomes APP_KEY." + hint="Prefix each key to avoid name conflicts. An underscore is added automatically." /> )} /> From 42dc59f3ef0fe5c84a087a583e917bacf60b22e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Tue, 13 Oct 2026 11:42:21 +0200 Subject: [PATCH 12/17] refactor(variables): remove JSON secret mock data and persistence --- .../step-summary/step-summary.spec.tsx | 2 +- .../step-summary/step-summary.tsx | 4 +- .../step-variables/step-variables.spec.tsx | 17 ++-- .../step-variables/step-variables.tsx | 10 +- libs/domains/variables/feature/src/index.ts | 2 - .../add-secret-modal.spec.tsx | 31 +++--- .../add-secret-modal/add-secret-modal.tsx | 24 ++--- .../external-secret-json-preview.ts | 48 --------- .../external-secrets-tab.spec.tsx | 99 +------------------ .../external-secrets/external-secrets-tab.tsx | 93 ++++------------- .../common/flow-variable-data.interface.ts | 2 +- 11 files changed, 61 insertions(+), 271 deletions(-) delete mode 100644 libs/domains/variables/feature/src/lib/external-secrets/external-secret-json-preview.ts diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-summary/step-summary/step-summary.spec.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-summary/step-summary/step-summary.spec.tsx index c125776ecac..5717d2ac2ff 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-summary/step-summary/step-summary.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-summary/step-summary/step-summary.spec.tsx @@ -249,7 +249,7 @@ describe('ApplicationContainerStepSummary', () => { }) }) - it('does not send JSON previews to the external variable API', async () => { + it('does not send unsupported JSON references to the external variable API', async () => { const { userEvent } = renderComponent({ externalSecrets: [{ ...externalSecretsData[0], isJson: true }], }) diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-summary/step-summary/step-summary.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-summary/step-summary/step-summary.tsx index d7cdc237b7f..43286011516 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-summary/step-summary/step-summary.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-summary/step-summary/step-summary.tsx @@ -115,8 +115,8 @@ export function ApplicationContainerStepSummary({ // External secret creation errors are surfaced by the mutation notifications but should not block the service creation flow. await Promise.allSettled( externalSecretsData.map(async (externalSecret) => { - // TODO(secret-manager-json): Replace this preview-only guard with JSON API integration before production. - // Never send fake JSON references through the real variable API. + // TODO(secret-manager-json): Wire JSON API integration before enabling JSON secrets in service creation. + // JSON references must not be sent through the single-variable API. if (externalSecret.isJson) return const scope = externalSecret.scope ?? createPayload.serviceType return createVariable({ 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 2d13d2e7428..81346ca2ac4 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 @@ -37,22 +37,19 @@ jest.mock('@tanstack/react-router', () => ({ })) jest.mock('@qovery/domains/variables/feature', () => ({ - createJsonSecretPreview: () => [{ reference: 'lucis/production/application' }], AddSecretDropdown: jest.requireActual('@qovery/domains/variables/feature').AddSecretDropdown, AddSecretModal: ({ isFile, isJson, - jsonSecrets, + submitDisabled, }: { isFile?: boolean isJson?: boolean - jsonSecrets?: { reference: string }[] + submitDisabled?: boolean }) => ( -
- {isJson ? 'JSON secret modal' : isFile ? 'File secret modal' : 'Variable secret modal'} - {isJson && - jsonSecrets?.map((secret, index) => {secret.reference})} -
+ ), VariableFormModal: ({ onSubmit, @@ -224,7 +221,9 @@ describe('ApplicationContainerStepVariables', () => { ]) await userEvent.click(screen.getByRole('menuitem', { name: option })) expect(screen.getByText(modal)).toBeInTheDocument() - expect(screen.queryAllByText('lucis/production/application')).toHaveLength(option === 'Add secret as JSON' ? 2 : 0) + expect(screen.getByRole('button', { name: modal }))[ + option === 'Add secret as JSON' ? 'toBeDisabled' : 'toBeEnabled' + ]() }) it.each([undefined, 'APP', 'APP_'])( 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 ae71ea8b28f..6ccaece81ac 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 @@ -8,7 +8,6 @@ import { type AddSecretModalSubmitData, type CreateUpdateVariableModalSubmitData, VariableFormModal, - createJsonSecretPreview, mapSecretManagersToSources, useVariablesSecretManagers, } from '@qovery/domains/variables/feature' @@ -84,11 +83,6 @@ export function ApplicationContainerStepVariables({ onBack, onSubmit }: Applicat const serviceScope = generalForm.getValues('serviceType') === 'APPLICATION' ? 'APPLICATION' : 'CONTAINER' const secretSources = useMemo(() => mapSecretManagersToSources(secretManagers), [secretManagers]) - // TODO(secret-manager-json): Remove fixture references before production and use the provider reference list. - const jsonSecrets = useMemo( - () => secretManagers.flatMap((manager) => createJsonSecretPreview(environmentId, serviceScope, manager)), - [environmentId, serviceScope, secretManagers] - ) const sourceById = useMemo(() => new Map(secretSources.map((source) => [source.value, source])), [secretSources]) const { @@ -176,13 +170,15 @@ export function ApplicationContainerStepVariables({ onBack, onSubmit }: Applicat { + if (secret.isJson) throw new Error('JSON secret API integration is not available yet.') const mapped = mapSecretForForm(secret, sourceById, serviceScope) if (typeof index === 'number') { updateExternalSecret(index, mapped) diff --git a/libs/domains/variables/feature/src/index.ts b/libs/domains/variables/feature/src/index.ts index 67199719a81..183616421d4 100644 --- a/libs/domains/variables/feature/src/index.ts +++ b/libs/domains/variables/feature/src/index.ts @@ -21,5 +21,3 @@ export * from './lib/external-secrets/external-secrets-tab' export * from './lib/external-secrets/secret-manager-feature-flag-entry-point' export * from './lib/external-secrets/add-secret-modal/add-secret-modal' export * from './lib/external-secrets/add-secret-dropdown' -// TODO(secret-manager-json): Remove the fixture export and its creation-flow usage before production. -export { createJsonSecretPreview } from './lib/external-secrets/external-secret-json-preview' diff --git a/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.spec.tsx b/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.spec.tsx index cbb7f373f20..1bddaf5eab1 100644 --- a/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.spec.tsx +++ b/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.spec.tsx @@ -91,18 +91,7 @@ describe('AddSecretModal', () => { it('submits a JSON display name separately from its reference', async () => { const onSubmit = jest.fn() const { userEvent } = renderWithProviders( - + ) expect(screen.getByText('Add secret as JSON')).toBeInTheDocument() expect(screen.getByText('For identification only. Not used in your code.')).toBeInTheDocument() @@ -132,7 +121,6 @@ describe('AddSecretModal', () => { const { userEvent } = renderWithProviders( { await userEvent.click(screen.getByRole('button', { name: 'Edit secret' })) await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ keyPrefix: undefined }))) }) + it('blocks disabled JSON submission through the form as well as the button', async () => { + const onSubmit = jest.fn() + const { userEvent } = renderWithProviders( + + ) + expect(screen.getByRole('button', { name: 'Add secret' })).toBeDisabled() + await userEvent.click(screen.getByLabelText('Display name')) + await userEvent.keyboard('{Enter}') + expect(onSubmit).not.toHaveBeenCalled() + }) }) diff --git a/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.tsx b/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.tsx index 82123ca9bb5..b2e5f0c1788 100644 --- a/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.tsx +++ b/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.tsx @@ -48,7 +48,7 @@ export type AddSecretModalInitialSecret = { } interface AddSecretModalProps { - jsonSecrets?: { reference: string; secretManagerAccessId?: string }[] + submitDisabled?: boolean secretSources: SecretSourceOption[] defaultSource?: SecretSourceOption isFile?: boolean @@ -72,7 +72,7 @@ type AddSecretFormValues = { export function AddSecretModal({ secretSources, - jsonSecrets, + submitDisabled = false, defaultSource, isFile = false, isJson = false, @@ -135,26 +135,13 @@ export function AddSecretModal({ ) const referenceOptions = useMemo( - () => - [ - ...new Set([ - ...(providerSecrets?.results ?? []), - ...(isJson - ? (jsonSecrets ?? []) - .filter((secret) => secret.secretManagerAccessId === selectedSourceId) - .map((secret) => secret.reference) - : []), - ]), - ].map((secret) => ({ - label: secret, - value: secret, - })), - [providerSecrets?.results, isJson, jsonSecrets, selectedSourceId] + () => (providerSecrets?.results ?? []).map((secret) => ({ label: secret, value: secret })), + [providerSecrets?.results] ) const handleSubmit = methods.handleSubmit(async (data) => { const finalReference = (data.reference || referenceInput).trim() - if (!finalReference) return + if (!finalReference || submitDisabled) return const finalName = data.secretName.trim() || finalReference.split('/').pop()?.toUpperCase() || 'NEW_SECRET' const selectedSource = secretSources.find((option) => option.value === data.source) ?? secretSources[0] @@ -222,6 +209,7 @@ export function AddSecretModal({ onClose={onClose} submitLabel={mode === 'edit' ? 'Edit secret' : 'Add secret'} loading={methods.formState.isSubmitting} + submitDisabled={submitDisabled} > <>

Secret parameters

diff --git a/libs/domains/variables/feature/src/lib/external-secrets/external-secret-json-preview.ts b/libs/domains/variables/feature/src/lib/external-secrets/external-secret-json-preview.ts deleted file mode 100644 index fea5ba13751..00000000000 --- a/libs/domains/variables/feature/src/lib/external-secrets/external-secret-json-preview.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { type SecretManagerAccess } from 'qovery-typescript-axios' -import { getSecretManagerProvider } from '@qovery/domains/clusters/data-access' -import { type VariableScope } from '@qovery/domains/variables/data-access' -import { generateScopeLabel } from '@qovery/shared/util-js' - -// UI model only. Replace the local preview storage when the API contract is available. -// Reference metadata only: never fetch or store the JSON secret's contents. -export interface ExternalSecretJSON { - id: string - name: string - reference: string - description?: string - isJson: true - keyPrefix?: string - source: string | null - sourceIcon?: 'AWS' | 'GCP' - secretManagerAccessId?: string - scope: string - previewParentId: string -} - -// TODO(secret-manager-json): REMOVE BEFORE PRODUCTION: this entire fixture, its export, and its usages in the variables tab and service creation. -export function createJsonSecretPreview( - parentId: string, - scope: VariableScope, - manager?: SecretManagerAccess -): ExternalSecretJSON[] { - if ( - window.location.hostname !== 'localhost' || - !['ddb7eba1-a074-485f-b8f2-a7c1c2ad5719', 'a40ee878-05a1-434c-adba-0c03ed54a4bd'].includes(parentId) - ) - return [] - - return [ - { - id: 'local-json-example', - name: 'Lucis application secrets', - reference: 'lucis/production/application', - description: 'Example JSON secret reference.', - isJson: true, - source: manager?.name ?? 'Example secret manager', - sourceIcon: getSecretManagerProvider(manager), - secretManagerAccessId: manager?.id, - scope: generateScopeLabel(scope), - previewParentId: parentId, - }, - ] -} diff --git a/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.spec.tsx b/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.spec.tsx index f41544b1ccd..770015ff06b 100644 --- a/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.spec.tsx +++ b/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.spec.tsx @@ -1,18 +1,15 @@ import { useParams } from '@tanstack/react-router' import { type ReactElement, type ReactNode } from 'react' import { useModal, useModalConfirmation } from '@qovery/shared/ui' -import { act, renderWithProviders, screen } from '@qovery/shared/util-tests' +import { renderWithProviders, screen } from '@qovery/shared/util-tests' import { useCreateVariable } from '../hooks/use-create-variable/use-create-variable' import { useDeleteVariable } from '../hooks/use-delete-variable/use-delete-variable' import { useEditVariable } from '../hooks/use-edit-variable/use-edit-variable' import { useVariablesSecretManagers } from '../hooks/use-variables-secret-managers/use-variables-secret-managers' import { useVariables } from '../hooks/use-variables/use-variables' import { type AddSecretModalSubmitData } from './add-secret-modal/add-secret-modal' -import { createJsonSecretPreview } from './external-secret-json-preview' import { ExternalSecretsTab } from './external-secrets-tab' -jest.mock('./external-secret-json-preview', () => ({ createJsonSecretPreview: jest.fn(() => []) })) - jest.mock('../hooks/use-variables/use-variables', () => ({ useVariables: jest.fn(), })) @@ -73,7 +70,6 @@ describe('ExternalSecretsTab', () => { beforeEach(() => { jest.clearAllMocks() - jest.mocked(createJsonSecretPreview).mockReturnValue([]) useParamsMock.mockReturnValue({ organizationId: 'organization-id', environmentId: 'environment-id', @@ -508,65 +504,9 @@ describe('ExternalSecretsTab', () => { '/organization/organization-id/cluster/cluster-id/settings/addons' ) }) - it('counts JSON references as one secret and searches reference metadata only', async () => { - jest.mocked(createJsonSecretPreview).mockReturnValue([ - { - id: 'json', - name: 'Application credentials', - reference: 'lucis/app', - isJson: true, - source: 'Example manager', - scope: 'Environment', - previewParentId: 'environment-id', - }, - ]) - useVariablesMock.mockReturnValue({ - data: [ - { - id: 'regular', - key: 'PASSWORD', - value: 'lucis/password', - scope: 'ENVIRONMENT', - variable_type: 'EXTERNAL_SECRET', - }, - ], - } as unknown as ReturnType) - const { userEvent } = renderWithProviders() - expect(screen.getByText('2 external secrets')).toBeInTheDocument() - expect(screen.getByText('No prefix')).toBeInTheDocument() - await userEvent.click(screen.getByText('Application credentials')) - expect(screen.queryByLabelText(/Collapse keys|Expand keys/)).not.toBeInTheDocument() - expect(screen.queryByText(/secrets imported/)).not.toBeInTheDocument() - await userEvent.click(screen.getByPlaceholderText('Search secrets')) - await userEvent.paste('API_KEY') - expect(screen.getByText('0/2 external secrets')).toBeInTheDocument() - expect(screen.queryByText('Application credentials')).not.toBeInTheDocument() - await userEvent.clear(screen.getByPlaceholderText('Search secrets')) - await userEvent.paste('lucis/app') - expect(screen.getByText('Application credentials')).toBeInTheDocument() - expect(screen.getByText('1/2 external secrets')).toBeInTheDocument() - await userEvent.clear(screen.getByPlaceholderText('Search secrets')) - await userEvent.paste('Application credentials') - expect(screen.getByText('1/2 external secrets')).toBeInTheDocument() - await userEvent.clear(screen.getByPlaceholderText('Search secrets')) - expect(screen.getByText('2 external secrets')).toBeInTheDocument() - }) - - it('creates, edits and deletes JSON previews without calling the variable API', async () => { - const createVariable = jest.fn() - const editVariable = jest.fn() - const deleteVariable = jest.fn() + it('disables JSON submission until its API integration is available', async () => { const openModal = jest.fn() - const openModalConfirmation = jest.fn() - useCreateVariableMock.mockReturnValue({ mutateAsync: createVariable } as unknown as ReturnType< - typeof useCreateVariable - >) - useEditVariableMock.mockReturnValue({ mutateAsync: editVariable } as unknown as ReturnType) - useDeleteVariableMock.mockReturnValue({ mutateAsync: deleteVariable } as unknown as ReturnType< - typeof useDeleteVariable - >) useModalMock.mockReturnValue({ openModal, closeModal: jest.fn() }) - useModalConfirmationMock.mockReturnValue({ openModalConfirmation }) useVariablesMock.mockReturnValue({ data: [] } as unknown as ReturnType) useVariablesSecretManagersMock.mockReturnValue({ secretManagers: [], @@ -576,38 +516,9 @@ describe('ExternalSecretsTab', () => { const { userEvent } = renderWithProviders() await userEvent.click(screen.getByRole('button', { name: 'Add secret' })) await userEvent.click(screen.getByRole('menuitem', { name: 'Add secret as JSON' })) - const modal = openModal.mock.calls[0][0].content as ReactElement<{ - isJson: boolean - initialSecret?: { keyPrefix?: string } - onSubmit: (data: AddSecretModalSubmitData) => Promise - }> + const modal = openModal.mock.calls[0][0].content as ReactElement<{ isJson: boolean; submitDisabled: boolean }> expect(modal.props.isJson).toBe(true) - const data = { - name: 'Application credentials', - reference: 'lucis/app', - isJson: true, - isFile: false, - secretManagerAccessId: 'manager-id', - keyPrefix: 'APP', - } - await act(async () => modal.props.onSubmit(data)) - expect(screen.getByText('lucis/app')).toBeInTheDocument() - expect(screen.getByText('1 external secret')).toBeInTheDocument() - await userEvent.click(screen.getByRole('button', { name: 'Edit' })) - const editModal = openModal.mock.calls[1][0].content as typeof modal - expect(editModal.props.isJson).toBe(true) - expect(editModal.props.initialSecret?.keyPrefix).toBe('APP') - expect(screen.getByText('APP_')).toBeInTheDocument() - await act(async () => - editModal.props.onSubmit({ ...data, name: 'Updated credentials', reference: 'lucis/updated' }) - ) - expect(screen.getByText('lucis/updated')).toBeInTheDocument() - expect(screen.getByText('Updated credentials')).toBeInTheDocument() - await userEvent.click(screen.getByRole('button', { name: 'Delete' })) - await act(async () => openModalConfirmation.mock.calls[0][0].action()) - expect(screen.queryByText('lucis/updated')).not.toBeInTheDocument() - expect(createVariable).not.toHaveBeenCalled() - expect(editVariable).not.toHaveBeenCalled() - expect(deleteVariable).not.toHaveBeenCalled() + expect(modal.props.submitDisabled).toBe(true) + expect(screen.queryByText('Lucis application secrets')).not.toBeInTheDocument() }) }) diff --git a/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.tsx b/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.tsx index aaf2dbec4ca..1729724e1d9 100644 --- a/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.tsx +++ b/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.tsx @@ -43,7 +43,6 @@ import { type SecretSourceOption, mapSecretManagersToSources, } from './add-secret-modal/add-secret-modal' -import { type ExternalSecretJSON, createJsonSecretPreview } from './external-secret-json-preview' const { Table } = TablePrimitives @@ -57,7 +56,6 @@ type ExternalSecretRow = { isFile?: boolean isJson?: boolean keyPrefix?: string - previewParentId?: string secretManagerAccessId?: string reference: string source: string | null @@ -91,15 +89,9 @@ export function ExternalSecretsTab({ suspense: true, }) - // TODO(secret-manager-json): Replace local create/edit/delete state with the API when its contract is available. - // The seeded example and all JSON changes currently reset when this component remounts. - const [jsonSecrets, setJsonSecrets] = useState(() => - createJsonSecretPreview(parentId, scope, secretManagers[0]) - ) - const secrets = useMemo( - () => [ - ...variables.filter(isExternalSecretVariable).map((variable) => { + () => + variables.filter(isExternalSecretVariable).map((variable) => { const secretManager = secretManagers.find((manager) => manager.id === variable.secret_manager_access_id) return { @@ -114,9 +106,7 @@ export function ExternalSecretsTab({ scope: generateScopeLabel(variable.scope), } }), - ...jsonSecrets.filter((secret) => secret.previewParentId === parentId), - ], - [secretManagers, variables, jsonSecrets, parentId] + [secretManagers, variables] ) const [search, setSearch] = useState('') const [sorting, setSorting] = useState([]) @@ -151,30 +141,11 @@ export function ExternalSecretsTab({ filePath, isFile, isJson, - keyPrefix, reference, secretManagerAccessId, }: AddSecretModalSubmitData) => { - if (isJson) { - const manager = secretManagers.find((item) => item.id === secretManagerAccessId) - setJsonSecrets((current) => [ - ...current, - { - id: `local-json-${crypto.randomUUID()}`, - name, - reference, - description, - isJson: true, - keyPrefix, - source: manager?.name ?? null, - sourceIcon: getSecretManagerProvider(manager), - secretManagerAccessId, - scope: generateScopeLabel(scope), - previewParentId: parentId, - }, - ]) - return - } + // TODO(secret-manager-json): Wire the JSON API before enabling submission. + if (isJson) throw new Error('JSON secret API integration is not available yet.') await createVariable({ variableRequest: { key: name, @@ -189,43 +160,16 @@ export function ExternalSecretsTab({ }) onCreateSecret?.() }, - [createVariable, onCreateSecret, parentId, scope, secretManagers] + [createVariable, onCreateSecret, parentId, scope] ) const handleEditSecret = useCallback( async ( secretId: string, - { - name, - description, - filePath, - isFile, - isJson, - keyPrefix, - reference, - secretManagerAccessId, - }: AddSecretModalSubmitData + { name, description, filePath, isFile, isJson, reference, secretManagerAccessId }: AddSecretModalSubmitData ) => { - if (isJson) { - const manager = secretManagers.find((item) => item.id === secretManagerAccessId) - setJsonSecrets((current) => - current.map((secret) => - secret.id === secretId - ? { - ...secret, - name, - reference, - description, - keyPrefix, - secretManagerAccessId, - source: manager?.name ?? null, - sourceIcon: getSecretManagerProvider(manager), - } - : secret - ) - ) - return - } + // TODO(secret-manager-json): Wire the JSON API before enabling submission. + if (isJson) throw new Error('JSON secret API integration is not available yet.') const variableEditRequest = { key: name, value: reference, @@ -240,7 +184,7 @@ export function ExternalSecretsTab({ }) onEditSecret?.() }, - [editVariable, onEditSecret, secretManagers] + [editVariable, onEditSecret] ) const handleOpenAddSecret = useCallback( @@ -254,10 +198,10 @@ export function ExternalSecretsTab({ content: ( handleEditSecret(secret.id, updated)} @@ -294,7 +238,7 @@ export function ExternalSecretsTab({ }, }) }, - [closeModal, getSourceOption, handleEditSecret, openModal, secretSources, jsonSecrets] + [closeModal, getSourceOption, handleEditSecret, openModal, secretSources] ) useEffect(() => { @@ -307,11 +251,8 @@ export function ExternalSecretsTab({ const handleDeleteSecrets = useCallback( async (secretIds: string[]) => { const idsToDelete = new Set(secretIds) - const localIds = new Set(jsonSecrets.map((secret) => secret.id)) - const remoteIds = [...idsToDelete].filter((id) => !localIds.has(id)) - await Promise.all(remoteIds.map((variableId) => deleteVariable({ variableId }))) - setJsonSecrets((current) => current.filter((secret) => !idsToDelete.has(secret.id))) - if (remoteIds.length > 0) onDeleteSecret?.() + await Promise.all([...idsToDelete].map((variableId) => deleteVariable({ variableId }))) + onDeleteSecret?.() setRowSelection((prev) => { const next = { ...prev } for (const secretId of secretIds) { @@ -320,7 +261,7 @@ export function ExternalSecretsTab({ return next }) }, - [deleteVariable, onDeleteSecret, jsonSecrets] + [deleteVariable, onDeleteSecret] ) const handleConfirmDeleteSecrets = useCallback( diff --git a/libs/shared/interfaces/src/lib/common/flow-variable-data.interface.ts b/libs/shared/interfaces/src/lib/common/flow-variable-data.interface.ts index c1d450aca52..7ba83814235 100644 --- a/libs/shared/interfaces/src/lib/common/flow-variable-data.interface.ts +++ b/libs/shared/interfaces/src/lib/common/flow-variable-data.interface.ts @@ -16,7 +16,7 @@ export interface ExternalSecretData { description?: string filePath?: string isFile?: boolean - // TODO(secret-manager-json): Replace these preview fields with the API model before production. + // TODO(secret-manager-json): Align these fields with the JSON API contract when available. isJson?: boolean keyPrefix?: string source?: string | null From 872946d5a411eb962a848f2aa7344168a174f49f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Tue, 13 Oct 2026 11:43:32 +0200 Subject: [PATCH 13/17] fix(variables): infer JSON display name from reference --- .../add-secret-modal/add-secret-modal.spec.tsx | 18 ++++++++++++++++++ .../add-secret-modal/add-secret-modal.tsx | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.spec.tsx b/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.spec.tsx index 1bddaf5eab1..1a4127f8e30 100644 --- a/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.spec.tsx +++ b/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.spec.tsx @@ -214,4 +214,22 @@ describe('AddSecretModal', () => { await userEvent.keyboard('{Enter}') expect(onSubmit).not.toHaveBeenCalled() }) + it('infers the JSON display name from the reference without overwriting a custom name', async () => { + useSecretManagerProviderSecretsMock.mockReturnValue({ + data: { results: ['production/app', 'production/database'] }, + isError: false, + isFetching: false, + } as ReturnType) + const { userEvent } = renderWithProviders( + + ) + await userEvent.click(screen.getByLabelText('Reference')) + await userEvent.click(screen.getByText('production/app')) + expect(screen.getByLabelText('Display name')).toHaveValue('APP') + await userEvent.clear(screen.getByLabelText('Display name')) + await userEvent.paste('My credentials') + await userEvent.click(screen.getByLabelText('Reference')) + await userEvent.click(screen.getByText('production/database')) + expect(screen.getByLabelText('Display name')).toHaveValue('My credentials') + }) }) diff --git a/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.tsx b/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.tsx index b2e5f0c1788..f554bd615bd 100644 --- a/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.tsx +++ b/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.tsx @@ -258,7 +258,7 @@ export function AddSecretModal({ onChange={(value) => { const selected = value as string field.onChange(selected) - if (!isJson && !secretNameValue) { + if (!secretNameValue) { const inferredName = selected.split('/').pop()?.toUpperCase() if (inferredName) { methods.setValue('secretName', inferredName, { shouldValidate: true }) From e4b38dbd3fedc446d7142c0cbde0ec9baaa25a6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Tue, 13 Oct 2026 11:47:14 +0200 Subject: [PATCH 14/17] fix(variables): remove JSON submission flag and section heading --- .../step-variables/step-variables.spec.tsx | 4 +--- .../step-variables/step-variables.tsx | 2 -- .../add-secret-modal.spec.tsx | 19 ++----------------- .../add-secret-modal/add-secret-modal.tsx | 6 +----- .../external-secrets-tab.spec.tsx | 6 +++--- .../external-secrets/external-secrets-tab.tsx | 6 ++---- 6 files changed, 9 insertions(+), 34 deletions(-) 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 81346ca2ac4..4c61bfbff78 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 @@ -221,9 +221,7 @@ describe('ApplicationContainerStepVariables', () => { ]) await userEvent.click(screen.getByRole('menuitem', { name: option })) expect(screen.getByText(modal)).toBeInTheDocument() - expect(screen.getByRole('button', { name: modal }))[ - option === 'Add secret as JSON' ? 'toBeDisabled' : 'toBeEnabled' - ]() + expect(screen.getByRole('button', { name: modal })).toBeEnabled() }) it.each([undefined, 'APP', 'APP_'])( 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 6ccaece81ac..bd774965ec4 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 @@ -173,8 +173,6 @@ export function ApplicationContainerStepVariables({ onBack, onSubmit }: Applicat defaultSource={defaultSource} isFile={isFile} isJson={isJson} - // TODO(secret-manager-json): Enable submission once JSON secrets can be persisted by the API. - submitDisabled={isJson} initialSecret={currentSecret} onClose={closeModal} onSubmit={(secret) => { diff --git a/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.spec.tsx b/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.spec.tsx index 1a4127f8e30..0a65c2d93f0 100644 --- a/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.spec.tsx +++ b/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.spec.tsx @@ -94,6 +94,7 @@ describe('AddSecretModal', () => { ) expect(screen.getByText('Add secret as JSON')).toBeInTheDocument() + expect(screen.queryByText('Secret parameters')).not.toBeInTheDocument() expect(screen.getByText('For identification only. Not used in your code.')).toBeInTheDocument() expect(screen.queryByLabelText('Path')).not.toBeInTheDocument() expect(screen.getByRole('button', { name: 'Add secret' })).toBeDisabled() @@ -145,6 +146,7 @@ describe('AddSecretModal', () => { await userEvent.paste('My credentials') await userEvent.click(screen.getByLabelText('Reference')) await userEvent.paste('new/json/reference') + expect(screen.getByRole('button', { name: 'Add secret' })).toBeEnabled() await userEvent.click(screen.getByRole('button', { name: 'Add secret' })) await waitFor(() => expect(onSubmit).toHaveBeenCalledWith( @@ -197,23 +199,6 @@ describe('AddSecretModal', () => { await userEvent.click(screen.getByRole('button', { name: 'Edit secret' })) await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ keyPrefix: undefined }))) }) - it('blocks disabled JSON submission through the form as well as the button', async () => { - const onSubmit = jest.fn() - const { userEvent } = renderWithProviders( - - ) - expect(screen.getByRole('button', { name: 'Add secret' })).toBeDisabled() - await userEvent.click(screen.getByLabelText('Display name')) - await userEvent.keyboard('{Enter}') - expect(onSubmit).not.toHaveBeenCalled() - }) it('infers the JSON display name from the reference without overwriting a custom name', async () => { useSecretManagerProviderSecretsMock.mockReturnValue({ data: { results: ['production/app', 'production/database'] }, diff --git a/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.tsx b/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.tsx index f554bd615bd..8518dee571e 100644 --- a/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.tsx +++ b/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.tsx @@ -48,7 +48,6 @@ export type AddSecretModalInitialSecret = { } interface AddSecretModalProps { - submitDisabled?: boolean secretSources: SecretSourceOption[] defaultSource?: SecretSourceOption isFile?: boolean @@ -72,7 +71,6 @@ type AddSecretFormValues = { export function AddSecretModal({ secretSources, - submitDisabled = false, defaultSource, isFile = false, isJson = false, @@ -141,7 +139,7 @@ export function AddSecretModal({ const handleSubmit = methods.handleSubmit(async (data) => { const finalReference = (data.reference || referenceInput).trim() - if (!finalReference || submitDisabled) return + if (!finalReference) return const finalName = data.secretName.trim() || finalReference.split('/').pop()?.toUpperCase() || 'NEW_SECRET' const selectedSource = secretSources.find((option) => option.value === data.source) ?? secretSources[0] @@ -209,10 +207,8 @@ export function AddSecretModal({ onClose={onClose} submitLabel={mode === 'edit' ? 'Edit secret' : 'Add secret'} loading={methods.formState.isSubmitting} - submitDisabled={submitDisabled} > <> -

Secret parameters

{ '/organization/organization-id/cluster/cluster-id/settings/addons' ) }) - it('disables JSON submission until its API integration is available', async () => { + it('opens the JSON form without a forced submission block', async () => { const openModal = jest.fn() useModalMock.mockReturnValue({ openModal, closeModal: jest.fn() }) useVariablesMock.mockReturnValue({ data: [] } as unknown as ReturnType) @@ -516,9 +516,9 @@ describe('ExternalSecretsTab', () => { const { userEvent } = renderWithProviders() await userEvent.click(screen.getByRole('button', { name: 'Add secret' })) await userEvent.click(screen.getByRole('menuitem', { name: 'Add secret as JSON' })) - const modal = openModal.mock.calls[0][0].content as ReactElement<{ isJson: boolean; submitDisabled: boolean }> + const modal = openModal.mock.calls[0][0].content as ReactElement<{ isJson: boolean; submitDisabled?: boolean }> expect(modal.props.isJson).toBe(true) - expect(modal.props.submitDisabled).toBe(true) + expect(modal.props.submitDisabled).toBeUndefined() expect(screen.queryByText('Lucis application secrets')).not.toBeInTheDocument() }) }) diff --git a/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.tsx b/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.tsx index 1729724e1d9..ef08816b4e8 100644 --- a/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.tsx +++ b/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.tsx @@ -144,7 +144,7 @@ export function ExternalSecretsTab({ reference, secretManagerAccessId, }: AddSecretModalSubmitData) => { - // TODO(secret-manager-json): Wire the JSON API before enabling submission. + // TODO(secret-manager-json): Wire JSON persistence to the API when its contract is available. if (isJson) throw new Error('JSON secret API integration is not available yet.') await createVariable({ variableRequest: { @@ -168,7 +168,7 @@ export function ExternalSecretsTab({ secretId: string, { name, description, filePath, isFile, isJson, reference, secretManagerAccessId }: AddSecretModalSubmitData ) => { - // TODO(secret-manager-json): Wire the JSON API before enabling submission. + // TODO(secret-manager-json): Wire JSON persistence to the API when its contract is available. if (isJson) throw new Error('JSON secret API integration is not available yet.') const variableEditRequest = { key: name, @@ -201,7 +201,6 @@ export function ExternalSecretsTab({ defaultSource={secretSources[0]} isFile={isFile} isJson={format === 'json'} - submitDisabled={format === 'json'} scope={scope} onClose={closeModal} onSubmit={handleCreateSecret} @@ -226,7 +225,6 @@ export function ExternalSecretsTab({ defaultSource={getSourceOption(secret)} isFile={secret.isFile ?? false} isJson={secret.isJson} - submitDisabled={secret.isJson} initialSecret={secret} onClose={closeModal} onSubmit={(updated) => handleEditSecret(secret.id, updated)} From ef0e502cada1b04f1eeba35b2b4ba14ed489eec4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Tue, 13 Oct 2026 11:54:58 +0200 Subject: [PATCH 15/17] fix(variables): handle JSON secret drafts locally pending API --- .../step-summary/step-summary.tsx | 4 +- .../step-variables/step-variables.spec.tsx | 48 ++++++++++- .../step-variables/step-variables.tsx | 2 +- .../add-secret-modal.spec.tsx | 5 ++ .../add-secret-modal/add-secret-modal.tsx | 9 ++ .../external-secrets-tab.spec.tsx | 59 ++++++++++++- .../external-secrets/external-secrets-tab.tsx | 83 ++++++++++++++++--- 7 files changed, 190 insertions(+), 20 deletions(-) diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-summary/step-summary/step-summary.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-summary/step-summary/step-summary.tsx index 43286011516..503e3f7d437 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-summary/step-summary/step-summary.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/application-container/application-container-summary/step-summary/step-summary.tsx @@ -115,8 +115,8 @@ export function ApplicationContainerStepSummary({ // External secret creation errors are surfaced by the mutation notifications but should not block the service creation flow. await Promise.allSettled( externalSecretsData.map(async (externalSecret) => { - // TODO(secret-manager-json): Wire JSON API integration before enabling JSON secrets in service creation. - // JSON references must not be sent through the single-variable API. + // TODO(secret-manager-json): Persist the form’s JSON drafts here when the real API handler is available. + // Until then they are local only; never send JSON references through the single-variable API. if (externalSecret.isJson) return const scope = externalSecret.scope ?? createPayload.serviceType return createVariable({ 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 4c61bfbff78..a711d43a5a0 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 @@ -1,5 +1,6 @@ import { type SecretManagerAccess } from 'qovery-typescript-axios' import { type ReactNode, useEffect } from 'react' +import { type AddSecretModalSubmitData } from '@qovery/domains/variables/feature' import { renderWithProviders, screen, waitFor } from '@qovery/shared/util-tests' import { ApplicationContainerCreationFlow, @@ -41,13 +42,28 @@ jest.mock('@qovery/domains/variables/feature', () => ({ AddSecretModal: ({ isFile, isJson, - submitDisabled, + onSubmit, + onClose, }: { isFile?: boolean isJson?: boolean - submitDisabled?: boolean + onSubmit: (data: AddSecretModalSubmitData) => void | Promise + onClose: () => void }) => ( - ), @@ -224,6 +240,32 @@ describe('ApplicationContainerStepVariables', () => { expect(screen.getByRole('button', { name: modal })).toBeEnabled() }) + it('adds and removes a user-entered JSON reference in the creation form', async () => { + mockUseVariablesSecretManagers.mockReturnValue({ + secretManagers: [], + hasClusterSecretManagerConfigured: true, + clusterId: 'cluster-1', + }) + const { userEvent } = renderWithProviders( + + + + + ) + const buttons = screen.getAllByRole('button', { name: 'Add secret' }) + await userEvent.click(buttons[buttons.length - 1]) + await userEvent.click(screen.getByRole('menuitem', { name: 'Add secret as JSON' })) + await userEvent.click(screen.getByRole('button', { name: 'JSON secret modal' })) + expect(screen.getByText('My JSON credentials')).toBeInTheDocument() + expect(screen.getByText('APP_')).toBeInTheDocument() + expect(screen.getByTestId('variables-state')).toHaveTextContent('"keyPrefix":"APP"') + await userEvent.click(screen.getByRole('button', { name: 'Delete secret' })) + expect(screen.queryByText('My JSON credentials')).not.toBeInTheDocument() + }) + it.each([undefined, 'APP', 'APP_'])( 'renders JSON references with prefix %s without expandable key details', (keyPrefix) => { 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 bd774965ec4..87ceb4b2693 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 @@ -176,7 +176,7 @@ export function ApplicationContainerStepVariables({ onBack, onSubmit }: Applicat initialSecret={currentSecret} onClose={closeModal} onSubmit={(secret) => { - if (secret.isJson) throw new Error('JSON secret API integration is not available yet.') + // TODO(secret-manager-json): Persist JSON references with the real API in the final creation step. const mapped = mapSecretForForm(secret, sourceById, serviceScope) if (typeof index === 'number') { updateExternalSecret(index, mapped) diff --git a/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.spec.tsx b/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.spec.tsx index 0a65c2d93f0..a42d281e692 100644 --- a/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.spec.tsx +++ b/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.spec.tsx @@ -133,6 +133,11 @@ describe('AddSecretModal', () => { await userEvent.click(screen.getByRole('button', { name: 'Edit secret' })) await waitFor(() => expect(onSubmit).toHaveBeenCalled()) expect(onClose).not.toHaveBeenCalled() + expect(screen.getByRole('alert')).toHaveTextContent('Could not save') + onSubmit.mockResolvedValueOnce(undefined) + await userEvent.click(screen.getByRole('button', { name: 'Edit secret' })) + await waitFor(() => expect(onClose).toHaveBeenCalled()) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() consoleError.mockRestore() }) it('accepts a JSON reference without fetching its keys', async () => { diff --git a/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.tsx b/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.tsx index 8518dee571e..d2c26fd17de 100644 --- a/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.tsx +++ b/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.tsx @@ -96,6 +96,7 @@ export function AddSecretModal({ }) const { enableAlertClickOutside } = useModal() const [referenceInput, setReferenceInput] = useState('') + const [submitError, setSubmitError] = useState() const debouncedReferenceInput = useDebounce(referenceInput, 300) const isDirty = methods.formState.isDirty const shouldDisplayEnvironmentAcknowledgement = scope === 'ENVIRONMENT' && mode === 'create' @@ -149,6 +150,7 @@ export function AddSecretModal({ const fallbackFilePath = `/vault/secrets/${finalReference.split('/').pop() || 'secret'}` const finalPath = data.path.trim() || fallbackFilePath + setSubmitError(undefined) try { await onSubmit({ name: finalName, @@ -175,6 +177,7 @@ export function AddSecretModal({ is_file: isFile, success: false, }) + setSubmitError(error instanceof Error ? error.message : 'Could not save the secret. Please try again.') console.error(error) } }) @@ -340,6 +343,12 @@ export function AddSecretModal({ )} /> + {submitError && ( +

+ {submitError} +

+ )} + {shouldDisplayEnvironmentAcknowledgement && (
diff --git a/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.spec.tsx b/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.spec.tsx index fd25635bd67..f5ad9158d08 100644 --- a/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.spec.tsx +++ b/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.spec.tsx @@ -1,7 +1,7 @@ import { useParams } from '@tanstack/react-router' import { type ReactElement, type ReactNode } from 'react' import { useModal, useModalConfirmation } from '@qovery/shared/ui' -import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import { act, renderWithProviders, screen } from '@qovery/shared/util-tests' import { useCreateVariable } from '../hooks/use-create-variable/use-create-variable' import { useDeleteVariable } from '../hooks/use-delete-variable/use-delete-variable' import { useEditVariable } from '../hooks/use-edit-variable/use-edit-variable' @@ -521,4 +521,61 @@ describe('ExternalSecretsTab', () => { expect(modal.props.submitDisabled).toBeUndefined() expect(screen.queryByText('Lucis application secrets')).not.toBeInTheDocument() }) + it('adds, edits and deletes user-entered JSON references without using the ordinary variable API', async () => { + const createVariable = jest.fn() + const editVariable = jest.fn() + const deleteVariable = jest.fn() + const openModal = jest.fn() + const openModalConfirmation = jest.fn() + useCreateVariableMock.mockReturnValue({ mutateAsync: createVariable } as unknown as ReturnType< + typeof useCreateVariable + >) + useEditVariableMock.mockReturnValue({ mutateAsync: editVariable } as unknown as ReturnType) + useDeleteVariableMock.mockReturnValue({ mutateAsync: deleteVariable } as unknown as ReturnType< + typeof useDeleteVariable + >) + useModalMock.mockReturnValue({ openModal, closeModal: jest.fn() }) + useModalConfirmationMock.mockReturnValue({ openModalConfirmation }) + useVariablesMock.mockReturnValue({ data: [] } as unknown as ReturnType) + useVariablesSecretManagersMock.mockReturnValue({ + secretManagers: [], + hasClusterSecretManagerConfigured: true, + clusterId: 'cluster-id', + }) + const { userEvent } = renderWithProviders() + await userEvent.click(screen.getByRole('button', { name: 'Add secret' })) + await userEvent.click(screen.getByRole('menuitem', { name: 'Add secret as JSON' })) + const modal = openModal.mock.calls[0][0].content as ReactElement<{ + isJson: boolean + initialSecret?: { keyPrefix?: string } + onSubmit: (data: AddSecretModalSubmitData) => Promise + }> + expect(modal.props.isJson).toBe(true) + const data = { + name: 'Application credentials', + reference: 'lucis/app', + isJson: true, + isFile: false, + secretManagerAccessId: 'manager-id', + keyPrefix: 'APP_', + } + await act(async () => modal.props.onSubmit(data)) + expect(screen.getByText('lucis/app')).toBeInTheDocument() + expect(screen.getByText('1 external secret')).toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: 'Edit' })) + const editModal = openModal.mock.calls[1][0].content as typeof modal + expect(editModal.props.isJson).toBe(true) + expect(editModal.props.initialSecret?.keyPrefix).toBe('APP_') + await act(async () => + editModal.props.onSubmit({ ...data, name: 'Updated credentials', reference: 'lucis/updated' }) + ) + expect(screen.getByText('lucis/updated')).toBeInTheDocument() + expect(screen.getByText('Updated credentials')).toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: 'Delete' })) + await act(async () => openModalConfirmation.mock.calls[0][0].action()) + expect(screen.queryByText('lucis/updated')).not.toBeInTheDocument() + expect(createVariable).not.toHaveBeenCalled() + expect(editVariable).not.toHaveBeenCalled() + expect(deleteVariable).not.toHaveBeenCalled() + }) }) diff --git a/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.tsx b/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.tsx index ef08816b4e8..7db0ade0b63 100644 --- a/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.tsx +++ b/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.tsx @@ -89,9 +89,12 @@ export function ExternalSecretsTab({ suspense: true, }) + // TODO(secret-manager-json): Replace these user-entered drafts and their handlers with the real API. + // No seeded data; drafts reset on remount and are isolated to their service/environment. + const [jsonDrafts, setJsonDrafts] = useState<(ExternalSecretRow & { parentId: string })[]>([]) const secrets = useMemo( - () => - variables.filter(isExternalSecretVariable).map((variable) => { + () => [ + ...variables.filter(isExternalSecretVariable).map((variable) => { const secretManager = secretManagers.find((manager) => manager.id === variable.secret_manager_access_id) return { @@ -106,7 +109,9 @@ export function ExternalSecretsTab({ scope: generateScopeLabel(variable.scope), } }), - [secretManagers, variables] + ...jsonDrafts.filter((secret) => secret.parentId === parentId), + ], + [secretManagers, variables, jsonDrafts, parentId] ) const [search, setSearch] = useState('') const [sorting, setSorting] = useState([]) @@ -141,11 +146,31 @@ export function ExternalSecretsTab({ filePath, isFile, isJson, + keyPrefix, reference, secretManagerAccessId, }: AddSecretModalSubmitData) => { - // TODO(secret-manager-json): Wire JSON persistence to the API when its contract is available. - if (isJson) throw new Error('JSON secret API integration is not available yet.') + // TODO(secret-manager-json): Replace local creation with the real JSON API handler. + if (isJson) { + const manager = secretManagers.find((item) => item.id === secretManagerAccessId) + setJsonDrafts((current) => [ + ...current, + { + id: `json-draft-${crypto.randomUUID()}`, + parentId, + name, + reference, + description, + isJson: true, + keyPrefix, + secretManagerAccessId, + source: manager?.name ?? null, + sourceIcon: getSecretManagerProvider(manager), + scope: generateScopeLabel(scope), + }, + ]) + return + } await createVariable({ variableRequest: { key: name, @@ -160,16 +185,44 @@ export function ExternalSecretsTab({ }) onCreateSecret?.() }, - [createVariable, onCreateSecret, parentId, scope] + [createVariable, onCreateSecret, parentId, scope, secretManagers] ) const handleEditSecret = useCallback( async ( secretId: string, - { name, description, filePath, isFile, isJson, reference, secretManagerAccessId }: AddSecretModalSubmitData + { + name, + description, + filePath, + isFile, + isJson, + keyPrefix, + reference, + secretManagerAccessId, + }: AddSecretModalSubmitData ) => { - // TODO(secret-manager-json): Wire JSON persistence to the API when its contract is available. - if (isJson) throw new Error('JSON secret API integration is not available yet.') + // TODO(secret-manager-json): Replace local editing with the real JSON API handler. + if (isJson) { + const manager = secretManagers.find((item) => item.id === secretManagerAccessId) + setJsonDrafts((current) => + current.map((secret) => + secret.id === secretId + ? { + ...secret, + name, + reference, + description, + keyPrefix, + secretManagerAccessId, + source: manager?.name ?? null, + sourceIcon: getSecretManagerProvider(manager), + } + : secret + ) + ) + return + } const variableEditRequest = { key: name, value: reference, @@ -184,7 +237,7 @@ export function ExternalSecretsTab({ }) onEditSecret?.() }, - [editVariable, onEditSecret] + [editVariable, onEditSecret, secretManagers] ) const handleOpenAddSecret = useCallback( @@ -249,8 +302,12 @@ export function ExternalSecretsTab({ const handleDeleteSecrets = useCallback( async (secretIds: string[]) => { const idsToDelete = new Set(secretIds) - await Promise.all([...idsToDelete].map((variableId) => deleteVariable({ variableId }))) - onDeleteSecret?.() + // TODO(secret-manager-json): Replace local deletion with the real JSON API handler. + const draftIds = new Set(jsonDrafts.map((secret) => secret.id)) + const persistedIds = [...idsToDelete].filter((id) => !draftIds.has(id)) + await Promise.all(persistedIds.map((variableId) => deleteVariable({ variableId }))) + setJsonDrafts((current) => current.filter((secret) => !idsToDelete.has(secret.id))) + if (persistedIds.length > 0) onDeleteSecret?.() setRowSelection((prev) => { const next = { ...prev } for (const secretId of secretIds) { @@ -259,7 +316,7 @@ export function ExternalSecretsTab({ return next }) }, - [deleteVariable, onDeleteSecret] + [deleteVariable, onDeleteSecret, jsonDrafts] ) const handleConfirmDeleteSecrets = useCallback( From 5b007bad3784c4b18071a25b9fe3386138e048ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Tue, 13 Oct 2026 11:58:31 +0200 Subject: [PATCH 16/17] fix(variables): clarify JSON prefix and align button typography --- .../step-variables/step-variables.spec.tsx | 4 ++-- .../step-variables/step-variables.tsx | 8 +++----- .../src/lib/external-secrets/external-secrets-tab.tsx | 2 +- 3 files changed, 6 insertions(+), 8 deletions(-) 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 a711d43a5a0..68527ba9330 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 @@ -260,7 +260,7 @@ describe('ApplicationContainerStepVariables', () => { await userEvent.click(screen.getByRole('menuitem', { name: 'Add secret as JSON' })) await userEvent.click(screen.getByRole('button', { name: 'JSON secret modal' })) expect(screen.getByText('My JSON credentials')).toBeInTheDocument() - expect(screen.getByText('APP_')).toBeInTheDocument() + expect(screen.getByText('Prefix: APP_')).toBeInTheDocument() expect(screen.getByTestId('variables-state')).toHaveTextContent('"keyPrefix":"APP"') await userEvent.click(screen.getByRole('button', { name: 'Delete secret' })) expect(screen.queryByText('My JSON credentials')).not.toBeInTheDocument() @@ -284,7 +284,7 @@ describe('ApplicationContainerStepVariables', () => { ) expect(screen.getByText('Application credentials')).toBeInTheDocument() expect(screen.getByText('lucis/production/application')).toBeInTheDocument() - expect(screen.getByText(keyPrefix ? 'APP_' : 'No prefix')).toBeInTheDocument() + expect(screen.getByText(keyPrefix ? 'Prefix: APP_' : 'No prefix')).toBeInTheDocument() expect(screen.queryByLabelText(/Expand keys|Collapse keys|Copy DATABASE_URL/)).not.toBeInTheDocument() expect(screen.queryByText(/secrets imported/)).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 87ceb4b2693..1ef2e97352d 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 @@ -251,10 +251,9 @@ export function ApplicationContainerStepVariables({ onBack, onSubmit }: Applicat color="neutral" variant="solid" size="md" - className="text-ssm" onClick={() => openVariableModal()} > - + Add variable
@@ -425,7 +423,7 @@ export function ApplicationContainerStepVariables({ onBack, onSubmit }: Applicat
- {secret.keyPrefix ? `${secret.keyPrefix.replace(/_+$/, '')}_` : 'No prefix'} + {secret.keyPrefix ? `Prefix: ${secret.keyPrefix.replace(/_+$/, '')}_` : 'No prefix'}
)} diff --git a/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.tsx b/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.tsx index 7db0ade0b63..0755b5f09f7 100644 --- a/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.tsx +++ b/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.tsx @@ -428,7 +428,7 @@ export function ExternalSecretsTab({ {secret.isJson && (
- {secret.keyPrefix ? `${secret.keyPrefix.replace(/_+$/, '')}_` : 'No prefix'} + {secret.keyPrefix ? `Prefix: ${secret.keyPrefix.replace(/_+$/, '')}_` : 'No prefix'}
)} {showFilePath && ( From ac57956d2599565f33f3ad200bc9d400f4e7f602 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Wed, 14 Oct 2026 10:50:53 +0200 Subject: [PATCH 17/17] feat(variables): select JSON key with a separate secret name --- .../step-variables/step-variables.spec.tsx | 55 +++---- .../step-variables/step-variables.tsx | 6 +- .../add-secret-modal.spec.tsx | 150 ++++++++---------- .../add-secret-modal/add-secret-modal.tsx | 69 ++++---- .../external-secrets-tab.spec.tsx | 15 +- .../external-secrets/external-secrets-tab.tsx | 12 +- .../common/flow-variable-data.interface.ts | 2 +- 7 files changed, 152 insertions(+), 157 deletions(-) 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 68527ba9330..f8c4498d78f 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 @@ -58,7 +58,7 @@ jest.mock('@qovery/domains/variables/feature', () => ({ reference: 'production/credentials', isJson, isFile: !!isFile, - keyPrefix: 'APP', + keyName: 'APP', secretManagerAccessId: 'manager-id', }) onClose() @@ -107,7 +107,7 @@ function VariablesState() { return
{JSON.stringify(variablesForm.watch())}
} -function JsonSecretStep({ keyPrefix }: { keyPrefix?: string }) { +function JsonSecretStep({ keyName }: { keyName?: string }) { const { variablesForm } = useApplicationContainerCreateContext() useEffect(() => { variablesForm.setValue('externalSecrets', [ @@ -115,10 +115,10 @@ function JsonSecretStep({ keyPrefix }: { keyPrefix?: string }) { name: 'Application credentials', reference: 'lucis/production/application', isJson: true, - keyPrefix, + keyName, }, ]) - }, [variablesForm, keyPrefix]) + }, [variablesForm, keyName]) return } @@ -260,35 +260,32 @@ describe('ApplicationContainerStepVariables', () => { await userEvent.click(screen.getByRole('menuitem', { name: 'Add secret as JSON' })) await userEvent.click(screen.getByRole('button', { name: 'JSON secret modal' })) expect(screen.getByText('My JSON credentials')).toBeInTheDocument() - expect(screen.getByText('Prefix: APP_')).toBeInTheDocument() - expect(screen.getByTestId('variables-state')).toHaveTextContent('"keyPrefix":"APP"') + expect(screen.getByText('Key: APP')).toBeInTheDocument() + expect(screen.getByTestId('variables-state')).toHaveTextContent('"keyName":"APP"') await userEvent.click(screen.getByRole('button', { name: 'Delete secret' })) expect(screen.queryByText('My JSON credentials')).not.toBeInTheDocument() }) - it.each([undefined, 'APP', 'APP_'])( - 'renders JSON references with prefix %s without expandable key details', - (keyPrefix) => { - mockUseVariablesSecretManagers.mockReturnValue({ - secretManagers: [], - hasClusterSecretManagerConfigured: true, - clusterId: 'cluster-1', - }) - renderWithProviders( - - - - ) - expect(screen.getByText('Application credentials')).toBeInTheDocument() - expect(screen.getByText('lucis/production/application')).toBeInTheDocument() - expect(screen.getByText(keyPrefix ? 'Prefix: APP_' : 'No prefix')).toBeInTheDocument() - expect(screen.queryByLabelText(/Expand keys|Collapse keys|Copy DATABASE_URL/)).not.toBeInTheDocument() - expect(screen.queryByText(/secrets imported/)).not.toBeInTheDocument() - } - ) + it.each(['APP', 'APP_'])('renders JSON references with key name %s without expandable key details', (keyName) => { + mockUseVariablesSecretManagers.mockReturnValue({ + secretManagers: [], + hasClusterSecretManagerConfigured: true, + clusterId: 'cluster-1', + }) + renderWithProviders( + + + + ) + expect(screen.getByText('Application credentials')).toBeInTheDocument() + expect(screen.getByText('lucis/production/application')).toBeInTheDocument() + expect(screen.getByText(`Key: ${keyName}`)).toBeInTheDocument() + expect(screen.queryByLabelText(/Expand keys|Collapse keys|Copy DATABASE_URL/)).not.toBeInTheDocument() + expect(screen.queryByText(/secrets imported/)).not.toBeInTheDocument() + }) it('does not expose external secret creation when no secret manager is linked', () => { renderWithProviders( 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 1ef2e97352d..1d8eb71c566 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 @@ -66,7 +66,7 @@ function mapSecretForForm( description: secret.description, filePath: secret.filePath, isFile: secret.isFile, - ...(secret.isJson ? { isJson: true, keyPrefix: secret.keyPrefix } : {}), + ...(secret.isJson ? { isJson: true, keyName: secret.keyName } : {}), reference: secret.reference, secretManagerAccessId: secret.secretManagerAccessId, source: source?.tableLabel ?? null, @@ -422,9 +422,7 @@ export function ApplicationContainerStepVariables({ onBack, onSubmit }: Applicat {secret.isJson && (
- - {secret.keyPrefix ? `Prefix: ${secret.keyPrefix.replace(/_+$/, '')}_` : 'No prefix'} - + {`Key: ${secret.keyName}`}
)} {secret.filePath && ( diff --git a/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.spec.tsx b/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.spec.tsx index a42d281e692..d7bd73e721f 100644 --- a/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.spec.tsx +++ b/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.spec.tsx @@ -88,125 +88,112 @@ describe('AddSecretModal', () => { ) }) }) - it('submits a JSON display name separately from its reference', async () => { + it('imports a JSON key and follows its name until the secret name is overridden', async () => { const onSubmit = jest.fn() const { userEvent } = renderWithProviders( ) - expect(screen.getByText('Add secret as JSON')).toBeInTheDocument() - expect(screen.queryByText('Secret parameters')).not.toBeInTheDocument() - expect(screen.getByText('For identification only. Not used in your code.')).toBeInTheDocument() - expect(screen.queryByLabelText('Path')).not.toBeInTheDocument() + expect(screen.queryByLabelText('Display name')).not.toBeInTheDocument() + expect(screen.queryByLabelText('Key prefix (optional)')).not.toBeInTheDocument() + expect(screen.queryByText('Variables to import')).not.toBeInTheDocument() + await userEvent.type(screen.getByLabelText('Reference'), 'production/database') expect(screen.getByRole('button', { name: 'Add secret' })).toBeDisabled() - await userEvent.type(screen.getByLabelText('Reference'), 'lucis/production/application') + await userEvent.type(screen.getByLabelText('Key name'), 'DATABASE_URL') + expect(screen.getByLabelText('Secret name')).toHaveValue('DATABASE_URL') + await userEvent.clear(screen.getByLabelText('Key name')) + await userEvent.type(screen.getByLabelText('Key name'), 'DATABASE_PASSWORD') + expect(screen.getByLabelText('Secret name')).toHaveValue('DATABASE_PASSWORD') + await userEvent.clear(screen.getByLabelText('Secret name')) expect(screen.getByRole('button', { name: 'Add secret' })).toBeDisabled() - await userEvent.type(screen.getByLabelText('Display name'), 'Application credentials') + await userEvent.type(screen.getByLabelText('Secret name'), 'APP_PASSWORD') + await userEvent.clear(screen.getByLabelText('Key name')) + await userEvent.type(screen.getByLabelText('Key name'), 'password') + expect(screen.getByLabelText('Secret name')).toHaveValue('APP_PASSWORD') await userEvent.click(screen.getByRole('button', { name: 'Add secret' })) await waitFor(() => expect(onSubmit).toHaveBeenCalledWith( expect.objectContaining({ - name: 'Application credentials', - reference: 'lucis/production/application', + name: 'APP_PASSWORD', + keyName: 'password', + reference: 'production/database', isJson: true, - isFile: false, - filePath: undefined, }) ) ) + expect(onSubmit.mock.calls[0][0]).not.toHaveProperty('keyPrefix') }) - it('keeps the JSON edit form open if saving fails', async () => { - const onClose = jest.fn() - const onSubmit = jest.fn().mockRejectedValue(new Error('Could not save')) - const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined) - const { userEvent } = renderWithProviders( - - ) - await userEvent.type(screen.getByLabelText('Reference'), '-updated') - await userEvent.click(screen.getByRole('button', { name: 'Edit secret' })) - await waitFor(() => expect(onSubmit).toHaveBeenCalled()) - expect(onClose).not.toHaveBeenCalled() - expect(screen.getByRole('alert')).toHaveTextContent('Could not save') - onSubmit.mockResolvedValueOnce(undefined) - await userEvent.click(screen.getByRole('button', { name: 'Edit secret' })) - await waitFor(() => expect(onClose).toHaveBeenCalled()) - expect(screen.queryByRole('alert')).not.toBeInTheDocument() - consoleError.mockRestore() - }) - it('accepts a JSON reference without fetching its keys', async () => { + it.each(['Key name', 'Secret name'])('requires a nonblank %s for JSON secrets', async (label) => { const onSubmit = jest.fn() const { userEvent } = renderWithProviders( ) - expect(screen.queryByText('Variables to import')).not.toBeInTheDocument() - expect(screen.queryByText('No JSON detected')).not.toBeInTheDocument() - await userEvent.click(screen.getByLabelText('Display name')) - await userEvent.paste('My credentials') - await userEvent.click(screen.getByLabelText('Reference')) - await userEvent.paste('new/json/reference') - expect(screen.getByRole('button', { name: 'Add secret' })).toBeEnabled() - await userEvent.click(screen.getByRole('button', { name: 'Add secret' })) - await waitFor(() => - expect(onSubmit).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'My credentials', - reference: 'new/json/reference', - isJson: true, - }) - ) + await userEvent.type(screen.getByLabelText('Reference'), 'production/database') + await userEvent.type(screen.getByLabelText('Key name'), 'password') + await userEvent.clear(screen.getByLabelText(label)) + await userEvent.type(screen.getByLabelText(label), ' ') + expect(screen.getByRole('button', { name: 'Add secret' })).toBeDisabled() + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('keeps names unlinked after an override even when they match again', async () => { + const { userEvent } = renderWithProviders( + ) - expect(onSubmit.mock.calls[0][0]).not.toHaveProperty('keys') + await userEvent.type(screen.getByLabelText('Key name'), 'password') + await userEvent.clear(screen.getByLabelText('Secret name')) + await userEvent.type(screen.getByLabelText('Secret name'), 'password') + await userEvent.type(screen.getByLabelText('Key name'), '_new') + expect(screen.getByLabelText('Secret name')).toHaveValue('password') }) - it.each(['', 'APP'])('submits a JSON reference with optional prefix "%s"', async (prefix) => { + + it('submits the auto-filled secret name without adding an underscore', async () => { const onSubmit = jest.fn() const { userEvent } = renderWithProviders( ) - expect(screen.getByLabelText('Key prefix (optional)')).toHaveValue('') - await userEvent.click(screen.getByLabelText('Display name')) - await userEvent.paste('Application credentials') - await userEvent.click(screen.getByLabelText('Reference')) - await userEvent.paste('lucis/app') - await userEvent.click(screen.getByLabelText('Key prefix (optional)')) - await userEvent.paste(prefix) + await userEvent.type(screen.getByLabelText('Reference'), 'production/database') + await userEvent.type(screen.getByLabelText('Key name'), 'password') await userEvent.click(screen.getByRole('button', { name: 'Add secret' })) await waitFor(() => - expect(onSubmit).toHaveBeenCalledWith( - expect.objectContaining({ - isJson: true, - keyPrefix: prefix || undefined, - }) - ) + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ name: 'password', keyName: 'password' })) ) }) - it('restores and clears the JSON prefix when editing', async () => { - const onSubmit = jest.fn() + it('preserves the saved secret name when editing a JSON key and supports retry after failure', async () => { + const onClose = jest.fn() + const onSubmit = jest.fn().mockRejectedValue(new Error('Could not save')) + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined) const { userEvent } = renderWithProviders( ) - expect(screen.getByLabelText('Key prefix (optional)')).toHaveValue('APP') - await userEvent.clear(screen.getByLabelText('Key prefix (optional)')) + expect(screen.getByLabelText('Secret name')).toHaveValue('APP_PASSWORD') + expect(screen.getByLabelText('Key name')).toHaveValue('password') + await userEvent.type(screen.getByLabelText('Key name'), '_new') + expect(screen.getByLabelText('Secret name')).toHaveValue('APP_PASSWORD') await userEvent.click(screen.getByRole('button', { name: 'Edit secret' })) - await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ keyPrefix: undefined }))) + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ name: 'APP_PASSWORD', keyName: 'password_new' })) + ) + expect(onClose).not.toHaveBeenCalled() + expect(screen.getByRole('alert')).toHaveTextContent('Could not save') + onSubmit.mockResolvedValueOnce(undefined) + await userEvent.click(screen.getByRole('button', { name: 'Edit secret' })) + await waitFor(() => expect(onClose).toHaveBeenCalled()) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + consoleError.mockRestore() }) - it('infers the JSON display name from the reference without overwriting a custom name', async () => { + + it('does not infer JSON keys from the reference name', async () => { useSecretManagerProviderSecretsMock.mockReturnValue({ - data: { results: ['production/app', 'production/database'] }, + data: { results: ['production/app'] }, isError: false, isFetching: false, } as ReturnType) @@ -215,11 +202,8 @@ describe('AddSecretModal', () => { ) await userEvent.click(screen.getByLabelText('Reference')) await userEvent.click(screen.getByText('production/app')) - expect(screen.getByLabelText('Display name')).toHaveValue('APP') - await userEvent.clear(screen.getByLabelText('Display name')) - await userEvent.paste('My credentials') - await userEvent.click(screen.getByLabelText('Reference')) - await userEvent.click(screen.getByText('production/database')) - expect(screen.getByLabelText('Display name')).toHaveValue('My credentials') + expect(screen.getByLabelText('Key name')).toHaveValue('') + expect(screen.getByLabelText('Secret name')).toHaveValue('') + expect(screen.getByRole('button', { name: 'Add secret' })).toBeDisabled() }) }) diff --git a/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.tsx b/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.tsx index d2c26fd17de..f91308e37f4 100644 --- a/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.tsx +++ b/libs/domains/variables/feature/src/lib/external-secrets/add-secret-modal/add-secret-modal.tsx @@ -32,7 +32,7 @@ export type AddSecretModalSubmitData = { name: string description?: string filePath?: string - keyPrefix?: string + keyName?: string isFile: boolean isJson?: boolean reference: string @@ -44,7 +44,7 @@ export type AddSecretModalInitialSecret = { reference: string description?: string filePath?: string - keyPrefix?: string + keyName?: string } interface AddSecretModalProps { @@ -64,7 +64,7 @@ type AddSecretFormValues = { reference: string path: string secretName: string - keyPrefix: string + keyName: string description: string acknowledgeEnvironmentSecretCost: boolean } @@ -88,7 +88,7 @@ export function AddSecretModal({ reference: initialSecret?.reference ?? '', path: initialSecret?.filePath ?? '', secretName: initialSecret?.name ?? '', - keyPrefix: initialSecret?.keyPrefix ?? '', + keyName: initialSecret?.keyName ?? '', description: initialSecret?.description ?? '', acknowledgeEnvironmentSecretCost: false, }, @@ -97,6 +97,7 @@ export function AddSecretModal({ const { enableAlertClickOutside } = useModal() const [referenceInput, setReferenceInput] = useState('') const [submitError, setSubmitError] = useState() + const [isSecretNameOverridden, setIsSecretNameOverridden] = useState(mode === 'edit') const debouncedReferenceInput = useDebounce(referenceInput, 300) const isDirty = methods.formState.isDirty const shouldDisplayEnvironmentAcknowledgement = scope === 'ENVIRONMENT' && mode === 'create' @@ -157,7 +158,7 @@ export function AddSecretModal({ description: descriptionValue ? descriptionValue : undefined, filePath: isFile ? finalPath : undefined, isFile, - ...(isJson ? { isJson: true, keyPrefix: data.keyPrefix.trim() || undefined } : {}), + ...(isJson ? { isJson: true, keyName: data.keyName.trim() } : {}), reference: finalReference, secretManagerAccessId: selectedSource.value, }) @@ -199,7 +200,7 @@ export function AddSecretModal({ description={match({ isJson, isFile }) .with( { isJson: true }, - () => 'Qovery will inject all top-level JSON keys as environment variables at deployment.' + () => 'Qovery will import the selected JSON key as an environment variable at deployment.' ) .with( { isFile: true }, @@ -257,7 +258,7 @@ export function AddSecretModal({ onChange={(value) => { const selected = value as string field.onChange(selected) - if (!secretNameValue) { + if (!isJson && !secretNameValue) { const inferredName = selected.split('/').pop()?.toUpperCase() if (inferredName) { methods.setValue('secretName', inferredName, { shouldValidate: true }) @@ -291,44 +292,52 @@ export function AddSecretModal({ /> )} + {isJson && ( + !!value.trim() || 'Please enter a key name.' }} + render={({ field, fieldState: { error } }) => ( + { + field.onChange(event) + if (!isSecretNameOverridden) { + methods.setValue('secretName', event.target.value, { shouldValidate: true }) + } + }} + hint="Key to import from the selected JSON secret." + error={error?.message} + /> + )} + /> + )} + !!value.trim() || 'Please enter a secret name.' }} render={({ field, fieldState: { error } }) => ( { + field.onChange(event) + if (isJson) setIsSecretNameOverridden(true) + }} hint={ - isJson - ? 'For identification only. Not used in your code.' - : 'Name that other Qovery services will use' + isJson ? 'Environment variable name to use in your code.' : 'Name that other Qovery services will use' } error={error?.message} /> )} /> - {isJson && ( - ( - - )} - /> - )} - { await userEvent.click(screen.getByRole('menuitem', { name: 'Add secret as JSON' })) const modal = openModal.mock.calls[0][0].content as ReactElement<{ isJson: boolean - initialSecret?: { keyPrefix?: string } + initialSecret?: { keyName?: string } onSubmit: (data: AddSecretModalSubmitData) => Promise }> expect(modal.props.isJson).toBe(true) @@ -557,7 +557,7 @@ describe('ExternalSecretsTab', () => { isJson: true, isFile: false, secretManagerAccessId: 'manager-id', - keyPrefix: 'APP_', + keyName: 'password', } await act(async () => modal.props.onSubmit(data)) expect(screen.getByText('lucis/app')).toBeInTheDocument() @@ -565,12 +565,19 @@ describe('ExternalSecretsTab', () => { await userEvent.click(screen.getByRole('button', { name: 'Edit' })) const editModal = openModal.mock.calls[1][0].content as typeof modal expect(editModal.props.isJson).toBe(true) - expect(editModal.props.initialSecret?.keyPrefix).toBe('APP_') + expect(screen.getByText('Key: password')).toBeInTheDocument() + expect(editModal.props.initialSecret?.keyName).toBe('password') await act(async () => - editModal.props.onSubmit({ ...data, name: 'Updated credentials', reference: 'lucis/updated' }) + editModal.props.onSubmit({ + ...data, + name: 'Updated credentials', + keyName: 'new_password', + reference: 'lucis/updated', + }) ) expect(screen.getByText('lucis/updated')).toBeInTheDocument() expect(screen.getByText('Updated credentials')).toBeInTheDocument() + expect(screen.getByText('Key: new_password')).toBeInTheDocument() await userEvent.click(screen.getByRole('button', { name: 'Delete' })) await act(async () => openModalConfirmation.mock.calls[0][0].action()) expect(screen.queryByText('lucis/updated')).not.toBeInTheDocument() diff --git a/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.tsx b/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.tsx index 0755b5f09f7..a53d6da1be0 100644 --- a/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.tsx +++ b/libs/domains/variables/feature/src/lib/external-secrets/external-secrets-tab.tsx @@ -55,7 +55,7 @@ type ExternalSecretRow = { filePath?: string isFile?: boolean isJson?: boolean - keyPrefix?: string + keyName?: string secretManagerAccessId?: string reference: string source: string | null @@ -146,7 +146,7 @@ export function ExternalSecretsTab({ filePath, isFile, isJson, - keyPrefix, + keyName, reference, secretManagerAccessId, }: AddSecretModalSubmitData) => { @@ -162,7 +162,7 @@ export function ExternalSecretsTab({ reference, description, isJson: true, - keyPrefix, + keyName, secretManagerAccessId, source: manager?.name ?? null, sourceIcon: getSecretManagerProvider(manager), @@ -197,7 +197,7 @@ export function ExternalSecretsTab({ filePath, isFile, isJson, - keyPrefix, + keyName, reference, secretManagerAccessId, }: AddSecretModalSubmitData @@ -213,7 +213,7 @@ export function ExternalSecretsTab({ name, reference, description, - keyPrefix, + keyName, secretManagerAccessId, source: manager?.name ?? null, sourceIcon: getSecretManagerProvider(manager), @@ -428,7 +428,7 @@ export function ExternalSecretsTab({ {secret.isJson && (
- {secret.keyPrefix ? `Prefix: ${secret.keyPrefix.replace(/_+$/, '')}_` : 'No prefix'} + {`Key: ${secret.keyName}`}
)} {showFilePath && ( diff --git a/libs/shared/interfaces/src/lib/common/flow-variable-data.interface.ts b/libs/shared/interfaces/src/lib/common/flow-variable-data.interface.ts index 7ba83814235..462b1d96426 100644 --- a/libs/shared/interfaces/src/lib/common/flow-variable-data.interface.ts +++ b/libs/shared/interfaces/src/lib/common/flow-variable-data.interface.ts @@ -18,7 +18,7 @@ export interface ExternalSecretData { isFile?: boolean // TODO(secret-manager-json): Align these fields with the JSON API contract when available. isJson?: boolean - keyPrefix?: string + keyName?: string source?: string | null sourceIcon?: 'aws' | 'gcp' secretManagerAccessId?: string