This is an automated email from the ASF dual-hosted git repository.

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new 931b5a036 fix(studio): handle rejected, cancelled and unauthorized 
writes (#5839)
931b5a036 is described below

commit 931b5a036263b31c6d19700fa1f85a4b76381fc7
Author: btlqql <[email protected]>
AuthorDate: Mon Oct 12 16:12:16 2026 +0800

    fix(studio): handle rejected, cancelled and unauthorized writes (#5839)
    
    Three fixes from the same author on the studio user-management and 
general-settings write
    surfaces — a rejected validation, a cancelled dialog and a non-admin caller 
— folded into a
    single commit.
    
    - #5839 `web/src/pages/studio/UserManagement.tsx:249` and `:298` await
      `createForm.validateFields()` / `passwordForm.validateFields()` outside 
the `try`, while both
      modals invoke the handlers fire-and-forget (`:754 onOk={() => void 
createUser()}`,
      `:788 onOk={() => void updatePassword()}`), so clicking OK with a 
required field empty escapes
      as an unhandled promise rejection that antd has already reported 
visually; moves both awaits
      inside the `try`, narrows the catch and returns silently when the 
rejection carries
      `errorFields`, keeping `message.error(t('userMgmt.createFailed'))` /
      `t('userMgmt.changePasswordFailed')` for every other failure — the guard 
trunk already applies
      in `instance/index.tsx:370`, `ops/alerts.tsx:830`, 
`settings/CloudCredentialTab.tsx:221` and
      three more call sites.
    - #5840 `web/src/pages/studio/UserManagement.tsx:755` makes the create-user 
modal's `onCancel`
      do nothing but close it, the `Form` at `:757` keeps antd's default 
`preserve`, and
      `openCreateUserModal` (`:339`) does not reset either, so reopening after 
a cancel refills the
      username and the plaintext initial password — only the success path 
resets (`:254`), while the
      sibling password modal has reset on cancel all along (`:789-792`); resets 
`createForm` in
      `onCancel`, adds `destroyOnHidden` to the modal and `preserve={false}` to 
the form, matching
      `settings/CloudCredentialTab.tsx:414` and 
`settings/DataSourceTab.tsx:479`.
    - #5849 `web/src/pages/settings/GeneralSettingsTab.tsx` never reads 
`authStore` and the settings
      route has no admin guard, so a signed-in reader can flip theme 
(`:247-251`) or compact mode
      (`:264-267`) and fire `saveGeneralSettings` (`:127`), which
      `AuthInterceptor.requiresAdmin` 
(`server/src/main/java/org/apache/rocketmq/studio/auth/AuthInterceptor.java:138-149`)
      always answers with 403 — the catch at `:130-131` then raises 
`settings.saveFailed` for a
      preference that already took effect locally, and the four other write 
entries (`:152`, `:171`,
      `:185`, `:204`) plus six buttons are just as clickable and just as 
doomed; derives
      `canWriteSettings = !authUserId || authAdmin === true` from `authStore`, 
gates all five write
      paths and disables the six buttons, the same derivation trunk uses in 
`studio/Ops.tsx:72`,
      `ops/systemAlerts.tsx:139` and `ai/index.tsx:79` — the server-side 
boundary is untouched, this
      only stops the UI offering writes it knows will be refused.
    
    Five new cases in three new test files (2 for #5839, 1 for #5840, 2 for 
#5849), all
    mutation-sensitive: dropping the `errorFields` branch makes 
`expect(unhandled.reasons).toEqual([])`
    fail for both modals, dropping the reset / `preserve` / `destroyOnHidden` 
trio leaves `alice` and
    `super-secret-1` in the reopened form, and dropping the `persistPreference` 
gate makes a reader's
    theme click reach `saveGeneralSettings` and raise the failure toast while 
dropping the button
    `disabled` fails `toBeDisabled()`. The expected toast text is matched 
verbatim against
    `web/src/i18n/translations.ts:1238-1241`, so none of these assertions can 
pass vacuously, and the
    reader cases run against a file-level `authStore` mock that the existing
    `GeneralSettingsTab.test.tsx` cannot provide.
    
    Folded: #5840, #5849 were closed as folded into this commit (same author).
---
 web/src/pages/settings/GeneralSettingsTab.tsx      |  40 ++++-
 .../__tests__/GeneralSettingsTabReader.test.tsx    | 128 ++++++++++++++
 web/src/pages/studio/UserManagement.tsx            |  22 ++-
 .../UserManagementCreateModalResidue.test.tsx      | 138 +++++++++++++++
 .../UserManagementModalValidation.test.tsx         | 189 +++++++++++++++++++++
 5 files changed, 504 insertions(+), 13 deletions(-)

diff --git a/web/src/pages/settings/GeneralSettingsTab.tsx 
b/web/src/pages/settings/GeneralSettingsTab.tsx
index dc9f16113..7680bcd4e 100644
--- a/web/src/pages/settings/GeneralSettingsTab.tsx
+++ b/web/src/pages/settings/GeneralSettingsTab.tsx
@@ -35,6 +35,7 @@ import type { GeneralSettings, GeneralSettingsUpdate } from 
'../../api/settings'
 import { useTheme } from '../../theme/useTheme';
 import type { ThemeMode } from '../../theme/themePreference';
 import { useLang } from '../../i18n/LangContext';
+import useAuthStore from '../../stores/authStore';
 
 const { Text } = Typography;
 
@@ -70,6 +71,12 @@ export const GeneralSettingsTab = () => {
   const [testingChannel, setTestingChannel] = useState<string>();
   const securityInFlightRef = useRef(false);
   const notifyInFlightRef = useRef(false);
+  const authUserId = useAuthStore((state) => state.userId);
+  const authAdmin = useAuthStore((state) => state.admin);
+  // Mirrors the server policy: POST /api/settings/general/save is admin-only, 
and the
+  // server also hands redacted webhook values to readers. Without a signed-in 
studio
+  // user (auth disabled) the caller counts as system, so writes stay allowed.
+  const canWriteSettings = !authUserId || authAdmin === true;
   const translationRef = useRef(t);
   const [securityForm] = Form.useForm();
   const [notifyForm] = Form.useForm();
@@ -121,6 +128,9 @@ export const GeneralSettingsTab = () => {
     if (!settings) return;
     const next = { ...settings, ...patch };
     setSettings(next);
+    // Readers cannot write the shared record; their preference already applied
+    // locally via useTheme, so skip the round-trip that could only end in 403.
+    if (!canWriteSettings) return;
     setSavingPreference(true);
     try {
       const base = await loadFreshSettings();
@@ -150,7 +160,7 @@ export const GeneralSettingsTab = () => {
   };
 
   const clearDingtalkSigningSecret = async () => {
-    if (!settings) return;
+    if (!settings || !canWriteSettings) return;
     setSavingNotification(true);
     try {
       if (
@@ -169,7 +179,7 @@ export const GeneralSettingsTab = () => {
   };
 
   const handleSecurityFinish = async (values: { sessionTimeout: number }) => {
-    if (securityInFlightRef.current) return;
+    if (!canWriteSettings || securityInFlightRef.current) return;
     securityInFlightRef.current = true;
     setSavingSecurity(true);
     try {
@@ -188,7 +198,7 @@ export const GeneralSettingsTab = () => {
     emailRecipients?: string;
     smsWebhook?: string;
   }) => {
-    if (notifyInFlightRef.current) return;
+    if (!canWriteSettings || notifyInFlightRef.current) return;
     notifyInFlightRef.current = true;
     setSavingNotification(true);
     try {
@@ -202,6 +212,7 @@ export const GeneralSettingsTab = () => {
   };
 
   const sendTest = async (channel: 'dingtalk' | 'email' | 'sms') => {
+    if (!canWriteSettings) return;
     setTestingChannel(channel);
     try {
       const values = await notifyForm.validateFields();
@@ -303,7 +314,12 @@ export const GeneralSettingsTab = () => {
           </Form.Item>
 
           <Form.Item style={{ marginBottom: 16 }}>
-            <Button type="primary" htmlType="submit" loading={savingSecurity} 
disabled={loading}>
+            <Button
+              type="primary"
+              htmlType="submit"
+              loading={savingSecurity}
+              disabled={loading || !canWriteSettings}
+            >
               {t('settings.saveSettings')}
             </Button>
           </Form.Item>
@@ -344,6 +360,7 @@ export const GeneralSettingsTab = () => {
                 danger
                 onClick={() => void clearDingtalkSigningSecret()}
                 loading={savingNotification}
+                disabled={!canWriteSettings}
               >
                 {t('settings.clearDingtalkSigningSecret')}
               </Button>
@@ -372,13 +389,22 @@ export const GeneralSettingsTab = () => {
                 <Button
                   onClick={() => void sendTest('dingtalk')}
                   loading={testingChannel === 'dingtalk'}
+                  disabled={!canWriteSettings}
                 >
                   {t('settings.testDingtalk')}
                 </Button>
-                <Button onClick={() => void sendTest('email')} 
loading={testingChannel === 'email'}>
+                <Button
+                  onClick={() => void sendTest('email')}
+                  loading={testingChannel === 'email'}
+                  disabled={!canWriteSettings}
+                >
                   {t('settings.testEmail')}
                 </Button>
-                <Button onClick={() => void sendTest('sms')} 
loading={testingChannel === 'sms'}>
+                <Button
+                  onClick={() => void sendTest('sms')}
+                  loading={testingChannel === 'sms'}
+                  disabled={!canWriteSettings}
+                >
                   {t('settings.testSmsWebhook')}
                 </Button>
               </Space>
@@ -386,7 +412,7 @@ export const GeneralSettingsTab = () => {
                 type="primary"
                 htmlType="submit"
                 loading={savingNotification}
-                disabled={loading}
+                disabled={loading || !canWriteSettings}
               >
                 {t('settings.saveSettings')}
               </Button>
diff --git a/web/src/pages/settings/__tests__/GeneralSettingsTabReader.test.tsx 
b/web/src/pages/settings/__tests__/GeneralSettingsTabReader.test.tsx
new file mode 100644
index 000000000..646c3e42d
--- /dev/null
+++ b/web/src/pages/settings/__tests__/GeneralSettingsTabReader.test.tsx
@@ -0,0 +1,128 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen } from '@testing-library/react';
+import { App } from 'antd';
+import { getGeneralSettings, saveGeneralSettings } from 
'../../../api/settings';
+import { ThemeProvider } from '../../../theme/ThemeProvider';
+import { LangProvider } from '../../../i18n/LangContext';
+import { LANGUAGE_STORAGE_KEY } from '../../../i18n/languagePreference';
+import { GeneralSettingsTab } from '../GeneralSettingsTab';
+
+type MockAuthState = { admin: boolean | null; userId: number | null; logout: 
() => void };
+vi.mock('../../../stores/authStore', () => ({
+  default: (selector: (state: MockAuthState) => unknown) =>
+    selector({ admin: false, userId: 42, logout: vi.fn() }),
+}));
+
+vi.mock('../../../api/settings', () => ({
+  createDataSource: vi.fn(),
+  deleteDataSource: vi.fn(),
+  getGeneralSettings: vi.fn(),
+  listDataSources: vi.fn(),
+  saveGeneralSettings: vi.fn(),
+  testDataSource: vi.fn(),
+  updateDataSource: vi.fn(),
+}));
+
+beforeAll(() => {
+  Object.defineProperty(window, 'matchMedia', {
+    writable: true,
+    value: vi.fn().mockImplementation((query: string) => ({
+      matches: false,
+      media: query,
+      onchange: null,
+      addListener: vi.fn(),
+      removeListener: vi.fn(),
+      addEventListener: vi.fn(),
+      removeEventListener: vi.fn(),
+      dispatchEvent: vi.fn(),
+    })),
+  });
+});
+
+const renderTab = () =>
+  render(
+    <App>
+      <LangProvider>
+        <ThemeProvider>
+          <GeneralSettingsTab />
+        </ThemeProvider>
+      </LangProvider>
+    </App>,
+  );
+
+describe('GeneralSettingsTab reader access', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    localStorage.clear();
+    localStorage.setItem(LANGUAGE_STORAGE_KEY, 'en');
+    // The server hands redacted webhook values to readers and rejects their
+    // writes with 403 (POST /api/settings/general/save is admin-only).
+    vi.mocked(getGeneralSettings).mockResolvedValue({
+      theme: 'system',
+      compact: false,
+      desktopNotify: false,
+      notifySound: false,
+      sessionTimeout: 30,
+      requireLogin: true,
+      llmProvider: 'tongyi',
+      apiKeyConfigured: false,
+      model: 'qwen',
+      baseUrl: 'https://example.com/v1',
+      dingtalkWebhook: '******',
+      dingtalkSigningSecretConfigured: true,
+      emailRecipients: '[email protected]',
+      smsWebhook: '******',
+      smsWebhookConfigured: true,
+    });
+    vi.mocked(saveGeneralSettings).mockRejectedValue({
+      response: { status: 403, data: { message: 'Forbidden' } },
+    });
+  });
+
+  it('applies a reader theme switch locally without a doomed settings write', 
async () => {
+    renderTab();
+
+    fireEvent.click(await screen.findByText('Dark'));
+    // Give the doomed save round-trip (fresh GET, then POST) time to fire.
+    await new Promise((resolve) => {
+      setTimeout(resolve, 200);
+    });
+
+    expect(saveGeneralSettings).not.toHaveBeenCalled();
+    expect(
+      screen.queryByText('Failed to save settings. Please try again later.'),
+    ).not.toBeInTheDocument();
+  });
+
+  it('disables the admin-only save and test buttons for readers', async () => {
+    renderTab();
+
+    await screen.findByText('Test DingTalk');
+
+    const saveButtons = screen.getAllByRole('button', { name: 'Save settings' 
});
+    expect(saveButtons).toHaveLength(2);
+    for (const button of saveButtons) {
+      expect(button).toBeDisabled();
+    }
+    expect(screen.getByRole('button', { name: 'Test DingTalk' 
})).toBeDisabled();
+    expect(screen.getByRole('button', { name: 'Test email' })).toBeDisabled();
+    expect(screen.getByRole('button', { name: 'Test SMS/Webhook' 
})).toBeDisabled();
+  });
+});
diff --git a/web/src/pages/studio/UserManagement.tsx 
b/web/src/pages/studio/UserManagement.tsx
index 1dae92cac..cd11dc837 100644
--- a/web/src/pages/studio/UserManagement.tsx
+++ b/web/src/pages/studio/UserManagement.tsx
@@ -251,15 +251,19 @@ const UserManagementPage = () => {
   };
 
   const createUser = async () => {
-    const values = await createForm.validateFields();
     try {
+      const values = await createForm.validateFields();
       await createStudioUser(values);
       message.success(t('userMgmt.userCreated'));
       setCreateOpen(false);
       createForm.resetFields();
       if (page === 1) await loadUsers();
       else setPage(1);
-    } catch {
+    } catch (error) {
+      // Validation failures are surfaced by antd as field errors; only report
+      // request failures here. Either way the rejection must not escape this
+      // handler, since the modal wiring calls it fire-and-forget.
+      if (error && typeof error === 'object' && 'errorFields' in error) return;
       message.error(t('userMgmt.createFailed'));
     }
   };
@@ -300,8 +304,8 @@ const UserManagementPage = () => {
 
   const updatePassword = async () => {
     if (!passwordTarget) return;
-    const values = await passwordForm.validateFields();
     try {
+      const values = await passwordForm.validateFields();
       if (passwordTarget.id === userId) {
         await changePassword(values.currentPassword ?? '', values.newPassword);
         clearAuth();
@@ -313,7 +317,9 @@ const UserManagementPage = () => {
       }
       setPasswordTarget(null);
       passwordForm.resetFields();
-    } catch {
+    } catch (error) {
+      // See createUser: validation rejections must stay handled here.
+      if (error && typeof error === 'object' && 'errorFields' in error) return;
       message.error(t('userMgmt.changePasswordFailed'));
     }
   };
@@ -759,9 +765,13 @@ const UserManagementPage = () => {
         title={t('userMgmt.createTitle')}
         open={createOpen}
         onOk={() => void createUser()}
-        onCancel={() => setCreateOpen(false)}
+        onCancel={() => {
+          setCreateOpen(false);
+          createForm.resetFields();
+        }}
+        destroyOnHidden
       >
-        <Form form={createForm} layout="vertical" initialValues={{ admin: 
false }}>
+        <Form form={createForm} layout="vertical" initialValues={{ admin: 
false }} preserve={false}>
           <Form.Item
             name="username"
             label={t('userMgmt.username')}
diff --git 
a/web/src/pages/studio/__tests__/UserManagementCreateModalResidue.test.tsx 
b/web/src/pages/studio/__tests__/UserManagementCreateModalResidue.test.tsx
new file mode 100644
index 000000000..b65a1996d
--- /dev/null
+++ b/web/src/pages/studio/__tests__/UserManagementCreateModalResidue.test.tsx
@@ -0,0 +1,138 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { App } from 'antd';
+import { MemoryRouter } from 'react-router-dom';
+import {
+  getStudioUserSessionOverview,
+  listStudioUsers,
+  type StudioUser,
+} from '../../../api/studioUsers';
+import { LangProvider } from '../../../i18n/LangContext';
+import { LANGUAGE_STORAGE_KEY } from '../../../i18n/languagePreference';
+import UserManagementPage from '../UserManagement';
+
+type MockAuthState = { admin: boolean; userId: number; logout: () => void };
+vi.mock('../../../api/studioUsers', () => ({
+  createStudioUser: vi.fn(),
+  getStudioUserSessionOverview: vi.fn(),
+  listAllStudioUsers: vi.fn(),
+  listStudioUserSessions: vi.fn(),
+  listStudioUsers: vi.fn(),
+  resetStudioUserPassword: vi.fn(),
+  revokeStudioUserSessions: vi.fn(),
+  setStudioUserEnabled: vi.fn(),
+}));
+
+vi.mock('../../../stores/authStore', () => ({
+  default: (selector: (state: MockAuthState) => unknown) =>
+    selector({ admin: true, userId: 1, logout: vi.fn() }),
+}));
+
+vi.mock('../../../utils/download', async () => {
+  const downloadModule =
+    await vi.importActual<typeof 
import('../../../utils/download')>('../../../utils/download');
+  return {
+    ...downloadModule,
+    downloadCsv: vi.fn(),
+  };
+});
+
+const studioUserPage = {
+  items: [
+    {
+      id: 7,
+      username: 'operator',
+      admin: false,
+      enabled: true,
+      activeSessionCount: 2,
+      lastSessionSeenAt: '2026-08-22T09:30:00',
+      nearestSessionExpiresAt: '2026-08-22T10:00:00',
+      passwordChangedAt: '2026-08-22T08:00:00',
+      gmtCreate: '2026-08-22T08:00:00',
+      gmtModified: '2026-08-22T08:00:00',
+    } satisfies StudioUser,
+  ],
+  total: 1,
+  page: 1,
+  size: 20,
+};
+
+beforeAll(() => {
+  Object.defineProperty(window, 'matchMedia', {
+    writable: true,
+    value: vi.fn().mockImplementation((query: string) => ({
+      matches: false,
+      media: query,
+      onchange: null,
+      addListener: vi.fn(),
+      removeListener: vi.fn(),
+      addEventListener: vi.fn(),
+      removeEventListener: vi.fn(),
+      dispatchEvent: vi.fn(),
+    })),
+  });
+});
+
+const renderPage = () =>
+  render(
+    <MemoryRouter>
+      <App>
+        <LangProvider>
+          <UserManagementPage />
+        </LangProvider>
+      </App>
+    </MemoryRouter>,
+  );
+
+describe('UserManagement create user modal residue', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    localStorage.setItem(LANGUAGE_STORAGE_KEY, 'en');
+    vi.mocked(listStudioUsers).mockResolvedValue(studioUserPage);
+    vi.mocked(getStudioUserSessionOverview).mockResolvedValue({
+      activeSessionCount: 2,
+      activeUserCount: 1,
+      expiringSoonSessionCount: 0,
+      expiringSoonWindowMinutes: 30,
+      staleSessionCount: 0,
+      staleSessionThresholdMinutes: 120,
+    });
+  });
+
+  it('clears the typed username and password after cancelling the create 
dialog', async () => {
+    try {
+      const user = userEvent.setup();
+      renderPage();
+
+      await user.click(await screen.findByRole('button', { name: 'Create User' 
}));
+      await user.type(screen.getByLabelText('Username'), 'alice');
+      await user.type(screen.getByLabelText('Initial Password'), 
'super-secret-1');
+      await user.click(screen.getByRole('button', { name: 'Cancel' }));
+
+      await user.click(screen.getByRole('button', { name: 'Create User' }));
+
+      expect(screen.getByLabelText('Username')).toHaveValue('');
+      expect(screen.getByLabelText('Initial Password')).toHaveValue('');
+    } finally {
+      localStorage.removeItem(LANGUAGE_STORAGE_KEY);
+    }
+  });
+});
diff --git 
a/web/src/pages/studio/__tests__/UserManagementModalValidation.test.tsx 
b/web/src/pages/studio/__tests__/UserManagementModalValidation.test.tsx
new file mode 100644
index 000000000..f1efc59eb
--- /dev/null
+++ b/web/src/pages/studio/__tests__/UserManagementModalValidation.test.tsx
@@ -0,0 +1,189 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { App } from 'antd';
+import { MemoryRouter } from 'react-router-dom';
+import {
+  createStudioUser,
+  getStudioUserSessionOverview,
+  listStudioUsers,
+  resetStudioUserPassword,
+  type StudioUser,
+} from '../../../api/studioUsers';
+import { LangProvider } from '../../../i18n/LangContext';
+import { LANGUAGE_STORAGE_KEY } from '../../../i18n/languagePreference';
+import UserManagementPage from '../UserManagement';
+
+type MockAuthState = { admin: boolean; userId: number; logout: () => void };
+vi.mock('../../../api/studioUsers', () => ({
+  createStudioUser: vi.fn(),
+  getStudioUserSessionOverview: vi.fn(),
+  listAllStudioUsers: vi.fn(),
+  listStudioUserSessions: vi.fn(),
+  listStudioUsers: vi.fn(),
+  resetStudioUserPassword: vi.fn(),
+  revokeStudioUserSessions: vi.fn(),
+  setStudioUserEnabled: vi.fn(),
+}));
+
+vi.mock('../../../stores/authStore', () => ({
+  default: (selector: (state: MockAuthState) => unknown) =>
+    selector({ admin: true, userId: 1, logout: vi.fn() }),
+}));
+
+vi.mock('../../../utils/download', async () => {
+  const downloadModule =
+    await vi.importActual<typeof 
import('../../../utils/download')>('../../../utils/download');
+  return {
+    ...downloadModule,
+    downloadCsv: vi.fn(),
+  };
+});
+
+const studioUserPage = {
+  items: [
+    {
+      id: 7,
+      username: 'operator',
+      admin: false,
+      enabled: true,
+      activeSessionCount: 2,
+      lastSessionSeenAt: '2026-08-22T09:30:00',
+      nearestSessionExpiresAt: '2026-08-22T10:00:00',
+      passwordChangedAt: '2026-08-22T08:00:00',
+      gmtCreate: '2026-08-22T08:00:00',
+      gmtModified: '2026-08-22T08:00:00',
+    } satisfies StudioUser,
+  ],
+  total: 1,
+  page: 1,
+  size: 20,
+};
+
+beforeAll(() => {
+  Object.defineProperty(window, 'matchMedia', {
+    writable: true,
+    value: vi.fn().mockImplementation((query: string) => ({
+      matches: false,
+      media: query,
+      onchange: null,
+      addListener: vi.fn(),
+      removeListener: vi.fn(),
+      addEventListener: vi.fn(),
+      removeEventListener: vi.fn(),
+      dispatchEvent: vi.fn(),
+    })),
+  });
+});
+
+const renderPage = () =>
+  render(
+    <MemoryRouter>
+      <App>
+        <LangProvider>
+          <UserManagementPage />
+        </LangProvider>
+      </App>
+    </MemoryRouter>,
+  );
+
+const collectUnhandledRejections = () => {
+  const reasons: unknown[] = [];
+  const handler = (reason: unknown) => {
+    reasons.push(reason);
+  };
+  const nodeProcess = (globalThis as typeof globalThis & {
+    process: {
+      on: (event: 'unhandledRejection', listener: (reason: unknown) => void) 
=> void;
+      off: (event: 'unhandledRejection', listener: (reason: unknown) => void) 
=> void;
+    };
+  }).process;
+  nodeProcess.on('unhandledRejection', handler);
+  return {
+    reasons,
+    restore: () => {
+      nodeProcess.off('unhandledRejection', handler);
+    },
+  };
+};
+
+const flushMicrotasks = async () => {
+  await new Promise((resolve) => {
+    setTimeout(resolve, 50);
+  });
+};
+
+describe('UserManagement modal validation failures', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    localStorage.setItem(LANGUAGE_STORAGE_KEY, 'en');
+    vi.mocked(listStudioUsers).mockResolvedValue(studioUserPage);
+    vi.mocked(getStudioUserSessionOverview).mockResolvedValue({
+      activeSessionCount: 2,
+      activeUserCount: 1,
+      expiringSoonSessionCount: 0,
+      expiringSoonWindowMinutes: 30,
+      staleSessionCount: 0,
+      staleSessionThresholdMinutes: 120,
+    });
+  });
+
+  it('does not leak an unhandled rejection when user creation validation 
fails', async () => {
+    const unhandled = collectUnhandledRejections();
+    try {
+      const user = userEvent.setup();
+      renderPage();
+      await user.click(await screen.findByRole('button', { name: 'Create User' 
}));
+      await user.click(await screen.findByRole('button', { name: 'OK' }));
+
+      await waitFor(() => {
+        
expect(document.querySelectorAll('.ant-form-item-explain-error').length).toBeGreaterThan(0);
+      });
+      expect(createStudioUser).not.toHaveBeenCalled();
+
+      await flushMicrotasks();
+      expect(unhandled.reasons).toEqual([]);
+    } finally {
+      unhandled.restore();
+      localStorage.removeItem(LANGUAGE_STORAGE_KEY);
+    }
+  });
+
+  it('does not leak an unhandled rejection when password reset validation 
fails', async () => {
+    const unhandled = collectUnhandledRejections();
+    try {
+      const user = userEvent.setup();
+      renderPage();
+      await user.click(await screen.findByRole('button', { name: 'Password' 
}));
+      await user.click(await screen.findByRole('button', { name: 'OK' }));
+
+      await waitFor(() => {
+        
expect(document.querySelectorAll('.ant-form-item-explain-error').length).toBeGreaterThan(0);
+      });
+      expect(resetStudioUserPassword).not.toHaveBeenCalled();
+
+      await flushMicrotasks();
+      expect(unhandled.reasons).toEqual([]);
+    } finally {
+      unhandled.restore();
+      localStorage.removeItem(LANGUAGE_STORAGE_KEY);
+    }
+  });
+});

Reply via email to