mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-24 02:39:19 -05:00
feat(auth): implement user authentication and password change functionality
This commit is contained in:
@@ -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
|
||||
158
frontend/src/components/ChangePasswordForm.tsx
Normal file
158
frontend/src/components/ChangePasswordForm.tsx
Normal 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;
|
||||
27
frontend/src/components/ProtectedRoute.tsx
Normal file
27
frontend/src/components/ProtectedRoute.tsx
Normal 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;
|
||||
0
frontend/src/components/ui/Button.tsx
Normal file
0
frontend/src/components/ui/Button.tsx
Normal file
159
frontend/src/contexts/AuthContext.tsx
Normal file
159
frontend/src/contexts/AuthContext.tsx
Normal 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);
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": "取消"
|
||||
}
|
||||
}
|
||||
30
frontend/src/pages/ChangePasswordPage.tsx
Normal file
30
frontend/src/pages/ChangePasswordPage.tsx
Normal 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;
|
||||
104
frontend/src/pages/LoginPage.tsx
Normal file
104
frontend/src/pages/LoginPage.tsx
Normal 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;
|
||||
141
frontend/src/services/authService.ts
Normal file
141
frontend/src/services/authService.ts
Normal 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();
|
||||
};
|
||||
@@ -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
0
frontend/src/utils/cn.ts
Normal file
@@ -17,6 +17,10 @@ export default defineConfig({
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/auth': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user