@n8n/frontend-module-type-availability-policies 0.1.0 → 0.2.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/LICENSE_EE.md ADDED
@@ -0,0 +1,27 @@
1
+ # The n8n Enterprise License (the “Enterprise License”)
2
+
3
+ Copyright (c) 2022-present n8n GmbH.
4
+
5
+ With regard to the n8n Software:
6
+
7
+ This software and associated documentation files (the "Software") may only be used in production, if
8
+ you (and any entity that you represent) hold a valid n8n Enterprise license corresponding to your
9
+ usage. Subject to the foregoing sentence, you are free to modify this Software and publish patches
10
+ to the Software. You agree that n8n and/or its licensors (as applicable) retain all right, title and
11
+ interest in and to all such modifications and/or patches, and all such modifications and/or patches
12
+ may only be used, copied, modified, displayed, distributed, or otherwise exploited with a valid n8n
13
+ Enterprise license for the corresponding usage. Notwithstanding the foregoing, you may copy and
14
+ modify the Software for development and testing purposes, without requiring a subscription. You
15
+ agree that n8n and/or its licensors (as applicable) retain all right, title and interest in and to
16
+ all such modifications. You are not granted any other rights beyond what is expressly stated herein.
17
+ Subject to the foregoing, it is forbidden to copy, merge, publish, distribute, sublicense, and/or
18
+ sell the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
21
+ NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
22
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES
23
+ OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
24
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
25
+
26
+ For all third party components incorporated into the n8n Software, those components are licensed
27
+ under the original license provided by the owner of the applicable component.
package/README.md CHANGED
@@ -10,6 +10,12 @@ The descriptor `id` must match the backend module id
10
10
  The module is license-gated on `feat:nodeTypePolicies` and is not a default module. Enable it
11
11
  in a dev instance with `N8N_ENABLED_MODULES=type-availability-policies`.
12
12
 
13
+ `useTypeAvailabilityPoliciesStore` answers whether a project may use a node type. The store is
14
+ passive: it does not watch the route or the active project, because the projects store lives in the
15
+ shell. The shell calls `fetchForProject(projectId)` when a workflow opens or the active project
16
+ changes. When the module is off, the request fails, a request is in flight for a different project,
17
+ or nothing has loaded yet, every lookup reports "available".
18
+
13
19
  ```bash
14
20
  pnpm turbo typecheck lint test --filter=@n8n/frontend-module-type-availability-policies
15
21
  ```
@@ -17,5 +23,14 @@ pnpm turbo typecheck lint test --filter=@n8n/frontend-module-type-availability-p
17
23
  Use turbo and not `pnpm --filter`: this package reads its platform dependencies from source,
18
24
  and on a cold tree turbo builds them first.
19
25
 
26
+ `useNodeTypeRestriction(type)` turns the store answer into `isRestricted` and `restrictionScope`
27
+ for one node type. `isNodeTypeRestricted(type)` is the plain predicate for a list of nodes. The
28
+ shell reads restriction through these and never through the store directly.
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.
34
+
20
35
  `packages/@n8n/module-cli/frontend-module-guide.md` holds the descriptor contract, the
21
36
  registration points, the import boundaries and the setup a module needs to render UI.
package/package.json CHANGED
@@ -1,32 +1,53 @@
1
1
  {
2
2
  "name": "@n8n/frontend-module-type-availability-policies",
3
- "version": "0.1.0",
3
+ "version": "0.2.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/frontend-module-sdk": "0.10.0"
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",
16
+ "@vueuse/core": "^14.3.0",
17
+ "pinia": "^2.2.4",
18
+ "vue": "^3.5.13"
11
19
  },
12
20
  "devDependencies": {
21
+ "@iconify/json": "^2.2.488",
13
22
  "@n8n/eslint-config": "0.0.1",
14
23
  "@n8n/frontend-test-utils": "0.1.0",
15
24
  "@n8n/frontend-vite-config": "0.1.0",
16
25
  "@n8n/playwright-janitor": "0.1.0",
17
26
  "@n8n/typescript-config": "1.12.0",
18
27
  "@n8n/vitest-config": "1.22.0",
28
+ "@pinia/testing": "^0.1.6",
19
29
  "@testing-library/jest-dom": "^6.6.3",
30
+ "@testing-library/user-event": "^14.6.1",
20
31
  "@testing-library/vue": "^8.1.0",
21
32
  "@vitejs/plugin-vue": "^6.0.8",
22
33
  "eslint": "9.29.0",
23
34
  "typescript": "6.0.2",
24
35
  "unplugin-icons": "^23.0.1",
25
36
  "vite": "^8.0.2",
37
+ "vite-svg-loader": "5.1.0",
26
38
  "vitest": "^4.1.9",
27
39
  "vue-tsc": "^2.2.8"
28
40
  },
