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:
Csaba Tuncsik 2025-02-17 16:16:47 +01:00 committed by GitHub
parent fa488f1561
commit a5401d06a5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 105 additions and 28 deletions

View file

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

View file

@ -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 = [
{ {

View file

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