diff --git a/packages/editor-ui/src/routes/projects.routes.ts b/packages/editor-ui/src/routes/projects.routes.ts index b58ab926b2..d9258785a6 100644 --- a/packages/editor-ui/src/routes/projects.routes.ts +++ b/packages/editor-ui/src/routes/projects.routes.ts @@ -1,4 +1,4 @@ -import type { RouteRecordRaw } from 'vue-router'; +import type { RouteLocationNormalized, RouteRecordRaw } from 'vue-router'; import { VIEWS } from '@/constants'; import { useProjectsStore } from '@/stores/projects.store'; 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 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[] = [ { path: 'workflows', @@ -17,7 +25,10 @@ const commonChildRoutes: RouteRecordRaw[] = [ sidebar: MainSidebar, }, meta: { - middleware: ['authenticated'], + middleware: ['authenticated', 'custom'], + middlewareOptions: { + custom: (options) => checkProjectAvailability(options?.to), + }, }, }, { @@ -28,7 +39,10 @@ const commonChildRoutes: RouteRecordRaw[] = [ sidebar: MainSidebar, }, meta: { - middleware: ['authenticated'], + middleware: ['authenticated', 'custom'], + middlewareOptions: { + custom: (options) => checkProjectAvailability(options?.to), + }, }, }, { @@ -38,7 +52,10 @@ const commonChildRoutes: RouteRecordRaw[] = [ sidebar: MainSidebar, }, meta: { - middleware: ['authenticated'], + middleware: ['authenticated', 'custom'], + middlewareOptions: { + custom: (options) => checkProjectAvailability(options?.to), + }, }, }, ]; diff --git a/packages/editor-ui/src/views/CredentialsView.test.ts b/packages/editor-ui/src/views/CredentialsView.test.ts index 878cc108b0..94132f37b5 100644 --- a/packages/editor-ui/src/views/CredentialsView.test.ts +++ b/packages/editor-ui/src/views/CredentialsView.test.ts @@ -1,4 +1,5 @@ import { createComponentRenderer } from '@/__tests__/render'; +import { createTestProject } from '@/__tests__/data/projects'; import { createTestingPinia } from '@pinia/testing'; import { useCredentialsStore } from '@/stores/credentials.store'; import CredentialsView from '@/views/CredentialsView.vue'; @@ -7,10 +8,10 @@ import { mockedStore } from '@/__tests__/utils'; import { waitFor, within, fireEvent } from '@testing-library/vue'; import { CREDENTIAL_SELECT_MODAL_KEY, STORES, VIEWS } from '@/constants'; import { useProjectsStore } from '@/stores/projects.store'; -import type { Project } from '@/types/projects.types'; import { createRouter, createWebHistory } from 'vue-router'; import { flushPromises } from '@vue/test-utils'; import { CREDENTIAL_EMPTY_VALUE } from 'n8n-workflow'; + vi.mock('@/composables/useGlobalEntityCreation', () => ({ useGlobalEntityCreation: () => ({ menu: [], @@ -20,6 +21,11 @@ vi.mock('@/composables/useGlobalEntityCreation', () => ({ const router = createRouter({ history: createWebHistory(), routes: [ + { + name: VIEWS.HOMEPAGE, + path: '/', + component: { template: '
' }, + }, { path: '/:credentialId?', name: VIEWS.CREDENTIALS, @@ -99,25 +105,63 @@ describe('CredentialsView', () => { }); 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); - 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); }); + + 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', () => { - 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); - renderComponent({ props: { credentialId: 'credential-id' } }); - expect(uiStore.openExistingCredential).toHaveBeenCalledWith('credential-id'); + 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: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 () => { const replaceSpy = vi.spyOn(router, 'replace'); const projectsStore = mockedStore(useProjectsStore); projectsStore.isProjectHome = false; - projectsStore.currentProject = { scopes: ['credential:read'] } as Project; + projectsStore.currentProject = createTestProject({ scopes: ['credential:read'] }); const credentialsStore = mockedStore(useCredentialsStore); credentialsStore.allCredentials = [ { diff --git a/packages/editor-ui/src/views/CredentialsView.vue b/packages/editor-ui/src/views/CredentialsView.vue index c8482bd75d..a8c6282d67 100644 --- a/packages/editor-ui/src/views/CredentialsView.vue +++ b/packages/editor-ui/src/views/CredentialsView.vue @@ -12,6 +12,7 @@ import { CREDENTIAL_SELECT_MODAL_KEY, CREDENTIAL_EDIT_MODAL_KEY, EnterpriseEditionFeature, + VIEWS, } from '@/constants'; import { useUIStore, listenForModalChanges } from '@/stores/ui.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 Resource = resource as ICredentialsResponse & { needsSetup: boolean }; const filtersToApply = newFilters as Filters; @@ -163,6 +147,28 @@ const onFilter = (resource: Resource, newFilters: BaseFilters, matches: boolean) 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 () => { loading.value = true; const isVarsEnabled = @@ -177,6 +183,8 @@ const initialize = async () => { ]; await Promise.all(loadPromises); + maybeCreateCredential(); + maybeEditCredential(); loading.value = false; }; @@ -197,6 +205,14 @@ sourceControlStore.$onAction(({ name, after }) => { watch(() => route?.params?.projectId, initialize); +watch( + () => props.credentialId, + () => { + maybeCreateCredential(); + maybeEditCredential(); + }, +); + onMounted(() => { documentTitle.set(i18n.baseText('credentials.heading')); });