diff --git a/tests/ui-testing/ci-matrix/ci_matrix_regression.json b/tests/ui-testing/ci-matrix/ci_matrix_regression.json
index 634671f70d..0393e0f395 100644
--- a/tests/ui-testing/ci-matrix/ci_matrix_regression.json
+++ b/tests/ui-testing/ci-matrix/ci_matrix_regression.json
@@ -128,6 +128,7 @@
"landing-regression.spec.js",
"internal-route-guards.spec.js",
"datetime-paste-invalid-values-14648.spec.js",
+ "iam-users-15546-edit-dialog-cancel.spec.js",
"query-management-sql-tooltip.spec.js"
],
"disabled": []
diff --git a/tests/ui-testing/pages/iamPages/iamFormValidationPage.js b/tests/ui-testing/pages/iamPages/iamFormValidationPage.js
index 15d37774eb..d1567ac062 100644
--- a/tests/ui-testing/pages/iamPages/iamFormValidationPage.js
+++ b/tests/ui-testing/pages/iamPages/iamFormValidationPage.js
@@ -355,6 +355,12 @@ export class IamFormValidationPage {
return this.page.locator(this.editBasicUserFirstBtn).count();
}
+ async clickFirstEditUserButton() {
+ await this.page.locator(this.editBasicUserFirstBtn).first().click();
+ }
+
+ getUrlWithUserActionPattern() { return /[?&]action=(update|add)\b|[?&]email=/; }
+
async navigateToUsersTab() {
await this.navigateToIam();
const tab = this.page.locator(this.iamUsersTab);
diff --git a/tests/ui-testing/playwright-tests/RegressionSet/General/iam-users-15546-edit-dialog-cancel.spec.js b/tests/ui-testing/playwright-tests/RegressionSet/General/iam-users-15546-edit-dialog-cancel.spec.js
new file mode 100644
index 0000000000..0f59bda2b9
--- /dev/null
+++ b/tests/ui-testing/playwright-tests/RegressionSet/General/iam-users-15546-edit-dialog-cancel.spec.js
@@ -0,0 +1,48 @@
+const { test, expect, navigateToBase } = require('../../utils/enhanced-baseFixtures.js');
+const testLogger = require('../../utils/test-logger.js');
+const PageManager = require('../../../pages/page-manager.js');
+
+test.describe("IAM Users edit dialog cancel Regression", () => {
+ let pm;
+
+ test.beforeEach(async ({ page }, testInfo) => {
+ testLogger.testStart(testInfo.title, testInfo.file);
+ await navigateToBase(page);
+ pm = new PageManager(page);
+ await pm.iamFormValidation.navigateToUsersTab();
+ });
+
+ test("cancelled edit dialog stays closed and leaves no action in the URL @bug-15546 @P3 @regression @iamUsers", async ({ page }) => {
+ const iam = pm.iamFormValidation;
+
+ await iam.openUpdateRoleDialogForFirstUser();
+ await expect(page).toHaveURL(/action=update/);
+
+ await iam.getUpdateRoleCancelBtnLocator().click();
+ await expect(iam.getUpdateRoleDialogLocator()).toBeHidden();
+ await expect(page).not.toHaveURL(iam.getUrlWithUserActionPattern());
+
+ // Outlast the dialog's 100ms open timer so a late reopen is caught.
+ await page.waitForTimeout(2000);
+ await expect(iam.getUpdateRoleDialogLocator()).toBeHidden();
+ testLogger.info('Edit dialog stayed closed and URL is clean after Cancel');
+ });
+
+ test("edit dialog cancelled during a slow roles load does not reopen @bug-15546 @P3 @regression @iamUsers", async ({ page }) => {
+ const iam = pm.iamFormValidation;
+ await page.route(/\/api\/[^/]+\/users\/roles(\?.*)?$/, async (route) => {
+ await new Promise((r) => setTimeout(r, 3000));
+ await route.continue();
+ });
+
+ await page.reload();
+ await iam.clickFirstEditUserButton();
+ await expect(iam.getUpdateRoleDialogLocator()).toBeVisible();
+ await iam.getUpdateRoleCancelBtnLocator().click();
+ await expect(iam.getUpdateRoleDialogLocator()).toBeHidden();
+
+ await page.waitForTimeout(4500);
+ await expect(iam.getUpdateRoleDialogLocator()).toBeHidden();
+ await expect(page).not.toHaveURL(iam.getUrlWithUserActionPattern());
+ });
+});
diff --git a/web/src/components/iam/users/User.spec.ts b/web/src/components/iam/users/User.spec.ts
index 1159cdc78f..bc1e9afeb2 100644
--- a/web/src/components/iam/users/User.spec.ts
+++ b/web/src/components/iam/users/User.spec.ts
@@ -1051,6 +1051,62 @@ describe("User Component", () => {
});
});
+ describe("AddUser dialog dismissal (#15546)", () => {
+ it("clears action/email from the URL when the dialog is cancelled", async () => {
+ const replaceSpy = vi.spyOn(router, "replace").mockImplementation(() => Promise.resolve());
+ wrapper.vm.showAddUserDialog = true;
+ await flushPromises();
+
+ wrapper.findComponent(AddUserStub).vm.$emit("update:open", false);
+ await flushPromises();
+
+ expect(wrapper.vm.showAddUserDialog).toBe(false);
+ expect(replaceSpy).toHaveBeenCalledWith({
+ name: "users",
+ query: { org_identifier: store.state.selectedOrganization.identifier },
+ });
+ replaceSpy.mockRestore();
+ });
+
+ it("drops a pending open timer when the form is hidden", async () => {
+ const replaceSpy = vi.spyOn(router, "replace").mockImplementation(() => Promise.resolve());
+ wrapper.vm.addUser({ row: { email: "test@example.com" } }, true);
+ wrapper.vm.hideForm();
+ await new Promise((r) => setTimeout(r, 150));
+
+ expect(wrapper.vm.showAddUserDialog).toBe(false);
+ replaceSpy.mockRestore();
+ });
+
+ it("does not reopen the edit dialog when it was cancelled while the page was still loading", async () => {
+ sharedUsersState.users = [];
+ queryClient.clear();
+ let resolveRoles: (v: any) => void = () => {};
+ mockUsersService.getRoles.mockReturnValue(new Promise((r) => (resolveRoles = r)) as any);
+ await router.push({
+ name: "users",
+ query: {
+ action: "update",
+ email: "test@example.com",
+ org_identifier: store.state.selectedOrganization.identifier,
+ },
+ });
+ const late = mountUser();
+ await flushPromises();
+
+ late.findComponent(AddUserStub).vm.$emit("update:open", false);
+ await flushPromises();
+ expect(router.currentRoute.value.query.action).toBeUndefined();
+
+ resolveRoles({ data: [{ label: "admin", value: "admin" }] });
+ await flushPromises();
+ await new Promise((r) => setTimeout(r, 150));
+
+ expect((late.vm as any).showAddUserDialog).toBe(false);
+ late.unmount();
+ });
+ });
+
describe("hideForm", () => {
it("should hide add user dialog and navigate", () => {
const replaceSpy = vi.spyOn(router, "replace").mockImplementation(() => Promise.resolve());
diff --git a/web/src/components/iam/users/User.vue b/web/src/components/iam/users/User.vue
index e1fd207579..d5487ff897 100644
--- a/web/src/components/iam/users/User.vue
+++ b/web/src/components/iam/users/User.vue
@@ -283,7 +283,8 @@ along with this program. If not, see .
/>
| undefined;
+
const addUser = (props: any, is_updated: boolean) => {
isUpdated.value = is_updated;
selectedUser.value.organization = store.state.selectedOrganization.identifier;
@@ -1133,7 +1136,8 @@ export default defineComponent({
} else {
selectedUser.value = {};
}
- setTimeout(() => {
+ clearTimeout(openAddUserTimer);
+ openAddUserTimer = setTimeout(() => {
showAddUserDialog.value = true;
}, 100);
};
@@ -1227,6 +1231,7 @@ export default defineComponent({
};
const hideForm = () => {
+ clearTimeout(openAddUserTimer);
showAddUserDialog.value = false;
router.replace({
name: "users",
@@ -1236,6 +1241,11 @@ export default defineComponent({
});
};
+ const onAddUserOpenChange = (open: boolean) => {
+ if (open) showAddUserDialog.value = true;
+ else hideForm();
+ };
+
const addMember = async (res: any, data: any, operationType: string) => {
showAddUserDialog.value = false;
if (res.code == 200) {
@@ -1576,6 +1586,7 @@ export default defineComponent({
addRoutePush,
addMember,
hideForm,
+ onAddUserOpenChange,
isUpdated,
showAddUserDialog,
selectedUser,