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 c221dc16 fix: honor configurable login protection in frontend routes 
(#668)
c221dc16 is described below

commit c221dc163c7a1ffa413d47c2e73984e1f2a35b0c
Author: Rui <[email protected]>
AuthorDate: Fri Jul 31 16:14:12 2026 +0800

    fix: honor configurable login protection in frontend routes (#668)
---
 .../rocketmq/studio/auth/AuthController.java       |  16 +++
 .../rocketmq/studio/auth/AuthInterceptor.java      |   1 +
 .../apache/rocketmq/studio/auth/AuthStatusVO.java  |  32 ++++++
 .../rocketmq/studio/auth/AuthControllerTest.java   |  41 +++++++
 .../rocketmq/studio/auth/AuthInterceptorTest.java  |  12 ++
 web/src/App.test.tsx                               | 102 ++++++++++++++++
 web/src/App.tsx                                    | 128 ++++++++++++++++-----
 web/src/api/auth.test.ts                           |   9 +-
 web/src/api/auth.ts                                |  10 ++
 web/src/i18n/translations.ts                       |   5 +
 10 files changed, 327 insertions(+), 29 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthController.java 
b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthController.java
index 4389a744..d5398dbd 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthController.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthController.java
@@ -19,7 +19,10 @@ package org.apache.rocketmq.studio.auth;
 
 import org.apache.rocketmq.studio.common.domain.Result;
 import lombok.RequiredArgsConstructor;
+import org.springframework.http.CacheControl;
 import org.springframework.http.HttpHeaders;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestHeader;
@@ -32,6 +35,19 @@ import 
org.springframework.web.bind.annotation.RestController;
 public class AuthController {
 
     private final AuthService authService;
+    private final AuthProperties authProperties;
+
+    @GetMapping("/status")
+    public ResponseEntity<Result<AuthStatusVO>> status(
+            @RequestHeader(value = HttpHeaders.AUTHORIZATION, required = 
false) String authorization) {
+        AuthStatusVO status = AuthStatusVO.builder()
+                .loginRequired(authProperties.isLoginRequired())
+                .authenticated(authService.isAuthenticated(authorization))
+                .build();
+        return ResponseEntity.ok()
+                .cacheControl(CacheControl.noStore())
+                .body(Result.ok(status));
+    }
 
     @PostMapping("/login")
     public Result<LoginVO> login(@RequestBody LoginDTO request) {
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthInterceptor.java 
b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthInterceptor.java
index 43f51861..93ce5442 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthInterceptor.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthInterceptor.java
@@ -49,6 +49,7 @@ public class AuthInterceptor implements HandlerInterceptor {
 
     private boolean isPublicPath(String path) {
         return path.equals("/api/auth/login")
+                || path.equals("/api/auth/status")
                 || path.startsWith("/api-docs")
                 || path.startsWith("/swagger-ui")
                 || path.startsWith("/actuator/health");
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthStatusVO.java 
b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthStatusVO.java
new file mode 100644
index 00000000..c766754e
--- /dev/null
+++ b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthStatusVO.java
@@ -0,0 +1,32 @@
+/*
+ * 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.
+ */
+
+package org.apache.rocketmq.studio.auth;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class AuthStatusVO {
+    private boolean loginRequired;
+    private boolean authenticated;
+}
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthControllerTest.java 
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthControllerTest.java
index 0b140a74..9d0a5f16 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthControllerTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthControllerTest.java
@@ -32,7 +32,9 @@ import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.doNothing;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
+import static 
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
 import static 
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static 
org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
 import static 
org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
 import static 
org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
 
@@ -46,9 +48,48 @@ class AuthControllerTest {
     @MockBean
     private AuthService authService;
 
+    @MockBean
+    private AuthProperties authProperties;
+
     @Autowired
     private ObjectMapper objectMapper;
 
+    @Test
+    void statusShouldReportDisabledLoginProtection() throws Exception {
+        when(authProperties.isLoginRequired()).thenReturn(false);
+        when(authService.isAuthenticated(null)).thenReturn(false);
+
+        mockMvc.perform(get("/api/auth/status"))
+                .andExpect(status().isOk())
+                .andExpect(header().string(HttpHeaders.CACHE_CONTROL, 
"no-store"))
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.loginRequired").value(false))
+                .andExpect(jsonPath("$.data.authenticated").value(false));
+    }
+
+    @Test
+    void statusShouldReportUnauthenticatedWhenTokenIsMissing() throws 
Exception {
+        when(authProperties.isLoginRequired()).thenReturn(true);
+        when(authService.isAuthenticated(null)).thenReturn(false);
+
+        mockMvc.perform(get("/api/auth/status"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.loginRequired").value(true))
+                .andExpect(jsonPath("$.data.authenticated").value(false));
+    }
+
+    @Test
+    void statusShouldReportAuthenticatedForActiveToken() throws Exception {
+        when(authProperties.isLoginRequired()).thenReturn(true);
+        when(authService.isAuthenticated("Bearer token-1")).thenReturn(true);
+
+        mockMvc.perform(get("/api/auth/status")
+                        .header(HttpHeaders.AUTHORIZATION, "Bearer token-1"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.loginRequired").value(true))
+                .andExpect(jsonPath("$.data.authenticated").value(true));
+    }
+
     @Test
     void loginShouldReturnTokenOnValidRequest() throws Exception {
         LoginVO mockResponse = LoginVO.builder()
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthInterceptorTest.java 
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthInterceptorTest.java
index 9c9d0e66..5aa924ba 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthInterceptorTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthInterceptorTest.java
@@ -88,4 +88,16 @@ class AuthInterceptorTest {
 
         assertThat(allowed).isTrue();
     }
+
+    @Test
+    void shouldAllowAuthStatusEndpointWhenLoginIsEnabled() throws Exception {
+        AuthProperties properties = new AuthProperties();
+        properties.setLoginRequired(true);
+        AuthInterceptor interceptor = new AuthInterceptor(properties, new 
AuthService(properties));
+        MockHttpServletRequest request = new MockHttpServletRequest("GET", 
"/api/auth/status");
+
+        boolean allowed = interceptor.preHandle(request, new 
MockHttpServletResponse(), new Object());
+
+        assertThat(allowed).isTrue();
+    }
 }
diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx
new file mode 100644
index 00000000..4ac35351
--- /dev/null
+++ b/web/src/App.test.tsx
@@ -0,0 +1,102 @@
+/*
+ * 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 { cleanup, fireEvent, render, screen, waitFor } from 
'@testing-library/react';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { getAuthStatus } from './api/auth';
+import { AuthGate } from './App';
+import { LangProvider } from './i18n/LangContext';
+
+vi.mock('./api/auth', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('./api/auth')>();
+  return { ...actual, getAuthStatus: vi.fn() };
+});
+
+vi.mock('./config', () => ({ API_BASE_URL: '/api', USE_MOCK: false }));
+
+const mockedGetAuthStatus = vi.mocked(getAuthStatus);
+
+function renderGate() {
+  return render(
+    <LangProvider>
+      <MemoryRouter initialEntries={['/protected']}>
+        <Routes>
+          <Route path="/login" element={<div>login page</div>} />
+          <Route element={<AuthGate />}>
+            <Route path="/protected" element={<div>protected content</div>} />
+          </Route>
+        </Routes>
+      </MemoryRouter>
+    </LangProvider>,
+  );
+}
+
+describe('AuthGate', () => {
+  beforeEach(() => {
+    mockedGetAuthStatus.mockReset();
+    localStorage.setItem('token', 'stale-token');
+    localStorage.setItem('rocketmq-studio-user', 'admin');
+  });
+
+  afterEach(() => {
+    cleanup();
+    localStorage.clear();
+  });
+
+  it('allows protected routes when login protection is disabled', async () => {
+    mockedGetAuthStatus.mockResolvedValue({ loginRequired: false, 
authenticated: false });
+
+    renderGate();
+
+    expect(await screen.findByText('protected content')).toBeInTheDocument();
+  });
+
+  it('allows protected routes for an authenticated session', async () => {
+    mockedGetAuthStatus.mockResolvedValue({ loginRequired: true, 
authenticated: true });
+
+    renderGate();
+
+    expect(await screen.findByText('protected content')).toBeInTheDocument();
+  });
+
+  it('clears an invalid session and redirects to login', async () => {
+    mockedGetAuthStatus.mockResolvedValue({ loginRequired: true, 
authenticated: false });
+
+    renderGate();
+
+    expect(await screen.findByText('login page')).toBeInTheDocument();
+    expect(localStorage.getItem('token')).toBeNull();
+    expect(localStorage.getItem('rocketmq-studio-user')).toBeNull();
+  });
+
+  it('fails closed and retries the status check', async () => {
+    mockedGetAuthStatus
+      .mockRejectedValueOnce(new Error('network unavailable'))
+      .mockResolvedValueOnce({ loginRequired: false, authenticated: false });
+
+    renderGate();
+
+    expect(await screen.findByText('无法验证登录状态')).toBeInTheDocument();
+    expect(screen.queryByText('protected content')).not.toBeInTheDocument();
+
+    fireEvent.click(screen.getByRole('button', { name: /重\s*试/ }));
+
+    await waitFor(() => expect(mockedGetAuthStatus).toHaveBeenCalledTimes(2));
+    expect(await screen.findByText('protected content')).toBeInTheDocument();
+  });
+});
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 593295ac..627fceb7 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -15,7 +15,13 @@
  * limitations under the License.
  */
 
-import { Routes, Route, Navigate } from 'react-router-dom';
+import { useCallback, useEffect, useState } from 'react';
+import { Button, Result, Spin } from 'antd';
+import { Routes, Route, Navigate, Outlet } from 'react-router-dom';
+import { getAuthStatus } from './api/auth';
+import { USE_MOCK } from './config';
+import { useLang } from './i18n/LangContext';
+import useAuthStore from './stores/authStore';
 import MainLayout from './layouts/MainLayout';
 import HomePage from './pages/home';
 import InstancePage from './pages/instance';
@@ -44,37 +50,103 @@ import ProducerPage from './pages/studio/Producer';
 import OpsPage from './pages/studio/Ops';
 import LoginPage from './pages/login';
 
+type AuthGateState = 'checking' | 'allowed' | 'denied' | 'error';
+
+export function AuthGate() {
+  const { t } = useLang();
+  const clearAuth = useAuthStore((state) => state.logout);
+  const [gateState, setGateState] = useState<AuthGateState>(USE_MOCK ? 
'allowed' : 'checking');
+  const [attempt, setAttempt] = useState(0);
+
+  useEffect(() => {
+    if (USE_MOCK) return;
+
+    let cancelled = false;
+    void getAuthStatus()
+      .then((status) => {
+        if (cancelled) return;
+        if (!status.loginRequired || status.authenticated) {
+          setGateState('allowed');
+          return;
+        }
+        clearAuth();
+        setGateState('denied');
+      })
+      .catch(() => {
+        if (!cancelled) setGateState('error');
+      });
+
+    return () => {
+      cancelled = true;
+    };
+  }, [attempt, clearAuth]);
+
+  const retry = useCallback(() => {
+    setGateState('checking');
+    setAttempt((current) => current + 1);
+  }, []);
+
+  if (gateState === 'checking') {
+    return (
+      <div
+        role="status"
+        aria-label={t('common.loading')}
+        style={{ minHeight: '100vh', display: 'grid', placeItems: 'center' }}
+      >
+        <Spin size="large" />
+      </div>
+    );
+  }
+  if (gateState === 'denied') return <Navigate to="/login" replace />;
+  if (gateState === 'error') {
+    return (
+      <Result
+        status="error"
+        title={t('login.statusCheckFailed')}
+        extra={
+          <Button type="primary" onClick={retry}>
+            {t('common.retry')}
+          </Button>
+        }
+      />
+    );
+  }
+  return <Outlet />;
+}
+
 function App() {
   return (
     <Routes>
       <Route path="/login" element={<LoginPage />} />
-      <Route path="/" element={<MainLayout />}>
-        <Route index element={<HomePage />} />
-        <Route path="instance" element={<InstancePage />} />
-        <Route path="instance/topic" element={<TopicPage />} />
-        <Route path="instance/consumer" element={<ConsumerPage />} />
-        <Route path="instance/message" element={<MessagePage />} />
-        <Route path="instance/acl" element={<AclPage />} />
-        <Route path="instance/dlq" element={<DlqPage />} />
-        <Route path="cluster" element={<ClusterPage />} />
-        <Route path="cluster/certs" element={<K8sCertsPage />} />
-        <Route path="cluster/clients" element={<ClientsPage />} />
-        <Route path="ops/dashboard" element={<DashboardOpsPage />} />
-        <Route path="ops/alerts" element={<AlertsPage />} />
-        <Route path="ops/system-alerts" element={<SystemAlertsPage />} />
-        <Route path="ops/audit" element={<AuditPage />} />
-        <Route path="ai" element={<AiPage />} />
-        <Route path="settings" element={<SettingsPage />} />
-        <Route path="studio/llm-settings" element={<LlmSettingsPage />} />
-        <Route path="studio/proxy" element={<ProxyPage />} />
-        <Route path="studio/lite-topic" element={<LiteTopicPage />} />
-        <Route path="studio/group-management" element={<GroupManagementPage 
/>} />
-        <Route path="studio/broker-cluster" element={<BrokerClusterPage />} />
-        <Route path="studio/ssl-settings" element={<SslSettingsPage />} />
-        <Route path="studio/alert-management" element={<AlertManagementPage 
/>} />
-        <Route path="studio/producer" element={<ProducerPage />} />
-        <Route path="studio/ops" element={<OpsPage />} />
-        <Route path="*" element={<Navigate to="/" replace />} />
+      <Route element={<AuthGate />}>
+        <Route path="/" element={<MainLayout />}>
+          <Route index element={<HomePage />} />
+          <Route path="instance" element={<InstancePage />} />
+          <Route path="instance/topic" element={<TopicPage />} />
+          <Route path="instance/consumer" element={<ConsumerPage />} />
+          <Route path="instance/message" element={<MessagePage />} />
+          <Route path="instance/acl" element={<AclPage />} />
+          <Route path="instance/dlq" element={<DlqPage />} />
+          <Route path="cluster" element={<ClusterPage />} />
+          <Route path="cluster/certs" element={<K8sCertsPage />} />
+          <Route path="cluster/clients" element={<ClientsPage />} />
+          <Route path="ops/dashboard" element={<DashboardOpsPage />} />
+          <Route path="ops/alerts" element={<AlertsPage />} />
+          <Route path="ops/system-alerts" element={<SystemAlertsPage />} />
+          <Route path="ops/audit" element={<AuditPage />} />
+          <Route path="ai" element={<AiPage />} />
+          <Route path="settings" element={<SettingsPage />} />
+          <Route path="studio/llm-settings" element={<LlmSettingsPage />} />
+          <Route path="studio/proxy" element={<ProxyPage />} />
+          <Route path="studio/lite-topic" element={<LiteTopicPage />} />
+          <Route path="studio/group-management" element={<GroupManagementPage 
/>} />
+          <Route path="studio/broker-cluster" element={<BrokerClusterPage />} 
/>
+          <Route path="studio/ssl-settings" element={<SslSettingsPage />} />
+          <Route path="studio/alert-management" element={<AlertManagementPage 
/>} />
+          <Route path="studio/producer" element={<ProducerPage />} />
+          <Route path="studio/ops" element={<OpsPage />} />
+          <Route path="*" element={<Navigate to="/" replace />} />
+        </Route>
       </Route>
     </Routes>
   );
diff --git a/web/src/api/auth.test.ts b/web/src/api/auth.test.ts
index c0fcc8fd..3adfd5f7 100644
--- a/web/src/api/auth.test.ts
+++ b/web/src/api/auth.test.ts
@@ -18,7 +18,7 @@
 import MockAdapter from 'axios-mock-adapter';
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
 import client from './client';
-import { login, logout } from './auth';
+import { getAuthStatus, login, logout } from './auth';
 
 const mock = new MockAdapter(client);
 
@@ -37,6 +37,13 @@ describe('Auth API', () => {
     vi.unstubAllGlobals();
   });
 
+  it('status should return the login requirement and session state', async () 
=> {
+    const authStatus = { loginRequired: true, authenticated: false };
+    mock.onGet('/auth/status').reply(200, { data: authStatus });
+
+    await expect(getAuthStatus()).resolves.toEqual(authStatus);
+  });
+
   it('login should post credentials and return token data', async () => {
     const mockResponse = {
       token: 'jwt-token-123',
diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts
index 5abeb807..0c3a8f07 100644
--- a/web/src/api/auth.ts
+++ b/web/src/api/auth.ts
@@ -32,7 +32,17 @@ export interface LoginResponse {
   };
 }
 
+export interface AuthStatus {
+  loginRequired: boolean;
+  authenticated: boolean;
+}
+
 // ─── Auth ───────────────────────────────────────────────────────
+export async function getAuthStatus() {
+  const res = await client.get<{ data: AuthStatus }>('/auth/status');
+  return res.data.data;
+}
+
 export async function login(username: string, password: string) {
   const res = await client.post<{ data: LoginResponse }>('/auth/login', { 
username, password });
   return res.data.data;
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 54625d24..47bde378 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -70,6 +70,7 @@ const translations: Record<string, Record<Lang, string>> = {
   'common.liveRefresh': { zh: '实时刷新', en: 'Live Refresh' },
   'common.yes': { zh: '是', en: 'Yes' },
   'common.no': { zh: '否', en: 'No' },
+  'common.retry': { zh: '重试', en: 'Retry' },
 
   // ─── Dashboard ───
   'dashboard.title': { zh: '监控面板', en: 'Dashboard' },
@@ -641,6 +642,10 @@ const translations: Record<string, Record<Lang, string>> = 
{
   'login.success': { zh: '登录成功', en: 'Login successful' },
   'login.failed': { zh: '登录失败', en: 'Login failed' },
   'login.welcome': { zh: '欢迎使用 RocketMQ 仪表盘', en: 'Welcome to RocketMQ 
Dashboard' },
+  'login.statusCheckFailed': {
+    zh: '无法验证登录状态',
+    en: 'Unable to verify login status',
+  },
 
   // ─── Ops ───
   'ops.title': { zh: '运维', en: 'Ops' },

Reply via email to