29
- "license": "LicenseRef-n8n-sustainable-use",
41
+ "license": "SEE LICENSE IN LICENSE.md",
42
+ "homepage": "https://n8n.io",
43
+ "author": {
44
+ "name": "Jan Oberhauser",
45
+ "email": "jan@n8n.io"
46
+ },
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "git+https://github.com/n8n-io/n8n.git"
50
+ },
30
51
  "scripts": {
31
52
  "clean": "rimraf .turbo",
32
53
  "typecheck": "vue-tsc --noEmit",
@@ -0,0 +1,90 @@
1
+ import { ROLE } from '@n8n/api-types';
2
+ import { createComponentRenderer, mockedStore, waitAllPromises } from '@n8n/frontend-test-utils';
3
+ import type { IUser } from '@n8n/rest-api-client/api/users';
4
+ import { useUsersStore } from '@n8n/stores/users.store';
5
+ import { createTestingPinia } from '@pinia/testing';
6
+ import { setActivePinia } from 'pinia';
7
+ import { describe, it, expect, vi } from 'vitest';
8
+
9
+ import ContactInstanceAdminModal from './ContactInstanceAdminModal.vue';
10
+
11
+ vi.mock('@n8n/design-system', async (importOriginal) => {
12
+ const actual = await importOriginal<typeof import('@n8n/design-system')>();
13
+ return {
14
+ ...actual,
15
+ N8nDialog: {
16
+ name: 'N8nDialog',
17
+ props: ['open', 'header', 'description'],
18
+ template: '<div v-if="open"><h2>{{ header }}</h2><p>{{ description }}</p><slot /></div>',
19
+ },
20
+ };
21
+ });
22
+
23
+ const owner = {
24
+ id: 'owner-1',
25
+ firstName: 'Ada',
26
+ lastName: 'Lovelace',
27
+ fullName: 'Ada Lovelace',
28
+ email: 'ada@example.com',
29
+ role: ROLE.Owner,
30
+ isPendingUser: false,
31
+ } as unknown as IUser;
32
+
33
+ const member = { ...owner, id: 'member-1', fullName: 'Bob Builder', role: ROLE.Member } as IUser;
34
+
35
+ const renderComponent = createComponentRenderer(ContactInstanceAdminModal, {
36
+ props: { open: true, description: 'Ask for the Slack node.', mailSubject: 'Access request' },
37
+ });
38
+
39
+ function setup(allUsers: IUser[], fetchUsers = vi.fn().mockResolvedValue(undefined)) {
40
+ setActivePinia(createTestingPinia());
41
+ const usersStore = mockedStore(useUsersStore);
42
+ usersStore.allUsers = allUsers;
43
+ usersStore.fetchUsers = fetchUsers;
44
+ return usersStore;
45
+ }
46
+
47
+ describe('ContactInstanceAdminModal', () => {
48
+ it('lists the owners with a mailto link and shows the description', async () => {
49
+ const usersStore = setup([owner, member]);
50
+ const { findByTestId, getByText } = renderComponent();
51
+
52
+ const list = await findByTestId('contact-instance-admin-list');
53
+
54
+ expect(usersStore.fetchUsers).toHaveBeenCalledWith({ filter: { isOwner: true } });
55
+ expect(list).toHaveTextContent('Ada Lovelace');
56
+ expect(list).not.toHaveTextContent('Bob Builder');
57
+ expect(getByText('ada@example.com').closest('a')).toHaveAttribute(
58
+ 'href',
59
+ 'mailto:ada@example.com?subject=Access%20request',
60
+ );
61
+ expect(getByText('Ask for the Slack node.')).toBeInTheDocument();
62
+ });
63
+
64
+ it('keeps loading until the latest owner lookup resolves after a reopen', async () => {
65
+ const lookups: Array<() => void> = [];
66
+ setup(
67
+ [owner],
68
+ vi.fn(async () => await new Promise<void>((resolve) => lookups.push(resolve))),
69
+ );
70
+ const { rerender, queryByTestId, findByTestId } = renderComponent();
71
+
72
+ await rerender({ open: false });
73
+ await rerender({ open: true });
74
+ expect(lookups).toHaveLength(2);
75
+
76
+ lookups[0]();
77
+ await waitAllPromises();
78
+ expect(queryByTestId('contact-instance-admin-list')).not.toBeInTheDocument();
79
+
80
+ lookups[1]();
81
+ expect(await findByTestId('contact-instance-admin-list')).toBeInTheDocument();
82
+ });
83
+
84
+ it('falls back to a generic hint when the owner lookup yields nothing', async () => {
85
+ setup([], vi.fn().mockRejectedValue(new Error('forbidden')));
86
+ const { findByTestId } = renderComponent();
87
+
88
+ expect(await findByTestId('contact-instance-admin-empty')).toBeInTheDocument();
89
+ });
90
+ });
@@ -0,0 +1,109 @@
1
+ <script lang="ts" setup>
2
+ import { ROLE } from '@n8n/api-types';
3
+ import { N8nAvatar, N8nDialog, N8nLink, N8nLoading, N8nText } from '@n8n/design-system';
4
+ import { useI18n } from '@n8n/i18n';
5
+ import { useUsersStore } from '@n8n/stores/users.store';
6
+ import { computed, ref, watch } from 'vue';
7
+
8
+ const props = defineProps<{
9
+ description: string;
10
+ mailSubject?: string;
11
+ }>();
12
+
13
+ const open = defineModel<boolean>('open', { default: false });
14
+
15
+ const i18n = useI18n();
16
+ const usersStore = useUsersStore();
17
+
18
+ const isLoading = ref(false);
19
+ let lookupId = 0;
20
+
21
+ const owners = computed(() =>
22
+ usersStore.allUsers.filter(
23
+ (user) => user.role === ROLE.Owner && !user.isPendingUser && Boolean(user.email),
24
+ ),
25
+ );
26
+
27
+ watch(
28
+ open,
29
+ async (isOpen) => {
30
+ if (!isOpen) return;
31
+
32
+ // A reopen can start a second lookup before the first resolves; only the latest one ends loading.
33
+ const id = ++lookupId;
34
+ isLoading.value = true;
35
+ await usersStore.fetchUsers({ filter: { isOwner: true } }).catch(() => undefined);
36
+ if (id === lookupId) isLoading.value = false;
37
+ },
38
+ { immediate: true },
39
+ );
40
+
41
+ function mailtoHref(email: string): string {
42
+ const subject = props.mailSubject ? `?subject=${encodeURIComponent(props.mailSubject)}` : '';
43
+ return `mailto:${email}${subject}`;
44
+ }
45
+ </script>
46
+
47
+ <template>
48
+ <N8nDialog
49
+ v-model:open="open"
50
+ :header="i18n.baseText('typeAvailabilityPolicies.contactAdmin.title')"
51
+ :description="description"
52
+ size="medium"
53
+ data-test-id="contact-instance-admin-modal"
54
+ >
55
+ <N8nLoading v-if="isLoading" :rows="2" variant="p" :class="$style.body" />
56
+ <ul
57
+ v-else-if="owners.length"
58
+ :class="[$style.body, $style.list]"
59
+ data-test-id="contact-instance-admin-list"
60
+ >
61
+ <li v-for="owner in owners" :key="owner.id" :class="$style.owner">
62
+ <N8nAvatar :first-name="owner.firstName" :last-name="owner.lastName" size="small" />
63
+ <div :class="$style.identity">
64
+ <N8nText size="medium" color="text-dark">{{ owner.fullName }}</N8nText>
65
+ <N8nLink :to="mailtoHref(owner.email ?? '')" size="small" theme="text">
66
+ {{ owner.email }}
67
+ </N8nLink>
68
+ </div>
69
+ </li>
70
+ </ul>
71
+ <N8nText
72
+ v-else
73
+ tag="p"
74
+ size="small"
75
+ color="text-base"
76
+ :class="$style.body"
77
+ data-test-id="contact-instance-admin-empty"
78
+ >
79
+ {{ i18n.baseText('typeAvailabilityPolicies.contactAdmin.empty') }}
80
+ </N8nText>
81
+ </N8nDialog>
82
+ </template>
83
+
84
+ <style lang="scss" module>
85
+ .list {
86
+ list-style: none;
87
+ margin: 0;
88
+ padding: 0;
89
+ display: flex;
90
+ flex-direction: column;
91
+ gap: var(--spacing--lg);
92
+ }
93
+
94
+ .body {
95
+ margin-top: var(--spacing--lg);
96
+ }
97
+
98
+ .owner {
99
+ display: flex;
100
+ align-items: center;
101
+ gap: var(--spacing--xs);
102
+ }
103
+
104
+ .identity {
105
+ display: flex;
106
+ flex-direction: column;
107
+ min-width: 0;
108
+ }
109
+ </style>
@@ -0,0 +1,77 @@
1
+ import { createComponentRenderer } from '@n8n/frontend-test-utils';
2
+ import { screen } from '@testing-library/vue';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { describe, it, expect } from 'vitest';
5
+
6
+ import RestrictedNodePopover from './RestrictedNodePopover.vue';
7
+
8
+ const DESCRIPTION =
9
+ 'An administrator blocked this node. To use it in your workflows, contact an instance administrator for access.';
10
+
11
+ const renderComponent = createComponentRenderer(RestrictedNodePopover, {
12
+ props: { nodeTypeName: 'Gmail', scope: 'instance' },
13
+ global: {
14
+ stubs: {
15
+ ContactInstanceAdminModal: {
16
+ props: ['open', 'description'],
17
+ template:
18
+ '<div v-if="open" data-test-id="contact-instance-admin-modal">{{ description }}</div>',
19
+ },
20
+ },
21
+ },
22
+ });
23
+
24
+ const renderPopover = (props: Record<string, unknown>) => renderComponent({ props });
25
+
26
+ describe('RestrictedNodePopover', () => {
27
+ it('renders only the lock while the row is neither hovered nor active', () => {
28
+ renderPopover({ active: false });
29
+
30
+ expect(screen.getByTestId('node-restricted-icon')).toBeInTheDocument();
31
+ expect(screen.queryByTestId('node-restricted-popover')).not.toBeInTheDocument();
32
+ });
33
+
34
+ it('opens for the keyboard-active row and names the node and the instance scope', async () => {
35
+ renderPopover({ active: true });
36
+
37
+ expect(await screen.findByTestId('node-restricted-popover')).toBeInTheDocument();
38
+ expect(screen.getByText('Gmail')).toBeInTheDocument();
39
+ expect(screen.getByText('Restricted on this instance')).toBeInTheDocument();
40
+ expect(screen.getByText(DESCRIPTION)).toBeInTheDocument();
41
+ });
42
+
43
+ it('names the project scope', async () => {
44
+ renderPopover({ active: true, scope: 'project' });
45
+
46
+ expect(await screen.findByText('Restricted in this project')).toBeInTheDocument();
47
+ });
48
+
49
+ it('falls back to the generic title for an unknown scope', async () => {
50
+ renderPopover({ active: true, scope: undefined });
51
+
52
+ expect(await screen.findByText('This node is restricted')).toBeInTheDocument();
53
+ });
54
+
55
+ 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
+ renderPopover({ anchor });
59
+
60
+ await userEvent.hover(anchor);
61
+ const popover = await screen.findByTestId('node-restricted-popover');
62
+ await userEvent.hover(popover);
63
+ await userEvent.unhover(anchor);
64
+
65
+ expect(screen.getByTestId('node-restricted-popover')).toBeInTheDocument();
66
+ anchor.remove();
67
+ });
68
+
69
+ it('opens the contact-admin dialog for this node type', async () => {
70
+ renderPopover({ active: true });
71
+ expect(screen.queryByTestId('contact-instance-admin-modal')).not.toBeInTheDocument();
72
+
73
+ await userEvent.click(await screen.findByTestId('node-restricted-contact-admin'));
74
+
75
+ expect(screen.getByTestId('contact-instance-admin-modal')).toHaveTextContent('Gmail');
76
+ });
77
+ });
@@ -0,0 +1,125 @@
1
+ <script setup lang="ts">
2
+ import type { NodeTypeAvailabilityScope } from '@n8n/api-types';
3
+ import { N8nButton, N8nIcon, N8nPopover, N8nText } from '@n8n/design-system';
4
+ import { useI18n, type BaseTextKey } from '@n8n/i18n';
5
+ import { unrefElement, useElementHover, type MaybeElement } from '@vueuse/core';
6
+ import { computed, ref } from 'vue';
7
+
8
+ import ContactInstanceAdminModal from './ContactInstanceAdminModal.vue';
9
+
10
+ const props = defineProps<{
11
+ nodeTypeName: string;
12
+ scope?: NodeTypeAvailabilityScope;
13
+ /** The list row the popover explains. It opens beside this element, not beside the lock. */
14
+ anchor?: MaybeElement;
15
+ /** The row is the keyboard-active item, which opens the popover like a hover does. */
16
+ active?: boolean;
17
+ }>();
18
+
19
+ /** Leaving waits this long before closing, so the pointer can cross the gap to the popover. */
20
+ const HOVER_GRACE_MS = 200;
21
+
22
+ const SCOPE_TITLE_KEY: Record<NodeTypeAvailabilityScope, BaseTextKey> = {
23
+ instance: 'typeAvailabilityPolicies.restrictedNode.scope.instance',
24
+ project: 'typeAvailabilityPolicies.restrictedNode.scope.project',
25
+ };
26
+
27
+ const i18n = useI18n();
28
+
29
+ const anchorElement = computed(() => unrefElement(props.anchor) ?? undefined);
30
+ const contentRef = ref<HTMLElement | null>(null);
31
+ const anchorHovered = useElementHover(anchorElement, { delayLeave: HOVER_GRACE_MS });
32
+ const contentHovered = useElementHover(contentRef, { delayLeave: HOVER_GRACE_MS });
33
+ const open = computed(() => anchorHovered.value || contentHovered.value || props.active);
34
+
35
+ const isContactAdminOpen = ref(false);
36
+
37
+ const scopeKey = computed<BaseTextKey>(
38
+ () =>
39
+ (props.scope && SCOPE_TITLE_KEY[props.scope]) ??
40
+ 'typeAvailabilityPolicies.restrictedNode.title',
41
+ );
42
+
43
+ const interpolate = computed(() => ({ nodeType: props.nodeTypeName }));
44
+ </script>
45
+
46
+ <template>
47
+ <span :class="$style.root">
48
+ <N8nPopover
49
+ :open="open"
50
+ side="left"
51
+ align="center"
52
+ :side-offset="24"
53
+ :reference="anchorElement"
54
+ :suppress-auto-focus="true"
55
+ :content-class="$style.card"
56
+ width="254px"
57
+ >
58
+ <template #trigger>
59
+ <N8nIcon
60
+ icon="lock"
61
+ size="small"
62
+ :title="i18n.baseText('typeAvailabilityPolicies.restrictedNode.title')"
63
+ data-test-id="node-restricted-icon"
64
+ />
65
+ </template>
66
+ <template #content>
67
+ <div ref="contentRef" :class="$style.popover" data-test-id="node-restricted-popover">
68
+ <N8nText tag="p" size="large" color="text-dark">{{ nodeTypeName }}</N8nText>
69
+ <N8nText tag="p" size="small" color="text-light">{{ i18n.baseText(scopeKey) }}</N8nText>
70
+ <N8nText tag="p" size="small" color="text-base" :class="$style.description">
71
+ {{ i18n.baseText('typeAvailabilityPolicies.restrictedNode.popover.description') }}
72
+ </N8nText>
73
+ <N8nButton
74
+ variant="outline"
75
+ size="small"
76
+ :class="$style.action"
77
+ data-test-id="node-restricted-contact-admin"
78
+ @click="isContactAdminOpen = true"
79
+ >
80
+ {{ i18n.baseText('typeAvailabilityPolicies.restrictedNode.contactAdmin') }}
81
+ <N8nIcon icon="arrow-up-right" size="xsmall" />
82
+ </N8nButton>
83
+ </div>
84
+ </template>
85
+ </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
+ />
99
+ </span>
100
+ </template>
101
+
102
+ <style lang="scss" module>
103
+ // Two teleported children and one visible trigger: the wrapper must not affect the slot's layout.
104
+ .root {
105
+ display: contents;
106
+ }
107
+
108
+ // The design system defaults popovers to --radius--xs (8px); the design uses the editor's 4px.
109
+ .card {
110
+ border-radius: var(--radius);
111
+ }
112
+
113
+ .popover {
114
+ padding: var(--spacing--xs);
115
+ text-align: left;
116
+ }
117
+
118
+ .description {
119
+ margin-top: var(--spacing--3xs);
120
+ }
121
+
122
+ .action {
123
+ margin-top: var(--spacing--sm);
124
+ }
125
+ </style>
@@ -0,0 +1,37 @@
1
+ import { createPinia, setActivePinia } from 'pinia';
2
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
3
+ import { ref } from 'vue';
4
+
5
+ import { isNodeTypeRestricted, useNodeTypeRestriction } from './useNodeTypeRestriction';
6
+ import { useTypeAvailabilityPoliciesStore } from '../type-availability-policies.store';
7
+
8
+ describe('useNodeTypeRestriction', () => {
9
+ beforeEach(() => {
10
+ setActivePinia(createPinia());
11
+ vi.spyOn(useTypeAvailabilityPoliciesStore(), 'getNodeTypeAvailability').mockImplementation(
12
+ (name) =>
13
+ name === 'n8n-nodes-base.slack'
14
+ ? { name, available: false, scope: 'project' }
15
+ : { name, available: true },
16
+ );
17
+ });
18
+
19
+ it('reports a blocked type with the scope that blocked it', () => {
20
+ const nodeType = ref<string | null>('n8n-nodes-base.set');
21
+
22
+ const { isRestricted, restrictionScope } = useNodeTypeRestriction(nodeType);
23
+ expect(isRestricted.value).toBe(false);
24
+
25
+ nodeType.value = 'n8n-nodes-base.slack';
26
+ expect(isRestricted.value).toBe(true);
27
+ expect(restrictionScope.value).toBe('project');
28
+
29
+ nodeType.value = null;
30
+ expect(isRestricted.value).toBe(false);
31
+ });
32
+
33
+ it('answers the plain predicate', () => {
34
+ expect(isNodeTypeRestricted('n8n-nodes-base.slack')).toBe(true);
35
+ expect(isNodeTypeRestricted('n8n-nodes-base.set')).toBe(false);
36
+ });
37
+ });
@@ -0,0 +1,25 @@
1
+ import type { NodeTypeAvailability } from '@n8n/api-types';
2
+ import { computed, toValue, type MaybeRefOrGetter } from 'vue';
3
+
4
+ import { useTypeAvailabilityPoliciesStore } from '../type-availability-policies.store';
5
+
6
+ export function getNodeTypeRestriction(nodeType: string): NodeTypeAvailability | null {
7
+ const availability = useTypeAvailabilityPoliciesStore().getNodeTypeAvailability(nodeType);
8
+ return availability && !availability.available ? availability : null;
9
+ }
10
+
11
+ export function isNodeTypeRestricted(nodeType: string): boolean {
12
+ return getNodeTypeRestriction(nodeType) !== null;
13
+ }
14
+
15
+ export function useNodeTypeRestriction(nodeType: MaybeRefOrGetter<string | null | undefined>) {
16
+ const restriction = computed(() => {
17
+ const type = toValue(nodeType);
18
+ return type ? getNodeTypeRestriction(type) : null;
19
+ });
20
+
21
+ const isRestricted = computed(() => restriction.value !== null);
22
+ const restrictionScope = computed(() => restriction.value?.scope);
23
+
24
+ return { isRestricted, restrictionScope };
25
+ }
package/src/index.ts CHANGED
@@ -1 +1,9 @@
1
1
  export { TypeAvailabilityPoliciesModule } from './type-availability-policies.module';
