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 a354b1408b15ae9e4ecd4afbaf162ee7fb345040
Author: Loyal-Young <[email protected]>
AuthorDate: Wed Jul 22 20:22:15 2026 +0800

    feat: add service capabilities and interaction enhancements (#462)
    
    Consolidated service/interaction work 
(#462,#468,#469,#464,#470,#471,#472,#488).
---
 web/src/api/ai.ts                  |   9 +-
 web/src/api/client.ts              |   8 +-
 web/src/api/cluster.test.ts        |  59 ++++++++++++-
 web/src/config.ts                  |   3 +
 web/src/pages/ai/chatDraft.test.ts |  34 +++++++
 web/src/pages/ai/chatDraft.ts      |  32 +++++++
 web/src/pages/ai/index.tsx         | 176 +++++++++++++++++--------------------
 web/src/pages/cluster/certs.tsx    |  36 +++++++-
 web/src/pages/home/index.tsx       |  12 ++-
 web/src/stores/authStorage.test.ts |  51 +++++++++++
 web/src/stores/authStorage.ts      |  54 ++++++++++++
 11 files changed, 371 insertions(+), 103 deletions(-)

diff --git a/web/src/api/ai.ts b/web/src/api/ai.ts
index 547b3b44..f2d7beb9 100644
--- a/web/src/api/ai.ts
+++ b/web/src/api/ai.ts
@@ -31,6 +31,13 @@ export interface AiExecuteRequest {
   tools?: string[];
 }
 
+export interface AiChatRequest {
+  message: string;
+  mode: string;
+  model: string;
+  conversationId?: string;
+}
+
 interface AiStreamPayload {
   content?: unknown;
   text?: unknown;
@@ -76,7 +83,7 @@ function emitEvent(event: string, onChunk: (text: string) => 
void): boolean {
 
 // ─── AI ─────────────────────────────────────────────────────────
 export async function chatStream(
-  data: AiExecuteRequest,
+  data: AiChatRequest,
   onChunk: (text: string) => void,
   signal?: AbortSignal,
 ) {
diff --git a/web/src/api/client.ts b/web/src/api/client.ts
index a87d762c..6cebadbd 100644
--- a/web/src/api/client.ts
+++ b/web/src/api/client.ts
@@ -17,6 +17,8 @@
 
 import axios from 'axios';
 import { message } from 'antd';
+import { clearAuthSession, TOKEN_STORAGE_KEY } from '../stores/authStorage';
+import { API_BASE_URL } from '../config';
 
 const SUCCESS_BUSINESS_CODES = new Set([0, 200]);
 
@@ -40,14 +42,14 @@ function getBusinessError(data: unknown): string | null {
 }
 
 const client = axios.create({
-  baseURL: '/api',
+  baseURL: API_BASE_URL,
   timeout: 30000,
 });
 
 // Request interceptor: attach Authorization header
 client.interceptors.request.use(
   (config) => {
-    const token = localStorage.getItem('token');
+    const token = localStorage.getItem(TOKEN_STORAGE_KEY);
     if (token) {
       config.headers.Authorization = `Bearer ${token}`;
     }
@@ -68,7 +70,7 @@ client.interceptors.response.use(
   },
   (error) => {
     if (error.response?.status === 401) {
-      localStorage.removeItem('token');
+      clearAuthSession();
       window.location.href = '/';
     }
     return Promise.reject(error);
diff --git a/web/src/api/cluster.test.ts b/web/src/api/cluster.test.ts
index 49a35e52..7019f8b0 100644
--- a/web/src/api/cluster.test.ts
+++ b/web/src/api/cluster.test.ts
@@ -18,7 +18,19 @@
 import MockAdapter from 'axios-mock-adapter';
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
 import client from './client';
-import { createK8sCert, deleteK8sCert, listK8sCerts, updateK8sCert } from 
'./cluster';
+import {
+  createK8sCert,
+  createNameServer,
+  deleteK8sCert,
+  deleteNameServer,
+  listK8sCerts,
+  renewK8sCert,
+  restartNameServer,
+  restartProxy,
+  updateK8sCert,
+  updateNameServer,
+  upgradeNameServer,
+} from './cluster';
 import type { K8sCertInfo } from './cluster';
 
 const mock = new MockAdapter(client);
@@ -72,6 +84,16 @@ describe('K8s certificate API', () => {
     await expect(updateK8sCert({ id: cert.id, issuer: 'vault' 
})).resolves.toEqual(updated);
   });
 
+  it('renews a certificate using its id', async () => {
+    const renewed = { ...cert, daysRemaining: 365, status: 'valid' };
+    mock.onPost('/k8s-certs/renew').reply((config) => {
+      expect(JSON.parse(config.data)).toEqual({ id: cert.id });
+      return [200, { code: 200, message: 'success', data: renewed }];
+    });
+
+    await expect(renewK8sCert(cert.id)).resolves.toEqual(renewed);
+  });
+
   it('sends the certificate id when deleting', async () => {
     mock.onPost('/k8s-certs/delete').reply((config) => {
       expect(JSON.parse(config.data)).toEqual({ id: cert.id });
@@ -80,4 +102,39 @@ describe('K8s certificate API', () => {
 
     await expect(deleteK8sCert(cert.id)).resolves.toBeUndefined();
   });
+
+  it('sends NameServer operation payloads to their endpoints', async () => {
+    const target = { clusterId: 'cluster-1', addr: '127.0.0.1:9876' };
+    const requests = [
+      ['/nameservers/restart', target],
+      ['/nameservers/upgrade', { ...target, version: '5.4.0' }],
+      ['/nameservers/create', target],
+      ['/nameservers/update', { ...target, newAddr: '127.0.0.2:9876' }],
+      ['/nameservers/delete', target],
+    ] as const;
+    requests.forEach(([url, body]) => {
+      mock.onPost(url).reply((config) => {
+        expect(JSON.parse(config.data)).toEqual(body);
+        return [200, { code: 200, data: null }];
+      });
+    });
+
+    await expect(restartNameServer(target)).resolves.toBeUndefined();
+    await expect(upgradeNameServer({ ...target, version: '5.4.0' 
})).resolves.toBeUndefined();
+    await expect(createNameServer(target)).resolves.toBeUndefined();
+    await expect(
+      updateNameServer({ ...target, newAddr: '127.0.0.2:9876' }),
+    ).resolves.toBeUndefined();
+    await expect(deleteNameServer(target)).resolves.toBeUndefined();
+  });
+
+  it('sends the proxy restart target', async () => {
+    const target = { clusterId: 'cluster-1', addr: '127.0.0.1:8081' };
+    mock.onPost('/proxies/restart').reply((config) => {
+      expect(JSON.parse(config.data)).toEqual(target);
+      return [200, { code: 200, data: null }];
+    });
+
+    await expect(restartProxy(target)).resolves.toBeUndefined();
+  });
 });
diff --git a/web/src/config.ts b/web/src/config.ts
index ec4e3644..8d58a72c 100644
--- a/web/src/config.ts
+++ b/web/src/config.ts
@@ -5,3 +5,6 @@
  */
 
 export const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true';
+
+/** API prefix for browser requests. Defaults to the reverse-proxy friendly 
`/api`. */
+export const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL || 
'/api').replace(/\/$/, '');
diff --git a/web/src/pages/ai/chatDraft.test.ts 
b/web/src/pages/ai/chatDraft.test.ts
new file mode 100644
index 00000000..6f1e09e8
--- /dev/null
+++ b/web/src/pages/ai/chatDraft.test.ts
@@ -0,0 +1,34 @@
+/*
+ * 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 { describe, expect, it } from 'vitest';
+import { getChatDraft } from './chatDraft';
+
+describe('AI chat draft navigation state', () => {
+  it('normalizes a prompt and preserves a selected model', () => {
+    expect(getChatDraft({ prompt: '  检查集群状态  ', model: 'qwen3.7-max' 
})).toEqual({
+      prompt: '检查集群状态',
+      model: 'qwen3.7-max',
+    });
+  });
+
+  it('rejects invalid or empty navigation state', () => {
+    expect(getChatDraft(null)).toBeNull();
+    expect(getChatDraft({ prompt: '   ' })).toBeNull();
+    expect(getChatDraft({ prompt: 42 })).toBeNull();
+  });
+});
diff --git a/web/src/pages/ai/chatDraft.ts b/web/src/pages/ai/chatDraft.ts
new file mode 100644
index 00000000..115692ad
--- /dev/null
+++ b/web/src/pages/ai/chatDraft.ts
@@ -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.
+ */
+
+export interface ChatDraft {
+  prompt: string;
+  model?: string;
+}
+
+export function getChatDraft(state: unknown): ChatDraft | null {
+  if (typeof state !== 'object' || state === null) return null;
+  const candidate = state as Record<string, unknown>;
+  if (typeof candidate.prompt !== 'string' || !candidate.prompt.trim()) return 
null;
+
+  return {
+    prompt: candidate.prompt.trim(),
+    ...(typeof candidate.model === 'string' && candidate.model ? { model: 
candidate.model } : {}),
+  };
+}
diff --git a/web/src/pages/ai/index.tsx b/web/src/pages/ai/index.tsx
index 8c8e38a4..e0e645c0 100644
--- a/web/src/pages/ai/index.tsx
+++ b/web/src/pages/ai/index.tsx
@@ -16,6 +16,7 @@
  */
 
 import { useState, useRef, useEffect, useCallback } from 'react';
+import { useLocation, useNavigate } from 'react-router-dom';
 import {
   Card,
   Button,
@@ -29,10 +30,13 @@ import {
   Flex,
   Divider,
   Select,
+  message,
 } from 'antd';
 import { ArrowUp, Sparkle, SlidersHorizontal, CaretDown } from 
'@phosphor-icons/react';
 import type { ColumnsType } from 'antd/es/table';
 import { useLang } from '../../i18n/LangContext';
+import { chatStream } from '../../api/ai';
+import { getChatDraft } from './chatDraft';
 
 const { Text, Paragraph } = Typography;
 
@@ -96,86 +100,7 @@ const modelOptions = [
 
 /* ─── Mock Data ─── */
 
-const topicColumns: ColumnsType<TopicRow> = [
-  { title: 'Topic 名称', dataIndex: 'name', key: 'name' },
-  {
-    title: '类型',
-    dataIndex: 'type',
-    key: 'type',
-    render: (type: string) => {
-      const map: Record<string, { label: string; color: string }> = {
-        NORMAL: { label: '普通', color: 'default' },
-        FIFO: { label: '顺序', color: 'blue' },
-        DELAY: { label: '延迟', color: 'orange' },
-        TRANSACTION: { label: '事务', color: 'purple' },
-      };
-      const cfg = map[type] || { label: type, color: 'default' };
-      return <Tag color={cfg.color}>{cfg.label}</Tag>;
-    },
-  },
-  { title: '队列数', dataIndex: 'queues', key: 'queues', width: 80, align: 
'center' },
-];
-
-const mockTopicData: TopicRow[] = [
-  { key: '1', name: 'order-create', type: 'TRANSACTION', queues: 16 },
-  { key: '2', name: 'payment-notify', type: 'NORMAL', queues: 12 },
-  { key: '3', name: 'user-login-event', type: 'NORMAL', queues: 8 },
-  { key: '4', name: 'inventory-sync', type: 'FIFO', queues: 8 },
-  { key: '5', name: 'promo-push', type: 'DELAY', queues: 4 },
-];
-
-const initialMessages: Message[] = [
-  {
-    id: 'm1',
-    role: 'user',
-    text: '查看生产集群-杭州的 Topic 列表',
-  },
-  {
-    id: 'm2',
-    role: 'ai',
-    toolCall: { name: 'list_topics', label: '🔧 list_topics' },
-    tableData: mockTopicData,
-    tableColumns: topicColumns,
-    summary: '生产集群-杭州 共有 128 个 Topic,以上为按吞吐量排序的前 5 个。',
-  },
-  {
-    id: 'm3',
-    role: 'user',
-    text: 'order-create 这个 Topic 最近 1 小时的堆积情况',
-  },
-  {
-    id: 'm4',
-    role: 'ai',
-    toolCall: { name: 'get_topic_lag', label: '🔧 get_topic_lag' },
-    stats: [
-      { title: '当前堆积', value: '2,340', color: '#1677ff' },
-      { title: '消费速率', value: '856', suffix: '/s', color: '#52c41a' },
-      { title: '预计追平', value: '2.7', suffix: 's', color: '#722ed1' },
-    ],
-    summary: 'order-create 消费状态良好,堆积量较低,消费速率稳定。',
-  },
-  {
-    id: 'm5',
-    role: 'user',
-    text: '帮我创建一个事务类型的 Topic,名称 order-refund',
-  },
-  {
-    id: 'm6',
-    role: 'ai',
-    toolCall: { name: 'create_topic', label: '🔧 create_topic (dry-run)' },
-    descriptions: [
-      { label: '名称', value: 'order-refund' },
-      { label: '类型', value: 'TRANSACTION' },
-      { label: '队列数', value: '16' },
-      { label: '集群', value: '生产集群-杭州' },
-    ],
-    summary: '已生成创建预览,确认后将执行创建操作。',
-    actions: [
-      { label: '确认创建', type: 'primary' },
-      { label: '取消', type: 'default' },
-    ],
-  },
-];
+const initialMessages: Message[] = [];
 
 /* ─── Quick Actions ─── */
 
@@ -346,12 +271,17 @@ const AiMessage = ({ msg }: { msg: Message }) => (
 
 const AiPage = () => {
   const { t } = useLang();
+  const location = useLocation();
+  const navigate = useNavigate();
   const [messages, setMessages] = useState<Message[]>(initialMessages);
   const [inputValue, setInputValue] = useState('');
   const [loading, setLoading] = useState(false);
   const [selectedModel, setSelectedModel] = useState('qwen3.7-max');
   const chatEndRef = useRef<HTMLDivElement>(null);
   const textareaRef = useRef<HTMLTextAreaElement>(null);
+  const abortControllerRef = useRef<AbortController | null>(null);
+  const conversationIdRef = useRef<string | null>(null);
+  const consumedDraftRef = useRef(false);
 
   const scrollToBottom = useCallback(() => {
     chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
@@ -361,6 +291,19 @@ const AiPage = () => {
     scrollToBottom();
   }, [messages, scrollToBottom]);
 
+  useEffect(() => {
+    const draft = getChatDraft(location.state);
+    if (!draft || consumedDraftRef.current) return;
+    consumedDraftRef.current = true;
+
+    void Promise.resolve().then(() => {
+      setInputValue(draft.prompt);
+      if (draft.model) setSelectedModel(draft.model);
+      navigate('/ai', { replace: true, state: null });
+      textareaRef.current?.focus();
+    });
+  }, [location.state, navigate]);
+
   /* ─── Auto-resize textarea ─── */
   useEffect(() => {
     const ta = textareaRef.current;
@@ -373,35 +316,75 @@ const AiPage = () => {
     return () => ta.removeEventListener('input', handler);
   }, []);
 
-  const handleSend = useCallback(() => {
+  useEffect(() => {
+    return () => abortControllerRef.current?.abort();
+  }, []);
+
+  const handleSend = useCallback(async () => {
     const text = inputValue.trim();
     if (!text || loading) return;
 
+    if (!conversationIdRef.current) {
+      conversationIdRef.current = `conversation-${Date.now()}`;
+    }
+
     const userMsg: Message = {
       id: `user-${Date.now()}`,
       role: 'user',
       text,
     };
 
-    setMessages((prev) => [...prev, userMsg]);
+    const responseId = `ai-${Date.now()}`;
+    setMessages((prev) => [...prev, userMsg, { id: responseId, role: 'ai', 
summary: '' }]);
     setInputValue('');
     if (textareaRef.current) {
       textareaRef.current.style.height = 'auto';
     }
     setLoading(true);
-
-    // Mock AI response after brief delay
-    setTimeout(() => {
-      const aiMsg: Message = {
-        id: `ai-${Date.now()}`,
-        role: 'ai',
-        toolCall: { name: 'process_query', label: '🔧 process_query' },
-        summary: `已收到你的问题:「${text}」。AI 助手正在分析并调用相关 MCP 工具,结果将在此处展示。(当前为演示模式)`,
-      };
-      setMessages((prev) => [...prev, aiMsg]);
+    const controller = new AbortController();
+    abortControllerRef.current = controller;
+
+    try {
+      await chatStream(
+        {
+          message: text,
+          mode: 'chat',
+          model: selectedModel,
+          conversationId: conversationIdRef.current,
+        },
+        (chunk) => {
+          setMessages((prev) =>
+            prev.map((item) =>
+              item.id === responseId ? { ...item, summary: `${item.summary ?? 
''}${chunk}` } : item,
+            ),
+          );
+        },
+        controller.signal,
+      );
+    } catch (error) {
+      if (controller.signal.aborted) {
+        setMessages((prev) =>
+          prev.map((item) =>
+            item.id === responseId && !item.summary ? { ...item, summary: 
'回答已停止。' } : item,
+          ),
+        );
+      } else {
+        setMessages((prev) =>
+          prev.map((item) =>
+            item.id === responseId ? { ...item, summary: 'AI 服务暂时不可用,请稍后重试。' } 
: item,
+          ),
+        );
+        message.error(error instanceof Error ? error.message : 'AI 请求失败');
+      }
+    } finally {
+      if (abortControllerRef.current === controller) 
abortControllerRef.current = null;
       setLoading(false);
-    }, 1200);
-  }, [inputValue, loading]);
+    }
+  }, [inputValue, loading, selectedModel]);
+
+  const handleStop = useCallback(() => {
+    abortControllerRef.current?.abort();
+  }, []);
 
   const handleKeyDown = useCallback(
     (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
@@ -609,6 +592,11 @@ const AiPage = () => {
                   >
                     <ArrowUp size={19} weight="bold" />
                   </button>
+                  {loading && (
+                    <Button size="small" onClick={handleStop}>
+                      停止
+                    </Button>
+                  )}
                 </div>
               </div>
             </div>
diff --git a/web/src/pages/cluster/certs.tsx b/web/src/pages/cluster/certs.tsx
index e5155467..dfa0da2c 100644
--- a/web/src/pages/cluster/certs.tsx
+++ b/web/src/pages/cluster/certs.tsx
@@ -31,13 +31,14 @@ import {
   message,
 } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
-import { EditOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons';
+import { EditOutlined, DeleteOutlined, PlusOutlined, SyncOutlined } from 
'@ant-design/icons';
 import PageHeader from '../../components/PageHeader';
 import type { K8sCertInfo } from '../../api/cluster';
 import {
   createK8sCert,
   deleteK8sCert,
   listK8sCerts,
+  renewK8sCert,
   updateK8sCert,
 } from '../../services/clusterService';
 
@@ -56,6 +57,7 @@ const K8sCertsPage = () => {
   const [certs, setCerts] = useState<K8sCertInfo[]>([]);
   const [loading, setLoading] = useState(true);
   const [submitting, setSubmitting] = useState(false);
+  const [renewingId, setRenewingId] = useState<string | null>(null);
   const [certSearch, setCertSearch] = useState('');
   const [certTypeFilter, setCertTypeFilter] = useState<string>('');
   const [editModalOpen, setEditModalOpen] = useState(false);
@@ -136,6 +138,20 @@ const K8sCertsPage = () => {
     return matchSearch && matchType;
   });
 
+  const renewCert = async (cert: K8sCertInfo) => {
+    setRenewingId(cert.id);
+    try {
+      const renewed = await renewK8sCert(cert.id);
+      setCerts((prev) => prev.map((item) => (item.id === renewed.id ? renewed 
: item)));
+      message.success(`证书「${renewed.name}」已续期`);
+    } catch (error) {
+      message.error(getErrorMessage(error));
+      throw error;
+    } finally {
+      setRenewingId(null);
+    }
+  };
+
   const certColumns: ColumnsType<K8sCertInfo> = [
     {
       title: 'K8s 集群名称',
@@ -225,7 +241,7 @@ const K8sCertsPage = () => {
     {
       title: '操作',
       key: 'action',
-      width: 200,
+      width: 270,
       render: (_: unknown, record: K8sCertInfo) => (
         <Flex gap={6}>
           <Button
@@ -247,6 +263,22 @@ const K8sCertsPage = () => {
           >
             编辑
           </Button>
+          <Button
+            size="small"
+            icon={<SyncOutlined />}
+            loading={renewingId === record.id}
+            onClick={() => {
+              Modal.confirm({
+                title: '确认续期',
+                content: `确定要为证书 "${record.name}" 续期一年吗?`,
+                okText: '续期',
+                cancelText: '取消',
+                onOk: () => renewCert(record),
+              });
+            }}
+          >
+            续期
+          </Button>
           <Button
             size="small"
             icon={<DeleteOutlined />}
diff --git a/web/src/pages/home/index.tsx b/web/src/pages/home/index.tsx
index dcd9d2a7..a83e3095 100644
--- a/web/src/pages/home/index.tsx
+++ b/web/src/pages/home/index.tsx
@@ -66,6 +66,7 @@ const modelOptions = [
 const HomePage = () => {
   const [activeMode, setActiveMode] = useState('query');
   const [selectedModel, setSelectedModel] = useState('qwen3.7-max');
+  const [inputValue, setInputValue] = useState('');
   const [indicatorStyle, setIndicatorStyle] = useState({ width: 83, left: 6 });
   const textareaRef = useRef<HTMLTextAreaElement>(null);
   const modeBarRef = useRef<HTMLDivElement>(null);
@@ -117,10 +118,15 @@ const HomePage = () => {
   const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
     if (e.key === 'Enter' && !e.shiftKey) {
       e.preventDefault();
-      navigate('/ai');
+      handlePromptSubmit();
     }
   };
 
+  const handlePromptSubmit = () => {
+    const prompt = inputValue.trim();
+    navigate('/ai', { state: prompt ? { prompt, model: selectedModel } : null 
});
+  };
+
   return (
     <ConfigProvider theme={{ algorithm: theme.defaultAlgorithm }}>
       <div
@@ -321,6 +327,8 @@ const HomePage = () => {
                     ref={textareaRef}
                     className="chat-input"
                     placeholder="向 RocketMQ Bot 提问,全程加密、安全、可信"
+                    value={inputValue}
+                    onChange={(event) => setInputValue(event.target.value)}
                     onKeyDown={handleKeyDown}
                   />
                   <RobotOutlined
@@ -359,7 +367,7 @@ const HomePage = () => {
                       <div className="shrink-0 flex items-center gap-1">
                         <button
                           className="flex items-center justify-center w-9 h-9 
rounded-full bg-gradient-to-r from-purple-500 to-violet-600 text-white 
shadow-lg hover:shadow-xl transition-all hover:scale-105"
-                          onClick={() => navigate('/ai')}
+                          onClick={handlePromptSubmit}
                         >
                           <ArrowUp size={19} weight="bold" />
                         </button>
diff --git a/web/src/stores/authStorage.test.ts 
b/web/src/stores/authStorage.test.ts
new file mode 100644
index 00000000..9348dbed
--- /dev/null
+++ b/web/src/stores/authStorage.test.ts
@@ -0,0 +1,51 @@
+/*
+ * 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 { afterEach, describe, expect, it } from 'vitest';
+import {
+  clearAuthSession,
+  persistAuthSession,
+  readAuthSession,
+  TOKEN_STORAGE_KEY,
+  USER_STORAGE_KEY,
+} from './authStorage';
+
+describe('auth session storage', () => {
+  afterEach(() => {
+    localStorage.clear();
+  });
+
+  it('persists the token and user together', () => {
+    persistAuthSession('token-1', 'studio-admin');
+
+    expect(readAuthSession()).toEqual({ token: 'token-1', user: 'studio-admin' 
});
+  });
+
+  it('does not restore an orphaned user without a token', () => {
+    localStorage.setItem(USER_STORAGE_KEY, 'studio-admin');
+
+    expect(readAuthSession()).toEqual({ token: null, user: null });
+  });
+
+  it('clears every persisted session key', () => {
+    persistAuthSession('token-1', 'studio-admin');
+    clearAuthSession();
+
+    expect(localStorage.getItem(TOKEN_STORAGE_KEY)).toBeNull();
+    expect(localStorage.getItem(USER_STORAGE_KEY)).toBeNull();
+  });
+});
diff --git a/web/src/stores/authStorage.ts b/web/src/stores/authStorage.ts
new file mode 100644
index 00000000..8aa22d57
--- /dev/null
+++ b/web/src/stores/authStorage.ts
@@ -0,0 +1,54 @@
+/*
+ * 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.
+ */
+
+export const TOKEN_STORAGE_KEY = 'token';
+export const USER_STORAGE_KEY = 'rocketmq-studio-user';
+
+export interface AuthSession {
+  token: string | null;
+  user: string | null;
+}
+
+export function readAuthSession(): AuthSession {
+  try {
+    const token = localStorage.getItem(TOKEN_STORAGE_KEY);
+    return {
+      token,
+      user: token ? localStorage.getItem(USER_STORAGE_KEY) : null,
+    };
+  } catch {
+    return { token: null, user: null };
+  }
+}
+
+export function persistAuthSession(token: string, user: string): void {
+  try {
+    localStorage.setItem(TOKEN_STORAGE_KEY, token);
+    localStorage.setItem(USER_STORAGE_KEY, user);
+  } catch {
+    // The in-memory store remains usable when browser storage is unavailable.
+  }
+}
+
+export function clearAuthSession(): void {
+  try {
+    localStorage.removeItem(TOKEN_STORAGE_KEY);
+    localStorage.removeItem(USER_STORAGE_KEY);
+  } catch {
+    // The caller still clears the in-memory store.
+  }
+}

Reply via email to