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)
This commit is contained in:
Csaba Tuncsik 2023-11-27 13:38:03 +01:00 committed by GitHub
parent 5acb7b94c0
commit 137e23853f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 74 additions and 17 deletions

View file

@ -35,10 +35,11 @@ export type Scope =
| SourceControlScope | SourceControlScope
| ExternalSecretStoreScope; | ExternalSecretStoreScope;
export type ScopeLevel<T extends 'global' | 'project' | 'resource'> = Record<T, Scope[]>; export type ScopeLevel = 'global' | 'project' | 'resource';
export type GlobalScopes = ScopeLevel<'global'>; export type GetScopeLevel<T extends ScopeLevel> = Record<T, Scope[]>;
export type ProjectScopes = ScopeLevel<'project'>; export type GlobalScopes = GetScopeLevel<'global'>;
export type ResourceScopes = ScopeLevel<'resource'>; export type ProjectScopes = GetScopeLevel<'project'>;
export type ResourceScopes = GetScopeLevel<'resource'>;
export type ScopeLevels = GlobalScopes & (ProjectScopes | (ProjectScopes & ResourceScopes)); export type ScopeLevels = GlobalScopes & (ProjectScopes | (ProjectScopes & ResourceScopes));
export type ScopeMode = 'oneOf' | 'allOf'; export type ScopeMode = 'oneOf' | 'allOf';

View file

@ -670,7 +670,7 @@ export type IPersonalizationSurveyVersions =
| IPersonalizationSurveyAnswersV2 | IPersonalizationSurveyAnswersV2
| IPersonalizationSurveyAnswersV3; | IPersonalizationSurveyAnswersV3;
export type IRole = 'default' | 'owner' | 'member'; export type IRole = 'default' | 'owner' | 'member' | 'admin';
export interface IUserResponse { export interface IUserResponse {
id: string; id: string;

View file

@ -2,10 +2,12 @@ import type {
CurrentUserResponse, CurrentUserResponse,
IPersonalizationLatestVersion, IPersonalizationLatestVersion,
IRestApiContext, IRestApiContext,
IRole,
IUserResponse, IUserResponse,
} from '@/Interface'; } from '@/Interface';
import type { IDataObject } from 'n8n-workflow'; import type { IDataObject } from 'n8n-workflow';
import { makeRestApiRequest } from '@/utils/apiUtils'; import { makeRestApiRequest } from '@/utils/apiUtils';
import type { ScopeLevel } from '@n8n/permissions';
export async function loginCurrentUser( export async function loginCurrentUser(
context: IRestApiContext, context: IRestApiContext,
@ -143,3 +145,10 @@ export async function submitPersonalizationSurvey(
): Promise<void> { ): Promise<void> {
await makeRestApiRequest(context, 'POST', '/me/survey', params as unknown as IDataObject); 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<IUserResponse> {
return makeRestApiRequest(context, 'PATCH', `/users/${id}/role`, { newRole: role });
}

View file

@ -132,6 +132,10 @@ 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'),
},
], ],
capitalize: true, capitalize: true,
}, },

View file

@ -103,6 +103,7 @@
"auth.password": "Password", "auth.password": "Password",
"auth.role": "Role", "auth.role": "Role",
"auth.roles.member": "Member", "auth.roles.member": "Member",
"auth.roles.admin": "Admin",
"auth.roles.owner": "Owner", "auth.roles.owner": "Owner",
"auth.agreement.label": "Inform me about security vulnerabilities if they arise", "auth.agreement.label": "Inform me about security vulnerabilities if they arise",
"auth.setup.next": "Next", "auth.setup.next": "Next",

View file

