fix(editor): Add user role tooltip to personal settings page (#15941)

Co-authored-by: Raúl Gómez Morales <raul00gm@gmail.com>
This commit is contained in:
Csaba Tuncsik
2025-06-03 14:52:24 +02:00
committed by GitHub
parent 2f648098fd
commit 2c9c3dab33
4 changed files with 89 additions and 5 deletions

View File

@@ -147,6 +147,7 @@
"auth.newPassword": "New password", "auth.newPassword": "New password",
"auth.password": "Password", "auth.password": "Password",
"auth.role": "Role", "auth.role": "Role",
"auth.roles.default": "Default",
"auth.roles.member": "Member", "auth.roles.member": "Member",
"auth.roles.admin": "Admin", "auth.roles.admin": "Admin",
"auth.roles.owner": "Owner", "auth.roles.owner": "Owner",
@@ -1892,6 +1893,11 @@
"settings.personal.personalSettings": "Personal Settings", "settings.personal.personalSettings": "Personal Settings",
"settings.personal.personalSettingsUpdated": "Personal details updated", "settings.personal.personalSettingsUpdated": "Personal details updated",
"settings.personal.personalSettingsUpdatedError": "Problem updating your details", "settings.personal.personalSettingsUpdatedError": "Problem updating your details",
"settings.personal.role.tooltip.default": "Default role for new users",
"settings.personal.role.tooltip.member": "Create and manage own workflows and credentials",
"settings.personal.role.tooltip.admin": "Full access to manage workflows,tags, credentials, projects, users and more",
"settings.personal.role.tooltip.owner": "Manage everything{cloudAccess}",
"settings.personal.role.tooltip.cloud": " and access Cloud dashboard",
"settings.personal.save": "Save", "settings.personal.save": "Save",
"settings.personal.security": "Security", "settings.personal.security": "Security",
"settings.signup.signUpInviterInfo": "{firstName} {lastName} has invited you to n8n", "settings.signup.signUpInviterInfo": "{firstName} {lastName} has invited you to n8n",

View File

@@ -47,9 +47,9 @@ export const useCloudPlanStore = defineStore(STORES.CLOUD_PLAN, () => {
return state.usage?.executions >= state.data?.monthlyExecutionsLimit; return state.usage?.executions >= state.data?.monthlyExecutionsLimit;
}); });
const hasCloudPlan = computed(() => { const hasCloudPlan = computed<boolean>(() => {
const cloudUserId = settingsStore.settings.n8nMetadata?.userId; const cloudUserId = settingsStore.settings.n8nMetadata?.userId;
return hasPermission(['instanceOwner']) && settingsStore.isCloudDeployment && cloudUserId; return hasPermission(['instanceOwner']) && settingsStore.isCloudDeployment && !!cloudUserId;
}); });
const getUserCloudAccount = async () => { const getUserCloudAccount = async () => {
@@ -189,6 +189,7 @@ export const useCloudPlanStore = defineStore(STORES.CLOUD_PLAN, () => {
currentUsageData, currentUsageData,
trialExpired, trialExpired,
allExecutionsUsed, allExecutionsUsed,
hasCloudPlan,
generateCloudDashboardAutoLoginLink, generateCloudDashboardAutoLoginLink,
initialize, initialize,
getOwnerCurrentPlan, getOwnerCurrentPlan,

View File

@@ -1,3 +1,4 @@
import userEvent from '@testing-library/user-event';
import { createPinia } from 'pinia'; import { createPinia } from 'pinia';
import { waitAllPromises } from '@/__tests__/utils'; import { waitAllPromises } from '@/__tests__/utils';
import SettingsPersonalView from '@/views/SettingsPersonalView.vue'; import SettingsPersonalView from '@/views/SettingsPersonalView.vue';
@@ -7,11 +8,13 @@ import { createComponentRenderer } from '@/__tests__/render';
import { setupServer } from '@/__tests__/server'; import { setupServer } from '@/__tests__/server';
import { ROLE } from '@/constants'; import { ROLE } from '@/constants';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
let pinia: ReturnType<typeof createPinia>; let pinia: ReturnType<typeof createPinia>;
let settingsStore: ReturnType<typeof useSettingsStore>; let settingsStore: ReturnType<typeof useSettingsStore>;
let usersStore: ReturnType<typeof useUsersStore>; let usersStore: ReturnType<typeof useUsersStore>;
let uiStore: ReturnType<typeof useUIStore>; let uiStore: ReturnType<typeof useUIStore>;
let cloudPlanStore: ReturnType<typeof useCloudPlanStore>;
let server: ReturnType<typeof setupServer>; let server: ReturnType<typeof setupServer>;
const renderComponent = createComponentRenderer(SettingsPersonalView); const renderComponent = createComponentRenderer(SettingsPersonalView);
@@ -40,6 +43,7 @@ describe('SettingsPersonalView', () => {
settingsStore = useSettingsStore(pinia); settingsStore = useSettingsStore(pinia);
usersStore = useUsersStore(pinia); usersStore = useUsersStore(pinia);
uiStore = useUIStore(pinia); uiStore = useUIStore(pinia);
cloudPlanStore = useCloudPlanStore(pinia);
usersStore.usersById[currentUser.id] = currentUser; usersStore.usersById[currentUser.id] = currentUser;
usersStore.currentUserId = currentUser.id; usersStore.currentUserId = currentUser.id;
@@ -143,4 +147,31 @@ describe('SettingsPersonalView', () => {
expect(queryByTestId('mfa-section')).not.toBeInTheDocument(); expect(queryByTestId('mfa-section')).not.toBeInTheDocument();
}); });
}); });
test.each([
['Default', ROLE.Default, false, 'Default role for new users'],
['Member', ROLE.Member, false, 'Create and manage own workflows and credentials'],
[
'Admin',
ROLE.Admin,
false,
'Full access to manage workflows,tags, credentials, projects, users and more',
],
['Owner', ROLE.Owner, false, 'Manage everything'],
['Owner', ROLE.Owner, true, 'Manage everything and access Cloud dashboard'],
])('should show %s user role information', async (label, role, hasCloudPlan, tooltipText) => {
vi.spyOn(cloudPlanStore, 'hasCloudPlan', 'get').mockReturnValue(hasCloudPlan);
vi.spyOn(usersStore, 'globalRoleName', 'get').mockReturnValue(role);
const { queryByTestId, getByText } = renderComponent({ pinia });
await waitAllPromises();
expect(queryByTestId('current-user-role')).toBeVisible();
expect(queryByTestId('current-user-role')).toHaveTextContent(label);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await userEvent.hover(queryByTestId('current-user-role')!);
expect(getByText(tooltipText)).toBeVisible();
});
}); });

