Compare commits

...

6 Commits

Author SHA1 Message Date
samanhappy
198ea85225 feat: implement user management features with add, edit, and delete functionality 2025-10-02 15:11:08 +08:00
dependabot[bot]
6b39916909 chore(deps): bump typeorm from 0.3.26 to 0.3.27 (#356)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-02 14:47:55 +08:00
dependabot[bot]
9e8db370ff chore(deps-dev): bump @tailwindcss/postcss from 4.1.12 to 4.1.13 (#358)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-02 14:47:09 +08:00
dependabot[bot]
5d8bc44a73 chore(deps-dev): bump @types/node from 22.17.2 to 24.6.1 (#357)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-02 14:44:13 +08:00
dependabot[bot]
021901dbda chore(deps-dev): bump jest and @types/jest (#354)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-02 14:40:42 +08:00
WuWen
f6934a32dc feat: add configurable name separator for tools and prompts (#353) 2025-10-02 14:40:01 +08:00
23 changed files with 2077 additions and 761 deletions

View File

@@ -0,0 +1,153 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useUserData } from '@/hooks/useUserData';
import { UserFormData } from '@/types';
interface AddUserFormProps {
onAdd: () => void;
onCancel: () => void;
}
const AddUserForm = ({ onAdd, onCancel }: AddUserFormProps) => {
const { t } = useTranslation();
const { createUser } = useUserData();
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState<UserFormData>({
username: '',
password: '',
isAdmin: false,
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!formData.username.trim()) {
setError(t('users.usernameRequired'));
return;
}
if (!formData.password.trim()) {
setError(t('users.passwordRequired'));
return;
}
if (formData.password.length < 6) {
setError(t('users.passwordTooShort'));
return;
}
setIsSubmitting(true);
try {
const result = await createUser(formData);
if (result?.success) {
onAdd();
} else {
setError(result?.message || t('users.createError'));
}
} catch (err) {
setError(err instanceof Error ? err.message : t('users.createError'));
} finally {
setIsSubmitting(false);
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}));
};
return (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full flex items-center justify-center z-50">
<div className="bg-white p-8 rounded-lg shadow-xl max-w-md w-full mx-4">
<form onSubmit={handleSubmit}>
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t('users.addNew')}</h2>
{error && (
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-3 mb-4">
<p className="text-sm">{error}</p>
</div>
)}
<div className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
{t('users.username')} *
</label>
<input
type="text"
id="username"
name="username"
value={formData.username}
onChange={handleInputChange}
placeholder={t('users.usernamePlaceholder')}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
disabled={isSubmitting}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
{t('users.password')} *
</label>
<input
type="password"
id="password"
name="password"
value={formData.password}
onChange={handleInputChange}
placeholder={t('users.passwordPlaceholder')}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
disabled={isSubmitting}
minLength={6}
/>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="isAdmin"
name="isAdmin"
checked={formData.isAdmin}
onChange={handleInputChange}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
disabled={isSubmitting}
/>
<label htmlFor="isAdmin" className="ml-2 block text-sm text-gray-700">
{t('users.adminRole')}
</label>
</div>
</div>
<div className="flex justify-end space-x-3 mt-6">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-gray-700 bg-gray-200 rounded hover:bg-gray-300 transition-colors duration-200"
disabled={isSubmitting}
>
{t('common.cancel')}
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isSubmitting}
>
{isSubmitting ? t('common.creating') : t('users.create')}
</button>
</div>
</form>
</div>
</div>
);
};
export default AddUserForm;

View File

