Compare commits

..

5 Commits

18 changed files with 663 additions and 547 deletions

View File

@@ -57,28 +57,28 @@ const AddUserForm = ({ onAdd, onCancel }: AddUserFormProps) => {
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
setFormData(prev => ({
setFormData((prev) => ({
...prev,
[name]: type === 'checkbox' ? checked : value
[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">
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white p-8 rounded-xl shadow-2xl max-w-md w-full mx-4 border border-gray-100">
<form onSubmit={handleSubmit}>
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t('users.addNew')}</h2>
<h2 className="text-xl font-bold text-gray-900 mb-6">{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 className="bg-red-50 border-l-4 border-red-500 text-red-700 p-4 mb-6 rounded-md">
<p className="text-sm font-medium">{error}</p>
</div>
)}
<div className="space-y-4">
<div className="space-y-5">
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
{t('users.username')} *
{t('users.username')} <span className="text-red-500">*</span>
</label>
<input
type="text"
@@ -87,7 +87,7 @@ const AddUserForm = ({ onAdd, onCancel }: AddUserFormProps) => {
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"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent form-input transition-all duration-200"
required
disabled={isSubmitting}
/>
@@ -95,7 +95,7 @@ const AddUserForm = ({ onAdd, onCancel }: AddUserFormProps) => {
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
{t('users.password')} *
{t('users.password')} <span className="text-red-500">*</span>
</label>
<input
type="password"
@@ -104,43 +104,68 @@ const AddUserForm = ({ onAdd, onCancel }: AddUserFormProps) => {
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"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent form-input transition-all duration-200"
required
disabled={isSubmitting}
minLength={6}
/>
</div>
<div className="flex items-center">
<div className="flex items-center pt-2">
<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"
className="h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded transition-colors duration-200"
disabled={isSubmitting}
/>
<label htmlFor="isAdmin" className="ml-2 block text-sm text-gray-700">
<label
htmlFor="isAdmin"
className="ml-3 block text-sm font-medium text-gray-700 cursor-pointer select-none"
>
{t('users.adminRole')}
</label>
</div>
</div>
<div className="flex justify-end space-x-3 mt-6">
<div className="flex justify-end space-x-3 mt-8">
<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"
className="px-5 py-2.5 text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-all duration-200 font-medium btn-secondary shadow-sm"
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"
className="px-5 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-all duration-200 font-medium btn-primary shadow-md disabled:opacity-70 disabled:cursor-not-allowed flex items-center"
disabled={isSubmitting}
>
{isSubmitting && (
<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>
)}
{isSubmitting ? t('common.creating') : t('users.create')}
</button>
</div>

View File

@@ -62,93 +62,132 @@ const EditUserForm = ({ user, onEdit, onCancel }: EditUserFormProps) => {
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
setFormData(prev => ({
setFormData((prev) => ({
...prev,
[name]: type === 'checkbox' ? checked : value
[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">
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white p-8 rounded-xl shadow-2xl max-w-md w-full mx-4 border border-gray-100">
<form onSubmit={handleSubmit}>
<h2 className="text-xl font-semibold text-gray-800 mb-4">
{t('users.edit')} - {user.username}
<h2 className="text-xl font-bold text-gray-900 mb-6">
{t('users.edit')} - <span className="text-blue-600">{user.username}</span>
</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 className="bg-red-50 border-l-4 border-red-500 text-red-700 p-4 mb-6 rounded-md">
<p className="text-sm font-medium">{error}</p>
</div>
)}
<div className="space-y-4">
<div className="flex items-center">
<div className="space-y-5">
<div className="flex items-center pt-2">
<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"
className="h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded transition-colors duration-200"
disabled={isSubmitting}
/>
<label htmlFor="isAdmin" className="ml-2 block text-sm text-gray-700">
<label
htmlFor="isAdmin"
className="ml-3 block text-sm font-medium text-gray-700 cursor-pointer select-none"
>
{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>
<div className="border-t border-gray-100 pt-4 mt-2">
<p className="text-xs text-gray-500 uppercase font-semibold tracking-wider mb-3">
{t('users.changePassword')}
</p>
{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 className="space-y-4">
<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-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent form-input transition-all duration-200"
disabled={isSubmitting}
minLength={6}
/>
</div>
{formData.newPassword && (
<div className="animate-fadeIn">
<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-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent form-input transition-all duration-200"
disabled={isSubmitting}
minLength={6}
/>
</div>
)}
</div>
)}
</div>
</div>
<div className="flex justify-end space-x-3 mt-6">
<div className="flex justify-end space-x-3 mt-8">
<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"
className="px-5 py-2.5 text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-all duration-200 font-medium btn-secondary shadow-sm"
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"
className="px-5 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-all duration-200 font-medium btn-primary shadow-md disabled:opacity-70 disabled:cursor-not-allowed flex items-center"
disabled={isSubmitting}
>
{isSubmitting && (
<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>
)}
{isSubmitting ? t('common.updating') : t('users.update')}
</button>
</div>

View File

@@ -2,8 +2,9 @@
export const PERMISSIONS = {
// Settings page permissions
SETTINGS_SMART_ROUTING: 'settings:smart_routing',
SETTINGS_SKIP_AUTH: 'settings:skip_auth',
SETTINGS_ROUTE_CONFIG: 'settings:route_config',
SETTINGS_INSTALL_CONFIG: 'settings:install_config',
SETTINGS_SYSTEM_CONFIG: 'settings:system_config',
SETTINGS_OAUTH_SERVER: 'settings:oauth_server',
SETTINGS_EXPORT_CONFIG: 'settings:export_config',
} as const;

View File

@@ -1,69 +1,69 @@
import React, { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import ChangePasswordForm from '@/components/ChangePasswordForm'
import { Switch } from '@/components/ui/ToggleGroup'
import { useSettingsData } from '@/hooks/useSettingsData'
import { useToast } from '@/contexts/ToastContext'
import { generateRandomKey } from '@/utils/key'
import { PermissionChecker } from '@/components/PermissionChecker'
import { PERMISSIONS } from '@/constants/permissions'
import { Copy, Check, Download } from 'lucide-react'
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import ChangePasswordForm from '@/components/ChangePasswordForm';
import { Switch } from '@/components/ui/ToggleGroup';
import { useSettingsData } from '@/hooks/useSettingsData';
import { useToast } from '@/contexts/ToastContext';
import { generateRandomKey } from '@/utils/key';
import { PermissionChecker } from '@/components/PermissionChecker';
import { PERMISSIONS } from '@/constants/permissions';
import { Copy, Check, Download } from 'lucide-react';
const SettingsPage: React.FC = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const { showToast } = useToast()
const { t } = useTranslation();
const navigate = useNavigate();
const { showToast } = useToast();
const [installConfig, setInstallConfig] = useState<{
pythonIndexUrl: string
npmRegistry: string
baseUrl: string
pythonIndexUrl: string;
npmRegistry: string;
baseUrl: string;
}>({
pythonIndexUrl: '',
npmRegistry: '',
baseUrl: 'http://localhost:3000',
})
});
const [tempSmartRoutingConfig, setTempSmartRoutingConfig] = useState<{
dbUrl: string
openaiApiBaseUrl: string
openaiApiKey: string
openaiApiEmbeddingModel: string
dbUrl: string;
openaiApiBaseUrl: string;
openaiApiKey: string;
openaiApiEmbeddingModel: string;
}>({
dbUrl: '',
openaiApiBaseUrl: '',
openaiApiKey: '',
openaiApiEmbeddingModel: '',
})
});
const [tempMCPRouterConfig, setTempMCPRouterConfig] = useState<{
apiKey: string
referer: string
title: string
baseUrl: string
apiKey: string;
referer: string;
title: string;
baseUrl: string;
}>({
apiKey: '',
referer: 'https://www.mcphubx.com',
title: 'MCPHub',
baseUrl: 'https://api.mcprouter.to/v1',
})
});
const [tempOAuthServerConfig, setTempOAuthServerConfig] = useState<{
accessTokenLifetime: string
refreshTokenLifetime: string
authorizationCodeLifetime: string
allowedScopes: string
dynamicRegistrationAllowedGrantTypes: string
accessTokenLifetime: string;
refreshTokenLifetime: string;
authorizationCodeLifetime: string;
allowedScopes: string;
dynamicRegistrationAllowedGrantTypes: string;
}>({
accessTokenLifetime: '3600',
refreshTokenLifetime: '1209600',
authorizationCodeLifetime: '300',
allowedScopes: 'read, write',
dynamicRegistrationAllowedGrantTypes: 'authorization_code, refresh_token',
})
});
const [tempNameSeparator, setTempNameSeparator] = useState<string>('-')
const [tempNameSeparator, setTempNameSeparator] = useState<string>('-');
const {
routingConfig,
@@ -86,14 +86,14 @@ const SettingsPage: React.FC = () => {
updateNameSeparator,
updateSessionRebuild,
exportMCPSettings,
} = useSettingsData()
} = useSettingsData();
// Update local installConfig when savedInstallConfig changes
useEffect(() => {
if (savedInstallConfig) {
setInstallConfig(savedInstallConfig)
setInstallConfig(savedInstallConfig);
}
}, [savedInstallConfig])
}, [savedInstallConfig]);
// Update local tempSmartRoutingConfig when smartRoutingConfig changes
useEffect(() => {
@@ -103,9 +103,9 @@ const SettingsPage: React.FC = () => {
openaiApiBaseUrl: smartRoutingConfig.openaiApiBaseUrl || '',
openaiApiKey: smartRoutingConfig.openaiApiKey || '',
openaiApiEmbeddingModel: smartRoutingConfig.openaiApiEmbeddingModel || '',
})
});
}
}, [smartRoutingConfig])
}, [smartRoutingConfig]);
// Update local tempMCPRouterConfig when mcpRouterConfig changes
useEffect(() => {
@@ -115,9 +115,9 @@ const SettingsPage: React.FC = () => {
referer: mcpRouterConfig.referer || 'https://www.mcphubx.com',
title: mcpRouterConfig.title || 'MCPHub',
baseUrl: mcpRouterConfig.baseUrl || 'https://api.mcprouter.to/v1',
})
});
}
}, [mcpRouterConfig])
}, [mcpRouterConfig]);
useEffect(() => {
if (oauthServerConfig) {
@@ -138,18 +138,18 @@ const SettingsPage: React.FC = () => {
oauthServerConfig.allowedScopes && oauthServerConfig.allowedScopes.length > 0
? oauthServerConfig.allowedScopes.join(', ')
: '',
dynamicRegistrationAllowedGrantTypes:
oauthServerConfig.dynamicRegistration?.allowedGrantTypes?.length
? oauthServerConfig.dynamicRegistration.allowedGrantTypes.join(', ')
: '',
})
dynamicRegistrationAllowedGrantTypes: oauthServerConfig.dynamicRegistration
?.allowedGrantTypes?.length
? oauthServerConfig.dynamicRegistration.allowedGrantTypes.join(', ')
: '',
});
}
}, [oauthServerConfig])
}, [oauthServerConfig]);
// Update local tempNameSeparator when nameSeparator changes
useEffect(() => {
setTempNameSeparator(nameSeparator)
}, [nameSeparator])
setTempNameSeparator(nameSeparator);
}, [nameSeparator]);
const [sectionsVisible, setSectionsVisible] = useState({
routingConfig: false,
@@ -160,7 +160,7 @@ const SettingsPage: React.FC = () => {
nameSeparator: false,
password: false,
exportConfig: false,
})
});
const toggleSection = (
section:
@@ -176,8 +176,8 @@ const SettingsPage: React.FC = () => {
setSectionsVisible((prev) => ({
...prev,
[section]: !prev[section],
}))
}
}));
};
const handleRoutingConfigChange = async (
key:
@@ -191,39 +191,39 @@ const SettingsPage: React.FC = () => {
// If enableBearerAuth is turned on and there's no key, generate one first
if (key === 'enableBearerAuth' && value === true) {
if (!tempRoutingConfig.bearerAuthKey && !routingConfig.bearerAuthKey) {
const newKey = generateRandomKey()
handleBearerAuthKeyChange(newKey)
const newKey = generateRandomKey();
handleBearerAuthKeyChange(newKey);
// Update both enableBearerAuth and bearerAuthKey in a single call
const success = await updateRoutingConfigBatch({
enableBearerAuth: true,
bearerAuthKey: newKey,
})
});
if (success) {
// Update tempRoutingConfig to reflect the saved values
setTempRoutingConfig((prev) => ({
...prev,
bearerAuthKey: newKey,
}))
}));
}
return
return;
}
}
await updateRoutingConfig(key, value)
}
await updateRoutingConfig(key, value);
};
const handleBearerAuthKeyChange = (value: string) => {
setTempRoutingConfig((prev) => ({
...prev,
bearerAuthKey: value,
}))
}
}));
};
const saveBearerAuthKey = async () => {
await updateRoutingConfig('bearerAuthKey', tempRoutingConfig.bearerAuthKey)
}
await updateRoutingConfig('bearerAuthKey', tempRoutingConfig.bearerAuthKey);
};
const handleInstallConfigChange = (
key: 'pythonIndexUrl' | 'npmRegistry' | 'baseUrl',
@@ -232,12 +232,12 @@ const SettingsPage: React.FC = () => {
setInstallConfig({
...installConfig,
[key]: value,
})
}
});
};
const saveInstallConfig = async (key: 'pythonIndexUrl' | 'npmRegistry' | 'baseUrl') => {
await updateInstallConfig(key, installConfig[key])
}
await updateInstallConfig(key, installConfig[key]);
};
const handleSmartRoutingConfigChange = (
key: 'dbUrl' | 'openaiApiBaseUrl' | 'openaiApiKey' | 'openaiApiEmbeddingModel',
@@ -246,14 +246,14 @@ const SettingsPage: React.FC = () => {
setTempSmartRoutingConfig({
...tempSmartRoutingConfig,
[key]: value,
})
}
});
};
const saveSmartRoutingConfig = async (
key: 'dbUrl' | 'openaiApiBaseUrl' | 'openaiApiKey' | 'openaiApiEmbeddingModel',
) => {
await updateSmartRoutingConfig(key, tempSmartRoutingConfig[key])
}
await updateSmartRoutingConfig(key, tempSmartRoutingConfig[key]);
};
const handleMCPRouterConfigChange = (
key: 'apiKey' | 'referer' | 'title' | 'baseUrl',
@@ -262,24 +262,24 @@ const SettingsPage: React.FC = () => {
setTempMCPRouterConfig({
...tempMCPRouterConfig,
[key]: value,
})
}
});
};
const saveMCPRouterConfig = async (key: 'apiKey' | 'referer' | 'title' | 'baseUrl') => {
await updateMCPRouterConfig(key, tempMCPRouterConfig[key])
}
await updateMCPRouterConfig(key, tempMCPRouterConfig[key]);
};
type OAuthServerNumberField =
| 'accessTokenLifetime'
| 'refreshTokenLifetime'
| 'authorizationCodeLifetime'
| 'authorizationCodeLifetime';
const handleOAuthServerNumberChange = (key: OAuthServerNumberField, value: string) => {
setTempOAuthServerConfig((prev) => ({
...prev,
[key]: value,
}))
}
}));
};
const handleOAuthServerTextChange = (
key: 'allowedScopes' | 'dynamicRegistrationAllowedGrantTypes',
@@ -288,52 +288,52 @@ const SettingsPage: React.FC = () => {
setTempOAuthServerConfig((prev) => ({
...prev,
[key]: value,
}))
}
}));
};
const saveOAuthServerNumberConfig = async (key: OAuthServerNumberField) => {
const rawValue = tempOAuthServerConfig[key]
const rawValue = tempOAuthServerConfig[key];
if (!rawValue || rawValue.trim() === '') {
showToast(t('settings.invalidNumberInput') || 'Please enter a valid number', 'error')
return
showToast(t('settings.invalidNumberInput') || 'Please enter a valid number', 'error');
return;
}
const parsedValue = Number(rawValue)
const parsedValue = Number(rawValue);
if (Number.isNaN(parsedValue) || parsedValue < 0) {
showToast(t('settings.invalidNumberInput') || 'Please enter a valid number', 'error')
return
showToast(t('settings.invalidNumberInput') || 'Please enter a valid number', 'error');
return;
}
await updateOAuthServerConfig(key, parsedValue)
}
await updateOAuthServerConfig(key, parsedValue);
};
const saveOAuthServerAllowedScopes = async () => {
const scopes = tempOAuthServerConfig.allowedScopes
.split(',')
.map((scope) => scope.trim())
.filter((scope) => scope.length > 0)
.filter((scope) => scope.length > 0);
await updateOAuthServerConfig('allowedScopes', scopes)
}
await updateOAuthServerConfig('allowedScopes', scopes);
};
const saveOAuthServerGrantTypes = async () => {
const grantTypes = tempOAuthServerConfig.dynamicRegistrationAllowedGrantTypes
.split(',')
.map((grant) => grant.trim())
.filter((grant) => grant.length > 0)
.filter((grant) => grant.length > 0);
await updateOAuthServerConfig('dynamicRegistration', {
...oauthServerConfig.dynamicRegistration,
allowedGrantTypes: grantTypes,
})
}
});
};
const handleOAuthServerToggle = async (
key: 'enabled' | 'requireClientSecret' | 'requireState',
value: boolean,
) => {
await updateOAuthServerConfig(key, value)
}
await updateOAuthServerConfig(key, value);
};
const handleDynamicRegistrationToggle = async (
updates: Partial<typeof oauthServerConfig.dynamicRegistration>,
@@ -341,137 +341,137 @@ const SettingsPage: React.FC = () => {
await updateOAuthServerConfig('dynamicRegistration', {
...oauthServerConfig.dynamicRegistration,
...updates,
})
}
});
};
const saveNameSeparator = async () => {
await updateNameSeparator(tempNameSeparator)
}
await updateNameSeparator(tempNameSeparator);
};
const handleSmartRoutingEnabledChange = async (value: boolean) => {
// If enabling Smart Routing, validate required fields and save any unsaved changes
if (value) {
const currentDbUrl = tempSmartRoutingConfig.dbUrl || smartRoutingConfig.dbUrl
const currentDbUrl = tempSmartRoutingConfig.dbUrl || smartRoutingConfig.dbUrl;
const currentOpenaiApiKey =
tempSmartRoutingConfig.openaiApiKey || smartRoutingConfig.openaiApiKey
tempSmartRoutingConfig.openaiApiKey || smartRoutingConfig.openaiApiKey;
if (!currentDbUrl || !currentOpenaiApiKey) {
const missingFields = []
if (!currentDbUrl) missingFields.push(t('settings.dbUrl'))
if (!currentOpenaiApiKey) missingFields.push(t('settings.openaiApiKey'))
const missingFields = [];
if (!currentDbUrl) missingFields.push(t('settings.dbUrl'));
if (!currentOpenaiApiKey) missingFields.push(t('settings.openaiApiKey'));
showToast(
t('settings.smartRoutingValidationError', {
fields: missingFields.join(', '),
}),
)
return
);
return;
}
// Prepare updates object with unsaved changes and enabled status
const updates: any = { enabled: value }
const updates: any = { enabled: value };
// Check for unsaved changes and include them in the batch update
if (tempSmartRoutingConfig.dbUrl !== smartRoutingConfig.dbUrl) {
updates.dbUrl = tempSmartRoutingConfig.dbUrl
updates.dbUrl = tempSmartRoutingConfig.dbUrl;
}
if (tempSmartRoutingConfig.openaiApiBaseUrl !== smartRoutingConfig.openaiApiBaseUrl) {
updates.openaiApiBaseUrl = tempSmartRoutingConfig.openaiApiBaseUrl
updates.openaiApiBaseUrl = tempSmartRoutingConfig.openaiApiBaseUrl;
}
if (tempSmartRoutingConfig.openaiApiKey !== smartRoutingConfig.openaiApiKey) {
updates.openaiApiKey = tempSmartRoutingConfig.openaiApiKey
updates.openaiApiKey = tempSmartRoutingConfig.openaiApiKey;
}
if (
tempSmartRoutingConfig.openaiApiEmbeddingModel !==
smartRoutingConfig.openaiApiEmbeddingModel
) {
updates.openaiApiEmbeddingModel = tempSmartRoutingConfig.openaiApiEmbeddingModel
updates.openaiApiEmbeddingModel = tempSmartRoutingConfig.openaiApiEmbeddingModel;
}
// Save all changes in a single batch update
await updateSmartRoutingConfigBatch(updates)
await updateSmartRoutingConfigBatch(updates);
} else {
// If disabling, just update the enabled status
await updateSmartRoutingConfig('enabled', value)
await updateSmartRoutingConfig('enabled', value);
}
}
};
const handlePasswordChangeSuccess = () => {
setTimeout(() => {
navigate('/')
}, 2000)
}
navigate('/');
}, 2000);
};
const [copiedConfig, setCopiedConfig] = useState(false)
const [mcpSettingsJson, setMcpSettingsJson] = useState<string>('')
const [copiedConfig, setCopiedConfig] = useState(false);
const [mcpSettingsJson, setMcpSettingsJson] = useState<string>('');
const fetchMcpSettings = async () => {
try {
const result = await exportMCPSettings()
console.log('Fetched MCP settings:', result)
const configJson = JSON.stringify(result.data, null, 2)
setMcpSettingsJson(configJson)
const result = await exportMCPSettings();
console.log('Fetched MCP settings:', result);
const configJson = JSON.stringify(result.data, null, 2);
setMcpSettingsJson(configJson);
} catch (error) {
console.error('Error fetching MCP settings:', error)
showToast(t('settings.exportError') || 'Failed to fetch settings', 'error')
console.error('Error fetching MCP settings:', error);
showToast(t('settings.exportError') || 'Failed to fetch settings', 'error');
}
}
};
useEffect(() => {
if (sectionsVisible.exportConfig && !mcpSettingsJson) {
fetchMcpSettings()
fetchMcpSettings();
}
}, [sectionsVisible.exportConfig])
}, [sectionsVisible.exportConfig]);
const handleCopyConfig = async () => {
if (!mcpSettingsJson) return
if (!mcpSettingsJson) return;
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(mcpSettingsJson)
setCopiedConfig(true)
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
setTimeout(() => setCopiedConfig(false), 2000)
await navigator.clipboard.writeText(mcpSettingsJson);
setCopiedConfig(true);
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success');
setTimeout(() => setCopiedConfig(false), 2000);
} else {
// Fallback for HTTP or unsupported clipboard API
const textArea = document.createElement('textarea')
textArea.value = mcpSettingsJson
textArea.style.position = 'fixed'
textArea.style.left = '-9999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
const textArea = document.createElement('textarea');
textArea.value = mcpSettingsJson;
textArea.style.position = 'fixed';
textArea.style.left = '-9999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy')
setCopiedConfig(true)
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
setTimeout(() => setCopiedConfig(false), 2000)
document.execCommand('copy');
setCopiedConfig(true);
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success');
setTimeout(() => setCopiedConfig(false), 2000);
} catch (err) {
showToast(t('common.copyFailed') || 'Copy failed', 'error')
console.error('Copy to clipboard failed:', err)
showToast(t('common.copyFailed') || 'Copy failed', 'error');
console.error('Copy to clipboard failed:', err);
}
document.body.removeChild(textArea)
document.body.removeChild(textArea);
}
} catch (error) {
console.error('Error copying configuration:', error)
showToast(t('common.copyFailed') || 'Copy failed', 'error')
console.error('Error copying configuration:', error);
showToast(t('common.copyFailed') || 'Copy failed', 'error');
}
}
};
const handleDownloadConfig = () => {
if (!mcpSettingsJson) return
if (!mcpSettingsJson) return;
const blob = new Blob([mcpSettingsJson], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = 'mcp_settings.json'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
showToast(t('settings.exportSuccess') || 'Settings exported successfully', 'success')
}
const blob = new Blob([mcpSettingsJson], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'mcp_settings.json';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
showToast(t('settings.exportSuccess') || 'Settings exported successfully', 'success');
};
return (
<div className="container mx-auto">
@@ -643,9 +643,7 @@ const SettingsPage: React.FC = () => {
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
<div>
<h3 className="font-medium text-gray-700">
{t('settings.requireClientSecret')}
</h3>
<h3 className="font-medium text-gray-700">{t('settings.requireClientSecret')}</h3>
<p className="text-sm text-gray-500">
{t('settings.requireClientSecretDescription')}
</p>
@@ -673,9 +671,7 @@ const SettingsPage: React.FC = () => {
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2">
<h3 className="font-medium text-gray-700">
{t('settings.accessTokenLifetime')}
</h3>
<h3 className="font-medium text-gray-700">{t('settings.accessTokenLifetime')}</h3>
<p className="text-sm text-gray-500">
{t('settings.accessTokenLifetimeDescription')}
</p>
@@ -764,9 +760,7 @@ const SettingsPage: React.FC = () => {
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2">
<h3 className="font-medium text-gray-700">{t('settings.allowedScopes')}</h3>
<p className="text-sm text-gray-500">
{t('settings.allowedScopesDescription')}
</p>
<p className="text-sm text-gray-500">{t('settings.allowedScopesDescription')}</p>
</div>
<div className="flex items-center gap-3">
<input
@@ -946,142 +940,154 @@ const SettingsPage: React.FC = () => {
</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 className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
<div>
<h3 className="font-medium text-gray-700">{t('settings.enableSessionRebuild')}</h3>
<p className="text-sm text-gray-500">{t('settings.enableSessionRebuildDescription')}</p>
</div>
<Switch
disabled={loading}
checked={enableSessionRebuild}
onCheckedChange={(checked) => updateSessionRebuild(checked)}
/>
</div>
<PermissionChecker permissions={PERMISSIONS.SETTINGS_SYSTEM_CONFIG}>
<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>
)}
</div>
{/* Route Configuration 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('routingConfig')}
>
<h2 className="font-semibold text-gray-800">{t('pages.settings.routeConfig')}</h2>
<span className="text-gray-500">{sectionsVisible.routingConfig ? '▼' : '►'}</span>
</div>
{sectionsVisible.routingConfig && (
<div className="space-y-4 mt-4">
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
<div>
<h3 className="font-medium text-gray-700">{t('settings.enableBearerAuth')}</h3>
<p className="text-sm text-gray-500">{t('settings.enableBearerAuthDescription')}</p>
</div>
<Switch
disabled={loading}
checked={routingConfig.enableBearerAuth}
onCheckedChange={(checked) =>
handleRoutingConfigChange('enableBearerAuth', checked)
}
/>
</div>
{routingConfig.enableBearerAuth && (
{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.bearerAuthKey')}</h3>
<p className="text-sm text-gray-500">{t('settings.bearerAuthKeyDescription')}</p>
<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={tempRoutingConfig.bearerAuthKey}
onChange={(e) => handleBearerAuthKeyChange(e.target.value)}
placeholder={t('settings.bearerAuthKeyPlaceholder')}
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 || !routingConfig.enableBearerAuth}
disabled={loading}
maxLength={5}
/>
<button
onClick={saveBearerAuthKey}
disabled={loading || !routingConfig.enableBearerAuth}
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 className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
<div>
<h3 className="font-medium text-gray-700">{t('settings.enableGlobalRoute')}</h3>
<p className="text-sm text-gray-500">
{t('settings.enableGlobalRouteDescription')}
</p>
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
<div>
<h3 className="font-medium text-gray-700">
{t('settings.enableSessionRebuild')}
</h3>
<p className="text-sm text-gray-500">
{t('settings.enableSessionRebuildDescription')}
</p>
</div>
<Switch
disabled={loading}
checked={enableSessionRebuild}
onCheckedChange={(checked) => updateSessionRebuild(checked)}
/>
</div>
<Switch
disabled={loading}
checked={routingConfig.enableGlobalRoute}
onCheckedChange={(checked) =>
handleRoutingConfigChange('enableGlobalRoute', checked)
}
/>
</div>
)}
</div>
</PermissionChecker>
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
<div>
<h3 className="font-medium text-gray-700">{t('settings.enableGroupNameRoute')}</h3>
<p className="text-sm text-gray-500">
{t('settings.enableGroupNameRouteDescription')}
</p>
{/* Route Configuration Settings */}
<PermissionChecker permissions={PERMISSIONS.SETTINGS_ROUTE_CONFIG}>
<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('routingConfig')}
>
<h2 className="font-semibold text-gray-800">{t('pages.settings.routeConfig')}</h2>
<span className="text-gray-500">{sectionsVisible.routingConfig ? '▼' : '►'}</span>
</div>
{sectionsVisible.routingConfig && (
<div className="space-y-4 mt-4">
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
<div>
<h3 className="font-medium text-gray-700">{t('settings.enableBearerAuth')}</h3>
<p className="text-sm text-gray-500">
{t('settings.enableBearerAuthDescription')}
</p>
</div>
<Switch
disabled={loading}
checked={routingConfig.enableBearerAuth}
onCheckedChange={(checked) =>
handleRoutingConfigChange('enableBearerAuth', checked)
}
/>
</div>
{routingConfig.enableBearerAuth && (
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2">
<h3 className="font-medium text-gray-700">{t('settings.bearerAuthKey')}</h3>
<p className="text-sm text-gray-500">
{t('settings.bearerAuthKeyDescription')}
</p>
</div>
<div className="flex items-center gap-3">
<input
type="text"
value={tempRoutingConfig.bearerAuthKey}
onChange={(e) => handleBearerAuthKeyChange(e.target.value)}
placeholder={t('settings.bearerAuthKeyPlaceholder')}
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 || !routingConfig.enableBearerAuth}
/>
<button
onClick={saveBearerAuthKey}
disabled={loading || !routingConfig.enableBearerAuth}
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 className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
<div>
<h3 className="font-medium text-gray-700">{t('settings.enableGlobalRoute')}</h3>
<p className="text-sm text-gray-500">
{t('settings.enableGlobalRouteDescription')}
</p>
</div>
<Switch
disabled={loading}
checked={routingConfig.enableGlobalRoute}
onCheckedChange={(checked) =>
handleRoutingConfigChange('enableGlobalRoute', checked)
}
/>
</div>
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
<div>
<h3 className="font-medium text-gray-700">
{t('settings.enableGroupNameRoute')}
</h3>
<p className="text-sm text-gray-500">
{t('settings.enableGroupNameRouteDescription')}
</p>
</div>
<Switch
disabled={loading}
checked={routingConfig.enableGroupNameRoute}
onCheckedChange={(checked) =>
handleRoutingConfigChange('enableGroupNameRoute', checked)
}
/>
</div>
<Switch
disabled={loading}
checked={routingConfig.enableGroupNameRoute}
onCheckedChange={(checked) =>
handleRoutingConfigChange('enableGroupNameRoute', checked)
}
/>
</div>
<PermissionChecker permissions={PERMISSIONS.SETTINGS_SKIP_AUTH}>
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
<div>
<h3 className="font-medium text-gray-700">{t('settings.skipAuth')}</h3>
@@ -1093,10 +1099,10 @@ const SettingsPage: React.FC = () => {
onCheckedChange={(checked) => handleRoutingConfigChange('skipAuth', checked)}
/>
</div>
</PermissionChecker>
</div>
)}
</div>
</div>
)}
</div>
</PermissionChecker>
{/* Installation Configuration Settings */}
<PermissionChecker permissions={PERMISSIONS.SETTINGS_INSTALL_CONFIG}>
@@ -1188,7 +1194,10 @@ const SettingsPage: React.FC = () => {
</PermissionChecker>
{/* Change Password */}
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card" data-section="password">
<div
className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card"
data-section="password"
>
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => toggleSection('password')}
@@ -1258,7 +1267,7 @@ const SettingsPage: React.FC = () => {
</div>
</PermissionChecker>
</div>
)
}
);
};
export default SettingsPage
export default SettingsPage;

