mirror of
https://github.com/n8n-io/n8n.git
synced 2024-09-11 13:15:28 +03:00
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:
parent
fdb2c18ecc
commit
67a88914f2
@ -12,14 +12,14 @@ export function hasScope(
|
||||
): boolean;
|
||||
export function hasScope(
|
||||
scope: Scope | Scope[],
|
||||
userScopes: unknown,
|
||||
userScopes: GlobalScopes | ScopeLevels,
|
||||
options: ScopeOptions = { mode: 'oneOf' },
|
||||
): boolean {
|
||||
if (!Array.isArray(scope)) {
|
||||
scope = [scope];
|
||||
}
|
||||
|
||||
const userScopeSet = new Set(Object.values(userScopes ?? {}).flat());
|
||||
const userScopeSet = new Set(Object.values(userScopes).flat());
|
||||
|
||||
if (options.mode === 'allOf') {
|
||||
return !!scope.length && scope.every((s) => userScopeSet.has(s));
|
||||
|
@ -10,8 +10,9 @@ export type Resource =
|
||||
|
||||
export type ResourceScope<
|
||||
R extends Resource,
|
||||
Operations extends string = DefaultOperations,
|
||||
> = `${R}:${Operations}`;
|
||||
Operation extends string = DefaultOperations,
|
||||
> = `${R}:${Operation}`;
|
||||
|
||||
export type WildcardScope = `${Resource}:*` | '*';
|
||||
|
||||
export type WorkflowScope = ResourceScope<'workflow', DefaultOperations | 'share'>;
|
||||
|
@ -55,7 +55,9 @@ import {
|
||||
N8nUserStack,
|
||||
} from './components';
|
||||
|
||||
export const N8nPlugin: Plugin<{}> = {
|
||||
export interface N8nPluginOptions {}
|
||||
|
||||
export const N8nPlugin: Plugin<N8nPluginOptions> = {
|
||||
install: (app) => {
|
||||
app.component('n8n-action-box', N8nActionBox);
|
||||
app.component('n8n-action-dropdown', N8nActionDropdown);
|
||||
|
@ -47,6 +47,7 @@
|
||||
"@jsplumb/util": "^5.13.2",
|
||||
"@lezer/common": "^1.0.4",
|
||||
"@n8n/codemirror-lang-sql": "^1.0.2",
|
||||
"@n8n/permissions": "workspace:*",
|
||||
"@vueuse/components": "^10.5.0",
|
||||
"@vueuse/core": "^10.5.0",
|
||||
"axios": "^0.21.1",
|
||||
|
@ -35,7 +35,6 @@
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { mapStores } from 'pinia';
|
||||
import { extendExternalHooks } from '@/mixins/externalHooks';
|
||||
import { newVersions } from '@/mixins/newVersions';
|
||||
|
||||
import BannerStack from '@/components/banners/BannerStack.vue';
|
||||
@ -62,6 +61,7 @@ import {
|
||||
import { useHistoryHelper } from '@/composables/useHistoryHelper';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { runExternalHook } from '@/utils';
|
||||
import { initializeAuthenticatedFeatures } from '@/init';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'App',
|
||||
@ -114,69 +114,22 @@ export default defineComponent({
|
||||
console.log(HIRING_BANNER);
|
||||
}
|
||||
},
|
||||
async initializeCloudData() {
|
||||
await this.cloudPlanStore.checkForCloudPlanData();
|
||||
await this.cloudPlanStore.fetchUserCloudAccount();
|
||||
},
|
||||
async initializeTemplates() {
|
||||
if (this.settingsStore.isTemplatesEnabled) {
|
||||
try {
|
||||
await this.settingsStore.testTemplatesEndpoint();
|
||||
} catch (e) {}
|
||||
}
|
||||
},
|
||||
async initializeSourceControl() {
|
||||
if (this.sourceControlStore.isEnterpriseSourceControlEnabled) {
|
||||
await this.sourceControlStore.getPreferences();
|
||||
}
|
||||
},
|
||||
async initializeNodeTranslationHeaders() {
|
||||
if (this.defaultLocale !== 'en') {
|
||||
await this.nodeTypesStore.getNodeTranslationHeaders();
|
||||
}
|
||||
},
|
||||
async initializeHooks(): Promise<void> {
|
||||
if (this.settingsStore.isCloudDeployment) {
|
||||
const { n8nCloudHooks } = await import('@/hooks/cloud');
|
||||
extendExternalHooks(n8nCloudHooks);
|
||||
}
|
||||
},
|
||||
async onAfterAuthenticate() {
|
||||
if (this.onAfterAuthenticateInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.usersStore.currentUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
this.initializeSourceControl(),
|
||||
this.initializeTemplates(),
|
||||
this.initializeNodeTranslationHeaders(),
|
||||
]);
|
||||
|
||||
this.onAfterAuthenticateInitialized = true;
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
this.logHiringBanner();
|
||||
|
||||
await this.settingsStore.initialize();
|
||||
await this.initializeHooks();
|
||||
await this.initializeCloudData();
|
||||
|
||||
void this.checkForNewVersions();
|
||||
void this.onAfterAuthenticate();
|
||||
void initializeAuthenticatedFeatures();
|
||||
|
||||
void runExternalHook('app.mount');
|
||||
this.pushStore.pushConnect();
|
||||
this.loading = false;
|
||||
},
|
||||
watch: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
async 'usersStore.currentUser'(currentValue, previousValue) {
|
||||
if (currentValue && !previousValue) {
|
||||
await this.onAfterAuthenticate();
|
||||
await initializeAuthenticatedFeatures();
|
||||
}
|
||||
},
|
||||
defaultLocale(newLocale) {
|
||||
|
@ -49,6 +49,7 @@ import type {
|
||||
import type { BulkCommand, Undoable } from '@/models/history';
|
||||
import type { PartialBy, TupleToUnion } from '@/utils/typeHelpers';
|
||||
import type { Component } from 'vue';
|
||||
import type { Scope } from '@n8n/permissions';
|
||||
import type { runExternalHook } from '@/utils';
|
||||
|
||||
export * from 'n8n-design-system/types';
|
||||
@ -682,6 +683,7 @@ export interface IUserResponse {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
};
|
||||
globalScopes?: Scope[];
|
||||
personalizationAnswers?: IPersonalizationSurveyVersions | null;
|
||||
isPending: boolean;
|
||||
signInType?: SignInType;
|
||||
@ -1682,6 +1684,7 @@ export declare namespace Cloud {
|
||||
}
|
||||
|
||||
export interface CloudPlanState {
|
||||
initialized: boolean;
|
||||
data: Cloud.PlanData | null;
|
||||
usage: InstanceUsage | null;
|
||||
loadingPlan: boolean;
|
||||
|
@ -44,4 +44,14 @@ describe('parsePermissionsTable()', () => {
|
||||
|
||||
expect(permissions.canRead).toBe(true);
|
||||
});
|
||||
|
||||
it('should pass permission to test functions', () => {
|
||||
const permissions = parsePermissionsTable(user, [
|
||||
{ name: 'canRead', test: (p) => !!p.isInstanceOwner },
|
||||
{ name: 'canUpdate', test: (p) => !!p.canRead },
|
||||
]);
|
||||
|
||||
expect(permissions.canRead).toBe(true);
|
||||
expect(permissions.canUpdate).toBe(true);
|
||||
});
|
||||
});
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
@ -567,6 +567,7 @@ export const enum STORES {
|
||||
WEBHOOKS = 'webhooks',
|
||||
HISTORY = 'history',
|
||||
CLOUD_PLAN = 'cloudPlan',
|
||||
RBAC = 'rbac',
|
||||
COLLABORATION = 'collaboration',
|
||||
PUSH = 'push',
|
||||
}
|
||||
|
12
packages/editor-ui/src/hooks/register.ts
Normal file
12
packages/editor-ui/src/hooks/register.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { extendExternalHooks } from '@/mixins/externalHooks';
|
||||
|
||||
let cloudHooksInitialized = false;
|
||||
export async function initializeCloudHooks() {
|
||||
if (cloudHooksInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { n8nCloudHooks } = await import('@/hooks/cloud');
|
||||
extendExternalHooks(n8nCloudHooks);
|
||||
cloudHooksInitialized = true;
|
||||
}
|
67
packages/editor-ui/src/init.ts
Normal file
67
packages/editor-ui/src/init.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { useRootStore } from '@/stores/n8nRoot.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { initializeCloudHooks } from '@/hooks/register';
|
||||
|
||||
let coreInitialized = false;
|
||||
let authenticatedFeaturesInitialized = false;
|
||||
|
||||
/**
|
||||
* Initializes the core application stores and hooks
|
||||
* This is called once, when the first route is loaded.
|
||||
*/
|
||||
export async function initializeCore() {
|
||||
if (coreInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
const cloudPlanStore = useCloudPlanStore();
|
||||
const usersStore = useUsersStore();
|
||||
|
||||
await settingsStore.initialize();
|
||||
await usersStore.initialize();
|
||||
if (settingsStore.isCloudDeployment) {
|
||||
await Promise.all([cloudPlanStore.initialize(), initializeCloudHooks()]);
|
||||
}
|
||||
|
||||
coreInitialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the features of the application that require an authenticated user
|
||||
*/
|
||||
export async function initializeAuthenticatedFeatures() {
|
||||
if (authenticatedFeaturesInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const usersStore = useUsersStore();
|
||||
if (!usersStore.currentUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceControlStore = useSourceControlStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const rootStore = useRootStore();
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
|
||||
if (sourceControlStore.isEnterpriseSourceControlEnabled) {
|
||||
await sourceControlStore.getPreferences();
|
||||
}
|
||||
|
||||
if (settingsStore.isTemplatesEnabled) {
|
||||
try {
|
||||
await settingsStore.testTemplatesEndpoint();
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
if (rootStore.defaultLocale !== 'en') {
|
||||
await nodeTypesStore.getNodeTranslationHeaders();
|
||||
}
|
||||
|
||||
authenticatedFeaturesInitialized = true;
|
||||
}
|
@ -1,27 +1,30 @@
|
||||
import type { IPermissions } from '@/Interface';
|
||||
import { isAuthorized } from '@/utils';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { defineComponent } from 'vue';
|
||||
import type { RouteLocation } from 'vue-router';
|
||||
import { hasPermission } from '@/rbac/permissions';
|
||||
import type { RouteConfig } from '@/types/router';
|
||||
import type { PermissionTypeOptions } from '@/types/rbac';
|
||||
|
||||
export const userHelpers = defineComponent({
|
||||
methods: {
|
||||
canUserAccessRouteByName(name: string): boolean {
|
||||
canUserAccessRouteByName(name: string) {
|
||||
const route = this.$router.resolve({ name });
|
||||
|
||||
return this.canUserAccessRoute(route);
|
||||
},
|
||||
|
||||
canUserAccessCurrentRoute(): boolean {
|
||||
canUserAccessCurrentRoute() {
|
||||
return this.canUserAccessRoute(this.$route);
|
||||
},
|
||||
|
||||
canUserAccessRoute(route: RouteLocation): boolean {
|
||||
const permissions: IPermissions = route.meta?.permissions;
|
||||
const usersStore = useUsersStore();
|
||||
const currentUser = usersStore.currentUser;
|
||||
canUserAccessRoute(route: RouteLocation & RouteConfig) {
|
||||
const middleware = route.meta?.middleware;
|
||||
const middlewareOptions = route.meta?.middlewareOptions;
|
||||
|
||||
return permissions && isAuthorized(permissions, currentUser);
|
||||
if (!middleware) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return hasPermission(middleware, middlewareOptions as PermissionTypeOptions | undefined);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
@ -6,7 +6,13 @@
|
||||
|
||||
import type { IUser, ICredentialsResponse, IWorkflowDb } from '@/Interface';
|
||||
import { EnterpriseEditionFeature, PLACEHOLDER_EMPTY_WORKFLOW_ID } from '@/constants';
|
||||
import { useSettingsStore } from './stores/settings.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useRBACStore } from '@/stores/rbac.store';
|
||||
|
||||
/**
|
||||
* Old permissions implementation
|
||||
* @deprecated
|
||||
*/
|
||||
|
||||
export const enum UserRole {
|
||||
InstanceOwner = 'isInstanceOwner',
|
||||
@ -36,15 +42,20 @@ export const parsePermissionsTable = (
|
||||
user: IUser | null,
|
||||
table: IPermissionsTable,
|
||||
): IPermissions => {
|
||||
const genericTable = [{ name: UserRole.InstanceOwner, test: () => user?.isOwner }];
|
||||
const genericTable: IPermissionsTable = [
|
||||
{ name: UserRole.InstanceOwner, test: () => !!user?.isOwner },
|
||||
];
|
||||
|
||||
return [...genericTable, ...table].reduce((permissions: IPermissions, row) => {
|
||||
permissions[row.name] = Array.isArray(row.test)
|
||||
? row.test.some((ability) => permissions[ability])
|
||||
: (row.test as IPermissionsTableRowTestFn)(permissions);
|
||||
return [...genericTable, ...table].reduce(
|
||||
(permissions: IPermissions, row: IPermissionsTableRow) => {
|
||||
permissions[row.name] = Array.isArray(row.test)
|
||||
? row.test.some((ability) => permissions[ability])
|
||||
: row.test(permissions);
|
||||
|
||||
return permissions;
|
||||
}, {});
|
||||
return permissions;
|
||||
},
|
||||
{},
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
@ -60,17 +71,12 @@ export const getCredentialPermissions = (user: IUser | null, credential: ICreden
|
||||
const table: IPermissionsTable = [
|
||||
{
|
||||
name: UserRole.ResourceOwner,
|
||||
test: () =>
|
||||
!!(credential?.ownedBy && credential.ownedBy.id === user?.id) || !isSharingEnabled,
|
||||
test: () => !!(credential?.ownedBy?.id === user?.id) || !isSharingEnabled,
|
||||
},
|
||||
{
|
||||
name: UserRole.ResourceSharee,
|
||||
test: () => !!credential?.sharedWith?.find((sharee) => sharee.id === user?.id),
|
||||
},
|
||||
{
|
||||
name: 'read',
|
||||
test: [UserRole.ResourceOwner, UserRole.InstanceOwner, UserRole.ResourceSharee],
|
||||
},
|
||||
{ name: 'save', test: [UserRole.ResourceOwner, UserRole.InstanceOwner] },
|
||||
{ name: 'updateName', test: [UserRole.ResourceOwner, UserRole.InstanceOwner] },
|
||||
{ name: 'updateConnection', test: [UserRole.ResourceOwner] },
|
||||
@ -85,6 +91,7 @@ export const getCredentialPermissions = (user: IUser | null, credential: ICreden
|
||||
|
||||
export const getWorkflowPermissions = (user: IUser | null, workflow: IWorkflowDb) => {
|
||||
const settingsStore = useSettingsStore();
|
||||
const rbacStore = useRBACStore();
|
||||
const isSharingEnabled = settingsStore.isEnterpriseFeatureEnabled(
|
||||
EnterpriseEditionFeature.Sharing,
|
||||
);
|
||||
@ -93,27 +100,15 @@ export const getWorkflowPermissions = (user: IUser | null, workflow: IWorkflowDb
|
||||
const table: IPermissionsTable = [
|
||||
{
|
||||
name: UserRole.ResourceOwner,
|
||||
test: () =>
|
||||
!!(isNewWorkflow || (workflow?.ownedBy && workflow.ownedBy.id === user?.id)) ||
|
||||
!isSharingEnabled,
|
||||
test: () => !!(isNewWorkflow || workflow?.ownedBy?.id === user?.id) || !isSharingEnabled,
|
||||
},
|
||||
{
|
||||
name: UserRole.ResourceSharee,
|
||||
test: () => !!workflow?.sharedWith?.find((sharee) => sharee.id === user?.id),
|
||||
name: 'updateSharing',
|
||||
test: (permissions) => rbacStore.hasScope('workflow:update') || !!permissions.isOwner,
|
||||
},
|
||||
{
|
||||
name: 'read',
|
||||
test: [UserRole.ResourceOwner, UserRole.InstanceOwner, UserRole.ResourceSharee],
|
||||
},
|
||||
{ name: 'save', test: [UserRole.ResourceOwner, UserRole.InstanceOwner] },
|
||||
{ name: 'updateName', test: [UserRole.ResourceOwner, UserRole.InstanceOwner] },
|
||||
{ name: 'updateConnection', test: [UserRole.ResourceOwner] },
|
||||
{ name: 'updateSharing', test: [UserRole.ResourceOwner] },
|
||||
{ name: 'updateNodeAccess', test: [UserRole.ResourceOwner] },
|
||||
{ name: 'delete', test: [UserRole.ResourceOwner, UserRole.InstanceOwner] },
|
||||
{
|
||||
name: 'use',
|
||||
test: [UserRole.ResourceOwner, UserRole.InstanceOwner, UserRole.ResourceSharee],
|
||||
name: 'delete',
|
||||
test: (permissions) => rbacStore.hasScope('workflow:delete') || !!permissions.isOwner,
|
||||
},
|
||||
];
|
||||
|
||||
@ -121,20 +116,12 @@ export const getWorkflowPermissions = (user: IUser | null, workflow: IWorkflowDb
|
||||
};
|
||||
|
||||
export const getVariablesPermissions = (user: IUser | null) => {
|
||||
const rbacStore = useRBACStore();
|
||||
const table: IPermissionsTable = [
|
||||
{
|
||||
name: 'create',
|
||||
test: [UserRole.InstanceOwner],
|
||||
},
|
||||
{
|
||||
name: 'edit',
|
||||
test: [UserRole.InstanceOwner],
|
||||
},
|
||||
{
|
||||
name: 'delete',
|
||||
test: [UserRole.InstanceOwner],
|
||||
},
|
||||
{ name: 'use', test: () => true },
|
||||
{ name: 'create', test: () => rbacStore.hasScope('variable:create') },
|
||||
{ name: 'edit', test: () => rbacStore.hasScope('variable:update') },
|
||||
{ name: 'delete', test: () => rbacStore.hasScope('variable:delete') },
|
||||
{ name: 'use', test: () => rbacStore.hasScope('variable:read') },
|
||||
];
|
||||
|
||||
return parsePermissionsTable(user, table);
|
||||
|
@ -6,12 +6,14 @@ import ElementPlus, { ElLoading, ElMessageBox } from 'element-plus';
|
||||
import { N8nPlugin } from 'n8n-design-system';
|
||||
import { useMessage } from '@/composables/useMessage';
|
||||
import EnterpriseEdition from '@/components/EnterpriseEdition.ee.vue';
|
||||
import RBAC from '@/components/RBAC.vue';
|
||||
|
||||
export const GlobalComponentsPlugin: Plugin<{}> = {
|
||||
install(app) {
|
||||
const messageService = useMessage();
|
||||
|
||||
app.component('enterprise-edition', EnterpriseEdition);
|
||||
app.component('RBAC', RBAC);
|
||||
|
||||
app.use(ElementPlus);
|
||||
app.use(N8nPlugin);
|
||||
|
63
packages/editor-ui/src/rbac/__tests__/permissions.test.ts
Normal file
63
packages/editor-ui/src/rbac/__tests__/permissions.test.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { hasPermission } from '@/rbac/permissions';
|
||||
import * as checks from '@/rbac/checks';
|
||||
|
||||
vi.mock('@/rbac/checks', () => ({
|
||||
hasRole: vi.fn(),
|
||||
hasScope: vi.fn(),
|
||||
isGuest: vi.fn(),
|
||||
isAuthenticated: vi.fn(),
|
||||
isEnterpriseFeatureEnabled: vi.fn(),
|
||||
isValid: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('hasPermission()', () => {
|
||||
it('should return true if all permissions are valid', () => {
|
||||
vi.mocked(checks.hasRole).mockReturnValue(true);
|
||||
vi.mocked(checks.hasScope).mockReturnValue(true);
|
||||
vi.mocked(checks.isGuest).mockReturnValue(true);
|
||||
vi.mocked(checks.isAuthenticated).mockReturnValue(true);
|
||||
vi.mocked(checks.isEnterpriseFeatureEnabled).mockReturnValue(true);
|
||||
vi.mocked(checks.isValid).mockReturnValue(true);
|
||||
|
||||
expect(hasPermission(['authenticated', 'custom', 'enterprise', 'guest', 'rbac', 'role'])).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false if any permission is invalid', () => {
|
||||
vi.mocked(checks.hasRole).mockReturnValue(true);
|
||||
vi.mocked(checks.isGuest).mockReturnValue(true);
|
||||
vi.mocked(checks.isAuthenticated).mockReturnValue(true);
|
||||
vi.mocked(checks.isEnterpriseFeatureEnabled).mockReturnValue(true);
|
||||
vi.mocked(checks.isValid).mockReturnValue(true);
|
||||
|
||||
vi.mocked(checks.hasScope).mockReturnValue(false);
|
||||
|
||||
expect(hasPermission(['authenticated', 'custom', 'enterprise', 'guest', 'rbac', 'role'])).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return true for a specific valid permission', () => {
|
||||
vi.mocked(checks.isAuthenticated).mockReturnValue(true);
|
||||
|
||||
expect(hasPermission(['authenticated'])).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for a specific invalid permission', () => {
|
||||
vi.mocked(checks.isGuest).mockReturnValue(false);
|
||||
|
||||
expect(hasPermission(['guest'])).toBe(false);
|
||||
});
|
||||
|
||||
it('should call permission function with given permission options', () => {
|
||||
const customFn = () => true;
|
||||
vi.mocked(checks.isValid).mockReturnValue(true);
|
||||
|
||||
hasPermission(['custom'], {
|
||||
custom: customFn,
|
||||
});
|
||||
|
||||
expect(checks.isValid).toHaveBeenCalledWith(customFn);
|
||||
});
|
||||
});
|
51
packages/editor-ui/src/rbac/checks/__tests__/hasRole.test.ts
Normal file
51
packages/editor-ui/src/rbac/checks/__tests__/hasRole.test.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { hasRole } from '@/rbac/checks';
|
||||
import { ROLE } from '@/utils';
|
||||
|
||||
vi.mock('@/stores/users.store', () => ({
|
||||
useUsersStore: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('Checks', () => {
|
||||
describe('hasRole', () => {
|
||||
it('should return true if the user has the specified role', () => {
|
||||
vi.mocked(useUsersStore).mockReturnValue({
|
||||
currentUser: {
|
||||
isDefaultUser: false,
|
||||
globalRole: { name: ROLE.Owner },
|
||||
},
|
||||
} as ReturnType<typeof useUsersStore>);
|
||||
|
||||
expect(hasRole([ROLE.Owner])).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if the user does not have the specified role', () => {
|
||||
vi.mocked(useUsersStore).mockReturnValue({
|
||||
currentUser: {
|
||||
isDefaultUser: false,
|
||||
globalRole: { name: ROLE.Member },
|
||||
},
|
||||
} as ReturnType<typeof useUsersStore>);
|
||||
|
||||
expect(hasRole([ROLE.Owner])).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for default user if checking for default role', () => {
|
||||
vi.mocked(useUsersStore).mockReturnValue({
|
||||
currentUser: {
|
||||
isDefaultUser: true,
|
||||
},
|
||||
} as ReturnType<typeof useUsersStore>);
|
||||
|
||||
expect(hasRole([ROLE.Default])).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if there is no current user', () => {
|
||||
vi.mocked(useUsersStore).mockReturnValue({ currentUser: null } as ReturnType<
|
||||
typeof useUsersStore
|
||||
>);
|
||||
|
||||
expect(hasRole([ROLE.Owner])).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,54 @@
|
||||
import { useRBACStore } from '@/stores/rbac.store';
|
||||
import { hasScope } from '@/rbac/checks/hasScope';
|
||||
import type { HasScopeOptions } from '@n8n/permissions';
|
||||
|
||||
vi.mock('@/stores/rbac.store', () => ({
|
||||
useRBACStore: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('Checks', () => {
|
||||
describe('hasScope()', () => {
|
||||
it('should return true if no scope is provided', () => {
|
||||
expect(hasScope({})).toBe(true);
|
||||
});
|
||||
|
||||
it('should call rbacStore.hasScope with the correct parameters', () => {
|
||||
const mockHasScope = vi.fn().mockReturnValue(true);
|
||||
vi.mocked(useRBACStore).mockReturnValue({
|
||||
hasScope: mockHasScope,
|
||||
} as unknown as ReturnType<typeof useRBACStore>);
|
||||
|
||||
const scope = 'workflow:read';
|
||||
const options: HasScopeOptions = { mode: 'allOf' };
|
||||
const projectId = 'proj123';
|
||||
const resourceType = 'workflow';
|
||||
const resourceId = 'res123';
|
||||
|
||||
hasScope({ scope, options, projectId, resourceType, resourceId });
|
||||
|
||||
expect(mockHasScope).toHaveBeenCalledWith(
|
||||
scope,
|
||||
{ projectId, resourceType, resourceId },
|
||||
options,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return true if rbacStore.hasScope returns true', () => {
|
||||
const mockHasScope = vi.fn().mockReturnValue(true);
|
||||
vi.mocked(useRBACStore).mockReturnValue({ hasScope: mockHasScope } as unknown as ReturnType<
|
||||
typeof useRBACStore
|
||||
>);
|
||||
|
||||
expect(hasScope({ scope: 'workflow:read' })).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if rbacStore.hasScope returns false', () => {
|
||||
const mockHasScope = vi.fn().mockReturnValue(false);
|
||||
vi.mocked(useRBACStore).mockReturnValue({ hasScope: mockHasScope } as unknown as ReturnType<
|
||||
typeof useRBACStore
|
||||
>);
|
||||
|
||||
expect(hasScope({ scope: 'workflow:read' })).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,27 @@
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { isAuthenticated } from '@/rbac/checks/isAuthenticated';
|
||||
|
||||
vi.mock('@/stores/users.store', () => ({
|
||||
useUsersStore: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('Checks', () => {
|
||||
describe('isAuthenticated()', () => {
|
||||
it('should return true if there is a current user', () => {
|
||||
const mockUser = { id: 'user123', name: 'Test User' };
|
||||
vi.mocked(useUsersStore).mockReturnValue({ currentUser: mockUser } as unknown as ReturnType<
|
||||
typeof useUsersStore
|
||||
>);
|
||||
|
||||
expect(isAuthenticated()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if there is no current user', () => {
|
||||
vi.mocked(useUsersStore).mockReturnValue({ currentUser: null } as ReturnType<
|
||||
typeof useUsersStore
|
||||
>);
|
||||
|
||||
expect(isAuthenticated()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,87 @@
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { isEnterpriseFeatureEnabled } from '@/rbac/checks/isEnterpriseFeatureEnabled';
|
||||
import { EnterpriseEditionFeature } from '@/constants';
|
||||
|
||||
vi.mock('@/stores/settings.store', () => ({
|
||||
useSettingsStore: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('Checks', () => {
|
||||
describe('isEnterpriseFeatureEnabled()', () => {
|
||||
it('should return true if no feature is provided', () => {
|
||||
expect(isEnterpriseFeatureEnabled({})).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if feature is enabled', () => {
|
||||
vi.mocked(useSettingsStore).mockReturnValue({
|
||||
isEnterpriseFeatureEnabled: vi
|
||||
.fn()
|
||||
.mockImplementation((feature) => feature !== EnterpriseEditionFeature.Variables),
|
||||
} as unknown as ReturnType<typeof useSettingsStore>);
|
||||
|
||||
expect(
|
||||
isEnterpriseFeatureEnabled({
|
||||
feature: EnterpriseEditionFeature.Saml,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if all features are enabled in allOf mode', () => {
|
||||
vi.mocked(useSettingsStore).mockReturnValue({
|
||||
isEnterpriseFeatureEnabled: vi
|
||||
.fn()
|
||||
.mockImplementation((feature) => feature !== EnterpriseEditionFeature.Variables),
|
||||
} as unknown as ReturnType<typeof useSettingsStore>);
|
||||
|
||||
expect(
|
||||
isEnterpriseFeatureEnabled({
|
||||
feature: [EnterpriseEditionFeature.Ldap, EnterpriseEditionFeature.Saml],
|
||||
mode: 'allOf',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if any feature is not enabled in allOf mode', () => {
|
||||
vi.mocked(useSettingsStore).mockReturnValue({
|
||||
isEnterpriseFeatureEnabled: vi
|
||||
.fn()
|
||||
.mockImplementation((feature) => feature !== EnterpriseEditionFeature.Saml),
|
||||
} as unknown as ReturnType<typeof useSettingsStore>);
|
||||
|
||||
expect(
|
||||
isEnterpriseFeatureEnabled({
|
||||
feature: [EnterpriseEditionFeature.Ldap, EnterpriseEditionFeature.Saml],
|
||||
mode: 'allOf',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true if any feature is enabled in oneOf mode', () => {
|
||||
vi.mocked(useSettingsStore).mockReturnValue({
|
||||
isEnterpriseFeatureEnabled: vi
|
||||
.fn()
|
||||
.mockImplementation((feature) => feature === EnterpriseEditionFeature.Ldap),
|
||||
} as unknown as ReturnType<typeof useSettingsStore>);
|
||||
|
||||
expect(
|
||||
isEnterpriseFeatureEnabled({
|
||||
feature: [EnterpriseEditionFeature.Ldap, EnterpriseEditionFeature.Saml],
|
||||
mode: 'oneOf',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if no features are enabled in anyOf mode', () => {
|
||||
vi.mocked(useSettingsStore).mockReturnValue({
|
||||
isEnterpriseFeatureEnabled: vi.fn().mockReturnValue(false),
|
||||
} as unknown as ReturnType<typeof useSettingsStore>);
|
||||
|
||||
expect(
|
||||
isEnterpriseFeatureEnabled({
|
||||
feature: [EnterpriseEditionFeature.Ldap, EnterpriseEditionFeature.Saml],
|
||||
mode: 'oneOf',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
27
packages/editor-ui/src/rbac/checks/__tests__/isGuest.test.ts
Normal file
27
packages/editor-ui/src/rbac/checks/__tests__/isGuest.test.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { isGuest } from '@/rbac/checks/isGuest';
|
||||
|
||||
vi.mock('@/stores/users.store', () => ({
|
||||
useUsersStore: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('Checks', () => {
|
||||
describe('isGuest()', () => {
|
||||
it('should return false if there is a current user', () => {
|
||||
const mockUser = { id: 'user123', name: 'Test User' };
|
||||
vi.mocked(useUsersStore).mockReturnValue({ currentUser: mockUser } as unknown as ReturnType<
|
||||
typeof useUsersStore
|
||||
>);
|
||||
|
||||
expect(isGuest()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true if there is no current user', () => {
|
||||
vi.mocked(useUsersStore).mockReturnValue({ currentUser: null } as ReturnType<
|
||||
typeof useUsersStore
|
||||
>);
|
||||
|
||||
expect(isGuest()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
19
packages/editor-ui/src/rbac/checks/__tests__/isValid.test.ts
Normal file
19
packages/editor-ui/src/rbac/checks/__tests__/isValid.test.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { isValid } from '@/rbac/checks/isValid';
|
||||
|
||||
describe('Checks', () => {
|
||||
describe('isValid()', () => {
|
||||
it('should return true if the provided function returns true', () => {
|
||||
const mockFn = () => true;
|
||||
expect(isValid(mockFn)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if the provided function returns false', () => {
|
||||
const mockFn = () => false;
|
||||
expect(isValid(mockFn)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if no function is provided', () => {
|
||||
expect(isValid(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
16
packages/editor-ui/src/rbac/checks/hasRole.ts
Normal file
16
packages/editor-ui/src/rbac/checks/hasRole.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import type { RBACPermissionCheck, RolePermissionOptions } from '@/types/rbac';
|
||||
import { ROLE } from '@/utils';
|
||||
import type { IRole } from '@/Interface';
|
||||
|
||||
export const hasRole: RBACPermissionCheck<RolePermissionOptions> = (checkRoles) => {
|
||||
const usersStore = useUsersStore();
|
||||
const currentUser = usersStore.currentUser;
|
||||
|
||||
if (currentUser && checkRoles) {
|
||||
const userRole = currentUser.isDefaultUser ? ROLE.Default : currentUser.globalRole?.name;
|
||||
return checkRoles.includes(userRole as IRole);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
20
packages/editor-ui/src/rbac/checks/hasScope.ts
Normal file
20
packages/editor-ui/src/rbac/checks/hasScope.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { useRBACStore } from '@/stores/rbac.store';
|
||||
import type { RBACPermissionCheck, RBACPermissionOptions } from '@/types/rbac';
|
||||
|
||||
export const hasScope: RBACPermissionCheck<RBACPermissionOptions> = (opts) => {
|
||||
if (!opts?.scope) {
|
||||
return true;
|
||||
}
|
||||
const { projectId, resourceType, resourceId, scope, options } = opts;
|
||||
|
||||
const rbacStore = useRBACStore();
|
||||
return rbacStore.hasScope(
|
||||
scope,
|
||||
{
|
||||
projectId,
|
||||
resourceType,
|
||||
resourceId,
|
||||
},
|
||||
options,
|
||||
);
|
||||
};
|
6
packages/editor-ui/src/rbac/checks/index.ts
Normal file
6
packages/editor-ui/src/rbac/checks/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export * from './hasRole';
|
||||
export * from './hasScope';
|
||||
export * from './isAuthenticated';
|
||||
export * from './isEnterpriseFeatureEnabled';
|
||||
export * from './isGuest';
|
||||
export * from './isValid';
|
7
packages/editor-ui/src/rbac/checks/isAuthenticated.ts
Normal file
7
packages/editor-ui/src/rbac/checks/isAuthenticated.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { useUsersStore } from '@/stores';
|
||||
import type { RBACPermissionCheck, AuthenticatedPermissionOptions } from '@/types/rbac';
|
||||
|
||||
export const isAuthenticated: RBACPermissionCheck<AuthenticatedPermissionOptions> = () => {
|
||||
const usersStore = useUsersStore();
|
||||
return !!usersStore.currentUser;
|
||||
};
|
@ -0,0 +1,19 @@
|
||||
import { useSettingsStore } from '@/stores';
|
||||
import type { RBACPermissionCheck, EnterprisePermissionOptions } from '@/types/rbac';
|
||||
|
||||
export const isEnterpriseFeatureEnabled: RBACPermissionCheck<EnterprisePermissionOptions> = (
|
||||
options,
|
||||
) => {
|
||||
if (!options?.feature) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const features = Array.isArray(options.feature) ? options.feature : [options.feature];
|
||||
const settingsStore = useSettingsStore();
|
||||
const mode = options.mode ?? 'allOf';
|
||||
if (mode === 'allOf') {
|
||||
return features.every(settingsStore.isEnterpriseFeatureEnabled);
|
||||
} else {
|
||||
return features.some(settingsStore.isEnterpriseFeatureEnabled);
|
||||
}
|
||||
};
|
7
packages/editor-ui/src/rbac/checks/isGuest.ts
Normal file
7
packages/editor-ui/src/rbac/checks/isGuest.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { useUsersStore } from '@/stores';
|
||||
import type { RBACPermissionCheck, GuestPermissionOptions } from '@/types/rbac';
|
||||
|
||||
export const isGuest: RBACPermissionCheck<GuestPermissionOptions> = () => {
|
||||
const usersStore = useUsersStore();
|
||||
return !usersStore.currentUser;
|
||||
};
|
5
packages/editor-ui/src/rbac/checks/isValid.ts
Normal file
5
packages/editor-ui/src/rbac/checks/isValid.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import type { RBACPermissionCheck, CustomPermissionOptions } from '@/types/rbac';
|
||||
|
||||
export const isValid: RBACPermissionCheck<CustomPermissionOptions> = (fn) => {
|
||||
return fn ? fn() : false;
|
||||
};
|
20
packages/editor-ui/src/rbac/middleware.ts
Normal file
20
packages/editor-ui/src/rbac/middleware.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import type { RouterMiddleware, RouterMiddlewareType, MiddlewareOptions } from '@/types/router';
|
||||
import { authenticatedMiddleware } from '@/rbac/middleware/authenticated';
|
||||
import { enterpriseMiddleware } from '@/rbac/middleware/enterprise';
|
||||
import { guestMiddleware } from '@/rbac/middleware/guest';
|
||||
import { rbacMiddleware } from '@/rbac/middleware/rbac';
|
||||
import { roleMiddleware } from '@/rbac/middleware/role';
|
||||
import { customMiddleware } from '@/rbac/middleware/custom';
|
||||
|
||||
type Middleware = {
|
||||
[key in RouterMiddlewareType]: RouterMiddleware<MiddlewareOptions[key]>;
|
||||
};
|
||||
|
||||
export const middleware: Middleware = {
|
||||
authenticated: authenticatedMiddleware,
|
||||
custom: customMiddleware,
|
||||
enterprise: enterpriseMiddleware,
|
||||
guest: guestMiddleware,
|
||||
rbac: rbacMiddleware,
|
||||
role: roleMiddleware,
|
||||
};
|
@ -0,0 +1,60 @@
|
||||
import { authenticatedMiddleware } from '@/rbac/middleware/authenticated';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { VIEWS } from '@/constants';
|
||||
import type { RouteLocationNormalized } from 'vue-router';
|
||||
|
||||
vi.mock('@/stores/users.store', () => ({
|
||||
useUsersStore: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('Middleware', () => {
|
||||
describe('authenticated', () => {
|
||||
it('should redirect to signin if no current user is present', async () => {
|
||||
vi.mocked(useUsersStore).mockReturnValue({ currentUser: null } as ReturnType<
|
||||
typeof useUsersStore
|
||||
>);
|
||||
|
||||
const nextMock = vi.fn();
|
||||
const toMock = { query: {} } as RouteLocationNormalized;
|
||||
const fromMock = {} as RouteLocationNormalized;
|
||||
|
||||
await authenticatedMiddleware(toMock, fromMock, nextMock, {});
|
||||
|
||||
expect(nextMock).toHaveBeenCalledWith({
|
||||
name: VIEWS.SIGNIN,
|
||||
query: { redirect: encodeURIComponent('/') },
|
||||
});
|
||||
});
|
||||
|
||||
it('should call next with the correct redirect query if present', async () => {
|
||||
vi.mocked(useUsersStore).mockReturnValue({ currentUser: null } as ReturnType<
|
||||
typeof useUsersStore
|
||||
>);
|
||||
|
||||
const nextMock = vi.fn();
|
||||
const toMock = { query: { redirect: '/' } } as unknown as RouteLocationNormalized;
|
||||
const fromMock = {} as RouteLocationNormalized;
|
||||
|
||||
await authenticatedMiddleware(toMock, fromMock, nextMock, {});
|
||||
|
||||
expect(nextMock).toHaveBeenCalledWith({
|
||||
name: VIEWS.SIGNIN,
|
||||
query: { redirect: '/' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow navigation if a current user is present', async () => {
|
||||
vi.mocked(useUsersStore).mockReturnValue({ currentUser: { id: '123' } } as ReturnType<
|
||||
typeof useUsersStore
|
||||
>);
|
||||
|
||||
const nextMock = vi.fn();
|
||||
const toMock = { query: {} } as RouteLocationNormalized;
|
||||
const fromMock = {} as RouteLocationNormalized;
|
||||
|
||||
await authenticatedMiddleware(toMock, fromMock, nextMock, {});
|
||||
|
||||
expect(nextMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,35 @@
|
||||
import { customMiddleware } from '@/rbac/middleware/custom';
|
||||
import type { RouteLocationNormalized } from 'vue-router';
|
||||
import { VIEWS } from '@/constants';
|
||||
|
||||
describe('Middleware', () => {
|
||||
describe('custom', () => {
|
||||
it('should redirect to homepage if validation function returns false', async () => {
|
||||
const nextMock = vi.fn();
|
||||
const fn = () => false;
|
||||
|
||||
await customMiddleware(
|
||||
{} as RouteLocationNormalized,
|
||||
{} as RouteLocationNormalized,
|
||||
nextMock,
|
||||
fn,
|
||||
);
|
||||
|
||||
expect(nextMock).toHaveBeenCalledWith({ name: VIEWS.HOMEPAGE });
|
||||
});
|
||||
|
||||
it('should pass if validation function returns true', async () => {
|
||||
const nextMock = vi.fn();
|
||||
const fn = () => true;
|
||||
|
||||
await customMiddleware(
|
||||
{} as RouteLocationNormalized,
|
||||
{} as RouteLocationNormalized,
|
||||
nextMock,
|
||||
fn,
|
||||
);
|
||||
|
||||
expect(nextMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,98 @@
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { VIEWS, EnterpriseEditionFeature } from '@/constants';
|
||||
import { enterpriseMiddleware } from '@/rbac/middleware/enterprise';
|
||||
import { type RouteLocationNormalized } from 'vue-router';
|
||||
import type { EnterprisePermissionOptions } from '@/types/rbac';
|
||||
|
||||
vi.mock('@/stores/settings.store', () => ({
|
||||
useSettingsStore: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('Middleware', () => {
|
||||
describe('enterprise', () => {
|
||||
it('should redirect to homepage if none of the required features are enabled in allOf mode', async () => {
|
||||
vi.mocked(useSettingsStore).mockReturnValue({
|
||||
isEnterpriseFeatureEnabled: (_) => false,
|
||||
} as ReturnType<typeof useSettingsStore>);
|
||||
|
||||
const nextMock = vi.fn();
|
||||
const options: EnterprisePermissionOptions = {
|
||||
feature: [EnterpriseEditionFeature.Saml, EnterpriseEditionFeature.Ldap],
|
||||
mode: 'allOf',
|
||||
};
|
||||
|
||||
await enterpriseMiddleware(
|
||||
{} as RouteLocationNormalized,
|
||||
{} as RouteLocationNormalized,
|
||||
nextMock,
|
||||
options,
|
||||
);
|
||||
|
||||
expect(nextMock).toHaveBeenCalledWith({ name: VIEWS.HOMEPAGE });
|
||||
});
|
||||
|
||||
it('should allow navigation if all of the required features are enabled in allOf mode', async () => {
|
||||
vi.mocked(useSettingsStore).mockReturnValue({
|
||||
isEnterpriseFeatureEnabled: (feature) =>
|
||||
[EnterpriseEditionFeature.Saml, EnterpriseEditionFeature.Ldap].includes(feature),
|
||||
} as ReturnType<typeof useSettingsStore>);
|
||||
|
||||
const nextMock = vi.fn();
|
||||
const options: EnterprisePermissionOptions = {
|
||||
feature: [EnterpriseEditionFeature.Saml, EnterpriseEditionFeature.Ldap],
|
||||
mode: 'allOf',
|
||||
};
|
||||
|
||||
await enterpriseMiddleware(
|
||||
{} as RouteLocationNormalized,
|
||||
{} as RouteLocationNormalized,
|
||||
nextMock,
|
||||
options,
|
||||
);
|
||||
|
||||
expect(nextMock).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('should redirect to homepage if none of the required features are enabled in oneOf mode', async () => {
|
||||
vi.mocked(useSettingsStore).mockReturnValue({
|
||||
isEnterpriseFeatureEnabled: (_) => false,
|
||||
} as ReturnType<typeof useSettingsStore>);
|
||||
|
||||
const nextMock = vi.fn();
|
||||
const options: EnterprisePermissionOptions = {
|
||||
feature: [EnterpriseEditionFeature.Saml],
|
||||
mode: 'oneOf',
|
||||
};
|
||||
|
||||
await enterpriseMiddleware(
|
||||
{} as RouteLocationNormalized,
|
||||
{} as RouteLocationNormalized,
|
||||
nextMock,
|
||||
options,
|
||||
);
|
||||
|
||||
expect(nextMock).toHaveBeenCalledWith({ name: VIEWS.HOMEPAGE });
|
||||
});
|
||||
|
||||
it('should allow navigation if at least one of the required features is enabled in oneOf mode', async () => {
|
||||
vi.mocked(useSettingsStore).mockReturnValue({
|
||||
isEnterpriseFeatureEnabled: (feature) => feature === EnterpriseEditionFeature.Saml,
|
||||
} as ReturnType<typeof useSettingsStore>);
|
||||
|
||||
const nextMock = vi.fn();
|
||||
const options: EnterprisePermissionOptions = {
|
||||
feature: [EnterpriseEditionFeature.Saml, EnterpriseEditionFeature.Ldap],
|
||||
mode: 'oneOf',
|
||||
};
|
||||
|
||||
await enterpriseMiddleware(
|
||||
{} as RouteLocationNormalized,
|
||||
{} as RouteLocationNormalized,
|
||||
nextMock,
|
||||
options,
|
||||
);
|
||||
|
||||
expect(nextMock).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,54 @@
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { VIEWS } from '@/constants';
|
||||
import { guestMiddleware } from '@/rbac/middleware/guest';
|
||||
import type { RouteLocationNormalized } from 'vue-router';
|
||||
|
||||
vi.mock('@/stores/users.store', () => ({
|
||||
useUsersStore: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('Middleware', () => {
|
||||
describe('guest', () => {
|
||||
it('should redirect to given path if current user is present and valid redirect is provided', async () => {
|
||||
vi.mocked(useUsersStore).mockReturnValue({ currentUser: { id: '123' } } as ReturnType<
|
||||
typeof useUsersStore
|
||||
>);
|
||||
|
||||
const nextMock = vi.fn();
|
||||
const toMock = { query: { redirect: '/some-path' } } as unknown as RouteLocationNormalized;
|
||||
const fromMock = {} as RouteLocationNormalized;
|
||||
|
||||
await guestMiddleware(toMock, fromMock, nextMock, {});
|
||||
|
||||
expect(nextMock).toHaveBeenCalledWith('/some-path');
|
||||
});
|
||||
|
||||
it('should redirect to homepage if current user is present and no valid redirect', async () => {
|
||||
vi.mocked(useUsersStore).mockReturnValue({ currentUser: { id: '123' } } as ReturnType<
|
||||
typeof useUsersStore
|
||||
>);
|
||||
|
||||
const nextMock = vi.fn();
|
||||
const toMock = { query: {} } as RouteLocationNormalized;
|
||||
const fromMock = {} as RouteLocationNormalized;
|
||||
|
||||
await guestMiddleware(toMock, fromMock, nextMock, {});
|
||||
|
||||
expect(nextMock).toHaveBeenCalledWith({ name: VIEWS.HOMEPAGE });
|
||||
});
|
||||
|
||||
it('should allow navigation if no current user is present', async () => {
|
||||
vi.mocked(useUsersStore).mockReturnValue({ currentUser: null } as ReturnType<
|
||||
typeof useUsersStore
|
||||
>);
|
||||
|
||||
const nextMock = vi.fn();
|
||||
const toMock = { query: {} } as RouteLocationNormalized;
|
||||
const fromMock = {} as RouteLocationNormalized;
|
||||
|
||||
await guestMiddleware(toMock, fromMock, nextMock, {});
|
||||
|
||||
expect(nextMock).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,60 @@
|
||||
import { useRBACStore } from '@/stores/rbac.store';
|
||||
import { rbacMiddleware } from '@/rbac/middleware/rbac';
|
||||
import { VIEWS } from '@/constants';
|
||||
import {
|
||||
inferProjectIdFromRoute,
|
||||
inferResourceIdFromRoute,
|
||||
inferResourceTypeFromRoute,
|
||||
} from '@/utils/rbacUtils';
|
||||
import type { RouteLocationNormalized } from 'vue-router';
|
||||
import type { Scope } from '@n8n/permissions';
|
||||
|
||||
vi.mock('@/stores/rbac.store', () => ({
|
||||
useRBACStore: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/rbacUtils', () => ({
|
||||
inferProjectIdFromRoute: vi.fn(),
|
||||
inferResourceIdFromRoute: vi.fn(),
|
||||
inferResourceTypeFromRoute: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('Middleware', () => {
|
||||
describe('rbac', () => {
|
||||
it('should redirect to homepage if the user does not have the required scope', async () => {
|
||||
vi.mocked(useRBACStore).mockReturnValue({
|
||||
hasScope: vi.fn().mockReturnValue(false),
|
||||
} as unknown as ReturnType<typeof useRBACStore>);
|
||||
vi.mocked(inferProjectIdFromRoute).mockReturnValue('123');
|
||||
vi.mocked(inferResourceTypeFromRoute).mockReturnValue('workflow');
|
||||
vi.mocked(inferResourceIdFromRoute).mockReturnValue('456');
|
||||
|
||||
const nextMock = vi.fn();
|
||||
const scope: Scope = 'workflow:read';
|
||||
|
||||
await rbacMiddleware({} as RouteLocationNormalized, {} as RouteLocationNormalized, nextMock, {
|
||||
scope,
|
||||
});
|
||||
|
||||
expect(nextMock).toHaveBeenCalledWith({ name: VIEWS.HOMEPAGE });
|
||||
});
|
||||
|
||||
it('should allow navigation if the user has the required scope', async () => {
|
||||
vi.mocked(useRBACStore).mockReturnValue({
|
||||
hasScope: vi.fn().mockReturnValue(true),
|
||||
} as unknown as ReturnType<typeof useRBACStore>);
|
||||
vi.mocked(inferProjectIdFromRoute).mockReturnValue('123');
|
||||
vi.mocked(inferResourceTypeFromRoute).mockReturnValue(undefined);
|
||||
vi.mocked(inferResourceIdFromRoute).mockReturnValue(undefined);
|
||||
|
||||
const nextMock = vi.fn();
|
||||
const scope: Scope = 'workflow:read';
|
||||
|
||||
await rbacMiddleware({} as RouteLocationNormalized, {} as RouteLocationNormalized, nextMock, {
|
||||
scope,
|
||||
});
|
||||
|
||||
expect(nextMock).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,82 @@
|
||||
import { roleMiddleware } from '@/rbac/middleware/role';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { ROLE } from '@/utils';
|
||||
import type { IUser } from '@/Interface';
|
||||
import type { RouteLocationNormalized } from 'vue-router';
|
||||
import { VIEWS } from '@/constants';
|
||||
|
||||
vi.mock('@/stores/users.store', () => ({
|
||||
useUsersStore: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('Middleware', () => {
|
||||
describe('role', () => {
|
||||
it('should redirect to homepage if the user does not have the required role', async () => {
|
||||
vi.mocked(useUsersStore).mockReturnValue({
|
||||
currentUser: {
|
||||
isDefaultUser: false,
|
||||
globalRole: {
|
||||
id: '123',
|
||||
createdAt: new Date(),
|
||||
name: ROLE.Owner,
|
||||
},
|
||||
} as IUser,
|
||||
} as ReturnType<typeof useUsersStore>);
|
||||
|
||||
const nextMock = vi.fn();
|
||||
const role = [ROLE.Default];
|
||||
|
||||
await roleMiddleware(
|
||||
{} as RouteLocationNormalized,
|
||||
{} as RouteLocationNormalized,
|
||||
nextMock,
|
||||
role,
|
||||
);
|
||||
|
||||
expect(nextMock).toHaveBeenCalledWith({ name: VIEWS.HOMEPAGE });
|
||||
});
|
||||
|
||||
it('should redirect to homepage if the user is not logged in', async () => {
|
||||
vi.mocked(useUsersStore).mockReturnValue({
|
||||
currentUser: null,
|
||||
} as ReturnType<typeof useUsersStore>);
|
||||
|
||||
const nextMock = vi.fn();
|
||||
const role = [ROLE.Default];
|
||||
|
||||
await roleMiddleware(
|
||||
{} as RouteLocationNormalized,
|
||||
{} as RouteLocationNormalized,
|
||||
nextMock,
|
||||
role,
|
||||
);
|
||||
|
||||
expect(nextMock).toHaveBeenCalledWith({ name: VIEWS.HOMEPAGE });
|
||||
});
|
||||
|
||||
it('should nor redirect if the user has the required role', async () => {
|
||||
vi.mocked(useUsersStore).mockReturnValue({
|
||||
currentUser: {
|
||||
isDefaultUser: false,
|
||||
globalRole: {
|
||||
id: '123',
|
||||
createdAt: new Date(),
|
||||
name: ROLE.Owner,
|
||||
},
|
||||
} as IUser,
|
||||
} as ReturnType<typeof useUsersStore>);
|
||||
|
||||
const nextMock = vi.fn();
|
||||
const role = [ROLE.Owner];
|
||||
|
||||
await roleMiddleware(
|
||||
{} as RouteLocationNormalized,
|
||||
{} as RouteLocationNormalized,
|
||||
nextMock,
|
||||
role,
|
||||
);
|
||||
|
||||
expect(nextMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
18
packages/editor-ui/src/rbac/middleware/authenticated.ts
Normal file
18
packages/editor-ui/src/rbac/middleware/authenticated.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import type { RouterMiddleware } from '@/types/router';
|
||||
import { VIEWS } from '@/constants';
|
||||
import type { AuthenticatedPermissionOptions } from '@/types/rbac';
|
||||
import { isAuthenticated } from '@/rbac/checks';
|
||||
|
||||
export const authenticatedMiddleware: RouterMiddleware<AuthenticatedPermissionOptions> = async (
|
||||
to,
|
||||
from,
|
||||
next,
|
||||
) => {
|
||||
const valid = isAuthenticated();
|
||||
if (!valid) {
|
||||
const redirect =
|
||||
to.query.redirect ??
|
||||
encodeURIComponent(`${window.location.pathname}${window.location.search}`);
|
||||
return next({ name: VIEWS.SIGNIN, query: { redirect } });
|
||||
}
|
||||
};
|
14
packages/editor-ui/src/rbac/middleware/custom.ts
Normal file
14
packages/editor-ui/src/rbac/middleware/custom.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import type { CustomMiddlewareOptions, RouterMiddleware } from '@/types/router';
|
||||
import { VIEWS } from '@/constants';
|
||||
|
||||
export const customMiddleware: RouterMiddleware<CustomMiddlewareOptions> = async (
|
||||
to,
|
||||
from,
|
||||
next,
|
||||
isValid,
|
||||
) => {
|
||||
const valid = isValid({ to, from, next });
|
||||
if (!valid) {
|
||||
return next({ name: VIEWS.HOMEPAGE });
|
||||
}
|
||||
};
|
16
packages/editor-ui/src/rbac/middleware/enterprise.ts
Normal file
16
packages/editor-ui/src/rbac/middleware/enterprise.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import type { RouterMiddleware } from '@/types/router';
|
||||
import { VIEWS } from '@/constants';
|
||||
import type { EnterprisePermissionOptions } from '@/types/rbac';
|
||||
import { isEnterpriseFeatureEnabled } from '@/rbac/checks';
|
||||
|
||||
export const enterpriseMiddleware: RouterMiddleware<EnterprisePermissionOptions> = async (
|
||||
to,
|
||||
from,
|
||||
next,
|
||||
options,
|
||||
) => {
|
||||
const valid = isEnterpriseFeatureEnabled(options);
|
||||
if (!valid) {
|
||||
return next({ name: VIEWS.HOMEPAGE });
|
||||
}
|
||||
};
|
16
packages/editor-ui/src/rbac/middleware/guest.ts
Normal file
16
packages/editor-ui/src/rbac/middleware/guest.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import type { RouterMiddleware } from '@/types/router';
|
||||
import { VIEWS } from '@/constants';
|
||||
import type { GuestPermissionOptions } from '@/types/rbac';
|
||||
import { isGuest } from '@/rbac/checks';
|
||||
|
||||
export const guestMiddleware: RouterMiddleware<GuestPermissionOptions> = async (to, from, next) => {
|
||||
const valid = isGuest();
|
||||
if (!valid) {
|
||||
const redirect = to.query.redirect as string;
|
||||
if (redirect && redirect.startsWith('/')) {
|
||||
return next(redirect);
|
||||
}
|
||||
|
||||
return next({ name: VIEWS.HOMEPAGE });
|
||||
}
|
||||
};
|
25
packages/editor-ui/src/rbac/middleware/rbac.ts
Normal file
25
packages/editor-ui/src/rbac/middleware/rbac.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import type { RouterMiddleware } from '@/types/router';
|
||||
import { VIEWS } from '@/constants';
|
||||
import {
|
||||
inferProjectIdFromRoute,
|
||||
inferResourceIdFromRoute,
|
||||
inferResourceTypeFromRoute,
|
||||
} from '@/utils/rbacUtils';
|
||||
import type { RBACPermissionOptions } from '@/types/rbac';
|
||||
import { hasScope } from '@/rbac/checks';
|
||||
|
||||
export const rbacMiddleware: RouterMiddleware<RBACPermissionOptions> = async (
|
||||
to,
|
||||
from,
|
||||
next,
|
||||
{ scope, options },
|
||||
) => {
|
||||
const projectId = inferProjectIdFromRoute(to);
|
||||
const resourceType = inferResourceTypeFromRoute(to);
|
||||
const resourceId = resourceType ? inferResourceIdFromRoute(to) : undefined;
|
||||
|
||||
const valid = hasScope({ scope, projectId, resourceType, resourceId, options });
|
||||
if (!valid) {
|
||||
return next({ name: VIEWS.HOMEPAGE });
|
||||
}
|
||||
};
|
16
packages/editor-ui/src/rbac/middleware/role.ts
Normal file
16
packages/editor-ui/src/rbac/middleware/role.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import type { RouterMiddleware } from '@/types/router';
|
||||
import type { RolePermissionOptions } from '@/types/rbac';
|
||||
import { VIEWS } from '@/constants';
|
||||
import { hasRole } from '@/rbac/checks';
|
||||
|
||||
export const roleMiddleware: RouterMiddleware<RolePermissionOptions> = async (
|
||||
to,
|
||||
from,
|
||||
next,
|
||||
checkRoles,
|
||||
) => {
|
||||
const valid = hasRole(checkRoles);
|
||||
if (!valid) {
|
||||
return next({ name: VIEWS.HOMEPAGE });
|
||||
}
|
||||
};
|
38
packages/editor-ui/src/rbac/permissions.ts
Normal file
38
packages/editor-ui/src/rbac/permissions.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import {
|
||||
hasRole,
|
||||
hasScope,
|
||||
isAuthenticated,
|
||||
isEnterpriseFeatureEnabled,
|
||||
isGuest,
|
||||
isValid,
|
||||
} from '@/rbac/checks';
|
||||
import type { PermissionType, PermissionTypeOptions, RBACPermissionCheck } from '@/types/rbac';
|
||||
|
||||
type Permissions = {
|
||||
[key in PermissionType]: RBACPermissionCheck<PermissionTypeOptions[key]>;
|
||||
};
|
||||
|
||||
export const permissions: Permissions = {
|
||||
authenticated: isAuthenticated,
|
||||
custom: isValid,
|
||||
enterprise: isEnterpriseFeatureEnabled,
|
||||
guest: isGuest,
|
||||
rbac: hasScope,
|
||||
role: hasRole,
|
||||
};
|
||||
|
||||
export function hasPermission(
|
||||
permissionNames: PermissionType[],
|
||||
options?: Partial<PermissionTypeOptions>,
|
||||
) {
|
||||
let valid = true;
|
||||
|
||||
for (const permissionName of permissionNames) {
|
||||
const permissionOptions = options?.[permissionName] ?? {};
|
||||
const permissionFn = permissions[permissionName] as RBACPermissionCheck<unknown>;
|
||||
|
||||
valid = valid && permissionFn(permissionOptions);
|
||||
}
|
||||
|
||||
return valid;
|
||||
}
|
@ -1,16 +1,23 @@
|
||||
import { useStorage } from '@/composables/useStorage';
|
||||
|
||||
import type { RouteLocation, RouteRecordRaw } from 'vue-router';
|
||||
import type {
|
||||
NavigationGuardNext,
|
||||
RouteLocation,
|
||||
RouteRecordRaw,
|
||||
RouteLocationRaw,
|
||||
RouteLocationNormalized,
|
||||
} from 'vue-router';
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import type { IPermissions } from './Interface';
|
||||
import { isAuthorized, LOGIN_STATUS, ROLE, runExternalHook } from '@/utils';
|
||||
import { ROLE, runExternalHook } from '@/utils';
|
||||
import { useSettingsStore } from './stores/settings.store';
|
||||
import { useUsersStore } from './stores/users.store';
|
||||
import { useTemplatesStore } from './stores/templates.store';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useSSOStore } from './stores/sso.store';
|
||||
import { EnterpriseEditionFeature, VIEWS } from '@/constants';
|
||||
import { useTelemetry } from '@/composables';
|
||||
import { middleware } from '@/rbac/middleware';
|
||||
import type { RouteConfig, RouterMiddleware } from '@/types/router';
|
||||
import { initializeCore } from '@/init';
|
||||
|
||||
const ChangePasswordView = async () => import('./views/ChangePasswordView.vue');
|
||||
const ErrorView = async () => import('./views/ErrorView.vue');
|
||||
@ -51,20 +58,6 @@ const WorkerView = async () => import('./views/WorkerView.vue');
|
||||
const WorkflowHistory = async () => import('@/views/WorkflowHistory.vue');
|
||||
const WorkflowOnboardingView = async () => import('@/views/WorkflowOnboardingView.vue');
|
||||
|
||||
interface IRouteConfig {
|
||||
meta: {
|
||||
nodeView?: boolean;
|
||||
templatesEnabled?: boolean;
|
||||
getRedirect?: () => { name: string } | false;
|
||||
permissions: IPermissions;
|
||||
telemetry?: {
|
||||
disabled?: true;
|
||||
getProperties: (route: RouteLocation) => object;
|
||||
};
|
||||
scrollOffset?: number;
|
||||
};
|
||||
}
|
||||
|
||||
function getTemplatesRedirect(defaultRedirect: VIEWS[keyof VIEWS]) {
|
||||
const settingsStore = useSettingsStore();
|
||||
const isTemplatesEnabled: boolean = settingsStore.isTemplatesEnabled;
|
||||
@ -83,11 +76,7 @@ export const routes = [
|
||||
return { name: VIEWS.WORKFLOWS };
|
||||
},
|
||||
meta: {
|
||||
permissions: {
|
||||
allow: {
|
||||
loginStatus: [LOGIN_STATUS.LoggedIn],
|
||||
},
|
||||
},
|
||||
middleware: ['authenticated'],
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -109,11 +98,7 @@ export const routes = [
|
||||
},
|
||||
},
|
||||
getRedirect: getTemplatesRedirect,
|
||||
permissions: {
|
||||
allow: {
|
||||
loginStatus: [LOGIN_STATUS.LoggedIn],
|
||||
},
|
||||
},
|
||||
middleware: ['authenticated'],
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -135,11 +120,7 @@ export const routes = [
|
||||
};
|
||||
},
|
||||
},
|
||||
permissions: {
|
||||
allow: {
|
||||
loginStatus: [LOGIN_STATUS.LoggedIn],
|
||||
},
|
||||
},
|
||||
middleware: ['authenticated'],
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -165,11 +146,7 @@ export const routes = [
|
||||
setScrollPosition(pos: number) {
|
||||
this.scrollOffset = pos;
|
||||
},
|
||||
permissions: {
|
||||
allow: {
|
||||
loginStatus: [LOGIN_STATUS.LoggedIn],
|
||||
},
|
||||
},
|
||||
middleware: ['authenticated'],
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -180,11 +157,7 @@ export const routes = [
|
||||
sidebar: MainSidebar,
|
||||
},
|
||||
meta: {
|
||||
permissions: {
|
||||
allow: {
|
||||
loginStatus: [LOGIN_STATUS.LoggedIn],
|
||||
},
|
||||
},
|
||||
middleware: ['authenticated'],
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -194,13 +167,7 @@ export const routes = [
|
||||
default: VariablesView,
|
||||
sidebar: MainSidebar,
|
||||
},
|
||||
meta: {
|
||||
permissions: {
|
||||
allow: {
|
||||
loginStatus: [LOGIN_STATUS.LoggedIn],
|
||||
},
|
||||
},
|
||||
},
|
||||
meta: { middleware: ['authenticated'] },
|
||||
},
|
||||
{
|
||||
path: '/executions',
|
||||
@ -210,11 +177,7 @@ export const routes = [
|
||||
sidebar: MainSidebar,
|
||||
},
|
||||
meta: {
|
||||
permissions: {
|
||||
allow: {
|
||||
loginStatus: [LOGIN_STATUS.LoggedIn],
|
||||
},
|
||||
},
|
||||
middleware: ['authenticated'],
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -225,11 +188,7 @@ export const routes = [
|
||||
sidebar: MainSidebar,
|
||||
},
|
||||
meta: {
|
||||
permissions: {
|
||||
allow: {
|
||||
loginStatus: [LOGIN_STATUS.LoggedIn],
|
||||
},
|
||||
},
|
||||
middleware: ['authenticated'],
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -243,13 +202,10 @@ export const routes = [
|
||||
meta: {
|
||||
nodeView: true,
|
||||
keepWorkflowAlive: true,
|
||||
permissions: {
|
||||
allow: {
|
||||
loginStatus: [LOGIN_STATUS.LoggedIn],
|
||||
},
|
||||
deny: {
|
||||
shouldDeny: () =>
|
||||
!useSettingsStore().isEnterpriseFeatureEnabled(EnterpriseEditionFeature.DebugInEditor),
|
||||
middleware: ['authenticated', 'enterprise'],
|
||||
middlewareOptions: {
|
||||
enterprise: {
|
||||
feature: [EnterpriseEditionFeature.DebugInEditor],
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -264,11 +220,7 @@ export const routes = [
|
||||
},
|
||||
meta: {
|
||||
keepWorkflowAlive: true,
|
||||
permissions: {
|
||||
allow: {
|
||||
loginStatus: [LOGIN_STATUS.LoggedIn],
|
||||
},
|
||||
},
|
||||
middleware: ['authenticated'],
|
||||
},
|
||||
children: [
|
||||
{
|
||||
@ -279,11 +231,7 @@ export const routes = [
|
||||
},
|
||||
meta: {
|
||||
keepWorkflowAlive: true,
|
||||
permissions: {
|
||||
allow: {
|
||||
loginStatus: [LOGIN_STATUS.LoggedIn],
|
||||
},
|
||||
},
|
||||
middleware: ['authenticated'],
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -294,11 +242,7 @@ export const routes = [
|
||||
},
|
||||
meta: {
|
||||
keepWorkflowAlive: true,
|
||||
permissions: {
|
||||
allow: {
|
||||
loginStatus: [LOGIN_STATUS.LoggedIn],
|
||||
},
|
||||
},
|
||||
middleware: ['authenticated'],
|
||||
},
|
||||
},
|
||||
],
|
||||
@ -311,15 +255,10 @@ export const routes = [
|
||||
sidebar: MainSidebar,
|
||||
},
|
||||
meta: {
|
||||
permissions: {
|
||||
allow: {
|
||||
loginStatus: [LOGIN_STATUS.LoggedIn],
|
||||
},
|
||||
deny: {
|
||||
shouldDeny: () =>
|
||||
!useSettingsStore().isEnterpriseFeatureEnabled(
|
||||
EnterpriseEditionFeature.WorkflowHistory,
|
||||
),
|
||||
middleware: ['authenticated', 'enterprise'],
|
||||
middlewareOptions: {
|
||||
enterprise: {
|
||||
feature: [EnterpriseEditionFeature.WorkflowHistory],
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -336,11 +275,7 @@ export const routes = [
|
||||
templatesEnabled: true,
|
||||
keepWorkflowAlive: true,
|
||||
getRedirect: getTemplatesRedirect,
|
||||
permissions: {
|
||||
allow: {
|
||||
loginStatus: [LOGIN_STATUS.LoggedIn],
|
||||
},
|
||||
},
|
||||
middleware: ['authenticated'],
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -355,11 +290,7 @@ export const routes = [
|
||||
templatesEnabled: true,
|
||||
keepWorkflowAlive: true,
|
||||
getRedirect: () => getTemplatesRedirect(VIEWS.NEW_WORKFLOW),
|
||||
permissions: {
|
||||
allow: {
|
||||
loginStatus: [LOGIN_STATUS.LoggedIn],
|
||||
},
|
||||
},
|
||||
middleware: ['authenticated'],
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -373,11 +304,7 @@ export const routes = [
|
||||
meta: {
|
||||
nodeView: true,
|
||||
keepWorkflowAlive: true,
|
||||
permissions: {
|
||||
allow: {
|
||||
loginStatus: [LOGIN_STATUS.LoggedIn],
|
||||
},
|
||||
},
|
||||
middleware: ['authenticated'],
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -387,11 +314,7 @@ export const routes = [
|
||||
default: NodeView,
|
||||
},
|
||||
meta: {
|
||||
permissions: {
|
||||
allow: {
|
||||
loginStatus: [LOGIN_STATUS.LoggedIn],
|
||||
},
|
||||
},
|
||||
middleware: ['authenticated'],
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -405,11 +328,7 @@ export const routes = [
|
||||
meta: {
|
||||
nodeView: true,
|
||||
keepWorkflowAlive: true,
|
||||
permissions: {
|
||||
allow: {
|
||||
loginStatus: [LOGIN_STATUS.LoggedIn],
|
||||
},
|
||||
},
|
||||
middleware: ['authenticated'],
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -426,11 +345,7 @@ export const routes = [
|
||||
telemetry: {
|
||||
pageCategory: 'auth',
|
||||
},
|
||||
permissions: {
|
||||
allow: {
|
||||
loginStatus: [LOGIN_STATUS.LoggedOut],
|
||||
},
|
||||
},
|
||||
middleware: ['guest'],
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -443,11 +358,7 @@ export const routes = [
|
||||
telemetry: {
|
||||
pageCategory: 'auth',
|
||||
},
|
||||
permissions: {
|
||||
allow: {
|
||||
loginStatus: [LOGIN_STATUS.LoggedOut],
|
||||
},
|
||||
},
|
||||
middleware: ['guest'],
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -460,11 +371,7 @@ export const routes = [
|
||||
telemetry: {
|
||||
pageCategory: 'auth',
|
||||
},
|
||||
permissions: {
|
||||
allow: {
|
||||
loginStatus: [LOGIN_STATUS.LoggedIn],
|
||||
},
|
||||
},
|
||||
middleware: ['authenticated'],
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -474,14 +381,13 @@ export const routes = [
|
||||
default: SetupView,
|
||||
},
|
||||
meta: {
|
||||
middleware: ['role'],
|
||||
middlewareOptions: {
|
||||
role: [ROLE.Default],
|
||||
},
|
||||
telemetry: {
|
||||
pageCategory: 'auth',
|
||||
},
|
||||
permissions: {
|
||||
allow: {
|
||||
role: [ROLE.Default],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -491,14 +397,10 @@ export const routes = [
|
||||
default: ForgotMyPasswordView,
|
||||
},
|
||||
meta: {
|
||||
middleware: ['guest'],
|
||||
telemetry: {
|
||||
pageCategory: 'auth',
|
||||
},
|
||||
permissions: {
|
||||
allow: {
|
||||
loginStatus: [LOGIN_STATUS.LoggedOut],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -508,14 +410,10 @@ export const routes = [
|
||||
default: ChangePasswordView,
|
||||
},
|
||||
meta: {
|
||||
middleware: ['guest'],
|
||||
telemetry: {
|
||||
pageCategory: 'auth',
|
||||
},
|
||||
permissions: {
|
||||
allow: {
|
||||
loginStatus: [LOGIN_STATUS.LoggedOut],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -530,6 +428,16 @@ export const routes = [
|
||||
settingsView: SettingsUsageAndPlan,
|
||||
},
|
||||
meta: {
|
||||
middleware: ['authenticated', 'custom'],
|
||||
middlewareOptions: {
|
||||
custom: () => {
|
||||
const settingsStore = useSettingsStore();
|
||||
return !(
|
||||
settingsStore.settings.hideUsagePage ||
|
||||
settingsStore.settings.deployment?.type === 'cloud'
|
||||
);
|
||||
},
|
||||
},
|
||||
telemetry: {
|
||||
pageCategory: 'settings',
|
||||
getProperties(route: RouteLocation) {
|
||||
@ -538,20 +446,6 @@ export const routes = [
|
||||
};
|
||||
},
|
||||
},
|
||||
permissions: {
|
||||
allow: {
|
||||
loginStatus: [LOGIN_STATUS.LoggedIn],
|
||||
},
|
||||
deny: {
|
||||
shouldDeny: () => {
|
||||
const settingsStore = useSettingsStore();
|
||||
return (
|
||||
settingsStore.settings.hideUsagePage ||
|
||||
settingsStore.settings.deployment?.type === 'cloud'
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -561,6 +455,10 @@ export const routes = [
|
||||
settingsView: SettingsPersonalView,
|
||||
},
|
||||
meta: {
|
||||
middleware: ['authenticated', 'role'],
|
||||
middlewareOptions: {
|
||||
role: [ROLE.Owner, ROLE.Member],
|
||||
},
|
||||
telemetry: {
|
||||
pageCategory: 'settings',
|
||||
getProperties(route: RouteLocation) {
|
||||
@ -569,14 +467,6 @@ export const routes = [
|
||||
};
|
||||
},
|
||||
},
|
||||
permissions: {
|
||||
allow: {
|
||||
loginStatus: [LOGIN_STATUS.LoggedIn],
|
||||
},
|
||||
deny: {
|
||||
role: [ROLE.Default],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -586,6 +476,10 @@ export const routes = [
|
||||
settingsView: SettingsUsersView,
|
||||
},
|
||||
meta: {
|
||||
middleware: ['authenticated', 'role'],
|
||||
middlewareOptions: {
|
||||
role: [ROLE.Owner],
|
||||
},
|
||||
telemetry: {
|
||||
pageCategory: 'settings',
|
||||
getProperties(route: RouteLocation) {
|
||||
@ -594,11 +488,6 @@ export const routes = [
|
||||
};
|
||||
},
|
||||
},
|
||||
permissions: {
|
||||
allow: {
|
||||
role: [ROLE.Owner],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -608,6 +497,13 @@ export const routes = [
|
||||
settingsView: SettingsApiView,
|
||||
},
|
||||
meta: {
|
||||
middleware: ['authenticated', 'custom'],
|
||||
middlewareOptions: {
|
||||
custom: () => {
|
||||
const settingsStore = useSettingsStore();
|
||||
return settingsStore.isPublicApiEnabled;
|
||||
},
|
||||
},
|
||||
telemetry: {
|
||||
pageCategory: 'settings',
|
||||
getProperties(route: RouteLocation) {
|
||||
@ -616,17 +512,6 @@ export const routes = [
|
||||
};
|
||||
},
|
||||
},
|
||||
permissions: {
|
||||
allow: {
|
||||
loginStatus: [LOGIN_STATUS.LoggedIn],
|
||||
},
|
||||
deny: {
|
||||
shouldDeny: () => {
|
||||
const settingsStore = useSettingsStore();
|
||||
return !settingsStore.isPublicApiEnabled;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -636,6 +521,10 @@ export const routes = [
|
||||
settingsView: SettingsSourceControl,
|
||||
},
|
||||
meta: {
|
||||
middleware: ['authenticated', 'role'],
|
||||
middlewareOptions: {
|
||||
role: [ROLE.Owner],
|
||||
},
|
||||
telemetry: {
|
||||
pageCategory: 'settings',
|
||||
getProperties(route: RouteLocation) {
|
||||
@ -644,11 +533,6 @@ export const routes = [
|
||||
};
|
||||
},
|
||||
},
|
||||
permissions: {
|
||||
allow: {
|
||||
role: [ROLE.Owner],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -658,19 +542,18 @@ export const routes = [
|
||||
settingsView: SettingsExternalSecrets,
|
||||
},
|
||||
meta: {
|
||||
middleware: ['authenticated', 'role'],
|
||||
middlewareOptions: {
|
||||
role: [ROLE.Owner],
|
||||
},
|
||||
telemetry: {
|
||||
pageCategory: 'settings',
|
||||
getProperties(route: Route) {
|
||||
getProperties(route: RouteLocation) {
|
||||
return {
|
||||
feature: 'external-secrets',
|
||||
};
|
||||
},
|
||||
},
|
||||
permissions: {
|
||||
allow: {
|
||||
role: [ROLE.Owner],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -680,6 +563,14 @@ export const routes = [
|
||||
settingsView: SettingsSso,
|
||||
},
|
||||
meta: {
|
||||
middleware: ['authenticated', 'role', 'custom'],
|
||||
middlewareOptions: {
|
||||
custom: () => {
|
||||
const settingsStore = useSettingsStore();
|
||||
return !settingsStore.isDesktopDeployment;
|
||||
},
|
||||
role: [ROLE.Owner],
|
||||
},
|
||||
telemetry: {
|
||||
pageCategory: 'settings',
|
||||
getProperties(route: RouteLocation) {
|
||||
@ -688,17 +579,6 @@ export const routes = [
|
||||
};
|
||||
},
|
||||
},
|
||||
permissions: {
|
||||
allow: {
|
||||
role: [ROLE.Owner],
|
||||
},
|
||||
deny: {
|
||||
shouldDeny: () => {
|
||||
const settingsStore = useSettingsStore();
|
||||
return settingsStore.isDesktopDeployment;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -708,17 +588,13 @@ export const routes = [
|
||||
settingsView: SettingsLogStreamingView,
|
||||
},
|
||||
meta: {
|
||||
middleware: ['authenticated', 'role'],
|
||||
middlewareOptions: {
|
||||
role: [ROLE.Owner],
|
||||
},
|
||||
telemetry: {
|
||||
pageCategory: 'settings',
|
||||
},
|
||||
permissions: {
|
||||
allow: {
|
||||
role: [ROLE.Owner],
|
||||
},
|
||||
deny: {
|
||||
role: [ROLE.Member],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -728,11 +604,7 @@ export const routes = [
|
||||
settingsView: WorkerView,
|
||||
},
|
||||
meta: {
|
||||
permissions: {
|
||||
allow: {
|
||||
loginStatus: [LOGIN_STATUS.LoggedIn],
|
||||
},
|
||||
},
|
||||
middleware: ['authenticated'],
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -742,20 +614,17 @@ export const routes = [
|
||||
settingsView: SettingsCommunityNodesView,
|
||||
},
|
||||
meta: {
|
||||
middleware: ['authenticated', 'role', 'custom'],
|
||||
middlewareOptions: {
|
||||
role: [ROLE.Owner],
|
||||
custom: () => {
|
||||
const settingsStore = useSettingsStore();
|
||||
return settingsStore.isCommunityNodesFeatureEnabled;
|
||||
},
|
||||
},
|
||||
telemetry: {
|
||||
pageCategory: 'settings',
|
||||
},
|
||||
permissions: {
|
||||
allow: {
|
||||
role: [ROLE.Owner],
|
||||
},
|
||||
deny: {
|
||||
shouldDeny: () => {
|
||||
const settingsStore = useSettingsStore();
|
||||
return !settingsStore.isCommunityNodesFeatureEnabled;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -765,6 +634,7 @@ export const routes = [
|
||||
settingsView: SettingsFakeDoorView,
|
||||
},
|
||||
meta: {
|
||||
middleware: ['authenticated'],
|
||||
telemetry: {
|
||||
pageCategory: 'settings',
|
||||
getProperties(route: RouteLocation) {
|
||||
@ -773,11 +643,6 @@ export const routes = [
|
||||
};
|
||||
},
|
||||
},
|
||||
permissions: {
|
||||
allow: {
|
||||
loginStatus: [LOGIN_STATUS.LoggedIn],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -787,13 +652,9 @@ export const routes = [
|
||||
settingsView: SettingsLdapView,
|
||||
},
|
||||
meta: {
|
||||
permissions: {
|
||||
allow: {
|
||||
role: [ROLE.Owner],
|
||||
},
|
||||
deny: {
|
||||
role: [ROLE.Member],
|
||||
},
|
||||
middleware: ['authenticated', 'role'],
|
||||
middlewareOptions: {
|
||||
role: [ROLE.Owner],
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -804,6 +665,13 @@ export const routes = [
|
||||
settingsView: SettingsAuditLogs,
|
||||
},
|
||||
meta: {
|
||||
middleware: ['authenticated', 'role', 'custom'],
|
||||
middlewareOptions: {
|
||||
custom: () => {
|
||||
return !!useStorage('audit-logs').value;
|
||||
},
|
||||
role: [ROLE.Owner],
|
||||
},
|
||||
telemetry: {
|
||||
pageCategory: 'settings',
|
||||
getProperties(route: RouteLocation) {
|
||||
@ -812,14 +680,6 @@ export const routes = [
|
||||
};
|
||||
},
|
||||
},
|
||||
permissions: {
|
||||
allow: {
|
||||
role: [ROLE.Owner],
|
||||
},
|
||||
deny: {
|
||||
shouldDeny: () => !useStorage('audit-logs').value,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
@ -831,25 +691,21 @@ export const routes = [
|
||||
default: SamlOnboarding,
|
||||
},
|
||||
meta: {
|
||||
middleware: ['authenticated', 'custom'],
|
||||
middlewareOptions: {
|
||||
custom: () => {
|
||||
const settingsStore = useSettingsStore();
|
||||
const ssoStore = useSSOStore();
|
||||
return (
|
||||
ssoStore.isEnterpriseSamlEnabled &&
|
||||
!settingsStore.isCloudDeployment &&
|
||||
!settingsStore.isDesktopDeployment
|
||||
);
|
||||
},
|
||||
},
|
||||
telemetry: {
|
||||
pageCategory: 'auth',
|
||||
},
|
||||
permissions: {
|
||||
allow: {
|
||||
loginStatus: [LOGIN_STATUS.LoggedIn],
|
||||
},
|
||||
deny: {
|
||||
shouldDeny: () => {
|
||||
const settingsStore = useSettingsStore();
|
||||
const ssoStore = useSSOStore();
|
||||
return (
|
||||
!ssoStore.isEnterpriseSamlEnabled ||
|
||||
settingsStore.isCloudDeployment ||
|
||||
settingsStore.isDesktopDeployment
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -867,21 +723,15 @@ export const routes = [
|
||||
telemetry: {
|
||||
disabled: true,
|
||||
},
|
||||
permissions: {
|
||||
allow: {
|
||||
// TODO: Once custom permissions are merged, this needs to be updated with index validation
|
||||
loginStatus: [LOGIN_STATUS.LoggedIn, LOGIN_STATUS.LoggedOut],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
] as Array<RouteRecordRaw & IRouteConfig>;
|
||||
] as Array<RouteRecordRaw & RouteConfig>;
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.DEV ? '/' : window.BASE_PATH ?? '/'),
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
scrollBehavior(to: RouteLocationNormalized & RouteConfig, from, savedPosition) {
|
||||
// saved position == null means the page is NOT visited from history (back button)
|
||||
if (savedPosition === null && to.name === VIEWS.TEMPLATES && to.meta) {
|
||||
if (savedPosition === null && to.name === VIEWS.TEMPLATES && to.meta?.setScrollPosition) {
|
||||
// for templates view, reset scroll position in this case
|
||||
to.meta.setScrollPosition(0);
|
||||
}
|
||||
@ -889,19 +739,19 @@ const router = createRouter({
|
||||
routes,
|
||||
});
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
router.beforeEach(async (to: RouteLocationNormalized & RouteConfig, from, next) => {
|
||||
/**
|
||||
* Initialize stores before routing
|
||||
* Initialize application core
|
||||
* This step executes before first route is loaded and is required for permission checks
|
||||
*/
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
const usersStore = useUsersStore();
|
||||
await usersStore.initialize();
|
||||
await initializeCore();
|
||||
|
||||
/**
|
||||
* Redirect to setup page. User should be redirected to this only once
|
||||
*/
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
if (settingsStore.showSetupPage) {
|
||||
if (to.name === VIEWS.SETUP) {
|
||||
return next();
|
||||
@ -914,41 +764,25 @@ router.beforeEach(async (to, from, next) => {
|
||||
* Verify user permissions for current route
|
||||
*/
|
||||
|
||||
const currentUser = usersStore.currentUser;
|
||||
const permissions = to.meta?.permissions as IPermissions;
|
||||
const canUserAccessCurrentRoute = permissions && isAuthorized(permissions, currentUser);
|
||||
if (canUserAccessCurrentRoute) {
|
||||
return next();
|
||||
}
|
||||
const routeMiddleware = to.meta?.middleware ?? [];
|
||||
const routeMiddlewareOptions = to.meta?.middlewareOptions ?? {};
|
||||
for (const middlewareName of routeMiddleware) {
|
||||
let nextCalled = false;
|
||||
const middlewareNext = ((location: RouteLocationRaw): void => {
|
||||
next(location);
|
||||
nextCalled = true;
|
||||
}) as NavigationGuardNext;
|
||||
|
||||
/**
|
||||
* If user cannot access the page and is not logged in, redirect to sign in
|
||||
*/
|
||||
const middlewareOptions = routeMiddlewareOptions[middlewareName];
|
||||
const middlewareFn = middleware[middlewareName] as RouterMiddleware<unknown>;
|
||||
await middlewareFn(to, from, middlewareNext, middlewareOptions);
|
||||
|
||||
if (!currentUser) {
|
||||
const redirect =
|
||||
to.query.redirect ||
|
||||
encodeURIComponent(`${window.location.pathname}${window.location.search}`);
|
||||
return next({ name: VIEWS.SIGNIN, query: { redirect } });
|
||||
}
|
||||
|
||||
/**
|
||||
* If user cannot access page but is logged in, respect sign in redirect
|
||||
*/
|
||||
|
||||
if (to.name === VIEWS.SIGNIN && typeof to.query.redirect === 'string') {
|
||||
const redirect = decodeURIComponent(to.query.redirect);
|
||||
if (redirect.startsWith('/')) {
|
||||
// protect against phishing
|
||||
return next(redirect);
|
||||
if (nextCalled) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Otherwise, redirect to home page
|
||||
*/
|
||||
|
||||
return next({ name: VIEWS.HOMEPAGE });
|
||||
return next();
|
||||
});
|
||||
|
||||
router.afterEach((to, from) => {
|
||||
|
152
packages/editor-ui/src/stores/__tests__/rbac.store.test.ts
Normal file
152
packages/editor-ui/src/stores/__tests__/rbac.store.test.ts
Normal file
@ -0,0 +1,152 @@
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { useRBACStore } from '@/stores/rbac.store';
|
||||
import type { Scope } from '@n8n/permissions';
|
||||
import { hasScope } from '@n8n/permissions';
|
||||
|
||||
vi.mock('@n8n/permissions', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
const { hasScope } = await vi.importActual<typeof import('@n8n/permissions')>('@n8n/permissions');
|
||||
return {
|
||||
hasScope: vi.fn().mockImplementation(hasScope),
|
||||
};
|
||||
});
|
||||
|
||||
describe('RBAC store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
describe('addGlobalScope()', () => {
|
||||
it('should add global scope', () => {
|
||||
const newScope = 'example:list' as Scope;
|
||||
const rbacStore = useRBACStore();
|
||||
rbacStore.addGlobalScope(newScope);
|
||||
expect(rbacStore.globalScopes).toContain(newScope);
|
||||
});
|
||||
|
||||
it('should not add global scope if it already exists', () => {
|
||||
const newScope = 'example:list' as Scope;
|
||||
const rbacStore = useRBACStore();
|
||||
rbacStore.addGlobalScope(newScope);
|
||||
rbacStore.addGlobalScope(newScope);
|
||||
expect(rbacStore.globalScopes.filter((scope) => scope === newScope)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addProjectScope()', () => {
|
||||
it('should add project scope', () => {
|
||||
const newScope = 'example:list' as Scope;
|
||||
const rbacStore = useRBACStore();
|
||||
rbacStore.addProjectScope(newScope, { projectId: '1' });
|
||||
expect(rbacStore.scopesByProjectId['1']).toContain(newScope);
|
||||
});
|
||||
|
||||
it('should not add project scope if it already exists', () => {
|
||||
const newScope = 'example:list' as Scope;
|
||||
const rbacStore = useRBACStore();
|
||||
rbacStore.addProjectScope(newScope, { projectId: '1' });
|
||||
rbacStore.addProjectScope(newScope, { projectId: '1' });
|
||||
expect(rbacStore.scopesByProjectId['1'].filter((scope) => scope === newScope)).toHaveLength(
|
||||
1,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addResourceScope()', () => {
|
||||
it('should add resource scope', () => {
|
||||
const newScope = 'example:list' as Scope;
|
||||
const rbacStore = useRBACStore();
|
||||
rbacStore.addResourceScope(newScope, { resourceId: '1', resourceType: 'variable' });
|
||||
expect(rbacStore.scopesByResourceId['variable']['1']).toContain(newScope);
|
||||
});
|
||||
|
||||
it('should not add resource scope if it already exists', () => {
|
||||
const newScope = 'example:list' as Scope;
|
||||
const rbacStore = useRBACStore();
|
||||
rbacStore.addResourceScope(newScope, { resourceId: '1', resourceType: 'variable' });
|
||||
rbacStore.addResourceScope(newScope, { resourceId: '1', resourceType: 'variable' });
|
||||
expect(
|
||||
rbacStore.scopesByResourceId['variable']['1'].filter((scope) => scope === newScope),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasScope()', () => {
|
||||
it('evaluates global scope correctly', () => {
|
||||
const newScope = 'example:list' as Scope;
|
||||
const store = useRBACStore();
|
||||
store.addGlobalScope(newScope);
|
||||
|
||||
const result = store.hasScope(newScope, {});
|
||||
expect(result).toBe(true);
|
||||
expect(vi.mocked(hasScope)).toHaveBeenCalledWith(
|
||||
newScope,
|
||||
{
|
||||
global: expect.arrayContaining([newScope]),
|
||||
project: [],
|
||||
resource: [],
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('evaluates project scope correctly', () => {
|
||||
const newScope = 'example:list' as Scope;
|
||||
const store = useRBACStore();
|
||||
store.addProjectScope(newScope, { projectId: '1' });
|
||||
|
||||
const result = store.hasScope(newScope, { projectId: '1' });
|
||||
expect(result).toBe(true);
|
||||
expect(vi.mocked(hasScope)).toHaveBeenCalledWith(
|
||||
newScope,
|
||||
{
|
||||
global: expect.any(Array),
|
||||
project: expect.arrayContaining([newScope]),
|
||||
resource: [],
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('evaluates resource scope correctly', () => {
|
||||
const newScope = 'example:list' as Scope;
|
||||
const store = useRBACStore();
|
||||
store.addResourceScope(newScope, { resourceId: '1', resourceType: 'variable' });
|
||||
|
||||
const result = store.hasScope(newScope, { resourceId: '1', resourceType: 'variable' });
|
||||
expect(result).toBe(true);
|
||||
expect(vi.mocked(hasScope)).toHaveBeenCalledWith(
|
||||
newScope,
|
||||
{
|
||||
global: expect.any(Array),
|
||||
project: [],
|
||||
resource: expect.arrayContaining([newScope]),
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('evaluates project and resource scope correctly', () => {
|
||||
const newScope = 'example:list' as Scope;
|
||||
const store = useRBACStore();
|
||||
store.addProjectScope(newScope, { projectId: '1' });
|
||||
store.addResourceScope(newScope, { resourceId: '1', resourceType: 'variable' });
|
||||
|
||||
const result = store.hasScope(newScope, {
|
||||
projectId: '1',
|
||||
resourceId: '1',
|
||||
resourceType: 'variable',
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
expect(vi.mocked(hasScope)).toHaveBeenCalledWith(
|
||||
newScope,
|
||||
{
|
||||
global: expect.any(Array),
|
||||
project: expect.arrayContaining([newScope]),
|
||||
resource: expect.arrayContaining([newScope]),
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
@ -10,6 +10,7 @@ import { DateTime } from 'luxon';
|
||||
import { CLOUD_TRIAL_CHECK_INTERVAL, STORES } from '@/constants';
|
||||
|
||||
const DEFAULT_STATE: CloudPlanState = {
|
||||
initialized: false,
|
||||
data: null,
|
||||
usage: null,
|
||||
loadingPlan: false,
|
||||
@ -157,8 +158,20 @@ export const useCloudPlanStore = defineStore(STORES.CLOUD_PLAN, () => {
|
||||
window.location.href = `https://${adminPanelHost}/login?code=${code}`;
|
||||
};
|
||||
|
||||
const initialize = async () => {
|
||||
if (state.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
await checkForCloudPlanData();
|
||||
await fetchUserCloudAccount();
|
||||
|
||||
state.initialized = true;
|
||||
};
|
||||
|
||||
return {
|
||||
state,
|
||||
initialize,
|
||||
getOwnerCurrentPlan,
|
||||
getInstanceCurrentUsage,
|
||||
usageLeft,
|
||||
|
@ -26,5 +26,6 @@ export * from './cloudPlan.store';
|
||||
export * from './sourceControl.store';
|
||||
export * from './sso.store';
|
||||
export * from './auditLogs.store';
|
||||
export * from './rbac.store';
|
||||
export * from './collaboration.store';
|
||||
export * from './pushConnection.store';
|
||||
|
113
packages/editor-ui/src/stores/rbac.store.ts
Normal file
113
packages/editor-ui/src/stores/rbac.store.ts
Normal file
@ -0,0 +1,113 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { hasScope as genericHasScope } from '@n8n/permissions';
|
||||
import type { HasScopeOptions, Scope, Resource } from '@n8n/permissions';
|
||||
import { ref } from 'vue';
|
||||
import { STORES } from '@/constants';
|
||||
import type { IRole } from '@/Interface';
|
||||
|
||||
export const useRBACStore = defineStore(STORES.RBAC, () => {
|
||||
const globalRoles = ref<IRole[]>([]);
|
||||
const rolesByProjectId = ref<Record<string, string[]>>({});
|
||||
|
||||
const globalScopes = ref<Scope[]>([]);
|
||||
const scopesByProjectId = ref<Record<string, Scope[]>>({});
|
||||
const scopesByResourceId = ref<Record<Resource, Record<string, Scope[]>>>({
|
||||
workflow: {},
|
||||
tag: {},
|
||||
user: {},
|
||||
credential: {},
|
||||
variable: {},
|
||||
sourceControl: {},
|
||||
externalSecretsStore: {},
|
||||
});
|
||||
|
||||
function addGlobalRole(role: IRole) {
|
||||
if (!globalRoles.value.includes(role)) {
|
||||
globalRoles.value.push(role);
|
||||
}
|
||||
}
|
||||
|
||||
function hasRole(role: IRole) {
|
||||
return globalRoles.value.includes(role);
|
||||
}
|
||||
|
||||
function addGlobalScope(scope: Scope) {
|
||||
if (!globalScopes.value.includes(scope)) {
|
||||
globalScopes.value.push(scope);
|
||||
}
|
||||
}
|
||||
|
||||
function setGlobalScopes(scopes: Scope[]) {
|
||||
globalScopes.value = scopes;
|
||||
}
|
||||
|
||||
function addProjectScope(
|
||||
scope: Scope,
|
||||
context: {
|
||||
projectId: string;
|
||||
},
|
||||
) {
|
||||
if (!scopesByProjectId.value[context.projectId]) {
|
||||
scopesByProjectId.value[context.projectId] = [];
|
||||
}
|
||||
|
||||
if (!scopesByProjectId.value[context.projectId].includes(scope)) {
|
||||
scopesByProjectId.value[context.projectId].push(scope);
|
||||
}
|
||||
}
|
||||
|
||||
function addResourceScope(
|
||||
scope: Scope,
|
||||
context: {
|
||||
resourceType: Resource;
|
||||
resourceId: string;
|
||||
},
|
||||
) {
|
||||
const scopesByResourceType = scopesByResourceId.value[context.resourceType];
|
||||
if (!scopesByResourceType[context.resourceId]) {
|
||||
scopesByResourceType[context.resourceId] = [];
|
||||
}
|
||||
|
||||
if (!scopesByResourceType[context.resourceId].includes(scope)) {
|
||||
scopesByResourceType[context.resourceId].push(scope);
|
||||
}
|
||||
}
|
||||
|
||||
function hasScope(
|
||||
scope: Scope | Scope[],
|
||||
context?: {
|
||||
resourceType?: Resource;
|
||||
resourceId?: string;
|
||||
projectId?: string;
|
||||
},
|
||||
options?: HasScopeOptions,
|
||||
): boolean {
|
||||
return genericHasScope(
|
||||
scope,
|
||||
{
|
||||
global: globalScopes.value,
|
||||
project: context?.projectId ? scopesByProjectId.value[context.projectId] : [],
|
||||
resource:
|
||||
context?.resourceType && context?.resourceId
|
||||
? scopesByResourceId.value[context.resourceType][context.resourceId]
|
||||
: [],
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
globalRoles,
|
||||
rolesByProjectId,
|
||||
globalScopes,
|
||||
scopesByProjectId,
|
||||
scopesByResourceId,
|
||||
addGlobalRole,
|
||||
hasRole,
|
||||
addGlobalScope,
|
||||
setGlobalScopes,
|
||||
addProjectScope,
|
||||
addResourceScope,
|
||||
hasScope,
|
||||
};
|
||||
});
|
@ -111,7 +111,6 @@ export const useUsageStore = defineStore('usage', () => {
|
||||
() =>
|
||||
`${subscriptionAppUrl.value}/manage?token=${managementToken.value}&${commonSubscriptionAppUrlQueryParams.value}`,
|
||||
),
|
||||
canUserActivateLicense: computed(() => usersStore.canUserActivateLicense),
|
||||
isLoading: computed(() => state.loading),
|
||||
telemetryPayload: computed<UsageTelemetry>(() => ({
|
||||
instance_id: instanceId.value,
|
||||
|
@ -26,9 +26,10 @@ import type {
|
||||
IUser,
|
||||
IUserResponse,
|
||||
IUsersState,
|
||||
CurrentUserResponse,
|
||||
} from '@/Interface';
|
||||
import { getCredentialPermissions } from '@/permissions';
|
||||
import { getPersonalizedNodeTypes, isAuthorized, PERMISSIONS, ROLE } from '@/utils';
|
||||
import { getPersonalizedNodeTypes, ROLE } from '@/utils';
|
||||
import { defineStore } from 'pinia';
|
||||
import { useRootStore } from './n8nRoot.store';
|
||||
import { usePostHog } from './posthog.store';
|
||||
@ -37,6 +38,8 @@ import { useUIStore } from './ui.store';
|
||||
import { useCloudPlanStore } from './cloudPlan.store';
|
||||
import { disableMfa, enableMfa, getMfaQR, verifyMfaToken } from '@/api/mfa';
|
||||
import { confirmEmail, getCloudUserInfo } from '@/api/cloudPlans';
|
||||
import { useRBACStore } from '@/stores/rbac.store';
|
||||
import type { Scope } from '@n8n/permissions';
|
||||
import { inviteUsers, acceptInvitation } from '@/api/invitation';
|
||||
|
||||
const isDefaultUser = (user: IUserResponse | null) =>
|
||||
@ -79,26 +82,6 @@ export const useUsersStore = defineStore(STORES.USERS, {
|
||||
globalRoleName(): IRole {
|
||||
return this.currentUser?.globalRole?.name ?? 'default';
|
||||
},
|
||||
canUserDeleteTags(): boolean {
|
||||
return isAuthorized(PERMISSIONS.TAGS.CAN_DELETE_TAGS, this.currentUser);
|
||||
},
|
||||
canUserActivateLicense(): boolean {
|
||||
return isAuthorized(PERMISSIONS.USAGE.CAN_ACTIVATE_LICENSE, this.currentUser);
|
||||
},
|
||||
canUserAccessSidebarUserInfo() {
|
||||
if (this.currentUser) {
|
||||
const currentUser: IUser = this.currentUser;
|
||||
return isAuthorized(PERMISSIONS.PRIMARY_MENU.CAN_ACCESS_USER_INFO, currentUser);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
showUMSetupWarning() {
|
||||
if (this.currentUser) {
|
||||
const currentUser: IUser = this.currentUser;
|
||||
return isAuthorized(PERMISSIONS.USER_SETTINGS.VIEW_UM_SETUP_WARNING, currentUser);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
personalizedNodeTypes(): string[] {
|
||||
const user = this.currentUser;
|
||||
if (!user) {
|
||||
@ -130,6 +113,19 @@ export const useUsersStore = defineStore(STORES.USERS, {
|
||||
this.initialized = true;
|
||||
} catch (e) {}
|
||||
},
|
||||
setCurrentUser(user: CurrentUserResponse) {
|
||||
this.addUsers([user]);
|
||||
this.currentUserId = user.id;
|
||||
|
||||
const defaultScopes: Scope[] = [];
|
||||
useRBACStore().setGlobalScopes(user.globalScopes || defaultScopes);
|
||||
usePostHog().init(user.featureFlags);
|
||||
},
|
||||
unsetCurrentUser() {
|
||||
this.currentUserId = null;
|
||||
this.currentUserCloudInfo = null;
|
||||
useRBACStore().setGlobalScopes([]);
|
||||
},
|
||||
addUsers(users: IUserResponse[]) {
|
||||
users.forEach((userResponse: IUserResponse) => {
|
||||
const prevUser = this.users[userResponse.id] || {};
|
||||
@ -177,10 +173,7 @@ export const useUsersStore = defineStore(STORES.USERS, {
|
||||
return;
|
||||
}
|
||||
|
||||
this.addUsers([user]);
|
||||
this.currentUserId = user.id;
|
||||
|
||||
usePostHog().init(user.featureFlags);
|
||||
this.setCurrentUser(user);
|
||||
},
|
||||
async loginWithCreds(params: {
|
||||
email: string;
|
||||
@ -194,18 +187,14 @@ export const useUsersStore = defineStore(STORES.USERS, {
|
||||
return;
|
||||
}
|
||||
|
||||
this.addUsers([user]);
|
||||
this.currentUserId = user.id;
|
||||
|
||||
usePostHog().init(user.featureFlags);
|
||||
this.setCurrentUser(user);
|
||||
},
|
||||
async logout(): Promise<void> {
|
||||
const rootStore = useRootStore();
|
||||
await logout(rootStore.getRestApiContext);
|
||||
this.currentUserId = null;
|
||||
this.unsetCurrentUser();
|
||||
useCloudPlanStore().reset();
|
||||
usePostHog().reset();
|
||||
this.currentUserCloudInfo = null;
|
||||
useUIStore().clearBannerStack();
|
||||
},
|
||||
async createOwner(params: {
|
||||
@ -218,10 +207,8 @@ export const useUsersStore = defineStore(STORES.USERS, {
|
||||
const user = await setupOwner(rootStore.getRestApiContext, params);
|
||||
const settingsStore = useSettingsStore();
|
||||
if (user) {
|
||||
this.addUsers([user]);
|
||||
this.currentUserId = user.id;
|
||||
this.setCurrentUser(user);
|
||||
settingsStore.stopShowingSetupPage();
|
||||
usePostHog().init(user.featureFlags);
|
||||
}
|
||||
},
|
||||
async validateSignupToken(params: {
|
||||
@ -241,9 +228,7 @@ export const useUsersStore = defineStore(STORES.USERS, {
|
||||
const rootStore = useRootStore();
|
||||
const user = await acceptInvitation(rootStore.getRestApiContext, params);
|
||||
if (user) {
|
||||
this.addUsers([user]);
|
||||
this.currentUserId = user.id;
|
||||
usePostHog().init(user.featureFlags);
|
||||
this.setCurrentUser(user);
|
||||
}
|
||||
},
|
||||
async sendForgotPasswordEmail(params: { email: string }): Promise<void> {
|
||||
|
33
packages/editor-ui/src/types/rbac.ts
Normal file
33
packages/editor-ui/src/types/rbac.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import type { EnterpriseEditionFeature } from '@/constants';
|
||||
import type { Resource, HasScopeOptions, Scope } from '@n8n/permissions';
|
||||
import type { IRole } from '@/Interface';
|
||||
|
||||
export type AuthenticatedPermissionOptions = {};
|
||||
export type CustomPermissionOptions<C = {}> = RBACPermissionCheck<C>;
|
||||
export type EnterprisePermissionOptions = {
|
||||
feature?: EnterpriseEditionFeature | EnterpriseEditionFeature[];
|
||||
mode?: 'oneOf' | 'allOf';
|
||||
};
|
||||
export type GuestPermissionOptions = {};
|
||||
export type RBACPermissionOptions = {
|
||||
scope?: Scope | Scope[];
|
||||
projectId?: string;
|
||||
resourceType?: Resource;
|
||||
resourceId?: string;
|
||||
options?: HasScopeOptions;
|
||||
};
|
||||
export type RolePermissionOptions = IRole[];
|
||||
|
||||
export type PermissionType = 'authenticated' | 'custom' | 'enterprise' | 'guest' | 'rbac' | 'role';
|
||||
export type PermissionTypeOptions = {
|
||||
authenticated: AuthenticatedPermissionOptions;
|
||||
custom: CustomPermissionOptions;
|
||||
enterprise: EnterprisePermissionOptions;
|
||||
guest: GuestPermissionOptions;
|
||||
rbac: RBACPermissionOptions;
|
||||
role: RolePermissionOptions;
|
||||
};
|
||||
|
||||
export interface RBACPermissionCheck<Options> {
|
||||
(options?: Options): boolean;
|
||||
}
|
59
packages/editor-ui/src/types/router.ts
Normal file
59
packages/editor-ui/src/types/router.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import type {
|
||||
NavigationGuardNext,
|
||||
NavigationGuardWithThis,
|
||||
RouteLocationNormalized,
|
||||
RouteLocation,
|
||||
} from 'vue-router';
|
||||
import type { IPermissions } from '@/Interface';
|
||||
import type {
|
||||
AuthenticatedPermissionOptions,
|
||||
CustomPermissionOptions,
|
||||
EnterprisePermissionOptions,
|
||||
GuestPermissionOptions,
|
||||
RBACPermissionOptions,
|
||||
RolePermissionOptions,
|
||||
PermissionType,
|
||||
} from '@/types/rbac';
|
||||
|
||||
export type RouterMiddlewareType = PermissionType;
|
||||
export type CustomMiddlewareOptions = CustomPermissionOptions<{
|
||||
to: RouteLocationNormalized;
|
||||
from: RouteLocationNormalized;
|
||||
next: NavigationGuardNext;
|
||||
}>;
|
||||
export type MiddlewareOptions = {
|
||||
authenticated: AuthenticatedPermissionOptions;
|
||||
custom: CustomMiddlewareOptions;
|
||||
enterprise: EnterprisePermissionOptions;
|
||||
guest: GuestPermissionOptions;
|
||||
rbac: RBACPermissionOptions;
|
||||
role: RolePermissionOptions;
|
||||
};
|
||||
|
||||
export interface RouteConfig {
|
||||
meta: {
|
||||
nodeView?: boolean;
|
||||
templatesEnabled?: boolean;
|
||||
getRedirect?: () => { name: string } | false;
|
||||
permissions?: IPermissions;
|
||||
middleware?: RouterMiddlewareType[];
|
||||
middlewareOptions?: Partial<MiddlewareOptions>;
|
||||
telemetry?: {
|
||||
disabled?: true;
|
||||
getProperties: (route: RouteLocation) => object;
|
||||
};
|
||||
scrollOffset?: number;
|
||||
setScrollPosition?: (position: number) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export type RouterMiddlewareReturnType = ReturnType<NavigationGuardWithThis<undefined>>;
|
||||
|
||||
export interface RouterMiddleware<RouterMiddlewareOptions = {}> {
|
||||
(
|
||||
to: RouteLocationNormalized,
|
||||
from: RouteLocationNormalized,
|
||||
next: NavigationGuardNext,
|
||||
options: RouterMiddlewareOptions,
|
||||
): RouterMiddlewareReturnType;
|
||||
}
|
65
packages/editor-ui/src/utils/__tests__/rbacUtils.test.ts
Normal file
65
packages/editor-ui/src/utils/__tests__/rbacUtils.test.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import type { RouteLocationNormalized } from 'vue-router';
|
||||
import {
|
||||
inferProjectIdFromRoute,
|
||||
inferResourceTypeFromRoute,
|
||||
inferResourceIdFromRoute,
|
||||
} from '../rbacUtils';
|
||||
|
||||
describe('rbacUtils', () => {
|
||||
describe('inferProjectIdFromRoute()', () => {
|
||||
it('should infer project ID from route correctly', () => {
|
||||
const route = { path: '/dashboard/projects/123/settings' } as RouteLocationNormalized;
|
||||
const projectId = inferProjectIdFromRoute(route);
|
||||
expect(projectId).toBe('123');
|
||||
});
|
||||
|
||||
it('should return undefined for project ID if not found', () => {
|
||||
const route = { path: '/dashboard/settings' } as RouteLocationNormalized;
|
||||
const projectId = inferProjectIdFromRoute(route);
|
||||
expect(projectId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('inferResourceTypeFromRoute()', () => {
|
||||
it.each([
|
||||
['/workflows', 'workflow'],
|
||||
['/workflows/123', 'workflow'],
|
||||
['/workflows/123/settings', 'workflow'],
|
||||
['/credentials', 'credential'],
|
||||
['/variables', 'variable'],
|
||||
['/users', 'user'],
|
||||
['/source-control', 'sourceControl'],
|
||||
['/external-secrets', 'externalSecretsStore'],
|
||||
])('should infer resource type from %s correctly to %s', (path, type) => {
|
||||
const route = { path } as RouteLocationNormalized;
|
||||
const resourceType = inferResourceTypeFromRoute(route);
|
||||
expect(resourceType).toBe(type);
|
||||
});
|
||||
|
||||
it('should return undefined for resource type if not found', () => {
|
||||
const route = { path: '/dashboard/settings' } as RouteLocationNormalized;
|
||||
const resourceType = inferResourceTypeFromRoute(route);
|
||||
expect(resourceType).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('inferResourceIdFromRoute()', () => {
|
||||
it('should infer resource ID from params.id', () => {
|
||||
const route = { params: { id: 'abc123' } } as RouteLocationNormalized;
|
||||
const resourceId = inferResourceIdFromRoute(route);
|
||||
expect(resourceId).toBe('abc123');
|
||||
});
|
||||
|
||||
it('should infer resource ID from params.name if id is not present', () => {
|
||||
const route = { params: { name: 'my-resource' } } as RouteLocationNormalized;
|
||||
const resourceId = inferResourceIdFromRoute(route);
|
||||
expect(resourceId).toBe('my-resource');
|
||||
});
|
||||
|
||||
it('should return undefined for resource ID if neither id nor name is present', () => {
|
||||
const route = { params: {} } as RouteLocationNormalized;
|
||||
const resourceId = inferResourceIdFromRoute(route);
|
||||
expect(resourceId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
@ -1,67 +0,0 @@
|
||||
import { beforeAll } from 'vitest';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { merge } from 'lodash-es';
|
||||
import { isAuthorized, ROLE } from '@/utils';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useSSOStore } from '@/stores/sso.store';
|
||||
import type { IUser } from '@/Interface';
|
||||
import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
|
||||
import type { IN8nUISettings } from 'n8n-workflow';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
|
||||
const DEFAULT_SETTINGS: IN8nUISettings = SETTINGS_STORE_DEFAULT_STATE.settings;
|
||||
|
||||
const DEFAULT_USER: IUser = {
|
||||
id: '1',
|
||||
isPending: false,
|
||||
isDefaultUser: true,
|
||||
isOwner: false,
|
||||
isPendingUser: false,
|
||||
globalRole: {
|
||||
name: 'default',
|
||||
id: '1',
|
||||
createdAt: new Date(),
|
||||
},
|
||||
};
|
||||
|
||||
describe('userUtils', () => {
|
||||
let settingsStore: ReturnType<typeof useSettingsStore>;
|
||||
let ssoStore: ReturnType<typeof useSSOStore>;
|
||||
|
||||
describe('isAuthorized', () => {
|
||||
beforeAll(() => {
|
||||
setActivePinia(createTestingPinia());
|
||||
settingsStore = useSettingsStore();
|
||||
ssoStore = useSSOStore();
|
||||
});
|
||||
|
||||
// @TODO Move to routes tests in the future
|
||||
it('should check SSO settings route permissions', () => {
|
||||
const ssoSettingsPermissions = {
|
||||
allow: {
|
||||
role: [ROLE.Owner],
|
||||
},
|
||||
deny: {
|
||||
shouldDeny: () => {
|
||||
const settingsStore = useSettingsStore();
|
||||
return settingsStore.isDesktopDeployment;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const user: IUser = merge({}, DEFAULT_USER, {
|
||||
isDefaultUser: false,
|
||||
isOwner: true,
|
||||
globalRole: {
|
||||
id: '1',
|
||||
name: 'owner',
|
||||
createdAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
settingsStore.setSettings(merge({}, DEFAULT_SETTINGS, { enterprise: { saml: true } }));
|
||||
|
||||
expect(isAuthorized(ssoSettingsPermissions, user)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
32
packages/editor-ui/src/utils/rbacUtils.ts
Normal file
32
packages/editor-ui/src/utils/rbacUtils.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import type { RouteLocationNormalized } from 'vue-router';
|
||||
import type { Resource } from '@n8n/permissions';
|
||||
|
||||
export function inferProjectIdFromRoute(to: RouteLocationNormalized): string {
|
||||
const routeParts = to.path.split('/');
|
||||
const projectsIndex = routeParts.indexOf('projects');
|
||||
const projectIdIndex = projectsIndex !== -1 ? projectsIndex + 1 : -1;
|
||||
|
||||
return routeParts[projectIdIndex];
|
||||
}
|
||||
|
||||
export function inferResourceTypeFromRoute(to: RouteLocationNormalized): Resource | undefined {
|
||||
const routeParts = to.path.split('/');
|
||||
const routeMap = {
|
||||
workflow: 'workflows',
|
||||
credential: 'credentials',
|
||||
user: 'users',
|
||||
variable: 'variables',
|
||||
sourceControl: 'source-control',
|
||||
externalSecretsStore: 'external-secrets',
|
||||
};
|
||||
|
||||
for (const resource of Object.keys(routeMap) as Array<keyof typeof routeMap>) {
|
||||
if (routeParts.includes(routeMap[resource])) {
|
||||
return resource;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function inferResourceIdFromRoute(to: RouteLocationNormalized): string | undefined {
|
||||
return (to.params.id as string | undefined) ?? (to.params.name as string | undefined);
|
||||
}
|
@ -61,7 +61,6 @@ import {
|
||||
CODE_NODE_TYPE,
|
||||
} from '@/constants';
|
||||
import type {
|
||||
IPermissions,
|
||||
IPersonalizationSurveyAnswersV1,
|
||||
IPersonalizationSurveyAnswersV2,
|
||||
IPersonalizationSurveyAnswersV3,
|
||||
@ -70,7 +69,6 @@ import type {
|
||||
IUser,
|
||||
ILogInStatus,
|
||||
IRole,
|
||||
IUserPermissions,
|
||||
} from '@/Interface';
|
||||
|
||||
/*
|
||||
@ -97,89 +95,6 @@ export const LOGIN_STATUS: { LoggedIn: ILogInStatus; LoggedOut: ILogInStatus } =
|
||||
LoggedOut: 'LoggedOut', // Can only be logged out if UM has been setup
|
||||
};
|
||||
|
||||
export const PERMISSIONS: IUserPermissions = {
|
||||
TAGS: {
|
||||
CAN_DELETE_TAGS: {
|
||||
allow: {
|
||||
role: [ROLE.Owner],
|
||||
},
|
||||
},
|
||||
},
|
||||
PRIMARY_MENU: {
|
||||
CAN_ACCESS_USER_INFO: {
|
||||
allow: {
|
||||
loginStatus: [LOGIN_STATUS.LoggedIn],
|
||||
},
|
||||
deny: {
|
||||
role: [ROLE.Default],
|
||||
},
|
||||
},
|
||||
},
|
||||
USER_SETTINGS: {
|
||||
VIEW_UM_SETUP_WARNING: {
|
||||
allow: {
|
||||
role: [ROLE.Default],
|
||||
},
|
||||
},
|
||||
},
|
||||
USAGE: {
|
||||
CAN_ACTIVATE_LICENSE: {
|
||||
allow: {
|
||||
role: [ROLE.Owner],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* To be authorized, user must pass all deny rules and pass any of the allow rules.
|
||||
*
|
||||
*/
|
||||
export const isAuthorized = (permissions: IPermissions, currentUser: IUser | null): boolean => {
|
||||
const loginStatus = currentUser ? LOGIN_STATUS.LoggedIn : LOGIN_STATUS.LoggedOut;
|
||||
// big AND block
|
||||
// if any of these are false, block user
|
||||
if (permissions.deny) {
|
||||
if (permissions.deny.shouldDeny && permissions.deny.shouldDeny()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (permissions.deny.loginStatus && permissions.deny.loginStatus.includes(loginStatus)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (currentUser?.globalRole?.name) {
|
||||
const role = currentUser.isDefaultUser ? ROLE.Default : currentUser.globalRole.name;
|
||||
if (permissions.deny.role && permissions.deny.role.includes(role)) {
|
||||
return false;
|
||||
}
|
||||
} else if (permissions.deny.role) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// big OR block
|
||||
// if any of these are true, allow user
|
||||
if (permissions.allow) {
|
||||
if (permissions.allow.shouldAllow && permissions.allow.shouldAllow()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (permissions.allow.loginStatus && permissions.allow.loginStatus.includes(loginStatus)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (currentUser?.globalRole?.name) {
|
||||
const role = currentUser.isDefaultUser ? ROLE.Default : currentUser.globalRole.name;
|
||||
if (permissions.allow.role && permissions.allow.role.includes(role)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export function getPersonalizedNodeTypes(
|
||||
answers:
|
||||
| IPersonalizationSurveyAnswersV1
|
||||
|
@ -8,6 +8,8 @@ import { i18n as locale } from '@/plugins/i18n';
|
||||
import { useUIStore } from '@/stores';
|
||||
import { N8N_PRICING_PAGE_URL } from '@/constants';
|
||||
import { useToast } from '@/composables';
|
||||
import { ROLE } from '@/utils';
|
||||
import { hasPermission } from '@/rbac/permissions';
|
||||
|
||||
const usageStore = useUsageStore();
|
||||
const route = useRoute();
|
||||
@ -26,6 +28,12 @@ const activationKeyModal = ref(false);
|
||||
const activationKey = ref('');
|
||||
const activationKeyInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const canUserActivateLicense = computed(() =>
|
||||
hasPermission(['role'], {
|
||||
role: [ROLE.Owner],
|
||||
}),
|
||||
);
|
||||
|
||||
const showActivationSuccess = () => {
|
||||
toast.showMessage({
|
||||
type: 'success',
|
||||
@ -77,7 +85,7 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (!route.query.key && usageStore.canUserActivateLicense) {
|
||||
if (!route.query.key && canUserActivateLicense.value) {
|
||||
await usageStore.refreshLicenseManagementToken();
|
||||
} else {
|
||||
await usageStore.getLicenseInfo();
|
||||
@ -184,7 +192,7 @@ const openPricingPage = () => {
|
||||
<n8n-button
|
||||
:class="$style.buttonTertiary"
|
||||
@click="onAddActivationKey"
|
||||
v-if="usageStore.canUserActivateLicense"
|
||||
v-if="canUserActivateLicense"
|
||||
type="tertiary"
|
||||
size="large"
|
||||
>
|
||||
|
@ -2,7 +2,7 @@
|
||||
<div :class="$style.container">
|
||||
<div>
|
||||
<n8n-heading size="2xlarge">{{ $locale.baseText('settings.users') }}</n8n-heading>
|
||||
<div :class="$style.buttonContainer" v-if="!usersStore.showUMSetupWarning">
|
||||
<div :class="$style.buttonContainer" v-if="!showUMSetupWarning">
|
||||
<n8n-tooltip :disabled="!ssoStore.isSamlLoginEnabled">
|
||||
<template #content>
|
||||
<span> {{ $locale.baseText('settings.users.invite.tooltip') }} </span>
|
||||
@ -70,6 +70,8 @@ import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { useUsageStore } from '@/stores/usage.store';
|
||||
import { useSSOStore } from '@/stores/sso.store';
|
||||
import { hasPermission } from '@/rbac/permissions';
|
||||
import { ROLE } from '@/utils';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'SettingsUsersView',
|
||||
@ -80,7 +82,7 @@ export default defineComponent({
|
||||
};
|
||||
},
|
||||
async mounted() {
|
||||
if (!this.usersStore.showUMSetupWarning) {
|
||||
if (!this.showUMSetupWarning) {
|
||||
await this.usersStore.fetchUsers();
|
||||
}
|
||||
},
|
||||
@ -89,6 +91,9 @@ export default defineComponent({
|
||||
isSharingEnabled() {
|
||||
return this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing);
|
||||
},
|
||||
showUMSetupWarning() {
|
||||
return hasPermission(['role'], { role: [ROLE.Default] });
|
||||
},
|
||||
usersListActions(): IUserListAction[] {
|
||||
return [
|
||||
{
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { afterAll, beforeAll, vi } from 'vitest';
|
||||
import { afterAll, beforeAll } from 'vitest';
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
import { waitFor } from '@testing-library/vue';
|
||||
import { setupServer } from '@/__tests__/server';
|
||||
import VariablesView from '@/views/VariablesView.vue';
|
||||
import { useSettingsStore, useUsersStore } from '@/stores';
|
||||
import { useSettingsStore, useUsersStore, useRBACStore } from '@/stores';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { EnterpriseEditionFeature } from '@/constants';
|
||||
|
||||
@ -12,6 +12,7 @@ describe('VariablesView', () => {
|
||||
let pinia: ReturnType<typeof createPinia>;
|
||||
let settingsStore: ReturnType<typeof useSettingsStore>;
|
||||
let usersStore: ReturnType<typeof useUsersStore>;
|
||||
let rbacStore: ReturnType<typeof useRBACStore>;
|
||||
|
||||
const renderComponent = createComponentRenderer(VariablesView);
|
||||
|
||||
@ -25,6 +26,7 @@ describe('VariablesView', () => {
|
||||
|
||||
settingsStore = useSettingsStore();
|
||||
usersStore = useUsersStore();
|
||||
rbacStore = useRBACStore();
|
||||
await settingsStore.getSettings();
|
||||
await usersStore.fetchUsers();
|
||||
await usersStore.loginWithCookie();
|
||||
@ -41,29 +43,12 @@ describe('VariablesView', () => {
|
||||
});
|
||||
|
||||
describe('should render empty state', () => {
|
||||
it('when feature is enabled and logged in user is owner', async () => {
|
||||
settingsStore.settings.enterprise[EnterpriseEditionFeature.Variables] = true;
|
||||
vi.spyOn(usersStore, 'currentUser', 'get').mockReturnValue({
|
||||
isOwner: true,
|
||||
});
|
||||
it('when feature is disabled and logged in user is not owner', async () => {
|
||||
settingsStore.settings.enterprise[EnterpriseEditionFeature.Variables] = false;
|
||||
rbacStore.setGlobalScopes(['variable:read', 'variable:list']);
|
||||
|
||||
const { queryByTestId } = renderComponent({ pinia });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByTestId('empty-resources-list')).toBeVisible();
|
||||
expect(queryByTestId('unavailable-resources-list')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('cannot-create-variables')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('when feature is disabled and logged in user is owner', async () => {
|
||||
settingsStore.settings.enterprise[EnterpriseEditionFeature.Variables] = false;
|
||||
vi.spyOn(usersStore, 'currentUser', 'get').mockReturnValue({
|
||||
isOwner: true,
|
||||
});
|
||||
|
||||
const { queryByTestId } = renderComponent(VariablesView, { pinia });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByTestId('empty-resources-list')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('unavailable-resources-list')).toBeVisible();
|
||||
@ -71,13 +56,49 @@ describe('VariablesView', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('when feature is eanbled and logged in user is not owner', async () => {
|
||||
settingsStore.settings.enterprise[EnterpriseEditionFeature.Variables] = true;
|
||||
vi.spyOn(usersStore, 'currentUser', 'get').mockReturnValue({
|
||||
isDefaultUser: true,
|
||||
});
|
||||
it('when feature is disabled and logged in user is owner', async () => {
|
||||
settingsStore.settings.enterprise[EnterpriseEditionFeature.Variables] = false;
|
||||
rbacStore.setGlobalScopes([
|
||||
'variable:create',
|
||||
'variable:read',
|
||||
'variable:update',
|
||||
'variable:delete',
|
||||
'variable:list',
|
||||
]);
|
||||
|
||||
const { queryByTestId } = renderComponent(VariablesView, { pinia });
|
||||
const { queryByTestId } = renderComponent({ pinia });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByTestId('empty-resources-list')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('unavailable-resources-list')).toBeVisible();
|
||||
expect(queryByTestId('cannot-create-variables')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('when feature is enabled and logged in user is owner', async () => {
|
||||
settingsStore.settings.enterprise[EnterpriseEditionFeature.Variables] = true;
|
||||
rbacStore.setGlobalScopes([
|
||||
'variable:create',
|
||||
'variable:read',
|
||||
'variable:update',
|
||||
'variable:delete',
|
||||
'variable:list',
|
||||
]);
|
||||
|
||||
const { queryByTestId } = renderComponent({ pinia });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByTestId('empty-resources-list')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('unavailable-resources-list')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('cannot-create-variables')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('when feature is enabled and logged in user is not owner', async () => {
|
||||
settingsStore.settings.enterprise[EnterpriseEditionFeature.Variables] = true;
|
||||
rbacStore.setGlobalScopes(['variable:read', 'variable:list']);
|
||||
|
||||
const { queryByTestId } = renderComponent({ pinia });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByTestId('empty-resources-list')).not.toBeInTheDocument();
|
||||
|
@ -850,6 +850,9 @@ importers:
|
||||
'@n8n/codemirror-lang-sql':
|
||||
specifier: ^1.0.2
|
||||
version: 1.0.2(@codemirror/view@6.5.1)(@lezer/common@1.1.0)
|
||||
'@n8n/permissions':
|
||||
specifier: workspace:*
|
||||
version: link:../@n8n/permissions
|
||||
'@vueuse/components':
|
||||
specifier: ^10.5.0
|
||||
version: 10.5.0(vue@3.3.4)
|
||||
|
Loading…
Reference in New Issue
Block a user