feat(auth): implement user authentication and password change functionality

This commit is contained in:
samanhappy
2025-04-12 21:55:26 +08:00
parent 929780c415
commit 5532c19305
25 changed files with 1405 additions and 43 deletions

View File

@@ -1,9 +1,14 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { BrowserRouter as Router, Route, Routes, Navigate, useNavigate } from 'react-router-dom'
import { Server, ApiResponse } from './types'
import ServerCard from './components/ServerCard'
import AddServerForm from './components/AddServerForm'
import EditServerForm from './components/EditServerForm'
import LoginPage from './pages/LoginPage'
import ChangePasswordPage from './pages/ChangePasswordPage'
import ProtectedRoute from './components/ProtectedRoute'
import { AuthProvider, useAuth } from './contexts/AuthContext'
// 配置选项
const CONFIG = {
@@ -18,7 +23,8 @@ const CONFIG = {
}
}
function App() {
// Dashboard component that contains the main application
const Dashboard = () => {
const { t } = useTranslation()
const [servers, setServers] = useState<Server[]>([])
const [error, setError] = useState<string | null>(null)
@@ -26,6 +32,8 @@ function App() {
const [editingServer, setEditingServer] = useState<Server | null>(null)
const [isInitialLoading, setIsInitialLoading] = useState(true)
const [fetchAttempts, setFetchAttempts] = useState(0)
const { auth, logout } = useAuth()
const navigate = useNavigate()
// 轮询定时器引用
const intervalRef = useRef<NodeJS.Timeout | null>(null)
@@ -47,7 +55,12 @@ function App() {
const fetchServers = async () => {
try {
const response = await fetch('/api/servers')
const token = localStorage.getItem('mcphub_token');
const response = await fetch('/api/servers', {
headers: {
'x-auth-token': token || ''
}
});
const data = await response.json()
if (data && data.success && Array.isArray(data.data)) {
@@ -95,7 +108,12 @@ function App() {
// 初始化加载阶段的请求函数
const fetchInitialData = async () => {
try {
const response = await fetch('/api/servers')
const token = localStorage.getItem('mcphub_token');
const response = await fetch('/api/servers', {
headers: {
'x-auth-token': token || ''
}
});
const data = await response.json()
// 处理API响应中的包装对象提取data字段
@@ -194,7 +212,12 @@ function App() {
const handleServerEdit = (server: Server) => {
// Fetch settings to get the full server config before editing
fetch(`/api/settings`)
const token = localStorage.getItem('mcphub_token');
fetch(`/api/settings`, {
headers: {
'x-auth-token': token || ''
}
})
.then(response => response.json())
.then((settingsData: ApiResponse<{ mcpServers: Record<string, any> }>) => {
if (
@@ -232,8 +255,12 @@ function App() {
const handleServerRemove = async (serverName: string) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(`/api/servers/${serverName}`, {
method: 'DELETE',
headers: {
'x-auth-token': token || ''
}
})
const result = await response.json()
@@ -248,6 +275,11 @@ function App() {
}
}
const handleLogout = () => {
logout()
navigate('/login')
}
return (
<div className="min-h-screen bg-gray-100 p-8">
<div className="max-w-3xl mx-auto">
@@ -273,7 +305,21 @@ function App() {
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold text-gray-900">{t('app.title')}</h1>
<AddServerForm onAdd={handleServerAdd} />
<div className="flex items-center">
<AddServerForm onAdd={handleServerAdd} />
<button
onClick={() => navigate('/change-password')}
className="ml-4 bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
>
{t('app.changePassword')}
</button>
<button
onClick={handleLogout}
className="ml-4 bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600"
>
{t('app.logout')}
</button>
</div>
</div>
{servers.length === 0 ? (
<div className="bg-white shadow rounded-lg p-6">
@@ -303,4 +349,21 @@ function App() {
)
}
function App() {
return (
<AuthProvider>
<Router>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<ProtectedRoute />}>
<Route path="/" element={<Dashboard />} />
<Route path="/change-password" element={<ChangePasswordPage />} />
</Route>
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</Router>
</AuthProvider>
)
}
export default App

View File

@@ -0,0 +1,158 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ChangePasswordCredentials } from '../types';
import { changePassword } from '../services/authService';
interface ChangePasswordFormProps {
onSuccess?: () => void;
onCancel?: () => void;
}
const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCancel }) => {
const { t } = useTranslation();
const [formData, setFormData] = useState<ChangePasswordCredentials>({
currentPassword: '',
newPassword: '',
});
const [confirmPassword, setConfirmPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
if (name === 'confirmPassword') {
setConfirmPassword(value);
} else {
setFormData(prev => ({ ...prev, [name]: value }));
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
// Validate passwords match
if (formData.newPassword !== confirmPassword) {
setError(t('auth.passwordsNotMatch'));
return;
}
setIsLoading(true);
try {
const response = await changePassword(formData);
if (response.success) {
setSuccess(true);
if (onSuccess) {
onSuccess();
}
} else {
setError(response.message || t('auth.changePasswordError'));
}
} catch (err) {
setError(t('auth.changePasswordError'));
} finally {
setIsLoading(false);
}
};
return (
<div className="p-6 bg-white rounded-lg shadow-md">
<h2 className="text-xl font-bold mb-4">{t('auth.changePassword')}</h2>
{success ? (
<div className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
{t('auth.changePasswordSuccess')}
</div>
) : (
<form onSubmit={handleSubmit}>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{error}
</div>
)}
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="currentPassword">
{t('auth.currentPassword')}
</label>
<input
type="password"
id="currentPassword"
name="currentPassword"
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
value={formData.currentPassword}
onChange={handleChange}
required
/>
</div>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="newPassword">
{t('auth.newPassword')}
</label>
<input
type="password"
id="newPassword"
name="newPassword"
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
value={formData.newPassword}
onChange={handleChange}
required
minLength={6}
/>
</div>
<div className="mb-6">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="confirmPassword">
{t('auth.confirmPassword')}
</label>
<input
type="password"
id="confirmPassword"
name="confirmPassword"
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
value={confirmPassword}
onChange={handleChange}
required
minLength={6}
/>
</div>
<div className="flex justify-end space-x-2">
{onCancel && (
<button
type="button"
onClick={onCancel}
disabled={isLoading}
className="py-2 px-4 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
{t('common.cancel')}
</button>
)}
<button
type="submit"
disabled={isLoading}
className="py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
{isLoading ? (
<span className="flex items-center">
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{t('common.save')}
</span>
) : (
t('common.save')
)}
</button>
</div>
</form>
)}
</div>
);
};
export default ChangePasswordForm;

View File

@@ -0,0 +1,27 @@
import React from 'react';
import { Navigate, Outlet } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../contexts/AuthContext';
interface ProtectedRouteProps {
redirectPath?: string;
}
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
redirectPath = '/login'
}) => {
const { t } = useTranslation();
const { auth } = useAuth();
if (auth.loading) {
return <div className="flex items-center justify-center h-screen">{t('app.loading')}</div>;
}
if (!auth.isAuthenticated) {
return <Navigate to={redirectPath} replace />;
}
return <Outlet />;
};
export default ProtectedRoute;

View File

View File

@@ -0,0 +1,159 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { AuthState, IUser } from '../types';
import * as authService from '../services/authService';
// Initial auth state
const initialState: AuthState = {
token: null,
isAuthenticated: false,
loading: true,
user: null,
error: null,
};
// Create auth context
const AuthContext = createContext<{
auth: AuthState;
login: (username: string, password: string) => Promise<boolean>;
register: (username: string, password: string, isAdmin?: boolean) => Promise<boolean>;
logout: () => void;
}>({
auth: initialState,
login: async () => false,
register: async () => false,
logout: () => {},
});
// Auth provider component
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [auth, setAuth] = useState<AuthState>(initialState);
// Load user if token exists
useEffect(() => {
const loadUser = async () => {
const token = authService.getToken();
if (!token) {
setAuth({
...initialState,
loading: false,
});
return;
}
try {
const response = await authService.getCurrentUser();
if (response.success && response.user) {
setAuth({
token,
isAuthenticated: true,
loading: false,
user: response.user,
error: null,
});
} else {
authService.removeToken();
setAuth({
...initialState,
loading: false,
});
}
} catch (error) {
authService.removeToken();
setAuth({
...initialState,
loading: false,
});
}
};
loadUser();
}, []);
// Login function
const login = async (username: string, password: string): Promise<boolean> => {
try {
const response = await authService.login({ username, password });
if (response.success && response.token && response.user) {
setAuth({
token: response.token,
isAuthenticated: true,
loading: false,
user: response.user,
error: null,
});
return true;
} else {
setAuth({
...initialState,
loading: false,
error: response.message || 'Authentication failed',
});
return false;
}
} catch (error) {
setAuth({
...initialState,
loading: false,
error: 'Authentication failed',
});
return false;
}
};
// Register function
const register = async (
username: string,
password: string,
isAdmin = false
): Promise<boolean> => {
try {
const response = await authService.register({ username, password, isAdmin });
if (response.success && response.token && response.user) {
setAuth({
token: response.token,
isAuthenticated: true,
loading: false,
user: response.user,
error: null,
});
return true;
} else {
setAuth({
...initialState,
loading: false,
error: response.message || 'Registration failed',
});
return false;
}
} catch (error) {
setAuth({
...initialState,
loading: false,
error: 'Registration failed',
});
return false;
}
};
// Logout function
const logout = (): void => {
authService.logout();
setAuth({
...initialState,
loading: false,
});
};
return (
<AuthContext.Provider value={{ auth, login, register, logout }}>
{children}
</AuthContext.Provider>
);
};
// Custom hook to use auth context
export const useAuth = () => useContext(AuthContext);

View File

@@ -3,7 +3,28 @@
"title": "MCP Hub Dashboard",
"error": "Error",
"closeButton": "Close",
"noServers": "No MCP servers available"
"noServers": "No MCP servers available",
"loading": "Loading...",
"logout": "Logout",
"profile": "Profile",
"changePassword": "Change Password"
},
"auth": {
"login": "Login",
"loginTitle": "Login to MCP Hub",
"username": "Username",
"password": "Password",
"loggingIn": "Logging in...",
"emptyFields": "Username and password cannot be empty",
"loginFailed": "Login failed, please check your username and password",
"loginError": "An error occurred during login",
"currentPassword": "Current Password",
"newPassword": "New Password",
"confirmPassword": "Confirm Password",
"passwordsNotMatch": "New password and confirmation do not match",
"changePasswordSuccess": "Password changed successfully",
"changePasswordError": "Failed to change password",
"changePassword": "Change Password"
},
"server": {
"addServer": "Add Server",
@@ -45,5 +66,9 @@
"serverUpdate": "Failed to edit server {{serverName}}. Please check the server status",
"serverFetch": "Failed to retrieve server data. Please try again later",
"initialStartup": "The server might be starting up. Please wait a moment as this process can take some time on first launch..."
},
"common": {
"save": "Save",
"cancel": "Cancel"
}
}

View File

@@ -3,7 +3,28 @@
"title": "MCP Hub 控制面板",
"error": "错误",
"closeButton": "关闭",
"noServers": "没有可用的 MCP 服务器"
"noServers": "没有可用的 MCP 服务器",
"loading": "加载中...",
"logout": "退出登录",
"profile": "个人资料",
"changePassword": "修改密码"
},
"auth": {
"login": "登录",
"loginTitle": "登录 MCP Hub",
"username": "用户名",
"password": "密码",
"loggingIn": "登录中...",
"emptyFields": "用户名和密码不能为空",
"loginFailed": "登录失败,请检查用户名和密码",
"loginError": "登录过程中出现错误",
"currentPassword": "当前密码",
"newPassword": "新密码",
"confirmPassword": "确认密码",
"passwordsNotMatch": "新密码与确认密码不一致",
"changePasswordSuccess": "密码修改成功",
"changePasswordError": "修改密码失败",
"changePassword": "修改密码"
},
"server": {
"addServer": "添加服务器",
@@ -45,5 +66,9 @@
"serverUpdate": "编辑服务器 {{serverName}} 失败,请检查服务器状态",
"serverFetch": "获取服务器数据失败,请稍后重试",
"initialStartup": "服务器可能正在启动中。首次启动可能需要一些时间,请耐心等候..."
},
"common": {
"save": "保存",
"cancel": "取消"
}
}

View File

@@ -0,0 +1,30 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import ChangePasswordForm from '../components/ChangePasswordForm';
const ChangePasswordPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const handleSuccess = () => {
setTimeout(() => {
navigate('/');
}, 2000);
};
const handleCancel = () => {
navigate('/');
};
return (
<div className="container mx-auto px-4 py-8">
<div className="max-w-md mx-auto">
<h1 className="text-2xl font-bold mb-6">{t('auth.changePassword')}</h1>
<ChangePasswordForm onSuccess={handleSuccess} onCancel={handleCancel} />
</div>
</div>
);
};
export default ChangePasswordPage;

View File

@@ -0,0 +1,104 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../contexts/AuthContext';
const LoginPage: React.FC = () => {
const { t } = useTranslation();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
if (!username || !password) {
setError(t('auth.emptyFields'));
setLoading(false);
return;
}
const success = await login(username, password);
if (success) {
navigate('/');
} else {
setError(t('auth.loginFailed'));
}
} catch (err) {
setError(t('auth.loginError'));
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
{t('auth.loginTitle')}
</h2>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="rounded-md shadow-sm -space-y-px">
<div>
<label htmlFor="username" className="sr-only">
{t('auth.username')}
</label>
<input
id="username"
name="username"
type="text"
autoComplete="username"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder={t('auth.username')}
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div>
<label htmlFor="password" className="sr-only">
{t('auth.password')}
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder={t('auth.password')}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
</div>
{error && (
<div className="text-red-500 text-sm text-center">{error}</div>
)}
<div>
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
{loading ? t('auth.loggingIn') : t('auth.login')}
</button>
</div>
</form>
</div>
</div>
);
};
export default LoginPage;

View File

@@ -0,0 +1,141 @@
import { AuthResponse, LoginCredentials, RegisterCredentials, ChangePasswordCredentials } from '../types';
// Base URL for API requests
const API_URL = '';
// Token key in localStorage
const TOKEN_KEY = 'mcphub_token';
// Get token from localStorage
export const getToken = (): string | null => {
return localStorage.getItem(TOKEN_KEY);
};
// Set token in localStorage
export const setToken = (token: string): void => {
localStorage.setItem(TOKEN_KEY, token);
};
// Remove token from localStorage
export const removeToken = (): void => {
localStorage.removeItem(TOKEN_KEY);
};
// Login user
export const login = async (credentials: LoginCredentials): Promise<AuthResponse> => {
try {
const response = await fetch(`${API_URL}/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(credentials),
});
const data: AuthResponse = await response.json();
if (data.success && data.token) {
setToken(data.token);
}
return data;
} catch (error) {
console.error('Login error:', error);
return {
success: false,
message: 'An error occurred during login',
};
}
};
// Register user
export const register = async (credentials: RegisterCredentials): Promise<AuthResponse> => {
try {
const response = await fetch(`${API_URL}/auth/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(credentials),
});
const data: AuthResponse = await response.json();
if (data.success && data.token) {
setToken(data.token);
}
return data;
} catch (error) {
console.error('Register error:', error);
return {
success: false,
message: 'An error occurred during registration',
};
}
};
// Get current user
export const getCurrentUser = async (): Promise<AuthResponse> => {
const token = getToken();
if (!token) {
return {
success: false,
message: 'No authentication token',
};
}
try {
const response = await fetch(`${API_URL}/auth/user`, {
method: 'GET',
headers: {
'x-auth-token': token,
},
});
return await response.json();
} catch (error) {
console.error('Get current user error:', error);
return {
success: false,
message: 'An error occurred while fetching user data',
};
}
};
// Change password
export const changePassword = async (credentials: ChangePasswordCredentials): Promise<AuthResponse> => {
const token = getToken();
if (!token) {
return {
success: false,
message: 'No authentication token',
};
}
try {
const response = await fetch(`${API_URL}/auth/change-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token,
},
body: JSON.stringify(credentials),
});
return await response.json();
} catch (error) {
console.error('Change password error:', error);
return {
success: false,
message: 'An error occurred while changing password',
};
}
};
// Logout user
export const logout = (): void => {
removeToken();
};

View File

@@ -52,4 +52,40 @@ export interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
// Auth types
export interface IUser {
username: string;
isAdmin?: boolean;
}
export interface AuthState {
token: string | null;
isAuthenticated: boolean;
loading: boolean;
user: IUser | null;
error: string | null;
}
export interface LoginCredentials {
username: string;
password: string;
}
export interface RegisterCredentials extends LoginCredentials {
isAdmin?: boolean;
}
export interface ChangePasswordCredentials {
currentPassword: string;
newPassword: string;
}
export interface AuthResponse {
success: boolean;
token?: string;
user?: IUser;
message?: string;
errors?: Array<{ msg: string }>;
}

0
frontend/src/utils/cn.ts Normal file
View File

View File

@@ -17,6 +17,10 @@ export default defineConfig({
target: 'http://localhost:3000',
changeOrigin: true,
},
'/auth': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
});