feat(editor): Allow instance admin to re-enable disabled secret provider connections (#26760)

This commit is contained in:
Sandra Zollner
2026-03-10 10:25:02 +00:00
committed by GitHub
parent 18db53c966
commit 56176bf6b5
7 changed files with 257 additions and 3 deletions
@@ -37,6 +37,7 @@
"clientSecret": "Client Secret"
}
},
"generic.activate": "Activate",
"generic.annotations": "Annotations",
"generic.annotationData": "Highlighted data",
"generic.any": "Any",
@@ -3088,6 +3089,10 @@
"settings.secretsProviderConnections.description": "Manage credentials across multiple environments by connecting an external secrets store. Keep sensitive credential information in your vault for added security.",
"settings.secretsProviderConnections.card.createdAt": "Created on {date}",
"settings.secretsProviderConnections.actions.share": "Share",
"settings.secretsProviderConnections.actions.activate.success.title": "Secret store active",
"settings.secretsProviderConnections.actions.activate.success.description": "\"{provider}\" is now active.",
"settings.secretsProviderConnections.actions.activate.error.title": "Couldn't activate secret store",
"settings.secretsProviderConnections.actions.activate.error.description": "Try again to make \"{provider}\" active.",
"settings.secretsProviderConnections.oneSecret": "1 secret",
"settings.secretsProviderConnections.secrets": "{count} secrets",
"settings.secretsProviderConnections.modal.items.connection": "Connection",
@@ -3113,6 +3118,7 @@
"settings.secretsProviderConnections.modal.connectionName.hint": "Enter a unique name for your secret store. This can not be changed later.",
"settings.secretsProviderConnections.modal.connectionName.unique": "A secret store with this name already exists. Enter a unique name for your secret store.",
"settings.secretsProviderConnections.state.disconnected": "Disconnected",
"settings.secretsProviderConnections.state.disabled": "Inactive",
"settings.secretsProviderConnections.delete.title": "Delete \"{name}\"",
"settings.secretsProviderConnections.delete.description": "Deleting this vault will remove it and all {secretsCount} imported from it. This will impact credentials that rely on those secrets.",
"settings.secretsProviderConnections.delete.impact.title": "Impact:",
@@ -57,6 +57,20 @@ export const updateSecretProviderConnection = async (
);
};
export const enableSecretProviderConnection = async (
context: IRestApiContext,
providerKey: string,
): Promise<SecretProviderConnection> => {
return await makeRestApiRequest(
context,
'PATCH',
`/secret-providers/connections/${providerKey}`,
{
isEnabled: true,
},
);
};
export const testSecretProviderConnection = async (
context: IRestApiContext,
providerKey: string,
@@ -40,6 +40,7 @@ const emit = defineEmits<{
share: [providerKey: string];
reload: [providerKey: string];
delete: [providerKey: string];
activate: [providerKey: string];
}>();
const provider = toRef(props, 'provider');
@@ -57,6 +58,8 @@ const showDisconnectedBadge = computed(() => {
return provider.value.state === 'error';
});
const isDisabled = computed(() => provider.value.isEnabled === false);
const canDelete = computed(() => {
if (rbacStore.hasScope('externalSecretsProvider:delete')) return true;
if (provider.value.projects.length > 0) {
@@ -118,6 +121,14 @@ const actionDropdownOptions = computed(() => {
value: 'edit',
},
];
if (isDisabled.value) {
options.push({
label: i18n.baseText('generic.activate'),
value: 'activate',
});
}
if (isProjectScopedSecretsEnabled) {
options.push({
label: i18n.baseText('settings.secretsProviderConnections.actions.share'),
@@ -125,7 +136,7 @@ const actionDropdownOptions = computed(() => {
});
}
if (provider.value.state === 'connected' && canSync.value) {
if (provider.value.state === 'connected' && canSync.value && !isDisabled.value) {
options.push({
label: i18n.baseText('settings.externalSecrets.card.actionDropdown.reload'),
value: 'reload',
@@ -145,6 +156,8 @@ const actionDropdownOptions = computed(() => {
function onAction(action: string) {
if (action === 'edit') {
emit('edit', provider.value.name);
} else if (action === 'activate') {
emit('activate', provider.value.name);
} else if (action === 'share') {
emit('share', provider.value.name);
} else if (action === 'reload') {
@@ -170,7 +183,16 @@ function onAction(action: string) {
provider.name
}}</N8nHeading>
<N8nBadge
v-if="showDisconnectedBadge"
v-if="isDisabled"
theme="tertiary"
:bold="false"
size="xsmall"
data-test-id="disabled-badge"
>
{{ i18n.baseText('settings.secretsProviderConnections.state.disabled') }}
</N8nBadge>
<N8nBadge
v-else-if="showDisconnectedBadge"
theme="warning"
:bold="false"
size="xsmall"
@@ -207,7 +229,7 @@ function onAction(action: string) {
</N8nText>
</template>
<template #append>
<N8nTooltip :class="$style.cardBadge" placement="top">
<N8nTooltip v-if="!isDisabled" :class="$style.cardBadge" placement="top">
<N8nBadge
:class="$style.badge"
theme="tertiary"
@@ -240,4 +240,107 @@ describe('SecretsProviderConnectionCard', () => {
expect(screen.queryByTestId('action-reload')).not.toBeInTheDocument();
});
it('should hide sharing badge when provider is disabled', () => {
const disabledProvider: SecretProviderConnection = {
...mockProvider,
isEnabled: false,
};
const providerTypeInfo = MOCK_PROVIDER_TYPES.find((t) => t.type === mockProvider.type);
renderComponent({
pinia,
props: { provider: disabledProvider, providerTypeInfo, canUpdate: true },
});
expect(screen.queryByTestId('secrets-provider-global-badge')).not.toBeInTheDocument();
expect(screen.queryByTestId('secrets-provider-project-badge')).not.toBeInTheDocument();
});
it('should show sharing badge when provider is enabled', () => {
const providerTypeInfo = MOCK_PROVIDER_TYPES.find((t) => t.type === mockProvider.type);
renderComponent({
pinia,
props: { provider: mockProvider, providerTypeInfo, canUpdate: true },
});
const globalBadge = screen.queryByTestId('secrets-provider-global-badge');
const projectBadge = screen.queryByTestId('secrets-provider-project-badge');
expect(globalBadge ?? projectBadge).toBeInTheDocument();
});
it('should show activate option in context menu when provider is disabled and user can update', () => {
const disabledProvider: SecretProviderConnection = {
...mockProvider,
isEnabled: false,
};
const providerTypeInfo = MOCK_PROVIDER_TYPES.find((t) => t.type === mockProvider.type);
renderComponent({
pinia,
props: { provider: disabledProvider, providerTypeInfo, canUpdate: true },
});
expect(screen.getByTestId('action-activate')).toBeInTheDocument();
});
it('should not show activate option in context menu when provider is already enabled', () => {
const providerTypeInfo = MOCK_PROVIDER_TYPES.find((t) => t.type === mockProvider.type);
renderComponent({
pinia,
props: { provider: mockProvider, providerTypeInfo, canUpdate: true },
});
expect(screen.queryByTestId('action-activate')).not.toBeInTheDocument();
});
it('should not show activate option in context menu when user lacks update permission', () => {
const disabledProvider: SecretProviderConnection = {
...mockProvider,
isEnabled: false,
};
const providerTypeInfo = MOCK_PROVIDER_TYPES.find((t) => t.type === mockProvider.type);
renderComponent({
pinia,
props: { provider: disabledProvider, providerTypeInfo, canUpdate: false },
});
expect(screen.queryByTestId('action-activate')).not.toBeInTheDocument();
});
it('should show inactive badge text when provider is disabled', () => {
const disabledProvider: SecretProviderConnection = {
...mockProvider,
isEnabled: false,
};
const providerTypeInfo = MOCK_PROVIDER_TYPES.find((t) => t.type === mockProvider.type);
renderComponent({
pinia,
props: { provider: disabledProvider, providerTypeInfo, canUpdate: true },
});
expect(screen.getByTestId('disabled-badge')).toHaveTextContent('Inactive');
});
it('should not show reload action when provider is disabled', () => {
const rbacStore = useRBACStore();
rbacStore.globalScopes = ['externalSecretsProvider:sync'];
const disabledProvider: SecretProviderConnection = {
...mockProvider,
state: 'connected',
isEnabled: false,
};
const providerTypeInfo = MOCK_PROVIDER_TYPES.find((t) => t.type === mockProvider.type);
renderComponent({
pinia,
props: { provider: disabledProvider, providerTypeInfo, canUpdate: true },
});
expect(screen.queryByTestId('action-reload')).not.toBeInTheDocument();
});
});
@@ -14,6 +14,7 @@ import {
createProjectSecretProviderConnection,
updateProjectSecretProviderConnection,
testProjectSecretProviderConnection,
enableSecretProviderConnection,
} from '@n8n/rest-api-client';
/**
@@ -118,6 +119,10 @@ export function useSecretsProviderConnection(projectId?: string) {
return await reloadSecretProviderConnection(rootStore.restApiContext, providerKey);
}
async function activateConnection(providerKey: string): Promise<SecretProviderConnection> {
return await enableSecretProviderConnection(rootStore.restApiContext, providerKey);
}
return {
// State
connectionState,
@@ -131,5 +136,6 @@ export function useSecretsProviderConnection(projectId?: string) {
updateConnection,
testConnection,
reloadConnection,
activateConnection,
};
}
@@ -106,6 +106,31 @@ function getProjectForProvider(provider: SecretProviderConnection): ProjectListI
);
}
async function handleActivate(providerKey: string) {
try {
await secretsProviderConnection.activateConnection(providerKey);
await secretsProviders.fetchConnection(providerKey);
toast.showMessage({
title: i18n.baseText('settings.secretsProviderConnections.actions.activate.success.title'),
message: i18n.baseText(
'settings.secretsProviderConnections.actions.activate.success.description',
{
interpolate: { provider: providerKey },
},
),
type: 'success',
});
} catch (error) {
toast.showError(
error,
i18n.baseText('settings.secretsProviderConnections.actions.activate.error.title'),
i18n.baseText('settings.secretsProviderConnections.actions.activate.error.description', {
interpolate: { provider: providerKey },
}),
);
}
}
function getProviderTypeInfo(providerType: string) {
return secretsProviders.providerTypes.value.find((type) => type.type === providerType);
}
@@ -290,6 +315,7 @@ function goToUpgrade() {
@edit="handleEdit"
@share="handleShare"
@reload="handleReload"
@activate="handleActivate"
@delete="handleDelete"
/>
</div>
@@ -28,7 +28,18 @@ vi.mock('vue-router', async () => {
};
});
const mockShowMessage = vi.fn();
const mockShowError = vi.fn();
vi.mock('@/app/composables/useToast', () => ({
useToast: vi.fn(() => ({
showMessage: mockShowMessage,
showError: mockShowError,
})),
}));
const mockReloadConnection = vi.fn();
const mockActivateConnection = vi.fn();
vi.mock('../composables/useSecretsProviderConnection.ee', () => ({
useSecretsProviderConnection: () => ({
@@ -41,6 +52,7 @@ vi.mock('../composables/useSecretsProviderConnection.ee', () => ({
createConnection: vi.fn(),
updateConnection: vi.fn(),
testConnection: vi.fn(),
activateConnection: mockActivateConnection,
}),
}));
@@ -82,6 +94,7 @@ describe('SettingsSecretsProviders', () => {
mockFetchProviders.mockResolvedValue(undefined);
mockFetchActiveConnections.mockResolvedValue(undefined);
mockFetchConnection.mockResolvedValue(undefined);
mockActivateConnection.mockResolvedValue(undefined);
mockIsEnterpriseEnabled.value = false;
mockProviders.value = [];
mockActiveProviders.value = [];
@@ -348,4 +361,68 @@ describe('SettingsSecretsProviders', () => {
expect(mockFetchConnection).not.toHaveBeenCalled();
});
});
describe('handleActivate', () => {
const activeProviders: SecretProviderConnection[] = [
{
id: '1',
name: 'aws-prod',
type: 'awsSecretsManager',
state: 'connected',
isEnabled: false,
projects: [],
settings: {},
secretsCount: 5,
secrets: [],
createdAt: '2024-01-20T10:00:00Z',
updatedAt: '2024-01-20T10:00:00Z',
},
];
it('should call activateConnection, fetchConnection and show success toast on success', async () => {
settingsStore.settings.enterprise[EnterpriseEditionFeature.ExternalSecrets] = true;
mockIsEnterpriseEnabled.value = true;
mockIsLoading.value = false;
mockActiveProviders.value = activeProviders;
const rbacStore = useRBACStore();
rbacStore.globalScopes = ['externalSecretsProvider:update'];
mockActivateConnection.mockResolvedValue({ ...activeProviders[0], isEnabled: true });
const { getByTestId } = renderComponent({ pinia });
await userEvent.click(getByTestId('action-activate'));
await vi.waitFor(() => {
expect(mockActivateConnection).toHaveBeenCalledWith('aws-prod');
});
expect(mockFetchConnection).toHaveBeenCalledWith('aws-prod');
expect(mockShowMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }));
});
it('should show error toast and not fetch connection when activation fails', async () => {
settingsStore.settings.enterprise[EnterpriseEditionFeature.ExternalSecrets] = true;
mockIsEnterpriseEnabled.value = true;
mockIsLoading.value = false;
mockActiveProviders.value = activeProviders;
const rbacStore = useRBACStore();
rbacStore.globalScopes = ['externalSecretsProvider:update'];
mockActivateConnection.mockRejectedValue(new Error('Activation failed'));
const { getByTestId } = renderComponent({ pinia });
await userEvent.click(getByTestId('action-activate'));
await vi.waitFor(() => {
expect(mockActivateConnection).toHaveBeenCalledWith('aws-prod');
});
expect(mockFetchConnection).not.toHaveBeenCalled();
expect(mockShowError).toHaveBeenCalled();
});
});
});