feat(editor): Add routing middleware, permission checks, RBAC store, RBAC component (#7702)
Github issue / Community forum post (link here to close automatically): --------- Co-authored-by: Csaba Tuncsik <csaba@n8n.io>
This commit is contained in:
@@ -122,6 +122,8 @@ import {
|
||||
import { isNavigationFailure } from 'vue-router';
|
||||
import ExecutionsUsage from '@/components/ExecutionsUsage.vue';
|
||||
import MainSidebarSourceControl from '@/components/MainSidebarSourceControl.vue';
|
||||
import { ROLE } from '@/utils';
|
||||
import { hasPermission } from '@/rbac/permissions';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'MainSidebar',
|
||||
@@ -177,7 +179,9 @@ export default defineComponent({
|
||||
return accessibleRoute !== null;
|
||||
},
|
||||
showUserArea(): boolean {
|
||||
return this.usersStore.canUserAccessSidebarUserInfo && this.usersStore.currentUser !== null;
|
||||
return hasPermission(['role'], {
|
||||
role: [ROLE.Member, ROLE.Owner],
|
||||
});
|
||||
},
|
||||
workflowExecution(): IExecutionResponse | null {
|
||||
return this.workflowsStore.getWorkflowExecution;
|
||||
@@ -347,7 +351,7 @@ export default defineComponent({
|
||||
};
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
async mounted() {
|
||||
this.basePath = this.rootStore.baseUrl;
|
||||
if (this.$refs.user) {
|
||||
void this.$externalHooks().run('mainSidebar.mounted', {
|
||||
|
||||
61
packages/editor-ui/src/components/RBAC.vue
Normal file
61
packages/editor-ui/src/components/RBAC.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import type { PropType } from 'vue';
|
||||
import { computed, defineComponent } from 'vue';
|
||||
import { useRBACStore } from '@/stores/rbac.store';
|
||||
import type { HasScopeMode, Scope, Resource } from '@n8n/permissions';
|
||||
import {
|
||||
inferProjectIdFromRoute,
|
||||
inferResourceIdFromRoute,
|
||||
inferResourceTypeFromRoute,
|
||||
} from '@/utils/rbacUtils';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
scope: {
|
||||
type: [String, Array] as PropType<Scope | Scope[]>,
|
||||
required: true,
|
||||
},
|
||||
mode: {
|
||||
type: String as PropType<HasScopeMode>,
|
||||
default: 'allOf',
|
||||
},
|
||||
resourceType: {
|
||||
type: String as PropType<Resource>,
|
||||
default: undefined,
|
||||
},
|
||||
resourceId: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
projectId: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
const rbacStore = useRBACStore();
|
||||
const route = useRoute();
|
||||
|
||||
const hasScope = computed(() => {
|
||||
const projectId = props.projectId ?? inferProjectIdFromRoute(route);
|
||||
const resourceType = props.resourceType ?? inferResourceTypeFromRoute(route);
|
||||
const resourceId = resourceType
|
||||
? props.resourceId ?? inferResourceIdFromRoute(route)
|
||||
: undefined;
|
||||
|
||||
return rbacStore.hasScope(
|
||||
props.scope,
|
||||
{
|
||||
projectId,
|
||||
resourceType,
|
||||
resourceId,
|
||||
},
|
||||
{ mode: props.mode },
|
||||
);
|
||||
});
|
||||
|
||||
return () => (hasScope.value ? slots.default?.() : slots.fallback?.());
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -30,6 +30,7 @@ import TagsTableHeader from '@/components/TagsManager/TagsView/TagsTableHeader.v
|
||||
import TagsTable from '@/components/TagsManager/TagsView/TagsTable.vue';
|
||||
import { mapStores } from 'pinia';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { useRBACStore } from '@/stores/rbac.store';
|
||||
|
||||
const matches = (name: string, filter: string) =>
|
||||
name.toLowerCase().trim().includes(filter.toLowerCase().trim());
|
||||
@@ -50,7 +51,7 @@ export default defineComponent({
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useUsersStore),
|
||||
...mapStores(useUsersStore, useRBACStore),
|
||||
isCreateEnabled(): boolean {
|
||||
return (this.tags || []).length === 0 || this.createEnabled;
|
||||
},
|
||||
@@ -70,7 +71,7 @@ export default defineComponent({
|
||||
disable: disabled && tag.id !== this.deleteId && tag.id !== this.updateId,
|
||||
update: disabled && tag.id === this.updateId,
|
||||
delete: disabled && tag.id === this.deleteId,
|
||||
canDelete: this.usersStore.canUserDeleteTags,
|
||||
canDelete: this.rbacStore.hasScope('tag:delete'),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
50
packages/editor-ui/src/components/__tests__/RBAC.test.ts
Normal file
50
packages/editor-ui/src/components/__tests__/RBAC.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import RBAC from '@/components/RBAC.vue';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { useRBACStore } from '@/stores/rbac.store';
|
||||
|
||||
const renderComponent = createComponentRenderer(RBAC);
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: vi.fn(() => ({
|
||||
path: '/workflows',
|
||||
params: {},
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/rbac.store', () => ({
|
||||
useRBACStore: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('RBAC', () => {
|
||||
it('renders default slot when hasScope is true', async () => {
|
||||
vi.mocked(useRBACStore).mockImplementation(() => ({
|
||||
hasScope: () => true,
|
||||
}));
|
||||
|
||||
const wrapper = renderComponent({
|
||||
props: { scope: 'worfklow:list' },
|
||||
slots: {
|
||||
default: 'Default Content',
|
||||
fallback: 'Fallback Content',
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.getByText('Default Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders fallback slot when hasScope is false', async () => {
|
||||
vi.mocked(useRBACStore).mockImplementation(() => ({
|
||||
hasScope: () => false,
|
||||
}));
|
||||
|
||||
const wrapper = renderComponent({
|
||||
props: { scope: 'worfklow:list' },
|
||||
slots: {
|
||||
default: 'Default Content',
|
||||
fallback: 'Fallback Content',
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.getByText('Fallback Content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user