2
+ export { useTypeAvailabilityPoliciesStore } from './type-availability-policies.store';
3
+ export {
4
+ getNodeTypeRestriction,
5
+ isNodeTypeRestricted,
6
+ useNodeTypeRestriction,
7
+ } from './composables/useNodeTypeRestriction';
8
+ export { default as ContactInstanceAdminModal } from './components/ContactInstanceAdminModal.vue';
9
+ export { default as RestrictedNodePopover } from './components/RestrictedNodePopover.vue';
@@ -0,0 +1,10 @@
1
+ import type { AvailableTypesResponse } from '@n8n/api-types';
2
+ import { makeRestApiRequest } from '@n8n/rest-api-client';
3
+ import type { IRestApiContext } from '@n8n/rest-api-client';
4
+
5
+ export async function fetchAvailableTypes(
6
+ context: IRestApiContext,
7
+ projectId: string,
8
+ ): Promise<AvailableTypesResponse> {
9
+ return await makeRestApiRequest(context, 'GET', `/projects/${projectId}/available-types`);
10
+ }
@@ -1,2 +1,4 @@
1
1
  /** Must stay the same as the backend module id: `settings.activeModules` is keyed by it. */
2
2
  export const TYPE_AVAILABILITY_POLICIES_MODULE_ID = 'type-availability-policies';