@ -476,9 +476,11 @@ export const routes = [
settingsView: SettingsUsersView, settingsView: SettingsUsersView,
}, },
meta: { meta: {
middleware: ['authenticated', 'role'], middleware: ['authenticated', 'rbac'],
middlewareOptions: { middlewareOptions: {
role: [ROLE.Owner], rbac: {
scope: ['user:create', 'user:update'],
},
}, },
telemetry: { telemetry: {
pageCategory: 'settings', pageCategory: 'settings',

View file

@ -15,6 +15,7 @@ import {
updateOtherUserSettings, updateOtherUserSettings,
validatePasswordToken, validatePasswordToken,
validateSignupToken, validateSignupToken,
updateRole,
} from '@/api/users'; } from '@/api/users';
import { PERSONALIZATION_MODAL_KEY, STORES } from '@/constants'; import { PERSONALIZATION_MODAL_KEY, STORES } from '@/constants';
import type { import type {
@ -39,16 +40,13 @@ import { useCloudPlanStore } from './cloudPlan.store';
import { disableMfa, enableMfa, getMfaQR, verifyMfaToken } from '@/api/mfa'; import { disableMfa, enableMfa, getMfaQR, verifyMfaToken } from '@/api/mfa';
import { confirmEmail, getCloudUserInfo } from '@/api/cloudPlans'; import { confirmEmail, getCloudUserInfo } from '@/api/cloudPlans';
import { useRBACStore } from '@/stores/rbac.store'; 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'; import { inviteUsers, acceptInvitation } from '@/api/invitation';
const isDefaultUser = (user: IUserResponse | null) => const isDefaultUser = (user: IUserResponse | null) =>
Boolean(user && user.isPending && user.globalRole && user.globalRole.name === ROLE.Owner); user?.isPending && user?.globalRole?.name === ROLE.Owner;
const isPendingUser = (user: IUserResponse | null) => !!user?.isPending;
const isPendingUser = (user: IUserResponse | null) => Boolean(user && user.isPending); const isInstanceOwner = (user: IUserResponse | null) => user?.globalRole?.name === ROLE.Owner;
const isInstanceOwner = (user: IUserResponse | null) =>
Boolean(user?.globalRole?.name === ROLE.Owner);
export const useUsersStore = defineStore(STORES.USERS, { export const useUsersStore = defineStore(STORES.USERS, {
state: (): IUsersState => ({ state: (): IUsersState => ({
@ -375,5 +373,11 @@ export const useUsersStore = defineStore(STORES.USERS, {
async confirmEmail() { async confirmEmail() {
await confirmEmail(useRootStore().getRestApiContext); 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();
},
}, },
}); });

View file

@ -84,9 +84,11 @@ function isPersonalizationSurveyV2OrLater(
return 'version' in data; return 'version' in data;
} }
export const ROLE: { Owner: IRole; Member: IRole; Default: IRole } = { export type Roles = { [R in IRole as Capitalize<R>]: R };
export const ROLE: Roles = {
Owner: 'owner', Owner: 'owner',
Member: 'member', Member: 'member',
Admin: 'admin',
Default: 'default', // default user with no email when setting up instance Default: 'default', // default user with no email when setting up instance
}; };

View file

@ -52,7 +52,23 @@
@copyPasswordResetLink="onCopyPasswordResetLink" @copyPasswordResetLink="onCopyPasswordResetLink"
@allowSSOManualLogin="onAllowSSOManualLogin" @allowSSOManualLogin="onAllowSSOManualLogin"
@disallowSSOManualLogin="onDisallowSSOManualLogin" @disallowSSOManualLogin="onDisallowSSOManualLogin"
/> >
<template #actions="{ user }">
<n8n-select
:modelValue="user.globalRole.name"
@update:modelValue="($event: IRole) => 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"
/>
</n8n-select>
</template>
</n8n-users-list>
</div> </div>
</div> </div>
</template> </template>
@ -62,7 +78,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 { IUserListAction } from '@/Interface'; import type { IRole, IUser, IUserListAction } from '@/Interface';
import { useToast } from '@/composables'; import { useToast } from '@/composables';
import { copyPaste } from '@/mixins/copyPaste'; import { copyPaste } from '@/mixins/copyPaste';
import { useUIStore } from '@/stores/ui.store'; 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: { methods: {
redirectToSetup() { redirectToSetup() {
@ -220,6 +251,9 @@ export default defineComponent({
goToUpgrade() { goToUpgrade() {
void this.uiStore.goToUpgrade('settings-users', 'upgrade-users'); 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 } });
},
}, },
}); });
</script> </script>