From 137e23853fdbd3e62037a6cb7f742811af41a03d Mon Sep 17 00:00:00 2001 From: Csaba Tuncsik Date: Mon, 27 Nov 2023 13:38:03 +0100 Subject: [PATCH] feat: Add user role select to users list settings page (#7796) ![CleanShot 2023-11-27 at 07 20 58](https://github.com/n8n-io/n8n/assets/5410822/40be0505-32ee-48a7-923e-ba6b4dbce670) --- packages/@n8n/permissions/src/types.ts | 9 +++-- packages/editor-ui/src/Interface.ts | 2 +- packages/editor-ui/src/api/users.ts | 9 +++++ .../src/components/InviteUsersModal.vue | 4 ++ .../src/plugins/i18n/locales/en.json | 1 + packages/editor-ui/src/router.ts | 6 ++- packages/editor-ui/src/stores/users.store.ts | 18 +++++---- packages/editor-ui/src/utils/userUtils.ts | 4 +- .../editor-ui/src/views/SettingsUsersView.vue | 38 ++++++++++++++++++- 9 files changed, 74 insertions(+), 17 deletions(-) diff --git a/packages/@n8n/permissions/src/types.ts b/packages/@n8n/permissions/src/types.ts index 0b1feb355e..edb4e4c099 100644 --- a/packages/@n8n/permissions/src/types.ts +++ b/packages/@n8n/permissions/src/types.ts @@ -35,10 +35,11 @@ export type Scope = | SourceControlScope | ExternalSecretStoreScope; -export type ScopeLevel = Record; -export type GlobalScopes = ScopeLevel<'global'>; -export type ProjectScopes = ScopeLevel<'project'>; -export type ResourceScopes = ScopeLevel<'resource'>; +export type ScopeLevel = 'global' | 'project' | 'resource'; +export type GetScopeLevel = Record; +export type GlobalScopes = GetScopeLevel<'global'>; +export type ProjectScopes = GetScopeLevel<'project'>; +export type ResourceScopes = GetScopeLevel<'resource'>; export type ScopeLevels = GlobalScopes & (ProjectScopes | (ProjectScopes & ResourceScopes)); export type ScopeMode = 'oneOf' | 'allOf'; diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 6a36fc927a..c5a46d55d8 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -670,7 +670,7 @@ export type IPersonalizationSurveyVersions = | IPersonalizationSurveyAnswersV2 | IPersonalizationSurveyAnswersV3; -export type IRole = 'default' | 'owner' | 'member'; +export type IRole = 'default' | 'owner' | 'member' | 'admin'; export interface IUserResponse { id: string; diff --git a/packages/editor-ui/src/api/users.ts b/packages/editor-ui/src/api/users.ts index 2363304469..679625d720 100644 --- a/packages/editor-ui/src/api/users.ts +++ b/packages/editor-ui/src/api/users.ts @@ -2,10 +2,12 @@ import type { CurrentUserResponse, IPersonalizationLatestVersion, IRestApiContext, + IRole, IUserResponse, } from '@/Interface'; import type { IDataObject } from 'n8n-workflow'; import { makeRestApiRequest } from '@/utils/apiUtils'; +import type { ScopeLevel } from '@n8n/permissions'; export async function loginCurrentUser( context: IRestApiContext, @@ -143,3 +145,10 @@ export async function submitPersonalizationSurvey( ): Promise { await makeRestApiRequest(context, 'POST', '/me/survey', params as unknown as IDataObject); } + +export async function updateRole( + context: IRestApiContext, + { id, role }: { id: string; role: { scope: ScopeLevel; name: IRole } }, +): Promise { + return makeRestApiRequest(context, 'PATCH', `/users/${id}/role`, { newRole: role }); +} diff --git a/packages/editor-ui/src/components/InviteUsersModal.vue b/packages/editor-ui/src/components/InviteUsersModal.vue index 791b9fa10b..befa66f8b5 100644 --- a/packages/editor-ui/src/components/InviteUsersModal.vue +++ b/packages/editor-ui/src/components/InviteUsersModal.vue @@ -132,6 +132,10 @@ export default defineComponent({ value: ROLE.Member, label: this.$locale.baseText('auth.roles.member'), }, + { + value: ROLE.Admin, + label: this.$locale.baseText('auth.roles.admin'), + }, ], capitalize: true, }, diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index fb78e8f83c..d3880239c4 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -103,6 +103,7 @@ "auth.password": "Password", "auth.role": "Role", "auth.roles.member": "Member", + "auth.roles.admin": "Admin", "auth.roles.owner": "Owner", "auth.agreement.label": "Inform me about security vulnerabilities if they arise", "auth.setup.next": "Next", diff --git a/packages/editor-ui/src/router.ts b/packages/editor-ui/src/router.ts index 13d6109b1e..44008d7922 100644 --- a/packages/editor-ui/src/router.ts +++ b/packages/editor-ui/src/router.ts @@ -476,9 +476,11 @@ export const routes = [ settingsView: SettingsUsersView, }, meta: { - middleware: ['authenticated', 'role'], + middleware: ['authenticated', 'rbac'], middlewareOptions: { - role: [ROLE.Owner], + rbac: { + scope: ['user:create', 'user:update'], + }, }, telemetry: { pageCategory: 'settings', diff --git a/packages/editor-ui/src/stores/users.store.ts b/packages/editor-ui/src/stores/users.store.ts index dd87089aa9..0b2e1e787a 100644 --- a/packages/editor-ui/src/stores/users.store.ts +++ b/packages/editor-ui/src/stores/users.store.ts @@ -15,6 +15,7 @@ import { updateOtherUserSettings, validatePasswordToken, validateSignupToken, + updateRole, } from '@/api/users'; import { PERSONALIZATION_MODAL_KEY, STORES } from '@/constants'; import type { @@ -39,16 +40,13 @@ import { useCloudPlanStore } from './cloudPlan.store'; import { disableMfa, enableMfa, getMfaQR, verifyMfaToken } from '@/api/mfa'; import { confirmEmail, getCloudUserInfo } from '@/api/cloudPlans'; import { useRBACStore } from '@/stores/rbac.store'; -import type { Scope } from '@n8n/permissions'; +import type { Scope, ScopeLevel } from '@n8n/permissions'; import { inviteUsers, acceptInvitation } from '@/api/invitation'; const isDefaultUser = (user: IUserResponse | null) => - Boolean(user && user.isPending && user.globalRole && user.globalRole.name === ROLE.Owner); - -const isPendingUser = (user: IUserResponse | null) => Boolean(user && user.isPending); - -const isInstanceOwner = (user: IUserResponse | null) => - Boolean(user?.globalRole?.name === ROLE.Owner); + user?.isPending && user?.globalRole?.name === ROLE.Owner; +const isPendingUser = (user: IUserResponse | null) => !!user?.isPending; +const isInstanceOwner = (user: IUserResponse | null) => user?.globalRole?.name === ROLE.Owner; export const useUsersStore = defineStore(STORES.USERS, { state: (): IUsersState => ({ @@ -375,5 +373,11 @@ export const useUsersStore = defineStore(STORES.USERS, { async confirmEmail() { await confirmEmail(useRootStore().getRestApiContext); }, + + async updateRole({ id, role }: { id: string; role: { scope: ScopeLevel; name: IRole } }) { + const rootStore = useRootStore(); + await updateRole(rootStore.getRestApiContext, { id, role }); + await this.fetchUsers(); + }, }, }); diff --git a/packages/editor-ui/src/utils/userUtils.ts b/packages/editor-ui/src/utils/userUtils.ts index 316d3e9602..237a8067d5 100644 --- a/packages/editor-ui/src/utils/userUtils.ts +++ b/packages/editor-ui/src/utils/userUtils.ts @@ -84,9 +84,11 @@ function isPersonalizationSurveyV2OrLater( return 'version' in data; } -export const ROLE: { Owner: IRole; Member: IRole; Default: IRole } = { +export type Roles = { [R in IRole as Capitalize]: R }; +export const ROLE: Roles = { Owner: 'owner', Member: 'member', + Admin: 'admin', Default: 'default', // default user with no email when setting up instance }; diff --git a/packages/editor-ui/src/views/SettingsUsersView.vue b/packages/editor-ui/src/views/SettingsUsersView.vue index 15e0104320..8b72ad6a98 100644 --- a/packages/editor-ui/src/views/SettingsUsersView.vue +++ b/packages/editor-ui/src/views/SettingsUsersView.vue @@ -52,7 +52,23 @@ @copyPasswordResetLink="onCopyPasswordResetLink" @allowSSOManualLogin="onAllowSSOManualLogin" @disallowSSOManualLogin="onDisallowSSOManualLogin" - /> + > + + @@ -62,7 +78,7 @@ import { defineComponent } from 'vue'; import { mapStores } from 'pinia'; import { EnterpriseEditionFeature, INVITE_USER_MODAL_KEY, VIEWS } from '@/constants'; -import type { IUserListAction } from '@/Interface'; +import type { IRole, IUser, IUserListAction } from '@/Interface'; import { useToast } from '@/composables'; import { copyPaste } from '@/mixins/copyPaste'; import { useUIStore } from '@/stores/ui.store'; @@ -133,6 +149,21 @@ export default defineComponent({ }, ]; }, + userRoles(): Array<{ value: IRole; label: string }> { + return [ + { + value: ROLE.Member, + label: this.$locale.baseText('auth.roles.member'), + }, + { + value: ROLE.Admin, + label: this.$locale.baseText('auth.roles.admin'), + }, + ]; + }, + canUpdateRole(): boolean { + return hasPermission(['rbac'], { rbac: { scope: 'user:update' } }); + }, }, methods: { redirectToSetup() { @@ -220,6 +251,9 @@ export default defineComponent({ goToUpgrade() { void this.uiStore.goToUpgrade('settings-users', 'upgrade-users'); }, + async onRoleChange(user: IUser, name: IRole) { + await this.usersStore.updateRole({ id: user.id, role: { scope: 'global', name } }); + }, }, });