3
+
4
+ export const TYPE_AVAILABILITY_POLICIES_STORE_ID = 'typeAvailabilityPolicies';
@@ -0,0 +1,249 @@
1
+ import type { AvailableTypesResponse } from '@n8n/api-types';
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
3
+
4
+ import { useTypeAvailabilityPoliciesStore } from './type-availability-policies.store';
5
+
6
+ const mocks = vi.hoisted(() => ({
7
+ fetchAvailableTypes: vi.fn(),
8
+ isModuleActive: vi.fn(),
9
+ }));
10
+
11
+ vi.mock('./type-availability-policies.api', () => ({
12
+ fetchAvailableTypes: mocks.fetchAvailableTypes,
13
+ }));
14
+
15
+ vi.mock('@n8n/stores/settings.store', () => ({
16
+ useSettingsStore: () => ({ isModuleActive: mocks.isModuleActive }),
17
+ }));
18
+
19
+ vi.mock('@n8n/stores/useRootStore', () => ({
20
+ useRootStore: () => ({
21
+ restApiContext: { baseUrl: 'http://localhost', pushRef: 'test' },
22
+ }),
23
+ }));
24
+
25
+ const ALLOWED = 'n8n-nodes-base.slack';
26
+ const RESTRICTED = 'n8n-nodes-base.executeCommand';
27
+ const UNKNOWN = 'n8n-nodes-base.doesNotExist';
28
+
29
+ const PROJECT_A_RESPONSE: AvailableTypesResponse = [
30
+ { name: ALLOWED, available: true },
31
+ { name: RESTRICTED, available: false, scope: 'project', matchedRuleId: 'rule-1' },
32
+ ];
33
+
34
+ const PROJECT_B_RESPONSE: AvailableTypesResponse = [
35
+ { name: ALLOWED, available: false, scope: 'instance' },
36
+ { name: RESTRICTED, available: true },
37
+ ];
38
+
39
+ describe('useTypeAvailabilityPoliciesStore', () => {
40
+ let errorSpy: ReturnType<typeof vi.spyOn>;
41
+
42
+ beforeEach(() => {
43
+ mocks.fetchAvailableTypes.mockReset();
44
+ mocks.isModuleActive.mockReset().mockReturnValue(true);
45
+ errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
46
+ });
47
+
48
+ afterEach(() => {
49
+ errorSpy.mockRestore();
50
+ });
51
+
52
+ describe('when the module is not active', () => {
53
+ beforeEach(() => {
54
+ mocks.isModuleActive.mockReturnValue(false);
55
+ });
56
+
57
+ it('reports every type as available and makes no request', async () => {
58
+ const store = useTypeAvailabilityPoliciesStore();
59
+
60
+ await store.fetchForProject('project-a');
61
+
62
+ expect(store.isEnabled).toBe(false);
63
+ expect(mocks.fetchAvailableTypes).not.toHaveBeenCalled();
64
+ expect(store.getNodeTypeAvailability(RESTRICTED)).toEqual({
65
+ name: RESTRICTED,
66
+ available: true,
67
+ });
68
+ });
69
+ });
70
+
71
+ describe('when the instance reports no active modules', () => {
72
+ beforeEach(() => {
73
+ mocks.isModuleActive.mockReturnValue(undefined);
74
+ });
75
+
76
+ it('reports every type as available and makes no request', async () => {
77
+ const store = useTypeAvailabilityPoliciesStore();
78
+
79
+ await store.fetchForProject('project-a');
80
+
81
+ expect(store.isEnabled).toBe(false);
82
+ expect(mocks.fetchAvailableTypes).not.toHaveBeenCalled();
83
+ expect(store.isNodeTypeAvailable(RESTRICTED)).toBe(true);
84
+ });
85
+ });
86
+
87
+ describe('when the module is active', () => {
88
+ it('returns the restricted entry with its scope and rule', async () => {
89
+ mocks.fetchAvailableTypes.mockResolvedValue(PROJECT_A_RESPONSE);
90
+ const store = useTypeAvailabilityPoliciesStore();
91
+
92
+ await store.fetchForProject('project-a');
93
+
94
+ expect(mocks.fetchAvailableTypes).toHaveBeenCalledWith(
95
+ expect.objectContaining({ baseUrl: 'http://localhost' }),
96
+ 'project-a',
97
+ );
98
+ expect(store.loadedProjectId).toBe('project-a');
99
+ expect(store.getNodeTypeAvailability(RESTRICTED)).toEqual({
100
+ name: RESTRICTED,
101
+ available: false,
102
+ scope: 'project',
103
+ matchedRuleId: 'rule-1',
104
+ });
105
+ expect(store.isNodeTypeAvailable(RESTRICTED)).toBe(false);
106
+ });
107
+
108
+ it('reports allowed and unknown types as available', async () => {
109
+ mocks.fetchAvailableTypes.mockResolvedValue(PROJECT_A_RESPONSE);
110
+ const store = useTypeAvailabilityPoliciesStore();
111
+
112
+ await store.fetchForProject('project-a');
113
+
114
+ expect(store.getNodeTypeAvailability(ALLOWED)).toEqual({ name: ALLOWED, available: true });
115
+ expect(store.getNodeTypeAvailability(UNKNOWN)).toEqual({ name: UNKNOWN, available: true });
116
+ });
117
+
118
+ it('does not refetch the project that is already loaded', async () => {
119
+ mocks.fetchAvailableTypes.mockResolvedValue(PROJECT_A_RESPONSE);
120
+ const store = useTypeAvailabilityPoliciesStore();
121
+
122
+ await store.fetchForProject('project-a');
123
+ await store.fetchForProject('project-a');
124
+
125
+ expect(mocks.fetchAvailableTypes).toHaveBeenCalledTimes(1);
126
+ });
127
+
128
+ it('reflects the new project after a switch', async () => {
129
+ mocks.fetchAvailableTypes
130
+ .mockResolvedValueOnce(PROJECT_A_RESPONSE)
131
+ .mockResolvedValueOnce(PROJECT_B_RESPONSE);
132
+ const store = useTypeAvailabilityPoliciesStore();
133
+
134
+ await store.fetchForProject('project-a');
135
+ await store.fetchForProject('project-b');
136
+
137
+ expect(store.loadedProjectId).toBe('project-b');
138
+ expect(store.isNodeTypeAvailable(ALLOWED)).toBe(false);
139
+ expect(store.isNodeTypeAvailable(RESTRICTED)).toBe(true);
140
+ });
141
+
142
+ it('reports every type as available while a project switch is in flight', async () => {
143
+ let resolveB: (value: AvailableTypesResponse) => void = () => {};
144
+ mocks.fetchAvailableTypes.mockResolvedValueOnce(PROJECT_A_RESPONSE).mockImplementationOnce(
145
+ async () =>
146
+ await new Promise<AvailableTypesResponse>((resolve) => {
147
+ resolveB = resolve;
148
+ }),
149
+ );
150
+ const store = useTypeAvailabilityPoliciesStore();
151
+
152
+ await store.fetchForProject('project-a');
153
+ const pendingB = store.fetchForProject('project-b');
154
+
155
+ expect(store.isNodeTypeAvailable(RESTRICTED)).toBe(true);
156
+ expect(store.getNodeTypeAvailability(RESTRICTED)).toEqual({
157
+ name: RESTRICTED,
158
+ available: true,
159
+ });
160
+
161
+ resolveB(PROJECT_B_RESPONSE);
162
+ await pendingB;
163
+
164
+ expect(store.isNodeTypeAvailable(ALLOWED)).toBe(false);
165
+ expect(store.isNodeTypeAvailable(RESTRICTED)).toBe(true);
166
+ });
167
+
168
+ it('discards a response for a project that is no longer requested', async () => {
169
+ let resolveA: (value: AvailableTypesResponse) => void = () => {};
170
+ mocks.fetchAvailableTypes
171
+ .mockImplementationOnce(
172
+ async () =>
173
+ await new Promise<AvailableTypesResponse>((resolve) => {
174
+ resolveA = resolve;
175
+ }),
176
+ )
177
+ .mockResolvedValueOnce(PROJECT_B_RESPONSE);
178
+ const store = useTypeAvailabilityPoliciesStore();
179
+
180
+ const pendingA = store.fetchForProject('project-a');
181
+ await store.fetchForProject('project-b');
182
+ resolveA(PROJECT_A_RESPONSE);
183
+ await pendingA;
184
+
185
+ expect(store.loadedProjectId).toBe('project-b');
186
+ expect(store.isNodeTypeAvailable(ALLOWED)).toBe(false);
187
+ expect(store.isLoading).toBe(false);
188
+ });
189
+
190
+ it('discards an in-flight response when the user returns to the loaded project', async () => {
191
+ let resolveB: (value: AvailableTypesResponse) => void = () => {};
192
+ mocks.fetchAvailableTypes.mockResolvedValueOnce(PROJECT_A_RESPONSE).mockImplementationOnce(
193
+ async () =>
194
+ await new Promise<AvailableTypesResponse>((resolve) => {
195
+ resolveB = resolve;
196
+ }),
197
+ );
198
+ const store = useTypeAvailabilityPoliciesStore();
199
+
200
+ await store.fetchForProject('project-a');
201
+ const pendingB = store.fetchForProject('project-b');
202
+ await store.fetchForProject('project-a');
203
+ resolveB(PROJECT_B_RESPONSE);
204
+ await pendingB;
205
+
206
+ expect(store.loadedProjectId).toBe('project-a');
207
+ expect(store.isNodeTypeAvailable(ALLOWED)).toBe(true);
208
+ expect(store.isNodeTypeAvailable(RESTRICTED)).toBe(false);
209
+ expect(store.isLoading).toBe(false);
210
+ });
211
+
212
+ it('degrades to available and logs when the request fails', async () => {
213
+ mocks.fetchAvailableTypes.mockRejectedValue(new Error('endpoint failure'));
214
+ const store = useTypeAvailabilityPoliciesStore();
215
+
216
+ await store.fetchForProject('project-a');
217
+
218
+ expect(errorSpy).toHaveBeenCalledTimes(1);
219
+ expect(store.loadedProjectId).toBeNull();
220
+ expect(store.isLoading).toBe(false);
221
+ expect(store.isNodeTypeAvailable(RESTRICTED)).toBe(true);
222
+ });
223
+
224
+ it('retries after a failed request', async () => {
225
+ mocks.fetchAvailableTypes
226
+ .mockRejectedValueOnce(new Error('endpoint failure'))
227
+ .mockResolvedValueOnce(PROJECT_A_RESPONSE);
228
+ const store = useTypeAvailabilityPoliciesStore();
229
+
230
+ await store.fetchForProject('project-a');
231
+ await store.fetchForProject('project-a');
232
+
233
+ expect(mocks.fetchAvailableTypes).toHaveBeenCalledTimes(2);
234
+ expect(store.loadedProjectId).toBe('project-a');
235
+ expect(store.isNodeTypeAvailable(RESTRICTED)).toBe(false);
236
+ });
237
+
238
+ it('clears all state on reset', async () => {
239
+ mocks.fetchAvailableTypes.mockResolvedValue(PROJECT_A_RESPONSE);
240
+ const store = useTypeAvailabilityPoliciesStore();
241
+
242
+ await store.fetchForProject('project-a');
243
+ store.reset();
244
+
245
+ expect(store.loadedProjectId).toBeNull();
246
+ expect(store.isNodeTypeAvailable(RESTRICTED)).toBe(true);
247
+ });
248
+ });
249
+ });
@@ -0,0 +1,85 @@
1
+ import type { NodeTypeAvailability } from '@n8n/api-types';
2
+ import { useSettingsStore } from '@n8n/stores/settings.store';
3
+ import { useRootStore } from '@n8n/stores/useRootStore';
4
+ import { defineStore } from 'pinia';
5
+ import { computed, ref, shallowRef } from 'vue';
6
+
7
+ import { fetchAvailableTypes } from './type-availability-policies.api';
8
+ import {
9
+ TYPE_AVAILABILITY_POLICIES_MODULE_ID,
10
+ TYPE_AVAILABILITY_POLICIES_STORE_ID,
11
+ } from './type-availability-policies.constants';
12
+
13
+ export const useTypeAvailabilityPoliciesStore = defineStore(
14
+ TYPE_AVAILABILITY_POLICIES_STORE_ID,
15
+ () => {
16
+ const rootStore = useRootStore();
17
+ const settingsStore = useSettingsStore();
18
+
19
+ const restrictedNodeTypes = shallowRef<ReadonlyMap<string, NodeTypeAvailability>>(new Map());
20
+ const loadedProjectId = ref<string | null>(null);
21
+ const requestedProjectId = ref<string | null>(null);
22
+ const isLoading = ref(false);
23
+
24
+ const isEnabled = computed(
25
+ () => settingsStore.isModuleActive(TYPE_AVAILABILITY_POLICIES_MODULE_ID) ?? false,
26
+ );
27
+
28
+ async function fetchForProject(projectId: string): Promise<void> {
29
+ if (!isEnabled.value) return;
30
+
31
+ requestedProjectId.value = projectId;
32
+
33
+ if (projectId === loadedProjectId.value) {
34
+ isLoading.value = false;
35
+ return;
36
+ }
37
+
38
+ isLoading.value = true;
39
+ try {
40
+ const entries = await fetchAvailableTypes(rootStore.restApiContext, projectId);
41
+ if (requestedProjectId.value !== projectId) return;
42
+
43
+ restrictedNodeTypes.value = new Map(
44
+ entries.filter((entry) => !entry.available).map((entry) => [entry.name, entry]),
45
+ );
46
+ loadedProjectId.value = projectId;
47
+ } catch (error) {
48
+ if (requestedProjectId.value !== projectId) return;
49
+
50
+ console.error('Failed to fetch available types for project', projectId, error);
51
+ restrictedNodeTypes.value = new Map();
52
+ loadedProjectId.value = null;
53
+ } finally {
54
+ if (requestedProjectId.value === projectId) isLoading.value = false;
55
+ }
56
+ }
57
+
58
+ function getNodeTypeAvailability(name: string): NodeTypeAvailability {
59
+ if (loadedProjectId.value !== requestedProjectId.value) return { name, available: true };
60
+
61
+ return restrictedNodeTypes.value.get(name) ?? { name, available: true };
62
+ }
63
+
64
+ function isNodeTypeAvailable(name: string): boolean {
65
+ return getNodeTypeAvailability(name).available;
66
+ }
67
+
68
+ function reset(): void {
69
+ restrictedNodeTypes.value = new Map();
70
+ loadedProjectId.value = null;
71
+ requestedProjectId.value = null;
72
+ isLoading.value = false;
73
+ }
74
+
75
+ return {
76
+ isEnabled,
77
+ isLoading,
78
+ loadedProjectId,
79
+ fetchForProject,
80
+ getNodeTypeAvailability,
81
+ isNodeTypeAvailable,
82
+ reset,
83
+ };
84
+ },
85
+ );
package/vite.config.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import vue from '@vitejs/plugin-vue';
2
+ import icons from 'unplugin-icons/vite';
3
+ import svgLoader from 'vite-svg-loader';
2
4
  import { fileURLToPath } from 'node:url';
3
5
  import { resolve } from 'node:path';
4
6
  import { defineConfig, mergeConfig } from 'vite';
@@ -10,7 +12,7 @@ const packagesDir = resolve(packageDir, '..', '..', '..');
10
12
 
11
13
  export default mergeConfig(
12
14
  defineConfig({
13
- plugins: [vue()],
15
+ plugins: [vue(), icons({ compiler: 'vue3' }), svgLoader()],
14
16
  resolve: {
15
17
  // The same platform mapping the editor-ui dev server uses, so a test resolves
16
18
  // `@n8n/stores/...` from source rather than from a stale `dist` — the two disagreeing is