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 a95e98ab8 fix(users): preserve session drawer ownership after
revocation (#6183)
a95e98ab8 is described below
commit a95e98ab819dd0e8bc17ed709e9352248a09d658
Author: zmuxuny <[email protected]>
AuthorDate: Mon Oct 12 00:17:18 2026 -0700
fix(users): preserve session drawer ownership after revocation (#6183)
`revokeSessions` captured `sessionDrawerUser` at call time and its
continuation compared that stale value at `UserManagement.tsx:331`. Closing the
drawer while a revocation was in flight and opening another user's drawer left
the comparison true against the old id, so `loadSessionDetails(record)` ran for
the revoked user and wrote its rows at `:217` — the drawer showed one user's
title over another user's sessions. The existing `sessionDetailsRequestSeqRef`
cannot catch this: the sequ [...]
Two new cases in `UserManagement.test.tsx` release the pending revocation
manually and assert both the drawer title and the concrete row contents.
Restoring the `sessionDrawerUser?.id` comparison makes `does not replace
another user session drawer when a previous revocation completes` render the
revoked user's row again, and makes `does not reload session details after
their drawer is closed during revocation` issue a second
`listStudioUserSessions` call; removing the close/unmount cl [...]
---
web/src/pages/studio/UserManagement.tsx | 9 +++-
.../pages/studio/__tests__/UserManagement.test.tsx | 61 ++++++++++++++++++++++
2 files changed, 69 insertions(+), 1 deletion(-)
diff --git a/web/src/pages/studio/UserManagement.tsx
b/web/src/pages/studio/UserManagement.tsx
index c40068e06..1dae92cac 100644
--- a/web/src/pages/studio/UserManagement.tsx
+++ b/web/src/pages/studio/UserManagement.tsx
@@ -142,6 +142,7 @@ const UserManagementPage = () => {
const [passwordForm] = Form.useForm<PasswordFormValues>();
const requestSeqRef = useRef(0);
const sessionDetailsRequestSeqRef = useRef(0);
+ const sessionDrawerUserIdRef = useRef<number | null>(null);
const mutatingUserIdsRef = useRef(new Set<number>());
useEffect(() => {
@@ -155,6 +156,7 @@ const UserManagementPage = () => {
setUsers([]);
setTotal(0);
setSessionOverview(null);
+ sessionDrawerUserIdRef.current = null;
setSessionDrawerUser(null);
setSessionDetails([]);
return;
@@ -203,6 +205,7 @@ const UserManagementPage = () => {
() => () => {
requestSeqRef.current += 1;
sessionDetailsRequestSeqRef.current += 1;
+ sessionDrawerUserIdRef.current = null;
},
[],
);
@@ -233,12 +236,14 @@ const UserManagementPage = () => {
);
const openSessionDrawer = (record: StudioUser) => {
+ sessionDrawerUserIdRef.current = record.id;
setSessionDrawerUser(record);
setSessionDetails([]);
void loadSessionDetails(record);
};
const closeSessionDrawer = () => {
+ sessionDrawerUserIdRef.current = null;
sessionDetailsRequestSeqRef.current += 1;
setSessionDrawerUser(null);
setSessionDetails([]);
@@ -328,7 +333,9 @@ const UserManagementPage = () => {
navigate('/login', { replace: true });
return;
}
- if (sessionDrawerUser?.id === record.id) {
+ // The operator may have closed or changed drawers while revocation
was pending.
+ // Do not let its follow-up read take ownership of another user's
session details.
+ if (sessionDrawerUserIdRef.current === record.id) {
await loadSessionDetails({ ...record, activeSessionCount: 0 });
}
await loadUsers();
diff --git a/web/src/pages/studio/__tests__/UserManagement.test.tsx
b/web/src/pages/studio/__tests__/UserManagement.test.tsx
index 87db6f59b..f98ac4ac0 100644
--- a/web/src/pages/studio/__tests__/UserManagement.test.tsx
+++ b/web/src/pages/studio/__tests__/UserManagement.test.tsx
@@ -335,6 +335,67 @@ describe('UserManagementPage', () => {
expect(within(drawer).getByText('暂无活跃会话')).toBeInTheDocument();
});
+ it('does not replace another user session drawer when a previous revocation
completes', async () => {
+ vi.mocked(listStudioUsers).mockResolvedValue({
+ ...studioUserPage,
+ items: [
+ studioUserPage.items[0],
+ { ...studioUserPage.items[0], id: 8, username: 'second-user' },
+ ],
+ });
+ const secondSessions = [{ ...sessionDetails[0], id: 99, userId: 8 }];
+ vi.mocked(listStudioUserSessions).mockImplementation(async (id) =>
+ id === 8 ? secondSessions : sessionDetails,
+ );
+ let completeRevoke!: () => void;
+ vi.mocked(revokeStudioUserSessions).mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ completeRevoke = () => resolve({ userId: 7, revokedSessionCount: 2
});
+ }),
+ );
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ renderPage();
+ const firstRow = await screen.findByRole('row', { name: /operator/ });
+ await user.click(within(firstRow).getByRole('button', { name: '会话' }));
+ const drawer = await screen.findByRole('dialog', { name: 'operator 的会话' });
+ await user.click(within(drawer).getByRole('button', { name: '注销全部' }));
+ await confirmRevokePopover(user);
+ await waitFor(() =>
expect(revokeStudioUserSessions).toHaveBeenCalledWith(7));
+ await user.click(within(drawer).getByRole('button', { name: /close/i }));
+ const secondRow = screen.getByRole('row', { name: /second-user/ });
+ await user.click(within(secondRow).getByRole('button', { name: '会话' }));
+ const secondDrawer = await screen.findByRole('dialog', { name:
'second-user 的会话' });
+ expect(await within(secondDrawer).findByText('99')).toBeInTheDocument();
+ await act(async () => completeRevoke());
+ await waitFor(() => expect(listStudioUsers).toHaveBeenCalledTimes(2));
+ expect(within(secondDrawer).getByText('99')).toBeInTheDocument();
+ expect(within(secondDrawer).queryByText('19')).not.toBeInTheDocument();
+ });
+
+ it('does not reload session details after their drawer is closed during
revocation', async () => {
+ let completeRevoke!: () => void;
+ vi.mocked(revokeStudioUserSessions).mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ completeRevoke = () => resolve({ userId: 7, revokedSessionCount: 2
});
+ }),
+ );
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ renderPage();
+ await screen.findByText('operator');
+ await user.click(screen.getByRole('button', { name: '会话' }));
+ const drawer = await screen.findByRole('dialog', { name: 'operator 的会话' });
+ await user.click(within(drawer).getByRole('button', { name: '注销全部' }));
+ await confirmRevokePopover(user);
+ await waitFor(() =>
expect(revokeStudioUserSessions).toHaveBeenCalledWith(7));
+ await user.click(within(drawer).getByRole('button', { name: /close/i }));
+ await act(async () => completeRevoke());
+ await waitFor(() => expect(listStudioUsers).toHaveBeenCalledTimes(2));
+ expect(listStudioUserSessions).toHaveBeenCalledTimes(1);
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
it('refreshes the open session detail drawer', async () => {
vi.mocked(listStudioUserSessions)
.mockResolvedValueOnce(sessionDetails)