@@ -0,0 +1,161 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useUserData } from '@/hooks/useUserData';
import { User, UserUpdateData } from '@/types';
interface EditUserFormProps {
user: User;
onEdit: () => void;
onCancel: () => void;
}
const EditUserForm = ({ user, onEdit, onCancel }: EditUserFormProps) => {
const { t } = useTranslation();
const { updateUser } = useUserData();
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState({
isAdmin: user.isAdmin,
newPassword: '',
confirmPassword: '',
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
// Validate passwords match if changing password
if (formData.newPassword && formData.newPassword !== formData.confirmPassword) {
setError(t('users.passwordMismatch'));
return;
}
if (formData.newPassword && formData.newPassword.length < 6) {
setError(t('users.passwordTooShort'));
return;
}
setIsSubmitting(true);
try {
const updateData: UserUpdateData = {
isAdmin: formData.isAdmin,
};
if (formData.newPassword) {
updateData.newPassword = formData.newPassword;
}
const result = await updateUser(user.username, updateData);
if (result?.success) {
onEdit();
} else {
setError(result?.message || t('users.updateError'));
}
} catch (err) {
setError(err instanceof Error ? err.message : t('users.updateError'));
} finally {
setIsSubmitting(false);
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}));
};
return (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full flex items-center justify-center z-50">
<div className="bg-white p-8 rounded-lg shadow-xl max-w-md w-full mx-4">
<form onSubmit={handleSubmit}>
<h2 className="text-xl font-semibold text-gray-800 mb-4">
{t('users.edit')} - {user.username}
</h2>
{error && (
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-3 mb-4">
<p className="text-sm">{error}</p>
</div>
)}
<div className="space-y-4">
<div className="flex items-center">
<input
type="checkbox"
id="isAdmin"
name="isAdmin"
checked={formData.isAdmin}
onChange={handleInputChange}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
disabled={isSubmitting}
/>
<label htmlFor="isAdmin" className="ml-2 block text-sm text-gray-700">
{t('users.adminRole')}
</label>
</div>
<div>
<label htmlFor="newPassword" className="block text-sm font-medium text-gray-700 mb-1">
{t('users.newPassword')}
</label>
<input
type="password"
id="newPassword"
name="newPassword"
value={formData.newPassword}
onChange={handleInputChange}
placeholder={t('users.newPasswordPlaceholder')}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={isSubmitting}
minLength={6}
/>
</div>
{formData.newPassword && (
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-1">
{t('users.confirmPassword')}
</label>
<input
type="password"
id="confirmPassword"
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleInputChange}
placeholder={t('users.confirmPasswordPlaceholder')}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={isSubmitting}
minLength={6}
/>
</div>
)}
</div>
<div className="flex justify-end space-x-3 mt-6">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-gray-700 bg-gray-200 rounded hover:bg-gray-300 transition-colors duration-200"
disabled={isSubmitting}
>
{t('common.cancel')}
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isSubmitting}
>
{isSubmitting ? t('common.updating') : t('users.update')}
</button>
</div>
</form>
</div>
</div>
);
};
export default EditUserForm;

View File

