SettingsView.tsx 96 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577
  1. import React, { useState, useEffect } from 'react';
  2. import { ModelConfig, ModelType, AppSettings, KnowledgeGroup } from '../../types';
  3. import { useLanguage } from '../../contexts/LanguageContext';
  4. import { X, Plus, Trash2, Edit2, Save, Cpu, Box, Loader2, User, Shield, Key, LogOut, Globe, Settings as SettingsIcon, ToggleLeft, ToggleRight, Database, Sparkles, ChevronRight, Lock, Building2, BookOpen } from 'lucide-react';
  5. import { motion, AnimatePresence } from 'framer-motion';
  6. import { userService } from '../../services/userService';
  7. // import { settingsService } from '../../services/settingsService';
  8. import { userSettingService } from '../../services/userSettingService';
  9. import { knowledgeGroupService } from '../../services/knowledgeGroupService';
  10. import { useConfirm } from '../../contexts/ConfirmContext';
  11. import { useToast } from '../../contexts/ToastContext';
  12. interface SettingsViewProps {
  13. // Model Props
  14. models: ModelConfig[];
  15. authToken: string | null;
  16. onUpdateModels: (action: 'create' | 'update' | 'delete', model: ModelConfig) => Promise<void>;
  17. isAdmin?: boolean; // Added isAdmin prop
  18. currentUser?: any; // Added current user prop
  19. initialTab?: TabType;
  20. }
  21. type TabType = 'general' | 'user' | 'model' | 'tenants' | 'knowledge_base';
  22. export const SettingsView: React.FC<SettingsViewProps> = ({
  23. models,
  24. authToken,
  25. onUpdateModels,
  26. isAdmin = false,
  27. currentUser,
  28. initialTab = 'general',
  29. }) => {
  30. const { t, language, setLanguage } = useLanguage();
  31. const { confirm } = useConfirm();
  32. const { showError, showSuccess } = useToast();
  33. const [activeTab, setActiveTab] = useState<TabType>('general');
  34. const [isLoading, setIsLoading] = useState(false);
  35. const [error, setError] = useState<string | null>(null);
  36. // --- Model Manager State ---
  37. const [editingId, setEditingId] = useState<string | null>(null);
  38. const [modelFormData, setModelFormData] = useState<Partial<ModelConfig>>({
  39. type: ModelType.LLM,
  40. baseUrl: 'http://localhost:11434/v1',
  41. modelId: 'llama3',
  42. name: '',
  43. dimensions: 1536,
  44. apiKey: '',
  45. maxInputTokens: 8191,
  46. maxBatchSize: 2048
  47. });
  48. // --- User Management State ---
  49. interface UserType {
  50. id: string;
  51. username: string;
  52. isAdmin: boolean;
  53. role?: string;
  54. tenantId?: string;
  55. createdAt: string;
  56. }
  57. const [users, setUsers] = useState<UserType[]>([]);
  58. const [isUserLoading, setIsUserLoading] = useState(false);
  59. const [showAddUser, setShowAddUser] = useState(false);
  60. const [newUser, setNewUser] = useState({ username: '', password: '', role: 'USER' });
  61. const [userSuccess, setUserSuccess] = useState('');
  62. // --- Change Password State ---
  63. const [passwordForm, setPasswordForm] = useState({ current: '', new: '', confirm: '' });
  64. const [passwordSuccess, setPasswordSuccess] = useState('');
  65. // --- App Settings State ---
  66. const [appSettings, setAppSettings] = useState<AppSettings | null>(null);
  67. const [knowledgeGroups, setKnowledgeGroups] = useState<KnowledgeGroup[]>([]);
  68. const [isSettingsLoading, setIsSettingsLoading] = useState(false);
  69. const [enabledModelIds, setEnabledModelIds] = useState<string[]>([]);
  70. // --- Tenant Admin Binding Search State ---
  71. const [bindingTenantId, setBindingTenantId] = useState<string | null>(null);
  72. const [userSearchQuery, setUserSearchQuery] = useState('');
  73. useEffect(() => {
  74. if (initialTab) {
  75. setActiveTab(initialTab);
  76. }
  77. }, [initialTab]);
  78. // ユーザー一覧の取得(ユーザータブがアクティブな場合)
  79. useEffect(() => {
  80. if (activeTab === 'user') {
  81. fetchUsers();
  82. } else if (activeTab === 'general') {
  83. fetchSettingsAndGroups();
  84. } else if (activeTab === 'model' && (isAdmin || currentUser?.role === 'SUPER_ADMIN')) {
  85. // Model tab initialization
  86. } else if (activeTab === 'tenants' && currentUser?.role === 'SUPER_ADMIN') {
  87. fetchTenantsData();
  88. fetchUsers(); // Ensure users are loaded for admin binding
  89. } else if (activeTab === 'knowledge_base' && (currentUser?.role === 'TENANT_ADMIN' || currentUser?.role === 'SUPER_ADMIN')) {
  90. fetchKnowledgeBaseSettings();
  91. }
  92. }, [activeTab, currentUser]);
  93. const [kbSettings, setKbSettings] = useState<any>(null);
  94. const [localKbSettings, setLocalKbSettings] = useState<any>(null);
  95. const [isSavingKbSettings, setIsSavingKbSettings] = useState(false);
  96. const fetchKnowledgeBaseSettings = async () => {
  97. if (!authToken) return;
  98. setIsLoading(true);
  99. try {
  100. const res = await fetch('/api/v1/admin/settings', {
  101. headers: { 'Authorization': `Bearer ${authToken}` }
  102. });
  103. if (res.ok) {
  104. const data = await res.json();
  105. setKbSettings(data);
  106. setLocalKbSettings(data);
  107. }
  108. } catch (error) {
  109. console.error(error);
  110. } finally {
  111. setIsLoading(false);
  112. }
  113. };
  114. const handleUpdateKbSettings = (key: string, value: any) => {
  115. setLocalKbSettings((prev: any) => ({ ...prev, [key]: value }));
  116. };
  117. const handleSaveKbSettings = async () => {
  118. if (!authToken || !localKbSettings) return;
  119. setIsSavingKbSettings(true);
  120. try {
  121. const res = await fetch('/api/v1/admin/settings', {
  122. method: 'PUT',
  123. headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${authToken}` },
  124. body: JSON.stringify(localKbSettings)
  125. });
  126. if (res.ok) {
  127. setKbSettings(localKbSettings);
  128. showSuccess('Knowledge Base settings saved');
  129. } else {
  130. showError('Failed to save settings');
  131. }
  132. } catch (error) {
  133. showError('Error saving settings');
  134. } finally {
  135. setIsSavingKbSettings(false);
  136. }
  137. };
  138. const handleCancelKbSettings = () => {
  139. setLocalKbSettings(kbSettings);
  140. };
  141. const fetchSettingsAndGroups = async () => {
  142. if (!authToken) return;
  143. setIsSettingsLoading(true);
  144. try {
  145. const [settings, groups] = await Promise.all([
  146. userSettingService.get(authToken),
  147. knowledgeGroupService.getGroups()
  148. ]);
  149. setAppSettings(settings);
  150. setKnowledgeGroups(groups);
  151. } catch (error) {
  152. console.error('Failed to fetch settings or groups:', error);
  153. } finally {
  154. setIsSettingsLoading(false);
  155. }
  156. };
  157. // --- 一般タブのハンドラー ---
  158. const handleChangePassword = async (e: React.FormEvent) => {
  159. e.preventDefault();
  160. setError(null);
  161. setPasswordSuccess('');
  162. if (passwordForm.new !== passwordForm.confirm) {
  163. setError(t('passwordMismatch'));
  164. return;
  165. }
  166. if (passwordForm.new.length < 6) {
  167. setError(t('newPasswordMinLength'));
  168. return;
  169. }
  170. setIsLoading(true);
  171. try {
  172. await userService.changePassword(passwordForm.current, passwordForm.new);
  173. setPasswordSuccess(t('passwordChangeSuccess'));
  174. setPasswordForm({ current: '', new: '', confirm: '' });
  175. } catch (err: any) {
  176. setError(err.message || t('passwordChangeFailed'));
  177. } finally {
  178. setIsLoading(false);
  179. }
  180. };
  181. // --- ユーザータブのハンドラー ---
  182. const fetchUsers = async () => {
  183. setIsUserLoading(true);
  184. try {
  185. const userList = await userService.getUsers();
  186. setUsers(userList);
  187. } catch (error: any) {
  188. setError(error.message || t('getUserListFailed'));
  189. } finally {
  190. setIsUserLoading(false);
  191. }
  192. };
  193. const handleCreateUser = async (e: React.FormEvent) => {
  194. e.preventDefault();
  195. setError('');
  196. setUserSuccess('');
  197. if (newUser.password.length < 6) {
  198. setError(t('passwordMinLength'));
  199. return;
  200. }
  201. try {
  202. await userService.createUser(newUser.username, newUser.password, newUser.role);
  203. setUserSuccess(t('userCreatedSuccess'));
  204. setNewUser({ username: '', password: '', role: 'USER' });
  205. setShowAddUser(false);
  206. fetchUsers();
  207. } catch (error: any) {
  208. setError(error.message || t('createUserFailed'));
  209. }
  210. };
  211. const [passwordChangeUserData, setPasswordChangeUserData] = useState<{ userId: string, newPassword: string } | null>(null);
  212. // --- Edit Role State ---
  213. const [roleChangeUserData, setRoleChangeUserData] = useState<{ userId: string, newRole: string } | null>(null);
  214. const handleToggleUserAdmin = async (userId: string, newAdminStatus: boolean) => {
  215. try {
  216. await userService.updateUser(userId, newAdminStatus);
  217. // ユーザーリストを再取得
  218. fetchUsers();
  219. setUserSuccess(newAdminStatus ? t('userPromotedToAdmin') : t('userDemotedFromAdmin'));
  220. } catch (error: any) {
  221. setError(error.message || t('updateUserFailed'));
  222. }
  223. };
  224. const handleUserPasswordChange = async () => {
  225. if (!passwordChangeUserData || !passwordChangeUserData.newPassword) return;
  226. try {
  227. // Update user password
  228. await userService.updateUserInfo(passwordChangeUserData.userId, { password: passwordChangeUserData.newPassword });
  229. setUserSuccess(t('passwordChangeSuccess'));
  230. setPasswordChangeUserData(null);
  231. fetchUsers(); // Refresh the user list
  232. } catch (error: any) {
  233. setError(error.message || t('passwordChangeFailed'));
  234. }
  235. };
  236. // --- Tenants Management State (Migrated) ---
  237. const [stats, setStats] = useState({ tenants: 0, users: 0 });
  238. const [tenants, setTenants] = useState<any[]>([]);
  239. const [tenantAdmins, setTenantAdmins] = useState<any[]>([]);
  240. const [isTenantsLoading, setIsTenantsLoading] = useState(false);
  241. const [showCreateTenant, setShowCreateTenant] = useState(false);
  242. const [editingTenant, setEditingTenant] = useState<any | null>(null);
  243. const [newTenant, setNewTenant] = useState({ name: '', domain: '', adminUserId: '' });
  244. const fetchTenantsData = async () => {
  245. setIsTenantsLoading(true);
  246. try {
  247. const headers = { 'Authorization': `Bearer ${authToken}`, 'x-api-key': authToken || '' };
  248. const [tenRes, admRes] = await Promise.all([
  249. fetch('/api/v1/tenants', { headers }),
  250. fetch('/api/v1/admin/users', { headers })
  251. ]);
  252. if (tenRes.ok) {
  253. const data = await tenRes.json();
  254. setTenants(data);
  255. setStats(s => ({ ...s, tenants: data.length }));
  256. }
  257. if (admRes.ok) {
  258. const data = await admRes.json();
  259. setTenantAdmins(data.filter((u: any) => u.role === 'TENANT_ADMIN' || u.role === 'SUPER_ADMIN'));
  260. setStats(s => ({ ...s, users: data.length }));
  261. }
  262. } catch (e) {
  263. console.error(e);
  264. } finally {
  265. setIsTenantsLoading(false);
  266. }
  267. };
  268. const handleCreateTenant = async (e: React.FormEvent) => {
  269. e.preventDefault();
  270. try {
  271. if (editingTenant) {
  272. const res = await fetch(`/api/v1/tenants/${editingTenant.id}`, {
  273. method: 'PUT',
  274. headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${authToken}`, 'x-api-key': authToken || '' },
  275. body: JSON.stringify({ name: newTenant.name, domain: newTenant.domain })
  276. });
  277. if (res.ok) {
  278. setEditingTenant(null);
  279. setNewTenant({ name: '', domain: '', adminUserId: '' });
  280. fetchTenantsData();
  281. showSuccess('Tenant updated successfully');
  282. } else {
  283. showError('Failed to update tenant');
  284. }
  285. } else {
  286. const res = await fetch('/api/v1/tenants', {
  287. method: 'POST',
  288. headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${authToken}`, 'x-api-key': authToken || '' },
  289. body: JSON.stringify(newTenant)
  290. });
  291. if (res.ok) {
  292. setShowCreateTenant(false);
  293. setNewTenant({ name: '', domain: '', adminUserId: '' });
  294. fetchTenantsData();
  295. showSuccess('Tenant created successfully');
  296. } else {
  297. showError('Failed to create tenant');
  298. }
  299. }
  300. } catch (e) {
  301. showError('Error processing tenant');
  302. }
  303. };
  304. const handleDeleteTenant = async (tenantId: string) => {
  305. if (!(await confirm('Are you sure you want to delete this tenant? All associated data will be removed.'))) return;
  306. try {
  307. const res = await fetch(`/api/v1/tenants/${tenantId}`, {
  308. method: 'DELETE',
  309. headers: { 'Authorization': `Bearer ${authToken}`, 'x-api-key': authToken || '' }
  310. });
  311. if (res.ok) {
  312. showSuccess('Tenant deleted successfully');
  313. fetchTenantsData();
  314. } else {
  315. showError('Failed to delete tenant');
  316. }
  317. } catch (e) {
  318. showError('Error deleting tenant');
  319. }
  320. };
  321. const handleBindAdmin = async (tenantId: string, userId: string): Promise<boolean> => {
  322. if (!userId) return false;
  323. try {
  324. const res = await fetch(`/api/v1/tenants/${tenantId}/admin`, {
  325. method: 'PUT',
  326. headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${authToken}`, 'x-api-key': authToken || '' },
  327. body: JSON.stringify({ userId })
  328. });
  329. if (res.ok) {
  330. showSuccess('Admin bound successfully');
  331. // Refresh tenants data and users to ensure all states are in sync
  332. await Promise.all([
  333. fetchTenantsData(),
  334. fetchUsers()
  335. ]);
  336. return true;
  337. } else {
  338. showError('Failed to bind admin');
  339. return false;
  340. }
  341. } catch (e) {
  342. showError('Error binding admin');
  343. return false;
  344. }
  345. };
  346. const handleToggleNotebookFeature = async (tenantId: string, currentEnabled: boolean) => {
  347. try {
  348. const res = await fetch(`/api/tenants/${tenantId}/settings`, {
  349. method: 'PUT',
  350. headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${authToken}` },
  351. body: JSON.stringify({ isNotebookEnabled: !currentEnabled })
  352. });
  353. if (res.ok) {
  354. showSuccess('Feature updated successfully');
  355. fetchTenantsData();
  356. }
  357. } catch (e) {
  358. showError('Failed to update feature');
  359. }
  360. };
  361. const handleUserRoleChange = async () => {
  362. if (!roleChangeUserData) return;
  363. try {
  364. await userService.updateUserInfo(roleChangeUserData.userId, { role: roleChangeUserData.newRole });
  365. showSuccess('Role updated successfully.');
  366. setRoleChangeUserData(null);
  367. fetchUsers();
  368. } catch (error: any) {
  369. showError(error.response?.data?.message || 'Failed to update user role.');
  370. }
  371. };
  372. const handleDeleteUser = async (userId: string) => {
  373. if (!confirm(t('confirmDeleteUser') || "Are you sure you want to delete this user?")) return;
  374. try {
  375. await userService.deleteUser(userId);
  376. showSuccess(t('userDeletedSuccessfully'));
  377. fetchUsers();
  378. } catch (error: any) {
  379. showError(error.message || t('deleteUserFailed'));
  380. }
  381. };
  382. // --- モデルタブのハンドラー ---
  383. const handleSaveModel = async () => {
  384. if (!authToken) {
  385. setError(t('mmErrorNotAuthenticated'));
  386. return;
  387. }
  388. setError(null);
  389. if (!modelFormData.name?.trim()) { setError(t('mmErrorNameRequired')); return; }
  390. if (!modelFormData.modelId?.trim()) { setError(t('mmErrorModelIdRequired')); return; }
  391. if (!modelFormData.baseUrl?.trim()) { setError(t('mmErrorBaseUrlRequired')); return; }
  392. setIsLoading(true);
  393. try {
  394. const saveData = { ...modelFormData } as ModelConfig;
  395. await onUpdateModels(editingId === 'new' ? 'create' : 'update', saveData);
  396. setEditingId(null);
  397. } catch (err: any) {
  398. setError(err.message || t('errorGeneric'));
  399. } finally {
  400. setIsLoading(false);
  401. }
  402. };
  403. const handleToggleModel = async (model: ModelConfig) => {
  404. if (currentUser?.role === 'TENANT_ADMIN') {
  405. const newEnabledIds = enabledModelIds.includes(model.id)
  406. ? enabledModelIds.filter(id => id !== model.id)
  407. : [...enabledModelIds, model.id];
  408. try {
  409. await fetch('/api/v1/admin/settings', {
  410. method: 'PUT',
  411. headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${authToken}` },
  412. body: JSON.stringify({ enabledModelIds: newEnabledIds })
  413. });
  414. setEnabledModelIds(newEnabledIds);
  415. showSuccess('Settings updated successfully');
  416. } catch (error) {
  417. console.error(error);
  418. showError(t('errorGeneric'));
  419. }
  420. return;
  421. }
  422. try {
  423. await onUpdateModels('update', { ...model, isEnabled: !model.isEnabled });
  424. } catch (error) {
  425. console.error('Failed to toggle model:', error);
  426. }
  427. };
  428. const handleDeleteModel = async (id: string) => {
  429. if (await confirm(t('confirmClear'))) {
  430. await onUpdateModels('delete', { id } as ModelConfig);
  431. }
  432. };
  433. const getTypeLabel = (type: ModelType) => {
  434. switch (type) {
  435. case ModelType.LLM: return t('typeLLM');
  436. case ModelType.EMBEDDING: return t('typeEmbedding');
  437. case ModelType.RERANK: return t('typeRerank');
  438. }
  439. };
  440. // --- レンダリング関数 ---
  441. const renderGeneralTab = () => (
  442. <div className="space-y-8 animate-in slide-in-from-right duration-300 max-w-2xl">
  443. {/* パスワード変更セクション */}
  444. <section className="bg-white p-6 rounded-lg border border-slate-200 shadow-sm">
  445. <h3 className="text-sm font-medium text-slate-800 mb-4 flex items-center gap-2">
  446. <Key className="w-4 h-4 text-blue-500" />
  447. {t('changePassword')}
  448. </h3>
  449. <form onSubmit={handleChangePassword} className="space-y-4 max-w-sm">
  450. <div>
  451. <input
  452. type="password"
  453. placeholder={t('currentPassword')}
  454. value={passwordForm.current}
  455. onChange={e => setPasswordForm({ ...passwordForm, current: e.target.value })}
  456. className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
  457. required
  458. />
  459. </div>
  460. <div>
  461. <input
  462. type="password"
  463. placeholder={t('newPassword')}
  464. value={passwordForm.new}
  465. onChange={e => setPasswordForm({ ...passwordForm, new: e.target.value })}
  466. className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
  467. required
  468. />
  469. </div>
  470. <div>
  471. <input
  472. type="password"
  473. placeholder={t('confirmPassword')}
  474. value={passwordForm.confirm}
  475. onChange={e => setPasswordForm({ ...passwordForm, confirm: e.target.value })}
  476. className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
  477. required
  478. />
  479. </div>
  480. {passwordSuccess && <p className="text-xs text-green-600">{passwordSuccess}</p>}
  481. <button
  482. type="submit"
  483. disabled={isLoading}
  484. className="w-full py-2 bg-slate-800 text-white rounded-md text-sm hover:bg-slate-900 transition-colors disabled:opacity-50"
  485. >
  486. {isLoading ? <Loader2 className="w-4 h-4 animate-spin mx-auto" /> : t('confirmChange')}
  487. </button>
  488. </form>
  489. </section>
  490. {/* 语言设置セクション */}
  491. <section className="bg-white p-6 rounded-lg border border-slate-200 shadow-sm">
  492. <h3 className="text-sm font-medium text-slate-800 mb-4 flex items-center gap-2">
  493. <Globe className="w-4 h-4 text-blue-500" />
  494. {t('languageSettings')}
  495. </h3>
  496. <div className="space-y-4 max-w-sm">
  497. <div className="space-y-2">
  498. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">
  499. {t('switchLanguage')}
  500. </label>
  501. <select
  502. value={language}
  503. onChange={(e) => setLanguage(e.target.value as any)}
  504. className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none bg-white"
  505. >
  506. <option value="en">English</option>
  507. <option value="zh">中文 (Chinese)</option>
  508. <option value="ja">日本語 (Japanese)</option>
  509. </select>
  510. </div>
  511. </div>
  512. </section>
  513. </div >
  514. );
  515. const renderUserTab = () => (
  516. <div className="space-y-6 w-full">
  517. <div className="flex justify-between items-center mb-6">
  518. <div>
  519. <h3 className="text-sm font-black text-slate-500 uppercase tracking-[0.15em] mb-1">{t('userList')}</h3>
  520. <p className="text-xs text-slate-400 font-medium">{t('sidebarDesc')}</p>
  521. </div>
  522. <button
  523. onClick={() => setShowAddUser(!showAddUser)}
  524. className="flex items-center gap-2 px-6 py-2.5 bg-indigo-600 text-white text-sm font-bold rounded-2xl hover:bg-indigo-700 shadow-lg shadow-indigo-100 transition-all active:scale-95"
  525. >
  526. <Plus className="w-4 h-4" />
  527. {t('addUser')}
  528. </button>
  529. </div>
  530. {showAddUser && (
  531. <motion.form
  532. initial={{ opacity: 0, y: -10 }}
  533. animate={{ opacity: 1, y: 0 }}
  534. onSubmit={handleCreateUser}
  535. className="bg-white/80 backdrop-blur-md p-8 rounded-3xl border border-slate-200/50 shadow-xl mb-8 space-y-5"
  536. >
  537. <div className="grid grid-cols-1 md:grid-cols-2 gap-5">
  538. <input
  539. type="text"
  540. placeholder={t('usernamePlaceholder')}
  541. value={newUser.username}
  542. onChange={e => setNewUser({ ...newUser, username: e.target.value })}
  543. className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all"
  544. required
  545. />
  546. <input
  547. type="password"
  548. placeholder={t('passwordPlaceholder')}
  549. value={newUser.password}
  550. onChange={e => setNewUser({ ...newUser, password: e.target.value })}
  551. className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all"
  552. required
  553. />
  554. </div>
  555. <div className="flex items-center justify-between">
  556. <div className="flex items-center gap-3 bg-slate-50 px-4 py-2 rounded-xl border border-slate-200/50">
  557. <Shield className={`w-4 h-4 ${currentUser?.role === 'SUPER_ADMIN' ? 'text-red-500' : 'text-indigo-500'}`} />
  558. {currentUser?.role === 'SUPER_ADMIN' ? (
  559. <select
  560. className="bg-transparent text-xs font-bold text-slate-600 uppercase tracking-widest outline-none cursor-pointer"
  561. value={newUser.role}
  562. onChange={e => setNewUser({ ...newUser, role: e.target.value })}
  563. >
  564. <option value="TENANT_ADMIN">Tenant Admin</option>
  565. <option value="USER">Regular User</option>
  566. </select>
  567. ) : (
  568. <span className="text-xs font-bold text-slate-600 uppercase tracking-widest">
  569. Creating Regular User
  570. </span>
  571. )}
  572. </div>
  573. <div className="flex gap-3">
  574. <button type="button" onClick={() => setShowAddUser(false)} className="px-5 py-2 text-xs font-bold text-slate-500 hover:text-slate-700">{t('cancel')}</button>
  575. <button type="submit" className="px-8 py-2 bg-slate-900 text-white rounded-xl text-xs font-black uppercase tracking-widest hover:bg-indigo-600 transition-all shadow-lg shadow-slate-100">{t('create')}</button>
  576. </div>
  577. </div>
  578. </motion.form>
  579. )}
  580. {passwordChangeUserData && (
  581. <div className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm flex items-center justify-center z-[100] p-6">
  582. <motion.div
  583. initial={{ scale: 0.95, opacity: 0 }}
  584. animate={{ scale: 1, opacity: 1 }}
  585. className="bg-white rounded-3xl p-10 w-full max-w-md shadow-2xl border border-white/20"
  586. >
  587. <div className="flex items-center justify-between mb-8">
  588. <h3 className="text-xl font-black text-slate-900 tracking-tight">{t('changeUserPassword')}</h3>
  589. <button onClick={() => setPasswordChangeUserData(null)} className="p-2 hover:bg-slate-100 rounded-xl transition-all">
  590. <X size={20} className="text-slate-400" />
  591. </button>
  592. </div>
  593. <form onSubmit={(e) => { e.preventDefault(); handleUserPasswordChange(); }} className="space-y-6">
  594. <div>
  595. <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">
  596. {t('newPassword')}
  597. </label>
  598. <input
  599. type="password"
  600. value={passwordChangeUserData.newPassword}
  601. onChange={(e) => setPasswordChangeUserData({ ...passwordChangeUserData, newPassword: e.target.value })}
  602. className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-[14px] font-medium transition-all focus:outline-none focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50"
  603. placeholder={t('enterNewPassword')}
  604. required
  605. />
  606. </div>
  607. <div className="flex gap-4 pt-4">
  608. <button type="button" onClick={() => setPasswordChangeUserData(null)} className="flex-1 py-3.5 text-slate-500 font-bold text-sm">{t('cancel')}</button>
  609. <button type="submit" className="flex-1 py-3.5 bg-slate-900 text-white rounded-2xl font-black uppercase tracking-widest text-xs hover:bg-indigo-600 shadow-xl shadow-slate-100 transition-all">{t('confirmChange')}</button>
  610. </div>
  611. </form>
  612. </motion.div>
  613. </div>
  614. )}
  615. {/* Edit Role Modal */}
  616. {roleChangeUserData && (
  617. <div className="fixed inset-0 z-[110] flex items-center justify-center bg-slate-900/40 backdrop-blur-sm p-4">
  618. <motion.div
  619. initial={{ scale: 0.95, opacity: 0 }}
  620. animate={{ scale: 1, opacity: 1 }}
  621. className="bg-white rounded-3xl p-10 w-full max-w-md shadow-2xl border border-white/20"
  622. >
  623. <div className="flex items-center justify-between mb-8">
  624. <h3 className="text-xl font-black text-slate-900 tracking-tight">Edit User Role</h3>
  625. <button onClick={() => setRoleChangeUserData(null)} className="p-2 hover:bg-slate-100 rounded-xl transition-all">
  626. <X size={20} className="text-slate-400" />
  627. </button>
  628. </div>
  629. <form onSubmit={(e) => { e.preventDefault(); handleUserRoleChange(); }} className="space-y-6">
  630. <div>
  631. <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">
  632. Target Role
  633. </label>
  634. <select
  635. value={roleChangeUserData.newRole}
  636. onChange={(e) => setRoleChangeUserData({ ...roleChangeUserData, newRole: e.target.value })}
  637. className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-[14px] font-medium transition-all focus:outline-none focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50"
  638. >
  639. <option value="TENANT_ADMIN">Tenant Admin</option>
  640. <option value="USER">Regular User</option>
  641. </select>
  642. </div>
  643. <div className="flex gap-4 pt-4">
  644. <button type="button" onClick={() => setRoleChangeUserData(null)} className="flex-1 py-3.5 text-slate-500 font-bold text-sm">{t('cancel')}</button>
  645. <button type="submit" className="flex-1 py-3.5 bg-slate-900 text-white rounded-2xl font-black uppercase tracking-widest text-xs hover:bg-indigo-600 shadow-xl shadow-slate-100 transition-all">{t('confirmChange')}</button>
  646. </div>
  647. </form>
  648. </motion.div>
  649. </div>
  650. )}
  651. <div className="grid grid-cols-1 gap-4 overflow-y-auto pr-2 scrollbar-hide">
  652. <AnimatePresence>
  653. {users.map((user, index) => {
  654. let IconComponent = User;
  655. let iconColors = 'bg-slate-50 text-slate-400';
  656. if (user.role === 'SUPER_ADMIN') {
  657. IconComponent = Shield;
  658. iconColors = 'bg-red-50 text-red-600';
  659. } else if (user.isAdmin || user.role === 'TENANT_ADMIN') {
  660. IconComponent = Shield;
  661. iconColors = 'bg-indigo-50 text-indigo-600';
  662. }
  663. return (
  664. <motion.div
  665. key={user.id}
  666. initial={{ opacity: 0, x: -10 }}
  667. animate={{ opacity: 1, x: 0 }}
  668. transition={{ delay: index * 0.05 }}
  669. className="flex items-center justify-between p-5 bg-white/70 backdrop-blur-md border border-slate-200/50 rounded-2xl shadow-sm hover:shadow-md hover:border-indigo-200/50 transition-all group"
  670. >
  671. <div className="flex items-center gap-4">
  672. <div className={`w-12 h-12 rounded-xl flex items-center justify-center transition-all ${iconColors}`}>
  673. <IconComponent size={22} />
  674. </div>
  675. <div>
  676. <div className="flex items-center gap-2">
  677. <p className="font-black text-slate-900">{user.username}</p>
  678. {(user.role === 'SUPER_ADMIN' || user.isAdmin) && <span className={`text-[9px] font-black px-1.5 py-0.5 rounded-md uppercase tracking-wider ${user.role === 'SUPER_ADMIN' ? 'bg-red-100 text-red-700' : 'bg-indigo-100 text-indigo-700'}`}>{user.role === 'SUPER_ADMIN' ? 'SUPER' : 'ADMIN'}</span>} </div>
  679. <p className="text-[10px] font-bold text-slate-400 mt-0.5 uppercase tracking-widest">{t('createdAt')}: {new Date(user.createdAt).toLocaleDateString()}</p>
  680. </div>
  681. </div>
  682. <div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-all">
  683. {user.username !== 'admin' && (
  684. <>
  685. {currentUser?.role === 'SUPER_ADMIN' && user.role !== 'SUPER_ADMIN' && (
  686. <button
  687. onClick={(e) => {
  688. e.preventDefault();
  689. e.stopPropagation();
  690. setRoleChangeUserData({ userId: user.id, newRole: user.role && user.role !== 'USER' ? user.role : (user.isAdmin ? 'TENANT_ADMIN' : 'USER') });
  691. }}
  692. className="p-2.5 rounded-xl text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 transition-all cursor-pointer relative z-10"
  693. title="Edit Role"
  694. >
  695. <Edit2 className="w-4.5 h-4.5" />
  696. </button>
  697. )}
  698. <button
  699. onClick={() => setPasswordChangeUserData({ userId: user.id, newPassword: '' })}
  700. className="p-2.5 rounded-xl text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 transition-all"
  701. title={t('changeUserPassword')}
  702. >
  703. <Key className="w-4.5 h-4.5" />
  704. </button>
  705. {user.id !== currentUser?.id && (
  706. <button
  707. onClick={() => handleDeleteUser(user.id)}
  708. className="p-2.5 rounded-xl text-slate-400 hover:text-red-500 hover:bg-red-50 transition-all"
  709. title={t('deleteUser')}
  710. >
  711. <Trash2 className="w-4.5 h-4.5" />
  712. </button>
  713. )}
  714. </>
  715. )}
  716. </div>
  717. </motion.div>
  718. );
  719. })}
  720. </AnimatePresence>
  721. </div>
  722. </div>
  723. );
  724. const renderTenantsTab = () => (
  725. <div className="w-full space-y-8 animate-in fade-in duration-500">
  726. <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
  727. <div className="p-6 bg-white border border-slate-200 rounded-3xl shadow-sm">
  728. <div className="flex items-center gap-3 mb-2 text-blue-600">
  729. <Building2 size={20} />
  730. <span className="text-xs font-black uppercase tracking-widest">Total Tenants</span>
  731. </div>
  732. <p className="text-3xl font-black text-slate-900">{stats.tenants}</p>
  733. </div>
  734. <div className="p-6 bg-white border border-slate-200 rounded-3xl shadow-sm">
  735. <div className="flex items-center gap-3 mb-2 text-indigo-600">
  736. <User size={20} />
  737. <span className="text-xs font-black uppercase tracking-widest">System Users</span>
  738. </div>
  739. <p className="text-3xl font-black text-slate-900">{stats.users}</p>
  740. </div>
  741. <div className="p-6 bg-emerald-50 border border-emerald-100 rounded-3xl shadow-sm">
  742. <div className="flex items-center gap-3 mb-2 text-emerald-600">
  743. <Sparkles size={20} />
  744. <span className="text-xs font-black uppercase tracking-widest">System Health</span>
  745. </div>
  746. <p className="text-xl font-bold text-emerald-700">Operational</p>
  747. </div>
  748. </div>
  749. <div className="bg-white border border-slate-200 rounded-3xl shadow-xl overflow-hidden">
  750. <div className="p-6 border-b border-slate-100 flex items-center justify-between">
  751. <div>
  752. <h3 className="font-black text-slate-900 text-lg tracking-tight">Organization Management</h3>
  753. <p className="text-xs font-medium text-slate-400">Global tenant list and control</p>
  754. </div>
  755. <button onClick={() => setShowCreateTenant(true)} className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white text-xs font-bold rounded-xl hover:bg-slate-700 transition-all">
  756. <Plus size={16} /> New Tenant
  757. </button>
  758. </div>
  759. <div className="overflow-x-auto">
  760. <table className="w-full text-left">
  761. <thead className="bg-slate-50/50 text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-100">
  762. <tr>
  763. <th className="px-6 py-4">Name</th>
  764. <th className="px-6 py-4">Domain</th>
  765. <th className="px-6 py-4">Admin</th>
  766. <th className="px-6 py-4">Features</th>
  767. <th className="px-6 py-4">Created</th>
  768. <th className="px-6 py-4 text-right">Actions</th>
  769. </tr>
  770. </thead>
  771. <tbody className="divide-y divide-slate-100">
  772. {tenants.map(t => (
  773. <tr key={t.id} className="hover:bg-slate-50/50 transition-colors group">
  774. <td className="px-6 py-4 text-sm font-bold text-slate-900">{t.name}</td>
  775. <td className="px-6 py-4 text-xs font-mono text-slate-500">{t.domain || '-'}</td>
  776. <td className="px-6 py-4 text-xs">
  777. {(() => {
  778. // Search admin in tenant members - the source of truth for multi-tenancy
  779. const adminMember = t.members?.find((m: any) => m.role === 'TENANT_ADMIN' || m.role === 'SUPER_ADMIN');
  780. const adminUser = adminMember?.user;
  781. if (adminUser) {
  782. return (
  783. <div className="flex items-center gap-1.5 text-slate-600 font-medium">
  784. <div className="w-5 h-5 rounded-full bg-indigo-50 flex items-center justify-center">
  785. <User className="w-3 h-3 text-indigo-500" />
  786. </div>
  787. {adminUser.username}
  788. <button
  789. onClick={() => {
  790. setBindingTenantId(t.id);
  791. setUserSearchQuery('');
  792. fetchUsers();
  793. }}
  794. className="ml-2 p-1 hover:bg-indigo-50 text-slate-400 hover:text-indigo-600 rounded transition-all"
  795. title="Change Admin"
  796. >
  797. <Edit2 size={12} />
  798. </button>
  799. </div>
  800. );
  801. } else {
  802. return (
  803. <div className="flex items-center gap-2">
  804. <button
  805. onClick={() => {
  806. setBindingTenantId(t.id);
  807. setUserSearchQuery('');
  808. fetchUsers();
  809. }}
  810. 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"
  811. >
  812. Bind Admin
  813. </button>
  814. <span className="text-[10px] text-slate-400 italic">None</span>
  815. </div>
  816. );
  817. }
  818. })()}
  819. </td>
  820. <td className="px-6 py-4">
  821. <div className="flex items-center gap-2">
  822. <div className={`p-1.5 rounded-lg ${t.settings_obj?.isNotebookEnabled ? 'bg-indigo-50 text-indigo-600' : 'bg-slate-50 text-slate-400'}`} title="Notebook Feature">
  823. <BookOpen size={14} />
  824. </div>
  825. <button
  826. onClick={() => handleToggleNotebookFeature(t.id, t.settings_obj?.isNotebookEnabled !== false)}
  827. className="text-[10px] font-black uppercase tracking-widest text-indigo-600 hover:underline"
  828. >
  829. {t.settings_obj?.isNotebookEnabled !== false ? 'Enabled' : 'Disabled'}
  830. </button>
  831. </div>
  832. </td>
  833. <td className="px-6 py-4 text-xs text-slate-400">{new Date(t.createdAt).toLocaleDateString()}</td>
  834. <td className="px-6 py-4 text-right">
  835. <div className="flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-all">
  836. <button
  837. onClick={() => {
  838. setEditingTenant(t);
  839. setNewTenant({ name: t.name, domain: t.domain || '', adminUserId: '' });
  840. }}
  841. className="p-1.5 hover:bg-slate-100 text-slate-400 hover:text-indigo-600 rounded-lg transition-all"
  842. title="Edit Tenant"
  843. >
  844. <Edit2 size={14} />
  845. </button>
  846. <button
  847. onClick={() => handleDeleteTenant(t.id)}
  848. className="p-1.5 hover:bg-red-50 text-slate-400 hover:text-red-500 rounded-lg transition-all"
  849. title="Delete Tenant"
  850. >
  851. <Trash2 size={14} />
  852. </button>
  853. </div>
  854. </td>
  855. </tr>
  856. ))}
  857. </tbody>
  858. </table>
  859. </div>
  860. </div>
  861. {(showCreateTenant || editingTenant) && (
  862. <div className="fixed inset-0 z-[120] bg-slate-900/40 backdrop-blur-sm flex items-center justify-center p-4">
  863. <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">
  864. <h3 className="text-xl font-black text-slate-900 mb-6">{editingTenant ? 'Edit Organization' : 'Create New Tenant'}</h3>
  865. <form onSubmit={handleCreateTenant} className="space-y-5">
  866. <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 />
  867. <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 })} />
  868. {!editingTenant && (
  869. <div className="space-y-2">
  870. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">Assign Initial Admin</label>
  871. <select
  872. 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"
  873. value={newTenant.adminUserId}
  874. onChange={e => setNewTenant({ ...newTenant, adminUserId: e.target.value })}
  875. >
  876. <option value="">Select a user (optional)</option>
  877. {users.filter(u => u.role === 'TENANT_ADMIN').map(u => (
  878. <option key={u.id} value={u.id}>{u.username}</option>
  879. ))}
  880. </select>
  881. <p className="text-[10px] text-slate-400 px-1 italic">Selecting a user will promote them to Tenant Admin for this organization.</p>
  882. </div>
  883. )}
  884. <div className="flex gap-4 pt-2">
  885. <button type="button" onClick={() => { setShowCreateTenant(false); setEditingTenant(null); }} className="flex-1 py-3 text-slate-500 font-bold text-sm">Cancel</button>
  886. <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>
  887. </div>
  888. </form>
  889. </motion.div>
  890. </div>
  891. )}
  892. {/* Bind Admin Search Modal */}
  893. <AnimatePresence>
  894. {bindingTenantId && (
  895. <div className="fixed inset-0 z-[130] bg-slate-900/60 backdrop-blur-md flex items-center justify-center p-4">
  896. <motion.div
  897. initial={{ scale: 0.9, opacity: 0 }}
  898. animate={{ scale: 1, opacity: 1 }}
  899. exit={{ scale: 0.9, opacity: 0 }}
  900. className="bg-white rounded-[2.5rem] p-8 w-full max-w-lg shadow-2xl border border-white/20 overflow-hidden relative"
  901. >
  902. <div className="absolute top-0 right-0 p-6">
  903. <button onClick={() => setBindingTenantId(null)} className="p-2 hover:bg-slate-100 rounded-2xl transition-all">
  904. <X size={20} className="text-slate-400" />
  905. </button>
  906. </div>
  907. <div className="mb-8">
  908. <h3 className="text-2xl font-black text-slate-900 tracking-tight mb-2">Bind Tenant Admin</h3>
  909. <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>
  910. </div>
  911. <div className="relative mb-6">
  912. <div className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400">
  913. <Globe size={18} />
  914. </div>
  915. <input
  916. autoFocus
  917. 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"
  918. placeholder="Search users by name..."
  919. value={userSearchQuery}
  920. onChange={e => setUserSearchQuery(e.target.value)}
  921. />
  922. </div>
  923. <div className="max-h-[350px] overflow-y-auto pr-2 flex flex-col gap-3 scrollbar-hide">
  924. {users
  925. .filter(u =>
  926. (u.role === 'TENANT_ADMIN' || u.isAdmin) &&
  927. u.role !== 'SUPER_ADMIN' &&
  928. u.username.toLowerCase().includes(userSearchQuery.toLowerCase())
  929. )
  930. .map(u => (
  931. <button
  932. key={u.id}
  933. onClick={async () => {
  934. const success = await handleBindAdmin(bindingTenantId, u.id);
  935. if (success) setBindingTenantId(null);
  936. }}
  937. 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"
  938. >
  939. <div className="flex items-center gap-3">
  940. <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">
  941. <User size={18} />
  942. </div>
  943. <div className="text-left">
  944. <p className="text-sm font-black text-slate-900">{u.username}</p>
  945. <p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">{u.role || 'User'}</p>
  946. </div>
  947. </div>
  948. <ChevronRight size={16} className="text-slate-300 group-hover:text-indigo-400 group-hover:translate-x-1 transition-all" />
  949. </button>
  950. ))}
  951. {users.filter(u =>
  952. (u.role === 'TENANT_ADMIN' || u.isAdmin) &&
  953. u.role !== 'SUPER_ADMIN' &&
  954. u.username.toLowerCase().includes(userSearchQuery.toLowerCase())
  955. ).length === 0 && (
  956. <div className="py-12 text-center opacity-40">
  957. <User size={40} className="mx-auto mb-3" />
  958. <p className="text-xs font-bold uppercase tracking-widest">No unassigned users found</p>
  959. </div>
  960. )}
  961. </div>
  962. <div className="mt-8 pt-6 border-t border-slate-100 flex justify-end">
  963. <button onClick={() => setBindingTenantId(null)} className="px-6 py-3 text-sm font-bold text-slate-500 hover:text-slate-700 transition-all">
  964. Cancel
  965. </button>
  966. </div>
  967. </motion.div>
  968. </div>
  969. )}
  970. </AnimatePresence>
  971. </div>
  972. );
  973. const renderKnowledgeBaseTab = () => (
  974. <div className="space-y-8 animate-in slide-in-from-right duration-300 w-full max-w-5xl pb-10">
  975. {localKbSettings && (
  976. <>
  977. {/* Save/Cancel Bar */}
  978. <div className="flex justify-end gap-3 sticky top-0 z-20 py-4 bg-white/50 backdrop-blur-sm border-b border-slate-100 mb-6">
  979. <button
  980. onClick={handleCancelKbSettings}
  981. disabled={isSavingKbSettings || JSON.stringify(localKbSettings) === JSON.stringify(kbSettings)}
  982. className="px-6 py-2 text-sm font-bold text-slate-500 hover:text-slate-700 disabled:opacity-30"
  983. >
  984. Cancel
  985. </button>
  986. <button
  987. onClick={handleSaveKbSettings}
  988. disabled={isSavingKbSettings || JSON.stringify(localKbSettings) === JSON.stringify(kbSettings)}
  989. className="flex items-center gap-2 px-8 py-2 bg-indigo-600 text-white text-sm font-black uppercase tracking-widest rounded-2xl hover:bg-indigo-700 shadow-lg shadow-indigo-100 transition-all active:scale-95 disabled:opacity-50"
  990. >
  991. {isSavingKbSettings ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
  992. Save Changes
  993. </button>
  994. </div>
  995. {/* Model Configuration */}
  996. <section className="bg-white/80 backdrop-blur-md p-8 rounded-3xl border border-slate-200/50 shadow-sm space-y-6">
  997. <div className="flex items-center gap-3 text-slate-900 font-black uppercase tracking-widest text-[11px] border-b border-slate-100 pb-4">
  998. <div className="w-8 h-8 rounded-xl bg-indigo-50 flex items-center justify-center text-indigo-600">
  999. <Cpu size={16} />
  1000. </div>
  1001. Model Configuration
  1002. </div>
  1003. <div className="grid grid-cols-1 gap-6">
  1004. <div>
  1005. <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">Default LLM Model</label>
  1006. <select
  1007. value={localKbSettings.selectedLLMId || ''}
  1008. onChange={(e) => handleUpdateKbSettings('selectedLLMId', e.target.value)}
  1009. className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium outline-none focus:ring-4 focus:ring-indigo-500/10 transition-all cursor-pointer appearance-none"
  1010. >
  1011. <option value="">Select LLM...</option>
  1012. {models.filter(m => m.type === ModelType.LLM).map(m => (
  1013. <option key={m.id} value={m.id}>{m.name} ({m.modelId})</option>
  1014. ))}
  1015. </select>
  1016. </div>
  1017. <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
  1018. <div>
  1019. <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">Embedding Model</label>
  1020. <select
  1021. value={localKbSettings.selectedEmbeddingId || ''}
  1022. onChange={(e) => handleUpdateKbSettings('selectedEmbeddingId', e.target.value)}
  1023. className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium outline-none focus:ring-4 focus:ring-indigo-500/10 transition-all"
  1024. >
  1025. <option value="">Select Embedding...</option>
  1026. {models.filter(m => m.type === ModelType.EMBEDDING).map(m => (
  1027. <option key={m.id} value={m.id}>{m.name}</option>
  1028. ))}
  1029. </select>
  1030. </div>
  1031. <div>
  1032. <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">Rerank Model</label>
  1033. <select
  1034. value={localKbSettings.selectedRerankId || ''}
  1035. onChange={(e) => handleUpdateKbSettings('selectedRerankId', e.target.value)}
  1036. className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium outline-none focus:ring-4 focus:ring-indigo-500/10 transition-all"
  1037. >
  1038. <option value="">None</option>
  1039. {models.filter(m => m.type === ModelType.RERANK).map(m => (
  1040. <option key={m.id} value={m.id}>{m.name}</option>
  1041. ))}
  1042. </select>
  1043. </div>
  1044. </div>
  1045. </div>
  1046. </section>
  1047. {/* Chat Hyperparameters */}
  1048. <section className="bg-white/80 backdrop-blur-md p-8 rounded-3xl border border-slate-200/50 shadow-sm space-y-6">
  1049. <div className="flex items-center gap-3 text-slate-900 font-black uppercase tracking-widest text-[11px] border-b border-slate-100 pb-4">
  1050. <div className="w-8 h-8 rounded-xl bg-pink-50 flex items-center justify-center text-pink-600">
  1051. <Sparkles size={16} />
  1052. </div>
  1053. Chat Hyperparameters
  1054. </div>
  1055. <div className="space-y-8">
  1056. <div>
  1057. <div className="flex justify-between mb-3 px-1">
  1058. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Temperature</label>
  1059. <span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.temperature}</span>
  1060. </div>
  1061. <input
  1062. type="range"
  1063. min="0"
  1064. max="1"
  1065. step="0.1"
  1066. value={localKbSettings.temperature || 0.7}
  1067. onChange={(e) => handleUpdateKbSettings('temperature', parseFloat(e.target.value))}
  1068. className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
  1069. />
  1070. <div className="flex justify-between mt-2 px-1 text-[9px] font-bold text-slate-300 uppercase tracking-tighter">
  1071. <span>Precise</span>
  1072. <span>Creative</span>
  1073. </div>
  1074. </div>
  1075. <div>
  1076. <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">Max Response Tokens</label>
  1077. <input
  1078. type="number"
  1079. value={localKbSettings.maxTokens || 2000}
  1080. onChange={(e) => handleUpdateKbSettings('maxTokens', parseInt(e.target.value))}
  1081. className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium outline-none focus:ring-4 focus:ring-indigo-500/10 transition-all"
  1082. />
  1083. </div>
  1084. </div>
  1085. </section>
  1086. {/* Retrieval & Search Settings */}
  1087. <section className="bg-white/80 backdrop-blur-md p-8 rounded-3xl border border-slate-200/50 shadow-sm space-y-6">
  1088. <div className="flex items-center gap-3 text-slate-900 font-black uppercase tracking-widest text-[11px] border-b border-slate-100 pb-4">
  1089. <div className="w-8 h-8 rounded-xl bg-emerald-50 flex items-center justify-center text-emerald-600">
  1090. <Database size={16} />
  1091. </div>
  1092. Retrieval & Search Settings
  1093. </div>
  1094. <div className="space-y-8">
  1095. <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
  1096. <div>
  1097. <div className="flex justify-between mb-3 px-1">
  1098. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Top-K Results</label>
  1099. <span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.topK}</span>
  1100. </div>
  1101. <input
  1102. type="range"
  1103. min="1"
  1104. max="50"
  1105. step="1"
  1106. value={localKbSettings.topK || 10}
  1107. onChange={(e) => handleUpdateKbSettings('topK', parseInt(e.target.value))}
  1108. className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
  1109. />
  1110. </div>
  1111. <div>
  1112. <div className="flex justify-between mb-3 px-1">
  1113. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Similarity Threshold</label>
  1114. <span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.similarityThreshold}</span>
  1115. </div>
  1116. <input
  1117. type="range"
  1118. min="0"
  1119. max="1"
  1120. step="0.05"
  1121. value={localKbSettings.similarityThreshold || 0.5}
  1122. onChange={(e) => handleUpdateKbSettings('similarityThreshold', parseFloat(e.target.value))}
  1123. className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
  1124. />
  1125. </div>
  1126. </div>
  1127. <div className="space-y-4 pt-4 border-t border-slate-100">
  1128. <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">
  1129. <div>
  1130. <div className="text-sm font-bold text-slate-800">Enable Hybrid Search</div>
  1131. <div className="text-[10px] text-slate-400 font-medium">Combine vector and full-text search results.</div>
  1132. </div>
  1133. <button
  1134. onClick={() => handleUpdateKbSettings('enableFullTextSearch', !localKbSettings.enableFullTextSearch)}
  1135. className={`relative inline-flex h-6 w-11 items-center rounded-full transition-all duration-300 ${localKbSettings.enableFullTextSearch ? 'bg-indigo-600 shadow-md shadow-indigo-100' : 'bg-slate-300'}`}
  1136. >
  1137. <span className={`${localKbSettings.enableFullTextSearch ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`} />
  1138. </button>
  1139. </div>
  1140. {localKbSettings.enableFullTextSearch && (
  1141. <motion.div
  1142. initial={{ opacity: 0, y: -10 }}
  1143. animate={{ opacity: 1, y: 0 }}
  1144. className="p-5 bg-indigo-50/30 rounded-2xl border border-indigo-100/50 space-y-4"
  1145. >
  1146. <div className="flex justify-between mb-2 px-1">
  1147. <label className="text-[10px] font-black text-indigo-400 uppercase tracking-widest">Hybrid Weight (Vector vs Text)</label>
  1148. <span className="text-sm font-black text-indigo-600">{localKbSettings.hybridVectorWeight || 0.5}</span>
  1149. </div>
  1150. <input
  1151. type="range"
  1152. min="0"
  1153. max="1"
  1154. step="0.05"
  1155. value={localKbSettings.hybridVectorWeight || 0.5}
  1156. onChange={(e) => handleUpdateKbSettings('hybridVectorWeight', parseFloat(e.target.value))}
  1157. className="w-full h-2 bg-indigo-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
  1158. />
  1159. <div className="flex justify-between mt-1 px-1 text-[9px] font-bold text-indigo-300 uppercase">
  1160. <span>Pure Text</span>
  1161. <span>Pure Vector</span>
  1162. </div>
  1163. </motion.div>
  1164. )}
  1165. <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
  1166. <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">
  1167. <div>
  1168. <div className="text-sm font-bold text-slate-800">Enable Query Expansion</div>
  1169. <div className="text-[10px] text-slate-400 font-medium">Rewrites query for better recall.</div>
  1170. </div>
  1171. <button
  1172. onClick={() => handleUpdateKbSettings('enableQueryExpansion', !localKbSettings.enableQueryExpansion)}
  1173. className={`relative inline-flex h-6 w-11 items-center rounded-full transition-all duration-300 ${localKbSettings.enableQueryExpansion ? 'bg-indigo-600 shadow-md shadow-indigo-100' : 'bg-slate-300'}`}
  1174. >
  1175. <span className={`${localKbSettings.enableQueryExpansion ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`} />
  1176. </button>
  1177. </div>
  1178. <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">
  1179. <div>
  1180. <div className="text-sm font-bold text-slate-800">Enable HyDE</div>
  1181. <div className="text-[10px] text-slate-400 font-medium">Hypothetical Document Embeddings.</div>
  1182. </div>
  1183. <button
  1184. onClick={() => handleUpdateKbSettings('enableHyDE', !localKbSettings.enableHyDE)}
  1185. className={`relative inline-flex h-6 w-11 items-center rounded-full transition-all duration-300 ${localKbSettings.enableHyDE ? 'bg-indigo-600 shadow-md shadow-indigo-100' : 'bg-slate-300'}`}
  1186. >
  1187. <span className={`${localKbSettings.enableHyDE ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`} />
  1188. </button>
  1189. </div>
  1190. </div>
  1191. <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">
  1192. <div>
  1193. <div className="text-sm font-bold text-slate-800">Enable Reranking</div>
  1194. <div className="text-[10px] text-slate-400 font-medium">Re-score search results for higher accuracy.</div>
  1195. </div>
  1196. <button
  1197. onClick={() => handleUpdateKbSettings('enableRerank', !localKbSettings.enableRerank)}
  1198. className={`relative inline-flex h-6 w-11 items-center rounded-full transition-all duration-300 ${localKbSettings.enableRerank ? 'bg-indigo-600 shadow-md shadow-indigo-100' : 'bg-slate-300'}`}
  1199. >
  1200. <span className={`${localKbSettings.enableRerank ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`} />
  1201. </button>
  1202. </div>
  1203. {localKbSettings.enableRerank && (
  1204. <motion.div
  1205. initial={{ opacity: 0, y: -10 }}
  1206. animate={{ opacity: 1, y: 0 }}
  1207. className="p-5 bg-indigo-50/30 rounded-2xl border border-indigo-100/50 space-y-4"
  1208. >
  1209. <div className="flex justify-between mb-2 px-1">
  1210. <label className="text-[10px] font-black text-indigo-400 uppercase tracking-widest">Rerank Similarity Threshold</label>
  1211. <span className="text-sm font-black text-indigo-600">{localKbSettings.rerankSimilarityThreshold || 0.5}</span>
  1212. </div>
  1213. <input
  1214. type="range"
  1215. min="0"
  1216. max="1"
  1217. step="0.05"
  1218. value={localKbSettings.rerankSimilarityThreshold || 0.5}
  1219. onChange={(e) => handleUpdateKbSettings('rerankSimilarityThreshold', parseFloat(e.target.value))}
  1220. className="w-full h-2 bg-indigo-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
  1221. />
  1222. <div className="flex justify-between mt-1 px-1 text-[9px] font-bold text-indigo-300 uppercase">
  1223. <span>Broad</span>
  1224. <span>Strict</span>
  1225. </div>
  1226. </motion.div>
  1227. )}
  1228. </div>
  1229. </div>
  1230. </section>
  1231. </>
  1232. )}
  1233. </div>
  1234. );
  1235. const renderModelTab = () => (
  1236. <div className="w-full space-y-6">
  1237. <div className="flex justify-between items-center mb-6">
  1238. <div>
  1239. <h3 className="text-sm font-black text-slate-500 uppercase tracking-[0.15em] mb-1">{t('mmTitle')}</h3>
  1240. <p className="text-xs text-slate-400 font-medium">{t('sidebarDesc')}</p>
  1241. </div>
  1242. {!editingId && currentUser?.role === 'SUPER_ADMIN' && (
  1243. <button
  1244. onClick={() => { setEditingId('new'); setModelFormData({ type: ModelType.LLM, baseUrl: 'http://localhost:11434/v1', modelId: 'llama3', name: '', dimensions: 1536 }); }}
  1245. className="flex items-center gap-2 px-6 py-2.5 bg-indigo-600 text-white text-sm font-bold rounded-2xl hover:bg-indigo-700 shadow-lg shadow-indigo-100 transition-all active:scale-95"
  1246. >
  1247. <Plus className="w-4 h-4" />
  1248. {t('mmAddBtn')}
  1249. </button>
  1250. )}
  1251. </div>
  1252. {editingId ? (
  1253. <motion.div
  1254. initial={{ opacity: 0, y: 10 }}
  1255. animate={{ opacity: 1, y: 0 }}
  1256. className="bg-white/90 backdrop-blur-md p-10 rounded-3xl border border-slate-200/50 shadow-xl space-y-8"
  1257. >
  1258. <div className="flex items-center gap-4 mb-2">
  1259. <div className="w-12 h-12 rounded-2xl bg-indigo-50 flex items-center justify-center text-indigo-600">
  1260. <Cpu className="w-6 h-6" />
  1261. </div>
  1262. <h3 className="text-xl font-black text-slate-900 tracking-tight">{editingId === 'new' ? t('mmAddBtn') : t('mmEdit')}</h3>
  1263. </div>
  1264. <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
  1265. <div className="space-y-2">
  1266. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('mmFormName')} *</label>
  1267. <input className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all" value={modelFormData.name || ''} onChange={e => setModelFormData({ ...modelFormData, name: e.target.value })} disabled={isLoading} />
  1268. </div>
  1269. <div className="space-y-2">
  1270. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('mmFormModelId')} *</label>
  1271. <input className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-mono focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all" value={modelFormData.modelId || ''} onChange={e => setModelFormData({ ...modelFormData, modelId: e.target.value })} disabled={isLoading} />
  1272. </div>
  1273. </div>
  1274. <div className="space-y-2">
  1275. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('mmFormType')} *</label>
  1276. <select className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-medium focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all appearance-none" value={modelFormData.type} onChange={e => setModelFormData({ ...modelFormData, type: e.target.value as ModelType })} disabled={isLoading}>
  1277. <option value={ModelType.LLM}>{t('typeLLM')}</option>
  1278. <option value={ModelType.EMBEDDING}>{t('typeEmbedding')}</option>
  1279. <option value={ModelType.RERANK}>{t('typeRerank')}</option>
  1280. <option value={ModelType.VISION}>{t('typeVision')}</option>
  1281. </select>
  1282. </div>
  1283. <div className="space-y-2">
  1284. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('mmFormBaseUrl')} *</label>
  1285. <input className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-mono focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all" value={modelFormData.baseUrl || ''} onChange={e => setModelFormData({ ...modelFormData, baseUrl: e.target.value })} disabled={isLoading} />
  1286. </div>
  1287. <div className="space-y-2">
  1288. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('mmFormApiKey')}</label>
  1289. <input
  1290. type="password"
  1291. className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-mono focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all"
  1292. value={modelFormData.apiKey || ''}
  1293. onChange={e => setModelFormData({ ...modelFormData, apiKey: e.target.value })}
  1294. disabled={isLoading}
  1295. placeholder={t('mmFormApiKeyPlaceholder')}
  1296. />
  1297. </div>
  1298. {modelFormData.type === ModelType.EMBEDDING && (
  1299. <div className="grid grid-cols-2 gap-6 p-6 bg-slate-50 rounded-3xl border border-slate-200/50">
  1300. <div className="space-y-2">
  1301. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">Max Input</label>
  1302. <input type="number" className="w-full px-4 py-3 bg-white border border-slate-200 rounded-xl text-sm font-bold" value={modelFormData.maxInputTokens || 8191} onChange={e => setModelFormData({ ...modelFormData, maxInputTokens: parseInt(e.target.value) })} />
  1303. </div>
  1304. <div className="space-y-2">
  1305. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">Dimensions</label>
  1306. <input type="number" className="w-full px-4 py-3 bg-white border border-slate-200 rounded-xl text-sm font-bold" value={modelFormData.dimensions || 1536} onChange={e => setModelFormData({ ...modelFormData, dimensions: parseInt(e.target.value) })} />
  1307. </div>
  1308. </div>
  1309. )}
  1310. <div className="flex justify-end gap-3 pt-4">
  1311. <button onClick={() => { setEditingId(null); setError(null); }} className="px-6 py-3 text-sm font-bold text-slate-500 hover:text-slate-700">{t('mmCancel')}</button>
  1312. <button onClick={handleSaveModel} className="px-10 py-3 bg-indigo-600 text-white rounded-2xl font-black uppercase tracking-widest text-xs shadow-xl shadow-indigo-100 transition-all active:scale-95 flex items-center gap-2" disabled={isLoading}>
  1313. {isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
  1314. {t('mmSave')}
  1315. </button>
  1316. </div>
  1317. </motion.div>
  1318. ) : (
  1319. <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">
  1320. <AnimatePresence>
  1321. {models.map((model, index) => (
  1322. <motion.div
  1323. key={model.id}
  1324. initial={{ opacity: 0, scale: 0.95 }}
  1325. animate={{ opacity: 1, scale: 1 }}
  1326. transition={{ delay: index * 0.05 }}
  1327. 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"
  1328. >
  1329. {/* Subtle background pattern/glow */}
  1330. <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" />
  1331. <div className="relative z-10">
  1332. <div className="flex items-start justify-between mb-5">
  1333. <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'}`}>
  1334. <Cpu size={26} strokeWidth={2.5} />
  1335. </div>
  1336. <div className="flex gap-1 items-center bg-slate-50 p-1 rounded-xl border border-slate-100/50">
  1337. <button
  1338. onClick={() => handleToggleModel(model)}
  1339. 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'}`}
  1340. title={((currentUser?.role === 'SUPER_ADMIN' && model.isEnabled !== false) || (currentUser?.role === 'TENANT_ADMIN' && enabledModelIds.includes(model.id))) ? t('modelEnabled') : t('modelDisabled')}
  1341. >
  1342. {((currentUser?.role === 'SUPER_ADMIN' && model.isEnabled !== false) || (currentUser?.role === 'TENANT_ADMIN' && enabledModelIds.includes(model.id))) ? <ToggleRight size={24} /> : <ToggleLeft size={24} />}
  1343. </button>
  1344. </div>
  1345. </div>
  1346. <div className="space-y-1 mb-6">
  1347. <div className="flex items-center gap-2.5">
  1348. <h4 className="font-black text-slate-900 text-lg tracking-tight truncate">{model.name}</h4>
  1349. </div>
  1350. <div className="flex items-center gap-2">
  1351. <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">
  1352. {getTypeLabel(model.type)}
  1353. </span>
  1354. {model.isDefault && (
  1355. <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">
  1356. <Sparkles size={8} /> Default
  1357. </span>
  1358. )}
  1359. </div>
  1360. <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">
  1361. {model.modelId}
  1362. </p>
  1363. </div>
  1364. {/* Additional info grid */}
  1365. <div className="grid grid-cols-2 gap-3 mb-6">
  1366. {model.type === ModelType.EMBEDDING && (
  1367. <>
  1368. <div className="bg-slate-50/50 p-2.5 rounded-2xl border border-slate-100/50">
  1369. <span className="block text-[8px] font-black text-slate-400 uppercase tracking-widest mb-0.5">Dims</span>
  1370. <span className="text-xs font-bold text-slate-700">{model.dimensions || '-'}</span>
  1371. </div>
  1372. <div className="bg-slate-50/50 p-2.5 rounded-2xl border border-slate-100/50">
  1373. <span className="block text-[8px] font-black text-slate-400 uppercase tracking-widest mb-0.5">Ctx</span>
  1374. <span className="text-xs font-bold text-slate-700">{model.maxInputTokens || '-'}</span>
  1375. </div>
  1376. </>
  1377. )}
  1378. {model.type === ModelType.LLM && (
  1379. <div className="col-span-2 bg-slate-50/50 p-2.5 rounded-2xl border border-slate-100/50 flex items-center justify-between">
  1380. <span className="text-[8px] font-black text-slate-400 uppercase tracking-widest">Base API</span>
  1381. <span className="text-[9px] font-mono font-medium text-slate-600 max-w-[140px] truncate">{model.baseUrl}</span>
  1382. </div>
  1383. )}
  1384. </div>
  1385. </div>
  1386. <div className="flex items-center justify-between pt-4 border-t border-slate-100/50 relative z-10">
  1387. <div className="flex items-center gap-1 text-[10px] font-bold text-slate-400">
  1388. <SettingsIcon size={12} />
  1389. Configured
  1390. </div>
  1391. <div className="flex gap-2">
  1392. {currentUser?.role === 'SUPER_ADMIN' && (
  1393. <>
  1394. <button
  1395. onClick={() => { setEditingId(model.id); setModelFormData({ ...model }); }}
  1396. 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"
  1397. >
  1398. <Edit2 size={15} />
  1399. </button>
  1400. <button
  1401. onClick={() => handleDeleteModel(model.id)}
  1402. 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"
  1403. >
  1404. <Trash2 size={15} />
  1405. </button>
  1406. </>
  1407. )}
  1408. </div>
  1409. </div>
  1410. </motion.div>
  1411. ))}
  1412. </AnimatePresence>
  1413. {models.length === 0 && (
  1414. <div className="bg-white/50 border-2 border-dashed border-slate-200 rounded-3xl p-16 text-center">
  1415. <Cpu className="w-12 h-12 text-slate-200 mx-auto mb-4" />
  1416. <p className="text-slate-400 font-bold uppercase tracking-widest text-xs">{t('mmEmpty')}</p>
  1417. </div>
  1418. )}
  1419. </div>
  1420. )}
  1421. </div>
  1422. );
  1423. return (
  1424. <div className="flex h-full bg-transparent overflow-hidden relative">
  1425. {/* Content Area */}
  1426. <div className="flex-1 flex flex-col min-w-0 h-full overflow-hidden relative z-10">
  1427. <div className="px-8 pt-8 pb-6 flex items-start justify-between shrink-0">
  1428. <div>
  1429. <h1 className="text-2xl font-bold text-slate-900 leading-tight">
  1430. {activeTab === 'general' ? t('generalSettings') : activeTab === 'user' ? t('userManagement') : activeTab === 'model' ? t('modelManagement') : activeTab === 'knowledge_base' ? 'Index Chat Config' : t('navTenants')}
  1431. </h1>
  1432. <p className="text-[15px] text-slate-500 mt-1">
  1433. {activeTab === 'general' ? 'Manage your application preferences.' : activeTab === 'user' ? 'Manage access and accounts.' : activeTab === 'model' ? 'Configure global AI models.' : activeTab === 'knowledge_base' ? 'Technical configuration for indexing and chat parameters.' : 'Global system overview.'}
  1434. </p>
  1435. </div>
  1436. </div>
  1437. <div className="flex-1 overflow-y-auto px-10 pb-10 scrollbar-hide">
  1438. <div className="w-full">
  1439. {error && (
  1440. <motion.div
  1441. initial={{ opacity: 0, y: -10 }}
  1442. animate={{ opacity: 1, y: 0 }}
  1443. className="mb-8 p-4 bg-red-50/80 backdrop-blur-md border border-red-200/50 text-red-700 rounded-2xl flex gap-3 items-center text-sm shadow-sm"
  1444. >
  1445. <div className="w-8 h-8 rounded-full bg-red-100 flex items-center justify-center shrink-0">
  1446. <X className="w-4 h-4 text-red-600" />
  1447. </div>
  1448. <div>
  1449. <span className="font-black uppercase tracking-widest text-[10px] block mb-0.5">{t('errorLabel')}</span>
  1450. {error}
  1451. </div>
  1452. </motion.div>
  1453. )}
  1454. <AnimatePresence mode="wait">
  1455. <motion.div
  1456. key={activeTab}
  1457. initial={{ opacity: 0, x: 20 }}
  1458. animate={{ opacity: 1, x: 0 }}
  1459. exit={{ opacity: 0, x: -20 }}
  1460. transition={{ duration: 0.3 }}
  1461. >
  1462. {activeTab === 'general' && renderGeneralTab()}
  1463. {activeTab === 'user' && renderUserTab()}
  1464. {activeTab === 'model' && (isAdmin || currentUser?.role === 'SUPER_ADMIN') && renderModelTab()}
  1465. {activeTab === 'knowledge_base' && (isAdmin || currentUser?.role === 'SUPER_ADMIN') && renderKnowledgeBaseTab()}
  1466. {activeTab === 'tenants' && currentUser?.role === 'SUPER_ADMIN' && renderTenantsTab()}
  1467. </motion.div>
  1468. </AnimatePresence>
  1469. </div>
  1470. </div>
  1471. </div>
  1472. </div>
  1473. );
  1474. };