mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
feat(editor): Allow instance admin to re-enable disabled secret provider connections (#26760)
This commit is contained in:
@@ -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,
|
||||
|
||||
+25
-3
@@ -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"
|
||||
|
||||
+103
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+6
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
+26
@@ -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>
|
||||
|
||||
+77
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user