mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-13 16:14:07 -08:00
feat: Introduce advanced permissions (#7844)
This PR introduces the possibility of inviting new users with an `admin` role and changing the role of already invited users. Also using scoped permission checks where applicable instead of using user role checks. --------- Co-authored-by: Val <68596159+valya@users.noreply.github.com> Co-authored-by: Alex Grozav <alex@grozav.com> Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
This commit is contained in:
parent
e00577b1d3
commit
dbd62a4992
|
@ -1,4 +1,4 @@
|
||||||
import { INSTANCE_MEMBERS, INSTANCE_OWNER } from '../constants';
|
import { INSTANCE_MEMBERS, INSTANCE_OWNER, INSTANCE_ADMIN } from '../constants';
|
||||||
import {
|
import {
|
||||||
CredentialsModal,
|
CredentialsModal,
|
||||||
CredentialsPage,
|
CredentialsPage,
|
||||||
|
@ -7,6 +7,7 @@ import {
|
||||||
WorkflowSharingModal,
|
WorkflowSharingModal,
|
||||||
WorkflowsPage,
|
WorkflowsPage,
|
||||||
} from '../pages';
|
} from '../pages';
|
||||||
|
import { getVisibleSelect } from '../utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User U1 - Instance owner
|
* User U1 - Instance owner
|
||||||
|
@ -129,4 +130,38 @@ describe('Sharing', { disableAutoLogin: true }, () => {
|
||||||
credentialsPage.getters.credentialCard('Credential C2').click();
|
credentialsPage.getters.credentialCard('Credential C2').click();
|
||||||
credentialsModal.getters.testSuccessTag().should('be.visible');
|
credentialsModal.getters.testSuccessTag().should('be.visible');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.only('should work for admin role on credentials created by others (also can share it with themselves)', () => {
|
||||||
|
cy.signin(INSTANCE_MEMBERS[0]);
|
||||||
|
|
||||||
|
cy.visit(credentialsPage.url);
|
||||||
|
credentialsPage.getters.createCredentialButton().click();
|
||||||
|
credentialsModal.getters.newCredentialTypeOption('Notion API').click();
|
||||||
|
credentialsModal.getters.newCredentialTypeButton().click({ force: true });
|
||||||
|
credentialsModal.getters.connectionParameter('Internal Integration Secret').type('1234567890');
|
||||||
|
credentialsModal.actions.setName('Credential C3');
|
||||||
|
credentialsModal.actions.save();
|
||||||
|
credentialsModal.actions.close();
|
||||||
|
|
||||||
|
cy.signout();
|
||||||
|
cy.signin(INSTANCE_ADMIN);
|
||||||
|
cy.visit(credentialsPage.url);
|
||||||
|
credentialsPage.getters.credentialCard('Credential C3').click();
|
||||||
|
credentialsModal.getters.testSuccessTag().should('be.visible');
|
||||||
|
cy.get('input').should('not.have.length');
|
||||||
|
credentialsModal.actions.changeTab('Sharing');
|
||||||
|
|
||||||
|
credentialsModal.getters.usersSelect().click();
|
||||||
|
cy.getByTestId('user-email')
|
||||||
|
.filter(':visible')
|
||||||
|
.should('have.length', 3)
|
||||||
|
.contains(INSTANCE_ADMIN.email)
|
||||||
|
.should('have.length', 1);
|
||||||
|
getVisibleSelect().contains(INSTANCE_OWNER.email.toLowerCase()).click();
|
||||||
|
|
||||||
|
credentialsModal.actions.addUser(INSTANCE_MEMBERS[1].email);
|
||||||
|
credentialsModal.actions.addUser(INSTANCE_ADMIN.email);
|
||||||
|
credentialsModal.actions.saveSharing();
|
||||||
|
credentialsModal.actions.close();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { INSTANCE_MEMBERS, INSTANCE_OWNER, INSTANCE_ADMIN } from '../constants';
|
import { INSTANCE_MEMBERS, INSTANCE_OWNER, INSTANCE_ADMIN } from '../constants';
|
||||||
import { MainSidebar, SettingsSidebar, SettingsUsersPage, WorkflowPage } from '../pages';
|
import { MainSidebar, SettingsSidebar, SettingsUsersPage, WorkflowPage } from '../pages';
|
||||||
import { PersonalSettingsPage } from '../pages/settings-personal';
|
import { PersonalSettingsPage } from '../pages/settings-personal';
|
||||||
|
import { getVisibleSelect } from '../utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User A - Instance owner
|
* User A - Instance owner
|
||||||
|
@ -29,7 +30,9 @@ const settingsSidebar = new SettingsSidebar();
|
||||||
const mainSidebar = new MainSidebar();
|
const mainSidebar = new MainSidebar();
|
||||||
|
|
||||||
describe('User Management', { disableAutoLogin: true }, () => {
|
describe('User Management', { disableAutoLogin: true }, () => {
|
||||||
before(() => cy.enableFeature('sharing'));
|
before(() => {
|
||||||
|
cy.enableFeature('sharing');
|
||||||
|
});
|
||||||
|
|
||||||
it('should prevent non-owners to access UM settings', () => {
|
it('should prevent non-owners to access UM settings', () => {
|
||||||
usersSettingsPage.actions.loginAndVisit(
|
usersSettingsPage.actions.loginAndVisit(
|
||||||
|
@ -58,6 +61,67 @@ describe('User Management', { disableAutoLogin: true }, () => {
|
||||||
usersSettingsPage.getters.userActionsToggle(INSTANCE_ADMIN.email).should('exist');
|
usersSettingsPage.getters.userActionsToggle(INSTANCE_ADMIN.email).should('exist');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should be able to change user role to Admin and back', () => {
|
||||||
|
cy.enableFeature('advancedPermissions');
|
||||||
|
|
||||||
|
usersSettingsPage.actions.loginAndVisit(INSTANCE_OWNER.email, INSTANCE_OWNER.password, true);
|
||||||
|
|
||||||
|
// Change role from Member to Admin
|
||||||
|
usersSettingsPage.getters
|
||||||
|
.userRoleSelect(INSTANCE_MEMBERS[0].email)
|
||||||
|
.find('input')
|
||||||
|
.should('contain.value', 'Member');
|
||||||
|
usersSettingsPage.getters.userRoleSelect(INSTANCE_MEMBERS[0].email).click();
|
||||||
|
getVisibleSelect().find('li').contains('Admin').click();
|
||||||
|
usersSettingsPage.getters
|
||||||
|
.userRoleSelect(INSTANCE_MEMBERS[0].email)
|
||||||
|
.find('input')
|
||||||
|
.should('contain.value', 'Admin');
|
||||||
|
|
||||||
|
usersSettingsPage.actions.loginAndVisit(
|
||||||
|
INSTANCE_MEMBERS[0].email,
|
||||||
|
INSTANCE_MEMBERS[0].password,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Change role from Admin to Member, then back to Admin
|
||||||
|
usersSettingsPage.getters
|
||||||
|
.userRoleSelect(INSTANCE_ADMIN.email)
|
||||||
|
.find('input')
|
||||||
|
.should('contain.value', 'Admin');
|
||||||
|
|
||||||
|
usersSettingsPage.getters.userRoleSelect(INSTANCE_ADMIN.email).click();
|
||||||
|
getVisibleSelect().find('li').contains('Member').click();
|
||||||
|
usersSettingsPage.getters
|
||||||
|
.userRoleSelect(INSTANCE_ADMIN.email)
|
||||||
|
.find('input')
|
||||||
|
.should('contain.value', 'Member');
|
||||||
|
|
||||||
|
usersSettingsPage.actions.loginAndVisit(INSTANCE_ADMIN.email, INSTANCE_ADMIN.password, false);
|
||||||
|
usersSettingsPage.actions.loginAndVisit(
|
||||||
|
INSTANCE_MEMBERS[0].email,
|
||||||
|
INSTANCE_MEMBERS[0].password,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
usersSettingsPage.getters.userRoleSelect(INSTANCE_ADMIN.email).click();
|
||||||
|
getVisibleSelect().find('li').contains('Admin').click();
|
||||||
|
usersSettingsPage.getters
|
||||||
|
.userRoleSelect(INSTANCE_ADMIN.email)
|
||||||
|
.find('input')
|
||||||
|
.should('contain.value', 'Admin');
|
||||||
|
|
||||||
|
usersSettingsPage.actions.loginAndVisit(INSTANCE_ADMIN.email, INSTANCE_ADMIN.password, true);
|
||||||
|
usersSettingsPage.getters.userRoleSelect(INSTANCE_MEMBERS[0].email).click();
|
||||||
|
getVisibleSelect().find('li').contains('Member').click();
|
||||||
|
usersSettingsPage.getters
|
||||||
|
.userRoleSelect(INSTANCE_MEMBERS[0].email)
|
||||||
|
.find('input')
|
||||||
|
.should('contain.value', 'Member');
|
||||||
|
|
||||||
|
cy.disableFeature('advancedPermissions');
|
||||||
|
});
|
||||||
|
|
||||||
it('should be able to change theme', () => {
|
it('should be able to change theme', () => {
|
||||||
personalSettingsPage.actions.loginAndVisit(INSTANCE_OWNER.email, INSTANCE_OWNER.password);
|
personalSettingsPage.actions.loginAndVisit(INSTANCE_OWNER.email, INSTANCE_OWNER.password);
|
||||||
|
|
||||||
|
|
23
cypress/e2e/35-admin-user-smoke-test.cy.ts
Normal file
23
cypress/e2e/35-admin-user-smoke-test.cy.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { INSTANCE_ADMIN, INSTANCE_OWNER } from '../constants';
|
||||||
|
import { SettingsPage } from '../pages/settings';
|
||||||
|
|
||||||
|
const settingsPage = new SettingsPage();
|
||||||
|
|
||||||
|
describe('Admin user', { disableAutoLogin: true }, () => {
|
||||||
|
it('should see same Settings sub menu items as instance owner', () => {
|
||||||
|
cy.signin(INSTANCE_OWNER);
|
||||||
|
cy.visit(settingsPage.url);
|
||||||
|
|
||||||
|
let ownerMenuItems = 0;
|
||||||
|
|
||||||
|
settingsPage.getters.menuItems().then(($el) => {
|
||||||
|
ownerMenuItems = $el.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.signout();
|
||||||
|
cy.signin(INSTANCE_ADMIN);
|
||||||
|
cy.visit(settingsPage.url);
|
||||||
|
|
||||||
|
settingsPage.getters.menuItems().should('have.length', ownerMenuItems);
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,4 +1,5 @@
|
||||||
import { BasePage } from '../base';
|
import { BasePage } from '../base';
|
||||||
|
import { getVisibleSelect } from '../../utils';
|
||||||
|
|
||||||
export class CredentialsModal extends BasePage {
|
export class CredentialsModal extends BasePage {
|
||||||
getters = {
|
getters = {
|
||||||
|
@ -30,11 +31,7 @@ export class CredentialsModal extends BasePage {
|
||||||
actions = {
|
actions = {
|
||||||
addUser: (email: string) => {
|
addUser: (email: string) => {
|
||||||
this.getters.usersSelect().click();
|
this.getters.usersSelect().click();
|
||||||
this.getters
|
getVisibleSelect().contains(email.toLowerCase()).click();
|
||||||
.usersSelect()
|
|
||||||
.get('.el-select-dropdown__item')
|
|
||||||
.contains(email.toLowerCase())
|
|
||||||
.click();
|
|
||||||
},
|
},
|
||||||
setName: (name: string) => {
|
setName: (name: string) => {
|
||||||
this.getters.name().click();
|
this.getters.name().click();
|
||||||
|
@ -48,6 +45,12 @@ export class CredentialsModal extends BasePage {
|
||||||
if (test) cy.wait('@testCredential');
|
if (test) cy.wait('@testCredential');
|
||||||
this.getters.saveButton().should('contain.text', 'Saved');
|
this.getters.saveButton().should('contain.text', 'Saved');
|
||||||
},
|
},
|
||||||
|
saveSharing: (test = false) => {
|
||||||
|
cy.intercept('PUT', '/rest/credentials/*/share').as('shareCredential');
|
||||||
|
this.getters.saveButton().click({ force: true });
|
||||||
|
cy.wait('@shareCredential');
|
||||||
|
this.getters.saveButton().should('contain.text', 'Saved');
|
||||||
|
},
|
||||||
close: () => {
|
close: () => {
|
||||||
this.getters.closeButton().click();
|
this.getters.closeButton().click();
|
||||||
},
|
},
|
||||||
|
|
|
@ -20,6 +20,8 @@ export class SettingsUsersPage extends BasePage {
|
||||||
userItem: (email: string) => cy.getByTestId(`user-list-item-${email.toLowerCase()}`),
|
userItem: (email: string) => cy.getByTestId(`user-list-item-${email.toLowerCase()}`),
|
||||||
userActionsToggle: (email: string) =>
|
userActionsToggle: (email: string) =>
|
||||||
this.getters.userItem(email).find('[data-test-id="action-toggle"]'),
|
this.getters.userItem(email).find('[data-test-id="action-toggle"]'),
|
||||||
|
userRoleSelect: (email: string) =>
|
||||||
|
this.getters.userItem(email).find('[data-test-id="user-role-select"]'),
|
||||||
deleteUserAction: () =>
|
deleteUserAction: () =>
|
||||||
cy.getByTestId('action-toggle-dropdown').find('li:contains("Delete"):visible'),
|
cy.getByTestId('action-toggle-dropdown').find('li:contains("Delete"):visible'),
|
||||||
confirmDeleteModal: () => cy.getByTestId('deleteUser-modal').last(),
|
confirmDeleteModal: () => cy.getByTestId('deleteUser-modal').last(),
|
||||||
|
|
9
cypress/pages/settings.ts
Normal file
9
cypress/pages/settings.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import { BasePage } from './base';
|
||||||
|
|
||||||
|
export class SettingsPage extends BasePage {
|
||||||
|
url = '/settings';
|
||||||
|
getters = {
|
||||||
|
menuItems: () => cy.getByTestId('menu-item'),
|
||||||
|
};
|
||||||
|
actions = {};
|
||||||
|
}
|
|
@ -170,7 +170,6 @@ export class UsersController {
|
||||||
/**
|
/**
|
||||||
* Delete a user. Optionally, designate a transferee for their workflows and credentials.
|
* Delete a user. Optionally, designate a transferee for their workflows and credentials.
|
||||||
*/
|
*/
|
||||||
@Authorized(['global', 'owner'])
|
|
||||||
@Delete('/:id')
|
@Delete('/:id')
|
||||||
@RequireGlobalScope('user:delete')
|
@RequireGlobalScope('user:delete')
|
||||||
async deleteUser(req: UserRequest.Delete) {
|
async deleteUser(req: UserRequest.Delete) {
|
||||||
|
|
|
@ -51,6 +51,7 @@
|
||||||
:key="option.value"
|
:key="option.value"
|
||||||
:value="option.value"
|
:value="option.value"
|
||||||
:label="option.label"
|
:label="option.label"
|
||||||
|
:disabled="!!option.disabled"
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
</n8n-select>
|
</n8n-select>
|
||||||
|
@ -118,7 +119,7 @@ export interface Props {
|
||||||
validationRules?: Array<Rule | RuleGroup>;
|
validationRules?: Array<Rule | RuleGroup>;
|
||||||
validators?: { [key: string]: IValidator | RuleGroup };
|
validators?: { [key: string]: IValidator | RuleGroup };
|
||||||
maxlength?: number;
|
maxlength?: number;
|
||||||
options?: Array<{ value: string | number; label: string }>;
|
options?: Array<{ value: string | number; label: string; disabled?: boolean }>;
|
||||||
autocomplete?: string;
|
autocomplete?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
focusInitially?: boolean;
|
focusInitially?: boolean;
|
||||||
|
|
|
@ -44,7 +44,7 @@ export type IFormInput = {
|
||||||
validateOnBlur?: boolean;
|
validateOnBlur?: boolean;
|
||||||
infoText?: string;
|
infoText?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
options?: Array<{ label: string; value: string }>;
|
options?: Array<{ label: string; value: string; disabled?: boolean }>;
|
||||||
autocomplete?:
|
autocomplete?:
|
||||||
| 'off'
|
| 'off'
|
||||||
| 'new-password'
|
| 'new-password'
|
||||||
|
|
|
@ -1790,7 +1790,8 @@ export type UTMCampaign =
|
||||||
| 'upgrade-users'
|
| 'upgrade-users'
|
||||||
| 'upgrade-variables'
|
| 'upgrade-variables'
|
||||||
| 'upgrade-community-nodes'
|
| 'upgrade-community-nodes'
|
||||||
| 'upgrade-workflow-history';
|
| 'upgrade-workflow-history'
|
||||||
|
| 'upgrade-advanced-permissions';
|
||||||
|
|
||||||
export type N8nBanners = {
|
export type N8nBanners = {
|
||||||
[key in BannerName]: {
|
[key in BannerName]: {
|
||||||
|
|
|
@ -4,6 +4,9 @@ import router from '@/router';
|
||||||
import { VIEWS } from '@/constants';
|
import { VIEWS } from '@/constants';
|
||||||
import { setupServer } from '@/__tests__/server';
|
import { setupServer } from '@/__tests__/server';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
import { useRBACStore } from '@/stores/rbac.store';
|
||||||
|
import type { Scope } from '@n8n/permissions';
|
||||||
|
import type { RouteRecordName } from 'vue-router';
|
||||||
|
|
||||||
const App = {
|
const App = {
|
||||||
template: '<div />',
|
template: '<div />',
|
||||||
|
@ -64,7 +67,7 @@ describe('router', () => {
|
||||||
'should resolve %s to %s if user has permissions',
|
'should resolve %s to %s if user has permissions',
|
||||||
async (path, name) => {
|
async (path, name) => {
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
await settingsStore.getSettings();
|
|
||||||
settingsStore.settings.enterprise.debugInEditor = true;
|
settingsStore.settings.enterprise.debugInEditor = true;
|
||||||
settingsStore.settings.enterprise.workflowHistory = true;
|
settingsStore.settings.enterprise.workflowHistory = true;
|
||||||
|
|
||||||
|
@ -73,4 +76,42 @@ describe('router', () => {
|
||||||
},
|
},
|
||||||
10000,
|
10000,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test.each<[string, RouteRecordName, Scope[]]>([
|
||||||
|
['/settings/users', VIEWS.WORKFLOWS, []],
|
||||||
|
['/settings/users', VIEWS.USERS_SETTINGS, ['user:create', 'user:update']],
|
||||||
|
['/settings/environments', VIEWS.WORKFLOWS, []],
|
||||||
|
['/settings/environments', VIEWS.SOURCE_CONTROL, ['sourceControl:manage']],
|
||||||
|
['/settings/external-secrets', VIEWS.WORKFLOWS, []],
|
||||||
|
[
|
||||||
|
'/settings/external-secrets',
|
||||||
|
VIEWS.EXTERNAL_SECRETS_SETTINGS,
|
||||||
|
['externalSecretsProvider:list', 'externalSecretsProvider:update'],
|
||||||
|
],
|
||||||
|
['/settings/sso', VIEWS.WORKFLOWS, []],
|
||||||
|
['/settings/sso', VIEWS.SSO_SETTINGS, ['saml:manage']],
|
||||||
|
['/settings/log-streaming', VIEWS.WORKFLOWS, []],
|
||||||
|
['/settings/log-streaming', VIEWS.LOG_STREAMING_SETTINGS, ['logStreaming:manage']],
|
||||||
|
['/settings/community-nodes', VIEWS.WORKFLOWS, []],
|
||||||
|
[
|
||||||
|
'/settings/community-nodes',
|
||||||
|
VIEWS.COMMUNITY_NODES,
|
||||||
|
['communityPackage:list', 'communityPackage:update'],
|
||||||
|
],
|
||||||
|
['/settings/ldap', VIEWS.WORKFLOWS, []],
|
||||||
|
['/settings/ldap', VIEWS.LDAP_SETTINGS, ['ldap:manage']],
|
||||||
|
])(
|
||||||
|
'should resolve %s to %s with %s user permissions',
|
||||||
|
async (path, name, scopes) => {
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
const rbacStore = useRBACStore();
|
||||||
|
|
||||||
|
settingsStore.settings.communityNodesEnabled = true;
|
||||||
|
rbacStore.setGlobalScopes(scopes);
|
||||||
|
|
||||||
|
await router.push(path);
|
||||||
|
expect(router.currentRoute.value.name).toBe(name);
|
||||||
|
},
|
||||||
|
10000,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import type { CurrentUserResponse, IInviteResponse, IRestApiContext } from '@/Interface';
|
import type { CurrentUserResponse, IInviteResponse, IRestApiContext, IRole } from '@/Interface';
|
||||||
import type { IDataObject } from 'n8n-workflow';
|
import type { IDataObject } from 'n8n-workflow';
|
||||||
import { makeRestApiRequest } from '@/utils/apiUtils';
|
import { makeRestApiRequest } from '@/utils/apiUtils';
|
||||||
|
|
||||||
|
@ -10,7 +10,10 @@ type AcceptInvitationParams = {
|
||||||
password: string;
|
password: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function inviteUsers(context: IRestApiContext, params: Array<{ email: string }>) {
|
export async function inviteUsers(
|
||||||
|
context: IRestApiContext,
|
||||||
|
params: Array<{ email: string; role: IRole }>,
|
||||||
|
) {
|
||||||
return makeRestApiRequest<IInviteResponse[]>(context, 'POST', '/invitations', params);
|
return makeRestApiRequest<IInviteResponse[]>(context, 'POST', '/invitations', params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -61,7 +61,7 @@
|
||||||
data-test-id="credentials-config-container-test-success"
|
data-test-id="credentials-config-container-test-success"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<template v-if="credentialPermissions.updateConnection">
|
<template v-if="credentialPermissions.update">
|
||||||
<n8n-notice v-if="documentationUrl && credentialProperties.length" theme="warning">
|
<n8n-notice v-if="documentationUrl && credentialProperties.length" theme="warning">
|
||||||
{{ $locale.baseText('credentialEdit.credentialConfig.needHelpFillingOutTheseFields') }}
|
{{ $locale.baseText('credentialEdit.credentialConfig.needHelpFillingOutTheseFields') }}
|
||||||
<span class="ml-4xs">
|
<span class="ml-4xs">
|
||||||
|
@ -104,7 +104,7 @@
|
||||||
</enterprise-edition>
|
</enterprise-edition>
|
||||||
|
|
||||||
<CredentialInputs
|
<CredentialInputs
|
||||||
v-if="credentialType && credentialPermissions.updateConnection"
|
v-if="credentialType && credentialPermissions.update"
|
||||||
:credentialData="credentialData"
|
:credentialData="credentialData"
|
||||||
:credentialProperties="credentialProperties"
|
:credentialProperties="credentialProperties"
|
||||||
:documentationUrl="documentationUrl"
|
:documentationUrl="documentationUrl"
|
||||||
|
|
|
@ -17,7 +17,7 @@
|
||||||
<InlineNameEdit
|
<InlineNameEdit
|
||||||
:modelValue="credentialName"
|
:modelValue="credentialName"
|
||||||
:subtitle="credentialType ? credentialType.displayName : ''"
|
:subtitle="credentialType ? credentialType.displayName : ''"
|
||||||
:readonly="!credentialPermissions.updateName || !credentialType"
|
:readonly="!credentialPermissions.update || !credentialType"
|
||||||
type="Credential"
|
type="Credential"
|
||||||
@update:modelValue="onNameEdit"
|
@update:modelValue="onNameEdit"
|
||||||
data-test-id="credential-name"
|
data-test-id="credential-name"
|
||||||
|
@ -224,6 +224,7 @@ export default defineComponent({
|
||||||
selectedCredential: '',
|
selectedCredential: '',
|
||||||
requiredCredentials: false, // Are credentials required or optional for the node
|
requiredCredentials: false, // Are credentials required or optional for the node
|
||||||
hasUserSpecifiedName: false,
|
hasUserSpecifiedName: false,
|
||||||
|
isSharedWithChanged: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
|
@ -683,6 +684,7 @@ export default defineComponent({
|
||||||
...this.credentialData,
|
...this.credentialData,
|
||||||
sharedWith: sharees,
|
sharedWith: sharees,
|
||||||
};
|
};
|
||||||
|
this.isSharedWithChanged = true;
|
||||||
this.hasUnsavedChanges = true;
|
this.hasUnsavedChanges = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -920,10 +922,23 @@ export default defineComponent({
|
||||||
): Promise<ICredentialsResponse | null> {
|
): Promise<ICredentialsResponse | null> {
|
||||||
let credential;
|
let credential;
|
||||||
try {
|
try {
|
||||||
|
if (this.credentialPermissions.update) {
|
||||||
credential = await this.credentialsStore.updateCredential({
|
credential = await this.credentialsStore.updateCredential({
|
||||||
id: this.credentialId,
|
id: this.credentialId,
|
||||||
data: credentialDetails,
|
data: credentialDetails,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
this.credentialPermissions.share &&
|
||||||
|
this.isSharedWithChanged &&
|
||||||
|
credentialDetails.sharedWith
|
||||||
|
) {
|
||||||
|
credential = await this.credentialsStore.setCredentialSharedWith({
|
||||||
|
credentialId: credentialDetails.id,
|
||||||
|
sharedWith: credentialDetails.sharedWith,
|
||||||
|
});
|
||||||
|
this.isSharedWithChanged = false;
|
||||||
|
}
|
||||||
this.hasUnsavedChanges = false;
|
this.hasUnsavedChanges = false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.showError(
|
this.showError(
|
||||||
|
|
|
@ -9,7 +9,7 @@
|
||||||
<el-col :span="16">
|
<el-col :span="16">
|
||||||
<div v-for="node in nodesWithAccess" :key="node.name" :class="$style.valueLabel">
|
<div v-for="node in nodesWithAccess" :key="node.name" :class="$style.valueLabel">
|
||||||
<el-checkbox
|
<el-checkbox
|
||||||
v-if="credentialPermissions.updateNodeAccess"
|
v-if="credentialPermissions.update"
|
||||||
:label="
|
:label="
|
||||||
$locale.headerText({
|
$locale.headerText({
|
||||||
key: `headers.${shortNodeType(node)}.displayName`,
|
key: `headers.${shortNodeType(node)}.displayName`,
|
||||||
|
|
|
@ -34,7 +34,7 @@
|
||||||
<n8n-info-tip v-if="credentialPermissions.isOwner" :bold="false" class="mb-s">
|
<n8n-info-tip v-if="credentialPermissions.isOwner" :bold="false" class="mb-s">
|
||||||
{{ $locale.baseText('credentialEdit.credentialSharing.info.owner') }}
|
{{ $locale.baseText('credentialEdit.credentialSharing.info.owner') }}
|
||||||
</n8n-info-tip>
|
</n8n-info-tip>
|
||||||
<n8n-info-tip v-if="!credentialPermissions.updateSharing" :bold="false" class="mb-s">
|
<n8n-info-tip v-if="!credentialPermissions.share" :bold="false" class="mb-s">
|
||||||
{{
|
{{
|
||||||
$locale.baseText('credentialEdit.credentialSharing.info.sharee', {
|
$locale.baseText('credentialEdit.credentialSharing.info.sharee', {
|
||||||
interpolate: { credentialOwnerName },
|
interpolate: { credentialOwnerName },
|
||||||
|
@ -42,10 +42,14 @@
|
||||||
}}
|
}}
|
||||||
</n8n-info-tip>
|
</n8n-info-tip>
|
||||||
<n8n-info-tip v-if="credentialPermissions.read" class="mb-s" :bold="false">
|
<n8n-info-tip v-if="credentialPermissions.read" class="mb-s" :bold="false">
|
||||||
{{ $locale.baseText('credentialEdit.credentialSharing.info.reader') }}
|
<i18n-t keypath="credentialEdit.credentialSharing.info.reader">
|
||||||
|
<template v-if="!isCredentialSharedWithCurrentUser" #notShared>
|
||||||
|
{{ $locale.baseText('credentialEdit.credentialSharing.info.notShared') }}
|
||||||
|
</template>
|
||||||
|
</i18n-t>
|
||||||
</n8n-info-tip>
|
</n8n-info-tip>
|
||||||
<n8n-user-select
|
<n8n-user-select
|
||||||
v-if="credentialPermissions.updateSharing"
|
v-if="credentialPermissions.share"
|
||||||
class="mb-s"
|
class="mb-s"
|
||||||
size="large"
|
size="large"
|
||||||
:users="usersList"
|
:users="usersList"
|
||||||
|
@ -62,7 +66,7 @@
|
||||||
:actions="usersListActions"
|
:actions="usersListActions"
|
||||||
:users="sharedWithList"
|
:users="sharedWithList"
|
||||||
:currentUserId="usersStore.currentUser.id"
|
:currentUserId="usersStore.currentUser.id"
|
||||||
:readonly="!credentialPermissions.updateSharing"
|
:readonly="!credentialPermissions.share"
|
||||||
@delete="onRemoveSharee"
|
@delete="onRemoveSharee"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -114,13 +118,12 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
usersList(): IUser[] {
|
usersList(): IUser[] {
|
||||||
return this.usersStore.allUsers.filter((user: IUser) => {
|
return this.usersStore.allUsers.filter((user: IUser) => {
|
||||||
const isCurrentUser = user.id === this.usersStore.currentUser?.id;
|
|
||||||
const isAlreadySharedWithUser = (this.credentialData.sharedWith || []).find(
|
const isAlreadySharedWithUser = (this.credentialData.sharedWith || []).find(
|
||||||
(sharee: IUser) => sharee.id === user.id,
|
(sharee: IUser) => sharee.id === user.id,
|
||||||
);
|
);
|
||||||
const isOwner = this.credentialData.ownedBy.id === user.id;
|
const isOwner = this.credentialData.ownedBy?.id === user.id;
|
||||||
|
|
||||||
return !isCurrentUser && !isAlreadySharedWithUser && !isOwner;
|
return !isAlreadySharedWithUser && !isOwner;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
sharedWithList(): IUser[] {
|
sharedWithList(): IUser[] {
|
||||||
|
@ -134,6 +137,11 @@ export default defineComponent({
|
||||||
credentialOwnerName(): string {
|
credentialOwnerName(): string {
|
||||||
return this.credentialsStore.getCredentialOwnerNameById(`${this.credentialId}`);
|
return this.credentialsStore.getCredentialOwnerNameById(`${this.credentialId}`);
|
||||||
},
|
},
|
||||||
|
isCredentialSharedWithCurrentUser(): boolean {
|
||||||
|
return (this.credentialData.sharedWith || []).some((sharee: IUser) => {
|
||||||
|
return sharee.id === this.usersStore.currentUser?.id;
|
||||||
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async onAddSharee(userId: string) {
|
async onAddSharee(userId: string) {
|
||||||
|
|
|
@ -12,6 +12,15 @@
|
||||||
:eventBus="modalBus"
|
:eventBus="modalBus"
|
||||||
>
|
>
|
||||||
<template #content>
|
<template #content>
|
||||||
|
<n8n-notice v-if="!isAdvancedPermissionsEnabled">
|
||||||
|
<i18n-t keypath="settings.users.advancedPermissions.warning">
|
||||||
|
<template #link>
|
||||||
|
<n8n-link size="small" @click="goToUpgradeAdvancedPermissions">
|
||||||
|
{{ $locale.baseText('settings.users.advancedPermissions.warning.link') }}
|
||||||
|
</n8n-link>
|
||||||
|
</template>
|
||||||
|
</i18n-t>
|
||||||
|
</n8n-notice>
|
||||||
<div v-if="showInviteUrls">
|
<div v-if="showInviteUrls">
|
||||||
<n8n-users-list :users="invitedUsers">
|
<n8n-users-list :users="invitedUsers">
|
||||||
<template #actions="{ user }">
|
<template #actions="{ user }">
|
||||||
|
@ -58,10 +67,11 @@ import { useToast } from '@/composables/useToast';
|
||||||
import { copyPaste } from '@/mixins/copyPaste';
|
import { copyPaste } from '@/mixins/copyPaste';
|
||||||
import Modal from './Modal.vue';
|
import Modal from './Modal.vue';
|
||||||
import type { IFormInputs, IInviteResponse, IUser } from '@/Interface';
|
import type { IFormInputs, IInviteResponse, IUser } from '@/Interface';
|
||||||
import { VALID_EMAIL_REGEX, INVITE_USER_MODAL_KEY } from '@/constants';
|
|
||||||
import { ROLE } from '@/utils/userUtils';
|
import { ROLE } from '@/utils/userUtils';
|
||||||
|
import { EnterpriseEditionFeature, VALID_EMAIL_REGEX, INVITE_USER_MODAL_KEY } from '@/constants';
|
||||||
import { useUsersStore } from '@/stores/users.store';
|
import { useUsersStore } from '@/stores/users.store';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import { createEventBus } from 'n8n-design-system/utils';
|
import { createEventBus } from 'n8n-design-system/utils';
|
||||||
|
|
||||||
const NAME_EMAIL_FORMAT_REGEX = /^.* <(.*)>$/;
|
const NAME_EMAIL_FORMAT_REGEX = /^.* <(.*)>$/;
|
||||||
|
@ -97,6 +107,7 @@ export default defineComponent({
|
||||||
formBus: createEventBus(),
|
formBus: createEventBus(),
|
||||||
modalBus: createEventBus(),
|
modalBus: createEventBus(),
|
||||||
emails: '',
|
emails: '',
|
||||||
|
role: 'member',
|
||||||
showInviteUrls: null as IInviteResponse[] | null,
|
showInviteUrls: null as IInviteResponse[] | null,
|
||||||
loading: false,
|
loading: false,
|
||||||
INVITE_USER_MODAL_KEY,
|
INVITE_USER_MODAL_KEY,
|
||||||
|
@ -132,6 +143,11 @@ export default defineComponent({
|
||||||
value: ROLE.Member,
|
value: ROLE.Member,
|
||||||
label: this.$locale.baseText('auth.roles.member'),
|
label: this.$locale.baseText('auth.roles.member'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: ROLE.Admin,
|
||||||
|
label: this.$locale.baseText('auth.roles.admin'),
|
||||||
|
disabled: !this.isAdvancedPermissionsEnabled,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
capitalize: true,
|
capitalize: true,
|
||||||
},
|
},
|
||||||
|
@ -139,7 +155,7 @@ export default defineComponent({
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapStores(useUsersStore, useSettingsStore),
|
...mapStores(useUsersStore, useSettingsStore, useUIStore),
|
||||||
emailsCount(): number {
|
emailsCount(): number {
|
||||||
return this.emails.split(',').filter((email: string) => !!email.trim()).length;
|
return this.emails.split(',').filter((email: string) => !!email.trim()).length;
|
||||||
},
|
},
|
||||||
|
@ -167,6 +183,11 @@ export default defineComponent({
|
||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
},
|
},
|
||||||
|
isAdvancedPermissionsEnabled(): boolean {
|
||||||
|
return this.settingsStore.isEnterpriseFeatureEnabled(
|
||||||
|
EnterpriseEditionFeature.AdvancedPermissions,
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
validateEmails(value: string | number | boolean | null | undefined) {
|
validateEmails(value: string | number | boolean | null | undefined) {
|
||||||
|
@ -193,6 +214,9 @@ export default defineComponent({
|
||||||
if (e.name === 'emails') {
|
if (e.name === 'emails') {
|
||||||
this.emails = e.value;
|
this.emails = e.value;
|
||||||
}
|
}
|
||||||
|
if (e.name === 'role') {
|
||||||
|
this.role = e.value;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async onSubmit() {
|
async onSubmit() {
|
||||||
try {
|
try {
|
||||||
|
@ -200,7 +224,7 @@ export default defineComponent({
|
||||||
|
|
||||||
const emails = this.emails
|
const emails = this.emails
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((email) => ({ email: getEmail(email) }))
|
.map((email) => ({ email: getEmail(email), role: this.role }))
|
||||||
.filter((invite) => !!invite.email);
|
.filter((invite) => !!invite.email);
|
||||||
|
|
||||||
if (emails.length === 0) {
|
if (emails.length === 0) {
|
||||||
|
@ -308,6 +332,9 @@ export default defineComponent({
|
||||||
this.showCopyInviteLinkToast([]);
|
this.showCopyInviteLinkToast([]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
goToUpgradeAdvancedPermissions() {
|
||||||
|
void this.uiStore.goToUpgrade('settings-users', 'upgrade-advanced-permissions');
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -210,13 +210,12 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
usersList(): IUser[] {
|
usersList(): IUser[] {
|
||||||
return this.usersStore.allUsers.filter((user: IUser) => {
|
return this.usersStore.allUsers.filter((user: IUser) => {
|
||||||
const isCurrentUser = user.id === this.usersStore.currentUser?.id;
|
|
||||||
const isAlreadySharedWithUser = (this.sharedWith || []).find(
|
const isAlreadySharedWithUser = (this.sharedWith || []).find(
|
||||||
(sharee) => sharee.id === user.id,
|
(sharee) => sharee.id === user.id,
|
||||||
);
|
);
|
||||||
const isOwner = this.workflow?.ownedBy?.id === user.id;
|
const isOwner = this.workflow?.ownedBy?.id === user.id;
|
||||||
|
|
||||||
return !isCurrentUser && !isAlreadySharedWithUser && !isOwner;
|
return !isAlreadySharedWithUser && !isOwner;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
sharedWithList(): Array<Partial<IUser>> {
|
sharedWithList(): Array<Partial<IUser>> {
|
||||||
|
|
|
@ -522,6 +522,7 @@ export const enum EnterpriseEditionFeature {
|
||||||
DebugInEditor = 'debugInEditor',
|
DebugInEditor = 'debugInEditor',
|
||||||
WorkflowHistory = 'workflowHistory',
|
WorkflowHistory = 'workflowHistory',
|
||||||
WorkerView = 'workerView',
|
WorkerView = 'workerView',
|
||||||
|
AdvancedPermissions = 'advancedPermissions',
|
||||||
}
|
}
|
||||||
export const MAIN_NODE_PANEL_WIDTH = 360;
|
export const MAIN_NODE_PANEL_WIDTH = 360;
|
||||||
|
|
||||||
|
|
|
@ -446,7 +446,7 @@ export const nodeHelpers = defineComponent({
|
||||||
.getCredentialsByType(credentialTypeDescription.name)
|
.getCredentialsByType(credentialTypeDescription.name)
|
||||||
.filter((credential: ICredentialsResponse) => {
|
.filter((credential: ICredentialsResponse) => {
|
||||||
const permissions = getCredentialPermissions(currentUser, credential);
|
const permissions = getCredentialPermissions(currentUser, credential);
|
||||||
return permissions.use;
|
return permissions.read;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (userCredentials === null) {
|
if (userCredentials === null) {
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
import type { IUser, ICredentialsResponse, IWorkflowDb } from '@/Interface';
|
import type { IUser, ICredentialsResponse, IWorkflowDb } from '@/Interface';
|
||||||
import { EnterpriseEditionFeature, PLACEHOLDER_EMPTY_WORKFLOW_ID } from '@/constants';
|
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';
|
import { hasPermission } from './rbac/permissions';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Old permissions implementation
|
* Old permissions implementation
|
||||||
|
@ -64,7 +64,6 @@ export const parsePermissionsTable = (
|
||||||
|
|
||||||
export const getCredentialPermissions = (user: IUser | null, credential: ICredentialsResponse) => {
|
export const getCredentialPermissions = (user: IUser | null, credential: ICredentialsResponse) => {
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
const rbacStore = useRBACStore();
|
|
||||||
const isSharingEnabled = settingsStore.isEnterpriseFeatureEnabled(
|
const isSharingEnabled = settingsStore.isEnterpriseFeatureEnabled(
|
||||||
EnterpriseEditionFeature.Sharing,
|
EnterpriseEditionFeature.Sharing,
|
||||||
);
|
);
|
||||||
|
@ -78,17 +77,30 @@ export const getCredentialPermissions = (user: IUser | null, credential: ICreden
|
||||||
name: UserRole.ResourceSharee,
|
name: UserRole.ResourceSharee,
|
||||||
test: () => !!credential?.sharedWith?.find((sharee) => sharee.id === user?.id),
|
test: () => !!credential?.sharedWith?.find((sharee) => sharee.id === user?.id),
|
||||||
},
|
},
|
||||||
{ name: 'read', test: () => rbacStore.hasScope('credential:read') },
|
|
||||||
{ name: 'save', test: [UserRole.ResourceOwner, UserRole.InstanceOwner] },
|
|
||||||
{ name: 'updateName', test: [UserRole.ResourceOwner, UserRole.InstanceOwner] },
|
|
||||||
{ name: 'updateConnection', test: [UserRole.ResourceOwner] },
|
|
||||||
{
|
{
|
||||||
name: 'updateSharing',
|
name: 'read',
|
||||||
|
test: (permissions) =>
|
||||||
|
hasPermission(['rbac'], { rbac: { scope: 'credential:read' } }) || !!permissions.isOwner,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'save',
|
||||||
|
test: (permissions) =>
|
||||||
|
hasPermission(['rbac'], { rbac: { scope: 'credential:create' } }) || !!permissions.isOwner,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'update',
|
||||||
test: (permissions) => !!permissions.isOwner,
|
test: (permissions) => !!permissions.isOwner,
|
||||||
},
|
},
|
||||||
{ name: 'updateNodeAccess', test: [UserRole.ResourceOwner] },
|
{
|
||||||
{ name: 'delete', test: [UserRole.ResourceOwner, UserRole.InstanceOwner] },
|
name: 'share',
|
||||||
{ name: 'use', test: [UserRole.ResourceOwner, UserRole.ResourceSharee] },
|
test: (permissions) =>
|
||||||
|
hasPermission(['rbac'], { rbac: { scope: 'credential:share' } }) || !!permissions.isOwner,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'delete',
|
||||||
|
test: (permissions) =>
|
||||||
|
hasPermission(['rbac'], { rbac: { scope: 'credential:delete' } }) || !!permissions.isOwner,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return parsePermissionsTable(user, table);
|
return parsePermissionsTable(user, table);
|
||||||
|
@ -96,7 +108,6 @@ export const getCredentialPermissions = (user: IUser | null, credential: ICreden
|
||||||
|
|
||||||
export const getWorkflowPermissions = (user: IUser | null, workflow: IWorkflowDb) => {
|
export const getWorkflowPermissions = (user: IUser | null, workflow: IWorkflowDb) => {
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
const rbacStore = useRBACStore();
|
|
||||||
const isSharingEnabled = settingsStore.isEnterpriseFeatureEnabled(
|
const isSharingEnabled = settingsStore.isEnterpriseFeatureEnabled(
|
||||||
EnterpriseEditionFeature.Sharing,
|
EnterpriseEditionFeature.Sharing,
|
||||||
);
|
);
|
||||||
|
@ -109,11 +120,13 @@ export const getWorkflowPermissions = (user: IUser | null, workflow: IWorkflowDb
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'updateSharing',
|
name: 'updateSharing',
|
||||||
test: (permissions) => !!permissions.isOwner,
|
test: (permissions) =>
|
||||||
|
hasPermission(['rbac'], { rbac: { scope: 'workflow:share' } }) || !!permissions.isOwner,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'delete',
|
name: 'delete',
|
||||||
test: (permissions) => rbacStore.hasScope('workflow:delete') || !!permissions.isOwner,
|
test: (permissions) =>
|
||||||
|
hasPermission(['rbac'], { rbac: { scope: 'workflow:delete' } }) || !!permissions.isOwner,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -121,12 +134,11 @@ export const getWorkflowPermissions = (user: IUser | null, workflow: IWorkflowDb
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getVariablesPermissions = (user: IUser | null) => {
|
export const getVariablesPermissions = (user: IUser | null) => {
|
||||||
const rbacStore = useRBACStore();
|
|
||||||
const table: IPermissionsTable = [
|
const table: IPermissionsTable = [
|
||||||
{ name: 'create', test: () => rbacStore.hasScope('variable:create') },
|
{ name: 'create', test: () => hasPermission(['rbac'], { rbac: { scope: 'variable:create' } }) },
|
||||||
{ name: 'edit', test: () => rbacStore.hasScope('variable:update') },
|
{ name: 'edit', test: () => hasPermission(['rbac'], { rbac: { scope: 'variable:update' } }) },
|
||||||
{ name: 'delete', test: () => rbacStore.hasScope('variable:delete') },
|
{ name: 'delete', test: () => hasPermission(['rbac'], { rbac: { scope: 'variable:delete' } }) },
|
||||||
{ name: 'use', test: () => rbacStore.hasScope('variable:read') },
|
{ name: 'use', test: () => hasPermission(['rbac'], { rbac: { scope: 'variable:read' } }) },
|
||||||
];
|
];
|
||||||
|
|
||||||
return parsePermissionsTable(user, table);
|
return parsePermissionsTable(user, table);
|
||||||
|
|
|
@ -416,7 +416,8 @@
|
||||||
"credentialEdit.oAuthButton.connectMyAccount": "Connect my account",
|
"credentialEdit.oAuthButton.connectMyAccount": "Connect my account",
|
||||||
"credentialEdit.oAuthButton.signInWithGoogle": "Sign in with Google",
|
"credentialEdit.oAuthButton.signInWithGoogle": "Sign in with Google",
|
||||||
"credentialEdit.credentialSharing.info.owner": "Sharing a credential allows people to use it in their workflows. They cannot access credential details.",
|
"credentialEdit.credentialSharing.info.owner": "Sharing a credential allows people to use it in their workflows. They cannot access credential details.",
|
||||||
"credentialEdit.credentialSharing.info.reader": "You can view this credential because you have permission to read and share (and rename or delete it too). To use it in a workflow, ask the credential owner to share it with you.",
|
"credentialEdit.credentialSharing.info.reader": "You can view this credential because you have permission to read and share (and rename or delete it too).{notShared}",
|
||||||
|
"credentialEdit.credentialSharing.info.notShared": "To use it in a workflow, ask the credential owner to share it with you.",
|
||||||
"credentialEdit.credentialSharing.info.sharee": "Only {credentialOwnerName} can change who this credential is shared with",
|
"credentialEdit.credentialSharing.info.sharee": "Only {credentialOwnerName} can change who this credential is shared with",
|
||||||
"credentialEdit.credentialSharing.info.sharee.fallback": "the owner",
|
"credentialEdit.credentialSharing.info.sharee.fallback": "the owner",
|
||||||
"credentialEdit.credentialSharing.select.placeholder": "Add users...",
|
"credentialEdit.credentialSharing.select.placeholder": "Add users...",
|
||||||
|
@ -1458,6 +1459,8 @@
|
||||||
"settings.users.usersEmailedError": "Couldn't send invite email",
|
"settings.users.usersEmailedError": "Couldn't send invite email",
|
||||||
"settings.users.usersInvited": "Users invited",
|
"settings.users.usersInvited": "Users invited",
|
||||||
"settings.users.usersInvitedError": "Could not invite users",
|
"settings.users.usersInvitedError": "Could not invite users",
|
||||||
|
"settings.users.advancedPermissions.warning": "{link} to unlock the ability to create additional admin users",
|
||||||
|
"settings.users.advancedPermissions.warning.link": "Upgrade",
|
||||||
"settings.api": "API",
|
"settings.api": "API",
|
||||||
"settings.n8napi": "n8n API",
|
"settings.n8napi": "n8n API",
|
||||||
"settings.log-streaming": "Log Streaming",
|
"settings.log-streaming": "Log Streaming",
|
||||||
|
|
|
@ -303,14 +303,6 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, {
|
||||||
credentialId: credential.id,
|
credentialId: credential.id,
|
||||||
ownedBy: data.ownedBy,
|
ownedBy: data.ownedBy,
|
||||||
});
|
});
|
||||||
|
|
||||||
const usersStore = useUsersStore();
|
|
||||||
if (data.sharedWith && data.ownedBy.id === usersStore.currentUserId) {
|
|
||||||
await this.setCredentialSharedWith({
|
|
||||||
credentialId: credential.id,
|
|
||||||
sharedWith: data.sharedWith,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.upsertCredential(credential);
|
this.upsertCredential(credential);
|
||||||
|
@ -365,7 +357,10 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, {
|
||||||
ownedBy: payload.ownedBy,
|
ownedBy: payload.ownedBy,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
async setCredentialSharedWith(payload: { sharedWith: IUser[]; credentialId: string }) {
|
async setCredentialSharedWith(payload: {
|
||||||
|
sharedWith: IUser[];
|
||||||
|
credentialId: string;
|
||||||
|
}): Promise<ICredentialsResponse> {
|
||||||
if (useSettingsStore().isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing)) {
|
if (useSettingsStore().isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing)) {
|
||||||
await setCredentialSharedWith(useRootStore().getRestApiContext, payload.credentialId, {
|
await setCredentialSharedWith(useRootStore().getRestApiContext, payload.credentialId, {
|
||||||
shareWithIds: payload.sharedWith.map((sharee) => sharee.id),
|
shareWithIds: payload.sharedWith.map((sharee) => sharee.id),
|
||||||
|
@ -376,6 +371,7 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, {
|
||||||
sharedWith: payload.sharedWith,
|
sharedWith: payload.sharedWith,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
return this.credentials[payload.credentialId];
|
||||||
},
|
},
|
||||||
addCredentialSharee(payload: { credentialId: string; sharee: Partial<IUser> }): void {
|
addCredentialSharee(payload: { credentialId: string; sharee: Partial<IUser> }): void {
|
||||||
this.credentials[payload.credentialId] = {
|
this.credentials[payload.credentialId] = {
|
||||||
|
|
|
@ -71,6 +71,7 @@ import {
|
||||||
isValidTheme,
|
isValidTheme,
|
||||||
updateTheme,
|
updateTheme,
|
||||||
} from './ui.utils';
|
} from './ui.utils';
|
||||||
|
import { useUsersStore } from './users.store';
|
||||||
|
|
||||||
let savedTheme: ThemeOption = 'system';
|
let savedTheme: ThemeOption = 'system';
|
||||||
try {
|
try {
|
||||||
|
@ -373,6 +374,7 @@ export const useUIStore = defineStore(STORES.UI, {
|
||||||
let linkUrl = '';
|
let linkUrl = '';
|
||||||
|
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
|
const { isInstanceOwner } = useUsersStore();
|
||||||
|
|
||||||
if (deploymentType === 'cloud' && hasPermission(['instanceOwner'])) {
|
if (deploymentType === 'cloud' && hasPermission(['instanceOwner'])) {
|
||||||
const adminPanelHost = new URL(window.location.href).host.split('.').slice(1).join('.');
|
const adminPanelHost = new URL(window.location.href).host.split('.').slice(1).join('.');
|
||||||
|
|
|
@ -95,7 +95,7 @@ export const useUsersStore = defineStore(STORES.USERS, {
|
||||||
return (resource: ICredentialsResponse): boolean => {
|
return (resource: ICredentialsResponse): boolean => {
|
||||||
const permissions = getCredentialPermissions(this.currentUser, resource);
|
const permissions = getCredentialPermissions(this.currentUser, resource);
|
||||||
|
|
||||||
return permissions.use;
|
return permissions.read;
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -301,10 +301,16 @@ export const useUsersStore = defineStore(STORES.USERS, {
|
||||||
const users = await getUsers(rootStore.getRestApiContext);
|
const users = await getUsers(rootStore.getRestApiContext);
|
||||||
this.addUsers(users);
|
this.addUsers(users);
|
||||||
},
|
},
|
||||||
async inviteUsers(params: Array<{ email: string }>): Promise<IInviteResponse[]> {
|
async inviteUsers(params: Array<{ email: string; role: IRole }>): Promise<IInviteResponse[]> {
|
||||||
const rootStore = useRootStore();
|
const rootStore = useRootStore();
|
||||||
const users = await inviteUsers(rootStore.getRestApiContext, params);
|
const users = await inviteUsers(rootStore.getRestApiContext, params);
|
||||||
this.addUsers(users.map(({ user }) => ({ isPending: true, ...user })));
|
this.addUsers(
|
||||||
|
users.map(({ user }, index) => ({
|
||||||
|
isPending: true,
|
||||||
|
globalRole: { name: params[index].role },
|
||||||
|
...user,
|
||||||
|
})),
|
||||||
|
);
|
||||||
return users;
|
return users;
|
||||||
},
|
},
|
||||||
async reinviteUser(params: { email: string }): Promise<void> {
|
async reinviteUser(params: { email: string }): Promise<void> {
|
||||||
|
|
|
@ -35,6 +35,15 @@
|
||||||
@click:button="goToUpgrade"
|
@click:button="goToUpgrade"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<n8n-notice v-if="!isAdvancedPermissionsEnabled">
|
||||||
|
<i18n-t keypath="settings.users.advancedPermissions.warning">
|
||||||
|
<template #link>
|
||||||
|
<n8n-link size="small" @click="goToUpgradeAdvancedPermissions">
|
||||||
|
{{ $locale.baseText('settings.users.advancedPermissions.warning.link') }}
|
||||||
|
</n8n-link>
|
||||||
|
</template>
|
||||||
|
</i18n-t>
|
||||||
|
</n8n-notice>
|
||||||
<!-- If there's more than 1 user it means the account quota was more than 1 in the past. So we need to allow instance owner to be able to delete users and transfer workflows.
|
<!-- If there's more than 1 user it means the account quota was more than 1 in the past. So we need to allow instance owner to be able to delete users and transfer workflows.
|
||||||
-->
|
-->
|
||||||
<div
|
<div
|
||||||
|
@ -52,7 +61,25 @@
|
||||||
@copyPasswordResetLink="onCopyPasswordResetLink"
|
@copyPasswordResetLink="onCopyPasswordResetLink"
|
||||||
@allowSSOManualLogin="onAllowSSOManualLogin"
|
@allowSSOManualLogin="onAllowSSOManualLogin"
|
||||||
@disallowSSOManualLogin="onDisallowSSOManualLogin"
|
@disallowSSOManualLogin="onDisallowSSOManualLogin"
|
||||||
|
>
|
||||||
|
<template #actions="{ user }">
|
||||||
|
<n8n-select
|
||||||
|
v-if="user.id !== usersStore.currentUserId"
|
||||||
|
:modelValue="user?.globalRole?.name || 'member'"
|
||||||
|
@update:modelValue="onRoleChange(user, $event)"
|
||||||
|
:disabled="!canUpdateRole"
|
||||||
|
data-test-id="user-role-select"
|
||||||
|
>
|
||||||
|
<n8n-option
|
||||||
|
v-for="role in userRoles"
|
||||||
|
:key="role.value"
|
||||||
|
:value="role.value"
|
||||||
|
:label="role.label"
|
||||||
|
:disabled="role.disabled"
|
||||||
/>
|
/>
|
||||||
|
</n8n-select>
|
||||||
|
</template>
|
||||||
|
</n8n-users-list>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -62,7 +89,7 @@ import { defineComponent } from 'vue';
|
||||||
import { mapStores } from 'pinia';
|
import { mapStores } from 'pinia';
|
||||||
import { EnterpriseEditionFeature, INVITE_USER_MODAL_KEY, VIEWS } from '@/constants';
|
import { EnterpriseEditionFeature, INVITE_USER_MODAL_KEY, VIEWS } from '@/constants';
|
||||||
|
|
||||||
import type { IRole, IUser, IUserListAction } from '@/Interface';
|
import type { IUser, IUserListAction } from '@/Interface';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { copyPaste } from '@/mixins/copyPaste';
|
import { copyPaste } from '@/mixins/copyPaste';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
@ -113,11 +140,18 @@ export default defineComponent({
|
||||||
{
|
{
|
||||||
label: this.$locale.baseText('settings.users.actions.delete'),
|
label: this.$locale.baseText('settings.users.actions.delete'),
|
||||||
value: 'delete',
|
value: 'delete',
|
||||||
|
guard: (user) =>
|
||||||
|
hasPermission(['rbac'], { rbac: { scope: 'user:delete' } }) &&
|
||||||
|
user.id !== this.usersStore.currentUserId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: this.$locale.baseText('settings.users.actions.copyPasswordResetLink'),
|
label: this.$locale.baseText('settings.users.actions.copyPasswordResetLink'),
|
||||||
value: 'copyPasswordResetLink',
|
value: 'copyPasswordResetLink',
|
||||||
guard: () => this.settingsStore.isBelowUserQuota,
|
guard: (user) =>
|
||||||
|
hasPermission(['rbac'], { rbac: { scope: 'user:resetPassword' } }) &&
|
||||||
|
this.settingsStore.isBelowUserQuota &&
|
||||||
|
!user.isPendingUser &&
|
||||||
|
user.id !== this.usersStore.currentUserId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: this.$locale.baseText('settings.users.actions.allowSSOManualLogin'),
|
label: this.$locale.baseText('settings.users.actions.allowSSOManualLogin'),
|
||||||
|
@ -133,7 +167,12 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
userRoles(): Array<{ value: IRole; label: string }> {
|
isAdvancedPermissionsEnabled(): boolean {
|
||||||
|
return this.settingsStore.isEnterpriseFeatureEnabled(
|
||||||
|
EnterpriseEditionFeature.AdvancedPermissions,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
userRoles(): Array<{ value: IRole; label: string; disabled?: boolean }> {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
value: ROLE.Member,
|
value: ROLE.Member,
|
||||||
|
@ -142,11 +181,12 @@ export default defineComponent({
|
||||||
{
|
{
|
||||||
value: ROLE.Admin,
|
value: ROLE.Admin,
|
||||||
label: this.$locale.baseText('auth.roles.admin'),
|
label: this.$locale.baseText('auth.roles.admin'),
|
||||||
|
disabled: !this.isAdvancedPermissionsEnabled,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
canUpdateRole(): boolean {
|
canUpdateRole(): boolean {
|
||||||
return hasPermission(['rbac'], { rbac: { scope: 'user:update' } });
|
return hasPermission(['rbac'], { rbac: { scope: ['user:update', 'user:changeRole'] } });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
@ -235,6 +275,9 @@ export default defineComponent({
|
||||||
goToUpgrade() {
|
goToUpgrade() {
|
||||||
void this.uiStore.goToUpgrade('settings-users', 'upgrade-users');
|
void this.uiStore.goToUpgrade('settings-users', 'upgrade-users');
|
||||||
},
|
},
|
||||||
|
goToUpgradeAdvancedPermissions() {
|
||||||
|
void this.uiStore.goToUpgrade('settings-users', 'upgrade-advanced-permissions');
|
||||||
|
},
|
||||||
async onRoleChange(user: IUser, name: IRole) {
|
async onRoleChange(user: IUser, name: IRole) {
|
||||||
await this.usersStore.updateRole({ id: user.id, role: { scope: 'global', name } });
|
await this.usersStore.updateRole({ id: user.id, role: { scope: 'global', name } });
|
||||||
},
|
},
|
||||||
|
|
Loading…
Reference in a new issue