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, {