mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
fix(editor): Do not show credential details popup for users without necessary scopes with direct link (#13264)
Co-authored-by: Raúl Gómez Morales <raul00gm@gmail.com>
This commit is contained in:
parent
fa488f1561
commit
a5401d06a5
|
@ -1,4 +1,4 @@
|
||||||
import type { RouteRecordRaw } from 'vue-router';
|
import type { RouteLocationNormalized, RouteRecordRaw } from 'vue-router';
|
||||||
import { VIEWS } from '@/constants';
|
import { VIEWS } from '@/constants';
|
||||||
import { useProjectsStore } from '@/stores/projects.store';
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
import { getResourcePermissions } from '@/permissions';
|
import { getResourcePermissions } from '@/permissions';
|
||||||
|
@ -9,6 +9,14 @@ const CredentialsView = async () => await import('@/views/CredentialsView.vue');
|
||||||
const ProjectSettings = async () => await import('@/views/ProjectSettings.vue');
|
const ProjectSettings = async () => await import('@/views/ProjectSettings.vue');
|
||||||
const ExecutionsView = async () => await import('@/views/ExecutionsView.vue');
|
const ExecutionsView = async () => await import('@/views/ExecutionsView.vue');
|
||||||
|
|
||||||
|
const checkProjectAvailability = (to?: RouteLocationNormalized): boolean => {
|
||||||
|
if (!to?.params.projectId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const project = useProjectsStore().myProjects.find((p) => to?.params.projectId === p.id);
|
||||||
|
return !!project;
|
||||||
|
};
|
||||||
|
|
||||||
const commonChildRoutes: RouteRecordRaw[] = [
|
const commonChildRoutes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
path: 'workflows',
|
path: 'workflows',
|
||||||
|
@ -17,7 +25,10 @@ const commonChildRoutes: RouteRecordRaw[] = [
|
||||||
sidebar: MainSidebar,
|
sidebar: MainSidebar,
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
middleware: ['authenticated'],
|
middleware: ['authenticated', 'custom'],
|
||||||
|
middlewareOptions: {
|
||||||
|
custom: (options) => checkProjectAvailability(options?.to),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -28,7 +39,10 @@ const commonChildRoutes: RouteRecordRaw[] = [
|
||||||
sidebar: MainSidebar,
|
sidebar: MainSidebar,
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
middleware: ['authenticated'],
|
middleware: ['authenticated', 'custom'],
|
||||||
|
middlewareOptions: {
|
||||||
|
custom: (options) => checkProjectAvailability(options?.to),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -38,7 +52,10 @@ const commonChildRoutes: RouteRecordRaw[] = [
|
||||||
sidebar: MainSidebar,
|
sidebar: MainSidebar,
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
middleware: ['authenticated'],
|
middleware: ['authenticated', 'custom'],
|
||||||
|
middlewareOptions: {
|
||||||
|
custom: (options) => checkProjectAvailability(options?.to),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { createComponentRenderer } from '@/__tests__/render';
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
|
import { createTestProject } from '@/__tests__/data/projects';
|
||||||
import { createTestingPinia } from '@pinia/testing';
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||||
import CredentialsView from '@/views/CredentialsView.vue';
|
import CredentialsView from '@/views/CredentialsView.vue';
|
||||||
|
@ -7,10 +8,10 @@ import { mockedStore } from '@/__tests__/utils';
|
||||||
import { waitFor, within, fireEvent } from '@testing-library/vue';
|
import { waitFor, within, fireEvent } from '@testing-library/vue';
|
||||||
import { CREDENTIAL_SELECT_MODAL_KEY, STORES, VIEWS } from '@/constants';
|
import { CREDENTIAL_SELECT_MODAL_KEY, STORES, VIEWS } from '@/constants';
|
||||||
import { useProjectsStore } from '@/stores/projects.store';
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
import type { Project } from '@/types/projects.types';
|
|
||||||
import { createRouter, createWebHistory } from 'vue-router';
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
import { flushPromises } from '@vue/test-utils';
|
import { flushPromises } from '@vue/test-utils';
|
||||||
import { CREDENTIAL_EMPTY_VALUE } from 'n8n-workflow';
|
import { CREDENTIAL_EMPTY_VALUE } from 'n8n-workflow';
|
||||||
|
|
||||||
vi.mock('@/composables/useGlobalEntityCreation', () => ({
|
vi.mock('@/composables/useGlobalEntityCreation', () => ({
|
||||||
useGlobalEntityCreation: () => ({
|
useGlobalEntityCreation: () => ({
|
||||||
menu: [],
|
menu: [],
|
||||||
|
@ -20,6 +21,11 @@ vi.mock('@/composables/useGlobalEntityCreation', () => ({
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
routes: [
|
routes: [
|
||||||
|
{
|
||||||
|
name: VIEWS.HOMEPAGE,
|
||||||
|
path: '/',
|
||||||
|
component: { template: '<div></div>' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/:credentialId?',
|
path: '/:credentialId?',
|
||||||
name: VIEWS.CREDENTIALS,
|
name: VIEWS.CREDENTIALS,
|
||||||
|
@ -99,25 +105,63 @@ describe('CredentialsView', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('create credential', () => {
|
describe('create credential', () => {
|
||||||
it('should show modal based on route param', async () => {
|
it('should show the modal on the route if the user has the scope to create credentials in the project.', async () => {
|
||||||
const uiStore = mockedStore(useUIStore);
|
const uiStore = mockedStore(useUIStore);
|
||||||
renderComponent({ props: { credentialId: 'create' } });
|
const projectsStore = mockedStore(useProjectsStore);
|
||||||
|
projectsStore.currentProject = createTestProject({ scopes: ['credential:create'] });
|
||||||
|
const { rerender } = renderComponent();
|
||||||
|
await rerender({ credentialId: 'create' });
|
||||||
expect(uiStore.openModal).toHaveBeenCalledWith(CREDENTIAL_SELECT_MODAL_KEY);
|
expect(uiStore.openModal).toHaveBeenCalledWith(CREDENTIAL_SELECT_MODAL_KEY);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not show the modal on the route if the user has no scope to create credential in the project', async () => {
|
||||||
|
const uiStore = mockedStore(useUIStore);
|
||||||
|
const projectsStore = mockedStore(useProjectsStore);
|
||||||
|
projectsStore.currentProject = createTestProject({ scopes: ['credential:read'] });
|
||||||
|
const { rerender } = renderComponent();
|
||||||
|
await rerender({ credentialId: 'create' });
|
||||||
|
expect(uiStore.openModal).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('open existing credential', () => {
|
describe('open existing credential', () => {
|
||||||
it('should show modal based on route param', async () => {
|
it('should show the modal on the route if the user has permission to read or update', async () => {
|
||||||
const uiStore = mockedStore(useUIStore);
|
const uiStore = mockedStore(useUIStore);
|
||||||
renderComponent({ props: { credentialId: 'credential-id' } });
|
const credentialsStore = mockedStore(useCredentialsStore);
|
||||||
expect(uiStore.openExistingCredential).toHaveBeenCalledWith('credential-id');
|
credentialsStore.getCredentialById = vi.fn().mockImplementation(() => ({
|
||||||
|
id: 'abc123',
|
||||||
|
name: 'test',
|
||||||
|
type: 'test',
|
||||||
|
createdAt: '2021-05-05T00:00:00Z',
|
||||||
|
updatedAt: '2021-05-05T00:00:00Z',
|
||||||
|
scopes: ['credential:read'],
|
||||||
|
}));
|
||||||
|
const { rerender } = renderComponent();
|
||||||
|
await rerender({ credentialId: 'abc123' });
|
||||||
|
expect(uiStore.openExistingCredential).toHaveBeenCalledWith('abc123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not show the modal on the route if the user has no permission to read or update', async () => {
|
||||||
|
const uiStore = mockedStore(useUIStore);
|
||||||
|
const credentialsStore = mockedStore(useCredentialsStore);
|
||||||
|
credentialsStore.getCredentialById = vi.fn().mockImplementation(() => ({
|
||||||
|
id: 'abc123',
|
||||||
|
name: 'test',
|
||||||
|
type: 'test',
|
||||||
|
createdAt: '2021-05-05T00:00:00Z',
|
||||||
|
updatedAt: '2021-05-05T00:00:00Z',
|
||||||
|
scopes: ['credential:list'],
|
||||||
|
}));
|
||||||
|
const { rerender } = renderComponent();
|
||||||
|
await rerender({ credentialId: 'abc123' });
|
||||||
|
expect(uiStore.openExistingCredential).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update credentialId route param when opened', async () => {
|
it('should update credentialId route param when opened', async () => {
|
||||||
const replaceSpy = vi.spyOn(router, 'replace');
|
const replaceSpy = vi.spyOn(router, 'replace');
|
||||||
const projectsStore = mockedStore(useProjectsStore);
|
const projectsStore = mockedStore(useProjectsStore);
|
||||||
projectsStore.isProjectHome = false;
|
projectsStore.isProjectHome = false;
|
||||||
projectsStore.currentProject = { scopes: ['credential:read'] } as Project;
|
projectsStore.currentProject = createTestProject({ scopes: ['credential:read'] });
|
||||||
const credentialsStore = mockedStore(useCredentialsStore);
|
const credentialsStore = mockedStore(useCredentialsStore);
|
||||||
credentialsStore.allCredentials = [
|
credentialsStore.allCredentials = [
|
||||||
{
|
{
|
||||||
|
|
|
@ -12,6 +12,7 @@ import {
|
||||||
CREDENTIAL_SELECT_MODAL_KEY,
|
CREDENTIAL_SELECT_MODAL_KEY,
|
||||||
CREDENTIAL_EDIT_MODAL_KEY,
|
CREDENTIAL_EDIT_MODAL_KEY,
|
||||||
EnterpriseEditionFeature,
|
EnterpriseEditionFeature,
|
||||||
|
VIEWS,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
import { useUIStore, listenForModalChanges } from '@/stores/ui.store';
|
import { useUIStore, listenForModalChanges } from '@/stores/ui.store';
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
|
@ -123,23 +124,6 @@ listenForModalChanges({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.credentialId,
|
|
||||||
(id) => {
|
|
||||||
if (!id) return;
|
|
||||||
|
|
||||||
if (id === 'create') {
|
|
||||||
uiStore.openModal(CREDENTIAL_SELECT_MODAL_KEY);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
uiStore.openExistingCredential(id);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
immediate: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const onFilter = (resource: Resource, newFilters: BaseFilters, matches: boolean): boolean => {
|
const onFilter = (resource: Resource, newFilters: BaseFilters, matches: boolean): boolean => {
|
||||||
const Resource = resource as ICredentialsResponse & { needsSetup: boolean };
|
const Resource = resource as ICredentialsResponse & { needsSetup: boolean };
|
||||||
const filtersToApply = newFilters as Filters;
|
const filtersToApply = newFilters as Filters;
|
||||||
|
@ -163,6 +147,28 @@ const onFilter = (resource: Resource, newFilters: BaseFilters, matches: boolean)
|
||||||
return matches;
|
return matches;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const maybeCreateCredential = () => {
|
||||||
|
if (props.credentialId === 'create') {
|
||||||
|
if (projectPermissions.value.credential.create) {
|
||||||
|
uiStore.openModal(CREDENTIAL_SELECT_MODAL_KEY);
|
||||||
|
} else {
|
||||||
|
void router.replace({ name: VIEWS.HOMEPAGE });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const maybeEditCredential = () => {
|
||||||
|
if (!!props.credentialId && props.credentialId !== 'create') {
|
||||||
|
const credential = credentialsStore.getCredentialById(props.credentialId);
|
||||||
|
const credentialPermissions = getResourcePermissions(credential?.scopes).credential;
|
||||||
|
if (credential && (credentialPermissions.update || credentialPermissions.read)) {
|
||||||
|
uiStore.openExistingCredential(props.credentialId);
|
||||||
|
} else {
|
||||||
|
void router.replace({ name: VIEWS.HOMEPAGE });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const initialize = async () => {
|
const initialize = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
const isVarsEnabled =
|
const isVarsEnabled =
|
||||||
|
@ -177,6 +183,8 @@ const initialize = async () => {
|
||||||
];
|
];
|
||||||
|
|
||||||
await Promise.all(loadPromises);
|
await Promise.all(loadPromises);
|
||||||
|
maybeCreateCredential();
|
||||||
|
maybeEditCredential();
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -197,6 +205,14 @@ sourceControlStore.$onAction(({ name, after }) => {
|
||||||
|
|
||||||
watch(() => route?.params?.projectId, initialize);
|
watch(() => route?.params?.projectId, initialize);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.credentialId,
|
||||||
|
() => {
|
||||||
|
maybeCreateCredential();
|
||||||
|
maybeEditCredential();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
documentTitle.set(i18n.baseText('credentials.heading'));
|
documentTitle.set(i18n.baseText('credentials.heading'));
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue