mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-09 22:24:05 -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 {
|
||||
CredentialsModal,
|
||||
CredentialsPage,
|
||||
|
@ -7,6 +7,7 @@ import {
|
|||
WorkflowSharingModal,
|
||||
WorkflowsPage,
|
||||
} from '../pages';
|
||||
import { getVisibleSelect } from '../utils';
|
||||
|
||||
/**
|
||||
* User U1 - Instance owner
|
||||
|
@ -129,4 +130,38 @@ describe('Sharing', { disableAutoLogin: true }, () => {
|
|||
credentialsPage.getters.credentialCard('Credential C2').click();
|
||||
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 { MainSidebar, SettingsSidebar, SettingsUsersPage, WorkflowPage } from '../pages';
|
||||
import { PersonalSettingsPage } from '../pages/settings-personal';
|
||||
import { getVisibleSelect } from '../utils';
|
||||
|
||||
/**
|
||||
* User A - Instance owner
|
||||
|
@ -29,7 +30,9 @@ const settingsSidebar = new SettingsSidebar();
|
|||
const mainSidebar = new MainSidebar();
|
||||
|
||||
describe('User Management', { disableAutoLogin: true }, () => {
|
||||
before(() => cy.enableFeature('sharing'));
|
||||
before(() => {
|
||||
cy.enableFeature('sharing');
|
||||
});
|
||||
|
||||
it('should prevent non-owners to access UM settings', () => {
|
||||
usersSettingsPage.actions.loginAndVisit(
|
||||
|
@ -58,6 +61,67 @@ describe('User Management', { disableAutoLogin: true }, () => {
|
|||
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', () => {
|
||||
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 { getVisibleSelect } from '../../utils';
|
||||
|
||||
export class CredentialsModal extends BasePage {
|
||||
getters = {
|
||||
|
@ -30,11 +31,7 @@ export class CredentialsModal extends BasePage {
|
|||
actions = {
|
||||
addUser: (email: string) => {
|
||||
this.getters.usersSelect().click();
|
||||
this.getters
|
||||
.usersSelect()
|
||||
.get('.el-select-dropdown__item')
|
||||
.contains(email.toLowerCase())
|
||||
.click();
|
||||
getVisibleSelect().contains(email.toLowerCase()).click();
|
||||
},
|
||||
setName: (name: string) => {
|
||||
this.getters.name().click();
|
||||
|
@ -48,6 +45,12 @@ export class CredentialsModal extends BasePage {
|
|||
if (test) cy.wait('@testCredential');
|
||||
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: () => {
|
||||
this.getters.closeButton().click();
|
||||
},
|
||||
|
|
|
@ -20,6 +20,8 @@ export class SettingsUsersPage extends BasePage {
|
|||
userItem: (email: string) => cy.getByTestId(`user-list-item-${email.toLowerCase()}`),
|
||||
userActionsToggle: (email: string) =>
|
||||
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: () =>
|
||||
cy.getByTestId('action-toggle-dropdown').find('li:contains("Delete"):visible'),
|
||||
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.
|
||||
*/
|
||||
@Authorized(['global', 'owner'])
|
||||
@Delete('/:id')
|
||||
@RequireGlobalScope('user:delete')
|
||||
async deleteUser(req: UserRequest.Delete) {
|
||||
|
|
|
@ -51,6 +51,7 @@
|
|||
:key="option.value"
|
||||
:value="option.value"
|
||||
:label="option.label"
|
||||
:disabled="!!option.disabled"
|
||||
size="small"
|
||||
/>
|
||||
</n8n-select>
|
||||
|
@ -118,7 +119,7 @@ export interface Props {
|
|||
validationRules?: Array<Rule | RuleGroup>;
|
||||
validators?: { [key: string]: IValidator | RuleGroup };
|
||||
maxlength?: number;
|
||||
options?: Array<{ value: string | number; label: string }>;
|
||||
options?: Array<{ value: string | number; label: string; disabled?: boolean }>;
|
||||
autocomplete?: string;
|
||||
name?: string;
|
||||
focusInitially?: boolean;
|
||||
|
|
|
@ -44,7 +44,7 @@ export type IFormInput = {
|
|||
validateOnBlur?: boolean;
|
||||
infoText?: string;
|
||||
placeholder?: string;
|
||||
options?: Array<{ label: string; value: string }>;
|
||||
options?: Array<{ label: string; value: string; disabled?: boolean }>;
|
||||
autocomplete?:
|
||||
| 'off'
|
||||
| 'new-password'
|
||||
|
|
|
@ -1790,7 +1790,8 @@ export type UTMCampaign =
|
|||
| 'upgrade-users'
|
||||
| 'upgrade-variables'
|
||||
| 'upgrade-community-nodes'
|
||||
| 'upgrade-workflow-history';
|
||||
| 'upgrade-workflow-history'
|
||||
| 'upgrade-advanced-permissions';
|
||||
|
||||
export type N8nBanners = {
|
||||
[key in BannerName]: {
|
||||
|
|
|
@ -4,6 +4,9 @@ import router from '@/router';
|
|||
import { VIEWS } from '@/constants';
|
||||
import { setupServer } from '@/__tests__/server';
|
||||
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 = {
|
||||
template: '<div />',
|
||||
|
@ -64,7 +67,7 @@ describe('router', () => {
|
|||
'should resolve %s to %s if user has permissions',
|
||||
async (path, name) => {
|
||||
const settingsStore = useSettingsStore();
|
||||
await settingsStore.getSettings();
|
||||
|
||||
settingsStore.settings.enterprise.debugInEditor = true;
|
||||
settingsStore.settings.enterprise.workflowHistory = true;
|
||||
|
||||
|
@ -73,4 +76,42 @@ describe('router', () => {
|
|||
},
|
||||
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 { makeRestApiRequest } from '@/utils/apiUtils';
|
||||
|
||||
|
@ -10,7 +10,10 @@ type AcceptInvitationParams = {
|
|||
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);
|
||||
}
|
||||
|
||||
|
|
|
@ -61,7 +61,7 @@
|
|||
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">
|
||||
{{ $locale.baseText('credentialEdit.credentialConfig.needHelpFillingOutTheseFields') }}
|
||||
<span class="ml-4xs">
|
||||
|
@ -104,7 +104,7 @@
|
|||
</enterprise-edition>
|
||||
|
||||
<CredentialInputs
|
||||
v-if="credentialType && credentialPermissions.updateConnection"
|
||||
v-if="credentialType && credentialPermissions.update"
|
||||
:credentialData="credentialData"
|
||||
:credentialProperties="credentialProperties"
|
||||
:documentationUrl="documentationUrl"
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
<InlineNameEdit
|
||||
:modelValue="credentialName"
|
||||
:subtitle="credentialType ? credentialType.displayName : ''"
|
||||
:readonly="!credentialPermissions.updateName || !credentialType"
|
||||
:readonly="!credentialPermissions.update || !credentialType"
|
||||
type="Credential"
|
||||
@update:modelValue="onNameEdit"
|
||||
data-test-id="credential-name"
|
||||
|
@ -224,6 +224,7 @@ export default defineComponent({
|
|||
selectedCredential: '',
|
||||
requiredCredentials: false, // Are credentials required or optional for the node
|
||||
hasUserSpecifiedName: false,
|
||||
isSharedWithChanged: false,
|
||||
};
|
||||
},
|
||||
async mounted() {
|
||||
|
@ -683,6 +684,7 @@ export default defineComponent({
|
|||
...this.credentialData,
|
||||
sharedWith: sharees,
|
||||
};
|
||||
this.isSharedWithChanged = true;
|
||||
this.hasUnsavedChanges = true;
|
||||
},
|
||||
|
||||
|
@ -920,10 +922,23 @@ export default defineComponent({
|
|||
): Promise<ICredentialsResponse | null> {
|
||||
let credential;
|
||||
try {
|
||||
credential = await this.credentialsStore.updateCredential({
|
||||
id: this.credentialId,
|
||||
data: credentialDetails,
|
||||
});
|
||||
if (this.credentialPermissions.update) {
|
||||
credential = await this.credentialsStore.updateCredential({
|
||||
id: this.credentialId,
|
||||
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;
|
||||
} catch (error) {
|
||||
this.showError(
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
<el-col :span="16">
|
||||
<div v-for="node in nodesWithAccess" :key="node.name" :class="$style.valueLabel">
|
||||
<el-checkbox
|
||||
v-if="credentialPermissions.updateNodeAccess"
|
||||
v-if="credentialPermissions.update"
|
||||
:label="
|
||||
$locale.headerText({
|
||||
key: `headers.${shortNodeType(node)}.displayName`,
|
||||
|
|
|
@ -34,7 +34,7 @@
|
|||
<n8n-info-tip v-if="credentialPermissions.isOwner" :bold="false" class="mb-s">
|
||||
{{ $locale.baseText('credentialEdit.credentialSharing.info.owner') }}
|
||||
</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', {
|
||||
interpolate: { credentialOwnerName },
|
||||
|
@ -42,10 +42,14 @@
|
|||
}}
|
||||
</n8n-info-tip>
|
||||
<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-user-select
|
||||
v-if="credentialPermissions.updateSharing"
|
||||
v-if="credentialPermissions.share"
|
||||
class="mb-s"
|
||||
size="large"
|
||||
:users="usersList"
|
||||
|
@ -62,7 +66,7 @@
|
|||
:actions="usersListActions"
|
||||
:users="sharedWithList"
|
||||
:currentUserId="usersStore.currentUser.id"
|
||||
:readonly="!credentialPermissions.updateSharing"
|
||||
:readonly="!credentialPermissions.share"
|
||||
@delete="onRemoveSharee"
|
||||
/>
|
||||
</div>
|
||||
|
@ -114,13 +118,12 @@ export default defineComponent({
|
|||
},
|
||||
usersList(): IUser[] {
|
||||
return this.usersStore.allUsers.filter((user: IUser) => {
|
||||
const isCurrentUser = user.id === this.usersStore.currentUser?.id;
|
||||
const isAlreadySharedWithUser = (this.credentialData.sharedWith || []).find(
|
||||
(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[] {
|
||||
|
@ -134,6 +137,11 @@ export default defineComponent({
|
|||
credentialOwnerName(): string {
|
||||
return this.credentialsStore.getCredentialOwnerNameById(`${this.credentialId}`);
|
||||
},
|
||||
isCredentialSharedWithCurrentUser(): boolean {
|
||||
return (this.credentialData.sharedWith || []).some((sharee: IUser) => {
|
||||
return sharee.id === this.usersStore.currentUser?.id;
|
||||
});
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async onAddSharee(userId: string) {
|
||||
|
|
|
@ -12,6 +12,15 @@
|
|||
:eventBus="modalBus"
|
||||
>
|
||||
<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">
|
||||
<n8n-users-list :users="invitedUsers">
|
||||
<template #actions="{ user }">
|
||||
|
@ -58,10 +67,11 @@ import { useToast } from '@/composables/useToast';
|
|||
import { copyPaste } from '@/mixins/copyPaste';
|
||||
import Modal from './Modal.vue';
|
||||
import type { IFormInputs, IInviteResponse, IUser } from '@/Interface';
|
||||
import { VALID_EMAIL_REGEX, INVITE_USER_MODAL_KEY } from '@/constants';
|
||||
import { ROLE } from '@/utils/userUtils';
|
||||
import { EnterpriseEditionFeature, VALID_EMAIL_REGEX, INVITE_USER_MODAL_KEY } from '@/constants';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { createEventBus } from 'n8n-design-system/utils';
|
||||
|
||||
const NAME_EMAIL_FORMAT_REGEX = /^.* <(.*)>$/;
|
||||
|
@ -97,6 +107,7 @@ export default defineComponent({
|
|||
formBus: createEventBus(),
|
||||
modalBus: createEventBus(),
|
||||
emails: '',
|
||||
role: 'member',
|
||||
showInviteUrls: null as IInviteResponse[] | null,
|
||||
loading: false,
|
||||
INVITE_USER_MODAL_KEY,
|
||||
|
@ -132,6 +143,11 @@ export default defineComponent({
|
|||
value: ROLE.Member,
|
||||
label: this.$locale.baseText('auth.roles.member'),
|
||||
},
|
||||
{
|
||||
value: ROLE.Admin,
|
||||
label: this.$locale.baseText('auth.roles.admin'),
|
||||
disabled: !this.isAdvancedPermissionsEnabled,
|
||||
},
|
||||
],
|
||||
capitalize: true,
|
||||
},
|
||||
|
@ -139,7 +155,7 @@ export default defineComponent({
|
|||
];
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useUsersStore, useSettingsStore),
|
||||
...mapStores(useUsersStore, useSettingsStore, useUIStore),
|
||||
emailsCount(): number {
|
||||
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: {
|
||||
validateEmails(value: string | number | boolean | null | undefined) {
|
||||
|
@ -193,6 +214,9 @@ export default defineComponent({
|
|||
if (e.name === 'emails') {
|
||||
this.emails = e.value;
|
||||
}
|
||||
if (e.name === 'role') {
|
||||
this.role = e.value;
|
||||
}
|
||||
},
|
||||
async onSubmit() {
|
||||
try {
|
||||
|
@ -200,7 +224,7 @@ export default defineComponent({
|
|||
|
||||
const emails = this.emails
|
||||
.split(',')
|
||||
.map((email) => ({ email: getEmail(email) }))
|
||||
.map((email) => ({ email: getEmail(email), role: this.role }))
|
||||
.filter((invite) => !!invite.email);
|
||||
|
||||
if (emails.length === 0) {
|
||||
|
@ -308,6 +332,9 @@ export default defineComponent({
|
|||
this.showCopyInviteLinkToast([]);
|
||||
}
|
||||
},
|
||||
goToUpgradeAdvancedPermissions() {
|
||||
void this.uiStore.goToUpgrade('settings-users', 'upgrade-advanced-permissions');
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -210,13 +210,12 @@ export default defineComponent({
|
|||
},
|
||||
usersList(): IUser[] {
|
||||
return this.usersStore.allUsers.filter((user: IUser) => {
|
||||
const isCurrentUser = user.id === this.usersStore.currentUser?.id;
|
||||
const isAlreadySharedWithUser = (this.sharedWith || []).find(
|
||||
(sharee) => sharee.id === user.id,
|
||||
);
|
||||
const isOwner = this.workflow?.ownedBy?.id === user.id;
|
||||
|
||||
return !isCurrentUser && !isAlreadySharedWithUser && !isOwner;
|
||||
return !isAlreadySharedWithUser && !isOwner;
|
||||
});
|
||||
},
|
||||
sharedWithList(): Array<Partial<IUser>> {
|
||||
|
|
|
@ -522,6 +522,7 @@ export const enum EnterpriseEditionFeature {
|
|||
DebugInEditor = 'debugInEditor',
|
||||
WorkflowHistory = 'workflowHistory',
|
||||
WorkerView = 'workerView',
|
||||
AdvancedPermissions = 'advancedPermissions',
|
||||
}
|
||||
export const MAIN_NODE_PANEL_WIDTH = 360;
|
||||
|
||||
|
|
|
@ -446,7 +446,7 @@ export const nodeHelpers = defineComponent({
|
|||
.getCredentialsByType(credentialTypeDescription.name)
|
||||
.filter((credential: ICredentialsResponse) => {
|
||||
const permissions = getCredentialPermissions(currentUser, credential);
|
||||
return permissions.use;
|
||||
return permissions.read;
|
||||
});
|
||||
|
||||
if (userCredentials === null) {
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
import type { IUser, ICredentialsResponse, IWorkflowDb } from '@/Interface';
|
||||
import { EnterpriseEditionFeature, PLACEHOLDER_EMPTY_WORKFLOW_ID } from '@/constants';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useRBACStore } from '@/stores/rbac.store';
|
||||
import { hasPermission } from './rbac/permissions';
|
||||
|
||||
/**
|
||||
* Old permissions implementation
|
||||
|
@ -64,7 +64,6 @@ export const parsePermissionsTable = (
|
|||
|
||||
export const getCredentialPermissions = (user: IUser | null, credential: ICredentialsResponse) => {
|
||||
const settingsStore = useSettingsStore();
|
||||
const rbacStore = useRBACStore();
|
||||
const isSharingEnabled = settingsStore.isEnterpriseFeatureEnabled(
|
||||
EnterpriseEditionFeature.Sharing,
|
||||
);
|
||||
|
@ -78,17 +77,30 @@ export const getCredentialPermissions = (user: IUser | null, credential: ICreden
|
|||
name: UserRole.ResourceSharee,
|
||||
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,
|
||||
},
|
||||
{ name: 'updateNodeAccess', test: [UserRole.ResourceOwner] },
|
||||
{ name: 'delete', test: [UserRole.ResourceOwner, UserRole.InstanceOwner] },
|
||||
{ name: 'use', test: [UserRole.ResourceOwner, UserRole.ResourceSharee] },
|
||||
{
|
||||
name: 'share',
|
||||
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);
|
||||
|
@ -96,7 +108,6 @@ 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,
|
||||
);
|
||||
|
@ -109,11 +120,13 @@ export const getWorkflowPermissions = (user: IUser | null, workflow: IWorkflowDb
|
|||
},
|
||||
{
|
||||
name: 'updateSharing',
|
||||
test: (permissions) => !!permissions.isOwner,
|
||||
test: (permissions) =>
|
||||
hasPermission(['rbac'], { rbac: { scope: 'workflow:share' } }) || !!permissions.isOwner,
|
||||
},
|
||||
{
|
||||
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) => {
|
||||
const rbacStore = useRBACStore();
|
||||
const table: IPermissionsTable = [
|
||||
{ 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') },
|
||||
{ name: 'create', test: () => hasPermission(['rbac'], { rbac: { scope: 'variable:create' } }) },
|
||||
{ name: 'edit', test: () => hasPermission(['rbac'], { rbac: { scope: 'variable:update' } }) },
|
||||
{ name: 'delete', test: () => hasPermission(['rbac'], { rbac: { scope: 'variable:delete' } }) },
|
||||
{ name: 'use', test: () => hasPermission(['rbac'], { rbac: { scope: 'variable:read' } }) },
|
||||
];
|
||||
|
||||
return parsePermissionsTable(user, table);
|
||||
|
|
|
@ -416,7 +416,8 @@
|
|||
"credentialEdit.oAuthButton.connectMyAccount": "Connect my account",
|
||||
"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.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.fallback": "the owner",
|
||||
"credentialEdit.credentialSharing.select.placeholder": "Add users...",
|
||||
|
@ -1458,6 +1459,8 @@
|
|||
"settings.users.usersEmailedError": "Couldn't send invite email",
|
||||
"settings.users.usersInvited": "Users invited",
|
||||
"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.n8napi": "n8n API",
|
||||
"settings.log-streaming": "Log Streaming",
|
||||
|
|
|
@ -303,14 +303,6 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, {
|
|||
credentialId: credential.id,
|
||||
ownedBy: data.ownedBy,
|
||||
});
|
||||
|
||||
const usersStore = useUsersStore();
|
||||
if (data.sharedWith && data.ownedBy.id === usersStore.currentUserId) {
|
||||
await this.setCredentialSharedWith({
|
||||
credentialId: credential.id,
|
||||
sharedWith: data.sharedWith,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.upsertCredential(credential);
|
||||
|
@ -365,7 +357,10 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, {
|
|||
ownedBy: payload.ownedBy,
|
||||
};
|
||||
},
|
||||
async setCredentialSharedWith(payload: { sharedWith: IUser[]; credentialId: string }) {
|
||||
async setCredentialSharedWith(payload: {
|
||||
sharedWith: IUser[];
|
||||
credentialId: string;
|
||||
}): Promise<ICredentialsResponse> {
|
||||
if (useSettingsStore().isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing)) {
|
||||
await setCredentialSharedWith(useRootStore().getRestApiContext, payload.credentialId, {
|
||||
shareWithIds: payload.sharedWith.map((sharee) => sharee.id),
|
||||
|
@ -376,6 +371,7 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, {
|
|||
sharedWith: payload.sharedWith,
|
||||
};
|
||||
}
|
||||
return this.credentials[payload.credentialId];
|
||||
},
|
||||
addCredentialSharee(payload: { credentialId: string; sharee: Partial<IUser> }): void {
|
||||
this.credentials[payload.credentialId] = {
|
||||
|
|
|
@ -71,6 +71,7 @@ import {
|
|||
isValidTheme,
|
||||
updateTheme,
|
||||
} from './ui.utils';
|
||||
import { useUsersStore } from './users.store';
|
||||
|
||||
let savedTheme: ThemeOption = 'system';
|
||||
try {
|
||||
|
@ -373,6 +374,7 @@ export const useUIStore = defineStore(STORES.UI, {
|
|||
let linkUrl = '';
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
const { isInstanceOwner } = useUsersStore();
|
||||
|
||||
if (deploymentType === 'cloud' && hasPermission(['instanceOwner'])) {
|
||||
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 => {
|
||||
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);
|
||||
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 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;
|
||||
},
|
||||
async reinviteUser(params: { email: string }): Promise<void> {
|
||||
|
|
|
@ -35,6 +35,15 @@
|
|||
@click:button="goToUpgrade"
|
||||
/>
|
||||
</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.
|
||||
-->
|
||||
<div
|
||||
|
@ -52,7 +61,25 @@
|
|||
@copyPasswordResetLink="onCopyPasswordResetLink"
|
||||
@allowSSOManualLogin="onAllowSSOManualLogin"
|
||||
@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>
|
||||
</template>
|
||||
|
@ -62,7 +89,7 @@ import { defineComponent } from 'vue';
|
|||
import { mapStores } from 'pinia';
|
||||
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 { copyPaste } from '@/mixins/copyPaste';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
|
@ -113,11 +140,18 @@ export default defineComponent({
|
|||
{
|
||||
label: this.$locale.baseText('settings.users.actions.delete'),
|
||||
value: 'delete',
|
||||
guard: (user) =>
|
||||
hasPermission(['rbac'], { rbac: { scope: 'user:delete' } }) &&
|
||||
user.id !== this.usersStore.currentUserId,
|
||||
},
|
||||
{
|
||||
label: this.$locale.baseText('settings.users.actions.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'),
|
||||
|
@ -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 [
|
||||
{
|
||||
value: ROLE.Member,
|
||||
|
@ -142,11 +181,12 @@ export default defineComponent({
|
|||
{
|
||||
value: ROLE.Admin,
|
||||
label: this.$locale.baseText('auth.roles.admin'),
|
||||
disabled: !this.isAdvancedPermissionsEnabled,
|
||||
},
|
||||
];
|
||||
},
|
||||
canUpdateRole(): boolean {
|
||||
return hasPermission(['rbac'], { rbac: { scope: 'user:update' } });
|
||||
return hasPermission(['rbac'], { rbac: { scope: ['user:update', 'user:changeRole'] } });
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
|
@ -235,6 +275,9 @@ export default defineComponent({
|
|||
goToUpgrade() {
|
||||
void this.uiStore.goToUpgrade('settings-users', 'upgrade-users');
|
||||
},
|
||||
goToUpgradeAdvancedPermissions() {
|
||||
void this.uiStore.goToUpgrade('settings-users', 'upgrade-advanced-permissions');
|
||||
},
|
||||
async onRoleChange(user: IUser, name: IRole) {
|
||||
await this.usersStore.updateRole({ id: user.id, role: { scope: 'global', name } });
|
||||
},
|
||||
|
|
Loading…
Reference in a new issue