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' },