From 1ebfec5a928fc42734d5a50f30d05544fe9caa7c Mon Sep 17 00:00:00 2001 From: Melvin Zottola Date: Tue, 13 Oct 2026 15:14:31 +0200 Subject: [PATCH 1/8] feat(qov-2386) Enable wide permissions under feature flag --- .../$clusterId/settings/credentials.tsx | 164 ++++++++++----- libs/domains/clusters/feature/src/index.ts | 1 + .../cluster-credentials-modal.spec.tsx | 17 ++ .../cluster-credentials-modal.tsx | 23 ++- .../cluster-credentials-settings.spec.tsx | 161 ++++++++++++++- .../cluster-credentials-settings.tsx | 93 ++++++++- .../save-cluster-credentials.spec.ts | 192 ++++++++++++++++++ .../save-cluster-credentials.ts | 89 ++++++++ .../use-edit-cluster.spec.tsx | 82 ++++++++ .../use-edit-cluster/use-edit-cluster.ts | 51 +++-- 10 files changed, 791 insertions(+), 82 deletions(-) create mode 100644 libs/domains/clusters/feature/src/lib/cluster-credentials-settings/save-cluster-credentials.spec.ts create mode 100644 libs/domains/clusters/feature/src/lib/cluster-credentials-settings/save-cluster-credentials.ts create mode 100644 libs/domains/clusters/feature/src/lib/hooks/use-edit-cluster/use-edit-cluster.spec.tsx diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/credentials.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/credentials.tsx index 05591a6842f..1f7b12f6eb1 100644 --- a/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/credentials.tsx +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/credentials.tsx @@ -1,19 +1,20 @@ import { createFileRoute, useParams } from '@tanstack/react-router' -import { - type ClusterCloudProviderInfo, - type ClusterCloudProviderInfoRequest, - type ClusterCredentials, -} from 'qovery-typescript-axios' -import { useEffect } from 'react' -import { type FieldValues, FormProvider, useForm } from 'react-hook-form' +import { useFeatureFlagEnabled } from 'posthog-js/react' +import { useEffect, useRef } from 'react' +import { FormProvider, useForm } from 'react-hook-form' import { useCloudProviderCredentials } from '@qovery/domains/cloud-providers/feature' import { isClusterEksManaged } from '@qovery/domains/clusters/data-access' import { + type ClusterCredentialsFormValues, ClusterCredentialsSettings, + getCloudProviderInfoRequest, + getClusterCredentialsSavePlan, + saveClusterCredentials, useCluster, useClusterCloudProviderInfo, useDeployCluster, useEditCloudProviderInfo, + useEditCluster, } from '@qovery/domains/clusters/feature' import { SettingsHeading } from '@qovery/shared/console-shared' import { BlockContent, Button, Section, toast, useModalConfirmation } from '@qovery/shared/ui' @@ -24,27 +25,11 @@ export const Route = createFileRoute( component: RouteComponent, }) -const handleSubmit = ( - data: FieldValues, - credentials: ClusterCredentials[], - cluster: ClusterCloudProviderInfo -): ClusterCloudProviderInfoRequest => { - const currentCredentials = credentials.filter((item) => item.id === data['credentials'])[0] - - return { - cloud_provider: cluster.cloud_provider, - credentials: { - id: currentCredentials.id, - name: currentCredentials.name, - }, - region: cluster.region, - } -} - function ClusterCredentialsSettingsForm() { const { organizationId = '', clusterId = '' } = useParams({ strict: false }) + const isWideCredentialsFeatureFlagEnabled = Boolean(useFeatureFlagEnabled('wide-credentials')) - const methods = useForm({ + const methods = useForm({ mode: 'onChange', }) @@ -58,55 +43,127 @@ function ClusterCredentialsSettingsForm() { organizationId, cloudProvider: clusterCloudProviderInfo?.cloud_provider, }) - const { mutate: editCloudProviderInfo, isLoading: isEditCloudProviderInfoLoading } = useEditCloudProviderInfo() + const { + mutate: editCloudProviderInfo, + mutateAsync: editCloudProviderInfoAsync, + isLoading: isEditCloudProviderInfoLoading, + } = useEditCloudProviderInfo() const { mutateAsync: editCloudProviderInfoBeforeRedeploy } = useEditCloudProviderInfo({ showClusterUpdateAction: false, }) + const { mutateAsync: editCluster, isLoading: isEditClusterLoading } = useEditCluster() + const { mutateAsync: editClusterSilently, isLoading: isEditClusterSilentlyLoading } = useEditCluster({ + silently: true, + }) const { mutateAsync: deployCluster } = useDeployCluster() const { openModalConfirmation } = useModalConfirmation() - const onSubmit = methods.handleSubmit((data) => { - const findCredentials = credentials.find((credential) => credential.id === data['credentials']) + // Restrictive (dual) credentials are only available for AWS managed (EKS) clusters + const isWideCredentialsEnabled = isWideCredentialsFeatureFlagEnabled && !!cluster && isClusterEksManaged(cluster) + const initialUsingRestrictivePermissions = cluster?.wide_permissions?.using_restrictive_permissions ?? false + const isSavingRef = useRef(false) - // The cluster is required to know if the credentials change needs a redeploy + const openRedeployConfirmation = (action: () => Promise) => { if (!cluster) return - const isEksManaged = isClusterEksManaged(cluster) + openModalConfirmation({ + title: 'Confirm credentials change', + description: 'Changing the credentials will trigger a cluster redeployment. To confirm, please type the name:', + warning: 'Your cluster will be redeployed automatically once the new credentials are saved.', + name: cluster.name, + action, + }) + } - if (data && clusterCloudProviderInfo && findCredentials) { - const variables = { - organizationId, - clusterId, - cloudProviderInfoRequest: handleSubmit(data, credentials, clusterCloudProviderInfo), - } + const onSubmit = methods.handleSubmit(async (data) => { + // The cluster is required to know if the credentials change needs a redeploy + if (!cluster || !clusterCloudProviderInfo) return + + const formValues = isWideCredentialsEnabled ? data : { ...data, using_restrictive_permissions: false } + const cloudProviderInfoRequest = getCloudProviderInfoRequest(formValues, credentials, clusterCloudProviderInfo) + + if (!cloudProviderInfoRequest) { + toast('error', 'Please select a credential') + return + } + + const variables = { organizationId, clusterId, cloudProviderInfoRequest } + if (!isWideCredentialsEnabled) { // Changing the credentials of a managed EKS cluster requires a cluster redeploy, triggered once they are saved - const hasCredentialsChanged = findCredentials.id !== clusterCloudProviderInfo.credentials?.id - if (isEksManaged && hasCredentialsChanged) { - openModalConfirmation({ - title: 'Confirm credentials change', - description: - 'Changing the credentials will trigger a cluster redeployment. To confirm, please type the name:', - warning: 'Your cluster will be redeployed automatically once the new credentials are saved.', - name: cluster.name, - action: async () => { - await editCloudProviderInfoBeforeRedeploy(variables) - await deployCluster({ organizationId, clusterId }) - }, + const hasCredentialsChanged = data.credentials !== clusterCloudProviderInfo.credentials?.id + if (isClusterEksManaged(cluster) && hasCredentialsChanged) { + openRedeployConfirmation(async () => { + await editCloudProviderInfoBeforeRedeploy(variables) + await deployCluster({ organizationId, clusterId }) }) } else { editCloudProviderInfo(variables) } + return + } + + const plan = getClusterCredentialsSavePlan({ + formValues, + cloudProviderInfo: clusterCloudProviderInfo, + initialUsingRestrictivePermissions, + }) + + const save = async () => { + // The cluster and its credentials are refetched one after the other: syncing the form from each refetch would + // briefly show a mix of old and new values, so the form is reset once with the saved values instead + isSavingRef.current = true + try { + await saveClusterCredentials({ + plan, + // A single toast is shown when the credentials are saved too + editCluster: () => + (plan.shouldEditCloudProviderInfo ? editClusterSilently : editCluster)({ + organizationId, + clusterId, + clusterRequest: { + ...cluster, + wide_permissions: { using_restrictive_permissions: Boolean(formValues.using_restrictive_permissions) }, + }, + }), + editCloudProviderInfo: () => + (plan.shouldRedeploy ? editCloudProviderInfoBeforeRedeploy : editCloudProviderInfoAsync)(variables), + deployCluster: () => deployCluster({ organizationId, clusterId }), + }) + const usingRestrictivePermissions = Boolean(formValues.using_restrictive_permissions) + methods.reset({ + credentials: formValues.credentials, + // Turning restrictive permissions off clears the extended credentials on the backend side + wide_permission_credentials: usingRestrictivePermissions ? formValues.wide_permission_credentials : undefined, + using_restrictive_permissions: usingRestrictivePermissions, + }) + } finally { + isSavingRef.current = false + } + } + + if (plan.shouldRedeploy) { + openRedeployConfirmation(save) } else { - toast('error', 'Please select a credential') + try { + await save() + } catch (error) { + // Errors are already notified by the mutations + console.error(error) + } } }) useEffect(() => { - if (clusterCloudProviderInfo) { - methods.setValue('credentials', clusterCloudProviderInfo.credentials?.id) + if (clusterCloudProviderInfo && !isSavingRef.current) { + // Toggling restrictive permissions never clears the fields, so the user always gets back these initial values + methods.reset({ + credentials: clusterCloudProviderInfo.credentials?.id, + wide_permission_credentials: clusterCloudProviderInfo.wide_permission_credentials?.id, + using_restrictive_permissions: initialUsingRestrictivePermissions, + }) } - }, [methods, clusterCloudProviderInfo]) + }, [methods, clusterCloudProviderInfo, initialUsingRestrictivePermissions]) return ( @@ -120,6 +177,7 @@ function ClusterCredentialsSettingsForm() { cloudProvider={clusterCloudProviderInfo?.cloud_provider} isSetting={true} cluster={cluster} + isWideCredentialsEnabled={isWideCredentialsEnabled} />
@@ -127,7 +185,7 @@ function ClusterCredentialsSettingsForm() { data-testid="submit-button" type="submit" size="lg" - loading={isEditCloudProviderInfoLoading} + loading={isEditCloudProviderInfoLoading || isEditClusterLoading || isEditClusterSilentlyLoading} disabled={!methods.formState.isValid || !cluster} > Save diff --git a/libs/domains/clusters/feature/src/index.ts b/libs/domains/clusters/feature/src/index.ts index b4e0c7de319..343fef352d0 100644 --- a/libs/domains/clusters/feature/src/index.ts +++ b/libs/domains/clusters/feature/src/index.ts @@ -49,6 +49,7 @@ export * from './lib/secret-manager-modals/secret-manager-associated-external-se export * from './lib/secret-manager-modals/secret-manager-integration-modal' export * from './lib/cluster-general-settings/cluster-general-settings' export * from './lib/cluster-credentials-settings/cluster-credentials-settings' +export * from './lib/cluster-credentials-settings/save-cluster-credentials' export * from './lib/cluster-dns-provider-settings/cluster-dns-provider-settings' export * from './lib/hooks/use-cluster-cloud-provider-info/use-cluster-cloud-provider-info' export * from './lib/hooks/use-cluster-dns-provider/use-cluster-dns-provider' diff --git a/libs/domains/clusters/feature/src/lib/cluster-credentials-modal/cluster-credentials-modal.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-credentials-modal/cluster-credentials-modal.spec.tsx index 2dbd9f7cb4d..6fba0fcce28 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-credentials-modal/cluster-credentials-modal.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-credentials-modal/cluster-credentials-modal.spec.tsx @@ -278,6 +278,23 @@ describe('ClusterCredentialsModal', () => { }) }) + it('should only offer the allowed authentication types', async () => { + renderWithProviders(wrapWithReactHookForm()) + + selectEvent.openMenu(screen.getByLabelText('Authentication type')) + + expect(await screen.findAllByText('Assume role via STS (preferred)')).not.toHaveLength(0) + expect(screen.queryByText('Static credentials')).not.toBeInTheDocument() + }) + + it('should offer both AWS authentication types by default', async () => { + renderWithProviders(wrapWithReactHookForm()) + + selectEvent.openMenu(screen.getByLabelText('Authentication type')) + + expect(await screen.findByText('Static credentials')).toBeInTheDocument() + }) + it('should render GCP WIF as default authentication type', () => { props.cloudProvider = CloudProviderEnum.GCP diff --git a/libs/domains/clusters/feature/src/lib/cluster-credentials-modal/cluster-credentials-modal.tsx b/libs/domains/clusters/feature/src/lib/cluster-credentials-modal/cluster-credentials-modal.tsx index d98309f8b19..df25d390705 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-credentials-modal/cluster-credentials-modal.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-credentials-modal/cluster-credentials-modal.tsx @@ -51,7 +51,7 @@ type ClusterCredentialsFormValues = { id?: string } -type ClusterCredentialAuthType = ClusterCredentialsFormValues['type'] +export type ClusterCredentialAuthType = ClusterCredentialsFormValues['type'] export type ClusterCredentialsModalCloudProvider = CloudProviderEnum | 'AWS_EKS_ANYWHERE' const AWS_CREDENTIAL_TYPES: ClusterCredentialAuthType[] = ['STS', 'STATIC'] const GCP_CREDENTIAL_TYPES: ClusterCredentialAuthType[] = ['WIF', 'STATIC'] @@ -66,6 +66,8 @@ export interface ClusterCredentialsModalProps { onClose: (response?: ClusterCredentials) => void credential?: ClusterCredentials cloudProvider?: ClusterCredentialsModalCloudProvider + // Restricts the authentication types the user can pick (e.g. extended permissions only accept an AWS role) + allowedTypes?: ClusterCredentialAuthType[] } export const getDefaultClusterCredentialType = ({ @@ -238,6 +240,7 @@ export function ClusterCredentialsModal({ onClose, credential, cloudProvider = 'AWS', + allowedTypes, }: ClusterCredentialsModalProps) { const isEksAnywhereEnabled = Boolean(useFeatureFlagEnabled('eks-anywhere')) const { enableAlertClickOutside } = useModal() @@ -304,10 +307,20 @@ export function ClusterCredentialsModal({ : cloudProviderLocal === 'AWS_EKS_ANYWHERE' ? EKS_ANYWHERE_CREDENTIAL_TYPES : AWS_CREDENTIAL_TYPES - ).filter((type) => - type === 'EKS_ANYWHERE_VSPHERE_ROLE' || type === 'EKS_ANYWHERE_VSPHERE_STATIC' ? isEksAnywhereEnabled : true - ), - [cloudProviderLocal, credential?.object_type, inferredCredentialTypes, isEdit, isEksAnywhereEnabled, isGcpMode] + ) + .filter((type) => + type === 'EKS_ANYWHERE_VSPHERE_ROLE' || type === 'EKS_ANYWHERE_VSPHERE_STATIC' ? isEksAnywhereEnabled : true + ) + .filter((type) => !allowedTypes || allowedTypes.includes(type)), + [ + allowedTypes, + cloudProviderLocal, + credential?.object_type, + inferredCredentialTypes, + isEdit, + isEksAnywhereEnabled, + isGcpMode, + ] ) const defaultType: ClusterCredentialsFormValues['type'] = getDefaultClusterCredentialType({ diff --git a/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-credentials-settings.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-credentials-settings.spec.tsx index 713d890d38d..acc1de6334e 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-credentials-settings.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-credentials-settings.spec.tsx @@ -5,10 +5,11 @@ import { useFormContext } from 'react-hook-form' import selectEvent from 'react-select-event' import * as cloudProvidersDomain from '@qovery/domains/cloud-providers/feature' import { clusterFactoryMock } from '@qovery/shared/factories' -import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import { renderWithProviders, screen, waitFor } from '@qovery/shared/util-tests' import ClusterCredentialsSettings, { type ClusterCredentialsSettingsProps, filterCredentialsByCloudProvider, + filterWidePermissionCredentials, } from './cluster-credentials-settings' const mockOpenModal = jest.fn() @@ -247,4 +248,162 @@ describe('ClusterCredentialsSettings', () => { { id: '2', name: 'EKS Anywhere credential', object_type: 'EKS_ANYWHERE_VSPHERE' }, ]) }) + + it('should only keep AWS role credentials for extended permissions', () => { + const credentials = [ + { id: '1', name: 'AWS role credential', object_type: 'AWS_ROLE' }, + { id: '2', name: 'AWS static credential', object_type: 'AWS' }, + ] as ClusterCredentials[] + + expect(filterWidePermissionCredentials(credentials)).toEqual([ + { id: '1', name: 'AWS role credential', object_type: 'AWS_ROLE' }, + ]) + }) + + describe('wide credentials', () => { + const awsCredentials = [ + { id: 'static-1', name: 'static-credential', object_type: 'AWS', access_key_id: 'AKIA' }, + { id: 'role-1', name: 'role-credential', object_type: 'AWS_ROLE', role_arn: 'arn:aws:iam::1:role/qovery' }, + { id: 'role-2', name: 'other-role-credential', object_type: 'AWS_ROLE', role_arn: 'arn:aws:iam::1:role/other' }, + ] + + function FormValues() { + const { watch } = useFormContext() + return ( + <> + {watch('credentials')} + {watch('wide_permission_credentials')} + + ) + } + + function FormValidity() { + const { + formState: { isValid }, + } = useFormContext() + return {String(isValid)} + } + + const renderSettings = ( + defaultValues: Record, + settingsProps: Partial = {} + ) => + renderWithProviders( + wrapWithReactHookForm( + <> + + + + , + { defaultValues } + ) + ) + + beforeEach(() => { + useCloudProviderCredentialsMockSpy.mockReturnValue({ data: awsCredentials, isLoading: false }) + }) + + it('should only warn about a redeploy for the restrictive credentials', () => { + renderSettings({ credentials: 'static-1', using_restrictive_permissions: true }) + + expect(screen.getByTestId('eks-credentials-change-warning')).toHaveTextContent( + 'Changing the restrictive credentials triggers a cluster redeployment' + ) + }) + + it('should not display the restrictive permissions toggle when wide credentials are disabled', () => { + renderSettings({ credentials: 'static-1' }, { isWideCredentialsEnabled: false }) + + expect(screen.queryByRole('switch', { name: 'Use restrictive permissions' })).not.toBeInTheDocument() + expect(screen.queryByLabelText('Extended credentials')).not.toBeInTheDocument() + expect(screen.getByLabelText('Credentials')).toBeInTheDocument() + }) + + it('should only display a single credentials select when restrictive permissions are off', () => { + renderSettings({ credentials: 'static-1', using_restrictive_permissions: false }) + + expect(screen.getByRole('switch', { name: 'Use restrictive permissions' })).not.toBeChecked() + expect(screen.getByLabelText('Credentials')).toBeInTheDocument() + expect(screen.queryByLabelText('Extended credentials')).not.toBeInTheDocument() + }) + + it('should display restrictive and extended credentials selects when restrictive permissions are on', async () => { + const { userEvent } = renderSettings({ credentials: 'static-1', using_restrictive_permissions: false }) + + await userEvent.click(screen.getByRole('switch', { name: 'Use restrictive permissions' })) + + expect(screen.getByLabelText('Restrictive credentials')).toBeInTheDocument() + expect(screen.getByLabelText('Extended credentials')).toBeInTheDocument() + }) + + it('should only list AWS role credentials in the extended credentials select', async () => { + renderSettings({ credentials: '', wide_permission_credentials: '', using_restrictive_permissions: true }) + + selectEvent.openMenu(screen.getByLabelText('Extended credentials')) + + expect(await screen.findByText('role-credential')).toBeInTheDocument() + expect(screen.getByText('other-role-credential')).toBeInTheDocument() + expect(screen.queryByText('static-credential (AKIA)')).not.toBeInTheDocument() + }) + + it('should keep the initial values when toggling restrictive permissions off and on', async () => { + const { userEvent } = renderSettings({ + credentials: 'static-1', + wide_permission_credentials: 'role-1', + using_restrictive_permissions: true, + }) + const toggle = screen.getByRole('switch', { name: 'Use restrictive permissions' }) + + await userEvent.click(toggle) + expect(screen.queryByLabelText('Extended credentials')).not.toBeInTheDocument() + expect(screen.getByText('static-credential (AKIA)')).toBeInTheDocument() + + await userEvent.click(toggle) + expect(screen.getByText('static-credential (AKIA)')).toBeInTheDocument() + expect(screen.getByText('role-credential')).toBeInTheDocument() + expect(screen.getByTestId('value-credentials')).toHaveTextContent('static-1') + expect(screen.getByTestId('value-wide-permission-credentials')).toHaveTextContent('role-1') + }) + + it('should require the extended credentials only while restrictive permissions are on', async () => { + const { userEvent } = renderSettings({ + credentials: 'static-1', + wide_permission_credentials: '', + using_restrictive_permissions: false, + }) + const toggle = screen.getByRole('switch', { name: 'Use restrictive permissions' }) + + await waitFor(() => expect(screen.getByTestId('form-valid')).toHaveTextContent('true')) + + await userEvent.click(toggle) + await waitFor(() => expect(screen.getByTestId('form-valid')).toHaveTextContent('false')) + + await userEvent.click(toggle) + await waitFor(() => expect(screen.getByTestId('form-valid')).toHaveTextContent('true')) + }) + + it('should only allow creating an AWS role credential from the extended credentials select', async () => { + const { userEvent } = renderSettings({ + credentials: 'static-1', + wide_permission_credentials: '', + using_restrictive_permissions: true, + }) + + await userEvent.click(screen.getByLabelText('Extended credentials')) + await userEvent.click(await screen.findByText('New credential')) + + const modalProps = mockOpenModal.mock.calls[0][0].content.props + expect(modalProps.allowedTypes).toEqual(['STS']) + + act(() => modalProps.onClose({ id: 'role-new' })) + expect(screen.getByTestId('value-wide-permission-credentials')).toHaveTextContent('role-new') + expect(screen.getByTestId('value-credentials')).toHaveTextContent('static-1') + }) + }) }) diff --git a/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-credentials-settings.tsx b/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-credentials-settings.tsx index f502e75046d..7bb86b6bb83 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-credentials-settings.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-credentials-settings.tsx @@ -1,7 +1,7 @@ import { useParams } from '@tanstack/react-router' import { type Cluster, type ClusterCredentials } from 'qovery-typescript-axios' import { useCallback } from 'react' -import { Controller, useFormContext } from 'react-hook-form' +import { Controller, useFormContext, useWatch } from 'react-hook-form' import { useCloudProviderCredentials } from '@qovery/domains/cloud-providers/feature' import { isClusterEksManaged } from '@qovery/domains/clusters/data-access' import { @@ -11,21 +11,30 @@ import { ExternalLink, Icon, InputSelect, + InputToggle, Link, LoaderSpinner, useModal, } from '@qovery/shared/ui' import { + type ClusterCredentialAuthType, ClusterCredentialsModal, type ClusterCredentialsModalCloudProvider, } from '../cluster-credentials-modal/cluster-credentials-modal' +// Extended permissions credentials must be an AWS role (assumed via STS) +const WIDE_PERMISSION_CREDENTIAL_TYPES: ClusterCredentialAuthType[] = ['STS'] + export interface ClusterCredentialsSettingsProps { cloudProvider?: ClusterCredentialsModalCloudProvider isSetting?: boolean cluster?: Cluster + isWideCredentialsEnabled?: boolean } +export const filterWidePermissionCredentials = (credentials: ClusterCredentials[]) => + credentials.filter((credential) => credential.object_type === 'AWS_ROLE') + export const filterCredentialsByCloudProvider = ( credentials: ClusterCredentials[], cloudProvider?: ClusterCredentialsModalCloudProvider @@ -37,11 +46,20 @@ export const filterCredentialsByCloudProvider = ( return credentials.filter((credential) => credential.object_type === 'EKS_ANYWHERE_VSPHERE') } -export function ClusterCredentialsSettings({ cloudProvider, isSetting, cluster }: ClusterCredentialsSettingsProps) { +export function ClusterCredentialsSettings({ + cloudProvider, + isSetting, + cluster, + isWideCredentialsEnabled = false, +}: ClusterCredentialsSettingsProps) { const { organizationId = '', clusterId } = useParams({ strict: false }) - const { control } = useFormContext() + const { control, getValues } = useFormContext() const { openModal, closeModal } = useModal() + // useWatch subscribes this component itself, so it stays in sync even when the parent form is reset + const usingRestrictivePermissions = useWatch({ control, name: 'using_restrictive_permissions' }) + const isUsingRestrictivePermissions = isWideCredentialsEnabled && Boolean(usingRestrictivePermissions) + const queryCloudProvider = cloudProvider === 'AWS_EKS_ANYWHERE' ? 'AWS' : cloudProvider const isEksManaged = cluster !== undefined && isClusterEksManaged(cluster) @@ -55,7 +73,7 @@ export function ClusterCredentialsSettings({ cloudProvider, isSetting, cluster } ) const openCredentialsModal = useCallback( - (id?: string, onChange?: (e: string | string[]) => void) => { + (id?: string, onChange?: (e: string | string[]) => void, allowedTypes?: ClusterCredentialAuthType[]) => { openModal({ content: ( currentCredentials.id === id )} cloudProvider={cloudProvider} + allowedTypes={allowedTypes} /> ), options: { @@ -79,11 +98,14 @@ export function ClusterCredentialsSettings({ cloudProvider, isSetting, cluster } [cloudProvider, clusterId, closeModal, openModal, organizationId, sortedCredentials] ) - const buildCredentials = sortedCredentials.map((item: ClusterCredentials) => ({ + const toCredentialOption = (item: ClusterCredentials) => ({ label: `${item.name}${'access_key_id' in item ? ` (${item.access_key_id})` : ''}`, value: item.id, onClickEditable: !isSetting ? () => openCredentialsModal(item.id) : undefined, - })) + }) + + const buildCredentials = sortedCredentials.map(toCredentialOption) + const buildWidePermissionCredentials = filterWidePermissionCredentials(sortedCredentials).map(toCredentialOption) return (
@@ -99,7 +121,12 @@ export function ClusterCredentialsSettings({ cloudProvider, isSetting, cluster } - Changing credentials triggers a cluster redeployment + + {/* Only the restrictive credentials trigger a redeploy, not the toggle nor the extended credentials */} + {isWideCredentialsEnabled + ? 'Changing the restrictive credentials triggers a cluster redeployment' + : 'Changing credentials triggers a cluster redeployment'} + Saving new credentials will automatically redeploy the cluster to keep your services' deployments working. @@ -108,6 +135,25 @@ export function ClusterCredentialsSettings({ cloudProvider, isSetting, cluster } )} + {isWideCredentialsEnabled && ( + ( + + )} + /> + )} + + {isUsingRestrictivePermissions && ( + + !getValues('using_restrictive_permissions') || Boolean(value) || 'Please select a credential.', + }} + render={({ field, fieldState: { error } }) => ( + , + onClick: () => openCredentialsModal(undefined, field.onChange, WIDE_PERMISSION_CREDENTIAL_TYPES), + }} + /> + )} + /> + )} + {isSetting && ( { + it('should send the restrictive and extended credentials when restrictive permissions are on', () => { + expect( + getCloudProviderInfoRequest( + { credentials: 'static-2', wide_permission_credentials: 'role-2', using_restrictive_permissions: true }, + credentials, + cloudProviderInfo + ) + ).toEqual({ + cloud_provider: 'AWS', + region: 'eu-west-3', + credentials: { id: 'static-2', name: 'other-static-credential' }, + wide_permission_credentials: { id: 'role-2', name: 'other-role-credential' }, + }) + }) + + it('should not send the extended credentials when restrictive permissions are off', () => { + expect( + getCloudProviderInfoRequest( + { credentials: 'static-2', wide_permission_credentials: 'role-2', using_restrictive_permissions: false }, + credentials, + cloudProviderInfo + ) + ).toEqual({ + cloud_provider: 'AWS', + region: 'eu-west-3', + credentials: { id: 'static-2', name: 'other-static-credential' }, + }) + }) + + it('should return undefined when a selected credential is missing', () => { + expect(getCloudProviderInfoRequest({ credentials: 'unknown' }, credentials, cloudProviderInfo)).toBeUndefined() + expect( + getCloudProviderInfoRequest( + { credentials: 'static-1', wide_permission_credentials: '', using_restrictive_permissions: true }, + credentials, + cloudProviderInfo + ) + ).toBeUndefined() + }) +}) + +describe('getClusterCredentialsSavePlan', () => { + const initialFormValues = { + credentials: 'static-1', + wide_permission_credentials: 'role-1', + using_restrictive_permissions: true, + } + + it.each([ + { + case: 'restrictive credentials changed', + formValues: { ...initialFormValues, credentials: 'static-2' }, + initialUsingRestrictivePermissions: true, + expected: { shouldEditCluster: false, shouldEditCloudProviderInfo: true, shouldRedeploy: true }, + }, + { + case: 'restrictive credentials and toggle changed', + formValues: { ...initialFormValues, credentials: 'static-2', using_restrictive_permissions: false }, + initialUsingRestrictivePermissions: true, + expected: { shouldEditCluster: true, shouldEditCloudProviderInfo: true, shouldRedeploy: true }, + }, + { + case: 'toggle turned off only', + formValues: { ...initialFormValues, using_restrictive_permissions: false }, + initialUsingRestrictivePermissions: true, + expected: { shouldEditCluster: true, shouldEditCloudProviderInfo: false, shouldRedeploy: false }, + }, + { + case: 'extended credentials changed only', + formValues: { ...initialFormValues, wide_permission_credentials: 'role-2' }, + initialUsingRestrictivePermissions: true, + expected: { shouldEditCluster: false, shouldEditCloudProviderInfo: true, shouldRedeploy: false }, + }, + { + case: 'toggle turned on with extended credentials', + formValues: { ...initialFormValues, wide_permission_credentials: 'role-2' }, + initialUsingRestrictivePermissions: false, + expected: { shouldEditCluster: true, shouldEditCloudProviderInfo: true, shouldRedeploy: false }, + }, + { + case: 'nothing changed', + formValues: initialFormValues, + initialUsingRestrictivePermissions: true, + expected: { shouldEditCluster: false, shouldEditCloudProviderInfo: true, shouldRedeploy: false }, + }, + ])('should plan the save when $case', ({ formValues, initialUsingRestrictivePermissions, expected }) => { + expect( + getClusterCredentialsSavePlan({ formValues, cloudProviderInfo, initialUsingRestrictivePermissions }) + ).toEqual(expected) + }) + + it('should ignore extended credentials while restrictive permissions are off', () => { + expect( + getClusterCredentialsSavePlan({ + formValues: { + credentials: 'static-1', + wide_permission_credentials: 'role-2', + using_restrictive_permissions: false, + }, + cloudProviderInfo, + initialUsingRestrictivePermissions: false, + }) + ).toEqual({ shouldEditCluster: false, shouldEditCloudProviderInfo: true, shouldRedeploy: false }) + }) +}) + +describe('saveClusterCredentials', () => { + const plan = { shouldEditCluster: true, shouldEditCloudProviderInfo: true, shouldRedeploy: true } + + it('should update the cluster, then the cloud provider info, then redeploy', async () => { + const calls: string[] = [] + await saveClusterCredentials({ + plan, + editCluster: async () => calls.push('editCluster'), + editCloudProviderInfo: async () => calls.push('editCloudProviderInfo'), + deployCluster: async () => calls.push('deployCluster'), + }) + + expect(calls).toEqual(['editCluster', 'editCloudProviderInfo', 'deployCluster']) + }) + + it('should not update the cloud provider info nor redeploy when the cluster update fails', async () => { + const editCloudProviderInfo = jest.fn() + const deployCluster = jest.fn() + + await expect( + saveClusterCredentials({ + plan, + editCluster: () => Promise.reject(new Error('cluster update failed')), + editCloudProviderInfo, + deployCluster, + }) + ).rejects.toThrow('cluster update failed') + + expect(editCloudProviderInfo).not.toHaveBeenCalled() + expect(deployCluster).not.toHaveBeenCalled() + }) + + it('should not redeploy when the cloud provider info update fails', async () => { + const deployCluster = jest.fn() + + await expect( + saveClusterCredentials({ + plan, + editCluster: jest.fn().mockResolvedValue(undefined), + editCloudProviderInfo: () => Promise.reject(new Error('credentials update failed')), + deployCluster, + }) + ).rejects.toThrow('credentials update failed') + + expect(deployCluster).not.toHaveBeenCalled() + }) + + it('should only run the planned steps', async () => { + const editCluster = jest.fn() + const editCloudProviderInfo = jest.fn() + const deployCluster = jest.fn() + + await saveClusterCredentials({ + plan: { shouldEditCluster: true, shouldEditCloudProviderInfo: false, shouldRedeploy: false }, + editCluster, + editCloudProviderInfo, + deployCluster, + }) + + expect(editCluster).toHaveBeenCalledTimes(1) + expect(editCloudProviderInfo).not.toHaveBeenCalled() + expect(deployCluster).not.toHaveBeenCalled() + }) +}) diff --git a/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/save-cluster-credentials.ts b/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/save-cluster-credentials.ts new file mode 100644 index 00000000000..8652bc92d66 --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/save-cluster-credentials.ts @@ -0,0 +1,89 @@ +import { + type ClusterCloudProviderInfo, + type ClusterCloudProviderInfoRequest, + type ClusterCredentials, +} from 'qovery-typescript-axios' + +export interface ClusterCredentialsFormValues { + credentials?: string + wide_permission_credentials?: string + using_restrictive_permissions?: boolean +} + +export interface ClusterCredentialsSavePlan { + shouldEditCluster: boolean + shouldEditCloudProviderInfo: boolean + shouldRedeploy: boolean +} + +export const getCloudProviderInfoRequest = ( + formValues: ClusterCredentialsFormValues, + credentials: ClusterCredentials[], + cloudProviderInfo: ClusterCloudProviderInfo +): ClusterCloudProviderInfoRequest | undefined => { + const restrictiveCredentials = credentials.find(({ id }) => id === formValues.credentials) + if (!restrictiveCredentials) return undefined + + const widePermissionCredentials = formValues.using_restrictive_permissions + ? credentials.find(({ id }) => id === formValues.wide_permission_credentials) + : undefined + if (formValues.using_restrictive_permissions && !widePermissionCredentials) return undefined + + return { + cloud_provider: cloudProviderInfo.cloud_provider, + credentials: { + id: restrictiveCredentials.id, + name: restrictiveCredentials.name, + }, + region: cloudProviderInfo.region, + // When restrictive permissions are turned off, the backend clears the extended credentials itself + ...(widePermissionCredentials && { + wide_permission_credentials: { + id: widePermissionCredentials.id, + name: widePermissionCredentials.name, + }, + }), + } +} + +export const getClusterCredentialsSavePlan = ({ + formValues, + cloudProviderInfo, + initialUsingRestrictivePermissions, +}: { + formValues: ClusterCredentialsFormValues + cloudProviderInfo: ClusterCloudProviderInfo + initialUsingRestrictivePermissions: boolean +}): ClusterCredentialsSavePlan => { + const usingRestrictivePermissions = Boolean(formValues.using_restrictive_permissions) + const hasCredentialsChanged = formValues.credentials !== cloudProviderInfo.credentials?.id + const hasWidePermissionCredentialsChanged = + usingRestrictivePermissions && + formValues.wide_permission_credentials !== cloudProviderInfo.wide_permission_credentials?.id + const shouldEditCluster = usingRestrictivePermissions !== initialUsingRestrictivePermissions + + return { + shouldEditCluster, + // Only toggling restrictive permissions is saved on the cluster alone + shouldEditCloudProviderInfo: hasCredentialsChanged || hasWidePermissionCredentialsChanged || !shouldEditCluster, + // Only a restrictive credentials change requires a redeploy, not the toggle nor the extended credentials + shouldRedeploy: hasCredentialsChanged, + } +} + +// The cluster is updated first, the credentials are only sent once it succeeded, then the cluster is redeployed +export async function saveClusterCredentials({ + plan, + editCluster, + editCloudProviderInfo, + deployCluster, +}: { + plan: ClusterCredentialsSavePlan + editCluster: () => Promise + editCloudProviderInfo: () => Promise + deployCluster: () => Promise +}) { + if (plan.shouldEditCluster) await editCluster() + if (plan.shouldEditCloudProviderInfo) await editCloudProviderInfo() + if (plan.shouldRedeploy) await deployCluster() +} diff --git a/libs/domains/clusters/feature/src/lib/hooks/use-edit-cluster/use-edit-cluster.spec.tsx b/libs/domains/clusters/feature/src/lib/hooks/use-edit-cluster/use-edit-cluster.spec.tsx new file mode 100644 index 00000000000..7351a95fa83 --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/hooks/use-edit-cluster/use-edit-cluster.spec.tsx @@ -0,0 +1,82 @@ +import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query' +import axios from 'axios' +import { type Cluster, type ClusterRequest } from 'qovery-typescript-axios' +import { type PropsWithChildren } from 'react' +import { act, renderHook, waitFor } from '@qovery/shared/util-tests' +import { queries } from '@qovery/state/util-queries' +import { useEditCluster } from './use-edit-cluster' + +const organizationId = 'org-123' +const clusterId = 'cluster-123' + +const cluster = { + id: clusterId, + name: 'my-cluster', + region: 'eu-west-3', + cloud_provider: 'AWS', + wide_permissions: { using_restrictive_permissions: false }, +} as Cluster +const otherCluster = { ...cluster, id: 'other-cluster' } as Cluster + +const setup = (editedCluster: Cluster) => { + const requestSpy = jest.spyOn(axios, 'request').mockImplementation(async (config) => { + // Keep refetches pending to observe the cache before they complete + if (config?.method?.toUpperCase() === 'GET') return new Promise(() => undefined) + return { data: editedCluster } + }) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) + const clustersQuery = queries.clusters.list({ organizationId }) + queryClient.setQueryData(clustersQuery.queryKey, [cluster, otherCluster]) + const invalidateSpy = jest.spyOn(queryClient, 'invalidateQueries') + const wrapper = ({ children }: PropsWithChildren) => ( + {children} + ) + const { result: mutation } = renderHook(() => useEditCluster(), { wrapper }) + const { result: list } = renderHook(() => useQuery(clustersQuery), { wrapper }) + + return { requestSpy, queryClient, invalidateSpy, mutation, list, clustersQuery } +} + +describe('useEditCluster', () => { + afterEach(() => { + jest.restoreAllMocks() + }) + + it('seeds the edited cluster in the list before the refetch completes', async () => { + const editedCluster = { ...cluster, wide_permissions: { using_restrictive_permissions: true } } as Cluster + const { queryClient, invalidateSpy, mutation, list, clustersQuery } = setup(editedCluster) + + await act(async () => { + await mutation.current.mutateAsync({ + organizationId, + clusterId, + clusterRequest: { ...cluster, wide_permissions: { using_restrictive_permissions: true } } as ClusterRequest, + }) + }) + + await waitFor(() => expect(list.current.data).toEqual([editedCluster, otherCluster])) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: clustersQuery.queryKey }) + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: queries.clusters.cloudProviderInfo({ organizationId, clusterId }).queryKey, + }) + queryClient.clear() + }) + + it('does not refetch the cloud provider info when restrictive permissions are unchanged', async () => { + const editedCluster = { ...cluster, name: 'renamed-cluster' } as Cluster + const { queryClient, invalidateSpy, mutation } = setup(editedCluster) + + await act(async () => { + await mutation.current.mutateAsync({ + organizationId, + clusterId, + clusterRequest: { ...cluster, name: 'renamed-cluster' } as ClusterRequest, + }) + }) + + expect(invalidateSpy).not.toHaveBeenCalledWith({ + queryKey: queries.clusters.cloudProviderInfo({ organizationId, clusterId }).queryKey, + }) + queryClient.clear() + }) +}) diff --git a/libs/domains/clusters/feature/src/lib/hooks/use-edit-cluster/use-edit-cluster.ts b/libs/domains/clusters/feature/src/lib/hooks/use-edit-cluster/use-edit-cluster.ts index 0d32666c1a0..eb655aacc87 100644 --- a/libs/domains/clusters/feature/src/lib/hooks/use-edit-cluster/use-edit-cluster.ts +++ b/libs/domains/clusters/feature/src/lib/hooks/use-edit-cluster/use-edit-cluster.ts @@ -1,21 +1,40 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import posthog from 'posthog-js' +import { type Cluster } from 'qovery-typescript-axios' import { mutations } from '@qovery/domains/clusters/data-access' import { queries } from '@qovery/state/util-queries' import { useDeployCluster } from '../use-deploy-cluster/use-deploy-cluster' -export function useEditCluster() { +export function useEditCluster({ silently = false } = {}) { const queryClient = useQueryClient() const { mutateAsync: deployCluster } = useDeployCluster() return useMutation(mutations.editCluster, { - onSuccess(_, { organizationId, clusterId, clusterRequest }) { - queryClient.invalidateQueries({ - queryKey: queries.clusters.list({ organizationId }).queryKey, - }) + onSuccess(editedCluster, { organizationId, clusterId, clusterRequest }) { + const clustersQueryKey = queries.clusters.list({ organizationId }).queryKey + const previousCluster = queryClient + .getQueryData(clustersQueryKey) + ?.find((cluster) => cluster.id === clusterId) + + // Seed the list with the edited cluster so consumers don't briefly read stale values before the refetch completes + if (editedCluster) { + queryClient.setQueryData(clustersQueryKey, (clusters: Cluster[] | undefined) => + clusters?.map((cluster) => (cluster.id === editedCluster.id ? editedCluster : cluster)) + ) + } + queryClient.invalidateQueries({ queryKey: clustersQueryKey }) queryClient.invalidateQueries({ queryKey: queries.clusters.kubeconfig({ organizationId, clusterId }).queryKey, }) + // Toggling restrictive permissions changes the extended credentials on the backend side (cleared when turned off) + if ( + previousCluster?.wide_permissions?.using_restrictive_permissions !== + clusterRequest.wide_permissions?.using_restrictive_permissions + ) { + queryClient.invalidateQueries({ + queryKey: queries.clusters.cloudProviderInfo({ organizationId, clusterId }).queryKey, + }) + } if (clusterRequest.keda?.enabled) { posthog.capture('cluster-keda-enabled', { @@ -24,17 +43,19 @@ export function useEditCluster() { } }, meta: { - notifyOnSuccess(_: unknown, variables: unknown) { - const { organizationId, clusterId } = variables as Parameters[0] - return { - title: 'Cluster updated', - description: 'You must update to apply the settings', - callback() { - deployCluster({ organizationId, clusterId }) + notifyOnSuccess: silently + ? false + : (_: unknown, variables: unknown) => { + const { organizationId, clusterId } = variables as Parameters[0] + return { + title: 'Cluster updated', + description: 'You must update to apply the settings', + callback() { + deployCluster({ organizationId, clusterId }) + }, + labelAction: 'Update', + } }, - labelAction: 'Update', - } - }, notifyOnError: true, }, }) From 9676575319bbd534f4db041dc55be3c241e4e167 Mon Sep 17 00:00:00 2001 From: Melvin Zottola Date: Tue, 13 Oct 2026 17:01:00 +0200 Subject: [PATCH 2/8] feat(qov-2386) Filter changes to not indicate to redeploy cluster --- .../$clusterId/settings/credentials.tsx | 28 ++++++++++++------- .../use-edit-cluster.spec.tsx | 22 +++++++++++++-- .../use-edit-cluster/use-edit-cluster.ts | 6 +++- 3 files changed, 43 insertions(+), 13 deletions(-) diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/credentials.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/credentials.tsx index 1f7b12f6eb1..3543f05a366 100644 --- a/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/credentials.tsx +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/credentials.tsx @@ -43,15 +43,17 @@ function ClusterCredentialsSettingsForm() { organizationId, cloudProvider: clusterCloudProviderInfo?.cloud_provider, }) + const { mutate: editCloudProviderInfo, isLoading: isEditCloudProviderInfoLoading } = useEditCloudProviderInfo() const { - mutate: editCloudProviderInfo, - mutateAsync: editCloudProviderInfoAsync, - isLoading: isEditCloudProviderInfoLoading, - } = useEditCloudProviderInfo() - const { mutateAsync: editCloudProviderInfoBeforeRedeploy } = useEditCloudProviderInfo({ + mutateAsync: editCloudProviderInfoWithoutUpdateAction, + isLoading: isEditCloudProviderInfoWithoutUpdateActionLoading, + } = useEditCloudProviderInfo({ + showClusterUpdateAction: false, + }) + // Toggling restrictive permissions doesn't require a cluster update + const { mutateAsync: editCluster, isLoading: isEditClusterLoading } = useEditCluster({ showClusterUpdateAction: false, }) - const { mutateAsync: editCluster, isLoading: isEditClusterLoading } = useEditCluster() const { mutateAsync: editClusterSilently, isLoading: isEditClusterSilentlyLoading } = useEditCluster({ silently: true, }) @@ -94,7 +96,7 @@ function ClusterCredentialsSettingsForm() { const hasCredentialsChanged = data.credentials !== clusterCloudProviderInfo.credentials?.id if (isClusterEksManaged(cluster) && hasCredentialsChanged) { openRedeployConfirmation(async () => { - await editCloudProviderInfoBeforeRedeploy(variables) + await editCloudProviderInfoWithoutUpdateAction(variables) await deployCluster({ organizationId, clusterId }) }) } else { @@ -126,8 +128,9 @@ function ClusterCredentialsSettingsForm() { wide_permissions: { using_restrictive_permissions: Boolean(formValues.using_restrictive_permissions) }, }, }), - editCloudProviderInfo: () => - (plan.shouldRedeploy ? editCloudProviderInfoBeforeRedeploy : editCloudProviderInfoAsync)(variables), + // Either the cluster is redeployed right after, or the change (toggle, extended credentials) doesn't require + // a cluster update: no update to offer + editCloudProviderInfo: () => editCloudProviderInfoWithoutUpdateAction(variables), deployCluster: () => deployCluster({ organizationId, clusterId }), }) const usingRestrictivePermissions = Boolean(formValues.using_restrictive_permissions) @@ -185,7 +188,12 @@ function ClusterCredentialsSettingsForm() { data-testid="submit-button" type="submit" size="lg" - loading={isEditCloudProviderInfoLoading || isEditClusterLoading || isEditClusterSilentlyLoading} + loading={ + isEditCloudProviderInfoLoading || + isEditCloudProviderInfoWithoutUpdateActionLoading || + isEditClusterLoading || + isEditClusterSilentlyLoading + } disabled={!methods.formState.isValid || !cluster} > Save diff --git a/libs/domains/clusters/feature/src/lib/hooks/use-edit-cluster/use-edit-cluster.spec.tsx b/libs/domains/clusters/feature/src/lib/hooks/use-edit-cluster/use-edit-cluster.spec.tsx index 7351a95fa83..f8553d48206 100644 --- a/libs/domains/clusters/feature/src/lib/hooks/use-edit-cluster/use-edit-cluster.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/hooks/use-edit-cluster/use-edit-cluster.spec.tsx @@ -18,7 +18,7 @@ const cluster = { } as Cluster const otherCluster = { ...cluster, id: 'other-cluster' } as Cluster -const setup = (editedCluster: Cluster) => { +const setup = (editedCluster: Cluster, options?: Parameters[0]) => { const requestSpy = jest.spyOn(axios, 'request').mockImplementation(async (config) => { // Keep refetches pending to observe the cache before they complete if (config?.method?.toUpperCase() === 'GET') return new Promise(() => undefined) @@ -31,7 +31,7 @@ const setup = (editedCluster: Cluster) => { const wrapper = ({ children }: PropsWithChildren) => ( {children} ) - const { result: mutation } = renderHook(() => useEditCluster(), { wrapper }) + const { result: mutation } = renderHook(() => useEditCluster(options), { wrapper }) const { result: list } = renderHook(() => useQuery(clustersQuery), { wrapper }) return { requestSpy, queryClient, invalidateSpy, mutation, list, clustersQuery } @@ -79,4 +79,22 @@ describe('useEditCluster', () => { }) queryClient.clear() }) + + it.each([ + [undefined, expect.objectContaining({ title: 'Cluster updated', labelAction: 'Update' })], + [{ showClusterUpdateAction: false }, { title: 'Cluster updated' }], + ])('notifies the success with options %p', (options, expectedNotification) => { + const { queryClient, mutation } = setup(cluster, options) + + act(() => { + mutation.current.mutate({ organizationId, clusterId, clusterRequest: cluster as ClusterRequest }) + }) + + const notifyOnSuccess = queryClient.getMutationCache().getAll()[0].options.meta?.['notifyOnSuccess'] as ( + data: unknown, + variables: unknown + ) => unknown + expect(notifyOnSuccess(cluster, { organizationId, clusterId })).toEqual(expectedNotification) + queryClient.clear() + }) }) diff --git a/libs/domains/clusters/feature/src/lib/hooks/use-edit-cluster/use-edit-cluster.ts b/libs/domains/clusters/feature/src/lib/hooks/use-edit-cluster/use-edit-cluster.ts index eb655aacc87..5b5a07782d2 100644 --- a/libs/domains/clusters/feature/src/lib/hooks/use-edit-cluster/use-edit-cluster.ts +++ b/libs/domains/clusters/feature/src/lib/hooks/use-edit-cluster/use-edit-cluster.ts @@ -5,7 +5,7 @@ import { mutations } from '@qovery/domains/clusters/data-access' import { queries } from '@qovery/state/util-queries' import { useDeployCluster } from '../use-deploy-cluster/use-deploy-cluster' -export function useEditCluster({ silently = false } = {}) { +export function useEditCluster({ silently = false, showClusterUpdateAction = true } = {}) { const queryClient = useQueryClient() const { mutateAsync: deployCluster } = useDeployCluster() @@ -46,6 +46,10 @@ export function useEditCluster({ silently = false } = {}) { notifyOnSuccess: silently ? false : (_: unknown, variables: unknown) => { + if (!showClusterUpdateAction) { + return { title: 'Cluster updated' } + } + const { organizationId, clusterId } = variables as Parameters[0] return { title: 'Cluster updated', From eb40850f038c8b506b98fa929176153e85fd92a8 Mon Sep 17 00:00:00 2001 From: Melvin Zottola Date: Tue, 13 Oct 2026 17:07:31 +0200 Subject: [PATCH 3/8] fix(qov-2386) Fix test --- .../lib/hooks/use-edit-cluster/use-edit-cluster.spec.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libs/domains/clusters/feature/src/lib/hooks/use-edit-cluster/use-edit-cluster.spec.tsx b/libs/domains/clusters/feature/src/lib/hooks/use-edit-cluster/use-edit-cluster.spec.tsx index f8553d48206..d7c2c51b697 100644 --- a/libs/domains/clusters/feature/src/lib/hooks/use-edit-cluster/use-edit-cluster.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/hooks/use-edit-cluster/use-edit-cluster.spec.tsx @@ -83,11 +83,11 @@ describe('useEditCluster', () => { it.each([ [undefined, expect.objectContaining({ title: 'Cluster updated', labelAction: 'Update' })], [{ showClusterUpdateAction: false }, { title: 'Cluster updated' }], - ])('notifies the success with options %p', (options, expectedNotification) => { + ])('notifies the success with options %p', async (options, expectedNotification) => { const { queryClient, mutation } = setup(cluster, options) - act(() => { - mutation.current.mutate({ organizationId, clusterId, clusterRequest: cluster as ClusterRequest }) + await act(async () => { + await mutation.current.mutateAsync({ organizationId, clusterId, clusterRequest: cluster as ClusterRequest }) }) const notifyOnSuccess = queryClient.getMutationCache().getAll()[0].options.meta?.['notifyOnSuccess'] as ( From eb9ebfde43c339f5b75e716d5f8f3c6395861cfa Mon Sep 17 00:00:00 2001 From: Melvin Zottola Date: Tue, 13 Oct 2026 18:17:50 +0200 Subject: [PATCH 4/8] fix(qov-2386) Fix layout for registry credentials --- .../cluster-credentials-settings.spec.tsx | 20 ++++++ .../cluster-credentials-settings.tsx | 69 ++++++++++--------- 2 files changed, 57 insertions(+), 32 deletions(-) diff --git a/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-credentials-settings.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-credentials-settings.spec.tsx index acc1de6334e..15927b989f6 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-credentials-settings.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-credentials-settings.spec.tsx @@ -317,6 +317,26 @@ describe('ClusterCredentialsSettings', () => { ) }) + const isBefore = (element: HTMLElement, other: HTMLElement) => + Boolean(element.compareDocumentPosition(other) & Node.DOCUMENT_POSITION_FOLLOWING) + + it('should display the credentials link and mirroring registry warning between restrictive and extended credentials', () => { + renderSettings({ credentials: 'static-1', using_restrictive_permissions: true }) + + const link = screen.getByText('See and edit all Cloud credentials') + const warning = screen.getByTestId('mirroring-registry-credentials-warning') + expect(isBefore(screen.getByTestId('input-credentials'), link)).toBe(true) + expect(isBefore(link, warning)).toBe(true) + expect(isBefore(warning, screen.getByTestId('input-wide-permission-credentials'))).toBe(true) + }) + + it('should display the mirroring registry warning last when restrictive permissions are off', () => { + renderSettings({ credentials: 'static-1', using_restrictive_permissions: false }) + + const warning = screen.getByTestId('mirroring-registry-credentials-warning') + expect(isBefore(screen.getByText('See and edit all Cloud credentials'), warning)).toBe(true) + }) + it('should not display the restrictive permissions toggle when wide credentials are disabled', () => { renderSettings({ credentials: 'static-1' }, { isWideCredentialsEnabled: false }) diff --git a/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-credentials-settings.tsx b/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-credentials-settings.tsx index 7bb86b6bb83..840a47b2cbf 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-credentials-settings.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-credentials-settings.tsx @@ -201,37 +201,6 @@ export function ClusterCredentialsSettings({ } /> - {isUsingRestrictivePermissions && ( - - !getValues('using_restrictive_permissions') || Boolean(value) || 'Please select a credential.', - }} - render={({ field, fieldState: { error } }) => ( - , - onClick: () => openCredentialsModal(undefined, field.onChange, WIDE_PERMISSION_CREDENTIAL_TYPES), - }} - /> - )} - /> - )} - {isSetting && ( + @@ -263,6 +237,37 @@ export function ClusterCredentialsSettings({ )} + + {isUsingRestrictivePermissions && ( + + !getValues('using_restrictive_permissions') || Boolean(value) || 'Please select a credential.', + }} + render={({ field, fieldState: { error } }) => ( + , + onClick: () => openCredentialsModal(undefined, field.onChange, WIDE_PERMISSION_CREDENTIAL_TYPES), + }} + /> + )} + /> + )} )}
From c36304dec63dd8ce4056dde8434f82ea4aa0778f Mon Sep 17 00:00:00 2001 From: Melvin Zottola Date: Wed, 14 Oct 2026 08:26:09 +0200 Subject: [PATCH 5/8] feat(qov-2386) Improve layout --- .../$clusterId/settings/credentials.tsx | 17 +- libs/domains/clusters/feature/src/index.ts | 2 + .../cloud-credentials-link.tsx | 21 ++ .../cluster-credentials-settings.spec.tsx | 190 ++---------------- .../cluster-credentials-settings.tsx | 105 ++-------- ...-restrictive-permissions-settings.spec.tsx | 161 +++++++++++++++ ...uster-restrictive-permissions-settings.tsx | 60 ++++++ ...e-permission-credentials-settings.spec.tsx | 138 +++++++++++++ ...r-wide-permission-credentials-settings.tsx | 101 ++++++++++ 9 files changed, 523 insertions(+), 272 deletions(-) create mode 100644 libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cloud-credentials-link.tsx create mode 100644 libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-restrictive-permissions-settings.spec.tsx create mode 100644 libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-restrictive-permissions-settings.tsx create mode 100644 libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-wide-permission-credentials-settings.spec.tsx create mode 100644 libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-wide-permission-credentials-settings.tsx diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/credentials.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/credentials.tsx index 3543f05a366..3e3cbdd59eb 100644 --- a/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/credentials.tsx +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/settings/credentials.tsx @@ -7,6 +7,7 @@ import { isClusterEksManaged } from '@qovery/domains/clusters/data-access' import { type ClusterCredentialsFormValues, ClusterCredentialsSettings, + ClusterRestrictivePermissionsSettings, getCloudProviderInfoRequest, getClusterCredentialsSavePlan, saveClusterCredentials, @@ -175,14 +176,20 @@ function ClusterCredentialsSettingsForm() {
- - - + ) : ( + + + + )}
diff --git a/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-restrictive-permissions-settings.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-restrictive-permissions-settings.spec.tsx index 96b67c56cf2..67d013e0728 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-restrictive-permissions-settings.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-restrictive-permissions-settings.spec.tsx @@ -1,8 +1,7 @@ import { wrapWithReactHookForm } from '__tests__/utils/wrap-with-react-hook-form' -import { CloudProviderEnum, KubernetesEnum } from 'qovery-typescript-axios' +import { CloudProviderEnum } from 'qovery-typescript-axios' import { useFormContext } from 'react-hook-form' import * as cloudProvidersDomain from '@qovery/domains/cloud-providers/feature' -import { clusterFactoryMock } from '@qovery/shared/factories' import { renderWithProviders, screen, within } from '@qovery/shared/util-tests' import { ClusterRestrictivePermissionsSettings } from './cluster-restrictive-permissions-settings' @@ -41,11 +40,6 @@ jest.mock('@tanstack/react-router', () => { const useCloudProviderCredentialsMockSpy = jest.spyOn(cloudProvidersDomain, 'useCloudProviderCredentials') as jest.Mock -const eksManagedCluster = { - ...clusterFactoryMock(1, CloudProviderEnum.AWS)[0], - kubernetes: KubernetesEnum.MANAGED, -} - const awsCredentials = [ { id: 'static-1', name: 'static-credential', object_type: 'AWS', access_key_id: 'AKIA' }, { id: 'role-1', name: 'role-credential', object_type: 'AWS_ROLE', role_arn: 'arn:aws:iam::1:role/qovery' }, @@ -55,8 +49,8 @@ function FormValues() { const { watch } = useFormContext() return ( <> - {watch('credentials')} - {watch('wide_permission_credentials')} + {watch('configured_credentials')} + {watch('restricted_credentials')} ) } @@ -65,7 +59,7 @@ const renderSettings = (defaultValues: Record) => renderWithProviders( wrapWithReactHookForm( <> - + , { defaultValues } @@ -84,101 +78,91 @@ describe('ClusterRestrictivePermissionsSettings', () => { }) it('should display the toggle above the credentials block', () => { - renderSettings({ credentials: 'static-1', using_restrictive_permissions: false }) + renderSettings({ configured_credentials: 'role-1', using_restrictive_permissions: false }) const toggle = screen.getByRole('switch', { name: 'Use restrictive permissions' }) - const block = screen.getByTestId('restrictive-credentials-block') + const block = screen.getByTestId('configured-credentials-block') expect(within(block).queryByRole('switch')).not.toBeInTheDocument() - expect(isBefore(toggle, block)).toBe(true) + expect(isBefore(block, toggle)).toBe(true) }) it('should display a single configured credentials block when restrictive permissions are off', () => { - renderSettings({ credentials: 'static-1', using_restrictive_permissions: false }) + renderSettings({ configured_credentials: 'role-1', using_restrictive_permissions: false }) expect(screen.getByRole('switch', { name: 'Use restrictive permissions' })).not.toBeChecked() - const block = screen.getByTestId('restrictive-credentials-block') + const block = screen.getByTestId('configured-credentials-block') expect(within(block).getByText('Configured credentials')).toBeInTheDocument() - expect(within(block).getByTestId('eks-credentials-change-warning')).toHaveTextContent( - 'Changing credentials triggers a cluster redeployment' - ) expectInOrder( - within(block).getByTestId('eks-credentials-change-warning'), - within(block).getByTestId('input-credentials'), - within(block).getByText('See and edit all Cloud credentials'), - within(block).getByTestId('mirroring-registry-credentials-warning') + within(block).getByTestId('input-configured-credentials'), + within(block).getByText('See and edit all Cloud credentials') ) - expect(screen.queryByTestId('extended-credentials-block')).not.toBeInTheDocument() + expect(screen.queryByTestId('restricted-credentials-block')).not.toBeInTheDocument() + expect(screen.queryByTestId('slack-notification-webhook-block')).not.toBeInTheDocument() + expect(screen.queryByTestId('mirroring-registry-credentials-warning')).not.toBeInTheDocument() }) - it('should display restrictive and extended credentials blocks when restrictive permissions are on', () => { + it('should keep configured credentials first and display restricted credentials when restrictive permissions are on', () => { renderSettings({ - credentials: 'static-1', - wide_permission_credentials: 'role-1', + configured_credentials: 'role-1', + restricted_credentials: 'static-1', using_restrictive_permissions: true, }) - const restrictiveBlock = screen.getByTestId('restrictive-credentials-block') - expect(within(restrictiveBlock).getByText('Restrictive credentials')).toBeInTheDocument() - expect(within(restrictiveBlock).getByTestId('eks-credentials-change-warning')).toHaveTextContent( - 'Changing the restrictive credentials triggers a cluster redeployment' - ) - expectInOrder( - within(restrictiveBlock).getByTestId('eks-credentials-change-warning'), - within(restrictiveBlock).getByTestId('input-credentials'), - within(restrictiveBlock).getByText('See and edit all Cloud credentials'), - within(restrictiveBlock).getByTestId('mirroring-registry-credentials-warning') - ) - - const extendedBlock = screen.getByTestId('extended-credentials-block') - expect(within(extendedBlock).getByText('Extended credentials')).toBeInTheDocument() - expect(within(extendedBlock).getByTestId('input-wide-permission-credentials')).toBeInTheDocument() - // The link is only displayed in the restrictive credentials block, so it doesn't move when switching the toggle - expect(within(extendedBlock).queryByText('See and edit all Cloud credentials')).not.toBeInTheDocument() + const configuredBlock = screen.getByTestId('configured-credentials-block') + const toggle = screen.getByRole('switch', { name: 'Use restrictive permissions' }) + const restrictedBlock = screen.getByTestId('restricted-credentials-block') + const webhookBlock = screen.getByTestId('slack-notification-webhook-block') + expect(within(restrictedBlock).getByText('Restricted credentials')).toBeInTheDocument() + expectInOrder(configuredBlock, toggle, restrictedBlock, webhookBlock) + expect(within(configuredBlock).getByTestId('input-configured-credentials')).toBeInTheDocument() + expect(within(restrictedBlock).getByTestId('input-restricted-credentials')).toBeInTheDocument() + expect(within(configuredBlock).getByText('See and edit all Cloud credentials')).toBeInTheDocument() expect(screen.getAllByText('See and edit all Cloud credentials')).toHaveLength(1) - expect(within(extendedBlock).queryByTestId('mirroring-registry-credentials-warning')).not.toBeInTheDocument() - expect(isBefore(restrictiveBlock, extendedBlock)).toBe(true) + expect(screen.queryByTestId('mirroring-registry-credentials-warning')).not.toBeInTheDocument() }) - it('should display the Slack webhook in its own block as a password field when restrictive permissions are on', () => { + it('should display the Slack webhook after restricted credentials when restrictive permissions are on', () => { renderSettings({ - credentials: 'static-1', - wide_permission_credentials: 'role-1', + configured_credentials: 'role-1', + restricted_credentials: 'static-1', using_restrictive_permissions: true, }) - const block = screen.getByTestId('slack-webhook-block') - expect(within(block).getByText('Slack webhook')).toBeInTheDocument() + const block = screen.getByTestId('slack-notification-webhook-block') + expect(within(block).getByText('Slack notification webhook')).toBeInTheDocument() expect(within(block).getByLabelText('Slack webhook URL')).toHaveAttribute('type', 'password') expect( - within(block).getByText('Receive a Slack message when Qovery requests access to the extended credentials.') + within(block).getByText('Receive a Slack message when Qovery requests access to the configured credentials.') + ).toBeInTheDocument() + expect( + within(screen.getByTestId('restricted-credentials-block')).getByTestId('input-restricted-credentials') ).toBeInTheDocument() - expect(isBefore(screen.getByTestId('extended-credentials-block'), block)).toBe(true) }) it('should not display the Slack webhook when restrictive permissions are off', () => { - renderSettings({ credentials: 'static-1', using_restrictive_permissions: false }) + renderSettings({ configured_credentials: 'role-1', using_restrictive_permissions: false }) - expect(screen.queryByTestId('slack-webhook-block')).not.toBeInTheDocument() + expect(screen.queryByTestId('slack-notification-webhook-block')).not.toBeInTheDocument() expect(screen.queryByLabelText('Slack webhook URL')).not.toBeInTheDocument() }) it('should switch the layout with the toggle and keep the initial values', async () => { const { userEvent } = renderSettings({ - credentials: 'static-1', - wide_permission_credentials: 'role-1', + configured_credentials: 'role-1', + restricted_credentials: 'static-1', using_restrictive_permissions: true, }) const toggle = screen.getByRole('switch', { name: 'Use restrictive permissions' }) await userEvent.click(toggle) expect(screen.getByText('Configured credentials')).toBeInTheDocument() - expect(screen.queryByTestId('extended-credentials-block')).not.toBeInTheDocument() - expect(screen.getByText('static-credential (AKIA)')).toBeInTheDocument() + expect(screen.queryByTestId('restricted-credentials-block')).not.toBeInTheDocument() + expect(screen.getByTestId('value-configured-credentials')).toHaveTextContent('role-1') await userEvent.click(toggle) - expect(screen.getByText('Restrictive credentials')).toBeInTheDocument() - expect(within(screen.getByTestId('extended-credentials-block')).getByText('role-credential')).toBeInTheDocument() - expect(screen.getByTestId('value-credentials')).toHaveTextContent('static-1') - expect(screen.getByTestId('value-wide-permission-credentials')).toHaveTextContent('role-1') + expect(screen.getByText('Restricted credentials')).toBeInTheDocument() + expect(screen.getByTestId('value-configured-credentials')).toHaveTextContent('role-1') + expect(screen.getByTestId('value-restricted-credentials')).toHaveTextContent('static-1') + expect(screen.getByTestId('value-configured-credentials')).toHaveTextContent('role-1') }) }) diff --git a/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-restrictive-permissions-settings.tsx b/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-restrictive-permissions-settings.tsx index c902d0c770d..4d9d1d9f06d 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-restrictive-permissions-settings.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-restrictive-permissions-settings.tsx @@ -1,4 +1,4 @@ -import { type CloudProviderEnum, type Cluster } from 'qovery-typescript-axios' +import { type CloudProviderEnum } from 'qovery-typescript-axios' import { Controller, useFormContext, useWatch } from 'react-hook-form' import { BlockContent, InputText, InputToggle } from '@qovery/shared/ui' import { ClusterCredentialsSettings } from './cluster-credentials-settings' @@ -6,56 +6,53 @@ import { ClusterWidePermissionCredentialsSettings } from './cluster-wide-permiss export interface ClusterRestrictivePermissionsSettingsProps { cloudProvider?: CloudProviderEnum - cluster?: Cluster } -export function ClusterRestrictivePermissionsSettings({ - cloudProvider, - cluster, -}: ClusterRestrictivePermissionsSettingsProps) { +export function ClusterRestrictivePermissionsSettings({ cloudProvider }: ClusterRestrictivePermissionsSettingsProps) { const { control } = useFormContext() // useWatch subscribes this component itself, so it stays in sync even when the parent form is reset const isUsingRestrictivePermissions = Boolean(useWatch({ control, name: 'using_restrictive_permissions' })) return ( <> + + + + ( )} /> - - - - {isUsingRestrictivePermissions && ( <> - - + + {/* UI only for now: the value isn't sent until the backend supports it */} - + )} diff --git a/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-wide-permission-credentials-settings.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-wide-permission-credentials-settings.spec.tsx index e8cde181435..83e4f3bc9c6 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-wide-permission-credentials-settings.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-wide-permission-credentials-settings.spec.tsx @@ -86,7 +86,9 @@ describe('ClusterWidePermissionCredentialsSettings', () => { expect(screen.getByLabelText('Credentials')).toBeInTheDocument() expect(screen.getByText('role-credential')).toBeInTheDocument() - expect(screen.getByText('Only AWS role credentials can be used for extended permissions.')).toBeInTheDocument() + expect( + screen.queryByText('When restrictive permissions are enabled, configured credentials must be an AWS role.') + ).not.toBeInTheDocument() expect(screen.queryByText('See and edit all Cloud credentials')).not.toBeInTheDocument() }) diff --git a/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-wide-permission-credentials-settings.tsx b/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-wide-permission-credentials-settings.tsx index 72e71bb538c..deef02c2039 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-wide-permission-credentials-settings.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/cluster-wide-permission-credentials-settings.tsx @@ -1,18 +1,20 @@ import { useParams } from '@tanstack/react-router' import { type CloudProviderEnum, type ClusterCredentials } from 'qovery-typescript-axios' -import { Controller, useFormContext } from 'react-hook-form' +import { Controller, useFormContext, useWatch } from 'react-hook-form' import { useCloudProviderCredentials } from '@qovery/domains/cloud-providers/feature' -import { Icon, InputSelect, LoaderSpinner, useModal } from '@qovery/shared/ui' +import { Icon, InputSelect, Link, LoaderSpinner, useModal } from '@qovery/shared/ui' import { type ClusterCredentialAuthType, ClusterCredentialsModal, } from '../cluster-credentials-modal/cluster-credentials-modal' -// Extended permissions credentials must be an AWS role (assumed via STS) +// Wide permission credentials must be an AWS role (assumed via STS) const WIDE_PERMISSION_CREDENTIAL_TYPES: ClusterCredentialAuthType[] = ['STS'] export interface ClusterWidePermissionCredentialsSettingsProps { cloudProvider?: CloudProviderEnum + fieldName?: 'wide_permission_credentials' | 'configured_credentials' + isSetting?: boolean } export const filterWidePermissionCredentials = (credentials: ClusterCredentials[]) => @@ -20,18 +22,28 @@ export const filterWidePermissionCredentials = (credentials: ClusterCredentials[ export function ClusterWidePermissionCredentialsSettings({ cloudProvider, + fieldName = 'wide_permission_credentials', + isSetting, }: ClusterWidePermissionCredentialsSettingsProps) { const { organizationId = '', clusterId } = useParams({ strict: false }) const { control, getValues } = useFormContext() + const isUsingRestrictivePermissions = Boolean(useWatch({ control, name: 'using_restrictive_permissions' })) const { openModal, closeModal } = useModal() const { data: credentials = [], isLoading } = useCloudProviderCredentials({ organizationId, cloudProvider, }) - const widePermissionCredentials = filterWidePermissionCredentials(credentials) - .sort((a, b) => a.name.localeCompare(b.name)) - .map((item) => ({ label: item.name, value: item.id })) + const sortedCredentials = [...credentials].sort((a, b) => a.name.localeCompare(b.name)) + const widePermissionCredentials = filterWidePermissionCredentials(sortedCredentials) + const availableCredentials = ( + fieldName === 'configured_credentials' && !isUsingRestrictivePermissions + ? sortedCredentials + : widePermissionCredentials + ).map((item) => ({ + label: 'access_key_id' in item ? `${item.name} (${item.access_key_id})` : item.name, + value: item.id, + })) const openCredentialsModal = (onChange: (e: string | string[]) => void) => { openModal({ @@ -44,7 +56,11 @@ export function ClusterWidePermissionCredentialsSettings({ closeModal() }} cloudProvider={cloudProvider} - allowedTypes={WIDE_PERMISSION_CREDENTIAL_TYPES} + allowedTypes={ + fieldName === 'configured_credentials' && !isUsingRestrictivePermissions + ? undefined + : WIDE_PERMISSION_CREDENTIAL_TYPES + } /> ), options: { @@ -62,33 +78,62 @@ export function ClusterWidePermissionCredentialsSettings({ } return ( - - !getValues('using_restrictive_permissions') || Boolean(value) || 'Please select a credential.', - }} - render={({ field, fieldState: { error } }) => ( - , - onClick: () => openCredentialsModal(field.onChange), - }} - /> + <> + { + if (!value && (fieldName === 'configured_credentials' || getValues('using_restrictive_permissions'))) { + return 'Please select a credential.' + } + if ( + fieldName === 'configured_credentials' && + isUsingRestrictivePermissions && + !widePermissionCredentials.some((credential) => credential.id === value) + ) { + return 'Select an AWS role credential for wider access.' + } + return true + }, + }} + render={({ field, fieldState: { error } }) => ( + , + onClick: () => openCredentialsModal(field.onChange), + }} + /> + )} + /> + + {isSetting && ( + <> + + See and edit all Cloud credentials + + + )} - /> + ) } diff --git a/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/save-cluster-credentials.spec.ts b/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/save-cluster-credentials.spec.ts index 86d28004010..705fa541981 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/save-cluster-credentials.spec.ts +++ b/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/save-cluster-credentials.spec.ts @@ -20,10 +20,10 @@ const cloudProviderInfo: ClusterCloudProviderInfo = { } describe('getCloudProviderInfoRequest', () => { - it('should send the restrictive and extended credentials when restrictive permissions are on', () => { + it('should map configured credentials to wide and restricted credentials to credentials when enabled', () => { expect( getCloudProviderInfoRequest( - { credentials: 'static-2', wide_permission_credentials: 'role-2', using_restrictive_permissions: true }, + { configured_credentials: 'role-2', restricted_credentials: 'static-2', using_restrictive_permissions: true }, credentials, cloudProviderInfo ) @@ -35,10 +35,10 @@ describe('getCloudProviderInfoRequest', () => { }) }) - it('should not send the extended credentials when restrictive permissions are off', () => { + it('should map configured credentials to credentials when restrictive permissions are off', () => { expect( getCloudProviderInfoRequest( - { credentials: 'static-2', wide_permission_credentials: 'role-2', using_restrictive_permissions: false }, + { configured_credentials: 'static-2', restricted_credentials: 'role-2', using_restrictive_permissions: false }, credentials, cloudProviderInfo ) @@ -50,10 +50,12 @@ describe('getCloudProviderInfoRequest', () => { }) it('should return undefined when a selected credential is missing', () => { - expect(getCloudProviderInfoRequest({ credentials: 'unknown' }, credentials, cloudProviderInfo)).toBeUndefined() + expect( + getCloudProviderInfoRequest({ configured_credentials: 'unknown' }, credentials, cloudProviderInfo) + ).toBeUndefined() expect( getCloudProviderInfoRequest( - { credentials: 'static-1', wide_permission_credentials: '', using_restrictive_permissions: true }, + { configured_credentials: 'role-1', restricted_credentials: '', using_restrictive_permissions: true }, credentials, cloudProviderInfo ) @@ -63,41 +65,39 @@ describe('getCloudProviderInfoRequest', () => { describe('getClusterCredentialsSavePlan', () => { const initialFormValues = { - credentials: 'static-1', - wide_permission_credentials: 'role-1', + configured_credentials: 'role-1', + restricted_credentials: 'static-1', using_restrictive_permissions: true, } it.each([ { - case: 'restrictive credentials changed', - formValues: { ...initialFormValues, credentials: 'static-2' }, + case: 'restricted credentials changed', + formValues: { ...initialFormValues, restricted_credentials: 'static-2' }, initialUsingRestrictivePermissions: true, expected: { shouldEditCluster: false, shouldEditCloudProviderInfo: true, shouldRedeploy: true }, }, { - case: 'restrictive credentials and toggle changed', - formValues: { ...initialFormValues, credentials: 'static-2', using_restrictive_permissions: false }, - initialUsingRestrictivePermissions: true, - expected: { shouldEditCluster: true, shouldEditCloudProviderInfo: true, shouldRedeploy: true }, - }, - { - case: 'toggle turned off only', + case: 'configured credentials become active when the toggle is turned off', formValues: { ...initialFormValues, using_restrictive_permissions: false }, initialUsingRestrictivePermissions: true, - expected: { shouldEditCluster: true, shouldEditCloudProviderInfo: false, shouldRedeploy: false }, + expected: { shouldEditCluster: true, shouldEditCloudProviderInfo: true, shouldRedeploy: true }, }, { - case: 'extended credentials changed only', - formValues: { ...initialFormValues, wide_permission_credentials: 'role-2' }, + case: 'configured wide credential changed only', + formValues: { ...initialFormValues, configured_credentials: 'role-2' }, initialUsingRestrictivePermissions: true, expected: { shouldEditCluster: false, shouldEditCloudProviderInfo: true, shouldRedeploy: false }, }, { - case: 'toggle turned on with extended credentials', - formValues: { ...initialFormValues, wide_permission_credentials: 'role-2' }, + case: 'toggle turned on with new restricted credentials', + formValues: { + configured_credentials: 'role-2', + restricted_credentials: 'static-2', + using_restrictive_permissions: true, + }, initialUsingRestrictivePermissions: false, - expected: { shouldEditCluster: true, shouldEditCloudProviderInfo: true, shouldRedeploy: false }, + expected: { shouldEditCluster: true, shouldEditCloudProviderInfo: true, shouldRedeploy: true }, }, { case: 'nothing changed', @@ -111,18 +111,18 @@ describe('getClusterCredentialsSavePlan', () => { ).toEqual(expected) }) - it('should ignore extended credentials while restrictive permissions are off', () => { + it('should use configured credentials and ignore the restricted value while restrictive permissions are off', () => { expect( getClusterCredentialsSavePlan({ formValues: { - credentials: 'static-1', - wide_permission_credentials: 'role-2', + configured_credentials: 'role-1', + restricted_credentials: 'static-2', using_restrictive_permissions: false, }, cloudProviderInfo, initialUsingRestrictivePermissions: false, }) - ).toEqual({ shouldEditCluster: false, shouldEditCloudProviderInfo: true, shouldRedeploy: false }) + ).toEqual({ shouldEditCluster: false, shouldEditCloudProviderInfo: true, shouldRedeploy: true }) }) }) diff --git a/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/save-cluster-credentials.ts b/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/save-cluster-credentials.ts index 8652bc92d66..83ee15bd1c3 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/save-cluster-credentials.ts +++ b/libs/domains/clusters/feature/src/lib/cluster-credentials-settings/save-cluster-credentials.ts @@ -5,9 +5,12 @@ import { } from 'qovery-typescript-axios' export interface ClusterCredentialsFormValues { + configured_credentials?: string + restricted_credentials?: string credentials?: string wide_permission_credentials?: string using_restrictive_permissions?: boolean + slack_webhook_url?: string } export interface ClusterCredentialsSavePlan { @@ -21,13 +24,17 @@ export const getCloudProviderInfoRequest = ( credentials: ClusterCredentials[], cloudProviderInfo: ClusterCloudProviderInfo ): ClusterCloudProviderInfoRequest | undefined => { - const restrictiveCredentials = credentials.find(({ id }) => id === formValues.credentials) + const usingRestrictivePermissions = Boolean(formValues.using_restrictive_permissions) + const credentialsId = usingRestrictivePermissions + ? formValues.restricted_credentials ?? formValues.credentials + : formValues.configured_credentials ?? formValues.credentials + const restrictiveCredentials = credentials.find(({ id }) => id === credentialsId) if (!restrictiveCredentials) return undefined - const widePermissionCredentials = formValues.using_restrictive_permissions - ? credentials.find(({ id }) => id === formValues.wide_permission_credentials) + const widePermissionCredentials = usingRestrictivePermissions + ? credentials.find(({ id }) => id === (formValues.configured_credentials ?? formValues.wide_permission_credentials)) : undefined - if (formValues.using_restrictive_permissions && !widePermissionCredentials) return undefined + if (usingRestrictivePermissions && !widePermissionCredentials) return undefined return { cloud_provider: cloudProviderInfo.cloud_provider, @@ -56,17 +63,21 @@ export const getClusterCredentialsSavePlan = ({ initialUsingRestrictivePermissions: boolean }): ClusterCredentialsSavePlan => { const usingRestrictivePermissions = Boolean(formValues.using_restrictive_permissions) - const hasCredentialsChanged = formValues.credentials !== cloudProviderInfo.credentials?.id + const credentialsId = usingRestrictivePermissions + ? formValues.restricted_credentials ?? formValues.credentials + : formValues.configured_credentials ?? formValues.credentials + const hasCredentialsChanged = credentialsId !== cloudProviderInfo.credentials?.id const hasWidePermissionCredentialsChanged = usingRestrictivePermissions && - formValues.wide_permission_credentials !== cloudProviderInfo.wide_permission_credentials?.id + (formValues.configured_credentials ?? formValues.wide_permission_credentials) !== + cloudProviderInfo.wide_permission_credentials?.id const shouldEditCluster = usingRestrictivePermissions !== initialUsingRestrictivePermissions return { shouldEditCluster, - // Only toggling restrictive permissions is saved on the cluster alone + // Toggling restrictive permissions is saved on the cluster; changed credential mappings are saved with cloud provider info. shouldEditCloudProviderInfo: hasCredentialsChanged || hasWidePermissionCredentialsChanged || !shouldEditCluster, - // Only a restrictive credentials change requires a redeploy, not the toggle nor the extended credentials + // Changing the API credentials field requires a redeploy; changing the configured wide credential alone does not. shouldRedeploy: hasCredentialsChanged, } }