View File

@@ -5,7 +5,8 @@ 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';
import { Edit, Trash, User as UserIcon } from 'lucide-react';
import DeleteDialog from '@/components/ui/DeleteDialog';
const UsersPage: React.FC = () => {
const { t } = useTranslation();
@@ -22,11 +23,12 @@ const UsersPage: React.FC = () => {
const [editingUser, setEditingUser] = useState<User | null>(null);
const [showAddForm, setShowAddForm] = useState(false);
const [userToDelete, setUserToDelete] = useState<string | null>(null);
// Check if current user is admin
if (!currentUser?.isAdmin) {
return (
<div className="bg-white shadow rounded-lg p-6">
<div className="bg-white shadow rounded-lg p-6 dashboard-card">
<p className="text-red-600">{t('users.adminRequired')}</p>
</div>
);
@@ -41,10 +43,17 @@ const UsersPage: React.FC = () => {
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 handleDeleteClick = (username: string) => {
setUserToDelete(username);
};
const handleConfirmDelete = async () => {
if (userToDelete) {
const result = await deleteUser(userToDelete);
if (!result?.success) {
setUserError(result?.message || t('users.deleteError'));
}
setUserToDelete(null);
}
};
@@ -58,13 +67,13 @@ const UsersPage: React.FC = () => {
};
return (
<div>
<div className="container mx-auto">
<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"
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 flex items-center btn-primary transition-all duration-200 shadow-sm"
>
<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" />
@@ -75,13 +84,23 @@ const UsersPage: React.FC = () => {
</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 className="bg-red-50 border-l-4 border-red-500 text-red-700 p-4 mb-6 error-box rounded-lg shadow-sm">
<div className="flex justify-between items-center">
<p>{userError}</p>
<button
onClick={() => setUserError(null)}
className="text-red-500 hover:text-red-700"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 111.414 1.414L11.414 10l4.293 4.293a1 1 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 01-1.414-1.414L8.586 10 4.293 5.707a1 1 010-1.414z" clipRule="evenodd" />
</svg>
</button>
</div>
</div>
)}
{usersLoading ? (
<div className="bg-white shadow rounded-lg p-6 loading-container">
<div className="bg-white shadow rounded-lg p-6 loading-container flex justify-center items-center h-64">
<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>
@@ -91,20 +110,93 @@ const UsersPage: React.FC = () => {
</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 className="bg-white shadow rounded-lg p-6 empty-state dashboard-card">
<div className="flex flex-col items-center justify-center py-12">
<div className="p-4 bg-gray-100 rounded-full mb-4">
<UserIcon className="h-8 w-8 text-gray-400" />
</div>
<p className="text-gray-600 text-lg font-medium">{t('users.noUsers')}</p>
<button
onClick={handleAddUser}
className="mt-4 text-blue-600 hover:text-blue-800 font-medium"
>
{t('users.addFirst')}
</button>
</div>
</div>
) : (
<div className="space-y-6">
{users.map((user) => (
<UserCard
key={user.username}
user={user}
currentUser={currentUser}
onEdit={handleEditClick}
onDelete={handleDeleteUser}
/>
))}
<div className="bg-white shadow rounded-lg overflow-hidden table-container dashboard-card">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('users.username')}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('users.role')}
</th>
<th scope="col" className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('users.actions')}
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{users.map((user) => {
const isCurrentUser = currentUser?.username === user.username;
return (
<tr key={user.username} className="hover:bg-gray-50 transition-colors duration-150">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10">
<div className="h-10 w-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold text-lg">
{user.username.charAt(0).toUpperCase()}
</div>
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900 flex items-center">
{user.username}
{isCurrentUser && (
<span className="ml-2 px-2 py-0.5 text-xs bg-blue-100 text-blue-800 rounded-full border border-blue-200">
{t('users.currentUser')}
</span>
)}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${user.isAdmin
? 'bg-purple-100 text-purple-800 border border-purple-200'
: 'bg-gray-100 text-gray-800 border border-gray-200'
}`}>
{user.isAdmin ? t('users.admin') : t('users.user')}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex justify-end space-x-3">
<button
onClick={() => handleEditClick(user)}
className="text-blue-600 hover:text-blue-900 p-1 rounded hover:bg-blue-50 transition-colors"
title={t('users.edit')}
>
<Edit size={18} />
</button>
{!isCurrentUser && (
<button
onClick={() => handleDeleteClick(user.username)}
className="text-red-600 hover:text-red-900 p-1 rounded hover:bg-red-50 transition-colors"
title={t('users.delete')}
>
<Trash size={18} />
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
@@ -119,6 +211,15 @@ const UsersPage: React.FC = () => {
onCancel={() => setEditingUser(null)}
/>
)}
<DeleteDialog
isOpen={!!userToDelete}
onClose={() => setUserToDelete(null)}
onConfirm={handleConfirmDelete}
serverName={userToDelete || ''}
isGroup={false}
isUser={true}
/>
</div>
);
};

View File

@@ -673,9 +673,13 @@
"password": "Password",
"newPassword": "New Password",
"confirmPassword": "Confirm Password",
"changePassword": "Change Password",
"adminRole": "Administrator",
"admin": "Admin",
"user": "User",
"role": "Role",
"actions": "Actions",
"addFirst": "Add your first user",
"permissions": "Permissions",
"adminPermissions": "Full system access",
"userPermissions": "Limited access",

View File

@@ -673,9 +673,13 @@
"password": "Mot de passe",
"newPassword": "Nouveau mot de passe",
"confirmPassword": "Confirmer le mot de passe",
"changePassword": "Changer le mot de passe",
"adminRole": "Administrateur",
"admin": "Admin",
"user": "Utilisateur",
"role": "Rôle",
"actions": "Actions",
"addFirst": "Ajoutez votre premier utilisateur",
"permissions": "Permissions",
"adminPermissions": "Accès complet au système",
"userPermissions": "Accès limité",

View File

@@ -673,9 +673,13 @@
"password": "Şifre",
"newPassword": "Yeni Şifre",
"confirmPassword": "Şifreyi Onayla",
"changePassword": "Şifre Değiştir",
"adminRole": "Yönetici",
"admin": "Yönetici",
"user": "Kullanıcı",
"role": "Rol",
"actions": "Eylemler",
"addFirst": "İlk kullanıcınızı ekleyin",
"permissions": "İzinler",
"adminPermissions": "Tam sistem erişimi",
"userPermissions": "Sınırlı erişim",

View File

@@ -675,9 +675,13 @@
"password": "密码",
"newPassword": "新密码",
"confirmPassword": "确认密码",
"changePassword": "修改密码",
"adminRole": "管理员",
"admin": "管理员",
"user": "用户",
"role": "角色",
"actions": "操作",
"addFirst": "添加第一个用户",
"permissions": "权限",
"adminPermissions": "完全系统访问权限",
"userPermissions": "受限访问权限",

View File

@@ -41,27 +41,5 @@
"password": "$2b$10$Vt7krIvjNgyN67LXqly0uOcTpN0LI55cYRbcKC71pUDAP0nJ7RPa.",
"isAdmin": true
}
],
"systemConfig": {
"oauthServer": {
"enabled": true,
"accessTokenLifetime": 3600,
"refreshTokenLifetime": 1209600,
"authorizationCodeLifetime": 300,
"requireClientSecret": false,
"allowedScopes": [
"read",
"write"
],
"requireState": false,
"dynamicRegistration": {
"enabled": true,
"allowedGrantTypes": [
"authorization_code",
"refresh_token"
],
"requiresAuthentication": false
}
}
}
]
}

View File

@@ -543,7 +543,7 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
const hasNameSeparatorUpdate = typeof nameSeparator === 'string';
const hasSessionRebuildUpdate = typeof enableSessionRebuild !== 'boolean';
const hasSessionRebuildUpdate = typeof enableSessionRebuild === 'boolean';
const hasOAuthServerUpdate =
oauthServer &&

View File

@@ -187,7 +187,7 @@ export async function exampleUserConfigOperations() {
console.log('All user configs:', Object.keys(allUserConfigs));
// Get specific section for user
const userRoutingConfig = await userConfigDao.getSection('admin', 'routing');
const userRoutingConfig = await userConfigDao.getSection('admin', 'routing' as never);
console.log('Admin routing config:', userRoutingConfig);
// Delete user configuration

View File

@@ -1,13 +0,0 @@
import { DataService } from './dataService.js';
import { getDataService } from './services.js';
import './services.js';
describe('DataService', () => {
test('should get default implementation and call foo method', async () => {
const dataService: DataService = await getDataService();
const consoleSpy = jest.spyOn(console, 'log');
dataService.foo();
expect(consoleSpy).toHaveBeenCalledWith('default implementation');
consoleSpy.mockRestore();
});
});

View File

@@ -1,31 +1,69 @@
import { IUser, McpSettings } from '../types/index.js';
import { UserContextService } from './userContextService.js';
import { UserConfig } from '../types/index.js';
export interface DataService {
foo(): void;
filterData(data: any[], user?: IUser): any[];
filterSettings(settings: McpSettings, user?: IUser): McpSettings;
mergeSettings(all: McpSettings, newSettings: McpSettings, user?: IUser): McpSettings;
getPermissions(user: IUser): string[];
}
export class DataServiceImpl implements DataService {
foo() {
console.log('default implementation');
export class DataService {
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);
}
}
filterData(data: any[], _user?: IUser): any[] {
return data;
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 };
// TODO: apply userConfig to filter settings as needed
// const userConfig = settings.userConfigs?.[currentUser?.username || ''];
delete result.userConfigs;
return result;
}
}
filterSettings(settings: McpSettings, _user?: IUser): McpSettings {
return settings;
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.mcpServers = newSettings.mcpServers;
result.users = newSettings.users;
result.systemConfig = newSettings.systemConfig;
result.groups = newSettings.groups;
result.oauthClients = newSettings.oauthClients;
result.oauthTokens = newSettings.oauthTokens;
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
? {
// TODO: only allow modifying certain fields based on userConfig permissions
}
: undefined,
};
result.userConfigs[currentUser?.username || ''] = userConfig;
return result;
}
}
mergeSettings(all: McpSettings, newSettings: McpSettings, _user?: IUser): McpSettings {
return newSettings;
}
getPermissions(_user: IUser): string[] {
return ['*'];
getPermissions(user: IUser): string[] {
if (user && user.isAdmin) {
return ['*', 'x'];
} else {
return [''];
}
}
}

View File

@@ -1,75 +0,0 @@
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.mcpServers = newSettings.mcpServers;
result.users = newSettings.users;
result.systemConfig = newSettings.systemConfig;
result.groups = newSettings.groups;
result.oauthClients = newSettings.oauthClients;
result.oauthTokens = newSettings.oauthTokens;
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

@@ -1,5 +1,5 @@
import { createRequire } from 'module';
import { join } from 'path';
import { pathToFileURL } from 'url';
type Class<T> = new (...args: any[]) => T;
@@ -11,7 +11,24 @@ interface Service<T> {
const registry = new Map<string, Service<any>>();
const instances = new Map<string, unknown>();
export function registerService<T>(key: string, entry: Service<T>) {
async function tryLoadOverride<T>(key: string, overridePath: string): Promise<Class<T> | undefined> {
try {
const moduleUrl = pathToFileURL(overridePath).href;
const mod = await import(moduleUrl);
const override = mod[key.charAt(0).toUpperCase() + key.slice(1) + 'x'];
if (typeof override === 'function') {
return override as Class<T>;
}
} catch (error: any) {
// Ignore not-found errors and keep trying other paths; surface other errors for visibility
if (error?.code !== 'ERR_MODULE_NOT_FOUND' && error?.code !== 'MODULE_NOT_FOUND') {
console.warn(`Failed to load service override from ${overridePath}:`, error);
}
}
return undefined;
}
export async function registerService<T>(key: string, entry: Service<T>) {
// Try to load override immediately during registration
// Try multiple paths and file extensions in order
const serviceDirs = ['src/services', 'dist/services'];
@@ -22,18 +39,10 @@ export function registerService<T>(key: string, entry: Service<T>) {
for (const fileExt of fileExts) {
const overridePath = join(process.cwd(), serviceDir, overrideFileName + fileExt);
try {
// Use createRequire with a stable path reference
const require = createRequire(join(process.cwd(), 'package.json'));
const mod = require(overridePath);
const override = mod[key.charAt(0).toUpperCase() + key.slice(1) + 'x'];
if (typeof override === 'function') {
entry.override = override;
break; // Found override, exit both loops
}
} catch (error) {
// Continue trying next path/extension combination
continue;
const override = await tryLoadOverride<T>(key, overridePath);
if (override) {
entry.override = override;
break; // Found override, exit both loops
}
}

View File

@@ -1,10 +1,5 @@
import { registerService, getService } from './registry.js';
import { DataService, DataServiceImpl } from './dataService.js';
registerService('dataService', {
defaultImpl: DataServiceImpl,
});
import { DataService } from './dataService.js';
export function getDataService(): DataService {
return getService<DataService>('dataService');
return new DataService();
}

View File

@@ -175,14 +175,7 @@ export interface SystemConfig {
enableSessionRebuild?: boolean; // Controls whether server session rebuild is enabled
}
export interface UserConfig {
routing?: {
enableGlobalRoute?: boolean; // Controls whether the /sse endpoint without group is enabled
enableGroupNameRoute?: boolean; // Controls whether group routing by name is allowed
enableBearerAuth?: boolean; // Controls whether bearer auth is enabled for group routes
bearerAuthKey?: string; // The bearer auth key to validate against
};
}
export interface UserConfig {}
// OAuth Client for MCPHub's own authorization server
export interface IOAuthClient {