mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
feat(editor): Add access control and not found entity views (#15860)
This commit is contained in:
committed by
GitHub
parent
24e4be1ece
commit
80a784a50c
@@ -126,8 +126,7 @@ describe('Sharing', { disableAutoLogin: true }, () => {
|
|||||||
|
|
||||||
cy.visit(workflowW2Url);
|
cy.visit(workflowW2Url);
|
||||||
cy.waitForLoad();
|
cy.waitForLoad();
|
||||||
cy.wait(1000);
|
cy.location('pathname', { timeout: 10000 }).should('eq', '/entity-not-authorized/workflow');
|
||||||
cy.get('.el-notification').contains('Could not find workflow').should('be.visible');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have access to W1, W2, as U1', () => {
|
it('should have access to W1, W2, as U1', () => {
|
||||||
|
|||||||
@@ -708,6 +708,11 @@
|
|||||||
"error": "Error",
|
"error": "Error",
|
||||||
"error.goBack": "Go back",
|
"error.goBack": "Go back",
|
||||||
"error.pageNotFound": "Oops, couldn’t find that",
|
"error.pageNotFound": "Oops, couldn’t find that",
|
||||||
|
"error.entityNotFound.title": "{entity} not found",
|
||||||
|
"error.entityNotFound.text": "We couldn’t find the {entity} you were looking for. Make sure you have the correct URL.",
|
||||||
|
"error.entityNotFound.action": "Go to overview",
|
||||||
|
"error.entityUnAuthorized.title": "You need access",
|
||||||
|
"error.entityUnAuthorized.content": "You don't have permission to view this {entity}. Please contact the person who shared this link to request access.",
|
||||||
"executions.ExecutionStatus": "Execution status",
|
"executions.ExecutionStatus": "Execution status",
|
||||||
"executions.concurrency.docsLink": "https://docs.n8n.io/hosting/scaling/concurrency-control/",
|
"executions.concurrency.docsLink": "https://docs.n8n.io/hosting/scaling/concurrency-control/",
|
||||||
"executionDetails.additionalActions": "Additional Actions",
|
"executionDetails.additionalActions": "Additional Actions",
|
||||||
|
|||||||
@@ -571,6 +571,8 @@ export const enum VIEWS {
|
|||||||
SHARED_WITH_ME = 'SharedWithMe',
|
SHARED_WITH_ME = 'SharedWithMe',
|
||||||
SHARED_WORKFLOWS = 'SharedWorkflows',
|
SHARED_WORKFLOWS = 'SharedWorkflows',
|
||||||
SHARED_CREDENTIALS = 'SharedCredentials',
|
SHARED_CREDENTIALS = 'SharedCredentials',
|
||||||
|
ENTITY_NOT_FOUND = 'EntityNotFound',
|
||||||
|
ENTITY_UNAUTHORIZED = 'EntityUnAuthorized',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EDITABLE_CANVAS_VIEWS = [VIEWS.WORKFLOW, VIEWS.NEW_WORKFLOW, VIEWS.EXECUTION_DEBUG];
|
export const EDITABLE_CANVAS_VIEWS = [VIEWS.WORKFLOW, VIEWS.NEW_WORKFLOW, VIEWS.EXECUTION_DEBUG];
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import TestRunDetailView from '@/views/Evaluations.ee/TestRunDetailView.vue';
|
|||||||
|
|
||||||
const ChangePasswordView = async () => await import('./views/ChangePasswordView.vue');
|
const ChangePasswordView = async () => await import('./views/ChangePasswordView.vue');
|
||||||
const ErrorView = async () => await import('./views/ErrorView.vue');
|
const ErrorView = async () => await import('./views/ErrorView.vue');
|
||||||
|
const EntityNotFound = async () => await import('./views/EntityNotFound.vue');
|
||||||
|
const EntityUnAuthorised = async () => await import('./views/EntityUnAuthorised.vue');
|
||||||
const ForgotMyPasswordView = async () => await import('./views/ForgotMyPasswordView.vue');
|
const ForgotMyPasswordView = async () => await import('./views/ForgotMyPasswordView.vue');
|
||||||
const MainHeader = async () => await import('@/components/MainHeader/MainHeader.vue');
|
const MainHeader = async () => await import('@/components/MainHeader/MainHeader.vue');
|
||||||
const MainSidebar = async () => await import('@/components/MainSidebar.vue');
|
const MainSidebar = async () => await import('@/components/MainSidebar.vue');
|
||||||
@@ -721,6 +723,24 @@ export const routes: RouteRecordRaw[] = [
|
|||||||
},
|
},
|
||||||
...projectsRoutes,
|
...projectsRoutes,
|
||||||
...insightsRoutes,
|
...insightsRoutes,
|
||||||
|
{
|
||||||
|
path: '/entity-not-found/:entityType(credential|workflow)',
|
||||||
|
props: true,
|
||||||
|
name: VIEWS.ENTITY_NOT_FOUND,
|
||||||
|
components: {
|
||||||
|
default: EntityNotFound,
|
||||||
|
sidebar: MainSidebar,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/entity-not-authorized/:entityType(credential|workflow)',
|
||||||
|
props: true,
|
||||||
|
name: VIEWS.ENTITY_UNAUTHORIZED,
|
||||||
|
components: {
|
||||||
|
default: EntityUnAuthorised,
|
||||||
|
sidebar: MainSidebar,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/:pathMatch(.*)*',
|
path: '/:pathMatch(.*)*',
|
||||||
name: VIEWS.NOT_FOUND,
|
name: VIEWS.NOT_FOUND,
|
||||||
|
|||||||
@@ -32,6 +32,16 @@ const router = createRouter({
|
|||||||
name: VIEWS.CREDENTIALS,
|
name: VIEWS.CREDENTIALS,
|
||||||
component: { template: '<div></div>' },
|
component: { template: '<div></div>' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/entity-un-authorized',
|
||||||
|
name: VIEWS.ENTITY_UNAUTHORIZED,
|
||||||
|
component: { template: '<div></div>' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/entity-not-found',
|
||||||
|
name: VIEWS.ENTITY_NOT_FOUND,
|
||||||
|
component: { template: '<div></div>' },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -189,6 +199,41 @@ describe('CredentialsView', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should redirect to unauthorized page if user doesn't have read or update permissions", async () => {
|
||||||
|
const replaceSpy = vi.spyOn(router, 'replace');
|
||||||
|
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: [],
|
||||||
|
}));
|
||||||
|
const { rerender } = renderComponent();
|
||||||
|
await rerender({ credentialId: 'abc123' });
|
||||||
|
expect(replaceSpy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: VIEWS.ENTITY_UNAUTHORIZED,
|
||||||
|
params: { entityType: 'credential' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should redirect to not found page if the credential doesn't exist", async () => {
|
||||||
|
const replaceSpy = vi.spyOn(router, 'replace');
|
||||||
|
const credentialsStore = mockedStore(useCredentialsStore);
|
||||||
|
credentialsStore.getCredentialById = vi.fn().mockImplementation(() => undefined);
|
||||||
|
const { rerender } = renderComponent();
|
||||||
|
await rerender({ credentialId: 'abc123' });
|
||||||
|
expect(replaceSpy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: VIEWS.ENTITY_NOT_FOUND,
|
||||||
|
params: { entityType: 'credential' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
describe('filters', () => {
|
describe('filters', () => {
|
||||||
it('should filter by type', async () => {
|
it('should filter by type', async () => {
|
||||||
await router.push({ name: VIEWS.CREDENTIALS, query: { type: ['test'] } });
|
await router.push({ name: VIEWS.CREDENTIALS, query: { type: ['test'] } });
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import ResourcesListLayout, {
|
|||||||
} from '@/components/layouts/ResourcesListLayout.vue';
|
} from '@/components/layouts/ResourcesListLayout.vue';
|
||||||
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
|
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
|
||||||
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
||||||
import { useI18n } from '@n8n/i18n';
|
|
||||||
import { useProjectPages } from '@/composables/useProjectPages';
|
import { useProjectPages } from '@/composables/useProjectPages';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import {
|
import {
|
||||||
@@ -31,6 +30,7 @@ import { useUsersStore } from '@/stores/users.store';
|
|||||||
import type { Project } from '@/types/projects.types';
|
import type { Project } from '@/types/projects.types';
|
||||||
import { isCredentialsResource } from '@/utils/typeGuards';
|
import { isCredentialsResource } from '@/utils/typeGuards';
|
||||||
import { N8nCheckbox } from '@n8n/design-system';
|
import { N8nCheckbox } from '@n8n/design-system';
|
||||||
|
import { useI18n } from '@n8n/i18n';
|
||||||
import pickBy from 'lodash/pickBy';
|
import pickBy from 'lodash/pickBy';
|
||||||
import type { ICredentialType, ICredentialsDecrypted } from 'n8n-workflow';
|
import type { ICredentialType, ICredentialsDecrypted } from 'n8n-workflow';
|
||||||
import { CREDENTIAL_EMPTY_VALUE } from 'n8n-workflow';
|
import { CREDENTIAL_EMPTY_VALUE } from 'n8n-workflow';
|
||||||
@@ -169,15 +169,26 @@ const maybeCreateCredential = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const maybeEditCredential = () => {
|
const maybeEditCredential = async () => {
|
||||||
if (!!props.credentialId && props.credentialId !== 'create') {
|
if (!!props.credentialId && props.credentialId !== 'create') {
|
||||||
const credential = credentialsStore.getCredentialById(props.credentialId);
|
const credential = credentialsStore.getCredentialById(props.credentialId);
|
||||||
const credentialPermissions = getResourcePermissions(credential?.scopes).credential;
|
const credentialPermissions = getResourcePermissions(credential?.scopes).credential;
|
||||||
if (credential && (credentialPermissions.update || credentialPermissions.read)) {
|
if (!credential) {
|
||||||
uiStore.openExistingCredential(props.credentialId);
|
return await router.replace({
|
||||||
} else {
|
name: VIEWS.ENTITY_NOT_FOUND,
|
||||||
void router.replace({ name: VIEWS.HOMEPAGE });
|
params: { entityType: 'credential' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (credentialPermissions.update || credentialPermissions.read) {
|
||||||
|
uiStore.openExistingCredential(props.credentialId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await router.replace({
|
||||||
|
name: VIEWS.ENTITY_UNAUTHORIZED,
|
||||||
|
params: { entityType: 'credential' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -200,7 +211,7 @@ const initialize = async () => {
|
|||||||
|
|
||||||
await Promise.all(loadPromises);
|
await Promise.all(loadPromises);
|
||||||
maybeCreateCredential();
|
maybeCreateCredential();
|
||||||
maybeEditCredential();
|
await maybeEditCredential();
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -225,7 +236,7 @@ watch(
|
|||||||
() => props.credentialId,
|
() => props.credentialId,
|
||||||
() => {
|
() => {
|
||||||
maybeCreateCredential();
|
maybeCreateCredential();
|
||||||
maybeEditCredential();
|
void maybeEditCredential();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
67
packages/frontend/editor-ui/src/views/EntityNotFound.vue
Normal file
67
packages/frontend/editor-ui/src/views/EntityNotFound.vue
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { N8nButton, N8nCard, N8nHeading, N8nText } from '@n8n/design-system';
|
||||||
|
import { useI18n } from '@n8n/i18n';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
entityType: 'credential' | 'workflow';
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const locale = useI18n();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="entity-not-found-view">
|
||||||
|
<N8nCard style="" class="entity-card">
|
||||||
|
<div class="text-center mb-l">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="var(--color-text-button-secondary-font)"
|
||||||
|
stroke-width="2"
|
||||||
|
/>
|
||||||
|
<rect x="11" y="6" width="2" height="8" fill="var(--color-text-button-secondary-font)" />
|
||||||
|
<rect x="11" y="16" width="2" height="2" fill="var(--color-text-button-secondary-font)" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<N8nHeading size="xlarge" align="center" tag="h2" color="text-dark" class="mb-2xs">
|
||||||
|
{{
|
||||||
|
locale.baseText('error.entityNotFound.title', {
|
||||||
|
interpolate: { entity: locale.baseText(`generic.${entityType}`) },
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</N8nHeading>
|
||||||
|
<N8nText color="text-base" tag="p" align="center" class="mb-m">
|
||||||
|
{{
|
||||||
|
locale.baseText('error.entityNotFound.text', {
|
||||||
|
interpolate: { entity: locale.baseText(`generic.${entityType}`).toLocaleLowerCase() },
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</N8nText>
|
||||||
|
<N8nButton href="/" element="a" type="secondary">
|
||||||
|
{{ locale.baseText('error.entityNotFound.action') }}
|
||||||
|
</N8nButton>
|
||||||
|
</N8nCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.entity-not-found-view {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-card {
|
||||||
|
max-width: 400px;
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
65
packages/frontend/editor-ui/src/views/EntityUnAuthorised.vue
Normal file
65
packages/frontend/editor-ui/src/views/EntityUnAuthorised.vue
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { N8nCard, N8nHeading, N8nText } from '@n8n/design-system';
|
||||||
|
import { useI18n } from '@n8n/i18n';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
entityType: 'credential' | 'workflow';
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const locale = useI18n();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="entity-un-authorized-view">
|
||||||
|
<N8nCard class="entity-card">
|
||||||
|
<div class="text-center mb-l">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M19 11H5C3.89543 11 3 11.8954 3 13V20C3 21.1046 3.89543 22 5 22H19C20.1046 22 21 21.1046 21 20V13C21 11.8954 20.1046 11 19 11Z"
|
||||||
|
stroke="var(--color-text-button-secondary-font)"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M7 11V7C7 5.67392 7.52678 4.40215 8.46447 3.46447C9.40215 2.52678 10.6739 2 12 2C13.3261 2 14.5979 2.52678 15.5355 3.46447C16.4732 4.40215 17 5.67392 17 7V11"
|
||||||
|
stroke="var(--color-text-button-secondary-font)"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<N8nHeading size="xlarge" align="center" tag="h2" color="text-dark" class="mb-2xs">
|
||||||
|
{{ locale.baseText('error.entityUnAuthorized.title') }}
|
||||||
|
</N8nHeading>
|
||||||
|
<N8nText color="text-base" tag="p" align="center">
|
||||||
|
{{
|
||||||
|
locale.baseText('error.entityUnAuthorized.content', {
|
||||||
|
interpolate: { entity: locale.baseText(`generic.${entityType}`).toLocaleLowerCase() },
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</N8nText>
|
||||||
|
</N8nCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.entity-un-authorized-view {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-card {
|
||||||
|
max-width: 400px;
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -468,8 +468,20 @@ async function initializeWorkspaceForExistingWorkflow(id: string) {
|
|||||||
|
|
||||||
await projectsStore.setProjectNavActiveIdByWorkflowHomeProject(workflowData.homeProject);
|
await projectsStore.setProjectNavActiveIdByWorkflowHomeProject(workflowData.homeProject);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.showError(error, i18n.baseText('openWorkflow.workflowNotFoundError'));
|
if (error.httpStatusCode === 404) {
|
||||||
|
return await router.replace({
|
||||||
|
name: VIEWS.ENTITY_NOT_FOUND,
|
||||||
|
params: { entityType: 'workflow' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (error.httpStatusCode === 403) {
|
||||||
|
return await router.replace({
|
||||||
|
name: VIEWS.ENTITY_UNAUTHORIZED,
|
||||||
|
params: { entityType: 'workflow' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.showError(error, i18n.baseText('openWorkflow.workflowNotFoundError'));
|
||||||
void router.push({
|
void router.push({
|
||||||
name: VIEWS.NEW_WORKFLOW,
|
name: VIEWS.NEW_WORKFLOW,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user