@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { IGroupServerConfig, Server, Tool } from '@/types';
import { cn } from '@/utils/cn';
import { useSettingsData } from '@/hooks/useSettingsData';
interface ServerToolConfigProps {
servers: Server[];
@@ -17,6 +18,7 @@ export const ServerToolConfig: React.FC<ServerToolConfigProps> = ({
className
}) => {
const { t } = useTranslation();
const { nameSeparator } = useSettingsData();
const [expandedServers, setExpandedServers] = useState<Set<string>>(new Set());
// Normalize current value to IGroupServerConfig[] format
@@ -116,7 +118,7 @@ export const ServerToolConfig: React.FC<ServerToolConfigProps> = ({
const server = availableServers.find(s => s.name === serverName);
if (!server) return;
const allToolNames = server.tools?.map(tool => tool.name.replace(`${serverName}-`, '')) || [];
const allToolNames = server.tools?.map(tool => tool.name.replace(`${serverName}${nameSeparator}`, '')) || [];
const serverConfig = normalizedValue.find(config => config.name === serverName);
if (!serverConfig) {
@@ -279,7 +281,7 @@ export const ServerToolConfig: React.FC<ServerToolConfigProps> = ({
<div className="grid grid-cols-1 gap-2 max-h-32 overflow-y-auto">
{serverTools.map(tool => {
const toolName = tool.name.replace(`${server.name}-`, '');
const toolName = tool.name.replace(`${server.name}${nameSeparator}`, '');
const isToolChecked = isToolSelected(server.name, toolName);
return (

View File

@@ -0,0 +1,96 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { User, IUser } from '@/types';
import { Edit, Trash } from '@/components/icons/LucideIcons';
import DeleteDialog from '@/components/ui/DeleteDialog';
interface UserCardProps {
user: User;
currentUser: IUser | null;
onEdit: (user: User) => void;
onDelete: (username: string) => void;
}
const UserCard: React.FC<UserCardProps> = ({ user, currentUser, onEdit, onDelete }) => {
const { t } = useTranslation();
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const handleDeleteClick = () => {
setShowDeleteDialog(true);
};
const handleConfirmDelete = () => {
onDelete(user.username);
setShowDeleteDialog(false);
};
const isCurrentUser = currentUser?.username === user.username;
const canDelete = !isCurrentUser; // Can't delete own account
return (
<div className="bg-white shadow rounded-lg p-6">
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center space-x-3 mb-2">
<div className="w-10 h-10 bg-blue-500 rounded-full flex items-center justify-center">
<span className="text-white font-medium text-sm">
{user.username.charAt(0).toUpperCase()}
</span>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900">
{user.username}
{isCurrentUser && (
<span className="ml-2 px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded">
{t('users.currentUser')}
</span>
)}
</h3>
<div className="flex items-center space-x-2">
<span
className={`px-2 py-1 text-xs font-medium rounded ${user.isAdmin
? 'bg-red-100 text-red-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{user.isAdmin ? t('users.admin') : t('users.user')}
</span>
</div>
</div>
</div>
</div>
<div className="flex space-x-2">
<button
onClick={() => onEdit(user)}
className="text-gray-500 hover:text-gray-700"
title={t('users.edit')}
>
<Edit size={18} />
</button>
{canDelete && (
<button
onClick={handleDeleteClick}
className="text-gray-500 hover:text-red-600"
title={t('users.delete')}
>
<Trash size={18} />
</button>
)}
</div>
</div>
<DeleteDialog
isOpen={showDeleteDialog}
onClose={() => setShowDeleteDialog(false)}
onConfirm={handleConfirmDelete}
serverName={user.username}
isGroup={false}
isUser={true}
/>
</div>
);
};
export default UserCard;

View File

@@ -4,6 +4,7 @@ import { Prompt } from '@/types'
import { ChevronDown, ChevronRight, Play, Loader, Edit, Check } from '@/components/icons/LucideIcons'
import { Switch } from './ToggleGroup'
import { getPrompt, PromptCallResult } from '@/services/promptService'
import { useSettingsData } from '@/hooks/useSettingsData'
import DynamicForm from './DynamicForm'
import PromptResult from './PromptResult'
@@ -16,6 +17,7 @@ interface PromptCardProps {
const PromptCard = ({ prompt, server, onToggle, onDescriptionUpdate }: PromptCardProps) => {
const { t } = useTranslation()
const { nameSeparator } = useSettingsData()
const [isExpanded, setIsExpanded] = useState(false)
const [showRunForm, setShowRunForm] = useState(false)
const [isRunning, setIsRunning] = useState(false)
@@ -154,7 +156,7 @@ const PromptCard = ({ prompt, server, onToggle, onDescriptionUpdate }: PromptCar
>
<div className="flex-1">
<h3 className="text-lg font-medium text-gray-900">
{prompt.name.replace(server + '-', '')}
{prompt.name.replace(server + nameSeparator, '')}
{prompt.title && (
<span className="ml-2 text-sm font-normal text-gray-600">
{prompt.title}
@@ -249,7 +251,7 @@ const PromptCard = ({ prompt, server, onToggle, onDescriptionUpdate }: PromptCar
onCancel={handleCancelRun}
loading={isRunning}
storageKey={getStorageKey()}
title={t('prompt.runPromptWithName', { name: prompt.name.replace(server + '-', '') })}
title={t('prompt.runPromptWithName', { name: prompt.name.replace(server + nameSeparator, '') })}
/>
{/* Prompt Result */}
{result && (

View File

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
import { Tool } from '@/types'
import { ChevronDown, ChevronRight, Play, Loader, Edit, Check } from '@/components/icons/LucideIcons'
import { callTool, ToolCallResult, updateToolDescription } from '@/services/toolService'
import { useSettingsData } from '@/hooks/useSettingsData'
import { Switch } from './ToggleGroup'
import DynamicForm from './DynamicForm'
import ToolResult from './ToolResult'
@@ -25,6 +26,7 @@ function isEmptyValue(value: any): boolean {
const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps) => {
const { t } = useTranslation()
const { nameSeparator } = useSettingsData()
const [isExpanded, setIsExpanded] = useState(false)
const [showRunForm, setShowRunForm] = useState(false)
const [isRunning, setIsRunning] = useState(false)
@@ -148,7 +150,7 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
>
<div className="flex-1">
<h3 className="text-lg font-medium text-gray-900">
{tool.name.replace(server + '-', '')}
{tool.name.replace(server + nameSeparator, '')}
<span className="ml-2 text-sm font-normal text-gray-600 inline-flex items-center">
{isEditingDescription ? (
<>
@@ -246,7 +248,7 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
onCancel={handleCancelRun}
loading={isRunning}
storageKey={getStorageKey()}
title={t('tool.runToolWithName', { name: tool.name.replace(server + '-', '') })}
title={t('tool.runToolWithName', { name: tool.name.replace(server + nameSeparator, '') })}
/>
{/* Tool Result */}
{result && (

View File

@@ -40,6 +40,7 @@ interface SystemSettings {
install?: InstallConfig;
smartRouting?: SmartRoutingConfig;
mcpRouter?: MCPRouterConfig;
nameSeparator?: string;
};
}
@@ -84,6 +85,8 @@ export const useSettingsData = () => {
baseUrl: 'https://api.mcprouter.to/v1',
});
const [nameSeparator, setNameSeparator] = useState<string>('-');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
@@ -135,6 +138,9 @@ export const useSettingsData = () => {
baseUrl: data.data.systemConfig.mcpRouter.baseUrl || 'https://api.mcprouter.to/v1',
});
}
if (data.success && data.data?.systemConfig?.nameSeparator !== undefined) {
setNameSeparator(data.data.systemConfig.nameSeparator);
}
} catch (error) {
console.error('Failed to fetch settings:', error);
setError(error instanceof Error ? error.message : 'Failed to fetch settings');
@@ -384,6 +390,36 @@ export const useSettingsData = () => {
}
};
// Update name separator
const updateNameSeparator = async (value: string) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
nameSeparator: value,
});
if (data.success) {
setNameSeparator(value);
showToast(t('settings.restartRequired'), 'info');
return true;
} else {
showToast(data.message || t('errors.failedToUpdateSystemConfig'));
return false;
}
} catch (error) {
console.error('Failed to update name separator:', error);
const errorMessage =
error instanceof Error ? error.message : 'Failed to update name separator';
setError(errorMessage);
showToast(errorMessage);
return false;
} finally {
setLoading(false);
}
};
// Fetch settings when the component mounts or refreshKey changes
useEffect(() => {
fetchSettings();
@@ -404,6 +440,7 @@ export const useSettingsData = () => {
installConfig,
smartRoutingConfig,
mcpRouterConfig,
nameSeparator,
loading,
error,
setError,
@@ -416,5 +453,6 @@ export const useSettingsData = () => {
updateRoutingConfigBatch,
updateMCPRouterConfig,
updateMCPRouterConfigBatch,
updateNameSeparator,
};
};

View File

@@ -0,0 +1,100 @@
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { User, ApiResponse, UserFormData, UserUpdateData } from '@/types';
import { apiDelete, apiGet, apiPost, apiPut } from '../utils/fetchInterceptor';
export const useUserData = () => {
const { t } = useTranslation();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const fetchUsers = useCallback(async () => {
try {
setLoading(true);
const data: ApiResponse<User[]> = await apiGet('/users');
if (!data.success) {
setError(data.message || t('users.fetchError'));
return;
}
if (data && data.success && Array.isArray(data.data)) {
setUsers(data.data);
} else {
console.error('Invalid user data format:', data);
setUsers([]);
}
setError(null);
} catch (err) {
console.error('Error fetching users:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch users');
setUsers([]);
} finally {
setLoading(false);
}
}, []);
// Trigger a refresh of the users data
const triggerRefresh = useCallback(() => {
setRefreshKey((prev) => prev + 1);
}, []);
// Create a new user
const createUser = async (userData: UserFormData) => {
try {
const result: ApiResponse<User> = await apiPost('/users', userData);
triggerRefresh();
return result;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create user');
return null;
}
};
// Update an existing user
const updateUser = async (username: string, data: UserUpdateData) => {
try {
const result: ApiResponse<User> = await apiPut(`/users/${username}`, data);
triggerRefresh();
return result || null;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update user');
return null;
}
};
// Delete a user
const deleteUser = async (username: string) => {
try {
const result = await apiDelete(`/users/${username}`);
if (!result?.success) {
setError(result?.message || t('users.deleteError'));
return result;
}
triggerRefresh();
return result;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete user');
return false;
}
};
// Fetch users when the component mounts or refreshKey changes
useEffect(() => {
fetchUsers();
}, [fetchUsers, refreshKey]);
return {
users,
loading,
error,
setError,
triggerRefresh,
createUser,
updateUser,
deleteUser,
};
};

View File

@@ -48,6 +48,8 @@ const SettingsPage: React.FC = () => {
baseUrl: 'https://api.mcprouter.to/v1',
});
const [tempNameSeparator, setTempNameSeparator] = useState<string>('-');
const {
routingConfig,
tempRoutingConfig,
@@ -55,13 +57,15 @@ const SettingsPage: React.FC = () => {
installConfig: savedInstallConfig,
smartRoutingConfig,
mcpRouterConfig,
nameSeparator,
loading,
updateRoutingConfig,
updateRoutingConfigBatch,
updateInstallConfig,
updateSmartRoutingConfig,
updateSmartRoutingConfigBatch,
updateMCPRouterConfig
updateMCPRouterConfig,
updateNameSeparator,
} = useSettingsData();
// Update local installConfig when savedInstallConfig changes
@@ -95,15 +99,21 @@ const SettingsPage: React.FC = () => {
}
}, [mcpRouterConfig]);
// Update local tempNameSeparator when nameSeparator changes
useEffect(() => {
setTempNameSeparator(nameSeparator);
}, [nameSeparator]);
const [sectionsVisible, setSectionsVisible] = useState({
routingConfig: false,
installConfig: false,
smartRoutingConfig: false,
mcpRouterConfig: false,
nameSeparator: false,
password: false
});
const toggleSection = (section: 'routingConfig' | 'installConfig' | 'smartRoutingConfig' | 'mcpRouterConfig' | 'password') => {
const toggleSection = (section: 'routingConfig' | 'installConfig' | 'smartRoutingConfig' | 'mcpRouterConfig' | 'nameSeparator' | 'password') => {
setSectionsVisible(prev => ({
...prev,
[section]: !prev[section]
@@ -181,6 +191,10 @@ const SettingsPage: React.FC = () => {
await updateMCPRouterConfig(key, tempMCPRouterConfig[key]);
};
const saveNameSeparator = async () => {
await updateNameSeparator(tempNameSeparator);
};
const handleSmartRoutingEnabledChange = async (value: boolean) => {
// If enabling Smart Routing, validate required fields and save any unsaved changes
if (value) {
@@ -427,6 +441,48 @@ const SettingsPage: React.FC = () => {
</div>
</PermissionChecker>
{/* System Settings */}
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card">
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => toggleSection('nameSeparator')}
>
<h2 className="font-semibold text-gray-800">{t('settings.systemSettings')}</h2>
<span className="text-gray-500">
{sectionsVisible.nameSeparator ? '▼' : '►'}
</span>
</div>
{sectionsVisible.nameSeparator && (
<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.nameSeparatorLabel')}</h3>
<p className="text-sm text-gray-500">{t('settings.nameSeparatorDescription')}</p>
</div>
<div className="flex items-center gap-3">
<input
type="text"
value={tempNameSeparator}
onChange={(e) => setTempNameSeparator(e.target.value)}
placeholder="-"
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 form-input"
disabled={loading}
maxLength={5}
/>
<button
onClick={saveNameSeparator}
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 btn-primary"
>
{t('common.save')}
</button>
</div>
</div>
</div>
)}
</div>
{/* Route Configuration Settings */}
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card">
<div

View File

@@ -1,8 +1,125 @@
import React from 'react';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { User } from '@/types';
import { useUserData } from '@/hooks/useUserData';
import { useAuth } from '@/contexts/AuthContext';
import AddUserForm from '@/components/AddUserForm';
import EditUserForm from '@/components/EditUserForm';
import UserCard from '@/components/UserCard';
const UsersPage: React.FC = () => {
const { t } = useTranslation();
const { auth } = useAuth();
const currentUser = auth.user;
const {
users,
loading: usersLoading,
error: userError,
setError: setUserError,
deleteUser,
triggerRefresh
} = useUserData();
const [editingUser, setEditingUser] = useState<User | null>(null);
const [showAddForm, setShowAddForm] = useState(false);
// Check if current user is admin
if (!currentUser?.isAdmin) {
return (
<div className="bg-white shadow rounded-lg p-6">
<p className="text-red-600">{t('users.adminRequired')}</p>
</div>
);
}
const handleEditClick = (user: User) => {
setEditingUser(user);
};
const handleEditComplete = () => {
setEditingUser(null);
triggerRefresh(); // Refresh the users list after editing
};
const handleDeleteUser = async (username: string) => {
const result = await deleteUser(username);
if (!result?.success) {
setUserError(result?.message || t('users.deleteError'));
}
};
const handleAddUser = () => {
setShowAddForm(true);
};
const handleAddComplete = () => {
setShowAddForm(false);
triggerRefresh(); // Refresh the users list after adding
};
return (
<div></div>
<div>
<div className="flex justify-between items-center mb-8">
<h1 className="text-2xl font-bold text-gray-900">{t('pages.users.title')}</h1>
<div className="flex space-x-4">
<button
onClick={handleAddUser}
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 3a1 1 0 00-1 1v5H4a1 1 0 100 2h5v5a1 1 0 102 0v-5h5a1 1 0 100-2h-5V4a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
{t('users.add')}
</button>
</div>
</div>
{userError && (
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6 error-box rounded-lg">
<p>{userError}</p>
</div>
)}
{usersLoading ? (
<div className="bg-white shadow rounded-lg p-6 loading-container">
<div className="flex flex-col items-center justify-center">
<svg className="animate-spin h-10 w-10 text-blue-500 mb-4" 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>
<p className="text-gray-600">{t('app.loading')}</p>
</div>
</div>
) : users.length === 0 ? (
<div className="bg-white shadow rounded-lg p-6 empty-state">
<p className="text-gray-600">{t('users.noUsers')}</p>
</div>
) : (
<div className="space-y-6">
{users.map((user) => (
<UserCard
key={user.username}
user={user}
currentUser={currentUser}
onEdit={handleEditClick}
onDelete={handleDeleteUser}
/>
))}
</div>
)}
{showAddForm && (
<AddUserForm onAdd={handleAddComplete} onCancel={handleAddComplete} />
)}
{editingUser && (
<EditUserForm
user={editingUser}
onEdit={handleEditComplete}
onCancel={() => setEditingUser(null)}
/>
)}
</div>
);
};

View File

@@ -20,6 +20,7 @@ export interface SystemConfig {
openaiApiKey?: string;
openaiApiEmbeddingModel?: string;
};
nameSeparator?: string;
}
export interface PublicConfigResponse {
@@ -96,3 +97,5 @@ export const shouldSkipAuth = async (): Promise<boolean> => {
return false;
}
};

View File

@@ -498,7 +498,11 @@
"mcpRouterTitlePlaceholder": "MCPHub",
"mcpRouterBaseUrl": "Base URL",
"mcpRouterBaseUrlDescription": "Base URL for MCPRouter API",
"mcpRouterBaseUrlPlaceholder": "https://api.mcprouter.to/v1"
"mcpRouterBaseUrlPlaceholder": "https://api.mcprouter.to/v1",
"systemSettings": "System Settings",
"nameSeparatorLabel": "Name Separator",
"nameSeparatorDescription": "Character used to separate server name and tool/prompt name (default: -)",
"restartRequired": "Configuration saved. It is recommended to restart the application to ensure all services load the new settings correctly."
},
"dxt": {
"upload": "Upload",

View File

@@ -498,7 +498,11 @@
"mcpRouterTitlePlaceholder": "MCPHub",
"mcpRouterBaseUrl": "URL de base",
"mcpRouterBaseUrlDescription": "URL de base pour l'API MCPRouter",
"mcpRouterBaseUrlPlaceholder": "https://api.mcprouter.to/v1"
"mcpRouterBaseUrlPlaceholder": "https://api.mcprouter.to/v1",
"systemSettings": "Paramètres système",
"nameSeparatorLabel": "Séparateur de noms",
"nameSeparatorDescription": "Caractère utilisé pour séparer le nom du serveur et le nom de l'outil/prompt (par défaut : -)",
"restartRequired": "Configuration enregistrée. Il est recommandé de redémarrer l'application pour s'assurer que tous les services chargent correctement les nouveaux paramètres."
},
"dxt": {
"upload": "Télécharger",

View File

@@ -500,7 +500,11 @@
"mcpRouterTitlePlaceholder": "MCPHub",
"mcpRouterBaseUrl": "基础地址",
"mcpRouterBaseUrlDescription": "MCPRouter API 的基础地址",
"mcpRouterBaseUrlPlaceholder": "https://api.mcprouter.to/v1"
"mcpRouterBaseUrlPlaceholder": "https://api.mcprouter.to/v1",
"systemSettings": "系统设置",
"nameSeparatorLabel": "名称分隔符",
"nameSeparatorDescription": "用于分隔服务器名称和工具/提示名称(默认:-",
"restartRequired": "配置已保存。为确保所有服务正确加载新设置,建议重启应用。"
},
"dxt": {
"upload": "上传",

View File

@@ -84,9 +84,9 @@
"@types/bcryptjs": "^3.0.0",
"@types/cors": "^2.8.19",
"@types/express": "^4.17.23",
"@types/jest": "^29.5.14",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^22.17.2",
"@types/node": "^24.6.2",
"@types/react": "^19.1.11",
"@types/react-dom": "^19.1.7",
"@types/supertest": "^6.0.3",
@@ -101,7 +101,7 @@
"eslint": "^8.57.1",
"i18next": "^25.5.0",
"i18next-browser-languagedetector": "^8.2.0",
"jest": "^29.7.0",
"jest": "^30.2.0",
"jest-environment-node": "^30.0.5",
"jest-mock-extended": "4.0.0",
"lucide-react": "^0.486.0",

1928
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -138,3 +138,8 @@ export const expandEnvVars = (value: string): string => {
};
export default defaultConfig;
export function getNameSeparator(): string {
const settings = loadSettings();
return settings.systemConfig?.nameSeparator || '-';
}

View File

@@ -7,6 +7,7 @@ import {
} from '../services/openApiGeneratorService.js';
import { getServerByName } from '../services/mcpService.js';
import { getGroupByIdOrName } from '../services/groupService.js';
import { getNameSeparator } from '../config/index.js';
/**
* Controller for OpenAPI generation endpoints
@@ -177,7 +178,7 @@ export const executeToolViaOpenAPI = async (req: Request, res: Response): Promis
if (serverInfo) {
// Find the tool in the server's tools list
const fullToolName = `${serverName}-${toolName}`;
const fullToolName = `${serverName}${getNameSeparator()}${toolName}`;
const tool = serverInfo.tools.find(
(t: any) => t.name === fullToolName || t.name === toolName,
);

View File

@@ -504,7 +504,7 @@ export const updateToolDescription = async (req: Request, res: Response): Promis
export const updateSystemConfig = (req: Request, res: Response): void => {
try {
const { routing, install, smartRouting, mcpRouter } = req.body;
const { routing, install, smartRouting, mcpRouter, nameSeparator } = req.body;
const currentUser = (req as any).user;
if (
@@ -528,7 +528,8 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
(typeof mcpRouter.apiKey !== 'string' &&
typeof mcpRouter.referer !== 'string' &&
typeof mcpRouter.title !== 'string' &&
typeof mcpRouter.baseUrl !== 'string'))
typeof mcpRouter.baseUrl !== 'string')) &&
(typeof nameSeparator !== 'string')
) {
res.status(400).json({
success: false,
@@ -710,6 +711,10 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
}
}
if (typeof nameSeparator === 'string') {
settings.systemConfig.nameSeparator = nameSeparator;
}
if (saveSettings(settings, currentUser)) {
res.json({
success: true,

View File

@@ -0,0 +1,71 @@
import { IUser, McpSettings, UserConfig } from '../types/index.js';
import { DataService } from './dataService.js';
import { UserContextService } from './userContextService.js';
export class DataServicex implements DataService {
foo() {
console.log('default implementation');
}
filterData(data: any[], user?: IUser): any[] {
// Use passed user parameter if available, otherwise fall back to context
const currentUser = user || UserContextService.getInstance().getCurrentUser();
if (!currentUser || currentUser.isAdmin) {
return data;
} else {
return data.filter((item) => item.owner === currentUser?.username);
}
}
filterSettings(settings: McpSettings, user?: IUser): McpSettings {
// Use passed user parameter if available, otherwise fall back to context
const currentUser = user || UserContextService.getInstance().getCurrentUser();
if (!currentUser || currentUser.isAdmin) {
const result = { ...settings };
delete result.userConfigs;
return result;
} else {
const result = { ...settings };
result.systemConfig = settings.userConfigs?.[currentUser?.username || ''] || {};
delete result.userConfigs;
return result;
}
}
mergeSettings(all: McpSettings, newSettings: McpSettings, user?: IUser): McpSettings {
// Use passed user parameter if available, otherwise fall back to context
const currentUser = user || UserContextService.getInstance().getCurrentUser();
if (!currentUser || currentUser.isAdmin) {
const result = { ...all };
result.users = newSettings.users;
result.systemConfig = newSettings.systemConfig;
return result;
} else {
const result = JSON.parse(JSON.stringify(all));
if (!result.userConfigs) {
result.userConfigs = {};
}
const systemConfig = newSettings.systemConfig || {};
const userConfig: UserConfig = {
routing: systemConfig.routing
? {
enableGlobalRoute: systemConfig.routing.enableGlobalRoute,
enableGroupNameRoute: systemConfig.routing.enableGroupNameRoute,
enableBearerAuth: systemConfig.routing.enableBearerAuth,
bearerAuthKey: systemConfig.routing.bearerAuthKey,
}
: undefined,
};
result.userConfigs[currentUser?.username || ''] = userConfig;
return result;
}
}
getPermissions(user: IUser): string[] {
if (user && user.isAdmin) {
return ['*', 'x'];
} else {
return [''];
}
}
}

View File

@@ -12,7 +12,7 @@ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { ServerInfo, ServerConfig, Tool } from '../types/index.js';
import { loadSettings, expandEnvVars, replaceEnvVars } from '../config/index.js';
import { loadSettings, expandEnvVars, replaceEnvVars, getNameSeparator } from '../config/index.js';
import config from '../config/index.js';
import { getGroup } from './sseService.js';
import { getServersInGroup, getServerConfigInGroup } from './groupService.js';
@@ -294,7 +294,7 @@ const callToolWithReconnect = async (
try {
const tools = await client.listTools({}, serverInfo.options || {});
serverInfo.tools = tools.tools.map((tool) => ({
name: `${serverInfo.name}-${tool.name}`,
name: `${serverInfo.name}${getNameSeparator()}${tool.name}`,
description: tool.description || '',
inputSchema: cleanInputSchema(tool.inputSchema || {}),
}));
@@ -420,7 +420,7 @@ export const initializeClientsFromSettings = async (
// Convert OpenAPI tools to MCP tool format
const openApiTools = openApiClient.getTools();
const mcpTools: Tool[] = openApiTools.map((tool) => ({
name: `${name}-${tool.name}`,
name: `${name}${getNameSeparator()}${tool.name}`,
description: tool.description,
inputSchema: cleanInputSchema(tool.inputSchema),
}));
@@ -507,7 +507,7 @@ export const initializeClientsFromSettings = async (
.then((tools) => {
console.log(`Successfully listed ${tools.tools.length} tools for server: ${name}`);
serverInfo.tools = tools.tools.map((tool) => ({
name: `${name}-${tool.name}`,
name: `${name}${getNameSeparator()}${tool.name}`,
description: tool.description || '',
inputSchema: cleanInputSchema(tool.inputSchema || {}),
}));
@@ -530,7 +530,7 @@ export const initializeClientsFromSettings = async (
`Successfully listed ${prompts.prompts.length} prompts for server: ${name}`,
);
serverInfo.prompts = prompts.prompts.map((prompt) => ({
name: `${name}-${prompt.name}`,
name: `${name}${getNameSeparator()}${prompt.name}`,
title: prompt.title,
description: prompt.description,
arguments: prompt.arguments,
@@ -848,7 +848,7 @@ Available servers: ${serversList}`;
if (serverConfig && serverConfig.tools !== 'all' && Array.isArray(serverConfig.tools)) {
// Filter tools based on group configuration
const allowedToolNames = serverConfig.tools.map(
(toolName) => `${serverInfo.name}-${toolName}`,
(toolName) => `${serverInfo.name}${getNameSeparator()}${toolName}`,
);
enabledTools = enabledTools.filter((tool) => allowedToolNames.includes(tool.name));
}
@@ -1035,8 +1035,10 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
);
// Remove server prefix from tool name if present
const cleanToolName = toolName.startsWith(`${targetServerInfo.name}-`)
? toolName.replace(`${targetServerInfo.name}-`, '')
const separator = getNameSeparator();
const prefix = `${targetServerInfo.name}${separator}`;
const cleanToolName = toolName.startsWith(prefix)
? toolName.substring(prefix.length)
: toolName;
// Extract passthrough headers from extra or request context
@@ -1093,8 +1095,10 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
`Invoking tool '${toolName}' on server '${targetServerInfo.name}' with arguments: ${JSON.stringify(finalArgs)}`,
);
toolName = toolName.startsWith(`${targetServerInfo.name}-`)
? toolName.replace(`${targetServerInfo.name}-`, '')
const separator = getNameSeparator();
const prefix = `${targetServerInfo.name}${separator}`;
toolName = toolName.startsWith(prefix)
? toolName.substring(prefix.length)
: toolName;
const result = await callToolWithReconnect(
targetServerInfo,
@@ -1121,8 +1125,10 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
const openApiClient = serverInfo.openApiClient;
// Remove server prefix from tool name if present
const cleanToolName = request.params.name.startsWith(`${serverInfo.name}-`)
? request.params.name.replace(`${serverInfo.name}-`, '')
const separator = getNameSeparator();
const prefix = `${serverInfo.name}${separator}`;
const cleanToolName = request.params.name.startsWith(prefix)
? request.params.name.substring(prefix.length)
: request.params.name;
console.log(
@@ -1179,8 +1185,10 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
throw new Error(`Client not found for server: ${serverInfo.name}`);
}
request.params.name = request.params.name.startsWith(`${serverInfo.name}-`)
? request.params.name.replace(`${serverInfo.name}-`, '')
const separator = getNameSeparator();
const prefix = `${serverInfo.name}${separator}`;
request.params.name = request.params.name.startsWith(prefix)
? request.params.name.substring(prefix.length)
: request.params.name;
const result = await callToolWithReconnect(
serverInfo,
@@ -1223,8 +1231,10 @@ export const handleGetPromptRequest = async (request: any, extra: any) => {
}
// Remove server prefix from prompt name if present
const cleanPromptName = name.startsWith(`${server.name}-`)
? name.replace(`${server.name}-`, '')
const separator = getNameSeparator();
const prefix = `${server.name}${separator}`;
const cleanPromptName = name.startsWith(prefix)
? name.substring(prefix.length)
: name;
const promptParams = {

View File

@@ -2,7 +2,7 @@ import { OpenAPIV3 } from 'openapi-types';
import { Tool } from '../types/index.js';
import { getServersInfo } from './mcpService.js';
import config from '../config/index.js';
import { loadSettings } from '../config/index.js';
import { loadSettings, getNameSeparator } from '../config/index.js';
/**
* Service for generating OpenAPI 3.x specifications from MCP tools
@@ -209,10 +209,11 @@ export async function generateOpenAPISpec(
const allowedTools = groupConfig.get(serverInfo.name);
if (allowedTools !== 'all') {
// Filter tools to only include those specified in the group configuration
const separator = getNameSeparator();
filteredTools = tools.filter(
(tool) =>
Array.isArray(allowedTools) &&
allowedTools.includes(tool.name.replace(serverInfo.name + '-', '')),
allowedTools.includes(tool.name.replace(serverInfo.name + separator, '')),
);
}
}

View File

@@ -144,6 +144,7 @@ export interface SystemConfig {
title?: string; // Title header for MCPRouter API requests
baseUrl?: string; // Base URL for MCPRouter API (default: https://api.mcprouter.to/v1)
};
nameSeparator?: string; // Separator used between server name and tool/prompt name (default: '-')
}
export interface UserConfig {