mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-24 10:49:35 -05:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7af3c8a2ba | ||
|
|
7c43ca359e | ||
|
|
2bb6302cbc | ||
|
|
3a3f6c984c | ||
|
|
e8bc053788 | ||
|
|
bb674236c7 |
15
.github/FUNDING.yml
vendored
Normal file
15
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: samanhappy
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
7
.github/workflows/npm-publish.yml
vendored
7
.github/workflows/npm-publish.yml
vendored
@@ -14,13 +14,13 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
node-version: '20'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
@@ -42,13 +42,10 @@ jobs:
|
||||
|
||||
- name: Update version from tag
|
||||
run: |
|
||||
# 提取标签版本号(移除 'v' 前缀)
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
echo "Updating package.json version to $VERSION"
|
||||
# 使用 jq 更新 package.json 中的版本号
|
||||
jq ".version = \"$VERSION\"" package.json > package.json.tmp
|
||||
mv package.json.tmp package.json
|
||||
# 显示更新后的版本号
|
||||
echo "Updated version in package.json:"
|
||||
grep -m 1 "version" package.json
|
||||
|
||||
|
||||
@@ -34,7 +34,6 @@ RUN if [ "$INSTALL_EXT" = "true" ]; then \
|
||||
fi
|
||||
|
||||
RUN uv tool install mcp-server-fetch
|
||||
ENV UV_PYTHON_INSTALL_MIRROR="http://mirrors.aliyun.com/pypi/simple/"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -203,6 +203,9 @@ proxy_buffering off
|
||||
|
||||
<img src="assets/reward.png" width="350">
|
||||
|
||||
## 致谢
|
||||
感谢以下人员的赞赏:小白、琛。你们的支持是我继续前进的动力!
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
本项目采用 [Apache 2.0 许可证](LICENSE)。
|
||||
|
||||
@@ -18,6 +18,5 @@ if [ -n "$HTTPS_PROXY" ]; then
|
||||
fi
|
||||
|
||||
echo "Using REQUEST_TIMEOUT: $REQUEST_TIMEOUT"
|
||||
echo "Using UV_PYTHON_INSTALL_MIRROR: $UV_PYTHON_INSTALL_MIRROR"
|
||||
|
||||
exec "$@"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ChevronDown, ChevronRight, Edit, Trash, Copy, Check } from 'lucide-react'
|
||||
import { ChevronDown, ChevronRight, Edit, Trash, Copy, Check, User, Settings, LogOut, Info } from 'lucide-react'
|
||||
|
||||
export { ChevronDown, ChevronRight, Edit, Trash, Copy, Check }
|
||||
export { ChevronDown, ChevronRight, Edit, Trash, Copy, Check, User, Settings, LogOut, Info }
|
||||
|
||||
const LucideIcons = {
|
||||
ChevronDown,
|
||||
@@ -8,7 +8,11 @@ const LucideIcons = {
|
||||
Edit,
|
||||
Trash,
|
||||
Copy,
|
||||
Check
|
||||
Check,
|
||||
User,
|
||||
Settings,
|
||||
LogOut,
|
||||
Info
|
||||
}
|
||||
|
||||
export default LucideIcons
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import ThemeSwitch from '@/components/ui/ThemeSwitch';
|
||||
|
||||
@@ -10,20 +9,14 @@ interface HeaderProps {
|
||||
|
||||
const Header: React.FC<HeaderProps> = ({ onToggleSidebar }) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { auth, logout } = useAuth();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
const { auth } = useAuth();
|
||||
|
||||
return (
|
||||
<header className="bg-white dark:bg-gray-800 shadow-sm z-10">
|
||||
<div className="flex justify-between items-center px-4 py-3">
|
||||
<div className="flex items-center">
|
||||
{/* 侧边栏切换按钮 */}
|
||||
<button
|
||||
<button
|
||||
onClick={onToggleSidebar}
|
||||
className="p-2 rounded-md text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none"
|
||||
aria-label={t('app.toggleSidebar')}
|
||||
@@ -32,30 +25,15 @@ const Header: React.FC<HeaderProps> = ({ onToggleSidebar }) => {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
|
||||
{/* 应用标题 */}
|
||||
<h1 className="ml-4 text-xl font-bold text-gray-900 dark:text-white">{t('app.title')}</h1>
|
||||
</div>
|
||||
|
||||
{/* 用户信息和操作 */}
|
||||
|
||||
{/* Theme Switch */}
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* Theme Switch */}
|
||||
<ThemeSwitch />
|
||||
|
||||
{auth.user && (
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{t('app.welcomeUser', { username: auth.user.username })}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="px-3 py-1.5 bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-100 rounded hover:bg-red-200 dark:hover:bg-red-800 text-sm"
|
||||
>
|
||||
{t('app.logout')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import UserProfileMenu from '@/components/ui/UserProfileMenu';
|
||||
|
||||
interface SidebarProps {
|
||||
collapsed: boolean;
|
||||
@@ -16,6 +17,9 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
|
||||
// Application version from package.json (accessed via Vite environment variables)
|
||||
const appVersion = import.meta.env.PACKAGE_VERSION as string;
|
||||
|
||||
// Menu item configuration
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
@@ -64,42 +68,41 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
label: t('nav.settings'),
|
||||
icon: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`bg-white dark:bg-gray-800 shadow-sm transition-all duration-300 ease-in-out ${
|
||||
className={`bg-white dark:bg-gray-800 shadow-sm transition-all duration-300 ease-in-out flex flex-col h-full relative ${
|
||||
collapsed ? 'w-16' : 'w-64'
|
||||
}`}
|
||||
>
|
||||
<nav className="p-3 space-y-1">
|
||||
{menuItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center px-3 py-2 rounded-md transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`
|
||||
}
|
||||
end={item.path === '/'}
|
||||
>
|
||||
<span className="flex-shrink-0">{item.icon}</span>
|
||||
{!collapsed && <span className="ml-3">{item.label}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
{/* Scrollable navigation area */}
|
||||
<div className="overflow-y-auto flex-grow">
|
||||
<nav className="p-3 space-y-1">
|
||||
{menuItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center px-3 py-2 rounded-md transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`
|
||||
}
|
||||
end={item.path === '/'}
|
||||
>
|
||||
<span className="flex-shrink-0">{item.icon}</span>
|
||||
{!collapsed && <span className="ml-3">{item.label}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* User profile menu fixed at the bottom */}
|
||||
<div className="p-3 bg-white dark:bg-gray-800">
|
||||
<UserProfileMenu collapsed={collapsed} version={appVersion} />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
92
frontend/src/components/ui/AboutDialog.tsx
Normal file
92
frontend/src/components/ui/AboutDialog.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface AboutDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
version: string;
|
||||
}
|
||||
|
||||
const AboutDialog: React.FC<AboutDialogProps> = ({ isOpen, onClose, version }) => {
|
||||
const { t } = useTranslation();
|
||||
const [hasNewVersion, setHasNewVersion] = useState(false);
|
||||
const [latestVersion, setLatestVersion] = useState("");
|
||||
|
||||
// Check if there's a new version available
|
||||
// In a real application, this would make an API call to check for updates
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// Mock implementation - in real implementation, you would fetch from an API
|
||||
const checkForUpdates = async () => {
|
||||
try {
|
||||
// This is a placeholder - in a real app you would call an API
|
||||
// const response = await fetch('/api/check-updates');
|
||||
// const data = await response.json();
|
||||
|
||||
// For demo purposes, we'll just pretend there's a new version
|
||||
// TODO: Replace this placeholder logic with a real API call to check for updates
|
||||
setHasNewVersion(false);
|
||||
setLatestVersion("0.0.28"); // Just a higher version for demo
|
||||
} catch (error) {
|
||||
console.error('Failed to check for updates:', error);
|
||||
}
|
||||
};
|
||||
|
||||
checkForUpdates();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 bg-opacity-30 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg max-w-md w-full">
|
||||
<div className="p-6 relative">
|
||||
{/* Close button (X) in the top-right corner */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-gray-400"
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
|
||||
{t('about.title')}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
{t('about.currentVersion')}:
|
||||
</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{version}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{hasNewVersion && (
|
||||
<div className="bg-blue-50 dark:bg-blue-900 p-3 rounded">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-blue-600 dark:text-blue-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm font-medium text-blue-800 dark:text-blue-200">
|
||||
{t('about.newVersion')} (v{latestVersion})
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AboutDialog;
|
||||
127
frontend/src/components/ui/UserProfileMenu.tsx
Normal file
127
frontend/src/components/ui/UserProfileMenu.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { User, Settings, LogOut, Info } from 'lucide-react';
|
||||
import AboutDialog from './AboutDialog';
|
||||
|
||||
interface UserProfileMenuProps {
|
||||
collapsed: boolean;
|
||||
version: string;
|
||||
}
|
||||
|
||||
const UserProfileMenu: React.FC<UserProfileMenuProps> = ({ collapsed, version }) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { auth, logout } = useAuth();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [showNewVersionInfo, setShowNewVersionInfo] = useState(false);
|
||||
const [showAboutDialog, setShowAboutDialog] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Check for new version - this could be replaced with a real API call
|
||||
useEffect(() => {
|
||||
// Mock implementation - in a real app, you would check against an API
|
||||
const checkForNewVersion = async () => {
|
||||
// For demo purposes, we're just showing the notification
|
||||
// In real implementation, you would compare current version with latest version
|
||||
setShowNewVersionInfo(false);
|
||||
};
|
||||
|
||||
checkForNewVersion();
|
||||
}, []);
|
||||
|
||||
// Close the menu when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSettingsClick = () => {
|
||||
navigate('/settings');
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleLogoutClick = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const handleAboutClick = () => {
|
||||
setShowAboutDialog(true);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className="relative">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`flex ${collapsed ? 'justify-center' : 'items-center'} w-full p-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors rounded-md ${isOpen ? 'bg-gray-100 dark:bg-gray-700' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex-shrink-0 relative">
|
||||
<div className="w-7 h-7 flex items-center justify-center rounded-full border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700">
|
||||
<User className="h-4 w-4 text-gray-700 dark:text-gray-300" />
|
||||
</div>
|
||||
{showNewVersionInfo && (
|
||||
<span className="absolute -top-1 -right-1 block w-2 h-2 bg-red-500 rounded-full"></span>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="ml-3 flex flex-col items-start">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{auth.user?.username || t('auth.user')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute top-0 transform -translate-y-full left-0 w-48 bg-white dark:bg-gray-800 shadow-lg rounded-md py-1 z-50">
|
||||
<button
|
||||
onClick={handleSettingsClick}
|
||||
className="flex items-center w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
{t('nav.settings')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAboutClick}
|
||||
className="flex items-center w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 relative"
|
||||
>
|
||||
<Info className="h-4 w-4 mr-2" />
|
||||
{t('about.title')}
|
||||
({version})
|
||||
{showNewVersionInfo && (
|
||||
<span className="absolute top-2 right-4 block w-2 h-2 bg-red-500 rounded-full"></span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogoutClick}
|
||||
className="flex items-center w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
{t('app.logout')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* About dialog */}
|
||||
<AboutDialog
|
||||
isOpen={showAboutDialog}
|
||||
onClose={() => setShowAboutDialog(false)}
|
||||
version={version}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserProfileMenu;
|
||||
@@ -9,9 +9,14 @@ interface RoutingConfig {
|
||||
enableGroupNameRoute: boolean;
|
||||
}
|
||||
|
||||
interface InstallConfig {
|
||||
pythonIndexUrl: string;
|
||||
}
|
||||
|
||||
interface SystemSettings {
|
||||
systemConfig?: {
|
||||
routing?: RoutingConfig;
|
||||
install?: InstallConfig;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,6 +29,10 @@ export const useSettingsData = () => {
|
||||
enableGroupNameRoute: true,
|
||||
});
|
||||
|
||||
const [installConfig, setInstallConfig] = useState<InstallConfig>({
|
||||
pythonIndexUrl: '',
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
@@ -58,6 +67,12 @@ export const useSettingsData = () => {
|
||||
enableGroupNameRoute: data.data.systemConfig.routing.enableGroupNameRoute ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
if (data.success && data.data?.systemConfig?.install) {
|
||||
setInstallConfig({
|
||||
pythonIndexUrl: data.data.systemConfig.install.pythonIndexUrl || '',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch settings:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to fetch settings');
|
||||
@@ -114,6 +129,53 @@ export const useSettingsData = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Update install configuration
|
||||
const updateInstallConfig = async (key: keyof InstallConfig, value: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('mcphub_token');
|
||||
const response = await fetch('/api/system-config', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token || '',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
install: {
|
||||
[key]: value,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setInstallConfig({
|
||||
...installConfig,
|
||||
[key]: value,
|
||||
});
|
||||
showToast(t('settings.systemConfigUpdated'));
|
||||
return true;
|
||||
} else {
|
||||
showToast(t('errors.failedToUpdateSystemConfig'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update system config:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to update system config');
|
||||
showToast(t('errors.failedToUpdateSystemConfig'));
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch settings when the component mounts or refreshKey changes
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
@@ -121,11 +183,13 @@ export const useSettingsData = () => {
|
||||
|
||||
return {
|
||||
routingConfig,
|
||||
installConfig,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
triggerRefresh,
|
||||
fetchSettings,
|
||||
updateRoutingConfig,
|
||||
updateInstallConfig,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ const MainLayout: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-gray-100 dark:bg-gray-900">
|
||||
<div className="flex flex-col h-screen bg-gray-100 dark:bg-gray-900">
|
||||
{/* 顶部导航 */}
|
||||
<Header onToggleSidebar={toggleSidebar} />
|
||||
|
||||
|
||||
@@ -12,6 +12,16 @@
|
||||
"welcomeUser": "Welcome, {{username}}",
|
||||
"name": "MCP Hub"
|
||||
},
|
||||
"about": {
|
||||
"title": "About",
|
||||
"versionInfo": "MCP Hub Version: {{version}}",
|
||||
"newVersion": "New version available!",
|
||||
"currentVersion": "Current version"
|
||||
},
|
||||
"profile": {
|
||||
"viewProfile": "View profile",
|
||||
"userCenter": "User Center"
|
||||
},
|
||||
"theme": {
|
||||
"title": "Theme",
|
||||
"light": "Light",
|
||||
@@ -105,7 +115,8 @@
|
||||
"delete": "Delete",
|
||||
"copy": "Copy",
|
||||
"copySuccess": "Copied to clipboard",
|
||||
"copyFailed": "Copy failed"
|
||||
"copyFailed": "Copy failed",
|
||||
"close": "Close"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
@@ -137,7 +148,8 @@
|
||||
"account": "Account Settings",
|
||||
"password": "Change Password",
|
||||
"appearance": "Appearance",
|
||||
"routeConfig": "Route Configuration"
|
||||
"routeConfig": "Route Configuration",
|
||||
"installConfig": "Installation Configuration"
|
||||
},
|
||||
"market": {
|
||||
"title": "Server Market - (Data from mcpm.sh)"
|
||||
@@ -232,6 +244,10 @@
|
||||
"enableGlobalRouteDescription": "Allow connections to /sse endpoint without specifying a group ID",
|
||||
"enableGroupNameRoute": "Enable Group Name Route",
|
||||
"enableGroupNameRouteDescription": "Allow connections to /sse endpoint using group names instead of just group IDs",
|
||||
"pythonIndexUrl": "Python Package Repository URL",
|
||||
"pythonIndexUrlDescription": "Set UV_DEFAULT_INDEX environment variable for Python package installation",
|
||||
"pythonIndexUrlPlaceholder": "e.g. https://pypi.org/simple",
|
||||
"installConfig": "Installation Configuration",
|
||||
"systemConfigUpdated": "System configuration updated successfully"
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,16 @@
|
||||
"welcomeUser": "欢迎, {{username}}",
|
||||
"name": "MCP Hub"
|
||||
},
|
||||
"about": {
|
||||
"title": "关于",
|
||||
"versionInfo": "MCP Hub 版本: {{version}}",
|
||||
"newVersion": "有新版本可用!",
|
||||
"currentVersion": "当前版本"
|
||||
},
|
||||
"profile": {
|
||||
"viewProfile": "查看个人中心",
|
||||
"userCenter": "个人中心"
|
||||
},
|
||||
"theme": {
|
||||
"title": "主题",
|
||||
"light": "浅色",
|
||||
@@ -106,7 +116,8 @@
|
||||
"delete": "删除",
|
||||
"copy": "复制",
|
||||
"copySuccess": "已复制到剪贴板",
|
||||
"copyFailed": "复制失败"
|
||||
"copyFailed": "复制失败",
|
||||
"close": "关闭"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "仪表盘",
|
||||
@@ -135,7 +146,8 @@
|
||||
"account": "账户设置",
|
||||
"password": "修改密码",
|
||||
"appearance": "外观",
|
||||
"routeConfig": "路由配置"
|
||||
"routeConfig": "路由配置",
|
||||
"installConfig": "安装配置"
|
||||
},
|
||||
"groups": {
|
||||
"title": "分组管理"
|
||||
@@ -230,9 +242,13 @@
|
||||
},
|
||||
"settings": {
|
||||
"enableGlobalRoute": "启用全局路由",
|
||||
"enableGlobalRouteDescription": "允许不指定分组 ID 就连接到 /sse 端点",
|
||||
"enableGroupNameRoute": "启用分组名称路由",
|
||||
"enableGroupNameRouteDescription": "允许使用分组名称而非分组 ID 连接到 /sse 端点",
|
||||
"enableGlobalRouteDescription": "允许不指定组 ID 就连接到 /sse 端点",
|
||||
"enableGroupNameRoute": "启用组名路由",
|
||||
"enableGroupNameRouteDescription": "允许使用组名而不仅仅是组 ID 连接到 /sse 端点",
|
||||
"pythonIndexUrl": "Python 包仓库地址",
|
||||
"pythonIndexUrlDescription": "设置 UV_DEFAULT_INDEX 环境变量,用于 Python 包安装",
|
||||
"pythonIndexUrlPlaceholder": "例如: https://mirrors.aliyun.com/pypi/simple",
|
||||
"installConfig": "安装配置",
|
||||
"systemConfigUpdated": "系统配置更新成功"
|
||||
}
|
||||
}
|
||||
@@ -17,18 +17,34 @@ const SettingsPage: React.FC = () => {
|
||||
setCurrentLanguage(i18n.language);
|
||||
}, [i18n.language]);
|
||||
|
||||
const [installConfig, setInstallConfig] = useState<{
|
||||
pythonIndexUrl: string;
|
||||
}>({
|
||||
pythonIndexUrl: '',
|
||||
});
|
||||
|
||||
const {
|
||||
routingConfig,
|
||||
installConfig: savedInstallConfig,
|
||||
loading,
|
||||
updateRoutingConfig
|
||||
updateRoutingConfig,
|
||||
updateInstallConfig
|
||||
} = useSettingsData();
|
||||
|
||||
// Update local installConfig when savedInstallConfig changes
|
||||
useEffect(() => {
|
||||
if (savedInstallConfig) {
|
||||
setInstallConfig(savedInstallConfig);
|
||||
}
|
||||
}, [savedInstallConfig]);
|
||||
|
||||
const [sectionsVisible, setSectionsVisible] = useState({
|
||||
routingConfig: false,
|
||||
installConfig: false,
|
||||
password: false
|
||||
});
|
||||
|
||||
const toggleSection = (section: 'routingConfig' | 'password') => {
|
||||
const toggleSection = (section: 'routingConfig' | 'installConfig' | 'password') => {
|
||||
setSectionsVisible(prev => ({
|
||||
...prev,
|
||||
[section]: !prev[section]
|
||||
@@ -39,6 +55,17 @@ const SettingsPage: React.FC = () => {
|
||||
await updateRoutingConfig(key, value);
|
||||
};
|
||||
|
||||
const handleInstallConfigChange = (value: string) => {
|
||||
setInstallConfig({
|
||||
...installConfig,
|
||||
pythonIndexUrl: value
|
||||
});
|
||||
};
|
||||
|
||||
const saveInstallConfig = async () => {
|
||||
await updateInstallConfig('pythonIndexUrl', installConfig.pythonIndexUrl);
|
||||
};
|
||||
|
||||
const handlePasswordChangeSuccess = () => {
|
||||
setTimeout(() => {
|
||||
navigate('/');
|
||||
@@ -124,6 +151,47 @@ const SettingsPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Installation Configuration Settings */}
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleSection('installConfig')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('settings.installConfig')}</h2>
|
||||
<span className="text-gray-500">
|
||||
{sectionsVisible.installConfig ? '▼' : '►'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{sectionsVisible.installConfig && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.pythonIndexUrl')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.pythonIndexUrlDescription')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={installConfig.pythonIndexUrl}
|
||||
onChange={(e) => handleInstallConfigChange(e.target.value)}
|
||||
placeholder={t('settings.pythonIndexUrlPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={saveInstallConfig}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Change Password */}
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
|
||||
<div
|
||||
|
||||
9
frontend/src/vite-env.d.ts
vendored
Normal file
9
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: {
|
||||
readonly PACKAGE_VERSION: string;
|
||||
// Add other custom env variables here if needed
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,13 @@ import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
// Import the package.json to get the version
|
||||
import { readFileSync } from 'fs';
|
||||
|
||||
// Get package.json version
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(path.resolve(__dirname, '../package.json'), 'utf-8')
|
||||
);
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
@@ -11,6 +18,10 @@ export default defineConfig({
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
define: {
|
||||
// Make package version available as global variable
|
||||
'import.meta.env.PACKAGE_VERSION': JSON.stringify(packageJson.version),
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@samanhappy/mcphub",
|
||||
"version": "0.0.27",
|
||||
"version": "0.5.4",
|
||||
"description": "A hub server for mcp servers",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
@@ -79,7 +79,7 @@
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-i18next": "^15.4.1",
|
||||
"react-router-dom": "^7.5.0",
|
||||
"react-router-dom": "^7.6.0",
|
||||
"tailwind-merge": "^3.1.0",
|
||||
"tailwind-scrollbar-hide": "^2.0.0",
|
||||
"tailwindcss": "^4.0.17",
|
||||
@@ -92,5 +92,6 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@10.10.0+sha256.fa0f513aa8191764d2b6b432420788c270f07b4f999099b65bb2010eec702a30"
|
||||
}
|
||||
|
||||
30
pnpm-lock.yaml
generated
30
pnpm-lock.yaml
generated
@@ -124,8 +124,8 @@ importers:
|
||||
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)
|
||||
specifier: ^7.6.0
|
||||
version: 7.6.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
tailwind-merge:
|
||||
specifier: ^3.1.0
|
||||
version: 3.1.0
|
||||
@@ -1308,9 +1308,6 @@ packages:
|
||||
'@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==}
|
||||
|
||||
@@ -3032,15 +3029,15 @@ packages:
|
||||
resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
react-router-dom@7.5.0:
|
||||
resolution: {integrity: sha512-fFhGFCULy4vIseTtH5PNcY/VvDJK5gvOWcwJVHQp8JQcWVr85ENhJ3UpuF/zP1tQOIFYNRJHzXtyhU1Bdgw0RA==}
|
||||
react-router-dom@7.6.0:
|
||||
resolution: {integrity: sha512-DYgm6RDEuKdopSyGOWZGtDfSm7Aofb8CCzgkliTjtu/eDuB0gcsv6qdFhhi8HdtmA+KHkt5MfZ5K2PdzjugYsA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
react: '>=18'
|
||||
react-dom: '>=18'
|
||||
|
||||
react-router@7.5.0:
|
||||
resolution: {integrity: sha512-estOHrRlDMKdlQa6Mj32gIks4J+AxNsYoE0DbTTxiMy2mPzZuWSDU+N85/r1IlNR7kGfznF3VCUlvc5IUO+B9g==}
|
||||
react-router@7.6.0:
|
||||
resolution: {integrity: sha512-GGufuHIVCJDbnIAXP3P9Sxzq3UUsddG3rrI3ut1q6m0FI6vxVBF3JoPQ38+W/blslLH4a5Yutp8drkEpXoddGQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
react: '>=18'
|
||||
@@ -3420,9 +3417,6 @@ 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'}
|
||||
@@ -4637,8 +4631,6 @@ 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':
|
||||
@@ -6652,19 +6644,17 @@ 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):
|
||||
react-router-dom@7.6.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.6.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):
|
||||
react-router@7.6.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)
|
||||
|
||||
@@ -7089,8 +7079,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
turbo-stream@2.4.0: {}
|
||||
|
||||
type-check@0.4.0:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
|
||||
@@ -247,9 +247,10 @@ export const toggleServer = async (req: Request, res: Response): Promise<void> =
|
||||
|
||||
export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { routing } = req.body;
|
||||
const { routing, install } = req.body;
|
||||
|
||||
if (!routing || (typeof routing.enableGlobalRoute !== 'boolean' && typeof routing.enableGroupNameRoute !== 'boolean')) {
|
||||
if ((!routing || (typeof routing.enableGlobalRoute !== 'boolean' && typeof routing.enableGroupNameRoute !== 'boolean'))
|
||||
&& (!install || typeof install.pythonIndexUrl !== 'string')) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Invalid system configuration provided',
|
||||
@@ -263,6 +264,9 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true
|
||||
},
|
||||
install: {
|
||||
pythonIndexUrl: ''
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -274,12 +278,26 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof routing.enableGlobalRoute === 'boolean') {
|
||||
settings.systemConfig.routing.enableGlobalRoute = routing.enableGlobalRoute;
|
||||
if (!settings.systemConfig.install) {
|
||||
settings.systemConfig.install = {
|
||||
pythonIndexUrl: ''
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof routing.enableGroupNameRoute === 'boolean') {
|
||||
settings.systemConfig.routing.enableGroupNameRoute = routing.enableGroupNameRoute;
|
||||
if (routing) {
|
||||
if (typeof routing.enableGlobalRoute === 'boolean') {
|
||||
settings.systemConfig.routing.enableGlobalRoute = routing.enableGlobalRoute;
|
||||
}
|
||||
|
||||
if (typeof routing.enableGroupNameRoute === 'boolean') {
|
||||
settings.systemConfig.routing.enableGroupNameRoute = routing.enableGroupNameRoute;
|
||||
}
|
||||
}
|
||||
|
||||
if (install) {
|
||||
if (typeof install.pythonIndexUrl === 'string') {
|
||||
settings.systemConfig.install.pythonIndexUrl = install.pythonIndexUrl;
|
||||
}
|
||||
}
|
||||
|
||||
if (saveSettings(settings)) {
|
||||
|
||||
@@ -79,6 +79,13 @@ export const initializeClientsFromSettings = (isInit: boolean): ServerInfo[] =>
|
||||
} else if (conf.command && conf.args) {
|
||||
const env: Record<string, string> = conf.env || {};
|
||||
env['PATH'] = expandEnvVars(process.env.PATH as string) || '';
|
||||
|
||||
// Add UV_DEFAULT_INDEX from settings if available (for Python packages)
|
||||
const settings = loadSettings();
|
||||
if (settings.systemConfig?.install?.pythonIndexUrl && conf.command === 'uvx') {
|
||||
env['UV_DEFAULT_INDEX'] = settings.systemConfig.install.pythonIndexUrl;
|
||||
}
|
||||
|
||||
transport = new StdioClientTransport({
|
||||
command: conf.command,
|
||||
args: conf.args,
|
||||
|
||||
@@ -83,6 +83,9 @@ export interface McpSettings {
|
||||
enableGlobalRoute?: boolean; // Controls whether the /sse endpoint without group is enabled
|
||||
enableGroupNameRoute?: boolean; // Controls whether group routing by name is allowed
|
||||
};
|
||||
install?: {
|
||||
pythonIndexUrl?: string; // Python package repository URL (UV_DEFAULT_INDEX)
|
||||
};
|
||||
// Add other system configuration sections here in the future
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user