Bläddra i källkod

feat(auth): 添加用户注册功能和登录 UI 优化

- 登录对话框添加登录/注册 Tab 切换
- 实现完整的注册表单(邮箱、用户名、密码、确认密码)
- 登录后显示用户菜单(头像、用户名、退出按钮)
- 实现退出登录功能(清除 localStorage)
- 添加注册 API 代理端点 /api/auth/register
- 优化 MCP 状态显示(已登录/需要登录)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude AI 13 timmar sedan
förälder
incheckning
7aac2e4d74
2 ändrade filer med 342 tillägg och 8 borttagningar
  1. 56 0
      backend/app.py
  2. 286 8
      frontend/index.html

+ 56 - 0
backend/app.py

@@ -442,6 +442,62 @@ def login():
         return jsonify({"error": str(e)}), 500
 
 
+@app.route('/api/auth/register', methods=['POST'])
+def register():
+    """
+    Novel Platform 用户注册
+    代理到实际的注册端点
+    """
+    try:
+        data = request.json
+        email = data.get('email')
+        username = data.get('username')
+        password = data.get('password')
+
+        if not email or not username or not password:
+            return jsonify({"error": "Email, username and password are required"}), 400
+
+        # 查找需要 JWT 认证的 MCP 服务器
+        target_server = None
+        for server_id, config in MCP_SERVERS.items():
+            if config.get('auth_type') == 'jwt' and 'base_url' in config:
+                # 优先使用 user 类型的服务器
+                if 'user' in server_id:
+                    target_server = config
+                    break
+                elif not target_server:
+                    target_server = config
+
+        if not target_server:
+            return jsonify({"error": "No JWT-authenticated server configured"}), 400
+
+        # 构建注册 URL
+        base_url = target_server.get('base_url', '')
+        register_url = f"{base_url}/api/v1/auth/register"
+
+        # 调用实际的注册接口
+        response = httpx.post(
+            register_url,
+            json={"email": email, "username": username, "password": password},
+            timeout=30.0
+        )
+
+        if response.status_code == 200:
+            result = response.json()
+            return jsonify({
+                "success": True,
+                "message": result.get("message", "注册成功")
+            })
+        else:
+            return jsonify({
+                "error": "Registration failed",
+                "details": response.text
+            }), response.status_code
+
+    except Exception as e:
+        return jsonify({"error": str(e)}), 500
+
+
 @app.route('/api/auth/admin-login', methods=['POST'])
 def admin_login():
     """

+ 286 - 8
frontend/index.html

@@ -400,9 +400,41 @@
                         <p class="text-sm text-gray-500">通用 MCP 服务器 Web 界面</p>
                     </div>
                 </div>
-                <div id="status-indicator" class="flex items-center space-x-2">
-                    <span class="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
-                    <span class="text-sm text-gray-600">已连接</span>
+                <div class="flex items-center space-x-4">
+                    <!-- 用户菜单 (登录后显示) -->
+                    <div id="user-menu" class="hidden relative">
+                        <button id="user-menu-btn" class="flex items-center space-x-2 px-3 py-1.5 bg-purple-50 rounded-lg hover:bg-purple-100 transition-colors">
+                            <div class="w-8 h-8 bg-gradient-to-br from-purple-500 to-indigo-600 rounded-full flex items-center justify-center">
+                                <span id="user-avatar" class="text-white text-sm font-bold">U</span>
+                            </div>
+                            <span id="user-name" class="text-sm font-medium text-gray-700">用户</span>
+                            <svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
+                            </svg>
+                        </button>
+                        <!-- 下拉菜单 -->
+                        <div id="user-dropdown" class="hidden absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50">
+                            <div class="px-4 py-2 border-b border-gray-100">
+                                <p class="text-xs text-gray-500">已登录</p>
+                                <p id="dropdown-username" class="text-sm font-medium text-gray-800">用户名</p>
+                            </div>
+                            <button id="logout-btn" class="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 transition-colors flex items-center space-x-2">
+                                <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path>
+                                </svg>
+                                <span>退出登录</span>
+                            </button>
+                        </div>
+                    </div>
+                    <!-- 登录按钮 (未登录时显示) -->
+                    <button id="header-login-btn" class="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium">
+                        登录
+                    </button>
+                    <!-- 状态指示器 -->
+                    <div id="status-indicator" class="flex items-center space-x-2">
+                        <span class="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
+                        <span class="text-sm text-gray-600">已连接</span>
+                    </div>
                 </div>
             </div>
         </header>
@@ -457,14 +489,27 @@
         </div>
     </div>
 
-    <!-- 登录对话框 -->
+    <!-- 登录/注册对话框 -->
     <div id="login-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
         <div class="bg-white rounded-lg p-6 max-w-md w-full mx-4 shadow-2xl">
             <div class="flex items-center justify-between mb-4">
                 <h3 class="text-xl font-bold text-gray-800" id="login-title">登录 Novel Platform</h3>
                 <button id="login-close-x" class="text-gray-500 hover:text-gray-700 text-2xl">&times;</button>
             </div>
+
+            <!-- Tab 切换 -->
+            <div class="flex border-b border-gray-200 mb-4">
+                <button id="tab-login" class="flex-1 py-2 text-center font-medium text-purple-600 border-b-2 border-purple-600 transition-colors">
+                    登录
+                </button>
+                <button id="tab-register" class="flex-1 py-2 text-center font-medium text-gray-500 hover:text-gray-700 transition-colors">
+                    注册
+                </button>
+            </div>
+
             <p id="login-description" class="text-sm text-gray-600 mb-4">请输入您的账号密码以访问需要认证的 MCP 工具</p>
+
+            <!-- 登录表单 -->
             <form id="login-form">
                 <div class="mb-3">
                     <label for="login-username" class="block text-sm font-medium text-gray-700 mb-1">用户名/邮箱</label>
@@ -480,6 +525,32 @@
                     <button type="submit" id="login-submit" class="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">登录</button>
                 </div>
             </form>
+
+            <!-- 注册表单 (默认隐藏) -->
+            <form id="register-form" class="hidden">
+                <div class="mb-3">
+                    <label for="register-email" class="block text-sm font-medium text-gray-700 mb-1">邮箱</label>
+                    <input type="email" id="register-email" placeholder="请输入邮箱" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent">
+                </div>
+                <div class="mb-3">
+                    <label for="register-username" class="block text-sm font-medium text-gray-700 mb-1">用户名</label>
+                    <input type="text" id="register-username" placeholder="请输入用户名" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent">
+                </div>
+                <div class="mb-3">
+                    <label for="register-password" class="block text-sm font-medium text-gray-700 mb-1">密码</label>
+                    <input type="password" id="register-password" placeholder="请输入密码" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent">
+                </div>
+                <div class="mb-3">
+                    <label for="register-confirm-password" class="block text-sm font-medium text-gray-700 mb-1">确认密码</label>
+                    <input type="password" id="register-confirm-password" placeholder="请再次输入密码" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent">
+                </div>
+                <p id="register-error" class="text-red-500 text-sm mb-3 hidden"></p>
+                <p id="register-success" class="text-green-500 text-sm mb-3 hidden"></p>
+                <div class="flex justify-end gap-2">
+                    <button type="button" id="register-cancel" class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">取消</button>
+                    <button type="submit" id="register-submit" class="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">注册</button>
+                </div>
+            </form>
         </div>
     </div>
 
@@ -561,15 +632,48 @@
             }
         };
 
-        // ========== 登录相关 ==========
+        // ========== 登录/注册相关 ==========
         let currentLoginMcpType = null;
+        let currentAuthTab = 'login'; // 'login' or 'register'
+        let currentUser = null; // 当前登录用户
+
         const loginModal = document.getElementById('login-modal');
         const loginForm = document.getElementById('login-form');
+        const registerForm = document.getElementById('register-form');
         const loginUsername = document.getElementById('login-username');
         const loginPassword = document.getElementById('login-password');
         const loginError = document.getElementById('login-error');
         const loginTitle = document.getElementById('login-title');
         const loginSubmit = document.getElementById('login-submit');
+        const loginDescription = document.getElementById('login-description');
+
+        // Tab 切换
+        const tabLogin = document.getElementById('tab-login');
+        const tabRegister = document.getElementById('tab-register');
+
+        function switchTab(tab) {
+            currentAuthTab = tab;
+            if (tab === 'login') {
+                tabLogin.classList.add('text-purple-600', 'border-b-2', 'border-purple-600');
+                tabLogin.classList.remove('text-gray-500');
+                tabRegister.classList.remove('text-purple-600', 'border-b-2', 'border-purple-600');
+                tabRegister.classList.add('text-gray-500');
+                loginForm.classList.remove('hidden');
+                registerForm.classList.add('hidden');
+                loginDescription.textContent = '请输入您的账号密码以访问需要认证的 MCP 工具';
+            } else {
+                tabRegister.classList.add('text-purple-600', 'border-b-2', 'border-purple-600');
+                tabRegister.classList.remove('text-gray-500');
+                tabLogin.classList.remove('text-purple-600', 'border-b-2', 'border-purple-600');
+                tabLogin.classList.add('text-gray-500');
+                registerForm.classList.remove('hidden');
+                loginForm.classList.add('hidden');
+                loginDescription.textContent = '创建新账号以访问需要认证的 MCP 工具';
+            }
+        }
+
+        tabLogin.addEventListener('click', () => switchTab('login'));
+        tabRegister.addEventListener('click', () => switchTab('register'));
 
         // 显示登录对话框
         function showLoginModal(mcpType) {
@@ -577,13 +681,23 @@
             const config = MCP_SERVERS[mcpType];
             if (!config) return;
 
-            loginTitle.textContent = `登录 ${config.name}`;
+            loginTitle.textContent = config.name;
+            // 重置为登录 Tab
+            switchTab('login');
             loginUsername.value = '';
             loginPassword.value = '';
             loginError.classList.add('hidden');
             loginSubmit.disabled = false;
             loginModal.classList.remove('hidden');
             loginUsername.focus();
+
+            // 清空注册表单
+            document.getElementById('register-email').value = '';
+            document.getElementById('register-username').value = '';
+            document.getElementById('register-password').value = '';
+            document.getElementById('register-confirm-password').value = '';
+            document.getElementById('register-error').classList.add('hidden');
+            document.getElementById('register-success').classList.add('hidden');
         }
 
         // 隐藏登录对话框
@@ -617,8 +731,11 @@
 
                 if (response.ok && data.success) {
                     const token = data.token;
-                    TokenManager.saveToken(mcpType, token, data.username || username);
-                    return { success: true, token, username: data.username || username };
+                    const loggedUsername = data.username || username;
+                    TokenManager.saveToken(mcpType, token, loggedUsername);
+                    currentUser = loggedUsername;
+                    updateUserMenu();
+                    return { success: true, token, username: loggedUsername };
                 } else {
                     return { success: false, error: data.detail || data.error || '登录失败' };
                 }
@@ -627,6 +744,31 @@
             }
         }
 
+        // 执行注册
+        async function performRegister(email, username, password) {
+            try {
+                const response = await fetch('/api/auth/register', {
+                    method: 'POST',
+                    headers: { 'Content-Type': 'application/json' },
+                    body: JSON.stringify({
+                        email: email,
+                        username: username,
+                        password: password
+                    })
+                });
+
+                const data = await response.json();
+
+                if (response.ok) {
+                    return { success: true, message: data.message || '注册成功' };
+                } else {
+                    return { success: false, error: data.detail || data.error || '注册失败' };
+                }
+            } catch (e) {
+                return { success: false, error: e.message };
+            }
+        }
+
         // 登录表单提交处理
         loginForm.addEventListener('submit', async (e) => {
             e.preventDefault();
@@ -656,8 +798,71 @@
             }
         });
 
+        // 注册表单提交处理
+        registerForm.addEventListener('submit', async (e) => {
+            e.preventDefault();
+
+            const email = document.getElementById('register-email').value.trim();
+            const username = document.getElementById('register-username').value.trim();
+            const password = document.getElementById('register-password').value;
+            const confirmPassword = document.getElementById('register-confirm-password').value;
+            const registerError = document.getElementById('register-error');
+            const registerSuccess = document.getElementById('register-success');
+            const registerSubmit = document.getElementById('register-submit');
+
+            // 验证输入
+            if (!email || !username || !password || !confirmPassword) {
+                registerError.textContent = '请填写所有字段';
+                registerError.classList.remove('hidden');
+                registerSuccess.classList.add('hidden');
+                return;
+            }
+
+            // 验证邮箱格式
+            const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+            if (!emailRegex.test(email)) {
+                registerError.textContent = '请输入有效的邮箱地址';
+                registerError.classList.remove('hidden');
+                registerSuccess.classList.add('hidden');
+                return;
+            }
+
+            // 验证密码匹配
+            if (password !== confirmPassword) {
+                registerError.textContent = '两次输入的密码不一致';
+                registerError.classList.remove('hidden');
+                registerSuccess.classList.add('hidden');
+                return;
+            }
+
+            // 验证密码长度
+            if (password.length < 6) {
+                registerError.textContent = '密码长度至少为 6 位';
+                registerError.classList.remove('hidden');
+                registerSuccess.classList.add('hidden');
+                return;
+            }
+
+            registerSubmit.disabled = true;
+            registerError.classList.add('hidden');
+            registerSuccess.classList.add('hidden');
+
+            const result = await performRegister(email, username, password);
+
+            if (result.success) {
+                registerSuccess.textContent = result.message + ',请切换到登录页面登录';
+                registerSuccess.classList.remove('hidden');
+                registerSubmit.disabled = false;
+            } else {
+                registerError.textContent = result.error;
+                registerError.classList.remove('hidden');
+                registerSubmit.disabled = false;
+            }
+        });
+
         // 登录对话框事件绑定
         document.getElementById('login-cancel').addEventListener('click', hideLoginModal);
+        document.getElementById('register-cancel').addEventListener('click', hideLoginModal);
         document.getElementById('login-close-x').addEventListener('click', hideLoginModal);
 
         // 点击背景关闭
@@ -667,6 +872,79 @@
             }
         });
 
+        // ========== 用户菜单相关 ==========
+        const userMenu = document.getElementById('user-menu');
+        const userMenuBtn = document.getElementById('user-menu-btn');
+        const userDropdown = document.getElementById('user-dropdown');
+        const headerLoginBtn = document.getElementById('header-login-btn');
+        const logoutBtn = document.getElementById('logout-btn');
+
+        // 更新用户菜单显示状态
+        function updateUserMenu() {
+            // 检查是否有任何 MCP 服务器已登录
+            let isLoggedIn = false;
+            let loggedInUsername = null;
+
+            for (const [mcpType, config] of Object.entries(MCP_SERVERS)) {
+                if (config.auth_type === 'jwt' && TokenManager.isLoggedIn(mcpType)) {
+                    isLoggedIn = true;
+                    loggedInUsername = TokenManager.getUsername(mcpType);
+                    break;
+                }
+            }
+
+            if (isLoggedIn) {
+                userMenu.classList.remove('hidden');
+                headerLoginBtn.classList.add('hidden');
+
+                // 更新用户信息显示
+                if (loggedInUsername) {
+                    document.getElementById('user-name').textContent = loggedInUsername;
+                    document.getElementById('dropdown-username').textContent = loggedInUsername;
+                    document.getElementById('user-avatar').textContent = loggedInUsername.charAt(0).toUpperCase();
+                    currentUser = loggedInUsername;
+                }
+            } else {
+                userMenu.classList.add('hidden');
+                headerLoginBtn.classList.remove('hidden');
+                currentUser = null;
+            }
+        }
+
+        // 用户菜单按钮点击 - 切换下拉菜单
+        userMenuBtn.addEventListener('click', (e) => {
+            e.stopPropagation();
+            userDropdown.classList.toggle('hidden');
+        });
+
+        // 点击其他地方关闭下拉菜单
+        document.addEventListener('click', () => {
+            if (!userDropdown.classList.contains('hidden')) {
+                userDropdown.classList.add('hidden');
+            }
+        });
+
+        // 头部登录按钮点击
+        headerLoginBtn.addEventListener('click', () => {
+            // 显示登录对话框,默认登录到 novel-platform-user
+            showLoginModal('novel-platform-user');
+        });
+
+        // 退出登录
+        logoutBtn.addEventListener('click', () => {
+            // 清除所有 MCP 的 token
+            for (const mcpType of Object.keys(MCP_SERVERS)) {
+                TokenManager.clearToken(mcpType);
+            }
+            currentUser = null;
+            updateUserMenu();
+            loadMCPServers(); // 刷新 MCP 服务器列表
+            userDropdown.classList.add('hidden');
+        });
+
+        // 初始化用户菜单状态
+        updateUserMenu();
+
         // ========== Token 传递到后端 ==========
         // 为每个请求添加 Authorization header
         async function fetchWithAuth(url, options = {}) {