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

commit 80d0945915435061ea7df8a051b0518a0263dc70
Author: zhaohai <[email protected]>
AuthorDate: Wed Jul 22 19:09:38 2026 +0800

    feat: implement login page, auth & AI modules, simplify theme management 
(#473)
    
    Add login page with full auth workflow, Auth/AI API modules, Zustand state, 
and simplified theme management.
---
 web/package.json                              |   2 +-
 web/src/App.tsx                               |   2 +
 web/src/api/ai.test.ts                        | 191 ++++++++++++++++++--------
 web/src/api/auth.test.ts                      |  74 ++++++++++
 web/src/layouts/MainLayout.tsx                |  10 +-
 web/src/pages/login/index.tsx                 | 102 ++++++++++++++
 web/src/theme/ThemeContext.tsx                |   2 +-
 web/src/theme/__tests__/ThemeContext.test.tsx |   2 +-
 8 files changed, 316 insertions(+), 69 deletions(-)

diff --git a/web/package.json b/web/package.json
index 307eab84..b9dc0ecf 100644
--- a/web/package.json
+++ b/web/package.json
@@ -60,4 +60,4 @@
     "vite": "^6.0.0",
     "vitest": "^4.1.10"
   }
-}
\ No newline at end of file
+}
diff --git a/web/src/App.tsx b/web/src/App.tsx
index eac2795c..a75edbbf 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -33,10 +33,12 @@ import SystemAlertsPage from './pages/ops/systemAlerts';
 import AuditPage from './pages/ops/audit';
 import AiPage from './pages/ai';
 import SettingsPage from './pages/settings';
