From 72cdca23d6b58352618a7386799ce43a260f320c Mon Sep 17 00:00:00 2001 From: Stephen Wright Date: Fri, 21 Nov 2025 10:58:32 +0000 Subject: [PATCH] feat: Block UI updates for instance / project roles if provisioning enabed (#22095) --- .../frontend/@n8n/i18n/src/locales/en.json | 2 ++ .../components/ProjectMembersTable.test.ts | 13 +++++++++++ .../components/ProjectMembersTable.vue | 4 +++- .../projects/views/ProjectSettings.vue | 21 ++++++++++++++++- .../components/SettingsUsersTable.test.ts | 11 +++++++++ .../users/components/SettingsUsersTable.vue | 7 +++++- .../users/views/SettingsUsersView.vue | 23 +++++++++++++++++-- 7 files changed, 76 insertions(+), 5 deletions(-) diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index cb6b1d722e1..611d01c7c91 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -2563,6 +2563,8 @@ "settings.provisioningConfirmDialog.button.generateCsvExport": "Generate access settings CSV export", "settings.provisioningConfirmDialog.button.downloadProjectRolesCsv": "Download existing project access settings csv", "settings.provisioningConfirmDialog.button.downloadInstanceRolesCsv": "Download existing instance role settings csv", + "settings.provisioningInstanceRolesHandledBySsoProvider.description": "User management and instance roles are controlled by your SSO provider. Contact your n8n instance owner or admin to make changes.", + "settings.provisioningProjectRolesHandledBySsoProvider.description": "User management and project roles are controlled by your SSO provider. Contact your n8n instance owner or admin to make changes.", "settings.externalSecrets.title": "External Secrets", "settings.externalSecrets.info": "Connect external secrets tools for centralized credentials management across environments, and to enhance system security.", "settings.externalSecrets.info.link": "More info", diff --git a/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectMembersTable.test.ts b/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectMembersTable.test.ts index f60c9ee72d2..971b7dba197 100644 --- a/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectMembersTable.test.ts +++ b/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectMembersTable.test.ts @@ -173,6 +173,7 @@ describe('ProjectMembersTable', () => { currentUserId: '2', projectRoles: mockProjectRoles, tableOptions: mockTableOptions, + canEditRole: true, }, }); }); @@ -354,6 +355,18 @@ describe('ProjectMembersTable', () => { // The text should be rendered by N8nText component expect(screen.getByText('Editor')).toBeInTheDocument(); }); + + it('should not show a role dropdown if canEditRole is false', () => { + renderComponent({ + props: { + canEditRole: false, + }, + }); + + expect(screen.queryByTestId('role-dropdown-1')).not.toBeInTheDocument(); + expect(screen.queryByTestId('role-dropdown-2')).not.toBeInTheDocument(); + expect(screen.queryByTestId('role-dropdown-3')).not.toBeInTheDocument(); + }); }); describe('Integration with ProjectMembersRoleCell', () => { diff --git a/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectMembersTable.vue b/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectMembersTable.vue index df6e4f5f8da..908cd02a4b3 100644 --- a/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectMembersTable.vue +++ b/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectMembersTable.vue @@ -22,6 +22,7 @@ const props = defineProps<{ currentUserId?: string; projectRoles: AllRolesMap['project']; actions?: Array>; + canEditRole: boolean; }>(); const emit = defineEmits<{ @@ -76,7 +77,8 @@ const roleActions = computed>>(() => })), ); -const canUpdateRole = (member: ProjectMemberData): boolean => member.id !== props.currentUserId; +const canUpdateRole = (member: ProjectMemberData): boolean => + member.id !== props.currentUserId && props.canEditRole; const onRoleChange = ({ role, userId }: { role: Role['slug']; userId: string }) => { emit('update:role', { role, userId }); diff --git a/packages/frontend/editor-ui/src/features/collaboration/projects/views/ProjectSettings.vue b/packages/frontend/editor-ui/src/features/collaboration/projects/views/ProjectSettings.vue index a99940aac97..1f59cdedf58 100644 --- a/packages/frontend/editor-ui/src/features/collaboration/projects/views/ProjectSettings.vue +++ b/packages/frontend/editor-ui/src/features/collaboration/projects/views/ProjectSettings.vue @@ -22,6 +22,8 @@ import { isIconOrEmoji, type IconOrEmoji } from '@n8n/design-system/components/N import type { TableOptions } from '@n8n/design-system/components/N8nDataTableServer'; import type { UserAction } from '@n8n/design-system'; import { isProjectRole } from '@/app/utils/typeGuards'; +import { useUserRoleProvisioningStore } from '@/features/settings/sso/provisioning/composables/userRoleProvisioning.store'; +import { N8nAlert } from '@n8n/design-system'; import { N8nButton, @@ -45,6 +47,7 @@ const i18n = useI18n(); const projectsStore = useProjectsStore(); const rolesStore = useRolesStore(); const cloudPlanStore = useCloudPlanStore(); +const userRoleProvisioningStore = useUserRoleProvisioningStore(); const toast = useToast(); const router = useRouter(); const telemetry = useTelemetry(); @@ -475,9 +478,15 @@ onBeforeMount(async () => { await usersStore.fetchUsers(); }); -onMounted(() => { +const isProjectRoleProvisioningEnabled = computed( + () => userRoleProvisioningStore.provisioningConfig?.scopesProvisionProjectRoles || false, +); + +onMounted(async () => { documentTitle.set(i18n.baseText('projects.settings')); selectProjectNameIfMatchesDefault(); + + await userRoleProvisioningStore.getProvisioningConfig(); }); @@ -567,6 +576,7 @@ onMounted(() => { :placeholder="i18n.baseText('workflows.shareModal.select.placeholder')" data-test-id="project-members-select" @update:model-value="onAddMember" + :disabled="isProjectRoleProvisioningEnabled" > +
+ +
{ :current-user-id="usersStore.currentUser?.id" :project-roles="rolesStore.processedProjectRoles" :actions="projectMembersActions" + :can-edit-role="!isProjectRoleProvisioningEnabled" @update:options="onUpdateMembersTableOptions" @update:role="onUpdateMemberRole" @action="onMembersListAction" diff --git a/packages/frontend/editor-ui/src/features/settings/users/components/SettingsUsersTable.test.ts b/packages/frontend/editor-ui/src/features/settings/users/components/SettingsUsersTable.test.ts index 1a6da710089..0a3baba6e5b 100644 --- a/packages/frontend/editor-ui/src/features/settings/users/components/SettingsUsersTable.test.ts +++ b/packages/frontend/editor-ui/src/features/settings/users/components/SettingsUsersTable.test.ts @@ -134,6 +134,7 @@ describe('SettingsUsersTable', () => { data: mockUsersList, actions: mockActions, loading: false, + canEditRole: true, }, }); hasPermission.mockReturnValue(true); // Default to having permission @@ -176,6 +177,16 @@ describe('SettingsUsersTable', () => { }); }); + it('should not render role update component when canEditRole is false', () => { + renderComponent({ + props: { + canEditRole: false, + }, + }); + + expect(screen.queryByTestId('user-role')).not.toBeInTheDocument(); + }); + it('should emit "update:role" when a new role is selected', () => { const { emitted } = renderComponent(); emitters.settingsUsersRoleCell.emit('update:role', { role: 'global:admin', userId: '2' }); diff --git a/packages/frontend/editor-ui/src/features/settings/users/components/SettingsUsersTable.vue b/packages/frontend/editor-ui/src/features/settings/users/components/SettingsUsersTable.vue index bea126e4173..dad27a92a94 100644 --- a/packages/frontend/editor-ui/src/features/settings/users/components/SettingsUsersTable.vue +++ b/packages/frontend/editor-ui/src/features/settings/users/components/SettingsUsersTable.vue @@ -26,6 +26,7 @@ const props = defineProps<{ data: UsersList; actions: Array>; loading?: boolean; + canEditRole: boolean; }>(); const emit = defineEmits<{ @@ -122,7 +123,11 @@ const roleActions = computed>>(() => [ ]); const canUpdateRole = computed((): boolean => { - return hasPermission(['rbac'], { rbac: { scope: ['user:update', 'user:changeRole'] } }); + if (!hasPermission(['rbac'], { rbac: { scope: ['user:update', 'user:changeRole'] } })) + return false; + if (!props.canEditRole) return false; + + return true; }); const filterActions = (user: UsersList['items'][number]) => { diff --git a/packages/frontend/editor-ui/src/features/settings/users/views/SettingsUsersView.vue b/packages/frontend/editor-ui/src/features/settings/users/views/SettingsUsersView.vue index 864ed088fe2..f3ade50745a 100644 --- a/packages/frontend/editor-ui/src/features/settings/users/views/SettingsUsersView.vue +++ b/packages/frontend/editor-ui/src/features/settings/users/views/SettingsUsersView.vue @@ -27,8 +27,9 @@ import { useDocumentTitle } from '@/app/composables/useDocumentTitle'; import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper'; import SettingsUsersTable from '../components/SettingsUsersTable.vue'; import { I18nT } from 'vue-i18n'; - +import { useUserRoleProvisioningStore } from '@/features/settings/sso/provisioning/composables/userRoleProvisioning.store'; import { ElSwitch } from 'element-plus'; +import N8nAlert from '@n8n/design-system/components/N8nAlert/Alert.vue'; import { N8nActionBox, N8nBadge, @@ -50,6 +51,7 @@ const usersStore = useUsersStore(); const ssoStore = useSSOStore(); const documentTitle = useDocumentTitle(); const pageRedirectionHelper = usePageRedirectionHelper(); +const userRoleProvisioningStore = useUserRoleProvisioningStore(); const tooltipKey = 'settings.personal.mfa.enforce.unlicensed_tooltip'; @@ -70,12 +72,18 @@ const isEnforceMFAEnabled = computed( () => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.EnforceMFA], ); +const isInstanceRoleProvisioningEnabled = computed( + () => userRoleProvisioningStore.provisioningConfig?.scopesProvisionInstanceRole || false, +); + onMounted(async () => { documentTitle.set(i18n.baseText('settings.users')); if (!showUMSetupWarning.value) { await updateUsersTableData(usersTableState.value); } + + await userRoleProvisioningStore.getProvisioningConfig(); }); const usersListActions = computed((): Array> => { @@ -448,6 +456,12 @@ async function onUpdateMfaEnforced(value: string | number | boolean) {
+
+ +