From da3491fc8786525670e24d35a2c136a0482b32cc Mon Sep 17 00:00:00 2001 From: Yuliia Pominchuk <31064937+yuliia-pominchuk@users.noreply.github.com> Date: Wed, 14 Oct 2026 07:48:34 +0000 Subject: [PATCH] feat(editor): Link to the credential sharing tab when execution data is redacted (#40976) --- .../@n8n/telemetry/src/events/credentials.ts | 10 + .../execution-redaction.service.test.ts | 16 ++ .../execution-redaction.interfaces.ts | 3 + .../executions/execution-redaction.service.ts | 17 +- .../full-item-redaction.strategy.test.ts | 11 +- ...e-defined-field-redaction.strategy.test.ts | 1 + .../full-item-redaction.strategy.ts | 3 + .../frontend/@n8n/i18n/src/locales/en.json | 4 + packages/frontend/editor-ui/src/Interface.ts | 2 + .../editor-ui/src/app/stores/ui.store.test.ts | 10 + .../editor-ui/src/app/stores/ui.store.ts | 3 + .../CredentialEdit/CredentialEdit.test.ts | 51 +++++ .../CredentialEdit/CredentialEdit.vue | 1 + .../composables/useExecutionRedaction.test.ts | 204 ++++++++++++++++++ .../composables/useExecutionRedaction.ts | 136 ++++++++++-- .../logs/components/LogDetailsPanel.vue | 13 +- .../logs/components/LogsViewRunData.vue | 5 +- .../ndv/panel/components/InputPanel.vue | 7 +- .../ndv/panel/components/OutputPanel.vue | 7 +- .../components/RedactedDataState.test.ts | 33 +++ .../panel/components/RedactedDataState.vue | 19 ++ .../run-execution-data.v1.ts | 5 + 22 files changed, 536 insertions(+), 25 deletions(-) diff --git a/packages/@n8n/telemetry/src/events/credentials.ts b/packages/@n8n/telemetry/src/events/credentials.ts index 14054e553bd..0e1f45692c2 100644 --- a/packages/@n8n/telemetry/src/events/credentials.ts +++ b/packages/@n8n/telemetry/src/events/credentials.ts @@ -111,4 +111,14 @@ export const CREDENTIALS_TELEMETRY = defineTelemetryEvents({ ), }), }, + USER_OPENED_CREDENTIAL_SHARING_FROM_REDACTED_DATA: { + name: 'User opened credential sharing from redacted data', + description: + 'A user who can share a credential but cannot use it clicked the link on redacted execution data that opens its sharing tab. Fires once per click.', + properties: z.object({ + credential_id: z.string(), + workflow_id: z.string(), + execution_id: z.string().optional(), + }), + }, }); diff --git a/packages/cli/src/modules/redaction/executions/__tests__/execution-redaction.service.test.ts b/packages/cli/src/modules/redaction/executions/__tests__/execution-redaction.service.test.ts index 5838e642c98..03f7d51d922 100644 --- a/packages/cli/src/modules/redaction/executions/__tests__/execution-redaction.service.test.ts +++ b/packages/cli/src/modules/redaction/executions/__tests__/execution-redaction.service.test.ts @@ -1050,6 +1050,22 @@ describe('ExecutionRedactionService', () => { expect(context.userCanReveal).toBe(false); }); + it('passes on only the credentials of this execution that the viewer cannot use', async () => { + credentialsPermissionChecker.resolveInaccessibleCredentialIdsForUser.mockResolvedValue([ + 'cred-1', + ]); + const execution = makeExecution({ + policy: 'none', + mode: 'manual', + nodes: [nodeWithCredential('cred-1'), nodeWithCredential('cred-2')], + }); + + await service.processExecution(execution, { user: mockUser }); + + const [, context] = fullItemRedactionStrategy.apply.mock.calls[0]; + expect(context.inaccessibleCredentialIds).toEqual(['cred-1']); + }); + it('does not force-redact when the viewer can use every referenced credential', async () => { const execution = makeExecution({ policy: 'none', diff --git a/packages/cli/src/modules/redaction/executions/execution-redaction.interfaces.ts b/packages/cli/src/modules/redaction/executions/execution-redaction.interfaces.ts index 13c56ccd44d..bbb50797e61 100644 --- a/packages/cli/src/modules/redaction/executions/execution-redaction.interfaces.ts +++ b/packages/cli/src/modules/redaction/executions/execution-redaction.interfaces.ts @@ -21,6 +21,9 @@ export interface RedactionContext { * dynamic credentials. Drives the `credential_inaccessible` redaction * reason. */ readonly enforceCredentialUsabilityRedaction: boolean; + /** The credentials behind `enforceCredentialUsabilityRedaction`: those the + * requesting user cannot use. Empty when that redaction does not apply. */ + readonly inaccessibleCredentialIds: readonly string[]; /** Generic memo store — strategies may cache intermediate results here * to avoid redundant computation across requiresRedaction/apply calls. * Keyed by strategy name. */ diff --git a/packages/cli/src/modules/redaction/executions/execution-redaction.service.ts b/packages/cli/src/modules/redaction/executions/execution-redaction.service.ts index 03a9c2ae15c..45cf56dcf2b 100644 --- a/packages/cli/src/modules/redaction/executions/execution-redaction.service.ts +++ b/packages/cli/src/modules/redaction/executions/execution-redaction.service.ts @@ -192,7 +192,8 @@ export class ExecutionRedactionService implements ExecutionRedaction { const isOwnDynCreds = hasDynCreds && this.isOwnDynamicCredentialsExecution(execution, options.user.id); const policyAllowsReveal = this.policyAllowsReveal(execution); - const enforceCredentialUsabilityRedaction = credentialInaccessibleExecutions.has(execution); + const inaccessibleCredentialIds = credentialInaccessibleExecutions.get(execution) ?? []; + const enforceCredentialUsabilityRedaction = inaccessibleCredentialIds.length > 0; const userCanReveal = enforceCredentialUsabilityRedaction ? false : hasDynCreds @@ -205,6 +206,7 @@ export class ExecutionRedactionService implements ExecutionRedaction { userCanReveal, enforceDynCredRedaction, enforceCredentialUsabilityRedaction, + inaccessibleCredentialIds, memo: new Map(), }; const pipeline = this.buildPipeline(execution, context, policyAllowsReveal); @@ -310,8 +312,8 @@ export class ExecutionRedactionService implements ExecutionRedaction { private async resolveCredentialInaccessibleExecutions( executions: RedactableExecution[], user: User, - ): Promise> { - if (!isCredSharingEnabled()) return new Set(); + ): Promise> { + if (!isCredSharingEnabled()) return new Map(); const credentialIdsByExecution = new Map(); const allCredentialIds = new Set(); @@ -323,7 +325,7 @@ export class ExecutionRedactionService implements ExecutionRedaction { credentialIdsByExecution.set(execution, credentialIds); for (const id of credentialIds) allCredentialIds.add(id); } - if (allCredentialIds.size === 0) return new Set(); + if (allCredentialIds.size === 0) return new Map(); const inaccessibleIds = await this.credentialsPermissionChecker.resolveInaccessibleCredentialIdsForUser( @@ -331,12 +333,13 @@ export class ExecutionRedactionService implements ExecutionRedaction { [...allCredentialIds], { ignoreGlobalUseScope: true }, ); - if (inaccessibleIds.length === 0) return new Set(); + if (inaccessibleIds.length === 0) return new Map(); const inaccessibleIdSet = new Set(inaccessibleIds); - const result = new Set(); + const result = new Map(); for (const [execution, credentialIds] of credentialIdsByExecution) { - if (credentialIds.some((id) => inaccessibleIdSet.has(id))) result.add(execution); + const inaccessible = credentialIds.filter((id) => inaccessibleIdSet.has(id)); + if (inaccessible.length > 0) result.set(execution, inaccessible); } return result; } diff --git a/packages/cli/src/modules/redaction/executions/strategies/__tests__/full-item-redaction.strategy.test.ts b/packages/cli/src/modules/redaction/executions/strategies/__tests__/full-item-redaction.strategy.test.ts index 670ff469a64..637829d05dc 100644 --- a/packages/cli/src/modules/redaction/executions/strategies/__tests__/full-item-redaction.strategy.test.ts +++ b/packages/cli/src/modules/redaction/executions/strategies/__tests__/full-item-redaction.strategy.test.ts @@ -12,6 +12,7 @@ const makeContext = (overrides: Partial = {}): RedactionContex userCanReveal: false, enforceDynCredRedaction: false, enforceCredentialUsabilityRedaction: false, + inaccessibleCredentialIds: [], memo: new Map(), ...overrides, }); @@ -235,11 +236,19 @@ describe('FullItemRedactionStrategy', () => { ], }); - await strategy.apply(execution, makeContext({ enforceCredentialUsabilityRedaction: true })); + await strategy.apply( + execution, + makeContext({ + enforceCredentialUsabilityRedaction: true, + inaccessibleCredentialIds: ['alice-gmail'], + }), + ); const item = execution.data.resultData.runData.NodeA[0].data!.main[0]![0]; expect(item.redaction?.reason).toBe('credential_inaccessible'); expect(execution.data.redactionInfo?.reason).toBe('credential_inaccessible'); + // Names the credentials, so the editor can link to where access is granted. + expect(execution.data.redactionInfo?.credentialIds).toEqual(['alice-gmail']); }); it('prefers "dynamic_credentials" over "credential_inaccessible" when both are set', async () => { diff --git a/packages/cli/src/modules/redaction/executions/strategies/__tests__/node-defined-field-redaction.strategy.test.ts b/packages/cli/src/modules/redaction/executions/strategies/__tests__/node-defined-field-redaction.strategy.test.ts index 096a59ad3bf..9cf32df4a46 100644 --- a/packages/cli/src/modules/redaction/executions/strategies/__tests__/node-defined-field-redaction.strategy.test.ts +++ b/packages/cli/src/modules/redaction/executions/strategies/__tests__/node-defined-field-redaction.strategy.test.ts @@ -14,6 +14,7 @@ const makeContext = (overrides: Partial = {}): RedactionContex userCanReveal: false, enforceDynCredRedaction: false, enforceCredentialUsabilityRedaction: false, + inaccessibleCredentialIds: [], memo: new Map(), ...overrides, }); diff --git a/packages/cli/src/modules/redaction/executions/strategies/full-item-redaction.strategy.ts b/packages/cli/src/modules/redaction/executions/strategies/full-item-redaction.strategy.ts index 6f85120a53d..596eac5b304 100644 --- a/packages/cli/src/modules/redaction/executions/strategies/full-item-redaction.strategy.ts +++ b/packages/cli/src/modules/redaction/executions/strategies/full-item-redaction.strategy.ts @@ -76,6 +76,9 @@ export class FullItemRedactionStrategy implements IExecutionRedactionStrategy { isRedacted: true, reason, canReveal: context.userCanReveal, + ...(reason === 'credential_inaccessible' && { + credentialIds: [...context.inaccessibleCredentialIds], + }), }; } diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 5ed9fc065ab..6954c81f88d 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -2092,6 +2092,9 @@ "ndv.redacted.description.sentence2": "Adjust redaction rules in {link}.", "ndv.redacted.description.link": "workflow settings", "ndv.redacted.noPermission.description": "Execution data has been redacted. You do not have the permissions to reveal it.", + "ndv.redacted.shareCredential.description": "This execution used a credential you don't have access to, so its data is hidden.", + "ndv.redacted.shareCredential.link": "Give yourself access to {credential}", + "ndv.redacted.shareCredential.linkNoName": "Give yourself access to the credential", "ndv.redacted.dynamicCredentials.description": "This execution used end-user credentials. Data from end-user credential executions cannot be revealed.", "ndv.redacted.revealButton": "Reveal data", "ndv.redacted.revealModal.title": "Reveal redacted data", @@ -2101,6 +2104,7 @@ "ndv.redacted.revealModal.policy": "Unnecessary access may violate your organization's policy", "ndv.redacted.revealModal.confirmButton": "Reveal data", "ndv.redacted.revealError": "Failed to reveal execution data", + "ndv.redacted.reloadError": "Failed to reload execution data", "ndv.output": "Output", "ndv.output.ai.empty": "👈 Use these logs to see information on how the {node} node completed processing. You can click on a node to see the input it received and data it output.", "ndv.output.ai.waiting": "Waiting for message", diff --git a/packages/frontend/editor-ui/src/Interface.ts b/packages/frontend/editor-ui/src/Interface.ts index 947b3ad4015..e1dbf95d95e 100644 --- a/packages/frontend/editor-ui/src/Interface.ts +++ b/packages/frontend/editor-ui/src/Interface.ts @@ -735,6 +735,8 @@ export interface NewCredentialsModal extends ModalState { hideAskAssistant?: boolean; appendToBody?: boolean; usageScope?: 'project' | 'instance'; + /** The tab the modal opens on, for a surface that sends the user to one part of it. */ + initialTab?: 'connection' | 'sharing' | 'details'; /** Behavior for the Instance AI credential setup-help button, supplied by the * surface that opened the modal (an editor capability, or the credentials list). * Resolves to whether the credential modal should close (false keeps it open for diff --git a/packages/frontend/editor-ui/src/app/stores/ui.store.test.ts b/packages/frontend/editor-ui/src/app/stores/ui.store.test.ts index ca91479ddea..ab2abb5899c 100644 --- a/packages/frontend/editor-ui/src/app/stores/ui.store.test.ts +++ b/packages/frontend/editor-ui/src/app/stores/ui.store.test.ts @@ -347,6 +347,16 @@ describe('UI Store', () => { expect(credentialModalState().workflowId).toBeUndefined(); expect(credentialModalState().contextNode).toBeUndefined(); }); + + it('opens on the requested tab, and forgets it for the next credential', () => { + const uiStore = useUIStore(); + + uiStore.openExistingCredential('cred-1', { initialTab: 'sharing' }); + expect(credentialModalState().initialTab).toBe('sharing'); + + uiStore.openNewCredential('httpBasicAuth'); + expect(credentialModalState().initialTab).toBeUndefined(); + }); }); describe('isModalActiveById', () => { diff --git a/packages/frontend/editor-ui/src/app/stores/ui.store.ts b/packages/frontend/editor-ui/src/app/stores/ui.store.ts index 3a2b1381489..637482dd082 100644 --- a/packages/frontend/editor-ui/src/app/stores/ui.store.ts +++ b/packages/frontend/editor-ui/src/app/stores/ui.store.ts @@ -454,6 +454,7 @@ export const useUIStore = defineStore(STORES.UI, () => { instanceAiCredentialHelp?: NewCredentialsModal['instanceAiCredentialHelp']; workflowId?: string; contextNode?: INodeUi; + initialTab?: NewCredentialsModal['initialTab']; } = {}, ) => { setActiveId(CREDENTIAL_EDIT_MODAL_KEY, id); @@ -473,6 +474,7 @@ export const useUIStore = defineStore(STORES.UI, () => { hideAskAssistant: options.hideAskAssistant, appendToBody: options.appendToBody, instanceAiCredentialHelp: options.instanceAiCredentialHelp, + initialTab: options.initialTab, } as Partial); openModal(CREDENTIAL_EDIT_MODAL_KEY); }; @@ -518,6 +520,7 @@ export const useUIStore = defineStore(STORES.UI, () => { instanceAiCredentialHelp: options.instanceAiCredentialHelp, usageScope: options.usageScope, credentialSetupHint: options.credentialSetupHint, + initialTab: undefined, notice: options.notice, initialName: options.initialName, initialData: options.initialData, diff --git a/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialEdit.test.ts b/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialEdit.test.ts index 863ff79f284..177d06ddaa2 100644 --- a/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialEdit.test.ts +++ b/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialEdit.test.ts @@ -2,6 +2,7 @@ import { usePostHog } from '@/app/stores/posthog.store'; import { createComponentRenderer } from '@/__tests__/render'; import CredentialEdit from './CredentialEdit.vue'; import { createTestingPinia } from '@pinia/testing'; +import { getActivePinia, setActivePinia } from 'pinia'; import { CREDENTIAL_EDIT_MODAL_KEY } from '../../credentials.constants'; import { STORES } from '@n8n/stores'; import { retry, mockedStore } from '@/__tests__/utils'; @@ -485,6 +486,56 @@ describe('CredentialEdit', () => { await retry(() => expect(queryByText('Sharing')).toBeInTheDocument()); }); + test('opens on the tab the modal state asks for', async () => { + // createTestingPinia makes its pinia the active one; the tests after this one + // read stores from the renderer's pinia, so hand it back afterwards. + const rendererPinia = getActivePinia(); + const pinia = createTestingPinia({ + initialState: { + [STORES.UI]: { + modalStateById: { + [CREDENTIAL_EDIT_MODAL_KEY]: { open: true, initialTab: 'sharing' }, + }, + }, + [STORES.SETTINGS]: { + settings: { + enterprise: { sharing: true, externalSecrets: false }, + templates: { host: '' }, + }, + }, + }, + }); + const credentialsStore = mockedStore(useCredentialsStore); + credentialsStore.state.credentialTypes = { + testApi: { name: 'testApi', displayName: 'Test API', properties: [] } as ICredentialType, + }; + credentialsStore.getCredentialData.mockResolvedValueOnce({ + id: '123', + name: 'Test API account', + type: 'testApi', + data: {}, + isManaged: false, + sharedWithProjects: [], + scopes: ['credential:share'], + createdAt: '', + updatedAt: '', + }); + + const { findByTestId } = renderComponent({ + props: { activeId: '123', modalName: CREDENTIAL_EDIT_MODAL_KEY, mode: 'edit' }, + pinia, + global: { + stubs: { CredentialSharing: { template: '
' } }, + }, + }); + + try { + expect(await findByTestId('credential-sharing-tab')).toBeInTheDocument(); + } finally { + if (rendererPinia) setActivePinia(rendererPinia); + } + }); + test('shows Connection and Details for a managed credential', async () => { mockedStore(usePostHog).isFeatureEnabled.mockReturnValue(true); const credentialsStore = useCredentialsStore(); diff --git a/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialEdit.vue b/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialEdit.vue index 6a116dbe948..00fb3d9fd74 100644 --- a/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialEdit.vue +++ b/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialEdit.vue @@ -459,6 +459,7 @@ watch(showAiGatewayErrorNudge, (isVisible) => { onMounted(async () => { loading.value = !!modalOptions.value?.createCredential; + if (modalOptions.value?.initialTab) activeTab.value = modalOptions.value.initialTab; void aiGateway.fetchConfig(); void aiGateway.fetchWallet(); diff --git a/packages/frontend/editor-ui/src/features/execution/executions/composables/useExecutionRedaction.test.ts b/packages/frontend/editor-ui/src/features/execution/executions/composables/useExecutionRedaction.test.ts index 5aa4b177508..7e91b92bd05 100644 --- a/packages/frontend/editor-ui/src/features/execution/executions/composables/useExecutionRedaction.test.ts +++ b/packages/frontend/editor-ui/src/features/execution/executions/composables/useExecutionRedaction.test.ts @@ -7,9 +7,21 @@ import { createExecutionDataId, useExecutionDataStore } from '@/app/stores/execu import type { IExecutionResponse } from '@/features/execution/executions/executions.types'; import { useExecutionRedaction } from './useExecutionRedaction'; import { MODAL_CONFIRM } from '@/app/constants/modals'; +import { useUIStore } from '@/app/stores/ui.store'; +import { useUsersStore } from '@n8n/stores/users.store'; +import { useCredentialsStore } from '@/features/credentials/credentials.store'; +import { CREDENTIAL_EDIT_MODAL_KEY } from '@/features/credentials/credentials.constants'; +import { TELEMETRY_EVENT } from '@n8n/telemetry'; +import { flushPromises } from '@vue/test-utils'; +import { effectScope } from 'vue'; const showError = vi.fn(); const confirm = vi.fn(); +const track = vi.fn(); + +vi.mock('@n8n/composables/useTelemetry', () => ({ + useTelemetry: () => ({ track }), +})); vi.mock('@n8n/composables/useToast', () => ({ useToast: () => ({ showError }), @@ -89,6 +101,198 @@ describe('useExecutionRedaction()', () => { }); }); + describe('credentialToShare', () => { + const redactedForCredential = (credentialIds: string[]) => + ({ + workflowData: { + nodes: [{ credentials: { gmailOAuth2: { id: 'alice-gmail', name: "Alice's Gmail" } } }], + }, + data: { + redactionInfo: { + isRedacted: true, + reason: 'credential_inaccessible', + canReveal: false, + credentialIds, + }, + }, + }) as unknown as IExecutionResponse; + + const viewerWith = (globalScopes: string[]) => { + const usersStore = mockedStore(useUsersStore); + usersStore.currentUser = { globalScopes } as never; + }; + + it('offers the credential to an owner or admin who can share it', () => { + viewerWith(['credential:share']); + setActiveExecution(redactedForCredential(['alice-gmail'])); + + const { credentialToShare } = useExecutionRedaction(); + + expect(credentialToShare.value).toEqual({ id: 'alice-gmail', name: "Alice's Gmail" }); + }); + + it('offers nothing to a viewer who cannot share the credential', () => { + viewerWith([]); + setActiveExecution(redactedForCredential(['alice-gmail'])); + + const { credentialToShare } = useExecutionRedaction(); + + expect(credentialToShare.value).toBeUndefined(); + }); + + it('offers nothing for another redaction reason', () => { + viewerWith(['credential:share']); + setActiveExecution({ + data: { + redactionInfo: { + isRedacted: true, + reason: 'workflow_redaction_policy', + canReveal: false, + }, + }, + } as never); + + const { credentialToShare } = useExecutionRedaction(); + + expect(credentialToShare.value).toBeUndefined(); + }); + + it('opens the credential on its sharing tab', () => { + viewerWith(['credential:share']); + setActiveExecution(redactedForCredential(['alice-gmail'])); + const uiStore = mockedStore(useUIStore); + + useExecutionRedaction().openCredentialSharing(); + + expect(uiStore.openExistingCredential).toHaveBeenCalledWith('alice-gmail', { + initialTab: 'sharing', + }); + }); + + it('tracks the click on the sharing link', () => { + viewerWith(['credential:share']); + setActiveExecution({ ...redactedForCredential(['alice-gmail']), id: 'exec-123' }); + + useExecutionRedaction().openCredentialSharing(); + + expect(track).toHaveBeenCalledWith( + TELEMETRY_EVENT.CREDENTIALS.USER_OPENED_CREDENTIAL_SHARING_FROM_REDACTED_DATA, + { credential_id: 'alice-gmail', workflow_id: '', execution_id: 'exec-123' }, + ); + }); + + describe('after the credential modal closes', () => { + const reloadedData = { resultData: { runData: { Node: [] } } }; + + beforeEach(() => { + viewerWith(['credential:share']); + setActiveExecution({ ...redactedForCredential(['alice-gmail']), id: 'exec-123' }); + workflowsStore.fetchExecutionDataById = vi.fn().mockResolvedValue({ data: reloadedData }); + }); + + it('reloads the execution when the sharing of the credential changed', async () => { + const setExecutionRunDataSpy = vi.spyOn( + useExecutionDataStore(createExecutionDataId('exec-123')), + 'setExecutionRunData', + ); + + useExecutionRedaction().openCredentialSharing(); + await useCredentialsStore().setCredentialSharedWith({ + credentialId: 'alice-gmail', + sharedWithProjects: [], + }); + useUIStore().closeModal(CREDENTIAL_EDIT_MODAL_KEY); + await flushPromises(); + + expect(workflowsStore.fetchExecutionDataById).toHaveBeenCalledWith('exec-123', undefined); + expect(setExecutionRunDataSpy).toHaveBeenCalledWith(reloadedData); + }); + + it('keeps listening while another modal opens over the credential modal', async () => { + useExecutionRedaction().openCredentialSharing(); + // A modal on top makes the credential modal no longer the active one, but it + // is still open, and the user can still change the sharing. + useUIStore().openModal('someOtherModal'); + await flushPromises(); + await useCredentialsStore().setCredentialSharedWith({ + credentialId: 'alice-gmail', + sharedWithProjects: [], + }); + useUIStore().closeModal('someOtherModal'); + useUIStore().closeModal(CREDENTIAL_EDIT_MODAL_KEY); + await flushPromises(); + + expect(workflowsStore.fetchExecutionDataById).toHaveBeenCalledTimes(1); + }); + + it('does not reload when the sharing did not change', async () => { + useExecutionRedaction().openCredentialSharing(); + useUIStore().closeModal(CREDENTIAL_EDIT_MODAL_KEY); + await flushPromises(); + + expect(workflowsStore.fetchExecutionDataById).not.toHaveBeenCalled(); + }); + + it('stops listening when the panel goes away before the modal closes', async () => { + const scope = effectScope(); + const redaction = scope.run(() => useExecutionRedaction()); + redaction?.openCredentialSharing(); + await useCredentialsStore().setCredentialSharedWith({ + credentialId: 'alice-gmail', + sharedWithProjects: [], + }); + + scope.stop(); + useUIStore().closeModal(CREDENTIAL_EDIT_MODAL_KEY); + await flushPromises(); + + expect(workflowsStore.fetchExecutionDataById).not.toHaveBeenCalled(); + }); + + it('shows no error for a reload that fails after the panel went away', async () => { + let rejectReload: (error: Error) => void = () => {}; + workflowsStore.fetchExecutionDataById = vi.fn( + async () => + await new Promise((_resolve, reject) => { + rejectReload = reject; + }), + ); + const scope = effectScope(); + const redaction = scope.run(() => useExecutionRedaction()); + redaction?.openCredentialSharing(); + await useCredentialsStore().setCredentialSharedWith({ + credentialId: 'alice-gmail', + sharedWithProjects: [], + }); + useUIStore().closeModal(CREDENTIAL_EDIT_MODAL_KEY); + await flushPromises(); + + scope.stop(); + rejectReload(new Error('Network error')); + await flushPromises(); + + expect(showError).not.toHaveBeenCalled(); + }); + + it('stops listening once the modal closed', async () => { + useExecutionRedaction().openCredentialSharing(); + useUIStore().closeModal(CREDENTIAL_EDIT_MODAL_KEY); + await flushPromises(); + + await useCredentialsStore().setCredentialSharedWith({ + credentialId: 'alice-gmail', + sharedWithProjects: [], + }); + useUIStore().openModal(CREDENTIAL_EDIT_MODAL_KEY); + await flushPromises(); + useUIStore().closeModal(CREDENTIAL_EDIT_MODAL_KEY); + await flushPromises(); + + expect(workflowsStore.fetchExecutionDataById).not.toHaveBeenCalled(); + }); + }); + }); + describe('revealData', () => { it('should not fetch when user cancels confirmation', async () => { setActiveExecution({ diff --git a/packages/frontend/editor-ui/src/features/execution/executions/composables/useExecutionRedaction.ts b/packages/frontend/editor-ui/src/features/execution/executions/composables/useExecutionRedaction.ts index af2f4ccbddc..9f6bbeaee69 100644 --- a/packages/frontend/editor-ui/src/features/execution/executions/composables/useExecutionRedaction.ts +++ b/packages/frontend/editor-ui/src/features/execution/executions/composables/useExecutionRedaction.ts @@ -1,4 +1,4 @@ -import { computed, h } from 'vue'; +import { computed, getCurrentScope, h, onScopeDispose, watch } from 'vue'; import { useWorkflowsStore } from '@/app/stores/workflows.store'; import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store'; import { injectWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store'; @@ -8,6 +8,13 @@ import { useTelemetry } from '@n8n/composables/useTelemetry'; import { useToast } from '@n8n/composables/useToast'; import { useI18n } from '@n8n/i18n'; import { MODAL_CONFIRM } from '@/app/constants/modals'; +import { useUIStore } from '@/app/stores/ui.store'; +import { useCredentialsStore } from '@/features/credentials/credentials.store'; +import { CREDENTIAL_EDIT_MODAL_KEY } from '@/features/credentials/credentials.constants'; +import { useUsersStore } from '@n8n/stores/users.store'; +import { getResourcePermissions } from '@n8n/permissions'; +import { TELEMETRY_EVENT } from '@n8n/telemetry'; +import type { ExecutionRedactionQueryDto } from '@n8n/api-types'; import RevealDataWarning from '../components/RevealDataWarning.vue'; export function useExecutionRedaction() { @@ -18,6 +25,9 @@ export function useExecutionRedaction() { const telemetry = useTelemetry(); const { showError } = useToast(); const i18n = useI18n(); + const uiStore = useUIStore(); + const usersStore = useUsersStore(); + const credentialsStore = useCredentialsStore(); const redactionInfo = computed( () => workflowExecutionStateStore.value.activeExecution?.data?.redactionInfo, @@ -31,6 +41,107 @@ export function useExecutionRedaction() { () => redactionInfo.value?.reason === 'dynamic_credentials', ); + /** The name a node of the execution stored for the credential. */ + function credentialName(credentialId: string): string { + const nodes = workflowExecutionStateStore.value.activeExecution?.workflowData?.nodes ?? []; + for (const node of nodes) { + for (const credential of Object.values(node.credentials ?? {})) { + if (credential.id === credentialId) return credential.name; + } + } + return credentialsStore.getCredentialById(credentialId)?.name ?? ''; + } + + function canShareCredential(credentialId: string): boolean { + if (getResourcePermissions(usersStore.currentUser?.globalScopes).credential?.share) { + return true; + } + const scopes = credentialsStore.getCredentialById(credentialId)?.scopes; + return getResourcePermissions(scopes).credential?.share === true; + } + + /** + * A credential behind a `credential_inaccessible` redaction that the viewer + * can share, so they can give themselves access and see the data. + */ + const credentialToShare = computed(() => { + if (redactionInfo.value?.reason !== 'credential_inaccessible') return undefined; + + const id = (redactionInfo.value.credentialIds ?? []).find(canShareCredential); + return id ? { id, name: credentialName(id) } : undefined; + }); + + /** Fetches the execution again and writes it to its own data store, keyed by its id. */ + async function reloadExecutionData(executionId: string, query?: ExecutionRedactionQueryDto) { + const execution = await workflowsStore.fetchExecutionDataById(executionId, query); + if (execution?.data) { + useExecutionDataStore(createExecutionDataId(executionId)).setExecutionRunData(execution.data); + } + } + + // The listeners below start from a click handler, outside any component scope, + // so nothing stops them on unmount. Tie them to this composable's scope instead. + let stopPendingReload: (() => void) | undefined; + let disposed = false; + if (getCurrentScope()) { + onScopeDispose(() => { + disposed = true; + stopPendingReload?.(); + }); + } + + /** + * Reloads the execution when the credential modal closes after the sharing of + * the credential changed, so the panel shows the data the user can now see. + */ + function reloadAfterSharingChange(credentialId: string, executionId: string) { + stopPendingReload?.(); + + let sharingChanged = false; + const stopListening = credentialsStore.$onAction(({ name, args, after }) => { + if (name !== 'setCredentialSharedWith' || args[0].credentialId !== credentialId) return; + after(() => { + sharingChanged = true; + }); + }); + // Open, not active: a modal on top makes it inactive while it stays open. + const stopWatching = watch( + () => uiStore.modalsById[CREDENTIAL_EDIT_MODAL_KEY]?.open === true, + (isOpen) => { + if (isOpen) return; + stop(); + if (!sharingChanged) return; + reloadExecutionData(executionId).catch((error: unknown) => { + // The panel this reload was for is gone, so there is nobody to tell. + if (disposed) return; + showError(error, i18n.baseText('ndv.redacted.reloadError')); + }); + }, + ); + + function stop() { + stopListening(); + stopWatching(); + if (stopPendingReload === stop) stopPendingReload = undefined; + } + stopPendingReload = stop; + } + + function openCredentialSharing() { + if (!credentialToShare.value) return; + const credentialId = credentialToShare.value.id; + const executionId = workflowExecutionStateStore.value.activeExecution?.id; + + telemetry.track(TELEMETRY_EVENT.CREDENTIALS.USER_OPENED_CREDENTIAL_SHARING_FROM_REDACTED_DATA, { + credential_id: credentialId, + workflow_id: workflowDocumentStore.value.workflowId, + execution_id: executionId, + }); + + uiStore.openExistingCredential(credentialId, { initialTab: 'sharing' }); + if (executionId) reloadAfterSharingChange(credentialId, executionId); + } + async function revealData() { telemetry.track('User clicked reveal data', { workflow_id: workflowDocumentStore.value.workflowId, @@ -60,21 +171,20 @@ export function useExecutionRedaction() { if (!executionId) return; try { - const revealed = await workflowsStore.fetchExecutionDataById(executionId, { - redactExecutionData: false, - }); - if (revealed?.data) { - // Write to the per-execution data store directly (keyed by the - // revealed execution's id) so the update lands on the execution we - // revealed regardless of which document scope we run under. - useExecutionDataStore(createExecutionDataId(executionId)).setExecutionRunData( - revealed.data, - ); - } + // Keyed by the execution's id, so the update lands on the execution we + // revealed regardless of which document scope we run under. + await reloadExecutionData(executionId, { redactExecutionData: false }); } catch (error) { showError(error, i18n.baseText('ndv.redacted.revealError')); } } - return { isRedacted, canReveal, isDynamicCredentials, revealData }; + return { + isRedacted, + canReveal, + isDynamicCredentials, + revealData, + credentialToShare, + openCredentialSharing, + }; } diff --git a/packages/frontend/editor-ui/src/features/execution/logs/components/LogDetailsPanel.vue b/packages/frontend/editor-ui/src/features/execution/logs/components/LogDetailsPanel.vue index fc0267f1e9d..24a8d56a2d4 100644 --- a/packages/frontend/editor-ui/src/features/execution/logs/components/LogDetailsPanel.vue +++ b/packages/frontend/editor-ui/src/features/execution/logs/components/LogDetailsPanel.vue @@ -74,7 +74,14 @@ const nodeTypeStore = useNodeTypesStore(); const ndvStore = injectNDVStore(); const experimentalNdvStore = useExperimentalNdvStore(); const uiStore = useUIStore(); -const { isRedacted, canReveal, isDynamicCredentials, revealData } = useExecutionRedaction(); +const { + isRedacted, + canReveal, + isDynamicCredentials, + revealData, + credentialToShare, + openCredentialSharing, +} = useExecutionRedaction(); const nodeEntry = computed(() => (isNodeLog(logEntry) ? logEntry : undefined)); const groupEntry = computed(() => (isGroupLog(logEntry) ? logEntry : undefined)); @@ -254,6 +261,8 @@ function handleResizeEnd() { :title="locale.baseText('ndv.output.redacted.title')" :is-dynamic-credentials="isDynamicCredentials" :can-reveal="canReveal" + :credential-to-share="credentialToShare" + @share-credential="openCredentialSharing" wide @open-settings="uiStore.openModal(WORKFLOW_SETTINGS_MODAL_KEY)" @reveal="revealData" @@ -341,6 +350,8 @@ function handleResizeEnd() { :title="locale.baseText('ndv.output.redacted.title')" :is-dynamic-credentials="isDynamicCredentials" :can-reveal="canReveal" + :credential-to-share="credentialToShare" + @share-credential="openCredentialSharing" wide @open-settings="uiStore.openModal(WORKFLOW_SETTINGS_MODAL_KEY)" @reveal="revealData" diff --git a/packages/frontend/editor-ui/src/features/execution/logs/components/LogsViewRunData.vue b/packages/frontend/editor-ui/src/features/execution/logs/components/LogsViewRunData.vue index 5c8458308f9..165916eea39 100644 --- a/packages/frontend/editor-ui/src/features/execution/logs/components/LogsViewRunData.vue +++ b/packages/frontend/editor-ui/src/features/execution/logs/components/LogsViewRunData.vue @@ -47,7 +47,8 @@ const emit = defineEmits<{ const locale = useI18n(); const ndvStore = injectNDVStore(); const uiStore = useUIStore(); -const { canReveal, isDynamicCredentials, revealData } = useExecutionRedaction(); +const { canReveal, isDynamicCredentials, revealData, credentialToShare, openCredentialSharing } = + useExecutionRedaction(); const popOutWindow = inject(PopOutWindowKey, ref()); @@ -157,6 +158,8 @@ function handleChangeDisplayMode(value: IRunDataDisplayMode) { " :is-dynamic-credentials="isDynamicCredentials" :can-reveal="canReveal" + :credential-to-share="credentialToShare" + @share-credential="openCredentialSharing" @open-settings="uiStore.openModal(WORKFLOW_SETTINGS_MODAL_KEY)" @reveal="revealData" /> diff --git a/packages/frontend/editor-ui/src/features/ndv/panel/components/InputPanel.vue b/packages/frontend/editor-ui/src/features/ndv/panel/components/InputPanel.vue index fc9cd6bd851..7548dbb2bd4 100644 --- a/packages/frontend/editor-ui/src/features/ndv/panel/components/InputPanel.vue +++ b/packages/frontend/editor-ui/src/features/ndv/panel/components/InputPanel.vue @@ -108,7 +108,8 @@ const workflowExecutionStateStore = injectWorkflowExecutionStateStore(); const workflowExecution = computed(() => workflowExecutionStateStore.value.activeExecution); const router = useRouter(); const { runWorkflow } = useRunWorkflow({ router }); -const { canReveal, isDynamicCredentials, revealData } = useExecutionRedaction(); +const { canReveal, isDynamicCredentials, revealData, credentialToShare, openCredentialSharing } = + useExecutionRedaction(); const uiStore = useUIStore(); const workflowObject = computed(() => @@ -610,6 +611,8 @@ function handleChangeCollapsingColumn(columnName: string | null) { :title="i18n.baseText('ndv.input.redacted.title')" :is-dynamic-credentials="isDynamicCredentials" :can-reveal="canReveal" + :credential-to-share="credentialToShare" + @share-credential="openCredentialSharing" @open-settings="openWorkflowSettings" @reveal="revealData" /> @@ -620,6 +623,8 @@ function handleChangeCollapsingColumn(columnName: string | null) { :title="i18n.baseText('ndv.input.redacted.title')" :is-dynamic-credentials="isDynamicCredentials" :can-reveal="canReveal" + :credential-to-share="credentialToShare" + @share-credential="openCredentialSharing" @open-settings="openWorkflowSettings" @reveal="revealData" /> diff --git a/packages/frontend/editor-ui/src/features/ndv/panel/components/OutputPanel.vue b/packages/frontend/editor-ui/src/features/ndv/panel/components/OutputPanel.vue index ceaa65b9cc0..51dd878fe31 100644 --- a/packages/frontend/editor-ui/src/features/ndv/panel/components/OutputPanel.vue +++ b/packages/frontend/editor-ui/src/features/ndv/panel/components/OutputPanel.vue @@ -118,7 +118,8 @@ const node = computed(() => { const { hasNodeRun, workflowExecution, workflowRunData, nodeRunData } = useExecutionData({ node }); -const { canReveal, isDynamicCredentials, revealData } = useExecutionRedaction(); +const { canReveal, isDynamicCredentials, revealData, credentialToShare, openCredentialSharing } = + useExecutionRedaction(); const isTriggerNode = computed(() => { return !!node.value && nodeTypesStore.isTriggerNode(node.value.type); @@ -465,6 +466,8 @@ function handleChangeCollapsingColumn(columnName: string | null) { :title="i18n.baseText('ndv.output.redacted.title')" :is-dynamic-credentials="isDynamicCredentials" :can-reveal="canReveal" + :credential-to-share="credentialToShare" + @share-credential="openCredentialSharing" @open-settings="openWorkflowSettings" @reveal="revealData" /> @@ -475,6 +478,8 @@ function handleChangeCollapsingColumn(columnName: string | null) { :title="i18n.baseText('ndv.output.redacted.title')" :is-dynamic-credentials="isDynamicCredentials" :can-reveal="canReveal" + :credential-to-share="credentialToShare" + @share-credential="openCredentialSharing" @open-settings="openWorkflowSettings" @reveal="revealData" /> diff --git a/packages/frontend/editor-ui/src/features/ndv/panel/components/RedactedDataState.test.ts b/packages/frontend/editor-ui/src/features/ndv/panel/components/RedactedDataState.test.ts index 9067fbda664..67565fda960 100644 --- a/packages/frontend/editor-ui/src/features/ndv/panel/components/RedactedDataState.test.ts +++ b/packages/frontend/editor-ui/src/features/ndv/panel/components/RedactedDataState.test.ts @@ -50,6 +50,39 @@ describe('RedactedDataState', () => { expect(getByText('workflow settings')).toBeInTheDocument(); }); + describe('a credential the viewer can share with themselves', () => { + const props = { + title: 'Redacted', + isDynamicCredentials: false, + canReveal: false, + credentialToShare: { id: 'alice-gmail', name: "Alice's Gmail" }, + }; + + it('says why the data is hidden and links to the credential sharing', async () => { + const { getByText, queryByText, getByTestId, emitted } = renderComponent({ props }); + + expect( + getByText("This execution used a credential you don't have access to", { exact: false }), + ).toBeInTheDocument(); + expect( + queryByText('You do not have the permissions to reveal it', { exact: false }), + ).not.toBeInTheDocument(); + const link = getByTestId('redacted-data-share-credential'); + expect(link).toHaveTextContent("Give yourself access to Alice's Gmail"); + + link.click(); + expect(emitted().shareCredential).toHaveLength(1); + }); + + it('shows no link without a credential to share', () => { + const { queryByTestId } = renderComponent({ + props: { ...props, credentialToShare: undefined }, + }); + + expect(queryByTestId('redacted-data-share-credential')).not.toBeInTheDocument(); + }); + }); + it('should show reveal button when canReveal is true', () => { const { getByTestId } = renderComponent({ props: { title: 'Redacted', isDynamicCredentials: false, canReveal: true }, diff --git a/packages/frontend/editor-ui/src/features/ndv/panel/components/RedactedDataState.vue b/packages/frontend/editor-ui/src/features/ndv/panel/components/RedactedDataState.vue index 319fb0af7e9..940cbac5c3d 100644 --- a/packages/frontend/editor-ui/src/features/ndv/panel/components/RedactedDataState.vue +++ b/packages/frontend/editor-ui/src/features/ndv/panel/components/RedactedDataState.vue @@ -9,11 +9,14 @@ defineProps<{ title: string; isDynamicCredentials: boolean; canReveal: boolean; + /** A credential the viewer cannot use but can share with themselves to see the data. */ + credentialToShare?: { id: string; name: string }; }>(); const emit = defineEmits<{ openSettings: []; reveal: []; + shareCredential: []; }>(); const i18n = useI18n(); @@ -37,6 +40,22 @@ const i18n = useI18n(); + diff --git a/packages/workflow/src/run-execution-data/run-execution-data.v1.ts b/packages/workflow/src/run-execution-data/run-execution-data.v1.ts index f80fb307fb4..39118954ab9 100644 --- a/packages/workflow/src/run-execution-data/run-execution-data.v1.ts +++ b/packages/workflow/src/run-execution-data/run-execution-data.v1.ts @@ -22,6 +22,11 @@ export interface RedactionInfo { isRedacted: boolean; reason: string; canReveal: boolean; + /** + * With the `credential_inaccessible` reason: the credentials the viewer cannot + * use, so the editor can point to where access is granted. + */ + credentialIds?: string[]; /** * True when the info is synthesized from live push markers before the * authoritative executionFinished metadata arrives. `canReveal` is a