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

@@ -53,3 +53,39 @@ export interface ApiResponse<T> {
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,
},
},
},
});

View File

@@ -1,4 +1,11 @@
{
"users": [
{
"username": "admin",
"password": "$2b$10$Vt7krIvjNgyN67LXqly0uOcTpN0LI55cYRbcKC71pUDAP0nJ7RPa.",
"isAdmin": true
}
],
"mcpServers": {
"amap-maps": {
"command": "npx",

View File

@@ -31,18 +31,22 @@
"@types/react": "^19.0.12",
"@types/react-dom": "^19.0.4",
"autoprefixer": "^10.4.21",
"bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-validator": "^7.2.1",
"i18next": "^24.2.3",
"i18next-browser-languagedetector": "^8.0.4",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.486.0",
"next": "^15.2.4",
"postcss": "^8.5.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-i18next": "^15.4.1",
"react-router-dom": "^7.5.0",
"tailwind-merge": "^3.1.0",
"tailwind-scrollbar-hide": "^2.0.0",
"tailwindcss": "^4.0.17",
@@ -50,8 +54,10 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.3",
"@types/bcryptjs": "^3.0.0",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.5",
"@types/jsonwebtoken": "^9.0.9",
"@types/node": "^20.8.2",
"@typescript-eslint/eslint-plugin": "^6.7.4",
"@typescript-eslint/parser": "^6.7.4",

230
pnpm-lock.yaml generated
View File

@@ -32,6 +32,9 @@ importers:
autoprefixer:
specifier: ^10.4.21
version: 10.4.21(postcss@8.5.3)
bcryptjs:
specifier: ^3.0.2
version: 3.0.2
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -44,12 +47,18 @@ importers:
express:
specifier: ^4.18.2
version: 4.21.2
express-validator:
specifier: ^7.2.1
version: 7.2.1
i18next:
specifier: ^24.2.3
version: 24.2.3(typescript@5.8.2)
i18next-browser-languagedetector:
specifier: ^8.0.4
version: 8.0.4
jsonwebtoken:
specifier: ^9.0.2
version: 9.0.2
lucide-react:
specifier: ^0.486.0
version: 0.486.0(react@19.1.0)
@@ -68,6 +77,9 @@ importers:
react-i18next:
specifier: ^15.4.1
version: 15.4.1(i18next@24.2.3(typescript@5.8.2))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react-router-dom:
specifier: ^7.5.0
version: 7.5.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
tailwind-merge:
specifier: ^3.1.0
version: 3.1.0
@@ -84,12 +96,18 @@ importers:
'@tailwindcss/postcss':
specifier: ^4.1.3
version: 4.1.3
'@types/bcryptjs':
specifier: ^3.0.0
version: 3.0.0
'@types/express':
specifier: ^4.17.21
version: 4.17.21
'@types/jest':
specifier: ^29.5.5
version: 29.5.14
'@types/jsonwebtoken':
specifier: ^9.0.9
version: 9.0.9
'@types/node':
specifier: ^20.8.2
version: 20.17.28
@@ -669,79 +687,67 @@ packages:
resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.0.5':
resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.0.4':
resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.0.4':
resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.0.4':
resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.0.4':
resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.33.5':
resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.33.5':
resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.33.5':
resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.33.5':
resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.33.5':
resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.33.5':
resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.33.5':
resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==}
@@ -879,28 +885,24 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@15.2.4':
resolution: {integrity: sha512-Oioa0SORWLwi35/kVB8aCk5Uq+5/ZIumMK1kJV+jSdazFm2NzPDztsefzdmzzpx5oGCJ6FkUC7vkaUseNTStNA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@15.2.4':
resolution: {integrity: sha512-yb5WTRaHdkgOqFOZiu6rHV1fAEK0flVpaIN2HB6kxHVSy/dIajWbThS7qON3W9/SNOH2JWkVCyulgGYekMePuw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@15.2.4':
resolution: {integrity: sha512-Dcdv/ix6srhkM25fgXiyOieFUkz+fOYkHlydWCtB0xMST6X9XYI3yPDKBZt1xuhOytONsIFJFB08xXYsxUwJLw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@15.2.4':
resolution: {integrity: sha512-dW0i7eukvDxtIhCYkMrZNQfNicPDExt2jPb9AZPpL7cfyUo7QSNl1DjsHjmmKp6qNAqUESyT8YFl/Aw91cNJJg==}
@@ -1100,67 +1102,56 @@ packages:
resolution: {integrity: sha512-mz5POx5Zu58f2xAG5RaRRhp3IZDK7zXGk5sdEDj4o96HeaXhlUwmLFzNlc4hCQi5sGdR12VDgEUqVSHer0lI9g==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.39.0':
resolution: {integrity: sha512-+YDwhM6gUAyakl0CD+bMFpdmwIoRDzZYaTWV3SDRBGkMU/VpIBYXXEvkEcTagw/7VVkL2vA29zU4UVy1mP0/Yw==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.39.0':
resolution: {integrity: sha512-EKf7iF7aK36eEChvlgxGnk7pdJfzfQbNvGV/+l98iiMwU23MwvmV0Ty3pJ0p5WQfm3JRHOytSIqD9LB7Bq7xdQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.39.0':
resolution: {integrity: sha512-vYanR6MtqC7Z2SNr8gzVnzUul09Wi1kZqJaek3KcIlI/wq5Xtq4ZPIZ0Mr/st/sv/NnaPwy/D4yXg5x0B3aUUA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loongarch64-gnu@4.39.0':
resolution: {integrity: sha512-NMRUT40+h0FBa5fb+cpxtZoGAggRem16ocVKIv5gDB5uLDgBIwrIsXlGqYbLwW8YyO3WVTk1FkFDjMETYlDqiw==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-powerpc64le-gnu@4.39.0':
resolution: {integrity: sha512-0pCNnmxgduJ3YRt+D+kJ6Ai/r+TaePu9ZLENl+ZDV/CdVczXl95CbIiwwswu4L+K7uOIGf6tMo2vm8uadRaICQ==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.39.0':
resolution: {integrity: sha512-t7j5Zhr7S4bBtksT73bO6c3Qa2AV/HqiGlj9+KB3gNF5upcVkx+HLgxTm8DK4OkzsOYqbdqbLKwvGMhylJCPhQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.39.0':
resolution: {integrity: sha512-m6cwI86IvQ7M93MQ2RF5SP8tUjD39Y7rjb1qjHgYh28uAPVU8+k/xYWvxRO3/tBN2pZkSMa5RjnPuUIbrwVxeA==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.39.0':
resolution: {integrity: sha512-iRDJd2ebMunnk2rsSBYlsptCyuINvxUfGwOUldjv5M4tpa93K8tFMeYGpNk2+Nxl+OBJnBzy2/JCscGeO507kA==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.39.0':
resolution: {integrity: sha512-t9jqYw27R6Lx0XKfEFe5vUeEJ5pF3SGIM6gTfONSMb7DuG6z6wfj2yjcoZxHg129veTqU7+wOhY6GX8wmf90dA==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.39.0':
resolution: {integrity: sha512-ThFdkrFDP55AIsIZDKSBWEt/JcWlCzydbZHinZ0F/r1h83qbGeenCt/G/wG2O0reuENDD2tawfAj2s8VK7Bugg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-win32-arm64-msvc@4.39.0':
resolution: {integrity: sha512-jDrLm6yUtbOg2TYB3sBF3acUnAwsIksEYjLeHL+TJv9jg+TmTwdyjnDex27jqEMakNKf3RwwPahDIt7QXCSqRQ==}
@@ -1234,28 +1225,24 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.1.3':
resolution: {integrity: sha512-dm18aQiML5QCj9DQo7wMbt1Z2tl3Giht54uVR87a84X8qRtuXxUqnKQkRDK5B4bCOmcZ580lF9YcoMkbDYTXHQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.1.3':
resolution: {integrity: sha512-LMdTmGe/NPtGOaOfV2HuO7w07jI3cflPrVq5CXl+2O93DCewADK0uW1ORNAcfu2YxDUS035eY2W38TxrsqngxA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.1.3':
resolution: {integrity: sha512-aalNWwIi54bbFEizwl1/XpmdDrOaCjRFQRgtbv9slWjmNPuJJTIKPHf5/XXDARc9CneW9FkSTqTbyvNecYAEGw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-win32-arm64-msvc@4.1.3':
resolution: {integrity: sha512-PEj7XR4OGTGoboTIAdXicKuWl4EQIjKHKuR+bFy9oYN7CFZo0eu74+70O4XuERX4yjqVZGAkCdglBODlgqcCXg==}
@@ -1305,12 +1292,19 @@ packages:
'@types/babel__traverse@7.20.7':
resolution: {integrity: sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==}
'@types/bcryptjs@3.0.0':
resolution: {integrity: sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==}
deprecated: This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed.
'@types/body-parser@1.19.5':
resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==}
'@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
'@types/cookie@0.6.0':
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
'@types/estree@1.0.7':
resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==}
@@ -1341,9 +1335,15 @@ packages:
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/jsonwebtoken@9.0.9':
resolution: {integrity: sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==}
'@types/mime@1.3.5':
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/node@20.17.28':
resolution: {integrity: sha512-DHlH/fNL6Mho38jTy7/JT7sn2wnXI+wULR6PV4gy4VHLVvnrV/d3pHAMQHhc4gjdLmK2ZiPoMxzp6B3yRajLSQ==}
@@ -1558,6 +1558,10 @@ packages:
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
bcryptjs@3.0.2:
resolution: {integrity: sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==}
hasBin: true
binary-extensions@2.3.0:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
@@ -1595,6 +1599,9 @@ packages:
bser@2.1.1:
resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==}
buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
@@ -1740,6 +1747,10 @@ packages:
resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==}
engines: {node: '>= 0.6'}
cookie@1.0.2:
resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==}
engines: {node: '>=18'}
cors@2.8.5:
resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==}
engines: {node: '>= 0.10'}
@@ -1854,6 +1865,9 @@ packages:
dynamic-dedupe@0.3.0:
resolution: {integrity: sha512-ssuANeD+z97meYOqd50e04Ze5qp4bPqo8cCkI4TRjZkzAUgIDTrXV1R8QCdINpiI+hw14+rYazvTRdQrz0/rFQ==}
ecdsa-sig-formatter@1.0.11:
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
@@ -1997,6 +2011,10 @@ packages:
peerDependencies:
express: ^4.11 || 5 || ^5.0.0-beta.1
express-validator@7.2.1:
resolution: {integrity: sha512-CjNE6aakfpuwGaHQZ3m8ltCG2Qvivd7RHtVMS/6nVxOM7xVGqr4bhflsm4+N5FP5zI7Zxp+Hae+9RE+o8e3ZOQ==}
engines: {node: '>= 8.0.0'}
express@4.21.2:
resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==}
engines: {node: '>= 0.10.0'}
@@ -2495,6 +2513,16 @@ packages:
jsonfile@6.1.0:
resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
jsonwebtoken@9.0.2:
resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==}
engines: {node: '>=12', npm: '>=6'}
jwa@1.4.1:
resolution: {integrity: sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==}
jws@3.2.2:
resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==}
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -2539,28 +2567,24 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.29.2:
resolution: {integrity: sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.29.2:
resolution: {integrity: sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.29.2:
resolution: {integrity: sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.29.2:
resolution: {integrity: sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==}
@@ -2589,12 +2613,33 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
lodash.includes@4.3.0:
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
lodash.isboolean@3.0.3:
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
lodash.isinteger@4.0.4:
resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
lodash.isnumber@3.0.3:
resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
lodash.isplainobject@4.0.6:
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
lodash.isstring@4.0.1:
resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
lodash.memoize@4.1.2:
resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==}
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
lodash.once@4.1.1:
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
@@ -2977,6 +3022,23 @@ packages:
resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==}
engines: {node: '>=0.10.0'}
react-router-dom@7.5.0:
resolution: {integrity: sha512-fFhGFCULy4vIseTtH5PNcY/VvDJK5gvOWcwJVHQp8JQcWVr85ENhJ3UpuF/zP1tQOIFYNRJHzXtyhU1Bdgw0RA==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: '>=18'
react-dom: '>=18'
react-router@7.5.0:
resolution: {integrity: sha512-estOHrRlDMKdlQa6Mj32gIks4J+AxNsYoE0DbTTxiMy2mPzZuWSDU+N85/r1IlNR7kGfznF3VCUlvc5IUO+B9g==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: '>=18'
react-dom: '>=18'
peerDependenciesMeta:
react-dom:
optional: true
react@19.1.0:
resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==}
engines: {node: '>=0.10.0'}
@@ -3087,6 +3149,9 @@ packages:
resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==}
engines: {node: '>= 18'}
set-cookie-parser@2.7.1:
resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==}
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
@@ -3345,6 +3410,9 @@ packages:
engines: {node: '>=18.0.0'}
hasBin: true
turbo-stream@2.4.0:
resolution: {integrity: sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==}
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@@ -3412,6 +3480,10 @@ packages:
resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==}
engines: {node: '>=10.12.0'}
validator@13.12.0:
resolution: {integrity: sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==}
engines: {node: '>= 0.10'}
vary@1.1.2:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
@@ -4538,6 +4610,10 @@ snapshots:
dependencies:
'@babel/types': 7.27.0
'@types/bcryptjs@3.0.0':
dependencies:
bcryptjs: 3.0.2
'@types/body-parser@1.19.5':
dependencies:
'@types/connect': 3.4.38
@@ -4547,6 +4623,8 @@ snapshots:
dependencies:
'@types/node': 20.17.28
'@types/cookie@0.6.0': {}
'@types/estree@1.0.7': {}
'@types/express-serve-static-core@4.19.6':
@@ -4586,8 +4664,15 @@ snapshots:
'@types/json-schema@7.0.15': {}
'@types/jsonwebtoken@9.0.9':
dependencies:
'@types/ms': 2.1.0
'@types/node': 20.17.28
'@types/mime@1.3.5': {}
'@types/ms@2.1.0': {}
'@types/node@20.17.28':
dependencies:
undici-types: 6.19.8
@@ -4857,6 +4942,8 @@ snapshots:
base64-js@1.5.1: {}
bcryptjs@3.0.2: {}
binary-extensions@2.3.0: {}
bl@5.1.0:
@@ -4924,6 +5011,8 @@ snapshots:
dependencies:
node-int64: 0.4.0
buffer-equal-constant-time@1.0.1: {}
buffer-from@1.1.2: {}
buffer@6.0.3:
@@ -5058,6 +5147,8 @@ snapshots:
cookie@0.7.1: {}
cookie@1.0.2: {}
cors@2.8.5:
dependencies:
object-assign: 4.1.1
@@ -5148,6 +5239,10 @@ snapshots:
dependencies:
xtend: 4.0.2
ecdsa-sig-formatter@1.0.11:
dependencies:
safe-buffer: 5.2.1
ee-first@1.1.1: {}
ejs@3.1.10:
@@ -5359,6 +5454,11 @@ snapshots:
dependencies:
express: 5.0.1
express-validator@7.2.1:
dependencies:
lodash: 4.17.21
validator: 13.12.0
express@4.21.2:
dependencies:
accepts: 1.3.8
@@ -6101,6 +6201,30 @@ snapshots:
optionalDependencies:
graceful-fs: 4.2.11
jsonwebtoken@9.0.2:
dependencies:
jws: 3.2.2
lodash.includes: 4.3.0
lodash.isboolean: 3.0.3
lodash.isinteger: 4.0.4
lodash.isnumber: 3.0.3
lodash.isplainobject: 4.0.6
lodash.isstring: 4.0.1
lodash.once: 4.1.1
ms: 2.1.3
semver: 7.7.1
jwa@1.4.1:
dependencies:
buffer-equal-constant-time: 1.0.1
ecdsa-sig-formatter: 1.0.11
safe-buffer: 5.2.1
jws@3.2.2:
dependencies:
jwa: 1.4.1
safe-buffer: 5.2.1
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
@@ -6169,10 +6293,24 @@ snapshots:
dependencies:
p-locate: 5.0.0
lodash.includes@4.3.0: {}
lodash.isboolean@3.0.3: {}
lodash.isinteger@4.0.4: {}
lodash.isnumber@3.0.3: {}
lodash.isplainobject@4.0.6: {}
lodash.isstring@4.0.1: {}
lodash.memoize@4.1.2: {}
lodash.merge@4.6.2: {}
lodash.once@4.1.1: {}
lodash@4.17.21: {}
log-symbols@5.1.0:
@@ -6498,6 +6636,22 @@ snapshots:
react-refresh@0.14.2: {}
react-router-dom@7.5.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
react-router: 7.5.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react-router@7.5.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
'@types/cookie': 0.6.0
cookie: 1.0.2
react: 19.1.0
set-cookie-parser: 2.7.1
turbo-stream: 2.4.0
optionalDependencies:
react-dom: 19.1.0(react@19.1.0)
react@19.1.0: {}
readable-stream@3.6.2:
@@ -6653,6 +6807,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
set-cookie-parser@2.7.1: {}
setprototypeof@1.2.0: {}
sharp@0.33.5:
@@ -6917,6 +7073,8 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
turbo-stream@2.4.0: {}
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
@@ -6970,6 +7128,8 @@ snapshots:
'@types/istanbul-lib-coverage': 2.0.6
convert-source-map: 2.0.0
validator@13.12.0: {}
vary@1.1.2: {}
vite@5.4.17(@types/node@20.17.28)(lightningcss@1.29.2):

View File

@@ -23,7 +23,7 @@ export const loadSettings = (): McpSettings => {
return JSON.parse(settingsData);
} catch (error) {
console.error(`Failed to load settings from ${settingsPath}:`, error);
return { mcpServers: {} };
return { mcpServers: {}, users: [] };
}
};

View File

@@ -0,0 +1,179 @@
import { Request, Response } from 'express';
import jwt from 'jsonwebtoken';
import { validationResult } from 'express-validator';
import { findUserByUsername, verifyPassword, createUser, updateUserPassword } from '../models/User.js';
// Default secret key - in production, use an environment variable
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-this';
const TOKEN_EXPIRY = '24h';
// Login user
export const login = async (req: Request, res: Response): Promise<void> => {
// Validate request
const errors = validationResult(req);
if (!errors.isEmpty()) {
res.status(400).json({ success: false, errors: errors.array() });
return;
}
const { username, password } = req.body;
try {
// Find user by username
const user = findUserByUsername(username);
if (!user) {
res.status(401).json({ success: false, message: 'Invalid credentials' });
return;
}
// Verify password
const isPasswordValid = await verifyPassword(password, user.password);
if (!isPasswordValid) {
res.status(401).json({ success: false, message: 'Invalid credentials' });
return;
}
// Generate JWT token
const payload = {
user: {
username: user.username,
isAdmin: user.isAdmin || false
}
};
jwt.sign(
payload,
JWT_SECRET,
{ expiresIn: TOKEN_EXPIRY },
(err, token) => {
if (err) throw err;
res.json({
success: true,
token,
user: {
username: user.username,
isAdmin: user.isAdmin
}
});
}
);
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ success: false, message: 'Server error' });
}
};
// Register new user
export const register = async (req: Request, res: Response): Promise<void> => {
// Validate request
const errors = validationResult(req);
if (!errors.isEmpty()) {
res.status(400).json({ success: false, errors: errors.array() });
return;
}
const { username, password, isAdmin } = req.body;
try {
// Create new user
const newUser = await createUser({ username, password, isAdmin });
if (!newUser) {
res.status(400).json({ success: false, message: 'User already exists' });
return;
}
// Generate JWT token
const payload = {
user: {
username: newUser.username,
isAdmin: newUser.isAdmin || false
}
};
jwt.sign(
payload,
JWT_SECRET,
{ expiresIn: TOKEN_EXPIRY },
(err, token) => {
if (err) throw err;
res.json({
success: true,
token,
user: {
username: newUser.username,
isAdmin: newUser.isAdmin
}
});
}
);
} catch (error) {
console.error('Registration error:', error);
res.status(500).json({ success: false, message: 'Server error' });
}
};
// Get current user
export const getCurrentUser = (req: Request, res: Response): void => {
try {
// User is already attached to request by auth middleware
const user = (req as any).user;
res.json({
success: true,
user: {
username: user.username,
isAdmin: user.isAdmin
}
});
} catch (error) {
console.error('Get current user error:', error);
res.status(500).json({ success: false, message: 'Server error' });
}
};
// Change password
export const changePassword = async (req: Request, res: Response): Promise<void> => {
// Validate request
const errors = validationResult(req);
if (!errors.isEmpty()) {
res.status(400).json({ success: false, errors: errors.array() });
return;
}
const { currentPassword, newPassword } = req.body;
const username = (req as any).user.username;
try {
// Find user by username
const user = findUserByUsername(username);
if (!user) {
res.status(404).json({ success: false, message: 'User not found' });
return;
}
// Verify current password
const isPasswordValid = await verifyPassword(currentPassword, user.password);
if (!isPasswordValid) {
res.status(401).json({ success: false, message: 'Current password is incorrect' });
return;
}
// Update the password
const updated = await updateUserPassword(username, newPassword);
if (!updated) {
res.status(500).json({ success: false, message: 'Failed to update password' });
return;
}
res.json({ success: true, message: 'Password updated successfully' });
} catch (error) {
console.error('Change password error:', error);
res.status(500).json({ success: false, message: 'Server error' });
}
};

28
src/middlewares/auth.ts Normal file
View File

@@ -0,0 +1,28 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
// Default secret key - in production, use an environment variable
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-this';
// Middleware to authenticate JWT token
export const auth = (req: Request, res: Response, next: NextFunction): void => {
// Get token from header
const token = req.header('x-auth-token');
// Check if no token
if (!token) {
res.status(401).json({ success: false, message: 'No token, authorization denied' });
return;
}
// Verify token
try {
const decoded = jwt.verify(token, JWT_SECRET);
// Add user from payload to request
(req as any).user = (decoded as any).user;
next();
} catch (error) {
res.status(401).json({ success: false, message: 'Token is not valid' });
}
};

View File

@@ -1,5 +1,7 @@
import express, { Request, Response, NextFunction } from 'express';
import path from 'path';
import { auth } from './auth.js';
import { initializeDefaultUser } from '../models/User.js';
export const errorHandler = (
err: Error,
@@ -25,6 +27,14 @@ export const initMiddlewares = (app: express.Application): void => {
}
});
// Initialize default admin user if no users exist
initializeDefaultUser().catch(err => {
console.error('Error initializing default user:', err);
});
// Protect all API routes with authentication middleware
app.use('/api', auth);
app.get('/', (_req: Request, res: Response) => {
res.sendFile(path.join(process.cwd(), 'frontend', 'dist', 'index.html'));
});

103
src/models/User.ts Normal file
View File

@@ -0,0 +1,103 @@
import fs from 'fs';
import path from 'path';
import bcrypt from 'bcryptjs';
import { IUser, McpSettings } from '../types/index.js';
import { loadSettings, saveSettings } from '../config/index.js';
// Get all users
export const getUsers = (): IUser[] => {
try {
const settings = loadSettings();
return settings.users || [];
} catch (error) {
console.error('Error reading users from settings:', error);
return [];
}
};
// Save users to settings
const saveUsers = (users: IUser[]): void => {
try {
const settings = loadSettings();
settings.users = users;
saveSettings(settings);
} catch (error) {
console.error('Error saving users to settings:', error);
}
};
// Create a new user
export const createUser = async (userData: IUser): Promise<IUser | null> => {
const users = getUsers();
// Check if username already exists
if (users.some(user => user.username === userData.username)) {
return null;
}
// Hash the password
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(userData.password, salt);
const newUser = {
username: userData.username,
password: hashedPassword,
isAdmin: userData.isAdmin || false
};
users.push(newUser);
saveUsers(users);
return newUser;
};
// Find user by username
export const findUserByUsername = (username: string): IUser | undefined => {
const users = getUsers();
return users.find(user => user.username === username);
};
// Verify user password
export const verifyPassword = async (
plainPassword: string,
hashedPassword: string
): Promise<boolean> => {
return await bcrypt.compare(plainPassword, hashedPassword);
};
// Update user password
export const updateUserPassword = async (
username: string,
newPassword: string
): Promise<boolean> => {
const users = getUsers();
const userIndex = users.findIndex(user => user.username === username);
if (userIndex === -1) {
return false;
}
// Hash the new password
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(newPassword, salt);
// Update the user's password
users[userIndex].password = hashedPassword;
saveUsers(users);
return true;
};
// Initialize with default admin user if no users exist
export const initializeDefaultUser = async (): Promise<void> => {
const users = getUsers();
if (users.length === 0) {
await createUser({
username: 'admin',
password: 'admin123',
isAdmin: true
});
console.log('Default admin user created');
}
};

View File

@@ -1,4 +1,5 @@
import express from 'express';
import { check } from 'express-validator';
import {
getAllServers,
getAllSettings,
@@ -6,16 +7,44 @@ import {
updateServer,
deleteServer,
} from '../controllers/serverController.js';
import {
login,
register,
getCurrentUser,
changePassword
} from '../controllers/authController.js';
import { auth } from '../middlewares/auth.js';
const router = express.Router();
export const initRoutes = (app: express.Application): void => {
// API routes protected by auth middleware in middlewares/index.ts
router.get('/servers', getAllServers);
router.get('/settings', getAllSettings);
router.post('/servers', createServer);
router.put('/servers/:name', updateServer);
router.delete('/servers/:name', deleteServer);
// Auth routes (these will NOT be protected by auth middleware)
app.post('/auth/login', [
check('username', 'Username is required').not().isEmpty(),
check('password', 'Password is required').not().isEmpty(),
], login);
app.post('/auth/register', [
check('username', 'Username is required').not().isEmpty(),
check('password', 'Password must be at least 6 characters').isLength({ min: 6 }),
], register);
app.get('/auth/user', auth, getCurrentUser);
// Add change password route
app.post('/auth/change-password', [
auth,
check('currentPassword', 'Current password is required').not().isEmpty(),
check('newPassword', 'New password must be at least 6 characters').isLength({ min: 6 }),
], changePassword);
app.use('/api', router);
};

View File

@@ -4,6 +4,8 @@ import { initMcpServer, registerAllTools } from './services/mcpService.js';
import { initMiddlewares } from './middlewares/index.js';
import { initRoutes } from './routes/index.js';
import { handleSseConnection, handleSseMessage } from './services/sseService.js';
import { migrateUserData } from './utils/migration.js';
import { initializeDefaultUser } from './models/User.js';
export class AppServer {
private app: express.Application;
@@ -16,6 +18,12 @@ export class AppServer {
async initialize(): Promise<void> {
try {
// Migrate user data from users.json to mcp_settings.json if needed
migrateUserData();
// Initialize default admin user if no users exist
await initializeDefaultUser();
const mcpServer = await initMcpServer(config.mcpHubName, config.mcpHubVersion);
await registerAllTools(mcpServer, true);
initMiddlewares(this.app);

View File

@@ -2,8 +2,16 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
// User interface
export interface IUser {
username: string;
password: string;
isAdmin?: boolean;
}
// Represents the settings for MCP servers
export interface McpSettings {
users?: IUser[]; // Array of user credentials and permissions
mcpServers: {
[key: string]: ServerConfig; // Key-value pairs of server names and their configurations
};

52
src/utils/migration.ts Normal file
View File

@@ -0,0 +1,52 @@
// filepath: /Users/sunmeng/code/github/mcphub/src/utils/migration.ts
import fs from 'fs';
import path from 'path';
import { loadSettings, saveSettings } from '../config/index.js';
import { IUser } from '../types/index.js';
/**
* Migrates user data from the old users.json file to mcp_settings.json
* This is a one-time migration to support the refactoring from separate
* users.json to integrated user data in mcp_settings.json
*/
export const migrateUserData = (): void => {
const oldUsersFilePath = path.join(process.cwd(), 'data', 'users.json');
// Check if the old users file exists
if (fs.existsSync(oldUsersFilePath)) {
try {
// Read users from the old file
const usersData = fs.readFileSync(oldUsersFilePath, 'utf8');
const users = JSON.parse(usersData) as IUser[];
if (users && Array.isArray(users) && users.length > 0) {
console.log(`Migrating ${users.length} users from users.json to mcp_settings.json`);
// Load current settings
const settings = loadSettings();
// Merge users, giving priority to existing settings users
const existingUsernames = new Set((settings.users || []).map(u => u.username));
const newUsers = users.filter(u => !existingUsernames.has(u.username));
settings.users = [...(settings.users || []), ...newUsers];
// Save updated settings
if (saveSettings(settings)) {
console.log('User data migration completed successfully');
// Rename the old file as backup
const backupPath = `${oldUsersFilePath}.bak.${Date.now()}`;
fs.renameSync(oldUsersFilePath, backupPath);
console.log(`Renamed old users file to ${backupPath}`);
}
} else {
console.log('No users found in users.json, skipping migration');
}
} catch (error) {
console.error('Error during user data migration:', error);
}
} else {
console.log('users.json not found, no migration needed');
}
};