@n8n/frontend-module-type-availability-policies 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,7 +7,7 @@ The descriptor `id` must match the backend module id
7
7
  (`packages/cli/src/modules/type-availability-policies`). Both `settings.activeModules` and
8
8
  `/rest/module-settings` are keyed by that string, and a mismatch fails silently.
9
9
 
10
- The module is license-gated on `feat:nodeTypePolicies` and is not a default module. Enable it
10
+ The module is license-gated on `feat:typeAvailabilityPolicies` and is not a default module. Enable it
11
11
  in a dev instance with `N8N_ENABLED_MODULES=type-availability-policies`.
12
12
 
13
13
  `useTypeAvailabilityPoliciesStore` answers whether a project may use a node type. The store is
@@ -27,10 +27,18 @@ and on a cold tree turbo builds them first.
27
27
  for one node type. `isNodeTypeRestricted(type)` is the plain predicate for a list of nodes. The
28
28
  shell reads restriction through these and never through the store directly.
29
29
 
30
- `ContactInstanceAdminModal` lists the instance owners with a mail link. `RestrictedNodePopover` is the
31
- explanation for a row in a list: the lock trigger, the hover-opened popover and the dialog. The caller
32
- passes the row element and whether the row is keyboard-active. The shell renders both where a
33
- restricted node appears; the module owns their copy and behaviour.
30
+ `ContactInstanceAdminModal` lists the instance owners with a mail link. The surfaces below render it
31
+ as a sibling of their own content, so a popover that closes never unmounts the open dialog.
32
+ `RestrictedNodePopover` is the explanation for a row in a list: the lock trigger, the hover-opened
33
+ popover and the dialog. The caller passes the row element and whether the row is keyboard-active.
34
+ `RestrictedNodePanel` is the builder-facing explanation of a blocked node in the NDV, with the
35
+ contact and replace actions. The shell renders the popover and the panel where a restricted node
36
+ appears; the module owns their copy and behaviour.
37
+
38
+ `getPolicyViolations(error)` reads the structured violations off a refused save, publish or run,
39
+ and `PolicyViolationList` groups them by scope and lists each blocked node or credential type,
40
+ with an optional jump. The shell resolves display names and the nodes to jump to, because only
41
+ it holds the open workflow.
34
42
 
35
43
  `packages/@n8n/module-cli/frontend-module-guide.md` holds the descriptor contract, the
36
44
  registration points, the import boundaries and the setup a module needs to render UI.
