mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat(editor): Link to the credential sharing tab when execution data is redacted (#40976)
This commit is contained in:
parent
d83f95b377
commit
da3491fc87
|
|
@ -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(),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ const makeContext = (overrides: Partial<RedactionContext> = {}): RedactionContex
|
|||
userCanReveal: false,
|
||||
enforceDynCredRedaction: false,
|
||||
enforceCredentialUsabilityRedaction: false,
|
||||
inaccessibleCredentialIds: [],
|
||||
memo: new Map(),
|
||||
...overrides,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -76,6 +76,9 @@ export class FullItemRedactionStrategy implements IExecutionRedactionStrategy {
|
|||
isRedacted: true,
|
||||
reason,
|
||||
canReveal: context.userCanReveal,
|
||||
...(reason === 'credential_inaccessible' && {
|
||||
credentialIds: [...context.inaccessibleCredentialIds],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user