feat(editor): Add access control and not found entity views (#15860)

This commit is contained in:
Raúl Gómez Morales
2025-06-13 14:17:05 +02:00
committed by GitHub
parent 24e4be1ece
commit 80a784a50c
9 changed files with 237 additions and 11 deletions

View File

@@ -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', () => {

View File

@@ -708,6 +708,11 @@
"error": "Error", "error": "Error",
"error.goBack": "Go back", "error.goBack": "Go back",
"error.pageNotFound": "Oops, couldnt find that", "error.pageNotFound": "Oops, couldnt find that",
"error.entityNotFound.title": "{entity} not found",
"error.entityNotFound.text": "We couldnt 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",

View File

@@ -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];

View File

@@ -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,

View File

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

View File

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

View 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>

View 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>

View File

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