feat(editor): Link to the credential sharing tab when execution data is redacted (#40976)

This commit is contained in:
Yuliia Pominchuk 2026-10-14 07:48:34 +00:00 committed by GitHub
parent d83f95b377
commit da3491fc87
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 536 additions and 25 deletions

View File

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

View File

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

View File

@ -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. */

View File

@ -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<Set<RedactableExecution>> {
if (!isCredSharingEnabled()) return new Set();
): Promise<Map<RedactableExecution, string[]>> {
if (!isCredSharingEnabled()) return new Map();
const credentialIdsByExecution = new Map<RedactableExecution, string[]>();
const allCredentialIds = new Set<string>();
@ -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<RedactableExecution>();
const result = new Map<RedactableExecution, string[]>();
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;
}

View File

@ -12,6 +12,7 @@ const makeContext = (overrides: Partial<RedactionContext> = {}): 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 () => {

View File

@ -14,6 +14,7 @@ const makeContext = (overrides: Partial<RedactionContext> = {}): RedactionContex
userCanReveal: false,
enforceDynCredRedaction: false,
enforceCredentialUsabilityRedaction: false,
inaccessibleCredentialIds: [],
memo: new Map(),
...overrides,
});

View File

@ -76,6 +76,9 @@ export class FullItemRedactionStrategy implements IExecutionRedactionStrategy {
isRedacted: true,
reason,
canReveal: context.userCanReveal,
...(reason === 'credential_inaccessible' && {
credentialIds: [...context.inaccessibleCredentialIds],
}),
};
}

View File

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

View File

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

View File

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

View File

@ -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<NewCredentialsModal>);
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,

View File

@ -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: '<div data-test-id="credential-sharing-tab" />' } },
},
});
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();

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Window | undefined>());
@ -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"
/>

View File

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

View File

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

View File

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

View File

@ -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();
</template>
</I18nT>
</template>
<template v-else-if="credentialToShare">
{{ i18n.baseText('ndv.redacted.shareCredential.description') }}<br />
<N8nLink
size="medium"
data-test-id="redacted-data-share-credential"
@click="emit('shareCredential')"
>
{{
credentialToShare.name
? i18n.baseText('ndv.redacted.shareCredential.link', {
interpolate: { credential: credentialToShare.name },
})
: i18n.baseText('ndv.redacted.shareCredential.linkNoName')
}}
</N8nLink>
</template>
<template v-else>
{{ i18n.baseText('ndv.redacted.noPermission.description') }}
</template>

View File

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