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 c4135abe7 fix(web): recheck the session on cross-tab identity change, 
cancel pending proxy loads, guard the time formatters (#5362)
c4135abe7 is described below

commit c4135abe7afb91f0c8b748273ed43f2c9c66bd55
Author: Loyal-Young <[email protected]>
AuthorDate: Sat Oct 10 12:03:25 2026 +0800

    fix(web): recheck the session on cross-tab identity change, cancel pending 
proxy loads, guard the time formatters (#5362)
    
    Three unrelated small frontend defects from the same author, batched 
because each is a few lines with its own test.
    
    - #5362 `fix(auth): recheck session after cross-tab identity changes`
    - #5366 `fix(proxy): invalidate pending loads on page unmount`
    - #5795 `fix(web): guard time formatters against invalid Date ranges`
    
    3 suites, 53 tests green.
    
    Maintainer edit: ran prettier over `App.test.tsx` and `format.test.ts` for 
the same reason as above.
    
    Folded in #5366, #5795 (same author). Merged through #5362.
---
 web/src/App.test.tsx                          | 65 +++++++++++++++++++++++++++
 web/src/App.tsx                               | 26 +++++++++++
 web/src/pages/studio/Proxy.tsx                |  3 +-
 web/src/pages/studio/__tests__/Proxy.test.tsx | 13 ++++++
 web/src/utils/format.test.ts                  | 17 +++++++
 web/src/utils/format.ts                       |  6 ++-
 6 files changed, 126 insertions(+), 4 deletions(-)

diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx
index d5b775385..d45baac3c 100644
--- a/web/src/App.test.tsx
+++ b/web/src/App.test.tsx
@@ -23,6 +23,7 @@ import { getAuthStatus } from './api/auth';
 import App, { AuthGate, LazyRouteOutlet } from './App';
 import { LangProvider } from './i18n/LangContext';
 import useAuthStore from './stores/authStore';
+import { USER_STORAGE_KEY } from './stores/authStorage';
 
 vi.mock('./api/auth', async (importOriginal) => {
   const actual = await importOriginal<typeof import('./api/auth')>();
@@ -149,6 +150,70 @@ describe('AuthGate', () => {
     expect(localStorage.getItem('rocketmq-studio-user')).toBeNull();
   });
 
+  it('hides protected content and rechecks the session after another tab signs 
out', async () => {
+    let resolveRecheck!: (status: { loginRequired: boolean; authenticated: 
boolean }) => void;
+    mockedGetAuthStatus
+      .mockResolvedValueOnce({
+        loginRequired: true,
+        authenticated: true,
+        user: { userId: 7, username: 'studio-admin', admin: true },
+      })
+      .mockImplementationOnce(
+        () =>
+          new Promise((resolve) => {
+            resolveRecheck = resolve;
+          }),
+      );
+
+    renderGate();
+    expect(await screen.findByText('protected content')).toBeInTheDocument();
+
+    act(() => {
+      window.dispatchEvent(new StorageEvent('storage', { key: 
USER_STORAGE_KEY, newValue: null }));
+    });
+    expect(screen.queryByText('protected content')).not.toBeInTheDocument();
+    expect(screen.getByRole('status', { name: '加载中' })).toBeInTheDocument();
+    await waitFor(() => expect(mockedGetAuthStatus).toHaveBeenCalledTimes(2));
+
+    await act(async () => {
+      resolveRecheck({ loginRequired: true, authenticated: false });
+    });
+    expect(await screen.findByText('login page')).toBeInTheDocument();
+  });
+
+  it('refreshes the account identity after another tab signs in', async () => {
+    mockedGetAuthStatus
+      .mockResolvedValueOnce({
+        loginRequired: true,
+        authenticated: true,
+        user: { userId: 7, username: 'account-a', admin: true },
+      })
+      .mockResolvedValueOnce({
+        loginRequired: true,
+        authenticated: true,
+        user: { userId: 8, username: 'account-b', admin: false },
+      });
+
+    renderGate();
+    expect(await screen.findByText('protected content')).toBeInTheDocument();
+    expect(useAuthStore.getState().user).toBe('account-a');
+
+    act(() => {
+      window.dispatchEvent(
+        new StorageEvent('storage', { key: USER_STORAGE_KEY, newValue: 
'account-b' }),
+      );
+    });
+    await waitFor(() => expect(mockedGetAuthStatus).toHaveBeenCalledTimes(2));
+    await waitFor(() =>
+      expect(useAuthStore.getState()).toMatchObject({
+        user: 'account-b',
+        userId: 8,
+        admin: false,
+      }),
+    );
+    expect(await screen.findByText('protected content')).toBeInTheDocument();
+  });
+
   it('fails closed and retries the status check', async () => {
     mockedGetAuthStatus
       .mockRejectedValueOnce(new Error('network unavailable'))
diff --git a/web/src/App.tsx b/web/src/App.tsx
index ee6e451ec..4e7284fe3 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -22,6 +22,11 @@ import { getAuthStatus } from './api/auth';
 import { isMockMode } from './services/dataMode';
 import { useLang } from './i18n/LangContext';
 import useAuthStore from './stores/authStore';
+import {
+  USER_ADMIN_STORAGE_KEY,
+  USER_ID_STORAGE_KEY,
+  USER_STORAGE_KEY,
+} from './stores/authStorage';
 import MainLayout from './layouts/MainLayout';
 
 const LoginPage = lazy(() => import('./pages/login'));
@@ -60,6 +65,27 @@ export function AuthGate() {
   const [gateState, setGateState] = useState<AuthGateState>(isMockMode() ? 
'allowed' : 'checking');
   const [attempt, setAttempt] = useState(0);
 
+  useEffect(() => {
+    if (isMockMode()) return;
+
+    const onStorage = (event: StorageEvent) => {
+      if (
+        event.key !== null &&
+        event.key !== USER_STORAGE_KEY &&
+        event.key !== USER_ID_STORAGE_KEY &&
+        event.key !== USER_ADMIN_STORAGE_KEY
+      ) {
+        return;
+      }
+      // Another tab may have changed the shared cookie too. Hide the old 
account's
+      // page while the server confirms which session is now active.
+      setGateState('checking');
+      setAttempt((current) => current + 1);
+    };
+    window.addEventListener('storage', onStorage);
+    return () => window.removeEventListener('storage', onStorage);
+  }, []);
+
   useEffect(() => {
     if (isMockMode()) return;
 
diff --git a/web/src/pages/studio/Proxy.tsx b/web/src/pages/studio/Proxy.tsx
index db46f5838..29a6761aa 100644
--- a/web/src/pages/studio/Proxy.tsx
+++ b/web/src/pages/studio/Proxy.tsx
@@ -166,12 +166,11 @@ const ProxyPage: React.FC = () => {
   }, [applyProxyHome, message, t]);
 
   useEffect(() => {
-    const requestId = loadRequestId.current;
     // The state updates are performed by the asynchronous Proxy API request, 
not by this effect itself.
     // eslint-disable-next-line react-hooks/set-state-in-effect
     void loadProxyNodes();
     return () => {
-      loadRequestId.current = requestId + 1;
+      loadRequestId.current += 1;
     };
   }, [loadProxyNodes]);
 
diff --git a/web/src/pages/studio/__tests__/Proxy.test.tsx 
b/web/src/pages/studio/__tests__/Proxy.test.tsx
index 430b3b597..188d58ea8 100644
--- a/web/src/pages/studio/__tests__/Proxy.test.tsx
+++ b/web/src/pages/studio/__tests__/Proxy.test.tsx
@@ -136,6 +136,19 @@ describe('ProxyPage', () => {
     expect(queryProxyHomePage).toHaveBeenCalledTimes(1);
   });
 
+  it('does not persist a Proxy address after an unfinished load unmounts', 
async () => {
+    const pending = createDeferred<typeof proxyHome>();
+    vi.mocked(queryProxyHomePage).mockReturnValueOnce(pending.promise);
+    const page = renderPage();
+    await waitFor(() => expect(queryProxyHomePage).toHaveBeenCalledTimes(1));
+
+    page.unmount();
+    await act(async () => pending.resolve(proxyHome));
+
+    expect(getProxyTopology).not.toHaveBeenCalled();
+    expect(localStorage.getItem('proxyAddr')).toBeNull();
+  });
+
   it('shows success after the proxy list refreshes', async () => {
     const user = userEvent.setup();
     renderPage();
diff --git a/web/src/utils/format.test.ts b/web/src/utils/format.test.ts
index 3e96edcc3..2333a0217 100644
--- a/web/src/utils/format.test.ts
+++ b/web/src/utils/format.test.ts
@@ -100,6 +100,23 @@ describe('formatBytes', () => {
 });
 
 describe('formatTimeMs', () => {
+  it.each([8_640_000_000_000_001, -8_640_000_000_000_001, Number.MAX_VALUE, 
-Number.MAX_VALUE])(
+    'uses the placeholder for finite timestamps outside the Date range: %s',
+    (value) => {
+      expect(formatTimeMs(value)).toBe('-');
+      expect(formatRelativeTime(value, 'en', (key) => key, 
Date.now())).toBe('-');
+      expect(formatRelativeTime(Date.now(), 'en', (key) => key, 
value)).toBe('-');
+    },
+  );
+
+  it.each([8_640_000_000_000_000, -8_640_000_000_000_000])(
+    'keeps representable boundary dates: %s',
+    (value) => {
+      expect(formatTimeMs(value)).toBe(`${formatDateTime(new 
Date(value))}.000`);
+      expect(formatRelativeTime(value, 'en', (key) => key, 
value)).toBe('ai.history.justNow');
+    },
+  );
+
   const shape = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}$/;
 
   it('preserves the Unix epoch timestamp', () => {
diff --git a/web/src/utils/format.ts b/web/src/utils/format.ts
index 2c291608a..e7d510ced 100644
--- a/web/src/utils/format.ts
+++ b/web/src/utils/format.ts
@@ -45,6 +45,7 @@ export function formatTimeMs(value: number | string | null | 
undefined): string
   const timestamp = typeof value === 'string' ? Date.parse(value) : value;
   if (!Number.isFinite(timestamp)) return '-';
   const date = new Date(timestamp);
+  if (Number.isNaN(date.getTime())) return '-';
   return `${formatDateTime(date)}.${pad(date.getMilliseconds(), 3)}`;
 }
 
@@ -121,6 +122,9 @@ export function formatRelativeTime(
   now = Date.now(),
 ): string {
   if (!Number.isFinite(timestamp) || !Number.isFinite(now)) return '-';
+  const updatedAt = new Date(timestamp);
+  const current = new Date(now);
+  if (Number.isNaN(updatedAt.getTime()) || Number.isNaN(current.getTime())) 
return '-';
   if (!timestamp) return t('ai.history.justNow');
 
   const elapsed = Math.max(0, now - timestamp);
@@ -128,8 +132,6 @@ export function formatRelativeTime(
   if (minutes < 1) return t('ai.history.justNow');
   if (minutes < 60) return t('ai.history.minutesAgo', { count: minutes });
 
-  const updatedAt = new Date(timestamp);
-  const current = new Date(now);
   const locale = lang === 'zh' ? 'zh-CN' : 'en-US';
   if (updatedAt.toDateString() === current.toDateString()) {
     return new Intl.DateTimeFormat(locale, {

Reply via email to