+import LoginPage from './pages/login';
 
 function App() {
   return (
     <Routes>
+      <Route path="/login" element={<LoginPage />} />
       <Route path="/" element={<MainLayout />}>
         <Route index element={<HomePage />} />
         <Route path="instance" element={<InstancePage />} />
diff --git a/web/src/api/ai.test.ts b/web/src/api/ai.test.ts
index 1b03825f..58250ebb 100644
--- a/web/src/api/ai.test.ts
+++ b/web/src/api/ai.test.ts
@@ -15,9 +15,12 @@
  * limitations under the License.
  */
 
-import { afterEach, describe, expect, it, vi } from 'vitest';
-import { chatStream } from './ai';
+import MockAdapter from 'axios-mock-adapter';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import client from './client';
+import { chatStream, executeAiCommand, listTools, type AiExecuteRequest, type 
McpTool } from './ai';
 
+const mock = new MockAdapter(client);
 const encoder = new TextEncoder();
 
 function streamResponse(chunks: string[]): Response {
@@ -30,68 +33,142 @@ function streamResponse(chunks: string[]): Response {
   return new Response(body, { status: 200 });
 }
 
-describe('AI chat SSE stream', () => {
+describe('AI API', () => {
+  beforeEach(() => {
+    mock.reset();
+    vi.stubGlobal('localStorage', {
+      getItem: vi.fn().mockReturnValue('test-token'),
+      setItem: vi.fn(),
+      removeItem: vi.fn(),
+    });
+  });
+
   afterEach(() => {
+    mock.reset();
     vi.unstubAllGlobals();
   });
 
-  it('reassembles an event split across network chunks', async () => {
-    vi.stubGlobal(
-      'fetch',
-      vi
-        .fn()
-        .mockResolvedValue(
-          streamResponse([
-            'event: message\r\ndata: {"text":"hel',
-            'lo"}\r\n\r\nevent: done\r\ndata: [DONE]\r\n\r\n',
-          ]),
-        ),
-    );
-    vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue('token') 
});
-    const chunks: string[] = [];
-
-    await chatStream({ message: 'hello', mode: 'chat', model: 'stub' }, (text) 
=>
-      chunks.push(text),
-    );
-
-    expect(chunks).toEqual(['hello']);
+  describe('chatStream (SSE)', () => {
+    it('reassembles an event split across network chunks', async () => {
+      vi.stubGlobal(
+        'fetch',
+        vi
+          .fn()
+          .mockResolvedValue(
+            streamResponse([
+              'event: message\r\ndata: {"text":"hel',
+              'lo"}\r\n\r\nevent: done\r\ndata: [DONE]\r\n\r\n',
+            ]),
+          ),
+      );
+      const chunks: string[] = [];
+
+      await chatStream({ message: 'hello', mode: 'chat', model: 'stub' }, 
(text) =>
+        chunks.push(text),
+      );
+
+      expect(chunks).toEqual(['hello']);
+    });
+
+    it('dispatches multiple events delivered in one network chunk', async () 
=> {
+      vi.stubGlobal(
+        'fetch',
+        vi
+          .fn()
+          .mockResolvedValue(
+            streamResponse([
+              'data: {"content":"first"}\n\ndata: 
{"content":"second"}\n\ndata: [DONE]\n\n',
+            ]),
+          ),
+      );
+      const chunks: string[] = [];
+
+      await chatStream({ message: 'hello', mode: 'chat', model: 'stub' }, 
(text) =>
+        chunks.push(text),
+      );
+
+      expect(chunks).toEqual(['first', 'second']);
+    });
+
+    it('supports multiline and raw SSE data at end of stream', async () => {
+      vi.stubGlobal(
+        'fetch',
+        vi
+          .fn()
+          .mockResolvedValue(
+            streamResponse(['data: {"content":\ndata: "hello"}\n\ndata: raw 
text']),
+          ),
+      );
+      const chunks: string[] = [];
+
+      await chatStream({ message: 'hello', mode: 'chat', model: 'stub' }, 
(text) =>
+        chunks.push(text),
+      );
+
+      expect(chunks).toEqual(['hello', 'raw text']);
+    });
   });
 
-  it('dispatches multiple events delivered in one network chunk', async () => {
-    vi.stubGlobal(
-      'fetch',
-      vi
-        .fn()
-        .mockResolvedValue(
-          streamResponse([
-            'data: {"content":"first"}\n\ndata: {"content":"second"}\n\ndata: 
[DONE]\n\n',
-          ]),
-        ),
-    );
-    vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue(null) });
-    const chunks: string[] = [];
-
-    await chatStream({ message: 'hello', mode: 'chat', model: 'stub' }, (text) 
=>
-      chunks.push(text),
-    );
-
-    expect(chunks).toEqual(['first', 'second']);
+  describe('executeAiCommand', () => {
+    it('should post AI command and return result with tool calls', async () => 
{
+      const request: AiExecuteRequest = {
+        message: 'list topics',
+        mode: 'agent',
+        model: 'gpt-4',
+      };
+      const mockResult = { result: 'Found 5 topics', toolCalls: [{ name: 
'listTopics' }] };
+      mock.onPost('/ai/execute', request).reply(200, { data: mockResult });
+
+      const result = await executeAiCommand(request);
+      expect(result.result).toBe('Found 5 topics');
+      expect(result.toolCalls).toHaveLength(1);
+      expect((result.toolCalls[0] as { name: string 
}).name).toBe('listTopics');
+    });
+
+    it('should handle empty tool calls', async () => {
+      mock.onPost('/ai/execute').reply(200, { data: { result: 'Hi!', 
toolCalls: [] } });
+
+      const result = await executeAiCommand({
+        message: 'hello',
+        mode: 'chat',
+        model: 'gpt-4',
+      });
+      expect(result.result).toBe('Hi!');
+      expect(result.toolCalls).toEqual([]);
+    });
+
+    it('should handle server error', async () => {
+      mock.onPost('/ai/execute').reply(500);
+      await expect(
+        executeAiCommand({ message: 'test', mode: 'chat', model: 'gpt-4' }),
+      ).rejects.toThrow();
+    });
   });
 
-  it('supports multiline and raw SSE data at end of stream', async () => {
-    vi.stubGlobal(
-      'fetch',
-      vi
-        .fn()
-        .mockResolvedValue(streamResponse(['data: {"content":\ndata: 
"hello"}\n\ndata: raw text'])),
-    );
-    vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue(null) });
-    const chunks: string[] = [];
-
-    await chatStream({ message: 'hello', mode: 'chat', model: 'stub' }, (text) 
=>
-      chunks.push(text),
-    );
-
-    expect(chunks).toEqual(['hello', 'raw text']);
+  describe('listTools', () => {
+    it('should return list of MCP tools', async () => {
+      const mockTools: McpTool[] = [
+        { name: 'listTopics', description: 'List all topics', parameters: {} },
+        { name: 'createTopic', description: 'Create a topic', parameters: { 
type: 'object' } },
+      ];
+      mock.onGet('/ai/tools').reply(200, { data: mockTools });
+
+      const result = await listTools();
+      expect(result).toHaveLength(2);
+      expect(result[0].name).toBe('listTopics');
+      expect(result[1].name).toBe('createTopic');
+    });
+
+    it('should return empty list when no tools available', async () => {
+      mock.onGet('/ai/tools').reply(200, { data: [] });
+
+      const result = await listTools();
+      expect(result).toEqual([]);
+    });
+
+    it('should handle server error', async () => {
+      mock.onGet('/ai/tools').reply(500);
+      await expect(listTools()).rejects.toThrow();
+    });
   });
 });
diff --git a/web/src/api/auth.test.ts b/web/src/api/auth.test.ts
new file mode 100644
index 00000000..6db27b12
--- /dev/null
+++ b/web/src/api/auth.test.ts
@@ -0,0 +1,74 @@
+/*
+ * 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 MockAdapter from 'axios-mock-adapter';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import client from './client';
+import { login, logout } from './auth';
+
+const mock = new MockAdapter(client);
+
+describe('Auth API', () => {
+  beforeEach(() => {
+    mock.reset();
+    vi.stubGlobal('localStorage', {
+      getItem: vi.fn().mockReturnValue(null),
+      setItem: vi.fn(),
+      removeItem: vi.fn(),
+    });
+  });
+
+  afterEach(() => {
+    mock.reset();
+    vi.unstubAllGlobals();
+  });
+
+  it('login should post credentials and return token data', async () => {
+    const mockResponse = {
+      token: 'jwt-token-123',
+      username: 'admin',
+      role: 'ADMIN',
+    };
+    mock.onPost('/auth/login', { username: 'admin', password: 'secret' 
}).reply(200, {
+      data: mockResponse,
+    });
+
+    const result = await login('admin', 'secret');
+    expect(result).toEqual(mockResponse);
+    expect(result.token).toBe('jwt-token-123');
+    expect(result.username).toBe('admin');
+    expect(result.role).toBe('ADMIN');
+  });
+
+  it('login should handle error response', async () => {
+    mock.onPost('/auth/login').reply(401, { message: 'Invalid credentials' });
+
+    await expect(login('wrong', 'creds')).rejects.toThrow();
+  });
+
+  it('logout should post to logout endpoint', async () => {
+    mock.onPost('/auth/logout').reply(200);
+
+    await expect(logout()).resolves.toBeUndefined();
+  });
+
+  it('logout should handle server error', async () => {
+    mock.onPost('/auth/logout').reply(500);
+
+    await expect(logout()).rejects.toThrow();
+  });
+});
diff --git a/web/src/layouts/MainLayout.tsx b/web/src/layouts/MainLayout.tsx
index de7dc55b..b656db82 100644
--- a/web/src/layouts/MainLayout.tsx
+++ b/web/src/layouts/MainLayout.tsx
@@ -16,15 +16,7 @@
  */
 
 import { useState } from 'react';
-import {
-  Layout,
-  Menu,
-  Breadcrumb,
-  Avatar,
-  Dropdown,
-  Input,
-  Modal,
-} from 'antd';
+import { Layout, Menu, Breadcrumb, Avatar, Dropdown, Input, Modal } from 
'antd';
 import { Outlet, useNavigate, useLocation } from 'react-router-dom';
 import {
   House,
diff --git a/web/src/pages/login/index.tsx b/web/src/pages/login/index.tsx
new file mode 100644
index 00000000..715201c1
--- /dev/null
+++ b/web/src/pages/login/index.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 { useState } from 'react';
+import { Button, Form, Input, Typography, App } from 'antd';
+import { useNavigate } from 'react-router-dom';
+import { useLang } from '../../i18n/LangContext';
+import useAuthStore from '../../stores/authStore';
+import { login as loginApi } from '../../api/auth';
+
+const { Title } = Typography;
+
+interface LoginFormValues {
+  username: string;
+  password: string;
+}
+
+const LoginPage = () => {
+  const [loading, setLoading] = useState(false);
+  const [form] = Form.useForm<LoginFormValues>();
+  const { t } = useLang();
+  const { message } = App.useApp();
+  const navigate = useNavigate();
+  const authLogin = useAuthStore((s) => s.login);
+
+  const onFinish = async (values: LoginFormValues) => {
+    setLoading(true);
+    try {
+      const data = await loginApi(values.username, values.password);
+      authLogin(data.token, data.username);
+      localStorage.setItem('userrole', data.role);
+      message.success(t('login.success'));
+      navigate('/', { replace: true });
+    } catch (err: unknown) {
+      const errorMsg = err instanceof Error ? err.message : t('login.failed');
+      message.error(errorMsg);
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  return (
+    <div
+      style={{
+        maxWidth: 400,
+        margin: '100px auto',
+        padding: 24,
+        boxShadow: '0 2px 8px #f0f1f2',
+        borderRadius: 8,
+      }}
+    >
+      <Title level={3} style={{ textAlign: 'center', marginBottom: 24 }}>
+        {t('login.welcome')}
+      </Title>
+      <Form
+        form={form}
+        name="login_form"
+        layout="vertical"
+        onFinish={onFinish}
+        initialValues={{ username: '', password: '' }}
+      >
+        <Form.Item
+          label={t('login.username')}
+          name="username"
+          rules={[{ required: true, message: t('login.usernameRequired') }]}
+        >
+          <Input placeholder={t('login.usernamePlaceholder')} />
+        </Form.Item>
+
+        <Form.Item
+          label={t('login.password')}
+          name="password"
+          rules={[{ required: true, message: t('login.passwordRequired') }]}
+        >
+          <Input.Password placeholder={t('login.passwordPlaceholder')} />
+        </Form.Item>
+
+        <Form.Item>
+          <Button type="primary" htmlType="submit" block loading={loading}>
+            {t('login.title')}
+          </Button>
+        </Form.Item>
+      </Form>
+    </div>
+  );
+};
+
+export default LoginPage;
diff --git a/web/src/theme/ThemeContext.tsx b/web/src/theme/ThemeContext.tsx
index 6bac20e2..48d250eb 100644
--- a/web/src/theme/ThemeContext.tsx
+++ b/web/src/theme/ThemeContext.tsx
@@ -65,4 +65,4 @@ export const ThemeProvider = ({ children }: { children: 
ReactNode }) => {
 
 export const useTheme = () => useContext(ThemeContext);
 
-export default ThemeContext;
\ No newline at end of file
+export default ThemeContext;
diff --git a/web/src/theme/__tests__/ThemeContext.test.tsx 
b/web/src/theme/__tests__/ThemeContext.test.tsx
index 3feb0cc6..95b919eb 100644
--- a/web/src/theme/__tests__/ThemeContext.test.tsx
+++ b/web/src/theme/__tests__/ThemeContext.test.tsx
@@ -153,4 +153,4 @@ describe('ThemeContext', () => {
     await user.click(screen.getByText('set-light'));
     expect(screen.getByTestId('mode')).toHaveTextContent('light');
   });
-});
\ No newline at end of file
+});

Reply via email to