|
|
@@ -20,7 +20,7 @@ interface SettingsViewProps {
|
|
|
initialTab?: TabType;
|
|
|
}
|
|
|
|
|
|
-type TabType = 'general' | 'user' | 'model' | 'dashboard' | 'knowledge_base';
|
|
|
+type TabType = 'general' | 'user' | 'model' | 'tenants' | 'knowledge_base';
|
|
|
|
|
|
export const SettingsView: React.FC<SettingsViewProps> = ({
|
|
|
models,
|
|
|
@@ -75,6 +75,10 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|
|
const [isSettingsLoading, setIsSettingsLoading] = useState(false);
|
|
|
const [enabledModelIds, setEnabledModelIds] = useState<string[]>([]);
|
|
|
|
|
|
+ // --- Tenant Admin Binding Search State ---
|
|
|
+ const [bindingTenantId, setBindingTenantId] = useState<string | null>(null);
|
|
|
+ const [userSearchQuery, setUserSearchQuery] = useState('');
|
|
|
+
|
|
|
useEffect(() => {
|
|
|
if (initialTab) {
|
|
|
setActiveTab(initialTab);
|
|
|
@@ -89,8 +93,9 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|
|
fetchSettingsAndGroups();
|
|
|
} else if (activeTab === 'model' && (isAdmin || currentUser?.role === 'SUPER_ADMIN')) {
|
|
|
// Model tab initialization
|
|
|
- } else if (activeTab === 'dashboard' && currentUser?.role === 'SUPER_ADMIN') {
|
|
|
- fetchDashboardData();
|
|
|
+ } else if (activeTab === 'tenants' && currentUser?.role === 'SUPER_ADMIN') {
|
|
|
+ fetchTenantsData();
|
|
|
+ fetchUsers(); // Ensure users are loaded for admin binding
|
|
|
} else if (activeTab === 'knowledge_base' && (currentUser?.role === 'TENANT_ADMIN' || currentUser?.role === 'SUPER_ADMIN')) {
|
|
|
fetchKnowledgeBaseSettings();
|
|
|
}
|
|
|
@@ -244,20 +249,21 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|
|
}
|
|
|
};
|
|
|
|
|
|
- // --- Dashboard State (Migrated) ---
|
|
|
+ // --- Tenants Management State (Migrated) ---
|
|
|
const [stats, setStats] = useState({ tenants: 0, users: 0 });
|
|
|
const [tenants, setTenants] = useState<any[]>([]);
|
|
|
const [tenantAdmins, setTenantAdmins] = useState<any[]>([]);
|
|
|
- const [isDashboardLoading, setIsDashboardLoading] = useState(false);
|
|
|
+ const [isTenantsLoading, setIsTenantsLoading] = useState(false);
|
|
|
const [showCreateTenant, setShowCreateTenant] = useState(false);
|
|
|
+ const [editingTenant, setEditingTenant] = useState<any | null>(null);
|
|
|
const [newTenant, setNewTenant] = useState({ name: '', domain: '', adminUserId: '' });
|
|
|
|
|
|
- const fetchDashboardData = async () => {
|
|
|
- setIsDashboardLoading(true);
|
|
|
+ const fetchTenantsData = async () => {
|
|
|
+ setIsTenantsLoading(true);
|
|
|
try {
|
|
|
const headers = { 'Authorization': `Bearer ${authToken}`, 'x-api-key': authToken || '' };
|
|
|
const [tenRes, admRes] = await Promise.all([
|
|
|
- fetch('/api/v1/super-admin/tenants', { headers }),
|
|
|
+ fetch('/api/v1/tenants', { headers }),
|
|
|
fetch('/api/v1/admin/users', { headers })
|
|
|
]);
|
|
|
if (tenRes.ok) {
|
|
|
@@ -273,40 +279,80 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|
|
} catch (e) {
|
|
|
console.error(e);
|
|
|
} finally {
|
|
|
- setIsDashboardLoading(false);
|
|
|
+ setIsTenantsLoading(false);
|
|
|
}
|
|
|
};
|
|
|
|
|
|
const handleCreateTenant = async (e: React.FormEvent) => {
|
|
|
e.preventDefault();
|
|
|
try {
|
|
|
- const res = await fetch('/api/v1/super-admin/tenants', {
|
|
|
- method: 'POST',
|
|
|
- headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${authToken}`, 'x-api-key': authToken || '' },
|
|
|
- body: JSON.stringify(newTenant)
|
|
|
+ if (editingTenant) {
|
|
|
+ const res = await fetch(`/api/v1/tenants/${editingTenant.id}`, {
|
|
|
+ method: 'PUT',
|
|
|
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${authToken}`, 'x-api-key': authToken || '' },
|
|
|
+ body: JSON.stringify({ name: newTenant.name, domain: newTenant.domain })
|
|
|
+ });
|
|
|
+ if (res.ok) {
|
|
|
+ setEditingTenant(null);
|
|
|
+ setNewTenant({ name: '', domain: '', adminUserId: '' });
|
|
|
+ fetchTenantsData();
|
|
|
+ showSuccess('Tenant updated successfully');
|
|
|
+ } else {
|
|
|
+ showError('Failed to update tenant');
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ const res = await fetch('/api/v1/tenants', {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${authToken}`, 'x-api-key': authToken || '' },
|
|
|
+ body: JSON.stringify(newTenant)
|
|
|
+ });
|
|
|
+ if (res.ok) {
|
|
|
+ setShowCreateTenant(false);
|
|
|
+ setNewTenant({ name: '', domain: '', adminUserId: '' });
|
|
|
+ fetchTenantsData();
|
|
|
+ showSuccess('Tenant created successfully');
|
|
|
+ } else {
|
|
|
+ showError('Failed to create tenant');
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ showError('Error processing tenant');
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleDeleteTenant = async (tenantId: string) => {
|
|
|
+ if (!(await confirm('Are you sure you want to delete this tenant? All associated data will be removed.'))) return;
|
|
|
+ try {
|
|
|
+ const res = await fetch(`/api/v1/tenants/${tenantId}`, {
|
|
|
+ method: 'DELETE',
|
|
|
+ headers: { 'Authorization': `Bearer ${authToken}`, 'x-api-key': authToken || '' }
|
|
|
});
|
|
|
if (res.ok) {
|
|
|
- setShowCreateTenant(false);
|
|
|
- setNewTenant({ name: '', domain: '', adminUserId: '' });
|
|
|
- fetchDashboardData();
|
|
|
- showSuccess('Tenant created successfully');
|
|
|
+ showSuccess('Tenant deleted successfully');
|
|
|
+ fetchTenantsData();
|
|
|
+ } else {
|
|
|
+ showError('Failed to delete tenant');
|
|
|
}
|
|
|
} catch (e) {
|
|
|
- showError('Failed to create tenant');
|
|
|
+ showError('Error deleting tenant');
|
|
|
}
|
|
|
};
|
|
|
|
|
|
const handleBindAdmin = async (tenantId: string, userId: string) => {
|
|
|
if (!userId) return;
|
|
|
try {
|
|
|
- const res = await fetch(`/api/v1/super-admin/tenants/${tenantId}/admin`, {
|
|
|
+ const res = await fetch(`/api/v1/tenants/${tenantId}/admin`, {
|
|
|
method: 'PUT',
|
|
|
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${authToken}`, 'x-api-key': authToken || '' },
|
|
|
body: JSON.stringify({ userId })
|
|
|
});
|
|
|
if (res.ok) {
|
|
|
showSuccess('Admin bound successfully');
|
|
|
- fetchDashboardData();
|
|
|
+ // Refresh tenants data and users to ensure all states are in sync
|
|
|
+ await Promise.all([
|
|
|
+ fetchTenantsData(),
|
|
|
+ fetchUsers()
|
|
|
+ ]);
|
|
|
} else {
|
|
|
showError('Failed to bind admin');
|
|
|
}
|
|
|
@@ -324,7 +370,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|
|
});
|
|
|
if (res.ok) {
|
|
|
showSuccess('Feature updated successfully');
|
|
|
- fetchDashboardData();
|
|
|
+ fetchTenantsData();
|
|
|
}
|
|
|
} catch (e) {
|
|
|
showError('Failed to update feature');
|
|
|
@@ -504,7 +550,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|
|
);
|
|
|
|
|
|
const renderUserTab = () => (
|
|
|
- <div className="space-y-6 max-w-4xl">
|
|
|
+ <div className="space-y-6 w-full">
|
|
|
<div className="flex justify-between items-center mb-6">
|
|
|
<div>
|
|
|
<h3 className="text-sm font-black text-slate-500 uppercase tracking-[0.15em] mb-1">{t('userList')}</h3>
|
|
|
@@ -722,8 +768,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|
|
</div>
|
|
|
);
|
|
|
|
|
|
- const renderDashboardTab = () => (
|
|
|
- <div className="max-w-6xl space-y-8 animate-in fade-in duration-500">
|
|
|
+ const renderTenantsTab = () => (
|
|
|
+ <div className="w-full space-y-8 animate-in fade-in duration-500">
|
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
|
<div className="p-6 bg-white border border-slate-200 rounded-3xl shadow-sm">
|
|
|
<div className="flex items-center gap-3 mb-2 text-blue-600">
|
|
|
@@ -767,6 +813,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|
|
<th className="px-6 py-4">Admin</th>
|
|
|
<th className="px-6 py-4">Features</th>
|
|
|
<th className="px-6 py-4">Created</th>
|
|
|
+ <th className="px-6 py-4 text-right">Actions</th>
|
|
|
</tr>
|
|
|
</thead>
|
|
|
<tbody className="divide-y divide-slate-100">
|
|
|
@@ -775,29 +822,49 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|
|
<td className="px-6 py-4 text-sm font-bold text-slate-900">{t.name}</td>
|
|
|
<td className="px-6 py-4 text-xs font-mono text-slate-500">{t.domain || '-'}</td>
|
|
|
<td className="px-6 py-4 text-xs">
|
|
|
- {tenantAdmins.filter(u => u.tenantId === t.id).map(u => (
|
|
|
- <div key={u.id} className="flex items-center gap-1.5 text-slate-600 font-medium">
|
|
|
- <div className="w-5 h-5 rounded-full bg-indigo-50 flex items-center justify-center">
|
|
|
- <User className="w-3 h-3 text-indigo-500" />
|
|
|
- </div>
|
|
|
- {u.username}
|
|
|
- </div>
|
|
|
- ))}
|
|
|
- {tenantAdmins.filter(u => u.tenantId === t.id).length === 0 && (
|
|
|
- <div className="flex items-center gap-2">
|
|
|
- <select
|
|
|
- className="text-[10px] bg-slate-50 border border-slate-200 rounded-lg px-2 py-1 outline-none focus:ring-1 focus:ring-indigo-500/30 transition-all"
|
|
|
- onChange={(e) => handleBindAdmin(t.id, e.target.value)}
|
|
|
- defaultValue=""
|
|
|
- >
|
|
|
- <option value="" disabled>Bind Admin...</option>
|
|
|
- {users.filter(u => u.role !== 'SUPER_ADMIN' && (!u.tenantId || u.tenantId === 'default')).map(u => (
|
|
|
- <option key={u.id} value={u.id}>{u.username}</option>
|
|
|
- ))}
|
|
|
- </select>
|
|
|
- <span className="text-[10px] text-slate-400 italic">None</span>
|
|
|
- </div>
|
|
|
- )}
|
|
|
+ {(() => {
|
|
|
+ const admin = users.find((u: any) =>
|
|
|
+ (u.tenantId === t.id || u.tenant_id === t.id || (t.name === 'Default' && (u.tenantId === 'default' || u.tenant_id === 'default'))) &&
|
|
|
+ (u.role === 'TENANT_ADMIN' || u.isAdmin)
|
|
|
+ );
|
|
|
+ if (admin) {
|
|
|
+ return (
|
|
|
+ <div className="flex items-center gap-1.5 text-slate-600 font-medium">
|
|
|
+ <div className="w-5 h-5 rounded-full bg-indigo-50 flex items-center justify-center">
|
|
|
+ <User className="w-3 h-3 text-indigo-500" />
|
|
|
+ </div>
|
|
|
+ {admin.username}
|
|
|
+ <button
|
|
|
+ onClick={() => {
|
|
|
+ setBindingTenantId(t.id);
|
|
|
+ setUserSearchQuery('');
|
|
|
+ fetchUsers();
|
|
|
+ }}
|
|
|
+ className="ml-2 p-1 hover:bg-indigo-50 text-slate-400 hover:text-indigo-600 rounded transition-all"
|
|
|
+ title="Change Admin"
|
|
|
+ >
|
|
|
+ <Edit2 size={12} />
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ } else {
|
|
|
+ return (
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <button
|
|
|
+ onClick={() => {
|
|
|
+ setBindingTenantId(t.id);
|
|
|
+ setUserSearchQuery('');
|
|
|
+ fetchUsers();
|
|
|
+ }}
|
|
|
+ className="px-3 py-1.5 bg-indigo-50 text-indigo-600 hover:bg-indigo-100 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all"
|
|
|
+ >
|
|
|
+ Bind Admin
|
|
|
+ </button>
|
|
|
+ <span className="text-[10px] text-slate-400 italic">None</span>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ }
|
|
|
+ })()}
|
|
|
</td>
|
|
|
<td className="px-6 py-4">
|
|
|
<div className="flex items-center gap-2">
|
|
|
@@ -813,6 +880,27 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|
|
</div>
|
|
|
</td>
|
|
|
<td className="px-6 py-4 text-xs text-slate-400">{new Date(t.createdAt).toLocaleDateString()}</td>
|
|
|
+ <td className="px-6 py-4 text-right">
|
|
|
+ <div className="flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-all">
|
|
|
+ <button
|
|
|
+ onClick={() => {
|
|
|
+ setEditingTenant(t);
|
|
|
+ setNewTenant({ name: t.name, domain: t.domain || '', adminUserId: '' });
|
|
|
+ }}
|
|
|
+ className="p-1.5 hover:bg-slate-100 text-slate-400 hover:text-indigo-600 rounded-lg transition-all"
|
|
|
+ title="Edit Tenant"
|
|
|
+ >
|
|
|
+ <Edit2 size={14} />
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ onClick={() => handleDeleteTenant(t.id)}
|
|
|
+ className="p-1.5 hover:bg-red-50 text-slate-400 hover:text-red-500 rounded-lg transition-all"
|
|
|
+ title="Delete Tenant"
|
|
|
+ >
|
|
|
+ <Trash2 size={14} />
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </td>
|
|
|
</tr>
|
|
|
))}
|
|
|
</tbody>
|
|
|
@@ -820,41 +908,128 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
- {showCreateTenant && (
|
|
|
+ {(showCreateTenant || editingTenant) && (
|
|
|
<div className="fixed inset-0 z-[120] bg-slate-900/40 backdrop-blur-sm flex items-center justify-center p-4">
|
|
|
<motion.div initial={{ scale: 0.95, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} className="bg-white rounded-3xl p-8 w-full max-w-md shadow-2xl">
|
|
|
- <h3 className="text-xl font-black text-slate-900 mb-6">Create New Tenant</h3>
|
|
|
+ <h3 className="text-xl font-black text-slate-900 mb-6">{editingTenant ? 'Edit Organization' : 'Create New Tenant'}</h3>
|
|
|
<form onSubmit={handleCreateTenant} className="space-y-5">
|
|
|
<input className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm" placeholder="Tenant Name" value={newTenant.name} onChange={e => setNewTenant({ ...newTenant, name: e.target.value })} required />
|
|
|
<input className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm" placeholder="Domain (optional)" value={newTenant.domain} onChange={e => setNewTenant({ ...newTenant, domain: e.target.value })} />
|
|
|
|
|
|
- <div className="space-y-2">
|
|
|
- <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">Assign Initial Admin</label>
|
|
|
- <select
|
|
|
- className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm outline-none focus:ring-2 focus:ring-indigo-500/20 transition-all"
|
|
|
- value={newTenant.adminUserId}
|
|
|
- onChange={e => setNewTenant({ ...newTenant, adminUserId: e.target.value })}
|
|
|
- >
|
|
|
- <option value="">Select a user (optional)</option>
|
|
|
- {users.filter(u => u.role !== 'SUPER_ADMIN').map(u => (
|
|
|
- <option key={u.id} value={u.id}>{u.username} ({u.role})</option>
|
|
|
- ))}
|
|
|
- </select>
|
|
|
- <p className="text-[10px] text-slate-400 px-1 italic">Selecting a user will promote them to Tenant Admin for this organization.</p>
|
|
|
- </div>
|
|
|
+ {!editingTenant && (
|
|
|
+ <div className="space-y-2">
|
|
|
+ <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">Assign Initial Admin</label>
|
|
|
+ <select
|
|
|
+ className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm outline-none focus:ring-2 focus:ring-indigo-500/20 transition-all"
|
|
|
+ value={newTenant.adminUserId}
|
|
|
+ onChange={e => setNewTenant({ ...newTenant, adminUserId: e.target.value })}
|
|
|
+ >
|
|
|
+ <option value="">Select a user (optional)</option>
|
|
|
+ {users.filter(u => u.role === 'TENANT_ADMIN').map(u => (
|
|
|
+ <option key={u.id} value={u.id}>{u.username}</option>
|
|
|
+ ))}
|
|
|
+ </select>
|
|
|
+ <p className="text-[10px] text-slate-400 px-1 italic">Selecting a user will promote them to Tenant Admin for this organization.</p>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
|
|
|
<div className="flex gap-4 pt-2">
|
|
|
- <button type="button" onClick={() => setShowCreateTenant(false)} className="flex-1 py-3 text-slate-500 font-bold text-sm">Cancel</button>
|
|
|
- <button type="submit" className="flex-1 py-3 bg-slate-900 text-white rounded-2xl font-black uppercase tracking-widest text-xs hover:bg-indigo-600">Create</button>
|
|
|
+ <button type="button" onClick={() => { setShowCreateTenant(false); setEditingTenant(null); }} className="flex-1 py-3 text-slate-500 font-bold text-sm">Cancel</button>
|
|
|
+ <button type="submit" className="flex-1 py-3 bg-slate-900 text-white rounded-2xl font-black uppercase tracking-widest text-xs hover:bg-indigo-600">{editingTenant ? 'Save Changes' : 'Create'}</button>
|
|
|
</div>
|
|
|
</form>
|
|
|
</motion.div>
|
|
|
</div>
|
|
|
)}
|
|
|
+
|
|
|
+ {/* Bind Admin Search Modal */}
|
|
|
+ <AnimatePresence>
|
|
|
+ {bindingTenantId && (
|
|
|
+ <div className="fixed inset-0 z-[130] bg-slate-900/60 backdrop-blur-md flex items-center justify-center p-4">
|
|
|
+ <motion.div
|
|
|
+ initial={{ scale: 0.9, opacity: 0 }}
|
|
|
+ animate={{ scale: 1, opacity: 1 }}
|
|
|
+ exit={{ scale: 0.9, opacity: 0 }}
|
|
|
+ className="bg-white rounded-[2.5rem] p-8 w-full max-w-lg shadow-2xl border border-white/20 overflow-hidden relative"
|
|
|
+ >
|
|
|
+ <div className="absolute top-0 right-0 p-6">
|
|
|
+ <button onClick={() => setBindingTenantId(null)} className="p-2 hover:bg-slate-100 rounded-2xl transition-all">
|
|
|
+ <X size={20} className="text-slate-400" />
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="mb-8">
|
|
|
+ <h3 className="text-2xl font-black text-slate-900 tracking-tight mb-2">Bind Tenant Admin</h3>
|
|
|
+ <p className="text-sm text-slate-500 font-medium">Search and select a user to manage <span className="text-indigo-600 font-bold">{tenants.find(t => t.id === bindingTenantId)?.name}</span></p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="relative mb-6">
|
|
|
+ <div className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400">
|
|
|
+ <Globe size={18} />
|
|
|
+ </div>
|
|
|
+ <input
|
|
|
+ autoFocus
|
|
|
+ className="w-full pl-12 pr-4 py-4 bg-slate-50 border border-slate-200 rounded-3xl text-sm font-medium focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all"
|
|
|
+ placeholder="Search users by name..."
|
|
|
+ value={userSearchQuery}
|
|
|
+ onChange={e => setUserSearchQuery(e.target.value)}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="max-h-[350px] overflow-y-auto pr-2 flex flex-col gap-3 scrollbar-hide">
|
|
|
+ {users
|
|
|
+ .filter(u =>
|
|
|
+ (u.role === 'TENANT_ADMIN' || u.isAdmin) &&
|
|
|
+ u.role !== 'SUPER_ADMIN' &&
|
|
|
+ u.username.toLowerCase().includes(userSearchQuery.toLowerCase())
|
|
|
+ )
|
|
|
+ .map(u => (
|
|
|
+ <button
|
|
|
+ key={u.id}
|
|
|
+ onClick={() => {
|
|
|
+ handleBindAdmin(bindingTenantId, u.id);
|
|
|
+ setBindingTenantId(null);
|
|
|
+ }}
|
|
|
+ className="w-full flex items-center justify-between p-4 bg-slate-50/50 hover:bg-indigo-50 border border-slate-200/50 hover:border-indigo-200 rounded-[1.5rem] transition-all group"
|
|
|
+ >
|
|
|
+ <div className="flex items-center gap-3">
|
|
|
+ <div className="w-10 h-10 rounded-xl bg-white flex items-center justify-center text-slate-400 group-hover:text-indigo-600 group-hover:shadow-sm transition-all">
|
|
|
+ <User size={18} />
|
|
|
+ </div>
|
|
|
+ <div className="text-left">
|
|
|
+ <p className="text-sm font-black text-slate-900">{u.username}</p>
|
|
|
+ <p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">{u.role || 'User'}</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <ChevronRight size={16} className="text-slate-300 group-hover:text-indigo-400 group-hover:translate-x-1 transition-all" />
|
|
|
+ </button>
|
|
|
+ ))}
|
|
|
+
|
|
|
+ {users.filter(u =>
|
|
|
+ (u.role === 'TENANT_ADMIN' || u.isAdmin) &&
|
|
|
+ u.role !== 'SUPER_ADMIN' &&
|
|
|
+ u.username.toLowerCase().includes(userSearchQuery.toLowerCase())
|
|
|
+ ).length === 0 && (
|
|
|
+ <div className="py-12 text-center opacity-40">
|
|
|
+ <User size={40} className="mx-auto mb-3" />
|
|
|
+ <p className="text-xs font-bold uppercase tracking-widest">No unassigned users found</p>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="mt-8 pt-6 border-t border-slate-100 flex justify-end">
|
|
|
+ <button onClick={() => setBindingTenantId(null)} className="px-6 py-3 text-sm font-bold text-slate-500 hover:text-slate-700 transition-all">
|
|
|
+ Cancel
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </motion.div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </AnimatePresence>
|
|
|
</div>
|
|
|
);
|
|
|
const renderKnowledgeBaseTab = () => (
|
|
|
- <div className="space-y-8 animate-in slide-in-from-right duration-300 max-w-2xl pb-10">
|
|
|
+ <div className="space-y-8 animate-in slide-in-from-right duration-300 w-full max-w-5xl pb-10">
|
|
|
{kbSettings && (
|
|
|
<>
|
|
|
{/* Model Configuration */}
|
|
|
@@ -1031,6 +1206,73 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|
|
</div>
|
|
|
</motion.div>
|
|
|
)}
|
|
|
+
|
|
|
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
|
+ <div className="flex items-center justify-between p-5 bg-slate-50/50 rounded-2xl border border-slate-200/30 transition-all hover:bg-white hover:border-indigo-100">
|
|
|
+ <div>
|
|
|
+ <div className="text-sm font-bold text-slate-800">Enable Query Expansion</div>
|
|
|
+ <div className="text-[10px] text-slate-400 font-medium">Rewrites query for better recall.</div>
|
|
|
+ </div>
|
|
|
+ <button
|
|
|
+ onClick={() => handleUpdateKbSettings('enableQueryExpansion', !kbSettings.enableQueryExpansion)}
|
|
|
+ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-all duration-300 ${kbSettings.enableQueryExpansion ? 'bg-indigo-600 shadow-md shadow-indigo-100' : 'bg-slate-300'}`}
|
|
|
+ >
|
|
|
+ <span className={`${kbSettings.enableQueryExpansion ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`} />
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="flex items-center justify-between p-5 bg-slate-50/50 rounded-2xl border border-slate-200/30 transition-all hover:bg-white hover:border-indigo-100">
|
|
|
+ <div>
|
|
|
+ <div className="text-sm font-bold text-slate-800">Enable HyDE</div>
|
|
|
+ <div className="text-[10px] text-slate-400 font-medium">Hypothetical Document Embeddings.</div>
|
|
|
+ </div>
|
|
|
+ <button
|
|
|
+ onClick={() => handleUpdateKbSettings('enableHyDE', !kbSettings.enableHyDE)}
|
|
|
+ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-all duration-300 ${kbSettings.enableHyDE ? 'bg-indigo-600 shadow-md shadow-indigo-100' : 'bg-slate-300'}`}
|
|
|
+ >
|
|
|
+ <span className={`${kbSettings.enableHyDE ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`} />
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="flex items-center justify-between p-5 bg-slate-50/50 rounded-2xl border border-slate-200/30 transition-all hover:bg-white hover:border-indigo-100">
|
|
|
+ <div>
|
|
|
+ <div className="text-sm font-bold text-slate-800">Enable Reranking</div>
|
|
|
+ <div className="text-[10px] text-slate-400 font-medium">Re-score search results for higher accuracy.</div>
|
|
|
+ </div>
|
|
|
+ <button
|
|
|
+ onClick={() => handleUpdateKbSettings('enableRerank', !kbSettings.enableRerank)}
|
|
|
+ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-all duration-300 ${kbSettings.enableRerank ? 'bg-indigo-600 shadow-md shadow-indigo-100' : 'bg-slate-300'}`}
|
|
|
+ >
|
|
|
+ <span className={`${kbSettings.enableRerank ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`} />
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {kbSettings.enableRerank && (
|
|
|
+ <motion.div
|
|
|
+ initial={{ opacity: 0, y: -10 }}
|
|
|
+ animate={{ opacity: 1, y: 0 }}
|
|
|
+ className="p-5 bg-indigo-50/30 rounded-2xl border border-indigo-100/50 space-y-4"
|
|
|
+ >
|
|
|
+ <div className="flex justify-between mb-2 px-1">
|
|
|
+ <label className="text-[10px] font-black text-indigo-400 uppercase tracking-widest">Rerank Similarity Threshold</label>
|
|
|
+ <span className="text-sm font-black text-indigo-600">{kbSettings.rerankSimilarityThreshold || 0.5}</span>
|
|
|
+ </div>
|
|
|
+ <input
|
|
|
+ type="range"
|
|
|
+ min="0"
|
|
|
+ max="1"
|
|
|
+ step="0.05"
|
|
|
+ value={kbSettings.rerankSimilarityThreshold || 0.5}
|
|
|
+ onChange={(e) => handleUpdateKbSettings('rerankSimilarityThreshold', parseFloat(e.target.value))}
|
|
|
+ className="w-full h-2 bg-indigo-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
|
|
|
+ />
|
|
|
+ <div className="flex justify-between mt-1 px-1 text-[9px] font-bold text-indigo-300 uppercase">
|
|
|
+ <span>Broad</span>
|
|
|
+ <span>Strict</span>
|
|
|
+ </div>
|
|
|
+ </motion.div>
|
|
|
+ )}
|
|
|
</div>
|
|
|
</div>
|
|
|
</section>
|
|
|
@@ -1040,7 +1282,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|
|
);
|
|
|
|
|
|
const renderModelTab = () => (
|
|
|
- <div className="max-w-4xl space-y-6">
|
|
|
+ <div className="w-full space-y-6">
|
|
|
<div className="flex justify-between items-center mb-6">
|
|
|
<div>
|
|
|
<h3 className="text-sm font-black text-slate-500 uppercase tracking-[0.15em] mb-1">{t('mmTitle')}</h3>
|
|
|
@@ -1130,42 +1372,100 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|
|
</div>
|
|
|
</motion.div>
|
|
|
) : (
|
|
|
- <div className="grid grid-cols-1 gap-4 overflow-y-auto pr-2 scrollbar-hide">
|
|
|
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-5 overflow-y-auto pr-2 pb-6 scrollbar-hide">
|
|
|
<AnimatePresence>
|
|
|
{models.map((model, index) => (
|
|
|
<motion.div
|
|
|
key={model.id}
|
|
|
- initial={{ opacity: 0, x: -10 }}
|
|
|
- animate={{ opacity: 1, x: 0 }}
|
|
|
+ initial={{ opacity: 0, scale: 0.95 }}
|
|
|
+ animate={{ opacity: 1, scale: 1 }}
|
|
|
transition={{ delay: index * 0.05 }}
|
|
|
- className="bg-white/70 backdrop-blur-md border border-slate-200/50 rounded-2xl p-5 flex justify-between items-center group hover:shadow-md hover:border-emerald-200/50 transition-all"
|
|
|
+ className="bg-white/70 backdrop-blur-md border border-slate-200/50 rounded-[2rem] p-6 flex flex-col justify-between group hover:shadow-xl hover:shadow-indigo-500/5 hover:border-indigo-500/30 transition-all duration-300 relative overflow-hidden"
|
|
|
>
|
|
|
- <div className="flex gap-4">
|
|
|
- <div className={`w-12 h-12 rounded-xl flex items-center justify-center transition-all ${model.isEnabled !== false ? 'bg-emerald-50 text-emerald-600' : 'bg-slate-50 text-slate-300'}`}>
|
|
|
- <Cpu size={22} />
|
|
|
+ {/* Subtle background pattern/glow */}
|
|
|
+ <div className="absolute -top-12 -right-12 w-32 h-32 bg-indigo-500/5 rounded-full blur-3xl group-hover:bg-indigo-500/10 transition-all duration-500" />
|
|
|
+
|
|
|
+ <div className="relative z-10">
|
|
|
+ <div className="flex items-start justify-between mb-5">
|
|
|
+ <div className={`w-14 h-14 rounded-2xl flex items-center justify-center transition-all duration-500 ${model.isEnabled !== false ? 'bg-indigo-600 text-white shadow-lg shadow-indigo-100 rotate-0 group-hover:rotate-6' : 'bg-slate-100 text-slate-400 opacity-60'}`}>
|
|
|
+ <Cpu size={26} strokeWidth={2.5} />
|
|
|
+ </div>
|
|
|
+ <div className="flex gap-1 items-center bg-slate-50 p-1 rounded-xl border border-slate-100/50">
|
|
|
+ <button
|
|
|
+ onClick={() => handleToggleModel(model)}
|
|
|
+ className={`p-1.5 rounded-lg transition-all ${((currentUser?.role === 'SUPER_ADMIN' && model.isEnabled !== false) || (currentUser?.role === 'TENANT_ADMIN' && enabledModelIds.includes(model.id))) ? 'text-indigo-600 bg-white shadow-sm' : 'text-slate-400 hover:text-slate-600'}`}
|
|
|
+ title={((currentUser?.role === 'SUPER_ADMIN' && model.isEnabled !== false) || (currentUser?.role === 'TENANT_ADMIN' && enabledModelIds.includes(model.id))) ? t('modelEnabled') : t('modelDisabled')}
|
|
|
+ >
|
|
|
+ {((currentUser?.role === 'SUPER_ADMIN' && model.isEnabled !== false) || (currentUser?.role === 'TENANT_ADMIN' && enabledModelIds.includes(model.id))) ? <ToggleRight size={24} /> : <ToggleLeft size={24} />}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- <div>
|
|
|
+
|
|
|
+ <div className="space-y-1 mb-6">
|
|
|
+ <div className="flex items-center gap-2.5">
|
|
|
+ <h4 className="font-black text-slate-900 text-lg tracking-tight truncate">{model.name}</h4>
|
|
|
+ </div>
|
|
|
<div className="flex items-center gap-2">
|
|
|
- <h4 className="font-black text-slate-900">{model.name}</h4>
|
|
|
- <span className="text-[9px] font-black bg-slate-100 text-slate-500 px-1.5 py-0.5 rounded-md uppercase tracking-wider">{getTypeLabel(model.type)}</span>
|
|
|
+ <span className="text-[9px] font-black bg-indigo-50 text-indigo-600 px-2 py-0.5 rounded-lg uppercase tracking-wider border border-indigo-100/50">
|
|
|
+ {getTypeLabel(model.type)}
|
|
|
+ </span>
|
|
|
+ {model.isDefault && (
|
|
|
+ <span className="text-[9px] font-black bg-amber-50 text-amber-600 px-2 py-0.5 rounded-lg uppercase tracking-wider border border-amber-100/50 flex items-center gap-1">
|
|
|
+ <Sparkles size={8} /> Default
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
</div>
|
|
|
- <p className="text-[10px] font-mono text-slate-400 mt-0.5">{model.modelId}</p>
|
|
|
+ <p className="text-[11px] font-mono text-slate-400 mt-2 truncate bg-slate-50 px-2 py-1 rounded-lg border border-slate-100/50 inline-block max-w-full">
|
|
|
+ {model.modelId}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Additional info grid */}
|
|
|
+ <div className="grid grid-cols-2 gap-3 mb-6">
|
|
|
+ {model.type === ModelType.EMBEDDING && (
|
|
|
+ <>
|
|
|
+ <div className="bg-slate-50/50 p-2.5 rounded-2xl border border-slate-100/50">
|
|
|
+ <span className="block text-[8px] font-black text-slate-400 uppercase tracking-widest mb-0.5">Dims</span>
|
|
|
+ <span className="text-xs font-bold text-slate-700">{model.dimensions || '-'}</span>
|
|
|
+ </div>
|
|
|
+ <div className="bg-slate-50/50 p-2.5 rounded-2xl border border-slate-100/50">
|
|
|
+ <span className="block text-[8px] font-black text-slate-400 uppercase tracking-widest mb-0.5">Ctx</span>
|
|
|
+ <span className="text-xs font-bold text-slate-700">{model.maxInputTokens || '-'}</span>
|
|
|
+ </div>
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ {model.type === ModelType.LLM && (
|
|
|
+ <div className="col-span-2 bg-slate-50/50 p-2.5 rounded-2xl border border-slate-100/50 flex items-center justify-between">
|
|
|
+ <span className="text-[8px] font-black text-slate-400 uppercase tracking-widest">Base API</span>
|
|
|
+ <span className="text-[9px] font-mono font-medium text-slate-600 max-w-[140px] truncate">{model.baseUrl}</span>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
</div>
|
|
|
</div>
|
|
|
- <div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-all">
|
|
|
- <button
|
|
|
- onClick={() => handleToggleModel(model)}
|
|
|
- className={`p-2.5 rounded-xl transition-all ${((currentUser?.role === 'SUPER_ADMIN' && model.isEnabled !== false) || (currentUser?.role === 'TENANT_ADMIN' && enabledModelIds.includes(model.id))) ? 'text-emerald-500 hover:bg-emerald-50' : 'text-slate-300 hover:bg-slate-100'}`}
|
|
|
- title={((currentUser?.role === 'SUPER_ADMIN' && model.isEnabled !== false) || (currentUser?.role === 'TENANT_ADMIN' && enabledModelIds.includes(model.id))) ? t('modelEnabled') : t('modelDisabled')}
|
|
|
- >
|
|
|
- {((currentUser?.role === 'SUPER_ADMIN' && model.isEnabled !== false) || (currentUser?.role === 'TENANT_ADMIN' && enabledModelIds.includes(model.id))) ? <ToggleRight size={24} /> : <ToggleLeft size={24} />}
|
|
|
- </button>
|
|
|
- {currentUser?.role === 'SUPER_ADMIN' && (
|
|
|
- <>
|
|
|
- <button onClick={() => { setEditingId(model.id); setModelFormData({ ...model }); }} className="p-2.5 rounded-xl text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 transition-all"><Edit2 size={18} /></button>
|
|
|
- <button onClick={() => handleDeleteModel(model.id)} className="p-2.5 rounded-xl text-slate-400 hover:text-red-500 hover:bg-red-50 transition-all"><Trash2 size={18} /></button>
|
|
|
- </>
|
|
|
- )}
|
|
|
+
|
|
|
+ <div className="flex items-center justify-between pt-4 border-t border-slate-100/50 relative z-10">
|
|
|
+ <div className="flex items-center gap-1 text-[10px] font-bold text-slate-400">
|
|
|
+ <SettingsIcon size={12} />
|
|
|
+ Configured
|
|
|
+ </div>
|
|
|
+ <div className="flex gap-2">
|
|
|
+ {currentUser?.role === 'SUPER_ADMIN' && (
|
|
|
+ <>
|
|
|
+ <button
|
|
|
+ onClick={() => { setEditingId(model.id); setModelFormData({ ...model }); }}
|
|
|
+ className="w-9 h-9 flex items-center justify-center rounded-xl bg-slate-50 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 hover:shadow-sm transition-all"
|
|
|
+ >
|
|
|
+ <Edit2 size={15} />
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ onClick={() => handleDeleteModel(model.id)}
|
|
|
+ className="w-9 h-9 flex items-center justify-center rounded-xl bg-slate-50 text-slate-400 hover:text-red-500 hover:bg-red-50 hover:shadow-sm transition-all"
|
|
|
+ >
|
|
|
+ <Trash2 size={15} />
|
|
|
+ </button>
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
</div>
|
|
|
</motion.div>
|
|
|
))}
|
|
|
@@ -1200,7 +1500,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|
|
</div>
|
|
|
|
|
|
<div className="flex-1 overflow-y-auto px-10 pb-10 scrollbar-hide">
|
|
|
- <div className="max-w-4xl">
|
|
|
+ <div className="w-full">
|
|
|
{error && (
|
|
|
<motion.div
|
|
|
initial={{ opacity: 0, y: -10 }}
|
|
|
@@ -1229,7 +1529,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|
|
{activeTab === 'user' && renderUserTab()}
|
|
|
{activeTab === 'model' && (isAdmin || currentUser?.role === 'SUPER_ADMIN') && renderModelTab()}
|
|
|
{activeTab === 'knowledge_base' && (isAdmin || currentUser?.role === 'SUPER_ADMIN') && renderKnowledgeBaseTab()}
|
|
|
- {activeTab === 'dashboard' && currentUser?.role === 'SUPER_ADMIN' && renderDashboardTab()}
|
|
|
+ {activeTab === 'tenants' && currentUser?.role === 'SUPER_ADMIN' && renderTenantsTab()}
|
|
|
</motion.div>
|
|
|
</AnimatePresence>
|
|
|
</div>
|