fix(editor): Enable Save when SSO role provisioning switches back to manual (#39925)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Savelii 2026-10-05 11:23:06 +00:00 committed by GitHub
parent 1cac1e056d
commit 360ec09cf2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 159 additions and 56 deletions

View File

@ -4279,7 +4279,8 @@
"settings.provisioningConfirmDialog.button.downloadProjectRolesCsv": "Existing project role access settings CSV",
"settings.provisioningConfirmDialog.button.downloadInstanceRolesCsv": "Existing instance role settings CSV",
"settings.provisioningConfirmDialog.projectRulesDeletion.warning": "Existing project mapping rules will be permanently deleted.",
"settings.provisioningConfirmDialog.projectRulesDeletion.description": "Saving this change removes all project role mapping rules. Download the project roles CSV above before saving so you can recreate them if needed.",
"settings.provisioningConfirmDialog.projectRulesDeletion.description.enable": "Saving this change removes all project role mapping rules. Download the project roles CSV above before saving so you can recreate them if needed.",
"settings.provisioningConfirmDialog.projectRulesDeletion.description.disable": "Saving this change removes all project role mapping rules. If you switch back to project role provisioning later, you will need to create them again.",
"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.provisioningInstanceRolesHandledByExpressionMapping.description": "Instance roles are managed automatically by expression-based role mapping rules. Manual role changes are disabled.",
"settings.projectRolesManaged.description": "Project roles are managed automatically. Manual role changes are disabled.",

View File

@ -1,19 +1,23 @@
import { createTestingPinia } from '@pinia/testing';
import { screen } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { nextTick } from 'vue';
import { createComponentRenderer } from '@/__tests__/render';
import ConfirmProvisioningDialog from './ConfirmProvisioningDialog.vue';
const downloadInstanceRolesCsv = vi.fn().mockResolvedValue(undefined);
const downloadProjectRolesCsv = vi.fn().mockResolvedValue(undefined);
const csvExport = await vi.hoisted(async () => {
const { ref } = await import('vue');
return {
hasDownloadedInstanceRoleCsv: ref(false),
hasDownloadedProjectRoleCsv: ref(false),
downloadInstanceRolesCsv: vi.fn().mockResolvedValue(undefined),
downloadProjectRolesCsv: vi.fn().mockResolvedValue(undefined),
accessSettingsCsvExportOnModalClose: vi.fn(),
};
});
vi.mock('@/features/settings/sso/provisioning/composables/useAccessSettingsCsvExport', () => ({
useAccessSettingsCsvExport: () => ({
hasDownloadedInstanceRoleCsv: false,
hasDownloadedProjectRoleCsv: false,
downloadInstanceRolesCsv,
downloadProjectRolesCsv,
accessSettingsCsvExportOnModalClose: vi.fn(),
}),
useAccessSettingsCsvExport: () => csvExport,
}));
const renderDialog = createComponentRenderer(ConfirmProvisioningDialog, {
@ -27,47 +31,144 @@ const baseProps = {
authProtocol: 'saml' as const,
};
describe('ConfirmProvisioningDialog — project rules deletion warning', () => {
describe('ConfirmProvisioningDialog', () => {
beforeEach(() => {
csvExport.hasDownloadedInstanceRoleCsv.value = false;
csvExport.hasDownloadedProjectRoleCsv.value = false;
});
afterEach(() => {
// ElDialog teleports content to body — clean up between tests.
document.body.innerHTML = '';
});
it('does not render the deletion warning when willDeleteProjectRules is absent (defaults to false)', async () => {
renderDialog({ props: baseProps });
await screen.findByTestId('provisioning-confirmation-checkbox');
describe('project rules deletion warning', () => {
it('does not render the deletion warning when willDeleteProjectRules is absent (defaults to false)', async () => {
renderDialog({ props: baseProps });
await screen.findByTestId('provisioning-confirmation-checkbox');
expect(screen.queryByTestId('provisioning-project-rules-deletion-warning')).toBeNull();
});
it('does not render the deletion warning when willDeleteProjectRules is explicitly false', async () => {
renderDialog({ props: { ...baseProps, willDeleteProjectRules: false } });
await screen.findByTestId('provisioning-confirmation-checkbox');
expect(screen.queryByTestId('provisioning-project-rules-deletion-warning')).toBeNull();
});
it('renders the deletion warning callout when willDeleteProjectRules is true', async () => {
renderDialog({ props: { ...baseProps, willDeleteProjectRules: true } });
const warning = await screen.findByTestId('provisioning-project-rules-deletion-warning');
expect(warning).toBeInTheDocument();
expect(warning).toHaveTextContent(
'Existing project mapping rules will be permanently deleted.',
);
});
it('renders the deletion warning in the switchToManual flow too (disabling SSO with project rules)', async () => {
renderDialog({
props: {
...baseProps,
transitionType: 'switchToManual' as const,
willDeleteProjectRules: true,
},
expect(screen.queryByTestId('provisioning-project-rules-deletion-warning')).toBeNull();
});
expect(
await screen.findByTestId('provisioning-project-rules-deletion-warning'),
).toBeInTheDocument();
it('does not render the deletion warning when willDeleteProjectRules is explicitly false', async () => {
renderDialog({ props: { ...baseProps, willDeleteProjectRules: false } });
await screen.findByTestId('provisioning-confirmation-checkbox');
expect(screen.queryByTestId('provisioning-project-rules-deletion-warning')).toBeNull();
});
it('renders the deletion warning callout when willDeleteProjectRules is true', async () => {
renderDialog({ props: { ...baseProps, willDeleteProjectRules: true } });
const warning = await screen.findByTestId('provisioning-project-rules-deletion-warning');
expect(warning).toBeInTheDocument();
expect(warning).toHaveTextContent(
'Existing project mapping rules will be permanently deleted.',
);
});
it('points to the CSV download control in the backup flow', async () => {
renderDialog({ props: { ...baseProps, willDeleteProjectRules: true } });
const warning = await screen.findByTestId('provisioning-project-rules-deletion-warning');
expect(warning).toHaveTextContent('Download the project roles CSV above');
expect(screen.getByTestId('provisioning-download-project-roles-csv-button')).toBeVisible();
});
it('renders the deletion warning in the switchToManual flow too (disabling SSO with project rules)', async () => {
renderDialog({
props: {
...baseProps,
transitionType: 'switchToManual' as const,
willDeleteProjectRules: true,
},
});
expect(
await screen.findByTestId('provisioning-project-rules-deletion-warning'),
).toBeInTheDocument();
});
it('does not point to a CSV control in the switchToManual flow, where none is rendered', async () => {
renderDialog({
props: {
...baseProps,
transitionType: 'switchToManual' as const,
willDeleteProjectRules: true,
},
});
const warning = await screen.findByTestId('provisioning-project-rules-deletion-warning');
expect(warning).not.toHaveTextContent('CSV');
expect(warning).toHaveTextContent(
'If you switch back to project role provisioning later, you will need to create them again.',
);
expect(screen.queryByTestId('provisioning-download-project-roles-csv-button')).toBeNull();
});
});
describe('save button gating', () => {
it('switchToManual: enables Save on the confirmation checkbox alone, even when project roles were stored', async () => {
const { emitted } = renderDialog({
props: {
...baseProps,
transitionType: 'switchToManual' as const,
showProjectRolesCsv: true,
willDeleteProjectRules: true,
},
});
const checkbox = await screen.findByTestId('provisioning-confirmation-checkbox');
const saveButton = screen.getByTestId('provisioning-confirm-button');
expect(screen.queryByTestId('provisioning-download-instance-roles-csv-button')).toBeNull();
expect(screen.queryByTestId('provisioning-download-project-roles-csv-button')).toBeNull();
expect(checkbox).toBeEnabled();
expect(saveButton).toBeDisabled();
await userEvent.click(checkbox);
expect(saveButton).toBeEnabled();
await userEvent.click(saveButton);
expect(emitted('confirmProvisioning')).toHaveLength(1);
});
it('backup with project roles: keeps Save disabled until both CSVs are downloaded', async () => {
renderDialog({ props: baseProps });
const checkbox = await screen.findByTestId('provisioning-confirmation-checkbox');
const saveButton = screen.getByTestId('provisioning-confirm-button');
expect(checkbox).toBeDisabled();
expect(saveButton).toBeDisabled();
csvExport.hasDownloadedInstanceRoleCsv.value = true;
await nextTick();
expect(checkbox).toBeDisabled();
expect(saveButton).toBeDisabled();
csvExport.hasDownloadedProjectRoleCsv.value = true;
await nextTick();
expect(checkbox).toBeEnabled();
expect(saveButton).toBeDisabled();
await userEvent.click(checkbox);
expect(saveButton).toBeEnabled();
});
it('backup without project roles: needs the instance CSV only', async () => {
renderDialog({ props: { ...baseProps, showProjectRolesCsv: false } });
const checkbox = await screen.findByTestId('provisioning-confirmation-checkbox');
const saveButton = screen.getByTestId('provisioning-confirm-button');
expect(screen.queryByTestId('provisioning-download-project-roles-csv-button')).toBeNull();
expect(checkbox).toBeDisabled();
csvExport.hasDownloadedInstanceRoleCsv.value = true;
await nextTick();
expect(checkbox).toBeEnabled();
await userEvent.click(checkbox);
expect(saveButton).toBeEnabled();
});
});
});

View File

@ -43,6 +43,14 @@ const messagingKey = computed(() => (isSwitchingToManual.value ? 'disable' : 'en
const shouldShowProjectRolesCsv = computed(() => props.showProjectRolesCsv);
// The manual flow renders no CSV controls, so it must never wait for a download.
const isCsvBackupPending = computed(
() =>
!isSwitchingToManual.value &&
(!hasDownloadedInstanceRoleCsv.value ||
(shouldShowProjectRolesCsv.value && !hasDownloadedProjectRoleCsv.value)),
);
watch(visible, () => {
loading.value = false;
confirmationChecked.value = false;
@ -157,7 +165,9 @@ const onConfirmProvisioningSetting = () => {
}}</N8nText>
<br />
<N8nText color="text-base" size="small">{{
locale.baseText('settings.provisioningConfirmDialog.projectRulesDeletion.description')
locale.baseText(
`settings.provisioningConfirmDialog.projectRulesDeletion.description.${messagingKey}`,
)
}}</N8nText>
</N8nCallout>
</div>
@ -165,11 +175,7 @@ const onConfirmProvisioningSetting = () => {
<N8nCard :class="$style.card">
<N8nCheckbox
v-model="confirmationChecked"
:disabled="
!isSwitchingToManual &&
(!hasDownloadedInstanceRoleCsv ||
(shouldShowProjectRolesCsv && !hasDownloadedProjectRoleCsv))
"
:disabled="isCsvBackupPending"
data-test-id="provisioning-confirmation-checkbox"
>
<template #label>
@ -193,12 +199,7 @@ const onConfirmProvisioningSetting = () => {
<N8nButton
variant="solid"
type="button"
:disabled="
loading ||
!confirmationChecked ||
(!isSwitchingToManual && !hasDownloadedInstanceRoleCsv) ||
(shouldShowProjectRolesCsv && !hasDownloadedProjectRoleCsv)
"
:disabled="loading || !confirmationChecked || isCsvBackupPending"
data-test-id="provisioning-confirm-button"
@click="onConfirmProvisioningSetting"
>{{