mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
feat(editor): Stop Execute and Publish when a credential is not yours to use (#39883)
Co-authored-by: MHMD <mohamed.dounnani@n8n.io> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e79a2ba84b
commit
deaa9b1054
|
|
@ -1176,6 +1176,8 @@
|
|||
"credentialEdit.credentialEdit.docs": "Docs",
|
||||
"credentialEdit.oAuthButton.connectMyAccount": "Connect my account",
|
||||
"credentialEdit.oAuthButton.signInWithGoogle": "Sign in with Google",
|
||||
"credentialSharing.blocked": "Uses {credential}. Only {owner} can run or publish with it. You can still edit the workflow and switch to a credential you can use.",
|
||||
"credentialSharing.blocked.unknownOwner": "Uses {credential}. Only its owner can run or publish with it. You can still edit the workflow and switch to a credential you can use.",
|
||||
"credentialEdit.credentialSharing.info.owner": "Sharing a credential allows people to use it in their workflows. They cannot access credential details.",
|
||||
"credentialEdit.credentialSharing.info.sharee.team": "Only users with credential sharing permission can change who this credential is shared with",
|
||||
"credentialEdit.credentialSharing.info.sharee.personal": "Only {credentialOwnerName} or users with credential sharing permission can change who this credential is shared with",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { WORKFLOW_PUBLISH_MODAL_KEY, EnterpriseEditionFeature } from '@/app/cons
|
|||
import { MANUAL_TRIGGER_NODE_TYPE } from '@/app/constants/nodeTypes';
|
||||
import { STORES } from '@n8n/stores';
|
||||
import type { INodeUi } from '@/Interface';
|
||||
import type { IUsedCredential } from '@/features/credentials/credentials.types';
|
||||
import { ProjectTypes } from '@/features/collaboration/projects/projects.types';
|
||||
import { createTestProject } from '@/features/collaboration/projects/__tests__/utils';
|
||||
import {
|
||||
|
|
@ -24,6 +25,7 @@ import {
|
|||
createWorkflowDocumentId,
|
||||
} from '@/app/stores/workflowDocument.store';
|
||||
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
|
||||
import { nodeViewEventBus } from '@/app/event-bus';
|
||||
import { useReviewRequiredStore } from '@/features/workflow-reviews/reviewRequired.store';
|
||||
import { useWorkflowReviewStatusStore } from '@/features/workflow-reviews/reviewStatus.store';
|
||||
import {
|
||||
|
|
@ -1061,6 +1063,145 @@ describe('WorkflowHeaderDraftPublishActions', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('Unusable credential', () => {
|
||||
const foreignCredential: IUsedCredential = {
|
||||
id: 'c1',
|
||||
name: "Alice's Gmail",
|
||||
credentialType: 'gmailOAuth2',
|
||||
currentUserCanUse: false,
|
||||
homeProject: {
|
||||
id: 'p1',
|
||||
name: 'Alice Chen <alice@acme.io>',
|
||||
type: 'personal',
|
||||
icon: null,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
},
|
||||
};
|
||||
|
||||
const useCredential = (credential: IUsedCredential, { flagEnabled = true } = {}) => {
|
||||
settingsStore.settings = {
|
||||
...settingsStore.settings,
|
||||
granularCredentialSharing: flagEnabled,
|
||||
};
|
||||
workflowDocumentStore.setUsedCredentials([credential]);
|
||||
workflowDocumentStore.setNodes([
|
||||
{
|
||||
...triggerNode,
|
||||
credentials: { gmailOAuth2: { id: credential.id, name: credential.name } },
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
// The file-wide stub always renders the tooltip content. This one honours
|
||||
// `disabled`, so a suppressed tooltip is visible as a missing reason.
|
||||
const tooltipStubs = {
|
||||
N8nTooltip: {
|
||||
props: { disabled: Boolean },
|
||||
template: '<div><slot v-if="!disabled" name="content" /><slot /></div>',
|
||||
},
|
||||
};
|
||||
|
||||
it('should disable publish and name the credential and its owner', () => {
|
||||
useCredential(foreignCredential);
|
||||
|
||||
const { getByTestId, getByText } = renderComponent();
|
||||
|
||||
expect(getByTestId('workflow-open-publish-modal-button')).toBeDisabled();
|
||||
expect(getByText(/Only Alice Chen can run or publish with it/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not open the publish modal', async () => {
|
||||
const openModalSpy = vi.spyOn(uiStore, 'openModalWithData');
|
||||
useCredential(foreignCredential);
|
||||
|
||||
const { getByTestId } = renderComponent();
|
||||
|
||||
await userEvent.click(getByTestId('workflow-open-publish-modal-button'));
|
||||
|
||||
expect(openModalSpy).not.toHaveBeenCalled();
|
||||
expect(mockSaveCurrentWorkflow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// No permission makes the workflow publishable, so this reason is the useful one.
|
||||
it('should give the credential reason before the permission one', () => {
|
||||
useCredential(foreignCredential);
|
||||
|
||||
const { getByText, queryByText } = renderComponent({
|
||||
props: {
|
||||
...defaultWorkflowProps,
|
||||
workflowPermissions: { ...defaultWorkflowProps.workflowPermissions, publish: false },
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByText(/Only Alice Chen can run or publish with it/)).toBeInTheDocument();
|
||||
expect(
|
||||
queryByText("You don't have permission to publish this workflow"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// A draft that is otherwise ready to publish hides the tooltip, and that is
|
||||
// the state a colleague opening someone else's workflow lands in.
|
||||
it('should still explain itself on a draft that is otherwise publishable', () => {
|
||||
useCredential(foreignCredential);
|
||||
workflowDocumentStore.setActiveState({ activeVersionId: null, activeVersion: null });
|
||||
|
||||
const { getByTestId, getByText } = renderComponent({
|
||||
global: { stubs: tooltipStubs },
|
||||
});
|
||||
|
||||
expect(getByTestId('workflow-open-publish-modal-button')).toBeDisabled();
|
||||
expect(getByText(/Only Alice Chen can run or publish with it/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The NDV and the command bar publish through the event bus, which never
|
||||
// sees the button's disabled state.
|
||||
it('should stop a publish command from the event bus', async () => {
|
||||
const openModalSpy = vi.spyOn(uiStore, 'openModalWithData');
|
||||
useCredential(foreignCredential);
|
||||
|
||||
renderComponent();
|
||||
nodeViewEventBus.emit('publishWorkflow');
|
||||
await waitFor(() => expect(mockShowMessage).toHaveBeenCalled());
|
||||
|
||||
expect(openModalSpy).not.toHaveBeenCalled();
|
||||
expect(mockSaveCurrentWorkflow).not.toHaveBeenCalled();
|
||||
expect(mockShowMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: expect.stringContaining('Only Alice Chen can run or publish with it'),
|
||||
type: 'warning',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should let a publish command through when the user can use the credential', async () => {
|
||||
const openModalSpy = vi.spyOn(uiStore, 'openModalWithData');
|
||||
useCredential({ ...foreignCredential, currentUserCanUse: true });
|
||||
|
||||
renderComponent();
|
||||
nodeViewEventBus.emit('publishWorkflow');
|
||||
|
||||
await waitFor(() => expect(openModalSpy).toHaveBeenCalled());
|
||||
expect(mockShowMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should keep publish enabled when the user can use the credential', () => {
|
||||
useCredential({ ...foreignCredential, currentUserCanUse: true });
|
||||
|
||||
const { getByTestId } = renderComponent();
|
||||
|
||||
expect(getByTestId('workflow-open-publish-modal-button')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('should keep publish enabled while the feature flag is off', () => {
|
||||
useCredential(foreignCredential, { flagEnabled: false });
|
||||
|
||||
const { getByTestId } = renderComponent();
|
||||
|
||||
expect(getByTestId('workflow-open-publish-modal-button')).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Name Version action', () => {
|
||||
let workflowHistoryStore: MockedStore<typeof useWorkflowHistoryStore>;
|
||||
let settingsStore: MockedStore<typeof useSettingsStore>;
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ import {
|
|||
createWorkflowDocumentId,
|
||||
} from '@/app/stores/workflowDocument.store';
|
||||
import { useWorkflowPublicationStatusSync } from '@/app/composables/useWorkflowPublicationStatusSync';
|
||||
import { useUnusableWorkflowCredentials } from '@/features/credentials/composables/useUnusableWorkflowCredentials';
|
||||
import { useWorkflowReviewsFeature } from '@/features/workflow-reviews/composables/useWorkflowReviewsFeature';
|
||||
import WorkflowReviewRequiredToggle from '@/features/workflow-reviews/components/WorkflowReviewRequiredToggle.vue';
|
||||
import WorkflowPublishChoiceDialog from '@/features/workflow-reviews/components/WorkflowPublishChoiceDialog.vue';
|
||||
|
|
@ -78,6 +79,11 @@ const workflowDocumentStore = computed(() =>
|
|||
// Pass a getter so the composable re-syncs internally when the user navigates
|
||||
// to a different workflow without this component being remounted.
|
||||
useWorkflowPublicationStatusSync(() => workflowDocumentStore.value.documentId);
|
||||
|
||||
const { reason: unusableCredentialReason } = useUnusableWorkflowCredentials(
|
||||
() => workflowDocumentStore.value.usedCredentials,
|
||||
() => workflowDocumentStore.value.allNodes,
|
||||
);
|
||||
const { refetch: refetchReviewStatus } = useWorkflowReviewStatusSync(() =>
|
||||
props.isNewWorkflow ? undefined : props.id,
|
||||
);
|
||||
|
|
@ -369,6 +375,12 @@ const onOpenReviewFromBanner = async () => {
|
|||
};
|
||||
|
||||
const onPublishButtonClick = async () => {
|
||||
// Event-bus callers skip the disabled button, so show the reason as a toast.
|
||||
if (unusableCredentialReason.value) {
|
||||
toast.showMessage({ title: unusableCredentialReason.value, type: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await ensureWorkflowSaved())) return;
|
||||
|
||||
if (isWorkflowReviewsEnabled.value) {
|
||||
|
|
@ -398,6 +410,19 @@ const onPublishButtonClick = async () => {
|
|||
};
|
||||
|
||||
const publishButtonConfig = computed(() => {
|
||||
// Published workflows run as the publisher, so check credentials before permissions.
|
||||
if (unusableCredentialReason.value) {
|
||||
return {
|
||||
text: i18n.baseText('workflows.publish'),
|
||||
enabled: false,
|
||||
loading: false,
|
||||
showIndicator: !!activeVersion.value,
|
||||
indicatorClass: activeVersion.value ? 'published' : '',
|
||||
tooltip: unusableCredentialReason.value,
|
||||
showVersionInfo: !!activeVersion.value,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle permission-denied state first
|
||||
if (!hasPublishPermission.value) {
|
||||
const defaultConfigForNoPermission = {
|
||||
|
|
@ -558,6 +583,22 @@ const shouldDisablePublishButton = computed(() => {
|
|||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* A workflow that is ready to publish has nothing to explain, so the tooltip
|
||||
* stays off. A credential this user cannot use is the exception: the button is
|
||||
* disabled in that state too, and the reason is the only way to learn why.
|
||||
*/
|
||||
const isPublishTooltipDisabled = computed(() => {
|
||||
if (unusableCredentialReason.value) return false;
|
||||
|
||||
return (
|
||||
(workflowPublishState.value === 'not-published-eligible' &&
|
||||
props.workflowPermissions.publish) ||
|
||||
(!publishButtonConfig.value.tooltip &&
|
||||
!(publishButtonConfig.value.showVersionInfo && activeVersion.value))
|
||||
);
|
||||
});
|
||||
|
||||
const activeVersion = computed(() => workflowDocumentStore.value.activeVersion ?? null);
|
||||
|
||||
/**
|
||||
|
|
@ -810,16 +851,7 @@ onBeforeUnmount(() => {
|
|||
</div>
|
||||
<div v-if="!shouldHidePublishButton" :class="$style.publishButtonWrapper">
|
||||
<div :class="$style.buttonGroup">
|
||||
<N8nTooltip
|
||||
:disabled="
|
||||
(workflowPublishState === 'not-published-eligible' &&
|
||||
props.workflowPermissions.publish) ||
|
||||
(!publishButtonConfig.tooltip &&
|
||||
!(publishButtonConfig.showVersionInfo && activeVersion))
|
||||
"
|
||||
:show-after="300"
|
||||
:offset="15"
|
||||
>
|
||||
<N8nTooltip :disabled="isPublishTooltipDisabled" :show-after="300" :offset="15">
|
||||
<template #content>
|
||||
<div>
|
||||
<template v-if="publishButtonConfig.tooltip">
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ import { useUIStore } from '@/app/stores/ui.store';
|
|||
import { needsAgentInput } from '@/app/utils/nodes/nodeTransforms';
|
||||
import { generateCodeForAiTransform } from '@/features/ndv/parameters/utils/buttonParameter.utils';
|
||||
import type { INodeUi } from '@/Interface';
|
||||
import type { IUsedCredential } from '@/features/credentials/credentials.types';
|
||||
import { useSettingsStore } from '@n8n/stores/settings.store';
|
||||
import {
|
||||
WEBHOOK_NODE_TYPE,
|
||||
MANUAL_TRIGGER_NODE_TYPE,
|
||||
|
|
@ -76,6 +78,8 @@ const {
|
|||
getStartNode: vi.fn(),
|
||||
checkIfNodeHasChatParent: vi.fn(),
|
||||
pinData: {} as Record<string, unknown>,
|
||||
usedCredentials: {} as Record<string, IUsedCredential>,
|
||||
allNodes: [] as INodeUi[],
|
||||
},
|
||||
mockNodeHelpers: {
|
||||
getNodeInputData: vi.fn().mockReturnValue([]),
|
||||
|
|
@ -155,10 +159,17 @@ vi.mock('@n8n/composables/useTelemetry', () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
// Keeps the interpolated values in the returned string, so a test can assert
|
||||
// what a message was built from without depending on the copy.
|
||||
const baseTextMock = vi.hoisted(
|
||||
() => (key: string, options?: { interpolate?: Record<string, string> }) =>
|
||||
options?.interpolate ? `${key} ${Object.values(options.interpolate).join(' ')}` : key,
|
||||
);
|
||||
|
||||
vi.mock('@n8n/i18n', () => ({
|
||||
i18n: { baseText: vi.fn().mockImplementation((key: string) => key) },
|
||||
i18n: { baseText: vi.fn().mockImplementation(baseTextMock) },
|
||||
useI18n: vi.fn().mockReturnValue({
|
||||
baseText: vi.fn().mockImplementation((key: string) => key),
|
||||
baseText: vi.fn().mockImplementation(baseTextMock),
|
||||
}),
|
||||
}));
|
||||
|
||||
|
|
@ -219,6 +230,8 @@ describe('useNodeExecution', () => {
|
|||
mockNodeHelpers.getNodeInputData.mockReset().mockReturnValue([]);
|
||||
mockWorkflowDocumentStore.getNodeByName.mockReset();
|
||||
mockWorkflowDocumentStore.pinData = {};
|
||||
mockWorkflowDocumentStore.usedCredentials = {};
|
||||
mockWorkflowDocumentStore.allNodes = [];
|
||||
|
||||
mockNodeTypesStore.getNodeType.mockReturnValue(null);
|
||||
mockNodeTypesStore.isTriggerNode.mockReturnValue(false);
|
||||
|
|
@ -468,6 +481,115 @@ describe('useNodeExecution', () => {
|
|||
|
||||
expect(disabledReason.value).toBe('');
|
||||
});
|
||||
|
||||
describe('unusable credential', () => {
|
||||
const foreignCredential: IUsedCredential = {
|
||||
id: 'c1',
|
||||
name: "Alice's Gmail",
|
||||
credentialType: 'gmailOAuth2',
|
||||
currentUserCanUse: false,
|
||||
homeProject: {
|
||||
id: 'p1',
|
||||
name: 'Alice Chen <alice@acme.io>',
|
||||
type: 'personal',
|
||||
icon: null,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
},
|
||||
};
|
||||
|
||||
const useCredential = (credential: IUsedCredential) => {
|
||||
mockWorkflowDocumentStore.usedCredentials = { [credential.id]: credential };
|
||||
mockWorkflowDocumentStore.allNodes = [
|
||||
createTestNode({
|
||||
name: 'Gmail',
|
||||
credentials: { gmailOAuth2: { id: credential.id, name: credential.name } },
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore().settings.granularCredentialSharing = true;
|
||||
});
|
||||
|
||||
it('should name the credential and its owner when the user cannot use it', () => {
|
||||
useCredential(foreignCredential);
|
||||
const node = ref(createTestNode());
|
||||
|
||||
const { disabledReason } = useNodeExecution(node);
|
||||
|
||||
expect(disabledReason.value).toContain('credentialSharing.blocked');
|
||||
expect(disabledReason.value).toContain("Alice's Gmail");
|
||||
expect(disabledReason.value).toContain('Alice Chen');
|
||||
});
|
||||
|
||||
// The whole workflow stops, not only the node that holds the credential.
|
||||
it('should block a node that does not hold the credential', () => {
|
||||
useCredential(foreignCredential);
|
||||
const node = ref(createTestNode({ name: 'Set' }));
|
||||
|
||||
const { disabledReason } = useNodeExecution(node);
|
||||
|
||||
expect(disabledReason.value).toContain('credentialSharing.blocked');
|
||||
});
|
||||
|
||||
// No per-node fix makes the run possible, so the credential reason is the
|
||||
// one worth showing.
|
||||
it.each([
|
||||
['a trigger node with issues', { issues: { parameters: { param1: ['error'] } } }],
|
||||
['a disabled node', { disabled: true }],
|
||||
])('should outrank %s', (_name, overrides) => {
|
||||
mockNodeTypesStore.isTriggerNode.mockReturnValue(true);
|
||||
useCredential(foreignCredential);
|
||||
const node = ref(createTestNode(overrides));
|
||||
|
||||
const { disabledReason } = useNodeExecution(node);
|
||||
|
||||
expect(disabledReason.value).toContain('credentialSharing.blocked');
|
||||
});
|
||||
|
||||
it('should outrank a workflow that is already running', () => {
|
||||
mockWorkflowExecutionStateStore.isWorkflowRunning = true;
|
||||
mockWorkflowsStore.executedNode = 'Other Node';
|
||||
useCredential(foreignCredential);
|
||||
const node = ref(createTestNode({ name: 'Test Node' }));
|
||||
|
||||
const { disabledReason } = useNodeExecution(node);
|
||||
|
||||
expect(disabledReason.value).toContain('credentialSharing.blocked');
|
||||
});
|
||||
|
||||
// The button says "Stop listening" there, so it has to stay clickable.
|
||||
it('should not outrank a node that is listening', () => {
|
||||
mockNodeTypesStore.isTriggerNode.mockReturnValue(true);
|
||||
mockWorkflowExecutionStateStore.executionWaitingForWebhook = true;
|
||||
useCredential(foreignCredential);
|
||||
const node = ref(createTestNode({ type: WEBHOOK_NODE_TYPE }));
|
||||
|
||||
const { disabledReason } = useNodeExecution(node);
|
||||
|
||||
expect(disabledReason.value).toBe('');
|
||||
});
|
||||
|
||||
it('should return empty string when the user can use the credential', () => {
|
||||
useCredential({ ...foreignCredential, currentUserCanUse: true });
|
||||
const node = ref(createTestNode());
|
||||
|
||||
const { disabledReason } = useNodeExecution(node);
|
||||
|
||||
expect(disabledReason.value).toBe('');
|
||||
});
|
||||
|
||||
it('should return empty string while the feature flag is off', () => {
|
||||
useSettingsStore().settings.granularCredentialSharing = false;
|
||||
useCredential(foreignCredential);
|
||||
const node = ref(createTestNode());
|
||||
|
||||
const { disabledReason } = useNodeExecution(node);
|
||||
|
||||
expect(disabledReason.value).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buttonLabel', () => {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { useToast } from '@n8n/composables/useToast';
|
|||
import { useExternalHooks } from '@/app/composables/useExternalHooks';
|
||||
import { useEditorContext } from '@/app/composables/useEditorContext';
|
||||
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
|
||||
import { useUnusableWorkflowCredentials } from '@/features/credentials/composables/useUnusableWorkflowCredentials';
|
||||
|
||||
import { needsAgentInput } from '@/app/utils/nodes/nodeTransforms';
|
||||
import { generateCodeForAiTransform } from '@/features/ndv/parameters/utils/buttonParameter.utils';
|
||||
|
|
@ -101,6 +102,10 @@ export function useNodeExecution(
|
|||
const uiStore = useUIStore();
|
||||
|
||||
const workflowDocumentStore = injectWorkflowDocumentStore();
|
||||
const { reason: unusableCredentialReason } = useUnusableWorkflowCredentials(
|
||||
() => workflowDocumentStore.value.usedCredentials,
|
||||
() => workflowDocumentStore.value.allNodes,
|
||||
);
|
||||
const ndvStore = computed(() => useNDVStore(workflowDocumentStore.value.documentId));
|
||||
const workflowExecutionStateStore = injectWorkflowExecutionStateStore();
|
||||
|
||||
|
|
@ -194,6 +199,11 @@ export function useNodeExecution(
|
|||
return i18n.baseText('ndv.execute.generatingCode');
|
||||
}
|
||||
|
||||
// An unusable credential blocks the whole run, so it outranks per-node reasons.
|
||||
if (unusableCredentialReason.value) {
|
||||
return unusableCredentialReason.value;
|
||||
}
|
||||
|
||||
if (nodeRef.value?.disabled) {
|
||||
return i18n.baseText('ndv.execute.nodeIsDisabled');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,6 +106,10 @@ export function useNodeHelpers() {
|
|||
* - It is either explicitly marked as `executable`, OR uses foreign credentials
|
||||
* (credentials the current user cannot access, allowed under Workflow Sharing).
|
||||
*
|
||||
* The foreign-credential arm decides whether the button is offered, not whether
|
||||
* the run is allowed. `useNodeExecution`'s `disabledReason` blocks the run and
|
||||
* shows why, so removing this arm would hide that reason.
|
||||
*
|
||||
* @param node The node to check
|
||||
* @param executable Whether the node is in a state that allows execution (e.g. not readonly)
|
||||
* @param foreignCredentials List of credential IDs that the current user cannot access
|
||||
|
|
|
|||
|
|
@ -89,6 +89,30 @@ vi.mock('vue-router', () => ({
|
|||
onBeforeRouteLeave: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockRunEntireWorkflow = vi.hoisted(() => vi.fn());
|
||||
const mockShowMessage = vi.hoisted(() => vi.fn());
|
||||
|
||||
// Only the entry point the canvas shortcut reaches is replaced; the rest of
|
||||
// the composable stays real so the view still wires itself up.
|
||||
vi.mock('@/app/composables/useRunWorkflow', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('@/app/composables/useRunWorkflow')>();
|
||||
return {
|
||||
...original,
|
||||
useRunWorkflow: (...args: Parameters<typeof original.useRunWorkflow>) => ({
|
||||
...original.useRunWorkflow(...args),
|
||||
runEntireWorkflow: mockRunEntireWorkflow,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@n8n/composables/useToast', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('@n8n/composables/useToast')>();
|
||||
return {
|
||||
...original,
|
||||
useToast: () => ({ ...original.useToast(), showMessage: mockShowMessage }),
|
||||
};
|
||||
});
|
||||
|
||||
// Route actions open the NDV. The real NDV needs `route.meta` and `<dialog>`
|
||||
// APIs that this mock and jsdom lack; these tests only assert canvas state.
|
||||
vi.mock('@/features/ndv/shared/views/NodeDetailsView.vue', () => ({
|
||||
|
|
@ -180,7 +204,7 @@ describe('NodeView', () => {
|
|||
}),
|
||||
LazySetupWorkflowCredentialsButton: { render: () => null },
|
||||
WorkflowCanvas: defineComponent({
|
||||
emits: ['copy:nodes', 'replace:node', 'viewport:change'],
|
||||
emits: ['copy:nodes', 'replace:node', 'viewport:change', 'run:workflow'],
|
||||
setup(_, { emit, expose }) {
|
||||
const canvasStore = useCanvasStore();
|
||||
expose({ ensureNodesAreVisible });
|
||||
|
|
@ -204,6 +228,10 @@ describe('NodeView', () => {
|
|||
data-test-id="canvas-stub-set-viewport"
|
||||
@click="$emit('viewport:change', { x: 0, y: 0, zoom: 1 }, { width: 1000, height: 1000 })"
|
||||
/>
|
||||
<button
|
||||
data-test-id="canvas-stub-run-workflow"
|
||||
@click="$emit('run:workflow')"
|
||||
/>
|
||||
<slot />
|
||||
</div>`,
|
||||
}),
|
||||
|
|
@ -1177,4 +1205,73 @@ describe('NodeView', () => {
|
|||
expect(workflowDocumentStore.allNodes.map((node) => node.name)).toEqual(['Existing']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Execute gating on an unusable credential', () => {
|
||||
const trigger = createTestNode({
|
||||
type: MANUAL_TRIGGER_NODE_TYPE,
|
||||
name: 'trigger',
|
||||
credentials: { gmailOAuth2: { id: 'c1', name: "Alice's Gmail" } },
|
||||
});
|
||||
|
||||
const unusableCredential = {
|
||||
id: 'c1',
|
||||
name: "Alice's Gmail",
|
||||
credentialType: 'gmailOAuth2',
|
||||
currentUserCanUse: false,
|
||||
homeProject: {
|
||||
id: 'p1',
|
||||
name: 'Alice Chen <alice@acme.io>',
|
||||
type: 'personal' as const,
|
||||
icon: null,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore().settings.granularCredentialSharing = true;
|
||||
useNodeTypesStore().setNodeTypes([
|
||||
mockNodeTypeDescription({ name: MANUAL_TRIGGER_NODE_TYPE, group: ['trigger'] }),
|
||||
]);
|
||||
workflowDocumentStore.setNodes([trigger]);
|
||||
});
|
||||
|
||||
// The shortcut bypasses the button, so it has to ask the same question.
|
||||
it('stops the run shortcut and says which credential and whose', async () => {
|
||||
workflowDocumentStore.setUsedCredentials([unusableCredential]);
|
||||
const { findByTestId } = renderNodeView();
|
||||
|
||||
await userEvent.click(await findByTestId('canvas-stub-run-workflow'));
|
||||
|
||||
expect(mockRunEntireWorkflow).not.toHaveBeenCalled();
|
||||
expect(mockShowMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: expect.stringContaining("Alice's Gmail"),
|
||||
type: 'warning',
|
||||
}),
|
||||
);
|
||||
expect(mockShowMessage.mock.calls[0][0].title).toContain('Alice Chen');
|
||||
});
|
||||
|
||||
it('runs from the shortcut when every credential is usable', async () => {
|
||||
workflowDocumentStore.setUsedCredentials([
|
||||
{ ...unusableCredential, currentUserCanUse: true },
|
||||
]);
|
||||
const { findByTestId } = renderNodeView();
|
||||
|
||||
await userEvent.click(await findByTestId('canvas-stub-run-workflow'));
|
||||
|
||||
expect(mockRunEntireWorkflow).toHaveBeenCalledWith('main');
|
||||
});
|
||||
|
||||
it('runs from the shortcut while the feature flag is off', async () => {
|
||||
useSettingsStore().settings.granularCredentialSharing = false;
|
||||
workflowDocumentStore.setUsedCredentials([unusableCredential]);
|
||||
const { findByTestId } = renderNodeView();
|
||||
|
||||
await userEvent.click(await findByTestId('canvas-stub-run-workflow'));
|
||||
|
||||
expect(mockRunEntireWorkflow).toHaveBeenCalledWith('main');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -149,6 +149,7 @@ import { useActivityDetection } from '@/app/composables/useActivityDetection';
|
|||
import { useCollaborationStore } from '@/features/collaboration/collaboration/collaboration.store';
|
||||
import { useInjectWorkflowId } from '@/app/composables/useInjectWorkflowId';
|
||||
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
|
||||
import { useUnusableWorkflowCredentials } from '@/features/credentials/composables/useUnusableWorkflowCredentials';
|
||||
|
||||
import {
|
||||
N8nCallout,
|
||||
|
|
@ -1273,7 +1274,32 @@ const isExecutionWaitingForWebhook = computed(
|
|||
() => workflowExecutionState.value.executionWaitingForWebhook,
|
||||
);
|
||||
|
||||
const { reason: unusableCredentialReason } = useUnusableWorkflowCredentials(
|
||||
() => workflowDocumentStore.value.usedCredentials,
|
||||
() => workflowDocumentStore.value.allNodes,
|
||||
);
|
||||
|
||||
/**
|
||||
* Ctrl+Enter reaches `runEntireWorkflow` straight from the canvas keymap, past
|
||||
* the button's own disabled state, so it has to ask the same question. There is
|
||||
* nothing to hover, so the reason is shown as a toast instead of a tooltip.
|
||||
*/
|
||||
function onRunWorkflowShortcut() {
|
||||
if (unusableCredentialReason.value) {
|
||||
toast.showMessage({ title: unusableCredentialReason.value, type: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
void runEntireWorkflow('main');
|
||||
}
|
||||
|
||||
const isExecutionDisabled = computed(() => {
|
||||
// A run is checked against the person it acts as, so a credential this user
|
||||
// cannot use stops the workflow. The backend refuses it either way.
|
||||
if (unusableCredentialReason.value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
containsChatTriggerNodes.value &&
|
||||
isOnlyChatTriggerNodeActive.value &&
|
||||
|
|
@ -2199,7 +2225,7 @@ onBeforeUnmount(() => {
|
|||
@copy:nodes="onCopyNodes"
|
||||
@cut:nodes="onCutNodes"
|
||||
@replace:node="onClickReplaceNode"
|
||||
@run:workflow="runEntireWorkflow('main')"
|
||||
@run:workflow="onRunWorkflowShortcut"
|
||||
@save:workflow="onSaveWorkflow"
|
||||
@create:workflow="onCreateWorkflow"
|
||||
@viewport:change="onViewportChange"
|
||||
|
|
@ -2235,6 +2261,7 @@ onBeforeUnmount(() => {
|
|||
<div :class="$style.executionButtons">
|
||||
<CanvasRunWorkflowButton
|
||||
v-if="isRunWorkflowButtonVisible"
|
||||
:disabled-reason="unusableCredentialReason"
|
||||
:waiting-for-webhook="isExecutionWaitingForWebhook"
|
||||
:disabled="isExecutionDisabled"
|
||||
:executing="isWorkflowRunning"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,134 @@
|
|||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import type { FrontendSettings } from '@n8n/api-types';
|
||||
import { useSettingsStore } from '@n8n/stores/settings.store';
|
||||
|
||||
import type { INodeUi } from '@/Interface';
|
||||
|
||||
import { createTestNode } from '@/__tests__/mocks';
|
||||
import type { IUsedCredential } from '../credentials.types';
|
||||
import { useUnusableWorkflowCredentials } from './useUnusableWorkflowCredentials';
|
||||
|
||||
describe('useUnusableWorkflowCredentials', () => {
|
||||
const usable: IUsedCredential = {
|
||||
id: 'ok',
|
||||
name: 'Team Gmail',
|
||||
credentialType: 'gmailOAuth2',
|
||||
currentUserCanUse: true,
|
||||
};
|
||||
|
||||
const personallyOwned: IUsedCredential = {
|
||||
id: 'nope',
|
||||
name: "Alice's Gmail",
|
||||
credentialType: 'gmailOAuth2',
|
||||
currentUserCanUse: false,
|
||||
homeProject: {
|
||||
id: 'p1',
|
||||
name: 'Alice Chen <alice@acme.io>',
|
||||
type: 'personal',
|
||||
icon: null,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
},
|
||||
};
|
||||
|
||||
/** A node that uses every credential passed in, so the workflow references them. */
|
||||
const nodeUsing = (credentials: IUsedCredential[], { disabled = false } = {}) =>
|
||||
createTestNode({
|
||||
name: 'Gmail',
|
||||
disabled,
|
||||
credentials: Object.fromEntries(
|
||||
credentials.map((c) => [c.credentialType, { id: c.id, name: c.name }]),
|
||||
),
|
||||
});
|
||||
|
||||
const setup = (
|
||||
credentials: IUsedCredential[],
|
||||
{ flagEnabled = true, nodes }: { flagEnabled?: boolean; nodes?: INodeUi[] } = {},
|
||||
) => {
|
||||
setActivePinia(createTestingPinia());
|
||||
useSettingsStore().settings = {
|
||||
granularCredentialSharing: flagEnabled,
|
||||
} as FrontendSettings;
|
||||
|
||||
return useUnusableWorkflowCredentials(
|
||||
Object.fromEntries(credentials.map((c) => [c.id, c])),
|
||||
nodes ?? [nodeUsing(credentials)],
|
||||
);
|
||||
};
|
||||
|
||||
it('blocks nothing when every credential is usable', () => {
|
||||
const { isBlocked, reason } = setup([usable]);
|
||||
|
||||
expect(isBlocked.value).toBe(false);
|
||||
expect(reason.value).toBe('');
|
||||
});
|
||||
|
||||
it('blocks on a credential the user cannot use, naming it and its owner', () => {
|
||||
const { isBlocked, unusable, reason } = setup([usable, personallyOwned]);
|
||||
|
||||
expect(isBlocked.value).toBe(true);
|
||||
expect(unusable.value).toEqual([personallyOwned]);
|
||||
expect(reason.value).toContain("Alice's Gmail");
|
||||
expect(reason.value).toContain('Alice Chen');
|
||||
// A personal project is named "Name <email>"; the email is not shown.
|
||||
expect(reason.value).not.toContain('alice@acme.io');
|
||||
});
|
||||
|
||||
// One sentence covers both actions, and says what the user can still do.
|
||||
it('names both blocked actions and the way out', () => {
|
||||
const { reason } = setup([personallyOwned]);
|
||||
|
||||
expect(reason.value).toContain('can run or publish with it');
|
||||
expect(reason.value).toContain('You can still edit the workflow');
|
||||
});
|
||||
|
||||
it('names the project when the credential belongs to a team', () => {
|
||||
const { reason } = setup([
|
||||
{
|
||||
...personallyOwned,
|
||||
homeProject: { ...personallyOwned.homeProject!, name: 'Sales Ops', type: 'team' },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(reason.value).toContain('Sales Ops');
|
||||
});
|
||||
|
||||
it('still explains itself when the owner is unknown', () => {
|
||||
const { isBlocked, reason } = setup([{ ...personallyOwned, homeProject: undefined }]);
|
||||
|
||||
expect(isBlocked.value).toBe(true);
|
||||
expect(reason.value).toContain('its owner');
|
||||
});
|
||||
|
||||
// Every user-facing change sits behind the flag.
|
||||
it('blocks nothing while the feature flag is off', () => {
|
||||
const { isBlocked, reason } = setup([personallyOwned], { flagEnabled: false });
|
||||
|
||||
expect(isBlocked.value).toBe(false);
|
||||
expect(reason.value).toBe('');
|
||||
});
|
||||
|
||||
// The used-credential metadata only changes on load and on save, so the
|
||||
// block has to follow the nodes instead.
|
||||
it('unblocks as soon as the node switches to another credential', () => {
|
||||
const { isBlocked } = setup([personallyOwned], { nodes: [nodeUsing([usable])] });
|
||||
|
||||
expect(isBlocked.value).toBe(false);
|
||||
});
|
||||
|
||||
it('unblocks when the node that used the credential is gone', () => {
|
||||
const { isBlocked } = setup([personallyOwned], { nodes: [] });
|
||||
|
||||
expect(isBlocked.value).toBe(false);
|
||||
});
|
||||
|
||||
// A disabled node does not run, and the backend skips it too.
|
||||
it('does not block on a disabled node', () => {
|
||||
const { isBlocked } = setup([personallyOwned], {
|
||||
nodes: [nodeUsing([personallyOwned], { disabled: true })],
|
||||
});
|
||||
|
||||
expect(isBlocked.value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import { computed, toValue, type DeepReadonly, type MaybeRefOrGetter } from 'vue';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
|
||||
import type { INodeUi } from '@/Interface';
|
||||
import { ProjectTypes } from '@/features/collaboration/projects/projects.types';
|
||||
import { splitName } from '@/features/collaboration/projects/projects.utils';
|
||||
import type { IUsedCredential } from '../credentials.types';
|
||||
import { useCredentialSharing } from './useCredentialSharing';
|
||||
|
||||
/**
|
||||
* The workflow's credentials the current user cannot use, and the reason to
|
||||
* show in place of running or publishing it.
|
||||
*
|
||||
* Reads `currentUserCanUse` (can-use), not `currentUserHasAccess` (can-see).
|
||||
*
|
||||
* @param usedCredentials getter for the workflow's used credentials.
|
||||
* @param nodes the workflow's current nodes, to stay live between saves.
|
||||
*/
|
||||
export function useUnusableWorkflowCredentials(
|
||||
usedCredentials: MaybeRefOrGetter<DeepReadonly<Record<string, IUsedCredential>> | undefined>,
|
||||
nodes: MaybeRefOrGetter<INodeUi[] | undefined>,
|
||||
) {
|
||||
const i18n = useI18n();
|
||||
const { isEnabled } = useCredentialSharing();
|
||||
|
||||
/**
|
||||
* The credential ids the nodes reference now. The used-credential metadata
|
||||
* only changes when the workflow is loaded or saved, so a credential the user
|
||||
* has just switched away from is still in it. A disabled node does not run,
|
||||
* and the backend skips it too.
|
||||
*/
|
||||
const referencedIds = computed(() => {
|
||||
const ids = new Set<string>();
|
||||
|
||||
for (const node of toValue(nodes) ?? []) {
|
||||
if (node.disabled) continue;
|
||||
|
||||
for (const credential of Object.values(node.credentials ?? {})) {
|
||||
if (credential.id) ids.add(credential.id);
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
});
|
||||
|
||||
const unusable = computed(() => {
|
||||
if (!isEnabled.value) return [];
|
||||
|
||||
return Object.values(toValue(usedCredentials) ?? {}).filter(
|
||||
(credential) =>
|
||||
credential.currentUserCanUse === false && referencedIds.value.has(credential.id),
|
||||
);
|
||||
});
|
||||
|
||||
/** Running and publishing both stop; editing and saving do not. */
|
||||
const isBlocked = computed(() => unusable.value.length > 0);
|
||||
|
||||
/**
|
||||
* Who to ask. A personal project is named after its owner, with their email
|
||||
* appended, so only the name is shown; a team project is named directly.
|
||||
*/
|
||||
const owner = computed(() => {
|
||||
const project = unusable.value[0]?.homeProject;
|
||||
if (!project?.name) return undefined;
|
||||
if (project.type === ProjectTypes.Team) return project.name;
|
||||
|
||||
const { name, email } = splitName(project.name);
|
||||
return name ?? email;
|
||||
});
|
||||
|
||||
const credentialName = computed(() => unusable.value[0]?.name ?? '');
|
||||
|
||||
/**
|
||||
* One sentence for both running and publishing: the credential is not this
|
||||
* user's to use either way. Empty when nothing is blocked, so a caller can
|
||||
* use it as the whole condition.
|
||||
*/
|
||||
const reason = computed(() => {
|
||||
if (!isBlocked.value) return '';
|
||||
|
||||
return owner.value
|
||||
? i18n.baseText('credentialSharing.blocked', {
|
||||
interpolate: { credential: credentialName.value, owner: owner.value },
|
||||
})
|
||||
: i18n.baseText('credentialSharing.blocked.unknownOwner', {
|
||||
interpolate: { credential: credentialName.value },
|
||||
});
|
||||
});
|
||||
|
||||
return { unusable, isBlocked, reason };
|
||||
}
|
||||
|
|
@ -152,6 +152,35 @@ describe('CanvasRunWorkflowButton', () => {
|
|||
await waitFor(() => expect(getTooltip()).toHaveTextContent('Execute workflow'));
|
||||
});
|
||||
|
||||
describe('disabled reason', () => {
|
||||
const reason = "Uses Alice's Gmail. Only Alice Chen can run or publish with it.";
|
||||
|
||||
// The reason replaces the shortcut: it says why the click does nothing.
|
||||
it('should show the reason instead of the label and the shortcut', async () => {
|
||||
const wrapper = renderComponent({ props: { disabled: true, disabledReason: reason } });
|
||||
|
||||
await hoverTooltipTrigger(wrapper.getByRole('button'));
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
|
||||
await waitFor(() => expect(getTooltip()).toHaveTextContent(reason));
|
||||
expect(getTooltip()).not.toHaveTextContent('Execute workflow');
|
||||
expect(getTooltip()).not.toHaveTextContent('↵');
|
||||
});
|
||||
|
||||
// Both of these hide the tooltip otherwise, and the reason has to survive them.
|
||||
it.each([
|
||||
['executing', { executing: true }],
|
||||
['hideTooltip', { hideTooltip: true }],
|
||||
])('should show the reason while %s suppresses the tooltip', async (_name, props) => {
|
||||
const wrapper = renderComponent({ props: { ...props, disabledReason: reason } });
|
||||
|
||||
await hoverTooltipTrigger(wrapper.getByRole('button'));
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
|
||||
await waitFor(() => expect(getTooltip()).toHaveTextContent(reason));
|
||||
});
|
||||
});
|
||||
|
||||
it('should select and execute the trigger picked from the dropdown', async () => {
|
||||
const wrapper = renderComponent({
|
||||
props: {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ const props = withDefaults(
|
|||
* primary CTA (e.g. in the Instance AI artifact or the demo view, where
|
||||
* the canvas isn't the primary surface). */
|
||||
type?: 'primary' | 'secondary';
|
||||
/** Why the button is disabled, shown in place of the shortcut tooltip. */
|
||||
disabledReason?: string;
|
||||
getNodeType: (type: string, typeVersion: number) => INodeTypeDescription | null;
|
||||
}>(),
|
||||
{ type: 'primary' },
|
||||
|
|
@ -116,9 +118,9 @@ function onSelectTriggerNode(name: string) {
|
|||
<template>
|
||||
<div :class="[$style.component, isSplitButton ? $style.split : '']">
|
||||
<KeyboardShortcutTooltip
|
||||
:label="tooltipLabel"
|
||||
:shortcut="{ metaKey: true, keys: ['↵'] }"
|
||||
:disabled="executing || hideTooltip"
|
||||
:label="disabledReason || tooltipLabel"
|
||||
:shortcut="disabledReason ? undefined : { metaKey: true, keys: ['↵'] }"
|
||||
:disabled="!disabledReason && (executing || hideTooltip)"
|
||||
>
|
||||
<N8nButton
|
||||
:variant="buttonVariant"
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user