package/package.json CHANGED
@@ -1,18 +1,19 @@
1
1
  {
2
2
  "name": "@n8n/frontend-module-type-availability-policies",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "exports": {
7
7
  ".": "./src/index.ts"
8
8
  },
9
9
  "dependencies": {
10
- "@n8n/api-types": "1.41.0",
11
- "@n8n/design-system": "2.40.0",
12
- "@n8n/frontend-module-sdk": "0.11.0",
13
- "@n8n/i18n": "2.41.0",
14
- "@n8n/rest-api-client": "2.41.0",
15
- "@n8n/stores": "2.41.0",
10
+ "@n8n/api-types": "1.42.0",
11
+ "@n8n/design-system": "2.41.0",
12
+ "@n8n/frontend-module-sdk": "0.12.0",
13
+ "@n8n/i18n": "2.42.0",
14
+ "@n8n/rest-api-client": "2.42.0",
15
+ "@n8n/stores": "2.42.0",
16
+ "@n8n/utils": "1.49.0",
16
17
  "@vueuse/core": "^14.3.0",
17
18
  "pinia": "^2.2.4",
18
19
  "vue": "^3.5.13"
@@ -24,7 +25,7 @@
24
25
  "@n8n/frontend-vite-config": "0.1.0",
25
26
  "@n8n/playwright-janitor": "0.1.0",
26
27
  "@n8n/typescript-config": "1.12.0",
27
- "@n8n/vitest-config": "1.22.0",
28
+ "@n8n/vitest-config": "1.23.0",
28
29
  "@pinia/testing": "^0.1.6",
29
30
  "@testing-library/jest-dom": "^6.6.3",
30
31
  "@testing-library/user-event": "^14.6.1",
@@ -35,7 +36,7 @@
35
36
  "unplugin-icons": "^23.0.1",
36
37
  "vite": "^8.0.2",
37
38
  "vite-svg-loader": "5.1.0",
38
- "vitest": "^4.1.9",
39
+ "vitest": "5.0.1",
39
40
  "vue-tsc": "^2.2.8"
40
41
  },
41
42
  "license": "SEE LICENSE IN LICENSE.md",
@@ -33,7 +33,7 @@ const owner = {
33
33
  const member = { ...owner, id: 'member-1', fullName: 'Bob Builder', role: ROLE.Member } as IUser;
34
34
 
35
35
  const renderComponent = createComponentRenderer(ContactInstanceAdminModal, {
36
- props: { open: true, description: 'Ask for the Slack node.', mailSubject: 'Access request' },
36
+ props: { open: true, nodeTypeName: 'Slack' },
37
37
  });
38
38
 
39
39
  function setup(allUsers: IUser[], fetchUsers = vi.fn().mockResolvedValue(undefined)) {
@@ -45,7 +45,7 @@ function setup(allUsers: IUser[], fetchUsers = vi.fn().mockResolvedValue(undefin
45
45
  }
46
46
 
47
47
  describe('ContactInstanceAdminModal', () => {
48
- it('lists the owners with a mailto link and shows the description', async () => {
48
+ it('lists the owners with a mailto link and builds the copy from the node type name', async () => {
49
49
  const usersStore = setup([owner, member]);
50
50
  const { findByTestId, getByText } = renderComponent();
51
51
 
@@ -56,9 +56,9 @@ describe('ContactInstanceAdminModal', () => {
56
56
  expect(list).not.toHaveTextContent('Bob Builder');
57
57
  expect(getByText('ada@example.com').closest('a')).toHaveAttribute(
58
58
  'href',
59
- 'mailto:ada@example.com?subject=Access%20request',
59
+ 'mailto:ada@example.com?subject=Access%20request%20for%20the%20Slack%20node',
60
60
  );
61
- expect(getByText('Ask for the Slack node.')).toBeInTheDocument();
61
+ expect(getByText(/request access to 'Slack'/)).toBeInTheDocument();
62
62
  });
63
63
 
64
64
  it('keeps loading until the latest owner lookup resolves after a reopen', async () => {
@@ -5,9 +5,8 @@ import { useI18n } from '@n8n/i18n';
5
5
  import { useUsersStore } from '@n8n/stores/users.store';
6
6
  import { computed, ref, watch } from 'vue';
7
7
 
8
- const props = defineProps<{
9
- description: string;
10
- mailSubject?: string;
8
+ const { nodeTypeName } = defineProps<{
9
+ nodeTypeName: string;
11
10
  }>();
12
11
 
13
12
  const open = defineModel<boolean>('open', { default: false });
@@ -18,6 +17,17 @@ const usersStore = useUsersStore();
18
17
  const isLoading = ref(false);
19
18
  let lookupId = 0;
20
19
 
20
+ const description = computed(() =>
21
+ i18n.baseText('typeAvailabilityPolicies.contactAdmin.description', {
22
+ interpolate: { nodeType: nodeTypeName },
23
+ }),
24
+ );
25
+ const mailSubject = computed(() =>
26
+ i18n.baseText('typeAvailabilityPolicies.contactAdmin.mailSubject', {
27
+ interpolate: { nodeType: nodeTypeName },
28
+ }),
29
+ );
30
+
21
31
  const owners = computed(() =>
22
32
  usersStore.allUsers.filter(
23
33
  (user) => user.role === ROLE.Owner && !user.isPendingUser && Boolean(user.email),
@@ -39,8 +49,7 @@ watch(
39
49
  );
40
50
 
41
51
  function mailtoHref(email: string): string {
42
- const subject = props.mailSubject ? `?subject=${encodeURIComponent(props.mailSubject)}` : '';
43
- return `mailto:${email}${subject}`;
52
+ return `mailto:${email}?subject=${encodeURIComponent(mailSubject.value)}`;
44
53
  }
45
54
  </script>
46
55
 
@@ -0,0 +1,68 @@
1
+ import { createComponentRenderer } from '@n8n/frontend-test-utils';
2
+ import { createTestingPinia } from '@pinia/testing';
3
+ import { fireEvent } from '@testing-library/vue';
4
+ import { setActivePinia } from 'pinia';
5
+ import { describe, it, expect, beforeEach } from 'vitest';
6
+
7
+ import RestrictedNodePanel from './RestrictedNodePanel.vue';
8
+
9
+ const renderComponent = createComponentRenderer(RestrictedNodePanel, {
10
+ props: { nodeTypeName: 'Slack' },
11
+ global: {
12
+ stubs: {
13
+ ContactInstanceAdminModal: {
14
+ props: ['open', 'nodeTypeName'],
15
+ template:
16
+ '<div v-if="open" data-test-id="contact-instance-admin-modal">{{ nodeTypeName }}</div>',
17
+ },
18
+ },
19
+ },
20
+ });
21
+
22
+ describe('RestrictedNodePanel', () => {
23
+ beforeEach(() => {
24
+ setActivePinia(createTestingPinia());
25
+ });
26
+
27
+ it('names the node type and the blocking scope', () => {
28
+ const { getByTestId } = renderComponent({ props: { scope: 'instance' } });
29
+
30
+ expect(getByTestId('node-restricted-panel')).toHaveTextContent(
31
+ "An administrator blocked 'Slack' on this instance. Contact an instance admin or replace the node to continue.",
32
+ );
33
+ });
34
+
35
+ it('renders a missing scope generically', () => {
36
+ const { getByTestId } = renderComponent();
37
+
38
+ expect(getByTestId('node-restricted-panel')).toHaveTextContent(
39
+ "An administrator blocked 'Slack'. Contact an instance admin or replace the node to continue.",
40
+ );
41
+ });
42
+
43
+ it('hides the replace action and drops it from the copy', () => {
44
+ const { getByTestId, queryByTestId } = renderComponent({ props: { showReplace: false } });
45
+
46
+ expect(queryByTestId('node-restricted-replace')).not.toBeInTheDocument();
47
+ expect(getByTestId('node-restricted-panel')).toHaveTextContent(
48
+ "An administrator blocked 'Slack'. Contact an instance admin to continue.",
49
+ );
50
+ });
51
+
52
+ it('opens the contact-admin dialog for this node type', async () => {
53
+ const { getByTestId, queryByTestId } = renderComponent();
54
+
55
+ expect(queryByTestId('contact-instance-admin-modal')).not.toBeInTheDocument();
56
+ await fireEvent.click(getByTestId('node-restricted-contact-admin'));
57
+
58
+ expect(getByTestId('contact-instance-admin-modal')).toHaveTextContent('Slack');
59
+ });
60
+
61
+ it('emits replaceNode from the replace action', async () => {
62
+ const { getByTestId, emitted } = renderComponent();
63
+
64
+ await fireEvent.click(getByTestId('node-restricted-replace'));
65
+
66
+ expect(emitted('replaceNode')).toHaveLength(1);
67
+ });
68
+ });
@@ -0,0 +1,106 @@
1
+ <script setup lang="ts">
2
+ import type { NodeTypeAvailabilityScope } from '@n8n/api-types';
3
+ import { N8nButton, N8nIcon, N8nText } from '@n8n/design-system';
4
+ import { useI18n, type BaseTextKey } from '@n8n/i18n';
5
+ import { computed, ref } from 'vue';
6
+
7
+ import ContactInstanceAdminModal from './ContactInstanceAdminModal.vue';
8
+
9
+ const {
10
+ nodeTypeName,
11
+ scope,
12
+ showReplace = true,
13
+ } = defineProps<{
14
+ nodeTypeName: string;
15
+ scope?: NodeTypeAvailabilityScope;
16
+ showReplace?: boolean;
17
+ }>();
18
+
19
+ const emit = defineEmits<{ replaceNode: [] }>();
20
+
21
+ const DESCRIPTION_KEY: Record<NodeTypeAvailabilityScope, BaseTextKey> = {
22
+ instance: 'typeAvailabilityPolicies.restrictedNode.description.instance',
23
+ project: 'typeAvailabilityPolicies.restrictedNode.description.project',
24
+ };
25
+
26
+ const i18n = useI18n();
27
+
28
+ const isContactAdminOpen = ref(false);
29
+
30
+ // The next step is interpolated into the description, so each locale controls the sentence order.
31
+ const description = computed(() => {
32
+ const nextStep = i18n.baseText(
33
+ showReplace
34
+ ? 'typeAvailabilityPolicies.restrictedNode.nextStep.replace'
35
+ : 'typeAvailabilityPolicies.restrictedNode.nextStep.contact',
36
+ );
37
+ return i18n.baseText(
38
+ scope ? DESCRIPTION_KEY[scope] : 'typeAvailabilityPolicies.restrictedNode.description.generic',
39
+ { interpolate: { nodeType: nodeTypeName, nextStep } },
40
+ );
41
+ });
42
+ </script>
43
+
44
+ <template>
45
+ <div :class="$style.panel" data-test-id="node-restricted-panel">
46
+ <N8nIcon icon="lock" :class="$style.icon" />
47
+ <N8nText size="large" color="text-dark" bold :class="$style.title">
48
+ {{ i18n.baseText('typeAvailabilityPolicies.restrictedNode.title') }}
49
+ </N8nText>
50
+ <N8nText :class="$style.description">{{ description }}</N8nText>
51
+ <div :class="$style.actions">
52
+ <N8nButton
53
+ variant="solid"
54
+ size="small"
55
+ icon="mail"
56
+ data-test-id="node-restricted-contact-admin"
57
+ @click="isContactAdminOpen = true"
58
+ >
59
+ {{ i18n.baseText('typeAvailabilityPolicies.restrictedNode.contactAdmin') }}
60
+ </N8nButton>
61
+ <N8nButton
62
+ v-if="showReplace"
63
+ variant="subtle"
64
+ size="small"
65
+ icon="arrow-left-right"
66
+ data-test-id="node-restricted-replace"
67
+ @click="emit('replaceNode')"
68
+ >
69
+ {{ i18n.baseText('typeAvailabilityPolicies.restrictedNode.replaceNode') }}
70
+ </N8nButton>
71
+ </div>
72
+ <ContactInstanceAdminModal v-model:open="isContactAdminOpen" :node-type-name="nodeTypeName" />
73
+ </div>
74
+ </template>
75
+
76
+ <style lang="scss" module>
77
+ .panel {
78
+ height: 75%;
79
+ display: flex;
80
+ flex-direction: column;
81
+ align-items: center;
82
+ justify-content: center;
83
+ padding: var(--spacing--2xs);
84
+ text-align: center;
85
+ line-height: var(--line-height--md);
86
+ }
87
+
88
+ .icon {
89
+ color: var(--color--text--tint-2);
90
+ font-size: var(--font-size--2xl);
91
+ }
92
+
93
+ .title {
94
+ margin-top: var(--spacing--sm);
95
+ margin-bottom: var(--spacing--xs);
96
+ }
97
+
98
+ .description {
99
+ margin-bottom: var(--spacing--2xs);
100
+ }
101
+
102
+ .actions {
103
+ display: flex;
104
+ gap: var(--spacing--2xs);
105
+ }
106
+ </style>
@@ -1,7 +1,7 @@
1
1
  import { createComponentRenderer } from '@n8n/frontend-test-utils';
2
- import { screen } from '@testing-library/vue';
2
+ import { screen, waitFor } from '@testing-library/vue';
3
3
  import userEvent from '@testing-library/user-event';
4
- import { describe, it, expect } from 'vitest';
4
+ import { afterEach, beforeEach, describe, it, expect } from 'vitest';
5
5
 
6
6
  import RestrictedNodePopover from './RestrictedNodePopover.vue';
7
7
 
@@ -13,9 +13,9 @@ const renderComponent = createComponentRenderer(RestrictedNodePopover, {
13
13
  global: {
14
14
  stubs: {
15
15
  ContactInstanceAdminModal: {
16
- props: ['open', 'description'],
16
+ props: ['open', 'nodeTypeName'],
17
17
  template:
18
- '<div v-if="open" data-test-id="contact-instance-admin-modal">{{ description }}</div>',
18
+ '<div v-if="open" data-test-id="contact-instance-admin-modal">{{ nodeTypeName }}</div>',
19
19
  },
20
20
  },
21
21
  },
@@ -24,6 +24,18 @@ const renderComponent = createComponentRenderer(RestrictedNodePopover, {
24
24
  const renderPopover = (props: Record<string, unknown>) => renderComponent({ props });
25
25
 
26
26
  describe('RestrictedNodePopover', () => {
27
+ /** The list row the popover anchors to. It lives outside the rendered component. */
28
+ let anchor: HTMLDivElement;
29
+
30
+ beforeEach(() => {
31
+ anchor = document.createElement('div');
32
+ document.body.appendChild(anchor);
33
+ });
34
+
35
+ afterEach(() => {
36
+ anchor.remove();
37
+ });
38
+
27
39
  it('renders only the lock while the row is neither hovered nor active', () => {
28
40
  renderPopover({ active: false });
29
41
 
@@ -53,8 +65,6 @@ describe('RestrictedNodePopover', () => {
53
65
  });
54
66
 
55
67
  it('opens when the anchor row is hovered and stays open while the pointer is on the popover', async () => {
56
- const anchor = document.createElement('div');
57
- document.body.appendChild(anchor);
58
68
  renderPopover({ anchor });
59
69
 
60
70
  await userEvent.hover(anchor);
@@ -63,7 +73,6 @@ describe('RestrictedNodePopover', () => {
63
73
  await userEvent.unhover(anchor);
64
74
 
65
75
  expect(screen.getByTestId('node-restricted-popover')).toBeInTheDocument();
66
- anchor.remove();
67
76
  });
68
77
 
69
78
  it('opens the contact-admin dialog for this node type', async () => {
@@ -74,4 +83,19 @@ describe('RestrictedNodePopover', () => {
74
83
 
75
84
  expect(screen.getByTestId('contact-instance-admin-modal')).toHaveTextContent('Gmail');
76
85
  });
86
+
87
+ it('keeps the dialog open after the pointer leaves the row and the popover', async () => {
88
+ renderPopover({ anchor });
89
+
90
+ await userEvent.hover(anchor);
91
+ const popover = await screen.findByTestId('node-restricted-popover');
92
+ await userEvent.click(screen.getByTestId('node-restricted-contact-admin'));
93
+ await userEvent.unhover(popover);
94
+ await userEvent.unhover(anchor);
95
+ await waitFor(() =>
96
+ expect(screen.queryByTestId('node-restricted-popover')).not.toBeInTheDocument(),
97
+ );
98
+
99
+ expect(screen.getByTestId('contact-instance-admin-modal')).toHaveTextContent('Gmail');
100
+ });
77
101
  });
@@ -5,6 +5,7 @@ import { useI18n, type BaseTextKey } from '@n8n/i18n';
5
5
  import { unrefElement, useElementHover, type MaybeElement } from '@vueuse/core';
6
6
  import { computed, ref } from 'vue';
7
7
 
8
+ import { SCOPE_LABEL_KEY } from '../type-availability-policies.constants';
8
9
  import ContactInstanceAdminModal from './ContactInstanceAdminModal.vue';
9
10
 
10
11
  const props = defineProps<{
@@ -19,11 +20,6 @@ const props = defineProps<{
19
20
  /** Leaving waits this long before closing, so the pointer can cross the gap to the popover. */
20
21
  const HOVER_GRACE_MS = 200;
21
22
 
22
- const SCOPE_TITLE_KEY: Record<NodeTypeAvailabilityScope, BaseTextKey> = {
23
- instance: 'typeAvailabilityPolicies.restrictedNode.scope.instance',
24
- project: 'typeAvailabilityPolicies.restrictedNode.scope.project',
25
- };
26
-
27
23
  const i18n = useI18n();
28
24
 
29
25
  const anchorElement = computed(() => unrefElement(props.anchor) ?? undefined);
@@ -36,11 +32,9 @@ const isContactAdminOpen = ref(false);
36
32
 
37
33
  const scopeKey = computed<BaseTextKey>(
38
34
  () =>
39
- (props.scope && SCOPE_TITLE_KEY[props.scope]) ??
35
+ (props.scope && SCOPE_LABEL_KEY[props.scope]) ??
40
36
  'typeAvailabilityPolicies.restrictedNode.title',
41
37
  );
42
-
43
- const interpolate = computed(() => ({ nodeType: props.nodeTypeName }));
44
38
  </script>
45
39
 
46
40
  <template>
@@ -83,19 +77,8 @@ const interpolate = computed(() => ({ nodeType: props.nodeTypeName }));
83
77
  </div>
84
78
  </template>
85
79
  </N8nPopover>
86
- <ContactInstanceAdminModal
87
- v-model:open="isContactAdminOpen"
88
- :description="
89
- i18n.baseText('typeAvailabilityPolicies.restrictedNode.contactAdmin.description', {
90
- interpolate,
91
- })
92
- "
93
- :mail-subject="
94
- i18n.baseText('typeAvailabilityPolicies.restrictedNode.contactAdmin.mailSubject', {
95
- interpolate,
96
- })
97
- "
98
- />
80
+ <!-- A sibling of the popover: its content unmounts on close and must not take the dialog with it. -->
81
+ <ContactInstanceAdminModal v-model:open="isContactAdminOpen" :node-type-name="nodeTypeName" />
99
82
  </span>
100
83
  </template>
101
84
 
package/src/index.ts CHANGED
@@ -5,5 +5,7 @@ export {
5
5
  isNodeTypeRestricted,
6
6
  useNodeTypeRestriction,
7
7
  } from './composables/useNodeTypeRestriction';
8
- export { default as ContactInstanceAdminModal } from './components/ContactInstanceAdminModal.vue';
9
8
  export { default as RestrictedNodePopover } from './components/RestrictedNodePopover.vue';
9
+ export { default as RestrictedNodePanel } from './components/RestrictedNodePanel.vue';
10
+ export { getPolicyViolations } from './policy-violations/policyViolations';
11
+ export { default as PolicyViolationList } from './policy-violations/PolicyViolationList.vue';
@@ -0,0 +1,124 @@
1
+ import type { PolicyViolation } from '@n8n/api-types';
2
+ import { createComponentRenderer } from '@n8n/frontend-test-utils';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { within } from '@testing-library/vue';
5
+ import { describe, expect, it } from 'vitest';
6
+
7
+ import PolicyViolationList from './PolicyViolationList.vue';
8
+
9
+ const SLACK_NODE_TYPE = 'n8n-nodes-base.slack';
10
+ const TELEGRAM_NODE_TYPE = 'n8n-nodes-base.telegram';
11
+ const GITHUB_CREDENTIAL_TYPE = 'githubApi';
12
+
13
+ function nodeTypeViolation(subject: string, scope: string): PolicyViolation {
14
+ return {
15
+ kind: 'node-type-unavailable',
16
+ checkId: 'node-type-availability',
17
+ message: `Node type "${subject}" is blocked`,
18
+ subject,
19
+ subjectType: 'nodeType',
20
+ scope,
21
+ };
22
+ }
23
+
24
+ const slackOnInstance = nodeTypeViolation(SLACK_NODE_TYPE, 'instance');
25
+ const telegramInProject = nodeTypeViolation(TELEGRAM_NODE_TYPE, 'project');
26
+
27
+ const githubCredentialOnInstance: PolicyViolation = {
28
+ kind: 'credential-type-unavailable',
29
+ checkId: 'credential-type-availability',
30
+ message: `Credential type "${GITHUB_CREDENTIAL_TYPE}" is blocked by an instance policy`,
31
+ subject: GITHUB_CREDENTIAL_TYPE,
32
+ subjectType: 'credentialType',
33
+ scope: 'instance',
34
+ };
35
+
36
+ const renderComponent = createComponentRenderer(PolicyViolationList, {
37
+ props: { violations: [slackOnInstance] },
38
+ });
39
+
40
+ function groupTexts(groups: HTMLElement[]) {
41
+ return groups.map((group) =>
42
+ within(group)
43
+ .getAllByTestId('policy-violation')
44
+ .map((line) => line.textContent?.trim()),
45
+ );
46
+ }
47
+
48
+ describe('PolicyViolationList', () => {
49
+ it('groups the violations under one heading for each scope, instance first', () => {
50
+ const { getAllByTestId } = renderComponent({
51
+ props: {
52
+ violations: [telegramInProject, slackOnInstance, githubCredentialOnInstance],
53
+ },
54
+ });
55
+
56
+ expect(getAllByTestId('policy-violation-scope').map((heading) => heading.textContent)).toEqual([
57
+ 'Restricted on this instance',
58
+ 'Restricted in this project',
59
+ ]);
60
+ expect(groupTexts(getAllByTestId('policy-violation-group'))).toEqual([
61
+ [`${SLACK_NODE_TYPE} node`, `${GITHUB_CREDENTIAL_TYPE} credential`],
62
+ [`${TELEGRAM_NODE_TYPE} node`],
63
+ ]);
64
+ });
65
+
66
+ it('names each type by the display name the host resolved', () => {
67
+ const labels: Record<string, string> = { [SLACK_NODE_TYPE]: 'Slack', githubApi: 'GitHub API' };
68
+ const { getAllByTestId } = renderComponent({
69
+ props: {
70
+ violations: [slackOnInstance, githubCredentialOnInstance],
71
+ labelOf: ({ subject }: PolicyViolation) => (subject ? labels[subject] : undefined),
72
+ },
73
+ });
74
+
75
+ expect(getAllByTestId('policy-violation').map((line) => line.textContent?.trim())).toEqual([
76
+ 'Slack node',
77
+ 'GitHub API credential',
78
+ ]);
79
+ });
80
+
81
+ it('shows the backend message for an unknown kind, and the raw heading for an unknown scope', () => {
82
+ const deprecated = { ...slackOnInstance, kind: 'node-type-deprecated', scope: 'team' };
83
+ const withoutScope: PolicyViolation = {
84
+ kind: 'workflow-start-denied',
85
+ checkId: 'workflow-start',
86
+ message: 'This project cannot start workflows',
87
+ };
88
+ const { getAllByTestId } = renderComponent({
89
+ props: { violations: [withoutScope, deprecated] },
90
+ });
91
+
92
+ expect(getAllByTestId('policy-violation-scope').map((heading) => heading.textContent)).toEqual([
93
+ 'team',
94
+ ]);
95
+ expect(groupTexts(getAllByTestId('policy-violation-group'))).toEqual([
96
+ [deprecated.message],
97
+ ['This project cannot start workflows'],
98
+ ]);
99
+ });
100
+
101
+ it('lists a repeated violation once', () => {
102
+ const { getAllByTestId } = renderComponent({
103
+ props: { violations: [slackOnInstance, { ...slackOnInstance }] },
104
+ });
105
+
106
+ expect(getAllByTestId('policy-violation')).toHaveLength(1);
107
+ });
108
+
109
+ it('makes only a jumpable type a link and emits its violation', async () => {
110
+ const { getAllByTestId, emitted } = renderComponent({
111
+ props: {
112
+ violations: [slackOnInstance, githubCredentialOnInstance, telegramInProject],
113
+ isJumpable: ({ subject }: PolicyViolation) => subject !== TELEGRAM_NODE_TYPE,
114
+ },
115
+ });
116
+
117
+ const jumps = getAllByTestId('policy-violation-jump');
118
+ expect(jumps).toHaveLength(2);
119
+
120
+ await userEvent.click(jumps[1]);
121
+
122
+ expect(emitted().jump).toEqual([[githubCredentialOnInstance]]);
123
+ });
124
+ });
@@ -0,0 +1,150 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import type { PolicyViolation } from '@n8n/api-types';
4
+ import { N8nText } from '@n8n/design-system';
5
+ import { useI18n, type BaseTextKey } from '@n8n/i18n';
6
+
7
+ import { isRestrictionScope, SCOPE_LABEL_KEY } from '../type-availability-policies.constants';
8
+
9
+ const props = withDefaults(
10
+ defineProps<{
11
+ violations: PolicyViolation[];
12
+ isJumpable?: (violation: PolicyViolation) => boolean;
13
+ labelOf?: (violation: PolicyViolation) => string | undefined;
14
+ }>(),
15
+ { isJumpable: () => false, labelOf: () => undefined },
16
+ );
17
+
18
+ const emit = defineEmits<{ jump: [violation: PolicyViolation] }>();
19
+
20
+ const i18n = useI18n();
21
+
22
+ const NAME_KEY_BY_KIND: Record<string, BaseTextKey | undefined> = {
23
+ 'node-type-unavailable': 'typeAvailabilityPolicies.violations.nodeType',
24
+ 'credential-type-unavailable': 'typeAvailabilityPolicies.violations.credentialType',
25
+ };
26
+
27
+ const SCOPE_ORDER = ['instance', 'project'];
28
+
29
+ type Item = { key: string; text: string; violation: PolicyViolation; jumpable: boolean };
30
+ type Group = { scope: string | undefined; heading: string | undefined; items: Item[] };
31
+
32
+ function itemText(violation: PolicyViolation): string {
33
+ const { kind, subject, message } = violation;
34
+ const key = NAME_KEY_BY_KIND[kind];
35
+ if (!subject || !key) return message;
36
+
37
+ return i18n.baseText(key, { interpolate: { name: props.labelOf(violation) ?? subject } });
38
+ }
39
+
40
+ function scopeHeading(scope: string | undefined): string | undefined {
41
+ if (!scope) return undefined;
42
+
43
+ return isRestrictionScope(scope) ? i18n.baseText(SCOPE_LABEL_KEY[scope]) : scope;
44
+ }
45
+
46
+ function scopeRank(scope: string | undefined): number {
47
+ if (!scope) return SCOPE_ORDER.length + 1;
48
+
49
+ const index = SCOPE_ORDER.indexOf(scope);
50
+ return index === -1 ? SCOPE_ORDER.length : index;
51
+ }
52
+
53
+ const groups = computed(() => {
54
+ const byScope = new Map<string | undefined, Group>();
55
+
56
+ for (const violation of props.violations) {
57
+ const text = itemText(violation);
58
+ const key = `${violation.subjectType ?? ''}:${violation.subject ?? ''}:${text}`;
59
+
60
+ let group = byScope.get(violation.scope);
61
+ if (!group) {
62
+ group = { scope: violation.scope, heading: scopeHeading(violation.scope), items: [] };
63
+ byScope.set(violation.scope, group);
64
+ }
65
+
66
+ if (group.items.some((item) => item.key === key)) continue;
67
+ group.items.push({ key, text, violation, jumpable: props.isJumpable(violation) });
68
+ }
69
+
70
+ return [...byScope.values()].sort((a, b) => scopeRank(a.scope) - scopeRank(b.scope));
71
+ });
72
+ </script>
73
+
74
+ <template>
75
+ <div :class="$style.list">
76
+ <section
77
+ v-for="group in groups"
78
+ :key="group.scope ?? ''"
79
+ :class="$style.group"
80
+ data-test-id="policy-violation-group"
81
+ >
82
+ <N8nText
83
+ v-if="group.heading"
84
+ tag="p"
85
+ size="small"
86
+ color="text-light"
87
+ data-test-id="policy-violation-scope"
88
+ >
89
+ {{ group.heading }}
90
+ </N8nText>
91
+ <ul :class="$style.items">
92
+ <li
93
+ v-for="item in group.items"
94
+ :key="item.key"
95
+ :class="$style.item"
96
+ data-test-id="policy-violation"
97
+ >
98
+ <button
99
+ v-if="item.jumpable"
100
+ type="button"
101
+ :class="$style.jump"
102
+ :title="i18n.baseText('typeAvailabilityPolicies.violations.jump')"
103
+ data-test-id="policy-violation-jump"
104
+ @click="emit('jump', item.violation)"
105
+ >
106
+ <N8nText size="small" color="primary">{{ item.text }}</N8nText>
107
+ </button>
108
+ <N8nText v-else size="small" color="text-dark">{{ item.text }}</N8nText>
109
+ </li>
110
+ </ul>
111
+ </section>
112
+ </div>
113
+ </template>
114
+
115
+ <style lang="scss" module>
116
+ .list {
117
+ display: flex;
118
+ flex-direction: column;
119
+ gap: var(--spacing--xs);
120
+ }
121
+
122
+ .group {
123
+ display: flex;
124
+ flex-direction: column;
125
+ gap: var(--spacing--4xs);
126
+ }
127
+
128
+ .items {
129
+ margin: 0;
130
+ padding-left: var(--spacing--sm);
131
+ list-style: disc;
132
+ }
133
+
134
+ .item + .item {
135
+ margin-top: var(--spacing--5xs);
136
+ }
137
+
138
+ .jump {
139
+ padding: 0;
140
+ border: 0;
141
+ background: none;
142
+ cursor: pointer;
143
+ text-align: left;
144
+
145
+ &:hover,
146
+ &:focus-visible {
147
+ text-decoration: underline;
148
+ }
149
+ }
150
+ </style>
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { getPolicyViolations } from './policyViolations';
4
+
5
+ const saveRefused403 = {
6
+ message: 'Node type "n8n-nodes-base.slack" is blocked by an instance policy',
7
+ meta: {
8
+ violations: [
9
+ {
10
+ kind: 'node-type-unavailable',
11
+ checkId: 'node-type-availability',
12
+ message: 'Node type "n8n-nodes-base.slack" is blocked by an instance policy',
13
+ subject: 'n8n-nodes-base.slack',
14
+ subjectType: 'nodeType',
15
+ scope: 'instance',
16
+ matchedRuleId: 'deny-n8n-nodes-base.slack',
17
+ },
18
+ ],
19
+ },
20
+ };
21
+
22
+ const storedExecutionError = {
23
+ message: 'Workflow start is blocked by a project policy',
24
+ stack: 'Error: Workflow start is blocked by a project policy\n at deny',
25
+ violations: [
26
+ {
27
+ kind: 'workflow-start-denied',
28
+ checkId: 'workflow-start',
29
+ message: 'Workflow start is blocked by a project policy',
30
+ subject: 'wf-1',
31
+ subjectType: 'workflow',
32
+ scope: 'project',
33
+ },
34
+ ],
35
+ };
36
+
37
+ describe('getPolicyViolations', () => {
38
+ it('reads the violations a refused request carries under meta', () => {
39
+ const error = Object.assign(new Error(saveRefused403.message), {
40
+ httpStatusCode: 403,
41
+ meta: saveRefused403.meta,
42
+ });
43
+
44
+ expect(getPolicyViolations(error)).toEqual(saveRefused403.meta.violations);
45
+ });
46
+
47
+ it('reads the violations a refused run stores on the execution error', () => {
48
+ expect(getPolicyViolations(storedExecutionError)).toEqual(storedExecutionError.violations);
49
+ });
50
+
51
+ it.each([
52
+ ['an error without violations', new Error('Request failed with status code 500')],
53
+ ['an empty violation list', { meta: { violations: [] } }],
54
+ [
55
+ 'a violation without the required fields',
56
+ { violations: [{ kind: 'node-type-unavailable' }] },
57
+ ],
58
+ ['a violation list that is not an array', { meta: { violations: 'blocked' } }],
59
+ ['a value that is not an object', 'blocked'],
60
+ ['no error at all', undefined],
61
+ ])('returns undefined for %s', (_label, error) => {
62
+ expect(getPolicyViolations(error)).toBeUndefined();
63
+ });
64
+ });
@@ -0,0 +1,19 @@
1
+ import { policyViolationSchema, type PolicyViolation } from '@n8n/api-types';
2
+ import { isRecord } from '@n8n/utils/is-record';
3
+
4
+ const violationsSchema = policyViolationSchema.array().nonempty();
5
+
6
+ /**
7
+ * A refused request carries the violations under `meta`, but a refused manual run is stored on
8
+ * the execution error, where only the own properties of the error survive.
9
+ */
10
+ function readViolations(error: unknown): unknown {
11
+ if (!isRecord(error)) return undefined;
12
+ if (isRecord(error.meta) && error.meta.violations !== undefined) return error.meta.violations;
13
+ return error.violations;
14
+ }
15
+
16
+ export function getPolicyViolations(error: unknown): PolicyViolation[] | undefined {
17
+ const parsed = violationsSchema.safeParse(readViolations(error));
18
+ return parsed.success ? parsed.data : undefined;
19
+ }
@@ -1,4 +1,16 @@
1
+ import type { NodeTypeAvailabilityScope } from '@n8n/api-types';
2
+ import type { BaseTextKey } from '@n8n/i18n';
3
+
1
4
  /** Must stay the same as the backend module id: `settings.activeModules` is keyed by it. */
2
5
  export const TYPE_AVAILABILITY_POLICIES_MODULE_ID = 'type-availability-policies';
3
6
 
4
7
  export const TYPE_AVAILABILITY_POLICIES_STORE_ID = 'typeAvailabilityPolicies';
8
+
9
+ export const SCOPE_LABEL_KEY: Record<NodeTypeAvailabilityScope, BaseTextKey> = {
10
+ instance: 'typeAvailabilityPolicies.restrictedNode.scope.instance',
11
+ project: 'typeAvailabilityPolicies.restrictedNode.scope.project',
12
+ };
13
+
14
+ export function isRestrictionScope(scope: string): scope is NodeTypeAvailabilityScope {
15
+ return Object.hasOwn(SCOPE_LABEL_KEY, scope);
16
+ }