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:
Yuliia Pominchuk 2026-10-02 11:06:04 +00:00 committed by GitHub
parent e79a2ba84b
commit deaa9b1054
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 708 additions and 17 deletions

View File

@ -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",

View File

@ -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>;

View File

@ -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">

View File

@ -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', () => {

View File

@ -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');
}

View File

@ -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

View File

@ -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');
});
});
});

View File

@ -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"

View File

@ -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);
});
});

View File

@ -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 };
}

View File

@ -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: {

View File

@ -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"