feat: Block UI updates for instance / project roles if provisioning enabed (#22095)

This commit is contained in:
Stephen Wright 2025-11-21 10:58:32 +00:00 committed by GitHub
parent 24a4de8cf9
commit 72cdca23d6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 76 additions and 5 deletions

View File

@ -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",

View File

@ -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', () => {

View File

@ -22,6 +22,7 @@ const props = defineProps<{
currentUserId?: string;
projectRoles: AllRolesMap['project'];
actions?: Array<UserAction<ProjectMemberData>>;
canEditRole: boolean;
}>();
const emit = defineEmits<{
@ -76,7 +77,8 @@ const roleActions = computed<Array<ActionDropdownItem<string>>>(() =>
})),
);
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 });

View File

@ -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();
});
</script>
@ -567,6 +576,7 @@ onMounted(() => {
:placeholder="i18n.baseText('workflows.shareModal.select.placeholder')"
data-test-id="project-members-select"
@update:model-value="onAddMember"
:disabled="isProjectRoleProvisioningEnabled"
>
<template #prefix>
<N8nIcon icon="search" />
@ -586,6 +596,14 @@ onMounted(() => {
</template>
</N8nInput>
</div>
<div v-if="isProjectRoleProvisioningEnabled" class="mb-m">
<N8nAlert
type="info"
:title="
i18n.baseText('settings.provisioningProjectRolesHandledBySsoProvider.description')
"
/>
</div>
<div v-if="relationUsers.length > 0" :class="$style.membersTableContainer">
<ProjectMembersTable
v-model:table-options="membersTableState"
@ -594,6 +612,7 @@ onMounted(() => {
:current-user-id="usersStore.currentUser?.id"
:project-roles="rolesStore.processedProjectRoles"
:actions="projectMembersActions"
:can-edit-role="!isProjectRoleProvisioningEnabled"
@update:options="onUpdateMembersTableOptions"
@update:role="onUpdateMemberRole"
@action="onMembersListAction"

View File

@ -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' });

View File

@ -26,6 +26,7 @@ const props = defineProps<{
data: UsersList;
actions: Array<UserAction<IUser>>;
loading?: boolean;
canEditRole: boolean;
}>();
const emit = defineEmits<{
@ -122,7 +123,11 @@ const roleActions = computed<Array<ActionDropdownItem<Role>>>(() => [
]);
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]) => {

View File

@ -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<UserAction<IUser>> => {
@ -448,6 +456,12 @@ async function onUpdateMfaEnforced(value: string | number | boolean) {
</EnterpriseEdition>
</div>
</div>
<div v-if="isInstanceRoleProvisioningEnabled" :class="$style.container">
<N8nAlert
type="info"
:title="i18n.baseText('settings.provisioningInstanceRolesHandledBySsoProvider.description')"
/>
</div>
<div v-if="!showUMSetupWarning" :class="$style.buttonContainer">
<N8nInput
:class="$style.search"
@ -467,7 +481,11 @@ async function onUpdateMfaEnforced(value: string | number | boolean) {
</template>
<div>
<N8nButton
:disabled="ssoStore.isSamlLoginEnabled || !usersStore.usersLimitNotReached"
:disabled="
ssoStore.isSamlLoginEnabled ||
!usersStore.usersLimitNotReached ||
isInstanceRoleProvisioningEnabled
"
:label="i18n.baseText('settings.users.invite')"
size="large"
data-test-id="settings-users-invite-button"
@ -485,6 +503,7 @@ async function onUpdateMfaEnforced(value: string | number | boolean) {
<SettingsUsersTable
v-model:table-options="usersTableState"
data-test-id="settings-users-table"
:can-edit-role="!isInstanceRoleProvisioningEnabled"
:data="usersStore.usersList.state"
:loading="usersStore.usersList.isLoading"
:actions="usersListActions"