View File

@@ -3,16 +3,18 @@ import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useI18n } from '@n8n/i18n'; import { useI18n } from '@n8n/i18n';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
import { useDocumentTitle } from '@/composables/useDocumentTitle'; import { useDocumentTitle } from '@/composables/useDocumentTitle';
import type { IFormInputs, IUser, ThemeOption } from '@/Interface'; import type { IFormInputs, IRole, IUser, ThemeOption } from '@/Interface';
import { import {
CHANGE_PASSWORD_MODAL_KEY, CHANGE_PASSWORD_MODAL_KEY,
MFA_DOCS_URL, MFA_DOCS_URL,
MFA_SETUP_MODAL_KEY, MFA_SETUP_MODAL_KEY,
PROMPT_MFA_CODE_MODAL_KEY, PROMPT_MFA_CODE_MODAL_KEY,
ROLE,
} from '@/constants'; } from '@/constants';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
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 { useCloudPlanStore } from '@/stores/cloudPlan.store';
import { createFormEventBus } from '@n8n/design-system/utils'; import { createFormEventBus } from '@n8n/design-system/utils';
import type { MfaModalEvents } from '@/event-bus/mfa'; import type { MfaModalEvents } from '@/event-bus/mfa';
import { promptMfaCodeBus } from '@/event-bus/mfa'; import { promptMfaCodeBus } from '@/event-bus/mfa';
@@ -28,6 +30,11 @@ type UserBasicDetailsWithMfa = UserBasicDetailsForm & {
mfaCode?: string; mfaCode?: string;
}; };
type RoleContent = {
name: string;
description: string;
};
const i18n = useI18n(); const i18n = useI18n();
const { showToast, showError } = useToast(); const { showToast, showError } = useToast();
const documentTitle = useDocumentTitle(); const documentTitle = useDocumentTitle();
@@ -55,6 +62,7 @@ const themeOptions = ref<Array<{ name: ThemeOption; label: BaseTextKey }>>([
const uiStore = useUIStore(); const uiStore = useUIStore();
const usersStore = useUsersStore(); const usersStore = useUsersStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const cloudPlanStore = useCloudPlanStore();
const currentUser = computed((): IUser | null => { const currentUser = computed((): IUser | null => {
return usersStore.currentUser; return usersStore.currentUser;
@@ -82,6 +90,33 @@ const hasAnyChanges = computed(() => {
return hasAnyBasicInfoChanges.value || hasAnyPersonalisationChanges.value; return hasAnyBasicInfoChanges.value || hasAnyPersonalisationChanges.value;
}); });
const roles = computed<Record<IRole, RoleContent>>(() => ({
[ROLE.Default]: {
name: i18n.baseText('auth.roles.default'),
description: i18n.baseText('settings.personal.role.tooltip.default'),
},
[ROLE.Member]: {
name: i18n.baseText('auth.roles.member'),
description: i18n.baseText('settings.personal.role.tooltip.member'),
},
[ROLE.Admin]: {
name: i18n.baseText('auth.roles.admin'),
description: i18n.baseText('settings.personal.role.tooltip.admin'),
},
[ROLE.Owner]: {
name: i18n.baseText('auth.roles.owner'),
description: i18n.baseText('settings.personal.role.tooltip.owner', {
interpolate: {
cloudAccess: cloudPlanStore.hasCloudPlan
? i18n.baseText('settings.personal.role.tooltip.cloud')
: '',
},
}),
},
}));
const currentUserRole = computed<RoleContent>(() => roles.value[usersStore.globalRoleName]);
onMounted(() => { onMounted(() => {
documentTitle.set(i18n.baseText('settings.personal.personalSettings')); documentTitle.set(i18n.baseText('settings.personal.personalSettings'));
formInputs.value = [ formInputs.value = [
@@ -260,7 +295,13 @@ onBeforeUnmount(() => {
}}</n8n-heading> }}</n8n-heading>
<div v-if="currentUser" :class="$style.user"> <div v-if="currentUser" :class="$style.user">
<span :class="$style.username" data-test-id="current-user-name"> <span :class="$style.username" data-test-id="current-user-name">
<n8n-text color="text-light">{{ currentUser.fullName }}</n8n-text> <n8n-text color="text-base" bold>{{ currentUser.fullName }}</n8n-text>
<N8nTooltip placement="bottom">
<template #content>{{ currentUserRole.description }}</template>
<n8n-text :class="$style.tooltip" color="text-light" data-test-id="current-user-role">{{
currentUserRole.name
}}</n8n-text>
</N8nTooltip>
</span> </span>
<n8n-avatar <n8n-avatar
:first-name="currentUser.firstName" :first-name="currentUser.firstName"
@@ -395,8 +436,9 @@ onBeforeUnmount(() => {
} }
.username { .username {
display: grid;
grid-template-columns: 1fr;
margin-right: var(--spacing-s); margin-right: var(--spacing-s);
text-align: right;
@media (max-width: $breakpoint-sm) { @media (max-width: $breakpoint-sm) {
max-width: 100px; max-width: 100px;
@@ -405,6 +447,10 @@ onBeforeUnmount(() => {
} }
} }
.tooltip {
justify-self: start;
}
.disableMfaButton { .disableMfaButton {
--button-color: var(--color-danger); --button-color: var(--color-danger);
> span { > span {